session-orchestrator 5.7.0 → 5.9.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 (202) 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 +68 -0
  42. package/README.md +13 -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 +45 -4
  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/ci-status-banner.mjs +38 -5
  94. package/scripts/lib/config/drift-check.mjs +1 -1
  95. package/scripts/lib/config/frontend-slop-hook.mjs +6 -0
  96. package/scripts/lib/config.mjs +5 -1
  97. package/scripts/lib/convergence-monitor.mjs +5 -3
  98. package/scripts/lib/ecosystem-health.mjs +5 -3
  99. package/scripts/lib/eval/engine.mjs +61 -27
  100. package/scripts/lib/eval/judge.mjs +14 -8
  101. package/scripts/lib/eval/session-resolve.mjs +7 -2
  102. package/scripts/lib/events-retention-banner.mjs +5 -2
  103. package/scripts/lib/events-schema.mjs +8 -0
  104. package/scripts/lib/events.mjs +19 -5
  105. package/scripts/lib/fleet-protocol.mjs +98 -1
  106. package/scripts/lib/instruction-budget-guard.mjs +25 -18
  107. package/scripts/lib/io.mjs +172 -35
  108. package/scripts/lib/learnings/candidates.mjs +7 -28
  109. package/scripts/lib/learnings/io.mjs +7 -2
  110. package/scripts/lib/learnings/judgment.mjs +5 -8
  111. package/scripts/lib/learnings/select.mjs +3 -3
  112. package/scripts/lib/maintenance-due-banner.mjs +2 -2
  113. package/scripts/lib/memory-proposals/sink.mjs +5 -6
  114. package/scripts/lib/memory-proposals/store.mjs +3 -5
  115. package/scripts/lib/mirror-issues-banner.mjs +11 -1
  116. package/scripts/lib/native-command.mjs +48 -0
  117. package/scripts/lib/orphan-reaper/scan.mjs +29 -3
  118. package/scripts/lib/peer-cards/writer.mjs +6 -18
  119. package/scripts/lib/platform.mjs +113 -4
  120. package/scripts/lib/plugin-package-stage.mjs +169 -0
  121. package/scripts/lib/plugin-surface.mjs +121 -0
  122. package/scripts/lib/quality-gate-wave.mjs +40 -0
  123. package/scripts/lib/quality-gate.mjs +36 -30
  124. package/scripts/lib/reconcile/idempotency.mjs +9 -10
  125. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +48 -11
  126. package/scripts/lib/reconcile/writer.mjs +11 -17
  127. package/scripts/lib/rule-loader.mjs +32 -6
  128. package/scripts/lib/scope-echo.mjs +12 -2
  129. package/scripts/lib/scope-gate.mjs +2 -1
  130. package/scripts/lib/session-close-backfill.mjs +3 -4
  131. package/scripts/lib/session-id.mjs +2 -1
  132. package/scripts/lib/session-record-repair.mjs +28 -21
  133. package/scripts/lib/session-schema/constants.mjs +20 -5
  134. package/scripts/lib/session-schema/validator.mjs +12 -0
  135. package/scripts/lib/session-start-probes.mjs +22 -7
  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 +3 -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 +207 -26
  156. package/scripts/lib/vault-mirror/utils.mjs +16 -1
  157. package/scripts/lib/vault-sync-baseline.mjs +9 -8
  158. package/scripts/lib/wave-executor/remote-dispatch.mjs +27 -9
  159. package/scripts/lib/wave-resource-gate.mjs +2 -1
  160. package/scripts/lib/wave-transcript-tail.mjs +8 -4
  161. package/scripts/lib/worktree/meta.mjs +7 -3
  162. package/scripts/mcp-server.mjs +158 -0
  163. package/scripts/migrate-sessions-jsonl.mjs +7 -5
  164. package/scripts/migrate-vault-paths.mjs +7 -4
  165. package/scripts/print-learnings-index.mjs +2 -1
  166. package/scripts/release.mjs +4 -1
  167. package/scripts/relocate-vault-corpus.mjs +5 -3
  168. package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
  169. package/scripts/run-quality-gate.mjs +9 -39
  170. package/scripts/self-update.mjs +179 -39
  171. package/scripts/site-numbers.mjs +8 -3
  172. package/scripts/sweep-expired-learnings.mjs +6 -4
  173. package/scripts/sync-vault-schema.mjs +3 -7
  174. package/scripts/windows-setup.mjs +45 -0
  175. package/skills/_shared/fleet-protocol.md +10 -0
  176. package/skills/architecture/SKILL.md +1 -0
  177. package/skills/bootstrap/_shared-template.md +5 -2
  178. package/skills/claude-md-drift-check/SKILL.md +4 -1
  179. package/skills/claude-md-drift-check/checker.mjs +83 -10
  180. package/skills/discovery/probes/ssot-code-diff.mjs +3 -3
  181. package/skills/eval/SKILL.md +12 -13
  182. package/skills/eval/rubric-v3.md +521 -0
  183. package/skills/frontmatter-guard/SKILL.md +1 -0
  184. package/skills/gitlab-portfolio/SKILL.md +1 -0
  185. package/skills/hook-development/SKILL.md +1 -0
  186. package/skills/mcp-builder/SKILL.md +1 -0
  187. package/skills/remote-offload/SKILL.md +1 -1
  188. package/skills/repo-audit/SKILL.md +2 -2
  189. package/skills/session-end/SKILL.md +32 -1
  190. package/skills/session-end/metrics-collection.md +15 -9
  191. package/skills/session-end/vault-operations.md +12 -2
  192. package/skills/session-start/SKILL.md +2 -2
  193. package/skills/session-start/phase-7-1-premise-check.md +2 -1
  194. package/skills/session-start/references/operations-contract.md +4 -1
  195. package/skills/tmux-layout/SKILL.md +1 -0
  196. package/skills/vault-mirror/SKILL.md +1 -0
  197. package/skills/vault-sync/SKILL.md +6 -6
  198. package/skills/vault-sync/validator.sh +29 -10
  199. package/skills/wave-executor/references/wave-loop-dispatch.md +1 -1
  200. package/skills/wave-executor/references/wave-loop-review.md +13 -7
  201. package/skills/wave-executor/references/wave-loop-scope-manifest.md +4 -4
  202. package/skills/write-executable-plan/SKILL.md +1 -0
