tickmarkr 1.84.0 → 1.86.0

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 (87) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters/catalog-remote.d.ts +64 -0
  3. package/dist/adapters/catalog-remote.js +287 -0
  4. package/dist/adapters/catalog.d.ts +96 -0
  5. package/dist/adapters/catalog.js +176 -0
  6. package/dist/adapters/claude-code.d.ts +1 -0
  7. package/dist/adapters/claude-code.js +59 -1
  8. package/dist/adapters/fake.js +42 -4
  9. package/dist/adapters/model-lints.d.ts +25 -5
  10. package/dist/adapters/model-lints.js +184 -50
  11. package/dist/adapters/model-windows.d.ts +31 -0
  12. package/dist/adapters/model-windows.js +69 -0
  13. package/dist/adapters/prompt.d.ts +5 -1
  14. package/dist/adapters/prompt.js +13 -4
  15. package/dist/adapters/registry.d.ts +25 -26
  16. package/dist/adapters/registry.js +173 -110
  17. package/dist/adapters/types.d.ts +3 -0
  18. package/dist/adapters/types.js +36 -3
  19. package/dist/brand.d.ts +5 -1
  20. package/dist/brand.js +18 -2
  21. package/dist/cli/commands/doctor.d.ts +3 -0
  22. package/dist/cli/commands/doctor.js +43 -21
  23. package/dist/cli/commands/fleet.d.ts +7 -0
  24. package/dist/cli/commands/fleet.js +94 -74
  25. package/dist/cli/commands/init.js +118 -5
  26. package/dist/cli/commands/status.js +202 -46
  27. package/dist/compile/collateral.d.ts +86 -2
  28. package/dist/compile/collateral.js +294 -3
  29. package/dist/compile/gsd.d.ts +2 -1
  30. package/dist/compile/gsd.js +68 -2
  31. package/dist/compile/native.d.ts +14 -0
  32. package/dist/compile/native.js +161 -12
  33. package/dist/config/config.d.ts +82 -5
  34. package/dist/config/config.js +253 -66
  35. package/dist/config/fleet-overlay.d.ts +25 -20
  36. package/dist/config/fleet-overlay.js +195 -77
  37. package/dist/config/fleet-why.d.ts +23 -0
  38. package/dist/config/fleet-why.js +42 -0
  39. package/dist/drivers/herdr.d.ts +21 -3
  40. package/dist/drivers/herdr.js +344 -110
  41. package/dist/gates/acceptance.js +7 -2
  42. package/dist/gates/baseline.d.ts +1 -0
  43. package/dist/gates/baseline.js +91 -13
  44. package/dist/gates/llm.d.ts +0 -1
  45. package/dist/gates/llm.js +5 -30
  46. package/dist/gates/review.d.ts +9 -1
  47. package/dist/gates/review.js +105 -10
  48. package/dist/gates/run-gates.d.ts +9 -0
  49. package/dist/gates/run-gates.js +285 -41
  50. package/dist/gates/verdict-cause.d.ts +4 -0
  51. package/dist/gates/verdict-cause.js +63 -0
  52. package/dist/graph/schema.d.ts +6 -0
  53. package/dist/graph/schema.js +8 -5
  54. package/dist/route/router.d.ts +0 -5
  55. package/dist/route/router.js +16 -20
  56. package/dist/run/consult.d.ts +6 -0
  57. package/dist/run/consult.js +35 -25
  58. package/dist/run/daemon.d.ts +48 -2
  59. package/dist/run/daemon.js +1488 -330
  60. package/dist/run/journal.d.ts +56 -3
  61. package/dist/run/journal.js +358 -4
  62. package/dist/run/stall.d.ts +35 -1
  63. package/dist/run/stall.js +118 -8
  64. package/dist/tui/cockpit/capture.d.ts +12 -0
  65. package/dist/tui/cockpit/capture.js +37 -1
  66. package/dist/tui/cockpit/components.d.ts +2 -0
  67. package/dist/tui/cockpit/components.js +8 -8
  68. package/dist/tui/cockpit/derive.d.ts +29 -2
  69. package/dist/tui/cockpit/derive.js +219 -23
  70. package/dist/tui/cockpit/run-cockpit.js +128 -27
  71. package/dist/tui/cockpit/theme.d.ts +32 -26
  72. package/dist/tui/cockpit/theme.js +11 -5
  73. package/dist/tui/ink/components.d.ts +0 -15
  74. package/dist/tui/ink/components.js +0 -17
  75. package/dist/tui/ink/fleet-app.d.ts +4 -1
  76. package/dist/tui/ink/fleet-app.js +134 -13
  77. package/fixtures/sample.native.md +1 -1
  78. package/package.json +1 -1
  79. package/skills/tickmarkr-overseer/SKILL.md +354 -34
  80. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +70 -0
  81. package/skills/tickmarkr-overseer/scripts/watch-panes.sh +1 -1
  82. package/dist/tui/ink/studio-app.d.ts +0 -59
  83. package/dist/tui/ink/studio-app.js +0 -320
  84. package/dist/tui/save.d.ts +0 -38
  85. package/dist/tui/save.js +0 -96
  86. package/dist/tui/staging.d.ts +0 -29
  87. package/dist/tui/staging.js +0 -78
@@ -1,6 +1,11 @@
1
- import { declaredInputBoxForWorkerName, matchesEmptyInputBox, matchesInputBox, shq } from "../adapters/types.js";
2
- import { PANE_IDENTITY_ENV, paneIdentityLine } from "../brand.js";
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { declaredInputBoxForWorkerName, matchesEmptyInputBox, matchesInputBox, matchesOccupiedInputBox, missingInputStateDeclarations, shq } from "../adapters/types.js";
6
+ import { consumePaneLaunchIntent, PANE_IDENTITY_ENV, paneIdentityLine } from "../brand.js";
3
7
  import { createWorktree, sh } from "../run/git.js";
8
+ import { Journal } from "../run/journal.js";
4
9
  import { herdrSealShellPrefix } from "./subprocess.js";
