tickmarkr 2.5.4 → 2.5.6

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 +3 -1
  2. package/dist/adapters/registry.js +6 -1
  3. package/dist/adapters/types.d.ts +3 -0
  4. package/dist/cli/commands/approve.d.ts +1 -0
  5. package/dist/cli/commands/approve.js +118 -9
  6. package/dist/cli/commands/doctor.d.ts +10 -0
  7. package/dist/cli/commands/doctor.js +44 -29
  8. package/dist/cli/commands/fleet.js +199 -33
  9. package/dist/cli/commands/init.js +196 -6
  10. package/dist/cli/commands/plan.js +20 -4
  11. package/dist/cli/commands/resume.js +4 -2
  12. package/dist/cli/commands/run.js +11 -2
  13. package/dist/cli/commands/verify.js +102 -81
  14. package/dist/cli/help.d.ts +2 -0
  15. package/dist/cli/help.js +3 -1
  16. package/dist/config/config.d.ts +25 -8
  17. package/dist/config/config.js +41 -29
  18. package/dist/config/fleet-overlay.d.ts +3 -9
  19. package/dist/config/fleet-overlay.js +114 -19
  20. package/dist/config/fleet-why.d.ts +7 -0
  21. package/dist/config/fleet-why.js +5 -0
  22. package/dist/drivers/index.d.ts +15 -1
  23. package/dist/drivers/index.js +39 -10
  24. package/dist/drivers/orca.d.ts +119 -10
  25. package/dist/drivers/orca.js +781 -110
  26. package/dist/gates/baseline.d.ts +17 -3
  27. package/dist/gates/baseline.js +63 -15
  28. package/dist/gates/cache.d.ts +101 -0
  29. package/dist/gates/cache.js +401 -0
  30. package/dist/gates/llm.d.ts +3 -0
  31. package/dist/gates/llm.js +11 -0
  32. package/dist/gates/review.d.ts +28 -3
  33. package/dist/gates/review.js +106 -14
  34. package/dist/gates/run-gates.d.ts +9 -1
  35. package/dist/gates/run-gates.js +407 -102
  36. package/dist/gates/test-manifest.d.ts +128 -0
  37. package/dist/gates/test-manifest.js +463 -0
  38. package/dist/gates/test-reporter.d.ts +4 -0
  39. package/dist/gates/test-reporter.js +57 -0
  40. package/dist/graph/graph.d.ts +2 -0
  41. package/dist/graph/graph.js +45 -1
  42. package/dist/route/preference.d.ts +22 -1
  43. package/dist/route/preference.js +123 -25
  44. package/dist/route/router.js +31 -6
  45. package/dist/run/consult.js +5 -4
  46. package/dist/run/daemon.d.ts +15 -0
  47. package/dist/run/daemon.js +638 -167
  48. package/dist/run/execution-budget.d.ts +25 -0
  49. package/dist/run/execution-budget.js +142 -0
  50. package/dist/run/git.d.ts +50 -1
  51. package/dist/run/git.js +131 -12
  52. package/dist/run/journal.d.ts +16 -2
  53. package/dist/run/journal.js +115 -25
  54. package/dist/run/lease.d.ts +58 -0
  55. package/dist/run/lease.js +310 -0
  56. package/dist/run/merge.d.ts +2 -0
  57. package/dist/run/merge.js +91 -3
  58. package/dist/run/operator-state.d.ts +11 -0
  59. package/dist/run/operator-state.js +17 -3
  60. package/dist/run/recovery.d.ts +8 -0
  61. package/dist/run/recovery.js +25 -0
  62. package/dist/run/repair-selection.d.ts +12 -0
  63. package/dist/run/repair-selection.js +56 -0
  64. package/dist/run/stall.d.ts +6 -1
  65. package/dist/run/stall.js +60 -3
  66. package/dist/tui/cockpit/board.d.ts +96 -0
  67. package/dist/tui/cockpit/board.js +346 -0
  68. package/dist/tui/cockpit/decision-actions.js +2 -0
  69. package/dist/tui/cockpit/layout.d.ts +5 -1
  70. package/dist/tui/cockpit/layout.js +8 -3
  71. package/dist/tui/cockpit/live-runtime.js +71 -21
  72. package/dist/tui/cockpit/run-view.d.ts +7 -5
  73. package/dist/tui/cockpit/run-view.js +12 -11
  74. package/dist/tui/ink/fleet-app.d.ts +49 -27
  75. package/dist/tui/ink/fleet-app.js +229 -38
  76. package/package.json +2 -2
  77. package/skills/tickmarkr-overseer/SKILL.md +173 -113
  78. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +29 -2
