tickmarkr 2.6.0 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +14 -3
  2. package/dist/adapters/catalog-remote.js +89 -47
  3. package/dist/adapters/claude-code.js +9 -6
  4. package/dist/adapters/codex.js +7 -4
  5. package/dist/adapters/prompt.d.ts +3 -1
  6. package/dist/adapters/prompt.js +21 -3
  7. package/dist/adapters/registry.js +3 -3
  8. package/dist/adapters/types.d.ts +13 -4
  9. package/dist/adapters/types.js +16 -0
  10. package/dist/cli/commands/approve.d.ts +5 -1
  11. package/dist/cli/commands/approve.js +66 -23
  12. package/dist/cli/commands/compile.js +13 -3
  13. package/dist/cli/commands/doctor.d.ts +2 -0
  14. package/dist/cli/commands/doctor.js +11 -3
  15. package/dist/cli/commands/fleet.js +71 -8
  16. package/dist/cli/commands/plan.js +7 -3
  17. package/dist/cli/commands/report.js +18 -2
  18. package/dist/cli/commands/resume.js +4 -2
  19. package/dist/cli/commands/status.js +37 -22
  20. package/dist/cli/help.d.ts +4 -0
  21. package/dist/cli/help.js +11 -2
  22. package/dist/config/config.d.ts +20 -0
  23. package/dist/config/config.js +47 -8
  24. package/dist/config/fleet-overlay.d.ts +1 -0
  25. package/dist/config/fleet-overlay.js +56 -0
  26. package/dist/drivers/orca.d.ts +26 -1
  27. package/dist/drivers/orca.js +199 -61
  28. package/dist/eval/canary.d.ts +2 -1
  29. package/dist/eval/canary.js +2 -2
  30. package/dist/eval/dispatch.js +1 -0
  31. package/dist/gates/acceptance.d.ts +2 -1
  32. package/dist/gates/acceptance.js +7 -2
  33. package/dist/gates/baseline.d.ts +12 -1
  34. package/dist/gates/baseline.js +11 -4
  35. package/dist/gates/cache.d.ts +3 -1
  36. package/dist/gates/cache.js +10 -3
  37. package/dist/gates/llm.d.ts +5 -4
  38. package/dist/gates/llm.js +17 -14
  39. package/dist/gates/review.d.ts +8 -0
  40. package/dist/gates/review.js +47 -15
  41. package/dist/gates/run-gates.d.ts +6 -1
  42. package/dist/gates/run-gates.js +34 -17
  43. package/dist/gates/test-manifest.d.ts +14 -0
  44. package/dist/gates/test-manifest.js +33 -6
  45. package/dist/graph/graph.d.ts +6 -2
  46. package/dist/graph/graph.js +15 -4
  47. package/dist/graph/schema.d.ts +2 -0
  48. package/dist/graph/schema.js +2 -0
  49. package/dist/plan/scope.js +2 -2
  50. package/dist/route/preference.d.ts +20 -2
  51. package/dist/route/preference.js +48 -13
  52. package/dist/route/role-pick.d.ts +16 -0
  53. package/dist/route/role-pick.js +15 -0
  54. package/dist/route/router.d.ts +14 -0
  55. package/dist/route/router.js +39 -16
  56. package/dist/run/consult.d.ts +13 -1
  57. package/dist/run/consult.js +19 -14
  58. package/dist/run/daemon.d.ts +46 -2
  59. package/dist/run/daemon.js +964 -190
  60. package/dist/run/git.d.ts +44 -1
  61. package/dist/run/git.js +103 -5
  62. package/dist/run/host-health.d.ts +20 -0
  63. package/dist/run/host-health.js +64 -0
  64. package/dist/run/journal.d.ts +126 -3
  65. package/dist/run/journal.js +418 -36
  66. package/dist/run/merge.d.ts +3 -1
  67. package/dist/run/merge.js +3 -2
  68. package/dist/run/operator-state.d.ts +24 -2
  69. package/dist/run/operator-state.js +41 -5
  70. package/dist/run/operator-summary.d.ts +3 -0
  71. package/dist/run/operator-summary.js +3 -1
  72. package/dist/run/protocol.d.ts +31 -1
  73. package/dist/run/protocol.js +3 -1
  74. package/dist/run/stall.d.ts +38 -2
  75. package/dist/run/stall.js +276 -6
  76. package/dist/run/supervision.d.ts +7 -1
  77. package/dist/run/supervision.js +5 -2
  78. package/dist/tui/cockpit/board.d.ts +1 -1
  79. package/dist/tui/cockpit/board.js +30 -22
  80. package/dist/tui/cockpit/decision-actions.d.ts +8 -5
  81. package/dist/tui/cockpit/decision-actions.js +55 -32
  82. package/dist/tui/cockpit/derive.d.ts +2 -0
  83. package/dist/tui/cockpit/derive.js +17 -2
  84. package/dist/tui/cockpit/live-runtime.d.ts +10 -0
  85. package/dist/tui/cockpit/live-runtime.js +50 -3
  86. package/dist/tui/cockpit/live-store.d.ts +1 -0
  87. package/dist/tui/cockpit/live-store.js +31 -8
  88. package/dist/tui/cockpit/run-cockpit.d.ts +3 -0
  89. package/dist/tui/cockpit/run-cockpit.js +28 -2
  90. package/dist/tui/cockpit/run-view.d.ts +11 -7
  91. package/dist/tui/cockpit/run-view.js +69 -15
  92. package/dist/tui/cockpit/setup-cockpit.d.ts +4 -0
  93. package/dist/tui/cockpit/setup-cockpit.js +6 -3
  94. package/dist/tui/ink/fleet-app.d.ts +15 -3
  95. package/dist/tui/ink/fleet-app.js +91 -22
  96. package/package.json +2 -1
  97. package/schema/config.schema.json +818 -0
  98. package/skills/tickmarkr-loop/SKILL.md +8 -2
  99. package/skills/tickmarkr-overseer/SKILL.md +85 -6
  100. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +88 -0
  101. package/skills/tickmarkr-overseer/scripts/context-statusline.sh +81 -0
  102. package/skills/tickmarkr-overseer/scripts/grade-ci.sh +36 -34
  103. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +6 -4
