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
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:
242
.claude/hooks/bash-guard.mjs
Executable file
242
.claude/hooks/bash-guard.mjs
Executable 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);
|
||||
38
.claude/hooks/block-docker-containers.mjs
Executable file
38
.claude/hooks/block-docker-containers.mjs
Executable 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);
|
||||
154
.claude/hooks/block-duplicate-dispatch.mjs
Executable file
154
.claude/hooks/block-duplicate-dispatch.mjs
Executable 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);
|
||||
38
.claude/hooks/block-merger-without-review.mjs
Executable file
38
.claude/hooks/block-merger-without-review.mjs
Executable 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(",")}` },
|
||||
);
|
||||
57
.claude/hooks/block-migration-writes.mjs
Executable file
57
.claude/hooks/block-migration-writes.mjs
Executable 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);
|
||||
65
.claude/hooks/block-nested-orchestrator.mjs
Executable file
65
.claude/hooks/block-nested-orchestrator.mjs
Executable 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);
|
||||
71
.claude/hooks/block-orchestrator-merge.mjs
Executable file
71
.claude/hooks/block-orchestrator-merge.mjs
Executable 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);
|
||||
54
.claude/hooks/block-promote-unmerged.mjs
Executable file
54
.claude/hooks/block-promote-unmerged.mjs
Executable 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}` },
|
||||
);
|
||||
68
.claude/hooks/block-wave-merger-promote.mjs
Executable file
68
.claude/hooks/block-wave-merger-promote.mjs
Executable 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})` },
|
||||
);
|
||||
55
.claude/hooks/check-typescript-shortcuts.mjs
Executable file
55
.claude/hooks/check-typescript-shortcuts.mjs
Executable 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
138
.claude/hooks/circuit-breaker.mjs
Executable 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);
|
||||
89
.claude/hooks/cleanup-session.mjs
Executable file
89
.claude/hooks/cleanup-session.mjs
Executable 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);
|
||||
172
.claude/hooks/cleanup-worktree.mjs
Executable file
172
.claude/hooks/cleanup-worktree.mjs
Executable 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}`,
|
||||
);
|
||||
142
.claude/hooks/completion-invariant.mjs
Executable file
142
.claude/hooks/completion-invariant.mjs
Executable 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 */
|
||||
}
|
||||
}
|
||||
69
.claude/hooks/enforce-dev-dispatch.mjs
Executable file
69
.claude/hooks/enforce-dev-dispatch.mjs
Executable 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);
|
||||
97
.claude/hooks/ensure-playwright-mcp.mjs
Normal file
97
.claude/hooks/ensure-playwright-mcp.mjs
Normal 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);
|
||||
69
.claude/hooks/force-foreground-orchestrator-dispatch.mjs
Executable file
69
.claude/hooks/force-foreground-orchestrator-dispatch.mjs
Executable 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);
|
||||
}
|
||||
52
.claude/hooks/format-on-write.mjs
Executable file
52
.claude/hooks/format-on-write.mjs
Executable 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);
|
||||
35
.claude/hooks/lib/agent-meta.mjs
Normal file
35
.claude/hooks/lib/agent-meta.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
150
.claude/hooks/lib/config.mjs
Normal file
150
.claude/hooks/lib/config.mjs
Normal 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,
|
||||
);
|
||||
170
.claude/hooks/lib/context.mjs
Normal file
170
.claude/hooks/lib/context.mjs
Normal 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,
|
||||
};
|
||||
}
|
||||
42
.claude/hooks/lib/dispatch-parse.mjs
Normal file
42
.claude/hooks/lib/dispatch-parse.mjs
Normal 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
88
.claude/hooks/lib/git.mjs
Normal 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
5
.claude/hooks/lib/io.mjs
Normal 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");
|
||||
}
|
||||
24
.claude/hooks/lib/paths.mjs
Normal file
24
.claude/hooks/lib/paths.mjs
Normal 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, "_");
|
||||
}
|
||||
15
.claude/hooks/lib/process.mjs
Normal file
15
.claude/hooks/lib/process.mjs
Normal 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);
|
||||
}
|
||||
32
.claude/hooks/lib/progress-log.mjs
Normal file
32
.claude/hooks/lib/progress-log.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
31
.claude/hooks/lib/reviews.mjs
Normal file
31
.claude/hooks/lib/reviews.mjs
Normal 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}`);
|
||||
121
.claude/hooks/lib/session-state.mjs
Normal file
121
.claude/hooks/lib/session-state.mjs
Normal 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 };
|
||||
}
|
||||
}
|
||||
53
.claude/hooks/lib/teams.mjs
Normal file
53
.claude/hooks/lib/teams.mjs
Normal 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 || "");
|
||||
25
.claude/hooks/lib/topology.mjs
Normal file
25
.claude/hooks/lib/topology.mjs
Normal 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";
|
||||
246
.claude/hooks/lib/validation.mjs
Normal file
246
.claude/hooks/lib/validation.mjs
Normal 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 };
|
||||
}
|
||||
88
.claude/hooks/lib/workspace-folders.mjs
Normal file
88
.claude/hooks/lib/workspace-folders.mjs
Normal 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);
|
||||
}
|
||||
28
.claude/hooks/lib/worktree.mjs
Normal file
28
.claude/hooks/lib/worktree.mjs
Normal 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;
|
||||
}
|
||||
92
.claude/hooks/link-session-workspace.mjs
Executable file
92
.claude/hooks/link-session-workspace.mjs
Executable 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
54
.claude/hooks/pre-pr-checks.mjs
Executable 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);
|
||||
57
.claude/hooks/record-merger-stage.mjs
Executable file
57
.claude/hooks/record-merger-stage.mjs
Executable 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);
|
||||
220
.claude/hooks/record-review-verdict.mjs
Executable file
220
.claude/hooks/record-review-verdict.mjs
Executable 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);
|
||||
42
.claude/hooks/reinject-invariants.mjs
Executable file
42
.claude/hooks/reinject-invariants.mjs
Executable 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
254
.claude/hooks/render-status.mjs
Executable 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);
|
||||
70
.claude/hooks/restrict-documentator-bash.mjs
Executable file
70
.claude/hooks/restrict-documentator-bash.mjs
Executable 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);
|
||||
57
.claude/hooks/restrict-documentator-write.mjs
Executable file
57
.claude/hooks/restrict-documentator-write.mjs
Executable 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);
|
||||
78
.claude/hooks/session-bootstrap.mjs
Executable file
78
.claude/hooks/session-bootstrap.mjs
Executable 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
276
.claude/hooks/setup-worktree.mjs
Executable 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}`);
|
||||
274
.claude/hooks/test/bash-guard.test.mjs
Normal file
274
.claude/hooks/test/bash-guard.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
95
.claude/hooks/test/block-docker-containers.test.mjs
Normal file
95
.claude/hooks/test/block-docker-containers.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
180
.claude/hooks/test/block-duplicate-dispatch.test.mjs
Normal file
180
.claude/hooks/test/block-duplicate-dispatch.test.mjs
Normal 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
|
||||
});
|
||||
});
|
||||
90
.claude/hooks/test/block-merger-without-review.test.mjs
Normal file
90
.claude/hooks/test/block-merger-without-review.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
90
.claude/hooks/test/block-nested-orchestrator.test.mjs
Normal file
90
.claude/hooks/test/block-nested-orchestrator.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
116
.claude/hooks/test/block-promote-unmerged.test.mjs
Normal file
116
.claude/hooks/test/block-promote-unmerged.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
145
.claude/hooks/test/block-wave-merger-promote.test.mjs
Normal file
145
.claude/hooks/test/block-wave-merger-promote.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
74
.claude/hooks/test/check-config-sync.test.mjs
Normal file
74
.claude/hooks/test/check-config-sync.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
80
.claude/hooks/test/check-typescript-shortcuts.test.mjs
Normal file
80
.claude/hooks/test/check-typescript-shortcuts.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
111
.claude/hooks/test/circuit-breaker.test.mjs
Normal file
111
.claude/hooks/test/circuit-breaker.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
142
.claude/hooks/test/cleanup-session.test.mjs
Normal file
142
.claude/hooks/test/cleanup-session.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
128
.claude/hooks/test/cleanup-worktree.test.mjs
Normal file
128
.claude/hooks/test/cleanup-worktree.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
157
.claude/hooks/test/config.test.mjs
Normal file
157
.claude/hooks/test/config.test.mjs
Normal 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");
|
||||
});
|
||||
});
|
||||
86
.claude/hooks/test/dispatch-parse.test.mjs
Normal file
86
.claude/hooks/test/dispatch-parse.test.mjs
Normal 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("");
|
||||
});
|
||||
});
|
||||
95
.claude/hooks/test/enforce-dev-dispatch.test.mjs
Normal file
95
.claude/hooks/test/enforce-dev-dispatch.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
89
.claude/hooks/test/format-on-write.test.mjs
Normal file
89
.claude/hooks/test/format-on-write.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
101
.claude/hooks/test/link-session-workspace.test.mjs
Normal file
101
.claude/hooks/test/link-session-workspace.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
79
.claude/hooks/test/pre-pr-checks.test.mjs
Normal file
79
.claude/hooks/test/pre-pr-checks.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
49
.claude/hooks/test/progress-log.test.mjs
Normal file
49
.claude/hooks/test/progress-log.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
90
.claude/hooks/test/provision-worktree.test.mjs
Normal file
90
.claude/hooks/test/provision-worktree.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
119
.claude/hooks/test/record-review-verdict.test.mjs
Normal file
119
.claude/hooks/test/record-review-verdict.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
67
.claude/hooks/test/reinject-invariants.test.mjs
Normal file
67
.claude/hooks/test/reinject-invariants.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
202
.claude/hooks/test/render-status.test.mjs
Normal file
202
.claude/hooks/test/render-status.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
174
.claude/hooks/test/restrict-documentator-write.test.mjs
Executable file
174
.claude/hooks/test/restrict-documentator-write.test.mjs
Executable 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);
|
||||
});
|
||||
});
|
||||
35
.claude/hooks/test/reviews.test.mjs
Normal file
35
.claude/hooks/test/reviews.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
100
.claude/hooks/test/session-bootstrap.test.mjs
Normal file
100
.claude/hooks/test/session-bootstrap.test.mjs
Normal 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>");
|
||||
});
|
||||
});
|
||||
214
.claude/hooks/test/setup-worktree.test.mjs
Executable file
214
.claude/hooks/test/setup-worktree.test.mjs
Executable 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);
|
||||
});
|
||||
});
|
||||
59
.claude/hooks/test/turn-complete.test.mjs
Normal file
59
.claude/hooks/test/turn-complete.test.mjs
Normal 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 });
|
||||
});
|
||||
});
|
||||
53
.claude/hooks/test/validate-on-stop.test.mjs
Normal file
53
.claude/hooks/test/validate-on-stop.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
53
.claude/hooks/test/validation.test.mjs
Normal file
53
.claude/hooks/test/validation.test.mjs
Normal 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([]);
|
||||
});
|
||||
});
|
||||
103
.claude/hooks/test/workspace-folders.test.mjs
Normal file
103
.claude/hooks/test/workspace-folders.test.mjs
Normal 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
34
.claude/hooks/turn-complete.mjs
Executable 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"));
|
||||
}
|
||||
111
.claude/hooks/validate-on-stop.mjs
Executable file
111
.claude/hooks/validate-on-stop.mjs
Executable 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"})`);
|
||||
Reference in New Issue
Block a user