Files
upstream-atomic-crm/.claude/scripts/harness-monitor.mjs
upstream-mirror 93ecc1a6f8
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
2026-09-18 01:48:50 +05:00

683 lines
23 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
//
// Harness monitor — a terminal view of an agent-workflow session.
//
// Gather informations from three places, all keyed by the session id:
//
// 1. hooks.log <TMP_ROOT>/<sanitized-repo>/<id>/hooks.log
// 2. subagent transcripts ~/.claude/projects/<slug>/<id>/subagents/*.jsonl
// 3. tickets <CHAT_SESSION_DIR or TICKETS_DIR>/TASK-*.json
//
// Usage:
// .claude/scripts/harness-monitor.mjs # latest session, one-shot summary
// .claude/scripts/harness-monitor.mjs --watch # latest session, live (re-renders)
// .claude/scripts/harness-monitor.mjs --session <id> # a specific session id
// .claude/scripts/harness-monitor.mjs --list # list known sessions, newest first
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
import { join, basename } from "node:path";
import { homedir } from "node:os";
import { execSync } from "node:child_process";
// ─── paths & session discovery ──────────────────────────────────────────────
const REPO = (() => {
if (process.env.CLAUDE_PROJECT_DIR) return process.env.CLAUDE_PROJECT_DIR;
try {
return execSync("git rev-parse --show-toplevel", {
encoding: "utf8",
}).trim();
} catch {
return process.cwd();
}
})();
const TMP_ROOT = process.env.CRM_TMP_ROOT || "/tmp";
const sanitize = (p) => String(p).replace(/\//g, "_");
const WORKTREE_ROOT = join(TMP_ROOT, sanitize(REPO)); // /tmp/_workspaces_atomic-crm
const CLAUDE_PROJ = join(
homedir(),
".claude",
"projects",
// Claude Code names the project dir by replacing EVERY non-alphanumeric char
// (slashes AND dots, e.g. ".code-workspace") with "-". Replacing only "/" broke
// discovery for any repo path containing a dot → "no subagent transcripts found".
REPO.replace(/[^a-zA-Z0-9]/g, "-"),
);
// A session id is a worktree-base subdir that has a hooks.log. Test fixtures
// (td-1, td-2, …) are skipped — they are not real sessions.
function listSessions() {
if (!existsSync(WORKTREE_ROOT)) return [];
return readdirSync(WORKTREE_ROOT)
.filter((name) => !/^td-/.test(name))
.map((id) => ({ id, log: join(WORKTREE_ROOT, id, "hooks.log") }))
.filter((s) => existsSync(s.log))
.map((s) => ({ ...s, mtime: statSync(s.log).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
}
function sessionPaths(id) {
const base = join(WORKTREE_ROOT, id);
return {
id,
base,
hooksLog: join(base, "hooks.log"),
mainTranscript: join(CLAUDE_PROJ, `${id}.jsonl`),
subagentsDir: join(CLAUDE_PROJ, id, "subagents"),
// Tickets land in the session dir itself (TICKETS_DIR = session_dir = base);
// <base>/tickets is the legacy fallback layout.
ticketDirs: [base, join(base, "tickets")],
};
}
// A worktree path → a short, human label. `simple` is the simple-developer's
// worktree; `_session` is the shared integration worktree the merger merges
// into before promoting to the base branch.
function wtLabel(path) {
if (!path) return "";
const b = basename(path);
if (b === "_session") return "_session (integration)";
if (b === "simple") return "simple (dev worktree)";
return b; // TASK-XXX
}
// ─── colour helpers ─────────────────────────────────────────────────────────
const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
const c = (code, s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
const dim = (s) => c("2", s);
const bold = (s) => c("1", s);
const red = (s) => c("31", s);
const green = (s) => c("32", s);
const yellow = (s) => c("33", s);
const blue = (s) => c("36", s);
const mag = (s) => c("35", s);
const hhmmss = (iso) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(11, 19);
};
// ─── hooks.log parsing ──────────────────────────────────────────────────────
// Line shape: `[<iso-ts>] [<hook>] <rest...>`. Lines without that prefix are
// continuation output (e.g. a multi-line validation failure) and attach to the
// previous event as detail.
const LINE_RE = /^\[([^\]]+)\]\s+\[([^\]]+)\]\s+(.*)$/;
function parseHooksLog(path) {
if (!existsSync(path)) return [];
const events = [];
for (const line of readFileSync(path, "utf8").split("\n")) {
if (!line) continue;
const m = line.match(LINE_RE);
if (!m) {
if (events.length) events[events.length - 1].detail.push(line);
continue;
}
const [, ts, hook, rest] = m;
const fields = {};
for (const f of rest.matchAll(/(\w+)=(\S+)/g)) fields[f[1]] = f[2];
const state = rest.split(/\s+/)[0];
events.push({ ts, hook, state, rest, fields, detail: [] });
}
return events;
}
// ─── subagent transcripts ───────────────────────────────────────────────────
function readJsonl(path) {
if (!existsSync(path)) return [];
const out = [];
for (const line of readFileSync(path, "utf8").split("\n")) {
if (!line.trim()) continue;
try {
out.push(JSON.parse(line));
} catch {
/* skip malformed line */
}
}
return out;
}
// Claude Code records a failed hook command as an `attachment` whose inner type
// is hook_(non_)blocking_error.
function hookErrorsFrom(records) {
const errs = [];
for (const rec of records) {
const a = rec.attachment;
if (!a || !/hook_(non_)?blocking_error/.test(a.type || "")) continue;
errs.push({
ts: rec.timestamp,
hookName: a.hookName || a.hookEvent || "hook",
stderr: (a.stderr || "").split("\n")[0].slice(0, 120),
command: (a.command || "").slice(0, 80),
exitCode: a.exitCode,
blocking: !/non_blocking/.test(a.type || ""),
});
}
return errs;
}
function readAgents(dir) {
if (!existsSync(dir)) return [];
const agents = [];
for (const file of readdirSync(dir)) {
if (!file.endsWith(".jsonl")) continue;
const agentId = basename(file, ".jsonl");
const meta = (() => {
const p = join(dir, `${agentId}.meta.json`);
try {
return JSON.parse(readFileSync(p, "utf8"));
} catch {
return {};
}
})();
const records = readJsonl(join(dir, file));
const toolCounts = {};
const messages = []; // SendMessage payloads, timestamped
let lastText = "";
let firstTs = null;
let lastTs = null;
for (const rec of records) {
if (rec.timestamp) {
if (!firstTs) firstTs = rec.timestamp;
lastTs = rec.timestamp;
}
const content = rec.message?.content;
if (!Array.isArray(content)) continue;
for (const block of content) {
if (block.type === "tool_use") {
toolCounts[block.name] = (toolCounts[block.name] || 0) + 1;
if (block.name === "SendMessage") {
const to = block.input?.to ?? "?";
const msg = block.input?.message;
messages.push({
to,
text: typeof msg === "string" ? msg : JSON.stringify(msg),
ts: rec.timestamp,
});
}
} else if (block.type === "text" && block.text.trim()) {
lastText = block.text.trim();
}
}
}
agents.push({
agentId,
type: meta.agentType || "?",
description: meta.description || "",
toolCounts,
messages,
lastText,
firstTs,
lastTs,
hookErrors: hookErrorsFrom(records),
verdict: classifyVerdict(lastText),
});
}
// Chronological by last activity so repeated dispatches of the same agent
// (retries / resumes) read top-to-bottom in the order they ran.
return agents.sort((a, b) =>
String(a.lastTs || "").localeCompare(String(b.lastTs || "")),
);
}
// The orchestrator is the main session transcript, NOT a subagent.
function readOrchestrator(path) {
const records = readJsonl(path);
if (!records.length) return null;
let lastText = "";
let lastTs = null;
const dispatches = []; // { ts, type } — when the orchestrator spawned each agent
for (const rec of records) {
if (rec.timestamp) lastTs = rec.timestamp;
const content = rec.message?.content;
if (!Array.isArray(content)) continue;
for (const block of content) {
if (
block.type === "tool_use" &&
(block.name === "Agent" || block.name === "Task")
)
dispatches.push({
ts: rec.timestamp,
type:
block.input?.subagent_type || block.input?.description || "agent",
});
else if (block.type === "text" && block.text.trim())
lastText = block.text.trim();
}
}
return { lastText, lastTs, dispatches, hookErrors: hookErrorsFrom(records) };
}
// Merge every timestamped signal — orchestrator spawns, agent SendMessages,
// agent completions, and the key hook milestones — into one chronological
// stream. This is the "what happened, in order" view: dev → merger, etc.
function buildEventTimeline(orchestrator, agents, events) {
const items = [];
const push = (ts, color, icon, text) =>
ts && items.push({ ts, color, icon, text });
for (const d of orchestrator?.dispatches ?? [])
push(d.ts, blue, "▶", `orchestrator spawned ${bold(d.type)}`);
for (const a of agents) {
for (const m of a.messages)
push(
m.ts,
mag,
"→",
`${a.type} → ${bold(m.to)}: ${m.text.split("\n")[0].slice(0, 80)}`,
);
const mark =
a.verdict.kind === "done"
? green("✓")
: a.verdict.kind === "fail"
? red("✗")
: dim("■");
const vcolor =
a.verdict.kind === "fail" ? red : a.verdict.kind === "done" ? green : dim;
push(
a.lastTs,
vcolor,
mark,
`${a.type} finished — ${a.verdict.label.slice(0, 80)}`,
);
}
for (const e of events) {
if (e.hook === "setup-worktree" && e.state === "CREATED")
push(e.ts, blue, "⊕", `worktree created ${dim(wtLabel(e.fields.path))}`);
else if (e.hook === "validate" && e.state === "OK")
push(e.ts, green, "✓", `validation OK ${dim(wtLabel(e.fields.wt))}`);
else if (e.hook === "validate" && e.state === "FAIL")
push(
e.ts,
red,
"✗",
`validation FAIL ${e.fields.step || ""}${e.detail.length ? dim(" — " + e.detail.join(" ").slice(0, 60)) : ""}`,
);
else if (e.hook === "circuit-breaker" && /^BLOCK/.test(e.rest))
push(e.ts, red, "⛔", `circuit-breaker BLOCK (count=${e.fields.count})`);
}
if (orchestrator?.lastTs)
push(
orchestrator.lastTs,
dim,
"▣",
`orchestrator last said: ${orchestrator.lastText.split("\n").pop().slice(0, 80)}`,
);
return items.sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
}
// Synthesize a single completion verdict for the whole session from the
// strongest available signal: the merger's DONE line, then a clean
// no-dirty-worktree finish, else still-running / unknown.
function sessionStatus(agents, events) {
const merger = agents.find((a) => a.type === "merger");
if (merger) {
const m = merger.lastText.match(/DONE:\s*commit=(\S+)/i);
if (m)
return green(
`✓ COMPLETE — merger promoted to main (commit ${m[1].replace(/[.,]$/, "")})`,
);
if (/promotion merged|merged .*into main/i.test(merger.lastText))
return green("✓ COMPLETE — merger reported the session merged into main");
if (merger.verdict.kind === "fail")
return red(`✗ merger FAILED — ${merger.verdict.label.slice(0, 120)}`);
}
if (agents.some((a) => a.verdict.kind === "fail"))
return red("✗ FAILED — an agent ended in failure (see DIAGNOSIS)");
const lastValidate = [...events]
.reverse()
.find((e) => e.hook === "validate" && e.state === "ACCEPT");
if (lastValidate && /no_dirty_worktree/.test(lastValidate.rest))
return yellow(
"… likely done — last validation found nothing left to do, but no merger DONE was seen",
);
return yellow("… in progress / outcome unclear");
}
// The structured-text contract: agents end with DONE / FAILED / BLOCKED, or a
// reviewer verdict.
function classifyVerdict(text) {
if (!text) return { kind: "running", label: "running / no final message" };
const head =
text
.split("\n")
.find((l) => l.trim())
?.slice(0, 200) ?? "";
if (/^FAILED\b|permanently deadlocked|\bBLOCKED\b/im.test(text))
return { kind: "fail", label: head };
if (
/^DONE:|merged\s+TASK|merged successfully|promotion merged|completed successfully|^Verdict:\s*(APPROVED|GREEN)/im.test(
text,
)
)
return { kind: "done", label: head };
return { kind: "info", label: head };
}
// ─── tickets ────────────────────────────────────────────────────────────────
function readTickets(dirs) {
for (const dir of dirs) {
if (!existsSync(dir)) continue;
const files = readdirSync(dir).filter((f) => /^TASK-\d+\.json$/.test(f));
if (!files.length) continue;
return files.sort().map((f) => {
try {
const t = JSON.parse(readFileSync(join(dir, f), "utf8"));
return {
id: t.id || basename(f, ".json"),
status: t.status || "?",
title: t.title || t.description || "",
};
} catch {
return { id: basename(f, ".json"), status: "unreadable", title: "" };
}
});
}
return [];
}
// ─── diagnosis ──────────────────────────────────────────────────────────────
// Turn the raw event stream into named pathologies.
function diagnose(events, agents, hookErrors = []) {
const findings = [];
// 0. Failed hook commands (from the transcript, not hooks.log). Aggregate by
// hook + first error line so a hook that fires on every tool use collapses
// into one finding with a count.
const byErr = new Map();
for (const h of hookErrors) {
const key = `${h.hookName} → ${h.stderr}`;
const row = byErr.get(key) ?? { ...h, count: 0 };
row.count++;
byErr.set(key, row);
}
for (const h of byErr.values()) {
const sev = h.blocking ? red("✗") : yellow("⚠");
const cmd = h.command ? dim(` [${h.command}]`) : "";
findings.push(
`${sev} ${h.blocking ? "BLOCKING " : ""}hook error ×${h.count}: ${h.hookName} — ${h.stderr} (exit ${h.exitCode})${cmd}`,
);
}
// 1. A validation step that keeps failing on the same worktree.
const validFails = events.filter(
(e) => e.hook === "validate" && e.state === "FAIL" && e.fields.step,
);
const byStep = {};
for (const e of validFails)
byStep[e.fields.step] = (byStep[e.fields.step] || 0) + 1;
for (const [step, n] of Object.entries(byStep)) {
if (n >= 2)
findings.push(
red(
`✗ validation never passes: '${step}' failed ${n}× — the flow cannot complete until this is green.`,
),
);
}
// 2. Circuit-breaker blocks → an agent is re-submitting an identical action.
const blocks = events.filter(
(e) => e.hook === "circuit-breaker" && /^BLOCK/.test(e.rest),
);
if (blocks.length) {
const maxCount = Math.max(
...blocks.map((b) => Number(b.fields.count) || 0),
);
findings.push(
red(
`✗ retry loop: circuit-breaker BLOCKED ${blocks.length}× (up to ${maxCount} identical attempts) — the agent is stuck repeating the same call.`,
),
);
}
// 3. bash-guard blocks → agent fighting the validation/permission rules.
const guard = events.filter(
(e) => e.hook === "bash-guard" && /BLOCK/.test(e.rest),
);
if (guard.length >= 3)
findings.push(
yellow(
`⚠ ${guard.length} bash-guard blocks — the agent tried to run forbidden commands (often a sign it's flailing).`,
),
);
// 4. Agents that reported failure
const seen = new Set();
for (const a of agents) {
const signals = [
a.verdict.kind === "fail" ? a.verdict.label : "",
...a.messages.map((m) => m.text),
];
for (const s of signals) {
const head = s.split("\n")[0];
if (
!/FAILED|BLOCKED|worktree not found|cannot create worktree/i.test(head)
)
continue;
const key = `${a.type}:${head.slice(0, 60)}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push(red(`✗ ${a.type}: ${head.slice(0, 140)}`));
}
}
// 4b. A TASK developer ran but its worktree was never created.
const createdTasks = new Set(
events
.filter(
(e) =>
e.hook === "setup-worktree" &&
/CREATED/.test(e.rest) &&
e.fields.branch,
)
.map((e) => (e.fields.branch.match(/TASK-\d+/) || [])[0])
.filter(Boolean),
);
for (const a of agents) {
const task = (a.type.match(/TASK-\d+/) || [])[0];
if (task && !createdTasks.has(task) && /developer/.test(a.type))
findings.push(
red(
`✗ ${task}: worktree never created (no setup-worktree CREATED entry) — the developer has nowhere to work.`,
),
);
}
// 5. Worktree setup failure.
if (
events.some(
(e) => e.hook === "setup-worktree" && /FAILED|EXIT=2/.test(e.rest),
)
)
findings.push(
red("✗ setup-worktree failed — no usable worktree was created."),
);
if (!findings.length) findings.push(green("✓ no known pathology detected."));
return findings;
}
// ─── rendering ──────────────────────────────────────────────────────────────
function render(id, limit = 0) {
const p = sessionPaths(id);
const events = parseHooksLog(p.hooksLog);
const agents = readAgents(p.subagentsDir);
const orchestrator = readOrchestrator(p.mainTranscript);
const tickets = readTickets(p.ticketDirs);
const out = [];
const line = (s = "") => out.push(s);
line(bold(blue(`━━━ harness session ${id} ━━━`)));
line(dim(`repo: ${REPO}`));
line(dim(`base: ${p.base}`));
if (existsSync(p.hooksLog))
line(
dim(
`hooks.log: ${events.length} events, updated ${hhmmss(new Date(statSync(p.hooksLog).mtime).toISOString())}`,
),
);
else line(red("hooks.log: missing"));
line();
// Headline: did the session complete?
line(bold("STATUS ") + sessionStatus(agents, events));
if (orchestrator)
line(
` ${dim("orchestrator last said:")} ${orchestrator.lastText.split("\n").pop().slice(0, 140) || dim("(none)")}`,
);
else line(` ${dim("orchestrator: main transcript not found")}`);
line();
// Agents
line(bold("AGENTS"));
if (!agents.length) line(dim(" (no subagent transcripts found)"));
for (const a of agents) {
const mark =
a.verdict.kind === "done"
? green("✓")
: a.verdict.kind === "fail"
? red("✗")
: yellow("…");
const tools = Object.entries(a.toolCounts)
.map(([n, k]) => `${n}:${k}`)
.join(" ");
line(` ${mark} ${bold(a.type)} ${dim(a.description)}`);
if (tools) line(` ${dim("tools")} ${tools}`);
for (const m of a.messages)
line(` ${mag("→ " + m.to)} ${m.text.split("\n")[0].slice(0, 100)}`);
if (a.verdict.label)
line(
` ${a.verdict.kind === "fail" ? red(a.verdict.label) : dim(a.verdict.label)}`,
);
}
line();
// Tickets
if (tickets.length) {
line(bold("TICKETS"));
for (const t of tickets) {
const cl =
t.status === "merged"
? green
: t.status === "in_progress"
? yellow
: dim;
line(` ${cl(t.status.padEnd(12))} ${t.id} ${dim(t.title.slice(0, 80))}`);
}
line();
}
// Unified event timeline: orchestrator spawns + agent messages + completions
// + key hook milestones, in chronological order. Consecutive identical rows
// (e.g. the same validation failing 40×) are run-length collapsed to `×N`.
let timeline = buildEventTimeline(orchestrator, agents, events);
const total = timeline.length;
if (limit && total > limit) timeline = timeline.slice(total - limit);
line(
bold("EVENT TIMELINE") +
dim(
` (chronological${limit && total > limit ? `, last ${limit} of ${total}` : ""})`,
),
);
let i = 0;
while (i < timeline.length) {
let j = i;
while (j + 1 < timeline.length && timeline[j + 1].text === timeline[i].text)
j++;
const n = j - i + 1;
const ev = timeline[i];
const span = n > 1 ? dim(` ×${n} (until ${hhmmss(timeline[j].ts)})`) : "";
line(` ${dim(hhmmss(ev.ts))} ${ev.color(ev.icon)} ${ev.text}${span}`);
i = j + 1;
}
line();
// Diagnosis — include hook-command failures gathered from every transcript
// (orchestrator + each subagent), which never appear in hooks.log.
const hookErrors = [
...(orchestrator?.hookErrors ?? []),
...agents.flatMap((a) => a.hookErrors ?? []),
];
line(bold("DIAGNOSIS"));
for (const f of diagnose(events, agents, hookErrors)) line(` ${f}`);
return out.join("\n");
}
// ─── main ───────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const has = (f) => args.includes(f);
const valueOf = (f) => {
const i = args.indexOf(f);
return i >= 0 ? args[i + 1] : null;
};
// This is a CLI tool: its whole job is to write a report to stdout. ESLint's
// no-console rule only permits warn/error, so write to stdout directly.
const print = (s = "") => process.stdout.write(s + "\n");
if (has("--list")) {
const sessions = listSessions();
if (!sessions.length) {
print("No sessions found under " + WORKTREE_ROOT);
process.exit(0);
}
print(bold("Sessions (newest first):"));
for (const s of sessions)
print(
` ${s.id} ${dim(new Date(s.mtime).toISOString().slice(0, 19).replace("T", " "))}`,
);
process.exit(0);
}
const sessionId = valueOf("--session") || listSessions()[0]?.id;
if (!sessionId) {
console.error(
red(
`No session found under ${WORKTREE_ROOT}. Start a harness session first (ask Claude Code for a change).`,
),
);
process.exit(1);
}
if (has("--watch")) {
// Use the terminal's alternate screen buffer (like top/htop/less): the view
// redraws in place and never pollutes the scrollback. We restore the normal
// screen on exit. The timeline is capped to the visible height so the newest
// events are always on screen instead of scrolling off the top.
const enter = () => process.stdout.write("\x1b[?1049h\x1b[?25l");
const leave = () => process.stdout.write("\x1b[?1049l\x1b[?25h");
const tick = () => {
const limit = Math.max(8, (process.stdout.rows || 40) - 18); // leave room for header/agents
process.stdout.write("\x1b[H\x1b[J"); // home + clear to end
process.stdout.write(render(sessionId, limit));
process.stdout.write(
"\n" + dim(`watching ${sessionId} — refresh 1.5s — Ctrl-C to quit`),
);
};
enter();
tick();
const iv = setInterval(tick, 1500);
const quit = () => {
clearInterval(iv);
leave();
process.exit(0);
};
process.on("SIGINT", quit);
process.on("SIGTERM", quit);
} else {
print(render(sessionId));
}