session-orchestrator 5.4.0 → 5.6.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 (151) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/060-evolve.mdc +5 -5
  5. package/.cursor-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +115 -3
  7. package/README.md +30 -15
  8. package/docs/ci-setup.md +120 -23
  9. package/docs/components.md +7 -7
  10. package/docs/events-schema.md +17 -14
  11. package/docs/install.md +14 -2
  12. package/docs/migration-v4.md +24 -17
  13. package/docs/plugin-architecture-v3.md +1 -1
  14. package/docs/scope-collision-guard.md +19 -15
  15. package/docs/session-config-reference.md +7 -7
  16. package/docs/vault-docs-architecture.md +8 -4
  17. package/hooks/_lib/hook-import-set.json +17 -8
  18. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  19. package/hooks/config-protection.mjs +1 -1
  20. package/hooks/enforce-commands.mjs +28 -13
  21. package/hooks/enforce-scope.mjs +201 -23
  22. package/hooks/hooks-codex.json +1 -1
  23. package/hooks/hooks.json +1 -1
  24. package/hooks/on-session-start.mjs +4 -2
  25. package/hooks/on-stop.mjs +77 -171
  26. package/hooks/post-bash-write-verify.mjs +219 -24
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +41 -156
  29. package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +603 -241
  32. package/hooks/wave-scope-commit-guard.mjs +235 -73
  33. package/package.json +9 -8
  34. package/scripts/apply-session-learnings.mjs +35 -14
  35. package/scripts/archive-closed-prds.mjs +38 -11
  36. package/scripts/autopilot.mjs +8 -1
  37. package/scripts/backfill-abandoned-sessions.mjs +80 -5
  38. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  39. package/scripts/codex-install.mjs +3 -0
  40. package/scripts/cursor-install.mjs +46 -2
  41. package/scripts/emit-session.mjs +5 -3
  42. package/scripts/export-hw-learnings.mjs +18 -7
  43. package/scripts/fleet-checkin.mjs +26 -3
  44. package/scripts/lib/autopilot/loop.mjs +25 -10
  45. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  46. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  47. package/scripts/lib/common.mjs +41 -0
  48. package/scripts/lib/config/vault-integration.mjs +151 -16
  49. package/scripts/lib/config.mjs +5 -1
  50. package/scripts/lib/eval/engine.mjs +13 -0
  51. package/scripts/lib/events-retention-banner.mjs +393 -0
  52. package/scripts/lib/events-rotation.mjs +21 -6
  53. package/scripts/lib/events.mjs +51 -3
  54. package/scripts/lib/fleet-protocol.mjs +20 -2
  55. package/scripts/lib/hardening.mjs +1 -0
  56. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  57. package/scripts/lib/instruction-budget-guard.mjs +8 -0
  58. package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
  59. package/scripts/lib/learnings/io.mjs +125 -16
  60. package/scripts/lib/lock-reaper.mjs +38 -14
  61. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  62. package/scripts/lib/named-vault-resolver.mjs +48 -5
  63. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  64. package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
  65. package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
  66. package/scripts/lib/orphan-reaper/scan.mjs +4 -0
  67. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  68. package/scripts/lib/orphan-reaper.mjs +5 -1
  69. package/scripts/lib/platform.mjs +206 -2
  70. package/scripts/lib/plugin-update-banner.mjs +3 -1
  71. package/scripts/lib/process-group.mjs +83 -22
  72. package/scripts/lib/scope-baseline.mjs +116 -25
  73. package/scripts/lib/scope-echo.mjs +7 -0
  74. package/scripts/lib/scope-gate.mjs +201 -27
  75. package/scripts/lib/session-close-backfill.mjs +202 -4
  76. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  77. package/scripts/lib/session-lock-shape.mjs +6 -3
  78. package/scripts/lib/session-lock.mjs +320 -67
  79. package/scripts/lib/session-registry.mjs +41 -16
  80. package/scripts/lib/session-start-probes.mjs +242 -68
  81. package/scripts/lib/session-token-rollup.mjs +39 -34
  82. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  83. package/scripts/lib/tail-window.mjs +27 -3
  84. package/scripts/lib/telemetry/pricing.mjs +5 -2
  85. package/scripts/lib/telemetry/sync.mjs +17 -0
  86. package/scripts/lib/tests-src-ratio.mjs +77 -2
  87. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  88. package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
  89. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  90. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  91. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  92. package/scripts/lib/vault-mirror/process.mjs +350 -62
  93. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  94. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  95. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  96. package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
  97. package/scripts/lib/vault-status/board-writer.mjs +5 -1
  98. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  99. package/scripts/materialize-wave-scope.mjs +7 -4
  100. package/scripts/release.mjs +1 -0
  101. package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
  102. package/scripts/run-quality-gate.mjs +8 -5
  103. package/scripts/self-update.mjs +229 -0
  104. package/scripts/sweep-expired-learnings.mjs +560 -28
  105. package/scripts/validate-wave-scope.mjs +60 -6
  106. package/scripts/vault-backfill.mjs +50 -37
  107. package/scripts/vault-mirror.mjs +170 -33
  108. package/skills/_shared/fleet-protocol.md +74 -71
  109. package/skills/_shared/parallel-aware-auq.md +1 -1
  110. package/skills/_shared/state-ownership.md +38 -14
  111. package/skills/autopilot/SKILL.md +1 -1
  112. package/skills/bootstrap/SKILL.md +5 -3
  113. package/skills/bootstrap/_shared-template.md +96 -1
  114. package/skills/bootstrap/fast-template.md +3 -0
  115. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  116. package/skills/bootstrap/standard-template.md +2 -0
  117. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  118. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  119. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  120. package/skills/docs-orchestrator/SKILL.md +12 -5
  121. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  122. package/skills/evolve/SKILL.md +11 -2
  123. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  124. package/skills/session-end/learning-patterns.md +3 -3
  125. package/skills/session-end/metrics-collection.md +1 -1
  126. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  127. package/skills/session-end/plan-verification.md +25 -9
  128. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  129. package/skills/session-end/session-metrics-write.md +45 -10
  130. package/skills/session-start/SKILL.md +3 -3
  131. package/skills/session-start/references/operations-contract.md +5 -3
  132. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  133. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  134. package/skills/vault-mirror/SKILL.md +21 -11
  135. package/skills/wave-executor/SKILL.md +1 -1
  136. package/skills/wave-executor/circuit-breaker.md +1 -1
  137. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  138. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  139. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  140. package/skills/wave-executor/references/wave-loop-scope-manifest.md +14 -6
  141. package/.agents/skills/navigator/SKILL.md +0 -22
  142. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  143. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  144. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  145. package/.cursor/commands/navigator.md +0 -15
  146. package/.cursor/skills/navigator/SKILL.md +0 -13
  147. package/pi/prompts/navigator.md +0 -12
  148. package/skills/navigator/SKILL.md +0 -193
  149. package/skills/navigator/references/account-check.sh +0 -87
  150. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  151. package/skills/navigator/references/slot-check.sh +0 -114
