cohorte 1.3.3 → 1.3.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +4 -4
  3. package/bin/cli.js +22 -4
  4. package/core/agents/implementer.template.md +10 -5
  5. package/core/commands/cycle.md +15 -8
  6. package/core/commands/doctor.md +3 -1
  7. package/core/hooks/gate.py +21 -6
  8. package/core/templates/agent-handoff.md +7 -2
  9. package/core/templates/review-feedback.md +7 -4
  10. package/core/templates/spec.template.md +5 -2
  11. package/core/templates/steps/init-pipeline/04-write-render.md +2 -1
  12. package/core/workflows/audit.js +20 -3
  13. package/core/workflows/cycle.js +146 -40
  14. package/core/workflows/refactor.js +16 -5
  15. package/core/workflows/review.js +59 -7
  16. package/dashboard/README.md +22 -5
  17. package/dashboard/dist/assets/index-AFQnlfjO.css +1 -0
  18. package/dashboard/dist/assets/{index-BxgA_mz1.js → index-DLBzciIC.js} +12 -11
  19. package/dashboard/dist/index.html +2 -2
  20. package/dashboard/server/doctor.js +60 -19
  21. package/dashboard/server/fleet.js +19 -5
  22. package/dashboard/server/index.js +79 -7
  23. package/dashboard/server/metrics.js +15 -4
  24. package/dashboard/server/versions.js +28 -6
  25. package/dashboard/server/yaml.js +4 -1
  26. package/install.ps1 +4 -0
  27. package/install.sh +19 -1
  28. package/package.json +5 -2
  29. package/profile/SCHEMA.md +28 -9
  30. package/scripts/kanban-move.sh +34 -20
  31. package/scripts/new-feature.sh.template +3 -1
  32. package/scripts/preflight.sh +16 -3
  33. package/scripts/remove-feature.sh.template +2 -1
  34. package/scripts/telemetry-send.sh +15 -1
  35. package/scripts/test-dashboard.mjs +356 -0
  36. package/scripts/test-gate.mjs +273 -0
  37. package/scripts/test-workflows.mjs +443 -0
  38. package/scripts/validate-core.mjs +49 -0
  39. package/dashboard/dist/assets/index-Cj0SpgEY.css +0 -1
package/profile/SCHEMA.md CHANGED
@@ -162,7 +162,11 @@ Coarse first, specialize on evidence: start with one `frontend` / `backend` surf
162
162
  surface that's proven slow and cleanly separable. The evidence lives in
163
163
  the **main checkout's** `.claude/pipeline-metrics.jsonl` (gitignored) — one JSONL line per phase batch
164
164
  (`ts`/`feature`/`phase`/`seconds`/`surfaces:{key: result}`), appended by `/build`, `/review`, `/fix`
