cohorte 2.10.0 → 3.0.0-dev.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.
Files changed (211) hide show
  1. package/README.md +7 -0
  2. package/assets/manifest.json +193 -0
  3. package/assets/migrations/config/README.md +12 -0
  4. package/assets/migrations/state/0001_init.sql +154 -0
  5. package/assets/prompts/README.md +11 -0
  6. package/assets/prompts/agents/fixer.md +7 -0
  7. package/assets/prompts/agents/implementer.md +12 -0
  8. package/assets/prompts/agents/reviewer.md +8 -0
  9. package/assets/prompts/agents/security-reviewer.md +7 -0
  10. package/assets/prompts/phases/build.md +5 -0
  11. package/assets/prompts/phases/review.md +5 -0
  12. package/assets/prompts/phases/test.md +5 -0
  13. package/assets/prompts/system.md +11 -0
  14. package/assets/schemas/agent-output.schema.json +225 -0
  15. package/assets/schemas/auth-status.schema.json +87 -0
  16. package/assets/schemas/command-result.schema.json +192 -0
  17. package/assets/schemas/commands.schema.json +952 -0
  18. package/assets/schemas/config.schema.json +2259 -0
  19. package/assets/schemas/doctor-report.schema.json +104 -0
  20. package/assets/schemas/events.schema.json +8514 -0
  21. package/assets/schemas/fake-script.schema.json +549 -0
  22. package/assets/schemas/inspect.schema.json +3133 -0
  23. package/assets/schemas/manifest.schema.json +79 -0
  24. package/assets/schemas/ownership.schema.json +52 -0
  25. package/assets/schemas/policy-verdict.schema.json +282 -0
  26. package/assets/schemas/project-model.schema.json +783 -0
  27. package/assets/schemas/project-status.schema.json +217 -0
  28. package/assets/schemas/reconcile-plan.schema.json +155 -0
  29. package/assets/schemas/run-diff.schema.json +177 -0
  30. package/assets/schemas/run-snapshot-manifest.schema.json +2104 -0
  31. package/assets/schemas/run-state.schema.json +1755 -0
  32. package/assets/schemas/runtime-capabilities.schema.json +1545 -0
  33. package/assets/schemas/sandbox-capabilities.schema.json +116 -0
  34. package/assets/schemas/skill.schema.json +87 -0
  35. package/assets/schemas/spec.schema.json +202 -0
  36. package/assets/schemas/tool-catalogue.schema.json +655 -0
  37. package/assets/schemas/trust-record.schema.json +38 -0
  38. package/assets/skills/README.md +9 -0
  39. package/assets/skills/inspect-surface.md +4 -0
  40. package/dist/agent-host.mjs +50 -0
  41. package/dist/agent-host.mjs.map +1 -0
  42. package/dist/bundle-manifest.json +102 -0
  43. package/dist/chunks/__host-DgGPyco9.mjs +18 -0
  44. package/dist/chunks/__host-DgGPyco9.mjs.map +1 -0
  45. package/dist/chunks/approve-Cv135UqI.mjs +19 -0
  46. package/dist/chunks/approve-Cv135UqI.mjs.map +1 -0
  47. package/dist/chunks/auth-BT5oVp76.mjs +132 -0
  48. package/dist/chunks/auth-BT5oVp76.mjs.map +1 -0
  49. package/dist/chunks/auth-D8rCVY33.mjs +84 -0
  50. package/dist/chunks/auth-D8rCVY33.mjs.map +1 -0
  51. package/dist/chunks/brainstorm-CU10T1tm.mjs +7 -0
  52. package/dist/chunks/brainstorm-CU10T1tm.mjs.map +1 -0
  53. package/dist/chunks/cancel-KV_cpsqS.mjs +15 -0
  54. package/dist/chunks/cancel-KV_cpsqS.mjs.map +1 -0
  55. package/dist/chunks/canonical-C8UxeEmB.mjs +57 -0
  56. package/dist/chunks/canonical-C8UxeEmB.mjs.map +1 -0
  57. package/dist/chunks/catalogue-BvwSrM_j.mjs +1549 -0
  58. package/dist/chunks/catalogue-BvwSrM_j.mjs.map +1 -0
  59. package/dist/chunks/compose-Cr0hP0qm.mjs +7303 -0
  60. package/dist/chunks/compose-Cr0hP0qm.mjs.map +1 -0
  61. package/dist/chunks/config-DefHA2Lw.mjs +96 -0
  62. package/dist/chunks/config-DefHA2Lw.mjs.map +1 -0
  63. package/dist/chunks/contract-B0ra2-bm.mjs +295 -0
  64. package/dist/chunks/contract-B0ra2-bm.mjs.map +1 -0
  65. package/dist/chunks/contract-vYbwYr0Y.mjs +123 -0
  66. package/dist/chunks/contract-vYbwYr0Y.mjs.map +1 -0
  67. package/dist/chunks/deny-ClTxSvHJ.mjs +15 -0
  68. package/dist/chunks/deny-ClTxSvHJ.mjs.map +1 -0
  69. package/dist/chunks/diff-Qw0II0sj.mjs +101 -0
  70. package/dist/chunks/diff-Qw0II0sj.mjs.map +1 -0
  71. package/dist/chunks/discover-30Ciya_0.mjs +20 -0
  72. package/dist/chunks/discover-30Ciya_0.mjs.map +1 -0
  73. package/dist/chunks/doctor-DfCrKpfT.mjs +222 -0
  74. package/dist/chunks/doctor-DfCrKpfT.mjs.map +1 -0
  75. package/dist/chunks/fix-D4Tj8RVA.mjs +18 -0
  76. package/dist/chunks/fix-D4Tj8RVA.mjs.map +1 -0
  77. package/dist/chunks/gc-BBXlBF5w.mjs +161 -0
  78. package/dist/chunks/gc-BBXlBF5w.mjs.map +1 -0
  79. package/dist/chunks/init-Okz4mlhW.mjs +30 -0
  80. package/dist/chunks/init-Okz4mlhW.mjs.map +1 -0
  81. package/dist/chunks/inspect-Buby4cQD.mjs +22 -0
  82. package/dist/chunks/inspect-Buby4cQD.mjs.map +1 -0
  83. package/dist/chunks/logs-Giq5sUau.mjs +25 -0
  84. package/dist/chunks/logs-Giq5sUau.mjs.map +1 -0
  85. package/dist/chunks/migrate-DRoFUg53.mjs +19 -0
  86. package/dist/chunks/migrate-DRoFUg53.mjs.map +1 -0
  87. package/dist/chunks/models-Btp6X2YZ.mjs +24 -0
  88. package/dist/chunks/models-Btp6X2YZ.mjs.map +1 -0
  89. package/dist/chunks/pause-BASBfKLO.mjs +16 -0
  90. package/dist/chunks/pause-BASBfKLO.mjs.map +1 -0
  91. package/dist/chunks/policy-DUIgzF3W.mjs +139 -0
  92. package/dist/chunks/policy-DUIgzF3W.mjs.map +1 -0
  93. package/dist/chunks/providers-Dx78imKj.mjs +19 -0
  94. package/dist/chunks/providers-Dx78imKj.mjs.map +1 -0
  95. package/dist/chunks/reconcile-BfD785gd.mjs +26 -0
  96. package/dist/chunks/reconcile-BfD785gd.mjs.map +1 -0
  97. package/dist/chunks/render-DyZr5Lgi.mjs +53 -0
  98. package/dist/chunks/render-DyZr5Lgi.mjs.map +1 -0
  99. package/dist/chunks/resume-GJQRb2Eb.mjs +15 -0
  100. package/dist/chunks/resume-GJQRb2Eb.mjs.map +1 -0
  101. package/dist/chunks/retry-CLrUem1H.mjs +15 -0
  102. package/dist/chunks/retry-CLrUem1H.mjs.map +1 -0
  103. package/dist/chunks/review-BDdjb6kB.mjs +17 -0
  104. package/dist/chunks/review-BDdjb6kB.mjs.map +1 -0
  105. package/dist/chunks/run-CgTWYlcd.mjs +82 -0
  106. package/dist/chunks/run-CgTWYlcd.mjs.map +1 -0
  107. package/dist/chunks/run-tool-C3rAv4XF.mjs +26 -0
  108. package/dist/chunks/run-tool-C3rAv4XF.mjs.map +1 -0
  109. package/dist/chunks/schema-C9QbUW4H.mjs +578 -0
  110. package/dist/chunks/schema-C9QbUW4H.mjs.map +1 -0
  111. package/dist/chunks/send-BdnJ85Nz.mjs +19 -0
  112. package/dist/chunks/send-BdnJ85Nz.mjs.map +1 -0
  113. package/dist/chunks/ship-DTuLGoaJ.mjs +19 -0
  114. package/dist/chunks/ship-DTuLGoaJ.mjs.map +1 -0
  115. package/dist/chunks/shutdown-BM92Iyv2.mjs +15 -0
  116. package/dist/chunks/shutdown-BM92Iyv2.mjs.map +1 -0
  117. package/dist/chunks/skip-DZG5kpcH.mjs +18 -0
  118. package/dist/chunks/skip-DZG5kpcH.mjs.map +1 -0
  119. package/dist/chunks/spec-Bq0P3jVh.mjs +20 -0
  120. package/dist/chunks/spec-Bq0P3jVh.mjs.map +1 -0
  121. package/dist/chunks/src-CId6kMsY.mjs +1689 -0
  122. package/dist/chunks/src-CId6kMsY.mjs.map +1 -0
  123. package/dist/chunks/src-CfAOde7C.mjs +5403 -0
  124. package/dist/chunks/src-CfAOde7C.mjs.map +1 -0
  125. package/dist/chunks/src-Cu3TxNob.mjs +783 -0
  126. package/dist/chunks/src-Cu3TxNob.mjs.map +1 -0
  127. package/dist/chunks/src-DcU4IXMe.mjs +117 -0
  128. package/dist/chunks/src-DcU4IXMe.mjs.map +1 -0
  129. package/dist/chunks/src-JxawBQBn.mjs +446 -0
  130. package/dist/chunks/src-JxawBQBn.mjs.map +1 -0
  131. package/dist/chunks/src-s7YRvk0t.mjs +2116 -0
  132. package/dist/chunks/src-s7YRvk0t.mjs.map +1 -0
  133. package/dist/chunks/status-SMjiOMYy.mjs +29 -0
  134. package/dist/chunks/status-SMjiOMYy.mjs.map +1 -0
  135. package/dist/chunks/tail-BuhT-2PH.mjs +25 -0
  136. package/dist/chunks/tail-BuhT-2PH.mjs.map +1 -0
  137. package/dist/chunks/update-BXIppsbi.mjs +16 -0
  138. package/dist/chunks/update-BXIppsbi.mjs.map +1 -0
  139. package/dist/cli.mjs +175 -0
  140. package/dist/cli.mjs.map +1 -0
  141. package/package.json +17 -32
  142. package/CHANGELOG.md +0 -1453
  143. package/bin/cli.js +0 -812
  144. package/bin/report.js +0 -141
  145. package/core/adapter/render.js +0 -389
  146. package/core/agents/implementer.template.md +0 -142
  147. package/core/agents/profile-reader.md +0 -50
  148. package/core/agents/release.md +0 -58
  149. package/core/agents/review.md +0 -179
  150. package/core/commands/cohorte-align-ds.md +0 -33
  151. package/core/commands/cohorte-audit.md +0 -58
  152. package/core/commands/cohorte-brainstorm.md +0 -70
  153. package/core/commands/cohorte-build.md +0 -219
  154. package/core/commands/cohorte-doctor.md +0 -170
  155. package/core/commands/cohorte-fix.md +0 -80
  156. package/core/commands/cohorte-fleet.md +0 -103
  157. package/core/commands/cohorte-init-pipeline.md +0 -31
  158. package/core/commands/cohorte-intake.md +0 -92
  159. package/core/commands/cohorte-patch.md +0 -118
  160. package/core/commands/cohorte-refactor.md +0 -59
  161. package/core/commands/cohorte-retro.md +0 -85
  162. package/core/commands/cohorte-review.md +0 -270
  163. package/core/commands/cohorte-ship.md +0 -122
  164. package/core/commands/cohorte-spec.md +0 -101
  165. package/core/commands/cohorte-update-pipeline.md +0 -134
  166. package/core/hooks/gate.py +0 -580
  167. package/core/runtimes/claude.json +0 -74
  168. package/core/runtimes/codex.json +0 -83
  169. package/core/runtimes/cursor.json +0 -76
  170. package/core/runtimes/gemini.json +0 -76
  171. package/core/runtimes/opencode.json +0 -73
  172. package/core/templates/agent-handoff.md +0 -34
  173. package/core/templates/brainstorm-return.md +0 -36
  174. package/core/templates/decisions.template.md +0 -42
  175. package/core/templates/design-brief.md +0 -44
  176. package/core/templates/patch.template.md +0 -86
  177. package/core/templates/pr-body.md +0 -25
  178. package/core/templates/review-feedback.md +0 -38
  179. package/core/templates/spec.template.md +0 -87
  180. package/core/templates/steps/init-pipeline/01-detect-stack.md +0 -44
  181. package/core/templates/steps/init-pipeline/02-interview-gaps.md +0 -55
  182. package/core/templates/steps/init-pipeline/03-draft-profile.md +0 -10
  183. package/core/templates/steps/init-pipeline/04-write-render.md +0 -115
  184. package/core/templates/steps/init-pipeline/05-report.md +0 -12
  185. package/core/workflows/audit.js +0 -240
  186. package/core/workflows/loop.js +0 -641
  187. package/core/workflows/refactor.js +0 -276
  188. package/core/workflows/review.js +0 -456
  189. package/install.ps1 +0 -103
  190. package/install.sh +0 -114
  191. package/lib/doctor.js +0 -504
  192. package/lib/runtime.js +0 -134
  193. package/lib/versions.js +0 -149
  194. package/lib/yaml.js +0 -129
  195. package/profile/PIPELINE.template.md +0 -251
  196. package/profile/SCHEMA.md +0 -698
  197. package/profile/cohorte.config.template.yaml +0 -43
  198. package/scripts/assert-gate-hook.mjs +0 -75
  199. package/scripts/kanban-move.sh +0 -313
  200. package/scripts/metrics/collect.mjs +0 -531
  201. package/scripts/metrics/prices.json +0 -42
  202. package/scripts/new-feature.sh.template +0 -98
  203. package/scripts/preflight.sh +0 -124
  204. package/scripts/remove-feature.sh.template +0 -56
  205. package/scripts/test-adapter.mjs +0 -368
  206. package/scripts/test-gate.mjs +0 -428
  207. package/scripts/test-kanban.mjs +0 -304
  208. package/scripts/test-lib.mjs +0 -310
  209. package/scripts/test-metrics.mjs +0 -156
  210. package/scripts/test-workflows.mjs +0 -758
  211. package/scripts/validate-core.mjs +0 -320