5
10
  import { canonicalizeLegacyName, formatOwnedName, panesToClose, parseOwnedName } from "./types.js";
6
11
  // VIS-09 P43-03: adopted safety floor from 43-MEASUREMENT.md (narrowest safe 53 → floor 108).
@@ -14,6 +19,15 @@ const DELIVERY_READ_LINES = 80;
14
19
  const DELIVERY_SETTLE_READ_ATTEMPTS = 6;
15
20
  const DELIVERY_SETTLE_POLL_MS = 100;
16
21
  const DELIVERY_READINESS_TIMEOUT_MS = 1_000;
22
+ // OBS-140/253: a shell or bootstrap dispatch is acknowledged by a START nonce the delivered line
23
+ // PRINTS as its first statement. The printf splits the nonce across two arguments, so the joined
24
+ // marker exists only after the shell actually ran the line — a pane that merely echoed the text it
25
+ // was given never produces it. The marker is written to a private file as well as the pane, because
26
+ // a pane snapshot is not a channel: a full-screen TUI or a noisy launch can scroll a printed nonce
27
+ // out of any bounded read, and a successful dispatch read as corrupt is dispatched TWICE.
28
+ export const DISPATCH_START_PREFIX = "TICKMARKR_START_";
29
+ const DISPATCH_ACK_TIMEOUT_MS = 15_000;
30
+ const DISPATCH_ACK_POLL_MS = 100;
17
31
  const SYSTEM_TIME = {
18
32
  now: () => Date.now(),
19
33
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -32,16 +46,53 @@ export class DeliveryReadinessError extends Error {
32
46
  this.name = "DeliveryReadinessError";
33
47
  }
34
48
  }
49
+ // OBS-253: the dispatch-corruption class, typed so the driver's own one-shot recovery can recognise
50
+ // it and the daemon can keep classifying an unrecovered one as `kind: dispatch`. A dispatch that
51
+ // never registered produced no output to trust, so a fresh-pane retry risks nothing a first dispatch
52
+ // does not already risk — the second consecutive one is what parks the task.
53
+ export class DeliveryCorruptedError extends Error {
54
+ pane;
55
+ transcript;
56
+ phase = "DISPATCH";
57
+ constructor(pane, reason, transcript) {
58
+ super(`herdr delivery corrupted on pane ${pane} — ${reason} (OBS-140/253); pane transcript:\n${transcript}`);
59
+ this.pane = pane;
60
+ this.transcript = transcript;
61
+ this.name = "DeliveryCorruptedError";
62
+ }
63
+ }
35
64
  /** First-generation join direction from measured trailer-safe floor (43-MEASUREMENT.md). */
36
65
  export function workerSplitDirection(paneCols, safeFloor = TRAILER_SAFE_FLOOR_COLS, margin = TRAILER_WIDTH_MARGIN) {
37
66
  if (paneCols == null || paneCols <= 0)
38
67
  return "down";
39
68
  return paneCols / 2 >= safeFloor + margin ? "right" : "down";
40
69
  }
