spexcode 0.6.7 → 0.7.0-next.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 (247) hide show
  1. package/README.md +12 -7
  2. package/node_modules/@spexcode/session-application/dist/index.d.ts +17 -0
  3. package/node_modules/@spexcode/session-application/dist/index.js +35 -0
  4. package/node_modules/@spexcode/session-application/dist/migration.d.ts +51 -0
  5. package/node_modules/@spexcode/session-application/dist/migration.js +694 -0
  6. package/node_modules/@spexcode/session-application/dist/production.d.ts +109 -0
  7. package/node_modules/@spexcode/session-application/dist/production.js +438 -0
  8. package/node_modules/@spexcode/session-application/dist/schema.d.ts +2 -0
  9. package/node_modules/@spexcode/session-application/dist/schema.js +39 -0
  10. package/node_modules/@spexcode/session-application/package.json +29 -0
  11. package/node_modules/@spexcode/session-events/dist/errors.d.ts +6 -0
  12. package/node_modules/@spexcode/session-events/dist/errors.js +11 -0
  13. package/node_modules/@spexcode/session-events/dist/index.d.ts +46 -0
  14. package/node_modules/@spexcode/session-events/dist/index.js +245 -0
  15. package/node_modules/@spexcode/session-events/dist/schema.d.ts +3 -0
  16. package/node_modules/@spexcode/session-events/dist/schema.js +44 -0
  17. package/node_modules/@spexcode/session-events/package.json +24 -0
  18. package/node_modules/@spexcode/session-protocol/dist/canonical.d.ts +36 -0
  19. package/node_modules/@spexcode/session-protocol/dist/canonical.js +139 -0
  20. package/node_modules/@spexcode/session-protocol/dist/engine.d.ts +22 -0
  21. package/node_modules/@spexcode/session-protocol/dist/engine.js +478 -0
  22. package/node_modules/@spexcode/session-protocol/dist/errors.d.ts +8 -0
  23. package/node_modules/@spexcode/session-protocol/dist/errors.js +39 -0
  24. package/node_modules/@spexcode/session-protocol/dist/index.d.ts +60 -0
  25. package/node_modules/@spexcode/session-protocol/dist/index.js +4 -0
  26. package/node_modules/@spexcode/session-protocol/dist/schema.d.ts +36 -0
  27. package/node_modules/@spexcode/session-protocol/dist/schema.js +192 -0
  28. package/node_modules/@spexcode/{session-core → session-protocol}/package.json +4 -8
  29. package/node_modules/@spexcode/session-runtime/dist/errors.d.ts +6 -0
  30. package/node_modules/@spexcode/session-runtime/dist/errors.js +11 -0
  31. package/node_modules/@spexcode/session-runtime/dist/index.d.ts +33 -0
  32. package/node_modules/@spexcode/session-runtime/dist/index.js +193 -0
  33. package/node_modules/@spexcode/session-runtime/dist/schema.d.ts +2 -0
  34. package/node_modules/@spexcode/session-runtime/dist/schema.js +37 -0
  35. package/node_modules/@spexcode/session-runtime/package.json +24 -0
  36. package/node_modules/@spexcode/session-selflaunch/bin/spex-session.mjs +4 -0
  37. package/node_modules/@spexcode/session-selflaunch/dist/cli.d.ts +25 -0
  38. package/node_modules/@spexcode/session-selflaunch/dist/cli.js +156 -0
  39. package/node_modules/@spexcode/session-selflaunch/dist/index.d.ts +28 -0
  40. package/node_modules/@spexcode/session-selflaunch/dist/index.js +25 -0
  41. package/node_modules/@spexcode/session-selflaunch/dist/locality.d.ts +31 -0
  42. package/node_modules/@spexcode/session-selflaunch/dist/locality.js +135 -0
  43. package/node_modules/@spexcode/session-selflaunch/dist/path.d.ts +13 -0
  44. package/node_modules/@spexcode/session-selflaunch/dist/path.js +45 -0
  45. package/node_modules/@spexcode/session-selflaunch/package.json +35 -0
  46. package/node_modules/@spexcode/session-topology/dist/errors.d.ts +6 -0
  47. package/node_modules/@spexcode/session-topology/dist/errors.js +11 -0
  48. package/node_modules/@spexcode/session-topology/dist/index.d.ts +24 -0
  49. package/node_modules/@spexcode/session-topology/dist/index.js +173 -0
  50. package/node_modules/@spexcode/session-topology/dist/schema.d.ts +3 -0
  51. package/node_modules/@spexcode/session-topology/dist/schema.js +27 -0
  52. package/node_modules/@spexcode/session-topology/package.json +24 -0
  53. package/node_modules/@spexcode/spec-cli/bin/spex.mjs +175 -21
  54. package/node_modules/@spexcode/spec-cli/dist/cli.js +121 -59
  55. package/node_modules/@spexcode/spec-cli/dist/client.d.ts +1 -3
  56. package/node_modules/@spexcode/spec-cli/dist/client.js +49 -30
  57. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +11 -0
  58. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +46 -9
  59. package/node_modules/@spexcode/spec-cli/dist/delivery-lock.d.ts +2 -0
  60. package/node_modules/@spexcode/spec-cli/dist/delivery-lock.js +58 -0
  61. package/node_modules/@spexcode/spec-cli/dist/doctor.js +53 -11
  62. package/node_modules/@spexcode/spec-cli/dist/file-write.js +14 -2
  63. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +2 -1
  64. package/node_modules/@spexcode/spec-cli/dist/gateway.js +6 -3
  65. package/node_modules/@spexcode/spec-cli/dist/graphCache.js +32 -2
  66. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +57 -2
  67. package/node_modules/@spexcode/spec-cli/dist/graphStream.d.ts +2 -0
  68. package/node_modules/@spexcode/spec-cli/dist/graphStream.js +83 -3
  69. package/node_modules/@spexcode/spec-cli/dist/guide.js +29 -18
  70. package/node_modules/@spexcode/spec-cli/dist/harness-select.js +16 -3
  71. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +24 -6
  72. package/node_modules/@spexcode/spec-cli/dist/harness.js +510 -72
  73. package/node_modules/@spexcode/spec-cli/dist/help.js +11 -8
  74. package/node_modules/@spexcode/spec-cli/dist/hook-prompts.js +8 -0
  75. package/node_modules/@spexcode/spec-cli/dist/host-resources.js +29 -8
  76. package/node_modules/@spexcode/spec-cli/dist/host.d.ts +7 -0
  77. package/node_modules/@spexcode/spec-cli/dist/host.js +98 -1
  78. package/node_modules/@spexcode/spec-cli/dist/index.js +294 -36
  79. package/node_modules/@spexcode/spec-cli/dist/init.js +1 -1
  80. package/node_modules/@spexcode/spec-cli/dist/lint.js +70 -35
  81. package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +3 -2
  82. package/node_modules/@spexcode/spec-cli/dist/listen.js +14 -2
  83. package/node_modules/@spexcode/spec-cli/dist/machine-peer.d.ts +2 -1
  84. package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +46 -15
  85. package/node_modules/@spexcode/spec-cli/dist/materialize.d.ts +2 -2
  86. package/node_modules/@spexcode/spec-cli/dist/materialize.js +176 -35
  87. package/node_modules/@spexcode/spec-cli/dist/opencode.js +10 -2
  88. package/node_modules/@spexcode/spec-cli/dist/pi-harness.js +18 -5
  89. package/node_modules/@spexcode/spec-cli/dist/pi-headless.d.ts +2 -0
  90. package/node_modules/@spexcode/spec-cli/dist/pi-headless.js +27 -0
  91. package/node_modules/@spexcode/spec-cli/dist/pty-bridge.js +14 -14
  92. package/node_modules/@spexcode/spec-cli/dist/reviews.js +12 -7
  93. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.d.ts +11 -0
  94. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.js +79 -1
  95. package/node_modules/@spexcode/spec-cli/dist/session-application.d.ts +23 -0
  96. package/node_modules/@spexcode/spec-cli/dist/session-application.js +189 -0
  97. package/node_modules/@spexcode/spec-cli/dist/session-declarations.js +13 -1
  98. package/node_modules/@spexcode/spec-cli/dist/session-files.d.ts +6 -0
  99. package/node_modules/@spexcode/spec-cli/dist/session-files.js +13 -1
  100. package/node_modules/@spexcode/spec-cli/dist/session-follow.js +39 -22
  101. package/node_modules/@spexcode/{session-core/dist/record-lock.d.ts → spec-cli/dist/session-record-lock.d.ts} +0 -4
  102. package/node_modules/@spexcode/spec-cli/dist/session-record-lock.js +94 -0
  103. package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.d.ts +44 -0
  104. package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.js +37 -0
  105. package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +25 -2
  106. package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +68 -11
  107. package/node_modules/@spexcode/spec-cli/dist/session-transcript.d.ts +57 -0
  108. package/node_modules/@spexcode/spec-cli/dist/session-transcript.js +137 -0
  109. package/node_modules/@spexcode/spec-cli/dist/session-web.js +4 -4
  110. package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +112 -15
  111. package/node_modules/@spexcode/spec-cli/dist/sessions.js +1553 -764
  112. package/node_modules/@spexcode/spec-cli/dist/shim-runtime.js +14 -0
  113. package/node_modules/@spexcode/spec-cli/dist/source-list.d.ts +13 -0
  114. package/node_modules/@spexcode/spec-cli/dist/source-list.js +99 -0
  115. package/node_modules/@spexcode/spec-cli/dist/source-read.d.ts +16 -0
  116. package/node_modules/@spexcode/spec-cli/dist/source-read.js +84 -0
  117. package/node_modules/@spexcode/spec-cli/dist/spec-attachments.d.ts +7 -0
  118. package/node_modules/@spexcode/spec-cli/dist/spec-attachments.js +89 -0
  119. package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.d.ts +23 -0
  120. package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.js +138 -0
  121. package/node_modules/@spexcode/spec-cli/dist/supervise.js +15 -6
  122. package/node_modules/@spexcode/spec-cli/hooks/dispatch.sh +19 -31
  123. package/node_modules/@spexcode/spec-cli/hooks/harness.sh +6 -6
  124. package/node_modules/@spexcode/spec-cli/package.json +8 -6
  125. package/node_modules/@spexcode/spec-cli/templates/hooks/post-checkout +4 -2
  126. package/node_modules/@spexcode/spec-cli/templates/hooks/post-merge +2 -1
  127. package/node_modules/@spexcode/spec-cli/templates/hooks/pre-commit +5 -3
  128. package/node_modules/@spexcode/spec-cli/templates/hooks/reference-transaction +5 -3
  129. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/spec.md +2 -7
  130. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
  131. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  132. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
  133. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
  134. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
  135. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
  136. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
  137. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
  138. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -0
  139. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  140. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
  141. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
  142. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/spec.md +2 -6
  143. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/spec.md +7 -0
  144. package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +13 -2
  145. package/node_modules/@spexcode/spec-core/dist/anchors.js +311 -15
  146. package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
  147. package/node_modules/@spexcode/spec-core/dist/git.js +9 -9
  148. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +12 -1
  149. package/node_modules/@spexcode/spec-core/dist/layout.js +4 -2
  150. package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +1 -0
  151. package/node_modules/@spexcode/spec-core/dist/resilience.js +8 -0
  152. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +1 -0
  153. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +7 -3
  154. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +1 -0
  155. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +4 -0
  156. package/node_modules/@spexcode/spec-core/dist/specs.d.ts +2 -0
  157. package/node_modules/@spexcode/spec-core/dist/specs.js +30 -0
  158. package/node_modules/@spexcode/spec-core/package.json +4 -1
  159. package/node_modules/@spexcode/spec-core/templates/spexcode.json +36 -13
  160. package/node_modules/@spexcode/spec-eval/dist/cli.js +1 -1
  161. package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +1 -0
  162. package/node_modules/@spexcode/spec-eval/dist/evaltab.js +6 -1
  163. package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +1 -1
  164. package/node_modules/@spexcode/spec-eval/dist/freshness.js +12 -12
  165. package/node_modules/@spexcode/spec-eval/dist/host.d.ts +2 -1
  166. package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +2 -1
  167. package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +20 -3
  168. package/node_modules/@spexcode/spec-eval/package.json +2 -2
  169. package/node_modules/@spexcode/spec-forge/package.json +2 -2
  170. package/node_modules/@spexcode/transcript/dist/frames.d.ts +44 -0
  171. package/node_modules/@spexcode/transcript/dist/frames.js +85 -0
  172. package/node_modules/@spexcode/transcript/dist/index.d.ts +5 -0
  173. package/node_modules/@spexcode/transcript/dist/index.js +5 -0
  174. package/node_modules/@spexcode/transcript/dist/live.d.ts +16 -0
  175. package/node_modules/@spexcode/transcript/dist/live.js +65 -0
  176. package/node_modules/@spexcode/transcript/dist/parsers.d.ts +47 -0
  177. package/node_modules/@spexcode/transcript/dist/parsers.js +285 -0
  178. package/node_modules/@spexcode/transcript/dist/readers.d.ts +10 -0
  179. package/node_modules/@spexcode/transcript/dist/readers.js +296 -0
  180. package/node_modules/@spexcode/transcript/dist/turns.d.ts +42 -0
  181. package/node_modules/@spexcode/transcript/dist/turns.js +11 -0
  182. package/node_modules/@spexcode/transcript/package.json +30 -0
  183. package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
  184. package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
  185. package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
  186. package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
  187. package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
  188. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
  189. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
  190. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
  191. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
  192. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
  193. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
  194. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
  195. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
  196. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
  197. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
  198. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
  199. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
  200. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
  201. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
  202. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
  203. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
  204. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
  205. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
  206. package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
  207. package/node_modules/smol-toml/LICENSE +24 -0
  208. package/node_modules/smol-toml/README.md +418 -0
  209. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  210. package/node_modules/smol-toml/dist/date.js +127 -0
  211. package/node_modules/smol-toml/dist/error.d.ts +38 -0
  212. package/node_modules/smol-toml/dist/error.js +63 -0
  213. package/node_modules/smol-toml/dist/extract.js +69 -0
  214. package/node_modules/smol-toml/dist/index.cjs +734 -0
  215. package/node_modules/smol-toml/dist/index.d.ts +43 -0
  216. package/node_modules/smol-toml/dist/index.js +33 -0
  217. package/node_modules/smol-toml/dist/parse.d.ts +36 -0
  218. package/node_modules/smol-toml/dist/parse.js +149 -0
  219. package/node_modules/smol-toml/dist/primitive.js +238 -0
  220. package/node_modules/smol-toml/dist/stringify.d.ts +31 -0
  221. package/node_modules/smol-toml/dist/stringify.js +181 -0
  222. package/node_modules/smol-toml/dist/struct.js +179 -0
  223. package/node_modules/smol-toml/dist/util.d.ts +38 -0
  224. package/node_modules/smol-toml/dist/util.js +89 -0
  225. package/node_modules/smol-toml/package.json +68 -0
  226. package/package.json +9 -3
  227. package/node_modules/@spexcode/session-core/dist/delivery-queue.d.ts +0 -24
  228. package/node_modules/@spexcode/session-core/dist/delivery-queue.js +0 -206
  229. package/node_modules/@spexcode/session-core/dist/index.d.ts +0 -5
  230. package/node_modules/@spexcode/session-core/dist/index.js +0 -5
  231. package/node_modules/@spexcode/session-core/dist/internal.d.ts +0 -3
  232. package/node_modules/@spexcode/session-core/dist/internal.js +0 -3
  233. package/node_modules/@spexcode/session-core/dist/message.d.ts +0 -22
  234. package/node_modules/@spexcode/session-core/dist/message.js +0 -53
  235. package/node_modules/@spexcode/session-core/dist/record-lock.js +0 -152
  236. package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +0 -62
  237. package/node_modules/@spexcode/session-core/dist/runtime-session.js +0 -326
  238. package/node_modules/@spexcode/session-core/dist/session-cursors.d.ts +0 -14
  239. package/node_modules/@spexcode/session-core/dist/session-cursors.js +0 -82
  240. package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +0 -47
  241. package/node_modules/@spexcode/session-core/dist/session-timeline.js +0 -216
  242. package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +0 -26
  243. package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +0 -475
  244. package/node_modules/@spexcode/spec-cli/dist/session-execution.d.ts +0 -10
  245. package/node_modules/@spexcode/spec-cli/dist/session-execution.js +0 -70
  246. package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
  247. package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-sed-v0.fixture +0 -46
