@miller-tech/uap 1.213.0 → 1.215.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/bin/cli.js +2 -0
  3. package/dist/bin/cli.js.map +1 -1
  4. package/dist/cli/deliver.d.ts +66 -0
  5. package/dist/cli/deliver.d.ts.map +1 -1
  6. package/dist/cli/deliver.js +227 -7
  7. package/dist/cli/deliver.js.map +1 -1
  8. package/dist/cli/verify.d.ts +5 -0
  9. package/dist/cli/verify.d.ts.map +1 -1
  10. package/dist/cli/verify.js +50 -1
  11. package/dist/cli/verify.js.map +1 -1
  12. package/dist/cli/wizard-config.d.ts.map +1 -1
  13. package/dist/cli/wizard-config.js +26 -1
  14. package/dist/cli/wizard-config.js.map +1 -1
  15. package/dist/delivery/await-run.d.ts +47 -0
  16. package/dist/delivery/await-run.d.ts.map +1 -1
  17. package/dist/delivery/await-run.js +392 -2
  18. package/dist/delivery/await-run.js.map +1 -1
  19. package/dist/delivery/capability-profile.d.ts +124 -0
  20. package/dist/delivery/capability-profile.d.ts.map +1 -0
  21. package/dist/delivery/capability-profile.js +392 -0
  22. package/dist/delivery/capability-profile.js.map +1 -0
  23. package/dist/delivery/convergence-loop.d.ts +38 -0
  24. package/dist/delivery/convergence-loop.d.ts.map +1 -1
  25. package/dist/delivery/convergence-loop.js +98 -1
  26. package/dist/delivery/convergence-loop.js.map +1 -1
  27. package/dist/delivery/epic-controller.d.ts +9 -0
  28. package/dist/delivery/epic-controller.d.ts.map +1 -1
  29. package/dist/delivery/epic-controller.js +1 -0
  30. package/dist/delivery/epic-controller.js.map +1 -1
  31. package/dist/delivery/epic-mission.d.ts +10 -2
  32. package/dist/delivery/epic-mission.d.ts.map +1 -1
  33. package/dist/delivery/epic-mission.js +1 -1
  34. package/dist/delivery/epic-mission.js.map +1 -1
  35. package/dist/delivery/escalation.d.ts +8 -0
  36. package/dist/delivery/escalation.d.ts.map +1 -1
  37. package/dist/delivery/escalation.js +8 -1
  38. package/dist/delivery/escalation.js.map +1 -1
  39. package/dist/delivery/execution-gate.d.ts +29 -0
  40. package/dist/delivery/execution-gate.d.ts.map +1 -1
  41. package/dist/delivery/execution-gate.js +237 -9
  42. package/dist/delivery/execution-gate.js.map +1 -1
  43. package/dist/delivery/mission-acceptance.d.ts +4 -0
  44. package/dist/delivery/mission-acceptance.d.ts.map +1 -1
  45. package/dist/delivery/mission-acceptance.js +35 -1
  46. package/dist/delivery/mission-acceptance.js.map +1 -1
  47. package/dist/delivery/run-state.d.ts +31 -0
  48. package/dist/delivery/run-state.d.ts.map +1 -1
  49. package/dist/delivery/run-state.js +49 -0
  50. package/dist/delivery/run-state.js.map +1 -1
  51. package/dist/mcp-router/tools/deliver.d.ts.map +1 -1
  52. package/dist/mcp-router/tools/deliver.js +5 -2
  53. package/dist/mcp-router/tools/deliver.js.map +1 -1
  54. package/package.json +1 -1
  55. package/scripts/lib/llama-upstream.sh +64 -16
  56. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  57. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  58. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  59. package/tools/agents/scripts/confidence_escalation.py +57 -0
  60. package/tools/agents/tests/test_confidence_escalation.py +140 -2
