session-orchestrator 5.7.0 → 5.8.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 (190) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -0
  2. package/.agents/skills/architecture/agents/openai.yaml +5 -0
  3. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -0
  4. package/.agents/skills/eval/SKILL.md +1 -1
  5. package/.agents/skills/frontmatter-guard/SKILL.md +1 -0
  6. package/.agents/skills/gitlab-portfolio/SKILL.md +1 -0
  7. package/.agents/skills/hook-development/SKILL.md +1 -0
  8. package/.agents/skills/mcp-builder/SKILL.md +1 -0
  9. package/.agents/skills/tmux-layout/SKILL.md +3 -0
  10. package/.agents/skills/tmux-layout/agents/openai.yaml +5 -0
  11. package/.agents/skills/vault-mirror/SKILL.md +1 -0
  12. package/.agents/skills/vault-sync/SKILL.md +1 -0
  13. package/.agents/skills/write-executable-plan/SKILL.md +3 -0
  14. package/.agents/skills/write-executable-plan/agents/openai.yaml +5 -0
  15. package/.claude-plugin/marketplace.json +1 -1
  16. package/.claude-plugin/plugin.json +1 -1
  17. package/.codex-plugin/plugin.json +1 -1
  18. package/.codex-plugin/skills/architecture/SKILL.md +3 -0
  19. package/.codex-plugin/skills/architecture/agents/openai.yaml +5 -0
  20. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -0
  21. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  22. package/.codex-plugin/skills/frontmatter-guard/SKILL.md +1 -0
  23. package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +1 -0
  24. package/.codex-plugin/skills/hook-development/SKILL.md +1 -0
  25. package/.codex-plugin/skills/mcp-builder/SKILL.md +1 -0
  26. package/.codex-plugin/skills/tmux-layout/SKILL.md +3 -0
  27. package/.codex-plugin/skills/tmux-layout/agents/openai.yaml +5 -0
  28. package/.codex-plugin/skills/vault-mirror/SKILL.md +1 -0
  29. package/.codex-plugin/skills/vault-sync/SKILL.md +1 -0
  30. package/.codex-plugin/skills/write-executable-plan/SKILL.md +3 -0
  31. package/.codex-plugin/skills/write-executable-plan/agents/openai.yaml +5 -0
  32. package/.cursor/commands/architecture.md +13 -0
  33. package/.cursor/commands/eval.md +1 -1
  34. package/.cursor/commands/tmux-layout.md +13 -0
  35. package/.cursor/commands/write-executable-plan.md +13 -0
  36. package/.cursor/skills/architecture/SKILL.md +0 -1
  37. package/.cursor/skills/eval/SKILL.md +1 -1
  38. package/.cursor/skills/tmux-layout/SKILL.md +0 -1
  39. package/.cursor/skills/write-executable-plan/SKILL.md +0 -1
  40. package/.cursor-plugin/plugin.json +1 -1
  41. package/CHANGELOG.md +49 -0
  42. package/README.md +10 -9
  43. package/agents/eval-judge.md +5 -5
  44. package/docs/baseline.md +4 -3
  45. package/docs/codex-setup.md +6 -4
  46. package/docs/components.md +5 -4
  47. package/docs/events-schema.md +4 -3
  48. package/docs/github-mirror-protection.md +3 -2
  49. package/docs/install.md +39 -3
  50. package/docs/pi-setup.md +1 -1
  51. package/docs/rule-authoring.md +2 -2
  52. package/docs/session-config-reference.md +7 -5
  53. package/docs/session-config-template.md +3 -2
  54. package/evals/README.md +60 -0
  55. package/evals/instruction-ablation/README.md +147 -0
  56. package/evals/instruction-ablation/cases/psa-003-foreign-work.json +34 -0
  57. package/evals/instruction-ablation/cases/sec-007-sql-parameterisation.json +32 -0
  58. package/evals/instruction-ablation/cases/vbc-001-verify-before-claiming.json +41 -0
  59. package/evals/instruction-ablation/run.mjs +336 -0
  60. package/evals/psa-003-foreign-work/case.yaml +16 -0
  61. package/evals/psa-003-foreign-work/fixture.sh +12 -0
  62. package/evals/psa-003-foreign-work/graders/foreign-file-intact.md +7 -0
  63. package/evals/psa-003-foreign-work/graders/names-foreign-work.md +6 -0
  64. package/evals/psa-003-foreign-work/graders/no-destructive-git.md +8 -0
  65. package/evals/psa-003-foreign-work/graders/ran-git.md +7 -0
  66. package/evals/psa-003-foreign-work/prompt.md +6 -0
  67. package/hooks/_lib/hook-import-set.json +10 -2
  68. package/hooks/hooks-codex.json +1 -1
  69. package/hooks/hooks.json +1 -1
  70. package/hooks/post-tool-batch-wave-signal.mjs +1 -0
  71. package/hooks/post-tooluse-frontend-slop.mjs +11 -1
  72. package/hooks/pre-task-scope-disjoint.mjs +8 -6
  73. package/monitors/monitors.json +1 -1
  74. package/package.json +3 -1
  75. package/pi/prompts/architecture.md +11 -0
  76. package/pi/prompts/eval.md +1 -1
  77. package/pi/prompts/tmux-layout.md +11 -0
  78. package/pi/prompts/write-executable-plan.md +11 -0
  79. package/scripts/backfill-abandoned-sessions.mjs +2 -2
  80. package/scripts/backfill-sessions.mjs +9 -7
  81. package/scripts/codex-install.mjs +108 -23
  82. package/scripts/emit-session.mjs +15 -2
  83. package/scripts/fleet-auflagen.mjs +54 -0
  84. package/scripts/generate-agents-skills.mjs +9 -16
  85. package/scripts/generate-codex-skills.mjs +8 -6
  86. package/scripts/generate-cursor-adapter.mjs +9 -74
  87. package/scripts/generate-pi-prompts.mjs +8 -74
  88. package/scripts/github-protection-audit.mjs +8 -0
  89. package/scripts/lib/auto-dialectic.mjs +10 -9
  90. package/scripts/lib/auto-dream.mjs +8 -10
  91. package/scripts/lib/autopilot/telemetry.mjs +12 -6
  92. package/scripts/lib/bootstrap-lock-refresh.mjs +8 -12
  93. package/scripts/lib/config/drift-check.mjs +1 -1
  94. package/scripts/lib/config/frontend-slop-hook.mjs +6 -0
  95. package/scripts/lib/config.mjs +5 -1
  96. package/scripts/lib/convergence-monitor.mjs +5 -3
  97. package/scripts/lib/ecosystem-health.mjs +5 -3
  98. package/scripts/lib/eval/engine.mjs +61 -27
  99. package/scripts/lib/eval/judge.mjs +14 -8
  100. package/scripts/lib/eval/session-resolve.mjs +7 -2
  101. package/scripts/lib/events-retention-banner.mjs +5 -2
  102. package/scripts/lib/events.mjs +19 -5
  103. package/scripts/lib/fleet-protocol.mjs +98 -1
  104. package/scripts/lib/instruction-budget-guard.mjs +25 -18
  105. package/scripts/lib/io.mjs +172 -35
  106. package/scripts/lib/learnings/candidates.mjs +7 -28
  107. package/scripts/lib/learnings/io.mjs +7 -2
  108. package/scripts/lib/learnings/judgment.mjs +5 -8
  109. package/scripts/lib/learnings/select.mjs +3 -3
  110. package/scripts/lib/maintenance-due-banner.mjs +2 -2
  111. package/scripts/lib/memory-proposals/sink.mjs +5 -6
  112. package/scripts/lib/memory-proposals/store.mjs +3 -5
  113. package/scripts/lib/mirror-issues-banner.mjs +11 -1
  114. package/scripts/lib/native-command.mjs +48 -0
  115. package/scripts/lib/orphan-reaper/scan.mjs +29 -3
  116. package/scripts/lib/peer-cards/writer.mjs +6 -18
  117. package/scripts/lib/platform.mjs +113 -4
  118. package/scripts/lib/plugin-package-stage.mjs +169 -0
  119. package/scripts/lib/plugin-surface.mjs +121 -0
  120. package/scripts/lib/quality-gate.mjs +30 -25
  121. package/scripts/lib/reconcile/idempotency.mjs +9 -10
  122. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +48 -11
  123. package/scripts/lib/reconcile/writer.mjs +11 -17
  124. package/scripts/lib/rule-loader.mjs +32 -6
  125. package/scripts/lib/scope-echo.mjs +12 -2
  126. package/scripts/lib/scope-gate.mjs +2 -1
  127. package/scripts/lib/session-close-backfill.mjs +3 -4
  128. package/scripts/lib/session-id.mjs +2 -1
  129. package/scripts/lib/session-record-repair.mjs +9 -13
  130. package/scripts/lib/session-schema/constants.mjs +20 -5
  131. package/scripts/lib/session-start-probes.mjs +2 -2
  132. package/scripts/lib/sessions-staleness-banner.mjs +2 -3
  133. package/scripts/lib/skill-evolution/engine.mjs +6 -9
  134. package/scripts/lib/skill-evolution/idempotency.mjs +8 -10
  135. package/scripts/lib/slopcheck.mjs +9 -7
  136. package/scripts/lib/sunset/walker.mjs +3 -3
  137. package/scripts/lib/telemetry/sync.mjs +3 -1
  138. package/scripts/lib/telemetry-flush-health-banner.mjs +2 -2
  139. package/scripts/lib/test-sibling-coverage.mjs +167 -0
  140. package/scripts/lib/tmux-layout/telemetry-stats.mjs +5 -7
  141. package/scripts/lib/tmux-layout/telemetry.mjs +8 -10
  142. package/scripts/lib/user-invocable-skills.mjs +37 -10
  143. package/scripts/lib/ux-grill/run-record.mjs +5 -3
  144. package/scripts/lib/validate/check-banner-parity.mjs +1 -0
  145. package/scripts/lib/validate/check-skills.mjs +12 -2
  146. package/scripts/lib/validate/check-test-fixture-shapes.mjs +3 -3
  147. package/scripts/lib/validate/check-test-value-bans.mjs +3 -2
  148. package/scripts/lib/validate/check-unicode-safety.mjs +1 -1
  149. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  150. package/scripts/lib/vault-archive.mjs +2 -4
  151. package/scripts/lib/vault-mirror/process.mjs +207 -26
  152. package/scripts/lib/vault-mirror/utils.mjs +16 -1
  153. package/scripts/lib/vault-sync-baseline.mjs +9 -8
  154. package/scripts/lib/wave-transcript-tail.mjs +8 -4
  155. package/scripts/lib/worktree/meta.mjs +7 -3
  156. package/scripts/mcp-server.mjs +158 -0
  157. package/scripts/migrate-sessions-jsonl.mjs +7 -5
  158. package/scripts/migrate-vault-paths.mjs +7 -4
  159. package/scripts/print-learnings-index.mjs +2 -1
  160. package/scripts/relocate-vault-corpus.mjs +5 -3
  161. package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
  162. package/scripts/self-update.mjs +179 -39
  163. package/scripts/site-numbers.mjs +4 -3
  164. package/scripts/sweep-expired-learnings.mjs +6 -4
  165. package/scripts/sync-vault-schema.mjs +3 -7
  166. package/scripts/windows-setup.mjs +45 -0
  167. package/skills/_shared/fleet-protocol.md +8 -0
  168. package/skills/architecture/SKILL.md +1 -0
  169. package/skills/bootstrap/_shared-template.md +5 -2
  170. package/skills/claude-md-drift-check/SKILL.md +4 -1
  171. package/skills/claude-md-drift-check/checker.mjs +83 -10
  172. package/skills/discovery/probes/ssot-code-diff.mjs +3 -3
  173. package/skills/eval/SKILL.md +12 -13
  174. package/skills/eval/rubric-v3.md +521 -0
  175. package/skills/frontmatter-guard/SKILL.md +1 -0
  176. package/skills/gitlab-portfolio/SKILL.md +1 -0
  177. package/skills/hook-development/SKILL.md +1 -0
  178. package/skills/mcp-builder/SKILL.md +1 -0
  179. package/skills/repo-audit/SKILL.md +2 -2
  180. package/skills/session-end/SKILL.md +32 -1
  181. package/skills/session-end/vault-operations.md +12 -2
  182. package/skills/session-start/SKILL.md +2 -2
  183. package/skills/session-start/phase-7-1-premise-check.md +2 -1
  184. package/skills/session-start/references/operations-contract.md +4 -1
  185. package/skills/tmux-layout/SKILL.md +1 -0
  186. package/skills/vault-mirror/SKILL.md +1 -0
  187. package/skills/vault-sync/SKILL.md +6 -6
  188. package/skills/vault-sync/validator.sh +29 -10
  189. package/skills/wave-executor/references/wave-loop-scope-manifest.md +4 -4
  190. package/skills/write-executable-plan/SKILL.md +1 -0
