@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,564 @@
1
+ /**
2
+ * wave-state.js — Atomic STATE.md read/write for orchestrator wave tracking.
3
+ *
4
+ * STATE.md lives at <projectRoot>/.ijfw/wave-<waveId>/STATE.md.
5
+ * Format: YAML frontmatter (---delimited) + markdown body.
6
+ * Writes are atomic: withFsLock + write-to-tmp + rename.
7
+ *
8
+ * Landed in W10-A0 (v1.4.4 prelude). checkpointWave is a stub;
9
+ * N4 (W10-A2) will flesh out the blackboard→STATE rollup logic.
10
+ *
11
+ * v1.5.0 T7 (this task): wave.* writes route through the state-SDK
12
+ * (`query('wave.advance', ...)`) — tmp+rename + locks + intent/commit
13
+ * journalling happen inside the SDK. STATE.md frontmatter is the single
14
+ * source of truth; the `blockers_open` key is now derived FROM
15
+ * `decisions.jsonl` at checkpoint time (the SDK's `blocker.add`/
16
+ * `blocker.resolve` verbs append there), giving a single writer and a single
17
+ * representation. `blockers_open` carries the blocker **id** array (machine-
18
+ * consumed); a separate `blockers_open_summary` carries human-readable text.
19
+ *
20
+ * KNOWN SDK GAP (T7-followup-1): the SDK's `wave.advance` verb does NOT
21
+ * accept a `body` field — its handler always preserves the existing body.
22
+ * Until a body-write SDK verb lands, `writeWaveState` does a follow-up raw
23
+ * atomic write to update the body. The body-write itself is still
24
+ * tmp+rename+lock-protected and the SDK frontmatter write already committed
25
+ * via the intent journal — so the worst-case partial state (frontmatter
26
+ * advanced, body stale) is bounded and self-healing on next checkpoint.
27
+ */
28
+
29
+ import { mkdir, readFile, writeFile, rename, appendFile } from 'node:fs/promises';
30
+ import { join } from 'node:path';
31
+ import { withFsLock } from '../fs-lock.js';
32
+ import { readBlackboard } from '../blackboard.js';
33
+ import { query } from './state-sdk.js';
34
+
35
+ // Lazy S4 loader. Top-level `await import` would break `node:test` (unsettled
36
+ // top-level await). Resolves on first checkpointWave call instead. Missing
37
+ // module is non-fatal (silent fail — populateBlackboardBlock stays null).
38
+ //
39
+ // v1.5.0 audit-MED-work-M9: previously this used a `_s4LoadAttempted` boolean
40
+ // + a sync `_populateBlackboardBlock` mutation. That had a race window: two
41
+ // concurrent callers entering before the `await import` settled would BOTH
42
+ // fire `import()` (cheap on resolved-module cache, but the race-condition
43
+ // taxonomy still flagged it as a singleton smell). Replaced with a Promise
44
+ // singleton: the first caller stores the promise; subsequent callers await
45
+ // the same promise. No double-import, no race on the result variable.
46
+ let _populateBlackboardBlockPromise = null;
47
+ function loadPopulateBlackboardBlock() {
48
+ if (_populateBlackboardBlockPromise === null) {
49
+ _populateBlackboardBlockPromise = (async () => {
50
+ try {
51
+ const mod = await import('./agents-md-blackboard.js');
52
+ return mod.populateBlackboardBlock ?? null;
53
+ } catch {
54
+ // S4 not landed — advisory only
55
+ return null;
56
+ }
57
+ })();
58
+ }
59
+ return _populateBlackboardBlockPromise;
60
+ }
61
+
62
+ /**
63
+ * Test-only helper: reset the populateBlackboardBlock promise singleton so a
64
+ * test can simulate "first call after process start" semantics. Internal.
65
+ *
66
+ * @internal
67
+ */
68
+ export function _resetPopulateBlackboardBlockSingleton() {
69
+ _populateBlackboardBlockPromise = null;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Internal YAML helpers — flat subset only (string/number/boolean/string[])
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Parse a YAML frontmatter block (lines between the two `---` delimiters).
78
+ * Supports: scalar string/number/boolean values, arrays of strings (block style).
79
+ * Rejects nested maps with a clear error.
80
+ *
81
+ * @param {string} block Lines between the two `---` markers (no delimiters)
82
+ * @returns {object}
83
+ */
84
+ function parseYaml(block) {
85
+ const result = {};
86
+ const lines = block.split('\n');
87
+ let i = 0;
88
+ while (i < lines.length) {
89
+ const line = lines[i];
90
+ if (line.trim() === '' || line.trimStart().startsWith('#')) { i++; continue; }
91
+
92
+ const colonIdx = line.indexOf(':');
93
+ if (colonIdx === -1) { i++; continue; }
94
+
95
+ const key = line.slice(0, colonIdx).trim();
96
+ const rest = line.slice(colonIdx + 1).trim();
97
+
98
+ if (!key) { i++; continue; }
99
+
100
+ // Detect nested map: next non-empty lines are indented key: value pairs
101
+ if (rest === '') {
102
+ // Could be array or nested map — peek ahead
103
+ const nextLines = [];
104
+ let j = i + 1;
105
+ while (j < lines.length && lines[j].trim() !== '' && !lines[j].match(/^\S.*:/)) {
106
+ nextLines.push(lines[j]);
107
+ j++;
108
+ }
109
+ if (nextLines.length > 0 && nextLines[0].trimStart().startsWith('- ')) {
110
+ // Block sequence
111
+ result[key] = nextLines.map((l) => l.replace(/^\s*-\s?/, ''));
112
+ i = j;
113
+ continue;
114
+ } else if (nextLines.length > 0) {
115
+ throw new Error(`wave-state: nested YAML maps are not supported (key: "${key}")`);
116
+ }
117
+ result[key] = null;
118
+ i++;
119
+ continue;
120
+ }
121
+
122
+ // Inline array: [a, b, c]
123
+ if (rest.startsWith('[')) {
124
+ const inner = rest.replace(/^\[/, '').replace(/\]$/, '');
125
+ result[key] = inner ? inner.split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')) : [];
126
+ i++;
127
+ continue;
128
+ }
129
+
130
+ // Scalar
131
+ if (rest === 'true') { result[key] = true; }
132
+ else if (rest === 'false') { result[key] = false; }
133
+ else if (rest === 'null' || rest === '~') { result[key] = null; }
134
+ else if (!Number.isNaN(Number(rest)) && rest !== '') { result[key] = Number(rest); }
135
+ else { result[key] = rest.replace(/^['"]|['"]$/g, ''); }
136
+ i++;
137
+ }
138
+ return result;
139
+ }
140
+
141
+ /**
142
+ * Emit a YAML frontmatter block for flat string/number/boolean/string[] values.
143
+ * @param {object} obj
144
+ * @returns {string} (no leading/trailing `---`)
145
+ */
146
+ function emitYaml(obj) {
147
+ const lines = [];
148
+ for (const [key, val] of Object.entries(obj)) {
149
+ if (val === null || val === undefined) {
150
+ lines.push(`${key}: null`);
151
+ } else if (Array.isArray(val)) {
152
+ if (val.length === 0) {
153
+ lines.push(`${key}: []`);
154
+ } else {
155
+ lines.push(`${key}:`);
156
+ for (const item of val) lines.push(` - ${item}`);
157
+ }
158
+ } else if (typeof val === 'boolean') {
159
+ lines.push(`${key}: ${val}`);
160
+ } else if (typeof val === 'number') {
161
+ lines.push(`${key}: ${val}`);
162
+ } else if (typeof val === 'object') {
163
+ throw new Error(`wave-state: nested YAML objects are not supported (key: "${key}")`);
164
+ } else {
165
+ lines.push(`${key}: ${val}`);
166
+ }
167
+ }
168
+ return lines.join('\n');
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Path helpers
173
+ // ---------------------------------------------------------------------------
174
+
175
+ function wavePaths(waveId, projectRoot) {
176
+ const dir = join(projectRoot, '.ijfw', `wave-${waveId}`);
177
+ return {
178
+ dir,
179
+ state: join(dir, 'STATE.md'),
180
+ summary: join(dir, 'SUMMARY.md'),
181
+ lock: join(dir, '.STATE.md.lock'),
182
+ summaryLock: join(dir, '.SUMMARY.md.lock'),
183
+ tmp: join(dir, '.STATE.md.tmp'),
184
+ };
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Public API
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /**
192
+ * Read a wave's STATE.md and return parsed { frontmatter, body, raw }.
193
+ * Returns null if the wave directory or file doesn't exist.
194
+ * Throws on malformed frontmatter.
195
+ *
196
+ * @param {string} waveId e.g. "W10-A0"
197
+ * @param {string} projectRoot absolute path to project root
198
+ * @returns {Promise<{frontmatter: object, body: string, raw: string} | null>}
199
+ */
200
+ export async function readWaveState(waveId, projectRoot) {
201
+ const { state } = wavePaths(waveId, projectRoot);
202
+ let raw;
203
+ try {
204
+ raw = await readFile(state, 'utf8');
205
+ } catch (err) {
206
+ if (err.code === 'ENOENT') return null;
207
+ throw err;
208
+ }
209
+
210
+ // Parse frontmatter
211
+ if (!raw.startsWith('---')) {
212
+ throw new Error(`wave-state: STATE.md for "${waveId}" is missing YAML frontmatter`);
213
+ }
214
+ const secondDelim = raw.indexOf('\n---', 3);
215
+ if (secondDelim === -1) {
216
+ throw new Error(`wave-state: STATE.md for "${waveId}" has unclosed YAML frontmatter`);
217
+ }
218
+ const fmBlock = raw.slice(4, secondDelim); // skip "---\n"
219
+ const body = raw.slice(secondDelim + 4).replace(/^\n+/, ''); // skip "\n---\n\n"
220
+
221
+ const frontmatter = parseYaml(fmBlock);
222
+ return { frontmatter, body, raw };
223
+ }
224
+
225
+ /**
226
+ * Atomically write a wave's STATE.md.
227
+ *
228
+ * v1.5.0 T7: frontmatter writes route through the state-SDK
229
+ * (`query('wave.advance', {waveId, status, frontmatter}, {projectRoot})`) so
230
+ * tmp+rename + locks + intent/commit journalling happen inside the SDK. The
231
+ * body — which the SDK contract does not yet expose a write verb for — is
232
+ * applied via a follow-up atomic write inside the same wave-STATE lock. The
233
+ * SDK's `wave.advance` handler preserves the existing body when it rewrites
234
+ * frontmatter, so the follow-up write only mutates body content and never
235
+ * loses an in-flight frontmatter update.
236
+ *
237
+ * Auto-creates `.ijfw/wave-<waveId>/` if missing (the SDK handler creates it
238
+ * on first call).
239
+ *
240
+ * @param {string} waveId
241
+ * @param {{frontmatter: object, body?: string}} state
242
+ * @param {string} projectRoot
243
+ * @returns {Promise<void>}
244
+ */
245
+ export async function writeWaveState(waveId, state, projectRoot) {
246
+ const fm = state.frontmatter || {};
247
+ // SDK's wave.advance requires `status` — supply 'pending' as a safe default
248
+ // for callers that haven't materialised one yet (matches deriveStatus's
249
+ // default-on-empty-blackboard behaviour).
250
+ const status = (typeof fm.status === 'string' && fm.status.length > 0)
251
+ ? fm.status : 'pending';
252
+ // wave.advance MERGES payload.frontmatter into the existing frontmatter;
253
+ // pass the full requested frontmatter so unrelated keys are overwritten
254
+ // intentionally (writeWaveState semantics: caller supplies the full
255
+ // frontmatter shape they want persisted).
256
+ await query(
257
+ 'wave.advance',
258
+ { waveId, status, frontmatter: { ...fm } },
259
+ { projectRoot },
260
+ );
261
+
262
+ // Body follow-up: SDK-gap T7-followup-1 — wave.advance preserves existing
263
+ // body and there is no body-write SDK verb yet. Until one lands, do an
264
+ // atomic in-place body update. Held under the same wave-STATE lock used by
265
+ // every wave-state writer, so concurrent checkpoints serialise.
266
+ if (state.body !== undefined && state.body !== null) {
267
+ const { dir, state: statePath, lock, tmp } = wavePaths(waveId, projectRoot);
268
+ await withFsLock(lock, async () => {
269
+ await mkdir(dir, { recursive: true });
270
+ let frontmatterRaw;
271
+ try {
272
+ const raw = await readFile(statePath, 'utf8');
273
+ const secondDelim = raw.indexOf('\n---', 3);
274
+ // Defensive: if the SDK-written STATE.md is somehow malformed, fall
275
+ // back to re-emitting frontmatter from the in-memory shape rather
276
+ // than refusing the body write.
277
+ if (raw.startsWith('---') && secondDelim !== -1) {
278
+ frontmatterRaw = raw.slice(0, secondDelim + 4); // '---\n…\n---\n'
279
+ } else {
280
+ frontmatterRaw = `---\n${emitYaml(fm)}\n---\n`;
281
+ }
282
+ } catch {
283
+ frontmatterRaw = `---\n${emitYaml(fm)}\n---\n`;
284
+ }
285
+ const payload = `${frontmatterRaw}\n${state.body}`;
286
+ await writeFile(tmp, payload, 'utf8');
287
+ await rename(tmp, statePath);
288
+ });
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Append a delta entry to a wave's SUMMARY.md — markdown append-only log.
294
+ * r13-M-03 (post-Trident r13 fix): minimum-viable implementation closing the
295
+ * handoff §N4 promise. Full blackboard→STATE rollup remains future work for
296
+ * v1.5.0 (would mean reading blackboard.js claims/findings and summarising).
297
+ *
298
+ * Delta shape (caller chooses what to record):
299
+ * { agent_id?, task_id?, commits?: string[], tests_delta?: string,
300
+ * contracts_touched?: string[], surprises?: string }
301
+ *
302
+ * Atomic via withFsLock + appendFile. Each delta is rendered as a markdown
303
+ * H3 section dated by ISO timestamp; subsequent entries append below.
304
+ *
305
+ * @param {string} waveId
306
+ * @param {object} delta
307
+ * @param {string} projectRoot
308
+ * @returns {Promise<void>}
309
+ */
310
+ export async function appendSummary(waveId, delta, projectRoot) {
311
+ const { dir, summary, summaryLock } = wavePaths(waveId, projectRoot);
312
+ const ts = new Date().toISOString();
313
+ const lines = [`### ${ts}`];
314
+ if (delta.agent_id) lines.push(`- **agent:** ${delta.agent_id}`);
315
+ if (delta.task_id) lines.push(`- **task:** ${delta.task_id}`);
316
+ if (Array.isArray(delta.commits) && delta.commits.length) {
317
+ lines.push(`- **commits:** ${delta.commits.join(', ')}`);
318
+ }
319
+ if (delta.tests_delta) lines.push(`- **tests:** ${delta.tests_delta}`);
320
+ if (Array.isArray(delta.contracts_touched) && delta.contracts_touched.length) {
321
+ lines.push(`- **contracts:** ${delta.contracts_touched.join(', ')}`);
322
+ }
323
+ if (delta.surprises) lines.push(`- **surprises:** ${delta.surprises}`);
324
+ const payload = lines.join('\n') + '\n\n';
325
+
326
+ await withFsLock(summaryLock, async () => {
327
+ await mkdir(dir, { recursive: true });
328
+ await appendFile(summary, payload, 'utf8');
329
+ });
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Rollup helpers — exported for direct testing (W11-B1 / S5)
334
+ // ---------------------------------------------------------------------------
335
+
336
+ /**
337
+ * Derive the next STATE.md status from the wave-filtered blackboard slice and
338
+ * the previously-persisted state.
339
+ *
340
+ * Rules (R1 §S5):
341
+ * 1. any open blocker → 'blocked'
342
+ * 2. no claims at all → preserve existing status (default 'pending')
343
+ * 3. every claim 'released' → 'review'
344
+ * 4. otherwise → 'in_progress'
345
+ *
346
+ * @param {{claims: object[], findings: object[], blockers: object[]}} filtered
347
+ * @param {{frontmatter?: object} | null} existing
348
+ * @returns {'blocked'|'pending'|'review'|'in_progress'}
349
+ */
350
+ export function deriveStatus(filtered, existing) {
351
+ if (filtered.blockers && filtered.blockers.length > 0) return 'blocked';
352
+ if (filtered.claims.length === 0) return existing?.frontmatter?.status ?? 'pending';
353
+ if (filtered.claims.every((c) => c.status === 'released')) return 'review';
354
+ return 'in_progress';
355
+ }
356
+
357
+ /**
358
+ * Tag a blackboard entry as belonging to a wave by checking, in order:
359
+ * - explicit `wave_id` field
360
+ * - artifact_id prefixed `<waveId>:`
361
+ * - message containing `[<waveId>]`
362
+ *
363
+ * @param {{claims?: {data?: {claims?: object[]}}, recent?: {findings?: object[], blockers?: object[]}}} blackboard
364
+ * @param {string} waveId
365
+ * @returns {{claims: object[], findings: object[], blockers: object[]}}
366
+ */
367
+ export function filterByWave(blackboard, waveId) {
368
+ const tag = (entry) => {
369
+ if (!entry) return false;
370
+ if (entry.wave_id === waveId) return true;
371
+ if (typeof entry.artifact_id === 'string' && entry.artifact_id.startsWith(`${waveId}:`)) return true;
372
+ if (typeof entry.message === 'string' && entry.message.includes(`[${waveId}]`)) return true;
373
+ return false;
374
+ };
375
+ const claims = (blackboard.claims?.data?.claims ?? []).filter(tag);
376
+ const findings = (blackboard.recent?.findings ?? []).filter(tag);
377
+ const blockers = (blackboard.recent?.blockers ?? []).filter(tag);
378
+ return { claims, findings, blockers };
379
+ }
380
+
381
+ /**
382
+ * Quote YAML strings that would otherwise confuse the flat-subset parser/emitter:
383
+ * presence of `:`, `#`, `[`, `]`, `{`, `}`, `"`, newline, or `<space>-`.
384
+ *
385
+ * Fold-in: Trident r13 F6 — emit safety for STATE.md frontmatter strings.
386
+ *
387
+ * @param {string} s
388
+ * @returns {string}
389
+ */
390
+ export function quoteYamlStr(s) {
391
+ if (typeof s !== 'string') return String(s);
392
+ if (/[:#[\]{}"\n]|\s-/.test(s)) return `"${s.replace(/"/g, '\\"')}"`;
393
+ return s;
394
+ }
395
+
396
+ /**
397
+ * Render the markdown body for STATE.md from the wave-filtered blackboard slice.
398
+ * Findings are capped to the last 5 (matches frontmatter.findings_recent window).
399
+ *
400
+ * @param {{findings: object[], blockers: object[]}} filtered
401
+ * @param {{body?: string} | null} _existing (reserved for future merge logic)
402
+ * @returns {string}
403
+ */
404
+ export function renderBody(filtered, _existing) {
405
+ const lines = [];
406
+ if (filtered.findings.length > 0) {
407
+ lines.push('## Recent findings');
408
+ for (const f of filtered.findings.slice(-5)) {
409
+ lines.push(`- ${f.message ?? '(unspecified)'}`);
410
+ }
411
+ lines.push('');
412
+ }
413
+ if (filtered.blockers.length > 0) {
414
+ lines.push('## Open blockers');
415
+ for (const b of filtered.blockers) {
416
+ lines.push(`- ${b.message ?? '(unspecified)'}`);
417
+ }
418
+ }
419
+ return lines.join('\n');
420
+ }
421
+
422
+ /**
423
+ * v1.5.0 T7: derive the open-blocker set for a wave from `decisions.jsonl`.
424
+ *
425
+ * The SDK's `blocker.add` / `blocker.resolve` verbs append `kind:'blocker'` /
426
+ * `kind:'blocker-resolution'` records to `.ijfw/blackboard/decisions.jsonl`
427
+ * (T4 contract §7). A blocker is **open** when:
428
+ * - a `kind:'blocker'` record exists for the wave (matched by
429
+ * record.waveId === waveId), AND
430
+ * - no later `kind:'blocker-resolution'` record carries the same
431
+ * `blockerId`.
432
+ *
433
+ * Returns parallel arrays of stable ids (for `blockers_open`, machine-
434
+ * consumed) and human messages (for `blockers_open_summary`, optional UI).
435
+ *
436
+ * @param {{recent?: {decisions?: object[]}}} blackboard
437
+ * @param {string} waveId
438
+ * @returns {{ids: string[], summaries: string[]}}
439
+ */
440
+ export function deriveOpenBlockers(blackboard, waveId) {
441
+ const decisions = Array.isArray(blackboard?.recent?.decisions)
442
+ ? blackboard.recent.decisions : [];
443
+ const resolvedIds = new Set();
444
+ for (const r of decisions) {
445
+ if (r && r.kind === 'blocker-resolution' && typeof r.blockerId === 'string') {
446
+ resolvedIds.add(r.blockerId);
447
+ }
448
+ }
449
+ const ids = [];
450
+ const summaries = [];
451
+ const seen = new Set();
452
+ for (const r of decisions) {
453
+ if (!r || r.kind !== 'blocker') continue;
454
+ if (typeof r.blockerId !== 'string' || !r.blockerId) continue;
455
+ if (r.waveId !== waveId) continue;
456
+ if (resolvedIds.has(r.blockerId)) continue;
457
+ if (seen.has(r.blockerId)) continue;
458
+ seen.add(r.blockerId);
459
+ ids.push(r.blockerId);
460
+ summaries.push(quoteYamlStr(typeof r.text === 'string' ? r.text : ''));
461
+ }
462
+ return { ids, summaries };
463
+ }
464
+
465
+ /**
466
+ * Roll up the blackboard slice for `waveId` into STATE.md frontmatter+body.
467
+ *
468
+ * Steps:
469
+ * 1. Read existing STATE.md (preserve created_at if present).
470
+ * 2. Read blackboard.js — defensive: missing/uninitialized blackboard yields
471
+ * empty arrays so checkpointing never throws on a clean tree.
472
+ * 3. Filter blackboard entries by wave tag.
473
+ * 4. Derive `blockers_open` from `decisions.jsonl` (single source of truth —
474
+ * the SDK's blocker.add/blocker.resolve verbs append there). Legacy
475
+ * `blackboard.recent.blockers` (from `addBlackboardNote(kind:'blocker')`)
476
+ * still drives the `status='blocked'` rule for back-compat.
477
+ * 5. Derive status + frontmatter; render markdown body.
478
+ * 6. Persist atomically via writeWaveState (SDK-routed).
479
+ * 7. Append a SUMMARY.md delta when status transitions.
480
+ * 8. If S4's populateBlackboardBlock is loaded, refresh AGENTS.md (advisory —
481
+ * silent on failure).
482
+ *
483
+ * @param {string} waveId
484
+ * @param {string} projectRoot
485
+ * @returns {Promise<{frontmatter: object, body: string}>}
486
+ */
487
+ export async function checkpointWave(waveId, projectRoot) {
488
+ const now = new Date().toISOString();
489
+ const existing = await readWaveState(waveId, projectRoot);
490
+
491
+ // readBlackboard returns synchronously per blackboard.js; uninitialized
492
+ // blackboard yields empty arrays so the rollup is safe on a clean tree.
493
+ let blackboard;
494
+ try {
495
+ blackboard = readBlackboard(projectRoot);
496
+ } catch {
497
+ blackboard = {
498
+ claims: { data: { claims: [] } },
499
+ recent: { findings: [], blockers: [], decisions: [] },
500
+ };
501
+ }
502
+
503
+ const filtered = filterByWave(blackboard, waveId);
504
+ // T7: single-writer reconciliation. `blockers_open` is now derived from
505
+ // decisions.jsonl (the SDK's blocker.add/blocker.resolve target) — an array
506
+ // of stable blocker ids. The legacy blackboard `blockers.jsonl` slice is
507
+ // still used to drive `status='blocked'` so existing call sites that emit
508
+ // blockers via `addBlackboardNote(kind:'blocker')` keep working.
509
+ const openBlockers = deriveOpenBlockers(blackboard, waveId);
510
+ // For deriveStatus and renderBody, merge legacy filtered blockers with the
511
+ // SDK-derived ones so any source of an open blocker still flips status.
512
+ const sdkBlockerEntries = openBlockers.ids.map((id, i) => ({
513
+ blockerId: id, message: openBlockers.summaries[i], wave_id: waveId,
514
+ }));
515
+ // Deduplicate by message text — a legacy blocker and an SDK blocker with
516
+ // identical text shouldn't appear twice in the body.
517
+ const blockerMessages = new Set(filtered.blockers.map((b) => b.message ?? ''));
518
+ const mergedBlockers = [...filtered.blockers];
519
+ for (const b of sdkBlockerEntries) {
520
+ if (!blockerMessages.has(b.message)) mergedBlockers.push(b);
521
+ }
522
+ const mergedFiltered = { ...filtered, blockers: mergedBlockers };
523
+ const status = deriveStatus(mergedFiltered, existing);
524
+
525
+ const next = {
526
+ frontmatter: {
527
+ wave_id: waveId,
528
+ status,
529
+ created_at: existing?.frontmatter?.created_at ?? now,
530
+ checkpoint_at: now,
531
+ claims_active: filtered.claims.filter((c) => c.status === 'active').length,
532
+ findings_recent: filtered.findings.slice(-5).map((f) => quoteYamlStr(f.message ?? '')),
533
+ // T7: canonical machine-consumed shape — array of stable blocker ids
534
+ // sourced from decisions.jsonl. Empty when no SDK blockers are open.
535
+ blockers_open: openBlockers.ids,
536
+ // Human-readable summary (optional UI), populated from the same SDK
537
+ // decisions.jsonl records that fed `blockers_open`.
538
+ blockers_open_summary: openBlockers.summaries,
539
+ agents: [...new Set(filtered.claims.map((c) => c.agent ?? c.owner).filter(Boolean))],
540
+ },
541
+ body: renderBody(mergedFiltered, existing),
542
+ };
543
+
544
+ await writeWaveState(waveId, next, projectRoot);
545
+
546
+ // Append summary delta when status changes (audit log).
547
+ const prevStatus = existing?.frontmatter?.status ?? 'new';
548
+ if (prevStatus !== status) {
549
+ await appendSummary(
550
+ waveId,
551
+ { agent_id: 'checkpointWave', surprises: `status: ${prevStatus} → ${status}` },
552
+ projectRoot,
553
+ );
554
+ }
555
+
556
+ // S4 integration: refresh AGENTS.md BLACKBOARD block. Silent on failure —
557
+ // populating AGENTS.md is advisory and must not block checkpointing.
558
+ const populateBlackboardBlock = await loadPopulateBlackboardBlock();
559
+ if (populateBlackboardBlock) {
560
+ try { await populateBlackboardBlock(waveId, projectRoot); } catch { /* advisory */ }
561
+ }
562
+
563
+ return next;
564
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * worktree-provision.js — v1.5.0 S2: auto-detect + install deps in a fresh worktree.
3
+ *
4
+ * Closes the Step 0 briefing gap: subagents in isolation:worktree get a clean
5
+ * git checkout but no node_modules/. Without this, every Node-touching subagent
6
+ * burns its first ~30s npm install-ing.
7
+ *
8
+ * Security: uses node:child_process execFile (NO shell, no string concat).
9
+ * Detector commands + args are frozen literals; cwd is the only per-call
10
+ * variable and is validated via lstat before invocation. `--ignore-scripts`
11
+ * is non-negotiable on npm install. A subagent worktree with malicious
12
+ * package.json {scripts:{preinstall:"..."}} must not execute arbitrary code.
13
+ */
14
+
15
+ import { execFile } from 'node:child_process';
16
+ import { promisify } from 'node:util';
17
+ import { lstat, readdir } from 'node:fs/promises';
18
+ import { existsSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ const execFileP = promisify(execFile);
22
+
23
+ export const DETECTORS = Object.freeze([
24
+ { name: 'node', file: 'package.json', cmd: 'npm', args: ['install', '--no-audit', '--no-fund', '--ignore-scripts'] },
25
+ { name: 'python', file: 'pyproject.toml', cmd: 'pip', args: ['install', '--quiet', '-e', '.'] },
26
+ { name: 'rust', file: 'Cargo.toml', cmd: 'cargo', args: ['fetch'] },
27
+ { name: 'go', file: 'go.mod', cmd: 'go', args: ['mod', 'download'] },
28
+ ]);
29
+
30
+ export const DEFAULT_PER_INSTALL_MS = 2 * 60 * 1000;
31
+ export const DEFAULT_WALL_MS = 5 * 60 * 1000;
32
+
33
+ async function firstLevelDirs(root) {
34
+ try {
35
+ const entries = await readdir(root, { withFileTypes: true });
36
+ return entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => join(root, e.name));
37
+ } catch { return []; }
38
+ }
39
+
40
+ export async function provisionWorktree(worktreePath, opts = {}) {
41
+ const detectors = opts.detectors ?? DETECTORS;
42
+ const perInstallMs = opts.perInstallMs ?? DEFAULT_PER_INSTALL_MS;
43
+ const wallMs = opts.wallMs ?? DEFAULT_WALL_MS;
44
+ const deadline = Date.now() + wallMs;
45
+ const result = { installed: [], skipped: [], failed: [] };
46
+
47
+ const candidateDirs = [worktreePath, ...(await firstLevelDirs(worktreePath))];
48
+
49
+ for (const det of detectors) {
50
+ if (Date.now() >= deadline) {
51
+ result.skipped.push({ name: det.name, reason: 'wall-deadline' });
52
+ continue;
53
+ }
54
+ for (const dir of candidateDirs) {
55
+ const manifest = join(dir, det.file);
56
+ if (!existsSync(manifest)) continue;
57
+ try {
58
+ const st = await lstat(manifest);
59
+ if (!st.isFile()) { result.skipped.push({ name: det.name, path: dir, reason: 'not-regular-file' }); continue; }
60
+ } catch { result.skipped.push({ name: det.name, path: dir, reason: 'lstat-failed' }); continue; }
61
+
62
+ const t0 = Date.now();
63
+ try {
64
+ await execFileP(det.cmd, det.args, { cwd: dir, timeout: perInstallMs, maxBuffer: 16 * 1024 * 1024 });
65
+ result.installed.push({ name: det.name, path: dir, ms: Date.now() - t0 });
66
+ } catch (err) {
67
+ // execFile timeout surfaces as either err.code === 'ETIMEDOUT' OR
68
+ // (err.killed && err.signal === 'SIGTERM'/'SIGKILL') depending on
69
+ // platform/Node version. Detect both to give callers a stable signal.
70
+ const timedOut = err.code === 'ETIMEDOUT'
71
+ || (err.killed && (err.signal === 'SIGTERM' || err.signal === 'SIGKILL'));
72
+ result.failed.push({ name: det.name, path: dir, reason: timedOut ? 'timeout' : (err.code || err.message) });
73
+ }
74
+ }
75
+ }
76
+ return result;
77
+ }