tickmarkr 1.70.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/adapters/kimi.js +12 -35
- package/dist/adapters/registry.d.ts +25 -1
- package/dist/adapters/registry.js +95 -4
- package/dist/adapters/types.d.ts +8 -0
- package/dist/cli/commands/doctor.js +8 -3
- package/dist/cli/commands/fleet.d.ts +2 -0
- package/dist/cli/commands/fleet.js +212 -449
- package/dist/cli/commands/resume.js +7 -1
- package/dist/cli/commands/status.js +20 -8
- package/dist/drivers/herdr.d.ts +7 -0
- package/dist/drivers/herdr.js +119 -66
- package/dist/gates/acceptance.js +41 -17
- package/dist/route/preference.d.ts +9 -0
- package/dist/route/preference.js +61 -0
- package/dist/run/daemon.d.ts +4 -0
- package/dist/run/daemon.js +67 -10
- package/dist/run/interactive-seed.d.ts +1 -0
- package/dist/run/interactive-seed.js +12 -4
- package/dist/run/journal.d.ts +9 -1
- package/dist/run/journal.js +53 -2
- 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
|
@@ -2,6 +2,7 @@ import { loadConfig } from "../../config/config.js";
|
|
|
2
2
|
import { pickDriver } from "../../drivers/index.js";
|
|
3
3
|
import { formatSummary, runDaemon } from "../../run/daemon.js";
|
|
4
4
|
import { formatJournalNarration } from "../../run/journal.js";
|
|
5
|
+
import { denyPreferCollisionLine, denyPreferCollisions } from "../../route/preference.js";
|
|
5
6
|
const summaryGreen = (s) => s.failed.length === 0 && s.human.length === 0 && s.blocked.length === 0 && s.pending.length === 0
|
|
6
7
|
&& s.tipVerify !== "failed";
|
|
7
8
|
export async function resume(argv, cwd = process.cwd()) {
|
|
@@ -12,7 +13,12 @@ export async function resume(argv, cwd = process.cwd()) {
|
|
|
12
13
|
// Fable F2) — the daemon refuses a mismatched/unbound journal unless this is set, then journals a
|
|
13
14
|
// graph-rehash event naming both hashes. Strip the flag before runId resolution so a bare id still wins.
|
|
14
15
|
const graphChanged = argv.includes("--graph-changed");
|
|
15
|
-
const
|
|
16
|
+
const cfg = loadConfig(cwd);
|
|
17
|
+
const collisions = denyPreferCollisions(cfg);
|
|
18
|
+
if (collisions.length) {
|
|
19
|
+
throw new Error(collisions.map(denyPreferCollisionLine).join("; "));
|
|
20
|
+
}
|
|
21
|
+
const s = await runDaemon(cwd, { runId, resume: true, graphChanged, driver: pickDriver(cfg), narrate: (event) => console.log(formatJournalNarration(event)) });
|
|
16
22
|
const out = `resumed ${s.runId} — ${formatSummary(s)}`;
|
|
17
23
|
return { out, code: summaryGreen(s) ? 0 : 2 };
|
|
18
24
|
}
|
|
@@ -2,7 +2,7 @@ import { BANNER, GLYPHS, dim, fail, legend, ok, rule, statusRow, title, warn } f
|
|
|
2
2
|
import { blockedTasks, graphDefinitionHash, loadGraph } from "../../graph/graph.js";
|
|
3
3
|
import { GATE_NAMES } from "../../graph/schema.js";
|
|
4
4
|
import { foldActivity } from "../../run/activity.js";
|
|
5
|
-
import { Journal, engagementComparable } from "../../run/journal.js";
|
|
5
|
+
import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFailureKind, runHasEnded, } from "../../run/journal.js";
|
|
6
6
|
// ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
|
|
7
7
|
const REFRESH_MS = 2000;
|
|
8
8
|
const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; run `tickmarkr run` to execute";
|
|
@@ -178,15 +178,23 @@ const renderFrame = (cwd) => {
|
|
|
178
178
|
const divider = unicode ? " · " : " / ";
|
|
179
179
|
const width = process.stdout.columns ?? 120;
|
|
180
180
|
const done = effective.tasks.filter((t) => t.status === "done").length;
|
|
181
|
+
const ended = comparable && runHasEnded(events);
|
|
181
182
|
const cells = g.tasks.map((t) => {
|
|
182
183
|
const st = replayed?.get(t.id) ?? t.status;
|
|
184
|
+
const failureKind = comparable ? recordedTaskFailureKind(events, t.id) : undefined;
|
|
185
|
+
const terminal = st === "failed" || st === "human";
|
|
186
|
+
// Unknown legacy task-failed events stay red. Typed availability noise is warn-tier only while
|
|
187
|
+
// the run is live; verified quality parks and every unresolved task in an ended run are red.
|
|
188
|
+
const redTier = terminal && (ended
|
|
189
|
+
|| (failureKind !== undefined && isQualityFailureParkKind(failureKind))
|
|
190
|
+
|| (st === "failed" && failureKind === undefined));
|
|
183
191
|
const isStarved = starved.has(t.id);
|
|
184
192
|
const phrase = isStarved ? undefined : activity.cells.get(t.id);
|
|
185
193
|
const label = isStarved ? " starved" : phrase ? ` ${phrase}` : "";
|
|
186
194
|
const channel = assignments.get(t.id) ?? "-";
|
|
187
195
|
const ctx = contexts.get(t.id);
|
|
188
196
|
const assignCol = ctx !== undefined ? `${channel}${divider}ctx ${ctx}` : channel;
|
|
189
|
-
return { t, st, label, assignCol, isStarved, phrase, channel, ctx, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
|
|
197
|
+
return { t, st, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
|
|
190
198
|
});
|
|
191
199
|
if (!unicode) {
|
|
192
200
|
// machine/CI surface — layout unchanged (pipes, greps, and the golden pins depend on it)
|
|
@@ -207,7 +215,7 @@ const renderFrame = (cwd) => {
|
|
|
207
215
|
// rows (pad plain text FIRST, colorize after: ANSI has zero display width and would corrupt
|
|
208
216
|
// padEnd math)
|
|
209
217
|
const dot = dim(" · ");
|
|
210
|
-
const anyFailed = cells.some((c) => c.
|
|
218
|
+
const anyFailed = cells.some((c) => c.redTier);
|
|
211
219
|
const gaugeCells = 10;
|
|
212
220
|
const fill = g.tasks.length ? Math.round((done / g.tasks.length) * gaugeCells) : 0;
|
|
213
221
|
const gauge = (fill ? (anyFailed ? fail : ok)("█".repeat(fill)) : "") + (fill < gaugeCells ? dim("░".repeat(gaugeCells - fill)) : "");
|
|
@@ -232,17 +240,20 @@ const renderFrame = (cwd) => {
|
|
|
232
240
|
// id, goal at full width, and only SHORT status words. Line 2 carries the machinery, dim and
|
|
233
241
|
// aligned under the goal: gate chain, live activity phrase (or channel), ctx. Long channel names
|
|
234
242
|
// and activity phrases live on line 2 only, so they can never squeeze the goal or wrap line 1.
|
|
235
|
-
const taskVerdict = (
|
|
243
|
+
const taskVerdict = (c) => c.st === "done" ? "pass" : c.redTier ? "fail" : c.st === "failed" || c.st === "human" ? "warn" : "neutral";
|
|
244
|
+
const statusWord = (c) => c.redTier ? "failed" : c.st === "failed" ? "warn" : String(c.st);
|
|
236
245
|
const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
|
|
237
246
|
// plain-text status suffixes for width math only — starved / failed gate names / approval hint
|
|
238
247
|
const suffixPlain = (c) => (c.isStarved ? " · starved" : "") + failedSuffix(c.states) + humanGateSuffix(c.t, c.st, c.states);
|
|
239
|
-
const stW = Math.max(...cells.map((c) =>
|
|
248
|
+
const stW = Math.max(...cells.map((c) => statusWord(c).length + suffixPlain(c).length));
|
|
240
249
|
const avail = Math.max(8, width - (5 + idW) - 2 - stW);
|
|
241
250
|
const goals = cells.map((c) => shortGoal(c.t.goal, avail));
|
|
242
251
|
const goalW = Math.max(8, ...goals.map((s) => s.length));
|
|
243
252
|
const indent = " ".repeat(idW + 5); // line 2 starts under the goal column
|
|
244
|
-
const rows = cells.map((
|
|
245
|
-
const
|
|
253
|
+
const rows = cells.map((c, i) => {
|
|
254
|
+
const { t, st, failureKind, redTier, states, isStarved, phrase, channel, ctx } = c;
|
|
255
|
+
const word = statusWord(c);
|
|
256
|
+
const stWord = st === "done" ? ok(word) : redTier ? fail(word) : st === "failed" || st === "human" ? warn(word) : word;
|
|
246
257
|
// a fail names its gate in words right here — the one moment gate identity is needed on a row
|
|
247
258
|
const f = failedGates(states);
|
|
248
259
|
const human = humanGateSuffix(t, st, states);
|
|
@@ -250,10 +261,11 @@ const renderFrame = (cwd) => {
|
|
|
250
261
|
(isStarved ? dot + fail("starved") : "") +
|
|
251
262
|
(f.length ? dot + fail(f.join(", ")) : "") +
|
|
252
263
|
(human ? dot + warn("awaiting approval") : "");
|
|
253
|
-
const line1 = ` ${statusRow(taskVerdict(
|
|
264
|
+
const line1 = ` ${statusRow(taskVerdict(c), `${t.id.padEnd(idW)} ${goals[i].padEnd(goalW)} ${statusCell}`)}`;
|
|
254
265
|
// activity already names its channel for in-flight attempts — never repeat it
|
|
255
266
|
const detail = [
|
|
256
267
|
...(phrase ? [phrase, ...(phrase.includes(channel) || channel === "-" ? [] : [channel])] : [channel]),
|
|
268
|
+
...(failureKind && !phrase?.includes(failureKind) ? [failureKind] : []),
|
|
257
269
|
...(ctx !== undefined ? [`ctx ${ctx}`] : []),
|
|
258
270
|
].join(" · ");
|
|
259
271
|
return [line1, `${indent}${gateChain(states, true)} ${dim(detail)}`, ""];
|
package/dist/drivers/herdr.d.ts
CHANGED
|
@@ -11,11 +11,14 @@ export declare class HerdrDriver implements ExecutorDriver {
|
|
|
11
11
|
interactive: boolean;
|
|
12
12
|
private groups;
|
|
13
13
|
private groupSerial;
|
|
14
|
+
private deliverySerial;
|
|
14
15
|
private ws;
|
|
15
16
|
private callerPane;
|
|
16
17
|
private watches;
|
|
17
18
|
constructor(bin?: string, workersPerTab?: number);
|
|
18
19
|
private serial;
|
|
20
|
+
private deliveryQueue;
|
|
21
|
+
private deliveryMatches;
|
|
19
22
|
static available(): boolean;
|
|
20
23
|
private herdr;
|
|
21
24
|
private namedPaneId;
|
|
@@ -23,12 +26,16 @@ export declare class HerdrDriver implements ExecutorDriver {
|
|
|
23
26
|
private paneWidth;
|
|
24
27
|
slot(cwd: string, name: string, opts?: SlotOpts): Promise<Slot>;
|
|
25
28
|
private tabSlot;
|
|
29
|
+
private reclaimStaleLabel;
|
|
30
|
+
private staleLabelPanes;
|
|
26
31
|
private groupSlot;
|
|
27
32
|
private newGeneration;
|
|
28
33
|
private renameGroupTab;
|
|
29
34
|
private glyphFor;
|
|
30
35
|
private joinGroup;
|
|
31
36
|
run(slot: Slot, cmd: string): Promise<void>;
|
|
37
|
+
private deliver;
|
|
38
|
+
private deliveryReadMatches;
|
|
32
39
|
private waitOk;
|
|
33
40
|
waitOutput(slot: Slot, pattern: string, timeoutMs: number, opts?: {
|
|
34
41
|
regex?: boolean;
|
package/dist/drivers/herdr.js
CHANGED
|
@@ -25,6 +25,8 @@ 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)
|
|
29
|
+
deliverySerial = Promise.resolve();
|
|
28
30
|
// VIS-10: the run's workspace id, captured once at construction (the daemon inherits it from the
|
|
29
31
|
// operator's env before the driver is built). Required at slot() time, never in the constructor —
|
|
30
32
|
// pickDriver and its unit test construct HerdrDriver without env, so slot() is the trust gate.
|
|
@@ -40,19 +42,37 @@ export class HerdrDriver {
|
|
|
40
42
|
this.groupSerial = p.catch(() => undefined);
|
|
41
43
|
return p;
|
|
42
44
|
}
|
|
45
|
+
deliveryQueue(fn) {
|
|
46
|
+
const p = this.deliverySerial.then(fn, fn);
|
|
47
|
+
this.deliverySerial = p.catch(() => undefined);
|
|
48
|
+
return p;
|
|
49
|
+
}
|
|
50
|
+
// ponytail: narrow panes hard-wrap the input line — collapse whitespace before comparing.
|
|
51
|
+
deliveryMatches(transcript, cmd) {
|
|
52
|
+
const norm = (s) => s.replace(/\s+/g, "");
|
|
53
|
+
const hay = norm(transcript);
|
|
54
|
+
const needle = norm(cmd);
|
|
55
|
+
return needle.length > 0 && hay.includes(needle);
|
|
56
|
+
}
|
|
43
57
|
static available() {
|
|
44
58
|
return process.env.HERDR_ENV === "1";
|
|
45
59
|
}
|
|
46
60
|
herdr(args, cwd = process.cwd(), timeoutMs) {
|
|
47
61
|
return sh(`${shq(this.bin)} ${args}`, cwd, timeoutMs);
|
|
48
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`.
|
|
49
66
|
async namedPaneId(name) {
|
|
50
|
-
const r = await this.herdr(`
|
|
67
|
+
const r = await this.herdr(`pane list`);
|
|
51
68
|
if (r.code !== 0)
|
|
52
69
|
return null;
|
|
53
70
|
try {
|
|
54
|
-
const
|
|
55
|
-
|
|
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;
|
|
56
76
|
}
|
|
57
77
|
catch {
|
|
58
78
|
return null;
|
|
@@ -106,7 +126,11 @@ export class HerdrDriver {
|
|
|
106
126
|
throw new Error("herdr placement requires HERDR_WORKSPACE_ID — refusing untargeted pane (VIS-10: fail closed, never place by focus)");
|
|
107
127
|
// pin the tab to the RUN's workspace, UNCONDITIONALLY (inherited via HERDR_WORKSPACE_ID), never the
|
|
108
128
|
// operator's focused one (Intl-Dossier run-20260709-104447 incident: worker tabs opened in the tickmarkr repo workspace)
|
|
109
|
-
|
|
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)}`);
|
|
110
134
|
if (t.code !== 0)
|
|
111
135
|
throw new Error(`herdr tab create failed (exit ${t.code}, refusing untargeted placement): ${t.stderr || t.stdout}`);
|
|
112
136
|
let res;
|
|
@@ -117,43 +141,27 @@ export class HerdrDriver {
|
|
|
117
141
|
throw new Error(`herdr tab create returned unparseable JSON (refusing untargeted placement): ${t.stdout}`);
|
|
118
142
|
}
|
|
119
143
|
const tabId = res?.tab?.tab_id;
|
|
120
|
-
const
|
|
144
|
+
const id = res?.root_pane?.pane_id; // the tab's root shell pane, already in cwd — this run's worker pane
|
|
121
145
|
if (typeof tabId !== "string" || !tabId)
|
|
122
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}`);
|
|
123
149
|
// T5: the banner's pane identity line, derived from the T1 owned name (legacy names pass through).
|
|
124
150
|
const identity = shq(paneIdentityLine(canonicalizeLegacyName(name, "")));
|
|
125
|
-
|
|
126
|
-
if
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
await this.herdr(`pane close ${shq(stale)}`);
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
139
|
-
/* holder vanished between calls (agent_not_found) — retry the start regardless */
|
|
140
|
-
}
|
|
141
|
-
r = await this.herdr(`agent start ${shq(name)} --cwd ${shq(cwd)} --tab ${shq(tabId)} --no-focus -- bash`); // once
|
|
142
|
-
}
|
|
143
|
-
if (r.code !== 0)
|
|
144
|
-
throw new Error(`herdr agent start failed: ${r.stderr || r.stdout}`); // fail closed, no loop
|
|
145
|
-
const id = JSON.parse(r.stdout).result?.agent?.pane_id;
|
|
146
|
-
if (typeof id !== "string" || !id)
|
|
147
|
-
throw new Error(`herdr agent start returned no pane id: ${r.stdout}`);
|
|
148
|
-
// tab create auto-spawns a root shell pane and agent start --tab adds the agent as a SECOND
|
|
149
|
-
// pane — reap the idle shell so no tab shows a dead "tickmarkr git:" prompt beside its agent
|
|
150
|
-
// (VIS-04 orphan fix). Best-effort: a failed reap costs cosmetics only.
|
|
151
|
-
if (typeof rootPane === "string" && rootPane && rootPane !== id) {
|
|
152
|
-
await this.herdr(`pane close ${shq(rootPane)}`);
|
|
153
|
-
}
|
|
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);
|
|
154
162
|
// VIS-10 hole 3: seed the run's workspace id into the agent pane's shell so a worker's own ad-hoc
|
|
155
|
-
// `herdr
|
|
156
|
-
//
|
|
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.
|
|
157
165
|
// v1.22 T3 / OBS-17: also strip HERDR_ENV + socket path so the worker cannot open/mutate panes in
|
|
158
166
|
// the operator's session. Daemon-side this.herdr() calls keep process.env (unsealed).
|
|
159
167
|
const seed = await this.herdr(`pane run ${shq(id)} ${shq(`export HERDR_WORKSPACE_ID=${shq(this.ws)}; export ${PANE_IDENTITY_ENV}=${identity}; ${herdrSealShellPrefix()}`)}`);
|
|
@@ -161,6 +169,32 @@ export class HerdrDriver {
|
|
|
161
169
|
throw new Error(`herdr workspace-id seed failed (refusing untargeted pane): ${seed.stderr || seed.stdout}`);
|
|
162
170
|
return { id, name, cwd, tabId };
|
|
163
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
|
+
}
|
|
164
198
|
// VIS-04 role-tab (extended VIS-09 item 2): first member bootstraps generation 1 (WORKERS);
|
|
165
199
|
// later members join the latest generation with live-member room via joinGroup, else a NEW
|
|
166
200
|
// overflow generation tab (cleanup, cleanup, …) opens — a second `tab create`, NOT a further
|
|
@@ -239,7 +273,7 @@ export class HerdrDriver {
|
|
|
239
273
|
// stack down. Rightward splits below the measured floor shred the marker (e8aa003 at COLUMNS≈2;
|
|
240
274
|
// unrecoverable at 25 cols per measurement). Introspection failure → down (fail closed).
|
|
241
275
|
const direction = entry.members.length === 1 ? workerSplitDirection(await this.paneWidth(srcPane)) : "down";
|
|
242
|
-
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)}`);
|
|
243
277
|
if (sp.code !== 0)
|
|
244
278
|
return null;
|
|
245
279
|
let pane;
|
|
@@ -251,13 +285,13 @@ export class HerdrDriver {
|
|
|
251
285
|
}
|
|
252
286
|
if (typeof pane !== "string" || !pane)
|
|
253
287
|
return null;
|
|
254
|
-
// the split pane is a bare shell: give it a durable
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
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)}`);
|
|
258
293
|
const verified = rn.code === 0 && (await this.paneId({ id: "", name, cwd })) === pane;
|
|
259
|
-
|
|
260
|
-
if (!cd || cd.code !== 0) {
|
|
294
|
+
if (!verified) {
|
|
261
295
|
await this.herdr(`pane close ${shq(pane)}`); // reap the failed join, best-effort
|
|
262
296
|
return null;
|
|
263
297
|
}
|
|
@@ -286,6 +320,9 @@ export class HerdrDriver {
|
|
|
286
320
|
// cleared (C-u), and retyped, bounded; persistent corruption fails closed WITH the captured
|
|
287
321
|
// transcript — the dispatch-time pincer the ledger asks for, not post-hoc `git:` archaeology.
|
|
288
322
|
async run(slot, cmd) {
|
|
323
|
+
return this.deliveryQueue(() => this.deliver(slot, cmd));
|
|
324
|
+
}
|
|
325
|
+
async deliver(slot, cmd) {
|
|
289
326
|
const pane = await this.paneId(slot);
|
|
290
327
|
let transcript = "";
|
|
291
328
|
for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
|
|
@@ -299,10 +336,8 @@ export class HerdrDriver {
|
|
|
299
336
|
const typed = await this.herdr(`pane send-text ${shq(pane)} ${shq(cmd)}`, slot.cwd);
|
|
300
337
|
if (typed.code !== 0)
|
|
301
338
|
throw new Error(`herdr pane send-text failed: ${typed.stderr || typed.stdout}`);
|
|
302
|
-
const back = await this.herdr(`wait
|
|
303
|
-
|
|
304
|
-
// prior echo of the same command ever false-verifies. Dead pane → error envelope → unverified.
|
|
305
|
-
if (this.waitOk(back.code, back.stdout)) {
|
|
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);
|
|
340
|
+
if (this.waitOk(back.code, back.stdout) || await this.deliveryReadMatches(pane, cmd, slot.cwd)) {
|
|
306
341
|
const enter = await this.herdr(`pane send-keys ${shq(pane)} Enter`, slot.cwd);
|
|
307
342
|
if (enter.code !== 0)
|
|
308
343
|
throw new Error(`herdr pane send-keys Enter failed: ${enter.stderr || enter.stdout}`);
|
|
@@ -313,6 +348,10 @@ export class HerdrDriver {
|
|
|
313
348
|
}
|
|
314
349
|
throw new Error(`herdr delivery corrupted after ${DELIVERY_ATTEMPTS} attempts — enter never pressed (OBS-85); pane transcript:\n${transcript}`);
|
|
315
350
|
}
|
|
351
|
+
async deliveryReadMatches(pane, cmd, cwd) {
|
|
352
|
+
const read = await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, cwd);
|
|
353
|
+
return read.code === 0 && this.deliveryMatches(read.stdout, cmd);
|
|
354
|
+
}
|
|
316
355
|
waitOk(code, stdout) {
|
|
317
356
|
if (code !== 0 || !stdout.trim())
|
|
318
357
|
return code === 0; // herdr's successful waits may be silent
|
|
@@ -325,12 +364,19 @@ export class HerdrDriver {
|
|
|
325
364
|
}
|
|
326
365
|
async waitOutput(slot, pattern, timeoutMs, opts) {
|
|
327
366
|
const pane = await this.paneId(slot);
|
|
328
|
-
|
|
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);
|
|
329
372
|
return this.waitOk(r.code, r.stdout); // dead pane: exit 0 + top-level error envelope (herdr bite)
|
|
330
373
|
}
|
|
331
374
|
async waitAgentStatus(slot, status, timeoutMs) {
|
|
332
375
|
const pane = await this.paneId(slot);
|
|
333
|
-
|
|
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);
|
|
334
380
|
return this.waitOk(r.code, r.stdout);
|
|
335
381
|
}
|
|
336
382
|
async status(slot) {
|
|
@@ -339,9 +385,13 @@ export class HerdrDriver {
|
|
|
339
385
|
// shared by status() and the VIS-13 blocked glyph (renameGroupTab): resolve the live agent_status
|
|
340
386
|
// by durable name; "unknown" on any failure (dead pane, unparseable json) — never throws.
|
|
341
387
|
async statusByName(name) {
|
|
342
|
-
|
|
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`);
|
|
343
391
|
try {
|
|
344
|
-
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;
|
|
345
395
|
return typeof s === "string" ? s : "unknown";
|
|
346
396
|
}
|
|
347
397
|
catch {
|
|
@@ -409,21 +459,21 @@ export class HerdrDriver {
|
|
|
409
459
|
async priorWatch(runId) {
|
|
410
460
|
if (!this.ws)
|
|
411
461
|
throw new Error("herdr watch placement requires HERDR_WORKSPACE_ID — refusing unseeded pane");
|
|
412
|
-
const list = await this.herdr("
|
|
462
|
+
const list = await this.herdr("pane list");
|
|
413
463
|
if (list.code !== 0)
|
|
414
|
-
throw new Error(`herdr
|
|
415
|
-
let
|
|
464
|
+
throw new Error(`herdr pane list failed: ${list.stderr || list.stdout}`);
|
|
465
|
+
let panes;
|
|
416
466
|
try {
|
|
417
|
-
|
|
467
|
+
panes = JSON.parse(list.stdout).result?.panes;
|
|
418
468
|
}
|
|
419
469
|
catch {
|
|
420
|
-
throw new Error(`herdr
|
|
470
|
+
throw new Error(`herdr pane list returned unparseable JSON: ${list.stdout}`);
|
|
421
471
|
}
|
|
422
|
-
if (!Array.isArray(
|
|
423
|
-
throw new Error(`herdr
|
|
424
|
-
const prior =
|
|
425
|
-
const owned = typeof
|
|
426
|
-
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;
|
|
427
477
|
});
|
|
428
478
|
return prior?.pane_id ?? null;
|
|
429
479
|
}
|
|
@@ -446,7 +496,7 @@ export class HerdrDriver {
|
|
|
446
496
|
}
|
|
447
497
|
if (typeof pane !== "string" || !pane)
|
|
448
498
|
throw new Error(`herdr watch split returned no pane id: ${sp.stdout}`);
|
|
449
|
-
const renamed = await this.herdr(`
|
|
499
|
+
const renamed = await this.herdr(`pane rename ${shq(pane)} ${shq(name)}`);
|
|
450
500
|
if (renamed.code !== 0 || await this.namedPaneId(name) !== pane) {
|
|
451
501
|
await this.herdr(`pane close ${shq(pane)}`);
|
|
452
502
|
throw new Error(`herdr watch rename failed: ${renamed.stderr || renamed.stdout}`);
|
|
@@ -476,7 +526,7 @@ export class HerdrDriver {
|
|
|
476
526
|
}
|
|
477
527
|
const prior = runId ? await this.priorWatch(runId) : null;
|
|
478
528
|
if (prior) {
|
|
479
|
-
const renamed = await this.herdr(`
|
|
529
|
+
const renamed = await this.herdr(`pane rename ${shq(prior)} ${shq(name)}`);
|
|
480
530
|
if (renamed.code !== 0 || await this.namedPaneId(name) !== prior) {
|
|
481
531
|
throw new Error(`herdr watch reclaim failed: ${renamed.stderr || renamed.stdout}`);
|
|
482
532
|
}
|
|
@@ -509,9 +559,12 @@ export class HerdrDriver {
|
|
|
509
559
|
try {
|
|
510
560
|
if (!this.ws)
|
|
511
561
|
return;
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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);
|
|
515
568
|
const touched = new Set();
|
|
516
569
|
for (const c of toClose) {
|
|
517
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,
|
|
@@ -25,3 +25,12 @@ export declare function disallowedBy(c: {
|
|
|
25
25
|
adapter: string;
|
|
26
26
|
model: string;
|
|
27
27
|
}, routing: TickmarkrConfig["routing"]): Disallowed | null;
|
|
28
|
+
export interface DenyPreferCollision {
|
|
29
|
+
kind: "prefer" | "pin";
|
|
30
|
+
shape: string;
|
|
31
|
+
detail: string;
|
|
32
|
+
disallowed: Disallowed;
|
|
33
|
+
}
|
|
34
|
+
export declare function preferEntryDenied(p: string, cfg: TickmarkrConfig): Disallowed | null;
|
|
35
|
+
export declare function denyPreferCollisions(cfg: TickmarkrConfig): DenyPreferCollision[];
|
|
36
|
+
export declare function denyPreferCollisionLine({ kind, shape, detail, disallowed }: DenyPreferCollision): string;
|