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
@@ -64,6 +64,9 @@ import { serializeSessionLineChecked as defaultSerialize } from './session-schem
64
64
  import { resolveStateMdPath as defaultResolveStateMdPath } from './state-md/frontmatter-mutators.mjs';
65
65
  import { parseStateMd as defaultParseStateMd } from './state-md/yaml-parser.mjs';
66
66
  import { canonicalizeSessions } from './sessions-canonical.mjs';
67
+ // Already in the hook import set (`emitEvent` lives there): the rotation-aware
68
+ // reader adds no new file to the SessionStart/SessionEnd hook graph.
69
+ import { scanEventsBackwards } from './events.mjs';
67
70
  // Leaf constants module (no imports of its own) and ALREADY in the hook import
68
71
  // set via session-schema/validator.mjs — importing it here adds no new file to
69
72
  // the SessionStart/SessionEnd hook graph. The profile enum must not be
@@ -326,6 +329,117 @@ function collectSessionEvents(events, { sessionId, semanticSessionId }) {
326
329
  };
327
330
  }
328
331
 
332
+ /**
333
+ * Events-ledger window for the #1401 census (`events-retention-banner.mjs`):
334
+ * `null` — {@link gatherSessionEvents} reads back as far as a candidate's own
335
+ * `session.started`, however old, so no day count bounds it. A cut history
336
+ * leaves the candidate's oldest events unread: its record then lists
337
+ * `started_at` among `_backfill_incomplete_fields` — the same answer as before
338
+ * this reader existed, never a guessed time.
339
+ */
340
+ export const REQUIRED_EVENTS_WINDOW_DAYS = null;
341
+
342
+ /**
343
+ * Every record of the ledger — rotated archives included — whose raw line
344
+ * contains `id`, oldest first. A backward scan in the existing rotation-aware
345
+ * reader (`scanEventsBackwards`), reversed: one scan's order is global, so the
346
+ * file order {@link collectSessionEvents} depends on survives.
347
+ *
348
+ * @param {string} eventsPath
349
+ * @param {string} id
350
+ * @param {number|null} deadlineMs — epoch ms the scan must end by; `null` = unbounded
351
+ * @returns {Array<object>|null} `null` when the deadline had passed before the
352
+ * scan or cut it short — a partial history is never handed on as a whole one.
353
+ */
354
+ function recordsMentioning(eventsPath, id, deadlineMs) {
355
+ let budgetMs;
356
+ if (deadlineMs !== null) {
357
+ budgetMs = deadlineMs - Date.now();
358
+ if (!(budgetMs > 0)) return null;
359
+ }
360
+ const newestFirst = [];
361
+ const { truncated, unreadable } = scanEventsBackwards({
362
+ filePath: eventsPath,
363
+ filter: id,
364
+ budgetMs,
365
+ onRecord: (record) => {
366
+ newestFirst.push(record);
367
+ return false;
368
+ },
369
+ });
370
+ // #1505.5b: a source the walk could not read cut the history exactly like a
371
+ // deadline does — its records are simply absent — so it defers the candidate
372
+ // the same way. Ceiling (BV-004): a PERMANENTLY unreadable archive defers its
373
+ // candidates on every run (`skipped-history-unread`, visible, never a record
374
+ // dated NOW). `gaps` (pruned/unindexed archives) are deliberately NOT treated
375
+ // so: the walk reports every gap of the whole ledger, mostly far older than
376
+ // the candidate, and deferring on any of them would skip the candidate
377
+ // forever — telling a gap inside the session's window apart needs its
378
+ // first_ts/last_ts against the session's range, a follow-up.
379
+ const cut = truncated || (Array.isArray(unreadable) && unreadable.length > 0);
380
+ return cut ? null : newestFirst.reverse();
381
+ }
382
+
383
+ /**
384
+ * {@link collectSessionEvents} over the ACTIVE events.jsonl, and — when that
385
+ * holds no `session.started` for the candidate — over the whole rotated ledger.
386
+ *
387
+ * #1498: `planSessions` reads every archive since #1414, but this core read only
388
+ * the active file, so a candidate whose events had rotated away gathered
389
+ * nothing: no `lastEventMs`, hence never dead-by-age and skipped behind a live
390
+ * foreign lock on every run — or, without a lock, written with `started_at` and
391
+ * `completed_at` set to NOW. The active read stays first and cheap; the scan
392
+ * runs only for such a candidate, and once its record is written the CLI's
393
+ * sessions.jsonl pre-filter skips it on every later run.
394
+ *
395
+ * Cost (#1498 review): ≈0.55-0.8 ms per MB of ledger per such candidate —
396
+ * measured 2026-10-02: 25 candidates over a synthetic 58 MB ledger of six
397
+ * sources took 1.17-1.20 s, active reads included, at load average ~20; the
398
+ * review measured +0.80-0.87 s for the scans alone at 60 MB and load ~7. That
399
+ * does not fit the 5 s SessionStart hook unbounded, so a caller may pass
400
+ * `deadlineMs`: ONE epoch-ms deadline shared by every candidate of a run. A
401
+ * scan the deadline skipped or cut returns `historyUnread: true` instead of a
402
+ * partial answer, and the caller defers the candidate — never the active-file
403
+ * answer, which for such a candidate is a record dated NOW (see above) that the
404
+ * dedupe would then keep forever.
405
+ *
406
+ * One filtered scan keeps the order exact: a native UUID appears on every event
407
+ * of its session. Without one, the semantic label is scanned for the UUID
408
+ * bridge, then that UUID's events. Named ceiling (BV-004): a label bridging to
409
+ * SEVERAL UUIDs would need several scans merged without a shared order, so it
410
+ * keeps the active-file answer — revisit if a backfill record shows
411
+ * `started_at` among its incomplete fields while the archive holds it.
412
+ *
413
+ * @param {Function} readFileSync
414
+ * @param {string} eventsPath
415
+ * @param {{ sessionId: string|null, semanticSessionId: string|null }} ids
416
+ * @param {number|null} [deadlineMs=null] — shared scan deadline (epoch ms); `null` = unbounded
417
+ * @returns {ReturnType<typeof collectSessionEvents> & { historyUnread?: true }}
418
+ */
419
+ function gatherSessionEvents(readFileSync, eventsPath, ids, deadlineMs = null) {
420
+ const gathered = collectSessionEvents(readJsonlSafe(readFileSync, eventsPath), ids);
421
+ if (gathered.startedAt !== null) return gathered;
422
+ try {
423
+ let uuid = isUuid(ids.sessionId) ? ids.sessionId : null;
424
+ if (uuid === null) {
425
+ const label = ids.semanticSessionId || ids.sessionId;
426
+ if (!label) return gathered;
427
+ const bridge = recordsMentioning(eventsPath, label, deadlineMs);
428
+ if (bridge === null) return { ...gathered, historyUnread: true };
429
+ const bridged = collectSessionEvents(bridge, ids).uuids;
430
+ if (bridged.size !== 1) return gathered;
431
+ [uuid] = bridged;
432
+ }
433
+ const history = recordsMentioning(eventsPath, uuid, deadlineMs);
434
+ if (history === null) return { ...gathered, historyUnread: true };
435
+ // Empty only when the scan found no source at all — e.g. a reader seam the
436
+ // filesystem does not back; the active answer stands.
437
+ return history.length > 0 ? collectSessionEvents(history, ids) : gathered;
438
+ } catch {
439
+ return gathered;
440
+ }
441
+ }
442
+
329
443
  /**
330
444
  * Determine whether a candidate should be treated as dead-by-age DESPITE a
331
445
  * live FOREIGN session.lock (#731 — the historical migration CLI blocks
@@ -724,6 +838,11 @@ function checkAlreadyRecorded(readFileSync, sessionsPath, { recordId, sessionId
724
838
  * `lastEventMs` strictly predates this value bypasses a foreign live lock
725
839
  * regardless of `relaxDeadByAge`. Corresponds to the CLI's
726
840
  * `--assume-dead-before <ISO>` flag.
841
+ * @param {number|null} [args.archiveScanDeadlineMs=null]
842
+ * #1498 — epoch ms by which a scan of the ROTATED ledger must end; one value
843
+ * shared by every candidate of a run (see `gatherSessionEvents`). A candidate
844
+ * whose history the deadline left unread returns `skipped-history-unread`
845
+ * and is retried by the next run. `null` (CLI, SessionEnd) = unbounded.
727
846
  * @param {object} [args.deps] DI overrides (fs, appendJsonl, readLock, …)
728
847
  * @returns {Promise<object>}
729
848
  */
