tickmarkr 2.6.1 → 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 (105) hide show
  1. package/README.md +16 -3
  2. package/dist/adapters/catalog-remote.js +89 -47
  3. package/dist/adapters/claude-code.js +9 -6
  4. package/dist/adapters/codex.js +7 -4
  5. package/dist/adapters/prompt.d.ts +1 -0
  6. package/dist/adapters/prompt.js +14 -6
  7. package/dist/adapters/registry.js +3 -3
  8. package/dist/adapters/types.d.ts +12 -4
  9. package/dist/adapters/types.js +6 -0
  10. package/dist/cli/commands/approve.d.ts +11 -4
  11. package/dist/cli/commands/approve.js +82 -27
  12. package/dist/cli/commands/compile.js +13 -3
  13. package/dist/cli/commands/doctor.d.ts +8 -2
  14. package/dist/cli/commands/doctor.js +11 -3
  15. package/dist/cli/commands/fleet.js +87 -11
  16. package/dist/cli/commands/plan.js +13 -8
  17. package/dist/cli/commands/report.d.ts +2 -1
  18. package/dist/cli/commands/report.js +74 -8
  19. package/dist/cli/commands/resume.js +4 -2
  20. package/dist/cli/commands/status.js +43 -20
  21. package/dist/cli/help.d.ts +2 -0
  22. package/dist/cli/help.js +9 -2
  23. package/dist/compile/native.js +7 -0
  24. package/dist/config/config.d.ts +35 -2
  25. package/dist/config/config.js +86 -10
  26. package/dist/config/fleet-overlay.d.ts +13 -2
  27. package/dist/config/fleet-overlay.js +60 -0
  28. package/dist/drivers/herdr.d.ts +12 -0
  29. package/dist/drivers/herdr.js +51 -0
  30. package/dist/drivers/orca.d.ts +35 -2
  31. package/dist/drivers/orca.js +222 -67
  32. package/dist/drivers/types.d.ts +2 -0
  33. package/dist/drivers/types.js +2 -2
  34. package/dist/eval/canary.d.ts +2 -1
  35. package/dist/eval/canary.js +2 -2
  36. package/dist/eval/dispatch.js +1 -0
  37. package/dist/gates/acceptance.d.ts +9 -1
  38. package/dist/gates/acceptance.js +31 -4
  39. package/dist/gates/baseline.d.ts +32 -2
  40. package/dist/gates/baseline.js +111 -24
  41. package/dist/gates/cache.d.ts +8 -0
  42. package/dist/gates/cache.js +12 -2
  43. package/dist/gates/llm.d.ts +11 -4
  44. package/dist/gates/llm.js +40 -21
  45. package/dist/gates/review.d.ts +14 -1
  46. package/dist/gates/review.js +160 -34
  47. package/dist/gates/run-gates.d.ts +56 -4
  48. package/dist/gates/run-gates.js +358 -58
  49. package/dist/gates/test-manifest.d.ts +45 -1
  50. package/dist/gates/test-manifest.js +78 -12
  51. package/dist/graph/schema.d.ts +2 -0
  52. package/dist/graph/schema.js +2 -0
  53. package/dist/plan/scope.js +2 -2
  54. package/dist/route/preference.d.ts +20 -2
  55. package/dist/route/preference.js +48 -13
  56. package/dist/route/router.d.ts +12 -1
  57. package/dist/route/router.js +56 -24
  58. package/dist/run/consult.d.ts +15 -1
  59. package/dist/run/consult.js +18 -7
  60. package/dist/run/daemon.d.ts +38 -2
  61. package/dist/run/daemon.js +895 -192
  62. package/dist/run/git.d.ts +8 -0
  63. package/dist/run/git.js +14 -0
  64. package/dist/run/interactive-seed.d.ts +4 -0
  65. package/dist/run/interactive-seed.js +35 -9
  66. package/dist/run/journal.d.ts +152 -3
  67. package/dist/run/journal.js +551 -50
  68. package/dist/run/lease.d.ts +13 -0
  69. package/dist/run/lease.js +45 -0
  70. package/dist/run/merge.d.ts +3 -1
  71. package/dist/run/merge.js +3 -2
  72. package/dist/run/operator-summary.d.ts +3 -0
  73. package/dist/run/operator-summary.js +3 -1
  74. package/dist/run/protocol.d.ts +46 -1
  75. package/dist/run/protocol.js +14 -2
  76. package/dist/run/receipt-resolver.d.ts +22 -0
  77. package/dist/run/receipt-resolver.js +40 -1
  78. package/dist/run/repair-selection.d.ts +11 -1
  79. package/dist/run/repair-selection.js +17 -9
  80. package/dist/run/supervision.d.ts +7 -1
  81. package/dist/run/supervision.js +5 -2
  82. package/dist/run/wall-budget.d.ts +48 -0
  83. package/dist/run/wall-budget.js +280 -0
  84. package/dist/tui/cockpit/board.js +3 -3
  85. package/dist/tui/cockpit/decision-actions.d.ts +8 -5
  86. package/dist/tui/cockpit/decision-actions.js +55 -32
  87. package/dist/tui/cockpit/derive.js +13 -2
  88. package/dist/tui/cockpit/live-runtime.d.ts +10 -0
  89. package/dist/tui/cockpit/live-runtime.js +50 -3
  90. package/dist/tui/cockpit/run-cockpit.d.ts +3 -0
  91. package/dist/tui/cockpit/run-cockpit.js +27 -2
  92. package/dist/tui/cockpit/run-view.d.ts +9 -2
  93. package/dist/tui/cockpit/run-view.js +66 -9
  94. package/dist/tui/cockpit/setup-cockpit.d.ts +6 -0
  95. package/dist/tui/cockpit/setup-cockpit.js +10 -3
  96. package/dist/tui/ink/fleet-app.d.ts +15 -3
  97. package/dist/tui/ink/fleet-app.js +91 -22
  98. package/package.json +3 -1
  99. package/schema/config.schema.json +825 -0
  100. package/skills/tickmarkr-loop/SKILL.md +15 -3
  101. package/skills/tickmarkr-overseer/SKILL.md +42 -0
  102. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +91 -0
  103. package/skills/tickmarkr-overseer/scripts/context-statusline.sh +81 -0
  104. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +36 -34
  105. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +6 -4
