@ijfw/memory-server 1.4.4 → 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 (232) 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 +1016 -17
  129. package/src/cross-project-search.js +195 -9
  130. package/src/dashboard-client-waves.html +304 -0
  131. package/src/dashboard-client.html +5 -1
  132. package/src/dashboard-server.js +73 -0
  133. package/src/deploy-alerts.js +150 -0
  134. package/src/design/iframe-bridge.js +242 -0
  135. package/src/design-companion.js +144 -0
  136. package/src/dispatch/checkpoint-cli.js +97 -0
  137. package/src/dispatch/colon-syntax.js +81 -1
  138. package/src/dispatch/extension.js +26 -2
  139. package/src/dispatch/registry-cli.js +4 -1
  140. package/src/dispatch/wave-cli.js +201 -6
  141. package/src/dispatch/worktree-cli.js +40 -0
  142. package/src/dispatch-planner.js +97 -2
  143. package/src/dream/runner.mjs +47 -11
  144. package/src/dream/stage-runner.js +40 -0
  145. package/src/dream/state-file.js +102 -0
  146. package/src/extension-installer.js +70 -24
  147. package/src/extension-quota-tracker.js +4 -2
  148. package/src/extension-registry.js +289 -35
  149. package/src/feedback-detector.js +26 -0
  150. package/src/fs-lock.js +259 -7
  151. package/src/gate-result.js +95 -1
  152. package/src/hero-line.js +86 -5
  153. package/src/intent-router.js +35 -0
  154. package/src/lib/a11y-contract.js +117 -0
  155. package/src/lib/atomic-io.js +29 -8
  156. package/src/lib/cache-keepalive.js +150 -0
  157. package/src/lib/jsonl-rotation.js +104 -0
  158. package/src/lib/lighthouse-pillar.js +121 -0
  159. package/src/lib/llm-call.js +121 -0
  160. package/src/lib/playwright-baseline.js +205 -0
  161. package/src/lib/rekor-bridge.js +221 -0
  162. package/src/lib/repo-map.js +392 -0
  163. package/src/lib/shasum-verify.js +164 -0
  164. package/src/lib/sketches-gc.js +132 -0
  165. package/src/lib/tmp-suffix.js +62 -0
  166. package/src/lib/ui-review-runner.js +554 -0
  167. package/src/lib/uispec-drift.js +301 -0
  168. package/src/lib/uispec-intake.js +381 -0
  169. package/src/lib/worktree-guards.js +118 -0
  170. package/src/lib/worktree-recovery.js +100 -0
  171. package/src/memory/auto-linker.js +152 -0
  172. package/src/memory/benchmark.js +498 -0
  173. package/src/memory/dedup.js +126 -0
  174. package/src/memory/embedding-cache.js +136 -0
  175. package/src/memory/fact-extractor.js +168 -0
  176. package/src/memory/fts5.js +65 -1
  177. package/src/memory/migrations/004-bitemporal.js +91 -0
  178. package/src/memory/migrations/005-vector-cache.js +61 -0
  179. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  180. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  181. package/src/memory/migrations/008-write-provenance.js +41 -0
  182. package/src/memory/obsidian-parser.js +91 -0
  183. package/src/memory/query-dataview.js +86 -0
  184. package/src/memory/search.js +10 -0
  185. package/src/memory/temporal.js +529 -0
  186. package/src/memory/tokenize.js +10 -0
  187. package/src/memory-facts-handler.js +37 -0
  188. package/src/memory-feedback.js +260 -2
  189. package/src/model-refresh.js +292 -0
  190. package/src/observability/cost-anomaly.js +166 -0
  191. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  192. package/src/observability/trace-id.js +163 -0
  193. package/src/orchestrator/agents-md-blackboard.js +152 -0
  194. package/src/orchestrator/checkpoint-contract.md +140 -0
  195. package/src/orchestrator/debug-trident.js +570 -0
  196. package/src/orchestrator/merge-block-aware.js +350 -0
  197. package/src/orchestrator/plan-checker.js +475 -0
  198. package/src/orchestrator/post-done-runner.js +249 -0
  199. package/src/orchestrator/review.js +38 -3
  200. package/src/orchestrator/runtime-loop.js +430 -0
  201. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  202. package/src/orchestrator/skill-telemetry.js +37 -0
  203. package/src/orchestrator/state-events.js +459 -0
  204. package/src/orchestrator/state-sdk.js +1764 -0
  205. package/src/orchestrator/status-protocol.js +84 -17
  206. package/src/orchestrator/subagent-telemetry.js +452 -0
  207. package/src/orchestrator/termination.js +160 -0
  208. package/src/orchestrator/verification-gate.js +200 -16
  209. package/src/orchestrator/wave-state.js +332 -23
  210. package/src/orchestrator/worktree-provision.js +77 -0
  211. package/src/override-use-registry.js +111 -5
  212. package/src/receipts.js +36 -4
  213. package/src/recovery/checkpoint.js +56 -3
  214. package/src/recovery/code-fixer.js +656 -0
  215. package/src/recovery/truncation.js +317 -0
  216. package/src/redactor.js +75 -6
  217. package/src/runtime-mediator.js +15 -0
  218. package/src/sanitizer.js +10 -0
  219. package/src/search-hybrid.js +139 -0
  220. package/src/server.js +603 -59
  221. package/src/swarm/worktree.js +27 -4
  222. package/src/swarm-config.js +94 -17
  223. package/src/team/domain-templates/book.json +51 -0
  224. package/src/team/domain-templates/business.json +41 -0
  225. package/src/team/domain-templates/content.json +50 -0
  226. package/src/team/domain-templates/design.json +44 -0
  227. package/src/team/domain-templates/research.json +41 -0
  228. package/src/team/domain-templates/software.json +40 -0
  229. package/src/team/generator.js +278 -3
  230. package/src/team/modify.js +203 -0
  231. package/src/team/schemas.js +48 -0
  232. 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,304 @@
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 · Waves</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: 1200px; margin: 0 auto; }
15
+ .layout { display: grid; grid-template-columns: 320px 1fr; gap: 20px; min-height: 500px; }
16
+ .sidebar { background: #fff; border: 1px solid #ddd; border-radius: 6px; overflow: hidden; }
17
+ .sidebar-header { padding: 10px 14px; font-weight: 600; border-bottom: 1px solid #eee; font-size: 13px; color: #555; }
18
+ .wave-list { max-height: 70vh; overflow-y: auto; }
19
+ .wave-row { padding: 10px 14px; border-bottom: 1px solid #f0f0f0; cursor: pointer; display: grid; grid-template-columns: 1fr auto; gap: 4px; }
20
+ .wave-row:hover { background: #f4f8ff; }
21
+ .wave-row.selected { background: #eaf3ff; }
22
+ .wave-row .id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; font-weight: 600; }
23
+ .wave-row .status { font-size: 11px; padding: 1px 6px; border-radius: 3px; background: #eee; color: #555; text-transform: uppercase; align-self: center; }
24
+ .wave-row.status-done .status { background: #d6f5d6; color: #1a6b1a; }
25
+ .wave-row.status-in_progress .status { background: #fff4cc; color: #7a5a00; }
26
+ .wave-row.status-blocked .status { background: #fbd5d5; color: #8a1a1a; }
27
+ .wave-row .ts { grid-column: 1 / -1; font-size: 11px; color: #888; }
28
+ .viewer { background: #fff; padding: 24px 28px; border: 1px solid #ddd; border-radius: 6px; min-height: 500px; }
29
+ .meta { font-size: 12px; color: #666; margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid #eee; }
30
+ .meta .meta-row { display: inline-block; margin-right: 16px; }
31
+ .meta strong { color: #333; }
32
+ .doc h1 { font-size: 22px; margin: 0 0 12px; }
33
+ .doc h2 { font-size: 18px; margin: 24px 0 10px; padding-bottom: 4px; border-bottom: 1px solid #eee; }
34
+ .doc h3 { font-size: 15px; margin: 20px 0 8px; }
35
+ .doc pre { background: #f4f4f4; padding: 12px; border-radius: 4px; overflow-x: auto; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
36
+ .doc code { background: #f4f4f4; padding: 1px 4px; border-radius: 3px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
37
+ .doc pre code { background: transparent; padding: 0; }
38
+ .doc table { border-collapse: collapse; margin: 12px 0; }
39
+ .doc th, .doc td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; font-size: 13px; }
40
+ .doc th { background: #f4f4f4; }
41
+ .doc blockquote { border-left: 3px solid #ccc; padding-left: 12px; color: #666; margin: 12px 0; }
42
+ .err { color: #c00; font-style: italic; }
43
+ .hint { color: #777; font-style: italic; }
44
+ @media (prefers-color-scheme: dark) {
45
+ body { background: #1a1a1a; color: #ddd; }
46
+ header, .sidebar, .viewer { background: #222; border-color: #333; }
47
+ .sidebar-header, .meta { border-color: #2e2e2e; color: #aaa; }
48
+ .wave-row { border-color: #2a2a2a; }
49
+ .wave-row:hover { background: #2a2f3a; }
50
+ .wave-row.selected { background: #2c3548; }
51
+ .wave-row .status { background: #333; color: #ccc; }
52
+ .doc pre, .doc code, .doc th { background: #2a2a2a; }
53
+ }
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <header>
58
+ <h1>IJFW · Waves</h1>
59
+ <div class="sub">Live state of .ijfw/wave-*/STATE.md across the current project.</div>
60
+ </header>
61
+ <main>
62
+ <div class="layout">
63
+ <aside class="sidebar">
64
+ <div class="sidebar-header">Waves (newest first)</div>
65
+ <div class="wave-list" id="waveList"></div>
66
+ </aside>
67
+ <section class="viewer">
68
+ <div class="meta" id="waveMeta"></div>
69
+ <div id="doc" class="doc"></div>
70
+ </section>
71
+ </div>
72
+ </main>
73
+ <script>
74
+ // Reused markdown shim from /planning — produces a safe DOMFragment, no innerHTML.
75
+ // Handles headings, paragraphs, code blocks, inline code/bold/italic, links,
76
+ // lists, blockquotes, tables. Not full CommonMark.
77
+
78
+ function setText(el, text) {
79
+ while (el.firstChild) el.removeChild(el.firstChild);
80
+ el.appendChild(document.createTextNode(text));
81
+ }
82
+ function setStatus(el, text, cls) {
83
+ while (el.firstChild) el.removeChild(el.firstChild);
84
+ const div = document.createElement('div');
85
+ div.className = cls;
86
+ div.textContent = text;
87
+ el.appendChild(div);
88
+ }
89
+
90
+ function makeNode(tag, text) {
91
+ const el = document.createElement(tag);
92
+ if (text !== undefined) el.appendChild(document.createTextNode(text));
93
+ return el;
94
+ }
95
+
96
+ function renderInlineToNodes(s) {
97
+ const nodes = [];
98
+ let i = 0;
99
+ while (i < s.length) {
100
+ if (s[i] === '`') {
101
+ const end = s.indexOf('`', i + 1);
102
+ if (end !== -1) { nodes.push(makeNode('code', s.slice(i + 1, end))); i = end + 1; continue; }
103
+ }
104
+ if (s[i] === '*' && s[i + 1] === '*') {
105
+ const end = s.indexOf('**', i + 2);
106
+ if (end !== -1) { nodes.push(makeNode('strong', s.slice(i + 2, end))); i = end + 2; continue; }
107
+ }
108
+ if (s[i] === '*') {
109
+ const end = s.indexOf('*', i + 1);
110
+ if (end !== -1 && end - i > 1) { nodes.push(makeNode('em', s.slice(i + 1, end))); i = end + 1; continue; }
111
+ }
112
+ if (s[i] === '[') {
113
+ const close = s.indexOf(']', i + 1);
114
+ if (close !== -1 && s[close + 1] === '(') {
115
+ const urlEnd = s.indexOf(')', close + 2);
116
+ if (urlEnd !== -1) {
117
+ const a = document.createElement('a');
118
+ a.textContent = s.slice(i + 1, close);
119
+ // r13-L-01: tightened URL guard. ALLOW http/https + same-origin relative.
120
+ // BLOCK javascript:, data:, mailto:, vbscript:, file:, AND protocol-relative `//`.
121
+ const url = s.slice(close + 2, urlEnd);
122
+ const isAllowed = (
123
+ /^https?:\/\//.test(url) ||
124
+ (!url.startsWith('//') && !/^[^:/?#]+:/.test(url))
125
+ );
126
+ if (isAllowed) { a.href = url; a.target = '_blank'; a.rel = 'noopener'; }
127
+ nodes.push(a);
128
+ i = urlEnd + 1;
129
+ continue;
130
+ }
131
+ }
132
+ }
133
+ let j = i;
134
+ while (j < s.length && !'`*['.includes(s[j])) j++;
135
+ if (j === i) j = i + 1;
136
+ nodes.push(document.createTextNode(s.slice(i, j)));
137
+ i = j;
138
+ }
139
+ return nodes;
140
+ }
141
+
142
+ function renderMarkdownToFragment(md) {
143
+ const frag = document.createDocumentFragment();
144
+ const lines = md.split('\n');
145
+ let i = 0;
146
+ while (i < lines.length) {
147
+ const line = lines[i];
148
+ if (/^```/.test(line)) {
149
+ const buf = []; i++;
150
+ while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; }
151
+ i++;
152
+ const pre = document.createElement('pre');
153
+ const code = document.createElement('code');
154
+ code.textContent = buf.join('\n');
155
+ pre.appendChild(code);
156
+ frag.appendChild(pre);
157
+ continue;
158
+ }
159
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
160
+ if (h) {
161
+ const tag = 'h' + h[1].length;
162
+ const el = document.createElement(tag);
163
+ for (const n of renderInlineToNodes(h[2])) el.appendChild(n);
164
+ frag.appendChild(el); i++; continue;
165
+ }
166
+ if (/^\s*\|/.test(line) && i + 1 < lines.length && /^\s*\|?\s*-/.test(lines[i + 1])) {
167
+ const tbl = document.createElement('table');
168
+ const head = line.split('|').slice(1, -1).map((c) => c.trim());
169
+ const tr = document.createElement('tr');
170
+ for (const c of head) {
171
+ const th = document.createElement('th');
172
+ for (const n of renderInlineToNodes(c)) th.appendChild(n);
173
+ tr.appendChild(th);
174
+ }
175
+ tbl.appendChild(tr); i += 2;
176
+ while (i < lines.length && /^\s*\|/.test(lines[i])) {
177
+ const cells = lines[i].split('|').slice(1, -1).map((c) => c.trim());
178
+ const row = document.createElement('tr');
179
+ for (const c of cells) {
180
+ const td = document.createElement('td');
181
+ for (const n of renderInlineToNodes(c)) td.appendChild(n);
182
+ row.appendChild(td);
183
+ }
184
+ tbl.appendChild(row); i++;
185
+ }
186
+ frag.appendChild(tbl); continue;
187
+ }
188
+ if (/^\s*[-*]\s+/.test(line)) {
189
+ const ul = document.createElement('ul');
190
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
191
+ const li = document.createElement('li');
192
+ for (const n of renderInlineToNodes(lines[i].replace(/^\s*[-*]\s+/, ''))) li.appendChild(n);
193
+ ul.appendChild(li); i++;
194
+ }
195
+ frag.appendChild(ul); continue;
196
+ }
197
+ if (/^>\s?/.test(line)) {
198
+ const bq = document.createElement('blockquote');
199
+ let first = true;
200
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
201
+ if (!first) bq.appendChild(document.createElement('br'));
202
+ for (const n of renderInlineToNodes(lines[i].replace(/^>\s?/, ''))) bq.appendChild(n);
203
+ first = false; i++;
204
+ }
205
+ frag.appendChild(bq); continue;
206
+ }
207
+ if (line.trim() === '') { i++; continue; }
208
+ const p = document.createElement('p');
209
+ const buf = [line]; i++;
210
+ while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,6}\s|```|>|\s*[-*]\s|\s*\|)/.test(lines[i])) {
211
+ buf.push(lines[i]); i++;
212
+ }
213
+ for (const n of renderInlineToNodes(buf.join(' '))) p.appendChild(n);
214
+ frag.appendChild(p);
215
+ }
216
+ return frag;
217
+ }
218
+
219
+ // ---------- wave list + viewer ----------
220
+
221
+ let selectedRow = null;
222
+
223
+ function renderMeta(w) {
224
+ const meta = document.getElementById('waveMeta');
225
+ while (meta.firstChild) meta.removeChild(meta.firstChild);
226
+ const fields = [
227
+ ['Wave', w.id],
228
+ ['Status', w.status],
229
+ ['Created', w.created_at ?? '—'],
230
+ ['Checkpoint', w.checkpoint_at ?? '—'],
231
+ ['Agents', String(w.agents_count ?? 0)],
232
+ ['Active claims', String(w.claims_active ?? 0)],
233
+ ['Open blockers', String(w.blockers_open ?? 0)],
234
+ ];
235
+ for (const [label, val] of fields) {
236
+ const row = document.createElement('span');
237
+ row.className = 'meta-row';
238
+ const lab = document.createElement('strong');
239
+ lab.textContent = label + ': ';
240
+ row.appendChild(lab);
241
+ row.appendChild(document.createTextNode(val));
242
+ meta.appendChild(row);
243
+ }
244
+ }
245
+
246
+ async function loadWaveState(w, row) {
247
+ if (selectedRow) selectedRow.classList.remove('selected');
248
+ if (row) { row.classList.add('selected'); selectedRow = row; }
249
+ renderMeta(w);
250
+ const doc = document.getElementById('doc');
251
+ setStatus(doc, 'Loading…', 'hint');
252
+ try {
253
+ const r = await fetch('/api/planning?path=' + encodeURIComponent(w.path));
254
+ if (!r.ok) {
255
+ const err = await r.json().catch(() => ({ error: 'unknown' }));
256
+ setStatus(doc, r.status + ': ' + (err.error || 'failed'), 'err');
257
+ return;
258
+ }
259
+ const j = await r.json();
260
+ while (doc.firstChild) doc.removeChild(doc.firstChild);
261
+ doc.appendChild(renderMarkdownToFragment(j.body || ''));
262
+ } catch (e) {
263
+ setStatus(doc, e.message, 'err');
264
+ }
265
+ }
266
+
267
+ async function loadWaves() {
268
+ const list = document.getElementById('waveList');
269
+ setStatus(list, 'Loading…', 'hint');
270
+ try {
271
+ const r = await fetch('/api/waves');
272
+ const j = await r.json();
273
+ while (list.firstChild) list.removeChild(list.firstChild);
274
+ const waves = j.waves || [];
275
+ if (waves.length === 0) {
276
+ setStatus(list, 'No waves found in .ijfw/wave-*/', 'hint');
277
+ setStatus(document.getElementById('doc'),
278
+ 'No wave-* directories exist yet. Dispatch a wave via /superpowers:subagent-driven-development to see live state here.',
279
+ 'hint');
280
+ return;
281
+ }
282
+ for (const w of waves) {
283
+ const row = document.createElement('div');
284
+ row.className = 'wave-row status-' + (w.status || 'unknown');
285
+ const id = document.createElement('div'); id.className = 'id'; id.textContent = w.id;
286
+ const status = document.createElement('div'); status.className = 'status'; status.textContent = w.status || 'unknown';
287
+ const ts = document.createElement('div'); ts.className = 'ts'; ts.textContent = w.checkpoint_at ?? (w.created_at ?? '');
288
+ row.appendChild(id); row.appendChild(status); row.appendChild(ts);
289
+ row.addEventListener('click', () => loadWaveState(w, row));
290
+ list.appendChild(row);
291
+ }
292
+ // Auto-select first (newest) wave.
293
+ const first = list.firstChild;
294
+ if (first) loadWaveState(waves[0], first);
295
+ } catch (e) {
296
+ setStatus(list, e.message, 'err');
297
+ }
298
+ }
299
+
300
+ setStatus(document.getElementById('doc'), 'Select a wave from the list to view its STATE.md.', 'hint');
301
+ loadWaves();
302
+ </script>
303
+ </body>
304
+ </html>
@@ -255,6 +255,10 @@ tr:hover td{background:var(--surface)}
255
255
  <div class="breadcrumb" id="breadcrumb"><span style="color:var(--fg-dim)">Overview</span> <b>Today</b></div>
256
256
  <div class="spacer"></div>
257
257
  <span class="tier-pill">MAX 20x</span>
258
+ <!-- v1.5.0 N4.obs M5: cross-link back to the operator dashboard (port
259
+ 19747). Operators flip between MCP (wave/orchestrator views) and
260
+ operator (cost/memory views) so the link is reciprocal. -->
261
+ <a class="icon-btn" id="operatorDashLink" href="http://localhost:19747/" target="_blank" rel="noopener" title="Open operator dashboard (cost + memory views)">Operator dashboard</a>
258
262
  <button class="icon-btn" id="themeBtn" aria-label="Toggle theme">&#9790; Theme</button>
259
263
  </header>
260
264
 
@@ -275,7 +279,7 @@ tr:hover td{background:var(--surface)}
275
279
  <div class="hcard">
276
280
  <div class="hlabel"><span class="pulse"></span> Active Session</div>
277
281
  <div class="hval" style="font-size:20px;padding-top:6px">ijfw</div>
278
- <div class="hsub">claude-opus-4-6 -- <b>current</b></div>
282
+ <div class="hsub">claude-opus-4-7 -- <b>current</b></div>
279
283
  </div>
280
284
  <div class="hcard">
281
285
  <div class="hlabel">Cache Efficiency</div>
@@ -513,6 +513,79 @@ export async function startServer(options = {}) {
513
513
  }
514
514
  }],
515
515
 
516
+ // v1.5.0 S10 — wave-state JSON list. Reads .ijfw/wave-*/STATE.md frontmatter.
517
+ // Sorted by checkpoint_at desc, capped at 50 (any project with >50 active
518
+ // waves has bigger problems). Same security pattern as /api/planning.
519
+ ['/api/waves', async (req, res) => {
520
+ try {
521
+ const ijfwDir = join(REPO_ROOT, '.ijfw');
522
+ if (!existsSync(ijfwDir)) {
523
+ res.writeHead(200, { 'Content-Type': 'application/json' });
524
+ res.end(JSON.stringify({ waves: [] }));
525
+ return;
526
+ }
527
+ const entries = readdirSync(ijfwDir, { withFileTypes: true });
528
+ const { readWaveState } = await import('./orchestrator/wave-state.js');
529
+ const out = [];
530
+ for (const ent of entries) {
531
+ if (!ent.isDirectory() || !ent.name.startsWith('wave-')) continue;
532
+ const waveId = ent.name.slice('wave-'.length);
533
+ if (!waveId || !/^[A-Za-z0-9_-]+$/.test(waveId)) continue;
534
+ try {
535
+ const state = await readWaveState(waveId, REPO_ROOT);
536
+ if (!state) continue;
537
+ out.push({
538
+ id: waveId,
539
+ status: state.frontmatter.status ?? 'unknown',
540
+ created_at: state.frontmatter.created_at ?? null,
541
+ checkpoint_at: state.frontmatter.checkpoint_at ?? null,
542
+ claims_active: state.frontmatter.claims_active ?? 0,
543
+ agents_count: Array.isArray(state.frontmatter.agents) ? state.frontmatter.agents.length : 0,
544
+ path: `.ijfw/wave-${waveId}/STATE.md`,
545
+ });
546
+ } catch { /* skip malformed wave dirs */ }
547
+ }
548
+ out.sort((a, b) => String(b.checkpoint_at || '').localeCompare(String(a.checkpoint_at || '')));
549
+ res.writeHead(200, { 'Content-Type': 'application/json' });
550
+ res.end(JSON.stringify({ waves: out.slice(0, 50) }));
551
+ } catch (err) {
552
+ res.writeHead(500, { 'Content-Type': 'application/json' });
553
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/waves' }));
554
+ }
555
+ }],
556
+
557
+ // v1.5.0 S10 — wave-state viewer (HTML SPA). Same CSP as /planning.
558
+ ['/waves', async (req, res) => {
559
+ try {
560
+ const html = await readFile(join(__dirname, 'dashboard-client-waves.html'), 'utf8');
561
+ res.writeHead(200, {
562
+ 'Content-Type': 'text/html; charset=utf-8',
563
+ 'Cache-Control': 'no-store',
564
+ 'Content-Security-Policy': "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'",
565
+ });
566
+ res.end(html);
567
+ } catch (err) {
568
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
569
+ res.end('Waves viewer not found: ' + err.message);
570
+ }
571
+ }],
572
+
573
+ // v1.5.0 F4 — serve checkpoint-contract.md as plain text so operators can
574
+ // find the implementer-side checkpoint protocol from the dashboard.
575
+ ['/docs/checkpoint-contract', async (req, res) => {
576
+ try {
577
+ const md = await readFile(join(__dirname, 'orchestrator/checkpoint-contract.md'), 'utf8');
578
+ res.writeHead(200, {
579
+ 'Content-Type': 'text/plain; charset=utf-8',
580
+ 'Cache-Control': 'public, max-age=300',
581
+ });
582
+ res.end(md);
583
+ } catch (err) {
584
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
585
+ res.end('Checkpoint contract not found: ' + err.message);
586
+ }
587
+ }],
588
+
516
589
  ['/api/memory/file', (req, res, url) => {
517
590
  const rawPath = url.searchParams.get('path') || '';
518
591
  if (!rawPath) {