session-orchestrator 5.6.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 (207) 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 +75 -0
  42. package/README.md +10 -10
  43. package/agents/eval-judge.md +5 -5
  44. package/docs/USER-GUIDE.md +1 -1
  45. package/docs/baseline.md +4 -3
  46. package/docs/codex-setup.md +6 -4
  47. package/docs/components.md +5 -4
  48. package/docs/events-schema.md +4 -3
  49. package/docs/github-mirror-protection.md +3 -2
  50. package/docs/install.md +39 -3
  51. package/docs/pi-setup.md +1 -1
  52. package/docs/rule-authoring.md +12 -2
  53. package/docs/session-config-reference.md +8 -6
  54. package/docs/session-config-template.md +4 -3
  55. package/evals/README.md +60 -0
  56. package/evals/instruction-ablation/README.md +147 -0
  57. package/evals/instruction-ablation/cases/psa-003-foreign-work.json +34 -0
  58. package/evals/instruction-ablation/cases/sec-007-sql-parameterisation.json +32 -0
  59. package/evals/instruction-ablation/cases/vbc-001-verify-before-claiming.json +41 -0
  60. package/evals/instruction-ablation/run.mjs +336 -0
  61. package/evals/psa-003-foreign-work/case.yaml +16 -0
  62. package/evals/psa-003-foreign-work/fixture.sh +12 -0
  63. package/evals/psa-003-foreign-work/graders/foreign-file-intact.md +7 -0
  64. package/evals/psa-003-foreign-work/graders/names-foreign-work.md +6 -0
  65. package/evals/psa-003-foreign-work/graders/no-destructive-git.md +8 -0
  66. package/evals/psa-003-foreign-work/graders/ran-git.md +7 -0
  67. package/evals/psa-003-foreign-work/prompt.md +6 -0
  68. package/hooks/_lib/hook-import-set.json +10 -2
  69. package/hooks/hooks-codex.json +1 -1
  70. package/hooks/hooks.json +1 -1
  71. package/hooks/post-tool-batch-wave-signal.mjs +1 -0
  72. package/hooks/post-tooluse-frontend-slop.mjs +11 -1
  73. package/hooks/pre-task-scope-disjoint.mjs +8 -6
  74. package/hooks/wave-scope-commit-guard.mjs +41 -10
  75. package/monitors/monitors.json +1 -1
  76. package/package.json +3 -1
  77. package/pi/prompts/architecture.md +11 -0
  78. package/pi/prompts/eval.md +1 -1
  79. package/pi/prompts/tmux-layout.md +11 -0
  80. package/pi/prompts/write-executable-plan.md +11 -0
  81. package/scripts/backfill-abandoned-sessions.mjs +6 -8
  82. package/scripts/backfill-learnings-from-vault.mjs +17 -6
  83. package/scripts/backfill-sessions.mjs +9 -7
  84. package/scripts/codex-install.mjs +108 -23
  85. package/scripts/emit-session.mjs +15 -2
  86. package/scripts/fleet-auflagen.mjs +54 -0
  87. package/scripts/generate-agents-skills.mjs +9 -16
  88. package/scripts/generate-codex-skills.mjs +8 -6
  89. package/scripts/generate-cursor-adapter.mjs +9 -74
  90. package/scripts/generate-pi-prompts.mjs +8 -74
  91. package/scripts/github-protection-audit.mjs +8 -0
  92. package/scripts/lib/auto-dialectic.mjs +10 -9
  93. package/scripts/lib/auto-dream.mjs +8 -10
  94. package/scripts/lib/autopilot/telemetry.mjs +12 -6
  95. package/scripts/lib/bootstrap-lock-refresh.mjs +8 -12
  96. package/scripts/lib/config/drift-check.mjs +1 -1
  97. package/scripts/lib/config/frontend-slop-hook.mjs +6 -0
  98. package/scripts/lib/config.mjs +5 -1
  99. package/scripts/lib/convergence-monitor.mjs +5 -3
  100. package/scripts/lib/dispatcher/rank.mjs +5 -1
  101. package/scripts/lib/ecosystem-health.mjs +5 -3
  102. package/scripts/lib/eval/engine.mjs +61 -27
  103. package/scripts/lib/eval/judge.mjs +14 -8
  104. package/scripts/lib/eval/session-resolve.mjs +7 -2
  105. package/scripts/lib/events-retention-banner.mjs +5 -2
  106. package/scripts/lib/events.mjs +81 -54
  107. package/scripts/lib/fleet-protocol.mjs +98 -1
  108. package/scripts/lib/instruction-budget-guard.mjs +25 -18
  109. package/scripts/lib/io.mjs +181 -39
  110. package/scripts/lib/learnings/candidates.mjs +14 -44
  111. package/scripts/lib/learnings/io.mjs +7 -2
  112. package/scripts/lib/learnings/judgment.mjs +5 -8
  113. package/scripts/lib/learnings/select.mjs +3 -3
  114. package/scripts/lib/maintenance-due-banner.mjs +2 -2
  115. package/scripts/lib/memory-proposals/sink.mjs +5 -6
  116. package/scripts/lib/memory-proposals/store.mjs +12 -7
  117. package/scripts/lib/mirror-issues-banner.mjs +11 -1
  118. package/scripts/lib/native-command.mjs +48 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +29 -3
  120. package/scripts/lib/peer-cards/writer.mjs +6 -18
  121. package/scripts/lib/platform.mjs +113 -4
  122. package/scripts/lib/plugin-package-stage.mjs +169 -0
  123. package/scripts/lib/plugin-surface.mjs +121 -0
  124. package/scripts/lib/quality-gate.mjs +30 -25
  125. package/scripts/lib/reconcile/idempotency.mjs +9 -10
  126. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +97 -8
  127. package/scripts/lib/reconcile/writer.mjs +11 -17
  128. package/scripts/lib/rule-loader.mjs +32 -6
  129. package/scripts/lib/scope-echo.mjs +12 -2
  130. package/scripts/lib/scope-gate.mjs +2 -1
  131. package/scripts/lib/session-close-backfill.mjs +21 -4
  132. package/scripts/lib/session-id.mjs +2 -1
  133. package/scripts/lib/session-record-repair.mjs +9 -13
  134. package/scripts/lib/session-schema/constants.mjs +20 -5
  135. package/scripts/lib/session-start-probes.mjs +2 -2
  136. package/scripts/lib/sessions-staleness-banner.mjs +2 -3
  137. package/scripts/lib/skill-evolution/engine.mjs +6 -9
  138. package/scripts/lib/skill-evolution/idempotency.mjs +8 -10
  139. package/scripts/lib/slopcheck.mjs +9 -7
  140. package/scripts/lib/sunset/walker.mjs +3 -3
  141. package/scripts/lib/telemetry/sync.mjs +3 -1
  142. package/scripts/lib/telemetry-flush-health-banner.mjs +2 -2
  143. package/scripts/lib/test-sibling-coverage.mjs +167 -0
  144. package/scripts/lib/tmux-layout/telemetry-stats.mjs +5 -7
  145. package/scripts/lib/tmux-layout/telemetry.mjs +8 -10
  146. package/scripts/lib/user-invocable-skills.mjs +37 -10
  147. package/scripts/lib/ux-grill/run-record.mjs +5 -3
  148. package/scripts/lib/validate/check-banner-parity.mjs +1 -0
  149. package/scripts/lib/validate/check-skills.mjs +12 -2
  150. package/scripts/lib/validate/check-test-fixture-shapes.mjs +3 -3
  151. package/scripts/lib/validate/check-test-value-bans.mjs +12 -2
  152. package/scripts/lib/validate/check-unicode-safety.mjs +1 -1
  153. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  154. package/scripts/lib/vault-archive.mjs +2 -4
  155. package/scripts/lib/vault-mirror/process.mjs +585 -72
  156. package/scripts/lib/vault-mirror/prune.mjs +413 -0
  157. package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
  158. package/scripts/lib/vault-mirror/retention.mjs +388 -0
  159. package/scripts/lib/vault-mirror/utils.mjs +16 -1
  160. package/scripts/lib/vault-status/board-writer.mjs +6 -6
  161. package/scripts/lib/vault-sync-baseline.mjs +9 -8
  162. package/scripts/lib/wave-transcript-tail.mjs +19 -6
  163. package/scripts/lib/worktree/meta.mjs +7 -3
  164. package/scripts/mcp-server.mjs +158 -0
  165. package/scripts/migrate-sessions-jsonl.mjs +7 -5
  166. package/scripts/migrate-vault-paths.mjs +7 -4
  167. package/scripts/print-applicable-rules.mjs +1 -25
  168. package/scripts/print-learnings-index.mjs +2 -1
  169. package/scripts/release.mjs +4 -0
  170. package/scripts/relocate-vault-corpus.mjs +5 -3
  171. package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
  172. package/scripts/self-update.mjs +179 -39
  173. package/scripts/site-numbers.mjs +4 -3
  174. package/scripts/sweep-expired-learnings.mjs +6 -4
  175. package/scripts/sync-vault-schema.mjs +3 -7
  176. package/scripts/vault-mirror-prune.mjs +145 -0
  177. package/scripts/vault-mirror.mjs +77 -12
  178. package/scripts/windows-setup.mjs +45 -0
  179. package/skills/_shared/fleet-protocol.md +8 -0
  180. package/skills/architecture/SKILL.md +1 -0
  181. package/skills/bootstrap/_shared-template.md +5 -2
  182. package/skills/claude-md-drift-check/SKILL.md +4 -1
  183. package/skills/claude-md-drift-check/checker.mjs +89 -26
  184. package/skills/discovery/probes/ssot-code-diff.mjs +3 -3
  185. package/skills/eval/SKILL.md +12 -13
  186. package/skills/eval/rubric-v3.md +521 -0
  187. package/skills/frontmatter-guard/SKILL.md +1 -0
  188. package/skills/gitlab-portfolio/SKILL.md +1 -0
  189. package/skills/hook-development/SKILL.md +1 -0
  190. package/skills/mcp-builder/SKILL.md +1 -0
  191. package/skills/repo-audit/SKILL.md +2 -2
  192. package/skills/session-end/SKILL.md +32 -1
  193. package/skills/session-end/vault-operations.md +12 -2
  194. package/skills/session-start/SKILL.md +2 -2
  195. package/skills/session-start/phase-7-1-premise-check.md +2 -1
  196. package/skills/session-start/references/operations-contract.md +4 -1
  197. package/skills/tmux-layout/SKILL.md +1 -0
  198. package/skills/vault-mirror/SKILL.md +28 -2
  199. package/skills/vault-sync/SKILL.md +6 -6
  200. package/skills/vault-sync/validator.sh +29 -10
  201. package/skills/wave-executor/references/wave-loop-scope-manifest.md +5 -3
  202. package/skills/write-executable-plan/SKILL.md +1 -0
  203. package/hooks/agent-teams-h3-test.sh +0 -362
  204. package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
  205. package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
  206. package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
  207. package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * eval/engine.mjs — deterministic session-eval engine for the aiat-llm-eval
