toolroll 0.9.7 → 0.9.9

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 (97) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/dist/assignment-adapters.d.ts +1 -0
  3. package/dist/assignment-adapters.js +2 -0
  4. package/dist/assignment-brief.d.ts +2 -0
  5. package/dist/assignment-brief.js +2 -1
  6. package/dist/assignment-presentation.js +3 -1
  7. package/dist/assignment-status.d.ts +1 -1
  8. package/dist/assignment-ui.js +5 -0
  9. package/dist/assignment.d.ts +15 -0
  10. package/dist/assignment.js +56 -9
  11. package/dist/browser/workspace.css +1 -1
  12. package/dist/browser/workspace.js +18 -18
  13. package/dist/browser-crew.js +4 -0
  14. package/dist/browser-workspace.d.ts +40 -0
  15. package/dist/build-review.d.ts +38 -0
  16. package/dist/build-review.js +341 -0
  17. package/dist/builder.d.ts +2 -0
  18. package/dist/builder.js +114 -8
  19. package/dist/chat-controls.d.ts +1 -1
  20. package/dist/chat-controls.js +1 -1
  21. package/dist/check-levels-ui.d.ts +5 -0
  22. package/dist/check-levels-ui.js +16 -2
  23. package/dist/check-progress.js +4 -3
  24. package/dist/checkout-cleanup.d.ts +40 -2
  25. package/dist/checkout-cleanup.js +129 -9
  26. package/dist/cli.js +2 -0
  27. package/dist/desktop-update.d.ts +52 -0
  28. package/dist/desktop-update.js +147 -5
  29. package/dist/dispatch.js +2 -1
  30. package/dist/dispose.d.ts +4 -3
  31. package/dist/dispose.js +12 -4
  32. package/dist/evidence.d.ts +6 -0
  33. package/dist/evidence.js +8 -0
  34. package/dist/exec.d.ts +5 -0
  35. package/dist/exec.js +30 -4
  36. package/dist/first-run.d.ts +1 -1
  37. package/dist/first-run.js +8 -6
  38. package/dist/invoke.js +5 -0
  39. package/dist/lead-context.d.ts +1 -1
  40. package/dist/lead-context.js +4 -2
  41. package/dist/lead-status.d.ts +10 -1
  42. package/dist/lead-status.js +21 -24
  43. package/dist/modes.d.ts +3 -1
  44. package/dist/modes.js +3 -1
  45. package/dist/needs-you.d.ts +55 -0
  46. package/dist/needs-you.js +83 -0
  47. package/dist/operate.d.ts +2 -0
  48. package/dist/operate.js +231 -36
  49. package/dist/plane-review.js +4 -0
  50. package/dist/process-custody.js +8 -0
  51. package/dist/process-recovery-eligibility.d.ts +1 -0
  52. package/dist/project-learning.d.ts +3 -3
  53. package/dist/project-learning.js +28 -17
  54. package/dist/proof.d.ts +3 -1
  55. package/dist/proof.js +4 -2
  56. package/dist/provider.js +16 -0
  57. package/dist/result-completion.d.ts +4 -0
  58. package/dist/result-completion.js +11 -0
  59. package/dist/retention-ui.d.ts +3 -1
  60. package/dist/retention-ui.js +4 -4
  61. package/dist/retention.d.ts +13 -6
  62. package/dist/retention.js +50 -12
  63. package/dist/review-context.d.ts +3 -0
  64. package/dist/review-context.js +45 -5
  65. package/dist/review-switch.d.ts +41 -0
  66. package/dist/review-switch.js +88 -0
  67. package/dist/reviewer.d.ts +24 -2
  68. package/dist/reviewer.js +49 -1
  69. package/dist/run-closeout.d.ts +89 -0
  70. package/dist/run-closeout.js +302 -0
  71. package/dist/serve.d.ts +15 -1
  72. package/dist/serve.js +158 -18
  73. package/dist/setup-guide.d.ts +2 -2
  74. package/dist/shared-deps.d.ts +94 -0
  75. package/dist/shared-deps.js +365 -0
  76. package/dist/storage.js +2 -0
  77. package/dist/store.d.ts +72 -1
  78. package/dist/store.js +258 -31
  79. package/dist/surface.js +5 -1
  80. package/dist/task-status.d.ts +22 -5
  81. package/dist/task-status.js +45 -24
  82. package/dist/telegram-progress.js +2 -1
  83. package/dist/telegram-status.js +3 -3
  84. package/dist/test-temp.d.ts +19 -0
  85. package/dist/test-temp.js +98 -0
  86. package/dist/toolroll-update-ui.js +7 -2
  87. package/dist/toolroll-update.d.ts +21 -1
  88. package/dist/toolroll-update.js +135 -5
  89. package/dist/work-index.d.ts +11 -0
  90. package/dist/work-index.js +103 -16
  91. package/dist/work-summary.d.ts +3 -1
  92. package/dist/work-summary.js +2 -1
  93. package/dist/workspace-ui.d.ts +3 -1
  94. package/dist/workspace-ui.js +10 -9
  95. package/dist/worktree.d.ts +16 -0
  96. package/dist/worktree.js +47 -0
  97. package/package.json +1 -1
@@ -27,6 +27,10 @@ export type UpdateJournal = {
27
27
  serviceInterrupted?: boolean;
28
28
  replacementOccurred?: boolean;
29
29
  retryableRecovery?: boolean;
30
+ /** The finished run the update is waiting on (or stopped waiting on). Absent while it waits on ordinary work. */
31
+ waiting?: LingeringRun & {
32
+ since: string;
33
+ };
30
34
  };
