tickmarkr 2.6.1 → 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 (85) 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 +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 +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 +45 -7
  16. package/dist/cli/commands/report.js +18 -2
  17. package/dist/cli/commands/resume.js +4 -2
  18. package/dist/cli/commands/status.js +24 -19
  19. package/dist/cli/help.d.ts +2 -0
  20. package/dist/cli/help.js +9 -2
  21. package/dist/config/config.d.ts +20 -0
  22. package/dist/config/config.js +47 -8
  23. package/dist/config/fleet-overlay.d.ts +1 -0
  24. package/dist/config/fleet-overlay.js +56 -0
  25. package/dist/drivers/orca.d.ts +26 -1
  26. package/dist/drivers/orca.js +193 -60
  27. package/dist/eval/canary.d.ts +2 -1
  28. package/dist/eval/canary.js +2 -2
  29. package/dist/eval/dispatch.js +1 -0
  30. package/dist/gates/acceptance.d.ts +2 -1
  31. package/dist/gates/acceptance.js +7 -2
  32. package/dist/gates/baseline.d.ts +12 -1
  33. package/dist/gates/baseline.js +11 -4
  34. package/dist/gates/llm.d.ts +5 -4
  35. package/dist/gates/llm.js +13 -13
  36. package/dist/gates/review.d.ts +8 -0
  37. package/dist/gates/review.js +40 -4
  38. package/dist/gates/run-gates.d.ts +2 -1
  39. package/dist/gates/run-gates.js +27 -13
  40. package/dist/gates/test-manifest.d.ts +3 -1
  41. package/dist/gates/test-manifest.js +9 -2
  42. package/dist/graph/schema.d.ts +2 -0
  43. package/dist/graph/schema.js +2 -0
  44. package/dist/plan/scope.js +2 -2
  45. package/dist/route/preference.d.ts +20 -2
  46. package/dist/route/preference.js +48 -13
  47. package/dist/route/router.js +30 -15
  48. package/dist/run/consult.d.ts +13 -1
  49. package/dist/run/consult.js +14 -5
  50. package/dist/run/daemon.d.ts +37 -2
  51. package/dist/run/daemon.js +579 -141
  52. package/dist/run/git.d.ts +8 -0
  53. package/dist/run/git.js +14 -0
  54. package/dist/run/journal.d.ts +126 -3
  55. package/dist/run/journal.js +410 -37
  56. package/dist/run/merge.d.ts +3 -1
  57. package/dist/run/merge.js +3 -2
  58. package/dist/run/operator-summary.d.ts +3 -0
  59. package/dist/run/operator-summary.js +3 -1
  60. package/dist/run/protocol.d.ts +31 -1
  61. package/dist/run/protocol.js +3 -1
  62. package/dist/run/supervision.d.ts +7 -1
  63. package/dist/run/supervision.js +5 -2
  64. package/dist/tui/cockpit/board.js +3 -3
  65. package/dist/tui/cockpit/decision-actions.d.ts +8 -5
  66. package/dist/tui/cockpit/decision-actions.js +55 -32
  67. package/dist/tui/cockpit/derive.js +13 -2
  68. package/dist/tui/cockpit/live-runtime.d.ts +10 -0
  69. package/dist/tui/cockpit/live-runtime.js +50 -3
  70. package/dist/tui/cockpit/run-cockpit.d.ts +3 -0
  71. package/dist/tui/cockpit/run-cockpit.js +26 -1
  72. package/dist/tui/cockpit/run-view.d.ts +9 -3
  73. package/dist/tui/cockpit/run-view.js +60 -7
  74. package/dist/tui/cockpit/setup-cockpit.d.ts +4 -0
  75. package/dist/tui/cockpit/setup-cockpit.js +6 -3
  76. package/dist/tui/ink/fleet-app.d.ts +15 -3
  77. package/dist/tui/ink/fleet-app.js +91 -22
  78. package/package.json +2 -1
  79. package/schema/config.schema.json +818 -0
  80. package/skills/tickmarkr-loop/SKILL.md +8 -2
  81. package/skills/tickmarkr-overseer/SKILL.md +42 -0
  82. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +88 -0
  83. package/skills/tickmarkr-overseer/scripts/context-statusline.sh +81 -0
  84. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +36 -34
  85. 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);
