tickmarkr 2.6.1 → 2.6.3

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 (105) hide show
  1. package/README.md +16 -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 +1 -0
  6. package/dist/adapters/prompt.js +14 -6
  7. package/dist/adapters/registry.js +3 -3
  8. package/dist/adapters/types.d.ts +12 -4
  9. package/dist/adapters/types.js +6 -0
  10. package/dist/cli/commands/approve.d.ts +11 -4
  11. package/dist/cli/commands/approve.js +82 -27
  12. package/dist/cli/commands/compile.js +13 -3
  13. package/dist/cli/commands/doctor.d.ts +8 -2
  14. package/dist/cli/commands/doctor.js +11 -3
  15. package/dist/cli/commands/fleet.js +87 -11
  16. package/dist/cli/commands/plan.js +13 -8
  17. package/dist/cli/commands/report.d.ts +2 -1
  18. package/dist/cli/commands/report.js +74 -8
  19. package/dist/cli/commands/resume.js +4 -2
  20. package/dist/cli/commands/status.js +43 -20
  21. package/dist/cli/help.d.ts +2 -0
  22. package/dist/cli/help.js +9 -2
  23. package/dist/compile/native.js +7 -0
  24. package/dist/config/config.d.ts +35 -2
  25. package/dist/config/config.js +86 -10
  26. package/dist/config/fleet-overlay.d.ts +13 -2
  27. package/dist/config/fleet-overlay.js +60 -0
  28. package/dist/drivers/herdr.d.ts +12 -0
  29. package/dist/drivers/herdr.js +51 -0
  30. package/dist/drivers/orca.d.ts +35 -2
  31. package/dist/drivers/orca.js +222 -67
  32. package/dist/drivers/types.d.ts +2 -0
  33. package/dist/drivers/types.js +2 -2
  34. package/dist/eval/canary.d.ts +2 -1
  35. package/dist/eval/canary.js +2 -2
  36. package/dist/eval/dispatch.js +1 -0
  37. package/dist/gates/acceptance.d.ts +9 -1
  38. package/dist/gates/acceptance.js +31 -4
  39. package/dist/gates/baseline.d.ts +32 -2
  40. package/dist/gates/baseline.js +111 -24
  41. package/dist/gates/cache.d.ts +8 -0
  42. package/dist/gates/cache.js +12 -2
  43. package/dist/gates/llm.d.ts +11 -4
  44. package/dist/gates/llm.js +40 -21
  45. package/dist/gates/review.d.ts +14 -1
  46. package/dist/gates/review.js +160 -34
  47. package/dist/gates/run-gates.d.ts +56 -4
  48. package/dist/gates/run-gates.js +358 -58
  49. package/dist/gates/test-manifest.d.ts +45 -1
  50. package/dist/gates/test-manifest.js +78 -12
  51. package/dist/graph/schema.d.ts +2 -0
  52. package/dist/graph/schema.js +2 -0
  53. package/dist/plan/scope.js +2 -2
  54. package/dist/route/preference.d.ts +20 -2
  55. package/dist/route/preference.js +48 -13
  56. package/dist/route/router.d.ts +12 -1
  57. package/dist/route/router.js +56 -24
  58. package/dist/run/consult.d.ts +15 -1
  59. package/dist/run/consult.js +18 -7
  60. package/dist/run/daemon.d.ts +38 -2
  61. package/dist/run/daemon.js +895 -192
  62. package/dist/run/git.d.ts +8 -0
  63. package/dist/run/git.js +14 -0
  64. package/dist/run/interactive-seed.d.ts +4 -0
  65. package/dist/run/interactive-seed.js +35 -9
  66. package/dist/run/journal.d.ts +152 -3
  67. package/dist/run/journal.js +551 -50
  68. package/dist/run/lease.d.ts +13 -0
  69. package/dist/run/lease.js +45 -0
  70. package/dist/run/merge.d.ts +3 -1
  71. package/dist/run/merge.js +3 -2
  72. package/dist/run/operator-summary.d.ts +3 -0
  73. package/dist/run/operator-summary.js +3 -1
  74. package/dist/run/protocol.d.ts +46 -1
  75. package/dist/run/protocol.js +14 -2
  76. package/dist/run/receipt-resolver.d.ts +22 -0
  77. package/dist/run/receipt-resolver.js +40 -1
  78. package/dist/run/repair-selection.d.ts +11 -1
  79. package/dist/run/repair-selection.js +17 -9
  80. package/dist/run/supervision.d.ts +7 -1
  81. package/dist/run/supervision.js +5 -2
  82. package/dist/run/wall-budget.d.ts +48 -0
  83. package/dist/run/wall-budget.js +280 -0
  84. package/dist/tui/cockpit/board.js +3 -3
  85. package/dist/tui/cockpit/decision-actions.d.ts +8 -5
  86. package/dist/tui/cockpit/decision-actions.js +55 -32
  87. package/dist/tui/cockpit/derive.js +13 -2
  88. package/dist/tui/cockpit/live-runtime.d.ts +10 -0
  89. package/dist/tui/cockpit/live-runtime.js +50 -3
  90. package/dist/tui/cockpit/run-cockpit.d.ts +3 -0
  91. package/dist/tui/cockpit/run-cockpit.js +27 -2
  92. package/dist/tui/cockpit/run-view.d.ts +9 -2
  93. package/dist/tui/cockpit/run-view.js +66 -9
  94. package/dist/tui/cockpit/setup-cockpit.d.ts +6 -0
  95. package/dist/tui/cockpit/setup-cockpit.js +10 -3
  96. package/dist/tui/ink/fleet-app.d.ts +15 -3
  97. package/dist/tui/ink/fleet-app.js +91 -22
  98. package/package.json +3 -1
  99. package/schema/config.schema.json +825 -0
  100. package/skills/tickmarkr-loop/SKILL.md +15 -3
  101. package/skills/tickmarkr-overseer/SKILL.md +42 -0
  102. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +91 -0
  103. package/skills/tickmarkr-overseer/scripts/context-statusline.sh +81 -0
  104. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +36 -34
  105. 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,10 @@ 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
