tickmarkr 2.6.2 → 2.6.3

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 (63) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/commands/approve.d.ts +6 -3
  3. package/dist/cli/commands/approve.js +16 -4
  4. package/dist/cli/commands/doctor.d.ts +6 -2
  5. package/dist/cli/commands/fleet.js +46 -8
  6. package/dist/cli/commands/plan.js +13 -8
  7. package/dist/cli/commands/report.d.ts +2 -1
  8. package/dist/cli/commands/report.js +56 -6
  9. package/dist/cli/commands/status.js +19 -1
  10. package/dist/compile/native.js +7 -0
  11. package/dist/config/config.d.ts +17 -4
  12. package/dist/config/config.js +43 -6
  13. package/dist/config/fleet-overlay.d.ts +13 -3
  14. package/dist/config/fleet-overlay.js +12 -8
  15. package/dist/drivers/herdr.d.ts +12 -0
  16. package/dist/drivers/herdr.js +51 -0
  17. package/dist/drivers/orca.d.ts +9 -1
  18. package/dist/drivers/orca.js +29 -7
  19. package/dist/drivers/types.d.ts +2 -0
  20. package/dist/drivers/types.js +2 -2
  21. package/dist/gates/acceptance.d.ts +7 -0
  22. package/dist/gates/acceptance.js +27 -5
  23. package/dist/gates/baseline.d.ts +20 -1
  24. package/dist/gates/baseline.js +100 -20
  25. package/dist/gates/cache.d.ts +8 -0
  26. package/dist/gates/cache.js +12 -2
  27. package/dist/gates/llm.d.ts +6 -0
  28. package/dist/gates/llm.js +27 -8
  29. package/dist/gates/review.d.ts +6 -1
  30. package/dist/gates/review.js +122 -32
  31. package/dist/gates/run-gates.d.ts +54 -3
  32. package/dist/gates/run-gates.js +331 -45
  33. package/dist/gates/test-manifest.d.ts +42 -0
  34. package/dist/gates/test-manifest.js +69 -10
  35. package/dist/route/router.d.ts +12 -1
  36. package/dist/route/router.js +26 -9
  37. package/dist/run/consult.d.ts +3 -1
  38. package/dist/run/consult.js +4 -2
  39. package/dist/run/daemon.d.ts +2 -1
  40. package/dist/run/daemon.js +342 -77
  41. package/dist/run/interactive-seed.d.ts +4 -0
  42. package/dist/run/interactive-seed.js +35 -9
  43. package/dist/run/journal.d.ts +26 -0
  44. package/dist/run/journal.js +143 -15
  45. package/dist/run/lease.d.ts +13 -0
  46. package/dist/run/lease.js +45 -0
  47. package/dist/run/protocol.d.ts +15 -0
  48. package/dist/run/protocol.js +11 -1
  49. package/dist/run/receipt-resolver.d.ts +22 -0
  50. package/dist/run/receipt-resolver.js +40 -1
  51. package/dist/run/repair-selection.d.ts +11 -1
  52. package/dist/run/repair-selection.js +17 -9
  53. package/dist/run/wall-budget.d.ts +48 -0
  54. package/dist/run/wall-budget.js +280 -0
  55. package/dist/tui/cockpit/run-cockpit.js +2 -2
  56. package/dist/tui/cockpit/run-view.d.ts +2 -1
  57. package/dist/tui/cockpit/run-view.js +13 -9
  58. package/dist/tui/cockpit/setup-cockpit.d.ts +2 -0
  59. package/dist/tui/cockpit/setup-cockpit.js +4 -0
  60. package/package.json +2 -1
  61. package/schema/config.schema.json +8 -1
  62. package/skills/tickmarkr-loop/SKILL.md +7 -1
  63. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +4 -1
@@ -325,7 +325,8 @@ const ShapeGateParticipationSchema = z
325
325
  });
326
326
  export const ExecutionPolicySchema = z.object({
327
327
  boundedInfrastructure: z.boolean().default(false),
328
- repairSelection: z.boolean().default(false),
328
+ // OBS-1199: absent inherits the effective default (on); an explicit false stays off.
329
+ repairSelection: z.boolean().optional(),
329
330
  taskExecutionLimitMs: z.number().int().positive(),
330
331
  }).strict();
