cohorte 1.2.2 → 1.2.4

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,52 @@
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.2.4 — 2026-07-29
7
+
8
+ > **If you installed with `npx cohorte`, this is the release that makes 1.2.3 actually
9
+ > reach you.** Re-run `npx cohorte@latest update --global` (or `update` for a bundled core).
10
+
11
+ - **`npx cohorte install/update` shipped a core missing two scripts.** `bin/cli.js` — the
12
+ port of `install.sh` that `npx` actually runs — copied only `scripts/*.template`, never
13
+ `kanban-move.sh` or `telemetry-send.sh`. Since every caller chains them with `|| true`,
14
+ the result was silent on every npx-installed machine: no kanban card moves, no telemetry
15
+ pings, no error anywhere. The shell installers named both files explicitly and this port
16
+ drifted from them. It now copies by a rule that needs no list to keep in sync.
17
+ - The same port never copied `CHANGELOG.md` into the core either, so `/doctor` and
18
+ `/update-pipeline`'s "What's new" had nothing to read on npx installs. Fixed.
19
+ - CI now dry-runs `bin/cli.js` into a scratch dir and asserts the same postconditions as
20
+ the `install.sh` dry-run. 1.2.3's guard only grepped the two shell installers — it would
21
+ have passed this bug, because the port copies by rule rather than by name.
22
+
23
+ ## 1.2.3 — 2026-07-29
24
+
25
+ - **Telemetry now covers the whole funnel.** Only `/build` was actually pinging; `/smoke`,
26
+ `/review` and `/fix` wrote their metrics line but never sent one, so consenting installs
27
+ reported a quarter of their pipeline. Those three are fixed, and `/brainstorm`, `/spec`
28
+ (on a landed freeze) and `/ship` join them — the seven stages of `idea → PR` now report,
29
+ so it's finally possible to see *where* features stall. Setup and maintenance commands
30
+ (`/doctor`, `/init-pipeline`, `/update-pipeline`, `/audit`, `/refactor`, `/align-ds`)
31
+ deliberately never ping: the collected set stays inside what the consent text describes.
32
+ Same data categories as before, same purpose — nothing new about you is sent, so your
33
+ existing consent stands and nothing re-asks. The full table is in SCHEMA.md §Telemetry.
34
+ - `telemetry-send.sh` now allowlists the phase name client-side — a typo in a command file
35
+ used to sail through and land a phantom phase in the dataset.
36
+ - `/fix` never defined a wall-clock start, so the `seconds` in its metrics line was
37
+ undefined. It now notes the epoch like `/build` and `/review` do.
38
+ - **`/doctor` catches a half-copied core.** New check: `pipeline/scripts/` must hold every
39
+ shipped script, and `VERSION` must not be newer than its siblings. Callers chain these
40
+ scripts with `|| true`, so a missing one was invisible — no kanban move, no telemetry
41
+ ping, no error. If you saw either go quiet, this is why: re-run the installer.
42
+ - CI now fails if an installer forgets to copy a `scripts/*.sh`, and the dry-run install
43
+ asserts the scripts land executable — the root cause above, caught before release
44
+ rather than on someone's machine.
45
+ - The npm tarball no longer ships `scripts/new-feature.sh` + `scripts/remove-feature.sh`
46
+ — cohorte's *own* rendered isolation scripts, with this repo's ports and paths baked
47
+ in. They claimed in their header to be excluded but never were (an explicit `files`
48
+ whitelist wins over `.npmignore`). Only the `*.sh.template` files ship, as intended.
49
+ - Fixed `validate-core.mjs` crashing on Windows (`C:\C:\…` path), so the guard above
50
+ actually runs locally too.
51
+
6
52
  ## 1.2.2 — 2026-07-29
7
53
 
8
54
  - The reference collector moved to its own (private) deployment repo; the public repo keeps
