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
@@ -57,9 +57,10 @@
57
57
  * (absence reads as `'written'`, the only outcome that existed then).
58
58
  */
59
59
 
60
- import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
61
- import { dirname, isAbsolute, join } from 'node:path';
62
- import { createHash, randomBytes } from 'node:crypto';
60
+ import { readFileSync } from 'node:fs';
61
+ import { isAbsolute, join } from 'node:path';
62
+ import { createHash } from 'node:crypto';
63
+ import { atomicWriteWithBackup } from '../io.mjs';
63
64
 
64
65
  /** Default repo-relative location of the reconcile-candidate work-queue. */
65
66
  export const DEFAULT_STORE_PATH = '.orchestrator/runtime/reconcile-candidates.jsonl';
@@ -159,18 +160,16 @@ function readStore(absPath) {
159
160
  * @returns {{ ok: true, lines: number } | { ok: false, reason: 'fs-error', error: string }}
160
161
  */
161
162
  function writeStore(absPath, records) {
163
+ let content;
162
164
  try {
163
- const dir = dirname(absPath);
164
- mkdirSync(dir, { recursive: true });
165
165
  const body = records.map((r) => JSON.stringify(r)).join('\n');
166
- const content = records.length > 0 ? body + '\n' : '';
167
- const tmpFile = join(dir, `.reconcile-candidates.${randomBytes(6).toString('hex')}.tmp`);
168
- writeFileSync(tmpFile, content, { encoding: 'utf8' });
169
- renameSync(tmpFile, absPath);
170
- return { ok: true, lines: records.length };
166
+ content = records.length > 0 ? body + '\n' : '';
171
167
  } catch (err) {
172
168
  return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
173
169
  }
170
+ // mkdir -p + tmp + rename; its failure envelope is this function's own.
171
+ const res = atomicWriteWithBackup(absPath, content, { tmpPrefix: '.reconcile-candidates.tmp' });
172
+ return res.ok ? { ok: true, lines: records.length } : res;
174
173
  }
175
174
 
176
175
  /**
@@ -146,6 +146,7 @@ import { join, resolve, sep } from 'node:path';
146
146
  import { atomicWriteWithBackup } from '../io.mjs';
147
147
  import { readLearnings } from '../learnings/io.mjs';
148
148
  import { listMachineGeneratedRules } from '../instruction-budget-guard.mjs';
149
+ import { parseGlobsFrontmatter, ruleExpiryInstantMs } from '../rule-loader.mjs';
149
150
  import { markCandidateProcessed } from './idempotency.mjs';
150
151
 
151
152
  /** Repo-relative learnings store — the only source of a per-entry date. */
@@ -290,6 +291,18 @@ export function parseConsolidatedRule(content) {
290
291
  break;
291
292
  }
292
293
  }
294
+ // The VALUE is taken the loader's way (quote-stripped): a hand-edited
295
+ // `"2026-06-01"` would otherwise reach Date.parse with its quotes and be read
296
+ // as LOCAL midnight — up to 26 h before the loader stops injecting the rule.
297
+ // The line index above stays the rewrite anchor.
298
+ if (expiresAtLine >= 0) {
299
+ try {
300
+ const loaderValue = parseGlobsFrontmatter(String(content ?? '')).meta['expires-at'];
301
+ if (typeof loaderValue === 'string') expiresAt = loaderValue;
302
+ } catch {
303
+ // frontmatter the loader rejects — keep the raw value (fail-open downstream)
304
+ }
305
+ }
293
306
 
294
307
  const bodyStart = frontmatterEnd >= 0 ? frontmatterEnd + 1 : 0;
