session-orchestrator 5.2.0 → 5.3.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 (295) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -1
  2. package/.agents/skills/autopilot/SKILL.md +5 -1
  3. package/.agents/skills/autopilot/agents/openai.yaml +5 -0
  4. package/.agents/skills/bootstrap/SKILL.md +5 -1
  5. package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
  6. package/.agents/skills/brainstorm/SKILL.md +5 -1
  7. package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
  8. package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
  9. package/.agents/skills/close/SKILL.md +5 -1
  10. package/.agents/skills/close/agents/openai.yaml +5 -0
  11. package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
  12. package/.agents/skills/debug/SKILL.md +5 -1
  13. package/.agents/skills/debug/agents/openai.yaml +5 -0
  14. package/.agents/skills/discovery/SKILL.md +5 -1
  15. package/.agents/skills/discovery/agents/openai.yaml +5 -0
  16. package/.agents/skills/dispatcher/SKILL.md +5 -1
  17. package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
  18. package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
  19. package/.agents/skills/ecosystem-health/SKILL.md +3 -1
  20. package/.agents/skills/eli5/SKILL.md +5 -1
  21. package/.agents/skills/eli5/agents/openai.yaml +5 -0
  22. package/.agents/skills/eval/SKILL.md +6 -2
  23. package/.agents/skills/eval/agents/openai.yaml +5 -0
  24. package/.agents/skills/evolve/SKILL.md +6 -2
  25. package/.agents/skills/evolve/agents/openai.yaml +5 -0
  26. package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
  27. package/.agents/skills/gitlab-ops/SKILL.md +3 -1
  28. package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
  29. package/.agents/skills/go/SKILL.md +5 -1
  30. package/.agents/skills/go/agents/openai.yaml +5 -0
  31. package/.agents/skills/grill/SKILL.md +5 -1
  32. package/.agents/skills/grill/agents/openai.yaml +5 -0
  33. package/.agents/skills/harness-audit/SKILL.md +5 -1
  34. package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
  35. package/.agents/skills/hook-development/SKILL.md +3 -1
  36. package/.agents/skills/mcp-builder/SKILL.md +3 -1
  37. package/.agents/skills/memory-cleanup/SKILL.md +5 -1
  38. package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
  39. package/.agents/skills/mode-selector/SKILL.md +3 -1
  40. package/.agents/skills/npm-publish/SKILL.md +4 -2
  41. package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
  42. package/.agents/skills/persona-panel/SKILL.md +5 -1
  43. package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
  44. package/.agents/skills/plan/SKILL.md +5 -1
  45. package/.agents/skills/plan/agents/openai.yaml +5 -0
  46. package/.agents/skills/playwright-driver/SKILL.md +3 -1
  47. package/.agents/skills/portfolio/SKILL.md +5 -1
  48. package/.agents/skills/portfolio/agents/openai.yaml +5 -0
  49. package/.agents/skills/quality-gates/SKILL.md +3 -1
  50. package/.agents/skills/reconcile/SKILL.md +5 -1
  51. package/.agents/skills/reconcile/agents/openai.yaml +5 -0
  52. package/.agents/skills/release/SKILL.md +5 -1
  53. package/.agents/skills/release/agents/openai.yaml +5 -0
  54. package/.agents/skills/remote-offload/SKILL.md +3 -1
  55. package/.agents/skills/repo-audit/SKILL.md +5 -1
  56. package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
  57. package/.agents/skills/session/SKILL.md +21 -0
  58. package/.agents/skills/session/agents/openai.yaml +5 -0
  59. package/.agents/skills/session-end/SKILL.md +3 -1
  60. package/.agents/skills/session-plan/SKILL.md +3 -1
  61. package/.agents/skills/session-start/SKILL.md +3 -1
  62. package/.agents/skills/spinout/SKILL.md +5 -1
  63. package/.agents/skills/spinout/agents/openai.yaml +5 -0
  64. package/.agents/skills/sunset-review/SKILL.md +5 -1
  65. package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
  66. package/.agents/skills/templates-ack/SKILL.md +21 -0
  67. package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
  68. package/.agents/skills/test/SKILL.md +5 -1
  69. package/.agents/skills/test/agents/openai.yaml +5 -0
  70. package/.agents/skills/test-runner/SKILL.md +3 -1
  71. package/.agents/skills/tmux-layout/SKILL.md +3 -1
  72. package/.agents/skills/using-orchestrator/SKILL.md +3 -1
  73. package/.agents/skills/ux-grill/SKILL.md +5 -1
  74. package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
  75. package/.agents/skills/vault-mirror/SKILL.md +3 -1
  76. package/.agents/skills/vault-sync/SKILL.md +3 -1
  77. package/.agents/skills/wave-executor/SKILL.md +3 -1
  78. package/.agents/skills/write-executable-plan/SKILL.md +3 -1
  79. package/.claude-plugin/marketplace.json +1 -1
  80. package/.claude-plugin/plugin.json +1 -1
  81. package/.codex-plugin/plugin.json +4 -4
  82. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
  83. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  84. package/.codex-plugin/skills/evolve/SKILL.md +1 -1
  85. package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
  86. package/.codex-plugin/skills/session/SKILL.md +1 -1
  87. package/.cursor/commands/eval.md +1 -1
  88. package/.cursor/commands/session.md +1 -1
  89. package/.cursor/rules/000-session-orchestrator.mdc +0 -2
  90. package/.cursor/rules/050-plan.mdc +1 -1
  91. package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
  92. package/.cursor/skills/eval/SKILL.md +1 -1
  93. package/.cursor/skills/npm-publish/SKILL.md +1 -0
  94. package/.cursor-plugin/plugin.json +1 -1
  95. package/.orchestrator/policy/blocked-commands.json +12 -3
  96. package/AGENTS.md +3 -2
  97. package/CHANGELOG.md +136 -0
  98. package/README.md +9 -9
  99. package/SECURITY.md +12 -0
  100. package/agents/dialectic-deriver.md +13 -10
  101. package/agents/eval-judge.md +67 -45
  102. package/agents/skill-applied-judge.md +34 -19
  103. package/commands/session.md +7 -3
  104. package/docs/baseline.md +12 -6
  105. package/docs/codex-setup.md +14 -2
  106. package/docs/components.md +7 -5
  107. package/docs/events-schema.md +56 -9
  108. package/docs/rule-authoring.md +58 -6
  109. package/docs/session-config-reference.md +100 -7
  110. package/docs/session-config-template.md +31 -2
  111. package/docs/telemetry.md +2 -0
  112. package/hooks/_lib/hook-import-set.json +85 -8
  113. package/hooks/_lib/subagent-transcript.mjs +582 -31
  114. package/hooks/config-protection.mjs +11 -3
  115. package/hooks/cwd-change-restore.mjs +11 -3
  116. package/hooks/enforce-commands.mjs +70 -23
  117. package/hooks/enforce-scope.mjs +143 -33
  118. package/hooks/hooks-codex.json +1 -1
  119. package/hooks/hooks.json +1 -1
  120. package/hooks/loop-guard.mjs +11 -3
  121. package/hooks/on-session-end.mjs +58 -23
  122. package/hooks/on-session-start.mjs +48 -11
  123. package/hooks/on-stop.mjs +168 -22
  124. package/hooks/operator-steer.mjs +11 -3
  125. package/hooks/post-bash-issue-budget-refund.mjs +18 -8
  126. package/hooks/post-bash-write-verify.mjs +3 -2
  127. package/hooks/post-edit-import-probe.mjs +17 -9
  128. package/hooks/post-edit-validate.mjs +13 -5
  129. package/hooks/post-subagent-discovery-validator.mjs +98 -13
  130. package/hooks/post-tool-batch-wave-signal.mjs +200 -38
  131. package/hooks/post-tool-failure-corrective-context.mjs +11 -5
  132. package/hooks/post-tooluse-frontend-slop.mjs +10 -4
  133. package/hooks/pre-auq-clarity.mjs +15 -2
  134. package/hooks/pre-bash-destructive-guard.mjs +80 -9
  135. package/hooks/pre-bash-issue-budget.mjs +16 -11
  136. package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
  138. package/hooks/pre-bash-staging-fence.mjs +335 -31
  139. package/hooks/pre-bash-templates-first.mjs +19 -14
  140. package/hooks/pre-task-scope-disjoint.mjs +233 -2
  141. package/hooks/subagent-telemetry.mjs +15 -19
  142. package/hooks/wave-scope-commit-guard.mjs +197 -100
  143. package/monitors/monitors.json +1 -1
  144. package/output-styles/wave-summary.md +1 -1
  145. package/package.json +1 -1
  146. package/pi/prompts/eval.md +1 -1
  147. package/pi/prompts/session.md +1 -1
  148. package/rules/README.md +1 -1
  149. package/rules/opt-in-domain/prompt-caching.md +1 -1
  150. package/rules/opt-in-stack/backend-data.md +1 -1
  151. package/rules/opt-in-stack/backend.md +3 -3
  152. package/rules/opt-in-stack/frontend.md +1 -1
  153. package/rules/opt-in-stack/security-web.md +3 -3
  154. package/rules/opt-in-stack/swift.md +1 -1
  155. package/scripts/autopilot.mjs +23 -2
  156. package/scripts/backfill-abandoned-sessions.mjs +117 -15
  157. package/scripts/check-sessions-integrity.mjs +300 -0
  158. package/scripts/dialectic-deriver.mjs +50 -13
  159. package/scripts/emit-session.mjs +75 -29
  160. package/scripts/eval-session.mjs +65 -3
  161. package/scripts/generate-agents-skills.mjs +102 -29
  162. package/scripts/generate-cursor-adapter.mjs +61 -16
  163. package/scripts/lib/agent-status.mjs +2 -31
  164. package/scripts/lib/auq/clarity.mjs +10 -2
  165. package/scripts/lib/auq/parse.mjs +12 -31
  166. package/scripts/lib/auq/schema.mjs +56 -41
  167. package/scripts/lib/auto-dialectic.mjs +304 -15
  168. package/scripts/lib/autopilot/flags.mjs +12 -1
  169. package/scripts/lib/autopilot/kill-switches.mjs +6 -3
  170. package/scripts/lib/autopilot/loop.mjs +14 -1
  171. package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
  172. package/scripts/lib/ci-status-banner.mjs +376 -16
  173. package/scripts/lib/command-blocker.mjs +275 -28
  174. package/scripts/lib/config/dialectic.mjs +12 -3
  175. package/scripts/lib/config/gate.mjs +74 -0
  176. package/scripts/lib/config/reaper.mjs +162 -0
  177. package/scripts/lib/config.mjs +14 -0
  178. package/scripts/lib/convergence-monitor.mjs +74 -11
  179. package/scripts/lib/ecosystem-health.mjs +11 -0
  180. package/scripts/lib/eval/engine.mjs +421 -53
  181. package/scripts/lib/eval/judge.mjs +463 -40
  182. package/scripts/lib/eval/schema.mjs +10 -1
  183. package/scripts/lib/events-rotation.mjs +221 -25
  184. package/scripts/lib/events-schema.mjs +114 -0
  185. package/scripts/lib/events.mjs +524 -5
  186. package/scripts/lib/frontmatter-guard.mjs +21 -10
  187. package/scripts/lib/gates/gate-baseline.mjs +27 -2
  188. package/scripts/lib/gates/gate-full.mjs +28 -3
  189. package/scripts/lib/gates/gate-helpers.mjs +243 -21
  190. package/scripts/lib/gates/gate-incremental.mjs +28 -3
  191. package/scripts/lib/gates/gate-per-file.mjs +27 -2
  192. package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
  193. package/scripts/lib/instruction-budget-guard.mjs +146 -4
  194. package/scripts/lib/io.mjs +42 -8
  195. package/scripts/lib/issue-close-strip-labels.mjs +207 -49
  196. package/scripts/lib/js-mask.mjs +197 -0
  197. package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
  198. package/scripts/lib/maintenance-due-banner.mjs +53 -88
  199. package/scripts/lib/orphan-reaper.mjs +1588 -0
  200. package/scripts/lib/peer-cards/merger.mjs +48 -10
  201. package/scripts/lib/peer-cards/reader.mjs +78 -2
  202. package/scripts/lib/process-group.mjs +899 -0
  203. package/scripts/lib/quality-gate.mjs +107 -28
  204. package/scripts/lib/reconcile/backlog.mjs +368 -0
  205. package/scripts/lib/reconcile/engine.mjs +55 -188
  206. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
  207. package/scripts/lib/reconcile/sanitize.mjs +69 -3
  208. package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
  209. package/scripts/lib/resource-probe/parsers.mjs +31 -0
  210. package/scripts/lib/rule-loader.mjs +41 -12
  211. package/scripts/lib/scope-echo.mjs +39 -2
  212. package/scripts/lib/scope-gate.mjs +605 -1
  213. package/scripts/lib/session-close-backfill.mjs +33 -6
  214. package/scripts/lib/session-id.mjs +9 -20
  215. package/scripts/lib/session-invocation.mjs +20 -0
  216. package/scripts/lib/session-schema/constants.mjs +30 -2
  217. package/scripts/lib/session-schema/normalizer.mjs +56 -4
  218. package/scripts/lib/session-schema.mjs +8 -3
  219. package/scripts/lib/session-start-probes.mjs +95 -10
  220. package/scripts/lib/sessions-canonical.mjs +23 -0
  221. package/scripts/lib/sessions-integrity-banner.mjs +7 -1
  222. package/scripts/lib/sessions-staleness-banner.mjs +193 -51
  223. package/scripts/lib/skill-evidence-window.mjs +891 -0
  224. package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
  225. package/scripts/lib/skill-evolution/engine.mjs +18 -9
  226. package/scripts/lib/skill-judge.mjs +45 -3
  227. package/scripts/lib/tail-window.mjs +56 -0
  228. package/scripts/lib/telemetry/schema.mjs +30 -0
  229. package/scripts/lib/telemetry/sync.mjs +61 -6
  230. package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
  231. package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
  232. package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
  233. package/scripts/lib/user-invocable-skills.mjs +23 -3
  234. package/scripts/lib/ux-grill/reconcile.mjs +48 -22
  235. package/scripts/lib/validate/check-agents-skills.mjs +26 -15
  236. package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
  237. package/scripts/lib/validate/check-entry-guard.mjs +13 -50
  238. package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
  239. package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
  240. package/scripts/lib/validate/check-rules.mjs +7 -5
  241. package/scripts/lib/validate/check-skill-links.mjs +9 -1
  242. package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
  243. package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
  244. package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
  245. package/scripts/lib/validate/check-unwired-features.mjs +130 -27
  246. package/scripts/lib/validate/check-validator-registration.mjs +34 -10
  247. package/scripts/lib/validate/confidential-names.mjs +10 -0
  248. package/scripts/lib/validate-vendored-rules.mjs +4 -3
  249. package/scripts/lib/vault-mirror/namespace.mjs +46 -8
  250. package/scripts/lib/vault-mirror/process.mjs +10 -3
  251. package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
  252. package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
  253. package/scripts/lib/vault-yaml.mjs +118 -0
  254. package/scripts/lib/worktree/lifecycle.mjs +153 -1
  255. package/scripts/release-session-lock.mjs +305 -0
  256. package/scripts/release.mjs +30 -5
  257. package/scripts/resolve-session-invocation.mjs +59 -0
  258. package/scripts/run-quality-gate.mjs +156 -17
  259. package/scripts/sweep-expired-rules.mjs +14 -3
  260. package/scripts/validate-plugin.mjs +12 -0
  261. package/scripts/validate-wave-scope.mjs +32 -105
  262. package/scripts/vault-mirror.mjs +9 -1
  263. package/skills/_shared/platform-tools.md +23 -11
  264. package/skills/autopilot/SKILL.md +22 -7
  265. package/skills/claude-md-drift-check/SKILL.md +1 -1
  266. package/skills/convergence-monitoring/README.md +8 -1
  267. package/skills/convergence-monitoring/SIGNALS.md +50 -6
  268. package/skills/convergence-monitoring/SKILL.md +15 -6
  269. package/skills/eval/SKILL.md +39 -24
  270. package/skills/eval/rubric-v1.md +1 -0
  271. package/skills/eval/rubric-v2.md +457 -0
  272. package/skills/evolve/SKILL.md +1 -1
  273. package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
  274. package/skills/gitlab-ops/SKILL.md +3 -2
  275. package/skills/npm-publish/SKILL.md +1 -1
  276. package/skills/reconcile/SKILL.md +11 -0
  277. package/skills/session-end/SKILL.md +13 -16
  278. package/skills/session-end/discovery-scan.md +1 -1
  279. package/skills/session-end/phase-3-6-tail.md +55 -9
  280. package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
  281. package/skills/session-end/session-metrics-write.md +10 -0
  282. package/skills/session-plan/SKILL.md +17 -5
  283. package/skills/session-plan/references/session-plan-task-classification.md +2 -2
  284. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
  285. package/skills/ux-grill/SKILL.md +1 -1
  286. package/skills/wave-executor/SKILL.md +8 -4
  287. package/skills/wave-executor/circuit-breaker.md +2 -0
  288. package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
  289. package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
  290. package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
  291. package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
  292. package/.cursor/commands/convergence-monitoring.md +0 -13
  293. package/.cursor/commands/npm-publish.md +0 -13
  294. package/pi/prompts/convergence-monitoring.md +0 -11
  295. package/pi/prompts/npm-publish.md +0 -11