@@ -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 { 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";
@@ -581,7 +581,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
581
581
  captions.push(`task hint: routing.map.${shape}.prefer ranks ${entry.prefer.join(", ")} — not manageable here, edit it in Shapes`);
582
582
  }
583
583
  // 8. deny∩prefer collision — a standing lint on this shape's declaration, never a fleet toggle.
584
- for (const collision of denyPreferCollisions(cfgPreview, [shape])) {
584
+ for (const collision of denyPreferCollisions(cfgPreview, [shape], health)) {
585
585
  captions.push(denyPreferCollisionLine(collision));
586
586
  }
587
587
  // 9. first-match provenance — the per-channel ledger below lists EVERY collector scope.
@@ -594,7 +594,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
594
594
  const channelLedger = (shape, map, cfgPreview, offered) => {
595
595
  const entry = map[shape];
596
596
  const floor = cfgPreview.routing.floors[shape];
597
- const collisions = denyPreferCollisions(cfgPreview, [shape]);
597
+ const collisions = denyPreferCollisions(cfgPreview, [shape], health);
598
598
  const discovered = new Set(previewChannels.map((c) => `${c.adapter}:${c.model}`));
599
599
  // T2 review (D-225): discovery already dropped a channel whose model probe failed — it is
600
600
  // rebuilt as a structured channel from its adapter's own declaration and walks the SAME
@@ -616,7 +616,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
616
616
  // never greys beside itself; its pin/pool/floor/prefer captions stay in excludedNote
617
617
  const ledgerChannels = [...previewChannels.map((c) => ({ ...c, authReason: undefined })), ...failedProbes, ...unauthedCliChannels]
618
618
  .filter((c) => !offered.has(`${c.adapter}:${c.model}`));
619
- return ledgerChannels.flatMap((c) => {
619
+ const lines = ledgerChannels.flatMap((c) => {
620
620
  const key = `${c.adapter}:${c.model}`;
621
621
  const reasons = exclusionCollector(c, cfgPreview.routing, "worker").map(exclusionReason);
622
622
  // OBS-1065: a greyed picker row carries its reach beside its reasons, like the models view
@@ -645,8 +645,19 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
645
645
  if (c.authReason)
646
646
  reasons.push(c.authReason);
647
647
  // 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("; ")}`] : [];
648
+ return reasons.length ? [{ key, line: `${c.adapter}/${c.model} — reach: ${reach} — ${reasons.join("; ")}` }] : [];
649
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);
650
661
  };
651
662
  const candidatesForShape = (shape, mode, map, deny) => {
652
663
  const preview = previewCfg(mode, map, deny);
@@ -677,6 +688,10 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
677
688
  review: cfg.review.prefer?.slice(),
678
689
  consult: cfg.consult.prefer?.slice(),
679
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}`);
680
695
  const steeringOptionsFor = (which, current) => {
681
696
  const discovered = which === "review" ? [...reviewAdapters, ...seats] : seats;
682
697
  return [...discovered, ...current.filter((entry) => !discovered.includes(entry))];
@@ -685,6 +700,9 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
685
700
  // discovered seats universe plus a keep-default row, and a write is staged only on a real change.
686
701
  const initialJudge = `${cfg.judge.adapter}:${cfg.judge.model}`;
687
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 });
688
706
  const reviewOverlay = (state) => {
689
707
  const staged = structuredClone(initial);
690
708
  // OBS-994/FL-1, OBS-1099 add.1: every schema-enumerated deny scope rides the same review/write funnel
@@ -692,6 +710,12 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
692
710
  staged[scope.key] = state[scope.key];
693
711
  staged.allowOut = state.allowOut ?? initial.allowOut;
694
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;
695
719
  const today = new Date().toISOString().slice(0, 10);
696
720
  for (const classification of state.classifications) {
697
721
  staged.tiers[classification.adapter] ??= {};
@@ -717,6 +741,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
717
741
  initial,
718
742
  edited: staged,
719
743
  universe,
744
+ lowerOverrides,
720
745
  ...(modeChanged ? { mode: state.selectedMode } : {}),
721
746
  ...(judgeChanged && judgeSeat ? { judge: judgeSeat } : {}),
722
747
  steering: { initial: initialSteering, edited: state.steering },
@@ -727,12 +752,23 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
727
752
  return { kind: "empty" };
728
753
  }
729
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
+ }
730
765
  return {
731
766
  kind: "diff",
732
767
  before,
733
768
  after,
734
769
  diff: unifiedYamlDiff(before, after, path),
735
770
  path,
771
+ ...(poolNotes.length ? { notes: poolNotes } : {}),
736
772
  };
737
773
  };
738
774
  const reloadGuard = io.reloadGuard
@@ -743,6 +779,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
743
779
  health,
744
780
  ...Object.fromEntries(DENY_SCOPES.map((scope) => [initialDenyPropOf(scope), editable[scope.key] ?? []])),
745
781
  initialAllowOut: editable.allowOut,
782
+ initialEfforts: editable.efforts,
746
783
  modelGroups,
747
784
  initialMode: rm.mode.mode,
748
785
  modeOptions: ROUTING_MODES.map((mode) => ({ id: mode, gloss: MODE_GLOSS[mode] })),
@@ -756,6 +793,7 @@ export async function assembleFleetEditor(cwd, adapters, io, opts) {
756
793
  ],
757
794
  initialSteering,
758
795
  steeringOptionsFor,
796
+ reviewAdvisories,
759
797
  reviewOverlay,
760
798
  reloadGuard,
761
799
  stagedRouting,
@@ -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
  }
@@ -12,7 +12,7 @@ import { authorsNote, doneAuthors } from "../../run/operator-state.js";
12
12
  import { projectOperatorSummary } from "../../run/operator-summary.js";
13
13
  import { trackJournalRows } from "../../run/protocol.js";
14
14
  import { newestPark, permittedDecisionVerbs } from "./approve.js";
15
- import { Journal, formatJournalNarration, engagementComparable, isQualityFailureParkKind, parseRunId, preservedRefsByTask, recordedTaskFailureKind, runHasEnded, upheldFeedbackByTask, } from "../../run/journal.js";
15
+ import { Journal, bindingToken, effectiveEvents, formatJournalNarration, parseJournalText, physicalLine, engagementComparable, isQualityFailureParkKind, parseRunId, preservedRefsByTask, recordedTaskFailureKind, runHasEnded, upheldFeedbackByTask, } from "../../run/journal.js";
16
16
  import { isPidLive, runLockRunId, runStatusLine } from "../../run/lock.js";
17
17
  import { normalizeGateOutcome } from "../../run/outcome.js";
18
18
  import { desiredPanes } from "../../run/reconcile.js";
@@ -167,7 +167,7 @@ export const decisionEventsFromJournal = (events, runId, stateDir = ".tickmarkr"
167
167
  ...base,
168
168
  type: "human-decision-required",
169
169
  tier: "decision",
170
- approvalCommand: `tickmarkr approve ${runId} ${event.taskId}`,
170
+ approvalCommand: `tickmarkr approve ${runId} ${event.taskId} --park ${bindingToken({ line: physicalLine(events, index), ts: event.ts })}`,
171
171
  ...(typeof event.data.kind === "string" ? { kind: event.data.kind } : {}),
172
172
  ...(typeof event.data.reason === "string" ? { reason: event.data.reason } : {}),
173
173
  }];
@@ -841,18 +841,6 @@ const statusEngagement = (events, loadedHash) => {
841
841
  }
842
842
  return { comparable: true };
843
843
  };
844
- // The journal's own reader rule (src/run/journal.ts readJsonl), applied to bytes already in hand:
845
- // skip blanks, drop a line that will not parse (a torn trailing write after a crash), keep the rest.
846
- const parseJournalSnapshot = (raw) => raw.split("\n").flatMap((line) => {
847
- if (!line.trim())
848
- return [];
849
- try {
850
- return [JSON.parse(line)];
851
- }
852
- catch {
853
- return [];
854
- }
855
- });
856
844
  // runLockOwner is the single lock payload/liveness reader. Its current path helper also initializes
857
845
  // .tickmarkr metadata; an engine-written lock necessarily passed through that initializer already, so
858
846
  // status avoids invoking it in stripped reader-purity fixtures where no engine lock can exist.
@@ -892,7 +880,7 @@ const readRunRecord = (cwd, graph, namedRunId) => {
892
880
  if (!runId)
893
881
  return undefined;
894
882
  const raw = readRunJournalRaw(cwd, runId, lockedRunId === runId);
895
- const events = parseJournalSnapshot(raw);
883
+ const events = parseJournalText(raw);
896
884
  // The resume comparator is the fail-closed baseline; a matching graph-rehash is the daemon's
897
885
  // append-only audit that authorizes this status replay after stop-amend-resume.
898
886
  const engagement = statusEngagement(events, graphDefinitionHash(graph));
@@ -918,7 +906,8 @@ const recordTaskRows = (record, graph, isDaemonAlive) => record.comparable && re
918
906
  const recordProjection = (record, graph, isDaemonAlive) => {
919
907
  const rows = record ? recordTaskRows(record, graph, isDaemonAlive) : new Map();
920
908
  const events = record?.comparable ? record.events : [];
921
- const activity = projectActivity(record?.runId ?? "", trackJournalRows(record?.runId ?? "", events.map((raw, sourceIndex) => ({ raw, sourceIndex }))), graph.tasks);
909
+ // OBS-1178: a refused or unsound decision released nothing, so the activity fold never reads it.
910
+ const activity = projectActivity(record?.runId ?? "", trackJournalRows(record?.runId ?? "", effectiveEvents(events).map((raw, sourceIndex) => ({ raw, sourceIndex }))), graph.tasks);
922
911
  const tasks = graph.tasks.map(task => {
923
912
  const row = rows.get(task.id);
924
913
  const evidence = events.filter(event => event.taskId === task.id);
@@ -1068,12 +1057,28 @@ binaryVersion, now = Date.now(), animationFrame = 0, workerLiveness = new Map(),
1068
1057
  // OBS-738: recovery facts stay on the journal's two established reducers. The prefix fold keeps an
1069
1058
  // older journal equally readable by asking upheldFeedbackByTask what was active at that exact
1070
1059
  // restore boundary; current resume-restore rows additionally carry the same task for narration.
1060
+ // OBS-1178: both folds read decisions through the one decision fold over the whole journal.
1061
+ const decided = effectiveEvents(events);
1071
1062
  const preservedRefs = preservedRefsByTask(events);
1072
- const restoredUpheldFeedback = new Set(events.flatMap((event, index) => event.event === "resume-restore" && event.taskId
1073
- && upheldFeedbackByTask(events.slice(0, index)).has(event.taskId)
1063
+ const restoredUpheldFeedback = new Set(decided.flatMap((event, index) => event.event === "resume-restore" && event.taskId
1064
+ && upheldFeedbackByTask(decided.slice(0, index)).has(event.taskId)
1074
1065
  ? [event.taskId]
1075
1066
  : []));
1067
+ // OBS-1178: a failed task's newest task-failed row is the token its recheck binds to. Only an
1068
+ // effective decision consumes it — a refused or unsound recheck leaves the task failed and the token shown.
1069
+ const failureTokens = new Map();
1070
+ decided.forEach((event, index) => {
1071
+ if (!event.taskId || !["task-dispatch", "task-done", "task-failed", "task-human", "task-approved"].includes(event.event))
1072
+ return;
1073
+ if (event.event === "task-failed")
1074
+ failureTokens.set(event.taskId, bindingToken({ line: physicalLine(decided, index), ts: event.ts }));
1075
+ else
1076
+ failureTokens.delete(event.taskId);
1077
+ });
1076
1078
  const recoveryLinesForTask = (taskId) => [
1079
+ ...(failureTokens.has(taskId) && runId
1080
+ ? [`failed — ${taskId} — failure ${failureTokens.get(taskId)} — re-gate landed work with \`tickmarkr approve ${runId} ${taskId} --recheck --park ${failureTokens.get(taskId)}\``]
1081
+ : []),
1077
1082
  ...(preservedRefs.get(taskId) ?? []).flatMap(({ ref, diffCommand }) => [
1078
1083
  `preserved worktree — ${taskId} — ${ref}`,
1079
1084
  ` ${diffCommand}`,
@@ -1501,7 +1506,7 @@ export async function status(argv, cwd = process.cwd(), opts = {}) {
1501
1506
  decisionRunId = runId;
1502
1507
  journalCursor = 0;
1503
1508
  }
1504
- const journalEvents = parseJournalSnapshot(readRunJournalRaw(cwd, runId, lockedRunId === runId));
1509
+ const journalEvents = parseJournalText(readRunJournalRaw(cwd, runId, lockedRunId === runId));
1505
1510
  if (journalEvents.length < journalCursor)
1506
1511
  journalCursor = 0;
1507
1512
  const fresh = decisionEventsFromJournal(journalEvents, runId, stateDirName(cwd))