package/README.md CHANGED
@@ -52,7 +52,7 @@ prefer, every example below works with either.
52
52
 
53
53
  - **macOS or Linux.** Every command shells out through `bash`, so native Windows is not
54
54
  supported — use WSL (untested).
55
- - Node ≥ 20 and `git` on PATH.
55
+ - Node ≥ 20.3 and `git` on PATH.
56
56
  - At least one agent CLI on PATH (`claude`, `codex`, `cursor-agent`, `opencode`, `grok`, `pi`,
57
57
  or `kimi`), authenticated through its own login. tickmarkr never handles vendor API keys itself.
58
58
 
@@ -476,6 +476,8 @@ accepted contributions are credited via `Co-authored-by:` on the release commit.
476
476
 
477
477
  ## Documentation
478
478
 
479
+ - [Experimental execution policy](docs/experimental-execution-policy.md) — bounded recovery, cumulative task time, and repair selection.
480
+
479
481
  - **[FLEET.md](https://github.com/alzahrani-khalid/tickmarkr/blob/main/FLEET.md)** — routing modes, steering syntax, tier provenance, and run flags (advanced reference)
480
482
  - **[LICENSE](LICENSE)** — MIT license
481
483
  - **[CONTRIBUTING.md](CONTRIBUTING.md)** — development setup and contribution guidelines
@@ -578,7 +578,12 @@ export function discoverChannels(cfg, adapters, health, role = "worker") {
578
578
  // channel — routing an unknown or 403 into dispatch is fail-closed. Operators with pre-v1.21 doctor.json
579
579
  // can opt into the prior unknown-is-routable behavior with routing.allowUnverifiedModels.
580
580
  // OBS-117: configured aliases absent from the adapter CLI's own model list are not dispatchable.
581
- return a.channels(cfg).filter((c) => !invalid.has(c.model) && modelAuthed(h, c.model, cfg.routing.allowUnverifiedModels) && (!s || s.includes(c.model)));
581
+ return a.channels(cfg)
582
+ .filter((c) => !invalid.has(c.model) && modelAuthed(h, c.model, cfg.routing.allowUnverifiedModels) && (!s || s.includes(c.model)))
583
+ .map((c) => {
584
+ const identity = h?.modelAuth?.[c.model]?.identity ?? h?.modelIdentities?.[c.model];
585
+ return identity ? { ...c, identity } : c;
586
+ });
582
587
  });
583
588
  if (!cfg.routing.allow && !cfg.routing.deny)
584
589
  return base;
@@ -22,6 +22,7 @@ export interface BillingChannel {
22
22
  model: string;
23
23
  channel: "sub" | "api";
24
24
  tier: Tier;
25
+ identity?: string;
25
26
  }
26
27
  export declare const MODEL_PROBE_ERRORS: readonly ["EMFILE", "EAGAIN", "ENFILE", "ENOMEM", "ENOSPC"];
27
28
  export type ModelProbeError = typeof MODEL_PROBE_ERRORS[number];
@@ -30,6 +31,7 @@ export interface ModelAuth {
30
31
  reason?: string;
31
32
  probeError?: ModelProbeError;
32
33
  probedAt: string;
34
+ identity?: string;
33
35
  }
