@ijfw/memory-server 1.4.4 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/bin/ijfw-memorize +14 -7
  2. package/fixtures/team/book.json +6 -6
  3. package/fixtures/team/business.json +146 -20
  4. package/fixtures/team/content.json +6 -6
  5. package/fixtures/team/design.json +148 -20
  6. package/fixtures/team/mixed.json +206 -27
  7. package/fixtures/team/research.json +146 -20
  8. package/fixtures/team/software.json +148 -20
  9. package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
  10. package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
  11. package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
  12. package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
  13. package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
  14. package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
  15. package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
  16. package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
  17. package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
  18. package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
  19. package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
  20. package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
  21. package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
  22. package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
  23. package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
  24. package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
  25. package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
  26. package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
  27. package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
  28. package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
  29. package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
  30. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
  31. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
  32. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
  33. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
  34. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
  35. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
  36. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
  37. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
  38. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
  39. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
  40. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
  41. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
  42. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
  43. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
  44. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
  45. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
  46. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
  47. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
  48. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
  49. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
  50. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
  51. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
  52. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
  53. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
  54. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
  55. package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
  56. package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
  57. package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
  58. package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
  59. package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
  60. package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
  61. package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
  62. package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  63. package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
  64. package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
  65. package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
  66. package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
  67. package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
  68. package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
  69. package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
  70. package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  71. package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
  72. package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
  73. package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
  74. package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
  75. package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
  76. package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
  77. package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
  78. package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
  79. package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
  80. package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
  81. package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
  82. package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
  83. package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
  84. package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
  85. package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
  86. package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
  87. package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
  88. package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
  89. package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
  90. package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
  91. package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
  92. package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
  93. package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
  94. package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
  95. package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
  96. package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
  97. package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
  98. package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
  99. package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
  100. package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
  101. package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
  102. package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
  103. package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
  104. package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
  105. package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
  106. package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
  107. package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
  108. package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  109. package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
  110. package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
  111. package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
  112. package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
  113. package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
  114. package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
  115. package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
  116. package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
  117. package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  118. package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
  119. package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
  120. package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
  121. package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
  122. package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
  123. package/package.json +6 -3
  124. package/src/active-extension-writer.js +144 -64
  125. package/src/api-client.js +43 -5
  126. package/src/audit-roster.js +80 -5
  127. package/src/blackboard.js +298 -6
  128. package/src/cli-run.js +33 -5
  129. package/src/codex-agents.js +96 -5
  130. package/src/cost/aggregator.js +39 -9
  131. package/src/cost/pricing.js +57 -0
  132. package/src/cost/readers/gemini.js +1 -1
  133. package/src/cross-audit-chunker.js +189 -0
  134. package/src/cross-dispatcher.js +124 -21
  135. package/src/cross-orchestrator-cli.js +754 -159
  136. package/src/cross-orchestrator.js +1065 -17
  137. package/src/cross-project-search.js +195 -9
  138. package/src/dashboard-client-waves.html +304 -0
  139. package/src/dashboard-client.html +5 -1
  140. package/src/dashboard-server.js +73 -0
  141. package/src/deploy-alerts.js +150 -0
  142. package/src/design/iframe-bridge.js +242 -0
  143. package/src/design-companion.js +144 -0
  144. package/src/dispatch/checkpoint-cli.js +97 -0
  145. package/src/dispatch/colon-syntax.js +81 -1
  146. package/src/dispatch/extension.js +26 -2
  147. package/src/dispatch/registry-cli.js +4 -1
  148. package/src/dispatch/wave-cli.js +201 -6
  149. package/src/dispatch/worktree-cli.js +40 -0
  150. package/src/dispatch-planner.js +97 -2
  151. package/src/dream/runner.mjs +47 -11
  152. package/src/dream/stage-runner.js +40 -0
  153. package/src/dream/state-file.js +102 -0
  154. package/src/extension-installer.js +70 -24
  155. package/src/extension-quota-tracker.js +4 -2
  156. package/src/extension-registry.js +289 -35
  157. package/src/feedback-detector.js +26 -0
  158. package/src/fs-lock.js +259 -7
  159. package/src/gate-result.js +95 -1
  160. package/src/hardware-signer.js +4 -2
  161. package/src/hero-line.js +86 -5
  162. package/src/intent-router.js +35 -0
  163. package/src/lib/a11y-contract.js +117 -0
  164. package/src/lib/atomic-io.js +29 -8
  165. package/src/lib/cache-keepalive.js +150 -0
  166. package/src/lib/jsonl-rotation.js +104 -0
  167. package/src/lib/lighthouse-pillar.js +121 -0
  168. package/src/lib/llm-call.js +121 -0
  169. package/src/lib/playwright-baseline.js +205 -0
  170. package/src/lib/rekor-bridge.js +221 -0
  171. package/src/lib/repo-map.js +392 -0
  172. package/src/lib/shasum-verify.js +164 -0
  173. package/src/lib/sketches-gc.js +132 -0
  174. package/src/lib/tmp-suffix.js +62 -0
  175. package/src/lib/ui-review-runner.js +595 -0
  176. package/src/lib/uispec-drift.js +301 -0
  177. package/src/lib/uispec-intake.js +381 -0
  178. package/src/lib/worktree-guards.js +118 -0
  179. package/src/lib/worktree-recovery.js +100 -0
  180. package/src/memory/auto-linker.js +267 -0
  181. package/src/memory/benchmark.js +498 -0
  182. package/src/memory/dedup.js +126 -0
  183. package/src/memory/embedding-cache.js +136 -0
  184. package/src/memory/fact-extractor.js +168 -0
  185. package/src/memory/fts5.js +65 -1
  186. package/src/memory/migration-runner.js +6 -1
  187. package/src/memory/migrations/004-bitemporal.js +91 -0
  188. package/src/memory/migrations/005-vector-cache.js +61 -0
  189. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  190. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  191. package/src/memory/migrations/008-write-provenance.js +41 -0
  192. package/src/memory/migrations/009-obsidian-backfill.js +50 -0
  193. package/src/memory/obsidian-parser.js +152 -0
  194. package/src/memory/query-dataview.js +86 -0
  195. package/src/memory/search.js +46 -15
  196. package/src/memory/temporal.js +529 -0
  197. package/src/memory/tokenize.js +10 -0
  198. package/src/memory-facts-handler.js +37 -0
  199. package/src/memory-feedback.js +260 -2
  200. package/src/model-refresh.js +292 -0
  201. package/src/observability/cost-anomaly.js +166 -0
  202. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  203. package/src/observability/trace-id.js +163 -0
  204. package/src/orchestrator/agents-md-blackboard.js +152 -0
  205. package/src/orchestrator/checkpoint-contract.md +140 -0
  206. package/src/orchestrator/debug-trident-trigger.js +374 -0
  207. package/src/orchestrator/debug-trident.js +570 -0
  208. package/src/orchestrator/merge-block-aware.js +350 -0
  209. package/src/orchestrator/plan-checker.js +475 -0
  210. package/src/orchestrator/post-done-runner.js +277 -0
  211. package/src/orchestrator/review.js +38 -3
  212. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  213. package/src/orchestrator/skill-telemetry.js +37 -0
  214. package/src/orchestrator/state-events.js +459 -0
  215. package/src/orchestrator/state-sdk.js +1932 -0
  216. package/src/orchestrator/status-protocol.js +84 -17
  217. package/src/orchestrator/subagent-telemetry.js +471 -0
  218. package/src/orchestrator/termination.js +160 -0
  219. package/src/orchestrator/verification-gate.js +200 -16
  220. package/src/orchestrator/wave-state.js +332 -23
  221. package/src/orchestrator/worktree-provision.js +77 -0
  222. package/src/override-resolver.js +5 -3
  223. package/src/override-use-registry.js +111 -5
  224. package/src/receipts.js +36 -4
  225. package/src/recovery/checkpoint.js +56 -3
  226. package/src/recovery/code-fixer.js +961 -0
  227. package/src/recovery/truncation.js +317 -0
  228. package/src/redactor.js +75 -6
  229. package/src/runtime-mediator.js +15 -1
  230. package/src/sanitizer.js +10 -0
  231. package/src/search-hybrid.js +139 -0
  232. package/src/server.js +795 -112
  233. package/src/swarm/worktree.js +27 -4
  234. package/src/swarm-config.js +102 -17
  235. package/src/team/domain-templates/book.json +51 -0
  236. package/src/team/domain-templates/business.json +44 -0
  237. package/src/team/domain-templates/content.json +50 -0
  238. package/src/team/domain-templates/design.json +44 -0
  239. package/src/team/domain-templates/research.json +44 -0
  240. package/src/team/domain-templates/software.json +40 -0
  241. package/src/team/generator.js +440 -3
  242. package/src/team/modify.js +203 -0
  243. package/src/team/schemas.js +48 -0
  244. package/src/update-apply.js +19 -3
  245. package/src/dashboard-charts.js +0 -239