@@ -1,9 +1,10 @@
1
1
  import { readFileSync, existsSync, statSync } from 'node:fs';
2
- import { join } from 'node:path';
2
+ import { dirname, join } from 'node:path';
3
3
  import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText, withEventLedgerBuild } from '@spexcode/spec-core';
4
- import { loadSpecs, parseFrontmatter } from '@spexcode/spec-core';
4
+ import { bodyMentions, loadSpecs, parseFrontmatter } from '@spexcode/spec-core';
5
5
  import { readJsonConfig } from '@spexcode/spec-core';
6
- import { extractors, extractorFor, extOf, parseCodeEntry, relationClaimsPath, resolveSelectors, windowEvents, anchorHitQueries } from '@spexcode/spec-core';
6
+ import { extractors, extractorFor, extOf, parseCodeEntry, parseRelation, relationClaimsPath, resolveSelectors, windowEvents, anchorHitQueries } from '@spexcode/spec-core';
7
+ import { EVAL_FILE, parseScenarios } from '@spexcode/spec-eval/scenarios';
7
8
  import { DEFAULT_TEST_GLOBS, sourcePolicyDescription, trackedSourceFiles } from './source-files.js';
8
9
  export const SPEC_LINT_REPORT_PROJECTION = 'spex.spec-lint.report';
