cohorte 1.3.2 → 1.3.3

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 CHANGED
@@ -3,6 +3,35 @@
3
3
  Entries are shown by `/update-pipeline` ("What's new") after a core refresh. Keep them short,
4
4
  user-facing, most recent first. One `## <version> — <YYYY-MM-DD>` section per release.
5
5
 
6
+ ## 1.3.3 — 2026-07-30
7
+
8
+ > **Re-run `npx cohorte@latest update --global` (or `update`)** — the gate fixes only apply once
9
+ > the installed `hooks/gate.py` is refreshed.
10
+
11
+ - **The cycle workflow could exit SHIP-READY with open findings.** A round with only HIGH/MEDIUM
12
+ findings scored `SHIP`, broke the loop, ticked the DoD and stamped the freshness gate — making
13
+ `/ship` a straight shot over unfixed findings, against the workflow's own "zero open findings"
14
+ contract. The exit condition is now literally zero open findings + a smoke PASS.
15
+ - **Dead implementers went undetected in the cycle workflow.** `agent()` returns `null` when a
16
+ subagent dies, but the build fan-out wrapped every result in a truthy object before the check —
17
+ so the "implementer(s) died" question never fired and build telemetry always said `ok`.
18
+ - **`gate.py` gated worktree commands as if they ran on the default branch.** Branch and HEAD were
19
+ resolved in `CLAUDE_PROJECT_DIR` (the main checkout, usually on `main`) instead of where the
20
+ command actually runs — so in a feature worktree, every `ask_on_default_branch` pattern
21
+ prompted, and the preflight HEAD-moved check compared against the wrong checkout. Git state now
22
+ resolves at the hook payload's `cwd`.
23
+ - **The preflight phase gate hung headless runs.** The bypassPermissions "nobody can answer an
24
+ ask ⇒ deny" escalation only covered Bash patterns; a review/smoke Task dispatch with a stale
25
+ stamp still emitted an unanswerable `ask`. The phase gate now escalates the same way.
26
+ - **`/init-pipeline` bundled installs registered the gate with the dead `Bash`-only matcher** —
27
+ the exact bug 1.3.2 fixed in the installers lived on in the template — and never dropped an
28
+ existing registration, so a bundled repo later switched to global ran the gate twice per
29
+ command. The template now mandates `Bash|Task` and a reconcile.
30
+ - Smaller cycle-workflow fixes: smoke telemetry reports the real failure count (was always 0 —
31
+ it filtered on a `kind` value that doesn't exist); a run whose last round ends on a red
32
+ preflight now flags that the reported findings are from the previous round; a malformed
33
+ preflight stamp says "unreadable" instead of "not found".
34
+
6
35
  ## 1.3.2 — 2026-07-30
7
36
 
8
37
  > **Re-run `npx cohorte@latest update --global` (or `update`).** This release repairs the gate
@@ -48,9 +48,12 @@ SPLIT = re.compile(r"&&|\|\||[;|\n]")
48
48
  WS = re.compile(r"\s+")
49
49
 
50
50
 
51
+ def project_root() -> str:
52
+ return os.environ.get("CLAUDE_PROJECT_DIR", ".")
53
+
54
+
51
55
  def load_config() -> dict:
52
- root = os.environ.get("CLAUDE_PROJECT_DIR", ".")
53
- path = os.path.join(root, ".claude", "gate-config.json")
56
+ path = os.path.join(project_root(), ".claude", "gate-config.json")
54
57
  empty = {"deny": [], "ask": [], "ask_on_default_branch": [], "default_branch": "main",
55
58
  "preflight": {}}
56
59
  try:
@@ -76,13 +79,19 @@ def norm(s: str) -> str:
76
79
  return WS.sub(" ", s.strip())
77
80
 
78
81
 
79
- def current_branch():
80
- """The checked-out branch of CLAUDE_PROJECT_DIR, or None (not a repo / detached / git absent)."""
81
- root = os.environ.get("CLAUDE_PROJECT_DIR", ".")
82
+ def session_cwd(payload: dict) -> str:
83
+ """Where the gated command actually runs. A feature worktree is its own
84
+ checkout resolving git state in CLAUDE_PROJECT_DIR (the main checkout)
85
+ would gate every worktree command as if it ran on the default branch."""
86
+ return payload.get("cwd") or project_root()
87
+
88
+
89
+ def current_branch(cwd: str):
90
+ """The checked-out branch at `cwd`, or None (not a repo / detached / git absent)."""
82
91
  try:
83
92
  out = subprocess.run(
84
93
  ["git", "rev-parse", "--abbrev-ref", "HEAD"],
85
- cwd=root, capture_output=True, text=True, timeout=3,
94
+ cwd=cwd, capture_output=True, text=True, timeout=3,
86
95
  )
87
96
  if out.returncode == 0:
88
97
  return out.stdout.strip() or None
@@ -91,13 +100,12 @@ def current_branch():
91
100
  return None
92
101
 
93
102
 
94
- def current_head():
95
- """HEAD sha of CLAUDE_PROJECT_DIR, or None."""
96
- root = os.environ.get("CLAUDE_PROJECT_DIR", ".")
103
+ def current_head(cwd: str):
104
+ """HEAD sha at `cwd`, or None."""
97
105
  try:
98
106
  out = subprocess.run(
99
107
  ["git", "rev-parse", "HEAD"],
100
- cwd=root, capture_output=True, text=True, timeout=3,
108
+ cwd=cwd, capture_output=True, text=True, timeout=3,
101
109
  )
102
110
  if out.returncode == 0:
103
111
  return out.stdout.strip() or None
@@ -116,29 +124,37 @@ def check_preflight(payload: dict, cfg: dict) -> int:
116
124
  if subagent not in agents:
117
125
  return 0
118
126
 
119
- root = os.environ.get("CLAUDE_PROJECT_DIR", ".")
120
- stamp = os.path.join(root, ".claude", "preflight.ok")
127
+ stamp = os.path.join(project_root(), ".claude", "preflight.ok")
121
128
  why = None
122
129
  try:
123
130
  with open(stamp, "r", encoding="utf-8") as fh:
124
- epoch_s, _, sha = fh.read().strip().partition(" ")
125
- age_min = (time.time() - float(epoch_s)) / 60
126
- max_age = float(pf.get("max_age_minutes", 30) or 30)
127
- if age_min > max_age:
128
- why = f"the preflight stamp is {age_min:.0f} min old (max {max_age:.0f})"
129
- else:
130
- head = current_head()
131
- if head and sha not in ("", "none") and head != sha:
132
- why = "HEAD moved since the preflight ran"
131
+ raw = fh.read().strip()
133
132
  except Exception:
133
+ raw = None
134
134
  why = "no preflight stamp found"
135
+ if raw is not None:
136
+ try:
137
+ epoch_s, _, sha = raw.partition(" ")
138
+ age_min = (time.time() - float(epoch_s)) / 60
139
+ max_age = float(pf.get("max_age_minutes", 30) or 30)
140
+ if age_min > max_age:
141
+ why = f"the preflight stamp is {age_min:.0f} min old (max {max_age:.0f})"
142
+ else:
143
+ head = current_head(session_cwd(payload))
144
+ if head and sha not in ("", "none") and head != sha:
145
+ why = "HEAD moved since the preflight ran"
146
+ except Exception:
147
+ why = "the preflight stamp is unreadable (expected `<epoch> <sha>`)"
135
148
  if why is None:
136
149
  return 0
150
+ # Same rule as the Bash gate: unattended runs have nobody to answer an "ask".
151
+ unattended = payload.get("permission_mode") == "bypassPermissions"
137
152
  return decide(
138
- "ask",
153
+ "deny" if unattended else "ask",
139
154
  f"Phase gate: dispatching `{subagent}` but {why}. Run the deterministic pre-flight first "
140
155
  f"(pipeline/scripts/preflight.sh — typecheck + lint + tests) so agents never review red code; "
141
- f"or confirm to dispatch anyway (PIPELINE.md gate.preflight).",
156
+ f"or confirm to dispatch anyway (PIPELINE.md gate.preflight)."
157
+ + (" (denied outright: unattended run, nobody to confirm)" if unattended else ""),
142
158
  )
143
159
 
144
160
 
@@ -173,7 +189,7 @@ def main() -> int:
173
189
  # be conservative and gate. Resolve the branch once, lazily.
174
190
  on_default = False
175
191
  if branch_gated:
176
- branch = current_branch()
192
+ branch = current_branch(session_cwd(payload))
177
193
  on_default = branch is None or branch == default
178
194
 
179
195
  for raw in SPLIT.split(command):
@@ -30,8 +30,12 @@
30
30
  retrieval provider's MCP tools when wired (e.g. `mcp__serena`). Never allowlist anything matching
31
31
  a `gate.ask`/`gate.deny` pattern. Mention the human can widen it later with
32
32
  `/fewer-permission-prompts`) + the hooks, **conditioned on the install mode:**
