cohorte 2.7.0 → 2.9.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +15 -10
  3. package/bin/cli.js +15 -2
  4. package/core/agents/review.md +4 -2
  5. package/core/commands/cohorte-brainstorm.md +5 -1
  6. package/core/commands/cohorte-build.md +3 -1
  7. package/core/commands/cohorte-doctor.md +5 -3
  8. package/core/commands/cohorte-fix.md +6 -5
  9. package/core/commands/cohorte-fleet.md +104 -0
  10. package/core/commands/cohorte-intake.md +92 -0
  11. package/core/commands/cohorte-patch.md +6 -1
  12. package/core/commands/cohorte-retro.md +85 -0
  13. package/core/commands/cohorte-review.md +85 -23
  14. package/core/commands/cohorte-ship.md +2 -1
  15. package/core/commands/cohorte-spec.md +1 -1
  16. package/core/hooks/gate.py +10 -4
  17. package/core/workflows/audit.js +14 -2
  18. package/core/workflows/loop.js +641 -0
  19. package/core/workflows/refactor.js +21 -8
  20. package/core/workflows/review.js +92 -12
  21. package/dashboard/dist/assets/{index-D1rsbLat.js → index-vtFc6Gyc.js} +12 -12
  22. package/dashboard/dist/index.html +1 -1
  23. package/dashboard/server/doctor.js +8 -2
  24. package/dashboard/server/index.js +6 -1
  25. package/dashboard/server/kanban.js +15 -4
  26. package/dashboard/server/metrics.js +8 -1
  27. package/dashboard/server/runtime.js +20 -1
  28. package/install.ps1 +16 -333
  29. package/install.sh +27 -297
  30. package/package.json +1 -1
  31. package/profile/SCHEMA.md +45 -14
  32. package/profile/cohorte.config.template.yaml +1 -1
  33. package/scripts/kanban-move.sh +15 -5
  34. package/scripts/new-feature.sh.template +8 -1
  35. package/scripts/preflight.sh +10 -2
  36. package/scripts/remove-feature.sh.template +3 -1
  37. package/scripts/test-dashboard.mjs +53 -5
  38. package/scripts/test-gate.mjs +6 -0
  39. package/scripts/test-workflows.mjs +415 -6
  40. package/scripts/validate-core.mjs +68 -34
package/install.sh CHANGED
@@ -43,8 +43,9 @@ install.sh — install the cohorte pipeline core.
43
43
 
44
44
  Honours $CLAUDE_CONFIG_DIR for the global destination and $PIPELINE_REPO for the
45
45
  source when piped through curl. The npm CLI (`npm i -g cohorte` then `cohorte install`)
46
- does the same thing and is the documented route; this script exists for Node-less
47
- environments.
46
+ does the same thing and is the documented route; this script exists for npm-less
47
+ setups (curl straight from the repo). Node itself is still required — the commands
48
+ are rendered per coding agent at install time and there is no shell renderer.
48
49
  USAGE
49
50
  exit 0 ;;
50
51
  --) shift; break ;;
@@ -59,7 +60,12 @@ src=""
59
60
  self="${0:-}"
60
61
  self_dir=""
61
62
  case "$self" in
