cohorte 2.10.1 → 3.0.0-dev.1

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 (213) 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 +154 -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 +2187 -0
  41. package/dist/agent-host.mjs.map +1 -0
  42. package/dist/bundle-manifest.json +104 -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-CYOh60KG.mjs +93 -0
  50. package/dist/chunks/auth-CYOh60KG.mjs.map +1 -0
  51. package/dist/chunks/brainstorm-BwKA6yZP.mjs +7 -0
  52. package/dist/chunks/brainstorm-BwKA6yZP.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-Dvxunpto.mjs +7307 -0
  60. package/dist/chunks/compose-Dvxunpto.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-BkMjU1kR.mjs +295 -0
  64. package/dist/chunks/contract-BkMjU1kR.mjs.map +1 -0
  65. package/dist/chunks/contract-bQ9FJl4N.mjs +123 -0
  66. package/dist/chunks/contract-bQ9FJl4N.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-eS01-me1.mjs +21 -0
  72. package/dist/chunks/discover-eS01-me1.mjs.map +1 -0
  73. package/dist/chunks/doctor-B-H2UK5e.mjs +222 -0
  74. package/dist/chunks/doctor-B-H2UK5e.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-BUnzIkeE.mjs +63 -0
  80. package/dist/chunks/init-BUnzIkeE.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-DybihKzZ.mjs +33 -0
  86. package/dist/chunks/migrate-DybihKzZ.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-Bhy3IIYo.mjs +50 -0
  96. package/dist/chunks/reconcile-Bhy3IIYo.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/scan-8Gy_B6kI.mjs +122 -0
  110. package/dist/chunks/scan-8Gy_B6kI.mjs.map +1 -0
  111. package/dist/chunks/schema-C9QbUW4H.mjs +578 -0
  112. package/dist/chunks/schema-C9QbUW4H.mjs.map +1 -0
  113. package/dist/chunks/send-BdnJ85Nz.mjs +19 -0
  114. package/dist/chunks/send-BdnJ85Nz.mjs.map +1 -0
  115. package/dist/chunks/ship-DTuLGoaJ.mjs +19 -0
  116. package/dist/chunks/ship-DTuLGoaJ.mjs.map +1 -0
  117. package/dist/chunks/shutdown-BM92Iyv2.mjs +15 -0
  118. package/dist/chunks/shutdown-BM92Iyv2.mjs.map +1 -0
  119. package/dist/chunks/skip-DZG5kpcH.mjs +18 -0
  120. package/dist/chunks/skip-DZG5kpcH.mjs.map +1 -0
  121. package/dist/chunks/spec-Bq0P3jVh.mjs +20 -0
  122. package/dist/chunks/spec-Bq0P3jVh.mjs.map +1 -0
  123. package/dist/chunks/src-CId6kMsY.mjs +1689 -0
  124. package/dist/chunks/src-CId6kMsY.mjs.map +1 -0
  125. package/dist/chunks/src-CfAOde7C.mjs +5403 -0
  126. package/dist/chunks/src-CfAOde7C.mjs.map +1 -0
  127. package/dist/chunks/src-CmFjh4M0.mjs +928 -0
  128. package/dist/chunks/src-CmFjh4M0.mjs.map +1 -0
  129. package/dist/chunks/src-Cu3TxNob.mjs +783 -0
  130. package/dist/chunks/src-Cu3TxNob.mjs.map +1 -0
  131. package/dist/chunks/src-DcU4IXMe.mjs +117 -0
  132. package/dist/chunks/src-DcU4IXMe.mjs.map +1 -0
  133. package/dist/chunks/src-s7YRvk0t.mjs +2116 -0
  134. package/dist/chunks/src-s7YRvk0t.mjs.map +1 -0
  135. package/dist/chunks/status-SMjiOMYy.mjs +29 -0
  136. package/dist/chunks/status-SMjiOMYy.mjs.map +1 -0
  137. package/dist/chunks/tail-BuhT-2PH.mjs +25 -0
  138. package/dist/chunks/tail-BuhT-2PH.mjs.map +1 -0
  139. package/dist/chunks/update-BXIppsbi.mjs +16 -0
  140. package/dist/chunks/update-BXIppsbi.mjs.map +1 -0
  141. package/dist/cli.mjs +175 -0
  142. package/dist/cli.mjs.map +1 -0
  143. package/package.json +17 -32
  144. package/CHANGELOG.md +0 -1461
  145. package/bin/cli.js +0 -817
  146. package/bin/report.js +0 -141
  147. package/core/adapter/render.js +0 -415
  148. package/core/agents/implementer.template.md +0 -142
  149. package/core/agents/profile-reader.md +0 -50
  150. package/core/agents/release.md +0 -58
  151. package/core/agents/review.md +0 -179
  152. package/core/commands/cohorte-align-ds.md +0 -33
  153. package/core/commands/cohorte-audit.md +0 -58
  154. package/core/commands/cohorte-brainstorm.md +0 -70
  155. package/core/commands/cohorte-build.md +0 -219
  156. package/core/commands/cohorte-doctor.md +0 -194
  157. package/core/commands/cohorte-fix.md +0 -80
  158. package/core/commands/cohorte-fleet.md +0 -103
  159. package/core/commands/cohorte-init-pipeline.md +0 -31
  160. package/core/commands/cohorte-intake.md +0 -92
  161. package/core/commands/cohorte-patch.md +0 -118
  162. package/core/commands/cohorte-refactor.md +0 -59
  163. package/core/commands/cohorte-retro.md +0 -85
  164. package/core/commands/cohorte-review.md +0 -270
  165. package/core/commands/cohorte-ship.md +0 -122
  166. package/core/commands/cohorte-spec.md +0 -101
  167. package/core/commands/cohorte-update-pipeline.md +0 -165
  168. package/core/hooks/gate.py +0 -588
  169. package/core/runtimes/claude.json +0 -74
  170. package/core/runtimes/codex.json +0 -85
  171. package/core/runtimes/cursor.json +0 -76
  172. package/core/runtimes/gemini.json +0 -76
  173. package/core/runtimes/opencode.json +0 -73
  174. package/core/templates/agent-handoff.md +0 -34
  175. package/core/templates/brainstorm-return.md +0 -36
  176. package/core/templates/decisions.template.md +0 -42
  177. package/core/templates/design-brief.md +0 -44
  178. package/core/templates/patch.template.md +0 -86
  179. package/core/templates/pr-body.md +0 -25
  180. package/core/templates/review-feedback.md +0 -38
  181. package/core/templates/spec.template.md +0 -87
  182. package/core/templates/steps/init-pipeline/01-detect-stack.md +0 -44
  183. package/core/templates/steps/init-pipeline/02-interview-gaps.md +0 -55
  184. package/core/templates/steps/init-pipeline/03-draft-profile.md +0 -10
  185. package/core/templates/steps/init-pipeline/04-write-render.md +0 -145
  186. package/core/templates/steps/init-pipeline/05-report.md +0 -12
  187. package/core/workflows/audit.js +0 -240
  188. package/core/workflows/loop.js +0 -641
  189. package/core/workflows/refactor.js +0 -276
  190. package/core/workflows/review.js +0 -456
  191. package/install.ps1 +0 -103
  192. package/install.sh +0 -114
  193. package/lib/doctor.js +0 -534
  194. package/lib/runtime.js +0 -139
  195. package/lib/versions.js +0 -149
  196. package/lib/yaml.js +0 -129
  197. package/profile/PIPELINE.template.md +0 -259
  198. package/profile/SCHEMA.md +0 -735
  199. package/profile/cohorte.config.template.yaml +0 -43
  200. package/scripts/assert-gate-hook.mjs +0 -75
  201. package/scripts/kanban-move.sh +0 -313
  202. package/scripts/metrics/collect.mjs +0 -531
  203. package/scripts/metrics/prices.json +0 -42
  204. package/scripts/new-feature.sh.template +0 -98
  205. package/scripts/preflight.sh +0 -124
  206. package/scripts/remove-feature.sh.template +0 -56
  207. package/scripts/test-adapter.mjs +0 -437
  208. package/scripts/test-gate.mjs +0 -443
  209. package/scripts/test-kanban.mjs +0 -304
  210. package/scripts/test-lib.mjs +0 -356
  211. package/scripts/test-metrics.mjs +0 -156
  212. package/scripts/test-workflows.mjs +0 -758
  213. package/scripts/validate-core.mjs +0 -325
