tickmarkr 2.6.0 → 2.6.2

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 (103) hide show
  1. package/README.md +14 -3
  2. package/dist/adapters/catalog-remote.js +89 -47
  3. package/dist/adapters/claude-code.js +9 -6
  4. package/dist/adapters/codex.js +7 -4
  5. package/dist/adapters/prompt.d.ts +3 -1
  6. package/dist/adapters/prompt.js +21 -3
  7. package/dist/adapters/registry.js +3 -3
  8. package/dist/adapters/types.d.ts +13 -4
  9. package/dist/adapters/types.js +16 -0
  10. package/dist/cli/commands/approve.d.ts +5 -1
  11. package/dist/cli/commands/approve.js +66 -23
  12. package/dist/cli/commands/compile.js +13 -3
  13. package/dist/cli/commands/doctor.d.ts +2 -0
  14. package/dist/cli/commands/doctor.js +11 -3
  15. package/dist/cli/commands/fleet.js +71 -8
  16. package/dist/cli/commands/plan.js +7 -3
  17. package/dist/cli/commands/report.js +18 -2
  18. package/dist/cli/commands/resume.js +4 -2
  19. package/dist/cli/commands/status.js +37 -22
  20. package/dist/cli/help.d.ts +4 -0
  21. package/dist/cli/help.js +11 -2
  22. package/dist/config/config.d.ts +20 -0
  23. package/dist/config/config.js +47 -8
  24. package/dist/config/fleet-overlay.d.ts +1 -0
  25. package/dist/config/fleet-overlay.js +56 -0
  26. package/dist/drivers/orca.d.ts +26 -1
  27. package/dist/drivers/orca.js +199 -61
  28. package/dist/eval/canary.d.ts +2 -1
  29. package/dist/eval/canary.js +2 -2
  30. package/dist/eval/dispatch.js +1 -0
  31. package/dist/gates/acceptance.d.ts +2 -1
  32. package/dist/gates/acceptance.js +7 -2
  33. package/dist/gates/baseline.d.ts +12 -1
  34. package/dist/gates/baseline.js +11 -4
  35. package/dist/gates/cache.d.ts +3 -1
  36. package/dist/gates/cache.js +10 -3
  37. package/dist/gates/llm.d.ts +5 -4
  38. package/dist/gates/llm.js +17 -14
  39. package/dist/gates/review.d.ts +8 -0
  40. package/dist/gates/review.js +47 -15
  41. package/dist/gates/run-gates.d.ts +6 -1
  42. package/dist/gates/run-gates.js +34 -17
  43. package/dist/gates/test-manifest.d.ts +14 -0
  44. package/dist/gates/test-manifest.js +33 -6
  45. package/dist/graph/graph.d.ts +6 -2
  46. package/dist/graph/graph.js +15 -4
  47. package/dist/graph/schema.d.ts +2 -0
  48. package/dist/graph/schema.js +2 -0
  49. package/dist/plan/scope.js +2 -2
  50. package/dist/route/preference.d.ts +20 -2
  51. package/dist/route/preference.js +48 -13
  52. package/dist/route/role-pick.d.ts +16 -0
  53. package/dist/route/role-pick.js +15 -0
  54. package/dist/route/router.d.ts +14 -0
  55. package/dist/route/router.js +39 -16
  56. package/dist/run/consult.d.ts +13 -1
  57. package/dist/run/consult.js +19 -14
  58. package/dist/run/daemon.d.ts +46 -2
  59. package/dist/run/daemon.js +964 -190
  60. package/dist/run/git.d.ts +44 -1
  61. package/dist/run/git.js +103 -5
  62. package/dist/run/host-health.d.ts +20 -0
  63. package/dist/run/host-health.js +64 -0
  64. package/dist/run/journal.d.ts +126 -3
  65. package/dist/run/journal.js +418 -36
  66. package/dist/run/merge.d.ts +3 -1
  67. package/dist/run/merge.js +3 -2
  68. package/dist/run/operator-state.d.ts +24 -2
  69. package/dist/run/operator-state.js +41 -5
  70. package/dist/run/operator-summary.d.ts +3 -0
  71. package/dist/run/operator-summary.js +3 -1
  72. package/dist/run/protocol.d.ts +31 -1
  73. package/dist/run/protocol.js +3 -1
  74. package/dist/run/stall.d.ts +38 -2
  75. package/dist/run/stall.js +276 -6
  76. package/dist/run/supervision.d.ts +7 -1
  77. package/dist/run/supervision.js +5 -2
  78. package/dist/tui/cockpit/board.d.ts +1 -1
  79. package/dist/tui/cockpit/board.js +30 -22
  80. package/dist/tui/cockpit/decision-actions.d.ts +8 -5
  81. package/dist/tui/cockpit/decision-actions.js +55 -32
  82. package/dist/tui/cockpit/derive.d.ts +2 -0
  83. package/dist/tui/cockpit/derive.js +17 -2
  84. package/dist/tui/cockpit/live-runtime.d.ts +10 -0
  85. package/dist/tui/cockpit/live-runtime.js +50 -3
  86. package/dist/tui/cockpit/live-store.d.ts +1 -0
  87. package/dist/tui/cockpit/live-store.js +31 -8
  88. package/dist/tui/cockpit/run-cockpit.d.ts +3 -0
  89. package/dist/tui/cockpit/run-cockpit.js +28 -2
  90. package/dist/tui/cockpit/run-view.d.ts +11 -7
  91. package/dist/tui/cockpit/run-view.js +69 -15
  92. package/dist/tui/cockpit/setup-cockpit.d.ts +4 -0
  93. package/dist/tui/cockpit/setup-cockpit.js +6 -3
  94. package/dist/tui/ink/fleet-app.d.ts +15 -3
  95. package/dist/tui/ink/fleet-app.js +91 -22
  96. package/package.json +2 -1
  97. package/schema/config.schema.json +818 -0
  98. package/skills/tickmarkr-loop/SKILL.md +8 -2
  99. package/skills/tickmarkr-overseer/SKILL.md +85 -6
  100. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +88 -0
  101. package/skills/tickmarkr-overseer/scripts/context-statusline.sh +81 -0
  102. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +36 -34
  103. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +6 -4
@@ -5,7 +5,7 @@ import { loadConfig } from "../../config/config.js";
5
5
  import { integrationBranch } from "../../run/merge.js";
