tickmarkr 2.6.6 → 2.6.7

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 (41) hide show
  1. package/dist/adapters/claude-code.js +8 -2
  2. package/dist/adapters/codex-commit-check.d.ts +3 -2
  3. package/dist/adapters/codex-commit-check.js +37 -10
  4. package/dist/adapters/codex.js +10 -4
  5. package/dist/cli/commands/beat.d.ts +6 -1
  6. package/dist/cli/commands/beat.js +123 -76
  7. package/dist/cli/commands/fleet.js +64 -21
  8. package/dist/cli/commands/init.js +18 -7
  9. package/dist/cli/help.d.ts +1 -0
  10. package/dist/cli/help.js +10 -5
  11. package/dist/cli/index.d.ts +1 -1
  12. package/dist/cli/index.js +1 -1
  13. package/dist/gates/baseline.d.ts +9 -0
  14. package/dist/gates/baseline.js +14 -3
  15. package/dist/gates/cache.js +1 -1
  16. package/dist/gates/review.js +15 -0
  17. package/dist/gates/run-gates.d.ts +18 -6
  18. package/dist/gates/run-gates.js +324 -204
  19. package/dist/gates/test-manifest.d.ts +8 -0
  20. package/dist/gates/test-manifest.js +151 -111
  21. package/dist/run/beat-lifecycle.d.ts +154 -0
  22. package/dist/run/beat-lifecycle.js +825 -0
  23. package/dist/run/daemon.js +18 -7
  24. package/dist/run/git-trust.d.ts +27 -0
  25. package/dist/run/git-trust.js +178 -0
  26. package/dist/run/git.js +34 -14
  27. package/dist/run/journal.js +15 -3
  28. package/dist/run/lease.d.ts +23 -0
  29. package/dist/run/lease.js +232 -38
  30. package/dist/run/merge.js +2 -2
  31. package/dist/run/stall.js +20 -1
  32. package/dist/run/supervision.d.ts +8 -0
  33. package/dist/run/supervision.js +24 -4
  34. package/dist/run/verification-job.d.ts +51 -0
  35. package/dist/run/verification-job.js +137 -0
  36. package/dist/tui/ink/fleet-app.d.ts +9 -0
  37. package/dist/tui/ink/fleet-app.js +209 -127
  38. package/package.json +1 -1
  39. package/skills/tickmarkr-auto/SKILL.md +19 -10
  40. package/skills/tickmarkr-loop/SKILL.md +19 -10
  41. package/skills/tickmarkr-overseer/SKILL.md +25 -16
@@ -7,7 +7,7 @@ import { TIER_RANK } from "../config/config.js";
7
7
  import { getAdapter } from "../adapters/registry.js";
8
8
  import { GATE_NAMES } from "../graph/schema.js";
9
9
  import { acceptanceGate } from "./acceptance.js";
10
- import { compareToBaseline, effectiveCeilingMs, waitForCalmWindow, calmWindowReady } from "./baseline.js";
10
+ import { classifiedFileDurations, compareToBaseline, effectiveCeilingMs, waitForCalmWindow, calmWindowReady } from "./baseline.js";
11
11
  import { evidenceGate } from "./evidence.js";
12
12
  import { captureLlmOutput } from "./llm.js";
13
13
  import { disallowedBy, observedSeat } from "../route/preference.js";
@@ -18,7 +18,7 @@ import { discoverTestManifest, evaluateManifestedTest, isVitestTestCommand, VITE
18
18
  import { RUNNER_INFRA_DIAGNOSTIC_RE, timeoutShaped } from "./timeout-shaped.js";
19
19
  import { executionSignal } from "../run/execution-budget.js";
20
20
  import { failureDisposition } from "../run/recovery.js";
21
- import { dependencyLinkRefusal, FORK_CAP_ENV, preserveWorktree, producerFields, ROUTING_ENV_SEAMS, shGit, resolvedCapacity, SUITE_PARENT_ENV, verificationProtocol } from "../run/git.js";
21
+ import { dependencyLinkRefusal, FORK_CAP_ENV, preserveWorktree, producerFields, readCapacity, ROUTING_ENV_SEAMS, shGit, resolvedCapacity, sameCapacity, SUITE_PARENT_ENV, verificationProtocol } from "../run/git.js";
22
22
  import { withJudgeInvocationEvidence } from "../run/journal.js";
23
23
  import { computeVerificationIdentity, verificationIdentityKey, formatReusedRow, getVerdictStore, isInfraResult, resolveStateDir, reusedIdentity, } from "./cache.js";
24
24
  const productionLoadProvider = () => loadavg()[0] ?? 0;
@@ -122,6 +122,9 @@ const TEST_FILE_RE = /(?:^|\/)[^/]*\.(?:test|spec)\.[cm]?[jt]sx?$/;
122
122
  // relative specifiers only — `from "./x.js"`, `import("./x.js")`, `require("./x.js")`
123
123
  const IMPORT_RE = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)["'](\.[^"']*)["']/g;
124
124
  const SELECTION_FILE_CAP = 3000;
125
+ // v2.6.7 T1: the paths the import scan can analyze — the cap bounds THESE, never every tracked path, so
126
+ // planning files, fixtures and docs cannot push an attributable source/test change over it.
127
+ const ANALYZABLE_RE = /\.[cm]?[jt]sx?$/;
125
128
  /**
126
129
  * T4: the tests covering this round's diff, or undefined when the diff cannot be attributed with
127
130
  * certainty — a rename or delete (the old path's coverage is gone), or a changed file no test
@@ -135,32 +138,40 @@ const SELECTION_FILE_CAP = 3000;
135
138
  * (tsconfig paths, package exports) if selection ever misses often enough to be worth a round.
136
139
  */