@@ -1,124 +0,0 @@
1
- #!/bin/sh
2
- #
3
- # preflight.sh — deterministic phase gate for /cohorte-review.
4
- #
5
- # Runs the profile's mechanical checks (typecheck, lint, tests — whatever the caller
6
- # passes) BEFORE any agent is spawned. A red gate means the caller aborts and relays
7
- # the raw failure — dispatching reviewers onto code that doesn't even compile burns
8
- # their whole run on noise a compiler already printed for free.
9
- #
10
- # preflight.sh <report-file> "<cmd>" ["<cmd>"...]
11
- #
12
- # - Each command runs through `sh -c`, all output appended to <report-file> — the bulk
13
- # never enters the calling agent's context.
14
- # - First failure: prints the last 40 lines of the report raw to stderr and exits 1.
15
- # The caller must stop there — no agents.
16
- # - All green: writes `<project>/.claude/preflight.ok` ("<epoch> <HEAD sha> <tree digest>")
17
- # — the stamp `hooks/gate.py` checks (gate-config.json `preflight` block) before letting
18
- # review agents dispatch.
19
- #
20
- # The stamp is a LOCAL, GITIGNORED artifact. Versioning it breaks the gate outright: the
21
- # stamp records the pre-commit sha, committing it moves HEAD, so the checked-out stamp can
22
- # never match its own commit — every dispatch then reports "HEAD moved" on a clean tree.
23
- # A tracked stamp also rides into every new worktree, handing it a green it never earned.
24
-
25
- set -u
26
-
27
- report="${1:?usage: preflight.sh <report-file> \"<cmd>\" [\"<cmd>\"...]}"
28
- shift
29
- [ "$#" -gt 0 ] || { echo "preflight: no commands given" >&2; exit 2; }
30
-
31
- mkdir -p "$(dirname "$report")" 2>/dev/null || true
32
- : > "$report"
33
-
34
- n=0
35
- for cmd in "$@"; do
36
- [ -n "$cmd" ] || continue
37
- n=$((n + 1))
38
- printf '\n$ %s\n' "$cmd" >> "$report"
39
- if ! sh -c "$cmd" >> "$report" 2>&1; then
40
- echo "PREFLIGHT FAIL — $cmd" >&2
41
- echo "--- last 40 lines of $report ---" >&2
42
- tail -40 "$report" >&2
43
- exit 1
44
- fi
45
- done
46
- # All-empty arguments verified nothing — stamping green here would gate reviews on a
47
- # check that never ran. Same failure as zero arguments, and the same exit.
48
- [ "$n" -gt 0 ] || { echo "preflight: every command argument was empty — nothing was verified" >&2; exit 2; }
49
-
50
- # Stamp for the gate.py phase gate: epoch + HEAD sha of the checkout we verified.
51
- # The stamp MUST land where gate.py reads it: <main checkout>/.claude. CLAUDE_PROJECT_DIR
52
- # is only exported to hooks — in an agent's Bash call it is unset, and a bare `.` would
53
- # drop the stamp in the feature worktree where the gate never looks. git-common-dir
54
- # resolves to the MAIN checkout's .git from any worktree.
55
- proj="${CLAUDE_PROJECT_DIR:-$(dirname "$(git rev-parse --git-common-dir 2>/dev/null || echo ./.git)")}"
56
- sha=$(git rev-parse HEAD 2>/dev/null || echo none)
57
-
58
- # Content digest of the state we actually verified: the git TREE id of the working tree,
59
- # built in a throwaway index so nothing touches the real one. The sha alone is the wrong
60
- # key — the reviewed tree is normally DIRTY (uncommitted feature work), so committing it
61
- # invalidates the stamp without changing a line of code, while an implementer's edit
62
- # changes every line without moving HEAD. A tree id is content-addressed: it survives a
63
- # commit of the same content and dies on any real edit (including new untracked files).
64
- # `.claude`/`.cohorte` (stamps, metrics) and `specs` (DoD ticks, report buffer) are excluded — the
65
- # pipeline writes those itself between the preflight and the dispatch it must not invalidate.
66
- # gate.py recomputes this identically; any change here must land there too.
67
- digest=none
68
- tmpidx=$(mktemp 2>/dev/null || echo "")
69
- if [ -n "$tmpidx" ]; then
70
- # Seed from the real index (not `read-tree HEAD`) so its stat cache is preserved and
71
- # `add` only re-hashes files that actually changed.
72
- idx=$(git rev-parse --git-path index 2>/dev/null || echo "")
73
- if [ -n "$idx" ] && [ -f "$idx" ]; then
74
- cp "$idx" "$tmpidx" 2>/dev/null || true
75
- # Backdate the copy, for the reason spelled out in gate.py's tree_digest(): git trusts an
76
- # entry's cached stat data only when its mtime predates the index file's, so a copy stamped
77
- # `now` makes a file edited in this same second look clean. Both sides must age it by the
78
- # same window or they compute different trees for the same content. `date -d` is GNU and
79
- # `date -v` is BSD — try both, and if neither exists just skip the touch (the digest is
80
- # still correct for anything not edited in the last few seconds).
81
- # LOCAL time on purpose: `touch -t` interprets its stamp as local time, so a
82
- # UTC-formatted stamp future-dates the index anywhere west of UTC — which makes
83
- # git trust the stat cache of files edited THIS second, the exact race the
84
- # backdate exists to prevent. (gate.py's side uses os.utime with an epoch, which
85
- # has no timezone to get wrong.)
86
- stamp=$(date -d '5 seconds ago' +%Y%m%d%H%M.%S 2>/dev/null \
87
- || date -v-5S +%Y%m%d%H%M.%S 2>/dev/null || echo "")
88
- [ -n "$stamp" ] && touch -t "$stamp" "$tmpidx" 2>/dev/null || true
89
- else
90
- rm -f "$tmpidx" # a 0-byte index is a corrupt index
91
- fi
92
- # Drop the excluded paths from the throwaway index entirely: an `add` exclude only stops
93
- # them being *updated*, so anything already tracked there (a committed stamp, a spec)
94
- # would still land in the tree and shift the digest.
95
- GIT_INDEX_FILE="$tmpidx" git rm --cached -r -q --ignore-unmatch -- .claude .cohorte specs > /dev/null 2>&1 || true
96
- if GIT_INDEX_FILE="$tmpidx" git add -A -- . ':(exclude).claude' ':(exclude).cohorte' ':(exclude)specs' > /dev/null 2>&1; then
97
- digest=$(GIT_INDEX_FILE="$tmpidx" git write-tree 2>/dev/null || echo none)
98
- fi
99
- rm -f "$tmpidx" "$tmpidx.lock" 2>/dev/null || true
100
- fi
101
- [ -n "$digest" ] || digest=none
102
- # Stamp BOTH the main checkout and the cwd: gate.py reads COHORTE_PROJECT_DIR /
103
- # CLAUDE_PROJECT_DIR, which is the worktree when the session was opened there and the
104
- # main checkout when it wasn't — the two disagree, and either layout is supported.
105
- # Stamp every state dir that EXISTS (`.cohorte` on a non-Claude runtime, `.claude` on a
106
- # Claude one, both where a repo is driven from both), falling back to `.claude` when the
107
- # repo has neither yet — gate.py's state_path() probes in that same order.
108
- now=$(date +%s)
109
- last=""
110
- for d in "$proj" "$(pwd)"; do
111
- [ "$d" = "$last" ] && continue # same dir twice in the main checkout
112
- last="$d"
113
- wrote=0
114
- for s in .cohorte .claude; do
115
- [ -d "$d/$s" ] || continue
116
- printf '%s %s %s\n' "$now" "$sha" "$digest" > "$d/$s/preflight.ok" 2>/dev/null && wrote=1
117
- done
118
- if [ "$wrote" -eq 0 ]; then
119
- mkdir -p "$d/.claude" 2>/dev/null || true
120
- printf '%s %s %s\n' "$now" "$sha" "$digest" > "$d/.claude/preflight.ok" 2>/dev/null || true
121
- fi
122
- done
123
-
124
- echo "PREFLIGHT PASS ($n checks green) — full log: $report"
@@ -1,56 +0,0 @@
1
- #!/usr/bin/env bash
2
- #
3
- # remove-feature.sh — tear down a feature's isolated worktree. Rendered from a
4
- # template by /cohorte-init-pipeline (double-underscore tokens from PIPELINE.md §isolation —
5
- # spelled out here so /cohorte-doctor's unrendered-token check never trips on this comment).
6
- #
7
- # Usage: scripts/remove-feature.sh <feature_id> [--drop-db]
8
- #
9
- # Removes the ../__PROJECT_SLUG__-<id> worktree, frees its slot, and (with
10
- # --drop-db) drops its database. The db is kept by default. The shared stack and
11
- # the main checkout are never touched.
12
-
13
- set -euo pipefail
14
-
15
- id="${1:-}"
16
- drop_db="no"
17
- [[ "${2:-}" == "--drop-db" ]] && drop_db="yes"
18
-
19
- if [[ -z "$id" ]]; then
20
- echo "usage: scripts/remove-feature.sh <feature_id> [--drop-db]" >&2
21
- exit 1
22
- fi
23
-
24
- repo_root="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --show-toplevel)"
25
- worktree_dir="$(dirname "$repo_root")/__PROJECT_SLUG__-${id}"
26
- db_name="__PROJECT_SLUG___$(echo "$id" | tr -c 'a-zA-Z0-9' '_' | sed 's/_*$//')"
27
- registry="$repo_root/.worktrees/slots.tsv"
28
- branch="__BRANCH_PREFIX__${id}"
29
-
30
- echo "→ removing worktree $worktree_dir"
31
- # Porcelain + fixed-string + whole-line: the human-readable list matched any worktree
32
- # whose path merely CONTAINS this one (auth vs auth-2fa), and dots in the path were regex.
33
- if git -C "$repo_root" worktree list --porcelain | grep -Fxq "worktree $worktree_dir"; then
34
- git -C "$repo_root" worktree remove "$worktree_dir" "${@:3}"
35
- else
36
- echo " (no such worktree registered — skipping)"
37
- fi
38
-
39
- if git -C "$repo_root" show-ref --verify --quiet "refs/heads/$branch"; then
40
- if git -C "$repo_root" branch -d "$branch" 2>/dev/null; then
41
- echo "→ deleted merged branch $branch"
42
- else
43
- echo " ! branch $branch is not fully merged — kept it." >&2
44
- echo " delete manually when ready: git branch -D $branch" >&2
45
- fi
46
- fi
47
-
48
- if [[ -f "$registry" ]]; then
49
- tmp="$(mktemp)"
50
- awk -F'\t' -v id="$id" '$1!=id' "$registry" >"$tmp" && mv "$tmp" "$registry"
51
- echo "→ freed slot for '$id'"
52
- fi
53
-
54
- # __DB_DROP_STANZA__
55
-
56
- echo "✓ feature '$id' torn down"
@@ -1,437 +0,0 @@
1
- #!/usr/bin/env node
2
- // Behavioural tests for the runtime adapter — core/adapter/render.js + core/runtimes/*.json.
3
- //
4
- // The adapter is where a single set of source prompts becomes N runtime-specific ones. Its
5
- // failure mode is silent and expensive: a dropped conditional ships a Claude-only instruction
6
- // to a runtime that cannot follow it, a leaked marker turns doctrine into visible noise, and a
7
- // wrong frontmatter key is read by the model as prose. None of that raises an error anywhere —
8
- // it just makes the pipeline quietly wrong on four runtimes out of five.
9
- //
10
- // node scripts/test-adapter.mjs
11
-
12
- import { readFileSync, readdirSync, mkdtempSync, mkdirSync, rmSync, existsSync } from "node:fs";
13
- import { spawnSync } from "node:child_process";
14
- import { tmpdir } from "node:os";
15
- import { join } from "node:path";
16
- import { fileURLToPath } from "node:url";
17
- import { createRequire } from "node:module";
18
-
19
- const root = fileURLToPath(new URL("..", import.meta.url));
20
- const require = createRequire(import.meta.url);
21
- const adapter = require(join(root, "core", "adapter", "render.js"));
22
-
23
- let failures = 0;
24
- const check = (name, cond, detail = "") => {
25
- if (cond) console.log(` ✓ ${name}`);
26
- else { failures++; console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`); }
27
- };
28
- const group = (name) => console.log(name);
29
- const throws = (fn) => { try { fn(); return false; } catch { return true; } };
30
-
31
- const RUNTIMES = adapter.listRuntimes();
32
- const tmps = [];
33
-
34
- // ---------------------------------------------------------------- registry ---
35
- group("registry — every runtime declares what the renderer reads");
36
-
37
- check("at least the five supported runtimes ship", RUNTIMES.length >= 5, RUNTIMES.join(","));
38
- for (const id of RUNTIMES) {
39
- const rt = adapter.loadRuntime(id);
40
- const ok = rt.id === id
41
- && typeof rt.label === "string"
42
- && rt.scopes && rt.scopes.global && rt.scopes.project
43
- && rt.command && ["md", "toml", "skill"].includes(rt.command.format)
44
- && Array.isArray(rt.command.frontmatter)
45
- && rt.capabilities && typeof rt.capabilities.subagents === "boolean"
46
- && typeof rt.capabilities.hooks === "boolean"
47
- && typeof rt.capabilities.workflows === "boolean"
48
- && typeof rt.capabilities.tool_restriction === "boolean";
49
- check(`${id}: complete and well-typed`, ok);
50
- }
51
- check("unknown runtime is an error, not a silent default",
52
- throws(() => adapter.loadRuntime("nope")));
53
-
54
- // A runtime that claims a capability it cannot back is the one mistake the whole design rests
55
- // on: every `cohorte:if` branch trusts these booleans literally, and an over-claim ships the
56
- // strict doctrine to a runtime that cannot enforce it. Pinned against the vendor docs — see
57
- // each runtime's `docs` field; revisit these three lines whenever one of them is re-read.
58
- const withCap = (c) => RUNTIMES.filter((id) => adapter.loadRuntime(id).capabilities[c]).sort().join();
59
- check("hooks claimed everywhere but OpenCode (plugins are not a blocking hook)",
60
- withCap("hooks") === "claude,codex,cursor,gemini", withCap("hooks"));
61
- check("only Claude Code claims workflows", withCap("workflows") === "claude");
62
- // Not a capability to branch on: a HARD requirement. The pipeline's isolation guarantee is the
63
- // subagent boundary, so a runtime without them cannot be supported — and must be refused loudly
64
- // rather than rendered into a pipeline whose central promise is silently absent.
65
- check("every target runtime has real subagents",
66
- withCap("subagents") === RUNTIMES.slice().sort().join(), withCap("subagents"));
67
- check("a runtime declaring no subagents is refused, not degraded",
68
- throws(() => adapter.assertSupported({ id: "x", capabilities: { subagents: false } })));
69
- check("…and one that has them passes the same guard",
70
- !throws(() => adapter.assertSupported({ id: "x", capabilities: { subagents: true } })));
71
-
72
- // A hook runtime must declare how to talk to it, and a no-ask runtime must be flagged: gate.py
73
- // escalates ask→deny there, and getting this backwards silently lets a gated command run.
74
- for (const id of RUNTIMES) {
75
- const rt = adapter.loadRuntime(id);
76
- if (!rt.capabilities.hooks) { check(`${id}: declares no hook contract`, !rt.hook); continue; }
77
- check(`${id}: hook contract is complete`, !!rt.hook && !!rt.hook.event
78
- && ["claude", "cursor", "gemini"].includes(rt.hook.format)
79
- && typeof rt.hook.supports_ask === "boolean"
80
- && !!rt.scopes.project.hooks_config);
81
- }
82
- check("the ask tier is claimed only where the runtime honours it",
83
- RUNTIMES.filter((id) => (adapter.loadRuntime(id).hook || {}).supports_ask).sort().join()
84
- === "claude,cursor");
85
-
86
- // ------------------------------------------------------------ conditionals ---
87
- group("conditionals — the branch that survives is the branch that is true");
88
-
89
- // Synthetic capability sets, not real runtimes: the branch logic must stay correct however the
90
- // vendors' feature matrix moves, and a unit test of the parser should not depend on which
91
- // runtimes happen to ship today.
92
- const rich = { id: "rich", capabilities: { subagents: true, hooks: true, workflows: true, tool_restriction: true } };
93
- const bare = { id: "bare", capabilities: { subagents: false, hooks: false, workflows: false, tool_restriction: false } };
94
-
95
- const basic = ["<!-- cohorte:if hooks -->", "H", "<!-- cohorte:else -->", "NOH", "<!-- cohorte:endif -->"].join("\n");
96
- check("if/else keeps the taken branch", adapter.applyConditionals(basic, rich).trim() === "H");
97
- check("if/else keeps the else branch", adapter.applyConditionals(basic, bare).trim() === "NOH");
98
-
99
- const neg = ["<!-- cohorte:if !hooks -->", "ADVISORY", "<!-- cohorte:endif -->"].join("\n");
100
- check("negation works", adapter.applyConditionals(neg, bare).trim() === "ADVISORY"
101
- && adapter.applyConditionals(neg, rich).trim() === "");
102
-
103
- const byId = ["<!-- cohorte:if runtime:claude -->", "CC", "<!-- cohorte:endif -->"].join("\n");
104
- check("runtime:<id> targets one runtime",
105
- adapter.applyConditionals(byId, adapter.loadRuntime("claude")).trim() === "CC"
106
- && adapter.applyConditionals(byId, adapter.loadRuntime("cursor")).trim() === "");
107
-
108
- const or = ["<!-- cohorte:if hooks workflows -->", "X", "<!-- cohorte:endif -->"].join("\n");
109
- check("a multi-term condition is an OR", adapter.applyConditionals(or, rich).trim() === "X");
110
-
111
- const nested = [
112
- "<!-- cohorte:if subagents -->", "A",
113
- "<!-- cohorte:if hooks -->", "B", "<!-- cohorte:else -->", "C", "<!-- cohorte:endif -->",
114
- "<!-- cohorte:endif -->",
115
- ].join("\n");
116
- check("nesting resolves inner branches inside a taken outer one",
117
- adapter.applyConditionals(nested, rich).trim().split("\n").join() === "A,B");
118
- check("a dropped outer branch drops its inner branches whole",
119
- adapter.applyConditionals(nested, bare).trim() === "");
120
-
121
- check("an unknown capability is an error, not a silently-false branch",
122
- throws(() => adapter.applyConditionals("<!-- cohorte:if telepathy -->\nx\n<!-- cohorte:endif -->", rich)));
123
- check("an unclosed if is an error", throws(() => adapter.applyConditionals("<!-- cohorte:if hooks -->\nx", rich)));
124
- check("a stray endif is an error", throws(() => adapter.applyConditionals("<!-- cohorte:endif -->", rich)));
125
- check("two elses in one if is an error", throws(() => adapter.applyConditionals(
126
- ["<!-- cohorte:if hooks -->", "<!-- cohorte:else -->", "<!-- cohorte:else -->", "<!-- cohorte:endif -->"].join("\n"), rich)));
127
-
128
- // Every marker in the real source must be resolvable for EVERY runtime — an unknown term in
129
- // a command nobody rendered yet would surface as an install-time crash for one runtime only.
130
- group("source prompts — every marker resolves for every runtime");
131
- const sources = [
132
- ...readdirSync(join(root, "core", "commands")).map((f) => ["commands", f]),
133
- ...readdirSync(join(root, "core", "agents")).map((f) => ["agents", f]),
134
- ].filter(([, f]) => f.endsWith(".md"));
135
- for (const id of RUNTIMES) {
136
- const rt = adapter.loadRuntime(id);
137
- let bad = null;
138
- for (const [dir, f] of sources) {
139
- const src = readFileSync(join(root, "core", dir, f), "utf8");
140
- try { adapter.applyConditionals(adapter.parseFrontmatter(src).body, rt); }
141
- catch (e) { bad = `${dir}/${f}: ${e.message}`; break; }
142
- }
143
- check(`${id}: all ${sources.length} source files render`, !bad, bad || "");
144
- }
145
-
146
- // ------------------------------------------------------------ full install ---
147
- group("install — what each runtime actually gets on disk");
148
-
149
- const home = mkdtempSync(join(tmpdir(), "cohorte-home-"));
150
- const proj = mkdtempSync(join(tmpdir(), "cohorte-proj-"));
151
- tmps.push(home, proj);
152
- spawnSync("git", ["init", "-q", "."], { cwd: proj });
153
- const run = spawnSync(process.execPath, [join(root, "bin", "cli.js"), "install",
154
- `--runtime=${RUNTIMES.join(",")}`], {
155
- cwd: proj, env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: "" }, encoding: "utf8",
156
- });
157
- check("the installer exits clean for every runtime at once", run.status === 0,
158
- (run.stderr || "").slice(0, 400));
159
-
160
- for (const id of RUNTIMES) {
161
- const rt = adapter.loadRuntime(id);
162
- // resolvePaths reads ~ from the real homedir, so re-point it at the sandbox.
163
- const p = adapter.resolvePaths(rt, "project", proj);
164
- const fix = (s) => s && s.replace(process.env.HOME, home);
165
- const cmdDir = fix(p.commands);
166
- const buildFile = join(cmdDir, `cohorte-build${rt.command.ext}`);
167
- check(`${id}: commands landed in ${rt.scopes.project.commands}`, existsSync(buildFile));
168
- if (!existsSync(buildFile)) continue;
169
- const build = readFileSync(buildFile, "utf8");
170
-
171
- check(`${id}: no unresolved marker leaked into the output`, !/cohorte:(if|else|endif)/.test(build));
172
- check(`${id}: the runtime preamble is present`, build.includes(`**Runtime: ${rt.label}.**`));
173
-
174
- // Parallel dispatch is the doctrine on every runtime now; the sequential-persona fallback was
175
- // removed in 2.2.0 along with any suggestion that a lead can simulate the boundary by hand.
176
- check(`${id}: dispatches surfaces in parallel`, build.includes("IN PARALLEL"));
177
- check(`${id}: no trace of the removed persona fallback`,
178
- !build.includes("ONE PERSONA AT A TIME") && !build.includes("adopt it verbatim"));
179
-
180
- // A path the runtime does not have is a path the model will fail to read, silently.
181
- if (id !== "claude") {
182
- check(`${id}: no hardcoded .claude path left in the prose`,
183
- !/(?<![\w/.-])~?\/?\.claude\//.test(build), (build.match(/.{0,60}\.claude\/.{0,40}/) || [""])[0]);
184
- }
185
-
186
- // The preamble must describe the gate the way it actually works here — a blocking hook that
187
- // fires regardless, or an advisory check the agent has to call. Getting this backwards is the
188
- // worst single error the adapter can make: it tells the model a safety property holds when it
189
- // does not.
190
- if (rt.capabilities.hooks) {
191
- check(`${id}: the gate is described as a blocking ${rt.hook.event} hook`,
192
- build.includes("is registered as a blocking") && build.includes(rt.hook.event));
193
- check(`${id}: the missing confirmation tier is stated`,
194
- build.includes("no confirmation tier") === !rt.hook.supports_ask);
195
- } else {
196
- check(`${id}: the gate is described as an explicit check`, build.includes("gate.py --check"));
197
- }
198
-
199
- // Frontmatter the runtime does not understand is prose the model reads as instruction.
200
- if (rt.command.format === "md" || rt.command.format === "skill") {
201
- const fm = adapter.parseFrontmatter(build).keys.map(([k]) => k);
202
- check(`${id}: only supported frontmatter keys survive`,
203
- fm.every((k) => rt.command.frontmatter.includes(k)), fm.join(","));
204
- if (rt.command.format === "skill") {
205
- // A skill is matched on its frontmatter `name`, both for explicit invocation and for
206
- // implicit selection. Without it the file installs and is simply never reachable.
207
- check(`${id}: the skill carries the name it is invoked by`,
208
- fm.includes("name") && /^name: cohorte-build$/m.test(build));
209
- check(`${id}: skills are repo-scoped, so a clone gets the commands`,
210
- rt.scopes.project.commands.startsWith(".agents/"));
211
- }
212
- } else {
213
- check(`${id}: emitted as ${rt.command.format}, not markdown frontmatter`,
214
- !build.startsWith("---\n") && /^description = "/m.test(build) && /^prompt = '''/m.test(build));
215
- }
216
-
217
- // Placeholder substitution: a token the runtime never expands must be explained, not left
218
- // to look like it works.
219
- if (rt.command.args && rt.command.args !== "$ARGUMENTS") {
220
- check(`${id}: $ARGUMENTS rewritten to ${rt.command.args}`,
221
- build.includes(rt.command.args) && !build.includes("$ARGUMENTS"));
222
- } else if (!rt.command.args) {
223
- check(`${id}: the unsubstituted placeholder is explained in the preamble`,
224
- build.includes("does not substitute placeholders"));
225
- }
226
-
227
- for (const excluded of rt.exclude_commands || []) {
228
- check(`${id}: ${excluded} is not installed (it cannot run here)`,
229
- !existsSync(join(cmdDir, `${excluded}${rt.command.ext}`)));
230
- }
231
-
232
- const agentsDir = fix(p.agents) || join(fix(p.core), "agents");
233
- const reviewFile = join(agentsDir, `review${rt.agent.ext || ".md"}`);
234
- check(`${id}: the review agent exists as ${rt.agent.format}`, existsSync(reviewFile));
235
- if (existsSync(reviewFile)) {
236
- const review = readFileSync(reviewFile, "utf8");
237
- // The reviewer must never be able to fix what it reports. Where the runtime can enforce
238
- // that, the rendered file must carry the restriction; where it cannot, the body must say
239
- // so — a reviewer that silently gains write access destroys the fix loop's evidence.
240
- if (rt.agent.readonly_key) {
241
- check(`${id}: the reviewer is pinned read-only (${rt.agent.readonly_key})`,
242
- review.includes(rt.agent.readonly_key) && review.includes(rt.agent.readonly_value));
243
- } else if (rt.capabilities.tool_restriction) {
244
- // Claude expresses it as the absence of write tools in the `tools:` list.
245
- check(`${id}: the reviewer's tool list carries no write tool`,
246
- /^tools:.*$/m.test(review) && !/^tools:.*(Write|Edit|Bash)/m.test(review));
247
- } else {
248
- check(`${id}: the reviewer is told read-only is on it`,
249
- review.includes("read-only **by discipline**") || review.includes("read-only by discipline"));
250
- }
251
- // An Anthropic model alias in another vendor's agent file either errors or is ignored.
252
- check(`${id}: no Anthropic model alias leaked into the agent file`,
253
- id === "claude" || !/^\s*model\s*[:=]/m.test(review), (review.match(/^.*model.*$/m) || [""])[0]);
254
- }
255
- // Every non-Claude runtime shares one `.cohorte` core, so this registry must ACCUMULATE.
256
- // A single-record file let each install erase the previous runtime's entry.
257
- const rtJson = join(fix(p.core), "pipeline", "runtimes.json");
258
- check(`${id}: survives in runtimes.json after the other installs`, existsSync(rtJson)
259
- && !!JSON.parse(readFileSync(rtJson, "utf8"))[id]);
260
- check(`${id}: the gate script ships with the core`, existsSync(join(fix(p.core), "hooks", "gate.py")));
261
- check(`${id}: workflows ship only where a workflow engine exists`,
262
- existsSync(join(fix(p.core), "workflows")) === rt.capabilities.workflows);
263
-
264
- // Templates are resolved in place at install time, so a shared core would let the LAST
265
- // runtime installed decide what every other one reads. Each core is its own directory
266
- // precisely to prevent that; assert the resolution actually matches this runtime.
267
- const step = join(fix(p.core), "templates", "steps", "init-pipeline", "04-write-render.md");
268
- if (existsSync(step)) {
269
- const text = readFileSync(step, "utf8");
270
- check(`${id}: templates carry no unresolved marker`, !/cohorte:(if|else|endif)/.test(text));
271
- check(`${id}: the settings/hook step matches this runtime`,
272
- text.includes("Write `.claude/settings.json`") === (rt.capabilities.hooks && id !== "codex"));
273
- if (id === "codex") {
274
- check("codex: init generates native project agents and MCP config",
275
- text.includes('.codex/agents/<agent>.toml') && text.includes('.codex/config.toml')
276
- && !text.includes('render `<agents>/<agent>.md`') && !text.includes('`review.md`'));
277
- const schema = readFileSync(join(p.core, 'pipeline', 'SCHEMA.md'), 'utf8');
278
- check("codex: reconciliation preserves TOML destinations and Codex MCP context",
279
- schema.includes('`<agents>/<agent>.toml`') && schema.includes('--context codex')
280
- && !schema.includes('claude mcp add'));
281
- const doctor = readFileSync(join(cmdDir, 'cohorte-doctor', 'SKILL.md'), 'utf8');
282
- check("codex: doctor understands inherited models and TOML",
283
- doctor.includes('Missing `model` means inheritance, not an error')
284
- && !doctor.includes('sonnet/haiku/haiku'));
285
- const update = readFileSync(join(cmdDir, 'cohorte-update-pipeline', 'SKILL.md'), 'utf8');
286
- check("codex: update selects its runtime and does not demand Claude workflows",
287
- update.includes('update --runtime=codex') && !update.includes('claude mcp add')
288
- && !update.includes('`<core>/workflows/` + `agents/profile-reader.md`'));
289
- const template = readFileSync(join(p.core, 'pipeline', 'PIPELINE.template.md'), 'utf8');
290
- check("codex: new profiles default to inheritance",
291
- /model: inherit/.test(template) && !/model: sonnet/.test(template));
292
- const implementer = readFileSync(join(p.core, 'pipeline', 'implementer.template.md'), 'utf8');
293
- check('codex: implementers are not accidentally pinned to a read-only sandbox',
294
- !/^sandbox_mode = "read-only"/m.test(implementer));
295
- }
296
- }
297
- }
298
-
299
- group("codex — global core, project-local surfaces, native TOML");
300
- {
301
- const env = { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: '' };
302
- const r = spawnSync(process.execPath,
303
- [join(root, 'bin/cli.js'), 'install', '--global', '--runtime=codex'],
304
- { cwd: proj, env, encoding: 'utf8' });
305
- check('global Codex install succeeds', r.status === 0, r.stderr);
306
- const init = readFileSync(join(home, '.agents/skills/cohorte-init-pipeline/SKILL.md'), 'utf8');
307
- check('global skill resolves surfaces relative to whichever project invokes it',
308
- init.includes('`<agents>` = `.codex/agents`') && !init.includes(proj));
309
- check('generic agents retain their global destination',
310
- existsSync(join(home, '.codex/agents/review.toml'))
311
- && init.includes('`<fixed-agents>` = `~/.codex/agents`'));
312
- const registry = JSON.parse(readFileSync(join(home, '.cohorte/codex/pipeline/runtimes.json'), 'utf8'));
313
- check('global registry does not pin surface agents to the installer cwd',
314
- registry.codex.paths.surface_agents === '.codex/agents'
315
- && registry.codex.paths.agent_ext === '.toml');
316
- const hook = JSON.parse(readFileSync(join(home, '.codex/hooks.json'), 'utf8')).hooks.PreToolUse[0];
317
- check('installed Codex hook matches real shell and subagent calls',
318
- ['Bash', 'spawn_agent', 'Agent'].every(n => new RegExp(hook.matcher).test(n)));
319
- const rt = adapter.loadRuntime('codex');
320
- const paths = adapter.resolvePaths(rt, 'global', proj);
321
- const render = model => adapter.renderAgent({
322
- source: `---\nname: example\ndescription: Test agent\nmodel: ${model}\nmodel_reasoning_effort: high\n---\nHandle literal ''' in instructions.`,
323
- name: 'example', runtime: rt, paths, projectRoot: proj,
324
- }).content;
325
- const explicit = render('gpt-5.6-terra');
326
- check('explicit Codex model and reasoning choices survive rendering',
327
- explicit.includes('model = "gpt-5.6-terra"') && explicit.includes('model_reasoning_effort = "high"'));
328
- check('inherit and legacy Anthropic aliases never become executable pins',
329
- ['inherit', 'sonnet', 'haiku', 'opus'].every(m => !/^model =/m.test(render(m))));
330
- const python = [process.env.COHORTE_TEST_PYTHON, 'python3', 'python'].filter(Boolean)
331
- .find(p => spawnSync(p, ['-c', 'import tomllib']).status === 0);
332
- if (!python) { check('Python 3.11+ available to validate emitted TOML', false); }
333
- else {
334
- const parsed = spawnSync(python, ['-c', 'import sys,tomllib; print(tomllib.loads(sys.stdin.read())["developer_instructions"])'],
335
- { input: explicit, encoding: 'utf8' });
336
- check('instructions containing triple apostrophes remain valid TOML',
337
- parsed.status === 0 && parsed.stdout.includes("literal '''"), parsed.stderr);
338
- const native = spawnSync(python, ['-c',
339
- 'import pathlib,sys,tomllib; [tomllib.loads(p.read_text()) for p in pathlib.Path(sys.argv[1]).glob("*.toml")]',
340
- join(home, '.codex/agents')], { encoding: 'utf8' });
341
- check('all installed generic agents parse as TOML', native.status === 0, native.stderr);
342
- }
343
- }
344
-
345
- // The project state — gate config, preflight stamp, metrics — describes the repo, not the
346
- // agent driving it, and must NOT fork per runtime.
347
- group("state — one project, one gate config");
348
- const stateDirs = new Set(RUNTIMES.map((id) => adapter.stateDir(adapter.loadRuntime(id))));
349
- check("every non-Claude runtime shares one state dir", stateDirs.size === 2
350
- && stateDirs.has(".claude") && stateDirs.has(".cohorte"));
351
- check("but each keeps its own rendered core",
352
- new Set(RUNTIMES.map((id) => adapter.loadRuntime(id).scopes.project.core)).size === RUNTIMES.length);
353
-
354
- // A config dir with a space in it is not exotic: a desktop host puts CLAUDE_CONFIG_DIR under
355
- // `~/Library/Application Support/…`. An unquoted path there splits in the shell, python reports
356
- // `can't open file '/Users/x/Library/Application'`, and EVERY tool call in the session fails —
357
- // including the ones the human would need to undo it. Assert the registration is quoted, per
358
- // runtime, and that the installer still recognises its own entry (or a re-install duplicates it).
359
- group("hook registration — paths with spaces");
360
- {
361
- const spacedHome = mkdtempSync(join(tmpdir(), "cohorte home-"));
362
- const spacedProj = mkdtempSync(join(tmpdir(), "cohorte proj-"));
363
- tmps.push(spacedHome, spacedProj);
364
- spawnSync("git", ["init", "-q", "."], { cwd: spacedProj });
365
- const env = { ...process.env, HOME: spacedHome, CLAUDE_CONFIG_DIR: join(spacedHome, ".claude") };
366
- const args = [join(root, "bin", "cli.js"), "install", `--runtime=${RUNTIMES.join(",")}`];
367
- const first = spawnSync(process.execPath, args, { cwd: spacedProj, env, encoding: "utf8" });
368
- check("installs into a path containing a space", first.status === 0,
369
- (first.stderr || "").slice(0, 300));
370
- // Claude registers its hook only on a GLOBAL install — project-scope settings.json is
371
- // /cohorte-init-pipeline's job — so exercise that scope too.
372
- const gargs = [join(root, "bin", "cli.js"), "install", "--global", "--runtime=claude"];
373
- spawnSync(process.execPath, gargs, { cwd: spacedProj, env, encoding: "utf8" });
374
- // Re-install BOTH: the reconcile must match its own quoted entry, or every run stacks another.
375
- spawnSync(process.execPath, args, { cwd: spacedProj, env, encoding: "utf8" });
376
- spawnSync(process.execPath, gargs, { cwd: spacedProj, env, encoding: "utf8" });
377
-
378
- for (const id of RUNTIMES) {
379
- const rt = adapter.loadRuntime(id);
380
- if (!rt.capabilities.hooks) continue;
381
- const cfgSpec = (id === "claude" ? rt.scopes.global : rt.scopes.project).hooks_config;
382
- const cfgPath = cfgSpec.startsWith("~/")
383
- ? join(spacedHome, cfgSpec.slice(2)) : join(spacedProj, cfgSpec);
384
- if (!existsSync(cfgPath)) { check(`${id}: hook config written`, false, cfgPath); continue; }
385
- const hooks = JSON.parse(readFileSync(cfgPath, "utf8")).hooks || {};
386
- const entries = hooks[rt.hook.event] || [];
387
- const cmds = entries.flatMap(e => e.command ? [e.command] : (e.hooks || []).map(h => h.command));
388
- const ours = cmds.filter(c => /gate\.py/.test(c));
389
- check(`${id}: the gate path is quoted`, ours.length > 0 && ours.every(c => /"[^"]*gate\.py"/.test(c)),
390
- ours.join(" | "));
391
- check(`${id}: re-installing does not stack a second registration`, ours.length === 1,
392
- `${ours.length} entries`);
393
- }
394
- }
395
-
396
- // CLAUDE_CONFIG_DIR moves Claude Code's whole tree — a desktop host points it at
397
- // `~/Library/Application Support/…`. The registry declares those paths as `~/.claude`, and
398
- // resolving them from the homedir instead of the override split the install in half: the core
399
- // went to the REAL `~/.claude` while the hook was registered in the override. A scratch install
400
- // therefore wrote into the user's actual global core, silently.
401
- group("CLAUDE_CONFIG_DIR is honoured, not half-honoured");
402
- {
403
- const home = mkdtempSync(join(tmpdir(), "cohorte-home-"));
404
- const cfg = mkdtempSync(join(tmpdir(), "cohorte-cfg-")); // deliberately NOT under home
405
- const proj = mkdtempSync(join(tmpdir(), "cohorte-proj-"));
406
- tmps.push(home, cfg, proj);
407
- spawnSync("git", ["init", "-q", "."], { cwd: proj });
408
- const r = spawnSync(process.execPath,
409
- [join(root, "bin", "cli.js"), "install", "--global", "--runtime=claude"],
410
- { cwd: proj, env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: cfg }, encoding: "utf8" });
411
-
412
- check("the global install succeeds under an overridden config dir", r.status === 0,
413
- (r.stderr || "").slice(0, 300));
414
- check("the core lands in the override", existsSync(join(cfg, "pipeline", "VERSION")));
415
- check("the commands land in the override", existsSync(join(cfg, "commands", "cohorte-build.md")));
416
- check("the hook is registered in the override", existsSync(join(cfg, "settings.json")));
417
- check("nothing is written to the home default",
418
- !existsSync(join(home, ".claude", "pipeline", "VERSION")));
419
- }
420
-
421
- // Claude Code must not regress: it is the runtime everyone is already on.
422
- group("no regression — the Claude install keeps its shape");
423
- check("commands still in .claude/commands", existsSync(join(proj, ".claude", "commands", "cohorte-build.md")));
424
- check("agents still in .claude/agents", existsSync(join(proj, ".claude", "agents", "review.md")));
425
- check("the model pin survives the render",
426
- /^model: sonnet$/m.test(readFileSync(join(proj, ".claude", "commands", "cohorte-build.md"), "utf8")));
427
- check("the subagent name survives the render",
428
- /^name: review$/m.test(readFileSync(join(proj, ".claude", "agents", "review.md"), "utf8")));
429
- // 2.2.0 retired /cohorte-loop. Copy-over never deletes, so the scrub is the only thing standing
430
- // between an upgrade and a decoy command the model can still fire — assert it on the layout that
431
- // actually had one installed.
432
- check("the retired /cohorte-loop is not installed",
433
- !existsSync(join(proj, ".claude", "commands", "cohorte-loop.md")));
434
-
435
- for (const d of tmps) rmSync(d, { recursive: true, force: true });
436
- console.log(failures ? `\ntest-adapter: ${failures} FAILED` : "\ntest-adapter: OK");
437
- process.exit(failures ? 1 : 0);