@dzhechkov/harness-core 0.8.10 → 0.8.20
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/.dz-manifest.json +490 -170
- package/LICENSE +13 -0
- package/README.md +420 -6
- package/dist/agents-policy.d.ts +15 -1
- package/dist/agents-policy.d.ts.map +1 -1
- package/dist/agents-policy.js +27 -1
- package/dist/agents-policy.js.map +1 -1
- package/dist/amendment-trace.d.ts +72 -0
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +385 -17
- package/dist/amendment-trace.js.map +1 -1
- package/dist/backlog-public.d.ts +153 -0
- package/dist/backlog-public.d.ts.map +1 -0
- package/dist/backlog-public.js +415 -0
- package/dist/backlog-public.js.map +1 -0
- package/dist/backlog-transitions.d.ts +48 -0
- package/dist/backlog-transitions.d.ts.map +1 -0
- package/dist/backlog-transitions.js +64 -0
- package/dist/backlog-transitions.js.map +1 -0
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +13 -0
- package/dist/backlog.js.map +1 -1
- package/dist/claim-check.d.ts.map +1 -1
- package/dist/claim-check.js +24 -2
- package/dist/claim-check.js.map +1 -1
- package/dist/claude-hooks-assets.d.ts +93 -0
- package/dist/claude-hooks-assets.d.ts.map +1 -0
- package/dist/claude-hooks-assets.js +208 -0
- package/dist/claude-hooks-assets.js.map +1 -0
- package/dist/clean-room-smoke.d.ts +83 -0
- package/dist/clean-room-smoke.d.ts.map +1 -0
- package/dist/clean-room-smoke.js +138 -0
- package/dist/clean-room-smoke.js.map +1 -0
- package/dist/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +36 -6
- package/dist/cmd-usage.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts +25 -7
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +138 -30
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +21 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +21 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/codex-invoke.d.ts +73 -0
- package/dist/codex-invoke.d.ts.map +1 -0
- package/dist/codex-invoke.js +80 -0
- package/dist/codex-invoke.js.map +1 -0
- package/dist/course-staleness.d.ts +19 -0
- package/dist/course-staleness.d.ts.map +1 -0
- package/dist/course-staleness.js +95 -0
- package/dist/course-staleness.js.map +1 -0
- package/dist/destructive-guard-hook.d.ts +40 -0
- package/dist/destructive-guard-hook.d.ts.map +1 -0
- package/dist/destructive-guard-hook.js +109 -0
- package/dist/destructive-guard-hook.js.map +1 -0
- package/dist/destructive-guard.d.ts +27 -0
- package/dist/destructive-guard.d.ts.map +1 -0
- package/dist/destructive-guard.js +2808 -0
- package/dist/destructive-guard.js.map +1 -0
- package/dist/discrimination-gate.d.ts +88 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +173 -16
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/event-chain.d.ts +30 -0
- package/dist/event-chain.d.ts.map +1 -1
- package/dist/event-chain.js +24 -0
- package/dist/event-chain.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +95 -1
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +193 -27
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +98 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +308 -0
- package/dist/guard.js.map +1 -1
- package/dist/harness-core-location.d.ts +18 -0
- package/dist/harness-core-location.d.ts.map +1 -0
- package/dist/harness-core-location.js +42 -0
- package/dist/harness-core-location.js.map +1 -0
- package/dist/index.d.ts +32 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +32 -7
- package/dist/index.js.map +1 -1
- package/dist/lead-shift-gate.d.ts +95 -0
- package/dist/lead-shift-gate.d.ts.map +1 -0
- package/dist/lead-shift-gate.js +100 -0
- package/dist/lead-shift-gate.js.map +1 -0
- package/dist/ledger-backfill.d.ts +11 -1
- package/dist/ledger-backfill.d.ts.map +1 -1
- package/dist/ledger-backfill.js +19 -0
- package/dist/ledger-backfill.js.map +1 -1
- package/dist/loop-blobs.generated.d.ts +1 -1
- package/dist/loop-blobs.generated.d.ts.map +1 -1
- package/dist/loop-blobs.generated.js +12 -3
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-lint.d.ts.map +1 -1
- package/dist/loop-lint.js +56 -7
- package/dist/loop-lint.js.map +1 -1
- package/dist/loop-plan-graph.d.ts +1 -3
- package/dist/loop-plan-graph.d.ts.map +1 -1
- package/dist/loop-plan-graph.js +70 -1
- package/dist/loop-plan-graph.js.map +1 -1
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +16 -2
- package/dist/loop-trace.js.map +1 -1
- package/dist/managed-hooks.d.ts +5 -6
- package/dist/managed-hooks.d.ts.map +1 -1
- package/dist/managed-hooks.js +2 -2
- package/dist/managed-hooks.js.map +1 -1
- package/dist/model-recommender.d.ts.map +1 -1
- package/dist/model-recommender.js +14 -3
- package/dist/model-recommender.js.map +1 -1
- package/dist/mutation-gate.d.ts +39 -36
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +111 -5
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +90 -5
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts +23 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +10 -0
- package/dist/patterns.js.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +27 -5
- package/dist/plugin.js.map +1 -1
- package/dist/publish.d.ts +11 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +16 -2
- package/dist/publish.js.map +1 -1
- package/dist/recommend.d.ts +4 -5
- package/dist/recommend.d.ts.map +1 -1
- package/dist/recommend.js +110 -45
- package/dist/recommend.js.map +1 -1
- package/dist/registry.d.ts +32 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +168 -11
- package/dist/registry.js.map +1 -1
- package/dist/run-records.d.ts +3 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +18 -0
- package/dist/run-records.js.map +1 -1
- package/dist/score.d.ts +95 -0
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +289 -6
- package/dist/score.js.map +1 -1
- package/dist/session-retro.d.ts +121 -2
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +454 -11
- package/dist/session-retro.js.map +1 -1
- package/dist/setup.d.ts +28 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +223 -6
- package/dist/setup.js.map +1 -1
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +18 -1
- package/dist/sign.js.map +1 -1
- package/dist/skill-selection.d.ts +72 -0
- package/dist/skill-selection.d.ts.map +1 -0
- package/dist/skill-selection.js +76 -0
- package/dist/skill-selection.js.map +1 -0
- package/dist/skills-verify.d.ts +34 -1
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +82 -0
- package/dist/skills-verify.js.map +1 -1
- package/dist/stage-line.d.ts +68 -0
- package/dist/stage-line.d.ts.map +1 -0
- package/dist/stage-line.js +129 -0
- package/dist/stage-line.js.map +1 -0
- package/dist/statusline.d.ts +99 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +310 -44
- package/dist/statusline.js.map +1 -1
- package/dist/stem.d.ts +12 -0
- package/dist/stem.d.ts.map +1 -0
- package/dist/stem.js +89 -0
- package/dist/stem.js.map +1 -0
- package/dist/store-counts.d.ts +26 -0
- package/dist/store-counts.d.ts.map +1 -0
- package/dist/store-counts.js +125 -0
- package/dist/store-counts.js.map +1 -0
- package/dist/store-guard.d.ts +106 -0
- package/dist/store-guard.d.ts.map +1 -0
- package/dist/store-guard.js +294 -0
- package/dist/store-guard.js.map +1 -0
- package/dist/swarm-brief.d.ts +95 -0
- package/dist/swarm-brief.d.ts.map +1 -0
- package/dist/swarm-brief.js +660 -0
- package/dist/swarm-brief.js.map +1 -0
- package/dist/telemetry-vocabulary.d.ts +7 -0
- package/dist/telemetry-vocabulary.d.ts.map +1 -1
- package/dist/telemetry-vocabulary.js +29 -0
- package/dist/telemetry-vocabulary.js.map +1 -1
- package/dist/trace-bundle.d.ts +8 -0
- package/dist/trace-bundle.d.ts.map +1 -1
- package/dist/trace-bundle.js +11 -0
- package/dist/trace-bundle.js.map +1 -1
- package/package.json +10 -9
- package/sbom.json +1033 -233
- package/src/agents-policy.ts +46 -2
- package/src/amendment-trace.ts +441 -19
- package/src/backlog-public.ts +503 -0
- package/src/backlog-transitions.ts +77 -0
- package/src/backlog.ts +12 -0
- package/src/claim-check.ts +25 -2
- package/src/claude-hooks-assets.ts +227 -0
- package/src/clean-room-smoke.ts +195 -0
- package/src/cmd-usage.ts +29 -5
- package/src/codex-hooks-assets.ts +140 -30
- package/src/codex-hooks.ts +21 -1
- package/src/codex-invoke.ts +138 -0
- package/src/course-staleness.ts +125 -0
- package/src/destructive-guard-hook.ts +151 -0
- package/src/destructive-guard.ts +3027 -0
- package/src/discrimination-gate.ts +262 -19
- package/src/event-chain.ts +41 -0
- package/src/feature-adr-routing.ts +220 -22
- package/src/guard.ts +358 -0
- package/src/harness-core-location.ts +44 -0
- package/src/index.ts +121 -7
- package/src/lead-shift-gate.ts +145 -0
- package/src/ledger-backfill.ts +20 -1
- package/src/loop-blobs.generated.ts +12 -3
- package/src/loop-lint.ts +52 -7
- package/src/loop-plan-graph.ts +66 -1
- package/src/loop-trace.ts +13 -1
- package/src/managed-hooks.ts +5 -6
- package/src/model-recommender.ts +14 -3
- package/src/mutation-gate.ts +165 -5
- package/src/operations.ts +83 -5
- package/src/patterns.ts +33 -0
- package/src/plugin.ts +27 -5
- package/src/publish.ts +27 -2
- package/src/recommend.ts +116 -46
- package/src/registry.ts +147 -13
- package/src/run-records.ts +23 -0
- package/src/score.ts +377 -7
- package/src/session-retro.ts +466 -11
- package/src/setup.ts +246 -9
- package/src/sign.ts +18 -1
- package/src/skill-selection.ts +111 -0
- package/src/skills-verify.ts +99 -1
- package/src/stage-line.ts +151 -0
- package/src/statusline.ts +396 -47
- package/src/stem.ts +87 -0
- package/src/store-counts.ts +154 -0
- package/src/store-guard.ts +388 -0
- package/src/swarm-brief.ts +661 -0
- package/src/telemetry-vocabulary.ts +36 -0
- package/src/trace-bundle.ts +10 -0
|
@@ -137,12 +137,182 @@ export interface ClassifyResultRow {
|
|
|
137
137
|
readonly tipEvidence?: ExecutionEvidence;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/** A CLOSED runner selection derived from the target package, never a command copied from package.json. */
|
|
141
|
+
export type RunnerSelection =
|
|
142
|
+
| {
|
|
143
|
+
readonly kind: 'vitest';
|
|
144
|
+
readonly command: 'npx vitest run';
|
|
145
|
+
readonly runnerName: 'vitest';
|
|
146
|
+
readonly how: 'scripts.test' | 'dev-dependency';
|
|
147
|
+
/** Сегменты составного `scripts.test`, которые НЕ будут выполнены. См. `selectRunner`. */
|
|
148
|
+
readonly skippedPreparation?: readonly string[];
|
|
149
|
+
}
|
|
150
|
+
| {
|
|
151
|
+
readonly kind: 'node-test';
|
|
152
|
+
readonly command: 'node --test';
|
|
153
|
+
readonly runnerName: 'node --test';
|
|
154
|
+
readonly how: 'scripts.test';
|
|
155
|
+
readonly skippedPreparation?: readonly string[];
|
|
156
|
+
}
|
|
157
|
+
| {
|
|
158
|
+
readonly kind: 'unsupported';
|
|
159
|
+
readonly runnerName: string;
|
|
160
|
+
readonly scriptsTest: string | null;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export type PlannedRunnerSelection =
|
|
164
|
+
| RunnerSelection
|
|
165
|
+
| {
|
|
166
|
+
readonly kind: 'explicit';
|
|
167
|
+
readonly command: string;
|
|
168
|
+
readonly runnerName: string;
|
|
169
|
+
readonly how: 'explicit-flag';
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
export interface BaseRefResolution {
|
|
173
|
+
readonly requestedRef: string;
|
|
174
|
+
readonly resolvedRef: string;
|
|
175
|
+
/**
|
|
176
|
+
* WHERE THE BASE CAME FROM, and it is part of the evidence, not decoration.
|
|
177
|
+
*
|
|
178
|
+
* `explicit-ref` is a claim about a HUMAN action — someone typed `--base <ref>`. It used to be
|
|
179
|
+
* written for a HEAD the tool had chosen by itself, so a receipt could not distinguish a base
|
|
180
|
+
* that was audited from one that was defaulted (MEASURED 2026-09-04:
|
|
181
|
+
* `resolveDiscriminationBaseRef('HEAD')` and `resolveDiscriminationBaseRef('abc123^')` returned
|
|
182
|
+
* the same `how`). This gate's whole output is cited later as proof; a receipt that overstates
|
|
183
|
+
* how its base was chosen forges the provenance of that proof.
|
|
184
|
+
*/
|
|
185
|
+
readonly how: 'explicit-ref' | 'merge-base' | 'default-ref';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Select one of the two runner families this instrument can measure honestly.
|
|
190
|
+
*
|
|
191
|
+
* The package script is used only for classification. Its flags and shell text are never spliced
|
|
192
|
+
* into a command: a wrapper or a third runner family is an explicit unsupported result. Vitest in
|
|
193
|
+
* devDependencies is the sole tie-break when scripts.test is absent; it still maps to the fixed
|
|
194
|
+
* command template below.
|
|
195
|
+
*/
|
|
196
|
+
export function selectRunner(scriptsTest: string | null, devDeps: readonly string[]): RunnerSelection {
|
|
197
|
+
const script = typeof scriptsTest === 'string' && scriptsTest.trim() ? scriptsTest.trim() : null;
|
|
198
|
+
const tokens = script?.split(/\s+/) ?? [];
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* СОСТАВНОЙ СКРИПТ — НЕ НЕПОДДЕРЖИВАЕМЫЙ РАННЁР (правка 2026-09-04).
|
|
202
|
+
*
|
|
203
|
+
* Пакет, добавивший в `scripts.test` предварительный шаг (`tsc -p … && vitest run`), ТИХО
|
|
204
|
+
* терял эту заставу целиком: `&&` попадал под общий запрет управляющих символов, и гейт
|
|
205
|
+
* отказывался с `unsupported-runner:tsc`. ИЗМЕРЕНО 2026-09-04 на этом дереве: 33 простых
|
|
206
|
+
* скрипта и один составной — и это ровно тот пакет, куда в ту же ночь добавили заставу типов.
|
|
207
|
+
* То есть починка в одном месте молча выключала проверку в другом.
|
|
208
|
+
*
|
|
209
|
+
* ЧТО ПРИ ЭТОМ НЕ ОСЛАБЛЕНО: скрипт ПО-ПРЕЖНЕМУ НИКОГДА НЕ ИСПОЛНЯЕТСЯ. Из него только
|
|
210
|
+
* ОПОЗНАЁТСЯ раннёр, а запускается наша собственная КОНСТАНТНАЯ команда (`npx vitest run` /
|
|
211
|
+
* `node --test`). Поэтому разрешён ровно один разделитель — `&&`; любой другой управляющий
|
|
212
|
+
* символ (`;`, `|`, `$`, обратная кавычка, перенаправление, подоболочка) отказывает как раньше.
|
|
213
|
+
*/
|
|
214
|
+
const SEGMENT_SEPARATOR = /\s*&&\s*/;
|
|
215
|
+
const hasForbiddenControl = script !== null && /[\0`$;|<>()\n\r]/.test(script);
|
|
216
|
+
const hasSingleAmp = script !== null && /(^|[^&])&([^&]|$)/.test(script);
|
|
217
|
+
|
|
218
|
+
if (!hasForbiddenControl && !hasSingleAmp) {
|
|
219
|
+
const segments = (script ?? '').split(SEGMENT_SEPARATOR).map((seg) => seg.trim()).filter(Boolean);
|
|
220
|
+
const recognised: RunnerSelection[] = [];
|
|
221
|
+
const skipped: string[] = [];
|
|
222
|
+
for (const seg of segments) {
|
|
223
|
+
const t = seg.split(/\s+/);
|
|
224
|
+
const vitestOffset =
|
|
225
|
+
t[0] === 'vitest'
|
|
226
|
+
? 0
|
|
227
|
+
: t[0] === 'npx' && t[1] === 'vitest'
|
|
228
|
+
? 1
|
|
229
|
+
: t[0] === 'pnpm' && t[1] === 'exec' && t[2] === 'vitest'
|
|
230
|
+
? 2
|
|
231
|
+
: -1;
|
|
232
|
+
if (vitestOffset >= 0) {
|
|
233
|
+
recognised.push({ kind: 'vitest', command: 'npx vitest run', runnerName: 'vitest', how: 'scripts.test' });
|
|
234
|
+
} else if (t[0] === 'node' && t[1] === '--test') {
|
|
235
|
+
recognised.push({ kind: 'node-test', command: 'node --test', runnerName: 'node --test', how: 'scripts.test' });
|
|
236
|
+
} else {
|
|
237
|
+
skipped.push(seg);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Два разных раннёра в одном скрипте — не выбор, а неопределённость: угадывать который из них
|
|
241
|
+
// «настоящий» значило бы измерять не то, что думает автор.
|
|
242
|
+
const kinds = new Set(recognised.map((r) => r.kind));
|
|
243
|
+
const first = recognised[0];
|
|
244
|
+
if (first !== undefined && kinds.size === 1) {
|
|
245
|
+
// ПРОПУЩЕННЫЕ ШАГИ НАЗЫВАЮТСЯ, А НЕ ЗАМАЛЧИВАЮТСЯ. Скрипт вида `npm run build && vitest run`
|
|
246
|
+
// опознаётся, но сборка НЕ выполняется — и если тесты без неё не грузятся, красное будет
|
|
247
|
+
// СТРУКТУРНЫМ. Гейт такое красное и так не засчитывает за дискриминацию
|
|
248
|
+
// (`classifyRunFailure` → 'file-load'), но читатель квитанции обязан видеть причину, а не
|
|
249
|
+
// гадать. Молчаливый пропуск подготовки — это ровно тот «успех из тишины», от которого весь
|
|
250
|
+
// этот механизм и защищает.
|
|
251
|
+
return (skipped.length > 0 && (first.kind === 'vitest' || first.kind === 'node-test'))
|
|
252
|
+
? { ...first, skippedPreparation: skipped }
|
|
253
|
+
: first;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const deps = Array.isArray(devDeps) ? devDeps : [];
|
|
258
|
+
if (script === null && deps.some((dep) => dep === 'vitest')) {
|
|
259
|
+
return { kind: 'vitest', command: 'npx vitest run', runnerName: 'vitest', how: 'dev-dependency' };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { kind: 'unsupported', runnerName: tokens[0] ?? 'none', scriptsTest: script };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Resolve an audited pre-feature ref supplied by the executor. HEAD never wins over a merge-base.
|
|
267
|
+
*
|
|
268
|
+
* `supplied` says whether a human passed `--base`. When it is UNKNOWN, a bare `HEAD` is labelled
|
|
269
|
+
* `default-head`: the fail-safe direction is to UNDERSTATE provenance, never to overstate it — a
|
|
270
|
+
* receipt claiming human audit is the one a later reader will cite as proof.
|
|
271
|
+
*/
|
|
272
|
+
export function resolveDiscriminationBaseRef(
|
|
273
|
+
requestedRef: string,
|
|
274
|
+
mergeBaseRef?: string,
|
|
275
|
+
supplied?: boolean,
|
|
276
|
+
): BaseRefResolution {
|
|
277
|
+
const requested = typeof requestedRef === 'string' ? requestedRef.trim() : '';
|
|
278
|
+
const mergeBase = typeof mergeBaseRef === 'string' ? mergeBaseRef.trim() : '';
|
|
279
|
+
if (requested === 'HEAD' && mergeBase) {
|
|
280
|
+
return { requestedRef: requested, resolvedRef: mergeBase, how: 'merge-base' };
|
|
281
|
+
}
|
|
282
|
+
// The label follows the PROVENANCE, not the spelling of the ref. The first version keyed the
|
|
283
|
+
// downgrade off `requested === 'HEAD'`, and cross-family QE produced the breaker:
|
|
284
|
+
// `resolveDiscriminationBaseRef('main', undefined, false)` — a ref we KNOW nobody supplied —
|
|
285
|
+
// came back `explicit-ref`. That is the overstatement this whole change exists to remove, so
|
|
286
|
+
// keying on the ref was an assumption about callers dressed as a property.
|
|
287
|
+
if (supplied === true) return { requestedRef: requested, resolvedRef: requested, how: 'explicit-ref' };
|
|
288
|
+
if (supplied === false) return { requestedRef: requested, resolvedRef: requested, how: 'default-ref' };
|
|
289
|
+
// Provenance UNKNOWN — and only here is anything inferred, from the one ref that is ever a
|
|
290
|
+
// default. A real ref had to be typed by someone; `HEAD` is what a tool falls back to. The
|
|
291
|
+
// inference errs toward understating, which is the direction that cannot forge evidence.
|
|
292
|
+
if (requested === 'HEAD') return { requestedRef: requested, resolvedRef: requested, how: 'default-ref' };
|
|
293
|
+
return { requestedRef: requested, resolvedRef: requested, how: 'explicit-ref' };
|
|
294
|
+
}
|
|
295
|
+
|
|
140
296
|
export interface DiscriminationPlanInput {
|
|
141
297
|
/** the git ref of pre-feature HEAD — the "base" the property test must fail against. */
|
|
142
298
|
readonly baseRef: string;
|
|
299
|
+
/**
|
|
300
|
+
* Did a human pass `--base`? Carried so the receipt can say WHERE the base came from. Absent
|
|
301
|
+
* reads as "not supplied" for a bare HEAD, which understates provenance rather than claiming an
|
|
302
|
+
* audit that may not have happened.
|
|
303
|
+
*/
|
|
304
|
+
readonly baseRefSupplied?: boolean;
|
|
305
|
+
/** merge-base already measured by the executor; mandatory to displace a sweeping HEAD. */
|
|
306
|
+
readonly mergeBaseRef?: string;
|
|
143
307
|
/** property test(s) mapped from the ADR Confirmation. Empty ⇒ CANNOT_ISOLATE. */
|
|
144
308
|
readonly propertyTests: readonly PropertyTestRef[];
|
|
145
|
-
/**
|
|
309
|
+
/** repo-relative directory owning the TARGET package.json; `.` when the repository root owns it. */
|
|
310
|
+
readonly packageDir?: string;
|
|
311
|
+
/** TARGET package.json scripts.test. It is classified, never executed verbatim. */
|
|
312
|
+
readonly packageTestScript?: string | null;
|
|
313
|
+
/** TARGET package devDependency names, used only for the documented vitest tie-break. */
|
|
314
|
+
readonly packageDevDependencies?: readonly string[];
|
|
315
|
+
/** explicit safe escape hatch. Absence derives from packageTestScript; unsafe input refuses. */
|
|
146
316
|
readonly runner?: string;
|
|
147
317
|
}
|
|
148
318
|
|
|
@@ -151,19 +321,33 @@ export interface DiscriminationPlan {
|
|
|
151
321
|
readonly runnable: boolean;
|
|
152
322
|
/** why not runnable, when `runnable` is false. */
|
|
153
323
|
readonly reason?: string;
|
|
324
|
+
/** Plan-time state. REFUSE is non-passing; PENDING says execution evidence is still required. */
|
|
325
|
+
readonly verdict: 'PENDING' | 'REFUSE';
|
|
326
|
+
/** A plan alone has measured nothing; in particular every refusal is false. */
|
|
327
|
+
readonly measurementValid: false;
|
|
328
|
+
readonly primaryAction: PrimaryAction;
|
|
154
329
|
/** the sanitized base ref actually used. */
|
|
155
330
|
readonly baseRef: string;
|
|
331
|
+
readonly baseRefResolution: BaseRefResolution;
|
|
332
|
+
readonly runnerSelection: PlannedRunnerSelection;
|
|
333
|
+
readonly packageDir: string;
|
|
156
334
|
/** the accepted, sanitized targets. */
|
|
157
335
|
readonly targets: readonly PropertyTestRef[];
|
|
158
336
|
/** refs rejected by sanitation, with the reason — surfaced so a rejection is never silent. */
|
|
159
337
|
readonly rejected: readonly { readonly file: string; readonly reason: string }[];
|
|
160
338
|
/**
|
|
161
|
-
* Ordered shell steps the caller runs: add
|
|
162
|
-
*
|
|
339
|
+
* Ordered shell steps the caller runs: add the complete detached revision at baseRef, run the selected
|
|
340
|
+
* package-scoped command over positional targets, then remove the worktree. No lone-file tree is valid.
|
|
163
341
|
* `{{WORKTREE}}` is a placeholder the caller substitutes with a fresh temp dir path it owns — the engine
|
|
164
342
|
* never invents a filesystem path. Commands use only sanitized tokens.
|
|
165
343
|
*/
|
|
166
344
|
readonly commands: readonly string[];
|
|
345
|
+
/** The detached worktree itself supplies the complete revision; no lone-file copy is an isolation tree. */
|
|
346
|
+
readonly isolation: {
|
|
347
|
+
readonly materialization: 'full-revision-tree';
|
|
348
|
+
readonly revision: string;
|
|
349
|
+
readonly overlays: readonly string[];
|
|
350
|
+
};
|
|
167
351
|
}
|
|
168
352
|
|
|
169
353
|
export interface ClassifyInput {
|
|
@@ -228,7 +412,6 @@ const SAFE_REF = /^[A-Za-z0-9_][A-Za-z0-9_./~^@{}-]{0,199}$/;
|
|
|
228
412
|
* relative file, and anything exotic is safer rejected (and surfaced) than quoted-and-hoped.
|
|
229
413
|
*/
|
|
230
414
|
const UNSAFE_PATH = /(^\/)|(^[A-Za-z]:)|(^~)|(^-)|(\/-)|(\.\.(\/|\\|$))|[\0`$;&|<>*?"'\n\r\t \\]/;
|
|
231
|
-
const DEFAULT_RUNNER = 'npx vitest run';
|
|
232
415
|
/** a runner must be a plain command with flags — no shell metacharacters that could chain a second command. */
|
|
233
416
|
const UNSAFE_RUNNER = /[\0`$;&|<>()\n\r]/;
|
|
234
417
|
|
|
@@ -244,15 +427,42 @@ function sanitizeName(name: string): string | null {
|
|
|
244
427
|
* ordered worktree commands. Returns `runnable:false` with a reason when there is nothing safe to run.
|
|
245
428
|
*/
|
|
246
429
|
export function planDiscriminationCheck(input: DiscriminationPlanInput): DiscriminationPlan {
|
|
247
|
-
const
|
|
430
|
+
const requestedBaseRef = typeof input.baseRef === 'string' ? input.baseRef.trim() : '';
|
|
431
|
+
const baseRefResolution = resolveDiscriminationBaseRef(requestedBaseRef, input.mergeBaseRef, input.baseRefSupplied);
|
|
432
|
+
const baseRef = baseRefResolution.resolvedRef;
|
|
433
|
+
const packageDirRaw = typeof input.packageDir === 'string' ? input.packageDir.trim().replace(/\/$/, '') : '.';
|
|
434
|
+
const packageDir = packageDirRaw || '.';
|
|
248
435
|
const rejected: { file: string; reason: string }[] = [];
|
|
436
|
+
const explicitRunner = typeof input.runner === 'string' && input.runner.trim() ? input.runner.trim() : null;
|
|
437
|
+
const runnerName = explicitRunner?.split(/\s+/)[0] ?? 'none';
|
|
438
|
+
const runnerSelection: PlannedRunnerSelection = explicitRunner
|
|
439
|
+
? { kind: 'explicit', command: explicitRunner, runnerName, how: 'explicit-flag' }
|
|
440
|
+
: selectRunner(input.packageTestScript ?? null, input.packageDevDependencies ?? []);
|
|
441
|
+
const isolation = { materialization: 'full-revision-tree', revision: baseRef, overlays: [] } as const;
|
|
442
|
+
|
|
443
|
+
const refuse = (reason: string, action: PrimaryAction = 'fix-runner-invocation'): DiscriminationPlan => ({
|
|
444
|
+
runnable: false,
|
|
445
|
+
reason,
|
|
446
|
+
verdict: 'REFUSE',
|
|
447
|
+
measurementValid: false,
|
|
448
|
+
primaryAction: action,
|
|
449
|
+
baseRef,
|
|
450
|
+
baseRefResolution,
|
|
451
|
+
runnerSelection,
|
|
452
|
+
packageDir,
|
|
453
|
+
targets: [],
|
|
454
|
+
rejected,
|
|
455
|
+
commands: [],
|
|
456
|
+
isolation,
|
|
457
|
+
});
|
|
249
458
|
|
|
250
|
-
if (!SAFE_REF.test(baseRef)) {
|
|
251
|
-
return
|
|
459
|
+
if (!SAFE_REF.test(requestedBaseRef) || !SAFE_REF.test(baseRef)) {
|
|
460
|
+
return refuse('unsafe-or-missing-base-ref');
|
|
252
461
|
}
|
|
253
462
|
|
|
254
|
-
|
|
255
|
-
|
|
463
|
+
if (packageDir !== '.' && UNSAFE_PATH.test(packageDir)) {
|
|
464
|
+
return refuse('unsafe-package-dir');
|
|
465
|
+
}
|
|
256
466
|
|
|
257
467
|
const targets: PropertyTestRef[] = [];
|
|
258
468
|
const seen = new Set<string>();
|
|
@@ -260,6 +470,10 @@ export function planDiscriminationCheck(input: DiscriminationPlanInput): Discrim
|
|
|
260
470
|
const file = t && typeof t.file === 'string' ? t.file.trim() : '';
|
|
261
471
|
if (!file) { rejected.push({ file: String(t?.file ?? ''), reason: 'empty-path' }); continue; }
|
|
262
472
|
if (UNSAFE_PATH.test(file)) { rejected.push({ file, reason: 'unsafe-path' }); continue; }
|
|
473
|
+
if (packageDir !== '.' && !file.startsWith(`${packageDir}/`)) {
|
|
474
|
+
rejected.push({ file, reason: 'outside-target-package' });
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
263
477
|
const name = t && typeof t.name === 'string' ? sanitizeName(t.name) : null;
|
|
264
478
|
if (t && typeof t.name === 'string' && name === null) { rejected.push({ file, reason: 'unsafe-test-name' }); continue; }
|
|
265
479
|
const key = `${file}|${name ?? ''}`;
|
|
@@ -269,22 +483,51 @@ export function planDiscriminationCheck(input: DiscriminationPlanInput): Discrim
|
|
|
269
483
|
}
|
|
270
484
|
|
|
271
485
|
if (targets.length === 0) {
|
|
272
|
-
|
|
486
|
+
const plan = refuse('no-isolable-test', 'map-a-test');
|
|
487
|
+
return { ...plan, targets };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (explicitRunner !== null && UNSAFE_RUNNER.test(explicitRunner)) {
|
|
491
|
+
const plan = refuse(`unsafe-runner:${runnerName}`);
|
|
492
|
+
return { ...plan, targets };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (runnerSelection.kind === 'unsupported') {
|
|
496
|
+
const plan = refuse(`unsupported-runner:${runnerSelection.runnerName}`);
|
|
497
|
+
return { ...plan, targets };
|
|
273
498
|
}
|
|
274
499
|
|
|
275
500
|
// `{{WORKTREE}}` is substituted by the caller with a temp dir IT owns; the engine never invents a path.
|
|
276
|
-
//
|
|
277
|
-
//
|
|
501
|
+
// `git worktree add` materialises the complete base revision. No lone property-test copy is emitted:
|
|
502
|
+
// a copied test without its sibling source/config tree is not an isolated revision and cannot measure.
|
|
278
503
|
const commands: string[] = [`git worktree add --detach {{WORKTREE}} ${baseRef}`];
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
504
|
+
const relativeToPackage = (file: string): string => (packageDir === '.' ? file : file.slice(packageDir.length + 1));
|
|
505
|
+
const fileArgs = [...new Set(targets.map((t) => relativeToPackage(t.file)))].map((f) => `'${f}'`).join(' ');
|
|
506
|
+
const testNames = targets.filter((t) => t.name).map((t) => t.name as string);
|
|
507
|
+
const nameFilters =
|
|
508
|
+
runnerSelection.kind === 'node-test'
|
|
509
|
+
? testNames.map((name) => `--test-name-pattern '${name}'`).join(' ')
|
|
510
|
+
: testNames.map((name) => `-t '${name}'`).join(' ');
|
|
511
|
+
const runner = runnerSelection.command;
|
|
512
|
+
const runnerArgs = `${runner}${nameFilters ? ` ${nameFilters}` : ''} ${fileArgs}`;
|
|
513
|
+
const worktreePackageDir = packageDir === '.' ? '{{WORKTREE}}' : `{{WORKTREE}}/${packageDir}`;
|
|
514
|
+
commands.push(`( cd "${worktreePackageDir}" && ${runnerArgs} )`);
|
|
285
515
|
commands.push(`git worktree remove --force {{WORKTREE}}`);
|
|
286
516
|
|
|
287
|
-
return {
|
|
517
|
+
return {
|
|
518
|
+
runnable: true,
|
|
519
|
+
verdict: 'PENDING',
|
|
520
|
+
measurementValid: false,
|
|
521
|
+
primaryAction: 'none',
|
|
522
|
+
baseRef,
|
|
523
|
+
baseRefResolution,
|
|
524
|
+
runnerSelection,
|
|
525
|
+
packageDir,
|
|
526
|
+
targets,
|
|
527
|
+
rejected,
|
|
528
|
+
commands,
|
|
529
|
+
isolation,
|
|
530
|
+
};
|
|
288
531
|
}
|
|
289
532
|
|
|
290
533
|
|
package/src/event-chain.ts
CHANGED
|
@@ -224,6 +224,47 @@ export function readTailInfo(tailText: string, opts: { readonly partial?: boolea
|
|
|
224
224
|
/** An empty log — what an appender assumes when the file is absent. */
|
|
225
225
|
export const EMPTY_LOG_TAIL: LogTail = { lastLine: undefined, endsWithNewline: true, unreadable: false };
|
|
226
226
|
|
|
227
|
+
/**
|
|
228
|
+
* ONE journal whose records are hash-chained, and the decision that rests on it.
|
|
229
|
+
*
|
|
230
|
+
* `decides` is not documentation garnish: it is the reason integrity matters HERE and not
|
|
231
|
+
* everywhere. A chain costs nothing to read and something to maintain, so a journal earns one by
|
|
232
|
+
* being the basis of a verdict — where a lost or duplicated record is a WRONG ANSWER WITH NO
|
|
233
|
+
* SYMPTOM. A journal nobody decides on does not need a chain, and saying so keeps the registry from
|
|
234
|
+
* growing into a list of every file we happen to append to.
|
|
235
|
+
*/
|
|
236
|
+
export interface ChainedJournal {
|
|
237
|
+
/** Path relative to the project root. */
|
|
238
|
+
readonly rel: string;
|
|
239
|
+
/** The verdict that would silently go wrong if a record were lost or duplicated. */
|
|
240
|
+
readonly decides: string;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* THE registry of chained journals — the single list every verification surface reads.
|
|
245
|
+
*
|
|
246
|
+
* Why this exists (backlog `bc4ee35c`, W0-chain): the chain machinery was built, and then each
|
|
247
|
+
* consumer grew its OWN private list of which files carry a chain — `dz doctor` had a two-element
|
|
248
|
+
* array inline, the score aggregate checked its own file, and nothing checked the rest. Three
|
|
249
|
+
* surfaces, three lists, and no way to ask "are all the chained journals intact?" So a journal
|
|
250
|
+
* could be given a chain and STILL be checked by nobody: the mechanism present, the coverage
|
|
251
|
+
* absent, and no red anywhere to say so.
|
|
252
|
+
*
|
|
253
|
+
* MEASURED 2026-09-01: of eight append-only journals under `.dz/`, exactly two carry a chain
|
|
254
|
+
* (probe: `tail -1 <file>` for a `seq` field). Adding the third must be one line HERE, not one line
|
|
255
|
+
* in each surface — which is the whole point of a registry, and what its test pins.
|
|
256
|
+
*/
|
|
257
|
+
export const CHAINED_JOURNALS: readonly ChainedJournal[] = [
|
|
258
|
+
{
|
|
259
|
+
rel: '.dz/recall-usage.jsonl',
|
|
260
|
+
decides: 'dz compounding — whether a taught lesson is actually paying off',
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
rel: '.dz/guard-audit.jsonl',
|
|
264
|
+
decides: 'dz guard promote — whether a lesson has won twice and may become a rule',
|
|
265
|
+
},
|
|
266
|
+
];
|
|
267
|
+
|
|
227
268
|
/**
|
|
228
269
|
* The exact text to append for a run of records: chained, newline-terminated, and preceded by a
|
|
229
270
|
* newline when the file ends mid-line. THE one place that knows how to extend one of these logs —
|
|
@@ -490,31 +490,220 @@ export function qeShouldUseCodex(env: RoutingEnv): boolean {
|
|
|
490
490
|
* 4. `code`/`qe` `null` sentinels resolve via the coder / cross-model rules
|
|
491
491
|
*/
|
|
492
492
|
export function resolveStageModel(stage: string, env: RoutingEnv): StageOpts {
|
|
493
|
+
return resolveStageDecision(stage, env).opts;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The CLOSED vocabulary of the stage announcement line (historical feature name
|
|
498
|
+
* stage-line-before-dispatch, ADR-001). Exported as a VALUE so a widened union fails a test rather than passing silently — a
|
|
499
|
+
* type-only union is invisible at runtime. TWENTY members in three groups:
|
|
500
|
+
*
|
|
501
|
+
* 1-8 RESOLVER BRANCHES — the branch of `resolveStageDecision` that chose the spec.
|
|
502
|
+
* 9-10 SPEC DEGRADATIONS — the two ways `specToOpts` cannot use what a branch chose (an unknown
|
|
503
|
+
* codex id substituted for `CODEX_MODEL`; an unrecognised name falling back to
|
|
504
|
+
* session-inherited). They evaluate LAST and therefore WIN the label: that the model named
|
|
505
|
+
* by the branch is not the model that will run is the one fact the line must never hide.
|
|
506
|
+
* 11-20 DISPATCH OVERRIDES — facts a PURE resolve cannot know, emitted only by the workflow at the
|
|
507
|
+
* dispatch site, and the reason the count grew from ten (cross-family review of 3fc406db):
|
|
508
|
+
* `coder-fallback` — the codex-fallback LADDER, not the resolver, decides
|
|
509
|
+
* which family runs first: the resolver picks Codex, the runtime tries Claude first and
|
|
510
|
+
* only reaches Codex if Claude returns null. Each rung is a real dispatch and gets its
|
|
511
|
+
* own line, so the reader is never told Codex is running while Claude is.
|
|
512
|
+
* `codex-unsupported-at-dispatch` — the stage refuses the codex wrapper outright (a
|
|
513
|
+
* data-returning stage; the wrapper stubs, per the codex-routing-honesty ADR), so a
|
|
514
|
+
* resolved codex spec is discarded and the dispatch runs Claude.
|
|
515
|
+
* `fallback-after-no-deliverable` — a previous RUNG of this stage ran and produced nothing
|
|
516
|
+
* usable (a Codex plan/design artifact that never landed; a Codex reviewer that returned
|
|
517
|
+
* no verdict), so the next rung dispatches. Each rung is a real dispatch and is announced
|
|
518
|
+
* on its own, because `modelsUsed` was already being rewritten there and the line was not.
|
|
519
|
+
* `precision-second-pass` — the optional A-normal L/XL Claude precision reviewer that
|
|
520
|
+
* runs AFTER the recall-oriented primary QE pass. It is a separate dispatch with its own
|
|
521
|
+
* `modelsUsed.qe2` entry, chosen by `qePrecisionPassSpec`, not by the stage resolver.
|
|
522
|
+
* `qe-same-family-degraded` — the QE branch resolved a reviewer of the SAME family as
|
|
523
|
+
* the coder. It happens when the coder is Claude and codex is unavailable: `resolveQeSpec`
|
|
524
|
+
* falls back to Claude `opus` rather than blocking, so cross-family review is LOST at that
|
|
525
|
+
* moment. The branch used to emit `qe-cross-family` regardless, and the line then claimed
|
|
526
|
+
* "the coder never self-reviews" about a Claude-on-Claude review. The reason is now DERIVED
|
|
527
|
+
* from the resolved families, never assumed from the branch.
|
|
528
|
+
* `challenge-panel` — the Step-6 adversarial plan gate. Its adversary is chosen
|
|
529
|
+
* as the OTHER family than the plan's AUTHOR, and its cross-validator re-checks the
|
|
530
|
+
* findings; both are substantive model reviews that decide the gate's verdict, not probes.
|
|
531
|
+
* `codex-refused-before-dispatch` — an id ANSWERED the probe, but the rung then declined to
|
|
532
|
+
* build a dispatch at all: an unusable review scope ref (`codexReviewCommand` returns
|
|
533
|
+
* `cmd:null`), an unsafe id at command-build time, or a declined exec plan. No agent ran,
|
|
534
|
+
* so the next rung is NOT a `fallback-after-no-deliverable` — that reason asserts a rung
|
|
535
|
+
* RAN and produced nothing, which is a false dispatch claim. Rounds 16-17 modelled this
|
|
536
|
+
* outcome with a BOOLEAN `probeFailed`, whose else-branch swallowed every non-probe
|
|
537
|
+
* refusal into the "it ran" bucket; the outcome is three-valued and is now carried as such.
|
|
538
|
+
* `codex-probe-failed` — a DIRECT `agent()` dispatch resolved to codex, but no id
|
|
539
|
+
* answered the probe. `safeCodexAgent` refuses by returning null; a direct path has no
|
|
540
|
+
* wrapper to return from, so it falls back to Claude and says so rather than dispatching
|
|
541
|
+
* an unprobed spec while the line claims a model nothing verified.
|
|
542
|
+
* `fallback-rung` — the CURRENT attempt is a fallback. The prior attempt's
|
|
543
|
+
* exact outcome is emitted on that prior rung's outcome line and is never copied into
|
|
544
|
+
* this attempt's intent.
|
|
545
|
+
* `auto-cost` — learned-cost routing chose this model. `resolveAutoCost`
|
|
546
|
+
* REWRITES `args.models[stage]` from the `auto-cost` token to the selected concrete model
|
|
547
|
+
* BEFORE the resolver runs, so the explicit-models branch fires and the line would credit
|
|
548
|
+
* an operator who never named that model. The selection is recovered from the run's own
|
|
549
|
+
* auto-cost ledger, so the line reports the branch that actually decided.
|
|
550
|
+
* `resolveStageDecision` NEVER returns ANY of these ten — asserted by its own test. A resolver
|
|
551
|
+
* that could emit them would be claiming resolver authority over a runtime fact.
|
|
552
|
+
*/
|
|
553
|
+
export const STAGE_DECISION_REASONS: readonly StageDecisionReason[] = [
|
|
554
|
+
'usage-override',
|
|
555
|
+
'explicit-models',
|
|
556
|
+
'routing-not-requested',
|
|
557
|
+
'coder-knob-codex',
|
|
558
|
+
'planner-knob-codex',
|
|
559
|
+
'qe-cross-family',
|
|
560
|
+
'budget-table-cell',
|
|
561
|
+
'default-models',
|
|
562
|
+
'codex-id-substituted',
|
|
563
|
+
'spec-unrecognised',
|
|
564
|
+
'coder-fallback',
|
|
565
|
+
'codex-unsupported-at-dispatch',
|
|
566
|
+
'fallback-after-no-deliverable',
|
|
567
|
+
'precision-second-pass',
|
|
568
|
+
'auto-cost',
|
|
569
|
+
'qe-same-family-degraded',
|
|
570
|
+
'challenge-panel',
|
|
571
|
+
'codex-probe-failed',
|
|
572
|
+
'codex-refused-before-dispatch',
|
|
573
|
+
'fallback-rung',
|
|
574
|
+
];
|
|
575
|
+
|
|
576
|
+
export type StageDecisionReason =
|
|
577
|
+
| 'usage-override'
|
|
578
|
+
| 'explicit-models'
|
|
579
|
+
| 'routing-not-requested'
|
|
580
|
+
| 'coder-knob-codex'
|
|
581
|
+
| 'planner-knob-codex'
|
|
582
|
+
| 'qe-cross-family'
|
|
583
|
+
| 'budget-table-cell'
|
|
584
|
+
| 'default-models'
|
|
585
|
+
| 'codex-id-substituted'
|
|
586
|
+
| 'spec-unrecognised'
|
|
587
|
+
| 'coder-fallback'
|
|
588
|
+
| 'codex-unsupported-at-dispatch'
|
|
589
|
+
| 'fallback-after-no-deliverable'
|
|
590
|
+
| 'precision-second-pass'
|
|
591
|
+
| 'auto-cost'
|
|
592
|
+
| 'qe-same-family-degraded'
|
|
593
|
+
| 'challenge-panel'
|
|
594
|
+
| 'codex-probe-failed'
|
|
595
|
+
| 'codex-refused-before-dispatch'
|
|
596
|
+
| 'fallback-rung';
|
|
597
|
+
|
|
598
|
+
/** What the resolver decided AND why. `spec` is the EFFECTIVE spec (derived from `opts`), never the
|
|
599
|
+
* requested-but-substituted one — a line naming a model the run did not use would be worse than no
|
|
600
|
+
* line at all. */
|
|
601
|
+
export interface StageDecision {
|
|
602
|
+
readonly opts: StageOpts;
|
|
603
|
+
readonly spec: string | null;
|
|
604
|
+
readonly reason: StageDecisionReason;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** The model FAMILY a resolved spec belongs to. */
|
|
608
|
+
export function specFamily(spec: string | null | undefined): 'codex' | 'claude' {
|
|
609
|
+
return (spec && String(spec).split(':')[0] === 'codex') ? 'codex' : 'claude';
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The QE branch's reason, DERIVED from the families that actually resolved rather than assumed from
|
|
614
|
+
* the branch. `resolveQeSpec` degrades to a Claude reviewer when codex is unavailable (it must never
|
|
615
|
+
* block), and a Claude coder then gets a Claude reviewer — cross-family review is lost, and saying
|
|
616
|
+
* otherwise would assert the one property that just failed.
|
|
617
|
+
*/
|
|
618
|
+
export function qeReasonForFamilies(coderCodex: boolean, qeSpec: string | null | undefined): StageDecisionReason {
|
|
619
|
+
if (specFamily(qeSpec) === (coderCodex ? 'codex' : 'claude')) return 'qe-same-family-degraded';
|
|
620
|
+
return 'qe-cross-family';
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** The compact spec that `opts` actually represents. Inverse of `specToOpts` over the applied opts. */
|
|
624
|
+
export function effectiveSpec(opts: StageOpts): string | null {
|
|
625
|
+
if (opts && opts.agentType === 'codex:codex-rescue') return 'codex:' + opts.codexModel + ':' + opts._reasoning;
|
|
626
|
+
if (opts && opts.model) return opts.model;
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Did `specToOpts` DEGRADE this spec? It NAMES what specToOpts already did — it changes nothing.
|
|
632
|
+
*
|
|
633
|
+
* The codex arm compares the EFFECTIVE id against the REQUESTED one rather than re-deriving the
|
|
634
|
+
* condition. MEASURED 2026-09-05: re-deriving claimed a substitution whenever the requested id was
|
|
635
|
+
* unknown — but `specToOpts` replaces it with `CODEX_MODEL`, and when that default is ITSELF unknown
|
|
636
|
+
* the replacement is the same string. A sweep over 11 stages x 4 specs x 4 defaults found 22 such
|
|
637
|
+
* outcomes, each announcing a substitution while dispatching the very id the operator asked for.
|
|
638
|
+
* Comparing the applied value cannot make that mistake.
|
|
639
|
+
*/
|
|
640
|
+
function specDegradation(spec: string | null | undefined, opts: StageOpts, env: RoutingEnv): StageDecisionReason | null {
|
|
641
|
+
if (!spec) return null;
|
|
642
|
+
const parts = String(spec).split(':');
|
|
643
|
+
const head = parts[0] || '';
|
|
644
|
+
if (head === 'codex') {
|
|
645
|
+
const requested = parts[1] || env.CODEX_MODEL;
|
|
646
|
+
if (opts.codexModel !== requested) return 'codex-id-substituted';
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
if (!CLAUDE_NAMES[head]) return 'spec-unrecognised';
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Wrap resolved opts into a decision, letting a spec DEGRADATION rename the branch (see above). */
|
|
654
|
+
function decisionFor(base: StageDecisionReason, spec: string | null | undefined, opts: StageOpts, env: RoutingEnv): StageDecision {
|
|
655
|
+
const deg = specDegradation(spec, opts, env);
|
|
656
|
+
if (deg !== null) return { opts: opts, spec: effectiveSpec(opts), reason: deg };
|
|
657
|
+
return { opts: opts, spec: effectiveSpec(opts), reason: base };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** The two `null` SENTINEL fall-throughs (`code`/`qe` whose chosen spec is `null` ⇒ derive it),
|
|
661
|
+
* shared by the explicit-models and table paths so the branch that CHOSE the spec keeps the label. */
|
|
662
|
+
function decideFromSpec(base: StageDecisionReason, stage: string, spec: string | null | undefined, env: RoutingEnv): StageDecision {
|
|
663
|
+
if (stage === 'code' && (spec === null || spec === undefined)) {
|
|
664
|
+
const s = resolveCoderSpec(env);
|
|
665
|
+
return decisionFor(base, s, specToOpts(s, env), env);
|
|
666
|
+
}
|
|
667
|
+
if (stage === 'qe' && (spec === null || spec === undefined)) {
|
|
668
|
+
const s = resolveQeSpec(env);
|
|
669
|
+
return decisionFor(base, s, specToOpts(s, env), env);
|
|
670
|
+
}
|
|
671
|
+
return decisionFor(base, spec, specToOpts(spec, env), env);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Resolve a stage to its `agent()` opts fragment AND the branch that decided it (ADR-001: the
|
|
676
|
+
* reason lives where the decision is made — otherwise it is a copy, and copies drift).
|
|
677
|
+
* `resolveStageModel` is now a thin wrapper over `.opts`, so the opts are byte-identical by
|
|
678
|
+
* construction, not by a second reading of the same rules.
|
|
679
|
+
*/
|
|
680
|
+
export function resolveStageDecision(stage: string, env: RoutingEnv): StageDecision {
|
|
493
681
|
if (env.usageOverride) {
|
|
494
682
|
const r = (env.usageReasoning && env.usageReasoning[stage]) || STAGE_EFFORT.override[stage] || 'medium';
|
|
495
|
-
const
|
|
683
|
+
const s = 'codex:' + topCodexId(env) + ':' + r;
|
|
684
|
+
const o = specToOpts(s, env);
|
|
496
685
|
o._usageSwitched = true;
|
|
497
|
-
return o;
|
|
686
|
+
return decisionFor('usage-override', s, o, env);
|
|
498
687
|
}
|
|
499
|
-
|
|
500
|
-
if (spec
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
return specToOpts(resolveQeSpec(env), env);
|
|
510
|
-
}
|
|
511
|
-
const resolvedPrimary = env.primary || 'claude';
|
|
512
|
-
const cell = budgetTable(resolvedPrimary, resolveBudgetMode(env.budget), env)[stage];
|
|
513
|
-
spec = cell !== undefined ? cell : DEFAULT_MODELS[stage];
|
|
688
|
+
const spec = env.MODELS[stage];
|
|
689
|
+
if (spec !== undefined) return decideFromSpec('explicit-models', stage, spec, env);
|
|
690
|
+
if (!routingRequested(env)) return { opts: {}, spec: null, reason: 'routing-not-requested' };
|
|
691
|
+
if (stage === 'code' && (env.CODER === 'codex' || env.CODER === 'codex-fallback')) {
|
|
692
|
+
const s = resolveCoderSpec(env);
|
|
693
|
+
return decisionFor('coder-knob-codex', s, specToOpts(s, env), env);
|
|
694
|
+
}
|
|
695
|
+
if (stage === 'plan' && env.PLANNER === 'codex') {
|
|
696
|
+
const s = 'codex:' + env.CODEX_MODEL + ':high';
|
|
697
|
+
return decisionFor('planner-knob-codex', s, specToOpts(s, env), env);
|
|
514
698
|
}
|
|
515
|
-
if (stage === '
|
|
516
|
-
|
|
517
|
-
|
|
699
|
+
if (stage === 'qe') {
|
|
700
|
+
const s = resolveQeSpec(env);
|
|
701
|
+
return decisionFor(qeReasonForFamilies(coderIsCodex(env), s), s, specToOpts(s, env), env);
|
|
702
|
+
}
|
|
703
|
+
const resolvedPrimary = env.primary || 'claude';
|
|
704
|
+
const cell = budgetTable(resolvedPrimary, resolveBudgetMode(env.budget), env)[stage];
|
|
705
|
+
if (cell !== undefined) return decideFromSpec('budget-table-cell', stage, cell, env);
|
|
706
|
+
return decideFromSpec('default-models', stage, DEFAULT_MODELS[stage], env);
|
|
518
707
|
}
|
|
519
708
|
|
|
520
709
|
/**
|
|
@@ -1484,9 +1673,18 @@ export function codexExecPlan(input: CodexExecPlanInput): CodexExecPlanResult {
|
|
|
1484
1673
|
return { mode: 'exec', reason: 'codex exec on ' + input.probedId };
|
|
1485
1674
|
}
|
|
1486
1675
|
|
|
1487
|
-
/**
|
|
1676
|
+
/**
|
|
1677
|
+
* A model id is user input (`args.codexModel`) and lands in a shell command. Shell-safe ids only.
|
|
1678
|
+
*
|
|
1679
|
+
* The `typeof` guard is LOAD-BEARING, not defensive noise (MEASURED 2026-09-05 while building the
|
|
1680
|
+
* concurrent-probe test): `RegExp.test` coerces, so `test(null)` tested the string `'null'` — which
|
|
1681
|
+
* matches this very pattern. `safeCodexAgent` passes `null` for the default `codexModel: 'auto'`, so
|
|
1682
|
+
* the probe ladder collapsed from `[flagship, gpt-5.5]` to the single literal id `"null"`, asked for
|
|
1683
|
+
* a model that cannot exist, and reported Codex UNAVAILABLE on the default path. Every 'auto' codex
|
|
1684
|
+
* route silently degraded to Claude, for a reason nothing in the run could show.
|
|
1685
|
+
*/
|
|
1488
1686
|
export function isSafeCodexId(id: string): boolean {
|
|
1489
|
-
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id);
|
|
1687
|
+
return typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id);
|
|
1490
1688
|
}
|
|
1491
1689
|
|
|
1492
1690
|
/**
|