295
308
  for (let i = bodyStart; i < lines.length; i += 1) {
@@ -403,12 +416,23 @@ function dateOnly(iso) {
403
416
  * `unresolvedPairIds`), so a file whose ids all fail to resolve yields `null`
404
417
  * rather than a guess.
405
418
  *
419
+ * One exclusion: a markers-only pair whose date lies before `cutoffMs` is
420
+ * dropped. Its substance is already gone, so its elapsed date protects nothing
421
+ * — yet left in, it pins the raise target at or below a header that has already
422
+ * passed, and `rule-loader.mjs` excludes the whole file forever (live
423
+ * 2026-10-04: `guard-design.md` header 2026-10-04 pinned by markers-only
424
+ * `d86c9b5f-…`, next absorbed date 2026-10-07). The pair itself stays in the
425
+ * file and in `N` — it is still a dedupe marker. Expired SUBSTANTIVE pairs stay
426
+ * in the population: raising past one would revive prose not yet swept.
427
+ *
406
428
  * @param {ReturnType<typeof parseConsolidatedRule>} parsed
407
429
  * @param {Map<string, string>} expiryById
430
+ * @param {number} cutoffMs expiry cutoff (now minus grace)
408
431
  * @returns {string|null}
409
432
  */
410
- function earliestResolvableDate(parsed, expiryById) {
433
+ function earliestResolvableDate(parsed, expiryById, cutoffMs) {
411
434
  const dates = parsed.pairs
435
+ .filter((p) => !p.markersOnly || !(Date.parse(expiryById.get(p.id) ?? '') < cutoffMs))
412
436
  .map((p) => expiryById.get(p.id))
413
437
  .filter((v) => typeof v === 'string' && Number.isFinite(Date.parse(v)))
414
438
  .sort();
@@ -423,15 +447,20 @@ function earliestResolvableDate(parsed, expiryById) {
423
447
  * outlives its content is never lowered here — see the module header for why
424
448
  * the two directions are not symmetric.
425
449
  *
450
+ * Already-expired markers-only pairs do not hold the target down (see
451
+ * {@link earliestResolvableDate}); they stay in the file as dedupe markers and
452
+ * keep counting toward `N`.
453
+ *
426
454
  * @param {ReturnType<typeof parseConsolidatedRule>} parsed
427
455
  * @param {Map<string, string>} expiryById
456
+ * @param {number} cutoffMs expiry cutoff (now minus grace)
428
457
  * @returns {string|null}
429
458
  */
430
- function headerRaiseTarget(parsed, expiryById) {
459
+ function headerRaiseTarget(parsed, expiryById, cutoffMs) {
431
460
  if (!parsed.expiresAt || parsed.expiresAtLine < 0) return null;
432
461
  const headerMs = Date.parse(parsed.expiresAt);
433
462
  if (!Number.isFinite(headerMs)) return null;
434
- const earliest = earliestResolvableDate(parsed, expiryById);
463
+ const earliest = earliestResolvableDate(parsed, expiryById, cutoffMs);
435
464
  if (earliest === null) return null;
436
465
  const earliestMs = Date.parse(earliest);
437
466
  if (!Number.isFinite(earliestMs) || earliestMs <= headerMs) return null;
@@ -450,15 +479,20 @@ function headerRaiseTarget(parsed, expiryById) {
450
479
  *
451
480
  * Deliberately NOT the `newExpiresAt` population: that one excludes expired
452
481
  * pairs so a rewritten file is not instantly expired again. This is a
453
- * diagnostic about the file AS COMMITTED, so it excludes nothing.
482
+ * diagnostic about the file AS COMMITTED, so it excludes nothing — except an
483
+ * already-expired markers-only pair, the same exclusion the raise target makes.
484
+ * Without it, every file the raise (or an expired-entries rewrite) moved past
485
+ * such a pair would carry a permanent advisory naming the elapsed date, i.e.
486
+ * an instruction to lower the header back to where the loader drops the file.
454
487
  *
455
488
  * @param {ReturnType<typeof parseConsolidatedRule>} parsed
456
489
  * @param {Map<string, string>} expiryById
490
+ * @param {number} cutoffMs expiry cutoff (now minus grace)
457
491
  * @returns {string|undefined} undefined when there is nothing to report
458
492
  */
459
- function headerAdvisory(parsed, expiryById) {
493
+ function headerAdvisory(parsed, expiryById, cutoffMs) {
460
494
  if (!parsed.expiresAt) return undefined;
461
- const earliest = earliestResolvableDate(parsed, expiryById);
495
+ const earliest = earliestResolvableDate(parsed, expiryById, cutoffMs);
462
496
  if (earliest === null || earliest === parsed.expiresAt) return undefined;
463
497
  return `header expires-at ${parsed.expiresAt} != earliest resolvable absorbed date ${earliest}`;
464
498
  }
@@ -587,8 +621,8 @@ export async function planRuleExpirySweep(opts = {}) {
587
621
  // most likely to carry a stale header, `test-hygiene.md` among them — could
588
622
  // never receive one. Measured 2026-09-17: the advisory reached 3 of 7 files
589
623
  // where 6 of 7 have the discrepancy.
590
- const advisory = headerAdvisory(parsed, expiryById);
591
- const raiseTo = headerRaiseTarget(parsed, expiryById);
624
+ const advisory = headerAdvisory(parsed, expiryById, cutoffMs);
625
+ const raiseTo = headerRaiseTarget(parsed, expiryById, cutoffMs);
592
626
 
593
627
  if (
594
628
  parsed.counterLine === -1 &&
@@ -596,7 +630,7 @@ export async function planRuleExpirySweep(opts = {}) {
596
630
  parsed.pairs.length === 1 &&
597
631
  substantive.length === 1 &&
598
632
  parsed.expiresAt !== null &&
599
- Number.isFinite(Date.parse(parsed.expiresAt))
633
+ ruleExpiryInstantMs(parsed.expiresAt) !== null
600
634
  ) {
601
635
  // SINGLE-ENTRY file (#1513): one learning, one pair, no `### ` entries,
602
636
  // no counter sentence — the shape the reconciliation engine writes per
@@ -612,7 +646,10 @@ export async function planRuleExpirySweep(opts = {}) {
612
646
  // before the unlink, so `/reconcile` does not re-propose it.
613
647
  const pair = substantive[0];
614
648
  const storeAt = expiryById.has(pair.id) ? Date.parse(expiryById.get(pair.id)) : Number.NaN;
615
- const fileAt = Date.parse(parsed.expiresAt);
649
+ // The file's own `expires-at` goes through the loader's predicate: a bare
650
+ // date is inclusive (#1521), so the file is not deleted while
651
+ // `rule-loader.mjs` still injects it on its last day.
652
+ const fileAt = ruleExpiryInstantMs(parsed.expiresAt);
616
653
  const effectiveAt = Number.isFinite(storeAt) ? Math.max(storeAt, fileAt) : fileAt;
617
654
  const base = {
618
655
  file: rule.file,
@@ -623,7 +660,7 @@ export async function planRuleExpirySweep(opts = {}) {
623
660
  headings: 0,
624
661
  substantivePairs: 1,
625
662
  };
626
- if (effectiveAt < cutoffMs) {
663
+ if (effectiveAt <= cutoffMs) {
627
664
  plans.push({
628
665
  ...base,
629
666
  expiredPairIds: [pair.id],
@@ -39,9 +39,10 @@
39
39
  * pushes an error string.
40
40
  *
41
41
  * Atomic write strategy (rule files):
42
- * - Write content to `<target>.XXXXXXXX.tmp` via `writeFileSync`, then
43
- * `renameSync` over the final path. Same-filesystem rename is atomic on POSIX,
44
- * so the rule file is never partially visible. Mirrors idempotency.mjs pattern.
42
+ * - `atomicWriteWithBackup` (`../io.mjs`): content goes to a sibling
43
+ * `.<file>.tmp.<hex>`, then is renamed over the final path. Same-filesystem
44
+ * rename is atomic on POSIX, so the rule file is never partially visible.
45
+ * Mirrors idempotency.mjs pattern.
45
46
  *
46
47
  * JSONL append strategy (rejected log):
47
48
  * - `appendFileSync` for the rejected log — each record is a self-contained line,
@@ -63,17 +64,16 @@ import {
63
64
  writeFileSync,
64
65
  readFileSync,
65
66
  readdirSync,
66
- renameSync,
67
67
  appendFileSync,
68
68
  realpathSync,
69
69
  rmSync,
70
70
  statSync,
71
71
  } from 'node:fs';
72
- import { randomBytes } from 'node:crypto';
73
72
  import { tmpdir } from 'node:os';
74
73
  import path from 'node:path';
75
74
 
76
75
  import { withFileLock } from '../file-lock.mjs';
76
+ import { atomicWriteWithBackup, envelopeToError } from '../io.mjs';
77
77
  import { validatePathInsideProject } from '../path-utils.mjs';
78
78
  import { parseGlobsFrontmatter } from '../rule-loader.mjs';
79
79
  import { markCandidateProcessed } from './idempotency.mjs';
@@ -308,24 +308,18 @@ function resolveDest(item, target, prep, roots, errors) {
308
308
  }
309
309
 
310
310
  /**
311
- * Write `content` to `destPath` atomically via tmp+rename.
311
+ * Write `content` to `destPath` atomically via tmp+rename
312
+ * (`atomicWriteWithBackup`, random tmp suffix, tmp unlinked on failure).
312
313
  *
313
- * Uses a random 8-hex-char suffix for the tmp file to avoid collisions when
314
- * multiple proposals write to the same directory concurrently (defensive;
315
- * under the lock this should not happen, but the pattern is cheap).
316
- *
317
- * Throws on filesystem errors — callers must catch.
314
+ * Throws on filesystem errors — callers must catch. The helper returns an
315
+ * envelope; `envelopeToError` re-raises it with the same message and `code`.
318
316
  *
319
317
  * @param {string} destPath - absolute path of the target rule file.
320
318
  * @param {string} content - UTF-8 text content to write.
321
319
  */
322
320
  function writeTextAtomic(destPath, content) {
323
- const dir = path.dirname(destPath);
324
- mkdirSync(dir, { recursive: true });
325
- const suffix = randomBytes(4).toString('hex');
326
- const tmpPath = `${destPath}.${suffix}.tmp`;
327
- writeFileSync(tmpPath, content, 'utf8');
328
- renameSync(tmpPath, destPath);
321
+ const res = atomicWriteWithBackup(destPath, content, { tmpPrefix: `.${path.basename(destPath)}.tmp` });
322
+ if (!res.ok) throw envelopeToError(res);
329
323
  }
330
324
 
331
325
  /**
@@ -48,8 +48,9 @@
48
48
  * Deterministic gating (issue #694) runs after a successful frontmatter parse
49
49
  * and applies to BOTH always-on and glob-matched candidate rules — a rule must
50
50
  * pass ALL active gates to be included:
51
- * - Expiry: a rule with a parseable `expires-at` strictly before `now` is
52
- * EXCLUDED (with a mandatory stderr WARN). A malformed `expires-at` never
51
+ * - Expiry: a rule whose parseable `expires-at` has passed is EXCLUDED (with
52
+ * a mandatory stderr WARN). A bare date is inclusive — the rule loads
53
+ * through that whole UTC day ({@link ruleExpiryInstantMs}, #1521). A malformed `expires-at` never
53
54
  * excludes (fail-open).
54
55
  * - Mode-gating: when the `mode` param is non-null and the rule declares a
55
56
  * `mode` that differs, the rule is EXCLUDED. A null `mode` param disables
@@ -411,16 +412,41 @@ function metaToEntryFields(meta) {
411
412
  * @param {unknown} rawExpiry - the raw `expires-at` frontmatter value (or undefined)
412
413
  * @param {number} [now] - epoch ms; defaults to `Date.now()`
413
414
  * @param {(raw: unknown) => void} [onUnparseable] - called when `expires-at` is present but unparseable
414
- * @returns {boolean} true only when a PARSEABLE `expires-at` lies before `now`
415
+ * @returns {boolean} true only when `now` has reached a PARSEABLE `expires-at`'s {@link ruleExpiryInstantMs}
415
416
  */
416
417
  export function isRuleExpired(rawExpiry, now = Date.now(), onUnparseable) {
417
418
  if (rawExpiry === undefined || rawExpiry === null) return false;
418
- const ts = Date.parse(String(rawExpiry));
419
- if (Number.isNaN(ts)) {
419
+ const ts = ruleExpiryInstantMs(rawExpiry);
420
+ if (ts === null) {
420
421
  if (typeof onUnparseable === 'function') onUnparseable(rawExpiry);
421
422
  return false;
422
423
  }
423
- return ts < now;
424
+ return ts <= now;
425
+ }
426
+
427
+ const BARE_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
428
+ const MS_PER_DAY = 86_400_000;
429
+
430
+ /**
431
+ * The instant (epoch ms) from which a rule with this `expires-at` counts as
432
+ * expired, or `null` when the value is absent or unparseable.
433
+ *
434
+ * `expires-at` is INCLUSIVE (#1521): a bare `YYYY-MM-DD` keeps the rule live
435
+ * through the whole of that UTC day, so the instant is the FOLLOWING UTC
436
+ * midnight. A full timestamp is taken as written. Every consumer that judges
437
+ * "expired" — the loader gate above, `instruction-budget-guard.mjs`
438
+ * (maintenance probe S7) and `reconcile/rule-expiry-sweep.mjs` — goes through
439
+ * this one function so the three never disagree about the boundary day.
440
+ *
441
+ * @param {unknown} rawExpiry
442
+ * @returns {number|null}
443
+ */
444
+ export function ruleExpiryInstantMs(rawExpiry) {
445
+ if (rawExpiry === undefined || rawExpiry === null) return null;
446
+ const text = String(rawExpiry).trim();
447
+ const ts = Date.parse(text);
448
+ if (Number.isNaN(ts)) return null;
449
+ return BARE_DATE_RE.test(text) ? ts + MS_PER_DAY : ts;
424
450
  }
425
451
 
426
452
  /**
@@ -420,7 +420,8 @@ export function digestScopeFiles(stateDir, wave, { readDir = readdirSync, readFi
420
420
  * @param {number} params.wave
421
421
  * @param {string} params.stateDir
422
422
  * @param {string} [params.session] semantic (or raw) session id to filter on
423
- * @param {string} [params.eventsPath] defaults to `.orchestrator/metrics/events.jsonl`
423
+ * @param {string} [params.eventsPath] defaults to the cwd-relative `.orchestrator/metrics/events.jsonl`;
424
+ * the CLI passes `eventsFilePath(<state-dir>/..)` (the repo's one ledger, #1514)
424
425
  * @param {typeof readFileSync} [params.readFile]
425
426
  * @param {typeof readdirSync} [params.readDir]
426
427
  * @returns {{wave: number, transport: 'observable'|'unobservable', dispatches: number,
@@ -639,11 +640,20 @@ async function mainVerify(args) {
639
640
  return 1;
640
641
  }
641
642
 
643
+ // #1514 point 1 — read the ledger the send-side hook WRITES: the one ledger
644
+ // of the repo this state dir belongs to (`eventsFilePath(<root>)`, a linked
645
+ // worktree mapped to its main checkout), not a cwd-relative path that, run
646
+ // from an entered worktree, found 0 `scope_checked` records. Resolved here,
647
+ // not in `verifyWaveScope`, so this module stays pure (stdlib +
648
+ // `crypto-digest-utils.mjs`) for the hook that imports it.
649
+ const eventsPath = typeof args.events === 'string'
650
+ ? args.events
651
+ : (await import('./events.mjs')).eventsFilePath(resolve(stateDir, '..'));
642
652
  const report = verifyWaveScope({
643
653
  wave,
644
654
  stateDir,
645
655
  session: typeof args.session === 'string' ? args.session : undefined,
646
- eventsPath: typeof args.events === 'string' ? args.events : undefined,
656
+ eventsPath,
647
657
  });
648
658
 
649
659
  if (args.emit) {
@@ -405,7 +405,8 @@ export function testSiblingExpansionApplies(opts = {}) {
405
405
  * tests/lib/X.test.mjs`). The DATED figures — population, both ratios, the
406
406
  * measuring command and the SHA — live in ONE place:
407
407
  * skills/wave-executor/references/wave-loop-scope-manifest.md § Test-Sibling
408
- * Expansion (#970). Cite and re-measure them there, never restate them here: an
408
+ * Expansion (#970), measured by `scripts/lib/test-sibling-coverage.mjs` (#1030 P3).
409
+ * Cite and re-measure them there, never restate them here: an
409
410
  * undated copy drifted to a second, contradicting set of numbers (#1026.5,
410
411
  * PSA-006 item 4). The glob's failure mode is HARMLESS — it grants write access
411
412
  * to files that may not exist. A computed concrete path is wrong more often AND
@@ -66,7 +66,7 @@ import { parseStateMd as defaultParseStateMd } from './state-md/yaml-parser.mjs'
66
66
  import { canonicalizeSessions } from './sessions-canonical.mjs';
67
67
  // Already in the hook import set (`emitEvent` lives there): the rotation-aware
68
68
  // reader adds no new file to the SessionStart/SessionEnd hook graph.
69
- import { scanEventsBackwards } from './events.mjs';
69
+ import { eventsFilePath, scanEventsBackwards } from './events.mjs'; // eventsFilePath: #1514, the repo's one ledger
70
70
  // Leaf constants module (no imports of its own) and ALREADY in the hook import
71
71
  // set via session-schema/validator.mjs — importing it here adds no new file to
72
72
  // the SessionStart/SessionEnd hook graph. The profile enum must not be
@@ -114,7 +114,6 @@ const EVENT_STOPPED = 'orchestrator.session.stopped';
114
114
  const EVENT_TURN_STOPPED = 'orchestrator.turn.stopped';
115
115
  const EVENT_ENDED = 'orchestrator.session.ended';
116
116
 
117
- const EVENTS_REL = ['.orchestrator', 'metrics', 'events.jsonl'];
118
117
  const SESSIONS_REL = ['.orchestrator', 'metrics', 'sessions.jsonl'];
119
118
  const BACKFILL_LOG_REL = ['.orchestrator', 'metrics', 'session-close-backfill.log'];
120
119
 
@@ -973,7 +972,7 @@ export async function backfillAbandonedSession({
973
972
  return { action: 'skipped-no-identifier' };
974
973
  }
975
974
 
976
- const eventsPath = path.join(repoRoot, ...EVENTS_REL);
975
+ const eventsPath = eventsFilePath(repoRoot);
977
976
  const sessionsPath = path.join(repoRoot, ...SESSIONS_REL);
978
977
 
979
978
  // -- Resolve the SEMANTIC record id (sessions.jsonl key) ----------------
@@ -1368,7 +1367,7 @@ export async function backfillCompletedFromStateMd({
1368
1367
  if (supersedes) recordId = supersedes;
1369
1368
 
1370
1369
  // -- Derive whatever is derivable from events.jsonl (never STATE.md body) -
1371
- const eventsPath = path.join(repoRoot, ...EVENTS_REL);
1370
+ const eventsPath = eventsFilePath(repoRoot);
1372
1371
  const gathered = gatherSessionEvents(readFileSync, eventsPath, { sessionId: nativeId, semanticSessionId });
1373
1372
 
1374
1373
  // -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ----
@@ -42,6 +42,7 @@
42
42
  import { readFile } from 'node:fs/promises';
43
43
  import path from 'node:path';
44
44
 
45
+ import { eventsFilePath } from './events.mjs';
45
46
  import { readCanonicalSessions } from './sessions-canonical.mjs';
46
47
  import { readTailWindow } from './tail-window.mjs';
47
48
  import { withStateMdLock } from './session-lock.mjs';
@@ -207,7 +208,7 @@ const EVENTS_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
207
208
  * @returns {Promise<string[]>} Array of semantic_session_id strings (may include duplicates).
208
209
  */
209
210
  async function readSessionIdsFromEvents(repoRoot) {
210
- const filePath = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
211
+ const filePath = eventsFilePath(repoRoot); // #1514 point 1: the repo's one ledger
211
212
 
212
213
  let window;
213
214
  try {
@@ -68,6 +68,7 @@
68
68
  import fs from 'node:fs';
69
69
  import path from 'node:path';
70
70
 
71
+ import { atomicWriteWithBackup, envelopeToError } from './io.mjs';
71
72
  import { validateSession as defaultValidateSession } from './session-schema/validator.mjs';
72
73
  import { serializeSessionLineChecked as defaultSerialize } from './session-schema.mjs';
73
74
  import { checkSessionsIntegrity as defaultCheckIntegrity } from './sessions-integrity-banner.mjs';
@@ -123,12 +124,11 @@ const INCOMPLETE_FIELD_ORDER = Object.freeze([
123
124
  const MS_PRECISION_FIELDS = Object.freeze(['started_at', 'completed_at', 'lease_acquired_at']);
124
125
 
125
126
  /**
126
- * ISO-8601 UTC with MORE than three fractional digits (`.994600Z`) — the one
127
- * rejected shape that has an unambiguous canonical form. Group 1 is the value
128
- * cut at the millisecond. Offsets, `.3Z` and non-ISO strings do not match and
129
- * stay for the validator to reject: there is no defensible default for them.
127
+ * ISO-8601 UTC with MORE than three fractional digits and exactly `Z` or
128
+ * `+00:00` (#1525). Group 1 is the value cut at the millisecond. Nonzero
129
+ * offsets, other timezone spellings and shorter fractions stay unrepaired.
130
130
  */
131
- const ISO_UTC_SUB_MS_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3})\d+Z$/;
131
+ const ISO_UTC_SUB_MS_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3})\d+(?:Z|\+00:00)$/;
132
132
 
133
133
  // ---------------------------------------------------------------------------
134
134
  // Helpers
@@ -154,7 +154,13 @@ function isCount(v) {
154
154
  function truncateToMs(value) {
155
155
  if (typeof value !== 'string') return null;
156
156
  const m = ISO_UTC_SUB_MS_RE.exec(value);
157
- return m ? `${m[1]}Z` : null;
157
+ if (!m) return null;
158
+ const canonical = `${m[1]}Z`;
159
+ const ms = Date.parse(canonical);
160
+ // Date.parse rolls over e.g. April 31, non-leap February 29 and 24:00.
161
+ // Equality with the original calendar components refuses those repairs.
162
+ if (!Number.isFinite(ms) || new Date(ms).toISOString() !== canonical) return null;
163
+ return canonical;
158
164
  }
159
165
 
160
166
  function orderIncompleteFields(fields) {
@@ -390,10 +396,16 @@ export function repairRecord(record) {
390
396
  preserveRaw(out, 'completed_at', record.completed_at, rescued);
391
397
  const endedMs = typeof out.ended_at === 'string' ? Date.parse(out.ended_at) : NaN;
392
398
  const startedMs = Date.parse(out.started_at);
393
- if (Number.isFinite(endedMs) && Number.isFinite(startedMs) && endedMs >= startedMs) {
399
+ const endedTruncated = truncateToMs(out.ended_at);
400
+ if (typeof out.ended_at === 'string' &&
401
+ (!Number.isFinite(endedMs) ||
402
+ (ISO_UTC_SUB_MS_RE.test(out.ended_at) && endedTruncated === null))) {
403
+ // Preserve an invalid legacy measurement for the final schema gate to
404
+ // reject; do not turn it into a valid default or a rolled-over date.
405
+ out.completed_at = out.ended_at;
406
+ } else if (Number.isFinite(endedMs) && Number.isFinite(startedMs) && endedMs >= startedMs) {
394
407
  // The COPY is cut to milliseconds; `ended_at` itself is not a validated
395
408
  // field and keeps its original bytes, so nothing needs a sidecar here.
396
- const endedTruncated = truncateToMs(out.ended_at);
397
409
  if (endedTruncated !== null) precisionCut = true;
398
410
  out.completed_at = endedTruncated ?? out.ended_at;
399
411
  } else {
@@ -716,7 +728,6 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
716
728
  renameSync = fs.renameSync,
717
729
  copyFileSync = fs.copyFileSync,
718
730
  unlinkSync = fs.unlinkSync,
719
- existsSync = fs.existsSync,
720
731
  } = deps;
721
732
 
722
733
  const { checkIntegrity = defaultCheckIntegrity } = deps;
@@ -751,18 +762,14 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
751
762
  }
752
763
 
753
764
  // -- atomic swap ----------------------------------------------------------
754
- const tmpPath = `${file}.tmp-${process.pid}`;
755
- try {
756
- writeFileSync(tmpPath, text, 'utf8');
757
- renameSync(tmpPath, file);
758
- } catch (err) {
759
- try {
760
- if (existsSync(tmpPath)) unlinkSync(tmpPath);
761
- } catch {
762
- /* best-effort cleanup */
763
- }
764
- throw err;
765
- }
765
+ // The helper unlinks its tmp on failure; the backup above keeps its own
766
+ // `.bak-<compact stamp>` schema, so the helper takes none. The injected fs
767
+ // seams (`deps`) are threaded through so a test fake still sees the write.
768
+ const res = atomicWriteWithBackup(file, text, {
769
+ tmpPrefix: `.${path.basename(file)}.tmp`,
770
+ fs: { writeFileSync, renameSync, unlinkSync },
771
+ });
772
+ if (!res.ok) throw envelopeToError(res);
766
773
 
767
774
  // -- post-verification ----------------------------------------------------
768
775
  const verdict = verifyWritten({
@@ -162,11 +162,11 @@ export const AGENT_SUMMARY_FIELDS = Object.freeze(['complete', 'partial', 'faile
162
162
  * #964). These are NOT in REQUIRED_FIELDS — older entries lacking them validate
163
163
  * cleanly. Validator: see `_validateOptionalFields` in validator.mjs.
164
164
  *
165
- * NOT YET EXHAUSTIVE. `_validateOptionalFields` additionally shape-checks
166
- * `discovery_stats`, `review_stats`, `platform`, `branch`, `base_branch`,
167
- * `notes`, `duration_seconds`, `issues_closed` and `issues_created` without
168
- * listing them here. Treat membership as "declared optional", never absence as
169
- * "not a known field" — see the #964 follow-up note in the session report.
165
+ * Exhaustive since #986: every optional key `validateSession` inspects is
166
+ * declared here. tests/lib/session-schema/constants.test.mjs takes that census
167
+ * from the validator itself (a Proxy records the keys it reads) and also pins
168
+ * the declared-but-unchecked remainder (`session_start_ref`, `raw_session_id`,
169
+ * `_repair_source`).
170
170
  */
171
171
  export const OPTIONAL_FIELDS = Object.freeze([
172
172
  'agent_identity',
@@ -252,4 +252,19 @@ export const OPTIONAL_FIELDS = Object.freeze([
252
252
  // 2026-10-01), so the counters say which of the two it is.
253
253
  'cost_records_priced',
254
254
  'cost_records_total',
255
+ // #986 — shape-checked by `_validateOptionalFields` since before this list
256
+ // existed, never declared until the census test made the gap visible.
257
+ 'discovery_stats',
258
+ 'review_stats',
259
+ 'platform',
260
+ 'branch',
261
+ 'base_branch',
262
+ 'notes',
263
+ 'duration_seconds',
264
+ 'issues_closed',
265
+ 'issues_created',
266
+ // `express_path` (canonical boolean, legacy {activated} object tolerated) and
267
+ // its forensic sidecar `_express_path_detail` written by `normalizeSession`.
268
+ 'express_path',
269
+ '_express_path_detail',
255
270
  ]);
@@ -182,6 +182,12 @@ function _validateTimestamps(entry) {
182
182
  `started_at must match ISO-8601 UTC ms format (YYYY-MM-DDTHH:MM:SS[.SSS]Z), got: ${entry.started_at}`
183
183
  );
184
184
  }
185
+ // Date.parse normalizes impossible calendar dates; compare without mutating the record.
186
+ const startedCanonical = entry.started_at.includes('.')
187
+ ? entry.started_at : entry.started_at.replace(/Z$/, '.000Z');
188
+ if (new Date(startedMs).toISOString() !== startedCanonical) {
189
+ throw new ValidationError(`started_at must be a valid calendar timestamp, got: ${entry.started_at}`);
190
+ }
185
191
  if (typeof entry.completed_at !== 'string') {
186
192
  throw new ValidationError('completed_at must be an ISO timestamp string');
187
193
  }
@@ -194,6 +200,12 @@ function _validateTimestamps(entry) {
194
200
  `completed_at must match ISO-8601 UTC ms format (YYYY-MM-DDTHH:MM:SS[.SSS]Z), got: ${entry.completed_at}`
195
201
  );
196
202
  }
203
+ // Date.parse normalizes impossible calendar dates; compare without mutating the record.
204
+ const completedCanonical = entry.completed_at.includes('.')
205
+ ? entry.completed_at : entry.completed_at.replace(/Z$/, '.000Z');
206
+ if (new Date(completedMs).toISOString() !== completedCanonical) {
207
+ throw new ValidationError(`completed_at must be a valid calendar timestamp, got: ${entry.completed_at}`);
208
+ }
197
209
  if (completedMs < startedMs) {
198
210
  throw new ValidationError(
199
211
  `completed_at (${entry.completed_at}) must be >= started_at (${entry.started_at})`
@@ -50,7 +50,7 @@ import path from 'node:path';
50
50
  import { pathToFileURL } from 'node:url';
51
51
  import { promisify } from 'node:util';
52
52
 
53
- import { emitEvent } from './events.mjs';
53
+ import { emitEvent, eventsFilePath } from './events.mjs';
54
54
  import { ARCHIVE_DIR_NAME, ARCHIVE_NAME_RE } from './events-schema.mjs';
55
55
 
56
56
  /**
@@ -258,10 +258,11 @@ function commitRunsCommand(gh, sha) {
258
258
  * @returns {string}
259
259
  */
260
260
  function ciUnknownHint(result) {
261
- if (!PUSHED_FOLLOW_UP_REASONS.has(result?.details?.reason)) return CI_UNKNOWN_HINT_DEFAULT;
262
- // The GitHub reason names the gh CLI; a glab command there would be wrong.
263
- const gh = result.details.cliUsed === 'gh';
261
+ // Every GitHub unknown reason needs a GitHub hint, including running,
262
+ // queued and cancelled checks that do not trigger a pushed-commit query.
263
+ const gh = result?.details?.cliUsed === 'gh';
264
264
  const onDemand = gh ? 'run `gh run list` on demand' : CI_UNKNOWN_HINT_DEFAULT;
265
+ if (!PUSHED_FOLLOW_UP_REASONS.has(result?.details?.reason)) return onDemand;
265
266
  const pushed = result.pushed && typeof result.pushed === 'object' ? result.pushed : null;
266
267
  // Pure (#1396): the SHA is whatever the follow-up resolved inside the budget.
267
268
  // Without `pushed` (the follow-up overran or threw before resolving it) the
@@ -275,6 +276,20 @@ function ciUnknownHint(result) {
275
276
  return `last pushed: ${short} (pipeline not checked; run \`${commitRunsCommand(gh, sha)}\`)`;
276
277
  }
277
278
 
279
+ /** Additive GitHub diagnostics; absent counts leave existing banners unchanged. */
280
+ function checkRunCountsText(result) {
281
+ const counts = result?.details?.checkRunCounts;
282
+ if (!counts || result?.details?.cliUsed !== 'gh') return '';
283
+ const parts = [
284
+ ['success', 'successful'], ['failure', 'failed/action required'],
285
+ ['inProgress', 'in progress'], ['queued', 'queued'],
286
+ ['cancelled', 'cancelled'], ['skipped', 'skipped'],
287
+ ['neutral', 'neutral'], ['waiting', 'waiting'], ['other', 'unrecognised'],
288
+ ].filter(([key]) => Number.isSafeInteger(counts[key]) && counts[key] > 0)
289
+ .map(([key, label]) => `${label}: ${counts[key]}`);
290
+ return parts.length ? ` — Check runs: ${parts.join(', ')}` : '';
291
+ }
292
+
278
293
  /**
279
294
  * "Green, but allow_failure jobs failed" — the ONE predicate both the
280
295
  * `ci-status` renderer and its `severityOf` use (#1333). Two predicates once
@@ -584,7 +599,7 @@ export const PROBES = [
584
599
  // #1337: a RED pushed commit is an alert, and the banner says what the
585
600
  // rule judges (HR-106) — same 🚨 as a red HEAD.
586
601
  const mark = r.pushed?.verdict?.status === 'red' ? '🚨' : '⚠';
587
- return `${mark} ci-status: CI status for HEAD could not be determined (${reason}) — ${ciUnknownHint(r)}`;
602
+ return `${mark} ci-status: CI status for HEAD could not be determined (${reason})${checkRunCountsText(r)} — ${ciUnknownHint(r)}`;
588
603
  }
589
604
  if (r.status === 'red') {
590
605
  const pid = r.details?.currentPipelineId ?? '?';
@@ -592,7 +607,7 @@ export const PROBES = [
592
607
  ? ` — last green: #${r.lastGreen.pipelineId} (commit ${String(r.lastGreen.sha ?? '').slice(0, 7)}, ${r.redCount} pipelines ago)`
593
608
  : '';
594
609
  const job = r.failingJobName ? ` Failing job: ${r.failingJobName}` : '';
595
- return `🚨 CI RED on HEAD (pipeline #${pid})${green}.${job}`;
610
+ return `🚨 CI RED on HEAD (pipeline #${pid})${green}.${job}${checkRunCountsText(r)}`;
596
611
  }
597
612
  if (r.status === 'green') {
598
613
  const lines = [];
@@ -762,7 +777,7 @@ export const PROBES = [
762
777
  fn: 'checkEventsRetention',
763
778
  network: false,
764
779
  precondition: ({ repoRoot }) => {
765
- const metrics = path.join(repoRoot, '.orchestrator', 'metrics');
780
+ const metrics = path.dirname(eventsFilePath(repoRoot)); // #1514: the ledger checkEventsRetention reads
766
781
  return existsSync(path.join(metrics, 'events.jsonl')) || hasRotationArchive(metrics)
767
782
  ? null
768
783
  : 'no-events-ledger';