package/bin/cli.js CHANGED
@@ -102,13 +102,31 @@ function copyCore() {
102
102
  for (const f of ['PIPELINE.template.md', 'SCHEMA.md', 'cohorte.config.template.yaml']) {
103
103
  fs.copyFileSync(path.join(src, 'profile', f), path.join(pipelineDir, f));
104
104
  }
105
- for (const f of fs.readdirSync(path.join(src, 'scripts'))) {
106
- if (f.endsWith('.template')) {
107
- fs.copyFileSync(path.join(src, 'scripts', f), path.join(pipelineDir, 'scripts', f));
105
+ // Copy the *.template files AND the shipped executables (kanban-move.sh,
106
+ // telemetry-send.sh). Until 1.2.4 this loop took only `.template`, so every
107
+ // `npx cohorte install/update` produced a core missing both scripts — and since
108
+ // every caller chains them with `|| true`, the result was silent: no kanban card
109
+ // moves, no telemetry pings, no error. The shell installers named them explicitly
110
+ // and this port drifted. The rule below needs no list to keep in sync: a `<x>.sh`
111
+ // with an `<x>.sh.template` sibling is rendered per-project by /init-pipeline, so
112
+ // only the template ships; every other `.sh` is a shipped executable.
113
+ const scriptFiles = fs.readdirSync(path.join(src, 'scripts'));
114
+ for (const f of scriptFiles) {
115
+ const isTemplate = f.endsWith('.template');
116
+ const isShipped = f.endsWith('.sh') && !scriptFiles.includes(`${f}.template`);
117
+ if (!isTemplate && !isShipped) continue;
118
+ const target = path.join(pipelineDir, 'scripts', f);
119
+ fs.copyFileSync(path.join(src, 'scripts', f), target);
120
+ if (isShipped && process.platform !== 'win32') {
121
+ try { fs.chmodSync(target, 0o755); } catch { /* optional */ }
108
122
  }
109
123
  }
110
124
  fs.copyFileSync(path.join(src, 'core', 'agents', 'implementer.template.md'),
111
125
  path.join(pipelineDir, 'implementer.template.md'));
126
+ // /doctor reads this to tell the human what they're missing; the shell installers
127
+ // have always copied it, this port never did.
128
+ const changelog = path.join(src, 'CHANGELOG.md');
129
+ if (fs.existsSync(changelog)) fs.copyFileSync(changelog, path.join(pipelineDir, 'CHANGELOG.md'));
112
130
  fs.writeFileSync(path.join(pipelineDir, 'VERSION'), VERSION + '\n');
113
131
  if (process.platform !== 'win32') {
114
132
  try { fs.chmodSync(path.join(dest, 'hooks', 'gate.py'), 0o755); } catch { /* optional */ }
@@ -49,3 +49,8 @@ when invoked with no paste). In chat print only a 3-line summary + the path. Tel
49
49
  downstream. If a board is configured, **move** the card into the **Brainstorm** column tagged
50
50
  `#<feature_id>` (per §Kanban): the picked Ideas card if the human chose one, else a new card. No-op if
51
51
  no board.
52
+
53
+ **Telemetry:** chain the opt-in usage ping onto that same Bash call — `/build` §4's shared form,
54
+ `<phase>` = `brainstorm`, `<seconds>` = `0` (this phase is human thinking time, not pipeline
55
+ wall-clock — the funnel signal is the event, not its duration), `<results>` = `""`. No board ⇒ still
56
+ ping, in its own `|| true` call. Silent no-op without consent; never ask about consent here.
@@ -101,10 +101,13 @@ Compute the elapsed time in the same Bash call
101
101
  (`echo "{...\"seconds\":$(($(date +%s)-<start epoch from §2>)),...}" >> …`):
102
102
  `{"ts":"<ISO date>","feature":"$ARGUMENTS","phase":"build","seconds":<wall-clock>,"surfaces":{"<key>":"ok|error",…}}`
103
103
  — this is the evidence SCHEMA.md §Specialization asks for before splitting a surface. In the same
104
- Bash call, chain the opt-in usage ping:
105
- `<core>/pipeline/scripts/telemetry-send.sh build "$ARGUMENTS" <seconds> "<ok,ok|error,…>" || true`
106
- (`<core>` = `~/.claude` global / `.claude` bundled) a silent no-op unless the human explicitly
107
- consented (SCHEMA.md §Telemetry); never ask about consent here.
104
+ Bash call, chain the opt-in usage ping — **the shared form every phase command reuses**:
105
+ `<core>/pipeline/scripts/telemetry-send.sh <phase> "$ARGUMENTS" <seconds> "<results>" || true`
106
+ (`<core>` = `~/.claude` global / `.claude` bundled; here `<phase>` = `build`, `<results>` =
107
+ `<ok,ok|error,…>`) — a silent no-op unless the human explicitly consented (SCHEMA.md §Telemetry);
108
+ never ask about consent here. `/review`, `/fix` and `/smoke` chain the same line with their own
109
+ phase + results. The `|| true` swallows a **missing** script too, so a half-copied core goes
110
+ silent rather than loud — `/doctor` check 1 is what catches that.
108
111
  Then tell the human: run `/smoke $ARGUMENTS` to exercise the feature end-to-end (or test by hand),
109
112
  then `/review $ARGUMENTS`. Do not run the app or migrations yourself here — `/smoke` is the
110
113
  sanctioned path for that. **Recommend a `/clear` now** — the spec, contract and diff are all on
@@ -18,7 +18,12 @@ fix only with the human's go-ahead (or hand them the command).
18
18
  suggest `/update-pipeline`. Read `pipeline/CHANGELOG.md` for what they're missing. The router
19
19
  commands' step files are present — `templates/steps/init-pipeline/` non-empty (a router whose
20
20
  `templates/steps/<cmd>/` dir is missing is a partial/stale install ⇒
21
- re-run install/update).
21
+ re-run install/update). **Shipped scripts present and executable** in `<core>/pipeline/scripts/`:
22
+ `kanban-move.sh`, `telemetry-send.sh`, `new-feature.sh.template`, `remove-feature.sh.template`
23
+ — ❌ any missing one. Every caller chains these with `|| true`, so an absent script is a **silent**
24
+ no-op (no kanban card moves, no telemetry ping, no error anywhere) — this check is the only thing
25
+ that sees it. Also flag ❌ a `VERSION` **newer than** the other `pipeline/` files (compare mtimes):
26
+ a version bumped without a full re-copy is a half-done update ⇒ re-run install/update.
22
27
  2. **Profile.** `PIPELINE.md` exists and its `yaml pipeline-profile` block parses. Every
23
28
  `surfaces[].agent` has its `.claude/agents/<agent>.md` and every agent file has its `surfaces[]`
24
29
  entry — **no orphans either way** (SCHEMA.md rule). Each rendered agent's frontmatter `tools`
@@ -29,6 +29,8 @@ that change the *contract*; `/fix` is for everything else.
29
29
  re-author the contract file yourself now (lead-only, per `/build` §2) — agents never edit it. If
30
30
  the contract change ripples into surfaces *without* findings, fall back to full `/build` instead
31
31
  and say so.
32
+ - **Note the epoch** (`date +%s`) in the first Bash call you make here — §3's metrics line and usage
33
+ ping both carry `seconds`, and there is no separate timing call.
32
34
 
33
35
  ## 2. Scope the re-dispatch — only surfaces with findings
34
36
 
@@ -61,7 +63,8 @@ When the agents return:
61
63
  needs those checkboxes).
