tickmarkr 1.85.0 → 1.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters/catalog-remote.d.ts +64 -0
  3. package/dist/adapters/catalog-remote.js +287 -0
  4. package/dist/adapters/catalog.d.ts +108 -0
  5. package/dist/adapters/catalog.js +189 -0
  6. package/dist/adapters/claude-code.js +5 -3
  7. package/dist/adapters/fake.js +33 -4
  8. package/dist/adapters/model-lints.d.ts +25 -5
  9. package/dist/adapters/model-lints.js +184 -50
  10. package/dist/adapters/model-windows.d.ts +31 -0
  11. package/dist/adapters/model-windows.js +69 -0
  12. package/dist/adapters/prompt.d.ts +5 -1
  13. package/dist/adapters/prompt.js +13 -4
  14. package/dist/adapters/registry.d.ts +34 -27
  15. package/dist/adapters/registry.js +215 -112
  16. package/dist/adapters/types.js +15 -3
  17. package/dist/brand.d.ts +5 -1
  18. package/dist/brand.js +18 -2
  19. package/dist/cli/commands/doctor.d.ts +3 -0
  20. package/dist/cli/commands/doctor.js +43 -21
  21. package/dist/cli/commands/fleet.d.ts +7 -0
  22. package/dist/cli/commands/fleet.js +94 -74
  23. package/dist/cli/commands/init.js +118 -5
  24. package/dist/cli/commands/plan.js +11 -1
  25. package/dist/cli/commands/resume.js +7 -1
  26. package/dist/cli/commands/status.js +44 -18
  27. package/dist/compile/gsd.d.ts +2 -1
  28. package/dist/compile/gsd.js +68 -2
  29. package/dist/compile/native.d.ts +14 -0
  30. package/dist/compile/native.js +168 -12
  31. package/dist/config/config.d.ts +20 -5
  32. package/dist/config/config.js +96 -64
  33. package/dist/config/fleet-overlay.d.ts +25 -20
  34. package/dist/config/fleet-overlay.js +195 -77
  35. package/dist/config/fleet-why.d.ts +23 -0
  36. package/dist/config/fleet-why.js +42 -0
  37. package/dist/drivers/herdr.d.ts +1 -0
  38. package/dist/drivers/herdr.js +70 -19
  39. package/dist/gates/acceptance.js +7 -2
  40. package/dist/gates/llm.d.ts +0 -1
  41. package/dist/gates/llm.js +5 -30
  42. package/dist/gates/review.d.ts +2 -1
  43. package/dist/gates/review.js +9 -7
  44. package/dist/gates/run-gates.d.ts +1 -0
  45. package/dist/gates/run-gates.js +21 -2
  46. package/dist/gates/verdict-cause.d.ts +4 -0
  47. package/dist/gates/verdict-cause.js +63 -0
  48. package/dist/graph/schema.d.ts +6 -0
  49. package/dist/graph/schema.js +8 -5
  50. package/dist/route/preference.d.ts +1 -1
  51. package/dist/route/preference.js +8 -1
  52. package/dist/route/router.d.ts +0 -5
  53. package/dist/route/router.js +16 -20
  54. package/dist/run/consult.d.ts +6 -0
  55. package/dist/run/consult.js +49 -26
  56. package/dist/run/daemon.js +81 -20
  57. package/dist/run/journal.js +87 -7
  58. package/dist/tui/cockpit/capture.d.ts +12 -0
  59. package/dist/tui/cockpit/capture.js +37 -1
  60. package/dist/tui/cockpit/components.js +8 -8
  61. package/dist/tui/cockpit/theme.d.ts +32 -26
  62. package/dist/tui/cockpit/theme.js +11 -5
  63. package/dist/tui/ink/components.d.ts +0 -15
  64. package/dist/tui/ink/components.js +0 -17
  65. package/dist/tui/ink/fleet-app.d.ts +4 -1
  66. package/dist/tui/ink/fleet-app.js +134 -13
  67. package/fixtures/gateway-models.json +1 -0
  68. package/fixtures/sample.native.md +1 -1
  69. package/package.json +1 -1
  70. package/skills/tickmarkr-overseer/SKILL.md +464 -34
  71. package/skills/tickmarkr-overseer/scripts/watch-artifacts.sh +86 -0
  72. package/skills/tickmarkr-overseer/scripts/watch-panes.sh +1 -1
  73. package/dist/tui/ink/studio-app.d.ts +0 -59
  74. package/dist/tui/ink/studio-app.js +0 -320
  75. package/dist/tui/save.d.ts +0 -38
  76. package/dist/tui/save.js +0 -96
  77. package/dist/tui/staging.d.ts +0 -29
  78. package/dist/tui/staging.js +0 -78
@@ -89,9 +89,11 @@ export const CLAUDE_INPUT_BOX = declareInputBox("claude-code", {
89
89
  fingerprint: "❯\u00A0",
90
90
  match: (paneText) => matchesClaudeEditor(paneText, false),
91
91
  emptyMatch: (paneText) => matchesClaudeEditor(paneText, true),
92
- // The launch line settles on a clean shell line; the editor paints after it. Both interactive
93
- // entry points count — a resumed session re-enters the same TUI (OBS-142's launch distinction).
94
- launchCommand: (command) => command.startsWith("claude --model ") || command.startsWith("claude -r "),
92
+ // OBS-342: daemon-built launches carry intent through paneDispatchCommand; their wrapper bytes are
93
+ // never re-parsed here. The first-delivery fact keeps direct driver consumers on the same honest
94
+ // lifecycle: a fresh claude-code worker slot is a shell awaiting its launch, every later delivery
95
+ // is a TUI turn awaiting this box. Both interactive and resume builders share that contract.
96
+ firstDeliveryIsLaunch: true,
95
97
  // The 2026-08-03 capture reached a painted editor ~10s after the trust answer on a warm install.
96
98
  readinessTimeoutMs: 30_000,
97
99
  });