@@ -9,24 +9,182 @@
9
9
  * per-append overhead is wasteful given ~6 KiB/day growth.
10
10
  *
11
11
  * Rename safety (POSIX): atomic rename is safe with in-flight writers. Old fds
12
- * continue writing to the original inode (now `events.jsonl.1`); new writers
13
- * will open the new file on next append.
12
+ * continue writing to the original inode (now the archive); new writers will
13
+ * open the new file on next append.
14
+ *
15
+ * ## The ledger carries its own break (#1401)
16
+ *
17
+ * Rotation used to be INVISIBLE. It wrote no event and no durable log line —
18
+ * success surfaced only as a `console.error` from the SessionStart hook, whose
19
+ * stderr the harness discards. So a rotation and a DELETED archive produced
20
+ * byte-identical evidence: an events.jsonl that simply starts later than it
21
+ * used to. Measured 2026-09-19: `events.jsonl.1` (53,896 lines, 2026-04-12 →
22
+ * 2026-09-18) was destroyed by a wave subagent's `touch <path> && rm -f <path>`
23
+ * ignore-probe that adopted the existing 10 MB file, and nothing anywhere
24
+ * recorded that the file had ever existed.
25
+ *
26
+ * Since #1401 the FIRST line of every new active file is an
27
+ * `orchestrator.events.rotated` record naming the archive, its size, its line
28
+ * count and its timestamp range. That record is the tombstone: it is what lets
29
+ * {@link readEventsWithRotations} in `events.mjs` report a MISSING archive as a
30
+ * finding instead of silently returning a shorter history.
31
+ *
32
+ * ## Why `_archive/<name>` and not the `.1`..`.N` ring
33
+ *
34
+ * The ring was replaced in #1401, for a structural reason rather than taste:
35
+ * its shift step RENAMES every surviving archive on each rotation, so the
36
+ * `archived_as` pointer above would go stale the moment the next rotation ran
37
+ * and every reader would report a phantom gap. A durable pointer needs a
38
+ * durable name. Three further consequences, all measured or direct:
39
+ *
40
+ * - `_archive/events-<first>_<last>.jsonl` says what it holds; `.1` does not,
41
+ * which is how a 5-month ledger read as scratch to the agent that deleted it.
42
+ * - `.orchestrator/metrics/_archive/` is already gitignored (`.gitignore`) and
43
+ * already the repo's archive convention for ledger data.
44
+ * - Census 2026-09-19 @ 8f15f77b — `rg -n 'jsonl\.1|jsonl\.[0-9]' scripts/ hooks/`
45
+ * (excluding tests) found ZERO code readers of the ring, so nothing in the
46
+ * codebase breaks. Two live fleet repos DO carry a ~10 MB `events.jsonl.1`
47
+ * on disk, which is why the READER still reads the legacy ring; this writer
48
+ * never creates, shifts or prunes one.
14
49
  */