62
64
  - Print one status line per surface (`<key> · items fixed <n>/<m> · tests pass/fail`) — do not restate
63
65
  handoff content — and append ONE metrics line for the batch to `pipeline-metrics.jsonl`
64
- (see `/build` §4, `phase: "fix"`).
66
+ (see `/build` §4, `phase: "fix"`), chaining the opt-in usage ping in the same Bash call
67
+ (results = items fixed over items found across surfaces, e.g. `"5/6"`).
65
68
  - Tell the human: re-run `/smoke` if the failures were runtime ones, and `/review $ARGUMENTS` for the
66
69
  re-verdict — the re-review is what *verifies* the ticked items actually hold (a regression simply
67
70
  reappears as a new finding in the next round). **Recommend a `/clear`** — all state (spec,
@@ -58,6 +58,8 @@ Merge the returned reports into **one** REVIEW REPORT (same template): findings
58
58
  re-ordered by severity, counts summed, duplicates collapsed, verdict = the worst returned
59
59
  (`BLOCK` > `REVISE` > `SHIP`). Append ONE metrics line for the batch to `pipeline-metrics.jsonl`
60
60
  (main-checkout path + rules in `/build` §4): `{"ts":"<ISO>","feature":"$ARGUMENTS","phase":"review","seconds":<wall-clock>,"surfaces":{"<key>":"<verdict>:<finding count>",…}}`.
61
+ In the same Bash call, chain the opt-in usage ping (`/build` §4, `phase: "review"`, results = the
62
+ merged verdict + total finding count, e.g. `"REVISE:3"`).
61
63
  **Stage the full report to `specs/reports/$ARGUMENTS.md`** (overwrite) — a gitignored buffer so a
62
64
  `/fix` after a `/clear` can still read the findings; the `specs/reports/` subfolder is skipped by the
63
65
  non-recursive `specs/*.md` glob, so it's never mistaken for a spec (no phantom card, no bogus stage).
@@ -60,6 +60,13 @@ compare URL was emitted (no PR yet), move the card without a number. Then verify
60
60
  or an offset-limited Read around the match): exactly one card, under the `shipped` heading — never
