@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
@@ -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>
@@ -1402,7 +1406,18 @@ async function loadExtensionActive() {
1402
1406
  permsEl.setAttribute('style', 'font-size:12px;color:var(--fg-dim)');
1403
1407
  var reads = (a.permissions.reads || []).join(', ') || 'none';
1404
1408
  var writes = (a.permissions.writes || []).join(', ') || 'none';
1405
- permsEl.innerHTML = '<b style="color:var(--fg)">reads:</b> ' + reads + ' &nbsp; <b style="color:var(--fg)">writes:</b> ' + writes;
1409
+ var bReads = document.createElement('b');
1410
+ bReads.setAttribute('style', 'color:var(--fg)');
1411
+ bReads.textContent = 'reads:';
1412
+ var textReads = document.createTextNode(' ' + reads + '\u00A0\u00A0');
1413
+ var bWrites = document.createElement('b');
1414
+ bWrites.setAttribute('style', 'color:var(--fg)');
1415
+ bWrites.textContent = 'writes:';
1416
+ var textWrites = document.createTextNode(' ' + writes);
1417
+ permsEl.appendChild(bReads);
1418
+ permsEl.appendChild(textReads);
1419
+ permsEl.appendChild(bWrites);
1420
+ permsEl.appendChild(textWrites);
1406
1421
  wrap.appendChild(permsEl);
1407
1422
  }
1408
1423
  el.appendChild(wrap);
@@ -434,6 +434,158 @@ export async function startServer(options = {}) {
434
434
  }));
435
435
  }],
436
436
 
