session-orchestrator 5.3.0 → 5.5.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -36,11 +36,17 @@
36
36
  * no key-order churn.
37
37
  *
38
38
  * ── PROVENANCE ───────────────────────────────────────────────────────────────
39
- * Every repaired record carries `_backfill_source` + `_backfill_incomplete_fields`
40
- * (both already schema-accepted by `_validateOptionalFields`, so no schema bump
41
- * is needed). The incomplete-fields list is the EXACT set of fields this record
42
- * had defaulted, which is what lets a downstream consumer tell a measured zero
43
- * apart from a repaired-to-zero.
39
+ * Every repaired record carries `_repair_source` (`REPAIR_SOURCE`) +
40
+ * `_backfill_incomplete_fields`; `_backfill_source` is set to `REPAIR_SOURCE`
41
+ * only when the record had no origin of its own. An existing origin
42
+ * (`state-md-completed`, `events-jsonl`, `abandoned`) is KEPT, because
43
+ * `sessions-canonical.mjs`'s stub guard reads it: overwriting it promoted a
44
+ * repaired zero-work stub to a real record that shadowed the real one (#1443
45
+ * F2). `_backfill_*` is schema-accepted by `_validateOptionalFields`, and
46
+ * `validateSession` ignores undeclared keys (as it does the `_<field>_raw`
47
+ * sidecars), so no schema bump is needed. The incomplete-fields list is the
48
+ * EXACT set of fields this record had defaulted, which is what lets a
49
+ * downstream consumer tell a measured zero apart from a repaired-to-zero.
44
50
  *
45
51
  * It also never DISCARDS what it replaces: any field whose present value is
46
52
  * overwritten by a default is first copied to a `_<field>_raw` sidecar
@@ -70,7 +76,10 @@ import { checkSessionsIntegrity as defaultCheckIntegrity } from './sessions-inte
70
76
  // Constants
71
77
  // ---------------------------------------------------------------------------
72
78
 
73
- /** Value written to `_backfill_source` on every record this module repairs. */
79
+ /**
80
+ * Value written to `_repair_source` on every record this module repairs, and to
81
+ * `_backfill_source` only on a record that had no origin there (#1443 F2).
82
+ */
74
83
  export const REPAIR_SOURCE = 'repair-invalid-sessions/1004';
75
84
 
76
85
  /** Ledger path (relative to repoRoot) that `checkSessionsIntegrity` inspects. */
@@ -98,6 +107,28 @@ const INCOMPLETE_FIELD_ORDER = Object.freeze([
98
107
  'total_agents',
99
108
  'total_files_changed',
100
109
  ]);