15
50
 
16
- import { statSync, renameSync, unlinkSync, existsSync } from 'node:fs';
51
+ import {
52
+ statSync,
53
+ renameSync,
54
+ unlinkSync,
55
+ existsSync,
56
+ readFileSync,
57
+ appendFileSync,
58
+ mkdirSync,
59
+ readdirSync,
60
+ } from 'node:fs';
61
+ import path from 'node:path';
62
+ import {
63
+ ARCHIVE_DIR_NAME,
64
+ ARCHIVE_NAME_RE,
65
+ ROTATION_EVENT,
66
+ parseEventLines,
67
+ stampEventSchemaVersion,
68
+ summarizeEventRecords,
69
+ validateEventRecord,
70
+ } from './events-schema.mjs';
71
+
72
+ // The archive naming contract (`ROTATION_EVENT`, `ARCHIVE_DIR_NAME`,
73
+ // `ARCHIVE_NAME_RE`, `LEGACY_RING_MAX`) is defined in `events-schema.mjs`, the
74
+ // zero-fs module the reader in `events.mjs` also loads — see the comment there
75
+ // for the hook-closure measurement that put it on that side.
76
+
77
+ /**
78
+ * `2026-04-12T06:33:01.123Z` → `20260412T063301Z` — filename-safe, and
79
+ * lexicographically ordered, which is what lets the pruner sort archives
80
+ * chronologically by name alone.
81
+ * @param {string} iso
82
+ * @returns {string}
83
+ */
84
+ function compactStamp(iso) {
85
+ return iso.replace(/\.\d+/, '').replace(/[-:]/g, '');
86
+ }
87
+
88
+ /**
89
+ * An archive path inside `dir` that does not yet exist.
90
+ *
91
+ * `renameSync` overwrites its destination SILENTLY, so a name collision would
92
+ * destroy an existing archive — the exact loss class #1401 exists to prevent.
93
+ * Exhausting the suffix range therefore THROWS rather than returning a path
94
+ * that would overwrite: the caller's catch turns that into `reason: 'error'`,
95
+ * leaving the active log in place and losing nothing.
96
+ *
97
+ * @param {string} dir
98
+ * @param {string|null} firstTs
99
+ * @param {string|null} lastTs
100
+ * @returns {string}
101
+ */
102
+ function uniqueArchivePath(dir, firstTs, lastTs) {
103
+ const from = firstTs ? compactStamp(firstTs) : 'unknown';
104
+ const to = lastTs ? compactStamp(lastTs) : compactStamp(new Date().toISOString());
105
+ const base = `events-${from}_${to}`;
106
+ 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
109
+ // collision already implies something is re-rotating identical content.
110
+ // Revisit if a `pruned`-less archive dir is ever seen holding a `-3` suffix.
111
+ for (let n = 2; existsSync(candidate); n += 1) {
112
+ if (n > 999) {
113
+ throw new Error(`events-rotation: no free archive name for ${base} in ${dir}`);
114
+ }
115
+ candidate = path.join(dir, `${base}-${n}.jsonl`);
116
+ }
117
+ return candidate;
118
+ }
119
+
120
+ /**
121
+ * Enforce `maxBackups` over the archives in `dir`, oldest first.
122
+ *
123
+ * The cap is kept rather than dropped because `events-rotation.max-backups` is
124
+ * 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
+ *
128
+ * Only names matching {@link ARCHIVE_NAME_RE} are eligible, and the archive
129
+ * just written is never a candidate. A failed unlink is swallowed: leaving one
130
+ * archive too many is strictly better than aborting a completed rotation.
131
+ *
132
+ * @param {string} dir
133
+ * @param {number} maxBackups
134
+ * @param {string} keepPath — absolute path of the archive written by this run.
135
+ * @returns {string[]} absolute paths actually deleted.
136
+ */
137
+ function pruneArchives(dir, maxBackups, keepPath) {
138
+ const pruned = [];
139
+ let names;
140
+ try {
141
+ names = readdirSync(dir).filter((name) => ARCHIVE_NAME_RE.test(name)).sort();
142
+ } catch {
143
+ return pruned;
144
+ }
145
+ // `sort()` orders by the leading `YYYYMMDDTHHMMSSZ` first stamp, i.e. oldest
146
+ // first. An `unknown_` prefix sorts AFTER every digit, so an archive whose
147
+ // range could not be derived is treated as newest and outlives the dated ones
148
+ // — conservative on purpose: never delete the file you understand least.
149
+ const excess = names.length - maxBackups;
150
+ for (let i = 0; i < excess; i += 1) {
151
+ const victim = path.join(dir, names[i]);
152
+ if (victim === keepPath) continue;
153
+ try {
154
+ unlinkSync(victim);
155
+ pruned.push(victim);
156
+ } catch {
157
+ /* keep it — an un-prunable archive is not a rotation failure */
158
+ }
159
+ }
160
+ return pruned;
161
+ }
17
162
 