@@ -7,11 +7,67 @@
7
7
  *
8
8
  * Landed in W10-A0 (v1.4.4 prelude). checkpointWave is a stub;
9
9
  * N4 (W10-A2) will flesh out the blackboard→STATE rollup logic.
10
+ *
11
+ * v1.5.0 T7 (this task): wave.* writes route through the state-SDK
12
+ * (`query('wave.advance', ...)`) — tmp+rename + locks + intent/commit
13
+ * journalling happen inside the SDK. STATE.md frontmatter is the single
14
+ * source of truth; the `blockers_open` key is now derived FROM
15
+ * `decisions.jsonl` at checkpoint time (the SDK's `blocker.add`/
16
+ * `blocker.resolve` verbs append there), giving a single writer and a single
17
+ * representation. `blockers_open` carries the blocker **id** array (machine-
18
+ * consumed); a separate `blockers_open_summary` carries human-readable text.
19
+ *
20
+ * KNOWN SDK GAP (T7-followup-1): the SDK's `wave.advance` verb does NOT
21
+ * accept a `body` field — its handler always preserves the existing body.
22
+ * Until a body-write SDK verb lands, `writeWaveState` does a follow-up raw
23
+ * atomic write to update the body. The body-write itself is still
24
+ * tmp+rename+lock-protected and the SDK frontmatter write already committed
25
+ * via the intent journal — so the worst-case partial state (frontmatter
26
+ * advanced, body stale) is bounded and self-healing on next checkpoint.
10
27
  */
11
28
 
12
29
  import { mkdir, readFile, writeFile, rename, appendFile } from 'node:fs/promises';
13
30
  import { join } from 'node:path';
14
31
  import { withFsLock } from '../fs-lock.js';