@@ -2,11 +2,11 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { filesGlob } from "../graph/files-glob.js";
5
- import { Document, parse } from "yaml";
5
+ import { Document, isScalar, parse } from "yaml";
6
6
  import { z } from "zod";
7
7
  import { CITED_MODEL_WINDOWS } from "../adapters/model-windows.js";
8
8
  import { stateDirName } from "../graph/graph.js";
9
- import { SHAPES, TIERS } from "../graph/schema.js";
9
+ import { EFFORTS, SHAPES, TIERS } from "../graph/schema.js";
10
10
  const TierEnum = z.enum(TIERS, {
11
11
  error: (iss) => `Invalid option: expected one of "cheap"|"mid"|"frontier" (got ${JSON.stringify(iss.input)})`,
12
12
  });
@@ -51,21 +51,32 @@ export const MapEntrySchema = z.object({
51
51
  escalate: z.boolean().optional(),
52
52
  });
53
53
  const DeclaredVendorSchema = z.string().trim().min(1, "vendor must be nonempty");
54
- const ModelOverrideSchema = z
54
+ // OBS-1182: effort is launch metadata only these CLIs accept (claude --effort, codex -c
55
+ // model_reasoning_effort=). Every other adapter's override refuses the key outright, so config
56
+ // load and the emitted JSON schema both reject an effort that could never be launched.
57
+ export const EFFORT_ADAPTERS = ["claude-code", "codex"];
58
+ const EffortEnum = z.enum(EFFORTS, {
59
+ error: (iss) => `Invalid effort: expected one of "low"|"medium"|"high" (got ${JSON.stringify(iss.input)})`,
60
+ });
61
+ const NoEffort = z.never({
62
+ error: () => `effort is launch metadata only ${EFFORT_ADAPTERS.join(" and ")} accept — this adapter's CLI takes no effort level`,
63
+ });
64
+ const modelOverrideSchema = (effort) => z
55
65
  .object({
56
66
  vendor: DeclaredVendorSchema.optional(),
57
67
  channel: z.enum(["sub", "api"]).optional(),
68
+ effort: effort.optional(),
58
69
  })