6
6
  import { separabilityErrors, surfaceErrors, taskBudgetErrors } from "../../compile/collateral.js";
7
7
  import { GATE_NAMES } from "../../graph/schema.js";
8
- import { applyScopeAmendments, engagementComparable, engagementReleased, ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, RECHECK_RELEASE, REVIEW_UPHELD_RELEASE } from "../../run/journal.js";
8
+ import { applyScopeAmendments, bindingToken, engagementComparable, engagementReleased, failedGateBeforePark, parseBindingToken, physicalLine, ATTEMPT_CAP_RELEASE, GATE_SATISFIED_RELEASE, Journal, RECHECK_RELEASE, REVIEW_UPHELD_RELEASE } from "../../run/journal.js";
9
9
  export const APPROVAL_DISPOSITIONS = ["dispatch", "waive-gate", "re-dispatch", "fund-fixed-attempt", "fresh-budget"];
10
10
  /**
11
11
  * What each disposition's release actually buys — the clause every operator-facing sentence about
@@ -56,9 +56,9 @@ sourceIndexes) {
56
56
  const kind = typeof e.data.kind === "string" ? e.data.kind : undefined;
57
57
  const reason = typeof e.data.reason === "string" ? e.data.reason : undefined;
58
58
  return {
59
- index: i, line: (sourceIndexes?.[i] ?? i) + 1, ts: typeof e.ts === "string" ? e.ts : undefined, kind, reason,
59
+ index: i, line: sourceIndexes?.[i] === undefined ? physicalLine(events, i) : sourceIndexes[i] + 1, ts: typeof e.ts === "string" ? e.ts : undefined, kind, reason,
60
60
  approveCommand: typeof e.data.approveCommand === "string" ? e.data.approveCommand : undefined,
61
- failedGate: failedGateForNewestPark(events, taskId, i), tombstone: isTombstonePark(kind, reason),
61
+ failedGate: failedGateBeforePark(events, taskId, i), tombstone: isTombstonePark(kind, reason),
62
62
  };
63
63
  }
64
64
  return undefined;
@@ -71,6 +71,17 @@ export function readJournalEvents(journal) {
71
71
  sourceIndexes: tracked.map((row) => row.sourceIndex),
72
72
  };
73
73
  }
74
+ /** OBS-1178: the token a park is decided by — what status, park notices and the cockpit print. */
75
+ export const parkToken = (park) => park.ts === undefined ? undefined : bindingToken({ line: park.line, ts: park.ts });
76
+ /** OBS-1178: the newest task-failed row for a task — the failure a failed-task recheck binds to. */
77
+ export function newestFailure(events, taskId, sourceIndexes) {
78
+ for (let i = events.length - 1; i >= 0; i--) {
79
+ const e = events[i];
80
+ if (e.event === "task-failed" && e.taskId === taskId && typeof e.ts === "string")
81
+ return { line: sourceIndexes?.[i] === undefined ? physicalLine(events, i) : sourceIndexes[i] + 1, ts: e.ts };
82
+ }
83
+ return undefined;
84
+ }
74
85
  /**
75
86
  * FINAL §3.3's decision menu as data: human-gate/attempt-cap/other non-gate parks → approve; infra →
76
87
  * approve or recheck; review gate-fail → waive/uphold/recheck; other gate-fail → waive/recheck; a
@@ -137,13 +148,17 @@ export function approvalEnactment(token, run) {
137
148
  }
138
149
  /** The production command registered in COMMANDS; its returned bytes are what the CLI prints. */
