cohorte 1.3.3 → 1.3.4

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 (39) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +4 -4
  3. package/bin/cli.js +22 -4
  4. package/core/agents/implementer.template.md +10 -5
  5. package/core/commands/cycle.md +15 -8
  6. package/core/commands/doctor.md +3 -1
  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 +20 -3
  13. package/core/workflows/cycle.js +146 -40
  14. package/core/workflows/refactor.js +16 -5
  15. package/core/workflows/review.js +59 -7
  16. package/dashboard/README.md +22 -5
  17. package/dashboard/dist/assets/index-AFQnlfjO.css +1 -0
  18. package/dashboard/dist/assets/{index-BxgA_mz1.js → index-DLBzciIC.js} +12 -11
  19. package/dashboard/dist/index.html +2 -2
  20. package/dashboard/server/doctor.js +60 -19
  21. package/dashboard/server/fleet.js +19 -5
  22. package/dashboard/server/index.js +79 -7
  23. package/dashboard/server/metrics.js +15 -4
  24. package/dashboard/server/versions.js +28 -6
  25. package/dashboard/server/yaml.js +4 -1
  26. package/install.ps1 +4 -0
  27. package/install.sh +19 -1
  28. package/package.json +5 -2
  29. package/profile/SCHEMA.md +28 -9
  30. package/scripts/kanban-move.sh +34 -20
  31. package/scripts/new-feature.sh.template +3 -1
  32. package/scripts/preflight.sh +16 -3
  33. package/scripts/remove-feature.sh.template +2 -1
  34. package/scripts/telemetry-send.sh +15 -1
  35. package/scripts/test-dashboard.mjs +356 -0
  36. package/scripts/test-gate.mjs +273 -0
  37. package/scripts/test-workflows.mjs +443 -0
  38. package/scripts/validate-core.mjs +49 -0
  39. package/dashboard/dist/assets/index-Cj0SpgEY.css +0 -1
@@ -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");