110
+ // `timestamp_precision` (#1442) deliberately adds NOTHING to the list above: a
111
+ // truncated timestamp is still a measurement (exact to the millisecond), not a
112
+ // default. Listing it would make consumers discard a real value —
113
+ // `sessions-staleness-banner.mjs` returns null for any timestamp named in
114
+ // `_backfill_incomplete_fields`, `session-close-backfill.mjs` keys on
115
+ // `includes('started_at')`. The sub-millisecond original survives in the
116
+ // `_<field>_raw` sidecar instead.
117
+
118
+ /**
119
+ * Timestamp fields `validateSession` holds to `ISO_8601_UTC_MS_RE`
120
+ * (`session-schema/validator.mjs`, not exported): exactly three fractional
121
+ * digits or none.
122
+ */
123
+ const MS_PRECISION_FIELDS = Object.freeze(['started_at', 'completed_at', 'lease_acquired_at']);
124
+
125
+ /**
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.
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$/;
101
132
 
102
133
  // ---------------------------------------------------------------------------
103
134
  // Helpers
@@ -112,6 +143,20 @@ function isCount(v) {
112
143
  return typeof v === 'number' && Number.isFinite(v) && v >= 0;
113
144
  }
114
145
 
146
+ /**
147
+ * Cut a sub-millisecond ISO-8601 UTC timestamp to milliseconds. TRUNCATES,
148
+ * never rounds: `.9999Z` must not roll into the next second, and truncation is
149
+ * monotone, so `completed_at >= started_at` survives on every pair it held on.
150
+ *
151
+ * @param {unknown} value
152
+ * @returns {string|null} the truncated value, or null when there is nothing to cut
153
+ */
154
+ function truncateToMs(value) {
155
+ if (typeof value !== 'string') return null;
156
+ const m = ISO_UTC_SUB_MS_RE.exec(value);
157
+ return m ? `${m[1]}Z` : null;
158
+ }
159
+
115
160
  function orderIncompleteFields(fields) {
116
161
  const rank = (f) => {
117
162
  const i = INCOMPLETE_FIELD_ORDER.indexOf(f);
@@ -321,6 +366,19 @@ export function repairRecord(record) {
321
366
  incomplete.add('total_files_changed');
322
367
  }
323
368
 
369
+ // -- timestamp precision (#1442) -------------------------------------------
370
+ // Runs BEFORE the completed_at default below, because that default copies
371
+ // `started_at` — a microsecond `started_at` would otherwise be copied into
372
+ // `completed_at` and spread the defect instead of fixing it.
373
+ let precisionCut = false;
374
+ for (const key of MS_PRECISION_FIELDS) {
375
+ const truncated = truncateToMs(out[key]);
376
+ if (truncated === null) continue;
377
+ preserveRaw(out, key, record[key], rescued);
378
+ out[key] = truncated;
379
+ precisionCut = true;
380
+ }
381
+
324
382
  // -- completed_at ---------------------------------------------------------
325
383
  // Legacy `ended_at` is the same fact under its old name (emit-session.mjs
326
384
  // aliasLegacyEndedAt precedent) — prefer it when parseable and monotonic,
@@ -332,13 +390,19 @@ export function repairRecord(record) {
332
390
  preserveRaw(out, 'completed_at', record.completed_at, rescued);
333
391
  const endedMs = typeof out.ended_at === 'string' ? Date.parse(out.ended_at) : NaN;
334
392
  const startedMs = Date.parse(out.started_at);
335
- out.completed_at =
336
- Number.isFinite(endedMs) && Number.isFinite(startedMs) && endedMs >= startedMs
337
- ? out.ended_at
338
- : out.started_at;
393
+ if (Number.isFinite(endedMs) && Number.isFinite(startedMs) && endedMs >= startedMs) {
394
+ // The COPY is cut to milliseconds; `ended_at` itself is not a validated
395
+ // field and keeps its original bytes, so nothing needs a sidecar here.
396
+ const endedTruncated = truncateToMs(out.ended_at);
397
+ if (endedTruncated !== null) precisionCut = true;
398
+ out.completed_at = endedTruncated ?? out.ended_at;
399
+ } else {
400
+ out.completed_at = out.started_at;
401
+ }
339
402
  defects.push('completed_at_missing');
340
403
  incomplete.add('completed_at');
341
404
  }
405
+ if (precisionCut) defects.push('timestamp_precision');
342
406
 
343
407
  if (defects.length === 0) {
344
408
  return { record, defects: [], incompleteFields: [], changed: false };
@@ -349,7 +413,14 @@ export function repairRecord(record) {
349
413
  ? record._backfill_incomplete_fields.filter((f) => typeof f === 'string')
350
414
  : [];
351
415
  const merged = orderIncompleteFields(incomplete);
352
- out._backfill_source = REPAIR_SOURCE;
416
+ // The repair is recorded in its own field; `_backfill_source` keeps the
417
+ // record's ORIGIN. Overwriting a synthesised origin ('state-md-completed',
418
+ // 'events-jsonl', 'abandoned') made a repaired zero-work stub read as a real
419
+ // record, and it shadowed the real one for its id (#1443 F2).
420
+ out._repair_source = REPAIR_SOURCE;
421
+ if (typeof record._backfill_source !== 'string' || record._backfill_source.length === 0) {
422
+ out._backfill_source = REPAIR_SOURCE;
423
+ }
353
424
  out._backfill_incomplete_fields = [...new Set([...prior, ...merged])];
354
425
 
355
426
  return { record: out, defects, incompleteFields: merged, changed: true };
@@ -487,6 +558,11 @@ export function repairText(raw, deps = {}) {
487
558
  repaired,
488
559
  // Projected: every line that was invalid and did NOT get repaired.
489
560
  invalid_after: invalidBefore - repaired,
561
+ // Lines with no defensible repair (`repairLine` status `error`). They are
562
+ // emitted byte-identically and are EXPECTED to stay invalid after a
563
+ // write — `verifyWritten` tolerates exactly these line numbers (#1442).
564
+ // Same population as `errors`, which carries the per-line detail.
565
+ unrepairable: errors.length,
490
566
  duplicate_ids_observed: duplicates,
491
567
  defects_by_class: defectsByClass,
492
568
  errors,
@@ -507,9 +583,32 @@ export function repairText(raw, deps = {}) {
507
583
  * any other target it is reported as skipped rather than quietly measuring the
508
584
  * wrong file.
509
585
  *
510
- * @returns {{ok: boolean, invalid_after: number, invalid_lines: object[], integrity: string|object}}
586
+ * ── WHAT "OK" MEANS (#1442) ──────────────────────────────────────────────────
587
+ * Not "zero invalid lines". A ledger that carries lines this module has no
588
+ * defensible repair for could never pass that bar, so one unrepairable record
589
+ * rolled back every repair beside it (all-or-nothing). `ok` now means NO
590
+ * WORSENING and ALL REPAIRABLE VALID:
591
+ * - every invalid line in the written file is one of `expected.unrepairableLines`
592
+ * (so every repaired AND every already-valid line validates), and
593
+ * - `invalid_after <= expected.invalidBefore`, and
594
+ * - the integrity probe flags no line it did not already flag in
595
+ * `expected.integrityBefore` (compared by line number — the repair never
596
+ * adds or removes lines, so positions are stable across the write).
597
+ * With no `expected`, the defaults reproduce the old strict bar: no invalid
598
+ * line tolerated, and a clean pre-write integrity baseline.
599
+ *
600
+ * NAMED CEILING (BV-004): integrity is compared per line, not per reason — a
601
+ * line flagged before AND after for a DIFFERENT reason is not "worse". The
602
+ * per-line `validateSession` pass above still catches it for the schema half.
603
+ *
604
+ * @param {object} args
605
+ * @param {object} [args.expected]
606
+ * @param {number[]} [args.expected.unrepairableLines] 1-based lines allowed to stay invalid
607
+ * @param {number} [args.expected.invalidBefore] invalid count before the write
608
+ * @param {object|null} [args.expected.integrityBefore] pre-write `checkIntegrity` banner (null = clean)
609
+ * @returns {{ok: boolean, invalid_after: number, invalid_lines: object[], unexpected_invalid: object[], integrity: string|object, integrity_worsened: boolean}}
511
610
  */
512
- export function verifyWritten({ file, repoRoot, deps = {} }) {
611
+ export function verifyWritten({ file, repoRoot, deps = {}, expected = {} }) {
513
612
  const {
514
613
  readFileSync = fs.readFileSync,
515
614
  validateSession = defaultValidateSession,
@@ -539,17 +638,50 @@ export function verifyWritten({ file, repoRoot, deps = {} }) {
539
638
  }
540
639
  }
541
640
 
641
+ const { unrepairableLines = [], invalidBefore = 0, integrityBefore = null } = expected;
642
+ const tolerated = new Set(unrepairableLines);
643
+ const unexpectedInvalid = invalidLines.filter((l) => !tolerated.has(l.line));
644
+
542
645
  let integrity = 'skipped-not-canonical-path';
543
- if (typeof repoRoot === 'string' && repoRoot.length > 0) {
544
- const canonical = path.join(repoRoot, CANONICAL_LEDGER_REL);
545
- if (path.resolve(file) === path.resolve(canonical)) {
546
- const banner = checkIntegrity({ repoRoot });
547
- integrity = banner === null ? 'clean' : banner;
548
- }
646
+ let worsened = false;
647
+ if (isCanonicalLedger(file, repoRoot)) {
648
+ const banner = checkIntegrity({ repoRoot });
649
+ integrity = banner === null ? 'clean' : banner;
650
+ worsened = integrityWorsened(integrityBefore, banner);
549
651
  }
550
652
 
551
- const ok = invalidLines.length === 0 && (integrity === 'clean' || integrity === 'skipped-not-canonical-path');
552
- return { ok, invalid_after: invalidLines.length, invalid_lines: invalidLines, integrity };
653
+ const ok = unexpectedInvalid.length === 0 && invalidLines.length <= invalidBefore && !worsened;
654
+ return {
655
+ ok,
656
+ invalid_after: invalidLines.length,
657
+ invalid_lines: invalidLines,
658
+ unexpected_invalid: unexpectedInvalid,
659
+ integrity,
660
+ integrity_worsened: worsened,
661
+ };
662
+ }
663
+
664
+ /** True when `file` IS `<repoRoot>/.orchestrator/metrics/sessions.jsonl` — the only file `checkIntegrity` reads. */
665
+ function isCanonicalLedger(file, repoRoot) {
666
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) return false;
667
+ return path.resolve(file) === path.resolve(path.join(repoRoot, CANONICAL_LEDGER_REL));
668
+ }
669
+
670
+ /**
671
+ * Did the integrity probe flag a line after the write that it did not flag
672
+ * before? Fail-safe on an unreadable banner shape: a non-null banner without
673
+ * its `schemaInvalid` / `mirrorSkipped` arrays counts as worse.
674
+ */
675
+ function integrityWorsened(before, after) {
676
+ if (after === null || after === undefined) return false;
677
+ for (const key of ['schemaInvalid', 'mirrorSkipped']) {
678
+ const now = after && Array.isArray(after[key]) ? after[key] : null;
679
+ if (now === null) return true;
680
+ const prior = before && Array.isArray(before[key]) ? before[key] : [];
681
+ const priorLines = new Set(prior.map((e) => e?.line));
682
+ if (now.some((e) => !priorLines.has(e?.line))) return true;
683
+ }
684
+ return false;
553
685
  }
554
686
 
555
687
  /**
@@ -560,7 +692,10 @@ export function verifyWritten({ file, repoRoot, deps = {} }) {
560
692
  * `<file>.tmp-<pid>` in the same directory and renames it over the target
561
693
  * (atomic within a filesystem), then re-verifies the written file. A failed
562
694
  * verification restores the backup byte-identically and reports `ok: false` —
563
- * the caller maps that to exit 3.
695
+ * the caller maps that to exit 3. Lines with no defensible repair are kept
696
+ * byte-identical and counted in `summary.unrepairable`; they are NOT a
697
+ * verification failure (see `verifyWritten` § WHAT "OK" MEANS) — the caller
698
+ * maps an ok run with an unrepairable remainder to exit 4.
564
699
  *
565
700
  * Never throws for a defective RECORD (those land in `summary.errors`); a
566
701
  * genuine I/O failure DOES throw and is the caller's exit-2 case.
@@ -584,6 +719,8 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
584
719
  existsSync = fs.existsSync,
585
720
  } = deps;
586
721
 
722
+ const { checkIntegrity = defaultCheckIntegrity } = deps;
723
+
587
724
  const raw = readFileSync(file, 'utf8');
588
725
  const { text, summary: base } = repairText(raw, deps);
589
726
 
@@ -594,10 +731,17 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
594
731
  backup_path: null,
595
732
  post_verify: null,
596
733
  ok: true,
734
+ restored: false,
597
735
  };
598
736
 
599
737
  if (!apply) return summary;
600
738
 
739
+ // -- integrity baseline (#1442) --------------------------------------------
740
+ // Measured on the ORIGINAL bytes, before anything is written, so the
741
+ // post-write probe can be judged "not worse" instead of "perfectly clean" —
742
+ // unrepairable lines keep the banner non-null on both sides by construction.
743
+ const integrityBefore = isCanonicalLedger(file, repoRoot) ? checkIntegrity({ repoRoot }) : null;
744
+
601
745
  // -- backup FIRST (unconditional unless explicitly opted out) --------------
602
746
  let backupPath = null;
603
747
  if (backup) {
@@ -621,14 +765,27 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
621
765
  }
622
766
 
623
767
  // -- post-verification ----------------------------------------------------
624
- const verdict = verifyWritten({ file, repoRoot, deps });
768
+ const verdict = verifyWritten({
769
+ file,
770
+ repoRoot,
771
+ deps,
772
+ expected: {
773
+ unrepairableLines: base.errors.map((e) => e.line),
774
+ invalidBefore: base.invalid_before,
775
+ integrityBefore,
776
+ },
777
+ });
625
778
  summary.post_verify = verdict;
626
779
  summary.invalid_after = verdict.invalid_after;
627
780
 
628
781
  if (!verdict.ok) {
629
- // Restore byte-identically. The backup is preferred (it is the on-disk
630
- // artefact an operator can inspect); `raw` is the in-memory fallback for
631
- // `--no-backup`, and both are the same bytes.
782
+ // Restore byte-identically — ONLY for a repaired (or previously valid) line
783
+ // that fails verification, or an integrity probe that got worse. An
784
+ // unrepairable remainder alone is not a failure (#1442): those lines were
785
+ // emitted byte-identically and keep the ledger exactly as broken as it was.
786
+ // The backup is preferred (it is the on-disk artefact an operator can
787
+ // inspect); `raw` is the in-memory fallback for `--no-backup`, and both are
788
+ // the same bytes.
632
789
  if (backupPath) {
633
790
  copyFileSync(backupPath, file);
634
791
  } else {
@@ -17,16 +17,20 @@
17
17
  * {
18
18
  * session_id, pid, platform, repo_path_hash, repo_name, branch,
19
19
  * started_at, last_heartbeat, status ('active'|'wave'|'idle'),
20
- * current_wave, host_class, mode (Epic #583 W2-I3)
20
+ * current_wave, host_class, mode (Epic #583 W2-I3),
21
+ * role ('navigator'|null, optional, #1462 — display only, never ownership;
22
+ * set by heartbeat(patch.role), NOT by registerSelf)
21
23
  * }
22
24
  *
23
25
  * Schema v2 (Epic #583, W2-I3): `mode` field added so the exclusivity-matrix
24
26
  * can classify cross-repo registry entries correctly. Without it, every
25
27
  * registry-sourced peer was bucketed as `mode='session'` → classifyMode threw
26
28
  * → fell back to `parallel-ok`, silently bypassing the exclusivity matrix
27
- * for cross-repo entries (D5 from Epic #583 audit). The field is optional
28
- * on read for back-compat with v1 entries (defaults to null) — retained
29
- * deliberately, see `skills/_shared/state-ownership.md` § Schema v1 Sunset.
29
+ * for cross-repo entries (D5 from Epic #583 audit). `registerSelf()` writes
30
+ * `mode: null` when the caller passes none; on read an entry WITHOUT the key
31
+ * is still accepted — a deliberate fail-open for peer visibility (see
32
+ * `_validEntry`), not v1 back-compat, which ended with the #595 sunset
33
+ * (2026-10-02). See `skills/_shared/state-ownership.md` § Schema v1 Sunset.
30
34
  */
31
35
 
32
36
  import path from 'node:path';
@@ -38,6 +42,7 @@ import { appendFileSync, mkdirSync } from 'node:fs';
38
42
 
39
43
  import { utcTimestamp, appendJsonl } from './common.mjs';
40
44
  import { resolvePrivateConfigDir } from './config/private-config-dir.mjs';
45
+ import { FUTURE_HEARTBEAT_TOLERANCE_MS } from './session-lock.mjs';
41
46
 
42
47
  // ---------------------------------------------------------------------------
43
48
  // Paths
@@ -129,26 +134,53 @@ function _validEntry(obj) {
129
134
  || typeof obj.started_at !== 'string') {
130
135
  return false;
131
136
  }
132
- // Schema v2 (Epic #583): `mode` is optional. When present it MUST be a string
133
- // (no number / object / array smuggling). When absent (v1 entry), it is
134
- // accepted — back-compat with pre-#583 registry files.
135
- //
136
- // RETAINED, not forgotten (#595, re-verified 2026-08-15): zero mode-less
137
- // entries exist on this host, but rejecting one would make a LIVE peer
138
- // invisible to the exclusivity matrix — a strict weakening of
139
- // parallel-session detection for zero functional gain (an absent mode
140
- // already degrades to the `parallel-ok` bucket). See
137
+ // `mode`: when present it MUST be null or a string (no number / object /
138
+ // array smuggling). When ABSENT the entry is still accepted — a deliberate
139
+ // fail-open for peer visibility, not schema-v1 compat. No writer omits the
140
+ // key (`registerSelf()` writes `mode: null` at worst), so a mode-less entry
141
+ // is foreign or damaged; but it still carries a heartbeat, and rejecting it
142
+ // would drop a possibly LIVE peer from `readRegistry()` / `detectPeers()`,
143
+ // hiding it from parallel-session detection. Accepting it costs nothing:
144
+ // `acquire()` already buckets an unclassifiable peer mode as `parallel-ok`.
145
+ // Decided at the #595 sunset (2026-10-02), see
141
146
  // `skills/_shared/state-ownership.md` § Schema v1 Sunset.
142
147
  if ('mode' in obj && obj.mode !== null && typeof obj.mode !== 'string') {
143
148
  return false;
144
149
  }
150
+ // #1462: `role` is optional and a DISPLAY field only — the registry shows who
151
+ // claims to be navigator, it never decides it. The authoritative claim is the
152
+ // navigator lease under `~/.config/navigator/leases/navigator.json`, and
153
+ // classifyMode ignores `role`. When present it MUST be null or a string (no
154
+ // number / object smuggling); absent is accepted, like an absent `mode`.
155
+ if ('role' in obj && obj.role !== null && typeof obj.role !== 'string') {
156
+ return false;
157
+ }
145
158
  return true;
146
159
  }
147
160
 
161
+ /**
162
+ * True when `isoTimestamp` lies further in the future than the lock module's
163
+ * {@link FUTURE_HEARTBEAT_TOLERANCE_MS} (#1505.1). Such a heartbeat was not
164
+ * written by a live writer on this clock — a hand edit or a clock stepped
165
+ * backwards — so it measures nothing.
166
+ */
167
+ function _isFutureStamp(isoTimestamp, now) {
168
+ const t = Date.parse(isoTimestamp);
169
+ return !Number.isNaN(t) && t > now + FUTURE_HEARTBEAT_TOLERANCE_MS;
170
+ }
171
+
172
+ /**
173
+ * Minutes since `isoTimestamp`. `Infinity` — never fresh — when it is
174
+ * unparseable or stamped beyond the future tolerance (#1505.1: `now - t` went
175
+ * negative, stayed under every freshness window, and kept the entry "fresh"
176
+ * forever, exactly the flaw `isLockLive()` closed in #1494). Within the
177
+ * tolerance the age clamps to 0 instead of going negative.
178
+ */
148
179
  function _ageMinutes(isoTimestamp, now = Date.now()) {
149
180
  const t = Date.parse(isoTimestamp);
150
181
  if (Number.isNaN(t)) return Infinity;
151
- return (now - t) / 60_000;
182
+ if (_isFutureStamp(isoTimestamp, now)) return Infinity;
183
+ return Math.max(0, now - t) / 60_000;
152
184
  }
153
185
 
154
186
  // ---------------------------------------------------------------------------
@@ -196,7 +228,7 @@ export function logSweepEvent({ event, session_id, error }) {
196
228
  * @param {string} [opts.mode] — session mode (e.g., 'deep', 'feature', 'housekeeping').
197
229
  * Schema v2 (Epic #583, W2-I3): when present, propagated into the entry so
198
230
  * discovery + exclusivity-matrix classification work for cross-repo peers.
199
- * Default: `null` (back-compat with v1 entries).
231
+ * Default: `null` (mode unknown — classified `parallel-ok` by `acquire()`).
200
232
  * @returns {Promise<object>} the written entry
201
233
  */
202
234
  export async function registerSelf(opts) {
@@ -236,11 +268,25 @@ export async function registerSelf(opts) {
236
268
  return entry;
237
269
  }
238
270
 
271
+ /** Roles `heartbeat(id, { role })` accepts besides `null` (#1462). */
272
+ export const REGISTRY_ROLES = Object.freeze(['navigator']);
273
+
239
274
  /**
240
275
  * Refresh last_heartbeat on the existing entry (and optionally status /
241
- * current_wave). No-op if the entry is missing.
276
+ * current_wave / role). No-op if the entry is missing.
277
+ *
278
+ * `patch.role`: `undefined` keeps the stored role, `null` clears it, a member
279
+ * of `REGISTRY_ROLES` sets it. Anything else throws before the file is touched.
280
+ *
281
+ * @param {string} sessionId
282
+ * @param {{ status?: string, currentWave?: number, role?: string|null }} [patch]
283
+ * @returns {Promise<object|null>} the updated entry, or null if none exists.
284
+ * @throws {TypeError} when `patch.role` is neither undefined, null, nor a REGISTRY_ROLES member.
242
285
  */
243
286
  export async function heartbeat(sessionId, patch = {}) {
287
+ if (patch.role !== undefined && patch.role !== null && !REGISTRY_ROLES.includes(patch.role)) {
288
+ throw new TypeError('heartbeat: role must be "navigator" or null');
289
+ }
244
290
  const file = entryPath(sessionId);
245
291
  const existing = await _readJsonSafe(file);
246
292
  if (!_validEntry(existing)) return null;
@@ -249,6 +295,7 @@ export async function heartbeat(sessionId, patch = {}) {
249
295
  last_heartbeat: utcTimestamp(),
250
296
  ...(patch.status !== undefined ? { status: patch.status } : {}),
251
297
  ...(patch.currentWave !== undefined ? { current_wave: patch.currentWave } : {}),
298
+ ...(patch.role !== undefined ? { role: patch.role } : {}),
252
299
  };
253
300
  await _writeJsonAtomic(file, updated);
254
301
  return updated;
@@ -328,12 +375,15 @@ export async function sweepZombies({ thresholdMin = 60, now = Date.now() } = {})
328
375
  const parsed = await _readJsonSafe(full);
329
376
  const valid = _validEntry(parsed);
330
377
  let age;
331
- if (valid) {
378
+ if (valid && !_isFutureStamp(parsed.last_heartbeat, now)) {
332
379
  age = _ageMinutes(parsed.last_heartbeat, now);
333
380
  } else {
334
381
  // Malformed files can be fresh semantic-session claim files. Use mtime so
335
382
  // a concurrent SessionStart claim is not swept before registerSelf()
336
383
  // overwrites it with the real heartbeat entry.
384
+ // #1505.1: a future-stamped heartbeat measures no age either — `now - t`
385
+ // was negative, never over the threshold, so it was never swept. The
386
+ // mtime is when it was last written: a live heartbeat() keeps it young.
337
387
  try {
338
388
  const info = await fs.stat(full);
339
389
  age = (now - info.mtimeMs) / 60_000;
@@ -219,11 +219,37 @@ export const OPTIONAL_FIELDS = Object.freeze([
219
219
  // #1339 P8 — HEAD sha at session start. Gives `/evolve analyze` an
220
220
  // attributable commit range instead of a time window that also catches
221
221
  // parallel sessions' commits. Absent = not measured. NOT guaranteed to be a
222
- // full sha: scripts/emit-session.mjs adopts STATE.md `session-start-ref`
223
- // only when it is a full 40/64-hex sha, but an explicit key already on the
224
- // record wins unchecked (even `null`), and every record before that
225
- // derivation was hand-composed — 19 with a 40-char and 9 with a 7-char
222
+ // full sha: scripts/emit-session.mjs now writes only a full 40/64-hex sha or
223
+ // an explicit `null` (a derived ref is adopted only in full form, and an
224
+ // explicit short ref on the record is dropped with a WARN), but every record
225
+ // before that derivation was hand-composed — 19 with a 40-char and 9 with a 7-char
226
226
  // value (measured 2026-09-19 @ 8f6ac022, jq over sessions.jsonl).
227
227
  // Consumers must accept a short sha or `null`, never assume a full one.
228
228
  'session_start_ref',
229
+ // #1457 point 7 — four fields that were written and (two of them) shape-
230
+ // checked while appearing in NEITHER list, the same "status only inferable
231
+ // from an `if`" class as `effectiveness` above (#964). Each one has a live
232
+ // writer:
233
+ // `total_tokens`, `matched_records` — merged from the #1436 subagents.jsonl
234
+ // rollup by scripts/emit-session.mjs (ROLLUP_KEYS) and produced by
235
+ // scripts/lib/session-token-rollup.mjs; validator.mjs checks both as
236
+ // non-negative integers.
237
+ // `raw_session_id` — the native harness uuid beside the semantic
238
+ // `session_id`, written by scripts/emit-session.mjs and by the SessionEnd
239
+ // backfill in scripts/lib/session-close-backfill.mjs.
240
+ // `_repair_source` — provenance marker every record repaired by
241
+ // scripts/lib/session-record-repair.mjs carries (#1443 F2), distinct from
242
+ // `_backfill_source`, which keeps the record's origin.
243
+ 'total_tokens',
244
+ 'matched_records',
245
+ 'raw_session_id',
246
+ '_repair_source',
247
+ // #1475 — how many of the session's cost-relevant subagent records were
248
+ // priced, out of how many. Merged from the rollup by scripts/emit-session.mjs;
249
+ // non-negative integers, priced ≤ total. `total_cost_usd` is omitted when
250
+ // priced < total (some agent's cost is unknown) AND when total is 0 (nothing
251
+ // to price — 1223 of 2093 ledger×session pairs, 31 fleet ledgers,
252
+ // 2026-10-01), so the counters say which of the two it is.
253
+ 'cost_records_priced',
254
+ 'cost_records_total',
229
255
  ]);
@@ -386,7 +386,9 @@ function _validateOptionalFields(entry) {
386
386
  }
387
387
  // #1244 — cache buckets, cost estimate and the token-contract marker.
388
388
  // Same non-negative-finite-or-null contract as the two totals above;
389
- // total_cost_usd is fractional and null means "unknown model", never 0.
389
+ // total_cost_usd is fractional; null means "cost unknown" — at least one
390
+ // subagent record could not be priced (#1475: unknown model, no model, or no
391
+ // tokens; see cost_records_* below) — never 0.
390
392
  for (const field of [
391
393
  'total_token_input_uncached',
392
394
  'total_token_cache_read',
@@ -417,6 +419,31 @@ function _validateOptionalFields(entry) {
417
419
  }
418
420
  }
419
421
 
422
+ // #1436 — the session-level token total and the rollup's record count, merged
423
+ // by emit-session from subagents.jsonl. Same contract as subagents_with_tokens:
424
+ // a non-negative integer, null/absent = not measured. Before this, neither was
425
+ // checked and `total_tokens: "abc"` validated — the field the autopilot token
426
+ // budget reads. #1475 adds the rollup's cost-coverage counters under the same
427
+ // contract, plus the one relation between them: priced never exceeds total.
428
+ for (const field of ['total_tokens', 'matched_records', 'cost_records_priced', 'cost_records_total']) {
429
+ const value = entry[field];
430
+ if (value !== undefined && value !== null) {
431
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
432
+ throw new ValidationError(`${field} must be a non-negative integer, got: ${value}`);
433
+ }
434
+ }
435
+ }
436
+ if (
437
+ Number.isInteger(entry.cost_records_priced) &&
438
+ Number.isInteger(entry.cost_records_total) &&
439
+ entry.cost_records_priced > entry.cost_records_total
440
+ ) {
441
+ throw new ValidationError(
442
+ `cost_records_priced must not exceed cost_records_total, got: ` +
443
+ `${entry.cost_records_priced} > ${entry.cost_records_total}`
444
+ );
445
+ }
446
+
420
447
  // Epic #724 C1 — SessionEnd close-through backfill provenance fields.
421
448
  // All additive-optional; null/absent passes without further checks so that
422
449
  // pre-#724 records (and every normally-closed record) validate cleanly.