package/install.sh DELETED
@@ -1,114 +0,0 @@
1
- #!/bin/sh
2
- #
3
- # install.sh — install the portable multi-agent pipeline.
4
- # POSIX sh (works with dash/bash/zsh).
5
- #
6
- # Per-project install (default — bundles the core into <target>/.claude, committable):
7
- # sh install.sh [target_dir]
8
- # curl -fsSL <raw-url>/install.sh | sh
9
- #
10
- # Global install (one core in ~/.claude, shared by every repo on this machine):
11
- # sh install.sh --global
12
- # curl -fsSL <raw-url>/install.sh | sh -s -- --global
13
- #
14
- # Update the generic core in place (keeps any generated PIPELINE.md + rendered agents):
15
- # sh install.sh --update [target_dir]
16
- # sh install.sh --update --global
17
- #
18
- # Per-project install copies the core into <target>/.claude; global install copies it once
19
- # into ~/.claude and registers the gate hook there. Either way you then run `/cohorte-init-pipeline`
20
- # in each repo to generate PIPELINE.md + render the surface agents. Update refreshes ONLY the
21
- # stack-agnostic files; generated profiles, rendered agents, gate-config.json and any project
22
- # settings.json are left untouched.
23
-
24
- set -eu
25
-
26
- REPO_URL="${PIPELINE_REPO:-https://github.com/TheBidouilleAgency/cohorte}"
27
-
28
- mode="install"
29
- scope="project"
30
- positional=""
31
- while [ $# -gt 0 ]; do
32
- case "$1" in
33
- --update) mode="update"; shift ;;
34
- --global) scope="global"; shift ;;
35
- -h|--help)
36
- cat <<'USAGE'
37
- install.sh — install the cohorte pipeline core.
38
-
39
- sh install.sh [target_dir] per-project: bundle the core into <target>/.claude
40
- sh install.sh --global one shared core in ~/.claude (recommended)
41
- sh install.sh --update [target] refresh the core in place, keep every generated file
42
- sh install.sh --update --global
43
-
44
- Honours $CLAUDE_CONFIG_DIR for the global destination and $PIPELINE_REPO for the
45
- source when piped through curl. The npm CLI (`npm i -g cohorte` then `cohorte install`)
46
- does the same thing and is the documented route; this script exists for npm-less
47
- setups (curl straight from the repo). Node itself is still required — the commands
48
- are rendered per coding agent at install time and there is no shell renderer.
49
- USAGE
50
- exit 0 ;;
51
- --) shift; break ;;
52
- -*) echo "error: unknown flag: $1 (try --help)" >&2; exit 2 ;;
53
- *) positional="$1"; shift ;;
54
- esac
55
- done
56
- target="${positional:-$PWD}"
57
-
58
- # --- locate the source (this checkout, or clone if piped via curl) ----------
59
- src=""
60
- self="${0:-}"
61
- self_dir=""
62
- case "$self" in
63
- # `sh install.sh` from inside a checkout hands $0 with no slash — the old */*-only
64
- # case missed it and silently CLONED the remote instead of installing the local
65
- # tree the human was standing in (dirname of a bare name is `.`, which is exactly
66
- # right here; the piped-stdin case stays `sh`, matches neither arm, and clones).
67
- */*) self_dir=$(CDPATH= cd -- "$(dirname -- "$self")" && pwd) ;;
68
- *install.sh*) self_dir=$(pwd) ;;
69
- esac
70
- if [ -n "$self_dir" ] && [ -d "$self_dir/core" ]; then
71
- src="$self_dir"
72
- else
73
- echo "→ fetching pipeline from $REPO_URL"
74
- tmp=$(mktemp -d)
75
- trap 'rm -rf "$tmp"' EXIT
76
- git clone --depth 1 "$REPO_URL" "$tmp/pipeline" >/dev/null 2>&1
77
- src="$tmp/pipeline"
78
- fi
79
- [ -d "$src/core" ] || { echo "error: pipeline source not found (no core/ in $src)" >&2; exit 1; }
80
-
81
- # --- require Node >= 18, then delegate to the Node CLI -----------------------
82
- # Since 2.2.0 the commands in core/ are runtime-NEUTRAL sources: they carry capability
83
- # conditionals (`<!-- cohorte:if subagents -->`) and path tokens (`<core>`, `<state>`) that
84
- # the adapter resolves per coding agent. Copying them verbatim, as this script used to,
85
- # would install prompts full of unresolved markers — an install that looks successful and
86
- # instructs the model with text meant for a different runtime. There is no shell renderer,
87
- # so the whole job goes to bin/cli.js, which is the documented route anyway. (The legacy
88
- # copy-verbatim shell path was removed in 2.7.0 — it had been unreachable dead code since
89
- # 2.2.0, and its text was what validate-core's copy checks were vacuously matching.)
90
- if command -v node >/dev/null 2>&1; then
91
- # An old Node fails DEEP into cli.js (fs.cpSync needs >= 16.7) after some files are
92
- # already on disk — a half-install that reports as a crash. Refuse up front instead.
93
- node_major=$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)
94
- case "$node_major" in *[!0-9]*) node_major=0 ;; esac
95
- if [ "$node_major" -lt 18 ]; then
96
- echo "error: cohorte needs Node >= 18 — found $(node --version 2>/dev/null || echo '?')." >&2
97
- echo " Upgrade Node, then re-run this script (or: npm i -g cohorte && cohorte install$([ "$scope" = global ] && echo ' --global'))." >&2
98
- exit 1
99
- fi
100
- set -- install
101
- [ "$mode" = "update" ] && set -- update
102
- [ "$scope" = "global" ] && set -- "$@" --global
103
- [ "$scope" = "project" ] && set -- "$@" "$target"
104
- # Not `exec`: exec replaces this shell, so the curl-path EXIT trap (rm -rf "$tmp")
105
- # never fires and every piped install leaks the shallow clone in $TMPDIR.
106
- node "$src/bin/cli.js" "$@"
107
- exit $?
108
- fi
109
- echo "error: cohorte needs Node >= 18 to install." >&2
110
- echo " The pipeline's commands are rendered per coding agent (Claude Code, Codex, Cursor," >&2
111
- echo " Gemini CLI, OpenCode) at install time; there is no shell equivalent of that step," >&2
112
- echo " and a raw copy would install prompts this runtime cannot follow." >&2
113
- echo " Install Node, then: npm i -g cohorte && cohorte install$([ "$scope" = global ] && echo ' --global')" >&2
114
- exit 1
package/lib/doctor.js DELETED
@@ -1,504 +0,0 @@
1
- 'use strict';
2
- // Programmatic port of the /cohorte-doctor checks (core/commands/cohorte-doctor.md), so
3
- // `cohorte doctor` can report without a coding agent in the loop.
4
- // Read-only: inspects files only. Checks that need a live process (MCP connectivity,
5
- // git worktree state, DesignSync) are reported as `skip` with a note — the node server
6
- // can't run them, and honest "not checked here" beats a false green.
7
-
8
- const fs = require('fs');
9
- const path = require('path');
10
- const { layouts, primary, stateDirs } = require('./runtime.js');
11
- const { parseProfileBlock } = require('./yaml');
12
- const { versions } = require('./versions');
13
-
14
- // Rendered surface agents live alongside these fixed (non-surface) agents; exclude them
15
- // from the orphan check so they're never mistaken for a stray surface agent.
16
- const FIXED_AGENTS = new Set([
17
- 'review', 'release', 'profile-reader',
18
- // retired (1.5.0) — still excluded so a stale install's leftover file isn't
19
- // reported as a stray surface agent.
20
- 'smoke',
21
- 'implementer.template',
22
- ]);
23
-
24
- // The spec lifecycle (SCHEMA.md §Spec status). `in-progress` and `blocked` stay valid after
25
- // /cohorte-loop was retired in 2.2.0: specs in existing repos carry them, and an external driver
26
- // still needs somewhere to record "a round is under way" / "a round gave up".
27
- const VALID_STATUS = ['draft', 'frozen', 'in-progress', 'in-review', 'shipped', 'blocked'];
28
-
29
- // Artifacts the pipeline itself writes into specs/ that are NOT feature specs and have no
30
- // front-matter status. `/cohorte-audit` writes specs/refactor-backlog.md by design, so scanning it
31
- // as a spec made /cohorte-doctor warn about a file cohorte had just created — a false positive that
32
- // fired in every project that had ever run /cohorte-audit. `_`-prefixed files (e.g. _template.md)
33
- // are already skipped by the reader below.
34
- const NON_SPEC_FILES = new Set(['refactor-backlog.md']);
35
-
36
- const exists = p => { try { return fs.existsSync(p); } catch { return false; } };
37
- const readText = p => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } };
38
- const readJson = p => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
39
- const isTrue = v => v === true;
40
- const sameSet = (a, b) => {
41
- const A = new Set(a || []), B = new Set(b || []);
42
- if (A.size !== B.size) return false;
43
- for (const x of A) if (!B.has(x)) return false;
44
- return true;
45
- };
46
-
47
- // Paths are reported to a human looking at a repo: show them relative to it.
48
- const rel = (projectRoot) => (p) => {
49
- const r = path.relative(projectRoot, p);
50
- return r && !r.startsWith('..') ? r : p;
51
- };
52
-
53
- function mk(id, label, status, detail, fix) {
54
- return fix ? { id, label, status, detail, fix } : { id, label, status, detail };
55
- }
56
-
57
- // --- individual checks -------------------------------------------------------
58
-
59
- function checkCore(v) {
60
- if (v.installMode === 'none') {
61
- return mk('core', 'Core & pointer', 'bad', 'no pipeline core installed for this project',
62
- 'cohorte install (or --global)');
63
- }
64
- if (v.pointer.present && v.pointer.core_version && v.installedVersion &&
65
- v.pointer.core_version !== v.installedVersion) {
66
- return mk('core', 'Core & pointer', 'warn',
67
- `pointer says core ${v.pointer.core_version} but installed core is ${v.installedVersion}`,
68
- 'cohorte update (reconcile the pointer)');
69
- }
70
- if (v.freshness === -1) {
71
- return mk('core', 'Core & pointer', 'warn',
72
- `core ${v.installedVersion} installed (${v.installMode}); npm latest is ${v.latest}`,
73
- '/cohorte-update-pipeline (or cohorte update)');
74
- }
75
- const tail = v.latest ? `, npm latest ${v.latest}` : ', npm unreachable';
76
- return mk('core', 'Core & pointer', 'ok', `core ${v.installedVersion} (${v.installMode})${tail}`);
77
- }
78
-
79
- function checkProfile(profile, hasPipelineMd) {
80
- if (!hasPipelineMd) {
81
- return mk('profile', 'Profile (PIPELINE.md)', 'bad', 'PIPELINE.md not found',
82
- '/cohorte-init-pipeline (generate the project profile)');
83
- }
84
- if (!profile) {
85
- return mk('profile', 'Profile (PIPELINE.md)', 'bad',
86
- 'PIPELINE.md present but its `yaml pipeline-profile` block is missing or unparseable',
87
- '/cohorte-init-pipeline (or fix the fenced yaml block)');
88
- }
89
- const n = (profile.surfaces || []).length;
90
- return mk('profile', 'Profile (PIPELINE.md)', n ? 'ok' : 'warn',
91
- `${profile.name || 'unnamed'} · ${n} surface${n === 1 ? '' : 's'}`,
92
- n ? undefined : 'add at least one surface to §surfaces');
93
- }
94
-
95
- function checkAgents(profile, projectRoot, layout) {
96
- if (!profile || !(profile.surfaces || []).length) {
97
- return mk('agents', 'Surfaces ↔ agents', 'skip', 'no surfaces to reconcile');
98
- }
99
- if (!layout) {
100
- return mk('agents', 'Surfaces ↔ agents', 'skip', 'no core installed — nothing renders agents yet');
101
- }
102
- const agentsDir = layout.agents;
103
- const surfaceAgents = profile.surfaces.map(s => s.agent).filter(Boolean);
104
-
105
- const missing = surfaceAgents.filter(a => !exists(path.join(agentsDir, `${a}.md`)));
106
-
107
- let files = [];
108
- try { files = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3)); }
109
- catch { /* dir absent → handled by `missing` */ }
110
- // Orphan detection only makes sense against a PROJECT agents dir. A global install's
111
- // agents dir (~/.claude/agents) is the user's shared Claude Code space — their personal
112
- // agents and other cohorte projects' surface agents live there legitimately, and
113
- // flagging them sent humans deleting files that were not this project's to judge.
114
- const orphans = layout.scope === 'global'
115
- ? []
116
- : files.filter(f => !FIXED_AGENTS.has(f) && !surfaceAgents.includes(f));
117
-
118
- if (missing.length) {
119
- return mk('agents', 'Surfaces ↔ agents', 'bad',
120
- `surface(s) with no rendered agent: ${missing.join(', ')}`,
121
- '/cohorte-init-pipeline (re-render surface agents)');
122
- }
123
- if (orphans.length) {
124
- return mk('agents', 'Surfaces ↔ agents', 'warn',
125
- `agent file(s) with no owning surface: ${orphans.join(', ')}`,
126
- 'remove the stale agent file, or add its surface to PIPELINE.md');
127
- }
128
- return mk('agents', 'Surfaces ↔ agents', 'ok',
129
- `${surfaceAgents.length} surface agent(s) all rendered, no orphans (${layout.label})`);
130
- }
131
-
132
- function checkGate(profile, projectRoot, stateDirs) {
133
- const gate = profile && profile.gate;
134
- if (!gate || (!gate.deny && !gate.ask && !gate.ask_on_default_branch && !gate.preflight)) {
135
- return mk('gate', 'Gate config', 'skip', 'no gate block in the profile');
136
- }
137
- // One gate config per PROJECT, wherever the installed runtime keeps it. A repo driven from
138
- // two agents shares it deliberately — they must not disagree about what is gated.
139
- const cfgPath = stateDirs.map(d => path.join(d, 'gate-config.json')).find(exists);
140
- const cfg = cfgPath ? readJson(cfgPath) : null;
141
- if (!cfg) {
142
- return mk('gate', 'Gate config', 'bad',
143
- `gate-config.json missing or unreadable (looked in ${stateDirs.map(rel(projectRoot)).join(', ')})`,
144
- '/cohorte-init-pipeline (regenerate gate-config.json from the gate block)');
145
- }
146
- const drifted = [];
147
- if (!sameSet(cfg.deny, gate.deny)) drifted.push('deny');
148
- if (!sameSet(cfg.ask, gate.ask)) drifted.push('ask');
149
- if (!sameSet(cfg.ask_on_default_branch, gate.ask_on_default_branch)) drifted.push('ask_on_default_branch');
150
- if ((cfg.default_branch || 'main') !== (gate.default_branch || 'main')) drifted.push('default_branch');
151
- // The phase gate (1.3.0) lives in the same file: a profile that enables
152
- // gate.preflight with a pre-1.3.0 gate-config.json silently never fires it.
153
- const wantPf = gate.preflight || {};
154
- const havePf = (cfg.preflight && typeof cfg.preflight === 'object') ? cfg.preflight : {};
155
- if (!!wantPf.enabled !== !!havePf.enabled
156
- || !sameSet(wantPf.agents || ['review'], havePf.agents || ['review'])
157
- || Number(wantPf.max_age_minutes || 30) !== Number(havePf.max_age_minutes || 30)) {
158
- drifted.push('preflight');
159
- }
160
- if (drifted.length) {
161
- return mk('gate', 'Gate config', 'warn',
162
- `gate-config.json drifted from PIPELINE.md gate block (${drifted.join(', ')})`,
163
- `regenerate ${rel(projectRoot)(cfgPath)} to mirror the gate block`);
164
- }
165
- const branchGated = (gate.ask_on_default_branch || []).length;
166
- return mk('gate', 'Gate config', 'ok',
167
- `mirrors the profile (${(gate.deny || []).length} deny, ${(gate.ask || []).length} ask` +
168
- (branchGated ? `, ${branchGated} gated on ${gate.default_branch || 'main'}` : '') + ')');
169
- }
170
-
171
- // Local-only pipeline artifacts, named relative to whichever `<state>` dir the installed
172
- // runtime uses (`.claude` on a Claude install, `.cohorte` on the others). `preflight.ok` is the
173
- // one that BREAKS things when versioned rather than merely being noise: the stamp names the tree
174
- // it verified, committing it moves HEAD past that, and the committed copy then rides into every
175
- // fresh clone and worktree — the phase gate either blocks a clean tree or greens one it never
176
- // checked.
177
- function localArtifacts(stateRels) {
178
- const out = [];
179
- for (const d of stateRels) {
180
- out.push({ path: `${d}/preflight.ok`, stamp: true,
181
- why: 'the phase-gate stamp — a versioned one breaks the gate in every clone and worktree' });
182
- out.push({ path: `${d}/pipeline-metrics.jsonl`,
183
- why: 'the per-dispatch metrics sink (local, append-only)' });
184
- }
185
- out.push({ path: 'specs/reports/',
186
- why: 'the /cohorte-review report buffer (derived, regenerated each run)' });
187
- return out;
188
- }
189
-
190
- // Does any .gitignore in play cover `rel`? Deliberately literal — it recognizes the exact
191
- // path, its basename, a trailing-slash dir prefix and a `*.<ext>` glob, which is every form
192
- // /cohorte-init-pipeline writes. Anything more clever would need a real gitignore engine.
193
- function ignoreCovers(projectRoot, rel) {
194
- const base = rel.replace(/\/$/, '').split('/').pop();
195
- const ext = base.includes('.') ? '*' + base.slice(base.lastIndexOf('.')) : null;
196
- const files = [
197
- path.join(projectRoot, '.gitignore'),
198
- path.join(projectRoot, '.claude', '.gitignore'),
199
- ];
200
- for (const f of files) {
201
- const text = readText(f);
202
- if (!text) continue;
203
- for (let line of text.split('\n')) {
204
- line = line.trim();
205
- if (!line || line.startsWith('#')) continue;
206
- const p = line.replace(/^\/+/, '').replace(/\/+$/, '');
207
- if (!p) continue;
208
- if (p === rel.replace(/\/$/, '') || p === base || p === ext) return true;
209
- // A directory rule covers everything under it (`.claude/` ignores the stamp too).
210
- if (rel.startsWith(p + '/')) return true;
211
- }
212
- }
213
- return false;
214
- }
215
-
216
- function checkLocalArtifacts(projectRoot, stateRels) {
217
- const artifacts = localArtifacts(stateRels);
218
- const unignored = artifacts.filter(a => !ignoreCovers(projectRoot, a.path));
219
- if (!unignored.length) {
220
- return mk('artifacts', 'Local artifacts', 'ok',
221
- `${artifacts.length} local-only artifact path(s) all gitignored`);
222
- }
223
- const stamp = unignored.find(a => a.stamp);
224
- return mk('artifacts', 'Local artifacts', stamp ? 'bad' : 'warn',
225
- `not gitignored: ${unignored.map(a => a.path).join(', ')} — ${unignored[0].why}`,
226
- `add ${unignored.map(a => a.path).join(' + ')} to .gitignore`
227
- + (stamp ? `, then \`git rm --cached --ignore-unmatch ${stamp.path}\`` : ''));
228
- }
229
-
230
- // Gate registrations in one config file. Three shapes across the runtimes that host the hook:
231
- // Claude Code and Codex use the matcher-group form under `PreToolUse`, Gemini the same shape
232
- // under `BeforeTool`, Cursor a flat `[{command}]` under `beforeShellExecution`. A registration
233
- // read with the wrong shape looks absent, which is how a healthy install gets reported broken.
234
- function gateRegs(settingsPath, event) {
235
- const data = readJson(settingsPath);
236
- const list = data && data.hooks && Array.isArray(data.hooks[event]) ? data.hooks[event] : [];
237
- // Trailing-quote tolerant, like the installers since 1.3.2: the Windows form is
238
- // `py "C:\…\gate.py"` — a bare .endsWith() reports every healthy Windows install
239
- // as "not registered".
240
- // Strip the trailing `--runtime <id>` BEFORE the quotes: the registered command is
241
- // `python3 "/path/gate.py" --runtime cursor`, so taking the quotes off first leaves
242
- // `…gate.py"` and the match fails — a healthy registration read as missing.
243
- const isOurs = (c) => typeof c === 'string'
244
- && c.trim().replace(/\s+--runtime[= ]\S+$/, '').replace(/"+$/, '').endsWith('gate.py');
245
- return list.filter(e => isOurs(e && e.command)
246
- || ((e && e.hooks) || []).some(h => isOurs(h && h.command)));
247
- }
248
-
249
- function checkHooks(projectRoot, globalDir, installMode, all) {
250
- // A registration can exist without a discoverable core — a repo whose core lives in a global
251
- // dir the caller was not pointed at, or one predating `runtimes.json`. Falling through to
252
- // "no runtime installed" there would hide a real double-registration, so assume the Claude
253
- // layout: before the adapter it was the only one, and it is the only one whose hook can be
254
- // registered outside its own core dir.
255
- const known = all.length ? all : [{
256
- id: 'claude', label: 'Claude Code', scope: installMode === 'bundled' ? 'project' : 'global',
257
- core: path.join(projectRoot, '.claude'),
258
- hooksConfig: path.join(projectRoot, '.claude', 'settings.json'),
259
- capabilities: { subagents: true, hooks: true, workflows: true },
260
- }];
261
- const hosts = known.filter(l => l.capabilities && l.capabilities.hooks && l.hooksConfig);
262
- const advisory = known.filter(l => !(l.capabilities && l.capabilities.hooks));
263
-
264
- if (!hosts.length) {
265
- if (advisory.length) {
266
- // OpenCode extends via plugins, not a blocking hook. The gate still runs — the rendered
267
- // commands call `gate.py --check` — but it is advisory, and saying ✅ here would claim an
268
- // enforcement property that genuinely is not there.
269
- return mk('hooks', 'Gate hook', 'warn',
270
- `${advisory.map(l => l.label).join(', ')} has no blocking hook — the gate runs as an explicit --check the agent must call`,
271
- 'nothing to fix; run /cohorte-doctor inside that runtime for the full picture');
272
- }
273
- return mk('hooks', 'Gate hook', 'warn', 'no runtime installed to register the gate hook against',
274
- 'cohorte install (or --global)');
275
- }
276
-
277
- const problems = [];
278
- const okLines = [];
279
- for (const l of hosts) {
280
- const event = (l.id === 'cursor') ? 'beforeShellExecution'
281
- : (l.id === 'gemini') ? 'BeforeTool' : 'PreToolUse';
282
- // A Claude registration serves the project from EITHER scope: bundled repos get it from
283
- // /cohorte-init-pipeline in project settings, but on a machine with the global core it
284
- // usually lives (correctly, exactly once) in global settings — warning there would
285
- // prescribe a re-registration that double-prompts.
286
- const paths = l.id === 'claude'
287
- ? (installMode === 'global'
288
- ? [path.join(globalDir, 'settings.json'), path.join(projectRoot, '.claude', 'settings.json')]
289
- : [path.join(projectRoot, '.claude', 'settings.json'), path.join(globalDir, 'settings.json')])
290
- : [l.hooksConfig];
291
- const found = paths.map(p2 => ({ path: p2, regs: gateRegs(p2, event) })).filter(f => f.regs.length);
292
- if (!found.length) {
293
- problems.push(`${l.label}: not registered (${event})`);
294
- continue;
295
- }
296
- const total = found.reduce((n, f) => n + f.regs.length, 0);
297
- if (total > 1) {
298
- problems.push(`${l.label}: registered ${total}× — it will double-prompt`);
299
- continue;
300
- }
301
- // Only the matcher-group runtimes carry one, and only Claude dispatches subagents through
302
- // a `Task` tool — a Bash-only matcher there leaves the preflight phase gate dead.
303
- const matcher = String(found[0].regs[0].matcher || '');
304
- if (l.id === 'claude' && (!/\bTask\b/.test(matcher) || !/\bBash\b/.test(matcher))) {
305
- problems.push(`${l.label}: matcher "${matcher}" must cover both Bash and Task`);
306
- continue;
307
- }
308
- okLines.push(`${l.label} (${event}${matcher ? `, ${matcher}` : ''})`);
309
- }
310
-
311
- if (problems.length) {
312
- // Name the healthy ones too. With several runtimes installed, "Claude Code: not registered"
313
- // alone reads as if the gate were off everywhere, when four of five may be fine.
314
- const tail = okLines.length ? ` (ok: ${okLines.join(', ')})` : '';
315
- return mk('hooks', 'Gate hook', 'warn', problems.join(' · ') + tail,
316
- 'npm i -g cohorte@latest && cohorte update (re-registers the gate hook for every installed runtime)');
317
- }
318
- const tail = advisory.length ? ` · ${advisory.map(l => l.label).join(', ')}: advisory --check only` : '';
319
- return mk('hooks', 'Gate hook', 'ok', `registered once for ${okLines.join(', ')}${tail}`);
320
- }
321
-
322
- function checkRetrieval(profile, projectRoot) {
323
- const provider = profile && profile.retrieval && profile.retrieval.provider;
324
- if (!provider || provider === 'none' || String(provider).startsWith('<')) {
325
- return mk('retrieval', 'Code retrieval', 'skip', 'provider: none');
326
- }
327
- // The profile alone isn't proof the provider was ever wired: /cohorte-init-pipeline
328
- // registers it at project scope in .mcp.json. Verify the entry exists on disk;
329
- // live connectivity still needs a session — note it, don't fake green.
330
- const mcp = readJson(path.join(projectRoot, '.mcp.json'));
331
- const servers = (mcp && mcp.mcpServers) || {};
332
- const wired = Object.keys(servers).some(k => k.toLowerCase().includes(String(provider).toLowerCase()));
333
- if (!wired) {
334
- return mk('retrieval', 'Code retrieval', 'warn',
335
- `profile says provider: ${provider} but .mcp.json has no matching server entry`,
336
- '/cohorte-init-pipeline or /cohorte-update-pipeline (re-wire the retrieval provider)');
337
- }
338
- return mk('retrieval', 'Code retrieval', 'ok',
339
- `provider: ${provider} — registered in .mcp.json (connectivity needs /cohorte-doctor in-session)`);
340
- }
341
-
342
- function checkDesign(profile, projectRoot) {
343
- const d = profile && profile.design;
344
- if (!d || !isTrue(d.enabled)) return mk('design', 'Design system', 'skip', 'design.enabled: false');
345
- const targets = [['snapshot_dir', d.snapshot_dir], ['ui_kit_path', d.ui_kit_path], ['tokens_path', d.tokens_path]];
346
- const missing = targets.filter(([, p]) => p && !exists(path.join(projectRoot, p))).map(([k]) => k);
347
- if (missing.length) {
348
- return mk('design', 'Design system', 'warn', `missing path(s): ${missing.join(', ')}`,
349
- 'create the missing design paths or fix them in PIPELINE.md §design');
350
- }
351
- return mk('design', 'Design system', 'ok', `provider: ${d.provider} — DS paths present`);
352
- }
353
-
354
- function checkIsolation(profile, projectRoot) {
355
- const iso = profile && profile.isolation;
356
- if (!iso || !isTrue(iso.enabled)) return mk('isolation', 'Isolation', 'skip', 'isolation.enabled: false');
357
- const scripts = ['scripts/new-feature.sh', 'scripts/remove-feature.sh'];
358
- const problems = [];
359
- for (const rel of scripts) {
360
- const txt = readText(path.join(projectRoot, rel));
361
- if (txt == null) problems.push(`${rel} missing`);
362
- else if (/__[A-Z_]+__/.test(txt)) problems.push(`${rel} has unrendered __TOKEN__`);
363
- }
364
- if (problems.length) {
365
- return mk('isolation', 'Isolation', 'warn', problems.join('; '),
366
- '/cohorte-init-pipeline (re-render the isolation scripts)');
367
- }
368
- return mk('isolation', 'Isolation', 'ok', 'feature scripts rendered (worktree state not checked here)');
369
- }
370
-
371
- // Workflow variants (review/audit/refactor as deterministic multi-agent runs) are opt-in;
372
- // the conversational commands stay the default path, so nothing here is ever 'bad'.
373
- // Whether the session has workflows ENABLED needs a live Claude session — /cohorte-doctor
374
- // in-session checks that; here we only check what's on disk.
375
- function checkWorkflows(projectRoot, globalDir, installMode, all) {
376
- if (installMode === 'none') return mk('workflows', 'Workflows', 'skip', 'no core installed');
377
- // The Workflow engine is Claude Code's; the other runtimes are not shipped the scripts at
378
- // all, and reporting them as "missing" would flag a deliberate omission as a defect.
379
- const cc = all.find(l => l.id === 'claude');
380
- if (!cc) {
381
- return mk('workflows', 'Workflows', 'skip',
382
- `not available on ${all.map(l => l.label).join(', ') || 'this runtime'} — conversational commands only`);
383
- }
384
- const dir = path.join(cc.core, 'workflows');
385
- const agentsDir = cc.agents;
386
- const scripts = ['review.js', 'audit.js', 'refactor.js', 'loop.js'];
387
- const missing = scripts.filter(s => !exists(path.join(dir, s)));
388
- if (missing.length === scripts.length) {
389
- return mk('workflows', 'Workflows', 'warn',
390
- 'no workflow scripts installed — conversational commands only (the default path)',
391
- 'cohorte update (ships core/workflows/)');
392
- }
393
- if (missing.length) {
394
- return mk('workflows', 'Workflows', 'warn', `missing script(s): ${missing.join(', ')}`,
395
- 'cohorte update (half-copied core)');
396
- }
397
- if (!exists(path.join(agentsDir, 'profile-reader.md'))) {
398
- return mk('workflows', 'Workflows', 'warn',
399
- 'scripts present but the profile-reader agent (their phase 0) is missing',
400
- 'cohorte update (re-copies the fixed agents)');
401
- }
402
- return mk('workflows', 'Workflows', 'ok',
403
- 'scripts + profile-reader installed — opt-in per run; needs Claude Code ≥ 2.1.154 with ' +
404
- 'workflows enabled (run /cohorte-doctor in-session to check the live half). Inline design ' +
405
- '(design.inline) has its own, higher floor of ≥ 2.1.234 — it is a separate prerequisite and ' +
406
- 'does not gate this check either way');
407
- }
408
-
409
- function scanSpecs(projectRoot) {
410
- const dir = path.join(projectRoot, 'specs');
411
- const specs = [];
412
- let files = [];
413
- try { files = fs.readdirSync(dir).filter(f => f.endsWith('.md') && !f.startsWith('_') && !NON_SPEC_FILES.has(f)); }
414
- catch { return specs; }
415
- for (const f of files) {
416
- const txt = readText(path.join(dir, f)) || '';
417
- const fm = txt.match(/^---\r?\n([\s\S]*?)\r?\n---/);
418
- const body = fm ? fm[1] : '';
419
- const get = k => { const m = body.match(new RegExp(`^${k}:\\s*(.*)$`, 'm')); return m ? m[1].trim() : null; };
420
- const status = get('status');
421
- // Legacy resume state from the retired /cohorte-loop driver. Nothing writes it any more;
422
- // it is still read so a spec left mid-flight by an older core still shows why on the board.
423
- const pass = parseInt(get('loop_pass'), 10);
424
- const phase = get('loop_phase');
425
- specs.push({
426
- file: f,
427
- id: get('feature_id') || f.replace(/\.md$/, ''),
428
- title: get('title'),
429
- status: status ? status.split('#')[0].trim() : null,
430
- branch: get('branch'),
431
- loop: pass > 0 ? { pass, phase: phase && phase !== 'done' ? phase : null } : null,
432
- });
433
- }
434
- return specs;
435
- }
436
-
437
- function checkSpecs(specs) {
438
- if (!specs.length) return mk('specs', 'Specs', 'skip', 'no specs yet');
439
- const bad = specs.filter(s => !VALID_STATUS.includes(s.status));
440
- const counts = {};
441
- for (const s of specs) counts[s.status || '?'] = (counts[s.status || '?'] || 0) + 1;
442
- const summary = VALID_STATUS.filter(st => counts[st]).map(st => `${counts[st]} ${st}`).join(', ');
443
- if (bad.length) {
444
- return mk('specs', 'Specs', 'warn',
445
- `invalid status in: ${bad.map(s => s.file).join(', ')}`,
446
- `set status to one of: ${VALID_STATUS.join(' · ')}`);
447
- }
448
- return mk('specs', 'Specs', 'ok', `${specs.length} spec(s) — ${summary}`);
449
- }
450
-
451
- // --- orchestrator ------------------------------------------------------------
452
-
453
- async function state({ projectRoot, globalDir, cliVersion }) {
454
- const v = await versions({ projectRoot, globalDir, cliVersion });
455
-
456
- const pipelineMd = readText(path.join(projectRoot, 'PIPELINE.md'));
457
- const profile = pipelineMd ? parseProfileBlock(pipelineMd) : null;
458
- const specs = scanSpecs(projectRoot);
459
-
460
- // Which coding agent(s) this repo is wired for. Every path-dependent check below reads from
461
- // this rather than assuming `.claude/` — on a Cursor-only repo that assumption reported the
462
- // core, the agents, the artifacts and the hook as all broken, and every one of them was fine.
463
- const all = layouts({ projectRoot, globalDir });
464
- const main = primary(all);
465
- const stateAbs = stateDirs(all, projectRoot);
466
- const stateRels = stateAbs.map(rel(projectRoot));
467
-
468
- const checks = [
469
- checkCore(v),
470
- checkProfile(profile, pipelineMd != null),
471
- checkAgents(profile, projectRoot, main),
472
- checkGate(profile, projectRoot, stateAbs),
473
- checkLocalArtifacts(projectRoot, stateRels),
474
- checkHooks(projectRoot, globalDir, v.installMode, all),
475
- checkRetrieval(profile, projectRoot),
476
- checkDesign(profile, projectRoot),
477
- checkIsolation(profile, projectRoot),
478
- checkWorkflows(projectRoot, globalDir, v.installMode, all),
479
- checkSpecs(specs),
480
- ];
481
-
482
- const summary = { ok: 0, warn: 0, bad: 0, skip: 0 };
483
- for (const c of checks) summary[c.status]++;
484
-
485
- return {
486
- project: projectRoot,
487
- runtimes: all.map(l => ({ id: l.id, label: l.label, scope: l.scope,
488
- capabilities: l.capabilities, legacy: !!l.legacy })),
489
- versions: v,
490
- profile: profile ? {
491
- name: profile.name,
492
- one_liner: profile.one_liner,
493
- surfaces: (profile.surfaces || []).map(s => ({
494
- key: s.key, label: s.label, agent: s.agent, path: s.path,
495
- model: s.model, uses_design: s.uses_design, tools: s.tools,
496
- })),
497
- } : null,
498
- specs,
499
- checks,
500
- summary,
501
- };
502
- }
503
-
504
- module.exports = { state, scanSpecs };