tickmarkr 1.71.0 → 1.73.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 (53) hide show
  1. package/dist/adapters/codex.js +13 -3
  2. package/dist/cli/commands/approve.js +21 -3
  3. package/dist/cli/commands/fleet.d.ts +2 -0
  4. package/dist/cli/commands/fleet.js +212 -449
  5. package/dist/cli/commands/resume.js +10 -2
  6. package/dist/cli/commands/status.d.ts +2 -0
  7. package/dist/cli/commands/status.js +219 -30
  8. package/dist/cli/commands/ui.d.ts +6 -2
  9. package/dist/cli/commands/ui.js +2 -4
  10. package/dist/config/config.d.ts +4 -0
  11. package/dist/config/config.js +6 -1
  12. package/dist/drivers/herdr.d.ts +6 -0
  13. package/dist/drivers/herdr.js +169 -71
  14. package/dist/drivers/subprocess.d.ts +3 -3
  15. package/dist/drivers/subprocess.js +10 -5
  16. package/dist/gates/acceptance.js +41 -17
  17. package/dist/route/preference.d.ts +2 -1
  18. package/dist/route/preference.js +8 -2
  19. package/dist/run/daemon.d.ts +1 -0
  20. package/dist/run/daemon.js +139 -4
  21. package/dist/run/git.d.ts +2 -0
  22. package/dist/run/git.js +55 -0
  23. package/dist/run/journal.d.ts +6 -1
  24. package/dist/run/journal.js +28 -1
  25. package/dist/tui/ink/components.d.ts +38 -0
  26. package/dist/tui/ink/components.js +42 -0
  27. package/dist/tui/ink/fleet-app.d.ts +113 -0
  28. package/dist/tui/ink/fleet-app.js +678 -0
  29. package/dist/tui/ink/studio-app.d.ts +59 -0
  30. package/dist/tui/ink/studio-app.js +320 -0
  31. package/package.json +4 -1
  32. package/dist/tui/app.d.ts +0 -72
  33. package/dist/tui/app.js +0 -334
  34. package/dist/tui/engine.d.ts +0 -52
  35. package/dist/tui/engine.js +0 -92
  36. package/dist/tui/frame.d.ts +0 -22
  37. package/dist/tui/frame.js +0 -56
  38. package/dist/tui/input.d.ts +0 -41
  39. package/dist/tui/input.js +0 -100
  40. package/dist/tui/views/consult-dossier.d.ts +0 -49
  41. package/dist/tui/views/consult-dossier.js +0 -169
  42. package/dist/tui/views/diff-modal.d.ts +0 -9
  43. package/dist/tui/views/diff-modal.js +0 -19
  44. package/dist/tui/views/fleet-view.d.ts +0 -91
  45. package/dist/tui/views/fleet-view.js +0 -337
  46. package/dist/tui/views/preview-view.d.ts +0 -7
  47. package/dist/tui/views/preview-view.js +0 -80
  48. package/dist/tui/views/profile-view.d.ts +0 -15
  49. package/dist/tui/views/profile-view.js +0 -93
  50. package/dist/tui/views/routing-view.d.ts +0 -48
  51. package/dist/tui/views/routing-view.js +0 -234
  52. package/dist/tui/views/runs-view.d.ts +0 -70
  53. package/dist/tui/views/runs-view.js +0 -387
