snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-18 01:48:50 +05:00
commit 93ecc1a6f8
787 changed files with 75240 additions and 0 deletions

242
.claude/hooks/bash-guard.mjs Executable file
View File

@@ -0,0 +1,242 @@
#!/usr/bin/env node
// PreToolUse(Bash) — single guard for Bash commands. Blocks commands that open browser windows (headed Playwright, Vite --open) for every caller, blocks the orchestrator from mutating the review/dispatch guard state under <sessionDir>/{reviews,breaker}, and blocks gated subagents from running validation commands (validate-on-stop.mjs runs them automatically on SubagentStop).
import { readFileSync } from "node:fs";
import { createHookContext } from "./lib/context.mjs";
import {
isDeveloper,
isOrchestrator,
isQualityReviewer,
} from "./lib/teams.mjs";
import {
loadConfig,
validationSteps,
extraForbidden,
launcher,
} from "./lib/config.mjs";
const input = JSON.parse(readFileSync(0, "utf8"));
const ctx = createHookContext(input, "bash-guard");
const agent = input.agent_type || "";
const cmd = input.tool_input?.command || "";
if (!cmd) process.exit(0);
// Browser rules — any caller: this sandbox has no display, a headed run hangs forever.
const opensHeadedPlaywright = (c) =>
/playwright/.test(c) &&
/(screenshot|test|codegen)/.test(c) &&
!c.includes("--headless");
const opensViteBrowser = (c) =>
/(vite|npm run (dev|start|start-demo))/.test(c) && c.includes("--open");
const BROWSER_RULES = [
[
opensHeadedPlaywright,
"Playwright must always use --headless. Add --headless to the command.",
],
[
opensViteBrowser,
"Vite must not use --open (opens a browser window). Remove the --open flag.",
],
];
const browserViolation = BROWSER_RULES.find(([matches]) => matches(cmd));
if (browserViolation) {
ctx.block({
reason: browserViolation[1],
log: `browser cmd=${cmd.slice(0, 120)}`,
});
}
// Guard-state rule — orchestrator only: the orchestrator must NEVER mutate the
// hook state under <sessionDir>/reviews (review-verdict flags) or
// <sessionDir>/breaker (planner/duplicate-dispatch markers). Those files ARE the
// safety guards (block-merger-without-review, block-duplicate-dispatch) — an
// orchestrator that touches/rm's them forges an approval or clears a debounce and
// bypasses review entirely.
// Match the orchestrator the same way block-nested-orchestrator does: agent_type
// when present, else the CLAUDE_AGENT_NAME-derived ctx.agentType, allowing a
// suffixed runtime name (orchestrator-…) and the chat- prefix. (isOrchestrator
// lives in lib/teams.mjs so both gates share one predicate.)
if (isOrchestrator(agent || ctx.agentType)) {
// Match `reviews`/`breaker` as a path segment bounded on the left by `/` and
// on the right by `/`, a quote, whitespace, or end-of-token. The trailing-slash
// form alone missed the command the codebase actually teaches the reviewer to
// use — `RD="$(dirname "$TICKET_FILE")/reviews" && touch "$RD/<flag>"` — whose
// literal text is `…/reviews"` then `$RD/…`, never `/reviews/`. That let a
// confused orchestrator forge a verdict flag through the documented form.
const guardPath = /\/(reviews|breaker)(\/|["'\s]|$)/;
const mutatingVerb =
/(^|[;&|]|\bsudo\b|\bxargs\b)\s*(rm|touch|mkdir|mv|cp|truncate|ln)\b/.test(
cmd,
) ||
/\bsed\s+(-[a-zA-Z]*i\b|--in-place)/.test(cmd) ||
/\|\s*tee\b/.test(cmd);
const redirectToGuard = />>?\s*\S*\/(reviews|breaker)(\/|["'\s]|$)/.test(cmd);
if ((mutatingVerb && guardPath.test(cmd)) || redirectToGuard) {
ctx.block({
reason:
"Refusing this command: the orchestrator must not write to or delete files under <session_dir>/reviews or <session_dir>/breaker — those ARE the review/dispatch guards. " +
"If a merger dispatch was blocked for 'no APPROVED verdict', do NOT fabricate the flag and do NOT re-dispatch the reviewer: the reviewer writes its own flag on APPROVED (quality-reviewer.md). A missing flag means the reviewer did NOT approve — read its output file and act on the real verdict. " +
"If a dispatch was blocked as 'still in flight', wait for its task-notification instead of clearing the marker.",
log: `BLOCK orchestrator guard-state mutation cmd=${cmd.slice(0, 120)}`,
});
}
}
// Validation rules — gated subagents only: the validation hooks already run
// these; manual runs burn budget and can hang (vitest headed without CI=true).
// Resolve identity the same robust way as the guard-state rule above: prefer the
// payload agent_type, fall back to the CLAUDE_AGENT_NAME-derived ctx.agentType,
// and match via the suffix-aware predicates — so a `developer-TASK-001` runtime
// name (or an empty agent_type) is still gated, not silently waved through.
const who = agent || ctx.agentType || "";
if (!isDeveloper(who) && !isQualityReviewer(who)) process.exit(0);
const runsTypecheck = (c) =>
/(make\s+typecheck|npm\s+run\s+typecheck|npx\s+tsc(\s|$)|tsc\s+--noEmit)/.test(
c,
);
const runsPrettier = (c) =>
/(npm\s+run\s+prettier(:apply)?|npx\s+prettier(\s|$)|make\s+prettier)/.test(
c,
);
const runsUnitTests = (c) =>
/(npm\s+run\s+test(:unit)?(:[a-z]+)?|npm\s+test\b|npx\s+vitest|make\s+test(-unit)?(-[a-z]+)?)/.test(
c,
);
const runsE2eTests = (c) => /(npx\s+playwright\s+test|make\s+test-e2e)/.test(c);
const runsLint = (c) => /(make\s+lint\b|npm\s+run\s+lint\b)/.test(c);
const runsBuild = (c) =>
/(npx\s+vite\s+build|npm\s+run\s+build\b|make\s+build\b)/.test(c);
const CATEGORY_RULES = {
typecheck: [
runsTypecheck,
"typecheck: validate-on-stop.mjs runs this automatically after you stop; read its stderr output instead.",
],
prettier: [
runsPrettier,
"prettier: the validation hooks auto-apply prettier and commit the result; don't run it manually.",
],
unit: [
runsUnitTests,
"unit tests: the validation hooks run vitest automatically. In this sandbox vitest browser mode HANGS without CI=true (chromium headed waits for display). Trust the hooks.",
],
e2e: [
runsE2eTests,
"e2e tests: the validation hooks run playwright in full mode only; in demo mode they're skipped. Don't run them manually.",
],
lint: [
runsLint,
"lint: validate-on-stop.mjs runs eslint on your changed files automatically after you stop; read its stderr output instead.",
],
build: [
runsBuild,
"build: don't run production builds during tickets; typecheck (run by the validation hooks) catches type errors.",
],
};
// Which categories to guard comes from the SAME config the validation chain runs
// (kills the triple-encoding: runner, guard, and doc no longer drift). Each
// validation step's kind maps to a category (lint included — it is now a chain
// step), plus validation.extraForbidden (build — never run during tickets, not
// part of the chain). Fail-open to ALL categories if the config can't be read,
// so a malformed config never weakens the guard.
const KIND_TO_CATEGORY = {
format: "prettier",
typecheck: "typecheck",
lint: "lint",
unit: "unit",
e2e: "e2e",
};
let activeCategories;
try {
const cfg = loadConfig();
activeCategories = new Set([
...validationSteps(cfg)
.map((s) => KIND_TO_CATEGORY[s.kind])
.filter(Boolean),
...extraForbidden(cfg),
]);
} catch {
activeCategories = new Set(Object.keys(CATEGORY_RULES));
}
const VALIDATION_RULES = [...activeCategories]
.map((c) => CATEGORY_RULES[c])
.filter(Boolean);
const violation = VALIDATION_RULES.find(([matches]) => matches(cmd));
if (violation) {
ctx.block({
reason: `Validation command forbidden: ${violation[1]} See .claude/rules/validation-commands.md.`,
log: `cmd=${cmd.slice(0, 120)}`,
});
}
// File-write rules — gated subagents only: code-writing agents must use the
// Write/Edit tools, never Bash redirection/in-place edits. Bash writes bypass the
// Write|Edit-only block-migration-writes guard (a developer could otherwise write a migration in
// bash).
// The managed-launcher log dir (e.g. CRM Builder's /chat-service/logs) is a
// launcher extension point: config.launcher.logsDir. A redirect into it is
// exempt (agents legitimately append their logs there). When unset, only
// /dev/null is exempt and no chat-service path is hardcoded.
let logsDir = "";
try {
logsDir = launcher(loadConfig()).logsDir || "";
} catch {
logsDir = "";
}
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const logsAlt = logsDir ? `${escapeRe(logsDir)}\\/\\S*|` : "";
const REDIRECT_TARGET_RE = new RegExp(
`(^|[^0-9&])>>?\\s*(\\/dev\\/null\\b|${logsAlt}\\/|\\.\\.?\\/|~\\/|[a-zA-Z0-9._-]+\\/|[a-zA-Z0-9._-]+\\.[a-zA-Z0-9]+)`,
"g",
);
const writesRedirect = (c) => {
// Evaluate the /dev/null and logs exemptions PER redirect, so an unrelated
// `cmd 2>/dev/null` can't disarm detection of a real `> file` write in the
// same command.
for (const m of c.matchAll(REDIRECT_TARGET_RE)) {
const target = m[2];
if (target === "/dev/null") continue;
if (logsDir && target.startsWith(logsDir + "/")) continue;
return true;
}
return false;
};
const writesSedInPlace = (c) => /sed\s+(-[a-zA-Z]*i\b|--in-place)/.test(c);
const writesAwkInPlace = (c) => /awk\s+-i\s+inplace/.test(c);
const writesTee = (c) => {
// tee writes to its file argument(s); skip leading flags (e.g. -a) to reach
// the target, and exempt the /dev/null sink. Bare `| tee` (no file) only
// duplicates to stdout — not a write.
const m = c.match(/\|\s*tee\s+((?:-\S+\s+)*)(\S+)/);
return !!m && m[2] !== "/dev/null";
};
const writesScript = (c) =>
/(node|python3?)\s+-[ecp].*(writeFileSync|writeFile|write_text|os\.write|fs\.write)/.test(
c,
);
const FILE_WRITE_RULES = [
[writesRedirect, "bash redirection to a file (> or >>)"],
[writesSedInPlace, "sed -i (in-place edit)"],
[writesAwkInPlace, "awk -i inplace"],
[writesTee, "pipe to tee (file write)"],
[writesScript, "scripted file write via node/python"],
];
const writeViolation = FILE_WRITE_RULES.find(([matches]) => matches(cmd));
if (writeViolation) {
ctx.block({
reason: `File editing via Bash is forbidden: ${writeViolation[1]}. Use the Write or Edit tool instead — Bash writes bypass prettier/typecheck and the migration-write guard. See developer.md.`,
log: `file-write cmd=${cmd.slice(0, 120)}`,
});
}
process.exit(0);

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env node
// PreToolUse(Bash) — any caller. This dev container runs docker-in-docker; the
// agent must not launch arbitrary containers. Block commands that create/run/start
// a container (`docker run|create|start`, `docker compose up`, `docker-compose up`)
// UNLESS they reference Supabase — the one stack the dev workflow is allowed to spin
// up. Inspection/management verbs (ps, logs, stop, rm, exec, …) are never blocked.
import { readFileSync } from "node:fs";
import { createHookContext } from "./lib/context.mjs";
const input = JSON.parse(readFileSync(0, "utf8"));
const ctx = createHookContext(input, "block-docker-containers");
const cmd = input.tool_input?.command || "";
if (!cmd) process.exit(0);
// A command that brings a container up: `docker [container] run|create|start`,
// or compose `up` (both `docker compose up` and legacy `docker-compose up`).
const launchesContainer = (c) =>
/\bdocker\s+(container\s+)?(run|create|start)\b/.test(c) ||
/\bdocker(-compose|\s+compose)\b[^\n]*\bup\b/.test(c);
// The single allowed stack. Supabase's own CLI (`npx supabase start`) drives the
// Docker daemon directly and never shells out `docker run`, so it is unaffected;
// this exception covers the rare case of operating Supabase's containers by hand.
const referencesSupabase = (c) => /supabase/i.test(c);
if (launchesContainer(cmd) && !referencesSupabase(cmd)) {
ctx.block({
reason:
"Docker container launch blocked: this dev container only permits Supabase containers. " +
"`docker run|create|start` and `docker compose up` are disabled for non-Supabase images. " +
"Use `npx supabase start` / `make start` for the local stack.",
log: `cmd=${cmd.slice(0, 120)}`,
});
}
process.exit(0);

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env node
// PreToolUse(Agent) — block accidental duplicate dispatches. Two concerns,
// grouped in one hook because both are "don't dispatch the same thing twice":
//
// 1. planner: at most ONE `planner` dispatch per request.
// Scope = caller orchestrator agent_id + TICKETS_DIR (one planning round).
// A 2nd planner would overwrite the TASK-*.json the wave is already running
// against.
//
// 2. developer / quality-reviewer / merger: debounce identical re-dispatches
// inside a short window. A foreground Agent call is SUPPOSED to block and
// return the subagent's final line inline; in some runtimes (interactive
// Claude Code) it instead returns immediately with "Async agent launched …
// agentId: <id>" and the real result arrives later as a task-notification.
import {
existsSync,
mkdirSync,
readFileSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { createHookContext } from "./lib/context.mjs";
import { parseDispatch } from "./lib/dispatch-parse.mjs";
import { pipelineRoleSet, debounceRoleSet } from "./lib/teams.mjs";
// Sourced from harness.config.json (config.roles debounce/pipeline flags) via
// teams.mjs, so these sets and force-foreground's share one source of truth.
const DEBOUNCE_ROLES = debounceRoleSet();
const DEBOUNCE_WINDOW_MS = 90 * 1000;
const PLANNER_STALE_MS = 60 * 60 * 1000;
const input = JSON.parse(readFileSync(0, "utf8"));
const ctx = createHookContext(input, "block-duplicate-dispatch");
const d = parseDispatch(input);
const prompt = String(input.tool_input?.prompt ?? "");
// Without a caller agent_id we can't scope a marker safely — allow rather than
// risk over-blocking the only dispatch.
const caller = ctx.agentId;
if (!caller) process.exit(0);
const markerDir = join(ctx.sessionDir, "breaker");
try {
mkdirSync(markerDir, { recursive: true });
} catch {
process.exit(0); // can't persist a marker → don't risk blocking a real dispatch
}
const sha = (s) => createHash("sha1").update(s).digest("hex").slice(0, 16);
// A pipeline dispatch WITHOUT an explicit run_in_background:false will be DENIED by
// force-foreground-orchestrator-dispatch and re-issued with false. Do NOT record or
// check a marker for it here: otherwise the denied attempt's marker would reject the
// corrective retry as a "duplicate" (a regression from force-foreground's deny-and-retry,
// seen when the planner never ran yet its marker blocked the
// retry for 60 min). Only debounce dispatches that will actually proceed (rib === false).
const PIPELINE_ROLES = pipelineRoleSet();
const childRole = PIPELINE_ROLES.has(d.subagentType)
? d.subagentType
: PIPELINE_ROLES.has(d.role)
? d.role
: "";
if (childRole && input.tool_input?.run_in_background !== false) process.exit(0);
// ---- Concern 1: at most one planner per request -------------------------------
if (d.subagentType === "planner") {
// The STATE A planner template writes `TICKETS_DIR=<path>` (equals);
// parseDispatch only captures the `TICKETS_DIR:` (colon) form, so accept both.
const promptTicketsDir =
(prompt.match(/^TICKETS_DIR[:=]\s*(\S+)/m) || [])[1] || "";
const ticketsDir = d.ticketsDir || promptTicketsDir || ctx.ticketsDir || "";
const marker = join(markerDir, `planner-${sha(`${caller}::${ticketsDir}`)}`);
if (existsSync(marker)) {
try {
// Stale if older than the window — OR if its mtime is in the FUTURE
// (clock skew / NFS drift): a negative age would otherwise read as "fresh"
// forever and permanently block re-planning, with no escape (the
// orchestrator can't rm a breaker marker — bash-guard blocks that).
const age = Date.now() - statSync(marker).mtimeMs;
if (age > PLANNER_STALE_MS || age < 0) unlinkSync(marker);
} catch {
// ignore — fall through to the existence check
}
}
if (existsSync(marker)) {
ctx.block({
reason:
`A \`planner\` has ALREADY been dispatched for this request (TICKETS_DIR=${ticketsDir || "(session default)"}). ` +
`Do NOT dispatch a second planner — a re-plan overwrites the existing TASK-*.json while the wave may already be running against them. ` +
`Read the tickets already in TICKETS_DIR and continue into STATE B with them. ` +
`If the first plan looks wrong, fix the ticket JSON in place — do not re-run the planner.`,
log: `BLOCK 2nd planner caller=${caller} ticketsDir=${ticketsDir || "(default)"}`,
});
}
try {
writeFileSync(marker, `${caller} ${ticketsDir}\n`);
} catch {
// if we can't record the marker, still allow this (first) planner through
}
ctx.log(
`ALLOW 1st planner caller=${caller} ticketsDir=${ticketsDir || "(default)"}`,
);
process.exit(0);
}
// ---- Concern 2: debounce duplicate developer/reviewer/merger dispatches -------
if (DEBOUNCE_ROLES.has(d.subagentType)) {
// Key on the ticket identity AND the prompt content. The async-ack duplicate
// re-issues the IDENTICAL dispatch prompt, so an identical (caller, role,
// ticket, prompt) inside the window collides and is blocked. A genuine retry
// carries different prompt text (RETRY_FEEDBACK / FIX findings) → different
// key → allowed even inside the window (keying on the TASK id alone would
// wrongly debounce a fast-failing ticket's legitimate re-dispatch). The
// TASK id, when present, is kept as the human-readable label for messages.
const label = d.taskId || `prompt:${sha(prompt)}`;
const idPart = `${d.taskId || "_"}::${sha(prompt)}`;
const marker = join(
markerDir,
`dispatch-${sha(`${caller}::${d.subagentType}::${idPart}`)}`,
);
if (existsSync(marker)) {
let ageMs = Infinity;
try {
ageMs = Date.now() - statSync(marker).mtimeMs;
} catch {
// unreadable mtime → treat as stale, fall through to refresh + allow
}
if (ageMs < DEBOUNCE_WINDOW_MS) {
ctx.block({
reason:
`A \`${d.subagentType}\` dispatch for ${label} was made ${Math.round(ageMs / 1000)}s ago and is still in flight. ` +
`A dispatch can return immediately as "Async agent launched … agentId: <id>" WITHOUT blocking — that acknowledgement means "dispatched", not "done". ` +
`Do NOT re-dispatch the same role for the same ticket: wait for its task-notification, then read the agent's output file and parse its contract line. ` +
`Two ${d.subagentType}s on the same ticket race on one worktree/branch — that is always a bug.`,
log: `BLOCK duplicate ${d.subagentType} key=${idPart} age=${Math.round(ageMs / 1000)}s caller=${caller}`,
});
}
}
try {
writeFileSync(marker, `${caller} ${d.subagentType} ${idPart}\n`);
} catch {
// can't record → allow this dispatch through
}
ctx.log(`ALLOW ${d.subagentType} dispatch key=${idPart} caller=${caller}`);
process.exit(0);
}
process.exit(0);

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env node
// PreToolUse(Agent) — block dispatching a per-ticket merger until the
// quality-reviewer has recorded APPROVED for that ticket. With no SendMessage
// handshake, nothing else stops the orchestrator from going developer -> merger
// directly and skipping review; this gate enforces the
// dev -> reviewer -> (APPROVED) -> merger ordering structurally.
//
// Verdicts are recorded by record-review-verdict.mjs (SubagentStop) as flags
// under <sessionDir>/reviews/<TASK>-<role>. Skipped for the SIMPLE flow, the
// migration round, promotion-only, and rollback dispatches (no per-ticket
// review), and when the ticket can't be identified (fail open, never wedge the flow).
import { existsSync, readFileSync } from "node:fs";
import { createHookContext } from "./lib/context.mjs";
import { parseDispatch } from "./lib/dispatch-parse.mjs";
import { REVIEW_ROLES, reviewFlag } from "./lib/reviews.mjs";
const input = JSON.parse(readFileSync(0, "utf8"));
const ctx = createHookContext(input, "block-merger-without-review");
const d = parseDispatch(input);
if (d.subagentType !== "merger") process.exit(0); // only gate merger dispatches
if (d.mode === "promote") process.exit(0); // promotion-only carries no per-ticket review
if (["SIMPLE", "MIGRATION", "PROMOTE", "ROLLBACK"].includes(d.taskId))
process.exit(0);
if (!/^TASK-\d+$/.test(d.taskId)) process.exit(0); // can't identify a ticket → fail open
const missing = REVIEW_ROLES.filter(
(role) => !existsSync(reviewFlag(ctx, d.taskId, role)),
);
if (missing.length === 0) process.exit(0); // APPROVED → allow the merge
ctx.fail(
`Refusing to dispatch the merger for ${d.taskId}: no APPROVED verdict from ${missing.join(" and ")} yet.\n` +
"The flow is: developer -> quality-reviewer -> (APPROVED) -> merger.\n" +
`Dispatch quality-reviewer-${d.taskId} first (STATE B transition), then dispatch the merger only after it returns APPROVED.`,
{ log: `BLOCK ${d.taskId} missing=${missing.join(",")}` },
);

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env node
// PreToolUse(Write|Edit) — enforce deferred migrations: block writes to
// supabase/migrations-pending/* (always) and writes to supabase/migrations/* from
// anywhere except the <base>/simple worktree. The developer writes migrations only
// when a dispatch sends it to the `writing-migrations` skill, which runs on the
// shared <base>/simple worktree; a per-ticket developer writes under <base>/TASK-XXX
// and must never touch migrations. The worktree path is the discriminator (the
// dispatch intent isn't visible at Write time), and it is authoritative —
// setup-worktree derives it, not the dispatch naming.
import { readFileSync } from "node:fs";
import { createHookContext } from "./lib/context.mjs";
const input = JSON.parse(readFileSync(0, "utf8"));
const ctx = createHookContext(input, "block-migration-writes");
const filePath = input.tool_input?.file_path || "";
if (!filePath) process.exit(0);
// Prefer the suffixed runtime name (developer-TASK-003) over the bare type.
const agent = ctx.agentName || ctx.agentType;
const baseAgent = agent.split("-TASK-")[0];
const isPendingMigration = (p) => p.includes("/supabase/migrations-pending/");
const isMigration = (p) => p.includes("/supabase/migrations/");
// Migrations are only legitimate in the deploy-time migration round, which always
// runs on the fixed <base>/simple worktree (the writing-migrations skill).
const isSimpleWorktreeMigration = (p) =>
p.includes("/simple/supabase/migrations/");
const RULES = [
{
blocked: isPendingMigration(filePath),
tag: "pending-folder",
reason:
"Writing to supabase/migrations-pending/ is not allowed. Migrations live in supabase/migrations/ and are written only via the writing-migrations skill (on the <base>/simple worktree).",
},
{
blocked:
baseAgent === "developer" &&
isMigration(filePath) &&
!isSimpleWorktreeMigration(filePath),
tag: "developer-migration",
reason:
"A per-ticket developer is not allowed to write SQL migrations. They are written only via the writing-migrations skill, on the <base>/simple worktree.",
},
];
const violation = RULES.find((rule) => rule.blocked);
if (violation) {
ctx.block({
reason: violation.reason,
log: `agent=${agent} path=${filePath} reason=${violation.tag}`,
});
}
process.exit(0);

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env node
// PreToolUse(Agent) — enforce the harness dispatch topology. Two rules:
//
// Rule 1 — the ORCHESTRATOR may dispatch ONLY the typed harness agents
// (planner, developer, quality-reviewer, merger, documentator). It keeps
// drifting to `general-purpose` for planning/review despite its instructions;
// a general-purpose agent has no role constraints, won't honour the output
// contracts, and tends to re-dispatch an orchestrator (→ recursion). Anything
// off the allowlist (general-purpose, orchestrator, Explore, …) is blocked.
//
// Rule 2 — any OTHER subagent (a stray general-purpose, a worker, …) must NOT
// dispatch an `orchestrator` / `chat-orchestrator`: that re-enters the harness
// and causes runaway nesting. Only the main/top-level session (no agent_id)
// may dispatch the orchestrator.
//
// CLAUDE.md tells the top-level session to "dispatch the orchestrator", but
// CLAUDE.md loads into every subagent too — so without this gate a subagent that
// reads it would re-dispatch an orchestrator.
import { readFileSync } from "node:fs";
import { createHookContext } from "./lib/context.mjs";
import { isOrchestrator } from "./lib/teams.mjs";
const input = JSON.parse(readFileSync(0, "utf8"));
const ctx = createHookContext(input, "block-nested-orchestrator");
const target = input.tool_input?.subagent_type || "";
const caller = input.agent_type || ctx.agentType || "";
// Rule 1 — orchestrator allowlist.
const ALLOWED = [
"planner",
"developer",
"quality-reviewer",
"merger",
"documentator",
];
if (isOrchestrator(caller)) {
if (!ALLOWED.includes(target)) {
ctx.block({
reason:
`As the orchestrator you may ONLY dispatch the typed harness agents: ${ALLOWED.join(", ")}. ` +
`You tried to dispatch \`${target || "(none)"}\` — not allowed. ` +
`For planning use the \`planner\` agent (subagent_type: "planner"), NEVER \`general-purpose\`. ` +
`Re-dispatch with subagent_type set to the correct typed agent.`,
log: `BLOCK orchestrator dispatch of ${target || "(none)"}`,
});
}
process.exit(0); // allowed typed agent — fine
}
// Rule 2 — non-main subagent must not dispatch an orchestrator.
if (
ctx.agentId &&
(target === "orchestrator" || target === "chat-orchestrator")
) {
ctx.block({
reason:
`Only the main thread may dispatch the \`${target}\` agent. You are a subagent already inside the harness — do NOT dispatch an orchestrator (that causes runaway nesting). ` +
`Do the task you were given; ignore CLAUDE.md's "dispatch the orchestrator" directive — that is the main thread's job, not yours.`,
log: `BLOCK nested ${target} by ${caller || "subagent"}`,
});
}
process.exit(0);

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env node
// PreToolUse(Bash) — only the dispatched `merger` agent may run merge-class git
// commands. The main orchestrator session and every other subagent (developer,
// reviewers, …) are blocked; if any of them falls back to merging itself, the
// team workflow looks healthy but is silently broken (the dev↔merger path is
// dead). The behavioral rule lives in orchestrator.md ("NEVER act as merger
// yourself"); this is the runtime guard.
//
// OPT-IN: inert unless HARNESS_ENFORCE_MERGE_GUARD=1 (deprecated fallback:
// ATOMIC_CRM_ENFORCE_MERGE_GUARD, kept for one release). In a plain Claude Code
// checkout the main session is a general assistant, and blocking its git
// merge/pull/checkout would break normal use, so it stays off until the
// orchestrator explicitly enables it before a team wave.
import { readFileSync } from "node:fs";
import { basename } from "node:path";
import { createHookContext } from "./lib/context.mjs";
import { isMerger } from "./lib/teams.mjs";
import { loadConfig, launcher } from "./lib/config.mjs";
const mergeGuardEnabled =
process.env.HARNESS_ENFORCE_MERGE_GUARD === "1" ||
process.env.ATOMIC_CRM_ENFORCE_MERGE_GUARD === "1";
if (!mergeGuardEnabled) process.exit(0);
const input = JSON.parse(readFileSync(0, "utf8"));
const ctx = createHookContext(input, "block-orchestrator-merge");
// Allow only the dispatched `merger` agent through. The runtime carries its
// (possibly suffixed) identity in agentName or agentType, so check both.
if ([ctx.agentName, ctx.agentType].some(isMerger)) process.exit(0);
const cmd = input.tool_input?.command || "";
if (!cmd) process.exit(0);
// The post-checkout script (e.g. CRM Builder's apply-app-variant.sh) is a
// launcher extension point: its path comes from config.launcher.postCheckoutScript
// so no chat-service path is hardcoded here. When unset, that guard clause is
// inert (a project with no post-checkout script has nothing to gate).
let postCheckoutScript = "";
try {
postCheckoutScript = launcher(loadConfig()).postCheckoutScript || "";
} catch {
postCheckoutScript = "";
}
let blocked = "";
if (/(^|[;&|\s])git\s+merge(\s|$)/.test(cmd)) blocked = "git merge";
else if (/(^|[;&|\s])git\s+checkout\s+(master|main)(\s|$)/.test(cmd))
blocked = "git checkout master/main";
else if (/(^|[;&|\s])git\s+pull(\s|$)/.test(cmd)) blocked = "git pull";
else if (/(^|[;&|\s])git\s+worktree\s+remove(\s|$)/.test(cmd))
blocked = "git worktree remove";
else if (postCheckoutScript && cmd.includes(basename(postCheckoutScript)))
blocked = `${basename(postCheckoutScript)} (merger-only command)`;
if (blocked) {
ctx.fail(
`Blocked: only the dispatched \`merger\` agent may run "${blocked}".\n\n` +
"Rule: the orchestrator must NEVER act as merger, and neither may any other\n" +
"subagent. Only the `merger` runs merge-class git commands. See orchestrator.md\n" +
'"NEVER act as merger yourself".\n\n' +
"If you got here because the merger didn't report back, the team communication broke —\n" +
"the dev's \"ready\" message never reached the merger. Don't salvage by merging yourself;\n" +
"report the failure to the user and stop. Salvaging hides the bug.\n\n" +
`Blocked command:\n ${cmd}`,
{ log: `BLOCK ${blocked}` },
);
}
process.exit(0);

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
// PreToolUse(Agent) — block the session->main promotion while any developed-but-
// unmerged task branch still exists. In the no-team flow the orchestrator tracks
// each wave's tickets in its working context across a long synchronous turn; a
// ticket can be lost between its developer's DONE and the REVIEW->MERGE
// transition — its branch never merges into session/<short>, yet the orchestrator
// believes the wave is done and dispatches the promotion merger, silently
// dropping the work. This is the deterministic backstop.
//
// Behavioural counterpart: orchestrator STATE B's Promotion step reconciles
// against disk before promoting, so in the normal case this never fires.
import { readFileSync } from "node:fs";
import { createHookContext } from "./lib/context.mjs";
import { parseDispatch } from "./lib/dispatch-parse.mjs";
import { getUnmergedTaskBranches, git } from "./lib/git.mjs";
import { simpleBranch, sessionBranch } from "./lib/topology.mjs";
const input = JSON.parse(readFileSync(0, "utf8"));
const ctx = createHookContext(input, "block-promote-unmerged");
const d = parseDispatch(input);
// Only gate the promotion-merger dispatch.
if (d.subagentType !== "merger" || d.mode !== "promote") process.exit(0);
const short = ctx.sessionShort;
const session = sessionBranch(ctx);
// No session branch yet → nothing to guard.
if (
git(["show-ref", "--verify", "--quiet", `refs/heads/${session}`]).status !== 0
) {
process.exit(0);
}
// Task branches under refs/heads/<short>/ with commits not on the session branch.
// <short>/simple is excluded: the single-shot rollback/migration round promotes it
// straight to main, never into the session branch, so it is legitimately "ahead"
// of session/<short> and must not block a wave promotion. Fails CLOSED — a
// branch whose rev-list count can't be read is reported as unmerged.
const unmerged = getUnmergedTaskBranches(short, session, [simpleBranch(ctx)]);
if (unmerged.length === 0) process.exit(0);
const describe = ({ branch, count }) =>
` - ${branch} (${count === null ? "unmerged (count unavailable)" : `${count} unmerged commit(s)`})`;
ctx.fail(
`Refusing to promote ${session} to the base branch — these task branches have commits NOT yet merged into the session branch:\n` +
unmerged.map(describe).join("\n") +
"\n" +
"A ticket was developed but never merged (its reviewer/merger were likely never dispatched). For EACH branch above, drive its ticket through REVIEW -> MERGE (dispatch its quality-reviewer if needed, then its per-ticket merger) so its work lands on the session branch. Re-dispatch the promotion merger only once this list is empty.",
{ log: `BLOCK unmerged=${unmerged.length}` },
);

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
// PreToolUse(Bash): a Stage-A-only merger (a per-ticket wave merge, or any
// dispatch carrying STAGE: a-only) must NEVER run Stage B: promoting the session
// branch into the base branch. Runtime enforcement of merger.md ("TASK-XXX ->
// Stage A only; promotion runs once at the end via a separate MODE: promote dispatch").
//
// Root cause it closes (forensic, session 7ba88e76): a wave merger dispatched as
// `TASK_ID: TASK-001` (no MODE, no STAGE) ran `git checkout -f <base>` +
// `git merge --no-ff session/<short> -m "merge(session)"`, promoting partial wave-1
// work onto the base branch. The merger never checks the persona, so this is
// mode-agnostic: in a NORMAL run it would corrupt the real base branch (or main)
// mid-flow. A technical-only guard would merely mask it, so the guard is universal.
//
// Authorization is recorded at dispatch by record-merger-stage.mjs into
// <sessionDir>/merger-stage.json. Fails CLOSED: a missing/unreadable marker blocks
// the promotion: the running merger cannot prove it is authorized.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createHookContext } from "./lib/context.mjs";
import { isMerger } from "./lib/teams.mjs";
import { sessionBranch } from "./lib/topology.mjs";
const input = JSON.parse(readFileSync(0, "utf8"));
const ctx = createHookContext(input, "block-wave-merger-promote");
// Only the merger runs Stage B; every other agent is out of scope for this guard.
if (![ctx.agentName, ctx.agentType].some(isMerger)) process.exit(0);
const cmd = input.tool_input?.command || "";
if (!cmd) process.exit(0);
// Stage-B signature: a `git merge` that pulls in the session branch (promotion),
// or the promotion commit message. Stage A merges a <short>/TASK-XXX branch INTO
// the session branch and never names session/<short> as a merge argument, so it
// does not match.
const session = sessionBranch(ctx); // session/<short>
const isStageBPromotion =
/\bgit\s+merge\b/.test(cmd) &&
(cmd.includes(session) || /merge\(session\)/.test(cmd));
if (!isStageBPromotion) process.exit(0);
// Read the dispatch-time authorization marker. Fail CLOSED.
let promote = false;
let markerSeen = false;
try {
const marker = JSON.parse(
readFileSync(join(ctx.sessionDir, "merger-stage.json"), "utf8"),
);
markerSeen = true;
promote = marker.promote === true;
} catch {
promote = false; // no/unreadable marker -> not authorized
}
if (promote) process.exit(0);
ctx.fail(
"Blocked: this merger is a Stage-A-only dispatch and must NOT promote the session branch to the base branch.\n\n" +
`It tried to run Stage B (promotion):\n ${cmd}\n\n` +
(markerSeen
? "Its dispatch is not authorized to promote (a per-ticket TASK-XXX wave merge, or STAGE: a-only)."
: "No promotion-authorization marker was found for this session (fail-closed).") +
"\n\nStage B runs ONLY via a dedicated `MODE: promote` merger after the LAST wave, or a SIMPLE / MIGRATION single-shot. " +
`A per-ticket wave merger runs Stage A ONLY: merge <short>/TASK-XXX into ${session} and stop. ` +
'See merger.md ("TASK-XXX -> Stage A only") and orchestrator.md\'s promotion block.',
{ log: `BLOCK stage-A merger tried Stage B (markerSeen=${markerSeen})` },
);

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env node
// PostToolUse(Write|Edit): flag TypeScript typing escape hatches in .ts/.tsx files.
//
// Deliberately non-blocking (exit 1, not exit 2): it flags a workaround rather
// than blocking a write, so a legitimate, justified suppression is never broken.
// A justified `@ts-expect-error <reason>` on the same line is NOT flagged.
import { readFileSync } from "node:fs";
import { createHookContext } from "./lib/context.mjs";
let input = {};
try {
input = JSON.parse(readFileSync(0, "utf8"));
} catch {
process.exit(0); // unparseable payload: nothing to flag, fail open
}
const filePath = input.tool_input?.file_path || "";
if (!/\.(ts|tsx)$/.test(filePath)) process.exit(0);
let content;
try {
content = readFileSync(filePath, "utf8");
} catch {
process.exit(0); // file gone / unreadable: nothing to flag
}
const hits = [];
// Blanket escape hatches: any occurrence is a hit.
if (/\$TSFixMe\b/.test(content)) hits.push("$TSFixMe");
if (/:\s*any\b/.test(content)) hits.push(": any");
if (/\bas\s+any\b/.test(content)) hits.push("as any");
// Suppression directives are only a hit when unjustified (no explanation text
// following the directive on the same line). `@ts-expect-error legacy API` is
// fine; a bare `@ts-ignore` is not.
const SUPPRESS = /@ts-(ignore|expect-error)\b[ \t]*(.*)$/gm;
for (const m of content.matchAll(SUPPRESS)) {
const rest = (m[2] || "").replace(/^[\s:.-]+/, "").trim();
if (rest.length < 3) {
hits.push(`@ts-${m[1]} without justification`);
break;
}
}
if (hits.length === 0) process.exit(0);
const ctx = createHookContext(input, "check-typescript-shortcuts");
ctx.log(`FLAG ${filePath} ${hits.join(", ")}`);
ctx.error(
`${filePath} contains a typing workaround (${[...new Set(hits)].join(", ")}).\n` +
`Replace it with the correct type instead of bypassing the typecheck.`,
);
process.exit(1);

138
.claude/hooks/circuit-breaker.mjs Executable file
View File

@@ -0,0 +1,138 @@
#!/usr/bin/env node
// PreToolUse(Bash) - per-subagent circuit breaker. Counts Bash calls per subagent
// (keyed on agent_id, present only for subagents) and blocks once a subagent
// exceeds the loop limit, so a stuck agent can't spin forever. The main session
// (no agent_id) is never throttled - interactive sessions legitimately make many
// Bash calls.
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
statSync,
unlinkSync,
} from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { createHookContext } from "./lib/context.mjs";
import { TMP_ROOT } from "./lib/paths.mjs";
// Per-subagent budget of WORK calls. Read-only exploration and git plumbing/commit
// are free (see isFreeCommand), so this counts only "real work" (builds, scripts,
// migrations, rebases). 45 work calls is comfortable and catches infinite loops
// (which hit 100+ fast). Before this exclusion, devs burned the whole budget on
// grep-based symbol search (0 LSP / 209 grep in one session) and had none left to
// commit.
const ITERATION_LIMIT = 45;
// A Bash call is "free" (never counted) when its primary command is read-only
// exploration or git plumbing/commit. Strips a leading `cd <path> &&` (the worktree
// convention) and inspects the first token of the first pipeline stage.
function isFreeCommand(raw) {
let c = String(raw || "").trim();
c = c.replace(/^cd\s+\S+\s*(?:&&|;)\s*/, "").trim();
const first = c.split(/[|&;]/)[0].trim().split(/\s+/)[0] || "";
const READONLY = new Set([
"grep",
"rg",
"egrep",
"fgrep",
"find",
"ls",
"cat",
"wc",
"head",
"tail",
"pwd",
"tree",
"stat",
"file",
"which",
"echo",
"dirname",
"basename",
"realpath",
]);
if (READONLY.has(first)) return true;
const gm = c.match(/^git\s+(\S+)/);
if (gm) {
const GIT_FREE = new Set([
"add",
"commit",
"status",
"diff",
"log",
"show",
"branch",
"rev-parse",
"rev-list",
"stash",
"worktree",
"ls-files",
"merge-base",
]);
if (GIT_FREE.has(gm[1])) return true;
}
return false;
}
const input = JSON.parse(readFileSync(0, "utf8"));
const ctx = createHookContext(input, "circuit-breaker");
// Per-subagent breaker only. The main session (no agent_id) is never throttled.
if (!ctx.agentId) process.exit(0);
// Read-only exploration (grep/find/ls/cat/...) and git plumbing (add/commit/status)
// do NOT count against the budget. The breaker exists to catch stuck WORK loops, not
// to ration symbol search or starve the final commit.
if (isFreeCommand(input.tool_input?.command)) {
ctx.log(`free: ${String(input.tool_input?.command ?? "").slice(0, 80)}`);
process.exit(0);
}
const key = `sub-${ctx.agentId}`;
const keyHash = createHash("sha1").update(key).digest("hex").slice(0, 16);
let counterDir = join(ctx.sessionDir, "breaker");
try {
mkdirSync(counterDir, { recursive: true });
} catch {
counterDir = TMP_ROOT;
}
const counterFile = join(counterDir, `bash-count-${keyHash}`);
// Auto-reset if the counter file is older than 1 hour (stale subagent).
if (existsSync(counterFile)) {
try {
const ageMs = Date.now() - statSync(counterFile).mtimeMs;
if (ageMs > 60 * 60 * 1000) unlinkSync(counterFile);
} catch {
// ignore
}
}
let count = 0;
try {
count = parseInt(readFileSync(counterFile, "utf8"), 10) || 0;
} catch {
count = 0;
}
count += 1;
try {
writeFileSync(counterFile, String(count));
} catch {
// ignore
}
// Observability: record the keyed count so per-agent budgets can be audited.
ctx.log(`key=${key} hash=${keyHash} count=${count}`);
if (count > ITERATION_LIMIT) {
ctx.block({
reason: `Circuit breaker: this subagent has made ${count} Bash calls - likely stuck in a loop. Stop, and report where you are blocked so the orchestrator can re-dispatch with a fresh context.`,
log: `BLOCK count=${count}`,
});
}
process.exit(0);

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env node
// SessionEnd: tear down THIS session's harness scratch state when the Claude
// session ends: remove every git worktree under <WORKTREE_BASE> (_session,
// simple, any leftover TASK-XXX), prune the admin refs, drop the base dir, and
// remove the Playwright test-results left in the repo.
//
// Branches are left intact: they are cheap, do not clutter the editor's Source
// Control view (only worktrees do), and deleting them could drop work stopped at
// the session branch and not yet promoted.
//
// `.promote.lock` is deliberately NOT removed: it is the shared promotion mutex
// (flock), and unlinking it mid-run would break a concurrent session's mutual
// exclusion (same reasoning as harness-revert.mjs). It is gitignored, so it does
// not show as untracked either way.
//
// Resume guard: if the session is still mid-harness at end (plan gate, mid-wave,
// awaiting promotion, or a pending deploy migration), teardown is SKIPPED so the
// same session id can resume from disk later (STATE RECOVERY). Only a finished /
// idle session is torn down. See lib/session-state.mjs.
//
// Only the CURRENT session's paths are touched, never another session's (a
// second VS Code window is its own session and may be running concurrently).
// No-op under a managed launcher (CHAT_SESSION_DIR owns its own lifecycle).
// Never throws. No file logging: this hook's log file lives inside the base dir
// it deletes, so writing after removal would recreate the dir we tear down.
import { readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { createHookContext } from "./lib/context.mjs";
import { REPO } from "./lib/paths.mjs";
import { removeWorktreesUnder } from "./lib/worktree.mjs";
import { detectInflight } from "./lib/session-state.mjs";
import { removeWorktreeFolders } from "./lib/workspace-folders.mjs";
if (process.env.CHAT_SESSION_DIR) process.exit(0);
let raw = "";
try {
raw = readFileSync(0, "utf8");
} catch {
process.exit(0);
}
const ctx = createHookContext(raw, "cleanup-session");
// Resume safety: if THIS session is mid-harness (plan gate, mid-wave, awaiting
// promotion, or a pending deploy migration), a clean SessionEnd must NOT destroy
// its state - the same session id resumes later and STATE RECOVERY needs the
// tickets and partial worktrees on disk. Preserve everything and bail. `null`
// (undetermined) also preserves: never delete resumable work on a probe error.
// Keyed on this session's own namespace, so a concurrent session is unaffected.
try {
const { inflight, phase } = detectInflight(ctx);
if (inflight !== false) {
ctx.error(
`preserved for resume (phase=${phase ?? "undetermined"}); state kept for STATE RECOVERY`,
);
process.exit(0);
}
} catch {
// Detection failed entirely: fail safe (preserve), do not risk deleting work.
process.exit(0);
}
try {
// All worktrees under this session's base (_session, simple, leftover TASK-XXX)
// + prune, then the base dir itself (worktrees, tickets, logs; scratch once the
// session ends). Base removal is last: nothing may log after it (this hook's log
// file lives inside it and logging would recreate it).
removeWorktreesUnder(ctx.worktreeBase);
// Drop this session's worktree folders from the editor workspace (setup-worktree
// added them on a technical run). Path prefix match on the session's worktree
// base, so a concurrent session's folders stay intact.
removeWorktreeFolders(
ctx.repo,
(p) => p === ctx.worktreeBase || p.startsWith(ctx.worktreeBase + "/"),
);
rmSync(join(REPO, "test-results"), { recursive: true, force: true });
// This session's rendered board only (render-status.mjs). Per-session subdir,
// so a concurrent session's board under .harness/<other-short> is left intact.
rmSync(join(REPO, ".harness", ctx.sessionShort), {
recursive: true,
force: true,
});
rmSync(ctx.worktreeBase, { recursive: true, force: true });
} catch (e) {
ctx.error(`skipped: ${e?.message ?? e}`);
}
process.exit(0);

View File

@@ -0,0 +1,172 @@
#!/usr/bin/env node
// SubagentStop — remove this session's task worktrees once their branch has been
// merged (--no-ff, the merger's contract) into the session integration branch and
// the worktree has no uncommitted changes. Session worktrees/branches (_session,
// simple, session*/), fresh worktrees, and unmerged work are preserved.
import {
existsSync,
readdirSync,
readFileSync,
rmSync,
rmdirSync,
} from "node:fs";
import { join } from "node:path";
import { createHookContext } from "./lib/context.mjs";
import { readAgentMeta } from "./lib/agent-meta.mjs";
import {
getBaseBranch,
getWorktreeEntries,
getWorktreePaths,
git,
} from "./lib/git.mjs";
import { exec } from "./lib/process.mjs";
import {
isInfraWorktreePath,
isProtectedBranch,
isTaskWorktreeDirName,
sessionBaseBranch,
sessionBranch,
} from "./lib/topology.mjs";
import { removeWorktree } from "./lib/worktree.mjs";
import { removeWorktreeFolders } from "./lib/workspace-folders.mjs";
const raw = readFileSync(0, "utf8");
const ctx = createHookContext(raw, "cleanup-worktree");
// SubagentStop fires this on EVERY subagent stop here (the matcher doesn't filter
// and agent_type is empty in the payload). cleanup is the MERGER's post-merge step
// — skip the worktree sweep when the sibling <agent>.meta.json shows the stopping
// agent is clearly NOT a merger (developer / reviewer / planner / …). The sweep is
// idempotent either way; this just avoids running it on every stop. Meta
// missing/ambiguous → proceed with the normal cleanup.
{
let payload = {};
try {
payload = JSON.parse(raw);
} catch {
/* proceed */
}
const meta = readAgentMeta(payload);
if (meta && meta.agentType && meta.agentType !== "merger") {
ctx.accept(`skip — ${meta.agentType} stop (cleanup runs on merger stops)`);
}
}
if (!existsSync(ctx.worktreeBase)) {
ctx.accept(`${ctx.worktreeBase} not found`);
}
const base = getBaseBranch();
ctx.log(
`START session=${ctx.sessionShort} base=${ctx.worktreeBase} branch=${base}`,
);
const isUnderBase = (p) =>
p === ctx.worktreeBase || p.startsWith(ctx.worktreeBase + "/");
const hasLocalBranch = (ref) =>
git(["show-ref", "--verify", "--quiet", `refs/heads/${ref}`]).status === 0;
// Tips that were merged with --no-ff into the session integration branch: the
// second-plus parents of its merge commits. Ancestry alone cannot tell a merged
// branch from a fresh one that never diverged (both tips are ancestors), and a
// fresh worktree may belong to a developer still working — so only branches
// whose tip is a recorded merge parent are removable.
const getMergedTips = () => {
const integration = sessionBranch(ctx);
if (!hasLocalBranch(integration)) return new Set();
const anchor = sessionBaseBranch(ctx);
const range = hasLocalBranch(anchor)
? `${anchor}..${integration}`
: integration;
return new Set(
git(["log", "--merges", "--format=%P", range])
.stdout.split("\n")
.filter(Boolean)
.flatMap((line) => line.split(" ").slice(1)),
);
};
const mergedTips = getMergedTips();
const shouldRemove = ({ path: wtPath, branch }) => {
if (isInfraWorktreePath(wtPath)) {
ctx.log(`SKIP-SESSION-WORKTREE ${wtPath}`);
return false;
}
if (!branch) {
ctx.log(`SKIP-DETACHED ${wtPath} (detached HEAD)`);
return false;
}
const tip = git(["rev-parse", "--verify", branch]).stdout.trim();
if (!tip || !mergedTips.has(tip)) {
ctx.log(`SKIP-UNMERGED ${wtPath} branch=${branch}`);
return false;
}
if (exec("git", ["-C", wtPath, "status", "--porcelain"]).stdout.trim()) {
ctx.log(`SKIP-DIRTY ${wtPath} (uncommitted changes)`);
return false;
}
return true;
};
const deleteBranch = (branch) => {
if (!branch) return;
if (isProtectedBranch(branch)) {
ctx.log(`SKIP-SESSION-BRANCH ${branch}`);
return;
}
if (git(["branch", "-d", branch]).status !== 0) {
git(["branch", "-D", branch]);
}
ctx.log(`BRANCH-DELETED ${branch}`);
};
const ourWorktrees = getWorktreeEntries().filter((e) => isUnderBase(e.path));
const toRemove = ourWorktrees.filter(shouldRemove);
toRemove.forEach((e) => {
removeWorktree(e.path);
ctx.log(`REMOVED ${e.path}`);
});
toRemove.map((e) => e.branch).forEach(deleteBranch);
// Drop the merged worktrees from the editor's workspace folders (setup-worktree
// added them on a technical run). No-op under a managed launcher or a mono-folder
// window (no `.code-workspace` to edit; `code --remove` does not exist).
if (!process.env.CHAT_SESSION_DIR && toRemove.length) {
const removed = new Set(toRemove.map((e) => e.path));
removeWorktreeFolders(ctx.repo, (p) => removed.has(p));
}
git(["worktree", "prune"]);
const registered = getWorktreePaths();
const sweepLeftover = (entry) => {
if (!entry.isDirectory()) return;
const dir = join(ctx.worktreeBase, entry.name);
if (dir.endsWith("/_session")) {
ctx.log(`SKIP-SESSION-LEFTOVER ${dir}`);
return;
}
if (!isTaskWorktreeDirName(entry.name)) {
ctx.log(`SKIP-NON-WORKTREE ${dir}`);
return;
}
if (registered.includes(dir)) return;
rmSync(dir, { recursive: true, force: true });
ctx.log(`LEFTOVER RM ${dir}`);
};
readdirSync(ctx.worktreeBase, { withFileTypes: true }).forEach(sweepLeftover);
try {
rmdirSync(ctx.worktreeBase);
} catch {
// not empty / already gone — fine
}
ctx.accept(
`removed=${toRemove.length} skipped=${ourWorktrees.length - toRemove.length} session=${ctx.sessionShort}`,
);

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env node
// SubagentStop(orchestrator) - completion invariant. INSURANCE for the handoff
// stall: forcing foreground dispatch is the real fix (foreground blocks, verified),
// but if the orchestrator ever stops with APPROVED dev work NOT merged into the session
// branch, that is an orphaned / incomplete pipeline. Reject the stop a few times (keep
// it alive to finish the merges FOREGROUND); if it still stops orphaned, drop a
// `needs-recovery` marker so the LAUNCHING SURFACE (main thread / chat-service, which IS
// re-invokable) re-runs <intent>recovery</intent>. Fail-open: any doubt or error accepts
// the stop, so this never wedges a legitimate completion.
import {
existsSync,
mkdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { createHookContext } from "./lib/context.mjs";
import { readAgentMeta } from "./lib/agent-meta.mjs";
import { getUnmergedTaskBranches, git } from "./lib/git.mjs";
import { sessionBranch, simpleBranch } from "./lib/topology.mjs";
const REJECT_LIMIT = 2; // reject at most twice, then allow + mark for recovery
let ctx;
try {
const raw = readFileSync(0, "utf8");
ctx = createHookContext(raw, "completion-invariant");
const payload = JSON.parse(raw);
// Only the orchestrator's stop is our concern. SubagentStop matchers do not reliably
// filter (see cleanup-worktree) and agent_type in the payload is empty, so identify
// via the sibling meta.json. Any doubt -> accept (never block a non-orchestrator stop).
const meta = readAgentMeta(payload);
if (!meta || meta.agentType !== "orchestrator") {
ctx.accept(`not orchestrator (${meta?.agentType || "unknown"})`);
}
// No session branch yet -> nothing could be orphaned.
const sessionRef = sessionBranch(ctx);
if (
git(["show-ref", "--verify", "--quiet", `refs/heads/${sessionRef}`])
.status !== 0
) {
ctx.accept("no session branch");
}
// Committed dev work not merged into the session branch. Reuse the promotion-safety
// helper (single source of truth, fail-closed). Exclude <short>/simple (it promotes
// straight to base, never into the session branch).
const unmerged = getUnmergedTaskBranches(ctx.sessionShort, sessionRef, [
simpleBranch(ctx),
]);
// Only an APPROVED-but-unmerged branch is "orphaned by a stall". A branch with no
// APPROVED review flag is a FAILED or still-in-review ticket, not our concern -
// flagging it would be a false positive on every failed-ticket run.
const orphaned = unmerged
.map((u) => u.branch)
.filter((br) => {
const taskId = br.split("/").pop(); // <short>/TASK-XXX -> TASK-XXX
return existsSync(
join(ctx.ticketsDir, "reviews", `${taskId}-quality-reviewer`),
);
});
if (orphaned.length === 0) {
clearRejects();
ctx.accept("no approved-but-unmerged work");
}
const list = orphaned.join(", ");
const rejects = readRejects();
if (rejects < REJECT_LIMIT) {
writeRejects(rejects + 1);
ctx.fail(
`Completion invariant: APPROVED work on [${list}] is NOT merged into ${sessionRef}. ` +
`You are a nested subagent and are never re-invoked by a background child, so finish ` +
`the pipeline NOW in this turn: dispatch the merger for these FOREGROUND ` +
`(run_in_background:false). Do not end your turn. (attempt ${rejects + 1}/${REJECT_LIMIT})`,
{ log: `reject ${rejects + 1}/${REJECT_LIMIT} orphaned=[${list}]` },
);
}
// Still orphaned after REJECT_LIMIT tries -> stop looping. Hand off to the launching
// surface: write a recovery marker and allow the stop.
writeRecoveryMarker(orphaned);
clearRejects();
ctx.accept(`needs-recovery written; orphaned=[${list}]`);
} catch (e) {
// Fail-open: our own error must never wedge a stop.
if (ctx) ctx.log(`error, accepting: ${String(e).slice(0, 140)}`);
process.exit(0);
}
// --- markers -----------------------------------------------------------------
function breakerFile() {
const dir = join(ctx.sessionDir, "breaker");
try {
mkdirSync(dir, { recursive: true });
} catch {
/* best effort */
}
return join(dir, "completion-invariant-rejects");
}
function readRejects() {
try {
return parseInt(readFileSync(breakerFile(), "utf8"), 10) || 0;
} catch {
return 0;
}
}
function writeRejects(n) {
try {
writeFileSync(breakerFile(), String(n));
} catch {
/* best effort */
}
}
function clearRejects() {
try {
unlinkSync(breakerFile());
} catch {
/* absent - fine */
}
}
function writeRecoveryMarker(branches) {
try {
writeFileSync(
join(ctx.sessionDir, "needs-recovery"),
JSON.stringify({
reason: "orphaned-task-branches",
branches,
at: new Date().toISOString(),
}) + "\n",
);
} catch {
/* best effort */
}
}

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env node
// PreToolUse(Agent) — force `developer` dispatches onto the session worktree that
// setup-worktree creates (WORKTREE_PATH in the prompt), forbid the Agent tool's
// own isolation:"worktree" (which spawns off-convention `worktree-agent-*`
// branches outside the session-branch topology — breaking Stage A merges and the
// migration diff baseline), and forbid run_in_background:true (STATE B is fully
// synchronous — a detached developer would let the orchestrator advance before
// the worktree is finished). Keeps the orchestrator on the STATE B dispatch
// template instead of improvising a free-form prompt.
//
// A developer dispatch on the <short>/simple branch (single-shot rollback /
// migration) is intentionally NOT gated here: setup-worktree derives its fixed
// <short>/simple worktree from the session, so those templates don't need a
// per-ticket WORKTREE_PATH (the per-ticket wave developer has no such fallback —
// its path is per-ticket and must be carried explicitly).
//
// The promotion-conflict-resolver is also a `developer` dispatch but is the one
// sanctioned exception that works in $REPO on `main` under the promote lock (see
// worktree-scope.md), so it carries no WORKTREE_PATH on purpose — exempt it, or
// this gate would block the only path that can finish a session->main promotion
// after a merge conflict.
import { readFileSync } from "node:fs";
import { createHookContext } from "./lib/context.mjs";
import { parseDispatch } from "./lib/dispatch-parse.mjs";
const input = JSON.parse(readFileSync(0, "utf8"));
const ctx = createHookContext(input, "enforce-dev-dispatch");
const d = parseDispatch(input);
if (d.subagentType !== "developer") process.exit(0);
if (d.role === "promotion-conflict-resolver") {
ctx.accept(
"promotion-conflict-resolver — operates in $REPO on main, no worktree",
);
}
// Single-shot simple flows run on the fixed <short>/simple worktree that setup-worktree
// derives from the session; their templates need no per-ticket WORKTREE_PATH, so
// skip the per-ticket gate below.
if (/\/simple$/.test(d.branchName) || /\/simple$/.test(d.worktreePath)) {
ctx.accept(
"single-shot simple developer (<short>/simple) — fixed simple worktree",
);
}
if (d.isolation === "worktree") {
ctx.fail(
"developer must NOT use isolation:worktree — setup-worktree already creates <WORKTREE_BASE>/TASK-XXX. Drop isolation and pass WORKTREE_PATH + BRANCH_NAME in the prompt (STATE B template).",
{ log: "BLOCK isolation=worktree" },
);
}
if (d.runInBackground) {
ctx.fail(
"developer must NOT be dispatched with run_in_background:true — STATE B drives every wave synchronously in the foreground (a foreground Agent call blocks until the subagent returns). A detached developer would let the orchestrator advance to review/merge before the worktree is finished. Drop run_in_background.",
{ log: "BLOCK run_in_background" },
);
}
if (!d.worktreePath) {
ctx.fail(
"developer dispatch prompt is missing 'WORKTREE_PATH: <WORKTREE_BASE>/TASK-XXX' (and BRANCH_NAME). Use the STATE B dispatch template verbatim instead of a free-form prompt.",
{ log: "BLOCK missing WORKTREE_PATH" },
);
}
process.exit(0);

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env node
// SessionStart: ensure the Playwright MCP browser is provisioned so the harness
// feature-smoke (quality-reviewer MODE: feature-smoke) can DRIVE the app itself.
//
// The server is already wired: .mcp.json declares it (node
// node_modules/@playwright/mcp/cli.js) and the developer / quality-reviewer agents
// carry the mcp__playwright__browser_* tools. The only failure mode seen in the wild
// is a partial `npm install` that never pulled `@playwright/mcp` (or its chromium),
// which silently degrades feature-smoke to static checks. This hook detects that and
// auto-repairs it, so the demo-smoke works without a human running `make install`.
//
// An MCP server connects at SESSION START, before any hook finishes, so a synchronous
// install here could NOT connect the server for THIS session anyway. The right move is
// therefore: detect fast, kick off `make install-playwright-browsers` DETACHED in the
// background (logged), and warn that feature-smoke degrades this session and will work
// the next one. Fast when present (the 99% case), never blocks startup, never throws.
import { existsSync, statSync } from "node:fs";
import { readFileSync } from "node:fs";
import { spawn } from "node:child_process";
import { join } from "node:path";
import { createHookContext } from "./lib/context.mjs";
let input = {};
try {
input = JSON.parse(readFileSync(0, "utf8"));
} catch {
process.exit(0); // no payload -> nothing to do
}
const ctx = createHookContext(input, "ensure-playwright-mcp");
const repo = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const mcpCli = join(repo, "node_modules", "@playwright", "mcp", "cli.js");
// Present -> nothing to do. Fast path, no output (avoid per-session noise).
if (existsSync(mcpCli)) {
ctx.log("playwright MCP present");
process.exit(0);
}
// A global lock so parallel sessions don't launch duplicate installs. A fresh lock
// (< 30 min) means an install is already running; just warn, don't relaunch.
const lock = "/tmp/atomic-crm-playwright-mcp-install.lock";
let installing = false;
try {
if (
existsSync(lock) &&
Date.now() - statSync(lock).mtimeMs < 30 * 60 * 1000
) {
installing = true;
}
} catch {
// ignore -> treat as not installing
}
const installLog = join(ctx.sessionDir, "playwright-mcp-install.log");
if (!installing && process.env.PLAYWRIGHT_MCP_CHECK_ONLY !== "1") {
try {
// Detached: returns immediately, survives this hook's exit. `make
// install-playwright-browsers` runs `npm install` then `npx playwright install
// chromium chromium-headless-shell`; provisions both the MCP package and browsers.
const cmd = `touch "${lock}"; make -C "${repo}" install-playwright-browsers > "${installLog}" 2>&1; rm -f "${lock}"`;
const child = spawn("bash", ["-lc", cmd], {
detached: true,
stdio: "ignore",
});
child.unref();
ctx.log(
`playwright MCP missing -> background install started, log=${installLog}`,
);
} catch (e) {
ctx.log(
`playwright MCP missing -> could not start install: ${e?.message ?? e}`,
);
}
} else {
ctx.log("playwright MCP missing -> install already in progress");
}
// Warn the session: the MCP cannot connect mid-session, so feature-smoke degrades to
// static checks THIS run; it will drive the browser once the install completes.
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext:
`<playwright-mcp-status>NOT INSTALLED. The Playwright MCP browser is being ` +
`provisioned in the background (log: ${installLog}). For THIS session the harness ` +
`feature-smoke (MODE: feature-smoke) cannot drive the UI and will fall back to ` +
`static/compile checks; the Supabase e2e-smoke (CLI Playwright) is unaffected. It ` +
`will be available next session. To provision now: run \`make install-playwright-browsers\`.` +
`</playwright-mcp-status>`,
},
}) + "\n",
);
process.exit(0);

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env node
// PreToolUse(Agent) - force the orchestrator's pipeline dispatches to run FOREGROUND.
//
// Root cause (transcript forensics + a probe): the VS Code extension defaults the Agent
// tool to BACKGROUND, and a nested subagent (the orchestrator, spawnDepth >= 1) is NEVER
// re-invoked when a background child completes. So a background dispatch from the
// orchestrator ends its turn "to await a notification" that never comes - review / merge /
// promotion never run and finished dev work is orphaned.
// A probe confirmed explicit run_in_background:false DOES block (child output inline) while
// the default is async; CLI defaults to foreground, so the bug only ever bit the extension.
//
// Enforcement (the prose guard in orchestrator.md was ignored): DENY any orchestrator->child
// pipeline dispatch that is not explicitly run_in_background:false, forcing a re-dispatch
// with false. Detection is by the CHILD role - only the orchestrator dispatches these - so
// main->orchestrator (child=orchestrator) and the fire-and-forget documentator are never
// touched. Fail-open: any error or unrecognized shape allows the dispatch.
import { readFileSync } from "node:fs";
import { createHookContext } from "./lib/context.mjs";
import { parseDispatch } from "./lib/dispatch-parse.mjs";
import { pipelineRoleSet } from "./lib/teams.mjs";
// Pipeline roles whose result the orchestrator MUST consume in the same turn come
// from harness.config.json (config.roles `pipeline` flag), so this set and the
// one in block-duplicate-dispatch share one source. The orchestrator
// (main->orchestrator hop) and documentator (fire-and-forget) are excluded
// (pipeline:false): their background dispatch is correct.
const PIPELINE_ROLES = pipelineRoleSet();
let input;
try {
input = JSON.parse(readFileSync(0, "utf8"));
} catch {
process.exit(0); // no / unparseable payload -> allow
}
const ctx = createHookContext(input, "force-foreground-orchestrator-dispatch");
try {
const d = parseDispatch(input);
const childRole = PIPELINE_ROLES.has(d.subagentType)
? d.subagentType
: PIPELINE_ROLES.has(d.role)
? d.role
: "";
// Not an orchestrator->pipeline-child dispatch -> none of our business.
if (!childRole) ctx.accept("not a pipeline dispatch");
// Read run_in_background RAW: parseDispatch coerces absent->false, but the difference
// matters here - in the extension, ABSENT means the background default, which is the bug.
// Only an EXPLICIT false is safe.
const rib = input.tool_input?.run_in_background;
if (rib === false) ctx.accept(`${childRole} foreground (explicit false)`);
ctx.block({
reason:
`Nested orchestrator dispatch of "${childRole}" must be FOREGROUND. A nested subagent ` +
`is never re-invoked when a background child completes, so this dispatch would orphan ` +
`the pipeline (review/merge/promotion never run). Re-dispatch it with ` +
`run_in_background: false - verified to block and return the child's result inline in ` +
`this runtime.`,
log: `blocked ${childRole} rib=${rib === undefined ? "absent" : String(rib)}`,
});
} catch (e) {
// Never let this gate break a real dispatch.
ctx.log(`error, allowing: ${String(e).slice(0, 120)}`);
process.exit(0);
}

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env node
// PostToolUse(Write|Edit): format the file just written, using the formatter and
// extension list from harness.config.json's format-kind validation step. This is
// the human/main-thread convenience: harness subagents are SKIPPED because their
// SubagentStop validation chain already applies the format step (and would
// re-run it), so formatting their in-flight writes is redundant churn.
//
// Non-blocking (exit 1 on a formatter error): the Write already succeeded, so a
// formatting failure only warns.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { loadConfig, formatStep } from "./lib/config.mjs";
let input = {};
try {
input = JSON.parse(readFileSync(0, "utf8"));
} catch {
process.exit(0);
}
// Skip harness subagents (they carry an agent_type / runtime agent name);
// validate-on-stop covers them.
if (input.agent_type || process.env.CLAUDE_AGENT_NAME) process.exit(0);
const filePath = input.tool_input?.file_path;
if (!filePath) process.exit(0);
let step;
try {
step = formatStep(loadConfig());
} catch {
process.exit(0); // no readable config -> nothing to do
}
if (!step || !step.formatter) process.exit(0);
const exts = step.extensions ?? [];
if (exts.length && !exts.some((e) => filePath.endsWith(e))) process.exit(0);
const [bin, ...baseArgs] = step.formatter.split(/\s+/).filter(Boolean);
try {
execFileSync(bin, [...baseArgs, filePath], {
stdio: "ignore",
timeout: 15_000,
});
} catch (err) {
console.error(
`format-on-write: failed to format ${filePath}: ${err.message}`,
);
process.exit(1);
}
process.exit(0);

View File

@@ -0,0 +1,35 @@
// Read the sibling <agent>.meta.json for a SubagentStop payload.
//
// At SubagentStop the big transcript JSONL is often not flushed yet (a real
// race), but the <agent>.meta.json written at spawn already carries the
// stopping agent's identity. Three hooks (record-review-verdict, validate-on-stop,
// cleanup-worktree) need it to scope their work to the right agent/worktree, so
// the path derivation + parse lives here once instead of being copy-pasted.
//
// Returns { agentType, description } (both strings, possibly empty), or null
// when the meta is absent/unreadable — callers then fall back to transcript
// recovery.
import { existsSync, readFileSync } from "node:fs";
/**
* @param {Record<string, unknown>} payload Parsed SubagentStop payload.
* @returns {{ agentType: string, description: string } | null}
*/
export function readAgentMeta(payload) {
const tp = String(
(payload && (payload.agent_transcript_path || payload.transcript_path)) ||
"",
);
const metaPath = tp.replace(/\.jsonl$/, ".meta.json");
if (!metaPath.endsWith(".meta.json") || !existsSync(metaPath)) return null;
try {
const meta = JSON.parse(readFileSync(metaPath, "utf8"));
return {
agentType: String(meta.agentType || ""),
description: String(meta.description || ""),
};
} catch {
return null;
}
}

View File

@@ -0,0 +1,150 @@
// harness.config.json loader: the single project-facts manifest consumed by
// hooks/scripts at runtime (and by the renderer at sync time in a later phase).
// Stdlib only. Fail-closed on a malformed config (throws a clear Error); a
// MISSING file degrades to the built-in defaults so a config-less repo still
// runs a minimal harness.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { REPO } from "./paths.mjs";
export const CONFIG_FILENAME = "harness.config.json";
// Minimal safe baseline. The committed harness.config.json overrides these.
// Optional capabilities (deploy, app) are ABSENT here on purpose: a capability
// exists iff its block is present in the config.
const DEFAULTS = {
name: "harness",
layout: { src: "src", e2e: "e2e", adr: "adr" },
validation: { steps: [], extraForbidden: [] },
worktree: { provision: "npm-link" },
skills: { developerMenu: [] },
roles: {},
launcher: {
sessionDirEnv: "CHAT_SESSION_DIR",
turnSentinelDir: null,
postCheckoutScript: null,
logsDir: null,
},
};
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
// Deep-merge `override` onto `base`. Objects merge recursively; arrays and
// scalars from `override` REPLACE (never concatenate): a config that lists
// validation.steps fully replaces the default empty list.
function deepMerge(base, override) {
if (!isObject(base) || !isObject(override)) return override;
const out = { ...base };
for (const [k, v] of Object.entries(override)) {
out[k] = isObject(v) && isObject(base[k]) ? deepMerge(base[k], v) : v;
}
return out;
}
class ConfigError extends Error {}
// Throw a clear, prefixed error so a fail-closed consumer surfaces exactly what
// is wrong with the config.
function fail(msg) {
throw new ConfigError(`${CONFIG_FILENAME}: ${msg}`);
}
function validate(cfg) {
if (!isObject(cfg)) fail("top level must be a JSON object");
if (!isObject(cfg.validation) || !Array.isArray(cfg.validation.steps)) {
fail("`validation.steps` must be an array");
}
for (const [i, step] of cfg.validation.steps.entries()) {
if (!isObject(step)) fail(`validation.steps[${i}] must be an object`);
if (typeof step.id !== "string" || !step.id) {
fail(`validation.steps[${i}] needs a non-empty string \`id\``);
}
if (typeof step.kind !== "string" || !step.kind) {
fail(`validation.steps[${step.id}] needs a non-empty string \`kind\``);
}
const isVitest = step.runner === "vitest";
if (!isVitest && (typeof step.command !== "string" || !step.command)) {
fail(`validation.steps[${step.id}] needs a \`command\` (or runner:"vitest")`);
}
if (isVitest && (typeof step.config !== "string" || !Array.isArray(step.projects))) {
fail(`validation.steps[${step.id}] (vitest) needs \`config\` and \`projects[]\``);
}
}
if (!isObject(cfg.roles)) fail("`roles` must be an object");
for (const [name, role] of Object.entries(cfg.roles)) {
if (!isObject(role) || typeof role.model !== "string" || !role.model) {
fail(`roles.${name} needs a non-empty string \`model\``);
}
}
if ("deploy" in cfg && cfg.deploy !== undefined) {
if (!isObject(cfg.deploy) || !Array.isArray(cfg.deploy.relevantGlobs)) {
fail("`deploy.relevantGlobs` must be an array when `deploy` is present");
}
}
return cfg;
}
const cache = new Map();
/**
* Load and validate the harness config, merged over the defaults.
* @param {string} [repo] repo root holding harness.config.json (defaults to REPO)
* @returns {object} the validated, merged config
* @throws {Error} on malformed JSON or an invalid shape (fail-closed)
*/
export function loadConfig(repo = REPO) {
if (cache.has(repo)) return cache.get(repo);
const path = join(repo, CONFIG_FILENAME);
let merged;
if (!existsSync(path)) {
merged = validate(structuredClone(DEFAULTS));
} else {
let parsed;
try {
parsed = JSON.parse(readFileSync(path, "utf8"));
} catch (e) {
fail(`invalid JSON (${e.message})`);
}
merged = validate(deepMerge(DEFAULTS, parsed));
}
cache.set(repo, merged);
return merged;
}
// Test-only: drop the memoized config so a test can reload after writing a new
// file to the same repo path.
export function clearConfigCache() {
cache.clear();
}
// ---- Typed readers used by the config consumers ----------------
export const validationSteps = (cfg) => cfg.validation?.steps ?? [];
export const extraForbidden = (cfg) => cfg.validation?.extraForbidden ?? [];
export const isDeployEnabled = (cfg) => isObject(cfg.deploy);
export const deployGlobs = (cfg) => cfg.deploy?.relevantGlobs ?? [];
export const isAppSmokeEnabled = (cfg) => isObject(cfg.app);
export const worktreeProvision = (cfg) => cfg.worktree?.provision ?? "npm-link";
export const roleNames = (cfg) => Object.keys(cfg.roles ?? {});
export const roleModel = (cfg, role) => cfg.roles?.[role]?.model ?? "";
export const pipelineRoles = (cfg) =>
roleNames(cfg).filter((r) => cfg.roles[r]?.pipeline);
export const debounceRoles = (cfg) =>
roleNames(cfg).filter((r) => cfg.roles[r]?.debounce);
// Managed-launcher extension points (empty object when no launcher overlay).
export const launcher = (cfg) => cfg.launcher ?? {};
// The format-kind validation step (null when none), used by format-on-write.
export const formatStep = (cfg) =>
validationSteps(cfg).find((s) => s.kind === "format") ?? null;
// Steps a pre-PR / pre-push gate runs on the human path (fast checks only).
// `changedScoped` steps are EXCLUDED: they need a per-worktree diff base (the
// session branch) that the human push path lacks, and running their bare command
// whole-repo would lint/test far more than the change at hand.
export const prePrSteps = (cfg) =>
validationSteps(cfg).filter(
(s) => (s.kind === "typecheck" || s.kind === "lint") && !s.changedScoped,
);

View File

@@ -0,0 +1,170 @@
// Build a hook context from a hook's stdin payload: identity, session-scoped
// paths, a logger, and terminal accept/block/fail helpers. The `name` is the
// hook's label; log() and error() prefix every line with `[name]`, and
// accept/block/fail add the `ACCEPT|BLOCK|FAIL` verb on top — so call sites pass
// only the detail and never repeat the name.
import { appendFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { basename, dirname, isAbsolute, join } from "node:path";
import { decisionBlock } from "./io.mjs";
import { REPO, TMP_ROOT, sanitizePath } from "./paths.mjs";
import { exec } from "./process.mjs";
import { loadConfig, worktreeProvision } from "./config.mjs";
/**
* @param {string | Record<string, unknown>} input
* @param {string} [name]
* @returns {object}
*/
export function createHookContext(input, name = "hook") {
const i = typeof input === "string" ? JSON.parse(input) : input || {};
const clean = (s) => String(s ?? "").replace(/[\t\n]/g, " ");
const agentId = clean(i.agent_id);
// agentName: env var carries the suffixed runtime name (e.g. developer-TASK-001)
// in PostToolUse/PreToolUse contexts; for SubagentStart the env is the parent's,
// so fall back to i.agent_name which Claude Code populates with the child's name.
const agentName = process.env.CLAUDE_AGENT_NAME || clean(i.agent_name) || "";
const chatSessionId = process.env.CHAT_SESSION_DIR ? basename(process.env.CHAT_SESSION_DIR) : "";
const sessionId =
clean(i.session_id) || process.env.CLAUDE_CODE_SESSION_ID || chatSessionId || "default";
const agentType = clean(i.agent_type) || agentName;
const sessionShort = sessionId.split("-")[0];
const sessionDir = join(TMP_ROOT, sanitizePath(REPO), sessionId);
const logFile = join(sessionDir, "hooks.log");
/**
* @param {...unknown} parts
* @returns {void}
*/
const log = (...parts) => {
try {
mkdirSync(dirname(logFile), { recursive: true });
appendFileSync(
logFile,
`[${new Date().toISOString()}] [${name}] ${parts.join(" ")}\n`,
);
} catch {
// logging must never break a hook
}
};
/**
* Write to stderr (the agent-visible channel), prefixed with `[${name}]` the
* same way log() prefixes each log line. The prefix is added once, so
* multi-line payloads (compiler/test output) stay readable; a trailing
* newline is ensured.
* @param {...unknown} parts
* @returns {void}
*/
const error = (...parts) => {
const text = parts.join(" ");
process.stderr.write(`[${name}] ${text}${text.endsWith("\n") ? "" : "\n"}`);
};
const verdict = (verb, detail) => log(detail ? `${verb} ${detail}` : verb);
// The `npm-link` provisioning builtin: mirror $REPO/node_modules into the
// worktree. See provisionWorktree below for the config-driven dispatcher.
const linkNodeModules = (wt) => {
const target = join(wt, "node_modules");
if (existsSync(target)) return;
const source = join(REPO, "node_modules");
if (!existsSync(source)) return;
// Fast path: hardlink tree (cp -al) when the worktree base shares a
// filesystem with the repo. In dev containers /tmp and /workspaces are
// different mounts (cross-device), so cp -al fails; fall back to a full
// real copy. A symlinked node_modules is NOT an option: vitest browser
// mode (Chromium) hangs on it. See memory worktree-node-modules-provisioning.
if (exec("cp", ["-al", source, target]).status === 0) return;
rmSync(target, { recursive: true, force: true });
if (exec("cp", ["-a", source, target]).status !== 0) {
rmSync(target, { recursive: true, force: true });
throw new Error(`node_modules provisioning failed for ${wt} - cp -al and cp -a both failed`);
}
};
// Config-driven worktree provisioning (config.worktree.provision): the
// `npm-link` builtin (default, mirrors node_modules), `none` (no provisioning,
// for a stack that needs none), or a script path (run with the worktree as its
// one argument). Removes the npm assumption from worktree setup. Fail-closed on
// a provision-script error, like the npm-link builtin.
const provisionWorktree = (wt) => {
let mode = "npm-link";
try {
mode = worktreeProvision(loadConfig());
} catch {
mode = "npm-link";
}
if (mode === "none") {
log(`provision skipped (none) wt=${wt}`);
return;
}
if (mode === "npm-link") {
linkNodeModules(wt);
return;
}
const script = isAbsolute(mode) ? mode : join(REPO, mode);
const r = exec("bash", [script, wt], { cwd: REPO });
if (r.status !== 0) {
throw new Error(
`worktree provision script failed (${script}): ${(r.stderr || r.stdout || "").slice(0, 300)}`,
);
}
log(`provisioned via script ${script} wt=${wt}`);
};
return {
name,
repo: REPO,
sessionId,
agentType,
agentName,
agentId,
sessionShort,
sessionDir,
worktreeBase: sessionDir,
logFile,
ticketsDir: process.env.TICKETS_DIR || join(sessionDir, "tickets"),
log,
error,
/**
* @param {string} [detail]
* @returns {never}
*/
accept(detail) {
verdict("ACCEPT", detail);
process.exit(0);
},
/**
* @param {{ reason: string, log?: string }} options
* @returns {never}
*/
block({ reason, log: detail }) {
verdict("BLOCK", detail);
decisionBlock(reason);
process.exit(0);
},
/**
* @param {string} message
* @param {{ log?: string }} [options]
* @returns {never}
*/
fail(message, { log: detail } = {}) {
verdict("FAIL", detail);
error(message);
process.exit(2);
},
// Worktree provisioning: the config-driven dispatcher plus the npm-link
// builtin it wraps (both defined as closures above).
provisionWorktree,
linkNodeModules,
};
}

View File

@@ -0,0 +1,42 @@
// Parse a PreToolUse(Agent) hook payload: the dispatch tool_input plus the
// `KEY: value` contract carried in the dispatch prompt (orchestrator STATE B
// templates). Every Agent-gating hook (setup-worktree, enforce-dev-dispatch,
// block-merger-without-review, block-promote-unmerged) parses through here, so
// the contract regexes live in exactly one place.
//
// TASK_ID is anchored at line start and only accepts TASK-<n> | SIMPLE | MIGRATION
// | PROMOTE | ROLLBACK, so prose mentioning another ticket (e.g. "TASK-001 is
// merged; now merge this one") can never mis-key a gate.
/**
* @param {Record<string, unknown>} input Parsed PreToolUse(Agent) payload.
* @returns {object} Dispatch fields used by the Agent-gating hooks.
*/
export function parseDispatch(input) {
const ti = (input && input.tool_input) || {};
const prompt = String(ti.prompt ?? "");
const grab = (re) => {
const m = prompt.match(re);
return m ? m[1] : "";
};
return {
// "" for the main orchestrator; the agent's own type for a subagent-issued
// dispatch (none of the ticket agents can dispatch, so this is informational).
callerAgentType: String(input?.agent_type ?? ""),
subagentType: String(ti.subagent_type ?? ""),
name: String(ti.name ?? ""),
isolation: String(ti.isolation ?? ""),
runInBackground: Boolean(ti.run_in_background),
role: grab(/^ROLE:\s*(\S+)/m),
taskId: grab(/^TASK_ID:\s*(TASK-\d+|SIMPLE|MIGRATION|PROMOTE|ROLLBACK)\b/m),
worktreePath: grab(/^WORKTREE_PATH:\s*(\S+)/m),
branchName: grab(/^BRANCH_NAME:\s*(\S+)/m),
mode: grab(/^MODE:\s*(\S+)/m),
// STAGE: a-only marks a merger dispatch as Stage A ONLY (no Stage B / promotion),
// even in SIMPLE / MIGRATION mode. Read by record-merger-stage to authorize promotion.
stage: grab(/^STAGE:\s*(\S+)/m),
sessionShortId: grab(/^SESSION_SHORT_ID:\s*(\S+)/m),
ticketFile: grab(/^TICKET_FILE:\s*(\S+)/m),
ticketsDir: grab(/^TICKETS_DIR:\s*(\S+)/m),
};
}

88
.claude/hooks/lib/git.mjs Normal file
View File

@@ -0,0 +1,88 @@
// Git repo and worktree state queries.
import { REPO } from "./paths.mjs";
import { exec } from "./process.mjs";
export function git(args = [], opts = {}) {
return exec("git", ["-C", REPO, ...args], opts);
}
export function getBaseBranch() {
const r = git(["symbolic-ref", "--short", "HEAD"]);
return (r.status === 0 ? r.stdout.trim() : "") || "main";
}
const WORKTREE_PREFIX = "worktree ";
const BRANCH_PREFIX = "branch refs/heads/";
// Value of the first line carrying `prefix`, stripped of it; "" when absent.
const lineValue = (lines, prefix) => {
const line = lines.find((l) => l.startsWith(prefix));
return line ? line.slice(prefix.length) : "";
};
// `--porcelain` emits one blank-line-separated block per worktree, each opening
// with a `worktree <path>` line and (unless detached/bare) a `branch refs/...`
// line. Parse each block independently; drop blocks with no path (trailing blank).
export function getWorktreeEntries() {
return git(["worktree", "list", "--porcelain"]).stdout
.split("\n\n")
.map((block) => block.split("\n"))
.map((lines) => ({
path: lineValue(lines, WORKTREE_PREFIX),
branch: lineValue(lines, BRANCH_PREFIX),
}))
.filter((entry) => entry.path);
}
export const getWorktreePaths = () => getWorktreeEntries().map((e) => e.path);
// Combined per-worktree change summary in a single `git status --porcelain`
// (working tree) + one `git log` (commits ahead of base) + one `git diff
// --name-only` (committed file names). Returns { dirty, changedFiles } so callers
// (validate-on-stop) get both signals without spawning `git status` twice.
export function getWorktreeChangeSummary(wt, base) {
const nonEmpty = (lines) => lines.split("\n").filter((l) => l.trim());
const statusPath = (line) => line.trim().split(/\s+/).pop();
const working = exec("git", ["-C", wt, "status", "--porcelain"]).stdout;
const ahead = exec("git", ["-C", wt, "log", "--oneline", `${base}..HEAD`]).stdout.trim();
const committed = exec("git", ["-C", wt, "diff", "--name-only", `${base}..HEAD`]).stdout;
const changedFiles = [
...new Set([
...nonEmpty(committed).map((l) => l.trim()),
...nonEmpty(working).map(statusPath),
]),
];
return { dirty: Boolean(working.trim() || ahead), changedFiles };
}
// Task branches under refs/heads/<short>/ that still have commits NOT merged into
// `sessionRef`. The single source of truth for the promotion-safety check
// (block-promote-unmerged.mjs); the orchestrator's STATE B reconcile Bash
// mirrors it and must stay aligned. Fails CLOSED: a failed/unparseable rev-list
// count is reported as unmerged (count: null) rather than silently treated as
// merged. `exclude` simple branches that legitimately never merge into the session
// branch (e.g. <short>/simple, which promotes straight to main).
export function getUnmergedTaskBranches(short, sessionRef, exclude = []) {
const branches = git([
"for-each-ref",
"--format=%(refname:short)",
`refs/heads/${short}`,
])
.stdout.split("\n")
.map((s) => s.trim())
.filter(Boolean)
.filter((b) => !exclude.includes(b));
const unmerged = [];
for (const br of branches) {
const r = git(["rev-list", "--count", `${sessionRef}..${br}`]);
const n = Number(r.stdout.trim());
if (r.status !== 0 || !Number.isFinite(n)) {
unmerged.push({ branch: br, count: null }); // fail closed
} else if (n > 0) {
unmerged.push({ branch: br, count: n });
}
}
return unmerged;
}

5
.claude/hooks/lib/io.mjs Normal file
View File

@@ -0,0 +1,5 @@
// Emit a PreToolUse "block" decision on stdout — the channel Claude Code reads to
// deny a tool call while surfacing `reason` to the agent.
export function decisionBlock(reason) {
process.stdout.write(JSON.stringify({ decision: "block", reason }) + "\n");
}

View File

@@ -0,0 +1,24 @@
import { join } from "node:path";
import { exec } from "./process.mjs";
// APP_DIR / CLAUDE_PROJECT_DIR override the detected root (used by hook tests).
function getRepo() {
if (process.env.APP_DIR) return process.env.APP_DIR;
if (process.env.CLAUDE_PROJECT_DIR) return process.env.CLAUDE_PROJECT_DIR;
const top = exec("git", ["rev-parse", "--show-toplevel"]);
if (top.status === 0 && top.stdout.trim()) return top.stdout.trim();
return process.cwd();
}
export const REPO = getRepo();
export const CONFIG_DIR =
process.env.CLAUDE_CONFIG_DIR || join(process.env.HOME || "/root", ".claude");
// HARNESS_TMP_ROOT is the neutral name; CRM_TMP_ROOT is the deprecated fallback
// kept for one release so existing launchers / tests keep working.
export const TMP_ROOT =
process.env.HARNESS_TMP_ROOT || process.env.CRM_TMP_ROOT || "/tmp";
export function sanitizePath(p) {
return String(p ?? "").replace(/\//g, "_");
}

View File

@@ -0,0 +1,15 @@
import { spawnSync } from "node:child_process";
export function exec(file, args = [], opts = {}) {
const r = spawnSync(file, args, { encoding: "utf8", ...opts });
return {
status: r.status ?? (r.error ? 1 : 0),
stdout: r.stdout ?? "",
stderr: r.stderr ?? "",
error: r.error,
};
}
export function bash(cmd, opts = {}) {
return exec("bash", ["-c", cmd], opts);
}

View File

@@ -0,0 +1,32 @@
// Append a timestamped line to the #technical-harness progress log, but ONLY when
// that log already exists.
//
// Its existence is the technical-run gate: the orchestrator creates
// <sessionDir>/harness-progress.log under PERSONA: technical, render-status reads it,
// and a web-chat / normal run has none. Enrichers (validate-on-stop) call this to
// fill the otherwise-silent stretches BETWEEN the orchestrator's milestones, e.g.
// the multi-minute validation chain a developer runs on stop, so a `tail -f`, the
// board's "Recent activity", or a Monitor shows what is happening instead of going
// dark. Best-effort: logging must never break a run, and it never CREATES the log
// (creating it would leak a board into a non-technical run).
import { appendFileSync, existsSync } from "node:fs";
import { join } from "node:path";
/**
* @param {string} sessionDir The harness session dir (ctx.sessionDir).
* @param {string} line Event text; a `HH:MM:SS ` UTC stamp is prepended to match
* the orchestrator's own lines.
* @returns {boolean} true if a line was written, false if the log was absent/unwritable.
*/
export function appendProgress(sessionDir, line) {
try {
const file = join(sessionDir, "harness-progress.log");
if (!existsSync(file)) return false; // not a technical run -> stay inert
const stamp = new Date().toISOString().slice(11, 19); // HH:MM:SS UTC
appendFileSync(file, `${stamp} ${line}\n`);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,31 @@
// Per-ticket review verdict flags. The presence of a flag means that reviewer
// returned APPROVED for the ticket. PRIMARY writer is the quality-reviewer agent
// itself — it touches the flag via Bash as its last action before emitting the
// contract line (see quality-reviewer.md), so the verdict is recorded
// synchronously and never depends on a post-stop transcript read. FALLBACK writer
// is record-review-verdict.mjs (SubagentStop), kept as belt-and-suspenders for the
// case the reviewer forgot the touch. Read by block-merger-without-review.mjs
// (PreToolUse/Agent), cleared on REJECTED and when a developer is (re)dispatched
// (setup-worktree.mjs) so a changed diff invalidates stale approvals. Session-scoped
// under <sessionDir>/reviews, mirroring the <sessionDir>/flags convention.
//
// Path source: the quality-reviewer agent (PRIMARY writer) derives the flag dir
// from its ticket file — `$(dirname TICKET_FILE)/reviews` — i.e. under the
// `<session_dir>` it was handed (TICKETS_DIR == <session_dir>, see orchestrator.md).
// On a managed launcher (CRM Builder's chat-service) that `<session_dir>` is
// `CHAT_SESSION_DIR`, which is NOT the `/tmp/<repo>/<id>` path ctx.sessionDir
// recomputes — so the reader/clearer/fallback below must key off CHAT_SESSION_DIR
// when present, or they look in the wrong dir and the synchronous verdict is lost
// (block-merger then blocks on a phantom "no APPROVED"). With no managed launcher,
// CHAT_SESSION_DIR is unset and ctx.sessionDir already equals <session_dir> (the
// session-bootstrap hook injects exactly that), so this is a no-op there.
import { join } from "node:path";
export const REVIEW_ROLES = ["quality-reviewer"];
export const reviewsDir = (ctx) =>
join(process.env.CHAT_SESSION_DIR || ctx.sessionDir, "reviews");
export const reviewFlag = (ctx, taskId, role) =>
join(reviewsDir(ctx), `${taskId}-${role}`);

View File

@@ -0,0 +1,121 @@
// In-flight detection for a harness session, keyed STRICTLY off the current
// session's own id (ctx.sessionShort / ctx.sessionDir). Because Claude Code
// reuses the same session id when a conversation is resumed (verified: a single
// transcript spans multi-hour close/reopen gaps under one id), the same session
// recomputes the same namespace on resume — so "is THIS session mid-harness?"
// is answerable from disk + git alone, with no global state. Two concurrent
// sessions have distinct ids => distinct namespaces => this never crosses them,
// which is what makes the resume machinery multi-session safe by construction.
//
// Consumers: session-bootstrap (SessionStart banner offering resume) and
// cleanup-session (SessionEnd guard that preserves an in-flight session's state
// instead of deleting it). STATE RECOVERY in the orchestrator does the precise
// re-classification; this only answers "worth resuming, and roughly where".
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { REPO } from "./paths.mjs";
import { exec } from "./process.mjs";
import { getBaseBranch, git } from "./git.mjs";
import { sessionBranch } from "./topology.mjs";
const TICKET_RE = /^TASK-\d+\.json$/;
// Tickets live in the session dir itself or a `tickets/` subdir - try both, same
// as render-status.mjs. Unreadable JSON counts as a ticket (status "unreadable"),
// so a corrupt file never silently downgrades an in-flight session to "done".
function readTickets(ctx) {
for (const dir of [ctx.ticketsDir, ctx.sessionDir]) {
if (!existsSync(dir)) continue;
let files;
try {
files = readdirSync(dir).filter((f) => TICKET_RE.test(f));
} catch {
continue;
}
if (!files.length) continue;
return files.map((f) => {
try {
return JSON.parse(readFileSync(join(dir, f), "utf8"));
} catch {
return { status: "unreadable" };
}
});
}
return [];
}
const verifyRef = (ref) =>
git(["rev-parse", "--verify", "--quiet", ref]).status === 0;
// The base branch this session forked from: setup-worktree records it in
// .git/config (sessionbase.<short>.branch); fall back to the repo's base branch.
function forkBaseBranch(ctx) {
const r = git(["config", "--get", `sessionbase.${ctx.sessionShort}.branch`]);
return (r.status === 0 && r.stdout.trim()) || getBaseBranch();
}
// A deploy-time migration still pending after promotion. Best-effort: any
// non-zero exit (including 3 = undetermined) is read as "nothing pending", so a
// missing deploy adapter or an odd state never fabricates a migration gate.
function pendingDeploy(ctx) {
const script = join(REPO, ".claude", "scripts", "pending-deploys.mjs");
if (!existsSync(script)) return false;
// Bounded: a hung probe must never wedge the SessionStart/SessionEnd hooks.
// On timeout spawnSync sets an error → non-zero status → read as "nothing
// pending" (the pre-promotion preserve conditions already covered the risky
// windows; a promoted branch is git-recoverable regardless).
const r = exec(
"node",
[script, "--app", REPO, "--session", ctx.sessionShort],
{ timeout: 10000 },
);
return r.status === 0 && Boolean(r.stdout.trim());
}
/**
* @param {object} ctx hook context (createHookContext)
* @param {{ checkDeploy?: boolean }} [opts] run the deploy-gate probe (default true)
* @returns {{ inflight: boolean|null, phase: string|null }}
* inflight true = resume it; false = nothing/finished; null = undetermined
* (an error mid-probe) so callers can fail safe. `phase` is a human hint.
*/
export function detectInflight(ctx, { checkDeploy = true } = {}) {
try {
const short = ctx.sessionShort;
if (!short || short === "default") return { inflight: false, phase: null };
const tickets = readTickets(ctx);
const hasTickets = tickets.length > 0;
const allMerged =
hasTickets && tickets.every((t) => t.status === "merged");
const sessRef = sessionBranch(ctx); // session/<short>
const branchExists = verifyRef(`refs/heads/${sessRef}`);
// Tickets created but not all merged: plan gate leaves ONLY the tickets on
// disk (no session branch yet); once dev started it is a mid-wave stop.
if (hasTickets && !allMerged) {
return { inflight: true, phase: branchExists ? "mid-wave" : "plan gate" };
}
// No session branch and nothing unmerged: nothing was forked yet.
if (!branchExists) return { inflight: false, phase: null };
// Session branch exists. Promoted into its fork base yet?
const base = forkBaseBranch(ctx);
const promoted =
verifyRef(`refs/heads/${base}`) &&
git(["merge-base", "--is-ancestor", sessRef, base]).status === 0;
if (!promoted) {
return { inflight: true, phase: "waves done, awaiting promotion" };
}
// Promoted: only the deploy-time migration can still be pending.
if (checkDeploy && pendingDeploy(ctx)) {
return { inflight: true, phase: "migration gate" };
}
return { inflight: false, phase: null };
} catch {
return { inflight: null, phase: null };
}
}

View File

@@ -0,0 +1,53 @@
// Agent identity helpers — the single source for every hook that gates on who
// an agent is.
import { loadConfig, pipelineRoles, debounceRoles, roleNames } from "./config.mjs";
export const getFirstTaskId = (text) => (String(text ?? "").match(/TASK-[0-9]+/) || [])[0] || "";
// Role SETS come from harness.config.json (config.roles: `pipeline` / `debounce`
// flags), so force-foreground and block-duplicate-dispatch no longer hardcode
// their own copies. Fail-open to the built-in defaults when the config is
// missing or lists no roles (e.g. a config-less test repo), so a guard never
// silently empties out.
const DEFAULT_PIPELINE = [
"developer",
"quality-reviewer",
"merger",
"planner",
"simple-developer",
"test-writer",
];
const DEFAULT_DEBOUNCE = ["developer", "quality-reviewer", "merger"];
const setFromConfig = (reader, fallback) => {
try {
const roles = reader(loadConfig());
return new Set(roles.length ? roles : fallback);
} catch {
return new Set(fallback);
}
};
/** Roles the orchestrator must dispatch foreground and whose result it consumes inline. */
export const pipelineRoleSet = () => setFromConfig(pipelineRoles, DEFAULT_PIPELINE);
/** Roles whose identical re-dispatch is debounced (async-ack double dispatch). */
export const debounceRoleSet = () => setFromConfig(debounceRoles, DEFAULT_DEBOUNCE);
/** Every declared role name (config.roles keys); [] on a config error. */
export const configRoleNames = () => {
try {
return roleNames(loadConfig());
} catch {
return [];
}
};
// Agent-name role predicates. Match the bare role, a suffixed name
// (merger-TASK-003), or an @-qualified address.
export const isQualityReviewer = (name) => /^quality-reviewer([-@]|$)/.test(name || "");
export const isMerger = (name) => /^merger([-@]|$)/.test(name || "");
export const isDeveloper = (name) => /^developer([-@]|$)/.test(name || "");
// The orchestrator, including the chat- web variant and any runtime suffix
// (orchestrator-…). Single source for every gate that keys on "is this the
// orchestrator" (bash-guard guard-state rule, block-nested-orchestrator).
export const isOrchestrator = (name) => /^(chat-)?orchestrator([-@]|$)/.test(name || "");

View File

@@ -0,0 +1,25 @@
// Session worktree/branch topology — the single source for the layout created
// by setup-worktree and torn down by cleanup-worktree:
// <worktreeBase>/TASK-XXX on branch <short>/TASK-XXX (one per ticket)
// <worktreeBase>/simple on branch <short>/simple (single-shot rollback / migration)
// <worktreeBase>/_session on branch session/<short> (merger integration)
// session-base/<short> (fixed fork anchor)
// Every hook that builds or matches these names must go through this module.
import { join } from "node:path";
export const taskWorktreePath = (ctx, taskId) => join(ctx.worktreeBase, taskId);
export const simpleWorktreePath = (ctx) => join(ctx.worktreeBase, "simple");
export const sessionWorktreePath = (ctx) => join(ctx.worktreeBase, "_session");
export const taskBranch = (ctx, taskId) => `${ctx.sessionShort}/${taskId}`;
export const simpleBranch = (ctx) => `${ctx.sessionShort}/simple`;
export const sessionBranch = (ctx) => `session/${ctx.sessionShort}`;
export const sessionBaseBranch = (ctx) => `session-base/${ctx.sessionShort}`;
// Infrastructure worktrees are never removed by cleanup.
export const isInfraWorktreePath = (p) => p.endsWith("/_session") || p.endsWith("/simple");
// Session-lifetime branches are never deleted by cleanup.
export const isProtectedBranch = (b) => /^session(-base)?\//.test(b) || /\/simple$/.test(b);
// Directory names under <worktreeBase> that the leftover sweep may touch.
export const isTaskWorktreeDirName = (name) => /^TASK-[0-9]+$/.test(name) || name === "simple";

View File

@@ -0,0 +1,246 @@
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { getBaseBranch, getWorktreeChangeSummary, getWorktreePaths } from "./git.mjs";
import { bash, exec } from "./process.mjs";
import { loadConfig, validationSteps } from "./config.mjs";
import { appendProgress } from "./progress-log.mjs";
// `only` narrows to a single worktree when the caller already knows it
// (validate-on-stop scoping to the stopping agent's own task worktree);
// VALIDATE_WORKTREE is the test override. Either falls back to all session
// worktrees when the path is gone.
export function getActiveWorktrees(ctx, only = "") {
const narrowed = only || process.env.VALIDATE_WORKTREE || "";
if (narrowed && existsSync(narrowed)) return [narrowed];
return getWorktreePaths().filter((p) => p.startsWith(ctx.worktreeBase + "/"));
}
// Pure query — never exits. An empty list with a non-empty skipReason means
// there is nothing to validate.
function getWorktreesToValidate(ctx, { skipAdrOnly = false, only = "", base = "" } = {}) {
if (!existsSync(ctx.repo)) {
return { worktrees: [], skipReason: "cd_failed" };
}
const active = getActiveWorktrees(ctx, only);
if (active.length === 0) {
return { worktrees: [], skipReason: "no_active_worktree" };
}
// Compare against the branch the worktree forked from (the session branch,
// passed by validate-on-stop) so the change set is the ticket's OWN work, not
// everything since the repo's checked-out base branch — which would over- or
// under-report a worktree's delta whenever the base branch has diverged from
// the session branch. Falls back to the repo base branch when no override.
const effectiveBase = base || getBaseBranch();
const isAdrOnly = (files) => files.length > 0 && files.every((f) => f.startsWith("adr/"));
const worktrees = active.filter((wt) => {
const { dirty, changedFiles } = getWorktreeChangeSummary(wt, effectiveBase);
if (!dirty) {
ctx.log(`SKIP wt=${wt} (no changes)`);
return false;
}
if (skipAdrOnly && isAdrOnly(changedFiles)) {
ctx.log(`SKIP wt=${wt} (adr-only)`);
return false;
}
return true;
});
return { worktrees, skipReason: worktrees.length === 0 ? "no_dirty_worktree" : "" };
}
const tailLines = (text, n) => text.split("\n").slice(-n).join("\n");
// Last `n` lines of a file, "" if it can't be read.
const tailFile = (path, n) => {
try {
return tailLines(readFileSync(path, "utf8"), n);
} catch {
return "";
}
};
// Best-effort cleanup — a leftover temp file must never break validation.
const tryUnlink = (path) => {
try {
unlinkSync(path);
} catch {
/* swallow error */
}
};
function runVitest(wt, configFile, projects = [], changedSince = "") {
const projectTag = projects.length ? `-${projects.join("-")}` : "";
const out = join(tmpdir(), `vitest-${basename(configFile)}${projectTag}-${process.pid}.out`);
const projectFlags = projects.map((p) => `--project ${p}`).join(" ");
// Scope to tests related to THIS worktree's diff since `changedSince` (the session
// branch it forked from), so a ticket runs only its own affected tests, not the whole
// suite. Pass the ref EXPLICITLY: the ticket's work is committed by stop time,
// so a bare `--changed` (uncommitted-only) would find nothing and run zero tests (false
// green). Empty ref -> full run (safe fallback). The end-of-feature smoke re-runs the
// full suite on the integrated session branch, catching anything the module graph missed.
const changedFlag = changedSince ? `--changed ${changedSince}` : "";
const r = bash(
`CI=true timeout 180 npx vitest run --config ${configFile} ${projectFlags} ${changedFlag} > "${out}" 2>&1`,
{ cwd: wt },
);
const output = tailFile(out, 40);
tryUnlink(out);
return { status: r.status, output, timedOut: r.status === 124 };
}
// Files this worktree changed that lint should check: the config `extensions`,
// restricted to paths that still exist on disk. A deleted / renamed-away file
// must not be handed to eslint (it would error "No files matching"). Pure —
// unit-tested independently of the shell-out.
export function scopedLintFiles(changedFiles, cwd, extensions) {
return changedFiles.filter(
(f) => extensions.some((e) => f.endsWith(e)) && existsSync(join(cwd, f)),
);
}
// Run eslint on ONLY this worktree's diff since `base` (the session branch it
// forked from), mirroring the vitest `--changed` scoping: a ticket is judged on
// its own changed files, never the whole repo — which also sidesteps pre-existing
// lint noise in unrelated / generated files. Zero matching files -> nothing to
// lint (ok). `base` empty -> repo base branch, matching getWorktreesToValidate.
// `command` is the eslint invocation prefix (e.g. `npx eslint`); files are
// appended here, so the config command must NOT carry its own file glob.
function runEslintScoped(cwd, base, command, extensions, tail) {
const { changedFiles } = getWorktreeChangeSummary(cwd, base || getBaseBranch());
const files = scopedLintFiles(changedFiles, cwd, extensions);
if (files.length === 0) return { ok: true };
const out = join(tmpdir(), `eslint-${process.pid}.out`);
const args = files.map((f) => `'${f.replace(/'/g, `'\\''`)}'`).join(" ");
const r = bash(`${command} ${args} > "${out}" 2>&1`, { cwd });
const output = tailFile(out, tail);
tryUnlink(out);
return r.status === 0 ? { ok: true } : { ok: false, output };
}
// Per-kind tail length for failure output (mechanical, not a project fact).
const TAIL_LINES = { format: 15, typecheck: 20, lint: 30, unit: 40, e2e: 50 };
// A step is skipped when its `condition` is not met. `pathExists` gates on a
// path under the repo (e.g. the supabase/functions unit-fn gate); `modeNot`
// skips when MODE equals the given value (e.g. e2e in demo mode).
function stepSkipped(ctx, step) {
const c = step.condition;
if (!c) return false;
if (c.pathExists && !existsSync(join(ctx.repo, c.pathExists))) {
ctx.log(`SKIP ${step.id} (no ${c.pathExists})`);
return true;
}
if (c.modeNot && (process.env.MODE || "demo") === c.modeNot) {
ctx.log(`${step.id} skipped (${c.modeNot} mode)`);
return true;
}
return false;
}
// Run ONE config-declared step in `cwd`. Returns { ok } or { ok:false, output }.
// The step id is the caller's fail-report label, so config ids (prettier,
// typecheck, unit-app, unit-fn, e2e) ARE the reported step names.
function runStep(ctx, step, { cwd, base }) {
const tail = TAIL_LINES[step.kind] ?? 40;
if (step.runner === "vitest") {
const r = runVitest(cwd, step.config, step.projects, step.changedScoped ? base : "");
if (r.timedOut) return { ok: false, output: "TIMEOUT (>180s) -- vitest did not exit. Tests may be hanging." };
return r.status === 0 ? { ok: true } : { ok: false, output: r.output };
}
if (step.kind === "lint" && step.changedScoped) {
const exts = step.extensions ?? [".ts", ".tsx", ".mjs"];
return runEslintScoped(cwd, base, step.command, exts, tail);
}
const r = bash(`${step.command} 2>&1`, { cwd });
if (r.status === 0) {
if (step.kind === "format" && step.autoCommit) {
if (exec("git", ["-C", cwd, "diff", "--quiet"]).status !== 0) {
exec("git", ["-C", cwd, "add", "-A"]);
exec("git", ["-C", cwd, "commit", "-m", `style(${basename(cwd)}): auto-apply prettier`]);
ctx.log(`auto-prettier committed wt=${cwd}`);
}
}
return { ok: true };
}
const detail =
step.kind === "format"
? "Prettier could not format one or more files (likely a syntax error). Fix the issue and commit.\n" +
tailLines(r.stdout, tail)
: tailLines(r.stdout, tail);
return { ok: false, output: detail };
}
// The full validation chain, run by validate-on-stop.mjs on SubagentStop (after
// every developer stop). Steps are declared in harness.config.json's
// `validation.steps` (single source of truth, shared with bash-guard) and run in
// config order: steps without `cwd:"repo"` run per dirty worktree (fail-fast);
// `cwd:"repo"` steps (e2e) run once in the repo after the per-worktree pass.
// VALIDATE_DRY_RUN=1 skips everything, =fail simulates a failure. A malformed
// config fails closed. Returns { ok:true, skipReason? } or { ok:false, step, output }.
export function runValidationSteps(ctx, { worktree = "", base = "" } = {}) {
if (process.env.VALIDATE_DRY_RUN === "1") {
ctx.log("DRY_RUN=1, skipping validation");
return { ok: true, skipReason: "dry_run" };
}
if (process.env.VALIDATE_DRY_RUN === "fail") {
ctx.log("DRY_RUN=fail, simulating a failure");
return { ok: false, step: "dry-run", output: "Validation failed (simulated)." };
}
let steps;
try {
steps = validationSteps(loadConfig(ctx.repo));
} catch (e) {
// Fail closed: a malformed config must block the stop, not silently pass.
return { ok: false, step: "config", output: `${e.message}\n` };
}
const { worktrees, skipReason } = getWorktreesToValidate(ctx, {
skipAdrOnly: true,
only: worktree,
base,
});
if (skipReason) return { ok: true, skipReason };
const perWorktree = steps.filter((s) => s.cwd !== "repo");
const repoLevel = steps.filter((s) => s.cwd === "repo");
// Enrich the #technical-harness progress log with each validation step as it runs,
// so the otherwise-silent multi-minute chain (typecheck, lint, vitest, e2e) shows up
// in a `tail -f` / the board / a Monitor. Inert (no-op) on a non-technical run: the
// log does not exist there, so appendProgress writes nothing and creates nothing.
const progress = (line) => appendProgress(ctx.sessionDir, line);
for (const wt of worktrees) {
const label = basename(wt);
for (const step of perWorktree) {
if (stepSkipped(ctx, step)) continue;
progress(`[validate:${label}] ${step.id}…`);
const r = runStep(ctx, step, { cwd: wt, base });
if (!r.ok) {
progress(`[validate:${label}] ${step.id} FAILED`);
ctx.log(`FAIL step=${step.id} wt=${wt}\n${r.output}`);
return { ok: false, step: step.id, output: `=== ${step.id} failed in ${wt} ===\n${r.output}\n` };
}
}
progress(`[validate:${label}] checks passed`);
ctx.log(`OK wt=${wt}`);
}
for (const step of repoLevel) {
if (stepSkipped(ctx, step)) continue;
progress(`[validate:repo] ${step.id}…`);
const r = runStep(ctx, step, { cwd: ctx.repo, base });
if (!r.ok) {
progress(`[validate:repo] ${step.id} FAILED`);
ctx.log(`FAIL step=${step.id} exit`);
return { ok: false, step: step.id, output: r.output + "\n" };
}
ctx.log(`${step.id} OK`);
}
return { ok: true };
}

View File

@@ -0,0 +1,88 @@
// Show a harness worktree in the editor by adding it as a workspace folder, so
// its files AND its Source Control diff appear live during a run. Two mechanisms,
// auto-detected:
//
// - A `.code-workspace` file beside the repo (multi-root workspace): edit its
// `folders` array. VS Code live-reloads the file, so add AND remove are clean.
// - A mono-folder window (no `.code-workspace`): `code --add <path>` asks the
// running VS Code window to add the folder (which turns it into an untitled
// multi-root workspace). There is no `code --remove` in the VS Code CLI, so
// removal is a no-op here; the untitled workspace is not persisted, so the
// entry is gone on the next fresh open anyway.
//
// Every function is best-effort and never throws: a convenience view must not
// break a run. A parse failure leaves the file untouched (never corrupt it).
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { exec } from "./process.mjs";
// The `.code-workspace` file beside the repo (the multi-root workspace root is
// dirname(repo)). Returns its absolute path, or null (mono-folder / none).
export function workspaceFile(repo) {
const root = dirname(repo);
try {
const name = readdirSync(root).find((n) => n.endsWith(".code-workspace"));
return name ? join(root, name) : null;
} catch {
return null;
}
}
function readWorkspace(file) {
try {
const ws = JSON.parse(readFileSync(file, "utf8"));
return ws && Array.isArray(ws.folders) ? ws : null;
} catch {
return null; // JSONC/comments or unreadable — do not touch it
}
}
function writeWorkspace(file, ws) {
try {
writeFileSync(file, JSON.stringify(ws, null, 2) + "\n");
return true;
} catch {
return false;
}
}
// A VS Code window is reachable from a hook subprocess (its IPC env is present).
const canDriveWindow = () =>
Boolean(process.env.VSCODE_IPC_HOOK_CLI || process.env.REMOTE_CONTAINERS_IPC);
/**
* Add `path` (an absolute worktree path) as a workspace folder named `name`.
* @returns {boolean} whether the folder was added (or already present).
*/
export function addWorktreeFolder(repo, path, name) {
if (!existsSync(path)) return false;
const file = workspaceFile(repo);
if (file) {
const ws = readWorkspace(file);
if (!ws) return false;
if (ws.folders.some((f) => f.path === path)) return true; // idempotent
ws.folders.push({ path, name });
return writeWorkspace(file, ws);
}
// Mono-folder window: ask VS Code to add it (add-only, self-heals on reopen).
if (canDriveWindow()) return exec("code", ["--add", path]).status === 0;
return false;
}
/**
* Remove every workspace folder whose absolute path satisfies `matches(path)`
* (e.g. a prefix match on the session's worktree base, or an exact path). Only
* the `.code-workspace` mechanism supports removal; mono-folder is a no-op.
* @returns {boolean} whether anything was removed.
*/
export function removeWorktreeFolders(repo, matches) {
const file = workspaceFile(repo);
if (!file) return false;
const ws = readWorkspace(file);
if (!ws) return false;
const kept = ws.folders.filter((f) => !(f.path && matches(f.path)));
if (kept.length === ws.folders.length) return false;
ws.folders = kept;
return writeWorkspace(file, ws);
}

View File

@@ -0,0 +1,28 @@
// Worktree teardown primitives shared by the cleanup hooks (cleanup-worktree,
// cleanup-session). Mutations live here, kept separate from git.mjs's read-only
// queries. The harness-revert script reuses getWorktreeEntries (read) directly
// but keeps its own mutations because it honours --dry-run.
import { rmSync } from "node:fs";
import { getWorktreeEntries, git } from "./git.mjs";
// Remove one worktree: `git worktree remove --force`, falling back to a plain
// recursive rm when git refuses (e.g. the admin entry is already gone). Callers
// prune afterwards (removeWorktreesUnder does; a lone caller should too).
export function removeWorktree(path) {
if (git(["worktree", "remove", "--force", path]).status !== 0) {
rmSync(path, { recursive: true, force: true });
}
}
// Remove every registered worktree at or under `base`, then prune the admin
// refs. Returns the count removed. The main repo worktree is never under a
// session base, so it is excluded naturally.
export function removeWorktreesUnder(base) {
const under = getWorktreeEntries().filter(
(e) => e.path === base || e.path.startsWith(base + "/"),
);
under.forEach((e) => removeWorktree(e.path));
git(["worktree", "prune"]);
return under.length;
}

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env node
// SessionStart: maintain a stable ".harness-session" symlink in the VS Code
// multi-root workspace, pointing at the CURRENT session dir. The harness
// tickets live in that dir (TICKETS_DIR), so this keeps them visible in the
// editor for plan-gate validation. The session dir path carries the
// per-session id, so a static workspace folder entry would go stale every
// session; the .code-workspace references the stable symlink name instead and
// this hook repoints the symlink at each session start.
//
// No-op when a managed launcher owns the session (CHAT_SESSION_DIR set, it has
// its own UI) or when the repo's parent is not a VS Code multi-root workspace
// (no *.code-workspace file beside it). Never throws: a convenience feature
// must not break a session.
import {
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
readlinkSync,
rmSync,
symlinkSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { createHookContext } from "./lib/context.mjs";
const LINK_NAME = ".harness-session";
// A managed launcher (chat-service) owns the session and its own UI; there is
// no VS Code workspace to manage.
if (process.env.CHAT_SESSION_DIR) process.exit(0);
let input = {};
try {
input = JSON.parse(readFileSync(0, "utf8"));
} catch {
// no payload, nothing to do
process.exit(0);
}
const ctx = createHookContext(input, "link-session-workspace");
const isSymlink = (p) => {
try {
return lstatSync(p).isSymbolicLink();
} catch {
return false;
}
};
try {
// The .code-workspace file sits beside the repo (its parent dir is the
// multi-root workspace root). Only act when that layout is present.
const workspaceRoot = dirname(ctx.repo);
const inWorkspace =
existsSync(workspaceRoot) &&
readdirSync(workspaceRoot).some((f) => f.endsWith(".code-workspace"));
if (!inWorkspace) {
ctx.log(`no .code-workspace beside ${ctx.repo}, skip`);
process.exit(0);
}
// Never leave a dangling link: ensure the target exists first.
mkdirSync(ctx.sessionDir, { recursive: true });
const linkPath = join(workspaceRoot, LINK_NAME);
if (existsSync(linkPath) || isSymlink(linkPath)) {
if (!isSymlink(linkPath)) {
// A real file/dir is sitting there, do not clobber it.
ctx.log(`${linkPath} exists and is not a symlink, skip`);
process.exit(0);
}
let current = "";
try {
current = readlinkSync(linkPath);
} catch {
// unreadable link, replace it
}
if (current === ctx.sessionDir) {
ctx.log(`already linked to ${ctx.sessionDir}`);
process.exit(0);
}
rmSync(linkPath, { force: true });
}
symlinkSync(ctx.sessionDir, linkPath);
ctx.log(`linked ${LINK_NAME} -> ${ctx.sessionDir}`);
} catch (e) {
ctx.log(`skipped: ${e?.message ?? e}`);
}
process.exit(0);

54
.claude/hooks/pre-pr-checks.mjs Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
// PreToolUse(Bash): before `gh pr create` or `git push`, run the fast config
// checks (typecheck / lint validation steps) so a broken push is caught on the
// HUMAN path. Harness subagents are SKIPPED: they never push, and their
// SubagentStop chain already validates. exit 2 blocks the push/PR.
import { execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { loadConfig, prePrSteps } from "./lib/config.mjs";
import { REPO } from "./lib/paths.mjs";
const PR_TRIGGER_RE = /\bgh\s+pr\s+create\b|\bgit\s+push\b/;
let input = {};
try {
input = JSON.parse(readFileSync(0, "utf8"));
} catch {
process.exit(0);
}
// Human/main-thread only. A subagent carries an agent_type / runtime agent name.
if (input.agent_type || process.env.CLAUDE_AGENT_NAME) process.exit(0);
const command = input.tool_input?.command ?? "";
if (!PR_TRIGGER_RE.test(command)) process.exit(0);
let steps;
try {
steps = prePrSteps(loadConfig());
} catch {
process.exit(0); // no readable config -> do not block the human
}
if (!steps.length) process.exit(0);
const failures = [];
for (const step of steps) {
if (!step.command) continue;
try {
execSync(step.command, { cwd: REPO, stdio: "pipe", timeout: 120_000 });
} catch (err) {
const out = (err.stdout?.toString() || err.message || "").slice(0, 800);
failures.push(`- ${step.id} (\`${step.command}\`) failed:\n${out}`);
}
}
if (failures.length) {
console.error(
`pre-pr-checks: push/PR blocked, ${failures.length} check(s) failed:\n\n` +
failures.join("\n\n") +
`\n\nFix the issues above, then retry.`,
);
process.exit(2);
}
process.exit(0);

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env node
// PreToolUse(Agent): record whether the merger being dispatched is authorized to
// run Stage B (promotion of the session branch into the base branch). Written to
// <sessionDir>/merger-stage.json at dispatch time; read by
// block-wave-merger-promote.mjs when the merger runs its git commands.
//
// Why the bridge: a per-ticket wave merger (TASK_ID: TASK-XXX, no MODE) is a
// Stage-A-only dispatch (merger.md "TASK-XXX -> Stage A only") and must NOT promote.
// The dispatch itself is legitimate, so the violation can only be caught when the
// merger runs the Stage-B git commands. A PreToolUse(Bash) hook cannot see the
// merger's dispatch prompt, so its authorization is recorded here at dispatch.
//
// Race safety: wave mergers are dispatched sequentially and foreground
// (merger.md, Stage 3), and promotion is a single dispatch after the last wave, so
// there is never more than one merger running at a time. A single-slot marker
// therefore always describes the currently-running merger.
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { createHookContext } from "./lib/context.mjs";
import { parseDispatch } from "./lib/dispatch-parse.mjs";
const input = JSON.parse(readFileSync(0, "utf8"));
const d = parseDispatch(input);
// Only a merger dispatch carries promotion authorization.
if (d.subagentType !== "merger") process.exit(0);
const ctx = createHookContext(input, "record-merger-stage");
// A dispatch is Stage-A-only (may NOT promote) when it either carries STAGE: a-only
// or is a per-ticket wave merge (TASK_ID matches TASK-<n>). Every other merger
// dispatch (MODE: promote, or a SIMPLE / MIGRATION / ROLLBACK single-shot, none of
// which set a TASK-<n> id) legitimately promotes.
const stageAOnly = d.stage === "a-only" || /^TASK-\d+$/.test(d.taskId);
const promote = !stageAOnly;
try {
mkdirSync(ctx.sessionDir, { recursive: true });
writeFileSync(
join(ctx.sessionDir, "merger-stage.json"),
JSON.stringify({
promote,
taskId: d.taskId,
mode: d.mode,
stage: d.stage,
at: new Date().toISOString(),
}) + "\n",
);
ctx.log(
`RECORD promote=${promote} taskId=${d.taskId || "-"} mode=${d.mode || "-"} stage=${d.stage || "-"}`,
);
} catch {
// Best-effort: the Bash guard fails CLOSED when the marker is missing/unreadable,
// so a failed write cannot silently allow an unauthorized promotion.
}
process.exit(0);

View File

@@ -0,0 +1,220 @@
#!/usr/bin/env node
// SubagentStop(quality-reviewer) — FALLBACK recorder of the reviewer's verdict
// flag (block-merger-without-review.mjs enforces dev -> reviewer -> merger on it).
// The PRIMARY writer is now the quality-reviewer agent itself, which touches the
// flag via Bash before it stops (see quality-reviewer.md) — synchronous, no race.
// This hook stays as belt-and-suspenders: at SubagentStop the reviewer's final
// contract line is often not yet flushed to the transcript (and last_assistant_message
// is absent in this runtime), so verdict recovery here can return UNKNOWN and the
// flag is left untouched. That silent miss was the TASK-002 cascade — the agent
// self-write removes the dependency; this hook only catches the case the agent
// skipped its touch. SubagentStop cannot block — it only records.
//
// Flag (presence == APPROVED): <sessionDir>/reviews/<TASK>-<role>. Cleared on
// REJECTED here; cleared on a developer (re)dispatch by setup-worktree.mjs so a
// changed diff invalidates stale approvals.
//
// Verdict source: the reviewer's final contract line (exactly `APPROVED` or
// `REJECTED: ...`). Read from last_assistant_message when the runtime provides
// it, else from the JSONL transcript's last assistant text block. When the
// verdict can't be recovered, the flag is left untouched — never written on a
// guess (a dev re-dispatch is what invalidates a stale verdict).
import {
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createHookContext } from "./lib/context.mjs";
import { getFirstTaskId, isQualityReviewer } from "./lib/teams.mjs";
import { readAgentMeta } from "./lib/agent-meta.mjs";
import { reviewFlag, reviewsDir } from "./lib/reviews.mjs";
const input = JSON.parse(readFileSync(0, "utf8"));
const ctx = createHookContext(input, "record-review-verdict");
// Role + task from the (suffixed) agent identity, e.g. quality-reviewer-TASK-001.
const ids = [ctx.agentName, ctx.agentType].filter(Boolean);
let role = ids.some(isQualityReviewer) ? "quality-reviewer" : "";
let task = "";
for (const n of ids) {
const m = getFirstTaskId(n);
if (m) {
task = m;
break;
}
}
// Most reliable source at SubagentStop: the sibling <agent>.meta.json. It is
// written at spawn (so it exists even when the big transcript JSONL hasn't been
// flushed yet — a real race here) and carries agentType + the dispatch
// description. In this runtime the payload's `agent_type` is empty and
// `transcript_path` can point at a not-yet-written file, so prefer the meta.
if (!role || !task) {
const meta = readAgentMeta(input);
if (meta) {
if (!role && isQualityReviewer(meta.agentType)) role = "quality-reviewer";
if (!task) {
const m = meta.description.match(/TASK-\d+/);
if (m) task = m[0];
}
}
}
// No suffixed agent name in this harness → recover task/role from the dispatch
// prompt in the transcript. Done unconditionally: lastAssistantText() returns
// early on last_assistant_message, bypassing its own recovery (→ task=UNKNOWN).
if (!role || !task) {
const tp = input.agent_transcript_path || input.transcript_path;
if (tp && existsSync(tp)) {
let body = "";
try {
body = readFileSync(tp, "utf8");
} catch {
body = "";
}
for (const line of body.split("\n")) {
if (!role) {
const m = line.match(/(?:^|\\n|")ROLE:\s*(quality-reviewer)/);
if (m) role = m[1];
}
if (!task) {
const m =
line.match(/TASK_ID[:=\s]+(TASK-\d+)/) ||
line.match(/TICKET_FILE[=:\s]+\S*(TASK-\d+)/);
if (m) task = m[1];
}
if (role && task) break;
}
}
}
// Last assistant text: prefer the payload field, fall back to the transcript.
// While scanning the transcript, recover role/task from the dispatch prompt
// (ROLE: / TICKET_FILE:) when the agent name didn't carry them.
const lastAssistantText = () => {
if (
typeof input.last_assistant_message === "string" &&
input.last_assistant_message.trim()
) {
return input.last_assistant_message;
}
const tp = input.agent_transcript_path || input.transcript_path;
if (!tp || !existsSync(tp)) return "";
let body = "";
try {
body = readFileSync(tp, "utf8");
} catch {
return "";
}
let lastText = "";
for (const line of body.split("\n")) {
if (!line.trim()) continue;
let e;
try {
e = JSON.parse(line);
} catch {
continue;
}
const msg = e.message || e;
const content = msg && msg.content;
if (Array.isArray(content)) {
for (const b of content) {
if (
b &&
b.type === "text" &&
typeof b.text === "string" &&
b.text.trim()
)
lastText = b.text;
}
} else if (typeof content === "string" && content.trim()) {
lastText = content;
}
if (!role) {
// Anchor to a dispatch-line boundary — start of the JSONL record, an
// escaped newline (\n inside the JSON string), or the prompt field's
// opening quote — so an inline prose mention ("...the ROLE: quality-reviewer
// agent...") cannot mis-assign the role.
const m = line.match(/(?:^|\\n|")ROLE:\s*(quality-reviewer)/);
if (m) role = m[1];
}
if (!task) {
// Anchor to the dispatch's TASK_ID / TICKET_FILE lines only. A bare
// /TASK-\d+/ scan would latch onto the FIRST ticket mentioned anywhere in
// the transcript (e.g. "TASK-001 is merged, now review TASK-002"), keying
// the verdict flag to the wrong ticket.
const m =
line.match(/TASK_ID[:=\s]+(TASK-\d+)/) ||
line.match(/TICKET_FILE[=:\s]+\S*(TASK-\d+)/);
if (m) task = m[1];
}
}
return lastText;
};
// Verdict = the last CLEAN contract marker, read bottom-up. The contract puts the
// verdict on the final line: a bare `APPROVED`, or `REJECTED:` followed by a
// bulleted feedback list (so the REJECTED marker is NOT the last line). We scan
// upward from the bottom and take the first clean marker, skipping feedback
// bullets and trailing prose. Two deliberate asymmetries stop a chatty trailing
// line from FLIPPING a real verdict:
// - APPROVED matches only a standalone `APPROVED` (optionally trailing
// punctuation) — not "APPROVED parts: ..." prose.
// - REJECTED requires the contract colon `REJECTED:` — so a trailing sentence
// like "REJECTED concerns are resolved" after a real APPROVED is ignored.
// A line that is neither a clean marker nor a feedback bullet is skipped (keep
// scanning up); if none is found the verdict is UNKNOWN and the flag is untouched.
const src = lastAssistantText();
const isApproved = (l) => l.replace(/[.!\s]+$/, "") === "APPROVED";
const isRejected = (l) => /^REJECTED:/.test(l);
let verdict = "";
const verdictLines = src.split("\n").map((s) => s.trim());
for (let i = verdictLines.length - 1; i >= 0; i--) {
const line = verdictLines[i];
if (!line) continue;
if (isRejected(line)) {
verdict = "REJECTED";
break;
}
if (isApproved(line)) {
verdict = "APPROVED";
break;
}
}
// Only log for reviewer stops. This hook fires on EVERY subagent stop (the
// SubagentStop matcher doesn't filter and agent_type is empty in this runtime),
// so logging non-reviewer stops (developer/merger/planner/…) is pure noise.
// `role` found OR a verdict in the final message ⇒ this was a reviewer.
if (role || verdict) {
const _tp = input.agent_transcript_path || input.transcript_path || "";
ctx.log(
`role=${role || "UNKNOWN"} task=${task || "UNKNOWN"} verdict=${verdict || "UNKNOWN"}` +
(role && task && verdict
? ""
: ` | DIAG tp_exists=${Boolean(_tp && existsSync(_tp))} last_msg=${input.last_assistant_message ? "present" : "absent"}`),
);
}
if (!role || !task) process.exit(0); // can't key the flag — leave state untouched
const flag = reviewFlag(ctx, task, role);
if (verdict === "APPROVED") {
try {
mkdirSync(reviewsDir(ctx), { recursive: true });
writeFileSync(flag, "");
} catch {
// best-effort
}
} else if (verdict === "REJECTED") {
try {
rmSync(flag, { force: true });
} catch {
// best-effort
}
}
// UNKNOWN verdict: leave the flag untouched.
process.exit(0);

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env node
// PostCompact: re-inject the load-bearing harness invariants after the
// conversation is compacted, so the coordinating thread does not lose them and
// resume the duplicate-work / wrong-routing failures they prevent.
//
// Injection uses hookSpecificOutput.additionalContext, the same sanctioned path
// session-bootstrap uses for SessionStart. Fail-open: any error exits 0 (a
// PostCompact hook must never break the resumed session).
import { readFileSync } from "node:fs";
// A short, dense block: only the invariants a fresh context must not lose.
// Keep it under ~25 lines.
const INVARIANTS = [
"<harness-invariants> (re-injected after compaction)",
"- Worktree scope: each agent reads/writes ONLY its own <WORKTREE_PATH>; never edit $REPO/src directly. Every Bash needs a `cd <WORKTREE_PATH> &&` prefix.",
"- Git rights: the developer commits its OWN task branch; only the merger mutates session/base branches; no agent pushes or pulls (the user owns the remote).",
"- Launched is NOT done: an `Async agent launched ... agentId` acknowledgement means dispatched, not finished. Wait for the task-notification; do NOT start implementing the same change yourself.",
"- While an orchestrator runs: surface progress only; never route or re-implement the change in the main thread (that is the duplicate-work bug).",
"- Approvals (migration / plan gate): dispatch a FRESH orchestrator (new Agent call) with the right <intent>; never SendMessage the old one (relayed approvals carry no user authority).",
"</harness-invariants>",
].join("\n");
try {
readFileSync(0, "utf8"); // drain stdin; payload content is not needed
} catch {
// no payload is fine; still emit the invariants below
}
try {
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: "PostCompact",
additionalContext: INVARIANTS,
},
}) + "\n",
);
} catch {
// fail-open: never break the resumed session
}
process.exit(0);

254
.claude/hooks/render-status.mjs Executable file
View File

@@ -0,0 +1,254 @@
#!/usr/bin/env node
// SubagentStop - render a portable harness board from the session's cheap state
// (tickets, review flags, progress log, live worktrees) into
// <REPO>/.harness/<SESSION_SHORT_ID>/{STATUS.md,TICKETS.md,status.json}.
//
// Why: on the VS Code extension the /tmp worktrees are invisible, and on any
// surface the raw `harness-progress.log` tail drowns the chat. `.harness/` sits
// in the project dir, gitignored, so it shows in the VS Code Explorer AND the
// desktop file browser (which renders Markdown as a formatted preview). The
// developer sees who is doing what without the log tail polluting the chat.
//
// Gated to #technical-harness runs: renders ONLY when the technical-only
// harness-progress.log exists (orchestrator.md writes it under PERSONA: technical),
// so a normal / web-chat run never drops a board into the repo. Per-session
// subdir (SESSION_SHORT_ID) keeps concurrent sessions from colliding. No-op under
// a managed launcher (owns its own UI). Never throws: a view must not break a run.
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { createHookContext } from "./lib/context.mjs";
import { REPO } from "./lib/paths.mjs";
import { reviewsDir } from "./lib/reviews.mjs";
import { getBaseBranch, git } from "./lib/git.mjs";
import { sessionBaseBranch, sessionBranch } from "./lib/topology.mjs";
if (process.env.CHAT_SESSION_DIR) process.exit(0);
let input = {};
try {
input = JSON.parse(readFileSync(0, "utf8"));
} catch {
process.exit(0);
}
const ctx = createHookContext(input, "render-status");
const progressLog = join(ctx.sessionDir, "harness-progress.log");
// No technical progress log → not a #technical-harness run → stay inert.
if (!existsSync(progressLog)) process.exit(0);
const WT_RE = /^(TASK-\d+|simple|_session)$/;
const TICKET_RE = /^TASK-\d+\.json$/;
// Tickets live in the session dir itself or a `tickets/` subdir - try both.
function readTickets() {
for (const dir of [ctx.ticketsDir, ctx.sessionDir]) {
if (!existsSync(dir)) continue;
const files = readdirSync(dir).filter((f) => TICKET_RE.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 || f.replace(/\.json$/, ""),
title: t.title || t.description || "",
status: t.status || "planned",
acceptanceCriteria: t.acceptance_criteria || [],
files: t.files_to_modify || [],
dependencies: t.dependencies || [],
};
} catch {
return {
id: f.replace(/\.json$/, ""),
title: "",
status: "unreadable",
};
}
});
}
return [];
}
// A live worktree dir under the session base means work still in flight (the
// merger's cleanup-worktree removes it on merge).
function liveWorktrees() {
if (!existsSync(ctx.worktreeBase)) return [];
return readdirSync(ctx.worktreeBase, { withFileTypes: true })
.filter((d) => d.isDirectory() && WT_RE.test(d.name))
.map((d) => d.name)
.sort();
}
const approved = (id) =>
existsSync(join(reviewsDir(ctx), `${id}-quality-reviewer`));
function recentActivity(max = 25) {
const lines = readFileSync(progressLog, "utf8")
.split("\n")
.filter((l) => l.trim());
return lines.slice(-max);
}
const cell = (s) => String(s ?? "").replace(/\|/g, "\\|");
// The cumulative diff of everything the session has changed so far: work already
// merged into `session/<short>` PLUS in-flight work still on live dev branches
// (`<short>/TASK-XXX`, `<short>/simple`) not yet merged. Written as a `.diff` file
// so the developer can open it and SEE the change on any surface (the VS Code
// extension does not show the /tmp worktree diffs). Updated on every agent STOP
// (not continuously while an agent works), so a developer's work appears once THAT
// developer stops - before the merge, not only after it. Null when nothing has
// changed vs the fork anchor yet.
const verifyRef = (ref) =>
git(["rev-parse", "--verify", "--quiet", ref]).status === 0;
function diffSection(label, range) {
const patch = git(["diff", range]).stdout;
if (!patch.trim()) return null;
return {
label,
range,
patch,
stat: git(["diff", "--stat", range]).stdout.trim(),
};
}
function sessionDiff() {
const anchor = sessionBaseBranch(ctx);
const baseRef = verifyRef(anchor) ? anchor : getBaseBranch();
const sessRef = sessionBranch(ctx);
const sections = [];
// Merged work: fork anchor -> session branch.
if (verifyRef(sessRef)) {
const s = diffSection(`merged into ${sessRef}`, `${baseRef}...${sessRef}`);
if (s) sections.push(s);
}
// In-flight work: each <short>/* dev branch with commits not yet on the session
// branch (diffed against it, so the already-merged part is not counted twice).
const inflightBase = verifyRef(sessRef) ? sessRef : baseRef;
const devBranches = git([
"for-each-ref",
"--format=%(refname:short)",
`refs/heads/${ctx.sessionShort}/`,
])
.stdout.split("\n")
.map((b) => b.trim())
.filter(Boolean);
for (const br of devBranches) {
const s = diffSection(`in flight: ${br}`, `${inflightBase}...${br}`);
if (s) sections.push(s);
}
if (!sections.length) return null;
return {
patch: sections
.map((s) => `### ${s.label} (${s.range})\n\n${s.patch}`)
.join("\n\n"),
stat: sections.map((s) => `${s.label}:\n${s.stat}`).join("\n\n"),
};
}
function build() {
const tickets = readTickets();
const worktrees = liveWorktrees();
const activeTasks = worktrees.filter((w) => /^TASK-\d+$/.test(w));
const merged = tickets.filter((t) => t.status === "merged").length;
const updated = new Date(statSync(progressLog).mtime).toISOString();
const activity = recentActivity();
const diff = sessionDiff();
// STATUS.md - the live board.
const statusMd = [
`# ⚙ Harness session \`${ctx.sessionShort}\``,
`_updated ${updated}_`,
"",
`**${merged}/${tickets.length} merged** · ${activeTasks.length} in flight`,
"",
"## Tickets",
tickets.length
? "| Ticket | Status | Review | Title |"
: "_no tickets yet_",
tickets.length ? "|---|---|---|---|" : "",
...tickets.map(
(t) =>
`| ${t.id} | ${t.status} | ${approved(t.id) ? "✅" : ""} | ${cell(t.title)} |`,
),
"",
"## Live worktrees",
worktrees.length
? worktrees.map((w) => `- \`${w}\``).join("\n")
: "_none (nothing in flight)_",
"",
"## Changes",
diff
? `Full patch in \`session.diff\` (same folder).\n\n\`\`\`\n${diff.stat}\n\`\`\``
: "_no changes yet_",
"",
"## Recent activity",
"```",
...activity,
"```",
"",
]
.filter((l) => l !== "")
.join("\n");
// TICKETS.md - full ticket detail for the plan gate.
const ticketsMd = [
`# Tickets: session \`${ctx.sessionShort}\``,
"",
...tickets.flatMap((t) => [
`## ${t.id} · ${t.title}`,
`- **Status:** ${t.status}${approved(t.id) ? " (reviewed ✅)" : ""}`,
`- **Depends on:** ${t.dependencies?.length ? t.dependencies.join(", ") : "(none)"}`,
`- **Files:** ${t.files?.length ? t.files.map((f) => `\`${f}\``).join(", ") : "(none)"}`,
"- **Acceptance criteria:**",
...(t.acceptanceCriteria?.length
? t.acceptanceCriteria.map((c) => ` - ${c}`)
: [" - (none)"]),
"",
]),
].join("\n");
// status.json - compact, for the opt-in statusline.
const statusJson = {
short: ctx.sessionShort,
updated,
tickets: {
total: tickets.length,
merged,
inProgress: activeTasks.length,
},
active: activeTasks.map((task) => ({ task })),
last: activity[activity.length - 1] || "",
};
return { statusMd, ticketsMd, statusJson, diffPatch: diff?.patch };
}
try {
const { statusMd, ticketsMd, statusJson, diffPatch } = build();
const outDir = join(REPO, ".harness", ctx.sessionShort);
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, "STATUS.md"), statusMd);
writeFileSync(join(outDir, "TICKETS.md"), ticketsMd);
writeFileSync(
join(outDir, "status.json"),
JSON.stringify(statusJson, null, 2),
);
if (diffPatch) writeFileSync(join(outDir, "session.diff"), diffPatch);
ctx.log(`rendered board -> ${outDir}`);
} catch (e) {
ctx.log(`skipped: ${e?.message ?? e}`);
}
process.exit(0);

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env node
// PreToolUse(Bash) — restrict the documentator (DOCUMENTATOR_RUN=1 or agent_type=documentator) to a read-only / MEMORY.md-commit whitelist, rejecting shell metacharacters first; pass-through otherwise.
import { readFileSync } from "node:fs";
import { createHookContext } from "./lib/context.mjs";
// Detect the documentator EITHER by DOCUMENTATOR_RUN=1 (legacy standalone
// run — a top-level process with no agent_type) OR by agent_type ===
// "documentator" (an Agent-dispatched documentator subagent, the same signal the
// other PreToolUse hooks use). Read the payload first so agent_type is available.
//
// Fail closed: a malformed payload is a block signal for this restricted agent,
// not a pass-through. Leave input empty so the !command guard below exits 2
// (exit 1 from an uncaught throw would let the Bash command through).
let input = {};
try {
input = JSON.parse(readFileSync(0, "utf8"));
} catch {
// fall through to the empty-command block below
}
if (
process.env.DOCUMENTATOR_RUN !== "1" &&
(input.agent_type || "") !== "documentator"
) {
process.exit(0);
}
const ctx = createHookContext(input, "restrict-documentator-bash");
const command = input.tool_input?.command || "";
if (!command) {
ctx.error(
"Bash command blocked for documentator: empty or unparseable command.",
);
process.exit(2);
}
const SHELL_METACHARS = [";", "&&", "||", "|", "`", "$(", ">", "<", "\n"];
const hasShellMetachars = (cmd) => SHELL_METACHARS.some((m) => cmd.includes(m));
if (hasShellMetachars(command)) {
ctx.error(
'Bash command blocked for documentator: shell metacharacters not allowed (";", "&&", "||", "|", backtick, "$(", redirections, newline).',
);
process.exit(2);
}
const WHITELIST = [
/^git log( |$)/,
/^git show( |$)/,
/^git diff( |$)/,
/^git -C [^ ]+ diff( |$)/,
/^git -C [^ ]+ log( |$)/,
/^ls( |$)/,
/^wc -l( |$)/,
/^git -C [^ ]+ add MEMORY\.md *$/,
// Author identity is pinned. Accept the neutral harness.local and the
// deprecated atomic-crm.local (kept for one release).
/^git -C [^ ]+ -c user\.name=['"]?Documentator['"]? -c user\.email=['"]?documentator@(harness|atomic-crm)\.local['"]? commit -m /,
/^git -C [^ ]+ -c user\.email=['"]?documentator@(harness|atomic-crm)\.local['"]? -c user\.name=['"]?Documentator['"]? commit -m /,
];
const isWhitelisted = (cmd) => WHITELIST.some((pattern) => pattern.test(cmd));
if (isWhitelisted(command)) {
process.exit(0);
}
ctx.error(
"Bash command blocked for documentator. Allowed: git log, git show, git diff, ls, wc -l; Mode 2 only: 'git -C <repo> diff …', 'git -C <repo> log …', 'git -C <repo> add MEMORY.md', 'git -C <repo> -c user.name=Documentator -c user.email=documentator@harness.local commit -m …'. Use Read/Glob/Grep otherwise.",
);
process.exit(2);

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env node
// PreToolUse(Write|Edit) — restrict the documentator (DOCUMENTATOR_RUN=1 or agent_type=documentator) writes to the ledger, the runtime additions tree (CONFIG_DIR/local/**), MEMORY.md, and settings.local.json; pass-through otherwise.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createHookContext } from "./lib/context.mjs";
import { REPO, CONFIG_DIR } from "./lib/paths.mjs";
// Detect the documentator EITHER by DOCUMENTATOR_RUN=1 (legacy standalone
// run — a top-level process with no agent_type) OR by agent_type ===
// "documentator" (an Agent-dispatched documentator subagent). Read the payload
// first so agent_type is available.
//
// Fail closed: a malformed payload is a block signal for this restricted agent,
// not a pass-through. Leave input empty so the !filePath guard below exits 2
// (exit 1 from an uncaught throw would let the Write through).
let input = {};
try {
input = JSON.parse(readFileSync(0, "utf8"));
} catch {
// fall through to the empty-file_path block below
}
if (
process.env.DOCUMENTATOR_RUN !== "1" &&
(input.agent_type || "") !== "documentator"
) {
process.exit(0);
}
const ctx = createHookContext(input, "restrict-documentator-write");
const filePath = input.tool_input?.file_path || "";
if (!filePath) {
ctx.error(
"Write/Edit blocked for documentator: empty or unparseable file_path.",
);
process.exit(2);
}
const LEDGER = join(REPO, "docs/learnings/patterns.md");
const LOCAL_PREFIX = join(CONFIG_DIR, "local") + "/";
const SETTINGS_LOCAL = join(CONFIG_DIR, "settings.local.json");
const MEMORY = join(REPO, "MEMORY.md");
const isAllowedPath = (p) =>
p === LEDGER ||
p === SETTINGS_LOCAL ||
p === MEMORY ||
p.startsWith(LOCAL_PREFIX);
if (isAllowedPath(filePath)) {
process.exit(0);
}
ctx.error(
`Write/Edit blocked for documentator: ${filePath} is outside the allowed set. Allowed: ${LEDGER}, ${SETTINGS_LOCAL}, ${MEMORY}, ${LOCAL_PREFIX}**.`,
);
process.exit(2);

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env node
// SessionStart — bootstrap the harness session context so the orchestrator (or the
// main thread acting as one) can run on ANY Claude Code surface (CLI, VS Code
// extension, desktop), not only via a launch script that injects
// --append-system-prompt.
//
// It injects `<session_dir>` via hookSpecificOutput.additionalContext, derived
// from the REAL session id the harness assigns. Because every other hook
// (setup-worktree, merger, ...) also keys off that same real session id, the
// alignment invariant `basename(session_dir) == session_id` holds by construction
// — no minted UUID, no forced --session-id.
// Pure env-inspection + additionalContext: no MCP calls (MCP may be disabled in
// hosted/headless runs), no git mutation, never throws.
import { readFileSync, mkdirSync } from "node:fs";
import { createHookContext } from "./lib/context.mjs";
import { detectInflight } from "./lib/session-state.mjs";
// A managed launcher owns the session context — stay out of its way.
if (process.env.CHAT_SESSION_DIR) process.exit(0);
let input = {};
try {
input = JSON.parse(readFileSync(0, "utf8"));
} catch {
// no payload → nothing to inject
process.exit(0);
}
const ctx = createHookContext(input, "session-bootstrap");
// Use the SAME per-session dir that setup-worktree builds its worktrees under
// (ctx.sessionDir = /tmp/<sanitized repo>/<session_id>, via context.mjs). Its
// BASENAME is the real session id — what the orchestrator turns into
// SESSION_SHORT_ID and WORKTREE_BASE — so the alignment holds and tickets/logs
// share the one session namespace instead of a separate root.
const sessionDir = ctx.sessionDir;
try {
mkdirSync(sessionDir, { recursive: true });
} catch {
// best-effort: the orchestrator/agents recreate it as needed
}
ctx.log(`SESSION-BOOTSTRAP session_dir=${sessionDir}`);
// Resume banner: on a RESUMED session (same id → same namespace), the previous
// orchestrator process is gone and nothing re-launches it, so an interrupted
// harness would otherwise sit dead. If THIS session has in-flight harness state
// on disk/git, tell the main thread to re-dispatch a FRESH recovery orchestrator
// (never SendMessage the dead one). Keyed only on this session id — a fresh or
// unrelated concurrent session has no state under its id and gets no banner.
let resume = "";
try {
const { inflight, phase } = detectInflight(ctx);
if (inflight === true) {
ctx.log(`SESSION-BOOTSTRAP resume-available phase=${phase}`);
resume =
`\n<harness_resume>` +
`An interrupted harness session was detected for THIS session (short id \`${ctx.sessionShort}\`), paused at: ${phase}. ` +
`Its orchestrator process ended when the window last closed; nothing is running now. ` +
`To resume, dispatch a FRESH orchestrator with <intent>recovery</intent> and this same session_dir. ` +
`Do NOT SendMessage a previous orchestrator (it is dead). See CLAUDE.md "Resuming after a restart".` +
`</harness_resume>`;
}
} catch {
// detection must never break bootstrap
}
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: `<session_dir>${sessionDir}</session_dir>${resume}`,
},
}) + "\n",
);
process.exit(0);

276
.claude/hooks/setup-worktree.mjs Executable file
View File

@@ -0,0 +1,276 @@
#!/usr/bin/env node
// PreToolUse(Agent) — before a developer subagent starts, create its git
// worktree (forked from the session integration branch) and provision
// node_modules. Fires on the orchestrator's Agent dispatch, where the dispatched
// identity (subagent_type, name, prompt) is available — unlike SubagentStart,
// which in a parallel wave cannot tell which of N developers is starting, so it
// cannot know the TASK_ID / worktree to create.
//
// Identity: a real TASK id (TASK-001) from the prompt's TASK_ID line or the
// dispatch name (developer-TASK-001) → <base>/<TASK_ID> (per-ticket wave); a
// developer dispatch on the <short>/simple branch (rollback-conflict / migration,
// no task) → <base>/simple. Paths are derived from topology (authoritative),
// matching the value the orchestrator substitutes into WORKTREE_PATH.
//
// Also resets stale review verdicts for a (re)dispatched developer, so a changed
// diff cannot pass block-merger-without-review on a previous attempt's APPROVED.
//
// Recovery: already-registered worktree → skip (restart / retry); orphan dir →
// rm -rf then recreate; orphan branch with no worktree → force-delete so -b works.
import {
existsSync,
mkdirSync,
readFileSync,
rmdirSync,
rmSync,
statSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { createHookContext } from "./lib/context.mjs";
import { parseDispatch } from "./lib/dispatch-parse.mjs";
import { getBaseBranch, getWorktreePaths, git } from "./lib/git.mjs";
import { REVIEW_ROLES, reviewFlag } from "./lib/reviews.mjs";
import { getFirstTaskId } from "./lib/teams.mjs";
import { addWorktreeFolder } from "./lib/workspace-folders.mjs";
import {
simpleBranch,
simpleWorktreePath,
sessionBaseBranch,
sessionBranch,
sessionWorktreePath,
taskBranch,
taskWorktreePath,
} from "./lib/topology.mjs";
// Advisory-lock tuning (see the acquire loop below). LOCK_ACQUIRE_TIMEOUT_MS must
// stay comfortably under this hook's PreToolUse timeout in settings.json so a
// waiter gives up and proceeds best-effort rather than being killed mid-run.
const LOCK_ACQUIRE_TIMEOUT_MS = 20_000;
const LOCK_STALE_MS = 60_000;
const LOCK_SPIN_MS = 100;
const input = JSON.parse(readFileSync(0, "utf8"));
const ctx = createHookContext(input, "setup-worktree");
const d = parseDispatch(input);
// Only act on developer dispatches; reviewers, merger, planner and documentator
// reuse (or never touch) a worktree.
if (d.subagentType !== "developer") {
process.exit(0);
}
// A developer dispatch whose branch/worktree is <short>/simple runs single-shot on
// the shared <base>/simple worktree (rollback-conflict replay, deploy-time
// migration) instead of a per-ticket one. The /simple branch is the discriminator;
// every other developer dispatch is a per-ticket wave developer.
const isSimple =
/\/simple$/.test(d.branchName) || /\/simple$/.test(d.worktreePath);
const taskId =
(/^TASK-\d+$/.test(d.taskId) && d.taskId) || getFirstTaskId(d.name);
let worktreePath;
let branchName;
if (taskId) {
worktreePath = taskWorktreePath(ctx, taskId);
branchName = taskBranch(ctx, taskId);
} else if (isSimple) {
worktreePath = simpleWorktreePath(ctx);
branchName = simpleBranch(ctx);
} else if (d.role === "promotion-conflict-resolver") {
// The sanctioned $REPO-on-main exception (see enforce-dev-dispatch /
// worktree-scope): it works directly in the repo under the promote lock and
// owns no task worktree, so there is nothing to create here.
ctx.accept(
"promotion-conflict-resolver — operates in $REPO on main, no worktree",
);
} else {
// A developer that passed enforce-dev-dispatch (so it carries WORKTREE_PATH)
// but is neither a resolvable TASK-XXX nor an <short>/simple dispatch. We can't
// derive the canonical worktree path; accepting would let the developer cd into
// a directory that was never created. Fail closed instead.
ctx.fail(
"developer dispatch carries WORKTREE_PATH but no resolvable TASK-XXX id and no <short>/simple branch (expected a 'TASK_ID: TASK-XXX' line, a 'developer-TASK-XXX' name, or 'BRANCH_NAME: <SESSION_SHORT_ID>/simple'). Use the STATE B dispatch template.",
{ log: "BLOCK unresolvable task id" },
);
}
mkdirSync(ctx.sessionDir, { recursive: true });
// Serialise the git-mutation region per session: a wave dispatches N
// developers in ONE orchestrator message, so N PreToolUse hooks can fire nearly
// together and race on session-branch / _session creation and git's internal
// worktree locks. A best-effort advisory lock (atomic mkdir + bounded spin,
// 60s stale-steal) serialises them; released on any exit via the exit handler.
const lockDir = join(ctx.sessionDir, ".setup-worktree.lock");
const sleepSync = (ms) =>
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
let locked = false;
const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS;
while (Date.now() < deadline) {
try {
mkdirSync(lockDir);
locked = true;
break;
} catch (e) {
if (e.code !== "EEXIST") break; // can't lock — proceed best-effort
try {
if (Date.now() - statSync(lockDir).mtimeMs > LOCK_STALE_MS) {
rmSync(lockDir, { recursive: true, force: true });
continue;
}
} catch {
continue; // lock vanished — retry immediately
}
sleepSync(LOCK_SPIN_MS);
}
}
// Release the advisory lock. Idempotent (guarded by `locked`) so it is safe to
// call explicitly once the git-mutation region is done — releasing before the
// slow node_modules provisioning keeps the critical section short — and again on
// exit as a backstop.
const releaseLock = () => {
if (!locked) return;
try {
rmdirSync(lockDir);
} catch {
// best-effort
}
locked = false;
};
process.on("exit", releaseLock);
const base = getBaseBranch();
const integrationBranch = sessionBranch(ctx);
const sessionWt = sessionWorktreePath(ctx);
// Worktrees whose node_modules is provisioned AFTER the lock is released (see
// the explicit releaseLock() below). cp -a of node_modules can take 20-40s on a
// cross-mount dev container; doing it under the lock made parallel-wave waiters
// exceed the acquire deadline and run the git-mutation region unlocked.
const toProvision = [];
if (
git(["show-ref", "--verify", "--quiet", `refs/heads/${integrationBranch}`])
.status !== 0
) {
git(["branch", integrationBranch, base]);
git(["branch", sessionBaseBranch(ctx), base]);
}
// Record the fork-base branch NAME so promotion (merger Stage B) merges the
// session's work back into the branch it was forked from — the source branch the
// user is on — instead of always targeting the repo default (main). The
// session-base/<short> ref records the fork COMMIT but not the name, so the name
// is captured here. Written only when missing: that pins the value at fork time
// and never overwrites it with a later, possibly drifted, $REPO HEAD. The key
// lives for the whole session — like session/<short> and session-base/<short> it
// is NOT torn down per request (cleanup-worktree leaves it in place);
// harness-revert.mjs (the /harness-revert command) removes it at session teardown.
// Keyed by session short id (a config subsection,
// so any short id is valid).
const baseBranchKey = `sessionbase.${ctx.sessionShort}.branch`;
if (!git(["config", "--get", baseBranchKey]).stdout.trim()) {
git(["config", "--local", baseBranchKey, base]);
}
if (!existsSync(join(sessionWt, ".git"))) {
rmSync(sessionWt, { recursive: true, force: true });
mkdirSync(dirname(sessionWt), { recursive: true });
git(["worktree", "prune"]);
const add = git(["worktree", "add", sessionWt, integrationBranch]);
if (add.status === 0) {
toProvision.push(sessionWt);
ctx.log(`SESSION-BRANCH created ${integrationBranch} from ${base}`);
} else {
ctx.log(
`SESSION-BRANCH FAILED _session ${integrationBranch} err=${add.stderr.replace(/\n/g, " ")}`,
);
}
}
// A (re)dispatched developer means the diff will change — invalidate any prior
// review verdicts for this ticket so a stale APPROVED can't let the merger
// through before the new attempt is re-reviewed.
if (taskId) {
for (const r of REVIEW_ROLES) {
try {
rmSync(reviewFlag(ctx, taskId, r), { force: true });
} catch {
// best-effort
}
}
}
ctx.log(
`START agent=${d.subagentType}${d.mode ? ` mode=${d.mode}` : ""} path=${worktreePath} branch=${branchName}`,
);
if (getWorktreePaths().includes(worktreePath)) {
ctx.accept(`already registered (${worktreePath})`);
}
if (existsSync(worktreePath)) {
rmSync(worktreePath, { recursive: true, force: true });
ctx.log(`REMOVED orphan dir ${worktreePath}`);
}
mkdirSync(dirname(worktreePath), { recursive: true });
if (git(["branch", "--list", branchName]).stdout.trim()) {
git(["branch", "-D", branchName]);
ctx.log(`DELETED orphan branch ${branchName}`);
}
const add = git([
"worktree",
"add",
worktreePath,
"-b",
branchName,
integrationBranch,
]);
if (add.status !== 0) {
ctx.fail(
`Cannot create worktree at ${worktreePath} (branch=${branchName}): ${add.stderr}\n`,
{ log: `path=${worktreePath} err=${add.stderr}` },
);
}
ctx.log(`CREATED branch=${branchName} path=${worktreePath}`);
toProvision.push(worktreePath);
// Surface this worktree in the editor (files + live Source Control) for a
// technical run, so the developer can watch the code being modified. Done under
// the session lock so a parallel wave's edits to the shared .code-workspace do
// not race. No-op under a managed launcher (owns its own UI) or a non-technical
// run (no progress log), matching the render-status board's gating.
if (
!process.env.CHAT_SESSION_DIR &&
existsSync(join(ctx.sessionDir, "harness-progress.log"))
) {
addWorktreeFolder(
ctx.repo,
worktreePath,
`🎫 ${taskId || "simple"} · ${ctx.sessionShort}`,
);
}
// Git-mutation region is done — release the lock before provisioning so a
// parallel-wave waiter can serialise its own git ops while this hook copies
// node_modules. Distinct target dirs, copied from $REPO, so the copies can run
// concurrently without racing.
releaseLock();
try {
for (const wt of toProvision) ctx.provisionWorktree(wt);
} catch (e) {
// Fail closed (exit 2 -> block the dispatch): a developer must not start in a
// worktree with no dependencies. An uncaught throw would exit 1, which a
// PreToolUse hook treats as non-blocking, letting the broken dispatch run.
ctx.fail(`worktree provisioning failed for ${worktreePath}: ${e.message}`, {
log: `provision-failed wt=${worktreePath}`,
});
}
ctx.accept(`OK wt=${worktreePath}`);

View File

@@ -0,0 +1,274 @@
// Tests for bash-guard.mjs — browser rules (any caller) and validation-command rules (gated agents only). Blocks are decision JSON on stdout with exit 0; allowed commands produce no decision.
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { afterAll, describe, test, expect } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "bash-guard.mjs");
const tmpRoot = mkdtempSync(join(tmpdir(), "bash-guard-tmp-"));
afterAll(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
const runHook = (agent, command) => {
const env = { ...process.env, CRM_TMP_ROOT: tmpRoot };
delete env.CLAUDE_AGENT_NAME;
const input = JSON.stringify({
tool_name: "Bash",
agent_type: agent,
session_id: "test-1234",
tool_input: { command },
});
return spawnSync("node", [HOOK], { input, env, encoding: "utf8" });
};
// Run the hook against a temp repo whose harness.config.json we control, so we
// can prove the forbidden-command set is DERIVED from config.validation.
const runHookWithConfig = (agent, command, config) => {
const repo = mkdtempSync(join(tmpdir(), "bash-guard-repo-"));
writeFileSync(join(repo, "harness.config.json"), JSON.stringify(config));
const env = { ...process.env, CRM_TMP_ROOT: tmpRoot, APP_DIR: repo };
delete env.CLAUDE_AGENT_NAME;
delete env.CLAUDE_PROJECT_DIR;
const input = JSON.stringify({
tool_name: "Bash",
agent_type: agent,
session_id: "test-1234",
tool_input: { command },
});
const r = spawnSync("node", [HOOK], { input, env, encoding: "utf8" });
rmSync(repo, { recursive: true, force: true });
return r;
};
const isBlocked = (r) => r.stdout.includes('"decision":"block"');
describe("bash-guard hook", () => {
describe("browser rules — any caller", () => {
test("headed playwright test from main session → blocked", () => {
const r = runHook("", "npx playwright test");
expect(r.status).toBe(0);
expect(isBlocked(r)).toBe(true);
expect(r.stdout).toContain("--headless");
});
test("headed playwright screenshot from merger → blocked", () => {
const r = runHook(
"merger",
"npx playwright screenshot http://localhost:5173 out.png",
);
expect(isBlocked(r)).toBe(true);
});
test("playwright with --headless from main session → allowed", () => {
const r = runHook(
"",
"npx playwright screenshot --headless http://localhost:5173 out.png",
);
expect(r.status).toBe(0);
expect(isBlocked(r)).toBe(false);
});
test("vite --open → blocked", () => {
const r = runHook("", "npm run dev -- --open");
expect(isBlocked(r)).toBe(true);
});
test("vite without --open → allowed", () => {
const r = runHook("", "npm run dev");
expect(isBlocked(r)).toBe(false);
});
});
describe("validation rules — gated agents only", () => {
const cases = [
["developer", "npm run typecheck"],
["developer", "npx tsc --noEmit"],
["developer", "npx vitest run"],
["developer", "npm run prettier:apply"],
["quality-reviewer", "npx playwright test --headless"],
["quality-reviewer", "make lint"],
["developer", "npm run build"],
];
test.each(cases)("%s running '%s' → blocked", (agent, command) => {
const r = runHook(agent, command);
expect(r.status).toBe(0);
expect(isBlocked(r)).toBe(true);
});
test("non-gated agent running validation command → allowed", () => {
const r = runHook("merger", "npm run typecheck");
expect(isBlocked(r)).toBe(false);
});
test("main session running validation command → allowed", () => {
const r = runHook("", "npx vitest run");
expect(isBlocked(r)).toBe(false);
});
test("gated agent running plain git command → allowed", () => {
const r = runHook("developer", "git status && git log --oneline -3");
expect(isBlocked(r)).toBe(false);
});
test("empty command → allowed", () => {
const r = runHook("developer", "");
expect(isBlocked(r)).toBe(false);
});
});
describe("forbidden set is config-driven (no triple-encoding)", () => {
// A config with only a typecheck step and no extraForbidden: typecheck stays
// guarded, but unit/e2e/build are no longer part of the chain, so the guard
// must NOT block them.
const typecheckOnly = {
validation: {
steps: [
{ id: "typecheck", kind: "typecheck", command: "npm run typecheck" },
],
extraForbidden: [],
},
roles: { developer: { model: "sonnet" } },
};
test("a kind present in config is still blocked", () => {
const r = runHookWithConfig(
"developer",
"npm run typecheck",
typecheckOnly,
);
expect(isBlocked(r)).toBe(true);
});
test("a kind absent from config is NOT blocked", () => {
const r = runHookWithConfig("developer", "npx vitest run", typecheckOnly);
expect(isBlocked(r)).toBe(false);
});
test("removing lint from extraForbidden stops blocking lint", () => {
const r = runHookWithConfig("developer", "npm run lint", typecheckOnly);
expect(isBlocked(r)).toBe(false);
});
test("a lint step in the chain blocks manual lint (no extraForbidden)", () => {
const withLintStep = {
validation: {
steps: [
{
id: "lint",
kind: "lint",
command: "npx eslint",
changedScoped: true,
},
],
extraForbidden: [],
},
roles: { developer: { model: "sonnet" } },
};
const r = runHookWithConfig("developer", "npm run lint", withLintStep);
expect(isBlocked(r)).toBe(true);
});
test("extraForbidden build is blocked when listed", () => {
const withBuild = {
validation: { steps: [], extraForbidden: ["build"] },
roles: { developer: { model: "sonnet" } },
};
const r = runHookWithConfig("developer", "npm run build", withBuild);
expect(isBlocked(r)).toBe(true);
});
});
describe("guard-state rule — orchestrator only", () => {
const SD = "/tmp/_repo/sess-1234";
test("orchestrator touches a review flag → blocked", () => {
const r = runHook(
"orchestrator",
`touch ${SD}/reviews/TASK-002-quality-reviewer`,
);
expect(r.status).toBe(0);
expect(isBlocked(r)).toBe(true);
});
test("orchestrator rm's a breaker marker → blocked", () => {
const r = runHook("orchestrator", `rm ${SD}/breaker/dispatch-abc123`);
expect(isBlocked(r)).toBe(true);
});
test("orchestrator mkdir + touch a review flag (compound) → blocked", () => {
const r = runHook(
"orchestrator",
`mkdir -p ${SD}/reviews && touch ${SD}/reviews/TASK-002-quality-reviewer`,
);
expect(isBlocked(r)).toBe(true);
});
test("orchestrator redirects into a breaker file → blocked", () => {
const r = runHook("orchestrator", `echo x > ${SD}/breaker/marker`);
expect(isBlocked(r)).toBe(true);
});
test("orchestrator lists the reviews dir (read) → allowed", () => {
const r = runHook("orchestrator", `ls ${SD}/reviews/`);
expect(isBlocked(r)).toBe(false);
});
test("orchestrator cats a review flag with 2>/dev/null (read) → allowed", () => {
const r = runHook(
"orchestrator",
`cat ${SD}/reviews/TASK-002-quality-reviewer 2>/dev/null`,
);
expect(isBlocked(r)).toBe(false);
});
test("quality-reviewer touches its own flag → allowed (reviewer is the writer)", () => {
const r = runHook(
"quality-reviewer",
`mkdir -p ${SD}/reviews && touch ${SD}/reviews/TASK-002-quality-reviewer`,
);
expect(isBlocked(r)).toBe(false);
});
test("orchestrator rm of an unrelated tmp file → allowed", () => {
const r = runHook("orchestrator", `rm ${SD}/scratch/note.txt`);
expect(isBlocked(r)).toBe(false);
});
test("chat-orchestrator variant touching a review flag → blocked", () => {
const r = runHook(
"chat-orchestrator",
`touch ${SD}/reviews/TASK-001-quality-reviewer`,
);
expect(isBlocked(r)).toBe(true);
});
// Regression: the form the codebase teaches the reviewer uses a shell
// variable, so the literal command text is `…/reviews"` then `$RD/…` and
// never contains `/reviews/`. The trailing-slash-only guard missed it,
// letting a confused orchestrator forge a verdict flag through the
// documented form.
test("orchestrator forging the flag via the documented variable form → blocked", () => {
const r = runHook(
"orchestrator",
`RD="$(dirname "$TICKET_FILE")/reviews" && mkdir -p "$RD" && touch "$RD/TASK-002-quality-reviewer"`,
);
expect(isBlocked(r)).toBe(true);
});
test("orchestrator cd-ing into the reviews dir then touching a flag → blocked", () => {
const r = runHook(
"orchestrator",
`cd ${SD}/reviews && touch TASK-002-quality-reviewer`,
);
expect(isBlocked(r)).toBe(true);
});
});
});

View File

@@ -0,0 +1,95 @@
// Tests for block-docker-containers.mjs — blocks container launches (run/create/
// start, compose up) for any caller including the main session, except when the
// command references Supabase. Blocks are decision JSON on stdout with exit 0;
// allowed commands produce no decision.
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { afterAll, describe, test, expect } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "block-docker-containers.mjs");
const tmpRoot = mkdtempSync(join(tmpdir(), "block-docker-tmp-"));
afterAll(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
const runHook = (agent, command) => {
const env = { ...process.env, CRM_TMP_ROOT: tmpRoot };
delete env.CLAUDE_AGENT_NAME;
const input = JSON.stringify({
tool_name: "Bash",
agent_type: agent,
session_id: "test-1234",
tool_input: { command },
});
return spawnSync("node", [HOOK], { input, env, encoding: "utf8" });
};
const isBlocked = (r) => r.stdout.includes('"decision":"block"');
describe("block-docker-containers hook", () => {
describe("container launches → blocked (any caller, incl. main session)", () => {
const blocked = [
["", "docker run -it ubuntu bash"],
["", "docker run -v /home:/host alpine"],
["", "docker create nginx"],
["", "docker start my-stopped-container"],
["", "docker container run redis"],
["", "docker compose up -d"],
["", "docker-compose up"],
["developer", "docker run hello-world"],
["", "echo hi && docker run busybox"],
];
test.each(blocked)("%s running '%s' → blocked", (agent, command) => {
const r = runHook(agent, command);
expect(r.status).toBe(0);
expect(isBlocked(r)).toBe(true);
});
});
describe("Supabase containers → allowed", () => {
const allowed = [
["", "docker run supabase/postgres:15"],
["", "docker start supabase_db_atomic-crm"],
["", "docker compose -f supabase/docker-compose.yml up -d"],
["", "npx supabase start"],
["", "make start"],
];
test.each(allowed)("%s running '%s' → allowed", (agent, command) => {
const r = runHook(agent, command);
expect(r.status).toBe(0);
expect(isBlocked(r)).toBe(false);
});
});
describe("non-launch docker verbs → allowed", () => {
const allowed = [
"docker ps",
"docker logs my-container",
"docker stop my-container",
"docker rm my-container",
"docker exec my-container ls",
"docker images",
"docker version",
];
test.each(allowed)("'%s' → allowed", (command) => {
const r = runHook("", command);
expect(isBlocked(r)).toBe(false);
});
});
test("empty command → allowed", () => {
const r = runHook("", "");
expect(r.status).toBe(0);
expect(isBlocked(r)).toBe(false);
});
});

View File

@@ -0,0 +1,180 @@
// Tests for block-duplicate-dispatch.mjs — the PreToolUse(Agent) gate that
// stops accidental duplicate dispatches. Two concerns:
// 1. planner: at most one per (caller, TICKETS_DIR).
// 2. developer/quality-reviewer/merger: debounce identical re-dispatches
// inside a short window (the async-runtime "Async agent launched" trap).
// A block is emitted via decisionBlock: exit 0 with {"decision":"block"} on
// stdout (NOT exit 2). An allowed dispatch exits 0 with no decision JSON.
import {
mkdtempSync,
mkdirSync,
readdirSync,
rmSync,
utimesSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { describe, test, expect, beforeAll, afterAll } from "vitest";
import { sanitizePath } from "../lib/paths.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "block-duplicate-dispatch.mjs");
const SESSION_ID = "ab12cd34-1111-2222-3333-444455556666";
const CALLER = "orchestrator-agent-1";
let TMP;
let APP_DIR;
let breakerDir;
let env;
// Returns { blocked: boolean } — blocked when the decisionBlock JSON is printed.
// runInBackground defaults to false: in the real harness (post force-foreground)
// every pipeline dispatch carries run_in_background:false, and block-duplicate only
// debounces those. Pass `undefined` to simulate the malformed (absent) dispatch that
// force-foreground denies — block-duplicate must skip it (no marker).
const run = (
prompt,
subagentType,
agentId = CALLER,
runInBackground = false,
) => {
const tool_input = { subagent_type: subagentType, prompt };
// Pass the sentinel "absent" to OMIT the field (a default param can't express this:
// passing `undefined` still triggers the default). "absent" simulates the malformed
// dispatch force-foreground denies.
if (runInBackground !== "absent")
tool_input.run_in_background = runInBackground;
const r = spawnSync("node", [HOOK], {
input: JSON.stringify({
session_id: SESSION_ID,
agent_id: agentId,
tool_input,
}),
env,
encoding: "utf8",
});
return {
blocked: /"decision":"block"/.test(r.stdout || ""),
status: r.status,
};
};
// Backdate every marker so the next dispatch sees it as outside the debounce
// window (and a planner marker as stale).
const ageMarkers = (secondsAgo) => {
const when = Date.now() / 1000 - secondsAgo;
for (const f of readdirSync(breakerDir)) {
utimesSync(join(breakerDir, f), when, when);
}
};
beforeAll(() => {
TMP = mkdtempSync(join(tmpdir(), "block-dup-dispatch-test-"));
APP_DIR = join(TMP, "app");
const CRM_TMP_ROOT = join(TMP, "scratch");
breakerDir = join(CRM_TMP_ROOT, sanitizePath(APP_DIR), SESSION_ID, "breaker");
mkdirSync(breakerDir, { recursive: true });
env = { ...process.env, APP_DIR, CRM_TMP_ROOT };
});
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
describe("block-duplicate-dispatch — planner", () => {
test("allows the first planner, blocks the second for the same TICKETS_DIR", () => {
const p = `TICKETS_DIR=${join(TMP, "tickets")}`;
expect(run(p, "planner").blocked).toBe(false);
expect(run(p, "planner").blocked).toBe(true);
});
test("allows a planner again once the marker is stale (>1h)", () => {
ageMarkers(2 * 60 * 60); // 2h
const p = `TICKETS_DIR=${join(TMP, "tickets")}`;
expect(run(p, "planner").blocked).toBe(false);
});
});
describe("block-duplicate-dispatch — reviewer/developer/merger debounce", () => {
test("blocks a second quality-reviewer for the same ticket within the window", () => {
const p = `ROLE: quality-reviewer\nTASK_ID: TASK-100\nWORKTREE_PATH: /wt`;
expect(run(p, "quality-reviewer").blocked).toBe(false);
expect(run(p, "quality-reviewer").blocked).toBe(true);
});
test("allows a reviewer for a different ticket", () => {
expect(
run(
`ROLE: quality-reviewer\nTASK_ID: TASK-200\nWORKTREE_PATH: /wt`,
"quality-reviewer",
).blocked,
).toBe(false);
});
test("allows the same reviewer again once outside the debounce window", () => {
ageMarkers(5 * 60); // 5 min — well past the 90s window
expect(
run(
`ROLE: quality-reviewer\nTASK_ID: TASK-100\nWORKTREE_PATH: /wt`,
"quality-reviewer",
).blocked,
).toBe(false);
});
test("debounces by ticket, not role: a merger for a fresh ticket is allowed", () => {
expect(
run(`ROLE: merger\nTASK_ID: TASK-300\nBRANCH_NAME: x`, "merger").blocked,
).toBe(false);
});
test("a different caller is not debounced against the first caller's marker", () => {
const p = `ROLE: developer\nTASK_ID: TASK-400\nWORKTREE_PATH: /wt`;
expect(run(p, "developer", "orchestrator-A").blocked).toBe(false);
expect(run(p, "developer", "orchestrator-B").blocked).toBe(false);
});
test("falls back to a prompt hash when there is no TASK_ID (SIMPLE flow)", () => {
const p = `ROLE: developer\nCHANGE_REQUEST: rename the button\nWORKTREE_PATH: /wt`;
expect(run(p, "developer").blocked).toBe(false);
expect(run(p, "developer").blocked).toBe(true); // identical prompt → duplicate
// A different change request is a distinct dispatch, not a duplicate.
expect(
run(
`ROLE: developer\nCHANGE_REQUEST: hide the export\nWORKTREE_PATH: /wt`,
"developer",
).blocked,
).toBe(false);
});
});
describe("block-duplicate-dispatch — pass-through", () => {
test("ignores roles outside the debounce set (documentator)", () => {
const p = `ROLE: documentator`;
expect(run(p, "documentator").blocked).toBe(false);
expect(run(p, "documentator").blocked).toBe(false); // never debounced
});
test("allows when no caller agent_id is present (can't scope a marker)", () => {
const r = run(
`ROLE: quality-reviewer\nTASK_ID: TASK-100\nWORKTREE_PATH: /wt`,
"quality-reviewer",
"",
);
expect(r.blocked).toBe(false);
});
});
describe("block-duplicate-dispatch — force-foreground retry (regression 8468cc06)", () => {
// A pipeline dispatch without run_in_background:false is DENIED by
// force-foreground-orchestrator-dispatch and re-issued. block-duplicate must NOT
// record a marker for the denied attempt, or the corrective retry would be wrongly
// rejected as a duplicate (this wedged planning for 60 min in a real run).
test("a rib-absent pipeline dispatch is skipped, so the rib:false retry is not a duplicate", () => {
const p = `TICKETS_DIR=${join(TMP, "tickets-ff")}`;
const FF = "ff-orch"; // unique caller: no marker collision with earlier tests
expect(run(p, "planner", FF, "absent").blocked).toBe(false); // denied form -> skipped, no marker
expect(run(p, "planner", FF, false).blocked).toBe(false); // the retry proceeds
expect(run(p, "planner", FF, false).blocked).toBe(true); // a genuine 2nd planner is still blocked
});
});

View File

@@ -0,0 +1,90 @@
// Tests for block-merger-without-review.mjs — the PreToolUse(Agent) gate that
// blocks a per-ticket merger until the quality-reviewer recorded APPROVED (flags
// under <sessionDir>/reviews/<TASK>-<role>, written by record-review-verdict.mjs). The
// gate exits 2 to block and 0 to allow. Skipped for promotion-only / MIGRATION /
// ROLLBACK dispatches and when the ticket id can't be parsed (fail open).
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { describe, test, expect, beforeAll, afterAll } from "vitest";
import { sanitizePath } from "../lib/paths.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "block-merger-without-review.mjs");
const SS = "ab12cd34";
const SESSION_ID = `${SS}-1111-2222-3333-444455556666`;
let TMP;
let APP_DIR;
let env;
let reviewsDir;
const run = (prompt, subagentType = "merger") =>
spawnSync("node", [HOOK], {
input: JSON.stringify({
session_id: SESSION_ID,
tool_input: { subagent_type: subagentType, prompt },
}),
env,
encoding: "utf8",
}).status;
const approve = (task, role) =>
writeFileSync(join(reviewsDir, `${task}-${role}`), "");
beforeAll(() => {
TMP = mkdtempSync(join(tmpdir(), "block-merger-test-"));
APP_DIR = join(TMP, "app");
const CRM_TMP_ROOT = join(TMP, "scratch");
reviewsDir = join(CRM_TMP_ROOT, sanitizePath(APP_DIR), SESSION_ID, "reviews");
mkdirSync(reviewsDir, { recursive: true });
env = { ...process.env, APP_DIR, CRM_TMP_ROOT };
// Neutralise an ambient CHAT_SESSION_DIR: reviewsDir() prefers it over
// ctx.sessionDir (managed-launcher path), which would point the verdict flag
// away from this test's CRM_TMP_ROOT fixture. See reviews.test.mjs for the
// CHAT_SESSION_DIR-present behaviour.
delete env.CHAT_SESSION_DIR;
});
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
describe("block-merger-without-review", () => {
test("blocks a per-ticket merger when no reviewer flags exist", () => {
expect(
run(`ROLE: merger\nTASK_ID: TASK-001\nBRANCH_NAME: ${SS}/TASK-001`),
).toBe(2);
});
test("allows the merger once the reviewer approved", () => {
approve("TASK-003", "quality-reviewer");
expect(
run(`ROLE: merger\nTASK_ID: TASK-003\nBRANCH_NAME: ${SS}/TASK-003`),
).toBe(0);
});
test("ignores the promotion-only merger (MODE: promote)", () => {
expect(run(`ROLE: merger\nMODE: promote\nSESSION_SHORT_ID: ${SS}`)).toBe(0);
});
test("ignores the MIGRATION merger (no per-ticket review)", () => {
expect(
run(`ROLE: merger\nTASK_ID: MIGRATION\nBRANCH_NAME: ${SS}/simple`),
).toBe(0);
});
test("ignores a non-merger dispatch", () => {
expect(
run(
`ROLE: developer\nTASK_ID: TASK-001\nWORKTREE_PATH: /wt`,
"developer",
),
).toBe(0);
});
test("fails open when the ticket id can't be parsed (no TASK_ID line)", () => {
expect(run(`ROLE: merger\nBRANCH_NAME: ${SS}/whatever`)).toBe(0);
});
});

View File

@@ -0,0 +1,90 @@
// Tests for block-nested-orchestrator.mjs — the PreToolUse(Agent) gate that
// enforces the harness dispatch topology. Two rules:
// 1. the orchestrator may dispatch ONLY the typed harness agents
// (planner, developer, quality-reviewer, merger, documentator).
// 2. a subagent (has agent_id) must NOT dispatch an orchestrator.
// A block is emitted via decisionBlock: exit 0 with {"decision":"block"} on
// stdout. An allowed dispatch exits 0 with no decision JSON.
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { afterAll, describe, test, expect } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "block-nested-orchestrator.mjs");
const TMP = mkdtempSync(join(tmpdir(), "block-nested-orch-test-"));
const env = { ...process.env, APP_DIR: TMP, CRM_TMP_ROOT: TMP };
delete env.CLAUDE_AGENT_NAME;
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
// callerType = the dispatching agent's agent_type; agentId distinguishes the
// main thread ("") from a subagent (non-empty).
const run = (callerType, target, agentId = "") => {
const r = spawnSync("node", [HOOK], {
input: JSON.stringify({
session_id: "td-nested-1",
agent_type: callerType,
agent_id: agentId,
tool_input: { subagent_type: target },
}),
env,
encoding: "utf8",
});
return {
blocked: /"decision":"block"/.test(r.stdout || ""),
status: r.status,
};
};
describe("block-nested-orchestrator — Rule 1 (orchestrator allowlist)", () => {
for (const t of [
"planner",
"developer",
"quality-reviewer",
"merger",
"documentator",
]) {
test(`orchestrator dispatching ${t} → allowed`, () => {
expect(run("orchestrator", t, "orch-1").blocked).toBe(false);
});
}
test("orchestrator dispatching general-purpose → blocked", () => {
expect(run("orchestrator", "general-purpose", "orch-1").blocked).toBe(true);
});
test("orchestrator dispatching another orchestrator → blocked", () => {
expect(run("orchestrator", "orchestrator", "orch-1").blocked).toBe(true);
});
test("a suffixed orchestrator-name caller is still gated by the allowlist", () => {
expect(run("orchestrator-abc12", "Explore", "orch-1").blocked).toBe(true);
expect(run("orchestrator-abc12", "planner", "orch-1").blocked).toBe(false);
});
test("chat-orchestrator dispatching planner → allowed", () => {
expect(run("chat-orchestrator", "planner", "chat-1").blocked).toBe(false);
});
});
describe("block-nested-orchestrator — Rule 2 (no nested orchestrator)", () => {
test("a subagent dispatching orchestrator → blocked", () => {
expect(run("general-purpose", "orchestrator", "gp-1").blocked).toBe(true);
});
test("a subagent dispatching chat-orchestrator → blocked", () => {
expect(run("developer", "chat-orchestrator", "dev-1").blocked).toBe(true);
});
test("the main thread (no agent_id) dispatching orchestrator → allowed", () => {
expect(run("", "orchestrator", "").blocked).toBe(false);
});
test("a subagent dispatching a non-orchestrator agent → allowed", () => {
expect(run("developer", "general-purpose", "dev-1").blocked).toBe(false);
});
});

View File

@@ -0,0 +1,116 @@
// Tests for block-promote-unmerged.mjs — the no-team promotion guard (vitest,
// "claude" Node project). Builds a throwaway repo with a session branch + one
// merged and one unmerged task branch, then exercises the PreToolUse(Agent) hook.
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "block-promote-unmerged.mjs");
const SS = "ab12cd34";
const SESSION_ID = `${SS}-1111-2222-3333-444455556666`;
let TMP;
let APP_DIR;
let env;
const g = (...a) =>
spawnSync("git", ["-C", APP_DIR, ...a], { encoding: "utf8" });
const run = (payload) =>
spawnSync("node", [HOOK], {
input: JSON.stringify({ session_id: SESSION_ID, ...payload }),
env,
encoding: "utf8",
}).status;
const PROMOTE = {
agent_type: "",
tool_input: {
subagent_type: "merger",
prompt: `ROLE: merger\nMODE: promote\nSESSION_SHORT_ID: ${SS}`,
},
};
beforeAll(() => {
TMP = mkdtempSync(join(tmpdir(), "promote-unmerged-test-"));
APP_DIR = join(TMP, "app");
mkdirSync(APP_DIR, { recursive: true });
env = { ...process.env, APP_DIR };
g("init", "-q", "-b", "main");
g("config", "user.email", "t@t.t");
g("config", "user.name", "t");
writeFileSync(join(APP_DIR, "seed.txt"), "seed\n");
g("add", ".");
g("commit", "-qm", "seed");
g("branch", `session/${SS}`, "main");
g("branch", `session-base/${SS}`, "main");
// A merged task branch (its commits are already on the session branch).
g("checkout", "-q", "-b", `${SS}/TASK-001`, `session/${SS}`);
writeFileSync(join(APP_DIR, "a.txt"), "a\n");
g("add", ".");
g("commit", "-qm", "feat(TASK-001): work");
g("checkout", "-q", `session/${SS}`);
g("merge", "-q", "--no-ff", `${SS}/TASK-001`, "-m", "merge(TASK-001)");
g("checkout", "-q", "main");
});
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
describe("block-promote-unmerged", () => {
test("allows promotion when every task branch is merged", () => {
expect(run(PROMOTE)).toBe(0);
});
test("blocks promotion when a task branch has unmerged commits", () => {
g("checkout", "-q", "-b", `${SS}/TASK-002`, `session/${SS}`);
writeFileSync(join(APP_DIR, "b.txt"), "b\n");
g("add", ".");
g("commit", "-qm", "feat(TASK-002): work");
g("checkout", "-q", "main");
expect(run(PROMOTE)).toBe(2);
});
test("does not gate a per-ticket merger (no MODE: promote)", () => {
const perTicket = {
tool_input: {
subagent_type: "merger",
prompt: `ROLE: merger\nTASK_ID: TASK-002\nBRANCH_NAME: ${SS}/TASK-002`,
},
};
expect(run(perTicket)).toBe(0);
});
test("does not gate a developer dispatch", () => {
const dev = {
tool_input: {
subagent_type: "developer",
prompt: `ROLE: developer\nTASK_ID: TASK-003\nWORKTREE_PATH: /wt\nBRANCH_NAME: ${SS}/TASK-003`,
},
};
expect(run(dev)).toBe(0);
});
test("allows promotion again once the branch is merged", () => {
g("checkout", "-q", `session/${SS}`);
g("merge", "-q", "--no-ff", `${SS}/TASK-002`, "-m", "merge(TASK-002)");
g("checkout", "-q", "main");
expect(run(PROMOTE)).toBe(0);
});
test("ignores the <short>/simple branch even when ahead of the session branch", () => {
// <short>/simple promotes straight to main (never into the session branch),
// so its commits are legitimately not on session/<short> and must NOT block a
// wave promotion.
g("checkout", "-q", "-b", `${SS}/simple`, "main");
writeFileSync(join(APP_DIR, "simple.txt"), "s\n");
g("add", ".");
g("commit", "-qm", "feat: an simple change");
g("checkout", "-q", "main");
expect(run(PROMOTE)).toBe(0);
});
});

View File

@@ -0,0 +1,145 @@
// Tests for block-wave-merger-promote.mjs: the PreToolUse(Bash) guard that stops a
// Stage-A-only merger (a per-ticket wave merge, or STAGE: a-only) from running Stage B
// (promoting session/<short> into the base branch). Authorization is recorded at
// dispatch by record-merger-stage.mjs into <sessionDir>/merger-stage.json; the guard
// exits 2 to block and 0 to allow, and fails CLOSED when no marker exists.
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import {
describe,
test,
expect,
beforeAll,
beforeEach,
afterAll,
} from "vitest";
import { sanitizePath } from "../lib/paths.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const BLOCK = join(HERE, "..", "block-wave-merger-promote.mjs");
const RECORD = join(HERE, "..", "record-merger-stage.mjs");
const SS = "ab12cd34";
const SESSION_ID = `${SS}-1111-2222-3333-444455556666`;
const STAGE_B = `cd /wt && git merge --no-ff session/${SS} -m "merge(session): ${SS}"`;
const STAGE_A = `cd /wt && git merge --no-ff ${SS}/TASK-001 -m "merge(${SS}/TASK-001)"`;
let TMP, APP_DIR, env, sessionDir, marker;
const setMarker = (promote) =>
writeFileSync(marker, JSON.stringify({ promote }));
const clearMarker = () => rmSync(marker, { force: true });
// The Bash guard: agent_type carries the (possibly suffixed) merger identity.
const runBlock = (command, agentType = "merger") =>
spawnSync("node", [BLOCK], {
input: JSON.stringify({
tool_name: "Bash",
agent_type: agentType,
session_id: SESSION_ID,
tool_input: { command },
}),
env,
encoding: "utf8",
}).status;
// The Agent dispatch recorder: writes the marker as a side effect.
const runRecord = (prompt) =>
spawnSync("node", [RECORD], {
input: JSON.stringify({
session_id: SESSION_ID,
tool_input: { subagent_type: "merger", prompt },
}),
env,
encoding: "utf8",
}).status;
beforeAll(() => {
TMP = mkdtempSync(join(tmpdir(), "wave-merger-promote-test-"));
APP_DIR = join(TMP, "app");
const CRM_TMP_ROOT = join(TMP, "scratch");
sessionDir = join(CRM_TMP_ROOT, sanitizePath(APP_DIR), SESSION_ID);
marker = join(sessionDir, "merger-stage.json");
mkdirSync(sessionDir, { recursive: true });
env = { ...process.env, APP_DIR, CRM_TMP_ROOT };
delete env.CHAT_SESSION_DIR;
delete env.CLAUDE_AGENT_NAME;
});
beforeEach(() => clearMarker());
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
describe("block-wave-merger-promote", () => {
test("blocks a Stage-B promotion when the marker says promote=false", () => {
setMarker(false);
expect(runBlock(STAGE_B)).toBe(2);
});
test("allows a Stage-B promotion when the marker says promote=true", () => {
setMarker(true);
expect(runBlock(STAGE_B)).toBe(0);
});
test("fails CLOSED (blocks) when no authorization marker exists", () => {
expect(runBlock(STAGE_B)).toBe(2);
});
test("allows a Stage-A merge (task branch into session), even for a Stage-A-only merger", () => {
setMarker(false);
expect(runBlock(STAGE_A)).toBe(0);
});
test("detects the promotion via the merge(session) message alone", () => {
setMarker(false);
expect(runBlock(`git merge --no-ff HEAD -m "merge(session): ${SS}"`)).toBe(
2,
);
});
test("ignores a non-merger caller running the same command", () => {
setMarker(false);
expect(runBlock(STAGE_B, "developer")).toBe(0);
});
test("ignores a merger command that is not a git merge", () => {
setMarker(false);
expect(runBlock(`git checkout session/${SS}`)).toBe(0);
});
describe("integration with record-merger-stage", () => {
test("wave dispatch (TASK-XXX + STAGE: a-only) records promote=false -> Stage B blocked", () => {
expect(
runRecord(
`ROLE: merger\nTASK_ID: TASK-001\nSTAGE: a-only\nBRANCH_NAME: ${SS}/TASK-001`,
),
).toBe(0);
expect(runBlock(STAGE_B)).toBe(2);
});
test("plain wave dispatch (TASK-XXX, no STAGE) still records promote=false -> Stage B blocked", () => {
expect(
runRecord(
`ROLE: merger\nTASK_ID: TASK-001\nBRANCH_NAME: ${SS}/TASK-001`,
),
).toBe(0);
expect(runBlock(STAGE_B)).toBe(2);
});
test("promotion dispatch (MODE: promote) records promote=true -> Stage B allowed", () => {
expect(
runRecord(`ROLE: merger\nMODE: promote\nSESSION_SHORT_ID: ${SS}`),
).toBe(0);
expect(runBlock(STAGE_B)).toBe(0);
});
test("SIMPLE single-shot dispatch records promote=true -> Stage B allowed", () => {
expect(
runRecord(`ROLE: merger (SIMPLE mode)\nBRANCH_NAME: ${SS}/simple`),
).toBe(0);
expect(runBlock(STAGE_B)).toBe(0);
});
});
});

View File

@@ -0,0 +1,74 @@
// Tests for scripts/check-config-sync.mjs: settings.json SubagentStop role
// matchers must all be declared in harness.config.json (config.roles).
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { afterEach, describe, test, expect } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const SCRIPT = join(HERE, "..", "..", "scripts", "check-config-sync.mjs");
const REPO_ROOT = join(HERE, "..", "..", "..");
const tmpRepos = [];
afterEach(() => {
while (tmpRepos.length)
rmSync(tmpRepos.pop(), { recursive: true, force: true });
});
// Build a temp repo with a harness.config.json (roles) and a settings.json
// (SubagentStop matchers), then run the script against it.
const runAgainst = ({ roles, matchers }) => {
const repo = mkdtempSync(join(tmpdir(), "config-sync-"));
tmpRepos.push(repo);
mkdirSync(join(repo, ".claude"), { recursive: true });
const rolesObj = Object.fromEntries(
roles.map((r) => [r, { model: "sonnet" }]),
);
writeFileSync(
join(repo, "harness.config.json"),
JSON.stringify({ validation: { steps: [] }, roles: rolesObj }),
);
const SubagentStop = matchers.map((m) => ({ matcher: m, hooks: [] }));
writeFileSync(
join(repo, ".claude", "settings.json"),
JSON.stringify({ hooks: { SubagentStop } }),
);
return spawnSync("node", [SCRIPT, "--app", repo], { encoding: "utf8" });
};
describe("check-config-sync", () => {
test("all matcher tokens declared → exit 0", () => {
const r = runAgainst({
roles: ["developer", "test-writer", "merger"],
matchers: ["developer|test-writer", "merger"],
});
expect(r.status).toBe(0);
});
test("a matcher token missing from config.roles → exit 1", () => {
const r = runAgainst({
roles: ["developer", "merger"],
matchers: ["developer|test-writer", "merger"],
});
expect(r.status).toBe(1);
expect(r.stderr).toContain("test-writer");
});
test("renaming a role in BOTH files stays in sync → exit 0", () => {
const r = runAgainst({
roles: ["dev", "merger"],
matchers: ["dev", "merger"],
});
expect(r.status).toBe(0);
});
test("the committed repo is in sync → exit 0", () => {
const r = spawnSync("node", [SCRIPT, "--app", REPO_ROOT], {
encoding: "utf8",
});
expect(r.status).toBe(0);
});
});

View File

@@ -0,0 +1,80 @@
// Tests for check-typescript-shortcuts.mjs, a non-blocking PostToolUse(Write|Edit)
// flag. It scans the written .ts/.tsx file for typing escape hatches and exits 1
// (flag, not block) with a stderr warning when it finds one; exit 0 otherwise.
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { afterAll, describe, test, expect } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "check-typescript-shortcuts.mjs");
const tmpRoot = mkdtempSync(join(tmpdir(), "ts-shortcuts-"));
afterAll(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
// Write `source` to a file named `name`, then run the hook against it.
const runHook = (name, source) => {
const filePath = join(tmpRoot, name);
writeFileSync(filePath, source);
const input = JSON.stringify({
tool_name: "Write",
session_id: "test-1234",
tool_input: { file_path: filePath },
});
return spawnSync("node", [HOOK], { input, encoding: "utf8" });
};
const isFlagged = (r) => r.status === 1;
describe("check-typescript-shortcuts hook", () => {
describe("typing escape hatches → flagged (exit 1)", () => {
const flagged = [
["as-any.ts", "const x = value as any;"],
["colon-any.ts", "let count: any = 0;"],
["tsfixme.ts", "type T = $TSFixMe;"],
["bare-ts-ignore.ts", "// @ts-ignore\nconst y = z.foo;"],
["bare-ts-expect-error.tsx", "// @ts-expect-error\nreturn <div/>;"],
];
test.each(flagged)("%s → flagged", (name, source) => {
const r = runHook(name, source);
expect(isFlagged(r)).toBe(true);
expect(r.stderr).toMatch(/typing workaround/);
});
});
describe("clean or justified code → not flagged (exit 0)", () => {
const clean = [
["typed.ts", "const x: number = 1;\nconst s: string = 'ok';"],
[
"justified.ts",
"// @ts-expect-error legacy API returns an untyped payload\nconst y = z.foo;",
],
["anyword.ts", "const anything: string = 'not a match';"],
];
test.each(clean)("%s → not flagged", (name, source) => {
const r = runHook(name, source);
expect(r.status).toBe(0);
});
});
test("non-TS file → ignored", () => {
const r = runHook("notes.md", "this has as any inside prose");
expect(r.status).toBe(0);
});
test("unparseable payload → fails open (exit 0)", () => {
const r = spawnSync("node", [HOOK], {
input: "not json",
encoding: "utf8",
});
expect(r.status).toBe(0);
});
});

View File

@@ -0,0 +1,111 @@
// Tests for circuit-breaker.mjs - per-subagent WORK-call counter. Blocks are decision
// JSON on stdout with exit 0; the main session (no agent_id) is never throttled.
// Read-only exploration and git plumbing/commit are "free" (never counted).
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { afterAll, describe, test, expect } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "circuit-breaker.mjs");
const tmpRoot = mkdtempSync(join(tmpdir(), "circuit-breaker-tmp-"));
const appDir = mkdtempSync(join(tmpdir(), "circuit-breaker-app-"));
afterAll(() => {
rmSync(tmpRoot, { recursive: true, force: true });
rmSync(appDir, { recursive: true, force: true });
});
// agentId controls the per-subagent counter file; vary it per test for isolation.
// `command` decides whether the call counts: a WORK command counts, a free one does not.
const WORK = "node work.mjs";
const runHook = (agentId, command = WORK, sessionId = "cb-1234") => {
const env = { ...process.env, CRM_TMP_ROOT: tmpRoot, APP_DIR: appDir };
delete env.CLAUDE_AGENT_NAME;
const payload = {
tool_name: "Bash",
session_id: sessionId,
tool_input: { command },
};
if (agentId) payload.agent_id = agentId;
return spawnSync("node", [HOOK], {
input: JSON.stringify(payload),
env,
encoding: "utf8",
});
};
const isBlocked = (r) => r.stdout.includes('"decision":"block"');
describe("circuit-breaker hook", () => {
test("main session (no agent_id) is never throttled", () => {
let last;
for (let i = 0; i < 60; i++) last = runHook("", WORK);
expect(last.status).toBe(0);
expect(isBlocked(last)).toBe(false);
});
test("subagent is blocked once its WORK calls exceed the iteration limit", () => {
let r;
for (let i = 1; i <= 45; i++) r = runHook("agent-loop", WORK);
expect(isBlocked(r)).toBe(false); // 45th work call still allowed
const blocked = runHook("agent-loop", WORK); // 46th
expect(blocked.status).toBe(0);
expect(isBlocked(blocked)).toBe(true);
expect(blocked.stdout).toContain("stuck in a loop");
});
test("each subagent has an independent budget", () => {
for (let i = 1; i <= 46; i++) runHook("agent-busy", WORK);
const otherAgent = runHook("agent-fresh", WORK);
expect(isBlocked(otherAgent)).toBe(false);
});
test("read-only search (grep/find/ls/cat) is free - never counted", () => {
let last;
for (const cmd of [
"grep -rn Foo src",
"find . -name x",
"ls -la",
"cat pkg.json",
"wc -l f",
]) {
for (let i = 0; i < 20; i++) last = runHook("agent-explore", cmd);
}
expect(isBlocked(last)).toBe(false); // 100 read-only calls, still not blocked
});
test("git add/commit/status is free - the final commit is never starved", () => {
let last;
for (let i = 0; i < 60; i++)
last = runHook("agent-commit", 'git commit -m "feat(TASK-001): x"');
expect(isBlocked(last)).toBe(false);
});
test("a `cd <worktree> &&` prefix is stripped before classifying", () => {
// free command behind a cd prefix stays free
let free;
for (let i = 0; i < 60; i++)
free = runHook("agent-cd-free", "cd /tmp/wt && grep -rn Foo src");
expect(isBlocked(free)).toBe(false);
// work command behind a cd prefix still counts
let work;
for (let i = 1; i <= 46; i++)
work = runHook("agent-cd-work", "cd /tmp/wt && node work.mjs");
expect(isBlocked(work)).toBe(true);
});
test("free calls do not consume the work budget (mixed workload)", () => {
// 100 greps then 45 work calls on the same agent: still under the limit.
for (let i = 0; i < 100; i++) runHook("agent-mixed", "grep -rn Foo src");
let r;
for (let i = 1; i <= 45; i++) r = runHook("agent-mixed", WORK);
expect(isBlocked(r)).toBe(false); // greps did not eat the budget
const blocked = runHook("agent-mixed", WORK); // 46th work call
expect(isBlocked(blocked)).toBe(true);
});
});

View File

@@ -0,0 +1,142 @@
// Tests for cleanup-session.mjs: the SessionEnd hook that tears down this
// session's worktrees (_session, simple, TASK-XXX) under <WORKTREE_BASE> and
// removes the promotion lock + Playwright test-results. Builds a throwaway repo
// with real worktrees ("claude" Node project, real git/worktree work).
import { spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, test } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "cleanup-session.mjs");
const SESSION_ID = "cafe1234-1111-2222-3333-444455556666";
const SHORT = SESSION_ID.split("-")[0];
const sanitize = (p) => p.replace(/\//g, "_");
let TMP = null;
afterEach(() => {
if (TMP) rmSync(TMP, { recursive: true, force: true });
TMP = null;
});
// A throwaway repo with a session branch + a _session and a simple worktree, plus
// the leftover files. Returns paths and a run() that fires the SessionEnd hook.
const setup = () => {
TMP = mkdtempSync(join(tmpdir(), "cleanup-session-test-"));
const app = join(TMP, "app");
const tmpRoot = join(TMP, "wtroot");
mkdirSync(app, { recursive: true });
const base = join(tmpRoot, sanitize(app), SESSION_ID);
mkdirSync(base, { recursive: true });
const g = (...a) => spawnSync("git", ["-C", app, ...a], { encoding: "utf8" });
g("init", "-q", "-b", "main");
g("config", "user.email", "t@t.t");
g("config", "user.name", "t");
writeFileSync(join(app, "seed.txt"), "seed\n");
g("add", ".");
g("commit", "-qm", "seed");
g("branch", `session/${SHORT}`, "main");
g("branch", `${SHORT}/simple`, "main");
g("worktree", "add", "-q", join(base, "_session"), `session/${SHORT}`);
g("worktree", "add", "-q", join(base, "simple"), `${SHORT}/simple`);
const promoteLock = join(app, ".promote.lock");
const testResults = join(app, "test-results");
writeFileSync(promoteLock, "");
mkdirSync(testResults, { recursive: true });
writeFileSync(join(testResults, ".last-run.json"), "{}");
const env = { ...process.env, APP_DIR: app, HARNESS_TMP_ROOT: tmpRoot };
delete env.CHAT_SESSION_DIR;
const run = (extraEnv = {}) =>
spawnSync("node", [HOOK], {
input: JSON.stringify({ session_id: SESSION_ID }),
env: { ...env, ...extraEnv },
encoding: "utf8",
});
const worktreeList = () => g("worktree", "list", "--porcelain").stdout;
return { app, base, promoteLock, testResults, run, worktreeList };
};
describe("cleanup-session", () => {
test("removes the session worktrees, base dir and test-results", () => {
const { base, testResults, run, worktreeList } = setup();
const r = run();
expect(r.status).toBe(0);
expect(existsSync(base)).toBe(false);
expect(existsSync(testResults)).toBe(false);
// Only the main worktree remains registered.
expect(worktreeList()).not.toContain("_session");
expect(worktreeList()).not.toContain("/simple");
});
test("leaves the shared .promote.lock in place (concurrent-promotion mutex)", () => {
const { promoteLock, run } = setup();
run();
expect(existsSync(promoteLock)).toBe(true);
});
test("removes only THIS session's rendered board, leaving other sessions'", () => {
const { app, run } = setup();
const mine = join(app, ".harness", SHORT);
const other = join(app, ".harness", "beef9999");
mkdirSync(mine, { recursive: true });
mkdirSync(other, { recursive: true });
writeFileSync(join(mine, "STATUS.md"), "# mine\n");
writeFileSync(join(other, "STATUS.md"), "# other\n");
run();
expect(existsSync(mine)).toBe(false);
expect(existsSync(other)).toBe(true);
});
test("leaves the main repo worktree intact", () => {
const { app, run, worktreeList } = setup();
run();
expect(existsSync(join(app, "seed.txt"))).toBe(true);
expect(worktreeList()).toContain(app);
});
test("preserves state for resume when a ticket is not yet merged (in-flight)", () => {
const { base, run } = setup();
writeFileSync(
join(base, "TASK-001.json"),
JSON.stringify({ id: "TASK-001", status: "planned" }),
);
const r = run();
expect(r.status).toBe(0);
// Teardown SKIPPED: the same session id must resume from disk later.
expect(existsSync(base)).toBe(true);
expect(existsSync(join(base, "TASK-001.json"))).toBe(true);
});
test("preserves state when the session branch has unpromoted commits", () => {
const { base, run } = setup();
const sessWt = join(base, "_session");
writeFileSync(join(sessWt, "wave.txt"), "x\n");
const gw = (...a) =>
spawnSync("git", ["-C", sessWt, ...a], { encoding: "utf8" });
gw("add", ".");
gw("commit", "-qm", "wave work");
const r = run();
expect(r.status).toBe(0);
expect(existsSync(base)).toBe(true);
});
test("is a no-op under a managed launcher (CHAT_SESSION_DIR set)", () => {
const { base, promoteLock, run } = setup();
const r = run({ CHAT_SESSION_DIR: "/tmp/managed-session-xyz" });
expect(r.status).toBe(0);
expect(existsSync(base)).toBe(true);
expect(existsSync(promoteLock)).toBe(true);
});
});

View File

@@ -0,0 +1,128 @@
// Tests for cleanup-worktree.mjs removal semantics (vitest, Node project). A
// task worktree is removed only once its branch was merged with --no-ff into
// the session branch and the worktree is clean; fresh, unmerged, and dirty
// worktrees are preserved. Tests are ordered and stateful — each observes the
// state produced by the previous step.
import {
mkdtempSync,
mkdirSync,
writeFileSync,
existsSync,
rmSync,
unlinkSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { describe, test, expect, beforeAll, afterAll } from "vitest";
import { sanitizePath } from "../lib/paths.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const SETUP = join(HERE, "..", "setup-worktree.mjs");
const CLEANUP = join(HERE, "..", "cleanup-worktree.mjs");
const SESSION_ID = "cd34ef56-1111-2222-3333-444455556666";
const SS = SESSION_ID.split("-")[0];
let TMP;
let APP_DIR;
let WB;
let env;
const g = (...args) =>
spawnSync("git", ["-C", APP_DIR, ...args], { encoding: "utf8" });
const gWt = (wt, ...args) =>
spawnSync("git", ["-C", wt, ...args], { encoding: "utf8" });
const dispatch = (hook, agentType) =>
spawnSync("node", [hook], {
input: JSON.stringify({ agent_type: agentType, session_id: SESSION_ID }),
env,
encoding: "utf8",
});
// setup-worktree is now a PreToolUse(Agent) hook — build a dispatch payload to
// provision the task worktrees this test then exercises cleanup against.
const setupDispatch = (taskId) =>
spawnSync("node", [SETUP], {
input: JSON.stringify({
session_id: SESSION_ID,
tool_input: {
subagent_type: "developer",
name: `developer-${taskId}`,
prompt: `ROLE: developer\nTASK_ID: ${taskId}\nWORKTREE_PATH: ${join(WB, taskId)}\nBRANCH_NAME: ${SS}/${taskId}`,
},
}),
env,
encoding: "utf8",
});
beforeAll(() => {
TMP = mkdtempSync(join(tmpdir(), "cleanup-wt-test-"));
APP_DIR = join(TMP, "app");
mkdirSync(APP_DIR, { recursive: true });
g("init", "-q", "-b", "main");
g("config", "user.email", "t@t.t");
g("config", "user.name", "t");
writeFileSync(join(APP_DIR, "seed.txt"), "seed\n");
g("add", ".");
g("commit", "-q", "-m", "seed");
mkdirSync(join(APP_DIR, "node_modules"), { recursive: true });
const CRM_TMP_ROOT = join(TMP, "scratch");
WB = join(CRM_TMP_ROOT, sanitizePath(APP_DIR), SESSION_ID);
env = { ...process.env, APP_DIR, CRM_TMP_ROOT };
delete env.VALIDATE_WORKTREE;
setupDispatch("TASK-001");
setupDispatch("TASK-002");
});
afterAll(() => {
rmSync(TMP, { recursive: true, force: true });
});
describe("cleanup-worktree removal semantics", () => {
test("fresh worktrees (no commits) are preserved", () => {
expect(dispatch(CLEANUP, "developer-TASK-001").status).toBe(0);
expect(existsSync(join(WB, "TASK-001"))).toBe(true);
expect(existsSync(join(WB, "TASK-002"))).toBe(true);
});
test("unmerged commits are preserved", () => {
const wt = join(WB, "TASK-001");
writeFileSync(join(wt, "work.txt"), "work\n");
gWt(wt, "add", ".");
gWt(wt, "commit", "-q", "-m", "TASK-001 work");
dispatch(CLEANUP, "developer-TASK-001");
expect(existsSync(wt)).toBe(true);
expect(g("branch", "--list", `${SS}/TASK-001`).stdout.trim()).not.toBe("");
});
test("merged but dirty worktree is preserved", () => {
const sessionWt = join(WB, "_session");
const merge = gWt(
sessionWt,
"merge",
"--no-ff",
`${SS}/TASK-001`,
"-m",
"merge TASK-001",
);
expect(merge.status).toBe(0);
writeFileSync(join(WB, "TASK-001", "uncommitted.txt"), "wip\n");
dispatch(CLEANUP, "developer-TASK-001");
expect(existsSync(join(WB, "TASK-001"))).toBe(true);
});
test("merged clean worktree is removed, branch deleted, fresh sibling kept", () => {
unlinkSync(join(WB, "TASK-001", "uncommitted.txt"));
expect(dispatch(CLEANUP, "developer-TASK-001").status).toBe(0);
expect(existsSync(join(WB, "TASK-001"))).toBe(false);
expect(g("branch", "--list", `${SS}/TASK-001`).stdout.trim()).toBe("");
expect(existsSync(join(WB, "TASK-002"))).toBe(true);
expect(
g("show-ref", "--verify", "--quiet", `refs/heads/session/${SS}`).status,
).toBe(0);
});
});

View File

@@ -0,0 +1,157 @@
// Tests for lib/config.mjs: the harness.config.json loader. It merges the file
// over the built-in defaults, validates the shape (fail-closed on malformed),
// and degrades to defaults when the file is missing.
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, test, expect } from "vitest";
import {
loadConfig,
clearConfigCache,
CONFIG_FILENAME,
isDeployEnabled,
deployGlobs,
pipelineRoles,
debounceRoles,
roleModel,
worktreeProvision,
prePrSteps,
} from "../lib/config.mjs";
const REPO_ROOT = join(
dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"..",
);
const tmpRepos = [];
const makeRepo = (config) => {
const dir = mkdtempSync(join(tmpdir(), "harness-config-"));
tmpRepos.push(dir);
if (config !== undefined) {
writeFileSync(
join(dir, CONFIG_FILENAME),
typeof config === "string" ? config : JSON.stringify(config),
);
}
clearConfigCache();
return dir;
};
const VALID = {
name: "sample",
validation: {
steps: [
{ id: "typecheck", kind: "typecheck", command: "npm run typecheck" },
{
id: "unit",
kind: "unit",
runner: "vitest",
config: "vitest.config.ts",
projects: ["app"],
},
],
},
roles: {
developer: { model: "sonnet", pipeline: true, debounce: true },
planner: { model: "opus", pipeline: true, debounce: false },
},
deploy: { adapter: "supabase", relevantGlobs: ["supabase/**"] },
};
afterEach(() => {
while (tmpRepos.length)
rmSync(tmpRepos.pop(), { recursive: true, force: true });
clearConfigCache();
});
describe("config loader", () => {
test("loads a valid config and applies defaults for absent keys", () => {
const cfg = loadConfig(makeRepo(VALID));
expect(cfg.name).toBe("sample");
// default filled in when absent
expect(worktreeProvision(cfg)).toBe("npm-link");
expect(cfg.launcher.sessionDirEnv).toBe("CHAT_SESSION_DIR");
});
test("missing file degrades to defaults (no deploy capability)", () => {
const cfg = loadConfig(makeRepo(undefined));
expect(isDeployEnabled(cfg)).toBe(false);
expect(cfg.validation.steps).toEqual([]);
});
test("typed readers reflect the config", () => {
const cfg = loadConfig(makeRepo(VALID));
expect(isDeployEnabled(cfg)).toBe(true);
expect(deployGlobs(cfg)).toEqual(["supabase/**"]);
expect(pipelineRoles(cfg).sort()).toEqual(["developer", "planner"]);
expect(debounceRoles(cfg)).toEqual(["developer"]);
expect(roleModel(cfg, "planner")).toBe("opus");
});
test("malformed JSON throws a clear, prefixed error", () => {
expect(() => loadConfig(makeRepo("{ not json"))).toThrow(
new RegExp(`${CONFIG_FILENAME}: invalid JSON`),
);
});
test("validation.steps not an array is rejected", () => {
expect(() =>
loadConfig(makeRepo({ validation: { steps: {} }, roles: {} })),
).toThrow(/validation\.steps` must be an array/);
});
test("a role missing a model is rejected", () => {
expect(() =>
loadConfig(
makeRepo({ validation: { steps: [] }, roles: { developer: {} } }),
),
).toThrow(/roles\.developer needs/);
});
test("deploy present but without relevantGlobs is rejected", () => {
expect(() =>
loadConfig(
makeRepo({ validation: { steps: [] }, roles: {}, deploy: {} }),
),
).toThrow(/deploy\.relevantGlobs/);
});
test("prePrSteps takes typecheck/lint but excludes changedScoped steps", () => {
const cfg = loadConfig(
makeRepo({
validation: {
steps: [
{
id: "typecheck",
kind: "typecheck",
command: "npm run typecheck",
},
{
id: "lint",
kind: "lint",
command: "npx eslint",
changedScoped: true,
},
],
},
roles: {},
}),
);
// typecheck (whole-repo-safe) is on the human push path; the diff-scoped lint
// step is not — it needs a per-worktree base the human path lacks.
expect(prePrSteps(cfg).map((s) => s.id)).toEqual(["typecheck"]);
});
test("the committed harness.config.json is valid", () => {
clearConfigCache();
const cfg = loadConfig(REPO_ROOT);
expect(cfg.name).toBe("atomic-crm");
expect(isDeployEnabled(cfg)).toBe(true);
expect(roleModel(cfg, "quality-reviewer")).toBe("opus");
expect(pipelineRoles(cfg)).toContain("test-writer");
});
});

View File

@@ -0,0 +1,86 @@
// Unit tests for lib/dispatch-parse.mjs — the shared PreToolUse(Agent) parser.
// The anchored TASK_ID regex is the key safety property: prose mentioning
// another ticket must never mis-key a gate.
import { describe, test, expect } from "vitest";
import { parseDispatch } from "../lib/dispatch-parse.mjs";
describe("parseDispatch", () => {
test("anchors TASK_ID at line start, ignoring prose mentions", () => {
const d = parseDispatch({
tool_input: {
subagent_type: "merger",
run_in_background: true,
prompt:
"TASK-001 is merged; now handle this one.\nROLE: merger\nTASK_ID: TASK-002\nBRANCH_NAME: ab12cd34/TASK-002\nWORKTREE_PATH: /wt/ab12cd34/TASK-002",
},
});
expect(d.taskId).toBe("TASK-002");
expect(d.subagentType).toBe("merger");
expect(d.runInBackground).toBe(true);
expect(d.branchName).toBe("ab12cd34/TASK-002");
expect(d.worktreePath).toBe("/wt/ab12cd34/TASK-002");
});
test("empty TASK_ID when there is no TASK_ID line", () => {
const d = parseDispatch({
tool_input: {
subagent_type: "merger",
prompt: "merge the simple branch",
},
});
expect(d.taskId).toBe("");
});
test("recognises MIGRATION / ROLLBACK skip keys", () => {
expect(
parseDispatch({
tool_input: { prompt: "ROLE: merger\nTASK_ID: MIGRATION" },
}).taskId,
).toBe("MIGRATION");
expect(
parseDispatch({
tool_input: { prompt: "ROLE: merger\nTASK_ID: ROLLBACK" },
}).taskId,
).toBe("ROLLBACK");
});
test("promotion-only dispatch carries MODE + SESSION_SHORT_ID", () => {
const d = parseDispatch({
agent_type: "",
tool_input: {
subagent_type: "merger",
prompt: "ROLE: merger\nMODE: promote\nSESSION_SHORT_ID: ab12cd34",
},
});
expect(d.mode).toBe("promote");
expect(d.sessionShortId).toBe("ab12cd34");
expect(d.callerAgentType).toBe("");
});
test("isolation surfaces so enforce-dev-dispatch can block it", () => {
const d = parseDispatch({
tool_input: {
subagent_type: "developer",
isolation: "worktree",
prompt: "ROLE: developer\nWORKTREE_PATH: /wt/x/TASK-004",
},
});
expect(d.isolation).toBe("worktree");
expect(d.worktreePath).toBe("/wt/x/TASK-004");
});
test("developer dispatch exposes name, task and an empty isolation", () => {
const d = parseDispatch({
tool_input: {
subagent_type: "developer",
name: "developer-TASK-003",
prompt:
"ROLE: developer\nTASK_ID: TASK-003\nWORKTREE_PATH: /wt/x/TASK-003\nBRANCH_NAME: x/TASK-003",
},
});
expect(d.taskId).toBe("TASK-003");
expect(d.name).toBe("developer-TASK-003");
expect(d.isolation).toBe("");
});
});

View File

@@ -0,0 +1,95 @@
// Tests for enforce-dev-dispatch.mjs — the PreToolUse(Agent) gate that forces a
// `developer` dispatch onto the setup-worktree-prepared worktree (WORKTREE_PATH
// in the prompt) and forbids the Agent tool's own isolation:"worktree". Exit 2
// blocks the dispatch; exit 0 allows it.
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { describe, test, expect, beforeAll, afterAll } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "enforce-dev-dispatch.mjs");
let env;
let TMP;
const run = (tool_input) =>
spawnSync("node", [HOOK], {
input: JSON.stringify({
session_id: "ab12cd34-1111-2222-3333-444455556666",
tool_input,
}),
env,
encoding: "utf8",
}).status;
beforeAll(() => {
TMP = mkdtempSync(join(tmpdir(), "enforce-dev-dispatch-test-"));
env = { ...process.env, APP_DIR: TMP, CRM_TMP_ROOT: join(TMP, "scratch") };
});
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
describe("enforce-dev-dispatch", () => {
test("allows a developer dispatch carrying WORKTREE_PATH", () => {
expect(
run({
subagent_type: "developer",
prompt:
"ROLE: developer\nTASK_ID: TASK-001\nWORKTREE_PATH: /wt/x/TASK-001\nBRANCH_NAME: x/TASK-001",
}),
).toBe(0);
});
test("blocks a developer dispatch missing WORKTREE_PATH", () => {
expect(
run({
subagent_type: "developer",
prompt: "ROLE: developer\nTASK_ID: TASK-001",
}),
).toBe(2);
});
test("blocks a developer dispatch using isolation:worktree", () => {
expect(
run({
subagent_type: "developer",
isolation: "worktree",
prompt: "ROLE: developer\nWORKTREE_PATH: /wt/x/TASK-001",
}),
).toBe(2);
});
test("blocks a developer dispatch using run_in_background:true", () => {
expect(
run({
subagent_type: "developer",
run_in_background: true,
prompt:
"ROLE: developer\nTASK_ID: TASK-001\nWORKTREE_PATH: /wt/x/TASK-001\nBRANCH_NAME: x/TASK-001",
}),
).toBe(2);
});
test("allows the promotion-conflict-resolver (no WORKTREE_PATH by design)", () => {
expect(
run({
subagent_type: "developer",
prompt:
"ROLE: promotion-conflict-resolver (gated $CLAUDE_PROJECT_DIR exception)\nSESSION_SHORT_ID: ab12cd34\nUnder the promotion lock, in $CLAUDE_PROJECT_DIR on main, re-run the merge.",
}),
).toBe(0);
});
test("ignores non-developer dispatches (e.g. merger)", () => {
expect(
run({
subagent_type: "merger",
prompt: "ROLE: merger\nTASK_ID: TASK-001",
}),
).toBe(0);
});
});

View File

@@ -0,0 +1,89 @@
// Tests for format-on-write.mjs: a PostToolUse(Write|Edit) hook that formats the
// written file using the config's format-step formatter + extension list, on the
// human/main-thread path only (harness subagents are skipped). The formatter here
// is a sentinel-writing script so the test verifies invocation/scoping without
// depending on prettier.
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { afterEach, describe, test, expect } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "format-on-write.mjs");
const tmpRepos = [];
afterEach(() => {
while (tmpRepos.length)
rmSync(tmpRepos.pop(), { recursive: true, force: true });
});
// Build a temp repo whose format step's formatter is a script that touches a
// sentinel, so we can assert whether it ran. Returns { run(agent, file), sentinel }.
const setup = (extensions = [".ts", ".tsx"]) => {
const repo = mkdtempSync(join(tmpdir(), "format-on-write-"));
tmpRepos.push(repo);
const sentinel = join(repo, ".fmt-ran");
writeFileSync(join(repo, "fmt.sh"), `#!/bin/sh\ntouch "${sentinel}"\n`);
writeFileSync(
join(repo, "harness.config.json"),
JSON.stringify({
validation: {
steps: [
{
id: "prettier",
kind: "format",
command: "true",
formatter: `sh ${join(repo, "fmt.sh")}`,
extensions,
},
],
},
roles: {},
}),
);
const run = (agent, file) => {
const env = { ...process.env, APP_DIR: repo };
delete env.CLAUDE_PROJECT_DIR;
delete env.CLAUDE_AGENT_NAME;
const filePath = join(repo, file);
writeFileSync(filePath, "const x=1");
const input = JSON.stringify({
tool_name: "Write",
...(agent ? { agent_type: agent } : {}),
tool_input: { file_path: filePath },
});
return spawnSync("node", [HOOK], { input, env, encoding: "utf8" });
};
return { run, sentinel };
};
describe("format-on-write hook", () => {
test("main thread + matching extension → formatter runs", () => {
const { run, sentinel } = setup();
const r = run("", "a.ts");
expect(r.status).toBe(0);
expect(existsSync(sentinel)).toBe(true);
});
test("harness subagent → skipped (formatter does not run)", () => {
const { run, sentinel } = setup();
const r = run("developer", "a.ts");
expect(r.status).toBe(0);
expect(existsSync(sentinel)).toBe(false);
});
test("non-matching extension → skipped", () => {
const { run, sentinel } = setup([".ts"]);
const r = run("", "notes.md");
expect(r.status).toBe(0);
expect(existsSync(sentinel)).toBe(false);
});
test("unparseable payload → exits 0", () => {
const r = spawnSync("node", [HOOK], { input: "nope", encoding: "utf8" });
expect(r.status).toBe(0);
});
});

View File

@@ -0,0 +1,101 @@
// Tests for link-session-workspace.mjs: the SessionStart hook that keeps a
// stable ".harness-session" symlink in the VS Code multi-root workspace
// pointing at the current session dir (where the harness tickets live).
import {
mkdtempSync,
mkdirSync,
writeFileSync,
lstatSync,
readlinkSync,
existsSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { afterEach, describe, test, expect } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "link-session-workspace.mjs");
const LINK_NAME = ".harness-session";
// Mirror lib/paths.mjs sanitizePath: every "/" becomes "_".
const sanitize = (p) => p.replace(/\//g, "_");
let TMP = null;
afterEach(() => {
if (TMP) rmSync(TMP, { recursive: true, force: true });
TMP = null;
});
// Build an isolated layout: <root>/ws/repo is the repo, <root>/ws holds the
// .code-workspace file (the workspace root), and <root>/sessions is the session
// dir root. run() spawns the hook with APP_DIR/HARNESS_TMP_ROOT pointing there.
const setup = ({ withWorkspaceFile = true } = {}) => {
TMP = mkdtempSync(join(tmpdir(), "link-session-ws-test-"));
const workspaceRoot = join(TMP, "ws");
const repo = join(workspaceRoot, "repo");
const tmpRoot = join(TMP, "sessions");
mkdirSync(repo, { recursive: true });
mkdirSync(tmpRoot, { recursive: true });
if (withWorkspaceFile) {
writeFileSync(join(workspaceRoot, "proj.code-workspace"), "{}");
}
const linkPath = join(workspaceRoot, LINK_NAME);
const sessionDirFor = (id) => join(tmpRoot, sanitize(repo), id);
const run = (sessionId, extraEnv = {}) => {
const env = { ...process.env, APP_DIR: repo, HARNESS_TMP_ROOT: tmpRoot };
delete env.CHAT_SESSION_DIR;
delete env.CLAUDE_AGENT_NAME;
Object.assign(env, extraEnv);
return spawnSync("node", [HOOK], {
input: JSON.stringify({ session_id: sessionId }),
env,
encoding: "utf8",
});
};
return { workspaceRoot, repo, linkPath, sessionDirFor, run };
};
describe("link-session-workspace", () => {
test("creates the symlink to the current session dir", () => {
const { linkPath, sessionDirFor, run } = setup();
const r = run("sess-1");
expect(r.status).toBe(0);
expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
expect(readlinkSync(linkPath)).toBe(sessionDirFor("sess-1"));
// target materialised so the link is never dangling
expect(existsSync(sessionDirFor("sess-1"))).toBe(true);
});
test("repoints an existing harness symlink to the new session dir", () => {
const { linkPath, sessionDirFor, run } = setup();
run("sess-1");
run("sess-2");
expect(readlinkSync(linkPath)).toBe(sessionDirFor("sess-2"));
});
test("does nothing when there is no .code-workspace file", () => {
const { linkPath, run } = setup({ withWorkspaceFile: false });
const r = run("sess-1");
expect(r.status).toBe(0);
expect(existsSync(linkPath)).toBe(false);
});
test("does not clobber a real directory sitting at the link path", () => {
const { linkPath, run } = setup();
mkdirSync(linkPath);
writeFileSync(join(linkPath, "keep.txt"), "important");
run("sess-1");
expect(lstatSync(linkPath).isSymbolicLink()).toBe(false);
expect(existsSync(join(linkPath, "keep.txt"))).toBe(true);
});
test("is a no-op under a managed launcher (CHAT_SESSION_DIR set)", () => {
const { linkPath, run } = setup();
const r = run("sess-1", { CHAT_SESSION_DIR: "/tmp/managed-session-xyz" });
expect(r.status).toBe(0);
expect(existsSync(linkPath)).toBe(false);
});
});

View File

@@ -0,0 +1,79 @@
// Tests for pre-pr-checks.mjs: a PreToolUse(Bash) hook that runs the config's
// typecheck/lint steps before `gh pr create` / `git push`, on the human path
// only. exit 2 blocks the push; harness subagents and non-trigger commands pass.
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { afterEach, describe, test, expect } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "pre-pr-checks.mjs");
const tmpRepos = [];
afterEach(() => {
while (tmpRepos.length)
rmSync(tmpRepos.pop(), { recursive: true, force: true });
});
// Temp repo with a single typecheck step whose command is `true` (passes) or
// `false` (fails). Returns run(agent, command).
const setup = (checkCommand) => {
const repo = mkdtempSync(join(tmpdir(), "pre-pr-"));
tmpRepos.push(repo);
writeFileSync(
join(repo, "harness.config.json"),
JSON.stringify({
validation: {
steps: [{ id: "typecheck", kind: "typecheck", command: checkCommand }],
},
roles: {},
}),
);
return (agent, command) => {
const env = { ...process.env, APP_DIR: repo };
delete env.CLAUDE_PROJECT_DIR;
delete env.CLAUDE_AGENT_NAME;
const input = JSON.stringify({
tool_name: "Bash",
...(agent ? { agent_type: agent } : {}),
tool_input: { command },
});
return spawnSync("node", [HOOK], { input, env, encoding: "utf8" });
};
};
describe("pre-pr-checks hook", () => {
test("git push with a failing check → blocked (exit 2)", () => {
const run = setup("false");
const r = run("", "git push origin HEAD");
expect(r.status).toBe(2);
expect(r.stderr).toContain("typecheck");
});
test("git push with a passing check → allowed (exit 0)", () => {
const run = setup("true");
const r = run("", "git push origin HEAD");
expect(r.status).toBe(0);
});
test("gh pr create triggers the checks", () => {
const run = setup("false");
const r = run("", "gh pr create --fill");
expect(r.status).toBe(2);
});
test("non-trigger command → not checked (exit 0)", () => {
const run = setup("false");
const r = run("", "git status");
expect(r.status).toBe(0);
});
test("harness subagent pushing → skipped (exit 0, dev-agent stops unaffected)", () => {
const run = setup("false");
const r = run("developer", "git push origin HEAD");
expect(r.status).toBe(0);
});
});

View File

@@ -0,0 +1,49 @@
// Tests for lib/progress-log.mjs: appendProgress enriches the #technical-harness
// progress log ONLY when it already exists. The critical invariant: it must NEVER
// create the log, because the log's existence is the technical-run gate (creating it
// on a non-technical run would leak a board into the repo via render-status).
import {
mkdtempSync,
existsSync,
readFileSync,
writeFileSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, test, expect, beforeEach, afterAll } from "vitest";
import { appendProgress } from "../lib/progress-log.mjs";
let dir;
const log = () => join(dir, "harness-progress.log");
const roots = [];
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "progress-log-"));
roots.push(dir);
});
afterAll(() =>
roots.forEach((d) => rmSync(d, { recursive: true, force: true })),
);
describe("appendProgress", () => {
test("does nothing and returns false when the log does not exist (non-technical run)", () => {
expect(appendProgress(dir, "typecheck…")).toBe(false);
expect(existsSync(log())).toBe(false); // must NOT create it
});
test("appends a timestamped line and returns true when the log exists", () => {
writeFileSync(log(), "10:00:00 plan ready\n");
expect(appendProgress(dir, "[validate:TASK-001] typecheck…")).toBe(true);
const contents = readFileSync(log(), "utf8");
expect(contents).toContain("plan ready"); // preserves prior content (append only)
expect(contents).toMatch(
/\n\d{2}:\d{2}:\d{2} \[validate:TASK-001] typecheck…\n$/,
);
});
test("returns false (never throws) when the session dir is missing", () => {
expect(appendProgress(join(dir, "does-not-exist"), "x")).toBe(false);
});
});

View File

@@ -0,0 +1,90 @@
// Tests for the config-driven worktree provisioning dispatcher
// (context.mjs provisionWorktree, config.worktree.provision): `npm-link`
// (builtin), `none` (no-op), or a script path. Run in a child node process with
// APP_DIR pointing at a temp repo, so paths.mjs REPO resolves to that repo's
// harness.config.json.
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { spawnSync } from "node:child_process";
import { afterEach, describe, test, expect } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const CTX_URL = pathToFileURL(join(HERE, "..", "lib", "context.mjs")).href;
const tmpDirs = [];
afterEach(() => {
while (tmpDirs.length)
rmSync(tmpDirs.pop(), { recursive: true, force: true });
});
// Run provisionWorktree(wt) in a child process whose REPO is `repo`.
const runProvision = (repo, wt) => {
const script = `
const { createHookContext } = await import(${JSON.stringify(CTX_URL)});
const ctx = createHookContext({ session_id: "prov-test" }, "prov");
ctx.provisionWorktree(process.env.WT);
console.log("PROVISION_OK");
`;
const env = { ...process.env, APP_DIR: repo, WT: wt };
delete env.CLAUDE_PROJECT_DIR;
return spawnSync("node", ["--input-type=module", "-e", script], {
env,
encoding: "utf8",
});
};
const makeRepo = (provision) => {
const repo = mkdtempSync(join(tmpdir(), "provision-repo-"));
tmpDirs.push(repo);
writeFileSync(
join(repo, "harness.config.json"),
JSON.stringify({
validation: { steps: [] },
roles: {},
worktree: { provision },
}),
);
return repo;
};
const makeWorktree = () => {
const wt = mkdtempSync(join(tmpdir(), "provision-wt-"));
tmpDirs.push(wt);
return wt;
};
describe("worktree provisioning dispatcher", () => {
test("`none` provisions nothing and does not error", () => {
const repo = makeRepo("none");
const wt = makeWorktree();
const r = runProvision(repo, wt);
expect(r.status).toBe(0);
expect(r.stdout).toContain("PROVISION_OK");
expect(existsSync(join(wt, "node_modules"))).toBe(false);
});
test("a script path is run with the worktree as its argument", () => {
const repo = makeRepo("provision.sh");
const sentinel = join(repo, ".provisioned");
// $1 is the worktree path; the script records that it ran.
writeFileSync(
join(repo, "provision.sh"),
`#!/bin/sh\necho "$1" > "${sentinel}"\n`,
);
const wt = makeWorktree();
const r = runProvision(repo, wt);
expect(r.status).toBe(0);
expect(existsSync(sentinel)).toBe(true);
});
test("default `npm-link` with no source node_modules is a safe no-op", () => {
const repo = makeRepo("npm-link"); // repo has no node_modules to mirror
const wt = makeWorktree();
const r = runProvision(repo, wt);
expect(r.status).toBe(0);
expect(existsSync(join(wt, "node_modules"))).toBe(false);
});
});

View File

@@ -0,0 +1,119 @@
// Tests for record-review-verdict.mjs (vitest, "claude" Node project). A
// reviewer's final contract line (APPROVED / REJECTED) becomes a per-ticket flag
// under <sessionDir>/reviews/<TASK>-<role>, read later by
// block-merger-without-review.mjs. Verdict source here is last_assistant_message.
import { mkdtempSync, existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { describe, test, expect, beforeAll, afterAll } from "vitest";
import { sanitizePath } from "../lib/paths.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "record-review-verdict.mjs");
const SESSION_ID = "deadbeef-1111-2222-3333-444455556666";
let TMP;
let APP_DIR;
let env;
let reviewsDir;
const run = (payload) =>
spawnSync("node", [HOOK], {
input: JSON.stringify({ session_id: SESSION_ID, ...payload }),
env,
encoding: "utf8",
});
const flag = (name) => join(reviewsDir, name);
beforeAll(() => {
TMP = mkdtempSync(join(tmpdir(), "record-verdict-test-"));
APP_DIR = join(TMP, "app");
const CRM_TMP_ROOT = join(TMP, "scratch");
reviewsDir = join(CRM_TMP_ROOT, sanitizePath(APP_DIR), SESSION_ID, "reviews");
env = { ...process.env, APP_DIR, CRM_TMP_ROOT };
delete env.CLAUDE_AGENT_NAME;
// These tests assert the flag lands under ctx.sessionDir (CRM_TMP_ROOT/...).
// reviewsDir() prefers CHAT_SESSION_DIR when set (managed-launcher path), so a
// CHAT_SESSION_DIR inherited from the ambient env (e.g. CRM Builder's container)
// would redirect the flag and break the assertion — neutralise it here. The
// CHAT_SESSION_DIR-present path is covered by reviews.test.mjs.
delete env.CHAT_SESSION_DIR;
});
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
describe("record-review-verdict", () => {
test("APPROVED writes the per-ticket flag", () => {
run({
agent_type: "quality-reviewer-TASK-007",
last_assistant_message: "APPROVED",
});
expect(existsSync(flag("TASK-007-quality-reviewer"))).toBe(true);
});
test("REJECTED clears a prior flag", () => {
run({
agent_type: "quality-reviewer-TASK-007",
last_assistant_message: "REJECTED: do X",
});
expect(existsSync(flag("TASK-007-quality-reviewer"))).toBe(false);
});
test("records the flag from a final APPROVED after preamble prose", () => {
run({
agent_type: "quality-reviewer-TASK-011",
last_assistant_message: "Part C — integration: present\nAPPROVED",
});
expect(existsSync(flag("TASK-011-quality-reviewer"))).toBe(true);
});
test("multi-line REJECTED (bulleted feedback) clears a prior flag", () => {
// Arrange: a prior APPROVED flag exists for this ticket/role.
run({
agent_type: "quality-reviewer-TASK-009",
last_assistant_message: "APPROVED",
});
expect(existsSync(flag("TASK-009-quality-reviewer"))).toBe(true);
// Act: a re-review rejects using the contract's bulleted-list feedback, so
// the REJECTED keyword is NOT on the final line.
run({
agent_type: "quality-reviewer-TASK-009",
last_assistant_message:
"Findings:\nREJECTED:\n- src/foo.ts: missing null check\n- src/bar.ts: wrong import",
});
// Assert: the stale APPROVED flag is cleared.
expect(existsSync(flag("TASK-009-quality-reviewer"))).toBe(false);
});
test("trailing prose starting with REJECTED does not flip a real APPROVED", () => {
// A chatty reviewer that approves but adds a clarifying sentence which happens
// to start with the keyword must NOT be recorded as REJECTED. The contract
// REJECTED form requires a colon (`REJECTED:`), so this trailing prose is
// ignored and the standalone APPROVED above wins.
run({
agent_type: "quality-reviewer-TASK-010",
last_assistant_message:
"APPROVED\nREJECTED concerns from the first pass are now resolved.",
});
expect(existsSync(flag("TASK-010-quality-reviewer"))).toBe(true);
});
test("standalone APPROVED with a trailing note still approves", () => {
run({
agent_type: "quality-reviewer-TASK-012",
last_assistant_message: "APPROVED\nNice work — all e2e specs present.",
});
expect(existsSync(flag("TASK-012-quality-reviewer"))).toBe(true);
});
test("unknown verdict leaves state untouched (no flag written)", () => {
run({
agent_type: "quality-reviewer-TASK-008",
last_assistant_message: "I am still thinking about it",
});
expect(existsSync(flag("TASK-008-quality-reviewer"))).toBe(false);
});
});

View File

@@ -0,0 +1,67 @@
// Tests for reinject-invariants.mjs, a PostCompact hook that re-injects the
// load-bearing harness invariants via hookSpecificOutput.additionalContext.
// It must always exit 0 (fail-open), emit parseable JSON, and stay compact.
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { describe, test, expect } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "reinject-invariants.mjs");
const runHook = (payload) =>
spawnSync("node", [HOOK], {
input: typeof payload === "string" ? payload : JSON.stringify(payload),
encoding: "utf8",
});
const parse = (r) => JSON.parse(r.stdout);
describe("reinject-invariants hook", () => {
test("emits additionalContext on a PostCompact payload", () => {
const r = runHook({
hook_event_name: "PostCompact",
trigger: "auto",
compact_summary: "previous work summary",
});
expect(r.status).toBe(0);
const out = parse(r);
expect(out.hookSpecificOutput.hookEventName).toBe("PostCompact");
expect(out.hookSpecificOutput.additionalContext).toMatch(
/harness-invariants/,
);
});
test("injected block covers the load-bearing invariants", () => {
const out = parse(
runHook({ hook_event_name: "PostCompact", trigger: "manual" }),
);
const ctx = out.hookSpecificOutput.additionalContext;
expect(ctx).toMatch(/Worktree scope/);
expect(ctx).toMatch(/Launched is NOT done/);
expect(ctx).toMatch(/orchestrator runs/i);
expect(ctx).toMatch(/Git rights/);
});
test("injected block stays under 25 lines", () => {
const out = parse(
runHook({ hook_event_name: "PostCompact", trigger: "auto" }),
);
const lines = out.hookSpecificOutput.additionalContext.split("\n").length;
expect(lines).toBeLessThanOrEqual(25);
});
test("fails open on an unparseable payload (still exit 0, still emits)", () => {
const r = runHook("not json at all");
expect(r.status).toBe(0);
expect(parse(r).hookSpecificOutput.additionalContext).toMatch(
/harness-invariants/,
);
});
test("fails open on empty stdin", () => {
const r = spawnSync("node", [HOOK], { input: "", encoding: "utf8" });
expect(r.status).toBe(0);
});
});

View File

@@ -0,0 +1,202 @@
// Tests for render-status.mjs: the SubagentStop hook that renders a portable
// harness board (STATUS.md / TICKETS.md / status.json) under <REPO>/.harness/<short>
// from the session's cheap state. Uses APP_DIR + HARNESS_TMP_ROOT so the hook
// reads a throwaway repo and a fake session dir, same pattern as the other tests.
import { spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, test } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "render-status.mjs");
const SESSION_ID = "cafe1234-1111-2222-3333-444455556666";
const SHORT = SESSION_ID.split("-")[0];
const sanitize = (p) => p.replace(/\//g, "_");
let TMP = null;
afterEach(() => {
if (TMP) rmSync(TMP, { recursive: true, force: true });
TMP = null;
});
// A throwaway repo + a fake session dir populated with the sources the hook reads:
// a technical progress log (the gate), two tickets, one review flag, one live
// worktree dir. `run()` fires the SubagentStop hook.
const setup = ({ withProgressLog = true } = {}) => {
TMP = mkdtempSync(join(tmpdir(), "render-status-test-"));
const app = join(TMP, "app");
const tmpRoot = join(TMP, "wtroot");
const base = join(tmpRoot, sanitize(app), SESSION_ID);
mkdirSync(join(base, "tickets"), { recursive: true });
mkdirSync(join(base, "reviews"), { recursive: true });
mkdirSync(app, { recursive: true });
if (withProgressLog) {
writeFileSync(
join(base, "harness-progress.log"),
"12:00:01 plan ready (2 tickets)\n12:03:11 TASK-001 developer DONE\n12:05:42 TASK-001 merged\n",
);
}
writeFileSync(
join(base, "tickets", "TASK-001.json"),
JSON.stringify({
id: "TASK-001",
title: "Rename the Add contact button",
status: "merged",
acceptance_criteria: ["button reads New contact"],
files_to_modify: ["src/contacts/Button.tsx"],
dependencies: [],
}),
);
writeFileSync(
join(base, "tickets", "TASK-002.json"),
JSON.stringify({
id: "TASK-002",
title: "Add a Last activity column",
status: "planned",
acceptance_criteria: ["column visible", "sorted desc"],
files_to_modify: ["src/contacts/List.tsx"],
dependencies: ["TASK-001"],
}),
);
// TASK-001 reviewed (flag present); TASK-002 still in flight (live worktree).
writeFileSync(join(base, "reviews", "TASK-001-quality-reviewer"), "");
mkdirSync(join(base, "TASK-002"), { recursive: true });
const env = { ...process.env, APP_DIR: app, HARNESS_TMP_ROOT: tmpRoot };
delete env.CHAT_SESSION_DIR;
delete env.TICKETS_DIR;
const run = (extraEnv = {}) =>
spawnSync("node", [HOOK], {
input: JSON.stringify({ session_id: SESSION_ID }),
env: { ...env, ...extraEnv },
encoding: "utf8",
});
const outDir = join(app, ".harness", SHORT);
return { app, base, outDir, run };
};
describe("render-status", () => {
test("renders STATUS.md with ticket table, verdict and live worktree", () => {
const { outDir, run } = setup();
const r = run();
expect(r.status).toBe(0);
const md = readFileSync(join(outDir, "STATUS.md"), "utf8");
expect(md).toContain(SHORT);
expect(md).toContain("1/2 merged");
expect(md).toContain("TASK-001");
expect(md).toContain("merged");
expect(md).toContain("✅"); // TASK-001 review flag present
expect(md).toContain("TASK-002");
// TASK-002 has a live worktree dir → shown as in flight.
expect(md).toContain("`TASK-002`");
// The recent-activity block echoes the progress log.
expect(md).toContain("TASK-001 merged");
});
test("renders TICKETS.md with full per-ticket detail", () => {
const { outDir, run } = setup();
run();
const md = readFileSync(join(outDir, "TICKETS.md"), "utf8");
expect(md).toContain("## TASK-002 · Add a Last activity column");
expect(md).toContain("column visible");
expect(md).toContain("src/contacts/List.tsx");
expect(md).toContain("Depends on:** TASK-001");
});
test("writes status.json with correct counts and active tasks", () => {
const { outDir, run } = setup();
run();
const s = JSON.parse(readFileSync(join(outDir, "status.json"), "utf8"));
expect(s.short).toBe(SHORT);
expect(s.tickets).toEqual({ total: 2, merged: 1, inProgress: 1 });
expect(s.active).toEqual([{ task: "TASK-002" }]);
expect(s.last).toContain("TASK-001 merged");
});
test("writes session.diff and a Changes section from the session branch", () => {
const { app, outDir, run } = setup();
// Turn the throwaway repo into a git repo with a session branch carrying one
// change vs its fork anchor (session-base), like the harness topology.
const g = (...a) =>
spawnSync("git", ["-C", app, ...a], { encoding: "utf8" });
g("init", "-q", "-b", "main");
g("config", "user.email", "t@t.t");
g("config", "user.name", "t");
writeFileSync(join(app, "Button.tsx"), "label = 'Add contact'\n");
g("add", ".");
g("commit", "-qm", "base");
g("branch", `session-base/${SHORT}`);
g("checkout", "-q", "-b", `session/${SHORT}`);
writeFileSync(join(app, "Button.tsx"), "label = 'New contact'\n");
g("add", ".");
g("commit", "-qm", "rename button");
g("checkout", "-q", "main");
run();
const patch = readFileSync(join(outDir, "session.diff"), "utf8");
expect(patch).toContain("Button.tsx");
expect(patch).toContain("+label = 'New contact'");
expect(patch).toContain("-label = 'Add contact'");
const md = readFileSync(join(outDir, "STATUS.md"), "utf8");
expect(md).toContain("## Changes");
expect(md).toContain("Button.tsx");
});
test("includes in-flight dev-branch work before it is merged", () => {
const { app, outDir, run } = setup();
const g = (...a) =>
spawnSync("git", ["-C", app, ...a], { encoding: "utf8" });
g("init", "-q", "-b", "main");
g("config", "user.email", "t@t.t");
g("config", "user.name", "t");
writeFileSync(join(app, "List.tsx"), "no filter\n");
g("add", ".");
g("commit", "-qm", "base");
g("branch", `session-base/${SHORT}`);
g("branch", `session/${SHORT}`); // session branch at fork, nothing merged yet
// developer's in-flight branch, committed but NOT merged into the session
g("checkout", "-q", "-b", `${SHORT}/TASK-001`);
writeFileSync(join(app, "List.tsx"), "without-email filter\n");
g("add", ".");
g("commit", "-qm", "wip");
g("checkout", "-q", "main");
run();
const patch = readFileSync(join(outDir, "session.diff"), "utf8");
expect(patch).toContain(`in flight: ${SHORT}/TASK-001`);
expect(patch).toContain("+without-email filter");
});
test("omits session.diff when there is no git session branch", () => {
const { outDir, run } = setup();
run();
expect(existsSync(join(outDir, "session.diff"))).toBe(false);
const md = readFileSync(join(outDir, "STATUS.md"), "utf8");
expect(md).toContain("no changes yet");
});
test("is inert when there is no technical progress log", () => {
const { app, run } = setup({ withProgressLog: false });
const r = run();
expect(r.status).toBe(0);
expect(existsSync(join(app, ".harness"))).toBe(false);
});
test("is a no-op under a managed launcher (CHAT_SESSION_DIR set)", () => {
const { app, run } = setup();
const r = run({ CHAT_SESSION_DIR: "/tmp/managed-session-xyz" });
expect(r.status).toBe(0);
expect(existsSync(join(app, ".harness"))).toBe(false);
});
});

View File

@@ -0,0 +1,174 @@
// Tests for restrict-documentator-write.mjs — the DOCUMENTATOR_RUN=1 allowlist (ledger, settings.local.json, MEMORY.md, CONFIG_DIR/local/**) and pass-through when inactive (vitest, Node project).
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { describe, test, expect } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "restrict-documentator-write.mjs");
const baseEnv = {
...process.env,
CLAUDE_PROJECT_DIR: "/app",
CLAUDE_CONFIG_DIR: "/home/developer/.claude",
};
delete baseEnv.APP_DIR;
const runHook = (envFlag, input) => {
const env = { ...baseEnv };
if (envFlag) env.DOCUMENTATOR_RUN = envFlag;
else delete env.DOCUMENTATOR_RUN;
return spawnSync("node", [HOOK], { input, env, encoding: "utf8" }).status;
};
describe("restrict-documentator-write hook", () => {
test("non-documentator session, any path → allowed", () => {
expect(
runHook(
"",
'{"tool_name":"Edit","tool_input":{"file_path":"/app/src/App.tsx"}}',
),
).toBe(0);
});
test("non-documentator session, base agent path → allowed", () => {
expect(
runHook(
"",
'{"tool_name":"Write","tool_input":{"file_path":"/home/developer/.claude/agents/anything.md"}}',
),
).toBe(0);
});
test("ledger write → allowed", () => {
expect(
runHook(
"1",
'{"tool_name":"Write","tool_input":{"file_path":"/app/docs/learnings/patterns.md"}}',
),
).toBe(0);
});
test("settings.local.json write → allowed", () => {
expect(
runHook(
"1",
'{"tool_name":"Write","tool_input":{"file_path":"/home/developer/.claude/settings.local.json"}}',
),
).toBe(0);
});
test("local/agents file → allowed", () => {
expect(
runHook(
"1",
'{"tool_name":"Write","tool_input":{"file_path":"/home/developer/.claude/local/agents/my-agent.md"}}',
),
).toBe(0);
});
test("local/skills file → allowed", () => {
expect(
runHook(
"1",
'{"tool_name":"Write","tool_input":{"file_path":"/home/developer/.claude/local/skills/my-skill/SKILL.md"}}',
),
).toBe(0);
});
test("local/hooks file → allowed", () => {
expect(
runHook(
"1",
'{"tool_name":"Write","tool_input":{"file_path":"/home/developer/.claude/local/hooks/my-hook.mjs"}}',
),
).toBe(0);
});
test("local/rules file → allowed", () => {
expect(
runHook(
"1",
'{"tool_name":"Write","tool_input":{"file_path":"/home/developer/.claude/local/rules/my-rule.md"}}',
),
).toBe(0);
});
test("base agents/ write → blocked", () => {
expect(
runHook(
"1",
'{"tool_name":"Write","tool_input":{"file_path":"/home/developer/.claude/agents/foo.md"}}',
),
).toBe(2);
});
test("base settings.json write → blocked", () => {
expect(
runHook(
"1",
'{"tool_name":"Write","tool_input":{"file_path":"/home/developer/.claude/settings.json"}}',
),
).toBe(2);
});
test("base hooks/ write → blocked", () => {
expect(
runHook(
"1",
'{"tool_name":"Write","tool_input":{"file_path":"/home/developer/.claude/hooks/foo.mjs"}}',
),
).toBe(2);
});
test("app source code → blocked", () => {
expect(
runHook(
"1",
'{"tool_name":"Edit","tool_input":{"file_path":"/app/src/App.tsx"}}',
),
).toBe(2);
});
test("worktree code → blocked", () => {
expect(
runHook(
"1",
'{"tool_name":"Edit","tool_input":{"file_path":"/app/worktrees/TASK-001/src/foo.ts"}}',
),
).toBe(2);
});
test("empty file_path → blocked", () => {
expect(
runHook("1", '{"tool_name":"Write","tool_input":{"file_path":""}}'),
).toBe(2);
});
test("missing file_path → blocked", () => {
expect(runHook("1", '{"tool_name":"Write","tool_input":{}}')).toBe(2);
});
test("malformed JSON → blocked", () => {
expect(runHook("1", "not-json{")).toBe(2);
});
test("path that contains 'local/' but not under base local/ → blocked", () => {
expect(
runHook(
"1",
'{"tool_name":"Write","tool_input":{"file_path":"/app/some/local/file.md"}}',
),
).toBe(2);
});
test("ledger lookalike (subpath) → blocked", () => {
expect(
runHook(
"1",
'{"tool_name":"Write","tool_input":{"file_path":"/app/docs/learnings/patterns.md.bak"}}',
),
).toBe(2);
});
});

View File

@@ -0,0 +1,35 @@
// Unit tests for the review-verdict path resolution (lib/reviews.mjs).
// reviewsDir() prefers CHAT_SESSION_DIR (set by a managed launcher such as CRM
// Builder's chat-service, where <session_dir> is NOT the /tmp/<repo>/<id> path the
// hooks recompute) and falls back to ctx.sessionDir otherwise. Both branches are
// pinned here so the quality-reviewer's synchronous flag write and the hooks that
// read/clear it never drift apart again.
import { describe, test, expect, afterEach } from "vitest";
import { reviewsDir, reviewFlag } from "../lib/reviews.mjs";
const ORIG = process.env.CHAT_SESSION_DIR;
afterEach(() => {
if (ORIG === undefined) delete process.env.CHAT_SESSION_DIR;
else process.env.CHAT_SESSION_DIR = ORIG;
});
describe("reviews path resolution", () => {
test("falls back to ctx.sessionDir when CHAT_SESSION_DIR is unset", () => {
delete process.env.CHAT_SESSION_DIR;
const ctx = { sessionDir: "/tmp/_app/uuid" };
expect(reviewsDir(ctx)).toBe("/tmp/_app/uuid/reviews");
expect(reviewFlag(ctx, "TASK-001", "quality-reviewer")).toBe(
"/tmp/_app/uuid/reviews/TASK-001-quality-reviewer",
);
});
test("prefers CHAT_SESSION_DIR (managed launcher) over ctx.sessionDir", () => {
process.env.CHAT_SESSION_DIR = "/chat-service/logs/uuid";
const ctx = { sessionDir: "/tmp/_app/uuid" };
expect(reviewsDir(ctx)).toBe("/chat-service/logs/uuid/reviews");
expect(reviewFlag(ctx, "TASK-001", "quality-reviewer")).toBe(
"/chat-service/logs/uuid/reviews/TASK-001-quality-reviewer",
);
});
});

View File

@@ -0,0 +1,100 @@
// Tests for session-bootstrap.mjs — the SessionStart hook that injects
// <session_dir> via hookSpecificOutput.additionalContext so the harness can run
// on any surface without a launch script. Key invariant: the injected dir is
// ctx.sessionDir = <TMP_ROOT>/<sanitized repo>/<session_id>, so its basename is
// the real session id (what setup-worktree and the orchestrator also key off).
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { afterAll, describe, test, expect } from "vitest";
import { sanitizePath } from "../lib/paths.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "session-bootstrap.mjs");
const TMP = mkdtempSync(join(tmpdir(), "session-bootstrap-test-"));
const APP_DIR = join(TMP, "repo");
const CRM_TMP_ROOT = join(TMP, "scratch");
const SESSION_ID = "abcd1234-1111-2222-3333-444455556666";
const baseEnv = { ...process.env, APP_DIR, CRM_TMP_ROOT };
delete baseEnv.CHAT_SESSION_DIR;
delete baseEnv.CLAUDE_CODE_SESSION_ID;
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
const run = (payload, extraEnv = {}) =>
spawnSync("node", [HOOK], {
input: payload === undefined ? "" : JSON.stringify(payload),
env: { ...baseEnv, ...extraEnv },
encoding: "utf8",
});
describe("session-bootstrap", () => {
test("injects <session_dir> built from the real session id", () => {
const r = run({ session_id: SESSION_ID });
expect(r.status).toBe(0);
const expectedDir = join(CRM_TMP_ROOT, sanitizePath(APP_DIR), SESSION_ID);
expect(r.stdout).toContain(`<session_dir>${expectedDir}</session_dir>`);
// Alignment invariant: basename(session_dir) === session_id.
const m = r.stdout.match(/<session_dir>(.+?)<\/session_dir>/);
expect(m && m[1].split("/").pop()).toBe(SESSION_ID);
});
test("emits valid additionalContext JSON for SessionStart", () => {
const r = run({ session_id: SESSION_ID });
const parsed = JSON.parse(r.stdout.trim());
expect(parsed.hookSpecificOutput.hookEventName).toBe("SessionStart");
expect(parsed.hookSpecificOutput.additionalContext).toContain(
"<session_dir>",
);
});
test("stays out of the way when a managed launcher owns the session", () => {
const r = run(
{ session_id: SESSION_ID },
{ CHAT_SESSION_DIR: "/tmp/atomic-crm-harness/managed" },
);
expect(r.status).toBe(0);
expect(r.stdout).not.toContain("<session_dir>");
});
test("exits cleanly with no output when there is no payload", () => {
const r = run(undefined);
expect(r.status).toBe(0);
expect(r.stdout).not.toContain("<session_dir>");
});
test("emits no resume banner for a fresh session with no harness state", () => {
const r = run({ session_id: SESSION_ID });
expect(r.stdout).not.toContain("<harness_resume>");
});
test("injects a resume banner when THIS session has in-flight harness state", () => {
// An unmerged ticket in the session dir = an interrupted plan-gate session.
// Isolated app/scratch so the shared module-level dirs stay banner-free.
const app = mkdtempSync(join(tmpdir(), "sb-inflight-app-"));
const scratch = mkdtempSync(join(tmpdir(), "sb-inflight-scratch-"));
const sid = "beef1234-aaaa-bbbb-cccc-ddddeeeeffff";
const sessionDir = join(scratch, sanitizePath(app), sid);
mkdirSync(sessionDir, { recursive: true });
writeFileSync(
join(sessionDir, "TASK-001.json"),
JSON.stringify({ id: "TASK-001", status: "planned" }),
);
const r = spawnSync("node", [HOOK], {
input: JSON.stringify({ session_id: sid }),
env: { ...baseEnv, APP_DIR: app, CRM_TMP_ROOT: scratch },
encoding: "utf8",
});
rmSync(app, { recursive: true, force: true });
rmSync(scratch, { recursive: true, force: true });
expect(r.status).toBe(0);
expect(r.stdout).toContain("<harness_resume>");
expect(r.stdout).toContain("plan gate");
// The banner is appended, not a replacement: <session_dir> is still present.
expect(r.stdout).toContain("<session_dir>");
});
});

View File

@@ -0,0 +1,214 @@
// Tests for setup-worktree.mjs (vitest, "claude" Node project). setup-worktree is
// now a PreToolUse(Agent) hook: it reads the dispatch tool_input (subagent_type,
// name, prompt) and creates the worktree BEFORE the subagent starts. Uses a
// throwaway git repo (APP_DIR) + CRM_TMP_ROOT. Tests are ordered and stateful —
// each observes the state produced by the previous step.
import { spawnSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { sanitizePath } from "../lib/paths.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "setup-worktree.mjs");
const SESSION_ID = "ab12cd34-1111-2222-3333-444455556666";
const SS = SESSION_ID.split("-")[0];
let TMP;
let APP_DIR;
let WB;
let env;
const g = (...args) =>
spawnSync("git", ["-C", APP_DIR, ...args], { encoding: "utf8" });
// Build a PreToolUse(Agent) dispatch payload and run the hook.
const dispatch = ({
subagentType = "developer",
taskId = "",
name = "",
} = {}) => {
const wp = taskId ? join(WB, taskId) : join(WB, "simple");
const branch = taskId ? `${SS}/${taskId}` : `${SS}/simple`;
const prompt =
`ROLE: ${subagentType}\n` +
(taskId ? `TASK_ID: ${taskId}\n` : "") +
`WORKTREE_PATH: ${wp}\nBRANCH_NAME: ${branch}`;
return spawnSync("node", [HOOK], {
input: JSON.stringify({
session_id: SESSION_ID,
tool_input: {
subagent_type: subagentType,
name: name || (taskId ? `${subagentType}-${taskId}` : subagentType),
prompt,
},
}),
env,
encoding: "utf8",
});
};
beforeAll(() => {
TMP = mkdtempSync(join(tmpdir(), "setup-wt-test-"));
APP_DIR = join(TMP, "app");
mkdirSync(APP_DIR, { recursive: true });
g("init", "-q", "-b", "main");
g("config", "user.email", "t@t.t");
g("config", "user.name", "t");
writeFileSync(join(APP_DIR, "seed.txt"), "seed\n");
g("add", ".");
g("commit", "-q", "-m", "seed");
mkdirSync(join(APP_DIR, "node_modules"), { recursive: true });
const CRM_TMP_ROOT = join(TMP, "scratch");
WB = join(CRM_TMP_ROOT, sanitizePath(APP_DIR), SESSION_ID);
env = { ...process.env, APP_DIR, CRM_TMP_ROOT };
delete env.VALIDATE_WORKTREE;
dispatch({ subagentType: "developer", taskId: "TASK-001" });
});
afterAll(() => {
rmSync(TMP, { recursive: true, force: true });
});
describe("setup-worktree session-branch topology (PreToolUse/Agent)", () => {
test("session branch created", () => {
expect(
g("show-ref", "--verify", "--quiet", `refs/heads/session/${SS}`).status,
).toBe(0);
});
test("session-base anchor created", () => {
expect(
g("show-ref", "--verify", "--quiet", `refs/heads/session-base/${SS}`)
.status,
).toBe(0);
});
test("_session worktree created", () => {
expect(existsSync(join(WB, "_session"))).toBe(true);
});
test("task worktree created", () => {
expect(existsSync(join(WB, "TASK-001"))).toBe(true);
});
test("node_modules provisioned into task worktree", () => {
expect(existsSync(join(WB, "TASK-001", "node_modules"))).toBe(true);
});
test("task branch forked from session branch", () => {
expect(
g("merge-base", "--is-ancestor", `session/${SS}`, `${SS}/TASK-001`)
.status,
).toBe(0);
});
test("non-dev dispatch (reviewer) is a silent no-op", () => {
const r = spawnSync("node", [HOOK], {
input: JSON.stringify({
session_id: SESSION_ID,
tool_input: {
subagent_type: "quality-reviewer",
name: "quality-reviewer-TASK-001",
prompt: "ROLE: quality-reviewer\nTASK_ID: TASK-001",
},
}),
env,
encoding: "utf8",
});
expect(r.status).toBe(0);
});
test("idempotent second run exits 0 and keeps the worktree", () => {
expect(
dispatch({ subagentType: "developer", taskId: "TASK-001" }).status,
).toBe(0);
expect(existsSync(join(WB, "TASK-001"))).toBe(true);
});
test("stale _session registration survives dir wipe", () => {
rmSync(join(WB, "_session"), { recursive: true, force: true });
expect(
g("worktree", "list", "--porcelain").stdout.includes(
join(WB, "_session"),
),
).toBe(true);
});
test("_session recreated after stale-registration wipe", () => {
dispatch({ subagentType: "developer", taskId: "TASK-002" });
expect(existsSync(join(WB, "_session", ".git"))).toBe(true);
});
test("developer on the <short>/simple branch derives the simple worktree", () => {
spawnSync("node", [HOOK], {
input: JSON.stringify({
session_id: SESSION_ID,
tool_input: {
subagent_type: "developer",
name: "developer",
prompt: `ROLE: developer\nWORKTREE_PATH: ${join(WB, "simple")}\nBRANCH_NAME: ${SS}/simple`,
},
}),
env,
encoding: "utf8",
});
expect(existsSync(join(WB, "simple"))).toBe(true);
});
test("developer with WORKTREE_PATH but unresolvable task id is blocked (exit 2)", () => {
const r = spawnSync("node", [HOOK], {
input: JSON.stringify({
session_id: SESSION_ID,
tool_input: {
subagent_type: "developer",
name: "developer",
prompt:
"ROLE: developer\nWORKTREE_PATH: /wt/nowhere\nBRANCH_NAME: x/y",
},
}),
env,
encoding: "utf8",
});
expect(r.status).toBe(2);
});
test("promotion-conflict-resolver is accepted with no worktree (exit 0)", () => {
const r = spawnSync("node", [HOOK], {
input: JSON.stringify({
session_id: SESSION_ID,
tool_input: {
subagent_type: "developer",
name: "developer",
prompt: `ROLE: promotion-conflict-resolver (gated $CLAUDE_PROJECT_DIR exception)\nSESSION_SHORT_ID: ${SS}`,
},
}),
env,
encoding: "utf8",
});
expect(r.status).toBe(0);
});
test("no SESSION-BRANCH FAILED logged", () => {
let logTxt = "";
try {
logTxt = readFileSync(join(WB, "hooks.log"), "utf8");
} catch {
logTxt = "";
}
expect(logTxt.includes("SESSION-BRANCH FAILED")).toBe(false);
});
});

View File

@@ -0,0 +1,59 @@
// Tests for turn-complete.mjs — the Stop hook that drops a per-session sentinel
// (/tmp/pty-sentinels/pty-turn-done-<session_id>) the chat-service PtySession
// watcher polls. Must always exit 0 (a Stop hook never blocks) and never throw on
// a missing/malformed payload.
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { describe, test, expect, afterAll } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "turn-complete.mjs");
// A unique id so the test never collides with a real session's sentinel.
const SID = "turn-complete-test-7e3a9f21";
const sentinel = join("/tmp/pty-sentinels", `pty-turn-done-${SID}`);
const run = (input) => spawnSync("node", [HOOK], { input, encoding: "utf8" });
afterAll(() => rmSync(sentinel, { force: true }));
describe("turn-complete", () => {
test("creates the per-session sentinel and exits 0", () => {
const r = run(JSON.stringify({ session_id: SID }));
expect(r.status).toBe(0);
expect(existsSync(sentinel)).toBe(true);
});
test("no session id → no throw, exits 0", () => {
expect(run(JSON.stringify({})).status).toBe(0);
});
test("malformed payload → no throw, exits 0", () => {
expect(run("not json").status).toBe(0);
});
test("inert when config.launcher.turnSentinelDir is unset (no launcher)", () => {
// A repo whose config omits turnSentinelDir: the hook writes no sentinel.
const repo = mkdtempSync(join(tmpdir(), "turn-complete-repo-"));
writeFileSync(
join(repo, "harness.config.json"),
JSON.stringify({ validation: { steps: [] }, roles: {}, launcher: {} }),
);
const otherSid = "turn-complete-inert-9c1d";
const wouldBe = join("/tmp/pty-sentinels", `pty-turn-done-${otherSid}`);
rmSync(wouldBe, { force: true });
const env = { ...process.env, APP_DIR: repo };
delete env.CLAUDE_PROJECT_DIR;
const r = spawnSync("node", [HOOK], {
input: JSON.stringify({ session_id: otherSid }),
env,
encoding: "utf8",
});
expect(r.status).toBe(0);
expect(existsSync(wouldBe)).toBe(false);
rmSync(repo, { recursive: true, force: true });
});
});

View File

@@ -0,0 +1,53 @@
// Tests for validate-on-stop.mjs — dry-run short-circuits (VALIDATE_DRY_RUN=1 accepts, =fail exits 2) and the no-active-worktree accept, without running real tools.
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { afterAll, describe, test, expect } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const HOOK = join(HERE, "..", "validate-on-stop.mjs");
const appDir = mkdtempSync(join(tmpdir(), "validate-on-stop-app-"));
const tmpRoot = mkdtempSync(join(tmpdir(), "validate-on-stop-tmp-"));
afterAll(() => {
rmSync(appDir, { recursive: true, force: true });
rmSync(tmpRoot, { recursive: true, force: true });
});
const INPUT = '{"session_id":"test-1234","agent_type":"developer"}';
const runHook = (dryRun) => {
const env = {
...process.env,
APP_DIR: appDir,
CRM_TMP_ROOT: tmpRoot,
};
delete env.VALIDATE_WORKTREE;
delete env.VALIDATE_DRY_RUN;
delete env.MODE;
if (dryRun) env.VALIDATE_DRY_RUN = dryRun;
return spawnSync("node", [HOOK], { input: INPUT, env, encoding: "utf8" });
};
describe("validate-on-stop hook", () => {
test("VALIDATE_DRY_RUN=1 → accept (exit 0)", () => {
const r = runHook("1");
expect(r.status).toBe(0);
});
test("VALIDATE_DRY_RUN=fail → exit 2 with simulated failure on stderr", () => {
const r = runHook("fail");
expect(r.status).toBe(2);
expect(r.stderr).toContain("Validation failed");
expect(r.stderr).toContain("simulated");
});
test("no active worktree → accept (exit 0)", () => {
const r = runHook("");
expect(r.status).toBe(0);
});
});

View File

@@ -0,0 +1,53 @@
// Tests for lib/validation.mjs: scopedLintFiles — the pure file-selection the
// lint step feeds to eslint (extension filter + must-still-exist-on-disk).
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, test, expect } from "vitest";
import { scopedLintFiles } from "../lib/validation.mjs";
const EXT = [".ts", ".tsx", ".mjs"];
const tmpDirs = [];
const makeCwd = (files) => {
const dir = mkdtempSync(join(tmpdir(), "scoped-lint-"));
tmpDirs.push(dir);
for (const f of files) writeFileSync(join(dir, f), "");
return dir;
};
afterEach(() => {
while (tmpDirs.length)
rmSync(tmpDirs.pop(), { recursive: true, force: true });
});
describe("scopedLintFiles", () => {
test("keeps only lintable extensions", () => {
const cwd = makeCwd(["a.ts", "b.tsx", "c.mjs", "d.css", "e.sql", "f.md"]);
expect(
scopedLintFiles(
["a.ts", "b.tsx", "c.mjs", "d.css", "e.sql", "f.md"],
cwd,
EXT,
),
).toEqual(["a.ts", "b.tsx", "c.mjs"]);
});
test("drops paths that no longer exist (deleted / renamed away)", () => {
const cwd = makeCwd(["kept.ts"]);
expect(scopedLintFiles(["kept.ts", "gone.ts"], cwd, EXT)).toEqual([
"kept.ts",
]);
});
test("empty change set → empty list (nothing to lint)", () => {
const cwd = makeCwd([]);
expect(scopedLintFiles([], cwd, EXT)).toEqual([]);
});
test("a diff of only non-code files → empty list", () => {
const cwd = makeCwd(["schema.sql", "notes.md"]);
expect(scopedLintFiles(["schema.sql", "notes.md"], cwd, EXT)).toEqual([]);
});
});

View File

@@ -0,0 +1,103 @@
// Tests for workspace-folders.mjs: adding/removing a harness worktree as a VS Code
// workspace folder. Exercises the `.code-workspace` file mechanism (clean add +
// remove) and the mono-folder no-op path. IPC env is cleared in beforeEach so the
// mono path can never shell out to `code --add` against a real window during tests.
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
addWorktreeFolder,
removeWorktreeFolders,
} from "../lib/workspace-folders.mjs";
let TMP = null;
let saved = {};
beforeEach(() => {
saved = {
ipc: process.env.VSCODE_IPC_HOOK_CLI,
rc: process.env.REMOTE_CONTAINERS_IPC,
};
delete process.env.VSCODE_IPC_HOOK_CLI;
delete process.env.REMOTE_CONTAINERS_IPC;
});
afterEach(() => {
if (saved.ipc !== undefined) process.env.VSCODE_IPC_HOOK_CLI = saved.ipc;
if (saved.rc !== undefined) process.env.REMOTE_CONTAINERS_IPC = saved.rc;
if (TMP) rmSync(TMP, { recursive: true, force: true });
TMP = null;
});
// A workspace root containing the repo folder `app` and (optionally) a
// `.code-workspace` file beside it, plus a worktree dir living outside the repo.
const setup = ({ withWorkspaceFile = true } = {}) => {
TMP = mkdtempSync(join(tmpdir(), "workspace-folders-test-"));
const root = join(TMP, "root");
const repo = join(root, "app");
mkdirSync(repo, { recursive: true });
const wsFile = join(root, "proj.code-workspace");
if (withWorkspaceFile) {
writeFileSync(
wsFile,
JSON.stringify({ folders: [{ path: "app", name: "app" }] }, null, 2),
);
}
const wt = join(TMP, "wt", "TASK-001");
mkdirSync(wt, { recursive: true });
const folders = () => JSON.parse(readFileSync(wsFile, "utf8")).folders;
return { repo, wt, wsFile, folders };
};
describe("workspace-folders", () => {
test("adds a worktree folder to the .code-workspace", () => {
const { repo, wt, folders } = setup();
expect(addWorktreeFolder(repo, wt, "🎫 TASK-001")).toBe(true);
const f = folders();
expect(f).toContainEqual({ path: wt, name: "🎫 TASK-001" });
// the original repo folder is preserved
expect(f).toContainEqual({ path: "app", name: "app" });
});
test("is idempotent (no duplicate entry)", () => {
const { repo, wt, folders } = setup();
addWorktreeFolder(repo, wt, "🎫 TASK-001");
addWorktreeFolder(repo, wt, "🎫 TASK-001");
expect(folders().filter((f) => f.path === wt)).toHaveLength(1);
});
test("removeWorktreeFolders removes entries matching the predicate", () => {
const { repo, wt, folders } = setup();
addWorktreeFolder(repo, wt, "🎫 TASK-001");
expect(removeWorktreeFolders(repo, (p) => p === wt)).toBe(true);
expect(folders().some((f) => f.path === wt)).toBe(false);
// the original repo folder survives
expect(folders()).toContainEqual({ path: "app", name: "app" });
});
test("does not add a non-existent worktree path", () => {
const { repo, folders } = setup();
expect(addWorktreeFolder(repo, join(TMP, "nope"), "x")).toBe(false);
expect(folders()).toHaveLength(1);
});
test("never corrupts a .code-workspace it cannot parse (JSONC)", () => {
const { repo, wt, wsFile } = setup();
const withComment = '// my workspace\n{ "folders": [{ "path": "app" }] }\n';
writeFileSync(wsFile, withComment);
expect(addWorktreeFolder(repo, wt, "x")).toBe(false);
expect(readFileSync(wsFile, "utf8")).toBe(withComment);
});
test("mono-folder with no IPC: add and remove are no-ops", () => {
const { repo, wt } = setup({ withWorkspaceFile: false });
expect(addWorktreeFolder(repo, wt, "x")).toBe(false);
expect(removeWorktreeFolders(repo, () => true)).toBe(false);
});
});