165
- and `/smoke`. Always the main checkout, never the feature worktree (which dies at teardown while
165
+ and `/smoke`, plus a `phase: "cycle"` line from the cycle workflow.
166
+ **`surfaces` keys are surface keys, nothing else** — run-level facts go in their own top-level
167
+ fields (the cycle line carries `rounds` and `smoke` there). Anything put inside `surfaces` is read
168
+ as a surface: the dashboard renders it as a row in the per-surface table and scores a non-`ok`
169
+ value as that surface failing. Always the main checkout, never the feature worktree (which dies at teardown while
166
170
  metrics must accumulate across features) — resolve from anywhere with
167
171
  `$(dirname "$(git rev-parse --git-common-dir)")/.claude/pipeline-metrics.jsonl`. Read it before
168
172
  proposing a split: split the surface that actually dominates wall-clock, not the one that feels big.
@@ -268,7 +272,9 @@ this exact procedure so a surface is always defined the same way. To add surface
268
272
  this feature also changes. The link is self-contained — no stored project id. Build with the code UI
269
273
  kit (the `design_system_project`'s materialization: `@/components/ui/*` + tokens); read a primitive
270
274
  via `get_file` only if it's missing/stale in code. Mobile-first."_
271
- - `<SURFACE_TDD_STEP1>` — _"**Pull the feature design first** (skip if your dispatch's design slot
275
+ - `<SURFACE_TDD_STEP1>` — a **lead-in paragraph** above the TDD list (not a numbered item; it
276
+ renders as nothing for a non-design surface, which is why the list must not start at it):
277
+ _"**Pull the feature design first** (skip if your dispatch's design slot
272
278
  says `none`): `DesignSync get_file(<projectId>, <file>)` for each link in the slot and translate
273
279
  each into the code design system (`@/components/ui/*`, `cn()` + CVA), mobile-first — never ad-hoc
274
280
  CSS. Then:"_
@@ -313,9 +319,9 @@ itself changes in ways `/build` §1.5 can't auto-grow (e.g. package manager or c
313
319
 
314
320
  ## Workflows — deterministic multi-agent runs (opt-in)
315
321
 
316
- Three phases have a **workflow variant** — a deterministic orchestration script the Claude Code
322
+ Four phases have a **workflow variant** — a deterministic orchestration script the Claude Code
317
323
  Workflow runtime executes instead of the lead reasoning out the fan-out turn by turn:
318
- `<core>/workflows/review.js`, `audit.js`, `refactor.js` (installed to `.claude/workflows/` bundled or
324
+ `<core>/workflows/review.js`, `audit.js`, `refactor.js`, `cycle.js` (installed to `.claude/workflows/` bundled or
319
325
  `~/.claude/workflows/` global). The conversational commands (`/review`, `/audit`, `/refactor`)
320
326
  **remain the default path and the fallback** — a workflow runs only when the human explicitly asks
321
327
  for it ("run the review workflow", or via the `/cycle <id>` launcher command, which resolves
@@ -325,7 +331,7 @@ enabled.
325
331
  `/brainstorm`, `/spec`) and the dispatch-only ones (`/build`, `/ship`) have **no** workflow variant on
326
332
  purpose: they're interviews or already a single parallel dispatch — a script adds nothing.
327
333
 
328
- Shared design, all three scripts:
334
+ Shared design, all four scripts:
329
335
 
330
336
  - **Phase 0 is always `profile-reader`** — workflow scripts have no filesystem or shell access, so a
331
337
  dedicated agent (`core/agents/profile-reader.md`, haiku, read-only) reads `PIPELINE.md` and returns
@@ -336,6 +342,12 @@ Shared design, all three scripts:
336
342
  - **Only the verdict comes back.** Bulk (diffs, reports, backlogs) is staged to the same disk
337
343
  buffers the commands use (`specs/reports/`, `specs/refactor-backlog.md`); the workflow's return is
338
344
  counts + verdict + paths.
345
+ - **A dead agent is never a clean result.** `agent()` resolves to `null` when a subagent dies, and a
346
+ dead *reviewer* returns zero findings — byte-identical to a surface that is genuinely clean. Any
347
+ script that derives a verdict from "how many findings came back" must first subtract the agents
348
+ that never answered: `review.js` and `cycle.js` name them in `unreviewedSurfaces`, refuse to score
349
+ `SHIP`, and (in the cycle) never tick the DoD or stamp the freshness gate. `scripts/test-workflows.mjs`
350
+ pins this — it is the one invariant the structural checks in `validate-core.mjs` cannot see.
339
351
  - **`review.js`** — preflight gate (aborts red, zero agents), one `git diff --stat` staged per
340
352
  touched surface, one reviewer per surface in parallel, then an **adversarial cross-check** phase
341
353
  that tries to refute each CRITICAL/security finding before it can trigger a fix loop.
@@ -345,8 +357,8 @@ Shared design, all three scripts:
345
357
  conversational `/refactor` is cheaper there): `shared` first and alone, then the other domains'
346
358
  implementers in parallel, each verified per-domain.
347
359
  - **`cycle.js`** — the **full dev cycle** on a frozen spec: contract → parallel build → rounds of
348
- [preflight → smoke ∥ review(+cross-check) → fix on the surfaces with findings], looping until
349
- **zero open findings + a PASS smoke** (`maxRounds`, default 5, and the token budget are runaway
360
+ [preflight → review(+cross-check) (∥ smoke if opted in) → fix on the surfaces with findings], looping until
361
+ **zero open findings (+ a PASS smoke when opted in)** (`maxRounds`, default 5, and the token budget are runaway
350
362
  protection, not targets). Since a workflow can't ask anything mid-run, the decisions move to the
351
363
  edges: a **readiness gate** aborts up front if the spec isn't frozen (other gaps ride along as
352
364
  deferred questions), and everything genuinely human comes back at the END in the result's
@@ -355,7 +367,8 @@ Shared design, all three scripts:
355
367
  contract file (exactly what conversational `/fix` §1 does — implementers still never touch it),
356
368
  the consuming surfaces re-dispatch, and the loop continues; the re-authorings are reported in the
357
369
  result's `contractChanges` for the human to review in the diff. A clean exit ticks the DoD and
358
- stamps the freshness gate so `/ship <id>` is a straight shot; a stopped run appends its open
370
+ stamps the freshness gate (when smoke was skipped the default the runtime-flows DoD box stays
371
+ unticked and `/ship` flags it); a stopped run appends its open
359
372
  findings to the spec's `## Remediation` so a rerun of the cycle — or a conversational `/fix` —
360
373
  continues seamlessly. `/ship` itself stays outside on purpose — outward-facing and irreversible,
361
374
  it keeps its human confirmation.
@@ -416,7 +429,7 @@ card created in the target column if missing.
416
429
  | `/spec` opens (draft) | `spec` |
417
430
  | `/spec` freezes (`status: frozen`) | `ready` |
418
431
  | `/build` | `building` |
419
- | `/smoke` · `/review` | `review` |
432
+ | `/review` (owns the move — `/smoke` never moves the card) | `review` |
420
433
  | `/fix` | `fix` |
421
434
  | `/ship` starts | `ship` |
422
435
  | PR opened (`status: shipped`) | `shipped` (+ `PR #<num>` on the card) |
@@ -457,6 +470,12 @@ see where features stall, so every stage of `idea → PR` reports and nothing el
457
470
  | `fix` | after the batch metrics line | wall-clock | `<fixed>/<found>` |
458
471
  | `ship` | the release agent succeeded | `0` | `pr` / `compare` |
459
472
 
473
+ > Workflow-variant runs (`review.js`, `cycle.js`) report `seconds: 0` for their phases — only the
474
+ > conversational commands measure wall-clock. `cycle.js` also reports `fix` as `rounds:<n>` (the
475
+ > number of fix dispatches it made) rather than `<fixed>/<found>`: it never counts items the way a
476
+ > conversational `/fix` does. `results` is a free-text summary field, so both forms are valid — but
477
+ > read the `fix` column knowing which path produced it.
478
+
460
479
  `seconds: 0` marks a phase whose duration is human thinking time, not pipeline wall-clock — the
461
480
  funnel signal there is the event, not how long it took. `/doctor`, `/audit`, `/refactor`,
462
481
  `/align-ds`, `/init-pipeline` and `/update-pipeline` **never** ping: they sit outside the funnel, and
@@ -28,28 +28,44 @@ done
28
28
 
29
29
  tmp="${board}.kanban-move.$$"
30
30
  ID="$id" COL="$col" PR="$pr" TITLE="${title:-$id}" awk '
31
+ # Emit one line, collapsing runs of blank lines to a single one. Without this
32
+ # every move netted +1 blank line in the target column (the card is inserted
33
+ # with its own separator, right after the blank that already preceded the next
34
+ # heading) — ten moves of one card padded a board with fifteen blank lines.
35
+ function emit(s) {
36
+ if (s == "") { if (lastblank) return; lastblank = 1 } else lastblank = 0
37
+ print s
38
+ }
39
+ function put_card( k) {
40
+ emit(card)
41
+ for (k = 1; k <= sn; k++) emit(snote[k])
42
+ }
31
43
  BEGIN {
32
44
  id = ENVIRON["ID"]; col = tolower(ENVIRON["COL"])
33
45
  pr = ENVIRON["PR"]; title = ENVIRON["TITLE"]
34
- n = 0; card = ""; incard = 0; found = 0; colseen = 0
46
+ n = 0; card = ""; found = 0; colseen = 0; sn = 0; lastblank = 0
35
47
  }
36
48
  { lines[++n] = $0 }
37
49
  END {
38
50
  # pass 1: extract the FIRST card block tagged #id; mark every block for removal
39
51
  for (i = 1; i <= n; i++) {
40
52
  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
53
+ if (l !~ /^- \[.\] /) continue
54
+ p = index(l, "#" id)
55
+ if (p == 0) continue
56
+ # word-boundary check: char after the tag must not extend the id
57
+ rest = substr(l, p + length(id) + 1, 1)
58
+ if (rest != "" && rest ~ /[A-Za-z0-9_-]/) continue
59
+ # Capture the sub-notes of the FIRST match only. Testing `card == lines[i]`
60
+ # instead also captured a later duplicate whose text happened to match.
61
+ isfirst = 0
62
+ if (!found) { found = 1; card = l; isfirst = 1 }
63
+ del[i] = 1
64
+ for (j = i + 1; j <= n && lines[j] ~ /^[ \t]/; j++) {
65
+ del[j] = 1
66
+ if (isfirst) snote[++sn] = lines[j]
52
67
  }
68
+ i = j - 1
53
69
  }
54
70
  if (!found) card = "- [ ] " title " #" id
55
71
  if (pr != "" && index(card, "PR #") == 0) card = card " — PR #" pr
@@ -63,22 +79,20 @@ ID="$id" COL="$col" PR="$pr" TITLE="${title:-$id}" awk '
63
79
  atheading = (l ~ /^## /)
64
80
  atsettings = (l ~ /^%% kanban:settings/)
65
81
  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 ""
82
+ # can not back up over already-printed lines; place the card just before
83
+ # this boundary instead
84
+ put_card()
85
+ emit("")
71
86
  intarget = 0; placed = 1
72
87
  }
73
88
  if (atheading) {
74
89
  h = tolower(l); sub(/^## +/, "", h); gsub(/ +$/, "", h)
75
90
  if (h == col) { intarget = 1; colseen = 1 }
76
91
  }
77
- print l
92
+ emit(l)
78
93
  }
79
94
  if (intarget && !placed) {
80
- print card
81
- if (sub_notes != "") printf "%s", sub_notes
95
+ put_card()
82
96
  placed = 1
83
97
  }
84
98
  if (!colseen) exit 3
@@ -2,7 +2,9 @@
2
2
  #
3
3
  # new-feature.sh — spin up an ISOLATED git worktree for one feature so features
4
4
  # can be built fully in parallel. Rendered from a template by /init-pipeline;
5
- # the __TOKENS__ below are substituted from PIPELINE.md §isolation.
5
+ # the double-underscore tokens below are substituted from PIPELINE.md §isolation.
6
+ # (This comment deliberately avoids spelling the token pattern: /doctor flags any
7
+ # leftover double-underscore-caps token in the RENDERED script as unrendered.)
6
8
  #
7
9
  # Usage: scripts/new-feature.sh <feature_id>
8
10
  #
@@ -40,9 +40,22 @@ for cmd in "$@"; do
40
40
  done
41
41
 
42
42
  # Stamp for the gate.py phase gate: epoch + HEAD sha of the checkout we verified.
43
- proj="${CLAUDE_PROJECT_DIR:-.}"
43
+ # The stamp MUST land where gate.py reads it: <main checkout>/.claude. CLAUDE_PROJECT_DIR
44
+ # is only exported to hooks — in an agent's Bash call it is unset, and a bare `.` would
45
+ # drop the stamp in the feature worktree where the gate never looks. git-common-dir
46
+ # resolves to the MAIN checkout's .git from any worktree.
47
+ proj="${CLAUDE_PROJECT_DIR:-$(dirname "$(git rev-parse --git-common-dir 2>/dev/null || echo ./.git)")}"
44
48
  sha=$(git rev-parse HEAD 2>/dev/null || echo none)
45
- mkdir -p "$proj/.claude" 2>/dev/null || true
46
- printf '%s %s\n' "$(date +%s)" "$sha" > "$proj/.claude/preflight.ok" 2>/dev/null || true
49
+ # Stamp BOTH the main checkout and the cwd: gate.py reads CLAUDE_PROJECT_DIR,
50
+ # which is the worktree when the session was opened there and the main checkout
51
+ # when it wasn't — the two disagree, and either layout is supported.
52
+ now=$(date +%s)
53
+ last=""
54
+ for d in "$proj" "$(pwd)"; do
55
+ [ "$d" = "$last" ] && continue # same dir twice in the main checkout
56
+ last="$d"
57
+ mkdir -p "$d/.claude" 2>/dev/null || true
58
+ printf '%s %s\n' "$now" "$sha" > "$d/.claude/preflight.ok" 2>/dev/null || true
59
+ done
47
60
 
48
61
  echo "PREFLIGHT PASS ($n checks green) — full log: $report"
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  #
3
3
  # remove-feature.sh — tear down a feature's isolated worktree. Rendered from a
4
- # template by /init-pipeline (__TOKENS__ from PIPELINE.md §isolation).
4
+ # template by /init-pipeline (double-underscore tokens from PIPELINE.md §isolation
5
+ # spelled out here so /doctor's unrendered-token check never trips on this comment).
5
6
  #
6
7
  # Usage: scripts/remove-feature.sh <feature_id> [--drop-db]
7
8
  #
@@ -43,12 +43,26 @@ case "$phase" in
43
43
  *) exit 0 ;;
44
44
  esac
45
45
 
46
+ # The payload is hand-built JSON: a non-numeric seconds or a quote in results
47
+ # would silently produce an invalid document the collector drops.
48
+ case "$seconds" in ''|*[!0-9]*) seconds=0 ;; esac
49
+ results=$(printf '%s' "$results" | tr -d '"\\\n\r' | cut -c1-80)
50
+
46
51
  if command -v shasum >/dev/null 2>&1; then
