tickmarkr 1.71.0 → 1.72.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.
@@ -26,6 +26,8 @@ export declare class HerdrDriver implements ExecutorDriver {
26
26
  private paneWidth;
27
27
  slot(cwd: string, name: string, opts?: SlotOpts): Promise<Slot>;
28
28
  private tabSlot;
29
+ private reclaimStaleLabel;
30
+ private staleLabelPanes;
29
31
  private groupSlot;
30
32
  private newGeneration;
31
33
  private renameGroupTab;
@@ -60,13 +60,19 @@ export class HerdrDriver {
60
60
  herdr(args, cwd = process.cwd(), timeoutMs) {
61
61
  return sh(`${shq(this.bin)} ${args}`, cwd, timeoutMs);
62
62
  }
63
+ // herdr 0.7.5 (OBS-123): an "agent" is a DETECTED agent-CLI, so `agent get <name>` no longer resolves
64
+ // a tickmarkr-named bash pane. Durable identity is the pane LABEL (set via `pane rename`); resolve it
65
+ // by reading the label back from `pane list`.
63
66
  async namedPaneId(name) {
64
- const r = await this.herdr(`agent get ${shq(name)}`);
67
+ const r = await this.herdr(`pane list`);
65
68
  if (r.code !== 0)
66
69
  return null;
67
70
  try {
68
- const id = JSON.parse(r.stdout).result?.agent?.pane_id;
69
- return typeof id === "string" && id ? id : null;
71
+ const panes = JSON.parse(r.stdout).result?.panes;
72
+ if (!Array.isArray(panes))
73
+ return null;
74
+ const hit = panes.find((p) => p.label === name && typeof p.pane_id === "string" && p.pane_id);
75
+ return hit?.pane_id ?? null;
70
76
  }
71
77
  catch {
72
78
  return null;
@@ -120,7 +126,11 @@ export class HerdrDriver {
120
126
  throw new Error("herdr placement requires HERDR_WORKSPACE_ID — refusing untargeted pane (VIS-10: fail closed, never place by focus)");
121
127
  // pin the tab to the RUN's workspace, UNCONDITIONALLY (inherited via HERDR_WORKSPACE_ID), never the
122
128
  // 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)}`);
129
+ // herdr 0.7.5 (OBS-123): `tab create --cwd` spawns the tab's root shell pane already at a prompt in
130
+ // the worktree — that root pane IS the worker pane. The old one-shot `agent start … -- bash` verb
131
+ // (which named a fresh bash pane) was removed; 0.7.5's `agent start` only ATTACHES to a DETECTED
132
+ // agent CLI, so tickmarkr names the bash pane itself via `pane rename` and types the worker command in.
133
+ const t = await this.herdr(`tab create --label ${shq(label)} --no-focus --workspace ${shq(this.ws)} --cwd ${shq(cwd)}`);
124
134
  if (t.code !== 0)
125
135
  throw new Error(`herdr tab create failed (exit ${t.code}, refusing untargeted placement): ${t.stderr || t.stdout}`);
126
136
  let res;
@@ -131,43 +141,27 @@ export class HerdrDriver {
131
141
  throw new Error(`herdr tab create returned unparseable JSON (refusing untargeted placement): ${t.stdout}`);
132
142
  }
133
143
  const tabId = res?.tab?.tab_id;
134
- const rootPane = res?.root_pane?.pane_id; // auto-created shell pane (SKILL:343)
144
+ const id = res?.root_pane?.pane_id; // the tab's root shell pane, already in cwd — this run's worker pane
135
145
  if (typeof tabId !== "string" || !tabId)
136
146
  throw new Error(`herdr tab create returned no tab_id (refusing untargeted placement): ${t.stdout}`);
147
+ if (typeof id !== "string" || !id)
148
+ throw new Error(`herdr tab create returned no root pane id (refusing untargeted placement): ${t.stdout}`);
137
149
  // T5: the banner's pane identity line, derived from the T1 owned name (legacy names pass through).
138
150
  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
- }
151
+ // durable identity: label the PANE (resolution + reconcile read it back via `pane list`); fail closed
152
+ // and reap the tab we just made if the rename fails (no orphan tab).
153
+ const rn = await this.herdr(`pane rename ${shq(id)} ${shq(name)}`);
154
+ if (rn.code !== 0) {
155
+ await this.herdr(`tab close ${shq(tabId)}`);
156
+ throw new Error(`herdr pane rename failed: ${rn.stderr || rn.stdout}`);
157
+ }
158
+ // DEFECT-01: a prior (killed) process's kept pane may still carry this durable label (resume +
159
+ // keepPanes re-dispatches at attempt=0 into the same name). 0.7.5 rename never collides, so instead
160
+ // of agent_name_taken we sweep stale same-label panes and verify this fresh pane is the sole holder.
161
+ await this.reclaimStaleLabel(name, id);
168
162
  // 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.
163
+ // `herdr` call is workspace-targeted BY CONSTRUCTION — correct placement is the default a worker GETS,
164
+ // not a rule it must remember (P40-02 probe leak). Fail closed: a failed seed rejects.
171
165
  // v1.22 T3 / OBS-17: also strip HERDR_ENV + socket path so the worker cannot open/mutate panes in
172
166
  // the operator's session. Daemon-side this.herdr() calls keep process.env (unsealed).
173
167
  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 +169,32 @@ export class HerdrDriver {
175
169
  throw new Error(`herdr workspace-id seed failed (refusing untargeted pane): ${seed.stderr || seed.stdout}`);
176
170
  return { id, name, cwd, tabId };
177
171
  }
172
+ // DEFECT-01 (0.7.5): close any OTHER pane still carrying this durable label (a prior killed attempt's
173
+ // kept pane), then verify none remain — else fail closed, since `pane list` would otherwise resolve the
174
+ // name to an ambiguous/stale pane. No-op on the common path (no dup labels). Best-effort closes; the
175
+ // post-sweep re-list is the fail-closed gate (a close that does not free the name must reject).
176
+ async reclaimStaleLabel(name, fresh) {
177
+ const stale = await this.staleLabelPanes(name, fresh);
178
+ if (stale.length === 0)
179
+ return;
180
+ for (const p of stale)
181
+ await this.herdr(`pane close ${shq(p)}`);
182
+ if ((await this.staleLabelPanes(name, fresh)).length > 0) {
183
+ throw new Error(`herdr pane rename reclaim failed — durable name ${name} still held by a stale pane`);
184
+ }
185
+ }
186
+ async staleLabelPanes(name, fresh) {
187
+ const r = await this.herdr(`pane list`);
188
+ try {
189
+ const panes = JSON.parse(r.stdout).result?.panes ?? [];
190
+ return panes
191
+ .filter((p) => p.label === name && typeof p.pane_id === "string" && p.pane_id !== fresh)
192
+ .map((p) => p.pane_id);
193
+ }
194
+ catch {
195
+ return [];
196
+ }
197
+ }
178
198
  // VIS-04 role-tab (extended VIS-09 item 2): first member bootstraps generation 1 (WORKERS);
179
199
  // later members join the latest generation with live-member room via joinGroup, else a NEW
180
200
  // overflow generation tab (cleanup, cleanup, …) opens — a second `tab create`, NOT a further
@@ -253,7 +273,7 @@ export class HerdrDriver {
253
273
  // stack down. Rightward splits below the measured floor shred the marker (e8aa003 at COLUMNS≈2;
254
274
  // unrecoverable at 25 cols per measurement). Introspection failure → down (fail closed).
255
275
  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`);
276
+ const sp = await this.herdr(`pane split ${shq(srcPane)} --direction ${direction} --no-focus --cwd ${shq(cwd)}`);
257
277
  if (sp.code !== 0)
258
278
  return null;
259
279
  let pane;
@@ -265,13 +285,13 @@ export class HerdrDriver {
265
285
  }
266
286
  if (typeof pane !== "string" || !pane)
267
287
  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)}`);
288
+ // the split pane is a bare shell: give it a durable PANE label (0.7.5 — agent rename only binds
289
+ // detected agents; SKILL:197 was the old agent-name path) and VERIFY the label resolves back to this
290
+ // pane (research A1 is checked live per join, never assumed). `pane split --cwd` already placed the
291
+ // shell in this member's worktree, so no separate `cd` is needed.
292
+ const rn = await this.herdr(`pane rename ${shq(pane)} ${shq(name)}`);
272
293
  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) {
294
+ if (!verified) {
275
295
  await this.herdr(`pane close ${shq(pane)}`); // reap the failed join, best-effort
276
296
  return null;
277
297
  }
@@ -316,7 +336,7 @@ export class HerdrDriver {
316
336
  const typed = await this.herdr(`pane send-text ${shq(pane)} ${shq(cmd)}`, slot.cwd);
317
337
  if (typed.code !== 0)
318
338
  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);
339
+ 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
340
  if (this.waitOk(back.code, back.stdout) || await this.deliveryReadMatches(pane, cmd, slot.cwd)) {
321
341
  const enter = await this.herdr(`pane send-keys ${shq(pane)} Enter`, slot.cwd);
322
342
  if (enter.code !== 0)
@@ -344,12 +364,19 @@ export class HerdrDriver {
344
364
  }
345
365
  async waitOutput(slot, pattern, timeoutMs, opts) {
346
366
  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);
367
+ // 0.7.5: `--match` (literal) and `--regex` (pattern) are MUTUALLY EXCLUSIVE — the old
368
+ // `--match <p> --regex` form is rejected ("mutually exclusive"), which made the exit-marker wait
369
+ // error instantly instead of waiting, so LLM-gate/consult verdicts were read before they rendered
370
+ // and came back unparseable. Pick exactly one flag by whether the caller wants regex.
371
+ 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
372
  return this.waitOk(r.code, r.stdout); // dead pane: exit 0 + top-level error envelope (herdr bite)
349
373
  }
350
374
  async waitAgentStatus(slot, status, timeoutMs) {
351
375
  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);
376
+ // 0.7.5: `wait agent-status` (top-level) was removed; `agent wait <target> --until <status>` replaces
377
+ // it. Target is the pane id; when herdr has detected the worker's agent CLI there this resolves, else
378
+ // it returns an error envelope → waitOk false (a best-effort settle, same as the old timeout path).
379
+ const r = await this.herdr(`agent wait ${shq(pane)} --until ${shq(status)} --timeout ${Math.floor(timeoutMs)}`, slot.cwd, timeoutMs + 15_000);
353
380
  return this.waitOk(r.code, r.stdout);
354
381
  }
355
382
  async status(slot) {
@@ -358,9 +385,13 @@ export class HerdrDriver {
358
385
  // shared by status() and the VIS-13 blocked glyph (renameGroupTab): resolve the live agent_status
359
386
  // by durable name; "unknown" on any failure (dead pane, unparseable json) — never throws.
360
387
  async statusByName(name) {
361
- const r = await this.herdr(`agent get ${shq(name)}`);
388
+ // 0.7.5: read the pane's herdr-detected agent_status off `pane list` (keyed by the durable label);
389
+ // "unknown" on any failure (no such label, unparseable json) — never throws.
390
+ const r = await this.herdr(`pane list`);
362
391
  try {
363
- const s = JSON.parse(r.stdout).result?.agent?.agent_status;
392
+ const panes = JSON.parse(r.stdout).result?.panes;
393
+ const hit = Array.isArray(panes) ? panes.find((p) => p.label === name) : undefined;
394
+ const s = hit?.agent_status;
364
395
  return typeof s === "string" ? s : "unknown";
365
396
  }
366
397
  catch {
@@ -428,21 +459,21 @@ export class HerdrDriver {
428
459
  async priorWatch(runId) {
429
460
  if (!this.ws)
430
461
  throw new Error("herdr watch placement requires HERDR_WORKSPACE_ID — refusing unseeded pane");
431
- const list = await this.herdr("agent list");
462
+ const list = await this.herdr("pane list");
432
463
  if (list.code !== 0)
433
- throw new Error(`herdr agent list failed: ${list.stderr || list.stdout}`);
434
- let agents;
464
+ throw new Error(`herdr pane list failed: ${list.stderr || list.stdout}`);
465
+ let panes;
435
466
  try {
436
- agents = JSON.parse(list.stdout).result?.agents;
467
+ panes = JSON.parse(list.stdout).result?.panes;
437
468
  }
438
469
  catch {
439
- throw new Error(`herdr agent list returned unparseable JSON: ${list.stdout}`);
470
+ throw new Error(`herdr pane list returned unparseable JSON: ${list.stdout}`);
440
471
  }
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;
472
+ if (!Array.isArray(panes))
473
+ throw new Error(`herdr pane list returned no panes: ${list.stdout}`);
474
+ const prior = panes.find((p) => {
475
+ const owned = typeof p.label === "string" ? parseOwnedName(p.label) : null;
476
+ return p.workspace_id === this.ws && typeof p.pane_id === "string" && owned?.role === "watch" && owned.taskId === "run" && owned.runId !== runId;
446
477
  });
447
478
  return prior?.pane_id ?? null;
448
479
  }
@@ -465,7 +496,7 @@ export class HerdrDriver {
465
496
  }
466
497
  if (typeof pane !== "string" || !pane)
467
498
  throw new Error(`herdr watch split returned no pane id: ${sp.stdout}`);
468
- const renamed = await this.herdr(`agent rename ${shq(pane)} ${shq(name)}`);
499
+ const renamed = await this.herdr(`pane rename ${shq(pane)} ${shq(name)}`);
469
500
  if (renamed.code !== 0 || await this.namedPaneId(name) !== pane) {
470
501
  await this.herdr(`pane close ${shq(pane)}`);
471
502
  throw new Error(`herdr watch rename failed: ${renamed.stderr || renamed.stdout}`);
@@ -495,7 +526,7 @@ export class HerdrDriver {
495
526
  }
496
527
  const prior = runId ? await this.priorWatch(runId) : null;
497
528
  if (prior) {
498
- const renamed = await this.herdr(`agent rename ${shq(prior)} ${shq(name)}`);
529
+ const renamed = await this.herdr(`pane rename ${shq(prior)} ${shq(name)}`);
499
530
  if (renamed.code !== 0 || await this.namedPaneId(name) !== prior) {
500
531
  throw new Error(`herdr watch reclaim failed: ${renamed.stderr || renamed.stdout}`);
501
532
  }
@@ -528,9 +559,12 @@ export class HerdrDriver {
528
559
  try {
529
560
  if (!this.ws)
530
561
  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);
562
+ // 0.7.5: enumerate owned panes from `pane list` (labels), not `agent list` (detected agents only).
563
+ // panesToClose skips any pane whose label doesn't parse as tickmarkr-owned (orchestrator/operator
564
+ // shells, undetected agents), so a fuller pane listing never widens the blast radius.
565
+ const list = await this.herdr("pane list");
566
+ const panes = JSON.parse(list.stdout).result?.panes ?? [];
567
+ 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
568
  const touched = new Set();
535
569
  for (const c of toClose) {
536
570
  if (typeof c.tabId === "string")
@@ -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,
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from "react";
2
+ export type FleetListRow = {
3
+ id: string;
4
+ content: ReactNode;
5
+ };
6
+ export declare function ToggleMark({ active }: {
7
+ active: boolean;
8
+ }): import("react").JSX.Element;
9
+ export declare function FleetListScreen({ title, legend, rows, cursor, details, }: {
10
+ title: string;
11
+ legend: string;
12
+ rows: FleetListRow[];
13
+ cursor: number;
14
+ details?: string[];
15
+ }): import("react").JSX.Element;
16
+ export declare function FleetReviewScreen({ title, legend, diff, }: {
17
+ title: string;
18
+ legend: string;
19
+ diff: string;
20
+ }): import("react").JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { GLYPHS } from "../../brand.js";
4
+ export function ToggleMark({ active }) {
5
+ return active
6
+ ? _jsx(Text, { color: "ansi256(41)", children: GLYPHS.toggleActive })
7
+ : _jsx(Text, { dimColor: true, children: GLYPHS.toggleInactive });
8
+ }
9
+ export function FleetListScreen({ title, legend, rows, cursor, details = [], }) {
10
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: title }), _jsx(Text, { dimColor: true, children: legend }), rows.map((row, index) => (_jsxs(Text, { bold: index === cursor, children: [index === cursor ? `${GLYPHS.pointer} ` : " ", row.content] }, row.id))), details.map((detail) => _jsx(Text, { children: detail }, detail))] }));
11
+ }
12
+ export function FleetReviewScreen({ title, legend, diff, }) {
13
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: title }), _jsx(Text, { dimColor: true, children: legend }), _jsx(Text, { children: diff })] }));
14
+ }
@@ -0,0 +1,113 @@
1
+ import type { AuthHealth, WorkerAdapter } from "../../adapters/types.js";
2
+ import type { MapEntry, RoutingMode, Tier } from "../../config/config.js";
3
+ import { type Shape } from "../../graph/schema.js";
4
+ export type FleetSteeringKey = "review" | "consult";
5
+ export type FleetEditorState = {
6
+ denyAdapters: string[];
7
+ denyModels: string[];
8
+ classifications: FleetClassification[];
9
+ selectedMode: RoutingMode;
10
+ map: Record<string, MapEntry>;
11
+ steering: Record<FleetSteeringKey, string[] | undefined>;
12
+ };
13
+ export type FleetOverlayReview = {
14
+ kind: "empty";
15
+ } | {
16
+ kind: "diff";
17
+ before: string;
18
+ after: string;
19
+ diff: string;
20
+ path: string;
21
+ };
22
+ export type FleetEditorResult = {
23
+ kind: "write";
24
+ review: Extract<FleetOverlayReview, {
25
+ kind: "diff";
26
+ }>;
27
+ } | {
28
+ kind: "discard";
29
+ } | {
30
+ kind: "no-changes";
31
+ } | {
32
+ kind: "quit";
33
+ } | {
34
+ kind: "refresh";
35
+ };
36
+ type AgentCli = {
37
+ id: string;
38
+ version: string;
39
+ authed: boolean;
40
+ };
41
+ export type FleetModelGroup = {
42
+ adapter: string;
43
+ rows: Array<{
44
+ model: string;
45
+ tier?: Tier;
46
+ detectedAt?: string;
47
+ }>;
48
+ };
49
+ export type FleetClassification = {
50
+ adapter: string;
51
+ model: string;
52
+ tier: Tier;
53
+ note: string;
54
+ };
55
+ export type FleetModeOption = {
56
+ id: RoutingMode;
57
+ gloss: string;
58
+ };
59
+ export type FleetShapeRow = {
60
+ id: Shape;
61
+ label: string;
62
+ };
63
+ export type FleetCandidateOption = {
64
+ id: string;
65
+ label: string;
66
+ pin: {
67
+ via: string;
68
+ model: string;
69
+ };
70
+ };
71
+ export declare function formatDoctorAge(ageMs: number | null): string;
72
+ export declare function FleetApp({ ageMs, agents, initialDenyAdapters, initialDenyModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, }: {
73
+ ageMs: number | null;
74
+ agents: AgentCli[];
75
+ initialDenyAdapters: string[];
76
+ initialDenyModels: string[];
77
+ modelGroups: FleetModelGroup[];
78
+ initialMode: RoutingMode;
79
+ modeOptions: FleetModeOption[];
80
+ initialMap: Record<string, MapEntry>;
81
+ modePreview: (mode: RoutingMode, map: Record<string, MapEntry>) => string[];
82
+ shapeRows: (mode: RoutingMode, map: Record<string, MapEntry>) => FleetShapeRow[];
83
+ candidatesForShape: (shape: Shape, mode: RoutingMode, map: Record<string, MapEntry>) => FleetCandidateOption[];
84
+ preferOptionsForShape: (shape: Shape, current: string[]) => string[];
85
+ initialSteering: Record<FleetSteeringKey, string[] | undefined>;
86
+ steeringOptionsFor: (which: FleetSteeringKey, current: string[]) => string[];
87
+ reviewOverlay: (state: FleetEditorState) => FleetOverlayReview;
88
+ reloadGuard: (bytes: string) => string | null;
89
+ }): import("react").JSX.Element;
90
+ export declare function runFleetInkEditor({ ageMs, adapters, health, initialDenyAdapters, initialDenyModels, modelGroups, initialMode, modeOptions, initialMap, modePreview, shapeRows, candidatesForShape, preferOptionsForShape, initialSteering, steeringOptionsFor, reviewOverlay, reloadGuard, initialInput, input, output, debug, }: {
91
+ ageMs: number | null;
92
+ adapters: WorkerAdapter[];
93
+ health: Record<string, AuthHealth>;
94
+ initialDenyAdapters: string[];
95
+ initialDenyModels: string[];
96
+ modelGroups: FleetModelGroup[];
97
+ initialMode: RoutingMode;
98
+ modeOptions: FleetModeOption[];
99
+ initialMap: Record<string, MapEntry>;
100
+ modePreview: (mode: RoutingMode, map: Record<string, MapEntry>) => string[];
101
+ shapeRows: (mode: RoutingMode, map: Record<string, MapEntry>) => FleetShapeRow[];
102
+ candidatesForShape: (shape: Shape, mode: RoutingMode, map: Record<string, MapEntry>) => FleetCandidateOption[];
103
+ preferOptionsForShape: (shape: Shape, current: string[]) => string[];
104
+ initialSteering: Record<FleetSteeringKey, string[] | undefined>;
105
+ steeringOptionsFor: (which: FleetSteeringKey, current: string[]) => string[];
106
+ reviewOverlay: (state: FleetEditorState) => FleetOverlayReview;
107
+ reloadGuard: (bytes: string) => string | null;
108
+ initialInput?: string[];
109
+ input: NodeJS.ReadStream;
110
+ output: NodeJS.WriteStream;
111
+ debug?: boolean;
112
+ }): Promise<FleetEditorResult>;
113
+ export {};