session-orchestrator 5.7.0 → 5.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -0
  2. package/.agents/skills/architecture/agents/openai.yaml +5 -0
  3. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -0
  4. package/.agents/skills/eval/SKILL.md +1 -1
  5. package/.agents/skills/frontmatter-guard/SKILL.md +1 -0
  6. package/.agents/skills/gitlab-portfolio/SKILL.md +1 -0
  7. package/.agents/skills/hook-development/SKILL.md +1 -0
  8. package/.agents/skills/mcp-builder/SKILL.md +1 -0
  9. package/.agents/skills/tmux-layout/SKILL.md +3 -0
  10. package/.agents/skills/tmux-layout/agents/openai.yaml +5 -0
  11. package/.agents/skills/vault-mirror/SKILL.md +1 -0
  12. package/.agents/skills/vault-sync/SKILL.md +1 -0
  13. package/.agents/skills/write-executable-plan/SKILL.md +3 -0
  14. package/.agents/skills/write-executable-plan/agents/openai.yaml +5 -0
  15. package/.claude-plugin/marketplace.json +1 -1
  16. package/.claude-plugin/plugin.json +1 -1
  17. package/.codex-plugin/plugin.json +1 -1
  18. package/.codex-plugin/skills/architecture/SKILL.md +3 -0
  19. package/.codex-plugin/skills/architecture/agents/openai.yaml +5 -0
  20. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -0
  21. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  22. package/.codex-plugin/skills/frontmatter-guard/SKILL.md +1 -0
  23. package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +1 -0
  24. package/.codex-plugin/skills/hook-development/SKILL.md +1 -0
  25. package/.codex-plugin/skills/mcp-builder/SKILL.md +1 -0
  26. package/.codex-plugin/skills/tmux-layout/SKILL.md +3 -0
  27. package/.codex-plugin/skills/tmux-layout/agents/openai.yaml +5 -0
  28. package/.codex-plugin/skills/vault-mirror/SKILL.md +1 -0
  29. package/.codex-plugin/skills/vault-sync/SKILL.md +1 -0
  30. package/.codex-plugin/skills/write-executable-plan/SKILL.md +3 -0
  31. package/.codex-plugin/skills/write-executable-plan/agents/openai.yaml +5 -0
  32. package/.cursor/commands/architecture.md +13 -0
  33. package/.cursor/commands/eval.md +1 -1
  34. package/.cursor/commands/tmux-layout.md +13 -0
  35. package/.cursor/commands/write-executable-plan.md +13 -0
  36. package/.cursor/skills/architecture/SKILL.md +0 -1
  37. package/.cursor/skills/eval/SKILL.md +1 -1
  38. package/.cursor/skills/tmux-layout/SKILL.md +0 -1
  39. package/.cursor/skills/write-executable-plan/SKILL.md +0 -1
  40. package/.cursor-plugin/plugin.json +1 -1
  41. package/CHANGELOG.md +49 -0
  42. package/README.md +10 -9
  43. package/agents/eval-judge.md +5 -5
  44. package/docs/baseline.md +4 -3
  45. package/docs/codex-setup.md +6 -4
  46. package/docs/components.md +5 -4
  47. package/docs/events-schema.md +4 -3
  48. package/docs/github-mirror-protection.md +3 -2
  49. package/docs/install.md +39 -3
  50. package/docs/pi-setup.md +1 -1
  51. package/docs/rule-authoring.md +2 -2
  52. package/docs/session-config-reference.md +7 -5
  53. package/docs/session-config-template.md +3 -2
  54. package/evals/README.md +60 -0
  55. package/evals/instruction-ablation/README.md +147 -0
  56. package/evals/instruction-ablation/cases/psa-003-foreign-work.json +34 -0
  57. package/evals/instruction-ablation/cases/sec-007-sql-parameterisation.json +32 -0
  58. package/evals/instruction-ablation/cases/vbc-001-verify-before-claiming.json +41 -0
  59. package/evals/instruction-ablation/run.mjs +336 -0
  60. package/evals/psa-003-foreign-work/case.yaml +16 -0
  61. package/evals/psa-003-foreign-work/fixture.sh +12 -0
  62. package/evals/psa-003-foreign-work/graders/foreign-file-intact.md +7 -0
  63. package/evals/psa-003-foreign-work/graders/names-foreign-work.md +6 -0
  64. package/evals/psa-003-foreign-work/graders/no-destructive-git.md +8 -0
  65. package/evals/psa-003-foreign-work/graders/ran-git.md +7 -0
  66. package/evals/psa-003-foreign-work/prompt.md +6 -0
  67. package/hooks/_lib/hook-import-set.json +10 -2
  68. package/hooks/hooks-codex.json +1 -1
  69. package/hooks/hooks.json +1 -1
  70. package/hooks/post-tool-batch-wave-signal.mjs +1 -0
  71. package/hooks/post-tooluse-frontend-slop.mjs +11 -1
  72. package/hooks/pre-task-scope-disjoint.mjs +8 -6
  73. package/monitors/monitors.json +1 -1
  74. package/package.json +3 -1
  75. package/pi/prompts/architecture.md +11 -0
  76. package/pi/prompts/eval.md +1 -1
  77. package/pi/prompts/tmux-layout.md +11 -0
  78. package/pi/prompts/write-executable-plan.md +11 -0
  79. package/scripts/backfill-abandoned-sessions.mjs +2 -2
  80. package/scripts/backfill-sessions.mjs +9 -7
  81. package/scripts/codex-install.mjs +108 -23
  82. package/scripts/emit-session.mjs +15 -2
  83. package/scripts/fleet-auflagen.mjs +54 -0
  84. package/scripts/generate-agents-skills.mjs +9 -16
  85. package/scripts/generate-codex-skills.mjs +8 -6
  86. package/scripts/generate-cursor-adapter.mjs +9 -74
  87. package/scripts/generate-pi-prompts.mjs +8 -74
  88. package/scripts/github-protection-audit.mjs +8 -0
  89. package/scripts/lib/auto-dialectic.mjs +10 -9
  90. package/scripts/lib/auto-dream.mjs +8 -10
  91. package/scripts/lib/autopilot/telemetry.mjs +12 -6
  92. package/scripts/lib/bootstrap-lock-refresh.mjs +8 -12
  93. package/scripts/lib/config/drift-check.mjs +1 -1
  94. package/scripts/lib/config/frontend-slop-hook.mjs +6 -0
  95. package/scripts/lib/config.mjs +5 -1
  96. package/scripts/lib/convergence-monitor.mjs +5 -3
  97. package/scripts/lib/ecosystem-health.mjs +5 -3
  98. package/scripts/lib/eval/engine.mjs +61 -27
  99. package/scripts/lib/eval/judge.mjs +14 -8
  100. package/scripts/lib/eval/session-resolve.mjs +7 -2
  101. package/scripts/lib/events-retention-banner.mjs +5 -2
  102. package/scripts/lib/events.mjs +19 -5
  103. package/scripts/lib/fleet-protocol.mjs +98 -1
  104. package/scripts/lib/instruction-budget-guard.mjs +25 -18
  105. package/scripts/lib/io.mjs +172 -35
  106. package/scripts/lib/learnings/candidates.mjs +7 -28
  107. package/scripts/lib/learnings/io.mjs +7 -2
  108. package/scripts/lib/learnings/judgment.mjs +5 -8
  109. package/scripts/lib/learnings/select.mjs +3 -3
  110. package/scripts/lib/maintenance-due-banner.mjs +2 -2
  111. package/scripts/lib/memory-proposals/sink.mjs +5 -6
  112. package/scripts/lib/memory-proposals/store.mjs +3 -5
  113. package/scripts/lib/mirror-issues-banner.mjs +11 -1
  114. package/scripts/lib/native-command.mjs +48 -0
  115. package/scripts/lib/orphan-reaper/scan.mjs +29 -3
  116. package/scripts/lib/peer-cards/writer.mjs +6 -18
  117. package/scripts/lib/platform.mjs +113 -4
  118. package/scripts/lib/plugin-package-stage.mjs +169 -0
  119. package/scripts/lib/plugin-surface.mjs +121 -0
  120. package/scripts/lib/quality-gate.mjs +30 -25
  121. package/scripts/lib/reconcile/idempotency.mjs +9 -10
  122. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +48 -11
  123. package/scripts/lib/reconcile/writer.mjs +11 -17
  124. package/scripts/lib/rule-loader.mjs +32 -6
  125. package/scripts/lib/scope-echo.mjs +12 -2
  126. package/scripts/lib/scope-gate.mjs +2 -1
  127. package/scripts/lib/session-close-backfill.mjs +3 -4
  128. package/scripts/lib/session-id.mjs +2 -1
  129. package/scripts/lib/session-record-repair.mjs +9 -13
  130. package/scripts/lib/session-schema/constants.mjs +20 -5
  131. package/scripts/lib/session-start-probes.mjs +2 -2
  132. package/scripts/lib/sessions-staleness-banner.mjs +2 -3
  133. package/scripts/lib/skill-evolution/engine.mjs +6 -9
  134. package/scripts/lib/skill-evolution/idempotency.mjs +8 -10
  135. package/scripts/lib/slopcheck.mjs +9 -7
  136. package/scripts/lib/sunset/walker.mjs +3 -3
  137. package/scripts/lib/telemetry/sync.mjs +3 -1
  138. package/scripts/lib/telemetry-flush-health-banner.mjs +2 -2
  139. package/scripts/lib/test-sibling-coverage.mjs +167 -0
  140. package/scripts/lib/tmux-layout/telemetry-stats.mjs +5 -7
  141. package/scripts/lib/tmux-layout/telemetry.mjs +8 -10
  142. package/scripts/lib/user-invocable-skills.mjs +37 -10
  143. package/scripts/lib/ux-grill/run-record.mjs +5 -3
  144. package/scripts/lib/validate/check-banner-parity.mjs +1 -0
  145. package/scripts/lib/validate/check-skills.mjs +12 -2
  146. package/scripts/lib/validate/check-test-fixture-shapes.mjs +3 -3
  147. package/scripts/lib/validate/check-test-value-bans.mjs +3 -2
  148. package/scripts/lib/validate/check-unicode-safety.mjs +1 -1
  149. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  150. package/scripts/lib/vault-archive.mjs +2 -4
  151. package/scripts/lib/vault-mirror/process.mjs +207 -26
  152. package/scripts/lib/vault-mirror/utils.mjs +16 -1
  153. package/scripts/lib/vault-sync-baseline.mjs +9 -8
  154. package/scripts/lib/wave-transcript-tail.mjs +8 -4
  155. package/scripts/lib/worktree/meta.mjs +7 -3
  156. package/scripts/mcp-server.mjs +158 -0
  157. package/scripts/migrate-sessions-jsonl.mjs +7 -5
  158. package/scripts/migrate-vault-paths.mjs +7 -4
  159. package/scripts/print-learnings-index.mjs +2 -1
  160. package/scripts/relocate-vault-corpus.mjs +5 -3
  161. package/scripts/run-migrate-v2-cross-repo.mjs +9 -7
  162. package/scripts/self-update.mjs +179 -39
  163. package/scripts/site-numbers.mjs +4 -3
  164. package/scripts/sweep-expired-learnings.mjs +6 -4
  165. package/scripts/sync-vault-schema.mjs +3 -7
  166. package/scripts/windows-setup.mjs +45 -0
  167. package/skills/_shared/fleet-protocol.md +8 -0
  168. package/skills/architecture/SKILL.md +1 -0
  169. package/skills/bootstrap/_shared-template.md +5 -2
  170. package/skills/claude-md-drift-check/SKILL.md +4 -1
  171. package/skills/claude-md-drift-check/checker.mjs +83 -10
  172. package/skills/discovery/probes/ssot-code-diff.mjs +3 -3
  173. package/skills/eval/SKILL.md +12 -13
  174. package/skills/eval/rubric-v3.md +521 -0
  175. package/skills/frontmatter-guard/SKILL.md +1 -0
  176. package/skills/gitlab-portfolio/SKILL.md +1 -0
  177. package/skills/hook-development/SKILL.md +1 -0
  178. package/skills/mcp-builder/SKILL.md +1 -0
  179. package/skills/repo-audit/SKILL.md +2 -2
  180. package/skills/session-end/SKILL.md +32 -1
  181. package/skills/session-end/vault-operations.md +12 -2
  182. package/skills/session-start/SKILL.md +2 -2
  183. package/skills/session-start/phase-7-1-premise-check.md +2 -1
  184. package/skills/session-start/references/operations-contract.md +4 -1
  185. package/skills/tmux-layout/SKILL.md +1 -0
  186. package/skills/vault-mirror/SKILL.md +1 -0
  187. package/skills/vault-sync/SKILL.md +6 -6
  188. package/skills/vault-sync/validator.sh +29 -10
  189. package/skills/wave-executor/references/wave-loop-scope-manifest.md +4 -4
  190. package/skills/write-executable-plan/SKILL.md +1 -0