@@ -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;
@@ -239,5 +239,13 @@ export function validateEventRecord(record) {
239
239
  );
240
240
  }
241
241
 
242
+ if (
243
+ ['orchestrator.quality_gate.passed', 'orchestrator.quality_gate.failed'].includes(record.event) &&
244
+ Object.hasOwn(record, 'wave_number') &&
245
+ (!Number.isSafeInteger(record.wave_number) || record.wave_number <= 0)
246
+ ) {
247
+ errors.push('quality_gate wave_number must be a positive safe integer when present');
248
+ }
249
+
242
250
  return { valid: errors.length === 0, errors };
243
251
  }
@@ -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
  /**
@@ -21,12 +21,18 @@
21
21
  * expired lease is `none`, a lease that cannot be read or validated is
22
22
  * `unreadable`, and neither is ever `active`.
23
23
  *
24
+ * An auflagen file is keyed by the semantic session id, which collides across
25
+ * repos (`main-<date>-session-N`), so it is only accepted when its mandatory
26
+ * `repo` field resolves to the reader's own git common dir ({@link readAuflagen},
27
+ * #1520 / navigator#190).
28
+ *
24
29
  * Session ids become file names, so {@link isSafeSessionId} is the security
25
30
  * boundary between check-in input and the filesystem: every path builder that
26
31
  * takes a session id refuses an unsafe one with a TypeError.
27
32
  */
28
33
 
29
- import { promises as fs } from 'node:fs';
34
+ import { execFileSync } from 'node:child_process';
35
+ import fsSync, { promises as fs } from 'node:fs';
30
36
  import os from 'node:os';
31
37
  import path from 'node:path';
32
38
 
@@ -286,3 +292,94 @@ export async function readNavigatorLease({ now = Date.now(), env, home } = {}) {
286
292
  if (Date.parse(lease.laeuft_ab) <= now) return { state: 'none' };
287
293
  return { state: 'active', lease };
288
294
  }