61
61
  re-read the whole board into context. No board ⇒ skip silently.
62
62
 
63
+ **Telemetry — the usage ping that closes the funnel.** Chain it onto the verify call above
64
+ (`/build` §4's shared form, `<phase>` = `ship`, `<seconds>` = `0` — the release agent's duration is
65
+ not the pipeline's, `<results>` = `pr` when a PR was created / `compare` when only a compare URL was
66
+ emitted). Fire it **after** the release agent reports success, never on an aborted ship — a `ship`
67
+ event must mean the feature actually left the pipeline. No board ⇒ still ping, in its own `|| true`
68
+ call. Silent no-op without consent; never ask about consent here.
69
+
63
70
  ## 5. After the PR — CI gate + teardown
64
71
 
65
72
  - If `host: github` and `gh` is available, watch the PR's checks (`gh pr checks <url> --watch`) and
@@ -34,7 +34,8 @@ human; that's expected.
34
34
 
35
35
  - Print the agent's return as-is (verdict + ❌ lines + report path) — it is already minimal.
36
36
  - Append ONE metrics line to `pipeline-metrics.jsonl` (main-checkout path + rules in `/build` §4,
37
- `phase: "smoke"`).
37
+ `phase: "smoke"`), chaining the opt-in usage ping in the same Bash call (results = `PASS` or
38
+ `FAIL:<n>` failing flows).
38
39
  - **PASS** → tell the human to run `/review $ARGUMENTS`. **FAIL** → the failures are findings: feed
39
40
  them to `/fix $ARGUMENTS`, re-run `/smoke` after. Either way the report is on disk —
40
41
  **recommend a `/clear`** before the next command.
@@ -47,7 +47,12 @@ Detect the mode from the pasted content:
47
47
  it. This is just so the human isn't surprised when `/build` proposes a new agent.
48
48
  5. When the human validates, **freeze**: write `specs/<id>.md` (`status: frozen`, front-matter filled).
49
49
  Create the file — do not ask the human to. **Postcondition:** `grep -q '^status: frozen' specs/<id>.md`