@@ -49,6 +49,7 @@ export class FakeAdapter {
49
49
  catch {
50
50
  // unreadable promptFile: can't detect role; serve static values (legacy headless-call behavior)
51
51
  }
52
+ const nonce = this.nonceFor(promptFile);
52
53
  const serve = (key) => {
53
54
  if (role !== undefined && key !== role)
54
55
  return join(dirname(this.scriptPath), `${key}.json`);
@@ -58,7 +59,9 @@ export class FakeAdapter {
58
59
  val = val[Math.min(this.judgeIdx, val.length - 1)];
59
60
  this.judgeIdx++;
60
61
  }
61
- if (key === "judge" && isJudge && val && typeof val === "object" && !Array.isArray(val)) {
62
+ // Legacy zero-token judge scripts may omit per-criterion evidence. Shape that scripted response
63
+ // here, where the fake authors it, instead of teaching the shared LLM transport about adapter ids.
64
+ if (key === "judge" && prompt.startsWith("TICKMARKR-JUDGE") && val && typeof val === "object" && !Array.isArray(val)) {
62
65
  const verdict = val;
63
66
  if (verdict.pass === true && Array.isArray(verdict.criteria) && verdict.criteria.length === 0) {
64
67
  const items = prompt.match(/## Acceptance criteria \(judge\)\n([\s\S]*?)\n\n## Diff/)?.[1]
@@ -66,8 +69,31 @@ export class FakeAdapter {
66
69
  val = { ...verdict, criteria: items.map((criterion) => ({ criterion, met: true, reason: "scripted fake pass" })) };
67
70
  }
68
71
  }
69
- const p = join(dirname(this.scriptPath), `${key}.json`);
70
- writeFileSync(p, JSON.stringify(val ?? {}, null, 1));
72
+ if (key === "judge" && isJudge && val && typeof val === "object" && !Array.isArray(val)) {
73
+ const verdict = val;
74
+ const line = /```diff\n([\s\S]*?)```/.exec(prompt)?.[1].split("\n").find((candidate) => candidate.trim());
75
+ if (line && Array.isArray(verdict.criteria)) {
76
+ val = {
77
+ ...verdict,
78
+ criteria: verdict.criteria.map((row) => row && typeof row === "object" && !("evidence" in row) ? { ...row, evidence: line } : row),
79
+ };
80
+ }
81
+ }
82
+ // The configured fake adapter binds the nonce read from this prompt before one object is emitted.
83
+ // Renamed subclasses model other CLIs and retain their own producer contract. Missing nonces stay
84
+ // missing so the classifier can distinguish silence from participation.
85
+ if (this.id === "fake" && nonce && val && typeof val === "object" && !Array.isArray(val)) {
86
+ const { nonce: _scriptedNonce, ...verdict } = val;
87
+ val = { nonce, ...verdict };
88
+ }
89
+ // Concurrent gates share one FakeAdapter script. A nonce-specific response path keeps one call's
90
+ // responder-authored bytes from being replaced by a sibling between command construction and cat.
91
+ const responseNonce = this.id === "fake" ? nonce : "";
92
+ const p = join(dirname(this.scriptPath), `${key}${responseNonce ? `-${responseNonce}` : ""}.json`);
93
+ const json = JSON.stringify(val ?? {}, null, 1)
94
+ .split("\n").map((line) => line.trim()).join(" ")
95
+ .replace(/^\{ /, "{").replace(/ \}$/, "}");
96
+ writeFileSync(p, json);
71
97
  return p;
72
98
  };
73
99
  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'`;
@@ -120,7 +146,10 @@ export class FakeAdapter {
120
146
  // parseWorkerResult(output, nonce) succeeds exactly as before the nonce existed
121
147
  nonceFor(promptFile) {
122
148
  try {
123
- return /TICKMARKR_RESULT_([0-9a-z]+)/.exec(readFileSync(promptFile, "utf8"))?.[1] ?? "";
149
+ const prompt = readFileSync(promptFile, "utf8");
150
+ return /VERDICT_NONCE:\s*([0-9a-f]+)/i.exec(prompt)?.[1]
151
+ ?? /TICKMARKR_RESULT_([0-9a-z]+)/.exec(prompt)?.[1]
152
+ ?? "";
124
153
  }
125
154
  catch {
126
155
  return "";
@@ -1,14 +1,31 @@
1
- import { type TickmarkrConfig } from "../config/config.js";
1
+ import { type TickmarkrConfig, type Tier } from "../config/config.js";
2
2
  import type { Task } from "../graph/schema.js";
3
3
  import { type AuthHealth, type WorkerAdapter } from "./types.js";
4
+ import { type CatalogModelEvidence, type CatalogReadResult } from "./catalog-remote.js";
4
5
  export declare const SEED_STAMPED = "2026-07-09";
5
6
  export declare const MODEL_STALE_DAYS = 30;
6
7
  export declare const ttyVisual: () => boolean;
7
- /** True when any tier entry declares at least one model window. */
8
+ export interface CatalogModelAdvisory {
9
+ coverage: "covered" | "uncovered";
10
+ evidence?: CatalogModelEvidence;
11
+ suggestion?: {
12
+ tier: Tier;
13
+ kind: "inference";
14
+ basis: "intelligence" | "price";
15
+ provenanceNote: string;
16
+ };
17
+ display: string;
18
+ }
19
+ export declare function catalogModelAdvisory(cfg: TickmarkrConfig, catalog: CatalogReadResult, adapter: string, model: string, resolvedModel?: string): CatalogModelAdvisory;
20
+ /**
21
+ * Whether the doctor should add its optional window column. Keep non-TTY default output stable for
22
+ * machine consumers; an interactive seed-only matrix shows T14's fleet windows, and any explicit
23
+ * non-seed/overridden window keeps the historical operator-configured column behavior.
24
+ */
8
25
  export declare function hasWindowsConfig(cfg: TickmarkrConfig): boolean;
9
26
  export declare function declaredModelWindow(cfg: TickmarkrConfig, adapter: string, model: string): number | undefined;
10
- /** Best-effort plan-time payload estimate: prompt shell + context + files[] byte sizes. */
11
- export declare function estimateTaskPayloadTokens(task: Task, repoRoot: string, feedback?: string): number;
27
+ /** A numeric estimate exists only when every payload path is measurable from the worker-visible tree. */
28
+ export declare function estimateTaskPayloadTokens(task: Task, repoRoot: string, feedback?: string): number | undefined;
12
29
  export type RoutedAssignment = {
13
30
  taskId: string;
14
31
  adapter: string;
@@ -28,7 +45,10 @@ export declare function formatModelAuthLine(excluded: {
28
45
  reason: string;
29
46
  probedAt: string;
30
47
  }[], tty?: boolean, stateDir?: string): string;
31
- export declare function suggestOverlay(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], stateDir?: string): string;
48
+ export declare function suggestOverlay(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[], stateDir?: string, opts?: {
49
+ catalog?: CatalogReadResult;
50
+ resolvedModel?: (adapter: string, model: string) => string | undefined;
51
+ }): string;
32
52
  /** Unclassified models surfaced for fleet screen 2 (doctor matrix math, no tier fabrication). */
33
53
  export declare function fleetUnclassifiedModels(cfg: TickmarkrConfig, health: Record<string, AuthHealth>, adapters: WorkerAdapter[]): {
34
54
  adapter: string;
@@ -1,8 +1,10 @@
1
- import { existsSync, statSync } from "node:fs";
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { DEFAULT_CONFIG, TIER_RANK } from "../config/config.js";
4
5
  import { buildTaskPrompt } from "./prompt.js";
5
6
  import { MODEL_ID_RE } from "./types.js";
7
+ import { resolveCatalogModel } from "./catalog-remote.js";
6
8
  export const SEED_STAMPED = "2026-07-09";
7
9
  // knowledge past this age gets a "rerun tickmarkr doctor" nudge (BLOCKED_POLL_MS-style named constant).
8
10
  export const MODEL_STALE_DAYS = 30;
@@ -15,41 +17,154 @@ const LINT_CAP = 5;
15
17
  const TTY_LINT_CAP = 3;
16
18
  const DEFAULT_STATE_DIR = ".tickmarkr";
17
19
  const doctorJsonRef = (stateDir) => ` — see ${stateDir}/doctor.json`;
20
+ const DECLARED_SEED_PREFER_DISPOSITION = "no declared preference overrides it";
21
+ const LEGACY_DOCTOR_SEED_PREFER_DISPOSITION = "auto-prefer is routing around it";
18
22
  export const ttyVisual = () => process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
19
23
  // ponytail: chars/4 token heuristic — good enough for advisory plan lint; no tokenizer dep.
20
24
  const CHARS_PER_TOKEN = 4;
21
- /** True when any tier entry declares at least one model window. */
22
- export function hasWindowsConfig(cfg) {
25
+ const intelligenceTier = (index) => index >= 65 ? "frontier" : index >= 40 ? "mid" : "cheap";
26
+ const priceTier = (outputPerMtok) => outputPerMtok >= 12 ? "frontier" : outputPerMtok >= 2 ? "mid" : "cheap";
27
+ function catalogEvidenceNote(evidence, catalog) {
28
+ const cost = evidence.inputCostPerMtok !== undefined && evidence.outputCostPerMtok !== undefined
29
+ ? `$${evidence.inputCostPerMtok}/$${evidence.outputCostPerMtok} per Mtok`
30
+ : "not reported";
31
+ const features = evidence.features.length ? evidence.features.join(",") : "none reported";
32
+ const intelligence = evidence.intelligenceIndex !== undefined
33
+ ? `; Artificial Analysis Intelligence Index=${evidence.intelligenceIndex}${evidence.intelligenceIndexVersion ? ` (version ${evidence.intelligenceIndexVersion})` : ""}`
34
+ : "";
35
+ const freshness = catalog.stale ? `${catalog.source} stale cache` : catalog.source;
36
+ return `models.dev id=${evidence.modelId}; cost=${cost}; context=${evidence.contextWindow ?? "not reported"}; features=${features}${intelligence}; catalog=${freshness}; fetchedAt=${catalog.catalog.fetchedAt}`;
37
+ }
38
+ export function catalogModelAdvisory(cfg, catalog, adapter, model, resolvedModel) {
39
+ const entry = cfg.tiers[adapter];
40
+ const evidence = resolveCatalogModel(catalog.catalog, {
41
+ // A tier vendor is NULLABLE (config.ts:64) — null means "declared as having none at tier level, so
42
+ // each model must declare its own". `resolveCatalogModel` takes an optional provider HINT
43
+ // (catalog-remote.ts:218), and "no hint" is `undefined` there, so null normalizes to undefined rather
44
+ // than travelling as a third state. Neither T17 nor T19 could see this seam: their files[] are
45
+ // disjoint, so no ordering edge was derived, they dispatched 7ms apart, and each worktree was cut
46
+ // before the other's change existed. Both gates passed truthfully; only the merged pair fails to
47
+ // compile (OBS-371).
48
+ provider: entry?.vendor ?? undefined,
49
+ model,
50
+ ...(resolvedModel ? { resolvedModel } : {}),
51
+ });
52
+ if (!evidence) {
53
+ return {
54
+ coverage: "uncovered",
55
+ display: `${model} — uncovered by ${catalog.source === "cache" ? "cached catalogs" : "vendored catalog"}; no tier suggestion`,
56
+ };
57
+ }
58
+ const note = catalogEvidenceNote(evidence, catalog);
59
+ const basis = evidence.intelligenceIndex !== undefined
60
+ ? { basis: "intelligence", tier: intelligenceTier(evidence.intelligenceIndex) }
61
+ : entry?.channel === "api" && evidence.outputCostPerMtok !== undefined
62
+ ? { basis: "price", tier: priceTier(evidence.outputCostPerMtok) }
63
+ : undefined;
64
+ if (!basis) {
65
+ const reason = entry?.channel === "sub" && evidence.outputCostPerMtok !== undefined
66
+ ? "subscription billing; no price-derived suggestion"
67
+ : "no tier suggestion from available evidence";
68
+ return { coverage: "covered", evidence, display: `${model} → ${note}; ${reason}` };
69
+ }
70
+ const provenanceNote = `SUGGESTED ${basis.tier} (${basis.basis} inference, not a measurement) — ${note}; operator confirmation required`;
71
+ return {
72
+ coverage: "covered",
73
+ evidence,
74
+ suggestion: { tier: basis.tier, kind: "inference", basis: basis.basis, provenanceNote },
75
+ display: `${model} → ${note}; ${provenanceNote}`,
76
+ };
77
+ }
78
+ function hasAnyWindows(cfg) {
23
79
  return Object.values(cfg.tiers).some((t) => t.windows && Object.keys(t.windows).length > 0);
24
80
  }
81
+ /**
82
+ * Whether the doctor should add its optional window column. Keep non-TTY default output stable for
83
+ * machine consumers; an interactive seed-only matrix shows T14's fleet windows, and any explicit
84
+ * non-seed/overridden window keeps the historical operator-configured column behavior.
85
+ */
86
+ export function hasWindowsConfig(cfg) {
87
+ const hasOperatorWindow = Object.entries(cfg.tiers).some(([adapter, entry]) => Object.entries(entry.windows ?? {}).some(([model, window]) => DEFAULT_CONFIG.tiers[adapter]?.windows?.[model] !== window));
88
+ if (hasOperatorWindow)
89
+ return true;
90
+ const seedOnly = Object.keys(cfg.tiers).every((adapter) => adapter in DEFAULT_CONFIG.tiers);
91
+ return ttyVisual() && seedOnly && hasAnyWindows(cfg);
92
+ }
25
93
  export function declaredModelWindow(cfg, adapter, model) {
26
94
  return cfg.tiers[adapter]?.windows?.[model];
27
95
  }
28
- function fileBytes(repoRoot, rel) {
29
- if (rel.includes("*") || rel.includes("?") || rel.includes("{"))
30
- return 0; // glob — not measurable at plan time
31
- try {
32
- const p = join(repoRoot, rel);
33
- if (!existsSync(p))
34
- return 0;
35
- return statSync(p).size;
36
- }
37
- catch {
38
- return 0;
96
+ const GLOB_CHARS = /[*?{[]/;
97
+ // T13's visibility oracle is the committed base tree: workers are created from HEAD, not from the
98
+ // author's checkout or index. Keep --full-tree load-bearing for callers below the repository root.
99
+ function baseTreeAtHead(dir) {
100
+ const git = (...args) => spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", maxBuffer: 1 << 28 });
101
+ const top = git("rev-parse", "--show-toplevel");
102
+ const tree = git("ls-tree", "--full-tree", "-r", "--name-only", "HEAD");
103
+ if (top.status !== 0 || tree.status !== 0 || typeof top.stdout !== "string" || typeof tree.stdout !== "string")
104
+ return undefined;
105
+ return { root: top.stdout.trim(), tracked: new Set(tree.stdout.split("\n").filter(Boolean)) };
106
+ }
107
+ function fileBytes(repoRoot, rel, tree = baseTreeAtHead(repoRoot)) {
108
+ if (GLOB_CHARS.test(rel))
109
+ return { status: "unreadable", reason: "glob" };
110
+ if (!tree)
111
+ return { status: "unreadable", reason: "base-tree-unavailable" };
112
+ const path = rel.replace(/\/+$/, "");
113
+ if (!tree.tracked.has(path)) {
114
+ if ([...tree.tracked].some((tracked) => tracked.startsWith(`${path}/`))) {
115
+ return { status: "unreadable", reason: "directory" };
116
+ }
117
+ return { status: "unreadable", reason: existsSync(join(tree.root, path)) ? "untracked" : "absent" };
39
118
  }
119
+ // Read the blob size from HEAD too: an unstaged/staged checkout edit is not what the worker receives.
120
+ const size = spawnSync("git", ["-C", tree.root, "cat-file", "-s", `HEAD:${path}`], { encoding: "utf8" });
121
+ const bytes = Number.parseInt(typeof size.stdout === "string" ? size.stdout.trim() : "", 10);
122
+ return size.status === 0 && Number.isSafeInteger(bytes) && bytes >= 0
123
+ ? { status: "measured", bytes }
124
+ : { status: "unreadable", reason: "base-tree-read-failed" };
40
125
  }
41
- /** Best-effort plan-time payload estimate: prompt shell + context + files[] byte sizes. */
42
- export function estimateTaskPayloadTokens(task, repoRoot, feedback = "") {
126
+ function taskPayloadResolution(task, repoRoot, feedback) {
43
127
  let bytes = buildTaskPrompt(task, feedback).length;
44
- for (const p of task.context)
45
- bytes += fileBytes(repoRoot, p);
46
- for (const p of task.files)
47
- bytes += fileBytes(repoRoot, p);
48
- return Math.ceil(bytes / CHARS_PER_TOKEN);
128
+ const unreadable = [];
129
+ const tree = baseTreeAtHead(repoRoot);
130
+ const add = (field, path) => {
131
+ const measured = fileBytes(repoRoot, path, tree);
132
+ if (measured.status === "measured")
133
+ bytes += measured.bytes;
134
+ else
135
+ unreadable.push({ field, path, reason: measured.reason });
136
+ };
137
+ for (const path of task.context)
138
+ add("context", path);
139
+ for (const path of task.files)
140
+ add("files", path);
141
+ return { tokens: Math.ceil(bytes / CHARS_PER_TOKEN), unreadable };
142
+ }
143
+ /** A numeric estimate exists only when every payload path is measurable from the worker-visible tree. */
144
+ export function estimateTaskPayloadTokens(task, repoRoot, feedback = "") {
145
+ const estimate = taskPayloadResolution(task, repoRoot, feedback);
146
+ return estimate.unreadable.length === 0 ? estimate.tokens : undefined;
147
+ }
148
+ function unreadablePayloadLint(taskId, unreadable) {
149
+ const describe = ({ field, path, reason }) => {
150
+ const prefix = `${field} ${JSON.stringify(path)}`;
151
+ if (reason === "untracked")
152
+ return `${prefix} is present in the checkout but not in the base tree`;
153
+ if (reason === "absent")
154
+ return `${prefix} is absent from the base tree and checkout`;
155
+ if (reason === "glob")
156
+ return `${prefix} is a glob and cannot be measured`;
157
+ if (reason === "directory")
158
+ return `${prefix} is a directory and cannot be measured as one file`;
159
+ if (reason === "base-tree-unavailable")
160
+ return `${prefix} cannot be checked because the base tree is unavailable`;
161
+ return `${prefix} could not be read from the base tree`;
162
+ };
163
+ return `${taskId}: payload unreadable — ${unreadable.map(describe).join(", ")}; context-window comparison skipped`;
49
164
  }
50
165
  /** Advisory only — absent windows config or undeclared model window ⇒ no lint. */
51
166
  export function contextWindowLints(tasks, assignments, cfg, repoRoot) {
52
- if (!hasWindowsConfig(cfg))
167
+ if (!hasAnyWindows(cfg))
53
168
  return [];
54
169
  const byId = new Map(assignments.map((a) => [a.taskId, a]));
55
170
  const lints = [];
@@ -60,9 +175,13 @@ export function contextWindowLints(tasks, assignments, cfg, repoRoot) {
60
175
  const window = declaredModelWindow(cfg, a.adapter, a.model);
61
176
  if (window === undefined)
62
177
  continue;
63
- const est = estimateTaskPayloadTokens(t, repoRoot);
64
- if (est > window) {
65
- lints.push(`${t.id}: payload ~${est} tokens exceeds ${a.adapter}:${a.model} window ${window}`);
178
+ const estimate = taskPayloadResolution(t, repoRoot, "");
179
+ if (estimate.unreadable.length > 0) {
180
+ lints.push(unreadablePayloadLint(t.id, estimate.unreadable));
181
+ continue;
182
+ }
183
+ if (estimate.tokens > window) {
184
+ lints.push(`${t.id}: payload ~${estimate.tokens} tokens exceeds ${a.adapter}:${a.model} window ${window}`);
66
185
  }
67
186
  }
68
187
  return lints;
@@ -78,8 +197,7 @@ const adapterHasAuthedChannel = (adapterId, shape, cfg, health, adapters) => {
78
197
  return true; // no per-model probe data — compat, not dead
79
198
  return a.channels(cfg).some((c) => TIER_RANK[c.tier] >= TIER_RANK[minTier] && h.modelAuth?.[c.model]?.authed === true);
80
199
  };
81
- // OBS-30 T2: warn when a built-in seed prefer names an adapter with zero authed channels this probe pass.
82
- export function seedPreferLints(cfg, health, adapters, overlayPreferShapes = new Set()) {
200
+ function collectSeedPreferLints(cfg, health, adapters, overlayPreferShapes, disposition) {
83
201
  const lints = [];
84
202
  for (const shape of Object.keys(cfg.routing.map)) {
85
203
  if (overlayPreferShapes.has(shape))
@@ -89,18 +207,23 @@ export function seedPreferLints(cfg, health, adapters, overlayPreferShapes = new
89
207
  if (!cfg.tiers[adapterId])
90
208
  continue;
91
209
  if (!adapterHasAuthedChannel(adapterId, shape, cfg, health, adapters)) {
92
- lints.push(`routing seed names dead adapter '${adapterId}' for shape '${shape}' — auto-prefer is routing around it`);
210
+ lints.push(`routing seed names dead adapter '${adapterId}' for shape '${shape}' — ${disposition}`);
93
211
  }
94
212
  }
95
213
  }
96
214
  return lints;
97
215
  }
216
+ // OBS-30 T2 / v1.86 T3: warn when a built-in seed prefer names an adapter with zero authed
217
+ // channels and no operator-declared preference overrides that seed for the shape.
218
+ export function seedPreferLints(cfg, health, adapters, overlayPreferShapes = new Set()) {
219
+ return collectSeedPreferLints(cfg, health, adapters, overlayPreferShapes, DECLARED_SEED_PREFER_DISPOSITION);
220
+ }
98
221
  // v1.54 T3: dead-steering sweep — operator prefer entries (routing.map overlay shapes, review.prefer,
99
222
  // consult.prefer) that can never match an installed channel are named at plan time; v1.53 T2 pins the
100
223
  // no-match case as a silent no-op, which makes a typo invisible. Advisory only: reads config + doctor
101
- // health (no live probes), never touches routing. Seed map prefers stay seedPreferLints' turf (auto-prefer
102
- // routes around dead seeds) — mapShapes limits this sweep to operator-authored entries so a bare default
103
- // fleet isn't double-linted. Entry grammar mirrors preferIndex: adapter | adapter:model (first colon).
224
+ // health (no live probes), never touches routing. Seed map prefers stay seedPreferLints' turf;
225
+ // mapShapes limits this sweep to operator-authored entries so a bare default fleet isn't double-linted.
226
+ // Entry grammar mirrors preferIndex: adapter | adapter:model (first colon).
104
227
  export function preferEntryLints(cfg, health, mapShapes = new Set()) {
105
228
  const lints = [];
106
229
  const sweep = (surface, entries) => {
@@ -122,17 +245,15 @@ export function preferEntryLints(cfg, health, mapShapes = new Set()) {
122
245
  sweep("consult.prefer", cfg.consult.prefer);
123
246
  return lints;
124
247
  }
125
- // Diffs detected models (doctor.json) against configured tiers, both directions, per adapter id in cfg.tiers.
248
+ // Diffs detected models (doctor.json) against configured tiers, both directions, per installed adapter.
126
249
  // No ` ! ` prefix here — the consumer (doctor rows / plan lints) owns that. Pre-v1.5 doctor.json (models:[], no
127
250
  // modelsDetectedAt) is the compat baseline: `?.`/`?? []` everywhere, no zod (would reject old files).
128
251
  export function modelLints(cfg, health, adapters, opts) {
129
252
  const cap = opts?.tty ? TTY_LINT_CAP : LINT_CAP;
130
253
  const doctorRef = opts?.tty ? doctorJsonRef(opts.stateDir ?? DEFAULT_STATE_DIR) : "";
131
254
  const lints = [];
132
- for (const id of Object.keys(cfg.tiers)) {
133
- const adapter = adapters.find((a) => a.id === id);
134
- if (!adapter)
135
- continue; // fake/overlay-only tier entry with no adapter — nothing to diff against
255
+ for (const adapter of adapters) {
256
+ const id = adapter.id;
136
257
  if (!adapter.listModels) {
137
258
  lints.push(`${id}: no model-list surface — seeds stamped ${SEED_STAMPED}; verify manually`);
138
259
  continue;
@@ -144,7 +265,7 @@ export function modelLints(cfg, health, adapters, opts) {
144
265
  lints.push(`${id}: no detection data — run tickmarkr doctor`);
145
266
  continue; // no data to diff or age
146
267
  }
147
- const configured = Object.keys(cfg.tiers[id].models);
268
+ const configured = Object.keys(cfg.tiers[id]?.models ?? {});
148
269
  for (const model of configured) {
149
270
  if (!detected.includes(model)) {
150
271
  lints.push(`${id}: tiers lists ${model} — CLI no longer reports it; tombstone it (${model}: null overlay) or verify the id`);
@@ -163,7 +284,13 @@ export function modelLints(cfg, health, adapters, opts) {
163
284
  lints.push(`${id}: model knowledge is ${days} days old — rerun tickmarkr doctor`);
164
285
  }
165
286
  }
166
- lints.push(...seedPreferLints(cfg, health, adapters, opts?.overlayPreferShapes));
287
+ // v1.34 T3 byte-pins non-TTY doctor output. Doctor consumers are the ones that provide stateDir;
288
+ // keep that machine-facing compatibility surface stable while plan and direct lint callers state
289
+ // the current declared-preference mechanism truthfully.
290
+ const seedDisposition = opts?.stateDir !== undefined && opts.tty !== true
291
+ ? LEGACY_DOCTOR_SEED_PREFER_DISPOSITION
292
+ : DECLARED_SEED_PREFER_DISPOSITION;
293
+ lints.push(...collectSeedPreferLints(cfg, health, adapters, opts?.overlayPreferShapes ?? new Set(), seedDisposition));
167
294
  return lints;
168
295
  }
169
296
  // T2/T6: one lint per exclusion, naming the probe reason and date. TTY truncates reasons to 60 chars and
@@ -183,17 +310,17 @@ export function formatModelAuthLine(excluded, tty, stateDir = DEFAULT_STATE_DIR)
183
310
  // machine never fabricates one — auto-tiering reopens the NaN-routing class). Removals render as LIVE
184
311
  // `<id>: null` tombstones (deepMerge deletes the key). Pure function: no fs, no routing contact.
185
312
  // Returns "" when no adapter has a delta. Mirrors modelLints' per-adapter guards exactly.
186
- export function suggestOverlay(cfg, health, adapters, stateDir = DEFAULT_STATE_DIR) {
313
+ export function suggestOverlay(cfg, health, adapters, stateDir = DEFAULT_STATE_DIR, opts = {}) {
187
314
  const blocks = [];
188
- for (const id of Object.keys(cfg.tiers)) {
189
- const adapter = adapters.find((a) => a.id === id);
190
- if (!adapter?.listModels)
191
- continue; // no adapter / no list surface → nothing to diff (mirror modelLints)
315
+ for (const adapter of adapters) {
316
+ const id = adapter.id;
317
+ if (!adapter.listModels)
318
+ continue; // no list surface → nothing to diff (mirror modelLints)
192
319
  const h = health[id];
193
320
  const detected = h?.models ?? [];
194
321
  if (detected.length === 0)
195
322
  continue; // no detection data → don't guess a delta
196
- const configured = Object.keys(cfg.tiers[id].models);
323
+ const configured = Object.keys(cfg.tiers[id]?.models ?? {});
197
324
  const date = h?.modelsDetectedAt?.split("T")[0]; // best-effort day stamp
198
325
  const detNote = date ? ` (detected ${date})` : "";
199
326
  const lines = [];
@@ -220,11 +347,17 @@ export function suggestOverlay(cfg, health, adapters, stateDir = DEFAULT_STATE_D
220
347
  for (const model of detected) {
221
348
  if (configured.includes(model) || !MODEL_ID_RE.test(model) || LINT_VARIANT_RE.test(model))
222
349
  continue;
223
- if (!cfgPrefixes.has(providerPrefix(model)) && !cfgCanon.has(canonical(model))) {
350
+ if (configured.length > 0 && !cfgPrefixes.has(providerPrefix(model)) && !cfgCanon.has(canonical(model))) {
224
351
  omitted++;
225
352
  continue;
226
353
  }
227
- lines.push(` # ${model}: ??? #${date ? ` detected ${date} —` : ""} classify per benchmark policy (AA Index + SWE-bench Pro, dated), then uncomment`);
354
+ const advisory = opts.catalog
355
+ ? catalogModelAdvisory(cfg, opts.catalog, id, model, opts.resolvedModel?.(id, model))
356
+ : undefined;
357
+ const guidance = advisory?.suggestion
358
+ ? `provenance note (operator confirmation required): ${advisory.suggestion.provenanceNote}; choose a tier, then uncomment`
359
+ : `classify per benchmark policy (AA Index + SWE-bench Pro, dated), then uncomment${advisory ? ` — ${advisory.display}` : ""}`;
360
+ lines.push(` # ${model}: ??? #${date ? ` detected ${date} —` : ""} ${guidance}`);
228
361
  }
229
362
  if (omitted)
230
363
  lines.push(` # (+${omitted} other detected id${omitted === 1 ? "" : "s"} not related to your configured models — see ${stateDir}/doctor.json)`);
@@ -258,14 +391,15 @@ function referenceWarning(cfg, adapterId, model) {
258
391
  /** Unclassified models surfaced for fleet screen 2 (doctor matrix math, no tier fabrication). */
259
392
  export function fleetUnclassifiedModels(cfg, health, adapters) {
260
393
  const out = [];
261
- for (const id of Object.keys(cfg.tiers)) {
262
- if (!adapters.some((a) => a.id === id))
263
- continue;
394
+ for (const adapter of adapters) {
395
+ const id = adapter.id;
264
396
  const h = health[id];
397
+ if (!h?.installed)
398
+ continue;
265
399
  const detected = h?.models ?? [];
266
400
  if (!detected.length)
267
401
  continue;
268
- const configured = new Set(Object.keys(cfg.tiers[id].models));
402
+ const configured = new Set(Object.keys(cfg.tiers[id]?.models ?? {}));
269
403
  const date = h?.modelsDetectedAt?.split("T")[0];
270
404
  for (const model of detected) {
271
405
  if (configured.has(model) || LINT_VARIANT_RE.test(model))
@@ -0,0 +1,31 @@
1
+ import { z } from "zod";
2
+ declare const ModelWindowClaimSchema: z.ZodObject<{
3
+ modelId: z.ZodString;
4
+ window: z.ZodNumber;
5
+ source: z.ZodString;
6
+ readDate: z.ZodISODate;
7
+ }, z.core.$strict>;
8
+ export type ModelWindowClaim = Readonly<z.infer<typeof ModelWindowClaimSchema>>;
9
+ export type ModelWindowResolution = {
10
+ status: "declared";
11
+ claim: ModelWindowClaim;
12
+ } | {
13
+ status: "unknown";
14
+ modelId: string;
15
+ };
16
+ /**
17
+ * Validate cited model-window claims before production code can consume them.
18
+ * These values are vendor-published claims, not measurements made by tickmarkr.
19
+ */
20
+ export declare function loadModelWindowClaims(input: unknown): readonly ModelWindowClaim[];
21
+ /** The closed, production-readable catalog of cited context-window claims. */
22
+ export declare const CITED_MODEL_WINDOWS: readonly Readonly<{
23
+ modelId: string;
24
+ window: number;
25
+ source: string;
26
+ readDate: string;
27
+ }>[];
28
+ export declare function resolveModelWindowClaim(modelId: string, input?: unknown): ModelWindowResolution;
29
+ /** Fail when either side contains a model id absent from the other side. */
30
+ export declare function assertModelWindowClaimsMatchSeededModels(seededModelIds: readonly string[], input?: unknown): void;
31
+ export {};
@@ -0,0 +1,69 @@
1
+ import { z } from "zod";
2
+ const ModelWindowClaimSchema = z
3
+ .object({
4
+ modelId: z.string().min(1),
5
+ window: z.number().int().positive(),
6
+ source: z.string().url(),
7
+ readDate: z.iso.date(),
8
+ })
9
+ .strict();
10
+ /**
11
+ * Validate cited model-window claims before production code can consume them.
12
+ * These values are vendor-published claims, not measurements made by tickmarkr.
13
+ */
14
+ export function loadModelWindowClaims(input) {
15
+ const claims = z.array(ModelWindowClaimSchema).parse(input);
16
+ const seen = new Set();
17
+ for (const claim of claims) {
18
+ if (seen.has(claim.modelId))
19
+ throw new Error(`duplicate model id ${claim.modelId}`);
20
+ seen.add(claim.modelId);
21
+ }
22
+ return Object.freeze(claims.map((claim) => Object.freeze(claim)));
23
+ }
24
+ const ANTHROPIC_SOURCE = "https://platform.claude.com/docs/en/about-claude/models/overview";
25
+ const OPENAI_SOURCE = "https://developers.openai.com/api/docs/models";
26
+ const OPENAI_GPT_55_SOURCE = "https://developers.openai.com/api/docs/models/gpt-5.5";
27
+ const CURSOR_SOURCE = "https://cursor.com/help/ai-features/max-mode";
28
+ const ZAI_SOURCE = "https://z.ai/blog/glm-5.2";
29
+ const XAI_SOURCE = "https://docs.x.ai/developers/models/grok-4.5";
30
+ const KIMI_SOURCE = "https://www.kimi.com/code/docs/en/kimi-code-cli/configuration/config-files.html";
31
+ const READ_DATE = "2026-08-05";
32
+ const VENDORED_MODEL_WINDOW_CLAIMS = [
33
+ { modelId: "fable", window: 1_000_000, source: ANTHROPIC_SOURCE, readDate: READ_DATE },
34
+ { modelId: "opus", window: 1_000_000, source: ANTHROPIC_SOURCE, readDate: READ_DATE },
35
+ { modelId: "sonnet", window: 1_000_000, source: ANTHROPIC_SOURCE, readDate: READ_DATE },
36
+ { modelId: "haiku", window: 200_000, source: ANTHROPIC_SOURCE, readDate: READ_DATE },
37
+ { modelId: "gpt-5.6-sol", window: 1_050_000, source: OPENAI_SOURCE, readDate: READ_DATE },
38
+ { modelId: "gpt-5.5", window: 1_050_000, source: OPENAI_GPT_55_SOURCE, readDate: READ_DATE },
39
+ { modelId: "gpt-5.6-terra", window: 1_050_000, source: OPENAI_SOURCE, readDate: READ_DATE },
40
+ { modelId: "gpt-5.6-luna", window: 1_050_000, source: OPENAI_SOURCE, readDate: READ_DATE },
41
+ { modelId: "composer-2.5", window: 200_000, source: CURSOR_SOURCE, readDate: READ_DATE },
42
+ { modelId: "composer-2.5-fast", window: 200_000, source: CURSOR_SOURCE, readDate: READ_DATE },
43
+ { modelId: "zai-coding-plan/glm-5.2", window: 1_000_000, source: ZAI_SOURCE, readDate: READ_DATE },
44
+ { modelId: "zai/glm-5.2", window: 1_000_000, source: ZAI_SOURCE, readDate: READ_DATE },
45
+ { modelId: "grok-4.5", window: 500_000, source: XAI_SOURCE, readDate: READ_DATE },
46
+ { modelId: "grok-composer-2.5-fast", window: 200_000, source: CURSOR_SOURCE, readDate: READ_DATE },
47
+ { modelId: "kimi-code/k3", window: 1_048_576, source: KIMI_SOURCE, readDate: READ_DATE },
48
+ { modelId: "kimi-code/kimi-for-coding", window: 262_144, source: KIMI_SOURCE, readDate: READ_DATE },
49
+ { modelId: "kimi-code/kimi-for-coding-highspeed", window: 262_144, source: KIMI_SOURCE, readDate: READ_DATE },
50
+ ];
51
+ /** The closed, production-readable catalog of cited context-window claims. */
52
+ export const CITED_MODEL_WINDOWS = loadModelWindowClaims(VENDORED_MODEL_WINDOW_CLAIMS);
53
+ export function resolveModelWindowClaim(modelId, input = CITED_MODEL_WINDOWS) {
54
+ const claim = loadModelWindowClaims(input).find((entry) => entry.modelId === modelId);
55
+ return claim === undefined ? { status: "unknown", modelId } : { status: "declared", claim };
56
+ }
57
+ /** Fail when either side contains a model id absent from the other side. */
58
+ export function assertModelWindowClaimsMatchSeededModels(seededModelIds, input = CITED_MODEL_WINDOWS) {
59
+ const tableIds = new Set(loadModelWindowClaims(input).map((claim) => claim.modelId));
60
+ const seedIds = new Set(seededModelIds);
61
+ const tableOnly = [...tableIds].filter((id) => !seedIds.has(id)).sort();
62
+ const seedOnly = [...seedIds].filter((id) => !tableIds.has(id)).sort();
63
+ const differences = [
64
+ tableOnly.length ? `table-only model ids: ${tableOnly.join(", ")}` : "",
65
+ seedOnly.length ? `seed-only model ids: ${seedOnly.join(", ")}` : "",
66
+ ].filter(Boolean);
67
+ if (differences.length)
68
+ throw new Error(differences.join("; "));
69
+ }
@@ -1,10 +1,14 @@
1
1
  import { type Task } from "../graph/schema.js";
2
+ import { type VerdictUnparseableCause } from "../gates/verdict-cause.js";
2
3
  import type { WorkerResult } from "./types.js";
3
4
  export declare function buildTaskPrompt(task: Task, feedback?: string, nonce?: string): string;
4
5
  export declare function writePrompt(dir: string, task: Task, attempt: number, feedback?: string, nonce?: string): string;
5
6
  export declare function trailerPattern(nonce: string): string;
6
7
  export declare const NO_TRAILER_SUMMARY = "worker produced no TICKMARKR_RESULT trailer";
7
8
  export declare const UNPARSEABLE_TRAILER_SUMMARY = "unparseable TICKMARKR_RESULT trailer";
8
- export declare function parseWorkerResult(raw: string, nonce: string): WorkerResult;
9
+ export type ClassifiedWorkerResult = WorkerResult & {
10
+ cause?: VerdictUnparseableCause;
11
+ };
12
+ export declare function parseWorkerResult(raw: string, nonce: string): ClassifiedWorkerResult;
9
13
  export type DeadChannelReason = "auth-required" | "setup-required" | "provider-outage" | "timeout";
10
14
  export declare function classifyDeadChannel(result: WorkerResult): DeadChannelReason | undefined;