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.
- package/dist/adapters/codex.js +13 -3
- package/dist/cli/commands/fleet.d.ts +2 -0
- package/dist/cli/commands/fleet.js +212 -449
- package/dist/drivers/herdr.d.ts +2 -0
- package/dist/drivers/herdr.js +97 -63
- package/dist/gates/acceptance.js +41 -17
- package/dist/tui/ink/components.d.ts +20 -0
- package/dist/tui/ink/components.js +14 -0
- package/dist/tui/ink/fleet-app.d.ts +113 -0
- package/dist/tui/ink/fleet-app.js +678 -0
- package/package.json +4 -1
package/dist/drivers/herdr.d.ts
CHANGED
|
@@ -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;
|
package/dist/drivers/herdr.js
CHANGED
|
@@ -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(`
|
|
67
|
+
const r = await this.herdr(`pane list`);
|
|
65
68
|
if (r.code !== 0)
|
|
66
69
|
return null;
|
|
67
70
|
try {
|
|
68
|
-
const
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
140
|
-
if
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
170
|
-
//
|
|
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
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
462
|
+
const list = await this.herdr("pane list");
|
|
432
463
|
if (list.code !== 0)
|
|
433
|
-
throw new Error(`herdr
|
|
434
|
-
let
|
|
464
|
+
throw new Error(`herdr pane list failed: ${list.stderr || list.stdout}`);
|
|
465
|
+
let panes;
|
|
435
466
|
try {
|
|
436
|
-
|
|
467
|
+
panes = JSON.parse(list.stdout).result?.panes;
|
|
437
468
|
}
|
|
438
469
|
catch {
|
|
439
|
-
throw new Error(`herdr
|
|
470
|
+
throw new Error(`herdr pane list returned unparseable JSON: ${list.stdout}`);
|
|
440
471
|
}
|
|
441
|
-
if (!Array.isArray(
|
|
442
|
-
throw new Error(`herdr
|
|
443
|
-
const prior =
|
|
444
|
-
const owned = typeof
|
|
445
|
-
return
|
|
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(`
|
|
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(`
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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")
|
package/dist/gates/acceptance.js
CHANGED
|
@@ -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
|
|
108
|
-
// unified-diff
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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
|
-
//
|
|
159
|
-
|
|
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"}
|
|
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
|
|
254
|
-
//
|
|
255
|
-
// A citation to an untouched file or
|
|
256
|
-
//
|
|
257
|
-
//
|
|
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 {};
|