@@ -27,6 +27,7 @@ import { authorAcceptanceGate } from '../delivery/self-gate.js';
27
27
  import { applyPendingIntents } from '../delivery/pending-intents.js';
28
28
  import { runAcceptanceGate } from '../delivery/acceptance-judge.js';
29
29
  import { buildMissionAcceptanceGate, resolveAcceptanceVerdict } from '../delivery/mission-acceptance.js';
30
+ import { compareCapability, invalidateCapabilityBaseline, readCapabilityBaseline, readCapabilityCurrent, writeCapabilityBaseline, fpsOf, drawsPerFrame, } from '../delivery/capability-profile.js';
30
31
  import { createSpecRegistry } from '../delivery/spec-registry.js';
31
32
  import { guardAgainstOwnerExit, resolveOwnerPid } from '../delivery/orphan-guard.js';
32
33
  import { findRepoRoot, formatScopeNotice, unreachablePaths } from '../delivery/scope-notice.js';
@@ -187,7 +188,7 @@ export function bestKeepFastScore(gateResults, fastRungIds) {
187
188
  export { resolveAcceptanceVerdict } from '../delivery/mission-acceptance.js';
188
189
  import { createAgenticExecutor, noopApplier, selectExecutorMode, lockContractFiles } from '../delivery/agentic-executor.js';
189
190
  import { createRepairEscalation } from '../delivery/repair-escalation.js';
190
- import { clearProjectStop, clearStop, finalRunStatus, isRunBudgetExpired, isStopRequested, makeStopLatch, requestStop, runBudgetMinutes, loadRunState, newRunId, saveRunState, isEpicResume, MAX_PERSISTED_PHASES } from '../delivery/run-state.js';
191
+ import { clearProjectStop, clearStop, finalRunStatus, isRunBudgetExpired, isRunBudgetExplicit, isStopRequested, makeStopLatch, phaseAwareRunBudgetMinutes, requestStop, runBudgetMinutes, loadRunState, newRunId, saveRunState, isEpicResume, MAX_PERSISTED_PHASES } from '../delivery/run-state.js';
191
192
  import { planDeliveryPhases, shouldDecompose } from '../delivery/decompose.js';
192
193
  import { initLedger, markItem } from '../delivery/completion-ledger.js';
193
194
  import { loadUapConfigRaw } from '../utils/config-loader.js';
@@ -272,7 +273,7 @@ import { createHaloDeliveryTracer } from '../delivery/halo-trace.js';
272
273
  import { haloTracePath, isHaloTracingEnabled } from '../observability/halo-exporter.js';
273
274
  import { createRunCoordinator } from '../delivery/run-coordinator.js';
274
275
  import { readCuratedIdeas } from './ideate.js';
275
- import { createEscalationController, defaultEscalationLadder } from '../delivery/escalation.js';
276
+ import { createEscalationController, defaultEscalationLadder, DEFAULT_STAGNATION_TURNS } from '../delivery/escalation.js';
276
277
  import { FilePracticeStore, defaultPracticePath, extractKeywords, retrievePracticesSemantic, } from '../delivery/practice.js';
277
278
  import { detectRungs, mergeRedetectedRungs, runLadder, runTieredLadder, tierOf, TIER_ORDER, demoteBaselineFailures, baselineRegressions } from '../delivery/verifier-ladder.js';
278
279
  import { runDeployDevLadder } from '../delivery/deploy-dev-gate.js';
@@ -1126,6 +1127,91 @@ export async function deliverCommand(instruction, options) {
1126
1127
  throw err;
1127
1128
  }
1128
1129
  }
