toolroll 0.9.8 → 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 (69) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/assignment-brief.d.ts +2 -0
  3. package/dist/assignment-brief.js +2 -1
  4. package/dist/assignment-presentation.js +3 -1
  5. package/dist/assignment-status.d.ts +1 -1
  6. package/dist/assignment-ui.js +5 -0
  7. package/dist/assignment.d.ts +9 -0
  8. package/dist/assignment.js +39 -7
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/browser/workspace.js +18 -18
  11. package/dist/browser-crew.js +4 -0
  12. package/dist/browser-workspace.d.ts +40 -0
  13. package/dist/builder.d.ts +2 -0
  14. package/dist/builder.js +114 -8
  15. package/dist/chat-controls.d.ts +1 -1
  16. package/dist/chat-controls.js +1 -1
  17. package/dist/check-progress.js +4 -3
  18. package/dist/checkout-cleanup.d.ts +40 -2
  19. package/dist/checkout-cleanup.js +129 -9
  20. package/dist/desktop-update.d.ts +52 -0
  21. package/dist/desktop-update.js +147 -5
  22. package/dist/evidence.d.ts +6 -0
  23. package/dist/evidence.js +8 -0
  24. package/dist/first-run.d.ts +1 -1
  25. package/dist/first-run.js +7 -5
  26. package/dist/lead-context.d.ts +1 -1
  27. package/dist/lead-context.js +4 -2
  28. package/dist/lead-status.d.ts +10 -1
  29. package/dist/lead-status.js +21 -24
  30. package/dist/needs-you.d.ts +55 -0
  31. package/dist/needs-you.js +83 -0
  32. package/dist/operate.d.ts +2 -0
  33. package/dist/operate.js +112 -14
  34. package/dist/plane-review.js +4 -0
  35. package/dist/process-recovery-eligibility.d.ts +1 -0
  36. package/dist/project-learning.d.ts +3 -3
  37. package/dist/project-learning.js +28 -17
  38. package/dist/proof.d.ts +3 -1
  39. package/dist/proof.js +4 -2
  40. package/dist/result-completion.d.ts +4 -0
  41. package/dist/result-completion.js +11 -0
  42. package/dist/review-context.d.ts +3 -0
  43. package/dist/review-context.js +45 -5
  44. package/dist/run-closeout.d.ts +89 -0
  45. package/dist/run-closeout.js +302 -0
  46. package/dist/serve.d.ts +15 -1
  47. package/dist/serve.js +129 -14
  48. package/dist/setup-guide.d.ts +2 -2
  49. package/dist/shared-deps.d.ts +94 -0
  50. package/dist/shared-deps.js +365 -0
  51. package/dist/storage.js +2 -0
  52. package/dist/store.d.ts +36 -0
  53. package/dist/store.js +125 -29
  54. package/dist/task-status.d.ts +18 -4
  55. package/dist/task-status.js +35 -18
  56. package/dist/telegram-progress.js +2 -1
  57. package/dist/telegram-status.js +3 -3
  58. package/dist/test-temp.d.ts +19 -0
  59. package/dist/test-temp.js +98 -0
  60. package/dist/toolroll-update-ui.js +7 -2
  61. package/dist/toolroll-update.d.ts +21 -1
  62. package/dist/toolroll-update.js +135 -5
  63. package/dist/work-index.d.ts +11 -0
  64. package/dist/work-index.js +103 -16
  65. package/dist/work-summary.d.ts +3 -1
  66. package/dist/workspace-ui.js +1 -1
  67. package/dist/worktree.d.ts +16 -0
  68. package/dist/worktree.js +47 -0
  69. package/package.json +1 -1
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Test temp folders (`toolroll storage`, `storage clean`): what the unit tests, journeys and scripts make in the
3
+ * system temp folder. The tests delete their own (each run gets one temp root that its global teardown removes:
4
+ * test/temp-root.ts); a run that was killed, and older versions, leave folders behind. `toolroll storage` counts them
5
+ * and `storage clean` removes the ones nothing has touched for a day.
6
+ *
7
+ * Every prefix the tests and scripts give tmpdir() is covered here (test-temp.test.ts reads the sources to keep it
8
+ * so), and Playwright's own browser profiles.
9
+ */
10
+ import { lstatSync, readdirSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ const DAY_MS = 86_400_000;
14
+ /** Name prefixes of what tests, journeys and scripts leave in the temp folder. */
15
+ export const TEST_TEMP_PREFIXES = [
16
+ "so-", "standing-orders-", "toolroll-", "no-wt", "epoch", "cancel-floor",
17
+ "attest-", "budgets-", "chat-v66-", "check-levels-", "coding-context-", "console-url-", "console-v2-", "e2-attest-",
18
+ "flow-goal-", "flow-scratch", "flows-chat-", "invoke-attest-", "knowledge-test-", "learning-", "peek-", "phone-origin-",
19
+ "proposal-repo-", "race-", "refresh-migration-", "refresh-refusal-", "release-check-", "shared-chat-", "skills-test-",
20
+ "task-status-surfaces-", "template-repo-", "template-test-", "v14-migrate-", "v17-migrate-", "watch-anchor-",
21
+ "needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-",
22
+ // Playwright's browser profiles and artifacts.
23
+ "playwright_chromiumdev_profile-", "playwright_firefoxdev_profile-", "playwright-artifacts-",
24
+ ];
25
+ export function isTestTemp(name) {
26
+ return TEST_TEMP_PREFIXES.some(prefix => name.startsWith(prefix));
27
+ }
28
+ /** Where they collect: the temp folder (and /tmp, where some scripts write). */
29
+ export function tempRoots() {
30
+ const roots = [tmpdir()];
31
+ if (process.platform !== "win32" && !roots.includes("/tmp"))
32
+ roots.push("/tmp");
33
+ return roots;
34
+ }
35
+ /** When anything last happened in a folder: it, or what is directly in it, last changed. */
36
+ function touchedAt(path) {
37
+ let newest = lstatSync(path).mtimeMs;
38
+ let names = [];
39
+ try {
40
+ names = readdirSync(path);
41
+ }
42
+ catch {
43
+ return newest;
44
+ }
45
+ for (const name of names) {
46
+ try {
47
+ newest = Math.max(newest, lstatSync(join(path, name)).mtimeMs);
48
+ }
49
+ catch { /* gone */ }
50
+ }
51
+ return newest;
52
+ }
53
+ /** Every test temp folder in `roots`, and which of them nothing has touched for a day. */
54
+ export function testTempFolders(roots, now) {
55
+ const all = [];
56
+ const seen = new Set();
57
+ for (const root of roots) {
58
+ let names = [];
59
+ try {
60
+ names = readdirSync(root);
61
+ }
62
+ catch {
63
+ continue;
64
+ }
65
+ for (const name of names) {
66
+ if (!isTestTemp(name))
67
+ continue;
68
+ const path = join(root, name);
69
+ try {
70
+ const stat = lstatSync(path);
71
+ const key = `${stat.dev}:${stat.ino}`;
72
+ if (seen.has(key) || stat.isSymbolicLink())
73
+ continue;
74
+ seen.add(key);
75
+ all.push({ path, touchedAt: stat.isDirectory() ? touchedAt(path) : stat.mtimeMs });
76
+ }
77
+ catch { /* gone */ }
78
+ }
79
+ }
80
+ return { all, stale: all.filter(one => now.getTime() - one.touchedAt > DAY_MS) };
81
+ }
82
+ /** Remove the test temp folders nothing has touched for a day; each is looked at again right before it goes. */
83
+ export function removeStaleTestTemp(roots, now) {
84
+ const removed = [];
85
+ const failed = [];
86
+ for (const one of testTempFolders(roots, now).stale) {
87
+ try {
88
+ if (now.getTime() - (lstatSync(one.path).isDirectory() ? touchedAt(one.path) : lstatSync(one.path).mtimeMs) <= DAY_MS)
89
+ continue;
90
+ rmSync(one.path, { recursive: true, force: true, maxRetries: 3 });
91
+ removed.push(one.path);
92
+ }
93
+ catch {
94
+ failed.push(one.path);
95
+ }
96
+ }
97
+ return { removed, failed };
98
+ }
@@ -12,7 +12,7 @@ export const UPDATES_CSS = `.updates{max-width:640px;min-width:0;overflow-wrap:a
12
12
  `.updates ol.update-steps li[data-state=failed]{color:var(--so-danger);font-weight:600}.updates ol.update-steps li[data-state=failed]::before{background:var(--so-danger);border-color:var(--so-danger)}` +
13
13
  `.updates ol.update-steps .step-detail{grid-column:2;font-weight:400;color:var(--so-muted);font-size:.8125rem;margin-top:2px}` +
14
14
  `.updates .whats-new ul{margin:10px 0 0;padding-left:18px}.updates .whats-new li{margin:4px 0}.updates .whats-new form{margin:14px 0 0}` +
15
- `.updates .update-problem{color:var(--so-danger)}.updates .stamp{margin:10px 0 0}.updates code{white-space:nowrap}` +
15
+ `.updates .update-problem{color:var(--so-danger)}.updates .stamp{margin:10px 0 0}.updates code{white-space:nowrap}.updates [data-update-outcome=stopped] code,.updates .step-detail code{white-space:normal;overflow-wrap:anywhere}` +
16
16
  `@media (prefers-reduced-motion:no-preference){.updates ol.update-steps li[data-state=now]::before{animation:update-pulse 1.6s ease-in-out infinite}}@keyframes update-pulse{50%{box-shadow:0 0 0 7px color-mix(in srgb,var(--so-info) 6%,transparent)}}` +
17
17
  `@media (max-width:600px){.updates .update-actions{display:grid}.updates .update-actions button{width:100%;min-height:44px}.updates .step-up{max-width:none}.updates .step-up input{min-height:44px}}`;
18
18
  /** The steps, each done, current, failed or waiting. Also the live region's fragment. */
@@ -23,7 +23,8 @@ export function updateStepsHtml(j, running) {
23
23
  const items = UPDATE_STEPS.map((step, i) => {
24
24
  const state = j.phase === "complete" || i < last ? "done" : i > last ? "waiting" : failed ? "failed" : finished ? "waiting" : "now";
25
25
  // Only where it adds something: which work it waits for, or what went wrong.
26
- const detail = (state === "now" && step === "draining") || (state === "failed" && j.phase === "rolling-back") ? `<span class="step-detail">${e(j.detail)}</span>` : "";
26
+ const detail = state === "now" && step === "draining" && j.waiting ? `<span class="step-detail">${e(j.waiting.on)}.${j.waiting.action ? ` If nothing of it is running, run <code>${e(j.waiting.action)}</code>.` : ""}</span>`
27
+ : (state === "now" && step === "draining") || (state === "failed" && j.phase === "rolling-back") ? `<span class="step-detail">${e(j.detail)}</span>` : "";
27
28
  return `<li data-step="${step}" data-state="${state}">${e(STEP_WORDS[step])}${detail}</li>`;
28
29
  }).join("");
29
30
  const title = j.phase === "scheduled" ? `Update to ${e(j.to.version)} scheduled for ${e(clock(j.at ?? j.startedAt))}`
@@ -38,6 +39,10 @@ function outcomeHtml(j) {
38
39
  return j.kind === "rollback" ? `<div class="card" data-update-outcome="rolled-back"><h2>Back on ${e(j.to.version)}</h2><p class="meta">${e(j.detail)}</p></div>` : "";
39
40
  if (j.phase === "cancelled")
40
41
  return `<div class="card" data-update-outcome="cancelled"><h2>Update cancelled</h2><p class="meta">Nothing changed.</p></div>`;
42
+ // Stopped on a finished run it can't show has ended: what is in the way and the one command, nothing more.
43
+ if (j.phase === "refused" && j.waiting)
44
+ return `<div class="card" data-update-outcome="stopped"><h2>Update to ${e(j.to.version)} stopped: run #${e(j.waiting.run)} is in the way</h2>` +
45
+ `<p>${e(j.waiting.on)}. New work resumed.</p>` + (j.waiting.action ? `<p class="meta">If nothing of it is running, run <code>${e(j.waiting.action)}</code>, then update again.</p>` : `<p class="meta">Update again once it has stopped.</p>`) + `</div>`;
41
46
  const title = j.phase === "refused" ? `Didn't update to ${e(j.to.version)}` : j.phase === "restored" ? `Update to ${e(j.to.version)} didn't finish` : "The update needs attention";
42
47
  return `<div class="card" data-update-outcome="${e(j.phase)}"><h2>${title}</h2><p class="${j.phase === "needs-attention" ? "update-problem" : ""}" role="alert">${e(j.detail)}</p>` +
43
48
  `<details><summary>Steps</summary>${updateStepsHtml(j, false).replace(/<h2>.*?<\/h2>/, "")}</details></div>`;
@@ -1,4 +1,5 @@
1
1
  import type { DatabaseSync } from "node:sqlite";
2
+ import { type LingeringRun, type UpdateWaiting } from "./desktop-update.js";
2
3
  import { type SupervisorRunner } from "./daemon.js";
3
4
  export declare const PROVENANCE_REPOSITORY = "https://github.com/ap9000/toolroll";
4
5
  export declare const PROVENANCE_WORKFLOW = ".github/workflows/publish.yml";
@@ -103,6 +104,11 @@ export type RuntimeUpdateJournal = {
103
104
  * the restored version and would be lost), and it keeps a fresh copy aside before every attempt until then. */
104
105
  restoredDatabase?: boolean;
105
106
  seen?: boolean;
107
+ /** The finished run the update is waiting on (or stopped waiting on): what is in the way and the command that
108
+ * clears it. Absent while it waits on ordinary running work. */
109
+ waiting?: LingeringRun & {
110
+ since: string;
111
+ };
106
112
  };
