session-orchestrator 5.3.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -0,0 +1,393 @@
1
+ /**
2
+ * events-retention-banner.mjs — session-start probe: does the rotated events
3
+ * ledger still hold the TIME WINDOW its readers need? (#1401 part 3, variant a)
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * `.orchestrator/metrics/events.jsonl` rotates into
8
+ * `_archive/events-<firstTs>_<lastTs>.jsonl`, and `events-rotation.max-backups`
9
+ * bounds the NUMBER of archives kept (`events-rotation.mjs` `pruneArchives`).
10
+ * The readers of the rotated ledger need a TIME window instead. Count and time
11
+ * are different units (`.claude/rules/host-resources.md` § HR-103), so nothing
12
+ * noticed when a high write rate made "five archives" mean "less history than
13
+ * a reader reads": the pruned records simply stopped existing.
14
+ *
15
+ * Each reader declares its need as `REQUIRED_EVENTS_WINDOW_DAYS` beside its call
16
+ * to `readEventsWithRotations(`, `scanEventsBackwards(` or
17
+ * `listEventSourcesNewestFirst(` — a finite number of days, or `null` when no
18
+ * day count bounds its question (it then cannot set a retention floor, and its
19
+ * declaration comment states what a cut history does to its answer). This probe
20
+ * takes the MAX of the finite declarations and compares it against the span
21
+ * the retained ledger covers. No new Session Config key: it reads the existing
22
+ * `events-rotation.max-backups` from the config the runner passes.
23
+ *
24
+ * ## When it warns (HR-101 — a signal may only warn if it is rare)
25
+ *
26
+ * Only when the archive ring is FULL (`archives >= max-backups`). Below that
27
+ * the rotator prunes nothing under the current config, so the ledger only
28
+ * grows — a two-hour-old repo covering two hours is not "short retention", and
29
+ * judging it so would fire on every young repo's start.
30
+ *
31
+ * "Not full" does not mean "never pruned": after `max-backups` is RAISED, the
32
+ * archives pruned under the old, smaller ring stay gone. The oldest retained
33
+ * archive then opens with a rotation record whose `archived_as` names a
34
+ * predecessor that no longer exists — that is the hole, and the silent
35
+ * coverage figure then starts at the oldest archive exactly as on a full ring,
36
+ * never at a legacy `events.jsonl.N` older than the hole.
37
+ *
38
+ * ## Rare vs. broken (HR-105, #1489 item 2)
39
+ *
40
+ * Measured 2026-10-02: 4 of 35 fleet ledgers hold any archive, at most 3 at
41
+ * `max-backups: 5`, so the warning has never fired — and a warning that never
42
+ * fires looks the same whether it is rare or dead. Every MEASURED answer
43
+ * therefore carries the measurement, warn or not: `coverageDays`,
44
+ * `oldestEventAt`, `requiredDays`/`requiredBy`, `archives`, `maxBackups`.
45
+ * `coverageDays` is now minus the oldest archive's start on a FULL ring — the
46
+ * span the verdict judges — and below it when that archive's predecessor is
47
+ * gone (see above); otherwise now minus the oldest stamp of any source
48
+ * (archives, legacy ring, active file), where nothing was pruned.
49
+ * The registry entry's `telemetry` (`session-start-probes.mjs`) persists
50
+ * `coverage_days`, `archives`, `max_backups` and `required_days` as `measure`
51
+ * on this probe's element of `orchestrator.probes.completed`; `oldestEventAt`
52
+ * and `requiredBy` reach the caller only.
53
+ *
54
+ * ## Three states, never two
55
+ *
56
+ * - **not measured** — no live ledger and no `ARCHIVE_NAME_RE` archive in
57
+ * `_archive/` (absent, empty, or holding only foreign files):
58
+ * `{severity:'ok', kind:'not-measured', reason:'no-events-ledger'}`. The
59
+ * registry's precondition (`hasRotationArchive` in
60
+ * `session-start-probes.mjs`) lists `_archive/` the same way and skips
61
+ * exactly these inputs, so the runner records them as `skipped`, never as
62
+ * `ran-clean` — which is what severity ok would otherwise become.
63
+ * - **unmeasurable** — `_archive/` exists but cannot be listed, a reader's
64
+ * declaration cannot be read, the oldest retained stamp lies after now
65
+ * (a future-dated archive or a clock set back — the span would be
66
+ * negative), or no source carries a stamp at all (no archive, and no
67
+ * parseable `timestamp` in the head of the active file or a legacy-ring
68
+ * file — #1498: it answered `ring-not-full` with `coverageDays: null` and
69
+ * recorded as `ran-clean`): `{severity:'warn', degraded:true}`. An
70
+ * unreadable source must never read as "retention is fine".
71
+ * - **measured** — `{severity:'ok', kind:'ring-not-full'|'no-finite-window'|'covered', …}`
72
+ * or `{severity:'warn', kind:'retention-short', message, …}`.
73
+ *
74
+ * Cost: two `readdirSync` of `_archive/` per session start (the registry
75
+ * precondition, then this probe), below a full ring one ≤64 KiB head read of
76
+ * the oldest archive (for its rotation record) and — unless that shows a
77
+ * pruned predecessor — of the active file and of each legacy-ring file, and — on EVERY measured start,
78
+ * before the ring check, because `requiredDays` is part of the silent
79
+ * measurement — the import of every reader module to read its declaration:
80
+ * 20-28 ms cold for all seven in a fresh process (measured 2026-10-02, load
81
+ * average ~8); two of them are session-start probes and two the SessionStart
82
+ * backfill, already loaded in that process. So a reader module that fails to import degrades this probe on
83
+ * every start, not only on a full ring.
84
+ *
85
+ * @module scripts/lib/events-retention-banner
86
+ */
87
+
88
+ import { closeSync, existsSync, openSync, readSync, readdirSync } from 'node:fs';
89
+ import path from 'node:path';
90
+
91
+ import { ARCHIVE_DIR_NAME, ARCHIVE_NAME_RE, LEGACY_RING_MAX, ROTATION_EVENT } from './events-schema.mjs';
92
+
93
+ const DAY_MS = 24 * 60 * 60 * 1000;
94
+
95
+ /** `_parseEventsRotation`'s own default (`scripts/lib/config/events-rotation.mjs`). */
96
+ const DEFAULT_MAX_BACKUPS = 5;
97
+
98
+ /**
99
+ * How much of a ledger file's head is read for its first timestamp. NAMED
100
+ * CEILING (BV-004): one record is ~300 B in this repo's ledger, so 64 KiB holds
101
+ * hundreds; a file whose first 64 KiB carry no parseable `timestamp` reports
102
+ * no stamp rather than a guessed one.
103
+ */
104
+ const HEAD_BYTES = 64 * 1024;
105
+
106
+ /**
107
+ * Every module that reads the rotated ledger. Census 2026-10-02 @ `84c107a1`:
108
+ * `rg -n "readEventsWithRotations\(|scanEventsBackwards\(|listEventSourcesNewestFirst\(" --glob '!tests/**' scripts hooks skills`
109
+ * → 6 call sites beside the definitions in `events.mjs` (and its internal
110
+ * `listEventSourcesNewestFirst` call); the seventh, `session-close-backfill`,
111
+ * joined with #1498. A test re-runs that census, so an eighth reader cannot
112
+ * join unlisted.
113
+ */
114
+ export const EVENTS_WINDOW_READERS = Object.freeze([
115
+ { label: 'eval/engine', spec: './eval/engine.mjs' },
116
+ { label: 'telemetry/sync', spec: './telemetry/sync.mjs' },
117
+ { label: 'tmux-layout/telemetry-stats', spec: './tmux-layout/telemetry-stats.mjs' },
118
+ { label: 'maintenance-due-banner', spec: './maintenance-due-banner.mjs' },
119
+ { label: 'instruction-budget-guard', spec: './instruction-budget-guard.mjs' },
120
+ { label: 'backfill-abandoned-sessions', spec: '../backfill-abandoned-sessions.mjs' },
121
+ { label: 'session-close-backfill', spec: './session-close-backfill.mjs' },
122
+ ]);
123
+
124
+ /**
125
+ * Epoch ms of the START of an archive's range, from its name alone. An
126
+ * `unknown` first stamp falls back to the LAST stamp — a later instant, so the
127
+ * covered span is understated and the probe errs toward reporting.
128
+ *
129
+ * Total over `ARCHIVE_NAME_RE` matches, the only names it is given: the regex
130
+ * guarantees two digit stamps, and `Date.UTC` normalizes an out-of-range field
131
+ * (month 13 → January of the next year) instead of returning NaN. The NaN
132
+ * return exists only to type the regex miss; no caller can reach it.
133
+ *
134
+ * @param {string} name — matches `ARCHIVE_NAME_RE`
135
+ * @returns {number} epoch ms; NaN only for a name outside `ARCHIVE_NAME_RE`
136
+ */
137
+ function archiveStartMs(name) {
138
+ const m = /^events-(\d{8}T\d{6}Z|unknown)_(\d{8}T\d{6}Z)/.exec(name);
139
+ if (!m) return Number.NaN;
140
+ const stamp = m[1] === 'unknown' ? m[2] : m[1];
141
+ const p = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(stamp);
142
+ return p ? Date.UTC(+p[1], +p[2] - 1, +p[3], +p[4], +p[5], +p[6]) : Number.NaN;
143
+ }
144
+
145
+ /**
146
+ * The parseable JSON records within the first {@link HEAD_BYTES} of `file`, in
147
+ * file order; `[]` when absent or unreadable. Malformed lines — and the partial
148
+ * last line of the head — are skipped, never guessed at.
149
+ *
150
+ * @param {string} file
151
+ * @returns {Array<*>}
152
+ */
153
+ function headRecords(file) {
154
+ let fd;
155
+ let text = '';
156
+ try {
157
+ fd = openSync(file, 'r');
158
+ const buf = Buffer.alloc(HEAD_BYTES);
159
+ const n = readSync(fd, buf, 0, HEAD_BYTES, 0);
160
+ text = buf.subarray(0, n).toString('utf8');
161
+ } catch {
162
+ /* absent or unreadable — no records from this file */
163
+ } finally {
164
+ if (fd !== undefined) {
165
+ try {
166
+ closeSync(fd);
167
+ } catch {
168
+ /* best-effort */
169
+ }
170
+ }
171
+ }
172
+ const records = [];
173
+ for (const line of text.split('\n')) {
174
+ try {
175
+ records.push(JSON.parse(line));
176
+ } catch {
177
+ /* malformed, or the partial last line of the head */
178
+ }
179
+ }
180
+ return records;
181
+ }
182
+
183
+ /**
184
+ * Epoch ms of the first parseable `timestamp` within the head of `file` — the
185
+ * file's oldest event, since every ledger file is append-ordered. NaN when
186
+ * absent, unreadable, or stampless: the caller then has no stamp from this
187
+ * file, never a guessed one.
188
+ *
189
+ * @param {string} file
190
+ * @returns {number}
191
+ */
192
+ function firstTimestampMs(file) {
193
+ for (const rec of headRecords(file)) {
194
+ const ms = Date.parse(rec?.timestamp);
195
+ if (Number.isFinite(ms)) return ms;
196
+ }
197
+ return Number.NaN;
198
+ }
199
+
200
+ /**
201
+ * Was history pruned before `oldest`, the oldest retained archive? Its head
202
+ * carries the rotation record written when its predecessor was archived, and
203
+ * that record's `archived_as` names the predecessor: an archive name absent
204
+ * from `names` was pruned — under a smaller ring, before `max-backups` was
205
+ * raised — or deleted. `false` when the head holds no rotation record: the
206
+ * hole then goes unseen, it is never invented.
207
+ *
208
+ * Named ceiling (BV-004, #1498). A head without a rotation record is the FIRST
209
+ * archive a ledger ever wrote (its file predates any rotation, and the legacy
210
+ * ring wrote none) — no archive can have been pruned before it, so nothing is
211
+ * missed there. The tombstone is appended right after the rename, so it sits on
212
+ * the first lines, far inside {@link HEAD_BYTES}. The blind case is a later
213
+ * archive whose tombstone was never written (`maybeRotate` → `recordWritten:
214
+ * false`) AND whose predecessor is gone. Measured 2026-10-02 over the 5 fleet
215
+ * `_archive/` dirs: 4 of 4 first archives carry no tombstone, 2 of 2 later
216
+ * archives carry it on line 0. Revisit if a census finds a later archive
217
+ * without one.
218
+ *
219
+ * @param {string} archiveDir
220
+ * @param {string} oldest — a member of `names`
221
+ * @param {string[]} names — the retained `ARCHIVE_NAME_RE` archives
222
+ * @returns {boolean}
223
+ */
224
+ function predecessorPruned(archiveDir, oldest, names) {
225
+ const rotation = headRecords(path.join(archiveDir, oldest)).find((r) => r?.event === ROTATION_EVENT);
226
+ if (typeof rotation?.archived_as !== 'string') return false;
227
+ const predecessor = path.basename(rotation.archived_as);
228
+ return ARCHIVE_NAME_RE.test(predecessor) && !names.includes(predecessor);
229
+ }
230
+
231
+ /**
232
+ * The largest finite window any reader declares, with the reader naming it.
233
+ *
234
+ * @param {ReadonlyArray<{label: string, spec: string}>} readers
235
+ * @returns {Promise<{days: number|null, by: string|null}>}
236
+ * @throws when a reader cannot be imported or declares a non-window value
237
+ */
238
+ async function maxRequiredWindow(readers) {
239
+ const values = await Promise.all(
240
+ readers.map(async (r) => {
241
+ const mod = await import(new URL(r.spec, import.meta.url).href);
242
+ const v = mod.REQUIRED_EVENTS_WINDOW_DAYS;
243
+ if (v !== null && !(typeof v === 'number' && Number.isFinite(v) && v > 0)) {
244
+ throw new Error(`${r.label} declares no REQUIRED_EVENTS_WINDOW_DAYS`);
245
+ }
246
+ return { days: v, by: r.label };
247
+ }),
248
+ );
249
+ let best = { days: null, by: null };
250
+ for (const v of values) {
251
+ if (v.days !== null && (best.days === null || v.days > best.days)) best = v;
252
+ }
253
+ return best;
254
+ }
255
+
256
+ /**
257
+ * Compare the retained events ledger's time span against the window its
258
+ * readers need. Never throws; never blocks.
259
+ *
260
+ * @param {object} opts
261
+ * @param {string} opts.repoRoot
262
+ * @param {object} [opts.config] — parsed Session Config (`events-rotation.max-backups`)
263
+ * @param {number} [opts.now=Date.now()] — injectable clock, epoch ms
264
+ * @returns {Promise<null|{severity: 'ok'|'warn', kind: string, message?: string,
265
+ * degraded?: boolean, reason?: string, coverageDays?: number,
266
+ * oldestEventAt?: string, requiredDays?: number|null, requiredBy?: string|null,
267
+ * archives?: number, maxBackups?: number}>} `null` only without a `repoRoot`.
268
+ */
269
+ export async function checkEventsRetention({ repoRoot, config, now = Date.now() } = {}) {
270
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
271
+ const metricsDir = path.join(repoRoot, '.orchestrator', 'metrics');
272
+ const archiveDir = path.join(metricsDir, ARCHIVE_DIR_NAME);
273
+ const activePath = path.join(metricsDir, 'events.jsonl');
274
+ const nowMs = typeof now === 'number' && Number.isFinite(now) ? now : Date.now();
275
+
276
+ let names;
277
+ try {
278
+ names = readdirSync(archiveDir).filter((n) => ARCHIVE_NAME_RE.test(n));
279
+ } catch (err) {
280
+ if (err?.code !== 'ENOENT') {
281
+ return {
282
+ severity: 'warn',
283
+ degraded: true,
284
+ kind: 'unmeasurable',
285
+ message: `⚠ events-retention: could not list ${ARCHIVE_DIR_NAME}/ (${err?.code ?? 'error'}) — retention span not measured, which is not the same as covered.`,
286
+ };
287
+ }
288
+ names = [];
289
+ }
290
+ // Mirrored by the registry precondition, which skips these inputs before the
291
+ // runner could record this severity-ok answer as `ran-clean`.
292
+ if (names.length === 0 && !existsSync(activePath)) {
293
+ return { severity: 'ok', kind: 'not-measured', reason: 'no-events-ledger' };
294
+ }
295
+
296
+ let required;
297
+ try {
298
+ required = await maxRequiredWindow(EVENTS_WINDOW_READERS);
299
+ } catch (err) {
300
+ return {
301
+ severity: 'warn',
302
+ degraded: true,
303
+ kind: 'unmeasurable',
304
+ message: `⚠ events-retention: required window not readable (${String(err?.message ?? err).slice(0, 120)}) — retention span not judged, which is not the same as covered.`,
305
+ };
306
+ }
307
+
308
+ const raw = config?.['events-rotation']?.['max-backups'];
309
+ const maxBackups = Number.isInteger(raw) && raw >= 1 ? raw : DEFAULT_MAX_BACKUPS;
310
+ const ringFull = names.length >= maxBackups;
311
+
312
+ // A FULL ring is judged on its archives alone. The rotator prunes archives
313
+ // but never the legacy ring, so a legacy `events.jsonl.N` from before the
314
+ // migration outlives the pruned archives and its stamp would span the hole
315
+ // they left (#1489 review MED-1: a `.1` from April turned 20 hours of
316
+ // contiguous history into "173.5d, covered"). Below a full ring the same
317
+ // hole exists when an archive was pruned under an earlier, smaller ring
318
+ // (`max-backups` raised since): the oldest archive's rotation record names a
319
+ // predecessor that is gone. Only without such a hole does every source
320
+ // count toward the silent coverage figure.
321
+ const oldestArchive = names.reduce(
322
+ (a, b) => (a === null || archiveStartMs(b) < archiveStartMs(a) ? b : a),
323
+ null,
324
+ );
325
+ const holeBeforeArchives = ringFull || (oldestArchive !== null && predecessorPruned(archiveDir, oldestArchive, names));
326
+ const starts = [
327
+ ...names.map(archiveStartMs),
328
+ ...(holeBeforeArchives
329
+ ? []
330
+ : [
331
+ ...Array.from({ length: LEGACY_RING_MAX }, (_, i) => `${activePath}.${i + 1}`)
332
+ .filter((p) => existsSync(p))
333
+ .map(firstTimestampMs),
334
+ firstTimestampMs(activePath),
335
+ ]),
336
+ ].filter(Number.isFinite);
337
+ const oldestMs = starts.length > 0 ? Math.min(...starts) : Number.NaN;
338
+
339
+ // Every retained source dated after now — a future-dated archive, or a clock
340
+ // set back — makes the span negative. That is no measurement, and an
341
+ // unmeasured span must never record as clean, so it degrades like an
342
+ // unreadable source instead of persisting a negative `coverage_days`.
343
+ if (oldestMs > nowMs) {
344
+ return {
345
+ severity: 'warn',
346
+ degraded: true,
347
+ kind: 'unmeasurable',
348
+ message: `⚠ events-retention: the oldest retained event is dated ${new Date(oldestMs).toISOString()}, after now — retention span not measured, which is not the same as covered.`,
349
+ };
350
+ }
351
+
352
+ // No stamp at all (#1498) — reachable only without archives (every archive
353
+ // dates itself by name), when the first 64 KiB of the active file and of
354
+ // every legacy-ring file carry no parseable `timestamp`. That span was not
355
+ // measured; answered `ring-not-full` with `coverageDays: null` it recorded
356
+ // as `ran-clean`. Census 2026-10-02: 0 of 37 fleet `events.jsonl` heads are
357
+ // stampless, so this degrades rarely (HR-101) — and visibly (HR-105).
358
+ if (!Number.isFinite(oldestMs)) {
359
+ return {
360
+ severity: 'warn',
361
+ degraded: true,
362
+ kind: 'unmeasurable',
363
+ message: `⚠ events-retention: no parseable timestamp in the first ${HEAD_BYTES / 1024} KiB of events.jsonl (or of a legacy events.jsonl.N) and no ${ARCHIVE_DIR_NAME}/ archive — retention span not measured, which is not the same as covered.`,
364
+ };
365
+ }
366
+
367
+ const measured = {
368
+ coverageDays: (nowMs - oldestMs) / DAY_MS,
369
+ oldestEventAt: new Date(oldestMs).toISOString(),
370
+ requiredDays: required.days,
371
+ requiredBy: required.by,
372
+ archives: names.length,
373
+ maxBackups,
374
+ };
375
+
376
+ // Ring not full ⇒ nothing is pruned under this config, so the retained
377
+ // ledger only grows; any older hole is already excluded from the figure
378
+ // above. Never a warning here — see the module header (HR-101).
379
+ if (!ringFull) return { severity: 'ok', kind: 'ring-not-full', ...measured };
380
+ if (required.days === null) return { severity: 'ok', kind: 'no-finite-window', ...measured };
381
+ // Finite here: the ring is full, so `names` is non-empty and
382
+ // `archiveStartMs` is total over `ARCHIVE_NAME_RE` matches.
383
+ if (measured.coverageDays >= required.days) return { severity: 'ok', kind: 'covered', ...measured };
384
+ const cov = measured.coverageDays.toFixed(1);
385
+ return {
386
+ severity: 'warn',
387
+ kind: 'retention-short',
388
+ // Names what the rule judged (HR-106): the measured span against the
389
+ // reader's DECLARED window — not a claim about how the reader reads.
390
+ message: `⚠ events-retention: the rotated events ledger covers ${cov}d (${names.length} archives at events-rotation.max-backups: ${maxBackups}), less than the ${required.days}d window ${required.by} declares as REQUIRED_EVENTS_WINDOW_DAYS — events older than ${cov}d are not retained; raise events-rotation.max-backups or max-size-mb.`,
391
+ ...measured,
392
+ };
393
+ }
@@ -6,7 +6,8 @@
6
6
  * synchronous fs calls (the hook is short-lived; async adds complexity).
