cohorte 2.7.0 → 2.8.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/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.8.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
@@ -237,11 +237,15 @@ it, the dashboard boards on it, and the kanban backfill maps it to a column. Six
237
237
  | `blocked` | a round gave up here (non-convergent, no verdict, not implementable) | an automated driver, if any | yes, with the reason named |
238
238
  | `shipped` | the PR is open; the status flip is part of the release commit | `/cohorte-ship` | no |
239
239
 
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.
240
+ **`in-progress` and `blocked` are driver states.** No conversational command writes them the
241
+ human-driven cycle moves `frozen` → `in-review` `shipped`. Their producer is the **loop
242
+ workflow** (`core/workflows/loop.js`, `/cohorte-loop` the successor of the 2.2.0-retired
243
+ conversational driver): it stamps `in-progress` at each round, `in-review` when a run ends at
244
+ zero blocking findings, and `blocked` when a round gives up (non-convergent, unreviewed
245
+ surfaces, dead implementers, a contract-change finding), with the reason in
246
+ `specs/reports/<id>.loop.json`. External drivers may write them too. Every reader routes on
247
+ them either way, and a stamp on a spec with no front-matter stays a silent no-op — no driver
248
+ dies over a status line.
245
249
 
246
250
  **`kind` — feature (default) or `patch`.** Orthogonal to `status`, and the only other front-matter
247
251
  field commands route on. `/cohorte-patch` freezes `specs/patch-<slug>.md` with `kind: patch` from
@@ -525,16 +529,21 @@ itself changes in ways `/cohorte-build` §1.5 can't auto-grow (e.g. package mana
525
529
 
526
530
  ## Workflows — deterministic multi-agent runs (opt-in)
527
531
 
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`)
532
+ Four scripts run under the Claude Code Workflow runtime instead of the lead reasoning out the
533
+ fan-out turn by turn: `<core>/workflows/review.js`, `audit.js`, `refactor.js` each the
534
+ **workflow variant** of its same-named conversational command — plus `loop.js`
535
+ (`/cohorte-loop`), which has **no conversational form at all** (below). For the variant pairs,
536
+ the conversational commands (`/cohorte-review`, `/cohorte-audit`, `/cohorte-refactor`)
532
537
  **remain the default path and the fallback** — a workflow runs only when the human explicitly asks
533
538
  for it ("run the review workflow"), and requires Claude Code ≥ **2.1.154** with workflows
534
539
  enabled.
535
540
  `/cohorte-doctor` reports which path a session will take. The interactive commands (`/cohorte-init-pipeline`,
536
541
  `/cohorte-brainstorm`, `/cohorte-spec`) and the dispatch-only ones (`/cohorte-build`, `/cohorte-ship`) have **no** workflow variant on
537
542
  purpose: they're interviews or already a single parallel dispatch — a script adds nothing.
543
+ `/cohorte-loop` does not change that: it **consumes** `/cohorte-build`'s outputs (the frozen
544
+ spec, `readiness.json`, the lead-authored contract, `build.json`) — it is not a build variant,
545
+ and adding one would put the lead-only steps (§1.5 reconcile, §2 contract authoring) inside a
546
+ script that cannot ask.
538
547
 
539
548
  Shared design, all four scripts:
540
549
 
@@ -564,10 +573,24 @@ Shared design, all four scripts:
564
573
  - **`refactor.js`** — big domains only (it skips domains with a handful of open items — the
565
574
  conversational `/cohorte-refactor` is cheaper there): `shared` first and alone, then the other domains'
566
575
  implementers in parallel, each verified per-domain.
576
+ - **`loop.js`** (`/cohorte-loop`) — build → review → [fix → review]* for ONE feature, unattended and
577
+ resumable. Preconditions it verifies and refuses to work around: frozen/`in-review` spec, a fresh
578
+ `readiness.json` at `READY`/`RESERVATIONS`, the contract on disk, every readiness surface owned by
579
+ the profile. Round exits, in order: child abort relayed → `unreviewed` non-empty → `blocking == 0`
580
+ ⇒ ship → same blocking-item identity two consecutive rounds ⇒ treading water → `maxRounds`
581
+ (default 5). A blocking finding on the contract file aborts (`contract-change` — lead-only, per
582
+ `/cohorte-fix` §1). It calls the review **workflow** per round and reads the same `verdict.json`
583
+ contract the conversational `/cohorte-review` §3 writes; it stamps `in-progress` while running,
584
+ `in-review` on ship, `blocked` on a give-up. **Workflow-only, no command file, ever**
585
+ (`validate-core.mjs` pins it): if the runtime is unavailable it refuses explicitly rather than
586
+ degrading to a lead re-reasoning the fan-out every round at session-model prices.
567
587
  - **No input mid-run.** A workflow runs to completion without questions; anything interactive
568
588
  (contract changes, human decisions) belongs to the conversational path. The gate hook still
569
589
  fires on workflow subagents (see
570
- §Preflight) — in unattended runs its asks become denies.
590
+ §Preflight) — in unattended runs its asks become denies. Know what that means for edits:
591
+ workflow subagents run in **`acceptEdits` whatever the session mode**, so for the length of a
592
+ run — and `loop.js` runs long, unattended stretches — `hooks/gate.py` is the only brake on
593
+ what agents write. That is stated here rather than discovered.
571
594
  - **Permissions:** `/cohorte-init-pipeline` and `/cohorte-update-pipeline` extend the generated `settings.json`
572
595
  `allow` list with what workflow agents need (the quiet commands, the shipped
573
596
  `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)"
