@dzhechkov/harness-core 0.8.11 → 0.8.21

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 (205) hide show
  1. package/.dz-manifest.json +399 -139
  2. package/LICENSE +13 -0
  3. package/README.md +420 -6
  4. package/dist/agents-policy.d.ts +15 -1
  5. package/dist/agents-policy.d.ts.map +1 -1
  6. package/dist/agents-policy.js +27 -1
  7. package/dist/agents-policy.js.map +1 -1
  8. package/dist/amendment-trace.d.ts +72 -0
  9. package/dist/amendment-trace.d.ts.map +1 -1
  10. package/dist/amendment-trace.js +385 -17
  11. package/dist/amendment-trace.js.map +1 -1
  12. package/dist/backlog-public.d.ts +153 -0
  13. package/dist/backlog-public.d.ts.map +1 -0
  14. package/dist/backlog-public.js +415 -0
  15. package/dist/backlog-public.js.map +1 -0
  16. package/dist/backlog-transitions.d.ts +48 -0
  17. package/dist/backlog-transitions.d.ts.map +1 -0
  18. package/dist/backlog-transitions.js +64 -0
  19. package/dist/backlog-transitions.js.map +1 -0
  20. package/dist/backlog.d.ts.map +1 -1
  21. package/dist/backlog.js +13 -0
  22. package/dist/backlog.js.map +1 -1
  23. package/dist/claim-check.d.ts.map +1 -1
  24. package/dist/claim-check.js +24 -2
  25. package/dist/claim-check.js.map +1 -1
  26. package/dist/claude-hooks-assets.d.ts +93 -0
  27. package/dist/claude-hooks-assets.d.ts.map +1 -0
  28. package/dist/claude-hooks-assets.js +208 -0
  29. package/dist/claude-hooks-assets.js.map +1 -0
  30. package/dist/clean-room-smoke.d.ts +83 -0
  31. package/dist/clean-room-smoke.d.ts.map +1 -0
  32. package/dist/clean-room-smoke.js +138 -0
  33. package/dist/clean-room-smoke.js.map +1 -0
  34. package/dist/cmd-usage.d.ts.map +1 -1
  35. package/dist/cmd-usage.js +36 -6
  36. package/dist/cmd-usage.js.map +1 -1
  37. package/dist/codex-hooks-assets.d.ts +25 -7
  38. package/dist/codex-hooks-assets.d.ts.map +1 -1
  39. package/dist/codex-hooks-assets.js +138 -30
  40. package/dist/codex-hooks-assets.js.map +1 -1
  41. package/dist/codex-hooks.d.ts +21 -1
  42. package/dist/codex-hooks.d.ts.map +1 -1
  43. package/dist/codex-hooks.js +21 -1
  44. package/dist/codex-hooks.js.map +1 -1
  45. package/dist/course-staleness.d.ts +19 -0
  46. package/dist/course-staleness.d.ts.map +1 -0
  47. package/dist/course-staleness.js +95 -0
  48. package/dist/course-staleness.js.map +1 -0
  49. package/dist/destructive-guard-hook.d.ts +40 -0
  50. package/dist/destructive-guard-hook.d.ts.map +1 -0
  51. package/dist/destructive-guard-hook.js +109 -0
  52. package/dist/destructive-guard-hook.js.map +1 -0
  53. package/dist/destructive-guard.d.ts +27 -0
  54. package/dist/destructive-guard.d.ts.map +1 -0
  55. package/dist/destructive-guard.js +2808 -0
  56. package/dist/destructive-guard.js.map +1 -0
  57. package/dist/discrimination-gate.d.ts +28 -3
  58. package/dist/discrimination-gate.d.ts.map +1 -1
  59. package/dist/discrimination-gate.js +76 -16
  60. package/dist/discrimination-gate.js.map +1 -1
  61. package/dist/feature-adr-routing.d.ts +95 -1
  62. package/dist/feature-adr-routing.d.ts.map +1 -1
  63. package/dist/feature-adr-routing.js +193 -27
  64. package/dist/feature-adr-routing.js.map +1 -1
  65. package/dist/guard.d.ts +90 -0
  66. package/dist/guard.d.ts.map +1 -1
  67. package/dist/guard.js +271 -0
  68. package/dist/guard.js.map +1 -1
  69. package/dist/harness-core-location.d.ts +18 -0
  70. package/dist/harness-core-location.d.ts.map +1 -0
  71. package/dist/harness-core-location.js +42 -0
  72. package/dist/harness-core-location.js.map +1 -0
  73. package/dist/index.d.ts +24 -6
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +24 -3
  76. package/dist/index.js.map +1 -1
  77. package/dist/lead-shift-gate.d.ts +95 -0
  78. package/dist/lead-shift-gate.d.ts.map +1 -0
  79. package/dist/lead-shift-gate.js +100 -0
  80. package/dist/lead-shift-gate.js.map +1 -0
  81. package/dist/ledger-backfill.d.ts +11 -1
  82. package/dist/ledger-backfill.d.ts.map +1 -1
  83. package/dist/ledger-backfill.js +19 -0
  84. package/dist/ledger-backfill.js.map +1 -1
  85. package/dist/loop-blobs.generated.d.ts +1 -1
  86. package/dist/loop-blobs.generated.d.ts.map +1 -1
  87. package/dist/loop-blobs.generated.js +12 -3
  88. package/dist/loop-blobs.generated.js.map +1 -1
  89. package/dist/loop-lint.d.ts.map +1 -1
  90. package/dist/loop-lint.js +56 -7
  91. package/dist/loop-lint.js.map +1 -1
  92. package/dist/loop-plan-graph.d.ts +1 -3
  93. package/dist/loop-plan-graph.d.ts.map +1 -1
  94. package/dist/loop-plan-graph.js +70 -1
  95. package/dist/loop-plan-graph.js.map +1 -1
  96. package/dist/loop-trace.d.ts.map +1 -1
  97. package/dist/loop-trace.js +16 -2
  98. package/dist/loop-trace.js.map +1 -1
  99. package/dist/managed-hooks.d.ts +5 -6
  100. package/dist/managed-hooks.d.ts.map +1 -1
  101. package/dist/managed-hooks.js +2 -2
  102. package/dist/managed-hooks.js.map +1 -1
  103. package/dist/model-recommender.d.ts.map +1 -1
  104. package/dist/model-recommender.js +14 -3
  105. package/dist/model-recommender.js.map +1 -1
  106. package/dist/operations.d.ts.map +1 -1
  107. package/dist/operations.js +82 -0
  108. package/dist/operations.js.map +1 -1
  109. package/dist/patterns.d.ts +23 -0
  110. package/dist/patterns.d.ts.map +1 -1
  111. package/dist/patterns.js +10 -0
  112. package/dist/patterns.js.map +1 -1
  113. package/dist/publish.d.ts +11 -0
  114. package/dist/publish.d.ts.map +1 -1
  115. package/dist/publish.js +16 -2
  116. package/dist/publish.js.map +1 -1
  117. package/dist/registry.d.ts.map +1 -1
  118. package/dist/registry.js +3 -2
  119. package/dist/registry.js.map +1 -1
  120. package/dist/score.d.ts.map +1 -1
  121. package/dist/score.js +15 -4
  122. package/dist/score.js.map +1 -1
  123. package/dist/session-retro.d.ts +121 -2
  124. package/dist/session-retro.d.ts.map +1 -1
  125. package/dist/session-retro.js +454 -11
  126. package/dist/session-retro.js.map +1 -1
  127. package/dist/setup.d.ts +28 -0
  128. package/dist/setup.d.ts.map +1 -1
  129. package/dist/setup.js +223 -6
  130. package/dist/setup.js.map +1 -1
  131. package/dist/sign.d.ts.map +1 -1
  132. package/dist/sign.js +18 -1
  133. package/dist/sign.js.map +1 -1
  134. package/dist/skills-verify.d.ts +34 -1
  135. package/dist/skills-verify.d.ts.map +1 -1
  136. package/dist/skills-verify.js +82 -0
  137. package/dist/skills-verify.js.map +1 -1
  138. package/dist/stage-line.d.ts +68 -0
  139. package/dist/stage-line.d.ts.map +1 -0
  140. package/dist/stage-line.js +129 -0
  141. package/dist/stage-line.js.map +1 -0
  142. package/dist/statusline.d.ts +99 -0
  143. package/dist/statusline.d.ts.map +1 -1
  144. package/dist/statusline.js +310 -44
  145. package/dist/statusline.js.map +1 -1
  146. package/dist/store-counts.d.ts +26 -0
  147. package/dist/store-counts.d.ts.map +1 -0
  148. package/dist/store-counts.js +125 -0
  149. package/dist/store-counts.js.map +1 -0
  150. package/dist/store-guard.d.ts +106 -0
  151. package/dist/store-guard.d.ts.map +1 -0
  152. package/dist/store-guard.js +294 -0
  153. package/dist/store-guard.js.map +1 -0
  154. package/dist/swarm-brief.d.ts +95 -0
  155. package/dist/swarm-brief.d.ts.map +1 -0
  156. package/dist/swarm-brief.js +660 -0
  157. package/dist/swarm-brief.js.map +1 -0
  158. package/dist/trace-bundle.d.ts +8 -0
  159. package/dist/trace-bundle.d.ts.map +1 -1
  160. package/dist/trace-bundle.js +11 -0
  161. package/dist/trace-bundle.js.map +1 -1
  162. package/package.json +12 -11
  163. package/sbom.json +804 -154
  164. package/src/agents-policy.ts +46 -2
  165. package/src/amendment-trace.ts +441 -19
  166. package/src/backlog-public.ts +503 -0
  167. package/src/backlog-transitions.ts +77 -0
  168. package/src/backlog.ts +12 -0
  169. package/src/claim-check.ts +25 -2
  170. package/src/claude-hooks-assets.ts +227 -0
  171. package/src/clean-room-smoke.ts +195 -0
  172. package/src/cmd-usage.ts +29 -5
  173. package/src/codex-hooks-assets.ts +140 -30
  174. package/src/codex-hooks.ts +21 -1
  175. package/src/course-staleness.ts +125 -0
  176. package/src/destructive-guard-hook.ts +151 -0
  177. package/src/destructive-guard.ts +3027 -0
  178. package/src/discrimination-gate.ts +98 -19
  179. package/src/feature-adr-routing.ts +220 -22
  180. package/src/guard.ts +318 -0
  181. package/src/harness-core-location.ts +44 -0
  182. package/src/index.ts +111 -3
  183. package/src/lead-shift-gate.ts +145 -0
  184. package/src/ledger-backfill.ts +20 -1
  185. package/src/loop-blobs.generated.ts +12 -3
  186. package/src/loop-lint.ts +52 -7
  187. package/src/loop-plan-graph.ts +66 -1
  188. package/src/loop-trace.ts +13 -1
  189. package/src/managed-hooks.ts +5 -6
  190. package/src/model-recommender.ts +14 -3
  191. package/src/operations.ts +75 -0
  192. package/src/patterns.ts +33 -0
  193. package/src/publish.ts +27 -2
  194. package/src/registry.ts +3 -2
  195. package/src/score.ts +16 -4
  196. package/src/session-retro.ts +466 -11
  197. package/src/setup.ts +246 -9
  198. package/src/sign.ts +18 -1
  199. package/src/skills-verify.ts +99 -1
  200. package/src/stage-line.ts +151 -0
  201. package/src/statusline.ts +396 -47
  202. package/src/store-counts.ts +154 -0
  203. package/src/store-guard.ts +388 -0
  204. package/src/swarm-brief.ts +661 -0
  205. package/src/trace-bundle.ts +10 -0