34
.claude/hooks/turn-complete.mjs Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env node
// Stop: drop a per-session sentinel file so a managed launcher (e.g. CRM
// Builder's chat-service PtySession) knows the turn is complete. Stop fires after
// the JSONL transcript is fully written, so the watcher can emit its result event
// only once the transcript is flushed.
//
// The sentinel directory is a launcher extension point: config.launcher
// .turnSentinelDir (see .claude/rules/launcher-interface.md). When it is unset
// (no managed launcher), this hook is INERT: no directory is created, no path is
// hardcoded. Atomic CRM's config sets it to /tmp/pty-sentinels, preserving the
// current CRM Builder behavior.
import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { loadConfig, launcher } from "./lib/config.mjs";
let sid = "";
try {
sid = JSON.parse(readFileSync(0, "utf8")).session_id || "";
} catch {
// no session id -> nothing to signal
}
let dir = null;
try {
dir = launcher(loadConfig()).turnSentinelDir || null;
} catch {
dir = null; // fail-open: a config error must never break the Stop hook
}
if (sid && dir) {
mkdirSync(dir, { recursive: true });
closeSync(openSync(join(dir, `pty-turn-done-${sid}`), "w"));
}

View File

@@ -0,0 +1,111 @@
#!/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 <base>/TASK-XXX, a single-shot simple developer (on
// the <short>/simple branch — rollback / migration) validates <base>/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 <base>/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 <agent>.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/<short>, 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 <BASE_BRANCH>`, re-forking <short>/simple onto the default
// branch, so its diff against session/<short> 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"})`);