cohorte 1.3.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +4 -7
  3. package/bin/cli.js +49 -4
  4. package/core/agents/implementer.template.md +10 -5
  5. package/core/agents/profile-reader.md +22 -0
  6. package/core/commands/doctor.md +8 -4
  7. package/core/hooks/gate.py +21 -6
  8. package/core/templates/agent-handoff.md +7 -2
  9. package/core/templates/review-feedback.md +7 -4
  10. package/core/templates/spec.template.md +5 -2
  11. package/core/templates/steps/init-pipeline/04-write-render.md +2 -1
  12. package/core/workflows/audit.js +88 -7
  13. package/core/workflows/refactor.js +85 -9
  14. package/core/workflows/review.js +132 -11
  15. package/dashboard/README.md +22 -5
  16. package/dashboard/dist/assets/index-AFQnlfjO.css +1 -0
  17. package/dashboard/dist/assets/{index-BxgA_mz1.js → index-DLBzciIC.js} +12 -11
  18. package/dashboard/dist/index.html +2 -2
  19. package/dashboard/server/doctor.js +68 -20
  20. package/dashboard/server/fleet.js +19 -5
  21. package/dashboard/server/index.js +79 -7
  22. package/dashboard/server/metrics.js +16 -4
  23. package/dashboard/server/versions.js +28 -6
  24. package/dashboard/server/yaml.js +4 -1
  25. package/install.ps1 +4 -0
  26. package/install.sh +19 -1
  27. package/package.json +5 -2
  28. package/profile/SCHEMA.md +23 -26
  29. package/scripts/kanban-move.sh +34 -20
  30. package/scripts/metrics/collect.mjs +495 -0
  31. package/scripts/metrics/prices.json +39 -0
  32. package/scripts/new-feature.sh.template +3 -1
  33. package/scripts/preflight.sh +16 -3
  34. package/scripts/remove-feature.sh.template +2 -1
  35. package/scripts/telemetry-send.sh +15 -1
  36. package/scripts/test-dashboard.mjs +362 -0
  37. package/scripts/test-gate.mjs +273 -0
  38. package/scripts/test-metrics.mjs +135 -0
  39. package/scripts/test-workflows.mjs +321 -0
  40. package/scripts/validate-core.mjs +51 -1
  41. package/core/commands/cycle.md +0 -54
  42. package/core/workflows/cycle.js +0 -407
  43. package/dashboard/dist/assets/index-Cj0SpgEY.css +0 -1
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env node
2
+ // Tests for the dashboard's server modules (dashboard/server/*.js).
3
+ //
4
+ // These are shipped runtime code with real logic and zero coverage until now:
5
+ // a hand-rolled YAML parser that every /doctor check is derived from, a metrics
6
+ // aggregator, the JS port of /doctor, an Obsidian board parser, the fleet
7
+ // registry, and an HTTP layer whose guards are the dashboard's only defence
8
+ // against a web page driving the local agent.
9
+ //
10
+ // node scripts/test-dashboard.mjs
11
+
12
+ import { createRequire } from "node:module";
13
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import net from "node:net";
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const root = fileURLToPath(new URL("..", import.meta.url));
21
+ const { parse, parseProfileBlock } = require(join(root, "dashboard/server/yaml.js"));
22
+ const { metrics } = require(join(root, "dashboard/server/metrics.js"));
23
+ const { state, scanSpecs } = require(join(root, "dashboard/server/doctor.js"));
24
+ const { kanban } = require(join(root, "dashboard/server/kanban.js"));
25
+ const fleet = require(join(root, "dashboard/server/fleet.js"));
26
+
27
+ let failures = 0;
28
+ const check = (name, cond, detail = "") => {
29
+ if (cond) console.log(` ✓ ${name}`);
30
+ else { failures++; console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`); }
31
+ };
32
+ const eq = (name, got, want) =>
33
+ check(name, JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`);
34
+
35
+ const tmps = [];
36
+ const scratch = () => { const d = mkdtempSync(join(tmpdir(), "dash-")); tmps.push(d); return d; };
37
+ const write = (p, s) => { mkdirSync(join(p, ".."), { recursive: true }); writeFileSync(p, s); };
38
+
39
+ // ── yaml.js ──────────────────────────────────────────────────────────────────
40
+ console.log("yaml.js — the profile parser");
41
+ {
42
+ eq("scalars: bool / int / null / quoted",
43
+ parse("a: true\nb: 12\nc: null\nd: \"x: y\"\ne: ~"),
44
+ { a: true, b: 12, c: null, d: "x: y", e: null });
45
+ eq("flow array", parse("t: [Read, Write, mcp__serena]").t, ["Read", "Write", "mcp__serena"]);
46
+ eq("flow map", parse("p: { api: 3333, web: 5173 }").p, { api: 3333, web: 5173 });
47
+ eq("nested map", parse("a:\n b:\n c: 1").a.b, { c: 1 });
48
+ eq("block sequence of scalars", parse("d:\n - one\n - two").d, ["one", "two"]);
49
+ eq("block sequence of maps",
50
+ parse("s:\n - key: a\n path: x\n - key: b\n path: y").s,
51
+ [{ key: "a", path: "x" }, { key: "b", path: "y" }]);
52
+ eq("comment after a value is stripped", parse("a: 1 # note").a, 1);
53
+ eq("a # inside quotes is NOT a comment", parse('a: "x # y"').a, "x # y");
54
+ eq("a # not preceded by whitespace is literal", parse("a: c#d").a, "c#d");
55
+ eq("empty value ⇒ null", parse("a:").a, null);
56
+ eq("a colon in the value survives", parse('m: "cd x && node ace migration:run"').m,
57
+ "cd x && node ace migration:run");
58
+ eq("empty flow array", parse("a: []").a, []);
59
+ check("no fenced block ⇒ null", parseProfileBlock("# just prose") === null);
60
+
61
+ // The real thing: the shipped template must round-trip.
62
+ const tpl = readFileSync(join(root, "profile/PIPELINE.template.md"), "utf8");
63
+ const p = parseProfileBlock(tpl);
64
+ check("the shipped PIPELINE.template.md parses", !!p);
65
+ eq("…surfaces are a list of 2", (p.surfaces || []).length, 2);
66
+ eq("…surface tools survive as an array", p.surfaces[0].tools.length, 7);
67
+ eq("…uses_design is a real boolean", p.surfaces[1].uses_design, true);
68
+ eq("…build_cmd stays an empty string, not null", p.surfaces[0].build_cmd, "");
69
+ eq("…gate.deny is a list of 4", p.gate.deny.length, 4);
70
+ eq("…gate.preflight.max_age_minutes is a number", p.gate.preflight.max_age_minutes, 30);
71
+ eq("…port_base flow map", p.isolation.port_base, { api: 3333, web: 5173 });
72
+ eq("…a chained migrate command survives", p.commands.migrate, "cd apps/api && node ace migration:run");
73
+ }
74
+
75
+ // ── metrics.js ───────────────────────────────────────────────────────────────
76
+ console.log("metrics.js — the funnel aggregate");
77
+ {
78
+ const d = scratch();
79
+ const lines = [
80
+ JSON.stringify({ ts: "2026-01-01T00:00:00Z", feature: "f1", phase: "build", seconds: 100, surfaces: { backend: "ok", frontend: "error" } }),
81
+ JSON.stringify({ ts: "2026-01-01T01:00:00Z", feature: "f1", phase: "review", seconds: 50, surfaces: { backend: "REVISE:2" } }),
82
+ JSON.stringify({ ts: "2026-01-01T02:00:00Z", feature: "f1", phase: "fix", seconds: 20, surfaces: { backend: "ok" } }),
83
+ JSON.stringify({ ts: "2026-01-01T03:00:00Z", feature: "f1", phase: "cycle", seconds: 0, rounds: 3, smoke: "SKIPPED", surfaces: { backend: "SHIP:0" } }),
84
+ // legacy: one line PER surface, folded into one batch, wall-clock = max
85
+ JSON.stringify({ ts: "2026-01-02T00:00:00Z", feature: "f2", phase: "build", surface: "backend", seconds: 10, result: "ok" }),
86
+ JSON.stringify({ ts: "2026-01-02T00:00:00Z", feature: "f2", phase: "build", surface: "frontend", seconds: 40, result: "ok" }),
87
+ "not json at all",
88
+ JSON.stringify({ ts: "x", feature: "f3" }), // no phase ⇒ skipped
89
+ JSON.stringify({ ts: "x", feature: "f3", phase: "build" }), // neither surfaces nor surface ⇒ skipped
90
+ ];
91
+ mkdirSync(join(d, ".claude"), { recursive: true });
92
+ writeFileSync(join(d, ".claude", "pipeline-metrics.jsonl"), lines.join("\n") + "\n");
93
+ const m = metrics({ projectRoot: d });
94
+
95
+ eq("malformed + incomplete lines are skipped", m.batches, 5);
96
+ const f1 = m.features.find(f => f.feature === "f1");
97
+ const f2 = m.features.find(f => f.feature === "f2");
98
+ eq("per-phase wall-clock", f1.phases.build.seconds, 100);
99
+ eq("fix rounds counted", f1.fixRounds, 1);
100
+ eq("cycle rounds surface outside `surfaces`", f1.cycleRounds, 3);
101
+ eq("legacy lines fold into ONE batch", Object.keys(f2.surfaces).sort(), ["backend", "frontend"]);
102
+ eq("…with wall-clock = the slowest surface", f2.phases.build.seconds, 40);
103
+ eq("`error` counts as a surface failure", f1.surfaces.frontend.failures, 1);
104
+ eq("a REVISE verdict counts as a failure", f1.surfaces.backend.failures, 1);
105
+ check("newest feature first", m.features[0].feature === "f2", m.features[0].feature);
106
+ check("no metrics file ⇒ present:false", metrics({ projectRoot: scratch() }).present === false);
107
+ }
108
+
109
+ // ── doctor.js ────────────────────────────────────────────────────────────────
110
+ console.log("doctor.js — the /doctor port");
111
+ {
112
+ const spec = (fm) => `---\n${fm}\n---\n\n# x\n`;
113
+ const d = scratch();
114
+ mkdirSync(join(d, "specs"), { recursive: true });
115
+ writeFileSync(join(d, "specs", "a.md"), spec("feature_id: a\ntitle: A\nstatus: frozen\nbranch: feature/a"));
116
+ writeFileSync(join(d, "specs", "b.md"), spec("feature_id: b\nstatus: shipped # done"));
117
+ writeFileSync(join(d, "specs", "c.md"), "no front-matter at all");
118
+ writeFileSync(join(d, "specs", "_template.md"), spec("status: draft"));
119
+ // /audit writes this file by design and it has no front-matter. Scanning it as a
120
+ // spec made /doctor warn about a file cohorte itself had just created — it fired in
121
+ // every project that had ever run /audit.
122
+ writeFileSync(join(d, "specs", "refactor-backlog.md"), "# Refactor Backlog\n\n## backend\n- [ ] x\n");
123
+ const specs = scanSpecs(d);
124
+ eq("_template.md is excluded", specs.length, 3);
125
+ eq("the /audit backlog is not scanned as a spec",
126
+ specs.some(s => s.file === "refactor-backlog.md"), false);
127
+ eq("front-matter fields are read", specs.find(s => s.id === "a").title, "A");
128
+ eq("a trailing comment is stripped from status", specs.find(s => s.id === "b").status, "shipped");
129
+ eq("no front-matter ⇒ id falls back to the filename", specs.find(s => s.file === "c.md").id, "c");
130
+ }
131
+ {
132
+ // A fully-wired synthetic project must come back green on the checks that can
133
+ // be computed from disk.
134
+ const g = scratch(); // stands in for ~/.claude
135
+ const d = scratch();
136
+ const gate = {
137
+ deny: ["x"], ask: ["y"], ask_on_default_branch: ["git push"], default_branch: "main",
138
+ preflight: { enabled: true, agents: ["review", "smoke"], max_age_minutes: 30 },
139
+ };
140
+ writeFileSync(join(d, "PIPELINE.md"), [
141
+ "```yaml pipeline-profile",
142
+ "name: Proj",
143
+ "retrieval:",
144
+ " provider: serena",
145
+ "surfaces:",
146
+ " - key: backend",
147
+ " path: apps/api",
148
+ " agent: backend",
149
+ "gate:",
150
+ " default_branch: main",
151
+ ' deny: ["x"]',
152
+ ' ask: ["y"]',
153
+ ' ask_on_default_branch: ["git push"]',
154
+ " preflight:",
155
+ " enabled: true",
156
+ " agents: [review, smoke]",
157
+ " max_age_minutes: 30",
158
+ "```",
159
+ ].join("\n"));
160
+ mkdirSync(join(d, ".claude", "agents"), { recursive: true });
161
+ mkdirSync(join(d, ".claude", "pipeline"), { recursive: true });
162
+ writeFileSync(join(d, ".claude", "pipeline", "VERSION"), "9.9.9\n");
163
+ writeFileSync(join(d, ".claude", "agents", "backend.md"), "x");
164
+ writeFileSync(join(d, ".claude", "gate-config.json"), JSON.stringify(gate));
165
+ writeFileSync(join(d, ".mcp.json"), JSON.stringify({ mcpServers: { serena: {} } }));
166
+ mkdirSync(join(d, ".claude", "workflows"), { recursive: true });
167
+ for (const w of ["review.js", "audit.js", "refactor.js"]) {
168
+ writeFileSync(join(d, ".claude", "workflows", w), "x");
169
+ }
170
+ writeFileSync(join(d, ".claude", "agents", "profile-reader.md"), "x");
171
+ writeFileSync(join(d, ".claude", "settings.json"), JSON.stringify({
172
+ hooks: { PreToolUse: [{ matcher: "Bash|Task", hooks: [{ type: "command", command: 'py "C:\\x\\gate.py"' }] }] },
173
+ }));
174
+
175
+ const by = (checks, id) => checks.find(c => c.id === id);
176
+ let s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
177
+ eq("profile parses ⇒ ok", by(s.checks, "profile").status, "ok");
178
+ eq("surface agent present ⇒ ok", by(s.checks, "agents").status, "ok");
179
+ eq("gate mirrors the profile ⇒ ok", by(s.checks, "gate").status, "ok");
180
+ eq("Windows-quoted hook command is recognised", by(s.checks, "hooks").status, "ok");
181
+ eq("retrieval wired in .mcp.json ⇒ ok", by(s.checks, "retrieval").status, "ok");
182
+ eq("workflows + profile-reader ⇒ ok", by(s.checks, "workflows").status, "ok");
183
+
184
+ // …and each check must actually FAIL when its precondition breaks.
185
+ writeFileSync(join(d, ".claude", "gate-config.json"),
186
+ JSON.stringify({ ...gate, preflight: { enabled: false } }));
187
+ s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
188
+ check("gate-config preflight drift is detected",
189
+ by(s.checks, "gate").status === "warn" && /preflight/.test(by(s.checks, "gate").detail),
190
+ by(s.checks, "gate").detail);
191
+ writeFileSync(join(d, ".claude", "gate-config.json"), JSON.stringify(gate));
192
+
193
+ writeFileSync(join(d, ".claude", "settings.json"), JSON.stringify({
194
+ hooks: { PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "python3 /x/gate.py" }] }] },
195
+ }));
196
+ s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
197
+ check("a Bash-only matcher is flagged (the 1.3.0 dead phase gate)",
198
+ by(s.checks, "hooks").status === "warn" && /Task/.test(by(s.checks, "hooks").detail),
199
+ by(s.checks, "hooks").detail);
200
+
201
+ writeFileSync(join(d, ".claude", "settings.json"), JSON.stringify({
202
+ hooks: { PreToolUse: [
203
+ { matcher: "Bash|Task", hooks: [{ type: "command", command: "python3 /x/gate.py" }] },
204
+ { matcher: "Bash|Task", hooks: [{ type: "command", command: 'py "/x/gate.py"' }] },
205
+ ] },
206
+ }));
207
+ s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
208
+ check("a duplicate registration is flagged (it double-prompts)",
209
+ by(s.checks, "hooks").status === "warn" && /2×/.test(by(s.checks, "hooks").detail),
210
+ by(s.checks, "hooks").detail);
211
+
212
+ rmSync(join(d, ".mcp.json"));
213
+ writeFileSync(join(d, ".claude", "agents", "orphan.md"), "x");
214
+ s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
215
+ check("provider declared but never wired ⇒ warn",
216
+ by(s.checks, "retrieval").status === "warn", by(s.checks, "retrieval").detail);
217
+ check("an agent file with no surface ⇒ orphan warn",
218
+ by(s.checks, "agents").status === "warn" && /orphan/.test(by(s.checks, "agents").detail),
219
+ by(s.checks, "agents").detail);
220
+
221
+ const bare = scratch();
222
+ s = await state({ projectRoot: bare, globalDir: g, cliVersion: "9.9.9" });
223
+ eq("no PIPELINE.md ⇒ profile bad", by(s.checks, "profile").status, "bad");
224
+ eq("no core ⇒ core bad", by(s.checks, "core").status, "bad");
225
+ }
226
+
227
+ // ── kanban.js ────────────────────────────────────────────────────────────────
228
+ console.log("kanban.js — the Obsidian board");
229
+ {
230
+ const g = scratch(), vault = scratch(), d = scratch();
231
+ writeFileSync(join(d, "PIPELINE.md"), "```yaml pipeline-profile\nname: Proj\n```");
232
+ mkdirSync(join(vault, "Proj"), { recursive: true });
233
+ writeFileSync(join(vault, "Proj", "Tasks.md"), [
234
+ "---", "kanban-plugin: board", "---", "",
235
+ "## Spec", "", "- [ ] Do a thing #feat-a", "\t- a note", "",
236
+ "## Shipped", "", "- [x] Old #feat-z — PR #42", "",
237
+ "%% kanban:settings", "%%", "",
238
+ ].join("\n"));
239
+ writeFileSync(join(g, "cohorte.config.yaml"), [
240
+ "kanban:", " enabled: true", " boards:", " Proj:", ' board: "Proj/Tasks.md"',
241
+ "obsidian:", ` vault_path: "${vault.replace(/\\/g, "/")}"`,
242
+ ].join("\n"));
243
+
244
+ const k = kanban({ projectRoot: d, globalDir: g });
245
+ check("board resolves for the profile name", k.enabled === true, k.reason);
246
+ eq("columns parsed", k.columns.map(c => c.name), ["Spec", "Shipped"]);
247
+ eq("cards counted", k.total, 2);
248
+ eq("the #tag is extracted", k.columns[0].cards[0].tags, ["feat-a"]);
249
+ eq("the tag is stripped from the display text", k.columns[0].cards[0].text, "Do a thing");
250
+ eq("a checked card is done", k.columns[1].cards[0].done, true);
251
+ eq("a bare #<num> is read as a PR reference", k.columns[1].cards[0].prs.map(p => p.num), ["42"]);
252
+ check("the settings trailer is not parsed as a column",
253
+ !k.columns.some(c => /kanban:settings/.test(c.name)));
254
+
255
+ writeFileSync(join(g, "cohorte.config.yaml"), "kanban:\n enabled: false\n");
256
+ check("kanban disabled ⇒ enabled:false with a reason",
257
+ kanban({ projectRoot: d, globalDir: g }).enabled === false);
258
+ check("no config at all ⇒ enabled:false, never a throw",
259
+ kanban({ projectRoot: d, globalDir: scratch() }).enabled === false);
260
+ }
261
+
262
+ // ── fleet.js ─────────────────────────────────────────────────────────────────
263
+ console.log("fleet.js — the project registry");
264
+ {
265
+ const g = scratch(), p1 = scratch(), p2 = scratch();
266
+ fleet.ensureSeed(g, p1);
267
+ eq("seed adds the launch project", fleet.read(g), [p1]);
268
+ fleet.ensureSeed(g, p1);
269
+ eq("seeding twice does not duplicate", fleet.read(g).length, 1);
270
+ fleet.add(g, p2);
271
+ eq("add appends", fleet.read(g).length, 2);
272
+ fleet.remove(g, p2);
273
+ eq("remove drops it", fleet.read(g), [p1]);
274
+
275
+ let threw = null;
276
+ try { fleet.add(g, "relative/path"); } catch (e) { threw = e.message; }
277
+ check("a relative path is rejected with a clear message",
278
+ /must be absolute/.test(threw || ""), threw);
279
+ threw = null;
280
+ try { fleet.add(g, join(p1, "nope")); } catch (e) { threw = e.message; }
281
+ check("a non-existent path is rejected", /not found/.test(threw || ""), threw);
282
+
283
+ // legacy registry name is read, then migrated forward on the next write
284
+ const g2 = scratch();
285
+ writeFileSync(join(g2, "thebidouille-dashboard.json"), JSON.stringify({ projects: [p1] }));
286
+ eq("the pre-rename registry is still read", fleet.read(g2), [p1]);
287
+
288
+ const b = fleet.browse(p1);
289
+ check("browse lists a directory", Array.isArray(b.dirs) && b.parent !== null);
290
+ writeFileSync(join(p1, "PIPELINE.md"), "x");
291
+ check("browse flags a pipeline project", fleet.browse(p1).isProject === true);
292
+ const missing = fleet.browse(join(p1, "does-not-exist"));
293
+ check("browse reports an unreadable dir in the body (not a throw)",
294
+ !!missing.error && missing.dirs.length === 0, JSON.stringify(missing));
295
+ }
296
+
297
+ // ── index.js — the HTTP guards ───────────────────────────────────────────────
298
+ console.log("index.js — HTTP guards");
299
+ {
300
+ const freePort = await new Promise((res) => {
301
+ const s = net.createServer();
302
+ s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => res(port)); });
303
+ });
304
+ const home = scratch(); // stands in for the user's home
305
+ const globalDir = join(home, ".claude"); // …so <home>/.claude IS the global core
306
+ mkdirSync(globalDir, { recursive: true });
307
+ const proj = scratch();
308
+ const start = require(join(root, "dashboard/server/index.js"));
309
+ start({ projectRoot: proj, globalDir, port: freePort, host: "127.0.0.1", openBrowser: false, pkgRoot: root, version: "9.9.9" });
310
+ const base = `http://127.0.0.1:${freePort}`;
311
+ const post = (body, headers = { "content-type": "application/json" }) =>
312
+ fetch(`${base}/api/action`, { method: "POST", headers, body: JSON.stringify(body) });
313
+
314
+ // `Host` is a forbidden header name for fetch/undici — it silently drops it, so
315
+ // a fetch-based assertion here passes against a server with NO guard at all.
316
+ // Speak raw HTTP instead.
317
+ const rawStatus = (host) => new Promise((res, rej) => {
318
+ const s = net.connect(freePort, "127.0.0.1", () => {
319
+ s.write(`GET /api/fleet HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`);
320
+ });
321
+ let buf = "";
322
+ s.on("data", (c) => { buf += c; });
323
+ s.on("end", () => res(Number((buf.match(/^HTTP\/1\.1 (\d+)/) || [])[1])));
324
+ s.on("error", rej);
325
+ });
326
+ eq("a forged Host header is rejected (DNS rebinding)", await rawStatus("evil.example.com"), 403);
327
+ eq("…while a loopback Host with a port passes", await rawStatus(`127.0.0.1:${freePort}`), 200);
328
+ eq("…and a bracketed IPv6 loopback passes", await rawStatus(`[::1]:${freePort}`), 200);
329
+ eq("…and bare 'localhost' passes", await rawStatus("localhost"), 200);
330
+
331
+ const csrf = await fetch(`${base}/api/projects`, {
332
+ method: "POST", headers: { "content-type": "text/plain" }, body: "path=/x",
333
+ });
334
+ eq("a state-changing request without JSON content-type is rejected (CSRF)", csrf.status, 403);
335
+
336
+ eq("a GET on the API still works", (await fetch(`${base}/api/fleet`)).status, 200);
337
+
338
+ const reset = await post({ action: "reset", project: home });
339
+ eq("reset refuses a project whose .claude IS the global core", reset.status, 400);
340
+ check("…and says why", /shared global core/.test((await reset.json()).error));
341
+
342
+ const badPath = await post({ action: "install", project: join(proj, "nope") });
343
+ eq("install refuses a non-existent project path", badPath.status, 400);
344
+
345
+ const badAction = await post({ action: "rm -rf" });
346
+ eq("an unknown action is rejected", badAction.status, 400);
347
+
348
+ const badCmd = await post({ action: "claude", command: "/evil", project: proj });
349
+ eq("a non-whitelisted slash command is rejected", badCmd.status, 400);
350
+
351
+ eq("a missing hashed asset 404s (never index.html)",
352
+ (await fetch(`${base}/assets/index-DEADBEEF.js`)).status, 404);
353
+ eq("a malformed percent-escape is a 400, not a 500",
354
+ (await fetch(`${base}/%`)).status, 400);
355
+ eq("an unknown API route 404s", (await fetch(`${base}/api/nope`)).status, 404);
356
+ }
357
+
358
+ for (const d of tmps) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
359
+ console.log("");
360
+ if (failures) { console.error(`test-dashboard: ${failures} failure(s)`); process.exit(1); }
361
+ console.log("test-dashboard: OK");
362
+ process.exit(0); // the HTTP server has no handle to close
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ // Behavioural tests for core/hooks/gate.py — the PreToolUse gate.
3
+ //
4
+ // The gate is the one component that can BLOCK a user's command, and it is the
5
+ // only one with a pure, fully testable interface: a PreToolUse payload on stdin,
6
+ // a JSON permissionDecision (or nothing) on stdout. Until this file existed it
7
+ // had no test at all — every one of its shipped regressions (a Bash-only matcher
8
+ // leaving the phase gate dead, branch state resolved in the wrong checkout,
9
+ // unanswerable "ask"s in headless runs) reached users first.
10
+ //
11
+ // node scripts/test-gate.mjs
12
+
13
+ import { spawnSync, execFileSync } from "node:child_process";
14
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const root = fileURLToPath(new URL("..", import.meta.url));
20
+ const GATE = join(root, "core", "hooks", "gate.py");
21
+
22
+ const python = ["py", "python3", "python"].find((c) => {
23
+ try { return spawnSync(c, ["--version"], { stdio: "ignore" }).status === 0; }
24
+ catch { return false; }
25
+ });
26
+ if (!python) { console.error("test-gate: no python found on PATH"); process.exit(2); }
27
+
28
+ let failures = 0;
29
+ const check = (name, cond, detail = "") => {
30
+ if (cond) console.log(` ✓ ${name}`);
31
+ else { failures++; console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`); }
32
+ };
33
+
34
+ const tmps = [];
35
+ function scratch() {
36
+ const d = mkdtempSync(join(tmpdir(), "gate-"));
37
+ tmps.push(d);
38
+ mkdirSync(join(d, ".claude"), { recursive: true });
39
+ return d;
40
+ }
41
+ function writeConfig(dir, cfg) {
42
+ writeFileSync(join(dir, ".claude", "gate-config.json"), JSON.stringify(cfg));
43
+ }
44
+ function gitRepo(dir, branch) {
45
+ const git = (...a) => execFileSync("git", a, { cwd: dir, stdio: "ignore" });
46
+ git("init", "-q");
47
+ git("config", "user.email", "t@t.t");
48
+ git("config", "user.name", "t");
49
+ git("config", "commit.gpgsign", "false");
50
+ writeFileSync(join(dir, "f.txt"), "x");
51
+ git("add", "-A");
52
+ git("commit", "-qm", "init");
53
+ git("branch", "-M", "main");
54
+ if (branch && branch !== "main") git("checkout", "-qb", branch);
55
+ return execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" }).trim();
56
+ }
57
+
58
+ // Run the hook with a payload. Returns { decision, reason, raw, status }.
59
+ function run(payload, { projectDir } = {}) {
60
+ const r = spawnSync(python, [GATE], {
61
+ input: JSON.stringify(payload),
62
+ encoding: "utf8",
63
+ env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir || "" },
64
+ });
65
+ const raw = (r.stdout || "").trim();
66
+ if (!raw) return { decision: null, reason: null, raw, status: r.status };
67
+ try {
68
+ const o = JSON.parse(raw).hookSpecificOutput;
69
+ return { decision: o.permissionDecision, reason: o.permissionDecisionReason, raw, status: r.status };
70
+ } catch {
71
+ return { decision: "UNPARSEABLE", reason: null, raw, status: r.status };
72
+ }
73
+ }
74
+ const bash = (command, extra = {}) => ({ tool_name: "Bash", tool_input: { command }, ...extra });
75
+ const task = (subagent_type, extra = {}) => ({ tool_name: "Task", tool_input: { subagent_type }, ...extra });
76
+
77
+ const GATE_CFG = {
78
+ deny: ["node ace migration:fresh", "node ace db:wipe"],
79
+ ask: ["node ace migration:run", "psql"],
80
+ ask_on_default_branch: ["git commit", "git push", "docker compose"],
81
+ default_branch: "main",
82
+ };
83
+
84
+ // ── Bash command gating ──────────────────────────────────────────────────────
85
+ console.log("gate.py — Bash command gating");
86
+ {
87
+ const d = scratch(); writeConfig(d, GATE_CFG); gitRepo(d, "feature/x");
88
+ const at = p => ({ projectDir: d });
89
+
90
+ check("harmless command passes silently",
91
+ run(bash("ls -la"), at()).decision === null);
92
+ check("non-Bash, non-Task tool is ignored",
93
+ run({ tool_name: "Read", tool_input: { file_path: "x" } }, at()).decision === null);
94
+ check("deny pattern ⇒ deny",
95
+ run(bash("node ace migration:fresh"), at()).decision === "deny");
96
+ check("ask pattern ⇒ ask",
97
+ run(bash("node ace migration:run"), at()).decision === "ask");
98
+
99
+ // The headline capability: prefix-based settings.json rules cannot see this.
100
+ check("CHAINED command is caught (cd x && …)",
101
+ run(bash("cd apps/api && node ace migration:run"), at()).decision === "ask");
102
+ check("chained after a semicolon is caught",
103
+ run(bash("echo hi; node ace db:wipe"), at()).decision === "deny");
104
+ check("chained after a pipe is caught",
105
+ run(bash("cat x | psql"), at()).decision === "ask");
106
+ check("chained after || is caught",
107
+ run(bash("false || node ace migration:fresh"), at()).decision === "deny");
108
+ check("newline-separated is caught",
109
+ run(bash("echo a\nnode ace migration:run"), at()).decision === "ask");
110
+
111
+ check("whitespace is normalized before matching",
112
+ run(bash("node ace migration:run"), at()).decision === "ask");
113
+ check("deny wins over ask on the same segment",
114
+ run(bash("node ace migration:fresh"), at()).decision === "deny");
115
+ // Matching is substring-on-the-whole-pattern, so a partial overlap is NOT a
116
+ // match — `migration:run` alone does not trigger `node ace migration:run`.
117
+ check("a partial overlap of a pattern does not gate",
118
+ run(bash("echo 'we never run migration:run here'"), at()).decision === null);
119
+ // …but the full pattern inside a quoted string DOES gate. Intentional: the gate
120
+ // cannot know a shell quote is inert (`sh -c "node ace db:wipe"` is real), so it
121
+ // over-gates rather than reasoning about quoting.
122
+ check("the full pattern inside a quoted string still gates (fail-safe over-gating)",
123
+ run(bash("echo \"node ace db:wipe\""), at()).decision === "deny");
124
+ check("…including when wrapped in sh -c, which really would execute",
125
+ run(bash("sh -c 'node ace db:wipe'"), at()).decision === "deny");
126
+
127
+ // bypassPermissions: nobody can answer a prompt.
128
+ check("ask in bypassPermissions ⇒ escalated to deny",
129
+ run(bash("node ace migration:run"), { ...at(), }).decision === "ask");
130
+ const unattended = run({ ...bash("node ace migration:run"), permission_mode: "bypassPermissions" }, at());
131
+ check("ask + bypassPermissions ⇒ deny", unattended.decision === "deny", unattended.decision);
132
+ check("…and the reason says why", /unattended/i.test(unattended.reason || ""), unattended.reason);
133
+ }
134
+
135
+ // ── branch-conditional gating ────────────────────────────────────────────────
136
+ console.log("gate.py — branch-conditional gating");
137
+ {
138
+ const main = scratch(); writeConfig(main, GATE_CFG); gitRepo(main, "main");
139
+ const feat = scratch(); writeConfig(feat, GATE_CFG); gitRepo(feat, "feature/x");
140
+
141
+ check("git commit on the default branch ⇒ ask",
142
+ run({ ...bash("git commit -m x"), cwd: main }, { projectDir: main }).decision === "ask");
143
+ check("git commit on a feature branch ⇒ free",
144
+ run({ ...bash("git commit -m x"), cwd: feat }, { projectDir: feat }).decision === null);
145
+ check("docker compose on a feature branch ⇒ free",
146
+ run({ ...bash("docker compose up"), cwd: feat }, { projectDir: feat }).decision === null);
147
+
148
+ // The 1.3.3 fix: git state must resolve at the payload's cwd (the worktree),
149
+ // not CLAUDE_PROJECT_DIR (the main checkout, usually on the default branch).
150
+ const cross = run({ ...bash("git commit -m x"), cwd: feat }, { projectDir: main });
151
+ check("branch resolves at the payload cwd, not CLAUDE_PROJECT_DIR",
152
+ cross.decision === null, `got ${cross.decision} (a worktree commit must not be gated)`);
153
+
154
+ // Fail-safe: no repo ⇒ unknown branch ⇒ gate.
155
+ const norepo = scratch(); writeConfig(norepo, GATE_CFG);
156
+ check("unknown branch (not a repo) ⇒ gated, to stay safe",
157
+ run({ ...bash("git commit -m x"), cwd: norepo }, { projectDir: norepo }).decision === "ask");
158
+ }
159
+
160
+ // ── config robustness ────────────────────────────────────────────────────────
161
+ console.log("gate.py — config robustness");
162
+ {
163
+ const none = scratch(); // no gate-config.json at all
164
+ check("missing gate-config.json ⇒ silent (never bricks a repo)",
165
+ run(bash("node ace migration:fresh"), { projectDir: none }).decision === null);
166
+
167
+ const bad = scratch();
168
+ writeFileSync(join(bad, ".claude", "gate-config.json"), "{ not json");
169
+ check("unparseable gate-config.json ⇒ silent",
170
+ run(bash("node ace migration:fresh"), { projectDir: bad }).decision === null);
171
+
172
+ const empty = scratch(); writeConfig(empty, { deny: [], ask: [], ask_on_default_branch: [] });
173
+ check("empty pattern lists ⇒ silent",
174
+ run(bash("node ace migration:fresh"), { projectDir: empty }).decision === null);
175
+
176
+ const r = spawnSync(python, [GATE], { input: "not json at all", encoding: "utf8" });
177
+ check("malformed stdin ⇒ exit 0, no output (never blocks on its own bug)",
178
+ r.status === 0 && (r.stdout || "").trim() === "", `status=${r.status} out=${r.stdout}`);
179
+ }
180
+
181
+ // ── the preflight phase gate (Task dispatches) ───────────────────────────────
182
+ console.log("gate.py — preflight phase gate");
183
+ {
184
+ const pf = { enabled: true, agents: ["review", "smoke"], max_age_minutes: 30 };
185
+ const d = scratch(); writeConfig(d, { ...GATE_CFG, preflight: pf });
186
+ const head = gitRepo(d, "main");
187
+ const stamp = (epoch, sha) =>
188
+ writeFileSync(join(d, ".claude", "preflight.ok"), `${epoch} ${sha}\n`);
189
+ const now = () => Math.floor(Date.now() / 1000);
190
+ const at = { projectDir: d };
191
+
192
+ check("no stamp ⇒ ask", run(task("review"), at).decision === "ask");
193
+ check("…and the reason names the phase gate",
194
+ /phase gate/i.test(run(task("review"), at).reason || ""));
195
+
196
+ stamp(now(), head);
197
+ check("fresh stamp at the current HEAD ⇒ passes", run(task("review"), at).decision === null);
198
+ check("smoke is gated too", run(task("smoke"), at).decision === null);
199
+
200
+ stamp(now() - 60 * 60, head);
201
+ check("stamp older than max_age_minutes ⇒ ask", run(task("review"), at).decision === "ask");
202
+ check("…and the reason says it is stale",
203
+ /min old/.test(run(task("review"), at).reason || ""));
204
+
205
+ stamp(now(), "0000000000000000000000000000000000000000");
206
+ const moved = run(task("review"), at);
207
+ check("stamp from a different HEAD ⇒ ask", moved.decision === "ask", moved.decision);
208
+ check("…and the reason says HEAD moved", /HEAD moved/.test(moved.reason || ""));
209
+
210
+ writeFileSync(join(d, ".claude", "preflight.ok"), "garbage\n");
211
+ check("unreadable stamp ⇒ ask, reported as unreadable",
212
+ /unreadable/.test(run(task("review"), at).reason || ""));
213
+
214
+ stamp(now(), head);
215
+ check("an unlisted subagent_type is not gated",
216
+ run(task("backend"), at).decision === null);
217
+ check("a Task with no subagent_type is not gated",
218
+ run({ tool_name: "Task", tool_input: {} }, at).decision === null);
219
+
220
+ // Unattended: an "ask" nobody can answer must become a deny (1.3.3 fix).
221
+ writeFileSync(join(d, ".claude", "preflight.ok"), "garbage\n");
222
+ const headless = run({ ...task("review"), permission_mode: "bypassPermissions" }, at);
223
+ check("stale stamp + bypassPermissions ⇒ deny, not an unanswerable ask",
224
+ headless.decision === "deny", headless.decision);
225
+
226
+ // Disabled / absent block ⇒ the phase gate must not fire at all.
227
+ const off = scratch(); writeConfig(off, { ...GATE_CFG, preflight: { enabled: false } });
228
+ check("preflight.enabled false ⇒ Task never gated",
229
+ run(task("review"), { projectDir: off }).decision === null);
230
+ const noblock = scratch(); writeConfig(noblock, GATE_CFG);
231
+ check("profile with no preflight block ⇒ Task never gated (older installs keep working)",
232
+ run(task("review"), { projectDir: noblock }).decision === null);
233
+ }
234
+
235
+ // ── worktree awareness (the 1.3.3 known_heads fix) ───────────────────────────
236
+ console.log("gate.py — worktree awareness");
237
+ {
238
+ const pf = { enabled: true, agents: ["review"], max_age_minutes: 30 };
239
+ const d = scratch(); writeConfig(d, { ...GATE_CFG, preflight: pf });
240
+ const mainHead = gitRepo(d, "main");
241
+ const wt = join(d, "..", `wt-${Math.abs(mainHead.charCodeAt(0))}-${tmps.length}`);
242
+ let wtHead = null;
243
+ try {
244
+ execFileSync("git", ["worktree", "add", "-q", "-b", "feature/w", wt], { cwd: d, stdio: "ignore" });
245
+ tmps.push(wt);
246
+ // The worktree MUST diverge, or its HEAD equals the main checkout's and the
247
+ // test passes against the single-HEAD implementation too — a vacuous test
248
+ // (mutation testing is how that was caught).
249
+ writeFileSync(join(wt, "g.txt"), "y");
250
+ execFileSync("git", ["add", "-A"], { cwd: wt, stdio: "ignore" });
251
+ execFileSync("git", ["commit", "-qm", "wt"], { cwd: wt, stdio: "ignore" });
252
+ wtHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: wt, encoding: "utf8" }).trim();
253
+ if (wtHead === mainHead) wtHead = null; // did not diverge ⇒ nothing to prove
254
+ } catch { /* worktree unsupported here — skip */ }
255
+
256
+ if (wtHead) {
257
+ // The preflight legitimately runs in the worktree while the Task dispatch
258
+ // fires from the main checkout (or vice versa). Comparing against a single
259
+ // HEAD flagged those as stale.
260
+ writeFileSync(join(d, ".claude", "preflight.ok"), `${Math.floor(Date.now() / 1000)} ${wtHead}\n`);
261
+ const r = run({ ...task("review"), cwd: d }, { projectDir: d });
262
+ check("a stamp from a linked worktree's HEAD is accepted", r.decision === null,
263
+ `got ${r.decision} — ${r.reason}`);
264
+ } else {
265
+ console.log(" – worktree test skipped (git worktree unavailable)");
266
+ }
267
+ }
268
+
269
+ for (const d of tmps) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
270
+
271
+ console.log("");
272
+ if (failures) { console.error(`test-gate: ${failures} failure(s)`); process.exit(1); }
273
+ console.log("test-gate: OK");