@@ -144,12 +144,15 @@ export type RunnerSelection =
144
144
  readonly command: 'npx vitest run';
145
145
  readonly runnerName: 'vitest';
146
146
  readonly how: 'scripts.test' | 'dev-dependency';
147
+ /** Сегменты составного `scripts.test`, которые НЕ будут выполнены. См. `selectRunner`. */
148
+ readonly skippedPreparation?: readonly string[];
147
149
  }
148
150
  | {
149
151
  readonly kind: 'node-test';
150
152
  readonly command: 'node --test';
151
153
  readonly runnerName: 'node --test';
152
154
  readonly how: 'scripts.test';
155
+ readonly skippedPreparation?: readonly string[];
153
156
  }
154
157
  | {
155
158
  readonly kind: 'unsupported';
@@ -169,7 +172,17 @@ export type PlannedRunnerSelection =
169
172
  export interface BaseRefResolution {
170
173
  readonly requestedRef: string;
171
174
  readonly resolvedRef: string;
172
- readonly how: 'explicit-ref' | 'merge-base';
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';
173
186
  }
174
187
 
175
188
  /**
@@ -183,22 +196,61 @@ export interface BaseRefResolution {
183
196
  export function selectRunner(scriptsTest: string | null, devDeps: readonly string[]): RunnerSelection {
184
197
  const script = typeof scriptsTest === 'string' && scriptsTest.trim() ? scriptsTest.trim() : null;
185
198
  const tokens = script?.split(/\s+/) ?? [];
186
- const hasShellControl = script !== null && /[\0`$;&|<>()\n\r]/.test(script);
187
-
188
- if (!hasShellControl) {
189
- const vitestOffset =
190
- tokens[0] === 'vitest'
191
- ? 0
192
- : tokens[0] === 'npx' && tokens[1] === 'vitest'
193
- ? 1
194
- : tokens[0] === 'pnpm' && tokens[1] === 'exec' && tokens[2] === 'vitest'
195
- ? 2
196
- : -1;
197
- if (vitestOffset >= 0) {
198
- return { kind: 'vitest', command: 'npx vitest run', runnerName: 'vitest', how: 'scripts.test' };
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
+ }
199
239
  }
200
- if (tokens[0] === 'node' && tokens[1] === '--test') {
201
- return { kind: 'node-test', command: 'node --test', runnerName: 'node --test', how: 'scripts.test' };
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;
202
254
  }
203
255
  }
204
256
 
@@ -210,19 +262,46 @@ export function selectRunner(scriptsTest: string | null, devDeps: readonly strin
210
262
  return { kind: 'unsupported', runnerName: tokens[0] ?? 'none', scriptsTest: script };
211
263
  }
212
264
 
213
- /** Resolve an audited pre-feature ref supplied by the executor. HEAD never wins over a merge-base. */
214
- export function resolveDiscriminationBaseRef(requestedRef: string, mergeBaseRef?: string): BaseRefResolution {
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 {
215
277
  const requested = typeof requestedRef === 'string' ? requestedRef.trim() : '';
216
278
  const mergeBase = typeof mergeBaseRef === 'string' ? mergeBaseRef.trim() : '';
217
279
  if (requested === 'HEAD' && mergeBase) {
218
280
  return { requestedRef: requested, resolvedRef: mergeBase, how: 'merge-base' };
219
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' };
220
293
  return { requestedRef: requested, resolvedRef: requested, how: 'explicit-ref' };
221
294
  }
222
295
 
223
296
  export interface DiscriminationPlanInput {
224
297
  /** the git ref of pre-feature HEAD — the "base" the property test must fail against. */
225
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;
226
305
  /** merge-base already measured by the executor; mandatory to displace a sweeping HEAD. */
227
306
  readonly mergeBaseRef?: string;
228
307
  /** property test(s) mapped from the ADR Confirmation. Empty ⇒ CANNOT_ISOLATE. */
@@ -349,7 +428,7 @@ function sanitizeName(name: string): string | null {
349
428
  */
350
429
  export function planDiscriminationCheck(input: DiscriminationPlanInput): DiscriminationPlan {
351
430
  const requestedBaseRef = typeof input.baseRef === 'string' ? input.baseRef.trim() : '';
352
- const baseRefResolution = resolveDiscriminationBaseRef(requestedBaseRef, input.mergeBaseRef);
431
+ const baseRefResolution = resolveDiscriminationBaseRef(requestedBaseRef, input.mergeBaseRef, input.baseRefSupplied);
353
432
  const baseRef = baseRefResolution.resolvedRef;
354
433
  const packageDirRaw = typeof input.packageDir === 'string' ? input.packageDir.trim().replace(/\/$/, '') : '.';
355
434
  const packageDir = packageDirRaw || '.';
@@ -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 o = specToOpts('codex:' + topCodexId(env) + ':' + r, env);
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
- let spec = env.MODELS[stage];
500
- if (spec === undefined) {
501
- if (!routingRequested(env)) return {};
502
- if (stage === 'code' && (env.CODER === 'codex' || env.CODER === 'codex-fallback')) {
503
- return specToOpts(resolveCoderSpec(env), env);
504
- }
505
- if (stage === 'plan' && env.PLANNER === 'codex') {
506
- return specToOpts('codex:' + env.CODEX_MODEL + ':high', env);
507
- }
508
- if (stage === 'qe') {
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 === 'code' && (spec === null || spec === undefined)) return specToOpts(resolveCoderSpec(env), env);
516
- if (stage === 'qe' && (spec === null || spec === undefined)) return specToOpts(resolveQeSpec(env), env);
517
- return specToOpts(spec, env);
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
- /** A model id is user input (`args.codexModel`) and lands in a shell command. Shell-safe ids only. */
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
  /**