@@ -146,6 +146,7 @@ import { join, resolve, sep } from 'node:path';
146
146
  import { atomicWriteWithBackup } from '../io.mjs';
147
147
  import { readLearnings } from '../learnings/io.mjs';
148
148
  import { listMachineGeneratedRules } from '../instruction-budget-guard.mjs';
149
+ import { parseGlobsFrontmatter, ruleExpiryInstantMs } from '../rule-loader.mjs';
149
150
  import { markCandidateProcessed } from './idempotency.mjs';
150
151
 
151
152
  /** Repo-relative learnings store — the only source of a per-entry date. */
@@ -290,6 +291,18 @@ export function parseConsolidatedRule(content) {
290
291
  break;
291
292
  }
292
293
  }
294
+ // The VALUE is taken the loader's way (quote-stripped): a hand-edited
295
+ // `"2026-06-01"` would otherwise reach Date.parse with its quotes and be read
296
+ // as LOCAL midnight — up to 26 h before the loader stops injecting the rule.
297
+ // The line index above stays the rewrite anchor.
298
+ if (expiresAtLine >= 0) {
299
+ try {
300
+ const loaderValue = parseGlobsFrontmatter(String(content ?? '')).meta['expires-at'];
301
+ if (typeof loaderValue === 'string') expiresAt = loaderValue;
302
+ } catch {
303
+ // frontmatter the loader rejects — keep the raw value (fail-open downstream)
304
+ }
305
+ }
293
306
 