9
10
  export const SPEC_LINT_REPORT_SCHEMA_VERSION = 1;
@@ -276,24 +277,17 @@ async function specLintInLedger(root, regs, options) {
276
277
  }
277
278
  // mention: a `[[id]]` in a body must name a real node (ERROR — a dangling mention is a broken edge in
278
279
  // the very graph the tree exists to keep honest). Checked against the SAME minted ids every other
279
- // surface resolves ([[id-url-safe]]). Prose only: a fenced block or inline `code span` is sample text
280
- // (`[[node]]`, `[[<id>]]` placeholders live there), not a reference.
280
+ // surface resolves ([[id-url-safe]]). The body reader is `bodyMentions`, and this rule is now its ONLY
281
+ // consumer: the loader used to ship the surviving names as a per-node edge, and that projection is gone
282
+ // ([[context-dock]]) because a prose mention is a fact about the graph, never about the node the reader
283
+ // has open. The parser stays where it is — judging what is prose and what is sample text is the same
284
+ // judgement whoever asks, and this rule is the one place a name must be resolved at all.
285
+ // One finding per distinct dangling name: a name repeated in one body is still one broken edge.
281
286
  const idSet = new Set(specs.map((s) => s.id));
282
- const MENTION_RE = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/gu;
283
287
  for (const s of specs) {
284
- let inFence = false;
285
- for (const rawLine of s.body.split('\n')) {
286
- if (/^\s*```/.test(rawLine)) {
287
- inFence = !inFence;
288
- continue;
289
- }
290
- if (inFence)
291
- continue;
292
- const line = rawLine.replace(/`[^`]*`/g, '');
293
- for (const m of line.matchAll(MENTION_RE)) {
294
- if (!idSet.has(m[1]))
295
- out.push({ level: 'error', rule: 'mention', spec: s.id, msg: `'${s.id}' mentions [[${m[1]}]] — no such node; retarget or drop it (backtick it if it is sample text)` });
296
- }
288
+ for (const id of bodyMentions(s.body)) {
289
+ if (!idSet.has(id))
290
+ out.push({ level: 'error', rule: 'mention', spec: s.id, msg: `'${s.id}' mentions [[${id}]] — no such node; retarget or drop it (backtick it if it is sample text)` });
297
291
  }
298
292
  }
299
293
  // living: a spec body describes the node's CURRENT intent — it is not a changelog. Version history
@@ -329,19 +323,53 @@ async function specLintInLedger(root, regs, options) {
329
323
  const top = over.slice(0, 5).map(([f, ids]) => `${f.split('/').pop()}(${ids.length})`).join(', ');
330
324
  out.push({ level: 'warn', rule: 'owners', msg: `${over.length} file(s) are governed by > ${cfg.maxOwners} nodes — each holds more separately-specified functionality than one file should. Worst: ${top}. SPLIT the file so each governor owns its own module (or merge the nodes, or give it a single foundation owner + related:).` });
331
325
  }
326
+ const nodeSource = (s, relation, entries) => ({ relation, entries, drift: true, owner: `'${s.id}'`, repair: `the spec's ${relation}: entry` });
327
+ const scenarioSources = (s) => {
328
+ const evalPath = join(dirname(s.path), EVAL_FILE);
329
+ if (!existsAtTip(evalPath))
330
+ return [];
331
+ let scenarios;
332
+ // A malformed eval.md is `spex eval lint`'s eval-schema finding, not this gate's: refusing to parse it
333
+ // here would turn one measurement-layer typo into a blocked commit.
334
+ try {
335
+ scenarios = parseScenarios(textAtTip(evalPath) ?? '');
336
+ }
337
+ catch {
338
+ return [];
339
+ }
340
+ return scenarios.flatMap((sc) => [
341
+ // ONLY the scenario's OWN declarations. An empty scenario `code:` inherits the node's axis, and those
342
+ // entries are already this node's source above — re-reading them here would double-report one selector.
343
+ { relation: 'code', raw: sc.code },
344
+ { relation: 'related', raw: sc.related },
345
+ ].flatMap(({ relation, raw }) => raw?.length
346
+ ? [{ relation, entries: parseRelation([...raw], relation).entries, drift: false,
347
+ owner: `'${s.id}' scenario '${sc.name}'`, repair: `the scenario's ${relation}: entry in ${evalPath}` }]
348
+ : []));
349
+ };
332
350
  const readyWarned = new Set();
333
351
  const anchorSteps = [];
352
+ // One parse per candidate file per run, shared by every source that anchors it. Without it a file many
353
+ // scenarios anchor is re-extracted once per scenario, and extraction is the expensive half of this gate.
354
+ const unitsAtTip = new Map();
334
355
  for (const s of specs) {
335
- for (const { relation, entries } of [{ relation: 'code', entries: s.codeScoped }, { relation: 'related', entries: s.relatedScoped }]) {
336
- for (const { path, selectors } of entries) {
356
+ for (const src of [
357
+ nodeSource(s, 'code', s.codeScoped),
358
+ nodeSource(s, 'related', s.relatedScoped),
359
+ ...scenarioSources(s),
360
+ ]) {
361
+ const { relation, drift, owner, repair } = src;
362
+ for (const { path, selectors } of src.entries) {
363
+ if (!selectors.length)
364
+ continue;
337
365
  if (pending && !changed.some((file) => relationClaimsPath(path, file)))
338
366
  continue;
339
367
  const x = extractorFor(regs, extOf(path));
340
368
  if (!x) {
341
- anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' anchors ${path}#${selectors.join(', #')} (${relation}:), but no extractor is designated for '.${extOf(path)}' files — anchor validation was skipped and remains unverified; add a LangSpec row (anchors.ts) or drop the selector(s)` } });
369
+ anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `${owner} anchors ${path}#${selectors.join(', #')} (${relation}:), but no extractor is designated for '.${extOf(path)}' files — anchor validation was skipped and remains unverified; add a Tree-sitter language row (anchors.ts) or drop the selector(s)` } });
342
370
  continue;
343
371
  }
344
- const ready = x.ready();
372
+ const ready = await x.ready();
345
373
  if (ready !== true) {
346
374
  // once per (extractor, reason), even across several anchored nodes — one repair, one message.
347
375
  if (!readyWarned.has(x.id + ready)) {
@@ -353,20 +381,27 @@ async function specLintInLedger(root, regs, options) {
353
381
  if (!existsAtTip(path))
354
382
  continue; // the missing FILE already errored above
355
383
  if (isDirectoryAtTip(path)) {
356
- anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' puts a selector on a directory (${relation}: ${path}#${selectors[0]}) — a selector scopes ONE real file` } });
384
+ anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `${owner} puts a selector on a directory (${relation}: ${path}#${selectors[0]}) — a selector scopes ONE real file` } });
357
385
  continue;
358
386
  }
359
- let units;
360
- try {
361
- const source = textAtTip(path);
362
- if (source === null)
363
- throw new Error(`candidate tree has no file '${path}'`);
364
- units = x.extract(source, path);
387
+ let cached = unitsAtTip.get(path);
388
+ if (!cached) {
389
+ try {
390
+ const source = textAtTip(path);
391
+ if (source === null)
392
+ throw new Error(`candidate tree has no file '${path}'`);
393
+ cached = { units: await x.extract(source, path) };
394
+ }
395
+ catch (e) {
396
+ cached = { error: e?.message ?? String(e) };
397
+ }
398
+ unitsAtTip.set(path, cached);
365
399
  }
366
- catch (e) {
367
- anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `anchor ${path}#${selectors.join(', #')} ('${s.id}') is unverifiable — the current file does not parse: ${e?.message ?? e}` } });
400
+ if ('error' in cached) {
401
+ anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `anchor ${path}#${selectors.join(', #')} (${owner}) is unverifiable — the current file does not parse: ${cached.error}` } });
368
402
  continue;
369
403
  }
