snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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