50
- — if it fails the freeze didn't land; fix it before pointing the human at `/build`.
50
+ — if it fails the freeze didn't land; fix it before pointing the human at `/build`. Chain the
51
+ opt-in usage ping onto the postcondition's Bash call (`/build` §4's shared form, `<phase>` =
52
+ `spec`, `<seconds>` = `0` — interactive time, not pipeline wall-clock, `<results>` = `frozen`).
53
+ Ping only on a **landed** freeze, so the funnel counts specs that exist, not attempts. Mode B does
54
+ not ping — it re-enters an already-counted spec, and `/fix` covers that loop. Silent no-op without
55
+ consent; never ask about consent here.
51
56
  6. Author the **design brief** — `specs/design/<id>.md`, rendered via
52
57
  `.claude/templates/design-brief.md` (resolves to `~/.claude/templates/…` on a global install).
53
58
  _Only if `design.enabled` / the feature has UI; skip entirely for a backend-only feature._
@@ -43,8 +43,9 @@ Prefer sensible defaults from Phase 1 as the first (Recommended) option in each
43
43
  - **Telemetry** (optional, machine-scoped — SKIP entirely if `~/.claude/cohorte.config.yaml` already
44
44
  has a `telemetry:` block with a `consent_date`, i.e. the human already answered on this machine).
45
45
  Ask ONE opt-in question, stating exactly: _"Share anonymous usage stats with the cohorte project?
46
- Sent per pipeline phase: core version, OS, phase name, duration, per-surface result counts, and a
47
- hash of the feature id — never repo names, paths, code, or IPs. Off by default; withdraw anytime
46
+ One ping per pipeline phase, `/brainstorm` through `/ship`: core version, OS, phase name, duration,
47
+ per-surface result counts, and a hash of the feature id — never repo names, paths, code, or IPs.
48
+ Setup and maintenance commands never ping. Off by default; withdraw anytime
48
49
  (`telemetry.enabled: false`); erase your history anytime (SCHEMA.md §Telemetry). Default: No."_
