tickmarkr 2.6.2 → 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 (63) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/commands/approve.d.ts +6 -3
  3. package/dist/cli/commands/approve.js +16 -4
  4. package/dist/cli/commands/doctor.d.ts +6 -2
  5. package/dist/cli/commands/fleet.js +46 -8
  6. package/dist/cli/commands/plan.js +13 -8
  7. package/dist/cli/commands/report.d.ts +2 -1
  8. package/dist/cli/commands/report.js +56 -6
  9. package/dist/cli/commands/status.js +19 -1
  10. package/dist/compile/native.js +7 -0
  11. package/dist/config/config.d.ts +17 -4
  12. package/dist/config/config.js +43 -6
  13. package/dist/config/fleet-overlay.d.ts +13 -3
  14. package/dist/config/fleet-overlay.js +12 -8
  15. package/dist/drivers/herdr.d.ts +12 -0
  16. package/dist/drivers/herdr.js +51 -0
  17. package/dist/drivers/orca.d.ts +9 -1
  18. package/dist/drivers/orca.js +29 -7
  19. package/dist/drivers/types.d.ts +2 -0
  20. package/dist/drivers/types.js +2 -2
  21. package/dist/gates/acceptance.d.ts +7 -0
  22. package/dist/gates/acceptance.js +27 -5
  23. package/dist/gates/baseline.d.ts +20 -1
  24. package/dist/gates/baseline.js +100 -20
  25. package/dist/gates/cache.d.ts +8 -0
  26. package/dist/gates/cache.js +12 -2
  27. package/dist/gates/llm.d.ts +6 -0
  28. package/dist/gates/llm.js +27 -8
  29. package/dist/gates/review.d.ts +6 -1
  30. package/dist/gates/review.js +122 -32
  31. package/dist/gates/run-gates.d.ts +54 -3
  32. package/dist/gates/run-gates.js +331 -45
  33. package/dist/gates/test-manifest.d.ts +42 -0
  34. package/dist/gates/test-manifest.js +69 -10
  35. package/dist/route/router.d.ts +12 -1
  36. package/dist/route/router.js +26 -9
  37. package/dist/run/consult.d.ts +3 -1
  38. package/dist/run/consult.js +4 -2
  39. package/dist/run/daemon.d.ts +2 -1
  40. package/dist/run/daemon.js +342 -77
  41. package/dist/run/interactive-seed.d.ts +4 -0
  42. package/dist/run/interactive-seed.js +35 -9
  43. package/dist/run/journal.d.ts +26 -0
  44. package/dist/run/journal.js +143 -15
  45. package/dist/run/lease.d.ts +13 -0
  46. package/dist/run/lease.js +45 -0
  47. package/dist/run/protocol.d.ts +15 -0
  48. package/dist/run/protocol.js +11 -1
  49. package/dist/run/receipt-resolver.d.ts +22 -0
  50. package/dist/run/receipt-resolver.js +40 -1
  51. package/dist/run/repair-selection.d.ts +11 -1
  52. package/dist/run/repair-selection.js +17 -9
  53. package/dist/run/wall-budget.d.ts +48 -0
  54. package/dist/run/wall-budget.js +280 -0
  55. package/dist/tui/cockpit/run-cockpit.js +2 -2
  56. package/dist/tui/cockpit/run-view.d.ts +2 -1
  57. package/dist/tui/cockpit/run-view.js +13 -9
  58. package/dist/tui/cockpit/setup-cockpit.d.ts +2 -0
  59. package/dist/tui/cockpit/setup-cockpit.js +4 -0
  60. package/package.json +2 -1
  61. package/schema/config.schema.json +8 -1
  62. package/skills/tickmarkr-loop/SKILL.md +7 -1
  63. package/skills/tickmarkr-overseer/scripts/classify-vitest-log.sh +4 -1
@@ -2,9 +2,19 @@ import type { JournalEvent } from "./journal.js";
2
2
  export interface RepairSelectionDecision {
3
3
  selectTests: boolean;
4
4
  requiredFiles: string[];
5
- reason: "no-test-failure" | "known-failing-files" | "legacy-test-failure" | "unattributed-test-failure" | "full-suite-failure";
5
+ reason: "no-test-failure" | "known-failing-files" | "legacy-test-failure" | "unattributed-test-failure";
6
6
  }
7
7
  type SelectionEvent = Pick<JournalEvent, "event" | "taskId" | "data">;
