diff --git a/.claude/scripts/cockpit-serve.sh b/.claude/scripts/cockpit-serve.sh index 014155e..6b7759b 100755 --- a/.claude/scripts/cockpit-serve.sh +++ b/.claude/scripts/cockpit-serve.sh @@ -36,7 +36,8 @@ # GET /api/worker// # Worker inspector (issue #70), backing the drawer that # opens when a live-progress row is clicked. Returns -# JSON: { role, task, timeline, breadcrumbs, worktree }. +# JSON: { role, task, timeline, breadcrumbs, worktree, +# activity }. # - timeline: every events.jsonl record matching # (role,task), NEWEST FIRST. # - breadcrumbs: the most recent non-empty --detail @@ -51,6 +52,14 @@ # status, commits, diffstat, mergeBase, error }; if # no worktree matches, found:false with a plain # "no worktree found" error string (never a crash). +# - activity: (issue #84) up to the most recent 20 +# records from the worker-tools mirror log +# (log-worker-tool.sh's JSONL, default +# /.claude/state/worker-tools.jsonl, override +# via CLAUDE_WORKER_TOOLS_FILE) whose `path` falls +# under the worker's worktree path, NEWEST FIRST. +# Empty array if the log is missing/unreadable or no +# worktree was found — never a crash. # # Foreground process — SIGTERM/SIGINT close the server cleanly (via `exec`, # below, node receives signals directly; no bash wrapper indirection). @@ -93,6 +102,12 @@ gh_refresh="${COCKPIT_GH_REFRESH:-60}" # the real one — no network/gh either way, just local git plumbing. worktrees_root="${COCKPIT_SERVE_WORKTREES_ROOT:-$root}" +# Worker-tools mirror log (issue #84): log-worker-tool.sh's PostToolUse +# mirror JSONL, read by the worker inspector to populate the "activity" +# field. Same override seam as the hook script itself so tests can point +# this at a synthetic temp file instead of the real, gitignored state dir. +worker_tools_file="${CLAUDE_WORKER_TOOLS_FILE:-$root/.claude/state/worker-tools.jsonl}" + tmp_out="$(mktemp "${TMPDIR:-/tmp}/cockpit-serve.XXXXXX.html")" # NOTE: deliberately NO bash `trap ... EXIT` here. `exec` below REPLACES this # shell process image with node (same PID) — a bash-level EXIT trap @@ -112,6 +127,7 @@ COCKPIT_SERVE_EVENTS_FILE="$events_file" \ COCKPIT_SERVE_GH_REFRESH="$gh_refresh" \ COCKPIT_SERVE_TMP_OUT="$tmp_out" \ COCKPIT_SERVE_WORKTREES_ROOT="$worktrees_root" \ +CLAUDE_WORKER_TOOLS_FILE="$worker_tools_file" \ exec node - <<'NODE_SERVE' const http = require("http"); const fs = require("fs"); @@ -126,6 +142,11 @@ const GH_REFRESH_SECONDS = parseInt(process.env.COCKPIT_SERVE_GH_REFRESH, 10) || const GH_REFRESH_MS = GH_REFRESH_SECONDS * 1000; const TMP_OUT = process.env.COCKPIT_SERVE_TMP_OUT; const WORKTREES_ROOT = process.env.COCKPIT_SERVE_WORKTREES_ROOT || process.cwd(); +// Worker-tools mirror log (issue #84): env override for testability, else +// the default log-worker-tool.sh itself writes to. +const WORKER_TOOLS_FILE = + process.env.CLAUDE_WORKER_TOOLS_FILE || + path.join(process.cwd(), ".claude", "state", "worker-tools.jsonl"); // Fix (issue #70, 3a-followup): TMP_OUT cleanup moved here from the now-dead // bash EXIT trap (see the shell comment above `exec node`, above) — this @@ -318,6 +339,11 @@ function clientScript() { var timelineList = document.createElement("ul"); timelineList.id = "drawer-timeline"; + var activityHeading = document.createElement("h3"); + activityHeading.textContent = "Live activity (newest first)"; + var activityList = document.createElement("ul"); + activityList.id = "drawer-activity"; + drawer.appendChild(closeBtn); drawer.appendChild(title); drawer.appendChild(breadcrumbsHeading); @@ -326,6 +352,8 @@ function clientScript() { drawer.appendChild(forensicsBody); drawer.appendChild(timelineHeading); drawer.appendChild(timelineList); + drawer.appendChild(activityHeading); + drawer.appendChild(activityList); document.body.appendChild(drawer); return drawer; } @@ -386,6 +414,21 @@ function clientScript() { }); } + var activityList = d.querySelector("#drawer-activity"); + activityList.textContent = ""; + var activity = (data && data.activity) || []; + if (activity.length === 0) { + var noActivity = document.createElement("li"); + noActivity.textContent = "(no activity yet)"; + activityList.appendChild(noActivity); + } else { + activity.forEach(function (rec) { + var li = document.createElement("li"); + li.textContent = (rec.ts || "") + " " + (rec.tool || "") + " " + (rec.summary || ""); + activityList.appendChild(li); + }); + } + d.style.display = "block"; } @@ -529,6 +572,25 @@ function readEventsAll() { return out; } +// Worker-tools mirror log (issue #84) reader: same JSONL-tolerant pattern as +// readEventsAll() -- skip blank/malformed lines, exclude arrays. A +// missing/unreadable file yields [] (never throws), matching the hook +// script's own never-block contract. +function readWorkerToolsAll() { + let text = ""; + try { text = fs.readFileSync(WORKER_TOOLS_FILE, "utf8"); } catch (e) { return []; } + const out = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const obj = JSON.parse(trimmed); + if (obj && typeof obj === "object" && !Array.isArray(obj)) out.push(obj); + } catch (e) { /* skip malformed line */ } + } + return out; +} + function escapeRegExp(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -671,7 +733,20 @@ function handleWorkerInspector(req, res, rawRole, rawTask) { error: "no worktree found for task " + task, }; - const body = JSON.stringify({ role, task, timeline, breadcrumbs, worktree }); + // Live activity (issue #84): worker-tools mirror records whose `path` + // falls under the matched worktree, NEWEST FIRST, capped to the most + // recent 20. No worktree found -> []; never throws. + let activity = []; + if (wtPath) { + const allTools = readWorkerToolsAll(); + const matchingTools = allTools.filter((rec) => { + const recPath = rec && rec.path != null ? String(rec.path) : ""; + return recPath === wtPath || recPath.startsWith(wtPath + "/"); + }); + activity = matchingTools.slice().reverse().slice(0, 20); + } + + const body = JSON.stringify({ role, task, timeline, breadcrumbs, worktree, activity }); res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); res.end(body); } catch (e) { diff --git a/.claude/scripts/cockpit.test.sh b/.claude/scripts/cockpit.test.sh index 7df7705..70ed6da 100755 --- a/.claude/scripts/cockpit.test.sh +++ b/.claude/scripts/cockpit.test.sh @@ -378,7 +378,14 @@ insp_port="$(node -e ' ')" insp_serve_log="$work/serve-inspector.log" : > "$insp_serve_log" -COCKPIT_SERVE_WORKTREES_ROOT="$insp_root" bash "$cockpit_serve" "$insp_port" --fixtures "$work/fixtures-inspector" >"$insp_serve_log" 2>&1 & +# CLAUDE_WORKER_TOOLS_FILE (issue #84) deliberately points at a file that +# does not exist, so this primary server instance also covers the +# "no worker-tools log present" case below -- activity must come back as an +# empty array, never missing/an error, and never accidentally pick up this +# checkout's own real (gitignored) worker-tools.jsonl. +COCKPIT_SERVE_WORKTREES_ROOT="$insp_root" \ +CLAUDE_WORKER_TOOLS_FILE="$work/no-such-worker-tools.jsonl" \ + bash "$cockpit_serve" "$insp_port" --fixtures "$work/fixtures-inspector" >"$insp_serve_log" 2>&1 & server_pid=$! insp_ready=0 @@ -405,6 +412,18 @@ check "worker-inspector 70a: timeline has both events, NEWEST FIRST, plus breadc if (typeof wt.diffstat !== "string") throw new Error("diffstat field missing/wrong type"); ' "$resp_70a" +# Activity (issue #84), no-log-file case: CLAUDE_WORKER_TOOLS_FILE for this +# server points at a nonexistent file -- activity must be an empty array +# (not missing, not an error), and the rest of the response (timeline, +# worktree) must still be intact. +check "worker-inspector 70a: no worker-tools log present -> activity is [] (not missing/error), timeline/worktree intact" node -e ' + const got = JSON.parse(process.argv[1]); + if (!Array.isArray(got.activity)) throw new Error("expected activity to be an array, got " + JSON.stringify(got.activity)); + if (got.activity.length !== 0) throw new Error("expected empty activity with no log file, got " + JSON.stringify(got.activity)); + if (!Array.isArray(got.timeline) || got.timeline.length !== 2) throw new Error("timeline should still be intact"); + if (!got.worktree || got.worktree.found !== true) throw new Error("worktree should still be intact"); +' "$resp_70a" + resp_70b="$(curl -s "http://127.0.0.1:$insp_port/api/worker/reviewer/70b" 2>/dev/null)" check "worker-inspector 70b: worktree found via BRANCH-NAME fallback (not conventional dir name)" node -e ' const got = JSON.parse(process.argv[1]); @@ -449,6 +468,64 @@ check "worker-inspector: server still serves a valid request after malformed/tra kill "$server_pid" >/dev/null 2>&1 || true wait "$server_pid" 2>/dev/null || true server_pid="" + +# --------------------------------------------------------------------------- +# 6b. Worker inspector "activity" field (issue #84): a populated worker-tools +# mirror log (log-worker-tool.sh's JSONL), pointed at via +# CLAUDE_WORKER_TOOLS_FILE. Records under 70a's worktree path must come +# back NEWEST FIRST; a record under a DIFFERENT path (70b's worktree) +# must be excluded. +# --------------------------------------------------------------------------- +insp_wtools_file="$work/inspector-worker-tools.jsonl" +insp_wt_70a="$insp_root/.claude/worktrees/issue-70a" +cat >"$insp_wtools_file" < { console.log(s.address().port); s.close(); }); +')" +insp_serve_log2="$work/serve-inspector-activity.log" +: > "$insp_serve_log2" +COCKPIT_SERVE_WORKTREES_ROOT="$insp_root" \ +CLAUDE_WORKER_TOOLS_FILE="$insp_wtools_file" \ + bash "$cockpit_serve" "$insp_port2" --fixtures "$work/fixtures-inspector" >"$insp_serve_log2" 2>&1 & +server_pid2=$! + +insp_ready2=0 +for _ in $(seq 1 50); do + grep -q "cockpit serving" "$insp_serve_log2" 2>/dev/null && { insp_ready2=1; break; } + kill -0 "$server_pid2" 2>/dev/null || break + sleep 0.2 +done +check "worker-inspector (activity) server starts within the readiness timeout" [ "$insp_ready2" -eq 1 ] + +resp_70a_activity="$(curl -s "http://127.0.0.1:$insp_port2/api/worker/implementer/70a" 2>/dev/null)" +check "worker-inspector 70a: activity present, is an array, NEWEST FIRST, excludes records under a different worktree path" node -e ' + const got = JSON.parse(process.argv[1]); + if (!Array.isArray(got.activity)) throw new Error("expected activity to be an array, got " + JSON.stringify(got.activity)); + if (got.activity.length !== 3) throw new Error("expected 3 activity records (70a-scoped only), got " + JSON.stringify(got.activity)); + const summaries = got.activity.map((r) => r.summary); + const wantOrder = [got.activity[0], got.activity[1], got.activity[2]].map((r) => r.ts); + if (wantOrder[0] !== "2026-01-01T02:03:00Z") throw new Error("expected newest (bar.js write) first, got " + JSON.stringify(got.activity)); + if (wantOrder[1] !== "2026-01-01T02:01:00Z") throw new Error("expected foo.js edit second, got " + JSON.stringify(got.activity)); + if (wantOrder[2] !== "2026-01-01T02:00:00Z") throw new Error("expected git status last (oldest), got " + JSON.stringify(got.activity)); + if (summaries.some((s) => String(s).indexOf("bar.js") === -1 && String(s).indexOf("foo.js") === -1 && s !== "git status")) { + throw new Error("unexpected summary content: " + JSON.stringify(summaries)); + } + if (got.activity.some((r) => r.path && r.path.indexOf("issue-70a") === -1)) { + throw new Error("activity leaked a record outside 70a worktree: " + JSON.stringify(got.activity)); + } +' "$resp_70a_activity" + +kill "$server_pid2" >/dev/null 2>&1 || true +wait "$server_pid2" 2>/dev/null || true +server_pid2="" + IG worktree remove --force "$insp_root/.claude/worktrees/issue-70a" >/dev/null 2>&1 || true IG worktree remove --force "$insp_wt_70b" >/dev/null 2>&1 || true diff --git a/.claude/scripts/log-worker-tool.sh b/.claude/scripts/log-worker-tool.sh index 9a69c26..ae3c442 100755 --- a/.claude/scripts/log-worker-tool.sh +++ b/.claude/scripts/log-worker-tool.sh @@ -1,10 +1,14 @@ #!/usr/bin/env bash -# log-worker-tool.sh — PostToolUse mirror hook (issue #71, Cockpit 3c SPIKE). +# log-worker-tool.sh — PostToolUse mirror hook (issue #71, Cockpit 3c SPIKE; +# enabled by default as of issue #84). # -# 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. +# ENABLED BY DEFAULT — wired into .claude/settings.json's hooks.PostToolUse +# (see the "Wiring" section below, kept here as reference for what's live). +# The issue #71 spike gathered evidence on signal/noise and log growth +# (readable, truncated/collapsed Bash summaries; rotation via atomic mv keeps +# the JSONL valid; manageable lines/hour for a typical fan-out) and issue #84 +# concluded: enable it. Cockpit's worker inspector (cockpit-serve.sh) reads +# this log to power the "Live activity" drawer section. # # Reads the PostToolUse hook event JSON on STDIN and mirrors ONLY the tools # Bash, Edit, Write (every other tool — Read, Grep, Glob, ... — is silently @@ -42,7 +46,8 @@ # agent never sees this happen and it costs zero agent tokens. # # --------------------------------------------------------------------------- -# Wiring (DISABLED by default — copy into .claude/settings.json to enable): +# Wiring — this is the wiring now live in .claude/settings.json's +# hooks.PostToolUse array (kept here as reference, not a to-do): # # "PostToolUse": [ # { @@ -56,7 +61,9 @@ # } # ] # -# A "go" decision is this one array entry — nothing else to wire up. +# The evaluation (issue #84) concluded: enable — this is a single array +# entry alongside the existing Edit|Write -> lint entry, nothing else to +# wire up. # --------------------------------------------------------------------------- set -u diff --git a/.claude/scripts/log-worker-tool.test.sh b/.claude/scripts/log-worker-tool.test.sh index ebe149a..29ef5e6 100755 --- a/.claude/scripts/log-worker-tool.test.sh +++ b/.claude/scripts/log-worker-tool.test.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # log-worker-tool.test.sh — offline smoke test for log-worker-tool.sh -# (issue #71, Cockpit 3c SPIKE). +# (issue #71, Cockpit 3c SPIKE; enabled by default as of issue #84). # # Asserts: Bash/Edit/Write tool calls yield exactly one valid-JSON record # each with the expected tool/summary/path fields; non-mirrored tools (Read, diff --git a/.claude/settings.json b/.claude/settings.json index e59affe..7db3015 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,6 +10,15 @@ "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/scripts/gate.sh\" lint" } ] + }, + { + "matcher": "Bash|Edit|Write", + "hooks": [ + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/scripts/log-worker-tool.sh\"" + } + ] } ], "Stop": [