33
- - **bundled:** register the PreToolUse `Bash` hook `.claude/hooks/gate.py` and the PostToolUse
34
- formatter (detected formatter).
33
+ - **bundled:** register the PreToolUse hook `.claude/hooks/gate.py` with matcher `Bash|Task`
34
+ (Task is required — the preflight phase gate keys off Task dispatches; a `Bash`-only matcher
35
+ leaves it dead) and the PostToolUse formatter (detected formatter). Before adding, drop any
36
+ existing PreToolUse entry whose command ends in `gate.py` (here AND in `~/.claude/settings.json`
37
+ if one points at this repo's copy) — exactly one registration must survive, or every gated
38
+ command prompts twice.
35
39
  - **global:** the PreToolUse gate hook is
36
40
  already in `~/.claude/settings.json` and reads this repo's `gate-config.json` — do **not** re-register
37
41
  it here (double-registration double-prompts). It no-ops where its config is absent, so one
@@ -184,7 +184,9 @@ const buildPrompt = s =>
184
184
  'your surface): none'
185
185
  const handoffs = await parallel(surfaces.map(s => () =>
186
186
  agent(buildPrompt(s), { agentType: s.agent, label: `build:${s.key}`, phase: 'Build' })
187
- .then(h => ({ key: s.key, handoff: h }))))
187
+ // agent() resolves to null (never throws) when a subagent dies — wrapping
188
+ // unconditionally would hide every death from the `dead` check below.
189
+ .then(h => (h == null ? null : { key: s.key, handoff: h }))))
188
190
  const built = handoffs.filter(Boolean)