139
150
  export async function approve(argv, cwd = process.cwd()) {
140
- const { runId, taskId, by, reason, waive, uphold, recheck, reviewRoundCeiling, files } = parseArgs(argv);
151
+ const { runId, taskId, by, reason, waive, uphold, recheck, reviewRoundCeiling, files, park: namedPark, gate: namedGate } = parseArgs(argv);
141
152
  const decisions = [waive, uphold, recheck].filter(Boolean).length;
142
153
  if (decisions > 1)
143
154
  throw new Error("--waive, --uphold and --recheck are different decisions — pass one");
155
+ // OBS-1178: the decision binds at COMMAND time — to the named --park, else to the park (or, for a
156
+ // failed task's recheck, the failure) open now — and is revalidated under serialization below. A
157
+ // decision queued behind another approval can therefore never land on a park that opened meanwhile.
158
+ // Journal.open throws `no journal for <runId> at <dir>` on an unknown run — that IS the refusal.
159
+ const bound = namedPark ?? openDecision(Journal.open(cwd, runId), taskId);
144
160
  const serialization = await acquireApprovalSerialization(cwd, runId);
145
161
  try {
146
- // Journal.open throws `no journal for <runId> at <dir>` on an unknown run — that IS the refusal.
147
162
  const journal = Journal.open(cwd, runId);
148
163
  const status = journal.replayStatuses().get(taskId);
149
164
  if (status === undefined) {
@@ -153,13 +168,17 @@ export async function approve(argv, cwd = process.cwd()) {
153
168
  // integration base owns verified-able work; --recheck re-gates that tree with no worker. Any other
154
169
  // verb on a failed task, or a recheck over nothing landed, is refused naming why.
155
170
  if (status === "failed" && recheck) {
171
+ if (namedGate !== undefined)
172
+ throw new Error(`--gate names a parked failed gate; task ${taskId} is failed — bind its recheck with --park <failure token>`);
173
+ const { events: failedEvents, sourceIndexes: failedIndexes } = readJournalEvents(journal);
174
+ const failure = requireBound(runId, taskId, bound, newestFailure(failedEvents, taskId, failedIndexes), "failure");
156
175
  const ahead = failedTaskCommitsAhead(cwd, journal, runId, taskId);
157
176
  if (ahead.count === 0) {
158
177
  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`);
159
178
  }
160
179
  journal.append("task-approved", taskId, {
161
180
  by, ...(reason ? { reason } : {}), via: "cli", release: RECHECK_RELEASE,
162
- recheckedRef: ahead.ref, commitsAhead: ahead.count,
181
+ recheckedRef: ahead.ref, commitsAhead: ahead.count, failure,
163
182
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
164
183
  });
165
184
  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);
@@ -178,6 +197,10 @@ export async function approve(argv, cwd = process.cwd()) {
178
197
  const infraPark = park?.kind === "infra" || park?.kind === "diff-cap";
179
198
  const authoringRunnerPark = park?.kind === "authoring" && isToolGate(park.failedGate);
180
199
  const failedGate = gateFailPark || authoringRunnerPark ? park?.failedGate : undefined;
200
+ const parkBinding = requireBound(runId, taskId, bound, park?.ts === undefined ? undefined : { line: park.line, ts: park.ts }, "park");
201
+ if (namedGate !== undefined && namedGate !== park?.failedGate) {
202
+ throw new Error(`refusing mismatched gate for ${taskId}: --gate ${namedGate} but park ${bindingToken(parkBinding)} failed ${park?.failedGate ?? "no gate"} — nothing appended`);
203
+ }
181
204
  if ((park?.kind === "scope-request" && !decisions) || files !== undefined) {
182
205
  if (park?.kind !== "scope-request")
183
206
  throw new Error("--files requires a scope-request park");
@@ -209,7 +232,7 @@ export async function approve(argv, cwd = process.cwd()) {
209
232
  + `remedy: close the run, split the task in its spec, recompile, then \`tickmarkr resume ${runId} --graph-changed\``);
210
233
  }
211
234
  journal.append("task-approved", taskId, {
212
- by, ...(reason ? { reason } : {}), via: "cli", release: "scope-request",
235
+ by, ...(reason ? { reason } : {}), via: "cli", release: "scope-request", park: parkBinding,
213
236
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }), // OBS-1083
214
237
  amendment: { from, to: graphDefinitionHash(amended), beforeFiles: task.files, files: amendedFiles, parkLine: park.line, definition: taskDefinitionFingerprint(task) },
215
238
  });
@@ -234,6 +257,7 @@ export async function approve(argv, cwd = process.cwd()) {
234
257
  via: "cli",
235
258
  release: REVIEW_UPHELD_RELEASE,
236
259
  gate: "review",
260
+ park: parkBinding,
237
261
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
238
262
  });
239
263
  return disposition(cwd, runId, "fund-fixed-attempt", `upheld the reviewer for ${taskId} in ${runId} — by ${by}`, serialization.contended);
@@ -247,6 +271,7 @@ export async function approve(argv, cwd = process.cwd()) {
247
271
  ...(reason ? { reason } : {}),
248
272
  via: "cli",
249
273
  release: RECHECK_RELEASE,
274
+ park: parkBinding,
250
275
  // OBS-1084: failedGate names the runner report for every recheck. Authoring rechecks additionally
251
276
  // carry gate so their durable release row identifies the red tool gate that made recheck admissible.
252
277
  ...(failedGate ? { failedGate } : {}),
@@ -265,6 +290,7 @@ export async function approve(argv, cwd = process.cwd()) {
265
290
  via: "cli",
266
291
  release: GATE_SATISFIED_RELEASE,
267
292
  gate: failedGate,
293
+ park: parkBinding,
268
294
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
269
295
  });
270
296
  return disposition(cwd, runId, "waive-gate", `waived failed gate ${failedGate} for ${taskId} in ${runId} — by ${by}`, serialization.contended);
@@ -292,6 +318,7 @@ export async function approve(argv, cwd = process.cwd()) {
292
318
  via: "cli",
293
319
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
294
320
  ...(capPark ? { release: ATTEMPT_CAP_RELEASE } : {}),
321
+ park: parkBinding,
295
322
  });
296
323
  const token = capPark ? "fresh-budget" : "dispatch";
297
324
  return disposition(cwd, runId, token, `approved ${taskId} in ${runId} — by ${by}`, serialization.contended);
@@ -300,6 +327,23 @@ export async function approve(argv, cwd = process.cwd()) {
300
327
  serialization.release();
301
328
  }
302
329
  }
