cohorte 1.0.0 → 1.1.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.
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+ // Read a project's `.claude/pipeline-metrics.jsonl` (one line per phase batch, appended by
3
+ // /build, /review, /fix and /smoke) and aggregate it per feature: wall-clock per phase, fix
4
+ // rounds, and per-surface results. Dependency-free; a missing file is simply "no data yet".
5
+ //
6
+ // Two line formats coexist in the file:
7
+ // new {"ts","feature","phase","seconds",surfaces:{"<key>":"ok|error"|"<verdict>:<count>"}}
8
+ // legacy {"ts","feature","phase","surface","seconds","result"} — one line PER surface;
9
+ // legacy lines are grouped by ts+feature+phase into one batch (wall-clock = max).
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ const PHASES = ['build', 'review', 'fix', 'smoke'];
15
+
16
+ // Parse the raw JSONL into normalized batches ({ts, feature, phase, seconds, surfaces}),
17
+ // skipping malformed lines. Legacy per-surface lines are folded into their batch.
18
+ function parseBatches(raw) {
19
+ const batches = [];
20
+ const legacy = new Map(); // "ts|feature|phase" → batch (legacy lines share the key)
21
+ for (const line of raw.split(/\r?\n/)) {
22
+ if (!line.trim()) continue;
23
+ let e;
24
+ try { e = JSON.parse(line); } catch { continue; }
25
+ if (!e || typeof e !== 'object' || !e.feature || !e.phase) continue;
26
+ const seconds = Number(e.seconds) || 0;
27
+ if (e.surfaces && typeof e.surfaces === 'object') {
28
+ batches.push({ ts: e.ts || '', feature: String(e.feature), phase: String(e.phase), seconds, surfaces: e.surfaces });
29
+ } else if (e.surface) {
30
+ const key = `${e.ts}|${e.feature}|${e.phase}`;
31
+ let b = legacy.get(key);
32
+ if (!b) {
33
+ b = { ts: e.ts || '', feature: String(e.feature), phase: String(e.phase), seconds: 0, surfaces: {} };
34
+ legacy.set(key, b);
35
+ batches.push(b);
36
+ }
37
+ b.surfaces[String(e.surface)] = e.result == null ? '' : String(e.result);
38
+ b.seconds = Math.max(b.seconds, seconds); // batch wall-clock = the slowest surface
39
+ }
40
+ // lines with neither `surfaces` nor `surface` are malformed → skipped
41
+ }
42
+ return batches;
43
+ }
44
+
45
+ // A surface result string counts as a failure when it is "error" or a BLOCK/REVISE verdict
46
+ // (verdict lines look like "REVISE:2"), or a non-ok/pass word.
47
+ function isFailure(result) {
48
+ const head = String(result).split(':')[0].trim().toLowerCase();
49
+ return head !== '' && head !== 'ok' && head !== 'ship' && head !== 'pass';
50
+ }
51
+
52
+ // Aggregate batches per feature (newest feature first).
53
+ function aggregate(batches) {
54
+ const byFeature = new Map();
55
+ for (const b of batches) {
56
+ let f = byFeature.get(b.feature);
57
+ if (!f) {
58
+ f = {
59
+ feature: b.feature,
60
+ firstTs: b.ts, lastTs: b.ts,
61
+ totalSeconds: 0,
62
+ fixRounds: 0,
63
+ phases: {}, // phase → { seconds, rounds }
64
+ surfaces: {}, // surface → { phase → latest result }, plus failure count
65
+ };
66
+ byFeature.set(b.feature, f);
67
+ }
68
+ if (b.ts && (!f.firstTs || b.ts < f.firstTs)) f.firstTs = b.ts;
69
+ if (b.ts && b.ts > f.lastTs) f.lastTs = b.ts;
70
+ f.totalSeconds += b.seconds;
71
+ const ph = f.phases[b.phase] || (f.phases[b.phase] = { seconds: 0, rounds: 0 });
72
+ ph.seconds += b.seconds;
73
+ ph.rounds += 1;
74
+ if (b.phase === 'fix') f.fixRounds += 1;
75
+ for (const [key, result] of Object.entries(b.surfaces)) {
76
+ const s = f.surfaces[key] || (f.surfaces[key] = { results: {}, failures: 0 });
77
+ s.results[b.phase] = result; // batches are appended in order → last write is the latest
78
+ if (isFailure(result)) s.failures += 1;
79
+ }
80
+ }
81
+ return [...byFeature.values()].sort((a, b) => (b.lastTs || '').localeCompare(a.lastTs || ''));
82
+ }
83
+
84
+ function metrics({ projectRoot }) {
85
+ const file = path.join(projectRoot, '.claude', 'pipeline-metrics.jsonl');
86
+ let raw;
87
+ try { raw = fs.readFileSync(file, 'utf8'); }
88
+ catch { return { present: false, phases: PHASES, features: [], batches: 0 }; }
89
+
90
+ const batches = parseBatches(raw);
91
+ return { present: true, phases: PHASES, features: aggregate(batches), batches: batches.length };
92
+ }
93
+
94
+ module.exports = { metrics };
package/install.ps1 CHANGED
@@ -143,6 +143,7 @@ try {
143
143
  Copy-Item (Join-Path $src 'profile\SCHEMA.md') (Join-Path $dest 'pipeline') -Force
144
144
  Copy-Item (Join-Path $src 'profile\cohorte.config.template.yaml') (Join-Path $dest 'pipeline') -Force
145
145
  Copy-Item (Join-Path $src 'scripts\*.template') (Join-Path $dest 'pipeline\scripts') -Force
146
+ Copy-Item (Join-Path $src 'scripts\kanban-move.sh') (Join-Path $dest 'pipeline\scripts') -Force
146
147
  Copy-Item (Join-Path $src 'core\agents\implementer.template.md') (Join-Path $dest 'pipeline') -Force
147
148
  if (Test-Path (Join-Path $src 'CHANGELOG.md')) { Copy-Item (Join-Path $src 'CHANGELOG.md') (Join-Path $dest 'pipeline') -Force }
148
149
  [System.IO.File]::WriteAllText((Join-Path $dest 'pipeline\VERSION'), "$ver`n", [System.Text.UTF8Encoding]::new($false))
@@ -179,7 +180,8 @@ try {
179
180
  function Copy-FixedAgents {
180
181
  New-Item -ItemType Directory -Force -Path (Join-Path $dest 'agents') | Out-Null
181
182
  Copy-Item (Join-Path $src 'core\agents\review.md'),
182
- (Join-Path $src 'core\agents\release.md') (Join-Path $dest 'agents') -Force
183
+ (Join-Path $src 'core\agents\release.md'),
184
+ (Join-Path $src 'core\agents\smoke.md') (Join-Path $dest 'agents') -Force
183
185
  # 0.1.19 split the bi-mode questionnaire-researcher into research-agent + questionnaire-architect;
184
186
  # copy-over never deletes, so scrub the retired agent lest a dead subagent_type linger.
185
187
  Remove-Item -LiteralPath (Join-Path $dest 'agents\questionnaire-researcher.md') -Force -ErrorAction SilentlyContinue
package/install.sh CHANGED
@@ -85,6 +85,8 @@ copy_core() {
85
85
  cp "$src/profile/SCHEMA.md" "$dest/pipeline/"
86
86
  cp "$src/profile/cohorte.config.template.yaml" "$dest/pipeline/"
87
87
  cp "$src"/scripts/*.template "$dest/pipeline/scripts/"
88
+ cp "$src/scripts/kanban-move.sh" "$dest/pipeline/scripts/"
89
+ chmod +x "$dest/pipeline/scripts/kanban-move.sh" 2>/dev/null || true
88
90
  cp "$src/core/agents/implementer.template.md" "$dest/pipeline/"
89
91
  [ -f "$src/CHANGELOG.md" ] && cp "$src/CHANGELOG.md" "$dest/pipeline/"
90
92
  printf '%s\n' "$ver" > "$dest/pipeline/VERSION"
@@ -125,6 +127,7 @@ PY
125
127
  copy_fixed_agents() {
126
128
  mkdir -p "$dest/agents"
127
129
  cp "$src/core/agents/review.md" "$src/core/agents/release.md" \
130
+ "$src/core/agents/smoke.md" \
128
131
  "$dest/agents/"
129
132
  # 0.1.19 split the bi-mode questionnaire-researcher into research-agent + questionnaire-architect;
130
133
  # copy-over never deletes, so scrub the retired agent lest a dead subagent_type linger.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cohorte",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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"
@@ -69,7 +69,10 @@ surfaces:
69
69
  label: frontend (React/TanStack)
70
70
  agent: frontend
71
71
  tools: [Read, Write, Edit, Bash, Grep, Glob, DesignSync, mcp__serena]
72
- model: inherit # this surface makes real design decisions ⇒ worth the lead's model
72
+ model: sonnet # default even for design surfaces — designs + contract are
73
+ # frozen inputs the agent applies; `inherit` (bills at the
74
+ # lead's tier, often Opus) ONLY if this surface must make
75
+ # novel design decisions
73
76
  test_cmd: pnpm --filter web test
74
77
  lint_cmd: pnpm --filter web lint
75
78
  format_cmd: pnpm --filter web format
package/profile/SCHEMA.md CHANGED
@@ -155,16 +155,16 @@ the frozen contract as the only cross-surface channel**. So specialization means
155
155
 
156
156
  Coarse first, specialize on evidence: start with one `frontend` / `backend` surface each; split only a
157
157
  surface that's proven slow and cleanly separable. The evidence lives in
158
- `.claude/pipeline-metrics.jsonl` (gitignored) — one JSONL line per dispatched agent
159
- (`ts`/`feature`/`phase`/`surface`/`seconds`/`result`), appended by `/build`, `/review`, `/fix` and
160
- `/smoke`. Read it before proposing a split: split the surface that actually dominates wall-clock,
158
+ `.claude/pipeline-metrics.jsonl` (gitignored) — one JSONL line per phase batch
159
+ (`ts`/`feature`/`phase`/`seconds`/`surfaces:{key: result}`), appended by `/build`, `/review`, `/fix`
160
+ and `/smoke`. Read it before proposing a split: split the surface that actually dominates wall-clock,
161
161
  not the one that feels big.
162
162
 
163
163
  ## Measuring cost — what's slow vs what's expensive
164
164
 
165
- `pipeline-metrics.jsonl` records **wall-clock seconds** per dispatch (§Specialization) — it tells you what's
166
- SLOW. It deliberately does NOT record tokens: the lead can't reliably read a subagent's token count to log
167
- it. For what's EXPENSIVE, use Claude Code's own accounting:
165
+ `pipeline-metrics.jsonl` records **wall-clock seconds** per phase batch (§Specialization) — it tells you
166
+ what's SLOW. It deliberately does NOT record tokens: the lead can't reliably read a subagent's token count
167
+ to log it. For what's EXPENSIVE, use Claude Code's own accounting:
168
168
 
169
169
  - **`/cost`** (built-in, zero setup) — reports per-**subagent** and per-**slash-command** share of your usage
170
170
  over the last 24 h / 7 d (e.g. _"Top subagents: frontend 7 %, backend 4 % · Top skills: /build 1 %,
@@ -176,6 +176,14 @@ it. For what's EXPENSIVE, use Claude Code's own accounting:
176
176
  `session.id` + model + type (input/output/cacheRead). Subagent tokens roll into the session total;
177
177
  per-subagent attribution needs traces (`CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1`, beta).
178
178
 
179
+ **Lead context discipline — the silent bill.** The lead session's conversation history is re-sent as
180
+ input on EVERY turn; a session that spans spec→build→smoke→review→fix without clearing re-pays the
181
+ accumulated spec walk-through, handoffs, and reports on each turn. The pipeline is built so this is
182
+ never necessary: every phase handoff (spec, contract, diff, staged reports) lives on disk, so `/clear`
183
+ at each phase boundary is always safe — each command's closing line recommends it. Corollaries the
184
+ commands enforce: never paste a diff into a dispatch (agents compute their own, scoped); never echo a
185
+ staged report or design brief into chat; redirect bulky command output to a file and grep it.
186
+
179
187
  ## Rendering / reconciling a surface agent (shared procedure)
180
188
 
181
189
  Both `/init-pipeline` (initial render) and `/build` (auto-reconcile when a spec needs a new agent) use
@@ -196,16 +204,18 @@ this exact procedure so a surface is always defined the same way. To add surface
196
204
  ones empty unless `uses_design`). For a `uses_design` surface, fill them **link-based** (never with a
197
205
  stored `design_project` id — that goes stale on a DS rebuild):
198
206
  - `<SURFACE_DESIGN_INPUT>` — a 4th input bullet: _"The **feature design** — the pages this feature
199
- touches, listed in the spec front-matter `design_files` as full links
200
- (`https://claude.ai/design/p/<projectId>?file=<file>`). For each, extract the `<projectId>` (the
207
+ touches, listed in your dispatch's design slot as full links
208
+ (`https://claude.ai/design/p/<projectId>?file=<file>`); a slot saying `none` means a fix loop with
209
+ no visual work — skip DesignSync entirely. For each link, extract the `<projectId>` (the
201
210
  `/p/…` segment) and `<file>` (the `?file=` query) from the URL and read it read-only via `DesignSync
202
211
  get_file(<projectId>, <file>)`; `list_files(<projectId>)` to catch linked pages (shared nav/modals)
203
212
  this feature also changes. The link is self-contained — no stored project id. Build with the code UI
204
213
  kit (the `design_system_project`'s materialization: `@/components/ui/*` + tokens); read a primitive
205
214
  via `get_file` only if it's missing/stale in code. Mobile-first."_
206
- - `<SURFACE_TDD_STEP1>` — _"**Pull the feature design first:** `DesignSync get_file(<projectId>,
207
- <file>)` for each `design_files` link and translate each into the code design system
208
- (`@/components/ui/*`, `cn()` + CVA), mobile-first — never ad-hoc CSS. Then:"_
215
+ - `<SURFACE_TDD_STEP1>` — _"**Pull the feature design first** (skip if your dispatch's design slot
216
+ says `none`): `DesignSync get_file(<projectId>, <file>)` for each link in the slot and translate
217
+ each into the code design system (`@/components/ui/*`, `cn()` + CVA), mobile-first — never ad-hoc
218
+ CSS. Then:"_
209
219
  3. **Add a §Conventions + §Testing stanza** for `S` in `PIPELINE.md` (mirror a sibling surface; keep it
210
220
  rule-shaped). If `S` is a shared-code surface, its convention is "single owner of shared X; slices
211
221
  consume, never redefine."
@@ -270,11 +280,16 @@ Once shipped, `/ship` appends the **PR number** to the card — `- [ ] <title> #
270
280
  The bare `#<num>` is what the dashboard renders as a clickable link to the GitHub PR, so `/ship` always
271
281
  writes it when a PR was actually created.
272
282
 
273
- **Move a card (the core op).** To move card `#<id>` to a stage's column: find the list item carrying
274
- `#<id>` under its current `## <column>` heading, delete it there, and append it (whole line, tag
275
- preserved) under the target `## <column>` heading. If no card carries `#<id>` (feature started outside
276
- the board), create the card in the target column instead of erroring. One card per `#<id>`; if
277
- duplicates exist, keep the first and drop the rest.
283
+ **Move a card (the core op).** Use the shipped script — it does the whole op outside the agent's
284
+ context (find, dedupe, sub-notes carried along, settings block preserved):
285
+ `<core>/pipeline/scripts/kanban-move.sh <board.md> <id> <column> [--pr <num>] [--title <title>]`
286
+ where `<core>` is `~/.claude` (global install) or `.claude` (bundled) — probe with `test -x`. It
287
+ creates the card in the target column when none exists, keeps the first and drops duplicates, and
288
+ appends ` — PR #<num>` with `--pr`. **Fallback when the script is absent** (older core): do it by
289
+ hand, but never read the whole board into context — it grows with every feature ever tracked:
290
+ `grep -n` for `#<id>` and the `## ` headings to locate lines, then use offset-limited Reads +
291
+ targeted Edits around the matches. Either way: one card per `#<id>`, whole line moved tag-preserved,
292
+ card created in the target column if missing.
278
293
 
279
294
  **Stage → column**, used both by each pipeline command (to move its card live) and by backfill:
280
295
 
@@ -0,0 +1,88 @@
1
+ #!/bin/sh
2
+ # kanban-move.sh — move a feature card on an Obsidian Kanban board without
3
+ # loading the board into an agent's context (SCHEMA.md §Kanban "Move a card").
4
+ #
5
+ # kanban-move.sh <board.md> <feature-id> <target-column> [--pr <num>] [--title <title>]
6
+ #
7
+ # Behavior (mirrors the documented manual op):
8
+ # - finds the list item tagged #<feature-id> (plus its indented sub-notes),
9
+ # removes it from its current column, appends it under `## <target-column>`
10
+ # - no card yet ⇒ creates `- [ ] <title|id> #<id>` in the target column
11
+ # - duplicates ⇒ keeps the first, drops the rest
12
+ # - --pr N ⇒ ensures the card line ends with ` — PR #N`
13
+ # - never touches the front-matter or the trailing `%% kanban:settings %%` block
14
+ # Exit codes: 0 ok · 2 usage · 3 board/column not found.
15
+ set -eu
16
+
17
+ [ $# -ge 3 ] || { echo "usage: kanban-move.sh <board.md> <feature-id> <column> [--pr <num>] [--title <title>]" >&2; exit 2; }
18
+ board="$1"; id="$2"; col="$3"; shift 3
19
+ pr=""; title=""
20
+ while [ $# -gt 0 ]; do
21
+ case "$1" in
22
+ --pr) pr="$2"; shift 2 ;;
23
+ --title) title="$2"; shift 2 ;;
24
+ *) echo "error: unknown flag $1" >&2; exit 2 ;;
25
+ esac
26
+ done
27
+ [ -f "$board" ] || { echo "error: board not found: $board" >&2; exit 3; }
28
+
29
+ tmp="${board}.kanban-move.$$"
30
+ ID="$id" COL="$col" PR="$pr" TITLE="${title:-$id}" awk '
31
+ BEGIN {
32
+ id = ENVIRON["ID"]; col = tolower(ENVIRON["COL"])
33
+ pr = ENVIRON["PR"]; title = ENVIRON["TITLE"]
34
+ n = 0; card = ""; incard = 0; found = 0; colseen = 0
35
+ }
36
+ { lines[++n] = $0 }
37
+ END {
38
+ # pass 1: extract the FIRST card block tagged #id; mark every block for removal
39
+ for (i = 1; i <= n; i++) {
40
+ l = lines[i]
41
+ if (l ~ /^- \[.\] / && index(l, "#" id) > 0) {
42
+ # word-boundary check: char after the tag must not extend the id
43
+ rest = substr(l, index(l, "#" id) + length(id) + 1, 1)
44
+ if (rest != "" && rest ~ /[A-Za-z0-9_-]/) continue
45
+ del[i] = 1
46
+ if (!found) { found = 1; card = l }
47
+ for (j = i + 1; j <= n && lines[j] ~ /^[ \t]/; j++) {
48
+ del[j] = 1
49
+ if (found && card == lines[i]) sub_notes = sub_notes lines[j] "\n"
50
+ }
51
+ i = j - 1
52
+ }
53
+ }
54
+ if (!found) card = "- [ ] " title " #" id
55
+ if (pr != "" && index(card, "PR #") == 0) card = card " — PR #" pr
56
+
57
+ # pass 2: emit, skipping removed blocks; append the card at the end of the
58
+ # target column (before the next ## heading / settings block / EOF)
59
+ intarget = 0; placed = 0
60
+ for (i = 1; i <= n; i++) {
61
+ if (del[i]) continue
62
+ l = lines[i]
63
+ atheading = (l ~ /^## /)
64
+ atsettings = (l ~ /^%% kanban:settings/)
65
+ if (intarget && (atheading || atsettings)) {
66
+ # back up over trailing blank lines already printed is not possible;
67
+ # instead print card just before this boundary
68
+ print card
69
+ if (sub_notes != "") printf "%s", sub_notes
70
+ print ""
71
+ intarget = 0; placed = 1
72
+ }
73
+ if (atheading) {
74
+ h = tolower(l); sub(/^## +/, "", h); gsub(/ +$/, "", h)
75
+ if (h == col) { intarget = 1; colseen = 1 }
76
+ }
77
+ print l
78
+ }
79
+ if (intarget && !placed) {
80
+ print card
81
+ if (sub_notes != "") printf "%s", sub_notes
82
+ placed = 1
83
+ }
84
+ if (!colseen) exit 3
85
+ }
86
+ ' "$board" > "$tmp" || { rc=$?; rm -f "$tmp"; [ "$rc" = 3 ] && echo "error: column \"$col\" not found in $board" >&2; exit "$rc"; }
87
+ mv "$tmp" "$board"
88
+ echo "moved #$id -> $col${pr:+ (PR #$pr)}"
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ // Validates the structural invariants of the cohorte core — the things a
3
+ // prose refactor can silently break. Run by CI on every push/PR; run it
4
+ // locally with `node scripts/validate-core.mjs`.
5
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ const root = new URL("..", import.meta.url).pathname;
9
+ const errors = [];
10
+ const fail = (file, msg) => errors.push(`${file}: ${msg}`);
11
+
12
+ const read = (p) => readFileSync(join(root, p), "utf8");
13
+ const frontmatter = (text) => {
14
+ const m = text.match(/^---\n([\s\S]*?)\n---\n/);
15
+ return m ? m[1] : null;
16
+ };
17
+
18
+ // ── commands ────────────────────────────────────────────────────────────────
19
+ // Mechanical commands must pin model: sonnet (otherwise the lead's
20
+ // orchestration turn silently bills at the session model — Opus/Fable).
21
+ // Interactive commands must stay unpinned (they inherit on purpose).
22
+ const PINNED = ["build", "review", "fix", "smoke", "ship", "audit",
23
+ "refactor", "doctor", "align-ds", "update-pipeline"];
24
+ const UNPINNED = ["brainstorm", "spec", "init-pipeline"];
25
+
26
+ for (const f of readdirSync(join(root, "core/commands"))) {
27
+ const path = `core/commands/${f}`;
28
+ const fm = frontmatter(read(path));
29
+ if (!fm) { fail(path, "missing or malformed YAML frontmatter"); continue; }
30
+ if (!/^description:\s*\S/m.test(fm)) fail(path, "frontmatter lacks a description");
31
+ const name = f.replace(/\.md$/, "");
32
+ const pinned = /^model:\s*sonnet\s*$/m.test(fm);
33
+ if (PINNED.includes(name) && !pinned)
34
+ fail(path, "mechanical command must carry `model: sonnet` in frontmatter");
35
+ if (UNPINNED.includes(name) && /^model:/m.test(fm))
36
+ fail(path, "interactive command must NOT pin a model (inherits the session)");
37
+ }
38
+
39
+ // ── fixed agents ────────────────────────────────────────────────────────────
40
+ // Every non-template agent needs name/tools/model, and must be shipped by
41
+ // both installers (a new agent that install.sh doesn't copy never reaches
42
+ // a global install — the exact bug that motivated this check).
43
+ const AGENT_MODEL = { review: "sonnet", release: "haiku", smoke: "sonnet" };
44
+ const installSh = read("install.sh");
45
+ const installPs1 = read("install.ps1");
46
+
47
+ for (const f of readdirSync(join(root, "core/agents"))) {
48
+ const path = `core/agents/${f}`;
49
+ const text = read(path);
50
+ const fm = frontmatter(text);
51
+ if (!fm) { fail(path, "missing or malformed YAML frontmatter"); continue; }
52
+ if (f === "implementer.template.md") {
53
+ for (const ph of ["<SURFACE_AGENT>", "<SURFACE_LABEL>", "<SURFACE_PATH>",
54
+ "<SURFACE_TOOLS>", "<SURFACE_MODEL>", "<PROJECT_NAME>",
55
+ "<SURFACE_EXTRA_NEVER>", "<SURFACE_DESIGN_INPUT>", "<SURFACE_TDD_STEP1>"])
56
+ if (!text.includes(ph)) fail(path, `render placeholder ${ph} disappeared`);
57
+ continue;
58
+ }
59
+ const name = f.replace(/\.md$/, "");
60
+ if (!/^name:\s*\S/m.test(fm)) fail(path, "frontmatter lacks name");
61
+ if (!/^tools:\s*\S/m.test(fm)) fail(path, "frontmatter lacks tools");
62
+ const want = AGENT_MODEL[name];
63
+ if (want && !new RegExp(`^model:\\s*${want}\\s*$`, "m").test(fm))
64
+ fail(path, `frontmatter must pin \`model: ${want}\``);
65
+ if (text.includes("<SURFACE_"))
66
+ fail(path, "unrendered <SURFACE_*> placeholder in a non-template agent");
67
+ if (!installSh.includes(`core/agents/${f}`))
68
+ fail("install.sh", `does not copy core/agents/${f} (copy_fixed_agents)`);
69
+ if (!installPs1.includes(`core\\agents\\${f}`))
70
+ fail("install.ps1", `does not copy core\\agents\\${f} (Copy-FixedAgents)`);
71
+ }
72
+
73
+ // ── cross-references ────────────────────────────────────────────────────────
74
+ // Any `.claude/templates/<x>.md` referenced by a command or agent must exist
75
+ // in core/templates (they resolve to the installed copy of exactly that file).
76
+ const allDocs = [];
77
+ for (const dir of ["core/commands", "core/agents", "core/templates"])
78
+ for (const f of readdirSync(join(root, dir)))
79
+ if (f.endsWith(".md")) allDocs.push(`${dir}/${f}`);
80
+
81
+ for (const path of allDocs) {
82
+ const text = read(path);
83
+ for (const m of text.matchAll(/\.claude\/templates\/([a-z0-9.-]+\.md)/g)) {
84
+ if (!existsSync(join(root, "core/templates", m[1])))
85
+ fail(path, `references .claude/templates/${m[1]} which is not in core/templates/`);
86
+ }
87
+ for (const m of text.matchAll(/subagent_type:\s*(?:`|)([a-z-]+)(?:`|)/g)) {
88
+ const t = m[1];
89
+ if (["review", "release", "smoke"].includes(t)) continue;
90
+ if (t.startsWith("<")) continue; // <surface.agent> placeholder
91
+ if (!existsSync(join(root, "core/agents", `${t}.md`)))
92
+ fail(path, `dispatches subagent_type ${t} with no core/agents/${t}.md`);
93
+ }
94
+ }
95
+
96
+ // ── profile ─────────────────────────────────────────────────────────────────
97
+ for (const p of ["profile/PIPELINE.template.md", "profile/SCHEMA.md",
98
+ "profile/cohorte.config.template.yaml"])
99
+ if (!existsSync(join(root, p))) fail(p, "missing");
100
+
101
+ const tpl = read("profile/PIPELINE.template.md");
102
+ if (/^\s*model:\s*inherit\b/m.test(tpl))
103
+ fail("profile/PIPELINE.template.md",
104
+ "a surfaces[] example pins `model: inherit` — examples must default to sonnet " +
105
+ "(inherit bills at the lead session's model)");
106
+
107
+ // ── init-pipeline router steps ──────────────────────────────────────────────
108
+ const steps = join(root, "core/templates/steps/init-pipeline");
109
+ if (!existsSync(steps) || readdirSync(steps).length === 0)
110
+ fail("core/templates/steps/init-pipeline", "router step files missing/empty");
111
+
112
+ // ── report ──────────────────────────────────────────────────────────────────
113
+ if (errors.length) {
114
+ console.error(`validate-core: ${errors.length} error(s)\n`);
115
+ for (const e of errors) console.error(" ✗ " + e);
116
+ process.exit(1);
117
+ }
118
+ console.log("validate-core: OK");