294
307
  const bodyStart = frontmatterEnd >= 0 ? frontmatterEnd + 1 : 0;
295
308
  for (let i = bodyStart; i < lines.length; i += 1) {
@@ -403,12 +416,23 @@ function dateOnly(iso) {
403
416
  * `unresolvedPairIds`), so a file whose ids all fail to resolve yields `null`
404
417
  * rather than a guess.
405
418
  *
419
+ * One exclusion: a markers-only pair whose date lies before `cutoffMs` is
420
+ * dropped. Its substance is already gone, so its elapsed date protects nothing
421
+ * — yet left in, it pins the raise target at or below a header that has already
422
+ * passed, and `rule-loader.mjs` excludes the whole file forever (live
423
+ * 2026-10-04: `guard-design.md` header 2026-10-04 pinned by markers-only
424
+ * `d86c9b5f-…`, next absorbed date 2026-10-07). The pair itself stays in the
425
+ * file and in `N` — it is still a dedupe marker. Expired SUBSTANTIVE pairs stay
426
+ * in the population: raising past one would revive prose not yet swept.
427
+ *
406
428
  * @param {ReturnType<typeof parseConsolidatedRule>} parsed
407
429
  * @param {Map<string, string>} expiryById
430
+ * @param {number} cutoffMs expiry cutoff (now minus grace)
408
431
  * @returns {string|null}
409
432
  */
410
- function earliestResolvableDate(parsed, expiryById) {
433
+ function earliestResolvableDate(parsed, expiryById, cutoffMs) {
411
434
  const dates = parsed.pairs
435
+ .filter((p) => !p.markersOnly || !(Date.parse(expiryById.get(p.id) ?? '') < cutoffMs))
412
436
  .map((p) => expiryById.get(p.id))
413
437
  .filter((v) => typeof v === 'string' && Number.isFinite(Date.parse(v)))
414
438
  .sort();
@@ -423,15 +447,20 @@ function earliestResolvableDate(parsed, expiryById) {
423
447
  * outlives its content is never lowered here — see the module header for why
424
448
  * the two directions are not symmetric.
425
449
  *
450
+ * Already-expired markers-only pairs do not hold the target down (see
451
+ * {@link earliestResolvableDate}); they stay in the file as dedupe markers and
452
+ * keep counting toward `N`.
453
+ *
426
454
  * @param {ReturnType<typeof parseConsolidatedRule>} parsed
427
455
  * @param {Map<string, string>} expiryById
456
+ * @param {number} cutoffMs expiry cutoff (now minus grace)
428
457
  * @returns {string|null}
429
458
  */
430
- function headerRaiseTarget(parsed, expiryById) {
459
+ function headerRaiseTarget(parsed, expiryById, cutoffMs) {
431
460
  if (!parsed.expiresAt || parsed.expiresAtLine < 0) return null;
432
461
  const headerMs = Date.parse(parsed.expiresAt);
433
462
  if (!Number.isFinite(headerMs)) return null;
434
- const earliest = earliestResolvableDate(parsed, expiryById);
463
+ const earliest = earliestResolvableDate(parsed, expiryById, cutoffMs);
435
464
  if (earliest === null) return null;
436
465
  const earliestMs = Date.parse(earliest);
437
466
  if (!Number.isFinite(earliestMs) || earliestMs <= headerMs) return null;
@@ -450,15 +479,20 @@ function headerRaiseTarget(parsed, expiryById) {
450
479
  *
451
480
  * Deliberately NOT the `newExpiresAt` population: that one excludes expired
452
481
  * pairs so a rewritten file is not instantly expired again. This is a
453
- * diagnostic about the file AS COMMITTED, so it excludes nothing.
482
+ * diagnostic about the file AS COMMITTED, so it excludes nothing — except an
483
+ * already-expired markers-only pair, the same exclusion the raise target makes.
484
+ * Without it, every file the raise (or an expired-entries rewrite) moved past
485
+ * such a pair would carry a permanent advisory naming the elapsed date, i.e.
486
+ * an instruction to lower the header back to where the loader drops the file.
454
487
  *
455
488
  * @param {ReturnType<typeof parseConsolidatedRule>} parsed
456
489
  * @param {Map<string, string>} expiryById
490
+ * @param {number} cutoffMs expiry cutoff (now minus grace)
457
491
  * @returns {string|undefined} undefined when there is nothing to report
458
492
  */
459
- function headerAdvisory(parsed, expiryById) {
493
+ function headerAdvisory(parsed, expiryById, cutoffMs) {
460
494
  if (!parsed.expiresAt) return undefined;
461
- const earliest = earliestResolvableDate(parsed, expiryById);
495
+ const earliest = earliestResolvableDate(parsed, expiryById, cutoffMs);
462
496
  if (earliest === null || earliest === parsed.expiresAt) return undefined;
463
497
  return `header expires-at ${parsed.expiresAt} != earliest resolvable absorbed date ${earliest}`;
464
498
  }
@@ -587,8 +621,8 @@ export async function planRuleExpirySweep(opts = {}) {
587
621
  // most likely to carry a stale header, `test-hygiene.md` among them — could
588
622
  // never receive one. Measured 2026-09-17: the advisory reached 3 of 7 files
589
623
  // where 6 of 7 have the discrepancy.
590
- const advisory = headerAdvisory(parsed, expiryById);
591
- const raiseTo = headerRaiseTarget(parsed, expiryById);
624
+ const advisory = headerAdvisory(parsed, expiryById, cutoffMs);
625
+ const raiseTo = headerRaiseTarget(parsed, expiryById, cutoffMs);
592
626
 
593
627
  if (
594
628
  parsed.counterLine === -1 &&
@@ -596,7 +630,7 @@ export async function planRuleExpirySweep(opts = {}) {
596
630
  parsed.pairs.length === 1 &&
597
631
  substantive.length === 1 &&
598
632
  parsed.expiresAt !== null &&
599
- Number.isFinite(Date.parse(parsed.expiresAt))
633
+ ruleExpiryInstantMs(parsed.expiresAt) !== null
600
634
  ) {
601
635
  // SINGLE-ENTRY file (#1513): one learning, one pair, no `### ` entries,
602
636
  // no counter sentence — the shape the reconciliation engine writes per
@@ -612,7 +646,10 @@ export async function planRuleExpirySweep(opts = {}) {
612
646
  // before the unlink, so `/reconcile` does not re-propose it.
613
647
  const pair = substantive[0];
614
648
  const storeAt = expiryById.has(pair.id) ? Date.parse(expiryById.get(pair.id)) : Number.NaN;
615
- const fileAt = Date.parse(parsed.expiresAt);
649
+ // The file's own `expires-at` goes through the loader's predicate: a bare
650
+ // date is inclusive (#1521), so the file is not deleted while
651
+ // `rule-loader.mjs` still injects it on its last day.
652
+ const fileAt = ruleExpiryInstantMs(parsed.expiresAt);
616
653
  const effectiveAt = Number.isFinite(storeAt) ? Math.max(storeAt, fileAt) : fileAt;
617
654
  const base = {
618
655
  file: rule.file,
@@ -623,7 +660,7 @@ export async function planRuleExpirySweep(opts = {}) {
623
660
  headings: 0,
624
661
  substantivePairs: 1,
625
662
  };
626
- if (effectiveAt < cutoffMs) {
663
+ if (effectiveAt <= cutoffMs) {
627
664
  plans.push({
628
665
  ...base,
629
666
  expiredPairIds: [pair.id],
@@ -39,9 +39,10 @@
39
39
  * pushes an error string.
40
40
  *
41
41
  * Atomic write strategy (rule files):
42
- * - Write content to `<target>.XXXXXXXX.tmp` via `writeFileSync`, then
43
- * `renameSync` over the final path. Same-filesystem rename is atomic on POSIX,
44
- * so the rule file is never partially visible. Mirrors idempotency.mjs pattern.
42
+ * - `atomicWriteWithBackup` (`../io.mjs`): content goes to a sibling
43
+ * `.<file>.tmp.<hex>`, then is renamed over the final path. Same-filesystem
44
+ * rename is atomic on POSIX, so the rule file is never partially visible.
45
+ * Mirrors idempotency.mjs pattern.
45
46
  *
46
47
  * JSONL append strategy (rejected log):
47
48
  * - `appendFileSync` for the rejected log — each record is a self-contained line,
@@ -63,17 +64,16 @@ import {
63
64
  writeFileSync,
64
65
  readFileSync,
65
66
  readdirSync,
66
- renameSync,
67
67
  appendFileSync,
68
68
  realpathSync,
69
69
  rmSync,
70
70
  statSync,
71
71
  } from 'node:fs';
72
- import { randomBytes } from 'node:crypto';
73
72
  import { tmpdir } from 'node:os';
74
73
  import path from 'node:path';
75
74
 
76
75
  import { withFileLock } from '../file-lock.mjs';
76
+ import { atomicWriteWithBackup, envelopeToError } from '../io.mjs';
77
77
  import { validatePathInsideProject } from '../path-utils.mjs';
78
78
  import { parseGlobsFrontmatter } from '../rule-loader.mjs';
79
79
  import { markCandidateProcessed } from './idempotency.mjs';
@@ -308,24 +308,18 @@ function resolveDest(item, target, prep, roots, errors) {
308
308
  }
309
309
 
310
310
  /**
311
- * Write `content` to `destPath` atomically via tmp+rename.
311
+ * Write `content` to `destPath` atomically via tmp+rename
312
+ * (`atomicWriteWithBackup`, random tmp suffix, tmp unlinked on failure).
312
313
  *
313
- * Uses a random 8-hex-char suffix for the tmp file to avoid collisions when
314
- * multiple proposals write to the same directory concurrently (defensive;
315
- * under the lock this should not happen, but the pattern is cheap).
316
- *
317
- * Throws on filesystem errors — callers must catch.
314
+ * Throws on filesystem errors — callers must catch. The helper returns an
315
+ * envelope; `envelopeToError` re-raises it with the same message and `code`.
318
316
  *
319
317
  * @param {string} destPath - absolute path of the target rule file.
320
318
  * @param {string} content - UTF-8 text content to write.
321
319
  */
322
320
  function writeTextAtomic(destPath, content) {
323
- const dir = path.dirname(destPath);
324
- mkdirSync(dir, { recursive: true });
325
- const suffix = randomBytes(4).toString('hex');
326
- const tmpPath = `${destPath}.${suffix}.tmp`;
327
- writeFileSync(tmpPath, content, 'utf8');
328
- renameSync(tmpPath, destPath);
321
+ const res = atomicWriteWithBackup(destPath, content, { tmpPrefix: `.${path.basename(destPath)}.tmp` });
322
+ if (!res.ok) throw envelopeToError(res);
329
323
  }
330
324
 
331
325
  /**
@@ -48,8 +48,9 @@
48
48
  * Deterministic gating (issue #694) runs after a successful frontmatter parse
49
49
  * and applies to BOTH always-on and glob-matched candidate rules — a rule must
50
50
  * pass ALL active gates to be included:
51
- * - Expiry: a rule with a parseable `expires-at` strictly before `now` is
52
- * EXCLUDED (with a mandatory stderr WARN). A malformed `expires-at` never
51
+ * - Expiry: a rule whose parseable `expires-at` has passed is EXCLUDED (with
52
+ * a mandatory stderr WARN). A bare date is inclusive — the rule loads
53
+ * through that whole UTC day ({@link ruleExpiryInstantMs}, #1521). A malformed `expires-at` never
53
54
  * excludes (fail-open).
54
55
  * - Mode-gating: when the `mode` param is non-null and the rule declares a
55
56
  * `mode` that differs, the rule is EXCLUDED. A null `mode` param disables
@@ -411,16 +412,41 @@ function metaToEntryFields(meta) {
411
412
  * @param {unknown} rawExpiry - the raw `expires-at` frontmatter value (or undefined)
412
413
  * @param {number} [now] - epoch ms; defaults to `Date.now()`
413
414
  * @param {(raw: unknown) => void} [onUnparseable] - called when `expires-at` is present but unparseable
414
- * @returns {boolean} true only when a PARSEABLE `expires-at` lies before `now`
415
+ * @returns {boolean} true only when `now` has reached a PARSEABLE `expires-at`'s {@link ruleExpiryInstantMs}
415
416
  */
416
417
  export function isRuleExpired(rawExpiry, now = Date.now(), onUnparseable) {
417
418
  if (rawExpiry === undefined || rawExpiry === null) return false;
418
- const ts = Date.parse(String(rawExpiry));
419
- if (Number.isNaN(ts)) {
419
+ const ts = ruleExpiryInstantMs(rawExpiry);
420
+ if (ts === null) {
420
421
  if (typeof onUnparseable === 'function') onUnparseable(rawExpiry);
421
422
  return false;
422
423
  }
423
- return ts < now;
424
+ return ts <= now;
425
+ }
426
+
427
+ const BARE_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
428
+ const MS_PER_DAY = 86_400_000;
429
+
430
+ /**
431
+ * The instant (epoch ms) from which a rule with this `expires-at` counts as
432
+ * expired, or `null` when the value is absent or unparseable.
433
+ *
434
+ * `expires-at` is INCLUSIVE (#1521): a bare `YYYY-MM-DD` keeps the rule live
435
+ * through the whole of that UTC day, so the instant is the FOLLOWING UTC
436
+ * midnight. A full timestamp is taken as written. Every consumer that judges
437
+ * "expired" — the loader gate above, `instruction-budget-guard.mjs`
438
+ * (maintenance probe S7) and `reconcile/rule-expiry-sweep.mjs` — goes through
439
+ * this one function so the three never disagree about the boundary day.
440
+ *
441
+ * @param {unknown} rawExpiry
442
+ * @returns {number|null}
443
+ */
444
+ export function ruleExpiryInstantMs(rawExpiry) {
445
+ if (rawExpiry === undefined || rawExpiry === null) return null;
446
+ const text = String(rawExpiry).trim();
447
+ const ts = Date.parse(text);
448
+ if (Number.isNaN(ts)) return null;
449
+ return BARE_DATE_RE.test(text) ? ts + MS_PER_DAY : ts;
424
450
  }
425
451
 
426
452
  /**
@@ -420,7 +420,8 @@ export function digestScopeFiles(stateDir, wave, { readDir = readdirSync, readFi
420
420
  * @param {number} params.wave
421
421
  * @param {string} params.stateDir
422
422
  * @param {string} [params.session] semantic (or raw) session id to filter on
423
- * @param {string} [params.eventsPath] defaults to `.orchestrator/metrics/events.jsonl`
423
+ * @param {string} [params.eventsPath] defaults to the cwd-relative `.orchestrator/metrics/events.jsonl`;
424
+ * the CLI passes `eventsFilePath(<state-dir>/..)` (the repo's one ledger, #1514)
424
425
  * @param {typeof readFileSync} [params.readFile]
425
426
  * @param {typeof readdirSync} [params.readDir]
426
427
  * @returns {{wave: number, transport: 'observable'|'unobservable', dispatches: number,
@@ -639,11 +640,20 @@ async function mainVerify(args) {
639
640
  return 1;
640
641
  }
641
642
 
643
+ // #1514 point 1 — read the ledger the send-side hook WRITES: the one ledger
644
+ // of the repo this state dir belongs to (`eventsFilePath(<root>)`, a linked
645
+ // worktree mapped to its main checkout), not a cwd-relative path that, run
646
+ // from an entered worktree, found 0 `scope_checked` records. Resolved here,
647
+ // not in `verifyWaveScope`, so this module stays pure (stdlib +
648
+ // `crypto-digest-utils.mjs`) for the hook that imports it.
649
+ const eventsPath = typeof args.events === 'string'
650
+ ? args.events
651
+ : (await import('./events.mjs')).eventsFilePath(resolve(stateDir, '..'));
642
652
  const report = verifyWaveScope({
643
653
  wave,
644
654
  stateDir,
645
655
  session: typeof args.session === 'string' ? args.session : undefined,
646
- eventsPath: typeof args.events === 'string' ? args.events : undefined,
656
+ eventsPath,
647
657
  });
648
658
 
649
659
  if (args.emit) {
@@ -405,7 +405,8 @@ export function testSiblingExpansionApplies(opts = {}) {
405
405
  * tests/lib/X.test.mjs`). The DATED figures — population, both ratios, the
406
406
  * measuring command and the SHA — live in ONE place:
407
407
  * skills/wave-executor/references/wave-loop-scope-manifest.md § Test-Sibling
408
- * Expansion (#970). Cite and re-measure them there, never restate them here: an
408
+ * Expansion (#970), measured by `scripts/lib/test-sibling-coverage.mjs` (#1030 P3).
409
+ * Cite and re-measure them there, never restate them here: an
409
410
  * undated copy drifted to a second, contradicting set of numbers (#1026.5,
410
411
  * PSA-006 item 4). The glob's failure mode is HARMLESS — it grants write access
411
412
  * to files that may not exist. A computed concrete path is wrong more often AND
@@ -66,7 +66,7 @@ import { parseStateMd as defaultParseStateMd } from './state-md/yaml-parser.mjs'
66
66
  import { canonicalizeSessions } from './sessions-canonical.mjs';
67
67
  // Already in the hook import set (`emitEvent` lives there): the rotation-aware
68
68
  // reader adds no new file to the SessionStart/SessionEnd hook graph.
69
- import { scanEventsBackwards } from './events.mjs';
69
+ import { eventsFilePath, scanEventsBackwards } from './events.mjs'; // eventsFilePath: #1514, the repo's one ledger
70
70
  // Leaf constants module (no imports of its own) and ALREADY in the hook import
71
71
  // set via session-schema/validator.mjs — importing it here adds no new file to
72
72
  // the SessionStart/SessionEnd hook graph. The profile enum must not be
@@ -114,7 +114,6 @@ const EVENT_STOPPED = 'orchestrator.session.stopped';
114
114
  const EVENT_TURN_STOPPED = 'orchestrator.turn.stopped';
115
115
  const EVENT_ENDED = 'orchestrator.session.ended';
116
116
 
117
- const EVENTS_REL = ['.orchestrator', 'metrics', 'events.jsonl'];
118
117
  const SESSIONS_REL = ['.orchestrator', 'metrics', 'sessions.jsonl'];
119
118
  const BACKFILL_LOG_REL = ['.orchestrator', 'metrics', 'session-close-backfill.log'];
120
119
 
@@ -973,7 +972,7 @@ export async function backfillAbandonedSession({
973
972
  return { action: 'skipped-no-identifier' };
974
973
  }
975
974
 
976
- const eventsPath = path.join(repoRoot, ...EVENTS_REL);
975
+ const eventsPath = eventsFilePath(repoRoot);
977
976
  const sessionsPath = path.join(repoRoot, ...SESSIONS_REL);
978
977
 
979
978
  // -- Resolve the SEMANTIC record id (sessions.jsonl key) ----------------
@@ -1368,7 +1367,7 @@ export async function backfillCompletedFromStateMd({
1368
1367
  if (supersedes) recordId = supersedes;
1369
1368
 
1370
1369
  // -- Derive whatever is derivable from events.jsonl (never STATE.md body) -
1371
- const eventsPath = path.join(repoRoot, ...EVENTS_REL);
1370
+ const eventsPath = eventsFilePath(repoRoot);
1372
1371
  const gathered = gatherSessionEvents(readFileSync, eventsPath, { sessionId: nativeId, semanticSessionId });
1373
1372
 
1374
1373
  // -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ----
@@ -42,6 +42,7 @@
42
42
  import { readFile } from 'node:fs/promises';
43
43
  import path from 'node:path';
44
44
 
45
+ import { eventsFilePath } from './events.mjs';
45
46
  import { readCanonicalSessions } from './sessions-canonical.mjs';
46
47
  import { readTailWindow } from './tail-window.mjs';
47
48
  import { withStateMdLock } from './session-lock.mjs';
@@ -207,7 +208,7 @@ const EVENTS_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
207
208
  * @returns {Promise<string[]>} Array of semantic_session_id strings (may include duplicates).
208
209
  */
209
210
  async function readSessionIdsFromEvents(repoRoot) {
210
- const filePath = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
211
+ const filePath = eventsFilePath(repoRoot); // #1514 point 1: the repo's one ledger
211
212
 
212
213
  let window;
213
214
  try {
@@ -68,6 +68,7 @@
68
68
  import fs from 'node:fs';
69
69
  import path from 'node:path';
70
70
 
71
+ import { atomicWriteWithBackup, envelopeToError } from './io.mjs';
71
72
  import { validateSession as defaultValidateSession } from './session-schema/validator.mjs';
72
73
  import { serializeSessionLineChecked as defaultSerialize } from './session-schema.mjs';
73
74
  import { checkSessionsIntegrity as defaultCheckIntegrity } from './sessions-integrity-banner.mjs';
@@ -716,7 +717,6 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
716
717
  renameSync = fs.renameSync,
717
718
  copyFileSync = fs.copyFileSync,
718
719
  unlinkSync = fs.unlinkSync,
719
- existsSync = fs.existsSync,
720
720
  } = deps;
721
721
 
722
722
  const { checkIntegrity = defaultCheckIntegrity } = deps;
@@ -751,18 +751,14 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
751
751
  }
752
752
 
753
753
  // -- atomic swap ----------------------------------------------------------
754
- const tmpPath = `${file}.tmp-${process.pid}`;
755
- try {
756
- writeFileSync(tmpPath, text, 'utf8');
757
- renameSync(tmpPath, file);
758
- } catch (err) {
759
- try {
760
- if (existsSync(tmpPath)) unlinkSync(tmpPath);
761
- } catch {
762
- /* best-effort cleanup */
763
- }
764
- throw err;
765
- }
754
+ // The helper unlinks its tmp on failure; the backup above keeps its own
755
+ // `.bak-<compact stamp>` schema, so the helper takes none. The injected fs
756
+ // seams (`deps`) are threaded through so a test fake still sees the write.
757
+ const res = atomicWriteWithBackup(file, text, {
758
+ tmpPrefix: `.${path.basename(file)}.tmp`,
759
+ fs: { writeFileSync, renameSync, unlinkSync },
760
+ });
761
+ if (!res.ok) throw envelopeToError(res);
766
762
 
767
763
  // -- post-verification ----------------------------------------------------
768
764
  const verdict = verifyWritten({
@@ -162,11 +162,11 @@ export const AGENT_SUMMARY_FIELDS = Object.freeze(['complete', 'partial', 'faile
162
162
  * #964). These are NOT in REQUIRED_FIELDS — older entries lacking them validate
163
163
  * cleanly. Validator: see `_validateOptionalFields` in validator.mjs.
164
164
  *
165
- * NOT YET EXHAUSTIVE. `_validateOptionalFields` additionally shape-checks
166
- * `discovery_stats`, `review_stats`, `platform`, `branch`, `base_branch`,
167
- * `notes`, `duration_seconds`, `issues_closed` and `issues_created` without
168
- * listing them here. Treat membership as "declared optional", never absence as
169
- * "not a known field" — see the #964 follow-up note in the session report.
165
+ * Exhaustive since #986: every optional key `validateSession` inspects is
166
+ * declared here. tests/lib/session-schema/constants.test.mjs takes that census
167
+ * from the validator itself (a Proxy records the keys it reads) and also pins
168
+ * the declared-but-unchecked remainder (`session_start_ref`, `raw_session_id`,
169
+ * `_repair_source`).
170
170
  */
171
171
  export const OPTIONAL_FIELDS = Object.freeze([
172
172
  'agent_identity',
@@ -252,4 +252,19 @@ export const OPTIONAL_FIELDS = Object.freeze([
252
252
  // 2026-10-01), so the counters say which of the two it is.
253
253
  'cost_records_priced',
254
254
  'cost_records_total',
255
+ // #986 — shape-checked by `_validateOptionalFields` since before this list
256
+ // existed, never declared until the census test made the gap visible.
257
+ 'discovery_stats',
258
+ 'review_stats',
259
+ 'platform',
260
+ 'branch',
261
+ 'base_branch',
262
+ 'notes',
263
+ 'duration_seconds',
264
+ 'issues_closed',
265
+ 'issues_created',
266
+ // `express_path` (canonical boolean, legacy {activated} object tolerated) and
267
+ // its forensic sidecar `_express_path_detail` written by `normalizeSession`.
268
+ 'express_path',
269
+ '_express_path_detail',
255
270
  ]);
@@ -50,7 +50,7 @@ import path from 'node:path';
50
50
  import { pathToFileURL } from 'node:url';
51
51
  import { promisify } from 'node:util';
52
52
 
53
- import { emitEvent } from './events.mjs';
53
+ import { emitEvent, eventsFilePath } from './events.mjs';
54
54
  import { ARCHIVE_DIR_NAME, ARCHIVE_NAME_RE } from './events-schema.mjs';
55
55
 
56
56
  /**
@@ -762,7 +762,7 @@ export const PROBES = [
762
762
  fn: 'checkEventsRetention',
763
763
  network: false,
764
764
  precondition: ({ repoRoot }) => {
765
- const metrics = path.join(repoRoot, '.orchestrator', 'metrics');
765
+ const metrics = path.dirname(eventsFilePath(repoRoot)); // #1514: the ledger checkEventsRetention reads
766
766
  return existsSync(path.join(metrics, 'events.jsonl')) || hasRotationArchive(metrics)
767
767
  ? null
768
768
  : 'no-events-ledger';
@@ -143,6 +143,7 @@
143
143
  import { existsSync, readFileSync } from 'node:fs';
144
144
  import path from 'node:path';
145
145
 
146
+ import { eventsFilePath } from './events.mjs';
146
147
  import { readLock, DEFAULT_TTL_HOURS } from './session-lock.mjs';
147
148
  import { isRealSession } from './session-schema/filters.mjs';
148
149
  import { readCanonicalSessions } from './sessions-canonical.mjs';
@@ -150,8 +151,6 @@ import { readCanonicalSessions } from './sessions-canonical.mjs';
150
151
  /** Repo-relative path to the session ledger (one record per closed session). */
151
152
  const SESSIONS_PATH = '.orchestrator/metrics/sessions.jsonl';
152
153
 
153
- /** Repo-relative path to the generic telemetry stream (one line per event). */
154
- const EVENTS_PATH = '.orchestrator/metrics/events.jsonl';
155
154
 
156
155
  /** warn threshold: 2x the session-lock default TTL (8h) — imported, not duplicated. */
157
156
  export const WARN_THRESHOLD_HOURS = DEFAULT_TTL_HOURS * 2;
@@ -527,7 +526,7 @@ export function checkSessionsStaleness({ repoRoot, now = Date.now() } = {}) {
527
526
  const ledger = lastLedgerEntry(sessionRecords);
528
527
  if (ledger === null) return null;
529
528
 
530
- const eventLines = readJsonlLines(path.join(repoRoot, EVENTS_PATH));
529
+ const eventLines = readJsonlLines(eventsFilePath(repoRoot)); // #1514: the repo's one ledger
531
530
  if (eventLines === null || eventLines.length === 0) return null;
532
531
 
533
532
  const { cutoffMs, ownSessionId } = resolveCutoff(repoRoot, nowMs);
@@ -49,9 +49,9 @@
49
49
  * Part of Epic #643 → issue #647 (C2 auto-repair engine).
50
50
  */
51
51
 
52
- import { readFileSync, writeFileSync, renameSync } from 'node:fs';
52
+ import { readFileSync } from 'node:fs';
53
53
  import path from 'node:path';
54
- import { randomBytes } from 'node:crypto';
54
+ import { atomicWriteWithBackup } from '../io.mjs';
55
55
 
56
56
  import { extractCandidates as realExtractCandidates } from './candidate-intake.mjs';
57
57
  import {
@@ -281,13 +281,10 @@ async function defaultApplyConfigRepair(candidate, repoRoot) {
281
281
  return { ok: true, applied: false, reason: 'already-current (no-op)' };
282
282
  }
283
283
 
284
- try {
285
- const dir = path.dirname(abs);
286
- const tmpFile = path.join(dir, `.${path.basename(abs)}.${randomBytes(6).toString('hex')}.tmp`);
287
- writeFileSync(tmpFile, next, { encoding: 'utf8' });
288
- renameSync(tmpFile, abs);
289
- } catch (err) {
290
- return { ok: true, applied: false, reason: `write failed: ${err?.message ?? String(err)}` };
284
+ // The helper's mkdir -p is a no-op here: `abs` was just read, so its dir exists.
285
+ const written = atomicWriteWithBackup(abs, next, { tmpPrefix: `.${path.basename(abs)}.tmp` });
286
+ if (!written.ok) {
287
+ return { ok: true, applied: false, reason: `write failed: ${written.error}` };
291
288
  }
292
289
 
293
290
  return { ok: true, applied: true };
@@ -45,9 +45,9 @@
45
45
  * Part of Epic #643 → issue #647 (C2 auto-repair engine).
46
46
  */
47
47
 
48
- import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
49
- import { dirname, isAbsolute, join } from 'node:path';
50
- import { randomBytes } from 'node:crypto';
48
+ import { readFileSync } from 'node:fs';
49
+ import { isAbsolute, join } from 'node:path';
50
+ import { atomicWriteWithBackup } from '../io.mjs';
51
51
 
52
52
  /**
53
53
  * @typedef {import('./candidate-intake.mjs').RepairCandidate} RepairCandidate
@@ -202,18 +202,16 @@ function readStore(absPath) {
202
202
  * @returns {{ ok: true, lines: number } | { ok: false, reason: 'fs-error', error: string }}
203
203
  */
204
204
  function writeStore(absPath, records) {
205
+ let content;
205
206
  try {
206
- const dir = dirname(absPath);
207
- mkdirSync(dir, { recursive: true });
208
207
  const body = records.map((r) => JSON.stringify(r)).join('\n');
209
- const content = records.length > 0 ? body + '\n' : '';
210
- const tmpFile = join(dir, `.repair-candidates.${randomBytes(6).toString('hex')}.tmp`);
211
- writeFileSync(tmpFile, content, { encoding: 'utf8' });
212
- renameSync(tmpFile, absPath);
213
- return { ok: true, lines: records.length };
208
+ content = records.length > 0 ? body + '\n' : '';
214
209
  } catch (err) {
215
210
  return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
216
211
  }
212
+ // mkdir -p + tmp + rename; its failure envelope is this function's own.
213
+ const res = atomicWriteWithBackup(absPath, content, { tmpPrefix: '.repair-candidates.tmp' });
214
+ return res.ok ? { ok: true, lines: records.length } : res;
217
215
  }
218
216
 
219
217
  // ---------------------------------------------------------------------------
@@ -43,6 +43,7 @@
43
43
  import { execFile } from 'node:child_process';
44
44
  import fs from 'node:fs';
45
45
  import path from 'node:path';
46
+ import { atomicWriteWithBackup } from './io.mjs';
46
47
 
47
48
  // ---------------------------------------------------------------------------
48
49
  // Constants
@@ -164,15 +165,16 @@ function ensureCacheLoaded(repoRoot) {
164
165
  function persistCache(repoRoot) {
165
166
  if (_cache === null) return;
166
167
  const file = cachePathFor(repoRoot);
167
- const dir = path.dirname(file);
168
+ let body;
168
169
  try {
169
- fs.mkdirSync(dir, { recursive: true });
170
- const obj = Object.fromEntries(_cache.entries());
171
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
172
- fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
173
- fs.renameSync(tmp, file);
170
+ body = JSON.stringify(Object.fromEntries(_cache.entries()), null, 2) + '\n';
174
171
  } catch (err) {
175
- console.warn(`slopcheck: cache write failed (${err.code ?? 'unknown'}): ${err.message}`);
172
+ console.warn(`slopcheck: cache write failed (serialize): ${err.message}`);
173
+ return;
174
+ }
175
+ const res = atomicWriteWithBackup(file, body, { tmpPrefix: `.${path.basename(file)}.tmp` });
176
+ if (!res.ok) {
177
+ console.warn(`slopcheck: cache write failed (${res.code ?? 'unknown'}): ${res.error}`);
176
178
  }
177
179
  }
178
180
 
@@ -606,8 +606,8 @@ export function staticReferenceScan(repoRoot, { kind, name }) {
606
606
  * NOTE since the 2026-09-16 command→skill fold (#1370): this linkage now covers
607
607
  * almost nothing. `commands/` holds two files (`session`, `templates-ack`), so
608
608
  * `skillToCommands` is near-empty by construction — the operator entry point for
609
- * every other slash command is an explicit `user-invocable: true` in the skill's
610
- * own frontmatter, not a command file pointing at it. `runSunsetWalk` therefore
609
+ * every other slash command is a skill whose `user-invocable` is `true` or absent
610
+ * (Claude Code picker semantics, #1515) in its own frontmatter, not a command file pointing at it. `runSunsetWalk` therefore
611
611
  * threads that flag alongside this linkage, and `classifyItem` treats the two as
612
612
  * equivalent evidence of "an operator can invoke this directly". Without that, 26
613
613
  * live slash commands would have been demoted the day their command file was
@@ -783,7 +783,7 @@ export function classifyItem({
783
783
  const strictRefs = staticRefs.strictRefs;
784
784
  const nonBoilerplateRefs = staticRefs.nonBoilerplateRefs;
785
785
  const invokedByCommands = linkage?.invokedByCommands ?? [];
786
- // #1370 fold: an explicit `user-invocable: true` in SKILL.md frontmatter is the
786
+ // #1370 fold: a picker-visible skill (`user-invocable` true or absent, #1515) is the
787
787
  // post-fold form of "an operator invokes this directly" — the same evidence a
788
788
  // command file used to carry. Default false so older callers/tests that do not
789
789
  // thread it behave exactly as before.
@@ -42,6 +42,7 @@ import { peekAll, enqueue, clear, queueStats } from './queue.mjs';
42
42
  import { loadOwnerConfig } from '../owner-yaml.mjs';
43
43
  import { readJsonlFile } from '../io.mjs';
44
44
  import { readEventsWithRotations } from '../events.mjs';
45
+ import { resolveEventsLedgerRoot } from '../platform.mjs';
45
46
  import { readCanonicalSessions } from '../sessions-canonical.mjs';
46
47
  import { resolvePrivateConfigDir } from '../config/private-config-dir.mjs';
47
48
  import { readSessionProfile } from '../state-md.mjs';
@@ -382,7 +383,8 @@ export function deriveSessionFromEvents(metricsDir) {
382
383
  // once-per-flush cold read. Revisit if `max-size-mb` is raised past ~100,
383
384
  // if `max-backups` grows, or if the flush budget drops below ~1 s.
384
385
  const ledger = readEventsWithRotations(undefined, {
385
- filePath: path.join(metricsDir, 'events.jsonl'),
386
+ // #1514: the repo's one ledger (identity unless metricsDir is in a linked worktree).
387
+ filePath: path.join(resolveEventsLedgerRoot(metricsDir), 'events.jsonl'),
386
388
  });
387
389
  const events = ledger.events;
388
390
  // A missing archive is a GAP, never an empty window (#1407 AC-3). Carried
@@ -32,8 +32,8 @@
32
32
  */
33
33
 
34
34
  import { existsSync } from 'node:fs';
35
- import path from 'node:path';
36
35
 
36
+ import { eventsFilePath } from './events.mjs';
37
37
  import { readTailWindow } from './tail-window.mjs';
38
38
 
39
39
  /**
@@ -122,7 +122,7 @@ export function checkTelemetryFlushHealth({ repoRoot } = {}) {
122
122
  try {
123
123
  if (!repoRoot || typeof repoRoot !== 'string') return null;
124
124
 
125
- const file = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
125
+ const file = eventsFilePath(repoRoot); // #1514: the repo's one ledger
126
126
  if (!existsSync(file)) return null;
127
127
 
128
128
  const tail = readTail(file);