@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,459 @@
1
+ /**
2
+ * state-events.js -- v1.5.0 T5: per-subagent event log + tap + poll reader.
3
+ *
4
+ * Binds verbatim to .planning/v150-gap-closure/STATE-SDK-CONTRACT.md §5
5
+ * (CROSS-CUTTING MODEL 3 -- Event record + log rotation).
6
+ *
7
+ * ROLES:
8
+ * * `emitEvent(envelope)` -- the implementation behind the dispatcher's
9
+ * `_emitEvent` observability tap. Fire-and-forget, AFTER lock release,
10
+ * idempotent on no-arg/malformed input (swallows all I/O errors -- never
11
+ * propagates). Appends one envelope-shaped JSONL record per call.
12
+ * * `assignNextSeqAndAppendUnderLock({...})` -- the SHARED seq+append helper
13
+ * called by the `event.emit` verb (which is journaled and runs INSIDE its
14
+ * own §3 lock). Same seq stream as the tap (per-path); same rotation
15
+ * behaviour; same size cap.
16
+ * * `pollEvents({since})` -- explicit-interval reader. Returns events with
17
+ * `seq > since` across the current file + any rotated archive. NEVER uses
18
+ * `fs.watch`.
19
+ * * `resolveEventLogPath(root, waveId, subId)` -- single source of truth for
20
+ * the per-subagent log path AND the fallback for tap-events without a
21
+ * subagent context.
22
+ *
23
+ * SEQ MONOTONICITY ACROSS ROTATION:
24
+ * The jsonl-rotation primitive archives the current log to a gzipped
25
+ * sibling (`<stem>.<date>.jsonl.gz`) and truncates the live file -- so a
26
+ * naive read-tail of the current log to derive the next seq would reset to 1
27
+ * after every rotation. We persist a tiny sidecar `<log>.seq` containing the
28
+ * last-assigned seq, written via tmp-rename atomic so a crash leaves either
29
+ * the old or the new value -- never half. On startup of an event stream the
30
+ * sidecar is read; if absent (first-ever emit OR a manual wipe), we fall
31
+ * back to scanning the current file + the most-recent archive for the max
32
+ * seq, then write the sidecar.
33
+ *
34
+ * IN-PROCESS APPEND SERIALIZATION:
35
+ * The tap fires off the critical section with NO §3 lock held. Concurrent
36
+ * tap emits to the same log (multiple verbs in flight) would race on seq
37
+ * assignment + appendFile. We serialize tap appends per-log-path with a
38
+ * simple in-process Promise-chain mutex. Cross-process serialization is not
39
+ * required because the tap fires only from one orchestrator process and the
40
+ * `event.emit` verb takes the §3 event-log lock itself (Model 3).
41
+ *
42
+ * NO PRODUCTION DEPENDENCIES; ESM; Node >=18.
43
+ */
44
+
45
+ import {
46
+ appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync,
47
+ renameSync, statSync, writeFileSync,
48
+ } from 'node:fs';
49
+ import { join, dirname, basename } from 'node:path';
50
+ import { gunzipSync } from 'node:zlib';
51
+
52
+ import { rotateJsonlIfNeeded, DEFAULT_ROTATE_SIZE } from '../lib/jsonl-rotation.js';
53
+
54
+ // -- Contract constants ----------------------------------------------------
55
+
56
+ /** Contract §5 -- 4 MiB byte ceiling per event log. */
57
+ export const EVENT_BYTE_CEILING = DEFAULT_ROTATE_SIZE; // 4 * 1024 * 1024
58
+
59
+ /** Contract §5 -- 10000-line ceiling per event log. */
60
+ export const EVENT_LINE_CEILING = 10000;
61
+
62
+ /** Contract §5 -- per-event 4 KiB size cap. Truncate, never drop. */
63
+ export const EVENT_MAX_LINE_BYTES = 4 * 1024;
64
+
65
+ // -- Path resolution -------------------------------------------------------
66
+
67
+ /**
68
+ * Per-subagent event-log path per contract §1 + §5.
69
+ * `<projectRoot>/.ijfw/wave-<waveId>/events-<subId>.jsonl`
70
+ *
71
+ * Routing is total -- the tap never silently drops:
72
+ * - Both present: `<projectRoot>/.ijfw/wave-<waveId>/events-<subId>.jsonl`.
73
+ * - waveId present, subagentId absent: route under the wave dir with the
74
+ * §5-canonical `'parent'` subId fallback —
75
+ * `<projectRoot>/.ijfw/wave-<waveId>/events-parent.jsonl`. The waveId is
76
+ * honored; the no-subagent caller surfaces as `subagentId:'parent'` per §5.
77
+ * - subagentId present, waveId absent: legacy fallback under the system dir,
78
+ * `<projectRoot>/.ijfw/state/events-<sub>.jsonl`, because there is no wave
79
+ * directory to anchor to. (Rare in practice — verbs that carry a subagent
80
+ * carry a wave too.)
81
+ * - Both absent: system fallback `<projectRoot>/.ijfw/state/events-system.jsonl`
82
+ * (e.g. dispatcher-tap events for verbs called without a `waveId` payload,
83
+ * like `state.validate`). Ratified by contract §5 (see Model 3 note).
84
+ */
85
+ export function resolveEventLogPath(projectRoot, waveId, subagentId) {
86
+ if (typeof projectRoot !== 'string' || !projectRoot) {
87
+ throw new Error('state-events: projectRoot required');
88
+ }
89
+ const safeId = (v) => (typeof v === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : null);
90
+ const wid = safeId(waveId);
91
+ const sid = safeId(subagentId);
92
+ if (wid && sid) return join(projectRoot, '.ijfw', `wave-${wid}`, `events-${sid}.jsonl`);
93
+ if (wid) return join(projectRoot, '.ijfw', `wave-${wid}`, 'events-parent.jsonl');
94
+ if (sid) return join(projectRoot, '.ijfw', 'state', `events-${sid}.jsonl`);
95
+ return join(projectRoot, '.ijfw', 'state', 'events-system.jsonl');
96
+ }
97
+
98
+ /** Sidecar path holding the last-assigned seq for a given event log. */
99
+ function seqSidecarPath(eventLogPath) {
100
+ const dir = dirname(eventLogPath);
101
+ const base = basename(eventLogPath);
102
+ return join(dir, `.${base}.seq`);
103
+ }
104
+
105
+ // -- Internal helpers ------------------------------------------------------
106
+
107
+ function ensureDir(dir) {
108
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
109
+ }
110
+
111
+ function nowIso() { return new Date().toISOString(); }
112
+
113
+ /**
114
+ * Read + parse the seq sidecar. Returns 0 when absent / corrupt.
115
+ */
116
+ function readSeqSidecar(eventLogPath) {
117
+ const sidecar = seqSidecarPath(eventLogPath);
118
+ if (!existsSync(sidecar)) return 0;
119
+ try {
120
+ const raw = readFileSync(sidecar, 'utf8').trim();
121
+ const n = Number.parseInt(raw, 10);
122
+ return Number.isFinite(n) && n >= 0 ? n : 0;
123
+ } catch {
124
+ return 0;
125
+ }
126
+ }
127
+
128
+ /** Atomic sidecar write (tmp-rename) so a crash leaves either old or new. */
129
+ function writeSeqSidecar(eventLogPath, seq) {
130
+ const sidecar = seqSidecarPath(eventLogPath);
131
+ ensureDir(dirname(sidecar));
132
+ const tmp = `${sidecar}.tmp.${process.pid}`;
133
+ writeFileSync(tmp, String(seq), { mode: 0o600 });
134
+ renameSync(tmp, sidecar);
135
+ }
136
+
137
+ /**
138
+ * Recover the last-emitted seq when the sidecar is absent (first-ever emit
139
+ * for this log, or sidecar wiped). Scans the live file + the newest .jsonl.gz
140
+ * archive and returns the max `seq` field. Returns 0 when nothing found.
141
+ */
142
+ function recoverLastSeqFromDisk(eventLogPath) {
143
+ let max = 0;
144
+ const seenSeq = (line) => {
145
+ const t = line.trim();
146
+ if (!t) return;
147
+ try {
148
+ const obj = JSON.parse(t);
149
+ if (obj && typeof obj.seq === 'number' && obj.seq > max) max = obj.seq;
150
+ } catch { /* skip a corrupt line */ }
151
+ };
152
+
153
+ // Live file.
154
+ if (existsSync(eventLogPath)) {
155
+ for (const line of readFileSync(eventLogPath, 'utf8').split('\n')) seenSeq(line);
156
+ }
157
+ // Newest archive sibling (lexicographically sorted .jsonl.gz files).
158
+ const dir = dirname(eventLogPath);
159
+ const base = basename(eventLogPath); // e.g. events-W12-A1.jsonl
160
+ const stem = base.endsWith('.jsonl') ? base.slice(0, -'.jsonl'.length) : base;
161
+ if (existsSync(dir)) {
162
+ let archives = [];
163
+ try {
164
+ archives = readdirSync(dir)
165
+ .filter((n) => n.startsWith(`${stem}.`) && n.endsWith('.jsonl.gz'))
166
+ .sort()
167
+ .reverse(); // newest first by date-suffix
168
+ } catch { /* ignore */ }
169
+ for (const a of archives) {
170
+ try {
171
+ const raw = gunzipSync(readFileSync(join(dir, a))).toString('utf8');
172
+ for (const line of raw.split('\n')) seenSeq(line);
173
+ } catch { /* corrupt archive -- skip */ }
174
+ }
175
+ }
176
+ return max;
177
+ }
178
+
179
+ /**
180
+ * In-process serializer keyed by log path. Multiple tap emits to the same
181
+ * log from this process queue on a single Promise chain so seq assignment +
182
+ * append + sidecar update are atomic w.r.t. concurrent callers in-process.
183
+ */
184
+ const APPEND_QUEUES = new Map();
185
+
186
+ function queueAppend(path, work) {
187
+ const prev = APPEND_QUEUES.get(path) || Promise.resolve();
188
+ const next = prev.then(work, work);
189
+ // Store `next` itself (NOT a `.finally()`-wrapped copy) so the cleanup
190
+ // check below `=== next` actually identifies the queue head. A `.finally()`
191
+ // wrapper returns a different Promise object, which would make the
192
+ // identity-check `APPEND_QUEUES.get(path) === next` permanently false and
193
+ // leak Map entries (one per unique log path).
194
+ APPEND_QUEUES.set(path, next);
195
+ next.then(() => {
196
+ // Only delete when no follow-up emit has chained onto `next` -- if a
197
+ // subsequent `queueAppend(path, ...)` call has already set a new head,
198
+ // leave it in place. This keeps the Map bounded by the number of
199
+ // CURRENTLY-IN-FLIGHT log paths rather than ever-seen ones.
200
+ if (APPEND_QUEUES.get(path) === next) APPEND_QUEUES.delete(path);
201
+ }, () => {
202
+ if (APPEND_QUEUES.get(path) === next) APPEND_QUEUES.delete(path);
203
+ });
204
+ return next;
205
+ }
206
+
207
+ /**
208
+ * Count newline-terminated lines in the live file (cheap -- only the live
209
+ * file, not archives, because rotation is gated by the live file's size+lines).
210
+ */
211
+ function countLines(path) {
212
+ if (!existsSync(path)) return 0;
213
+ const raw = readFileSync(path, 'utf8');
214
+ if (!raw) return 0;
215
+ let n = 0;
216
+ for (let i = 0; i < raw.length; i += 1) if (raw.charCodeAt(i) === 10) n += 1;
217
+ return n;
218
+ }
219
+
220
+ /**
221
+ * Apply the per-event 4 KiB cap. Returns the (possibly-truncated) record.
222
+ * If the serialized form would exceed `EVENT_MAX_LINE_BYTES`, we keep the
223
+ * envelope (seq/verb/subagentId/ts/verbId/outcome/payloadDigest) and add a
224
+ * `truncated:true` marker, truncating the digest if even THAT is too large.
225
+ */
226
+ function applySizeCap(record) {
227
+ let line = JSON.stringify(record);
228
+ if (Buffer.byteLength(line, 'utf8') <= EVENT_MAX_LINE_BYTES) return record;
229
+ // Reduce to envelope-only + truncation marker.
230
+ const envelope = {
231
+ seq: record.seq,
232
+ verb: record.verb,
233
+ subagentId: record.subagentId,
234
+ ts: record.ts,
235
+ verbId: record.verbId,
236
+ outcome: record.outcome,
237
+ payloadDigest: record.payloadDigest,
238
+ truncated: true,
239
+ };
240
+ line = JSON.stringify(envelope);
241
+ if (Buffer.byteLength(line, 'utf8') <= EVENT_MAX_LINE_BYTES) return envelope;
242
+ // Last resort -- truncate payloadDigest itself. We still keep the prefix
243
+ // so the truncation is visibly a sha256-<hex>... cut, not a dropped event.
244
+ const room = EVENT_MAX_LINE_BYTES - Buffer.byteLength(JSON.stringify({
245
+ ...envelope, payloadDigest: '',
246
+ }), 'utf8') - 8; // 8 bytes of safety slack
247
+ const dig = String(record.payloadDigest || '');
248
+ envelope.payloadDigest = dig.slice(0, Math.max(0, room));
249
+ return envelope;
250
+ }
251
+
252
+ // -- Rotation ---------------------------------------------------------------
253
+
254
+ /**
255
+ * Rotate the live event log if it has crossed the byte OR line ceiling.
256
+ * Reuses the shared `jsonl-rotation` primitive for the byte ceiling (it
257
+ * gzip-archives + truncates atomically). The library is byte-only, so we
258
+ * implement the line ceiling by re-calling the primitive with `maxBytes: 1`
259
+ * once the line count is at the ceiling -- which forces a rotation because
260
+ * any non-empty file is necessarily larger than 1 byte. Choosing `1` (rather
261
+ * than `0`) is deliberate: `jsonl-rotation.js`'s argument-normaliser treats
262
+ * `maxBytes <= 0` as "fall back to DEFAULT_ROTATE_SIZE", which would silently
263
+ * defeat the force-rotate. `1` survives the normaliser and is unconditionally
264
+ * below any real file's size.
265
+ *
266
+ * Test override: `rotateOptions.maxBytes` / `rotateOptions.maxLines` allow
267
+ * tests to force rotation at small thresholds without writing megabytes.
268
+ */
269
+ function rotateIfNeeded(eventLogPath, rotateOptions = {}) {
270
+ const maxBytes = Number.isFinite(rotateOptions.maxBytes)
271
+ ? rotateOptions.maxBytes : EVENT_BYTE_CEILING;
272
+ const maxLines = Number.isFinite(rotateOptions.maxLines)
273
+ ? rotateOptions.maxLines : EVENT_LINE_CEILING;
274
+
275
+ // Byte path -- delegate to the library.
276
+ const byteResult = rotateJsonlIfNeeded(eventLogPath, { maxBytes });
277
+ if (byteResult.rotated) return byteResult;
278
+
279
+ // Line path -- the library is byte-only, so we force a rotation by
280
+ // calling it again with maxBytes=1 IF the live line count is at/past the
281
+ // ceiling. `1` (not `0`) is critical: the lib normalises `maxBytes <= 0`
282
+ // back to DEFAULT_ROTATE_SIZE (4 MiB), so `0` would not actually force.
283
+ const lineCount = countLines(eventLogPath);
284
+ if (lineCount >= maxLines && existsSync(eventLogPath) && statSync(eventLogPath).size > 0) {
285
+ return rotateJsonlIfNeeded(eventLogPath, { maxBytes: 1 });
286
+ }
287
+ return byteResult;
288
+ }
289
+
290
+ // -- The shared append core -------------------------------------------------
291
+
292
+ /**
293
+ * Append one event to the per-subagent log. Assigns `seq` (monotonic across
294
+ * rotation), applies the size cap, rotates if at ceiling, writes the line,
295
+ * then persists the seq sidecar. SYNCHRONOUS file I/O so that the post-tap
296
+ * sequence (rotate -> append -> sidecar) is observably atomic from the
297
+ * caller's point of view.
298
+ *
299
+ * The caller is responsible for serializing concurrent invocations on the
300
+ * SAME `path` (either via an in-process queue -- the tap's `emitEvent` does
301
+ * this -- or via a §3 fs lock -- the `event.emit` verb does this).
302
+ *
303
+ * Returns the persisted event record (with assigned seq + any truncation).
304
+ */
305
+ export function assignNextSeqAndAppend({ path, envelope, rotateOptions }) {
306
+ ensureDir(dirname(path));
307
+
308
+ // Determine the next seq.
309
+ let lastSeq = readSeqSidecar(path);
310
+ if (lastSeq === 0) {
311
+ // First-ever emit OR sidecar wiped -- recover from disk.
312
+ lastSeq = recoverLastSeqFromDisk(path);
313
+ }
314
+ const nextSeq = lastSeq + 1;
315
+
316
+ // Rotation BEFORE the append (per contract §5 -- "on reaching either
317
+ // ceiling, rotate ... and start a fresh log; seq continues monotonically
318
+ // across rotation"). seq does NOT reset because we keep the sidecar.
319
+ rotateIfNeeded(path, rotateOptions);
320
+
321
+ // Compose the record + size cap.
322
+ const record = applySizeCap({ ...envelope, seq: nextSeq });
323
+
324
+ // Append the JSONL line.
325
+ appendFileSync(path, `${JSON.stringify(record)}\n`, { mode: 0o600 });
326
+
327
+ // Persist the sidecar AFTER the append succeeds. If we crash between the
328
+ // append and the sidecar write, the next emit's recovery scans disk and
329
+ // recovers the true max seq -- so we are crash-safe with at most a
330
+ // re-derived seq, never a reset.
331
+ writeSeqSidecar(path, nextSeq);
332
+
333
+ return record;
334
+ }
335
+
336
+ // -- Public surface --------------------------------------------------------
337
+
338
+ /**
339
+ * The implementation behind the dispatcher's `_emitEvent` observability tap.
340
+ * Fire-and-forget, AFTER lock release. Swallows all errors -- writes any
341
+ * failure to stderr and returns. Never throws, never propagates.
342
+ *
343
+ * @param {{projectRoot:string, waveId?:string, subagentId?:string,
344
+ * verb:string, verbId:string, outcome:string, payloadDigest:string,
345
+ * ts?:string, rotateOptions?:object}} input
346
+ * @returns {Promise<void>}
347
+ */
348
+ export async function emitEvent(input) {
349
+ if (!input || typeof input !== 'object') return;
350
+ const {
351
+ projectRoot, waveId, subagentId, verb, verbId, outcome, payloadDigest,
352
+ ts, rotateOptions,
353
+ } = input;
354
+ if (!projectRoot || !verb || !verbId) return; // soft-fail, no throw
355
+ const path = resolveEventLogPath(projectRoot, waveId, subagentId);
356
+
357
+ // Build the envelope per contract §5.
358
+ const envelope = {
359
+ verb,
360
+ subagentId: subagentId || 'parent',
361
+ ts: ts || nowIso(),
362
+ verbId,
363
+ outcome: outcome || 'ok',
364
+ payloadDigest: payloadDigest || '',
365
+ };
366
+
367
+ // Serialize per-path so concurrent tap emits to the same log don't race
368
+ // on the seq sidecar.
369
+ await queueAppend(path, async () => {
370
+ try {
371
+ assignNextSeqAndAppend({ path, envelope, rotateOptions });
372
+ } catch (err) {
373
+ // NEVER propagate -- the tap is fire-and-forget. Log to stderr.
374
+ try {
375
+ process.stderr.write(`[ijfw state-events] emit failed: ${err?.message || err}\n`);
376
+ } catch { /* even stderr failed -- swallow */ }
377
+ }
378
+ });
379
+ }
380
+
381
+ /**
382
+ * Synchronous core for callers that ALREADY hold a §3 lock on the event log
383
+ * (currently: the `event.emit` verb in state-sdk.js). Bypasses the in-process
384
+ * queue -- the lock serializes; returns the persisted record. Errors here DO
385
+ * propagate -- the caller is journaled and wants to surface the failure.
386
+ */
387
+ export function appendUnderHeldLock({ path, envelope, rotateOptions }) {
388
+ return assignNextSeqAndAppend({ path, envelope, rotateOptions });
389
+ }
390
+
391
+ // -- pollEvents reader -----------------------------------------------------
392
+
393
+ /**
394
+ * Explicit-interval reader. Returns events with `seq > since` across the
395
+ * live file and any rotated archive(s). NEVER uses `fs.watch`.
396
+ *
397
+ * Cursor shape: a plain number (the highest seq the consumer has already
398
+ * processed). `since: 0` -> the entire stream.
399
+ *
400
+ * Return shape: `{ events: <array>, cursor: <number> }` -- `cursor` is the
401
+ * highest seq present (suitable to feed back as `since` on the next poll).
402
+ *
403
+ * Spans rotation: scans the most-recent .jsonl.gz archive(s) when the
404
+ * cursor predates the live file's first line.
405
+ */
406
+ export function pollEvents(input) {
407
+ const { projectRoot, waveId, subagentId } = input || {};
408
+ const since = Number.isFinite(input?.since) ? input.since : 0;
409
+ const path = resolveEventLogPath(projectRoot, waveId, subagentId);
410
+
411
+ const out = [];
412
+ let maxSeq = since;
413
+
414
+ const consumeRaw = (raw) => {
415
+ for (const line of raw.split('\n')) {
416
+ const t = line.trim();
417
+ if (!t) continue;
418
+ let obj;
419
+ try { obj = JSON.parse(t); } catch { continue; }
420
+ if (!obj || typeof obj.seq !== 'number') continue;
421
+ if (obj.seq > since) out.push(obj);
422
+ if (obj.seq > maxSeq) maxSeq = obj.seq;
423
+ }
424
+ };
425
+
426
+ // Read archives FIRST so the returned `events` array stays seq-sorted.
427
+ // Archives are date-stamped; we scan ALL .jsonl.gz siblings so a poll with
428
+ // a very old `since` recovers events from a rotated archive.
429
+ const dir = dirname(path);
430
+ const base = basename(path);
431
+ const stem = base.endsWith('.jsonl') ? base.slice(0, -'.jsonl'.length) : base;
432
+ if (existsSync(dir)) {
433
+ let archives = [];
434
+ try {
435
+ archives = readdirSync(dir)
436
+ .filter((n) => n.startsWith(`${stem}.`) && n.endsWith('.jsonl.gz'))
437
+ .sort(); // oldest first by date-suffix
438
+ } catch { /* ignore */ }
439
+ for (const a of archives) {
440
+ try {
441
+ const raw = gunzipSync(readFileSync(join(dir, a))).toString('utf8');
442
+ consumeRaw(raw);
443
+ } catch { /* skip corrupt archive */ }
444
+ }
445
+ }
446
+
447
+ // Then the live file.
448
+ if (existsSync(path)) {
449
+ consumeRaw(readFileSync(path, 'utf8'));
450
+ }
451
+
452
+ // Sort by seq (archives + live may overlap in degenerate cases).
453
+ out.sort((a, b) => a.seq - b.seq);
454
+
455
+ // Return cursor = max seen seq, or `since` if nothing seen + no file at all.
456
+ // When the log is absent entirely AND since:0 was passed, cursor stays 0.
457
+ const cursor = out.length > 0 ? out[out.length - 1].seq : maxSeq;
458
+ return { events: out, cursor };
459
+ }