137
140
  async function coveringTests(worktree, baseRef) {
138
- const diff = await shGit(`git diff --name-status ${shq(baseRef)} HEAD`, worktree);
141
+ const unsupported = { reason: "unsupported-attribution" };
142
+ // -z: NUL-delimited and never C-quoted, so a non-ASCII path keeps its real extension (v2.6.7 T1 review)
143
+ const diff = await shGit(`git diff -z --name-status ${shq(baseRef)} HEAD`, worktree);
139
144
  if (diff.code !== 0)
140
- return undefined;
145
+ return unsupported;
141
146
  const changed = [];
142
- for (const line of diff.stdout.split("\n")) {
143
- if (!line.trim())
147
+ const fields = diff.stdout.split("\0");
148
+ for (let i = 0; i < fields.length; i++) {
149
+ const status = fields[i];
150
+ if (!status)
144
151
  continue;
145
- const parts = line.split("\t");
146
- const status = parts[0] ?? "";
147
152
  // R (rename) and D (delete): whatever used to cover the old path is unattributable now — full suite.
148
- if (!status || status[0] === "R" || status[0] === "D" || parts.length < 2)
149
- return undefined;
150
- changed.push(parts[parts.length - 1]);
153
+ if (status[0] === "R" || status[0] === "D")
154
+ return unsupported;
155
+ if (status[0] === "C")
156
+ i++; // a copy carries its source path first; attribute the destination
157
+ const path = fields[++i];
158
+ if (!path)
159
+ return unsupported;
160
+ changed.push(path);
151
161
  }
152
162
  if (!changed.length)
153
- return undefined;
154
- const listed = await shGit("git ls-files", worktree);
163
+ return unsupported;
164
+ const listed = await shGit("git ls-files -z", worktree);
155
165
  if (listed.code !== 0)
156
- return undefined;
157
- const tracked = listed.stdout.split("\n").filter(Boolean);
158
- if (tracked.length > SELECTION_FILE_CAP)
159
- return undefined; // ponytail: a huge repo pays the full suite rather than a long scan
166
+ return unsupported;
167
+ const tracked = listed.stdout.split("\0").filter(Boolean);
168
+ // ponytail: a huge repo pays the full suite rather than a long scan — counted over analyzable paths only
169
+ if (tracked.filter((p) => ANALYZABLE_RE.test(p)).length > SELECTION_FILE_CAP)
170
+ return { reason: "analyzable-path-cap" };
160
171
  const trackedSet = new Set(tracked);
161
172
  const tests = tracked.filter((p) => TEST_FILE_RE.test(p));
162
173
  if (!tests.length)
163
- return undefined;
174
+ return { reason: "no-covering-tests" };
164
175
  const resolveSpec = (from, spec) => {
165
176
  const base = posix.join(posix.dirname(from), spec);
166
177
  // ESM-TS writes ".js" for a ".ts" source; a directory specifier means its index.
@@ -199,12 +210,14 @@ async function coveringTests(worktree, baseRef) {
199
210
  continue;
200
211
  }
201
212
  const covering = tests.filter((t) => reachOf(t).has(file));
202
- if (!covering.length)
203
- continue; // nothing covers this file — keep every attributable selection already accumulated
213
+ // A changed source no test reaches is unattributable: a diagnostic over the rest would screen around
214
+ // it, so the whole selection is unsupported. A non-source path (docs, planning) carries no imports.
215
+ if (!covering.length && ANALYZABLE_RE.test(file))
216
+ return unsupported;
204
217
  for (const t of covering)
205
218
  selected.add(t);
206
219
  }
207
- return selected.size ? [...selected].sort() : undefined;
220
+ return selected.size ? { files: [...selected].sort() } : { reason: "no-covering-tests" };
208
221
  }
209
222
  /**
210
223
  * The configured test command narrowed to these files. Mirrors testFiltered's `--` rule (acceptance.ts:104):
@@ -216,23 +229,56 @@ export function testCommandForFiles(testCmd, files) {
216
229
  const fwd = wrapped && !/\s--\s/.test(testCmd) ? " --" : "";
217
230
  return `${testCmd}${fwd} ${files.map(shq).join(" ")}`;
218
231
  }
219
- /** OBS-635: a screen costing at least this share of the full suite runs the full suite instead. */
220
- export const SCREEN_PROMOTION_RATIO = 0.75;
232
+ /** v2.6.7 T1 (closed order table): an attributed test-red diagnostic is admitted only when BOTH hold. */
233
+ export const DIAGNOSTIC_MAX_RATIO = 0.15;
234
+ export const DIAGNOSTIC_MAX_ESTIMATE_MS = 60_000;
221
235
  /**
222
- * The screen's share of the full suite's cost, from the per-file durations the harness measured at
223
- * baseline capture — never a worker's timing. Undefined (unknown) unless every selected file has a
224
- * measured duration and the measured total is positive; unknown keeps the conservative screen path.
236
+ * The diagnostic's admission evidence, from the per-file durations the harness measured at baseline
237
+ * capture — never a worker's timing. Unknown (any selected file unmeasured, any measurement that is not a
238
+ * finite number > 0 ms, an infra baseline, or no positive total) skips: an expensive or unknown screen must
239
+ * never become a full suite before semantics.
240
+ * So does timing recorded under a malformed or different capacity (git.ts sameCapacity): it divided the
241
+ * machine by another number, so it is not comparable evidence about this one.
242
+ * `estimatedMs` is the serial sum of those files: admission evidence, not a kill ceiling and not a
243
+ * claim about the parallel-plus-serial wall time the runner will actually take.
225
244
  */
226
- export function screenCostRatio(baseline, selected) {
245
+ export function diagnosticAdmission(baseline, selected) {
227
246
  const entry = baseline.commands.test;
228
- const files = entry?.infra ? undefined : entry?.fileDurations;
229
- if (!files?.length)
230
- return undefined;
231
- const cost = new Map(files.map((f) => [f.file, f.durationMs]));
247
+ // The CURRENT capacity is validated on its own first: an unstamped (absent) baseline capacity is comparable
248
+ // to anything, so it must never wave through a zero, negative, NaN, infinite or fractional current one.
249
+ const current = resolvedCapacity();
250
+ if (readCapacity(current).state !== "present" || !sameCapacity(entry?.capacity, current))
251
+ return { admitted: false, reason: "diagnostic-capacity-mismatch" };
252
+ const raw = entry?.infra ? [] : classifiedFileDurations(entry) ?? [];
253
+ const unknown = { admitted: false, reason: "diagnostic-unknown-cost" };
254
+ // Every input is checked BEFORE any arithmetic touches it: an entry that is not an object whose duration
255
+ // is a finite number > 0 (0 ms, negative, NaN, non-numeric, an object with no primitive value), selected
256
+ // or not, is unknown cost — never a free screen and never a throw.
257
+ const measured = (f) => {
258
+ const d = typeof f === "object" && f !== null ? f.durationMs : undefined;
259
+ return typeof d === "number" && Number.isFinite(d) && d > 0;
260
+ };
261
+ if (!Array.isArray(raw) || !raw.every(measured) || !selected.length)
262
+ return unknown;
263
+ const files = raw;
264
+ // Baseline capture keeps one entry per (project, file): a file's cost is the sum of its measurements.
265
+ const cost = new Map();
266
+ for (const f of files)
267
+ cost.set(f.file, (cost.get(f.file) ?? 0) + f.durationMs);
232
268
  const total = files.reduce((sum, f) => sum + f.durationMs, 0);
233
- if (!(total > 0) || selected.some((file) => !cost.has(file)))
234
- return undefined;
235
- return selected.reduce((sum, file) => sum + cost.get(file), 0) / total;
269
+ // Finite inputs can still overflow: a non-finite total or estimate is no comparable evidence either.
270
+ if (!(total > 0) || !Number.isFinite(total) || selected.some((file) => !((cost.get(file) ?? 0) > 0)))
271
+ return unknown;
272
+ // A selected file that was RED in the baseline ended early there: its cost is unknown, never cheap.
273
+ if (files.some((f) => f.failed === true && selected.includes(f.file)))
274
+ return unknown;
275
+ const estimatedMs = selected.reduce((sum, file) => sum + cost.get(file), 0);
276
+ if (!Number.isFinite(estimatedMs))
277
+ return unknown;
278
+ const costRatio = estimatedMs / total;
279
+ const reason = costRatio > DIAGNOSTIC_MAX_RATIO ? "diagnostic-cost-ratio"
280
+ : estimatedMs > DIAGNOSTIC_MAX_ESTIMATE_MS ? "diagnostic-cost-estimate" : "diagnostic-admitted";
281
+ return { admitted: reason === "diagnostic-admitted", reason, costRatio, estimatedMs };
236
282
  }
237
283
  /** OBS-635: the full manifest the runner lists NOW, under the environment evaluateManifestedTest's own
238
284
  * discovery receives (test-manifest.ts manifestEnvironment), so it compares with the one a verdict
@@ -264,7 +310,7 @@ async function listFullManifest(cmd, worktree) {
264
310
  async function runVitestManifestGate(worktree, cmd, baseline, selected, artifactDir, retry = {}, retried = false) {
265
311
  const entry = baseline.commands.test;
266
312
  const outcome = await evaluateManifestedTest(cmd, worktree, {
267
- baselineDurations: entry?.fileDurations,
313
+ baselineDurations: classifiedFileDurations(entry),
268
314
  longestFile: entry?.longestFile,
269
315
  overallCeilingMs: effectiveCeilingMs(entry),
270
316
  artifactDir,
@@ -325,14 +371,26 @@ const SIGNAL_EXIT_RE = /\b(?:SIGTERM|SIGKILL|signal\s+(?:9|15)|exit(?:s|ed|\s+co
325
371
  const FAILURE_IDENTITY_RE = /\b(?:AssertionError|FAIL\s+\S|Tests?\s+\d+\s+failed|expected\s+.+\s+to\s+)\b/i;
326
372
  /** D1: apply the daemon's signal-only rider before either battery cache read or write. Its onGate
327
373
  * classification happens after persistence, too late to keep a scripted runner's non-verdict out.
328
- * Keep named failures as work verdicts and preserve details for failure-policy fingerprinting. */
329
- function classifySignalOnlyTest(g) {
330
- if (g.gate !== "test" || g.pass || g.meta?.infra === true || !SIGNAL_EXIT_RE.test(g.details))
374
+ * Keep named failures as work verdicts and preserve details for failure-policy fingerprinting.
375
+ * A baseline-forgiven "green" whose runner was signal-killed is the same non-verdict: a killed job
376
+ * completed no suite, so forgiveness never publishes or caches it green. The authoritative termination
377
+ * receipt decides, never the verdict text: a signal-terminated job is killed whatever its details say, and
378
+ * a green stands only on a receipt showing a normal exit — a receiptless CACHED one (an older or planted
379
+ * entry) is unavailable proof, so a fresh job measures instead. A fresh row is this invocation's own: every
380
+ * production runner attaches its receipt, and the manifest path's verdict already requires a clean exit
381
+ * (test-manifest.ts verifyManifestReport). A no-command skip ran nothing and claims none. */
382
+ function classifySignalOnlyTest(g, cached = false) {
383
+ const termination = g.evidenceReceipt?.termination.kind;
384
+ const exitCode = g.evidenceReceipt?.termination.exitCode;
385
+ const killed = termination === "signal" || exitCode === 137 || exitCode === 143 || (!g.pass && SIGNAL_EXIT_RE.test(g.details));
386
+ const unproven = g.pass && g.meta?.skipped !== true && termination !== "exit" && (cached || termination !== undefined);
387
+ if (g.gate !== "test" || g.meta?.infra === true || !(killed || unproven))
331
388
  return;
332
389
  const named = Array.isArray(g.meta?.failingTests) && g.meta.failingTests.length > 0;
333
- if (named || FAILURE_IDENTITY_RE.test(g.details))
390
+ if (!g.pass && (named || FAILURE_IDENTITY_RE.test(g.details)))
334
391
  return;
335
- g.meta = { ...g.meta, classification: "infra", infra: true, retryable: false, kind: "signal-exit" };
392
+ g.pass = false;
393
+ g.meta = { ...g.meta, classification: "infra", infra: true, retryable: false, kind: killed || termination ? "signal-exit" : "no-exit-receipt" };
336
394
  }
337
395
  /** OBS-1151: a criterion's comparable subject — its canonical text, the task's declared bounds and the
338
396
  * operator context. The cited files' blobs are compared separately, over the union of both citations. */
@@ -421,9 +479,8 @@ export async function runGates(task, ctx) {
421
479
  await receiptNotes;
422
480
  };
423
481
  let selectionDecision;
424
- // OBS-635: the identity a full suite measured inside the battery, revalidated after semantics.
425
- let fullInBattery = false;
426
- let batteryFullIdentity;
482
+ // OBS-635: a stale in-battery full green buys one fresh merge-candidate job (see answersNow).
483
+ let rerunFull = false;
427
484
  let commits = [];
428
485
  // Check before cache identity, npm policy probes, or any gate command.
429
486
  const dependencyRefusal = dependencyLinkRefusal(ctx.worktree);
@@ -482,28 +539,20 @@ export async function runGates(task, ctx) {
482
539
  };
483
540
  const shapeGates = ctx.cfg.gates.byShape?.[task.shape];
484
541
  const enabled = (g) => task.gates.includes(g) && (g !== "acceptance" && g !== "review" || shapeGates?.[g] !== false);
485
- // T4 (OBS-265): a GREEN selected-test run is a screen, not the round's verdict — the merge-candidate
486
- // round re-runs the full suite on the same commit and THAT is what the round reports. With no
487
- // semantic gate to act on it, the screen is held so its full suite speaks for it in one row.
488
- // (A RED screen IS the verdict: the round ends there, so it is recorded immediately.)
489
- let heldTest;
490
542
  const semantic = enabled("acceptance") || enabled("review");
491
- // v2.6.5 T6 (D): the closed repair-mode table. A repair the REVIEW sent back (carried review:material)
492
- // whose test history is clean — selection allowed, no required repair test — asks the question it
493
- // was sent back for first: cheap gates → acceptance ‖ review → screen → merge-candidate full suite,
494
- // so a semantic red costs zero test starts. selectTests false or a nonempty requiredRepairTests is a
495
- // test-red repair and keeps battery-first order; so does every first attempt (nothing carried).
496
- const semanticFirst = semantic && ctx.selectTests === true && !ctx.requiredRepairTests?.length
497
- && (ctx.carriedFindings ?? []).some((f) => f.class === "review:material");
498
- // Semantic-first, a review that returned NO verdict is not a red that ends the round before its
499
- // test proof: the daemon re-asks only the review (runReviewRecovery) and merges on the rows beside
500
- // it, so the round still buys its screen and full suite. The row stays red; the round is unsatisfied.
501
- const failed = () => results.some((r) => !r.pass && !(semanticFirst && r.gate === "review" && r.meta?.noVerdict === true));
502
- // OBS-1176: when acceptance/review WILL act on a green screen, the screen is published before they
503
- // start, as its own selected row. The full suite afterwards is a second invocation on its own row —
504
- // it carries only its own receipts and interval, so it neither erases nor re-counts the screen.
505
- // Semantic-first, nothing acts on the screen but its full suite: it is held like a gate-less round's.
506
- const publishScreen = semantic && !semanticFirst;
543
+ // v2.6.7 T1 (closed order table): after the cheap build/lint/evidence/scope checks, acceptance and
544
+ // review run BEFORE any test-gate payload for every candidate — fresh, semantic repair, or a test-red
545
+ // repair without an admitted diagnostic — so a decisive semantic red costs zero test starts. Only a
546
+ // concrete attributed behavioral test-red repair may buy one cheap diagnostic first (see below).
547
+ // A review that returned NO verdict is not a red that ends the round before its test proof: the
548
+ // daemon re-asks only the review (runReviewRecovery) and merges on the rows beside it, so the round
549
+ // still buys its full job. The row stays red; the round is unsatisfied until that recovery answers.
550
+ // A gate that DECLINED (meta.skipped, R3) is no red either: a declined review no longer ends the round
551
+ // just because it now precedes the test gate.
552
+ const failed = () => results.some((r) => !r.pass && r.meta?.skipped !== true && !(r.gate === "review" && r.meta?.noVerdict === true));
553
+ // D-974: set once the cheap checks pass. From then on an enabled gate the round leaves unrun is owed
554
+ // proof (done()) unless a decisive red already decided the round.
555
+ let proofOwed = false;
507
556
  // v2.0 T2 (OBS-554): this round's per-gate measurement. Every interval a gate actually spends
508
557
  // executing is added HERE, at the call site that runs it, so a gate that runs twice (the test
509
558
  // gate's screen and its full suite) sums to its own cost and never to the span between them.
@@ -581,14 +630,7 @@ export async function runGates(task, ctx) {
581
630
  await ctx.onGate?.({ phase: "start", gate, index: indexOf(gate), total, ...(parentAt === undefined ? {} : { parentAt }) });
582
631
  };
583
632
  // The returned array stays in GATE_NAMES order however few gates a short-circuiting round reached.
584
- // A round that ends before its merge-candidate stage flushes the held screen on the way out, so a
585
- // green subset run is still journaled exactly once — as a selected run, which is what it was.
586
633
  const done = async () => {
587
- if (heldTest) {
588
- const held = heldTest;
589
- heldTest = undefined;
590
- await ctx.onGate?.({ phase: "end", gate: "test", result: held }); // stamped when it was held
591
- }
592
634
  const sorted = [...results].sort((a, b) => GATE_NAMES.indexOf(a.gate) - GATE_NAMES.indexOf(b.gate));
593
635
  // v1.87 T5: no round returns a MERGEABLE GREEN on a dirty tree. The battery is not the only gate
594
636
  // that executes shell in this worktree — the acceptance gate runs command and named-test oracles
@@ -607,6 +649,29 @@ export async function runGates(task, ctx) {
607
649
  await ctx.onGate?.({ phase: "end", gate: refusal.gate, result: refusal });
608
650
  }
609
651
  }
652
+ // D-974 (closed missing-proof table): owed rows land ONLY when the round holds no decisive red —
653
+ // every unsatisfied row is infra or no-verdict and could be recovered — so a review-only recovery
654
+ // can never merge over a gate the round never ran. After a decisive rejection the gates it left
655
+ // unrun owe nothing. A selected diagnostic green is not full proof, so it is owed too. Gates the
656
+ // task omits or its shape disables are outside `sequence` and create no debt.
657
+ const unsatisfied = results.filter((r) => !(r.pass || r.meta?.skipped === true) || r.meta?.infra === true);
658
+ if (proofOwed && unsatisfied.length && unsatisfied.every((r) => r.meta?.infra === true || r.meta?.noVerdict === true)) {
659
+ for (const gate of sequence) {
660
+ if (gate === "test" && ctx.commands.test === undefined)
661
+ continue;
662
+ const at = results.findIndex((r) => r.gate === gate);
663
+ const diagnosticOnly = gate === "test" && at >= 0 && results[at].pass
664
+ && Array.isArray(results[at].meta?.selectedTests) && results[at].meta?.fullSuite !== true;
665
+ if (at >= 0 && !diagnosticOnly)
666
+ continue;
667
+ if (at >= 0)
668
+ results.splice(at, 1);
669
+ await record({ gate, pass: false, details: `${gate} not run — the round ended on a recoverable non-verdict before `
670
+ + `${gate === "test" ? "its full verification job" : "this gate published a verdict"}; that proof is still owed on this subject`,
671
+ meta: { skipped: true, infra: true, classification: "infra", retryable: false, gateOwed: gate, ...(gate === "test" ? { testOwed: true } : {}) } });
672
+ }
673
+ return { results: [...results].sort((a, b) => GATE_NAMES.indexOf(a.gate) - GATE_NAMES.indexOf(b.gate)), commits };
674
+ }
610
675
  return { results: sorted, commits };
611
676
  };
612
677
  const toolGates = ["build", "lint"].filter(enabled);
@@ -803,18 +868,48 @@ export async function runGates(task, ctx) {
803
868
  capacity: resolvedCapacity(),
804
869
  });
805
870
  // OBS-635: a full green answers only for the manifest it certified. The tree identity cannot see an
806
- // ignored generated test the runner would collect, so every full-green reuse rediscovers the
807
- // runner's listing and requires the verdict's to equal it; a runner without a listing is bound by
808
- // its identity alone. Reads before the semantic gates share one listing; `listing` resets after them.
809
- let listing;
810
- const certifiesFullManifest = async (verdict) => {
871
+ // ignored generated test the runner would collect, so every full-green check rediscovers the
872
+ // runner's listing NOW and requires the verdict's to equal it; a runner without a listing is bound by
873
+ // its identity alone. So is a FRESH green that certified no manifest beside a runner that lists none
874
+ // now: neither side holds a manifest that could have moved. A cached one certifies nothing (no reuse).
875
+ // OOB M1: the listing is itself a command — one that creates an ignored collectable test AFTER taking
876
+ // its own snapshot returns the manifest it just made stale, and neither cleanliness nor the identity
877
+ // sees an ignored file. So an agreeing listing certifies only when STABLE: one more listing, taken
878
+ // after the first one's side effects, must return the same set (one extra listing per proof, and only
879
+ // on the agreeing path). An unstable listing is stale proof like any other moved manifest.
880
+ const certifiesFullManifest = async (verdict, fresh = false) => {
811
881
  if (!verdict.pass || !isVitestTestCommand(ctx.commands.test, ctx.worktree))
812
882
  return true;
813
883
  const certified = verdict.meta?.manifest;
814
- const current = await (listing ??= listFullManifest(ctx.commands.test, ctx.worktree));
815
- return Array.isArray(certified) && current !== undefined
816
- && certified.length === current.length && [...certified].sort().every((file, i) => file === current[i]);
884
+ const current = await listFullManifest(ctx.commands.test, ctx.worktree);
885
+ if (fresh && certified === undefined && current === undefined)
886
+ return true;
887
+ if (!Array.isArray(certified) || current === undefined
888
+ || certified.length !== current.length || ![...certified].sort().every((file, i) => file === current[i]))
889
+ return false;
890
+ const settled = await listFullManifest(ctx.commands.test, ctx.worktree);
891
+ return settled !== undefined && settled.length === current.length && settled.every((file, i) => file === current[i]);
817
892
  };
893
+ // OBS-635: a full green — fresh or cached — answers only while the identity it measured and the complete
894
+ // manifest the runner lists NOW still hold — the job's own command may have written an ignored lockfile or
895
+ // a test the runner now collects. Checked before that green is cached, reused or published. `strict` (the in-battery job,
896
+ // which may still buy a fresh one) adds D-598: an unmeasurable lifecycle on either side is not comparable
897
+ // to anything (VerdictStore R41 refuses it), so two `unknown`s hashing equal is no unchanged identity.
898
+ const answersNow = async (before, verdict, strict, fresh = strict) => {
899
+ // The listing is a command too (it may write an ignored lockfile or a tracked byte), so it runs FIRST
900
+ // and cleanliness and identity are sampled after it — never before.
901
+ if (!(await certifiesFullManifest(verdict, fresh)) || await dirtyWorktree())
902
+ return false;
903
+ const now = await fullTestIdentity();
904
+ const measurable = (id) => !strict || id.envParts?.verification?.lifecycle !== "unknown";
905
+ return !!now && !!before && measurable(now) && measurable(before)
906
+ && verificationIdentityKey(now) === verificationIdentityKey(before);
907
+ };
908
+ // A stale green is never green: an infra row naming why, unverdicted (skipped) while a fresh job supersedes it.
909
+ const staleProof = (r, superseded) => ({ ...r, pass: false,
910
+ details: `stale full proof: this job's own command moved the verification identity or the complete manifest the runner `
911
+ + `lists now, so its green answers an earlier subject — ${superseded ? "one fresh full job measures this one" : "failing closed"}\n${r.details}`,
912
+ meta: { ...r.meta, infra: true, classification: "infra", retryable: false, staleProof: true, ...(superseded ? { skipped: true } : {}) } });
818
913
  // shell tools vs the shared baseline
819
914
  const retryOptions = (identity) => ctx.authorizeInfraRetry
820
915
  ? { authorizeRetry: (cause) => ctx.authorizeInfraRetry(identity ? verificationIdentityKey(identity) : "", cause === "infra" ? "infrastructure" : "host-starved") }
@@ -842,14 +937,21 @@ export async function runGates(task, ctx) {
842
937
  });
843
938
  const hit = verdictStore.get(identity);
844
939
  if (hit)
845
- classifySignalOnlyTest(hit); // Older entries predate classification at the write seam.
940
+ classifySignalOnlyTest(hit, true); // Older entries predate classification at the write seam.
846
941
  if (hit && identity && !isInfraResult(hit) && (hit.pass || (ctx.verificationScope ?? "battery") === "battery")
847
- && !(await discardCachedRed(g, hit)) && (g !== "test" || selected !== undefined || await certifiesFullManifest(hit))) {
848
- r = formatReusedRow(hit, identity);
849
- cached = true;
850
- if (g === "build")
851
- await noBuild("reused-result", "verdict reused; no fresh build ran in this invocation");
852
- await noteReuse(g, r, identity);
942
+ && !(await discardCachedRed(g, hit))) {
943
+ // RE-PROOF: a cached FULL green is reused only after the current manifest listing, then cleanliness
944
+ // and the identity sampled after it, agree with it. Stale, it is never relabelled: the fresh job
945
+ // measures under the identity sampled now.
946
+ if (g !== "test" || selected !== undefined || !hit.pass || await answersNow(identity, hit, false)) {
947
+ r = formatReusedRow(hit, identity);
948
+ cached = true;
949
+ if (g === "build")
950
+ await noBuild("reused-result", "verdict reused; no fresh build ran in this invocation");
951
+ await noteReuse(g, r, identity);
952
+ }
953
+ else
954
+ identity = await fullTestIdentity();
853
955
  }
854
956
  }
