session-orchestrator 4.1.0 → 5.0.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 (230) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.agents/skills/session-start/SKILL.md +1 -1
  3. package/.agents/skills/ux-grill/SKILL.md +22 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +3 -2
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  8. package/.codex-plugin/skills/session-start/SKILL.md +1 -1
  9. package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
  10. package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
  11. package/.cursor/commands/ux-grill.md +14 -0
  12. package/.cursor/skills/session-plan/SKILL.md +1 -1
  13. package/.cursor/skills/session-start/SKILL.md +1 -1
  14. package/.cursor/skills/ux-grill/SKILL.md +13 -0
  15. package/.cursor-plugin/plugin.json +1 -1
  16. package/AGENTS.md +2 -1
  17. package/CHANGELOG.md +128 -1
  18. package/README.md +98 -86
  19. package/agents/dialectic-deriver.md +11 -0
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/close.md +3 -3
  22. package/commands/go.md +2 -0
  23. package/commands/memory-cleanup.md +4 -3
  24. package/commands/persona-panel.md +1 -1
  25. package/commands/session.md +3 -2
  26. package/commands/ux-grill.md +51 -0
  27. package/docs/README.md +4 -4
  28. package/docs/USER-GUIDE.md +117 -50
  29. package/docs/agent-authoring.md +2 -2
  30. package/docs/baseline.md +55 -1
  31. package/docs/ci-setup.md +1 -1
  32. package/docs/codex-setup.md +9 -0
  33. package/docs/components.md +9 -9
  34. package/docs/cursor-setup.md +1 -0
  35. package/docs/events-schema.md +13 -6
  36. package/docs/github-mirror-protection.md +61 -20
  37. package/docs/instruction-delivery.md +1 -1
  38. package/docs/memory-proposal-flow.md +3 -3
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/migration-v5.md +62 -0
  41. package/docs/owner-config-schema.md +74 -90
  42. package/docs/persona-panel.md +4 -4
  43. package/docs/pi-setup.md +1 -0
  44. package/docs/rule-authoring.md +13 -6
  45. package/docs/scope-collision-guard.md +16 -0
  46. package/docs/session-config-reference.md +55 -22
  47. package/docs/session-config-template.md +9 -5
  48. package/docs/vault-docs-architecture.md +4 -2
  49. package/hooks/_lib/hook-import-set.json +70 -3
  50. package/hooks/_lib/lock-bootstrap.mjs +84 -1
  51. package/hooks/_lib/vcs-create-matcher.mjs +401 -16
  52. package/hooks/enforce-scope.mjs +201 -0
  53. package/hooks/hooks-codex.json +1 -1
  54. package/hooks/hooks-cursor.json +5 -0
  55. package/hooks/hooks.json +7 -2
  56. package/hooks/on-session-start.mjs +171 -49
  57. package/hooks/post-bash-issue-budget-refund.mjs +375 -0
  58. package/hooks/pre-auq-clarity.mjs +70 -18
  59. package/hooks/pre-bash-issue-budget.mjs +170 -26
  60. package/hooks/subagent-telemetry.mjs +106 -20
  61. package/package.json +5 -4
  62. package/pi/prompts/ux-grill.md +12 -0
  63. package/scripts/baseline-archetypes.mjs +28 -0
  64. package/scripts/ci/assert-vitest-green.mjs +4 -2
  65. package/scripts/dialectic-deriver.mjs +32 -8
  66. package/scripts/emit-session.mjs +72 -1
  67. package/scripts/lib/agent-status.mjs +441 -9
  68. package/scripts/lib/auq/schema.mjs +10 -3
  69. package/scripts/lib/auto-dialectic.mjs +0 -68
  70. package/scripts/lib/baseline-archetypes.mjs +439 -0
  71. package/scripts/lib/build-live-signals.mjs +5 -6
  72. package/scripts/lib/ci-status-banner.mjs +29 -6
  73. package/scripts/lib/claude-md-budget-lint.mjs +52 -2
  74. package/scripts/lib/config/issue-budget.mjs +68 -8
  75. package/scripts/lib/config/private-config-dir.mjs +3 -2
  76. package/scripts/lib/config/remote-hosts.mjs +2 -2
  77. package/scripts/lib/config-schema.mjs +79 -0
  78. package/scripts/lib/config.mjs +12 -1
  79. package/scripts/lib/eval/engine.mjs +7 -1
  80. package/scripts/lib/file-lock.mjs +151 -8
  81. package/scripts/lib/git-porcelain.mjs +113 -0
  82. package/scripts/lib/instruction-budget-guard.mjs +415 -47
  83. package/scripts/lib/io.mjs +29 -4
  84. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  85. package/scripts/lib/issue-budget.mjs +412 -9
  86. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  87. package/scripts/lib/learnings/sizing-subject.mjs +44 -0
  88. package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
  89. package/scripts/lib/locks/state-md-lock.mjs +19 -41
  90. package/scripts/lib/maintenance-due-banner.mjs +450 -0
  91. package/scripts/lib/owner-config.example.yaml +29 -46
  92. package/scripts/lib/owner-yaml.mjs +14 -13
  93. package/scripts/lib/peer-cards/merger.mjs +143 -0
  94. package/scripts/lib/pre-dispatch-check.mjs +20 -14
  95. package/scripts/lib/project-hygiene.mjs +81 -30
  96. package/scripts/lib/quality-gate.mjs +27 -71
  97. package/scripts/lib/reconcile/engine.mjs +19 -1
  98. package/scripts/lib/reconcile/writer.mjs +278 -11
  99. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  100. package/scripts/lib/rules-sync.mjs +34 -4
  101. package/scripts/lib/scope-echo.mjs +346 -0
  102. package/scripts/lib/session-close-backfill.mjs +182 -40
  103. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  104. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  105. package/scripts/lib/session-lock.mjs +62 -2
  106. package/scripts/lib/session-record-repair.mjs +91 -0
  107. package/scripts/lib/session-schema/constants.mjs +6 -0
  108. package/scripts/lib/session-schema/filters.mjs +26 -1
  109. package/scripts/lib/session-schema/validator.mjs +20 -0
  110. package/scripts/lib/session-shape.mjs +558 -0
  111. package/scripts/lib/session-start-probes.mjs +429 -56
  112. package/scripts/lib/session-token-rollup.mjs +95 -10
  113. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  114. package/scripts/lib/state-md.mjs +1 -0
  115. package/scripts/lib/subagents-schema.mjs +77 -9
  116. package/scripts/lib/telemetry/pricing.mjs +197 -0
  117. package/scripts/lib/telemetry/sync.mjs +50 -1
  118. package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
  119. package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
  120. package/scripts/lib/tmux-layout/layouts.mjs +62 -4
  121. package/scripts/lib/ux-grill/collect.mjs +1163 -0
  122. package/scripts/lib/ux-grill/compare.mjs +285 -0
  123. package/scripts/lib/ux-grill/manifest.mjs +618 -0
  124. package/scripts/lib/ux-grill/measures.mjs +431 -0
  125. package/scripts/lib/ux-grill/paths.mjs +224 -0
  126. package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
  127. package/scripts/lib/ux-grill/reconcile.mjs +344 -0
  128. package/scripts/lib/ux-grill/run-record.mjs +316 -0
  129. package/scripts/lib/ux-grill/schema.mjs +321 -0
  130. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  131. package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
  132. package/scripts/lib/validate/check-unwired-features.mjs +56 -27
  133. package/scripts/lib/vault-mirror/process.mjs +2 -1
  134. package/scripts/lib/vault-status/board-lock.mjs +18 -0
  135. package/scripts/lib/vault-status/board-writer.mjs +8 -0
  136. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  137. package/scripts/lib/wave-resource-gate.mjs +23 -27
  138. package/scripts/lib/wave-sizing.mjs +10 -3
  139. package/scripts/materialize-wave-scope.mjs +68 -14
  140. package/scripts/mcp-server.sh +16 -1
  141. package/scripts/print-applicable-rules.mjs +7 -6
  142. package/scripts/print-learnings-index.mjs +3 -2
  143. package/scripts/release.mjs +7 -2
  144. package/scripts/session-shape.mjs +266 -0
  145. package/skills/_shared/config-reading.md +15 -9
  146. package/skills/_shared/private-capability-context.md +89 -0
  147. package/skills/bootstrap/SKILL.md +60 -209
  148. package/skills/bootstrap/_shared-template.md +99 -14
  149. package/skills/bootstrap/deep-template.md +36 -26
  150. package/skills/bootstrap/fast-template.md +44 -8
  151. package/skills/bootstrap/intensity-heuristic.md +10 -4
  152. package/skills/bootstrap/private-contract.md +119 -0
  153. package/skills/bootstrap/public-fallback.md +30 -18
  154. package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
  155. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
  156. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
  157. package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
  158. package/skills/bootstrap/standard-template.md +39 -24
  159. package/skills/claude-md-drift-check/SKILL.md +9 -2
  160. package/skills/claude-md-drift-check/checker.mjs +213 -21
  161. package/skills/discovery/SKILL.md +6 -173
  162. package/skills/discovery/probes/vault-staleness.mjs +35 -5
  163. package/skills/discovery/probes-docs.md +8 -4
  164. package/skills/discovery/probes-supply-chain.md +4 -2
  165. package/skills/discovery/probes-ui.md +8 -4
  166. package/skills/discovery/probes-vault.md +12 -4
  167. package/skills/discovery/references/discovery-interactive-triage.md +139 -0
  168. package/skills/discovery/references/discovery-triage-state.md +54 -0
  169. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  170. package/skills/eval/rubric-v1.md +13 -0
  171. package/skills/evolve/SKILL.md +2 -458
  172. package/skills/evolve/references/evolve-analyze-mode.md +360 -0
  173. package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
  174. package/skills/gitlab-ops/SKILL.md +3 -3
  175. package/skills/grill/SKILL.md +1 -1
  176. package/skills/memory-cleanup/SKILL.md +2 -2
  177. package/skills/plan/mode-new.md +9 -0
  178. package/skills/plan/mode-retro.md +4 -3
  179. package/skills/reconcile/SKILL.md +11 -1
  180. package/skills/session-end/SKILL.md +3 -2
  181. package/skills/session-end/drift-operations.md +20 -5
  182. package/skills/session-end/metrics-collection.md +1 -0
  183. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  184. package/skills/session-end/phase-3-6-tail.md +27 -67
  185. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  186. package/skills/session-end/references/phase-2-quality-gate.md +3 -3
  187. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  188. package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
  189. package/skills/session-end/session-metrics-write.md +33 -12
  190. package/skills/session-plan/SKILL.md +46 -180
  191. package/skills/session-plan/references/session-plan-task-classification.md +152 -0
  192. package/skills/session-plan/wave-template.md +8 -15
  193. package/skills/session-start/SKILL.md +41 -7
  194. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  195. package/skills/session-start/phase-8-5-express-path.md +12 -9
  196. package/skills/session-start/references/operations-contract.md +114 -0
  197. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  198. package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
  199. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  200. package/skills/session-start/soul.md +2 -2
  201. package/skills/test-runner/SKILL.md +1 -1
  202. package/skills/test-runner/rubric-v1.md +2 -2
  203. package/skills/tmux-layout/SKILL.md +3 -1
  204. package/skills/ux-grill/SKILL.md +211 -0
  205. package/skills/ux-grill/rubric-v2.md +201 -0
  206. package/skills/ux-grill/soul.md +76 -0
  207. package/skills/wave-executor/SKILL.md +32 -127
  208. package/skills/wave-executor/circuit-breaker.md +3 -1
  209. package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
  210. package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
  211. package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
  212. package/skills/wave-executor/references/wave-loop-review.md +19 -6
  213. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  214. package/templates/_shared/ux-manifest.template.md +149 -0
  215. package/templates/nextjs-minimal/package.json +1 -1
  216. package/templates/node-minimal/package.json +1 -1
  217. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  218. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  219. package/scripts/lib/owner-config/coerce.mjs +0 -29
  220. package/scripts/lib/owner-config/constants.mjs +0 -21
  221. package/scripts/lib/owner-config/defaults.mjs +0 -50
  222. package/scripts/lib/owner-config/error.mjs +0 -19
  223. package/scripts/lib/owner-config/index.mjs +0 -13
  224. package/scripts/lib/owner-config/merge.mjs +0 -52
  225. package/scripts/lib/owner-config/validate.mjs +0 -259
  226. package/scripts/lib/owner-config-loader.mjs +0 -170
  227. package/scripts/lib/owner-config.mjs +0 -28
  228. package/scripts/lib/soul-resolve.mjs +0 -130
  229. package/scripts/lib/vault-mirror/render.mjs +0 -8
  230. package/templates/_shared/journey-manifest.md +0 -114
