tickmarkr 1.44.0 → 1.46.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.
@@ -1,18 +1,93 @@
1
+ import { z } from "zod";
1
2
  import { channelKey, shq } from "../adapters/types.js";
2
3
  import { DEFAULT_DIFF_CAP } from "../config/config.js";
3
4
  import { renderAcceptanceItem } from "../graph/schema.js";
4
5
  import { sh } from "../run/git.js";
5
6
  import { checkDiffCap, fetchTaskDiff } from "./review.js";
6
- import { extractJson, runLlm } from "./llm.js";
7
+ import { extractVerdictJson, generateVerdictNonce, runLlm, verdictNonceLine } from "./llm.js";
8
+ // Fable F4: acceptance judge shares review's 900s timeout — 300s default killed frontier judges on cap-sized diffs.
9
+ const JUDGE_TIMEOUT_MS = 900_000;
10
+ const JudgeVerdictRowSchema = z.object({
11
+ criterion: z.string(),
12
+ met: z.boolean(),
13
+ reason: z.string(),
14
+ });
15
+ const JudgeVerdictSchema = z.object({
16
+ pass: z.boolean(),
17
+ criteria: z.array(JudgeVerdictRowSchema),
18
+ });
19
+ export function judgeCriterionId(index) {
20
+ return `c${index + 1}`;
21
+ }
22
+ function renderJudgeCriterion(item, index) {
23
+ return `[${judgeCriterionId(index)}] ${renderAcceptanceItem(item)}`;
24
+ }
25
+ function checkJudgeVerdict(raw, expectedIds) {
26
+ const parsed = JudgeVerdictSchema.safeParse(raw);
27
+ if (!parsed.success) {
28
+ return {
29
+ verdict: { pass: false, criteria: [] },
30
+ inconsistencies: ["judge verdict inconsistent: malformed verdict shape"],
31
+ };
32
+ }
33
+ const v = parsed.data;
34
+ const inconsistencies = [];
35
+ const counts = new Map();
36
+ for (const row of v.criteria) {
37
+ counts.set(row.criterion, (counts.get(row.criterion) ?? 0) + 1);
38
+ }
39
+ for (const [id, n] of counts) {
40
+ if (n > 1)
41
+ inconsistencies.push(`judge verdict inconsistent: duplicate criterion id ${id}`);
42
+ if (!expectedIds.includes(id))
43
+ inconsistencies.push(`judge verdict inconsistent: unknown criterion id ${id}`);
44
+ }
45
+ for (const id of expectedIds) {
46
+ if (!counts.has(id))
47
+ inconsistencies.push(`judge verdict inconsistent: missing criterion id ${id}`);
48
+ }
49
+ for (const row of v.criteria) {
50
+ if (!row.met && v.pass) {
51
+ inconsistencies.push(`judge verdict inconsistent: pass=true contradicts unmet criterion ${row.criterion}`);
52
+ }
53
+ }
54
+ if (!v.pass && v.criteria.every((row) => row.met) && inconsistencies.length === 0 && v.criteria.length === expectedIds.length) {
55
+ inconsistencies.push("judge verdict inconsistent: pass=false contradicts all criteria met");
56
+ }
57
+ return { verdict: v, inconsistencies };
58
+ }
7
59
  const isCommand = (a) => typeof a === "object" && a.oracle === "command";
8
60
  const isTest = (a) => typeof a === "object" && a.oracle === "test";
9
61
  const isJudge = (a) => typeof a === "string" || (typeof a === "object" && a.oracle === "judge");
10
62
  // npm/yarn/pnpm/npx script wrappers need `--` to forward -t to the underlying vitest/jest runner; a bare
11
63
  // runner (vitest/jest) takes -t directly. -t is the shared testNamePattern shorthand both honor.