437
+ // v1.4.4 N8 — Planning doc viewer. Same path-traversal guard as /api/memory/file,
438
+ // but allowed roots are .planning/, .ijfw/memory/, and .ijfw/wave-*/ all under REPO_ROOT.
439
+ ['/api/planning', (req, res, url) => {
440
+ const rawPath = url.searchParams.get('path') || '';
441
+ if (!rawPath) {
442
+ res.writeHead(400, { 'Content-Type': 'application/json' });
443
+ res.end(JSON.stringify({ error: 'path query param required' }));
444
+ return;
445
+ }
446
+ if (isAbsolute(rawPath) || rawPath.split(/[\\/]/).some((seg) => seg === '..')) {
447
+ res.writeHead(400, { 'Content-Type': 'application/json' });
448
+ res.end(JSON.stringify({ error: 'path traversal not allowed' }));
449
+ return;
450
+ }
451
+ const reqPath = resolve(REPO_ROOT, rawPath);
452
+ function canonOrNull(p) {
453
+ try { return realpathSync(p); } catch { return null; }
454
+ }
455
+ function isUnder(allowedRoot, canonChild) {
456
+ const canonRoot = canonOrNull(allowedRoot);
457
+ if (!canonRoot || !canonChild) return false;
458
+ const rel = relative(canonRoot, canonChild);
459
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
460
+ }
461
+ function isUnderWaveRoot(canonChild) {
462
+ // r13-M-05: restrict to STATE.md / SUMMARY.md filenames within
463
+ // .ijfw/wave-*/ subdirs. Previously allowed ANY file in a wave dir;
464
+ // wave directories may contain .tmp, lock files, or partial blackboard
465
+ // data that shouldn't be browser-readable.
466
+ const ijfwDir = canonOrNull(join(REPO_ROOT, '.ijfw'));
467
+ if (!ijfwDir || !canonChild) return false;
468
+ const rel = relative(ijfwDir, canonChild);
469
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) return false;
470
+ const first = rel.split(/[\\/]/)[0];
471
+ if (!first.startsWith('wave-') || first.length === 'wave-'.length) return false;
472
+ return rel.endsWith('STATE.md') || rel.endsWith('SUMMARY.md');
473
+ }
474
+ const canonPath = canonOrNull(reqPath);
475
+ if (!canonPath) {
476
+ res.writeHead(404, { 'Content-Type': 'application/json' });
477
+ res.end(JSON.stringify({ error: 'file not found' }));
478
+ return;
479
+ }
480
+ const allowed = (
481
+ isUnder(join(REPO_ROOT, '.planning'), canonPath) ||
482
+ isUnder(join(REPO_ROOT, '.ijfw', 'memory'), canonPath) ||
483
+ isUnderWaveRoot(canonPath)
484
+ );
485
+ if (!allowed) {
486
+ res.writeHead(403, { 'Content-Type': 'application/json' });
487
+ res.end(JSON.stringify({ error: 'outside allowed planning roots' }));
488
+ return;
489
+ }
490
+ try {
491
+ const body = readFileSync(canonPath, 'utf8');
492
+ res.writeHead(200, { 'Content-Type': 'application/json' });
493
+ res.end(JSON.stringify({ body: body.slice(0, 200000), path: rawPath }));
494
+ } catch (err) {
495
+ res.writeHead(500, { 'Content-Type': 'application/json' });
496
+ res.end(JSON.stringify({ error: err.message, endpoint: '/api/planning' }));
497
+ }
498
+ }],
499
+
500
+ // v1.4.4 N8 — Planning-docs viewer (HTML SPA).
501
+ ['/planning', async (req, res) => {
502
+ try {
503
+ const html = await readFile(join(__dirname, 'dashboard-client-planning.html'), 'utf8');
504
+ res.writeHead(200, {
505
+ 'Content-Type': 'text/html; charset=utf-8',
506
+ 'Cache-Control': 'no-store',
507
+ 'Content-Security-Policy': "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'",
508
+ });
509
+ res.end(html);
510
+ } catch (err) {
511
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
512
+ res.end('Planning viewer not found: ' + err.message);
513
+ }
514
+ }],
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
+
437
589
  ['/api/memory/file', (req, res, url) => {
438
590
  const rawPath = url.searchParams.get('path') || '';
439
591
  if (!rawPath) {
@@ -0,0 +1,150 @@
1
+ /**
2
+ * deploy-alerts.js — v1.5.0 audit-MED-update-M8 (F-REL-2).
3
+ *
4
+ * When `extension-installer.installExtension` exits with `deploy_partial: true`,
5
+ * the failure detail used to be returned in the install reply only — the next
6
+ * prelude had no way to surface "you have a half-deployed extension somewhere".
7
+ *
8
+ * This module persists each partial deploy to a jsonl tail at
9
+ * `~/.ijfw/state/deploy-failures.jsonl` so the memory prelude (handlePrelude in
10
+ * `server.js`) can read the last N entries and emit a "Deploy alerts" line.
11
+ *
12
+ * File contract:
13
+ * - JSONL, one record per line.
14
+ * - Each record:
15
+ * {
16
+ * ts: ISO8601,
17
+ * extension: <manifest.name>,
18
+ * scope: 'project' | 'org' | 'user',
19
+ * failures: Array<{platform, skillName, error}>,
20
+ * }
21
+ * - Soft cap: 200 lines. Older lines drop off via a one-shot trim on write.
22
+ * - Append-only and atomic in the common case (single writeFile call); the
23
+ * trim path rewrites the whole tail under the same atomic shape.
24
+ * - Failure to write is non-fatal — alert path is best-effort observability.
25
+ *
26
+ * The reader is bounded at N=10 by default — short-tail "what's wrong right
27
+ * now" surfacing, not an audit log.
28
+ */
29
+
30
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
31
+ import { homedir } from 'node:os';
32
+ import { join } from 'node:path';
33
+
34
+ const ALERT_FILE_NAME = 'deploy-failures.jsonl';
35
+ const MAX_LINES_ON_DISK = 200;
36
+ const DEFAULT_READ_TAIL = 10;
37
+
38
+ function statePath() {
39
+ return join(homedir(), '.ijfw', 'state');
40
+ }
41
+
42
+ export function deployFailuresPath() {
43
+ return join(statePath(), ALERT_FILE_NAME);
44
+ }
45
+
46
+ /**
47
+ * Record a partial-deploy event.
48
+ *
49
+ * @param {object} record
50
+ * @param {string} record.extension
51
+ * @param {'project'|'org'|'user'} record.scope
52
+ * @param {Array<{platform:string, skillName?:string, error:string}>} record.failures
53
+ * @returns {Promise<{ok:boolean, path?:string, error?:string}>}
54
+ */
55
+ export async function recordDeployFailure(record) {
56
+ if (!record || typeof record !== 'object') {
57
+ return { ok: false, error: 'record must be an object' };
58
+ }
59
+ if (typeof record.extension !== 'string' || record.extension.length === 0) {
60
+ return { ok: false, error: 'extension is required' };
61
+ }
62
+ if (!Array.isArray(record.failures)) {
63
+ return { ok: false, error: 'failures must be an array' };
64
+ }
65
+
66
+ const entry = {
67
+ ts: new Date().toISOString(),
68
+ extension: record.extension,
69
+ scope: record.scope || 'project',
70
+ failures: record.failures.map((f) => ({
71
+ // v1.5.0 wire-W2.design-misc — was `typeof f && f.platform`, which is
72
+ // effectively `f.platform` because `typeof f` is always a truthy string
73
+ // (even for null/undefined). That meant a null entry in the failures
74
+ // array threw a TypeError instead of falling back to 'unknown'. The
75
+ // adjacent skillName + error fields already use the correct guard.
76
+ platform: f && f.platform ? String(f.platform) : 'unknown',
77
+ skillName: f && f.skillName ? String(f.skillName) : null,
78
+ error: f && f.error ? String(f.error).slice(0, 500) : 'unknown',
79
+ })),
80
+ };
81
+
82
+ const path = deployFailuresPath();
83
+ try {
84
+ await mkdir(statePath(), { recursive: true });
85
+ // Trim-on-overflow: if existing file already > cap, rewrite the tail.
86
+ let existing = '';
87
+ try {
88
+ existing = await readFile(path, 'utf8');
89
+ } catch {
90
+ existing = '';
91
+ }
92
+ const lines = existing ? existing.split('\n').filter((l) => l.trim()) : [];
93
+ lines.push(JSON.stringify(entry));
94
+ const trimmed = lines.slice(-MAX_LINES_ON_DISK);
95
+ await writeFile(path, trimmed.join('\n') + '\n', { encoding: 'utf8', mode: 0o600 });
96
+ return { ok: true, path };
97
+ } catch (err) {
98
+ return { ok: false, error: err && err.message ? err.message : String(err) };
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Read the last N deploy-failure records (default 10). Returns oldest-first.
104
+ *
105
+ * @param {{limit?:number}} [opts]
106
+ * @returns {Promise<Array>}
107
+ */
108
+ export async function readDeployFailures(opts = {}) {
109
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : DEFAULT_READ_TAIL;
110
+ let raw;
111
+ try {
112
+ raw = await readFile(deployFailuresPath(), 'utf8');
113
+ } catch {
114
+ return [];
115
+ }
116
+ const lines = raw.split('\n').filter((l) => l.trim());
117
+ const tail = lines.slice(-limit);
118
+ const out = [];
119
+ for (const line of tail) {
120
+ try {
121
+ const parsed = JSON.parse(line);
122
+ if (parsed && typeof parsed === 'object') out.push(parsed);
123
+ } catch {
124
+ // skip malformed line — don't fail the read
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /**
131
+ * Render the last N entries as terse prelude lines. Empty array → empty string.
132
+ *
133
+ * @param {{limit?:number}} [opts]
134
+ * @returns {Promise<string>}
135
+ */
136
+ export async function renderDeployAlertsForPrelude(opts = {}) {
137
+ const entries = await readDeployFailures(opts);
138
+ if (entries.length === 0) return '';
139
+ const lines = ['## Deploy alerts'];
140
+ for (const e of entries) {
141
+ const fcount = Array.isArray(e.failures) ? e.failures.length : 0;
142
+ const platforms = Array.isArray(e.failures)
143
+ ? Array.from(new Set(e.failures.map((f) => f.platform).filter(Boolean))).join(',')
144
+ : '';
145
+ const head = `- ${e.ts} — ${e.extension} (scope=${e.scope || 'project'}): ${fcount} failure${fcount === 1 ? '' : 's'}${platforms ? ` [${platforms}]` : ''}`;
146
+ lines.push(head);
147
+ }
148
+ lines.push('');
149
+ return lines.join('\n');
150
+ }