tickmarkr 2.6.2 → 2.6.4

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 (79) hide show
  1. package/README.md +12 -2
  2. package/dist/adapters/codex.d.ts +2 -1
  3. package/dist/adapters/codex.js +26 -5
  4. package/dist/adapters/model-lints.d.ts +1 -0
  5. package/dist/adapters/model-lints.js +48 -1
  6. package/dist/adapters/model-windows.d.ts +2 -1
  7. package/dist/adapters/model-windows.js +18 -7
  8. package/dist/adapters/types.d.ts +6 -0
  9. package/dist/cli/commands/approve.d.ts +6 -3
  10. package/dist/cli/commands/approve.js +43 -6
  11. package/dist/cli/commands/doctor.d.ts +8 -2
  12. package/dist/cli/commands/doctor.js +6 -1
  13. package/dist/cli/commands/fleet.js +264 -59
  14. package/dist/cli/commands/init.js +5 -3
  15. package/dist/cli/commands/plan.js +13 -8
  16. package/dist/cli/commands/report.d.ts +40 -2
  17. package/dist/cli/commands/report.js +278 -11
  18. package/dist/cli/commands/resume.js +2 -4
  19. package/dist/cli/commands/run.d.ts +11 -0
  20. package/dist/cli/commands/run.js +33 -5
  21. package/dist/cli/commands/status.js +49 -4
  22. package/dist/cli/commands/verify.js +332 -121
  23. package/dist/compile/native.js +137 -0
  24. package/dist/config/config.d.ts +40 -9
  25. package/dist/config/config.js +133 -15
  26. package/dist/config/fleet-overlay.d.ts +13 -3
  27. package/dist/config/fleet-overlay.js +12 -8
  28. package/dist/drivers/herdr.d.ts +12 -0
  29. package/dist/drivers/herdr.js +51 -0
  30. package/dist/drivers/orca.d.ts +9 -1
  31. package/dist/drivers/orca.js +29 -7
  32. package/dist/drivers/types.d.ts +2 -0
  33. package/dist/drivers/types.js +2 -2
  34. package/dist/gates/acceptance.d.ts +7 -0
  35. package/dist/gates/acceptance.js +27 -5
  36. package/dist/gates/baseline.d.ts +20 -1
  37. package/dist/gates/baseline.js +100 -20
  38. package/dist/gates/cache.d.ts +8 -0
  39. package/dist/gates/cache.js +12 -2
  40. package/dist/gates/llm.d.ts +6 -0
  41. package/dist/gates/llm.js +27 -8
  42. package/dist/gates/review.d.ts +6 -1
  43. package/dist/gates/review.js +122 -32
  44. package/dist/gates/run-gates.d.ts +54 -3
  45. package/dist/gates/run-gates.js +331 -45
  46. package/dist/gates/test-manifest.d.ts +42 -0
  47. package/dist/gates/test-manifest.js +69 -10
  48. package/dist/route/router.d.ts +23 -1
  49. package/dist/route/router.js +54 -16
  50. package/dist/run/consult.d.ts +3 -1
  51. package/dist/run/consult.js +4 -2
  52. package/dist/run/daemon.d.ts +2 -1
  53. package/dist/run/daemon.js +349 -79
  54. package/dist/run/interactive-seed.d.ts +4 -0
  55. package/dist/run/interactive-seed.js +35 -9
  56. package/dist/run/journal.d.ts +123 -1
  57. package/dist/run/journal.js +480 -17
  58. package/dist/run/lease.d.ts +13 -0
  59. package/dist/run/lease.js +45 -0
  60. package/dist/run/protocol.d.ts +15 -0
  61. package/dist/run/protocol.js +11 -1
  62. package/dist/run/receipt-resolver.d.ts +22 -0
  63. package/dist/run/receipt-resolver.js +40 -1
  64. package/dist/run/repair-selection.d.ts +11 -1
  65. package/dist/run/repair-selection.js +17 -9
  66. package/dist/run/wall-budget.d.ts +48 -0
  67. package/dist/run/wall-budget.js +280 -0
  68. package/dist/tui/cockpit/live-store.d.ts +6 -0
  69. package/dist/tui/cockpit/live-store.js +36 -11
  70. package/dist/tui/cockpit/run-cockpit.js +2 -2
  71. package/dist/tui/cockpit/run-view.d.ts +2 -1
  72. package/dist/tui/cockpit/run-view.js +13 -9
  73. package/dist/tui/cockpit/setup-cockpit.d.ts +2 -0
  74. package/dist/tui/cockpit/setup-cockpit.js +4 -0
  75. package/package.json +2 -1
  76. package/schema/config.schema.json +8 -1
  77. package/skills/tickmarkr-auto/SKILL.md +2 -2
  78. package/skills/tickmarkr-loop/SKILL.md +17 -5
  79. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +4 -1
@@ -7,6 +7,7 @@ import { consumePaneLaunchIntent, PANE_IDENTITY_ENV, paneIdentityLine } from "..
7
7
  import { createWorktree, sh } from "../run/git.js";
8
8
  import { Journal } from "../run/journal.js";
9
9
  import { readSupervision, readWatchBoard, requestWatchBoardStop, reserveWatchBoard, stopWatchBoard, WATCH_OWNER_ENV } from "../run/supervision.js";
10
+ import { canonicalWorktreePath } from "./orca.js";
10
11
  import { herdrSealShellPrefix } from "./subprocess.js";