330
+ /** The row a decision on this task answers right now: its newest failure when failed, else its newest park. */
331
+ function openDecision(journal, taskId) {
332
+ const { events, sourceIndexes } = readJournalEvents(journal);
333
+ if (journal.replayStatuses().get(taskId) === "failed")
334
+ return newestFailure(events, taskId, sourceIndexes);
335
+ const park = newestPark(events, taskId, sourceIndexes);
336
+ return park?.ts === undefined ? undefined : { line: park.line, ts: park.ts };
337
+ }
338
+ /** OBS-1178: under serialization, the decision's command-time binding must still name the open row. */
339
+ function requireBound(runId, taskId, bound, open, what) {
340
+ if (open === undefined)
341
+ throw new Error(`task ${taskId} has no ${what} row with a timestamp to bind this decision to — refusing an unbound release`);
342
+ if (bound === undefined || bound.line !== open.line || bound.ts !== open.ts) {
343
+ throw new Error(`refusing stale decision for ${taskId}: bound to ${bound === undefined ? "no open row" : `${what} ${bindingToken(bound)}`} but the open ${what} is ${bindingToken(open)} — read \`tickmarkr status ${runId}\` and decide the open ${what}; nothing appended`);
344
+ }
345
+ return open;
346
+ }
303
347
  /**
304
348
  * OBS-1022: what a failed task has landed. A preserved snapshot is authoritative only when it contains
305
349
  * the current task-branch tip; an older preservation can remain in the journal after a later retry has
@@ -365,12 +409,14 @@ function disposition(cwd, runId, token, message, contended) {
365
409
  };
366
410
  return `${out}\nTICKMARKR_APPROVAL ${JSON.stringify(record)}`;
367
411
  }
368
- const USAGE = "usage: tickmarkr approve <run-id> <task-id> [--files <glob,…>] [--waive|--uphold|--recheck] [--review-rounds <positive-integer>] [--by <name>] [--reason <text>]";
412
+ const USAGE = "usage: tickmarkr approve <run-id> <task-id> [--park <line>@<ts>] [--gate <gate>] [--files <glob,…>] [--waive|--uphold|--recheck] [--review-rounds <positive-integer>] [--by <name>] [--reason <text>]";
369
413
  // hand-parsed argv — no CLI framework (house style). Positionals are runId then taskId; decision,
370
414
  // ceiling, actor and reason are flags. Throws usage on missing positionals (mirrors resume.ts/unlock.ts).
371
415
  function parseArgs(argv) {
372
416
  const positionals = [];
373
417
  let files;
418
+ let park;
419
+ let gate;
374
420
  let by;
375
421
  let reason;
376
422
  let waive = false;
@@ -388,6 +434,17 @@ function parseArgs(argv) {
388
434
  throw new Error("--files requires non-empty repository-relative globs");
389
435
  }
390
436
  }
437
+ else if (a === "--park") {
438
+ const value = argv[++i];
439
+ park = value === undefined ? undefined : parseBindingToken(value);
440
+ if (!park)
441
+ throw new Error("--park requires the <line>@<ts> token `tickmarkr status` prints for the park");
442
+ }
443
+ else if (a === "--gate") {
444
+ gate = argv[++i];
445
+ if (!gate || !GATE_NAMES.includes(gate))
446
+ throw new Error(`--gate requires one of ${GATE_NAMES.join(", ")}`);
447
+ }
391
448
  else if (a === "--by") {
392
449
  by = argv[++i];
393
450
  if (!by)
@@ -424,19 +481,5 @@ function parseArgs(argv) {
424
481
  if (!runId || !taskId) {
425
482
  throw new Error(USAGE);
426
483
  }
427
- return { runId, taskId, by: by ?? userInfo().username, reason, waive, uphold, recheck, reviewRoundCeiling, files };
428
- }
429
- function failedGateForNewestPark(events, taskId, lastHumanIndex) {
430
- for (let i = lastHumanIndex - 1; i >= 0; i -= 1) {
431
- const event = events[i];
432
- if (event.taskId !== taskId)
433
- continue;
434
- if (event.event === "task-approved" || event.event === "task-human" || event.event === "task-dispatch")
435
- return undefined;
436
- if (event.event === "gate-result" && event.data.pass === false
437
- && typeof event.data.gate === "string" && GATE_NAMES.includes(event.data.gate)) {
438
- return event.data.gate;
439
- }
440
- }
441
- return undefined;
484
+ return { runId, taskId, by: by ?? userInfo().username, reason, waive, uphold, recheck, reviewRoundCeiling, files, park, gate };
442
485
  }
@@ -2,8 +2,9 @@ import { isAbsolute, join } from "node:path";
2
2
  import { parseArgs } from "node:util";
3
3
  import { collateralLints, sourceScopeFindings, sourceScopeLints, } from "../../compile/collateral.js";
4
4
  import { CompileError } from "../../compile/common.js";
5
+ import { readDoctor } from "../../adapters/registry.js";
5
6
  import { loadConfig } from "../../config/config.js";
6
- import { denyPreferCollisionLine, denyPreferCollisions } from "../../route/preference.js";
7
+ import { deadPoolEntries, deadPoolEntryLine, denyPreferCollisionLine, denyPreferCollisions } from "../../route/preference.js";
7
8
  import { compileSource } from "../../compile/index.js";
8
9
  import { retiredLiteralErrors } from "../../compile/retired-literals.js";
9
10
  import { clearCompileRefusal, saveCompileRefusal, saveGraph, stateDirName } from "../../graph/graph.js";
@@ -88,11 +89,20 @@ export async function compile(argv, cwd = process.cwd(), harnessFrom = process.a
88
89
  // that uses it; plan already said so, compile now refuses the seal before any state write so a dry
89
90
  // run reaches the same refusal. Pool only: a partial deny leaves live members, and pin/prefer
90
91
  // collisions on the default map stay doctor's and resume's business. Scoped to the graph's shapes.
91
- const deadPools = denyPreferCollisions(loadConfig(cwd), g.tasks.map((task) => task.shape))
92
+ // OBS-1143: aliases are judged by doctor's CACHED identity — compile never spends a probe.
93
+ const routingCfg = loadConfig(cwd);
94
+ const shapes = g.tasks.map((task) => task.shape);
95
+ const health = readDoctor(cwd);
96
+ const deadPools = denyPreferCollisions(routingCfg, shapes, health)
92
97
  .filter((collision) => collision.kind === "pool");
93
98
  if (deadPools.length > 0) {
94
99
  throw new CompileError(`${src} routes a task shape to a fully denied pool:\n${deadPools.map((collision) => ` - ${denyPreferCollisionLine(collision)}`).join("\n")}${diagnostics}`);
95
100
  }
101
+ // OBS-1144: a partial deny compiles, as the router routes it — but each skipped entry is named.
102
+ const skippedPoolEntries = deadPoolEntries(routingCfg, shapes, health).map(deadPoolEntryLine);
103
+ const poolNotes = skippedPoolEntries.length
104
+ ? `\npool notes:\n${skippedPoolEntries.map((line) => ` ! ${line}`).join("\n")}`
105
+ : "";
96
106
  // One bounded read supplies both cross-run surfaces: unresolved findings below and merge facts for
97
107
  // the ancestry check. Neither fact mutates the compiled graph; status and every readiness predicate
98
108
  // remain the source compiler's answer.
@@ -121,7 +131,7 @@ export async function compile(argv, cwd = process.cwd(), harnessFrom = process.a
121
131
  const mergeHistory = mergedPending.length
122
132
  ? `\nmerge history:\n${mergedPending.map((line) => ` ${line}`).join("\n")}`
123
133
  : "";
124
- return `${harnessLine(resolveHarness(harnessFrom))}\n${summary}${diagnostics}${priorFindings}${mergeHistory}`;
134
+ return `${harnessLine(resolveHarness(harnessFrom))}\n${summary}${diagnostics}${poolNotes}${priorFindings}${mergeHistory}`;
125
135
  }
126
136
  catch (error) {
127
137
  // A dry run is a pure validation query. A real authoring refusal records the negative result
@@ -162,6 +162,7 @@ export declare function doctorProbePreflight(cwd?: string, cfg?: {
162
162
  modelOverrides?: Record<string, {
163
163
  vendor?: string | undefined;
164
164
  channel?: "sub" | "api" | undefined;
165
+ effort?: "low" | "medium" | "high" | undefined;
165
166
  }> | undefined;
166
167
  windows?: Record<string, number> | undefined;
167
168
  }>;
@@ -286,6 +287,7 @@ export declare function cachedDoctorDiagnostics(cwd?: string, adapters?: WorkerA
286
287
  modelOverrides?: Record<string, {
287
288
  vendor?: string | undefined;
288
289
  channel?: "sub" | "api" | undefined;
290
+ effort?: "low" | "medium" | "high" | undefined;
289
291
  }> | undefined;
290
292
  windows?: Record<string, number> | undefined;
291
293
  }>;
@@ -14,10 +14,11 @@ import { loadConfig, overlayPreferShapes } from "../../config/config.js";
14
14
  import { HerdrDriver } from "../../drivers/herdr.js";
15
15
  import { ORCA_FIXTURE_VERSION, parseEnvelope, resolveOrcaCliBinary } from "../../drivers/orca.js";
16
16
  import { kimi, probeKimiDoctorTurn } from "../../adapters/kimi.js";
17
- import { denyPreferCollisionLine, denyPreferCollisions, disallowedBy, excludedChannels, exclusionLine, preferRanks } from "../../route/preference.js";
17
+ import { denyPreferCollisionLine, denyPreferCollisions, disallowedBy, excludedChannels, exclusionLine, observedSeat, preferRanks } from "../../route/preference.js";
18
18
  import { CATALOG_REFRESH_TIMEOUT_MS, LIVEBENCH_TABLE_DATE, formatCatalogRefreshLegs, readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
19
19
  import { sh } from "../../run/git.js";
20
20
  import { auditNamedTestOracles, listVitestTests } from "../../gates/acceptance.js";
21
+ import { readReviewNoVerdictHistory, reviewNoVerdictRows } from "../../run/journal.js";
21
22
  /** Where a newer `table_<date>.csv` is discovered — the deployed site builds filenames by
22
23
  * concatenation and publishes no index, so the release listing is the only enumerable surface. */