18
163
  /**
19
164
  * Rotate the events log if it exceeds `maxSizeMb`.
20
165
  *
21
- * Shift scheme: `.N-1` `.N`, …, `.1` → `.2`, active `events.jsonl` → `.1`.
22
- * The oldest backup (`events.jsonl.{maxBackups}`) is deleted before shifting.
166
+ * On rotation the active file is renamed into
167
+ * `<dir>/_archive/events-<firstTs>_<lastTs>.jsonl` and an
168
+ * `orchestrator.events.rotated` record is appended to the now-absent active
169
+ * path, making it that file's first line. Archives beyond `maxBackups` are
170
+ * pruned (oldest first) and named in the record's `pruned` field, so the
171
+ * deletion is itself in the ledger.
172
+ *
173
+ * The record is written SYNCHRONOUSLY here rather than via `emitEvent()` for
174
+ * two reasons: it must land between the rename and any other writer's first
175
+ * append to be the first line, and `emitEvent()`'s async correlation lookups
176
+ * (session lock, wave manifest) describe a session, not a file operation. It
177
+ * still goes through the schema module's own stamper and validator, so it is
178
+ * not a raw writer inventing a shape.
23
179
  *
24
180
  * @param {object} opts
25
181
  * @param {string} opts.logPath — absolute path to `events.jsonl`
26
182
  * @param {number} opts.maxSizeMb — integer 1..1024
27
183
  * @param {number} opts.maxBackups — integer 1..20
28
184
  * @param {boolean} opts.enabled — if false, returns early
29
- * @returns {{rotated: boolean, reason?: string, archivedAs?: string, sizeBefore?: number, maxBackups?: number, error?: string}}
185
+ * @returns {{rotated: boolean, reason?: string, archivedAs?: string, sizeBefore?: number,
186
+ * maxBackups?: number, lines?: number, firstTs?: string|null, lastTs?: string|null,
187
+ * malformedLines?: number, pruned?: string[], recordWritten?: boolean, error?: string}}
30
188
  */