107
113
  export type PackageRelease = {
108
114
  version: string;
@@ -160,6 +166,8 @@ export type UpdateOutcome = {
160
166
  journal: RuntimeUpdateJournal | null;
161
167
  };
162
168
  export declare const runtimeUpdateTerminal: (phase: RuntimePhase) => boolean;
169
+ /** How long a launched or resuming updater is expected to take to start and hold the lock. */
170
+ export declare const UPDATER_START_MS: number;
163
171
  export declare function readRuntimeUpdate(stateDir: string, file?: string): RuntimeUpdateJournal | null;
164
172
  /** The update `--rollback` returns from: the last one that completed, whatever was attempted since. */
165
173
  export declare function lastCompletedUpdate(stateDir: string): RuntimeUpdateJournal | null;
@@ -245,6 +253,18 @@ export type RuntimeUpdateStatus = {
245
253
  } | null;
246
254
  };
247
255
  export declare function runtimeUpdateStatus(stateDir: string): RuntimeUpdateStatus;
256
+ /** An update that is waiting, what on, and the one action that clears it: for `toolroll status` and the console.
257
+ * `inTheWay` asks whether a finished run still holds it up; a stopped update whose run has since cleared says nothing. */
258
+ export type { UpdateWaiting };
259
+ export declare function updateWaitingOf(stateDir: string, inTheWay: (run: number) => boolean): UpdateWaiting | null;
260
+ /** An updater that ended while letting work finish (a crash, a kill, the Mac restarting) leaves new work paused with
261
+ * nobody left to lift the pause. With no updater holding the update, the pause is lifted here and the update says it
262
+ * stopped. Nothing was switched before the backup, so nothing else needs putting back. The background worker's
263
+ * reconcile, `toolroll status` and the console run this. Returns what it said, or null. */
264
+ export declare function releaseStalledUpdate(stateDir: string, now: Date): string | null;
265
+ /** What `toolroll status` and the console show: a stalled `toolroll update` released first, then this installation's
266
+ * waiting update, `toolroll update`'s or the desktop app's. */
267
+ export declare function waitingUpdate(databaseFile: string, inTheWay: (run: number) => boolean, now: Date, desktopStates?: readonly string[]): UpdateWaiting | null;
248
268
  export declare function markWhatsNewSeen(stateDir: string): void;
