@ijfw/memory-server 1.4.4 → 1.5.1

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