@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
@@ -47,6 +47,11 @@ import { dirname, join } from 'path';
47
47
  // so the user-facing spelling 'domain-manifest:<op>' (matching the error
48
48
  // message and CLI surface) parses + routes uniformly. The set entry is the
49
49
  // hyphenated form so copy-paste from the error string Just Works.
50
+ // v1.5.0 (T12): added 'state' — the state-SDK CLI face. Routes
51
+ // `state:<verb> <json-payload>` straight into orchestrator/state-sdk.js
52
+ // `query(verb, payload, ctx)`. This is the external-tooling surface for the
53
+ // state-SDK; the JS module is the in-process surface and the MCP tool is the
54
+ // remote surface (contract §0).
50
55
  const RUN_NAMESPACES = new Set([
51
56
  'compute',
52
57
  'index',
@@ -55,6 +60,7 @@ const RUN_NAMESPACES = new Set([
55
60
  'override',
56
61
  'extension',
57
62
  'domain-manifest',
63
+ 'state',
58
64
  ]);
59
65
  const SEARCH_NAMESPACES = new Set(['compute', 'graph']);
60
66
 
@@ -166,13 +172,87 @@ export async function dispatchRun(parsed, ctx = {}) {
166
172
  const m = await import('./domain-manifest.js');
167
173
  return m.domainManifestDispatch({ command: parsed.command, args: parsed.args, projectRoot });
168
174
  }
175
+ if (parsed.namespace === 'state') {
176
+ return dispatchState(parsed, { projectRoot, sessionId });
177
+ }
169
178
 
170
179
  return {
171
180
  ok: false,
172
- error: 'Unknown ijfw_run sub-command. Supported: compute:python, compute:js, index:<source>, detect:project_type, graph:traverse, override:<op>, extension:<op>, domain-manifest:<op>',
181
+ error: 'Unknown ijfw_run sub-command. Supported: compute:python, compute:js, index:<source>, detect:project_type, graph:traverse, override:<op>, extension:<op>, domain-manifest:<op>, state:<verb>',
173
182
  };
174
183
  }
175
184
 
185
+ // --- state:<verb> dispatch -------------------------------------------------
186
+ //
187
+ // T12: external tooling reaches the state-SDK via the CLI colon-namespace.
188
+ // `state:<verb> [json-payload]` parses the payload as JSON, then calls
189
+ // orchestrator/state-sdk.js `query(verb, payload, ctx)`. The verb name is the
190
+ // part after `state:` (e.g. `workflow.get`, `wave.advance`). The payload
191
+ // defaults to `{}` when no args are supplied — read-only verbs like
192
+ // `workflow.get` work with an empty payload.
193
+ //
194
+ // Errors come back as `{ ok: false, error, code }` so callers (cli-run.js,
195
+ // shell hooks) can JSON.parse the stdout uniformly. Unknown verbs throw
196
+ // inside `query()` and the throw is caught + surfaced with `code: 'UNKNOWN_VERB'`.
197
+ async function dispatchState(parsed, { projectRoot, sessionId }) {
198
+ const verb = parsed.command;
199
+ if (!verb) {
200
+ return { ok: false, error: 'state:<verb> requires a verb after the colon.', code: 'NO_VERB' };
201
+ }
202
+
203
+ // Parse the JSON payload. Empty args -> `{}`. The colon-syntax parser
204
+ // already strips a single matching outer quote pair so callers can use
205
+ // either `state:foo '{"k":"v"}'` (shell-style) or `state:foo {"k":"v"}`
206
+ // (already-stripped) and reach the same handler.
207
+ const raw = String(parsed.args || '').trim();
208
+ let payload = {};
209
+ if (raw.length > 0) {
210
+ try {
211
+ payload = JSON.parse(raw);
212
+ } catch (err) {
213
+ return {
214
+ ok: false,
215
+ error: `state:${verb} payload is not valid JSON: ${err && err.message ? err.message : String(err)}`,
216
+ code: 'INVALID_JSON',
217
+ };
218
+ }
219
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
220
+ return {
221
+ ok: false,
222
+ error: `state:${verb} payload must be a JSON object, got ${Array.isArray(payload) ? 'array' : typeof payload}.`,
223
+ code: 'INVALID_JSON',
224
+ };
225
+ }
226
+ }
227
+
228
+ // Lazy-import the SDK so this module stays cheap for the non-state
229
+ // dispatch paths (e.g. compute:python) and so a state-sdk init error
230
+ // surfaces as a structured ok:false instead of a top-level module crash.
231
+ let query;
232
+ try {
233
+ ({ query } = await import('../orchestrator/state-sdk.js'));
234
+ } catch (err) {
235
+ return {
236
+ ok: false,
237
+ error: `state:${verb} could not load state-sdk: ${err && err.message ? err.message : String(err)}`,
238
+ code: 'SDK_LOAD',
239
+ };
240
+ }
241
+
242
+ try {
243
+ const result = await query(verb, payload, { projectRoot, sessionId });
244
+ return result;
245
+ } catch (err) {
246
+ const msg = err && err.message ? err.message : String(err);
247
+ const isUnknownVerb = /unknown verb/i.test(msg);
248
+ return {
249
+ ok: false,
250
+ error: `state:${verb} did not complete: ${msg}`,
251
+ code: isUnknownVerb ? 'UNKNOWN_VERB' : (err && err.code) || 'ERROR',
252
+ };
253
+ }
254
+ }
255
+
176
256
  async function dispatchCompute(parsed, { projectRoot, sessionId /*, provenance unused for compute runs */ }) {
177
257
  const cmd = parsed.command;
178
258
  if (cmd !== 'js' && cmd !== 'python') {
@@ -17,6 +17,26 @@
17
17
  * current project's platform dirs. Fired by the
18
18
  * session-start hook so org/user-scoped extensions
19
19
  * become available in every project session.
20
+ *
21
+ * TODO(v1.5.0-major S01 — IJFW_PARENT_PROJECT_ROOT env passthrough):
22
+ * The Agent({ isolation: 'worktree' }) spawn path lives in the Claude Code
23
+ * harness (Task tool / SDK), NOT in this MCP server's dispatch flow. When the
24
+ * harness eventually exposes a hook for env passthrough on worktree dispatch,
25
+ * the dispatcher MUST set:
26
+ *
27
+ * IJFW_PARENT_PROJECT_ROOT=<absolute path to the orchestrator's projectRoot>
28
+ *
29
+ * so that the subagent's `ijfw checkpoint` writes land in the PARENT project's
30
+ * .ijfw/wave-<id>/ instead of the disposable worktree's .ijfw/. Until that
31
+ * harness hook exists, the contract is documented in
32
+ * `mcp-server/src/orchestrator/checkpoint-contract.md` ("Worktree isolation
33
+ * drain protocol") and the orchestrator MUST run `ijfw worktree-drain
34
+ * <waveId> <worktreePath>` BEFORE `git worktree remove` as a belt-and-
35
+ * suspenders fallback (see dispatch/wave-cli.js worktree-drain handler).
36
+ *
37
+ * Subagent-side: any agent template that may run in worktree isolation should
38
+ * read `process.env.IJFW_PARENT_PROJECT_ROOT` (already honored transparently
39
+ * by orchestrator/subagent-telemetry.js record/read/list).
20
40
  */
21
41
 
22
42
  import {
@@ -613,12 +633,14 @@ async function cmdDeactivate() {
613
633
  let _v143Handlers = null;
614
634
  async function loadV143Handlers() {
615
635
  if (_v143Handlers !== null) return _v143Handlers;
616
- const [registry, signer, quota, active, wave] = await Promise.all([
636
+ const [registry, signer, quota, active, wave, checkpoint, worktree] = await Promise.all([
617
637
  import('./registry-cli.js'),
618
638
  import('./signer-cli.js'),
619
639
  import('./quota-cli.js'),
620
640
  import('./active-cli.js'),
621
- import('./wave-cli.js'), // v1.4.4 N9 — wave-status / wave-list
641
+ import('./wave-cli.js'), // v1.4.4 N9 — wave-status / wave-list
642
+ import('./checkpoint-cli.js'), // v1.5.0 W11-A1 — ijfw checkpoint (S1)
643
+ import('./worktree-cli.js'), // v1.5.0 W11-A2 — ijfw worktree provision (S2)
622
644
  ]);
623
645
  _v143Handlers = Object.assign(
624
646
  Object.create(null),
@@ -627,6 +649,8 @@ async function loadV143Handlers() {
627
649
  quota.handlers || {},
628
650
  active.handlers || {},
629
651
  wave.handlers || {},
652
+ checkpoint.handlers || {},
653
+ worktree.handlers || {},
630
654
  );
631
655
  return _v143Handlers;
632
656
  }
@@ -28,6 +28,8 @@ import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
28
28
  import { join } from 'node:path';
29
29
  import { homedir as osHomedir } from 'node:os';
30
30
  import { createPublicKey } from 'node:crypto';
31
+ // v1.5.0 audit-LOW-update-#13: shared tmp-suffix helper.
32
+ import { tmpSuffix } from '../lib/tmp-suffix.js';
31
33
 
32
34
  import {
33
35
  loadRegistrySources,
@@ -70,7 +72,8 @@ async function readRegistriesFile(ctx) {
70
72
  async function writeRegistriesFile(ctx, doc) {
71
73
  const path = registriesConfigPath(ctx);
72
74
  await mkdir(join(homedir(ctx), '.ijfw'), { recursive: true });
73
- const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
75
+ // v1.5.0 audit-LOW-update-#13: consolidate tmp-suffix shape via helper.
76
+ const tmp = `${path}.tmp.${tmpSuffix()}`;
74
77
  await writeFile(tmp, JSON.stringify(doc, null, 2) + '\n', 'utf8');
75
78
  const { rename } = await import('node:fs/promises');
76
79
  await rename(tmp, path);
@@ -13,10 +13,11 @@
13
13
  * snapshot-based per lock-in #31 — no daemon, no subscriptions.
14
14
  */
15
15
 
16
- import { readdir, stat } from 'node:fs/promises';
16
+ import { readdir, stat, readFile, writeFile, mkdir } from 'node:fs/promises';
17
17
  import { join } from 'node:path';
18
18
 
19
19
  import { readWaveState } from '../orchestrator/wave-state.js';
20
+ import { drainCheckpoints } from '../orchestrator/subagent-telemetry.js';
20
21
 
21
22
  const WAVE_DIR_PREFIX = 'wave-';
22
23
 
@@ -111,18 +112,212 @@ export const handlers = {
111
112
  return { ok: true, output: '(no waves)' };
112
113
  }
113
114
  waves.sort((a, b) => b.mtimeMs - a.mtimeMs);
114
- const rows = [];
115
- for (const { id } of waves) {
116
- const state = await readWaveState(id, projectRoot);
115
+ // v1.5.0 F3 (R3 fold-in): parallel reads via Promise.all.
116
+ // Sequential await over N waves was O(N*disk-latency); parallelised on N>3.
117
+ const states = await Promise.all(waves.map(({ id }) => readWaveState(id, projectRoot)));
118
+ const rows = waves.map(({ id }, i) => {
119
+ const state = states[i];
117
120
  const status = state?.frontmatter?.status ?? '?';
118
121
  const createdAt = state?.frontmatter?.created_at ?? '';
119
- rows.push(`${id}\t${status}\t${createdAt}`);
120
- }
122
+ return `${id}\t${status}\t${createdAt}`;
123
+ });
121
124
  return { ok: true, output: rows.join('\n') };
122
125
  },
126
+
127
+ // r17.1 (item 6): subagent "did anyone go silent?" check. Takes a wave id
128
+ // plus the list of subagent ids you EXPECTED to complete and reports which
129
+ // ones have a checkpoint receipt vs which are MIA. Closes the wayland-
130
+ // session pattern where 5 of 8 dispatched subagents went silent and the
131
+ // orchestrator (Claude) had no way to know without manually scanning.
132
+ //
133
+ // Usage:
134
+ // ijfw wave-missing <waveId> <expectedId1> [<expectedId2> ...]
135
+ // Exits non-zero (via ctx-aware caller) when any expected id is missing.
136
+ //
137
+ // Why this is in IJFW vs a SKILL: the orchestrator harness doesn't notify
138
+ // Claude when an Agent subagent silently fails. IJFW can't fix that — only
139
+ // the harness can. What IJFW CAN do: give the orchestrator a deterministic,
140
+ // mechanical way to ask "are the receipts here?" without scraping the
141
+ // worktree filesystem by hand.
142
+ 'wave-missing': async (args, ctx) => {
143
+ const tokens = tokenize(args);
144
+ const waveId = tokens[0];
145
+ let expected = tokens.slice(1);
146
+
147
+ // v1.5.0 audit-MED-work-M4: when the operator omits the expected list,
148
+ // try to read `.ijfw/wave-<id>/expected.json` (written at fan-out by the
149
+ // orchestrator). Closes the "orchestrator forgot to call wave-missing
150
+ // with the right argv" failure mode. argv list still wins when present.
151
+ if (waveId && expected.length === 0) {
152
+ const projectRoot = (ctx && ctx.projectRoot) || process.cwd();
153
+ try {
154
+ const f = join(projectRoot, '.ijfw', `${WAVE_DIR_PREFIX}${waveId}`, 'expected.json');
155
+ const raw = await readFile(f, 'utf8');
156
+ const parsed = JSON.parse(raw);
157
+ if (parsed && Array.isArray(parsed.expected)) {
158
+ expected = parsed.expected.filter((x) => typeof x === 'string' && x.length > 0);
159
+ }
160
+ } catch { /* no expected.json — fall through to usage error below */ }
161
+ }
162
+
163
+ if (!waveId || expected.length === 0) {
164
+ return {
165
+ ok: false,
166
+ error: 'Usage: ijfw wave-missing <waveId> <expectedSubId1> [<expectedSubId2> ...]',
167
+ };
168
+ }
169
+ // r17-M2: validate waveId so it can't traverse out of .ijfw/. The wave
170
+ // directory format is `wave-<id>` and ids are expected to be a small set
171
+ // of safe chars (alnum, dash, dot for versions, underscore).
172
+ if (!/^[A-Za-z0-9._-]+$/.test(waveId) || waveId.includes('..')) {
173
+ return { ok: false, error: `wave-missing: invalid waveId "${waveId}" (must match [A-Za-z0-9._-] and not contain "..")` };
174
+ }
175
+ // Also validate expected ids — they're echoed back to the user but
176
+ // also used in regex construction inside the present-set match. Safe-char
177
+ // gate prevents both reflection of garbage and any future use as paths.
178
+ for (const id of expected) {
179
+ if (!/^[A-Za-z0-9._-]+$/.test(id) || id.includes('..')) {
180
+ return { ok: false, error: `wave-missing: invalid expected id "${id}" (must match [A-Za-z0-9._-] and not contain "..")` };
181
+ }
182
+ }
183
+ const projectRoot = (ctx && ctx.projectRoot) || process.cwd();
184
+ const waveDir = join(projectRoot, '.ijfw', `${WAVE_DIR_PREFIX}${waveId}`);
185
+ let dirents = [];
186
+ try {
187
+ dirents = await readdir(waveDir, { withFileTypes: true });
188
+ } catch (err) {
189
+ if (err.code === 'ENOENT') {
190
+ return {
191
+ ok: false,
192
+ output: `Wave ${waveId} has no .ijfw/wave-${waveId}/ directory. All ${expected.length} expected subagent(s) are MISSING.\nMissing: ${expected.join(', ')}`,
193
+ };
194
+ }
195
+ return { ok: false, error: `wave-missing: ${err.message}` };
196
+ }
197
+
198
+ // Two canonical receipt filename forms:
199
+ // 1. Wave-id-prefixed (Phase D synthesized + S01 CLI default):
200
+ // subagent-<waveId>-<subId>.checkpoint.json
201
+ // 2. Bare runtime form (when ijfw checkpoint <waveId> <subId> ... runs
202
+ // and the subId itself has no embedded dashes that collide):
203
+ // subagent-<subId>.checkpoint.json
204
+ // We extract subId by stripping a leading "<waveId>-" if present, then
205
+ // taking everything before ".checkpoint.json". This avoids the regex-
206
+ // overlap bug where greedy captures double-counted a subId that happens
207
+ // to start with a word that resembles another subId.
208
+ const present = new Set();
209
+ const wavePrefix = `subagent-${waveId}-`;
210
+ const barePrefix = 'subagent-';
211
+ const suffix = '.checkpoint.json';
212
+ for (const ent of dirents) {
213
+ if (!ent.isFile()) continue;
214
+ const name = ent.name;
215
+ if (!name.startsWith(barePrefix) || !name.endsWith(suffix)) continue;
216
+ let subId;
217
+ if (name.startsWith(wavePrefix)) {
218
+ subId = name.slice(wavePrefix.length, name.length - suffix.length);
219
+ } else {
220
+ subId = name.slice(barePrefix.length, name.length - suffix.length);
221
+ }
222
+ if (subId.length > 0) present.add(subId);
223
+ }
224
+ const found = expected.filter(id => present.has(id));
225
+ const missing = expected.filter(id => !present.has(id));
226
+ const stray = [...present].filter(id => !expected.includes(id));
227
+
228
+ const lines = [
229
+ `Wave: ${waveId}`,
230
+ `Expected: ${expected.length} subagent(s)`,
231
+ `Present: ${found.length} (${found.join(', ') || 'none'})`,
232
+ `Missing: ${missing.length} (${missing.join(', ') || 'none'})`,
233
+ ];
234
+ if (stray.length > 0) {
235
+ lines.push(`Stray: ${stray.length} (${stray.join(', ')}) — present but not expected`);
236
+ }
237
+ return {
238
+ ok: missing.length === 0,
239
+ output: lines.join('\n'),
240
+ };
241
+ },
242
+
243
+ // v1.5.0 audit-MED-work-M4: write `.ijfw/wave-<id>/expected.json` so a
244
+ // later `ijfw wave-missing <id>` call can self-config from disk and the
245
+ // orchestrator-LLM doesn't have to remember the expected subagent list at
246
+ // fan-in time. Usage:
247
+ // ijfw wave-expected <waveId> <expectedSubId1> [<expectedSubId2> ...]
248
+ 'wave-expected': async (args, ctx) => {
249
+ const tokens = tokenize(args);
250
+ const waveId = tokens[0];
251
+ const expected = tokens.slice(1);
252
+ if (!waveId || expected.length === 0) {
253
+ return {
254
+ ok: false,
255
+ error: 'Usage: ijfw wave-expected <waveId> <expectedSubId1> [<expectedSubId2> ...]',
256
+ };
257
+ }
258
+ if (!/^[A-Za-z0-9._-]+$/.test(waveId) || waveId.includes('..')) {
259
+ return { ok: false, error: `wave-expected: invalid waveId "${waveId}" (must match [A-Za-z0-9._-] and not contain "..")` };
260
+ }
261
+ for (const id of expected) {
262
+ if (!/^[A-Za-z0-9._-]+$/.test(id) || id.includes('..')) {
263
+ return { ok: false, error: `wave-expected: invalid expected id "${id}" (must match [A-Za-z0-9._-] and not contain "..")` };
264
+ }
265
+ }
266
+ const projectRoot = (ctx && ctx.projectRoot) || process.cwd();
267
+ const waveDir = join(projectRoot, '.ijfw', `${WAVE_DIR_PREFIX}${waveId}`);
268
+ try {
269
+ await mkdir(waveDir, { recursive: true });
270
+ const payload = JSON.stringify(
271
+ { wave_id: waveId, expected, recorded_at: new Date().toISOString() },
272
+ null, 2,
273
+ ) + '\n';
274
+ await writeFile(join(waveDir, 'expected.json'), payload, 'utf8');
275
+ return { ok: true, output: `ok: recorded ${expected.length} expected subagent(s) for ${waveId}` };
276
+ } catch (err) {
277
+ return { ok: false, error: `wave-expected: ${err && err.message ? err.message : String(err)}` };
278
+ }
279
+ },
280
+
281
+ // v1.5.0-major S01: belt-and-suspenders drain of subagent checkpoints from
282
+ // a worktree's .ijfw/wave-<id>/ into the parent project's .ijfw/wave-<id>/.
283
+ // Run BEFORE `git worktree remove` so checkpoints survive cleanup even if
284
+ // the subagent didn't honor IJFW_PARENT_PROJECT_ROOT (older callers, manual
285
+ // claude invocation in a worktree, etc.).
286
+ 'worktree-drain': async (args, ctx) => {
287
+ const tokens = tokenize(args);
288
+ const [waveId, worktreePath] = tokens;
289
+ if (!waveId || !worktreePath) {
290
+ return {
291
+ ok: false,
292
+ error: 'Usage: ijfw worktree-drain <waveId> <worktreePath>',
293
+ };
294
+ }
295
+ const projectRoot = (ctx && ctx.projectRoot) || process.cwd();
296
+ try {
297
+ const result = await drainCheckpoints(waveId, worktreePath, projectRoot);
298
+ if (!result.ok) {
299
+ return { ok: false, error: `ijfw worktree-drain: ${result.reason}` };
300
+ }
301
+ return {
302
+ ok: true,
303
+ output: `ok: drained ${result.drained} checkpoint(s) from ${worktreePath}`,
304
+ };
305
+ } catch (err) {
306
+ return {
307
+ ok: false,
308
+ error: `ijfw worktree-drain: ${err && err.message ? err.message : String(err)}`,
309
+ };
310
+ }
311
+ },
123
312
  };
124
313
 
125
314
  export const subcommandHelp = {
126
315
  'wave-status': 'wave-status [<id>|latest] — print live state of a wave',
127
316
  'wave-list': 'wave-list — list all known waves (newest first)',
317
+ 'wave-missing':
318
+ 'wave-missing <waveId> [<expectedId1> ...] — list any dispatched subagents that have no checkpoint receipt (catches silent-failure dispatches). When expected ids are omitted, reads from .ijfw/wave-<id>/expected.json (written by `ijfw wave-expected`).',
319
+ 'wave-expected':
320
+ 'wave-expected <waveId> <expectedId1> [<expectedId2> ...] — record the expected subagent ids for a wave so `ijfw wave-missing <id>` can self-config (v1.5.0 audit-MED-work-M4)',
321
+ 'worktree-drain':
322
+ 'worktree-drain <waveId> <worktreePath> — copy subagent checkpoints from a worktree to the parent before `git worktree remove` (v1.5.0 S01)',
128
323
  };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * dispatch/worktree-cli.js — v1.5.0 S2 worktree provisioning CLI.
3
+ *
4
+ * Frozen export contract (briefing-locked):
5
+ * export const worktreeHandlers = Object.freeze({ worktree: handler });
6
+ * export const worktreeSubcommandHelp = Object.freeze({ '<subcommand>': '<help>' });
7
+ *
8
+ * Handler returns a numeric exit code (0=ok, 1=failure, 2=usage), distinct
9
+ * from wave-cli's {ok,output} shape because this command writes JSON to stdout
10
+ * directly for downstream orchestrator consumption.
11
+ *
12
+ * Subcommand owned by this module:
13
+ * - worktree provision <path>
14
+ */
15
+
16
+ import { provisionWorktree } from '../orchestrator/worktree-provision.js';
17
+ import { resolve } from 'node:path';
18
+
19
+ const SUBCOMMAND_HELP = {
20
+ 'worktree provision': 'ijfw worktree provision <path> — detect+install deps in a worktree (v1.5.0 S2)',
21
+ };
22
+
23
+ async function handleWorktree(args, _ctx) {
24
+ if (args[0] !== 'provision' || !args[1]) {
25
+ process.stderr.write('Usage: ijfw worktree provision <path>\n');
26
+ return 2;
27
+ }
28
+ const path = resolve(args[1]);
29
+ try {
30
+ const result = await provisionWorktree(path);
31
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
32
+ return result.failed.length > 0 ? 1 : 0;
33
+ } catch (err) {
34
+ process.stderr.write(`ijfw worktree: ${err.message}\n`);
35
+ return 1;
36
+ }
37
+ }
38
+
39
+ export const worktreeHandlers = Object.freeze({ worktree: handleWorktree });
40
+ export const worktreeSubcommandHelp = Object.freeze(SUBCOMMAND_HELP);
@@ -4,7 +4,9 @@
4
4
  // computes a dispatch manifest: each sub-wave is either SHARED (no file overlap
5
5
  // with peers in the same wave) or WORKTREE (overlaps -> needs isolation).
6
6
  //
7
- // Pure + synchronous. ESM. Zero deps. Filesystem only touched by caller.
7
+ // Pure + synchronous. ESM. Zero deps. No filesystem writes all state
8
+ // persistence is the caller's responsibility via state-SDK query() (T6).
9
+ // A spy regression test in test-dispatch-planner.js enforces this permanently.
8
10
 
9
11
  // eslint-disable-next-line security/detect-unsafe-regex -- plan markdown is bounded human-authored text; pattern is line-anchored and token-sized.
10
12
  const WAVE_HEADER = /^###\s+Wave\s+([0-9]+[A-Z])(?:-([A-Za-z0-9_+]+))?\b/;
@@ -54,7 +56,25 @@ export function parsePlan(markdown) {
54
56
  .split(/[,\s]+/)
55
57
  .map((s) => s.replace(/^`|`$/g, '').trim())
56
58
  .filter(Boolean);
57
- for (const a of add) if (!target.files.includes(a)) target.files.push(a);
59
+ for (const a of add) {
60
+ // v1.5.0 audit-LOW-work-L7: refuse the universal glob `**` (and its
61
+ // common forms `**/*`, `./**`). It declares "every file under the
62
+ // repo," which guarantees overlap with every peer sub-wave and forces
63
+ // them all into worktree isolation — which is almost always a
64
+ // planner mistake, not a real declaration. Catch it at parse time
65
+ // with a clear message instead of letting it silently neutralise
66
+ // the wave-routing algorithm.
67
+ const norm = a.replace(/^\.\//, '');
68
+ if (norm === '**' || norm === '**/*' || norm === '*' || norm === '*/**') {
69
+ throw new Error(
70
+ `dispatch-planner: refusing universal glob "${a}" in Files: line ` +
71
+ `(declares every file in repo, forces every peer sub-wave into ` +
72
+ `worktree isolation — almost always a planner mistake). ` +
73
+ `Use specific path prefixes instead.`,
74
+ );
75
+ }
76
+ if (!target.files.includes(a)) target.files.push(a);
77
+ }
58
78
  }
59
79
  }
60
80
  push(currentSub);
@@ -182,6 +202,81 @@ export function mergeOrder(manifest) {
182
202
 
183
203
  function idOf(sw) { return sw.sub || sw.wave; }
184
204
 
205
+ // ---------------------------------------------------------------------------
206
+ // v1.5.0 T19 (G1) — env-var passthrough composer (pure, no fs writes).
207
+ //
208
+ // Computes the SDK-contract env map a dispatched subagent inherits, given
209
+ // the parent's process env and the per-subagent (waveId, subId, isolation,
210
+ // projectRoot) context. The actual dispatch is performed by the
211
+ // `subagent.dispatch` SDK verb — this is the planner-side pure helper used
212
+ // to construct the env passthrough payload BEFORE the verb call.
213
+ //
214
+ // Stays in dispatch-planner.js because:
215
+ // * Caller intent: the planner is the orchestrator's pure compute layer.
216
+ // * Test invariant: the dispatch-planner spy gate forbids ANY fs write —
217
+ // this helper performs zero fs I/O (read or write).
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /**
221
+ * Compose the deterministic env-var passthrough for a dispatched subagent.
222
+ *
223
+ * The SDK contract specifies these well-known names (verb contract §7
224
+ * `subagent.dispatch`):
225
+ * * `IJFW_PROJECT_DIR` — absolute project root
226
+ * * `IJFW_PARENT_PROJECT_ROOT` — parent's project root (worktree subagents)
227
+ * * `IJFW_WAVE_ID` — wave id
228
+ * * `IJFW_SUBAGENT_ID` — this subagent's id
229
+ * * `IJFW_ISOLATION` — 'shared' | 'worktree'
230
+ * * `IJFW_SESSION_ID` — orchestrator session id (when set)
231
+ *
232
+ * Caller-supplied `extraEnv` keys override the SDK contract on collision —
233
+ * by design (caller's intent wins). Values are coerced to strings; null /
234
+ * undefined entries are dropped.
235
+ *
236
+ * @param {{projectRoot:string, waveId:string, subagentId:string,
237
+ * isolation?:'shared'|'worktree', parentEnv?:object, extraEnv?:object}} input
238
+ * @returns {Record<string,string>} the composed env map (sorted-stable).
239
+ */
240
+ export function composeDispatchEnv(input) {
241
+ if (!input || typeof input !== 'object') {
242
+ throw new Error('dispatch-planner.composeDispatchEnv: input object required');
243
+ }
244
+ const { projectRoot, waveId, subagentId } = input;
245
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
246
+ throw new Error('dispatch-planner.composeDispatchEnv: projectRoot required');
247
+ }
248
+ if (typeof waveId !== 'string' || waveId.length === 0) {
249
+ throw new Error('dispatch-planner.composeDispatchEnv: waveId required');
250
+ }
251
+ if (typeof subagentId !== 'string' || subagentId.length === 0) {
252
+ throw new Error('dispatch-planner.composeDispatchEnv: subagentId required');
253
+ }
254
+ const isolation = input.isolation === 'shared' ? 'shared' : 'worktree';
255
+ const parentEnv = (input.parentEnv && typeof input.parentEnv === 'object')
256
+ ? input.parentEnv : {};
257
+ const extraEnv = (input.extraEnv && typeof input.extraEnv === 'object'
258
+ && !Array.isArray(input.extraEnv)) ? input.extraEnv : {};
259
+
260
+ const composed = {
261
+ IJFW_PROJECT_DIR: projectRoot,
262
+ IJFW_PARENT_PROJECT_ROOT: typeof parentEnv.IJFW_PARENT_PROJECT_ROOT === 'string'
263
+ && parentEnv.IJFW_PARENT_PROJECT_ROOT.length > 0
264
+ ? parentEnv.IJFW_PARENT_PROJECT_ROOT : projectRoot,
265
+ IJFW_WAVE_ID: waveId,
266
+ IJFW_SUBAGENT_ID: subagentId,
267
+ IJFW_ISOLATION: isolation,
268
+ };
269
+ if (typeof parentEnv.IJFW_SESSION_ID === 'string' && parentEnv.IJFW_SESSION_ID.length > 0) {
270
+ composed.IJFW_SESSION_ID = parentEnv.IJFW_SESSION_ID;
271
+ }
272
+ for (const [k, v] of Object.entries(extraEnv)) {
273
+ if (v === null || v === undefined) continue;
274
+ composed[k] = String(v);
275
+ }
276
+ // Stable sort for deterministic output ordering.
277
+ return Object.fromEntries(Object.keys(composed).sort().map((k) => [k, composed[k]]));
278
+ }
279
+
185
280
  // Glob-aware intersection. Treats `*`/`**` as wildcards so a declaration
186
281
  // like `claude/commands/*.md` conflicts with `claude/commands/status.md`.
187
282
  // Returns true on any exact match OR glob-vs-literal match.
@@ -38,6 +38,8 @@ import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'node:fs';
38
38
  import { join, dirname } from 'node:path';
39
39
  import { fileURLToPath, pathToFileURL } from 'node:url';
40
40
  import { isOnCooldown, markCompleted } from './cooldown.js';
41
+ import { shouldRunNow } from './state-file.js';
42
+ import { runStages } from './stage-runner.js';
41
43
 
42
44
  const __filename = fileURLToPath(import.meta.url);
43
45
  const __dirname = dirname(__filename);
@@ -91,13 +93,24 @@ function log(line) {
91
93
  }
92
94
 
93
95
  // ---------------------------------------------------------------------------
94
- // Cooldown gate
96
+ // Idle gate (M4 — replaces legacy 4h cooldown with min_idle_minutes
97
+ // gate, default 30 min, override via IJFW_DREAM_MIN_IDLE_MIN).
98
+ // Legacy cooldown.markCompleted() is still called as a final stage so
99
+ // downstream code reading the old marker keeps working.
95
100
  // ---------------------------------------------------------------------------
96
101
 
97
- if (isOnCooldown(stateDir)) {
98
- log(`skip: cooldown active (host=${opts.host}, reason=${opts.reason})`);
102
+ const MIN_IDLE_MIN = Number(process.env.IJFW_DREAM_MIN_IDLE_MIN || 30);
103
+ if (!shouldRunNow(opts.projectRoot, { min_idle_minutes: MIN_IDLE_MIN })) {
104
+ log(`skip: idle gate <${MIN_IDLE_MIN}min since last run`);
99
105
  process.exit(0);
100
106
  }
107
+ // Legacy cooldown is now informational only — the new idle gate above is
108
+ // strictly stricter (30 min) than the old 4h. We still consult it as a
109
+ // belt-and-braces signal, but ONLY for visibility in the log; it does not
110
+ // block the run.
111
+ if (isOnCooldown(stateDir)) {
112
+ log(`note: legacy 4h cooldown also active (host=${opts.host}, reason=${opts.reason}) — proceeding under idle gate`);
113
+ }
101
114
 
102
115
  log(`start: host=${opts.host}, reason=${opts.reason}, project=${opts.projectRoot}`);
103
116
 
@@ -351,14 +364,37 @@ function safeJournalSummary() {
351
364
 
352
365
  (async () => {
353
366
  try {
354
- const summary = safeJournalSummary();
355
- log(`journal: ${summary.entries} entries across ${summary.sessions} sessions`);
356
-
357
- await runTierPromotion();
358
-
359
- const ok = markCompleted(stateDir);
360
- log(`mark-completed: ${ok ? 'ok' : 'failed (non-fatal)'}`);
361
- log('end: clean');
367
+ // M4 hardening: lift each step into a stage-runner stage so a failure
368
+ // in one stage logs + continues; downstream stages still execute.
369
+ // State file records each stage's status independently.
370
+ const summary = await runStages(opts.projectRoot, [
371
+ {
372
+ name: 'journal_summary',
373
+ run: async () => {
374
+ const s = safeJournalSummary();
375
+ log(`journal: ${s.entries} entries across ${s.sessions} sessions`);
376
+ return s;
377
+ },
378
+ },
379
+ {
380
+ name: 'tier_promotion',
381
+ run: async () => {
382
+ const out = await runTierPromotion();
383
+ return out || { skipped: 'no-op' };
384
+ },
385
+ },
386
+ {
387
+ name: 'mark_completed_legacy',
388
+ run: async () => {
389
+ // Preserve the legacy 4h cooldown marker so any downstream code
390
+ // still reading .dream-state.json continues to work.
391
+ const ok = markCompleted(stateDir);
392
+ log(`mark-completed: ${ok ? 'ok' : 'failed (non-fatal)'}`);
393
+ return { ok };
394
+ },
395
+ },
396
+ ]);
397
+ log(`end: completed=${summary.completed.length} failed=${summary.failed.length}`);
362
398
  } catch (err) {
363
399
  // Defensive: any unexpected throw lands in the log but never
364
400
  // surfaces a non-zero exit to the parent hook.