cohorte 2.1.0 → 2.2.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 +120 -0
- package/README.md +41 -32
- package/bin/cli.js +316 -26
- package/core/adapter/render.js +389 -0
- package/core/agents/implementer.template.md +3 -3
- package/core/agents/release.md +1 -4
- package/core/agents/review.md +10 -2
- package/core/commands/cohorte-audit.md +2 -0
- package/core/commands/cohorte-brainstorm.md +3 -6
- package/core/commands/cohorte-build.md +14 -17
- package/core/commands/cohorte-doctor.md +59 -28
- package/core/commands/cohorte-fix.md +2 -3
- package/core/commands/cohorte-init-pipeline.md +7 -8
- package/core/commands/cohorte-refactor.md +5 -2
- package/core/commands/cohorte-review.md +20 -16
- package/core/commands/cohorte-ship.md +5 -5
- package/core/commands/cohorte-spec.md +3 -7
- package/core/commands/cohorte-update-pipeline.md +8 -8
- package/core/hooks/gate.py +203 -16
- package/core/runtimes/claude.json +73 -0
- package/core/runtimes/codex.json +82 -0
- package/core/runtimes/cursor.json +75 -0
- package/core/runtimes/gemini.json +75 -0
- package/core/runtimes/opencode.json +72 -0
- package/core/templates/spec.template.md +1 -3
- package/core/templates/steps/init-pipeline/01-detect-stack.md +1 -1
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +2 -2
- package/core/templates/steps/init-pipeline/04-write-render.md +23 -17
- package/core/templates/steps/init-pipeline/05-report.md +1 -1
- package/dashboard/dist/assets/{index-P1I1JGtj.js → index-D1rsbLat.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dashboard/server/doctor.js +156 -69
- package/dashboard/server/index.js +12 -2
- package/dashboard/server/metrics.js +13 -6
- package/dashboard/server/runtime.js +115 -0
- package/dashboard/server/versions.js +12 -1
- package/install.ps1 +23 -2
- package/install.sh +22 -4
- package/package.json +6 -2
- package/profile/PIPELINE.template.md +7 -6
- package/profile/SCHEMA.md +45 -48
- package/scripts/kanban-move.sh +11 -1
- package/scripts/metrics/collect.mjs +5 -3
- package/scripts/preflight.sh +27 -8
- package/scripts/telemetry-send.sh +10 -3
- package/scripts/test-adapter.mjs +368 -0
- package/scripts/test-dashboard.mjs +70 -0
- package/scripts/test-gate.mjs +62 -0
- package/scripts/validate-core.mjs +1 -1
- package/core/commands/cohorte-loop.md +0 -110
- package/scripts/loop-detach.sh +0 -153
- package/scripts/loop.sh +0 -399
- package/scripts/test-loop.mjs +0 -330
package/scripts/test-loop.mjs
DELETED
|
@@ -1,330 +0,0 @@
|
|
|
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
|
-
# Echoed so the tests can assert what the driver hands its children: an unattended child
|
|
63
|
-
# that cannot answer a permission prompt, and a background ceiling that must not fire.
|
|
64
|
-
echo "fake claude: bgceil=\${CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS:-unset}"
|
|
65
|
-
n=0; [ -f "$SCEN_DIR/count" ] && n=$(cat "$SCEN_DIR/count")
|
|
66
|
-
n=$((n + 1)); echo "$n" >"$SCEN_DIR/count"
|
|
67
|
-
step=$(sed -n "\${n}p" "$SCEN_DIR/phases")
|
|
68
|
-
echo "fake claude: phase=$cmd step=$step"
|
|
69
|
-
want="\${step%%:*}"; do_what="\${step#*:}"
|
|
70
|
-
[ "$want" = "$cmd" ] || { echo "fake claude: expected /$want, got /$cmd" >&2; exit 9; }
|
|
71
|
-
mkdir -p specs/reports
|
|
72
|
-
case "$do_what" in
|
|
73
|
-
notready)
|
|
74
|
-
printf '{"id":"feat-x","phase":"readiness","ts":"t","verdict":"NOT-READY","gaps":["contract|POST /o|no success shape"],"surfaces":["backend"]}' \\
|
|
75
|
-
>specs/reports/feat-x.readiness.json ;;
|
|
76
|
-
ready)
|
|
77
|
-
printf '{"id":"feat-x","phase":"readiness","ts":"t","verdict":"READY","gaps":[],"surfaces":["backend"]}' \\
|
|
78
|
-
>specs/reports/feat-x.readiness.json
|
|
79
|
-
printf '{"id":"feat-x","phase":"build","ts":"t","surfaces":{"backend":"ok"},"dead":[]}' \\
|
|
80
|
-
>specs/reports/feat-x.build.json ;;
|
|
81
|
-
# READY, dispatched, and then cut short before §3's report — the harness terminating
|
|
82
|
-
# background implementers, a teardown, a crash. No build.json, and exit 0 anyway.
|
|
83
|
-
cutshort)
|
|
84
|
-
printf '{"id":"feat-x","phase":"readiness","ts":"t","verdict":"READY","gaps":[],"surfaces":["backend","frontend"]}' \\
|
|
85
|
-
>specs/reports/feat-x.readiness.json ;;
|
|
86
|
-
clean)
|
|
87
|
-
printf '{"id":"feat-x","phase":"review","ts":"t","verdict":"SHIP","findings":2,"blocking":0,"deferred":2,"unreviewed":[],"fingerprint":""}' \\
|
|
88
|
-
>specs/reports/feat-x.verdict.json ;;
|
|
89
|
-
deadreviewer)
|
|
90
|
-
printf '{"id":"feat-x","phase":"review","ts":"t","verdict":"REVISE","findings":0,"blocking":0,"deferred":0,"unreviewed":["backend"],"fingerprint":""}' \\
|
|
91
|
-
>specs/reports/feat-x.verdict.json ;;
|
|
92
|
-
deadimplementer)
|
|
93
|
-
printf '{"id":"feat-x","phase":"readiness","ts":"t","verdict":"READY","gaps":[],"surfaces":["backend"]}' \\
|
|
94
|
-
>specs/reports/feat-x.readiness.json
|
|
95
|
-
printf '{"id":"feat-x","phase":"build","ts":"t","surfaces":{"backend":"dead","frontend":"ok"},"dead":["backend"]}' \\
|
|
96
|
-
>specs/reports/feat-x.build.json ;;
|
|
97
|
-
blocking)
|
|
98
|
-
printf '{"id":"feat-x","phase":"review","ts":"t","verdict":"REVISE","findings":3,"blocking":2,"deferred":1,"fingerprint":"aaaa1111bbbb2222"}' \\
|
|
99
|
-
>specs/reports/feat-x.verdict.json ;;
|
|
100
|
-
noop) : ;;
|
|
101
|
-
esac
|
|
102
|
-
exit 0
|
|
103
|
-
`;
|
|
104
|
-
|
|
105
|
-
// One scratch repo per scenario: a git checkout (loop.sh cds to its toplevel), a
|
|
106
|
-
// spec, the fake claude on PATH, and the phase script it plays out.
|
|
107
|
-
function scenario(phases, { frontmatter = FM } = {}) {
|
|
108
|
-
const dir = mkdtempSync(join(tmpdir(), "cohorte-loop-"));
|
|
109
|
-
const git = (...a) => execFileSync("git", ["-C", dir, ...a], { stdio: "ignore" });
|
|
110
|
-
git("init", "-q");
|
|
111
|
-
git("config", "user.email", "t@t.t");
|
|
112
|
-
git("config", "user.name", "t");
|
|
113
|
-
mkdirSync(join(dir, "specs/reports"), { recursive: true });
|
|
114
|
-
writeFileSync(join(dir, "specs/feat-x.md"), frontmatter);
|
|
115
|
-
git("add", "-A");
|
|
116
|
-
git("commit", "-qm", "init");
|
|
117
|
-
|
|
118
|
-
const bin = join(dir, "bin");
|
|
119
|
-
mkdirSync(bin);
|
|
120
|
-
writeFileSync(join(dir, "phases"), phases.join("\n") + "\n");
|
|
121
|
-
writeFileSync(join(bin, "claude"), FAKE_CLAUDE);
|
|
122
|
-
chmodSync(join(bin, "claude"), 0o755);
|
|
123
|
-
return { dir, bin };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function runLoop({ dir, bin }, args) {
|
|
127
|
-
const env = {
|
|
128
|
-
...process.env,
|
|
129
|
-
PATH: `${bin}:${process.env.PATH}`,
|
|
130
|
-
SCEN_DIR: dir,
|
|
131
|
-
GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t.t",
|
|
132
|
-
GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t.t",
|
|
133
|
-
};
|
|
134
|
-
// Never inherit these from whoever runs the suite: the default child flags and the
|
|
135
|
-
// background ceiling are exactly what the assertions below are about.
|
|
136
|
-
delete env.CLAUDE_FLAGS;
|
|
137
|
-
delete env.CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS;
|
|
138
|
-
const r = spawnSync("bash", [LOOP, ...args], { cwd: dir, encoding: "utf8", env });
|
|
139
|
-
const spec = readFileSync(join(dir, "specs/feat-x.md"), "utf8");
|
|
140
|
-
const fm = k => {
|
|
141
|
-
const m = spec.match(new RegExp(`^${k}:\\s*([^#\\n]*)`, "m"));
|
|
142
|
-
return m ? m[1].trim() : null;
|
|
143
|
-
};
|
|
144
|
-
const logPath = join(dir, "specs/reports/feat-x.loop.log");
|
|
145
|
-
const log = existsSync(logPath) ? readFileSync(logPath, "utf8") : "";
|
|
146
|
-
return { code: r.status, out: `${r.stdout}${r.stderr}`, spec, fm, log };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
console.log("loop.sh — readiness gate");
|
|
150
|
-
{
|
|
151
|
-
const s = scenario(["build:notready"]);
|
|
152
|
-
const r = runLoop(s, ["feat-x"]);
|
|
153
|
-
check("NOT-READY ⇒ exit 4, not 2", r.code === 4, `got ${r.code}: ${r.out.trim().split("\n").pop()}`);
|
|
154
|
-
check("NOT-READY ⇒ says the spec is not implementable",
|
|
155
|
-
/not implementable/i.test(r.out), r.out.trim().split("\n").pop());
|
|
156
|
-
check("NOT-READY ⇒ points at /cohorte-spec", /\/cohorte-spec feat-x/.test(r.out));
|
|
157
|
-
check("NOT-READY ⇒ spec left blocked", r.fm("status") === "blocked", r.fm("status"));
|
|
158
|
-
check("NOT-READY ⇒ no review ran (the gate is the point)", !/phase=review/.test(r.out));
|
|
159
|
-
check("NOT-READY ⇒ the build stamp is NOT written",
|
|
160
|
-
!existsSync(join(s.dir, "specs/reports/feat-x.built")));
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
console.log("loop.sh — clean run");
|
|
164
|
-
{
|
|
165
|
-
const s = scenario(["build:ready", "review:clean"]);
|
|
166
|
-
const r = runLoop(s, ["feat-x"]);
|
|
167
|
-
check("clean ⇒ exit 0", r.code === 0, `got ${r.code}: ${r.out}`);
|
|
168
|
-
check("clean ⇒ status in-review (ready to /cohorte-ship)", r.fm("status") === "in-review", r.fm("status"));
|
|
169
|
-
check("clean ⇒ loop state cleared", r.fm("loop_pass") === "0" && r.fm("loop_phase") === "done",
|
|
170
|
-
`${r.fm("loop_pass")}/${r.fm("loop_phase")}`);
|
|
171
|
-
check("clean ⇒ the deferred count is named, not dropped",
|
|
172
|
-
/2 deferred finding\(s\) parked/.test(r.out), r.out.trim().split("\n").pop());
|
|
173
|
-
check("clean ⇒ the status comment survives the awk rewrite",
|
|
174
|
-
/^status: in-review # draft/m.test(r.spec), r.spec.split("\n")[3]);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
console.log("loop.sh — a dead subagent is never a clean result");
|
|
178
|
-
{
|
|
179
|
-
// A dead implementer: /cohorte-build finishes fine having built one surface of two. Reviewing
|
|
180
|
-
// that would spend N reviewers auditing a half-built feature and report its holes as
|
|
181
|
-
// findings to fix — the wrong diagnosis at the wrong price.
|
|
182
|
-
const s = scenario(["build:deadimplementer"]);
|
|
183
|
-
const r = runLoop(s, ["feat-x"]);
|
|
184
|
-
check("dead implementer ⇒ exit 2, not a review pass", r.code === 2, `got ${r.code}: ${r.out}`);
|
|
185
|
-
check("dead implementer ⇒ no reviewer was spawned", !/phase=review/.test(r.out));
|
|
186
|
-
check("dead implementer ⇒ names the cause", /implementer died/.test(r.out),
|
|
187
|
-
r.out.trim().split("\n").pop());
|
|
188
|
-
check("dead implementer ⇒ spec left blocked", r.fm("status") === "blocked", r.fm("status"));
|
|
189
|
-
}
|
|
190
|
-
{
|
|
191
|
-
// THE dangerous one: blocking == 0 because the only reviewer that could have found
|
|
192
|
-
// something never answered. Exiting 0 here would report "clean" about unread code and
|
|
193
|
-
// send the human to /cohorte-ship.
|
|
194
|
-
const s = scenario(["build:ready", "review:deadreviewer"]);
|
|
195
|
-
const r = runLoop(s, ["feat-x"]);
|
|
196
|
-
check("dead reviewer + blocking 0 ⇒ NOT exit 0", r.code !== 0, `got ${r.code}: ${r.out}`);
|
|
197
|
-
check("dead reviewer ⇒ exit 2 (no usable verdict)", r.code === 2, `got ${r.code}`);
|
|
198
|
-
check("dead reviewer ⇒ names the unreviewed surface", /reviewer died/.test(r.out),
|
|
199
|
-
r.out.trim().split("\n").pop());
|
|
200
|
-
check("dead reviewer ⇒ never says clean", !/✓ clean/.test(r.out));
|
|
201
|
-
check("dead reviewer ⇒ spec is NOT left in-review", r.fm("status") === "blocked", r.fm("status"));
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
console.log("loop.sh — a build that never reported is never a built build");
|
|
205
|
-
{
|
|
206
|
-
// The absent-file twin of the dead implementer, and the one the `dead[]` grep cannot
|
|
207
|
-
// see: a phase cut short never reaches the step that writes build.json, so there is no
|
|
208
|
-
// file to read and no surface to name — while the child still exits 0. Scoring that as
|
|
209
|
-
// a clean build stamps `.built` over a half-written tree and sends reviewers at it.
|
|
210
|
-
const s = scenario(["build:cutshort"]);
|
|
211
|
-
const r = runLoop(s, ["feat-x"]);
|
|
212
|
-
check("no build.json ⇒ exit 2, not a review pass", r.code === 2, `got ${r.code}: ${r.out}`);
|
|
213
|
-
check("no build.json ⇒ no reviewer was spawned", !/phase=review/.test(r.out));
|
|
214
|
-
check("no build.json ⇒ names the cut-short phase", /wrote no .*build\.json/.test(r.out),
|
|
215
|
-
r.out.trim().split("\n").pop());
|
|
216
|
-
check("no build.json ⇒ the build stamp is NOT written (a re-run must rebuild)",
|
|
217
|
-
!existsSync(join(s.dir, "specs/reports/feat-x.built")));
|
|
218
|
-
check("no build.json ⇒ spec left blocked", r.fm("status") === "blocked", r.fm("status"));
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
console.log("loop.sh — what the children are handed");
|
|
222
|
-
{
|
|
223
|
-
// acceptEdits auto-approves Write/Edit and NOTHING else, so the first child Bash call no
|
|
224
|
-
// `allow` rule covers raises a prompt no `claude -p` can answer: the child stalls, asks
|
|
225
|
-
// the human in prose, and exits 0 — which the driver scores `ok`. Seen on a real run,
|
|
226
|
-
// where the review child hung on its own preflight.sh call. gate.py is built for the
|
|
227
|
-
// other mode: it escalates `ask` to a hard deny under bypassPermissions.
|
|
228
|
-
const s = scenario(["build:ready", "review:clean"]);
|
|
229
|
-
const r = runLoop(s, ["feat-x"]);
|
|
230
|
-
check("default child flags are bypassPermissions, not acceptEdits",
|
|
231
|
-
/# flags: --permission-mode bypassPermissions/.test(r.log) && !/acceptEdits/.test(r.log),
|
|
232
|
-
r.log.split("\n")[1]);
|
|
233
|
-
// Print mode TERMINATES still-running background tasks at its ceiling ("Background tasks
|
|
234
|
-
// still running after 600s"), which cuts a 25–40 min implementer batch off mid-write.
|
|
235
|
-
check("children inherit an unbounded background-task ceiling",
|
|
236
|
-
/fake claude: bgceil=0/.test(r.log), (r.log.match(/bgceil=\S*/) || ["absent"])[0]);
|
|
237
|
-
}
|
|
238
|
-
{
|
|
239
|
-
// The override is the escape hatch for a watched run — it must not have been hard-coded away.
|
|
240
|
-
const s = scenario(["review:clean"]);
|
|
241
|
-
const r = spawnSync("bash", [LOOP, "feat-x", "--no-build"], {
|
|
242
|
-
cwd: s.dir, encoding: "utf8",
|
|
243
|
-
env: { ...process.env, PATH: `${s.bin}:${process.env.PATH}`, SCEN_DIR: s.dir,
|
|
244
|
-
CLAUDE_FLAGS: "--permission-mode acceptEdits",
|
|
245
|
-
GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t.t",
|
|
246
|
-
GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t.t" },
|
|
247
|
-
});
|
|
248
|
-
const log = readFileSync(join(s.dir, "specs/reports/feat-x.loop.log"), "utf8");
|
|
249
|
-
check("CLAUDE_FLAGS overrides the default", /# flags: --permission-mode acceptEdits/.test(log),
|
|
250
|
-
`${r.status}: ${log.split("\n")[1]}`);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
console.log("loop.sh — non-convergent + resume");
|
|
254
|
-
{
|
|
255
|
-
const s = scenario(["build:ready", "review:blocking", "fix:noop", "review:blocking"]);
|
|
256
|
-
const r = runLoop(s, ["feat-x", "--max=4"]);
|
|
257
|
-
check("same fingerprint twice ⇒ exit 3", r.code === 3, `got ${r.code}: ${r.out}`);
|
|
258
|
-
check("non-convergent ⇒ status blocked", r.fm("status") === "blocked", r.fm("status"));
|
|
259
|
-
check("non-convergent ⇒ the pass it reached is recorded (resume anchor)",
|
|
260
|
-
r.fm("loop_pass") === "2", r.fm("loop_pass"));
|
|
261
|
-
check("non-convergent ⇒ the phase is recorded", r.fm("loop_phase") === "review", r.fm("loop_phase"));
|
|
262
|
-
|
|
263
|
-
// Resume: the recorded pass is where it picks up — passes 1..n-1 are not re-paid.
|
|
264
|
-
writeFileSync(join(s.dir, "count"), "0");
|
|
265
|
-
writeFileSync(join(s.dir, "phases"), "review:clean\n");
|
|
266
|
-
const r2 = runLoop(s, ["feat-x", "--max=4", "--resume"]);
|
|
267
|
-
check("--resume ⇒ announces the pass it continues from",
|
|
268
|
-
/resuming at review pass 2/.test(r2.out), r2.out.trim().split("\n")[0]);
|
|
269
|
-
check("--resume ⇒ skips the build (the stamp is there)", !/phase=build/.test(r2.out));
|
|
270
|
-
check("--resume ⇒ finishes clean from there", r2.code === 0, `got ${r2.code}: ${r2.out}`);
|
|
271
|
-
check("--resume ⇒ reports the resumed pass count, not 1",
|
|
272
|
-
/after 2 review pass\(es\)/.test(r2.out), r2.out.trim().split("\n").pop());
|
|
273
|
-
}
|
|
274
|
-
{
|
|
275
|
-
const s = scenario(["review:clean"]);
|
|
276
|
-
// A resume anchor past the ceiling is a usage error, not a silent restart at 1.
|
|
277
|
-
writeFileSync(join(s.dir, "specs/feat-x.md"), FM.replace("branch:", "loop_pass: 9\nbranch:"));
|
|
278
|
-
const r = runLoop(s, ["feat-x", "--max=3", "--resume"]);
|
|
279
|
-
check("--resume past --max ⇒ exit 64 with the reason", r.code === 64 && /raise --max/.test(r.out),
|
|
280
|
-
`${r.code}: ${r.out.trim()}`);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
console.log("loop.sh — a spec with no front-matter still runs");
|
|
284
|
-
{
|
|
285
|
-
const s = scenario(["build:ready", "review:clean"], { frontmatter: "# Feat X\n\nno front-matter\n" });
|
|
286
|
-
const r = runLoop(s, ["feat-x"]);
|
|
287
|
-
check("no front-matter ⇒ still exits 0 (stamps are a silent no-op)", r.code === 0,
|
|
288
|
-
`got ${r.code}: ${r.out}`);
|
|
289
|
-
check("no front-matter ⇒ the spec is left untouched",
|
|
290
|
-
r.spec === "# Feat X\n\nno front-matter\n", JSON.stringify(r.spec));
|
|
291
|
-
check("no front-matter ⇒ no stray temp file",
|
|
292
|
-
!existsSync(join(s.dir, "specs/feat-x.md.loop.tmp")));
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// ── the sleep inhibitor must never be able to fail the run ───────────────────
|
|
296
|
-
// loop.sh re-execs itself under caffeinate/systemd-inhibit to hold a power assertion.
|
|
297
|
-
// `exec` replaces the shell, so an inhibitor that EXISTS but is refused makes its own
|
|
298
|
-
// failure the driver's exit code and the run never starts. CI found this the hard way:
|
|
299
|
-
// GitHub's Linux runners ship systemd-inhibit and answer "Failed to inhibit: Access
|
|
300
|
-
// denied", which turned all 24 loop tests red at once.
|
|
301
|
-
console.log("loop.sh — the sleep inhibitor is best-effort, never fatal");
|
|
302
|
-
{
|
|
303
|
-
const s = scenario(["build:ready", "review:clean"]);
|
|
304
|
-
// Both inhibitors present on PATH and both failing — the CI shape.
|
|
305
|
-
writeFileSync(join(s.bin, "systemd-inhibit"),
|
|
306
|
-
'#!/bin/sh\necho "Failed to inhibit: Access denied" >&2\nexit 1\n');
|
|
307
|
-
chmodSync(join(s.bin, "systemd-inhibit"), 0o755);
|
|
308
|
-
writeFileSync(join(s.bin, "caffeinate"), "#!/bin/sh\nexit 127\n");
|
|
309
|
-
chmodSync(join(s.bin, "caffeinate"), 0o755);
|
|
310
|
-
const r = runLoop(s, ["feat-x"]);
|
|
311
|
-
check("a refused inhibitor ⇒ the run still completes clean", r.code === 0,
|
|
312
|
-
`got ${r.code}: ${r.out.trim().split("\n").pop()}`);
|
|
313
|
-
check("a refused inhibitor ⇒ its error never reaches the driver's output",
|
|
314
|
-
!/Access denied/.test(r.out), r.out.trim().split("\n").pop());
|
|
315
|
-
|
|
316
|
-
// A WORKING inhibitor must still be used (or the probe would have disabled the feature).
|
|
317
|
-
const s2 = scenario(["build:ready", "review:clean"]);
|
|
318
|
-
writeFileSync(join(s2.bin, "systemd-inhibit"),
|
|
319
|
-
'#!/bin/sh\nwhile [ $# -gt 0 ]; do case "$1" in --*) shift ;; *) break ;; esac; done\n'
|
|
320
|
-
+ 'echo "INHIBIT-HELD" >&2\nexec "$@"\n');
|
|
321
|
-
chmodSync(join(s2.bin, "systemd-inhibit"), 0o755);
|
|
322
|
-
writeFileSync(join(s2.bin, "caffeinate"), "#!/bin/sh\nexit 127\n");
|
|
323
|
-
chmodSync(join(s2.bin, "caffeinate"), 0o755);
|
|
324
|
-
const r2 = runLoop(s2, ["feat-x"]);
|
|
325
|
-
check("a usable inhibitor is still exec'd (the probe didn't kill the feature)",
|
|
326
|
-
/INHIBIT-HELD/.test(r2.out) && r2.code === 0, `${r2.code}: ${r2.out.trim().split("\n").pop()}`);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
if (failures) { console.error(`\ntest-loop: ${failures} failure(s)`); process.exit(1); }
|
|
330
|
-
console.log("\ntest-loop: OK");
|