8
+ /** OBS-1199: repair selection is on unless a config says otherwise, independent of the optional
9
+ * execution budget (which stays subprocess-only). Either explicit `false` turns it off. */
10
+ export declare function repairSelectionEnabled(cfg?: {
11
+ executionPolicy?: {
12
+ repairSelection?: unknown;
13
+ };
14
+ gates?: {
15
+ repairSelection?: unknown;
16
+ };
17
+ }): boolean;
8
18
  /** Diagnostic selection only: neither a narrow pass nor this decision can authorize integration.
9
19
  * Distrust and known failing files survive later green results, approvals and resume. A historical
10
20
  * ambiguous failure is never reinterpreted using a newly enabled policy. */
@@ -1,3 +1,8 @@
1
+ /** OBS-1199: repair selection is on unless a config says otherwise, independent of the optional
2
+ * execution budget (which stays subprocess-only). Either explicit `false` turns it off. */
3
+ export function repairSelectionEnabled(cfg) {
4
+ return cfg?.executionPolicy?.repairSelection !== false && cfg?.gates?.repairSelection !== false;
5
+ }
1
6
  /** These are literal repository-relative identities. Existence and selection support are checked
2
7
  * against the current worktree by the gate; this journal fold cannot establish either. */
3
8
  function fileIdentities(value) {
@@ -32,16 +37,19 @@ export function repairSelectionDecision(events, taskId, enabled) {
32
37
  distrust ??= "unattributed-test-failure";
33
38
  continue;
34
39
  }
35
- // A replacement full-suite failure follows a passing screen in the current pipeline. No
36
- // ordinary full-suite failure establishes that a selector safely covers the failed behavior.
37
- if (data.fullSuite === true) {
38
- distrust ??= "full-suite-failure";
39
- continue;
40
- }
41
- const selected = fileIdentities(data.selectedTests);
40
+ // OBS-1199: a full-suite red counts only when it positively says it was full — the replacement
41
+ // after a screen (`fullSuite`), or an ordinary full run whose recorded selection decision says
42
+ // so — and names its failing files. Those files join every later screen even when no selector
43
+ // reaches them; the merge candidate still runs the complete suite. No attribution means full.
42
44
  const failing = fileIdentities(data.failingFiles);
43
- if (!selected || !failing || failing.some((file) => !selected.includes(file))
44
- || (data.fullSuite !== undefined && data.fullSuite !== false)) {
45
+ const selected = fileIdentities(data.selectedTests);
46
+ const decision = data.selectionDecision;
47
+ const replacement = data.fullSuite === true;
48
+ const ordinaryFull = data.fullSuite === undefined && data.selectedTests === undefined
49
+ && typeof decision === "object" && decision !== null && decision.scope === "full";
50
+ const screen = (data.fullSuite === undefined || data.fullSuite === false) && selected !== undefined
51
+ && failing !== undefined && failing.every((file) => selected.includes(file));
52
+ if (!failing || !(replacement || ordinaryFull || screen)) {
45
53
  distrust ??= "unattributed-test-failure";
46
54
  continue;
47
55
  }
@@ -0,0 +1,48 @@
1
+ import type { JournalEvent } from "./journal.js";
2
+ /**
3
+ * OBS-1201: where a run's wall time went. Every instant of the engagement window lands in exactly ONE
4
+ * bucket — the first of WALL_PRIORITY with a span covering it — and what no span covers is residual,
5
+ * so the exposed buckets always sum to the wall. Task-time sums every span as recorded, so concurrent
6
+ * work (two workers, four suites waiting on one lease) stays visible without inflating the wall.
7
+ *
8
+ * Only journaled observations become spans: a fresh gate row's measured duration less the matched
9
+ * suite waits inside it, a worker launch matched to its result, a suite wait matched to its admission,
10
+ * and the gap a restart left between one engagement's last row and the next run-resume. A copy of
11
+ * evidence — a cache reuse, a journal replay, a row naming an invocation already counted — never adds
12
+ * a span; a reused full suite's row still times the fresh screen it carries. A row without a duration, a launch, wait or gate start with no matching end, and one cut by
13
+ * a restart are counted, never timed. Residual
14
+ * is unattributed time, not a claim of idleness. Pure: journal rows in, numbers out.
15
+ */
16
+ export declare const WALL_PRIORITY: readonly ["interruption", "test", "semantics", "worker", "queue", "other-gate"];
17
+ export type WallBucket = (typeof WALL_PRIORITY)[number];
18
+ type PerBucket = Record<WallBucket, number>;
19
+ export interface WallBudget {
20
+ wallMs: number;
21
+ exposedMs: PerBucket;
22
+ residualMs: number;
23
+ taskMs: PerBucket;
24
+ /** Gate-result rows by provenance. A declined gate never ran and is none of these. */
25
+ evidence: {
26
+ fresh: number;
27
+ replay: number;
28
+ reuse: number;
29
+ unknown: number;
30
+ };
31
+ /** Evidence that exists but carries no timing: rows without a duration, unmatched and interrupted starts. */
32
+ untimed: Record<WallBucket, {
33
+ unknown: number;
34
+ unmatched: number;
35
+ interrupted: number;
36
+ }>;
37
+ }
38
+ /** Undefined when the journal names no measurable window (no run-start, or unreadable timestamps). */
39
+ export declare function wallBudget(events: readonly JournalEvent[]): WallBudget | undefined;
40
+ /** Whole seconds past a minute, one decimal below ten seconds — never a rounded-away "0s" for a real span. */
41
+ export declare const formatSpan: (ms: number) => string;
42
+ /**
43
+ * One fact per bucket, then residual and the evidence tally — the words every surface prints. A
44
+ * bucket whose only evidence is untimed reads `unknown`, never a zero it did not measure.
45
+ */
46
+ export declare function wallBudgetFacts(b: WallBudget): Array<[label: string, fact: string]>;
47
+ export declare const WALL_PRIORITY_TEXT: string;
48
+ export {};
@@ -0,0 +1,280 @@
1
+ import { gateDeclined } from "../report/bundle.js";
2
+ /**
3
+ * OBS-1201: where a run's wall time went. Every instant of the engagement window lands in exactly ONE
4
+ * bucket — the first of WALL_PRIORITY with a span covering it — and what no span covers is residual,
5
+ * so the exposed buckets always sum to the wall. Task-time sums every span as recorded, so concurrent
6
+ * work (two workers, four suites waiting on one lease) stays visible without inflating the wall.
7
+ *
8
+ * Only journaled observations become spans: a fresh gate row's measured duration less the matched
9
+ * suite waits inside it, a worker launch matched to its result, a suite wait matched to its admission,
10
+ * and the gap a restart left between one engagement's last row and the next run-resume. A copy of
11
+ * evidence — a cache reuse, a journal replay, a row naming an invocation already counted — never adds
12
+ * a span; a reused full suite's row still times the fresh screen it carries. A row without a duration, a launch, wait or gate start with no matching end, and one cut by
13
+ * a restart are counted, never timed. Residual
14
+ * is unattributed time, not a claim of idleness. Pure: journal rows in, numbers out.
15
+ */
16
+ export const WALL_PRIORITY = ["interruption", "test", "semantics", "worker", "queue", "other-gate"];
17
+ const perBucket = () => Object.fromEntries(WALL_PRIORITY.map((bucket) => [bucket, 0]));
18
+ const at = (e) => Date.parse(e.ts);
19
+ const measured = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
20
+ const bucketOf = (gate) => gate === "test" ? "test" : gate === "acceptance" || gate === "review" ? "semantics" : "other-gate";
21
+ // Rows another process writes around a restart — the operator's approval, the resuming daemon's
22
+ // reclaim and rehash audit — so the prior engagement's own last row is the one before them.
23
+ // ponytail: a closed list; a new out-of-engagement writer lengthens the interruption it precedes.
24
+ const OUTSIDE_ENGAGEMENT = new Set(["task-approved", "graph-rehash", "lock-reclaimed", "superseded"]);
25
+ const outsideEngagement = (e) => OUTSIDE_ENGAGEMENT.has(e.event) || (e.event === "exit-cause" && e.data.cause === "unclean");
26
+ const WORKER_END = new Set(["worker-result", "worker-dead", "worker-hard-timeout"]);
27
+ const invocationOf = (data) => {
28
+ if (typeof data.nonce === "string")
29
+ return data.nonce;
30
+ const receipt = data.evidenceReceipt;
31
+ return typeof receipt?.invocationId === "string" ? receipt.invocationId : undefined;
32
+ };
33
+ /** Undefined when the journal names no measurable window (no run-start, or unreadable timestamps). */
34
+ export function wallBudget(events) {
35
+ const first = events.findIndex((e) => e.event === "run-start");
36
+ if (first < 0)
37
+ return undefined;
38
+ const lastOf = (event) => events.map((e) => e.event).lastIndexOf(event);
39
+ const lastEnd = lastOf("run-end");
40
+ const lastResume = lastOf("run-resume");
41
+ const from = at(events[first]);
42
+ // A closed engagement ends at its run-end; an open or crashed one at its newest row.
43
+ const to = at(events[lastEnd > lastResume ? lastEnd : events.length - 1]);
44
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to < from)
45
+ return undefined;
46
+ const spans = [];
47
+ const span = (bucket, a, b) => {
48
+ const s = Math.max(a, from);
49
+ const t = Math.min(b, to);
50
+ if (Number.isFinite(s) && Number.isFinite(t) && t > s)
51
+ spans.push({ bucket, from: s, to: t });
52
+ };
53
+ const evidence = { fresh: 0, replay: 0, reuse: 0, unknown: 0 };
54
+ const untimed = Object.fromEntries(WALL_PRIORITY.map((bucket) => [bucket, { unknown: 0, unmatched: 0, interrupted: 0 }]));
55
+ const counted = new Set();
56
+ // A gate row is journaled when its whole round settles (acceptance, review and test often share one
57
+ // timestamp), so its measured duration is anchored at the gate's FIRST phase-start in the round.
58
+ const gateStarts = new Map();
59
+ const workers = new Map();
60
+ // A dispatch that never journals a launch (every pre-launch-row journal) ran a worker nobody timed.
61
+ const dispatched = new Set();
62
+ // A wait is owned by the gate whose command was admitted (the daemon names it when the phase is
63
+ // unambiguous; a parallel sibling leaves it absent). Its measurement started before admission, so
64
+ // a matched wait inside that gate's own span is queue, not service, and is cut out before priority
65
+ // applies. Sibling gates keep executing through it, so nothing is cut from them. A wait naming no
66
+ // gate belongs to the suite lease, which is the test gate's.
67
+ const owner = (task, gate) => `${task}\0${typeof gate === "string" ? gate : "test"}`;
68
+ const waits = new Map();
69
+ const queued = new Map();
70
+ const admit = (task, gate, ts) => {
71
+ const key = owner(task, gate);
72
+ const opened = waits.get(key);
73
+ if (opened === undefined)
74
+ return;
75
+ span("queue", opened, ts);
76
+ // One open wait per owner, closed in journal order: the list stays sorted and non-overlapping.
77
+ queued.set(key, [...(queued.get(key) ?? []), [opened, ts]]);
78
+ waits.delete(key);
79
+ };
80
+ const service = (gate, task, a, b) => {
81
+ const bucket = bucketOf(gate);
82
+ // One linear sweep over the sorted waits; span() drops any empty or inverted piece.
83
+ let cursor = a;
84
+ for (const [ws, we] of queued.get(owner(task, gate)) ?? []) {
85
+ if (we <= cursor || ws >= b)
86
+ continue;
87
+ span(bucket, cursor, ws);
88
+ cursor = Math.max(cursor, we);
89
+ }
90
+ span(bucket, cursor, b);
91
+ };
92
+ // A gate that started and never journaled its result is evidence without timing, never a zero.
93
+ const untimedStarts = (starts, kind) => {
94
+ for (const gate of starts?.keys() ?? [])
95
+ untimed[bucketOf(gate)][kind]++;
96
+ };
97
+ for (let i = first + 1; i < events.length; i++) {
98
+ const e = events[i];
99
+ const task = e.taskId ?? "";
100
+ const ts = at(e);
101
+ switch (e.event) {
102
+ case "run-resume": {
103
+ let last = i - 1;
104
+ while (last > first && outsideEngagement(events[last]))
105
+ last--;
106
+ span("interruption", at(events[last]), ts);
107
+ untimed.worker.interrupted += workers.size;
108
+ untimed.worker.unknown += dispatched.size;
109
+ untimed.queue.interrupted += waits.size;
110
+ for (const starts of gateStarts.values())
111
+ untimedStarts(starts, "interrupted");
112
+ workers.clear();
113
+ dispatched.clear();
114
+ waits.clear();
115
+ gateStarts.clear();
116
+ break;
117
+ }
118
+ case "phase-start": {
119
+ if (e.data.phase === "gates") {
120
+ untimedStarts(gateStarts.get(task), "unmatched");
121
+ gateStarts.delete(task);
122
+ }
123
+ else if (typeof e.data.gate === "string") {
124
+ const starts = gateStarts.get(task) ?? new Map();
125
+ if (!starts.has(e.data.gate))
126
+ starts.set(e.data.gate, ts);
127
+ gateStarts.set(task, starts);
128
+ }
129
+ if (e.data.admitted === true)
130
+ admit(task, e.data.gate, ts);
131
+ break;
132
+ }
133
+ case "suite-admitted":
134
+ admit(task, undefined, ts);
135
+ break;
136
+ case "suite-wait":
137
+ // Repeated rows while a wait is open re-report its count; they do not open a second wait.
138
+ if (!waits.has(owner(task, e.data.gate)))
139
+ waits.set(owner(task, e.data.gate), ts);
140
+ break;
141
+ case "task-dispatch":
142
+ if (dispatched.has(task))
143
+ untimed.worker.unknown++;
144
+ dispatched.add(task);
145
+ break;
146
+ case "worker-launch":
147
+ if (workers.has(task))
148
+ untimed.worker.unmatched++;
149
+ workers.set(task, ts);
150
+ dispatched.delete(task);
151
+ break;
152
+ case "gate-provisioned":
153
+ if (measured(e.data.durationMs))
154
+ service(typeof e.data.gate === "string" ? e.data.gate : "build", task, ts - e.data.durationMs, ts);
155
+ break;
156
+ case "gate-result": {
157
+ const gate = e.data.gate;
158
+ if (typeof gate !== "string")
159
+ break;
160
+ const anchor = gateStarts.get(task)?.get(gate);
161
+ gateStarts.get(task)?.delete(gate);
162
+ if (gateDeclined(e.data))
163
+ break;
164
+ const bucket = bucketOf(gate);
165
+ const invocation = invocationOf(e.data);
166
+ const identity = invocation === undefined ? undefined : `${gate}\0${invocation}`;
167
+ if (e.data.reused === true) {
168
+ evidence.reuse++;
169
+ // A held screen runs fresh before its full suite is carried from the cache, and the one
170
+ // merge-candidate row holds both: the screen's measured interval is fresh execution, the
171
+ // carried suite adds none. A replay's copy is not a measurement of this attempt.
172
+ const screen = e.data.fullSuite === true && e.data.replayedFromAttempt === undefined ? e.data.selectedDurationMs : undefined;
173
+ if (!measured(screen) || screen <= 0)
174
+ break;
175
+ evidence.fresh++;
176
+ const screenFrom = anchor ?? ts - screen;
177
+ service(gate, task, screenFrom, Math.min(screenFrom + screen, ts));
178
+ break;
179
+ }
180
+ if (e.data.replayedFromAttempt !== undefined || (identity !== undefined && counted.has(identity))) {
181
+ evidence.replay++;
182
+ break;
183
+ }
184
+ if (identity !== undefined)
185
+ counted.add(identity);
186
+ const d = e.data.durationMs;
187
+ if (!measured(d)) {
188
+ evidence.unknown++;
189
+ untimed[bucket].unknown++;
190
+ break;
191
+ }
192
+ evidence.fresh++;
193
+ const full = e.data.fullSuite === true && measured(e.data.fullDurationMs) && measured(e.data.selectedDurationMs);
194
+ if (anchor === undefined)
195
+ service(gate, task, ts - d, ts);
196
+ else if (full) {
197
+ // The full suite ends at the row; the selected run began at the gate's first phase-start.
198
+ const fullFrom = ts - e.data.fullDurationMs;
199
+ service(gate, task, anchor, Math.min(anchor + e.data.selectedDurationMs, fullFrom));
200
+ service(gate, task, fullFrom, ts);
201
+ }
202
+ else
203
+ service(gate, task, anchor, Math.min(anchor + d, ts));
204
+ break;
205
+ }
206
+ default:
207
+ if (WORKER_END.has(e.event) && workers.has(task)) {
208
+ span("worker", workers.get(task), ts);
209
+ workers.delete(task);
210
+ }
211
+ }
212
+ }
213
+ untimed.worker.unmatched += workers.size;
214
+ untimed.worker.unknown += dispatched.size;
215
+ untimed.queue.unmatched += waits.size;
216
+ for (const starts of gateStarts.values())
217
+ untimedStarts(starts, "unmatched");
218
+ const taskMs = perBucket();
219
+ for (const s of spans)
220
+ taskMs[s.bucket] += s.to - s.from;
221
+ // One sweep over span edges: each segment goes to the highest-priority bucket still open over it.
222
+ const rank = (bucket) => WALL_PRIORITY.indexOf(bucket);
223
+ const edges = spans.flatMap((s) => [[s.from, rank(s.bucket), 1], [s.to, rank(s.bucket), -1]])
224
+ .sort((a, b) => a[0] - b[0]);
225
+ const open = WALL_PRIORITY.map(() => 0);
226
+ const exposedMs = perBucket();
227
+ let cursor = from;
228
+ for (const [t, r, delta] of edges) {
229
+ if (t > cursor) {
230
+ const top = open.findIndex((n) => n > 0);
231
+ if (top >= 0)
232
+ exposedMs[WALL_PRIORITY[top]] += t - cursor;
233
+ cursor = t;
234
+ }
235
+ open[r] += delta;
236
+ }
237
+ const wallMs = to - from;
238
+ const residualMs = wallMs - WALL_PRIORITY.reduce((sum, bucket) => sum + exposedMs[bucket], 0);
239
+ return { wallMs, exposedMs, residualMs, taskMs, evidence, untimed };
240
+ }
241
+ /** Whole seconds past a minute, one decimal below ten seconds — never a rounded-away "0s" for a real span. */
242
+ export const formatSpan = (ms) => {
243
+ if (ms > 0 && ms < 10_000)
244
+ return `${(Math.ceil(ms / 100) / 10).toString()}s`;
245
+ const s = Math.round(ms / 1_000);
246
+ if (s < 60)
247
+ return `${s}s`;
248
+ if (s < 3_600)
249
+ return `${Math.floor(s / 60)}m ${s % 60}s`;
250
+ return `${Math.floor(s / 3_600)}h ${Math.floor((s % 3_600) / 60)}m`;
251
+ };
252
+ const plural = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
253
+ /**
254
+ * One fact per bucket, then residual and the evidence tally — the words every surface prints. A
255
+ * bucket whose only evidence is untimed reads `unknown`, never a zero it did not measure.
256
+ */
257
+ export function wallBudgetFacts(b) {
258
+ const pct = (ms) => b.wallMs > 0 ? ` (${Math.round((100 * ms) / b.wallMs)}%)` : "";
259
+ const facts = WALL_PRIORITY.map((bucket) => {
260
+ const { unknown, unmatched, interrupted } = b.untimed[bucket];
261
+ const notes = [
262
+ ...(unknown ? [bucket === "worker"
263
+ ? `${unknown} ${unknown === 1 ? "dispatch" : "dispatches"} without a launch row`
264
+ : `${plural(unknown, "row")} without a duration`] : []),
265
+ ...(unmatched ? [`${unmatched} unmatched`] : []),
266
+ ...(interrupted ? [`${interrupted} cut by a restart`] : []),
267
+ ];
268
+ if (!b.exposedMs[bucket] && !b.taskMs[bucket] && notes.length)
269
+ return [bucket, `unknown — ${notes.join(" · ")}`];
270
+ const timed = `${formatSpan(b.exposedMs[bucket])}${pct(b.exposedMs[bucket])} · task-time ${formatSpan(b.taskMs[bucket])}`;
271
+ return [bucket, [timed, ...notes.map((note) => `${note}, untimed`)].join(" · ")];
272
+ });
273
+ const { fresh, replay, reuse, unknown } = b.evidence;
274
+ return [
275
+ ...facts,
276
+ ["residual", `${formatSpan(b.residualMs)}${pct(b.residualMs)} — unattributed, not proven idle`],
277
+ ["gate evidence", `fresh ${fresh} · replay ${replay} · reuse ${reuse} · unknown ${unknown}`],
278
+ ];
279
+ }
280
+ export const WALL_PRIORITY_TEXT = [...WALL_PRIORITY, "residual"].join(" › ");
@@ -334,7 +334,7 @@ export function taskProjectionText(row, journal = []) {
334
334
  `blocker ${blocker ? `${blocker.kind} ${at(evidence.blocker)}` : "none"}`,
335
335
  `next ${blocker?.nextAction == null ? fieldReading(undefined) : `${blocker.nextAction} ${at(evidence.nextAction)}`}`,
336
336
  ...(harvest?.suspectedStalledHarvest ? [`${STALL_MARKER} · launch ${at(evidence.launch)} · ${harvest.nudgeFailures} nudge failed ${at(evidence.nudge)} · ${harvest.pageCount} paged ${at(evidence.page)} · no worker-result`] : []),
337
- ].reduce((text, part) => `${text} · ${part}`);
337
+ ].join(" · ");
338
338
  }