59
- .refine((override) => override.vendor !== undefined || override.channel !== undefined, {
60
- message: "a model override must declare vendor, channel, or both",
70
+ .refine((override) => override.vendor !== undefined || override.channel !== undefined || override.effort !== undefined, {
71
+ message: "a model override must declare vendor, channel, effort, or a combination",
61
72
  });
62
- export const TierEntrySchema = z.object({
73
+ const tierEntrySchema = (effort) => z.object({
63
74
  vendor: DeclaredVendorSchema.nullable(),
64
75
  channel: z.enum(["sub", "api"]),
65
76
  models: z.record(z.string(), TierEnum),
66
77
  // T19: keep model values scalar for existing consumers. Optional sibling metadata resolves before
67
78
  // parent metadata in channelsFromConfig; neither provider prefixes nor adapter ids manufacture it.
68
- modelOverrides: z.record(z.string(), ModelOverrideSchema).optional(),
79
+ modelOverrides: z.record(z.string(), modelOverrideSchema(effort)).optional(),
69
80
  // v1.47 T3: optional per-model context-window sizes (tokens). Absent block ⇒ no doctor column, no plan lint.
70
81
  windows: z.record(z.string(), z.number().int().positive()).optional(),
71
82
  }).superRefine((entry, ctx) => {
@@ -81,6 +92,13 @@ export const TierEntrySchema = z.object({
81
92
  });
82
93
  }
83
94
  });
95
+ export const TierEntrySchema = tierEntrySchema(EffortEnum);
96
+ // Per-adapter object + catchall rather than a record, so the emitted JSON schema carries the same
97
+ // adapter restriction as the loader; typed as the record every consumer already reads.
98
+ // ponytail: catchall parses the EFFORT_ADAPTERS keys first — the seed table already lists them first.
99
+ const TiersSchema = z
100
+ .object(Object.fromEntries(EFFORT_ADAPTERS.map((id) => [id, TierEntrySchema.optional()])))
101
+ .catchall(tierEntrySchema(NoEffort));
84
102
  // v1.10 FLEET-06: optional routing.allow/deny fleet preference; absent blocks ⇒ byte-identical routing/discovery.
85
103
  // deny wins on conflict; presence of allow (even {} / empty arrays) activates allowlist (fail-closed).
86
104
  const PrefBlockSchema = z.object({
@@ -307,7 +325,8 @@ const ShapeGateParticipationSchema = z
307
325
  });
308
326
  export const ExecutionPolicySchema = z.object({
309
327
  boundedInfrastructure: z.boolean().default(false),
310
- repairSelection: z.boolean().default(false),
328
+ // OBS-1199: absent inherits the effective default (on); an explicit false stays off.
329
+ repairSelection: z.boolean().optional(),
311
330
  taskExecutionLimitMs: z.number().int().positive(),
312
331
  }).strict();
313
332
  export const TickmarkrConfigSchema = z.object({
@@ -372,7 +391,7 @@ export const TickmarkrConfigSchema = z.object({
372
391
  allow: PrefBlockSchema.optional(),
373
392
  deny: DenyBlockSchema.optional(),
374
393
  }),
375
- tiers: z.record(z.string(), TierEntrySchema),
394
+ tiers: TiersSchema,
376
395
  pricing: z.record(z.string(), z.number()),
377
396
  // v1.20 REC-02: optional detailed price table for cost estimation. Distinct from `pricing` above
378
397
  // (the coarse per-task tier estimate `tickmarkr plan` shows) — this one drives the usage/cost report.
@@ -390,6 +409,12 @@ export const TickmarkrConfigSchema = z.object({
390
409
  tipTest: z.string(),
391
410
  lint: z.string(),
392
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(),
393
418
  byShape: z.partialRecord(z.enum(SHAPES), ShapeGateParticipationSchema).optional(),
394
419
  }).partial(),
395
420
  scope: z.object({
@@ -470,6 +495,7 @@ function assertSeededModelWindows() {
470
495
  }
471
496
  }
472
497
  }
498
+ export const DEFAULT_EVIDENCE_QUOTA_BYTES = 8 * 1024 * 1024;
473
499
  export const DEFAULT_CONFIG = {
474
500
  concurrency: 3,
475
501
  driver: "auto",
@@ -642,7 +668,7 @@ export const DEFAULT_CONFIG = {
642
668
  },
643
669
  },
644
670
  pricing: { cheap: 0.1, mid: 0.5, frontier: 2.5 },
645
- gates: { diffCap: DEFAULT_DIFF_CAP },
671
+ gates: { diffCap: DEFAULT_DIFF_CAP, evidenceQuotaBytes: DEFAULT_EVIDENCE_QUOTA_BYTES },
646
672
  judge: { adapter: "claude-code", model: "fable" },
647
673
  // R3: no `policy` floor — the neutral floor leaves the compiler's per-task assignment standing, so
648
674
  // the path-keyed rule is reachable out of the box rather than raised to full by construction.
@@ -798,6 +824,44 @@ export function loadConfigWithMode(repoRoot, opts = {}) {
798
824
  assertSeededModelWindows();
799
825
  return { cfg: r.data, mode: resolveRoutingMode(r.data, [globalCfg, repoCfg]) };
800
826
  }
827
+ /** OBS-1182: tiers.<adapter>.modelOverrides as the layers under the repo overlay (defaults + global)
828
+ * merge them. Read raw, never schema-validated: a lower layer may only validate beside repo fields
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. */
832
+ export function lowerLayerModelOverrides(opts = {}) {
833
+ const isMap = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
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 };
864
+ }
801
865
  export function loadConfig(repoRoot, opts = {}) {
802
866
  return loadConfigWithMode(repoRoot, opts).cfg;
803
867
  }
@@ -959,10 +1023,14 @@ export function universeCovers(universe, entry) {
959
1023
  // Absent universe ⇒ deny arrays verbatim and no allowOut, so non-UI callers stay byte-identical.
960
1024
  export function fleetEditableFromConfig(cfg, universe) {
961
1025
  const tiers = {};
1026
+ const efforts = {};
962
1027
  for (const [adapter, entry] of Object.entries(cfg.tiers)) {
963
1028
  tiers[adapter] = {};
964
1029
  for (const [model, tier] of Object.entries(entry.models)) {
965
1030
  tiers[adapter][model] = { tier };
1031
+ const effort = entry.modelOverrides?.[model]?.effort;
1032
+ if (effort)
1033
+ (efforts[adapter] ??= {})[model] = effort;
966
1034
  }
967
1035
  }
968
1036
  // every enumerated deny scope rides verbatim (sorted) under its own key
@@ -991,6 +1059,7 @@ export function fleetEditableFromConfig(cfg, universe) {
991
1059
  ...denyLists,
992
1060
  ...(allowOut !== undefined ? { allowOut } : {}),
993
1061
  tiers,
1062
+ ...(Object.keys(efforts).length ? { efforts } : {}),
994
1063
  map: structuredClone(cfg.routing.map),
995
1064
  floors: { ...cfg.routing.floors },
996
1065
  };
@@ -1037,6 +1106,13 @@ export function formatFleetPrint(repoRoot, opts = {}) {
1037
1106
  for (const [adapter, entry] of Object.entries(effective.tiers)) {
1038
1107
  for (const model of Object.keys(entry.models))
1039
1108
  annotate(["tiers", adapter, "models", model]);
1109
+ // OBS-1182: the effort value prints in place; its note names the layer that set it and the edit
1110
+ for (const [model, override] of Object.entries(entry.modelOverrides ?? {})) {
1111
+ const path = ["tiers", adapter, "modelOverrides", model, "effort"];
1112
+ const node = doc.getIn(path, true);
1113
+ if (override.effort && isScalar(node))
1114
+ node.comment = ` ${fleetKeyLayer(repoRoot, path.join("."), opts)} — fleet e edits`;
1115
+ }
1040
1116
  }
1041
1117
  return String(doc);
1042
1118
  }
@@ -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;
@@ -21,6 +21,16 @@ export type FleetOverlayWrite = {
21
21
  };
22
22
  universe?: FleetUniverseRow[];
23
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
+ });
24
34
  export type FleetFirstTouch = {
25
35
  vendor: string;
26
36
  channel: "sub" | "api";
@@ -35,3 +45,4 @@ export declare function repoOverlayYaml(overlay: Record<string, unknown>): strin
35
45
  export declare function serializeFleetOverlay(overlay: Record<string, unknown>): string;
36
46
  export declare function unifiedYamlDiff(before: string, after: string, label?: string): string;
37
47
  export declare function fleetEditableEquals(a: FleetEditable, b: FleetEditable): boolean;
48
+ export {};
@@ -360,6 +360,66 @@ export function renderFleetOverlayWrite(priorBytes, write) {
360
360
  setScalarPreservingComment(doc, path, after.tier, unpackFleetProvenance(after.provenance).provenance);
361
361
  }
362
362
  }
363
+ // OBS-1182: effort lands in tiers.<adapter>.modelOverrides.<model>.effort — the scalar tier and
364
+ // every sibling override key (vendor, channel) keep their bytes.
365
+ const efforts = (e) => e.efforts ?? {};
366
+ for (const adapter of new Set([...Object.keys(efforts(initial)), ...Object.keys(efforts(edited))])) {
367
+ for (const model of new Set([...Object.keys(efforts(initial)[adapter] ?? {}), ...Object.keys(efforts(edited)[adapter] ?? {})])) {
368
+ const after = efforts(edited)[adapter]?.[model];
369
+ if (efforts(initial)[adapter]?.[model] === after)
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
+ }
377
+ const override = ["tiers", adapter, "modelOverrides", model];
378
+ if (after !== undefined) {
379
+ // Setting beneath a tombstone (the override, or modelOverrides above it) lifts it, and
380
+ // deepMerge would restore what it masked: re-mask each lower-layer key it covered — sibling
381
+ // models, then this override's own keys.
382
+ // ponytail: tiers.<adapter> itself tombstoned is unreachable here — its models, and so a tier, are masked too.
383
+ const tombstoned = (depth) => {
384
+ const node = doc.getIn(override.slice(0, depth), true);
385
+ return node !== undefined && !isMap(node);
386
+ };
387
+ const lowerModels = lowerAll[adapter] ?? {};
388
+ const remask = [];
389
+ if (tombstoned(3))
390
+ for (const other of Object.keys(lowerModels))
391
+ if (other !== model)
392
+ remask.push([...override.slice(0, 3), other]);
393
+ if (tombstoned(3) || tombstoned(4)) {
394
+ for (const key of Object.keys(lowerModels[model] ?? {}))
395
+ if (key !== "effort")
396
+ remask.push([...override, key]);
397
+ }
398
+ setScalarPreservingComment(doc, [...override, "effort"], after);
399
+ for (const path of remask)
400
+ setScalarPreservingComment(doc, path, null);
401
+ continue;
402
+ }
403
+ // Clearing means no effort in the MERGED config: drop the repo key, then mask an effort the
404
+ // lower layers still declare.
405
+ deleteAt(doc, [...override, "effort"]);
406
+ const lower = lowerAll[adapter]?.[model];
407
+ const maskEffort = lower?.effort !== undefined;
408
+ const repoNode = doc.getIn(override, true);
409
+ const { effort: _masked, ...rest } = { ...lower, ...(isMap(repoNode) ? repoNode.toJSON() : {}) };
410
+ if (Object.values(rest).some((v) => v !== null && v !== undefined)) {
411
+ if (maskEffort)
412
+ setScalarPreservingComment(doc, [...override, "effort"], null);
413
+ }
414
+ else if (maskEffort || (isMap(repoNode) && repoNode.items.length > 0)) {
415
+ // Tombstone-only siblings must still suppress lower metadata when effort is cleared,
416
+ // even without inherited effort. Their merged {} would fail the nonempty refinement.
417
+ setScalarPreservingComment(doc, override, null);
418
+ }
419
+ for (const depth of [4, 3, 2, 1])
420
+ deleteEmptyMap(doc, override.slice(0, depth));
421
+ }
422
+ }
363
423
  if (write.mode !== undefined) {
364
424
  setScalarPreservingComment(doc, ["routing", "mode"], write.mode);
365
425
  }
@@ -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
@@ -123,12 +123,13 @@ export declare function mapAgentState(term: Record<string, unknown>, tuiIdle: bo
123
123
  * A retired record is replaced by a new reservation (CAS on the tombstone bytes) only once its pane
124
124
  * is proven gone. Everything else — a reservation or claim with no bound pane, a failed cleanup, a
125
125
  * record another driver holds with a live observer — refuses and keeps the record exactly as it is.
126
+ * One reservation or claim is HELD rather than in flight: its placer is this process after an
127
+ * indeterminate split receipt (OBS-1172). It is reconciled again on every call, never split over.
126
128
  */
127
129
  /** `placer` is the pid of the narrator that wrote the reservation: a reserved or claimed record is a
128
130
  * normal intermediate state while that pid lives (another driver awaiting listing or bind) and a
129
131
  * crash to recover only once it is dead. */
130
132
  type BoardRecord = WatchBoardOwner & {
131
- retired?: true;
132
133
  runtimeId?: string;
133
134
  placer?: number;
134
135
  };
@@ -198,6 +199,16 @@ export declare class OrcaDriver implements ExecutorDriver {
198
199
  /** Explicit runtime probe. Also T3's doctor probe. */
199
200
  probeRuntime(cwd?: string): Promise<string>;
200
201
  slot(cwd: string, name: string, opts?: SlotOpts): Promise<Slot>;
202
+ /**
203
+ * OBS-1109: bind THIS instance to a terminal an earlier daemon created for an OWNED slot, so a
204
+ * resumed daemon can read the interrupted attempt's pane. Binding needs ownership evidence, never
205
+ * a title or handle alone: exactly one tab on the tracked worktree carries the FULL journaled owned
206
+ * title, it resolves to exactly one terminal, and that terminal's scrollback proves the exact task
207
+ * checkout `slot.cwd` (the frame only tickmarkr's own create command prints). Zero, ambiguous or
208
+ * foreign candidates leave the slot unbound: the throw is the caller's decline. The journaled
209
+ * nonce is then checked by the caller against the trailer this binding reads.
210
+ */
211
+ adopt(slot: Slot): Promise<Slot>;
201
212
  /** Where to invoke the CLI for this slot's calls (see OrcaSlotState.dir). */
202
213
  private cliCwd;
203
214
  private state;
@@ -263,7 +274,15 @@ export declare class OrcaDriver implements ExecutorDriver {
263
274
  */
264
275
  private screenBehindBlindStream;
265
276
  /** A rendered-frame liveness read. `--screen` and `--cursor` are mutually exclusive in Orca. */
266
- 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>;
267
286
  /** A single UNPAGED tail read — exactly what the caller asked for and nothing more. Markers split
268
287
  * across cursor pages are not reassembled here; that is waitOutput's job. */
269
288
  read(slot: Slot, lines: number): Promise<string>;
@@ -290,6 +309,15 @@ export declare class OrcaDriver implements ExecutorDriver {
290
309
  sendKey(slot: Slot, key: string): Promise<void>;
291
310
  nudge(slot: Slot, message: string): Promise<boolean>;
292
311
  narrator(cwd: string, command: string, runId?: string): Promise<Slot>;
312
+ /** OBS-1172: what an indeterminate split left behind, reconciled from the owner record and Orca's
313
+ * terminal table — never from the receipt that did not arrive. The reservation's token reaches no
314
+ * process but the split's own command, so a claim proves that split started a board; the owned
315
+ * record (this driver, the watch title) is ours by that same token. The claimant must be live (pid
316
+ * and arm presence), and it must name the terminal it runs in — a listed pane in the launching tab.
317
+ * A claim that names none proves a board started, not WHICH pane hosts it: a sole neighbouring pane
318
+ * (row titles are shell-controlled, never ownership keys) is not adopted, so the claim stays held.
319
+ * No claim and no such pane: the split started nothing the table shows. Anything else is held. */
320
+ private settleIndeterminate;
293
321
  private boardPath;
294
322
  /** A recorded pane (a receipt's handle bound to the split envelope's runtime, never a guess) is
295
323
  * gone when that runtime no longer lists it, or when a handle-bound close receipt names it.
@@ -303,6 +331,11 @@ export declare class OrcaDriver implements ExecutorDriver {
303
331
  * handle-bound close (timeout keeps the tombstone and the pane); a dead one never acknowledges,
304
332
  * so it is only asked. */
305
333
  private retireAndClose;
334
+ /** Stop a retired board's observer before its pane is closed. A live observer acknowledges on
335
+ * injected time; a dead one never can, so it is only asked. OBS-1131: an acknowledgement timeout
336
+ * is not proof of death — while the pid lives the owned pane stays protected — but a pid that died
337
+ * during the wait is, and its pane is closed instead of leaked. */
338
+ private stopObserver;
306
339
  /** WB-1 seam: the daemon reports this board lost. "Lost" can be a stale beat or missing presence
307
340
  * under a still-live owner pid, so it is not proof of a dead observer — retirement keeps close's
308
341
  * acknowledgement discipline (Leg-2 T9 P1). */