11
12
  import { canonicalizeLegacyName, formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
12
13
  // VIS-09 P43-03: adopted safety floor from 43-MEASUREMENT.md (narrowest safe 53 → floor 108).
@@ -153,6 +154,8 @@ export class HerdrDriver {
153
154
  deliverySerial = Promise.resolve();
154
155
  dispatchLeases = new WeakMap();
155
156
  deliveredPanes = new WeakMap();
157
+ /** OBS-1203: slots bound by adopt() — their reads are harvest evidence, so a failed read throws. */
158
+ adoptedSlots = new WeakSet();
156
159
  inputBoxes = new WeakMap();
157
160
  // OBS-253: the adapter-declared bootstrap this slot has already delivered. A fresh pane is a bare
158
161
  // shell, so it is the only thing that can put a TUI back under a typed turn that has to move.
@@ -452,6 +455,49 @@ export class HerdrDriver {
452
455
  // refcount, no groupSerial, no degrade latch — dedicated tabs have no shared state to guard (SUP-01).
453
456
  return slot; // label undefined → defaults to name (today's behavior)
454
457
  }
458
+ /**
459
+ * OBS-1203: bind THIS instance, READ-ONLY, to the pane an earlier daemon created for an owned slot,
460
+ * so a resumed daemon can read the interrupted attempt's trailer. slot() is never a substitute: it
461
+ * allocates a tab, reclaimStaleLabel closes the very pane holding the evidence, and it holds a
462
+ * dispatch lease only run() releases. Binding needs ownership evidence, never a label alone: exactly
463
+ * one pane carries the FULL owned label, herdr reports that pane's cwd as the journaled task
464
+ * checkout, and that path is a git checkout whose own top level it is. Zero, ambiguous or foreign
465
+ * candidates throw — the caller's decline. `pane list` and `git rev-parse` are the only calls made.
466
+ */
467
+ async adopt(slot) {
468
+ if (!parseOwnedName(slot.name))
469
+ throw new Error(`herdr adopt: slot ${slot.name} carries no owned label — nothing to adopt`);
470
+ const listed = await this.herdr("pane list");
471
+ let panes;
472
+ try {
473
+ const parsed = JSON.parse(listed.stdout).result?.panes;
474
+ if (listed.code !== 0 || !Array.isArray(parsed))
475
+ throw new Error("no panes array");
476
+ panes = parsed;
477
+ }
478
+ catch {
479
+ throw new Error(`herdr adopt: pane list unreadable (exit ${listed.code}) — cannot prove ownership of ${slot.name}`);
480
+ }
481
+ const owned = panes.filter((p) => p.label === slot.name && typeof p.pane_id === "string" && p.pane_id);
482
+ if (owned.length !== 1) {
483
+ throw new Error(`herdr adopt: ${owned.length === 0 ? "no pane carries" : `${owned.length} panes carry`} the owned label ${slot.name}`);
484
+ }
485
+ const pane = owned[0];
486
+ const checkout = canonicalWorktreePath(slot.cwd);
487
+ if (typeof pane.cwd !== "string" || canonicalWorktreePath(pane.cwd) !== checkout) {
488
+ throw new Error(`herdr adopt: pane ${pane.pane_id} labelled ${slot.name} sits in ${pane.cwd ?? "an unreported cwd"}, not the task checkout ${checkout} — foreign`);
489
+ }
490
+ const top = await sh("git rev-parse --show-toplevel", checkout);
491
+ if (top.code !== 0 || canonicalWorktreePath(top.stdout.trim()) !== checkout) {
492
+ throw new Error(`herdr adopt: ${checkout} is not its own git checkout — no checkout proof for ${slot.name}`);
493
+ }
494
+ const adopted = { id: pane.pane_id, name: slot.name, cwd: slot.cwd,
495
+ ...(pane.tab_id === undefined ? {} : { tabId: pane.tab_id }), ...(slot.group === undefined ? {} : { group: slot.group }) };
496
+ // Reads go to the pane ownership was proven on, never to a later re-resolution of the label.
497
+ this.deliveredPanes.set(adopted, pane.pane_id);
498
+ this.adoptedSlots.add(adopted);
499
+ return adopted;
500
+ }
455
501
  // today's per-slot tab path, plus the VIS-04 orphan reap
456
502
  // label defaults to the slot name; group tabs pass the STAGE name instead — a first-member label
457
503
  // outlives its member once keepPanes reaps it (run-20260709-104447: the codex pane sat in a tab
@@ -1099,6 +1145,11 @@ export class HerdrDriver {
1099
1145
  async read(slot, lines) {
1100
1146
  const pane = await this.paneId(slot);
1101
1147
  const r = await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${lines}`, slot.cwd);
1148
+ // OBS-1203 review: an adopted pane's read decides a resume harvest; a failed read proves neither a
1149
+ // trailer nor its absence, so it throws (the caller's decline) rather than read as an empty pane.
1150
+ // ponytail: other slots keep the historical empty-on-failure read; widen once seed/consult callers catch.
1151
+ if (r.code !== 0 && this.adoptedSlots.has(slot))
1152
+ throw new Error(`herdr pane read ${pane} failed (exit ${r.code}): ${r.stderr || r.stdout}`);
1102
1153
  return r.stdout;
1103
1154
  }
1104
1155
  // v1.22 T5 / OBS-19: raw keystroke into the pane TUI (trust-dialog auto-answer). Resolves the pane
@@ -274,7 +274,15 @@ export declare class OrcaDriver implements ExecutorDriver {
274
274
  */
275
275
  private screenBehindBlindStream;
276
276
  /** A rendered-frame liveness read. `--screen` and `--cursor` are mutually exclusive in Orca. */
277
- private readScreen;
277
+ private screenFrame;
278
+ /**
279
+ * OBS-1205: the public rendered-screen read a slot carries (Slot.readScreen). Kimi draws its trust
280
+ * modal with cursor addressing, so the stream holds the box rules and no `Trust` text while the
281
+ * painted frame shows `❯ Trust this folder`. Same ownership discipline as every read — bound
282
+ * runtime, addressed handle, running status — and a frame the runtime could not render is
283
+ * unreadable, never an empty screen a caller could read as "no modal".
284
+ */
285
+ readScreen(slot: Slot): Promise<string>;
278
286
  /** A single UNPAGED tail read — exactly what the caller asked for and nothing more. Markers split
279
287
  * across cursor pages are not reassembled here; that is waitOutput's job. */
280
288
  read(slot: Slot, lines: number): Promise<string>;
@@ -719,7 +719,8 @@ export class OrcaDriver {
719
719
  this.pendingProjects.delete(owned.taskId);
720
720
  }
721
721
  }
722
- return { id, name: title, cwd: worktree, group: opts?.group };
722
+ const slot = { id, name: title, cwd: worktree, group: opts?.group, readScreen: () => this.readScreen(slot) };
723
+ return slot;
723
724
  }
724
725
  /**
725
726
  * OBS-1109: bind THIS instance to a terminal an earlier daemon created for an OWNED slot, so a
@@ -1175,14 +1176,14 @@ export class OrcaDriver {
1175
1176
  throw new OrcaUnavailableError(family, `exited-shaped stream page: show answered by runtime ${show.runtimeId}, not the bound ${st.runtimeId}`, show.raw, "exited");
1176
1177
  }
1177
1178
  this.liveShowTerm(family, st, show);
1178
- const screen = await this.readScreen(st);
1179
+ const screen = await this.screenFrame(st);
1179
1180
  if (screen.source !== "screen") {
1180
1181
  throw new OrcaUnavailableError(family, `exited-shaped stream page and no rendered screen for ${st.handle}`, blind.raw, "exited");
1181
1182
  }
1182
1183
  return screen.term;
1183
1184
  }
1184
1185
  /** A rendered-frame liveness read. `--screen` and `--cursor` are mutually exclusive in Orca. */
1185
- async readScreen(st) {
1186
+ async screenFrame(st) {
1186
1187
  const env = await this.terminalOp("status", st, (h) => this.call("read", [
1187
1188
  "terminal", "read", "--terminal", h, "--screen",
1188
1189
  ], this.cliCwd(st)));
@@ -1193,6 +1194,20 @@ export class OrcaDriver {
1193
1194
  }
1194
1195
  return { term, source };
1195
1196
  }
1197
+ /**
1198
+ * OBS-1205: the public rendered-screen read a slot carries (Slot.readScreen). Kimi draws its trust
1199
+ * modal with cursor addressing, so the stream holds the box rules and no `Trust` text while the
1200
+ * painted frame shows `❯ Trust this folder`. Same ownership discipline as every read — bound
1201
+ * runtime, addressed handle, running status — and a frame the runtime could not render is
1202
+ * unreadable, never an empty screen a caller could read as "no modal".
1203
+ */
1204
+ async readScreen(slot) {
1205
+ const st = this.state(slot);
1206
+ const { term, source } = await this.screenFrame(st);
1207
+ if (source !== "screen")
1208
+ throw new OrcaUnavailableError("read", `no rendered screen for ${st.handle}`, "");
1209
+ return this.tailText("read", term, "");
1210
+ }
1196
1211
  /** A single UNPAGED tail read — exactly what the caller asked for and nothing more. Markers split
1197
1212
  * across cursor pages are not reassembled here; that is waitOutput's job. */
1198
1213
  async read(slot, lines) {
@@ -1269,7 +1284,7 @@ export class OrcaDriver {
1269
1284
  // reporting even when the show record alone would still look connected (show carries no
1270
1285
  // status field of its own, so a terminal can report "unknown"/"exited" on read while its
1271
1286
  // show row still says connected — the read leg is the only place that catches that).
1272
- const screen = await this.readScreen(st);
1287
+ const screen = await this.screenFrame(st);
1273
1288
  if (`${st.runtimeId}:${st.handle}:${st.recoveries}` !== gen) {
1274
1289
  continue;
1275
1290
  }
@@ -1407,7 +1422,7 @@ export class OrcaDriver {
1407
1422
  async sendKey(slot, key) {
1408
1423
  const st = this.state(slot);
1409
1424
  this.assertAvailable("send", st);
1410
- if (key === "enter") {
1425
+ if (key === "enter" || key === "Enter") { // "Enter": every adapter's declared trust key (OBS-1205)
1411
1426
  await this.sendText(st, "");
1412
1427
  return;
1413
1428
  }
@@ -1440,7 +1455,7 @@ export class OrcaDriver {
1440
1455
  return true;
1441
1456
  const deadline = this.time.now() + NUDGE_ECHO_TIMEOUT_MS;
1442
1457
  for (;;) {
1443
- const screen = await this.readScreen(st);
1458
+ const screen = await this.screenFrame(st);
1444
1459
  // ponytail: "composer emptied" is "the text is no longer on the frame" — a TUI that keeps the
1445
1460
  // submitted turn on screen reads as undelivered until the timeout; the receipt stage is primary.
1446
1461
  if (screen.source === "screen") {
@@ -1853,7 +1868,7 @@ export class OrcaDriver {
1853
1868
  const st = this.slots.get(slot.id);
1854
1869
  if (!st)
1855
1870
  return;
1856
- if (st.handle) {
1871
+ if (st.handle && !st.swept) {
1857
1872
  await this.closeTerminal(st);
1858
1873
  }
1859
1874
  this.slots.delete(slot.id);
@@ -2022,6 +2037,13 @@ export class OrcaDriver {
2022
2037
  recoveries: 0,
2023
2038
  recovering: false,
2024
2039
  });
2040
+ // A kept slot whose attempt the journal fold already retired (a seed readiness deadline
2041
+ // parks one) can still hold this exact terminal; its own close() must not re-address a
2042
+ // handle this driver just closed, or the run-end sweep latches on the vanished tab.
2043
+ for (const st of this.slots.values()) {
2044
+ if (st.handle === c.paneId && st.runtimeId === env.runtimeId)
2045
+ st.swept = true;
2046
+ }
2025
2047
  }
2026
2048
  catch { /* vanished, stale, or no longer provably ours — never a blind retry */ }
2027
2049
  }
@@ -5,6 +5,7 @@ export interface Slot {
5
5
  cwd: string;
6
6
  tabId?: string;
7
7
  group?: string;
8
+ readScreen?: () => Promise<string>;
8
9
  }
9
10
  export type NotifyTier = "routine" | "attention" | "decision";
10
11
  export interface NotifyOpts {
@@ -92,6 +93,7 @@ export interface ExecutorDriver {
92
93
  focus?(target: FocusTarget): Promise<FocusResult>;
93
94
  describe?(slot: Slot): SlotPlacement | Promise<SlotPlacement> | undefined;
94
95
  slot(cwd: string, name: string, opts?: SlotOpts): Promise<Slot>;
96
+ adopt?(slot: Slot): Promise<Slot>;
95
97
  run(slot: Slot, cmd: string): Promise<void>;
96
98
  waitOutput(slot: Slot, pattern: string, timeoutMs: number, opts?: {
97
99
  regex?: boolean;
@@ -63,8 +63,8 @@ export function canonicalizeLegacyName(name, runId) {
63
63
  return { role: "worker", taskId: w[1], attempt: Number(w[2]), runId: `run-${w[3]}` };
64
64
  const g = GATE_ROLE_RE.exec(name);
65
65
  if (g) {
66
- const retry = g[2].endsWith("-r1");
67
- return { role: g[1], taskId: retry ? g[2].slice(0, -3) : g[2], attempt: retry ? 1 : 0, runId };
66
+ const hop = /-r([1-9])$/.exec(g[2]);
67
+ return { role: g[1], taskId: hop ? g[2].slice(0, -hop[0].length) : g[2], attempt: hop ? Number(hop[1]) : 0, runId };
68
68
  }
69
69
  if (NARRATOR_RE.test(name))
70
70
  return { role: "watch", taskId: "run", attempt: 0, runId };
@@ -55,6 +55,13 @@ export type AcceptanceCorpusAuditResult = {
55
55
  };
56
56
  };
57
57
  export declare function auditAcceptanceCorpus(corpusRoot: string, listedTests: readonly VitestListedTest[]): AcceptanceCorpusAuditResult[];
58
+ /** OBS-1151: one criterion of a parsed judge verdict, as the contradiction check compares it. */
59
+ export interface JudgedCriterion {
60
+ id: string;
61
+ criterion: string;
62
+ met: boolean;
63
+ paths: string[];
64
+ }
58
65
  export interface AcceptanceGateOpts {
59
66
  testCmd?: string;
60
67
  diffCap?: number;
@@ -7,7 +7,7 @@ import { DEFAULT_DIFF_CAP } from "../config/config.js";
7
7
  import { renderAcceptanceItem } from "../graph/schema.js";
8
8
  import { sh } from "../run/git.js";
9
9
  import { checkTaskDiffCaps, fetchTaskDiff, isProtectedEvidence, setAsideReceiptPath } from "./review.js";
10
- import { appendAnchoredReview, COMPLETION_FAKING_CHECKLIST, extractVerdictJson, generateVerdictNonce, runLlm, verdictNonceLine } from "./llm.js";
10
+ import { appendAnchoredReview, COMPLETION_FAKING_CHECKLIST, extractVerdictJson, generateVerdictNonce, runLlm, SeatLaunchError, verdictNonceLine } from "./llm.js";
11
11
  import { classifyVerdictCause } from "./verdict-cause.js";
12
12
  import { reviewableLogicDiff } from "./artifact-manifest.js";
13
13
  import { classifyFailureOutput } from "./baseline.js";
@@ -442,9 +442,19 @@ Each criteria[].evidence MUST be a structured citation {"path", "line"} whose "p
442
442
  The top-level comments array is optional. Use it only for actionable line-anchored feedback.
443
443
  `;
444
444
  // OBS-1182: an unset seat passes no effort argument at all — the CLI default, and the call shape callers pin.
445
- const raw = await (judge.effort
446
- ? runLlm(judge.adapter, judge.model, prompt, worktree, via, JUDGE_TIMEOUT_MS, judge.effort)
447
- : runLlm(judge.adapter, judge.model, prompt, worktree, via, JUDGE_TIMEOUT_MS));
445
+ let raw;
446
+ try {
447
+ raw = await (judge.effort
448
+ ? runLlm(judge.adapter, judge.model, prompt, worktree, via, JUDGE_TIMEOUT_MS, judge.effort)
449
+ : runLlm(judge.adapter, judge.model, prompt, worktree, via, JUDGE_TIMEOUT_MS));
450
+ }
451
+ catch (error) {
452
+ if (!(error instanceof SeatLaunchError))
453
+ throw error;
454
+ // OBS-1168(b): no seat, no verdict — run-gates re-routes the judge or parks infra; never a worker charge.
455
+ return { gate: "acceptance", pass: false, details: `${warn}${detBlock}judge dispatch failed — ${error.message} — failing closed`,
456
+ meta: { unparseable: true, cause: "seat-launch-failed", judge: channelKey({ adapter: judge.adapter.id, model: judge.model }) } };
457
+ }
448
458
  const extracted = extractVerdictJson(raw, nonce);
449
459
  if (!extracted) {
450
460
  const cause = classifyVerdictCause(raw, nonce, "pass");
@@ -476,5 +486,17 @@ The top-level comments array is optional. Use it only for actionable line-anchor
476
486
  lines.push("judge verdict pass=false");
477
487
  lines.push(...inconsistencies);
478
488
  const prose = warn + detBlock + (lines.join("\n") || "judge passed");
479
- return { gate: "acceptance", pass, details: appendAnchoredReview(prose, extracted) };
489
+ // OBS-1151: each criterion's canonical text, ruling and the paths its evidence cites. A legacy quote
490
+ // cites no path, so its criterion has no comparable subject (missing evidence is unknown). An
491
+ // inconsistent verdict (a duplicate row ruling both ways, a missing id) carries NO judgment: it is not
492
+ // a comparable subject, and as an adjudicator it can confirm nothing — run-gates parks it infra.
493
+ const judgment = inconsistencies.length ? undefined : v.criteria.flatMap((row) => {
494
+ const index = expectedIds.indexOf(row.criterion);
495
+ if (index < 0)
496
+ return [];
497
+ return [{ id: row.criterion, criterion: renderAcceptanceItem(judgeItems[index]), met: row.met,
498
+ paths: typeof row.evidence === "string" ? [] : [row.evidence.path] }];
499
+ });
500
+ return { gate: "acceptance", pass, details: appendAnchoredReview(prose, extracted),
501
+ meta: { judge: channelKey({ adapter: judge.adapter.id, model: judge.model }), ...(judgment ? { judgment } : {}) } };
480
502
  }
@@ -1,5 +1,5 @@
1
1
  import { type EvidenceArtifact, type GateEvidenceReceipt, type ShellReceipt } from "../run/protocol.js";
2
- import type { TickmarkrConfig } from "../config/config.js";
2
+ import { type TickmarkrConfig } from "../config/config.js";
3
3
  import type { AcceptanceItem } from "../graph/schema.js";
4
4
  import { type RunCapacity, type ShellOptions, type ShResult } from "../run/git.js";
5
5
  import type { GateResult } from "./types.js";
@@ -31,8 +31,21 @@ export interface GateEvidenceOptions {
31
31
  write?: (path: string, bytes: Buffer) => void;
32
32
  }
33
33
  export declare const EVIDENCE_TAIL_BYTES: number;
34
+ /** The default per-run quota; `gates.evidenceQuotaBytes` in config overrides it (OBS-1140). */
34
35
  export declare const EVIDENCE_RUN_QUOTA_BYTES: number;
36
+ export type RedactionCounts = NonNullable<GateEvidenceReceipt["redaction"]["counts"]>;
37
+ /**
38
+ * OBS-1139: every category scans the ORIGINAL text on its own; overlapping spans then merge into one
39
+ * span counted once under its highest-priority member — token, assignment, secret environment, benign
40
+ * HOME/TMPDIR — so precedence never depends on which match starts first. Counts only: never a value.
41
+ */
42
+ export declare function classifyGateOutput(text: string, env: NodeJS.ProcessEnv): {
43
+ text: string;
44
+ counts: RedactionCounts;
45
+ };
35
46
  export declare function redactGateOutput(text: string, env: NodeJS.ProcessEnv): string;
47
+ /** Material means bytes were withheld; a benign location substitution withholds nothing. */
48
+ export declare const redactionMaterial: (c: RedactionCounts) => boolean;
36
49
  /** Resolve a snapshot reference against actual bytes: eviction never fabricates a live artifact. */
37
50
  export declare function resolveEvidenceArtifact(root: string, ref: EvidenceArtifact): EvidenceArtifact;
38
51
  export declare function beginGateEvidence(cwd: string, gate: string, command: string, opts?: GateEvidenceOptions, nonce?: string): {
@@ -55,6 +68,12 @@ export declare function beginGateEvidence(cwd: string, gate: string, command: st
55
68
  availability: "available" | "not-started" | "capture-failed" | "killed" | "expired" | "missing";
56
69
  redaction: {
57
70
  material: boolean;
71
+ counts?: {
72
+ token: number;
73
+ assignment: number;
74
+ secretEnv: number;
75
+ benignEnv: number;
76
+ } | undefined;
58
77
  };
59
78
  stdout: {
60
79
  path: string;
@@ -1,25 +1,81 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { execFileSync } from "node:child_process";
3
- import { existsSync, readFileSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync, rmdirSync } from "node:fs";
3
+ import { EVICTION_TOMBSTONE_LIMIT, EVICTION_TOMBSTONES_FILE, parseEvictionTombstones } from "../run/receipt-resolver.js";
4
+ import { existsSync, readFileSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, rmdirSync } from "node:fs";
4
5
  import { availableParallelism, loadavg, tmpdir } from "node:os";
5
- import { join } from "node:path";
6
+ import { basename, join } from "node:path";
7
+ import { DEFAULT_EVIDENCE_QUOTA_BYTES } from "../config/config.js";
6
8
  import { dependencyLinkRefusal, DEFAULT_SHELL_TIMEOUT_MS, describeCapacity, sameCapacity, sh } from "../run/git.js";
7
9
  import { executionSignal } from "../run/execution-budget.js";
8
10
  import { isVitestTestCommand, manifestFileCount } from "./test-manifest.js";
9
11
  export const EVIDENCE_TAIL_BYTES = 16 * 1024;
10
- export const EVIDENCE_RUN_QUOTA_BYTES = 8 * 1024 * 1024;
11
- export function redactGateOutput(text, env) {
12
- // One pass over the original bytes: replacing an environment value must not break a token
13
- // recognizer, and a short environment value must not rewrite the redaction marker itself.
12
+ /** The default per-run quota; `gates.evidenceQuotaBytes` in config overrides it (OBS-1140). */
13
+ export const EVIDENCE_RUN_QUOTA_BYTES = DEFAULT_EVIDENCE_QUOTA_BYTES;
14
+ /** Benign environment locations substituted by name; every other redaction is material. */
15
+ const BENIGN_ENV_KEYS = ["HOME", "TMPDIR"];
16
+ const REDACTION_CATEGORIES = ["token", "assignment", "secretEnv", "benignEnv"];
17
+ /**
18
+ * OBS-1139: every category scans the ORIGINAL text on its own; overlapping spans then merge into one
19
+ * span counted once under its highest-priority member — token, assignment, secret environment, benign
20
+ * HOME/TMPDIR — so precedence never depends on which match starts first. Counts only: never a value.
21
+ */
22
+ export function classifyGateOutput(text, env) {
23
+ // HOME/TMPDIR substitute by name at any length; "/" alone would rewrite every path separator.
24
+ const benign = BENIGN_ENV_KEYS.flatMap(key => { const value = env[key]; return value && value.length > 1 ? [{ value, label: `$${key}` }] : []; });
14
25
  // Short operational values (0, true, vi, test) occur throughout ordinary diagnostics.
15
- // Only secret-named keys justify redacting short values.
16
- const values = [...new Set(Object.entries(env)
17
- .filter(([key, value]) => !!value && (value.length >= 8 || /KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH/i.test(key)))
18
- .map(([, value]) => value))]
19
- .sort((a, b) => b.length - a.length).map(v => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
26
+ // Only secret-named keys justify redacting short values. A secret-named value that happens to equal
27
+ // HOME/TMPDIR stays a secret candidate: precedence decides the span, never candidate pruning.
28
+ const secrets = [...new Set(Object.entries(env)
29
+ .filter(([key, value]) => !!value && !BENIGN_ENV_KEYS.includes(key)
30
+ && (value.length >= 8 || /KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH/i.test(key)))
31
+ .map(([, value]) => value))];
32
+ const literal = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
33
  const tokens = String.raw `\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|AKIA[A-Z0-9]{16}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b`;
21
34
  const assignments = String.raw `(?:authorization\s*:\s*bearer|(?:api[_-]?key|token|password|secret)\s*[:=])\s*[^\s"']+`;
22
- return text.replace(new RegExp([tokens, assignments, ...values].join("|"), "gi"), "[REDACTED]");
35
+ // Every value scans the original text on its own: an alternation would consume the first match and
36
+ // skip a longer overlapping value's tail, so precedence is resolved on the collected spans instead.
37
+ const scan = (source, rank, label) => [...text.matchAll(new RegExp(source, "gi"))].map(m => ({ start: m.index, end: m.index + m[0].length, rank, label }));
38
+ const spans = [
39
+ ...scan(tokens, 0), ...scan(assignments, 1),
40
+ ...secrets.flatMap(v => scan(literal(v), 2)),
41
+ ...benign.flatMap(b => scan(literal(b.value), 3, b.label)),
42
+ ].sort((a, b) => a.start - b.start || b.end - a.end);
43
+ const merged = [];
44
+ for (const span of spans) {
45
+ const last = merged.at(-1);
46
+ if (!last || span.start >= last.end) {
47
+ merged.push({ ...span });
48
+ continue;
49
+ }
50
+ // C-11 (D-673): a benign value nested in (or identical to) another is one location, named by the containing
51
+ // span, which sorts first. Only a PARTIAL overlap of two different benign values is ambiguous: withhold it.
52
+ if (last.rank === 3 && span.rank === 3 && last.label !== span.label && span.end > last.end)
53
+ last.rank = 2;
54
+ else
55
+ last.rank = Math.min(last.rank, span.rank);
56
+ last.end = Math.max(last.end, span.end);
57
+ }
58
+ const counts = { token: 0, assignment: 0, secretEnv: 0, benignEnv: 0 };
59
+ let out = "", at = 0;
60
+ for (const { start, end, rank, label } of merged) {
61
+ counts[REDACTION_CATEGORIES[rank]]++;
62
+ out += text.slice(at, start) + (rank === 3 ? label : "[REDACTED]");
63
+ at = end;
64
+ }
65
+ return { text: out + text.slice(at), counts };
66
+ }
67
+ export function redactGateOutput(text, env) {
68
+ return classifyGateOutput(text, env).text;
69
+ }
70
+ /** Material means bytes were withheld; a benign location substitution withholds nothing. */
71
+ export const redactionMaterial = (c) => c.token + c.assignment + c.secretEnv > 0;
72
+ function readEvictionTombstones(root) {
73
+ try {
74
+ return parseEvictionTombstones(readFileSync(join(root, EVICTION_TOMBSTONES_FILE)));
75
+ }
76
+ catch {
77
+ return [];
78
+ }
23
79
  }
24
80
  /** Resolve a snapshot reference against actual bytes: eviction never fabricates a live artifact. */
25
81
  export function resolveEvidenceArtifact(root, ref) {
@@ -75,7 +131,12 @@ export function beginGateEvidence(cwd, gate, command, opts = {}, nonce) {
75
131
  exitCode: observed?.exitCode ?? null, signal: observed?.signal ?? null,
76
132
  timedOut: observed ? observed.outcome === "timed-out" : null,
77
133
  };
78
- const clean = [redactGateOutput(stdout, env), redactGateOutput(stderr, env)];
134
+ const classified = [classifyGateOutput(stdout, env), classifyGateOutput(stderr, env)];
135
+ const clean = classified.map(c => c.text);
136
+ const counts = { token: 0, assignment: 0, secretEnv: 0, benignEnv: 0 };
137
+ for (const c of classified)
138
+ for (const k of Object.keys(counts))
139
+ counts[k] += c.counts[k];
79
140
  const refs = clean.map((text, i) => {
80
141
  const bytes = Buffer.from(text);
81
142
  const tail = bytes.subarray(-EVIDENCE_TAIL_BYTES);
@@ -94,19 +155,38 @@ export function beginGateEvidence(cwd, gate, command, opts = {}, nonce) {
94
155
  lock = join(dir, ".write-lock");
95
156
  const quota = opts.quotaBytes !== undefined && Number.isFinite(opts.quotaBytes)
96
157
  ? Math.max(0, Math.floor(opts.quotaBytes)) : EVIDENCE_RUN_QUOTA_BYTES;
158
+ // An empty artifact costs no quota, so it is never evicted: every eviction frees bytes.
97
159
  const files = readdirSync(dir).filter(f => /^[a-zA-Z0-9-]+-(stdout|stderr)\.log$/.test(f)).map(f => ({ path: join(dir, f), stat: statSync(join(dir, f)) }))
98
- .sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs || a.path.localeCompare(b.path));
160
+ .filter(f => f.stat.size > 0).sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs || a.path.localeCompare(b.path));
99
161
  let total = files.reduce((sum, f) => sum + f.stat.size, 0);
100
162
  const incoming = refs.reduce((sum, ref) => sum + ref.retainedBytes, 0);
101
- if (incoming > quota)
102
- throw new Error("evidence quota cannot retain this invocation");
103
- while (files.length && total + incoming > quota) {
163
+ // OBS-1140: the quota binds what is already retained too, so a resumed run at zero quota keeps
164
+ // zero bytes. An invocation larger than the whole quota is expired at birth; older artifacts
165
+ // are evicted only as far as the quota itself demands, never for bytes that could not fit.
166
+ const fits = incoming <= quota;
167
+ const evicted = [];
168
+ while (files.length && total + (fits ? incoming : 0) > quota) {
104
169
  const oldest = files.shift();
170
+ const bytes = readFileSync(oldest.path);
105
171
  unlinkSync(oldest.path);
106
172
  total -= oldest.stat.size;
173
+ evicted.push({ path: `gate-evidence/${basename(oldest.path)}`, sha256: createHash("sha256").update(bytes).digest("hex"), retainedBytes: bytes.length });
174
+ }
175
+ if (evicted.length) {
176
+ // Identity-bound (path + hash + length) and bounded: the newest 256 survive a restart.
177
+ const tombstones = join(root, EVICTION_TOMBSTONES_FILE);
178
+ writeFileSync(`${tombstones}.tmp`, JSON.stringify([...readEvictionTombstones(root), ...evicted].slice(-EVICTION_TOMBSTONE_LIMIT)));
179
+ renameSync(`${tombstones}.tmp`, tombstones);
107
180
  }
108
- for (const [i, ref] of refs.entries()) {
109
- (opts.write ?? writeFileSync)(join(root, ref.path), Buffer.from(clean[i]).subarray(-EVIDENCE_TAIL_BYTES));
181
+ if (!fits) {
182
+ availability = "expired";
183
+ for (const ref of refs)
184
+ ref.availability = "expired";
185
+ }
186
+ else {
187
+ for (const [i, ref] of refs.entries()) {
188
+ (opts.write ?? writeFileSync)(join(root, ref.path), Buffer.from(clean[i]).subarray(-EVIDENCE_TAIL_BYTES));
189
+ }
110
190
  }
111
191
  }
112
192
  catch {
@@ -131,7 +211,7 @@ export function beginGateEvidence(cwd, gate, command, opts = {}, nonce) {
131
211
  }
132
212
  return { invocationId, ...(nonce ? { nonce } : {}),
133
213
  subject: { runId: opts.runId ?? "standalone", taskId: opts.taskId ?? null, attempt: opts.attempt ?? null, gate, subjectCommit },
134
- termination, availability, redaction: { material: clean[0] !== stdout || clean[1] !== stderr }, stdout: refs[0], stderr: refs[1] };
214
+ termination, availability, redaction: { material: redactionMaterial(counts), counts }, stdout: refs[0], stderr: refs[1] };
135
215
  },
136
216
  };
137
217
  }
@@ -9,8 +9,15 @@ export declare function lockfileHash(worktree: string): string;
9
9
  export declare function canonicalJson(obj: unknown): string;
10
10
  export declare function baselineIdentity(baseline?: Baseline): string;
11
11
  export declare function getWorktreeTree(worktree: string): Promise<string>;
12
+ /** OBS-635: environment inputs a runner child reads that capacity and lifecycle do not already bind.
13
+ * ponytail: a named list, not the whole env (pids and terminal vars would defeat every reuse); extend
14
+ * it when another variable is shown to change what a runner executes. */
15
+ export declare const RUNNER_ENV_KEYS: readonly ["PATH", "NODE_OPTIONS", "NODE_PATH", "NODE_ENV", "TZ"];
16
+ export declare function runnerInputsHash(env?: NodeJS.ProcessEnv): string;
12
17
  export interface GateEnvironmentInput {
13
18
  nodeRuntime?: string;
19
+ /** the runner inputs hash; defaults to this process's (runnerInputsHash) */
20
+ runner?: string;
14
21
  lockfile?: string;
15
22
  worktree?: string;
16
23
  capacity?: RunCapacity;
@@ -21,6 +28,7 @@ export interface GateEnvironmentInput {
21
28
  }
22
29
  export interface EnvironmentParts {
23
30
  nodeRuntime: string;
31
+ runner: string;
24
32
  lockfile: string;
25
33
  capacity: RunCapacity;
26
34
  selectedSet?: readonly string[];
@@ -77,8 +77,17 @@ export async function getWorktreeTree(worktree) {
77
77
  rmSync(scratch, { recursive: true, force: true });
78
78
  }
79
79
  }
80
+ /** OBS-635: environment inputs a runner child reads that capacity and lifecycle do not already bind.
81
+ * ponytail: a named list, not the whole env (pids and terminal vars would defeat every reuse); extend
82
+ * it when another variable is shown to change what a runner executes. */
83
+ export const RUNNER_ENV_KEYS = ["PATH", "NODE_OPTIONS", "NODE_PATH", "NODE_ENV", "TZ"];
84
+ export function runnerInputsHash(env = process.env) {
85
+ return createHash("sha256").update(canonicalJson(Object.fromEntries(RUNNER_ENV_KEYS.map((k) => [k, env[k] ?? null]))))
86
+ .digest("hex").slice(0, 16);
87
+ }
80
88
  export function environmentFingerprint(env) {
81
89
  const nodeRuntime = env.nodeRuntime ?? process.version;
90
+ const runner = env.runner ?? runnerInputsHash();
82
91
  const lockfile = env.lockfile ?? (env.worktree ? lockfileHash(env.worktree) : "no-lockfile");
83
92
  const cap = env.capacity ?? resolvedCapacity();
84
93
  const capacity = { forkCap: cap.forkCap, cores: cap.cores };
@@ -97,6 +106,7 @@ export function environmentFingerprint(env) {
97
106
  // explicit `false` and an npmrc `false` are the same policy for the child that ran.
98
107
  const payload = canonicalJson({
99
108
  nodeRuntime,
109
+ runner,
100
110
  lockfile,
101
111
  capacity,
102
112
  resolution,
@@ -107,7 +117,7 @@ export function environmentFingerprint(env) {
107
117
  const fingerprint = createHash("sha256").update(payload).digest("hex").slice(0, 16);
108
118
  return {
109
119
  fingerprint,
110
- parts: { nodeRuntime, lockfile, capacity, selectedSet, verification },
120
+ parts: { nodeRuntime, runner, lockfile, capacity, selectedSet, verification },
111
121
  };
112
122
  }
113
123
  export async function computeVerificationIdentity(params) {
@@ -151,7 +161,7 @@ export function verificationIdentityKey(id) {
151
161
  export function formatReusedDetails(originalDetails, id) {
152
162
  const unadorned = originalDetails.replace(/^reused verdict \(identity: [^)]+\):\s*/, "");
153
163
  const envDesc = id.envParts
154
- ? ` [node=${id.envParts.nodeRuntime}, lockfile=${id.envParts.lockfile}, capacity=${describeCapacity(id.envParts.capacity)}${id.envParts.selectedSet ? `, selected=${id.envParts.selectedSet.join(",")}` : ""}, protocol=${id.envParts.verification.protocol}, lifecycle=${id.envParts.verification.lifecycle} (${id.envParts.verification.source})]`
164
+ ? ` [node=${id.envParts.nodeRuntime}, runner=${id.envParts.runner}, lockfile=${id.envParts.lockfile}, capacity=${describeCapacity(id.envParts.capacity)}${id.envParts.selectedSet ? `, selected=${id.envParts.selectedSet.join(",")}` : ""}, protocol=${id.envParts.verification.protocol}, lifecycle=${id.envParts.verification.lifecycle} (${id.envParts.verification.source})]`
155
165
  : "";
156
166
  const gate = id.gate ?? "gate";
157
167
  const prefix = `reused ${id.scope === "tip" ? "tip " : ""}verdict (identity: gate=${gate} tree=${id.tree}${id.worktree ? ` worktree=${id.worktree}` : ""} command=${id.command} baseline=${id.baseline} env=${id.environment}${envDesc})`;
@@ -69,6 +69,12 @@ export interface LlmRunResult {
69
69
  }
70
70
  export declare const PROMPT_GLYPHS: readonly ["➜", "❯", "$", "%", ">>", ">"];
71
71
  export declare function reviewSeatOutput(raw: string, nonce: string, adapterBannerRows?: readonly string[]): string;
72
+ /** OBS-1168(b): a judge/review seat whose pane could not be created or launched. Typed so the gates can
73
+ * contain it as seat recovery (another seat) or an infra park — never as a worker failure. */
74
+ export declare class SeatLaunchError extends Error {
75
+ readonly seat: string;
76
+ constructor(seat: string, cause: unknown);
77
+ }
72
78
  export declare const REVIEW_FIRST_LIVENESS_MS = 30000;
73
79
  export declare const REVIEW_SILENT_BYTE_FLOOR = 64;
74
80
  export declare function runHeadless(adapter: WorkerAdapter, model: string, prompt: string, cwd: string, timeoutMs?: number, effort?: Effort): Promise<string>;