23
24
  export const LIVEBENCH_RELEASES_URL = "https://api.github.com/repos/LiveBench/livebench.github.io/contents/public";
@@ -670,6 +671,13 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
670
671
  rows.push(alignedStatusRow("warn", demotion.reviewer, `${demotion.count} review seat${demotion.count === 1 ? "" : "s"} demoted · cause ${causes}`));
671
672
  }
672
673
  }
674
+ // OBS-1052(3): measured, never inferred — an unreadable journal renders unknown, not a clean zero.
675
+ const noVerdictRows = reviewNoVerdictRows(readReviewNoVerdictHistory(cwd));
676
+ if (noVerdictRows.length) {
677
+ rows.push(legend("review no-verdict history:"));
678
+ for (const row of noVerdictRows)
679
+ rows.push(alignedStatusRow(row.verdict, row.channel, row.value));
680
+ }
673
681
  if (existsSync(graphPath(cwd))) {
674
682
  try {
675
683
  const graph = loadGraph(cwd);
@@ -780,7 +788,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
780
788
  const excluded = excludedChannels(cfg, adapters, health);
781
789
  if (excluded.length)
782
790
  lintRows.push(attentionRow(exclusionLine(excluded)));
783
- lintRows.push(...denyPreferCollisions(cfg).map((c) => attentionRow(denyPreferCollisionLine(c))));
791
+ lintRows.push(...denyPreferCollisions(cfg, undefined, health).map((c) => attentionRow(denyPreferCollisionLine(c))));
784
792
  const aliasExcluded = modelAliasExclusions(cfg, adapters, health);
785
793
  if (aliasExcluded.length)
786
794
  lintRows.push(attentionRow(modelAliasLine(aliasExcluded)));
@@ -857,7 +865,7 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
857
865
  : v.authed
858
866
  ? `${ok("authed")}${probed}`
859
867
  : `${fail("unauthed:")} ${trunc(v.reason ?? "probe failed", 40)} (${dateOf(v.probedAt)})`;
860
- const d = disallowedBy({ adapter: a.id, model: m }, cfg.routing);
868
+ const d = disallowedBy(observedSeat(health, a.id, m), cfg.routing);
861
869
  const denied = d?.by === "deny" ? d.entry : "—";
862
870
  const pref = preferRanks({ adapter: a.id, model: m }, cfg).map((p) => `${p.shape}#${p.rank}`).join(",") || "—";
863
871
  const window = declaredModelWindow(cfg, a.id, m);
@@ -1,19 +1,20 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { parseArgs } from "node:util";
4
- import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, modelAuthExclusions } from "../../adapters/registry.js";
4
+ import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, modelAuthExclusions, PREFERENCE_ROLES } from "../../adapters/registry.js";
5
5
  import { catalogModelAdvisory, catalogTierRanking, declaredModelWindow, fleetUnclassifiedModels } from "../../adapters/model-lints.js";
6
6
  import { CATALOG_REFRESH_TIMEOUT_MS, formatCatalogRefreshLegs, readCachedCatalog, refreshCatalogCommand } from "../../adapters/catalog-remote.js";
7
7
  import { CLAUDE_ALIAS_IDENTITY_STAMPS, readClaudeAliasIdentity } from "../../adapters/claude-code.js";
8
- import { fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, DENY_SCOPES, initialDenyPropOf, loadConfigWithMode, overlayBytesLoadError, readOverlayFile, stagedDenyKeyOf, renderFleetOverlayWrite, repoOverlayPath, ROUTING_MODES, TIER_RANK, unifiedYamlDiff, } from "../../config/config.js";
8
+ import { fleetEditableFromConfig, fleetEditableEquals, formatFleetPrint, globalConfigDir, DENY_SCOPES, initialDenyPropOf, loadConfigWithMode, lowerLayerModelOverrides, overlayBytesLoadError, readOverlayFile, stagedDenyKeyOf, renderFleetOverlayWrite, repoOverlayPath, ROUTING_MODES, TIER_RANK, unifiedYamlDiff, } from "../../config/config.js";
9
9
  import { exclusionReason, projectFleetWhy, renderFleetWhy } from "../../config/fleet-why.js";
10
10
  import { SHAPES, TIERS } from "../../graph/schema.js";
11
11
  import { doctor } from "./doctor.js";
12
12
  import { candidateRow, costSignal, shapeCandidates } from "./fleet-picker.js";
13
13
  import { route } from "../../route/router.js";
14
- import { denyPreferCollisionLine, denyPreferCollisions, disallowedBy, entryMatchesChannel, exclusionCollector } from "../../route/preference.js";
14
+ import { pickRole } from "../../route/role-pick.js";
15
+ import { deadPoolEntries, deadPoolEntryLine, denyPreferCollisionLine, denyPreferCollisions, disallowedBy, entryMatchesChannel, exclusionCollector } from "../../route/preference.js";
15
16
  import { resolveRunMode } from "../../run/daemon.js";
