cohorte 1.3.1 → 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 +54 -0
- package/README.md +3 -0
- package/bin/cli.js +32 -14
- package/core/hooks/gate.py +40 -24
- package/core/templates/steps/init-pipeline/04-write-render.md +6 -2
- package/core/workflows/cycle.js +16 -6
- package/install.ps1 +18 -10
- package/install.sh +26 -11
- package/package.json +1 -1
- package/scripts/assert-gate-hook.mjs +75 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,60 @@
|
|
|
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
|
+
|
|
35
|
+
## 1.3.2 — 2026-07-30
|
|
36
|
+
|
|
37
|
+
> **Re-run `npx cohorte@latest update --global` (or `update`).** This release repairs the gate
|
|
38
|
+
> hook registration in place — updating is what applies it.
|
|
39
|
+
|
|
40
|
+
- **1.3.0's preflight phase gate never fired on any install.** `gate.py` gates review/smoke
|
|
41
|
+
dispatches on `tool_name == "Task"`, but all three installers registered the hook with
|
|
42
|
+
`matcher: "Bash"` — a Task call never reached it. The `preflight` block in `gate-config.json`
|
|
43
|
+
and `gate.preflight` in `PIPELINE.md` were both dead config. The matcher is now `Bash|Task`.
|
|
44
|
+
- **Re-installing duplicated the hook, every time.** The "already registered?" test was
|
|
45
|
+
`command.endswith("gate.py")`, which is false for the Windows form `py "C:\…\gate.py"` because
|
|
46
|
+
of the trailing quote — so `install.sh` and `bin/cli.js` appended another copy on each run, and
|
|
47
|
+
`gate.py` ran once per copy on every Bash call (four copies seen in the wild). Registration is
|
|
48
|
+
now a **reconcile**: it drops every existing `gate.py` entry and writes exactly one. Idempotent,
|
|
49
|
+
it collapses the duplicates you already have, and it upgrades the stale matcher — an
|
|
50
|
+
append-if-absent would have found the stale entry and skipped, pinning the bug forever.
|
|
51
|
+
Unrelated hooks and every other settings key are untouched.
|
|
52
|
+
- **`npx cohorte update` never touched the hook at all**, so neither fix above could have reached
|
|
53
|
+
you through the command you actually run to get fixes — only a full re-install rewrote it.
|
|
54
|
+
`install.sh` and `install.ps1` always registered on update; this port had drifted (the same
|
|
55
|
+
class of drift as 1.2.4 and 1.2.6). It now registers on both paths.
|
|
56
|
+
- CI installs **twice** before asserting the hook, via a new `scripts/assert-gate-hook.mjs`:
|
|
57
|
+
exactly one registration, matcher covering both Bash and Task. A single install could never
|
|
58
|
+
surface the duplication — which is precisely why CI stayed green while it shipped.
|
|
59
|
+
|
|
6
60
|
## 1.3.1 — 2026-07-30
|
|
7
61
|
|
|
8
62
|
- **`/cycle <feature_id> [max_rounds]`** — a launcher command for the full dev-cycle workflow,
|
package/README.md
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
[](https://github.com/TheBidouilleAgency/cohorte/actions/workflows/publish.yml)
|
|
8
8
|
[](https://nodejs.org)
|
|
9
9
|
[](LICENSE)
|
|
10
|
+
[](https://thebidouilleagency.github.io/cohorte/)
|
|
11
|
+
|
|
12
|
+
**[📖 Full documentation](https://thebidouilleagency.github.io/cohorte/)** — guides (feature cycle, workflows, token economy, parallel features) + complete reference (commands, agents, profile, gate, scripts).
|
|
10
13
|
|
|
11
14
|
</div>
|
|
12
15
|
|
package/bin/cli.js
CHANGED
|
@@ -21,6 +21,13 @@ const VERSION = pkg.version;
|
|
|
21
21
|
|
|
22
22
|
const REPO_URL = 'https://github.com/TheBidouilleAgency/cohorte';
|
|
23
23
|
|
|
24
|
+
// PreToolUse matcher for the gate hook. MUST cover Task as well as Bash:
|
|
25
|
+
// gate.py's preflight phase gate keys off tool_name === "Task" (the `preflight`
|
|
26
|
+
// block in a repo's gate-config.json). A Bash-only matcher never delivers a Task
|
|
27
|
+
// dispatch to the hook, so that gate silently never fires — it was dead code
|
|
28
|
+
// from 1.3.0 to 1.3.1. Keep in lockstep with install.sh and install.ps1.
|
|
29
|
+
const GATE_MATCHER = 'Bash|Task';
|
|
30
|
+
|
|
24
31
|
function usage(code) {
|
|
25
32
|
console.log(`cohorte v${VERSION}
|
|
26
33
|
|
|
@@ -274,19 +281,24 @@ function registerGlobalHook() {
|
|
|
274
281
|
} catch { /* absent or invalid → start fresh */ }
|
|
275
282
|
if (!data.hooks || typeof data.hooks !== 'object') data.hooks = {};
|
|
276
283
|
if (!Array.isArray(data.hooks.PreToolUse)) data.hooks.PreToolUse = [];
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
284
|
+
|
|
285
|
+
const file = path.join(dest, 'hooks', 'gate.py');
|
|
286
|
+
const base = path.basename(file);
|
|
287
|
+
// Trailing-quote tolerant: the Windows form is `py "C:\...\gate.py"`, and a
|
|
288
|
+
// bare .endsWith() missed it — which is how repeat `npx cohorte install`
|
|
289
|
+
// runs accumulated a duplicate registration every time (gate.py then ran
|
|
290
|
+
// once per copy on every Bash call).
|
|
291
|
+
const isGate = entry => (entry.hooks || []).some(
|
|
292
|
+
h => typeof h.command === 'string' && h.command.trim().replace(/"+$/, '').endsWith(base));
|
|
293
|
+
const cmd = process.platform === 'win32' ? `${python} "${file}"` : `${python} ${file}`;
|
|
294
|
+
|
|
295
|
+
// Reconcile rather than append-if-absent: drop every existing gate.py
|
|
296
|
+
// registration, then add exactly one. Idempotent, collapses duplicates older
|
|
297
|
+
// installers left behind, and upgrades a stale "Bash"-only matcher in place —
|
|
298
|
+
// an append-if-absent would find the stale entry and skip, pinning the bug.
|
|
299
|
+
data.hooks.PreToolUse = data.hooks.PreToolUse.filter(e => !isGate(e));
|
|
300
|
+
data.hooks.PreToolUse.push({ matcher: GATE_MATCHER, hooks: [{ type: 'command', command: cmd }] });
|
|
301
|
+
|
|
290
302
|
fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2) + '\n');
|
|
291
303
|
return 'ok';
|
|
292
304
|
}
|
|
@@ -311,7 +323,13 @@ if (scope === 'global') {
|
|
|
311
323
|
: `→ updating pipeline core GLOBALLY in ${dest} (keeping global settings.json)`);
|
|
312
324
|
copyFixedAgents();
|
|
313
325
|
copyCore();
|
|
314
|
-
|
|
326
|
+
// Register on UPDATE too — install.sh and install.ps1 always have, and this
|
|
327
|
+
// port skipping it is why a duplicated or stale-matcher registration could
|
|
328
|
+
// never be repaired by `npx cohorte update`: the only route that rewrites it
|
|
329
|
+
// was a full re-install, which is not what anyone runs to get a fix. Safe to
|
|
330
|
+
// run every time — registration reconciles only gate.py entries and leaves
|
|
331
|
+
// every other hook and settings key untouched.
|
|
332
|
+
const hookState = registerGlobalHook();
|
|
315
333
|
await seedConfig();
|
|
316
334
|
console.log(`
|
|
317
335
|
✓ pipeline core installed globally into ${dest} (version ${VERSION})
|
package/core/hooks/gate.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
80
|
-
"""
|
|
81
|
-
|
|
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=
|
|
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
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
34
|
-
|
|
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
|
package/core/workflows/cycle.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:' +
|
|
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/install.ps1
CHANGED
|
@@ -251,23 +251,31 @@ try {
|
|
|
251
251
|
$data.hooks | Add-Member -NotePropertyName PreToolUse -NotePropertyValue @()
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
|
|
254
|
+
# Reconcile rather than append-if-absent: drop every existing gate.py
|
|
255
|
+
# registration, then add exactly one. Idempotent, collapses duplicates older
|
|
256
|
+
# installers left behind, and upgrades a stale "Bash"-only matcher in place —
|
|
257
|
+
# an append-if-absent would find the stale entry and skip, pinning the bug.
|
|
258
|
+
$kept = @()
|
|
255
259
|
foreach ($entry in @($data.hooks.PreToolUse)) {
|
|
260
|
+
$isGate = $false
|
|
256
261
|
foreach ($h in @($entry.hooks)) {
|
|
257
262
|
if ($h -and $h.command -and "$($h.command)".Trim().TrimEnd('"').EndsWith('gate.py')) {
|
|
258
|
-
$
|
|
263
|
+
$isGate = $true
|
|
259
264
|
}
|
|
260
265
|
}
|
|
266
|
+
if (-not $isGate) { $kept += $entry }
|
|
261
267
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
268
|
+
# The matcher MUST cover Task as well as Bash: gate.py's preflight phase gate
|
|
269
|
+
# keys off tool_name == "Task" (the `preflight` block in a repo's
|
|
270
|
+
# gate-config.json). A Bash-only matcher never delivers a Task dispatch to the
|
|
271
|
+
# hook, so that gate silently never fires. Keep in lockstep with install.sh
|
|
272
|
+
# and bin/cli.js.
|
|
273
|
+
$data.hooks.PreToolUse = @($kept) + [pscustomobject]@{
|
|
274
|
+
matcher = 'Bash|Task'
|
|
275
|
+
hooks = @([pscustomobject]@{ type = 'command'; command = $cmd })
|
|
269
276
|
}
|
|
270
|
-
|
|
277
|
+
Write-JsonFile $settingsPath $data
|
|
278
|
+
return 'ok'
|
|
271
279
|
}
|
|
272
280
|
|
|
273
281
|
# Bump only the core_version in a repo's committed .claude/pipeline.json (bundled mode).
|
package/install.sh
CHANGED
|
@@ -185,8 +185,24 @@ register_global_hook() {
|
|
|
185
185
|
python3 - "$dest/settings.json" "$dest/hooks/gate.py" <<'PY'
|
|
186
186
|
import json, sys
|
|
187
187
|
settings, gate = sys.argv[1], sys.argv[2]
|
|
188
|
-
#
|
|
189
|
-
|
|
188
|
+
# The matcher MUST cover Task as well as Bash: gate.py's preflight phase gate
|
|
189
|
+
# keys off tool_name == "Task" (the `preflight` block in a repo's
|
|
190
|
+
# gate-config.json). A Bash-only matcher never delivers a Task dispatch to the
|
|
191
|
+
# hook, so that gate silently never fires — it was dead code from 1.3.0 to 1.3.1.
|
|
192
|
+
MATCHER = "Bash|Task"
|
|
193
|
+
base = gate.rsplit("/", 1)[-1]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def is_gate(entry):
|
|
197
|
+
# Trailing-quote tolerant: the Windows form is `py "C:\...\gate.py"`, and a
|
|
198
|
+
# bare .endswith() missed it — which is how repeat installs accumulated a
|
|
199
|
+
# duplicate registration every time (gate.py then ran once per copy).
|
|
200
|
+
return any(
|
|
201
|
+
(h.get("command") or "").strip().rstrip('"').endswith(base)
|
|
202
|
+
for h in entry.get("hooks", [])
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
190
206
|
try:
|
|
191
207
|
with open(settings) as fh:
|
|
192
208
|
data = json.load(fh)
|
|
@@ -195,15 +211,14 @@ try:
|
|
|
195
211
|
except Exception:
|
|
196
212
|
data = {}
|
|
197
213
|
pre = data.setdefault("hooks", {}).setdefault("PreToolUse", [])
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
"hooks": [{"type": "command", "command": "python3 " + path}]})
|
|
214
|
+
# Reconcile rather than append-if-absent: drop every existing gate.py
|
|
215
|
+
# registration, then add exactly one. Idempotent, collapses duplicates older
|
|
216
|
+
# installers left behind, and upgrades a stale "Bash"-only matcher in place —
|
|
217
|
+
# an append-if-absent would find the stale entry and skip, pinning the bug.
|
|
218
|
+
kept = [e for e in pre if not is_gate(e)]
|
|
219
|
+
kept.append({"matcher": MATCHER,
|
|
220
|
+
"hooks": [{"type": "command", "command": "python3 " + gate}]})
|
|
221
|
+
data["hooks"]["PreToolUse"] = kept
|
|
207
222
|
with open(settings, "w") as fh:
|
|
208
223
|
json.dump(data, fh, indent=2)
|
|
209
224
|
fh.write("\n")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cohorte",
|
|
3
|
-
"version": "1.3.
|
|
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"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// assert-gate-hook.mjs — post-install assertion on a settings.json's gate hook.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/assert-gate-hook.mjs <path-to-settings.json>
|
|
6
|
+
//
|
|
7
|
+
// Two invariants, both regressions that shipped to users:
|
|
8
|
+
//
|
|
9
|
+
// 1. EXACTLY ONE registration. Until 1.3.2 the installers appended
|
|
10
|
+
// if-absent, and their "already registered?" test was a bare
|
|
11
|
+
// `.endsWith("gate.py")` — false for the Windows form `py "C:\...\gate.py"`
|
|
12
|
+
// because of the trailing quote. Every re-install appended another copy
|
|
13
|
+
// (four seen in the wild), so gate.py ran once per copy on every Bash call.
|
|
14
|
+
// CI installed only once, so it never noticed. Callers must install TWICE
|
|
15
|
+
// before running this.
|
|
16
|
+
//
|
|
17
|
+
// 2. THE MATCHER COVERS Task. gate.py's preflight phase gate dispatches on
|
|
18
|
+
// tool_name === "Task" (the `preflight` block in a repo's
|
|
19
|
+
// gate-config.json). A Bash-only matcher never delivers a Task call to the
|
|
20
|
+
// hook, so the gate was dead code from 1.3.0 (which introduced it) to 1.3.1.
|
|
21
|
+
//
|
|
22
|
+
// Exits non-zero with a specific message on failure.
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
|
|
26
|
+
const settingsPath = process.argv[2];
|
|
27
|
+
if (!settingsPath) {
|
|
28
|
+
console.error('usage: assert-gate-hook.mjs <path-to-settings.json>');
|
|
29
|
+
process.exit(2);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let data;
|
|
33
|
+
try {
|
|
34
|
+
data = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error(`assert-gate-hook: cannot read ${settingsPath}: ${err.message}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const pre = (data.hooks || {}).PreToolUse || [];
|
|
41
|
+
const isGate = (entry) =>
|
|
42
|
+
(entry.hooks || []).some(
|
|
43
|
+
(h) => typeof h.command === 'string' && h.command.trim().replace(/"+$/, '').endsWith('gate.py')
|
|
44
|
+
);
|
|
45
|
+
const gate = pre.filter(isGate);
|
|
46
|
+
|
|
47
|
+
if (gate.length !== 1) {
|
|
48
|
+
console.error(
|
|
49
|
+
`assert-gate-hook: expected exactly 1 gate.py registration, found ${gate.length}.\n` +
|
|
50
|
+
(gate.length > 1
|
|
51
|
+
? ' Registration is not idempotent — a re-install duplicated the hook.'
|
|
52
|
+
: ' The installer did not register the gate hook.') +
|
|
53
|
+
`\n PreToolUse: ${JSON.stringify(pre, null, 2)}`
|
|
54
|
+
);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const matcher = gate[0].matcher || '';
|
|
59
|
+
if (!/\bTask\b/.test(matcher)) {
|
|
60
|
+
console.error(
|
|
61
|
+
`assert-gate-hook: gate matcher must cover Task, got ${JSON.stringify(matcher)}.\n` +
|
|
62
|
+
" gate.py's preflight phase gate keys off tool_name === 'Task'; without it\n" +
|
|
63
|
+
' the `preflight` block in gate-config.json is dead code.'
|
|
64
|
+
);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
if (!/\bBash\b/.test(matcher)) {
|
|
68
|
+
console.error(
|
|
69
|
+
`assert-gate-hook: gate matcher must cover Bash, got ${JSON.stringify(matcher)}.\n` +
|
|
70
|
+
' The deny/ask command gating runs on Bash tool calls.'
|
|
71
|
+
);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(`gate hook ok — 1 registration, matcher ${JSON.stringify(matcher)}`);
|