tickmarkr 2.8.7 → 2.8.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -441,6 +441,28 @@ The daemon reconciles at every safe point:
441
441
  unavailable, a pane vanished mid-sweep) do not replace gate verdicts; unconfirmed board
442
442
  cleanup is journaled and reported.
443
443
 
444
+ ### Worktree lifecycle
445
+
446
+ Queue row 240 (D-2138): at run end, every journaled task checkout is removed once its
447
+ recovery ref is recorded, including parked and failed tasks. Clean merged checkouts
448
+ are removed silently. Dirty work is preserved under `refs/tickmarkr/preserved/`;
449
+ unmerged clean heads are pinned there too. The journal records the recovery ref and
450
+ removed checkout, and resume rebuilds from that work. Task branches and refs remain.
451
+
452
+ A crash or SIGTERM leaves checkouts until the next daemon start in the same checkout.
453
+ After its lifecycle row and reconciliation, that start sweeps other runs in this
454
+ checkout's runs directory only when their newest lifecycle row records a proven dead
455
+ pid, regardless of how they ended. Recovery and removal rows go into the owning run's
456
+ journal and name the sweeping run; outstanding checks and measured wall windows stay unchanged.
457
+ Only journaled task checkouts are touched: there is no worktree directory listing or
458
+ in-use check. Live or unrecorded pids, this run's checkouts, verification checkouts,
459
+ hand-made directories and sibling linked checkouts remain untouched; a refused start
460
+ sweeps nothing. `visibility.keepPanes: forever` disables both removal boundaries.
461
+
462
+ Staged bytes missing from the recovery tree, preservation failures and deletion failures
463
+ keep the checkout and journal a run-level removal failure in the current run. Cleanup
464
+ never changes the run's outcome or adds another run end.
465
+
444
466
  ### Workspace trust
445
467
 
446
468
  tickmarkr creates fresh git worktrees for change isolation. That is separate from each CLI's own