31
189
  export function maybeRotate({ logPath, maxSizeMb, maxBackups, enabled } = {}) {
32
190
  // --- Input validation (throw — programmer error, not runtime fs failure) ---
@@ -44,43 +202,81 @@ export function maybeRotate({ logPath, maxSizeMb, maxBackups, enabled } = {}) {
44
202
  return { rotated: false, reason: 'disabled' };
45
203
  }
46
204
 
205
+ // Set once the rename has happened. After that point the 10 MB HAS moved, so
206
+ // a later failure must never be reported as `rotated: false` — a caller that
207
+ // believes nothing happened is exactly how a rotation goes unnoticed.
208
+ let archivedAs = null;
209
+ let sizeBefore = 0;
210
+
47
211
  try {
48
212
  if (!existsSync(logPath)) {
49
213
  return { rotated: false, reason: 'no-file' };
50
214
  }
51
215
 
52
- const sizeBefore = statSync(logPath).size;
216
+ sizeBefore = statSync(logPath).size;
53
217
  const threshold = maxSizeMb * 1024 * 1024;
54
218
  if (sizeBefore < threshold) {
55
219
  return { rotated: false, reason: 'under-threshold' };
56
220
  }
57
221
 
58
- // Drop the oldest backup if it exists this keeps the ring bounded.
59
- const oldestPath = `${logPath}.${maxBackups}`;
60
- if (existsSync(oldestPath)) {
61
- unlinkSync(oldestPath);
62
- }
222
+ // Read BEFORE the rename: the content is what names the archive. Cost
223
+ // 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.
225
+ // Revisit if `max-size-mb` is ever raised past ~100.
226
+ const { records, malformedLines } = parseEventLines(readFileSync(logPath, 'utf8'));
227
+ const { firstTs, lastTs } = summarizeEventRecords(records);
228
+ const lines = records.length + malformedLines;
63
229
 
64
- // Shift `.N-1` `.N`, …, `.1` → `.2`.
65
- for (let i = maxBackups - 1; i >= 1; i--) {
66
- const src = `${logPath}.${i}`;
67
- const dst = `${logPath}.${i + 1}`;
68
- if (existsSync(src)) {
69
- renameSync(src, dst);
70
- }
71
- }
230
+ const archiveDir = path.join(path.dirname(logPath), ARCHIVE_DIR_NAME);
231
+ mkdirSync(archiveDir, { recursive: true });
232
+ const destination = uniqueArchivePath(archiveDir, firstTs, lastTs);
72
233
 
73
- // Rotate active file to `.1`. POSIX atomic rename.
74
- renameSync(logPath, `${logPath}.1`);
234
+ renameSync(logPath, destination);
235
+ archivedAs = destination;
236
+
237
+ const pruned = pruneArchives(archiveDir, maxBackups, archivedAs);
238
+
239
+ // The tombstone. `first_ts` / `last_ts` are `null` — present, never absent —
240
+ // when the archive held no parseable timestamp: a reader must be able to
241
+ // tell "range unknown" from "field not written by this version".
242
+ const record = stampEventSchemaVersion({
243
+ timestamp: new Date().toISOString(),
244
+ event: ROTATION_EVENT,
245
+ archived_as: archivedAs,
246
+ size_before: sizeBefore,
247
+ lines,
248
+ first_ts: firstTs,
249
+ last_ts: lastTs,
250
+ malformed_lines: malformedLines,
251
+ ...(pruned.length > 0 ? { pruned } : {}),
252
+ });
253
+ const verdict = validateEventRecord(record);
254
+ let recordWritten = false;
255
+ if (verdict.valid) {
256
+ appendFileSync(logPath, `${JSON.stringify(record)}\n`, 'utf8');
257
+ recordWritten = true;
258
+ }
75
259
 
76
260
  return {
77
261
  rotated: true,
78
- archivedAs: `${logPath}.1`,
262
+ archivedAs,
79
263
  sizeBefore,
80
264
  maxBackups,
265
+ lines,
266
+ firstTs,
267
+ lastTs,
268
+ malformedLines,
269
+ pruned,
270
+ recordWritten,
271
+ ...(recordWritten ? {} : { error: `invalid rotation record: ${verdict.errors.join('; ')}` }),
81
272
  };
82
273
  } catch (err) {
83
- // Never throw rotation failure must not break session-start.
84
- return { rotated: false, reason: 'error', error: err?.message ?? String(err) };
274
+ const message = err?.message ?? String(err);
275
+ // Never throw rotation failure must not break session-start. But once the
276
+ // rename landed, the archive EXISTS and the caller must hear about it.
277
+ if (archivedAs !== null) {
278
+ return { rotated: true, archivedAs, sizeBefore, maxBackups, recordWritten: false, error: message };
279
+ }
280
+ return { rotated: false, reason: 'error', error: message };
85
281
  }
86
282
  }