@@ -3,9 +3,12 @@
3
3
  *
4
4
  * ## Why this module exists
5
5
  *
6
- * `skills/session-start/SKILL.md` § Phase 4 names 18 measurement probes, each
7
- * with a module path and an entry function. Measured 2026-08-23 at `4f6404e`,
8
- * NONE of them had a mechanical caller:
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
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).
11
+ * Measured 2026-08-23 at `4f6404e`, NONE of them had a mechanical caller:
9
12
  *
10
13
  * ```
11
14
  * $ grep -c "session-start-probes\|checkSessionsStaleness\|checkProjectHygiene" \
@@ -32,7 +35,7 @@
32
35
  * install produces an `outcome`, never an exception. `runSessionStartProbes`
33
36
  * has no rejecting path; a caller needs no try/catch (the hook keeps one
34
37
  * anyway as defence-in-depth).
35
- * - **Budget-bounded.** The whole run shares one deadline (`PROBE_BUDGET_MS`).
38
+ * - **Budget-bounded.** Each probe gets `PROBE_BUDGET_MS` of its OWN work time.
36
39
  * See the ceiling note on that constant for what the bound can and cannot do.
37
40
  * - **Absent is not zero.** A probe that did not run is recorded with the
38
41
  * reason it did not (`skipped` + `reason`), never silently omitted and never
@@ -41,6 +44,7 @@
41
44
  * @module scripts/lib/session-start-probes
42
45
  */
43
46
 
47
+ import { execFileSync } from 'node:child_process';
44
48
  import { existsSync, readFileSync } from 'node:fs';
45
49
  import path from 'node:path';
46
50
  import { pathToFileURL } from 'node:url';
@@ -62,6 +66,169 @@ import { emitEvent } from './events.mjs';
62
66
  */
63
67
  const local = (rel) => pathToFileURL(path.join(import.meta.dirname, rel)).href;
64
68
 
69
+ /** Fallback hint when nothing cheaper than an on-demand CLI call is available. */
70
+ const CI_UNKNOWN_HINT_DEFAULT = 'run `glab ci status` on demand';
71
+
72
+ /**
73
+ * `ci-status` "no data for HEAD" reasons after which the last PUSHED commit is
74
+ * worth asking about: GitLab's `no-pipeline-for-head-sha` and GitHub's
75
+ * `no-check-runs-for-head` (both emitted by `checkCiStatus`).
76
+ */
77
+ const PUSHED_FOLLOW_UP_REASONS = new Set(['no-pipeline-for-head-sha', 'no-check-runs-for-head']);
78
+
79
+ /**
80
+ * Full SHAs for `refs`, in order, from ONE `git rev-parse` — or `null` when
81
+ * any ref does not resolve (no upstream, detached HEAD, not a git repo, git
82
+ * missing). Full, not short: `checkCiStatus({ sha })` matches against GitLab's
83
+ * full pipeline SHAs and refuses anything shorter.
84
+ *
85
+ * NAMED CEILING (BV-004): one `git rev-parse` with a 2s timeout, run only on
86
+ * the {@link PUSHED_FOLLOW_UP_REASONS} branch — i.e. only when the `ci-status`
87
+ * probe already ran (network opt-in) and already found no data for HEAD.
88
+ *
89
+ * @param {string|undefined} repoRoot
90
+ * @param {string[]} refs
91
+ * @returns {string[]|null}
92
+ */
93
+ function revParseShas(repoRoot, refs) {
94
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
95
+ try {
96
+ const out = execFileSync('git', ['rev-parse', ...refs], {
97
+ cwd: repoRoot,
98
+ encoding: 'utf8',
99
+ timeout: 2000,
100
+ stdio: ['ignore', 'pipe', 'ignore'],
101
+ });
102
+ const shas = String(out).trim().split('\n').map((s) => s.trim());
103
+ const valid = shas.length === refs.length
104
+ && shas.every((s) => /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(s));
105
+ return valid ? shas : null;
106
+ } catch {
107
+ // Every failure means "no cheap pushed-SHA hint", never an error worth
108
+ // surfacing here.
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Full SHA of the last PUSHED commit (`@{upstream}`), or `null`.
115
+ *
116
+ * @param {string|undefined} repoRoot
117
+ * @returns {string|null}
118
+ */
119
+ function lastPushedSha(repoRoot) {
120
+ return revParseShas(repoRoot, ['@{upstream}'])?.[0] ?? null;
121
+ }
122
+
123
+ /**
124
+ * `ci-status` follow-up (#1332): when HEAD has no CI data, ask for the verdict
125
+ * of the last PUSHED commit — the commit whose pipeline can actually be red
126
+ * while HEAD reads "unknown".
127
+ *
128
+ * NAMED CEILING (BV-004): at most one `git rev-parse` plus ONE extra CLI round
129
+ * trip (`checkCiStatus({ sha })`), only on a {@link PUSHED_FOLLOW_UP_REASONS}
130
+ * reading, and only behind the existing network opt-in
131
+ * (`SO_PROBES_INCLUDE_NETWORK=1` — without it the whole `ci-status` probe is
132
+ * `skipped: network-probe-opt-in` and this never runs). It runs INSIDE the
133
+ * probe's budget race and gets only what is left of that budget; if it
134
+ * overruns or throws, the runner keeps the HEAD reading it was given (see
135
+ * `runSessionStartProbes`). REVISIT TRIGGER: if `follow_up: 'budget-exceeded'`
136
+ * shows up regularly in `orchestrator.probes.completed`, cache the verdict per
137
+ * pushed SHA instead of re-asking.
138
+ *
139
+ * The CLI round trip is skipped when the pushed commit IS HEAD on GitLab: its
140
+ * HEAD reading was taken for the local HEAD SHA, so the re-query would ask the
141
+ * identical question. Not on GitHub: `checkCiStatus` asks the API for the
142
+ * literal ref `HEAD`, which GitHub resolves to the REMOTE default branch, not
143
+ * the local HEAD — there the pushed SHA is a different question even when it
144
+ * equals local HEAD.
145
+ *
146
+ * @param {*} result The HEAD reading from `checkCiStatus`
147
+ * @param {{repoRoot?: string}} ctx
148
+ * @param {(extra: object) => Promise<*>} requery Re-invokes the probe with extra options
149
+ * @returns {Promise<*>}
150
+ */
151
+ async function ciPushedFollowUp(result, ctx, requery) {
152
+ const reason = result?.details?.reason;
153
+ if (result?.status !== 'unknown' || !PUSHED_FOLLOW_UP_REASONS.has(reason)) {
154
+ return result;
155
+ }
156
+ // One spawn for both: an unresolvable `@{upstream}` fails the whole call,
157
+ // which is exactly the "no upstream" answer.
158
+ const shas = revParseShas(ctx?.repoRoot, ['HEAD', '@{upstream}']);
159
+ // `pushed.sha: null` records "no upstream" so the renderer does not ask git again.
160
+ if (!shas) return { ...result, pushed: { sha: null } };
161
+ const [head, sha] = shas;
162
+ if (reason === 'no-pipeline-for-head-sha' && sha === head) {
163
+ return { ...result, pushed: { sha, sameAsHead: true } };
164
+ }
165
+ let verdict;
166
+ try {
167
+ verdict = await requery({ sha });
168
+ } catch {
169
+ // checkCiStatus never throws; a failure here only loses the verdict detail.
170
+ verdict = null;
171
+ }
172
+ return { ...result, pushed: { sha, verdict } };
173
+ }
174
+
175
+ /**
176
+ * `CI <status> (#<pipeline>)` for a pushed-SHA verdict, or `null` when there
177
+ * is no readable verdict (the caller then falls back to the command hint).
178
+ *
179
+ * @param {*} v A `checkCiStatus` result
180
+ * @returns {string|null}
181
+ */
182
+ function pushedVerdictText(v) {
183
+ if (!v || typeof v !== 'object') return null;
184
+ if (v.degraded) return `CI state unknown (${v.degraded})`;
185
+ if (typeof v.status !== 'string') return null;
186
+ const pid = v.details?.currentPipelineId;
187
+ const parts = [];
188
+ if (pid !== null && pid !== undefined) parts.push(`#${pid}`);
189
+ if (v.status === 'unknown' && v.details?.reason) parts.push(v.details.reason);
190
+ return `CI ${v.status}${parts.length > 0 ? ` (${parts.join(', ')})` : ''}`;
191
+ }
192
+
193
+ /**
194
+ * Hint text for a `status: 'unknown'` ci-status reading.
195
+ *
196
+ * @param {object} result The probe result (possibly carrying `pushed` from
197
+ * {@link ciPushedFollowUp})
198
+ * @param {{repoRoot?: string}} [ctx]
199
+ * @returns {string}
200
+ */
201
+ function ciUnknownHint(result, ctx) {
202
+ if (!PUSHED_FOLLOW_UP_REASONS.has(result?.details?.reason)) return CI_UNKNOWN_HINT_DEFAULT;
203
+ // The GitHub reason names the gh CLI; a glab command there would be wrong.
204
+ const gh = result.details.cliUsed === 'gh';
205
+ const onDemand = gh ? 'run `gh run list` on demand' : CI_UNKNOWN_HINT_DEFAULT;
206
+ const pushed = result.pushed && typeof result.pushed === 'object' ? result.pushed : null;
207
+ // The follow-up already resolved the SHA (or its absence) — never re-ask git.
208
+ // Without `pushed` (follow-up overran or threw) this is the only git call.
209
+ const sha = pushed ? pushed.sha : lastPushedSha(ctx?.repoRoot);
210
+ if (!sha) return onDemand;
211
+ const short = sha.slice(0, 8);
212
+ if (pushed?.sameAsHead) return `last pushed: ${short} = HEAD — ${onDemand}`;
213
+ const verdict = pushedVerdictText(pushed?.verdict);
214
+ if (verdict) return `last pushed: ${short} — ${verdict}`;
215
+ const check = gh ? `gh run list --commit ${sha}` : `glab ci status --ref ${short}`;
216
+ return `last pushed: ${short} (pipeline not checked; run \`${check}\`)`;
217
+ }
218
+
219
+ /**
220
+ * "Green, but allow_failure jobs failed" — the ONE predicate both the
221
+ * `ci-status` renderer and its `severityOf` use (#1333). Two predicates once
222
+ * disagreed: `severityOf` read truthiness, so `allowFailureJobs: []` scored
223
+ * `warn` while the renderer printed nothing.
224
+ *
225
+ * @param {*} r
226
+ * @returns {boolean}
227
+ */
228
+ function hasFailedAllowFailureJobs(r) {
229
+ return Array.isArray(r?.allowFailureJobs) && r.allowFailureJobs.length > 0;
230
+ }
231
+
65
232
  // ---------------------------------------------------------------------------
66
233
  // Budget
67
234
  // ---------------------------------------------------------------------------
@@ -85,19 +252,23 @@ const local = (rel) => pathToFileURL(path.join(import.meta.dirname, rel)).href;
85
252
  * the WORST repo in the fleet by design — see the revisit trigger below, which
86
253
  * this repo already sits just underneath rather than comfortably below.
87
254
  *
88
- * NAMED CEILING (BV-004): the deadline is enforced at `await` points. A probe
89
- * that blocks the event loop *synchronously* (several do `project-hygiene`
90
- * and `tests-src-ratio` shell out with `execFileSync`) cannot be preempted by a
91
- * timer that cannot run; such a probe is reported with its TRUE `durationMs`
92
- * and can overrun this budget. The bound is therefore hard for async/network
93
- * probes and advisory for synchronous ones.
255
+ * NAMED CEILING (BV-004): the budget is denominated in a probe's OWN work time
256
+ * (wall-clock elapsed minus the time a synchronous sibling held the event loop
257
+ * see {@link startLoopBlockedMeter}), and it is PER PROBE, not one shared
258
+ * wall deadline. A probe that blocks the loop *synchronously* (several do
259
+ * `project-hygiene` and `tests-src-ratio` shell out with `execFileSync`) still
260
+ * cannot be preempted by a timer that cannot run, so it is reported with its
261
+ * true cost and can overrun this budget. The bound is therefore hard for
262
+ * async/network probes and advisory for synchronous ones — but it no longer
263
+ * charges the async ones for the synchronous ones' time.
94
264
  *
95
- * READING `durationMs`: probes are launched together, so a probe's individual
96
- * `durationMs` includes time spent waiting for a SIBLING synchronous probe to
97
- * release the event loop. Measured 2026-08-23 here, `loop-readiness` reports
98
- * ~513 ms under parallel launch and 0.5 ms in isolation. Only the run-level
99
- * `duration_ms` is an isolated cost; per-probe figures rank contention, not
100
- * work.
265
+ * READING `durationMs` vs `workMs`: probes are launched together, so a probe's
266
+ * individual `durationMs` includes time spent waiting for a SIBLING
267
+ * synchronous probe to release the event loop. Measured 2026-09-11 here, all
268
+ * 17 non-network probes reported `durationMs` 5.8 s while their isolated
269
+ * costs ranged 0.5–3519 ms. `durationMs` ranks contention; `workMs` the
270
+ * field the `timeout` verdict is computed from, and the one persisted as
271
+ * `work_ms` — ranks work.
101
272
  *
102
273
  * REVISIT TRIGGER: if `duration_ms` in `orchestrator.probes.completed` exceeds
103
274
  * half this budget at the median across a repo's recorded starts, or if any
@@ -110,6 +281,71 @@ export const PROBE_BUDGET_MS = 2000;
110
281
  /** Sentinel resolved by the deadline race; never leaks to a caller. */
111
282
  const TIMED_OUT = Symbol('probe-timeout');
112
283
 
284
+ /**
285
+ * Event-loop-lag sampling interval for {@link startLoopBlockedMeter}.
286
+ *
287
+ * NAMED CEILING (BV-004): 20 ms is ~50 wakeups/s, negligible next to the
288
+ * run's own hundreds of ms, and fine-grained enough that a block shorter than
289
+ * one sample is also shorter than anything the budget cares about. REVISIT
290
+ * TRIGGER: if a probe's own work ever needs to be bounded below ~100 ms, this
291
+ * sampling floor becomes the measurement error and needs `perf_hooks`
292
+ * `monitorEventLoopDelay` instead.
293
+ */
294
+ const LOOP_BLOCKED_SAMPLE_MS = 20;
295
+
296
+ /**
297
+ * Measure how long the event loop was monopolised by SYNCHRONOUS work.
298
+ *
299
+ * Why this exists (measured 2026-09-11 against this repo, `PROBES` run in
300
+ * parallel): `project-hygiene` (3519 ms) and `tests-src-ratio` (314 ms) shell
301
+ * out with `execFileSync` and cannot be preempted. Every probe's wall-clock
302
+ * `durationMs` therefore read ~5.8 s while its own work was 0.5–66 ms, and the
303
+ * only two probes reported as `timeout` were the two that YIELD to the event
304
+ * loop mid-work (`peer-cards-staleness`, 7 ms; `maintenance-due`, 40 ms) — a
305
+ * probe whose work is synchronous wins its own race in a microtask before the
306
+ * long-expired macrotask timer can run. The old instrument therefore graded
307
+ * ASYNCHRONY, not cost: the two cheapest preemptible probes took the blame for
308
+ * the two most expensive non-preemptible ones (`.claude/rules/host-resources.md`
309
+ * § HR-103 — check the unit before the threshold; § HR-106 — report what the
310
+ * rule judged).
311
+ *
312
+ * A timer scheduled every {@link LOOP_BLOCKED_SAMPLE_MS} that fires late by
313
+ * `d` proves the loop was unavailable for `d`. Summing that lateness gives the
314
+ * blocked time, which is subtracted from a probe's wall-clock elapsed to yield
315
+ * its OWN cost — the quantity `PROBE_BUDGET_MS` was always meant to bound.
316
+ *
317
+ * NAMED CEILING (BV-004): the meter knows THAT the loop was blocked, never BY
318
+ * WHOM — so the blocker's own blocking time is subtracted from its own
319
+ * `workMs` too. Measured 2026-09-11 here: `project-hygiene`, the ~1–3.5 s
320
+ * `execFileSync` blocker, reports `workMs` ≈ 9 ms. Read `workMs` as EXACT for a
321
+ * preemptible probe and as a LOWER BOUND for a synchronous one — which is
322
+ * coherent with what the budget can do (a non-preemptible probe was never
323
+ * boundable), but it means `workMs` must never be used to rank the synchronous
324
+ * probes against each other. REVISIT TRIGGER: if a synchronous probe ever has
325
+ * to be held to a budget, attribute the gap to the probe that caused it
326
+ * (`async_hooks`, or run the shell-outs in a worker) rather than re-tuning this
327
+ * subtraction. Isolated per-probe cost, for now, is measured by running one
328
+ * probe per process.
329
+ *
330
+ * @returns {{ read: () => number, stop: () => void }}
331
+ */
332
+ function startLoopBlockedMeter() {
333
+ let blockedMs = 0;
334
+ let last = Date.now();
335
+ const timer = setInterval(() => {
336
+ const now = Date.now();
337
+ blockedMs += Math.max(0, now - last - LOOP_BLOCKED_SAMPLE_MS);
338
+ last = now;
339
+ }, LOOP_BLOCKED_SAMPLE_MS);
340
+ if (typeof timer?.unref === 'function') timer.unref();
341
+ return {
342
+ // The in-progress gap counts too: after a long block the interval callback
343
+ // may not have run yet when a probe callback asks.
344
+ read: () => blockedMs + Math.max(0, Date.now() - last - LOOP_BLOCKED_SAMPLE_MS),
345
+ stop: () => clearInterval(timer),
346
+ };
347
+ }
348
+
113
349
  // ---------------------------------------------------------------------------
114
350
  // Probe registry
115
351
  // ---------------------------------------------------------------------------
@@ -119,12 +355,24 @@ const TIMED_OUT = Symbol('probe-timeout');
119
355
  * introduces them (banner order is registry order, never completion order, so
120
356
  * a run is reproducible).
121
357
  *
122
- * Census (2026-08-23, `skills/session-start/SKILL.md` lines 693-835):
123
- * sed -n '693,835p' skills/session-start/SKILL.md \
124
- * | grep -oE 'via `(await )?check[A-Za-z]+' # -> 16
358
+ * Census (2026-09-11, `skills/session-start/references/phase-4-ssot-environment-check.md`
359
+ * the #1157 SKILL.md-size split extracted Phase 4's procedure out of
360
+ * `SKILL.md` itself, so the original 2026-08-23 citation of SKILL.md lines
361
+ * 693-835 no longer resolves; re-run the census against the reference file):
362
+ * grep -oE 'via `(await )?check[A-Za-z]+' \
363
+ * skills/session-start/references/phase-4-ssot-environment-check.md # -> 17
125
364
  * plus two probes the prose introduces with different phrasing —
126
365
  * `checkBootstrapLockFreshness` ("invoke the bootstrap-lock-freshness probe")
127
- * and `checkVaultStaleness` ("read the most recent line via …") — for 18.
366
+ * and `checkVaultStaleness` ("read the most recent line via …") — for 19.
367
+ * (Prior census, 2026-08-23 at `4f6404e`: 16 + 2 = 18 — one probe was
368
+ * registered since without a matching update here. This count is deliberately
369
+ * NOT pinned by an exact-equality test against `PROBES.length` — the registry
370
+ * is exactly the "dynamically-grown artifact set" `.claude/rules/testing.md`
371
+ * § Dynamic Artifact Counts bans exact-count assertions for, and a regex over
372
+ * this prose paragraph would break on every rewording rather than on drift
373
+ * that matters. Re-run the grep above by hand whenever a probe is added or
374
+ * removed, so this comment and `skills/session-start/SKILL.md` § Phase 4
375
+ * stay in sync with each other.)
128
376
  *
129
377
  * Each entry:
130
378
  * - `id` stable telemetry id (also the banner-ordering key)
@@ -135,6 +383,11 @@ const TIMED_OUT = Symbol('probe-timeout');
135
383
  * - `precondition` optional; returns a skip-reason string to skip the probe
136
384
  * - `render` optional; maps a result to a banner line. Default:
137
385
  * `result.message` when severity is warn/alert.
386
+ * - `followUp` optional; `async (result, ctx, requery) => result'` run
387
+ * INSIDE the budget race after the entry function, where
388
+ * `requery(extra)` re-invokes it with `{...args, ...extra}`.
389
+ * If it throws or overruns the budget, the entry function's
390
+ * result stands (recorded `followUp: 'threw'|'budget-exceeded'`).
138
391
  */
139
392
  export const PROBES = [
140
393
  {
@@ -186,6 +439,10 @@ export const PROBES = [
186
439
  fn: 'checkCiStatus',
187
440
  network: true,
188
441
  args: ({ repoRoot }) => ({ repoRoot }),
442
+ // #1332: on `no-pipeline-for-head-sha` / `no-check-runs-for-head`, query
443
+ // the last PUSHED commit's verdict via `checkCiStatus({ sha })` — see the
444
+ // ceiling on the function.
445
+ followUp: ciPushedFollowUp,
189
446
  // Bespoke shape: `{status, ok, details, …}` with no `message` field. The
190
447
  // banner text is prescribed by SKILL.md § Phase 4.
191
448
  //
@@ -195,9 +452,22 @@ export const PROBES = [
195
452
  // probe. Without these two lines a degraded ci-status result scored `'ok'`
196
453
  // and rendered nothing — "could not read" displayed exactly like "green",
197
454
  // which is the confusion the probe's own migration removed one layer down.
198
- render: (r) => {
455
+ render: (r, ctx) => {
199
456
  if (!r || typeof r !== 'object') return null;
200
457
  if (r.degraded) return typeof r.message === 'string' && r.message ? r.message : null;
458
+ // `status: 'unknown'` is the SAME collapse one level over: HEAD carries
459
+ // no pipeline (the normal state of a working session with local commits),
460
+ // so the probe cannot say anything about CI — while the last PUSHED
461
+ // commit may be red. Measured 2026-09-12: HEAD had 2 unpushed commits,
462
+ // origin/main's pipeline #9301 was red, and session-start printed
463
+ // nothing. Silence there reads as green; it is not.
464
+ if (r.status === 'unknown') {
465
+ const reason = r.details?.reason ?? 'reason unrecorded';
466
+ // #1337: a RED pushed commit is an alert, and the banner says what the
467
+ // rule judges (HR-106) — same 🚨 as a red HEAD.
468
+ const mark = r.pushed?.verdict?.status === 'red' ? '🚨' : '⚠';
469
+ return `${mark} ci-status: CI status for HEAD could not be determined (${reason}) — ${ciUnknownHint(r, ctx)}`;
470
+ }
201
471
  if (r.status === 'red') {
202
472
  const pid = r.details?.currentPipelineId ?? '?';
203
473
  const green = r.lastGreen
@@ -206,7 +476,7 @@ export const PROBES = [
206
476
  const job = r.failingJobName ? ` Failing job: ${r.failingJobName}` : '';
207
477
  return `🚨 CI RED on HEAD (pipeline #${pid})${green}.${job}`;
208
478
  }
209
- if (r.status === 'green' && Array.isArray(r.allowFailureJobs) && r.allowFailureJobs.length > 0) {
479
+ if (r.status === 'green' && hasFailedAllowFailureJobs(r)) {
210
480
  const names = r.allowFailureJobs.map((j) => j?.name ?? String(j)).join(', ');
211
481
  return `⚠ CI green on HEAD, but ${r.allowFailureJobs.length} allow_failure job(s) FAILED: ${names}. A pipeline reports success regardless of these.`;
212
482
  }
@@ -215,14 +485,20 @@ export const PROBES = [
215
485
  // `status: 'red'` is an alert even though the probe publishes no severity.
216
486
  // A degraded result is a finding, never clean — same rule as the generic
217
487
  // path in `severityOf()` below.
218
- severityOf: (r) =>
219
- r?.degraded
220
- ? 'warn'
221
- : r?.status === 'red'
222
- ? 'alert'
223
- : r?.status === 'green' && r?.allowFailureJobs
224
- ? 'warn'
225
- : 'ok',
488
+ severityOf: (r) => {
489
+ // `null` is a COMPLETE answer — "this repo has no CI" — and stays clean.
490
+ // Every other non-green status is "state not determined", never `ok`
491
+ // (HR-105: a probe that scores an undeterminable state as clean is an
492
+ // instrument that cannot report the thing it exists to report).
493
+ if (!r || typeof r !== 'object') return 'ok';
494
+ if (r.degraded) return 'warn';
495
+ if (r.status === 'red') return 'alert';
496
+ if (r.status === 'green') return hasFailedAllowFailureJobs(r) ? 'warn' : 'ok';
497
+ // #1337: HEAD undetermined but the pushed commit's pipeline is red —
498
+ // the code on origin is broken, so this is an alert, not a warning.
499
+ if (r.pushed?.verdict?.status === 'red') return 'alert';
500
+ return 'warn';
501
+ },
226
502
  },
227
503
  {
228
504
  id: 'qg-command-drift',
@@ -260,9 +536,16 @@ export const PROBES = [
260
536
  args: ({ repoRoot }) => ({ repoRoot }),
261
537
  },
262
538
  {
263
- id: 'reconcile-nudge',
264
- spec: local('./reconcile-nudge-banner.mjs'),
265
- fn: 'checkReconcileNudge',
539
+ // Replaces the former `reconcile-nudge` entry AND the two session-end
540
+ // nudges (3.6.5 auto-dream, 3.6.7 auto-dialectic): one reading of the whole
541
+ // maintenance loop, at the one moment the operator can act on it. The
542
+ // reconcile signal is not lost — `maintenance-due-banner.mjs` calls
543
+ // `computeReconcileNudge` wholesale as its S3, so `reconcile-nudge-banner.mjs`
544
+ // remains a live dependency, just no longer its own registry entry (a
545
+ // second entry would double-report the same finding).
546
+ id: 'maintenance-due',
547
+ spec: local('./maintenance-due-banner.mjs'),
548
+ fn: 'checkMaintenanceDue',
266
549
  network: false,
267
550
  args: ({ repoRoot, config }) => ({ repoRoot, config }),
268
551
  },
@@ -363,29 +646,67 @@ function pluginVersion() {
363
646
  }
364
647
 
365
648
  /**
366
- * Race `promise` against a deadline.
649
+ * Race `promise` against a budget denominated in the probe's OWN work time —
650
+ * wall-clock elapsed MINUS the time a synchronous sibling held the event loop
651
+ * (see {@link startLoopBlockedMeter} for why that subtraction is the whole
652
+ * point).
367
653
  *
368
654
  * The loser is neutralised (`.catch`) before the race so a late rejection can
369
655
  * never surface as an unhandled rejection and kill an exit-0 hook.
370
656
  *
657
+ * Two properties worth stating, because both were live defects:
658
+ *
659
+ * - **A delivered result is never discarded.** When the budget really is
660
+ * exhausted, the already-settled promise still wins: "measured, then thrown
661
+ * away" is worse than not measuring (the module's own rule, stated at the
662
+ * telemetry boundary below). Only a probe that has produced nothing is a
663
+ * `timeout`.
664
+ * - **Termination.** The re-arm loop only repeats while blocked time is
665
+ * accruing, i.e. while some sibling monopolises the loop. Siblings are
666
+ * finite and synchronous, so the loop cannot spin forever; once they
667
+ * release, work time advances and the budget binds normally.
668
+ *
371
669
  * @template T
372
670
  * @param {Promise<T>} promise
373
- * @param {number} msremaining budget; `<= 0` times out immediately.
374
- * @returns {Promise<T|symbol>} resolves to {@link TIMED_OUT} on expiry.
671
+ * @param {number} budgetMsthe probe's own-work budget; `<= 0` times out.
672
+ * @param {{ read: () => number }} meter — loop-blocked meter.
673
+ * @returns {Promise<{ raced: T|symbol, workMs: number }>}
375
674
  */
376
- async function withDeadline(promise, ms) {
377
- const settled = promise.catch((err) => ({ __probeError: err }));
378
- if (ms <= 0) return TIMED_OUT;
379
- let timer;
380
- const expiry = new Promise((resolve) => {
381
- timer = setTimeout(() => resolve(TIMED_OUT), ms);
382
- // Never hold the event loop open for the timer alone.
383
- if (typeof timer?.unref === 'function') timer.unref();
675
+ async function withWorkDeadline(promise, budgetMs, meter) {
676
+ let done = false;
677
+ /** @type {unknown} */
678
+ let value;
679
+ const settled = promise.catch((err) => ({ __probeError: err })).then((v) => {
680
+ done = true;
681
+ value = v;
682
+ return v;
384
683
  });
385
- try {
386
- return await Promise.race([settled, expiry]);
387
- } finally {
388
- clearTimeout(timer);
684
+
685
+ const t0 = Date.now();
686
+ const blocked0 = meter.read();
687
+ const workElapsed = () => Date.now() - t0 - (meter.read() - blocked0);
688
+
689
+ for (;;) {
690
+ const remaining = budgetMs - workElapsed();
691
+ if (remaining <= 0) {
692
+ // Budget exhausted — but a probe that already delivered is not a timeout.
693
+ return { raced: done ? value : TIMED_OUT, workMs: workElapsed() };
694
+ }
695
+ let timer;
696
+ const expiry = new Promise((resolve) => {
697
+ timer = setTimeout(() => resolve(TIMED_OUT), remaining);
698
+ // Never hold the event loop open for the timer alone.
699
+ if (typeof timer?.unref === 'function') timer.unref();
700
+ });
701
+ let raced;
702
+ try {
703
+ raced = await Promise.race([settled, expiry]);
704
+ } finally {
705
+ clearTimeout(timer);
706
+ }
707
+ if (raced !== TIMED_OUT) return { raced, workMs: workElapsed() };
708
+ // The timer fired, but it may have fired LATE because the loop was blocked.
709
+ // Recheck against work time; re-arm for whatever budget is genuinely left.
389
710
  }
390
711
  }
391
712
 
@@ -408,9 +729,7 @@ function severityOf(result, probe) {
408
729
  }
409
730
  if (result === null || result === undefined) return 'ok';
410
731
  const s = result.severity;
411
- if (s === 'alert') return 'alert';
412
- if (s === 'warn') return 'warn';
413
- return 'ok';
732
+ return s === 'alert' || s === 'warn' ? s : 'ok';
414
733
  }
415
734
 
416
735
  /**
@@ -500,7 +819,12 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
500
819
  const includeNetwork = env?.SO_PROBES_INCLUDE_NETWORK === '1';
501
820
 
502
821
  const ctx = { repoRoot, config, env };
503
- const deadline = started + Math.max(0, Number(timeoutMs) || 0);
822
+ // Per-probe, denominated in the probe's own work time — NOT a shared
823
+ // wall-clock deadline. A shared wall deadline charged every probe for its
824
+ // siblings' non-preemptible `execFileSync` calls; see
825
+ // {@link startLoopBlockedMeter}.
826
+ const budgetMs = Math.max(0, Number(timeoutMs) || 0);
827
+ const meter = startLoopBlockedMeter();
504
828
 
505
829
  /** @type {Map<string, object>} */
506
830
  const byId = new Map();
@@ -536,6 +860,15 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
536
860
  return;
537
861
  }
538
862
 
863
+ // The entry function's result once it has returned, for probes with a
864
+ // `followUp`. The follow-up is optional enrichment: when it throws or
865
+ // overruns, the probe still DELIVERED, and "a probe that already
866
+ // delivered is not a timeout" (withWorkDeadline) applies to it too.
867
+ /** @type {{__probeResult: *}|null} */
868
+ let delivered = null;
869
+ /** @type {'threw'|'budget-exceeded'|undefined} */
870
+ let followUpFailure;
871
+
539
872
  // The whole invocation — import included — is inside the race, because a
540
873
  // pre-#369-style absent module and a hung probe are both "did not
541
874
  // deliver" and both must resolve to an outcome rather than to a throw.
@@ -545,13 +878,36 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
545
878
  if (typeof fn !== 'function') {
546
879
  return { __probeError: new Error(`export ${probe.fn} missing`), __absent: true };
547
880
  }
548
- return { __probeResult: await fn(probe.args(ctx)) };
881
+ const first = await fn(probe.args(ctx));
882
+ if (typeof probe.followUp !== 'function') return { __probeResult: first };
883
+ delivered = { __probeResult: first };
884
+ // Optional second step (#1332, `ci-status` only today), inside this
885
+ // race so it spends the SAME per-probe budget — NAMED CEILING (BV-004):
886
+ // it gets exactly what the entry function left of `budgetMs`, with no
887
+ // second timer and no margin, because the fallback is decided AFTER
888
+ // the race below: a timeout with `delivered` set is `delivered`.
889
+ try {
890
+ return {
891
+ __probeResult: await probe.followUp(first, ctx, (extra) => fn({ ...probe.args(ctx), ...extra })),
892
+ };
893
+ } catch {
894
+ followUpFailure = 'threw';
895
+ return delivered;
896
+ }
549
897
  })();
550
898
 
551
- const raced = await withDeadline(invocation, deadline - Date.now());
899
+ const deadline = await withWorkDeadline(invocation, budgetMs, meter);
900
+ const { workMs } = deadline;
901
+ let { raced } = deadline;
902
+ if (raced === TIMED_OUT && delivered) {
903
+ // The follow-up is what ran out of budget, not the probe. Its pending
904
+ // promise is abandoned exactly as a hung probe's would be.
905
+ raced = delivered;
906
+ followUpFailure = 'budget-exceeded';
907
+ }
552
908
 
553
909
  if (raced === TIMED_OUT) {
554
- record('timeout', { reason: 'budget-exceeded' });
910
+ record('timeout', { reason: 'budget-exceeded', workMs });
555
911
  return;
556
912
  }
557
913
  if (raced && raced.__probeError) {
@@ -567,16 +923,24 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
567
923
 
568
924
  const result = raced?.__probeResult;
569
925
  const severity = severityOf(result, probe);
926
+ // `ctx` (repoRoot/config/env) is passed as a SECOND argument so a
927
+ // renderer can name a repo-local fact the probe result does not carry
928
+ // (the `ci-status` unknown branch names the last pushed SHA). Every
929
+ // existing renderer takes one parameter and ignores it.
570
930
  const line = typeof probe.render === 'function'
571
- ? probe.render(result)
931
+ ? probe.render(result, ctx)
572
932
  : defaultRender(result, severity);
573
933
  record(severity === 'ok' ? 'ran-clean' : severity === 'warn' ? 'ran-warn' : 'ran-alert', {
574
934
  severity,
935
+ workMs,
936
+ ...(followUpFailure ? { followUp: followUpFailure } : {}),
575
937
  ...(line ? { line } : {}),
576
938
  });
577
939
  }),
578
940
  );
579
941
 
942
+ meter.stop();
943
+
580
944
  // Registry order, never completion order — a run must be reproducible.
581
945
  const results = [];
582
946
  const bannerLines = [];
@@ -624,6 +988,15 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
624
988
  id: r.id,
625
989
  outcome: r.outcome,
626
990
  ...(typeof r.reason === 'string' && r.reason.length > 0 ? { reason: r.reason } : {}),
991
+ // `work_ms` is the quantity the `timeout` verdict is computed FROM, so it
992
+ // has to reach the ledger (`.claude/rules/host-resources.md` § HR-105 — a
993
+ // rule you cannot falsify is not a rule). `duration_ms` at probe level is
994
+ // deliberately NOT persisted: under parallel launch it ranks contention,
995
+ // not work, and persisting a misleading field was the #1089 failure.
996
+ ...(Number.isFinite(r.workMs) ? { work_ms: Math.round(r.workMs) } : {}),
997
+ // A follow-up that fell back to the delivered result is otherwise
998
+ // invisible (the outcome is `ran-*`); this is its revisit trigger's input.
999
+ ...(typeof r.followUp === 'string' ? { follow_up: r.followUp } : {}),
627
1000
  })),
628
1001
  };
629
1002