249
269
  type Exec = (command: string, args: string[], options?: {
250
270
  cwd?: string;
@@ -282,8 +302,8 @@ export declare function launchRuntimeUpdate(args: {
282
302
  run?: SupervisorRunner;
283
303
  platform?: NodeJS.Platform;
284
304
  npmBin?: () => Promise<string | null>;
305
+ now?: () => Date;
285
306
  }): Promise<void>;
286
307
  /** The job's last act: its definition goes, so nothing can start it again. Only the definition for this id. */
287
308
  export declare function retireUpdateJob(id: string, home?: string): void;
288
309
  export declare const nextScheduledAt: typeof nextAt;
289
- export {};
@@ -29,7 +29,7 @@ import { homedir, userInfo } from "node:os";
29
29
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
30
30
  import { fileURLToPath, pathToFileURL } from "node:url";
31
31
  import { createRequire } from "node:module";
32
- import { durableJson, lingeringWork, sqliteLock, verifiedDatabaseBackup } from "./desktop-update.js";
32
+ import { clearWords, durableJson, LINGERING_LIMIT_MS, lingeringRun, lingeringWords, settleLeftoverRecords, sqliteLock, desktopUpdateWaitingOf, stoppedWaitingWords, updateWaitingWords, verifiedDatabaseBackup } from "./desktop-update.js";
33
33
  import { activeUpdateWork, freezeUpdateGate, installUpdateGate, removeUpdateGate, updateAdmissionPaused, updateGateOwned } from "./desktop-update-gate.js";
34
34
  import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, removeCodingUpdateGate } from "./coding-update.js";
35
35
  import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
@@ -61,6 +61,24 @@ const journalFile = (stateDir) => join(stateDir, "toolroll-update.json");
61
61
  /** The last completed update, kept apart from the journal: what `--rollback` returns from. */
62
62
  const lastUpdateFile = (stateDir) => join(stateDir, "toolroll-update.last.json");
63
63
  const cancelFile = (j) => join(j.stageDir, "cancel-request.json");
64
+ /** An updater on its way to this update before it holds the lock: the console's job once launched (launchd may not
65
+ * have started it yet), or a resume about to drive it. */
66
+ const startingFile = (j) => join(j.stageDir, "updater-starting.json");
67
+ /** How long a launched or resuming updater is expected to take to start and hold the lock. */
68
+ export const UPDATER_START_MS = 2 * 60_000;
69
+ function markStarting(j, state, now) {
70
+ durableJson(startingFile(j), { id: j.id, state, at: now.toISOString() });
71
+ }
72
+ function updaterStarting(j, now) {
73
+ try {
74
+ const mark = JSON.parse(readFileSync(startingFile(j), "utf8"));
75
+ const age = now.getTime() - Date.parse(mark.at ?? "");
76
+ return mark.id === j.id && age >= 0 && age < UPDATER_START_MS;
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
64
82
  const sha = (bytes, algorithm = "sha256") => createHash(algorithm).update(bytes).digest("hex");
65
83
  const fileHash = (file) => sha(readFileSync(file));
66
84
  const quote = (s) => '"' + s.replaceAll('"', '""') + '"';
@@ -744,6 +762,7 @@ export async function resumeRuntimeUpdate(stateDir, system, id) {
744
762
  return { ok: true, phase: j?.phase ?? "complete", message: "The update this job was started for is no longer the saved one. Nothing was changed.", journal: j };
745
763
  if (!j || runtimeUpdateTerminal(j.phase))
746
764
  return { ok: true, phase: j?.phase ?? "complete", message: "No update is in progress.", journal: j };
765
+ markStarting(j, "resuming", system.now());
747
766
  return driveRuntimeUpdate(j, system);
748
767
  }
749
768
  /** A prepared update whose job could not start. */
@@ -801,6 +820,23 @@ async function driveRuntimeUpdate(j, system) {
801
820
  const lock = sqliteLock(join(j.stageDir, "worker.sqlite"), 1000);
802
821
  if (!lock)
803
822
  return { ok: false, phase: j.phase, message: `An updater is already working on the update to ${j.to.version}.`, journal: j };
823
+ // What was read before the lock may be out of date: a stalled update released, or a cancel, in between.
824
+ let saved = null;
825
+ try {
826
+ saved = readRuntimeUpdate(j.stateDir);
827
+ }
828
+ catch {
829
+ saved = null;
830
+ }
831
+ if (saved?.id === j.id) {
832
+ if (runtimeUpdateTerminal(saved.phase)) {
833
+ lock.close();
834
+ return { ok: saved.phase === "complete" || saved.phase === "cancelled", phase: saved.phase, message: saved.detail, journal: saved };
835
+ }
836
+ Object.assign(j, saved);
837
+ }
838
+ // The lock now says an updater is here; the starting mark has done its job.
839
+ rmSync(startingFile(j), { force: true });
804
840
  const at = (phase) => UPDATE_STEPS.indexOf(phase);
805
841
  const step = (phase, detail) => { save(j, phase, detail, system.now()); system.checkpoint?.(phase); };
806
842
  const noun = j.kind === "update" ? "update" : "rollback";
@@ -1042,9 +1078,15 @@ async function drain(j, system) {
1042
1078
  try {
1043
1079
  db.exec("PRAGMA busy_timeout=5000");
1044
1080
  if (j.when === "now") {
1045
- const running = runningWorkWords(db) ?? lingeringWork(db);
1081
+ const running = runningWorkWords(db);
1046
1082
  if (running)
1047
1083
  throw new Refusal(`Work is running: ${running}. Nothing was changed. Use When idle to wait for it.`);
1084
+ settleLeftoverRecords(db, system.now());
1085
+ const lingering = lingeringRun(db);
1086
+ if (lingering) {
1087
+ j.waiting = { ...lingering, since: system.now().toISOString() };
1088
+ throw new Refusal(`${lingering.on}. ${clearWords(lingering)} Nothing was changed.`);
1089
+ }
1048
1090
  if (updateAdmissionPaused(db) && !updateGateOwned(db, j.id))
1049
1091
  throw new Refusal("Another update owns the admission pause. Nothing was changed.");
1050
1092
  installUpdateGate(db, j.id);
@@ -1058,10 +1100,23 @@ async function drain(j, system) {
1058
1100
  for (;;) {
1059
1101
  if (cancelRequested(j))
1060
1102
  return;
1061
- const lingering = Object.values(activeUpdateWork(db)).every(n => n === 0) ? lingeringWork(db) : null;
1062
- if (!lingering && freezeUpdateGate(db, j.id))
1103
+ const idle = Object.values(activeUpdateWork(db)).every(n => n === 0);
1104
+ // The same settling reconcile the background worker runs, so a leftover record it can prove never holds this up.
1105
+ if (idle)
1106
+ settleLeftoverRecords(db, system.now());
1107
+ const lingering = idle ? lingeringRun(db) : null;
1108
+ if (!lingering && freezeUpdateGate(db, j.id)) {
1109
+ delete j.waiting;
1063
1110
  return;
1064
- save(j, "draining", `New work is paused. Waiting for ${lingering ?? runningWorkWords(db) ?? "running work"} to finish. Nothing is being cancelled.`, system.now());
1111
+ }
1112
+ if (!lingering)
1113
+ delete j.waiting;
1114
+ else if (j.waiting?.run !== lingering.run || j.waiting.on !== lingering.on)
1115
+ j.waiting = { ...lingering, since: system.now().toISOString() };
1116
+ // Waiting longer on a finished run proves nothing more: stop, let new work resume, and say what clears it.
1117
+ if (j.waiting && system.now().getTime() - Date.parse(j.waiting.since) >= LINGERING_LIMIT_MS)
1118
+ throw new Refusal(stoppedWaitingWords(j.waiting));
1119
+ save(j, "draining", lingering ? `New work is paused. Waiting for run #${lingering.run}: ${lingeringWords(lingering)}` : `New work is paused. Waiting for ${runningWorkWords(db) ?? "running work"} to finish. Nothing is being cancelled.`, system.now());
1065
1120
  await system.sleep(2000);
1066
1121
  }
1067
1122
  }
@@ -1121,6 +1176,77 @@ export function runtimeUpdateStatus(stateDir) {
1121
1176
  }
1122
1177
  return { journal: j, running, whatsNew, lastUpdate: last ? { from: last.from.version, to: last.to.version } : null };
1123
1178
  }
1179
+ export function updateWaitingOf(stateDir, inTheWay) {
1180
+ let j;
1181
+ try {
1182
+ j = readRuntimeUpdate(stateDir);
1183
+ }
1184
+ catch {
1185
+ return null;
1186
+ }
1187
+ if (!j || j.kind !== "update")
1188
+ return null;
1189
+ const version = j.to.version;
1190
+ if (j.phase === "draining")
1191
+ return updateWaitingWords({ app: false, version, stopped: false, lingering: j.waiting ?? null });
1192
+ if (j.phase !== "refused" || !j.waiting || !inTheWay(j.waiting.run))
1193
+ return null;
1194
+ return updateWaitingWords({ app: false, version, stopped: true, lingering: j.waiting });
1195
+ }
1196
+ /** An updater that ended while letting work finish (a crash, a kill, the Mac restarting) leaves new work paused with
1197
+ * nobody left to lift the pause. With no updater holding the update, the pause is lifted here and the update says it
1198
+ * stopped. Nothing was switched before the backup, so nothing else needs putting back. The background worker's
1199
+ * reconcile, `toolroll status` and the console run this. Returns what it said, or null. */
1200
+ export function releaseStalledUpdate(stateDir, now) {
1201
+ let j;
1202
+ try {
1203
+ j = readRuntimeUpdate(stateDir);
1204
+ }
1205
+ catch {
1206
+ return null;
1207
+ }
1208
+ if (!j || j.phase !== "draining")
1209
+ return null;
1210
+ const lock = sqliteLock(join(j.stageDir, "worker.sqlite"));
1211
+ if (!lock)
1212
+ return null;
1213
+ try {
1214
+ // An updater may have moved on, or a resumed one replaced it, between the first read and the lock.
1215
+ const held = readRuntimeUpdate(stateDir);
1216
+ if (!held || held.id !== j.id || held.phase !== "draining")
1217
+ return null;
1218
+ // A job launched or a resume under way has not taken the lock yet: it continues this update; nothing is lifted under it.
1219
+ if (updaterStarting(held, now))
1220
+ return null;
1221
+ try {
1222
+ ungate(held);
1223
+ }
1224
+ catch { /* no pause yet, or the database is unreadable: the journal still says it stopped */ }
1225
+ if (held.kind === "update")
1226
+ rmSync(join(held.stageDir, "runtime"), { recursive: true, force: true });
1227
+ const noun = held.kind === "update" ? "update" : "rollback";
1228
+ const words = `The ${noun} to ${held.to.version} stopped: its updater ended before running work finished. Nothing was changed; new work resumed. Run toolroll ${noun === "update" ? "update" : "update --rollback"} to try again.`;
1229
+ save(held, "refused", words, now);
1230
+ try {
1231
+ ledger(held.databaseFile, now, held.actor, `toolroll ${noun} stopped`, "refused", `${held.from.version} → ${held.to.version}: the updater ended while draining`);
1232
+ }
1233
+ catch { /* the journal still says what happened */ }
1234
+ return words;
1235
+ }
1236
+ finally {
1237
+ lock.close();
1238
+ }
1239
+ }
1240
+ /** What `toolroll status` and the console show: a stalled `toolroll update` released first, then this installation's
1241
+ * waiting update, `toolroll update`'s or the desktop app's. */
1242
+ export function waitingUpdate(databaseFile, inTheWay, now, desktopStates) {
1243
+ const stateDir = dirname(databaseFile);
1244
+ try {
1245
+ releaseStalledUpdate(stateDir, now);
1246
+ }
1247
+ catch { /* an unreadable record is shown as it is */ }
1248
+ return updateWaitingOf(stateDir, inTheWay) ?? desktopUpdateWaitingOf(databaseFile, inTheWay, desktopStates);
1249
+ }
1124
1250
  export function markWhatsNewSeen(stateDir) {
1125
1251
  const j = readRuntimeUpdate(stateDir);
1126
1252
  if (!j || j.seen || j.phase !== "complete")
@@ -1362,6 +1488,10 @@ export async function launchRuntimeUpdate(args, seams = {}) {
1362
1488
  const dist = args.dist ?? dirname(fileURLToPath(import.meta.url));
1363
1489
  const command = [process.execPath, join(dist, "bin.js"), "update", "--resume", "--id", args.id, "--db", args.databaseFile];
1364
1490
  const stateDir = dirname(args.databaseFile);
1491
+ // Pending until launchd starts the job and it holds the lock: nothing releases the update meanwhile.
1492
+ const saved = readRuntimeUpdate(stateDir);
1493
+ if (saved?.id === args.id && !runtimeUpdateTerminal(saved.phase))
1494
+ markStarting(saved, "launched", (seams.now ?? (() => new Date()))());
1365
1495
  const log = join(stateDir, "toolroll-update.log");
1366
1496
  if ((seams.platform ?? process.platform) === "darwin") {
1367
1497
  const home = seams.home ?? homedir();
@@ -66,3 +66,14 @@ export declare class WorkIndexCursorError extends Error {
66
66
  * Cursors bind the admitted scope, project and view, never broaden access. */
67
67
  export declare function workIndexPage(store: Store, now: Date, access: WorkSummaryAccess, options?: WorkIndexOptions): WorkIndexPage;
68
68
  export declare function workCountsByProject(store: Store, now: Date, access: WorkSummaryAccess): WorkProjectCounts[];
69
+ /** Families whose headline is Ready for review, newest first: the Tasks
70
+ * list's own rows, so a completed family (root or any revision), a failed
71
+ * check and a superseded version never count. */
72
+ export declare function workReadyForReview(store: Store, now: Date, access: WorkSummaryAccess, limit?: number): {
73
+ count: number;
74
+ results: {
75
+ rootId: string;
76
+ taskId: string;
77
+ run: number | null;
78
+ }[];
79
+ };
@@ -1,12 +1,17 @@
1
1
  /** Paginated, permission-bound summaries of saved task facts. These reads do
2
- * not verify artifacts, inspect processes, or authorize task operations. */
2
+ * not verify artifacts or authorize task operations; a build Toolroll can't
3
+ * yet confirm stopped is read through the store's own read-only probe. */
3
4
  import { createHash } from 'node:crypto';
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
4
7
  import { readProjectAccess } from './project-access.js';
5
8
  import { pauseOnTaskProviders, providerName, signInReason } from './provider-auth.js';
6
- import { scopeTermsProblem } from './store.js';
9
+ import { scopeTermsProblem, stopFactOf } from './store.js';
7
10
  import { plainReasonOf, replacedWords, stageOfCode, taskStatusOf, workToneOf } from './task-status.js';
8
11
  import { isCheckLevel } from './check-levels.js';
12
+ import { MARKER } from './worktree.js';
9
13
  import { withFollowUps } from './result-follow-ups.js';
14
+ import { NEEDS, processNeedOf } from './needs-you.js';
10
15
  export const WORK_INDEX_PAGE_LIMIT = 40;
11
16
  export const WORK_INDEX_MAX_LIMIT = 100;
12
17
  const EMPTY = { all: 0, 'needs-you': 0, running: 0, completed: 0 };
@@ -20,6 +25,8 @@ const s = (row, key) => row[key] == null ? null : String(row[key]);
20
25
  * MATERIALIZED prevents counts + page selection repeating correlated reads. */
21
26
  /** A gate note counts only while its pause is on one of the task's current providers. */
22
27
  const GATE_PAUSE_ON_TASK = pauseOnTaskProviders('p', 'c.ref_id');
28
+ /** A row whose build may still be running (the store's reading, custodyReadings): Waiting, not Needs you. */
29
+ const CUSTODY_WAITS = `(custody_unresolved AND code IN ('process-needs-attention','result-needs-attention') AND ref_id IN (SELECT value FROM json_each($waitingCustody)))`;
23
30
  const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
24
31
  SELECT t.id, t.title, t.state, t.created_at, t.updated_at, r.id ref_id, r.repo,
25
32
  r.revision_of, r.revision_brief_artifact, r.assigned_runner, r.plan,
@@ -55,7 +62,8 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
55
62
  (SELECT id FROM run WHERE task_ref=a.ref_id AND outcome IS NULL ORDER BY id DESC LIMIT 1) unfinished,
56
63
  CASE WHEN a.state='done' THEN (SELECT id FROM run WHERE task_ref=a.ref_id AND finished_at IS NOT NULL AND role IN ('builder','scout') ORDER BY id DESC LIMIT 1) END result_id,
57
64
  (SELECT h.id FROM hold h WHERE h.task_ref=a.ref_id AND (h.until IS NULL OR h.until>$now) ORDER BY h.held_at,h.id LIMIT 1) hold_id,
58
- q.decision_id,cu.task_ref IS NOT NULL custody_unresolved
65
+ q.decision_id,(cu.task_ref IS NOT NULL OR a.ref_id IN (SELECT value FROM json_each($workspaceCustody)))
66
+ AND a.ref_id NOT IN (SELECT value FROM json_each($settledCustody)) custody_unresolved
59
67
  FROM admitted a LEFT JOIN lineage l ON l.ref_id=a.ref_id
60
68
  LEFT JOIN questions q ON q.task_ref=a.ref_id LEFT JOIN custody cu ON cu.task_ref=a.ref_id
61
69
  ), family_heads AS MATERIALIZED (
@@ -197,9 +205,9 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
197
205
  ELSE 'queued' END code
198
206
  FROM checked f
199
207
  ), ranked AS MATERIALIZED (
200
- SELECT c.*,code NOT IN ('cancelled','complete','running','updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') needs,
208
+ SELECT c.*,code NOT IN ('cancelled','complete','running','updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') AND NOT ${CUSTODY_WAITS} needs,
201
209
  CASE WHEN code='complete' THEN 3 WHEN code='cancelled' THEN 4 WHEN code='running' THEN 1
202
- WHEN code IN ('updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') THEN 2 ELSE 0 END rank,
210
+ WHEN ${CUSTODY_WAITS} OR code IN ('updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') THEN 2 ELSE 0 END rank,
203
211
  CASE WHEN code='complete' THEN completed.at ELSE family_updated END sort_at,
204
212
  completed.actor checked_actor,completed.at checked_at,completed.outcome checked_digest
205
213
  FROM classified c LEFT JOIN action_ledger completed ON completed.id=c.checked_id WHERE ($state IS NULL OR c.state=$state) AND ($leadId IS NULL OR EXISTS(SELECT 1 FROM team_task_owner own WHERE own.task_ref=c.root_ref AND own.lead=$leadId))
@@ -235,8 +243,38 @@ function registerValidators(store) {
235
243
  registered.set(db, projection);
236
244
  return projection;
237
245
  }
238
- function parameters(now, access, options) {
239
- return { $now: now.toISOString(), $alive: new Date(now.getTime() - 180_000).toISOString(),
246
+ /** The list can't see processes: each task whose build Toolroll can't yet
247
+ * confirm stopped, as the store reads it now (the task page's own reading).
248
+ * Null when it has since settled; `open` runs are the classification's own.
249
+ * `workspace`: a finished task whose last build's workspace note is still
250
+ * there, which only the store can read (a held or unreadable workspace). */
251
+ function custodyReadings(store) {
252
+ const refs = store.handle.prepare(`SELECT r.task_ref FROM held_session h JOIN run r ON r.id=h.run WHERE h.ended_at IS NULL
253
+ UNION SELECT r.task_ref FROM run r WHERE r.provider_started_at IS NOT NULL AND NOT EXISTS(SELECT 1 FROM run_process p WHERE p.run=r.id)
254
+ UNION SELECT r.task_ref FROM run_process p JOIN run r ON r.id=p.run WHERE p.exited_at IS NULL AND p.container_empty_at IS NULL`).all().map(row => Number(row['task_ref']));
255
+ const noted = store.handle.prepare(`SELECT r.task_ref,r.worktree FROM task t JOIN task_ref tr ON tr.external_id=t.id
256
+ JOIN run r ON r.id=(SELECT MAX(id) FROM run WHERE task_ref=tr.id AND role IN ('builder','scout') AND finished_at IS NOT NULL)
257
+ WHERE t.state='done' AND r.worktree IS NOT NULL`).all()
258
+ .filter(row => existsSync(join(String(row['worktree']), MARKER))).map(row => Number(row['task_ref']));
259
+ const readings = new Map();
260
+ for (const ref of new Set([...refs, ...noted])) {
261
+ let found = null;
262
+ for (const run of store.runsFor(ref)) {
263
+ const fact = stopFactOf(store, run.id);
264
+ if (fact !== null && fact.kind !== 'open') {
265
+ found = fact;
266
+ break;
267
+ }
268
+ }
269
+ readings.set(ref, found);
270
+ }
271
+ return { readings, workspace: new Set(noted) };
272
+ }
273
+ function parameters(now, access, options, custody) {
274
+ const ids = (keep) => JSON.stringify([...custody.readings].filter(([ref, fact]) => keep(ref, fact)).map(([ref]) => ref));
275
+ return { $settledCustody: ids((_, fact) => fact === null), $waitingCustody: ids((_, fact) => fact !== null && 'wait' in (processNeedOf(fact) ?? {})),
276
+ $workspaceCustody: ids((ref, fact) => fact !== null && custody.workspace.has(ref)),
277
+ $now: now.toISOString(), $alive: new Date(now.getTime() - 180_000).toISOString(),
240
278
  $all: access.repos === null ? 1 : 0, $repos: JSON.stringify(access.repos ?? []),
241
279
  $unplaced: access.principal === 'operator' && access.includeUnplaced === true ? 1 : 0,
242
280
  $projectSet: options.project != null ? 1 : 0, $project: options.project ?? null, $state: options.state ?? null, $leadId: options.leadId ?? null };
@@ -279,6 +317,7 @@ function readCursor(value, scope) {
279
317
  * Cursors bind the admitted scope, project and view, never broaden access. */
280
318
  export function workIndexPage(store, now, access, options = {}) {
281
319
  const projection = registerValidators(store);
320
+ const custody = custodyReadings(store);
282
321
  const view = VIEWS.includes(options.view ?? 'all') ? options.view ?? 'all' : 'all';
283
322
  const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(WORK_INDEX_MAX_LIMIT, Math.floor(options.limit))) : WORK_INDEX_PAGE_LIMIT;
284
323
  const scope = cursorScope(access, options, view), cursor = readCursor(options.cursor, scope);
@@ -309,21 +348,39 @@ export function workIndexPage(store, now, access, options = {}) {
309
348
  'question_run',(SELECT run FROM decision WHERE id=page.question_id),'replaced_by',(SELECT successor FROM task_replacement WHERE task_ref=page.ref_id),
310
349
  'completed_by_lead',(SELECT lead FROM task_act WHERE task_ref=page.root_ref AND act='completed' ORDER BY id DESC LIMIT 1),
311
350
  'question_task',(SELECT r.external_id FROM decision d JOIN run ON run.id=d.run JOIN task_ref r ON r.id=run.task_ref WHERE d.id=page.question_id)) FROM page`)
312
- .all({ ...parameters(now, access, options), $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
351
+ .all({ ...parameters(now, access, options, custody), $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
313
352
  const selected = rows.slice(1).map(row => JSON.parse(String(row['row_json'])));
314
353
  const page = selected.slice(0, limit), last = page.at(-1);
315
354
  const nextCursor = selected.length > limit && last !== undefined ? Buffer.from(JSON.stringify({ version: 1, scope,
316
355
  rank: n(last, 'rank'), at: s(last, 'sort_at'), root: n(last, 'root_ref') })).toString('base64url') : null;
317
356
  const projects = JSON.parse(String(rows[0]?.['row_json'] ?? '[]')).map(row => ({ repo: s(row, 'repo'), totals: counts(row), queued: n(row, 'queued'), doneRecently: n(row, 'done_recently') }));
318
- return { items: page.map(row => itemOf(row, access.principal)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]), nextCursor, limit, view };
357
+ // A row whose build Toolroll can't confirm stopped reads the store's own answer, as the task page does.
358
+ const probe = (taskId) => {
359
+ const ref = store.lookupRef(taskId);
360
+ return ref === null ? null : custody.readings.get(ref.id) ?? null;
361
+ };
362
+ return { items: page.map(row => itemOf(row, access.principal, probe)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]), nextCursor, limit, view };
319
363
  }
320
364
  export function workCountsByProject(store, now, access) {
321
365
  const projection = registerValidators(store);
322
366
  return store.handle.prepare(`${projection} SELECT repo,${TOTALS},SUM(state='queued' AND family_running=0) queued,
323
367
  SUM(state='done' AND family_running=0 AND updated_at>=$recent) done_recently FROM ranked GROUP BY repo ORDER BY repo`)
324
- .all({ ...parameters(now, access, {}), $recent: new Date(now.getTime() - 86_400_000).toISOString() })
368
+ .all({ ...parameters(now, access, {}, custodyReadings(store)), $recent: new Date(now.getTime() - 86_400_000).toISOString() })
325
369
  .map(row => ({ repo: s(row, 'repo'), totals: counts(row), queued: n(row, 'queued'), doneRecently: n(row, 'done_recently') }));
326
370
  }
371
+ /** Families whose headline is Ready for review, newest first: the Tasks
372
+ * list's own rows, so a completed family (root or any revision), a failed
373
+ * check and a superseded version never count. */
374
+ export function workReadyForReview(store, now, access, limit = 5) {
375
+ const ready = [];
376
+ let cursor = null;
377
+ do {
378
+ const page = workIndexPage(store, now, access, { view: 'needs-you', limit: WORK_INDEX_MAX_LIMIT, cursor });
379
+ ready.push(...page.items.filter(one => one.status.label === 'Ready for review'));
380
+ cursor = page.nextCursor;
381
+ } while (cursor !== null);
382
+ return { count: ready.length, results: ready.slice(0, Math.max(0, limit)).map(one => ({ rootId: one.rootId, taskId: one.activeTaskId, run: one.resultRunId })) };
383
+ }
327
384
  /** Labels stay navigation, never an authorization to mutate. Full task
328
385
  * opening re-proves process custody, receipt bytes and approval terms. */
329
386
  /** The list's Checks: the build's own result, its level, and follow-up checks on the same commit (result-follow-ups.ts). */
@@ -347,9 +404,16 @@ function listChecksOf(own, level, follows) {
347
404
  return null;
348
405
  return { status: read.status, exitCode: null, head: null, level: read.level, running: read.running };
349
406
  }
350
- function itemOf(row, principal) {
407
+ /** Index codes that wait on a person, as their need (needs-you.ts); dispatch codes come from task-status's own table. */
408
+ const INDEX_NEED = {
409
+ 'earlier-active': 'earlier-version', 'process-needs-attention': 'confirm-stopped', 'invalid-scope': 'define-task', 'decision-queue': 'questions',
410
+ };
411
+ function itemOf(row, principal, probe) {
351
412
  const code = String(row['code']), id = String(row['id']);
352
- const need = n(row, 'needs') === 1, running = n(row, 'family_running') === 1;
413
+ const custody = code === 'process-needs-attention' || (code === 'result-needs-attention' && n(row, 'custody_unresolved') === 1);
414
+ const process = custody && probe !== undefined ? processNeedOf(probe(id)) : null;
415
+ const waiting = process !== null && 'wait' in process;
416
+ const need = n(row, 'needs') === 1 && !waiting, running = n(row, 'family_running') === 1;
353
417
  const assignmentState = code === 'complete' ? 'complete' : code === 'ready-to-check' ? 'ready-to-check'
354
418
  : code === 'cancelled' ? 'cancelled' : need ? 'needs-decision' : 'working';
355
419
  const words = {
@@ -375,7 +439,7 @@ function itemOf(row, principal) {
375
439
  'needs-scope': ['Needs your decision', 'Define the task before a worker can start.', 'write-scope', 'Define the task'],
376
440
  'invalid-scope': ['Needs your decision', 'The saved task scope cannot be read safely.', 'write-scope', 'Review scope'],
377
441
  'needs-agent-profile': ['Needs your decision', 'Choose an available agent for this task.', 'select-agent', 'Choose an agent'],
378
- 'needs-approval': ['Needs your decision', 'Review the current plan before a worker can start.', 'approve-scope', 'Review plan'],
442
+ 'needs-approval': ['Needs your decision', 'Review the current plan before a worker can start.', 'approve-scope', 'Approve plan'],
379
443
  'missing-requirement': ['Needs your decision', 'A required capability is missing or expired.', 'repair-capability', 'Review missing requirement'],
380
444
  'no-worker-registered': ['Needs your decision', 'No connected builder includes this project.', 'start-worker', 'Check connection'],
381
445
  'no-worker-online': ['Needs your decision', 'The builder has stopped checking in.', 'start-worker', 'Check connection'],
@@ -409,6 +473,18 @@ function itemOf(row, principal) {
409
473
  actionCode = 'open-result';
410
474
  actionLabel = 'Open result';
411
475
  }
476
+ if (process !== null && 'need' in process) {
477
+ actionCode = 'confirm-stopped';
478
+ actionLabel = NEEDS[process.need].action.label;
479
+ }
480
+ else if (custody && probe === undefined) {
481
+ actionCode = 'confirm-stopped';
482
+ actionLabel = NEEDS['confirm-stopped'].action.label;
483
+ }
484
+ if (code === 'signed-out') {
485
+ actionCode = 'sign-in';
486
+ actionLabel = NEEDS['sign-in'].action.label;
487
+ }
412
488
  if (actionCode === null) {
413
489
  actionCode = code === 'running' ? 'inspect-run' : 'inspect-task';
414
490
  actionLabel = code === 'running' ? 'Watch the build' : 'View task details';
@@ -417,21 +493,32 @@ function itemOf(row, principal) {
417
493
  const primaryAction = actionCode === null ? null : { code: actionCode, label: actionLabel,
418
494
  target: { taskId: successor ?? (code === 'earlier-active' ? s(row, 'earlier_id') ?? id : actionCode === 'answer-decision' ? s(row, 'question_task') ?? id : id),
419
495
  runId: actionCode === 'answer-decision' ? n(row, 'question_run') || null : actionCode === 'inspect-run' && code === 'running' ? n(row, 'live_run') || null : actionCode === 'open-result' || actionCode === 'inspect-run' ? n(row, 'result_id') || null
420
- : actionCode === 'reconcile-run' ? n(row, 'unfinished') || null : ['resume-run', 'inspect-stop'].includes(actionCode) ? n(row, 'stop_run') || null : null,
496
+ : actionCode === 'reconcile-run' ? n(row, 'unfinished') || null : ['resume-run', 'inspect-stop'].includes(actionCode) ? n(row, 'stop_run') || null
497
+ : actionCode === 'confirm-stopped' ? process?.build ?? (n(row, 'result_id') || null) : null,
421
498
  decisionId: actionCode === 'answer-decision' ? n(row, 'question_id') || null : null },
422
499
  access: read ? 'read' : principal === 'operator' ? 'operator-control' : ['answer-decision', 'write-scope', 'unhold'].includes(actionCode) ? 'proposal-only' : 'operator-handoff',
423
500
  retry: actionCode === 'reconcile-run' ? 'reconcile-before-retry' : read ? 'read-again' : 'refresh-before-acting' };
424
501
  // The one shared status: the headline and sentence every surface uses.
425
- const reading = stageOfCode(code, { needsPerson: need, planning: s(row, 'live_role') === 'planner', operatorHold: s(row, 'hold_kind') === 'operator' });
502
+ const coded = stageOfCode(code, { needsPerson: need, planning: s(row, 'live_role') === 'planner', operatorHold: s(row, 'hold_kind') === 'operator' });
503
+ const reading = process !== null
504
+ ? 'wait' in process ? { stage: 'waiting', wait: process.wait } : { stage: 'needs-you', need: process.need }
505
+ : custody ? { stage: 'needs-you', need: 'confirm-stopped' }
506
+ : coded.stage === 'needs-you' && (coded.need === undefined || coded.need === 'other') && INDEX_NEED[code] !== undefined ? { stage: 'needs-you', need: INDEX_NEED[code] } : coded;
426
507
  const checkStatus = s(row, 'check_status'), verdict = s(row, 'proof_verdict');
427
508
  const finished = reading.stage === 'finished' || reading.stage === 'complete';
428
509
  const own = checkStatus === 'failed' || n(row, 'proof_failed_check') === 1 ? 'failed' : checkStatus === 'passed' || verdict === 'verified' ? 'passed' : null;
429
- const shared = taskStatusOf({ stage: reading.stage, ...(reading.need === undefined ? {} : { need: reading.need }),
510
+ const shared = taskStatusOf({ stage: reading.stage, ...(reading.need === undefined ? {} : { need: reading.need }), ...(reading.wait === undefined ? {} : { wait: reading.wait }),
511
+ ...(process === null ? {} : { needContext: { build: process.build } }),
430
512
  reason: finished ? null : plainReasonOf(reading.stage, code, detail || null), report: s(row, 'result_role') === 'scout',
431
513
  checks: !finished ? null : listChecksOf(own, s(row, 'check_level'), row['check_follows']),
432
514
  completedBy: code !== 'complete' ? null : n(row, 'completed_by_lead') === 1 ? 'the lead' : String(row['checked_actor']).replace(/^(?:operator|coordinator|lead):/, '') });
433
515
  label = shared.headline;
434
516
  detail = shared.sentence;
517
+ // Every Needs you action wears its need's own words.
518
+ if (shared.need != null && shared.need.key !== 'other' && shared.need.key !== 'review-result' && primaryAction !== null) {
519
+ actionLabel = shared.need.action.label;
520
+ primaryAction.label = actionLabel;
521
+ }
435
522
  const views = ['all'];
436
523
  if (need)
437
524
  views.push('needs-you');