@@ -63,6 +63,42 @@ export function stampEventSchemaVersion(record) {
63
63
  /** ISO-8601 UTC timestamp with trailing Z (e.g. 2026-05-28T14:35:13.123Z). */
64
64
  const ISO_8601_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
65
65
 
66
+ // ---------------------------------------------------------------------------
67
+ // Rotation-archive naming contract (#1401)
68
+ // ---------------------------------------------------------------------------
69
+ //
70
+ // These four live HERE, in the pure module, and not beside the rotation writer
71
+ // that produces them — deliberately, and measured. `events-rotation.mjs`
72
+ // imports `node:fs`; when `events.mjs` (the reader) imported the constants
73
+ // from it, `generate-hook-import-set.mjs` went from `reachable_from:
74
+ // ["on-session-start.mjs"]` to SEVENTEEN hooks, including the per-Edit/per-Bash
75
+ // hot paths (`enforce-scope`, `pre-task-scope-disjoint`, `post-edit-validate`).
76
+ // A naming CONTRACT shared by a writer and a reader belongs in the zero-fs
77
+ // module both already load; only the fs code stays behind. Same reasoning as
78
+ // `session-lock-shape.mjs` (`.claude/rules/identity-and-locks.md`).
79
+
80
+ /** Event name written as the first line of the new active file after a rotation. */
81
+ export const ROTATION_EVENT = 'orchestrator.events.rotated';
82
+
83
+ /** Directory (beside the active log) that holds rotated archives. */
84
+ export const ARCHIVE_DIR_NAME = '_archive';
85
+
86
+ /**
87
+ * Exact shape of a rotation archive: `events-<first>_<last>.jsonl`, each stamp
88
+ * `YYYYMMDDTHHMMSSZ`, with an optional `-<n>` collision suffix.
89
+ *
90
+ * Deliberately tight, because the same regex decides what the pruner may
91
+ * DELETE. `_archive/` is a shared human-facing directory — this repo's own copy
92
+ * already holds a hand-placed `events-worktree-vault-session-analysis-<date>.jsonl`
93
+ * — and a loose `^events-.*\.jsonl$` would both merge that file into the ledger
94
+ * timeline and offer it up for pruning. Rotation deletes only what it made.
95
+ */
96
+ export const ARCHIVE_NAME_RE =
97
+ /^events-(?:\d{8}T\d{6}Z|unknown)_\d{8}T\d{6}Z(?:-\d+)?\.jsonl$/;
98
+
99
+ /** Upper bound of the legacy `.1`..`.N` ring (`max-backups` is capped at 20). */
100
+ export const LEGACY_RING_MAX = 20;
101
+
66
102
  /** Prefix marking an orchestrator-owned event. */
67
103
  export const ORCHESTRATOR_PREFIX = 'orchestrator.';
68
104
 
@@ -89,6 +125,84 @@ export function isIso8601(value) {
89
125
  );
90
126
  }
