session-orchestrator 4.2.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.
- package/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +94 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/commands/ux-grill.md +51 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/codex-setup.md +8 -0
- package/docs/components.md +7 -7
- package/docs/events-schema.md +9 -5
- package/docs/github-mirror-protection.md +61 -20
- package/docs/migration-v5.md +62 -0
- package/docs/scope-collision-guard.md +14 -0
- package/hooks/_lib/hook-import-set.json +44 -2
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +190 -3
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +51 -4
- package/package.json +2 -1
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +114 -13
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget.mjs +336 -6
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +11 -1
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +14 -65
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-start-probes.mjs +419 -53
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +48 -20
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/mcp-server.sh +16 -1
- package/scripts/release.mjs +7 -2
- package/skills/bootstrap/SKILL.md +12 -209
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +7 -3
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +10 -0
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +4 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-5-issue-cleanup.md +6 -1
- package/skills/session-end/session-metrics-write.md +2 -0
- package/skills/session-plan/SKILL.md +2 -144
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-start/SKILL.md +24 -6
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +22 -20
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +3 -128
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +18 -5
- package/templates/_shared/ux-manifest.template.md +149 -0
- 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
|
|
7
|
-
* with a module path and an entry function
|
|
8
|
-
*
|
|
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.**
|
|
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
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* and
|
|
93
|
-
*
|
|
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
|
|
96
|
-
* `durationMs` includes time spent waiting for a SIBLING
|
|
97
|
-
* release the event loop. Measured 2026-
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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-
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
|
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' &&
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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',
|
|
@@ -370,29 +646,67 @@ function pluginVersion() {
|
|
|
370
646
|
}
|
|
371
647
|
|
|
372
648
|
/**
|
|
373
|
-
* Race `promise` against a
|
|
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).
|
|
374
653
|
*
|
|
375
654
|
* The loser is neutralised (`.catch`) before the race so a late rejection can
|
|
376
655
|
* never surface as an unhandled rejection and kill an exit-0 hook.
|
|
377
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
|
+
*
|
|
378
669
|
* @template T
|
|
379
670
|
* @param {Promise<T>} promise
|
|
380
|
-
* @param {number}
|
|
381
|
-
* @
|
|
671
|
+
* @param {number} budgetMs — the probe's own-work budget; `<= 0` times out.
|
|
672
|
+
* @param {{ read: () => number }} meter — loop-blocked meter.
|
|
673
|
+
* @returns {Promise<{ raced: T|symbol, workMs: number }>}
|
|
382
674
|
*/
|
|
383
|
-
async function
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
let
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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;
|
|
391
683
|
});
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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.
|
|
396
710
|
}
|
|
397
711
|
}
|
|
398
712
|
|
|
@@ -415,9 +729,7 @@ function severityOf(result, probe) {
|
|
|
415
729
|
}
|
|
416
730
|
if (result === null || result === undefined) return 'ok';
|
|
417
731
|
const s = result.severity;
|
|
418
|
-
|
|
419
|
-
if (s === 'warn') return 'warn';
|
|
420
|
-
return 'ok';
|
|
732
|
+
return s === 'alert' || s === 'warn' ? s : 'ok';
|
|
421
733
|
}
|
|
422
734
|
|
|
423
735
|
/**
|
|
@@ -507,7 +819,12 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
|
|
|
507
819
|
const includeNetwork = env?.SO_PROBES_INCLUDE_NETWORK === '1';
|
|
508
820
|
|
|
509
821
|
const ctx = { repoRoot, config, env };
|
|
510
|
-
|
|
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();
|
|
511
828
|
|
|
512
829
|
/** @type {Map<string, object>} */
|
|
513
830
|
const byId = new Map();
|
|
@@ -543,6 +860,15 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
|
|
|
543
860
|
return;
|
|
544
861
|
}
|
|
545
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
|
+
|
|
546
872
|
// The whole invocation — import included — is inside the race, because a
|
|
547
873
|
// pre-#369-style absent module and a hung probe are both "did not
|
|
548
874
|
// deliver" and both must resolve to an outcome rather than to a throw.
|
|
@@ -552,13 +878,36 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
|
|
|
552
878
|
if (typeof fn !== 'function') {
|
|
553
879
|
return { __probeError: new Error(`export ${probe.fn} missing`), __absent: true };
|
|
554
880
|
}
|
|
555
|
-
|
|
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
|
+
}
|
|
556
897
|
})();
|
|
557
898
|
|
|
558
|
-
const
|
|
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
|
+
}
|
|
559
908
|
|
|
560
909
|
if (raced === TIMED_OUT) {
|
|
561
|
-
record('timeout', { reason: 'budget-exceeded' });
|
|
910
|
+
record('timeout', { reason: 'budget-exceeded', workMs });
|
|
562
911
|
return;
|
|
563
912
|
}
|
|
564
913
|
if (raced && raced.__probeError) {
|
|
@@ -574,16 +923,24 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
|
|
|
574
923
|
|
|
575
924
|
const result = raced?.__probeResult;
|
|
576
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.
|
|
577
930
|
const line = typeof probe.render === 'function'
|
|
578
|
-
? probe.render(result)
|
|
931
|
+
? probe.render(result, ctx)
|
|
579
932
|
: defaultRender(result, severity);
|
|
580
933
|
record(severity === 'ok' ? 'ran-clean' : severity === 'warn' ? 'ran-warn' : 'ran-alert', {
|
|
581
934
|
severity,
|
|
935
|
+
workMs,
|
|
936
|
+
...(followUpFailure ? { followUp: followUpFailure } : {}),
|
|
582
937
|
...(line ? { line } : {}),
|
|
583
938
|
});
|
|
584
939
|
}),
|
|
585
940
|
);
|
|
586
941
|
|
|
942
|
+
meter.stop();
|
|
943
|
+
|
|
587
944
|
// Registry order, never completion order — a run must be reproducible.
|
|
588
945
|
const results = [];
|
|
589
946
|
const bannerLines = [];
|
|
@@ -631,6 +988,15 @@ export async function runSessionStartProbes(opts = {}, deps = {}) {
|
|
|
631
988
|
id: r.id,
|
|
632
989
|
outcome: r.outcome,
|
|
633
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 } : {}),
|
|
634
1000
|
})),
|
|
635
1001
|
};
|
|
636
1002
|
|