7
7
  *
8
8
  * Issue #251 (CRITICAL). Rotation fires only at session-start, not per-append —
9
- * per-append overhead is wasteful given ~6 KiB/day growth.
9
+ * a size check per append buys nothing when one 10 MiB file takes days to
10
+ * weeks to fill (measured growth: see {@link pruneArchives}).
10
11
  *
11
12
  * Rename safety (POSIX): atomic rename is safe with in-flight writers. Old fds
12
13
  * continue writing to the original inode (now the archive); new writers will
@@ -104,8 +105,9 @@ function uniqueArchivePath(dir, firstTs, lastTs) {
104
105
  const to = lastTs ? compactStamp(lastTs) : compactStamp(new Date().toISOString());
105
106
  const base = `events-${from}_${to}`;
106
107
  let candidate = path.join(dir, `${base}.jsonl`);
107
- // Ceiling (BV-004): 999 same-range archives. Rotation fires 2-3x/year at the
108
- // measured ~6 KiB/day growth, and the range is content-derived, so a second
108
+ // Ceiling (BV-004): 999 same-range archives. Rotation fires every ~52 days in
109
+ // this repo and every ~5 days in the busiest fleet repo (measured 2026-10-02,
110
+ // see `pruneArchives`), and the range is content-derived, so a second
109
111
  // collision already implies something is re-rotating identical content.
110
112
  // Revisit if a `pruned`-less archive dir is ever seen holding a `-3` suffix.
111
113
  for (let n = 2; existsSync(candidate); n += 1) {
@@ -122,8 +124,19 @@ function uniqueArchivePath(dir, firstTs, lastTs) {
122
124
  *
123
125
  * The cap is kept rather than dropped because `events-rotation.max-backups` is
124
126
  * a live, documented config key: a key whose reader silently stops enforcing it
125
- * is the "config key nobody produces" defect in reverse. At the measured
126
- * rotation rate `max-backups: 5` is roughly two years of history.
127
+ * is the "config key nobody produces" defect in reverse.
128
+ *
129
+ * What `max-backups: 5` retains, measured 2026-10-02 (line bytes summed per
130
+ * calendar day of each record's `timestamp`, via a `node` read of
131
+ * `.orchestrator/metrics/events.jsonl` and `_archive/`): this repo's active
132
+ * ledger grew 2,770,612 B over 13.7 days (2026-09-18 → 2026-10-02), i.e.
133
+ * ~202,570 B/day with a 1,207,177 B peak day; across 35 fleet ledgers the median
134
+ * active-file rate was 59,388 B/day, and the busiest repo averaged ~2.1 MB/day
135
+ * (43,978,706 B over 2026-09-11 → 2026-10-02) with a 10,248,010 B peak day.
136
+ * One archive holds ≥10 MiB, so five archives retain ~8.5 months here and ~3.5
137
+ * weeks in the busiest repo — not the "roughly two years" this comment used to
138
+ * claim from a ~6 KiB/day figure. Whether that suffices is judged per reader
139
+ * window by `events-retention-banner.mjs`.
127
140
  *
128
141
  * Only names matching {@link ARCHIVE_NAME_RE} are eligible, and the archive
129
142
  * just written is never a candidate. A failed unlink is swallowed: leaving one
@@ -221,7 +234,9 @@ export function maybeRotate({ logPath, maxSizeMb, maxBackups, enabled } = {}) {
221
234
 
222
235
  // Read BEFORE the rename: the content is what names the archive. Cost
223
236
  // ceiling (BV-004): one full read of a file at the rotation threshold —
224
- // ~50 ms and ~40 MB transient at the default 10 MB cap, paid 2-3x/year.
237
+ // ~50 ms and ~40 MB transient at the default 10 MB cap, paid once per
238
+ // rotation (every ~52 days here, ~5 days in the busiest fleet repo —
239
+ // measured 2026-10-02, see `pruneArchives`).
225
240
  // Revisit if `max-size-mb` is ever raised past ~100.
226
241
  const { records, malformedLines } = parseEventLines(readFileSync(logPath, 'utf8'));
227
242
  const { firstTs, lastTs } = summarizeEventRecords(records);
@@ -388,7 +388,11 @@ function waveForRecord(root, attribution) {
388
388
  * metrics/events.jsonl` instead of the module-level `SO_PROJECT_DIR` default
389
389
  * (#941). Ignored when `opts.filePath` is given (explicit path wins). This is
390
390
  * the clean interface replacing the hand-built `join(repoRoot, …)` recipes that
391
- * used to open-code this destination at each call-site.
391
+ * used to open-code this destination at each call-site. A relative or empty
392
+ * `repoRoot` is refused with one stderr WARN and nothing is written (no line,
393
+ * no directory, no webhook): joining it would land relative to the working
394
+ * directory — the incident class where an unset env var produced
395
+ * `undefined/…/.orchestrator/metrics/events.jsonl` under the cwd.
392
396
  * @returns {Promise<void>}
393
397
  */
394
398
  export async function emitEvent(type, payload = {}, opts = {}) {
@@ -434,11 +438,26 @@ export async function emitEvent(type, payload = {}, opts = {}) {
434
438
 
435
439
  // Ensure the destination directory exists before appending. Resolution order:
436
440
  // 1. explicit opts.filePath (a pre-resolved path — #611)
437
- // 2. opts.repoRoot → <repoRoot>/.orchestrator/metrics/events.jsonl (#941)
441
+ // 2. opts.repoRoot → <repoRoot>/.orchestrator/metrics/events.jsonl (#941),
442
+ // or refused when relative/empty (WARN, no write — see below)
438
443
  // 3. the SO_PROJECT_DIR default (unchanged for 2-arg callers)
439
444
  // eventsFilePath(undefined) falls through to its SO_PROJECT_DIR default, so a
440
445
  // caller passing neither behaves EXACTLY as before (additive) — except under
441
446
  // the test-only EVENTS_LEDGER_SANDBOX_ENV seam, which only 3. honours.
447
+ // An explicit filePath is never second-guessed. A defined repoRoot that is not
448
+ // absolute would be joined relative to the cwd, so it is refused: WARN and
449
+ // return (hooks must never throw). The value itself is not printed — it may
450
+ // carry a host-local path.
451
+ if (
452
+ opts.filePath === undefined &&
453
+ opts.repoRoot !== undefined &&
454
+ (typeof opts.repoRoot !== 'string' || !path.isAbsolute(opts.repoRoot))
455
+ ) {
456
+ process.stderr.write(
457
+ `[events] ${String(type)} not emitted: opts.repoRoot is not an absolute path; refusing to write relative to the working directory.\n`,
458
+ );
459
+ return;
460
+ }
442
461
  const filePath = opts.filePath ?? eventsFilePath(opts.repoRoot);
443
462
  await fs.mkdir(path.dirname(filePath), { recursive: true });
444
463
  await fs.appendFile(filePath, line, 'utf8');
@@ -807,7 +826,13 @@ export function listEventSourcesNewestFirst(opts = {}) {
807
826
  * a stop, the walk ends with `truncated: true` — which is NOT "not found":
808
827
  * the caller must report undeterminable, never a clean negative.
809
828
  * @returns {{stopped: boolean, truncated: boolean, malformed_lines: number,
810
- * sources: string[], unreadable: string[]}}
829
+ * sources: string[], unreadable: string[], gaps: object[]}} `gaps`
830
+ * (#1489): every rotation tombstone the walk passed, on any line and
831
+ * regardless of `filter`, whose archive is not one of this walk's sources — pruned
832
+ * (`missing-archive`) or renamed out of `ARCHIVE_NAME_RE`
833
+ * (`unindexed-archive`), the same two kinds and fields
834
+ * {@link readEventsWithRotations} reports. Non-empty ⇒ the history the walk
835
+ * read is CUT, so a walk that found nothing has not proven "never happened".
811
836
  */
812
837
  export function scanEventsBackwards(opts = {}) {
813
838
  const { onRecord, filter, budgetMs } = opts;
@@ -821,16 +846,58 @@ export function scanEventsBackwards(opts = {}) {
821
846
 
822
847
  const scanned = [];
823
848
  const unreadable = [];
849
+ const gaps = [];
824
850
  let malformed = 0;
825
851
  let stopped = false;
826
852
  let truncated = false;
827
853
 
854
+ const sources = listEventSourcesNewestFirst(opts);
855
+ const listed = new Set(sources.map((s) => s.path));
856
+ // Same construction as `discoverArchives`, so a listed archive matches by string.
857
+ const ownArchiveDir = path.join(
858
+ path.dirname(opts.filePath ?? eventsFilePath(opts.repoRoot)),
859
+ ARCHIVE_DIR_NAME,
860
+ );
861
+
862
+ /**
863
+ * Every rotation tombstone the walk passes is checked, on whatever line it
864
+ * sits — the semantics of {@link readEventsWithRotations}, which checks every
865
+ * rotation record of every source. Not only first lines (#1489 review
866
+ * LOW-2): `maybeRotate` renames, prunes, THEN appends the tombstone, from an
867
+ * async SessionStart hook, so a concurrent append can land first and push it
868
+ * to line 2. Called BEFORE the caller's `filter`, which names another event
869
+ * and would otherwise hide the cut. Resolved by basename against this
870
+ * ledger's own `_archive/`, never by the absolute provenance value (#1411).
871
+ */
872
+ const noteCut = (line, source) => {
873
+ if (!line.includes(ROTATION_EVENT)) return;
874
+ let record;
875
+ try {
876
+ record = JSON.parse(line);
877
+ } catch {
878
+ return; // counted by `consume` like every other malformed line
879
+ }
880
+ const target = record?.event === ROTATION_EVENT ? record.archived_as : null;
881
+ if (typeof target !== 'string' || target.length === 0) return;
882
+ const sibling = path.join(ownArchiveDir, path.basename(target));
883
+ if (listed.has(sibling)) return;
884
+ gaps.push({
885
+ kind: existsSync(sibling) ? 'unindexed-archive' : 'missing-archive',
886
+ path: sibling,
887
+ archived_as: target,
888
+ first_ts: record.first_ts ?? null,
889
+ last_ts: record.last_ts ?? null,
890
+ reported_by: source.path,
891
+ });
892
+ };
893
+
828
894
  /** @returns {boolean} true ⇒ the caller accepted a record; stop everything. */
829
895
  const consume = (block, source) => {
830
896
  const lines = block.toString('utf8').split('\n');
831
897
  for (let i = lines.length - 1; i >= 0; i -= 1) {
832
898
  const line = lines[i];
833
899
  if (!line) continue;
900
+ noteCut(line, source);
834
901
  if (typeof filter === 'string' && !line.includes(filter)) continue;
835
902
  let record;
836
903
  try {
@@ -844,7 +911,7 @@ export function scanEventsBackwards(opts = {}) {
844
911
  return false;
845
912
  };
846
913
 
847
- for (const source of listEventSourcesNewestFirst(opts)) {
914
+ for (const source of sources) {
848
915
  if (stopped || truncated) break;
849
916
  if (outOfTime()) {
850
917
  truncated = true;
@@ -896,5 +963,5 @@ export function scanEventsBackwards(opts = {}) {
896
963
  }
897
964
  }
898
965
 
899
- return { stopped, truncated, malformed_lines: malformed, sources: scanned, unreadable };
966
+ return { stopped, truncated, malformed_lines: malformed, sources: scanned, unreadable, gaps };
900
967
  }