@@ -67,7 +67,7 @@ export async function compile(argv, cwd = process.cwd(), harnessFrom = process.a
67
67
  // Both modes reach the same pure compiler; --dry-run removes every state write below.
68
68
  const g = compileSource(sourcePath, values.type, cwd, // repo root: gsd stores context[0] repo-relative so workers resolve it inside their worktree
69
69
  (plan) => plan, { strict: values.strict });
70
- const sourceFindings = sourceScopeFindings(g.tasks, cwd);
70
+ const sourceFindings = sourceScopeFindings(g.tasks, cwd, { failClosed: g.spec.source === "native" });
71
71
  const scopeLints = [
72
72
  ...collateralLints(g.tasks, cwd),
73
73
  ...sourceScopeLints(g.tasks, cwd, sourceFindings),
@@ -13,6 +13,7 @@ import { assignmentChannel, buildOperatorRecord, formatChannelMoney, formatChann
13
13
  import { cellsOf, cellSummary } from "../../route/profile.js";
14
14
  import { effectiveEvents, fatalRunEndCause, foldOwedChecks, Journal, loadRoutingProfile, OWED_DISCHARGE_EVENT, } from "../../run/journal.js";
15
15
  import { baselineProvenanceOf, fingerprintsOf, forgivenFingerprints, forgivenGateRow, formatBaselineProvenance, formatFingerprints, formatForgiven, formatTipProof, runEndTipProof, } from "../../run/daemon.js";
16
+ import { normalizeGateOutcome } from "../../run/outcome.js";
16
17
  import { formatSpan, WALL_PRIORITY, WALL_PRIORITY_TEXT, wallBudget, wallBudgetFacts } from "../../run/wall-budget.js";
17
18
  import { deriveRunCockpitData } from "../../tui/cockpit/derive.js";
18
19
  import { cellWidth, wrapCells } from "../../tui/cockpit/width.js";
@@ -382,12 +383,15 @@ const verificationOf = (runId, events) => {
382
383
  return "failed";
383
384
  if (tip?.state !== "pass")
384
385
  return "absent";
385
- // A cached green is a verified green of an EARLIER tip (daemon.ts verifyIntegrationTipCached
386
- // stamps `cached` on every gate it replays), so the record names it instead of folding it into a
387
- // plain pass. Still no second window: the cockpit reports "pass" only when the closed cycle
388
- // carried verdicts, so the last `tip-verify` IN THAT CYCLE is the one it passed on.
389
- const last = [...cycle].reverse().find((e) => e.event === "tip-verify");
390
- return last?.data.cached === true ? "cached" : "passed";
386
+ // Queue 35 (D-2134 R6): the same per-gate proof names both the lead and verification line.
387
+ const proof = runEndTipProof(cycle);
388
+ if (proof.gates?.length) {
389
+ if (proof.gates.every(({ kind }) => kind === "reused"))
390
+ return "cached";
391
+ if (proof.gates.some(({ kind }) => kind === "reused"))
392
+ return "mixed";
393
+ }
394
+ return "passed";
391
395
  };
392
396
  // Absent is a state to render, never a zero to invent — and no reading here calls a run green.
393
397
  // NOTE: every reading below is byte-pinned by tests/fixtures/brand-surfaces/report-md.md — the
@@ -396,6 +400,7 @@ const verificationOf = (runId, events) => {
396
400
  const VERIFICATION_READING = {
397
401
  passed: "passed — tickmarkr verified this run's integration tip",
398
402
  cached: "cached — carried forward from an earlier verified tip, not re-run for this one",
403
+ mixed: "mixed — some tip gates ran fresh and others were carried forward",
399
404
  failed: "FAILED — the run did not verify its own tip",
400
405
  absent: "absent — no tip verification recorded: neither passed nor failed",
401
406
  };
@@ -406,9 +411,9 @@ function forgivenLines(events) {
406
411
  function verificationReading(runId, events) {
407
412
  const state = verificationOf(runId, events);
408
413
  const proof = runEndTipProof(closedCycle(events));
409
- if ((state === "passed" || state === "cached") && proof.gates?.length) {
410
- const allCarried = proof.gates.every(({ kind }) => kind === "reused");
411
- return `${allCarried ? "cached" : "passed"} — ${formatTipProof(proof)}`;
414
+ if ((state === "passed" || state === "cached" || state === "mixed") && proof.gates?.length) {
415
+ // Queue 35 (D-2134 R6): mixed describes the lead's provenance; per-gate details retain it here.
416
+ return `${state === "mixed" ? "passed" : state} — ${formatTipProof(proof)}`;
412
417
  }
413
418
  return VERIFICATION_READING[state];
414
419
  }
@@ -520,9 +525,13 @@ export function renderMarkdownRecord(runId, events, prices = [], rows = [], suit
520
525
  lines.push("- **tickmarks:**");
521
526
  if (gates.length) {
522
527
  for (const g of gates) {
523
- // T11: a declined gate never ran — report it as declined, never as a pass.
524
- let pass = gateDeclined(g.data) ? "declined" : g.data.pass === true ? "pass" : g.data.pass === false ? "fail" : EM;
528
+ // Queue 35 (D-2134 R6): only held changes the existing tickmark vocabulary.
529
+ let pass = normalizeGateOutcome(g.data).kind === "held" ? "held"
530
+ : gateDeclined(g.data) ? "declined" : g.data.pass === true ? "pass" : g.data.pass === false ? "fail" : EM;
525
531
  const gate = typeof g.data.gate === "string" ? g.data.gate : EM;
532
+ if (gate === "acceptance" && g.data.judgeIndependent === false) {
533
+ pass += " (independence not established)";
534
+ }
526
535
  if (gate === "review" && pass === "pass") {
527
536
  const reviewer = reviewerIdentity(g.data);
528
537
  const before = events.slice(0, events.indexOf(g) + 1).reverse()
@@ -616,7 +625,7 @@ function textReport(runId, events, rows, cwd, suite) {
616
625
  // T11: gates that declined never ran — out of both the pass count and the rate base.
617
626
  const declinedCount = gateResults.filter((e) => gateDeclined(e.data)).length;
618
627
  const gateRan = gateResults.length - declinedCount;
619
- const gatePass = gateResults.filter((e) => e.data.pass && !gateDeclined(e.data)).length;
628
+ const gatePass = gateResults.filter((e) => normalizeGateOutcome(e.data).kind === "passed" && !gateDeclined(e.data)).length;
620
629
  const escalations = events.filter((e) => e.event === "escalation").length;
621
630
  const consults = events.filter((e) => e.event === "consult-verdict").length;
622
631
  const failovers = events.filter((e) => e.event === "quota-failover").length;
@@ -968,6 +968,16 @@ const recordedTasks = (graph, rows) => graph.tasks.filter((task) => {
968
968
  || row.parkKind !== undefined);
969
969
  });
970
970
  const recordedDone = (tasks, rows) => tasks.filter((task) => graphTaskStatus(rows.get(task.id)?.state, task.status) === "done").length;
971
+ const judgeIndependenceNotes = (events) => {
972
+ // Queue row 34(a) (D-2134 R5): only the newest acceptance row owns this note.
973
+ const acceptance = new Map();
974
+ for (const event of events) {
975
+ if (event.taskId && event.event === "gate-result" && event.data.gate === "acceptance")
976
+ acceptance.set(event.taskId, event);
977
+ }
978
+ return [...acceptance].filter(([, event]) => event.data.judgeIndependent === false)
979
+ .map(([id]) => `${sanitizeTaskText(id)} judge not independent`);
980
+ };
971
981
  const renderFrame = (cwd,
972
982
  // No default: the version is the binary's, resolved once by its caller, and a frame that had to
973
983
  // invent one would be naming a version nobody can check.
@@ -976,6 +986,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
976
986
  const record = readRunRecord(cwd, g, namedRunId);
977
987
  const runId = record?.runId;
978
988
  const events = record?.events ?? [];
989
+ const judgeNotes = judgeIndependenceNotes(events);
979
990
  const runLine = runId && record ? runStatusLine(cwd, runId, events) : null;
980
991
  // The header and every live-phase consumer share this exact process reading. Proved death retires
981
992
  // stale phase markers; unknown identity deliberately does not, preserving pre-identity journals.
@@ -1171,7 +1182,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1171
1182
  // At its machine width and wider the plain print keeps each machine row whole — one row per task and
1172
1183
  // its evidence line after it, which consumers parse. A configured width narrower than that holds for
1173
1184
  // EVERY row: one that overflows wraps beneath its own indent, its identity kept on the first.
1174
- const body = [header, legendLine, ` ${supervisionText(supervision, divider)}`, ...rows];
1185
+ const body = [header, legendLine, ` ${supervisionText(supervision, divider)}`, ...rows, ...judgeNotes.map((note) => ` ${note}`)];
1175
1186
  const wrapRow = (line) => wrapCells(line, width, { continuationPrefix: `${/^ */u.exec(line)[0]} ` });
1176
1187
  return {
1177
1188
  content: [
@@ -1383,6 +1394,7 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1383
1394
  "",
1384
1395
  ...tableRows(),
1385
1396
  ...recoverySection,
1397
+ ...judgeNotes.flatMap((note) => wrapCells(` ${note}`, boardColumns, { continuationPrefix: " " })),
1386
1398
  gatesLegend,
1387
1399
  "",
1388
1400
  rule(boardColumns),
@@ -1436,7 +1448,7 @@ const oneLine = (cwd, namedRunId) => {
1436
1448
  // A fatal run-end leads right after the run id: no completion fraction or verify state reads first.
1437
1449
  const crash = fatalRunEndCause(events);
1438
1450
  return sanitizeTaskText([
1439
- record.runId, ...(crash ? [`run crashed — ${crash}`] : []), ...claims, firstPassText(endToEndFirstPass(events)), wallText(events), needsYouText(events, currentOwed(events, cwd)),
1451
+ record.runId, ...(crash ? [`run crashed — ${crash}`] : []), ...claims, firstPassText(endToEndFirstPass(events)), wallText(events), needsYouText(events, currentOwed(events, cwd)), ...judgeIndependenceNotes(events),
1440
1452
  ].join(" · "));
1441
1453
  };
1442
1454
  export async function status(argv, cwd = process.cwd(), opts = {}) {
@@ -370,8 +370,8 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
370
370
  }
371
371
  // Baseline: --baseline file > cached capture for this merge-base > fresh capture on a detached
372
372
  // temp worktree of the merge-base (so pre-existing failures on base are forgiven, exactly as a run).
373
- // ALL verify state (cache, base worktree, artifacts) lives OUTSIDE the repo: verify gates the repo
374
- // root itself, so any file it wrote there would trip the battery's own dirty-worktree refusal.
373
+ // Queue row 31 / D-2134: gate-time state lives outside the repo; a validated discharge's durable
374
+ // proof is copied into its run only after the battery's final dirty-worktree check.
375
375
  // ponytail: tmpdir means the baseline cache dies on reboot/cleanup — worst case is one re-capture.
376
376
  const stateDir = verifyStateDir(cwd);
377
377
  mkdirSync(stateDir, { recursive: true });
@@ -600,6 +600,7 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
600
600
  console.error(`verify: evidence capture failed for ${artifactPath}`);
601
601
  }
602
602
  const review = reviewRows.at(-1);
603
+ let recordedArtifactPath = artifactPath;
603
604
  // C1: ONE discharge row names what the fold re-reads — the artifact by hash, the range mapping, the
604
605
  // criteria and each resolved channel — and it is appended only when the fold, the single decider,
605
606
  // already accepts it: verify never writes a proof that would turn known debt into unknown debt.
@@ -624,8 +625,31 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
624
625
  dischargeRefusal = "the results artifact is unavailable";
625
626
  else if (!candidate.known || !owed.every((o) => candidate.discharged.includes(o.id)))
626
627
  dischargeRefusal = candidate.unknown.at(-1)?.reason ?? "the proof does not discharge the check";
627
- else
628
- recordJournal.append(OWED_DISCHARGE_EVENT, values.task, data);
628
+ else {
629
+ // Queue row 31 / D-2134: validate before writing under the run, then retain the exact bytes.
630
+ // Exclusive creation accepts an identical existing proof, never overwrites different bytes.
631
+ const relativePath = join("discharges", `${artifactSha256}.json`);
632
+ const durablePath = join(recordJournal.dir, relativePath);
633
+ try {
634
+ mkdirSync(dirname(durablePath), { recursive: true });
635
+ try {
636
+ writeFileSync(durablePath, artifactBytes, { flag: "wx" });
637
+ }
638
+ catch (error) {
639
+ if (error.code !== "EEXIST")
640
+ throw error;
641
+ if (!readFileSync(durablePath).equals(artifactBytes))
642
+ throw new Error("existing durable proof has different bytes");
643
+ }
644
+ }
645
+ catch {
646
+ dischargeRefusal = "durable results artifact unavailable or has different bytes";
647
+ }
648
+ if (!dischargeRefusal) {
649
+ recordedArtifactPath = relativePath;
650
+ recordJournal.append(OWED_DISCHARGE_EVENT, values.task, { ...data, artifactPath: recordedArtifactPath });
651
+ }
652
+ }
629
653
  }
630
654
  const owedAfter = recordJournal ? foldOwedChecks(recordJournal.read(), cwd) : undefined;
631
655
  const code = green && !dischargeRefusal ? 0 : 2;
@@ -633,7 +657,7 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
633
657
  ? [...(dischargeRefusal ? [`verify --record refused discharge: ${dischargeRefusal}`] : []), debtLine(owedAfter)] : [];
634
658
  if (recordJournal && review) {
635
659
  recordJournal.append("review-leg2", values.task ?? "VERIFY", {
636
- base: baseTip, head, mergeBase, author: channelKey(author), artifactPath, artifactSha256, artifactAvailability,
660
+ base: baseTip, head, mergeBase, author: channelKey(author), artifactPath: recordedArtifactPath, artifactSha256, artifactAvailability,
637
661
  ...review,
638
662
  });
639
663
  }
@@ -55,7 +55,9 @@ export interface SourceScopeFinding {
55
55
  paths: string[];
56
56
  }
57
57
  /** Structured OBS-76 findings shared by human rendering and native compile enforcement. */
58
- export declare function sourceScopeFindings(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "acceptance">>, repoRoot: string): SourceScopeFinding[];
58
+ export declare function sourceScopeFindings(tasks: ReadonlyArray<Pick<Task, "id" | "files" | "acceptance">>, repoRoot: string, options?: {
59
+ failClosed?: boolean;
60
+ }): SourceScopeFinding[];
59
61
  /** Human display is derived from the structured finding; no enforcement path parses this text. */
60
62
  export declare function renderSourceScopeFinding(finding: SourceScopeFinding): string;
61
63
  /**
@@ -3,18 +3,15 @@ import { extname, join, posix, relative } from "node:path";
3
3
  import { filesGlob } from "../graph/files-glob.js";
4
4
  import { criticalPathHits, DEFAULT_CONFIG, DEFAULT_REVIEW_CRITICAL_PATHS, effectiveReviewPolicy, loadConfig, } from "../config/config.js";
5
5
  import { renderAcceptanceItem } from "../graph/schema.js";
6
+ import { CompileError } from "./common.js";
6
7
  // Advisory scan (OBS-12/13/14/21, OBS-76). NEVER expands files[] or fails compile — a warning the
7
8
  // author acts on. Plain-text (no AST), capped + sorted for DISPLAY only.
8
9
  // OBS-547: the collateral prediction is no longer plan-time-only. The daemon computes one full
9
10
  // (uncapped) map per run and hands each task's slice to its scope gate, which classifies a red
10
11
  // against it — the prediction never causes a refusal, it only says whether a red the gate already
11
12
  // found was foreseeable (authoring defect) or not (chargeable quality failure).
12
- /** Max files walked per root (sorted walk; rest ignored). */
13
- const MAX_WALK_FILES = 400;
14
13
  /** Max collateral test paths listed per task. */
15
14
  const MAX_HITS_PER_TASK = 20;
16
- /** Skip giant fixtures / snapshots. */
17
- const MAX_READ_BYTES = 512 * 1024;
18
15
  const EXPORT_MANIFEST_TEST = "tests/repo/export-manifest.test.ts";
19
16
  const SRC_EXT = /\.(ts|tsx|js|jsx|mts|cts)$/;
20
17
  const CODE_EXT = /\.(ts|tsx|js|jsx|mts|cts)$/;
@@ -33,8 +30,6 @@ function walkCode(repoRoot, subdir) {
33
30
  const root = join(repoRoot, subdir);
34
31
  const out = [];
35
32
  const walk = (dir) => {
36
- if (out.length >= MAX_WALK_FILES)
37
- return;
38
33
  let entries;
39
34
  try {
40
35
  entries = readdirSync(dir, { withFileTypes: true });
@@ -45,8 +40,6 @@ function walkCode(repoRoot, subdir) {
45
40
  // deterministic order
46
41
  entries.sort((a, b) => a.name.localeCompare(b.name));
47
42
  for (const e of entries) {
48
- if (out.length >= MAX_WALK_FILES)
49
- return;
50
43
  if (e.name === "node_modules" || e.name === ".git" || e.name.startsWith("."))
51
44
  continue;
52
45
  const full = join(dir, e.name);
@@ -78,22 +71,21 @@ function mentions(content, needles) {
78
71
  return false;
79
72
  }
80
73
  // cache file bodies (one read per path)
81
- function makeReader(repoRoot) {
74
+ function makeReader(repoRoot, failClosed = false) {
82
75
  const body = new Map();
83
76
  return (rel) => {
84
77
  if (body.has(rel))
85
78
  return body.get(rel);
86
79
  try {
87
- const st = statSync(join(repoRoot, rel));
88
- if (st.size > MAX_READ_BYTES) {
89
- body.set(rel, null);
90
- return null;
91
- }
92
80
  const text = readFileSync(join(repoRoot, rel), "utf8");
93
81
  body.set(rel, text);
94
82
  return text;
95
83
  }
96
84
  catch {
85
+ if (failClosed) {
86
+ throw new CompileError(`${rel} is unreadable, so the source-scope scan is incomplete — restore read access `
87
+ + `or remove the path and recompile (queue row 27, D-2134).`);
88
+ }
97
89
  body.set(rel, null);
98
90
  return null;
99
91
  }
@@ -502,7 +494,7 @@ export function newDirectoryLints(tasks, repoRoot) {
502
494
  return lines;
503
495
  }
504
496
  /** Structured OBS-76 findings shared by human rendering and native compile enforcement. */
505
- export function sourceScopeFindings(tasks, repoRoot) {
497
+ export function sourceScopeFindings(tasks, repoRoot, options = {}) {
506
498
  const perTask = tasks
507
499
  .map((t) => ({ t, needles: criteriaSymbols(t.acceptance) }))
508
500
  .filter((x) => x.needles.length);
@@ -511,7 +503,7 @@ export function sourceScopeFindings(tasks, repoRoot) {
511
503
  const srcFiles = walkCode(repoRoot, "src");
512
504
  if (!srcFiles.length)
513
505
  return [];
514
- const read = makeReader(repoRoot);
506
+ const read = makeReader(repoRoot, options.failClosed);
515
507
  const findings = [];
516
508
  for (const { t, needles } of perTask) {
517
509
  // OBS-22: scopeGate accepts picomatch globs; advisory warnings must agree.
@@ -272,6 +272,11 @@ function testsAtHead(root) {
272
272
  if (cached)
273
273
  return cached;
274
274
  const grep = gitSync(root, ["grep", "-I", "-n", "-e", "", "HEAD", "--", "tests/*.test.ts", "tests/**/*.test.ts"]);
275
+ if (grep.error || grep.signal || grep.status === null) {
276
+ const reason = grep.error?.message ?? (grep.signal ? `signal ${grep.signal}` : "no exit status");
277
+ throw new CompileError(`cannot complete the test corpus read (git grep HEAD tests) in ${root}: ${reason} — `
278
+ + `restore the corpus read and recompile (queue row 27, D-2134).`);
279
+ }
275
280
  if (grep.status !== 0 || typeof grep.stdout !== "string") {
276
281
  headTestCache.set(root, []);
277
282
  return [];
@@ -387,8 +392,8 @@ function criterionScopeFinding(task, text, criterion, id, tests) {
387
392
  // OBS-898: the fence lint's corpus is listed ONCE per compile by git (tracked plus untracked-not-ignored,
388
393
  // the same set a checkout walk sees minus the ignored trees) and filtered per task. The walk it replaces
389
394
  // descended every dot-directory (.tickmarkr/runs alone held 3 799 files here) once PER TASK, so a
390
- // 20-task spec compiled in seconds and the committed-spec corpus test timed out. Fail-open like
391
- // testsAtHead: no git answer means no corpus, and the lint stays quiet rather than wrong.
395
+ // 20-task spec compiled in seconds and the committed-spec corpus test timed out.
396
+ // Queue row 27 (D-2134): this advisory listing keeps its empty answer on git failure.
392
397
  function repoFileList(root) {
393
398
  const ls = gitSync(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
394
399
  if (ls.status !== 0 || typeof ls.stdout !== "string")
@@ -21,6 +21,14 @@ export interface JudgeVerdict {
21
21
  }>;
22
22
  }
23
23
  export declare function judgeCriterionId(index: number): string;
24
+ export interface OracleFailure {
25
+ index: number;
26
+ kind: "command" | "test";
27
+ criterion: string;
28
+ landing?: string;
29
+ failure: "absent" | "elsewhere" | "untitled" | "red" | "unverified" | "no-runner";
30
+ output: string;
31
+ }
24
32
  export declare function testFilterPattern(name: string): string;
25
33
  export declare function testFiltered(testCmd: string, name: string): string;
26
34
  export interface VitestListedTest {
@@ -249,18 +249,18 @@ function oracleExecutionFailure(label, code, stdout, stderr) {
249
249
  const zeroRun = testsRan(output) === 0 && classification !== "regression";
250
250
  const zeroRunEvidence = zeroRun ? "the runner reported zero tests run and no failure identity; " : "";
251
251
  if (classification === "infra" || signalKilled || zeroRun) {
252
- return {
252
+ return { failure: "unverified", output, result: {
253
+ gate: "acceptance",
254
+ pass: false,
255
+ details: `oracle failed: ${label} (exit ${code}) — ${signalEvidence || zeroRunEvidence}infrastructure blocked execution before a verdict; this oracle verified nothing${tail(output)}`,
256
+ meta: { cause: "oracle-execution", classification: "infra", infra: true, retryable: false },
257
+ } };
258
+ }
259
+ return { failure: "red", output, result: {
253
260
  gate: "acceptance",
254
261
  pass: false,
255
- details: `oracle failed: ${label} (exit ${code}) — ${signalEvidence || zeroRunEvidence}infrastructure blocked execution before a verdict; this oracle verified nothing${tail(output)}`,
256
- meta: { cause: "oracle-execution", classification: "infra", infra: true, retryable: false },
257
- };
258
- }
259
- return {
260
- gate: "acceptance",
261
- pass: false,
262
- details: `oracle failed: ${label} (exit ${code})${tail(stderr || stdout)}`,
263
- };
262
+ details: `oracle failed: ${label} (exit ${code})${tail(stderr || stdout)}`,
263
+ } };
264
264
  }
265
265
  // today's rule for a test oracle's run: exit 0 AND a positive printed run count (OBS-55: a zero-match run is vacuous).
266
266
  function countedTestRun(label, filter, r) {
@@ -269,8 +269,8 @@ function countedTestRun(label, filter, r) {
269
269
  const out = (r.stderr || "") + "\n" + (r.stdout || "");
270
270
  const ran = testsRan(out);
271
271
  if (ran === null || ran < 1) {
272
- return { gate: "acceptance", pass: false,
273
- details: `oracle failed: ${label} — name filter matched zero tests (filter: ${filter})${tail(out)}` };
272
+ return { failure: "untitled", output: out, result: { gate: "acceptance", pass: false,
273
+ details: `oracle failed: ${label} — name filter matched zero tests (filter: ${filter})${tail(out)}` } };
274
274
  }
275
275
  return undefined;
276
276
  }
@@ -290,11 +290,11 @@ function countedTestRun(label, filter, r) {
290
290
  */
291
291
  async function landedTestOracle(testCmd, title, landing, worktree) {
292
292
  const label = `test "${title}" in ${landing}`;
293
- const passed = { gate: "acceptance", pass: true, details: `✓ test: ${title} (exit 0) in ${landing}` };
294
- const work = (why) => ({ gate: "acceptance", pass: false, details: `oracle failed: ${label} — ${why}` });
295
- const unverified = (why) => ({ gate: "acceptance", pass: false,
296
- details: `oracle failed: ${label} — ${why}; unverified, failing closed`,
297
- meta: { cause: "oracle-execution", classification: "infra", infra: true, retryable: false } });
293
+ const passed = { output: "", result: { gate: "acceptance", pass: true, details: `✓ test: ${title} (exit 0) in ${landing}` } };
294
+ const work = (failure, why, output = "") => ({ failure, output, result: { gate: "acceptance", pass: false, details: `oracle failed: ${label} — ${why}` } });
295
+ const unverified = (why, output = "") => ({ failure: "unverified", output, result: { gate: "acceptance", pass: false,
296
+ details: `oracle failed: ${label} — ${why}; unverified, failing closed`,
297
+ meta: { cause: "oracle-execution", classification: "infra", infra: true, retryable: false } } });
298
298
  const path = resolve(worktree, landing);
299
299
  const within = relative(worktree, path);
300
300
  let regular = false;
@@ -303,7 +303,7 @@ async function landedTestOracle(testCmd, title, landing, worktree) {
303
303
  }
304
304
  catch { /* absent */ }
305
305
  if (!regular)
306
- return work(`suite file ${landing} is absent from the worktree or is not a regular file; the test command never started`);
306
+ return work("absent", `suite file ${landing} is absent from the worktree or is not a regular file; the test command never started`);
307
307
  if (!isVitestTestCommand(testCmd, worktree)) {
308
308
  return countedTestRun(label, title, await sh(fileFiltered(npmForwarded(testCmd), landing, title), worktree)) ?? passed;
309
309
  }
@@ -326,17 +326,18 @@ async function landedTestOracle(testCmd, title, landing, worktree) {
326
326
  const r = await shell(`${cmd} --reporter=${shq(reporterPath)} --outputFile=${shq(reportPath)}`, worktree, DEFAULT_SHELL_TIMEOUT_MS, false, { env: { ...manifestChildEnv(worktree), TICKMARKR_TEST_REPORT: reportPath, TICKMARKR_TEST_NONCE: nonce } });
327
327
  const raw = readTestReport(reportPath);
328
328
  const report = raw?.nonce === nonce ? raw : undefined;
329
+ const output = [r.stderr, r.stdout].filter(Boolean).join("\n");
329
330
  if (r.code !== 0) {
330
331
  // the reporter replaces the runner's own output: its failure identities are the evidence of a red.
331
332
  const named = report ? [...Object.values(report.completed).flatMap((c) => c.failures ?? []), ...(report.certificate?.diagnostics ?? [])] : [];
332
333
  return oracleExecutionFailure(label, r.code, r.stdout, [r.stderr, ...named].filter(Boolean).join("\n"));
333
334
  }
334
335
  if (!raw)
335
- return unverified("the runner exited 0 but wrote no per-file report");
336
+ return unverified("the runner exited 0 but wrote no per-file report", output);
336
337
  if (!report)
337
- return unverified(`the per-file report carries nonce "${raw.nonce}", not this invocation's`);
338
+ return unverified(`the per-file report carries nonce "${raw.nonce}", not this invocation's`, output);
338
339
  if (report.certificate?.exitCode !== 0)
339
- return unverified("the per-file report never reached its run-end certificate with exit 0");
340
+ return unverified("the per-file report never reached its run-end certificate with exit 0", output);
340
341
  const key = toManifestPath(within, worktree);
341
342
  // a module completed more than once (one path in several projects) keeps only its LAST counters, so its record
342
343
  // cannot show that no earlier completion ran a body: it counts as run.
@@ -346,9 +347,9 @@ async function landedTestOracle(testCmd, title, landing, worktree) {
346
347
  return file !== key && (repeated.has(file) || !t || t.passed + t.failed > 0);
347
348
  }).sort();
348
349
  if (elsewhere.length)
349
- return work(`the runner's per-file report shows a test body run outside ${landing}, in ${elsewhere.join(", ")} — only ${landing} can satisfy this criterion`);
350
+ return work("elsewhere", `the runner's per-file report shows a test body run outside ${landing}, in ${elsewhere.join(", ")} — only ${landing} can satisfy this criterion`, output);
350
351
  if (!((report.completed[key]?.tests?.passed ?? 0) > 0))
351
- return work(`the runner's per-file report shows no passing test of that title in ${landing}`);
352
+ return work("untitled", `the runner's per-file report shows no passing test of that title in ${landing}`, output);
352
353
  return passed;
353
354
  }
354
355
  finally {
@@ -456,38 +457,54 @@ function citesChangedLocation(evidence, changed) {
456
457
  export async function acceptanceGate(task, worktree, baseRef,
457
458
  // OBS-1182: the judge seat's OWN effort (absent = CLI default) — never the worker's.
458
459
  judge, via, opts = {}) {
459
- // 1. deterministic oracles — exit code decides, fail-closed, zero LLM calls (spec §2, T2).
460
- // A failure returns here, before any runLlm() call: a judge can never override it.
460
+ // Queue row 234(b) (D-2134): measure every deterministic oracle in declaration order before judging.
461
461
  const passedDet = [];
462
- for (const a of task.acceptance) {
462
+ const failures = [];
463
+ const record = (index, a, run) => {
464
+ if (run.result.pass)
465
+ passedDet.push(run.result.details);
466
+ else
467
+ failures.push({ run, oracle: {
468
+ index, kind: a.oracle, criterion: isCommand(a) ? a.command : a.test,
469
+ ...(isTest(a) && a.landing !== undefined ? { landing: a.landing } : {}),
470
+ failure: run.failure, output: run.output,
471
+ } });
472
+ };
473
+ for (const [index, a] of task.acceptance.entries()) {
463
474
  if (isCommand(a)) {
464
475
  const r = await sh(a.command, worktree);
465
476
  if (r.code !== 0) {
466
- return oracleExecutionFailure(`$ ${a.command}`, r.code, r.stdout, r.stderr);
477
+ record(index, a, oracleExecutionFailure(`$ ${a.command}`, r.code, r.stdout, r.stderr));
467
478
  }
468
- passedDet.push(`✓ $ ${a.command} (exit 0)`);
479
+ else
480
+ passedDet.push(`✓ $ ${a.command} (exit 0)`);
469
481
  }
470
482
  else if (isTest(a)) {
471
483
  if (!opts.testCmd) {
472
- return { gate: "acceptance", pass: false,
473
- details: `oracle failed: test "${a.test}" — no test command configured to run it (failing closed)` };
484
+ record(index, a, { failure: "no-runner", output: "", result: { gate: "acceptance", pass: false,
485
+ details: `oracle failed: test "${a.test}" — no test command configured to run it (failing closed)` } });
486
+ continue;
474
487
  }
475
488
  // queue row 179: a declared suite file is the only file that can satisfy its criterion.
476
489
  if (a.landing !== undefined) {
477
- const landed = await landedTestOracle(opts.testCmd, a.test, a.landing, worktree);
478
- if (!landed.pass)
479
- return landed;
480
- passedDet.push(landed.details);
490
+ record(index, a, await landedTestOracle(opts.testCmd, a.test, a.landing, worktree));
481
491
  continue;
482
492
  }
483
493
  // OBS-55: exit 0 alone is vacuous when the name filter matched zero tests — fail closed.
484
494
  const red = countedTestRun(`test "${a.test}"`, a.test, await sh(testFiltered(opts.testCmd, a.test), worktree));
485
495
  if (red)
486
- return red;
487
- passedDet.push(`✓ test: ${a.test} (exit 0)`);
496
+ record(index, a, red);
497
+ else
498
+ passedDet.push(`✓ test: ${a.test} (exit 0)`);
488
499
  }
489
500
  }
490
- // 2. judge items — only after every deterministic oracle passed (a failed one returned above).
501
+ if (failures.length) {
502
+ const first = failures[0].run.result;
503
+ const infra = failures.find(({ run }) => run.result.meta?.infra === true)?.run.result.meta;
504
+ return { ...first, details: failures.map(({ run }) => run.result.details).join("\n"),
505
+ meta: { ...first.meta, ...infra, oracles: failures.map(({ oracle }) => oracle) } };
506
+ }
507
+ // Queue row 234(b) (D-2134): only an entirely green deterministic round reaches the judge.
491
508
  const judgeItems = task.acceptance.filter(isJudge);
492
509
  const detBlock = passedDet.length ? passedDet.join("\n") + "\n" : "";
493
510
  if (!judgeItems.length) {