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
package/dist/store.js CHANGED
@@ -215,6 +215,20 @@ CREATE TABLE IF NOT EXISTS project_mute (
215
215
  const SECURITY_KINDS = new Set(["secret-detected", "security-release"]);
216
216
  /** A failure that leaves nothing for the lead to try: the attempts are spent. */
217
217
  const SPENT_KINDS = new Set(["attempts-exhausted", "plan-attempts-exhausted"]);
218
+ /** Store.stopQuiescenceFact, read safely: a deploy proves completion with this code over the INSTALLED runtime's store,
219
+ * which may predate the method. There the older stopQuiescenceProblem decides, and its words give the kind. */
220
+ export function stopFactOf(store, runId) {
221
+ if (typeof store.stopQuiescenceFact === "function")
222
+ return store.stopQuiescenceFact(runId);
223
+ const problem = store.stopQuiescenceProblem(runId);
224
+ if (problem === null)
225
+ return null;
226
+ const kind = /still open|does not exist/.test(problem) ? "open"
227
+ : /another host/.test(problem) ? "elsewhere"
228
+ : /may still be running|still has|held by|has not finished|still has members/.test(problem) ? "alive"
229
+ : /cannot be proven|could not be established|incomplete native/.test(problem) ? "unknown" : "unprovable";
230
+ return { run: Number(/run #(\d+)/.exec(problem)?.[1] ?? runId), kind, problem };
231
+ }
218
232
  /** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
219
233
  * nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
220
234
  * failure across checks, so a row can say both. `account` is a display name only, never a credential. */
@@ -10007,6 +10021,10 @@ export class Store {
10007
10021
  stampWorktreeSetup(path, digest) {
10008
10022
  this.db.prepare("UPDATE worktree SET setup_digest = ? WHERE path = ?").run(digest, path);
10009
10023
  }
10024
+ /** The checkout no longer holds what its setup made (its dependencies were dropped): the next lease runs setup again. */
10025
+ forgetWorktreeSetup(path) {
10026
+ this.db.prepare("UPDATE worktree SET setup_digest = NULL WHERE path = ?").run(path);
10027
+ }
10010
10028
  // ---- verify command (Priority 2) ---------------------------------------
10011
10029
  /**
10012
10030
  * Approve one repo's verification command, revoking any predecessor in
@@ -12721,6 +12739,22 @@ export class Store {
12721
12739
  .get(taskRef, head);
12722
12740
  return Number(row?.["n"] ?? 0);
12723
12741
  }
12742
+ /** Why a finished result can't be built again on the same filing: it was
12743
+ * verified, attested or accepted, or its branch was published (revised
12744
+ * through its pull request). Null when `requeueTask` may run it again. */
12745
+ finalResultReason(runId) {
12746
+ const verdict = this.proofVerdictFor(runId);
12747
+ if (verdict !== null && (verdict.verdict === "verified" || verdict.verdict === "attested"))
12748
+ return "accepted-result";
12749
+ if (this.proofAcceptance(runId) !== null)
12750
+ return "accepted-result";
12751
+ // An intent that never pushed is not a publication; a pushed or
12752
+ // opened branch is, and is revised through its pull request.
12753
+ const publication = this.publicationForRun(runId);
12754
+ if (publication !== null && (publication.state === "pushed" || publication.state === "opened"))
12755
+ return "published";
12756
+ return null;
12757
+ }
12724
12758
  requeueTask(taskId, by, now) {
12725
12759
  return this.transact(() => {
12726
12760
  const ref = this.db
@@ -12759,16 +12793,9 @@ export class Store {
12759
12793
  }
12760
12794
  else {
12761
12795
  rejectedRun = Number(last["id"]);
12762
- const verdict = this.proofVerdictFor(rejectedRun);
12763
- if (verdict !== null && (verdict.verdict === "verified" || verdict.verdict === "attested"))
12764
- return { ok: false, reason: "accepted-result" };
12765
- if (this.proofAcceptance(rejectedRun) !== null)
12766
- return { ok: false, reason: "accepted-result" };
12767
- // An intent that never pushed is not a publication; a pushed or
12768
- // opened branch is, and is revised through its pull request.
12769
- const publication = this.publicationForRun(rejectedRun);
12770
- if (publication !== null && (publication.state === "pushed" || publication.state === "opened"))
12771
- return { ok: false, reason: "published" };
12796
+ const final = this.finalResultReason(rejectedRun);
12797
+ if (final !== null)
12798
+ return { ok: false, reason: final };
12772
12799
  }
12773
12800
  }
12774
12801
  else if (incidents.length === 0 && state !== "failed") {
@@ -20981,21 +21008,32 @@ export class Store {
20981
21008
  /** Every owned run must have ended, and every retained local process
20982
21009
  * witness must be absent. Read-only probes never authorize PID kills. */
20983
21010
  stopQuiescenceProblem(runId) {
21011
+ return this.stopQuiescenceFact(runId)?.problem ?? null;
21012
+ }
21013
+ /** The same answer as stopQuiescenceProblem, with which run it names and
21014
+ * what kind of answer it is (needs-you.ts reads it): `alive` while
21015
+ * something of the run may still be running, `elsewhere` when only
21016
+ * another computer can tell, `unprovable` when nothing is known to run
21017
+ * but its exit was never recorded, `unknown` when this computer can't
21018
+ * check at all (both are what `run settle` resolves; only the first may
21019
+ * be called not running). */
21020
+ stopQuiescenceFact(runId) {
21021
+ const fact = (run, kind, problem) => ({ run, kind, problem });
20984
21022
  const ids = this.ownedRunsOf(runId);
20985
21023
  if (ids.length === 0)
20986
- return `run #${runId} does not exist`;
21024
+ return fact(runId, "open", `run #${runId} does not exist`);
20987
21025
  for (const id of ids) {
20988
21026
  const run = this.getRun(id);
20989
21027
  if (run.outcome === null)
20990
- return `run #${id} is still open`;
21028
+ return fact(id, "open", `run #${id} is still open`);
20991
21029
  if (ownedProcessCount(runOwnerTag(this, id)) > 0)
20992
- return `run #${id} still has an owned subprocess`;
21030
+ return fact(id, "alive", `run #${id} still has an owned subprocess`);
20993
21031
  const held = this.heldSessionOf(id);
20994
21032
  if (held !== null && held.endedAt === null)
20995
- return `run #${id}'s held supervisor has not finished shutdown`;
21033
+ return fact(id, "alive", `run #${id}'s held supervisor has not finished shutdown`);
20996
21034
  const witnesses = this.db.prepare("SELECT * FROM run_process WHERE run = ? ORDER BY id").all(id);
20997
21035
  if (run.providerStartedAt !== null && witnesses.length === 0) {
20998
- return `run #${id} may have spawned before its process witness was recorded; exit is unproven`;
21036
+ return fact(id, "unprovable", `run #${id} may have spawned before its process witness was recorded; exit is unproven`);
20999
21037
  }
21000
21038
  for (const witness of witnesses) {
21001
21039
  if (witness["exited_at"] !== null)
@@ -21009,12 +21047,12 @@ export class Store {
21009
21047
  if (provenDeadByBootChange({ host, bootId }))
21010
21048
  continue;
21011
21049
  if (host !== hostname())
21012
- return `run #${id}'s process belongs to another host; exit is unproven here`;
21050
+ return fact(id, "elsewhere", `run #${id}'s process belongs to another host; exit is unproven here`);
21013
21051
  const backend = witness["containment"] === null || witness["containment"] === undefined ? null : String(witness["containment"]);
21014
21052
  const container = witness["container"] === null || witness["container"] === undefined ? null : String(witness["container"]);
21015
21053
  if (backend !== null || container !== null) {
21016
21054
  if (backend === null || container === null)
21017
- return `run #${id} has incomplete native containment custody`;
21055
+ return fact(id, "unknown", `run #${id} has incomplete native containment custody`);
21018
21056
  if (witness["container_empty_at"] !== null)
21019
21057
  continue;
21020
21058
  // A native witness settles ONLY on the OS's word: the object is
@@ -21022,30 +21060,37 @@ export class Store {
21022
21060
  // when empty). A transport exit never proved it.
21023
21061
  const state = containerEmptiness(backend, container, process.platform, witness["container_identity"] == null ? null : String(witness["container_identity"]));
21024
21062
  if (state === "populated")
21025
- return `run #${id}'s ${backend} object ${container} still has members`;
21063
+ return fact(id, "alive", `run #${id}'s ${backend} object ${container} still has members`);
21026
21064
  if (state === "unknown")
21027
- return `run #${id}'s ${backend} object ${container} cannot be proven empty from here`;
21065
+ return fact(id, "unknown", `run #${id}'s ${backend} object ${container} cannot be proven empty from here`);
21028
21066
  continue;
21029
21067
  }
21030
21068
  if (witness["pid"] === null)
21031
- return `run #${id} has an incomplete spawn witness; exit is unproven`;
21069
+ return fact(id, "unprovable", `run #${id} has an incomplete spawn witness; exit is unproven`);
21032
21070
  const pid = Number(witness["pid"]);
21033
21071
  if (processMayBeAlive(pid, witness["process_group"] === 1, { observedAt: witness["observed_at"], finishedAt: run.finishedAt }))
21034
- return `run #${id}'s process ${pid} may still be running`;
21072
+ return fact(id, "alive", `run #${id}'s process ${pid} may still be running`);
21035
21073
  }
21036
21074
  if (run.worktree !== null) {
21037
21075
  try {
21038
21076
  const occupied = worktreeProcessOccupancy(run.worktree);
21039
21077
  if (occupied.held)
21040
- return `run #${id}'s workspace is still held by process ${occupied.by}`;
21078
+ return fact(id, "alive", `run #${id}'s workspace is still held by process ${occupied.by}`);
21041
21079
  }
21042
21080
  catch {
21043
- return `run #${id}'s workspace occupancy could not be established`;
21081
+ // An unreadable workspace note is no proof either way; an approver's recorded word settles it.
21082
+ if (!this.workspaceConfirmedByApprover(id))
21083
+ return fact(id, "unknown", `run #${id}'s workspace occupancy could not be established`);
21044
21084
  }
21045
21085
  }
21046
21086
  }
21047
21087
  return null;
21048
21088
  }
21089
+ /** `run settle` recorded an approver's word that a run whose workspace
21090
+ * note couldn't be read has stopped. */
21091
+ workspaceConfirmedByApprover(runId) {
21092
+ return this.db.prepare("SELECT 1 FROM action_ledger WHERE run_id = ? AND action = 'workspace settled by approver' LIMIT 1").get(runId) !== undefined;
21093
+ }
21049
21094
  reserveRunProcess(runId, now, group = true) {
21050
21095
  return Number(this.db.prepare("INSERT INTO run_process (run,host,process_group,observed_at,boot_id) VALUES (?,?,?,?,?)")
21051
21096
  .run(runId, hostname(), group ? 1 : 0, now.toISOString(), currentBootId()).lastInsertRowid);
@@ -21059,6 +21104,10 @@ export class Store {
21059
21104
  const rows = only === undefined
21060
21105
  ? this.db.prepare("SELECT * FROM run_process WHERE run = ? AND exited_at IS NULL").all(runId)
21061
21106
  : this.db.prepare("SELECT * FROM run_process WHERE run = ? AND pid = ? AND process_group = ? AND exited_at IS NULL").all(runId, only.pid, only.group ? 1 : 0);
21107
+ // A finished run's witness gets the same proof the deploy's quiescence
21108
+ // check uses: ESRCH, or a process now holding the pid that was born after
21109
+ // the run ended. An open run has no finish time, so only ESRCH counts.
21110
+ const finishedAt = this.getRun(runId)?.finishedAt ?? null;
21062
21111
  let recorded = 0;
21063
21112
  for (const row of rows) {
21064
21113
  const host = String(row["host"]), bootId = row["boot_id"] == null ? null : String(row["boot_id"]);
@@ -21072,9 +21121,7 @@ export class Store {
21072
21121
  gone = backend !== null && container !== null && (row["container_empty_at"] != null || containerEmptiness(backend, container, process.platform, row["container_identity"] == null ? null : String(row["container_identity"])) === "empty");
21073
21122
  }
21074
21123
  else {
21075
- // Do not borrow the run's finish time or guess a legacy spawn time.
21076
- // With no historical witness argument only ESRCH proves absence.
21077
- gone = row["pid"] != null && !processMayBeAlive(Number(row["pid"]), row["process_group"] === 1);
21124
+ gone = row["pid"] != null && !processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, finishedAt === null ? undefined : { observedAt: row["observed_at"], finishedAt });
21078
21125
  }
21079
21126
  }
21080
21127
  if (gone)
@@ -21085,6 +21132,25 @@ export class Store {
21085
21132
  }
21086
21133
  return recorded;
21087
21134
  }
21135
+ /** The reconcile road for a finished run whose process outlived its
21136
+ * finish: once each process is provably gone, its exit is recorded, so
21137
+ * the task stops waiting on a person by itself. A run that can't be proven
21138
+ * yet stays open and is passed over, never holding newer runs behind it:
21139
+ * every finished run is visited, a batch at a time. Never signals. */
21140
+ recordFinishedRunExits(now, batch = 200) {
21141
+ const page = this.db.prepare(`SELECT DISTINCT p.run FROM run_process p JOIN run r ON r.id = p.run
21142
+ WHERE p.exited_at IS NULL AND p.host = ? AND p.run > ? AND r.outcome IS NOT NULL AND r.finished_at IS NOT NULL
21143
+ ORDER BY p.run LIMIT ?`);
21144
+ let recorded = 0;
21145
+ for (let after = 0;;) {
21146
+ const runs = page.all(hostname(), after, batch).map(row => Number(row["run"]));
21147
+ for (const run of runs)
21148
+ recorded += this.recordRunProcessExits(run, now);
21149
+ if (runs.length < batch)
21150
+ return recorded;
21151
+ after = runs[runs.length - 1];
21152
+ }
21153
+ }
21088
21154
  finishUnspawnedProcess(witness, now) {
21089
21155
  this.db.prepare("UPDATE run_process SET exited_at = ? WHERE id = ? AND pid IS NULL AND ((containment IS NULL AND container IS NULL) OR container_empty_at IS NOT NULL)")
21090
21156
  .run(now.toISOString(), witness);
@@ -21179,12 +21245,42 @@ export class Store {
21179
21245
  return alive(`run #${args.runId}'s ${String(row["containment"])} object ${String(row["container"])} still has members`);
21180
21246
  }
21181
21247
  }
21182
- if (open.length === 0)
21183
- return { ok: true, witnesses: [], repeated: true };
21248
+ // A workspace still held by a live process refuses; one whose note can't
21249
+ // be read is settled by the approver's word, in the ledger.
21250
+ let workspace = false;
21251
+ if (run.worktree !== null) {
21252
+ try {
21253
+ const occupied = worktreeProcessOccupancy(run.worktree);
21254
+ if (occupied.held)
21255
+ return alive(`run #${args.runId}'s workspace is still held by process ${occupied.by}`);
21256
+ }
21257
+ catch {
21258
+ workspace = !this.workspaceConfirmedByApprover(args.runId);
21259
+ }
21260
+ }
21261
+ const ref = this.refById(run.taskRef);
21262
+ if (workspace) {
21263
+ this.recordAction({ at: now.toISOString(), actor: args.by, repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId: args.runId,
21264
+ action: "workspace settled by approver", outcome: run.worktree ?? "", source: "request", detail: args.why });
21265
+ }
21266
+ // A run that may have spawned before any witness was written has none
21267
+ // to settle: the approver's word is recorded as one ended witness.
21268
+ if (rows.length === 0 && run.providerStartedAt !== null) {
21269
+ const id = Number(this.db.prepare("INSERT INTO run_process (run,host,process_group,observed_at,boot_id,exited_at) VALUES (?,?,0,?,?,?)")
21270
+ .run(args.runId, hostname(), now.toISOString(), currentBootId(), now.toISOString()).lastInsertRowid);
21271
+ this.recordAction({ at: now.toISOString(), actor: args.by, repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId: args.runId,
21272
+ action: "process witness settled by approver", outcome: `witness ${id}`, source: "request", detail: args.why });
21273
+ this.settleQuiescentStops(now);
21274
+ return { ok: true, witnesses: [id], repeated: false };
21275
+ }
21276
+ if (open.length === 0) {
21277
+ if (workspace)
21278
+ this.settleQuiescentStops(now);
21279
+ return { ok: true, witnesses: [], repeated: !workspace };
21280
+ }
21184
21281
  const ids = open.map(row => Number(row["id"]));
21185
21282
  for (const id of ids)
21186
21283
  this.db.prepare("UPDATE run_process SET exited_at = ? WHERE id = ? AND run = ? AND exited_at IS NULL").run(now.toISOString(), id, args.runId);
21187
- const ref = this.refById(run.taskRef);
21188
21284
  this.recordAction({ at: now.toISOString(), actor: args.by, repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId: args.runId,
21189
21285
  action: "process witness settled by approver", outcome: `witness ${ids.join(", ")}`, source: "request", detail: args.why });
21190
21286
  this.settleQuiescentStops(now);
@@ -11,9 +11,12 @@
11
11
  * output) is an amber note on its own row with one action; the exact
12
12
  * technical reason stays one tap away in `why`. */
13
13
  import type { AssignmentSnapshot } from "./assignment.js";
14
+ import { type NeedAction, type NeedContext, type NeedKey, type WaitKey } from "./needs-you.js";
15
+ export type { NeedKey, WaitKey } from "./needs-you.js";
14
16
  /** A cancelled task with a successor reads "Replaced by <id>" everywhere, never "Cancelled". */
15
17
  export declare const replacedWords: (successor: string) => string;
16
- export declare const HEADLINES: readonly ["Queued", "Planning", "Needs you", "Building", "Ready for review", "Complete", "Failed", "Stopped"];
18
+ /** Waiting: a reason no person can act on, worded as what it waits for (needs-you.ts). */
19
+ export declare const HEADLINES: readonly ["Queued", "Planning", "Needs you", "Waiting", "Building", "Ready for review", "Complete", "Failed", "Stopped"];
17
20
  export type Headline = (typeof HEADLINES)[number];
18
21
  export type HeadlineTone = "neutral" | "live" | "attention" | "ready" | "success" | "danger";
19
22
  export declare const HEADLINE_TONE: Readonly<Record<Headline, HeadlineTone>>;
@@ -34,6 +37,7 @@ export type StatusDetail = {
34
37
  action: StatusAction | null;
35
38
  why: string | null;
36
39
  };
40
+ /** `need`: what a Needs you asks for and the one action that resolves it; null under every other headline. */
37
41
  export type TaskStatus = {
38
42
  headline: Headline;
39
43
  tone: HeadlineTone;
@@ -41,9 +45,13 @@ export type TaskStatus = {
41
45
  details: StatusDetail[];
42
46
  primaryAction: StatusAction | null;
43
47
  why: string[];
48
+ need?: {
49
+ key: NeedKey;
50
+ action: NeedAction;
51
+ } | null;
44
52
  };
45
53
  /** `reviewing`: the one automatic review (build-review.ts) is reading a finished build; it reads Building, not a headline of its own. */
46
- export type TaskStage = "queued" | "planning" | "needs-you" | "building" | "checking" | "reviewing" | "finished" | "complete" | "failed" | "stopped";
54
+ export type TaskStage = "queued" | "planning" | "needs-you" | "waiting" | "building" | "checking" | "reviewing" | "finished" | "complete" | "failed" | "stopped";
47
55
  export type ChecksFact = {
48
56
  status: "passed" | "failed" | "running" | "not-run" | "unavailable";
49
57
  exitCode: number | null;
@@ -66,8 +74,12 @@ export type PullRequestFact = {
66
74
  };
67
75
  export type TaskStatusFacts = {
68
76
  stage: TaskStage;
69
- /** Needs you: what a person is asked for. The sentence names it. */
70
- need?: "approval" | "answer" | "sign-in" | "other";
77
+ /** Needs you: what a person is asked for (needs-you.ts). The sentence names it. */
78
+ need?: NeedKey;
79
+ /** Waiting: what the work waits for when no person can act. */
80
+ wait?: WaitKey;
81
+ /** The build or agent a need's sentence names. */
82
+ needContext?: NeedContext;
71
83
  /** The recorded reason in plain words, when the stage has one. */
72
84
  reason?: string | null;
73
85
  /** A research report rather than a code change. */
@@ -124,12 +136,14 @@ export declare function pullRequestFactOf(publication: {
124
136
  * the exact reason (a running planner, a pause, a failed attempt, a sign-in). */
125
137
  export declare function assignmentStageOf(assignment: Pick<AssignmentSnapshot, "state" | "primaryAction"> & {
126
138
  review?: AssignmentSnapshot["review"];
139
+ need?: AssignmentSnapshot["need"];
127
140
  }, work?: {
128
141
  token: string;
129
142
  views?: readonly string[];
130
143
  } | null, planning?: boolean): {
131
144
  stage: TaskStage;
132
145
  need?: TaskStatusFacts["need"];
146
+ wait?: WaitKey;
133
147
  };
134
148
  /** Requirements from the stored matrix: met, the ones only a person confirms, and the total. */
135
149
  export declare function requirementsOf(matrix: readonly {
@@ -1,9 +1,11 @@
1
+ import { CODE_NEED, NEEDS, needSentence, waitSentence } from "./needs-you.js";
1
2
  /** A cancelled task with a successor reads "Replaced by <id>" everywhere, never "Cancelled". */
2
3
  export const replacedWords = (successor) => `Replaced by ${successor}`;
3
4
  const REPLACED = /^Replaced by \S+/;
4
- export const HEADLINES = ["Queued", "Planning", "Needs you", "Building", "Ready for review", "Complete", "Failed", "Stopped"];
5
+ /** Waiting: a reason no person can act on, worded as what it waits for (needs-you.ts). */
6
+ export const HEADLINES = ["Queued", "Planning", "Needs you", "Waiting", "Building", "Ready for review", "Complete", "Failed", "Stopped"];
5
7
  export const HEADLINE_TONE = {
6
- Queued: "neutral", Planning: "live", "Needs you": "attention", Building: "live",
8
+ Queued: "neutral", Planning: "live", "Needs you": "attention", Waiting: "neutral", Building: "live",
7
9
  "Ready for review": "ready", Complete: "success", Failed: "danger", Stopped: "neutral",
8
10
  };
9
11
  const short = (sha) => sha !== null && /^[a-f0-9]{7,40}$/.test(sha) ? sha.slice(0, 7) : null;
@@ -12,6 +14,7 @@ export function headlineOf(facts) {
12
14
  case "queued": return "Queued";
13
15
  case "planning": return "Planning";
14
16
  case "needs-you": return "Needs you";
17
+ case "waiting": return "Waiting";
15
18
  case "building":
16
19
  case "checking":
17
20
  case "reviewing": return "Building";
@@ -33,10 +36,9 @@ function sentenceOf(headline, facts) {
33
36
  switch (headline) {
34
37
  case "Queued": return reason ?? "Waiting for a worker.";
35
38
  case "Planning": return reason ?? "The lead is writing the plan.";
36
- // An approval always reads the same way; the plan itself names the work.
37
- case "Needs you": return facts.need === "approval" ? "Review the plan and approve it to start." : reason ?? (facts.need === "answer" ? "Answer the question so the work can continue."
38
- : facts.need === "sign-in" ? "Sign in again; the task starts on its own after."
39
- : "Something needs your decision before the work can continue.");
39
+ // One plain sentence per need (needs-you.ts); a recorded reason only where the need keeps one.
40
+ case "Needs you": return needSentence(facts.need ?? "other", reason, facts.needContext);
41
+ case "Waiting": return waitSentence(facts.wait, reason, facts.needContext);
40
42
  case "Building": return facts.stage === "checking" ? "Checks are running on the change."
41
43
  : facts.stage === "reviewing" ? "Reviewing the change before it reaches you." : reason ?? "An agent is working on it.";
42
44
  case "Ready for review":
@@ -133,8 +135,12 @@ function detailsOf(headline, facts) {
133
135
  }
134
136
  export function taskStatusOf(facts) {
135
137
  const headline = headlineOf(facts);
138
+ // Every Needs you carries the one action that resolves it, worded the same everywhere.
139
+ const need = headline === "Needs you" ? { key: facts.need ?? "other", action: NEEDS[facts.need ?? "other"].action } : null;
140
+ const ownWords = need !== null && (need.key === "other" || need.key === "review-result") && facts.action != null;
141
+ const primaryAction = need === null || ownWords ? facts.action ?? null : { label: need.action.label, href: facts.action?.href ?? null };
136
142
  return { headline, tone: HEADLINE_TONE[headline], sentence: sentenceOf(headline, facts), details: detailsOf(headline, facts),
137
- primaryAction: facts.action ?? null, why: [...new Set(facts.why ?? [])].filter(one => one.trim() !== "") };
143
+ primaryAction, why: [...new Set(facts.why ?? [])].filter(one => one.trim() !== ""), need };
138
144
  }
139
145
  // ---- reading the existing projections ----------------------------------------
140
146
  /** Every dispatch or work-index code, as the stage it means. Unknown codes that
@@ -146,6 +152,7 @@ const CODE_STAGE = {
146
152
  "signed-out": ["needs-you", "sign-in"],
147
153
  "needs-approval": ["needs-you", "approval"],
148
154
  "waiting-decision": ["needs-you", "answer"], "decision-queue": ["needs-you", "answer"],
155
+ "build-stopping": ["waiting"], "other-computer": ["waiting"],
149
156
  stopping: ["stopped"], stopped: ["stopped"], paused: ["stopped"], cancelled: ["stopped"],
150
157
  failed: ["failed"], "waiting-incident": ["failed"], "checks-failed": ["failed"],
151
158
  "ready-to-check": ["finished"], "ready-to-review": ["finished"], "report-ready": ["finished"], "no-change": ["finished"],
@@ -166,11 +173,11 @@ export function stageOfCode(code, options = {}) {
166
173
  if (code === "running" && options.planning)
167
174
  return { stage: "planning" };
168
175
  if (code === "held")
169
- return options.operatorHold === false ? { stage: "needs-you", need: "other" } : { stage: "stopped" };
176
+ return options.operatorHold === false ? { stage: "needs-you", need: "hold" } : { stage: "stopped" };
170
177
  const known = CODE_STAGE[code];
171
178
  if (known !== undefined)
172
179
  return known[1] === undefined ? { stage: known[0] } : { stage: known[0], need: known[1] };
173
- return options.needsPerson === false ? { stage: "queued" } : { stage: "needs-you", need: "other" };
180
+ return options.needsPerson === false ? { stage: "queued" } : { stage: "needs-you", need: CODE_NEED[code] ?? "other" };
174
181
  }
175
182
  /** The pull request as the task page's follower reports it, or the bare publication row. */
176
183
  export function pullRequestFactOf(publication) {
@@ -197,6 +204,10 @@ export function pullRequestFactOf(publication) {
197
204
  /** The assignment's stage. The work status, when the caller read one, names
198
205
  * the exact reason (a running planner, a pause, a failed attempt, a sign-in). */
199
206
  export function assignmentStageOf(assignment, work, planning = false) {
207
+ // The assignment's own reading (a build Toolroll can't confirm stopped) comes first.
208
+ const own = assignment.need ?? null;
209
+ if (own !== null && (assignment.state === "working" || assignment.state === "needs-decision"))
210
+ return "wait" in own ? { stage: "waiting", wait: own.wait } : { stage: "needs-you", need: own.key };
200
211
  switch (assignment.state) {
201
212
  case "cancelled": return { stage: "stopped" };
202
213
  case "complete": return { stage: "complete" };
@@ -211,8 +222,10 @@ export function assignmentStageOf(assignment, work, planning = false) {
211
222
  const action = assignment.primaryAction?.code;
212
223
  if (action === "approve-scope")
213
224
  return { stage: "needs-you", need: "approval" };
214
- if (action === "answer-decision" || action === "inspect-decisions")
225
+ if (action === "answer-decision")
215
226
  return { stage: "needs-you", need: "answer" };
227
+ if (action === "inspect-decisions")
228
+ return { stage: "needs-you", need: "questions" };
216
229
  const token = work?.token ?? "";
217
230
  // A failed attempt is Failed. A saved result that still needs a decision
218
231
  // (its scope, a hold, a process exit) is Needs you, even if its checks
@@ -220,7 +233,10 @@ export function assignmentStageOf(assignment, work, planning = false) {
220
233
  if (token === "failed" || token === "waiting-incident")
221
234
  return { stage: "failed" };
222
235
  const read = stageOfCode(token, { needsPerson: true, operatorHold: action === "unhold" });
223
- return read.stage === "stopped" ? read : { stage: "needs-you", need: read.need ?? "other" };
236
+ if (read.stage === "stopped" || read.stage === "waiting")
237
+ return read;
238
+ // A saved result that isn't ready keeps its own reading; a task's dispatch reason names its need.
239
+ return { stage: "needs-you", need: read.need ?? "other" };
224
240
  }
225
241
  }
226
242
  }
@@ -243,11 +259,12 @@ export function evidenceOf(receipt, problems = receipt?.caveats ?? []) {
243
259
  }
244
260
  /** The status facts of one assignment, as every surface reads them. */
245
261
  export function assignmentStatusFacts(assignment, options = {}) {
246
- const { stage, need } = assignmentStageOf(assignment, options.work ?? null, options.planning ?? false);
262
+ const { stage, need, wait } = assignmentStageOf(assignment, options.work ?? null, options.planning ?? false);
263
+ const build = assignment.need?.build ?? null;
247
264
  const receipt = assignment.receipt;
248
265
  const finished = stage === "finished" || stage === "complete";
249
266
  // A saved result's rows show whenever it is the reason a person is needed, too.
250
- const withResult = finished || stage === "failed" || (stage === "needs-you" && receipt !== null);
267
+ const withResult = finished || stage === "failed" || ((stage === "needs-you" || stage === "waiting") && receipt !== null);
251
268
  const report = receipt?.completionKind === "research-report";
252
269
  const checks = receipt === null || !withResult ? null
253
270
  : { status: receipt.checks.status, exitCode: receipt.checks.exitCode, head: receipt.head,
@@ -257,11 +274,11 @@ export function assignmentStatusFacts(assignment, options = {}) {
257
274
  : publication === null ? (withResult && !report ? { state: "none", number: null, url: null, ci: null, error: null } : null)
258
275
  : pullRequestFactOf(publication);
259
276
  // Ready, Complete and Failed speak for themselves; every other stage keeps its recorded reason.
260
- const reason = stage === "needs-you" || stage === "stopped" || stage === "queued" || (stage === "failed" && checks?.status !== "failed")
277
+ const reason = stage === "needs-you" || stage === "waiting" || stage === "stopped" || stage === "queued" || (stage === "failed" && checks?.status !== "failed")
261
278
  ? stage === "stopped" && assignment.state === "cancelled" ? REPLACED.test(assignment.detail) ? assignment.detail : "Cancelled. Nothing else will run." : plainReasonOf(stage, options.work?.token ?? "", options.work?.detail ?? assignment.detail)
262
279
  : null;
263
280
  return {
264
- stage, ...(need === undefined ? {} : { need }), reason, report,
281
+ stage, ...(need === undefined ? {} : { need }), ...(wait === undefined ? {} : { wait }), ...(build === null ? {} : { needContext: { build } }), reason, report,
265
282
  checks,
266
283
  pullRequest: withResult ? pullRequest : null,
267
284
  requirements: withResult ? requirementsOf(receipt?.proof?.matrix) : null,
@@ -292,7 +309,7 @@ export function statusDetailLines(status) {
292
309
  }
293
310
  /** The headline's emoji for chat cards (colour is never the only signal: the words follow). */
294
311
  export function headlineEmoji(headline) {
295
- return { Queued: "🕓", Planning: "📝", "Needs you": "👋", Building: "⏳", "Ready for review": "✅", Complete: "✅", Failed: "❌", Stopped: "⏹" }[headline];
312
+ return { Queued: "🕓", Planning: "📝", "Needs you": "👋", Waiting: "⏸", Building: "⏳", "Ready for review": "✅", Complete: "✅", Failed: "❌", Stopped: "⏹" }[headline];
296
313
  }
297
314
  const escapeHtml = (value) => value.replace(/[&<>"']/g, char => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
298
315
  const ICON_PATHS = {
@@ -332,8 +349,8 @@ export function stageOfDispatch(d, options = {}) {
332
349
  if (d.code === "complete" || d.code.startsWith("review-"))
333
350
  return { stage: options.completed ? "complete" : "finished" };
334
351
  if (d.code === "proof-refuted")
335
- return /approved check failed|checks failed/i.test(d.detail ?? "") ? { stage: "failed" } : { stage: "needs-you", need: "other" };
352
+ return /approved check failed|checks failed/i.test(d.detail ?? "") ? { stage: "failed" } : { stage: "needs-you", need: "review-result" };
336
353
  if (d.code === "needs-verification")
337
- return { stage: "needs-you", need: "other" };
354
+ return { stage: "needs-you", need: "review-result" };
338
355
  return stageOfCode(d.code, { needsPerson: d.condition === "waiting" && d.action !== null, planning: d.condition === "running" && d.role === "planner", operatorHold: d.action === "unhold" });
339
356
  }
@@ -86,6 +86,7 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
86
86
  const failedChecks = status.details.some(one => one.key === "checks" && (one.mark === "failed" || one.mark === "note"));
87
87
  const recovery = operatorHold !== undefined || accessBlocked || (!built && run.outcome !== null);
88
88
  return { text, next, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
89
- link: { label: built ? "Open result" : "Open task", path: recovery
89
+ // Needs you: the button names the action that resolves it.
90
+ link: { label: status.need != null && status.need.key !== "other" && status.need.key !== "review-result" ? status.need.action.label : built ? "Open result" : "Open task", path: recovery
90
91
  ? chatControlHref("recovery", taskId) : built ? chatResultHref(taskId, run.id, failedChecks ? "checks" : "summary") : chatControlHref("task", taskId) } };
91
92
  }
@@ -3,7 +3,7 @@ import { manualReviewOnly } from "./proof.js";
3
3
  /** Read-only phone views over the same dispatch and proof records as chat.
4
4
  * No provider calls, repository access, new workflow state, or inferred success. */
5
5
  import { diagnoseTaskDispatch, withDispatchDiagnoses } from "./dispatch.js";
6
- import { COMPLETION_ACTION } from "./result-completion.js";
6
+ import { familyCompleted } from "./result-completion.js";
7
7
  import { plainReasonOf, stageOfDispatch, taskStatusOf } from "./task-status.js";
8
8
  import { CHAT_CONTROLS, chatControlHref, chatResultHref } from "./chat-controls.js";
9
9
  export function phoneCommand(text) {
@@ -54,8 +54,8 @@ export function projectLabel(repo) {
54
54
  }
55
55
  /** The shared headline (task-status.ts) for a dispatch diagnosis on the phone. */
56
56
  function headlineFor(store, id, d) {
57
- const completed = d.code === "complete" && store.handle.prepare("SELECT 1 FROM action_ledger WHERE task_id = ? AND action = ? AND source = 'work' LIMIT 1")
58
- .get(store.taskFamilyOf(id, null, true)?.root.id ?? id, COMPLETION_ACTION) !== undefined;
57
+ // Any version of a completed family reads Complete, as taskWaitSnapshot says.
58
+ const completed = d.code === "complete" && familyCompleted(store, id);
59
59
  const read = stageOfDispatch(d, { completed });
60
60
  const finished = read.stage === "finished" || read.stage === "complete" || read.stage === "failed";
61
61
  return taskStatusOf({ stage: read.stage, ...(read.need === undefined ? {} : { need: read.need }), reason: finished ? null : plainReasonOf(read.stage, d.code, d.detail),
@@ -0,0 +1,19 @@
1
+ /** Name prefixes of what tests, journeys and scripts leave in the temp folder. */
2
+ export declare const TEST_TEMP_PREFIXES: readonly string[];
3
+ export declare function isTestTemp(name: string): boolean;
4
+ /** Where they collect: the temp folder (and /tmp, where some scripts write). */
5
+ export declare function tempRoots(): string[];
6
+ export type TempLeftover = {
7
+ path: string;
8
+ touchedAt: number;
9
+ };
10
+ /** Every test temp folder in `roots`, and which of them nothing has touched for a day. */
11
+ export declare function testTempFolders(roots: readonly string[], now: Date): {
12
+ all: TempLeftover[];
13
+ stale: TempLeftover[];
14
+ };
15
+ /** Remove the test temp folders nothing has touched for a day; each is looked at again right before it goes. */
16
+ export declare function removeStaleTestTemp(roots: readonly string[], now: Date): {
17
+ removed: string[];
18
+ failed: string[];
19
+ };