@ijfw/memory-server 1.4.3 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
  2. package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
  3. package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
  4. package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
  5. package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
  6. package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
  7. package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
  8. package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
  9. package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
  10. package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
  11. package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
  12. package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
  13. package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
  14. package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
  15. package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
  16. package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
  17. package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
  18. package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
  19. package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
  20. package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
  21. package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
  22. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
  23. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
  24. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
  25. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
  26. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
  27. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
  28. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
  29. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
  30. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
  31. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
  32. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
  33. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
  34. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
  35. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
  36. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
  37. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
  38. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
  39. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
  40. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
  41. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
  42. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
  43. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
  44. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
  45. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
  46. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
  47. package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
  48. package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
  49. package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
  50. package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
  51. package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
  52. package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
  53. package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
  54. package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  55. package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
  56. package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
  57. package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
  58. package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
  59. package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
  60. package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
  61. package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
  62. package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  63. package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
  64. package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
  65. package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
  66. package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
  67. package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
  68. package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
  69. package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
  70. package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
  71. package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
  72. package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
  73. package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
  74. package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
  75. package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
  76. package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
  77. package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
  78. package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
  79. package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
  80. package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
  81. package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
  82. package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
  83. package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
  84. package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
  85. package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
  86. package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
  87. package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
  88. package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
  89. package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
  90. package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
  91. package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
  92. package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
  93. package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
  94. package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
  95. package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
  96. package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
  97. package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
  98. package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
  99. package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
  100. package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  101. package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
  102. package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
  103. package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
  104. package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
  105. package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
  106. package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
  107. package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
  108. package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
  109. package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  110. package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
  111. package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
  112. package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
  113. package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
  114. package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
  115. package/package.json +1 -1
  116. package/src/active-extension-writer.js +144 -64
  117. package/src/api-client.js +43 -5
  118. package/src/audit-roster.js +80 -5
  119. package/src/blackboard.js +298 -6
  120. package/src/cli-run.js +33 -5
  121. package/src/codex-agents.js +96 -5
  122. package/src/cost/aggregator.js +39 -9
  123. package/src/cost/pricing.js +57 -0
  124. package/src/cost/readers/gemini.js +1 -1
  125. package/src/cross-audit-chunker.js +189 -0
  126. package/src/cross-dispatcher.js +124 -21
  127. package/src/cross-orchestrator-cli.js +550 -14
  128. package/src/cross-orchestrator.js +1171 -10
  129. package/src/cross-project-search.js +195 -9
  130. package/src/dashboard-client-planning.html +273 -0
  131. package/src/dashboard-client-waves.html +304 -0
  132. package/src/dashboard-client.html +17 -2
  133. package/src/dashboard-server.js +152 -0
  134. package/src/deploy-alerts.js +150 -0
  135. package/src/design/iframe-bridge.js +242 -0
  136. package/src/design-companion.js +144 -0
  137. package/src/dispatch/checkpoint-cli.js +97 -0
  138. package/src/dispatch/colon-syntax.js +81 -1
  139. package/src/dispatch/extension.js +27 -1
  140. package/src/dispatch/registry-cli.js +4 -1
  141. package/src/dispatch/wave-cli.js +323 -0
  142. package/src/dispatch/worktree-cli.js +40 -0
  143. package/src/dispatch-planner.js +97 -2
  144. package/src/dream/runner.mjs +47 -11
  145. package/src/dream/stage-runner.js +40 -0
  146. package/src/dream/state-file.js +102 -0
  147. package/src/extension-installer.js +70 -24
  148. package/src/extension-quota-tracker.js +4 -2
  149. package/src/extension-registry.js +289 -35
  150. package/src/feedback-detector.js +26 -0
  151. package/src/fs-lock.js +259 -7
  152. package/src/gate-result.js +95 -1
  153. package/src/hero-line.js +86 -5
  154. package/src/intent-router.js +35 -0
  155. package/src/lib/a11y-contract.js +117 -0
  156. package/src/lib/atomic-io.js +29 -8
  157. package/src/lib/cache-keepalive.js +150 -0
  158. package/src/lib/jsonl-rotation.js +104 -0
  159. package/src/lib/lighthouse-pillar.js +121 -0
  160. package/src/lib/llm-call.js +121 -0
  161. package/src/lib/playwright-baseline.js +205 -0
  162. package/src/lib/rekor-bridge.js +221 -0
  163. package/src/lib/repo-map.js +392 -0
  164. package/src/lib/shasum-verify.js +164 -0
  165. package/src/lib/sketches-gc.js +132 -0
  166. package/src/lib/tmp-suffix.js +62 -0
  167. package/src/lib/ui-review-runner.js +554 -0
  168. package/src/lib/uispec-drift.js +301 -0
  169. package/src/lib/uispec-intake.js +381 -0
  170. package/src/lib/worktree-guards.js +118 -0
  171. package/src/lib/worktree-recovery.js +100 -0
  172. package/src/memory/auto-linker.js +152 -0
  173. package/src/memory/benchmark.js +498 -0
  174. package/src/memory/dedup.js +126 -0
  175. package/src/memory/embedding-cache.js +136 -0
  176. package/src/memory/fact-extractor.js +168 -0
  177. package/src/memory/fts5.js +65 -1
  178. package/src/memory/migrations/004-bitemporal.js +91 -0
  179. package/src/memory/migrations/005-vector-cache.js +61 -0
  180. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  181. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  182. package/src/memory/migrations/008-write-provenance.js +41 -0
  183. package/src/memory/obsidian-parser.js +91 -0
  184. package/src/memory/query-dataview.js +86 -0
  185. package/src/memory/search.js +10 -0
  186. package/src/memory/temporal.js +529 -0
  187. package/src/memory/tokenize.js +10 -0
  188. package/src/memory-facts-handler.js +37 -0
  189. package/src/memory-feedback.js +260 -2
  190. package/src/model-refresh.js +292 -0
  191. package/src/observability/cost-anomaly.js +166 -0
  192. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  193. package/src/observability/trace-id.js +163 -0
  194. package/src/orchestrator/agents-md-blackboard.js +152 -0
  195. package/src/orchestrator/checkpoint-contract.md +140 -0
  196. package/src/orchestrator/debug-trident.js +570 -0
  197. package/src/orchestrator/merge-block-aware.js +350 -0
  198. package/src/orchestrator/plan-checker.js +475 -0
  199. package/src/orchestrator/post-done-runner.js +249 -0
  200. package/src/orchestrator/review.js +136 -0
  201. package/src/orchestrator/runtime-loop.js +430 -0
  202. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  203. package/src/orchestrator/skill-telemetry.js +37 -0
  204. package/src/orchestrator/state-events.js +459 -0
  205. package/src/orchestrator/state-sdk.js +1764 -0
  206. package/src/orchestrator/status-protocol.js +235 -0
  207. package/src/orchestrator/subagent-telemetry.js +452 -0
  208. package/src/orchestrator/termination.js +160 -0
  209. package/src/orchestrator/verification-gate.js +281 -0
  210. package/src/orchestrator/wave-state.js +564 -0
  211. package/src/orchestrator/worktree-provision.js +77 -0
  212. package/src/override-use-registry.js +111 -5
  213. package/src/receipts.js +36 -4
  214. package/src/recovery/checkpoint.js +56 -3
  215. package/src/recovery/code-fixer.js +656 -0
  216. package/src/recovery/truncation.js +317 -0
  217. package/src/redactor.js +75 -6
  218. package/src/runtime-mediator.js +15 -0
  219. package/src/sanitizer.js +10 -0
  220. package/src/search-hybrid.js +139 -0
  221. package/src/server.js +603 -59
  222. package/src/swarm/worktree.js +27 -4
  223. package/src/swarm-config.js +113 -12
  224. package/src/team/domain-templates/book.json +51 -0
  225. package/src/team/domain-templates/business.json +41 -0
  226. package/src/team/domain-templates/content.json +50 -0
  227. package/src/team/domain-templates/design.json +44 -0
  228. package/src/team/domain-templates/research.json +41 -0
  229. package/src/team/domain-templates/software.json +40 -0
  230. package/src/team/generator.js +278 -3
  231. package/src/team/modify.js +203 -0
  232. package/src/team/schemas.js +48 -0
  233. package/src/update-apply.js +19 -3