404
+ const units = cached.units;
370
405
  // each selector resolves (or errors) on its own; only the live ones feed the window engine.
371
406
  // The dead/ambiguous verdict itself comes from the ONE shared classifier ([[code-anchor]]); only the
372
407
  // wording of the gate's findings lives here.
@@ -374,18 +409,18 @@ async function specLintInLedger(root, regs, options) {
374
409
  for (const res of resolveSelectors(units, selectors)) {
375
410
  const sym = res.selector;
376
411
  if ('dead' in res) {
377
- anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `dead anchor: ${path}#${sym} ('${s.id}') names no unit on the current tree — the unit was deleted or renamed; update the spec's ${relation}: entry to follow it` } });
412
+ anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `dead anchor: ${path}#${sym} (${owner}) names no unit on the current tree — the unit was deleted or renamed; update ${repair} to follow it` } });
378
413
  continue;
379
414
  }
380
415
  if ('ambiguous' in res) {
381
- anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `ambiguous anchor: ${path}#${sym} ('${s.id}') names ${res.ambiguous} same-named units in one file — an anchor must be unique; rename one unit` } });
416
+ anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `ambiguous anchor: ${path}#${sym} (${owner}) names ${res.ambiguous} same-named units in one file — an anchor must be unique; rename one unit` } });
382
417
  continue;
383
418
  }
384
419
  if (res.ok.typeOnly)
385
420
  anchorSteps.push({ finding: { level: 'warn', rule: 'anchor', spec: s.id, file: path, msg: `${path}#${sym} anchors a ${res.ok.kind} — anchoring a type is usually wrong (types reshape with every refactor); anchor the behaviour-bearing unit instead` } });