855
957
  if (!r) {
@@ -871,10 +973,6 @@ export async function runGates(task, ctx) {
871
973
  // the screen's interval IS the test gate's first interval, so the split needs no second clock
872
974
  if (g === "test" && selected)
873
975
  selectedDurationMs = spans.get("test")?.durationMs ?? 0;
874
- if (g === "test" && !selected) {
875
- fullInBattery = true;
876
- batteryFullIdentity = identity;
877
- }
878
976
  // The pre-battery check proves the tree clean ONCE; a command that exits 0 having rewritten a
879
977
  // tracked file makes it dirty again, and every gate after it — including the next shell gate,
880
978
  // which would then run against bytes HEAD does not hold — inherits that. So re-check after each
@@ -889,24 +987,31 @@ export async function runGates(task, ctx) {
889
987
  }
890
988
  if (r)
891
989
  classifySignalOnlyTest(r);
990
+ // Every round's in-battery full job is its first — test-only verification included: stale, it is
991
+ // published unverdicted and buys one fresh merge-candidate job below, whose own stale green fails closed.
992
+ if (g === "test" && !selected && cmd !== undefined && !cached && r.pass && !(await answersNow(identity, r, true))) {
993
+ r = staleProof(r, true);
994
+ rerunFull = true;
995
+ }
892
996
  if (!cached && identity && r && !isInfraResult(r)) {
893
997
  verdictStore.set(identity, { ...r, meta: { ...r.meta, source: "gate", runDir: ctx.artifactDir } });
894
998
  }
895
999
  if (g === "test" && selected) {
896
- const screened = { ...r, meta: { ...r.meta, selectedTests: selected } };
897
- if (!screened.pass)
898
- await record(screened);
899
- else if (publishScreen) {
900
- await record(screened);
901
- // The screen's interval now lives on its own row; the full suite measures from zero.
902
- spans.delete("test");
903
- loadSamples.delete("test");
904
- selectedDurationMs = undefined;
905
- }
906
- else {
907
- heldTest = withTelemetry(screened);
908
- results.push(heldTest);
909
- }
1000
+ // v2.6.7 T1: the attributed diagnostic. A behavioral red IS the round's verdict and ends it
1001
+ // before semantics. An infrastructure red is no verdict either way: it is published UNVERDICTED
1002
+ // (skipped — the journal row keeps its infra evidence and receipts but no `pass`, so the review
1003
+ // round counter never reads it as a decisive test red) and never enters the round's results, so
1004
+ // it can neither end the round nor become green — semantics and the full job decide. A green is
1005
+ // published as its own selected row; the full job afterwards replaces it in the results.
1006
+ const inconclusive = !r.pass && isInfraResult(r);
1007
+ const screened = withTelemetry({ ...r, meta: { ...r.meta, selectedTests: selected, ...(inconclusive ? { skipped: true } : {}) } });
1008
+ if (!inconclusive)
1009
+ results.push(screened);
1010
+ await ctx.onGate?.({ phase: "end", gate: "test", result: screened });
1011
+ // The diagnostic's interval now lives on its own row; the full job measures from zero.
1012
+ spans.delete("test");
1013
+ loadSamples.delete("test");
1014
+ selectedDurationMs = undefined;
910
1015
  }
911
1016
  else {
912
1017
  await record(r);
@@ -1324,9 +1429,11 @@ export async function runGates(task, ctx) {
1324
1429
  // enough: an acceptance-first await withholds a completed review behind a slow/hung judge and a
1325
1430
  // process death can lose that already-earned verdict. The returned result is still sorted into
1326
1431
  // GATE_NAMES order by done(); the event stream truthfully records each independent completion.
1327
- // OBS-1168(c): a sibling that throws, or that ends seatless (no seat could launch, so the round can
1432
+ // OBS-1168(c): a sibling that throws, or a seatless JUDGE (no seat could launch, so the round can
1328
1433
  // only park infra), cancels the other — its seats are closed — and the round still AWAITS it, with
1329
1434
  // or without an execution policy, so no verdict of a settled round publishes after its engagement.
1435
+ // v2.6.7 T1: a seatless REVIEW is recoverable (runReviewRecovery re-asks it alone), so it cancels
1436
+ // nothing — acceptance finishes independently and publishes its own result.
1330
1437
  const seatless = (r) => r.meta?.cause === "seat-launch-failed" && r.meta?.infra === true;
1331
1438
  let failure;
1332
1439
  const fail = (reason) => {
@@ -1337,16 +1444,18 @@ export async function runGates(task, ctx) {
1337
1444
  const judged = judging?.then(async (outcome) => {
1338
1445
  if (cancelled)
1339
1446
  return;
1340
- await withJudgeInvocationEvidence(outcome.invocations, () => record(outcome.result));
1341
- if (seatless(outcome.result))
1447
+ // D-974: a seatless judge beside an already-settled no-verdict review has no sibling to cancel —
1448
+ // acceptance is MISSING proof: its row is owed and the round still buys its full job.
1449
+ const missing = seatless(outcome.result) && results.some((r) => r.gate === "review" && r.meta?.noVerdict === true);
1450
+ await withJudgeInvocationEvidence(outcome.invocations, () => record(missing
1451
+ ? { ...outcome.result, meta: { ...outcome.result.meta, skipped: true, gateOwed: "acceptance" } } : outcome.result));
1452
+ if (seatless(outcome.result) && !missing)
1342
1453
  cancelSemantic();
1343
1454
  }).catch(fail);
1344
1455
  const reviewed = reviewing?.then(async (outcome) => {
1345
1456
  if (cancelled)
1346
1457
  return;
1347
1458
  await record(outcome);
1348
- if (seatless(outcome))
1349
- cancelSemantic();
1350
1459
  }).catch(fail);
1351
1460
  // ponytail: a headless seat has no slot to close; it is awaited to its own timeout, never abandoned.
1352
1461
  await Promise.all([judged, reviewed]);
@@ -1356,108 +1465,105 @@ export async function runGates(task, ctx) {
1356
1465
  executionSignal()?.throwIfAborted();
1357
1466
  return cancelled || failed();
1358
1467
  };
1359
- if (semanticFirst) {
1360
- if (await runSemantics()) {
1361
- // A cancelled round starts no test, yet its no-verdict review can still be re-asked alone. Its test
1362
- // proof is recorded OWED — unsatisfied, never a verdict — so that re-ask cannot merge an unrun subject.
1363
- if (!failed() && enabled("test") && ctx.commands.test !== undefined) {
1364
- await record({ gate: "test", pass: false, details: "test not run — the semantic round was cancelled before its "
1365
- + "test screen; the merge-candidate full suite is still owed on this subject",
1366
- meta: { skipped: true, infra: true, classification: "infra", retryable: false, testOwed: true } });
1367
- }
1368
- return done();
1369
- }
1370
- // Subject freshness: the semantic gates ran oracles and vendor CLIs in this worktree. Their dirt is
1371
- // not the committed subject, so no screen measures it and no cached green row answers for it.
1372
- const dirt = enabled("test") && ctx.commands.test !== undefined ? await dirtyWorktree() : undefined;
1373
- if (dirt) {
1374
- await emitStart("test");
1375
- await record(await dirtyRefusal("test", dirt));
1376
- return done();
1377
- }
1378
- }
1379
- // A non-final round may run only the tests covering its own diff; the merge-candidate round below
1380
- // pays the full suite anyway, so a selection that misses costs a round and can never merge.
1381
- let selected = ctx.selectTests && enabled("test") && ctx.commands.test
1382
- ? await coveringTests(ctx.worktree, ctx.baseRef)
1383
- : undefined;
1384
- let selectionReason = ctx.selectionReason ?? (selected ? "affected-tests" : "full-suite-fallback");
1385
- if (selected && ctx.requiredRepairTests?.length) {
1386
- const required = ctx.requiredRepairTests;
1468
+ proofOwed = true;
1469
+ const testRuns = enabled("test") && ctx.commands.test !== undefined;
1470
+ // v2.6.7 T1 (closed order table): the ONE diagnostic a round may buy before semantics — a concrete
1471
+ // attributed behavioral test-red repair (the caller's known failing files) whose diagnostic covers
1472
+ // every required failing file plus the conservatively selected tests reaching its diff, admitted only
1473
+ // on comparable harness timing with ratio <= 0.15 AND estimate <= 60000 ms. Selection disabled,
1474
+ // unsupported attribution, the analyzable-path cap or unknown/over-bound timing skip it: semantics
1475
+ // then precede the one full job, never an expensive screen standing in front of them. Test-only
1476
+ // verification (no semantic gate) or an explicit full recheck (no selection) keeps its full scope.
1477
+ let diagnostic;
1478
+ let decision;
1479
+ const required = ctx.requiredRepairTests ?? [];
1480
+ if (testRuns && ctx.selectTests === true && required.length && !semantic)
1481
+ decision = { reason: "test-only-verification" };
1482
+ else if (testRuns && ctx.selectTests === true && required.length) {
1387
1483
  const safe = required.every((path) => posix.normalize(path) === path && !path.startsWith("../")
1388
1484
  && !path.startsWith("/") && TEST_FILE_RE.test(path) && existsSync(join(ctx.worktree, path)));
1389
1485
  const listed = safe ? await shGit(`git ls-files -z -- ${required.map(shq).join(" ")}`, ctx.worktree) : undefined;
1390
1486
  const tracked = new Set(listed?.stdout.split("\0").filter(Boolean));
1391
- if (!listed || listed.code !== 0 || required.some((path) => !tracked.has(path))) {
1392
- selected = undefined;
1393
- selectionReason = "required-repair-test-unavailable";
1487
+ const covering = await coveringTests(ctx.worktree, ctx.baseRef);
1488
+ if (!listed || listed.code !== 0 || required.some((path) => !tracked.has(path)))
1489
+ decision = { reason: "required-repair-test-unavailable" };
1490
+ else if (!covering.files)
1491
+ decision = { reason: covering.reason };
1492
+ else {
1493
+ const files = [...new Set([...covering.files, ...required])].sort();
1494
+ // An exact full green already answers the full job; reusing it is the full job, not a screen.
1495
+ const id = await fullTestIdentity();
1496
+ const hit = verdictStore.get(id);
1497
+ if (hit)
1498
+ classifySignalOnlyTest(hit, true);
1499
+ if (hit?.pass === true && !isInfraResult(hit) && await answersNow(id, hit, false))
1500
+ decision = { reason: "full-green-cache" };
1501
+ else {
1502
+ decision = diagnosticAdmission(ctx.baseline, files);
1503
+ if (decision.admitted)
1504
+ diagnostic = files;
1505
+ }
1394
1506
  }
1395
- else
1396
- selected = [...new Set([...selected, ...required])].sort();
1397
1507
  }
1398
- // OBS-635: a screen buys nothing when the full suite that must follow it already has a qualified
1399
- // green on this exact identity (read BEFORE any screen), or when the harness-measured screen costs
1400
- // at least 75 % of it — then the full suite runs in the battery instead. Unknown timing keeps the
1401
- // screen. A selected-only green never answers here: its identity names its selection.
1402
- let promotion;
1403
- if (selected) {
1404
- const hit = verdictStore.get(await fullTestIdentity());
1405
- const costRatio = screenCostRatio(ctx.baseline, selected);
1406
- if (hit?.pass === true && !isInfraResult(hit) && await certifiesFullManifest(hit))
1407
- promotion = { reason: "full-green-cache" };
1408
- else if (costRatio !== undefined && costRatio >= SCREEN_PROMOTION_RATIO)
1409
- promotion = { reason: "screen-cost-promoted", costRatio };
1410
- if (promotion)
1411
- selected = undefined;
1412
- }
1413
- if (ctx.selectionReason || promotion)
1508
+ // The recorded reason is the one that actually decided scope: with no decision above, it is the
1509
+ // eligibility check that failed. Selection off: the caller's reason is what turned it off (an explicit
1510
+ // full recheck, a distrusted history) — unless it names selection itself, which cannot have disabled it.
1511
+ if (ctx.selectionReason)
1414
1512
  selectionDecision = {
1415
- scope: selected ? "selected" : "full", reason: promotion?.reason ?? (selected ? selectionReason
1416
- : selectionReason === "known-failing-files" ? "unsupported-selection-full-suite" : selectionReason),
1417
- requiredFiles: [...(ctx.requiredRepairTests ?? [])],
1418
- ...(promotion?.costRatio !== undefined ? { costRatio: promotion.costRatio } : {}),
1513
+ scope: diagnostic ? "selected" : "full",
1514
+ reason: decision?.reason ?? (ctx.selectTests !== true
1515
+ ? (ctx.selectionReason === "known-failing-files" ? "selection-disabled" : ctx.selectionReason)
1516
+ : !required.length ? "no-required-repair-tests" : "test-gate-disabled"),
1517
+ requiredFiles: [...required],
1518
+ ...(decision?.costRatio !== undefined ? { costRatio: decision.costRatio } : {}),
1519
+ ...(decision?.estimatedMs !== undefined ? { estimatedMs: decision.estimatedMs } : {}),
1419
1520
  };
1420
- await runBattery(selected ? { ...ctx.commands, test: testCommandForFiles(ctx.commands.test, selected) } : ctx.commands, selected, enabled("test") ? ["test"] : []);
1421
- if (failed())
1422
- return done();
1423
- if (semantic && !semanticFirst && await runSemantics())
1424
- return done();
1425
- // OBS-635: the semantic gates ran oracles and vendor CLIs in this worktree after an in-battery full
1426
- // suite spoke, and its green stands only for the identity it measured. Oracle dirt withdraws it; a
1427
- // changed or unmeasurable identity — tree, command, baseline, environment, dependency resolution,
1428
- // capacity, protocol, lifecycle, full manifest — buys a fresh merge-candidate suite below.
1429
- // Semantic-first too: an in-battery full suite that ran AFTER the semantic gates is clear of their
1430
- // dirt, never of its own test command's effects (an ignored lockfile, a newly collected test).
1431
- let rerunFull = false;
1432
- listing = undefined;
1433
- if (fullInBattery && ctx.commands.test !== undefined && semantic) {
1434
- const dirt = await dirtyWorktree();
1521
+ let diagnosed = false;
1522
+ if (diagnostic) {
1523
+ await runBattery({ ...ctx.commands, test: testCommandForFiles(ctx.commands.test, diagnostic) }, diagnostic, ["test"]);
1524
+ if (failed())
1525
+ return done();
1526
+ diagnosed = results.some((r) => r.gate === "test");
1527
+ // An inconclusive (infrastructure) diagnostic answered nothing: the full job's row says so.
1528
+ if (!diagnosed && selectionDecision)
1529
+ selectionDecision = { ...selectionDecision, scope: "full", reason: "diagnostic-inconclusive" };
1530
+ }
1531
+ if (semantic) {
1532
+ // A cancelled round starts no test; done() records its proof OWED (D-974), never a verdict.
1533
+ if (await runSemantics())
1534
+ return done();
1535
+ // Subject freshness: the semantic gates ran oracles and vendor CLIs in this worktree. Their dirt is
1536
+ // not the committed subject, so no full job measures it and no cached green row answers for it.
1537
+ const dirt = testRuns ? await dirtyWorktree() : undefined;
1435
1538
  if (dirt) {
1436
- const refusal = withTelemetry(await dirtyRoundRefusal("test", dirt));
1437
- results[results.findIndex((r) => r.gate === "test")] = refusal;
1438
- await ctx.onGate?.({ phase: "end", gate: "test", result: refusal });
1539
+ const at = results.findIndex((r) => r.gate === "test");
1540
+ if (at >= 0)
1541
+ results.splice(at, 1);
1542
+ await emitStart("test");
1543
+ await record(await dirtyRefusal("test", dirt));
1439
1544
  return done();
1440
1545
  }
1441
- const now = await fullTestIdentity();
1442
- // D-598: an unmeasurable lifecycle on either side is not comparable to anything (VerdictStore R41
1443
- // refuses it); two `unknown`s hashing equal is not an unchanged identity, so the suite reruns.
1444
- const measurable = (id) => id?.envParts?.verification?.lifecycle !== "unknown";
1445
- rerunFull = !now || !batteryFullIdentity || !measurable(now) || !measurable(batteryFullIdentity)
1446
- || verificationIdentityKey(now) !== verificationIdentityKey(batteryFullIdentity)
1447
- || !(await certifiesFullManifest(results.find((r) => r.gate === "test")));
1448
- if (rerunFull) {
1449
- // The in-battery row keeps its own interval; the replacement measures from zero.
1450
- spans.delete("test");
1451
- loadSamples.delete("test");
1452
- }
1546
+ }
1547
+ if (!diagnosed) {
1548
+ await runBattery(ctx.commands, undefined, enabled("test") ? ["test"] : []);
1549
+ if (failed())
1550
+ return done();
1551
+ }
1552
+ if (rerunFull) {
1553
+ // The superseded in-battery row keeps its own interval; the replacement measures from zero.
1554
+ spans.delete("test");
1555
+ loadSamples.delete("test");
1453
1556
  }
1454
1557
  // The merge-candidate round: every other gate is green, so THIS round is the one that can merge —
1455
1558
  // the full suite runs on the exact gated commit before the pipeline reports green. Nothing merges
1456
1559
  // on a subset (spec: "nothing merges without a complete green suite"). Its verdict replaces the
1457
1560
  // screen's entry in the returned record (one `test` entry), and `fullSuite` says which suite spoke
1458
- // while `selectedTests` keeps what the screen ran. In the stream, a held screen is superseded (one
1459
- // `test` end event); a published screen keeps its own earlier event and this is the second.
1460
- if (selected || rerunFull) {
1561
+ // while `selectedTests` keeps what the screen ran. In the stream the diagnostic keeps its own
1562
+ // earlier event and this is the second.
1563
+ // OBS-635 (answersNow): a diagnosed round's full job is its first, so, like the in-battery job, a stale
1564
+ // green buys one fresh job (published unverdicted); a stale last job fails closed. Neither is cached.
1565
+ let spare = diagnosed ? 1 : 0;
1566
+ while (diagnosed || rerunFull) {
1461
1567
  await emitStart("test");
1462
1568
  // This is the last shell command a round can run — the judge's named-test oracle (acceptance.ts)
1463
1569
  // may have run one before it, and every gate between the battery and here reads commits only, so
@@ -1471,12 +1577,17 @@ export async function runGates(task, ctx) {
1471
1577
  identity = await fullTestIdentity();
1472
1578
  const hit = verdictStore.get(identity);
1473
1579
  if (hit)
1474
- classifySignalOnlyTest(hit);
1580
+ classifySignalOnlyTest(hit, true);
1475
1581
  if (hit && identity && !isInfraResult(hit) && (hit.pass || (ctx.verificationScope ?? "battery") === "battery")
1476
- && !(await discardCachedRed("test", hit)) && await certifiesFullManifest(hit)) {
1477
- full = formatReusedRow(hit, identity);
1478
- cached = true;
1479
- await noteReuse("test", full, identity);
1582
+ && !(await discardCachedRed("test", hit))) {
1583
+ // RE-PROOF, as in the battery: listing, then cleanliness and identity; stale, a fresh job measures now.
1584
+ if (!hit.pass || await answersNow(identity, hit, false)) {
1585
+ full = formatReusedRow(hit, identity);
1586
+ cached = true;
1587
+ await noteReuse("test", full, identity);
1588
+ }
1589
+ else
1590
+ identity = await fullTestIdentity();
1480
1591
  }
1481
1592
  }
1482
1593
  if (!full) {
@@ -1489,16 +1600,25 @@ export async function runGates(task, ctx) {
1489
1600
  const dirt = (!cached && full.pass) ? await dirtyWorktree() : undefined;
1490
1601
  if (full)
1491
1602
  classifySignalOnlyTest(full);
1492
- if (!cached && identity && full && !dirt && !isInfraResult(full)) {
1603
+ const stale = !cached && !dirt && full.pass && !(await answersNow(identity, full, false, true));
1604
+ if (!cached && identity && full && !dirt && !stale && !isInfraResult(full)) {
1493
1605
  verdictStore.set(identity, { ...full, meta: { ...full.meta, source: "gate", runDir: ctx.artifactDir } });
1494
1606
  }
1607
+ const spoke = stale ? staleProof(full, spare > 0) : full;
1495
1608
  const merged = withTelemetry(dirt
1496
1609
  ? { ...await dirtyRefusal("test", dirt, ctx.commands.test), evidenceReceipt: full.evidenceReceipt, evidenceReceipts: full.evidenceReceipts }
1497
- : { ...full, meta: { ...full.meta, fullSuite: true, selectedTests: selected } });
1498
- merged.evidenceReceipts = [...(heldTest?.evidenceReceipts ?? []), ...(full?.evidenceReceipts ?? [])];
1610
+ : { ...spoke, meta: { ...spoke.meta, fullSuite: true, selectedTests: diagnosed ? diagnostic : undefined } });
1611
+ merged.evidenceReceipts = [...(full?.evidenceReceipts ?? [])];
1612
+ if (stale && spare-- > 0) {
1613
+ await ctx.onGate?.({ phase: "end", gate: "test", result: merged });
1614
+ // The superseded job keeps its own interval; the replacement measures from zero.
1615
+ spans.delete("test");
1616
+ loadSamples.delete("test");
1617
+ continue;
1618
+ }
1499
1619
  results[results.findIndex((r) => r.gate === "test")] = merged;
1500
- heldTest = undefined;
1501
1620
  await ctx.onGate?.({ phase: "end", gate: "test", result: merged });
1621
+ break;
1502
1622
  }
1503
1623
  return done();
1504
1624
  }