session-orchestrator 5.4.0 → 5.6.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 (151) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/060-evolve.mdc +5 -5
  5. package/.cursor-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +115 -3
  7. package/README.md +30 -15
  8. package/docs/ci-setup.md +120 -23
  9. package/docs/components.md +7 -7
  10. package/docs/events-schema.md +17 -14
  11. package/docs/install.md +14 -2
  12. package/docs/migration-v4.md +24 -17
  13. package/docs/plugin-architecture-v3.md +1 -1
  14. package/docs/scope-collision-guard.md +19 -15
  15. package/docs/session-config-reference.md +7 -7
  16. package/docs/vault-docs-architecture.md +8 -4
  17. package/hooks/_lib/hook-import-set.json +17 -8
  18. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  19. package/hooks/config-protection.mjs +1 -1
  20. package/hooks/enforce-commands.mjs +28 -13
  21. package/hooks/enforce-scope.mjs +201 -23
  22. package/hooks/hooks-codex.json +1 -1
  23. package/hooks/hooks.json +1 -1
  24. package/hooks/on-session-start.mjs +4 -2
  25. package/hooks/on-stop.mjs +77 -171
  26. package/hooks/post-bash-write-verify.mjs +219 -24
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +41 -156
  29. package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +603 -241
  32. package/hooks/wave-scope-commit-guard.mjs +235 -73
  33. package/package.json +9 -8
  34. package/scripts/apply-session-learnings.mjs +35 -14
  35. package/scripts/archive-closed-prds.mjs +38 -11
  36. package/scripts/autopilot.mjs +8 -1
  37. package/scripts/backfill-abandoned-sessions.mjs +80 -5
  38. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  39. package/scripts/codex-install.mjs +3 -0
  40. package/scripts/cursor-install.mjs +46 -2
  41. package/scripts/emit-session.mjs +5 -3
  42. package/scripts/export-hw-learnings.mjs +18 -7
  43. package/scripts/fleet-checkin.mjs +26 -3
  44. package/scripts/lib/autopilot/loop.mjs +25 -10
  45. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  46. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  47. package/scripts/lib/common.mjs +41 -0
  48. package/scripts/lib/config/vault-integration.mjs +151 -16
  49. package/scripts/lib/config.mjs +5 -1
  50. package/scripts/lib/eval/engine.mjs +13 -0
  51. package/scripts/lib/events-retention-banner.mjs +393 -0
  52. package/scripts/lib/events-rotation.mjs +21 -6
  53. package/scripts/lib/events.mjs +51 -3
  54. package/scripts/lib/fleet-protocol.mjs +20 -2
  55. package/scripts/lib/hardening.mjs +1 -0
  56. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  57. package/scripts/lib/instruction-budget-guard.mjs +8 -0
  58. package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
  59. package/scripts/lib/learnings/io.mjs +125 -16
  60. package/scripts/lib/lock-reaper.mjs +38 -14
  61. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  62. package/scripts/lib/named-vault-resolver.mjs +48 -5
  63. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  64. package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
  65. package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
  66. package/scripts/lib/orphan-reaper/scan.mjs +4 -0
  67. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  68. package/scripts/lib/orphan-reaper.mjs +5 -1
  69. package/scripts/lib/platform.mjs +206 -2
  70. package/scripts/lib/plugin-update-banner.mjs +3 -1
  71. package/scripts/lib/process-group.mjs +83 -22
  72. package/scripts/lib/scope-baseline.mjs +116 -25
  73. package/scripts/lib/scope-echo.mjs +7 -0
  74. package/scripts/lib/scope-gate.mjs +201 -27
  75. package/scripts/lib/session-close-backfill.mjs +202 -4
  76. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  77. package/scripts/lib/session-lock-shape.mjs +6 -3
  78. package/scripts/lib/session-lock.mjs +320 -67
  79. package/scripts/lib/session-registry.mjs +41 -16
  80. package/scripts/lib/session-start-probes.mjs +242 -68
  81. package/scripts/lib/session-token-rollup.mjs +39 -34
  82. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  83. package/scripts/lib/tail-window.mjs +27 -3
  84. package/scripts/lib/telemetry/pricing.mjs +5 -2
  85. package/scripts/lib/telemetry/sync.mjs +17 -0
  86. package/scripts/lib/tests-src-ratio.mjs +77 -2
  87. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  88. package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
  89. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  90. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  91. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  92. package/scripts/lib/vault-mirror/process.mjs +350 -62
  93. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  94. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  95. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  96. package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
  97. package/scripts/lib/vault-status/board-writer.mjs +5 -1
  98. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  99. package/scripts/materialize-wave-scope.mjs +7 -4
  100. package/scripts/release.mjs +1 -0
  101. package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
  102. package/scripts/run-quality-gate.mjs +8 -5
  103. package/scripts/self-update.mjs +229 -0
  104. package/scripts/sweep-expired-learnings.mjs +560 -28
  105. package/scripts/validate-wave-scope.mjs +60 -6
  106. package/scripts/vault-backfill.mjs +50 -37
  107. package/scripts/vault-mirror.mjs +170 -33
  108. package/skills/_shared/fleet-protocol.md +74 -71
  109. package/skills/_shared/parallel-aware-auq.md +1 -1
  110. package/skills/_shared/state-ownership.md +38 -14
  111. package/skills/autopilot/SKILL.md +1 -1
  112. package/skills/bootstrap/SKILL.md +5 -3
  113. package/skills/bootstrap/_shared-template.md +96 -1
  114. package/skills/bootstrap/fast-template.md +3 -0
  115. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  116. package/skills/bootstrap/standard-template.md +2 -0
  117. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  118. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  119. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  120. package/skills/docs-orchestrator/SKILL.md +12 -5
  121. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  122. package/skills/evolve/SKILL.md +11 -2
  123. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  124. package/skills/session-end/learning-patterns.md +3 -3
  125. package/skills/session-end/metrics-collection.md +1 -1
  126. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  127. package/skills/session-end/plan-verification.md +25 -9
  128. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  129. package/skills/session-end/session-metrics-write.md +45 -10
  130. package/skills/session-start/SKILL.md +3 -3
  131. package/skills/session-start/references/operations-contract.md +5 -3
  132. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  133. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  134. package/skills/vault-mirror/SKILL.md +21 -11
  135. package/skills/wave-executor/SKILL.md +1 -1
  136. package/skills/wave-executor/circuit-breaker.md +1 -1
  137. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  138. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  139. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  140. package/skills/wave-executor/references/wave-loop-scope-manifest.md +14 -6
  141. package/.agents/skills/navigator/SKILL.md +0 -22
  142. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  143. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  144. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  145. package/.cursor/commands/navigator.md +0 -15
  146. package/.cursor/skills/navigator/SKILL.md +0 -13
  147. package/pi/prompts/navigator.md +0 -12
  148. package/skills/navigator/SKILL.md +0 -193
  149. package/skills/navigator/references/account-check.sh +0 -87
  150. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  151. package/skills/navigator/references/slot-check.sh +0 -114
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * ## Why this module exists
5
5
  *