@@ -184,7 +184,7 @@ console.log("doctor.js — the /cohorte-doctor port");
184
184
  writeFileSync(join(d, ".claude", "gate-config.json"), JSON.stringify(gate));
185
185
  writeFileSync(join(d, ".mcp.json"), JSON.stringify({ mcpServers: { serena: {} } }));
186
186
  mkdirSync(join(d, ".claude", "workflows"), { recursive: true });
187
- for (const w of ["review.js", "audit.js", "refactor.js"]) {
187
+ for (const w of ["review.js", "audit.js", "refactor.js", "loop.js"]) {
188
188
  writeFileSync(join(d, ".claude", "workflows", w), "x");
189
189
  }
190
190
  writeFileSync(join(d, ".claude", "agents", "profile-reader.md"), "x");
@@ -472,6 +472,47 @@ console.log("doctor.js — a non-Claude runtime layout");
472
472
  metrics({ projectRoot: d, globalDir: g }).batches === 1);
473
473
  }
474
474
 
475
+ // ── runtime.js — stale absolute registry paths (a cloned/moved bundled core) ─
476
+ // runtimes.json records install-time ABSOLUTE paths. A committed core cloned to
477
+ // another machine (or a checkout simply moved) still carries the original paths;
478
+ // taken verbatim, every check went red on a healthy install.
479
+ console.log("runtime.js — registry paths survive a clone/move");
480
+ {
481
+ const { layouts } = require(join(root, "dashboard/server/runtime.js"));
482
+ const d = scratch();
483
+ const core = join(d, ".claude");
484
+ mkdirSync(join(core, "pipeline"), { recursive: true });
485
+ const theirRoot = join("/Users", "somebody-else", "their-checkout");
486
+ writeFileSync(join(core, "pipeline", "runtimes.json"), JSON.stringify({
487
+ claude: {
488
+ label: "Claude Code", scope: "project", core_version: "9.9.9",
489
+ paths: {
490
+ core: join(theirRoot, ".claude"),
491
+ commands: join(theirRoot, ".claude", "commands"),
492
+ agents: join(theirRoot, ".claude", "agents"),
493
+ hooks_config: join(theirRoot, ".claude", "settings.json"),
494
+ state: ".claude",
495
+ },
496
+ },
497
+ }));
498
+ const [l] = layouts({ projectRoot: d, globalDir: join(d, "no-global") });
499
+ check("agents re-rooted onto the probed checkout",
500
+ l.agents === join(d, ".claude", "agents"), l.agents);
501
+ check("hooks config re-rooted too",
502
+ l.hooksConfig === join(d, ".claude", "settings.json"), l.hooksConfig);
503
+ // A path OUTSIDE the recorded project root (a genuine machine-local absolute,
504
+ // e.g. a global agents dir) must pass through untouched.
505
+ writeFileSync(join(core, "pipeline", "runtimes.json"), JSON.stringify({
506
+ claude: {
507
+ label: "Claude Code", scope: "project", core_version: "9.9.9",
508
+ paths: { core: join(theirRoot, ".claude"), agents: "/opt/shared-agents", state: ".claude" },
509
+ },
510
+ }));
511
+ const [l2] = layouts({ projectRoot: d, globalDir: join(d, "no-global") });
512
+ check("an absolute path outside the recorded root is untouched",
513
+ l2.agents === "/opt/shared-agents", l2.agents);
514
+ }
515
+
475
516
  for (const d of tmps) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
476
517
  console.log("");
477
518
  if (failures) { console.error(`test-dashboard: ${failures} failure(s)`); process.exit(1); }
@@ -112,6 +112,12 @@ console.log("gate.py — Bash command gating");
112
112
  run(bash("node ace migration:run"), at()).decision === "ask");
113
113
  check("deny wins over ask on the same segment",
114
114
  run(bash("node ace migration:fresh"), at()).decision === "deny");
115
+ // Deny must win ACROSS segments too: with segment-order scanning, the benign ask
116
+ // surfaced first and the human's one confirm ran the hard-denied command behind it.
117
+ check("deny in a LATER segment wins over an earlier ask segment",
118
+ run(bash("node ace migration:run && node ace migration:fresh"), at()).decision === "deny");
119
+ check("deny in a later segment wins over an earlier branch-gated ask",
120
+ run(bash("git commit -m x && node ace db:wipe"), at()).decision === "deny");
115
121
  // Matching is substring-on-the-whole-pattern, so a partial overlap is NOT a
116
122
  // match — `migration:run` alone does not trigger `node ace migration:run`.
117
123
  check("a partial overlap of a pattern does not gate",