spexcode 0.6.6 → 0.6.8

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 (205) 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 +20 -0
  42. package/node_modules/@spexcode/session-selflaunch/dist/locality.js +83 -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 +56 -19
  54. package/node_modules/@spexcode/spec-cli/dist/cli.js +102 -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/execution-trace.d.ts +1 -0
  63. package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +2 -2
  64. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +2 -1
  65. package/node_modules/@spexcode/spec-cli/dist/gateway.js +6 -3
  66. package/node_modules/@spexcode/spec-cli/dist/graphCache.js +32 -2
  67. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +57 -2
  68. package/node_modules/@spexcode/spec-cli/dist/graphStream.d.ts +2 -0
  69. package/node_modules/@spexcode/spec-cli/dist/graphStream.js +83 -3
  70. package/node_modules/@spexcode/spec-cli/dist/guide.js +20 -7
  71. package/node_modules/@spexcode/spec-cli/dist/harness-select.js +16 -3
  72. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +15 -3
  73. package/node_modules/@spexcode/spec-cli/dist/harness.js +331 -50
  74. package/node_modules/@spexcode/spec-cli/dist/help.js +11 -8
  75. package/node_modules/@spexcode/spec-cli/dist/hook-prompts.js +8 -0
  76. package/node_modules/@spexcode/spec-cli/dist/host-resources.js +29 -8
  77. package/node_modules/@spexcode/spec-cli/dist/host.d.ts +7 -0
  78. package/node_modules/@spexcode/spec-cli/dist/host.js +93 -0
  79. package/node_modules/@spexcode/spec-cli/dist/index.js +324 -22
  80. package/node_modules/@spexcode/spec-cli/dist/init.js +1 -1
  81. package/node_modules/@spexcode/spec-cli/dist/lint.js +70 -35
  82. package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +3 -2
  83. package/node_modules/@spexcode/spec-cli/dist/listen.js +14 -2
  84. package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +1 -1
  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/pty-bridge.js +14 -14
  88. package/node_modules/@spexcode/spec-cli/dist/reviews.js +12 -7
  89. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.d.ts +11 -0
  90. package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.js +79 -1
  91. package/node_modules/@spexcode/spec-cli/dist/session-application.d.ts +23 -0
  92. package/node_modules/@spexcode/spec-cli/dist/session-application.js +189 -0
  93. package/node_modules/@spexcode/spec-cli/dist/session-declarations.js +13 -1
  94. package/node_modules/@spexcode/spec-cli/dist/session-files.d.ts +6 -0
  95. package/node_modules/@spexcode/spec-cli/dist/session-files.js +13 -1
  96. package/node_modules/@spexcode/spec-cli/dist/session-follow.js +39 -22
  97. package/node_modules/@spexcode/{session-core/dist/record-lock.d.ts → spec-cli/dist/session-record-lock.d.ts} +0 -4
  98. package/node_modules/@spexcode/spec-cli/dist/session-record-lock.js +94 -0
  99. package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.d.ts +44 -0
  100. package/node_modules/@spexcode/spec-cli/dist/session-runtime-adapter.js +37 -0
  101. package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +25 -2
  102. package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +68 -11
  103. package/node_modules/@spexcode/spec-cli/dist/session-web.js +4 -4
  104. package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +108 -15
  105. package/node_modules/@spexcode/spec-cli/dist/sessions.js +1465 -744
  106. package/node_modules/@spexcode/spec-cli/dist/source-list.d.ts +13 -0
  107. package/node_modules/@spexcode/spec-cli/dist/source-list.js +99 -0
  108. package/node_modules/@spexcode/spec-cli/dist/source-read.d.ts +16 -0
  109. package/node_modules/@spexcode/spec-cli/dist/source-read.js +84 -0
  110. package/node_modules/@spexcode/spec-cli/dist/spec-attachments.d.ts +7 -0
  111. package/node_modules/@spexcode/spec-cli/dist/spec-attachments.js +89 -0
  112. package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.d.ts +23 -0
  113. package/node_modules/@spexcode/spec-cli/dist/spec-body-edit.js +138 -0
  114. package/node_modules/@spexcode/spec-cli/dist/supervise.js +15 -6
  115. package/node_modules/@spexcode/spec-cli/dist/transcript-reader.d.ts +36 -0
  116. package/node_modules/@spexcode/spec-cli/dist/transcript-reader.js +251 -0
  117. package/node_modules/@spexcode/spec-cli/hooks/dispatch.sh +19 -31
  118. package/node_modules/@spexcode/spec-cli/hooks/harness.sh +6 -6
  119. package/node_modules/@spexcode/spec-cli/package.json +6 -6
  120. package/node_modules/@spexcode/spec-cli/templates/hooks/post-checkout +4 -2
  121. package/node_modules/@spexcode/spec-cli/templates/hooks/post-merge +2 -1
  122. package/node_modules/@spexcode/spec-cli/templates/hooks/pre-commit +5 -3
  123. package/node_modules/@spexcode/spec-cli/templates/hooks/reference-transaction +5 -3
  124. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/spec.md +2 -7
  125. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
  126. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  127. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
  128. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
  129. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
  130. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
  131. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
  132. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
  133. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -0
  134. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  135. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
  136. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
  137. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/skills/spec.md +2 -6
  138. package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/spec.md +7 -0
  139. package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +13 -2
  140. package/node_modules/@spexcode/spec-core/dist/anchors.js +311 -15
  141. package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
  142. package/node_modules/@spexcode/spec-core/dist/git.js +9 -9
  143. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +12 -0
  144. package/node_modules/@spexcode/spec-core/dist/layout.js +4 -2
  145. package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +1 -0
  146. package/node_modules/@spexcode/spec-core/dist/resilience.js +8 -0
  147. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +1 -0
  148. package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +7 -3
  149. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +1 -0
  150. package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +4 -0
  151. package/node_modules/@spexcode/spec-core/dist/specs.d.ts +2 -0
  152. package/node_modules/@spexcode/spec-core/dist/specs.js +30 -0
  153. package/node_modules/@spexcode/spec-core/package.json +4 -1
  154. package/node_modules/@spexcode/spec-eval/dist/cli.js +1 -1
  155. package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +1 -0
  156. package/node_modules/@spexcode/spec-eval/dist/evaltab.js +6 -1
  157. package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +1 -1
  158. package/node_modules/@spexcode/spec-eval/dist/freshness.js +12 -12
  159. package/node_modules/@spexcode/spec-eval/dist/host.d.ts +2 -1
  160. package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +2 -1
  161. package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +20 -3
  162. package/node_modules/@spexcode/spec-eval/package.json +2 -2
  163. package/node_modules/@spexcode/spec-forge/package.json +2 -2
  164. package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
  165. package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
  166. package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
  167. package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
  168. package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
  169. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
  170. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
  171. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
  172. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
  173. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
  174. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
  175. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
  176. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
  177. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
  178. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
  179. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
  180. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
  181. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
  182. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
  183. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
  184. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
  185. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
  186. package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
  187. package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
  188. package/package.json +9 -3
  189. package/node_modules/@spexcode/session-core/dist/delivery-queue.d.ts +0 -23
  190. package/node_modules/@spexcode/session-core/dist/delivery-queue.js +0 -195
  191. package/node_modules/@spexcode/session-core/dist/index.d.ts +0 -5
  192. package/node_modules/@spexcode/session-core/dist/index.js +0 -5
  193. package/node_modules/@spexcode/session-core/dist/internal.d.ts +0 -3
  194. package/node_modules/@spexcode/session-core/dist/internal.js +0 -3
  195. package/node_modules/@spexcode/session-core/dist/message.d.ts +0 -22
  196. package/node_modules/@spexcode/session-core/dist/message.js +0 -52
  197. package/node_modules/@spexcode/session-core/dist/record-lock.js +0 -152
  198. package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +0 -50
  199. package/node_modules/@spexcode/session-core/dist/runtime-session.js +0 -286
  200. package/node_modules/@spexcode/session-core/dist/session-cursors.d.ts +0 -14
  201. package/node_modules/@spexcode/session-core/dist/session-cursors.js +0 -82
  202. package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +0 -46
  203. package/node_modules/@spexcode/session-core/dist/session-timeline.js +0 -216
  204. package/node_modules/@spexcode/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
  205. 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)