@@ -25,8 +25,11 @@ export class HerdrDriver {
25
25
  // grouped slot()/close() mutate shared group state across awaits — serialize them so two
26
26
  // concurrent first members can never both create the group tab (mergeSerial idiom, daemon.ts)
27
27
  groupSerial = Promise.resolve();
28
- // OBS-119: concurrent deliveries contend on herdr's send path — one chain per driver (mergeSerial idiom)
28
+ // OBS-120: canonical dispatch slots hold this chain from allocation through verified binding and
29
+ // delivery. Legacy/manual slots still serialize each delivery on the same chain (OBS-119).
29
30
  deliverySerial = Promise.resolve();
31
+ dispatchLeases = new WeakMap();
32
+ deliveredPanes = new WeakMap();
30
33
  // VIS-10: the run's workspace id, captured once at construction (the daemon inherits it from the
31
34
  // operator's env before the driver is built). Required at slot() time, never in the constructor —
32
35
  // pickDriver and its unit test construct HerdrDriver without env, so slot() is the trust gate.
@@ -47,6 +50,41 @@ export class HerdrDriver {
47
50
  this.deliverySerial = p.catch(() => undefined);
48
51
  return p;
49
52
  }
53
+ // OBS-120: ExecutorDriver exposes slot() and run() separately, so a canonical tickmarkr slot
54
+ // carries a lease across that API seam. The next dispatch cannot allocate until this slot's first
55
+ // run either delivers or fails. Allocation errors release immediately; no failed dispatch poisons
56
+ // the queue. The daemon calls run() directly after preparing the command for every owned slot.
57
+ reserveDispatch(allocate) {
58
+ let release;
59
+ const held = new Promise((resolve) => { release = resolve; });
60
+ const start = this.deliverySerial.then(() => undefined, () => undefined);
61
+ this.deliverySerial = start.then(() => held);
62
+ return start.then(async () => {
63
+ let slot;
64
+ try {
65
+ slot = await allocate();
66
+ this.dispatchLeases.set(slot, { release });
67
+ return slot;
68
+ }
69
+ catch (error) {
70
+ if (slot) {
71
+ try {
72
+ await this.close(slot);
73
+ }
74
+ catch { /* allocation already failed closed; cleanup is best-effort */ }
75
+ }
76
+ release();
77
+ throw error;
78
+ }
79
+ });
80
+ }
81
+ async verifyPaneIdentityBinding(slot) {
82
+ const paneId = await this.namedPaneId(slot.name);
83
+ if (paneId !== slot.id) {
84
+ throw new Error(`herdr pane identity binding verification failed for ${slot.name}: allocated ${slot.id}, resolved ${paneId ?? "none"} — refusing delivery`);
85
+ }
86
+ return paneId;
87
+ }
50
88
  // ponytail: narrow panes hard-wrap the input line — collapse whitespace before comparing.
51
89
  deliveryMatches(transcript, cmd) {
52
90
  const norm = (s) => s.replace(/\s+/g, "");
@@ -60,20 +98,30 @@ export class HerdrDriver {
60
98
  herdr(args, cwd = process.cwd(), timeoutMs) {
61
99
  return sh(`${shq(this.bin)} ${args}`, cwd, timeoutMs);
62
100
  }
101
+ // herdr 0.7.5 (OBS-123): an "agent" is a DETECTED agent-CLI, so `agent get <name>` no longer resolves
102
+ // a tickmarkr-named bash pane. Durable identity is the pane LABEL (set via `pane rename`); resolve it
103
+ // by reading the label back from `pane list`.
63
104
  async namedPaneId(name) {
64
- const r = await this.herdr(`agent get ${shq(name)}`);
105
+ const r = await this.herdr(`pane list`);
65
106
  if (r.code !== 0)
66
107
  return null;
67
108
  try {
68
- const id = JSON.parse(r.stdout).result?.agent?.pane_id;
69
- return typeof id === "string" && id ? id : null;
109
+ const panes = JSON.parse(r.stdout).result?.panes;
110
+ if (!Array.isArray(panes))
111
+ return null;
112
+ const hit = panes.find((p) => p.label === name && typeof p.pane_id === "string" && p.pane_id);
113
+ return hit?.pane_id ?? null;
70
114
  }
71
115
  catch {
72
116
  return null;
73
117
  }
74
118
  }
75
- // pane ids compact when panes close — resolve fresh via the durable agent name (spec §5)
119
+ // Before delivery, resolve fresh via the durable name because pane ids can compact. After delivery,
120
+ // pin the verified target so early liveness cannot drift to a label rebound onto another pane.
76
121
  async paneId(slot) {
122
+ const delivered = this.deliveredPanes.get(slot);
123
+ if (delivered)
124
+ return delivered;
77
125
  return await this.namedPaneId(slot.name) ?? slot.id;
78
126
  }
79
127
  // VIS-09 P43-03: runtime width for the layout gate (43-MEASUREMENT.md licensing condition 2).
@@ -98,12 +146,18 @@ export class HerdrDriver {
98
146
  // reconcile.ts and this driver's own renameGroupTab/glyphFor decode role/taskId/attempt from
99
147
  // those shapes without a call-site migration; T2 retires this branch by always passing `owned`.
100
148
  const resolved = opts?.owned ? formatOwnedName(opts.owned) : name;
149
+ const allocate = opts?.group
150
+ ? () => this.serial(() => this.groupSlot(cwd, resolved, opts.group))
151
+ : () => this.tabSlot(cwd, resolved, opts?.label);
152
+ // Production dispatch names are canonical even when the gate call site supplies the already-
153
+ // formatted name rather than SlotOpts.owned. Hold one lease across slot() → run(); legacy/manual
154
+ // slots retain their existing allocation-only semantics for compatibility.
155
+ if (parseOwnedName(resolved))
156
+ return this.reserveDispatch(allocate);
101
157
  // group wins if both are set (a group tab is already stage-labeled; passing both is a caller bug).
102
158
  // label (without group) → dedicated labeled tab via tabSlot's third param: no groups-map entry, no
103
159
  // refcount, no groupSerial, no degrade latch — dedicated tabs have no shared state to guard (SUP-01).
104
- if (opts?.group)
105
- return this.serial(() => this.groupSlot(cwd, resolved, opts.group));
106
- return this.tabSlot(cwd, resolved, opts?.label); // label undefined → defaults to name (today's behavior)
160
+ return allocate(); // label undefined → defaults to name (today's behavior)
107
161
  }
108
162
  // today's per-slot tab path, plus the VIS-04 orphan reap
109
163
  // label defaults to the slot name; group tabs pass the STAGE name instead — a first-member label
@@ -120,7 +174,11 @@ export class HerdrDriver {
120
174
  throw new Error("herdr placement requires HERDR_WORKSPACE_ID — refusing untargeted pane (VIS-10: fail closed, never place by focus)");
121
175
  // pin the tab to the RUN's workspace, UNCONDITIONALLY (inherited via HERDR_WORKSPACE_ID), never the
122
176
  // operator's focused one (Intl-Dossier run-20260709-104447 incident: worker tabs opened in the tickmarkr repo workspace)
123
- const t = await this.herdr(`tab create --label ${shq(label)} --no-focus --workspace ${shq(this.ws)}`);
177
+ // herdr 0.7.5 (OBS-123): `tab create --cwd` spawns the tab's root shell pane already at a prompt in
178
+ // the worktree — that root pane IS the worker pane. The old one-shot `agent start … -- bash` verb
179
+ // (which named a fresh bash pane) was removed; 0.7.5's `agent start` only ATTACHES to a DETECTED
180
+ // agent CLI, so tickmarkr names the bash pane itself via `pane rename` and types the worker command in.
181
+ const t = await this.herdr(`tab create --label ${shq(label)} --no-focus --workspace ${shq(this.ws)} --cwd ${shq(cwd)}`);
124
182
  if (t.code !== 0)
125
183
  throw new Error(`herdr tab create failed (exit ${t.code}, refusing untargeted placement): ${t.stderr || t.stdout}`);
126
184
  let res;
@@ -131,43 +189,27 @@ export class HerdrDriver {
131
189
  throw new Error(`herdr tab create returned unparseable JSON (refusing untargeted placement): ${t.stdout}`);
132
190
  }
133
191
  const tabId = res?.tab?.tab_id;
134
- const rootPane = res?.root_pane?.pane_id; // auto-created shell pane (SKILL:343)
192
+ const id = res?.root_pane?.pane_id; // the tab's root shell pane, already in cwd — this run's worker pane
135
193
  if (typeof tabId !== "string" || !tabId)
136
194
  throw new Error(`herdr tab create returned no tab_id (refusing untargeted placement): ${t.stdout}`);
195
+ if (typeof id !== "string" || !id)
196
+ throw new Error(`herdr tab create returned no root pane id (refusing untargeted placement): ${t.stdout}`);
137
197
  // T5: the banner's pane identity line, derived from the T1 owned name (legacy names pass through).
138
198
  const identity = shq(paneIdentityLine(canonicalizeLegacyName(name, "")));
139
- let r = await this.herdr(`agent start ${shq(name)} --cwd ${shq(cwd)} --tab ${shq(tabId)} --no-focus -- bash`);
140
- if (r.code !== 0 && /agent_name_taken/.test(r.stderr + r.stdout)) {
141
- // DEFECT-01: a prior (killed) process's kept pane still holds this durable name — reclaim it.
142
- // The old attempt is void by construction: its worktree was rm -rf'd on re-dispatch (git.ts:34),
143
- // so adoption is meaningless; close the stale PANE only (never its tab — sibling prior-run panes
144
- // may still be under the operator's eye) and restart. Match BOTH streams (herdr's error-stream
145
- // convention varies — Pitfall 6); an unrelated nonzero exit must never reach pane close (T-10-04).
146
- const g = await this.herdr(`agent get ${shq(name)}`);
147
- try {
148
- const stale = JSON.parse(g.stdout).result?.agent?.pane_id;
149
- if (typeof stale === "string" && stale)
150
- await this.herdr(`pane close ${shq(stale)}`);
151
- }
152
- catch {
153
- /* holder vanished between calls (agent_not_found) — retry the start regardless */
154
- }
155
- r = await this.herdr(`agent start ${shq(name)} --cwd ${shq(cwd)} --tab ${shq(tabId)} --no-focus -- bash`); // once
156
- }
157
- if (r.code !== 0)
158
- throw new Error(`herdr agent start failed: ${r.stderr || r.stdout}`); // fail closed, no loop
159
- const id = JSON.parse(r.stdout).result?.agent?.pane_id;
160
- if (typeof id !== "string" || !id)
161
- throw new Error(`herdr agent start returned no pane id: ${r.stdout}`);
162
- // tab create auto-spawns a root shell pane and agent start --tab adds the agent as a SECOND
163
- // pane — reap the idle shell so no tab shows a dead "tickmarkr git:" prompt beside its agent
164
- // (VIS-04 orphan fix). Best-effort: a failed reap costs cosmetics only.
165
- if (typeof rootPane === "string" && rootPane && rootPane !== id) {
166
- await this.herdr(`pane close ${shq(rootPane)}`);
167
- }
199
+ // durable identity: label the PANE (resolution + reconcile read it back via `pane list`); fail closed
200
+ // and reap the tab we just made if the rename fails (no orphan tab).
201
+ const rn = await this.herdr(`pane rename ${shq(id)} ${shq(name)}`);
202
+ if (rn.code !== 0) {
203
+ await this.herdr(`tab close ${shq(tabId)}`);
204
+ throw new Error(`herdr pane rename failed: ${rn.stderr || rn.stdout}`);
205
+ }
206
+ // DEFECT-01: a prior (killed) process's kept pane may still carry this durable label (resume +
207
+ // keepPanes re-dispatches at attempt=0 into the same name). 0.7.5 rename never collides, so instead
208
+ // of agent_name_taken we sweep stale same-label panes and verify this fresh pane is the sole holder.
209
+ await this.reclaimStaleLabel(name, id);
168
210
  // VIS-10 hole 3: seed the run's workspace id into the agent pane's shell so a worker's own ad-hoc
169
- // `herdr agent start` is workspace-targeted BY CONSTRUCTION — correct placement is the default a
170
- // worker GETS, not a rule it must remember (P40-02 probe leak). Fail closed: a failed seed rejects.
211
+ // `herdr` call is workspace-targeted BY CONSTRUCTION — correct placement is the default a worker GETS,
212
+ // not a rule it must remember (P40-02 probe leak). Fail closed: a failed seed rejects.
171
213
  // v1.22 T3 / OBS-17: also strip HERDR_ENV + socket path so the worker cannot open/mutate panes in
172
214
  // the operator's session. Daemon-side this.herdr() calls keep process.env (unsealed).
173
215
  const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${identity}; ${herdrSealShellPrefix()}`)}`);
@@ -175,6 +217,32 @@ export class HerdrDriver {
175
217
  throw new Error(`herdr workspace-id seed failed (refusing untargeted pane): ${seed.stderr || seed.stdout}`);
176
218
  return { id, name, cwd, tabId };
177
219
  }
220
+ // DEFECT-01 (0.7.5): close any OTHER pane still carrying this durable label (a prior killed attempt's
221
+ // kept pane), then verify none remain — else fail closed, since `pane list` would otherwise resolve the
222
+ // name to an ambiguous/stale pane. No-op on the common path (no dup labels). Best-effort closes; the
223
+ // post-sweep re-list is the fail-closed gate (a close that does not free the name must reject).
224
+ async reclaimStaleLabel(name, fresh) {
225
+ const stale = await this.staleLabelPanes(name, fresh);
226
+ if (stale.length === 0)
227
+ return;
228
+ for (const p of stale)
229
+ await this.herdr(`pane close ${shq(p)}`);
230
+ if ((await this.staleLabelPanes(name, fresh)).length > 0) {
231
+ throw new Error(`herdr pane rename reclaim failed — durable name ${name} still held by a stale pane`);
232
+ }
233
+ }
234
+ async staleLabelPanes(name, fresh) {
235
+ const r = await this.herdr(`pane list`);
236
+ try {
237
+ const panes = JSON.parse(r.stdout).result?.panes ?? [];
238
+ return panes
239
+ .filter((p) => p.label === name && typeof p.pane_id === "string" && p.pane_id !== fresh)
240
+ .map((p) => p.pane_id);
241
+ }
242
+ catch {
243
+ return [];
244
+ }
245
+ }
178
246
  // VIS-04 role-tab (extended VIS-09 item 2): first member bootstraps generation 1 (WORKERS);
179
247
  // later members join the latest generation with live-member room via joinGroup, else a NEW
180
248
  // overflow generation tab (cleanup, cleanup, …) opens — a second `tab create`, NOT a further
@@ -253,7 +321,7 @@ export class HerdrDriver {
253
321
  // stack down. Rightward splits below the measured floor shred the marker (e8aa003 at COLUMNS≈2;
254
322
  // unrecoverable at 25 cols per measurement). Introspection failure → down (fail closed).
255
323
  const direction = entry.members.length === 1 ? workerSplitDirection(await this.paneWidth(srcPane)) : "down";
256
- const sp = await this.herdr(`pane split ${shq(srcPane)} --direction ${direction} --no-focus`);
324
+ const sp = await this.herdr(`pane split ${shq(srcPane)} --direction ${direction} --no-focus --cwd ${shq(cwd)}`);
257
325
  if (sp.code !== 0)
258
326
  return null;
259
327
  let pane;
@@ -265,13 +333,13 @@ export class HerdrDriver {
265
333
  }
266
334
  if (typeof pane !== "string" || !pane)
267
335
  return null;
268
- // the split pane is a bare shell: give it a durable agent name (SKILL:197) and VERIFY the name
269
- // resolves back to this pane (research A1 is checked live per join, never assumed) — then cd the
270
- // shell into this member's own worktree (a split inherits its parent's cwd, not ours).
271
- const rn = await this.herdr(`agent rename ${shq(pane)} ${shq(name)}`);
336
+ // the split pane is a bare shell: give it a durable PANE label (0.7.5 — agent rename only binds
337
+ // detected agents; SKILL:197 was the old agent-name path) and VERIFY the label resolves back to this
338
+ // pane (research A1 is checked live per join, never assumed). `pane split --cwd` already placed the
339
+ // shell in this member's worktree, so no separate `cd` is needed.
340
+ const rn = await this.herdr(`pane rename ${shq(pane)} ${shq(name)}`);
272
341
  const verified = rn.code === 0 && (await this.paneId({ id: "", name, cwd })) === pane;
273
- const cd = verified ? await this.herdr(`pane run ${shq(pane)} ${shq(`cd ${shq(cwd)}`)}`) : null;
274
- if (!cd || cd.code !== 0) {
342
+ if (!verified) {
275
343
  await this.herdr(`pane close ${shq(pane)}`); // reap the failed join, best-effort
276
344
  return null;
277
345
  }
@@ -300,10 +368,26 @@ export class HerdrDriver {
300
368
  // cleared (C-u), and retyped, bounded; persistent corruption fails closed WITH the captured
301
369
  // transcript — the dispatch-time pincer the ledger asks for, not post-hoc `git:` archaeology.
302
370
  async run(slot, cmd) {
303
- return this.deliveryQueue(() => this.deliver(slot, cmd));
371
+ const lease = this.dispatchLeases.get(slot);
372
+ if (lease) {
373
+ this.dispatchLeases.delete(slot);
374
+ try {
375
+ const paneId = await this.verifyPaneIdentityBinding(slot);
376
+ await this.deliver(slot, cmd, paneId);
377
+ this.deliveredPanes.set(slot, paneId);
378
+ }
379
+ finally {
380
+ lease.release();
381
+ }
382
+ return;
383
+ }
384
+ return this.deliveryQueue(async () => {
385
+ const paneId = await this.paneId(slot);
386
+ await this.deliver(slot, cmd, paneId);
387
+ this.deliveredPanes.set(slot, paneId);
388
+ });
304
389
  }
305
- async deliver(slot, cmd) {
306
- const pane = await this.paneId(slot);
390
+ async deliver(slot, cmd, pane) {
307
391
  let transcript = "";
308
392
  for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
309
393
  if (attempt > 0) {
@@ -316,7 +400,7 @@ export class HerdrDriver {
316
400
  const typed = await this.herdr(`pane send-text ${shq(pane)} ${shq(cmd)}`, slot.cwd);
317
401
  if (typed.code !== 0)
318
402
  throw new Error(`herdr pane send-text failed: ${typed.stderr || typed.stdout}`);
319
- const back = await this.herdr(`wait output ${shq(pane)} --match ${shq(cmd)} --timeout ${DELIVERY_VERIFY_TIMEOUT_MS}`, slot.cwd, DELIVERY_VERIFY_TIMEOUT_MS + 15_000);
403
+ 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);
320
404
  if (this.waitOk(back.code, back.stdout) || await this.deliveryReadMatches(pane, cmd, slot.cwd)) {
321
405
  const enter = await this.herdr(`pane send-keys ${shq(pane)} Enter`, slot.cwd);
322
406
  if (enter.code !== 0)
@@ -344,12 +428,19 @@ export class HerdrDriver {
344
428
  }
345
429
  async waitOutput(slot, pattern, timeoutMs, opts) {
346
430
  const pane = await this.paneId(slot);
347
- const r = await this.herdr(`wait output ${shq(pane)} --match ${shq(pattern)}${opts?.regex ? " --regex" : ""} --timeout ${Math.floor(timeoutMs)}`, slot.cwd, timeoutMs + 15_000);
431
+ // 0.7.5: `--match` (literal) and `--regex` (pattern) are MUTUALLY EXCLUSIVE — the old
432
+ // `--match <p> --regex` form is rejected ("mutually exclusive"), which made the exit-marker wait
433
+ // error instantly instead of waiting, so LLM-gate/consult verdicts were read before they rendered
434
+ // and came back unparseable. Pick exactly one flag by whether the caller wants regex.
435
+ const r = await this.herdr(`pane wait-output ${shq(pane)} ${opts?.regex ? "--regex" : "--match"} ${shq(pattern)} --timeout ${Math.floor(timeoutMs)}`, slot.cwd, timeoutMs + 15_000);
348
436
  return this.waitOk(r.code, r.stdout); // dead pane: exit 0 + top-level error envelope (herdr bite)
349
437
  }
350
438
  async waitAgentStatus(slot, status, timeoutMs) {
351
439
  const pane = await this.paneId(slot);
352
- const r = await this.herdr(`wait agent-status ${shq(pane)} --status ${shq(status)} --timeout ${Math.floor(timeoutMs)}`, slot.cwd, timeoutMs + 15_000);
440
+ // 0.7.5: `wait agent-status` (top-level) was removed; `agent wait <target> --until <status>` replaces
441
+ // it. Target is the pane id; when herdr has detected the worker's agent CLI there this resolves, else
442
+ // it returns an error envelope → waitOk false (a best-effort settle, same as the old timeout path).
443
+ const r = await this.herdr(`agent wait ${shq(pane)} --until ${shq(status)} --timeout ${Math.floor(timeoutMs)}`, slot.cwd, timeoutMs + 15_000);
353
444
  return this.waitOk(r.code, r.stdout);
354
445
  }
355
446
  async status(slot) {
@@ -358,9 +449,13 @@ export class HerdrDriver {
358
449
  // shared by status() and the VIS-13 blocked glyph (renameGroupTab): resolve the live agent_status
359
450
  // by durable name; "unknown" on any failure (dead pane, unparseable json) — never throws.
360
451
  async statusByName(name) {
361
- const r = await this.herdr(`agent get ${shq(name)}`);
452
+ // 0.7.5: read the pane's herdr-detected agent_status off `pane list` (keyed by the durable label);
453
+ // "unknown" on any failure (no such label, unparseable json) — never throws.
454
+ const r = await this.herdr(`pane list`);
362
455
  try {
363
- const s = JSON.parse(r.stdout).result?.agent?.agent_status;
456
+ const panes = JSON.parse(r.stdout).result?.panes;
457
+ const hit = Array.isArray(panes) ? panes.find((p) => p.label === name) : undefined;
458
+ const s = hit?.agent_status;
364
459
  return typeof s === "string" ? s : "unknown";
365
460
  }
366
461
  catch {
@@ -428,21 +523,21 @@ export class HerdrDriver {
428
523
  async priorWatch(runId) {
429
524
  if (!this.ws)
430
525
  throw new Error("herdr watch placement requires HERDR_WORKSPACE_ID — refusing unseeded pane");
431
- const list = await this.herdr("agent list");
526
+ const list = await this.herdr("pane list");
432
527
  if (list.code !== 0)
433
- throw new Error(`herdr agent list failed: ${list.stderr || list.stdout}`);
434
- let agents;
528
+ throw new Error(`herdr pane list failed: ${list.stderr || list.stdout}`);
529
+ let panes;
435
530
  try {
436
- agents = JSON.parse(list.stdout).result?.agents;
531
+ panes = JSON.parse(list.stdout).result?.panes;
437
532
  }
438
533
  catch {
439
- throw new Error(`herdr agent list returned unparseable JSON: ${list.stdout}`);
534
+ throw new Error(`herdr pane list returned unparseable JSON: ${list.stdout}`);
440
535
  }
441
- if (!Array.isArray(agents))
442
- throw new Error(`herdr agent list returned no agents: ${list.stdout}`);
443
- const prior = agents.find((a) => {
444
- const owned = typeof a.name === "string" ? parseOwnedName(a.name) : null;
445
- return a.workspace_id === this.ws && typeof a.pane_id === "string" && owned?.role === "watch" && owned.taskId === "run" && owned.runId !== runId;
536
+ if (!Array.isArray(panes))
537
+ throw new Error(`herdr pane list returned no panes: ${list.stdout}`);
538
+ const prior = panes.find((p) => {
539
+ const owned = typeof p.label === "string" ? parseOwnedName(p.label) : null;
540
+ return p.workspace_id === this.ws && typeof p.pane_id === "string" && owned?.role === "watch" && owned.taskId === "run" && owned.runId !== runId;
446
541
  });
447
542
  return prior?.pane_id ?? null;
448
543
  }
@@ -465,7 +560,7 @@ export class HerdrDriver {
465
560
  }
466
561
  if (typeof pane !== "string" || !pane)
467
562
  throw new Error(`herdr watch split returned no pane id: ${sp.stdout}`);
468
- const renamed = await this.herdr(`agent rename ${shq(pane)} ${shq(name)}`);
563
+ const renamed = await this.herdr(`pane rename ${shq(pane)} ${shq(name)}`);
469
564
  if (renamed.code !== 0 || await this.namedPaneId(name) !== pane) {
470
565
  await this.herdr(`pane close ${shq(pane)}`);
471
566
  throw new Error(`herdr watch rename failed: ${renamed.stderr || renamed.stdout}`);
@@ -495,7 +590,7 @@ export class HerdrDriver {
495
590
  }
496
591
  const prior = runId ? await this.priorWatch(runId) : null;
497
592
  if (prior) {
498
- const renamed = await this.herdr(`agent rename ${shq(prior)} ${shq(name)}`);
593
+ const renamed = await this.herdr(`pane rename ${shq(prior)} ${shq(name)}`);
499
594
  if (renamed.code !== 0 || await this.namedPaneId(name) !== prior) {
500
595
  throw new Error(`herdr watch reclaim failed: ${renamed.stderr || renamed.stdout}`);
501
596
  }
@@ -528,9 +623,12 @@ export class HerdrDriver {
528
623
  try {
529
624
  if (!this.ws)
530
625
  return;
531
- const list = await this.herdr("agent list");
532
- const agents = JSON.parse(list.stdout).result?.agents ?? [];
533
- const toClose = panesToClose(agents.map((a) => ({ name: a.name, paneId: a.pane_id, tabId: a.tab_id, workspaceId: a.workspace_id })), desired, this.ws, runId, opts);
626
+ // 0.7.5: enumerate owned panes from `pane list` (labels), not `agent list` (detected agents only).
627
+ // panesToClose skips any pane whose label doesn't parse as tickmarkr-owned (orchestrator/operator
628
+ // shells, undetected agents), so a fuller pane listing never widens the blast radius.
629
+ const list = await this.herdr("pane list");
630
+ const panes = JSON.parse(list.stdout).result?.panes ?? [];
631
+ const toClose = panesToClose(panes.map((p) => ({ name: p.label, paneId: p.pane_id, tabId: p.tab_id, workspaceId: p.workspace_id })), desired, this.ws, runId, opts);
534
632
  const touched = new Set();
535
633
  for (const c of toClose) {
536
634
  if (typeof c.tabId === "string")
@@ -1,10 +1,10 @@
1
1
  import type { ExecutorDriver, NotifyOpts, Slot } from "./types.js";
2
2
  export declare const MAX_BUF: number;
3
3
  export declare const HERDR_CONTROL_VARS: readonly ["HERDR_ENV", "HERDR_SOCKET_PATH"];
4
- /** Copy of env with herdr control-plane vars stripped. Daemon process.env is left alone. */
4
+ /** Copy of worker env with the fork cap applied and herdr control-plane vars stripped. */
5
5
  export declare function sealHerdrEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
6
- /** Shell prefix that unsets herdr control vars in a pane/login shell (herdr seed + daemon setup). */
7
- export declare function herdrSealShellPrefix(): string;
6
+ /** Pane/login-shell form of the same worker env seal (herdr seed + daemon setup). */
7
+ export declare function herdrSealShellPrefix(env?: NodeJS.ProcessEnv): string;
8
8
  export declare class SubprocessDriver implements ExecutorDriver {
9
9
  id: string;
10
10
  interactive: boolean;
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
- import { createWorktree } from "../run/git.js";
2
+ import { shq } from "../adapters/types.js";
3
+ import { createWorktree, DEFAULT_FORK_CAP, FORK_CAP_ENV } from "../run/git.js";
3
4
  // HARD-03: cap retained worker output so a chatty worker can't grow tickmarkr's heap unbounded.
4
5
  // 2MB is safe against BOTH consumers: the largest read() call site asks for 1000 lines
5
6
  // (daemon.ts:217/247), and every marker (TICKMARKR_RESULT, TICKMARKR_EXIT_<nonce>) is emitted at the
@@ -12,16 +13,20 @@ export const MAX_BUF = 2 * 1024 * 1024;
12
13
  // (and its own herdr driver CLI calls) keep the live session. Socket path is the wire; HERDR_ENV
13
14
  // is the "I am inside herdr" gate every agent skill checks before mutating panes.
14
15
  export const HERDR_CONTROL_VARS = ["HERDR_ENV", "HERDR_SOCKET_PATH"];
15
- /** Copy of env with herdr control-plane vars stripped. Daemon process.env is left alone. */
16
+ /** Copy of worker env with the fork cap applied and herdr control-plane vars stripped. */
16
17
  export function sealHerdrEnv(env = process.env) {
17
18
  const out = { ...env };
19
+ if (!(FORK_CAP_ENV in out))
20
+ out[FORK_CAP_ENV] = DEFAULT_FORK_CAP;
18
21
  for (const k of HERDR_CONTROL_VARS)
19
22
  delete out[k];
20
23
  return out;
21
24
  }
22
- /** Shell prefix that unsets herdr control vars in a pane/login shell (herdr seed + daemon setup). */
23
- export function herdrSealShellPrefix() {
24
- return HERDR_CONTROL_VARS.map((k) => `unset ${k}`).join("; ") + "; ";
25
+ /** Pane/login-shell form of the same worker env seal (herdr seed + daemon setup). */
26
+ export function herdrSealShellPrefix(env = process.env) {
27
+ const forkCap = sealHerdrEnv(env)[FORK_CAP_ENV] ?? DEFAULT_FORK_CAP;
28
+ return `export ${FORK_CAP_ENV}=${shq(forkCap)}; ` +
29
+ HERDR_CONTROL_VARS.map((k) => `unset ${k}`).join("; ") + "; ";
25
30
  }
26
31
  export class SubprocessDriver {
27
32
  id = "subprocess";
@@ -104,10 +104,15 @@ function tail(out, n = 8) {
104
104
  return "";
105
105
  return "\n" + t.split("\n").slice(-n).join("\n");
106
106
  }
107
- // v1.70: the new-file line numbers each hunk actually ADDS, per changed path — parsed from the
108
- // unified-diff hunk headers so a citation is validated against real changed locations, not a substring
109
- // of the whole diff text (which also matches unchanged context lines and the diff's own +++/@@ headers).
110
- // Only `+` lines are changed locations; context lines advance the new-file counter but are not changes.
107
+ // v1.70 / OBS-129: the new-file line numbers each changed HUNK spans, per path — parsed from the
108
+ // unified-diff so a citation is validated against real changed regions, not a substring of the whole
109
+ // diff text (which also matches the diff's own +++/@@ headers). A hunk's span is every new-file line it
110
+ // carries: the added (`+`) lines AND the context lines git includes around them.
111
+ // OBS-129: this is a changed-HUNK check, not an exact-added-LINE check. Requiring the exact `+` line made
112
+ // honest judges fail closed on correct work — two frontier judges cited an adjacent line of the same
113
+ // change (LLMs miscount exact line numbers), voiding the whole verdict. The anti-hallucination property
114
+ // holds: a citation outside every hunk, or to an untouched file, still fails (matching the documented
115
+ // "outside every changed hunk → rejected" contract in acceptance.test.ts).
111
116
  // ponytail: assumes git's default a/ b/ prefixes (fetchTaskDiff uses plain `git diff`); revisit if a
112
117
  // caller passes a --no-prefix diff.
113
118
  function changedLinesByFile(diff) {
@@ -137,7 +142,9 @@ function changedLinesByFile(diff) {
137
142
  if (!inHunk || path === null)
138
143
  continue;
139
144
  const c = raw[0];
140
- if (c === "+") {
145
+ if (c === "+" || c === " ") {
146
+ // OBS-129: record every new-file line the hunk spans — added AND context — so a citation to an
147
+ // adjacent line of the same change validates, not only the exact `+` line.
141
148
  let set = byFile.get(path);
142
149
  if (!set) {
143
150
  set = new Set();
@@ -146,17 +153,28 @@ function changedLinesByFile(diff) {
146
153
  set.add(newLine);
147
154
  newLine++;
148
155
  }
149
- else if (c === " ") {
150
- newLine++;
151
- }
152
156
  else if (c !== "-") {
153
157
  inHunk = false; // "\ No newline", a trailing blank, or the next section — hunk body ended
154
158
  }
155
159
  }
156
160
  return byFile;
157
161
  }
158
- // A citation is valid evidence iff the diff adds the cited line of the cited file. A legacy free-text
159
- // quote (string) keeps v1.64's substring check — non-empty and present somewhere in the diff.
162
+ // Compact a sorted set of line numbers into "3, 7-9, 14" form for the judge's citable-lines index.
163
+ function compressLines(lines) {
164
+ const sorted = [...new Set(lines)].sort((a, b) => a - b);
165
+ const parts = [];
166
+ for (let i = 0; i < sorted.length;) {
167
+ let j = i;
168
+ while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1)
169
+ j++;
170
+ parts.push(i === j ? `${sorted[i]}` : `${sorted[i]}-${sorted[j]}`);
171
+ i = j + 1;
172
+ }
173
+ return parts.join(", ");
174
+ }
175
+ // A citation is valid evidence iff the cited line falls inside a changed hunk of the cited file (OBS-129:
176
+ // changed-hunk span, not exact-added-line). A legacy free-text quote (string) keeps v1.64's substring
177
+ // check — non-empty and present somewhere in the diff.
160
178
  function citesChangedLocation(evidence, changed, diff) {
161
179
  if (typeof evidence === "string")
162
180
  return evidence.trim().length > 0 && diff.includes(evidence);
@@ -213,6 +231,10 @@ export async function acceptanceGate(task, worktree, baseRef, judge, via, opts =
213
231
  if (capFail)
214
232
  return capFail;
215
233
  const expectedIds = judgeItems.map((_, index) => judgeCriterionId(index));
234
+ // OBS-129: give the judge the exact citable new-file line numbers per changed file, so it grounds each
235
+ // citation in a real changed line instead of miscounting. Validated below against this same `changed`.
236
+ const changed = changedLinesByFile(diff);
237
+ const citable = [...changed.entries()].map(([p, set]) => `- ${p}: ${compressLines([...set])}`).join("\n");
216
238
  const nonce = generateVerdictNonce();
217
239
  const prompt = `TICKMARKR-JUDGE
218
240
  You are a strict acceptance judge. Decide whether the diff satisfies EVERY acceptance criterion.
@@ -232,12 +254,15 @@ ${judgeItems.map((a, index) => `- ${renderJudgeCriterion(a, index)}`).join("\n")
232
254
  ${diff}
233
255
  \`\`\`
234
256
 
257
+ ## Citable evidence lines (new-file line numbers inside the changed hunks above)
258
+ ${citable || "(the diff changes no lines)"}
259
+
235
260
  ${verdictNonceLine(nonce)}
236
261
 
237
262
  Respond with ONLY this JSON (no prose before or after):
238
263
  {"nonce": "${nonce}", "pass": true|false, "criteria": [{"criterion": "c1", "met": true|false, "reason": "...", "evidence": {"path": "path/to/file", "line": 42}}]}
239
264
  Each criteria[].criterion MUST be the stable id from the rubric (c1, c2, ...) exactly once.
240
- Each criteria[].evidence MUST be a structured citation {"path", "line"} pointing at a line the diff above actually adds or changes (the new-file line number); a citation to an unchanged or nonexistent location voids the whole verdict.
265
+ Each criteria[].evidence MUST be a structured citation {"path", "line"} whose "path" and "line" appear in the "Citable evidence lines" list above (a new-file line number inside a changed hunk); a citation outside every changed hunk or to an untouched file voids the whole verdict.
241
266
  `;
242
267
  const raw = await runLlm(judge.adapter, judge.model, prompt, worktree, via, JUDGE_TIMEOUT_MS);
243
268
  const extracted = extractVerdictJson(raw, nonce);
@@ -250,12 +275,11 @@ Each criteria[].evidence MUST be a structured citation {"path", "line"} pointing
250
275
  meta: { unparseable: true, judge: channelKey({ adapter: judge.adapter.id, model: judge.model }) } };
251
276
  }
252
277
  const { verdict: v, inconsistencies } = checkJudgeVerdict(extracted, expectedIds);
253
- // v1.70: each citation must point at a line the diff actually changed — validated against the hunks
254
- // of `diff` (the exact string embedded in the prompt above), never the worktree or any other artifact.
255
- // A citation to an untouched file or a line outside every changed hunk is a hallucinated verdict:
256
- // treated as unparseable so GATE-09 retries the judge on a failover channel. (A legacy free-text quote
257
- // still validates by substring, for the fake seam and pre-v1.70 fixtures.)
258
- const changed = changedLinesByFile(diff);
278
+ // v1.70 / OBS-129: each citation must fall inside a changed hunk of `diff` (the exact string embedded in
279
+ // the prompt above and enumerated in the citable-lines index), never the worktree or any other artifact.
280
+ // A citation to an untouched file or outside every changed hunk is a hallucinated verdict: treated as
281
+ // unparseable so GATE-09 retries the judge on a failover channel. (A legacy free-text quote still
282
+ // validates by substring, for the fake seam and pre-v1.70 fixtures.) `changed` is computed above.
259
283
  const fabricated = v.criteria.filter((row) => !citesChangedLocation(row.evidence, changed, diff));
260
284
  if (fabricated.length) {
261
285
  return { gate: "acceptance", pass: false,
@@ -4,6 +4,7 @@ export interface Disallowed {
4
4
  by: "deny" | "allow";
5
5
  entry: string;
6
6
  }
7
+ export type PreferenceRole = "worker" | "judge" | "review" | "consult";
7
8
  export declare function excludedChannels(cfg: TickmarkrConfig, adapters: {
8
9
  id: string;
9
10
  }[] | string[], health: Record<string, AuthHealth>): {
@@ -24,7 +25,7 @@ export declare function preferRanks(c: {
24
25
  export declare function disallowedBy(c: {
25
26
  adapter: string;
26
27
  model: string;
27
- }, routing: TickmarkrConfig["routing"]): Disallowed | null;
28
+ }, routing: TickmarkrConfig["routing"], role?: PreferenceRole): Disallowed | null;
28
29
  export interface DenyPreferCollision {
29
30
  kind: "prefer" | "pin";
30
31
  shape: string;
@@ -36,10 +36,16 @@ export function preferRanks(c, cfg) {
36
36
  return out;
37
37
  }
38
38
  // entries in either list accept adapter id, model id, or adapter:model (grammar A1)
39
- export function disallowedBy(c, routing) {
39
+ export function disallowedBy(c, routing, role = "worker") {
40
40
  const { allow, deny } = routing;
41
41
  const matches = (e) => e === c.adapter || e === c.model || e === channelKey(c);
42
- const d = [...(deny?.adapters ?? []), ...(deny?.models ?? [])].find(matches);
42
+ const workerDeny = role === "worker" ? deny?.workers : undefined;
43
+ const d = [
44
+ ...(deny?.adapters ?? []),
45
+ ...(deny?.models ?? []),
46
+ ...(workerDeny?.adapters ?? []),
47
+ ...(workerDeny?.models ?? []),
48
+ ].find(matches);
43
49
  if (d)
44
50
  return { by: "deny", entry: d };
45
51
  if (allow) {
@@ -7,6 +7,7 @@ export interface RunOptions {
7
7
  resume?: boolean;
8
8
  supersedes?: string;
9
9
  graphChanged?: boolean;
10
+ retryFailed?: boolean;
10
11
  concurrency?: number;
11
12
  driver?: ExecutorDriver;
12
13
  adapters?: WorkerAdapter[];