34
36
  export interface AuthHealth {
35
37
  installed: boolean;
@@ -40,6 +42,7 @@ export interface AuthHealth {
40
42
  modelsDetectedAt?: string;
41
43
  servable?: string[];
42
44
  modelAuth?: Record<string, ModelAuth>;
45
+ modelIdentities?: Record<string, string>;
43
46
  }
44
47
  export declare function modelAuthed(health: AuthHealth | undefined, model: string, allowUnverifiedModels?: boolean): boolean;
45
48
  export interface Invocation {
@@ -21,6 +21,7 @@ export interface NewestPark {
21
21
  /** The daemon-recorded kind (task-human data.kind), never inferred from prose. */
22
22
  kind: string | undefined;
23
23
  reason: string | undefined;
24
+ approveCommand?: string;
24
25
  /** The newest failed gate before the park — the gate a waive would satisfy. */
25
26
  failedGate: string | undefined;
26
27
  /** A pre-dispatch human gate whose reason marks it permanent by design (see isTombstonePark). */
@@ -1,6 +1,11 @@
1
+ import { graphDefinitionHash, loadGraph, saveGraph } from "../../graph/graph.js";
2
+ import { execFileSync } from "node:child_process";
1
3
  import { userInfo } from "node:os";
4
+ import { loadConfig } from "../../config/config.js";
5
+ import { integrationBranch } from "../../run/merge.js";
6
+ import { separabilityErrors } from "../../compile/collateral.js";
2
7
  import { GATE_NAMES } from "../../graph/schema.js";
3
- import { ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, RECHECK_RELEASE, REVIEW_UPHELD_RELEASE } from "../../run/journal.js";
8
+ import { applyScopeAmendments, engagementComparable, ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, RECHECK_RELEASE, REVIEW_UPHELD_RELEASE } from "../../run/journal.js";
4
9
  export const APPROVAL_DISPOSITIONS = ["dispatch", "waive-gate", "re-dispatch", "fund-fixed-attempt", "fresh-budget"];
5
10
  /**
6
11
  * What each disposition's release actually buys — the clause every operator-facing sentence about
@@ -52,6 +57,7 @@ sourceIndexes) {
52
57
  const reason = typeof e.data.reason === "string" ? e.data.reason : undefined;
53
58
  return {
54
59
  index: i, line: (sourceIndexes?.[i] ?? i) + 1, ts: typeof e.ts === "string" ? e.ts : undefined, kind, reason,
60
+ approveCommand: typeof e.data.approveCommand === "string" ? e.data.approveCommand : undefined,
55
61
  failedGate: failedGateForNewestPark(events, taskId, i), tombstone: isTombstonePark(kind, reason),
56
62
  };
57
63
  }
@@ -73,7 +79,7 @@ export function readJournalEvents(journal) {
73
79
  * surface may read it from, so what a menu offers and what the command accepts cannot drift.
74
80
  */
75
81
  export function permittedDecisionVerbs(park) {
76
- if (!park || park.tombstone)
82
+ if (!park || park.tombstone || park.kind === "scope-request")
77
83
  return [];
78
84
  if (park.kind === "gate-fail") {
79
85
  if (park.failedGate === undefined)
@@ -82,6 +88,8 @@ export function permittedDecisionVerbs(park) {
82
88
  }
83
89
  if (park.kind === "infra")
84
90
  return ["approve", "recheck"];
91
+ if (park.kind === "diff-cap")
92
+ return ["recheck"]; // OBS-1007: a cap trip is re-gated under a raised cap, never re-bought
85
93
  return ["approve"];
86
94
  }
87
95
  /** The release marker this command appends for a verb on a park — the fact a read-back must match. */
@@ -123,7 +131,7 @@ export function approvalEnactment(token, run) {
123
131
  }
124
132
  /** The production command registered in COMMANDS; its returned bytes are what the CLI prints. */
125
133
  export async function approve(argv, cwd = process.cwd()) {
126
- const { runId, taskId, by, reason, waive, uphold, recheck, reviewRoundCeiling } = parseArgs(argv);
134
+ const { runId, taskId, by, reason, waive, uphold, recheck, reviewRoundCeiling, files } = parseArgs(argv);
127
135
  const decisions = [waive, uphold, recheck].filter(Boolean).length;
128
136
  if (decisions > 1)
129
137
  throw new Error("--waive, --uphold and --recheck are different decisions — pass one");
@@ -135,6 +143,21 @@ export async function approve(argv, cwd = process.cwd()) {
135
143
  if (status === undefined) {
136
144
  throw new Error(`task ${taskId} has no events in run ${runId} — unknown task or never dispatched`);
137
145
  }
146
+ // OBS-1022: a task-failed task whose preserved ref (or task branch) carries commits ahead of the
147
+ // integration base owns verified-able work; --recheck re-gates that tree with no worker. Any other
148
+ // verb on a failed task, or a recheck over nothing landed, is refused naming why.
149
+ if (status === "failed" && recheck) {
150
+ const ahead = failedTaskCommitsAhead(cwd, journal, runId, taskId);
151
+ if (ahead.count === 0) {
152
+ throw new Error(`task ${taskId} is failed and ${ahead.ref} carries no commits ahead of ${ahead.base} — nothing to re-gate; use resume --retry-failed to fund a worker`);
153
+ }
154
+ journal.append("task-approved", taskId, {
155
+ by, ...(reason ? { reason } : {}), via: "cli", release: RECHECK_RELEASE,
156
+ recheckedRef: ahead.ref, commitsAhead: ahead.count,
157
+ ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
158
+ });
159
+ return disposition(cwd, runId, "re-dispatch", `re-checking failed ${taskId} in ${runId} — by ${by}; ${ahead.count} commit(s) on ${ahead.ref} ahead of ${ahead.base}; no worker funded`, serialization.contended);
160
+ }
138
161
  if (status !== "human") {
139
162
  // a silent no-op would be worse than a loud refusal — name the actual status (D-05)
140
163
  throw new Error(`task ${taskId} is ${status}, not a parked human gate — refusing (a silent no-op would be worse)`);
@@ -146,8 +169,43 @@ export async function approve(argv, cwd = process.cwd()) {
146
169
  const lastHuman = park === undefined ? undefined : events[park.index];
147
170
  const capPark = park?.kind === ATTEMPT_CAP_RELEASE;
148
171
  const gateFailPark = park?.kind === "gate-fail";
149
- const infraPark = park?.kind === "infra";
172
+ const infraPark = park?.kind === "infra" || park?.kind === "diff-cap";
150
173
  const failedGate = gateFailPark ? park?.failedGate : undefined;
174
+ if ((park?.kind === "scope-request" && !decisions) || files !== undefined) {
175
+ if (park?.kind !== "scope-request")
176
+ throw new Error("--files requires a scope-request park");
177
+ if (!files?.length)
178
+ throw new Error(`scope-request for ${taskId} requires --files <glob,…>`);
179
+ if (decisions)
180
+ throw new Error("--files cannot be combined with --waive, --uphold or --recheck");
181
+ const graph = applyScopeAmendments(loadGraph(cwd), journal);
182
+ const from = graphDefinitionHash(graph);
183
+ if (!engagementComparable(journal.read(), from).comparable
184
+ || (lastHuman?.data.graphDefinitionHash !== undefined && lastHuman.data.graphDefinitionHash !== from)) {
185
+ throw new Error(`refusing stale scope-request approval for ${taskId}: graph revision changed`);
186
+ }
187
+ const task = graph.tasks.find((t) => t.id === taskId);
188
+ if (!task)
189
+ throw new Error(`unknown task ${taskId}`);
190
+ const amendedFiles = [...new Set([...task.files, ...files])];
191
+ if (amendedFiles.length === task.files.length)
192
+ throw new Error("--files must extend the parked files[] revision");
193
+ const amended = { ...graph, tasks: graph.tasks.map((t) => t.id === taskId ? { ...t, files: amendedFiles } : t) };
194
+ const conflicts = separabilityErrors(amended.tasks);
195
+ if (conflicts.length)
196
+ throw new Error(`refusing scope-request approval for ${taskId}: ${conflicts.join("\n")}`);
197
+ journal.append("task-approved", taskId, {
198
+ by, ...(reason ? { reason } : {}), via: "cli", release: "scope-request",
199
+ amendment: { from, to: graphDefinitionHash(amended), beforeFiles: task.files, files: amendedFiles, parkLine: park.line },
200
+ });
201
+ // Do not write graph.json from this process while the daemon owns it: its sweep materializes
202
+ // the amendment without replacing a sibling's running state with this command's snapshot.
203
+ const projected = applyScopeAmendments(graph, journal);
204
+ const owner = approvalRunOwner(cwd, runId);
205
+ if (!owner.live && !owner.blockingRunId)
206
+ saveGraph(cwd, projected);
207
+ return disposition(cwd, runId, "dispatch", `approved files[] for ${taskId} in ${runId} — by ${by}`, serialization.contended);
208
+ }
151
209
  if (gateFailPark && !failedGate) {
152
210
  throw new Error(`task ${taskId} is parked on gate-fail but has no failed gate result on the newest park — refusing to infer one`);
153
211
  }
@@ -167,7 +225,7 @@ export async function approve(argv, cwd = process.cwd()) {
167
225
  }
168
226
  if (recheck) {
169
227
  if ((!gateFailPark || !failedGate) && !infraPark) {
170
- throw new Error(`--recheck applies to a gate-fail or infra park; ${taskId}'s newest park is ${String(lastHuman?.data.kind ?? "none")} with failed gate ${failedGate ?? "none"} — refusing`);
228
+ throw new Error(`--recheck applies to a gate-fail, infra or diff-cap park; ${taskId}'s newest park is ${String(lastHuman?.data.kind ?? "none")} with failed gate ${failedGate ?? "none"} — refusing`);
171
229
  }
172
230
  journal.append("task-approved", taskId, {
173
231
  by,
@@ -176,7 +234,7 @@ export async function approve(argv, cwd = process.cwd()) {
176
234
  release: RECHECK_RELEASE,
177
235
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
178
236
  });
179
- return disposition(cwd, runId, "re-dispatch", `re-checking ${taskId} in ${runId} — by ${by}; ${failedGate ? `failed gate ${failedGate}` : "infra park"}; no gate marked satisfied`, serialization.contended);
237
+ return disposition(cwd, runId, "re-dispatch", `re-checking ${taskId} in ${runId} — by ${by}; ${failedGate ? `failed gate ${failedGate}` : `${park.kind} park`}; no gate marked satisfied`, serialization.contended);
180
238
  }
181
239
  if (waive) {
182
240
  if (!gateFailPark || !failedGate) {
@@ -204,6 +262,11 @@ export async function approve(argv, cwd = process.cwd()) {
204
262
  if (park?.tombstone) {
205
263
  throw new Error(`task ${taskId}'s newest park is a tombstone (${park.reason ?? "no reason"}) — permanent by design; no verb releases it`);
206
264
  }
265
+ // OBS-1007: a cap trip is re-gated under a raised cap, never re-bought — plain approve would fund a
266
+ // worker the production oracle (permittedDecisionVerbs) says this park cannot buy.
267
+ if (park?.kind === "diff-cap") {
268
+ throw new Error(`task ${taskId} is parked on a diff cap; plain approve would fund another worker — raise gates.diffCap and pass --recheck (disposition re-dispatch)`);
269
+ }
207
270
  journal.append("task-approved", taskId, {
208
271
  by,
209
272
  ...(reason ? { reason } : {}),
@@ -218,6 +281,42 @@ export async function approve(argv, cwd = process.cwd()) {
218
281
  serialization.release();
219
282
  }
220
283
  }
284
+ /**
285
+ * OBS-1022: what a failed task has landed. A preserved snapshot is authoritative only when it contains
286
+ * the current task-branch tip; an older preservation can remain in the journal after a later retry has
287
+ * advanced/rebuilt the branch. Missing task branches may still recover from a preserved ref. The selected
288
+ * ref is counted against the run's integration branch; unreadable refs count as zero and fail closed.
289
+ */
290
+ function failedTaskCommitsAhead(cwd, journal, runId, taskId) {
291
+ const cfg = loadConfig(cwd);
292
+ const base = integrationBranch(cfg, runId);
293
+ const taskRef = `${base}--${taskId}`;
294
+ const preserved = [...journal.read()].reverse().find((e) => e.taskId === taskId && e.event === "worktree-preserved" && typeof e.data.ref === "string");
295
+ const preservedRef = preserved?.data.ref;
296
+ let ref = taskRef;
297
+ if (preservedRef) {
298
+ try {
299
+ execFileSync("git", ["rev-parse", "--verify", taskRef], { cwd, stdio: "ignore" });
300
+ execFileSync("git", ["merge-base", "--is-ancestor", taskRef, preservedRef], { cwd, stdio: "ignore" });
301
+ ref = preservedRef;
302
+ }
303
+ catch {
304
+ try {
305
+ execFileSync("git", ["rev-parse", "--verify", taskRef], { cwd, stdio: "ignore" });
306
+ }
307
+ catch {
308
+ ref = preservedRef;
309
+ }
310
+ }
311
+ }
312
+ try {
313
+ const out = execFileSync("git", ["rev-list", "--count", `${base}..${ref}`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
314
+ return { ref, base, count: Number.parseInt(out.trim(), 10) || 0 };
315
+ }
316
+ catch {
317
+ return { ref, base, count: 0 };
318
+ }
319
+ }
221
320
  // The status is command OUTPUT, not a typed sibling result: the registered approve function returns
222
321
  // one primitive string and the dispatcher prints those bytes unchanged. A compact sentinel followed
223
322
  // by JSON makes the contract unambiguous to machines without widening the shared command result type.
@@ -247,11 +346,12 @@ function disposition(cwd, runId, token, message, contended) {
247
346
  };
248
347
  return `${out}\nTICKMARKR_APPROVAL ${JSON.stringify(record)}`;
249
348
  }
250
- const USAGE = "usage: tickmarkr approve <run-id> <task-id> [--waive|--uphold|--recheck] [--review-rounds <positive-integer>] [--by <name>] [--reason <text>]";
349
+ const USAGE = "usage: tickmarkr approve <run-id> <task-id> [--files <glob,…>] [--waive|--uphold|--recheck] [--review-rounds <positive-integer>] [--by <name>] [--reason <text>]";
251
350
  // hand-parsed argv — no CLI framework (house style). Positionals are runId then taskId; decision,
252
351
  // ceiling, actor and reason are flags. Throws usage on missing positionals (mirrors resume.ts/unlock.ts).
253
352
  function parseArgs(argv) {
254
353
  const positionals = [];
354
+ let files;
255
355
  let by;
256
356
  let reason;
257
357
  let waive = false;
@@ -260,7 +360,16 @@ function parseArgs(argv) {
260
360
  let reviewRoundCeiling;
261
361
  for (let i = 0; i < argv.length; i++) {
262
362
  const a = argv[i];
263
- if (a === "--by") {
363
+ if (a === "--files") {
364
+ const value = argv[++i];
365
+ if (!value || value.startsWith("--"))
366
+ throw new Error("--files requires <glob,…>");
367
+ files = value.split(",").map((file) => file.trim());
368
+ if (files.some((file) => !file || file.startsWith("/") || file.startsWith("!") || file.split("/").includes(".."))) {
369
+ throw new Error("--files requires non-empty repository-relative globs");
370
+ }
371
+ }
372
+ else if (a === "--by") {
264
373
  by = argv[++i];
265
374
  if (!by)
266
375
  throw new Error(USAGE);
@@ -296,7 +405,7 @@ function parseArgs(argv) {
296
405
  if (!runId || !taskId) {
297
406
  throw new Error(USAGE);
298
407
  }
299
- return { runId, taskId, by: by ?? userInfo().username, reason, waive, uphold, recheck, reviewRoundCeiling };
408
+ return { runId, taskId, by: by ?? userInfo().username, reason, waive, uphold, recheck, reviewRoundCeiling, files };
300
409
  }
301
410
  function failedGateForNewestPark(events, taskId, lastHumanIndex) {
302
411
  for (let i = lastHumanIndex - 1; i >= 0; i -= 1) {
@@ -203,6 +203,11 @@ export declare function doctorProbePreflight(cwd?: string, cfg?: {
203
203
  worker: "interactive" | "print";
204
204
  workersPerTab: number;
205
205
  };
206
+ executionPolicy?: {
207
+ boundedInfrastructure: boolean;
208
+ repairSelection: boolean;
209
+ taskExecutionLimitMs: number;
210
+ } | undefined;
206
211
  setup?: string | undefined;
207
212
  cost?: {
208
213
  models?: Record<string, {
@@ -322,6 +327,11 @@ export declare function cachedDoctorDiagnostics(cwd?: string, adapters?: WorkerA
322
327
  worker: "interactive" | "print";
323
328
  workersPerTab: number;
324
329
  };
330
+ executionPolicy?: {
331
+ boundedInfrastructure: boolean;
332
+ repairSelection: boolean;
333
+ taskExecutionLimitMs: number;
334
+ } | undefined;
325
335
  setup?: string | undefined;
326
336
  cost?: {
327
337
  models?: Record<string, {
@@ -613,6 +613,46 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
613
613
  if (probeProgressTTY && probeOk + probeBad > 0) {
614
614
  process.stderr.write(`\r\x1b[2K probed ${probeOk + probeBad} channels · ${probeOk} ok${probeBad ? ` · ${probeBad} failed` : ""}\n`);
615
615
  }
616
+ const identityResolver = opts.resolveClaudeAliasIdentity
617
+ ?? (adapters.some((a) => a.id === claudeCode.id) ? resolveClaudeAliasIdentity : undefined);
618
+ const aliasDriftFindings = [];
619
+ // Single pre-write snapshot of the resolver's outcome per alias (including undefined/failure).
620
+ // Catalog rendering below reads only this map — never calls the resolver again — so a transient
621
+ // resolver that succeeds on a later call can never produce a result that stays display-only
622
+ // while doctor.json and discovery keep the earlier undefined/failed outcome.
623
+ const resolvedIdentitySnapshot = {};
624
+ if (identityResolver && health["claude-code"]?.installed) {
625
+ const configured = cfg.tiers["claude-code"]?.models ?? {};
626
+ for (const [alias, stampedIdentity] of Object.entries(CLAUDE_ALIAS_IDENTITY_STAMPS)) {
627
+ if (!(alias in configured))
628
+ continue;
629
+ let resolvedIdentity;
630
+ try {
631
+ resolvedIdentity = identityResolver(cwd, alias);
632
+ }
633
+ catch {
634
+ resolvedIdentitySnapshot[alias] = undefined;
635
+ continue; // advisory source failure is unknown, never a doctor failure
636
+ }
637
+ resolvedIdentitySnapshot[alias] = resolvedIdentity;
638
+ if (resolvedIdentity) {
639
+ if (!health["claude-code"].modelAuth) {
640
+ health["claude-code"].modelAuth = {};
641
+ }
642
+ const existing = health["claude-code"].modelAuth[alias] ?? {
643
+ authed: true,
644
+ probedAt: new Date().toISOString(),
645
+ };
646
+ health["claude-code"].modelAuth[alias] = {
647
+ ...existing,
648
+ identity: resolvedIdentity,
649
+ };
650
+ }
651
+ if (resolvedIdentity && resolvedIdentity !== stampedIdentity) {
652
+ aliasDriftFindings.push(`resolved-identity drift: claude-code:${alias} resolved to ${resolvedIdentity}, stamped identity ${stampedIdentity} — reclassify per benchmark policy (advisory — routing unchanged)`);
653
+ }
654
+ }
655
+ }
616
656
  writeDoctor(cwd, health);
617
657
  const rows = adapters.map((a) => {
618
658
  const h = health[a.id];
@@ -752,37 +792,12 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
752
792
  // v1.65 T3: hardcoded-flag drift — advisory warn rows only. Runs AFTER writeDoctor so the verdicts
753
793
  // can never leak into doctor.json, and discoverChannels/routing never read them.
754
794
  lintRows.push(...flagDriftWarnings(adapters, health).map(attentionRow));
755
- // OBS-145: resolved-identity drift is the same class of display-only doctor warning. Stamps live
756
- // beside the alias-owning adapter; the comparison runs only for configured floating aliases and
757
- // never enters health/doctor.json, config, channel discovery, learned profiles, or route().
758
- const identityResolver = opts.resolveClaudeAliasIdentity
759
- ?? (adapters.includes(claudeCode) ? resolveClaudeAliasIdentity : undefined);
760
- if (identityResolver && health["claude-code"]?.installed) {
761
- const configured = cfg.tiers["claude-code"]?.models ?? {};
762
- for (const [alias, stampedIdentity] of Object.entries(CLAUDE_ALIAS_IDENTITY_STAMPS)) {
763
- if (!(alias in configured))
764
- continue;
765
- let resolvedIdentity;
766
- try {
767
- resolvedIdentity = identityResolver(cwd, alias);
768
- }
769
- catch {
770
- continue; // advisory source failure is unknown, never a doctor failure
771
- }
772
- if (resolvedIdentity && resolvedIdentity !== stampedIdentity) {
773
- lintRows.push(attentionRow(`resolved-identity drift: claude-code:${alias} resolved to ${resolvedIdentity}, stamped identity ${stampedIdentity} — reclassify per benchmark policy (advisory — routing unchanged)`));
774
- }
775
- }
776
- }
795
+ // OBS-145: resolved-identity drift is advisory display warning.
796
+ lintRows.push(...aliasDriftFindings.map(attentionRow));
777
797
  const resolvedCatalogModel = (adapter, model) => {
778
- if (adapter !== "claude-code" || !identityResolver || !(model in CLAUDE_ALIAS_IDENTITY_STAMPS))
798
+ if (adapter !== "claude-code" || !(model in CLAUDE_ALIAS_IDENTITY_STAMPS))
779
799
  return undefined;
780
- try {
781
- return identityResolver(cwd, model);
782
- }
783
- catch {
784
- return undefined;
785
- }
800
+ return resolvedIdentitySnapshot[model];
786
801
  };
787
802
  // MODEL-05/06: print-only drift fragment; advisory, whole-line-commented additions, tickmarkr NEVER applies it.
788
803
  // TTY gets a one-line summary + the fragment as a file (the full dump drowned everything else,