32
+ import { readBlackboard } from '../blackboard.js';
33
+ import { query } from './state-sdk.js';
34
+
35
+ // Lazy S4 loader. Top-level `await import` would break `node:test` (unsettled
36
+ // top-level await). Resolves on first checkpointWave call instead. Missing
37
+ // module is non-fatal (silent fail — populateBlackboardBlock stays null).
38
+ //
39
+ // v1.5.0 audit-MED-work-M9: previously this used a `_s4LoadAttempted` boolean
40
+ // + a sync `_populateBlackboardBlock` mutation. That had a race window: two
41
+ // concurrent callers entering before the `await import` settled would BOTH
42
+ // fire `import()` (cheap on resolved-module cache, but the race-condition
43
+ // taxonomy still flagged it as a singleton smell). Replaced with a Promise
44
+ // singleton: the first caller stores the promise; subsequent callers await
45
+ // the same promise. No double-import, no race on the result variable.
46
+ let _populateBlackboardBlockPromise = null;
47
+ function loadPopulateBlackboardBlock() {
48
+ if (_populateBlackboardBlockPromise === null) {
49
+ _populateBlackboardBlockPromise = (async () => {
50
+ try {
51
+ const mod = await import('./agents-md-blackboard.js');
52
+ return mod.populateBlackboardBlock ?? null;
53
+ } catch {
54
+ // S4 not landed — advisory only
55
+ return null;
56
+ }
57
+ })();
58
+ }
59
+ return _populateBlackboardBlockPromise;
60
+ }
61
+
62
+ /**
63
+ * Test-only helper: reset the populateBlackboardBlock promise singleton so a
64
+ * test can simulate "first call after process start" semantics. Internal.
65
+ *
66
+ * @internal
67
+ */
68
+ export function _resetPopulateBlackboardBlockSingleton() {
69
+ _populateBlackboardBlockPromise = null;
70
+ }
15
71
 
16
72
  // ---------------------------------------------------------------------------
17
73
  // Internal YAML helpers — flat subset only (string/number/boolean/string[])
@@ -167,23 +223,70 @@ export async function readWaveState(waveId, projectRoot) {
167
223
  }
168
224
 
169
225
  /**
170
- * Atomically write a wave's STATE.md using withFsLock + tmp+rename.
171
- * Auto-creates .ijfw/wave-<waveId>/ if missing.
226
+ * Atomically write a wave's STATE.md.
227
+ *
228
+ * v1.5.0 T7: frontmatter writes route through the state-SDK
229
+ * (`query('wave.advance', {waveId, status, frontmatter}, {projectRoot})`) so
230
+ * tmp+rename + locks + intent/commit journalling happen inside the SDK. The
231
+ * body — which the SDK contract does not yet expose a write verb for — is
232
+ * applied via a follow-up atomic write inside the same wave-STATE lock. The
233
+ * SDK's `wave.advance` handler preserves the existing body when it rewrites
234
+ * frontmatter, so the follow-up write only mutates body content and never
235
+ * loses an in-flight frontmatter update.
236
+ *
237
+ * Auto-creates `.ijfw/wave-<waveId>/` if missing (the SDK handler creates it
238
+ * on first call).
172
239
  *
173
240
  * @param {string} waveId
174
- * @param {{frontmatter: object, body: string}} state
241
+ * @param {{frontmatter: object, body?: string}} state
175
242
  * @param {string} projectRoot
176
243
  * @returns {Promise<void>}
177
244
  */
178
245
  export async function writeWaveState(waveId, state, projectRoot) {
179
- const { dir, state: statePath, lock, tmp } = wavePaths(waveId, projectRoot);
180
- const payload = `---\n${emitYaml(state.frontmatter)}\n---\n\n${state.body}`;
246
+ const fm = state.frontmatter || {};
247
+ // SDK's wave.advance requires `status` — supply 'pending' as a safe default
248
+ // for callers that haven't materialised one yet (matches deriveStatus's
249
+ // default-on-empty-blackboard behaviour).
250
+ const status = (typeof fm.status === 'string' && fm.status.length > 0)
251
+ ? fm.status : 'pending';
252
+ // wave.advance MERGES payload.frontmatter into the existing frontmatter;
253
+ // pass the full requested frontmatter so unrelated keys are overwritten
254
+ // intentionally (writeWaveState semantics: caller supplies the full
255
+ // frontmatter shape they want persisted).
256
+ await query(
257
+ 'wave.advance',
258
+ { waveId, status, frontmatter: { ...fm } },
259
+ { projectRoot },
260
+ );
181
261
 
182
- await withFsLock(lock, async () => {
183
- await mkdir(dir, { recursive: true });
184
- await writeFile(tmp, payload, 'utf8');
185
- await rename(tmp, statePath);
186
- });
262
+ // Body follow-up: SDK-gap T7-followup-1 — wave.advance preserves existing
263
+ // body and there is no body-write SDK verb yet. Until one lands, do an
264
+ // atomic in-place body update. Held under the same wave-STATE lock used by
265
+ // every wave-state writer, so concurrent checkpoints serialise.
266
+ if (state.body !== undefined && state.body !== null) {
267
+ const { dir, state: statePath, lock, tmp } = wavePaths(waveId, projectRoot);
268
+ await withFsLock(lock, async () => {
269
+ await mkdir(dir, { recursive: true });
270
+ let frontmatterRaw;
271
+ try {
272
+ const raw = await readFile(statePath, 'utf8');
273
+ const secondDelim = raw.indexOf('\n---', 3);
274
+ // Defensive: if the SDK-written STATE.md is somehow malformed, fall
275
+ // back to re-emitting frontmatter from the in-memory shape rather
276
+ // than refusing the body write.
277
+ if (raw.startsWith('---') && secondDelim !== -1) {
278
+ frontmatterRaw = raw.slice(0, secondDelim + 4); // '---\n…\n---\n'
279
+ } else {
280
+ frontmatterRaw = `---\n${emitYaml(fm)}\n---\n`;
281
+ }
282
+ } catch {
283
+ frontmatterRaw = `---\n${emitYaml(fm)}\n---\n`;
284
+ }
285
+ const payload = `${frontmatterRaw}\n${state.body}`;
286
+ await writeFile(tmp, payload, 'utf8');
287
+ await rename(tmp, statePath);
288
+ });
289
+ }
187
290
  }
