tickmarkr 1.45.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.
@@ -37,7 +37,7 @@ export class FakeAdapter {
37
37
  let isJudge = false;
38
38
  try {
39
39
  prompt = readFileSync(promptFile, "utf8");
40
- isJudge = /TICKMARKR-JUDGE/.test(prompt);
40
+ isJudge = /TICKMARKR-(?:JUDGE|SCOPE)/.test(prompt);
41
41
  }
42
42
  catch {
43
43
  // unreadable promptFile: can't detect role; serve static values (legacy headless-call behavior)
@@ -61,7 +61,7 @@ export class FakeAdapter {
61
61
  writeFileSync(p, JSON.stringify(val ?? {}, null, 1));
62
62
  return p;
63
63
  };
64
- return `bash -c 'grep -q TICKMARKR-JUDGE ${shq(promptFile)} && cat ${shq(serve("judge"))}; grep -q TICKMARKR-REVIEW ${shq(promptFile)} && cat ${shq(serve("review"))}; grep -q TICKMARKR-CONSULT ${shq(promptFile)} && cat ${shq(serve("consult"))}; true'`;
64
+ return `bash -c 'grep -Eq "TICKMARKR-(JUDGE|SCOPE)" ${shq(promptFile)} && cat ${shq(serve("judge"))}; grep -q TICKMARKR-REVIEW ${shq(promptFile)} && cat ${shq(serve("review"))}; grep -q TICKMARKR-CONSULT ${shq(promptFile)} && cat ${shq(serve("consult"))}; true'`;
65
65
  }
66
66
  resumeCommand(_sessionId, promptFile, model) {
67
67
  return this.interactiveCommand(promptFile, model) ?? this.headlessCommand(promptFile, model);
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdtempSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { overlayPreferShapes, TIER_RANK } from "../config/config.js";
@@ -178,7 +178,13 @@ async function mapLimit(items, limit, fn) {
178
178
  }
179
179
  const doctorPath = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "doctor.json");
180
180
  function readDoctorFile(repoRoot) {
181
- return existsSync(doctorPath(repoRoot)) ? JSON.parse(readFileSync(doctorPath(repoRoot), "utf8")) : null;
181
+ try {
182
+ return existsSync(doctorPath(repoRoot)) ? JSON.parse(readFileSync(doctorPath(repoRoot), "utf8")) : null;
183
+ }
184
+ catch {
185
+ // A torn cache is disposable: callers already re-probe when this returns null.
186
+ return null;
187
+ }
182
188
  }
183
189
  export function writeDoctor(repoRoot, health) {
184
190
  tickmarkrDir(repoRoot);
@@ -187,7 +193,10 @@ export function writeDoctor(repoRoot, health) {
187
193
  delete payload[pendingAutoPreferKey];
188
194
  if (pending)
189
195
  payload.autoPrefer = pending;
190
- writeFileSync(doctorPath(repoRoot), JSON.stringify(payload, null, 2) + "\n");
196
+ const path = doctorPath(repoRoot);
197
+ const tmp = `${path}.tmp`;
198
+ writeFileSync(tmp, JSON.stringify(payload, null, 2) + "\n");
199
+ renameSync(tmp, path);
191
200
  }
192
201
  export function readDoctor(repoRoot) {
193
202
  const raw = readDoctorFile(repoRoot);
package/dist/brand.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { shq } from "./adapters/types.js";
1
2
  // TTY-only pixel-tick logo (assets/mark.svg is the image twin — same block geometry).
2
3
  // Never printed to pipes — the non-TTY stdout surface is byte-pinned by tests and consumed by machines.
3
4
  const B = "\x1b[1m", R = "\x1b[0m";
@@ -26,5 +27,5 @@ export function paneDispatchScript(body) {
26
27
  }
27
28
  /** OBS-50: one short herdr pane-run line; bootstrap lives in the script file beside the prompt. */
28
29
  export function paneDispatchCommand(scriptPath) {
29
- return `bash ${JSON.stringify(scriptPath)}`;
30
+ return `bash ${shq(scriptPath)}`;
30
31
  }
@@ -6,10 +6,10 @@ import { ATTEMPT_CAP_RELEASE, Journal } from "../../run/journal.js";
6
6
  // append-only journal. Writing it into tickmarkr's compiled graph artifact would be silently erased by
7
7
  // the next recompile (which re-emits humanGate:true from the plan frontmatter) — Phase 42 D-02.
8
8
  //
9
- // v1.24 OBS-18: when the park was attempt-cap (not a humanGate pre-dispatch park), the event also
10
- // carries `release: "attempt-cap"`. replayResumeState zeros the attempt budget on that marker so
11
- // resume dispatches instead of re-parking in the same tick; tried-list is preserved. humanGate
12
- // parks (attempts 0, reason starts with "humanGate:") stamp no release byte-identical to GATE-08.
9
+ // v1.24 OBS-18: when the park kind is attempt-cap (not a humanGate pre-dispatch park), the event
10
+ // also carries `release: "attempt-cap"`. replayResumeState zeros the attempt budget on that marker so
11
+ // resume dispatches instead of re-parking in the same tick; tried-list is preserved. Unknown kinds
12
+ // receive no release and remain fail-closed to a human rather than being inferred from prose.
13
13
  //
14
14
  // Fail-closed (D-05): unknown runId, unknown taskId, a not-parked task, and a double-approve are all
15
15
  // LOUD refusals that name the reason and append NO event — never a silent no-op. A handler throw
@@ -30,10 +30,9 @@ export async function approve(argv, cwd = process.cwd()) {
30
30
  throw new Error(`task ${taskId} is ${status}, not a parked human gate — refusing (a silent no-op would be worse)`);
31
31
  }
32
32
  // OBS-18: only the most recent task-human for this task decides whether this approval grants a
33
- // fresh attempt budget. Match the daemon's park reason literally (`attempt cap (N) reached`).
33
+ // fresh attempt budget. The closed daemon-issued kind, never a human prose string, controls release.
34
34
  const lastHuman = journal.read().filter((e) => e.event === "task-human" && e.taskId === taskId).at(-1);
35
- const capPark = typeof lastHuman?.data.reason === "string"
36
- && /attempt cap \(\d+\) reached/.test(lastHuman.data.reason);
35
+ const capPark = lastHuman?.data.kind === ATTEMPT_CAP_RELEASE;
37
36
  journal.append("task-approved", taskId, {
38
37
  by,
39
38
  ...(reason ? { reason } : {}),
@@ -1,4 +1,4 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { parseArgs } from "node:util";
@@ -7,7 +7,59 @@ import { configTemplate, DEFAULT_CONFIG, globalConfigDir, loadConfig } from "../
7
7
  import { LEGACY_PREFIX, specTemplate } from "../../compile/native.js";
8
8
  import { BANNER } from "../../brand.js";
9
9
  import { tickmarkrDir } from "../../graph/graph.js";
10
+ import { Journal } from "../../run/journal.js";
10
11
  import { doctor } from "./doctor.js";
12
+ const SCAFFOLD_SPEC = "tickmarkr.spec.md";
13
+ // Operator-approved (2026-07-17) environments footer — three rows; no npm install for herdr
14
+ // (npm package "herdr" is a reserved 0.0.0 placeholder as of that date).
15
+ const ENVIRONMENTS_FOOTER = [
16
+ "environments:",
17
+ " herdr — the full cockpit — every worker, judge, and consult is a visible pane you can watch and unblock · https://herdr.dev",
18
+ " claude code — tickmarkr init --agent installs the /tkr skills + AGENTS.md so Claude Code (or any agent CLI) drives the loop natively",
19
+ " anywhere — no herdr? same fail-closed gates, headless subprocess driver",
20
+ ].join("\n");
21
+ /** Latest journal without a run-end event, if any. */
22
+ function activeRunId(cwd) {
23
+ const runId = Journal.latestRunId(cwd, { withJournal: true });
24
+ if (!runId)
25
+ return null;
26
+ try {
27
+ const events = Journal.open(cwd, runId).read();
28
+ if (events.some((e) => e.event === "run-end"))
29
+ return null;
30
+ return runId;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ /** Relative paths of specs/*.spec.md already in the repo. */
37
+ function existingSpecs(cwd) {
38
+ const dir = join(cwd, "specs");
39
+ if (!existsSync(dir))
40
+ return [];
41
+ try {
42
+ return readdirSync(dir)
43
+ .filter((f) => f.endsWith(".spec.md"))
44
+ .sort()
45
+ .map((f) => `specs/${f}`);
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ }
51
+ /** Context-aware next-steps line (operator-approved 2026-07-17). */
52
+ function nextSteps(cwd, scaffoldedSpec) {
53
+ const runId = activeRunId(cwd);
54
+ if (runId)
55
+ return `run ${runId} active — tickmarkr status`;
56
+ const specs = existingSpecs(cwd);
57
+ if (specs.length > 0) {
58
+ const listed = specs.length <= 3 ? specs.join(", ") : `${specs.slice(0, 3).join(", ")}, …`;
59
+ return `next: existing specs under specs/ (${listed}) — tickmarkr compile <spec> && tickmarkr plan && tickmarkr run`;
60
+ }
61
+ return `next: edit ${scaffoldedSpec}, then tickmarkr compile ${scaffoldedSpec} && tickmarkr plan && tickmarkr run`;
62
+ }
11
63
  const visual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
12
64
  const AGENT_SKILLS = ["tickmarkr-loop", "tickmarkr-auto"];
13
65
  const DOCS_BEGIN = "<!-- tickmarkr:agent-docs begin -->";
@@ -173,7 +225,7 @@ export async function init(argv, cwd = process.cwd()) {
173
225
  const repoConfigExists = existsSync(repoConfigPath);
174
226
  if (repoConfigExists)
175
227
  notes.push(`kept existing ${repoConfigPath}`);
176
- const specPath = join(cwd, "tickmarkr.spec.md");
228
+ const specPath = join(cwd, SCAFFOLD_SPEC);
177
229
  if (existsSync(specPath)) {
178
230
  notes.push(`kept existing ${specPath}`);
179
231
  }
@@ -210,7 +262,7 @@ export async function init(argv, cwd = process.cwd()) {
210
262
  }
211
263
  if (values.agent)
212
264
  await installAgentFiles(cwd, values.force ?? false, values.docs ?? false, notes);
213
- const body = `${notes.join("\n")}\n${doc}\nnext: edit tickmarkr.spec.md, then tickmarkr compile tickmarkr.spec.md && tickmarkr plan && tickmarkr run`;
265
+ const body = `${notes.join("\n")}\n${doc}\n${nextSteps(cwd, SCAFFOLD_SPEC)}\n${ENVIRONMENTS_FOOTER}`;
214
266
  if (visual() && !bannerEmitted)
215
267
  return BANNER + body;
216
268
  return body;
@@ -1,13 +1,21 @@
1
- import { writeFileSync } from "node:fs";
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loadConfig } from "../../config/config.js";
4
4
  import { tickmarkrDir, stateDirName } from "../../graph/graph.js";
5
- import { learnedScore, MIN_SAMPLES, cellsOf } from "../../route/profile.js";
6
- import { Journal, loadRoutingProfile, readProfileCursor, RUNS_WINDOW } from "../../run/journal.js";
7
- // tickmarkr profile — inspect (show) and forget (reset) the derived learned-routing profile (VIS-03).
8
- // Read-only class like plan/status/report: no run lock. `reset` writes ONE cursor scalar, nothing else.
5
+ import { cellOf, cellSummary, explorationBonus, EXPLORE_CAP, learnedScore, learnedScoreTerms, MIN_SAMPLES, PRIOR_K, REF_MS, cellsOf, } from "../../route/profile.js";
6
+ import { Journal, loadRoutingProfile, parseRunId, readProfileCursor, readProfileDiscounts, appendProfileDiscount, profileDiscountsPath, RUNS_WINDOW, } from "../../run/journal.js";
7
+ // tickmarkr profile — inspect (show), forget (reset), and discount poisoned evidence (v1.46 T5).
8
+ // Read-only class like plan/status/report except discount append. `reset` writes ONE cursor scalar.
9
9
  export async function profile(argv, cwd = process.cwd()) {
10
- return argv[0] === "reset" ? reset(cwd) : show(cwd);
10
+ if (argv[0] === "reset")
11
+ return reset(cwd);
12
+ if (argv[0] === "discount")
13
+ return discount(argv.slice(1), cwd);
14
+ if (argv[0] === "discounts")
15
+ return listDiscounts(cwd);
16
+ if (argv[0] === "--explain" || argv.includes("--explain"))
17
+ return explain(argv, cwd);
18
+ return show(cwd);
11
19
  }
12
20
  // Non-destructive reset (T-13-06): a one-line runId cutoff at .tickmarkr/profile-since. NEVER deletes,
13
21
  // truncates, or rotates any telemetry — the profile is derived (PROF-01), so there is nothing to delete;
@@ -26,6 +34,119 @@ function reset(cwd) {
26
34
  ` to un-reset: delete ${stateDir}/profile-since.`,
27
35
  ].join("\n");
28
36
  }
37
+ function parseDiscountArgs(argv) {
38
+ let runId;
39
+ let taskId;
40
+ let weight;
41
+ let reason;
42
+ for (let i = 0; i < argv.length; i++) {
43
+ if (argv[i] === "--weight" && argv[i + 1]) {
44
+ const w = argv[++i];
45
+ if (w !== "0" && w !== "0.5")
46
+ throw new Error(`profile discount --weight must be 0 or 0.5 (got ${w})`);
47
+ weight = w === "0" ? 0 : 0.5;
48
+ }
49
+ else if (argv[i] === "--reason" && argv[i + 1]) {
50
+ reason = argv[++i];
51
+ }
52
+ else if (!argv[i].startsWith("--") && runId === undefined) {
53
+ runId = parseRunId(argv[i]);
54
+ }
55
+ else if (!argv[i].startsWith("--") && taskId === undefined) {
56
+ taskId = argv[i];
57
+ }
58
+ }
59
+ if (!runId)
60
+ throw new Error("profile discount requires <runId>");
61
+ if (weight === undefined)
62
+ throw new Error("profile discount requires --weight 0|0.5");
63
+ if (!reason?.trim())
64
+ throw new Error("profile discount requires --reason (a discount is an evidence claim)");
65
+ return { runId, taskId, weight, reason: reason.trim() };
66
+ }
67
+ function discount(argv, cwd) {
68
+ const mark = parseDiscountArgs(argv);
69
+ appendProfileDiscount(cwd, mark);
70
+ const stateDir = stateDirName(cwd);
71
+ const scope = mark.taskId ? `${mark.runId} task ${mark.taskId}` : mark.runId;
72
+ return [
73
+ `profile discount — marked ${scope} at weight ${mark.weight}.`,
74
+ ` reason: ${mark.reason}`,
75
+ ` wrote ${stateDir}/profile-discounts (append-only).`,
76
+ ` list: tickmarkr profile discounts`,
77
+ ].join("\n");
78
+ }
79
+ function listDiscounts(cwd) {
80
+ const marks = readProfileDiscounts(cwd);
81
+ const path = profileDiscountsPath(cwd);
82
+ if (!marks.length) {
83
+ return existsDiscountsFile(cwd)
84
+ ? `profile discounts — ${path}\n (no valid marks)`
85
+ : `profile discounts — no ${stateDirName(cwd)}/profile-discounts file yet.`;
86
+ }
87
+ const lines = marks.map((m) => {
88
+ const scope = m.taskId ? `${m.runId} ${m.taskId}` : m.runId;
89
+ return ` ${scope.padEnd(28)} weight=${m.weight} # ${m.reason}`;
90
+ });
91
+ return [`profile discounts — ${path}`, ...lines].join("\n");
92
+ }
93
+ function existsDiscountsFile(cwd) {
94
+ try {
95
+ readFileSync(profileDiscountsPath(cwd));
96
+ return true;
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ const fmtSigned = (n) => (n >= 0 ? `+${n.toFixed(3)}` : n.toFixed(3));
103
+ function explain(argv, cwd) {
104
+ const rest = argv[0] === "--explain" ? argv.slice(1) : argv.filter((a) => a !== "--explain");
105
+ const shape = rest[0];
106
+ const chKey = rest[1];
107
+ const channel = rest[2] ?? "sub";
108
+ if (!shape || !chKey)
109
+ throw new Error("profile --explain requires <shape> <channel>");
110
+ const cfg = loadConfig(cwd);
111
+ const p = loadRoutingProfile(cwd, cfg, { preview: true });
112
+ const tuning = { availWeight: cfg.routing.learnedTuning?.availWeight };
113
+ const cell = cellOf(p, shape, chKey, channel);
114
+ const terms = learnedScoreTerms(p, shape, chKey, channel, tuning);
115
+ const score = learnedScore(p, shape, chKey, channel, tuning);
116
+ const header = `${shape} × ${chKey} (${channel})`;
117
+ if (!cell) {
118
+ return [
119
+ header,
120
+ ` quality ${fmtSigned(terms.quality)} (no cell — neutral)`,
121
+ ` perf ${fmtSigned(terms.perf)}`,
122
+ ` avail ${fmtSigned(terms.avail)}`,
123
+ ` overrun ${fmtSigned(terms.overrun)}`,
124
+ ` score ${fmtSigned(score)}`,
125
+ ].join("\n");
126
+ }
127
+ const s = cellSummary(cell);
128
+ const disc = s.discounted > 0 ? `, ${s.discounted} rows discounted` : "";
129
+ const qualityDetail = s.cold
130
+ ? `n_eff ${s.nEff} < ${MIN_SAMPLES} (cold)`
131
+ : `q̂=(qSum ${cell.qSum} + ${PRIOR_K})/(n ${cell.n} + ${2 * PRIOR_K}) − 0.5 n_raw ${s.nRaw}${disc}`;
132
+ const perfDetail = cell.doneMedianMs === undefined || s.cold
133
+ ? `cold`
134
+ : `median ${Math.round(cell.doneMedianMs / 60_000)}m vs ref ${REF_MS / 60_000}m done=${cell.doneCount}`;
135
+ const availDetail = `quotaHits ${cell.quotaHits} / dispatches ${cell.dispatches}`;
136
+ const overrunDetail = `overruns ${cell.overruns ?? 0} / dispatches ${cell.dispatches}`;
137
+ const explore = cell.dispatches >= EXPLORE_CAP
138
+ ? `spent dispatches ${cell.dispatches} ≥ cap ${EXPLORE_CAP}`
139
+ : `remaining ${s.exploreRemaining} bonus ${explorationBonus(cell).toFixed(3)}`;
140
+ return [
141
+ header,
142
+ ` quality ${fmtSigned(terms.quality)} ${qualityDetail}`,
143
+ ` perf ${fmtSigned(terms.perf)} ${perfDetail}`,
144
+ ` avail ${fmtSigned(terms.avail)} ${availDetail}`,
145
+ ` overrun ${fmtSigned(terms.overrun)} ${overrunDetail}`,
146
+ ` score ${fmtSigned(score)} (deciding only below prefer/cost/tier — ROUTE-08)`,
147
+ ` explore ${explore}`,
148
+ ].join("\n");
149
+ }
29
150
  // Inspection surface: preview:true bypasses the routing.learned:off short-circuit so `show` renders the
30
151
  // profile even under the default off — same trust ramp as `tickmarkr plan`. The routing path stays inert.
31
152
  function show(cwd) {
@@ -51,7 +172,8 @@ function show(cwd) {
51
172
  // VIS-04/ROUTE-15 — preview must match routing's tuning, never the bare module default.
52
173
  const score = learnedScore(p, shape, chKey, channel, { availWeight: cfg.routing.learnedTuning?.availWeight }).toFixed(3);
53
174
  const cold = cell.n < MIN_SAMPLES ? ` cold (n<${MIN_SAMPLES})` : "";
54
- return ` ${shape.padEnd(10)} ${chKey.padEnd(28)} ${channel.padEnd(4)} n=${String(cell.n).padEnd(3)} q=${quality.padEnd(5)} ${median.padEnd(9)} disp=${String(cell.dispatches).padEnd(3)} score=${score}${cold}`;
175
+ const disc = (cell.discounted ?? 0) > 0 ? ` disc=${cell.discounted}` : "";
176
+ return ` ${shape.padEnd(10)} ${chKey.padEnd(28)} ${channel.padEnd(4)} n=${String(cell.n).padEnd(3)} q=${quality.padEnd(5)} ${median.padEnd(9)} disp=${String(cell.dispatches).padEnd(3)} score=${score}${disc}${cold}`;
55
177
  });
56
178
  return [...header, "", " shape channel class obs quality median dispatch score", ...rows].join("\n");
57
179
  }
@@ -1,5 +1,6 @@
1
1
  import { readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { extname, join, relative } from "node:path";
3
+ import picomatch from "picomatch";
3
4
  // Advisory plan-time scan only (OBS-12/13/14/21). NEVER expands files[], fails compile, or
4
5
  // feeds the scope gate — a warning the author acts on. Plain-text (no AST), capped + sorted.
5
6
  /** Max test files walked under tests/ (sorted walk; rest ignored). */
@@ -99,7 +100,8 @@ export function collateralLints(tasks, repoRoot) {
99
100
  };
100
101
  const lines = [];
101
102
  for (const t of tasks) {
102
- const scoped = new Set(t.files.map((f) => f.replace(/^\.\//, "")));
103
+ // OBS-22: scopeGate accepts picomatch globs; advisory collateral warnings must agree.
104
+ const scoped = picomatch(t.files.map((f) => f.replace(/^\.\//, "")), { dot: true });
103
105
  const srcFiles = t.files.map((f) => f.replace(/^\.\//, "")).filter(isSrcPath);
104
106
  if (!srcFiles.length)
105
107
  continue;
@@ -107,7 +109,7 @@ export function collateralLints(tasks, repoRoot) {
107
109
  const needles = [...new Set(srcFiles.flatMap(needlesFor))];
108
110
  const hits = [];
109
111
  for (const tf of testFiles) {
110
- if (scoped.has(tf))
112
+ if (scoped(tf))
111
113
  continue;
112
114
  const text = read(tf);
113
115
  if (text === null)
@@ -28,6 +28,7 @@ export declare class HerdrDriver implements ExecutorDriver {
28
28
  private glyphFor;
29
29
  private joinGroup;
30
30
  run(slot: Slot, cmd: string): Promise<void>;
31
+ private waitOk;
31
32
  waitOutput(slot: Slot, pattern: string, timeoutMs: number, opts?: {
32
33
  regex?: boolean;
33
34
  }): Promise<boolean>;
@@ -275,15 +275,25 @@ export class HerdrDriver {
275
275
  if (r.code !== 0)
276
276
  throw new Error(`herdr pane run failed: ${r.stderr || r.stdout}`);
277
277
  }
278
+ waitOk(code, stdout) {
279
+ if (code !== 0 || !stdout.trim())
280
+ return code === 0; // herdr's successful waits may be silent
281
+ try {
282
+ return !Object.hasOwn(JSON.parse(stdout), "error");
283
+ }
284
+ catch {
285
+ return false; // a non-empty herdr wait response must be a parseable envelope
286
+ }
287
+ }
278
288
  async waitOutput(slot, pattern, timeoutMs, opts) {
279
289
  const pane = await this.paneId(slot);
280
290
  const r = await this.herdr(`wait output ${shq(pane)} --match ${shq(pattern)}${opts?.regex ? " --regex" : ""} --timeout ${Math.floor(timeoutMs)}`, slot.cwd, timeoutMs + 15_000);
281
- return r.code === 0 && !r.stdout.includes('"error"'); // dead pane: exit 0 + error json (herdr bite)
291
+ return this.waitOk(r.code, r.stdout); // dead pane: exit 0 + top-level error envelope (herdr bite)
282
292
  }
283
293
  async waitAgentStatus(slot, status, timeoutMs) {
284
294
  const pane = await this.paneId(slot);
285
295
  const r = await this.herdr(`wait agent-status ${shq(pane)} --status ${shq(status)} --timeout ${Math.floor(timeoutMs)}`, slot.cwd, timeoutMs + 15_000);
286
- return r.code === 0 && !r.stdout.includes('"error"');
296
+ return this.waitOk(r.code, r.stdout);
287
297
  }
288
298
  async status(slot) {
289
299
  return this.statusByName(slot.name);
@@ -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,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;