62
- */*) self_dir=$(CDPATH= cd -- "$(dirname -- "$self")" && pwd) ;;
63
+ # `sh install.sh` from inside a checkout hands $0 with no slash — the old */*-only
64
+ # case missed it and silently CLONED the remote instead of installing the local
65
+ # tree the human was standing in (dirname of a bare name is `.`, which is exactly
66
+ # right here; the piped-stdin case stays `sh`, matches neither arm, and clones).
67
+ */*) self_dir=$(CDPATH= cd -- "$(dirname -- "$self")" && pwd) ;;
68
+ *install.sh*) self_dir=$(pwd) ;;
63
69
  esac
64
70
  if [ -n "$self_dir" ] && [ -d "$self_dir/core" ]; then
65
71
  src="$self_dir"
@@ -72,313 +78,37 @@ else
72
78
  fi
73
79
  [ -d "$src/core" ] || { echo "error: pipeline source not found (no core/ in $src)" >&2; exit 1; }
74
80
 
75
- # --- delegate to the Node CLI ------------------------------------------------
81
+ # --- require Node >= 18, then delegate to the Node CLI -----------------------
76
82
  # Since 2.2.0 the commands in core/ are runtime-NEUTRAL sources: they carry capability
77
83
  # conditionals (`<!-- cohorte:if subagents -->`) and path tokens (`<core>`, `<state>`) that
78
84
  # the adapter resolves per coding agent. Copying them verbatim, as this script used to,
79
85
  # would install prompts full of unresolved markers — an install that looks successful and
80
86
  # instructs the model with text meant for a different runtime. There is no shell renderer,
81
- # so hand the whole job to bin/cli.js, which is the documented route anyway.
87
+ # so the whole job goes to bin/cli.js, which is the documented route anyway. (The legacy
88
+ # copy-verbatim shell path was removed in 2.7.0 — it had been unreachable dead code since
89
+ # 2.2.0, and its text was what validate-core's copy checks were vacuously matching.)
82
90
  if command -v node >/dev/null 2>&1; then
91
+ # An old Node fails DEEP into cli.js (fs.cpSync needs >= 16.7) after some files are
92
+ # already on disk — a half-install that reports as a crash. Refuse up front instead.
93
+ node_major=$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)
94
+ case "$node_major" in *[!0-9]*) node_major=0 ;; esac
95
+ if [ "$node_major" -lt 18 ]; then
96
+ echo "error: cohorte needs Node >= 18 — found $(node --version 2>/dev/null || echo '?')." >&2
97
+ echo " Upgrade Node, then re-run this script (or: npm i -g cohorte && cohorte install$([ "$scope" = global ] && echo ' --global'))." >&2
98
+ exit 1
99
+ fi
83
100
  set -- install
84
101
  [ "$mode" = "update" ] && set -- update
85
102
  [ "$scope" = "global" ] && set -- "$@" --global
86
103
  [ "$scope" = "project" ] && set -- "$@" "$target"
87
- exec node "$src/bin/cli.js" "$@"
104
+ # Not `exec`: exec replaces this shell, so the curl-path EXIT trap (rm -rf "$tmp")
105
+ # never fires and every piped install leaks the shallow clone in $TMPDIR.
106
+ node "$src/bin/cli.js" "$@"
107
+ exit $?
88
108
  fi
89
- echo "error: cohorte needs Node 18 to install." >&2
109
+ echo "error: cohorte needs Node >= 18 to install." >&2
90
110
  echo " The pipeline's commands are rendered per coding agent (Claude Code, Codex, Cursor," >&2
91
111
  echo " Gemini CLI, OpenCode) at install time; there is no shell equivalent of that step," >&2
92
112
  echo " and a raw copy would install prompts this runtime cannot follow." >&2
93
113
  echo " Install Node, then: npm i -g cohorte && cohorte install$([ "$scope" = global ] && echo ' --global')" >&2
94
114
  exit 1
95
-
96
- # --- resolve the destination .claude dir ------------------------------------
97
- if [ "$scope" = "global" ]; then
98
- dest="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
99
- else
100
- dest="$target/.claude"
101
- fi
102
- mkdir -p "$dest"
103
-
104
- # version stamp so a per-repo pointer can record which core it expects:
105
- # the package.json semver, with the git sha for traceability on from-main installs
106
- semver=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$src/package.json" 2>/dev/null | head -n 1)
107
- sha=$(git -C "$src" rev-parse --short HEAD 2>/dev/null || true)
108
- if [ -n "$semver" ] && [ -n "$sha" ]; then ver="$semver ($sha)"
109
- elif [ -n "$semver" ]; then ver="$semver"
110
- else ver="${sha:-unknown}"
111
- fi
112
-
113
- copy_core() {
114
- cp -R "$src/core/commands" "$dest/"
115
- cp -R "$src/core/hooks" "$dest/"
116
- cp -R "$src/core/templates" "$dest/"
117
- cp -R "$src/core/workflows" "$dest/"
118
- # A Python bytecode cache appears in a source checkout the moment anyone compiles
119
- # or imports gate.py (CI does) and `cp -R` carries it along — machine- and
120
- # interpreter-specific, and copy-over would never delete it later.
121
- rm -rf "$dest/hooks/__pycache__"
122
- # 0.1.19 renamed questionnaire-domain-brief.md → research-brief.md; drop the stale copy.
123
- rm -f "$dest/templates/questionnaire-domain-brief.md"
124
- mkdir -p "$dest/pipeline/scripts"
125
- cp "$src/profile/PIPELINE.template.md" "$dest/pipeline/"
126
- cp "$src/profile/SCHEMA.md" "$dest/pipeline/"
127
- cp "$src/profile/cohorte.config.template.yaml" "$dest/pipeline/"
128
- cp "$src"/scripts/*.template "$dest/pipeline/scripts/"
129
- cp "$src/scripts/kanban-move.sh" "$dest/pipeline/scripts/"
130
- cp "$src/scripts/preflight.sh" "$dest/pipeline/scripts/"
131
- chmod +x "$dest/pipeline/scripts/kanban-move.sh" \
132
- "$dest/pipeline/scripts/preflight.sh" 2>/dev/null || true
133
- # 2.3.0 removed telemetry. Copy-over never deletes, so an existing install would keep an
134
- # executable that still POSTs to the collector — scrub the script itself. The dead
135
- # `telemetry:` block in the user's config is not this installer's to parse; the interactive
136
- # /cohorte-update-pipeline deletes it (SCHEMA.md §Reconcile step 5).
137
- rm -f "$dest/pipeline/scripts/telemetry-send.sh"
138
- cp "$src/core/agents/implementer.template.md" "$dest/pipeline/"
139
- [ -f "$src/CHANGELOG.md" ] && cp "$src/CHANGELOG.md" "$dest/pipeline/"
140
- printf '%s\n' "$ver" > "$dest/pipeline/VERSION"
141
- chmod +x "$dest/hooks/gate.py" 2>/dev/null || true
142
- scrub_tdd_gate
143
- }
144
-
145
- # The TDD gate was removed in 0.1.6. Older installs have hooks/tdd_gate.py on disk and
146
- # registered in settings.json — copy-over never deletes, and a registered hook whose file
147
- # is gone errors on every Write/Edit, so scrub both.
148
- scrub_tdd_gate() {
149
- rm -f "$dest/hooks/tdd_gate.py"
150
- [ -f "$dest/settings.json" ] || return 0
151
- command -v python3 >/dev/null 2>&1 || return 0
152
- python3 - "$dest/settings.json" <<'PY'
153
- import json, sys
154
- settings = sys.argv[1]
155
- try:
156
- with open(settings) as fh:
157
- data = json.load(fh)
158
- except Exception:
159
- sys.exit(0)
160
- pre = data.get("hooks", {}).get("PreToolUse")
161
- if not isinstance(pre, list):
162
- sys.exit(0)
163
- kept = [e for e in pre if not any(
164
- h.get("command", "").strip().endswith("tdd_gate.py") for h in e.get("hooks", []))]
165
- if len(kept) != len(pre):
166
- data["hooks"]["PreToolUse"] = kept
167
- with open(settings, "w") as fh:
168
- json.dump(data, fh, indent=2)
169
- fh.write("\n")
170
- print(" · removed the retired tdd_gate.py hook (file + settings registration)")
171
- PY
172
- }
173
-
174
- # the fixed (non-rendered) agents: the dev review/release pipeline agents
175
- copy_fixed_agents() {
176
- mkdir -p "$dest/agents"
177
- cp "$src/core/agents/review.md" "$src/core/agents/release.md" \
178
- "$src/core/agents/profile-reader.md" \
179
- "$dest/agents/"
180
- # 1.5.0 removed the /smoke phase; copy-over never deletes, so scrub the orphan agent.
181
- rm -f "$dest/agents/smoke.md" "$dest/commands/smoke.md"
182
- # 1.4.0 removed /cycle and its workflow — and no installer ever scrubbed them, so every
183
- # install since has kept offering a command that dispatches a workflow whose phases were
184
- # later deleted. A dead command is worse than a missing one: the model can still fire it.
185
- rm -f "$dest/commands/cycle.md" "$dest/workflows/cycle.js"
186
- # 1.6.0 renamed /loop → /drive: Claude Code's own built-in /loop shadowed ours, so a leftover
187
- # commands/loop.md is a command the user can never reach — scrub it rather than leave a decoy.
188
- rm -f "$dest/commands/loop.md"
189
- # 2.0.0 prefixed every command with `cohorte-`, which ends the shadowing problem for good.
190
- # Copy-over never deletes, so all 13 bare names would survive an upgrade as decoys — and a
191
- # stale /build is the worst kind: it still dispatches implementers, from a 1.x command file
192
- # that knows nothing of this core's contract. /drive goes too (it became /cohorte-loop).
193
- for c in align-ds audit brainstorm build doctor drive fix init-pipeline \
194
- refactor review ship spec update-pipeline; do
195
- rm -f "$dest/commands/$c.md"
196
- done
197
- # 0.1.19 split the bi-mode questionnaire-researcher into research-agent + questionnaire-architect;
198
- # copy-over never deletes, so scrub the retired agent lest a dead subagent_type linger.
199
- rm -f "$dest/agents/questionnaire-researcher.md"
200
- scrub_research_questionnaire
201
- }
202
-
203
- # The research + questionnaire capability was removed. Older installs have its agents, commands,
204
- # templates and template-step dirs on disk; copy-over never deletes, so scrub every orphan.
205
- scrub_research_questionnaire() {
206
- rm -f "$dest/agents/research-agent.md" \
207
- "$dest/agents/questionnaire-architect.md" \
208
- "$dest/agents/questionnaire-writer.md" \
209
- "$dest/agents/questionnaire-validator.md" \
210
- "$dest/commands/research.md" \
211
- "$dest/commands/questionnaire.md" \
212
- "$dest/templates/research-brief.md" \
213
- "$dest/templates/questionnaire-blueprint.md" \
214
- "$dest/templates/questionnaire-declaration.md" \
215
- "$dest/templates/questionnaire-verdict.md"
216
- rm -rf "$dest/templates/steps/research" "$dest/templates/steps/questionnaire"
217
- }
218
-
219
- # pipeline capability config is USER-level (vault, Notion DB, kanban boards) — it lives in
220
- # ~/.claude regardless of install scope. Seed it only if neither the consolidated nor the
221
- # legacy copy exists. This piped installer is non-interactive: it seeds disabled defaults;
222
- # /cohorte-init-pipeline + /cohorte-update-pipeline wire it (the npm CLI's installer offers a quick interview instead).
223
- seed_config() {
224
- base="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
225
- cfg="$base/cohorte.config.yaml"
226
- legacy=""
227
- for n in thebidouille.config.yaml; do
228
- [ -f "$base/$n" ] && { legacy="$base/$n"; break; }
229
- done
230
- if [ -f "$cfg" ]; then
231
- echo " · kept your existing $cfg"
232
- elif [ -n "$legacy" ]; then
233
- echo " · found legacy $legacy — kept as-is (read as a fallback)."
234
- echo " Run /cohorte-update-pipeline to migrate it into cohorte.config.yaml + wire the kanban."
235
- else
236
- mkdir -p "$base"
237
- cp "$src/profile/cohorte.config.template.yaml" "$cfg"
238
- echo " · seeded $cfg (disabled defaults — enable via /cohorte-init-pipeline or /cohorte-update-pipeline)"
239
- fi
240
- }
241
-
242
- # Register the profile-driven gate hook in the GLOBAL settings.json. Idempotent: the
243
- # hook reads each repo's own .claude/gate-config.json (and no-ops where absent),
244
- # so one registration serves every project.
245
- register_global_hook() {
246
- python3 - "$dest/settings.json" "$dest/hooks/gate.py" <<'PY'
247
- import json, sys
248
- settings, gate = sys.argv[1], sys.argv[2]
249
- # The matcher MUST cover Task as well as Bash: gate.py's preflight phase gate
250
- # keys off tool_name == "Task" (the `preflight` block in a repo's
251
- # gate-config.json). A Bash-only matcher never delivers a Task dispatch to the
252
- # hook, so that gate silently never fires — it was dead code from 1.3.0 to 1.3.1.
253
- MATCHER = "Bash|Task"
254
- base = gate.rsplit("/", 1)[-1]
255
-
256
-
257
- def is_gate(entry):
258
- # Trailing-quote tolerant: the Windows form is `py "C:\...\gate.py"`, and a
259
- # bare .endswith() missed it — which is how repeat installs accumulated a
260
- # duplicate registration every time (gate.py then ran once per copy).
261
- return any(
262
- (h.get("command") or "").strip().rstrip('"').endswith(base)
263
- for h in entry.get("hooks", [])
264
- )
265
-
266
-
267
- try:
268
- with open(settings) as fh:
269
- data = json.load(fh)
270
- if not isinstance(data, dict):
271
- data = {}
272
- except Exception:
273
- data = {}
274
- pre = data.setdefault("hooks", {}).setdefault("PreToolUse", [])
275
- # Reconcile rather than append-if-absent: drop every existing gate.py
276
- # registration, then add exactly one. Idempotent, collapses duplicates older
277
- # installers left behind, and upgrades a stale "Bash"-only matcher in place —
278
- # an append-if-absent would find the stale entry and skip, pinning the bug.
279
- kept = [e for e in pre if not is_gate(e)]
280
- kept.append({"matcher": MATCHER,
281
- "hooks": [{"type": "command", "command": "python3 " + gate}]})
282
- data["hooks"]["PreToolUse"] = kept
283
- with open(settings, "w") as fh:
284
- json.dump(data, fh, indent=2)
285
- fh.write("\n")
286
- print("ok")
287
- PY
288
- }
289
-
290
- # Bump only the core_version in a repo's committed .claude/pipeline.json (bundled mode).
291
- # Leaves every other field intact; no-ops if the pointer is absent or has no core_version.
292
- bump_pointer_version() {
293
- ptr="$1"; newver="$2"
294
- [ -f "$ptr" ] || return 0
295
- python3 - "$ptr" "$newver" <<'PY'
296
- import json, sys
297
- ptr, newver = sys.argv[1], sys.argv[2]
298
- try:
299
- with open(ptr) as fh:
300
- data = json.load(fh)
301
- except Exception:
302
- sys.exit(0)
303
- if isinstance(data, dict) and "core_version" in data:
304
- data["core_version"] = newver
305
- with open(ptr, "w") as fh:
306
- json.dump(data, fh, indent=2, ensure_ascii=False)
307
- fh.write("\n")
308
- PY
309
- }
310
-
311
- if [ "$scope" = "global" ]; then
312
- if [ "$mode" = "install" ]; then
313
- echo "→ installing pipeline core GLOBALLY into $dest"
314
- else
315
- echo "→ updating pipeline core GLOBALLY in $dest (keeping global settings.json)"
316
- fi
317
- copy_fixed_agents
318
- copy_core
319
- hook_state=$(register_global_hook || echo "skipped")
320
- seed_config
321
- cat <<EOF
322
-
323
- ✓ pipeline core installed globally into $dest (version $ver)
324
- gate hook: $hook_state (reads each repo's .claude/gate-config.json; silent where absent)
325
-
326
- The commands (/cohorte-init-pipeline, /cohorte-brainstorm, /cohorte-build …) and the review/release agents are now
327
- available in EVERY project on this machine — nothing is copied per repo.
328
-
329
- Per repo:
330
- 1. Open the project in Claude Code.
331
- 2. Run /cohorte-init-pipeline — it generates PIPELINE.md, renders the surface agents, writes
332
- .claude/gate-config.json, and drops a committed .claude/pipeline.json pointer so
333
- teammates know to install the global core ($REPO_URL).
334
- 3. Commit PIPELINE.md + .claude/, then /cohorte-brainstorm to start a feature.
335
-
336
- Code retrieval (Serena — the default provider /cohorte-init-pipeline wires per repo):
337
- uv tool install -p 3.13 serena-agent # once per machine
338
- Make sure ~/.local/bin is on PATH (uv tool update-shell) — otherwise the
339
- registered MCP server silently fails to start.
340
-
341
- Global kanban config, user-scoped — optional:
342
- · One consolidated file: ~/.claude/cohorte.config.yaml (don't hand-edit it).
343
- · /cohorte-init-pipeline (new project) and /cohorte-update-pipeline (existing) wire it for you: creating +
344
- syncing an Obsidian kanban board of the pipeline in your shared vault.
345
- EOF
346
- exit 0
347
- fi
348
-
349
- if [ "$mode" = "install" ]; then
350
- echo "→ installing pipeline core into $dest"
351
- copy_fixed_agents
352
- copy_core
353
- seed_config
354
- mkdir -p "$target/specs"
355
- [ -f "$target/specs/_template.md" ] || cp "$src/core/templates/spec.template.md" "$target/specs/_template.md"
356
- cat <<EOF
357
-
358
- ✓ pipeline core installed into $dest (version $ver)
359
-
360
- Next:
361
- 1. Open the project in Claude Code.
362
- 2. Run /cohorte-init-pipeline — it detects your stack, asks the gaps, and generates
363
- PIPELINE.md + renders one implementer agent per surface.
364
- 3. Commit PIPELINE.md, then /cohorte-brainstorm to start a feature.
365
-
366
- Code retrieval (Serena — the default provider /cohorte-init-pipeline wires per repo):
367
- uv tool install -p 3.13 serena-agent # once per machine
368
- Make sure ~/.local/bin is on PATH (uv tool update-shell) — otherwise the
369
- registered MCP server silently fails to start.
370
-
371
- Prefer one shared core across all your repos? Re-run with --global.
372
- EOF
373
- else
374
- echo "→ updating pipeline core in $dest (keeping your PIPELINE.md + rendered agents)"
375
- copy_core
376
- copy_fixed_agents 2>/dev/null || true
377
- seed_config
378
- bump_pointer_version "$dest/pipeline.json" "$ver"
379
- cat <<EOF
380
-
381
- ✓ core refreshed to $ver. Your PIPELINE.md, rendered surface agents, gate-config.json and
382
- settings.json were left as-is. Re-run /cohorte-init-pipeline if your stack changed.
383
- EOF
384
- fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cohorte",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "description": "Portable, stack-agnostic multi-agent development pipeline for Claude Code, Codex CLI, Cursor, Gemini CLI and OpenCode — install the core, run /cohorte-init-pipeline, and it adapts to your project's stack.",
5
5
  "bin": {
6
6
  "cohorte": "bin/cli.js"
package/profile/SCHEMA.md CHANGED
@@ -185,8 +185,13 @@ proposing a split: split the surface that actually dominates wall-clock, not the
185
185
  ## Measuring cost — what's slow vs what's expensive
186
186
 
187
187
  `pipeline-metrics.jsonl` records **wall-clock seconds** per phase batch (§Specialization) — it tells you
188
- what's SLOW. It deliberately does NOT record tokens: the lead can't reliably read a subagent's token count
189
- to log it. For what's EXPENSIVE, use Claude Code's own accounting:
188
+ what's SLOW. Tokens are recorded only where they can be read honestly: the **workflow paths**
189
+ (`loop.js`, `review.js`) stamp an approximate `tokens` field per batch from the runtime's own
190
+ counter (`budget.spent()` deltas), and the loop's return carries a per-round breakdown in its
191
+ `history`. The **conversational** commands still record none — a lead cannot reliably read a
192
+ subagent's token count, and a guessed number is worse than a missing one. The dashboard sums
193
+ whatever is stamped (a token-less line aggregates as 0, rendered as absent, never as "free").
194
+ For exact spend, use Claude Code's own accounting:
190
195
 
191
196
  - **`/cost`** (built-in, zero setup) — reports per-**subagent** and per-**slash-command** share of your usage
192
197
  over the last 24 h / 7 d (e.g. _"Top subagents: frontend 7 %, backend 4 % · Top skills: /cohorte-build 1 %,
@@ -237,11 +242,15 @@ it, the dashboard boards on it, and the kanban backfill maps it to a column. Six
237
242
  | `blocked` | a round gave up here (non-convergent, no verdict, not implementable) | an automated driver, if any | yes, with the reason named |
238
243
  | `shipped` | the PR is open; the status flip is part of the release commit | `/cohorte-ship` | no |
239
244
 
240
- **`in-progress` and `blocked` are for external drivers.** No shipped command writes them: the
241
- built-in autonomous driver (`/cohorte-loop`) was retired in 2.2.0, and the human-driven cycle moves
242
- `frozen` → `in-review` `shipped`. They stay valid states because specs in existing repos carry
243
- them, and because anything automating the cycle from outside needs somewhere to record "a round is
244
- under way" and "a round gave up". Every reader still routes on them; nothing produces them.
245
+ **`in-progress` and `blocked` are driver states.** No conversational command writes them the
246
+ human-driven cycle moves `frozen` → `in-review` `shipped`. Their producer is the **loop
247
+ workflow** (`core/workflows/loop.js`, `/cohorte-loop` the successor of the 2.2.0-retired
248
+ conversational driver): it stamps `in-progress` at each round, `in-review` when a run ends at
249
+ zero blocking findings, and `blocked` when a round gives up (non-convergent, unreviewed
250
+ surfaces, dead implementers, a contract-change finding), with the reason in
251
+ `specs/reports/<id>.loop.json`. External drivers may write them too. Every reader routes on
252
+ them either way, and a stamp on a spec with no front-matter stays a silent no-op — no driver
253
+ dies over a status line.
245
254
 
246
255
  **`kind` — feature (default) or `patch`.** Orthogonal to `status`, and the only other front-matter
247
256
  field commands route on. `/cohorte-patch` freezes `specs/patch-<slug>.md` with `kind: patch` from
@@ -348,11 +357,14 @@ project has *decided*. Without somewhere for those, every `/cohorte-spec` re-dis
348
357
  `specs/_decisions.md` (from `core/templates/decisions.template.md`) is that place, deliberately small:
349
358
 
350
359
  - **Append-only, one line per decision, ≤ ~160 chars:**
351
- `- <YYYY-MM-DD> · <area> · <decision> — because <reason> · <feature_id>`. Reversal never edits a line:
360
+ `- <YYYY-MM-DD> · <area> · <decision> — because <reason> · <origin>`, where `<origin>` is the
361
+ `feature_id` that decided it — or the originating command (`retro`) when no single feature owns
362
+ it. Reversal never edits a line:
352
363
  append a superseding one (`· supersedes <date> <area>`) and move the old one to `## Superseded`. When
353
364
  `## Live` passes ~100 lines, sweep the superseded ones down.
354
365
  - **Written by** `/cohorte-spec` at freeze (the decisions that outlive the feature — typically 0–3 lines, and
355
- zero is a normal outcome) and `/cohorte-build` §1.5 when it adds or splits a surface.
366
+ zero is a normal outcome), `/cohorte-build` §1.5 when it adds or splits a surface, and
367
+ `/cohorte-retro` §4 when the human ratifies a convention rule (one line per adopted rule).
356
368
  - **Read by the deciding stages only** — `/cohorte-brainstorm` (so the panel argues about the idea, not about
357
369
  settled ground), `/cohorte-spec` (so a new spec does not silently un-decide something), `/cohorte-audit` (standing
358
370
  decisions are part of the rulebook it audits against).
@@ -525,16 +537,21 @@ itself changes in ways `/cohorte-build` §1.5 can't auto-grow (e.g. package mana
525
537
 
526
538
  ## Workflows — deterministic multi-agent runs (opt-in)
527
539
 
528
- Three phases have a **workflow variant** a deterministic orchestration script the Claude Code
529
- Workflow runtime executes instead of the lead reasoning out the fan-out turn by turn:
530
- `<core>/workflows/review.js`, `audit.js`, `refactor.js` (installed to `<core>/workflows/` bundled or
531
- `<core>/workflows/` global). The conversational commands (`/cohorte-review`, `/cohorte-audit`, `/cohorte-refactor`)
540
+ Four scripts run under the Claude Code Workflow runtime instead of the lead reasoning out the
541
+ fan-out turn by turn: `<core>/workflows/review.js`, `audit.js`, `refactor.js` each the
542
+ **workflow variant** of its same-named conversational command — plus `loop.js`
543
+ (`/cohorte-loop`), which has **no conversational form at all** (below). For the variant pairs,
544
+ the conversational commands (`/cohorte-review`, `/cohorte-audit`, `/cohorte-refactor`)
532
545
  **remain the default path and the fallback** — a workflow runs only when the human explicitly asks
533
546
  for it ("run the review workflow"), and requires Claude Code ≥ **2.1.154** with workflows
534
547
  enabled.
535
548
  `/cohorte-doctor` reports which path a session will take. The interactive commands (`/cohorte-init-pipeline`,
536
549
  `/cohorte-brainstorm`, `/cohorte-spec`) and the dispatch-only ones (`/cohorte-build`, `/cohorte-ship`) have **no** workflow variant on
537
550
  purpose: they're interviews or already a single parallel dispatch — a script adds nothing.
551
+ `/cohorte-loop` does not change that: it **consumes** `/cohorte-build`'s outputs (the frozen
552
+ spec, `readiness.json`, the lead-authored contract, `build.json`) — it is not a build variant,
553
+ and adding one would put the lead-only steps (§1.5 reconcile, §2 contract authoring) inside a
554
+ script that cannot ask.
538
555
 
539
556
  Shared design, all four scripts:
540
557
 
@@ -564,10 +581,24 @@ Shared design, all four scripts:
564
581
  - **`refactor.js`** — big domains only (it skips domains with a handful of open items — the
565
582
  conversational `/cohorte-refactor` is cheaper there): `shared` first and alone, then the other domains'
566
583
  implementers in parallel, each verified per-domain.
584
+ - **`loop.js`** (`/cohorte-loop`) — build → review → [fix → review]* for ONE feature, unattended and
585
+ resumable. Preconditions it verifies and refuses to work around: frozen/`in-review` spec, a fresh
586
+ `readiness.json` at `READY`/`RESERVATIONS`, the contract on disk, every readiness surface owned by
587
+ the profile. Round exits, in order: child abort relayed → `unreviewed` non-empty → `blocking == 0`
588
+ ⇒ ship → same blocking-item identity two consecutive rounds ⇒ treading water → `maxRounds`
589
+ (default 5). A blocking finding on the contract file aborts (`contract-change` — lead-only, per
590
+ `/cohorte-fix` §1). It calls the review **workflow** per round and reads the same `verdict.json`
591
+ contract the conversational `/cohorte-review` §3 writes; it stamps `in-progress` while running,
592
+ `in-review` on ship, `blocked` on a give-up. **Workflow-only, no command file, ever**
593
+ (`validate-core.mjs` pins it): if the runtime is unavailable it refuses explicitly rather than
594
+ degrading to a lead re-reasoning the fan-out every round at session-model prices.
567
595
  - **No input mid-run.** A workflow runs to completion without questions; anything interactive
568
596
  (contract changes, human decisions) belongs to the conversational path. The gate hook still
569
597
  fires on workflow subagents (see
570
- §Preflight) — in unattended runs its asks become denies.
598
+ §Preflight) — in unattended runs its asks become denies. Know what that means for edits:
599
+ workflow subagents run in **`acceptEdits` whatever the session mode**, so for the length of a
600
+ run — and `loop.js` runs long, unattended stretches — `hooks/gate.py` is the only brake on
601
+ what agents write. That is stated here rather than discovered.
571
602
  - **Permissions:** `/cohorte-init-pipeline` and `/cohorte-update-pipeline` extend the generated `settings.json`
572
603
  `allow` list with what workflow agents need (the quiet commands, the shipped
573
604
  `pipeline/scripts/*.sh`, read-only git incl. `git rev-parse`, and the retrieval provider's MCP
@@ -1,7 +1,7 @@
1
1
  # ~/.claude/cohorte.config.yaml — GLOBAL, user-scoped config for the Cohorte
2
2
  # pipeline capabilities. One machine, every project. NOT tied to any project's PIPELINE.md.
3
3
  #
4
- # Seeded once by the installer (npx cohorte install) and never clobbered on update. Wired
4
+ # Seeded once by the installer (cohorte install) and never clobbered on update. Wired
5
5
  # interactively by /cohorte-init-pipeline (new project) and /cohorte-update-pipeline (existing project) —
6
6
  # you should never need to hand-edit it.
7
7
  #
@@ -44,11 +44,17 @@ else
44
44
  fi
45
45
  pr=""; title=""; project=""; profile="PIPELINE.md"
46
46
  while [ $# -gt 0 ]; do
47
+ # A flag with no value must exit 2 (usage) like every other usage error — a bare
48
+ # `shift 2` fails under set -e with exit 1, outside the 0/2/3 contract callers branch on.
47
49
  case "$1" in
48
- --pr) pr="${2-}"; shift 2 ;;
49
- --title) title="${2-}"; shift 2 ;;
50
- --project) project="${2-}"; shift 2 ;;
51
- --profile) profile="${2-}"; shift 2 ;;
50
+ --pr|--title|--project|--profile)
51
+ [ $# -ge 2 ] || { echo "error: $1 needs a value" >&2; echo "$usage" >&2; exit 2; } ;;
52
+ esac
53
+ case "$1" in
54
+ --pr) pr="$2"; shift 2 ;;
55
+ --title) title="$2"; shift 2 ;;
56
+ --project) project="$2"; shift 2 ;;
57
+ --profile) profile="$2"; shift 2 ;;
52
58
  *) echo "error: unknown flag $1" >&2; echo "$usage" >&2; exit 2 ;;
53
59
  esac
54
60
  done
@@ -246,7 +252,11 @@ ID="$id" COL="$col" PR="$pr" TITLE="${title:-$id}" awk '
246
252
  pr = ENVIRON["PR"]; title = ENVIRON["TITLE"]
247
253
  n = 0; card = ""; found = 0; colseen = 0; sn = 0; lastblank = 0
248
254
  }
249
- { lines[++n] = $0 }
255
+ # Strip CR at capture: a CRLF board (Windows-synced vault) otherwise fails the
256
+ # `h == col` heading compare and exits 3 on a column that exists — while the
257
+ # config parser in this same script already strips \r. Emitting LF-only is fine
258
+ # (Obsidian reads both).
259
+ { sub(/\r$/, ""); lines[++n] = $0 }
250
260
  END {
251
261
  # pass 1: extract the FIRST card block tagged #id; mark every block for removal
252
262
  for (i = 1; i <= n; i++) {
@@ -53,8 +53,15 @@ web_port=$((__WEB_PORT_BASE__ + slot))
53
53
 
54
54
  echo "→ feature '$id' → slot $slot (db $db_name · api :$api_port · web :$web_port)"
55
55
  echo "→ creating worktree $worktree_dir on $branch (off __DEFAULT_BRANCH__)"
56
+ # Branch off the REMOTE ref when it exists: `git fetch origin <branch>` updates
57
+ # origin/<branch>, never the local one, so branching off the local ref silently
58
+ # started every feature from wherever the local default branch last sat.
56
59
  git -C "$repo_root" fetch --quiet origin __DEFAULT_BRANCH__ || true
57
- git -C "$repo_root" worktree add -b "$branch" "$worktree_dir" __DEFAULT_BRANCH__
60
+ base=__DEFAULT_BRANCH__
61
+ if git -C "$repo_root" rev-parse --verify -q "origin/__DEFAULT_BRANCH__" >/dev/null 2>&1; then
62
+ base="origin/__DEFAULT_BRANCH__"
63
+ fi
64
+ git -C "$repo_root" worktree add -b "$branch" "$worktree_dir" "$base"
58
65
 
59
66
  # Replace (or append) KEY=VALUE in a dotenv file — one definitive line per key.
60
67
  set_env() {
@@ -43,6 +43,9 @@ for cmd in "$@"; do
43
43
  exit 1
44
44
  fi
45
45
  done
46
+ # All-empty arguments verified nothing — stamping green here would gate reviews on a
47
+ # check that never ran. Same failure as zero arguments, and the same exit.
48
+ [ "$n" -gt 0 ] || { echo "preflight: every command argument was empty — nothing was verified" >&2; exit 2; }
46
49
 
47
50
  # Stamp for the gate.py phase gate: epoch + HEAD sha of the checkout we verified.
48
51
  # The stamp MUST land where gate.py reads it: <main checkout>/.claude. CLAUDE_PROJECT_DIR
@@ -75,8 +78,13 @@ if [ -n "$tmpidx" ]; then
75
78
  # same window or they compute different trees for the same content. `date -d` is GNU and
76
79
  # `date -v` is BSD — try both, and if neither exists just skip the touch (the digest is
77
80
  # still correct for anything not edited in the last few seconds).
78
- stamp=$(date -u -d '5 seconds ago' +%Y%m%d%H%M.%S 2>/dev/null \
79
- || date -u -v-5S +%Y%m%d%H%M.%S 2>/dev/null || echo "")
81
+ # LOCAL time on purpose: `touch -t` interprets its stamp as local time, so a
82
+ # UTC-formatted stamp future-dates the index anywhere west of UTC — which makes
83
+ # git trust the stat cache of files edited THIS second, the exact race the
84
+ # backdate exists to prevent. (gate.py's side uses os.utime with an epoch, which
85
+ # has no timezone to get wrong.)
86
+ stamp=$(date -d '5 seconds ago' +%Y%m%d%H%M.%S 2>/dev/null \
87
+ || date -v-5S +%Y%m%d%H%M.%S 2>/dev/null || echo "")
80
88
  [ -n "$stamp" ] && touch -t "$stamp" "$tmpidx" 2>/dev/null || true
81
89
  else
82
90
  rm -f "$tmpidx" # a 0-byte index is a corrupt index
@@ -28,7 +28,9 @@ registry="$repo_root/.worktrees/slots.tsv"
28
28
  branch="__BRANCH_PREFIX__${id}"
29
29
 
30
30
  echo "→ removing worktree $worktree_dir"
31
- if git -C "$repo_root" worktree list | grep -q "$worktree_dir"; then
31
+ # Porcelain + fixed-string + whole-line: the human-readable list matched any worktree
32
+ # whose path merely CONTAINS this one (auth vs auth-2fa), and dots in the path were regex.
33
+ if git -C "$repo_root" worktree list --porcelain | grep -Fxq "worktree $worktree_dir"; then
32
34
  git -C "$repo_root" worktree remove "$worktree_dir" "${@:3}"
33
35
  else
34
36
  echo " (no such worktree registered — skipping)"