diff --git a/.claude/scripts/log-worker-tool.sh b/.claude/scripts/log-worker-tool.sh new file mode 100755 index 0000000..9a69c26 --- /dev/null +++ b/.claude/scripts/log-worker-tool.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# log-worker-tool.sh — PostToolUse mirror hook (issue #71, Cockpit 3c SPIKE). +# +# NOT ENABLED BY DEFAULT — see the "Wiring (disabled by default)" section +# below for the copy-paste snippet that turns it on. This is a spike to +# gather evidence on signal/noise and log growth before committing to always- +# on tool mirroring. +# +# Reads the PostToolUse hook event JSON on STDIN and mirrors ONLY the tools +# Bash, Edit, Write (every other tool — Read, Grep, Glob, ... — is silently +# ignored, no record written). This is OBSERVABILITY ONLY: it never gates +# anything, never affects control flow, and must never break the caller — +# always exits 0, even on malformed/empty stdin, missing node, missing +# fields, or an unwritable log directory. +# +# Appends exactly ONE JSON object per line (JSONL) to the log, schema: +# {"ts":"","tool":"Bash|Edit|Write","summary":"", +# "path":""} +# - summary: for Bash, the command (collapsed to one line, truncated to a +# sane cap); for Edit/Write, the target file path. +# - path: for Bash, the hook event's `cwd` (the worktree-path attribution +# key — workers are worktree-isolated); for Edit/Write, `tool_input.file_path`. +# The JSON line is built with `node` (never hand-rolled string +# interpolation), mirroring log-event.sh EXACTLY, so quotes/backslashes/ +# newlines/HTML are always safely escaped and the summary is collapsed to a +# single line. +# +# Log file: defaults to /.claude/state/worker-tools.jsonl. +# Override with CLAUDE_WORKER_TOOLS_FILE= (used by tests to +# point at a temp file instead of the real, gitignored state dir). The parent +# directory is created if missing. +# +# Rotation/retention: after appending, the file is capped to the last +# ${WORKER_TOOLS_MAX_LINES:-2000} lines (oldest dropped first), via a temp +# file + atomic `mv`, mirroring log-event.sh's rotation node block exactly — +# so unattended multi-day runs never grow the log unbounded and a crash +# mid-rotation never leaves a truncated/corrupt log in place. +# +# Zero-token / harness-side rationale: this hook is invoked by the Claude +# Code harness itself (PostToolUse), OUTSIDE the agent's sandbox and context +# window — it reads the tool-call event off stdin and writes a log line; the +# agent never sees this happen and it costs zero agent tokens. +# +# --------------------------------------------------------------------------- +# Wiring (DISABLED by default — copy into .claude/settings.json to enable): +# +# "PostToolUse": [ +# { +# "matcher": "Bash|Edit|Write", +# "hooks": [ +# { +# "type": "command", +# "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/scripts/log-worker-tool.sh\"" +# } +# ] +# } +# ] +# +# A "go" decision is this one array entry — nothing else to wire up. +# --------------------------------------------------------------------------- +set -u + +# Two-root derivation (issue #63) — resolve-roots.sh never fails, matching +# this script's never-block contract. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/resolve-roots.sh" 2>/dev/null || exit 0 + +worker_tools_file="${CLAUDE_WORKER_TOOLS_FILE:-$root/.claude/state/worker-tools.jsonl}" +max_lines="${WORKER_TOOLS_MAX_LINES:-2000}" + +# Read the hook event JSON off stdin once; never let a hang or huge payload +# block the caller. +event_json="$(cat 2>/dev/null)" || exit 0 + +mkdir -p "$(dirname "$worker_tools_file")" 2>/dev/null || exit 0 + +ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)" || ts="" + +CLAUDE_WT_TS="$ts" \ +CLAUDE_WT_EVENT="$event_json" \ +node -e ' + let event; + try { + event = JSON.parse(process.env.CLAUDE_WT_EVENT || ""); + } catch (e) { + process.exit(0); // malformed/empty stdin — silent no-op + } + if (!event || typeof event !== "object") process.exit(0); + + const toolName = event.tool_name; + if (toolName !== "Bash" && toolName !== "Edit" && toolName !== "Write") { + process.exit(0); // not a mirrored tool — silent no-op + } + + const input = event.tool_input || {}; + const collapse = (s) => + String(s == null ? "" : s) + .replace(/\r?\n/g, " ") + .replace(/\s+/g, " ") + .trim(); + const truncate = (s, cap) => (s.length > cap ? s.slice(0, cap) : s); + + let summary = ""; + let path = ""; + if (toolName === "Bash") { + summary = truncate(collapse(input.command), 200); + path = event.cwd || ""; + } else { + // Edit or Write + path = input.file_path || ""; + summary = collapse(path); + } + + const line = JSON.stringify({ + ts: process.env.CLAUDE_WT_TS || "", + tool: toolName, + summary, + path, + }); + process.stdout.write(line + "\n"); +' >>"$worker_tools_file" 2>/dev/null || exit 0 + +# ---- rotation: cap to the last $max_lines lines, atomically ----------------- +node -e ' + const fs = require("fs"); + const file = process.argv[1]; + const max = parseInt(process.argv[2], 10); + const tmp = process.argv[3]; + try { + if (!Number.isFinite(max) || max <= 0) process.exit(0); + const text = fs.readFileSync(file, "utf8"); + const lines = text.split("\n"); + // drop a single trailing empty string from the final newline, if present + if (lines.length && lines[lines.length - 1] === "") lines.pop(); + if (lines.length <= max) process.exit(0); + const kept = lines.slice(lines.length - max); + fs.writeFileSync(tmp, kept.join("\n") + "\n"); + fs.renameSync(tmp, file); + } catch (e) { + process.exit(0); + } +' "$worker_tools_file" "$max_lines" "$worker_tools_file.tmp.$$" 2>/dev/null + +exit 0 diff --git a/.claude/scripts/log-worker-tool.test.sh b/.claude/scripts/log-worker-tool.test.sh new file mode 100755 index 0000000..ebe149a --- /dev/null +++ b/.claude/scripts/log-worker-tool.test.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# log-worker-tool.test.sh — offline smoke test for log-worker-tool.sh +# (issue #71, Cockpit 3c SPIKE). +# +# Asserts: Bash/Edit/Write tool calls yield exactly one valid-JSON record +# each with the expected tool/summary/path fields; non-mirrored tools (Read, +# Grep) and malformed/empty stdin produce NO record and still exit 0; +# multi-line Bash commands collapse to a single-line summary; quotes/ +# backslashes in a command still round-trip as valid JSON; and rotation caps +# the log to the last WORKER_TOOLS_MAX_LINES lines. +# +# Exit 0 on success, non-zero if any assertion fails. Runnable bare: +# bash .claude/scripts/log-worker-tool.test.sh +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +log_worker_tool="$script_dir/log-worker-tool.sh" + +work="$(mktemp -d "${TMPDIR:-/tmp}/log-worker-tool-test.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +fail=0 +ok=0 +check() { + local desc="$1"; shift + if "$@"; then + ok=$((ok + 1)) + echo "ok - $desc" + else + fail=1 + echo "FAIL - $desc" + fi +} + +emit() { + # emit + printf '%s' "$2" | CLAUDE_WORKER_TOOLS_FILE="$1" bash "$log_worker_tool" >/dev/null 2>&1 +} + +# --------------------------------------------------------------------------- +# 1. Bash tool call -> one record, tool=Bash, summary=command, path=cwd. +# --------------------------------------------------------------------------- +bash_file="$work/bash.jsonl" +emit "$bash_file" '{"tool_name":"Bash","tool_input":{"command":"echo hello world"},"cwd":"/wt/task-1"}' + +check "bash call yields exactly 1 line" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 1 ]' _ "$bash_file" +check "bash record has expected fields" node -e ' + const fs = require("fs"); + const obj = JSON.parse(fs.readFileSync(process.argv[1], "utf8").trim()); + if (obj.tool !== "Bash") throw new Error("tool mismatch: " + obj.tool); + if (obj.summary !== "echo hello world") throw new Error("summary mismatch: " + obj.summary); + if (obj.path !== "/wt/task-1") throw new Error("path mismatch: " + obj.path); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(obj.ts)) throw new Error("ts not ISO-8601 UTC: " + obj.ts); +' "$bash_file" + +# --------------------------------------------------------------------------- +# 2. Edit and Write -> record with correct tool + path=file_path. +# --------------------------------------------------------------------------- +edit_file="$work/edit.jsonl" +emit "$edit_file" '{"tool_name":"Edit","tool_input":{"file_path":"/wt/task-1/src/foo.js","old_string":"a","new_string":"b"},"cwd":"/wt/task-1"}' +check "edit record has correct tool + path" node -e ' + const fs = require("fs"); + const obj = JSON.parse(fs.readFileSync(process.argv[1], "utf8").trim()); + if (obj.tool !== "Edit") throw new Error("tool mismatch: " + obj.tool); + if (obj.path !== "/wt/task-1/src/foo.js") throw new Error("path mismatch: " + obj.path); +' "$edit_file" + +write_file="$work/write.jsonl" +emit "$write_file" '{"tool_name":"Write","tool_input":{"file_path":"/wt/task-1/src/bar.js","content":"..."},"cwd":"/wt/task-1"}' +check "write record has correct tool + path" node -e ' + const fs = require("fs"); + const obj = JSON.parse(fs.readFileSync(process.argv[1], "utf8").trim()); + if (obj.tool !== "Write") throw new Error("tool mismatch: " + obj.tool); + if (obj.path !== "/wt/task-1/src/bar.js") throw new Error("path mismatch: " + obj.path); +' "$write_file" + +# --------------------------------------------------------------------------- +# 3. A non-target tool (Read, Grep) -> NO record appended, exit 0. +# --------------------------------------------------------------------------- +nontarget_file="$work/nontarget.jsonl" +printf '%s' '{"tool_name":"Read","tool_input":{"file_path":"/wt/task-1/src/foo.js"},"cwd":"/wt/task-1"}' | \ + CLAUDE_WORKER_TOOLS_FILE="$nontarget_file" bash "$log_worker_tool" >/dev/null 2>&1 +check "Read call exits 0" [ "$?" -eq 0 ] +printf '%s' '{"tool_name":"Grep","tool_input":{"pattern":"foo"},"cwd":"/wt/task-1"}' | \ + CLAUDE_WORKER_TOOLS_FILE="$nontarget_file" bash "$log_worker_tool" >/dev/null 2>&1 +check "Grep call exits 0" [ "$?" -eq 0 ] +check "no record written for non-target tools" bash -c '[ ! -s "$1" ]' _ "$nontarget_file" + +# --------------------------------------------------------------------------- +# 4. Empty stdin / malformed JSON -> exit 0 AND no spurious line written. +# --------------------------------------------------------------------------- +malformed_file="$work/malformed.jsonl" +printf '' | CLAUDE_WORKER_TOOLS_FILE="$malformed_file" bash "$log_worker_tool" >/dev/null 2>&1 +check "empty stdin exits 0" [ "$?" -eq 0 ] +printf 'not json at all {{{' | CLAUDE_WORKER_TOOLS_FILE="$malformed_file" bash "$log_worker_tool" >/dev/null 2>&1 +check "malformed JSON exits 0" [ "$?" -eq 0 ] +check "no spurious line written for empty/malformed stdin" bash -c '[ ! -s "$1" ]' _ "$malformed_file" + +# --------------------------------------------------------------------------- +# 5. Multi-line Bash command -> summary collapsed to a single line. +# --------------------------------------------------------------------------- +multiline_file="$work/multiline.jsonl" +multiline_event="$(node -e ' + process.stdout.write(JSON.stringify({ + tool_name: "Bash", + tool_input: { command: "echo one\necho two\necho three" }, + cwd: "/wt/task-1", + })); +')" +printf '%s' "$multiline_event" | CLAUDE_WORKER_TOOLS_FILE="$multiline_file" bash "$log_worker_tool" >/dev/null 2>&1 +check "multi-line command yields exactly 1 line in the log" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 1 ]' _ "$multiline_file" +check "multi-line command collapsed to single-line summary (no raw newline)" node -e ' + const fs = require("fs"); + const raw = fs.readFileSync(process.argv[1], "utf8"); + if (raw.split("\n").filter(Boolean).length !== 1) throw new Error("expected exactly 1 non-empty line, got: " + JSON.stringify(raw)); + const obj = JSON.parse(raw.trim()); + if (obj.summary.includes("\n")) throw new Error("summary contains raw newline: " + JSON.stringify(obj.summary)); + if (obj.summary !== "echo one echo two echo three") throw new Error("summary mismatch: " + obj.summary); +' "$multiline_file" + +# --------------------------------------------------------------------------- +# 6. JSON escaping: quotes/backslashes in a command still produce valid JSON. +# --------------------------------------------------------------------------- +escapes_file="$work/escapes.jsonl" +escapes_event="$(node -e ' + process.stdout.write(JSON.stringify({ + tool_name: "Bash", + tool_input: { command: String.raw`echo "hi" \ then tags` }, + cwd: "/wt/task-1", + })); +')" +printf '%s' "$escapes_event" | CLAUDE_WORKER_TOOLS_FILE="$escapes_file" bash "$log_worker_tool" >/dev/null 2>&1 +check "command with quotes/backslashes yields valid parseable JSON" node -e ' + const fs = require("fs"); + const line = fs.readFileSync(process.argv[1], "utf8").trim(); + const obj = JSON.parse(line); // throws (fails the check) if not valid JSON + if (!obj.summary.includes(String.raw`"hi"`)) throw new Error("summary missing expected content: " + obj.summary); +' "$escapes_file" + +# --------------------------------------------------------------------------- +# 7. Rotation: WORKER_TOOLS_MAX_LINES small caps the file to the last N lines. +# --------------------------------------------------------------------------- +rotate_file="$work/rotate.jsonl" +for i in $(seq 1 12); do + event="$(node -e "process.stdout.write(JSON.stringify({tool_name:'Bash',tool_input:{command:'echo t-$i'},cwd:'/wt/task-1'}))")" + WORKER_TOOLS_MAX_LINES=5 CLAUDE_WORKER_TOOLS_FILE="$rotate_file" bash "$log_worker_tool" <<<"$event" >/dev/null 2>&1 +done + +check "rotation caps the file to exactly 5 lines" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 5 ]' _ "$rotate_file" +check "rotation keeps the LAST 5 records (most recent), in order" node -e ' + const fs = require("fs"); + const lines = fs.readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean); + const summaries = lines.map((l) => JSON.parse(l).summary); + const want = ["echo t-8", "echo t-9", "echo t-10", "echo t-11", "echo t-12"]; + if (JSON.stringify(summaries) !== JSON.stringify(want)) { + throw new Error("got " + JSON.stringify(summaries) + " want " + JSON.stringify(want)); + } +' "$rotate_file" + +# --------------------------------------------------------------------------- +# 8. Best-effort: never blocks the caller (always exits 0). +# --------------------------------------------------------------------------- +best_effort_file="$work/best-effort.jsonl" +printf '{"tool_name":"Bash"' | CLAUDE_WORKER_TOOLS_FILE="$best_effort_file" bash "$log_worker_tool" >/dev/null 2>&1 +check "truncated JSON still exits 0" [ "$?" -eq 0 ] + +CLAUDE_WORKER_TOOLS_FILE="/nonexistent/unwritable/dir/log.jsonl" bash "$log_worker_tool" <<<'{"tool_name":"Bash","tool_input":{"command":"x"},"cwd":"/"}' >/dev/null 2>&1 +check "unwritable log directory still exits 0" [ "$?" -eq 0 ] + +echo "" +if [ "$fail" -eq 0 ]; then + echo "log-worker-tool.test.sh: PASS ($ok checks)" + exit 0 +else + echo "log-worker-tool.test.sh: FAIL (see FAIL lines above)" + exit 1 +fi