386
421
  live.push(sym);
387
422
  }
388
- if (!live.length)
423
+ if (!drift || !live.length)
389
424
  continue;
390
425
  const since = rowsFor(hidx, s.path)[0]?.hash || '';
391
426
  const win = windowEvents(didx, since, path, s.id);
@@ -1,8 +1,9 @@
1
1
  import type { Server } from 'node:net';
2
+ export declare function resolveConfiguredPort(rawPort: string | undefined): number;
2
3
  export declare function listenOrExit(server: Server, port: number, opts: {
3
4
  host?: string;
4
5
  label: string;
5
6
  cleanup?: () => void;
6
- onListen?: () => void;
7
- ready: string | string[];
7
+ onListen?: (port: number) => void;
8
+ ready: string | string[] | ((port: number) => string | string[]);
8
9
  }): void;
@@ -1,3 +1,13 @@
1
+ export function resolveConfiguredPort(rawPort) {
2
+ const normalized = rawPort?.trim();
3
+ if (!normalized)
4
+ return 8787;
5
+ const port = Number(normalized);
6
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
7
+ throw new Error(`PORT must be an integer from 0 to 65535, got ${JSON.stringify(rawPort)}`);
8
+ }
9
+ return port;
10
+ }
1
11
  // @@@ listenOrExit ([[listener-readiness]]) - the one public-listener transition: before `listening`, a bind
2
12
  // failure is loud and fatal; after it, publication side effects and user-visible ready lines may run. Keeping
3
13
  // both halves here prevents a private child or pre-bind caller from announcing a surface it does not own.
@@ -13,8 +23,10 @@ export function listenOrExit(server, port, opts) {
13
23
  process.exit(1);
14
24
  });
15
25
  const publishReady = () => {
16
- opts.onListen?.();
17
- for (const line of Array.isArray(opts.ready) ? opts.ready : [opts.ready])
26
+ const actualPort = server.address().port;
27
+ opts.onListen?.(actualPort);
28
+ const ready = typeof opts.ready === 'function' ? opts.ready(actualPort) : opts.ready;
29
+ for (const line of Array.isArray(ready) ? ready : [ready])
18
30
  console.log(line);
19
31
  };
20
32
  if (opts.host)