16
- import { loadRoutingProfile } from "../../run/journal.js";
17
+ import { loadRoutingProfile, readReviewNoVerdictHistory, reviewNoVerdictRows } from "../../run/journal.js";
17
18
  const initialFetch = globalThis.fetch;
18
19
  const NON_TTY_MSG = "tickmarkr fleet: interactive fleet editor requires a TTY — use `tickmarkr fleet --print` for non-interactive output";
19
20
  const QUIT = "fleet: quit without writing";
@@ -85,6 +86,8 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
85
86
  const { values } = parseArgs({
86
87
  args: argv,
87
88
  options: {
89
+ pick: { type: "string" },
90
+ "exclude-vendor": { type: "string", multiple: true },
88
91
  print: { type: "boolean" },
89
92
  why: { type: "boolean" },
90
93
  "global-dir": { type: "string" },
@@ -92,6 +95,28 @@ export async function fleet(argv, cwd = process.cwd(), adapters = allAdapters(),
92
95
  },
93
96
  });
94
97
  const globalDir = values["global-dir"] ?? globalConfigDir();
98
+ if (values.pick !== undefined) {
99
+ const role = PREFERENCE_ROLES.find((role) => role === values.pick);
100
+ const refuse = (reason) => ({ out: `tickmarkr fleet --pick ${values.pick}: ${reason}`, code: 1 });
101
+ if (!role)
102
+ return refuse("unsupported-role — expected worker, judge, review or consult; no role prefer resolved");
103
+ if (values.print || values.why || values.fresh)
104
+ return refuse("incompatible options — --pick cannot use --print, --why or --fresh");
105
+ const { cfg } = resolveRunMode(cwd, { globalDir });
106
+ const { reuse, health } = initDoctorReuse(cwd, false);
107
+ const selection = pickRole(role, cfg, adapters, reuse && health ? health : {}, {
108
+ excludeVendors: new Set(values["exclude-vendor"] ?? []),
109
+ });
110
+ if (!selection.ok) {
111
+ return refuse(selection.reason === "missing-prefer" ? `${role}.prefer: missing-prefer`
112
+ : !reuse || !health ? `${role}.prefer: probe data missing or stale — run tickmarkr doctor first`
113
+ : `${role}.prefer: ${selection.reason}`);
114
+ }
115
+ const { adapter, model, vendor, channel } = selection.channel;
116
+ return JSON.stringify({ role, adapter, model, vendor, channel });
117
+ }
118
+ if (values["exclude-vendor"]?.length)
119
+ return { out: "tickmarkr fleet: --exclude-vendor requires --pick <role>", code: 1 };
95
120
  const print = values.print ?? false;
96
121
  const why = values.why ?? false;
97
122
  const input = io.input ?? process.stdin;
@@ -556,7 +581,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
556
581
  captions.push(`task hint: routing.map.${shape}.prefer ranks ${entry.prefer.join(", ")} — not manageable here, edit it in Shapes`);
557
582
  }
558
583
  // 8. deny∩prefer collision — a standing lint on this shape's declaration, never a fleet toggle.
