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
@@ -78,6 +78,171 @@ export const REVIEW_UPHELD_RELEASE = "review-upheld";
78
78
  // marks no gate satisfied: green merges directly, red returns to the existing ladder with attempts
79
79
  // and tried seats intact.
80
80
  export const RECHECK_RELEASE = "recheck";
81
+ // OBS-1178: the daemon's answer to a pending approval that no longer binds to the park it names. It
82
+ // enacts nothing and consumes the decision, so every fold reads the task as still parked.
83
+ export const APPROVAL_REFUSED = "approval-refused";
84
+ /** The token every surface prints and `approve --park` accepts: `<line>@<ts>`. */
85
+ export const bindingToken = (binding) => `${binding.line}@${binding.ts}`;
86
+ export function parseBindingToken(token) {
87
+ const match = /^([1-9]\d*)@(\S+)$/u.exec(token);
88
+ return match && Number.isSafeInteger(Number(match[1])) ? { line: Number(match[1]), ts: match[2] } : undefined;
89
+ }
90
+ /** The binding a task-approved row recorded under `park` or `failure`, when well formed. */
91
+ export function recordedBinding(value) {
92
+ if (!value || typeof value !== "object")
93
+ return undefined;
94
+ const { line, ts } = value;
95
+ return typeof line === "number" && Number.isSafeInteger(line) && line > 0 && typeof ts === "string" ? { line, ts } : undefined;
96
+ }
97
+ /** The newest failed gate before a park row — the gate a waive of that park would satisfy. */
98
+ export function failedGateBeforePark(events, taskId, parkIndex) {
99
+ for (let i = parkIndex - 1; i >= 0; i -= 1) {
100
+ const event = events[i];
101
+ if (event.taskId !== taskId)
102
+ continue;
103
+ if (event.event === "task-approved" || event.event === "task-human" || event.event === "task-dispatch")
104
+ return undefined;
105
+ if (event.event === "gate-result" && event.data.pass === false
106
+ && typeof event.data.gate === "string" && GATE_NAMES.includes(event.data.gate)) {
107
+ return event.data.gate;
108
+ }
109
+ }
110
+ return undefined;
111
+ }
112
+ // OBS-1178: every row read from a journal file remembers its PHYSICAL 1-based line, so a decision
113
+ // fold keys bindings and refusals by the line an operator's token names — never by a compacted index
114
+ // a blank or torn line would shift. Rows built in memory (tests, fixtures) fall back to index + 1.
115
+ const SOURCE_LINE = new WeakMap();
116
+ /** The physical journal line of `events[i]` (see SOURCE_LINE). */
117
+ export const physicalLine = (events, i) => SOURCE_LINE.get(events[i]) ?? i + 1;
118
+ /** A row parsed outside this module (a cockpit capture) names its own physical line to the decision fold. */
119
+ export const withPhysicalLine = (row, line) => { SOURCE_LINE.set(row, line); return row; };
120
+ // Rows that enact (consume) the decisions open for their task: a dispatch, a battery, a recreation,
121
+ // or the task closing done. A new task-human or task-failed row is NOT one — it proves nothing was
122
+ // enacted, so an earlier decision stays open and is judged against the newer park or failure.
123
+ const ENACTS_DECISIONS = new Set([
124
+ "task-dispatch", "repair-dispatch", "resume-restore", "worker-launch", "recheck-battery", "worktree-recreation",
125
+ "task-done",
126
+ ]);
127
+ /**
128
+ * OBS-1178: THE decision fold. Every reader of task-approved rows — the replay folds, the daemon's
129
+ * startup, sweep and pending-action folds, scope-amendment replay and its audits, status and both
130
+ * cockpits — reads decisions through it, so no two surfaces can disagree on whether one happened.
131
+ *
132
+ * `effective` holds the PHYSICAL lines of the task-approved rows whose effects may apply: a row that
133
+ * was sound when an enactment row consumed it (bound by line and timestamp to the task's newest park,
134
+ * a waive also to that park's failed gate, or a recheck to the newest failure with no park after it;
135
+ * a legacy row carrying no binding only when no park or failure landed between it and that
136
+ * enactment), plus a row still open that is sound NOW. A refused row is never effective, and neither is an open row that no
137
+ * longer binds — a newer park or failure landed, or it names none — whatever unrelated rows landed
138
+ * around it. An enactment row also consumes the task's park and failure: a decision naming either
139
+ * after the task moved on is stale, while the decisions that enactment already consumed stay effective.
140
+ * `open` lists every unenacted, unrefused row with why it may not be enacted, and `refused` the lines
141
+ * an approval-refused row answered.
142
+ */
143
+ export function foldDecisions(events) {
144
+ const lineOf = (i) => physicalLine(events, i);
145
+ const token = (i) => i === undefined ? "none" : bindingToken({ line: lineOf(i), ts: String(events[i].ts) });
146
+ const names = (binding, i) => i !== undefined && lineOf(i) === binding.line && events[i].ts === binding.ts;
147
+ const parks = new Map();
148
+ const failures = new Map();
149
+ const pending = new Map();
150
+ const effective = new Set();
151
+ const refused = new Set();
152
+ // Why the decision at `index` may not be enacted against the rows read so far; `open` refuses an unbound row outright.
153
+ const judge = (taskId, index, open) => {
154
+ const row = events[index];
155
+ const park = parks.get(taskId);
156
+ const failure = failures.get(taskId);
157
+ const parkBinding = recordedBinding(row.data.park);
158
+ const failureBinding = row.data.release === RECHECK_RELEASE ? recordedBinding(row.data.failure) : undefined;
159
+ if (parkBinding) {
160
+ if (!names(parkBinding, park))
161
+ return `bound to park ${bindingToken(parkBinding)} but the newest park is ${token(park)}`;
162
+ if ((failure ?? -1) > park)
163
+ return `bound to park ${bindingToken(parkBinding)} but the task failed since, at ${token(failure)}`;
164
+ if (row.data.release !== GATE_SATISFIED_RELEASE)
165
+ return undefined;
166
+ const gate = failedGateBeforePark(events, taskId, park);
167
+ return gate !== undefined && gate === row.data.gate ? undefined : `waive names gate ${String(row.data.gate ?? "none")} but park ${token(park)} failed ${gate ?? "no gate"}`;
168
+ }
169
+ if (failureBinding) {
170
+ return names(failureBinding, failure) && (park ?? -1) < failure
171
+ ? undefined
172
+ : `bound to failure ${bindingToken(failureBinding)} but the newest failure is ${token(failure)} and the newest park ${token(park)}`;
173
+ }
174
+ // Enacted history written before decisions carried bindings stays readable unless a park or failure
175
+ // superseded it before its enactment. An open unbound row is refused whatever order it landed in.
176
+ if (!open && Math.max(park ?? -1, failure ?? -1) < index)
177
+ return undefined;
178
+ return `an unbound decision names no park token (the newest park is ${token(park)}) — refusing an unbound release`;
179
+ };
180
+ events.forEach((e, i) => {
181
+ const taskId = e.taskId;
182
+ if (!taskId)
183
+ return;
184
+ if (e.event === "task-approved") {
185
+ pending.set(taskId, [...(pending.get(taskId) ?? []), i]);
186
+ }
187
+ else if (e.event === APPROVAL_REFUSED) {
188
+ // A refusal voids the rows its `lines` name (every open row of its task when it names none).
189
+ const named = Array.isArray(e.data.lines) ? new Set(e.data.lines) : undefined;
190
+ const kept = (pending.get(taskId) ?? []).filter((at) => {
191
+ if (named !== undefined && !named.has(lineOf(at)))
192
+ return true;
193
+ refused.add(lineOf(at));
194
+ return false;
195
+ });
196
+ if (kept.length)
197
+ pending.set(taskId, kept);
198
+ else
199
+ pending.delete(taskId);
200
+ }
201
+ else if (ENACTS_DECISIONS.has(e.event)) {
202
+ for (const at of pending.get(taskId) ?? [])
203
+ if (judge(taskId, at, false) === undefined)
204
+ effective.add(lineOf(at));
205
+ pending.delete(taskId);
206
+ // The task moved on: its park and failure are consumed, so a later decision naming either is stale.
207
+ parks.delete(taskId);
208
+ failures.delete(taskId);
209
+ }
210
+ else if (e.event === "task-human" || e.event === "task-failed") {
211
+ (e.event === "task-human" ? parks : failures).set(taskId, i);
212
+ }
213
+ });
214
+ const open = [];
215
+ for (const [taskId, rows] of pending) {
216
+ for (const at of rows) {
217
+ const stale = judge(taskId, at, true);
218
+ if (stale === undefined)
219
+ effective.add(lineOf(at));
220
+ open.push({ taskId, line: lineOf(at), ...(stale === undefined ? {} : { stale }) });
221
+ }
222
+ }
223
+ return { effective, open, refused };
224
+ }
225
+ /** The physical lines of the task-approved rows whose effects may apply (see foldDecisions). */
226
+ export const effectiveDecisions = (events) => foldDecisions(events).effective;
227
+ /**
228
+ * The journal as every decision reader must see it: task-approved rows that are not effective removed,
229
+ * every other row (and its physical line) kept. Order-only folds iterate this instead of skipping rows
230
+ * on their own.
231
+ */
232
+ export function effectiveEvents(events) {
233
+ const effective = effectiveDecisions(events);
234
+ return events.filter((e, i) => e.event !== "task-approved" || effective.has(physicalLine(events, i)));
235
+ }
236
+ export function staleApprovals(events) {
237
+ const refused = new Map();
238
+ for (const { taskId, line, stale } of foldDecisions(events).open) {
239
+ if (stale === undefined)
240
+ continue;
241
+ const prior = refused.get(taskId);
242
+ refused.set(taskId, { reason: prior ? `${prior.reason}; #L${line} ${stale}` : `#L${line} ${stale}`, lines: [...(prior?.lines ?? []), line] });
243
+ }
244
+ return refused;
245
+ }
81
246
  // OBS-738: one authority for every recovery surface. The ref is accepted only from the row that