@@ -1,50 +1,236 @@
1
1
  // --- cross-project-search: BM25 search across every registered IJFW project ---
2
2
  //
3
- // Complements the existing naive keyword-count searchAcrossProjects in
4
- // server.js. Builds a corpus of (project, source, line) docs from each
3
+ // Canonical cross-project search surface. The legacy naive-keyword-count
4
+ // `searchAcrossProjects` in server.js was removed in v1.5.1 H1.5 (audit
5
+ // finding memory-engine.md F-FUN-4) — `scope:'all'` on the MCP search tool
6
+ // now routes here. Builds a corpus of (project, source, line) docs from each
5
7
  // registered project's memory files, hands it to the BM25 ranker in
6
8
  // search-bm25.js, and returns hits tagged with [project:<basename>].
7
9
  //
8
10
  // Pure + injectable. The caller supplies the registry reader and the
9
11
  // per-project memory reader so this module can be unit-tested without
10
12
  // touching the home directory.
13
+ //
14
+ // v1.5.0 audit-H3.4 (memory-engine.md F-SEC-1): registry entries are
15
+ // treated as UNTRUSTED. Each entry.path is:
16
+ // 1. realpath-resolved (symlinks resolved against the filesystem)
17
+ // 2. containment-checked against `allowedRoots` (default: $HOME)
18
+ // 3. silently skipped if it escapes the allowed roots OR fails realpath
19
+ // 4. logged to stderr ONCE per unique offending raw-path (Set dedup)
20
+ // Threat model: a compromised dep / malicious project that writes a
21
+ // registry entry pointing at /etc/passwd via a symlink in the user's
22
+ // home directory will be caught by step 1+2. Plain `isAbsolute()` was
23
+ // the only prior validation and was insufficient.
11
24
 