331
332
  export const TickmarkrConfigSchema = z.object({
@@ -408,6 +409,12 @@ export const TickmarkrConfigSchema = z.object({
408
409
  tipTest: z.string(),
409
410
  lint: z.string(),
410
411
  diffCap: z.number().int().positive(),
412
+ // OBS-1140: per-run gate-evidence byte quota; the oldest artifacts are evicted (and tombstoned)
413
+ // past it. Zero retains zero artifact bytes. Evidence retention never changes a gate's pass bit.
414
+ evidenceQuotaBytes: z.number().int().nonnegative(),
415
+ // OBS-1199: repair rounds screen known failing files plus affected tests before the full suite
416
+ // that still decides every merge. On unless false here or in executionPolicy, on every driver.
417
+ repairSelection: z.boolean(),
411
418
  byShape: z.partialRecord(z.enum(SHAPES), ShapeGateParticipationSchema).optional(),
412
419
  }).partial(),
413
420
  scope: z.object({
@@ -488,6 +495,7 @@ function assertSeededModelWindows() {
488
495
  }
489
496
  }
490
497
  }
498
+ export const DEFAULT_EVIDENCE_QUOTA_BYTES = 8 * 1024 * 1024;
491
499
  export const DEFAULT_CONFIG = {
492
500
  concurrency: 3,
493
501
  driver: "auto",
@@ -660,7 +668,7 @@ export const DEFAULT_CONFIG = {
660
668
  },
661
669
  },
662
670
  pricing: { cheap: 0.1, mid: 0.5, frontier: 2.5 },
663
- gates: { diffCap: DEFAULT_DIFF_CAP },
671
+ gates: { diffCap: DEFAULT_DIFF_CAP, evidenceQuotaBytes: DEFAULT_EVIDENCE_QUOTA_BYTES },
664
672
  judge: { adapter: "claude-code", model: "fable" },
665
673
  // R3: no `policy` floor — the neutral floor leaves the compiler's per-task assignment standing, so
666
674
  // the path-keyed rule is reachable out of the box rather than raised to full by construction.
@@ -818,12 +826,41 @@ export function loadConfigWithMode(repoRoot, opts = {}) {
818
826
  }
819
827
  /** OBS-1182: tiers.<adapter>.modelOverrides as the layers under the repo overlay (defaults + global)
820
828
  * merge them. Read raw, never schema-validated: a lower layer may only validate beside repo fields
821
- * (global `vendor: null` completed by a repo vendor) and its override metadata is still inherited. */
829
+ * (global `vendor: null` completed by a repo vendor) and its override metadata is still inherited.
830
+ * OBS-1188: an unparseable global file, or a non-map where this path expects a map, is an error —
831
+ * never an empty lower layer, which would silently drop the metadata a write must re-mask. */
822
832
  export function lowerLayerModelOverrides(opts = {}) {
823
833
  const isMap = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
824
- const merged = deepMerge(structuredClone(DEFAULT_CONFIG), readYaml(join(opts.globalDir ?? globalConfigDir(), "config.yaml")));
825
- const tiers = isMap(merged) && isMap(merged.tiers) ? merged.tiers : {};
826
- return Object.fromEntries(Object.entries(tiers).map(([adapter, entry]) => [adapter, Object.fromEntries(Object.entries(isMap(entry) && isMap(entry.modelOverrides) ? entry.modelOverrides : {}).filter(([, o]) => isMap(o)))]));
834
+ const path = join(opts.globalDir ?? globalConfigDir(), "config.yaml");
835
+ let global;
836
+ try {
837
+ global = readYaml(path);
838
+ }
839
+ catch (error) {
840
+ return { ok: false, error: `lower config layer ${path} is malformed YAML: ${error.message.replace(/\s+/g, " ").trim()}` };
841
+ }
842
+ const malformed = (at) => ({ ok: false, error: `lower config layer ${path} is malformed: ${at} is not a map` });
843
+ // deepMerge replaces a map with any non-map the global declares, so the merged shape is the global's
844
+ const merged = deepMerge(structuredClone(DEFAULT_CONFIG), global);
845
+ if (!isMap(merged))
846
+ return malformed("the document");
847
+ const tiers = merged.tiers ?? {}; // a global `tiers: null` tombstone leaves no lower tiers
848
+ if (!isMap(tiers))
849
+ return malformed("tiers");
850
+ const overrides = {};
851
+ for (const [adapter, entry] of Object.entries(tiers)) {
852
+ if (!isMap(entry))
853
+ return malformed(`tiers.${adapter}`);
854
+ const models = entry.modelOverrides ?? {};
855
+ if (!isMap(models))
856
+ return malformed(`tiers.${adapter}.modelOverrides`);
857
+ for (const [model, override] of Object.entries(models)) {
858
+ if (!isMap(override))
859
+ return malformed(`tiers.${adapter}.modelOverrides.${model}`);
860
+ }
861
+ overrides[adapter] = models;
862
+ }
863
+ return { ok: true, overrides };
827
864
  }
828
865
  export function loadConfig(repoRoot, opts = {}) {
829
866
  return loadConfigWithMode(repoRoot, opts).cfg;
@@ -1,7 +1,7 @@
1
- import { type FleetEditable, type FleetUniverseRow, type RoutingMode } from "./config.js";
1
+ import { type FleetEditable, type FleetUniverseRow, type LowerLayerModelOverrides, type RoutingMode } from "./config.js";
2
2
  /** Fleet-owned overlay keys — the only config surface `tickmarkr fleet` may write. */
3
3
  export declare const FLEET_OVERLAY_KEYS: readonly ["routing", "tiers"];
4
- export type FleetOverlayWrite = {
4
+ type FleetOverlayWriteFields = {
5
5
  initial: FleetEditable;
6
6
  edited: FleetEditable;
7
7
  mode?: RoutingMode;
@@ -20,8 +20,17 @@ export type FleetOverlayWrite = {
20
20
  };
21
21
  };
22
22
  universe?: FleetUniverseRow[];
23
- lowerOverrides?: Record<string, Record<string, Record<string, unknown>>>;
24
23
  };
24
+ type EffortFree = FleetEditable & {
25
+ efforts?: undefined;
26
+ };
27
+ export type FleetOverlayWrite = FleetOverlayWriteFields & ({
28
+ lowerOverrides: LowerLayerModelOverrides;
29
+ } | {
30
+ initial: EffortFree;
31
+ edited: EffortFree;
32
+ lowerOverrides?: undefined;
33
+ });
25
34
  export type FleetFirstTouch = {
26
35
  vendor: string;
27
36
  channel: "sub" | "api";
@@ -36,3 +45,4 @@ export declare function repoOverlayYaml(overlay: Record<string, unknown>): strin
36
45
  export declare function serializeFleetOverlay(overlay: Record<string, unknown>): string;
37
46
  export declare function unifiedYamlDiff(before: string, after: string, label?: string): string;
38
47
  export declare function fleetEditableEquals(a: FleetEditable, b: FleetEditable): boolean;
48
+ export {};
@@ -368,27 +368,32 @@ export function renderFleetOverlayWrite(priorBytes, write) {
368
368
  const after = efforts(edited)[adapter]?.[model];
369
369
  if (efforts(initial)[adapter]?.[model] === after)
370
370
  continue;
371
+ // OBS-1188: the type already demands lower state here; this refuses a cast/JS caller before the
372
+ // bytes are returned, so a blind effort write never reaches disk.
373
+ const lowerAll = write.lowerOverrides;
374
+ if (!lowerAll) {
375
+ throw new Error(`fleet write: the effort edit on ${adapter}:${model} needs the lower config layers' model overrides — refusing to write it blind`);
376
+ }
371
377
  const override = ["tiers", adapter, "modelOverrides", model];
372
378
  if (after !== undefined) {
373
379
  // Setting beneath a tombstone (the override, or modelOverrides above it) lifts it, and
374
380
  // deepMerge would restore what it masked: re-mask each lower-layer key it covered — sibling
375
- // models, then this override's vendor/channel (unknown lower ⇒ both, blind).
381
+ // models, then this override's own keys.
376
382
  // ponytail: tiers.<adapter> itself tombstoned is unreachable here — its models, and so a tier, are masked too.
377
383
  const tombstoned = (depth) => {
378
384
  const node = doc.getIn(override.slice(0, depth), true);
379
385
  return node !== undefined && !isMap(node);
380
386
  };
381
- const lowerModels = write.lowerOverrides?.[adapter] ?? {};
387
+ const lowerModels = lowerAll[adapter] ?? {};
382
388
  const remask = [];
383
389
  if (tombstoned(3))
384
390
  for (const other of Object.keys(lowerModels))
385
391
  if (other !== model)
386
392
  remask.push([...override.slice(0, 3), other]);
387
393
  if (tombstoned(3) || tombstoned(4)) {
388
- for (const key of write.lowerOverrides ? Object.keys(lowerModels[model] ?? {}) : ["vendor", "channel"]) {
394
+ for (const key of Object.keys(lowerModels[model] ?? {}))
389
395
  if (key !== "effort")
390
396
  remask.push([...override, key]);
391
- }
392
397
  }
393
398
  setScalarPreservingComment(doc, [...override, "effort"], after);
394
399
  for (const path of remask)
@@ -396,11 +401,10 @@ export function renderFleetOverlayWrite(priorBytes, write) {
396
401
  continue;
397
402
  }
398
403
  // Clearing means no effort in the MERGED config: drop the repo key, then mask an effort the
399
- // lower layers still declare (unknown lower ⇒ a repo that never held the key inherited it).
400
- const held = doc.getIn([...override, "effort"]) !== undefined;
404
+ // lower layers still declare.
401
405
  deleteAt(doc, [...override, "effort"]);
402
- const lower = write.lowerOverrides?.[adapter]?.[model];
403
- const maskEffort = write.lowerOverrides ? lower?.effort !== undefined : !held;
406
+ const lower = lowerAll[adapter]?.[model];
407
+ const maskEffort = lower?.effort !== undefined;
404
408
  const repoNode = doc.getIn(override, true);
405
409
  const { effort: _masked, ...rest } = { ...lower, ...(isMap(repoNode) ? repoNode.toJSON() : {}) };
406
410
  if (Object.values(rest).some((v) => v !== null && v !== undefined)) {
@@ -62,6 +62,8 @@ export declare class HerdrDriver implements ExecutorDriver {
62
62
  private deliverySerial;
63
63
  private dispatchLeases;
64
64
  private deliveredPanes;
65
+ /** OBS-1203: slots bound by adopt() — their reads are harvest evidence, so a failed read throws. */
66
+ private adoptedSlots;
65
67
  private inputBoxes;
66
68
  private bootstraps;
67
69
  private journalRoots;
@@ -93,6 +95,16 @@ export declare class HerdrDriver implements ExecutorDriver {
93
95
  private paneId;
94
96
  private paneWidth;
95
97
  slot(cwd: string, name: string, opts?: SlotOpts): Promise<Slot>;
98
+ /**
99
+ * OBS-1203: bind THIS instance, READ-ONLY, to the pane an earlier daemon created for an owned slot,
100
+ * so a resumed daemon can read the interrupted attempt's trailer. slot() is never a substitute: it
101
+ * allocates a tab, reclaimStaleLabel closes the very pane holding the evidence, and it holds a
102
+ * dispatch lease only run() releases. Binding needs ownership evidence, never a label alone: exactly
103
+ * one pane carries the FULL owned label, herdr reports that pane's cwd as the journaled task
104
+ * checkout, and that path is a git checkout whose own top level it is. Zero, ambiguous or foreign
105
+ * candidates throw — the caller's decline. `pane list` and `git rev-parse` are the only calls made.
106
+ */
107
+ adopt(slot: Slot): Promise<Slot>;
96
108
  private tabSlot;
97
109
  private reclaimStaleLabel;
98
110
  private staleLabelPanes;
@@ -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;