82
247
  // preservation itself writes; task-human prose, branch heads and commit history are deliberately
83
248
  // absent from this fold. Keep every row in journal order — one task can be recreated more than once,
@@ -100,18 +265,21 @@ export function preservedRefsByTask(events) {
100
265
  // approval for the task. A whole-journal count re-parks an upheld task before its funded attempt can
101
266
  // dispatch (measured live on run-20260726-213539), making a fresh journal the only escape. A T15
102
267
  // replayMeasurement re-observes an interrupted round and is audit evidence, not a newly funded round.
103
- export function reviewRoundsSinceApproval(events, taskId) {
104
- let rounds = 0;
105
- for (const e of events) {
268
+ // OBS-1178: only an effective decision opens an engagement — a refused or unsound approval resets nothing.
269
+ // `rounds` narrows the decided rows AFTER the decision fold has read the whole journal (the daemon's
270
+ // decisive-round filter drops gate rows a park's failed gate is read from).
271
+ export function reviewRoundsSinceApproval(events, taskId, rounds = (decided) => decided) {
272
+ let drawn = 0;
273
+ for (const e of rounds(effectiveEvents(events))) {
106
274
  if (e.taskId !== taskId)
107
275
  continue;
108
276
  if (e.event === "task-approved")
109
- rounds = 0;
277
+ drawn = 0;
110
278
  else if (e.event === "gate-result" && e.data.gate === "review" && e.data.pass === false
111
279
  && e.data.replayMeasurement !== true)
112
- rounds++;
280
+ drawn++;
113
281
  }
114
- return rounds;
282
+ return drawn;
115
283
  }
116
284
  // OBS-189/OBS-254: the uphold brief is the operator's funded decision, not attempt state. ONE fold,
117
285
  // two consumers — replayResumeState seeds it, and the daemon re-derives it from the journal at
@@ -545,24 +713,56 @@ export function normalizeGateFailure(details) {
545
713
  // operator approval is a new engagement, and nothing else resets the count. Resume re-measurements
546
714
  // are excluded because the interrupted attempt already bought the result they confirm.
547
715
  export const GATE_FINGERPRINT_CAP = 2;
716
+ /** The invocation a gate row's receipt names — the identity of the execution that observed it. */
717
+ export const receiptOrigin = (data) => {
718
+ const receipt = data.evidenceReceipt ?? (Array.isArray(data.evidenceReceipts) ? data.evidenceReceipts[0] : undefined);
719
+ const id = receipt && typeof receipt === "object" ? receipt.invocationId : undefined;
720
+ return typeof id === "string" && id ? `invocation:${id}` : undefined;
721
+ };
722
+ // OBS-1106 residual: occurrences are independent OBSERVATIONS, not rows. A journal replay
723
+ // (`replayedFromAttempt`) and a verdict-cache hit (`reused`) re-state a measurement another row
724
+ // already bought, so each resolves to that row's origin — its receipt's invocation when it carries
725
+ // one, else the attempt (replay) or the newest observation on the same subject (cache) — and one
726
+ // observation counts once however many copies the ledger holds, across resume. Only a fresh
727
+ // execution mints a new origin.
548
728
  export function identicalGateFailures(events, taskId, gate, normalized) {
549
- let n = 0;
550
- for (const e of events) {
729
+ const counted = new Set();
730
+ const byAttempt = new Map();
731
+ const bySubject = new Map();
732
+ effectiveEvents(events).forEach((e, i) => {
551
733
  if (e.taskId !== taskId)
552
- continue;
553
- if (e.event === "task-approved")
554
- n = 0;
555
- else if (e.event === "gate-result" && e.data.gate === gate && e.data.pass === false
556
- && e.data.replayMeasurement !== true
557
- && typeof e.data.details === "string"
734
+ return;
735
+ if (e.event === "task-approved") {
736
+ counted.clear();
737
+ return;
738
+ }
739
+ if (e.event !== "gate-result" || e.data.gate !== gate)
740
+ return;
741
+ const commit = typeof e.data.commit === "string" ? e.data.commit : undefined;
742
+ const origin = typeof e.data.replayedFromAttempt === "number"
743
+ ? byAttempt.get(e.data.replayedFromAttempt) ?? receiptOrigin(e.data) ?? `attempt:${e.data.replayedFromAttempt}`
744
+ : e.data.reused === true
745
+ ? receiptOrigin(e.data) ?? (commit ? bySubject.get(commit) : undefined) ?? `reused:${commit ?? i}`
746
+ : receiptOrigin(e.data) ?? `row:${i}`;
747
+ if (typeof e.data.attempt === "number")
748
+ byAttempt.set(e.data.attempt, origin);
749
+ if (commit)
750
+ bySubject.set(commit, origin);
751
+ if (e.data.pass === false && e.data.replayMeasurement !== true && typeof e.data.details === "string"
558
752
  && normalizeGateFailure(e.data.details) === normalized)
559
- n++;
560
- }
561
- return n;
753
+ counted.add(origin);
754
+ });
755
+ return counted.size;
562
756
  }
757
+ // OBS-1072 add.2: the controls a terminal would consume — OSC strings (ESC ] … BEL/ST), CSI sequences
758
+ // (SGR included), other ESC sequences and stray C0 bytes but tab and newline. Applied only to the
759
+ // human-facing excerpts a worker brief or a scope hint reads; the journal row, the receipt bytes and
760
+ // the failure fingerprint keep the raw text.
761
+ const TERMINAL_CONTROL_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b[ -/]*[0-~]?|[\x00-\x08\x0b-\x1f\x7f]/g;
762
+ export const readableExcerpt = (text) => text.replace(TERMINAL_CONTROL_RE, "");
563
763
  export function repairReachSinceApproval(events, taskId) {
564
764
  const repairs = [];
565
- for (const e of events) {
765
+ for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound approval resets no repair history
566
766
  if (e.taskId !== taskId)
567
767
  continue;
568
768
  if (e.event === "task-approved") {
@@ -591,6 +791,9 @@ export function repairReachSinceApproval(events, taskId) {
591
791
  else
592
792
  repair.launched = true;
593
793
  }
794
+ else if (BOOTSTRAP_DEATH.has(e.event)) {
795
+ repair.launched = false; // that launch died in its CLI's bootstrap: the repair's turn is still owed
796
+ }
594
797
  else if (repair.launched && e.event === "gate-result" && typeof e.data.gate === "string") {
595
798
  if (!repair.reached.includes(e.data.gate))
596
799
  repair.reached.push(e.data.gate);
@@ -610,7 +813,7 @@ export function repairsSinceApproval(events, taskId) {
610
813
  /** Recheck releases not yet enacted by a battery (or by a legacy worker launch). */
611
814
  export function pendingRechecks(events) {
612
815
  const pending = new Set();
613
- for (const e of events) {
816
+ for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound recheck was never pending
614
817
  if (!e.taskId)
615
818
  continue;
616
819
  if (e.event === "task-approved" && e.data.release === RECHECK_RELEASE)
@@ -620,6 +823,7 @@ export function pendingRechecks(events) {
620
823
  }
621
824
  return pending;
622
825
  }
826
+ // OBS-1178: only effective decisions (effectiveEvents) are pending actions; a refused or unsound row never is.
623
827
  const ENACTED_BY = {
624
828
  worker: ["task-dispatch", "worker-launch"],
625
829
  battery: ["recheck-battery"],
@@ -635,7 +839,7 @@ const ENACTED_BY = {
635
839
  */
636
840
  export function pendingApprovalActions(events) {
637
841
  const pending = new Map();
638
- for (const e of events) {
842
+ for (const e of effectiveEvents(events)) {
639
843
  if (!e.taskId)
640
844
  continue;
641
845
  if (e.event === "task-approved") {
@@ -648,7 +852,7 @@ export function pendingApprovalActions(events) {
648
852
  }
649
853
  return pending;
650
854
  }
651
- function approvalAction(taskId, e) {
855
+ export function approvalAction(taskId, e) {
652
856
  const { release, gate } = e.data;
653
857
  const base = { taskId, ts: e.ts };
654
858
  if (release === undefined)
@@ -675,15 +879,22 @@ function approvalAction(taskId, e) {
675
879
  // prompt with the repair findings gone, or re-run the banned channel. worker-launch is appended only
676
880
  // once the prompt has actually been delivered to a worker, which is the dispatch the decision governs.
677
881
  const DECISION_SPENT = "worker-launch";
882
+ // OBS-1169: the daemon's rows closing a launch whose CLI died in its own bootstrap. That worker never
883
+ // read the brief, so the launch spent nothing: its decision, failure brief and repair turn survive it,
884
+ // and the dispatch it closes charges no attempt.
885
+ const BOOTSTRAP_DEATH = new Set(["bootstrap-retry", "bootstrap-failover"]);
678
886
  function decisionForNextDispatch(events, taskId, event) {
679
887
  let pending;
888
+ let spent;
680
889
  for (const e of events) {
681
890
  if (e.taskId !== taskId)
682
891
  continue;
683
892
  if (e.event === event)
684
893
  pending = e;
685
894
  else if (e.event === DECISION_SPENT)
686
- pending = undefined;
895
+ [spent, pending] = [pending, undefined];
896
+ else if (BOOTSTRAP_DEATH.has(e.event))
897
+ pending = spent;
687
898
  }
688
899
  return pending;
689
900
  }
@@ -702,25 +913,25 @@ function decisionForNextDispatch(events, taskId, event) {
702
913
  * failures OR the delivery failure that preceded it. Of the approvals, only a WAIVE clears (the operator
703
914
  * retired the findings by fiat — the uphold case re-derives its own brief separately). OBS-1074: a
704
915
  * plain approve, a scope grant or a recheck re-funds an attempt that must still see why the last one
705
- * parked, plus the operator's stated reason — v2.5.7's T11 looped four times on one hygiene oracle
706
- * because every approval erased exactly the finding the fresh attempt was funded to fix.
916
+ * parked — v2.5.7's T11 looped four times on one hygiene oracle because every approval erased exactly
917
+ * the finding the fresh attempt was funded to fix. The operator's stated reasons ride after those rows
918
+ * as `standingRulings` (OBS-1150): no launch and no waive resets them.
707
919
  */
708
920
  export function journaledFailureBrief(events, taskId) {
709
921
  let rows = [];
710
- for (const e of events) {
922
+ let spent = [];
923
+ for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound waive retires nothing
711
924
  if (e.taskId !== taskId)
712
925
  continue;
713
926
  if (e.event === "worker-launch")
927
+ [spent, rows] = [rows, []];
928
+ else if (BOOTSTRAP_DEATH.has(e.event))
929
+ rows = [...spent, ...rows];
930
+ else if (e.event === "task-approved" && e.data.release === GATE_SATISFIED_RELEASE)
714
931
  rows = [];
715
- else if (e.event === "task-approved") {
716
- if (e.data.release === GATE_SATISFIED_RELEASE)
717
- rows = [];
718
- else if (typeof e.data.reason === "string" && e.data.reason.trim())
719
- rows.push(`approval: ${e.data.reason.trim()}`);
720
- }
721
932
  else if (e.event === "gate-result" && e.data.pass === false && e.data.skipped !== true
722
933
  && typeof e.data.details === "string")
723
- rows.push(`${e.data.gate}: ${e.data.details}`);
934
+ rows.push(`${e.data.gate}: ${readableExcerpt(e.data.details)}`);
724
935
  else if (e.event === "delivery-readiness-failed" && typeof e.data.transcript === "string") {
725
936
  rows.push(`dispatch: delivery readiness failed after ${e.data.waitedMs}ms; pane transcript:\n${e.data.transcript}`);
726
937
  }
@@ -728,7 +939,26 @@ export function journaledFailureBrief(events, taskId) {
728
939
  rows.push(`dispatch: ${e.data.error}`);
729
940
  }
730
941
  }
731
- return rows;
942
+ return [...rows, ...standingRulings(events, taskId).map((ruling) => `approval: ${ruling}`)];
943
+ }
944
+ /**
945
+ * OBS-1150: an operator's approval reason is a ruling on the TASK, not on the attempt it released.
946
+ * The failure rows above are spent at the next worker-launch and the review context once bound only
947
+ * the newest reason, so ruling A vanished at the first launch after it and a later ruling B replaced
948
+ * it. Every effective reason therefore stands, oldest first, for the whole run; a repeat is carried
949
+ * once. A gate-satisfied release accepts a gate's verdict rather than ruling on the work, so its
950
+ * reason adds no standing ruling — and, being no ruling, it retires none either.
951
+ */
952
+ export function standingRulings(events, taskId) {
953
+ const rulings = [];
954
+ for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound approval rules nothing
955
+ if (e.taskId !== taskId || e.event !== "task-approved" || e.data.release === GATE_SATISFIED_RELEASE)
956
+ continue;
957
+ const reason = typeof e.data.reason === "string" ? e.data.reason.trim() : "";
958
+ if (reason && !rulings.includes(reason))
959
+ rulings.push(reason);
960
+ }
961
+ return rulings;
732
962
  }
733
963
  /** Never infer a cross-path alias from a symbol, sentence, or hash. */
734
964
  export function reviewFingerprintMatches(candidate, fingerprint) {
@@ -741,6 +971,17 @@ export function observedReviewFingerprints(finding) {
741
971
  return [...new Set([finding.fingerprint, ...observed])];
742
972
  }
743
973
  const reviewNoteIdentity = (note) => note.replace(LINE_REF_RE, "").replace(/\s+/g, " ").trim();
974
+ /** OBS-1195: the anchor row a verdict's recorded settled comment spells, or undefined if malformed. */
975
+ function settledAnchorRow(anchor) {
976
+ if (!anchor || typeof anchor !== "object")
977
+ return undefined;
978
+ const { path, line, body, disposition } = anchor;
979
+ if (typeof path !== "string" || !Number.isInteger(line) || typeof body !== "string")
980
+ return undefined;
981
+ if (disposition !== "deferred" && disposition !== "resolved")
982
+ return undefined;
983
+ return structuredFindings("review", `- ${path}:${line} — ${body}`).find((finding) => finding.class === "review:anchored");
984
+ }
744
985
  function distinctReviewFinding(row) {
745
986
  const identity = JSON.stringify([reviewNoteIdentity(row.note), row.codeIdentity ?? null]);
746
987
  const symbol = `${row.symbol}#${createHash("sha256").update(identity).digest("hex").slice(0, 12)}`;
@@ -844,9 +1085,26 @@ export function carryReviewFindings(priors, rows) {
844
1085
  * after round re-seats ONE fingerprint, and a revised rationale replaces the prior rationale on that
845
1086
  * row. N rounds of the same concern therefore carry the newest accepted explanation once, not N rows.
846
1087
  */
1088
+ /** OBS-1151: this task's journaled judgments, newest first — subjects to compare a fresh ruling against,
1089
+ * never verdicts to reuse. A row without a well-formed judgment (legacy, park, unparseable) is skipped. */
1090
+ export function priorJudgments(events, taskId) {
1091
+ const out = [];
1092
+ for (const e of events) {
1093
+ const j = e.taskId === taskId && e.event === "gate-result" && e.data.gate === "acceptance" ? e.data.judgment : undefined;
1094
+ if (!j || typeof j !== "object")
1095
+ continue;
1096
+ const { commit, judge, criteria } = j;
1097
+ if (typeof commit !== "string" || !commit || !Array.isArray(criteria))
1098
+ continue;
1099
+ const rows = criteria.filter((c) => !!c && typeof c.id === "string" && typeof c.key === "string" && typeof c.met === "boolean"
1100
+ && Array.isArray(c.paths) && c.paths.every((p) => typeof p === "string"));
1101
+ out.unshift({ commit, ...(typeof judge === "string" ? { judge } : {}), criteria: rows });
1102
+ }
1103
+ return out;
1104
+ }
847
1105
  export function outstandingReviewFindings(events, taskId) {
848
1106
  let open = [];
849
- for (const e of events) {
1107
+ for (const e of effectiveEvents(events)) { // OBS-1178: only an effective review waive retires findings
850
1108
  if (e.taskId !== taskId)
851
1109
  continue;
852
1110
  if (e.event === "task-approved") {
@@ -861,6 +1119,9 @@ export function outstandingReviewFindings(events, taskId) {
861
1119
  continue;
862
1120
  // A failed review can resolve one chain while re-raising another. Only observed, uniquely
863
1121
  // matched spellings retire a chain; skipped/no-verdict rows were excluded above.
1122
+ // OBS-1195: every parent chain this verdict resolved or explicitly deferred; an anchor bound to one
1123
+ // retires whether it was carried in or restated by this same verdict.
1124
+ const retiredParents = new Set();
864
1125
  if (Array.isArray(e.data.resolved)) {
865
1126
  const resolved = e.data.resolved;
866
1127
  const settled = new Set(resolved.flatMap((id) => {
@@ -868,8 +1129,45 @@ export function outstandingReviewFindings(events, taskId) {
868
1129
  && observedReviewFingerprints(finding).some((fp) => reviewFingerprintMatches(id, fp)));
869
1130
  return matches.length === 1 ? matches : [];
870
1131
  }));
1132
+ // OBS-1195: an anchor explicitly bound to a settled chain retires with it; unbound ones stay.
1133
+ for (const id of [...settled].flatMap(observedReviewFingerprints))
1134
+ retiredParents.add(id);
871
1135
  open = open.filter((finding) => !settled.has(finding));
872
1136
  }
1137
+ // OBS-1195: a failed verdict that restates a bound anchor's parent ONLY as a deferral (its deferred
1138
+ // entry echoes the prior's id and no material entry claims it) has explicitly deferred that parent,
1139
+ // so the anchor retires. The parent chain itself keeps whatever the closure lists said of it.
1140
+ // A material row's `reraisedFrom` is this verdict's claim only when its own reraised list names that
1141
+ // id; a link an earlier verdict drew (carried metadata) is lineage and claims nothing here. Without a
1142
+ // reraised list to check against, every link counts as a claim — fail closed.
1143
+ const rows = findingRows(e, "review");
1144
+ const reraisedHere = Array.isArray(e.data.reraised) ? e.data.reraised.filter((id) => typeof id === "string") : undefined;
1145
+ const claimedByMaterial = new Set(rows.filter((row) => !isDeferredFinding(row) && row.reraisedFrom
1146
+ && (reraisedHere === undefined || reraisedHere.some((id) => reviewFingerprintMatches(id, row.reraisedFrom)))).map((row) => row.reraisedFrom));
1147
+ const deferredParents = rows.filter((row) => isDeferredFinding(row) && row.reraisedFrom && !claimedByMaterial.has(row.reraisedFrom))
1148
+ .map((row) => row.reraisedFrom);
1149
+ for (const finding of open) {
1150
+ if (finding.class !== "review:material" || !observedReviewFingerprints(finding).some((fp) => deferredParents.includes(fp)))
1151
+ continue;
1152
+ for (const id of observedReviewFingerprints(finding))
1153
+ retiredParents.add(id);
1154
+ }
1155
+ const boundToRetired = (finding) => finding.boundTo !== undefined && retiredParents.has(finding.boundTo);
1156
+ open = open.filter((finding) => !boundToRetired(finding));
1157
+ // OBS-1195: a later verdict that re-anchors a carried anchor to a deferred entry or a resolved prior
1158
+ // settles that anchor explicitly. Only its own identity (path, symbol and note), uniquely matched,
1159
+ // retires it; a bare path coincidence binds nothing.
1160
+ if (Array.isArray(e.data.settledAnchors)) {
1161
+ const retired = new Set(e.data.settledAnchors.flatMap((anchor) => {
1162
+ const row = settledAnchorRow(anchor);
1163
+ // Identity, not fingerprint: a collision-disambiguated anchor carries a `symbol#hash` spelling.
1164
+ const matches = row ? open.filter((finding) => finding.class === "review:anchored" && finding.path === row.path
1165
+ && (finding.symbol === row.symbol || finding.symbol.startsWith(`${row.symbol}#`))
1166
+ && reviewNoteIdentity(finding.note) === reviewNoteIdentity(row.note)) : [];
1167
+ return matches.length === 1 ? matches : [];
1168
+ }));
1169
+ open = open.filter((finding) => !retired.has(finding));
1170
+ }
873
1171
  if (e.data.pass !== false) {
874
1172
  // a later review PASSED on this task: every finding it BLOCKED on is settled …
875
1173
  open = open.filter(isDeferredFinding);
@@ -879,11 +1177,39 @@ export function outstandingReviewFindings(events, taskId) {
879
1177
  // the very next round — the same silent drop by a different door.
880
1178
  open = carryReviewFindings(open, findingRows(e, "review").filter(isDeferredFinding));
881
1179
  }
882
- else
883
- open = carryReviewFindings(open, findingRows(e, "review"));
1180
+ else {
1181
+ // OBS-1195: a repeated comment bound to a parent this verdict resolved or deferred is not re-seated.
1182
+ open = carryReviewFindings(open, rows).filter((finding) => !boundToRetired(finding));
1183
+ }
884
1184
  }
885
1185
  return open;
886
1186
  }
1187
+ /**
1188
+ * OBS-1019 add.2: each outstanding material chain with the number of valid review verdicts that
1189
+ * re-raised it since the last review pass or review-gate waive. Only a chain's own observed spellings
1190
+ * count, so an unrelated finding sharing a path never lengthens it.
1191
+ */
1192
+ export function reraisedReviewChains(events, taskId) {
1193
+ let verdicts = [];
1194
+ for (const e of effectiveEvents(events)) {
1195
+ if (e.taskId !== taskId)
1196
+ continue;
1197
+ if (e.event === "task-approved" && e.data.release === GATE_SATISFIED_RELEASE && e.data.gate === "review")
1198
+ verdicts = [];
1199
+ if (e.event !== "gate-result" || e.data.gate !== "review" || e.data.skipped === true)
1200
+ continue;
1201
+ if (e.data.unparseable === true || e.data.noVerdict === true || e.data.cause !== undefined)
1202
+ continue;
1203
+ verdicts = e.data.pass === false ? [...verdicts, e] : [];
1204
+ }
1205
+ return outstandingReviewFindings(events, taskId)
1206
+ .filter((finding) => finding.class === "review:material")
1207
+ .map((finding) => ({
1208
+ finding,
1209
+ reraises: verdicts.filter((e) => Array.isArray(e.data.reraised) && e.data.reraised
1210
+ .some((id) => observedReviewFingerprints(finding).some((fp) => reviewFingerprintMatches(id, fp)))).length,
1211
+ }));
1212
+ }
887
1213
  /** The findings a funded repair must carry into the next dispatch, or undefined if none is pending. */
888
1214
  export function pendingRepairFindings(events, taskId) {
889
1215
  const e = decisionForNextDispatch(events, taskId, "repair-attempt");
@@ -896,7 +1222,7 @@ export function pendingRepairFindings(events, taskId) {
896
1222
  */
897
1223
  export function activeRetryBan(events, taskId, channel) {
898
1224
  let pending;
899
- for (const e of events) {
1225
+ for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound recheck lifts no ban
900
1226
  if (e.taskId !== taskId)
901
1227
  continue;
902
1228
  if (e.event === "gate-fingerprint-cap")
@@ -982,6 +1308,60 @@ export function recordedTaskFailureKind(events, taskId) {
982
1308
  }
983
1309
  return undefined;
984
1310
  }
1311
+ // OBS-1109: the `source` a resume harvest stamps on its worker-result and worker-result-harvested rows.
1312
+ export const RESUME_HARVEST_SOURCE = "resume";
1313
+ // Any harvest row — a live daemon's no-trailer synthesis or a resume's — means the attempt's result is
1314
+ // on record: whichever daemon died after it, the next resume gates that record and never redispatches.
1315
+ const isHarvested = (e) => e.event === "worker-result-harvested";
1316
+ const newestDispatch = (events, taskId) => {
1317
+ const rows = effectiveEvents(events).filter((e) => e.taskId === taskId);
1318
+ const at = rows.map((e) => e.event).lastIndexOf("task-dispatch");
1319
+ if (at < 0)
1320
+ return undefined;
1321
+ const assignment = DispatchAssignmentSchema.safeParse(rows[at].data.assignment);
1322
+ return { dispatch: rows[at], since: rows.slice(at + 1), assignment: assignment.success ? assignment.data : undefined };
1323
+ };
1324
+ // OBS-1109: the author of a harvested attempt. It holds through every gate result, park or release
1325
+ // recorded on that harvest — only a newer dispatch (new work, new author) ends it.
1326
+ export function resumeHarvestAuthor(events, taskId) {
1327
+ const newest = newestDispatch(events, taskId);
1328
+ return newest?.since.some(isHarvested) ? newest.assignment : undefined;
1329
+ }
1330
+ // OBS-1109: the newest dispatch of a task that no result, verdict or decision has closed yet — the
1331
+ // attempt a dead daemon left between worker-launch and worker-result. Only a launch row that recorded
1332
+ // its nonce and dispatch script is owned evidence; a legacy launch (or none) stays on the ordinary path.
1333
+ export function interruptedAttempt(events, taskId) {
1334
+ const newest = newestDispatch(events, taskId);
1335
+ if (!newest)
1336
+ return undefined;
1337
+ const { dispatch, since, assignment } = newest;
1338
+ // A declined harvest is on the ordinary recovery path: its pane is no longer evidence to spare.
1339
+ if (since.some((e) => ["gate-result", "task-done", "task-failed", "task-human", "task-approved", "resume-harvest-declined"].includes(e.event)))
1340
+ return undefined;
1341
+ const attempt = dispatch.data.attempt;
1342
+ if (!assignment || !Number.isInteger(attempt))
1343
+ return undefined; // no recorded author, no harvest
1344
+ const base = { attempt: attempt, assignment };
1345
+ if (since.some(isHarvested))
1346
+ return base;
1347
+ const launched = [...since].reverse().find((e) => e.event === "worker-launch" && e.data.attempt === attempt);
1348
+ const slot = launched?.data.slot;
1349
+ const launch = typeof launched?.data.nonce === "string" && typeof launched.data.dispatchScript === "string"
1350
+ && typeof slot?.id === "string" && typeof slot.name === "string" && typeof slot.cwd === "string"
1351
+ ? { nonce: launched.data.nonce, dispatchScript: launched.data.dispatchScript, slot: { id: slot.id, name: slot.name, cwd: slot.cwd } }
1352
+ : undefined;
1353
+ // A worker-result with no verdict after it — a live daemon's, or a resume harvest's torn off before
1354
+ // its harvest row — is a finished attempt whose gating never happened: finish its record, never redispatch.
1355
+ const resulted = since.find((e) => e.event === "worker-result");
1356
+ if (resulted) {
1357
+ return { ...base, ...(launch ? { launch } : {}), result: {
1358
+ finished: resulted.data.finished === true,
1359
+ summary: typeof resulted.data.summary === "string" ? resulted.data.summary : "",
1360
+ reaped: resulted.data.source === RESUME_HARVEST_SOURCE,
1361
+ } };
1362
+ }
1363
+ return launch ? { ...base, launch } : undefined;
1364
+ }
985
1365
  // Runs can end and later resume in the same journal. The newest lifecycle marker decides whether
986
1366
  // an unresolved task is still recoverable by this live daemon or belongs to an ended run.
987
1367
  export function runHasEnded(events) {
@@ -1115,9 +1495,14 @@ export function recordedGraphDefinitionHash(events) {
1115
1495
  return undefined;
1116
1496
  const origin = typeof start.data.graphDefinitionHash === "string" ? start.data.graphDefinitionHash : null;
1117
1497
  let recorded = origin;
1498
+ // OBS-1178: an approval's own rehash moves the identity only while that approval is effective.
1499
+ const granted = new Set(effectiveEvents(events).filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
1500
+ .map((e) => `${e.taskId}\0${e.ts}`));
1118
1501
  for (const e of events) {
1119
1502
  if (e.event !== "graph-rehash")
1120
1503
  continue;
1504
+ if (e.data.source === "approval" && e.data.replay !== true && !granted.has(`${e.taskId}\0${String(e.data.approval)}`))
1505
+ continue;
1121
1506
  const audited = e.data.from === recorded || e.data.from === origin;
1122
1507
  recorded = audited && typeof e.data.to === "string" ? e.data.to : null;
1123
1508
  }
@@ -1154,7 +1539,7 @@ export const ScopeAmendmentSchema = z.object({
1154
1539
  * older than v2.5.7): the caller reads it from the run's materialized graph snapshot.
1155
1540
  */
1156
1541
  export function replayScopeAmendments(graph, events, release = false, approvedDefinitions = new Map()) {
1157
- const amendments = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
1542
+ const amendments = effectiveEvents(events).filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
1158
1543
  .map((event) => ({ event, amendment: ScopeAmendmentSchema.parse(event.data.amendment) }));
1159
1544
  if (!amendments.length)
1160
1545
  return graph;
@@ -1203,7 +1588,7 @@ export function replayScopeAmendments(graph, events, release = false, approvedDe
1203
1588
  export function applyScopeAmendments(graph, journal, auditReplay = false, release = false) {
1204
1589
  const events = journal.read();
1205
1590
  const result = replayScopeAmendments(graph, events, release, release ? snapshotDefinitions(journal) : new Map()); // validate all before moving any identity
1206
- const approvals = events.filter((e) => e.event === "task-approved" && e.data.release === "scope-request");
1591
+ const approvals = effectiveEvents(events).filter((e) => e.event === "task-approved" && e.data.release === "scope-request");
1207
1592
  for (const approval of approvals) {
1208
1593
  const amendment = ScopeAmendmentSchema.parse(approval.data.amendment);
1209
1594
  if (!events.some((e) => e.event === "graph-rehash" && e.data.approval === approval.ts && e.taskId === approval.taskId
@@ -1282,14 +1667,19 @@ const runsDir = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "runs");
1282
1667
  // One JSONL reader for every append-only log: skip blanks, drop any line that
1283
1668
  // won't parse, keeping everything before it intact.
1284
1669
  function readJsonl(path) {
1285
- if (!existsSync(path))
1286
- return [];
1670
+ return existsSync(path) ? parseJournalText(readFileSync(path, "utf8")) : [];
1671
+ }
1672
+ /** The journal reader rule over bytes in hand: skip blanks, drop a torn line, keep each row's physical line. */
1673
+ export function parseJournalText(raw) {
1287
1674
  const out = [];
1288
- for (const line of readFileSync(path, "utf8").split("\n")) {
1675
+ for (const [sourceIndex, line] of raw.split("\n").entries()) {
1289
1676
  if (!line.trim())
1290
1677
  continue;
1291
1678
  try {
1292
- out.push(JSON.parse(line));
1679
+ const row = JSON.parse(line);
1680
+ if (row && typeof row === "object")
1681
+ SOURCE_LINE.set(row, sourceIndex + 1);
1682
+ out.push(row);
1293
1683
  }
1294
1684
  catch {
1295
1685
  // torn trailing write after a crash — ignore; everything before it is intact
@@ -1307,7 +1697,10 @@ function readJsonlSource(path) {
1307
1697
  if (!line.trim())
1308
1698
  continue;
1309
1699
  try {
1310
- out.push({ sourceIndex, raw: JSON.parse(line) });
1700
+ const raw = JSON.parse(line);
1701
+ if (raw && typeof raw === "object")
1702
+ SOURCE_LINE.set(raw, sourceIndex + 1);
1703
+ out.push({ sourceIndex, raw });
1311
1704
  }
1312
1705
  catch {
1313
1706
  // torn trailing write after a crash — ignore; everything before it is intact
@@ -1367,6 +1760,78 @@ export function readPriorRunEvidence(repoRoot, tasks, opts = {}) {
1367
1760
  && current.get(evidence.taskId) === evidence.taskContentDigest),
1368
1761
  };
1369
1762
  }
1763
+ // OBS-1052(3): measured review no-verdict history, per reviewer channel, over the last ten COMPLETED
1764
+ // run journals. Advisory display only — nothing here feeds discovery, routing or the review rotation
1765
+ // (the run-scoped tally in run-gates is the only thing that retires a seat).
1766
+ export const REVIEW_NO_VERDICT_RUN_WINDOW = 10;
1767
+ export const REVIEW_NO_VERDICT_ADVISORY_AT = 2;
1768
+ // Unlike readJsonl, one unparseable row voids the whole journal: it may have been the missing event.
1769
+ function readJournalStrict(path) {
1770
+ try {
1771
+ return readFileSync(path, "utf8").split("\n").filter((line) => line.trim()).map((line) => {
1772
+ const row = JSON.parse(line);
1773
+ if (!row || typeof row !== "object" || typeof row.event !== "string")
1774
+ throw new Error("not a journal row");
1775
+ return row;
1776
+ });
1777
+ }
1778
+ catch {
1779
+ return undefined;
1780
+ }
1781
+ }
1782
+ export function readReviewNoVerdictHistory(repoRoot, window = REVIEW_NO_VERDICT_RUN_WINDOW) {
1783
+ const history = { runs: [], unreadable: [], counts: new Map() };
1784
+ const dir = runsDir(repoRoot);
1785
+ if (!existsSync(dir))
1786
+ return history;
1787
+ const runIds = readdirSync(dir)
1788
+ .filter((runId) => runId.startsWith("run-") && existsSync(join(dir, runId, "journal.jsonl")))
1789
+ .sort()
1790
+ .reverse();
1791
+ for (const runId of runIds) {
1792
+ // an unreadable journal holds its slot: skipping it would reach an eleventh run in its place
1793
+ if (history.runs.length + history.unreadable.length >= window)
1794
+ break;
1795
+ const events = readJournalStrict(join(dir, runId, "journal.jsonl"));
1796
+ if (!events) {
1797
+ history.unreadable.unshift(runId);
1798
+ continue;
1799
+ }
1800
+ if (!runHasEnded(events))
1801
+ continue;
1802
+ history.runs.unshift(runId);
1803
+ for (const event of events) {
1804
+ const reviewer = event.data?.reviewer;
1805
+ if (typeof reviewer !== "string")
1806
+ continue;
1807
+ // Count each observation once: the terminal review gate-result repeats the last seat's
1808
+ // no-verdict note, so only the note counts and the gate row merely marks the seat as measured.
1809
+ if (event.event === "review-no-verdict")
1810
+ history.counts.set(reviewer, (history.counts.get(reviewer) ?? 0) + 1);
1811
+ else if (event.event === "gate-result" && event.data.gate === "review")
1812
+ history.counts.set(reviewer, history.counts.get(reviewer) ?? 0);
1813
+ }
1814
+ }
1815
+ return history;
1816
+ }
1817
+ /** One row per measured channel (plus one naming unreadable journals); doctor prints all, Fleet the warn rows. */
1818
+ export function reviewNoVerdictRows(history) {
1819
+ const span = `the last ${history.runs.length} completed run${history.runs.length === 1 ? "" : "s"}`;
1820
+ const unreadable = history.unreadable.length;
1821
+ const rows = [...history.counts].sort(([a], [b]) => a.localeCompare(b)).map(([channel, n]) => {
1822
+ const counted = `${n} review no-verdict${n === 1 ? "" : "s"} in ${span}`;
1823
+ if (n >= REVIEW_NO_VERDICT_ADVISORY_AT) {
1824
+ return { channel, verdict: "warn", value: `${counted} — advisory; review eligibility unchanged` };
1825
+ }
1826
+ return unreadable
1827
+ ? { channel, verdict: "warn", value: `unknown — at least ${n} in ${span}; ${unreadable} run journal${unreadable === 1 ? "" : "s"} unreadable` }
1828
+ : { channel, verdict: "pass", value: counted };
1829
+ });
1830
+ if (unreadable) {
1831
+ rows.push({ channel: "unreadable", verdict: "warn", value: `unknown — ${history.unreadable.join(", ")} did not parse; review no-verdicts there are unmeasured` });
1832
+ }
1833
+ return rows;
1834
+ }
1370
1835
  // VIS-03 reset cursor — one trimmed runId line at .tickmarkr/profile-since; absent/empty ⇒ undefined.
1371
1836
  // Opaque: used ONLY in the runId > comparison above, never a shell or path join beyond .tickmarkr/.
1372
1837
  export function readProfileCursor(repoRoot) {
@@ -1473,7 +1938,7 @@ export class Journal {
1473
1938
  const preservedRefs = [...preservedRefsByTask(priorEvents)].flatMap(([preservedTaskId, refs]) => refs.map(({ ref, diffCommand }) => ({ taskId: preservedTaskId, ref, diffCommand })));
1474
1939
  return preservedRefs.length > 0 ? { ...inputData, preservedRefs } : inputData;
1475
1940
  })()
1476
- : event === "resume-restore" && rowTaskId && upheldFeedbackByTask(priorEvents).has(rowTaskId)
1941
+ : event === "resume-restore" && rowTaskId && upheldFeedbackByTask(effectiveEvents(priorEvents)).has(rowTaskId)
1477
1942
  ? {
1478
1943
  ...inputData,
1479
1944
  upheldFeedbackRestoredFor: rowTaskId,
@@ -1540,12 +2005,27 @@ export class Journal {
1540
2005
  read() {
1541
2006
  return readJsonl(this.journalPath);
1542
2007
  }
2008
+ /** Parsed rows paired with their physical 1-based journal lines (OBS-1178 bindings name lines). */
2009
+ readSourced() {
2010
+ const rows = readJsonlSource(this.journalPath);
2011
+ return { events: rows.map((row) => row.raw), lines: rows.map((row) => row.sourceIndex + 1) };
2012
+ }
2013
+ /** OBS-1178: the binding of a task's newest park (or `task-failed`) row — what a decision on it names. */
2014
+ newestBinding(taskId, event = "task-human") {
2015
+ const { events, lines } = this.readSourced();
2016
+ for (let i = events.length - 1; i >= 0; i -= 1) {
2017
+ const e = events[i];
2018
+ if (e.event === event && e.taskId === taskId)
2019
+ return typeof e.ts === "string" ? { line: lines[i], ts: e.ts } : undefined;
2020
+ }
2021
+ return undefined;
2022
+ }
1543
2023
  readTracked() {
1544
2024
  return trackJournalRows(this.runId, readJsonlSource(this.journalPath));
1545
2025
  }
1546
2026
  replayStatuses() {
1547
2027
  const s = new Map();
1548
- for (const e of this.read()) {
2028
+ for (const e of effectiveEvents(this.read())) { // OBS-1178: a refused or unsound decision released nothing
1549
2029
  if (!e.taskId)
1550
2030
  continue;
1551
2031
  if (e.event === "task-dispatch")
@@ -1608,7 +2088,7 @@ export class Journal {
1608
2088
  }
1609
2089
  replayResumeState() {
1610
2090
  const m = new Map();
1611
- const events = this.read();
2091
+ const events = effectiveEvents(this.read()); // OBS-1178: a refused or unsound decision funds no budget
1612
2092
  // Keep the legacy resume-state field aligned with the journal-authoritative prompt-time fold.
1613
2093
  // In particular, a review pass after an uphold must erase the fallback daemon.ts may consult.
1614
2094
  const activeUpheldFeedback = upheldFeedbackByTask(events);
@@ -1639,6 +2119,8 @@ export class Journal {
1639
2119
  const added = !st.tried.includes(key);
1640
2120
  if (added)
1641
2121
  st.tried.push(key);
2122
+ if (st.escalated)
2123
+ delete st.escalated[key];
1642
2124
  lastDispatch.set(e.taskId, {
1643
2125
  ...(added ? { addedKey: key } : {}),
1644
2126
  prevAssignment: st.lastAssignment,
@@ -1673,14 +2155,31 @@ export class Journal {
1673
2155
  lastDispatch.delete(e.taskId);
1674
2156
  }
1675
2157
  }
1676
- else if (e.event === "capacity-requeue") {
2158
+ else if (e.event === "capacity-requeue" || BOOTSTRAP_DEATH.has(e.event)) {
1677
2159
  // OBS-1161: a capacity requeue is free — the daemon re-dispatches the SAME attempt on the SAME
1678
2160
  // seat — so the dispatch it closes must not count, or a resume bills every busy-seat wait
1679
2161
  // against MAX_ATTEMPTS. Only the attempt rewinds: the seat stays tried and in force (busy, not
1680
2162
  // burned). Idempotent by the same rule as the scope rewind: only the OUTSTANDING dispatch.
2163
+ // OBS-1169: a bootstrap death bought no worker turn either, retried in place or failed over.
1681
2164
  const st = m.get(e.taskId);
1682
2165
  if (st && lastDispatch.delete(e.taskId))
1683
2166
  st.attempts = Math.max(0, st.attempts - 1);
2167
+ if (st && e.event === "bootstrap-failover") {
2168
+ // OBS-1169: the failover's routing disposition outlives the process too — its escalated
2169
+ // siblings stay excluded with their reason, and a crash before the destination's dispatch
2170
+ // resumes ON that destination instead of relaunching the exhausted source channel.
2171
+ const reason = typeof e.data.reason === "string" ? e.data.reason : "vendor escalated";
2172
+ for (const k of Array.isArray(e.data.escalated) ? e.data.escalated : []) {
2173
+ if (typeof k === "string" && !st.tried.includes(k))
2174
+ st.escalated = { ...st.escalated, [k]: reason };
2175
+ }
2176
+ // The whole ADAPTER of `from` (a channel key, `adapter:model`) is excluded, sibling or none.
2177
+ const adapter = typeof e.data.from === "string" ? e.data.from.split(":")[0] : undefined;
2178
+ if (adapter && !st.escalatedAdapters?.includes(adapter))
2179
+ st.escalatedAdapters = [...(st.escalatedAdapters ?? []), adapter];
2180
+ const to = DispatchAssignmentSchema.safeParse(e.data.toAssignment);
2181
+ st.lastAssignment = to.success ? to.data : undefined;
2182
+ }
1684
2183
  }
1685
2184
  else if (e.event === "consult-verdict" && e.data.action === "reroute") {
1686
2185
  // A reroute bans the in-force channel; retry/decompose/human verdicts ban nothing (D-03).
@@ -1699,6 +2198,8 @@ export class Journal {
1699
2198
  st.attempts = 0;
1700
2199
  st.tried = [];
1701
2200
  st.lastAssignment = undefined;
2201
+ delete st.escalated;
2202
+ delete st.escalatedAdapters;
1702
2203
  }
1703
2204
  }
1704
2205
  else if (e.event === "task-approved" && e.data.release === RECHECK_RELEASE) {
@@ -1752,7 +2253,7 @@ export class Journal {
1752
2253
  const reviewSubjects = new Map();
1753
2254
  const subjects = new Map();
1754
2255
  const reviewWaivers = new Map();
1755
- for (const e of this.read()) {
2256
+ for (const e of effectiveEvents(this.read())) { // OBS-1178: a refused or unsound waive satisfies nothing
1756
2257
  if (!e.taskId)
1757
2258
  continue;
1758
2259
  if (e.event === "task-dispatch") {
@@ -1812,7 +2313,7 @@ export class Journal {
1812
2313
  // exclusively to replaySatisfiedGates(), whose typed release-marker contract above is unchanged.
1813
2314
  replayCurrentAttemptGateResults() {
1814
2315
  const replay = new Map();
1815
- for (const e of this.read()) {
2316
+ for (const e of effectiveEvents(this.read())) {
1816
2317
  if (!e.taskId)
1817
2318
  continue;
1818
2319
  if (e.event === "task-dispatch") {