@@ -160,7 +160,7 @@ function peerProjectsForSession(sessionId) {
160
160
  const hits = [];
161
161
  for (const id of ids) {
162
162
  const root = join(projects, id);
163
- const record = join(root, 'sessions', sessionId, 'session.json');
163
+ const record = join(root, 'sessions', sessionId, 'runtime.json');
164
164
  if (!existsSync(record))
165
165
  continue;
166
166
  const direct = readEndpointRecord(join(root, 'backend.json'));
@@ -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 });
@@ -1,7 +1,7 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { promisify } from 'node:util';
4
- import { alive, withSessionInputLock } from './sessions.js';
4
+ import { alive } from './sessions.js';
5
5
  const pexec = promisify(execFile);
6
6
  const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode';
7
7
  const HELPER = fileURLToPath(new URL('./pty-helper.mjs', import.meta.url));
@@ -9,8 +9,9 @@ const subscribers = new Map();
9
9
  const BSU = Buffer.from('\x1b[?2026h');
10
10
  const ESU = Buffer.from('\x1b[?2026l');
11
11
  const GEOMETRY_STABILIZATION_MS = 400;
12
- // A hidden but still-alive browser keeps its own client briefly so a quick return is continuous. A dead
13
- // socket bypasses this window and detaches immediately.
12
+ // A hidden but still-alive browser keeps its own native client briefly so a quick return skips helper spawn
13
+ // and tmux attach. It receives no output while hidden; the return always repaints. A dead socket bypasses
14
+ // this window and detaches immediately.
14
15
  const LINGER_MS = Number(process.env.SPEXCODE_TERM_LINGER_MS) > 0 ? Number(process.env.SPEXCODE_TERM_LINGER_MS) : 30_000;