@@ -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);
@@ -826,7 +826,13 @@ export function listEventSourcesNewestFirst(opts = {}) {
826
826
  * a stop, the walk ends with `truncated: true` — which is NOT "not found":
827
827
  * the caller must report undeterminable, never a clean negative.
828
828
  * @returns {{stopped: boolean, truncated: boolean, malformed_lines: number,
829
- * 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".
830
836
  */
831
837
  export function scanEventsBackwards(opts = {}) {
832
838
  const { onRecord, filter, budgetMs } = opts;
@@ -840,16 +846,58 @@ export function scanEventsBackwards(opts = {}) {
840
846
 
841
847
  const scanned = [];
842
848
  const unreadable = [];
849
+ const gaps = [];
843
850
  let malformed = 0;
844
851
  let stopped = false;
845
852
  let truncated = false;
846
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
+
847
894
  /** @returns {boolean} true ⇒ the caller accepted a record; stop everything. */
848
895
  const consume = (block, source) => {
849
896
  const lines = block.toString('utf8').split('\n');
850
897
  for (let i = lines.length - 1; i >= 0; i -= 1) {
851
898
  const line = lines[i];
852
899
  if (!line) continue;
900
+ noteCut(line, source);
853
901
  if (typeof filter === 'string' && !line.includes(filter)) continue;
854
902
  let record;
855
903
  try {
@@ -863,7 +911,7 @@ export function scanEventsBackwards(opts = {}) {
863
911
  return false;
864
912
  };
865
913
 
866
- for (const source of listEventSourcesNewestFirst(opts)) {
914
+ for (const source of sources) {
867
915
  if (stopped || truncated) break;
868
916
  if (outOfTime()) {
869
917
  truncated = true;
@@ -915,5 +963,5 @@ export function scanEventsBackwards(opts = {}) {
915
963
  }
916
964
  }
917
965
 
918
- return { stopped, truncated, malformed_lines: malformed, sources: scanned, unreadable };
966
+ return { stopped, truncated, malformed_lines: malformed, sources: scanned, unreadable, gaps };
919
967
  }
@@ -10,8 +10,11 @@
10
10
  * <navigatorDir>/auflagen/<session_id>.json — conditions issued to a session
11
11
  *
12
12
  * `<navigatorDir>` is `NAVIGATOR_CONFIG_DIR` (trimmed; empty/whitespace counts as
13
- * unset) or `<home>/.config/navigator`. Files are 0600, directories 0700,
14
- * timestamps `date -u +%FT%TZ` (`YYYY-MM-DDTHH:MM:SSZ`).
13
+ * unset) or `<home>/.config/navigator`. Files are 0600, directories 0700.
14
+ * Check-in and lease timestamps are UTC with `Z`, in a form per file: the
15
+ * check-in's `zeit` is seconds, `date -u +%FT%TZ` (`YYYY-MM-DDTHH:MM:SSZ`,
16
+ * stamped by the CLI); the lease's `seit` / `laeuft_ab` are seconds or
17
+ * milliseconds (`LEASE_TS_RE`); the auflagen content belongs to the navigator.
15
18
  *
16
19
  * "A navigator is active" means exactly one thing: a readable, well-formed lease
17
20
  * whose `laeuft_ab` lies in the future. Everything else is fail-closed — an
@@ -122,6 +125,21 @@ export function utcSecondsTimestamp(date = new Date()) {
122
125
  return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
123
126
  }
124
127
 
128
+ /**
129
+ * When the Standard-Auflagen apply to a check-in written at `zeit` if no
130
+ * `auflagen/<session_id>.json` has appeared: {@link CHECKIN_FALLBACK_MIN} later
131
+ * while a navigator is active (it may still write one), and at `zeit` itself
132
+ * otherwise — with no active navigator nobody will, so the fallback is due at
133
+ * once (#1501 item 8: this deadline lived only as prose in session-start).
134
+ * @param {string} zeit the check-in's `zeit`, as {@link utcSecondsTimestamp} writes it
135
+ * @param {'active'|'none'|'unreadable'} navigatorState from {@link readNavigatorLease}
136
+ * @returns {string} same form as `zeit`
137
+ */
138
+ export function fallbackDueAt(zeit, navigatorState) {
139
+ if (navigatorState !== 'active') return zeit;
140
+ return utcSecondsTimestamp(new Date(Date.parse(zeit) + CHECKIN_FALLBACK_MIN * 60_000));
141
+ }
142
+
125
143
  /** @param {unknown} v */
126
144
  function isPlainObject(v) {
127
145
  return v !== null && typeof v === 'object' && !Array.isArray(v);
@@ -34,6 +34,7 @@ export { assertNodeVersion, assertDepInstalled, checkEnvironment } from './env-c
34
34
 
35
35
  // B) Scope / pattern primitives
36
36
  export {
37
+ /** @deprecated Use findOwnScopeFile from scope-gate.mjs because first-file selection ignores manifest ownership and can select a peer session's manifest. */
37
38
  findScopeFile,
38
39
  getEnforcementLevel,
39
40
  gateEnabled,
@@ -27,8 +27,11 @@ export const DEFAULT_LOCK_TTL_HOURS = 4;
27
27
  /**
28
28
  * Heartbeat-based liveness for a parsed session.lock — a self-contained mirror
29
29
  * of session-lock.mjs `isLockLive` (which is the SSOT for the semantics). A lock
30
- * is live when (now - last_heartbeat) < ttl_hours; falls back to started_at for
31
- * v1 locks. Returns false for malformed input (→ treated as an orphaned lease).
30
+ * is live when (now - last_heartbeat) < ttl_hours. A lock without
31
+ * `last_heartbeat` is not live — no `started_at` fallback since the #595 sunset
32
+ * (2026-10-02), same as the SSOT. Nor is one stamped more than 5 minutes in the
33
+ * future (#1494 — the SSOT's FUTURE_HEARTBEAT_TOLERANCE_MS). Returns false for
34
+ * malformed input (→ treated as an orphaned lease).
32
35
  *
33
36
  * Exported (additive-only) so tests/lib/lock-ttl-parity.test.mjs can drive both
34
37
  * this mirror and the SSOT with identical fixtures and assert identical verdicts.
@@ -39,11 +42,10 @@ export const DEFAULT_LOCK_TTL_HOURS = 4;
39
42
  */
40
43
  export function lockIsLive(lock, nowMs) {
41
44
  if (!lock || typeof lock !== 'object') return false;
42
- const hb = (typeof lock.last_heartbeat === 'string' && lock.last_heartbeat.length > 0)
43
- ? lock.last_heartbeat
44
- : lock.started_at;
45
- const ms = Date.parse(hb);
45
+ if (typeof lock.last_heartbeat !== 'string' || lock.last_heartbeat.length === 0) return false;
46
+ const ms = Date.parse(lock.last_heartbeat);
46
47
  if (Number.isNaN(ms)) return false;
48
+ if (ms > nowMs + 5 * 60 * 1000) return false;
47
49
  const ttlHours = typeof lock.ttl_hours === 'number' ? lock.ttl_hours : DEFAULT_LOCK_TTL_HOURS;
48
50
  return (nowMs - ms) < ttlHours * 3600 * 1000;
49
51
  }
@@ -1306,6 +1306,14 @@ export function computeInstructionBudget(opts = {}) {
1306
1306
  /** Ledger event the backlog number below is read from (`reconcile/engine.mjs`). */
1307
1307
  const RECONCILE_EVENT = 'orchestrator.reconcile.completed';
1308
1308
 
1309
+ /**
1310
+ * Events-ledger window for the #1401 census (`events-retention-banner.mjs`):
1311
+ * `null` — {@link readLastReconcileCapped} wants the NEWEST reconcile record,
1312
+ * whose age no constant bounds; a pruned one reads as not measured (`null`,
1313
+ * rendered as absence), never as a measured `0`.
1314
+ */
1315
+ export const REQUIRED_EVENTS_WINDOW_DAYS = null;
1316
+
1309
1317
  /**
1310
1318
  * Wall-clock budget for the backwards ledger walk. Deliberately small: this
1311
1319
  * runs inside a session-start probe whose own budget is 2 s, and the number it