12
- import { basename } from 'node:path';
25
+ import { basename, resolve, join } from 'node:path';
26
+ import { realpathSync, statSync } from 'node:fs';
27
+ import { homedir } from 'node:os';
13
28
  import { searchCorpus } from './search-bm25.js';
14
29
 
30
+ // v1.5.0 audit MED #10 (memory-engine.md F-SPD-2): per-project corpus
31
+ // cache keyed on (canonicalProjectPath, signature) where `signature` is
32
+ // the joined mtimes of the project's memory files. As long as nothing
33
+ // has changed under .ijfw/memory the cache hits and we skip the
34
+ // readProjectMemory() call entirely. Cache is module-local; tests can
35
+ // reset via _resetCorpusCache(). Cap at 64 entries (LRU-ish via Map
36
+ // insertion order) so a long-lived dashboard process can't OOM the cache.
37
+ const CORPUS_CACHE = new Map();
38
+ const CORPUS_CACHE_CAP = 64;
39
+
40
+ /** Reset the corpus mtime cache. Test-only. */
41
+ export function _resetCorpusCache() {
42
+ CORPUS_CACHE.clear();
43
+ }
44
+
45
+ // Files that participate in the cache signature. Match the readers used
46
+ // by readProjectMemory in server.js so an edit to any of them busts the
47
+ // cache. Missing files contribute "0" -- creation of a previously-missing
48
+ // file is a cache-bust on its own.
49
+ const CACHE_SIGNATURE_FILES = [
50
+ ['.ijfw', 'memory', 'knowledge.md'],
51
+ ['.ijfw', 'memory', 'project-journal.md'],
52
+ ['.ijfw', 'memory', 'handoff.md'],
53
+ ['.ijfw', 'memory', 'team-knowledge.md'],
54
+ ];
55
+
56
+ function computeProjectSignature(canonicalProjectPath) {
57
+ let sig = '';
58
+ for (const parts of CACHE_SIGNATURE_FILES) {
59
+ const p = join(canonicalProjectPath, ...parts);
60
+ try {
61
+ const s = statSync(p);
62
+ sig += `${s.mtimeMs.toFixed(3)}:${s.size}|`;
63
+ } catch {
64
+ sig += '0|';
65
+ }
66
+ }
67
+ return sig;
68
+ }
69
+
70
+ function getCachedDocs(canonicalProjectPath) {
71
+ const sig = computeProjectSignature(canonicalProjectPath);
72
+ const cached = CORPUS_CACHE.get(canonicalProjectPath);
73
+ if (cached && cached.sig === sig) {
74
+ // LRU touch: re-insert at the end of the Map iteration order.
75
+ CORPUS_CACHE.delete(canonicalProjectPath);
76
+ CORPUS_CACHE.set(canonicalProjectPath, cached);
77
+ return cached.docs;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function setCachedDocs(canonicalProjectPath, docs) {
83
+ const sig = computeProjectSignature(canonicalProjectPath);
84
+ CORPUS_CACHE.set(canonicalProjectPath, { sig, docs });
85
+ // Trim oldest entry if we're over the cap. Map iteration is insertion
86
+ // order so the first key is the oldest.
87
+ if (CORPUS_CACHE.size > CORPUS_CACHE_CAP) {
88
+ const oldestKey = CORPUS_CACHE.keys().next().value;
89
+ if (oldestKey !== undefined) CORPUS_CACHE.delete(oldestKey);
90
+ }
91
+ }
92
+
93
+ // Module-level dedup set for "skipped offender" stderr noise control.
94
+ // Keyed by the raw entry.path (the attacker-controlled string) so the same
95
+ // malicious entry is reported only once per process lifetime. Exported for
96
+ // test reset via `_resetSkipLog()`.
97
+ const SKIP_LOG = new Set();
98
+
99
+ /** Reset the skip-log dedup set. Test-only. */
100
+ export function _resetSkipLog() {
101
+ SKIP_LOG.clear();
102
+ }
103
+
104
+ /** Default allowed roots: just the user's home directory.
105
+ * Tests can pass `opts.allowedRoots` to constrain or widen.
106
+ * Returned as resolved (realpath'd) absolute paths so containment
107
+ * comparison is apples-to-apples with the realpath'd entry. */
108
+ function defaultAllowedRoots() {
109
+ const roots = [];
110
+ try {
111
+ const home = homedir();
112
+ if (home && typeof home === 'string') {
113
+ // realpath the root itself so /var/root on macOS resolves to
114
+ // /private/var/root (same canonicalization as the entry side).
115
+ try { roots.push(realpathSync(home)); } catch { roots.push(resolve(home)); }
116
+ }
117
+ } catch { /* ignore */ }
118
+ return roots;
119
+ }
120
+
121
+ /** True iff `child` is `root` or a path nested under `root`.
122
+ * Both inputs are expected to be already-canonical absolute paths.
123
+ * We compare with a trailing-separator guard so `/home/alice-evil`
124
+ * is NOT considered inside `/home/alice`. */
125
+ function isUnder(child, root) {
126
+ if (!child || !root) return false;
127
+ if (child === root) return true;
128
+ // Use both POSIX and Windows separators defensively. Path normalization
129
+ // has already happened via realpathSync; we just need the boundary check.
130
+ const withPosixSep = root.endsWith('/') ? root : root + '/';
131
+ const withWindowsSep = root.endsWith('\\') ? root : root + '\\';
132
+ return child.startsWith(withPosixSep) || child.startsWith(withWindowsSep);
133
+ }
134
+
135
+ /** Resolve + containment check.
136
+ * Returns the canonical path on success, or null if the entry must be
137
+ * skipped. On skip, logs to stderr once per unique raw path. */
138
+ function safeResolveProjectPath(rawPath, allowedRoots) {
139
+ if (!rawPath || typeof rawPath !== 'string') {
140
+ return _skip(rawPath, 'not-a-string');
141
+ }
142
+ let canonical;
143
+ try {
144
+ // realpathSync resolves symlinks. If the entry points to a symlink
145
+ // chain whose target escapes allowedRoots, this is where we catch it.
146
+ canonical = realpathSync(rawPath);
147
+ } catch {
148
+ // ENOENT / EACCES / ELOOP — entry path doesn't exist or is unreadable.
149
+ // Graceful skip; don't throw (a stale registry shouldn't crash search).
150
+ return _skip(rawPath, 'realpath-failed');
151
+ }
152
+ if (!allowedRoots.some(root => isUnder(canonical, root))) {
153
+ return _skip(rawPath, `escapes-allowed-roots (realpath=${canonical})`);
154
+ }
155
+ return canonical;
156
+ }
157
+
158
+ function _skip(rawPath, reason) {
159
+ const key = String(rawPath || '<null>');
160
+ if (!SKIP_LOG.has(key)) {
161
+ SKIP_LOG.add(key);
162
+ try {
163
+ // Single-line, stderr-only — same posture as the rest of the engine's
164
+ // advisory warnings. Production callers (Claude Code) capture stderr
165
+ // and surface in the dashboard's diagnostics tab.
166
+ process.stderr.write(
167
+ `[ijfw cross-project-search] skipped registry entry: ${reason}: ${key}\n`
168
+ );
169
+ } catch { /* never let a logging failure break search */ }
170
+ }
171
+ return null;
172
+ }
173
+
15
174
  // Build a corpus of line-level docs from the provided projects.
16
175
  // projects: [{ path, hash?, iso? }]
17
176
  // readProjectMemory(path) -> { knowledge, journal, handoff } (strings)
177
+ // opts.allowedRoots: optional array of canonical absolute roots; entries
178
+ // whose realpath escapes ALL of them are skipped.
179
+ // Default: [realpath(homedir())].
18
180
  // Returns [{ id, text, meta }] where meta carries project + source + lineNo.
19
- export function buildCorpus(projects, readProjectMemory) {
181
+ export function buildCorpus(projects, readProjectMemory, opts = {}) {
182
+ const allowedRoots = Array.isArray(opts.allowedRoots) && opts.allowedRoots.length
183
+ ? opts.allowedRoots
184
+ : defaultAllowedRoots();
20
185
  const docs = [];
21
186
  for (const entry of projects) {
22
- const tag = basename(entry.path);
23
- const mem = readProjectMemory(entry.path) || {};
187
+ if (!entry || typeof entry !== 'object') continue;
188
+ const canonical = safeResolveProjectPath(entry.path, allowedRoots);
189
+ if (canonical === null) continue; // skipped (symlink-escape or missing)
190
+
191
+ // v1.5.0 audit MED #10: try the mtime cache before re-reading.
192
+ // The cache is opt-out via opts.useCache=false (tests / consistency runs).
193
+ const useCache = opts.useCache !== false;
194
+ let projectDocs = useCache ? getCachedDocs(canonical) : null;
195
+ if (projectDocs) {
196
+ for (const d of projectDocs) docs.push(d);
197
+ continue;
198
+ }
199
+
200
+ const tag = basename(canonical);
201
+ // Pass the CANONICAL path to the reader, not the raw one — so a reader
202
+ // that joins with `.ijfw/memory/...` walks the canonical tree, not a
203
+ // possibly-spoofed symlink chain.
204
+ const mem = readProjectMemory(canonical) || {};
205
+ projectDocs = [];
24
206
  for (const [source, content] of Object.entries(mem)) {
25
207
  if (typeof content !== 'string' || content.length === 0) continue;
26
208
  const lines = content.split('\n');
27
209
  for (let i = 0; i < lines.length; i++) {
28
210
  const line = lines[i];
29
211
  if (line.trim().length === 0) continue;
30
- docs.push({
212
+ projectDocs.push({
31
213
  id: `${tag}:${source}:${i + 1}`,
32
214
  text: line,
33
- meta: { project: tag, projectPath: entry.path, source, lineNo: i + 1 },
215
+ meta: { project: tag, projectPath: canonical, source, lineNo: i + 1 },
34
216
  });
35
217
  }
36
218
  }
219
+ if (useCache) setCachedDocs(canonical, projectDocs);
220
+ for (const d of projectDocs) docs.push(d);
37
221
  }
38
222
  return docs;
39
223
  }
40
224
 
41
225
  // Run a BM25-ranked search across the corpus produced from `projects`.
42
226
  // Returns [{ content, source, line, project, score, snippet }], capped at limit.
227
+ // opts.allowedRoots: forwarded to buildCorpus (see above).
228
+ // opts.limit: 1..50, default 10.
43
229
  export function crossProjectSearch(query, projects, readProjectMemory, opts = {}) {
44
230
  const limit = clamp(opts.limit, 1, 50, 10);
45
231
  if (!query || typeof query !== 'string') return [];
46
232
 
47
- const docs = buildCorpus(projects, readProjectMemory);
233
+ const docs = buildCorpus(projects, readProjectMemory, opts);
48
234
  if (docs.length === 0) return [];
49
235
 
50
236
  const hits = searchCorpus(query, docs, { limit });
@@ -0,0 +1,273 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>IJFW · Planning Docs</title>
7
+ <style>
8
+ :root { color-scheme: light dark; }
9
+ * { box-sizing: border-box; }
10
+ body { margin: 0; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #fafafa; color: #222; }
11
+ header { padding: 12px 20px; border-bottom: 1px solid #ddd; background: #fff; }
12
+ header h1 { margin: 0; font-size: 18px; font-weight: 600; }
13
+ header .sub { color: #777; font-size: 12px; margin-top: 4px; }
14
+ main { padding: 20px; max-width: 900px; margin: 0 auto; }
15
+ .path-input { width: 100%; padding: 8px 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; border: 1px solid #ccc; border-radius: 4px; }
16
+ .roots { color: #777; font-size: 12px; margin: 8px 0 16px; }
17
+ .roots code { background: #eee; padding: 1px 5px; border-radius: 3px; }
18
+ .doc { background: #fff; padding: 24px 28px; border: 1px solid #ddd; border-radius: 6px; min-height: 300px; }
19
+ .doc h1 { font-size: 22px; margin: 0 0 12px; }
20
+ .doc h2 { font-size: 18px; margin: 24px 0 10px; padding-bottom: 4px; border-bottom: 1px solid #eee; }
21
+ .doc h3 { font-size: 15px; margin: 20px 0 8px; }
22
+ .doc pre { background: #f4f4f4; padding: 12px; border-radius: 4px; overflow-x: auto; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
23
+ .doc code { background: #f4f4f4; padding: 1px 4px; border-radius: 3px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
24
+ .doc pre code { background: transparent; padding: 0; }
25
+ .doc table { border-collapse: collapse; margin: 12px 0; }
26
+ .doc th, .doc td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; font-size: 13px; }
27
+ .doc th { background: #f4f4f4; }
28
+ .doc blockquote { border-left: 3px solid #ccc; padding-left: 12px; color: #666; margin: 12px 0; }
29
+ .err { color: #c00; font-style: italic; }
30
+ .hint { color: #777; font-style: italic; }
31
+ @media (prefers-color-scheme: dark) {
32
+ body { background: #1a1a1a; color: #ddd; }
33
+ header { background: #222; border-color: #333; }
34
+ .doc, .path-input { background: #222; border-color: #333; color: #ddd; }
35
+ .doc pre, .doc code, .roots code, .doc th { background: #2a2a2a; }
36
+ }
37
+ </style>
38
+ </head>
39
+ <body>
40
+ <header>
41
+ <h1>IJFW · Planning Docs</h1>
42
+ <div class="sub">Browse .planning/, .ijfw/memory/, and .ijfw/wave-*/ docs from this project.</div>
43
+ </header>
44
+ <main>
45
+ <input type="text" id="path" class="path-input" placeholder=".planning/1.4.4/HANDOFF-1.4.4.md" autofocus>
46
+ <div class="roots">Allowed roots: .planning/ · .ijfw/memory/ · .ijfw/wave-*/STATE.md · .ijfw/wave-*/SUMMARY.md</div>
47
+ <div id="doc" class="doc"></div>
48
+ </main>
49
+ <script>
50
+ // Tiny markdown shim — produces a safe DOMFragment via DOMParser, no innerHTML.
51
+ // Renders the subset used in IJFW planning docs: headings, paragraphs, code blocks,
52
+ // inline code/bold/italic, links, lists, blockquotes, tables. Not a full CommonMark.
53
+
54
+ function setText(el, text) {
55
+ while (el.firstChild) el.removeChild(el.firstChild);
56
+ el.appendChild(document.createTextNode(text));
57
+ }
58
+ function setStatus(el, text, cls) {
59
+ while (el.firstChild) el.removeChild(el.firstChild);
60
+ const div = document.createElement('div');
61
+ div.className = cls;
62
+ div.textContent = text;
63
+ el.appendChild(div);
64
+ }
65
+
66
+ function makeNode(tag, text) {
67
+ const el = document.createElement(tag);
68
+ if (text !== undefined) el.appendChild(document.createTextNode(text));
69
+ return el;
70
+ }
71
+
72
+ // Renders a single line of markdown-inline content into an array of DOM nodes.
73
+ // Handles `code`, **bold**, *italic*, [text](url). All text is added via
74
+ // createTextNode — no HTML parsing of user content.
75
+ function renderInlineToNodes(s) {
76
+ const nodes = [];
77
+ let i = 0;
78
+ while (i < s.length) {
79
+ // Code span: `...`
80
+ if (s[i] === '`') {
81
+ const end = s.indexOf('`', i + 1);
82
+ if (end !== -1) {
83
+ nodes.push(makeNode('code', s.slice(i + 1, end)));
84
+ i = end + 1;
85
+ continue;
86
+ }
87
+ }
88
+ // Bold: **...**
89
+ if (s[i] === '*' && s[i + 1] === '*') {
90
+ const end = s.indexOf('**', i + 2);
91
+ if (end !== -1) {
92
+ nodes.push(makeNode('strong', s.slice(i + 2, end)));
93
+ i = end + 2;
94
+ continue;
95
+ }
96
+ }
97
+ // Italic: *...*
98
+ if (s[i] === '*') {
99
+ const end = s.indexOf('*', i + 1);
100
+ if (end !== -1 && end - i > 1) {
101
+ nodes.push(makeNode('em', s.slice(i + 1, end)));
102
+ i = end + 1;
103
+ continue;
104
+ }
105
+ }
106
+ // Link: [text](url)
107
+ if (s[i] === '[') {
108
+ const close = s.indexOf(']', i + 1);
109
+ if (close !== -1 && s[close + 1] === '(') {
110
+ const urlEnd = s.indexOf(')', close + 2);
111
+ if (urlEnd !== -1) {
112
+ const a = document.createElement('a');
113
+ a.textContent = s.slice(i + 1, close);
114
+ // r13-L-01: tightened URL guard.
115
+ // ALLOW: http://, https://, and same-origin relative paths (no protocol).
116
+ // BLOCK: javascript:, data:, mailto:, vbscript:, file:, AND protocol-relative
117
+ // URLs starting with `//` (would open cross-origin without scheme).
118
+ const url = s.slice(close + 2, urlEnd);
119
+ const isAllowed = (
120
+ /^https?:\/\//.test(url) || // explicit http/https
121
+ (!url.startsWith('//') && !/^[^:/?#]+:/.test(url)) // relative, no protocol, no `//`
122
+ );
123
+ if (isAllowed) {
124
+ a.href = url;
125
+ a.target = '_blank';
126
+ a.rel = 'noopener';
127
+ }
128
+ nodes.push(a);
129
+ i = urlEnd + 1;
130
+ continue;
131
+ }
132
+ }
133
+ }
134
+ // Plain text — accumulate until next special marker
135
+ let j = i;
136
+ while (j < s.length && !'`*['.includes(s[j])) j++;
137
+ if (j === i) j = i + 1;
138
+ nodes.push(document.createTextNode(s.slice(i, j)));
139
+ i = j;
140
+ }
141
+ return nodes;
142
+ }
143
+
144
+ function renderMarkdownToFragment(md) {
145
+ const frag = document.createDocumentFragment();
146
+ const lines = md.split('\n');
147
+ let i = 0;
148
+ while (i < lines.length) {
149
+ const line = lines[i];
150
+ // Fenced code block
151
+ if (/^```/.test(line)) {
152
+ const buf = [];
153
+ i++;
154
+ while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; }
155
+ i++;
156
+ const pre = document.createElement('pre');
157
+ const code = document.createElement('code');
158
+ code.textContent = buf.join('\n');
159
+ pre.appendChild(code);
160
+ frag.appendChild(pre);
161
+ continue;
162
+ }
163
+ // Headings
164
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
165
+ if (h) {
166
+ const tag = 'h' + h[1].length;
167
+ const el = document.createElement(tag);
168
+ for (const n of renderInlineToNodes(h[2])) el.appendChild(n);
169
+ frag.appendChild(el);
170
+ i++;
171
+ continue;
172
+ }
173
+ // Table: header row + separator row + rows
174
+ if (/^\s*\|/.test(line) && i + 1 < lines.length && /^\s*\|?\s*-/.test(lines[i + 1])) {
175
+ const tbl = document.createElement('table');
176
+ const head = line.split('|').slice(1, -1).map((c) => c.trim());
177
+ const tr = document.createElement('tr');
178
+ for (const c of head) {
179
+ const th = document.createElement('th');
180
+ for (const n of renderInlineToNodes(c)) th.appendChild(n);
181
+ tr.appendChild(th);
182
+ }
183
+ tbl.appendChild(tr);
184
+ i += 2;
185
+ while (i < lines.length && /^\s*\|/.test(lines[i])) {
186
+ const cells = lines[i].split('|').slice(1, -1).map((c) => c.trim());
187
+ const row = document.createElement('tr');
188
+ for (const c of cells) {
189
+ const td = document.createElement('td');
190
+ for (const n of renderInlineToNodes(c)) td.appendChild(n);
191
+ row.appendChild(td);
192
+ }
193
+ tbl.appendChild(row);
194
+ i++;
195
+ }
196
+ frag.appendChild(tbl);
197
+ continue;
198
+ }
199
+ // Bullet list
200
+ if (/^\s*[-*]\s+/.test(line)) {
201
+ const ul = document.createElement('ul');
202
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
203
+ const li = document.createElement('li');
204
+ for (const n of renderInlineToNodes(lines[i].replace(/^\s*[-*]\s+/, ''))) li.appendChild(n);
205
+ ul.appendChild(li);
206
+ i++;
207
+ }
208
+ frag.appendChild(ul);
209
+ continue;
210
+ }
211
+ // Blockquote
212
+ if (/^>\s?/.test(line)) {
213
+ const bq = document.createElement('blockquote');
214
+ let first = true;
215
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
216
+ if (!first) bq.appendChild(document.createElement('br'));
217
+ for (const n of renderInlineToNodes(lines[i].replace(/^>\s?/, ''))) bq.appendChild(n);
218
+ first = false;
219
+ i++;
220
+ }
221
+ frag.appendChild(bq);
222
+ continue;
223
+ }
224
+ // Blank
225
+ if (line.trim() === '') { i++; continue; }
226
+ // Paragraph
227
+ const p = document.createElement('p');
228
+ const buf = [line];
229
+ i++;
230
+ while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,6}\s|```|>|\s*[-*]\s|\s*\|)/.test(lines[i])) {
231
+ buf.push(lines[i]);
232
+ i++;
233
+ }
234
+ for (const n of renderInlineToNodes(buf.join(' '))) p.appendChild(n);
235
+ frag.appendChild(p);
236
+ }
237
+ return frag;
238
+ }
239
+
240
+ async function load(path) {
241
+ const doc = document.getElementById('doc');
242
+ setStatus(doc, 'Loading…', 'hint');
243
+ try {
244
+ const r = await fetch('/api/planning?path=' + encodeURIComponent(path));
245
+ if (!r.ok) {
246
+ const err = await r.json().catch(() => ({ error: 'unknown' }));
247
+ setStatus(doc, r.status + ': ' + (err.error || 'failed'), 'err');
248
+ return;
249
+ }
250
+ const j = await r.json();
251
+ while (doc.firstChild) doc.removeChild(doc.firstChild);
252
+ doc.appendChild(renderMarkdownToFragment(j.body || ''));
253
+ } catch (e) {
254
+ setStatus(doc, e.message, 'err');
255
+ }
256
+ }
257
+
258
+ // Initial hint
259
+ setStatus(document.getElementById('doc'),
260
+ 'Enter a relative path above (e.g. .planning/1.4.4/HANDOFF-1.4.4.md) and press Enter.',
261
+ 'hint');
262
+
263
+ const input = document.getElementById('path');
264
+ input.addEventListener('keydown', (e) => {
265
+ if (e.key === 'Enter' && input.value.trim()) load(input.value.trim());
266
+ });
267
+ // Auto-load if ?path= in URL
268
+ const params = new URLSearchParams(location.search);
269
+ const init = params.get('path');
270
+ if (init) { input.value = init; load(init); }
271
+ </script>
272
+ </body>
273
+ </html>