tickmarkr 1.72.0 → 1.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/cli/commands/approve.js +21 -3
  2. package/dist/cli/commands/resume.js +10 -2
  3. package/dist/cli/commands/status.d.ts +2 -0
  4. package/dist/cli/commands/status.js +260 -41
  5. package/dist/cli/commands/ui.d.ts +6 -2
  6. package/dist/cli/commands/ui.js +2 -4
  7. package/dist/config/config.d.ts +4 -0
  8. package/dist/config/config.js +6 -1
  9. package/dist/drivers/herdr.d.ts +5 -0
  10. package/dist/drivers/herdr.js +100 -10
  11. package/dist/drivers/subprocess.d.ts +3 -3
  12. package/dist/drivers/subprocess.js +10 -5
  13. package/dist/gates/acceptance.js +19 -1
  14. package/dist/gates/llm.d.ts +4 -0
  15. package/dist/gates/llm.js +16 -3
  16. package/dist/gates/run-gates.d.ts +1 -1
  17. package/dist/gates/run-gates.js +25 -3
  18. package/dist/route/preference.d.ts +2 -1
  19. package/dist/route/preference.js +8 -2
  20. package/dist/run/daemon.d.ts +1 -0
  21. package/dist/run/daemon.js +139 -4
  22. package/dist/run/git.d.ts +2 -0
  23. package/dist/run/git.js +55 -0
  24. package/dist/run/journal.d.ts +21 -1
  25. package/dist/run/journal.js +83 -5
  26. package/dist/tui/ink/components.d.ts +18 -0
  27. package/dist/tui/ink/components.js +30 -2
  28. package/dist/tui/ink/studio-app.d.ts +59 -0
  29. package/dist/tui/ink/studio-app.js +320 -0
  30. package/package.json +1 -1
  31. package/dist/tui/app.d.ts +0 -72
  32. package/dist/tui/app.js +0 -334
  33. package/dist/tui/engine.d.ts +0 -52
  34. package/dist/tui/engine.js +0 -92
  35. package/dist/tui/frame.d.ts +0 -22
  36. package/dist/tui/frame.js +0 -56
  37. package/dist/tui/input.d.ts +0 -41
  38. package/dist/tui/input.js +0 -100
  39. package/dist/tui/views/consult-dossier.d.ts +0 -49
  40. package/dist/tui/views/consult-dossier.js +0 -169
  41. package/dist/tui/views/diff-modal.d.ts +0 -9
  42. package/dist/tui/views/diff-modal.js +0 -19
  43. package/dist/tui/views/fleet-view.d.ts +0 -91
  44. package/dist/tui/views/fleet-view.js +0 -337
  45. package/dist/tui/views/preview-view.d.ts +0 -7
  46. package/dist/tui/views/preview-view.js +0 -80
  47. package/dist/tui/views/profile-view.d.ts +0 -15
  48. package/dist/tui/views/profile-view.js +0 -93
  49. package/dist/tui/views/routing-view.d.ts +0 -48
  50. package/dist/tui/views/routing-view.js +0 -234
  51. package/dist/tui/views/runs-view.d.ts +0 -70
  52. package/dist/tui/views/runs-view.js +0 -387
@@ -10,6 +10,8 @@ export const TRAILER_WIDTH_MARGIN = 2; // cols below (floor + margin) refuse a r
10
10
  export const DELIVERY_ATTEMPTS = 3;
11
11
  const DELIVERY_VERIFY_TIMEOUT_MS = 2000; // per attempt — a paste that hasn't rendered in 2s is retyped
12
12
  const DELIVERY_READ_LINES = 80;
13
+ const DELIVERY_SETTLE_READ_ATTEMPTS = 6;
14
+ const DELIVERY_SETTLE_POLL_MS = 100;
13
15
  /** First-generation join direction from measured trailer-safe floor (43-MEASUREMENT.md). */