91
127
 
128
+ /**
129
+ * Split raw JSONL text into records, COUNTING the lines that could not be read.
130
+ *
131
+ * The counting is the point (#1401). Skipping an unreadable line is right — a
132
+ * torn tail from a killed writer must not abort a whole window read — but
133
+ * skipping it SILENTLY turns a partial result into a clean verdict: a join over
134
+ * the ledger then reports "everything matched" in the very instrument built to
135
+ * surface silent failure. So the count travels with the records and every
136
+ * consumer is expected to carry it into its own report and telemetry (HR-105).
137
+ *
138
+ * What counts as malformed here is UNREADABLE, not invalid: a line that is not
139
+ * JSON, or that parses to something other than a plain object. A readable
140
+ * record that `validateEventRecord()` would reject (bad timestamp, illegal
141
+ * event name) is a DIFFERENT class and is returned in `records` — the two must
142
+ * not be conflated, or a schema violation would hide inside a corruption count.
143
+ * Blank lines are neither: a trailing newline is the normal shape of a JSONL
144
+ * file, so an empty line is skipped without being counted.
145
+ *
146
+ * @param {string} text — raw file contents.
147
+ * @returns {{records: object[], malformedLines: number}}
148
+ */
149
+ export function parseEventLines(text) {
150
+ const records = [];
151
+ let malformedLines = 0;
152
+ for (const line of String(text ?? '').split('\n')) {
153
+ if (line.trim() === '') continue;
154
+ let parsed;
155
+ try {
156
+ parsed = JSON.parse(line);
157
+ } catch {
158
+ malformedLines += 1;
159
+ continue;
160
+ }
161
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
162
+ malformedLines += 1;
163
+ continue;
164
+ }
165
+ records.push(parsed);
166
+ }
167
+ return { records, malformedLines };
168
+ }
169
+
170
+ /**
171
+ * Earliest and latest VALID timestamp across `records` — `null` for each when
172
+ * no record carries a parseable one.
173
+ *
174
+ * Compared on `Date.parse`, not lexicographically: ISO-8601 UTC strings sort by
175
+ * string order ONLY while their millisecond part is uniform, and this stream
176
+ * carries both spellings (`…:13Z` and `…:13.123Z`). `Z` (0x5A) sorts after `.`
177
+ * (0x2E), so the millisecond-free form of the SAME second would compare as the
178
+ * later instant.
179
+ *
180
+ * `null` means "no parseable timestamp in this set" — an honest absence, never
181
+ * a fabricated epoch.
182
+ *
183
+ * @param {object[]} records
184
+ * @returns {{firstTs: string|null, lastTs: string|null}}
185
+ */
186
+ export function summarizeEventRecords(records) {
187
+ let firstTs = null;
188
+ let lastTs = null;
189
+ let firstMs = Infinity;
190
+ let lastMs = -Infinity;
191
+ for (const record of records ?? []) {
192
+ if (!isIso8601(record?.timestamp)) continue;
193
+ const ms = Date.parse(record.timestamp);
194
+ if (ms < firstMs) {
195
+ firstMs = ms;
196
+ firstTs = record.timestamp;
197
+ }
198
+ if (ms > lastMs) {
199
+ lastMs = ms;
200
+ lastTs = record.timestamp;
201
+ }
202
+ }
203
+ return { firstTs, lastTs };
204
+ }
205
+
92
206
  /**
93
207
  * Validate a single events.jsonl record against the canonical schema.
94
208
  *