tickmarkr 1.73.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.
@@ -8,7 +8,8 @@ import { Journal, engagementComparable, isQualityFailureParkKind, recordedTaskFa
8
8
  import { normalizeStallSnapshot } from "../../run/stall.js";
9
9
  // ponytail: fixed 2s refresh; promote to config.visibility.* only when an operator asks.
10
10
  const REFRESH_MS = 2000;
11
- const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; run `tickmarkr run` to execute";
11
+ const NOT_COMPARABLE_NOTICE = "graph recompiled since this run — task states not comparable; resume with `--graph-changed` to audit this recompile";
12
+ const PRIOR_GRAPH_MARKER = "prior graph";
12
13
  // The timer must keep the process ALIVE: an unref'd timer here let the event loop drain after the
13
14
  // first frame, so a live `--watch` printed once and exited 0 (OBS-11). Never unref this.
14
15
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -120,23 +121,28 @@ export const gateBox = (state, unicode) => {
120
121
  return state === "pass" ? "[x]" : state === "fail" ? "[!]" : state === "skip" ? "." : "[ ]";
121
122
  };
122
123
  export const defaultGateStates = (task) => GATE_NAMES.map((gate) => task.gates.includes(gate) ? "open" : "skip");
123
- export const gateStates = (task, events) => {
124
+ const gateSnapshot = (task, events, rehashAt) => {
124
125
  const outcomes = new Map();
125
126
  const start = attemptStartIdx(events, task.id);
126
127
  if (start >= 0) {
127
- for (const e of events.slice(start)) {
128
+ for (let eventIndex = start; eventIndex < events.length; eventIndex++) {
129
+ const e = events[eventIndex];
128
130
  if (e.taskId !== task.id || e.event !== "gate-result" || typeof e.data.gate !== "string")
129
131
  continue;
130
132
  if (e.data.skipped === true)
131
- outcomes.set(e.data.gate, "skip");
133
+ outcomes.set(e.data.gate, { state: "skip", eventIndex });
132
134
  else if (e.data.pass === true)
133
- outcomes.set(e.data.gate, "pass");
135
+ outcomes.set(e.data.gate, { state: "pass", eventIndex });
134
136
  else if (e.data.pass === false)
135
- outcomes.set(e.data.gate, "fail");
137
+ outcomes.set(e.data.gate, { state: "fail", eventIndex });
136
138
  }
137
139
  }
138
- return GATE_NAMES.map((gate) => task.gates.includes(gate) ? outcomes.get(gate) ?? "open" : "skip");
140
+ return {
141
+ states: GATE_NAMES.map((gate) => task.gates.includes(gate) ? outcomes.get(gate)?.state ?? "open" : "skip"),
142
+ priorGraph: rehashAt !== undefined && [...outcomes.values()].some((outcome) => outcome.eventIndex < rehashAt),
143
+ };
139
144
  };
145
+ export const gateStates = (task, events) => gateSnapshot(task, events).states;
140
146
  // verdict semantics only: pass brand green, fail red, skip/open dim chrome — everything else stays quiet
141
147
  const GATE_STATE_TOKEN = { pass: ok, fail, skip: dim, open: dim };
142
148
  // TTY cells are bare glyphs in fixed GATE_NAMES order — gate identity lives once in the frame
@@ -220,6 +226,24 @@ const liveness = (events, now = Date.now()) => {
220
226
  } // EPERM ⇒ alive
221
227
  return `last event ${age} ago · daemon pid ${pid} ${state}${cause ? ` · ${cause}` : ""}`;
222
228
  };
229
+ // Resume's shared comparator remains the fail-closed baseline. Status may additionally accept the
230
+ // daemon's audited graph-rehash release, but only when its `from` names that baseline identity (or
231
+ // null for an explicitly released legacy journal) and its `to` names the graph loaded now.
232
+ const statusEngagement = (events, loadedHash) => {
233
+ const baseline = engagementComparable(events, loadedHash);
234
+ for (let i = events.length - 1; i >= 0; i--) {
235
+ const event = events[i];
236
+ if (event.event !== "graph-rehash")
237
+ continue;
238
+ const auditsBaseline = baseline.comparable || baseline.reason === "mismatch"
239
+ ? event.data.from === baseline.recorded
240
+ : event.data.from === null;
241
+ return event.data.to === loadedHash && auditsBaseline
242
+ ? { comparable: true, rehashAt: i }
243
+ : { comparable: false };
244
+ }
245
+ return { comparable: baseline.comparable };
246
+ };
223
247
  const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness = new Map()) => {
224
248
  const g = loadGraph(cwd);
225
249
  const runId = Journal.latestRunId(cwd, { withJournal: true });
@@ -228,16 +252,18 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
228
252
  let events = [];
229
253
  const contexts = new Map();
230
254
  let comparable = false;
255
+ let rehashAt;
231
256
  let supersededBy; // v1.53 T5: this run is dead — a newer run replaced it
232
257
  if (runId) {
233
258
  const j = Journal.open(cwd, runId);
234
259
  events = j.read();
235
260
  const sup = [...events].reverse().find((e) => e.event === "superseded" && typeof e.data.by === "string");
236
261
  supersededBy = sup?.data.by;
237
- // T3 (Sol #2 / Fable F2): the SAME comparator resume uses (engagementComparable) — one decision,
238
- // two consumers. graphDefinitionHash (compiled task definitions only) survives status/evidence
239
- // mutation but changes when a task definition changes, so a recompiled graph is detected here too.
240
- comparable = engagementComparable(events, graphDefinitionHash(g)).comparable;
262
+ // The resume comparator is the fail-closed baseline; a matching graph-rehash is the daemon's
263
+ // append-only audit that authorizes this status replay after stop-amend-resume.
264
+ const engagement = statusEngagement(events, graphDefinitionHash(g));
265
+ comparable = engagement.comparable;
266
+ rehashAt = engagement.rehashAt;
241
267
  if (comparable) {
242
268
  replayed = j.replayStatuses();
243
269
  for (const e of events) {
@@ -285,15 +311,18 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
285
311
  const channel = assignments.get(t.id) ?? "-";
286
312
  const ctx = contexts.get(t.id);
287
313
  const assignCol = ctx !== undefined ? `${channel}${divider}ctx ${ctx}` : channel;
288
- return { t, st, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
314
+ const gates = comparable
315
+ ? gateSnapshot(t, events, rehashAt)
316
+ : { states: defaultGateStates(t), priorGraph: false };
317
+ return { t, st, failureKind, redTier, label, assignCol, isStarved, phrase, channel, ctx, livePhase, ...gates };
289
318
  });
290
319
  if (!unicode) {
291
320
  // machine/CI surface — journals without phase-start stay byte-identical; new phase-aware frames
292
321
  // use an ASCII spinner so pipes never receive terminal-only braille/ANSI.
293
- const rows = cells.map(({ t, st, label, assignCol, livePhase, states }) => {
322
+ const rows = cells.map(({ t, st, label, assignCol, livePhase, states, priorGraph }) => {
294
323
  const chain = gateChain(states, false);
295
324
  const prefix = livePhase ? ` ${ASCII_SPINNER[animationFrame % ASCII_SPINNER.length]} ${t.id} ` : ` ${taskBox(st)} ${t.id} `;
296
- const suffix = ` ${chain} ${livePhase ? "running" : String(st)}${label} ${assignCol}`;
325
+ const suffix = ` ${chain}${priorGraph ? ` ${PRIOR_GRAPH_MARKER}` : ""} ${livePhase ? "running" : String(st)}${label} ${assignCol}`;
297
326
  return `${prefix}${shortGoal(t.goal, Math.max(0, width - prefix.length - suffix.length))}${suffix}`;
298
327
  });
299
328
  const header = runId
@@ -348,7 +377,7 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
348
377
  const goalW = Math.max(8, ...goals.map((s) => s.length));
349
378
  const indent = " ".repeat(idW + 5); // line 2 starts under the goal column
350
379
  const rows = cells.map((c, i) => {
351
- const { t, st, failureKind, redTier, states, isStarved, phrase, channel, ctx, livePhase } = c;
380
+ const { t, st, failureKind, redTier, states, priorGraph, isStarved, phrase, channel, ctx, livePhase } = c;
352
381
  const word = statusWord(c);
353
382
  const staleWorker = livePhase?.phase === "worker"
354
383
  && workerOutputAge(livePhase, now, workerLiveness.get(t.id)) >= 60_000;
@@ -366,6 +395,7 @@ const renderFrame = (cwd, now = Date.now(), animationFrame = 0, workerLiveness =
366
395
  : ` ${statusRow(taskVerdict(c), taskLabel)}`;
367
396
  // activity already names its channel for in-flight attempts — never repeat it
368
397
  const detail = [
398
+ ...(priorGraph ? [PRIOR_GRAPH_MARKER] : []),
369
399
  ...(phrase ? [phrase, ...(phrase.includes(channel) || channel === "-" ? [] : [channel])] : [channel]),
370
400
  ...(failureKind && !phrase?.includes(failureKind) ? [failureKind] : []),
371
401
  ...(ctx !== undefined ? [`ctx ${ctx}`] : []),
@@ -39,6 +39,7 @@ export declare class HerdrDriver implements ExecutorDriver {
39
39
  private joinGroup;
40
40
  run(slot: Slot, cmd: string): Promise<void>;
41
41
  private deliver;
42
+ private settleDeliveryLine;
42
43
  private deliveryReadMatches;
43
44
  private waitOk;
44
45
  waitOutput(slot: Slot, pattern: string, timeoutMs: number, opts?: {
@@ -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)
@@ -391,8 +393,17 @@ export class HerdrDriver {
391
393
  let transcript = "";
392
394
  for (let attempt = 0; attempt < DELIVERY_ATTEMPTS; attempt++) {
393
395
  if (attempt > 0) {
394
- // clear the corrupted input line before retyping; a failed clear must NOT be retyped onto —
395
- // 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.
396
407
  const cleared = await this.herdr(`pane send-keys ${shq(pane)} C-u`, slot.cwd);
397
408
  if (cleared.code !== 0)
398
409
  throw new Error(`herdr delivery clear failed — refusing to retype onto a corrupted line (OBS-85); pane transcript:\n${transcript}`);
@@ -412,6 +423,21 @@ export class HerdrDriver {
412
423
  }
413
424
  throw new Error(`herdr delivery corrupted after ${DELIVERY_ATTEMPTS} attempts — enter never pressed (OBS-85); pane transcript:\n${transcript}`);
414
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
+ }
415
441
  async deliveryReadMatches(pane, cmd, cwd) {
416
442
  const read = await this.herdr(`pane read ${shq(pane)} --source recent-unwrapped --lines ${DELIVERY_READ_LINES}`, cwd);
417
443
  return read.code === 0 && this.deliveryMatches(read.stdout, cmd);
@@ -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
  }
@@ -92,8 +92,22 @@ export declare const TelemetryRowSchema: z.ZodObject<{
92
92
  vacuous: "vacuous";
93
93
  skipped: "skipped";
94
94
  }>>;
95
+ kind: z.ZodOptional<z.ZodLiteral<"judge">>;
96
+ judgeOutcome: z.ZodOptional<z.ZodEnum<{
97
+ parseable: "parseable";
98
+ unparseable: "unparseable";
99
+ }>>;
95
100
  }, z.core.$strip>;
96
101
  export type TelemetryRow = z.infer<typeof TelemetryRowSchema>;
102
+ export interface JudgeInvocationEvidence {
103
+ taskId: string;
104
+ channel: string;
105
+ outcome: "done" | "failed";
106
+ judgeOutcome: "parseable" | "unparseable";
107
+ durationMs: number;
108
+ transcript?: string;
109
+ }
110
+ export declare function withJudgeInvocationEvidence<T>(invocations: JudgeInvocationEvidence[], persist: () => Promise<T>): Promise<T>;
97
111
  export declare const SIGNAL_BASIS: readonly ["proved", "review-agree", "judge-only", "legacy", "vacuous", "skipped"];
98
112
  export type SignalBasis = (typeof SIGNAL_BASIS)[number];
99
113
  export declare const SIGNAL_QUALITY: Record<SignalBasis, 0 | 0.25 | 0.5 | 0.75 | 1>;
@@ -154,4 +168,5 @@ export declare class Journal {
154
168
  replayExcludedChannels(): Set<string>;
155
169
  telemetry(row: TelemetryRow): void;
156
170
  readTelemetry(): TelemetryRow[];
171
+ readJudgeTelemetry(): TelemetryRow[];
157
172
  }
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { z } from "zod";
@@ -141,7 +142,19 @@ export const TelemetryRowSchema = z.object({
141
142
  // v1.46 additive (T5): gate-signal quality for routing hygiene — OPTIONAL, absent = legacy (0.25 at fold).
142
143
  signalQuality: z.union([z.literal(0), z.literal(0.25), z.literal(0.5), z.literal(0.75), z.literal(1)]).optional(),
143
144
  signalBasis: z.enum(["proved", "review-agree", "judge-only", "legacy", "vacuous", "skipped"]).optional(),
145
+ // OBS-132 additive judge invocation rows. Absent means the legacy per-task row. Judge rows reuse the
146
+ // required v1.5 envelope so per-run raw readers stay source-compatible, while readAllTelemetry filters
147
+ // them before profile derivation: verdict production must be observable without becoming worker reward.
148
+ kind: z.literal("judge").optional(),
149
+ judgeOutcome: z.enum(["parseable", "unparseable"]).optional(),
144
150
  });
151
+ const judgePersistence = new AsyncLocalStorage();
152
+ // run-gates scopes this context around the existing daemon onGate callback. Journal.append therefore
153
+ // remains the sole persistence boundary: it can enrich the existing judge-retry row and write invocation
154
+ // telemetry without requiring a parallel daemon callback or changing healthy gate-result payloads.
155
+ export function withJudgeInvocationEvidence(invocations, persist) {
156
+ return judgePersistence.run({ invocations, written: false }, persist);
157
+ }
145
158
  // v1.46 T5 (Sol signal telemetry): gate-result rows carry explicit signalQuality so future defect windows
146
159
  // are identifiable without forensics. Basis is the provenance claim; quality is the dyadic h-fold weight.
147
160
  export const SIGNAL_BASIS = ["proved", "review-agree", "judge-only", "legacy", "vacuous", "skipped"];
@@ -259,7 +272,7 @@ export function readAllTelemetry(repoRoot, lastK, opts = {}) {
259
272
  for (const runId of runIds) {
260
273
  for (const raw of readJsonl(join(dir, runId, "telemetry.jsonl"))) {
261
274
  const r = TelemetryRowSchema.safeParse(raw);
262
- if (r.success)
275
+ if (r.success && r.data.kind !== "judge")
263
276
  out.push({ ...r.data, runId });
264
277
  }
265
278
  }
@@ -358,7 +371,18 @@ export class Journal {
358
371
  return join(this.dir, "journal.jsonl");
359
372
  }
360
373
  append(event, taskId, data = {}) {
361
- const row = { ts: new Date().toISOString(), event, ...(taskId ? { taskId } : {}), data };
374
+ const evidence = judgePersistence.getStore();
375
+ const failed = evidence?.invocations.filter((invocation) => invocation.transcript !== undefined) ?? [];
376
+ const persistedData = event === "judge-retry" && failed.length > 0
377
+ ? {
378
+ ...data,
379
+ transcript: failed[0].transcript,
380
+ ...(failed[1] ? { retryTranscript: failed[1].transcript } : {}),
381
+ }
382
+ : data;
383
+ const row = {
384
+ ts: new Date().toISOString(), event, ...(taskId ? { taskId } : {}), data: persistedData,
385
+ };
362
386
  // T3 secret redaction: only the persisted bytes are masked — the caller's data stays untouched in
363
387
  // memory. The narrator receives the persisted (masked) row so a pane sink never shows a credential.
364
388
  const line = redactSecrets(JSON.stringify(row));
@@ -369,6 +393,26 @@ export class Journal {
369
393
  catch {
370
394
  // narration is observational; a broken sink must not affect the journal or run
371
395
  }
396
+ if (evidence && !evidence.written && (event === "judge-retry" || event === "gate-result")) {
397
+ evidence.written = true;
398
+ for (const invocation of evidence.invocations) {
399
+ const colon = invocation.channel.indexOf(":");
400
+ const adapter = colon === -1 ? invocation.channel : invocation.channel.slice(0, colon);
401
+ const model = colon === -1 ? "" : invocation.channel.slice(colon + 1);
402
+ this.telemetry({
403
+ kind: "judge",
404
+ taskId: invocation.taskId,
405
+ shape: "judge",
406
+ adapter,
407
+ model,
408
+ channel: invocation.channel,
409
+ attempts: 1,
410
+ outcome: invocation.outcome,
411
+ durationMs: invocation.durationMs,
412
+ judgeOutcome: invocation.judgeOutcome,
413
+ });
414
+ }
415
+ }
372
416
  }
373
417
  phaseStart(taskId, phase, data = {}) {
374
418
  this.append("phase-start", taskId, { ...data, phase });
@@ -506,8 +550,15 @@ export class Journal {
506
550
  // T3 secret redaction: same persistence seam as append — credential-free rows are byte-identical.
507
551
  appendFileSync(join(this.dir, "telemetry.jsonl"), redactSecrets(JSON.stringify(row)) + "\n");
508
552
  }
509
- // Per-run, raw (NOT schema-validated) — report.ts reads v1.5 core fields; stays byte-compatible.
553
+ // Per-run task rows stay byte-compatible for legacy readers (report and task-level test helpers).
554
+ // Judge rows share telemetry.jsonl but have a dedicated reader so their earlier gate-time ordering
555
+ // cannot make a find(taskId) caller mistake invocation evidence for the later terminal task row.
510
556
  readTelemetry() {
511
- return readJsonl(join(this.dir, "telemetry.jsonl"));
557
+ return readJsonl(join(this.dir, "telemetry.jsonl"))
558
+ .filter((row) => !(row && typeof row === "object" && "kind" in row && row.kind === "judge"));
559
+ }
560
+ readJudgeTelemetry() {
561
+ return readJsonl(join(this.dir, "telemetry.jsonl"))
562
+ .filter((row) => row && typeof row === "object" && "kind" in row && row.kind === "judge");
512
563
  }
513
564
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.73.0",
3
+ "version": "1.74.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",