#!/usr/bin/env node // SubagentStop — full validation chain (prettier auto-fix, typecheck, unit app/functions, e2e) on active worktrees with changes; exit 2 keeps the subagent alive to fix and commit. VALIDATE_DRY_RUN=1 skips the chain; =fail simulates a failure. import { existsSync, readFileSync } from "node:fs"; import { createHookContext } from "./lib/context.mjs"; import { git } from "./lib/git.mjs"; import { getFirstTaskId } from "./lib/teams.mjs"; import { readAgentMeta } from "./lib/agent-meta.mjs"; import { sessionBranch, simpleWorktreePath, taskWorktreePath, } from "./lib/topology.mjs"; import { runValidationSteps } from "./lib/validation.mjs"; const raw = readFileSync(0, "utf8"); const ctx = createHookContext(raw, "validate"); let payload = {}; try { payload = JSON.parse(raw); } catch { payload = {}; } // Scope validation to the stopping subagent's OWN worktree: a per-ticket // developer-TASK-XXX validates /TASK-XXX, a single-shot simple developer (on // the /simple branch — rollback / migration) validates /simple. Avoids the // "shared brakes" failure where one ticket's broken state blocks an unrelated // developer and N stops each re-validate every session worktree. Falls back to // all session worktrees when the identity can't be resolved. const ids = [ctx.agentName, ctx.agentType].filter(Boolean); let taskId = ids.map(getFirstTaskId).find(Boolean) || ""; // A single-shot simple developer runs on the shared /simple worktree; its agent // name carries no TASK suffix, so it's recovered from the dispatch prompt below. let isSimple = false; // Reliable identity at SubagentStop: the sibling .meta.json (written at // spawn, so it exists even when the big transcript JSONL hasn't been flushed yet — // a real race here). Its `description` carries the ticket (e.g. "Implement // TASK-002: …"). Use it to scope validation to THIS dev's worktree instead of // re-validating every session worktree (wt=all) on every stop. if (!taskId) { const meta = readAgentMeta(payload); if (meta) { const m = meta.description.match(/TASK-\d+/); if (m) taskId = m[0]; } } // No suffixed agent name in this harness → recover the TASK_ID (or the single-shot // simple flow) from the dispatch prompt in the transcript, to scope validation to // this dev's worktree (not wt=all). if (!taskId) { const tp = payload.agent_transcript_path || payload.transcript_path; if (tp && existsSync(tp)) { try { const body = readFileSync(tp, "utf8"); for (const line of body.split("\n")) { const m = line.match(/TASK_ID[:=\s]+(TASK-\d+)/) || line.match(/TICKET_FILE[=:\s]+\S*(TASK-\d+)/); if (m) { taskId = m[1]; break; } if (/BRANCH_NAME[:=\s]+\S+\/simple\b/.test(line)) { isSimple = true; } } } catch { // best-effort — fall back to all-worktree validation below } } } const ownWorktree = taskId ? taskWorktreePath(ctx, taskId) : isSimple ? simpleWorktreePath(ctx) : ""; // Diff each worktree against session/, not the repo's checked-out base // branch, so validation sees a per-ticket developer's OWN change set. (A // single-shot simple developer is the exception: resolving-rollback-conflicts does // `git reset --hard `, re-forking /simple onto the default // branch, so its diff against session/ can span unrelated files — accepted // noise, since the rollback's whole point is to diverge from the session.) // Empty base → validation.mjs falls back to the repo base branch (e.g. before the // session branch exists). const sessionRef = sessionBranch(ctx); const base = git(["show-ref", "--verify", "--quiet", `refs/heads/${sessionRef}`]) .status === 0 ? sessionRef : ""; ctx.log( `START REPO=${ctx.repo} MODE=${process.env.MODE || ""} wt=${ownWorktree || "all"} base=${base || "repo-default"}`, ); const result = runValidationSteps(ctx, { worktree: ownWorktree, base }); if (!result.ok) { ctx.fail( `Validation failed at step '${result.step}' — fix the errors and commit before completing:\n` + result.output, { log: `step=${result.step}` }, ); } ctx.accept(result.skipReason || `OK (${ownWorktree || "all worktrees"})`);