31
35
  type ServiceState = {
32
36
  state: string;
@@ -39,6 +43,8 @@ export type UpdateHooks = {
39
43
  healthy?: (bundle: DesktopBundle, journal: UpdateJournal, since: string) => Promise<boolean>;
40
44
  swap?: (journal: UpdateJournal) => Promise<void>;
41
45
  sleep?: (ms: number) => Promise<void>;
46
+ /** The clock the drain's two-minute limit reads (tests). */
47
+ now?: () => Date;
42
48
  healthTimeoutMs?: number;
43
49
  /** Fault injection for state-machine tests, never selectable by a CLI flag. */
44
50
  checkpoint?: (phase: Phase) => void;
@@ -48,7 +54,53 @@ export declare const updateTerminal: (phase: string) => boolean;
48
54
  /** Durable receipts are private and independent of the app being replaced. */
49
55
  export declare function durableJson(file: string, value: unknown): void;
50
56
  export declare function readUpdateJournal(stateDir: string, retainedReceipt?: string): UpdateJournal | null;
57
+ /** A finished run whose end Toolroll cannot show yet: in plain words, with the one command that clears it when a
58
+ * person can. */
59
+ export type LingeringRun = {
60
+ run: number;
61
+ on: string;
62
+ action: string | null;
63
+ };
64
+ /** How long an update waits on a finished run it cannot show has ended before it stops waiting and says why. */
65
+ export declare const LINGERING_LIMIT_MS = 120000;
66
+ export declare function lingeringRun(db: DatabaseSync): LingeringRun | null;
51
67
  export declare function lingeringWork(db: DatabaseSync): string | null;
68
+ export declare const lingeringWords: (l: LingeringRun) => string;
69
+ /** What an update says when it stops waiting on `l`: what is in the way, the command, and that new work resumed. */
70
+ export declare const stoppedWaitingWords: (l: LingeringRun) => string;
71
+ /** The next step once an update has stopped on `l`. */
72
+ export declare const clearWords: (l: LingeringRun) => string;
73
+ /** An update that is waiting or stopped, what on, and the one action that clears it: for `toolroll status` and the
74
+ * console. `app`: the desktop app's update rather than `toolroll update`. */
75
+ export type UpdateWaiting = {
76
+ app: boolean;
77
+ version: string;
78
+ stopped: boolean;
79
+ run: number | null;
80
+ on: string;
81
+ action: string | null;
82
+ words: string;
83
+ short: string;
84
+ };
85
+ export declare function updateWaitingWords(o: {
86
+ app: boolean;
87
+ version: string;
88
+ stopped: boolean;
89
+ lingering: LingeringRun | null;
90
+ }): UpdateWaiting;
91
+ /** An app updater that ended while letting work finish, with no automatic recovery coming to resume it, leaves new
92
+ * work paused with nobody left to lift the pause. Nothing was stopped or swapped yet, so the pause is lifted here and
93
+ * the update says it stopped. Returns what it said, or null. */
94
+ export declare function releaseStalledDesktopUpdate(stateDir: string): string | null;
95
+ /** The desktop app's update, when it is waiting on work in `databaseFile`'s installation, or stopped on a run still in
96
+ * the way. An app updater that ended mid-drain is released first, as `toolroll update`'s is. */
97
+ export declare function desktopUpdateWaitingOf(databaseFile: string, inTheWay: (run: number) => boolean, stateDirs?: readonly string[]): UpdateWaiting | null;
98
+ /** The same settling reconcile the background worker runs: a finished run's leftover record of a process that never
99
+ * started settles once the run's process groups are proven gone. A record nothing can prove either way stays. */
100
+ export declare function settleLeftoverRecords(db: DatabaseSync, now: Date): number;
101
+ /** Store.stopQuiescenceProblem's reasons, said for a person: no internal terms, and `toolroll run settle` only where it
102
+ * can clear the record (it refuses while anything of the run is alive). */
103
+ export declare function plainLingering(fallbackRun: number, problem: string): LingeringRun;
52
104
  export declare function previewDesktopUpdate(stateDir: string, installed: string, candidate: string, label: string, hooks?: UpdateHooks): Promise<{
53
105
  old: DesktopBundle;
54
106
  next: DesktopBundle;
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { closeSync, chmodSync, copyFileSync, cpSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
3
4
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
5
  import { createRequire } from "node:module";
5
6
  import { bundleHash, readDesktopBundle, verifyDesktopUpdateBundles } from "./desktop-bundle.js";
@@ -139,15 +140,135 @@ async function otherControllers(app, stateDir) {
139
140
  const command = `${join(resources(app), "runtime", "node")} ${join(resources(app), "dist", "desktop-host.js")} serve --state `;
140
141
  return result.stdout.split("\n").some(line => line.trim().startsWith(command) && line.trim() !== command + resolve(stateDir));
141
142
  }
142
- export function lingeringWork(db) {
143
+ /** How long an update waits on a finished run it cannot show has ended before it stops waiting and says why. */
144
+ export const LINGERING_LIMIT_MS = 120_000;
145
+ export function lingeringRun(db) {
143
146
  const store = new Store(db);
144
147
  for (const row of db.prepare("SELECT DISTINCT run FROM run_process WHERE exited_at IS NULL").all()) {
145
148
  const problem = store.stopQuiescenceProblem(Number(row.run));
146
149
  if (problem)
147
- return problem;
150
+ return plainLingering(Number(row.run), problem);
148
151
  }
149
152
  return null;
150
153
  }
154
+ export function lingeringWork(db) {
155
+ const lingering = lingeringRun(db);
156
+ return lingering && lingeringWords(lingering);
157
+ }
158
+ export const lingeringWords = (l) => l.action ? `${l.on}. If nothing of it is running, run: ${l.action}` : `${l.on}.`;
159
+ /** What an update says when it stops waiting on `l`: what is in the way, the command, and that new work resumed. */
160
+ export const stoppedWaitingWords = (l) => `Stopped waiting after 2 minutes. ${l.on}. ${clearWords(l)} New work resumed; nothing was changed.`;
161
+ /** The next step once an update has stopped on `l`. */
162
+ export const clearWords = (l) => l.action ? `If nothing of it is running, run ${l.action}, then update again.` : "Update again once it has stopped.";
163
+ export function updateWaitingWords(o) {
164
+ const { app, version, stopped, lingering: l } = o;
165
+ const update = `${app ? "App update" : "Update"} to ${version}`;
166
+ if (!l) {
167
+ const on = "running work to finish";
168
+ const words = `${update} is waiting for ${on}. New work is paused.`;
169
+ return { app, version, stopped: false, run: null, on, action: null, words, short: words };
170
+ }
171
+ const next = l.action ? ` If nothing of it is running, run ${l.action}${stopped ? ", then update again" : ""}.` : stopped ? " Update again once it has stopped." : "";
172
+ return { app, version, stopped, run: l.run, on: l.on, action: l.action,
173
+ words: `${update} ${stopped ? "stopped waiting" : "is waiting"}: ${l.on}.${next}`,
174
+ short: `${update} ${stopped ? `stopped: run #${l.run} is in the way` : `is waiting on run #${l.run}`}.${next}` };
175
+ }
176
+ /** An app updater that ended while letting work finish, with no automatic recovery coming to resume it, leaves new
177
+ * work paused with nobody left to lift the pause. Nothing was stopped or swapped yet, so the pause is lifted here and
178
+ * the update says it stopped. Returns what it said, or null. */
179
+ export function releaseStalledDesktopUpdate(stateDir) {
180
+ let j;
181
+ try {
182
+ j = readUpdateJournal(stateDir);
183
+ }
184
+ catch {
185
+ return null;
186
+ }
187
+ if (!j || j.phase !== "draining" || j.serviceInterrupted || j.replacementOccurred)
188
+ return null;
189
+ const status = desktopUpdateStatus(stateDir);
190
+ if (status.running || status.automaticRecovery)
191
+ return null;
192
+ const lock = workerLock(j);
193
+ if (!lock)
194
+ return null;
195
+ try {
196
+ const held = readUpdateJournal(stateDir);
197
+ if (!held || held.id !== j.id || held.phase !== "draining" || held.serviceInterrupted || held.replacementOccurred || bundleHash(held.old.path) !== held.old.hash)
198
+ return null;
199
+ const db = connect(held.databaseFile);
200
+ try {
201
+ removeUpdateGate(db, held.id);
202
+ }
203
+ finally {
204
+ db.close();
205
+ }
206
+ const words = `The app update to ${held.next.version} stopped: its updater ended before running work finished. Nothing was changed; new work resumed. Update again from the app.`;
207
+ held.error = "The updater ended while letting work finish.";
208
+ save(held, "cancelled", words);
209
+ return words;
210
+ }
211
+ finally {
212
+ lock.close();
213
+ }
214
+ }
215
+ /** The desktop app's update, when it is waiting on work in `databaseFile`'s installation, or stopped on a run still in
216
+ * the way. An app updater that ended mid-drain is released first, as `toolroll update`'s is. */
217
+ export function desktopUpdateWaitingOf(databaseFile, inTheWay, stateDirs = [dirname(databaseFile), join(homedir(), "Library", "Application Support", "Standing Orders")]) {
218
+ for (const stateDir of new Set(stateDirs.map(dir => resolve(dir)))) {
219
+ let j;
220
+ try {
221
+ j = readUpdateJournal(stateDir);
222
+ }
223
+ catch {
224
+ continue;
225
+ }
226
+ if (!j || resolve(j.databaseFile) !== resolve(databaseFile))
227
+ continue;
228
+ try {
229
+ if (releaseStalledDesktopUpdate(stateDir))
230
+ j = readUpdateJournal(stateDir);
231
+ }
232
+ catch { /* shown as last saved */ }
233
+ const version = j.next.version;
234
+ if (j.phase === "draining")
235
+ return updateWaitingWords({ app: true, version, stopped: false, lingering: j.waiting ?? null });
236
+ if (j.phase === "cancelled" && j.waiting && inTheWay(j.waiting.run))
237
+ return updateWaitingWords({ app: true, version, stopped: true, lingering: j.waiting });
238
+ }
239
+ return null;
240
+ }
241
+ /** The same settling reconcile the background worker runs: a finished run's leftover record of a process that never
242
+ * started settles once the run's process groups are proven gone. A record nothing can prove either way stays. */
243
+ export function settleLeftoverRecords(db, now) {
244
+ return new Store(db).settleUnspawnedWitnesses(now);
245
+ }
246
+ /** Store.stopQuiescenceProblem's reasons, said for a person: no internal terms, and `toolroll run settle` only where it
247
+ * can clear the record (it refuses while anything of the run is alive). */
248
+ export function plainLingering(fallbackRun, problem) {
249
+ const run = Number(/run #(\d+)/.exec(problem)?.[1] ?? fallbackRun);
250
+ const settle = `toolroll run settle ${run} --why "it is not running"`;
251
+ const pid = /process (\d+)/.exec(problem)?.[1];
252
+ if (/incomplete spawn witness|before its process witness was recorded/.test(problem))
253
+ return { run, on: `Run #${run} finished, but Toolroll has no process ID for one of its processes, so it can't confirm that process ended`, action: settle };
254
+ if (/belongs to another host|another host/.test(problem))
255
+ return { run, on: `Run #${run} finished on another computer, and Toolroll can only confirm its processes ended there`, action: `${settle} (on that computer)` };
256
+ if (/incomplete native containment custody|cannot be proven empty/.test(problem))
257
+ return { run, on: `Run #${run} finished, but Toolroll can't confirm its sandbox is empty`, action: settle };
258
+ if (/still has members/.test(problem))
259
+ return { run, on: `Run #${run} finished, but processes are still running in its sandbox`, action: null };
260
+ if (/workspace is still held/.test(problem))
261
+ return { run, on: `Run #${run} finished, but process ${pid ?? "?"} is still using its folder`, action: null };
262
+ if (/workspace occupancy/.test(problem))
263
+ return { run, on: `Run #${run} finished, but Toolroll can't check whether its folder is still in use`, action: null };
264
+ if (/may still be running|owned subprocess/.test(problem))
265
+ return { run, on: `Run #${run} finished, but ${pid ? `its process ${pid}` : "one of its processes"} is still running`, action: null };
266
+ if (/held supervisor/.test(problem))
267
+ return { run, on: `Run #${run} is still shutting down`, action: null };
268
+ if (/still open/.test(problem))
269
+ return { run, on: `Run #${run} is still running`, action: null };
270
+ return { run, on: `Run #${run} hasn't finished shutting down`, action: null };
271
+ }
151
272
  export async function previewDesktopUpdate(stateDir, installed, candidate, label, hooks = {}) {
152
273
  const config = readDesktopConfig(stateDir), old = readDesktopBundle(resolve(installed)), next = readDesktopBundle(resolve(candidate));
153
274
  if (!/^[a-zA-Z0-9.-]+$/.test(label))
@@ -634,6 +755,7 @@ export async function runDesktopUpdate(stateDir, hooks = {}) {
634
755
  return;
635
756
  }
636
757
  checkpoint("draining", "Waiting for current work to finish. New work is paused; no task will be killed for this update.");
758
+ const now = hooks.now ?? (() => new Date());
637
759
  for (;;) {
638
760
  if (restoreRequested(j) || updateStopRequested(j)) {
639
761
  await cancel();
@@ -642,16 +764,36 @@ export async function runDesktopUpdate(stateDir, hooks = {}) {
642
764
  assertConfig(j);
643
765
  assertCodingCatalogPresent(j);
644
766
  const checking = connect(j.databaseFile);
767
+ let stuck = null;
645
768
  try {
646
769
  const active = activeUpdateWork(checking);
647
- const lingering = Object.values(active).every(n => n === 0) ? lingeringWork(checking) : null;
648
- if (!lingering && Object.values(active).every(n => n === 0) && freezeUpdateGate(checking, j.id))
770
+ const idle = Object.values(active).every(n => n === 0);
771
+ // A finished run's leftover record settles here first, as the background worker would settle it.
772
+ if (idle)
773
+ settleLeftoverRecords(checking, now());
774
+ const lingering = idle ? lingeringRun(checking) : null;
775
+ if (!lingering && idle && freezeUpdateGate(checking, j.id)) {
776
+ delete j.waiting;
649
777
  break;
650
- save(j, "draining", lingering ? `Waiting for a worker to finish shutdown: ${lingering}. No process is being killed.` : `Waiting for current work: ${active.runs} runs, ${active.claims} leases, ${active.conversations} chat requests, ${active.sessions} sessions, ${active.stopping} shutdowns, ${active.coding} coding sessions, ${active.codingDeliveries} unconfirmed messages. Nothing is being cancelled.`);
778
+ }
779
+ // Saved, so a resumed updater keeps counting from when it began, and status can say what it waits on.
780
+ if (!lingering)
781
+ delete j.waiting;
782
+ else if (j.waiting?.run !== lingering.run || j.waiting.on !== lingering.on)
783
+ j.waiting = { ...lingering, since: now().toISOString() };
784
+ if (j.waiting && now().getTime() - Date.parse(j.waiting.since) >= LINGERING_LIMIT_MS)
785
+ stuck = lingering;
786
+ else
787
+ save(j, "draining", lingering ? `New work is paused. Waiting for run #${lingering.run}: ${lingeringWords(lingering)} No process is being killed.` : `Waiting for current work: ${active.runs} runs, ${active.claims} leases, ${active.conversations} chat requests, ${active.sessions} sessions, ${active.stopping} shutdowns, ${active.coding} coding sessions, ${active.codingDeliveries} unconfirmed messages. Nothing is being cancelled.`);
651
788
  }
652
789
  finally {
653
790
  checking.close();
654
791
  }
792
+ // Waiting longer proves nothing more: new work resumes, and the one thing in the way is named.
793
+ if (stuck) {
794
+ await release("cancelled", stoppedWaitingWords(stuck));
795
+ return;
796
+ }
655
797
  assertCodingCatalogPresent(j);
656
798
  await sleep(1000);
657
799
  }
package/dist/dispatch.js CHANGED
@@ -13,6 +13,7 @@ import { plannerSourceProblemOf } from "./planner-source.js";
13
13
  import { authPauseOf, authWaitOf, providerName, signInReason } from "./provider-auth.js";
14
14
  import { REVIEW_TOKENS, resultStatusOf, reviewFactsOf } from "./workspace-ui.js";
15
15
  import { replacedWords } from "./task-status.js";
16
+ import { buildReviewOf } from "./review-switch.js";
16
17
  import { BUILT_IN, parseCapabilityKey } from "./store.js";
17
18
  export const DEFAULT_MAX_OPEN_DECISIONS = 5;
18
19
  /**
@@ -187,7 +188,7 @@ export function diagnoseTaskDispatch(store, taskId, now) {
187
188
  const reviewFacts = reviewFactsOf(retry, runner => {
188
189
  const reviewer = store.getRunner(runner)?.runner;
189
190
  return reviewer !== undefined && isAlive(reviewer, now);
190
- });
191
+ }, result !== undefined && buildReviewOf(store, result.id)?.state === "pending");
191
192
  // A no-change conclusion owes no proof beyond its handoff and sealed
192
193
  // diff (the task page's own "attested floor"); with both on record it
193
194
  // is complete, not verification-needed.
package/dist/dispose.d.ts CHANGED
@@ -85,9 +85,10 @@ export type Disposition = {
85
85
  kind: "invariant";
86
86
  reason: string;
87
87
  };
88
- /** Compatibility for historical integrations: finished work is handed to the
89
- * lead. No model review is requested and no signed history is rewritten. */
90
- export declare function maybeRequestAutoReview(_store: Store, _repo: string, _runId: number, _committed: boolean, _noChange: boolean, _now: Date): void;
88
+ /** A committed build whose approved check passed gets the project's one
89
+ * automatic review when its review switch is on (build-review.ts). No-change
90
+ * results and failed checks go straight to the person, as before. */
91
+ export declare function maybeRequestAutoReview(store: Store, repo: string, runId: number, committed: boolean, noChange: boolean, now: Date, root?: string): void;
91
92
  export declare function disposeBuildOutcome(context: DisposeContext, result: BuildResult): Disposition;
92
93
  /**
93
94
  * The stale-approval HOLD, shared by the post-build disposition above and
package/dist/dispose.js CHANGED
@@ -17,7 +17,9 @@ import { createHash } from "node:crypto";
17
17
  import { completeFenced, finalizeFailureFenced, finalizeHandBackFenced, finalizeMalformedFenced, finalizeParkFenced, interruptIfStopped, release, } from "./claim.js";
18
18
  import { bodyHashOf, publicationBody } from "./publish.js";
19
19
  import { modeTermsFromJson } from "./modes.js";
20
- import { readVerifiedArtifact, writeEvidenceFile } from "./evidence.js";
20
+ import { evidenceRoot, readVerifiedArtifact, writeEvidenceFile } from "./evidence.js";
21
+ import { queueBuildReview } from "./build-review.js";
22
+ import { homedir } from "node:os";
21
23
  import {} from "./proof.js";
22
24
  import { verificationEvidence, failedVerificationEvidence } from "./verification-evidence.js";
23
25
  import { classifyGateFailure, describeGateFailure, gateFailureSummary } from "./gate-failure.js";
@@ -55,9 +57,14 @@ const STANDALONE_BROKE_REASONS = new Set([
55
57
  "timeout",
56
58
  "git",
57
59
  ]);
58
- /** Compatibility for historical integrations: finished work is handed to the
59
- * lead. No model review is requested and no signed history is rewritten. */
60
- export function maybeRequestAutoReview(_store, _repo, _runId, _committed, _noChange, _now) { }
60
+ /** A committed build whose approved check passed gets the project's one
61
+ * automatic review when its review switch is on (build-review.ts). No-change
62
+ * results and failed checks go straight to the person, as before. */
63
+ export function maybeRequestAutoReview(store, repo, runId, committed, noChange, now, root = evidenceRoot(homedir())) {
64
+ if (!committed || noChange)
65
+ return;
66
+ queueBuildReview(store, repo, runId, root, now);
67
+ }
61
68
  export function disposeBuildOutcome(context, result) {
62
69
  // Stop and every terminal side effect compete under the same SQLite
63
70
  // write lock. A separate preliminary transaction leaves a completion gap.
@@ -238,6 +245,7 @@ function disposeBuildOutcomeLocked(context, result) {
238
245
  // proves the credential, clearing any quota stamp.
239
246
  store.resetStrikes(taskRef);
240
247
  store.clearQuota(runner, provider, model ?? "");
248
+ maybeRequestAutoReview(store, repo, runId, result.committed, result.noChange === true, clock(), context.evidenceRoot);
241
249
  return { kind: "built", committed: result.committed, noChange: result.noChange === true };
242
250
  }
243
251
  store.transact(() => {
@@ -50,6 +50,12 @@ export declare const RETENTION_NOTE = "REMOVED-BY-RETENTION";
50
50
  /** Bounds on a claimed screenshot file's own bytes — independent of the
51
51
  * proof manifest's byte cap, since these are binary images, not JSON. */
52
52
  export declare const SCREENSHOT_BYTE_CAP: number;
53
+ /** Check evidence lives in the run's evidence, never on the branch: images a
54
+ * build adds under this folder are left out of its commit. */
55
+ export declare const WORKTREE_EVIDENCE_DIR = "evidence/";
56
+ export declare function isImagePath(path: string): boolean;
57
+ /** Images and other files that are never read as text, judged by name alone. */
58
+ export declare function isBinaryAssetPath(path: string): boolean;
53
59
  /** Bytes each kind may store. Originals can be any size; the record says what was cut. */
54
60
  /** Bound one captured stream to `cap` bytes keeping its beginning and, mostly,
55
61
  * its end: a test runner's summary and failure list come last, and that is
package/dist/evidence.js CHANGED
@@ -55,6 +55,14 @@ export const RETENTION_NOTE = "REMOVED-BY-RETENTION";
55
55
  /** Bounds on a claimed screenshot file's own bytes — independent of the
56
56
  * proof manifest's byte cap, since these are binary images, not JSON. */
57
57
  export const SCREENSHOT_BYTE_CAP = 5 * 1024 * 1024;
58
+ /** Check evidence lives in the run's evidence, never on the branch: images a
59
+ * build adds under this folder are left out of its commit. */
60
+ export const WORKTREE_EVIDENCE_DIR = "evidence/";
61
+ const IMAGE_PATH = /\.(?:png|jpe?g|gif|webp|bmp|ico|tiff?|avif|heic)$/i;
62
+ const BINARY_PATH = /\.(?:pdf|zip|gz|tgz|tar|7z|woff2?|ttf|otf|eot|mp4|mov|webm|mp3|wav|sqlite3?|db|wasm|bin|jar|exe|dll|so|dylib)$/i;
63
+ export function isImagePath(path) { return IMAGE_PATH.test(path); }
64
+ /** Images and other files that are never read as text, judged by name alone. */
65
+ export function isBinaryAssetPath(path) { return IMAGE_PATH.test(path) || BINARY_PATH.test(path); }
58
66
  /** Bytes each kind may store. Originals can be any size; the record says what was cut. */
59
67
  /** Bound one captured stream to `cap` bytes keeping its beginning and, mostly,
60
68
  * its end: a test runner's summary and failure list come last, and that is
package/dist/exec.d.ts CHANGED
@@ -113,6 +113,11 @@ export type RunOptions = ProcessTreeObserver & {
113
113
  /** Fires once the child exists, with its pid (the process-group id when
114
114
  * processGroup is set) — the slot ledger records it (v14 finding 26). */
115
115
  onSpawn?: (pid: number) => void;
116
+ /** Fires instead of onSpawn when a spawn that beforeSpawn admitted made no
117
+ * process at all (the OS call threw — ENOENT, EAGAIN — returned no pid, or
118
+ * its OS object could not be made), so its reserved witness can be settled
119
+ * as never started in the same step. Never fires once a child exists. */
120
+ onSpawnFailed?: () => void;
116
121
  /** Fires before any target spawn, so a crash cannot lose its OS object. */
117
122
  onContainer?: (info: {
118
123
  backend: ContainmentBackendId;
package/dist/exec.js CHANGED
@@ -84,9 +84,24 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
84
84
  throw new ContainmentRefusal(effective.refusal);
85
85
  if (bag.beforeSpawn?.() === false)
86
86
  throw new Error("the attempt stopped before this process could spawn");
87
- const made = effective === null ? { container: null } : createContainer(effective, label);
88
- if ("refused" in made)
87
+ // From here a witness may be reserved: every road that makes no process
88
+ // says so through onSpawnFailed before it returns or throws.
89
+ const neverStarted = () => { try {
90
+ bag.onSpawnFailed?.();
91
+ }
92
+ catch { /* the reservation stays unproven */ } };
93
+ let made;
94
+ try {
95
+ made = effective === null ? { container: null } : createContainer(effective, label);
96
+ }
97
+ catch (error) {
98
+ neverStarted();
99
+ throw error;
100
+ }
101
+ if ("refused" in made) {
102
+ neverStarted();
89
103
  throw new ContainmentRefusal(made.refused);
104
+ }
90
105
  const container = made.container;
91
106
  try {
92
107
  if (container !== null)
@@ -94,6 +109,7 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
94
109
  }
95
110
  catch (error) {
96
111
  container?.release();
112
+ neverStarted();
97
113
  throw error;
98
114
  }
99
115
  let launch;
@@ -102,6 +118,7 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
102
118
  }
103
119
  catch (error) {
104
120
  container?.release();
121
+ neverStarted();
105
122
  throw error;
106
123
  }
107
124
  let child;
@@ -123,6 +140,7 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
123
140
  }
124
141
  catch { }
125
142
  container?.release();
143
+ neverStarted();
126
144
  throw error;
127
145
  }
128
146
  if (container !== null) {
@@ -143,6 +161,12 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
143
161
  if (child.pid !== undefined) {
144
162
  bag.onSpawn?.(child.pid);
145
163
  }
164
+ else if (container === null) {
165
+ // No pid: the OS refused the spawn (ENOENT, EACCES) and only an
166
+ // 'error' event follows. A contained spawn settles once its object is
167
+ // proven empty, and its transport's return finishes the witness.
168
+ neverStarted();
169
+ }
146
170
  }
147
171
  catch (error) {
148
172
  throw new SpawnCustodyFailure(child, error);
@@ -201,7 +225,7 @@ function containmentOf(container, empty, attachFailure) {
201
225
  }
202
226
  /** The streaming transports' spawn: contained when the spawn is a provider (process group), plain otherwise. */
203
227
  function spawnStream(file, args, options, stdio, childEnv) {
204
- return spawnContained(file, args, { cwd: options.cwd, env: childEnv, stdio, detached: options.processGroup === true && process.platform !== "win32" }, options.owner ?? "stream", { beforeSpawn: options.beforeSpawn, onSpawn: options.onSpawn, onContainer: options.onContainer, onContainerEmpty: options.onContainerEmpty, onUnknown: options.onUnknown, fence: options.fence }, options.processGroup === true);
228
+ return spawnContained(file, args, { cwd: options.cwd, env: childEnv, stdio, detached: options.processGroup === true && process.platform !== "win32" }, options.owner ?? "stream", { beforeSpawn: options.beforeSpawn, onSpawn: options.onSpawn, onSpawnFailed: options.onSpawnFailed, onContainer: options.onContainer, onContainerEmpty: options.onContainerEmpty, onUnknown: options.onUnknown, fence: options.fence }, options.processGroup === true);
205
229
  }
206
230
  /** The refusal-before-spawn shape shared by every containable transport. */
207
231
  function refusedResult(error) {
@@ -506,6 +530,7 @@ export function run(file, args, options = {}) {
506
530
  ...(cwd === undefined ? {} : { cwd }),
507
531
  ...(childEnv === undefined ? {} : { childEnv }),
508
532
  ...(options.onSpawn === undefined ? {} : { onSpawn: options.onSpawn }),
533
+ ...(options.onSpawnFailed === undefined ? {} : { onSpawnFailed: options.onSpawnFailed }),
509
534
  ...(options.owner === undefined ? {} : { owner: options.owner }),
510
535
  ...(options.beforeSpawn === undefined ? {} : { beforeSpawn: options.beforeSpawn }),
511
536
  ...(options.onStdout === undefined ? {} : { onStdout: options.onStdout }),
@@ -553,7 +578,7 @@ function runBufferedGroup(file, args, bag) {
553
578
  let container = null;
554
579
  let attached;
555
580
  try {
556
- const spawned = spawnContained(file, args, { cwd: bag.cwd, env: bag.childEnv, stdio: [bag.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], detached: process.platform !== "win32" }, bag.owner ?? "buffered", { beforeSpawn: bag.beforeSpawn, onSpawn: bag.onSpawn, onContainer: bag.onContainer, onContainerEmpty: bag.onContainerEmpty, onUnknown: bag.onUnknown, fence: bag.fence });
581
+ const spawned = spawnContained(file, args, { cwd: bag.cwd, env: bag.childEnv, stdio: [bag.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], detached: process.platform !== "win32" }, bag.owner ?? "buffered", { beforeSpawn: bag.beforeSpawn, onSpawn: bag.onSpawn, onSpawnFailed: bag.onSpawnFailed, onContainer: bag.onContainer, onContainerEmpty: bag.onContainerEmpty, onUnknown: bag.onUnknown, fence: bag.fence });
557
582
  child = spawned.child;
558
583
  container = spawned.container;
559
584
  attached = spawned.attached;
@@ -1578,6 +1603,7 @@ export function startClaudeHeldSession(file, args, options) {
1578
1603
  }, options.owner ?? "held", {
1579
1604
  beforeSpawn: options.beforeSpawn,
1580
1605
  onSpawn: options.onSpawn,
1606
+ onSpawnFailed: options.onSpawnFailed,
1581
1607
  onContainer: options.onContainer,
1582
1608
  onContainerEmpty: options.onContainerEmpty,
1583
1609
  onUnknown: options.onUnknown,
@@ -88,7 +88,7 @@ export type JourneyStep = {
88
88
  * the build finished with a result.
89
89
  */
90
90
  export declare function firstTaskJourney(read: {
91
- stage: "queued" | "planning" | "needs-you" | "building" | "checking" | "finished" | "complete" | "failed" | "stopped";
91
+ stage: "queued" | "planning" | "needs-you" | "waiting" | "building" | "checking" | "reviewing" | "finished" | "complete" | "failed" | "stopped";
92
92
  need?: string | undefined;
93
93
  }, approved: boolean, built?: boolean): JourneyStep[];
94
94
  export {};
package/dist/first-run.js CHANGED
@@ -129,13 +129,15 @@ export const JOURNEY_STEPS = [["plan", "Plan"], ["approve", "You approve"], ["bu
129
129
  */
130
130
  export function firstTaskJourney(read, approved, built = false) {
131
131
  const at = read.stage === "finished" || read.stage === "complete" ? { index: 5, stuck: false }
132
- : read.stage === "checking" ? { index: 3, stuck: false }
132
+ : read.stage === "checking" || read.stage === "reviewing" ? { index: 3, stuck: false }
133
133
  : read.stage === "building" ? { index: 2, stuck: false }
134
- : read.stage === "needs-you" && read.need === "approval" ? { index: 1, stuck: false }
135
- : read.stage === "planning" ? { index: 0, stuck: false }
136
- : read.stage === "queued" ? { index: approved ? 2 : 0, stuck: false }
137
- // A build that finished but isn't Ready (its checks failed or didn't run, or it needs a decision) stopped at Checks.
138
- : { index: built ? 3 : approved ? 2 : 0, stuck: true };
134
+ // Waiting on nothing a person does: the build finished and is settling.
135
+ : read.stage === "waiting" ? { index: built ? 3 : 2, stuck: false }
136
+ : read.stage === "needs-you" && read.need === "approval" ? { index: 1, stuck: false }
137
+ : read.stage === "planning" ? { index: 0, stuck: false }
138
+ : read.stage === "queued" ? { index: approved ? 2 : 0, stuck: false }
139
+ // A build that finished but isn't Ready (its checks failed or didn't run, or it needs a decision) stopped at Checks.
140
+ : { index: built ? 3 : approved ? 2 : 0, stuck: true };
139
141
  return JOURNEY_STEPS.map(([key, label], index) => ({ key, label,
140
142
  state: index < at.index ? "done" : index === at.index ? (at.stuck ? "stuck" : "current") : "todo" }));
141
143
  }
package/dist/invoke.js CHANGED
@@ -646,6 +646,11 @@ export async function invokeHeldAgent(store, runId, spec, argv, options) {
646
646
  store.recordRunProcess(runId, pid, clock(), false, heldWitness);
647
647
  runOptions.onSpawn?.(pid);
648
648
  },
649
+ onSpawnFailed: () => {
650
+ if (heldWitness !== undefined)
651
+ store.finishUnspawnedProcess(heldWitness, clock());
652
+ runOptions.onSpawnFailed?.();
653
+ },
649
654
  onContainer: info => {
650
655
  nativeHeld = true;
651
656
  if (heldWitness !== undefined)
@@ -5,4 +5,4 @@ import { type AssignmentCatchUp } from './assignment-brief.js';
5
5
  export declare function leadContext(store: Store, repos: readonly string[], now: Date, evidenceRoot?: string): string;
6
6
  /** The same saved brief is useful before chat spending is authorized. */
7
7
  export declare function leadBriefHtml(brief: AssignmentCatchUp): string;
8
- export declare const LEAD_CONTEXT_CSS = ".lead-brief{margin:1rem 0;min-width:0}.lead-brief h2{font-size:1.1rem;margin:0 0 .8rem}.repository-context input[name=q]{display:block;box-sizing:border-box;min-height:44px;width:100%;margin:.4rem 0 .75rem;padding:.6rem .75rem;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:inherit;font:inherit}.repository-context form{margin:.5rem 0 1rem}.repository-context summary{min-height:44px;padding:.75rem 0;overflow-wrap:anywhere}.lead-brief h3{font-size:.85rem;margin:1rem 0 .4rem;color:var(--muted-foreground)}.lead-brief ul{list-style:none;margin:0;padding:0}.lead-brief li{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:.75rem;align-items:center;padding:.35rem 0;border-bottom:1px solid var(--border);min-width:0}.lead-brief li a{display:flex;min-height:44px;align-items:center;font-weight:500;overflow-wrap:anywhere;text-decoration:none}.lead-brief li a:hover{text-decoration:underline}.lead-brief-state{font-size:.75rem;font-weight:600;padding:.15rem .5rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink);white-space:nowrap}.lead-brief-state--needs-decision,.lead-brief-state--ready-to-check{background:var(--so-attention-soft);color:var(--so-attention)}.lead-brief-state--working,.lead-brief-state--checking{background:var(--so-info-soft);color:var(--so-info)}.lead-brief-state--complete{background:var(--so-success-soft);color:var(--so-success)}.lead-brief-detail{grid-column:1/-1;font-size:.85rem;color:var(--muted-foreground);overflow-wrap:anywhere;padding-bottom:.35rem}@media(max-width:760px){.lead-brief{margin:.5rem 0}.lead-brief h2{margin:0 0 .25rem}.lead-brief h3{margin:.5rem 0 0}.lead-brief li{padding:0 0 .375rem}.lead-brief-detail{margin-top:-.375rem;padding-bottom:0;line-height:1.35;pointer-events:none}}";
8
+ export declare const LEAD_CONTEXT_CSS = ".lead-brief{margin:1rem 0;min-width:0}.lead-brief h2{font-size:1.1rem;margin:0 0 .8rem}.repository-context input[name=q]{display:block;box-sizing:border-box;min-height:44px;width:100%;margin:.4rem 0 .75rem;padding:.6rem .75rem;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:inherit;font:inherit}.repository-context form{margin:.5rem 0 1rem}.repository-context summary{min-height:44px;padding:.75rem 0;overflow-wrap:anywhere}.lead-brief h3{font-size:.85rem;margin:1rem 0 .4rem;color:var(--muted-foreground)}.lead-brief ul{list-style:none;margin:0;padding:0}.lead-brief li{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:.75rem;align-items:center;padding:.35rem 0;border-bottom:1px solid var(--border);min-width:0}.lead-brief li a{display:flex;min-height:44px;align-items:center;font-weight:500;overflow-wrap:anywhere;text-decoration:none}.lead-brief li a:hover{text-decoration:underline}.lead-brief-state{font-size:.75rem;font-weight:600;padding:.15rem .5rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink);white-space:nowrap}.lead-brief-state--needs-decision,.lead-brief-state--ready-to-check{background:var(--so-attention-soft);color:var(--so-attention)}.lead-brief-state--working,.lead-brief-state--checking{background:var(--so-info-soft);color:var(--so-info)}.lead-brief-state--complete{background:var(--so-success-soft);color:var(--so-success)}.lead-brief li a.lead-brief-act{grid-column:1/-1;justify-self:start;display:inline-flex;min-height:40px;margin:.15rem 0 .4rem;font-weight:600}@media(max-width:760px){.lead-brief li a.lead-brief-act{justify-self:stretch;justify-content:center;min-height:44px}}.lead-brief-detail{grid-column:1/-1;font-size:.85rem;color:var(--muted-foreground);overflow-wrap:anywhere;padding-bottom:.35rem}@media(max-width:760px){.lead-brief{margin:.5rem 0}.lead-brief h2{margin:0 0 .25rem}.lead-brief h3{margin:.5rem 0 0}.lead-brief li{padding:0 0 .375rem}.lead-brief-detail{margin-top:-.375rem;padding-bottom:0;line-height:1.35;pointer-events:none}}";
@@ -53,8 +53,10 @@ export function leadBriefHtml(brief) {
53
53
  };
54
54
  return [`<section><h3>${group.title}</h3><ul>${entries.map(one => `<li><a href="/chat?task=${encodeURIComponent(one.rootId)}">${escape(one.title)}</a>` +
55
55
  stateChip(one.state, one.detail) +
56
- (quiet ? '' : `<span class="lead-brief-detail">${escape(publicChatText(one.detail || one.outcome || '', 160))}</span>`) + `</li>`).join('')}</ul></section>`];
56
+ (quiet ? '' : `<span class="lead-brief-detail">${escape(publicChatText(one.detail || one.outcome || '', 160))}</span>`) +
57
+ // Needs you: the one action that resolves it, under its sentence.
58
+ (one.state === 'needs-decision' && one.nextAction !== null && one.nextHref ? `<a class="lead-brief-act button-link" href="${escape(one.nextHref)}" data-catch-up-action>${escape(one.nextAction.label)}</a>` : '') + `</li>`).join('')}</ul></section>`];
57
59
  });
58
60
  return `<section class="lead-brief" aria-label="Project catch-up"><h2>Catch up</h2>${sections.length ? sections.join('') : '<p class="meta">Nothing needs you right now.</p>'}${brief.assignments.length > 3 || brief.omissions.candidateScanLimited || brief.omissions.assignments > 0 ? '<a href="/work">See all tasks</a>' : ''}</section>`;
59
61
  }
60
- export const LEAD_CONTEXT_CSS = '.lead-brief{margin:1rem 0;min-width:0}.lead-brief h2{font-size:1.1rem;margin:0 0 .8rem}.repository-context input[name=q]{display:block;box-sizing:border-box;min-height:44px;width:100%;margin:.4rem 0 .75rem;padding:.6rem .75rem;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:inherit;font:inherit}.repository-context form{margin:.5rem 0 1rem}.repository-context summary{min-height:44px;padding:.75rem 0;overflow-wrap:anywhere}.lead-brief h3{font-size:.85rem;margin:1rem 0 .4rem;color:var(--muted-foreground)}.lead-brief ul{list-style:none;margin:0;padding:0}.lead-brief li{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:.75rem;align-items:center;padding:.35rem 0;border-bottom:1px solid var(--border);min-width:0}.lead-brief li a{display:flex;min-height:44px;align-items:center;font-weight:500;overflow-wrap:anywhere;text-decoration:none}.lead-brief li a:hover{text-decoration:underline}.lead-brief-state{font-size:.75rem;font-weight:600;padding:.15rem .5rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink);white-space:nowrap}.lead-brief-state--needs-decision,.lead-brief-state--ready-to-check{background:var(--so-attention-soft);color:var(--so-attention)}.lead-brief-state--working,.lead-brief-state--checking{background:var(--so-info-soft);color:var(--so-info)}.lead-brief-state--complete{background:var(--so-success-soft);color:var(--so-success)}.lead-brief-detail{grid-column:1/-1;font-size:.85rem;color:var(--muted-foreground);overflow-wrap:anywhere;padding-bottom:.35rem}@media(max-width:760px){.lead-brief{margin:.5rem 0}.lead-brief h2{margin:0 0 .25rem}.lead-brief h3{margin:.5rem 0 0}.lead-brief li{padding:0 0 .375rem}.lead-brief-detail{margin-top:-.375rem;padding-bottom:0;line-height:1.35;pointer-events:none}}';
62
+ export const LEAD_CONTEXT_CSS = '.lead-brief{margin:1rem 0;min-width:0}.lead-brief h2{font-size:1.1rem;margin:0 0 .8rem}.repository-context input[name=q]{display:block;box-sizing:border-box;min-height:44px;width:100%;margin:.4rem 0 .75rem;padding:.6rem .75rem;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:inherit;font:inherit}.repository-context form{margin:.5rem 0 1rem}.repository-context summary{min-height:44px;padding:.75rem 0;overflow-wrap:anywhere}.lead-brief h3{font-size:.85rem;margin:1rem 0 .4rem;color:var(--muted-foreground)}.lead-brief ul{list-style:none;margin:0;padding:0}.lead-brief li{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:.75rem;align-items:center;padding:.35rem 0;border-bottom:1px solid var(--border);min-width:0}.lead-brief li a{display:flex;min-height:44px;align-items:center;font-weight:500;overflow-wrap:anywhere;text-decoration:none}.lead-brief li a:hover{text-decoration:underline}.lead-brief-state{font-size:.75rem;font-weight:600;padding:.15rem .5rem;border-radius:.375rem;background:var(--so-neutral-soft);color:var(--so-neutral-ink);white-space:nowrap}.lead-brief-state--needs-decision,.lead-brief-state--ready-to-check{background:var(--so-attention-soft);color:var(--so-attention)}.lead-brief-state--working,.lead-brief-state--checking{background:var(--so-info-soft);color:var(--so-info)}.lead-brief-state--complete{background:var(--so-success-soft);color:var(--so-success)}.lead-brief li a.lead-brief-act{grid-column:1/-1;justify-self:start;display:inline-flex;min-height:40px;margin:.15rem 0 .4rem;font-weight:600}@media(max-width:760px){.lead-brief li a.lead-brief-act{justify-self:stretch;justify-content:center;min-height:44px}}.lead-brief-detail{grid-column:1/-1;font-size:.85rem;color:var(--muted-foreground);overflow-wrap:anywhere;padding-bottom:.35rem}@media(max-width:760px){.lead-brief{margin:.5rem 0}.lead-brief h2{margin:0 0 .25rem}.lead-brief h3{margin:.5rem 0 0}.lead-brief li{padding:0 0 .375rem}.lead-brief-detail{margin-top:-.375rem;padding-bottom:0;line-height:1.35;pointer-events:none}}';
@@ -47,7 +47,7 @@ export type InstallationStatus = {
47
47
  count: number;
48
48
  results: {
49
49
  task: string;
50
- run: number;
50
+ run: number | null;
51
51
  check: CheckSummary;
52
52
  }[];
53
53
  };
@@ -84,6 +84,15 @@ export type InstallationStatus = {
84
84
  * has been observed yet. */
85
85
  export declare function taskWaitSnapshot(store: Store, taskId: string, now: Date, watchedRun?: number): TaskWaitSnapshot | null;
86
86
  /** Whole-installation status, deliberately aggregated and bounded. */
87
+ /** The one-off repair on start. An older build left finished runs' process
88
+ * exits unrecorded and counted completed families as waiting; nothing about
89
+ * a status is stored, so recording every exit that can now be proven and
90
+ * re-deriving each open task's headline from the shared projection clears
91
+ * them. What can't be proven stays as it was. */
92
+ export declare function repairStaleStatuses(store: Store, now: Date): {
93
+ exitsRecorded: number;
94
+ readyForReview: number;
95
+ };
87
96
  export declare function installationStatus(store: Store, now: Date): InstallationStatus;
88
97
  export declare function renderInstallationStatus(status: InstallationStatus): string[];
89
98
  export declare function renderTaskWait(snapshot: TaskWaitSnapshot, outcome?: string): string;