12
- function testFiltered(testCmd, name) {
13
- const fwd = /^\s*(npm|yarn|pnpm|npx)\b/.test(testCmd) ? "-- " : "";
64
+ // OBS-55: when the base command already contains `--`, append -t after forwarded args — a second `--`
65
+ // makes vitest treat -t as a positional file filter and the name filter is dropped.
66
+ export function testFiltered(testCmd, name) {
67
+ const wrapped = /^\s*(npm|yarn|pnpm|npx)\b/.test(testCmd);
68
+ if (wrapped && /\s--\s/.test(testCmd))
69
+ return `${testCmd} -t ${shq(name)}`;
70
+ const fwd = wrapped ? "-- " : "";
14
71
  return `${testCmd} ${fwd}-t ${shq(name)}`;
15
72
  }
73
+ // vitest/jest summary: "Tests N passed | M skipped (T)" — count passed+failed as actually ran.
74
+ function testsRan(output) {
75
+ const lines = output.replace(/\x1b\[[\d;#]*[A-Za-z]/g, "").split("\n");
76
+ for (let i = lines.length - 1; i >= 0; i--) {
77
+ const line = lines[i].trim();
78
+ const m = line.match(/^Tests\s+(.+?)\s*\(\d+\)\s*$/);
79
+ if (!m)
80
+ continue;
81
+ let ran = 0;
82
+ for (const chunk of m[1].split("|").map((s) => s.trim())) {
83
+ const n = chunk.match(/^(\d+)\s+(passed|failed)\b/);
84
+ if (n)
85
+ ran += Number(n[1]);
86
+ }
87
+ return ran;
88
+ }
89
+ return null;
90
+ }
16
91
  // last few non-empty output lines, for context on a failing oracle (capped so details stay readable)
17
92
  function tail(out, n = 8) {
18
93
  const t = out.trim();
@@ -39,10 +114,17 @@ export async function acceptanceGate(task, worktree, baseRef, judge, via, opts =
39
114
  details: `oracle failed: test "${a.test}" — no test command configured to run it (failing closed)` };
40
115
  }
41
116
  const r = await sh(testFiltered(opts.testCmd, a.test), worktree);
117
+ const out = (r.stderr || "") + "\n" + (r.stdout || "");
42
118
  if (r.code !== 0) {
43
119
  return { gate: "acceptance", pass: false,
44
120
  details: `oracle failed: test "${a.test}" (exit ${r.code})${tail(r.stderr || r.stdout)}` };
45
121
  }
122
+ // OBS-55: exit 0 alone is vacuous when the name filter matched zero tests — fail closed.
123
+ const ran = testsRan(out);
124
+ if (ran === null || ran < 1) {
125
+ return { gate: "acceptance", pass: false,
126
+ details: `oracle failed: test "${a.test}" — name filter matched zero tests (filter: ${a.test})${tail(out)}` };
127
+ }
46
128
  passedDet.push(`✓ test: ${a.test} (exit 0)`);
47
129
  }
48
130
  }
@@ -63,6 +145,8 @@ export async function acceptanceGate(task, worktree, baseRef, judge, via, opts =
63
145
  const capFail = checkDiffCap("acceptance", forCap.length, diffCap, warn + detBlock);
64
146
  if (capFail)
65
147
  return capFail;
148
+ const expectedIds = judgeItems.map((_, index) => judgeCriterionId(index));
149
+ const nonce = generateVerdictNonce();
66
150
  const prompt = `TICKMARKR-JUDGE
67
151
  You are a strict acceptance judge. Decide whether the diff satisfies EVERY acceptance criterion.
68
152
  Judge only what the diff proves — plausible-but-wrong must fail. Do not award partial credit.
@@ -72,19 +156,22 @@ Deterministic command/test oracles have already passed mechanically; judge ONLY
72
156
  Goal: ${task.goal}
73
157
 
74
158
  ## Acceptance criteria (judge)
75
- ${judgeItems.map((a) => `- ${renderAcceptanceItem(a)}`).join("\n")}
159
+ ${judgeItems.map((a, index) => `- ${renderJudgeCriterion(a, index)}`).join("\n")}
76
160
 
77
161
  ## Diff (vs base)
78
162
  \`\`\`diff
79
163
  ${diff}
80
164
  \`\`\`
81
165
 
166
+ ${verdictNonceLine(nonce)}
167
+
82
168
  Respond with ONLY this JSON (no prose before or after):
83
- {"pass": true|false, "criteria": [{"criterion": "...", "met": true|false, "reason": "..."}]}
169
+ {"nonce": "${nonce}", "pass": true|false, "criteria": [{"criterion": "c1", "met": true|false, "reason": "..."}]}
170
+ Each criteria[].criterion MUST be the stable id from the rubric (c1, c2, ...) exactly once.
84
171
  `;
85
- const raw = await runLlm(judge.adapter, judge.model, prompt, worktree, via);
86
- const v = extractJson(raw);
87
- if (!v || typeof v.pass !== "boolean" || !Array.isArray(v.criteria)) {
172
+ const raw = await runLlm(judge.adapter, judge.model, prompt, worktree, via, JUDGE_TIMEOUT_MS);
173
+ const extracted = extractVerdictJson(raw, nonce);
174
+ if (!extracted) {
88
175
  // GATE-09: structured meta names the flaked judge channel (mirrors review.ts:99 meta precedent) so
89
176
  // run-gates can retry the judge on a failover channel without string-matching details (D-03).
90
177
  // The parsed-verdict paths below are untouched — the flake signal is exactly extractJson→null here.
@@ -92,25 +179,9 @@ Respond with ONLY this JSON (no prose before or after):
92
179
  details: warn + detBlock + "judge output unparseable — failing closed",
93
180
  meta: { unparseable: true, judge: channelKey({ adapter: judge.adapter.id, model: judge.model }) } };
94
181
  }
95
- const inconsistencies = [];
96
- const expected = judgeItems.length;
97
- const received = v.criteria.length;
98
- // OBS-51: judges sometimes split one semicolon-joined criterion into multiple verdict items.
99
- const clauseSplitAllPass = received > expected
100
- && v.criteria.every((c) => c && typeof c === "object" && c.met === true);
101
- if (received !== expected && !clauseSplitAllPass) {
102
- inconsistencies.push(`judge verdict inconsistent: criteria count mismatch — expected ${expected}, received ${received}`);
103
- }
104
- v.criteria.forEach((c, i) => {
105
- if (!c || typeof c !== "object" || c.met !== true) {
106
- const unmet = c && typeof c === "object" && c.criterion ? ` — unmet clause: ${c.criterion}` : "";
107
- inconsistencies.push(`judge verdict inconsistent: criteria[${i}].met must be true${unmet}`);
108
- }
109
- });
110
- const pass = v.pass === true && inconsistencies.length === 0;
111
- const lines = v.criteria.map((c, i) => c && typeof c === "object"
112
- ? `${c.met ? "✓" : "✗"} ${c.criterion}: ${c.reason}`
113
- : `✗ criteria[${i}]: invalid criterion`);
182
+ const { verdict: v, inconsistencies } = checkJudgeVerdict(extracted, expectedIds);
183
+ const pass = v.pass === true && inconsistencies.length === 0 && v.criteria.every((row) => row.met);
184
+ const lines = v.criteria.map((row) => `${row.met ? "✓" : "✗"} ${row.criterion}: ${row.reason}`);
114
185
  if (!v.pass)
115
186
  lines.push("judge verdict pass=false");
116
187
  lines.push(...inconsistencies);
@@ -1,6 +1,12 @@
1
1
  import type { WorkerAdapter } from "../adapters/types.js";
2
2
  import { type ExecutorDriver, type Slot } from "../drivers/types.js";
3
3
  export declare const GATE_PANE_SEP = " \u00B7 ";
4
+ /** Fable F3: per-call nonce echoed in verdict JSON and gate exit markers. */
5
+ export declare function generateVerdictNonce(): string;
6
+ export declare function verdictNonceLine(nonce: string): string;
7
+ export declare function extractPromptNonce(prompt: string): string | null;
8
+ export declare function gateExitTrailer(nonce: string): string;
9
+ export declare function augmentFakeVerdictOutput(adapter: WorkerAdapter, out: string, nonce: string): string;
4
10
  export type GatePaneRole = "judge" | "review" | "consult";
5
11
  /** T8: role-first pane name for fleet visibility — judge · T4, review · T3, consult · T2. */
6
12
  export declare function gatePaneName(role: GatePaneRole, taskId: string, suffix?: string): string;
@@ -23,3 +29,5 @@ export declare function runHeadless(adapter: WorkerAdapter, model: string, promp
23
29
  export declare function runViaDriver(adapter: WorkerAdapter, model: string, prompt: string, cwd: string, via: LlmVia, timeoutMs?: number): Promise<string>;
24
30
  export declare function runLlm(adapter: WorkerAdapter, model: string, prompt: string, cwd: string, via?: LlmVia, timeoutMs?: number): Promise<string>;
25
31
  export declare function extractJson<T>(raw: string): T | null;
32
+ /** Fable F3: verdict JSON must echo the call nonce — skip unbound or mismatched objects. */
33
+ export declare function extractVerdictJson<T>(raw: string, nonce: string): T | null;
package/dist/gates/llm.js CHANGED
@@ -1,10 +1,35 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { mkdtempSync, writeFileSync } from "node:fs";
2
3
  import { tmpdir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { formatOwnedName, parseOwnedName } from "../drivers/types.js";
5
- import { bannerShell, paneDispatchCommand, paneDispatchScript } from "../brand.js";
6
+ import { bannerShell, paneDispatchCommand } from "../brand.js";
6
7
  import { sh } from "../run/git.js";
7
8
  export const GATE_PANE_SEP = " · ";
9
+ /** Fable F3: per-call nonce echoed in verdict JSON and gate exit markers. */
10
+ export function generateVerdictNonce() {
11
+ return randomBytes(4).toString("hex");
12
+ }
13
+ export function verdictNonceLine(nonce) {
14
+ return `VERDICT_NONCE: ${nonce}`;
15
+ }
16
+ export function extractPromptNonce(prompt) {
17
+ return /VERDICT_NONCE:\s*([0-9a-f]+)/i.exec(prompt)?.[1] ?? null;
18
+ }
19
+ export function gateExitTrailer(nonce) {
20
+ return `printf '\\nTICKMARKR_''EXIT_${nonce}:%s\\n' $?`;
21
+ }
22
+ // ponytail: fake adapter serves static verdict JSON without nonce; append a bound copy for zero-token tests.
23
+ export function augmentFakeVerdictOutput(adapter, out, nonce) {
24
+ if (adapter.id !== "fake")
25
+ return out;
26
+ const obj = extractJson(out);
27
+ if (!obj || typeof obj !== "object" || obj.nonce === nonce)
28
+ return out;
29
+ if (typeof obj.nonce === "string")
30
+ return out;
31
+ return `${out}\n${JSON.stringify({ ...obj, nonce })}`;
32
+ }
8
33
  /** T8: role-first pane name for fleet visibility — judge · T4, review · T3, consult · T2. */
9
34
  export function gatePaneName(role, taskId, suffix = "") {
10
35
  return `${role}${GATE_PANE_SEP}${taskId}${suffix}`;
@@ -33,7 +58,11 @@ export async function runHeadless(adapter, model, prompt, cwd, timeoutMs = 30000
33
58
  const pf = join(mkdtempSync(join(tmpdir(), "tickmarkr-llm-")), "prompt.md");
34
59
  writeFileSync(pf, prompt);
35
60
  const r = await sh(adapter.headlessCommand(pf, model), cwd, timeoutMs);
36
- return r.stdout + "\n" + r.stderr;
61
+ const nonce = extractPromptNonce(prompt);
62
+ let out = r.stdout + "\n" + r.stderr;
63
+ if (nonce)
64
+ out = augmentFakeVerdictOutput(adapter, out, nonce);
65
+ return out;
37
66
  }
38
67
  // v1.1 default path: the same headless CLI call, but dispatched through the driver
39
68
  // as a visible named agent (herdr pane), with the quote-split completion wrapper.
@@ -43,17 +72,23 @@ export async function runViaDriver(adapter, model, prompt, cwd, via, timeoutMs =
43
72
  writeFileSync(pf, prompt);
44
73
  const scriptPath = join(dir, "dispatch.sh");
45
74
  // OBS-50: bootstrap in a script beside the prompt — pane sees one short bash line + banner, not the raw inline command
46
- writeFileSync(scriptPath, paneDispatchScript([bannerShell(), adapter.headlessCommand(pf, model)]));
75
+ const nonce = extractPromptNonce(prompt) ?? generateVerdictNonce();
76
+ writeFileSync(scriptPath, [
77
+ "export BASH_SILENCE_DEPRECATION_WARNING=1",
78
+ bannerShell(),
79
+ adapter.headlessCommand(pf, model),
80
+ gateExitTrailer(nonce),
81
+ ].join("\n"));
47
82
  const slot = await via.driver.slot(cwd, rolePaneNameFromPrompt(prompt, via.name), via.label ? { label: via.label } : undefined);
48
83
  via.onSlot?.(slot);
49
84
  await via.driver.run(slot, paneDispatchCommand(scriptPath));
50
- // wait for the digit-suffixed marker (a real $? exit code), never a bare "TICKMARKR_EXIT:" that a
51
- // self-referential diff under review merely DISPLAYS — same guard the worker path uses (daemon.ts:193).
52
- // Without it, a judge/review pane echoing tickmarkr's own source false-completes before its verdict lands.
53
- await via.driver.waitOutput(slot, "TICKMARKR_EXIT:\\d", timeoutMs, { regex: true });
54
- const out = await via.driver.read(slot, 400);
85
+ // nonce-suffixed exit only: a displayed bare "TICKMARKR_EXIT:" or another call's marker must not
86
+ // false-complete — same guard the worker path uses (daemon.ts:330-331).
87
+ await via.driver.waitOutput(slot, `TICKMARKR_EXIT_${nonce}:\\d`, timeoutMs, { regex: true });
88
+ let out = await via.driver.read(slot, 400);
55
89
  if (!via.keep)
56
90
  await via.driver.close(slot);
91
+ out = augmentFakeVerdictOutput(adapter, out, nonce);
57
92
  return out;
58
93
  }
59
94
  export function runLlm(adapter, model, prompt, cwd, via, timeoutMs = 300000) {
@@ -104,3 +139,52 @@ export function extractJson(raw) {
104
139
  }
105
140
  return null;
106
141
  }
142
+ /** Fable F3: verdict JSON must echo the call nonce — skip unbound or mismatched objects. */
143
+ export function extractVerdictJson(raw, nonce) {
144
+ const fenced = [...raw.matchAll(/```json\s*\n([\s\S]*?)```/g)];
145
+ for (let fi = fenced.length - 1; fi >= 0; fi--) {
146
+ try {
147
+ const v = JSON.parse(fenced[fi][1]);
148
+ if (v && typeof v === "object" && v.nonce === nonce) {
149
+ const { nonce: _n, ...rest } = v;
150
+ return rest;
151
+ }
152
+ }
153
+ catch {
154
+ /* fall through */
155
+ }
156
+ }
157
+ let pos = raw.length - 1;
158
+ while (pos >= 0) {
159
+ const end = raw.lastIndexOf("}", pos);
160
+ if (end === -1)
161
+ return null;
162
+ let depth = 1;
163
+ let stepped = false;
164
+ for (let i = end - 1; i >= 0; i--) {
165
+ if (raw[i] === "}")
166
+ depth++;
167
+ else if (raw[i] === "{") {
168
+ depth--;
169
+ if (depth === 0) {
170
+ stepped = true;
171
+ try {
172
+ const v = JSON.parse(raw.slice(i, end + 1));
173
+ if (v && typeof v === "object" && v.nonce === nonce) {
174
+ const { nonce: _n, ...rest } = v;
175
+ return rest;
176
+ }
177
+ }
178
+ catch {
179
+ /* keep scanning */
180
+ }
181
+ pos = i - 1;
182
+ break;
183
+ }
184
+ }
185
+ }
186
+ if (!stepped)
187
+ return null;
188
+ }
189
+ return null;
190
+ }
@@ -4,7 +4,7 @@ import { renderAcceptanceItem } from "../graph/schema.js";
4
4
  import { getAdapter } from "../adapters/registry.js";
5
5
  import { shOk } from "../run/git.js";
6
6
  import { marginalCostRank } from "../route/router.js";
7
- import { extractJson, runLlm } from "./llm.js";
7
+ import { extractVerdictJson, generateVerdictNonce, runLlm, verdictNonceLine } from "./llm.js";
8
8
  // OBS-48: cap on zero-context diff bytes (git diff -U0), not context-padded full diff — scattered
9
9
  // one-line hunks no longer trip at ~370 diff-bytes per changed line. Full diff still goes to the judge.
10
10
  const DIFF_CAP_REMEDY = "split the task, or raise gates.diffCap";
@@ -68,6 +68,7 @@ export async function reviewGate(task, worktree, baseRef, author, channels, adap
68
68
  const capFail = checkDiffCap("review", forCap.length, diffCap);
69
69
  if (capFail)
70
70
  return capFail;
71
+ const nonce = generateVerdictNonce();
71
72
  const prompt = `TICKMARKR-REVIEW
72
73
  You are a skeptical cross-vendor code reviewer. Another agent (vendor: ${author.adapter}) authored this diff.
73
74
  Look for correctness bugs, security issues, and acceptance-criteria gaps. Approve only if you would merge it.
@@ -81,8 +82,10 @@ ${task.acceptance.map((a) => `- ${renderAcceptanceItem(a)}`).join("\n")}
81
82
  ${diff}
82
83
  \`\`\`
83
84
 
85
+ ${verdictNonceLine(nonce)}
86
+
84
87
  Respond with ONLY this JSON:
85
- {"approve": true|false, "issues": ["..."]}
88
+ {"nonce": "${nonce}", "approve": true|false, "issues": ["..."]}
86
89
  `;
87
90
  const raw = await runLlm(getAdapter(reviewer.adapter, adapters), reviewer.model, prompt, worktree, via ? { driver: via.driver, keep: via.keep, onSlot: via.onSlot, name: via.nameFor("review", reviewer.adapter), label: via.labelFor("review") } : undefined,
88
91
  // frontier reviewers routinely need >5min on a configured-cap-sized diff, and `claude -p` buffers all
@@ -91,7 +94,7 @@ Respond with ONLY this JSON:
91
94
  // (run-20260709-104447 P87-09). ponytail: literal 15min; make it cfg.review.timeoutMs if a
92
95
  // second knob-turner appears.
93
96
  900_000);
94
- const v = extractJson(raw);
97
+ const v = extractVerdictJson(raw, nonce);
95
98
  if (!v || typeof v.approve !== "boolean" || !Array.isArray(v.issues)) {
96
99
  return {
97
100
  gate: "review",
@@ -1,5 +1,5 @@
1
1
  import picomatch from "picomatch";
2
- import { shOk } from "../run/git.js";
2
+ import { shGitOk } from "../run/git.js";
3
3
  /** Pure offender split — corpus-replay oracle exercises this exact decision logic. */
4
4
  export function dispositionOffenders(offenders, allowDeviations) {
5
5
  const allowedMatch = allowDeviations.length ? picomatch(allowDeviations, { dot: true }) : () => false;
@@ -16,7 +16,7 @@ export function dispositionOffenders(offenders, allowDeviations) {
16
16
  export async function scopeGate(worktree, baseRef, files, result, allowDeviations = []) {
17
17
  if (!files.length)
18
18
  return { gate: "scope", pass: true, details: "no file scope declared — unrestricted" };
19
- const changed = (await shOk(`git diff --name-only '${baseRef}..HEAD'`, worktree)).trim().split("\n").filter(Boolean);
19
+ const changed = (await shGitOk(`git diff --name-only '${baseRef}..HEAD'`, worktree)).trim().split("\n").filter(Boolean);
20
20
  const inScope = picomatch(files, { dot: true }); // byte-identical options to assertWriteScope in src/compile/common.ts
21
21
  const offenders = changed.filter((f) => !inScope(f));
22
22
  if (!offenders.length)
@@ -1,6 +1,7 @@
1
1
  import { type RunGraph, type Task, type TaskStatus } from "./schema.js";
2
2
  export declare function stateDirName(_repoRoot: string): string;
3
3
  export declare function graphPath(repoRoot: string): string;
4
+ export declare function graphDefinitionHash(g: RunGraph): string;
4
5
  export declare function tickmarkrDir(repoRoot: string): string;
5
6
  export declare function loadGraph(repoRoot: string): RunGraph;
6
7
  export declare function saveGraph(repoRoot: string, g: RunGraph): void;
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { validateGraph } from "./schema.js";
@@ -7,6 +8,17 @@ export function stateDirName(_repoRoot) {
7
8
  export function graphPath(repoRoot) {
8
9
  return join(repoRoot, stateDirName(repoRoot), "graph.json");
9
10
  }
11
+ // T3 (Sol #2 / Fable F2): ONE canonical engagement identity over COMPILED TASK DEFINITIONS only.
12
+ // status/evidence are runtime-mutated (the daemon flips status, accumulates evidence every attempt) so
13
+ // they are excluded — the identity survives a status flip or evidence growth but changes the instant a
14
+ // task definition changes (goal/acceptance/deps/gates/etc.). Shared by status AND resume through the
15
+ // single comparator in journal.ts (engagementComparable) so the journal↔graph join is decided once.
16
+ // ponytail: sha256 truncated to 16 hex — stable, grep-friendly; promote to full digest only if a
17
+ // collision ever bites (engagement ids are not a trust boundary, collisions just force a re-run).
18
+ export function graphDefinitionHash(g) {
19
+ const definitions = g.tasks.map(({ status: _status, evidence: _evidence, ...def }) => def);
20
+ return createHash("sha256").update(JSON.stringify({ version: g.version, spec: g.spec, tasks: definitions })).digest("hex").slice(0, 16);
21
+ }
10
22
  export function tickmarkrDir(repoRoot) {
11
23
  const dir = join(repoRoot, stateDirName(repoRoot));
12
24
  mkdirSync(dir, { recursive: true });
@@ -1,5 +1,5 @@
1
1
  export function scopePrompt(intent, repair) {
2
- return `TICKMARKR-JUDGE
2
+ return `TICKMARKR-SCOPE
3
3
  You are drafting a tickmarkr native spec from an answered intent. Return only the Markdown spec, with no commentary or code fence.
4
4
 
5
5
  The draft must:
@@ -11,6 +11,7 @@ export interface ProfileRow {
11
11
  consults?: number;
12
12
  parkKind?: string;
13
13
  runId?: string;
14
+ taskId?: string;
14
15
  quotaFailover?: true;
15
16
  overrun?: true;
16
17
  }
@@ -23,6 +24,7 @@ export interface ProfileCell {
23
24
  doneMedianMs?: number;
24
25
  nRaw?: number;
25
26
  overruns?: number;
27
+ discounted?: number;
26
28
  }
27
29
  export interface RoutingProfile {
28
30
  cells: Map<string, ProfileCell>;
@@ -38,10 +40,20 @@ export declare const EXPLORE_CAP = 5;
38
40
  export declare const HALF_LIFE_RUNS = 5;
39
41
  export declare const DECAY_CAP = 30;
40
42
  export declare function decayWeight(age: number, halfLife?: number): number;
43
+ export declare const HYGIENE_WEIGHTS: readonly [0, 0.5, 1];
44
+ export type HygieneWeight = (typeof HYGIENE_WEIGHTS)[number];
45
+ export interface ProfileDiscount {
46
+ runId: string;
47
+ taskId?: string;
48
+ weight: 0 | 0.5;
49
+ reason: string;
50
+ }
51
+ export declare function resolveHygieneWeight(row: ProfileRow, discounts?: readonly ProfileDiscount[]): HygieneWeight;
41
52
  export declare function explorationBonus(cell: ProfileCell | undefined): number;
42
53
  export declare function classify(r: ProfileRow): 1 | 0.5 | 0 | null;
43
54
  export declare function buildProfile(rows: ProfileRow[], opts?: {
44
55
  halfLifeRuns?: number;
56
+ discounts?: readonly ProfileDiscount[];
45
57
  }): RoutingProfile;
46
58
  export declare function cellOf(profile: RoutingProfile | undefined, shape: string, chKey: string, channel: string): ProfileCell | undefined;
47
59
  export declare function cellsOf(profile: RoutingProfile): Generator<{
@@ -58,8 +70,18 @@ export interface CellSummary {
58
70
  quotaHits: number;
59
71
  cold: boolean;
60
72
  exploreRemaining: number;
73
+ discounted: number;
61
74
  }
62
75
  export declare function cellSummary(cell: ProfileCell): CellSummary;
76
+ export interface LearnedScoreTerms {
77
+ quality: number;
78
+ perf: number;
79
+ avail: number;
80
+ overrun: number;
81
+ }
82
+ export declare function learnedScoreTerms(profile: RoutingProfile | undefined, shape: string, chKey: string, channel: string, opts?: {
83
+ availWeight?: number;
84
+ }): LearnedScoreTerms;
63
85
  export declare function learnedScore(profile: RoutingProfile | undefined, shape: string, chKey: string, channel: string, opts?: {
64
86
  availWeight?: number;
65
87
  }): number;
@@ -31,6 +31,25 @@ export const DECAY_CAP = 30;
31
31
  export function decayWeight(age, halfLife = HALF_LIFE_RUNS) {
32
32
  return 2 ** -Math.min(Math.floor(age / halfLife), DECAY_CAP);
33
33
  }
34
+ // v1.46 T5 evidence hygiene — operator-filed discounts on poisoned runs/tasks. h ∈ {0, 0.5, 1} is dyadic
35
+ // so h·w (w a power of two) and every partial sum stay exact; magnitude headroom loses one bit (2^-32 floor).
36
+ export const HYGIENE_WEIGHTS = [0, 0.5, 1];
37
+ // Resolve h for one row: run-level marks (no taskId) apply to every row in the run; task-level marks are
38
+ // selective. Multiple marks ⇒ minimum weight (most aggressive discount wins).
39
+ export function resolveHygieneWeight(row, discounts = []) {
40
+ if (!discounts.length || row.runId === undefined)
41
+ return 1;
42
+ let h = 1;
43
+ for (const d of discounts) {
44
+ if (d.runId !== row.runId)
45
+ continue;
46
+ if (d.taskId !== undefined && d.taskId !== row.taskId)
47
+ continue;
48
+ if (d.weight < h)
49
+ h = d.weight;
50
+ }
51
+ return h;
52
+ }
34
53
  // Phase 14 exploration bonus: an under-observed channel keeps gathering evidence so early bad luck can't
35
54
  // starve it permanently (EXP-01). Total by construction — dispatches is a finite non-negative integer counter,
36
55
  // the denominator is a compile-time positive constant; no ln/sqrt/data-dependent divide, so none of the
@@ -107,8 +126,12 @@ export function buildProfile(rows, opts = {}) {
107
126
  // in IEEE-754 ⇒ the fold stays order-insensitive (v1.7's invariant EXTENDED, not abandoned). Magnitude
108
127
  // headroom: weights are multiples of 2^-31, so exactness holds while row-count ≪ 2^21. nRaw is the
109
128
  // integer undecayed observation count (Phase 28) — never enters the score arithmetic.
129
+ // SINGLE evidence-fold site — hygiene h multiplies quality evidence ONLY (n, qSum), never dispatches/quotaHits/overruns.
110
130
  if (q !== null) {
111
- const w = decayWeight(ageOf(r.runId), halfLife);
131
+ const h = resolveHygieneWeight(r, opts.discounts);
132
+ const w = decayWeight(ageOf(r.runId), halfLife) * h;
133
+ if (h < 1)
134
+ c.discounted = (c.discounted ?? 0) + 1;
112
135
  c.n += w;
113
136
  c.qSum += q * w;
114
137
  c.nRaw = (c.nRaw ?? 0) + 1;
@@ -154,18 +177,15 @@ export function cellSummary(cell) {
154
177
  quotaHits: cell.quotaHits,
155
178
  cold: cell.n < MIN_SAMPLES,
156
179
  exploreRemaining: Math.max(0, EXPLORE_CAP - cell.dispatches),
180
+ discounted: cell.discounted ?? 0,
157
181
  };
158
182
  }
159
- // Total by construction: every miss returns the explicit NEUTRAL constant; every denominator is a
160
- // compile-time-positive constant sum; no Record indexing by row strings (the TIER_RANK[typo] scar).
161
- // Score 0 defers to the static sort keys, which already encode the benchmark-dated tier seeds — a
162
- // per-channel numeric prior would give differing n=0 scores and break ROUTE-07's byte-identical cold
163
- // start. Shrinking toward 0 IS shrinking toward the static prior.
164
- export function learnedScore(profile, shape, chKey, channel, opts = {}) {
183
+ // Per-term decomposition of learnedScore the single arithmetic source for profile --explain.
184
+ export function learnedScoreTerms(profile, shape, chKey, channel, opts = {}) {
165
185
  const availWeight = opts.availWeight ?? AVAIL_WEIGHT;
166
186
  const cell = cellOf(profile, shape, chKey, channel);
167
187
  if (!cell)
168
- return NEUTRAL; // unknown channel exactly neutral (empty-profile / cold-start leg, ROUTE-07)
188
+ return { quality: NEUTRAL, perf: NEUTRAL, avail: NEUTRAL, overrun: NEUTRAL };
169
189
  // Cold-start proof (ROUTE-07): n ≤ nRaw ≤ dispatches and doneCount ≤ dispatches by construction, so a
170
190
  // thin-dispatch cell (dispatches < MIN_SAMPLES) gates ALL THREE terms to literal +0 ⇒ exactly NEUTRAL.
171
191
  // Each term is gated INDEPENDENTLY (no early-exit) so a dispatch-warm/quality-cold HIGH-throttle cell can
@@ -192,6 +212,18 @@ export function learnedScore(profile, shape, chKey, channel, opts = {}) {
192
212
  // fact as a quality signal, ejecting it, or predicting it. overruns=0 ⇒ overrunPen 0 (overrun-free byte-identity).
193
213
  // Gated on dispatches (≥ MIN_SAMPLES > 0 ⇒ positive denominator, no divide-by-zero) INDEPENDENTLY of n, mirroring
194
214
  // the ROUTE-12 avail term exactly. NOT the symmetric (0.5 − ratio) form (the v1.9 ROUTE-16 penalty-only precedent).
195
- const overrunPen = cell.dispatches < MIN_SAMPLES ? 0 : -OVERRUN_WEIGHT * ((cell.overruns ?? 0) / cell.dispatches); // ∈ [−OVERRUN_WEIGHT, 0]
196
- return quality + perf + avail + overrunPen; // score ∈ (−0.625, +0.525), finite for every input
215
+ const overrun = cell.dispatches < MIN_SAMPLES ? 0 : -OVERRUN_WEIGHT * ((cell.overruns ?? 0) / cell.dispatches); // ∈ [−OVERRUN_WEIGHT, 0]
216
+ return { quality, perf, avail, overrun };
217
+ }
218
+ // Total by construction: every miss returns the explicit NEUTRAL constant; every denominator is a
219
+ // compile-time-positive constant sum; no Record indexing by row strings (the TIER_RANK[typo] scar).
220
+ // Score 0 defers to the static sort keys, which already encode the benchmark-dated tier seeds — a
221
+ // per-channel numeric prior would give differing n=0 scores and break ROUTE-07's byte-identical cold
222
+ // start. Shrinking toward 0 IS shrinking toward the static prior.
223
+ export function learnedScore(profile, shape, chKey, channel, opts = {}) {
224
+ const cell = cellOf(profile, shape, chKey, channel);
225
+ if (!cell)
226
+ return NEUTRAL; // unknown channel ⇒ exactly neutral (empty-profile / cold-start leg, ROUTE-07)
227
+ const t = learnedScoreTerms(profile, shape, chKey, channel, opts);
228
+ return t.quality + t.perf + t.avail + t.overrun; // score ∈ (−0.625, +0.525), finite for every input
197
229
  }
@@ -30,5 +30,5 @@ export declare class RoutingError extends Error {
30
30
  constructor(msg: string);
31
31
  }
32
32
  export declare function marginalCostRank(c: BillingChannel): number;
33
- export declare function route(task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext): Route;
34
- export declare function nextChannel(current: Assignment, task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile): Assignment | null;
33
+ export declare function route(task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], profile?: RoutingProfile, preferCtx?: RoutingPreferContext, exclude?: ReadonlySet<string>): Route;
34
+ export declare function nextChannel(current: Assignment, task: Task, cfg: TickmarkrConfig, channels: BillingChannel[], tried: string[], profile?: RoutingProfile, exclude?: ReadonlySet<string>): Assignment | null;
@@ -44,7 +44,14 @@ function ladderFor(task, entry) {
44
44
  const escalate = task.routingHints?.escalate ?? entry?.escalate ?? true;
45
45
  return escalate ? ["retry", "escalate", "consult", "human"] : ["retry", "consult", "human"];
46
46
  }
47
- export function route(task, cfg, channels, profile, preferCtx) {
47
+ function withoutExcluded(channels, exclude) {
48
+ if (!exclude?.size)
49
+ return channels;
50
+ // OBS-57: in-run demotion after consecutive no-trailer windows — route around poisoned channels for the rest of the run.
51
+ return channels.filter((c) => !exclude.has(channelKey(c)));
52
+ }
53
+ export function route(task, cfg, channels, profile, preferCtx, exclude) {
54
+ channels = withoutExcluded(channels, exclude);
48
55
  const lints = [];
49
56
  const floor = cfg.routing.floors[task.shape];
50
57
  const entry = cfg.routing.map[task.shape];
@@ -201,7 +208,8 @@ export function route(task, cfg, channels, profile, preferCtx) {
201
208
  ? preferVia : "cheapest sufficient tier");
202
209
  return { assignment: toAssignment(eligible[0]), ladder: ladderFor(task, entry), lints, provenance: `${degraded}${bound}, marginal-cost auto (${chosenBy})`, ...(deviation ? { deviation } : {}) };
203
210
  }
204
- export function nextChannel(current, task, cfg, channels, tried, profile) {
211
+ export function nextChannel(current, task, cfg, channels, tried, profile, exclude) {
212
+ channels = withoutExcluded(channels, exclude);
205
213
  // already cheapest-sufficient: TIER_RANK asc is the PRIMARY key so escalation climbs one band at a time.
206
214
  // Do NOT "unify" this onto route()'s key order (marginal-cost first) — that reverses climb-one-band on mixed fleets (ROUTE-02, D2).
207
215
  // ROUTE-13: learnedScore is the STRICTLY-LAST key — within-band tiebreak only. Precomputed
@@ -10,6 +10,11 @@ export interface ConsultVerdict {
10
10
  excludeAdapter?: string;
11
11
  }
12
12
  export declare function renderRetryGuidance(v: ConsultVerdict): string;
13
+ export declare function augmentRetryBrief(feedback: string, opts: {
14
+ attempted: string[];
15
+ carried: string[];
16
+ present: Set<string>;
17
+ }): string;
13
18
  export interface Dossier {
14
19
  taskId: string;
15
20
  trigger: string;
@@ -18,7 +23,7 @@ export interface Dossier {
18
23
  diff: string;
19
24
  gates: GateResult[];
20
25
  }
21
- export declare function buildDossierPrompt(d: Dossier): string;
26
+ export declare function buildDossierPrompt(d: Dossier, nonce: string): string;
22
27
  export declare function consult(d: Dossier, cfg: TickmarkrConfig, adapters: WorkerAdapter[], driver: ExecutorDriver, cwd: string, runDir: string, opts?: {
23
28
  keep?: boolean;
24
29
  onSlot?: (slot: Slot) => void;