cohorte 2.0.2 → 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 +148 -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 +7 -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 +44 -9
- 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 +7 -3
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +8 -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 +27 -6
- package/profile/SCHEMA.md +88 -49
- 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
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Behavioural tests for the runtime adapter — core/adapter/render.js + core/runtimes/*.json.
|
|
3
|
+
//
|
|
4
|
+
// The adapter is where a single set of source prompts becomes N runtime-specific ones. Its
|
|
5
|
+
// failure mode is silent and expensive: a dropped conditional ships a Claude-only instruction
|
|
6
|
+
// to a runtime that cannot follow it, a leaked marker turns doctrine into visible noise, and a
|
|
7
|
+
// wrong frontmatter key is read by the model as prose. None of that raises an error anywhere —
|
|
8
|
+
// it just makes the pipeline quietly wrong on four runtimes out of five.
|
|
9
|
+
//
|
|
10
|
+
// node scripts/test-adapter.mjs
|
|
11
|
+
|
|
12
|
+
import { readFileSync, readdirSync, mkdtempSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
|
|
19
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const adapter = require(join(root, "core", "adapter", "render.js"));
|
|
22
|
+
|
|
23
|
+
let failures = 0;
|
|
24
|
+
const check = (name, cond, detail = "") => {
|
|
25
|
+
if (cond) console.log(` ✓ ${name}`);
|
|
26
|
+
else { failures++; console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`); }
|
|
27
|
+
};
|
|
28
|
+
const group = (name) => console.log(name);
|
|
29
|
+
const throws = (fn) => { try { fn(); return false; } catch { return true; } };
|
|
30
|
+
|
|
31
|
+
const RUNTIMES = adapter.listRuntimes();
|
|
32
|
+
const tmps = [];
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------- registry ---
|
|
35
|
+
group("registry — every runtime declares what the renderer reads");
|
|
36
|
+
|
|
37
|
+
check("at least the five supported runtimes ship", RUNTIMES.length >= 5, RUNTIMES.join(","));
|
|
38
|
+
for (const id of RUNTIMES) {
|
|
39
|
+
const rt = adapter.loadRuntime(id);
|
|
40
|
+
const ok = rt.id === id
|
|
41
|
+
&& typeof rt.label === "string"
|
|
42
|
+
&& rt.scopes && rt.scopes.global && rt.scopes.project
|
|
43
|
+
&& rt.command && ["md", "toml", "skill"].includes(rt.command.format)
|
|
44
|
+
&& Array.isArray(rt.command.frontmatter)
|
|
45
|
+
&& rt.capabilities && typeof rt.capabilities.subagents === "boolean"
|
|
46
|
+
&& typeof rt.capabilities.hooks === "boolean"
|
|
47
|
+
&& typeof rt.capabilities.workflows === "boolean"
|
|
48
|
+
&& typeof rt.capabilities.tool_restriction === "boolean";
|
|
49
|
+
check(`${id}: complete and well-typed`, ok);
|
|
50
|
+
}
|
|
51
|
+
check("unknown runtime is an error, not a silent default",
|
|
52
|
+
throws(() => adapter.loadRuntime("nope")));
|
|
53
|
+
|
|
54
|
+
// A runtime that claims a capability it cannot back is the one mistake the whole design rests
|
|
55
|
+
// on: every `cohorte:if` branch trusts these booleans literally, and an over-claim ships the
|
|
56
|
+
// strict doctrine to a runtime that cannot enforce it. Pinned against the vendor docs — see
|
|
57
|
+
// each runtime's `docs` field; revisit these three lines whenever one of them is re-read.
|
|
58
|
+
const withCap = (c) => RUNTIMES.filter((id) => adapter.loadRuntime(id).capabilities[c]).sort().join();
|
|
59
|
+
check("hooks claimed everywhere but OpenCode (plugins are not a blocking hook)",
|
|
60
|
+
withCap("hooks") === "claude,codex,cursor,gemini", withCap("hooks"));
|
|
61
|
+
check("only Claude Code claims workflows", withCap("workflows") === "claude");
|
|
62
|
+
// Not a capability to branch on: a HARD requirement. The pipeline's isolation guarantee is the
|
|
63
|
+
// subagent boundary, so a runtime without them cannot be supported — and must be refused loudly
|
|
64
|
+
// rather than rendered into a pipeline whose central promise is silently absent.
|
|
65
|
+
check("every target runtime has real subagents",
|
|
66
|
+
withCap("subagents") === RUNTIMES.slice().sort().join(), withCap("subagents"));
|
|
67
|
+
check("a runtime declaring no subagents is refused, not degraded",
|
|
68
|
+
throws(() => adapter.assertSupported({ id: "x", capabilities: { subagents: false } })));
|
|
69
|
+
check("…and one that has them passes the same guard",
|
|
70
|
+
!throws(() => adapter.assertSupported({ id: "x", capabilities: { subagents: true } })));
|
|
71
|
+
|
|
72
|
+
// A hook runtime must declare how to talk to it, and a no-ask runtime must be flagged: gate.py
|
|
73
|
+
// escalates ask→deny there, and getting this backwards silently lets a gated command run.
|
|
74
|
+
for (const id of RUNTIMES) {
|
|
75
|
+
const rt = adapter.loadRuntime(id);
|
|
76
|
+
if (!rt.capabilities.hooks) { check(`${id}: declares no hook contract`, !rt.hook); continue; }
|
|
77
|
+
check(`${id}: hook contract is complete`, !!rt.hook && !!rt.hook.event
|
|
78
|
+
&& ["claude", "cursor", "gemini"].includes(rt.hook.format)
|
|
79
|
+
&& typeof rt.hook.supports_ask === "boolean"
|
|
80
|
+
&& !!rt.scopes.project.hooks_config);
|
|
81
|
+
}
|
|
82
|
+
check("the ask tier is claimed only where the runtime honours it",
|
|
83
|
+
RUNTIMES.filter((id) => (adapter.loadRuntime(id).hook || {}).supports_ask).sort().join()
|
|
84
|
+
=== "claude,cursor");
|
|
85
|
+
|
|
86
|
+
// ------------------------------------------------------------ conditionals ---
|
|
87
|
+
group("conditionals — the branch that survives is the branch that is true");
|
|
88
|
+
|
|
89
|
+
// Synthetic capability sets, not real runtimes: the branch logic must stay correct however the
|
|
90
|
+
// vendors' feature matrix moves, and a unit test of the parser should not depend on which
|
|
91
|
+
// runtimes happen to ship today.
|
|
92
|
+
const rich = { id: "rich", capabilities: { subagents: true, hooks: true, workflows: true, tool_restriction: true } };
|
|
93
|
+
const bare = { id: "bare", capabilities: { subagents: false, hooks: false, workflows: false, tool_restriction: false } };
|
|
94
|
+
|
|
95
|
+
const basic = ["<!-- cohorte:if hooks -->", "H", "<!-- cohorte:else -->", "NOH", "<!-- cohorte:endif -->"].join("\n");
|
|
96
|
+
check("if/else keeps the taken branch", adapter.applyConditionals(basic, rich).trim() === "H");
|
|
97
|
+
check("if/else keeps the else branch", adapter.applyConditionals(basic, bare).trim() === "NOH");
|
|
98
|
+
|
|
99
|
+
const neg = ["<!-- cohorte:if !hooks -->", "ADVISORY", "<!-- cohorte:endif -->"].join("\n");
|
|
100
|
+
check("negation works", adapter.applyConditionals(neg, bare).trim() === "ADVISORY"
|
|
101
|
+
&& adapter.applyConditionals(neg, rich).trim() === "");
|
|
102
|
+
|
|
103
|
+
const byId = ["<!-- cohorte:if runtime:claude -->", "CC", "<!-- cohorte:endif -->"].join("\n");
|
|
104
|
+
check("runtime:<id> targets one runtime",
|
|
105
|
+
adapter.applyConditionals(byId, adapter.loadRuntime("claude")).trim() === "CC"
|
|
106
|
+
&& adapter.applyConditionals(byId, adapter.loadRuntime("cursor")).trim() === "");
|
|
107
|
+
|
|
108
|
+
const or = ["<!-- cohorte:if hooks workflows -->", "X", "<!-- cohorte:endif -->"].join("\n");
|
|
109
|
+
check("a multi-term condition is an OR", adapter.applyConditionals(or, rich).trim() === "X");
|
|
110
|
+
|
|
111
|
+
const nested = [
|
|
112
|
+
"<!-- cohorte:if subagents -->", "A",
|
|
113
|
+
"<!-- cohorte:if hooks -->", "B", "<!-- cohorte:else -->", "C", "<!-- cohorte:endif -->",
|
|
114
|
+
"<!-- cohorte:endif -->",
|
|
115
|
+
].join("\n");
|
|
116
|
+
check("nesting resolves inner branches inside a taken outer one",
|
|
117
|
+
adapter.applyConditionals(nested, rich).trim().split("\n").join() === "A,B");
|
|
118
|
+
check("a dropped outer branch drops its inner branches whole",
|
|
119
|
+
adapter.applyConditionals(nested, bare).trim() === "");
|
|
120
|
+
|
|
121
|
+
check("an unknown capability is an error, not a silently-false branch",
|
|
122
|
+
throws(() => adapter.applyConditionals("<!-- cohorte:if telepathy -->\nx\n<!-- cohorte:endif -->", rich)));
|
|
123
|
+
check("an unclosed if is an error", throws(() => adapter.applyConditionals("<!-- cohorte:if hooks -->\nx", rich)));
|
|
124
|
+
check("a stray endif is an error", throws(() => adapter.applyConditionals("<!-- cohorte:endif -->", rich)));
|
|
125
|
+
check("two elses in one if is an error", throws(() => adapter.applyConditionals(
|
|
126
|
+
["<!-- cohorte:if hooks -->", "<!-- cohorte:else -->", "<!-- cohorte:else -->", "<!-- cohorte:endif -->"].join("\n"), rich)));
|
|
127
|
+
|
|
128
|
+
// Every marker in the real source must be resolvable for EVERY runtime — an unknown term in
|
|
129
|
+
// a command nobody rendered yet would surface as an install-time crash for one runtime only.
|
|
130
|
+
group("source prompts — every marker resolves for every runtime");
|
|
131
|
+
const sources = [
|
|
132
|
+
...readdirSync(join(root, "core", "commands")).map((f) => ["commands", f]),
|
|
133
|
+
...readdirSync(join(root, "core", "agents")).map((f) => ["agents", f]),
|
|
134
|
+
].filter(([, f]) => f.endsWith(".md"));
|
|
135
|
+
for (const id of RUNTIMES) {
|
|
136
|
+
const rt = adapter.loadRuntime(id);
|
|
137
|
+
let bad = null;
|
|
138
|
+
for (const [dir, f] of sources) {
|
|
139
|
+
const src = readFileSync(join(root, "core", dir, f), "utf8");
|
|
140
|
+
try { adapter.applyConditionals(adapter.parseFrontmatter(src).body, rt); }
|
|
141
|
+
catch (e) { bad = `${dir}/${f}: ${e.message}`; break; }
|
|
142
|
+
}
|
|
143
|
+
check(`${id}: all ${sources.length} source files render`, !bad, bad || "");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ------------------------------------------------------------ full install ---
|
|
147
|
+
group("install — what each runtime actually gets on disk");
|
|
148
|
+
|
|
149
|
+
const home = mkdtempSync(join(tmpdir(), "cohorte-home-"));
|
|
150
|
+
const proj = mkdtempSync(join(tmpdir(), "cohorte-proj-"));
|
|
151
|
+
tmps.push(home, proj);
|
|
152
|
+
spawnSync("git", ["init", "-q", "."], { cwd: proj });
|
|
153
|
+
const run = spawnSync(process.execPath, [join(root, "bin", "cli.js"), "install",
|
|
154
|
+
`--runtime=${RUNTIMES.join(",")}`], {
|
|
155
|
+
cwd: proj, env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: "" }, encoding: "utf8",
|
|
156
|
+
});
|
|
157
|
+
check("the installer exits clean for every runtime at once", run.status === 0,
|
|
158
|
+
(run.stderr || "").slice(0, 400));
|
|
159
|
+
|
|
160
|
+
for (const id of RUNTIMES) {
|
|
161
|
+
const rt = adapter.loadRuntime(id);
|
|
162
|
+
// resolvePaths reads ~ from the real homedir, so re-point it at the sandbox.
|
|
163
|
+
const p = adapter.resolvePaths(rt, "project", proj);
|
|
164
|
+
const fix = (s) => s && s.replace(process.env.HOME, home);
|
|
165
|
+
const cmdDir = fix(p.commands);
|
|
166
|
+
const buildFile = join(cmdDir, `cohorte-build${rt.command.ext}`);
|
|
167
|
+
check(`${id}: commands landed in ${rt.scopes.project.commands}`, existsSync(buildFile));
|
|
168
|
+
if (!existsSync(buildFile)) continue;
|
|
169
|
+
const build = readFileSync(buildFile, "utf8");
|
|
170
|
+
|
|
171
|
+
check(`${id}: no unresolved marker leaked into the output`, !/cohorte:(if|else|endif)/.test(build));
|
|
172
|
+
check(`${id}: the runtime preamble is present`, build.includes(`**Runtime: ${rt.label}.**`));
|
|
173
|
+
|
|
174
|
+
// Parallel dispatch is the doctrine on every runtime now; the sequential-persona fallback was
|
|
175
|
+
// removed in 2.2.0 along with any suggestion that a lead can simulate the boundary by hand.
|
|
176
|
+
check(`${id}: dispatches surfaces in parallel`, build.includes("IN PARALLEL"));
|
|
177
|
+
check(`${id}: no trace of the removed persona fallback`,
|
|
178
|
+
!build.includes("ONE PERSONA AT A TIME") && !build.includes("adopt it verbatim"));
|
|
179
|
+
|
|
180
|
+
// A path the runtime does not have is a path the model will fail to read, silently.
|
|
181
|
+
if (id !== "claude") {
|
|
182
|
+
check(`${id}: no hardcoded .claude path left in the prose`,
|
|
183
|
+
!/(?<![\w/.-])~?\/?\.claude\//.test(build), (build.match(/.{0,60}\.claude\/.{0,40}/) || [""])[0]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The preamble must describe the gate the way it actually works here — a blocking hook that
|
|
187
|
+
// fires regardless, or an advisory check the agent has to call. Getting this backwards is the
|
|
188
|
+
// worst single error the adapter can make: it tells the model a safety property holds when it
|
|
189
|
+
// does not.
|
|
190
|
+
if (rt.capabilities.hooks) {
|
|
191
|
+
check(`${id}: the gate is described as a blocking ${rt.hook.event} hook`,
|
|
192
|
+
build.includes("is registered as a blocking") && build.includes(rt.hook.event));
|
|
193
|
+
check(`${id}: the missing confirmation tier is stated`,
|
|
194
|
+
build.includes("no confirmation tier") === !rt.hook.supports_ask);
|
|
195
|
+
} else {
|
|
196
|
+
check(`${id}: the gate is described as an explicit check`, build.includes("gate.py --check"));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Frontmatter the runtime does not understand is prose the model reads as instruction.
|
|
200
|
+
if (rt.command.format === "md" || rt.command.format === "skill") {
|
|
201
|
+
const fm = adapter.parseFrontmatter(build).keys.map(([k]) => k);
|
|
202
|
+
check(`${id}: only supported frontmatter keys survive`,
|
|
203
|
+
fm.every((k) => rt.command.frontmatter.includes(k)), fm.join(","));
|
|
204
|
+
if (rt.command.format === "skill") {
|
|
205
|
+
// A skill is matched on its frontmatter `name`, both for explicit invocation and for
|
|
206
|
+
// implicit selection. Without it the file installs and is simply never reachable.
|
|
207
|
+
check(`${id}: the skill carries the name it is invoked by`,
|
|
208
|
+
fm.includes("name") && /^name: cohorte-build$/m.test(build));
|
|
209
|
+
check(`${id}: skills are repo-scoped, so a clone gets the commands`,
|
|
210
|
+
rt.scopes.project.commands.startsWith(".agents/"));
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
check(`${id}: emitted as ${rt.command.format}, not markdown frontmatter`,
|
|
214
|
+
!build.startsWith("---\n") && /^description = "/m.test(build) && /^prompt = '''/m.test(build));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Placeholder substitution: a token the runtime never expands must be explained, not left
|
|
218
|
+
// to look like it works.
|
|
219
|
+
if (rt.command.args && rt.command.args !== "$ARGUMENTS") {
|
|
220
|
+
check(`${id}: $ARGUMENTS rewritten to ${rt.command.args}`,
|
|
221
|
+
build.includes(rt.command.args) && !build.includes("$ARGUMENTS"));
|
|
222
|
+
} else if (!rt.command.args) {
|
|
223
|
+
check(`${id}: the unsubstituted placeholder is explained in the preamble`,
|
|
224
|
+
build.includes("does not substitute placeholders"));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const excluded of rt.exclude_commands || []) {
|
|
228
|
+
check(`${id}: ${excluded} is not installed (it cannot run here)`,
|
|
229
|
+
!existsSync(join(cmdDir, `${excluded}${rt.command.ext}`)));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const agentsDir = fix(p.agents) || join(fix(p.core), "agents");
|
|
233
|
+
const reviewFile = join(agentsDir, `review${rt.agent.ext || ".md"}`);
|
|
234
|
+
check(`${id}: the review agent exists as ${rt.agent.format}`, existsSync(reviewFile));
|
|
235
|
+
if (existsSync(reviewFile)) {
|
|
236
|
+
const review = readFileSync(reviewFile, "utf8");
|
|
237
|
+
// The reviewer must never be able to fix what it reports. Where the runtime can enforce
|
|
238
|
+
// that, the rendered file must carry the restriction; where it cannot, the body must say
|
|
239
|
+
// so — a reviewer that silently gains write access destroys the fix loop's evidence.
|
|
240
|
+
if (rt.agent.readonly_key) {
|
|
241
|
+
check(`${id}: the reviewer is pinned read-only (${rt.agent.readonly_key})`,
|
|
242
|
+
review.includes(rt.agent.readonly_key) && review.includes(rt.agent.readonly_value));
|
|
243
|
+
} else if (rt.capabilities.tool_restriction) {
|
|
244
|
+
// Claude expresses it as the absence of write tools in the `tools:` list.
|
|
245
|
+
check(`${id}: the reviewer's tool list carries no write tool`,
|
|
246
|
+
/^tools:.*$/m.test(review) && !/^tools:.*(Write|Edit|Bash)/m.test(review));
|
|
247
|
+
} else {
|
|
248
|
+
check(`${id}: the reviewer is told read-only is on it`,
|
|
249
|
+
review.includes("read-only **by discipline**") || review.includes("read-only by discipline"));
|
|
250
|
+
}
|
|
251
|
+
// An Anthropic model alias in another vendor's agent file either errors or is ignored.
|
|
252
|
+
check(`${id}: no Anthropic model alias leaked into the agent file`,
|
|
253
|
+
id === "claude" || !/^\s*model\s*[:=]/m.test(review), (review.match(/^.*model.*$/m) || [""])[0]);
|
|
254
|
+
}
|
|
255
|
+
// Every non-Claude runtime shares one `.cohorte` core, so this registry must ACCUMULATE.
|
|
256
|
+
// A single-record file let each install erase the previous runtime's entry.
|
|
257
|
+
const rtJson = join(fix(p.core), "pipeline", "runtimes.json");
|
|
258
|
+
check(`${id}: survives in runtimes.json after the other installs`, existsSync(rtJson)
|
|
259
|
+
&& !!JSON.parse(readFileSync(rtJson, "utf8"))[id]);
|
|
260
|
+
check(`${id}: the gate script ships with the core`, existsSync(join(fix(p.core), "hooks", "gate.py")));
|
|
261
|
+
check(`${id}: workflows ship only where a workflow engine exists`,
|
|
262
|
+
existsSync(join(fix(p.core), "workflows")) === rt.capabilities.workflows);
|
|
263
|
+
|
|
264
|
+
// Templates are resolved in place at install time, so a shared core would let the LAST
|
|
265
|
+
// runtime installed decide what every other one reads. Each core is its own directory
|
|
266
|
+
// precisely to prevent that; assert the resolution actually matches this runtime.
|
|
267
|
+
const step = join(fix(p.core), "templates", "steps", "init-pipeline", "04-write-render.md");
|
|
268
|
+
if (existsSync(step)) {
|
|
269
|
+
const text = readFileSync(step, "utf8");
|
|
270
|
+
check(`${id}: templates carry no unresolved marker`, !/cohorte:(if|else|endif)/.test(text));
|
|
271
|
+
check(`${id}: the settings/hook step matches this runtime`,
|
|
272
|
+
text.includes("Write `.claude/settings.json`") === rt.capabilities.hooks);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The project state — gate config, preflight stamp, metrics — describes the repo, not the
|
|
277
|
+
// agent driving it, and must NOT fork per runtime.
|
|
278
|
+
group("state — one project, one gate config");
|
|
279
|
+
const stateDirs = new Set(RUNTIMES.map((id) => adapter.stateDir(adapter.loadRuntime(id))));
|
|
280
|
+
check("every non-Claude runtime shares one state dir", stateDirs.size === 2
|
|
281
|
+
&& stateDirs.has(".claude") && stateDirs.has(".cohorte"));
|
|
282
|
+
check("but each keeps its own rendered core",
|
|
283
|
+
new Set(RUNTIMES.map((id) => adapter.loadRuntime(id).scopes.project.core)).size === RUNTIMES.length);
|
|
284
|
+
|
|
285
|
+
// A config dir with a space in it is not exotic: a desktop host puts CLAUDE_CONFIG_DIR under
|
|
286
|
+
// `~/Library/Application Support/…`. An unquoted path there splits in the shell, python reports
|
|
287
|
+
// `can't open file '/Users/x/Library/Application'`, and EVERY tool call in the session fails —
|
|
288
|
+
// including the ones the human would need to undo it. Assert the registration is quoted, per
|
|
289
|
+
// runtime, and that the installer still recognises its own entry (or a re-install duplicates it).
|
|
290
|
+
group("hook registration — paths with spaces");
|
|
291
|
+
{
|
|
292
|
+
const spacedHome = mkdtempSync(join(tmpdir(), "cohorte home-"));
|
|
293
|
+
const spacedProj = mkdtempSync(join(tmpdir(), "cohorte proj-"));
|
|
294
|
+
tmps.push(spacedHome, spacedProj);
|
|
295
|
+
spawnSync("git", ["init", "-q", "."], { cwd: spacedProj });
|
|
296
|
+
const env = { ...process.env, HOME: spacedHome, CLAUDE_CONFIG_DIR: join(spacedHome, ".claude") };
|
|
297
|
+
const args = [join(root, "bin", "cli.js"), "install", `--runtime=${RUNTIMES.join(",")}`];
|
|
298
|
+
const first = spawnSync(process.execPath, args, { cwd: spacedProj, env, encoding: "utf8" });
|
|
299
|
+
check("installs into a path containing a space", first.status === 0,
|
|
300
|
+
(first.stderr || "").slice(0, 300));
|
|
301
|
+
// Claude registers its hook only on a GLOBAL install — project-scope settings.json is
|
|
302
|
+
// /cohorte-init-pipeline's job — so exercise that scope too.
|
|
303
|
+
const gargs = [join(root, "bin", "cli.js"), "install", "--global", "--runtime=claude"];
|
|
304
|
+
spawnSync(process.execPath, gargs, { cwd: spacedProj, env, encoding: "utf8" });
|
|
305
|
+
// Re-install BOTH: the reconcile must match its own quoted entry, or every run stacks another.
|
|
306
|
+
spawnSync(process.execPath, args, { cwd: spacedProj, env, encoding: "utf8" });
|
|
307
|
+
spawnSync(process.execPath, gargs, { cwd: spacedProj, env, encoding: "utf8" });
|
|
308
|
+
|
|
309
|
+
for (const id of RUNTIMES) {
|
|
310
|
+
const rt = adapter.loadRuntime(id);
|
|
311
|
+
if (!rt.capabilities.hooks) continue;
|
|
312
|
+
const cfgSpec = (id === "claude" ? rt.scopes.global : rt.scopes.project).hooks_config;
|
|
313
|
+
const cfgPath = cfgSpec.startsWith("~/")
|
|
314
|
+
? join(spacedHome, cfgSpec.slice(2)) : join(spacedProj, cfgSpec);
|
|
315
|
+
if (!existsSync(cfgPath)) { check(`${id}: hook config written`, false, cfgPath); continue; }
|
|
316
|
+
const hooks = JSON.parse(readFileSync(cfgPath, "utf8")).hooks || {};
|
|
317
|
+
const entries = hooks[rt.hook.event] || [];
|
|
318
|
+
const cmds = entries.flatMap(e => e.command ? [e.command] : (e.hooks || []).map(h => h.command));
|
|
319
|
+
const ours = cmds.filter(c => /gate\.py/.test(c));
|
|
320
|
+
check(`${id}: the gate path is quoted`, ours.length > 0 && ours.every(c => /"[^"]*gate\.py"/.test(c)),
|
|
321
|
+
ours.join(" | "));
|
|
322
|
+
check(`${id}: re-installing does not stack a second registration`, ours.length === 1,
|
|
323
|
+
`${ours.length} entries`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// CLAUDE_CONFIG_DIR moves Claude Code's whole tree — a desktop host points it at
|
|
328
|
+
// `~/Library/Application Support/…`. The registry declares those paths as `~/.claude`, and
|
|
329
|
+
// resolving them from the homedir instead of the override split the install in half: the core
|
|
330
|
+
// went to the REAL `~/.claude` while the hook was registered in the override. A scratch install
|
|
331
|
+
// therefore wrote into the user's actual global core, silently.
|
|
332
|
+
group("CLAUDE_CONFIG_DIR is honoured, not half-honoured");
|
|
333
|
+
{
|
|
334
|
+
const home = mkdtempSync(join(tmpdir(), "cohorte-home-"));
|
|
335
|
+
const cfg = mkdtempSync(join(tmpdir(), "cohorte-cfg-")); // deliberately NOT under home
|
|
336
|
+
const proj = mkdtempSync(join(tmpdir(), "cohorte-proj-"));
|
|
337
|
+
tmps.push(home, cfg, proj);
|
|
338
|
+
spawnSync("git", ["init", "-q", "."], { cwd: proj });
|
|
339
|
+
const r = spawnSync(process.execPath,
|
|
340
|
+
[join(root, "bin", "cli.js"), "install", "--global", "--runtime=claude"],
|
|
341
|
+
{ cwd: proj, env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: cfg }, encoding: "utf8" });
|
|
342
|
+
|
|
343
|
+
check("the global install succeeds under an overridden config dir", r.status === 0,
|
|
344
|
+
(r.stderr || "").slice(0, 300));
|
|
345
|
+
check("the core lands in the override", existsSync(join(cfg, "pipeline", "VERSION")));
|
|
346
|
+
check("the commands land in the override", existsSync(join(cfg, "commands", "cohorte-build.md")));
|
|
347
|
+
check("the hook is registered in the override", existsSync(join(cfg, "settings.json")));
|
|
348
|
+
check("nothing is written to the home default",
|
|
349
|
+
!existsSync(join(home, ".claude", "pipeline", "VERSION")));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Claude Code must not regress: it is the runtime everyone is already on.
|
|
353
|
+
group("no regression — the Claude install keeps its shape");
|
|
354
|
+
check("commands still in .claude/commands", existsSync(join(proj, ".claude", "commands", "cohorte-build.md")));
|
|
355
|
+
check("agents still in .claude/agents", existsSync(join(proj, ".claude", "agents", "review.md")));
|
|
356
|
+
check("the model pin survives the render",
|
|
357
|
+
/^model: sonnet$/m.test(readFileSync(join(proj, ".claude", "commands", "cohorte-build.md"), "utf8")));
|
|
358
|
+
check("the subagent name survives the render",
|
|
359
|
+
/^name: review$/m.test(readFileSync(join(proj, ".claude", "agents", "review.md"), "utf8")));
|
|
360
|
+
// 2.2.0 retired /cohorte-loop. Copy-over never deletes, so the scrub is the only thing standing
|
|
361
|
+
// between an upgrade and a decoy command the model can still fire — assert it on the layout that
|
|
362
|
+
// actually had one installed.
|
|
363
|
+
check("the retired /cohorte-loop is not installed",
|
|
364
|
+
!existsSync(join(proj, ".claude", "commands", "cohorte-loop.md")));
|
|
365
|
+
|
|
366
|
+
for (const d of tmps) rmSync(d, { recursive: true, force: true });
|
|
367
|
+
console.log(failures ? `\ntest-adapter: ${failures} FAILED` : "\ntest-adapter: OK");
|
|
368
|
+
process.exit(failures ? 1 : 0);
|
|
@@ -402,6 +402,76 @@ console.log("index.js — HTTP guards");
|
|
|
402
402
|
eq("an unknown API route 404s", (await fetch(`${base}/api/nope`)).status, 404);
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
+
// ── runtime.js + a non-Claude layout ────────────────────────────────────────
|
|
406
|
+
// Every path-dependent check used to assume `.claude/`. On a repo driven from Cursor that
|
|
407
|
+
// reported a healthy install as three ❌ and a ⚠️ — no core, no rendered agent, artifacts not
|
|
408
|
+
// ignored, hook not registered — and every one was wrong. A false red is worse than no check:
|
|
409
|
+
// it sends a human fixing something that is not broken.
|
|
410
|
+
console.log("doctor.js — a non-Claude runtime layout");
|
|
411
|
+
{
|
|
412
|
+
const d = scratch();
|
|
413
|
+
const g = join(d, "global-claude");
|
|
414
|
+
mkdirSync(g, { recursive: true });
|
|
415
|
+
|
|
416
|
+
const core = join(d, ".cohorte", "cursor");
|
|
417
|
+
mkdirSync(join(core, "pipeline"), { recursive: true });
|
|
418
|
+
writeFileSync(join(core, "pipeline", "VERSION"), "9.9.9\n");
|
|
419
|
+
writeFileSync(join(core, "pipeline", "runtimes.json"), JSON.stringify({
|
|
420
|
+
cursor: {
|
|
421
|
+
label: "Cursor", scope: "project", core_version: "9.9.9",
|
|
422
|
+
capabilities: { subagents: true, hooks: true, workflows: false, tool_restriction: true },
|
|
423
|
+
paths: {
|
|
424
|
+
core, commands: join(d, ".cursor", "commands"), agents: join(d, ".cursor", "agents"),
|
|
425
|
+
hooks_config: join(d, ".cursor", "hooks.json"), state: ".cohorte",
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
}));
|
|
429
|
+
mkdirSync(join(d, ".cursor", "agents"), { recursive: true });
|
|
430
|
+
writeFileSync(join(d, ".cursor", "agents", "api.md"), "---\nname: api\n---\n");
|
|
431
|
+
writeFileSync(join(d, ".cursor", "hooks.json"), JSON.stringify({
|
|
432
|
+
version: 1,
|
|
433
|
+
hooks: { beforeShellExecution: [{ command: `python3 ${core}/hooks/gate.py --runtime cursor` }] },
|
|
434
|
+
}));
|
|
435
|
+
mkdirSync(join(d, ".cohorte"), { recursive: true });
|
|
436
|
+
const gate = { deny: ["rm -rf /"], ask: [], ask_on_default_branch: [], default_branch: "main" };
|
|
437
|
+
writeFileSync(join(d, ".cohorte", "gate-config.json"),
|
|
438
|
+
JSON.stringify({ ...gate, preflight: { enabled: false } }));
|
|
439
|
+
writeFileSync(join(d, ".gitignore"),
|
|
440
|
+
".cohorte/preflight.ok\n.cohorte/pipeline-metrics.jsonl\nspecs/reports/\n");
|
|
441
|
+
writeFileSync(join(d, "PIPELINE.md"), [
|
|
442
|
+
"```yaml pipeline-profile", "name: demo",
|
|
443
|
+
"surfaces:", " - key: api", " path: src/api", " agent: api",
|
|
444
|
+
"gate:", " deny:", " - rm -rf /", " default_branch: main",
|
|
445
|
+
" preflight:", " enabled: false", "```",
|
|
446
|
+
].join("\n"));
|
|
447
|
+
|
|
448
|
+
const s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
|
|
449
|
+
const pick = id => s.checks.find(c => c.id === id) || {};
|
|
450
|
+
const st = id => pick(id).status;
|
|
451
|
+
const dt = id => pick(id).detail;
|
|
452
|
+
|
|
453
|
+
eq("the runtime is discovered from runtimes.json", s.runtimes.map(r => r.id), ["cursor"]);
|
|
454
|
+
check("the core in .cohorte/<id>/ counts as installed", st("core") === "ok", dt("core"));
|
|
455
|
+
check("agents are looked for in .cursor/agents", st("agents") === "ok", dt("agents"));
|
|
456
|
+
check("gate-config is read from .cohorte, not .claude", st("gate") === "ok", dt("gate"));
|
|
457
|
+
check("artifact paths are named against the right state dir",
|
|
458
|
+
st("artifacts") === "ok" && !/\.claude/.test(dt("artifacts")), dt("artifacts"));
|
|
459
|
+
// Cursor's registration is a flat {command} under beforeShellExecution — read with Claude's
|
|
460
|
+
// matcher-group shape it looks absent, which is exactly the false red this guards.
|
|
461
|
+
check("the Cursor hook envelope is recognised", st("hooks") === "ok", dt("hooks"));
|
|
462
|
+
check("workflows are skipped, not reported missing",
|
|
463
|
+
st("workflows") === "skip" && /Cursor/.test(dt("workflows")), dt("workflows"));
|
|
464
|
+
check("nothing is reported broken on a healthy non-Claude install",
|
|
465
|
+
s.summary.bad === 0 && s.summary.warn === 0, JSON.stringify(s.summary));
|
|
466
|
+
|
|
467
|
+
// The metrics sink follows `<state>` too.
|
|
468
|
+
writeFileSync(join(d, ".cohorte", "pipeline-metrics.jsonl"),
|
|
469
|
+
JSON.stringify({ ts: "2026-01-01T00:00:00Z", feature: "f", phase: "build", seconds: 10,
|
|
470
|
+
surfaces: { api: "ok" } }) + "\n");
|
|
471
|
+
check("metrics are read from the runtime's state dir",
|
|
472
|
+
metrics({ projectRoot: d, globalDir: g }).batches === 1);
|
|
473
|
+
}
|
|
474
|
+
|
|
405
475
|
for (const d of tmps) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
406
476
|
console.log("");
|
|
407
477
|
if (failures) { console.error(`test-dashboard: ${failures} failure(s)`); process.exit(1); }
|
package/scripts/test-gate.mjs
CHANGED
|
@@ -353,6 +353,68 @@ console.log("gate.py — worktree awareness");
|
|
|
353
353
|
}
|
|
354
354
|
}
|
|
355
355
|
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// Runtime dialects. Four runtimes host this hook and none of them agree on the
|
|
358
|
+
// envelope. A verdict emitted in the wrong shape is read as "allow" by every one of
|
|
359
|
+
// them — the gate would look installed, print JSON, and block nothing. Two of them
|
|
360
|
+
// also have no confirmation tier, where an honest `ask` must become a `deny` rather
|
|
361
|
+
// than fall through.
|
|
362
|
+
console.log("gate.py — runtime dialects");
|
|
363
|
+
{
|
|
364
|
+
const d = scratch();
|
|
365
|
+
gitRepo(d, "main");
|
|
366
|
+
writeConfig(d, {
|
|
367
|
+
deny: ["rm -rf /"], ask: ["git push"], ask_on_default_branch: [],
|
|
368
|
+
default_branch: "main", preflight: { enabled: true, agents: ["review"] },
|
|
369
|
+
});
|
|
370
|
+
const raw = (payload, ...args) => {
|
|
371
|
+
const r = spawnSync(python, [GATE, ...args], {
|
|
372
|
+
input: JSON.stringify(payload), encoding: "utf8",
|
|
373
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: d },
|
|
374
|
+
});
|
|
375
|
+
let json = null;
|
|
376
|
+
try { json = JSON.parse((r.stdout || "").trim()); } catch { /* no verdict */ }
|
|
377
|
+
return { json, status: r.status };
|
|
378
|
+
};
|
|
379
|
+
const push = { tool_name: "Bash", tool_input: { command: "git push" }, cwd: d };
|
|
380
|
+
|
|
381
|
+
const cc = raw(push, "--runtime", "claude");
|
|
382
|
+
check("claude: ask stays an ask, in the PreToolUse envelope",
|
|
383
|
+
cc.json?.hookSpecificOutput?.permissionDecision === "ask");
|
|
384
|
+
check("no --runtime flag behaves exactly as claude (pre-adapter registrations)",
|
|
385
|
+
raw(push).json?.hookSpecificOutput?.permissionDecision === "ask");
|
|
386
|
+
|
|
387
|
+
const cx = raw(push, "--runtime", "codex");
|
|
388
|
+
check("codex: ask escalates to deny (its `ask` is parsed but never honoured)",
|
|
389
|
+
cx.json?.hookSpecificOutput?.permissionDecision === "deny");
|
|
390
|
+
check("…and the reason says why it was refused rather than queried",
|
|
391
|
+
/no confirmation tier/.test(cx.json?.hookSpecificOutput?.permissionDecisionReason || ""));
|
|
392
|
+
|
|
393
|
+
// Cursor sends the command at the top level and names no tool.
|
|
394
|
+
const cu = raw({ hook_event_name: "beforeShellExecution", command: "git push", cwd: d },
|
|
395
|
+
"--runtime", "cursor");
|
|
396
|
+
check("cursor: its own envelope, and the top-level command is found",
|
|
397
|
+
cu.json?.permission === "ask" && typeof cu.json?.user_message === "string");
|
|
398
|
+
const cuDeny = raw({ hook_event_name: "beforeShellExecution", command: "rm -rf /", cwd: d },
|
|
399
|
+
"--runtime", "cursor");
|
|
400
|
+
check("cursor: a deny also exits 2 (its documented blocking code)",
|
|
401
|
+
cuDeny.json?.permission === "deny" && cuDeny.status === 2);
|
|
402
|
+
|
|
403
|
+
const ge = raw({ tool_name: "run_shell_command", tool_input: { command: "rm -rf /" }, cwd: d },
|
|
404
|
+
"--runtime", "gemini");
|
|
405
|
+
check("gemini: BeforeTool envelope, and run_shell_command is recognised as the shell",
|
|
406
|
+
ge.json?.decision === "deny" && typeof ge.json?.reason === "string");
|
|
407
|
+
|
|
408
|
+
// Gemini exposes each subagent as a tool of its own name, so the phase gate has to fire
|
|
409
|
+
// on `tool_name: review` — not only on Claude's `Task` + subagent_type shape.
|
|
410
|
+
const geDispatch = raw({ tool_name: "review", tool_input: {}, cwd: d }, "--runtime", "gemini");
|
|
411
|
+
check("gemini: a subagent-as-tool dispatch still hits the preflight phase gate",
|
|
412
|
+
geDispatch.json?.decision === "deny"
|
|
413
|
+
&& /preflight/i.test(geDispatch.json?.reason || ""));
|
|
414
|
+
check("an unrelated tool is never gated on any runtime",
|
|
415
|
+
raw({ tool_name: "read_file", tool_input: {}, cwd: d }, "--runtime", "gemini").json === null);
|
|
416
|
+
}
|
|
417
|
+
|
|
356
418
|
for (const d of tmps) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
357
419
|
|
|
358
420
|
console.log("");
|
|
@@ -24,7 +24,7 @@ const frontmatter = (text) => {
|
|
|
24
24
|
// Interactive commands must stay unpinned (they inherit on purpose).
|
|
25
25
|
const PINNED = ["cohorte-build", "cohorte-review", "cohorte-fix", "cohorte-ship",
|
|
26
26
|
"cohorte-audit", "cohorte-refactor", "cohorte-doctor", "cohorte-align-ds",
|
|
27
|
-
"cohorte-update-pipeline"
|
|
27
|
+
"cohorte-update-pipeline"];
|
|
28
28
|
const UNPINNED = ["cohorte-brainstorm", "cohorte-spec", "cohorte-init-pipeline"];
|
|
29
29
|
|
|
30
30
|
// Every command must carry the `cohorte-` prefix. This replaces the old RESERVED
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
model: sonnet
|
|
3
|
-
description: Autonomous /cohorte-build → /cohorte-review → /cohorte-fix → /cohorte-review loop for one feature, until no blocking finding remains.
|
|
4
|
-
argument-hint: <feature_id> [--max=N] [--no-build] [--rebuild] [--resume]
|
|
5
|
-
allowed-tools: Bash(bash ~/.claude/pipeline/scripts/loop-detach.sh:*), Bash(bash .claude/pipeline/scripts/loop-detach.sh:*), Bash(test:*), Read(specs/reports/**)
|
|
6
|
-
disable-model-invocation: true
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
You are the **launcher**, not the loop. Run the driver for **$ARGUMENTS** and relay three lines.
|
|
10
|
-
|
|
11
|
-
> **The driver's name has moved twice.** It was `/loop`, which Claude Code's own built-in `/loop`
|
|
12
|
-
> (run a prompt on a recurring interval) silently **shadowed** — typing `/loop <id>` started the
|
|
13
|
-
> interval runner with the feature id as its prompt, so the driver never ran and the session
|
|
14
|
-
> reported a loop that did not exist. 1.6.0 renamed it `/drive` to escape that. 2.0.0 prefixed
|
|
15
|
-
> every command with `cohorte-`, which makes shadowing impossible by construction, so the accurate
|
|
16
|
-
> name is back: **`/cohorte-loop`**. The shipped script keeps its `loop.sh` name throughout.
|
|
17
|
-
>
|
|
18
|
-
> This command exists because a slash command cannot `/clear` itself. Every phase of the loop runs
|
|
19
|
-
> as a **separate `claude -p` child session** with its own fresh context, driven by a bash script —
|
|
20
|
-
> so the diff, the N review reports and the N contracts never accumulate in YOUR history, which is
|
|
21
|
-
> re-sent at input price on every turn. Running the loop conversationally here would cost more than
|
|
22
|
-
> the loop saves.
|
|
23
|
-
|
|
24
|
-
## 1. Launch — detached, then poll
|
|
25
|
-
|
|
26
|
-
The driver runs for **hours**, which rules out running it as one foreground Bash call: a single
|
|
27
|
-
call is capped at 600 s, and a backgrounded one is not detached — the child stays in this session's
|
|
28
|
-
process group, so a Claude Code restart, crash or laptop sleep kills `loop.sh` and every `claude -p`
|
|
29
|
-
child with it, mid-write. `loop-detach.sh` puts the driver in its own `screen` session so it
|
|
30
|
-
survives all of that, and `loop.sh` re-execs itself under `caffeinate` so idle sleep cannot abort
|
|
31
|
-
its in-flight requests either.
|
|
32
|
-
|
|
33
|
-
Launch — returns immediately:
|
|
34
|
-
|
|
35
|
-
```
|
|
36
|
-
test -f .claude/pipeline/scripts/loop-detach.sh \
|
|
37
|
-
&& bash .claude/pipeline/scripts/loop-detach.sh start $ARGUMENTS \
|
|
38
|
-
|| bash ~/.claude/pipeline/scripts/loop-detach.sh start $ARGUMENTS
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
Then poll. Each call blocks up to ~9 min (inside the tool ceiling) and prints the **status file** —
|
|
42
|
-
one line per phase, plus `__EXIT__ <code>` when the run is over:
|
|
43
|
-
|
|
44
|
-
```
|
|
45
|
-
bash ~/.claude/pipeline/scripts/loop-detach.sh wait <feature_id>
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
**Repeat `wait` until you see `__EXIT__ <code>`.** A `__RUNNING__` line means the driver is still
|
|
49
|
-
working and nothing is wrong — call `wait` again. Never conclude anything from a `__RUNNING__`;
|
|
50
|
-
the run has not finished and you have no verdict to report. If the human says to stop waiting,
|
|
51
|
-
tell them the run continues detached and how to follow it (`screen -r cohorte-<id>`) — do not
|
|
52
|
-
kill it unless they ask.
|
|
53
|
-
|
|
54
|
-
Pass `$ARGUMENTS` through untouched — `loop.sh` owns its own flag parsing (`--max=N`,
|
|
55
|
-
`--no-build`, `--rebuild`, `--resume`) and exits 64 on anything it doesn't know. Don't validate flags
|
|
56
|
-
yourself, don't rewrite them, don't add any.
|
|
57
|
-
|
|
58
|
-
**One lid-close caveat to pass on** if the human is walking away from a laptop: `caffeinate` holds
|
|
59
|
-
off *idle* sleep, but no userspace assertion can prevent lid-close sleep. Lid open, or clamshell
|
|
60
|
-
mode (AC + external display + external input).
|
|
61
|
-
|
|
62
|
-
**Resume is the human's call, not yours.** The loop records its position in the spec's front-matter
|
|
63
|
-
(`status: in-progress` · `loop_pass` · `loop_phase` — SCHEMA.md §Spec status), so a run killed by a
|
|
64
|
-
dead session, a ceiling or a `blocked` exit can continue with `--resume` instead of re-paying the
|
|
65
|
-
passes it already made. If the human types `/cohorte-loop <id>` on a spec whose front-matter says
|
|
66
|
-
`status: in-progress` or `blocked` with `loop_pass` > 1, say so in one line and ask whether to resume
|
|
67
|
-
or restart — never silently add the flag, and never silently restart from pass 1.
|
|
68
|
-
|
|
69
|
-
**Never read `specs/reports/<id>.loop.log`.** It holds the full transcript of every child session —
|
|
70
|
-
the entire diff, every review report, every fix handoff. Pulling it into this session re-imports
|
|
71
|
-
exactly the context the loop was built to keep out, and it is the one mistake that turns this
|
|
72
|
-
command into the most expensive one in the pipeline. Point the human at the path instead; they can
|
|
73
|
-
open it in an editor for free. The same goes for the per-surface `.diff` and `.preflight.txt` files.
|
|
74
|
-
|
|
75
|
-
`<id>.loop.status` is the **other** file and is safe: it is the driver's stdout, one line per phase.
|
|
76
|
-
`wait` already prints it, so you never need to Read it yourself. Two files, one letter apart —
|
|
77
|
-
`.log` is the expensive one.
|
|
78
|
-
|
|
79
|
-
## 2. Report — three lines, from the exit code
|
|
80
|
-
|
|
81
|
-
`wait` prints one line per phase, one closing line, and `__EXIT__ <code>`; that is your raw
|
|
82
|
-
material, and that code is the exit code the table below is keyed on. For exit
|
|
83
|
-
**1** or **3** only, also Read `specs/reports/<id>.verdict.json` (small, structured, safe) to name
|
|
84
|
-
the remaining findings — never the markdown report, which is the findings body in full. For exit
|
|
85
|
-
**4**, Read `specs/reports/<id>.readiness.json` instead (also small) and relay its `gaps`. On any
|
|
86
|
-
other exit the closing line already carries the deferred count, so read nothing.
|
|
87
|
-
|
|
88
|
-
| exit | meaning | what to say |
|
|
89
|
-
| ---- | ------- | ----------- |
|
|
90
|
-
| `0` | clean | no blocking findings left; the human can `/cohorte-ship <id>` |
|
|
91
|
-
| `1` | ceiling hit | the fix was progressing but ran out of passes ⇒ re-run with a higher `--max` |
|
|
92
|
-
| `2` | no usable verdict | `/cohorte-review` produced nothing, or aborted on a red preflight — the closing line says which; point at `specs/reports/<id>.preflight.txt` |
|
|
93
|
-
| `3` | non-convergent | the same blocking findings survived a fix pass; a higher `--max` will NOT help — the human needs to look at them (list them from the verdict) |
|
|
94
|
-
| `4` | not implementable | `/cohorte-build`'s readiness gate returned `NOT-READY` — the frozen spec cannot be built and **no agent ran**; Read `specs/reports/<id>.readiness.json` (small, structured) and relay its `gaps`, then point at `/cohorte-spec <id>`. More passes cannot fix this |
|
|
95
|
-
| `64` | usage | relay the script's own message verbatim |
|
|
96
|
-
|
|
97
|
-
Then print exactly three lines and nothing else — plus a fourth **only when the verdict carries
|
|
98
|
-
`deferred` > 0** (findings that were real but out of this feature's scope, parked in the backlog by
|
|
99
|
-
`/cohorte-review` §3.5; they are not blocking and never cost a pass, but they are not nothing either):
|
|
100
|
-
|
|
101
|
-
```
|
|
102
|
-
outcome: <one clause — clean / ceiling / no verdict / non-convergent / not implementable / usage>
|
|
103
|
-
iterations: <n> review pass(es)<, m fix pass(es) committed>
|
|
104
|
-
remaining: <blocking count + one short phrase per blocking item, or "none">
|
|
105
|
-
deferred: <n> parked in specs/refactor-backlog.md — /cohorte-refactor <domain> when you want them
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
Add at most one follow-up sentence: the next command to run. Never restate a finding's fix, never
|
|
109
|
-
summarize the log, never open the diff. Each fix pass is already committed
|
|
110
|
-
(`loop(<id>): fix pass <i>`) — say so on a non-zero exit, since those commits are the way back.
|