47
52
  fhash=$(printf '%s' "$feature" | shasum -a 256 | cut -c1-12)
48
53
  else
49
54
  fhash=$(printf '%s' "$feature" | sha256sum | cut -c1-12)
50
55
  fi
51
- ver=$(cat "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/pipeline/VERSION" 2>/dev/null | head -1)
56
+ # The core that shipped THIS script is the one whose version we report. Bundled
57
+ # installs live at <project>/.claude/pipeline/scripts/, where the global VERSION is
58
+ # either absent or a DIFFERENT core — reading only the global path made every
59
+ # bundled repo report an empty core_version.
60
+ here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd 2>/dev/null) || here=""
61
+ ver=""
62
+ for v in "$here/../VERSION" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/pipeline/VERSION"; do
63
+ [ -n "$ver" ] && break
64
+ ver=$(head -1 "$v" 2>/dev/null | tr -d '"\\')
65
+ done
52
66
  os=$(uname -s 2>/dev/null || echo unknown)
53
67
  ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
54
68
 
@@ -0,0 +1,356 @@
1
+ #!/usr/bin/env node
2
+ // Tests for the dashboard's server modules (dashboard/server/*.js).
3
+ //
4
+ // These are shipped runtime code with real logic and zero coverage until now:
5
+ // a hand-rolled YAML parser that every /doctor check is derived from, a metrics
6
+ // aggregator, the JS port of /doctor, an Obsidian board parser, the fleet
7
+ // registry, and an HTTP layer whose guards are the dashboard's only defence
8
+ // against a web page driving the local agent.
9
+ //
10
+ // node scripts/test-dashboard.mjs
11
+
12
+ import { createRequire } from "node:module";
13
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import net from "node:net";
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const root = fileURLToPath(new URL("..", import.meta.url));
21
+ const { parse, parseProfileBlock } = require(join(root, "dashboard/server/yaml.js"));
22
+ const { metrics } = require(join(root, "dashboard/server/metrics.js"));
23
+ const { state, scanSpecs } = require(join(root, "dashboard/server/doctor.js"));
24
+ const { kanban } = require(join(root, "dashboard/server/kanban.js"));
25
+ const fleet = require(join(root, "dashboard/server/fleet.js"));
26
+
27
+ let failures = 0;
28
+ const check = (name, cond, detail = "") => {
29
+ if (cond) console.log(` ✓ ${name}`);
30
+ else { failures++; console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`); }
31
+ };
32
+ const eq = (name, got, want) =>
33
+ check(name, JSON.stringify(got) === JSON.stringify(want), `got ${JSON.stringify(got)}`);
34
+
35
+ const tmps = [];
36
+ const scratch = () => { const d = mkdtempSync(join(tmpdir(), "dash-")); tmps.push(d); return d; };
37
+ const write = (p, s) => { mkdirSync(join(p, ".."), { recursive: true }); writeFileSync(p, s); };
38
+
39
+ // ── yaml.js ──────────────────────────────────────────────────────────────────
40
+ console.log("yaml.js — the profile parser");
41
+ {
42
+ eq("scalars: bool / int / null / quoted",
43
+ parse("a: true\nb: 12\nc: null\nd: \"x: y\"\ne: ~"),
44
+ { a: true, b: 12, c: null, d: "x: y", e: null });
45
+ eq("flow array", parse("t: [Read, Write, mcp__serena]").t, ["Read", "Write", "mcp__serena"]);
46
+ eq("flow map", parse("p: { api: 3333, web: 5173 }").p, { api: 3333, web: 5173 });
47
+ eq("nested map", parse("a:\n b:\n c: 1").a.b, { c: 1 });
48
+ eq("block sequence of scalars", parse("d:\n - one\n - two").d, ["one", "two"]);
49
+ eq("block sequence of maps",
50
+ parse("s:\n - key: a\n path: x\n - key: b\n path: y").s,
51
+ [{ key: "a", path: "x" }, { key: "b", path: "y" }]);
52
+ eq("comment after a value is stripped", parse("a: 1 # note").a, 1);
53
+ eq("a # inside quotes is NOT a comment", parse('a: "x # y"').a, "x # y");
54
+ eq("a # not preceded by whitespace is literal", parse("a: c#d").a, "c#d");
55
+ eq("empty value ⇒ null", parse("a:").a, null);
56
+ eq("a colon in the value survives", parse('m: "cd x && node ace migration:run"').m,
57
+ "cd x && node ace migration:run");
58
+ eq("empty flow array", parse("a: []").a, []);
59
+ check("no fenced block ⇒ null", parseProfileBlock("# just prose") === null);
60
+
61
+ // The real thing: the shipped template must round-trip.
62
+ const tpl = readFileSync(join(root, "profile/PIPELINE.template.md"), "utf8");
63
+ const p = parseProfileBlock(tpl);
64
+ check("the shipped PIPELINE.template.md parses", !!p);
65
+ eq("…surfaces are a list of 2", (p.surfaces || []).length, 2);
66
+ eq("…surface tools survive as an array", p.surfaces[0].tools.length, 7);
67
+ eq("…uses_design is a real boolean", p.surfaces[1].uses_design, true);
68
+ eq("…build_cmd stays an empty string, not null", p.surfaces[0].build_cmd, "");
69
+ eq("…gate.deny is a list of 4", p.gate.deny.length, 4);
70
+ eq("…gate.preflight.max_age_minutes is a number", p.gate.preflight.max_age_minutes, 30);
71
+ eq("…port_base flow map", p.isolation.port_base, { api: 3333, web: 5173 });
72
+ eq("…a chained migrate command survives", p.commands.migrate, "cd apps/api && node ace migration:run");
73
+ }
74
+
75
+ // ── metrics.js ───────────────────────────────────────────────────────────────
76
+ console.log("metrics.js — the funnel aggregate");
77
+ {
78
+ const d = scratch();
79
+ const lines = [
80
+ JSON.stringify({ ts: "2026-01-01T00:00:00Z", feature: "f1", phase: "build", seconds: 100, surfaces: { backend: "ok", frontend: "error" } }),
81
+ JSON.stringify({ ts: "2026-01-01T01:00:00Z", feature: "f1", phase: "review", seconds: 50, surfaces: { backend: "REVISE:2" } }),
82
+ JSON.stringify({ ts: "2026-01-01T02:00:00Z", feature: "f1", phase: "fix", seconds: 20, surfaces: { backend: "ok" } }),
83
+ JSON.stringify({ ts: "2026-01-01T03:00:00Z", feature: "f1", phase: "cycle", seconds: 0, rounds: 3, smoke: "SKIPPED", surfaces: { backend: "SHIP:0" } }),
84
+ // legacy: one line PER surface, folded into one batch, wall-clock = max
85
+ JSON.stringify({ ts: "2026-01-02T00:00:00Z", feature: "f2", phase: "build", surface: "backend", seconds: 10, result: "ok" }),
86
+ JSON.stringify({ ts: "2026-01-02T00:00:00Z", feature: "f2", phase: "build", surface: "frontend", seconds: 40, result: "ok" }),
87
+ "not json at all",
88
+ JSON.stringify({ ts: "x", feature: "f3" }), // no phase ⇒ skipped
89
+ JSON.stringify({ ts: "x", feature: "f3", phase: "build" }), // neither surfaces nor surface ⇒ skipped
90
+ ];
91
+ mkdirSync(join(d, ".claude"), { recursive: true });
92
+ writeFileSync(join(d, ".claude", "pipeline-metrics.jsonl"), lines.join("\n") + "\n");
93
+ const m = metrics({ projectRoot: d });
94
+
95
+ eq("malformed + incomplete lines are skipped", m.batches, 5);
96
+ const f1 = m.features.find(f => f.feature === "f1");
97
+ const f2 = m.features.find(f => f.feature === "f2");
98
+ eq("per-phase wall-clock", f1.phases.build.seconds, 100);
99
+ eq("fix rounds counted", f1.fixRounds, 1);
100
+ eq("cycle rounds surface outside `surfaces`", f1.cycleRounds, 3);
101
+ eq("legacy lines fold into ONE batch", Object.keys(f2.surfaces).sort(), ["backend", "frontend"]);
102
+ eq("…with wall-clock = the slowest surface", f2.phases.build.seconds, 40);
103
+ eq("`error` counts as a surface failure", f1.surfaces.frontend.failures, 1);
104
+ eq("a REVISE verdict counts as a failure", f1.surfaces.backend.failures, 1);
105
+ check("newest feature first", m.features[0].feature === "f2", m.features[0].feature);
106
+ check("no metrics file ⇒ present:false", metrics({ projectRoot: scratch() }).present === false);
107
+ }
108
+
109
+ // ── doctor.js ────────────────────────────────────────────────────────────────
110
+ console.log("doctor.js — the /doctor port");
111
+ {
112
+ const spec = (fm) => `---\n${fm}\n---\n\n# x\n`;
113
+ const d = scratch();
114
+ mkdirSync(join(d, "specs"), { recursive: true });
115
+ writeFileSync(join(d, "specs", "a.md"), spec("feature_id: a\ntitle: A\nstatus: frozen\nbranch: feature/a"));
116
+ writeFileSync(join(d, "specs", "b.md"), spec("feature_id: b\nstatus: shipped # done"));
117
+ writeFileSync(join(d, "specs", "c.md"), "no front-matter at all");
118
+ writeFileSync(join(d, "specs", "_template.md"), spec("status: draft"));
119
+ const specs = scanSpecs(d);
120
+ eq("_template.md is excluded", specs.length, 3);
121
+ eq("front-matter fields are read", specs.find(s => s.id === "a").title, "A");
122
+ eq("a trailing comment is stripped from status", specs.find(s => s.id === "b").status, "shipped");
123
+ eq("no front-matter ⇒ id falls back to the filename", specs.find(s => s.file === "c.md").id, "c");
124
+ }
125
+ {
126
+ // A fully-wired synthetic project must come back green on the checks that can
127
+ // be computed from disk.
128
+ const g = scratch(); // stands in for ~/.claude
129
+ const d = scratch();
130
+ const gate = {
131
+ deny: ["x"], ask: ["y"], ask_on_default_branch: ["git push"], default_branch: "main",
132
+ preflight: { enabled: true, agents: ["review", "smoke"], max_age_minutes: 30 },
133
+ };
134
+ writeFileSync(join(d, "PIPELINE.md"), [
135
+ "```yaml pipeline-profile",
136
+ "name: Proj",
137
+ "retrieval:",
138
+ " provider: serena",
139
+ "surfaces:",
140
+ " - key: backend",
141
+ " path: apps/api",
142
+ " agent: backend",
143
+ "gate:",
144
+ " default_branch: main",
145
+ ' deny: ["x"]',
146
+ ' ask: ["y"]',
147
+ ' ask_on_default_branch: ["git push"]',
148
+ " preflight:",
149
+ " enabled: true",
150
+ " agents: [review, smoke]",
151
+ " max_age_minutes: 30",
152
+ "```",
153
+ ].join("\n"));
154
+ mkdirSync(join(d, ".claude", "agents"), { recursive: true });
155
+ mkdirSync(join(d, ".claude", "pipeline"), { recursive: true });
156
+ writeFileSync(join(d, ".claude", "pipeline", "VERSION"), "9.9.9\n");
157
+ writeFileSync(join(d, ".claude", "agents", "backend.md"), "x");
158
+ writeFileSync(join(d, ".claude", "gate-config.json"), JSON.stringify(gate));
159
+ writeFileSync(join(d, ".mcp.json"), JSON.stringify({ mcpServers: { serena: {} } }));
160
+ mkdirSync(join(d, ".claude", "workflows"), { recursive: true });
161
+ for (const w of ["review.js", "audit.js", "refactor.js", "cycle.js"]) {
162
+ writeFileSync(join(d, ".claude", "workflows", w), "x");
163
+ }
164
+ writeFileSync(join(d, ".claude", "agents", "profile-reader.md"), "x");
165
+ writeFileSync(join(d, ".claude", "settings.json"), JSON.stringify({
166
+ hooks: { PreToolUse: [{ matcher: "Bash|Task", hooks: [{ type: "command", command: 'py "C:\\x\\gate.py"' }] }] },
167
+ }));
168
+
169
+ const by = (checks, id) => checks.find(c => c.id === id);
170
+ let s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
171
+ eq("profile parses ⇒ ok", by(s.checks, "profile").status, "ok");
172
+ eq("surface agent present ⇒ ok", by(s.checks, "agents").status, "ok");
173
+ eq("gate mirrors the profile ⇒ ok", by(s.checks, "gate").status, "ok");
174
+ eq("Windows-quoted hook command is recognised", by(s.checks, "hooks").status, "ok");
175
+ eq("retrieval wired in .mcp.json ⇒ ok", by(s.checks, "retrieval").status, "ok");
176
+ eq("workflows + profile-reader ⇒ ok", by(s.checks, "workflows").status, "ok");
177
+
178
+ // …and each check must actually FAIL when its precondition breaks.
179
+ writeFileSync(join(d, ".claude", "gate-config.json"),
180
+ JSON.stringify({ ...gate, preflight: { enabled: false } }));
181
+ s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
182
+ check("gate-config preflight drift is detected",
183
+ by(s.checks, "gate").status === "warn" && /preflight/.test(by(s.checks, "gate").detail),
184
+ by(s.checks, "gate").detail);
185
+ writeFileSync(join(d, ".claude", "gate-config.json"), JSON.stringify(gate));
186
+
187
+ writeFileSync(join(d, ".claude", "settings.json"), JSON.stringify({
188
+ hooks: { PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "python3 /x/gate.py" }] }] },
189
+ }));
190
+ s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
191
+ check("a Bash-only matcher is flagged (the 1.3.0 dead phase gate)",
192
+ by(s.checks, "hooks").status === "warn" && /Task/.test(by(s.checks, "hooks").detail),
193
+ by(s.checks, "hooks").detail);
194
+
195
+ writeFileSync(join(d, ".claude", "settings.json"), JSON.stringify({
196
+ hooks: { PreToolUse: [
197
+ { matcher: "Bash|Task", hooks: [{ type: "command", command: "python3 /x/gate.py" }] },
198
+ { matcher: "Bash|Task", hooks: [{ type: "command", command: 'py "/x/gate.py"' }] },
199
+ ] },
200
+ }));
201
+ s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
202
+ check("a duplicate registration is flagged (it double-prompts)",
203
+ by(s.checks, "hooks").status === "warn" && /2×/.test(by(s.checks, "hooks").detail),
204
+ by(s.checks, "hooks").detail);
205
+
206
+ rmSync(join(d, ".mcp.json"));
207
+ writeFileSync(join(d, ".claude", "agents", "orphan.md"), "x");
208
+ s = await state({ projectRoot: d, globalDir: g, cliVersion: "9.9.9" });
209
+ check("provider declared but never wired ⇒ warn",
210
+ by(s.checks, "retrieval").status === "warn", by(s.checks, "retrieval").detail);
211
+ check("an agent file with no surface ⇒ orphan warn",
212
+ by(s.checks, "agents").status === "warn" && /orphan/.test(by(s.checks, "agents").detail),
213
+ by(s.checks, "agents").detail);
214
+
215
+ const bare = scratch();
216
+ s = await state({ projectRoot: bare, globalDir: g, cliVersion: "9.9.9" });
217
+ eq("no PIPELINE.md ⇒ profile bad", by(s.checks, "profile").status, "bad");
218
+ eq("no core ⇒ core bad", by(s.checks, "core").status, "bad");
219
+ }
220
+
221
+ // ── kanban.js ────────────────────────────────────────────────────────────────
222
+ console.log("kanban.js — the Obsidian board");
223
+ {
224
+ const g = scratch(), vault = scratch(), d = scratch();
225
+ writeFileSync(join(d, "PIPELINE.md"), "```yaml pipeline-profile\nname: Proj\n```");
226
+ mkdirSync(join(vault, "Proj"), { recursive: true });
227
+ writeFileSync(join(vault, "Proj", "Tasks.md"), [
228
+ "---", "kanban-plugin: board", "---", "",
229
+ "## Spec", "", "- [ ] Do a thing #feat-a", "\t- a note", "",
230
+ "## Shipped", "", "- [x] Old #feat-z — PR #42", "",
231
+ "%% kanban:settings", "%%", "",
232
+ ].join("\n"));
233
+ writeFileSync(join(g, "cohorte.config.yaml"), [
234
+ "kanban:", " enabled: true", " boards:", " Proj:", ' board: "Proj/Tasks.md"',
235
+ "obsidian:", ` vault_path: "${vault.replace(/\\/g, "/")}"`,
236
+ ].join("\n"));
237
+
238
+ const k = kanban({ projectRoot: d, globalDir: g });
239
+ check("board resolves for the profile name", k.enabled === true, k.reason);
240
+ eq("columns parsed", k.columns.map(c => c.name), ["Spec", "Shipped"]);
241
+ eq("cards counted", k.total, 2);
242
+ eq("the #tag is extracted", k.columns[0].cards[0].tags, ["feat-a"]);
243
+ eq("the tag is stripped from the display text", k.columns[0].cards[0].text, "Do a thing");
244
+ eq("a checked card is done", k.columns[1].cards[0].done, true);
245
+ eq("a bare #<num> is read as a PR reference", k.columns[1].cards[0].prs.map(p => p.num), ["42"]);
246
+ check("the settings trailer is not parsed as a column",
247
+ !k.columns.some(c => /kanban:settings/.test(c.name)));
248
+
249
+ writeFileSync(join(g, "cohorte.config.yaml"), "kanban:\n enabled: false\n");
250
+ check("kanban disabled ⇒ enabled:false with a reason",
251
+ kanban({ projectRoot: d, globalDir: g }).enabled === false);
252
+ check("no config at all ⇒ enabled:false, never a throw",
253
+ kanban({ projectRoot: d, globalDir: scratch() }).enabled === false);
254
+ }
255
+
256
+ // ── fleet.js ─────────────────────────────────────────────────────────────────
257
+ console.log("fleet.js — the project registry");
258
+ {
259
+ const g = scratch(), p1 = scratch(), p2 = scratch();
260
+ fleet.ensureSeed(g, p1);
261
+ eq("seed adds the launch project", fleet.read(g), [p1]);
262
+ fleet.ensureSeed(g, p1);
263
+ eq("seeding twice does not duplicate", fleet.read(g).length, 1);
264
+ fleet.add(g, p2);
265
+ eq("add appends", fleet.read(g).length, 2);
266
+ fleet.remove(g, p2);
267
+ eq("remove drops it", fleet.read(g), [p1]);
268
+
269
+ let threw = null;
270
+ try { fleet.add(g, "relative/path"); } catch (e) { threw = e.message; }
271
+ check("a relative path is rejected with a clear message",
272
+ /must be absolute/.test(threw || ""), threw);
273
+ threw = null;
274
+ try { fleet.add(g, join(p1, "nope")); } catch (e) { threw = e.message; }
275
+ check("a non-existent path is rejected", /not found/.test(threw || ""), threw);
276
+
277
+ // legacy registry name is read, then migrated forward on the next write
278
+ const g2 = scratch();
279
+ writeFileSync(join(g2, "thebidouille-dashboard.json"), JSON.stringify({ projects: [p1] }));
280
+ eq("the pre-rename registry is still read", fleet.read(g2), [p1]);
281
+
282
+ const b = fleet.browse(p1);
283
+ check("browse lists a directory", Array.isArray(b.dirs) && b.parent !== null);
284
+ writeFileSync(join(p1, "PIPELINE.md"), "x");
285
+ check("browse flags a pipeline project", fleet.browse(p1).isProject === true);
286
+ const missing = fleet.browse(join(p1, "does-not-exist"));
287
+ check("browse reports an unreadable dir in the body (not a throw)",
288
+ !!missing.error && missing.dirs.length === 0, JSON.stringify(missing));
289
+ }
290
+
291
+ // ── index.js — the HTTP guards ───────────────────────────────────────────────
292
+ console.log("index.js — HTTP guards");
293
+ {
294
+ const freePort = await new Promise((res) => {
295
+ const s = net.createServer();
296
+ s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => res(port)); });
297
+ });
298
+ const home = scratch(); // stands in for the user's home
299
+ const globalDir = join(home, ".claude"); // …so <home>/.claude IS the global core
300
+ mkdirSync(globalDir, { recursive: true });
301
+ const proj = scratch();
302
+ const start = require(join(root, "dashboard/server/index.js"));
303
+ start({ projectRoot: proj, globalDir, port: freePort, host: "127.0.0.1", openBrowser: false, pkgRoot: root, version: "9.9.9" });
304
+ const base = `http://127.0.0.1:${freePort}`;
305
+ const post = (body, headers = { "content-type": "application/json" }) =>
306
+ fetch(`${base}/api/action`, { method: "POST", headers, body: JSON.stringify(body) });
307
+
308
+ // `Host` is a forbidden header name for fetch/undici — it silently drops it, so
309
+ // a fetch-based assertion here passes against a server with NO guard at all.
310
+ // Speak raw HTTP instead.
311
+ const rawStatus = (host) => new Promise((res, rej) => {
312
+ const s = net.connect(freePort, "127.0.0.1", () => {
313
+ s.write(`GET /api/fleet HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`);
314
+ });
315
+ let buf = "";
316
+ s.on("data", (c) => { buf += c; });
317
+ s.on("end", () => res(Number((buf.match(/^HTTP\/1\.1 (\d+)/) || [])[1])));
318
+ s.on("error", rej);
319
+ });
320
+ eq("a forged Host header is rejected (DNS rebinding)", await rawStatus("evil.example.com"), 403);
321
+ eq("…while a loopback Host with a port passes", await rawStatus(`127.0.0.1:${freePort}`), 200);
322
+ eq("…and a bracketed IPv6 loopback passes", await rawStatus(`[::1]:${freePort}`), 200);
323
+ eq("…and bare 'localhost' passes", await rawStatus("localhost"), 200);
324
+
325
+ const csrf = await fetch(`${base}/api/projects`, {
326
+ method: "POST", headers: { "content-type": "text/plain" }, body: "path=/x",
327
+ });
328
+ eq("a state-changing request without JSON content-type is rejected (CSRF)", csrf.status, 403);
329
+
330
+ eq("a GET on the API still works", (await fetch(`${base}/api/fleet`)).status, 200);
331
+
332
+ const reset = await post({ action: "reset", project: home });
333
+ eq("reset refuses a project whose .claude IS the global core", reset.status, 400);
334
+ check("…and says why", /shared global core/.test((await reset.json()).error));
335
+
336
+ const badPath = await post({ action: "install", project: join(proj, "nope") });
337
+ eq("install refuses a non-existent project path", badPath.status, 400);
338
+
339
+ const badAction = await post({ action: "rm -rf" });
340
+ eq("an unknown action is rejected", badAction.status, 400);
341
+
342
+ const badCmd = await post({ action: "claude", command: "/evil", project: proj });
343
+ eq("a non-whitelisted slash command is rejected", badCmd.status, 400);
344
+
345
+ eq("a missing hashed asset 404s (never index.html)",
346
+ (await fetch(`${base}/assets/index-DEADBEEF.js`)).status, 404);
347
+ eq("a malformed percent-escape is a 400, not a 500",
348
+ (await fetch(`${base}/%`)).status, 400);
349
+ eq("an unknown API route 404s", (await fetch(`${base}/api/nope`)).status, 404);
350
+ }
351
+
352
+ for (const d of tmps) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
353
+ console.log("");
354
+ if (failures) { console.error(`test-dashboard: ${failures} failure(s)`); process.exit(1); }
355
+ console.log("test-dashboard: OK");
356
+ process.exit(0); // the HTTP server has no handle to close