@@ -16,7 +16,7 @@
16
16
  * #457; writeJsonAtomicSync extracted for #558 M1.
17
17
  */
18
18
 
19
- import { writeFile, rename, mkdir } from 'node:fs/promises';
19
+ import { writeFile, rename, mkdir, copyFile, unlink } from 'node:fs/promises';
20
20
  import {
21
21
  mkdirSync,
22
22
  writeFileSync,
@@ -888,7 +888,10 @@ export async function writeJsonAtomic(filePath, value, opts = {}) {
888
888
  * @param {object} [opts]
889
889
  * @param {number} [opts.indent=2] JSON.stringify indent.
890
890
  * @param {string} [opts.tmpPrefix='.tmp'] Tmp-file prefix (callers pick their domain prefix).
891
- * @returns {{ ok: true } | { ok: false, reason: 'fs-error', error: string }}
891
+ * @returns {{ ok: true } | { ok: false, reason: 'fs-error', error: string, code: string|null }}
892
+ * `code` is the failing call's `err.code` (`'ENOSPC'`, `'EACCES'`, …), or `null`
893
+ * when the error carried none (a serialization TypeError, the partial-adapter
894
+ * refusal). Additive (#1032): `error` keeps its exact text.
892
895
  */
893
896
  export function writeJsonAtomicSync(filePath, data, opts = {}) {
894
897
  const { indent = 2, tmpPrefix = '.tmp' } = opts;
@@ -897,7 +900,7 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
897
900
  try {
898
901
  body = JSON.stringify(data, null, indent) + '\n';
899
902
  } catch (err) {
900
- return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
903
+ return _fsErrorEnvelope(err);
901
904
  }
902
905
 
903
906
  const res = atomicWriteWithBackup(filePath, body, { tmpPrefix });
@@ -941,9 +944,10 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
941
944
  * envelope is returned. Without that, every failed write leaves a
942
945
  * `<tmpPrefix>.<hex>` behind — the board writer's failure mode is a retry loop,
943
946
  * so the litter accumulates in the operator's vault directory. The cleanup is
944
- * attempted only when the write actually created the tmp file, and its own
945
- * failure is swallowed: a leaked tmp is worse than a silent unlink miss, and
946
- * neither may mask the original error.
947
+ * attempted once the tmp path has been chosen — including a write that created
948
+ * the file and then failed mid-way — and its own failure is swallowed: a leaked
949
+ * tmp is worse than a silent unlink miss, and neither may mask the original
950
+ * error.
947
951
  *
948
952
  * ── BV-004 ceiling + revisit trigger ────────────────────────────────────────
949
953
  * THREE PRODUCTION CALL-SITES (re-measured 2026-10-03 @ 0e0c4b08 with
@@ -964,8 +968,8 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
964
968
  * `backup: false` and no `fs`, so the backup half and the injection seam still
965
969
  * rest on tests plus one board-writer flag. REVISIT TRIGGER — when a sweep
966
970
  * migrates the remaining hand-rolled sites, re-check before widening:
967
- * (a) whether an `async` twin is needed rather than bolting a promise mode onto
968
- * this one (three known sites are `fs/promises`), and (b) whether rotation
971
+ * (a) answered (#1032): the async twin is {@link atomicWriteText}, not a
972
+ * promise mode on this function; and (b) whether rotation
969
973
  * belongs here after all (it does only if ≥2 migrated callers want the SAME
970
974
  * keep-N). If NO caller ever passes `backup: true` in production, that half is
971
975
  * still the part to shrink back.
@@ -982,7 +986,9 @@ export function writeJsonAtomicSync(filePath, data, opts = {}) {
982
986
  * Injectable fs (tests). Omitted methods fall back to `node:fs` — EXCEPT on
983
987
  * the backup path, which fails closed (see below).
984
988
  * @returns {{ ok: true, path: string, bytes: number, backupPath: string|null }
985
- * | { ok: false, reason: 'fs-error', error: string }}
989
+ * | { ok: false, reason: 'fs-error', error: string, code: string|null }}
990
+ * `code` is the failing call's `err.code`, or `null` when there was none
991
+ * (additive, #1032 — a caller that must re-throw with the same code can now).
986
992
  */
987
993
  export function atomicWriteWithBackup(filePath, body, opts = {}) {
988
994
  const {
@@ -1016,20 +1022,15 @@ export function atomicWriteWithBackup(filePath, body, opts = {}) {
1016
1022
  // methods are guarded, not just `copyFileSync` — a missing `existsSync` probes
1017
1023
  // the real target and silently decides the backup branch from it, which is the
1018
1024
  // same escape one step earlier.
1019
- if (backup && injectedFs) {
1020
- for (const method of ['existsSync', 'copyFileSync']) {
1021
- if (typeof injectedFs[method] !== 'function') {
1022
- return {
1023
- ok: false,
1024
- reason: 'fs-error',
1025
- error: `partial fs adapter: ${method} required for backup`,
1026
- };
1027
- }
1028
- }
1029
- }
1025
+ const refusal = _partialAdapterRefusal(backup, injectedFs, ['existsSync', 'copyFileSync']);
1026
+ if (refusal) return refusal;
1030
1027
 
1028
+ // Assigned BEFORE the write, and cleanup keys on it rather than on the write
1029
+ // having returned: `writeFileSync` can create the tmp file and THEN throw
1030
+ // (ENOSPC / EIO mid-write), and a flag set only after it returned leaked
1031
+ // exactly that file (#1032). Unlinking a tmp that was never created throws
1032
+ // ENOENT, which the cleanup swallows.
1031
1033
  let tmpFile = null;
1032
- let tmpCreated = false;
1033
1034
 
1034
1035
  try {
1035
1036
  const dir = dirname(filePath);
@@ -1037,34 +1038,170 @@ export function atomicWriteWithBackup(filePath, body, opts = {}) {
1037
1038
 
1038
1039
  let backupPath = null;
1039
1040
  if (backup && fsExists(filePath)) {
1040
- const stamp = (now instanceof Date ? now : new Date()).toISOString().replace(/[:.]/g, '-');
1041
- backupPath = `${filePath}.bak-${stamp}`;
1041
+ backupPath = _backupPathFor(filePath, now);
1042
1042
  fsCopyFile(filePath, backupPath);
1043
1043
  }
1044
1044
 
1045
- tmpFile = path.join(dir, `${tmpPrefix}.${randomBytes(6).toString('hex')}`);
1045
+ tmpFile = _tmpPathFor(dir, tmpPrefix);
1046
1046
  fsWriteFile(tmpFile, body, encoding);
1047
- tmpCreated = true;
1048
1047
  fsRename(tmpFile, filePath);
1049
1048
 
1050
- return {
1051
- ok: true,
1052
- path: filePath,
1053
- bytes: Buffer.isBuffer(body) ? body.length : Buffer.byteLength(String(body), encoding),
1054
- backupPath,
1055
- };
1049
+ return { ok: true, path: filePath, bytes: _byteLength(body, encoding), backupPath };
1056
1050
  } catch (err) {
1057
- // Only when the write got far enough to create it. The name carries 12 hex
1058
- // chars of entropy, so this cannot collide with a caller's real file.
1059
- if (tmpCreated) {
1051
+ // The name carries 12 hex chars of entropy, so this cannot collide with a
1052
+ // caller's real file.
1053
+ if (tmpFile !== null) {
1060
1054
  try {
1061
1055
  fsUnlink(tmpFile);
1062
1056
  } catch {
1063
1057
  // Best-effort: never let cleanup replace the error the caller needs.
1064
1058
  }
1065
1059
  }
1066
- return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
1060
+ return _fsErrorEnvelope(err);
1061
+ }
1062
+ }
1063
+
1064
+ /**
1065
+ * Async twin of {@link atomicWriteWithBackup} (#1032): same tmp naming
1066
+ * (`<dir>/<tmpPrefix>.<12 hex>`), same backup stamp, SAME envelope — and like
1067
+ * the sync helper it RESOLVES on failure and never rejects. A `promise` mode on
1068
+ * the sync function was rejected on purpose: one contract with two failure
1069
+ * directions (`ok:false` vs. reject) is the bug class this envelope exists to
1070
+ * end. Callers whose contract is to throw re-raise via {@link envelopeToError},
1071
+ * which keeps the message and `err.code`.
1072
+ *
1073
+ * Shared with the sync helper: tmp/backup naming, byte count, the error
1074
+ * envelope and the partial-adapter refusal. The five-step flow itself is
1075
+ * spelled twice — sync calls and awaited calls cannot share one body without a
1076
+ * generator driver, which would put an indirection on the hook-hot sync path
1077
+ * for ~15 lines of saved control flow.
1078
+ *
1079
+ * Backup differs in one detail: it copies and treats `ENOENT` as "first write,
1080
+ * nothing to snapshot" instead of probing `existsSync` first — the same result
1081
+ * without a check-then-act window.
1082
+ *
1083
+ * @param {string} filePath Target path; parent dirs created with mkdir -p semantics.
1084
+ * @param {string|Buffer} body Bytes to write, verbatim. Never inspected.
1085
+ * @param {object} [opts]
1086
+ * @param {BufferEncoding} [opts.encoding='utf8'] Encoding for a string `body`.
1087
+ * @param {boolean} [opts.backup=false] Snapshot the existing file to `.bak-<ISO>` first.
1088
+ * @param {string} [opts.tmpPrefix='.tmp'] Tmp-file prefix (callers pick their domain prefix).
1089
+ * @param {Date} [opts.now] Clock seam for the backup stamp (tests).
1090
+ * @param {{ mkdir?: Function, writeFile?: Function, rename?: Function,
1091
+ * copyFile?: Function, unlink?: Function }} [opts.fs]
1092
+ * Injectable `fs/promises`-shaped object (tests). Omitted methods fall back to
1093
+ * `node:fs/promises` — except `copyFile` under `backup: true`, which fails closed.
1094
+ * @returns {Promise<{ ok: true, path: string, bytes: number, backupPath: string|null }
1095
+ * | { ok: false, reason: 'fs-error', error: string, code: string|null }>}
1096
+ */
1097
+ export async function atomicWriteText(filePath, body, opts = {}) {
1098
+ const {
1099
+ encoding = 'utf8',
1100
+ backup = false,
1101
+ tmpPrefix = '.tmp',
1102
+ now = new Date(),
1103
+ fs: injectedFs,
1104
+ } = opts;
1105
+
1106
+ const fsMkdir = injectedFs?.mkdir ?? mkdir;
1107
+ const fsWriteFile = injectedFs?.writeFile ?? writeFile;
1108
+ const fsRename = injectedFs?.rename ?? rename;
1109
+ const fsCopyFile = injectedFs?.copyFile ?? copyFile;
1110
+ const fsUnlink = injectedFs?.unlink ?? unlink;
1111
+
1112
+ const refusal = _partialAdapterRefusal(backup, injectedFs, ['copyFile']);
1113
+ if (refusal) return refusal;
1114
+
1115
+ let tmpFile = null;
1116
+
1117
+ try {
1118
+ const dir = dirname(filePath);
1119
+ await fsMkdir(dir, { recursive: true });
1120
+
1121
+ let backupPath = null;
1122
+ if (backup) {
1123
+ const candidate = _backupPathFor(filePath, now);
1124
+ try {
1125
+ await fsCopyFile(filePath, candidate);
1126
+ backupPath = candidate;
1127
+ } catch (err) {
1128
+ if (err?.code !== 'ENOENT') throw err;
1129
+ }
1130
+ }
1131
+
1132
+ tmpFile = _tmpPathFor(dir, tmpPrefix);
1133
+ await fsWriteFile(tmpFile, body, encoding);
1134
+ await fsRename(tmpFile, filePath);
1135
+
1136
+ return { ok: true, path: filePath, bytes: _byteLength(body, encoding), backupPath };
1137
+ } catch (err) {
1138
+ if (tmpFile !== null) {
1139
+ try {
1140
+ await fsUnlink(tmpFile);
1141
+ } catch {
1142
+ // Best-effort: never let cleanup replace the error the caller needs.
1143
+ }
1144
+ }
1145
+ return _fsErrorEnvelope(err);
1146
+ }
1147
+ }
1148
+
1149
+ /**
1150
+ * Re-raise a failed {@link atomicWriteText} / {@link atomicWriteWithBackup}
1151
+ * envelope as an Error carrying the original message and `code`, for call-sites
1152
+ * whose own contract is to throw (#1032).
1153
+ *
1154
+ * @param {{ error: string, code?: string|null }} envelope
1155
+ * @returns {Error & { code?: string }}
1156
+ */
1157
+ export function envelopeToError(envelope) {
1158
+ const err = new Error(envelope.error);
1159
+ if (envelope.code) err.code = envelope.code;
1160
+ return err;
1161
+ }
1162
+
1163
+ /** @returns {{ ok: false, reason: 'fs-error', error: string, code: string|null }} */
1164
+ function _fsErrorEnvelope(err) {
1165
+ return {
1166
+ ok: false,
1167
+ reason: 'fs-error',
1168
+ error: err?.message ?? String(err),
1169
+ code: typeof err?.code === 'string' ? err.code : null,
1170
+ };
1171
+ }
1172
+
1173
+ /**
1174
+ * Backup-path fail-closed for a partial injected adapter (see the rationale in
1175
+ * {@link atomicWriteWithBackup}). Returns the refusal envelope, or null.
1176
+ */
1177
+ function _partialAdapterRefusal(backup, injectedFs, methods) {
1178
+ if (!backup || !injectedFs) return null;
1179
+ for (const method of methods) {
1180
+ if (typeof injectedFs[method] !== 'function') {
1181
+ return {
1182
+ ok: false,
1183
+ reason: 'fs-error',
1184
+ error: `partial fs adapter: ${method} required for backup`,
1185
+ code: null,
1186
+ };
1187
+ }
1067
1188
  }
1189
+ return null;
1190
+ }
1191
+
1192
+ /** `<filePath>.bak-<ISO>` with `:`/`.` → `-`, so a lexical sort is chronological. */
1193
+ function _backupPathFor(filePath, now) {
1194
+ const stamp = (now instanceof Date ? now : new Date()).toISOString().replace(/[:.]/g, '-');
1195
+ return `${filePath}.bak-${stamp}`;
1196
+ }
1197
+
1198
+ /** Sibling tmp path (same filesystem → atomic rename): `<dir>/<tmpPrefix>.<12 hex>`. */
1199
+ function _tmpPathFor(dir, tmpPrefix) {
1200
+ return path.join(dir, `${tmpPrefix}.${randomBytes(6).toString('hex')}`);
1201
+ }
1202
+
1203
+ function _byteLength(body, encoding) {
1204
+ return Buffer.isBuffer(body) ? body.length : Buffer.byteLength(String(body), encoding);
1068
1205
  }
1069
1206
 
1070
1207
  /**
@@ -48,11 +48,11 @@
48
48
  * re-derived (not re-cited) if a threshold is ever revisited.
49
49
  *
50
50
  * **Stage 0 — corpus prep (once per run).**
51
- * 1. Read through the existing funnel (`io.mjs::readLearnings` →
52
- * `schema.mjs::normalizeLearning`) so producer dialects (`files` →
53
- * `file_paths`) and type aliases (`gotcha` → `anti-pattern`) are already
54
- * canonical. The in-memory entry point re-applies {@link normalizeDialects}
55
- * so both entry points score the same shape.
51
+ * 1. Canonicalise producer dialects (`files` → `file_paths`) and type
52
+ * aliases (`gotcha` → `anti-pattern`): the caller reads through the
53
+ * existing funnel (`io.mjs::readLearnings` → `schema.mjs::normalizeLearning`),
54
+ * and the entry point re-applies {@link normalizeDialects} so raw
55
+ * in-memory records score the same shape.
56
56
  * 2. Drop expired records — they may neither seed a pool nor join one.
57
57
  * 3. Derive `learning_key` = `` `${type}/${kebab(title || subject)}` `` using
58
58
  * the SHARED {@link kebab}. A divergent kebab does not produce an ugly
@@ -184,7 +184,6 @@
184
184
  */
185
185
 
186
186
  import { tokenize } from './affinity.mjs';
187
- import { readLearnings } from './io.mjs';
188
187
  import { learningKeyOf } from './kebab.mjs';
189
188
  import { normalizeDialects } from './schema.mjs';
190
189
 
@@ -218,7 +217,7 @@ export const PATH_BOOST_EXACT = 0.05;
218
217
  export const PATH_BOOST_DIR = 0.025;
219
218
 
220
219
  /** How many leading directory segments define the directory-prefix boost. */
221
- export const DIR_PREFIX_SEGMENTS = 2;
220
+ const DIR_PREFIX_SEGMENTS = 2;
222
221
 
223
222
  /**
224
223
  * Documented ceiling for consumers (accepted failure mode 2): a pool graph walk
@@ -229,7 +228,7 @@ export const DIR_PREFIX_SEGMENTS = 2;
229
228
  export const MAX_POOL_HOPS = 2;
230
229
 
231
230
  /** Token length floor — tokens shorter than this are dropped before stoplisting. */
232
- export const MIN_TOKEN_LENGTH = 3;
231
+ const MIN_TOKEN_LENGTH = 3;
233
232
 
234
233
  /** Cap on the reported `sharedTokens` — a diagnostic for the judge, not a payload. */
235
234
  const SHARED_TOKEN_CAP = 8;
@@ -705,23 +704,3 @@ export function buildCandidatePools(records, opts = {}) {
705
704
  return emptyPools();
706
705
  }
707
706
  }
708
-
709
- /**
710
- * File entry-point: read through the existing funnel (`readLearnings` →
711
- * `normalizeLearning`, so dialects and type aliases are canonical), then pool.
712
- *
713
- * There is no second reader here on purpose — a private read path would be a
714
- * second place for the dialect normalization to drift out of.
715
- *
716
- * @param {string} filePath — absolute path to learnings.jsonl
717
- * @param {object} [opts] — everything {@link buildCandidatePools} accepts
718
- * @returns {Promise<CandidatePools>} {@link emptyPools} on a missing/unreadable file
719
- */
720
- export async function buildCandidatePoolsFromFile(filePath, opts = {}) {
721
- try {
722
- const { entries } = await readLearnings(filePath);
723
- return buildCandidatePools(entries, opts);
724
- } catch {
725
- return emptyPools();
726
- }
727
- }
@@ -262,6 +262,11 @@ export async function readLearningsSnapshot(filePath) {
262
262
  * malformed too (#1500): `normalizeLearning` turned it into a record of
263
263
  * defaults alone, and every rewrite wrote that invented record back.
264
264
  *
265
+ * A UTF-8 BOM (U+FEFF) in front of the FIRST line — an editor's save — is
266
+ * stripped before parsing, so that line is an entry, not malformed (#1506.5b).
267
+ * A BOM inside the file (line 2+) stays malformed: that is a concatenation
268
+ * artefact the drop-malformed preview reports as `bom`.
269
+ *
265
270
  * @param {string} raw
266
271
  * @returns {{entries: object[], malformed: string[], malformedLineNumbers: number[]}}
267
272
  * `malformedLineNumbers[i]` is the 1-based line of `malformed[i]` in `raw`
@@ -273,7 +278,7 @@ export function parseLearningsText(raw) {
273
278
  raw.split('\n').forEach((line, i) => {
274
279
  if (line.trim().length === 0) return;
275
280
  try {
276
- const parsed = JSON.parse(line);
281
+ const parsed = JSON.parse(i === 0 && line.charCodeAt(0) === 0xfeff ? line.slice(1) : line);
277
282
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
278
283
  throw new TypeError('not a JSON object');
279
284
  }
@@ -414,7 +419,7 @@ const BACKUP_KEEP = 3;
414
419
  * @param {string} fileName — sibling filename to classify
415
420
  * @returns {string|null} the suffix after the delimiter, or `null`
416
421
  */
417
- export function backupSuffixOf(baseName, fileName) {
422
+ function backupSuffixOf(baseName, fileName) {
418
423
  const stem = `${baseName}.bak`;
419
424
  if (!fileName.startsWith(stem)) return null;
420
425
  const delim = fileName[stem.length];
@@ -145,10 +145,7 @@ export const JUDGMENT_DECISIONS = Object.freeze([
145
145
  ]);
146
146
 
147
147
  /** Decisions that assert a relation to at least one other record. */
148
- export const RELATION_DECISIONS = Object.freeze(['refine', 'supersede', 'merge', 'contradict']);
149
-
150
- /** Decisions that assert no relation (`skip`) or no judgment (`abstain`). */
151
- export const NO_RELATION_DECISIONS = Object.freeze(['skip', 'abstain']);
148
+ const RELATION_DECISIONS = Object.freeze(['refine', 'supersede', 'merge', 'contradict']);
152
149
 
153
150
  /**
154
151
  * The eight failure modes. Closed set — the free-text `detail` field carries
@@ -174,7 +171,7 @@ export const FAILURE_MODES = Object.freeze([
174
171
  * on purpose: registering the AUQ in the SAME map as the archive writer is what
175
172
  * makes "an AUQ is a write" structural rather than a comment someone can miss.
176
173
  */
177
- export const EFFECT_BY_DECISION = Object.freeze({
174
+ const EFFECT_BY_DECISION = Object.freeze({
178
175
  skip: null,
179
176
  abstain: null,
180
177
  refine: 'refine',
@@ -191,7 +188,7 @@ export const EFFECT_BY_DECISION = Object.freeze({
191
188
  * the corpus develops genuine clusters (max pairwise similarity > ~0.4) or if
192
189
  * live judgments start naming a truncated-away record in their rationale.
193
190
  */
194
- export const DEFAULT_MAX_NEIGHBOURS = 12;
191
+ const DEFAULT_MAX_NEIGHBOURS = 12;
195
192
 
196
193
  /**
197
194
  * Judge wall-clock budget. Ceiling: 60s — a judgment pass runs at session-end
@@ -206,7 +203,7 @@ export const DEFAULT_JUDGE_TIMEOUT_MS = 60_000;
206
203
  * the judge to lean on `scope` or `host_class` would be steering it by a field
207
204
  * that is constant across the corpus.
208
205
  */
209
- export const SURFACE_HINT = [
206
+ const SURFACE_HINT = [
210
207
  'Judge the SURFACE, not the subject line.',
211
208
  '',
212
209
  'Measured 2026-08-13 over the 100 live records: `scope` is "local" in 100/100 and',
@@ -233,7 +230,7 @@ export const SURFACE_HINT = [
233
230
  * restatement drifts silently, and the failure mode of drift here is that every
234
231
  * batch voids and the feature is dead while looking wired.
235
232
  */
236
- export const JUDGMENT_OUTPUT_CONTRACT = [
233
+ const JUDGMENT_OUTPUT_CONTRACT = [
237
234
  'Answer with JSON only, exactly this shape:',
238
235
  '',
239
236
  '{"candidate_id": "<the candidate id you were given>",',
@@ -182,7 +182,7 @@ export const DEFAULT_MAX_GLOBAL = 4;
182
182
  * overlaps only, so a directory-prefix match scores 0.75 without appearing
183
183
  * there. Using it as a proxy would silently drop the strongest partial matches.
184
184
  */
185
- export const SCOPE_MATCH_MIN_PATH_SCORE = 0.25;
185
+ const SCOPE_MATCH_MIN_PATH_SCORE = 0.25;
186
186
 
187
187
  /**
188
188
  * Blend of relevance (affinity to this agent's scope) against quality
@@ -190,7 +190,7 @@ export const SCOPE_MATCH_MIN_PATH_SCORE = 0.25;
190
190
  * IS the acceptance criterion. Weight-normalized like `affinity()`, so the
191
191
  * result stays in [0,1] for any non-negative pair.
192
192
  */
193
- export const SELECT_WEIGHTS = Object.freeze({ relevanceWeight: 0.7, qualityWeight: 0.3 });
193
+ const SELECT_WEIGHTS = Object.freeze({ relevanceWeight: 0.7, qualityWeight: 0.3 });
194
194
 
195
195
  /**
196
196
  * How many active entries the file entry-point pulls before ranking.
@@ -496,7 +496,7 @@ export function renderIndexLine(entry, opts = {}) {
496
496
  * @param {{now?: Date|number, decay?: object, affinityOpts?: object}} [opts]
497
497
  * @returns {{score: number, relevance: number, quality: number, pathScore: number}}
498
498
  */
499
- export function scoreLearning(entry, scope, opts = {}) {
499
+ function scoreLearning(entry, scope, opts = {}) {
500
500
  const zero = { score: 0, relevance: 0, quality: 0, pathScore: 0 };
501
501
  if (!_isRecord(entry)) return zero;
502
502
 
@@ -73,7 +73,7 @@
73
73
  import { existsSync, statSync } from 'node:fs';
74
74
  import path from 'node:path';
75
75
 
76
- import { SCAN_CHUNK_BYTES, scanEventsBackwards } from './events.mjs';
76
+ import { SCAN_CHUNK_BYTES, eventsFilePath, scanEventsBackwards } from './events.mjs';
77
77
  import { computeReconcileNudge } from './reconcile-nudge-banner.mjs';
78
78
  import { sweepExpiredLearnings } from './learnings/expiry-sweep.mjs';
79
79
  import { shouldDispatchAutoDialectic } from './auto-dialectic.mjs';
@@ -204,7 +204,7 @@ export const EVOLVE_SCAN_BUDGET_MS = 1000;
204
204
  * `events-rotation.max-backups`), so a miss has not proven "never" either.
205
205
  */
206
206
  function readLastEvolveRun(repoRoot) {
207
- const file = path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl');
207
+ const file = eventsFilePath(repoRoot); // #1514: the repo's one ledger
208
208
  let lastAt = null;
209
209
  const scan = scanEventsBackwards({
210
210
  filePath: file,
@@ -42,11 +42,11 @@
42
42
  */
43
43
 
44
44
  import { appendFile, mkdir, readdir, rm } from 'node:fs/promises';
45
- import { writeFileSync, renameSync, readFileSync, existsSync } from 'node:fs';
45
+ import { readFileSync, existsSync } from 'node:fs';
46
46
  import path from 'node:path';
47
- import crypto from 'node:crypto';
48
47
  import { appendLearning } from '../learnings/io.mjs';
49
48
  import { validatePathInsideProject } from '../path-utils.mjs';
49
+ import { atomicWriteWithBackup } from '../io.mjs';
50
50
 
51
51
  // ---------------------------------------------------------------------------
52
52
  // Path constants (relative to repoRoot)
@@ -320,10 +320,9 @@ export async function clearProposalsJsonl({ repoRoot }) {
320
320
  // target — a concurrent hook reading proposals.jsonl mid-clear observes
321
321
  // either the pre-clear content or the fully-cleared file, never a
322
322
  // partial truncate.
323
- const tmpSuffix = crypto.randomBytes(6).toString('hex');
324
- const tmpFile = path.join(metricsDirPath, `.proposals.jsonl.tmp.${tmpSuffix}`);
325
- writeFileSync(tmpFile, '', 'utf8');
326
- renameSync(tmpFile, proposalsPath);
323
+ // Same tmp-name shape as before: `.proposals.jsonl.tmp.<12 hex>`.
324
+ const res = atomicWriteWithBackup(proposalsPath, '', { tmpPrefix: '.proposals.jsonl.tmp' });
325
+ if (!res.ok) return { cleared: false, summariesCleared: 0 };
327
326
  } catch {
328
327
  return { cleared: false, summariesCleared: 0 };
329
328
  }
@@ -16,10 +16,10 @@
16
16
 
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
- import crypto from 'node:crypto';
20
19
  import { serializeProposal } from './schema.mjs';
21
20
  import { validatePathInsideProject } from '../path-utils.mjs';
22
21
  import { tryAcquireFileLock, releaseFileLock } from '../file-lock.mjs';
22
+ import { writeJsonAtomicSync } from '../io.mjs';
23
23
 
24
24
  // ---------------------------------------------------------------------------
25
25
  // Constants
@@ -285,10 +285,8 @@ function incrementSummary(summaryPath, field) {
285
285
  ensureDir(summaryPath);
286
286
  const current = readSummary(summaryPath);
287
287
  current[field] = (current[field] ?? 0) + 1;
288
- const tmpSuffix = crypto.randomBytes(4).toString('hex');
289
- const tmpFile = `${summaryPath}.tmp.${tmpSuffix}`;
290
- fs.writeFileSync(tmpFile, JSON.stringify(current, null, 2) + '\n', 'utf8');
291
- fs.renameSync(tmpFile, summaryPath);
288
+ // Same bytes (indent 2 + trailing newline); a failure is swallowed as before.
289
+ writeJsonAtomicSync(summaryPath, current, { tmpPrefix: `.${path.basename(summaryPath)}.tmp` });
292
290
  } catch {
293
291
  /* best-effort: summary writes are non-critical */
294
292
  }
@@ -61,7 +61,9 @@ const MESSAGE_ISSUE_CAP = 5;
61
61
  *
62
62
  * `query-failed` is the residual bucket for a `gh` that ran and exited
63
63
  * non-zero for a reason that is neither missing-CLI, timeout, nor auth
64
- * (network down, repo renamed, rate limit). It is a deliberate fifth member
64
+ * (network down, repo renamed, rate limit — including GitHub's HTTP 403
65
+ * rate-limit answer, which `classifyFailure` checks before its 403 → auth
66
+ * rule). It is a deliberate fifth member
65
67
  * beyond the four originally specified: folding those into `parse-error`
66
68
  * would mislabel a network failure as malformed output, reintroducing the
67
69
  * dishonest-state class this module was built to remove.
@@ -122,6 +124,14 @@ function classifyFailure(err) {
122
124
 
123
125
  const stderr = err && typeof err === 'object' ? String(/** @type {any} */ (err).stderr ?? '') : '';
124
126
  const haystack = `${message}\n${stderr}`.toLowerCase();
127
+ // Rate limit BEFORE the auth check (#1034 item 2): GitHub answers a primary
128
+ // or secondary rate limit with HTTP 403, so the `http 403` test below would
129
+ // otherwise tell the operator to re-authenticate when waiting is the fix. A
130
+ // 403 without rate-limit wording (expired token, missing scope) stays
131
+ // `auth-error`, and so does an UNAUTHENTICATED rate limit, whose fix is to
132
+ // log in. `abuse detection` is GitHub's older wording for the secondary limit.
133
+ const rateLimited = haystack.includes('rate limit') || haystack.includes('abuse detection');
134
+ if (rateLimited && !haystack.includes('authenticated requests get a higher')) return 'query-failed';
125
135
  if (
126
136
  haystack.includes('gh auth login') ||
127
137
  haystack.includes('not logged in') ||
@@ -0,0 +1,48 @@
1
+ /** Shell-free command launching for native Windows and Unix hosts. */
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ /**
7
+ * Windows cannot spawn npm's .cmd shim without cmd.exe. Run its JavaScript
8
+ * entrypoint with this Node instead, preserving each argument literally.
9
+ * Supports Node's bundled npm (including nvm-windows) and npm on PATH.
10
+ * Other commands and Unix retain their normal executable lookup.
11
+ * @param {string} cmd
12
+ * @param {string[]} args
13
+ * @param {object} [context] Host overrides for platform regression tests.
14
+ * @returns {{cmd: string, args: string[]}}
15
+ */
16
+ export function resolveNativeCommand(cmd, args, {
17
+ platform = process.platform, execPath = process.execPath, env = process.env, exists = existsSync,
18
+ } = {}) {
19
+ if (platform !== 'win32' || !/^npm(?:\.cmd)?$/i.test(cmd)) return { cmd, args };
20
+ const winPath = path.win32;
21
+ const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path');
22
+ const dirs = [winPath.dirname(execPath), ...(env[pathKey] || '').split(';')];
23
+ for (const dir of dirs) {
24
+ // Ignore empty/relative PATH entries: never select npm from the working repo.
25
+ const absolute = dir.replace(/^"(.*)"$/, '$1');
26
+ if (!winPath.isAbsolute(absolute)) continue;
27
+ const cli = winPath.join(absolute, 'node_modules', 'npm', 'bin', 'npm-cli.js');
28
+ if (exists(cli)) return { cmd: execPath, args: [cli, ...args] };
29
+ }
30
+ throw Object.assign(new Error('Cannot locate npm-cli.js beside Node or on PATH; install npm with Node.js.'), { code: 'ENOENT' });
31
+ }
32
+
33
+ /** Like spawnSync, with shell-free Windows npm support and spawn-style errors. */
34
+ export function spawnNativeSync(cmd, args, options = {}) {
35
+ try {
36
+ const command = resolveNativeCommand(cmd, args, { env: options.env ?? process.env });
37
+ return spawnSync(command.cmd, command.args, { ...options, shell: false });
38
+ } catch (error) {
39
+ return { error, status: null, stdout: '', stderr: '' };
40
+ }
41
+ }
42
+
43
+ /** Test for a command using the host's executable locator, without a shell. */
44
+ export function commandOnPath(bin) {
45
+ return spawnSync(process.platform === 'win32' ? 'where.exe' : 'which', [bin], {
46
+ encoding: 'utf8', timeout: 10_000, shell: false,
47
+ }).status === 0;
48
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import {
11
+ GATE_PROCESS_LEDGER_RELPATH,
11
12
  killProcessGroup,
12
13
  pruneGateProcessLedger,
13
14
  readGateProcessLedger,
@@ -25,9 +26,16 @@ import {
25
26
  } from './reaper-audit.mjs';
26
27
  import { decideReapCandidates, verifyGroupMemberIdentity } from './reaper-decide.mjs';
27
28
 
28
- /** Name const for the one event this module emits. @see docs/events-schema.md */
29
+ /** Name const for the scan-summary event. @see docs/events-schema.md */
29
30
  export const REAPER_SCAN_EVENT = 'orchestrator.reaper.scan_completed';
30
31
 
32
+ /**
33
+ * Name const for the degraded-scan event (#1505 point 5a, owner decision
34
+ * 2026-10-03). An unreadable process register disables the scan; this record
35
+ * is what makes that countable (HR-105). @see docs/events-schema.md
36
+ */
37
+ export const LEDGER_UNREADABLE_EVENT = 'orchestrator.ledger.unreadable';
38
+
31
39
  /** Default own-session-id reader. Lazily imported so the static import closure
32
40
  * of this module stays small — it is destined for a hot-path hook (#1432).
33
41
  * @param {string} repoRoot @returns {Promise<string|null>} */
@@ -237,16 +245,34 @@ export async function runOrphanScan({
237
245
 
238
246
  const { rows, malformed } = parsePsSnapshotDetailed(text);
239
247
 
248
+ // HR-105 (#1505 point 5a): an unreadable register switches the scan off, and
249
+ // the detached hook child ignores the exit 2 that says so — without a record
250
+ // the instrument is off with no trace. Once per degraded scan; the scan runs
251
+ // throttled, so this cannot become a per-fire signal (HR-101). Repo-relative
252
+ // path only, never a host path. A failing emit never fails the scan.
253
+ const unreadable = async (reason) => {
254
+ try {
255
+ await d.emitEvent(LEDGER_UNREADABLE_EVENT, {
256
+ path: GATE_PROCESS_LEDGER_RELPATH,
257
+ reason,
258
+ consumer: 'orphan-reaper',
259
+ }, { repoRoot });
260
+ } catch {
261
+ /* telemetry that can fail a scan would fail the hook the scan runs in */
262
+ }
263
+ return empty('ledger-unreadable', malformed);
264
+ };
265
+
240
266
  let ledger;
241
267
  try {
242
268
  ledger = d.readLedger(repoRoot, { nowMs: startedAt });
243
269
  } catch {
244
- return empty('ledger-unreadable', malformed);
270
+ return unreadable('reader-threw');
245
271
  }
246
272
  // The default reader never throws: a refused or unreadable ledger comes back
247
273
  // as no records with `state: 'unreadable'` (#1498). Read as "no register
248
274
  // hits" it ran a clean-looking scan that measured nothing.
249
- if (ledger?.state === 'unreadable') return empty('ledger-unreadable', malformed);
275
+ if (ledger?.state === 'unreadable') return unreadable('refused');
250
276
 
251
277
  let ownSessionId = null;
252
278
  try {