1130
+ /**
1131
+ * Consecutive flat turns after which an epic attempt yields to a fresh one.
1132
+ *
1133
+ * TWO, from the measurement rather than from taste. In every flat attempt
1134
+ * observed (rust-pg-ext, qwen3.8-27b, 2026-08-20) the score was already pinned
1135
+ * by turn 2 and never moved again through turn 5 — and `↑ turn 3: enable
1136
+ * critic` fired in all four of them without changing anything. Turn 1 can never
1137
+ * trip it: the first score is by definition an improvement on "no score yet".
1138
+ *
1139
+ * Only ever applied when a retry remains, so the cost of being wrong is one
1140
+ * fresh attempt on the same epic — the thing that actually rescued the measured
1141
+ * case (a flat 5-turn attempt, then a pass in 2).
1142
+ */
1143
+ /**
1144
+ * Consecutive flat turns after which an epic attempt yields to a fresh one.
1145
+ *
1146
+ * THREE, and the number is measured rather than chosen. Replaying this rail's
1147
+ * exact streak logic over 157 deliver logs — 51 attempts where it would arm —
1148
+ * gives, per threshold: fires / turns cut / in-attempt breakthroughs destroyed
1149
+ *
1150
+ * K=2 35 101 5 <- the first draft. Destroys five real wins.
1151
+ * K=3 32 67 3
1152
+ * K=4 30 35 2
1153
+ *
1154
+ * Moving 2 -> 3 gives up 34 turns of saving and spares two attempts that went
1155
+ * on to PASS from a flat run — `migration-register-rel-record` [75,75,75,PASS]
1156
+ * and `contracts` [100,100,100,PASS]. The first draft's comment asserted that a
1157
+ * turn-4 breakthrough was something "this sample never showed"; the corpus
1158
+ * contains one, and it passed. That claim was wrong and this is the correction.
1159
+ *
1160
+ * The count is a FLOOR, scaled up with the turn budget by the loop (see
1161
+ * `abortOnFlatTurns`): every breakthrough still lost at K=3 is a 6-8 turn
1162
+ * attempt from a maxTurns=20 run, where three flat turns is a far weaker signal
1163
+ * than it is out of five.
1164
+ *
1165
+ * Caveat kept in view: the replay reads gate percentages from logs, so it
1166
+ * cannot see acceptance movement or inconclusive turns — both of which reset
1167
+ * the streak. Every "destroyed" count above is therefore an upper bound.
1168
+ */
1169
+ export const EPIC_FLAT_TURN_YIELD = 3;
1170
+ /**
1171
+ * The threshold, with an operator override: `UAP_EPIC_FLAT_TURN_YIELD`.
1172
+ * `0` or any non-positive value disables the rail entirely.
1173
+ *
1174
+ * Blank is treated as UNSET, not as zero: an exported-but-empty variable is a
1175
+ * common accident, and `Number('')` is 0, which would silently switch the rail
1176
+ * off — the exact inversion of the "a typo must not disable it" intent.
1177
+ */
1178
+ export function epicFlatTurnYield() {
1179
+ const raw = process.env.UAP_EPIC_FLAT_TURN_YIELD;
1180
+ if (raw !== undefined && raw.trim() !== '') {
1181
+ const n = Number(raw);
1182
+ if (Number.isFinite(n))
1183
+ return n >= 1 ? Math.floor(n) : undefined;
1184
+ }
1185
+ return EPIC_FLAT_TURN_YIELD;
1186
+ }
1187
+ /**
1188
+ * The loop option that yields a FLAT epic attempt to a fresh one — or nothing.
1189
+ *
1190
+ * Extracted so the WIRING is testable, not just the threshold. It carries the
1191
+ * whole safety argument: ending an attempt early is defensible only while a
1192
+ * fresh attempt remains, so on the last attempt (`retriesRemaining` 0 or
1193
+ * absent) this returns `{}` and the attempt plays out exactly as today.
1194
+ *
1195
+ * WHAT THIS DOES AND DOES NOT BUY, stated precisely because the first draft
1196
+ * overclaimed it: no turn budget is transferred. Each attempt gets its own
1197
+ * `maxTurns` either way, so a fresh attempt is not made longer by an earlier
1198
+ * one ending sooner — the epic's TOTAL exploration shrinks. What is saved is
1199
+ * wall clock, on turns whose gate score is provably not moving.
1200
+ */
1201
+ export function epicFlatTurnOption(retriesRemaining, escalationLadderReach = 0) {
1202
+ if (!((retriesRemaining ?? 0) > 0))
1203
+ return {};
1204
+ const limit = epicFlatTurnYield();
1205
+ if (limit === undefined)
1206
+ return {};
1207
+ // NEVER yield while this attempt still has an unplayed escalation tier — the
1208
+ // ladder is precisely "what this attempt has left to try", and cutting it is
1209
+ // not reclaiming waste, it is skipping the rescue. Raising the threshold to
1210
+ // the ladder's reach is self-correcting: when the ladder needs the whole turn
1211
+ // budget (a stronger model on a 5-turn attempt), the resulting limit exceeds
1212
+ // the budget and the rail simply never fires.
1213
+ return { abortOnFlatTurns: Math.max(limit, escalationLadderReach) };
1214
+ }
1129
1215
  async function runDeliver(instruction, options) {
1130
1216
  const projectRoot = resolve(options.projectRoot ?? process.cwd());
1131
1217
  // P1 (plan D1): --pending replays gate-recorded edit intents exactly, then
@@ -2052,6 +2138,32 @@ async function runDeliver(instruction, options) {
2052
2138
  onEscalate: (tier, turn) => console.log(chalk.magenta(` ↑ turn ${turn}: ${tier.label}`)),
2053
2139
  })