@@ -736,6 +855,7 @@ export async function backfillAbandonedSession({
736
855
  relaxDeadByAge = false,
737
856
  ownSessionIsEnding = false,
738
857
  assumeDeadBeforeMs = null,
858
+ archiveScanDeadlineMs = null,
739
859
  deps = {},
740
860
  } = {}) {
741
861
  const {
@@ -795,8 +915,14 @@ export async function backfillAbandonedSession({
795
915
  }
796
916
 
797
917
  // -- Read events (needed to synthesize, and to bridge UUID→semantic) ----
798
- const events = readJsonlSafe(readFileSync, eventsPath);
799
- const gathered = collectSessionEvents(events, { sessionId, semanticSessionId });
918
+ const gathered = gatherSessionEvents(
919
+ readFileSync, eventsPath, { sessionId, semanticSessionId }, archiveScanDeadlineMs,
920
+ );
921
+ // Deferred, never answered from the active file alone: for this candidate
922
+ // that answer is a record dated NOW, which the dedupe keeps forever.
923
+ if (gathered.historyUnread) {
924
+ return { action: 'skipped-history-unread', sessionId: recordId ?? sessionId };
925
+ }
800
926
 
801
927
  // -- Resolve a deferred id from the lock bridge or a synthetic mint ------
802
928
  if (recordId === null) {
@@ -1153,8 +1279,7 @@ export async function backfillCompletedFromStateMd({
1153
1279
 
1154
1280
  // -- Derive whatever is derivable from events.jsonl (never STATE.md body) -
1155
1281
  const eventsPath = path.join(repoRoot, ...EVENTS_REL);
1156
- const events = readJsonlSafe(readFileSync, eventsPath);
1157
- const gathered = collectSessionEvents(events, { sessionId: nativeId, semanticSessionId });
1282
+ const gathered = gatherSessionEvents(readFileSync, eventsPath, { sessionId: nativeId, semanticSessionId });
1158
1283
 
1159
1284
  // -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ----
1160
1285
  const record = synthesizeRecord({
@@ -45,6 +45,8 @@
45
45
  * @property {string} [error] - error message, `reason === 'error'` only.
46
46
  * @property {number} [scanned] - entries read from the active store (ran only).
47
47
  * @property {number} [archived] - entries moved to the archive sidecar (ran only).
48
+ * @property {number} [malformed] - unparseable store lines kept verbatim, never
49
+ * archived (ran only; a measured `0` when none).
48
50
  * @property {string} [archivePath] - the archive sidecar written to (ran only).
49
51
  */
50
52
 
@@ -88,10 +90,10 @@ function findPhaseDecision(plan, phase) {
88
90
  * happens to resolve to (#941 — that mistake once landed a test record in the
89
91
  * operator's real fleet ledger).
90
92
  *
91
- * @param {{repoRoot: string, scanned: number, archived: number}} ctx
93
+ * @param {{repoRoot: string, scanned: number, archived: number, malformed: number}} ctx
92
94
  * @returns {Promise<void>}
93
95
  */
94
- async function emitSweepApplied({ repoRoot, scanned, archived }) {
96
+ async function emitSweepApplied({ repoRoot, scanned, archived, malformed }) {
95
97
  if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
96
98
  process.stderr.write(
97
99
  `tail-runner: skipped ${SWEEP_EVENT} — no repoRoot given; ` +
@@ -103,7 +105,7 @@ async function emitSweepApplied({ repoRoot, scanned, archived }) {
103
105
  const { emitEvent, sessionAttribution } = await import('../events.mjs');
104
106
  await emitEvent(
105
107
  SWEEP_EVENT,
106
- { scanned, archived, source: SWEEP_SOURCE, ...sessionAttribution(repoRoot) },
108
+ { scanned, archived, malformed, source: SWEEP_SOURCE, ...sessionAttribution(repoRoot) },
107
109
  { repoRoot },
108
110
  );
109
111
  } catch {
@@ -151,9 +153,12 @@ export async function runExpiredSweep({ repoRoot, plan, now, graceDays, emit = t
151
153
 
152
154
  const scanned = res?.scanned ?? 0;
153
155
  const archived = res?.archived ?? 0;
154
- if (emit) await emitSweepApplied({ repoRoot, scanned, archived });
156
+ // The sweep result omits `malformed` when the read found none, so absent IS
157
+ // a measured 0 here — carried as a number like its two siblings (HR-105).
158
+ const malformed = res?.malformed ?? 0;
159
+ if (emit) await emitSweepApplied({ repoRoot, scanned, archived, malformed });
155
160
 
156
- return { ran: true, scanned, archived, archivePath: res?.archivePath ?? archivePath };
161
+ return { ran: true, scanned, archived, malformed, archivePath: res?.archivePath ?? archivePath };
157
162
  } catch (err) {
158
163
  // Fail CLOSED (see the module header): a maintenance sweep must never be
159
164
  // able to block a session close.
@@ -25,9 +25,12 @@
25
25
  * @param {unknown} obj — a value already parsed from JSON.
26
26
  * @returns {boolean} true when `obj` carries all six required lock fields with
27
27
  * the right primitive types. `semantic_session_id` and `last_heartbeat` are
28
- * deliberately NOT required — both are optional by schema (v1 locks predate
29
- * `last_heartbeat`, and `semantic_session_id` is absent on harnesses that
30
- * resolve no semantic id).
28
+ * deliberately NOT required. `semantic_session_id` is absent on harnesses
29
+ * that resolve no semantic id. A lock without `last_heartbeat` must stay
30
+ * shape-valid so it remains VISIBLE: since the #595 sunset (2026-10-02)
31
+ * `isLockLive()` calls it not live and `acquire()` classifies it
32
+ * `stale-heartbeat`, so the bootstrap reclaims it. Requiring the field here
33
+ * would hide it from every `readLock()` caller instead.
31
34
  */
32
35
  export function isLockShape(obj) {
33
36
  return (