14
16
  export function workerSplitDirection(paneCols, safeFloor = TRAILER_SAFE_FLOOR_COLS, margin = TRAILER_WIDTH_MARGIN) {
15
17
  if (paneCols == null || paneCols <= 0)
@@ -25,8 +27,11 @@ export class HerdrDriver {
25
27
  // grouped slot()/close() mutate shared group state across awaits — serialize them so two
26
28
  // concurrent first members can never both create the group tab (mergeSerial idiom, daemon.ts)
27
29
  groupSerial = Promise.resolve();
28
- // OBS-119: concurrent deliveries contend on herdr's send path — one chain per driver (mergeSerial idiom)
30
+ // OBS-120: canonical dispatch slots hold this chain from allocation through verified binding and
31
+ // delivery. Legacy/manual slots still serialize each delivery on the same chain (OBS-119).
29
32
  deliverySerial = Promise.resolve();
33
+ dispatchLeases = new WeakMap();
34
+ deliveredPanes = new WeakMap();
30
35
  // VIS-10: the run's workspace id, captured once at construction (the daemon inherits it from the
31
36
  // operator's env before the driver is built). Required at slot() time, never in the constructor —
32
37
  // pickDriver and its unit test construct HerdrDriver without env, so slot() is the trust gate.
@@ -47,6 +52,41 @@ export class HerdrDriver {
47
52
  this.deliverySerial = p.catch(() => undefined);
48
53
  return p;
49
54
  }
55
+ // OBS-120: ExecutorDriver exposes slot() and run() separately, so a canonical tickmarkr slot
56
+ // carries a lease across that API seam. The next dispatch cannot allocate until this slot's first
57
+ // run either delivers or fails. Allocation errors release immediately; no failed dispatch poisons
58
+ // the queue. The daemon calls run() directly after preparing the command for every owned slot.
59
+ reserveDispatch(allocate) {
60
+ let release;
61
+ const held = new Promise((resolve) => { release = resolve; });
62
+ const start = this.deliverySerial.then(() => undefined, () => undefined);
63
+ this.deliverySerial = start.then(() => held);
64
+ return start.then(async () => {
65
+ let slot;
66
+ try {
67
+ slot = await allocate();
68
+ this.dispatchLeases.set(slot, { release });
69
+ return slot;
70
+ }
71
+ catch (error) {
72
+ if (slot) {
73
+ try {
74
+ await this.close(slot);
75
+ }
76
+ catch { /* allocation already failed closed; cleanup is best-effort */ }
77
+ }
78
+ release();
79
+ throw error;
80
+ }
81
+ });
82
+ }
83
+ async verifyPaneIdentityBinding(slot) {
84
+ const paneId = await this.namedPaneId(slot.name);
85
+ if (paneId !== slot.id) {
86
+ throw new Error(`herdr pane identity binding verification failed for ${slot.name}: allocated ${slot.id}, resolved ${paneId ?? "none"} — refusing delivery`);
87
+ }
88
+ return paneId;
89
+ }
50
90
  // ponytail: narrow panes hard-wrap the input line — collapse whitespace before comparing.
51
91
  deliveryMatches(transcript, cmd) {
52
92
  const norm = (s) => s.replace(/\s+/g, "");
@@ -78,8 +118,12 @@ export class HerdrDriver {
78
118
  return null;
79
119
  }
80
120
  }
81
- // pane ids compact when panes close — resolve fresh via the durable agent name (spec §5)
121
+ // Before delivery, resolve fresh via the durable name because pane ids can compact. After delivery,
122
+ // pin the verified target so early liveness cannot drift to a label rebound onto another pane.
82
123
  async paneId(slot) {
124
+ const delivered = this.deliveredPanes.get(slot);
125
+ if (delivered)
126
+ return delivered;
83
127
  return await this.namedPaneId(slot.name) ?? slot.id;
84
128
  }
85
129
  // VIS-09 P43-03: runtime width for the layout gate (43-MEASUREMENT.md licensing condition 2).
@@ -104,12 +148,18 @@ export class HerdrDriver {
104
148
  // reconcile.ts and this driver's own renameGroupTab/glyphFor decode role/taskId/attempt from
105
149
  // those shapes without a call-site migration; T2 retires this branch by always passing `owned`.
106
150
  const resolved = opts?.owned ? formatOwnedName(opts.owned) : name;
151
+ const allocate = opts?.group
152
+ ? () => this.serial(() => this.groupSlot(cwd, resolved, opts.group))
153
+ : () => this.tabSlot(cwd, resolved, opts?.label);
154
+ // Production dispatch names are canonical even when the gate call site supplies the already-
155
+ // formatted name rather than SlotOpts.owned. Hold one lease across slot() → run(); legacy/manual
156
+ // slots retain their existing allocation-only semantics for compatibility.
157
+ if (parseOwnedName(resolved))
158
+ return this.reserveDispatch(allocate);
107
159
  // group wins if both are set (a group tab is already stage-labeled; passing both is a caller bug).
108
160
  // label (without group) → dedicated labeled tab via tabSlot's third param: no groups-map entry, no
109
161
  // refcount, no groupSerial, no degrade latch — dedicated tabs have no shared state to guard (SUP-01).
110
- if (opts?.group)
111
- return this.serial(() => this.groupSlot(cwd, resolved, opts.group));
112
- return this.tabSlot(cwd, resolved, opts?.label); // label undefined → defaults to name (today's behavior)
162
+ return allocate(); // label undefined → defaults to name (today's behavior)
113
163
  }
114
164
  // today's per-slot tab path, plus the VIS-04 orphan reap
115
165
  // label defaults to the slot name; group tabs pass the STAGE name instead — a first-member label
@@ -320,15 +370,40 @@ export class HerdrDriver {
320
370
  // cleared (C-u), and retyped, bounded; persistent corruption fails closed WITH the captured
321
371
  // transcript — the dispatch-time pincer the ledger asks for, not post-hoc `git:` archaeology.
322
372
  async run(slot, cmd) {
323
- return this.deliveryQueue(() => this.deliver(slot, cmd));
373
+ const lease = this.dispatchLeases.get(slot);
374
+ if (lease) {
375
+ this.dispatchLeases.delete(slot);
376
+ try {
377
+ const paneId = await this.verifyPaneIdentityBinding(slot);
378
+ await this.deliver(slot, cmd, paneId);
379
+ this.deliveredPanes.set(slot, paneId);
380
+ }
381
+ finally {
382
+ lease.release();
383
+ }
384
+ return;
385
+ }
386
+ return this.deliveryQueue(async () => {
387
+ const paneId = await this.paneId(slot);
388
+ await this.deliver(slot, cmd, paneId);
389
+ this.deliveredPanes.set(slot, paneId);
390
+ });
324
391
  }
325
- async deliver(slot, cmd) {
326
- const pane = await this.paneId(slot);
392
+ async deliver(slot, cmd, pane) {
327
393
  let transcript = "";
328
394
  for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
329
395
  if (attempt > 0) {
330
- // clear the corrupted input line before retyping; a failed clear must NOT be retyped onto —
331
- // corrupt-prefix + clean-retype would concatenate and false-verify by containment
396
+ // A TUI may still be painting while the failed delivery is captured (OBS-135). Judge the
397
+ // line only after two consecutive pane reads agree; an already-stable frame returns on the
398
+ // first fresh read without a timer. A changing pane is bounded and preserves OBS-85's
399
+ // fail-closed error instead of guessing from an adapter fingerprint.
400
+ const settled = await this.settleDeliveryLine(pane, slot.cwd, transcript);
401
+ transcript = settled.transcript;
402
+ if (!settled.ok) {
403
+ throw new Error(`herdr delivery clear failed — refusing to retype onto a corrupted line (OBS-85); pane transcript:\n${transcript}`);
404
+ }
405
+ // Clear the corrupted input line before retyping; a failed clear must NOT be retyped onto —
406
+ // corrupt-prefix + clean-retype would concatenate and false-verify by containment.
332
407
  const cleared = await this.herdr(`pane send-keys ${shq(pane)} C-u`, slot.cwd);
333
408
  if (cleared.code !== 0)
334
409
  throw new Error(`herdr delivery clear failed — refusing to retype onto a corrupted line (OBS-85); pane transcript:\n${transcript}`);
@@ -348,6 +423,21 @@ export class HerdrDriver {
348
423
  }
349
424
  throw new Error(`herdr delivery corrupted after ${DELIVERY_ATTEMPTS} attempts — enter never pressed (OBS-85); pane transcript:\n${transcript}`);
350
425
  }
426
+ async settleDeliveryLine(pane, cwd, initialTranscript) {
427
+ let transcript = initialTranscript;
428
+ for (let readAttempt = 0; readAttempt < DELIVERY_SETTLE_READ_ATTEMPTS; readAttempt++) {
429
+ const read = await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, cwd);
430
+ if (read.code !== 0)
431
+ return { ok: false, transcript: read.stdout || transcript };
432
+ if (read.stdout === transcript)
433
+ return { ok: true, transcript };
434
+ transcript = read.stdout;
435
+ if (readAttempt < DELIVERY_SETTLE_READ_ATTEMPTS - 1) {
436
+ await new Promise((resolve) => setTimeout(resolve, DELIVERY_SETTLE_POLL_MS));
437
+ }
438
+ }
439
+ return { ok: false, transcript };
440
+ }
351
441
  async deliveryReadMatches(pane, cmd, cwd) {
352
442
  const read = await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, cwd);
353
443
  return read.code === 0 && this.deliveryMatches(read.stdout, cmd);
@@ -1,10 +1,10 @@
1
1
  import type { ExecutorDriver, NotifyOpts, Slot } from "./types.js";
2
2
  export declare const MAX_BUF: number;
3
3
  export declare const HERDR_CONTROL_VARS: readonly ["HERDR_ENV", "HERDR_SOCKET_PATH"];
4
- /** Copy of env with herdr control-plane vars stripped. Daemon process.env is left alone. */
4
+ /** Copy of worker env with the fork cap applied and herdr control-plane vars stripped. */
5
5
  export declare function sealHerdrEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
6
- /** Shell prefix that unsets herdr control vars in a pane/login shell (herdr seed + daemon setup). */
7
- export declare function herdrSealShellPrefix(): string;
6
+ /** Pane/login-shell form of the same worker env seal (herdr seed + daemon setup). */
7
+ export declare function herdrSealShellPrefix(env?: NodeJS.ProcessEnv): string;
8
8
  export declare class SubprocessDriver implements ExecutorDriver {
9
9
  id: string;
10
10
  interactive: boolean;
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
- import { createWorktree } from "../run/git.js";
2
+ import { shq } from "../adapters/types.js";
3
+ import { createWorktree, DEFAULT_FORK_CAP, FORK_CAP_ENV } from "../run/git.js";
3
4
  // HARD-03: cap retained worker output so a chatty worker can't grow tickmarkr's heap unbounded.
4
5
  // 2MB is safe against BOTH consumers: the largest read() call site asks for 1000 lines
5
6
  // (daemon.ts:217/247), and every marker (TICKMARKR_RESULT, TICKMARKR_EXIT_<nonce>) is emitted at the
@@ -12,16 +13,20 @@ export const MAX_BUF = 2 * 1024 * 1024;
12
13
  // (and its own herdr driver CLI calls) keep the live session. Socket path is the wire; HERDR_ENV
13
14
  // is the "I am inside herdr" gate every agent skill checks before mutating panes.
14
15
  export const HERDR_CONTROL_VARS = ["HERDR_ENV", "HERDR_SOCKET_PATH"];
15
- /** Copy of env with herdr control-plane vars stripped. Daemon process.env is left alone. */
16
+ /** Copy of worker env with the fork cap applied and herdr control-plane vars stripped. */
16
17
  export function sealHerdrEnv(env = process.env) {
17
18
  const out = { ...env };
19
+ if (!(FORK_CAP_ENV in out))
20
+ out[FORK_CAP_ENV] = DEFAULT_FORK_CAP;
18
21
  for (const k of HERDR_CONTROL_VARS)
19
22
  delete out[k];
20
23
  return out;
21
24
  }
22
- /** Shell prefix that unsets herdr control vars in a pane/login shell (herdr seed + daemon setup). */
23
- export function herdrSealShellPrefix() {
24
- return HERDR_CONTROL_VARS.map((k) => `unset ${k}`).join("; ") + "; ";
25
+ /** Pane/login-shell form of the same worker env seal (herdr seed + daemon setup). */
26
+ export function herdrSealShellPrefix(env = process.env) {
27
+ const forkCap = sealHerdrEnv(env)[FORK_CAP_ENV] ?? DEFAULT_FORK_CAP;
28
+ return `export ${FORK_CAP_ENV}=${shq(forkCap)}; ` +
29
+ HERDR_CONTROL_VARS.map((k) => `unset ${k}`).join("; ") + "; ";
25
30
  }
26
31
  export class SubprocessDriver {
27
32
  id = "subprocess";
@@ -172,6 +172,22 @@ function compressLines(lines) {
172
172
  }
173
173
  return parts.join(", ");
174
174
  }
175
+ // OBS-134: whole-file deletions are already fully described by their path. Sending every removed line
176
+ // spends the cap and judge context on content that cannot exist after the change. Added and modified
177
+ // sections pass through byte-for-byte, so their anti-flooding budget is unchanged.
178
+ function judgeRelevantDiff(diff) {
179
+ return diff.split(/(?=^diff --git )/m).map((section) => {
180
+ if (!/^deleted file mode /m.test(section))
181
+ return section;
182
+ const oldPath = /^--- (.+)$/m.exec(section)?.[1]
183
+ ?? /^Binary files (.+) and \/dev\/null differ$/m.exec(section)?.[1];
184
+ if (!oldPath || oldPath === "/dev/null")
185
+ return section;
186
+ const unquoted = oldPath.startsWith('"') && oldPath.endsWith('"') ? oldPath.slice(1, -1) : oldPath;
187
+ const path = unquoted.replace(/^a\//, "");
188
+ return `deleted file: ${path}\n`;
189
+ }).join("");
190
+ }
175
191
  // A citation is valid evidence iff the cited line falls inside a changed hunk of the cited file (OBS-129:
176
192
  // changed-hunk span, not exact-added-line). A legacy free-text quote (string) keeps v1.64's substring
177
193
  // check — non-empty and present somewhere in the diff.
@@ -225,7 +241,9 @@ export async function acceptanceGate(task, worktree, baseRef, judge, via, opts =
225
241
  const warn = onlyJudge
226
242
  ? "WARNING: only judge oracles — no deterministic command/test oracle guards this task (deterministic preferred, spec §2).\n"
227
243
  : "";
228
- const { full: diff, forCap } = await fetchTaskDiff(worktree, baseRef);
244
+ const fetched = await fetchTaskDiff(worktree, baseRef);
245
+ const diff = judgeRelevantDiff(fetched.full);
246
+ const forCap = judgeRelevantDiff(fetched.forCap);
229
247
  const diffCap = opts.diffCap ?? DEFAULT_DIFF_CAP;
230
248
  const capFail = checkDiffCap("acceptance", forCap.length, diffCap, warn + detBlock);
231
249
  if (capFail)
@@ -26,6 +26,10 @@ export interface GateVia {
26
26
  nameFor: (role: "judge" | "review", adapter: string) => string;
27
27
  labelFor: (role: "judge" | "review") => string;
28
28
  }
29
+ export declare function captureLlmOutput<T>(run: () => Promise<T>): Promise<{
30
+ value: T;
31
+ outputs: string[];
32
+ }>;
29
33
  export declare function runHeadless(adapter: WorkerAdapter, model: string, prompt: string, cwd: string, timeoutMs?: number): Promise<string>;
30
34
  export declare function runViaDriver(adapter: WorkerAdapter, model: string, prompt: string, cwd: string, via: LlmVia, timeoutMs?: number): Promise<string>;
31
35
  export declare function runLlm(adapter: WorkerAdapter, model: string, prompt: string, cwd: string, via?: LlmVia, timeoutMs?: number): Promise<string>;
package/dist/gates/llm.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { randomBytes } from "node:crypto";
2
3
  import { mkdtempSync, writeFileSync } from "node:fs";
3
4
  import { tmpdir } from "node:os";
@@ -82,6 +83,16 @@ export function rolePaneNameFromPrompt(prompt, fallback) {
82
83
  return gatePaneName("review", id, retry ? "-r1" : "");
83
84
  return fallback;
84
85
  }
86
+ const llmOutputCapture = new AsyncLocalStorage();
87
+ // OBS-132: acceptance.ts owns verdict parsing and is deliberately byte-untouched. This async-scoped
88
+ // recorder lets run-gates observe the exact output that acceptance parsed without changing runLlm's
89
+ // return value or leaking concurrent tasks into one another. Callers retain output only when the
90
+ // resulting verdict is a production failure; healthy output is discarded in memory.
91
+ export async function captureLlmOutput(run) {
92
+ const outputs = [];
93
+ const value = await llmOutputCapture.run(outputs, run);
94
+ return { value, outputs };
95
+ }
85
96
  export async function runHeadless(adapter, model, prompt, cwd, timeoutMs = 300000) {
86
97
  const pf = join(mkdtempSync(join(tmpdir(), "tickmarkr-llm-")), "prompt.md");
87
98
  writeFileSync(pf, prompt);
@@ -119,10 +130,12 @@ export async function runViaDriver(adapter, model, prompt, cwd, via, timeoutMs =
119
130
  out = augmentFakeVerdictOutput(adapter, out, nonce, prompt);
120
131
  return out;
121
132
  }
122
- export function runLlm(adapter, model, prompt, cwd, via, timeoutMs = 300000) {
123
- return via
133
+ export async function runLlm(adapter, model, prompt, cwd, via, timeoutMs = 300000) {
134
+ const out = await (via
124
135
  ? runViaDriver(adapter, model, prompt, cwd, via, timeoutMs)
125
- : runHeadless(adapter, model, prompt, cwd, timeoutMs);
136
+ : runHeadless(adapter, model, prompt, cwd, timeoutMs));
137
+ llmOutputCapture.getStore()?.push(out);
138
+ return out;
126
139
  }
127
140
  export function extractJson(raw) {
128
141
  const fenced = [...raw.matchAll(/```json\s*\n([\s\S]*?)```/g)].at(-1);
@@ -2,7 +2,7 @@ import { type Assignment, type BillingChannel, type WorkerAdapter, type WorkerRe
2
2
  import { type TickmarkrConfig } from "../config/config.js";
3
3
  import { type GateName, type Task } from "../graph/schema.js";
4
4
  import { type Baseline } from "./baseline.js";
5
- import type { GateVia } from "./llm.js";
5
+ import { type GateVia } from "./llm.js";
6
6
  import type { GateResult } from "./types.js";
7
7
  export type GateEvent = {
8
8
  phase: "start";
@@ -5,9 +5,11 @@ import { GATE_NAMES } from "../graph/schema.js";
5
5
  import { acceptanceGate } from "./acceptance.js";
6
6
  import { compareToBaseline } from "./baseline.js";
7
7
  import { evidenceGate } from "./evidence.js";
8
+ import { captureLlmOutput } from "./llm.js";
8
9
  import { marginalCostRank } from "../route/router.js";
9
10
  import { reviewGate } from "./review.js";
10
11
  import { scopeGate } from "./scope.js";
12
+ import { withJudgeInvocationEvidence } from "../run/journal.js";
11
13
  export async function runGates(task, ctx) {
12
14
  const results = [];
13
15
  let commits = [];
@@ -63,7 +65,27 @@ export async function runGates(task, ctx) {
63
65
  : undefined;
64
66
  // v1.19 (T2): testCmd threads the detected test runner to the gate so named-test oracles run
65
67
  // deterministically (filtered via -t) before any LLM judge dispatch.
66
- let a = await acceptanceGate(task, ctx.worktree, ctx.baseRef, { adapter: judgeAdapter, model: ctx.cfg.judge.model }, jvia, { testCmd: ctx.commands.test, diffCap: ctx.cfg.gates.diffCap });
68
+ const invocations = [];
69
+ const invokeJudge = async (adapter, model, via) => {
70
+ const started = Date.now();
71
+ const captured = await captureLlmOutput(() => acceptanceGate(task, ctx.worktree, ctx.baseRef, { adapter, model }, via, { testCmd: ctx.commands.test, diffCap: ctx.cfg.gates.diffCap }));
72
+ const channel = channelKey({ adapter: adapter.id, model });
73
+ const unparseable = captured.value.meta?.unparseable === true;
74
+ // acceptanceGate has exactly one runLlm call. Keep the map shape so a future deterministic early
75
+ // return (zero outputs) stays telemetry-free instead of manufacturing a judge invocation.
76
+ for (const output of captured.outputs) {
77
+ invocations.push({
78
+ taskId: task.id,
79
+ channel,
80
+ outcome: unparseable ? "failed" : "done",
81
+ judgeOutcome: unparseable ? "unparseable" : "parseable",
82
+ durationMs: Date.now() - started,
83
+ ...(unparseable ? { transcript: output } : {}),
84
+ });
85
+ }
86
+ return captured.value;
87
+ };
88
+ let a = await invokeJudge(judgeAdapter, ctx.cfg.judge.model, jvia);
67
89
  // GATE-09: an unparseable judge verdict retries the JUDGE exactly once on a failover channel — never
68
90
  // the worker (run-20260711-185020 P43-03 L70-72 billed a judge flake as a worker attempt). The flaked
69
91
  // first verdict NEVER enters results (no false gate-result journal event, no operator notify, no stale
@@ -96,10 +118,10 @@ export async function runGates(task, ctx) {
96
118
  ? { driver: ctx.via.driver, keep: ctx.via.keep, onSlot: ctx.via.onSlot, name: ctx.via.nameFor("judge", retryAdapter.id) + "-r1", label: ctx.via.labelFor("judge") }
97
119
  : undefined;
98
120
  // the retry IS a second acceptanceGate call: one code path, one parser, zero new parse leniency.
99
- a = await acceptanceGate(task, ctx.worktree, ctx.baseRef, { adapter: retryAdapter, model: retry.model }, retryJvia, { testCmd: ctx.commands.test, diffCap: ctx.cfg.gates.diffCap });
121
+ a = await invokeJudge(retryAdapter, retry.model, retryJvia);
100
122
  a = { ...a, meta: { ...a.meta, judgeRetry: { flaked: flakedKey, retried: channelKey({ adapter: retry.adapter, model: retry.model }) } } };
101
123
  }
102
- await record(a);
124
+ await withJudgeInvocationEvidence(invocations, () => record(a));
103
125
  if (failed())
104
126
  return { results, commits };
105
127
  }
@@ -4,6 +4,7 @@ export interface Disallowed {
4
4
  by: "deny" | "allow";
5
5
  entry: string;
6
6
  }
7
+ export type PreferenceRole = "worker" | "judge" | "review" | "consult";
7
8
  export declare function excludedChannels(cfg: TickmarkrConfig, adapters: {
8
9
  id: string;
9
10
  }[] | string[], health: Record<string, AuthHealth>): {
@@ -24,7 +25,7 @@ export declare function preferRanks(c: {
24
25
  export declare function disallowedBy(c: {
25
26
  adapter: string;
26
27
  model: string;
27
- }, routing: TickmarkrConfig["routing"]): Disallowed | null;
28
+ }, routing: TickmarkrConfig["routing"], role?: PreferenceRole): Disallowed | null;
28
29
  export interface DenyPreferCollision {
29
30
  kind: "prefer" | "pin";
30
31
  shape: string;
@@ -36,10 +36,16 @@ export function preferRanks(c, cfg) {
36
36
  return out;
37
37
  }
38
38
  // entries in either list accept adapter id, model id, or adapter:model (grammar A1)
39
- export function disallowedBy(c, routing) {
39
+ export function disallowedBy(c, routing, role = "worker") {
40
40
  const { allow, deny } = routing;
41
41
  const matches = (e) => e === c.adapter || e === c.model || e === channelKey(c);
42
- const d = [...(deny?.adapters ?? []), ...(deny?.models ?? [])].find(matches);
42
+ const workerDeny = role === "worker" ? deny?.workers : undefined;
43
+ const d = [
44
+ ...(deny?.adapters ?? []),
45
+ ...(deny?.models ?? []),
46
+ ...(workerDeny?.adapters ?? []),
47
+ ...(workerDeny?.models ?? []),
48
+ ].find(matches);
43
49
  if (d)
44
50
  return { by: "deny", entry: d };
45
51
  if (allow) {
@@ -7,6 +7,7 @@ export interface RunOptions {
7
7
  resume?: boolean;
8
8
  supersedes?: string;
9
9
  graphChanged?: boolean;
10
+ retryFailed?: boolean;
10
11
  concurrency?: number;
11
12
  driver?: ExecutorDriver;
12
13
  adapters?: WorkerAdapter[];
@@ -14,11 +14,12 @@ import { formatOwnedName } from "../drivers/types.js";
14
14
  import { captureBaseline, detectGateCommands, detectVacuousOracles } from "../gates/baseline.js";
15
15
  import { runGates } from "../gates/run-gates.js";
16
16
  import { addEvidence, attributeBlocked, blockedTasks, getTask, graphDefinitionHash, loadGraph, pendingTasks, readyTasks, saveGraph, setStatus } from "../graph/graph.js";
17
+ import { GATE_NAMES } from "../graph/schema.js";
17
18
  import { augmentRetryBrief, consult, renderRetryGuidance } from "./consult.js";
18
19
  import { runEnvironment } from "./environment.js";
19
- import { cleanupRunWorktrees, gitHead, linkNodeModules, sh, shGit, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
20
+ import { cleanupRunWorktrees, gitHead, linkNodeModules, npmDependencyInstallCommand, npmDependencyManifestChanged, sh, shGit, WORKTREE_LAYOUT_CONTRACT, worktreePath } from "./git.js";
20
21
  import { runInteractiveSeed } from "./interactive-seed.js";
21
- import { classifyWorkerResultCause, engagementComparable, Journal, loadRoutingProfile, newRunId } from "./journal.js";
22
+ import { classifyWorkerResultCause, engagementComparable, Journal, loadRoutingProfile, newRunId, phaseForGate, recordedTaskFailureKind } from "./journal.js";
22
23
  import { acquireRunLock, releaseRunLock } from "./lock.js";
23
24
  import { ensureIntegration, integrationBranch, integrationHead, mergeTask, verifyIntegrationTip } from "./merge.js";
24
25
  import { nextChannel, route } from "../route/router.js";
@@ -229,6 +230,7 @@ export async function runDaemon(repoRoot, opts = {}) {
229
230
  // GATE-08 lesson at the humanGate guard: condition on the data, not the code path). Dead-code
230
231
  // equivalence to the router.ts:194 profile⇒undefined pattern: no map entry ⇒ today's literal.
231
232
  const resume = opts.resume ? journal.replayResumeState() : new Map();
233
+ const satisfiedGates = opts.resume ? journal.replaySatisfiedGates() : new Map();
232
234
  const replayedExclusions = opts.resume ? journal.replayExcludedChannels() : new Set();
233
235
  if (opts.resume) {
234
236
  // v1.53 T5: a superseded run is dead — resuming it beside its successor is the exact
@@ -259,7 +261,13 @@ export async function runDaemon(repoRoot, opts = {}) {
259
261
  });
260
262
  }
261
263
  baseline = JSON.parse(readFileSync(join(journal.dir, "baseline.json"), "utf8"));
264
+ const replayEvents = journal.read();
262
265
  for (const [id, st] of journal.replayStatuses()) {
266
+ if (opts.retryFailed && st === "failed" && recordedTaskFailureKind(replayEvents, id) === "dispatch") {
267
+ graph = setStatus(graph, id, "pending");
268
+ resume.delete(id);
269
+ continue;
270
+ }
263
271
  // operator release: a graph.json edit back to "pending" beats a replayed human/failed park (locked decision 12)
264
272
  if ((st === "human" || st === "failed") && getTask(graph, id).status === "pending")
265
273
  continue;
@@ -268,6 +276,7 @@ export async function runDaemon(repoRoot, opts = {}) {
268
276
  journal.append("run-resume", undefined, {
269
277
  pid: process.pid, // v1.13 (VIS-11): record the live daemon pid for status liveness
270
278
  ...(replayedExclusions.size > 0 ? { excludedChannels: [...replayedExclusions].sort() } : {}),
279
+ ...(opts.retryFailed ? { retryFailed: true } : {}),
271
280
  });
272
281
  }
273
282
  else {
@@ -366,7 +375,10 @@ export async function runDaemon(repoRoot, opts = {}) {
366
375
  // merges are serialized — two concurrent `git merge`s in one worktree would corrupt each other
367
376
  let mergeChain = Promise.resolve();
368
377
  const mergeSerial = (taskBranch, t, gated) => {
369
- const next = mergeChain.then(() => mergeTask(intWt, taskBranch, `tickmarkr: merge ${t.id} ${t.title}`, gated));
378
+ const next = mergeChain.then(() => {
379
+ journal.phaseStart(t.id, "merge");
380
+ return mergeTask(intWt, taskBranch, `tickmarkr: merge ${t.id} ${t.title}`, gated);
381
+ });
370
382
  mergeChain = next.catch(() => undefined);
371
383
  return next;
372
384
  };
@@ -526,6 +538,114 @@ export async function runDaemon(repoRoot, opts = {}) {
526
538
  await park(t, `consult verdict: ${v.action} — ${v.notes}`, trigger, assignment, attempts, startMs, gateFails, consults, tokens, metered, retryMode); // decompose|human
527
539
  return false;
528
540
  };
541
+ // OBS-130: an operator-approved gate-fail resumes from the persisted task branch, after the exact
542
+ // satisfied gate. No worker dispatch and no earlier gate/judge invocation occur on this path.
543
+ // replaySatisfiedGates() admits only an explicit task-approved ledger event, scoped by task+gate.
544
+ const satisfiedGate = satisfiedGates.get(t.id);
545
+ if (satisfiedGate) {
546
+ const taskBase = await integrationHead(intWt);
547
+ const taskBranch = `${branch}--${t.id}`;
548
+ const priorWt = worktreePath(repoRoot, taskBranch);
549
+ if (!existsSync(priorWt)) {
550
+ throw new Error(`approved gate ${satisfiedGate} cannot resume: task worktree is missing`);
551
+ }
552
+ const commitsToCarry = await commitsAheadOf(taskBase, priorWt);
553
+ const wt = await driver.worktree(repoRoot, taskBranch, taskBase);
554
+ const carriedCommits = await cherryPickCommits(wt, commitsToCarry);
555
+ journal.append("worktree-recreation", t.id, { attempted: commitsToCarry, carried: carriedCommits });
556
+ if (!linkNodeModules(repoRoot, wt, { force: true })) {
557
+ await park(t, "environmental: node_modules link could not be re-asserted before gates (OBS-47)", "setup", assignment, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
558
+ return;
559
+ }
560
+ if (await npmDependencyManifestChanged(wt, taskBase)) {
561
+ const installCommand = npmDependencyInstallCommand(wt);
562
+ const installed = await sh(installCommand, repoRoot, 10 * 60_000);
563
+ if (installed.code !== 0) {
564
+ throw new Error(`dependency install failed (exit ${installed.code}): ${installed.stderr || installed.stdout}`);
565
+ }
566
+ }
567
+ const workerEvent = [...journal.read()].reverse()
568
+ .find((e) => e.event === "worker-result" && e.taskId === t.id);
569
+ if (!workerEvent)
570
+ throw new Error(`approved gate ${satisfiedGate} cannot resume: worker result is missing`);
571
+ const priorResult = {
572
+ ok: workerEvent.data.ok === true,
573
+ summary: typeof workerEvent.data.summary === "string" ? workerEvent.data.summary : "",
574
+ deviations: Array.isArray(workerEvent.data.deviations)
575
+ ? workerEvent.data.deviations.filter((d) => typeof d === "string")
576
+ : [],
577
+ raw: "",
578
+ };
579
+ const gateAuthor = rs?.lastAssignment ?? assignment;
580
+ const satisfiedIndex = GATE_NAMES.indexOf(satisfiedGate);
581
+ const remainingGates = t.gates.filter((gate) => GATE_NAMES.indexOf(gate) > satisfiedIndex);
582
+ const resumedTask = { ...t, gates: remainingGates };
583
+ gateLoop: while (true) {
584
+ const gated = await gitHead(wt);
585
+ journal.phaseStart(t.id, "gates");
586
+ const { results } = await runGates(resumedTask, {
587
+ worktree: wt, baseRef: taskBase, result: priorResult, author: gateAuthor,
588
+ commands, baseline, channels, adapters, cfg,
589
+ via: cfg.visibility.llm === "pane"
590
+ ? {
591
+ driver: trackedDriver,
592
+ keep: keepLlm,
593
+ onSlot: keepLlm ? (s) => keptSlots.push(s) : undefined,
594
+ nameFor: (role) => formatOwnedName({ role, taskId: t.id, attempt: 0, runId }),
595
+ labelFor: (role) => `${role.toUpperCase()} ${t.id}`,
596
+ }
597
+ : undefined,
598
+ excludeReviewers: badReviewers,
599
+ onGate: async (e) => {
600
+ if (e.phase === "start") {
601
+ journal.phaseStart(t.id, phaseForGate(e.gate), { gate: e.gate, index: e.index, total: e.total });
602
+ return;
603
+ }
604
+ const g = e.result;
605
+ journal.append("gate-result", t.id, {
606
+ gate: g.gate, pass: g.pass, details: g.details,
607
+ ...(g.meta?.skipped === true ? { skipped: true } : {}),
608
+ });
609
+ if (g.gate === "review" && !g.pass && /unparseable/.test(g.details)
610
+ && typeof g.meta?.reviewer === "string") {
611
+ badReviewers.push(g.meta.reviewer);
612
+ }
613
+ },
614
+ });
615
+ const approvedCommits = await commitsAheadOf(taskBase, wt);
616
+ graph = addEvidence(graph, t.id, { commits: approvedCommits, gateResults: results });
617
+ saveGraph(repoRoot, graph);
618
+ if (!results.every((g) => g.pass)) {
619
+ gateFails++;
620
+ await park(t, "post-approval gate failed", "gate-fail", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
621
+ return;
622
+ }
623
+ const m = await mergeSerial(taskBranch, t, gated);
624
+ if (m.tipMoved) {
625
+ journal.append("tip-moved", t.id, m.tipMoved);
626
+ if (tipMoves++ === 0)
627
+ continue gateLoop;
628
+ await park(t, "task branch tip moved twice after gating", "tip-moved", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
629
+ return;
630
+ }
631
+ if (!m.ok) {
632
+ journal.append("merge-conflict", t.id, { conflict: m.conflict });
633
+ await park(t, `merge conflict after approved gate: ${m.conflict ?? "unknown conflict"}`, "merge-conflict", gateAuthor, rs?.attempts ?? 0, startMs, gateFails, consults, tokens, metered, retryMode);
634
+ return;
635
+ }
636
+ graph = setStatus(graph, t.id, "done");
637
+ saveGraph(repoRoot, graph);
638
+ journal.append("task-done", t.id, { attempts: rs?.attempts ?? 0, assignment: gateAuthor });
639
+ journal.append("merge", t.id, { branch: taskBranch, commit: await integrationHead(intWt) });
640
+ journal.telemetry({
641
+ taskId: t.id, shape: t.shape, adapter: gateAuthor.adapter, model: gateAuthor.model,
642
+ channel: gateAuthor.channel, attempts: rs?.attempts ?? 0, outcome: "done",
643
+ durationMs: Date.now() - startMs, firstAttemptOk: false, gateFails, consults, retryMode,
644
+ });
645
+ await reconcile({ spareLiveLlm: true });
646
+ return;
647
+ }
648
+ }
529
649
  // Phase 46 (RES-01): start at the replayed attempt count; a replayed count ≥ MAX_ATTEMPTS parks via
530
650
  // the existing attempt-cap check below with zero new code. Fresh path: rs is undefined ⇒ 0.
531
651
  // v1.24 OBS-18: after task-approved{release:attempt-cap}, replay zeros attempts so this loop
@@ -594,6 +714,7 @@ export async function runDaemon(repoRoot, opts = {}) {
594
714
  graph = setStatus(graph, t.id, "running");
595
715
  saveGraph(repoRoot, graph);
596
716
  journal.append("task-dispatch", t.id, { assignment, attempt, provenance: dispatchProvenance(r.provenance), retryMode });
717
+ journal.phaseStart(t.id, "worker", { attempt, assignment });
597
718
  const taskBase = await integrationHead(intWt); // deps are merged → visible to this task
598
719
  const taskBranch = `${branch}--${t.id}`; // "--": a ref can't nest under the existing integration branch (locked decision 10)
599
720
  const priorWt = worktreePath(repoRoot, taskBranch);
@@ -1044,9 +1165,22 @@ export async function runDaemon(repoRoot, opts = {}) {
1044
1165
  await park(t, "environmental: node_modules link could not be re-asserted before gates (OBS-47)", "setup", assignment, attempt + 1, startMs, gateFails, consults, tokens, metered, retryMode);
1045
1166
  return;
1046
1167
  }
1168
+ // OBS-126: workers cannot write the provisioned node_modules target outside their sandbox.
1169
+ // Once the link is known-good, the daemon installs only attempts whose npm manifest differs
1170
+ // from the integration-tip baseline. Keep lock/package manifests untouched: the worker's
1171
+ // committed files are the deliverable, while this step only provisions the gate-visible tree.
1172
+ if (await npmDependencyManifestChanged(wt, taskBase)) {
1173
+ const installCommand = npmDependencyInstallCommand(wt);
1174
+ const installed = await sh(installCommand, repoRoot, 10 * 60_000);
1175
+ if (installed.code !== 0) {
1176
+ throw new Error(`dependency install failed (exit ${installed.code}): ${installed.stderr || installed.stdout}`);
1177
+ }
1178
+ }
1047
1179
  const onGate = async (e) => {
1048
- if (e.phase === "start")
1180
+ if (e.phase === "start") {
1181
+ journal.phaseStart(t.id, phaseForGate(e.gate), { gate: e.gate, index: e.index, total: e.total });
1049
1182
  return;
1183
+ }
1050
1184
  const g = e.result;
1051
1185
  // GATE-09 (ROADMAP SC-4): journal every judge retry as an attributable event — which gate flaked,
1052
1186
  // which channel flaked, which channel retried — so `tickmarkr journal`/report can distinguish "judge
@@ -1075,6 +1209,7 @@ export async function runDaemon(repoRoot, opts = {}) {
1075
1209
  let commits = [];
1076
1210
  gateLoop: while (true) {
1077
1211
  const gated = await gitHead(wt);
1212
+ journal.phaseStart(t.id, "gates");
1078
1213
  ({ results, commits } = await runGates(t, {
1079
1214
  worktree: wt, baseRef: taskBase, result, author: assignment,
1080
1215
  commands, baseline, channels, adapters, cfg,
package/dist/run/git.d.ts CHANGED
@@ -12,6 +12,8 @@ export declare function sh(cmd: string, cwd: string, timeoutMs?: number): Promis
12
12
  export declare function shGit(cmd: string, cwd: string, timeoutMs?: number): Promise<ShResult>;
13
13
  export declare function shOk(cmd: string, cwd: string): Promise<string>;
14
14
  export declare function shGitOk(cmd: string, cwd: string): Promise<string>;
15
+ export declare function npmDependencyManifestChanged(cwd: string, baselineRef: string): Promise<boolean>;
16
+ export declare function npmDependencyInstallCommand(cwd: string): string;
15
17
  export declare function gitHead(cwd: string): Promise<string>;
16
18
  export declare const sanitizeBranch: (branch: string) => string;
17
19
  export declare const WORKTREES_DIR = "worktrees.noindex";