70
+ /** The tab a slot belongs to: its TASK — worker, judge, review and consult panes for one task share it.
71
+ * Returns undefined for everything else, which keeps those on the dedicated-tab path.
72
+ *
73
+ * The ROLE gate is load-bearing and was added after it bit: `canonicalizeLegacyName` returns
74
+ * `role:"other", taskId:"<the whole name>"` for any unrecognised string, so keying on taskId alone
75
+ * makes EVERY one-off pane its own "task" and gives it a group tab. `watch` is excluded for the same
76
+ * reason in the other direction — its taskId is the literal "run", which is a board, not a task. */
77
+ const TASK_TAB_ROLES = new Set(["worker", "judge", "review", "consult"]);
78
+ export function taskGroupOf(name) {
79
+ const { role, taskId } = canonicalizeLegacyName(name, "");
80
+ if (!TASK_TAB_ROLES.has(role))
81
+ return undefined;
82
+ return taskId && taskId.trim() ? taskId : undefined;
83
+ }
84
+ /** Gate panes ride with the task they belong to and never consume the tab cap; everything else does.
85
+ * Scoped to the three GATE roles deliberately — an earlier cut of this said "not a worker", which let
86
+ * role:"other" members (any unrecognised name) bypass the cap and silently disabled overflow for
87
+ * explicit stage groups. The cap still governs every member it governed before. */
88
+ const GATE_ROLES = new Set(["judge", "review", "consult"]);
89
+ const ridesWithTask = (name) => GATE_ROLES.has(canonicalizeLegacyName(name, "").role);
90
+ const cappedMembers = (g) => g.members.filter((m) => !ridesWithTask(m.name)).length;
41
91
  export class HerdrDriver {
42
92
  bin;
43
93
  workersPerTab;
44
94
  time;
95
+ journal;
45
96
  id = "herdr";
46
97
  interactive = true;
47
98
  groups = new Map();
@@ -54,16 +105,44 @@ export class HerdrDriver {
54
105
  dispatchLeases = new WeakMap();
55
106
  deliveredPanes = new WeakMap();
56
107
  inputBoxes = new WeakMap();
108
+ // OBS-253: the adapter-declared bootstrap this slot has already delivered. A fresh pane is a bare
109
+ // shell, so it is the only thing that can put a TUI back under a typed turn that has to move.
110
+ bootstraps = new WeakMap();
111
+ // runDaemon gives the driver the authoritative repo root at its worktree seam before it allocates
112
+ // that task's slot. Keep the binding by cwd so a dispatch retry opens THAT run's Journal even when
113
+ // the caller launched tickmarkr elsewhere (process.cwd is not run identity). The repo itself is
114
+ // also bound for judge/review/consult slots whose cwd is the root rather than a task worktree.
115
+ journalRoots = new Map();
57
116
  // VIS-10: the run's workspace id, captured once at construction (the daemon inherits it from the
58
117
  // operator's env before the driver is built). Required at slot() time, never in the constructor —
59
118
  // pickDriver and its unit test construct HerdrDriver without env, so slot() is the trust gate.
60
119
  ws = process.env.HERDR_WORKSPACE_ID;
61
120
  callerPane = process.env.HERDR_PANE_ID;
62
121
  watches = new Map();
63
- constructor(bin = "herdr", workersPerTab = 3, time = SYSTEM_TIME) {
122
+ constructor(bin = "herdr", workersPerTab = 3, time = SYSTEM_TIME, journal) {
64
123
  this.bin = bin;
65
124
  this.workersPerTab = workersPerTab;
66
125
  this.time = time;
126
+ this.journal = journal;
127
+ }
128
+ // The dispatch-retry record is mandatory: it is the only durable fact left by a recovered pane
129
+ // swap. Tests may inject a sink, while production resolves the daemon's real Journal from the
130
+ // worktree binding plus the canonical slot name. Every resolution/open/append failure propagates;
131
+ // an unjournaled recovery is never performed and its audit failure is never hidden by the original
132
+ // corruption error.
133
+ appendDispatchRetry(slot, data) {
134
+ if (this.journal) {
135
+ this.journal("dispatch-retry", slot.name, data);
136
+ return;
137
+ }
138
+ const owned = parseOwnedName(slot.name);
139
+ if (!owned)
140
+ throw new Error(`cannot journal dispatch-retry: slot ${slot.name} carries no run identity`);
141
+ const repoRoot = this.journalRoots.get(slot.cwd);
142
+ if (!repoRoot) {
143
+ throw new Error(`cannot journal dispatch-retry: slot ${slot.name} has no daemon repo binding for ${slot.cwd}`);
144
+ }
145
+ Journal.open(repoRoot, owned.runId).append("dispatch-retry", owned.taskId, data);
67
146
  }
68
147
  serial(fn) {
69
148
  const p = this.groupSerial.then(fn, fn);
@@ -124,62 +203,18 @@ export class HerdrDriver {
124
203
  const needle = norm(cmd);
125
204
  return needle.length > 0 && hay.includes(needle);
126
205
  }
127
- shellExecutionEchoed(transcript, cmd) {
128
- const norm = (s) => s.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\s+/g, " ").trim();
129
- const needle = norm(cmd);
130
- if (!needle)
206
+ // v1.85 T5: submission is acknowledged CAUSALLY or not at all. The adapter's declared OCCUPIED
207
+ // state means the prompt is still sitting in the box — never submitted; its declared EMPTY state
208
+ // means the box consumed it. Nothing else counts: the positional-transcript fallback this used to
209
+ // end with (prompt found, bytes exist after it ⇒ "delivered") answered yes for panes that had
210
+ // processed nothing (OBS-181), and it is DELETED. A box that is off-screen or unrecognisable is an
211
+ // absence of evidence, which fails closed — the same posture every gate takes. The launch-stage
212
+ // shell-echo reader is gone with it: a bootstrap line is now delivered atomically and acknowledged
213
+ // by its own START nonce, so there is no echo left to read (OBS-144 closed by construction).
214
+ submissionRegistered(transcript, inputBox) {
215
+ if (matchesOccupiedInputBox(transcript, inputBox))
131
216
  return false;
132
- return transcript.split("\n").some((rawLine) => {
133
- const line = norm(rawLine);
134
- if (line === needle || !line.endsWith(needle))
135
- return false;
136
- const prefix = line.slice(0, -needle.length).trimEnd();
137
- if (/[│┃╭╰┌└]/.test(prefix))
138
- return false;
139
- return /^(?:[$%#>]|[➜❯❱›»λ])(?:\s|$)/.test(prefix)
140
- || /[$%#>✗❯❱›»λ]$/.test(prefix);
141
- });
142
- }
143
- // Submission requires positive post-Enter evidence. A prompt echoed above a fresh input target
144
- // proves registration; a declared box that is visibly empty after the verified paste also proves
145
- // it consumed the prompt. At the launch stage, a structurally prompt-prefixed shell execution echo
146
- // is stronger evidence than waiting for first paint from the launched interface (OBS-144).
147
- // Prompt absence alone is never success (OBS-142).
148
- submissionRegistered(transcript, cmd, inputBox) {
149
- const norm = (s) => s.replace(/\s+/g, "");
150
- const hay = norm(transcript);
151
- const needle = norm(cmd);
152
- const promptAt = hay.lastIndexOf(needle);
153
- if (needle.length === 0)
154
- return false;
155
- if (inputBox?.launchCommand?.(cmd) === true && this.shellExecutionEchoed(transcript, cmd))
156
- return true;
157
- if (promptAt < 0)
158
- return inputBox !== undefined && matchesEmptyInputBox(transcript, inputBox);
159
- if (inputBox) {
160
- // OBS-181: an EMPTY declared input box is positive evidence of submission even while the
161
- // prompt is still visible above as the echoed user turn — which is exactly how every TUI
162
- // renders the moment after Enter lands. Test it FIRST: `match` means "the box is painted" and
163
- // is deliberately true for an empty box, so asking `match` first cannot decide submission.
164
- if (matchesEmptyInputBox(transcript, inputBox))
165
- return true;
166
- // The box is painted and is NOT empty: the prompt is still sitting in it. Only a fingerprint
167
- // appearing AFTER the prompt — a fresh input line below the submitted text — counts.
168
- //
169
- // This is the branch a wedged kimi pane belongs in, and could not reach: its occupied editor
170
- // renders a blank continuation row, the matcher demanded the bottom border immediately below
171
- // the prompt, so NEITHER matcher fired and the positional fallback below answered "delivered"
172
- // for a pane that had processed nothing. The matcher fix (adapters/kimi.ts) is what routes it
173
- // here; keeping the fallback unreachable for a painted box is what stops the next one.
174
- if (matchesInputBox(transcript, inputBox)) {
175
- return hay.lastIndexOf(norm(inputBox.fingerprint)) > promptAt;
176
- }
177
- }
178
- // No declared box, or the box is not on screen at all (it may have scrolled out of the read
179
- // window). Positional evidence only — weaker, and unsound for any surface that paints chrome
180
- // below its prompt, which is why a declared box must be able to recognise its own OCCUPIED
181
- // state. Every adapter declaring an inputBox needs a captured occupied frame in its tests.
182
- return promptAt + needle.length < hay.length;
217
+ return matchesEmptyInputBox(transcript, inputBox);
183
218
  }
184
219
  static available() {
185
220
  return process.env.HERDR_ENV === "1";
@@ -236,8 +271,22 @@ export class HerdrDriver {
236
271
  // reconcile.ts and this driver's own renameGroupTab/glyphFor decode role/taskId/attempt from
237
272
  // those shapes without a call-site migration; T2 retires this branch by always passing `owned`.
238
273
  const resolved = opts?.owned ? formatOwnedName(opts.owned) : name;
239
- const allocate = opts?.group
240
- ? () => this.serial(() => this.groupSlot(cwd, resolved, opts.group))
274
+ // ONE TAB PER TASK. The group defaults to the task id, derived from the same parser that already
275
+ // decodes role/taskId for tab labels — so a worker and every gate pane it earns (judge, review,
276
+ // consult) land in that task's tab instead of scattering. Deriving it HERE rather than at each
277
+ // call site is the point: the defect this replaces was three call sites of which exactly one
278
+ // passed a group, so judge/review/consult each opened a tab of their own. A caller may still pass
279
+ // `group` explicitly to override, and a name with no task id (or an explicit `label`) keeps the
280
+ // dedicated-tab path unchanged.
281
+ // PRECEDENCE: a task-bearing name ALWAYS groups by its task; an explicit `group` applies only to
282
+ // names with no task identity. That direction is deliberate — the invariant is "a task's panes are
283
+ // never scattered", and a caller passing a stage group (the daemon still passes "workers", which now
284
+ // serves as the fallback for task-less names) must not be able to override it by omission or habit.
285
+ // An explicit `label` still wins outright: that is the dedicated-role-tab path, chosen on purpose.
286
+ const derivedGroup = opts?.label ? undefined : taskGroupOf(resolved);
287
+ const group = derivedGroup ?? opts?.group;
288
+ const allocate = group
289
+ ? () => this.serial(() => this.groupSlot(cwd, resolved, group))
241
290
  : () => this.tabSlot(cwd, resolved, opts?.label);
242
291
  // Production dispatch names are canonical even when the gate call site supplies the already-
243
292
  // formatted name rather than SlotOpts.owned. Hold one lease across slot() → run(); legacy/manual
@@ -285,8 +334,6 @@ export class HerdrDriver {
285
334
  throw new Error(`herdr tab create returned no tab_id (refusing untargeted placement): ${t.stdout}`);
286
335
  if (typeof id !== "string" || !id)
287
336
  throw new Error(`herdr tab create returned no root pane id (refusing untargeted placement): ${t.stdout}`);
288
- // T5: the banner's pane identity line, derived from the T1 owned name (legacy names pass through).
289
- const identity = shq(paneIdentityLine(canonicalizeLegacyName(name, "")));
290
337
  // durable identity: label the PANE (resolution + reconcile read it back via `pane list`); fail closed
291
338
  // and reap the tab we just made if the rename fails (no orphan tab).
292
339
  const rn = await this.herdr(`pane rename ${shq(id)} ${shq(name)}`);
@@ -303,7 +350,7 @@ export class HerdrDriver {
303
350
  // not a rule it must remember (P40-02 probe leak). Fail closed: a failed seed rejects.
304
351
  // v1.22 T3 / OBS-17: also strip HERDR_ENV + socket path so the worker cannot open/mutate panes in
305
352
  // the operator's session. Daemon-side this.herdr() calls keep process.env (unsealed).
306
- const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${identity}; ${herdrSealShellPrefix()}`)}`);
353
+ const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${shq(paneIdentityLine(canonicalizeLegacyName(name, "")))}; ${herdrSealShellPrefix()}`)}`);
307
354
  if (seed.code !== 0)
308
355
  throw new Error(`herdr workspace-id seed failed (refusing untargeted pane): ${seed.stderr || seed.stdout}`);
309
356
  return { id, name, cwd, tabId };
@@ -346,7 +393,10 @@ export class HerdrDriver {
346
393
  return this.tabSlot(cwd, name); // D-09: degrade, NOT a shared-tab member
347
394
  if (state) {
348
395
  const latest = state.generations[state.generations.length - 1];
349
- if (latest && latest.members.length < this.workersPerTab) {
396
+ // A task tab's judge/review/consult panes belong with their worker and must never overflow into a
397
+ // `cleanup` tab — that would re-scatter exactly what the per-task grouping exists to gather. They
398
+ // therefore join freely and do not consume `workersPerTab`; every other member still does.
399
+ if (latest && (ridesWithTask(name) || cappedMembers(latest) < this.workersPerTab)) {
350
400
  const joined = await this.joinGroup(cwd, name, group, latest);
351
401
  if (joined)
352
402
  return joined;
@@ -381,7 +431,11 @@ export class HerdrDriver {
381
431
  const newest = [...entry.members].reverse().find((m) => canonicalizeLegacyName(m.name, "").role === "worker");
382
432
  const token = newest ? canonicalizeLegacyName(newest.name, "").taskId : undefined;
383
433
  const glyph = newest ? await this.glyphFor(newest) : "";
384
- const label = token ? `${entry.label} · ${token}${glyph}` : entry.label;
434
+ // A per-task tab is already labelled with its task id; appending the same token again reads as a
435
+ // duplicate rather than as state, so the glyph rides the existing label instead.
436
+ const label = !token ? entry.label
437
+ : entry.label === token ? `${token}${glyph}`
438
+ : `${entry.label} · ${token}${glyph}`;
385
439
  const cmd = `tab rename ${shq(entry.tabId)} ${shq(label)}`;
386
440
  const ok = async () => (await this.herdr(cmd)).code === 0;
387
441
  if (await ok() || await ok())
@@ -450,22 +504,22 @@ export class HerdrDriver {
450
504
  await this.renameGroupTab(entry);
451
505
  return { id: pane, name, cwd, tabId: entry.tabId, group };
452
506
  }
453
- // OBS-85 verified delivery: a pane paste can interleave a long dispatch line with itself (codex
454
- // `$(git rev-parse…)` mashed into its trailing printf — v1.58 T2 attempts 2-4, v1.61 T10). Never
455
- // the atomic `pane run` (text+Enter in one request, uninspectable between the two): type WITHOUT
456
- // enter, read the pane back — `wait output --match` checks the same unwrapped transcript pane
457
- // read exposes, event-driven so wrap and render timing can't race the check — and press Enter
458
- // only when that read-back contains the typed command. A corrupted paste is captured (pane read),
459
- // cleared (C-u), and retyped, bounded; persistent corruption fails closed WITH the captured
460
- // transcript — the dispatch-time pincer the ledger asks for, not post-hoc `git:` archaeology.
507
+ // v1.85 T5 (OBS-140/253): every dispatch is routed by what the target actually IS. A shell or
508
+ // bootstrap line goes out atomically through `pane run` and is acknowledged by its own START
509
+ // nonce; only a real TUI turn — an adapter-declared input box, and not that adapter's own launch
510
+ // command — earns the typed pincer. One dispatch corruption is then retried in-process against a
511
+ // FRESH pane before anything is reported as a failure.
461
512
  async run(slot, cmd) {
513
+ // paneLaunchCommand is a linear same-call handoff: consume before the first await so concurrent
514
+ // task dispatches cannot exchange intent. The command remains an ordinary string for every
515
+ // ExecutorDriver implementation; classification never reads its bytes when the builder spoke.
516
+ const recordedLaunch = consumePaneLaunchIntent();
462
517
  const lease = this.dispatchLeases.get(slot);
463
518
  if (lease) {
464
519
  this.dispatchLeases.delete(slot);
465
520
  try {
466
521
  const paneId = await this.verifyPaneIdentityBinding(slot);
467
- await this.deliver(slot, cmd, paneId);
468
- this.deliveredPanes.set(slot, paneId);
522
+ await this.dispatchWithRetry(slot, cmd, paneId, recordedLaunch);
469
523
  }
470
524
  finally {
471
525
  lease.release();
@@ -473,13 +527,200 @@ export class HerdrDriver {
473
527
  return;
474
528
  }
475
529
  return this.deliveryQueue(async () => {
476
- const paneId = await this.paneId(slot);
477
- await this.deliver(slot, cmd, paneId);
478
- this.deliveredPanes.set(slot, paneId);
530
+ await this.dispatchWithRetry(slot, cmd, await this.paneId(slot), recordedLaunch);
479
531
  });
480
532
  }
481
- async deliver(slot, cmd, pane, verifySubmission = true) {
482
- const readiness = await this.awaitDeliveryReadiness(slot, cmd, pane);
533
+ // OBS-253: dispatch corruption is self-clearing — all ten recorded occurrences cleared on a fresh
534
+ // pane and a fresh submit, and every one of them cost an operator `resume --retry-failed`. Retry
535
+ // ONCE here against a pane that has never seen this delivery; the wedged pane is closed, never
536
+ // re-pressed. A second consecutive corruption propagates, and the daemon parks the task on it.
537
+ async dispatchWithRetry(slot, cmd, pane, recordedLaunch = false) {
538
+ try {
539
+ await this.dispatch(slot, cmd, pane, recordedLaunch);
540
+ this.deliveredPanes.set(slot, pane);
541
+ return;
542
+ }
543
+ catch (error) {
544
+ if (!(error instanceof DeliveryCorruptedError))
545
+ throw error;
546
+ // A fresh pane is a bare shell. A shell dispatch is simply re-run on it; a typed turn first
547
+ // needs its interface back, which is possible only where the adapter declared the bootstrap
548
+ // this slot already delivered. Retyping a turn into a bare shell would guarantee the second
549
+ // failure rather than recover from the first, so without a replayable bootstrap the
550
+ // corruption propagates untouched.
551
+ const typedTurn = this.isTuiTurn(slot, cmd, recordedLaunch);
552
+ const relaunch = typedTurn ? this.bootstraps.get(slot) : undefined;
553
+ if (typedTurn && relaunch === undefined)
554
+ throw error;
555
+ // Record BEFORE acting: a retry that cannot be written to the run's ledger is not taken at all,
556
+ // because an unrecorded pane swap is exactly the silence OBS-253 cost ten times. The journal
557
+ // failure itself propagates visibly; the original corruption then remains terminal rather than
558
+ // triggering an off-record recovery.
559
+ this.appendDispatchRetry(slot, { wedgedPane: pane, reason: error.message });
560
+ const fresh = await this.freshPane(slot, pane);
561
+ if (fresh === null)
562
+ throw error;
563
+ if (relaunch !== undefined)
564
+ await this.deliverAtomic(slot, relaunch, fresh);
565
+ await this.dispatch(slot, cmd, fresh, recordedLaunch);
566
+ this.deliveredPanes.set(slot, fresh);
567
+ }
568
+ }
569
+ // Is this delivery a turn TYPED into a running interface, rather than a line a shell runs? The
570
+ // The builder's recorded fact decides first, regardless of command bytes. For direct driver users,
571
+ // an adapter may declare the fresh-slot lifecycle rather than a prefix list. Legacy declarations
572
+ // still decide where neither stronger fact exists. With NO declaration the only evidence is history — a
573
+ // pane that already accepted a delivery is running whatever that delivery started, so a second
574
+ // delivery is a turn into it. Answering "shell" there is what would run an adapter's seed prompt
575
+ // as a shell command; answering "turn" routes it to deliverTyped, which refuses it by name
576
+ // because the adapter declared no input box (fail closed, never a guess).
577
+ isTuiTurn(slot, cmd, recordedLaunch = false) {
578
+ if (recordedLaunch)
579
+ return false;
580
+ const inputBox = this.inputBoxes.get(slot);
581
+ if (inputBox?.firstDeliveryIsLaunch && !this.deliveredPanes.has(slot))
582
+ return false;
583
+ return inputBox ? inputBox.launchCommand?.(cmd) !== true : this.deliveredPanes.has(slot);
584
+ }
585
+ dispatch(slot, cmd, pane, recordedLaunch = false) {
586
+ if (this.isTuiTurn(slot, cmd, recordedLaunch))
587
+ return this.deliverTyped(slot, cmd, pane);
588
+ // A classified bootstrap is the one command that can restore this slot's interface on a fresh
589
+ // pane, so remember the fact's command without asking its bytes to prove the classification again.
590
+ if (this.inputBoxes.has(slot))
591
+ this.bootstraps.set(slot, cmd);
592
+ return this.deliverAtomic(slot, cmd, pane);
593
+ }
594
+ // The OBS-140 class dies here. `pane run` puts text and Enter in ONE herdr request, so there is no
595
+ // seam in which a swallowed Enter can leave a verified-but-unsubmitted prompt, and nothing about
596
+ // the delivery is inferred from typed-text read-back. The line's first two statements print a
597
+ // per-dispatch START nonce assembled from two printf arguments — once to a file whose name only
598
+ // this process knows, then once to the pane for the operator. The joined marker cannot exist
599
+ // unless the shell RAN our line, so the acknowledgment is causal; and because the file is written
600
+ // before the agent the line launches produces a byte, no amount of later output can hide it. Both
601
+ // statements are shell builtins: the ack cannot fail for want of a binary on the pane's PATH.
602
+ //
603
+ // v1.85 T5 review: the durable half GATES the launch, and goes FIRST. A dispatch whose ack the
604
+ // pane could not write is indistinguishable, from here, from a line that never ran — so if the
605
+ // command ran anyway, the miss buys a fresh pane and a SECOND live agent for the same task. The
606
+ // `|| exit 1` makes that impossible: no ack, no command. Ordering carries the same weight in the
607
+ // other direction — a pane-visible marker printed before a failed ack write would satisfy the
608
+ // event watch and report a launch that never happened.
609
+ async deliverAtomic(slot, cmd, pane) {
610
+ const suffix = randomUUID(); // unguessable: nothing the delivered command runs can forge the ack
611
+ const nonce = `${DISPATCH_START_PREFIX}${suffix}`;
612
+ const ackPath = join(tmpdir(), `tickmarkr-dispatch-${suffix}.ack`);
613
+ // Open the channel before spending a pane on it: an ack path this process cannot write is a
614
+ // broken host, not a wedged pane, and the fresh-pane retry would fail there for the same reason.
615
+ // Throwing something other than DeliveryCorruptedError is what keeps that retry unspent.
616
+ try {
617
+ writeFileSync(ackPath, ""); // empty: only the delivered line may ever put the nonce here
618
+ }
619
+ catch (error) {
620
+ throw new Error(`dispatch acknowledgment channel unavailable (${ackPath}): ${error.message}`);
621
+ }
622
+ const marker = `printf '%s%s\\n' ${shq(DISPATCH_START_PREFIX)} ${shq(suffix)}`;
623
+ const line = `${marker} > ${shq(ackPath)} || exit 1; ${marker}; ${cmd}`;
624
+ const deadline = this.time.now() + DISPATCH_ACK_TIMEOUT_MS;
625
+ try {
626
+ const sent = await this.herdr(`pane run ${shq(pane)} ${shq(line)}`, slot.cwd);
627
+ if (sent.code !== 0) {
628
+ throw new DeliveryCorruptedError(pane, `pane run failed: ${sent.stderr || sent.stdout}`, "");
629
+ }
630
+ // Fast path: herdr's own event-driven watch on the pane's output. A match is causal — the pane
631
+ // cannot emit the joined marker without having RUN the line. A MISS is not evidence of
632
+ // anything: this watch subscribes after the request, and a full-screen repaint can swallow the
633
+ // very line it is watching for, which is how a successful launch got dispatched twice. So a
634
+ // miss falls through to the channel the line wrote for itself, which nothing can repaint.
635
+ const seen = await this.herdr(`pane wait-output ${shq(pane)} --match ${shq(nonce)} --timeout ${DISPATCH_ACK_TIMEOUT_MS}`, slot.cwd, DISPATCH_ACK_TIMEOUT_MS + 15_000);
636
+ if (this.waitOk(seen.code, seen.stdout))
637
+ return;
638
+ if (await this.awaitDispatchAck(ackPath, nonce, deadline))
639
+ return;
640
+ // Only now, and only as evidence for the operator: the transcript explains the failure, it
641
+ // never decides one. A pane that looks like it ran the line but never wrote the ack did not.
642
+ const transcript = (await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, slot.cwd)).stdout;
643
+ throw new DeliveryCorruptedError(pane, `START nonce ${nonce} never appeared — the line never ran`, transcript);
644
+ }
645
+ finally {
646
+ try {
647
+ unlinkSync(ackPath);
648
+ }
649
+ catch { /* never written, or already reaped — nothing to clean */ }
650
+ }
651
+ }
652
+ // The durable half of the acknowledgment: the file the delivered line wrote before it launched
653
+ // anything. Checked at least once even when the shared dispatch deadline has already passed, so a
654
+ // watch that spent the whole window missing the line still gets the truth from the line itself.
655
+ async awaitDispatchAck(ackPath, nonce, deadline) {
656
+ for (;;) {
657
+ try {
658
+ if (readFileSync(ackPath, "utf8").includes(nonce))
659
+ return true;
660
+ }
661
+ catch {
662
+ /* the line has not reached the marker yet — or never will, which the deadline decides */
663
+ }
664
+ if (this.time.now() >= deadline)
665
+ return false;
666
+ await this.time.sleep(DISPATCH_ACK_POLL_MS);
667
+ }
668
+ }
669
+ // OBS-253: a fresh pane is a SIBLING inside this slot's own tab. Split first so the tab can never
670
+ // empty, then close the wedged pane and take its durable label — every other driver call addresses
671
+ // this slot through that label or the delivered-pane pin, so no caller observes the swap.
672
+ async freshPane(slot, wedged) {
673
+ if (!this.ws)
674
+ return null;
675
+ const sp = await this.herdr(`pane split ${shq(wedged)} --direction down --no-focus --cwd ${shq(slot.cwd)}`);
676
+ if (sp.code !== 0)
677
+ return null;
678
+ let pane;
679
+ try {
680
+ pane = JSON.parse(sp.stdout).result?.pane?.pane_id ?? undefined;
681
+ }
682
+ catch {
683
+ return null;
684
+ }
685
+ if (typeof pane !== "string" || !pane)
686
+ return null;
687
+ // The wedged pane is never re-pressed, only reaped — and the reap is a POSTCONDITION, not a
688
+ // best-effort. It still carries this slot's durable label, so a close that fails (or succeeds
689
+ // and frees nothing) leaves two panes answering to one name and `pane list` resolving it by
690
+ // whichever comes first. Verify the name is free BEFORE the replacement is renamed, seeded or
691
+ // dispatched: a fresh pane that is only PROBABLY the slot is not a fresh pane (v1.85 T5 review).
692
+ const closed = await this.herdr(`pane close ${shq(wedged)}`);
693
+ if (closed.code !== 0 || (await this.staleLabelPanes(slot.name, pane)).length > 0) {
694
+ await this.herdr(`pane close ${shq(pane)}`);
695
+ return null;
696
+ }
697
+ const rn = await this.herdr(`pane rename ${shq(pane)} ${shq(slot.name)}`);
698
+ if (rn.code !== 0 || (await this.namedPaneId(slot.name)) !== pane) {
699
+ await this.herdr(`pane close ${shq(pane)}`);
700
+ return null;
701
+ }
702
+ // VIS-10 hole 3: a split pane is a bare shell with FRESH env, so it is untargeted until seeded.
703
+ const seed = await this.herdr(`pane run ${shq(pane)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${shq(paneIdentityLine(canonicalizeLegacyName(slot.name, "")))}; ${herdrSealShellPrefix()}`)}`, slot.cwd);
704
+ if (seed.code !== 0) {
705
+ await this.herdr(`pane close ${shq(pane)}`);
706
+ return null;
707
+ }
708
+ return pane;
709
+ }
710
+ // OBS-85 typed delivery, now reserved for real TUI turns: type WITHOUT enter, read the pane back
711
+ // — `wait output --match` checks the same unwrapped transcript pane read exposes, event-driven so
712
+ // wrap and render timing can't race the check — and press Enter only when that read-back contains
713
+ // the typed command. A corrupted paste is captured (pane read), cleared (C-u), and retyped,
714
+ // bounded; persistent corruption fails closed WITH the captured transcript.
715
+ async deliverTyped(slot, cmd, pane) {
716
+ const inputBox = this.inputBoxes.get(slot);
717
+ const missing = missingInputStateDeclarations(inputBox);
718
+ if (missing.length > 0) {
719
+ throw new Error(`herdr refuses typed delivery to ${slot.name}: the adapter has not declared its input states `
720
+ + `(missing: ${missing.join(", ")}) — only a declared box can acknowledge a submission, and `
721
+ + `nothing else may stand in for it (OBS-140)`);
722
+ }
723
+ const readiness = await this.awaitDeliveryReadiness(slot, cmd, pane, inputBox);
483
724
  let transcript = "";
484
725
  for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
485
726
  if (attempt > 0) {
@@ -487,7 +728,7 @@ export class HerdrDriver {
487
728
  // line only after two consecutive pane reads agree; an already-stable frame returns on the
488
729
  // first fresh read without a timer. A changing pane is bounded and preserves OBS-85's
489
730
  // fail-closed error instead of guessing from an adapter fingerprint.
490
- const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript, this.inputBoxes.get(slot));
731
+ const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript, inputBox);
491
732
  transcript = settled.transcript;
492
733
  if (!settled.ok) {
493
734
  throw new Error(`herdr delivery clear failed — refusing to retype onto a corrupted line (OBS-85); pane transcript:\n${transcript}`);
@@ -506,24 +747,14 @@ export class HerdrDriver {
506
747
  throw new Error(`herdr pane send-text failed: ${typed.stderr || typed.stdout}`);
507
748
  const back = await this.herdr(`pane wait-output ${shq(pane)} --match ${shq(cmd)} --timeout ${DELIVERY_VERIFY_TIMEOUT_MS}`, slot.cwd, DELIVERY_VERIFY_TIMEOUT_MS + 15_000);
508
749
  if (this.waitOk(back.code, back.stdout) || await this.deliveryReadMatches(pane, cmd, slot.cwd)) {
509
- if (verifySubmission) {
510
- await this.submitVerifiedDelivery(slot, cmd, pane, readiness);
511
- }
512
- else {
513
- const enter = await this.herdr(`pane send-keys ${shq(pane)} Enter`, slot.cwd);
514
- if (enter.code !== 0)
515
- throw new Error(`herdr pane send-keys Enter failed: ${enter.stderr || enter.stdout}`);
516
- }
750
+ await this.submitVerifiedDelivery(slot, pane, inputBox, readiness);
517
751
  return;
518
752
  }
519
753
  // capture the corrupted delivery BEFORE clearing it — the OBS-85 byte-level evidence
520
754
  transcript = (await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, slot.cwd)).stdout;
521
755
  }
522
- throw new Error(`herdr delivery corrupted after ${DELIVERY_ATTEMPTS} attempts — enter never pressed (OBS-85); pane transcript:\n${transcript}`);
756
+ throw new DeliveryCorruptedError(pane, `${DELIVERY_ATTEMPTS} typed attempts — enter never pressed (OBS-85)`, transcript);
523
757
  }
524
- // The narrator launches a perpetual shell watch, not an adapter-backed input interface. Keep
525
- // OBS-85's readiness + paste read-back and the historical single Enter, while worker/gate runs
526
- // continue through positive post-Enter evidence in submitVerifiedDelivery.
527
758
  // OBS-201: liveness nudge — deliver one message into the live worker TUI through the exact
528
759
  // pincer every dispatch uses (readiness stable-frame, type-without-Enter, read-back, C-u clear
529
760
  // on corruption, verified submit), serialized on the deliveryQueue so it can never interleave
@@ -536,23 +767,24 @@ export class HerdrDriver {
536
767
  if (!pinned)
537
768
  return false;
538
769
  try {
539
- await this.deliveryQueue(() => this.deliver(slot, message, pinned));
770
+ await this.deliveryQueue(() => this.deliverTyped(slot, message, pinned));
540
771
  return true;
541
772
  }
542
773
  catch {
543
774
  return false; // the daemon journals worker-nudge-failed and falls back to the stall window
544
775
  }
545
776
  }
777
+ // The narrator launches a perpetual shell watch, not an adapter-backed input interface — a shell
778
+ // dispatch like any other, delivered atomically and acknowledged by its own START nonce.
546
779
  async deliverPersistentShellCommand(slot, cmd) {
547
780
  return this.deliveryQueue(async () => {
548
781
  const pane = await this.paneId(slot);
549
- await this.deliver(slot, cmd, pane, false);
782
+ await this.deliverAtomic(slot, cmd, pane);
550
783
  this.deliveredPanes.set(slot, pane);
551
784
  });
552
785
  }
553
- async submitVerifiedDelivery(slot, cmd, pane, readiness) {
786
+ async submitVerifiedDelivery(slot, pane, inputBox, readiness) {
554
787
  let transcript = "";
555
- const inputBox = this.inputBoxes.get(slot);
556
788
  // The base window preserves OBS-140's bounded behavior. A slow readiness observation grants
557
789
  // the same measured time once more for submit paint, capped by the readiness bound itself.
558
790
  const baseVerifyMs = (DELIVERY_SETTLE_READ_ATTEMPTS - 1) * DELIVERY_SETTLE_POLL_MS;
@@ -564,15 +796,15 @@ export class HerdrDriver {
564
796
  // Reuse the existing settle-read window. A first-read success returns before any timer; only
565
797
  // a prompt that still occupies the delivery target spends the bounded settle window. This
566
798
  // verification always completes before a possible re-press, so a slow submit cannot duplicate.
567
- const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript, inputBox, (candidate) => this.submissionRegistered(candidate, cmd, inputBox), verifyWindowMs);
799
+ const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript, inputBox, (candidate) => this.submissionRegistered(candidate, inputBox), verifyWindowMs);
568
800
  transcript = settled.transcript;
569
801
  if (settled.ok)
570
802
  return;
571
803
  if (settled.readFailed) {
572
- throw new Error(`herdr delivery corrupted — submission verification failed, refusing to re-press Enter (OBS-140); pane transcript:\n${transcript}`);
804
+ throw new DeliveryCorruptedError(pane, "submission verification read failed, refusing to re-press Enter (OBS-140)", transcript);
573
805
  }
574
806
  }
575
- throw new Error(`herdr delivery corrupted after ${DELIVERY_SUBMIT_ATTEMPTS} submit attempts — submission never registered (OBS-140); pane transcript:\n${transcript}`);
807
+ throw new DeliveryCorruptedError(pane, `${DELIVERY_SUBMIT_ATTEMPTS} submit attempts — submission never registered (OBS-140)`, transcript);
576
808
  }
577
809
  async settleDeliveryLine(pane, cwd, initialTranscript, inputBox, accept, settleWindowMs) {
578
810
  let transcript = initialTranscript;
@@ -604,10 +836,11 @@ export class HerdrDriver {
604
836
  }
605
837
  return { ok: false, transcript, recognizedInputBox: false };
606
838
  }
607
- async awaitDeliveryReadiness(slot, cmd, pane) {
608
- const inputBox = this.inputBoxes.get(slot);
609
- const requireInputBox = inputBox !== undefined && inputBox.launchCommand?.(cmd) !== true;
610
- const timeoutMs = inputBox?.readinessTimeoutMs ?? DELIVERY_READINESS_TIMEOUT_MS;
839
+ // Readiness now serves typed delivery only, so the target is always the adapter's declared box:
840
+ // the "no declared box, so assume ready when the pane doesn't already show our command" branch is
841
+ // gone with the rest of the typed-text inference (v1.85 T5).
842
+ async awaitDeliveryReadiness(slot, cmd, pane, inputBox) {
843
+ const timeoutMs = inputBox.readinessTimeoutMs ?? DELIVERY_READINESS_TIMEOUT_MS;
611
844
  const started = this.time.now();
612
845
  let previous;
613
846
  let transcript = "";
@@ -635,11 +868,9 @@ export class HerdrDriver {
635
868
  }
636
869
  if (previous !== undefined) {
637
870
  const stableFrame = read.stdout === previous;
638
- const targetReady = stableFrame && (requireInputBox
639
- ? matchesInputBox(previous, inputBox) && matchesInputBox(read.stdout, inputBox)
640
- : !this.deliveryMatches(read.stdout, cmd));
641
- if (targetReady)
871
+ if (stableFrame && matchesInputBox(previous, inputBox) && matchesInputBox(read.stdout, inputBox)) {
642
872
  return { waitedMs, timeoutMs, transcript: read.stdout };
873
+ }
643
874
  }
644
875
  previous = read.stdout;
645
876
  const remaining = timeoutMs - waitedMs;
@@ -888,7 +1119,10 @@ export class HerdrDriver {
888
1119
  /* cosmetic — visibility hygiene never fails the run */
889
1120
  }
890
1121
  }
891
- worktree(repo, branch, baseRef) {
892
- return createWorktree(repo, branch, baseRef);
1122
+ async worktree(repo, branch, baseRef) {
1123
+ const worktree = await createWorktree(repo, branch, baseRef);
1124
+ this.journalRoots.set(repo, repo);
1125
+ this.journalRoots.set(worktree, repo);
1126
+ return worktree;
893
1127
  }
894
1128
  }