49
50
  On **yes**: in the global config set `telemetry.enabled: true`, mint `install_id` (`uuidgen`,
50
51
  lowercase), set `consent_date` (ISO date). On **no**: set `enabled: false` + `consent_date` (so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cohorte",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
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"
@@ -13,6 +13,8 @@
13
13
  "core",
14
14
  "profile",
15
15
  "scripts",
16
+ "!scripts/new-feature.sh",
17
+ "!scripts/remove-feature.sh",
16
18
  "dashboard/server",
17
19
  "dashboard/dist",
18
20
  "dashboard/README.md",
package/profile/SCHEMA.md CHANGED
@@ -325,9 +325,28 @@ Cohorte can send the maintainers anonymous usage pings so the pipeline improves
325
325
  slow. **Nothing is ever sent without explicit consent**: `/init-pipeline` (and `/update-pipeline` on
326
326
  pre-telemetry installs) ask ONE question, once per machine, default **No**, and record the answer in
327
327
  `~/.claude/cohorte.config.yaml` §`telemetry` (`enabled`, `install_id`, `consent_date`). The sender —
328
- `pipeline/scripts/telemetry-send.sh`, chained by each phase after its metrics line is a silent
329
- no-op unless `enabled: true` AND `install_id` AND `endpoint` are all set, times out at 2s, and never
330
- fails the pipeline.
328
+ `pipeline/scripts/telemetry-send.sh` is a silent no-op unless `enabled: true` AND `install_id` AND
329
+ `endpoint` are all set, times out at 2s, and never fails the pipeline. Callers chain it with
330
+ `|| true`, so a **missing** script is equally silent: `/doctor` check 1 verifies `pipeline/scripts/`
331
+ is fully populated.
332
+
333
+ **Which commands ping** — the seven that make up the feature funnel, and only those. The point is to
334
+ see where features stall, so every stage of `idea → PR` reports and nothing else does:
335
+
336
+ | phase | fired when | `seconds` | `results` |
337
+ | --- | --- | --- | --- |
338
+ | `brainstorm` | the return is staged | `0` | — |
339
+ | `spec` | a freeze lands (Mode A only) | `0` | `frozen` |
340
+ | `build` | after the batch metrics line | wall-clock | `ok,ok` / `error` |
341
+ | `smoke` | after the verdict | wall-clock | `PASS` / `FAIL:<n>` |
342
+ | `review` | after the merged verdict | wall-clock | `<verdict>:<count>` |
343
+ | `fix` | after the batch metrics line | wall-clock | `<fixed>/<found>` |
344
+ | `ship` | the release agent succeeded | `0` | `pr` / `compare` |
345
+
346
+ `seconds: 0` marks a phase whose duration is human thinking time, not pipeline wall-clock — the
347
+ funnel signal there is the event, not how long it took. `/doctor`, `/audit`, `/refactor`,
348
+ `/align-ds`, `/init-pipeline` and `/update-pipeline` **never** ping: they sit outside the funnel, and
349
+ keeping them out is what holds the collected set to what the consent text describes.
331
350
 
332
351
  **What one event contains** (strict allowlist, ~200 bytes):
333
352
 
@@ -15,7 +15,9 @@ enabled: true # cfg:enabled — master switch; false disables ev
15
15
  # (/init-pipeline or /update-pipeline ask once per machine; they record your answer here).
16
16
  # What is sent when enabled: core version, OS, phase name, wall-clock seconds, per-surface
17
17
  # result counts, and a SHA-256 HASH of the feature id — never repo names, paths, code, spec
18
- # content, emails, or IPs. Sent fire-and-forget (2s timeout, silent on failure) by
18
+ # content, emails, or IPs. One ping per pipeline phase, /brainstorm through /ship; setup and
19
+ # maintenance commands (/doctor, /init-pipeline, /update-pipeline, /audit, /refactor,
20
+ # /align-ds) never ping. Sent fire-and-forget (2s timeout, silent on failure) by
19
21
  # pipeline/scripts/telemetry-send.sh. Withdraw anytime: set enabled: false. Erase your history:
20
22
  # see SCHEMA.md §Telemetry (DELETE by install_id).
21
23
  telemetry:
@@ -2,7 +2,8 @@
2
2
  # telemetry-send.sh — fire-and-forget anonymous usage ping (SCHEMA.md §Telemetry).
3
3
  #
4
4
  # telemetry-send.sh <phase> <feature_id> <seconds> [results]
5
- # phase build|review|fix|smoke
5
+ # phase brainstorm|spec|build|smoke|review|fix|ship — the feature funnel, and only it.
6
+ # Setup/maintenance commands never ping (SCHEMA.md §Telemetry).
6
7
  # feature the feature id — NEVER sent raw; SHA-256-hashed to 12 hex chars
7
8
  # seconds batch wall-clock
8
9
  # results optional compact summary, e.g. "ok,ok" or "REVISE:3"
@@ -35,6 +36,13 @@ install_id="$(tval install_id)"; [ -n "$install_id" ] || exit 0
35
36
  phase="${1:-}"; feature="${2:-}"; seconds="${3:-0}"; results="${4:-}"
36
37
  [ -n "$phase" ] || exit 0
37
38
 
39
+ # Allowlist the phase here — the collector accepts any string, so a typo in a command
40
+ # file would silently pollute the dataset with a phantom phase nobody notices.
41
+ case "$phase" in
42
+ brainstorm|spec|build|smoke|review|fix|ship) ;;
43
+ *) exit 0 ;;
44
+ esac
45
+
38
46
  if command -v shasum >/dev/null 2>&1; then
39
47
  fhash=$(printf '%s' "$feature" | shasum -a 256 | cut -c1-12)
40
48
  else