2054
2140
  : undefined;
2141
+ /**
2142
+ * The turn on which the LAST escalation tier issues its directive.
2143
+ *
2144
+ * An epic attempt may end early on a plateau, and that must never cut the
2145
+ * ladder short: a tier decided on turn N is consumed by turn N+1, so yielding
2146
+ * at or before the last tier's turn means the tier never runs. Measured
2147
+ * against the real ladder — with a stronger model configured, an attempt that
2148
+ * delivers on turn 6 was abandoned on turn 4 and the stronger model was never
2149
+ * called once.
2150
+ *
2151
+ * 0 when no ladder is active, which is the configuration the plateau
2152
+ * measurement was taken in (those logs show `enable critic` and nothing
2153
+ * above it — no stronger model was configured).
2154
+ */
2155
+ const escalationLadderReach = (() => {
2156
+ if (!options.escalate)
2157
+ return 0;
2158
+ const tiers = defaultEscalationLadder({
2159
+ includeExploration: !agentic,
2160
+ candidates,
2161
+ maxTurns: maxTurns ?? 5,
2162
+ escalateExecutor,
2163
+ escalateModelName,
2164
+ }).length;
2165
+ return tiers > 0 ? DEFAULT_STAGNATION_TURNS * tiers + 1 : 0;
2166
+ })();
2055
2167
  // Phase 4: learned best-practice cards for similar tasks. Retrieval is
2056
2168
  // semantic (embeddings) by default — better recall than keyword overlap —
2057
2169
  // and falls back to keyword matching automatically when no embedding