6
- * `skills/session-start/SKILL.md` § Phase 4 names 19 measurement probes, each
7
- * with a module path and an entry function (re-measured 2026-09-11 — see the
6
+ * `skills/session-start/SKILL.md` § Phase 4 names 20 measurement probes, each
7
+ * with a module path and an entry function (re-measured 2026-10-02 — see the
8
8
  * registry Census below; the family stood at 18 when this module was written
9
- * on 2026-08-23 and one probe was registered since without updating this
10
- * count, the exact drift this header now warns readers not to repeat).
9
+ * on 2026-08-23, at 19 on 2026-09-11, and gained `events-retention` with
10
+ * #1401 — update this count with every registration).
11
11
  * Measured 2026-08-23 at `4f6404e`, NONE of them had a mechanical caller:
12
12
  *
13
13
  * ```
@@ -44,12 +44,14 @@
44
44
  * @module scripts/lib/session-start-probes
45
45
  */
46
46
 
47
- import { execFileSync } from 'node:child_process';
48
- import { existsSync, readFileSync } from 'node:fs';
47
+ import { execFile } from 'node:child_process';
48
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
49
49
  import path from 'node:path';
50
50
  import { pathToFileURL } from 'node:url';
51
+ import { promisify } from 'node:util';
51
52
 
52
53
  import { emitEvent } from './events.mjs';
54
+ import { ARCHIVE_DIR_NAME, ARCHIVE_NAME_RE } from './events-schema.mjs';
53
55
 
54
56
  /**
55
57
  * Resolve a sibling module to an absolute `file:` URL.
@@ -66,6 +68,8 @@ import { emitEvent } from './events.mjs';
66
68
  */
67
69
  const local = (rel) => pathToFileURL(path.join(import.meta.dirname, rel)).href;
68
70
 
71
+ const execFileAsync = promisify(execFile);
72
+
69
73
  /** Fallback hint when nothing cheaper than an on-demand CLI call is available. */
70
74
  const CI_UNKNOWN_HINT_DEFAULT = 'run `glab ci status` on demand';
71
75
 
@@ -93,24 +97,30 @@ const PUSHED_FOLLOW_UP_REASONS = new Set([
93
97
  * missing). Full, not short: `checkCiStatus({ sha })` matches against GitLab's
94
98
  * full pipeline SHAs and refuses anything shorter.
95
99
  *
96
- * NAMED CEILING (BV-004): one `git rev-parse` with a 2s timeout, run only on
97
- * the {@link PUSHED_FOLLOW_UP_REASONS} branch — i.e. only when the `ci-status`
98
- * probe already ran (network opt-in) and already found no data for HEAD.
100
+ * NAMED CEILING (BV-004): one `git rev-parse` with a 2s timeout, called only
101
+ * from {@link ciStatusFollowUp} — i.e. only when the `ci-status` probe already
102
+ * ran (network opt-in) and its reading needs a commit SHA.
103
+ *
104
+ * ASYNC on purpose (#1396): it runs inside the probe's budget race, and only a
105
+ * call that yields the event loop is (a) counted as the probe's OWN work time
106
+ * and (b) preemptible by the race. A synchronous spawn is subtracted from
107
+ * `workMs` as loop-blocked time (see {@link startLoopBlockedMeter}), so a 2s
108
+ * hang would never have scored `budget-exceeded`. An abandoned child is not
109
+ * waited for: the hook ends with `process.exit(0)`.
99
110
  *
100
111
  * @param {string|undefined} repoRoot
101
112
  * @param {string[]} refs
102
- * @returns {string[]|null}
113
+ * @returns {Promise<string[]|null>}
103
114
  */
104
- function revParseShas(repoRoot, refs) {
115
+ async function revParseShas(repoRoot, refs) {
105
116
  if (!repoRoot || typeof repoRoot !== 'string') return null;
106
117
  try {
107
- const out = execFileSync('git', ['rev-parse', ...refs], {
118
+ const { stdout } = await execFileAsync('git', ['rev-parse', ...refs], {
108
119
  cwd: repoRoot,
109
120
  encoding: 'utf8',
110
121
  timeout: 2000,
111
- stdio: ['ignore', 'pipe', 'ignore'],
112
122
  });
113
- const shas = String(out).trim().split('\n').map((s) => s.trim());
123
+ const shas = String(stdout).trim().split('\n').map((s) => s.trim());
114
124
  const valid = shas.length === refs.length
115
125
  && shas.every((s) => /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(s));
116
126
  return valid ? shas : null;
@@ -121,16 +131,6 @@ function revParseShas(repoRoot, refs) {
121
131
  }
122
132
  }
123
133
 
124
- /**
125
- * Full SHA of the last PUSHED commit (`@{upstream}`), or `null`.
126
- *
127
- * @param {string|undefined} repoRoot
128
- * @returns {string|null}
129
- */
130
- function lastPushedSha(repoRoot) {
131
- return revParseShas(repoRoot, ['@{upstream}'])?.[0] ?? null;
132
- }
133
-
134
134
  /**
135
135
  * `ci-status` follow-up (#1332): when HEAD has no CI data, ask for the verdict
136
136
  * of the last PUSHED commit — the commit whose pipeline can actually be red
@@ -154,25 +154,28 @@ function lastPushedSha(repoRoot) {
154
154
  * the local HEAD — there the pushed SHA is a different question even when it
155
155
  * equals local HEAD.
156
156
  *
157
+ * The resolved SHA is handed to the runner through `deliver` BEFORE the CLI
158
+ * round trip, so a requery that overruns the budget or throws still leaves
159
+ * the pushed SHA in the reading the renderer receives (#1396).
160
+ *
157
161
  * @param {*} result The HEAD reading from `checkCiStatus`
158
162
  * @param {{repoRoot?: string}} ctx
159
163
  * @param {(extra: object) => Promise<*>} requery Re-invokes the probe with extra options
164
+ * @param {(partial: *) => void} deliver Records a partial result as the fallback
160
165
  * @returns {Promise<*>}
161
166
  */
162
- async function ciPushedFollowUp(result, ctx, requery) {
167
+ async function ciPushedFollowUp(result, ctx, requery, deliver) {
163
168
  const reason = result?.details?.reason;
164
- if (result?.status !== 'unknown' || !PUSHED_FOLLOW_UP_REASONS.has(reason)) {
165
- return result;
166
- }
167
169
  // One spawn for both: an unresolvable `@{upstream}` fails the whole call,
168
170
  // which is exactly the "no upstream" answer.
169
- const shas = revParseShas(ctx?.repoRoot, ['HEAD', '@{upstream}']);
170
- // `pushed.sha: null` records "no upstream" so the renderer does not ask git again.
171
+ const shas = await revParseShas(ctx?.repoRoot, ['HEAD', '@{upstream}']);
172
+ // `pushed.sha: null` records "no upstream" as DATA — the renderer never asks git.
171
173
  if (!shas) return { ...result, pushed: { sha: null } };
172
174
  const [head, sha] = shas;
173
175
  if (reason === 'no-pipeline-for-head-sha' && sha === head) {
174
176
  return { ...result, pushed: { sha, sameAsHead: true } };
175
177
  }
178
+ deliver({ ...result, pushed: { sha } });
176
179
  let verdict;
177
180
  try {
178
181
  verdict = await requery({ sha });
@@ -183,6 +186,36 @@ async function ciPushedFollowUp(result, ctx, requery) {
183
186
  return { ...result, pushed: { sha, verdict } };
184
187
  }
185
188
 
189
+ /**
190
+ * The `ci-status` follow-up (#1332, #1396): every git fact a `ci-status`
191
+ * banner names is resolved HERE, inside the probe's budget race, and carried
192
+ * on the result as data — the renderer stays a pure function of the result.
193
+ * #1396: the renderer used to run `git rev-parse` itself after a fallen-back
194
+ * follow-up, outside the budget, where `orchestrator.probes.completed` could
195
+ * never score it `timed_out`.
196
+ *
197
+ * - an `unknown` reading on a {@link PUSHED_FOLLOW_UP_REASONS} reason →
198
+ * {@link ciPushedFollowUp} (`pushed`)
199
+ * - a `green` reading beside a set-aside FAILED same-commit pipeline →
200
+ * `head: { sha }` for {@link droppedFailedLine} (#1390 P5)
201
+ *
202
+ * @param {*} result
203
+ * @param {{repoRoot?: string}} ctx
204
+ * @param {(extra: object) => Promise<*>} requery
205
+ * @param {(partial: *) => void} deliver
206
+ * @returns {Promise<*>}
207
+ */
208
+ async function ciStatusFollowUp(result, ctx, requery, deliver) {
209
+ if (result?.status === 'unknown' && PUSHED_FOLLOW_UP_REASONS.has(result?.details?.reason)) {
210
+ return ciPushedFollowUp(result, ctx, requery, deliver);
211
+ }
212
+ if (result?.status === 'green' && droppedFailedCount(result) > 0) {
213
+ const sha = (await revParseShas(ctx?.repoRoot, ['HEAD']))?.[0] ?? null;
214
+ return { ...result, head: { sha } };
215
+ }
216
+ return result;
217
+ }
218
+
186
219
  /**
187
220
  * `CI <status> (#<pipeline>)` for a pushed-SHA verdict, or `null` when there
188
221
  * is no readable verdict (the caller then falls back to the command hint).
@@ -222,18 +255,18 @@ function commitRunsCommand(gh, sha) {
222
255
  *
223
256
  * @param {object} result The probe result (possibly carrying `pushed` from
224
257
  * {@link ciPushedFollowUp})
225
- * @param {{repoRoot?: string}} [ctx]
226
258
  * @returns {string}
227
259
  */
228
- function ciUnknownHint(result, ctx) {
260
+ function ciUnknownHint(result) {
229
261
  if (!PUSHED_FOLLOW_UP_REASONS.has(result?.details?.reason)) return CI_UNKNOWN_HINT_DEFAULT;
230
262
  // The GitHub reason names the gh CLI; a glab command there would be wrong.
231
263
  const gh = result.details.cliUsed === 'gh';
232
264
  const onDemand = gh ? 'run `gh run list` on demand' : CI_UNKNOWN_HINT_DEFAULT;
233
265
  const pushed = result.pushed && typeof result.pushed === 'object' ? result.pushed : null;
234
- // The follow-up already resolved the SHA (or its absence) — never re-ask git.
235
- // Without `pushed` (follow-up overran or threw) this is the only git call.
236
- const sha = pushed ? pushed.sha : lastPushedSha(ctx?.repoRoot);
266
+ // Pure (#1396): the SHA is whatever the follow-up resolved inside the budget.
267
+ // Without `pushed` (the follow-up overran or threw before resolving it) the
268
+ // hint degrades to the on-demand command — it never asks git itself.
269
+ const sha = pushed ? pushed.sha : null;
237
270
  if (!sha) return onDemand;
238
271
  const short = sha.slice(0, 8);
239
272
  if (pushed?.sameAsHead) return `last pushed: ${short} = HEAD — ${onDemand}`;
@@ -277,22 +310,22 @@ function droppedFailedCount(r) {
277
310
  * #857 ref-preference result; this only stops the failure from vanishing
278
311
  * behind a silent green.
279
312
  *
280
- * NAMED CEILING (BV-004): one `git rev-parse HEAD` (2s timeout), run only on
281
- * this branch — a green reading WITH a set-aside `failed` run, behind the
282
- * network opt-in. The reading carries no sha, and the GitLab path read the
283
- * local HEAD, so HEAD is the commit it spoke for. REVISIT TRIGGER: once
284
- * `checkCiStatus` publishes the sha it read, take it from the reading.
313
+ * The HEAD sha comes from `r.head.sha`, resolved by {@link ciStatusFollowUp}
314
+ * inside the budget (#1396) — the reading carries no sha, and the GitLab path
315
+ * read the local HEAD, so HEAD is the commit it spoke for. Without it (the
316
+ * follow-up fell back) the line names the CLI's failed-run listing instead.
317
+ * REVISIT TRIGGER: once `checkCiStatus` publishes the sha it read, take it
318
+ * from the reading.
285
319
  *
286
320
  * Only the GitLab path publishes `droppedStatuses` today (`candidateEvidence`
287
321
  * is called from `checkGitlab` alone); the gh form exists so a GitHub producer
288
322
  * can never print a glab command (#1339 P1 class) — see {@link commitRunsCommand}.
289
323
  *
290
324
  * @param {*} r A green reading with `droppedFailedCount(r) > 0`
291
- * @param {{repoRoot?: string}} [ctx]
292
325
  * @returns {string}
293
326
  */
294
- function droppedFailedLine(r, ctx) {
295
- const sha = revParseShas(ctx?.repoRoot, ['HEAD'])?.[0] ?? null;
327
+ function droppedFailedLine(r) {
328
+ const sha = typeof r.head?.sha === 'string' ? r.head.sha : null;
296
329
  const gh = r.details.cliUsed === 'gh';
297
330
  let check;
298
331
  if (sha) check = commitRunsCommand(gh, sha);
@@ -432,11 +465,15 @@ function startLoopBlockedMeter() {
432
465
  * — the #1157 SKILL.md-size split extracted Phase 4's procedure out of
433
466
  * `SKILL.md` itself, so the original 2026-08-23 citation of SKILL.md lines
434
467
  * 693-835 no longer resolves; re-run the census against the reference file):
435
- * grep -oE 'via `(await )?check[A-Za-z]+' \
436
- * skills/session-start/references/phase-4-ssot-environment-check.md # -> 17
437
- * plus two probes the prose introduces with different phrasing —
438
- * `checkBootstrapLockFreshness` ("invoke the bootstrap-lock-freshness probe")
439
- * and `checkVaultStaleness` ("read the most recent line via …") — for 19.
468
+ * grep -oE "fn: 'check[A-Za-z]+'" scripts/lib/session-start-probes.mjs \
469
+ * | sed "s/fn: '//;s/'//" > fns.txt # -> 20
470
+ * grep -oFf fns.txt \
471
+ * skills/session-start/references/phase-4-ssot-environment-check.md \
472
+ * | sort -u | wc -l # -> 20
473
+ * i.e. every registered entry function is named in the reference file
474
+ * (2026-10-02, after `events-retention` / #1401; 19 on 2026-09-11). The older
475
+ * census `grep -oE 'via \`(await )?check[A-Za-z]+'` returned 0 at `86e636a9`
476
+ * — the prose no longer says "via" — so it is retired, not re-counted.
440
477
  * (Prior census, 2026-08-23 at `4f6404e`: 16 + 2 = 18 — one probe was
441
478
  * registered since without a matching update here. This count is deliberately
442
479
  * NOT pinned by an exact-equality test against `PROBES.length` — the registry
@@ -456,11 +493,19 @@ function startLoopBlockedMeter() {
456
493
  * - `precondition` optional; returns a skip-reason string to skip the probe
457
494
  * - `render` optional; maps a result to a banner line. Default:
458
495
  * `result.message` when severity is warn/alert.
459
- * - `followUp` optional; `async (result, ctx, requery) => result'` run
460
- * INSIDE the budget race after the entry function, where
461
- * `requery(extra)` re-invokes it with `{...args, ...extra}`.
462
- * If it throws or overruns the budget, the entry function's
463
- * result stands (recorded `followUp: 'threw'|'budget-exceeded'`).
496
+ * - `followUp` optional; `async (result, ctx, requery, deliver) => result'`
497
+ * run INSIDE the budget race after the entry function, where
498
+ * `requery(extra)` re-invokes it with `{...args, ...extra}`
499
+ * and `deliver(partial)` replaces the fallback result. If it
500
+ * throws or overruns the budget, the last delivered result
501
+ * stands (recorded `followUp: 'threw'|'budget-exceeded'`).
502
+ * - `render` is a PURE function of the result (#1396): anything it names
503
+ * that the entry function does not return is resolved in `followUp`,
504
+ * inside the budget — never in the renderer.
505
+ * - `telemetry` optional; PURE `(result) => object|undefined` — the numbers
506
+ * the probe measured, persisted as `measure` on its element
507
+ * of `orchestrator.probes.completed` for `ran-*` outcomes
508
+ * (#1489), bounded by {@link probeMeasure}.
464
509
  */
465
510
  export const PROBES = [
466
511
  {
@@ -514,8 +559,8 @@ export const PROBES = [
514
559
  args: ({ repoRoot }) => ({ repoRoot }),
515
560
  // #1332: on `no-pipeline-for-head-sha` / `no-check-runs-for-head`, query
516
561
  // the last PUSHED commit's verdict via `checkCiStatus({ sha })` — see the
517
- // ceiling on the function.
518
- followUp: ciPushedFollowUp,
562
+ // ceiling on the function. #1396: also resolves every SHA the renderer names.
563
+ followUp: ciStatusFollowUp,
519
564
  // Bespoke shape: `{status, ok, details, …}` with no `message` field. The
520
565
  // banner text is prescribed by SKILL.md § Phase 4.
521
566
  //
@@ -525,7 +570,7 @@ export const PROBES = [
525
570
  // probe. Without these two lines a degraded ci-status result scored `'ok'`
526
571
  // and rendered nothing — "could not read" displayed exactly like "green",
527
572
  // which is the confusion the probe's own migration removed one layer down.
528
- render: (r, ctx) => {
573
+ render: (r) => {
529
574
  if (!r || typeof r !== 'object') return null;
530
575
  if (r.degraded) return typeof r.message === 'string' && r.message ? r.message : null;
531
576
  // `status: 'unknown'` is the SAME collapse one level over: HEAD carries
@@ -539,7 +584,7 @@ export const PROBES = [
539
584
  // #1337: a RED pushed commit is an alert, and the banner says what the
540
585
  // rule judges (HR-106) — same 🚨 as a red HEAD.
541
586
  const mark = r.pushed?.verdict?.status === 'red' ? '🚨' : '⚠';
542
- return `${mark} ci-status: CI status for HEAD could not be determined (${reason}) — ${ciUnknownHint(r, ctx)}`;
587
+ return `${mark} ci-status: CI status for HEAD could not be determined (${reason}) — ${ciUnknownHint(r)}`;
543
588
  }
544
589
  if (r.status === 'red') {
545
590
  const pid = r.details?.currentPipelineId ?? '?';
@@ -556,7 +601,7 @@ export const PROBES = [
556
601
  lines.push(`⚠ CI green on HEAD, but ${r.allowFailureJobs.length} allow_failure job(s) FAILED: ${names}. A pipeline reports success regardless of these.`);
557
602
  }
558
603
  // #1390 P5: a failed same-commit pipeline the ref preference set aside.
559
- if (droppedFailedCount(r) > 0) lines.push(droppedFailedLine(r, ctx));
604
+ if (droppedFailedCount(r) > 0) lines.push(droppedFailedLine(r));
560
605
  // Both findings can hold at once; `pushBanner` takes a multi-line
561
606
  // string verbatim, so neither swallows the other.
562
607
  return lines.length > 0 ? lines.join('\n') : null;
@@ -705,6 +750,40 @@ export const PROBES = [
705
750
  network: false,
706
751
  args: ({ repoRoot, env }) => ({ repoRoot, env }),
707
752
  },
753
+ {
754
+ // #1401 part 3: archive COUNT retention vs the TIME window the ledger's
755
+ // readers declare (`REQUIRED_EVENTS_WINDOW_DAYS`). Without any ledger there
756
+ // is nothing to measure — recorded as `skipped`, never as `ran-clean`. A
757
+ // bare, empty or foreign-only `_archive/` is no ledger either: the probe
758
+ // answers those `not-measured` at severity ok, which the runner would
759
+ // record as `ran-clean` (see {@link hasRotationArchive}).
760
+ id: 'events-retention',
761
+ spec: local('./events-retention-banner.mjs'),
762
+ fn: 'checkEventsRetention',
763
+ network: false,
764
+ precondition: ({ repoRoot }) => {
765
+ const metrics = path.join(repoRoot, '.orchestrator', 'metrics');
766
+ return existsSync(path.join(metrics, 'events.jsonl')) || hasRotationArchive(metrics)
767
+ ? null
768
+ : 'no-events-ledger';
769
+ },
770
+ args: ({ repoRoot, config }) => ({ repoRoot, config }),
771
+ // #1489: the coverage reaches the ledger on every measured answer, warn or
772
+ // not — a warning that never fires is otherwise indistinguishable from a
773
+ // dead one (HR-105). Counts and day spans only: no path, no timestamp.
774
+ // A degraded answer carries no `archives`, so it persists no measure. Every
775
+ // measured answer has a finite `coverageDays` — a span with no stamp is
776
+ // degraded since #1498 — and `probeMeasure` drops a non-finite value, so a
777
+ // regression there reads as "not measured", never as a number.
778
+ telemetry: (r) => (Number.isInteger(r?.archives)
779
+ ? {
780
+ coverage_days: Math.round(r.coverageDays * 1000) / 1000,
781
+ archives: r.archives,
782
+ max_backups: r.maxBackups,
783
+ required_days: r.requiredDays ?? null,
784
+ }
785
+ : undefined),
786
+ },
708
787
  ];
709
788
 
710
789
  // ---------------------------------------------------------------------------
@@ -730,6 +809,68 @@ function pluginVersion() {
730
809
  }
731
810
  }
732
811
 
812
+ /**
813
+ * NAMED CEILING (BV-004): keys per probe `measure`. `events-retention`, the one
814
+ * probe with telemetry today, persists 4. Revisit when a probe needs more than
815
+ * 8 — the cap exists so one probe cannot inflate every SessionStart record.
816
+ */
817
+ const MEASURE_MAX_KEYS = 8;
818
+ const MEASURE_KEY_RE = /^[a-z][a-z0-9_]{0,31}$/;
819
+
820
+ /**
821
+ * A probe's `telemetry(result)`, bounded for the ledger (#1489): at most
822
+ * {@link MEASURE_MAX_KEYS} snake_case keys whose values are finite numbers or
823
+ * `null`; any other entry is dropped. Strings are refused outright, not
824
+ * vetted: `orchestrator.probes.completed` also travels over the optional
825
+ * webhook unredacted, and a string is how a path or a repo slug gets there.
826
+ * A missing, throwing or empty telemetry yields `undefined` — no `measure`
827
+ * key, which reads as "not measured", never as zero.
828
+ *
829
+ * Reading the returned object stays inside the `try`: `Object.entries` runs
830
+ * its getters and a Proxy's `ownKeys` trap, and either may throw — which,
831
+ * outside it, rejected the whole runner against its never-rejects contract.
832
+ *
833
+ * @param {{telemetry?: Function}} probe
834
+ * @param {*} result — the probe's delivered result
835
+ * @returns {Record<string, number|null>|undefined}
836
+ */
837
+ function probeMeasure(probe, result) {
838
+ if (typeof probe.telemetry !== 'function') return undefined;
839
+ try {
840
+ const raw = probe.telemetry(result);
841
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
842
+ const entries = Object.entries(raw)
843
+ .filter(([k, v]) => MEASURE_KEY_RE.test(k) && (v === null || (typeof v === 'number' && Number.isFinite(v))))
844
+ .slice(0, MEASURE_MAX_KEYS);
845
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
846
+ } catch {
847
+ return undefined;
848
+ }
849
+ }
850
+
851
+ /**
852
+ * Does `<metricsDir>/_archive/` hold at least one rotated events archive? The
853
+ * `events-retention` precondition's second half.
854
+ *
855
+ * It lists the directory exactly as `checkEventsRetention` does (same
856
+ * `ARCHIVE_NAME_RE` filter), so the precondition skips precisely the inputs the
857
+ * probe would answer `not-measured`. The directory existing is not a ledger
858
+ * (#1401 review: an empty or foreign-only `_archive/` was recorded
859
+ * `ran-clean`). Any listing error other than ENOENT answers `true`: the probe
860
+ * then runs and reports the unlistable directory as `degraded`, because an
861
+ * unreadable source must never be skipped as absent.
862
+ *
863
+ * @param {string} metricsDir — `<repoRoot>/.orchestrator/metrics`
864
+ * @returns {boolean}
865
+ */
866
+ function hasRotationArchive(metricsDir) {
867
+ try {
868
+ return readdirSync(path.join(metricsDir, ARCHIVE_DIR_NAME)).some((n) => ARCHIVE_NAME_RE.test(n));
869
+ } catch (err) {
870
+ return err?.code !== 'ENOENT';
871
+ }
872
+ }
873
+
733
874
  /**
734
875
  * Race `promise` against a budget denominated in the probe's OWN work time —
735
876
  * wall-clock elapsed MINUS the time a synchronous sibling held the event loop
@@ -973,7 +1114,12 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
973
1114
  // the race below: a timeout with `delivered` set is `delivered`.
974
1115
  try {
975
1116
  return {
976
- __probeResult: await probe.followUp(first, ctx, (extra) => fn({ ...probe.args(ctx), ...extra })),
1117
+ __probeResult: await probe.followUp(
1118
+ first,
1119
+ ctx,
1120
+ (extra) => fn({ ...probe.args(ctx), ...extra }),
1121
+ (partial) => { delivered = { __probeResult: partial }; },
1122
+ ),
977
1123
  };
978
1124
  } catch {
979
1125
  followUpFailure = 'threw';
@@ -1001,24 +1147,49 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
1001
1147
  if (raced.__absent || raced.__probeError?.code === 'ERR_MODULE_NOT_FOUND') {
1002
1148
  record('skipped', { reason: 'module-absent' });
1003
1149
  } else {
1004
- record('error', { reason: String(raced.__probeError?.message ?? raced.__probeError).slice(0, 200) });
1150
+ // The thrown value is probe-supplied: String() of an object without a
1151
+ // prototype (or with a throwing toString) throws itself, which would
1152
+ // reject the runner — the contract is that it never rejects.
1153
+ let reason;
1154
+ try {
1155
+ reason = String(raced.__probeError?.message ?? raced.__probeError).slice(0, 200);
1156
+ } catch {
1157
+ reason = 'probe-threw-unprintable';
1158
+ }
1159
+ record('error', { reason });
1005
1160
  }
1006
1161
  return;
1007
1162
  }
1008
1163
 
1009
1164
  const result = raced?.__probeResult;
1010
- const severity = severityOf(result, probe);
1011
- // `ctx` (repoRoot/config/env) is passed as a SECOND argument so a
1012
- // renderer can name a repo-local fact the probe result does not carry
1013
- // (the `ci-status` unknown branch names the last pushed SHA). Every
1014
- // existing renderer takes one parameter and ignores it.
1015
- const line = typeof probe.render === 'function'
1016
- ? probe.render(result, ctx)
1017
- : defaultRender(result, severity);
1165
+ // A custom `severityOf`, a `render`, or a getter on the result the
1166
+ // defaults read is probe-supplied code that can throw. The runner never
1167
+ // rejects, so such a throw degrades this probe the way a throwing
1168
+ // precondition does: `error` with a fixed reason, no banner line.
1169
+ let severity;
1170
+ let line;
1171
+ let stage = 'severity-threw';
1172
+ try {
1173
+ severity = severityOf(result, probe);
1174
+ stage = 'render-threw';
1175
+ // The renderer gets the RESULT only (#1396): no `ctx`, so it has no
1176
+ // repo to spawn git against. It runs after the budget race, where
1177
+ // nothing it did would be measured; a repo-local fact a banner names
1178
+ // is resolved in the probe's `followUp`, inside the race, and carried
1179
+ // as data.
1180
+ line = typeof probe.render === 'function'
1181
+ ? probe.render(result)
1182
+ : defaultRender(result, severity);
1183
+ } catch {
1184
+ record('error', { reason: stage, workMs });
1185
+ return;
1186
+ }
1187
+ const measure = probeMeasure(probe, result);
1018
1188
  record(severity === 'ok' ? 'ran-clean' : severity === 'warn' ? 'ran-warn' : 'ran-alert', {
1019
1189
  severity,
1020
1190
  workMs,
1021
1191
  ...(followUpFailure ? { followUp: followUpFailure } : {}),
1192
+ ...(measure ? { measure } : {}),
1022
1193
  ...(line ? { line } : {}),
1023
1194
  });
1024
1195
  }),
@@ -1082,6 +1253,9 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
1082
1253
  // A follow-up that fell back to the delivered result is otherwise
1083
1254
  // invisible (the outcome is `ran-*`); this is its revisit trigger's input.
1084
1255
  ...(typeof r.followUp === 'string' ? { follow_up: r.followUp } : {}),
1256
+ // What the probe MEASURED, warn or not (#1489, HR-105): without it a
1257
+ // probe that never warns reads the same whether it is rare or dead.
1258
+ ...(r.measure ? { measure: r.measure } : {}),
1085
1259
  })),
1086
1260
  };
1087
1261
 
@@ -93,8 +93,9 @@
93
93
  * without usage turns, or unreadable, #1474), or when the agent has a start
94
94
  * record but its stop found no transcript of its own and no other record of the
95
95
  * same agent did (`start_record_found: true`, `subagent_transcript_found: false`
96
- * — mostly `workflow-subagent`). A phantom stop (#939, no start record) is no
97
- * agent and no candidate. A record whose four token buckets
96
+ * — mostly `workflow-subagent`), or when its only own-transcript records are
97
+ * schema_version 1 (#1487 Pkt 1 — v1 has no priceable buckets). A phantom stop
98
+ * (#939, no start record) is no agent and no candidate. A record whose four token buckets
98
99
  * are all 0 is priced at $0 whatever its model, and never turns a null total into
99
100
  * a number on its own. A record whose turns span several models carries
100
101
  * `models_usage` (#1470) and is priced part by part. `cost_records_priced` /
@@ -104,11 +105,16 @@
104
105
  * Blind spots the ledger cannot close: an agent whose start record lies outside
105
106
  * the hook's tail window reads `start_record_found: false` and is
106
107
  * indistinguishable from a phantom, and an agent that left neither a start nor a
107
- * stop record is invisible. One blind spot is a rule of this module, not the
108
- * ledger: a session spanning the 2026-09-09 schema boundary is priced over its v2
109
- * records only — its v1 records are `legacy_v1_records`, never cost candidates
110
- * (4 distinct fleet sessions carry such a numeric cost, 31 ledgers, 2026-10-01).
111
- * "Never a partial sum" holds only over the v2 agents the ledger can see.
108
+ * stop record is invisible. "Never a partial sum" holds over the agents the
109
+ * ledger can see. Until #1487 Pkt 1 (2026-10-02) a session spanning the
110
+ * 2026-09-09 schema boundary was priced over its v2 records only — its v1
111
+ * agents were never cost candidates, so the v2 half's sum read as the whole
112
+ * bill (4 distinct fleet sessions, 31 ledgers, 2026-10-01). A v1 agent is now an
113
+ * unpriced candidate: such a session reports `total_cost_usd: null` with
114
+ * `cost_records_priced < cost_records_total`. Re-measured 2026-10-02 (28
115
+ * ledgers, 1,887 ledger/session pairs): the same 4 sessions turn null, and 498
116
+ * pure-v1 pairs change only `cost_records_total` (0/0 → 0/N, cost null before
117
+ * and after). FORWARD-ONLY like the #949 cut.
112
118
  *
113
119
  * @module session-token-rollup
114
120
  */
@@ -238,10 +244,10 @@ function recordCostUsd(record) {
238
244
  * @property {number|null} total_token_input_uncached - Sum of token_input_uncached over the same one record per agent.
239
245
  * @property {number|null} total_token_cache_read - Sum of token_cache_read over the same one record per agent.
240
246
  * @property {number|null} total_token_cache_creation - Sum of token_cache_creation over the same one record per agent.
241
- * @property {number|null} total_cost_usd - Σ cost over the agents counted in cost_records_priced; null when cost_records_priced < cost_records_total (#1475 — any unpriced candidate: unknown model, no model, no tokens, or a started agent without a transcript), null when there is nothing to price (cost_records_total 0), and null when no record with a non-zero bucket was priced (all-zero records alone never yield a fabricated $0). Never 0 for unknown — see telemetry/pricing.mjs.
247
+ * @property {number|null} total_cost_usd - Σ cost over the agents counted in cost_records_priced; null when cost_records_priced < cost_records_total (#1475 — any unpriced candidate: unknown model, no model, no tokens, a started agent without a transcript, or a v1 agent, #1487 Pkt 1), null when there is nothing to price (cost_records_total 0), and null when no record with a non-zero bucket was priced (all-zero records alone never yield a fabricated $0). Never 0 for unknown — see telemetry/pricing.mjs.
242
248
  * @property {number} cost_records_priced - How many agents of cost_records_total carry a known cost on their last found v2 record: priced by the table (per model part when the record carries models_usage, #1470), or all four token buckets 0 (priced at $0 whatever the model, #1474).
243
- * @property {number} cost_records_total - How many agents were candidates for pricing, each counted once however often it stopped: every agent with a v2 record whose own transcript was found — priced on its LAST such record, so one whose last read yielded no tokens (transcript oversized, without usage turns, or unreadable, #1474) is unpriced — plus every started agent whose v2 stops found no transcript of its own (`start_record_found: true`, `subagent_transcript_found: false`) and that has no token-bearing record in the session. Both latter kinds have an unknown cost. Phantom stops (no start record, #939) are never candidates. priced < total nulls total_cost_usd.
244
- * @property {number} legacy_v1_records - Token-bearing records EXCLUDED from every total above because their schema_version < 2 (their token_input is a different quantity).
249
+ * @property {number} cost_records_total - How many agents were candidates for pricing, each counted once however often it stopped: every agent with a v2 record whose own transcript was found — priced on its LAST such record, so one whose last read yielded no tokens (transcript oversized, without usage turns, or unreadable, #1474) is unpriced — plus every started agent whose stops found no transcript of its own (`start_record_found: true`, `subagent_transcript_found: false`), plus every agent with a schema_version 1 own-transcript record (#1487 Pkt 1) — each of these two only when the agent has no v2 own-transcript record in the session. All three latter kinds have an unknown cost. Phantom stops (no start record, #939) are never candidates. priced < total nulls total_cost_usd.
250
+ * @property {number} legacy_v1_records - Token-bearing records EXCLUDED from every token total above because their schema_version < 2 (their token_input is a different quantity). Their agents are unpriced cost candidates (cost_records_total, #1487 Pkt 1).
245
251
  * @property {2} _token_schema - The token contract these totals were computed under.
246
252
  */
247
253
 
@@ -341,13 +347,20 @@ export function rollupSessionTokens({
341
347
  const addNonNegative = (acc, value) =>
342
348
  typeof value === 'number' && value >= 0 ? (acc ?? 0) + value : acc;
343
349
 
344
- // Agents with at least one record whose own transcript WAS read. A later stop
345
- // of the same agent can find a transcript an earlier one missed (measured
346
- // 2026-10-01: 4 agents in one fleet session, `workflow-subagent` found:false
347
- // first, then found:true) — its cost is then known and must not null the total.
348
- const agentsWithTranscript = new Set(
349
- matched.filter(isTokenBearing).map((r) => r.agent_id).filter((id) => typeof id === 'string'),
350
- );
350
+ // Real agents that may have no v2 record of their own transcript — each an
351
+ // unpriced candidate, counted once: a started agent whose stop found no
352
+ // transcript (#1475), and an agent seen in a v1 record (#1487 Pkt 1 — v1
353
+ // carries no priceable buckets). Resolved against `agents` only AFTER the
354
+ // pass: a later v2 stop of the same agent that found its transcript prices it
355
+ // (measured 2026-10-01: 4 agents in one fleet session, `workflow-subagent`
356
+ // found:false first, then found:true) and must not null a known cost. A record
357
+ // without an agent_id cannot be joined to another and stands alone.
358
+ const maybeUnpriced = new Set();
359
+ let unkeyedUnpriced = 0;
360
+ const addMaybeUnpriced = (record) => {
361
+ if (record.agent_id === undefined || record.agent_id === null) unkeyedUnpriced += 1;
362
+ else maybeUnpriced.add(record.agent_id);
363
+ };
351
364
 
352
365
  // One agent, one count (see module header § One record per agent). Per agent:
353
366
  // `last` is its last stop that found its transcript — the cost candidate —
@@ -358,9 +371,6 @@ export function rollupSessionTokens({
358
371
  // cannot be joined to another and stands alone.
359
372
  /** @type {Map<unknown, {last: object, lastWithTokens: object|null}>} */
360
373
  const agents = new Map();
361
- /** Started agents whose stops found no transcript — each counts once. */
362
- const startedWithoutTranscript = new Set();
363
- let unkeyedStartedWithoutTranscript = 0;
364
374
 
365
375
  for (const record of matched) {
366
376
  // Provenance gate (#949) — a record whose tokens describe the PARENT
@@ -369,28 +379,22 @@ export function rollupSessionTokens({
369
379
  // sentinel: a session of only untrustworthy records reports "no data",
370
380
  // which is true, instead of a fabricated 0.
371
381
  if (!isTokenBearing(record)) {
372
- // …but a v2 agent that really ran without a readable transcript has an
382
+ // …but an agent that really ran without a readable transcript has an
373
383
  // unknown cost: an unpriced candidate (total, not priced), so priced <
374
384
  // total nulls `total_cost_usd` (#1475). Phantoms never reach this branch.
375
- if (
376
- isV2(record) &&
377
- isStartedWithoutTranscript(record) &&
378
- !agentsWithTranscript.has(record.agent_id)
379
- ) {
380
- if (record.agent_id === undefined || record.agent_id === null) {
381
- unkeyedStartedWithoutTranscript += 1;
382
- } else {
383
- startedWithoutTranscript.add(record.agent_id);
384
- }
385
- }
385
+ if (isStartedWithoutTranscript(record)) addMaybeUnpriced(record);
386
386
  continue;
387
387
  }
388
388
 
389
389
  // Schema gate (#1244) — a v1 record's token_input is raw uncached input,
390
390
  // a different quantity from a v2 record's billable prompt volume. Count it
391
- // so the boundary is visible, never sum it.
391
+ // so the boundary is visible, never sum it. Its agent ran, though, and v1
392
+ // cannot be priced: it is an unpriced cost candidate (#1487 Pkt 1), so a
393
+ // session spanning the 2026-09-09 boundary reports a null cost instead of
394
+ // the v2 half's partial sum.
392
395
  if (!isV2(record)) {
393
396
  legacyV1 += 1;
397
+ addMaybeUnpriced(record);
394
398
  continue;
395
399
  }
396
400
 
@@ -452,7 +456,8 @@ export function rollupSessionTokens({
452
456
  if (!allZero) sumCost = (sumCost ?? 0) + cost;
453
457
  }
454
458
  }
455
- costTotal += startedWithoutTranscript.size + unkeyedStartedWithoutTranscript;
459
+ for (const id of maybeUnpriced) if (!agents.has(id)) costTotal += 1;
460
+ costTotal += unkeyedUnpriced;
456
461
 
457
462
  return {
458
463
  match_status: 'matched',
@@ -192,7 +192,9 @@ const CLOSING_DIAGNOSTIC_EVENTS = new Map([
192
192
  // scripts/lib/autopilot/worktree-pipeline.mjs (release_failed/released only).
193
193
  ['orchestrator.session.lock.released', null],
194
194
  ['orchestrator.session.lock.release_failed', null],
195
- // Only emitter: hooks/on-session-end.mjs — reading the lock during teardown.
195
+ // hooks/on-session-end.mjs — reading the lock during teardown. Since #1494 also
196
+ // hooks/_lib/lock-bootstrap.mjs at SessionStart (`unreadable` only); skipping that
197
+ // one too loses nothing, because the same start emits orchestrator.session.started.
196
198
  ['orchestrator.session.lock.read_anomaly', null],
197
199
  // Wave lifecycle: hooks/post-tool-batch-wave-signal.mjs emits wave.completed MID-session
198
200
  // (real activity, still counted); only the SessionEnd final-wave stamp is a closing