3
3
  * standard (Epic #803, S3). Scores ONE completed orchestrator session against
4
- * the rubric-v2 dimensions using ONLY local metrics files
4
+ * the rubric-v3 dimensions using ONLY local metrics files
5
5
  * (sessions.jsonl + events.jsonl AND its rotated `_archive/`, #1407 — the
6
6
  * scored window is a PAST one and may predate the last rotation). Missing
7
7
  * source data ⇒ `cannot-determine`
@@ -18,7 +18,14 @@
18
18
  * `--verify` path checks. provenance.engine_commit / harness.hostname_hash may
19
19
  * vary across machines/commits but are EXCLUDED from the per-dimension diff.
20
20
  *
21
- * ── rubric-v2 DIMENSIONS (pre-registered verbatim in skills/eval/rubric-v2.md) ─
21
+ * ── rubric-v3 DIMENSIONS (pre-registered verbatim in skills/eval/rubric-v3.md) ─
22
+ *
23
+ * rubric-v3 (#1487) changes ONE rule against v2: a quality_gate event is
24
+ * FAILED when `exit_code !== 0`, `timed_out === true`, or the event is
25
+ * `orchestrator.quality_gate.failed` (`isFailedGateEvent`). v2 read the exit
26
+ * code only, so a killed reporting-variant run (exit 0 by design, #1459)
27
+ * scored `pass`. Stored v2 records are never re-scored: `--verify` reports
28
+ * VERSION-MISMATCH across versions.
22
29
  *
23
30
  * verification-evidence quality_gate events in the (clean) window all green
24
31
  * plan-fidelity effectiveness.completion_rate vs the v1 threshold
@@ -50,6 +57,7 @@ import { execFileSync } from 'node:child_process';
50
57
  import { readFileSync, existsSync } from 'node:fs';
51
58
  import os from 'node:os';
52
59
  import path from 'node:path';
60
+ import { resolveEventsLedgerRoot } from '../platform.mjs';
53
61
 
54
62
  import { resolvePluginRoot } from '../common.mjs';
55
63
  import { readEventsWithRotations } from '../events.mjs';
@@ -59,10 +67,10 @@ import { buildRunId, CURRENT_STANDARD_VERSION, VALID_MODEL_SOURCES } from './sch
59
67
  import { resolveSession, computeWindow, findPeerOverlap } from './session-resolve.mjs';
60
68
 
61
69
  /** The rubric version this engine scores against. */
62
- export const RUBRIC_VERSION = 'rubric-v2';
70
+ export const RUBRIC_VERSION = 'rubric-v3';
63
71
 
64
72
  /** Rubric location relative to the plugin root. */
65
- export const RUBRIC_RELATIVE_PATH = 'skills/eval/rubric-v2.md';
73
+ export const RUBRIC_RELATIVE_PATH = 'skills/eval/rubric-v3.md';
66
74
 
67
75
  /**
68
76
  * Default rubric location, resolved against the PLUGIN root rather than the
@@ -89,7 +97,7 @@ export const DEFAULT_RUBRIC_PATH = (() => {
89
97
  }
90
98
  })();
91
99
 
92
- /** Ordered rubric-v2 dimension ids — the canonical scoring order. */
100
+ /** Ordered rubric-v3 dimension ids — the canonical scoring order. */
93
101
  export const RUBRIC_DIMENSION_IDS = Object.freeze([
94
102
  'verification-evidence',
95
103
  'plan-fidelity',
@@ -124,6 +132,22 @@ const QUALITY_GATE_EVENTS = new Set([
124
132
  'orchestrator.quality_gate.failed',
125
133
  ]);
126
134
 
135
+ /**
136
+ * rubric-v3 § 1 "Failed gate event" (#1487): a quality_gate event is failed
137
+ * when its exit code is non-zero, when its timeout ladder killed it
138
+ * (`timed_out: true`), or when the gate itself recorded the run as
139
+ * `orchestrator.quality_gate.failed`. The reporting variants of
140
+ * `scripts/run-quality-gate.mjs` keep `exit_code: 0` on a killed run (#1459),
141
+ * so the exit code alone scored that run green. Both gate dimensions use this
142
+ * one predicate so they can never disagree about the same event.
143
+ *
144
+ * @param {{event?: string, exit_code?: unknown, timed_out?: unknown}} e
145
+ * @returns {boolean}
146
+ */
147
+ export function isFailedGateEvent(e) {
148
+ return e.exit_code !== 0 || e.timed_out === true || e.event === 'orchestrator.quality_gate.failed';
149
+ }
150
+
127
151
  function isNonEmptyString(v) {
128
152
  return typeof v === 'string' && v.trim().length > 0;
129
153
  }
@@ -223,12 +247,12 @@ function countAttributedEvents(ctx, eventName) {
223
247
  // ---------------------------------------------------------------------------
224
248
 
225
249
  /**
226
- * verification-evidence: ≥1 quality_gate event in the clean window ∧ all
227
- * exit_code==0 → pass; any exit_code≠0 → fail; 0 events ∧ total_files_changed==0
250
+ * verification-evidence: ≥1 quality_gate event in the clean window ∧ none
251
+ * failed (`isFailedGateEvent`) → pass; any failed → fail; 0 events ∧ total_files_changed==0
228
252
  * → not-applicable; 0 events otherwise / peer-contaminated window → cannot-determine.
229
253
  *
230
254
  * Exported ONLY so `tests/eval/rubric-parity.test.mjs` can drive it row-by-row
231
- * against the pre-registered table in `skills/eval/rubric-v2.md`. NOT a public
255
+ * against the pre-registered table in `skills/eval/rubric-v3.md`. NOT a public
232
256
  * contract: signature, arguments and return shape may change with the rubric.
233
257
  */
234
258
  export function scoreVerificationEvidence(ctx) {
@@ -266,20 +290,20 @@ export function scoreVerificationEvidence(ctx) {
266
290
  };
267
291
  }
268
292
 
269
- const failing = gates.filter((g) => g.exit_code !== 0);
293
+ const failing = gates.filter(isFailedGateEvent);
270
294
  if (failing.length === 0) {
271
295
  return {
272
296
  id,
273
297
  method,
274
298
  status: 'pass',
275
- evidence: `attribution: time-window. ${gates.length} quality_gate event(s) in window, all exit_code=0.`,
299
+ evidence: `attribution: time-window. ${gates.length} quality_gate event(s) in window, all exit_code=0, none timed_out or recorded failed.`,
276
300
  };
277
301
  }
278
302
  return {
279
303
  id,
280
304
  method,
281
305
  status: 'fail',
282
- evidence: `attribution: time-window. ${gates.length} quality_gate event(s) in window; ${failing.length} with non-zero exit_code.`,
306
+ evidence: `attribution: time-window. ${gates.length} quality_gate event(s) in window; ${failing.length} failed (non-zero exit_code, timed_out or quality_gate.failed).`,
283
307
  };
284
308
  }
285
309
 
@@ -290,7 +314,7 @@ export function scoreVerificationEvidence(ctx) {
290
314
  * work → cannot-determine. score = completion_rate (informative).
291
315
  *
292
316
  * Exported ONLY so `tests/eval/rubric-parity.test.mjs` can drive it row-by-row
293
- * against the pre-registered table in `skills/eval/rubric-v2.md`. NOT a public
317
+ * against the pre-registered table in `skills/eval/rubric-v3.md`. NOT a public
294
318
  * contract: signature, arguments and return shape may change with the rubric.
295
319
  */
296
320
  export function scorePlanFidelity(ctx) {
@@ -334,12 +358,13 @@ export function scorePlanFidelity(ctx) {
334
358
 
335
359
  /**
336
360
  * gate-health: like verification-evidence but ONLY variant=='full-gate' events;
337
- * pass = the LAST full-gate in the clean window has exit_code==0, else fail.
361
+ * pass = the LAST full-gate in the clean window is not a failed gate event
362
+ * (`isFailedGateEvent`, rubric-v3), else fail.
338
363
  * 0 full-gate events → not-applicable when no waves ran (housekeeping), else
339
364
  * cannot-determine. Peer-contaminated window → cannot-determine.
340
365
  *
341
366
  * Exported ONLY so `tests/eval/rubric-parity.test.mjs` can drive it row-by-row
342
- * against the pre-registered table in `skills/eval/rubric-v2.md`. NOT a public
367
+ * against the pre-registered table in `skills/eval/rubric-v3.md`. NOT a public
343
368
  * contract: signature, arguments and return shape may change with the rubric.
344
369
  */
345
370
  export function scoreGateHealth(ctx) {
@@ -390,12 +415,15 @@ export function scoreGateHealth(ctx) {
390
415
  const last = fullGates.reduce((a, b) =>
391
416
  Date.parse(a.timestamp) >= Date.parse(b.timestamp) ? a : b,
392
417
  );
393
- const status = last.exit_code === 0 ? 'pass' : 'fail';
418
+ const lastFailed = isFailedGateEvent(last);
419
+ // A failed run that still exited 0 (killed, or recorded `.failed`) carries a
420
+ // marker, so the judge's `last_full_gate_exit=0` is not read as a green finish.
421
+ const flag = lastFailed && last.exit_code === 0 ? ', last failed-flag=true' : '';
394
422
  return {
395
423
  id,
396
424
  method,
397
- status,
398
- evidence: `attribution: time-window. ${fullGates.length} full-gate event(s) in window; last exit_code=${last.exit_code}.`,
425
+ status: lastFailed ? 'fail' : 'pass',
426
+ evidence: `attribution: time-window. ${fullGates.length} full-gate event(s) in window; last exit_code=${last.exit_code}${flag}.`,
399
427
  };
400
428
  }
401
429
 
@@ -420,7 +448,7 @@ export function scoreGateHealth(ctx) {
420
448
  * those only `spiral` is emitted at all, which the evidence discloses.
421
449
  *
422
450
  * Exported ONLY so `tests/eval/rubric-parity.test.mjs` can drive it row-by-row
423
- * against the pre-registered table in `skills/eval/rubric-v2.md`. NOT a public
451
+ * against the pre-registered table in `skills/eval/rubric-v3.md`. NOT a public
424
452
  * contract: signature, arguments and return shape may change with the rubric.
425
453
  */
426
454
  export function scoreProcessSafety(ctx) {
@@ -465,7 +493,7 @@ export function scoreProcessSafety(ctx) {
465
493
  * window — see `countAttributedEvents`.
466
494
  *
467
495
  * Exported ONLY so `tests/eval/rubric-parity.test.mjs` can drive it row-by-row
468
- * against the pre-registered table in `skills/eval/rubric-v2.md`. NOT a public
496
+ * against the pre-registered table in `skills/eval/rubric-v3.md`. NOT a public
469
497
  * contract: signature, arguments and return shape may change with the rubric.
470
498
  */
471
499
  export function scoreGuardFriction(ctx) {
@@ -509,7 +537,7 @@ export function scoreGuardFriction(ctx) {
509
537
  * Missing values are null ("don't fake perfect"), never guessed.
510
538
  *
511
539
  * Exported ONLY so `tests/eval/rubric-parity.test.mjs` can drive it row-by-row
512
- * against the pre-registered table in `skills/eval/rubric-v2.md`. NOT a public
540
+ * against the pre-registered table in `skills/eval/rubric-v3.md`. NOT a public
513
541
  * contract: signature, arguments and return shape may change with the rubric.
514
542
  */
515
543
  export function scoreEfficiencyKpis(kpis) {
@@ -528,7 +556,7 @@ export function scoreEfficiencyKpis(kpis) {
528
556
  * Dimension id → its scorer, in the canonical `RUBRIC_DIMENSION_IDS` order.
529
557
  *
530
558
  * Exists so the rubric parity test resolves a scorer from the dimension ANCHOR
531
- * it parsed out of `skills/eval/rubric-v2.md` instead of retyping six function
559
+ * it parsed out of `skills/eval/rubric-v3.md` instead of retyping six function
532
560
  * names — a hand-typed list under a census title is a green tick with no cover
533
561
  * (`.claude/rules/measurement-discipline.md`). NOT a public contract; the
534
562
  * callable values are the same test-only exports as above.
@@ -579,7 +607,7 @@ const EVENT_DERIVED_DIMENSIONS = new Set([
579
607
  * turns a partial result into a clean verdict.
580
608
  *
581
609
  * NOT A RUBRIC CHANGE — and deliberately so. This appends to `evidence` and
582
- * touches no `status`: no pre-registered formula in `skills/eval/rubric-v2.md`
610
+ * touches no `status`: no pre-registered formula in `skills/eval/rubric-v3.md`
583
611
  * reads it, and no dimension can flip verdict because of it. Measured
584
612
  * 2026-09-20 over the 40 records in `.orchestrator/metrics/eval.jsonl`: the
585
613
  * live ledger reads `complete: true`, `gaps: []`, `malformed_lines: 0`
@@ -652,8 +680,11 @@ export const EVIDENCE_PATTERNS = Object.freeze({
652
680
  gateRunsTotal: /(\d+) quality_gate event\(s\) in window/,
653
681
  /** ≥1-gate, all-green branch. */
654
682
  gateAllGreen: /quality_gate event\(s\) in window, all exit_code=0/,
655
- /** ≥1-gate, some-red branch ("…; N with non-zero exit_code."). */
656
- gateRunsFailed: /(\d+) with non-zero exit_code/,
683
+ /**
684
+ * ≥1-gate, some-red branch — rubric-v3 "…; N failed (non-zero exit_code, …)."
685
+ * and, for stored rubric-v2 records, "…; N with non-zero exit_code.".
686
+ */
687
+ gateRunsFailed: /(\d+) (?:failed \(non-zero exit_code|with non-zero exit_code)/,
657
688
  /** 0-gate, nothing changed → not-applicable branch. */
658
689
  noChangeToVerify: /0 quality_gate events in window and total_files_changed=0/,
659
690
  /** 0-gate, files changed → cannot-determine branch. */
@@ -673,6 +704,8 @@ export const EVIDENCE_PATTERNS = Object.freeze({
673
704
  /** ≥1-full-gate branch ("N full-gate event(s) in window; last exit_code=X."). */
674
705
  fullGateRuns: /(\d+) full-gate event\(s\) in window/,
675
706
  lastFullGateExit: /last exit_code=(-?\d+)/,
707
+ /** rubric-v3: the last full-gate failed although it exited 0 (killed / recorded `.failed`). */
708
+ lastFullGateFailedFlag: /last failed-flag=true/,
676
709
  /** 0-full-gate branches (not-applicable / cannot-determine). */
677
710
  fullGateZero: /0 full-gate events (?:in window|and no waves ran)/,
678
711
  }),
@@ -690,7 +723,7 @@ export const EVIDENCE_PATTERNS = Object.freeze({
690
723
  // `RecordFacts` field and no rubric-v2 decision rule names them, so a
691
724
  // pattern for them would be a reader with zero consumers (BV-001.1). They
692
725
  // were exactly that until 2026-09-19. Adding one back means adding the
693
- // fact to the pre-registered list in `skills/eval/rubric-v2.md` first.
726
+ // fact to the pre-registered list in `skills/eval/rubric-v3.md` first.
694
727
  /** attribution marker: session-id (preferred) vs the time-window fallback. */
695
728
  attributionSessionId: /attribution: session-id/,
696
729
  attributionTimeWindow: /attribution: time-window/,
@@ -854,7 +887,7 @@ function resolveModel(model, env, resolveModelFromEnv) {
854
887
  export const REQUIRED_EVENTS_WINDOW_DAYS = null;
855
888
 
856
889
  /**
857
- * Evaluate one completed session against the rubric-v2 dimensions.
890
+ * Evaluate one completed session against the rubric-v3 dimensions.
858
891
  *
859
892
  * @param {object} opts
860
893
  * @param {string} [opts.sessionId] — explicit session_id; default is the cascade.
@@ -899,7 +932,8 @@ export function evaluateSession(opts = {}) {
899
932
  }
900
933
 
901
934
  const sessionsPath = path.join(metricsDir, 'sessions.jsonl');
902
- const eventsPath = path.join(metricsDir, 'events.jsonl');
935
+ // #1514 point 1 — the repo's ONE ledger (main checkout for a linked worktree).
936
+ const eventsPath = path.join(resolveEventsLedgerRoot(metricsDir), 'events.jsonl');
903
937
  // #1209: sessions.jsonl is APPEND-ONLY (the same physical session can carry
904
938
  // more than one line — crash-recovery re-appends, #1068 stub/supersede
905
939
  // pairs), so a raw readJsonlFile() left resolveSession()/findPeerOverlap()
@@ -2,7 +2,7 @@
2
2
  * eval/judge.mjs — opt-in advisory LLM-judge overlay for the aiat-llm-eval
3
3
  * standard (Epic #803, S7 / issue #810).
4
4
  *
5
- * Overlays the ONE pre-registered judge dimension from `skills/eval/rubric-v2.md`
5
+ * Overlays the ONE pre-registered judge dimension from `skills/eval/rubric-v3.md`
6
6
  * § "Judge Dimensions" — `instruction-adherence` — onto a deterministic
7
7
  * session-eval record produced by `scripts/lib/eval/engine.mjs`.
8
8
  * Default OFF (`eval.judge: off` in Session Config); when disabled, zero code in
@@ -64,7 +64,7 @@ export const DEFAULT_BUDGET = Object.freeze({ input: 8000, output: 4000 });
64
64
  const CHARS_PER_TOKEN = 4;
65
65
 
66
66
  /**
67
- * The pre-registered judge dimension ids (rubric-v2.md § "Judge Dimensions").
67
+ * The pre-registered judge dimension ids (rubric-v3.md § "Judge Dimensions").
68
68
  * Fixed set — the judge may never invent a second dimension. `report-quality`
69
69
  * was retired in v2 (#1381; see the module docblock for the two measurements).
70
70
  */
@@ -82,11 +82,11 @@ export const JUDGE_DIMENSION_IDS = Object.freeze(['instruction-adherence']);
82
82
  * signal that is allowed to speak at all. Numerator AND denominator are quoted
83
83
  * here because the bare percentage disagreed with its own rubric copy: this
84
84
  * docblock read 5.1% (= 2/39) against the rubric's 5.0% for the same
85
- * measurement. Pre-registered in `skills/eval/rubric-v2.md`.
85
+ * measurement. Pre-registered in `skills/eval/rubric-v3.md`.
86
86
  */
87
87
  export const GUARD_BLOCKED_CONSPICUOUS_THRESHOLD = 20;
88
88
 
89
- /** The judge question text per dimension, pre-registered verbatim in rubric-v2.md. */
89
+ /** The judge question text per dimension, pre-registered verbatim in rubric-v3.md. */
90
90
  export const JUDGE_QUESTIONS = Object.freeze({
91
91
  'instruction-adherence':
92
92
  "Reading the session-eval record's dimension evidence, kpis, session_id and the pre-computed facts below, did the coordinator follow the operator's stated instructions and the repo's always-on rules (verification-before-completion, ask-via-tool, parallel-session safety, scope discipline) — or is a concrete deviation visible in the record?",
@@ -94,7 +94,7 @@ export const JUDGE_QUESTIONS = Object.freeze({
94
94
 
95
95
  /**
96
96
  * The ordered decision rules for `instruction-adherence`, pre-registered
97
- * verbatim in `skills/eval/rubric-v2.md` § "Judge Dimensions". Applied IN THIS
97
+ * verbatim in `skills/eval/rubric-v3.md` § "Judge Dimensions". Applied IN THIS
98
98
  * ORDER; the first rule that applies decides.
99
99
  *
100
100
  * They exist because the v1 wording was under-specified: it named "isolated
@@ -213,7 +213,7 @@ const LEGACY_V1_SPIRAL = /(?:^|[\s(])(\d+) spiral\b/;
213
213
  /**
214
214
  * @typedef {Object} RecordFacts
215
215
  * @property {number|null} gate_runs_total quality_gate events attributed to the session
216
- * @property {number|null} gate_runs_failed of those, how many exited non-zero
216
+ * @property {number|null} gate_runs_failed of those, how many failed (rubric-v3: non-zero exit, timed_out, or `.failed`)
217
217
  * @property {number|null} full_gate_runs full-gate events attributed to the session
218
218
  * @property {number|null} last_full_gate_exit exit code of the LAST full gate
219
219
  * @property {boolean|null} red_runs_then_green_finish red intermediate runs, green finish
@@ -356,6 +356,10 @@ export function computeRecordFacts(dimensions) {
356
356
  const ghEv = ev('gate-health');
357
357
  let full_gate_runs = null;
358
358
  let last_full_gate_exit = null;
359
+ // rubric-v3 (#1487): the last full-gate can be red while it exited 0 (killed
360
+ // reporting run). Not a fact of its own — it only keeps the exit code from
361
+ // being read as a green finish below.
362
+ let last_full_gate_flagged_failed = false;
359
363
  if (ghEv !== null) {
360
364
  if (GH.windowContaminated.test(ghEv)) {
361
365
  // Same construction as above — unattributable, no number to read.
@@ -365,6 +369,7 @@ export function computeRecordFacts(dimensions) {
365
369
  } else if (GH.fullGateRuns.test(ghEv)) {
366
370
  full_gate_runs = num(ghEv, GH.fullGateRuns);
367
371
  last_full_gate_exit = num(ghEv, GH.lastFullGateExit);
372
+ last_full_gate_flagged_failed = GH.lastFullGateFailedFlag.test(ghEv);
368
373
  if (last_full_gate_exit === null) {
369
374
  miss('last_full_gate_exit', 'gate-health', 'the ≥1-full-gate branch matched but carried no exit code');
370
375
  }
@@ -454,10 +459,11 @@ export function computeRecordFacts(dimensions) {
454
459
  const carryover = planCarryover ?? kpiCarryover;
455
460
 
456
461
  // --- derived + contradictions -------------------------------------------
462
+ const lastFullGateGreen = last_full_gate_exit === 0 && !last_full_gate_flagged_failed;
457
463
  const red_runs_then_green_finish =
458
464
  gate_runs_failed === null || last_full_gate_exit === null
459
465
  ? null
460
- : gate_runs_failed > 0 && last_full_gate_exit === 0;
466
+ : gate_runs_failed > 0 && lastFullGateGreen;
461
467
 
462
468
  let window_contaminated = null;
463
469
  const contaminationSources = [
@@ -484,7 +490,7 @@ export function computeRecordFacts(dimensions) {
484
490
  if (st('gate-health') === 'pass' && last_full_gate_exit !== null && last_full_gate_exit !== 0) {
485
491
  contradictions.push(`gate-health status=pass but last_full_gate_exit=${last_full_gate_exit}`);
486
492
  }
487
- if (st('gate-health') === 'fail' && last_full_gate_exit === 0) {
493
+ if (st('gate-health') === 'fail' && lastFullGateGreen) {
488
494
  contradictions.push('gate-health status=fail but last_full_gate_exit=0');
489
495
  }
490
496
  if (planCarryover !== null && kpiCarryover !== null && planCarryover !== kpiCarryover) {
@@ -10,8 +10,13 @@
10
10
  * order) that is either status:'completed' or non-abandoned with completed
11
11
  * work. Abandoned records are ALWAYS skipped.
12
12
  *
13
- * 2. WHOSE events? — quality_gate events in events.jsonl carry NO session_id,
14
- * so they are attributed to a session by its wall-clock window
13
+ * 2. WHOSE events? — quality_gate events in events.jsonl do not RELIABLY
14
+ * carry a session_id (measured 2026-10-05, this repo's events.jsonl:
15
+ * 17 of 82 `orchestrator.quality_gate.*` records without one, 65 with —
16
+ * `grep '"orchestrator.quality_gate' <file> | grep -c '"session_id"'`;
17
+ * the id-less ones are interleaved through the file, not just the oldest),
18
+ * so this module attributes them to a
19
+ * session by its wall-clock window
15
20
  * [started_at, completed_at] (Decision #1: attribution = time-window).
16
21
  * findPeerOverlap() detects when that window overlaps ANY other session's
17
22
  * window — a contaminated window means gate-attribution is unsafe and the
@@ -88,6 +88,7 @@
88
88
  import { closeSync, existsSync, openSync, readSync, readdirSync } from 'node:fs';
89
89
  import path from 'node:path';
90
90
 
91
+ import { eventsFilePath } from './events.mjs';
91
92
  import { ARCHIVE_DIR_NAME, ARCHIVE_NAME_RE, LEGACY_RING_MAX, ROTATION_EVENT } from './events-schema.mjs';
92
93
 
93
94
  const DAY_MS = 24 * 60 * 60 * 1000;
@@ -268,9 +269,11 @@ async function maxRequiredWindow(readers) {
268
269
  */
269
270
  export async function checkEventsRetention({ repoRoot, config, now = Date.now() } = {}) {
270
271
  if (!repoRoot || typeof repoRoot !== 'string') return null;
271
- const metricsDir = path.join(repoRoot, '.orchestrator', 'metrics');
272
+ // #1514 point 1 — the repo's ONE ledger; its archive dir sits beside the SAME
273
+ // resolved file rotation writes (`events-rotation.mjs`: `<dirname(log)>/_archive`).
274
+ const activePath = eventsFilePath(repoRoot);
275
+ const metricsDir = path.dirname(activePath);
272
276
  const archiveDir = path.join(metricsDir, ARCHIVE_DIR_NAME);
273
- const activePath = path.join(metricsDir, 'events.jsonl');
274
277
  const nowMs = typeof now === 'number' && Number.isFinite(now) ? now : Date.now();
275
278
 
276
279
  let names;
@@ -52,7 +52,7 @@ import {
52
52
  } from 'node:fs';
53
53
  import { tmpdir } from 'node:os';
54
54
  import path from 'node:path';
55
- import { getProjectDir, SO_SHARED_DIR } from './platform.mjs';
55
+ import { getProjectDir, resolveEventsLedgerRoot, SO_SHARED_DIR } from './platform.mjs';
56
56
  import { readLock } from './session-lock.mjs';
57
57
  import { resolveStateMdPath } from './state-md/frontmatter-mutators.mjs';
58
58
  import {
@@ -139,8 +139,12 @@ function sandboxedDefault(defaultPath) {
139
139
  /**
140
140
  * Returns the absolute path to `.orchestrator/metrics/events.jsonl` under `repoRoot`.
141
141
  *
142
- * `repoRoot` defaults to the module-level `SO_PROJECT_DIR` constant, so the
143
- * zero-arg call is unchanged for every existing caller (#941). Pass an explicit
142
+ * `repoRoot` defaults to the ONE events-ledger root, `resolveEventsLedgerRoot()`
143
+ * (`platform.mjs`, #1514 point 1): the launch dir from env, else the main
144
+ * checkout when the cwd is a linked git worktree, else `getProjectDir()` — the
145
+ * pre-#1514 default, unchanged everywhere a worktree is not involved (#941). An
146
+ * explicit absolute `repoRoot` inside a linked worktree is mapped to its main
147
+ * checkout the same way. Pass an explicit
144
148
  * `repoRoot` when the destination must be pinned to a tree other than the
145
149
  * CWD/env-resolved project — e.g. a unit test running the gate against a tmp
146
150
  * repo, which must NOT append synthetic records to the real fleet telemetry.
@@ -154,8 +158,18 @@ function sandboxedDefault(defaultPath) {
154
158
  * @returns {string}
155
159
  */
156
160
  export function eventsFilePath(repoRoot) {
157
- if (repoRoot !== undefined) return path.join(repoRoot, SO_SHARED_DIR, 'metrics', 'events.jsonl');
158
- return sandboxedDefault(path.join(getProjectDir(), SO_SHARED_DIR, 'metrics', 'events.jsonl'));
161
+ if (repoRoot !== undefined) {
162
+ // #1514 point 1 — an absolute root inside a LINKED git worktree maps to the
163
+ // same spot in its main checkout: one ledger per repo, never one per
164
+ // worktree. Identity for every other root (a plain repo, a tmp fixture, a
165
+ // non-git dir), and a relative/empty value is joined exactly as before —
166
+ // `emitEvent()` refuses it upstream.
167
+ const root = typeof repoRoot === 'string' && path.isAbsolute(repoRoot)
168
+ ? resolveEventsLedgerRoot(repoRoot)
169
+ : repoRoot;
170
+ return path.join(root, SO_SHARED_DIR, 'metrics', 'events.jsonl');
171
+ }
172
+ return sandboxedDefault(path.join(resolveEventsLedgerRoot(), SO_SHARED_DIR, 'metrics', 'events.jsonl'));
159
173
  }
160
174
 
161
175
  /**
@@ -578,6 +592,60 @@ function discoverArchives(logPath) {
578
592
  return { archives, legacy: present.map((i) => `${logPath}.${i}`), ringHoles, unindexed };
579
593
  }
580
594
 
595
+ /**
596
+ * The one tombstone-to-archive match (#1505 5c) — shared by
597
+ * {@link readEventsWithRotations} rule 1, {@link scanEventsBackwards}, and
598
+ * `planWithGaps` in `scripts/backfill-abandoned-sessions.mjs`, which used to
599
+ * carry three hand-kept copies of it.
600
+ *
601
+ * #1411 — the tombstone resolves against THIS ledger's own `_archive/`, by
602
+ * BASENAME, never against the absolute value it stores. The writer keeps
603
+ * `archived_as` absolute (`events-rotation.mjs` joins the repo root) and that
604
+ * value stays in the record as PROVENANCE. It is not a lookup key: a moved
605
+ * checkout, a clone, or a sibling git worktree — routine here — makes every
606
+ * tombstone name a path that does not exist in THIS tree, so an exact
607
+ * comparison reported a phantom `missing-archive` for an archive sitting right
608
+ * beside the active file. Basename, not `realpath`: realpath cannot resolve a
609
+ * path that no longer exists, which IS the failure mode.
610
+ *
611
+ * ORDER (why the absolute value has no second chance): the sibling answer is
612
+ * consulted first, and an absolute hit would only be trustworthy while it
613
+ * pointed INSIDE this ledger's own archive dir — but any such path resolves to
614
+ * exactly the sibling path already tested, so once the sibling misses, an
615
+ * absolute hit can ONLY be a still-present OLD checkout. Honouring it would
616
+ * validate THIS ledger against a FOREIGN repo's archive: a silent false
617
+ * negative, worse than the phantom gap.
618
+ *
619
+ * #1423 — THREE outcomes, not two: read (`null`), present-but-unread
620
+ * (`unindexed-archive`), gone (`missing-archive`). The middle one used to be
621
+ * folded into the first by `existsSync`, so a tombstone whose archive had been
622
+ * RENAMED out of `ARCHIVE_NAME_RE` reported `complete: true` while its records
623
+ * were absent.
624
+ *
625
+ * @param {unknown} record a parsed ledger line
626
+ * @param {string} ownArchiveDir this ledger's `_archive/`, built exactly as
627
+ * `discoverArchives` builds it so a read archive matches `readSet` by string
628
+ * @param {Set<string>} readSet the paths this read actually took as sources
629
+ * @returns {{kind: 'unindexed-archive'|'missing-archive', path: string,
630
+ * archived_as: string, first_ts: string|null, last_ts: string|null}|null}
631
+ * `null` when `record` is not a rotation tombstone, names no archive, or its
632
+ * archive was read.
633
+ */
634
+ export function tombstoneGap(record, ownArchiveDir, readSet) {
635
+ if (record?.event !== ROTATION_EVENT) return null;
636
+ const target = record.archived_as;
637
+ if (typeof target !== 'string' || target.length === 0) return null;
638
+ const sibling = path.join(ownArchiveDir, path.basename(target));
639
+ if (readSet.has(sibling)) return null;
640
+ return {
641
+ kind: existsSync(sibling) ? 'unindexed-archive' : 'missing-archive',
642
+ path: sibling,
643
+ archived_as: target,
644
+ first_ts: record.first_ts ?? null,
645
+ last_ts: record.last_ts ?? null,
646
+ };
647
+ }
648
+
581
649
  /**
582
650
  * Read the events ledger ACROSS rotation boundaries — active file plus every
583
651
  * archive still on disk — in time order, reporting what is missing instead of
@@ -660,44 +728,14 @@ export function readEventsWithRotations(repoRoot, opts = {}) {
660
728
  // `onDisk` still match by string.
661
729
  const ownArchiveDir = path.join(path.dirname(activePath), ARCHIVE_DIR_NAME);
662
730
 
663
- // Rule 1 — every rotation tombstone must still point at a file.
731
+ // Rule 1 — every rotation tombstone must still point at a file (resolution
732
+ // and the three outcomes: {@link tombstoneGap}).
664
733
  for (const source of sources) {
665
734
  for (const record of source.records) {
666
- if (record?.event !== ROTATION_EVENT) continue;
667
- const target = record.archived_as;
668
- if (typeof target !== 'string' || target.length === 0) continue;
669
- // #1411 — resolve the tombstone against THIS ledger's own `_archive/`,
670
- // by BASENAME, and never against the absolute value it stores.
671
- //
672
- // The writer keeps `archived_as` absolute (`events-rotation.mjs` joins
673
- // the repo root) and that value stays in the record as PROVENANCE. It is
674
- // not a lookup key: a moved checkout, a clone, or a sibling git worktree
675
- // — routine here — makes every tombstone name a path that does not exist
676
- // in THIS tree, so the old exact comparison reported a phantom
677
- // `missing-archive` for an archive sitting right beside the active file.
678
- // Basename, not `realpath`: realpath cannot resolve a path that no longer
679
- // exists, which IS the failure mode.
680
- //
681
- // ORDER (why the absolute value has no second chance): the sibling answer
682
- // is consulted first, and an absolute hit would only be trustworthy while
683
- // it pointed INSIDE this ledger's own archive dir — but any such path
684
- // resolves to exactly the sibling path already tested, so once the
685
- // sibling misses, an absolute hit can ONLY be a still-present OLD
686
- // checkout. Honouring it would validate THIS ledger against a FOREIGN
687
- // repo's archive: a silent false negative, worse than the phantom gap.
688
- const sibling = path.join(ownArchiveDir, path.basename(target));
689
- if (onDisk.has(sibling)) continue;
735
+ const gap = tombstoneGap(record, ownArchiveDir, onDisk);
736
+ if (gap === null) continue;
690
737
  gaps.push({
691
- // #1423 — THREE outcomes, not two: read (above), present-but-unread
692
- // (here), gone (below). The middle one used to be silently folded into
693
- // the first by `existsSync`, so a tombstone whose archive had been
694
- // RENAMED out of `ARCHIVE_NAME_RE` reported `complete: true` while its
695
- // records were absent from `events`.
696
- kind: existsSync(sibling) ? 'unindexed-archive' : 'missing-archive',
697
- path: sibling,
698
- archived_as: target,
699
- first_ts: record.first_ts ?? null,
700
- last_ts: record.last_ts ?? null,
738
+ ...gap,
701
739
  lines: record.lines ?? null,
702
740
  size_before: record.size_before ?? null,
703
741
  reported_by: source.path,
@@ -866,8 +904,7 @@ export function scanEventsBackwards(opts = {}) {
866
904
  * LOW-2): `maybeRotate` renames, prunes, THEN appends the tombstone, from an
867
905
  * async SessionStart hook, so a concurrent append can land first and push it
868
906
  * 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).
907
+ * and would otherwise hide the cut. Matched by {@link tombstoneGap}.
871
908
  */
872
909
  const noteCut = (line, source) => {
873
910
  if (!line.includes(ROTATION_EVENT)) return;
@@ -877,18 +914,8 @@ export function scanEventsBackwards(opts = {}) {
877
914
  } catch {
878
915
  return; // counted by `consume` like every other malformed line
879
916
  }
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
- });
917
+ const gap = tombstoneGap(record, ownArchiveDir, listed);
918
+ if (gap !== null) gaps.push({ ...gap, reported_by: source.path });
892
919
  };
893
920
 
894
921
  /** @returns {boolean} true ⇒ the caller accepted a record; stop everything. */