@@ -4,8 +4,11 @@
4
4
  // locally with `node scripts/validate-core.mjs`.
5
5
  import { readFileSync, readdirSync, existsSync } from "node:fs";
6
6
  import { join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
7
8
 
8
- const root = new URL("..", import.meta.url).pathname;
9
+ // fileURLToPath, not .pathname — on Windows the latter yields "/C:/…", which
10
+ // join() then resolves against the cwd drive ("C:\C:\…") and every read ENOENTs.
11
+ const root = fileURLToPath(new URL("..", import.meta.url));
9
12
  const errors = [];
10
13
  const fail = (file, msg) => errors.push(`${file}: ${msg}`);
11
14
 
@@ -109,6 +112,40 @@ const steps = join(root, "core/templates/steps/init-pipeline");
109
112
  if (!existsSync(steps) || readdirSync(steps).length === 0)
110
113
  fail("core/templates/steps/init-pipeline", "router step files missing/empty");
111
114
 
115
+ // ── telemetry coverage ──────────────────────────────────────────────────────
116
+ // The funnel is only readable if every one of its stages pings — a single missing
117
+ // one silently truncates it (that is how /smoke, /review and /fix went unreported
118
+ // until 1.2.3). The phase list here must match SCHEMA.md §Telemetry's table.
119
+ const FUNNEL = ["brainstorm", "spec", "build", "smoke", "review", "fix", "ship"];
120
+ for (const c of FUNNEL)
121
+ if (!/usage ping/i.test(read(`core/commands/${c}.md`)))
122
+ fail(`core/commands/${c}.md`, "funnel command with no usage ping — breaks the telemetry funnel");
123
+ // …and nothing outside the funnel may ping (consent text scopes it to the funnel).
124
+ for (const f of readdirSync(join(root, "core/commands"))) {
125
+ const c = f.replace(/\.md$/, "");
126
+ // `telemetry-send.sh` + an argument = a call site; the bare filename (e.g. /doctor
127
+ // listing the scripts it checks for) is a mention, not a ping.
128
+ if (!FUNNEL.includes(c) && /telemetry-send\.sh +\S|usage ping/i.test(read(`core/commands/${f}`)))
129
+ fail(`core/commands/${f}`, "non-funnel command pings telemetry — outside the consented scope");
130
+ }
131
+
132
+ // ── shipped scripts ─────────────────────────────────────────────────────────
133
+ // Every scripts/*.sh must be copied by BOTH shell installers. Callers chain these
134
+ // with `|| true`, so one an installer forgets is a silent no-op forever — no kanban
135
+ // card moves, no telemetry ping, no error. CI is the only place this is loud.
136
+ // The third installer, bin/cli.js (what `npx cohorte` runs), copies by rule rather
137
+ // than by name, so grepping for filenames can't see it — ci.yml dry-runs it into a
138
+ // scratch HOME and asserts the same postconditions instead. Both are needed: this
139
+ // check catches a forgotten name, that one catches a drifted rule.
140
+ // A `<name>.sh` with a `<name>.sh.template` sibling is a locally-rendered artifact
141
+ // (this repo dogfoods its own /init-pipeline), not a core asset — skip those.
142
+ const installers = { "install.sh": read("install.sh"), "install.ps1": read("install.ps1") };
143
+ const shipped = readdirSync(join(root, "scripts"));
144
+ for (const f of shipped.filter((f) => f.endsWith(".sh") && !shipped.includes(`${f}.template`)))
145
+ for (const [name, src] of Object.entries(installers))
146
+ if (!src.includes(`scripts/${f}`) && !src.includes(`scripts\\${f}`))
147
+ fail(name, `never copies scripts/${f} into pipeline/scripts/ (silent no-op at runtime)`);
148
+
112
149
  // ── report ──────────────────────────────────────────────────────────────────
113
150
  if (errors.length) {
114
151
  console.error(`validate-core: ${errors.length} error(s)\n`);