@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,1764 @@
1
+ /**
2
+ * state-sdk.js — v1.5.0 T2: the state-SDK verb core + dispatcher.
3
+ *
4
+ * ONE `query(verb, payload, ctx)` dispatcher over a frozen 20-verb registry.
5
+ * The SDK is a **verb facade over the EXISTING physical state files** — it is
6
+ * the single mutation surface, not a new storage format. Physical files keep
7
+ * their existing locations and formats (see STATE-SDK-CONTRACT.md §1).
8
+ *
9
+ * Binds verbatim to `.planning/v150-gap-closure/STATE-SDK-CONTRACT.md` (T1,
10
+ * FROZEN). Every verb's Signature / Payload / Returns / Day-1 behavior comes
11
+ * straight off that contract.
12
+ *
13
+ * ───────────────────────────────────────────────────────────────────────────
14
+ * SCOPE BOUNDARY — T2 builds the verb core; three later tasks wrap it:
15
+ *
16
+ * T3 (lock hierarchy) — wraps `_withLocks()`. Today it is a pass-through;
17
+ * T3 swaps in `withFsLock` acquisition in the §3
18
+ * canonical acquire-order. Handlers already declare
19
+ * the ordered lock-target list they touch, so T3
20
+ * only has to make `_withLocks` honor it.
21
+ * T4 (intent/commit) — wraps `_journalBegin()` / `_journalCommit()`.
22
+ * Today they are no-ops; T4 makes them write the
23
+ * write-ahead `intent-journal.jsonl` records and
24
+ * keeps the pre-write snapshot for rollback.
25
+ * T5 (event emission) — wraps `_emitEvent()`. Today it is a no-op; T5
26
+ * makes it append to the rotated per-subagent event
27
+ * log AFTER lock release (fire-and-forget).
28
+ *
29
+ * Those three seams are the ONLY extension points later tasks touch. The verb
30
+ * handlers themselves are frozen by this task.
31
+ * ───────────────────────────────────────────────────────────────────────────
32
+ *
33
+ * ESM, Node ≥18, zero new production dependencies.
34
+ */
35
+
36
+ import {
37
+ readFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, readdirSync,
38
+ } from 'node:fs';
39
+ import { join, isAbsolute, dirname, basename } from 'node:path';
40
+ import { homedir } from 'node:os';
41
+ import { randomUUID, createHash } from 'node:crypto';
42
+ import { gunzipSync } from 'node:zlib';
43
+
44
+ import { writeAtomic, readSafe } from '../lib/atomic-io.js';
45
+ import { rotateJsonlIfNeeded } from '../lib/jsonl-rotation.js';
46
+ import { withFsLock, canonicalLockOrder, lockPathFor } from '../fs-lock.js';
47
+ import {
48
+ enforceVerificationGate as _realEnforceVerificationGate,
49
+ VerificationGateViolation,
50
+ } from './verification-gate.js';
51
+ import {
52
+ validatePlan as _realValidatePlan,
53
+ isHighFinding,
54
+ } from './plan-checker.js';
55
+ import { runSelfCheck as _realRunSelfCheck } from './post-done-runner.js';
56
+ import {
57
+ emitEvent as emitEventToLog,
58
+ appendUnderHeldLock as appendEventUnderHeldLock,
59
+ resolveEventLogPath,
60
+ } from './state-events.js';
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Gate-function indirection (T15 — Model 4 testability seam)
64
+ //
65
+ // ESM module bindings are read-only, so a test cannot replace
66
+ // `enforceVerificationGate` / `validatePlan` / `runSelfCheck` on the source
67
+ // module to drive the execution-fail branch deterministically. To exercise the
68
+ // "gate threw a non-Violation → degrade to advisory" path (contract §4 Model 4
69
+ // row 2) we route the three gate calls through a single mutable registry
70
+ // (`_gateFns`) and expose a test-only `_setGateFnsForTest({...})` /
71
+ // `_resetGateFnsForTest()` pair.
72
+ //
73
+ // PRODUCTION callers always go through the live registry — `_gateFns.foo(...)`
74
+ // reads the current binding on every call, so tests can swap it in/out around
75
+ // a single assertion without leaking state across tests. The default values
76
+ // are the real gate functions; tests restore the defaults in their `finally`.
77
+ //
78
+ // This is the same advisory-seam pattern used by `_resetRecordViolationDedup`
79
+ // in verification-gate.js — internal, documented, test-only.
80
+ // ---------------------------------------------------------------------------
81
+
82
+ const _gateFns = {
83
+ enforceVerificationGate: _realEnforceVerificationGate,
84
+ validatePlan: _realValidatePlan,
85
+ runSelfCheck: _realRunSelfCheck,
86
+ };
87
+
88
+ /**
89
+ * Replace one or more gate functions. Test-only — production code MUST NOT
90
+ * call this. The previous values are NOT stacked; callers are responsible for
91
+ * restoring with `_resetGateFnsForTest()` in a `finally`.
92
+ *
93
+ * @param {{enforceVerificationGate?: Function, validatePlan?: Function, runSelfCheck?: Function}} overrides
94
+ * @internal
95
+ */
96
+ export function _setGateFnsForTest(overrides) {
97
+ if (overrides && typeof overrides === 'object') {
98
+ if (typeof overrides.enforceVerificationGate === 'function') {
99
+ _gateFns.enforceVerificationGate = overrides.enforceVerificationGate;
100
+ }
101
+ if (typeof overrides.validatePlan === 'function') {
102
+ _gateFns.validatePlan = overrides.validatePlan;
103
+ }
104
+ if (typeof overrides.runSelfCheck === 'function') {
105
+ _gateFns.runSelfCheck = overrides.runSelfCheck;
106
+ }
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Restore every gate function to its real implementation. Test-only.
112
+ * @internal
113
+ */
114
+ export function _resetGateFnsForTest() {
115
+ _gateFns.enforceVerificationGate = _realEnforceVerificationGate;
116
+ _gateFns.validatePlan = _realValidatePlan;
117
+ _gateFns.runSelfCheck = _realRunSelfCheck;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Constants
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /** waveId / subagentId safe-token shape (contract §7 wave.get). */
125
+ const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
126
+
127
+ /** Env escape hatch for the gate subsystem (Model 4 MCP-unavailable row). */
128
+ const GATE_BYPASS = process.env.IJFW_STATE_GATE_BYPASS === '1';
129
+
130
+ /**
131
+ * Lock-acquisition tuning (T3). `staleMs` is the window after which a holder
132
+ * that has STOPPED refreshing (a crashed process) is reclaimed; `heartbeatMs`
133
+ * is well under it so a *live* long-running verb always renews its lock before
134
+ * a concurrent caller's stale check fires. `acquireTimeoutMs` is generous so a
135
+ * legitimate queue of concurrent verbs all get their turn rather than throwing
136
+ * `FsLockBusyError` under a burst.
137
+ */
138
+ const LOCK_OPTS = {
139
+ staleMs: 10_000,
140
+ heartbeatMs: 2_000,
141
+ acquireTimeoutMs: 30_000,
142
+ };
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Physical-path resolvers — every canonical state file from contract §1.
146
+ // The SDK introduces NO new file locations; these mirror the table verbatim.
147
+ // ---------------------------------------------------------------------------
148
+
149
+ const paths = {
150
+ workflow: (root) => join(root, '.ijfw', 'state', 'workflow.json'),
151
+ waves: (root) => join(root, '.ijfw', 'state', 'waves.json'),
152
+ intentJournal: (root) => join(root, '.ijfw', 'state', 'intent-journal.jsonl'),
153
+ waveDir: (root, waveId) => join(root, '.ijfw', `wave-${waveId}`),
154
+ waveState: (root, waveId) => join(root, '.ijfw', `wave-${waveId}`, 'STATE.md'),
155
+ checkpoint: (root, waveId, subId) =>
156
+ join(root, '.ijfw', `wave-${waveId}`, `subagent-${subId}.checkpoint.json`),
157
+ eventLog: (root, waveId, subId) =>
158
+ join(root, '.ijfw', `wave-${waveId}`, `events-${subId}.jsonl`),
159
+ decisions: (root) => join(root, '.ijfw', 'blackboard', 'decisions.jsonl'),
160
+ telemetry: (root) => join(root, '.ijfw', 'telemetry', 'convergence.json'),
161
+ teamWorkflow: (root) => join(root, '.ijfw', 'team', 'workflow.json'),
162
+ teamCharter: (root) => join(root, '.ijfw', 'team', 'charter.json'),
163
+ activeExtension: (home) => join(home, '.ijfw', 'state', 'active-extension.json'),
164
+ };
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // T3 / T4 / T5 SEAMS — deliberately thin pass-throughs.
168
+ //
169
+ // These exist so the cross-cutting tasks have a single, well-named place to
170
+ // hook in. T2 must NOT implement locking / journaling / events itself.
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /**
174
+ * Lock-acquisition seam (T3) + journal-begin source of truth (T4 — issue 2).
175
+ *
176
+ * `lockTargets` is the canonical-sorted list of physical files a verb mutates.
177
+ * It is the verb's SINGLE declaration of its target set — `_withLocks` both
178
+ * acquires the locks from it AND (for a mutating verb) writes the write-ahead
179
+ * `begin` record + rollback snapshot from the SAME list. There is no second
180
+ * place that re-derives a verb's targets.
181
+ *
182
+ * `_withLocks` routes the list through `canonicalLockOrder` (defense in depth)
183
+ * so the acquire-order is always the STATE-SDK-CONTRACT §3 coarse-to-fine
184
+ * order regardless of caller input, then acquires every lock coarse-to-fine
185
+ * and releases in reverse by NESTING `withFsLock` calls — the innermost call
186
+ * runs `fn`, and the `finally` unwind of each `withFsLock` releases in exact
187
+ * reverse order. Because the acquire-order is total and deterministic, two
188
+ * verbs touching an overlapping file set can never form a lock-ordering cycle
189
+ * → the SDK is deadlock-free by construction.
190
+ *
191
+ * JOURNAL-BEGIN (T4): when `env` carries `{ isMutating:true }`, `_withLocks`
192
+ * runs `_journalBegin` AFTER acquiring the intent-journal lock but BEFORE `fn`
193
+ * — write-ahead by construction. The real journal targets are derived from
194
+ * `lockTargets` minus the intent-journal path itself (infrastructure, never a
195
+ * verb target). The resulting handle is stashed on `env.journalHandle` for the
196
+ * dispatcher's `_journalCommit`.
197
+ *
198
+ * Locks are heartbeat-refreshed (`LOCK_OPTS.heartbeatMs`) so a long-running
199
+ * verb is never wrongly reclaimed; a crashed holder still ages out at
200
+ * `LOCK_OPTS.staleMs`. No subprocess is spawned anywhere inside the lock
201
+ * (the verb core does no spawning — confirmed for T3).
202
+ *
203
+ * @param {string[]} lockTargets physical paths the verb mutates (any order)
204
+ * @param {() => Promise<T>} fn the verb's critical section
205
+ * @param {object} [env] per-invocation env — when mutating, carries verbId /
206
+ * verb / dedupKey / payloadDigest / isMutating /
207
+ * appendVerb; `_withLocks` writes `journalHandle` back.
208
+ * @returns {Promise<T>}
209
+ * @template T
210
+ */
211
+ async function _withLocks(lockTargets, fn, env) {
212
+ const declared = Array.isArray(lockTargets) ? lockTargets : [];
213
+ const ordered = canonicalLockOrder(declared);
214
+ if (ordered.length === 0) {
215
+ // No file targets → no locks. A mutating verb with no file targets still
216
+ // needs a journal begin/commit pair so it is replay-classifiable; the
217
+ // dispatcher handles that case (env.journalHandle stays null here).
218
+ return fn();
219
+ }
220
+
221
+ // Recursively nest withFsLock: acquire ordered[0] coarse-to-fine; the
222
+ // innermost frame runs `fn`. Each withFsLock's release fires on unwind, so
223
+ // locks release in exact reverse order automatically. For a mutating verb,
224
+ // immediately after the intent-journal lock (always ordered[0] — §3 #1) is
225
+ // held we run `_journalBegin`: write-ahead, and from the verb's OWN target
226
+ // list — never a re-derived one.
227
+ const journalAbs = ordered[0]; // §3 #1 — intent-journal is always first.
228
+ const realTargets = ordered.filter((t) => t !== journalAbs);
229
+
230
+ const acquireFrom = async (index) => {
231
+ if (index >= ordered.length) return fn();
232
+ return withFsLock(
233
+ lockPathFor(ordered[index]),
234
+ async () => {
235
+ // Just inside the intent-journal lock, before any other lock or `fn`:
236
+ // write the write-ahead begin record from this verb's real targets.
237
+ if (index === 0 && env && env.isMutating && !env.journalHandle) {
238
+ env.journalHandle = await _journalBegin({
239
+ root: env.root,
240
+ verb: env.verb,
241
+ verbId: env.verbId,
242
+ dedupKey: env.dedupKey,
243
+ payloadDigest: env.payloadDigest,
244
+ targets: realTargets,
245
+ // Append/dedupKey verbs are NOT snapshot-rolled-back (§4) — skip
246
+ // capturing a snapshot we would never restore.
247
+ snapshot: !env.appendVerb,
248
+ });
249
+ }
250
+ return acquireFrom(index + 1);
251
+ },
252
+ LOCK_OPTS,
253
+ );
254
+ };
255
+ return acquireFrom(0);
256
+ }
257
+
258
+ /**
259
+ * Relative-path form for a journal `targets[]` entry. Project-scope files are
260
+ * rendered relative to `projectRoot` (the §4 example shape — e.g.
261
+ * `.ijfw/wave-W12-A/STATE.md`). The homedir active-extension file lives on a
262
+ * different filesystem root, so it cannot be made relative — it is recorded by
263
+ * its absolute path. Replay reconstructs `absPath` from the snapshot sidecar
264
+ * regardless, so the journal `targets[]` form is purely informational.
265
+ */
266
+ function relForJournal(root, abs) {
267
+ const prefix = root.endsWith('/') ? root : `${root}/`;
268
+ return abs.startsWith(prefix) ? abs.slice(prefix.length) : abs;
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // T4 — Intent / commit journal (STATE-SDK-CONTRACT §4, CROSS-CUTTING MODEL 2).
273
+ //
274
+ // Every mutating verb writes a write-ahead `begin` record to
275
+ // `.ijfw/state/intent-journal.jsonl` BEFORE touching any target file and a
276
+ // `commit` record AFTER the atomic rename(s) succeed.
277
+ //
278
+ // SINGLE SOURCE OF TRUTH FOR A VERB'S TARGET SET (T4 spec-review issue 2):
279
+ // `_withLocks(targets, fn, env)` is the ONE place a verb's target list is
280
+ // known. The handler passes its real, canonical-sorted target list there to
281
+ // acquire locks; `_withLocks` *reuses that exact list* to write the `begin`
282
+ // record and capture the rollback snapshot. There is NO second switch that
283
+ // re-derives targets — a handler that changes its target set changes it in
284
+ // exactly one place (its own `_withLocks` call), and journaling follows for
285
+ // free. `_withLocks` runs strictly BEFORE `fn` (the mutation) and is
286
+ // write-ahead by construction; the dispatcher's `_journalCommit` runs after
287
+ // the handler returns, reading the handle `_withLocks` stashed on `env`.
288
+ //
289
+ // Rollback source: alongside the `begin` record, `_journalBegin` captures a
290
+ // pre-write snapshot of every target file into a per-verbId sidecar at
291
+ // `.ijfw/state/intent-snapshots/<verbId>.json`. `_journalCommit` deletes the
292
+ // sidecar (the write is durable — nothing to roll back). `state.replay` reads
293
+ // a partial's sidecar to restore its targets. Append/dedupKey verbs do NOT
294
+ // capture a snapshot (see ROLLBACK MODEL below).
295
+ //
296
+ // ROLLBACK MODEL — by verb kind (T4 spec-review issue 4):
297
+ // * Overwrite / read-modify-write verbs (no dedupKey — workflow.set-phase,
298
+ // wave.advance, phase.*, extension.set-active, …): a begin-without-commit
299
+ // partial is snapshot-rolled-back by `state.replay`. The whole target is
300
+ // restored to its pre-begin content.
301
+ // * Append / dedupKey verbs (wave.record-task, subagent.checkpoint,
302
+ // event.emit, telemetry.record, roster.record, decision.add, blocker.add,
303
+ // blocker.resolve): a partial append is LEFT IN PLACE. The append is
304
+ // durable and the `dedupKey` makes the caller's retry a no-op (§4) — so
305
+ // snapshot-rollback would only DESTROY a durably-committed record. Replay
306
+ // seals the partial with a commit marker; it never reverts the file.
307
+ // Append verbs therefore capture no snapshot at all.
308
+ //
309
+ // CRASH-SAFETY — honest scope (T4 spec-review issue 3): the LIVE double-call
310
+ // fast path is atomic — a verb's target-log dedup scan and its mutation run
311
+ // inside the verb's own §3 critical section. The `begin` record and the
312
+ // `commit` record, however, are written by TWO separate intent-journal lock
313
+ // acquisitions with the handler running between them; a crash can land in
314
+ // that window. That is precisely what `state.replay` reconciles — replay-level
315
+ // recovery is BEST-EFFORT across a crash, and the journal is the authority for
316
+ // it. No comment here claims a single-critical-section guarantee that does not
317
+ // exist; the design is a write-ahead log + replay, not a two-phase commit.
318
+ // ---------------------------------------------------------------------------
319
+
320
+ /** Snapshot-sidecar directory for in-flight (begin-but-not-commit) verbs. */
321
+ function snapshotDir(root) {
322
+ return join(root, '.ijfw', 'state', 'intent-snapshots');
323
+ }
324
+
325
+ /** Snapshot-sidecar path for one verb invocation. */
326
+ function snapshotPath(root, verbId) {
327
+ return join(snapshotDir(root), `${verbId}.json`);
328
+ }
329
+
330
+ /**
331
+ * LOCKING NOTE — `_journalBegin` runs INSIDE the §3 intent-journal lock that
332
+ * `_withLocks` already holds (it is the verb's outermost lock, §3 #1). It must
333
+ * NOT re-acquire that lock — `withFsLock` is a non-re-entrant `mkdir`-based
334
+ * mutex, so a nested acquire on the same path would deadlock against itself.
335
+ * `_journalBegin` is therefore lock-free by contract: its sole caller is
336
+ * `_withLocks`, immediately after the intent-journal lock is held.
337
+ *
338
+ * `_journalCommit` runs at the DISPATCHER level, strictly AFTER the handler
339
+ * has returned and released ALL §3 locks (including the intent-journal lock).
340
+ * It therefore acquires the intent-journal lock itself — no nesting, no
341
+ * re-entry. begin (inside the handler's journal lock) → handler → commit
342
+ * (its own fresh journal lock) is a sequential chain across TWO separate lock
343
+ * acquisitions; the window between them is reconciled by `state.replay`, not
344
+ * eliminated (see CRASH-SAFETY note above — this is a WAL + replay design).
345
+ */
346
+
347
+ /**
348
+ * Intent-journal `begin` writer (T4). LOCK-FREE — the caller (`_withLocks`)
349
+ * already holds the intent-journal lock. For overwrite verbs, captures a
350
+ * pre-write snapshot sidecar of every target; for append verbs
351
+ * (`record.snapshot === false`) it captures NOTHING (a partial append is
352
+ * never snapshot-rolled-back — §4). Then appends the `begin` record. Returns a
353
+ * handle the matching `_journalCommit` consumes.
354
+ *
355
+ * @param {{root:string, verb:string, verbId:string, dedupKey?:string, targets:string[], payloadDigest:string, snapshot:boolean}} record
356
+ * `targets` is the absolute-path list the verb mutates (the
357
+ * intent-journal file itself is excluded — it is infrastructure).
358
+ * `snapshot` — false for append/dedupKey verbs (no rollback snapshot).
359
+ * @returns {Promise<object>} journal handle — `{ begun, root, verbId, ... }`
360
+ */
361
+ async function _journalBegin(record) {
362
+ const {
363
+ root, verb, verbId, dedupKey, targets, snapshot,
364
+ } = record;
365
+ const journal = paths.intentJournal(root);
366
+ const relTargets = [];
367
+ if (snapshot) {
368
+ const snapTargets = [];
369
+ for (const abs of targets) {
370
+ const rel = relForJournal(root, abs);
371
+ relTargets.push(rel);
372
+ // Pre-write snapshot: content + existence — rollback restores-or-deletes.
373
+ if (existsSync(abs)) {
374
+ snapTargets.push({
375
+ relPath: rel, absPath: abs, existed: true,
376
+ content: readFileSync(abs, 'utf8'),
377
+ });
378
+ } else {
379
+ snapTargets.push({ relPath: rel, absPath: abs, existed: false, content: null });
380
+ }
381
+ }
382
+ // Snapshot sidecar is written BEFORE the begin record: if we crash between
383
+ // the two, replay sees no begin record and treats the verb as never-started
384
+ // (the orphan sidecar is harmless — `state.validate` ignores it).
385
+ ensureDir(snapshotDir(root));
386
+ writeAtomic(snapshotPath(root, verbId), JSON.stringify({ verbId, targets: snapTargets }));
387
+ } else {
388
+ // Append/dedupKey verb — no snapshot. The begin record still lists the
389
+ // real targets so the journal stays a complete record of intent.
390
+ for (const abs of targets) relTargets.push(relForJournal(root, abs));
391
+ }
392
+ const begin = {
393
+ verb, verbId, phase: 'begin', ts: nowIso(), targets: relTargets,
394
+ payloadDigest: record.payloadDigest,
395
+ };
396
+ if (typeof dedupKey === 'string' && dedupKey) begin.dedupKey = dedupKey;
397
+ // `kind` lets `state.replay` decide rollback vs seal-only without re-deriving
398
+ // verb taxonomy — the begin record is self-describing.
399
+ begin.kind = snapshot ? 'overwrite' : 'append';
400
+ ensureDir(join(journal, '..'));
401
+ appendFileSync(journal, `${JSON.stringify(begin)}\n`, { mode: 0o600 });
402
+ return {
403
+ begun: true, root, verbId, verb, dedupKey, snapshot,
404
+ payloadDigest: record.payloadDigest,
405
+ };
406
+ }
407
+
408
+ /**
409
+ * Intent-journal `commit` seam (T4 — IMPLEMENTED). Runs at the dispatcher
410
+ * level AFTER the handler released all §3 locks — so it acquires the
411
+ * intent-journal lock itself (no nesting, no re-entry). Appends the `commit`
412
+ * record (durable-applied marker) and deletes the now-redundant snapshot
413
+ * sidecar (overwrite verbs only — append verbs never wrote one).
414
+ *
415
+ * @param {object} handle the handle returned by `_journalBegin`
416
+ */
417
+ async function _journalCommit(handle) {
418
+ if (!handle || !handle.begun) return;
419
+ const {
420
+ root, verbId, verb, dedupKey, snapshot,
421
+ } = handle;
422
+ const journal = paths.intentJournal(root);
423
+ await withFsLock(lockPathFor(journal), async () => {
424
+ const commit = {
425
+ verb, verbId, phase: 'commit', ts: nowIso(),
426
+ payloadDigest: handle.payloadDigest,
427
+ kind: snapshot ? 'overwrite' : 'append',
428
+ };
429
+ if (typeof dedupKey === 'string' && dedupKey) commit.dedupKey = dedupKey;
430
+ appendFileSync(journal, `${JSON.stringify(commit)}\n`, { mode: 0o600 });
431
+ // The write is durable — the snapshot is no longer needed for rollback.
432
+ // Append verbs never captured one; the unlink is a harmless no-op for them.
433
+ if (snapshot) {
434
+ try { const s = snapshotPath(root, verbId); if (existsSync(s)) unlinkSync(s); }
435
+ catch { /* best-effort; a stale sidecar of a committed verb is harmless */ }
436
+ }
437
+ }, LOCK_OPTS);
438
+ }
439
+
440
+ /**
441
+ * Event-emit seam (T5 — IMPLEMENTED). Fire-and-forget, AFTER lock release
442
+ * (Model 3). Distinct from the `event.emit` *verb* — the verb is a
443
+ * caller-facing journaled append that acquires its own §3 lock; this seam is
444
+ * the implicit per-query observability tap that fires for EVERY verb dispatch
445
+ * (read + mutating). The dispatcher invokes this AFTER the handler returns
446
+ * and AFTER all §3 locks are released — see the dispatcher's call sites.
447
+ *
448
+ * The tap takes NO §3 lock and is serialized per-log-path by an in-process
449
+ * Promise-chain mutex inside `state-events.emitEvent`. Errors are swallowed
450
+ * (logged to stderr in the impl); a tap failure NEVER propagates to the
451
+ * caller. This call returns immediately — the underlying append happens on
452
+ * the microtask queue but is not awaited by the dispatcher.
453
+ *
454
+ * @param {{verb:string, subagentId:string, ts:string, verbId:string,
455
+ * outcome:string, payloadDigest:string,
456
+ * projectRoot:string, waveId?:string}} event
457
+ */
458
+ function _emitEvent(event) {
459
+ if (!event || !event.projectRoot) return;
460
+ // Fire-and-forget: do NOT await. `emitEvent` swallows its own errors so
461
+ // an unhandled rejection cannot escape here either.
462
+ emitEventToLog({
463
+ projectRoot: event.projectRoot,
464
+ waveId: event.waveId,
465
+ subagentId: event.subagentId,
466
+ verb: event.verb,
467
+ verbId: event.verbId,
468
+ outcome: event.outcome,
469
+ payloadDigest: event.payloadDigest,
470
+ ts: event.ts,
471
+ }).catch(() => { /* impossible — emitEvent swallows; belt-and-suspenders */ });
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // Internal I/O helpers — every verb routes file I/O through these so T3 has a
476
+ // single chokepoint to wrap and the atomic-write contract is enforced in one
477
+ // place (no handler ever calls fs.writeFile on a final path directly).
478
+ // ---------------------------------------------------------------------------
479
+
480
+ /** Read + JSON-parse a file; returns `fallback` when absent/unparseable. */
481
+ function readJson(path, fallback = null) {
482
+ const r = readSafe(path);
483
+ return r.ok ? r.data : fallback;
484
+ }
485
+
486
+ /** Atomic JSON write (tmp-write + fsync + rename via atomic-io). */
487
+ function writeJson(path, obj) {
488
+ return writeAtomic(path, JSON.stringify(obj, null, 2));
489
+ }
490
+
491
+ /** Ensure a directory exists (0o700 — matches atomic-io / fs-lock posture). */
492
+ function ensureDir(dir) {
493
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
494
+ }
495
+
496
+ /**
497
+ * Append one JSONL line. Rotates first when over the 4 MiB ceiling
498
+ * (jsonl-rotation `DEFAULT_ROTATE_SIZE`). Not idempotent on its own — callers
499
+ * supply a `dedupKey` and pre-check via `jsonlHasDedupKey` (Model 2).
500
+ */
501
+ function appendJsonl(path, obj) {
502
+ ensureDir(join(path, '..'));
503
+ rotateJsonlIfNeeded(path);
504
+ appendFileSync(path, `${JSON.stringify(obj)}\n`, { mode: 0o600 });
505
+ }
506
+
507
+ /** Read a JSONL file into an array of parsed objects (skips blank/bad lines). */
508
+ function readJsonl(path) {
509
+ if (!existsSync(path)) return [];
510
+ const out = [];
511
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
512
+ const t = line.trim();
513
+ if (!t) continue;
514
+ try { out.push(JSON.parse(t)); } catch { /* skip a corrupt line */ }
515
+ }
516
+ return out;
517
+ }
518
+
519
+ /** True when any record in the JSONL log already carries `dedupKey`. */
520
+ function jsonlHasDedupKey(path, dedupKey) {
521
+ return readJsonl(path).some((rec) => rec && rec.dedupKey === dedupKey);
522
+ }
523
+
524
+ /**
525
+ * Cross-rotation dedup helper for the `event.emit` verb. Scans the MOST
526
+ * RECENT `.jsonl.gz` archive sibling of `path` (produced by
527
+ * `jsonl-rotation.js` — date-stamped `<stem>.<date>[.<n>].jsonl.gz`) for a
528
+ * record carrying `dedupKey`. Returns the matched record (so the caller can
529
+ * surface its `seq`) or `null` when no archive exists / no match. Scope is
530
+ * intentionally bounded to the newest archive only — that matches normal-
531
+ * operation rotation windows and avoids unbounded gzip-decompression on every
532
+ * `event.emit` call. Errors (corrupt archive, gunzip failure, missing dir)
533
+ * are swallowed and treated as "no match" — the live-file scan is the
534
+ * authoritative path; the archive scan is best-effort cross-rotation safety.
535
+ */
536
+ function findDedupKeyInNewestArchive(path, dedupKey) {
537
+ try {
538
+ const dir = dirname(path);
539
+ if (!existsSync(dir)) return null;
540
+ const base = basename(path);
541
+ const stem = base.endsWith('.jsonl') ? base.slice(0, -'.jsonl'.length) : base;
542
+ const archives = readdirSync(dir)
543
+ .filter((n) => n.startsWith(`${stem}.`) && n.endsWith('.jsonl.gz'))
544
+ .sort();
545
+ if (archives.length === 0) return null;
546
+ const newest = archives[archives.length - 1]; // lexicographic == chronological
547
+ const raw = gunzipSync(readFileSync(join(dir, newest))).toString('utf8');
548
+ for (const line of raw.split('\n')) {
549
+ const t = line.trim();
550
+ if (!t) continue;
551
+ let rec;
552
+ try { rec = JSON.parse(t); } catch { continue; }
553
+ if (rec && rec.dedupKey === dedupKey) return rec;
554
+ }
555
+ return null;
556
+ } catch {
557
+ return null;
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Canonical JSON serialization — recursive, key-sorted. The digest must be
563
+ * STABLE across process restarts (replay safety: a verb is recognized as
564
+ * already-committed by digest). Plain `JSON.stringify` preserves key INSERTION
565
+ * order, so two payloads with the same content but different key order would
566
+ * hash differently and a replay would wrongly re-apply. This sorts every
567
+ * object's keys recursively so the canonical form is content-addressable.
568
+ *
569
+ * Arrays are NOT reordered — array element order is meaningful data.
570
+ * `undefined` collapses to `null` (matches JSON's own value space).
571
+ */
572
+ function canonicalJson(value) {
573
+ if (value === undefined || value === null) return 'null';
574
+ if (typeof value === 'number') {
575
+ return Number.isFinite(value) ? JSON.stringify(value) : 'null';
576
+ }
577
+ if (typeof value === 'boolean' || typeof value === 'string') {
578
+ return JSON.stringify(value);
579
+ }
580
+ if (Array.isArray(value)) {
581
+ return `[${value.map((v) => canonicalJson(v === undefined ? null : v)).join(',')}]`;
582
+ }
583
+ if (typeof value === 'object') {
584
+ const keys = Object.keys(value).sort();
585
+ const parts = [];
586
+ for (const k of keys) {
587
+ if (value[k] === undefined) continue; // JSON.stringify drops these too
588
+ parts.push(`${JSON.stringify(k)}:${canonicalJson(value[k])}`);
589
+ }
590
+ return `{${parts.join(',')}}`;
591
+ }
592
+ // function / symbol / bigint — not valid JSON; collapse to null.
593
+ return 'null';
594
+ }
595
+
596
+ /**
597
+ * sha256-<hex> digest of the CANONICAL-JSON payload (intent/event records).
598
+ * Deterministic regardless of payload key insertion order — see `canonicalJson`.
599
+ * Exported so T4's idempotency suite can assert cross-run digest stability.
600
+ */
601
+ export function payloadDigest(payload) {
602
+ return `sha256-${createHash('sha256').update(canonicalJson(payload)).digest('hex')}`;
603
+ }
604
+
605
+ // --- STATE.md (YAML frontmatter + md body) flat read/write ------------------
606
+ // A self-contained flat-YAML subset — string/number/boolean/string[]. The SDK
607
+ // is a facade: this matches wave-state.js's on-disk format exactly so a wave
608
+ // written by either surface round-trips through the other.
609
+
610
+ function parseFrontmatter(raw) {
611
+ if (!raw.startsWith('---')) {
612
+ throw new Error('state-sdk: STATE.md missing YAML frontmatter');
613
+ }
614
+ const end = raw.indexOf('\n---', 3);
615
+ if (end === -1) throw new Error('state-sdk: STATE.md has unclosed frontmatter');
616
+ const block = raw.slice(4, end);
617
+ const body = raw.slice(end + 4).replace(/^\n+/, '');
618
+ const fm = {};
619
+ const lines = block.split('\n');
620
+ for (let i = 0; i < lines.length; i += 1) {
621
+ const line = lines[i];
622
+ if (line.trim() === '' || line.trimStart().startsWith('#')) continue;
623
+ const c = line.indexOf(':');
624
+ if (c === -1) continue;
625
+ const key = line.slice(0, c).trim();
626
+ const rest = line.slice(c + 1).trim();
627
+ if (!key) continue;
628
+ if (rest === '') {
629
+ // block sequence (" - item" lines) or empty
630
+ const seq = [];
631
+ let j = i + 1;
632
+ while (j < lines.length && lines[j].trimStart().startsWith('- ')) {
633
+ seq.push(lines[j].replace(/^\s*-\s?/, ''));
634
+ j += 1;
635
+ }
636
+ if (seq.length) { fm[key] = seq; i = j - 1; } else { fm[key] = null; }
637
+ } else if (rest.startsWith('[')) {
638
+ const inner = rest.replace(/^\[/, '').replace(/\]$/, '');
639
+ fm[key] = inner ? inner.split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')) : [];
640
+ } else if (rest === 'true') { fm[key] = true; }
641
+ else if (rest === 'false') { fm[key] = false; }
642
+ else if (rest === 'null' || rest === '~') { fm[key] = null; }
643
+ else if (rest !== '' && !Number.isNaN(Number(rest))) { fm[key] = Number(rest); }
644
+ else { fm[key] = rest.replace(/^['"]|['"]$/g, ''); }
645
+ }
646
+ return { frontmatter: fm, body };
647
+ }
648
+
649
+ function emitFrontmatter(obj) {
650
+ const lines = [];
651
+ for (const [k, v] of Object.entries(obj)) {
652
+ if (v === null || v === undefined) lines.push(`${k}: null`);
653
+ else if (Array.isArray(v)) {
654
+ if (v.length === 0) lines.push(`${k}: []`);
655
+ else { lines.push(`${k}:`); for (const it of v) lines.push(` - ${it}`); }
656
+ } else if (typeof v === 'object') {
657
+ throw new Error(`state-sdk: nested YAML not supported (key "${k}")`);
658
+ } else lines.push(`${k}: ${v}`);
659
+ }
660
+ return lines.join('\n');
661
+ }
662
+
663
+ /** Read a wave STATE.md → { frontmatter, body, raw } | null when absent. */
664
+ function readWaveStateFile(root, waveId) {
665
+ const p = paths.waveState(root, waveId);
666
+ if (!existsSync(p)) return null;
667
+ const raw = readFileSync(p, 'utf8');
668
+ const { frontmatter, body } = parseFrontmatter(raw);
669
+ return { frontmatter, body, raw };
670
+ }
671
+
672
+ /** Atomically write a wave STATE.md from { frontmatter, body }. */
673
+ function writeWaveStateFile(root, waveId, frontmatter, body) {
674
+ ensureDir(paths.waveDir(root, waveId));
675
+ const raw = `---\n${emitFrontmatter(frontmatter)}\n---\n\n${body || ''}`;
676
+ writeAtomic(paths.waveState(root, waveId), raw);
677
+ return { frontmatter, body: body || '', raw };
678
+ }
679
+
680
+ // ---------------------------------------------------------------------------
681
+ // Context / payload validation
682
+ // ---------------------------------------------------------------------------
683
+
684
+ function requireRoot(ctx) {
685
+ if (!ctx || typeof ctx.projectRoot !== 'string' || ctx.projectRoot.length === 0) {
686
+ throw new Error('state-sdk: ctx.projectRoot is required');
687
+ }
688
+ return ctx.projectRoot;
689
+ }
690
+
691
+ function requireId(value, field) {
692
+ if (typeof value !== 'string' || !ID_RE.test(value)) {
693
+ throw new Error(`state-sdk: ${field} must match ${ID_RE} (got ${JSON.stringify(value)})`);
694
+ }
695
+ return value;
696
+ }
697
+
698
+ function requireStr(value, field) {
699
+ if (typeof value !== 'string' || value.length === 0) {
700
+ throw new Error(`state-sdk: ${field} is required (non-empty string)`);
701
+ }
702
+ return value;
703
+ }
704
+
705
+ function nowIso() { return new Date().toISOString(); }
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // VERB HANDLERS — one per contract §7 block. Signature: (payload, ctx, env).
709
+ // `env` carries the per-invocation { verbId } so handlers can stamp records.
710
+ // Read verbs return the documented shape; write verbs create-or-refuse Day-1.
711
+ // ---------------------------------------------------------------------------
712
+
713
+ const handlers = {
714
+ // --- workflow.get — read, Day-1 no-op ------------------------------------
715
+ async 'workflow.get'(_payload, ctx) {
716
+ const root = requireRoot(ctx);
717
+ const workflow = readJson(paths.workflow(root), null);
718
+ return { ok: true, workflow };
719
+ },
720
+
721
+ // --- workflow.set-phase — write, Day-1 create ----------------------------
722
+ async 'workflow.set-phase'(payload, ctx, env) {
723
+ const root = requireRoot(ctx);
724
+ const phase = requireStr(payload?.phase, 'phase');
725
+ const file = paths.workflow(root);
726
+ const targets = [paths.intentJournal(root), file];
727
+ return _withLocks(targets, async () => {
728
+ const current = readJson(file, {}) || {};
729
+ const next = { ...current, phase, updated_at: nowIso() };
730
+ next.status = payload.status ?? current.status ?? 'in_progress';
731
+ if (payload.milestone !== undefined) next.milestone = payload.milestone;
732
+ if (payload.version !== undefined) next.version = payload.version;
733
+ writeJson(file, next);
734
+ return { ok: true, workflow: next };
735
+ }, env);
736
+ },
737
+
738
+ // --- wave.get — read, Day-1 no-op ----------------------------------------
739
+ async 'wave.get'(payload, ctx) {
740
+ const root = requireRoot(ctx);
741
+ const waveId = requireId(payload?.waveId, 'waveId');
742
+ return { ok: true, wave: readWaveStateFile(root, waveId) };
743
+ },
744
+
745
+ // --- wave.advance — write, Day-1 create, gate=checkpoint-completeness ----
746
+ // v1.5.0 T18 (W3 mid-wave boundary): when the wave declares a hard gate
747
+ // — either via `payload.hardGate: true` on the call, or via the wave's
748
+ // persisted frontmatter (`hard_gate: true`) — we run a checkpoint-
749
+ // completeness pre-lock check: every subagent on the wave's roster MUST
750
+ // have a checkpoint file at `paths.checkpoint(root, waveId, subId)`.
751
+ // Missing checkpoints → verdict-fail → REFUSE (no state mutation, no
752
+ // journal commit). Advisory-by-default: when no hard gate is declared the
753
+ // current behavior is preserved verbatim. `GATE_BYPASS` downgrades a would-
754
+ // be refusal to a loud advisory — enforcement is a floor, never a single
755
+ // point of failure (contract §4 MCP-unavailable row).
756
+ async 'wave.advance'(payload, ctx, env) {
757
+ const root = requireRoot(ctx);
758
+ const waveId = requireId(payload?.waveId, 'waveId');
759
+ const status = requireStr(payload?.status, 'status');
760
+
761
+ // Pre-lock hard-gate decision. The wave's hard_gate declaration is
762
+ // persisted in its frontmatter so a downstream advance call (which may
763
+ // not re-pass `hardGate`) still honors it. An explicit `payload.hardGate`
764
+ // wins when supplied — that's how a caller arms the gate on first write.
765
+ const existingPreGate = readWaveStateFile(root, waveId);
766
+ const hardGate = payload?.hardGate === true
767
+ || existingPreGate?.frontmatter?.hard_gate === true;
768
+
769
+ if (hardGate) {
770
+ // Compute the roster the gate measures. We honor an explicit
771
+ // `payload.requiredSubagents` (whitelist) so a caller can scope the
772
+ // check to the subset that should hold checkpoints at THIS advance;
773
+ // otherwise the wave's registered roster (from subagent.dispatch) is
774
+ // the source of truth.
775
+ const roster = Array.isArray(payload?.requiredSubagents)
776
+ ? payload.requiredSubagents.filter((s) => typeof s === 'string' && s)
777
+ : (Array.isArray(existingPreGate?.frontmatter?.subagents)
778
+ ? existingPreGate.frontmatter.subagents
779
+ : []);
780
+ const missing = [];
781
+ for (const subId of roster) {
782
+ if (!existsSync(paths.checkpoint(root, waveId, subId))) {
783
+ missing.push(subId);
784
+ }
785
+ }
786
+ if (missing.length > 0) {
787
+ const reason = `wave.advance hard-gate: ${missing.length} subagent(s) `
788
+ + `lack a checkpoint (${missing.join(', ')})`;
789
+ if (!GATE_BYPASS) {
790
+ // Verdict-fail → REFUSE. Pre-`_withLocks` early-return guarantees
791
+ // no state mutation, no journal begin/commit pair.
792
+ return {
793
+ ok: false, refused: true, gate: 'wave-advance-hard',
794
+ reason, missing,
795
+ };
796
+ }
797
+ // Bypass masks a would-be refusal — loud WARN + advisory result so
798
+ // the operator can see what enforcement skipped.
799
+ process.stderr.write(
800
+ '[state-sdk] WARN wave.advance gate bypassed via IJFW_STATE_GATE_BYPASS '
801
+ + `(would-refuse: ${reason})\n`,
802
+ );
803
+ }
804
+ }
805
+
806
+ const targets = [
807
+ paths.intentJournal(root), paths.waves(root), paths.waveState(root, waveId),
808
+ ];
809
+ return _withLocks(targets, async () => {
810
+ const existing = readWaveStateFile(root, waveId);
811
+ const fm = {
812
+ ...existing?.frontmatter,
813
+ wave_id: waveId,
814
+ status,
815
+ created_at: existing?.frontmatter?.created_at ?? nowIso(),
816
+ updated_at: nowIso(),
817
+ };
818
+ if (payload.frontmatter && typeof payload.frontmatter === 'object') {
819
+ for (const [k, v] of Object.entries(payload.frontmatter)) fm[k] = v;
820
+ }
821
+ // Persist the hard-gate declaration on the wave's frontmatter so
822
+ // subsequent advance calls honor it without re-passing `hardGate`.
823
+ if (payload?.hardGate === true) fm.hard_gate = true;
824
+ const wave = writeWaveStateFile(root, waveId, fm, existing?.body ?? '');
825
+ const result = { ok: true, wave };
826
+ if (hardGate && GATE_BYPASS) {
827
+ result.advisory = true;
828
+ result.gate = 'wave-advance-hard';
829
+ result.reason = 'IJFW_STATE_GATE_BYPASS=1';
830
+ }
831
+ return result;
832
+ }, env);
833
+ },
834
+
835
+ // --- wave.record-task — append, Day-1 create, dedupKey -------------------
836
+ async 'wave.record-task'(payload, ctx, env) {
837
+ const root = requireRoot(ctx);
838
+ const waveId = requireId(payload?.waveId, 'waveId');
839
+ const taskId = requireStr(payload?.taskId, 'taskId');
840
+ const status = requireStr(payload?.status, 'status');
841
+ const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
842
+ const targets = [paths.intentJournal(root), paths.waveState(root, waveId)];
843
+ return _withLocks(targets, async () => {
844
+ const existing = readWaveStateFile(root, waveId);
845
+ const tasks = Array.isArray(existing?.frontmatter?.tasks)
846
+ ? [...existing.frontmatter.tasks] : [];
847
+ // Tasks are recorded as "taskId:status:dedupKey" — flat-YAML-safe.
848
+ if (tasks.some((t) => t.endsWith(`:${dedupKey}`))) {
849
+ const wave = existing ?? readWaveStateFile(root, waveId);
850
+ return { ok: true, wave, deduped: true };
851
+ }
852
+ tasks.push(`${taskId}:${status}:${dedupKey}`);
853
+ const fm = {
854
+ ...existing?.frontmatter,
855
+ wave_id: waveId,
856
+ created_at: existing?.frontmatter?.created_at ?? nowIso(),
857
+ updated_at: nowIso(),
858
+ tasks,
859
+ };
860
+ if (existing?.frontmatter?.status === undefined) fm.status = 'in_progress';
861
+ const wave = writeWaveStateFile(root, waveId, fm, existing?.body ?? '');
862
+ return { ok: true, wave, deduped: false };
863
+ }, env);
864
+ },
865
+
866
+ // --- phase.plan-check — write, Day-1 refuse, gate=validatePlan -----------
867
+ async 'phase.plan-check'(payload, ctx, env) {
868
+ const root = requireRoot(ctx);
869
+ let planText = payload?.planText;
870
+ if (typeof planText !== 'string') {
871
+ const planPath = payload?.planPath;
872
+ if (typeof planPath !== 'string' || planPath.length === 0) {
873
+ throw new Error('state-sdk: phase.plan-check needs planPath or planText');
874
+ }
875
+ const abs = isAbsolute(planPath) ? planPath : join(root, planPath);
876
+ if (!existsSync(abs)) {
877
+ return { ok: false, refused: true, gate: 'plan-check', reason: 'plan-not-found' };
878
+ }
879
+ planText = readFileSync(abs, 'utf8');
880
+ }
881
+ // Model 4: gate-fail → refuse; gate threw → advisory (proceed).
882
+ // GATE_BYPASS (MCP-unavailable row of §4) short-circuits the gate to
883
+ // advisory and writes a loud WARN — enforcement is a floor, never a
884
+ // single point of failure.
885
+ if (GATE_BYPASS) {
886
+ process.stderr.write('[state-sdk] WARN phase.plan-check gate bypassed via IJFW_STATE_GATE_BYPASS\n');
887
+ const file = paths.workflow(root);
888
+ const targets = [paths.intentJournal(root), file];
889
+ return _withLocks(targets, async () => {
890
+ const current = readJson(file, {}) || {};
891
+ current.plan_check = {
892
+ verdict: 'bypass', phaseId: payload?.phaseId ?? null, checked_at: nowIso(),
893
+ };
894
+ writeJson(file, current);
895
+ return {
896
+ ok: true, advisory: true, gate: 'plan-check',
897
+ reason: 'IJFW_STATE_GATE_BYPASS=1', findings: [],
898
+ };
899
+ }, env);
900
+ }
901
+ let result;
902
+ try {
903
+ result = _gateFns.validatePlan(planText, { strict: true });
904
+ } catch (e) {
905
+ process.stderr.write(`[state-sdk] WARN phase.plan-check gate execution-fail: ${e.message}\n`);
906
+ return { ok: true, advisory: true, gate: 'plan-check', reason: e.message, findings: [] };
907
+ }
908
+ // v1.5.0 T17 (W1 plan-check hard-BLOCK): structurally REFUSE on any
909
+ // HIGH-tier finding (severity in {BLOCK, HIGH} per `isHighFinding`).
910
+ // This is the dispatch-blocking precondition — execute cannot proceed.
911
+ // We don't rely on `result.ok` alone: even if a future `validatePlan`
912
+ // regression set `ok:true` while a HIGH-tier finding slipped into the
913
+ // list, the gate stays correct. Pre-`_withLocks` early-return guarantees
914
+ // NO state mutation (no intent-journal append, no workflow.json write).
915
+ const highFindings = Array.isArray(result.findings)
916
+ ? result.findings.filter(isHighFinding)
917
+ : [];
918
+ if (!result.ok || highFindings.length > 0) {
919
+ return {
920
+ ok: false, refused: true, gate: 'plan-check',
921
+ findings: result.findings, reason: 'plan-check HIGH finding',
922
+ };
923
+ }
924
+ // Clean plan: record the verdict on the workflow object.
925
+ const file = paths.workflow(root);
926
+ const targets = [paths.intentJournal(root), file];
927
+ return _withLocks(targets, async () => {
928
+ const current = readJson(file, {}) || {};
929
+ current.plan_check = {
930
+ verdict: 'pass', phaseId: payload?.phaseId ?? null, checked_at: nowIso(),
931
+ };
932
+ writeJson(file, current);
933
+ return { ok: true, findings: result.findings, verdict: 'pass' };
934
+ }, env);
935
+ },
936
+
937
+ // --- phase.complete — write, Day-1 create, gate=verification ------------
938
+ async 'phase.complete'(payload, ctx, env) {
939
+ const root = requireRoot(ctx);
940
+ const phase = requireStr(payload?.phase, 'phase');
941
+ const ev = payload?.evidence || {};
942
+ // Model 4: verdict-fail → refuse; execution-fail / MCP-unavailable →
943
+ // advisory (proceed). GATE_BYPASS short-circuits to advisory.
944
+ let gateAdvisory = null;
945
+ if (!GATE_BYPASS) {
946
+ try {
947
+ _gateFns.enforceVerificationGate(
948
+ typeof ev.reportText === 'string' ? ev.reportText : '',
949
+ Array.isArray(ev.toolCalls) ? ev.toolCalls : [],
950
+ { strict: true },
951
+ );
952
+ } catch (e) {
953
+ if (e instanceof VerificationGateViolation) {
954
+ return {
955
+ ok: false, refused: true, gate: 'verification',
956
+ reason: e.message,
957
+ };
958
+ }
959
+ // Gate itself threw — execution-fail → degrade to advisory.
960
+ process.stderr.write(`[state-sdk] WARN phase.complete gate execution-fail: ${e.message}\n`);
961
+ gateAdvisory = e.message;
962
+ }
963
+ } else {
964
+ gateAdvisory = 'IJFW_STATE_GATE_BYPASS=1';
965
+ process.stderr.write('[state-sdk] WARN phase.complete gate bypassed via IJFW_STATE_GATE_BYPASS\n');
966
+ }
967
+ const file = paths.workflow(root);
968
+ const targets = [paths.intentJournal(root), file];
969
+ return _withLocks(targets, async () => {
970
+ const current = readJson(file, {}) || {};
971
+ const next = {
972
+ ...current, phase, status: 'complete', updated_at: nowIso(),
973
+ };
974
+ writeJson(file, next);
975
+ if (gateAdvisory) {
976
+ return { ok: true, advisory: true, gate: 'verification', reason: gateAdvisory, workflow: next };
977
+ }
978
+ return { ok: true, workflow: next };
979
+ }, env);
980
+ },
981
+
982
+ // --- subagent.dispatch — write, Day-1 create -----------------------------
983
+ //
984
+ // v1.5.0 T19 (G1 — subagent event stream + dispatch verb): produces a
985
+ // DETERMINISTIC dispatch brief that bakes in the SDK contract — the
986
+ // env-var passthrough (`IJFW_PROJECT_DIR`, `IJFW_SESSION_ID`,
987
+ // `IJFW_PARENT_PROJECT_ROOT`, `IJFW_WAVE_ID`, `IJFW_SUBAGENT_ID`,
988
+ // `IJFW_ISOLATION`) PLUS any caller-supplied env keys. The brief is
989
+ // platform-agnostic markdown: on Claude the orchestrator hands it to the
990
+ // native subagent primitive (deterministic execution); elsewhere it is
991
+ // pasted into a prompt template (best-effort — recorded in the T16
992
+ // enforcement matrix). Parent observes subagent progress via the event
993
+ // log -- each verb the subagent dispatches fires `_emitEvent` (T5),
994
+ // streamed by `pollEvents` from state-events.js.
995
+ async 'subagent.dispatch'(payload, ctx, env) {
996
+ const root = requireRoot(ctx);
997
+ const subagentId = requireId(payload?.subagentId, 'subagentId');
998
+ const waveId = requireId(payload?.waveId, 'waveId');
999
+ const brief = requireStr(payload?.brief, 'brief');
1000
+ const isolation = payload?.isolation === 'shared' ? 'shared' : 'worktree';
1001
+ const role = typeof payload?.role === 'string' && payload.role.length > 0
1002
+ ? payload.role : null;
1003
+ // Caller-supplied env passthrough (object: name → value). Coerce values to
1004
+ // strings (env vars are always strings) and drop nullish entries.
1005
+ const callerEnv = (payload?.env && typeof payload.env === 'object'
1006
+ && !Array.isArray(payload.env)) ? payload.env : {};
1007
+
1008
+ // SDK env-var contract — the deterministic set every subagent inherits.
1009
+ // The parent's process env is the source of truth for IJFW_SESSION_ID /
1010
+ // IJFW_PARENT_PROJECT_ROOT (when the orchestrator runs inside a wrapper
1011
+ // that already set them); the verb derives the rest from its own payload.
1012
+ const sdkContractEnv = {
1013
+ IJFW_PROJECT_DIR: root,
1014
+ IJFW_PARENT_PROJECT_ROOT: process.env.IJFW_PARENT_PROJECT_ROOT || root,
1015
+ IJFW_WAVE_ID: waveId,
1016
+ IJFW_SUBAGENT_ID: subagentId,
1017
+ IJFW_ISOLATION: isolation,
1018
+ };
1019
+ if (typeof process.env.IJFW_SESSION_ID === 'string' && process.env.IJFW_SESSION_ID) {
1020
+ sdkContractEnv.IJFW_SESSION_ID = process.env.IJFW_SESSION_ID;
1021
+ }
1022
+ // Merge: caller env overrides SDK contract on duplicate keys (caller's
1023
+ // intent wins). Coerce all values to strings, drop null/undefined.
1024
+ const inheritedEnv = { ...sdkContractEnv };
1025
+ for (const [k, v] of Object.entries(callerEnv)) {
1026
+ if (v === null || v === undefined) continue;
1027
+ inheritedEnv[k] = String(v);
1028
+ }
1029
+
1030
+ const targets = [paths.intentJournal(root), paths.waveState(root, waveId)];
1031
+ return _withLocks(targets, async () => {
1032
+ // Register the subagent on the wave STATE.md.
1033
+ const existing = readWaveStateFile(root, waveId);
1034
+ const roster = Array.isArray(existing?.frontmatter?.subagents)
1035
+ ? [...existing.frontmatter.subagents] : [];
1036
+ if (!roster.includes(subagentId)) roster.push(subagentId);
1037
+ const fm = {
1038
+ ...existing?.frontmatter,
1039
+ wave_id: waveId,
1040
+ status: existing?.frontmatter?.status ?? 'in_progress',
1041
+ created_at: existing?.frontmatter?.created_at ?? nowIso(),
1042
+ updated_at: nowIso(),
1043
+ subagents: roster,
1044
+ };
1045
+ writeWaveStateFile(root, waveId, fm, existing?.body ?? '');
1046
+ // `mode` is deterministic on Claude (real subagent primitive),
1047
+ // prompt-template elsewhere. T16 owns the per-platform matrix; the
1048
+ // verb core picks deterministic when a Claude subagent context is set.
1049
+ const mode = ctx?.platform === 'claude' || ctx?.subagentId
1050
+ ? 'deterministic' : 'prompt-template';
1051
+ // Compose the deterministic dispatch brief — markdown, platform-
1052
+ // agnostic. The subagent reads this verbatim; SDK env vars are listed
1053
+ // explicitly so a best-effort prompt-template platform can paste them
1054
+ // into its shell-export preamble.
1055
+ const envLines = Object.keys(inheritedEnv).sort()
1056
+ .map((k) => ` ${k}=${inheritedEnv[k]}`);
1057
+ const eventLogPath = resolveEventLogPath(root, waveId, subagentId);
1058
+ const eventLogRel = eventLogPath.startsWith(root + '/')
1059
+ ? eventLogPath.slice(root.length + 1) : eventLogPath;
1060
+ const dispatchBrief = [
1061
+ `# Subagent dispatch — ${subagentId} (wave ${waveId})`,
1062
+ role ? `Role: ${role}` : null,
1063
+ `Isolation: ${isolation}`,
1064
+ `Event log: ${eventLogRel}`,
1065
+ '',
1066
+ '## Inherited env (SDK contract + caller passthrough)',
1067
+ ...envLines,
1068
+ '',
1069
+ '## Brief',
1070
+ brief,
1071
+ ].filter((l) => l !== null).join('\n');
1072
+ return {
1073
+ ok: true,
1074
+ dispatchBrief,
1075
+ subagentId,
1076
+ waveId,
1077
+ mode,
1078
+ isolation,
1079
+ inheritedEnv,
1080
+ eventLogPath,
1081
+ };
1082
+ }, env);
1083
+ },
1084
+
1085
+ // --- subagent.checkpoint — append, Day-1 create, dedupKey ---------------
1086
+ async 'subagent.checkpoint'(payload, ctx, env) {
1087
+ const root = requireRoot(ctx);
1088
+ const waveId = requireId(payload?.waveId, 'waveId');
1089
+ const subagentId = requireId(payload?.subagentId, 'subagentId');
1090
+ const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
1091
+ if (!payload?.checkpoint || typeof payload.checkpoint !== 'object') {
1092
+ throw new Error('state-sdk: subagent.checkpoint needs a checkpoint object');
1093
+ }
1094
+ const file = paths.checkpoint(root, waveId, subagentId);
1095
+ const targets = [paths.intentJournal(root), file];
1096
+ return _withLocks(targets, async () => {
1097
+ const existing = readJson(file, null);
1098
+ if (existing && existing.dedupKey === dedupKey) {
1099
+ return { ok: true, path: file, deduped: true };
1100
+ }
1101
+ writeJson(file, {
1102
+ waveId, subagentId, dedupKey,
1103
+ checkpoint: payload.checkpoint, updated_at: nowIso(),
1104
+ });
1105
+ return { ok: true, path: file, deduped: false };
1106
+ }, env);
1107
+ },
1108
+
1109
+ // --- subagent.post-done — write, Day-1 create, gate=self-check ----------
1110
+ async 'subagent.post-done'(payload, ctx) {
1111
+ const root = requireRoot(ctx);
1112
+ // requireId is called for its side-effecting validation (throws on bad
1113
+ // input). The actual subagentId is consumed downstream by the gate; the
1114
+ // local binding stays prefixed with `_` to signal "intentionally unused".
1115
+ const _subagentId = requireId(payload?.subagentId, 'subagentId');
1116
+ const reportText = requireStr(payload?.reportText, 'reportText');
1117
+ const projectRoot = typeof payload?.projectRoot === 'string' && payload.projectRoot
1118
+ ? payload.projectRoot : root;
1119
+ // Model 4: failed self-check is a verdict-fail → refuse. A thrown
1120
+ // self-check is an execution-fail → advisory (proceed). GATE_BYPASS
1121
+ // (MCP-unavailable row of §4) downgrades a would-be refusal to a loud
1122
+ // advisory — enforcement is a floor, never a single point of failure.
1123
+ let selfCheck;
1124
+ try {
1125
+ selfCheck = _gateFns.runSelfCheck(reportText, projectRoot);
1126
+ } catch (e) {
1127
+ process.stderr.write(`[state-sdk] WARN subagent.post-done gate execution-fail: ${e.message}\n`);
1128
+ return { ok: true, advisory: true, gate: 'post-done-self-check', reason: e.message };
1129
+ }
1130
+ if (selfCheck.verdict !== 'PASSED') {
1131
+ const reason = `self-check FAILED — ${selfCheck.files_missing.length} missing file(s), `
1132
+ + `${selfCheck.commits_missing.length} missing commit(s)`;
1133
+ if (!GATE_BYPASS) {
1134
+ return {
1135
+ ok: false, refused: true, gate: 'post-done-self-check', reason,
1136
+ };
1137
+ }
1138
+ // Bypass masks a would-be refusal — emit a loud WARN + advisory
1139
+ // result so the operator can see what enforcement skipped.
1140
+ process.stderr.write(
1141
+ '[state-sdk] WARN subagent.post-done gate bypassed via IJFW_STATE_GATE_BYPASS '
1142
+ + `(would-refuse: ${reason})\n`,
1143
+ );
1144
+ return {
1145
+ ok: true, advisory: true, gate: 'post-done-self-check',
1146
+ reason: 'IJFW_STATE_GATE_BYPASS=1',
1147
+ selfCheck: {
1148
+ claimedPaths: selfCheck.files_claimed,
1149
+ claimedCommits: selfCheck.commits_claimed,
1150
+ verified: false,
1151
+ },
1152
+ };
1153
+ }
1154
+ return {
1155
+ ok: true,
1156
+ selfCheck: {
1157
+ claimedPaths: selfCheck.files_claimed,
1158
+ claimedCommits: selfCheck.commits_claimed,
1159
+ verified: selfCheck.verdict === 'PASSED',
1160
+ },
1161
+ };
1162
+ },
1163
+
1164
+ // --- event.emit — append, Day-1 create ----------------------------------
1165
+ // The `event.emit` *verb* is a caller-facing append (distinct from the
1166
+ // implicit per-query observability tap `_emitEvent`, which is the §3 #10
1167
+ // fire-and-forget one). §3 says the event-log entry "appears in the list
1168
+ // only so its relative position is defined if a future verb ever needs it
1169
+ // inline" — `event.emit` is that verb. It acquires the intent-journal lock
1170
+ // (for the §4 begin/commit pair) + the event-log lock so its
1171
+ // read-seq-then-append is atomic; both are released before the handler
1172
+ // returns. T5 fleshes out rotation + the post-lock observability envelope.
1173
+ async 'event.emit'(payload, ctx, env) {
1174
+ const root = requireRoot(ctx);
1175
+ const subagentId = requireId(payload?.subagentId, 'subagentId');
1176
+ const waveId = requireId(payload?.waveId, 'waveId');
1177
+ const eventType = requireStr(payload?.eventType, 'eventType');
1178
+ const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
1179
+ if (!payload?.data || typeof payload.data !== 'object') {
1180
+ throw new Error('state-sdk: event.emit needs a data object');
1181
+ }
1182
+ const log = paths.eventLog(root, waveId, subagentId);
1183
+ const targets = [paths.intentJournal(root), log];
1184
+ return _withLocks(targets, async () => {
1185
+ // Dedup against any prior record with the same dedupKey -- check the
1186
+ // live file (cheap hot path), then the most-recent archive
1187
+ // (cross-rotation dedup -- a normal-operation rotation must not silently
1188
+ // re-append a record with a previously-seen dedupKey). Scope is limited
1189
+ // to the most-recent archive (not the full archive history); that
1190
+ // matches the contract's append/dedup semantics — recent-enough to cover
1191
+ // a rotation window without scanning unbounded gzip blobs on every emit.
1192
+ const liveRecords = readJsonl(log);
1193
+ const liveDup = liveRecords.find((e) => e && e.dedupKey === dedupKey);
1194
+ if (liveDup) return { ok: true, seq: liveDup.seq, deduped: true };
1195
+ const archiveDup = findDedupKeyInNewestArchive(log, dedupKey);
1196
+ if (archiveDup) return { ok: true, seq: archiveDup.seq, deduped: true };
1197
+
1198
+ // T5: seq is assigned by the shared `state-events` helper so the verb's
1199
+ // seq stream + the dispatcher tap's seq stream are ONE stream, monotonic
1200
+ // across rotation. We are under the §3 event-log lock here, so we use
1201
+ // the under-lock path that bypasses the in-process tap mutex.
1202
+ //
1203
+ // Envelope shape is the §5 base shape (`{seq, verb, subagentId, ts,
1204
+ // verbId, outcome, payloadDigest}`) plus the verb-path-only extension
1205
+ // fields `eventType`, `data`, and `dedupKey`. §5 documents both shapes —
1206
+ // the dispatcher tap leaves `eventType` / `data` undefined; the verb
1207
+ // populates them. `verb` is the literal string `'event.emit'` (not
1208
+ // `eventType`) so consumers can branch on the canonical §5 field.
1209
+ const verbId = env?.verbId || `v-${randomUUID()}`;
1210
+ const record = appendEventUnderHeldLock({
1211
+ path: log,
1212
+ envelope: {
1213
+ verb: 'event.emit',
1214
+ subagentId,
1215
+ ts: nowIso(),
1216
+ verbId,
1217
+ outcome: 'ok',
1218
+ payloadDigest: payloadDigest(payload.data),
1219
+ // Verb-path extension fields (documented in §5 — optional, present
1220
+ // when the writer is the `event.emit` verb; undefined for taps).
1221
+ eventType,
1222
+ data: payload.data,
1223
+ dedupKey,
1224
+ },
1225
+ });
1226
+ return { ok: true, seq: record.seq, deduped: false };
1227
+ }, env);
1228
+ },
1229
+
1230
+ // --- telemetry.record — append, Day-1 create, dedupKey -----------------
1231
+ async 'telemetry.record'(payload, ctx, env) {
1232
+ const root = requireRoot(ctx);
1233
+ const kind = requireStr(payload?.kind, 'kind');
1234
+ const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
1235
+ if (!payload?.metrics || typeof payload.metrics !== 'object') {
1236
+ throw new Error('state-sdk: telemetry.record needs a metrics object');
1237
+ }
1238
+ const file = paths.telemetry(root);
1239
+ const targets = [paths.intentJournal(root), file];
1240
+ return _withLocks(targets, async () => {
1241
+ const current = readJson(file, null) || { records: [] };
1242
+ if (!Array.isArray(current.records)) current.records = [];
1243
+ if (current.records.some((r) => r && r.dedupKey === dedupKey)) {
1244
+ return { ok: true, telemetry: current, deduped: true };
1245
+ }
1246
+ current.records.push({
1247
+ kind, dedupKey, metrics: payload.metrics, recorded_at: nowIso(),
1248
+ });
1249
+ current.updated_at = nowIso();
1250
+ writeJson(file, current);
1251
+
1252
+ // v1.5.0 memory-moat M3 (INT.3): when kind === 'skill.execution',
1253
+ // additionally sink the structured row into memory.db's skill_telemetry
1254
+ // table so handlePrelude (INT.4) can surface recommended_skills.
1255
+ // Best-effort: failures here never affect the generic telemetry.record
1256
+ // verb result (which has already written its append-only record above).
1257
+ if (kind === 'skill.execution') {
1258
+ try {
1259
+ const { sinkSkillTelemetry } = await import('./skill-telemetry-sink.js');
1260
+ const Database = (await import('better-sqlite3')).default;
1261
+ const { join: joinP } = await import('node:path');
1262
+ const dbPath = joinP(root, '.ijfw', 'index', 'memory.db');
1263
+ const db = new Database(dbPath);
1264
+ try {
1265
+ sinkSkillTelemetry(db, payload);
1266
+ } finally {
1267
+ try { db.close(); } catch { /* best-effort */ }
1268
+ }
1269
+ } catch { /* best-effort sink — never block the generic record path */ }
1270
+ }
1271
+
1272
+ return { ok: true, telemetry: current, deduped: false };
1273
+ }, env);
1274
+ },
1275
+
1276
+ // --- roster.synthesize — read, Day-1 no-op ------------------------------
1277
+ // Pure synthesis: computes a roster from the domain. roster.record persists.
1278
+ // The verb-core ships a built-in default roster per known domain; T25/T26
1279
+ // layer richer domain-template-driven synthesis on top.
1280
+ async 'roster.synthesize'(payload, ctx) {
1281
+ requireRoot(ctx);
1282
+ const domain = requireStr(payload?.domain, 'domain');
1283
+ const DEFAULT_ROSTERS = {
1284
+ software: [
1285
+ { id: 'architect', role: 'system design', source: 'builtin' },
1286
+ { id: 'builder', role: 'implementation', source: 'builtin' },
1287
+ { id: 'reviewer', role: 'code review', source: 'builtin' },
1288
+ ],
1289
+ book: [
1290
+ { id: 'outliner', role: 'structure', source: 'builtin' },
1291
+ { id: 'writer', role: 'drafting', source: 'builtin' },
1292
+ { id: 'editor', role: 'revision', source: 'builtin' },
1293
+ ],
1294
+ campaign: [
1295
+ { id: 'strategist', role: 'positioning', source: 'builtin' },
1296
+ { id: 'copywriter', role: 'messaging', source: 'builtin' },
1297
+ { id: 'analyst', role: 'measurement', source: 'builtin' },
1298
+ ],
1299
+ };
1300
+ const agents = DEFAULT_ROSTERS[domain];
1301
+ if (!agents) {
1302
+ return { ok: false, reason: 'domain-template-missing', domain };
1303
+ }
1304
+ return { ok: true, roster: { domain, agents } };
1305
+ },
1306
+
1307
+ // --- roster.record — append, Day-1 create, dedupKey --------------------
1308
+ async 'roster.record'(payload, ctx, env) {
1309
+ const root = requireRoot(ctx);
1310
+ const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
1311
+ const roster = payload?.roster;
1312
+ if (!roster || typeof roster !== 'object' || !Array.isArray(roster.agents)) {
1313
+ throw new Error('state-sdk: roster.record needs a roster { domain, agents }');
1314
+ }
1315
+ const file = paths.teamWorkflow(root);
1316
+ // The verb writes BOTH team/workflow.json AND team/charter.json — both are
1317
+ // declared targets so the journal `begin` records the full mutation set.
1318
+ const targets = [paths.intentJournal(root), file, paths.teamCharter(root)];
1319
+ return _withLocks(targets, async () => {
1320
+ const existing = readJson(file, null);
1321
+ if (existing && existing.dedupKey === dedupKey) {
1322
+ return { ok: true, path: file, deduped: true };
1323
+ }
1324
+ ensureDir(join(root, '.ijfw', 'team'));
1325
+ const record = { ...roster, dedupKey, recorded_at: nowIso() };
1326
+ writeJson(file, record);
1327
+ writeJson(paths.teamCharter(root), {
1328
+ domain: roster.domain,
1329
+ agent_count: roster.agents.length,
1330
+ recorded_at: record.recorded_at,
1331
+ });
1332
+ return { ok: true, path: file, deduped: false };
1333
+ }, env);
1334
+ },
1335
+
1336
+ // --- extension.set-active — write, Day-1 create, homedir file ----------
1337
+ // CRITICAL: writes the FLAT consumer-contract shape:
1338
+ // { name, scope, permissions:{ reads, writes }, activated_at,
1339
+ // activated_by_ide?, activated_by_pid?, quotas? }
1340
+ // Five consumers read these fields at the top level — runtime-mediator.js,
1341
+ // extension-permission-check.mjs, dashboard-server.js, dispatch/active-cli.js,
1342
+ // and active-extension-writer.detectCrossIdeDivergence. A wrapped
1343
+ // {manifest, scope, updated_at} shape would fail-closed at the security
1344
+ // boundary in runtime-mediator (returns MALFORMED) on every call. Contract:
1345
+ // .planning/v150-gap-closure/STATE-SDK-CONTRACT.md §7 extension.set-active.
1346
+ async 'extension.set-active'(payload, ctx, env) {
1347
+ requireRoot(ctx);
1348
+ const scope = payload?.scope;
1349
+ if (!['project', 'org', 'user'].includes(scope)) {
1350
+ throw new Error("state-sdk: extension.set-active scope must be 'project'|'org'|'user'");
1351
+ }
1352
+ const home = payload?.homeDir || ctx?.homeDir || homedir();
1353
+ const file = paths.activeExtension(home);
1354
+ const targets = [paths.intentJournal(requireRoot(ctx)), file];
1355
+ return _withLocks(targets, async () => {
1356
+ if (payload?.manifest === null) {
1357
+ // Clear the active extension.
1358
+ try { if (existsSync(file)) unlinkSync(file); } catch { /* best-effort */ }
1359
+ return { ok: true, path: file, cleared: true };
1360
+ }
1361
+ const manifest = payload?.manifest;
1362
+ if (!manifest || typeof manifest !== 'object' || typeof manifest.name !== 'string') {
1363
+ throw new Error('state-sdk: extension.set-active needs a manifest { name, permissions } or null');
1364
+ }
1365
+ // Build the FLAT consumer-contract shape.
1366
+ const perms = manifest.permissions && typeof manifest.permissions === 'object'
1367
+ ? manifest.permissions : {};
1368
+ const reads = Array.isArray(perms.reads) ? perms.reads : [];
1369
+ const writes = Array.isArray(perms.writes) ? perms.writes : [];
1370
+ const out = {
1371
+ name: manifest.name,
1372
+ scope,
1373
+ permissions: { reads, writes },
1374
+ activated_at: nowIso(),
1375
+ };
1376
+ // Optional IDE/PID stamping — only when valid.
1377
+ const ideId = payload?.activated_by_ide;
1378
+ if (typeof ideId === 'string' && /^[a-z0-9-]+$/.test(ideId)) {
1379
+ out.activated_by_ide = ideId;
1380
+ const pid = payload?.activated_by_pid;
1381
+ if (typeof pid === 'number' && Number.isFinite(pid) && Number.isInteger(pid) && pid > 0) {
1382
+ out.activated_by_pid = pid;
1383
+ }
1384
+ }
1385
+ // Optional quotas — copy only positive integer dimensions (matches
1386
+ // active-extension-writer.js semantics so the tier-2 hook can enforce).
1387
+ if (
1388
+ manifest.quotas !== undefined &&
1389
+ manifest.quotas !== null &&
1390
+ typeof manifest.quotas === 'object' &&
1391
+ !Array.isArray(manifest.quotas)
1392
+ ) {
1393
+ const cleanQuotas = {};
1394
+ let copied = 0;
1395
+ for (const [k, v] of Object.entries(manifest.quotas)) {
1396
+ if (typeof v === 'number' && Number.isFinite(v) && Number.isInteger(v) && v > 0) {
1397
+ cleanQuotas[k] = v;
1398
+ copied++;
1399
+ }
1400
+ }
1401
+ if (copied > 0) out.quotas = cleanQuotas;
1402
+ }
1403
+ writeJson(file, out);
1404
+ return { ok: true, path: file };
1405
+ }, env);
1406
+ },
1407
+
1408
+ // --- decision.add — append, Day-1 create, dedupKey --------------------
1409
+ async 'decision.add'(payload, ctx, env) {
1410
+ const root = requireRoot(ctx);
1411
+ const text = requireStr(payload?.text, 'text');
1412
+ const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
1413
+ const kind = typeof payload?.kind === 'string' && payload.kind ? payload.kind : 'decision';
1414
+ const log = paths.decisions(root);
1415
+ const targets = [paths.intentJournal(root), log];
1416
+ return _withLocks(targets, async () => {
1417
+ if (jsonlHasDedupKey(log, dedupKey)) return { ok: true, deduped: true };
1418
+ appendJsonl(log, { kind, text, dedupKey, ts: nowIso() });
1419
+ return { ok: true, deduped: false };
1420
+ }, env);
1421
+ },
1422
+
1423
+ // --- blocker.add — append, Day-1 create, dedupKey --------------------
1424
+ // Appends a kind:'blocker' record to decisions.jsonl — its ONLY mutation.
1425
+ // `waveId`, when given, is recorded INSIDE that blocker record; the verb does
1426
+ // NOT write any wave-<waveId>/STATE.md. The `blockers_open` wave-summary is
1427
+ // owned by `wave-state.js` (a separate co-writer of that key) — reconciling
1428
+ // it to a single writer is deferred to T7 (migrate wave-state.js to the SDK).
1429
+ // Lock targets therefore list exactly the one file the verb mutates.
1430
+ async 'blocker.add'(payload, ctx, env) {
1431
+ const root = requireRoot(ctx);
1432
+ const id = requireStr(payload?.id, 'id');
1433
+ const text = requireStr(payload?.text, 'text');
1434
+ const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
1435
+ const waveId = payload?.waveId === undefined
1436
+ ? undefined : requireId(payload.waveId, 'waveId');
1437
+ const log = paths.decisions(root);
1438
+ const targets = [paths.intentJournal(root), log];
1439
+ return _withLocks(targets, async () => {
1440
+ if (jsonlHasDedupKey(log, dedupKey)) {
1441
+ return { ok: true, blockerId: id, deduped: true };
1442
+ }
1443
+ appendJsonl(log, {
1444
+ kind: 'blocker', blockerId: id, text, dedupKey,
1445
+ waveId: waveId ?? null, resolved: false, ts: nowIso(),
1446
+ });
1447
+ return { ok: true, blockerId: id, deduped: false };
1448
+ }, env);
1449
+ },
1450
+
1451
+ // --- blocker.resolve — append, Day-1 refuse, dedupKey ---------------
1452
+ // Appends a kind:'blocker-resolution' record to decisions.jsonl — its ONLY
1453
+ // mutation. `waveId`, when given, is recorded INSIDE that resolution record;
1454
+ // the verb does NOT write any wave-<waveId>/STATE.md. The `blockers_open`
1455
+ // wave-summary is owned by `wave-state.js`; its single-writer reconciliation
1456
+ // is deferred to T7 (migrate wave-state.js to the SDK). Lock targets list
1457
+ // exactly the one file the verb mutates.
1458
+ async 'blocker.resolve'(payload, ctx, env) {
1459
+ const root = requireRoot(ctx);
1460
+ const id = requireStr(payload?.id, 'id');
1461
+ const resolution = requireStr(payload?.resolution, 'resolution');
1462
+ const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
1463
+ const waveId = payload?.waveId === undefined
1464
+ ? undefined : requireId(payload.waveId, 'waveId');
1465
+ const log = paths.decisions(root);
1466
+ if (!existsSync(log)) {
1467
+ return { ok: false, refused: true, reason: 'no-blocker-log' };
1468
+ }
1469
+ const targets = [paths.intentJournal(root), log];
1470
+ return _withLocks(targets, async () => {
1471
+ if (jsonlHasDedupKey(log, dedupKey)) {
1472
+ return { ok: true, blockerId: id, resolved: true, deduped: true };
1473
+ }
1474
+ // An open blocker exists iff there is a kind:'blocker' record with this
1475
+ // id and no later kind:'blocker-resolution' record for the same id.
1476
+ const records = readJsonl(log);
1477
+ const opened = records.some((r) => r && r.kind === 'blocker' && r.blockerId === id);
1478
+ const alreadyResolved = records.some(
1479
+ (r) => r && r.kind === 'blocker-resolution' && r.blockerId === id,
1480
+ );
1481
+ const resolvable = opened && !alreadyResolved;
1482
+ appendJsonl(log, {
1483
+ kind: 'blocker-resolution', blockerId: id, resolution, dedupKey,
1484
+ waveId: waveId ?? null, resolved: resolvable, ts: nowIso(),
1485
+ });
1486
+ return { ok: true, blockerId: id, resolved: resolvable, deduped: false };
1487
+ }, env);
1488
+ },
1489
+
1490
+ // --- state.replay — read (recovery), Day-1 no-op -------------------
1491
+ // T4 (this task): reads the intent journal, classifies each verbId, and
1492
+ // resolves partials BY VERB KIND (the begin record's `kind` field):
1493
+ // * begin + commit → already applied → skip (no-op).
1494
+ // * begin, no commit, kind:'overwrite' → snapshot-rollback: restore each
1495
+ // target from the pre-begin snapshot sidecar (restore-or-delete), then
1496
+ // seal with a synthetic commit.
1497
+ // * begin, no commit, kind:'append' → DO NOT roll back. A partial
1498
+ // append is durable and its dedupKey makes the caller's retry a no-op
1499
+ // (§4) — reverting the file would silently destroy a committed record.
1500
+ // Replay only seals it with a synthetic commit marker.
1501
+ // A second replay sees the synthetic commit and treats the partial as
1502
+ // resolved. T20 layers truncation-recovery orchestration on top.
1503
+ async 'state.replay'(payload, ctx) {
1504
+ const root = requireRoot(ctx);
1505
+ const journal = paths.intentJournal(root);
1506
+ if (!existsSync(journal)) {
1507
+ return { ok: true, replayed: [], skipped: [], rolledBack: [] };
1508
+ }
1509
+ // The replay walk + any rollback restores happen under the intent-journal
1510
+ // lock so a concurrent mutating verb cannot interleave with recovery.
1511
+ return withFsLock(lockPathFor(journal), async () => {
1512
+ const records = readJsonl(journal);
1513
+ const sinceVerbId = payload?.sinceVerbId;
1514
+ let scoped = records;
1515
+ if (typeof sinceVerbId === 'string' && sinceVerbId) {
1516
+ const idx = records.findIndex((r) => r && r.verbId === sinceVerbId);
1517
+ if (idx !== -1) scoped = records.slice(idx);
1518
+ }
1519
+ const begins = new Map();
1520
+ const commits = new Set();
1521
+ for (const r of scoped) {
1522
+ if (!r || typeof r.verbId !== 'string') continue;
1523
+ if (r.phase === 'begin') begins.set(r.verbId, r);
1524
+ else if (r.phase === 'commit') commits.add(r.verbId);
1525
+ }
1526
+ const skipped = [];
1527
+ const rolledBack = [];
1528
+ const sealed = [];
1529
+ for (const [verbId, beginRec] of begins) {
1530
+ if (commits.has(verbId)) {
1531
+ // begin + commit → durably applied. Re-issuing it would be a no-op,
1532
+ // so replay simply records it as skipped and mutates nothing.
1533
+ skipped.push(verbId);
1534
+ continue;
1535
+ }
1536
+ // Partial: begin without commit. Resolve it by verb kind.
1537
+ // `kind:'append'` → seal only; NEVER revert (a durable append's
1538
+ // record would be lost). The dedupKey makes the
1539
+ // caller's retry a no-op anyway (§4).
1540
+ // `kind:'overwrite'` (or a legacy begin with no `kind` but a
1541
+ // snapshot sidecar) → snapshot-rollback.
1542
+ // The snapshot sidecar's presence is the legacy-safe discriminator:
1543
+ // append verbs never write one.
1544
+ const snap = readJson(snapshotPath(root, verbId), null);
1545
+ const isAppend = beginRec.kind === 'append'
1546
+ || (beginRec.kind === undefined && snap === null);
1547
+ if (!isAppend && snap && Array.isArray(snap.targets)) {
1548
+ // Overwrite verb: restore every target from the snapshot sidecar —
1549
+ // restore-or-delete per its pre-begin existence.
1550
+ for (const t of snap.targets) {
1551
+ try {
1552
+ if (t.existed) {
1553
+ writeAtomic(t.absPath, t.content ?? '');
1554
+ } else if (existsSync(t.absPath)) {
1555
+ unlinkSync(t.absPath); // the partial created it — undo by delete
1556
+ }
1557
+ } catch { /* a single target restore failing must not abort the walk */ }
1558
+ }
1559
+ }
1560
+ // Discard any snapshot sidecar (overwrite verbs only — append verbs
1561
+ // never wrote one) and seal the verbId with a synthetic `commit` so a
1562
+ // re-run of replay treats this partial as resolved.
1563
+ try {
1564
+ const s = snapshotPath(root, verbId);
1565
+ if (existsSync(s)) unlinkSync(s);
1566
+ } catch { /* best-effort */ }
1567
+ appendFileSync(journal, `${JSON.stringify({
1568
+ verb: beginRec.verb, verbId, phase: 'commit', ts: nowIso(),
1569
+ payloadDigest: beginRec.payloadDigest,
1570
+ kind: isAppend ? 'append' : 'overwrite',
1571
+ // `rolledBack:true` only for an overwrite verb whose targets were
1572
+ // reverted; an append partial is sealed in place, not rolled back.
1573
+ ...(isAppend ? { sealed: true } : { rolledBack: true }),
1574
+ })}\n`, { mode: 0o600 });
1575
+ // `rolledBack[]` = overwrite partials whose targets were restored
1576
+ // (contract §7). `sealed[]` = append partials left durably in place
1577
+ // and only marked terminal — additive, does not redefine the three
1578
+ // documented arrays.
1579
+ if (isAppend) sealed.push(verbId);
1580
+ else rolledBack.push(verbId);
1581
+ }
1582
+ return {
1583
+ ok: true, replayed: [], skipped, rolledBack, sealed,
1584
+ };
1585
+ }, LOCK_OPTS);
1586
+ },
1587
+
1588
+ // --- state.validate — read, Day-1 no-op ----------------------------
1589
+ async 'state.validate'(_payload, ctx) {
1590
+ const root = requireRoot(ctx);
1591
+ const issues = [];
1592
+ // Parse-integrity scan of the canonical JSON state files.
1593
+ for (const [label, p] of [
1594
+ ['workflow.json', paths.workflow(root)],
1595
+ ['waves.json', paths.waves(root)],
1596
+ ['telemetry/convergence.json', paths.telemetry(root)],
1597
+ ['team/workflow.json', paths.teamWorkflow(root)],
1598
+ ]) {
1599
+ if (!existsSync(p)) {
1600
+ issues.push({ file: label, problem: 'absent' });
1601
+ continue;
1602
+ }
1603
+ const r = readSafe(p);
1604
+ if (!r.ok) issues.push({ file: label, problem: `parse: ${r.error}` });
1605
+ }
1606
+ // Orphaned begin-without-commit records in the intent journal.
1607
+ const journal = paths.intentJournal(root);
1608
+ if (existsSync(journal)) {
1609
+ const records = readJsonl(journal);
1610
+ const commits = new Set(
1611
+ records.filter((r) => r && r.phase === 'commit').map((r) => r.verbId),
1612
+ );
1613
+ for (const r of records) {
1614
+ if (r && r.phase === 'begin' && !commits.has(r.verbId)) {
1615
+ issues.push({ file: 'intent-journal.jsonl', problem: `orphaned begin: ${r.verbId}` });
1616
+ }
1617
+ }
1618
+ }
1619
+ // `absent` is informational, not a failure — `valid` reflects only
1620
+ // genuine integrity problems among PRESENT files (contract §7).
1621
+ const valid = !issues.some((i) => i.problem !== 'absent');
1622
+ return { ok: true, valid, issues };
1623
+ },
1624
+ };
1625
+
1626
+ /** The frozen verb registry — verb name → handler. Exported for tests. */
1627
+ export const VERBS = handlers;
1628
+
1629
+ /**
1630
+ * Verbs that mutate state and therefore write an intent-journal begin/commit
1631
+ * pair (T4). Each one funnels through `_withLocks`, which is the single place
1632
+ * a verb's target set is declared — there is NO parallel `targetsFor` switch
1633
+ * (removed in the T4 spec-review fix; it was a second source of truth that
1634
+ * already drifted from the handlers).
1635
+ *
1636
+ * `subagent.post-done` is NOT here — contract §8 classes it as a `read` verb
1637
+ * (no-op Day-1, no file mutation) and §4 says read verbs write no journal
1638
+ * records. It runs only the post-done self-check gate.
1639
+ */
1640
+ const MUTATING = new Set([
1641
+ 'workflow.set-phase', 'wave.advance', 'wave.record-task', 'phase.plan-check',
1642
+ 'phase.complete', 'subagent.dispatch', 'subagent.checkpoint',
1643
+ 'event.emit', 'telemetry.record', 'roster.record',
1644
+ 'extension.set-active', 'decision.add', 'blocker.add', 'blocker.resolve',
1645
+ ]);
1646
+
1647
+ /**
1648
+ * Append/dedupKey verbs (contract §8). A partial append is replay-safe via its
1649
+ * `dedupKey` (§4), NOT via snapshot-rollback — so `_journalBegin` captures no
1650
+ * snapshot for these and `state.replay` never reverts their target file (it
1651
+ * would destroy a durably-committed record). All other mutating verbs are
1652
+ * overwrite / read-modify-write and DO snapshot-rollback.
1653
+ */
1654
+ const APPEND_VERBS = new Set([
1655
+ 'wave.record-task', 'subagent.checkpoint', 'event.emit', 'telemetry.record',
1656
+ 'roster.record', 'decision.add', 'blocker.add', 'blocker.resolve',
1657
+ ]);
1658
+
1659
+ // ---------------------------------------------------------------------------
1660
+ // THE DISPATCHER
1661
+ // ---------------------------------------------------------------------------
1662
+
1663
+ /**
1664
+ * query(verb, payload, ctx) — the single state-SDK mutation/read surface.
1665
+ *
1666
+ * Routes `verb` to its registered handler. An UNKNOWN verb throws — there is
1667
+ * NO silent fallback and NO default handler (contract §8, frozen for T2).
1668
+ *
1669
+ * @param {string} verb a verb name from the frozen 20-verb registry.
1670
+ * @param {object} [payload] verb-specific payload (see STATE-SDK-CONTRACT §7).
1671
+ * @param {{projectRoot:string, subagentId?:string, homeDir?:string, platform?:string}} [ctx]
1672
+ * @returns {Promise<object>} the verb's result shape; always carries `ok` + `verbId`.
1673
+ */
1674
+ export async function query(verb, payload = {}, ctx = {}) {
1675
+ const handler = typeof verb === 'string' ? handlers[verb] : undefined;
1676
+ if (typeof handler !== 'function') {
1677
+ throw new Error(
1678
+ `state-sdk: unknown verb "${verb}" — no silent fallback. `
1679
+ + `Known verbs: ${Object.keys(handlers).sort().join(', ')}`,
1680
+ );
1681
+ }
1682
+
1683
+ // Per-invocation id — `begin`/`commit` journal records (T4) and every event
1684
+ // record (T5) for this query share this verbId.
1685
+ const verbId = `v-${randomUUID()}-0000`;
1686
+ const digest = payloadDigest(payload);
1687
+ const isMutating = MUTATING.has(verb);
1688
+
1689
+ // The `env` object is the single channel between the dispatcher and the
1690
+ // verb's `_withLocks` call. For a mutating verb it carries everything
1691
+ // `_withLocks` needs to write the write-ahead `begin` record FROM THE VERB'S
1692
+ // OWN TARGET LIST (issue 2 — no re-derivation): `_withLocks` populates
1693
+ // `env.journalHandle` for `_journalCommit` to consume. The journal root is
1694
+ // required up-front so a mutating verb with a malformed ctx fails fast.
1695
+ const env = { verbId };
1696
+ if (isMutating) {
1697
+ env.isMutating = true;
1698
+ env.verb = verb;
1699
+ env.root = requireRoot(ctx);
1700
+ env.dedupKey = payload?.dedupKey;
1701
+ env.payloadDigest = digest;
1702
+ env.appendVerb = APPEND_VERBS.has(verb);
1703
+ env.journalHandle = null;
1704
+ }
1705
+
1706
+ // The tap envelope's projectRoot is best-effort: `ctx.projectRoot` is
1707
+ // required for mutating verbs but may be unset for invalid calls — in that
1708
+ // case the tap silently no-ops (an erroring unknown verb / missing-root
1709
+ // call has nowhere to write its tap event). `waveId` is derived from the
1710
+ // payload when the verb names one — the tap routes to the wave-scoped log.
1711
+ const eventRoot = typeof ctx?.projectRoot === 'string' ? ctx.projectRoot : null;
1712
+ const eventWaveId = typeof payload?.waveId === 'string' ? payload.waveId : null;
1713
+
1714
+ let result;
1715
+ let outcome = 'ok';
1716
+ try {
1717
+ result = await handler(payload, ctx, env);
1718
+ if (result && result.refused) outcome = 'refused';
1719
+ else if (result && result.advisory) outcome = 'advisory';
1720
+ } catch (err) {
1721
+ outcome = 'error';
1722
+ // T4 — the handler threw: if a `begin` was written (`env.journalHandle`
1723
+ // set) the verb is a partial. Leave the begin record + snapshot in place
1724
+ // so `state.replay` rolls it back (overwrite verb) or seals it (append
1725
+ // verb). T5 — emit the failure event before re-throwing. Fire-and-forget,
1726
+ // no §3 lock taken, errors swallowed inside `_emitEvent`.
1727
+ _emitEvent({
1728
+ verb, subagentId: ctx?.subagentId ?? 'parent', ts: nowIso(),
1729
+ verbId, outcome, payloadDigest: digest,
1730
+ projectRoot: eventRoot, waveId: eventWaveId,
1731
+ });
1732
+ throw err;
1733
+ }
1734
+
1735
+ // T4 — `commit` marker after the handler returned. `env.journalHandle` is
1736
+ // set iff `_withLocks` ran a `begin` (every mutating verb that reaches its
1737
+ // critical section). A handler that returned early WITHOUT calling
1738
+ // `_withLocks` (e.g. `phase.plan-check` Day-1 refuse / gate refuse) wrote no
1739
+ // `begin` and needs no `commit` — it mutated nothing. When a `begin` exists,
1740
+ // commit regardless of refused/ok. This commit-on-refuse is sound ONLY
1741
+ // because of the §4 handler invariant: a handler MUST NOT return a refusal
1742
+ // after entering `_withLocks` / after any mutation — refusals are decided
1743
+ // before the critical section (verdict gates already run pre-`_withLocks`).
1744
+ // So a `begin`-then-`refused` result mutated nothing inside the lock, the
1745
+ // snapshot still equals disk, and committing only marks the verbId terminal
1746
+ // so replay never treats a clean pre-lock refusal as a recoverable partial.
1747
+ if (env.journalHandle) {
1748
+ await _journalCommit(env.journalHandle);
1749
+ }
1750
+
1751
+ // T5 — fire-and-forget event AFTER the critical section. Per Model 3 the
1752
+ // tap is observability, not state — it runs post-lock-release and never
1753
+ // blocks the caller. `_emitEvent` returns synchronously after queueing.
1754
+ _emitEvent({
1755
+ verb, subagentId: ctx?.subagentId ?? 'parent', ts: nowIso(),
1756
+ verbId, outcome, payloadDigest: digest,
1757
+ projectRoot: eventRoot, waveId: eventWaveId,
1758
+ });
1759
+
1760
+ // Every query() result carries `verbId` + `ok` (contract §7).
1761
+ return { ok: result?.ok !== false, verbId, ...result };
1762
+ }
1763
+
1764
+ export default { query, VERBS };