339
339
  /**
340
340
  * Identity, phase, and build are unrecorded until a dispatch. Append the blocker and next action
@@ -354,7 +354,7 @@ function undispatchedProjectionText(row, journal) {
354
354
  MISSING_EVIDENCE,
355
355
  ...(showBlocker ? [`blocker ${blocker.kind} ${at(evidence.blocker)}`] : []),
356
356
  ...(showNext ? [`next ${blocker.nextAction} ${at(evidence.nextAction)}`] : []),
357
- ].reduce((text, part) => `${text} · ${part}`);
357
+ ].join(" · ");
358
358
  }
359
359
  /**
360
360
  * Correlate each already-derived field with the row that can have produced that value. This is
@@ -93,8 +93,9 @@ export interface TaskProjection {
93
93
  readonly build: ProjectionField;
94
94
  readonly blocker: ProjectionField;
95
95
  readonly nextAction: ProjectionField;
96
- /** OBS-1048: present only when the newest launched attempt has failed nudges, pages and no worker-result. OBS-1104: neverDispatched collapses that task's line. */
96
+ /** OBS-1048: present only when the newest launched attempt has failed nudges, pages and no worker-result. */
97
97
  readonly stalled?: ProjectionField;
98
+ /** OBS-1104: a task with no recorded dispatch collapses its line to one missing-evidence clause. */
98
99
  readonly neverDispatched?: true;