@@ -2282,6 +2394,24 @@ async function runDeliver(instruction, options) {
2282
2394
  const fastScore = bestKeepFastScore(record.gateResults, bestFastRungIds);
2283
2395
  if (fastScore === null || fastScore <= baselineGateScore)
2284
2396
  return; // not a new best
2397
+ // VETO on capability. This is the whole reason the capability profile exists,
2398
+ // and without this hook it would not have closed the incident it cites: on
2399
+ // the recorded regression the gate score went 0% -> 100% WHILE the app lost
2400
+ // most of its function. Acceptance now blocks that turn — but acceptance is
2401
+ // not a rung, so its verdict never reaches `record.gateResults`, and this
2402
+ // hook would happily snapshot the gutted tree as the run's new "best" and
2403
+ // DISPOSE the good one. The end-of-run rollback then restores the damage.
2404
+ //
2405
+ // Reads the same two files acceptance does, so the two cannot disagree.
2406
+ const capCmp = compareCapability(readCapabilityBaseline(projectRoot), readCapabilityCurrent(projectRoot));
2407
+ if (capCmp.regressed) {
2408
+ if (!options.dryRun) {
2409
+ console.log(chalk.yellow(` no-regress: turn scored ${fastScore.toFixed(2)} but LOST capability — refusing to adopt it as best`));
2410
+ for (const f of capCmp.findings)
2411
+ console.log(chalk.dim(` ${f}`));
2412
+ }
2413
+ return;
2414
+ }
2285
2415
  const snap = snapshotTree(projectRoot);
2286
2416
  if (!snap.ok)
2287
2417
  return; // size-cap / failure: keep the previous best
@@ -2682,7 +2812,26 @@ async function runDeliver(instruction, options) {
2682
2812
  // 2026-08-13 — 651 minutes, still phase 0 of 6, 20% of gates on every turn).
2683
2813
  // Riding the stop latch means expiry behaves exactly like an operator's stop
2684
2814
  // request: the turn finishes, the work is checkpointed, the lock is released.
2685
- const budgetMinutes = runBudgetMinutes(projectRoot);
2815
+ // The budget is captured BEFORE decomposition, so it starts at the calibrated
2816
+ // single-mission default and is re-derived once the plan's size is known (see
2817
+ // applyPhaseBudget). `let`, because a 7-epic plan needs a different clock than
2818
+ // the one-mission runs the 120-minute default was measured on.
2819
+ const budgetExplicit = isRunBudgetExplicit(projectRoot);
2820
+ let budgetMinutes = runBudgetMinutes(projectRoot);
2821
+ const baseBudgetMinutes = budgetMinutes;
2822
+ /**
2823
+ * Grant the run time proportional to the plan, once the plan exists.
2824
+ * No-op when the operator set the budget themselves, and never shrinks it.
2825
+ */
2826
+ const applyPhaseBudget = (phaseCount) => {
2827
+ if (budgetExplicit)
2828
+ return;
2829
+ const next = phaseAwareRunBudgetMinutes(baseBudgetMinutes, phaseCount);
2830
+ if (next > budgetMinutes) {
2831
+ budgetMinutes = next;
2832
+ console.log(chalk.dim(` ⏱ run budget ${baseBudgetMinutes} → ${budgetMinutes} min for ${phaseCount} planned phases`));
2833
+ }
2834
+ };
2686
2835
  const runStartedAtMs = Date.now();
2687
2836
  let stoppedForBudget = false;
2688
2837
  // Captured at OBSERVATION time: isStopRequested consumes the project-level
@@ -3021,6 +3170,14 @@ async function runDeliver(instruction, options) {
3021
3170
  persistPlan: (plan) => {
3022
3171
  runState.phases = plan;
3023
3172
  saveRunState(runState);
3173
+ // THE epic budget hook. On a fresh epic run — the DEFAULT for every
3174
+ // mission — the outer `phases` is undefined (it is only ever
3175
+ // resumeState.phases; the decompose block is explicitly skipped when
3176
+ // epics are on), so the call site before dispatch never fires and the
3177
+ // whole scaler was dead on the path that motivated it. The plan first
3178
+ // exists HERE. Re-granting on a later re-plan is safe: the grant only
3179
+ // ever raises (see phaseAwareRunBudgetMinutes).
3180
+ applyPhaseBudget(plan.length);
3024
3181
  },
3025
3182
  persistCompleted: ({ summaries, completed }) => {
3026
3183
  runState.phaseSummaries = [...summaries];
@@ -3040,8 +3197,14 @@ async function runDeliver(instruction, options) {
3040
3197
  saveRunState(runState);
3041
3198
  },
3042
3199
  runOrchestrated: (missionText, plan, parentTaskId) => runOrchestratedMissionCore(buildOrchestratedDeps(missionText, plan, epicParallelTasks, parentTaskId)),
3043
- runEpicLoop: async (scoped) => {
3044
- const loop = new ConvergenceLoop({ ...loopConfig, baselineCheck: false, resumeFrom: undefined, onIteration: makeIterationHook() }, executor, seams);
3200
+ runEpicLoop: async (scoped, opts) => {
3201
+ const loop = new ConvergenceLoop({
3202
+ ...loopConfig,
3203
+ baselineCheck: false,
3204
+ resumeFrom: undefined,
3205
+ onIteration: makeIterationHook(),
3206
+ ...epicFlatTurnOption(opts?.retriesRemaining, escalationLadderReach),
3207
+ }, executor, seams);
3045
3208
  return loop.deliver(scoped);
3046
3209
  },
3047
3210
  setEpicSpec: (spec) => specs.setShared(spec), // fresh epic — breaker needs fresh diff evidence
@@ -3199,6 +3362,32 @@ async function runDeliver(instruction, options) {
3199
3362
  const keepBest = resolveKeepBest(options.keepBest) && fastRungs.length > 0 && !needsSelfGate;
3200
3363
  let regressSnapshot = null;
3201
3364
  let baselineGateScore = 0;
3365
+ // Capability baseline: what the deliverable DOES before this run touches it.
3366
+ //
3367
+ // PROMOTED, not measured. The pre-run ladder immediately below already runs
3368
+ // the execution gate, and that gate writes `.uap/capability/current.json` as a
3369
+ // byproduct of the browser session it opens anyway — so pinning a baseline is
3370
+ // a file copy, not a second headless launch. That is the point of having moved
3371
+ // the measurement into the gate.
3372
+ //
3373
+ // Reported when it is ABSENT, too. An unmeasured baseline silently disables
3374
+ // the whole check, and this repo has shipped inert rungs that looked exactly
3375
+ // like healthy ones from the outside.
3376
+ const pinCapabilityBaseline = () => {
3377
+ if (options.dryRun)
3378
+ return;
3379
+ const pre = readCapabilityCurrent(projectRoot);
3380
+ if (!pre?.measured) {
3381
+ // Nothing measurable this run, so any baseline still on disk describes a
3382
+ // DIFFERENT tree. Drop it rather than measure against it.
3383
+ invalidateCapabilityBaseline(projectRoot);
3384
+ console.log(chalk.dim(` capability: no baseline (${pre?.reason ?? 'not measured'}) — regression check inactive`));
3385
+ return;
3386
+ }
3387
+ writeCapabilityBaseline(projectRoot, pre);
3388
+ console.log(chalk.dim(` capability baseline: ${fpsOf(pre).toFixed(1)} fps, ${drawsPerFrame(pre).toFixed(2)} draws/frame, ` +
3389
+ `${pre.programsUsed} programs, ${pre.listenerTypes} event types`));
3390
+ };
3202
3391
  if (keepBest) {
3203
3392
  baselineGateScore = runLadder(fastRungs, projectRoot).score;
3204
3393
  const snapResult = snapshotTree(projectRoot);
@@ -3216,6 +3405,18 @@ async function runDeliver(instruction, options) {
3216
3405
  console.log(chalk.yellow(` no-regress: ${label} — ${snapResult.detail}; rollback disabled for this run`));
3217
3406
  }
3218
3407
  }
3408
+ // Pin the capability baseline for EVERY run, not only under --keep-best.
3409
+ //
3410
+ // It used to sit inside that branch, which meant --no-keep-best (or
3411
+ // UAP_DELIVER_KEEP_BEST=0, or a self-gated project) left the PREVIOUS run's
3412
+ // baseline on disk. A later, unrelated run then measured against a days-old
3413
+ // reading of a different app and blocked every turn — with the only documented
3414
+ // escape being a flag that did nothing. A baseline nobody pinned this run is
3415
+ // worse than no baseline.
3416
+ //
3417
+ // When --keep-best did not run a ladder there may be no reading yet; the
3418
+ // helper says so and leaves the check inactive rather than guessing.
3419
+ pinCapabilityBaseline();
3219
3420
  // Mark this run (and the gate subprocesses it spawns) as deliver-driven so
3220
3421
  // the delivery-enforcement policy exempts the sanctioned path. Scoped to the
3221
3422
  // loop and RESTORED afterward so a programmatic/long-lived caller doesn't
@@ -3261,6 +3462,14 @@ async function runDeliver(instruction, options) {
3261
3462
  console.log(chalk.dim(' lazy attempt did not pass — engaging the full convergence stack'));
3262
3463
  }
3263
3464
  }
3465
+ // Grant the clock the sequence the plan implies, before any runner starts
3466
+ // consuming it. Epics run as N missions back to back, so the single-mission
3467
+ // default expires mid-sequence (measured: 7 epics, ~16 min each, against a
3468
+ // 120-minute cap). This covers the paths where the plan is ALREADY known —
3469
+ // --no-epics decomposed runs, and epic RESUMES (resumeState.phases). A
3470
+ // fresh epic run has no plan yet and is granted in persistPlan instead.
3471
+ if (phases && phases.length >= 2)
3472
+ applyPhaseBudget(phases.length);
3264
3473
  // Resume routing: an epic-kind resume re-enters the epic path (fidelity —
3265
3474
  // completed epics carry forward as prior summaries). Everything else takes
3266
3475
  // a cursor-honoring sequential path — phased when a phase plan was
@@ -3437,13 +3646,24 @@ async function runDeliver(instruction, options) {
3437
3646
  const endLadder = runLadder(fastRungs, projectRoot);
3438
3647
  const endScore = endLadder.score;
3439
3648
  const endRegressions = baselineRegressions(fastRungs, endLadder.results);
3440
- if (endScore < baselineGateScore || endRegressions.length > 0) {
3649
+ // A capability regression is a THIRD reason to restore, and without it the veto
3650
+ // above is only half a fix. The incident this feature cites is precisely
3651
+ // "gate score went UP while capability collapsed": the veto correctly refuses
3652
+ // to adopt the gutted turn as best, the run then ENDS on that turn, and this
3653
+ // predicate sees endScore >= baseline with no gate regressions and keeps it —
3654
+ // shipping the damage and disposing the good snapshot. The end-of-run ladder
3655
+ // just re-ran the execution rung, so the reading here is fresh.
3656
+ const endCap = compareCapability(readCapabilityBaseline(projectRoot), readCapabilityCurrent(projectRoot));
3657
+ const endCapabilityLost = endCap.regressed;
3658
+ if (endScore < baselineGateScore || endRegressions.length > 0 || endCapabilityLost) {
3441
3659
  try {
3442
3660
  restoreTree(projectRoot, regressSnapshot);
3443
3661
  console.log(chalk.yellow(endRegressions.length > 0
3444
3662
  ? ` ↩ no-regress: reverted to best — tests that passed at baseline are failing now: ` +
3445
3663
  `${endRegressions.flatMap((r) => r.tests).slice(0, 5).join(', ')}`
3446
- : ` ↩ no-regress: reverted to best (end gate score ${endScore.toFixed(2)} < best ${baselineGateScore.toFixed(2)})`));
3664
+ : endCapabilityLost
3665
+ ? ` ↩ no-regress: reverted to best — the run ended having LOST capability: ${endCap.findings.join('; ')}`
3666
+ : ` ↩ no-regress: reverted to best (end gate score ${endScore.toFixed(2)} < best ${baselineGateScore.toFixed(2)})`));
3447
3667
  disposeSnapshot(regressSnapshot);
3448
3668
  }
3449
3669
  catch (restoreErr) {