295
+
296
+ /** Timeout for the `git rev-parse` calls in {@link readAuflagen}. */
297
+ const GIT_TIMEOUT_MS = 5_000;
298
+
299
+ /**
300
+ * Absolute, realpath'd git common dir of `dir` — the same value for a main tree,
301
+ * any of its linked worktrees, and the `.git` directory itself — or null when
302
+ * `dir` is not inside a git repository (or git is unavailable).
303
+ * @param {string} dir
304
+ * @returns {string|null}
305
+ */
306
+ function gitCommonDir(dir) {
307
+ try {
308
+ const out = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
309
+ env: gitEnv(),
310
+ encoding: 'utf8',
311
+ stdio: ['ignore', 'pipe', 'ignore'],
312
+ timeout: GIT_TIMEOUT_MS,
313
+ }).trim();
314
+ if (!out) return null;
315
+ return realpathSyncSafe(out);
316
+ } catch {
317
+ return null;
318
+ }
319
+ }
320
+
321
+ /**
322
+ * The only environment variables forwarded to `git`. An inherited `GIT_DIR`
323
+ * outranks `-C`, so both sides of the repo comparison would resolve to the
324
+ * same directory and a foreign repo's Auflagen would be ACCEPTED. Same
325
+ * allowlist as `git-config-drift.mjs` / `check-banner-parity.mjs`.
326
+ */
327
+ const GIT_ENV_ALLOWLIST = Object.freeze(['PATH', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', 'TZ']);
328
+
329
+ function gitEnv() {
330
+ /** @type {Record<string, string>} */
331
+ const env = {};
332
+ for (const k of GIT_ENV_ALLOWLIST) if (process.env[k] !== undefined) env[k] = /** @type {string} */ (process.env[k]);
333
+ return env;
334
+ }
335
+
336
+ /** @param {string} p */
337
+ function realpathSyncSafe(p) {
338
+ try {
339
+ return fsSync.realpathSync(p);
340
+ } catch {
341
+ return path.resolve(p);
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Read this session's conditions file and accept it only when it belongs to the
347
+ * reader's repository. Never throws; fail-closed.
348
+ *
349
+ * The file's mandatory `repo` field (an absolute path to the target repo's
350
+ * toplevel or its git common dir, written by the navigator) and `repoRoot` are
351
+ * both resolved to their git common dir, so a session in a linked worktree
352
+ * matches a file naming the main tree. Every `rejected` result means: behave as
353
+ * if no file existed (Standard-Auflagen apply).
354
+ *
355
+ * @param {string} sessionId
356
+ * @param {{ repoRoot?: string, env?: Record<string, string|undefined>, home?: string }} [opts]
357
+ * @returns {Promise<
358
+ * | { state: 'absent' }
359
+ * | { state: 'accepted', auflagen: Record<string, unknown> }
360
+ * | { state: 'rejected', reason: 'unsafe-session-id'|'unreadable'|'invalid-json'|'repo-missing'|'repo-unresolved'|'repo-mismatch' }
361
+ * >}
362
+ */
363
+ export async function readAuflagen(sessionId, { repoRoot = process.cwd(), env, home } = {}) {
364
+ if (!isSafeSessionId(sessionId)) return { state: 'rejected', reason: 'unsafe-session-id' };
365
+ let raw;
366
+ try {
367
+ raw = await fs.readFile(auflagenPath(sessionId, { env, home }), 'utf8');
368
+ } catch (err) {
369
+ if (err && /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return { state: 'absent' };
370
+ return { state: 'rejected', reason: 'unreadable' };
371
+ }
372
+ let auflagen;
373
+ try {
374
+ auflagen = JSON.parse(raw);
375
+ } catch {
376
+ return { state: 'rejected', reason: 'invalid-json' };
377
+ }
378
+ if (!isPlainObject(auflagen)) return { state: 'rejected', reason: 'invalid-json' };
379
+ const fileRepo = auflagen.repo;
380
+ if (typeof fileRepo !== 'string' || !path.isAbsolute(fileRepo)) return { state: 'rejected', reason: 'repo-missing' };
381
+ const own = gitCommonDir(repoRoot);
382
+ if (!own) return { state: 'rejected', reason: 'repo-unresolved' };
383
+ if (gitCommonDir(fileRepo) !== own) return { state: 'rejected', reason: 'repo-mismatch' };
384
+ return { state: 'accepted', auflagen };
385
+ }
@@ -88,11 +88,13 @@
88
88
 
89
89
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
90
90
  import { basename, join } from 'node:path';
91
- import { scanEventsBackwards } from './events.mjs';
91
+ import { eventsFilePath, scanEventsBackwards } from './events.mjs';
92
92
  import {
93
93
  FRONTMATTER_NOT_AT_TOP,
94
94
  loadApplicableRules,
95
+ isRuleExpired,
95
96
  parseGlobsFrontmatter,
97
+ ruleExpiryInstantMs,
96
98
  } from './rule-loader.mjs';
97
99
 
98
100
  /** Default directive ceiling (operator-chosen growth ratchet just above the ~457 baseline). */
@@ -846,11 +848,11 @@ const MS_PER_DAY = 86_400_000;
846
848
  * declares no expiry or an unparseable one.
847
849
  *
848
850
  * Negative ⇒ already expired. The comparison basis is deliberately the SAME one
849
- * `rule-loader.mjs` `applyGates` uses — `Date.parse(expires-at) < now`, i.e. a
850
- * bare `YYYY-MM-DD` is UTC midnight — so "expired" here means exactly "the
851
- * loader already filters it out of every wave's rule set", never a second
852
- * definition of the same word. `Math.floor` therefore returns `-1` for a rule
853
- * that expired at midnight today: past, but by less than a day.
851
+ * `rule-loader.mjs` `applyGates` uses — `ruleExpiryInstantMs`, where a bare
852
+ * `YYYY-MM-DD` is INCLUSIVE and expires at the FOLLOWING UTC midnight (#1521) —
853
+ * so "expired" here means exactly "the loader already filters it out of every
854
+ * wave's rule set", never a second definition of the same word. `0` therefore
855
+ * means "last day: still loads today", `-1` "dropped at the last midnight".
854
856
  *
855
857
  * An unparseable date returns `null` (fail-OPEN, matching the loader, which
856
858
  * warns and ignores the expiry rather than excluding the file) — the malformed
@@ -863,17 +865,18 @@ export function daysUntilGeneratedRuleExpiry(opts = {}) {
863
865
  const meta = opts.meta;
864
866
  if (!meta || typeof meta !== 'object') return null;
865
867
  if (!Object.prototype.hasOwnProperty.call(meta, 'expires-at')) return null;
866
- const expiresAt = Date.parse(String(meta['expires-at']));
867
- if (!Number.isFinite(expiresAt)) return null;
868
+ const expiresAt = ruleExpiryInstantMs(meta['expires-at']);
869
+ if (expiresAt === null) return null;
868
870
 
869
- const raw = opts.now;
870
- let now;
871
- if (raw instanceof Date) now = raw.getTime();
872
- else if (typeof raw === 'number' && Number.isFinite(raw)) now = raw;
873
- else if (typeof raw === 'string' && Number.isFinite(Date.parse(raw))) now = Date.parse(raw);
874
- else now = Date.now();
871
+ return Math.floor((expiresAt - nowMs(opts.now)) / MS_PER_DAY);
872
+ }
875
873
 
876
- return Math.floor((expiresAt - now) / MS_PER_DAY);
874
+ /** `now` as epoch ms: Date, number or parseable string; anything else → the process clock. */
875
+ function nowMs(raw) {
876
+ if (raw instanceof Date) return raw.getTime();
877
+ if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
878
+ if (typeof raw === 'string' && Number.isFinite(Date.parse(raw))) return Date.parse(raw);
879
+ return Date.now();
877
880
  }
878
881
 
879
882
  /**
@@ -888,8 +891,12 @@ export function daysUntilGeneratedRuleExpiry(opts = {}) {
888
891
  * @returns {boolean}
889
892
  */
890
893
  export function isExpiredGeneratedRule(opts = {}) {
891
- const days = daysUntilGeneratedRuleExpiry(opts);
892
- return days !== null && days < 0;
894
+ const meta = opts.meta;
895
+ if (!meta || typeof meta !== 'object') return false;
896
+ if (!Object.prototype.hasOwnProperty.call(meta, 'expires-at')) return false;
897
+ // The loader's own predicate, not `days < 0`: flooring whole days would call
898
+ // the rule live for the first millisecond after the boundary (#1521).
899
+ return isRuleExpired(meta['expires-at'], nowMs(opts.now));
893
900
  }
894
901
 
895
902
  /**
@@ -1335,7 +1342,7 @@ const RECONCILE_SCAN_BUDGET_MS = 150;
1335
1342
  */
1336
1343
  function readLastReconcileCapped(repoRoot) {
1337
1344
  if (typeof repoRoot !== 'string' || repoRoot === '') return null;
1338
- const filePath = join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
1345
+ const filePath = eventsFilePath(repoRoot); // #1514 point 1: the repo's one ledger
1339
1346
  let capped = null;
1340
1347
  let scan;
1341
1348
  try {