188
291
 
189
292
  /**
@@ -226,9 +329,156 @@ export async function appendSummary(waveId, delta, projectRoot) {
226
329
  });
227
330
  }
228
331
 
332
+ // ---------------------------------------------------------------------------
333
+ // Rollup helpers — exported for direct testing (W11-B1 / S5)
334
+ // ---------------------------------------------------------------------------
335
+
229
336
  /**
230
- * Stub checkpoint full blackboard→STATE rollup remains v1.5.0 work.
231
- * Seeds an empty state if missing; updates only frontmatter.checkpoint_at.
337
+ * Derive the next STATE.md status from the wave-filtered blackboard slice and
338
+ * the previously-persisted state.
339
+ *
340
+ * Rules (R1 §S5):
341
+ * 1. any open blocker → 'blocked'
342
+ * 2. no claims at all → preserve existing status (default 'pending')
343
+ * 3. every claim 'released' → 'review'
344
+ * 4. otherwise → 'in_progress'
345
+ *
346
+ * @param {{claims: object[], findings: object[], blockers: object[]}} filtered
347
+ * @param {{frontmatter?: object} | null} existing
348
+ * @returns {'blocked'|'pending'|'review'|'in_progress'}
349
+ */
350
+ export function deriveStatus(filtered, existing) {
351
+ if (filtered.blockers && filtered.blockers.length > 0) return 'blocked';
352
+ if (filtered.claims.length === 0) return existing?.frontmatter?.status ?? 'pending';
353
+ if (filtered.claims.every((c) => c.status === 'released')) return 'review';
354
+ return 'in_progress';
355
+ }
356
+
357
+ /**
358
+ * Tag a blackboard entry as belonging to a wave by checking, in order:
359
+ * - explicit `wave_id` field
360
+ * - artifact_id prefixed `<waveId>:`
361
+ * - message containing `[<waveId>]`
362
+ *
363
+ * @param {{claims?: {data?: {claims?: object[]}}, recent?: {findings?: object[], blockers?: object[]}}} blackboard
364
+ * @param {string} waveId
365
+ * @returns {{claims: object[], findings: object[], blockers: object[]}}
366
+ */
367
+ export function filterByWave(blackboard, waveId) {
368
+ const tag = (entry) => {
369
+ if (!entry) return false;
370
+ if (entry.wave_id === waveId) return true;
371
+ if (typeof entry.artifact_id === 'string' && entry.artifact_id.startsWith(`${waveId}:`)) return true;
372
+ if (typeof entry.message === 'string' && entry.message.includes(`[${waveId}]`)) return true;
373
+ return false;
374
+ };
375
+ const claims = (blackboard.claims?.data?.claims ?? []).filter(tag);
376
+ const findings = (blackboard.recent?.findings ?? []).filter(tag);
377
+ const blockers = (blackboard.recent?.blockers ?? []).filter(tag);
378
+ return { claims, findings, blockers };
379
+ }
380
+
381
+ /**
382
+ * Quote YAML strings that would otherwise confuse the flat-subset parser/emitter:
383
+ * presence of `:`, `#`, `[`, `]`, `{`, `}`, `"`, newline, or `<space>-`.
384
+ *
385
+ * Fold-in: Trident r13 F6 — emit safety for STATE.md frontmatter strings.
386
+ *
387
+ * @param {string} s
388
+ * @returns {string}
389
+ */
390
+ export function quoteYamlStr(s) {
391
+ if (typeof s !== 'string') return String(s);
392
+ if (/[:#[\]{}"\n]|\s-/.test(s)) return `"${s.replace(/"/g, '\\"')}"`;
393
+ return s;
394
+ }
395
+
396
+ /**
397
+ * Render the markdown body for STATE.md from the wave-filtered blackboard slice.
398
+ * Findings are capped to the last 5 (matches frontmatter.findings_recent window).
399
+ *
400
+ * @param {{findings: object[], blockers: object[]}} filtered
401
+ * @param {{body?: string} | null} _existing (reserved for future merge logic)
402
+ * @returns {string}
403
+ */
404
+ export function renderBody(filtered, _existing) {
405
+ const lines = [];
406
+ if (filtered.findings.length > 0) {
407
+ lines.push('## Recent findings');
408
+ for (const f of filtered.findings.slice(-5)) {
409
+ lines.push(`- ${f.message ?? '(unspecified)'}`);
410
+ }
411
+ lines.push('');
412
+ }
413
+ if (filtered.blockers.length > 0) {
414
+ lines.push('## Open blockers');
415
+ for (const b of filtered.blockers) {
416
+ lines.push(`- ${b.message ?? '(unspecified)'}`);
417
+ }
418
+ }
419
+ return lines.join('\n');
420
+ }
421
+
422
+ /**
423
+ * v1.5.0 T7: derive the open-blocker set for a wave from `decisions.jsonl`.
424
+ *
425
+ * The SDK's `blocker.add` / `blocker.resolve` verbs append `kind:'blocker'` /
426
+ * `kind:'blocker-resolution'` records to `.ijfw/blackboard/decisions.jsonl`
427
+ * (T4 contract §7). A blocker is **open** when:
428
+ * - a `kind:'blocker'` record exists for the wave (matched by
429
+ * record.waveId === waveId), AND
430
+ * - no later `kind:'blocker-resolution'` record carries the same
431
+ * `blockerId`.
432
+ *
433
+ * Returns parallel arrays of stable ids (for `blockers_open`, machine-
434
+ * consumed) and human messages (for `blockers_open_summary`, optional UI).
435
+ *
436
+ * @param {{recent?: {decisions?: object[]}}} blackboard
437
+ * @param {string} waveId
438
+ * @returns {{ids: string[], summaries: string[]}}
439
+ */
440
+ export function deriveOpenBlockers(blackboard, waveId) {
441
+ const decisions = Array.isArray(blackboard?.recent?.decisions)
442
+ ? blackboard.recent.decisions : [];
443
+ const resolvedIds = new Set();
444
+ for (const r of decisions) {
445
+ if (r && r.kind === 'blocker-resolution' && typeof r.blockerId === 'string') {
446
+ resolvedIds.add(r.blockerId);
447
+ }
448
+ }
449
+ const ids = [];
450
+ const summaries = [];
451
+ const seen = new Set();
452
+ for (const r of decisions) {
453
+ if (!r || r.kind !== 'blocker') continue;
454
+ if (typeof r.blockerId !== 'string' || !r.blockerId) continue;
455
+ if (r.waveId !== waveId) continue;
456
+ if (resolvedIds.has(r.blockerId)) continue;
457
+ if (seen.has(r.blockerId)) continue;
458
+ seen.add(r.blockerId);
459
+ ids.push(r.blockerId);
460
+ summaries.push(quoteYamlStr(typeof r.text === 'string' ? r.text : ''));
461
+ }
462
+ return { ids, summaries };
463
+ }
464
+
465
+ /**
466
+ * Roll up the blackboard slice for `waveId` into STATE.md frontmatter+body.
467
+ *
468
+ * Steps:
469
+ * 1. Read existing STATE.md (preserve created_at if present).
470
+ * 2. Read blackboard.js — defensive: missing/uninitialized blackboard yields
471
+ * empty arrays so checkpointing never throws on a clean tree.
472
+ * 3. Filter blackboard entries by wave tag.
473
+ * 4. Derive `blockers_open` from `decisions.jsonl` (single source of truth —
474
+ * the SDK's blocker.add/blocker.resolve verbs append there). Legacy
475
+ * `blackboard.recent.blockers` (from `addBlackboardNote(kind:'blocker')`)
476
+ * still drives the `status='blocked'` rule for back-compat.
477
+ * 5. Derive status + frontmatter; render markdown body.
478
+ * 6. Persist atomically via writeWaveState (SDK-routed).
479
+ * 7. Append a SUMMARY.md delta when status transitions.
480
+ * 8. If S4's populateBlackboardBlock is loaded, refresh AGENTS.md (advisory —
481
+ * silent on failure).
232
482
  *
233
483
  * @param {string} waveId
234
484
  * @param {string} projectRoot
@@ -238,18 +488,77 @@ export async function checkpointWave(waveId, projectRoot) {
238
488
  const now = new Date().toISOString();
239
489
  const existing = await readWaveState(waveId, projectRoot);
240
490
 
241
- const next = existing
242
- ? { frontmatter: { ...existing.frontmatter, checkpoint_at: now }, body: existing.body }
243
- : {
244
- frontmatter: {
245
- wave_id: waveId,
246
- status: 'in_progress',
247
- created_at: now,
248
- checkpoint_at: now,
249
- },
250
- body: '',
251
- };
491
+ // readBlackboard returns synchronously per blackboard.js; uninitialized
492
+ // blackboard yields empty arrays so the rollup is safe on a clean tree.
493
+ let blackboard;
494
+ try {
495
+ blackboard = readBlackboard(projectRoot);
496
+ } catch {
497
+ blackboard = {
498
+ claims: { data: { claims: [] } },
499
+ recent: { findings: [], blockers: [], decisions: [] },
500
+ };
501
+ }
502
+
503
+ const filtered = filterByWave(blackboard, waveId);
504
+ // T7: single-writer reconciliation. `blockers_open` is now derived from
505
+ // decisions.jsonl (the SDK's blocker.add/blocker.resolve target) — an array
506
+ // of stable blocker ids. The legacy blackboard `blockers.jsonl` slice is
507
+ // still used to drive `status='blocked'` so existing call sites that emit
508
+ // blockers via `addBlackboardNote(kind:'blocker')` keep working.
509
+ const openBlockers = deriveOpenBlockers(blackboard, waveId);
510
+ // For deriveStatus and renderBody, merge legacy filtered blockers with the
511
+ // SDK-derived ones so any source of an open blocker still flips status.
512
+ const sdkBlockerEntries = openBlockers.ids.map((id, i) => ({
513
+ blockerId: id, message: openBlockers.summaries[i], wave_id: waveId,
514
+ }));
515
+ // Deduplicate by message text — a legacy blocker and an SDK blocker with
516
+ // identical text shouldn't appear twice in the body.
517
+ const blockerMessages = new Set(filtered.blockers.map((b) => b.message ?? ''));
518
+ const mergedBlockers = [...filtered.blockers];
519
+ for (const b of sdkBlockerEntries) {
520
+ if (!blockerMessages.has(b.message)) mergedBlockers.push(b);
521
+ }
522
+ const mergedFiltered = { ...filtered, blockers: mergedBlockers };
523
+ const status = deriveStatus(mergedFiltered, existing);
524
+
525
+ const next = {
526
+ frontmatter: {
527
+ wave_id: waveId,
528
+ status,
529
+ created_at: existing?.frontmatter?.created_at ?? now,
530
+ checkpoint_at: now,
531
+ claims_active: filtered.claims.filter((c) => c.status === 'active').length,
532
+ findings_recent: filtered.findings.slice(-5).map((f) => quoteYamlStr(f.message ?? '')),
533
+ // T7: canonical machine-consumed shape — array of stable blocker ids
534
+ // sourced from decisions.jsonl. Empty when no SDK blockers are open.
535
+ blockers_open: openBlockers.ids,
536
+ // Human-readable summary (optional UI), populated from the same SDK
537
+ // decisions.jsonl records that fed `blockers_open`.
538
+ blockers_open_summary: openBlockers.summaries,
539
+ agents: [...new Set(filtered.claims.map((c) => c.agent ?? c.owner).filter(Boolean))],
540
+ },
541
+ body: renderBody(mergedFiltered, existing),
542
+ };
252
543
 
253
544
  await writeWaveState(waveId, next, projectRoot);
545
+
546
+ // Append summary delta when status changes (audit log).
547
+ const prevStatus = existing?.frontmatter?.status ?? 'new';
548
+ if (prevStatus !== status) {
549
+ await appendSummary(
550
+ waveId,
551
+ { agent_id: 'checkpointWave', surprises: `status: ${prevStatus} → ${status}` },
552
+ projectRoot,
553
+ );
554
+ }
555
+
556
+ // S4 integration: refresh AGENTS.md BLACKBOARD block. Silent on failure —
557
+ // populating AGENTS.md is advisory and must not block checkpointing.
558
+ const populateBlackboardBlock = await loadPopulateBlackboardBlock();
559
+ if (populateBlackboardBlock) {
560
+ try { await populateBlackboardBlock(waveId, projectRoot); } catch { /* advisory */ }
561
+ }
562
+
254
563
  return next;
