cohorte 1.5.0 → 2.0.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.
- package/CHANGELOG.md +169 -3
- package/README.md +65 -57
- package/bin/cli.js +31 -15
- package/core/agents/implementer.template.md +3 -3
- package/core/agents/release.md +1 -1
- package/core/agents/review.md +25 -2
- package/core/commands/{audit.md → cohorte-audit.md} +11 -3
- package/core/commands/{brainstorm.md → cohorte-brainstorm.md} +9 -3
- package/core/commands/{build.md → cohorte-build.md} +95 -10
- package/core/commands/{doctor.md → cohorte-doctor.md} +22 -9
- package/core/commands/{fix.md → cohorte-fix.md} +20 -13
- package/core/commands/{init-pipeline.md → cohorte-init-pipeline.md} +1 -1
- package/core/commands/cohorte-loop.md +110 -0
- package/core/commands/{refactor.md → cohorte-refactor.md} +3 -3
- package/core/commands/{review.md → cohorte-review.md} +70 -20
- package/core/commands/{ship.md → cohorte-ship.md} +5 -5
- package/core/commands/{spec.md → cohorte-spec.md} +32 -12
- package/core/commands/{update-pipeline.md → cohorte-update-pipeline.md} +16 -6
- package/core/hooks/gate.py +101 -6
- package/core/templates/brainstorm-return.md +4 -4
- package/core/templates/decisions.template.md +42 -0
- package/core/templates/design-brief.md +1 -1
- package/core/templates/spec.template.md +8 -6
- package/core/templates/steps/init-pipeline/01-detect-stack.md +1 -1
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +6 -6
- package/core/templates/steps/init-pipeline/03-draft-profile.md +1 -1
- package/core/templates/steps/init-pipeline/04-write-render.md +16 -12
- package/core/templates/steps/init-pipeline/05-report.md +5 -5
- package/core/workflows/audit.js +6 -6
- package/core/workflows/refactor.js +14 -14
- package/core/workflows/review.js +62 -20
- package/dashboard/README.md +2 -2
- package/dashboard/dist/assets/{index-dkO8UUVl.css → index-BZ_LQlEj.css} +1 -1
- package/dashboard/dist/assets/{index-8owBnqyv.js → index-P1I1JGtj.js} +11 -11
- package/dashboard/dist/index.html +2 -2
- package/dashboard/server/doctor.js +75 -18
- package/dashboard/server/index.js +5 -5
- package/dashboard/server/metrics.js +1 -1
- package/install.ps1 +31 -14
- package/install.sh +31 -14
- package/package.json +2 -2
- package/profile/PIPELINE.template.md +17 -16
- package/profile/SCHEMA.md +199 -48
- package/profile/cohorte.config.template.yaml +8 -8
- package/scripts/loop-detach.sh +153 -0
- package/scripts/loop.sh +202 -25
- package/scripts/metrics/collect.mjs +17 -8
- package/scripts/new-feature.sh.template +3 -3
- package/scripts/preflight.sh +40 -4
- package/scripts/remove-feature.sh.template +2 -2
- package/scripts/test-dashboard.mjs +34 -7
- package/scripts/test-gate.mjs +58 -0
- package/scripts/test-loop.mjs +269 -0
- package/scripts/test-metrics.mjs +23 -11
- package/scripts/test-workflows.mjs +33 -5
- package/scripts/validate-core.mjs +46 -9
- package/core/commands/loop.md +0 -61
- /package/core/commands/{align-ds.md → cohorte-align-ds.md} +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
#
|
|
3
3
|
# remove-feature.sh — tear down a feature's isolated worktree. Rendered from a
|
|
4
|
-
# template by /init-pipeline (double-underscore tokens from PIPELINE.md §isolation —
|
|
5
|
-
# spelled out here so /doctor's unrendered-token check never trips on this comment).
|
|
4
|
+
# template by /cohorte-init-pipeline (double-underscore tokens from PIPELINE.md §isolation —
|
|
5
|
+
# spelled out here so /cohorte-doctor's unrendered-token check never trips on this comment).
|
|
6
6
|
#
|
|
7
7
|
# Usage: scripts/remove-feature.sh <feature_id> [--drop-db]
|
|
8
8
|
#
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Tests for the dashboard's server modules (dashboard/server/*.js).
|
|
3
3
|
//
|
|
4
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
|
|
5
|
+
// a hand-rolled YAML parser that every /cohorte-doctor check is derived from, a metrics
|
|
6
|
+
// aggregator, the JS port of /cohorte-doctor, an Obsidian board parser, the fleet
|
|
7
7
|
// registry, and an HTTP layer whose guards are the dashboard's only defence
|
|
8
8
|
// against a web page driving the local agent.
|
|
9
9
|
//
|
|
@@ -127,7 +127,7 @@ console.log("usage.js — the collector bridge");
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
// ── doctor.js ────────────────────────────────────────────────────────────────
|
|
130
|
-
console.log("doctor.js — the /doctor port");
|
|
130
|
+
console.log("doctor.js — the /cohorte-doctor port");
|
|
131
131
|
{
|
|
132
132
|
const spec = (fm) => `---\n${fm}\n---\n\n# x\n`;
|
|
133
133
|
const d = scratch();
|
|
@@ -136,13 +136,13 @@ console.log("doctor.js — the /doctor port");
|
|
|
136
136
|
writeFileSync(join(d, "specs", "b.md"), spec("feature_id: b\nstatus: shipped # done"));
|
|
137
137
|
writeFileSync(join(d, "specs", "c.md"), "no front-matter at all");
|
|
138
138
|
writeFileSync(join(d, "specs", "_template.md"), spec("status: draft"));
|
|
139
|
-
// /audit writes this file by design and it has no front-matter. Scanning it as a
|
|
140
|
-
// spec made /doctor warn about a file cohorte itself had just created — it fired in
|
|
141
|
-
// every project that had ever run /audit.
|
|
139
|
+
// /cohorte-audit writes this file by design and it has no front-matter. Scanning it as a
|
|
140
|
+
// spec made /cohorte-doctor warn about a file cohorte itself had just created — it fired in
|
|
141
|
+
// every project that had ever run /cohorte-audit.
|
|
142
142
|
writeFileSync(join(d, "specs", "refactor-backlog.md"), "# Refactor Backlog\n\n## backend\n- [ ] x\n");
|
|
143
143
|
const specs = scanSpecs(d);
|
|
144
144
|
eq("_template.md is excluded", specs.length, 3);
|
|
145
|
-
eq("the /audit backlog is not scanned as a spec",
|
|
145
|
+
eq("the /cohorte-audit backlog is not scanned as a spec",
|
|
146
146
|
specs.some(s => s.file === "refactor-backlog.md"), false);
|
|
147
147
|
eq("front-matter fields are read", specs.find(s => s.id === "a").title, "A");
|
|
148
148
|
eq("a trailing comment is stripped from status", specs.find(s => s.id === "b").status, "shipped");
|
|
@@ -201,6 +201,21 @@ console.log("doctor.js — the /doctor port");
|
|
|
201
201
|
eq("retrieval wired in .mcp.json ⇒ ok", by(s.checks, "retrieval").status, "ok");
|
|
202
202
|
eq("workflows + profile-reader ⇒ ok", by(s.checks, "workflows").status, "ok");
|
|
203
203
|
|
|
204
|
+
// Local artifacts: a versioned preflight stamp is what made the phase gate ask on
|
|
205
|
+
// every review dispatch forever, so its absence from .gitignore is a hard failure.
|
|
206
|
+
check("no .gitignore ⇒ local artifacts flagged bad (the stamp is the breaking one)",
|
|
207
|
+
by(s.checks, "artifacts").status === "bad"
|
|
208
|
+
&& /preflight\.ok/.test(by(s.checks, "artifacts").detail),
|
|
209
|
+
by(s.checks, "artifacts").detail);
|
|
210
|
+
writeFileSync(join(d, ".gitignore"),
|
|
211
|
+
"node_modules/\n.claude/preflight.ok\n.claude/pipeline-metrics.jsonl\nspecs/reports/\n");
|
|
212
|
+
s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
|
|
213
|
+
eq("all local artifacts gitignored ⇒ ok", by(s.checks, "artifacts").status, "ok");
|
|
214
|
+
writeFileSync(join(d, ".gitignore"), "node_modules/\n.claude/\nspecs/reports/\n");
|
|
215
|
+
s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
|
|
216
|
+
eq("a `.claude/` directory rule covers the files inside it",
|
|
217
|
+
by(s.checks, "artifacts").status, "ok");
|
|
218
|
+
|
|
204
219
|
// …and each check must actually FAIL when its precondition breaks.
|
|
205
220
|
writeFileSync(join(d, ".claude", "gate-config.json"),
|
|
206
221
|
JSON.stringify({ ...gate, preflight: { enabled: false } }));
|
|
@@ -368,6 +383,18 @@ console.log("index.js — HTTP guards");
|
|
|
368
383
|
const badCmd = await post({ action: "claude", command: "/evil", project: proj });
|
|
369
384
|
eq("a non-whitelisted slash command is rejected", badCmd.status, 400);
|
|
370
385
|
|
|
386
|
+
// Both directions, because testing only the rejection missed a real bug: 2.0.0 prefixed
|
|
387
|
+
// every command, the error message was updated to say `/cohorte-audit`, but the allowlist
|
|
388
|
+
// regex still matched the bare names — so the server accepted the one command that no
|
|
389
|
+
// longer exists and rejected the only one the UI can send. A rejection-only test is blind
|
|
390
|
+
// to an allowlist that drifts away from the client.
|
|
391
|
+
const staleCmd = await post({ action: "claude", command: "/audit", project: proj });
|
|
392
|
+
eq("the pre-2.0.0 unprefixed command is rejected", staleCmd.status, 400);
|
|
393
|
+
|
|
394
|
+
const goodCmd = await post({ action: "claude", command: "/cohorte-audit", project: proj });
|
|
395
|
+
check("a prefixed whitelisted command passes the allowlist",
|
|
396
|
+
goodCmd.status !== 400 || !/unsupported command/.test((await goodCmd.json()).error || ""));
|
|
397
|
+
|
|
371
398
|
eq("a missing hashed asset 404s (never index.html)",
|
|
372
399
|
(await fetch(`${base}/assets/index-DEADBEEF.js`)).status, 404);
|
|
373
400
|
eq("a malformed percent-escape is a 400, not a 500",
|
package/scripts/test-gate.mjs
CHANGED
|
@@ -231,6 +231,64 @@ console.log("gate.py — preflight phase gate");
|
|
|
231
231
|
run(task("review"), { projectDir: noblock }).decision === null);
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
// ── the content digest (2.0.0): freshness keyed on code, not on HEAD ─────────
|
|
235
|
+
// Before this, the stamp recorded the HEAD sha — backwards on both sides. The
|
|
236
|
+
// reviewed tree is normally DIRTY, so committing already-verified code made the
|
|
237
|
+
// gate ask on a clean tree (and a committed stamp made it ask forever), while an
|
|
238
|
+
// implementer's edit between preflight and dispatch invalidated nothing.
|
|
239
|
+
console.log("gate.py — preflight content digest");
|
|
240
|
+
{
|
|
241
|
+
const pf = { enabled: true, agents: ["review"], max_age_minutes: 30 };
|
|
242
|
+
const d = scratch(); writeConfig(d, { ...GATE_CFG, preflight: pf });
|
|
243
|
+
gitRepo(d, "main");
|
|
244
|
+
mkdirSync(join(d, "specs", "reports"), { recursive: true });
|
|
245
|
+
writeFileSync(join(d, "specs", "s.md"), "spec\n");
|
|
246
|
+
writeFileSync(join(d, "src.txt"), "code v1\n"); // uncommitted feature work
|
|
247
|
+
const git = (...a) => execFileSync("git", a, { cwd: d, stdio: "ignore" });
|
|
248
|
+
const at = { projectDir: d };
|
|
249
|
+
const runPreflight = () =>
|
|
250
|
+
spawnSync("sh", [join(root, "scripts", "preflight.sh"), join(d, "specs", "reports", "r.txt"), "true"],
|
|
251
|
+
{ cwd: d, encoding: "utf8" });
|
|
252
|
+
|
|
253
|
+
const pre = runPreflight();
|
|
254
|
+
const raw = execFileSync("cat", [join(d, ".claude", "preflight.ok")], { encoding: "utf8" }).trim();
|
|
255
|
+
check("preflight.sh stamps three fields (epoch, sha, digest)",
|
|
256
|
+
raw.split(/\s+/).length === 3, `${pre.status}: ${raw}`);
|
|
257
|
+
check("fresh stamp on a dirty tree ⇒ passes", run(task("review"), at).decision === null);
|
|
258
|
+
|
|
259
|
+
// The regression that started this: commit the very code the preflight verified.
|
|
260
|
+
git("add", "-A"); git("commit", "-qm", "wip");
|
|
261
|
+
const afterCommit = run(task("review"), at);
|
|
262
|
+
check("committing the verified code ⇒ still passes (HEAD moved, code did not)",
|
|
263
|
+
afterCommit.decision === null, `got ${afterCommit.decision} — ${afterCommit.reason}`);
|
|
264
|
+
|
|
265
|
+
// The pipeline's own writes must never invalidate its own stamp.
|
|
266
|
+
writeFileSync(join(d, "specs", "s.md"), "spec + DoD ticks\n");
|
|
267
|
+
writeFileSync(join(d, "specs", "reports", "r2.txt"), "report\n");
|
|
268
|
+
writeFileSync(join(d, ".claude", "pipeline-metrics.jsonl"), "{}\n");
|
|
269
|
+
check("spec ticks, report buffer and metrics writes ⇒ still passes",
|
|
270
|
+
run(task("review"), at).decision === null);
|
|
271
|
+
|
|
272
|
+
// …and a real edit must.
|
|
273
|
+
writeFileSync(join(d, "src.txt"), "code v2\n");
|
|
274
|
+
const edited = run(task("review"), at);
|
|
275
|
+
check("an uncommitted code edit ⇒ ask", edited.decision === "ask", edited.decision);
|
|
276
|
+
check("…and the reason says the code changed", /code changed/.test(edited.reason || ""));
|
|
277
|
+
|
|
278
|
+
// A brand-new untracked source file is a code change too (the sha never saw these).
|
|
279
|
+
writeFileSync(join(d, "src.txt"), "code v1\n");
|
|
280
|
+
writeFileSync(join(d, "extra.txt"), "new surface\n");
|
|
281
|
+
check("a new untracked source file ⇒ ask", run(task("review"), at).decision === "ask");
|
|
282
|
+
rmSync(join(d, "extra.txt"));
|
|
283
|
+
check("reverting to the verified content ⇒ passes again",
|
|
284
|
+
run(task("review"), at).decision === null);
|
|
285
|
+
|
|
286
|
+
// The hook must never touch the caller's index — it computes in a throwaway one.
|
|
287
|
+
const status = execFileSync("git", ["status", "--porcelain"], { cwd: d, encoding: "utf8" });
|
|
288
|
+
check("the gate leaves the real index untouched (nothing staged)",
|
|
289
|
+
!/^[MARCD]/m.test(status), status.trim());
|
|
290
|
+
}
|
|
291
|
+
|
|
234
292
|
// ── worktree awareness (the 1.3.3 known_heads fix) ───────────────────────────
|
|
235
293
|
console.log("gate.py — worktree awareness");
|
|
236
294
|
{
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Behavioural tests for scripts/loop.sh — the autonomous /cohorte-review ⇄ /cohorte-fix driver.
|
|
3
|
+
//
|
|
4
|
+
// The driver is pure shell around two JSON files it does not write, so it is
|
|
5
|
+
// testable end-to-end by putting a FAKE `claude` on PATH that produces those files
|
|
6
|
+
// per phase. What is pinned here cannot be seen by any structural check:
|
|
7
|
+
//
|
|
8
|
+
// · exit 4 — /cohorte-build's readiness gate said NOT-READY, so no pass count helps
|
|
9
|
+
// · exit 0/3 leave the right TERMINAL status in the spec's front-matter, which is
|
|
10
|
+
// what makes an interrupted loop resumable (SCHEMA.md §Spec status)
|
|
11
|
+
// · the front-matter stamps are written with awk on every platform — a `sed -i`
|
|
12
|
+
// would pass on GNU and corrupt every spec on BSD/macOS
|
|
13
|
+
// · --resume continues at the recorded pass instead of re-paying passes 1..n-1
|
|
14
|
+
// · a spec with no front-matter still runs (the stamps are a silent no-op)
|
|
15
|
+
//
|
|
16
|
+
// node scripts/test-loop.mjs
|
|
17
|
+
|
|
18
|
+
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
|
|
19
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
25
|
+
const LOOP = join(root, "scripts/loop.sh");
|
|
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
|
+
|
|
33
|
+
const FM = `---
|
|
34
|
+
feature_id: feat-x
|
|
35
|
+
title: Feat X
|
|
36
|
+
status: frozen # draft → frozen → in-progress → in-review → shipped · blocked
|
|
37
|
+
branch: feature/feat-x
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
# Feat X
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
// A fake `claude`: reads the phase out of the `-p "/<cmd> <id>"` argument and
|
|
44
|
+
// writes whatever the scenario says that phase produces. `$PHASES` is a
|
|
45
|
+
// newline-separated script of `<cmd>:<what to write>` steps, consumed in order,
|
|
46
|
+
// so a scenario can make pass 1 and pass 2 differ.
|
|
47
|
+
const FAKE_CLAUDE = `#!/usr/bin/env bash
|
|
48
|
+
set -u
|
|
49
|
+
prompt=""
|
|
50
|
+
while [ $# -gt 0 ]; do
|
|
51
|
+
case "$1" in -p) prompt="$2"; shift 2 ;; *) shift ;; esac
|
|
52
|
+
done
|
|
53
|
+
cmd="\${prompt%% *}"
|
|
54
|
+
# The driver must dispatch the PREFIXED command (2.0.0) — an unprefixed /build would be
|
|
55
|
+
# shadowed by Claude Code's own built-in and never reach the pipeline, so fail loudly
|
|
56
|
+
# rather than let a regression pass by being lenient here.
|
|
57
|
+
case "$cmd" in
|
|
58
|
+
/cohorte-*) ;;
|
|
59
|
+
*) echo "fake claude: expected a /cohorte-* command, got '$cmd'" >&2; exit 9 ;;
|
|
60
|
+
esac
|
|
61
|
+
cmd="\${cmd#/cohorte-}" # scenarios are keyed on the PHASE, which stays unprefixed
|
|
62
|
+
n=0; [ -f "$SCEN_DIR/count" ] && n=$(cat "$SCEN_DIR/count")
|
|
63
|
+
n=$((n + 1)); echo "$n" >"$SCEN_DIR/count"
|
|
64
|
+
step=$(sed -n "\${n}p" "$SCEN_DIR/phases")
|
|
65
|
+
echo "fake claude: phase=$cmd step=$step"
|
|
66
|
+
want="\${step%%:*}"; do_what="\${step#*:}"
|
|
67
|
+
[ "$want" = "$cmd" ] || { echo "fake claude: expected /$want, got /$cmd" >&2; exit 9; }
|
|
68
|
+
mkdir -p specs/reports
|
|
69
|
+
case "$do_what" in
|
|
70
|
+
notready)
|
|
71
|
+
printf '{"id":"feat-x","phase":"readiness","ts":"t","verdict":"NOT-READY","gaps":["contract|POST /o|no success shape"],"surfaces":["backend"]}' \\
|
|
72
|
+
>specs/reports/feat-x.readiness.json ;;
|
|
73
|
+
ready)
|
|
74
|
+
printf '{"id":"feat-x","phase":"readiness","ts":"t","verdict":"READY","gaps":[],"surfaces":["backend"]}' \\
|
|
75
|
+
>specs/reports/feat-x.readiness.json ;;
|
|
76
|
+
clean)
|
|
77
|
+
printf '{"id":"feat-x","phase":"review","ts":"t","verdict":"SHIP","findings":2,"blocking":0,"deferred":2,"unreviewed":[],"fingerprint":""}' \\
|
|
78
|
+
>specs/reports/feat-x.verdict.json ;;
|
|
79
|
+
deadreviewer)
|
|
80
|
+
printf '{"id":"feat-x","phase":"review","ts":"t","verdict":"REVISE","findings":0,"blocking":0,"deferred":0,"unreviewed":["backend"],"fingerprint":""}' \\
|
|
81
|
+
>specs/reports/feat-x.verdict.json ;;
|
|
82
|
+
deadimplementer)
|
|
83
|
+
printf '{"id":"feat-x","phase":"readiness","ts":"t","verdict":"READY","gaps":[],"surfaces":["backend"]}' \\
|
|
84
|
+
>specs/reports/feat-x.readiness.json
|
|
85
|
+
printf '{"id":"feat-x","phase":"build","ts":"t","surfaces":{"backend":"dead","frontend":"ok"},"dead":["backend"]}' \\
|
|
86
|
+
>specs/reports/feat-x.build.json ;;
|
|
87
|
+
blocking)
|
|
88
|
+
printf '{"id":"feat-x","phase":"review","ts":"t","verdict":"REVISE","findings":3,"blocking":2,"deferred":1,"fingerprint":"aaaa1111bbbb2222"}' \\
|
|
89
|
+
>specs/reports/feat-x.verdict.json ;;
|
|
90
|
+
noop) : ;;
|
|
91
|
+
esac
|
|
92
|
+
exit 0
|
|
93
|
+
`;
|
|
94
|
+
|
|
95
|
+
// One scratch repo per scenario: a git checkout (loop.sh cds to its toplevel), a
|
|
96
|
+
// spec, the fake claude on PATH, and the phase script it plays out.
|
|
97
|
+
function scenario(phases, { frontmatter = FM } = {}) {
|
|
98
|
+
const dir = mkdtempSync(join(tmpdir(), "cohorte-loop-"));
|
|
99
|
+
const git = (...a) => execFileSync("git", ["-C", dir, ...a], { stdio: "ignore" });
|
|
100
|
+
git("init", "-q");
|
|
101
|
+
git("config", "user.email", "t@t.t");
|
|
102
|
+
git("config", "user.name", "t");
|
|
103
|
+
mkdirSync(join(dir, "specs/reports"), { recursive: true });
|
|
104
|
+
writeFileSync(join(dir, "specs/feat-x.md"), frontmatter);
|
|
105
|
+
git("add", "-A");
|
|
106
|
+
git("commit", "-qm", "init");
|
|
107
|
+
|
|
108
|
+
const bin = join(dir, "bin");
|
|
109
|
+
mkdirSync(bin);
|
|
110
|
+
writeFileSync(join(dir, "phases"), phases.join("\n") + "\n");
|
|
111
|
+
writeFileSync(join(bin, "claude"), FAKE_CLAUDE);
|
|
112
|
+
chmodSync(join(bin, "claude"), 0o755);
|
|
113
|
+
return { dir, bin };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runLoop({ dir, bin }, args) {
|
|
117
|
+
const r = spawnSync("bash", [LOOP, ...args], {
|
|
118
|
+
cwd: dir,
|
|
119
|
+
encoding: "utf8",
|
|
120
|
+
env: {
|
|
121
|
+
...process.env,
|
|
122
|
+
PATH: `${bin}:${process.env.PATH}`,
|
|
123
|
+
SCEN_DIR: dir,
|
|
124
|
+
CLAUDE_FLAGS: "--permission-mode acceptEdits",
|
|
125
|
+
GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t.t",
|
|
126
|
+
GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t.t",
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
const spec = readFileSync(join(dir, "specs/feat-x.md"), "utf8");
|
|
130
|
+
const fm = k => {
|
|
131
|
+
const m = spec.match(new RegExp(`^${k}:\\s*([^#\\n]*)`, "m"));
|
|
132
|
+
return m ? m[1].trim() : null;
|
|
133
|
+
};
|
|
134
|
+
return { code: r.status, out: `${r.stdout}${r.stderr}`, spec, fm };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log("loop.sh — readiness gate");
|
|
138
|
+
{
|
|
139
|
+
const s = scenario(["build:notready"]);
|
|
140
|
+
const r = runLoop(s, ["feat-x"]);
|
|
141
|
+
check("NOT-READY ⇒ exit 4, not 2", r.code === 4, `got ${r.code}: ${r.out.trim().split("\n").pop()}`);
|
|
142
|
+
check("NOT-READY ⇒ says the spec is not implementable",
|
|
143
|
+
/not implementable/i.test(r.out), r.out.trim().split("\n").pop());
|
|
144
|
+
check("NOT-READY ⇒ points at /cohorte-spec", /\/cohorte-spec feat-x/.test(r.out));
|
|
145
|
+
check("NOT-READY ⇒ spec left blocked", r.fm("status") === "blocked", r.fm("status"));
|
|
146
|
+
check("NOT-READY ⇒ no review ran (the gate is the point)", !/phase=review/.test(r.out));
|
|
147
|
+
check("NOT-READY ⇒ the build stamp is NOT written",
|
|
148
|
+
!existsSync(join(s.dir, "specs/reports/feat-x.built")));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log("loop.sh — clean run");
|
|
152
|
+
{
|
|
153
|
+
const s = scenario(["build:ready", "review:clean"]);
|
|
154
|
+
const r = runLoop(s, ["feat-x"]);
|
|
155
|
+
check("clean ⇒ exit 0", r.code === 0, `got ${r.code}: ${r.out}`);
|
|
156
|
+
check("clean ⇒ status in-review (ready to /cohorte-ship)", r.fm("status") === "in-review", r.fm("status"));
|
|
157
|
+
check("clean ⇒ loop state cleared", r.fm("loop_pass") === "0" && r.fm("loop_phase") === "done",
|
|
158
|
+
`${r.fm("loop_pass")}/${r.fm("loop_phase")}`);
|
|
159
|
+
check("clean ⇒ the deferred count is named, not dropped",
|
|
160
|
+
/2 deferred finding\(s\) parked/.test(r.out), r.out.trim().split("\n").pop());
|
|
161
|
+
check("clean ⇒ the status comment survives the awk rewrite",
|
|
162
|
+
/^status: in-review # draft/m.test(r.spec), r.spec.split("\n")[3]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log("loop.sh — a dead subagent is never a clean result");
|
|
166
|
+
{
|
|
167
|
+
// A dead implementer: /cohorte-build finishes fine having built one surface of two. Reviewing
|
|
168
|
+
// that would spend N reviewers auditing a half-built feature and report its holes as
|
|
169
|
+
// findings to fix — the wrong diagnosis at the wrong price.
|
|
170
|
+
const s = scenario(["build:deadimplementer"]);
|
|
171
|
+
const r = runLoop(s, ["feat-x"]);
|
|
172
|
+
check("dead implementer ⇒ exit 2, not a review pass", r.code === 2, `got ${r.code}: ${r.out}`);
|
|
173
|
+
check("dead implementer ⇒ no reviewer was spawned", !/phase=review/.test(r.out));
|
|
174
|
+
check("dead implementer ⇒ names the cause", /implementer died/.test(r.out),
|
|
175
|
+
r.out.trim().split("\n").pop());
|
|
176
|
+
check("dead implementer ⇒ spec left blocked", r.fm("status") === "blocked", r.fm("status"));
|
|
177
|
+
}
|
|
178
|
+
{
|
|
179
|
+
// THE dangerous one: blocking == 0 because the only reviewer that could have found
|
|
180
|
+
// something never answered. Exiting 0 here would report "clean" about unread code and
|
|
181
|
+
// send the human to /cohorte-ship.
|
|
182
|
+
const s = scenario(["build:ready", "review:deadreviewer"]);
|
|
183
|
+
const r = runLoop(s, ["feat-x"]);
|
|
184
|
+
check("dead reviewer + blocking 0 ⇒ NOT exit 0", r.code !== 0, `got ${r.code}: ${r.out}`);
|
|
185
|
+
check("dead reviewer ⇒ exit 2 (no usable verdict)", r.code === 2, `got ${r.code}`);
|
|
186
|
+
check("dead reviewer ⇒ names the unreviewed surface", /reviewer died/.test(r.out),
|
|
187
|
+
r.out.trim().split("\n").pop());
|
|
188
|
+
check("dead reviewer ⇒ never says clean", !/✓ clean/.test(r.out));
|
|
189
|
+
check("dead reviewer ⇒ spec is NOT left in-review", r.fm("status") === "blocked", r.fm("status"));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
console.log("loop.sh — non-convergent + resume");
|
|
193
|
+
{
|
|
194
|
+
const s = scenario(["build:ready", "review:blocking", "fix:noop", "review:blocking"]);
|
|
195
|
+
const r = runLoop(s, ["feat-x", "--max=4"]);
|
|
196
|
+
check("same fingerprint twice ⇒ exit 3", r.code === 3, `got ${r.code}: ${r.out}`);
|
|
197
|
+
check("non-convergent ⇒ status blocked", r.fm("status") === "blocked", r.fm("status"));
|
|
198
|
+
check("non-convergent ⇒ the pass it reached is recorded (resume anchor)",
|
|
199
|
+
r.fm("loop_pass") === "2", r.fm("loop_pass"));
|
|
200
|
+
check("non-convergent ⇒ the phase is recorded", r.fm("loop_phase") === "review", r.fm("loop_phase"));
|
|
201
|
+
|
|
202
|
+
// Resume: the recorded pass is where it picks up — passes 1..n-1 are not re-paid.
|
|
203
|
+
writeFileSync(join(s.dir, "count"), "0");
|
|
204
|
+
writeFileSync(join(s.dir, "phases"), "review:clean\n");
|
|
205
|
+
const r2 = runLoop(s, ["feat-x", "--max=4", "--resume"]);
|
|
206
|
+
check("--resume ⇒ announces the pass it continues from",
|
|
207
|
+
/resuming at review pass 2/.test(r2.out), r2.out.trim().split("\n")[0]);
|
|
208
|
+
check("--resume ⇒ skips the build (the stamp is there)", !/phase=build/.test(r2.out));
|
|
209
|
+
check("--resume ⇒ finishes clean from there", r2.code === 0, `got ${r2.code}: ${r2.out}`);
|
|
210
|
+
check("--resume ⇒ reports the resumed pass count, not 1",
|
|
211
|
+
/after 2 review pass\(es\)/.test(r2.out), r2.out.trim().split("\n").pop());
|
|
212
|
+
}
|
|
213
|
+
{
|
|
214
|
+
const s = scenario(["review:clean"]);
|
|
215
|
+
// A resume anchor past the ceiling is a usage error, not a silent restart at 1.
|
|
216
|
+
writeFileSync(join(s.dir, "specs/feat-x.md"), FM.replace("branch:", "loop_pass: 9\nbranch:"));
|
|
217
|
+
const r = runLoop(s, ["feat-x", "--max=3", "--resume"]);
|
|
218
|
+
check("--resume past --max ⇒ exit 64 with the reason", r.code === 64 && /raise --max/.test(r.out),
|
|
219
|
+
`${r.code}: ${r.out.trim()}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
console.log("loop.sh — a spec with no front-matter still runs");
|
|
223
|
+
{
|
|
224
|
+
const s = scenario(["build:ready", "review:clean"], { frontmatter: "# Feat X\n\nno front-matter\n" });
|
|
225
|
+
const r = runLoop(s, ["feat-x"]);
|
|
226
|
+
check("no front-matter ⇒ still exits 0 (stamps are a silent no-op)", r.code === 0,
|
|
227
|
+
`got ${r.code}: ${r.out}`);
|
|
228
|
+
check("no front-matter ⇒ the spec is left untouched",
|
|
229
|
+
r.spec === "# Feat X\n\nno front-matter\n", JSON.stringify(r.spec));
|
|
230
|
+
check("no front-matter ⇒ no stray temp file",
|
|
231
|
+
!existsSync(join(s.dir, "specs/feat-x.md.loop.tmp")));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── the sleep inhibitor must never be able to fail the run ───────────────────
|
|
235
|
+
// loop.sh re-execs itself under caffeinate/systemd-inhibit to hold a power assertion.
|
|
236
|
+
// `exec` replaces the shell, so an inhibitor that EXISTS but is refused makes its own
|
|
237
|
+
// failure the driver's exit code and the run never starts. CI found this the hard way:
|
|
238
|
+
// GitHub's Linux runners ship systemd-inhibit and answer "Failed to inhibit: Access
|
|
239
|
+
// denied", which turned all 24 loop tests red at once.
|
|
240
|
+
console.log("loop.sh — the sleep inhibitor is best-effort, never fatal");
|
|
241
|
+
{
|
|
242
|
+
const s = scenario(["build:ready", "review:clean"]);
|
|
243
|
+
// Both inhibitors present on PATH and both failing — the CI shape.
|
|
244
|
+
writeFileSync(join(s.bin, "systemd-inhibit"),
|
|
245
|
+
'#!/bin/sh\necho "Failed to inhibit: Access denied" >&2\nexit 1\n');
|
|
246
|
+
chmodSync(join(s.bin, "systemd-inhibit"), 0o755);
|
|
247
|
+
writeFileSync(join(s.bin, "caffeinate"), "#!/bin/sh\nexit 127\n");
|
|
248
|
+
chmodSync(join(s.bin, "caffeinate"), 0o755);
|
|
249
|
+
const r = runLoop(s, ["feat-x"]);
|
|
250
|
+
check("a refused inhibitor ⇒ the run still completes clean", r.code === 0,
|
|
251
|
+
`got ${r.code}: ${r.out.trim().split("\n").pop()}`);
|
|
252
|
+
check("a refused inhibitor ⇒ its error never reaches the driver's output",
|
|
253
|
+
!/Access denied/.test(r.out), r.out.trim().split("\n").pop());
|
|
254
|
+
|
|
255
|
+
// A WORKING inhibitor must still be used (or the probe would have disabled the feature).
|
|
256
|
+
const s2 = scenario(["build:ready", "review:clean"]);
|
|
257
|
+
writeFileSync(join(s2.bin, "systemd-inhibit"),
|
|
258
|
+
'#!/bin/sh\nwhile [ $# -gt 0 ]; do case "$1" in --*) shift ;; *) break ;; esac; done\n'
|
|
259
|
+
+ 'echo "INHIBIT-HELD" >&2\nexec "$@"\n');
|
|
260
|
+
chmodSync(join(s2.bin, "systemd-inhibit"), 0o755);
|
|
261
|
+
writeFileSync(join(s2.bin, "caffeinate"), "#!/bin/sh\nexit 127\n");
|
|
262
|
+
chmodSync(join(s2.bin, "caffeinate"), 0o755);
|
|
263
|
+
const r2 = runLoop(s2, ["feat-x"]);
|
|
264
|
+
check("a usable inhibitor is still exec'd (the probe didn't kill the feature)",
|
|
265
|
+
/INHIBIT-HELD/.test(r2.out) && r2.code === 0, `${r2.code}: ${r2.out.trim().split("\n").pop()}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (failures) { console.error(`\ntest-loop: ${failures} failure(s)`); process.exit(1); }
|
|
269
|
+
console.log("\ntest-loop: OK");
|
package/scripts/test-metrics.mjs
CHANGED
|
@@ -59,7 +59,7 @@ const usageOpus = {
|
|
|
59
59
|
};
|
|
60
60
|
|
|
61
61
|
const lines = [
|
|
62
|
-
user(0, '<command-message>build</command-message>\n<command-name>/build</command-name>'),
|
|
62
|
+
user(0, '<command-message>build</command-message>\n<command-name>/cohorte-build</command-name>'),
|
|
63
63
|
// Case 1: one response, three lines, identical usage on each. Only one should be billed.
|
|
64
64
|
assistant('m1', 5, 'claude-opus-5', usageOpus, [{ type: 'thinking', thinking: '...' }]),
|
|
65
65
|
assistant('m1', 5, 'claude-opus-5', usageOpus, [{ type: 'text', text: 'hello' }]),
|
|
@@ -74,26 +74,34 @@ const lines = [
|
|
|
74
74
|
assistant('m4', 605, 'claude-opus-5', { input_tokens: 0, output_tokens: 40 }),
|
|
75
75
|
// Case 6: a command named inside ordinary prose. The harness emits no <command-name>
|
|
76
76
|
// for this, but it is the way commands actually get invoked in practice.
|
|
77
|
-
user(1200, 'move on branding-ramp and /review'),
|
|
77
|
+
user(1200, 'move on branding-ramp and /cohorte-review'),
|
|
78
78
|
assistant('m5', 1205, 'claude-opus-5', { input_tokens: 0, output_tokens: 60 }),
|
|
79
|
-
// Case 7: a short steer continues the /review rather than opening an anonymous run.
|
|
79
|
+
// Case 7: a short steer continues the /cohorte-review rather than opening an anonymous run.
|
|
80
80
|
user(1260, 'continue'),
|
|
81
81
|
assistant('m6', 1265, 'claude-opus-5', { input_tokens: 0, output_tokens: 70 }),
|
|
82
82
|
// Case 8: a slash token that is not a command must not invent one.
|
|
83
83
|
user(1800, 'look at the /usr/local/share directory and report what you find there'),
|
|
84
84
|
assistant('m7', 1805, 'claude-opus-5', { input_tokens: 0, output_tokens: 10 }),
|
|
85
85
|
// Case 9: a long prompt that merely DISCUSSES a command is not an invocation of it.
|
|
86
|
-
// Without the length gate, writing about /review bills the conversation to /review —
|
|
86
|
+
// Without the length gate, writing about /cohorte-review bills the conversation to /cohorte-review —
|
|
87
87
|
// which is what happened in cohorte's own repo while the pipeline was being designed.
|
|
88
|
-
user(2400, 'I want to talk through how /review behaves when a surface has no findings at '
|
|
88
|
+
user(2400, 'I want to talk through how /cohorte-review behaves when a surface has no findings at '
|
|
89
89
|
+ 'all, because the verdict logic there is what produced the false green we saw last week '
|
|
90
90
|
+ 'and I am not convinced the fix covers the case where every reviewer dies at once.'),
|
|
91
91
|
assistant('m8', 2405, 'claude-opus-5', { input_tokens: 0, output_tokens: 20 }),
|
|
92
|
+
// Case 10: a RETIRED command name still attributes to itself. 2.0.0 prefixed every
|
|
93
|
+
// command, so months of existing transcripts say `/build` — and the collector reads its
|
|
94
|
+
// known names off the shipped core, where `build.md` no longer exists. Without the
|
|
95
|
+
// retired list every one of those runs silently reclassifies to (chat), rewriting spend
|
|
96
|
+
// history and inflating the catch-all. This is the largest instance of that bug class,
|
|
97
|
+
// so it gets pinned rather than trusted to a comment.
|
|
98
|
+
user(3000, '/build branding-ramp'),
|
|
99
|
+
assistant('m9', 3005, 'claude-opus-5', { input_tokens: 0, output_tokens: 90 }),
|
|
92
100
|
];
|
|
93
101
|
fs.writeFileSync(path.join(projectDir, `${SESSION}.jsonl`),
|
|
94
102
|
lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
|
|
95
103
|
|
|
96
|
-
// Case 3: subagent spend, linked back to /build by the Task tool_use id.
|
|
104
|
+
// Case 3: subagent spend, linked back to /cohorte-build by the Task tool_use id.
|
|
97
105
|
const agentDir = path.join(projectDir, SESSION, 'subagents');
|
|
98
106
|
fs.writeFileSync(path.join(agentDir, 'agent-a1.meta.json'),
|
|
99
107
|
JSON.stringify({ agentType: 'core', description: 'Build core surface', toolUseId: 'toolu_A', spawnDepth: 1 }));
|
|
@@ -109,13 +117,14 @@ if (run.status !== 0) {
|
|
|
109
117
|
process.exit(1);
|
|
110
118
|
}
|
|
111
119
|
const out = JSON.parse(run.stdout);
|
|
112
|
-
const build = out.commands.find((c) => c.command === '/build');
|
|
120
|
+
const build = out.commands.find((c) => c.command === '/cohorte-build');
|
|
113
121
|
const chat = out.commands.find((c) => c.command === '(chat)');
|
|
114
|
-
const
|
|
122
|
+
const retired = out.commands.find((c) => c.command === '/build');
|
|
123
|
+
const review = out.commands.find((c) => c.command === '/cohorte-review');
|
|
115
124
|
|
|
116
125
|
console.log('test-metrics');
|
|
117
|
-
check('the mid-command task-notification did not split the run', out.totals.runs,
|
|
118
|
-
check('/build is one run, not three', build.runs, 1);
|
|
126
|
+
check('the mid-command task-notification did not split the run', out.totals.runs, 6);
|
|
127
|
+
check('/cohorte-build is one run, not three', build.runs, 1);
|
|
119
128
|
check('duplicate lines of one response are billed once', build.tokens.output, 1000 + 500 + 2000);
|
|
120
129
|
check('the <synthetic> message contributed no tokens', build.tokens.output < 999999, true);
|
|
121
130
|
check('cache-write tokens are kept on their own tier', build.tokens.cacheWrite5m, 1000);
|
|
@@ -128,6 +137,9 @@ check('the continued turn counts toward the command it continued', review.tokens
|
|
|
128
137
|
check('a non-command slash token does not invent a command', chat.tokens.output, 40 + 10 + 20);
|
|
129
138
|
check('a long prompt that discusses a command is not counted as running it',
|
|
130
139
|
review.runs, 1);
|
|
140
|
+
check('a retired unprefixed command stays attributed to itself, not (chat)',
|
|
141
|
+
retired && retired.runs, 1);
|
|
142
|
+
check('…and keeps its own spend', retired && retired.tokens.output, 90);
|
|
131
143
|
|
|
132
144
|
// opus-5 $5 in / $25 out per MTok; 5m cache write 1.25x input, cache read 0.1x input.
|
|
133
145
|
// m1 100*5 + 1000*25 + 1000*6.25 + 10000*0.5 = 36750
|
|
@@ -136,7 +148,7 @@ check('a long prompt that discusses a command is not counted as running it',
|
|
|
136
148
|
check('cost sums the cache tiers at their own rates', Number(build.cost.total.toFixed(6)), 0.07925);
|
|
137
149
|
check('the unpriced list stays empty for known models', build.unpriced, []);
|
|
138
150
|
|
|
139
|
-
const detail = out.runs.find((r) => r.command === '/build');
|
|
151
|
+
const detail = out.runs.find((r) => r.command === '/cohorte-build');
|
|
140
152
|
check('per-run detail carries the subagent', detail.agents.map((a) => a.type), ['core']);
|
|
141
153
|
|
|
142
154
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
@@ -105,7 +105,7 @@ console.log("review.js");
|
|
|
105
105
|
]));
|
|
106
106
|
check("clean run ⇒ SHIP", result.verdict === "SHIP", `got ${result.verdict}`);
|
|
107
107
|
check("clean run ⇒ no unreviewed surfaces", (result.unreviewedSurfaces || []).length === 0);
|
|
108
|
-
check("clean run ⇒ next is /ship", String(result.next).startsWith("/ship"), result.next);
|
|
108
|
+
check("clean run ⇒ next is /cohorte-ship", String(result.next).startsWith("/cohorte-ship"), result.next);
|
|
109
109
|
}
|
|
110
110
|
{
|
|
111
111
|
// THE regression: every reviewer dies ⇒ zero findings ⇒ must NOT read as SHIP.
|
|
@@ -132,19 +132,47 @@ console.log("review.js");
|
|
|
132
132
|
}
|
|
133
133
|
{
|
|
134
134
|
// A SHIP carrying HIGH findings is a real verdict, but it is not "go ship it":
|
|
135
|
-
// the conversational /review routes any surviving HIGH to /fix.
|
|
135
|
+
// the conversational /cohorte-review routes any surviving HIGH to /cohorte-fix.
|
|
136
136
|
const { result } = await run("review.js", replier([
|
|
137
137
|
["review:", { verdict: "SHIP", findings: [finding()] }], ...BASE_REVIEW,
|
|
138
138
|
]));
|
|
139
139
|
check("SHIP + HIGH findings ⇒ verdict still SHIP", result.verdict === "SHIP");
|
|
140
|
-
check("SHIP + HIGH findings ⇒ next routes to /fix, not /ship",
|
|
141
|
-
String(result.next).startsWith("/fix"), result.next);
|
|
140
|
+
check("SHIP + HIGH findings ⇒ next routes to /cohorte-fix, not /cohorte-ship",
|
|
141
|
+
String(result.next).startsWith("/cohorte-fix"), result.next);
|
|
142
142
|
}
|
|
143
143
|
{
|
|
144
144
|
const { result } = await run("review.js", replier([
|
|
145
145
|
["review:", { verdict: "SHIP", findings: [finding({ severity: "LOW" })] }], ...BASE_REVIEW,
|
|
146
146
|
]));
|
|
147
|
-
check("SHIP + only LOW ⇒ next is /ship", String(result.next).startsWith("/ship"), result.next);
|
|
147
|
+
check("SHIP + only LOW ⇒ next is /cohorte-ship", String(result.next).startsWith("/cohorte-ship"), result.next);
|
|
148
|
+
}
|
|
149
|
+
{
|
|
150
|
+
// Deferred findings are real but out of the feature's scope: they must be
|
|
151
|
+
// counted and routed to the backlog, yet move NEITHER the verdict nor `clean`.
|
|
152
|
+
// Both halves matter — a deferred item that blocks costs a fix loop it was
|
|
153
|
+
// deferred out of, and one that is dropped is the leak the section exists to close.
|
|
154
|
+
const deferred = [{
|
|
155
|
+
severity: "HIGH", file: "apps/api/legacy.ts", line: 9, kind: "quality",
|
|
156
|
+
problem: "p", fix: "f", outOfScope: "predates this feature; diff never touched it",
|
|
157
|
+
}];
|
|
158
|
+
let stagePrompt = "";
|
|
159
|
+
const { result } = await run("review.js", (prompt, opts) => {
|
|
160
|
+
const label = opts.label || "";
|
|
161
|
+
if (label.startsWith("review:")) return { verdict: "SHIP", findings: [], deferred };
|
|
162
|
+
if (label === "stage-report") { stagePrompt = prompt; return "done"; }
|
|
163
|
+
return replier(BASE_REVIEW)(prompt, opts);
|
|
164
|
+
});
|
|
165
|
+
check("deferred-only ⇒ verdict still SHIP", result.verdict === "SHIP", `got ${result.verdict}`);
|
|
166
|
+
check("deferred-only ⇒ next is /cohorte-ship (not a fix loop)",
|
|
167
|
+
String(result.next).startsWith("/cohorte-ship"), result.next);
|
|
168
|
+
check("deferred are counted (both surfaces)", result.deferred === 2, `got ${result.deferred}`);
|
|
169
|
+
check("deferred stay out of the severity counts",
|
|
170
|
+
Object.values(result.counts).every(n => n === 0), JSON.stringify(result.counts));
|
|
171
|
+
check("deferred are routed to the refactor backlog",
|
|
172
|
+
/refactor-backlog\.md/.test(stagePrompt) && /deferred:feat-x/.test(stagePrompt),
|
|
173
|
+
stagePrompt.slice(0, 200));
|
|
174
|
+
check("deferred are never cross-checked (no verify agent spawned)",
|
|
175
|
+
!/verify:/.test(String(result.criticals)) && result.refutedByCrossCheck === 0);
|
|
148
176
|
}
|
|
149
177
|
{
|
|
150
178
|
const { result } = await run("review.js", replier([
|