559
- for (const collision of denyPreferCollisions(cfgPreview, [shape])) {
584
+ for (const collision of denyPreferCollisions(cfgPreview, [shape], health)) {
560
585
  captions.push(denyPreferCollisionLine(collision));
561
586
  }
562
587
  // 9. first-match provenance — the per-channel ledger below lists EVERY collector scope.
@@ -569,7 +594,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
569
594
  const channelLedger = (shape, map, cfgPreview, offered) => {
570
595
  const entry = map[shape];
571
596
  const floor = cfgPreview.routing.floors[shape];
572
- const collisions = denyPreferCollisions(cfgPreview, [shape]);
597
+ const collisions = denyPreferCollisions(cfgPreview, [shape], health);
573
598
  const discovered = new Set(previewChannels.map((c) => `${c.adapter}:${c.model}`));
574
599
  // T2 review (D-225): discovery already dropped a channel whose model probe failed — it is
575
600
  // rebuilt as a structured channel from its adapter's own declaration and walks the SAME
@@ -591,7 +616,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
591
616
  // never greys beside itself; its pin/pool/floor/prefer captions stay in excludedNote
592
617
  const ledgerChannels = [...previewChannels.map((c) => ({ ...c, authReason: undefined })), ...failedProbes, ...unauthedCliChannels]
593
618
  .filter((c) => !offered.has(`${c.adapter}:${c.model}`));
594
- return ledgerChannels.flatMap((c) => {
619
+ const lines = ledgerChannels.flatMap((c) => {
595
620
  const key = `${c.adapter}:${c.model}`;
596
621
  const reasons = exclusionCollector(c, cfgPreview.routing, "worker").map(exclusionReason);
597
622
  // OBS-1065: a greyed picker row carries its reach beside its reasons, like the models view
@@ -620,8 +645,19 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
620
645
  if (c.authReason)
621
646
  reasons.push(c.authReason);
622
647
  // labelled like the models view's row (adapter/model), so a ledger row never reads as a candidate
623
- return reasons.length ? [`${c.adapter}/${c.model} — reach: ${reach} — ${reasons.join("; ")}`] : [];
648
+ return reasons.length ? [{ key, line: `${c.adapter}/${c.model} — reach: ${reach} — ${reasons.join("; ")}` }] : [];
624
649
  });
650
+ // OBS-1145 add.1: every carried pool member the picker does not offer leads the ledger in chain
651
+ // order — one no reason above names still gets its own row — so the picker renders its ordinal
652
+ // and Space can drop it; a chain member with no row is hidden AND irremovable
653
+ const carried = entry?.pool?.channels.filter((key) => !offered.has(key)) ?? [];
654
+ const named = new Set(lines.map((row) => row.key));
655
+ const unnamed = carried.filter((key) => !named.has(key)).map((key) => ({
656
+ key,
657
+ line: `${key.replace(":", "/")} — ${discovered.has(key) ? `not offered for ${shape}` : "not served by an installed adapter"} — carried in routing.map.${shape}.pool`,
658
+ }));
659
+ const order = (key) => (carried.includes(key) ? carried.indexOf(key) : carried.length);
660
+ return [...lines, ...unnamed].sort((a, b) => order(a.key) - order(b.key)).map((row) => row.line);
625
661
  };
626
662
  const candidatesForShape = (shape, mode, map, deny) => {
627
663
  const preview = previewCfg(mode, map, deny);
@@ -652,6 +688,10 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
652
688
  review: cfg.review.prefer?.slice(),
653
689
  consult: cfg.consult.prefer?.slice(),
654
690
  };
691
+ // OBS-1052(3): shown on the Steering view beside review.prefer; the pickers above stay untouched.
692
+ const reviewAdvisories = reviewNoVerdictRows(readReviewNoVerdictHistory(cwd))
693
+ .filter((row) => row.verdict === "warn")
694
+ .map((row) => `review history: ${row.channel} — ${row.value}`);
655
695
  const steeringOptionsFor = (which, current) => {
656
696
  const discovered = which === "review" ? [...reviewAdapters, ...seats] : seats;
657
697
  return [...discovered, ...current.filter((entry) => !discovered.includes(entry))];
@@ -660,6 +700,9 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
660
700
  // discovered seats universe plus a keep-default row, and a write is staged only on a real change.
661
701
  const initialJudge = `${cfg.judge.adapter}:${cfg.judge.model}`;
662
702
  let pendingWrite = null;
703
+ // OBS-1182: the layers under the repo overlay, read raw, so clearing an effort masks an inherited
704
+ // one even when those layers only validate beside repo fields.
705
+ const lowerOverrides = lowerLayerModelOverrides({ globalDir });
663
706
  const reviewOverlay = (state) => {
664
707
  const staged = structuredClone(initial);
665
708
  // OBS-994/FL-1, OBS-1099 add.1: every schema-enumerated deny scope rides the same review/write funnel
@@ -667,6 +710,12 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
667
710
  staged[scope.key] = state[scope.key];
668
711
  staged.allowOut = state.allowOut ?? initial.allowOut;
669
712
  staged.map = state.map;
713
+ // OBS-1182: staged efforts replace the loaded ones; none staged anywhere ⇒ no key, as loaded
714
+ const efforts = Object.fromEntries(Object.entries(state.efforts ?? {}).filter(([, models]) => Object.keys(models).length));
715
+ if (Object.keys(efforts).length)
716
+ staged.efforts = efforts;
717
+ else
718
+ delete staged.efforts;
670
719
  const today = new Date().toISOString().slice(0, 10);
671
720
  for (const classification of state.classifications) {
672
721
  staged.tiers[classification.adapter] ??= {};
@@ -692,6 +741,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
692
741
  initial,
693
742
  edited: staged,
694
743
  universe,
744
+ lowerOverrides,
695
745
  ...(modeChanged ? { mode: state.selectedMode } : {}),
696
746
  ...(judgeChanged && judgeSeat ? { judge: judgeSeat } : {}),
697
747
  steering: { initial: initialSteering, edited: state.steering },
@@ -702,12 +752,23 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
702
752
  return { kind: "empty" };
703
753
  }
704
754
  pendingWrite = write;
755
+ // OBS-1144: the review names every dead pool entry the written config carries, with its reason and
756
+ // what still routes — before y. Unloadable bytes carry no notes: the reload guard refuses them anyway.
757
+ let poolNotes = [];
758
+ try {
759
+ const written = loadConfigWithMode(cwd, { globalDir, repoOverlayText: after }).cfg;
760
+ poolNotes = deadPoolEntries(written, undefined, health).map(deadPoolEntryLine);
761
+ }
762
+ catch {
763
+ poolNotes = [];
764
+ }
705
765
  return {
706
766
  kind: "diff",
707
767
  before,
708
768
  after,
709
769
  diff: unifiedYamlDiff(before, after, path),
710
770
  path,
771
+ ...(poolNotes.length ? { notes: poolNotes } : {}),
711
772
  };
712
773
  };
713
774
  const reloadGuard = io.reloadGuard
@@ -718,6 +779,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
718
779
  health,
719
780
  ...Object.fromEntries(DENY_SCOPES.map((scope) => [initialDenyPropOf(scope), editable[scope.key] ?? []])),
720
781
  initialAllowOut: editable.allowOut,
782
+ initialEfforts: editable.efforts,
721
783
  modelGroups,
722
784
  initialMode: rm.mode.mode,
723
785
  modeOptions: ROUTING_MODES.map((mode) => ({ id: mode, gloss: MODE_GLOSS[mode] })),
@@ -731,6 +793,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
731
793
  ],
732
794
  initialSteering,
733
795
  steeringOptionsFor,
796
+ reviewAdvisories,
734
797
  reviewOverlay,
735
798
  reloadGuard,
736
799
  stagedRouting,
@@ -7,10 +7,10 @@ import { GLYPHS, dim, rule, title, warn } from "../../brand.js";
7
7
  import { collateralLints, sourceScopeLints } from "../../compile/collateral.js";
8
8
  import { classifyContextPath } from "../../compile/native.js";
9
9
  import { DEFAULT_CONFIG, effectiveReviewPolicy, overlayPreferShapes, ROUTING_MODES, TIER_RANK } from "../../config/config.js";
10
- import { chainDepth, dispatchWaves, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
10
+ import { batteryPriority, chainDepth, dispatchWaves, graphDefinitionHash, loadGraph, stateDirName } from "../../graph/graph.js";
11
11
  import { filesGlob } from "../../graph/files-glob.js";
12
12
  import { renderAcceptanceItem } from "../../graph/schema.js";
13
- import { resolveRunMode } from "../../run/daemon.js";
13
+ import { pendingDaemonApprovalActions, resolveRunMode } from "../../run/daemon.js";
14
14
  import { disallowedBy, excludedChannels, exclusionLine, routingEntrySeatLines } from "../../route/preference.js";
15
15
  import { decayWeight, HALF_LIFE_RUNS, staffLedEvidence } from "../../route/profile.js";
16
16
  import { route, RoutingError } from "../../route/router.js";
@@ -372,7 +372,11 @@ export async function plan(argv, cwd = process.cwd(), adapters = allAdapters(),
372
372
  // OBS-1018: depth and wave come from the same functions the daemon admits by, so the plan shows the
373
373
  // critical path the run will actually follow at this concurrency.
374
374
  const depths = chainDepth(g);
375
- const waves = dispatchWaves(g, cfg.concurrency);
375
+ // OBS-1158: waves simulate the graph the daemon would admit on resume — the journal's replayed
376
+ // statuses (an approval releases its park to pending) and its pending recheck approvals ranked
377
+ // through the same seam the daemon admits by.
378
+ const projected = { ...g, tasks: g.tasks.map((t) => ({ ...t, status: taskStatuses.get(t.id) ?? t.status })) };
379
+ const waves = dispatchWaves(projected, cfg.concurrency, batteryPriority(pendingDaemonApprovalActions(matchingEvents ?? []).values()));
376
380
  const byId = new Map(g.tasks.map((t) => [t.id, t]));
377
381
  const chainLine = (t) => {
378
382
  const wave = waves.get(t.id);
@@ -11,7 +11,7 @@ import { estimateCosts } from "../../report/cost.js";
11
11
  import { assignmentChannel, buildOperatorRecord, formatChannelMoney, formatChannelTokens, formatTokenUsage as fields, totalTokens as total, } from "../../report/operator-record.js";
12
12
  import { cellsOf, cellSummary } from "../../route/profile.js";
13
13
  import { Journal, loadRoutingProfile } from "../../run/journal.js";
14
- import { formatTipProof, runEndTipProof } from "../../run/daemon.js";
14
+ import { baselineProvenanceOf, fingerprintsOf, forgivenFingerprints, forgivenGateRow, formatBaselineProvenance, formatFingerprints, formatForgiven, formatTipProof, runEndTipProof, } from "../../run/daemon.js";
15
15
  import { deriveRunCockpitData } from "../../tui/cockpit/derive.js";
16
16
  const n = (x) => x.toLocaleString("en-US"); // explicit locale — CI/darwin flake guard
17
17
  const EM = "—";
@@ -170,6 +170,10 @@ const VERIFICATION_READING = {
170
170
  failed: "FAILED — the run did not verify its own tip",
171
171
  absent: "absent — no tip verification recorded: neither passed nor failed",
172
172
  };
173
+ function forgivenLines(events) {
174
+ const forgiven = forgivenFingerprints(closedCycle(events));
175
+ return forgiven.length ? ["- **forgiven vs baseline:**", ...forgiven.map((f) => ` - ${formatForgiven(f)}`)] : [];
176
+ }
173
177
  function verificationReading(runId, events) {
174
178
  const state = verificationOf(runId, events);
175
179
  const proof = runEndTipProof(closedCycle(events));
@@ -179,6 +183,16 @@ function verificationReading(runId, events) {
179
183
  }
180
184
  return VERIFICATION_READING[state];
181
185
  }
186
+ // OBS-1123: a battery row that carried baseline reds names them apart from any red it introduced, so a
187
+ // new regression can never read as forgiven. Rows without the structured fields render as before.
188
+ const fingerprintClauses = (data) => {
189
+ const fresh = fingerprintsOf(data.freshFingerprints) ?? [];
190
+ const forgiven = fingerprintsOf(data.forgivenFingerprints);
191
+ if (!forgiven && !forgivenGateRow(data))
192
+ return "";
193
+ return `${fresh.length ? `; new red (not in baseline): ${formatFingerprints(fresh)}` : ""}`
194
+ + `; forgiven vs baseline: ${formatFingerprints(forgiven)} — ${formatBaselineProvenance(baselineProvenanceOf(data.baselineProvenance))}`;
195
+ };
182
196
  // VIS-07 / REC-01: derived only from the run journal, telemetry, and local configuration.
183
197
  export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
184
198
  const runStart = events.find((e) => e.event === "run-start");
@@ -216,6 +230,8 @@ export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
216
230
  `- **failed:** ${count("failed")}`,
217
231
  `- **human:** ${count("human")}`,
218
232
  `- **verification:** ${verificationReading(runId, events)}`,
233
+ // OBS-1123: the same fold the run-end record states, over the same closed cycle as verification.
234
+ ...forgivenLines(events),
219
235
  "",
220
236
  "## Usage & efficiency",
221
237
  "",
@@ -280,7 +296,7 @@ export function renderMarkdownRecord(runId, events, prices = [], rows = []) {
280
296
  }
281
297
  const resolved = gate === "review" && g.data.pass === true && Array.isArray(g.data.resolved)
282
298
  ? g.data.resolved.filter((id) => typeof id === "string") : [];
283
- lines.push(` - ${gate}: ${pass} — ${firstLine(g.data.details)}${resolved.length ? `; resolved: ${resolved.join(", ")}` : ""}`);
299
+ lines.push(` - ${gate}: ${pass} — ${firstLine(g.data.details)}${resolved.length ? `; resolved: ${resolved.join(", ")}` : ""}${fingerprintClauses(g.data)}`);
284
300
  }
285
301
  for (const row of leg2) {
286
302
  const pass = row.data.pass === true ? "pass" : row.data.pass === false ? "fail" : EM;
@@ -1,4 +1,5 @@
1
1
  import { parseArgs } from "node:util";
2
+ import { readDoctor } from "../../adapters/registry.js";
2
3
  import { loadConfig } from "../../config/config.js";
3
4
  import { classifyHost, parseDriverOverride, pickDriver, preflightHostDriver } from "../../drivers/index.js";
4
5
  import { loadGraph } from "../../graph/graph.js";
@@ -31,9 +32,10 @@ export async function resume(argv, cwd = process.cwd()) {
31
32
  // v1.87 T3 (OBS-162, twice-carried workaround): the preflight runs AFTER the graph is read and
32
33
  // sees only the shapes the resumed graph carries. A deny∩prefer collision on a shape no resumed
33
34
  // task uses is a config fact the run would never resolve — it must not refuse the only
34
- // crash-recovery path. doctor still walks the whole map.
35
+ // crash-recovery path. doctor still walks the whole map. OBS-1143: aliases are judged by the
36
+ // identity doctor cached — the same one the daemon's discovery routes with.
35
37
  const graph = loadGraph(cwd);
36
- const collisions = denyPreferCollisions(cfg, graph.tasks.map((t) => t.shape));
38
+ const collisions = denyPreferCollisions(cfg, graph.tasks.map((t) => t.shape), readDoctor(cwd));
37
39
  if (collisions.length) {
38
40
  throw new Error(collisions.map(denyPreferCollisionLine).join("; "));
39
41
  }