cohorte 1.3.1 → 1.3.2

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,31 @@
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.2 — 2026-07-30
7
+
8
+ > **Re-run `npx cohorte@latest update --global` (or `update`).** This release repairs the gate
9
+ > hook registration in place — updating is what applies it.
10
+
11
+ - **1.3.0's preflight phase gate never fired on any install.** `gate.py` gates review/smoke
12
+ dispatches on `tool_name == "Task"`, but all three installers registered the hook with
13
+ `matcher: "Bash"` — a Task call never reached it. The `preflight` block in `gate-config.json`
14
+ and `gate.preflight` in `PIPELINE.md` were both dead config. The matcher is now `Bash|Task`.
15
+ - **Re-installing duplicated the hook, every time.** The "already registered?" test was
16
+ `command.endswith("gate.py")`, which is false for the Windows form `py "C:\…\gate.py"` because
17
+ of the trailing quote — so `install.sh` and `bin/cli.js` appended another copy on each run, and
18
+ `gate.py` ran once per copy on every Bash call (four copies seen in the wild). Registration is
19
+ now a **reconcile**: it drops every existing `gate.py` entry and writes exactly one. Idempotent,
20
+ it collapses the duplicates you already have, and it upgrades the stale matcher — an
21
+ append-if-absent would have found the stale entry and skipped, pinning the bug forever.
22
+ Unrelated hooks and every other settings key are untouched.
23
+ - **`npx cohorte update` never touched the hook at all**, so neither fix above could have reached
24
+ you through the command you actually run to get fixes — only a full re-install rewrote it.
25
+ `install.sh` and `install.ps1` always registered on update; this port had drifted (the same
26
+ class of drift as 1.2.4 and 1.2.6). It now registers on both paths.
27
+ - CI installs **twice** before asserting the hook, via a new `scripts/assert-gate-hook.mjs`:
28
+ exactly one registration, matcher covering both Bash and Task. A single install could never
29
+ surface the duplication — which is precisely why CI stayed green while it shipped.
30
+
6
31
  ## 1.3.1 — 2026-07-30
7
32
 
8
33
  - **`/cycle <feature_id> [max_rounds]`** — a launcher command for the full dev-cycle workflow,
package/README.md CHANGED
@@ -7,6 +7,9 @@
7
7
  [![Publish to npm](https://github.com/TheBidouilleAgency/cohorte/actions/workflows/publish.yml/badge.svg)](https://github.com/TheBidouilleAgency/cohorte/actions/workflows/publish.yml)
8
8
  [![node >=18](https://img.shields.io/node/v/cohorte?logo=node.js&logoColor=white)](https://nodejs.org)
9
9
  [![license: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE)
10
+ [![docs](https://img.shields.io/badge/docs-thebidouilleagency.github.io%2Fcohorte-6f42c1)](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
- const pre = data.hooks.PreToolUse;
278
- const hooks = [
279
- { file: path.join(dest, 'hooks', 'gate.py'), matcher: 'Bash' },
280
- ];
281
- for (const { file, matcher } of hooks) {
282
- const base = path.basename(file);
283
- const already = pre.some(entry => (entry.hooks || []).some(
284
- h => typeof h.command === 'string' && h.command.trim().endsWith(base)));
285
- if (!already) {
286
- const cmd = process.platform === 'win32' ? `${python} "${file}"` : `${python} ${file}`;
287
- pre.push({ matcher, hooks: [{ type: 'command', command: cmd }] });
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
- const hookState = mode === 'install' ? registerGlobalHook() : 'unchanged';
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/install.ps1 CHANGED
@@ -251,23 +251,31 @@ try {
251
251
  $data.hooks | Add-Member -NotePropertyName PreToolUse -NotePropertyValue @()
252
252
  }
253
253
 
254
- $already = $false
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
- $already = $true
263
+ $isGate = $true
259
264
  }
260
265
  }
266
+ if (-not $isGate) { $kept += $entry }
261
267
  }
262
- if (-not $already) {
263
- $data.hooks.PreToolUse = @($data.hooks.PreToolUse) + [pscustomobject]@{
264
- matcher = 'Bash'
265
- hooks = @([pscustomobject]@{ type = 'command'; command = $cmd })
266
- }
267
- Write-JsonFile $settingsPath $data
268
- return 'ok'
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
- return 'present'
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
- # (hook path, PreToolUse matcher)
189
- hooks = [(gate, "Bash")]
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
- for path, matcher in hooks:
199
- base = path.rsplit("/", 1)[-1]
200
- already = any(
201
- h.get("command", "").strip().endswith(base)
202
- for entry in pre for h in entry.get("hooks", [])
203
- )
204
- if not already:
205
- pre.append({"matcher": matcher,
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.1",
3
+ "version": "1.3.2",
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)}`);