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:
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user