@@ -51,8 +51,9 @@ export declare class MachinePeerGateway {
51
51
  private control;
52
52
  private retry;
53
53
  private closing;
54
- start(): void;
54
+ start(): Promise<void>;
55
55
  private startControl;
56
+ private listenControl;
56
57
  private handleRpc;
57
58
  private startInbound;
58
59
  private stopInbound;
@@ -72,13 +72,17 @@ export function readPeerMachineId() {
72
72
  writeStore({ ...store, machineId });
73
73
  return machineId;
74
74
  }
75
+ // @@@ peer sender spelling - the authenticated peer identity travels as the message's ordinary `senderSessionId`,
76
+ // so it must fit the protocol's frozen session_id grammar `[0-9A-Za-z_][0-9A-Za-z_-]*` (session-protocol §5.2:
77
+ // no `:`, no leading `-`; namespaces are encoded INTO the id). `peer_<machineId>_<sessionId>` is that encoding;
78
+ // the earlier `peer:` spelling was refused by every receiving backend with PROTOCOL_SESSION_ID_INVALID.
75
79
  export function peerSenderRef(machineId, sessionId) {
76
- return sessionId && validMachineId(sessionId) ? `peer:${machineId}:${sessionId}` : `peer:${machineId}`;
80
+ return sessionId && validMachineId(sessionId) ? `peer_${machineId}_${sessionId}` : `peer_${machineId}`;
77
81
  }
78
82
  function validPeerSender(value, machineId) {
79
83
  if (typeof value !== 'string')
80
84
  return false;
81
- const match = value.match(/^peer:([0-9a-f-]{36})(?::([0-9a-f-]{36}))?$/i);
85
+ const match = value.match(/^peer_([0-9a-f-]{36})(?:_([0-9a-f-]{36}))?$/i);
82
86
  return !!match && match[1] === machineId && (match[2] === undefined || validMachineId(match[2]));
83
87
  }
84
88
  export function findMachinePeer(sshAddress) {
@@ -160,7 +164,7 @@ function peerProjectsForSession(sessionId) {
160
164
  const hits = [];
161
165
  for (const id of ids) {
162
166
  const root = join(projects, id);
163
- const record = join(root, 'sessions', sessionId, 'session.json');
167
+ const record = join(root, 'sessions', sessionId, 'runtime.json');
164
168
  if (!existsSync(record))
165
169
  continue;
166
170
  const direct = readEndpointRecord(join(root, 'backend.json'));
@@ -367,34 +371,61 @@ function sshArgs(peer) {
367
371
  '--', peer.sshAddress,
368
372
  ];
369
373
  }
374
+ // true iff something ACCEPTS a connection at this unix path; any error (ECONNREFUSED for a leftover path, ENOENT
375
+ // for a vanished one) means no live listener.
376
+ function probeUnixListener(path) {
377
+ return new Promise((resolve) => {
378
+ const socket = createConnection(path);
379
+ socket.once('connect', () => { socket.destroy(); resolve(true); });
380
+ socket.once('error', () => resolve(false));
381
+ });
382
+ }
370
383
  export class MachinePeerGateway {
371
384
  inbound = new Map();
372
385
  children = new Map();
373
386
  control = null;
374
387
  retry = null;
375
388
  closing = false;
389
+ // start() resolves once the control socket is claimed. With no leftover path the claim is synchronous (the
390
+ // listener is issued before the promise settles, so callers may issue control RPCs right away); a leftover path
391
+ // is probed first, which is the only asynchronous branch.
376
392
  start() {
377
393
  for (const peer of listMachinePeers())
378
394
  this.startInbound(peer);
379
- this.startControl();
380
- for (const peer of listMachinePeers())
381
- if (peer.owner)
382
- this.ensureTunnel(peer);
383
- this.retry = setInterval(() => {
395
+ return this.startControl().then(() => {
384
396
  for (const peer of listMachinePeers())
385
- if (peer.owner && peer.state === 'connecting')
397
+ if (peer.owner)
386
398
  this.ensureTunnel(peer);
387
- }, PEER_RETRY_MS);
388
- this.retry.unref();
399
+ this.retry = setInterval(() => {
400
+ for (const peer of listMachinePeers())
401
+ if (peer.owner && peer.state === 'connecting')
402
+ this.ensureTunnel(peer);
403
+ }, PEER_RETRY_MS);
404
+ this.retry.unref();
405
+ });
389
406
  }
407
+ // @@@ stale control socket - a killed gateway never unlinks peer.sock, so the FILE proves nothing about
408
+ // ownership; only a connect does. A live gateway accepts the connection; a leftover path refuses it
409
+ // (ECONNREFUSED). So a missing path is claimed at once, a refusing path is unlinked and reclaimed, and only an
410
+ // accepting listener is "another `spex dashboard`" — the same test claude-rendezvous applies to its socket file.
390
411
  startControl() {
391
412
  const path = peerSocketPath();
392
413
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
393
- if (existsSync(path)) {
394
- const error = new Error(`machine peer service already owns ${path} — another \`spex dashboard\` may be running`);
395
- error.name = 'BackendError';
396
- throw error;
414
+ if (!existsSync(path)) {
415
+ this.listenControl(path);
416
+ return Promise.resolve();
397
417
  }
418
+ return probeUnixListener(path).then((live) => {
419
+ if (live) {
420
+ const error = new Error(`machine peer service already owns ${path} — another \`spex dashboard\` is running`);
421
+ error.name = 'BackendError';
422
+ throw error;
423
+ }
424
+ rmSync(path, { force: true });
425
+ this.listenControl(path);
426
+ });
427
+ }
428
+ listenControl(path) {
398
429
  this.control = createNetServer({ allowHalfOpen: true }, (socket) => {
399
430
  let input = '';
400
431
  socket.setEncoding('utf8');
@@ -1,4 +1,4 @@
1
- import { type HarnessArtifacts } from './harness.js';
1
+ import { GENERATED_MARK, type HarnessArtifacts } from './harness.js';
2
2
  export type MaterializedArtifact = {
3
3
  kind: 'hook manifest' | 'contract' | 'shim' | 'skill' | 'agent' | 'plugin bundle' | 'trust';
4
4
  path: string;
@@ -13,6 +13,6 @@ export declare function retiredAxisNotice(cfg: {
13
13
  private?: boolean;
14
14
  }): void;
15
15
  export declare function stripSpexcodeBlock(text: string, comment?: readonly [string, string]): string;
16
- export declare const GENERATED_MARK = "<!-- spexcode:generated -->";
16
+ export { GENERATED_MARK };
17
17
  export declare function dematerialize(proj?: string, arts?: HarnessArtifacts): void;
18
18
  export declare function materialize(proj?: string): MaterializeResult;
@@ -4,13 +4,33 @@ import { fileURLToPath } from 'node:url';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { loadSystemConfig, loadSkillConfig, loadAgentConfig, loadConfig } from '@spexcode/spec-core';
6
6
  import { compileManifest } from './hooks.js';
7
- import { writeManagedBlock, removeManagedBlock, HARNESSES } from './harness.js';
7
+ import { writeManagedBlock, removeManagedBlock, writeManagedJsonHooks, removeManagedJsonHooks, sharedShimHasHostContent, isGeneratedArtifact, GENERATED_MARK, HARNESSES } from './harness.js';
8
8
  import { git, gitBinary } from '@spexcode/spec-core';
9
9
  import { runtimeRoot, treeSlotDir, mainCheckout, readConfig } from '@spexcode/spec-core';
10
10
  import { resolveHarnessTargets, partitionHarnesses } from './harness-select.js';
11
11
  import { emitPlugin, cleanPlugin, pluginBundleDir, pluginVersion } from './plugin-harness.js';
12
12
  import { clearContractFilterPayload, contractFilterPlanted, plantContractFilter, removeContractFilter, settleIndexStat } from './contract-filter.js';
13
13
  import { writeFileIfChanged } from './file-write.js';
14
+ // land one shim. A file wholly ours is the plain byte-equality write; a config file the host agent SHARES with
15
+ // the user gets ONLY our identity-stamped hook entries merged in, so their permissions, env, statusLine and
16
+ // own hooks survive adoption (writeManagedJsonHooks, [[harness-adapter]]).
17
+ // A shared file we cannot PARSE is the one landing that can fail, and it fails for a reason that is the
18
+ // user's to fix. Report it and carry on: their broken JSON must not also cost them the contract, the skills
19
+ // and the allowlist this pass still owes every other target (the pre-commit anchor runs this on the way into
20
+ // every commit). Returns whether the shim actually landed.
21
+ function landShim(file, shim) {
22
+ try {
23
+ if (shim.ownership === 'shared-json' && shim.hooks)
24
+ writeManagedJsonHooks(file, shim.hooks);
25
+ else
26
+ writeFileIfChanged(file, shim.content);
27
+ return true;
28
+ }
29
+ catch (e) {
30
+ console.error(`spexcode: no hooks delivered to ${file} — ${e.message}`);
31
+ return false;
32
+ }
33
+ }
14
34
  const PKG = fileURLToPath(new URL('..', import.meta.url)); // installed spec-cli root
15
35
  const DISPATCH = join(PKG, 'hooks', 'dispatch.sh');
16
36
  // the ONE spex entry: the launcher (bin/spex.mjs), never a raw source entry - the launcher runs compiled
@@ -50,6 +70,53 @@ export function retiredAxisNotice(cfg) {
50
70
  function gitCommonDirOf(proj) {
51
71
  return git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim();
52
72
  }
73
+ // Codex resolves linked-worktree project hooks from the main checkout, but old materializers left a second
74
+ // executable copy in each worktree. Migrate only an exact SpexCode-only JSON config; a file with any user
75
+ // hook is theirs and remains untouched. This is a one-time identity migration, not a sibling configuration
76
+ // sweep: it never creates files and never changes the main checkout's owner.
77
+ function retireLegacyCodexAnchors(checkout) {
78
+ let listing;
79
+ try {
80
+ listing = git(['-C', checkout, 'worktree', 'list', '--porcelain']);
81
+ }
82
+ catch {
83
+ return;
84
+ }
85
+ const paths = [...listing.matchAll(/^worktree (.+)$/gm)].map(match => match[1]).filter(path => path !== checkout);
86
+ for (const tree of paths) {
87
+ const file = join(tree, '.codex', 'hooks.json');
88
+ if (!existsSync(file))
89
+ continue;
90
+ let parsed;
91
+ try {
92
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
98
+ continue;
99
+ const hooks = parsed.hooks;
100
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks))
101
+ continue;
102
+ const commands = [];
103
+ for (const groups of Object.values(hooks)) {
104
+ if (!Array.isArray(groups))
105
+ continue;
106
+ for (const group of groups) {
107
+ if (!group || typeof group !== 'object')
108
+ continue;
109
+ for (const hook of group.hooks ?? []) {
110
+ if (hook && typeof hook === 'object' && typeof hook.command === 'string')
111
+ commands.push(hook.command);
112
+ }
113
+ }
114
+ }
115
+ if (!commands.length || commands.some(command => !command.includes('dispatch.sh')))
116
+ continue;
117
+ writeFileIfChanged(file, '{\n "hooks": {}\n}\n');
118
+ }
119
+ }
53
120
  function infoExcludePath(proj) {
54
121
  return join(gitCommonDirOf(proj), 'info', 'exclude');
55
122
  }
@@ -112,8 +179,10 @@ function hostContentOf(file) {
112
179
  return stripSpexcodeBlock(readFileSync(file, 'utf8'));
113
180
  }
114
181
  // the identity stamp on every generated skill/agent file — what lets the erase phase forget a product whose
115
- // NODE was renamed or deleted (the name-scoped sweep can only reconstruct paths the LIVE config still names).
116
- export const GENERATED_MARK = '<!-- spexcode:generated -->';
182
+ // NODE was renamed or deleted (the name-scoped sweep can only reconstruct paths the LIVE config still names),
183
+ // and what stops the write phase from landing on a same-named file the user wrote. Defined with the adapter
184
+ // (both halves of the pass need it); re-exported here for the commit-surgery reader.
185
+ export { GENERATED_MARK };
117
186
  function pruneGeneratedSkills(dir, keep) {
118
187
  if (!dir || !existsSync(dir))
119
188
  return false;
@@ -176,11 +245,20 @@ function reconcileTree(proj, targets, tracked) {
176
245
  removeManagedBlock(file, ['<!-- ', ' -->'], !tracked(file));
177
246
  removed = true;
178
247
  }
179
- const treeShims = new Set(HARNESSES.filter((h) => h.shimScope === 'tree').map((h) => h.shimFile(proj)));
180
- const anchors = new Set(HARNESSES.map((h) => h.worktreeHookAnchor(proj)).filter((path) => !!path));
181
- for (const file of [...treeShims, ...anchors]) {
248
+ // a DESELECTED harness's shim is un-landed the same way its owner would clean it: entry-by-entry out of a
249
+ // config file the host agent shares with the user, whole-file only when the file is wholly ours.
250
+ const shimSites = HARNESSES.flatMap((h) => [
251
+ ...(h.shimScope === 'tree' ? [[h.shimFile(proj), h.shimOwnership]] : []),
252
+ ...((path) => path ? [[path, h.shimOwnership]] : [])(h.worktreeHookAnchor(proj)),
253
+ ]);
254
+ for (const [file, ownership] of new Map(shimSites)) {
182
255
  if (targets.treeShims.has(file) || targets.anchors.has(file) || !existsSync(file))
183
256
  continue;
257
+ if (ownership === 'shared-json') {
258
+ removeManagedJsonHooks(file);
259
+ removed = true;
260
+ continue;
261
+ }
184
262
  if (readFileSync(file, 'utf8').includes('dispatch.sh')) {
185
263
  rmSync(file, { force: true });
186
264
  removed = true;
@@ -266,6 +344,32 @@ export function materialize(proj = process.cwd()) {
266
344
  const targets = resolveHarnessTargets(cfg.harnesses);
267
345
  retiredAxisNotice(cfg); // [[residence]] — the vote axis is retired
268
346
  const { selected, plugins } = partitionHarnesses(targets);
347
+ // WHERE a shim lives and WHICH toolchain it names are two questions, and only the first is per-tree.
348
+ //
349
+ // Residence is forced by the adapter: Codex reads one project shim shared by every linked worktree, while
350
+ // Claude reads project settings from the session's cwd only, so every tree carries its own copy.
351
+ //
352
+ // The toolchain is the SAME answer for both: the main checkout owns the hook path. It used to be answered
353
+ // per scope — the project shim took the checkout, tree-scoped shims took whichever install happened to be
354
+ // running materialize — and the consequence was invisible and constant. A session's worktree is created by
355
+ // the BACKEND's install, so its shim named the checkout; the first commit inside that worktree runs
356
+ // pre-commit → materialize with the WORKTREE's install, which rewrote the same shim to name the branch.
357
+ // Every session therefore ran its opening turns on one toolchain and silently switched to another mid-flight,
358
+ // with no receipt anywhere. Neither half was wrong on its own; having two answers was.
359
+ //
360
+ // The main checkout is the right one, and for the reason the project shim already gave: a worktree's CLI may
361
+ // write its own tree-local artifacts but may never replace the shared hook owner. A session worktree is a
362
+ // DESK, not a toolchain install — it has no node_modules of its own, so a tree-pointing shim makes the very
363
+ // first hook of a fresh session try to build the branch. Governance is the product's, not the branch's.
364
+ const checkout = mainCheckout(proj);
365
+ retireLegacyCodexAnchors(checkout);
366
+ const projectDispatch = existsSync(join(checkout, 'spec-cli', 'hooks', 'dispatch.sh'))
367
+ ? join(checkout, 'spec-cli', 'hooks', 'dispatch.sh')
368
+ : DISPATCH;
369
+ const projectSpex = existsSync(join(checkout, 'spec-cli', 'bin', 'spex.mjs'))
370
+ ? join(checkout, 'spec-cli', 'bin', 'spex.mjs')
371
+ : SPEX;
372
+ const shimFor = (h) => h.shim(projectDispatch, projectSpex);
269
373
  const skillNodes = loadSkillConfig();
270
374
  const agentNodes = loadAgentConfig();
271
375
  const commandNodes = loadConfig();
@@ -297,19 +401,33 @@ export function materialize(proj = process.cwd()) {
297
401
  throw new Error(`conflicting materialize targets for ${path}`);
298
402
  targets.set(path, content);
299
403
  };
404
+ const addShimTarget = (targets, path, shim) => {
405
+ const prior = targets.get(path);
406
+ if (prior !== undefined && prior.content !== shim.content)
407
+ throw new Error(`conflicting materialize targets for ${path}`);
408
+ targets.set(path, shim);
409
+ };
300
410
  for (const h of selected) {
301
411
  if (contract)
302
412
  for (const f of h.contractFiles(proj))
303
413
  addTarget(contractTargets, f, contract);
304
- const shim = h.shim(DISPATCH, SPEX);
305
- if (h.shimScope === 'tree') {
306
- addTarget(treeShimTargets, h.shimFile(proj), shim.content);
307
- }
414
+ const shim = shimFor(h);
415
+ // Codex discovers the root-checkout shim through this worktree anchor, but also parses the anchor as a
416
+ // project config layer. A second copy of our dispatcher therefore runs the same PreToolUse event twice.
417
+ // Keep the anchor present for layer discovery while leaving its hook set empty; the root checkout remains
418
+ // the sole executable hook owner.
419
+ // Claude reads project settings from the session's cwd only (measured on Claude Code 2.1.241: a hook
420
+ // configured solely in the main checkout never fires inside a nested linked worktree), so every tree
421
+ // carries its own tree-scoped shim; a session launched at the root fires the root's, never both. Its
422
+ // RESIDENCE is per-tree; its command paths are the main checkout's, exactly like the project shim's.
423
+ const target = { ownership: h.shimOwnership, content: shim.content, hooks: shim.hooks };
424
+ if (h.shimScope === 'tree')
425
+ addShimTarget(treeShimTargets, h.shimFile(proj), target);
308
426
  // a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
309
427
  // checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
310
428
  const anchor = h.worktreeHookAnchor(proj);
311
429
  if (anchor)
312
- addTarget(anchorTargets, anchor, shim.content);
430
+ addShimTarget(anchorTargets, anchor, { ownership: 'exclusive', content: '{\n "hooks": {}\n}\n' });
313
431
  }
314
432
  for (const sk of skillNodes)
315
433
  for (const h of selected) {
@@ -351,42 +469,57 @@ export function materialize(proj = process.cwd()) {
351
469
  record('contract', file);
352
470
  }
353
471
  const contractPaths = [...contractTargets.keys()];
354
- for (const [file, content] of treeShimTargets) {
472
+ // A shim file's residence is the SAME live content fact a contract file's is ([[residence]]): wholly ours →
473
+ // a machine fact hidden by the tree's ignore block, exactly as before; carrying the user's own content (or
474
+ // already tracked) → left VISIBLE, because hiding a file they own is data-loss shaped. A visible shim means
475
+ // our hook commands — absolute paths to THIS machine's toolchain — sit in a file they may commit, so say so.
476
+ const visibleShims = [];
477
+ for (const [file, shim] of [...treeShimTargets, ...anchorTargets]) {
355
478
  mkdirSync(dirname(file), { recursive: true });
356
- writeFileIfChanged(file, content);
357
- record('shim', file);
358
- machinePaths.push(file);
359
- }
360
- for (const [file, content] of anchorTargets) {
361
- mkdirSync(dirname(file), { recursive: true });
362
- writeFileIfChanged(file, content);
479
+ if (!landShim(file, shim))
480
+ continue;
363
481
  record('shim', file);
364
- machinePaths.push(file);
482
+ const theirs = shim.ownership === 'shared-json' && (isTrackedHere(file) || sharedShimHasHostContent(file));
483
+ if (theirs)
484
+ visibleShims.push(file);
485
+ else
486
+ machinePaths.push(file);
365
487
  }
488
+ if (visibleShims.length)
489
+ console.warn(`spexcode: ${visibleShims.map((f) => relative(proj, f)).join(', ')} carries your own configuration, so it stays visible to git — and it now also holds SpexCode's hook entries, whose commands are absolute paths to THIS machine's toolchain. Committing them would break the file for everyone else. Keep them out of your commits (each clone re-materializes its own), or adopt with "harnesses": [] and wire the hooks yourself.`);
366
490
  const selectedByDispatch = new Map(selected.map((h) => [h.dispatchId, h]));
367
491
  for (const h of selectedByDispatch.values()) {
368
- const shim = h.shim(DISPATCH, SPEX);
492
+ const shim = shimFor(h);
369
493
  if (h.shimScope === 'project') {
370
494
  const file = h.shimFile(proj);
371
495
  mkdirSync(dirname(file), { recursive: true });
372
- writeFileIfChanged(file, shim.content);
373
- record('shim', file);
496
+ if (landShim(file, { ownership: h.shimOwnership, content: shim.content, hooks: shim.hooks }))
497
+ record('shim', file);
374
498
  }
375
499
  for (const file of h.writeTrust(proj, shim.cmd))
376
500
  record('trust', file);
377
501
  }
378
- for (const [file, content] of skillTargets) {
379
- mkdirSync(dirname(file), { recursive: true });
380
- writeFileIfChanged(file, content);
381
- artifactPaths.push(file);
382
- record('skill', file);
383
- }
384
- for (const [file, content] of agentTargets) {
502
+ // A spec node named `distill` says WHICH path to write, never that the path is ours to take. An existing
503
+ // file with no GENERATED_MARK is the user's own same-named skill/agent — skip it and report the collision,
504
+ // the same identity gate the erase half has always applied. Skipped paths are NOT recorded and NOT excluded:
505
+ // the file is theirs in every respect, including how git sees it.
506
+ const collisions = [];
507
+ const plantGenerated = (kind, file, content) => {
508
+ if (!isGeneratedArtifact(file)) {
509
+ collisions.push(file);
510
+ return;
511
+ }
385
512
  mkdirSync(dirname(file), { recursive: true });
386
513
  writeFileIfChanged(file, content);
387
514
  artifactPaths.push(file);
388
- record('agent', file);
389
- }
515
+ record(kind, file);
516
+ };
517
+ for (const [file, content] of skillTargets)
518
+ plantGenerated('skill', file, content);
519
+ for (const [file, content] of agentTargets)
520
+ plantGenerated('agent', file, content);
521
+ if (collisions.length)
522
+ console.warn(`spexcode: left ${collisions.length} file(s) untouched — you already have a file at that name and it is not SpexCode-generated: ${collisions.map((f) => relative(proj, f)).join(', ')}. Rename the colliding .spec node (or your file) if you want the generated one delivered.`);
390
523
  // (8) the PLUGIN target ([[plugin-harness]]): materialize the whole system into one self-contained Claude-plugin
391
524
  // bundle per selected folder. A plugin is EXCLUSIVE (`selected` is empty then). Pruning a DESELECTED
392
525
  // folder needs the PREVIOUS folder set, which the live config no longer names — the one landing point
@@ -440,11 +573,19 @@ export function materialize(proj = process.cwd()) {
440
573
  const ignoreFile = join(proj, '.gitignore');
441
574
  const ignoreTracked = isTrackedHere(ignoreFile);
442
575
  const ignoreHost = existsSync(ignoreFile) ? stripSpexcodeBlock(readFileSync(ignoreFile, 'utf8'), ['# ', '']) : '';
443
- if (!ignoreTracked && !ignoreHost.trim())
576
+ // the self-entry only earns its keep alongside a real one: with NOTHING selected ("harnesses": []) there is
577
+ // no artifact to hide, and a .gitignore whose whole content is a rule ignoring itself is pure footprint.
578
+ // An empty body then un-writes the block, taking a wholly-ours .gitignore with it.
579
+ if (localEntries.length && !ignoreTracked && !ignoreHost.trim())
444
580
  localEntries.push('.gitignore');
445
581
  const ignoreBody = entries(localEntries);
446
- if (writeManagedBlock(ignoreFile, ignoreBody, ['# ', '']))
447
- changedMaterialized.add(ignoreFile);
582
+ if (ignoreBody) {
583
+ if (writeManagedBlock(ignoreFile, ignoreBody, ['# ', '']))
584
+ changedMaterialized.add(ignoreFile);
585
+ }
586
+ else {
587
+ removeManagedBlock(ignoreFile, ['# ', ''], !ignoreTracked && !ignoreHost.trim());
588
+ }
448
589
  const payloads = filterContracts.map((file) => ({ file: relative(proj, file), content: contract }));
449
590
  if (ignoreTracked || ignoreHost.trim())
450
591
  payloads.push({ file: '.gitignore', content: ignoreBody });