15
16
  function subscriptionMap(id) {
16
17
  let map = subscribers.get(id);
@@ -26,7 +27,7 @@ function isCurrent(bridge) {
26
27
  }
27
28
  function deliver(bridge, data) {
28
29
  const subscription = currentSubscription(bridge.id, bridge.viewer);
29
- if (!subscription || (!subscription.visible && !subscription.lingering))
30
+ if (!subscription?.visible)
30
31
  return;
31
32
  try {
32
33
  bridge.viewer.send(data);
@@ -174,7 +175,6 @@ function cancelLinger(subscription) {
174
175
  if (subscription.lingerTimer)
175
176
  clearTimeout(subscription.lingerTimer);
176
177
  subscription.lingerTimer = undefined;
177
- subscription.lingering = false;
178
178
  }
179
179
  function cancelRestore(subscription) {
180
180
  if (subscription.restoreTimer)
@@ -309,7 +309,7 @@ export function attachViewer(id, viewer) {
309
309
  const previous = map.get(viewer);
310
310
  if (previous)
311
311
  killBridge(previous);
312
- map.set(viewer, { visible: false, lingering: false, cols: 0, rows: 0 });
312
+ map.set(viewer, { visible: false, cols: 0, rows: 0 });
313
313
  }
314
314
  export function hideViewer(id, viewer) {
315
315
  const subscription = currentSubscription(id, viewer);
@@ -318,7 +318,6 @@ export function hideViewer(id, viewer) {
318
318
  subscription.visible = false;
319
319
  if (!subscription.bridge)
320
320
  return;
321
- subscription.lingering = true;
322
321
  if (subscription.lingerTimer)
323
322
  return;
324
323
  subscription.lingerTimer = setTimeout(() => {
@@ -346,15 +345,15 @@ export function resizeBridge(id, viewer, colsValue, rowsValue) {
346
345
  const subscription = currentSubscription(id, viewer);
347
346
  if (!subscription)
348
347
  return;
349
- const seamless = subscription.lingering && !!subscription.bridge
350
- && subscription.bridge.cols === cols && subscription.bridge.rows === rows;
351
348
  cancelLinger(subscription);
352
349
  subscription.visible = true;
353
350
  subscription.cols = cols;
354
351
  subscription.rows = rows;
355
352
  const { bridge, created } = ensureBridge(id, viewer, subscription, cols, rows);
356
- if (!bridge || created || seamless)
353
+ if (!bridge || created)
357
354
  return;
355
+ // A helper kept alive across a hidden window has been rendering to nobody: the browser cache behind this
356
+ // claim is stale by exactly that window, so an unchanged grid still answers with one atomic repaint.
358
357
  if (bridge.cols === cols && bridge.rows === rows) {
359
358
  beginDelivery(bridge);
360
359
  queueRefresh(bridge);
@@ -364,14 +363,15 @@ export function resizeBridge(id, viewer, colsValue, rowsValue) {
364
363
  }
365
364
  }
366
365
  const MAX_INPUT_BYTES = 64 * 1024;
366
+ // Input is transport: it goes straight to the helper. It never waits for, or is refused by, the session record
367
+ // lock — that lock serializes lifecycle writers, which hold it for a noticeable share of a busy session's wall
368
+ // time, and a keystroke that lost that race used to vanish silently.
367
369
  export function forwardInput(id, viewer, data) {
368
370
  const subscription = currentSubscription(id, viewer);
369
371
  if (!subscription?.visible || !subscription.bridge || !data || Buffer.byteLength(data, 'utf8') > MAX_INPUT_BYTES)
370
372
  return false;
371
- return withSessionInputLock(id, () => {
372
- sendControl(subscription.bridge, { t: 'input', data });
373
- return true;
374
- }) ?? false;
373
+ sendControl(subscription.bridge, { t: 'input', data });
374
+ return true;
375
375
  }
376
376
  async function restoreBridge(id, viewer, subscription) {
377
377
  subscription.restoreTimer = undefined;