99
100
  }
100
101
  export declare const STALL_MARKER = "\u26A0 stalled harvest suspected";
@@ -251,8 +251,11 @@ export function projectRunTasks(snapshot, rows, graph, decisions, runId) {
251
251
  const blocker = { label: `blocker ${blk ? `${blk.kind}${blk.diagnostic ? ` · ${blk.diagnostic}` : ""}` : "none"}`, ...(blk && blockerRow ? { line: blockerRow.line } : {}) };
252
252
  const nextAction = { label: `next ${blk?.nextAction ?? "none"}`, ...(blk?.nextAction && blockerRow ? { line: blockerRow.line } : {}) };
253
253
  // OBS-1048 harvest, chronological: the newest worker-launch opens the attempt; a later worker-result retires it.
254
- let launched, launchedAttempt, returned = false;
255
- const nudges = [], pages = [];
254
+ let launched;
255
+ let launchedAttempt;
256
+ let returned = false;
257
+ const nudges = [];
258
+ const pages = [];
256
259
  // Finding 1: only rows of the launched attempt count; a row without an attempt belongs to it (derive.ts attemptHarvests).
257
260
  const ofLaunched = (d) => launched !== undefined && (ordinal(d.attempt) ?? launchedAttempt) === launchedAttempt;
258
261
  for (const r of own) {
@@ -389,15 +392,16 @@ function undispatchedProjectionLine(p) {
389
392
  ].join(" · ");
390
393
  }
391
394
  /**
392
- * Rows the projection panel paints. A never-dispatched clause is shorter than the field line it
393
- * replaces; the shell's content counter is the panel's wrapped height, and the pinned run frames
394
- * record that counter. Blank rows keep the block as tall as the field lines were.
395
+ * Rows the projection panel paints: each task's clause at its own wrapped height (OBS-1193). A
396
+ * never-dispatched clause is shorter than the field line it replaces, so the block is shorter too;
397
+ * the shell's content counter reads that real height and the pinned run frames record it.
395
398
  */
396
399
  function projectionBlockRows(projections, wrap) {
397
- const shown = projections.flatMap((p) => wrap(projectionLine(p)).map((line, i) => ({ key: `${p.taskId}:${i}`, line, strong: p.stalled !== undefined && i === 0 })));
398
- const prior = projections.flatMap((p) => wrap(recordedProjectionLine(p)));
399
- const pad = Math.max(0, prior.length - shown.length);
400
- return [...shown, ...Array.from({ length: pad }, (_, i) => ({ key: `projection-pad:${i}`, line: " ", strong: false }))];
400
+ return projections.flatMap((p) => wrap(projectionLine(p)).map((line, i) => ({
401
+ key: `${p.taskId}:${i}`,
402
+ line,
403
+ strong: p.stalled !== undefined && i === 0,
404
+ })));
401
405
  }
402
406
  /** The Run body: the approved board (BD-1) over the fold, then the selected task's detail panels. */
403
407
  export function RunView({ snapshot, rows, page, graph, decisions, session, columns, run, now = Date.now }) {
@@ -73,6 +73,8 @@ export type ParkedDecision = {
73
73
  readonly tombstone: boolean;
74
74
  /** OBS-1178: the park's `<line>@<ts>` token — the confirmed write binds to it via `--park`. */
75
75
  readonly park?: string;
76
+ /** OBS-1202: a stall park's recorded reap failure — the one stall park recheck may release. */
77
+ readonly reapFailure?: string;
76
78
  };
77
79
  /** The parks a verb can release — the rows the surface draws live verbs on. */
78
80
  export declare function actionableDecisions(decisions: readonly ParkedDecision[]): readonly ParkedDecision[];
@@ -607,6 +607,7 @@ export function deriveParkedDecisions(journal) {
607
607
  failedGate,
608
608
  tombstone: isTombstonePark(kind, reason),
609
609
  ...(typeof parked?.ts === "string" ? { park: bindingToken({ line: lines[parkedIndex], ts: parked.ts }) } : {}),
610
+ ...(kind === "stall" && typeof parked?.data.reapFailure === "string" ? { reapFailure: parked.data.reapFailure } : {}),
610
611
  });
611
612
  }
