@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
@@ -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
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * IJFW design iframe bridge -- optional vercel:vercel-sandbox composition.
3
+ *
4
+ * IJFW core has zero runtime deps and ships a static viewer for design mockups.
5
+ * When the peer `vercel:vercel-sandbox` skill is present (or the user has set
6
+ * `IJFW_VERCEL_SANDBOX_URL` to a provisioner endpoint), this bridge upgrades
7
+ * the static viewer to live iframes running each mockup in an isolated
8
+ * Firecracker microVM via the vercel-sandbox skill.
9
+ *
10
+ * **Every entrypoint graceful-fails.** A missing CLI, an unset env var, a
11
+ * malformed response, or a network error all return null/false rather than
12
+ * throwing. The caller MUST fall back to the static-srcdoc viewer in that case.
13
+ *
14
+ * Why composition over a hard dep: IJFW is a meta-tool. Pinning vercel-sandbox
15
+ * would import sandboxing concerns into IJFW's trust model. Peer-skill detection
16
+ * keeps the boundary clean (and keeps the npm install size at zero).
17
+ */
18
+
19
+ import { spawnSync } from 'node:child_process';
20
+ import { mkdirSync, writeFileSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { randomUUID } from 'node:crypto';
24
+ import http from 'node:http';
25
+ import https from 'node:https';
26
+
27
+ const SANDBOX_URL_ENV = 'IJFW_VERCEL_SANDBOX_URL';
28
+ const PROVISION_TIMEOUT_MS = 15_000;
29
+ const DESTROY_TIMEOUT_MS = 5_000;
30
+
31
+ /** In-process registry of sandbox ids → provisioner URL for destroySandbox(). */
32
+ const _sandboxRegistry = new Map();
33
+
34
+ /**
35
+ * Returns true when EITHER the `vercel` CLI is on PATH OR
36
+ * `IJFW_VERCEL_SANDBOX_URL` env var is set.
37
+ *
38
+ * Cheap. Safe to call repeatedly (a few ms `which` shell-out worst case).
39
+ */
40
+ export function hasVercelSandbox() {
41
+ if (process.env[SANDBOX_URL_ENV]) return true;
42
+ try {
43
+ const which = process.platform === 'win32' ? 'where' : 'which';
44
+ const r = spawnSync(which, ['vercel'], { encoding: 'utf8', timeout: 2_000 });
45
+ if (r.status === 0 && r.stdout && r.stdout.trim()) return true;
46
+ } catch {
47
+ // graceful: missing `which`/`where` is the same as no CLI
48
+ }
49
+ return false;
50
+ }
51
+
52
+ /**
53
+ * Provision a sandbox preview for an HTML mockup. Returns
54
+ * { iframeUrl, sandboxId } on success
55
+ * null when bridge unavailable OR any failure
56
+ *
57
+ * The function is never expected to throw. All errors are logged advisory
58
+ * to stderr so the user understands why fallback kicked in, then null is
59
+ * returned and the caller renders the static-srcdoc viewer.
60
+ *
61
+ * @param {{ html: string, name?: string }} args
62
+ * @returns {Promise<{iframeUrl: string, sandboxId: string} | null>}
63
+ */
64
+ export async function createPreviewSandbox({ html, name } = {}) {
65
+ if (typeof html !== 'string' || !html.trim()) {
66
+ _advise('createPreviewSandbox: html missing -- skipping');
67
+ return null;
68
+ }
69
+ if (!hasVercelSandbox()) return null;
70
+
71
+ const safeName = String(name || 'mockup').replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64) || 'mockup';
72
+ const sandboxId = `ijfw-${safeName}-${randomUUID().slice(0, 8)}`;
73
+
74
+ // Write the html to a temp file so the provisioner can read it.
75
+ let tmpFile = null;
76
+ try {
77
+ const dir = join(tmpdir(), 'ijfw-design-sandboxes');
78
+ mkdirSync(dir, { recursive: true });
79
+ tmpFile = join(dir, `${sandboxId}.html`);
80
+ writeFileSync(tmpFile, html, 'utf8');
81
+ } catch (err) {
82
+ _advise(`createPreviewSandbox: temp write failed -- ${err.message}`);
83
+ return null;
84
+ }
85
+
86
+ // Prefer the env-configured HTTP provisioner when present (test-friendly,
87
+ // matches how the vercel-sandbox MCP skill exposes its provisioning API).
88
+ const url = process.env[SANDBOX_URL_ENV];
89
+ if (url) {
90
+ const result = await _provisionViaHttp(url, { html, name: safeName, sandboxId });
91
+ if (result) {
92
+ _sandboxRegistry.set(sandboxId, { mode: 'http', url });
93
+ return result;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ // Fall back to shell-out to `vercel sandbox` CLI. Best-effort: the CLI
99
+ // surface for vercel-sandbox is evolving; we accept any JSON line that
100
+ // contains a `url` field.
101
+ try {
102
+ const r = spawnSync('vercel', ['sandbox', 'create', '--file', tmpFile, '--name', sandboxId], {
103
+ encoding: 'utf8',
104
+ timeout: PROVISION_TIMEOUT_MS,
105
+ });
106
+ if (r.status !== 0) {
107
+ _advise(`createPreviewSandbox: vercel CLI exit ${r.status} -- falling back to static`);
108
+ return null;
109
+ }
110
+ const iframeUrl = _extractUrl(r.stdout);
111
+ if (!iframeUrl) {
112
+ _advise('createPreviewSandbox: vercel CLI produced no URL -- falling back to static');
113
+ return null;
114
+ }
115
+ _sandboxRegistry.set(sandboxId, { mode: 'cli' });
116
+ return { iframeUrl, sandboxId };
117
+ } catch (err) {
118
+ _advise(`createPreviewSandbox: CLI invocation failed -- ${err.message}`);
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Tear down a sandbox by id. Never throws. Best-effort.
125
+ */
126
+ export async function destroySandbox(sandboxId) {
127
+ if (!sandboxId) return;
128
+ const entry = _sandboxRegistry.get(sandboxId);
129
+ if (!entry) return;
130
+ _sandboxRegistry.delete(sandboxId);
131
+
132
+ try {
133
+ if (entry.mode === 'http') {
134
+ await _httpRequest(
135
+ 'DELETE',
136
+ `${entry.url.replace(/\/$/, '')}/sandboxes/${encodeURIComponent(sandboxId)}`,
137
+ null,
138
+ DESTROY_TIMEOUT_MS,
139
+ );
140
+ return;
141
+ }
142
+ if (entry.mode === 'cli') {
143
+ spawnSync('vercel', ['sandbox', 'delete', sandboxId], { encoding: 'utf8', timeout: DESTROY_TIMEOUT_MS });
144
+ return;
145
+ }
146
+ } catch (err) {
147
+ _advise(`destroySandbox(${sandboxId}): ${err.message}`);
148
+ }
149
+ }
150
+
151
+ // ---------- internals ----------
152
+
153
+ async function _provisionViaHttp(baseUrl, { html, name, sandboxId }) {
154
+ try {
155
+ const payload = JSON.stringify({ html, name, sandboxId });
156
+ const res = await _httpRequest(
157
+ 'POST',
158
+ `${baseUrl.replace(/\/$/, '')}/sandboxes`,
159
+ payload,
160
+ PROVISION_TIMEOUT_MS,
161
+ );
162
+ if (!res || res.status < 200 || res.status >= 300) {
163
+ _advise(`HTTP provisioner returned ${res ? res.status : 'no response'}`);
164
+ return null;
165
+ }
166
+ let body;
167
+ try { body = JSON.parse(res.body); } catch {
168
+ _advise('HTTP provisioner returned non-JSON');
169
+ return null;
170
+ }
171
+ const iframeUrl = body && (body.iframeUrl || body.url);
172
+ if (!iframeUrl) {
173
+ _advise('HTTP provisioner response missing url field');
174
+ return null;
175
+ }
176
+ return { iframeUrl: String(iframeUrl), sandboxId: String(body.sandboxId || sandboxId) };
177
+ } catch (err) {
178
+ _advise(`HTTP provisioner failed -- ${err.message}`);
179
+ return null;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Minimal http(s) client using node:http / node:https. Avoids `fetch`
185
+ * because we want deterministic timeouts and zero-dep behavior on every
186
+ * supported Node version.
187
+ */
188
+ function _httpRequest(method, url, body, timeoutMs) {
189
+ return new Promise((resolve) => {
190
+ try {
191
+ const parsed = new URL(url);
192
+ const mod = parsed.protocol === 'https:' ? https : http;
193
+ const opts = {
194
+ method,
195
+ protocol: parsed.protocol,
196
+ hostname: parsed.hostname,
197
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
198
+ path: parsed.pathname + parsed.search,
199
+ headers: body
200
+ ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }
201
+ : {},
202
+ timeout: timeoutMs,
203
+ };
204
+ const req = mod.request(opts, (res) => {
205
+ const chunks = [];
206
+ res.on('data', (c) => chunks.push(c));
207
+ res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
208
+ res.on('error', () => resolve(null));
209
+ });
210
+ req.on('error', () => resolve(null));
211
+ req.on('timeout', () => {
212
+ try { req.destroy(); } catch {}
213
+ resolve(null);
214
+ });
215
+ if (body) req.write(body);
216
+ req.end();
217
+ } catch {
218
+ resolve(null);
219
+ }
220
+ });
221
+ }
222
+
223
+ function _extractUrl(text) {
224
+ if (!text) return null;
225
+ // JSON-encoded url field
226
+ const jsonMatch = String(text).match(/"(?:iframeUrl|url)"\s*:\s*"(https?:\/\/[^"\s]+)"/);
227
+ if (jsonMatch) return jsonMatch[1];
228
+ // Bare URL printed by the CLI
229
+ const bare = String(text).match(/(https?:\/\/[^\s"]+\.vercel\.app[^\s"]*)/);
230
+ return bare ? bare[1] : null;
231
+ }
232
+
233
+ function _advise(msg) {
234
+ try {
235
+ process.stderr.write(`[ijfw design] ${msg}\n`);
236
+ } catch {
237
+ // never throw from advisory log
238
+ }
239
+ }
240
+
241
+ // Exported for tests
242
+ export const __internals = { _extractUrl, _sandboxRegistry, SANDBOX_URL_ENV };
@@ -6,6 +6,7 @@
6
6
  import { EventEmitter } from 'node:events';
7
7
  import { existsSync, readdirSync, statSync, watch } from 'node:fs';
8
8
  import { join } from 'node:path';
9
+ import { createPreviewSandbox as defaultCreatePreviewSandbox } from './design/iframe-bridge.js';
9
10
 
10
11
  export const PLACEHOLDER_HTML = `<!DOCTYPE html>
11
12
  <html lang="en">
@@ -52,6 +53,149 @@ export function getNewestFile(contentDir) {
52
53
  return newest;
53
54
  }
54
55
 
56
+ /**
57
+ * HTML-escape helper for the viewer codegen. Mirrors the audit-H3.1
58
+ * dashboard `esc()` -- escapes ampersand, angle brackets, double-quote, AND
59
+ * single-quote so output is safe inside single- or double-quoted attributes.
60
+ */
61
+ export function escHtml(s) {
62
+ if (s === null || s === undefined) return '';
63
+ return String(s)
64
+ .replace(/&/g, '&amp;')
65
+ .replace(/</g, '&lt;')
66
+ .replace(/>/g, '&gt;')
67
+ .replace(/"/g, '&quot;')
68
+ .replace(/'/g, '&#39;');
69
+ }
70
+
71
+ /**
72
+ * Build a tabbed viewer for a list of HTML mockups.
73
+ *
74
+ * Each mockup may carry an optional `iframeUrl` (provisioned via the
75
+ * `vercel:vercel-sandbox` peer skill). When the URL is present the viewer
76
+ * renders a live `<iframe src="...">` running in an isolated Firecracker
77
+ * microVM. When absent, the viewer falls back to a static `<iframe srcdoc>`
78
+ * with the html inlined. Either way the iframe carries
79
+ * `sandbox="allow-scripts"` to prevent top-window escape (v1.5.0 Trident r19
80
+ * dropped allow-same-origin; the combination is a documented MDN sandbox
81
+ * escape — JS still runs in the mockup but the embedded document can't reach
82
+ * window.parent to remove its own sandbox attribute).
83
+ *
84
+ * All user-controlled strings (mockup name, iframe url) flow through
85
+ * `escHtml()` -- the same pattern dashboard `esc()` uses post-audit-H3.1.
86
+ *
87
+ * @param {{ mockups: Array<{name: string, html?: string, iframeUrl?: string|null}>, title?: string }} args
88
+ * @returns {string} HTML document for the viewer.
89
+ */
90
+ export function buildMockupViewer({ mockups = [], title = 'IJFW Design Mockups' } = {}) {
91
+ const items = Array.isArray(mockups) ? mockups : [];
92
+ const safeTitle = escHtml(title);
93
+
94
+ const tabs = items
95
+ .map((m, i) => {
96
+ const name = escHtml(m && m.name ? m.name : `mockup-${i + 1}`);
97
+ return `<button class="tab" data-i="${i}" ${i === 0 ? 'aria-selected="true"' : ''}>${name}</button>`;
98
+ })
99
+ .join('');
100
+
101
+ const panes = items
102
+ .map((m, i) => {
103
+ const name = escHtml(m && m.name ? m.name : `mockup-${i + 1}`);
104
+ const isLive = m && typeof m.iframeUrl === 'string' && m.iframeUrl;
105
+ // v1.5.0 Trident r19 fix: drop allow-same-origin. With both allow-scripts
106
+ // AND allow-same-origin set, the embedded document can programmatically
107
+ // remove the sandbox attribute via window.parent.document (MDN sandbox
108
+ // escape). allow-scripts alone keeps the mockup dynamic while preventing
109
+ // any cross-origin reach into the host viewer.
110
+ const inner = isLive
111
+ ? `<iframe class="preview" src="${escHtml(m.iframeUrl)}" title="${name}" sandbox="allow-scripts" loading="lazy"></iframe>`
112
+ : `<iframe class="preview" srcdoc="${escHtml(m && m.html ? m.html : '<!doctype html><meta charset=utf-8><p>(no preview)</p>')}" title="${name}" sandbox="allow-scripts" loading="lazy"></iframe>`;
113
+ const badge = isLive
114
+ ? '<span class="badge live" title="Provisioned via vercel:vercel-sandbox">LIVE</span>'
115
+ : '<span class="badge static" title="Static srcdoc preview -- install vercel CLI or set IJFW_VERCEL_SANDBOX_URL for live sandbox">STATIC</span>';
116
+ return `<section class="pane" data-i="${i}" ${i === 0 ? '' : 'hidden'}>
117
+ <header class="phead">${name} ${badge}</header>
118
+ ${inner}
119
+ </section>`;
120
+ })
121
+ .join('\n');
122
+
123
+ return `<!DOCTYPE html>
124
+ <html lang="en">
125
+ <head>
126
+ <meta charset="UTF-8">
127
+ <meta name="viewport" content="width=device-width,initial-scale=1">
128
+ <title>${safeTitle}</title>
129
+ <style>
130
+ *{box-sizing:border-box;margin:0;padding:0}
131
+ body{background:#0f172a;color:#e2e8f0;font-family:system-ui,-apple-system,sans-serif;min-height:100vh;display:flex;flex-direction:column}
132
+ .tabs{display:flex;flex-wrap:wrap;gap:4px;padding:8px;background:#1e293b;border-bottom:1px solid #334155}
133
+ .tab{background:#334155;color:#e2e8f0;border:1px solid #475569;border-radius:6px;padding:6px 12px;cursor:pointer;font-size:13px}
134
+ .tab[aria-selected="true"]{background:#0369a1;border-color:#0ea5e9}
135
+ .pane{flex:1;display:flex;flex-direction:column;min-height:0}
136
+ .phead{padding:6px 12px;background:#0f172a;border-bottom:1px solid #1e293b;font-size:12px;color:#94a3b8;display:flex;gap:8px;align-items:center}
137
+ .badge{font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;letter-spacing:.04em}
138
+ .badge.live{background:#15803d;color:#fff}
139
+ .badge.static{background:#475569;color:#cbd5e1}
140
+ .preview{flex:1;width:100%;border:0;background:#fff;min-height:400px}
141
+ </style>
142
+ </head>
143
+ <body>
144
+ <nav class="tabs" role="tablist">${tabs || '<span style="color:#64748b;padding:6px 12px">No mockups yet.</span>'}</nav>
145
+ ${panes}
146
+ <script>
147
+ (function(){
148
+ var tabs = document.querySelectorAll('.tab');
149
+ var panes = document.querySelectorAll('.pane');
150
+ tabs.forEach(function(t){
151
+ t.addEventListener('click', function(){
152
+ var i = t.getAttribute('data-i');
153
+ tabs.forEach(function(x){ x.setAttribute('aria-selected', x === t ? 'true' : 'false'); });
154
+ panes.forEach(function(p){
155
+ if (p.getAttribute('data-i') === i) p.removeAttribute('hidden'); else p.setAttribute('hidden','');
156
+ });
157
+ });
158
+ });
159
+ })();
160
+ </script>
161
+ </body>
162
+ </html>`;
163
+ }
164
+
165
+ /**
166
+ * Provision per-mockup iframes via the vercel-sandbox bridge when available,
167
+ * then render the tabbed viewer. Falls back to static `<iframe srcdoc>` for
168
+ * any mockup whose provisioning failed (or for all of them if the bridge
169
+ * is unavailable).
170
+ *
171
+ * @param {object} args
172
+ * @param {Array<{name: string, html: string}>} args.mockups Mockup inputs.
173
+ * @param {Function} [args.createSandbox] Override for the bridge (test seam).
174
+ * Should match the createPreviewSandbox signature.
175
+ * @param {string} [args.title]
176
+ * @returns {Promise<{html: string, sandboxIds: string[]}>}
177
+ */
178
+ export async function renderMockupViewerWithBridge({ mockups = [], createSandbox, title } = {}) {
179
+ const fn = typeof createSandbox === 'function' ? createSandbox : defaultCreatePreviewSandbox;
180
+ const enriched = [];
181
+ const sandboxIds = [];
182
+ for (const m of mockups) {
183
+ let iframeUrl = null;
184
+ try {
185
+ const r = await fn({ html: m.html, name: m.name });
186
+ if (r && r.iframeUrl) {
187
+ iframeUrl = r.iframeUrl;
188
+ if (r.sandboxId) sandboxIds.push(r.sandboxId);
189
+ }
190
+ } catch {
191
+ // bridge promised never to throw; this catch is defense-in-depth.
192
+ iframeUrl = null;
193
+ }
194
+ enriched.push({ name: m.name, html: m.html, iframeUrl });
195
+ }
196
+ return { html: buildMockupViewer({ mockups: enriched, title }), sandboxIds };
197
+ }
198
+
55
199
  /**
56
200
  * Watches contentDir for new/changed .html files.
57
201
  * Returns an EventEmitter that emits 'new-content' (with the file path) on change.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * dispatch/checkpoint-cli.js — IJFW v1.5.0 / S1 subagent checkpoint CLI.
3
+ *
4
+ * Frozen export contract (v1.4.3 dispatch module convention):
5
+ * export const handlers = { '<subcommand>': async (args, ctx) => ({ ok, output?, error? }) };
6
+ * export const subcommandHelp = { '<subcommand>': 'one-line description' };
7
+ *
8
+ * Subcommands owned by this module:
9
+ * - checkpoint <waveId> <subId> <jsonPayload>
10
+ *
11
+ * Writes via mcp-server/src/orchestrator/subagent-telemetry.js (W11-A0).
12
+ * Used by implementer subagents to persist progress before the Claude Code
13
+ * harness ~20-tool / 60s wall-clock cap fires (v1.5.0 S1 — closes 8/13
14
+ * truncation pattern from v1.4.4 Wave 10 + v1.5.0 research).
15
+ *
16
+ * Wire-up into extension.js is handled by orchestrator post-Wave-11-A.
17
+ */
18
+
19
+ import { recordCheckpoint } from '../orchestrator/subagent-telemetry.js';
20
+
21
+ function tokenize(args) {
22
+ if (Array.isArray(args)) return args.filter((x) => x !== undefined && x !== null);
23
+ if (typeof args !== 'string') return [];
24
+ // Checkpoint args are: <waveId> <subId> <jsonPayload>.
25
+ // The JSON payload may contain spaces — split into at most 3 tokens so the
26
+ // payload survives intact regardless of internal whitespace.
27
+ const trimmed = args.trim();
28
+ if (!trimmed) return [];
29
+ const firstSpace = trimmed.indexOf(' ');
30
+ if (firstSpace === -1) return [trimmed];
31
+ const secondSpace = trimmed.indexOf(' ', firstSpace + 1);
32
+ if (secondSpace === -1) return [trimmed.slice(0, firstSpace), trimmed.slice(firstSpace + 1)];
33
+ return [
34
+ trimmed.slice(0, firstSpace),
35
+ trimmed.slice(firstSpace + 1, secondSpace),
36
+ trimmed.slice(secondSpace + 1),
37
+ ];
38
+ }
39
+
40
+ async function handleCheckpoint(args, ctx) {
41
+ const tokens = tokenize(args);
42
+ const [waveId, subId, payloadJson] = tokens;
43
+ if (!waveId || !subId || !payloadJson) {
44
+ return {
45
+ ok: false,
46
+ error: 'Usage: ijfw checkpoint <waveId> <subId> <jsonPayload>',
47
+ };
48
+ }
49
+
50
+ let payload;
51
+ try {
52
+ payload = JSON.parse(payloadJson);
53
+ } catch (err) {
54
+ return {
55
+ ok: false,
56
+ error: `ijfw checkpoint: invalid JSON payload — ${err.message}`,
57
+ };
58
+ }
59
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
60
+ return {
61
+ ok: false,
62
+ error: 'ijfw checkpoint: JSON payload must be a JSON object (not array/null/scalar)',
63
+ };
64
+ }
65
+
66
+ const projectRoot = (ctx && ctx.projectRoot) || process.cwd();
67
+
68
+ // v1.5.0-major S01: log the effective root (parent vs worktree) to stderr for
69
+ // debugging worktree-mode checkpoint visibility issues.
70
+ const effectiveRoot = process.env.IJFW_PARENT_PROJECT_ROOT ?? projectRoot;
71
+ try {
72
+ process.stderr.write(
73
+ `ijfw checkpoint: writing to ${effectiveRoot}/.ijfw/wave-${waveId}/\n`,
74
+ );
75
+ } catch {
76
+ // stderr write failure must never break the checkpoint path
77
+ }
78
+
79
+ try {
80
+ await recordCheckpoint(waveId, subId, payload, projectRoot);
81
+ return { ok: true, output: `ok: wrote checkpoint for ${waveId}/${subId}` };
82
+ } catch (err) {
83
+ return {
84
+ ok: false,
85
+ error: `ijfw checkpoint: ${err && err.message ? err.message : String(err)}`,
86
+ };
87
+ }
88
+ }
89
+
90
+ export const handlers = Object.freeze({
91
+ checkpoint: handleCheckpoint,
92
+ });
93
+
94
+ export const subcommandHelp = Object.freeze({
95
+ checkpoint:
96
+ 'checkpoint <waveId> <subId> <jsonPayload> — record a subagent checkpoint (v1.5.0 S1)',
97
+ });