@@ -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
@@ -547,7 +715,7 @@ export function normalizeGateFailure(details) {
547
715
  export const GATE_FINGERPRINT_CAP = 2;
548
716
  export function identicalGateFailures(events, taskId, gate, normalized) {
549
717
  let n = 0;
550
- for (const e of events) {
718
+ for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound approval is no new engagement
551
719
  if (e.taskId !== taskId)
552
720
  continue;
553
721
  if (e.event === "task-approved")
@@ -562,7 +730,7 @@ export function identicalGateFailures(events, taskId, gate, normalized) {
562
730
  }
563
731
  export function repairReachSinceApproval(events, taskId) {
564
732
  const repairs = [];
565
- for (const e of events) {
733
+ for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound approval resets no repair history
566
734
  if (e.taskId !== taskId)
567
735
  continue;
568
736
  if (e.event === "task-approved") {
@@ -591,6 +759,9 @@ export function repairReachSinceApproval(events, taskId) {
591
759
  else
592
760
  repair.launched = true;
593
761
  }
762
+ else if (BOOTSTRAP_DEATH.has(e.event)) {
763
+ repair.launched = false; // that launch died in its CLI's bootstrap: the repair's turn is still owed
764
+ }
594
765
  else if (repair.launched && e.event === "gate-result" && typeof e.data.gate === "string") {
595
766
  if (!repair.reached.includes(e.data.gate))
596
767
  repair.reached.push(e.data.gate);
@@ -610,7 +781,7 @@ export function repairsSinceApproval(events, taskId) {
610
781
  /** Recheck releases not yet enacted by a battery (or by a legacy worker launch). */
611
782
  export function pendingRechecks(events) {
612
783
  const pending = new Set();
613
- for (const e of events) {
784
+ for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound recheck was never pending
614
785
  if (!e.taskId)
615
786
  continue;
616
787
  if (e.event === "task-approved" && e.data.release === RECHECK_RELEASE)
@@ -620,6 +791,7 @@ export function pendingRechecks(events) {
620
791
  }
621
792
  return pending;
622
793
  }
794
+ // OBS-1178: only effective decisions (effectiveEvents) are pending actions; a refused or unsound row never is.
623
795
  const ENACTED_BY = {
624
796
  worker: ["task-dispatch", "worker-launch"],
625
797
  battery: ["recheck-battery"],
@@ -635,7 +807,7 @@ const ENACTED_BY = {
635
807
  */
636
808
  export function pendingApprovalActions(events) {
637
809
  const pending = new Map();
638
- for (const e of events) {
810
+ for (const e of effectiveEvents(events)) {
639
811
  if (!e.taskId)
640
812
  continue;
641
813
  if (e.event === "task-approved") {
@@ -648,7 +820,7 @@ export function pendingApprovalActions(events) {
648
820
  }
649
821
  return pending;
650
822
  }
651
- function approvalAction(taskId, e) {
823
+ export function approvalAction(taskId, e) {
652
824
  const { release, gate } = e.data;
653
825
  const base = { taskId, ts: e.ts };
654
826
  if (release === undefined)
@@ -675,15 +847,22 @@ function approvalAction(taskId, e) {
675
847
  // prompt with the repair findings gone, or re-run the banned channel. worker-launch is appended only
676
848
  // once the prompt has actually been delivered to a worker, which is the dispatch the decision governs.
677
849
  const DECISION_SPENT = "worker-launch";
850
+ // OBS-1169: the daemon's rows closing a launch whose CLI died in its own bootstrap. That worker never
851
+ // read the brief, so the launch spent nothing: its decision, failure brief and repair turn survive it,
852
+ // and the dispatch it closes charges no attempt.
853
+ const BOOTSTRAP_DEATH = new Set(["bootstrap-retry", "bootstrap-failover"]);
678
854
  function decisionForNextDispatch(events, taskId, event) {
679
855
  let pending;
856
+ let spent;
680
857
  for (const e of events) {
681
858
  if (e.taskId !== taskId)
682
859
  continue;
683
860
  if (e.event === event)
684
861
  pending = e;
685
862
  else if (e.event === DECISION_SPENT)
686
- pending = undefined;
863
+ [spent, pending] = [pending, undefined];
864
+ else if (BOOTSTRAP_DEATH.has(e.event))
865
+ pending = spent;
687
866
  }
688
867
  return pending;
689
868
  }
@@ -702,22 +881,22 @@ function decisionForNextDispatch(events, taskId, event) {
702
881
  * failures OR the delivery failure that preceded it. Of the approvals, only a WAIVE clears (the operator
703
882
  * retired the findings by fiat — the uphold case re-derives its own brief separately). OBS-1074: a
704
883
  * 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.
884
+ * parked — v2.5.7's T11 looped four times on one hygiene oracle because every approval erased exactly
885
+ * the finding the fresh attempt was funded to fix. The operator's stated reasons ride after those rows
886
+ * as `standingRulings` (OBS-1150): no launch and no waive resets them.
707
887
  */
708
888
  export function journaledFailureBrief(events, taskId) {
709
889
  let rows = [];
710
- for (const e of events) {
890
+ let spent = [];
891
+ for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound waive retires nothing
711
892
  if (e.taskId !== taskId)
712
893
  continue;
713
894
  if (e.event === "worker-launch")
895
+ [spent, rows] = [rows, []];
896
+ else if (BOOTSTRAP_DEATH.has(e.event))
897
+ rows = [...spent, ...rows];
898
+ else if (e.event === "task-approved" && e.data.release === GATE_SATISFIED_RELEASE)
714
899
  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
900
  else if (e.event === "gate-result" && e.data.pass === false && e.data.skipped !== true
722
901
  && typeof e.data.details === "string")
723
902
  rows.push(`${e.data.gate}: ${e.data.details}`);
@@ -728,7 +907,26 @@ export function journaledFailureBrief(events, taskId) {
728
907
  rows.push(`dispatch: ${e.data.error}`);
729
908
  }
730
909
  }
731
- return rows;
910
+ return [...rows, ...standingRulings(events, taskId).map((ruling) => `approval: ${ruling}`)];
911
+ }
912
+ /**
913
+ * OBS-1150: an operator's approval reason is a ruling on the TASK, not on the attempt it released.
914
+ * The failure rows above are spent at the next worker-launch and the review context once bound only
915
+ * the newest reason, so ruling A vanished at the first launch after it and a later ruling B replaced
916
+ * it. Every effective reason therefore stands, oldest first, for the whole run; a repeat is carried
917
+ * once. A gate-satisfied release accepts a gate's verdict rather than ruling on the work, so its
918
+ * reason adds no standing ruling — and, being no ruling, it retires none either.
919
+ */
920
+ export function standingRulings(events, taskId) {
921
+ const rulings = [];
922
+ for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound approval rules nothing
923
+ if (e.taskId !== taskId || e.event !== "task-approved" || e.data.release === GATE_SATISFIED_RELEASE)
924
+ continue;
925
+ const reason = typeof e.data.reason === "string" ? e.data.reason.trim() : "";
926
+ if (reason && !rulings.includes(reason))
927
+ rulings.push(reason);
928
+ }
929
+ return rulings;
732
930
  }
733
931
  /** Never infer a cross-path alias from a symbol, sentence, or hash. */
734
932
  export function reviewFingerprintMatches(candidate, fingerprint) {
@@ -846,7 +1044,7 @@ export function carryReviewFindings(priors, rows) {
846
1044
  */
847
1045
  export function outstandingReviewFindings(events, taskId) {
848
1046
  let open = [];
849
- for (const e of events) {
1047
+ for (const e of effectiveEvents(events)) { // OBS-1178: only an effective review waive retires findings
850
1048
  if (e.taskId !== taskId)
851
1049
  continue;
852
1050
  if (e.event === "task-approved") {
@@ -896,7 +1094,7 @@ export function pendingRepairFindings(events, taskId) {
896
1094
  */
897
1095
  export function activeRetryBan(events, taskId, channel) {
898
1096
  let pending;
899
- for (const e of events) {
1097
+ for (const e of effectiveEvents(events)) { // OBS-1178: a refused or unsound recheck lifts no ban
900
1098
  if (e.taskId !== taskId)
901
1099
  continue;
902
1100
  if (e.event === "gate-fingerprint-cap")
@@ -982,6 +1180,60 @@ export function recordedTaskFailureKind(events, taskId) {
982
1180
  }
983
1181
  return undefined;
984
1182
  }
1183
+ // OBS-1109: the `source` a resume harvest stamps on its worker-result and worker-result-harvested rows.
1184
+ export const RESUME_HARVEST_SOURCE = "resume";
1185
+ // Any harvest row — a live daemon's no-trailer synthesis or a resume's — means the attempt's result is
1186
+ // on record: whichever daemon died after it, the next resume gates that record and never redispatches.
1187
+ const isHarvested = (e) => e.event === "worker-result-harvested";
1188
+ const newestDispatch = (events, taskId) => {
1189
+ const rows = effectiveEvents(events).filter((e) => e.taskId === taskId);
1190
+ const at = rows.map((e) => e.event).lastIndexOf("task-dispatch");
1191
+ if (at < 0)
1192
+ return undefined;
1193
+ const assignment = DispatchAssignmentSchema.safeParse(rows[at].data.assignment);
1194
+ return { dispatch: rows[at], since: rows.slice(at + 1), assignment: assignment.success ? assignment.data : undefined };
1195
+ };
1196
+ // OBS-1109: the author of a harvested attempt. It holds through every gate result, park or release
1197
+ // recorded on that harvest — only a newer dispatch (new work, new author) ends it.
1198
+ export function resumeHarvestAuthor(events, taskId) {
1199
+ const newest = newestDispatch(events, taskId);
1200
+ return newest?.since.some(isHarvested) ? newest.assignment : undefined;
1201
+ }
1202
+ // OBS-1109: the newest dispatch of a task that no result, verdict or decision has closed yet — the
1203
+ // attempt a dead daemon left between worker-launch and worker-result. Only a launch row that recorded
1204
+ // its nonce and dispatch script is owned evidence; a legacy launch (or none) stays on the ordinary path.
1205
+ export function interruptedAttempt(events, taskId) {
1206
+ const newest = newestDispatch(events, taskId);
1207
+ if (!newest)
1208
+ return undefined;
1209
+ const { dispatch, since, assignment } = newest;
1210
+ // A declined harvest is on the ordinary recovery path: its pane is no longer evidence to spare.
1211
+ if (since.some((e) => ["gate-result", "task-done", "task-failed", "task-human", "task-approved", "resume-harvest-declined"].includes(e.event)))
1212
+ return undefined;
1213
+ const attempt = dispatch.data.attempt;
1214
+ if (!assignment || !Number.isInteger(attempt))
1215
+ return undefined; // no recorded author, no harvest
1216
+ const base = { attempt: attempt, assignment };
1217
+ if (since.some(isHarvested))
1218
+ return base;
1219
+ const launched = [...since].reverse().find((e) => e.event === "worker-launch" && e.data.attempt === attempt);
1220
+ const slot = launched?.data.slot;
1221
+ const launch = typeof launched?.data.nonce === "string" && typeof launched.data.dispatchScript === "string"
1222
+ && typeof slot?.id === "string" && typeof slot.name === "string" && typeof slot.cwd === "string"
1223
+ ? { nonce: launched.data.nonce, dispatchScript: launched.data.dispatchScript, slot: { id: slot.id, name: slot.name, cwd: slot.cwd } }
1224
+ : undefined;
1225
+ // A worker-result with no verdict after it — a live daemon's, or a resume harvest's torn off before
1226
+ // its harvest row — is a finished attempt whose gating never happened: finish its record, never redispatch.
1227
+ const resulted = since.find((e) => e.event === "worker-result");
1228
+ if (resulted) {
1229
+ return { ...base, ...(launch ? { launch } : {}), result: {
1230
+ finished: resulted.data.finished === true,
1231
+ summary: typeof resulted.data.summary === "string" ? resulted.data.summary : "",
1232
+ reaped: resulted.data.source === RESUME_HARVEST_SOURCE,
1233
+ } };
1234
+ }
1235
+ return launch ? { ...base, launch } : undefined;
1236
+ }
985
1237
  // Runs can end and later resume in the same journal. The newest lifecycle marker decides whether
986
1238
  // an unresolved task is still recoverable by this live daemon or belongs to an ended run.
987
1239
  export function runHasEnded(events) {
@@ -1115,9 +1367,14 @@ export function recordedGraphDefinitionHash(events) {
1115
1367
  return undefined;
1116
1368
  const origin = typeof start.data.graphDefinitionHash === "string" ? start.data.graphDefinitionHash : null;
1117
1369
  let recorded = origin;
1370
+ // OBS-1178: an approval's own rehash moves the identity only while that approval is effective.
1371
+ const granted = new Set(effectiveEvents(events).filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
1372
+ .map((e) => `${e.taskId}\0${e.ts}`));
1118
1373
  for (const e of events) {
1119
1374
  if (e.event !== "graph-rehash")
1120
1375
  continue;
1376
+ if (e.data.source === "approval" && e.data.replay !== true && !granted.has(`${e.taskId}\0${String(e.data.approval)}`))
1377
+ continue;
1121
1378
  const audited = e.data.from === recorded || e.data.from === origin;
1122
1379
  recorded = audited && typeof e.data.to === "string" ? e.data.to : null;
1123
1380
  }
@@ -1154,7 +1411,7 @@ export const ScopeAmendmentSchema = z.object({
1154
1411
  * older than v2.5.7): the caller reads it from the run's materialized graph snapshot.
1155
1412
  */
1156
1413
  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")
1414
+ const amendments = effectiveEvents(events).filter((e) => e.event === "task-approved" && e.data.release === "scope-request")
1158
1415
  .map((event) => ({ event, amendment: ScopeAmendmentSchema.parse(event.data.amendment) }));
1159
1416
  if (!amendments.length)
1160
1417
  return graph;
@@ -1203,7 +1460,7 @@ export function replayScopeAmendments(graph, events, release = false, approvedDe
1203
1460
  export function applyScopeAmendments(graph, journal, auditReplay = false, release = false) {
1204
1461
  const events = journal.read();
1205
1462
  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");
1463
+ const approvals = effectiveEvents(events).filter((e) => e.event === "task-approved" && e.data.release === "scope-request");
1207
1464
  for (const approval of approvals) {
1208
1465
  const amendment = ScopeAmendmentSchema.parse(approval.data.amendment);
1209
1466
  if (!events.some((e) => e.event === "graph-rehash" && e.data.approval === approval.ts && e.taskId === approval.taskId
@@ -1282,14 +1539,19 @@ const runsDir = (repoRoot) => join(repoRoot, stateDirName(repoRoot), "runs");
1282
1539
  // One JSONL reader for every append-only log: skip blanks, drop any line that
1283
1540
  // won't parse, keeping everything before it intact.
1284
1541
  function readJsonl(path) {
1285
- if (!existsSync(path))
1286
- return [];
1542
+ return existsSync(path) ? parseJournalText(readFileSync(path, "utf8")) : [];
1543
+ }
1544
+ /** The journal reader rule over bytes in hand: skip blanks, drop a torn line, keep each row's physical line. */
1545
+ export function parseJournalText(raw) {
1287
1546
  const out = [];
1288
- for (const line of readFileSync(path, "utf8").split("\n")) {
1547
+ for (const [sourceIndex, line] of raw.split("\n").entries()) {
1289
1548
  if (!line.trim())
1290
1549
  continue;
1291
1550
  try {
1292
- out.push(JSON.parse(line));
1551
+ const row = JSON.parse(line);
1552
+ if (row && typeof row === "object")
1553
+ SOURCE_LINE.set(row, sourceIndex + 1);
1554
+ out.push(row);
1293
1555
  }
1294
1556
  catch {
1295
1557
  // torn trailing write after a crash — ignore; everything before it is intact
@@ -1307,7 +1569,10 @@ function readJsonlSource(path) {
1307
1569
  if (!line.trim())
1308
1570
  continue;
1309
1571
  try {
1310
- out.push({ sourceIndex, raw: JSON.parse(line) });
1572
+ const raw = JSON.parse(line);
1573
+ if (raw && typeof raw === "object")
1574
+ SOURCE_LINE.set(raw, sourceIndex + 1);
1575
+ out.push({ sourceIndex, raw });
1311
1576
  }
1312
1577
  catch {
1313
1578
  // torn trailing write after a crash — ignore; everything before it is intact
@@ -1367,6 +1632,78 @@ export function readPriorRunEvidence(repoRoot, tasks, opts = {}) {
1367
1632
  && current.get(evidence.taskId) === evidence.taskContentDigest),
1368
1633
  };
1369
1634
  }
1635
+ // OBS-1052(3): measured review no-verdict history, per reviewer channel, over the last ten COMPLETED
1636
+ // run journals. Advisory display only — nothing here feeds discovery, routing or the review rotation
1637
+ // (the run-scoped tally in run-gates is the only thing that retires a seat).
1638
+ export const REVIEW_NO_VERDICT_RUN_WINDOW = 10;
1639
+ export const REVIEW_NO_VERDICT_ADVISORY_AT = 2;
1640
+ // Unlike readJsonl, one unparseable row voids the whole journal: it may have been the missing event.
1641
+ function readJournalStrict(path) {
1642
+ try {
1643
+ return readFileSync(path, "utf8").split("\n").filter((line) => line.trim()).map((line) => {
1644
+ const row = JSON.parse(line);
1645
+ if (!row || typeof row !== "object" || typeof row.event !== "string")
1646
+ throw new Error("not a journal row");
1647
+ return row;
1648
+ });
1649
+ }
1650
+ catch {
1651
+ return undefined;
1652
+ }
1653
+ }
1654
+ export function readReviewNoVerdictHistory(repoRoot, window = REVIEW_NO_VERDICT_RUN_WINDOW) {
1655
+ const history = { runs: [], unreadable: [], counts: new Map() };
1656
+ const dir = runsDir(repoRoot);
1657
+ if (!existsSync(dir))
1658
+ return history;
1659
+ const runIds = readdirSync(dir)
1660
+ .filter((runId) => runId.startsWith("run-") && existsSync(join(dir, runId, "journal.jsonl")))
1661
+ .sort()
1662
+ .reverse();
1663
+ for (const runId of runIds) {
1664
+ // an unreadable journal holds its slot: skipping it would reach an eleventh run in its place
1665
+ if (history.runs.length + history.unreadable.length >= window)
1666
+ break;
1667
+ const events = readJournalStrict(join(dir, runId, "journal.jsonl"));
1668
+ if (!events) {
1669
+ history.unreadable.unshift(runId);
1670
+ continue;
1671
+ }
1672
+ if (!runHasEnded(events))
1673
+ continue;
1674
+ history.runs.unshift(runId);
1675
+ for (const event of events) {
1676
+ const reviewer = event.data?.reviewer;
1677
+ if (typeof reviewer !== "string")
1678
+ continue;
1679
+ // Count each observation once: the terminal review gate-result repeats the last seat's
1680
+ // no-verdict note, so only the note counts and the gate row merely marks the seat as measured.
1681
+ if (event.event === "review-no-verdict")
1682
+ history.counts.set(reviewer, (history.counts.get(reviewer) ?? 0) + 1);
1683
+ else if (event.event === "gate-result" && event.data.gate === "review")
1684
+ history.counts.set(reviewer, history.counts.get(reviewer) ?? 0);
1685
+ }
1686
+ }
1687
+ return history;
1688
+ }
1689
+ /** One row per measured channel (plus one naming unreadable journals); doctor prints all, Fleet the warn rows. */
1690
+ export function reviewNoVerdictRows(history) {
1691
+ const span = `the last ${history.runs.length} completed run${history.runs.length === 1 ? "" : "s"}`;
1692
+ const unreadable = history.unreadable.length;
1693
+ const rows = [...history.counts].sort(([a], [b]) => a.localeCompare(b)).map(([channel, n]) => {
1694
+ const counted = `${n} review no-verdict${n === 1 ? "" : "s"} in ${span}`;
1695
+ if (n >= REVIEW_NO_VERDICT_ADVISORY_AT) {
1696
+ return { channel, verdict: "warn", value: `${counted} — advisory; review eligibility unchanged` };
1697
+ }
1698
+ return unreadable
1699
+ ? { channel, verdict: "warn", value: `unknown — at least ${n} in ${span}; ${unreadable} run journal${unreadable === 1 ? "" : "s"} unreadable` }
1700
+ : { channel, verdict: "pass", value: counted };
1701
+ });
1702
+ if (unreadable) {
1703
+ rows.push({ channel: "unreadable", verdict: "warn", value: `unknown — ${history.unreadable.join(", ")} did not parse; review no-verdicts there are unmeasured` });
1704
+ }
1705
+ return rows;
1706
+ }
1370
1707
  // VIS-03 reset cursor — one trimmed runId line at .tickmarkr/profile-since; absent/empty ⇒ undefined.
1371
1708
  // Opaque: used ONLY in the runId > comparison above, never a shell or path join beyond .tickmarkr/.
1372
1709
  export function readProfileCursor(repoRoot) {
@@ -1473,7 +1810,7 @@ export class Journal {
1473
1810
  const preservedRefs = [...preservedRefsByTask(priorEvents)].flatMap(([preservedTaskId, refs]) => refs.map(({ ref, diffCommand }) => ({ taskId: preservedTaskId, ref, diffCommand })));
1474
1811
  return preservedRefs.length > 0 ? { ...inputData, preservedRefs } : inputData;
1475
1812
  })()
1476
- : event === "resume-restore" && rowTaskId && upheldFeedbackByTask(priorEvents).has(rowTaskId)
1813
+ : event === "resume-restore" && rowTaskId && upheldFeedbackByTask(effectiveEvents(priorEvents)).has(rowTaskId)
1477
1814
  ? {
1478
1815
  ...inputData,
1479
1816
  upheldFeedbackRestoredFor: rowTaskId,
@@ -1540,12 +1877,27 @@ export class Journal {
1540
1877
  read() {
1541
1878
  return readJsonl(this.journalPath);
1542
1879
  }
1880
+ /** Parsed rows paired with their physical 1-based journal lines (OBS-1178 bindings name lines). */
1881
+ readSourced() {
1882
+ const rows = readJsonlSource(this.journalPath);
1883
+ return { events: rows.map((row) => row.raw), lines: rows.map((row) => row.sourceIndex + 1) };
1884
+ }
1885
+ /** OBS-1178: the binding of a task's newest park (or `task-failed`) row — what a decision on it names. */
1886
+ newestBinding(taskId, event = "task-human") {
1887
+ const { events, lines } = this.readSourced();
1888
+ for (let i = events.length - 1; i >= 0; i -= 1) {
1889
+ const e = events[i];
1890
+ if (e.event === event && e.taskId === taskId)
1891
+ return typeof e.ts === "string" ? { line: lines[i], ts: e.ts } : undefined;
1892
+ }
1893
+ return undefined;
1894
+ }
1543
1895
  readTracked() {
1544
1896
  return trackJournalRows(this.runId, readJsonlSource(this.journalPath));
1545
1897
  }
1546
1898
  replayStatuses() {
1547
1899
  const s = new Map();
1548
- for (const e of this.read()) {
1900
+ for (const e of effectiveEvents(this.read())) { // OBS-1178: a refused or unsound decision released nothing
1549
1901
  if (!e.taskId)
1550
1902
  continue;
1551
1903
  if (e.event === "task-dispatch")
@@ -1608,7 +1960,7 @@ export class Journal {
1608
1960
  }
1609
1961
  replayResumeState() {
1610
1962
  const m = new Map();
1611
- const events = this.read();
1963
+ const events = effectiveEvents(this.read()); // OBS-1178: a refused or unsound decision funds no budget
1612
1964
  // Keep the legacy resume-state field aligned with the journal-authoritative prompt-time fold.
1613
1965
  // In particular, a review pass after an uphold must erase the fallback daemon.ts may consult.
1614
1966
  const activeUpheldFeedback = upheldFeedbackByTask(events);
@@ -1639,6 +1991,8 @@ export class Journal {
1639
1991
  const added = !st.tried.includes(key);
1640
1992
  if (added)
1641
1993
  st.tried.push(key);
1994
+ if (st.escalated)
1995
+ delete st.escalated[key];
1642
1996
  lastDispatch.set(e.taskId, {
1643
1997
  ...(added ? { addedKey: key } : {}),
1644
1998
  prevAssignment: st.lastAssignment,
@@ -1673,6 +2027,32 @@ export class Journal {
1673
2027
  lastDispatch.delete(e.taskId);
1674
2028
  }
1675
2029
  }
2030
+ else if (e.event === "capacity-requeue" || BOOTSTRAP_DEATH.has(e.event)) {
2031
+ // OBS-1161: a capacity requeue is free — the daemon re-dispatches the SAME attempt on the SAME
2032
+ // seat — so the dispatch it closes must not count, or a resume bills every busy-seat wait
2033
+ // against MAX_ATTEMPTS. Only the attempt rewinds: the seat stays tried and in force (busy, not
2034
+ // burned). Idempotent by the same rule as the scope rewind: only the OUTSTANDING dispatch.
2035
+ // OBS-1169: a bootstrap death bought no worker turn either, retried in place or failed over.
2036
+ const st = m.get(e.taskId);
2037
+ if (st && lastDispatch.delete(e.taskId))
2038
+ st.attempts = Math.max(0, st.attempts - 1);
2039
+ if (st && e.event === "bootstrap-failover") {
2040
+ // OBS-1169: the failover's routing disposition outlives the process too — its escalated
2041
+ // siblings stay excluded with their reason, and a crash before the destination's dispatch
2042
+ // resumes ON that destination instead of relaunching the exhausted source channel.
2043
+ const reason = typeof e.data.reason === "string" ? e.data.reason : "vendor escalated";
2044
+ for (const k of Array.isArray(e.data.escalated) ? e.data.escalated : []) {
2045
+ if (typeof k === "string" && !st.tried.includes(k))
2046
+ st.escalated = { ...st.escalated, [k]: reason };
2047
+ }
2048
+ // The whole ADAPTER of `from` (a channel key, `adapter:model`) is excluded, sibling or none.
2049
+ const adapter = typeof e.data.from === "string" ? e.data.from.split(":")[0] : undefined;
2050
+ if (adapter && !st.escalatedAdapters?.includes(adapter))
2051
+ st.escalatedAdapters = [...(st.escalatedAdapters ?? []), adapter];
2052
+ const to = DispatchAssignmentSchema.safeParse(e.data.toAssignment);
2053
+ st.lastAssignment = to.success ? to.data : undefined;
2054
+ }
2055
+ }
1676
2056
  else if (e.event === "consult-verdict" && e.data.action === "reroute") {
1677
2057
  // A reroute bans the in-force channel; retry/decompose/human verdicts ban nothing (D-03).
1678
2058
  pendingReroute.add(e.taskId);
@@ -1690,6 +2070,8 @@ export class Journal {
1690
2070
  st.attempts = 0;
1691
2071
  st.tried = [];
1692
2072
  st.lastAssignment = undefined;
2073
+ delete st.escalated;
2074
+ delete st.escalatedAdapters;
1693
2075
  }
1694
2076
  }
1695
2077
  else if (e.event === "task-approved" && e.data.release === RECHECK_RELEASE) {
@@ -1743,7 +2125,7 @@ export class Journal {
1743
2125
  const reviewSubjects = new Map();
1744
2126
  const subjects = new Map();
1745
2127
  const reviewWaivers = new Map();
1746
- for (const e of this.read()) {
2128
+ for (const e of effectiveEvents(this.read())) { // OBS-1178: a refused or unsound waive satisfies nothing
1747
2129
  if (!e.taskId)
1748
2130
  continue;
1749
2131
  if (e.event === "task-dispatch") {
@@ -1803,7 +2185,7 @@ export class Journal {
1803
2185
  // exclusively to replaySatisfiedGates(), whose typed release-marker contract above is unchanged.
1804
2186
  replayCurrentAttemptGateResults() {
1805
2187
  const replay = new Map();
1806
- for (const e of this.read()) {
2188
+ for (const e of effectiveEvents(this.read())) {
1807
2189
  if (!e.taskId)
1808
2190
  continue;
1809
2191
  if (e.event === "task-dispatch") {