+ ...(kind === "stall" && typeof e.data.reapFailure === "string" ? { reapFailure: e.data.reapFailure } : {}),
62
63
  };
63
64
  }
64
65
  return undefined;
@@ -71,9 +72,21 @@ export function readJournalEvents(journal) {
71
72
  sourceIndexes: tracked.map((row) => row.sourceIndex),
72
73
  };
73
74
  }
75
+ /** OBS-1178: the token a park is decided by — what status, park notices and the cockpit print. */
76
+ export const parkToken = (park) => park.ts === undefined ? undefined : bindingToken({ line: park.line, ts: park.ts });
77
+ /** OBS-1178: the newest task-failed row for a task — the failure a failed-task recheck binds to. */
78
+ export function newestFailure(events, taskId, sourceIndexes) {
79
+ for (let i = events.length - 1; i >= 0; i--) {
80
+ const e = events[i];
81
+ if (e.event === "task-failed" && e.taskId === taskId && typeof e.ts === "string")
82
+ return { line: sourceIndexes?.[i] === undefined ? physicalLine(events, i) : sourceIndexes[i] + 1, ts: e.ts };
83
+ }
84
+ return undefined;
85
+ }
74
86
  /**
75
- * FINAL §3.3's decision menu as data: human-gate/attempt-cap/other non-gate parks → approve; infra →
76
- * approve or recheck; review gate-fail → waive/uphold/recheck; other gate-fail → waive/recheck; a
87
+ * FINAL §3.3's decision menu as data: human-gate/attempt-cap/other non-gate parks → approve; infra, and
88
+ * a stall park that recorded a reapFailure (OBS-1202), → approve or recheck; review gate-fail →
89
+ * waive/uphold/recheck; other gate-fail → waive/recheck; a
77
90
  * gate-fail park with no failed-gate evidence, or a tombstone → nothing (a diagnostic, never a
78
91
  * fabricated verb). The refusals in `approve` below enforce the same table; this is the one place a
79
92
  * surface may read it from, so what a menu offers and what the command accepts cannot drift.
@@ -88,6 +101,10 @@ export function permittedDecisionVerbs(park) {
88
101
  }
89
102
  if (park.kind === "infra")
90
103
  return ["approve", "recheck"];
104
+ // OBS-1202: an unreadable reap census parks finished work; recheck re-verifies that census and gates the
105
+ // harvested commits with no worker. An ordinary stall recorded no census failure: approve alone.
106
+ if (park.kind === "stall" && park.reapFailure !== undefined)
107
+ return ["approve", "recheck"];
91
108
  // OBS-1084: an authoring park whose newest gate row is a red tool gate is a runner report (a load
92
109
  // flake in an unowned suite), so the battery re-runs on the preserved ref with no worker.
93
110
  if (park.kind === "authoring" && isToolGate(park.failedGate))
@@ -137,13 +154,17 @@ export function approvalEnactment(token, run) {
137
154
  }
138
155
  /** The production command registered in COMMANDS; its returned bytes are what the CLI prints. */