612
613
  return decisions;
@@ -623,6 +624,9 @@ export function initialSetupDecisionsSession() {
623
624
  export function setupDecisionVerbs(decision) {
624
625
  if (decision.tombstone)
625
626
  return [];
627
+ // OBS-1202: the production table's census-recovery row; an ordinary stall stays approve-only.
628
+ if (decision.kind === "stall" && decision.reapFailure !== undefined)
629
+ return ["approve", "recheck"];
626
630
  if (decision.kind !== "gate-fail")
627
631
  return ["approve"];
628
632
  if (decision.failedGate === undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "2.6.2",
3
+ "version": "2.6.3",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -81,6 +81,7 @@
81
81
  "scripts/emit-schema.ts",
82
82
  "scripts/probe-rig.mjs",
83
83
  "scripts/run-ci-vitest.sh",
84
+ "scripts/vitest-lease.ts",
84
85
  "specs/export-selftest.spec.md"
85
86
  ],
86
87
  "prefixes": [
@@ -10,7 +10,6 @@
10
10
  "type": "boolean"
11
11
  },
12
12
  "repairSelection": {
13
- "default": false,
14
13
  "type": "boolean"
15
14
  },
16
15
  "taskExecutionLimitMs": {
@@ -619,6 +618,14 @@
619
618
  "exclusiveMinimum": 0,
620
619
  "maximum": 9007199254740991
621
620
  },
621
+ "evidenceQuotaBytes": {
622
+ "type": "integer",
623
+ "minimum": 0,
624
+ "maximum": 9007199254740991
625
+ },
626
+ "repairSelection": {
627
+ "type": "boolean"
628
+ },
622
629
  "byShape": {
623
630
  "type": "object",
624
631
  "propertyNames": {
@@ -129,7 +129,13 @@ Run offers only validated park verbs: human/attempt-cap/other non-gate parks all
129
129
  infra allows approve or `--recheck`; review gate-fail allows `--waive`, `--uphold` or
130
130
  `--recheck`; other gate-fail allows waive/recheck. Waive satisfies only the identified
131
131
  failed gate, uphold funds a fixed attempt carrying review findings, and recheck reruns the
132
- declared battery without satisfying a gate. Attempt-cap approval resets the budget while
132
+ declared battery without satisfying a gate. A stall park that recorded a `reapFailure`
133
+ (unreadable or surviving worker census) allows approve or `--recheck --park <line>@<ts>`:
134
+ recheck re-verifies that attempt's owned census and gates its harvested commits with no
135
+ worker only with an explicitly recorded empty survivors array (`[]`) — a missing,
136
+ unreadable or surviving census
137
+ re-parks the stall under a new token — while plain approve dispatches a worker. An
138
+ ordinary stall park (no `reapFailure`) stays approve-only; `--recheck` refuses it. Attempt-cap approval resets the budget while
133
139
  retaining routing exclusions. Tombstones and failures without identified gate evidence
134
140
  are diagnostic-only. Decisions cannot be undone; stale or duplicate decisions refuse.
135
141
 
@@ -44,7 +44,10 @@ awk -v esc="$esc" '
44
44
  gsub(/\^\[\[[0-9;]*m/, "", line); gsub(esc "\\[[0-9;]*m", "", line)
45
45
  sub(/^[^\t]*\t[^\t]*\t[0-9T:.-]+Z[ ]?/, "", line)
46
46
  }
47
- line ~ /Test timed out|Error: Hook timed out/ { timedout++ }
47
+ # The Vitest timeout message carries its millisecond count; a test that PRINTS a fingerprint normalized
48
+ # to #ms (the daemon OBS-1106 notifications) is output, not a timed-out test. A real timeout also fails
49
+ # its test, so the summary failed count still reds it.
50
+ line ~ /(Test|Hook) timed out in [0-9]+ ?ms/ { timedout++ }
48
51
  line ~ /ERROR: Coverage for .* does not meet .*threshold/ { coverage++ }
49
52
  line ~ /^npm (error|ERR!) signal / { signals++ }
50
53
  line ~ /^(⎯)+ Unhandled Errors (⎯)+[ \t]*$/ { if (armed) close_block(); opening = 1; next }