cohorte 1.6.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 +84 -2
- package/README.md +57 -57
- package/bin/cli.js +23 -15
- package/core/agents/implementer.template.md +3 -3
- package/core/agents/release.md +1 -1
- package/core/agents/review.md +3 -3
- package/core/commands/{audit.md → cohorte-audit.md} +3 -3
- package/core/commands/{brainstorm.md → cohorte-brainstorm.md} +4 -4
- package/core/commands/{build.md → cohorte-build.md} +15 -15
- package/core/commands/{doctor.md → cohorte-doctor.md} +17 -9
- package/core/commands/{fix.md → cohorte-fix.md} +15 -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} +20 -19
- package/core/commands/{ship.md → cohorte-ship.md} +5 -5
- package/core/commands/{spec.md → cohorte-spec.md} +13 -13
- package/core/commands/{update-pipeline.md → cohorte-update-pipeline.md} +11 -6
- package/core/hooks/gate.py +101 -6
- package/core/templates/brainstorm-return.md +4 -4
- package/core/templates/decisions.template.md +1 -1
- package/core/templates/design-brief.md +1 -1
- package/core/templates/spec.template.md +7 -7
- 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 +22 -22
- package/dashboard/README.md +2 -2
- package/dashboard/dist/assets/{index-DYyn4p93.js → index-P1I1JGtj.js} +2 -2
- package/dashboard/dist/index.html +1 -1
- package/dashboard/server/doctor.js +69 -19
- package/dashboard/server/index.js +5 -5
- package/dashboard/server/metrics.js +1 -1
- package/install.ps1 +23 -14
- package/install.sh +24 -14
- package/package.json +2 -2
- package/profile/PIPELINE.template.md +17 -16
- package/profile/SCHEMA.md +89 -77
- package/profile/cohorte.config.template.yaml +8 -8
- package/scripts/loop-detach.sh +153 -0
- package/scripts/loop.sh +75 -27
- 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 +49 -7
- package/scripts/test-metrics.mjs +23 -11
- package/scripts/test-workflows.mjs +7 -7
- package/scripts/validate-core.mjs +45 -23
- package/core/commands/drive.md +0 -80
- /package/core/commands/{align-ds.md → cohorte-align-ds.md} +0 -0
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
|
{
|
package/scripts/test-loop.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Behavioural tests for scripts/loop.sh — the autonomous /review ⇄ /fix driver.
|
|
2
|
+
// Behavioural tests for scripts/loop.sh — the autonomous /cohorte-review ⇄ /cohorte-fix driver.
|
|
3
3
|
//
|
|
4
4
|
// The driver is pure shell around two JSON files it does not write, so it is
|
|
5
5
|
// testable end-to-end by putting a FAKE `claude` on PATH that produces those files
|
|
6
6
|
// per phase. What is pinned here cannot be seen by any structural check:
|
|
7
7
|
//
|
|
8
|
-
// · exit 4 — /build's readiness gate said NOT-READY, so no pass count helps
|
|
8
|
+
// · exit 4 — /cohorte-build's readiness gate said NOT-READY, so no pass count helps
|
|
9
9
|
// · exit 0/3 leave the right TERMINAL status in the spec's front-matter, which is
|
|
10
10
|
// what makes an interrupted loop resumable (SCHEMA.md §Spec status)
|
|
11
11
|
// · the front-matter stamps are written with awk on every platform — a `sed -i`
|
|
@@ -50,7 +50,15 @@ prompt=""
|
|
|
50
50
|
while [ $# -gt 0 ]; do
|
|
51
51
|
case "$1" in -p) prompt="$2"; shift 2 ;; *) shift ;; esac
|
|
52
52
|
done
|
|
53
|
-
cmd="\${prompt%% *}"
|
|
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
|
|
54
62
|
n=0; [ -f "$SCEN_DIR/count" ] && n=$(cat "$SCEN_DIR/count")
|
|
55
63
|
n=$((n + 1)); echo "$n" >"$SCEN_DIR/count"
|
|
56
64
|
step=$(sed -n "\${n}p" "$SCEN_DIR/phases")
|
|
@@ -133,7 +141,7 @@ console.log("loop.sh — readiness gate");
|
|
|
133
141
|
check("NOT-READY ⇒ exit 4, not 2", r.code === 4, `got ${r.code}: ${r.out.trim().split("\n").pop()}`);
|
|
134
142
|
check("NOT-READY ⇒ says the spec is not implementable",
|
|
135
143
|
/not implementable/i.test(r.out), r.out.trim().split("\n").pop());
|
|
136
|
-
check("NOT-READY ⇒ points at /spec", /\/spec feat-x/.test(r.out));
|
|
144
|
+
check("NOT-READY ⇒ points at /cohorte-spec", /\/cohorte-spec feat-x/.test(r.out));
|
|
137
145
|
check("NOT-READY ⇒ spec left blocked", r.fm("status") === "blocked", r.fm("status"));
|
|
138
146
|
check("NOT-READY ⇒ no review ran (the gate is the point)", !/phase=review/.test(r.out));
|
|
139
147
|
check("NOT-READY ⇒ the build stamp is NOT written",
|
|
@@ -145,7 +153,7 @@ console.log("loop.sh — clean run");
|
|
|
145
153
|
const s = scenario(["build:ready", "review:clean"]);
|
|
146
154
|
const r = runLoop(s, ["feat-x"]);
|
|
147
155
|
check("clean ⇒ exit 0", r.code === 0, `got ${r.code}: ${r.out}`);
|
|
148
|
-
check("clean ⇒ status in-review (ready to /ship)", r.fm("status") === "in-review", r.fm("status"));
|
|
156
|
+
check("clean ⇒ status in-review (ready to /cohorte-ship)", r.fm("status") === "in-review", r.fm("status"));
|
|
149
157
|
check("clean ⇒ loop state cleared", r.fm("loop_pass") === "0" && r.fm("loop_phase") === "done",
|
|
150
158
|
`${r.fm("loop_pass")}/${r.fm("loop_phase")}`);
|
|
151
159
|
check("clean ⇒ the deferred count is named, not dropped",
|
|
@@ -156,7 +164,7 @@ console.log("loop.sh — clean run");
|
|
|
156
164
|
|
|
157
165
|
console.log("loop.sh — a dead subagent is never a clean result");
|
|
158
166
|
{
|
|
159
|
-
// A dead implementer: /build finishes fine having built one surface of two. Reviewing
|
|
167
|
+
// A dead implementer: /cohorte-build finishes fine having built one surface of two. Reviewing
|
|
160
168
|
// that would spend N reviewers auditing a half-built feature and report its holes as
|
|
161
169
|
// findings to fix — the wrong diagnosis at the wrong price.
|
|
162
170
|
const s = scenario(["build:deadimplementer"]);
|
|
@@ -170,7 +178,7 @@ console.log("loop.sh — a dead subagent is never a clean result");
|
|
|
170
178
|
{
|
|
171
179
|
// THE dangerous one: blocking == 0 because the only reviewer that could have found
|
|
172
180
|
// something never answered. Exiting 0 here would report "clean" about unread code and
|
|
173
|
-
// send the human to /ship.
|
|
181
|
+
// send the human to /cohorte-ship.
|
|
174
182
|
const s = scenario(["build:ready", "review:deadreviewer"]);
|
|
175
183
|
const r = runLoop(s, ["feat-x"]);
|
|
176
184
|
check("dead reviewer + blocking 0 ⇒ NOT exit 0", r.code !== 0, `got ${r.code}: ${r.out}`);
|
|
@@ -223,5 +231,39 @@ console.log("loop.sh — a spec with no front-matter still runs");
|
|
|
223
231
|
!existsSync(join(s.dir, "specs/feat-x.md.loop.tmp")));
|
|
224
232
|
}
|
|
225
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
|
+
|
|
226
268
|
if (failures) { console.error(`\ntest-loop: ${failures} failure(s)`); process.exit(1); }
|
|
227
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,19 @@ 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
148
|
}
|
|
149
149
|
{
|
|
150
150
|
// Deferred findings are real but out of the feature's scope: they must be
|
|
@@ -163,8 +163,8 @@ console.log("review.js");
|
|
|
163
163
|
return replier(BASE_REVIEW)(prompt, opts);
|
|
164
164
|
});
|
|
165
165
|
check("deferred-only ⇒ verdict still SHIP", result.verdict === "SHIP", `got ${result.verdict}`);
|
|
166
|
-
check("deferred-only ⇒ next is /ship (not a fix loop)",
|
|
167
|
-
String(result.next).startsWith("/ship"), result.next);
|
|
166
|
+
check("deferred-only ⇒ next is /cohorte-ship (not a fix loop)",
|
|
167
|
+
String(result.next).startsWith("/cohorte-ship"), result.next);
|
|
168
168
|
check("deferred are counted (both surfaces)", result.deferred === 2, `got ${result.deferred}`);
|
|
169
169
|
check("deferred stay out of the severity counts",
|
|
170
170
|
Object.values(result.counts).every(n => n === 0), JSON.stringify(result.counts));
|
|
@@ -22,27 +22,28 @@ const frontmatter = (text) => {
|
|
|
22
22
|
// Mechanical commands must pin model: sonnet (otherwise the lead's
|
|
23
23
|
// orchestration turn silently bills at the session model — Opus/Fable).
|
|
24
24
|
// Interactive commands must stay unpinned (they inherit on purpose).
|
|
25
|
-
const PINNED = ["build", "review", "fix", "ship",
|
|
26
|
-
"
|
|
27
|
-
|
|
25
|
+
const PINNED = ["cohorte-build", "cohorte-review", "cohorte-fix", "cohorte-ship",
|
|
26
|
+
"cohorte-audit", "cohorte-refactor", "cohorte-doctor", "cohorte-align-ds",
|
|
27
|
+
"cohorte-update-pipeline", "cohorte-loop"];
|
|
28
|
+
const UNPINNED = ["cohorte-brainstorm", "cohorte-spec", "cohorte-init-pipeline"];
|
|
28
29
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// `/
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
const
|
|
39
|
-
"init", "run", "schedule", "simplify", "review-pr"];
|
|
30
|
+
// Every command must carry the `cohorte-` prefix. This replaces the old RESERVED
|
|
31
|
+
// blocklist, which chased collisions one name at a time and always lagged: a command
|
|
32
|
+
// that collides with a Claude Code built-in is not overridden, it is SHADOWED — the
|
|
33
|
+
// built-in answers the slash, our file is never read, and the session confidently
|
|
34
|
+
// reports on a run that never happened. `/loop` did exactly that (Claude Code's own
|
|
35
|
+
// `/loop` runs a prompt on an interval) and went unnoticed until a user found the
|
|
36
|
+
// driver had never started; `/doctor` sat on a watchlist waiting to do the same.
|
|
37
|
+
// A blocklist can only forbid the collisions we already know about. The prefix makes
|
|
38
|
+
// the whole class unreachable, so this check is structural, not a list to maintain.
|
|
39
|
+
const PREFIX = "cohorte-";
|
|
40
40
|
|
|
41
41
|
for (const f of readdirSync(join(root, "core/commands"))) {
|
|
42
42
|
const path = `core/commands/${f}`;
|
|
43
|
-
if (
|
|
44
|
-
fail(path, `command name
|
|
45
|
-
`
|
|
43
|
+
if (!f.startsWith(PREFIX))
|
|
44
|
+
fail(path, `command name lacks the \`${PREFIX}\` prefix — an unprefixed command can be ` +
|
|
45
|
+
`SHADOWED by a Claude Code built-in of the same name (the built-in answers the slash ` +
|
|
46
|
+
`and this file is never read); rename it to ${PREFIX}${f}`);
|
|
46
47
|
const fm = frontmatter(read(path));
|
|
47
48
|
if (!fm) { fail(path, "missing or malformed YAML frontmatter"); continue; }
|
|
48
49
|
if (!/^description:\s*\S/m.test(fm)) fail(path, "frontmatter lacks a description");
|
|
@@ -130,16 +131,20 @@ if (!existsSync(steps) || readdirSync(steps).length === 0)
|
|
|
130
131
|
|
|
131
132
|
// ── telemetry coverage ──────────────────────────────────────────────────────
|
|
132
133
|
// The funnel is only readable if every one of its stages pings — a single missing
|
|
133
|
-
// one silently truncates it (that is how /review and /fix went unreported
|
|
134
|
+
// one silently truncates it (that is how /cohorte-review and /cohorte-fix went unreported
|
|
134
135
|
// until 1.2.3). The phase list here must match SCHEMA.md §Telemetry's table.
|
|
136
|
+
// These are telemetry PHASE names, not command names — they stay unprefixed even though
|
|
137
|
+
// the commands that emit them are now `/cohorte-*`. The phase is a wire field allowlisted
|
|
138
|
+
// in telemetry-send.sh and keyed on by the collector's existing dataset; prefixing it would
|
|
139
|
+
// orphan every ping ever sent. Command file = PREFIX + phase.
|
|
135
140
|
const FUNNEL = ["brainstorm", "spec", "build", "review", "fix", "ship"];
|
|
136
141
|
for (const c of FUNNEL)
|
|
137
|
-
if (!/usage ping/i.test(read(`core/commands/${c}.md`)))
|
|
138
|
-
fail(`core/commands/${c}.md`, "funnel command with no usage ping — breaks the telemetry funnel");
|
|
142
|
+
if (!/usage ping/i.test(read(`core/commands/${PREFIX}${c}.md`)))
|
|
143
|
+
fail(`core/commands/${PREFIX}${c}.md`, "funnel command with no usage ping — breaks the telemetry funnel");
|
|
139
144
|
// …and nothing outside the funnel may ping (consent text scopes it to the funnel).
|
|
140
145
|
for (const f of readdirSync(join(root, "core/commands"))) {
|
|
141
|
-
const c = f.replace(/\.md$/, "");
|
|
142
|
-
// `telemetry-send.sh` + an argument = a call site; the bare filename (e.g. /doctor
|
|
146
|
+
const c = f.replace(/\.md$/, "").replace(new RegExp(`^${PREFIX}`), "");
|
|
147
|
+
// `telemetry-send.sh` + an argument = a call site; the bare filename (e.g. /cohorte-doctor
|
|
143
148
|
// listing the scripts it checks for) is a mention, not a ping.
|
|
144
149
|
if (!FUNNEL.includes(c) && /telemetry-send\.sh +\S|usage ping/i.test(read(`core/commands/${f}`)))
|
|
145
150
|
fail(`core/commands/${f}`, "non-funnel command pings telemetry — outside the consented scope");
|
|
@@ -154,7 +159,7 @@ for (const f of readdirSync(join(root, "core/commands"))) {
|
|
|
154
159
|
// scratch HOME and asserts the same postconditions instead. Both are needed: this
|
|
155
160
|
// check catches a forgotten name, that one catches a drifted rule.
|
|
156
161
|
// A `<name>.sh` with a `<name>.sh.template` sibling is a locally-rendered artifact
|
|
157
|
-
// (this repo dogfoods its own /init-pipeline), not a core asset — skip those.
|
|
162
|
+
// (this repo dogfoods its own /cohorte-init-pipeline), not a core asset — skip those.
|
|
158
163
|
const installers = { "install.sh": read("install.sh"), "install.ps1": read("install.ps1") };
|
|
159
164
|
const shipped = readdirSync(join(root, "scripts"));
|
|
160
165
|
for (const f of shipped.filter((f) => f.endsWith(".sh") && !shipped.includes(`${f}.template`)))
|
|
@@ -218,6 +223,23 @@ for (const f of workflowNames) {
|
|
|
218
223
|
fail("dashboard/server/doctor.js", `checkWorkflows() does not list ${f}`);
|
|
219
224
|
}
|
|
220
225
|
|
|
226
|
+
// ── every test suite must run in BOTH workflows ──────────────────────────────
|
|
227
|
+
// publish.yml re-runs the test suites under the comment "same gate as CI", because
|
|
228
|
+
// it has no dependency on the CI workflow's conclusion — a merge whose CI failed
|
|
229
|
+
// would otherwise still ship to npm. That only holds if the two lists agree, and
|
|
230
|
+
// they drift the moment a suite is added to one: test-loop.mjs landed in ci.yml and
|
|
231
|
+
// publish.yml kept publishing without it. Neither list is the source of truth —
|
|
232
|
+
// the directory is.
|
|
233
|
+
const ciYml = existsSync(join(root, ".github/workflows/ci.yml")) ? read(".github/workflows/ci.yml") : "";
|
|
234
|
+
const publishYml = existsSync(join(root, ".github/workflows/publish.yml"))
|
|
235
|
+
? read(".github/workflows/publish.yml") : "";
|
|
236
|
+
for (const f of readdirSync(join(root, "scripts")).filter((f) => /^test-.*\.mjs$/.test(f))) {
|
|
237
|
+
if (ciYml && !ciYml.includes(`scripts/${f}`))
|
|
238
|
+
fail(".github/workflows/ci.yml", `never runs scripts/${f} — a suite CI does not run is a suite that does not exist`);
|
|
239
|
+
if (publishYml && !publishYml.includes(`scripts/${f}`))
|
|
240
|
+
fail(".github/workflows/publish.yml", `never runs scripts/${f} — publish would ship past a failure that gate is meant to catch`);
|
|
241
|
+
}
|
|
242
|
+
|
|
221
243
|
// ── dashboard: the metrics phase list is duplicated server/client ────────────
|
|
222
244
|
// A phase present in one and not the other parses fine and renders in no column —
|
|
223
245
|
// silently invisible data, which is how a phase batch once went unnoticed.
|
package/core/commands/drive.md
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
model: sonnet
|
|
3
|
-
description: Autonomous /build → /review → /fix → /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.sh:*), Bash(bash .claude/pipeline/scripts/loop.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
|
-
> **This command was `/drive` until 1.6.0.** Claude Code ships its own built-in `/drive` (run a prompt on
|
|
12
|
-
> a recurring interval), which **shadowed** this one: typing `/drive <id>` started the interval runner
|
|
13
|
-
> with the feature id as its prompt, so the driver below never ran and the session reported a loop that
|
|
14
|
-
> did not exist. The shipped script keeps its `loop.sh` name — nothing about a user's install path
|
|
15
|
-
> changes, only what you type.
|
|
16
|
-
>
|
|
17
|
-
> This command exists because a slash command cannot `/clear` itself. Every phase of the loop runs
|
|
18
|
-
> as a **separate `claude -p` child session** with its own fresh context, driven by a bash script —
|
|
19
|
-
> so the diff, the N review reports and the N contracts never accumulate in YOUR history, which is
|
|
20
|
-
> re-sent at input price on every turn. Running the loop conversationally here would cost more than
|
|
21
|
-
> the loop saves.
|
|
22
|
-
|
|
23
|
-
## 1. Launch
|
|
24
|
-
|
|
25
|
-
Probe the core, then run the script — ONE Bash call, and let it run to completion:
|
|
26
|
-
|
|
27
|
-
```
|
|
28
|
-
test -f .claude/pipeline/scripts/loop.sh \
|
|
29
|
-
&& bash .claude/pipeline/scripts/loop.sh $ARGUMENTS \
|
|
30
|
-
|| bash ~/.claude/pipeline/scripts/loop.sh $ARGUMENTS
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
Pass `$ARGUMENTS` through untouched — the script owns its own flag parsing (`--max=N`,
|
|
34
|
-
`--no-build`, `--rebuild`, `--resume`) and exits 64 on anything it doesn't know. Don't validate flags
|
|
35
|
-
yourself, don't rewrite them, don't add any.
|
|
36
|
-
|
|
37
|
-
**Resume is the human's call, not yours.** The loop records its position in the spec's front-matter
|
|
38
|
-
(`status: in-progress` · `loop_pass` · `loop_phase` — SCHEMA.md §Spec status), so a run killed by a
|
|
39
|
-
dead session, a ceiling or a `blocked` exit can continue with `--resume` instead of re-paying the
|
|
40
|
-
passes it already made. If the human types `/drive <id>` on a spec whose front-matter says
|
|
41
|
-
`status: in-progress` or `blocked` with `loop_pass` > 1, say so in one line and ask whether to resume
|
|
42
|
-
or restart — never silently add the flag, and never silently restart from pass 1.
|
|
43
|
-
|
|
44
|
-
**Never read `specs/reports/<id>.loop.log`.** It holds the full transcript of every child session —
|
|
45
|
-
the entire diff, every review report, every fix handoff. Pulling it into this session re-imports
|
|
46
|
-
exactly the context the loop was built to keep out, and it is the one mistake that turns this
|
|
47
|
-
command into the most expensive one in the pipeline. Point the human at the path instead; they can
|
|
48
|
-
open it in an editor for free. The same goes for the per-surface `.diff` and `.preflight.txt` files.
|
|
49
|
-
|
|
50
|
-
## 2. Report — three lines, from the exit code
|
|
51
|
-
|
|
52
|
-
The script prints one line per phase and one closing line; that is your raw material. For exit
|
|
53
|
-
**1** or **3** only, also Read `specs/reports/<id>.verdict.json` (small, structured, safe) to name
|
|
54
|
-
the remaining findings — never the markdown report, which is the findings body in full. For exit
|
|
55
|
-
**4**, Read `specs/reports/<id>.readiness.json` instead (also small) and relay its `gaps`. On any
|
|
56
|
-
other exit the closing line already carries the deferred count, so read nothing.
|
|
57
|
-
|
|
58
|
-
| exit | meaning | what to say |
|
|
59
|
-
| ---- | ------- | ----------- |
|
|
60
|
-
| `0` | clean | no blocking findings left; the human can `/ship <id>` |
|
|
61
|
-
| `1` | ceiling hit | the fix was progressing but ran out of passes ⇒ re-run with a higher `--max` |
|
|
62
|
-
| `2` | no usable verdict | `/review` produced nothing, or aborted on a red preflight — the closing line says which; point at `specs/reports/<id>.preflight.txt` |
|
|
63
|
-
| `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) |
|
|
64
|
-
| `4` | not implementable | `/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 `/spec <id>`. More passes cannot fix this |
|
|
65
|
-
| `64` | usage | relay the script's own message verbatim |
|
|
66
|
-
|
|
67
|
-
Then print exactly three lines and nothing else — plus a fourth **only when the verdict carries
|
|
68
|
-
`deferred` > 0** (findings that were real but out of this feature's scope, parked in the backlog by
|
|
69
|
-
`/review` §3.5; they are not blocking and never cost a pass, but they are not nothing either):
|
|
70
|
-
|
|
71
|
-
```
|
|
72
|
-
outcome: <one clause — clean / ceiling / no verdict / non-convergent / not implementable / usage>
|
|
73
|
-
iterations: <n> review pass(es)<, m fix pass(es) committed>
|
|
74
|
-
remaining: <blocking count + one short phrase per blocking item, or "none">
|
|
75
|
-
deferred: <n> parked in specs/refactor-backlog.md — /refactor <domain> when you want them
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
Add at most one follow-up sentence: the next command to run. Never restate a finding's fix, never
|
|
79
|
-
summarize the log, never open the diff. Each fix pass is already committed
|
|
80
|
-
(`loop(<id>): fix pass <i>`) — say so on a non-zero exit, since those commits are the way back.
|
|
File without changes
|