#!/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"})`);