189
191
  const dead = surfaces.filter(s => !built.some(b => b.key === s.key)).map(s => s.key)
190
192
  if (dead.length) questions.push(`implementer(s) died during build: ${dead.join(', ')} — inspect and re-run /build if their surface matters`)
@@ -207,8 +209,10 @@ const runPreflight = () => agent(
207
209
 
208
210
  let verdict = null
209
211
  let smokePass = false
212
+ let smokeFails = [] // last round's smoke failures — Close reports the real count
210
213
  let open = [] // findings still open, each {severity,file,line,kind,problem,fix,src}
211
214
  let rounds = 0
215
+ let preflightRed = false // did the LAST round end on a red preflight (open/verdict then stale)?
212
216
 
213
217
  // ── Phases 4/5 — bounded verify → fix rounds ────────────────────────────────
214
218
  while (rounds < MAX_ROUNDS && (!budget.total || budget.remaining() > 30000)) {
@@ -218,7 +222,8 @@ while (rounds < MAX_ROUNDS && (!budget.total || budget.remaining() > 30000)) {
218
222
 
219
223
  // 4a. preflight — mechanical red short-circuits straight to a fix round
220
224
  const pre = await runPreflight()
221
- if (!pre || !pre.pass) {
225
+ preflightRed = !pre || !pre.pass
226
+ if (preflightRed) {
222
227
  const tail = (pre && pre.tail) || ''
223
228
  const hit = new Set(surfaces.filter(s => tail.includes(String(s.path))).map(s => s.key))
224
229
  const targets = hit.size ? [...hit] : built.map(b => b.key)
@@ -281,13 +286,15 @@ while (rounds < MAX_ROUNDS && (!budget.total || budget.remaining() > 30000)) {
281
286
  ])
282
287
 
283
288
  smokePass = !!(smoke && smoke.pass)
284
- const smokeFails = (smoke && smoke.failures) || []
289
+ smokeFails = (smoke && smoke.failures) || []
285
290
  open = (reviewed || []).filter(Boolean).flatMap(r => r.kept.map(f => ({ ...f, src: r.key })))
286
291
  verdict = open.some(f => f.kind === 'security') ? 'BLOCK'
287
- : open.some(f => f.severity === 'CRITICAL') ? 'REVISE' : 'SHIP'
292
+ : open.length ? 'REVISE' : 'SHIP'
288
293
  log(`Round ${rounds}: review ${verdict} (${open.length} finding(s)) · smoke ${smokePass ? 'PASS' : `FAIL:${smokeFails.length}`}`)
289
294
 
290
- if (verdict === 'SHIP' && smokePass) break
295
+ // The loop's contract is ZERO open findings + PASS — a SHIP verdict alone
296
+ // (which older revisions granted despite HIGH/MEDIUM leftovers) is not enough.
297
+ if (!open.length && smokePass) break
291
298
  if (rounds >= MAX_ROUNDS) break
292
299
 
293
300
  // 5. fix round. Contract-impacting findings stay INSIDE the loop: a
@@ -343,6 +350,9 @@ while (rounds < MAX_ROUNDS && (!budget.total || budget.remaining() > 30000)) {
343
350
  { agentType: byKey[k].agent, label: `fix:${k}`, phase: 'Fix' })))
344
351
  }
345
352
 
353
+ if (preflightRed) {
354
+ questions.push('the last round ended on a RED preflight — the reported verdict/findings are from the previous round and may already be fixed; rerun /review after the mechanical fixes land')
355
+ }
346
356
  if (rounds >= MAX_ROUNDS && !(verdict === 'SHIP' && smokePass)) {
347
357
  questions.push(`round cap (${MAX_ROUNDS}) reached with ${open.length} finding(s) open — rerun the cycle (maxRounds higher) or continue with /fix ${feature} + /review`)
348
358
  }
@@ -376,7 +386,7 @@ await agent(
376
386
  `{"ts":"<ISO now>","feature":"${feature}","phase":"cycle","seconds":0,"surfaces":{"rounds":"${rounds}","verdict":"${verdict || 'none'}:${open.length}","smoke":"${smokePass ? 'PASS' : 'FAIL'}"}}\n` +
377
387
  '5. Chain the opt-in usage pings (all funnel phases this run executed, 0 seconds each): ' +
378
388
  `<core>/pipeline/scripts/telemetry-send.sh build "${feature}" 0 "${built.map(() => 'ok').join(',') || 'error'}" || true; ` +
379
- `<core>/pipeline/scripts/telemetry-send.sh smoke "${feature}" 0 "${smokePass ? 'PASS' : 'FAIL:' + open.filter(f => f.kind === 'runtime').length}" || true; ` +
389
+ `<core>/pipeline/scripts/telemetry-send.sh smoke "${feature}" 0 "${smokePass ? 'PASS' : 'FAIL:' + smokeFails.length}" || true; ` +
380
390
  `<core>/pipeline/scripts/telemetry-send.sh review "${feature}" 0 "${verdict || 'none'}:${open.length}" || true\n` +
381
391
  'Return the single word: done.',
382
392
  { model: 'haiku', label: 'close', effort: 'low' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cohorte",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Portable, stack-agnostic multi-agent development pipeline for Claude Code — install the core, run /init-pipeline, and it adapts to your project's stack.",
5
5
  "bin": {
6
6
  "cohorte": "bin/cli.js"