139
156
  export async function approve(argv, cwd = process.cwd()) {
140
- const { runId, taskId, by, reason, waive, uphold, recheck, reviewRoundCeiling, files } = parseArgs(argv);
157
+ const { runId, taskId, by, reason, waive, uphold, recheck, reviewRoundCeiling, files, park: namedPark, gate: namedGate } = parseArgs(argv);
141
158
  const decisions = [waive, uphold, recheck].filter(Boolean).length;
142
159
  if (decisions > 1)
143
160
  throw new Error("--waive, --uphold and --recheck are different decisions — pass one");
161
+ // OBS-1178: the decision binds at COMMAND time — to the named --park, else to the park (or, for a
162
+ // failed task's recheck, the failure) open now — and is revalidated under serialization below. A
163
+ // decision queued behind another approval can therefore never land on a park that opened meanwhile.
164
+ // Journal.open throws `no journal for <runId> at <dir>` on an unknown run — that IS the refusal.
165
+ const bound = namedPark ?? openDecision(Journal.open(cwd, runId), taskId);
144
166
  const serialization = await acquireApprovalSerialization(cwd, runId);
145
167
  try {
146
- // Journal.open throws `no journal for <runId> at <dir>` on an unknown run — that IS the refusal.
147
168
  const journal = Journal.open(cwd, runId);
148
169
  const status = journal.replayStatuses().get(taskId);
149
170
  if (status === undefined) {
@@ -153,13 +174,17 @@ export async function approve(argv, cwd = process.cwd()) {
153
174
  // integration base owns verified-able work; --recheck re-gates that tree with no worker. Any other
154
175
  // verb on a failed task, or a recheck over nothing landed, is refused naming why.
155
176
  if (status === "failed" && recheck) {
177
+ if (namedGate !== undefined)
178
+ throw new Error(`--gate names a parked failed gate; task ${taskId} is failed — bind its recheck with --park <failure token>`);
179
+ const { events: failedEvents, sourceIndexes: failedIndexes } = readJournalEvents(journal);
180
+ const failure = requireBound(runId, taskId, bound, newestFailure(failedEvents, taskId, failedIndexes), "failure");
156
181
  const ahead = failedTaskCommitsAhead(cwd, journal, runId, taskId);
157
182
  if (ahead.count === 0) {
158
183
  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
184
  }
160
185
  journal.append("task-approved", taskId, {
161
186
  by, ...(reason ? { reason } : {}), via: "cli", release: RECHECK_RELEASE,
162
- recheckedRef: ahead.ref, commitsAhead: ahead.count,
187
+ recheckedRef: ahead.ref, commitsAhead: ahead.count, failure,
163
188
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
164
189
  });
165
190
  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);
@@ -177,7 +202,12 @@ export async function approve(argv, cwd = process.cwd()) {
177
202
  const gateFailPark = park?.kind === "gate-fail";
178
203
  const infraPark = park?.kind === "infra" || park?.kind === "diff-cap";
179
204
  const authoringRunnerPark = park?.kind === "authoring" && isToolGate(park.failedGate);
205
+ const reapFailurePark = park?.kind === "stall" && park.reapFailure !== undefined;
180
206
  const failedGate = gateFailPark || authoringRunnerPark ? park?.failedGate : undefined;
207
+ const parkBinding = requireBound(runId, taskId, bound, park?.ts === undefined ? undefined : { line: park.line, ts: park.ts }, "park");
208
+ if (namedGate !== undefined && namedGate !== park?.failedGate) {
209
+ throw new Error(`refusing mismatched gate for ${taskId}: --gate ${namedGate} but park ${bindingToken(parkBinding)} failed ${park?.failedGate ?? "no gate"} — nothing appended`);
210
+ }
181
211
  if ((park?.kind === "scope-request" && !decisions) || files !== undefined) {
182
212
  if (park?.kind !== "scope-request")
183
213
  throw new Error("--files requires a scope-request park");
@@ -209,7 +239,7 @@ export async function approve(argv, cwd = process.cwd()) {
209
239
  + `remedy: close the run, split the task in its spec, recompile, then \`tickmarkr resume ${runId} --graph-changed\``);
210
240
  }
211
241
  journal.append("task-approved", taskId, {
212
- by, ...(reason ? { reason } : {}), via: "cli", release: "scope-request",
242
+ by, ...(reason ? { reason } : {}), via: "cli", release: "scope-request", park: parkBinding,
213
243
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }), // OBS-1083
214
244
  amendment: { from, to: graphDefinitionHash(amended), beforeFiles: task.files, files: amendedFiles, parkLine: park.line, definition: taskDefinitionFingerprint(task) },
215
245
  });
@@ -234,12 +264,16 @@ export async function approve(argv, cwd = process.cwd()) {
234
264
  via: "cli",
235
265
  release: REVIEW_UPHELD_RELEASE,
236
266
  gate: "review",
267
+ park: parkBinding,
237
268
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
238
269
  });
239
270
  return disposition(cwd, runId, "fund-fixed-attempt", `upheld the reviewer for ${taskId} in ${runId} — by ${by}`, serialization.contended);
240
271
  }
241
272
  if (recheck) {
242
- if ((!gateFailPark || !failedGate) && !infraPark && !authoringRunnerPark) {
273
+ if (park?.kind === "stall" && !reapFailurePark) {
274
+ throw new Error(`--recheck applies to a stall park only when it recorded a reapFailure (census recovery); ${taskId}'s stall park recorded none — plain approve dispatches a worker; refusing`);
275
+ }
276
+ if ((!gateFailPark || !failedGate) && !infraPark && !authoringRunnerPark && !reapFailurePark) {
243
277
  throw new Error(`--recheck applies to a gate-fail, infra, diff-cap or red-tool-gate authoring park; ${taskId}'s newest park is ${String(lastHuman?.data.kind ?? "none")} with failed gate ${failedGate ?? "none"} — refusing`);
244
278
  }
245
279
  journal.append("task-approved", taskId, {
@@ -247,13 +281,16 @@ export async function approve(argv, cwd = process.cwd()) {
247
281
  ...(reason ? { reason } : {}),
248
282
  via: "cli",
249
283
  release: RECHECK_RELEASE,
284
+ park: parkBinding,
250
285
  // OBS-1084: failedGate names the runner report for every recheck. Authoring rechecks additionally
251
286
  // carry gate so their durable release row identifies the red tool gate that made recheck admissible.
252
287
  ...(failedGate ? { failedGate } : {}),
253
288
  ...(authoringRunnerPark && failedGate ? { gate: failedGate } : {}),
289
+ // OBS-1202: the daemon re-verifies the parked attempt's owned census before any gate runs.
290
+ ...(reapFailurePark ? { reapFailure: park.reapFailure } : {}),
254
291
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
255
292
  });
256
- 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);
293
+ return disposition(cwd, runId, "re-dispatch", `re-checking ${taskId} in ${runId} — by ${by}; ${failedGate ? `failed gate ${failedGate}` : reapFailurePark ? "reapFailure stall park; the owned census is re-verified before the gates" : `${park.kind} park`}; no gate marked satisfied`, serialization.contended);
257
294
  }
258
295
  if (waive) {
259
296
  if (!gateFailPark || !failedGate) {
@@ -265,6 +302,7 @@ export async function approve(argv, cwd = process.cwd()) {
265
302
  via: "cli",
266
303
  release: GATE_SATISFIED_RELEASE,
267
304
  gate: failedGate,
305
+ park: parkBinding,
268
306
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
269
307
  });
270
308
  return disposition(cwd, runId, "waive-gate", `waived failed gate ${failedGate} for ${taskId} in ${runId} — by ${by}`, serialization.contended);
@@ -292,6 +330,7 @@ export async function approve(argv, cwd = process.cwd()) {
292
330
  via: "cli",
293
331
  ...(reviewRoundCeiling === undefined ? {} : { reviewRoundCeiling }),
294
332
  ...(capPark ? { release: ATTEMPT_CAP_RELEASE } : {}),
333
+ park: parkBinding,
295
334
  });
296
335
  const token = capPark ? "fresh-budget" : "dispatch";
297
336
  return disposition(cwd, runId, token, `approved ${taskId} in ${runId} — by ${by}`, serialization.contended);
@@ -300,6 +339,23 @@ export async function approve(argv, cwd = process.cwd()) {
300
339
  serialization.release();
301
340
  }
302
341
  }
342
+ /** The row a decision on this task answers right now: its newest failure when failed, else its newest park. */
343
+ function openDecision(journal, taskId) {
344
+ const { events, sourceIndexes } = readJournalEvents(journal);
345
+ if (journal.replayStatuses().get(taskId) === "failed")
346
+ return newestFailure(events, taskId, sourceIndexes);
347
+ const park = newestPark(events, taskId, sourceIndexes);
348
+ return park?.ts === undefined ? undefined : { line: park.line, ts: park.ts };
349
+ }
350
+ /** OBS-1178: under serialization, the decision's command-time binding must still name the open row. */
351
+ function requireBound(runId, taskId, bound, open, what) {
352
+ if (open === undefined)
353
+ throw new Error(`task ${taskId} has no ${what} row with a timestamp to bind this decision to — refusing an unbound release`);
354
+ if (bound === undefined || bound.line !== open.line || bound.ts !== open.ts) {
355
+ 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`);
356
+ }
357
+ return open;
358
+ }
303
359
  /**
304
360
  * OBS-1022: what a failed task has landed. A preserved snapshot is authoritative only when it contains
305
361
  * the current task-branch tip; an older preservation can remain in the journal after a later retry has
@@ -365,12 +421,14 @@ function disposition(cwd, runId, token, message, contended) {
365
421
  };
366
422
  return `${out}\nTICKMARKR_APPROVAL ${JSON.stringify(record)}`;
367
423
  }
368
- const USAGE = "usage: tickmarkr approve <run-id> <task-id> [--files <glob,…>] [--waive|--uphold|--recheck] [--review-rounds <positive-integer>] [--by <name>] [--reason <text>]";
424
+ 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
425
  // hand-parsed argv — no CLI framework (house style). Positionals are runId then taskId; decision,
370
426
  // ceiling, actor and reason are flags. Throws usage on missing positionals (mirrors resume.ts/unlock.ts).
371
427
  function parseArgs(argv) {
372
428
  const positionals = [];
373
429
  let files;
430
+ let park;
431
+ let gate;
374
432
  let by;
375
433
  let reason;
376
434
  let waive = false;
@@ -388,6 +446,17 @@ function parseArgs(argv) {
388
446
  throw new Error("--files requires non-empty repository-relative globs");
389
447
  }
390
448
  }
449
+ else if (a === "--park") {
450
+ const value = argv[++i];
451
+ park = value === undefined ? undefined : parseBindingToken(value);
452
+ if (!park)
453
+ throw new Error("--park requires the <line>@<ts> token `tickmarkr status` prints for the park");
454
+ }
455
+ else if (a === "--gate") {
456
+ gate = argv[++i];
457
+ if (!gate || !GATE_NAMES.includes(gate))
458
+ throw new Error(`--gate requires one of ${GATE_NAMES.join(", ")}`);
459
+ }
391
460
  else if (a === "--by") {
392
461
  by = argv[++i];
393
462
  if (!by)
@@ -424,19 +493,5 @@ function parseArgs(argv) {
424
493
  if (!runId || !taskId) {
425
494
  throw new Error(USAGE);
426
495
  }
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;
496
+ return { runId, taskId, by: by ?? userInfo().username, reason, waive, uphold, recheck, reviewRoundCeiling, files, park, gate };
442
497
  }
@@ -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
  }>;
@@ -172,6 +173,8 @@ export declare function doctorProbePreflight(cwd?: string, cfg?: {
172
173
  tipTest?: string | undefined;
173
174
  lint?: string | undefined;
174
175
  diffCap?: number | undefined;
176
+ evidenceQuotaBytes?: number | undefined;
177
+ repairSelection?: boolean | undefined;
175
178
  byShape?: Partial<Record<"plan" | "spec" | "implement" | "tests" | "docs" | "migration" | "ui" | "refactor" | "chore", {
176
179
  [x: string]: unknown;
177
180
  acceptance?: boolean | undefined;
@@ -205,8 +208,8 @@ export declare function doctorProbePreflight(cwd?: string, cfg?: {
205
208
  };
206
209
  executionPolicy?: {
207
210
  boundedInfrastructure: boolean;
208
- repairSelection: boolean;
209
211
  taskExecutionLimitMs: number;
212
+ repairSelection?: boolean | undefined;
210
213
  } | undefined;
211
214
  setup?: string | undefined;
212
215
  cost?: {
@@ -286,6 +289,7 @@ export declare function cachedDoctorDiagnostics(cwd?: string, adapters?: WorkerA
286
289
  modelOverrides?: Record<string, {
287
290
  vendor?: string | undefined;
288
291
  channel?: "sub" | "api" | undefined;
292
+ effort?: "low" | "medium" | "high" | undefined;
289
293
  }> | undefined;
290
294
  windows?: Record<string, number> | undefined;
291
295
  }>;
@@ -296,6 +300,8 @@ export declare function cachedDoctorDiagnostics(cwd?: string, adapters?: WorkerA
296
300
  tipTest?: string | undefined;
297
301
  lint?: string | undefined;
298
302
  diffCap?: number | undefined;
303
+ evidenceQuotaBytes?: number | undefined;
304
+ repairSelection?: boolean | undefined;
299
305
  byShape?: Partial<Record<"plan" | "spec" | "implement" | "tests" | "docs" | "migration" | "ui" | "refactor" | "chore", {
300
306
  [x: string]: unknown;
301
307
  acceptance?: boolean | undefined;
@@ -329,8 +335,8 @@ export declare function cachedDoctorDiagnostics(cwd?: string, adapters?: WorkerA
329
335
  };
330
336
  executionPolicy?: {
331
337
  boundedInfrastructure: boolean;
332
- repairSelection: boolean;
333
338
  taskExecutionLimitMs: number;
339
+ repairSelection?: boolean | undefined;
334
340
  } | undefined;
335
341
  setup?: string | undefined;
336
342
  cost?: {
@@ -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);
@@ -5,16 +5,16 @@ import { allAdapters, discoverChannels, doctorAgeMs, initDoctorReuse, modelAuthE
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 { ConfigError, 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
14
  import { pickRole } from "../../route/role-pick.js";
15
- import { denyPreferCollisionLine, denyPreferCollisions, disallowedBy, entryMatchesChannel, exclusionCollector } from "../../route/preference.js";
15
+ import { deadPoolEntries, deadPoolEntryLine, denyPreferCollisionLine, denyPreferCollisions, disallowedBy, entryMatchesChannel, exclusionCollector } from "../../route/preference.js";
16
16
  import { resolveRunMode } from "../../run/daemon.js";
17
- import { loadRoutingProfile } from "../../run/journal.js";
17
+ import { loadRoutingProfile, readReviewNoVerdictHistory, reviewNoVerdictRows } from "../../run/journal.js";
18
18
  const initialFetch = globalThis.fetch;
19
19
  const NON_TTY_MSG = "tickmarkr fleet: interactive fleet editor requires a TTY — use `tickmarkr fleet --print` for non-interactive output";
20
20
  const QUIT = "fleet: quit without writing";
@@ -346,8 +346,10 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
346
346
  const channels = discoverChannels(cfg, adapters, health);
347
347
  const profile = loadRoutingProfile(cwd, cfg, { preview: true });
348
348
  const stagedDenyOf = (editable) => Object.fromEntries(DENY_SCOPES.map((scope) => [stagedDenyKeyOf(scope), editable[scope.key] ?? []]));
349
+ // OBS-1188: previews stage no effort, so both sides drop efforts — the writer then needs no lower layers
350
+ const previewInitial = { ...structuredClone(initial), efforts: undefined };
349
351
  const stagedEditable = (map, deny) => ({
350
- ...structuredClone(initial),
352
+ ...structuredClone(previewInitial),
351
353
  ...Object.fromEntries(DENY_SCOPES.map((scope) => [scope.key, deny[stagedDenyKeyOf(scope)]])),
352
354
  // OBS-1046: the allow complement is staged beside the deny lists, never folded into them
353
355
  allowOut: deny.allowOut ?? initial.allowOut,
@@ -362,7 +364,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
362
364
  let preview;
363
365
  try {
364
366
  const bytes = renderFleetOverlayWrite(currentRepoOverlayText(cwd), {
365
- initial,
367
+ initial: previewInitial,
366
368
  edited: stagedEditable(map, deny),
367
369
  universe,
368
370
  ...(mode !== rm.mode.mode ? { mode } : {}),
@@ -581,7 +583,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
581
583
  captions.push(`task hint: routing.map.${shape}.prefer ranks ${entry.prefer.join(", ")} — not manageable here, edit it in Shapes`);
582
584
  }
583
585
  // 8. deny∩prefer collision — a standing lint on this shape's declaration, never a fleet toggle.
584
- for (const collision of denyPreferCollisions(cfgPreview, [shape])) {
586
+ for (const collision of denyPreferCollisions(cfgPreview, [shape], health)) {
585
587
  captions.push(denyPreferCollisionLine(collision));
586
588
  }
587
589
  // 9. first-match provenance — the per-channel ledger below lists EVERY collector scope.
@@ -594,7 +596,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
594
596
  const channelLedger = (shape, map, cfgPreview, offered) => {
595
597
  const entry = map[shape];
596
598
  const floor = cfgPreview.routing.floors[shape];
597
- const collisions = denyPreferCollisions(cfgPreview, [shape]);
599
+ const collisions = denyPreferCollisions(cfgPreview, [shape], health);
598
600
  const discovered = new Set(previewChannels.map((c) => `${c.adapter}:${c.model}`));
599
601
  // T2 review (D-225): discovery already dropped a channel whose model probe failed — it is
600
602
  // rebuilt as a structured channel from its adapter's own declaration and walks the SAME
@@ -616,7 +618,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
616
618
  // never greys beside itself; its pin/pool/floor/prefer captions stay in excludedNote
617
619
  const ledgerChannels = [...previewChannels.map((c) => ({ ...c, authReason: undefined })), ...failedProbes, ...unauthedCliChannels]
618
620
  .filter((c) => !offered.has(`${c.adapter}:${c.model}`));
619
- return ledgerChannels.flatMap((c) => {
621
+ const lines = ledgerChannels.flatMap((c) => {
620
622
  const key = `${c.adapter}:${c.model}`;
621
623
  const reasons = exclusionCollector(c, cfgPreview.routing, "worker").map(exclusionReason);
622
624
  // OBS-1065: a greyed picker row carries its reach beside its reasons, like the models view
@@ -645,8 +647,19 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
645
647
  if (c.authReason)
646
648
  reasons.push(c.authReason);
647
649
  // labelled like the models view's row (adapter/model), so a ledger row never reads as a candidate
648
- return reasons.length ? [`${c.adapter}/${c.model} — reach: ${reach} — ${reasons.join("; ")}`] : [];
650
+ return reasons.length ? [{ key, line: `${c.adapter}/${c.model} — reach: ${reach} — ${reasons.join("; ")}` }] : [];
649
651
  });
652
+ // OBS-1145 add.1: every carried pool member the picker does not offer leads the ledger in chain
653
+ // order — one no reason above names still gets its own row — so the picker renders its ordinal
654
+ // and Space can drop it; a chain member with no row is hidden AND irremovable
655
+ const carried = entry?.pool?.channels.filter((key) => !offered.has(key)) ?? [];
656
+ const named = new Set(lines.map((row) => row.key));
657
+ const unnamed = carried.filter((key) => !named.has(key)).map((key) => ({
658
+ key,
659
+ line: `${key.replace(":", "/")} — ${discovered.has(key) ? `not offered for ${shape}` : "not served by an installed adapter"} — carried in routing.map.${shape}.pool`,
660
+ }));
661
+ const order = (key) => (carried.includes(key) ? carried.indexOf(key) : carried.length);
662
+ return [...lines, ...unnamed].sort((a, b) => order(a.key) - order(b.key)).map((row) => row.line);
650
663
  };
651
664
  const candidatesForShape = (shape, mode, map, deny) => {
652
665
  const preview = previewCfg(mode, map, deny);
@@ -677,6 +690,10 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
677
690
  review: cfg.review.prefer?.slice(),
678
691
  consult: cfg.consult.prefer?.slice(),
679
692
  };
693
+ // OBS-1052(3): shown on the Steering view beside review.prefer; the pickers above stay untouched.
694
+ const reviewAdvisories = reviewNoVerdictRows(readReviewNoVerdictHistory(cwd))
695
+ .filter((row) => row.verdict === "warn")
696
+ .map((row) => `review history: ${row.channel} — ${row.value}`);
680
697
  const steeringOptionsFor = (which, current) => {
681
698
  const discovered = which === "review" ? [...reviewAdapters, ...seats] : seats;
682
699
  return [...discovered, ...current.filter((entry) => !discovered.includes(entry))];
@@ -685,6 +702,26 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
685
702
  // discovered seats universe plus a keep-default row, and a write is staged only on a real change.
686
703
  const initialJudge = `${cfg.judge.adapter}:${cfg.judge.model}`;
687
704
  let pendingWrite = null;
705
+ // OBS-1182: the layers under the repo overlay, read raw, so clearing an effort masks an inherited
706
+ // one even when those layers only validate beside repo fields. OBS-1188: re-read at every review
707
+ // and save, never an assembly-time snapshot; an unreadable read keeps the last good one for the
708
+ // preview only — the save guard below refuses it.
709
+ let lowerOverrides = {};
710
+ // OBS-1188: the reviewed bytes are the only bytes that may land. Re-rendered from the repo overlay
711
+ // and lower layers as they are NOW; any difference is a stale preview. Null = still current.
712
+ const stalePreview = (write, reviewed, prior = currentRepoOverlayText(cwd)) => {
713
+ const lower = lowerLayerModelOverrides({ globalDir });
714
+ if (!lower.ok)
715
+ return lower.error;
716
+ try {
717
+ if (renderFleetOverlayWrite(prior, { ...write, lowerOverrides: lower.overrides }) === reviewed)
718
+ return null;
719
+ }
720
+ catch (error) {
721
+ return error.message;
722
+ }
723
+ return "stale preview — the repo overlay or a lower config layer changed after the review diff was rendered; press w to review what would be written now";
724
+ };
688
725
  const reviewOverlay = (state) => {
689
726
  const staged = structuredClone(initial);
690
727
  // OBS-994/FL-1, OBS-1099 add.1: every schema-enumerated deny scope rides the same review/write funnel
@@ -692,6 +729,12 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
692
729
  staged[scope.key] = state[scope.key];
693
730
  staged.allowOut = state.allowOut ?? initial.allowOut;
694
731
  staged.map = state.map;
732
+ // OBS-1182: staged efforts replace the loaded ones; none staged anywhere ⇒ no key, as loaded
733
+ const efforts = Object.fromEntries(Object.entries(state.efforts ?? {}).filter(([, models]) => Object.keys(models).length));
734
+ if (Object.keys(efforts).length)
735
+ staged.efforts = efforts;
736
+ else
737
+ delete staged.efforts;
695
738
  const today = new Date().toISOString().slice(0, 10);
696
739
  for (const classification of state.classifications) {
697
740
  staged.tiers[classification.adapter] ??= {};
@@ -713,10 +756,14 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
713
756
  pendingWrite = null;
714
757
  return { kind: "empty" };
715
758
  }
759
+ const lower = lowerLayerModelOverrides({ globalDir });
760
+ if (lower.ok)
761
+ lowerOverrides = lower.overrides;
716
762
  const write = {
717
763
  initial,
718
764
  edited: staged,
719
765
  universe,
766
+ lowerOverrides,
720
767
  ...(modeChanged ? { mode: state.selectedMode } : {}),
721
768
  ...(judgeChanged && judgeSeat ? { judge: judgeSeat } : {}),
722
769
  steering: { initial: initialSteering, edited: state.steering },
@@ -727,22 +774,37 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
727
774
  return { kind: "empty" };
728
775
  }
729
776
  pendingWrite = write;
777
+ // OBS-1144: the review names every dead pool entry the written config carries, with its reason and
778
+ // what still routes — before y. Unloadable bytes carry no notes: the reload guard refuses them anyway.
779
+ let poolNotes = [];
780
+ try {
781
+ const written = loadConfigWithMode(cwd, { globalDir, repoOverlayText: after }).cfg;
782
+ poolNotes = deadPoolEntries(written, undefined, health).map(deadPoolEntryLine);
783
+ }
784
+ catch {
785
+ poolNotes = [];
786
+ }
787
+ const notes = [...(lower.ok ? [] : [`${lower.error} — y will be refused`]), ...poolNotes];
730
788
  return {
731
789
  kind: "diff",
732
790
  before,
733
791
  after,
734
792
  diff: unifiedYamlDiff(before, after, path),
735
793
  path,
794
+ ...(notes.length ? { notes } : {}),
736
795
  };
737
796
  };
738
- const reloadGuard = io.reloadGuard
797
+ const loaderGuard = io.reloadGuard
739
798
  ?? ((bytes) => overlayBytesLoadError(cwd, bytes, { globalDir }));
799
+ // OBS-1188: a stale preview or an unreadable lower layer answers on the same error channel as the loader
800
+ const reloadGuard = (bytes) => (pendingWrite && stalePreview(pendingWrite, bytes)) || loaderGuard(bytes);
740
801
  const props = {
741
802
  ageMs: doctorAgeMs(cwd),
742
803
  adapters,
743
804
  health,
744
805
  ...Object.fromEntries(DENY_SCOPES.map((scope) => [initialDenyPropOf(scope), editable[scope.key] ?? []])),
745
806
  initialAllowOut: editable.allowOut,
807
+ initialEfforts: editable.efforts,
746
808
  modelGroups,
747
809
  initialMode: rm.mode.mode,
748
810
  modeOptions: ROUTING_MODES.map((mode) => ({ id: mode, gloss: MODE_GLOSS[mode] })),
@@ -756,6 +818,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
756
818
  ],
757
819
  initialSteering,
758
820
  steeringOptionsFor,
821
+ reviewAdvisories,
759
822
  reviewOverlay,
760
823
  reloadGuard,
761
824
  stagedRouting,
@@ -782,7 +845,20 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
782
845
  const write = pendingWrite;
783
846
  if (!write)
784
847
  throw new Error("fleet write reached confirmation without a staged overlay mutation");
785
- writeFleetOverlay(result.review.path, (prior) => renderFleetOverlayWrite(prior, write));
848
+ // OBS-1188: re-checked against the bytes on disk at write time; a refusal throws before the temp file exists
849
+ try {
850
+ writeFleetOverlay(result.review.path, (prior) => {
851
+ const stale = stalePreview(write, result.review.after, prior);
852
+ if (stale !== null)
853
+ throw new ConfigError(stale);
854
+ return result.review.after;
855
+ });
856
+ }
857
+ catch (error) {
858
+ if (error instanceof ConfigError)
859
+ return `fleet: nothing written — ${error.message}`;
860
+ throw error;
861
+ }
786
862
  // OBS-529: a freshly classified model has no probe verdict (doctor probes CONFIGURED models,
787
863
  // and it was not configured at probe time), so it stays unroutable — invisible in every
788
864
  // picker — until the next probe. Name the step, or the classify flow reads as broken.