255
564
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * worktree-provision.js — v1.5.0 S2: auto-detect + install deps in a fresh worktree.
3
+ *
4
+ * Closes the Step 0 briefing gap: subagents in isolation:worktree get a clean
5
+ * git checkout but no node_modules/. Without this, every Node-touching subagent
6
+ * burns its first ~30s npm install-ing.
7
+ *
8
+ * Security: uses node:child_process execFile (NO shell, no string concat).
9
+ * Detector commands + args are frozen literals; cwd is the only per-call
10
+ * variable and is validated via lstat before invocation. `--ignore-scripts`
11
+ * is non-negotiable on npm install. A subagent worktree with malicious
12
+ * package.json {scripts:{preinstall:"..."}} must not execute arbitrary code.
13
+ */
14
+
15
+ import { execFile } from 'node:child_process';
16
+ import { promisify } from 'node:util';
17
+ import { lstat, readdir } from 'node:fs/promises';
18
+ import { existsSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ const execFileP = promisify(execFile);
22
+
23
+ export const DETECTORS = Object.freeze([
24
+ { name: 'node', file: 'package.json', cmd: 'npm', args: ['install', '--no-audit', '--no-fund', '--ignore-scripts'] },
25
+ { name: 'python', file: 'pyproject.toml', cmd: 'pip', args: ['install', '--quiet', '-e', '.'] },
26
+ { name: 'rust', file: 'Cargo.toml', cmd: 'cargo', args: ['fetch'] },
27
+ { name: 'go', file: 'go.mod', cmd: 'go', args: ['mod', 'download'] },
28
+ ]);
29
+
30
+ export const DEFAULT_PER_INSTALL_MS = 2 * 60 * 1000;
31
+ export const DEFAULT_WALL_MS = 5 * 60 * 1000;
32
+
33
+ async function firstLevelDirs(root) {
34
+ try {
35
+ const entries = await readdir(root, { withFileTypes: true });
36
+ return entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => join(root, e.name));
37
+ } catch { return []; }
38
+ }
39
+
40
+ export async function provisionWorktree(worktreePath, opts = {}) {
41
+ const detectors = opts.detectors ?? DETECTORS;
42
+ const perInstallMs = opts.perInstallMs ?? DEFAULT_PER_INSTALL_MS;
43
+ const wallMs = opts.wallMs ?? DEFAULT_WALL_MS;
44
+ const deadline = Date.now() + wallMs;
45
+ const result = { installed: [], skipped: [], failed: [] };
46
+
47
+ const candidateDirs = [worktreePath, ...(await firstLevelDirs(worktreePath))];
48
+
49
+ for (const det of detectors) {
50
+ if (Date.now() >= deadline) {
51
+ result.skipped.push({ name: det.name, reason: 'wall-deadline' });
52
+ continue;
53
+ }
54
+ for (const dir of candidateDirs) {
55
+ const manifest = join(dir, det.file);
56
+ if (!existsSync(manifest)) continue;
57
+ try {
58
+ const st = await lstat(manifest);
59
+ if (!st.isFile()) { result.skipped.push({ name: det.name, path: dir, reason: 'not-regular-file' }); continue; }
60
+ } catch { result.skipped.push({ name: det.name, path: dir, reason: 'lstat-failed' }); continue; }
61
+
62
+ const t0 = Date.now();
63
+ try {
64
+ await execFileP(det.cmd, det.args, { cwd: dir, timeout: perInstallMs, maxBuffer: 16 * 1024 * 1024 });
65
+ result.installed.push({ name: det.name, path: dir, ms: Date.now() - t0 });
66
+ } catch (err) {
67
+ // execFile timeout surfaces as either err.code === 'ETIMEDOUT' OR
68
+ // (err.killed && err.signal === 'SIGTERM'/'SIGKILL') depending on
69
+ // platform/Node version. Detect both to give callers a stable signal.
70
+ const timedOut = err.code === 'ETIMEDOUT'
71
+ || (err.killed && (err.signal === 'SIGTERM' || err.signal === 'SIGKILL'));
72
+ result.failed.push({ name: det.name, path: dir, reason: timedOut ? 'timeout' : (err.code || err.message) });
73
+ }
74
+ }
75
+ }
76
+ return result;
77
+ }
@@ -65,9 +65,11 @@ import {
65
65
  * projectRoot. Used by deployResolvedSkill to know which platforms to write
66
66
  * the merged body into.
67
67
  *
68
- * TODO(W2b/t11): replace this with an exported helper from
69
- * installer/src/install-helpers.js once that module exposes a canonical
70
- * platform-list getter. Until then this on-disk probe is the contract.
68
+ * DEFERRED (platform-list consolidation): could be replaced with an exported
69
+ * helper from installer/src/install-helpers.js once that module exposes a
70
+ * canonical platform-list getter. Until then this on-disk probe is the
71
+ * contract — and as an on-disk probe it correctly reflects what is actually
72
+ * deployed, so the consolidation is a nice-to-have, not a defect.
71
73
  *
72
74
  * @param {string} projectRoot
73
75
  * @returns {string[]} absolute paths to existing platform skill dirs
@@ -36,6 +36,92 @@ import os from 'node:os';
36
36
 
37
37
  const SCHEMA_VERSION = '1.0';
38
38
 
39
+ // Maximum path length the registry will accept. Practical limits across the
40
+ // supported platforms are PATH_MAX=4096 (Linux), 1024 (macOS), 32767 (Windows
41
+ // long-path). 4096 is a comfortable cap that won't reject any real path but
42
+ // will reject a flood-of-bytes prompt-injection payload.
43
+ const MAX_PROJECT_ROOT_LEN = 4096;
44
+
45
+ // Unicode tag-block range (U+E0000–U+E007F) — invisible "ASCII Smuggler" code
46
+ // points historically used to hide prompt-injection payloads inside text the
47
+ // model sees but the human doesn't. Closed at the prelude in H1.4; we also
48
+ // reject at registry-write time to prevent contamination at the source.
49
+ // eslint-disable-next-line security/detect-unsafe-regex -- single-char Unicode range class; no quantifier; not backtrack-exploitable
50
+ const UNICODE_TAG_BLOCK_RE = /[\u{E0000}-\u{E007F}]/u;
51
+
52
+ // ASCII control characters (excluding nothing — \n \r \t \0 etc are ALL
53
+ // rejected when present in a path key).
54
+ // eslint-disable-next-line no-control-regex -- this regex EXISTS to reject control chars; lint hit is a false positive on the gate itself
55
+ const CONTROL_CHAR_RE = /[\x00-\x1F\x7F]/;
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Validation
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * F-SEC-4 (HIGH, update-install-trust audit v1.5.0):
63
+ * recordOverrideUse persisted any non-empty string as a project key and that
64
+ * value flows verbatim into the cross-project promote suggestion that lands
65
+ * in the prelude the model reads — a prompt-injection vector.
66
+ *
67
+ * Validate projectRoot is shaped like a real absolute filesystem path with
68
+ * no `..` segments, no control characters, no Unicode tag-block smuggling
69
+ * code points, and within a sane length cap. Returns `{ ok: true }` on pass
70
+ * or `{ ok: false, reason }` on fail. We do NOT throw — the caller in
71
+ * override-resolver.js wraps the registry write in a non-fatal try/catch
72
+ * and we want a structured signal instead of a thrown Error so future
73
+ * callers can surface a clean rejection without try/catch noise.
74
+ *
75
+ * @param {unknown} projectRoot
76
+ * @returns {{ ok: true } | { ok: false, reason: string }}
77
+ */
78
+ function validateProjectRoot(projectRoot) {
79
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
80
+ return { ok: false, reason: 'projectRoot must be a non-empty string' };
81
+ }
82
+ if (projectRoot.length > MAX_PROJECT_ROOT_LEN) {
83
+ return {
84
+ ok: false,
85
+ reason: `projectRoot exceeds max length ${MAX_PROJECT_ROOT_LEN}`,
86
+ };
87
+ }
88
+ if (!path.isAbsolute(projectRoot)) {
89
+ return { ok: false, reason: 'projectRoot must be an absolute path' };
90
+ }
91
+ // Reject any `..` segment in the RAW path before normalization. path.normalize
92
+ // would collapse interior `..` (e.g. `/a/../b` -> `/b`) and silently rewrite
93
+ // the registry key, defeating the validation. A real filesystem path passed
94
+ // by override-resolver.js is already canonical, so a `..` is a tampering
95
+ // signal. Split on both POSIX and Windows separators so /a/../b and \a\..\b
96
+ // are both caught regardless of host platform.
97
+ const rawSegments = projectRoot.split(/[\\/]/);
98
+ if (rawSegments.includes('..')) {
99
+ return { ok: false, reason: 'projectRoot must not contain `..` segments' };
100
+ }
101
+ if (CONTROL_CHAR_RE.test(projectRoot)) {
102
+ return {
103
+ ok: false,
104
+ reason: 'projectRoot must not contain control characters',
105
+ };
106
+ }
107
+ if (UNICODE_TAG_BLOCK_RE.test(projectRoot)) {
108
+ return {
109
+ ok: false,
110
+ reason: 'projectRoot must not contain Unicode tag-block characters',
111
+ };
112
+ }
113
+ // Reject prompt-injection / markdown / HTML tokens that are not meaningful
114
+ // in a real path but ARE meaningful when rendered into the prelude. These
115
+ // are not legal in any reasonable filesystem path.
116
+ if (/[<>`]/.test(projectRoot)) {
117
+ return {
118
+ ok: false,
119
+ reason: 'projectRoot must not contain `<`, `>`, or backtick',
120
+ };
121
+ }
122
+ return { ok: true };
123
+ }
124
+
39
125
  // ---------------------------------------------------------------------------
40
126
  // Path helpers — read os.homedir()/HOME at call time so tests can swap HOME
41
127
  // via process.env between calls.
@@ -111,21 +197,31 @@ async function readSettings() {
111
197
  * Idempotent — re-recording the same (project, preset) updates applied_at
112
198
  * rather than duplicating the entry.
113
199
  *
114
- * @param {string} projectRoot
200
+ * Returns `{ ok: true }` on success or `{ ok: false, reason }` on validation
201
+ * failure. Does NOT throw on bad input — the value flows into the prelude
202
+ * the model reads, so we want every untrusted projectRoot rejected loudly
203
+ * via the return contract instead of via an unhandled Error.
204
+ *
205
+ * @param {string} projectRoot MUST be an absolute path, no `..`, no
206
+ * control / Unicode tag-block / HTML chars.
115
207
  * @param {string} preset
116
208
  * @param {string} scope 'base' | 'user' | 'org' | 'project'
117
209
  * @param {string} [project_type] auto-detected project type, defaults to
118
210
  * whatever is already on file or 'unknown'.
211
+ * @returns {Promise<{ ok: true } | { ok: false, reason: string }>}
119
212
  */
120
213
  export async function recordOverrideUse(projectRoot, preset, scope, project_type) {
121
- if (typeof projectRoot !== 'string' || !projectRoot) {
122
- throw new Error('recordOverrideUse: projectRoot must be a non-empty string');
214
+ // F-SEC-4 (HIGH): full path validation on projectRoot before it enters the
215
+ // registry. See validateProjectRoot above for the threat-model rationale.
216
+ const pathCheck = validateProjectRoot(projectRoot);
217
+ if (!pathCheck.ok) {
218
+ return pathCheck;
123
219
  }
124
220
  if (typeof preset !== 'string' || !preset) {
125
- throw new Error('recordOverrideUse: preset must be a non-empty string');
221
+ return { ok: false, reason: 'preset must be a non-empty string' };
126
222
  }
127
223
  if (typeof scope !== 'string' || !scope) {
128
- throw new Error('recordOverrideUse: scope must be a non-empty string');
224
+ return { ok: false, reason: 'scope must be a non-empty string' };
129
225
  }
130
226
 
131
227
  const state = await readRegistry();
@@ -153,6 +249,7 @@ export async function recordOverrideUse(projectRoot, preset, scope, project_type
153
249
  }
154
250
  state.projects[projectRoot] = proj;
155
251
  await writeRegistry(state);
252
+ return { ok: true };
156
253
  }
157
254
 
158
255
  /**
@@ -186,6 +283,9 @@ export async function findProjectsWithOverride(preset) {
186
283
  const out = [];
187
284
  for (const [project, entry] of Object.entries(state.projects || {})) {
188
285
  if (!entry || !Array.isArray(entry.active_overrides)) continue;
286
+ // F-SEC-4 (HIGH) defense-in-depth: legacy registries written before
287
+ // v1.5.1 validation could contain unsafe keys. Refuse to surface them.
288
+ if (!validateProjectRoot(project).ok) continue;
189
289
  const hit = entry.active_overrides.find(
190
290
  (o) => o && o.preset === preset
191
291
  );
@@ -226,6 +326,10 @@ export async function findProjectsWithSimilarOverrideSet(
226
326
  for (const [project, entry] of Object.entries(state.projects || {})) {
227
327
  if (exclude && project === exclude) continue;
228
328
  if (!entry || !Array.isArray(entry.active_overrides)) continue;
329
+ // F-SEC-4 (HIGH) defense-in-depth: legacy registries written before
330
+ // v1.5.1 validation could contain unsafe keys that would flow into the
331
+ // promote suggestion text and the prelude. Refuse to surface them.
332
+ if (!validateProjectRoot(project).ok) continue;
229
333
  const theirs = new Set(
230
334
  entry.active_overrides
231
335
  .filter((o) => o && typeof o.preset === 'string')
@@ -304,4 +408,6 @@ export const __test = {
304
408
  settingsPath,
305
409
  readRegistry,
306
410
  writeRegistry,
411
+ validateProjectRoot,
412
+ MAX_PROJECT_ROOT_LEN,
307
413
  };