toolroll 0.9.94 → 0.9.95

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 (191) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/dist/agent-fence.d.ts +6 -0
  3. package/dist/agent-fence.js +2 -2
  4. package/dist/assignment-status.d.ts +1 -1
  5. package/dist/assignment-ui.js +2 -0
  6. package/dist/attest.d.ts +3 -0
  7. package/dist/attest.js +10 -0
  8. package/dist/browser/terminal.js +1 -1
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/browser/workspace.js +24 -24
  11. package/dist/browser-crew.js +2 -0
  12. package/dist/browser-workspace.d.ts +5 -0
  13. package/dist/build-review.js +2 -0
  14. package/dist/builder.d.ts +7 -2
  15. package/dist/builder.js +183 -79
  16. package/dist/card-catalog.d.ts +209 -0
  17. package/dist/card-catalog.js +133 -0
  18. package/dist/card-cli.d.ts +43 -0
  19. package/dist/card-cli.js +44 -0
  20. package/dist/card-render.d.ts +26 -0
  21. package/dist/card-render.js +223 -0
  22. package/dist/card-sources.d.ts +68 -0
  23. package/dist/card-sources.js +234 -0
  24. package/dist/chat-actions.d.ts +95 -8
  25. package/dist/chat-actions.js +449 -45
  26. package/dist/chat-approval.d.ts +13 -0
  27. package/dist/chat-approval.js +39 -9
  28. package/dist/chat-card-part.d.ts +11 -0
  29. package/dist/chat-card-part.js +18 -0
  30. package/dist/chat-card.d.ts +46 -0
  31. package/dist/chat-card.js +99 -0
  32. package/dist/chat-channel.d.ts +2 -0
  33. package/dist/chat-channel.js +26 -14
  34. package/dist/chat-controls.d.ts +4 -0
  35. package/dist/chat-controls.js +4 -0
  36. package/dist/chat-decide.d.ts +3 -14
  37. package/dist/chat-decide.js +43 -54
  38. package/dist/chat-delivery.js +13 -4
  39. package/dist/chat-flow.d.ts +0 -27
  40. package/dist/chat-flow.js +31 -2
  41. package/dist/chat-quiet.js +1 -1
  42. package/dist/chat-task-actions.d.ts +3 -2
  43. package/dist/chat-task-actions.js +19 -5
  44. package/dist/chat-terms.d.ts +14 -2
  45. package/dist/chat-terms.js +35 -0
  46. package/dist/check-failure.d.ts +2 -0
  47. package/dist/check-failure.js +34 -2
  48. package/dist/check-repair.d.ts +47 -0
  49. package/dist/check-repair.js +240 -0
  50. package/dist/claim.d.ts +6 -0
  51. package/dist/claim.js +13 -2
  52. package/dist/cli-help.d.ts +1 -1
  53. package/dist/cli-help.js +2 -0
  54. package/dist/cli.js +4 -0
  55. package/dist/coding-provider.d.ts +1 -1
  56. package/dist/coding-provider.js +9 -5
  57. package/dist/coding-workspace.d.ts +1 -1
  58. package/dist/coding-workspace.js +4 -4
  59. package/dist/contracts/chat-actions.d.ts +83 -0
  60. package/dist/contracts/chat-actions.js +18 -0
  61. package/dist/contracts/chat-card.d.ts +91 -0
  62. package/dist/contracts/chat-card.js +54 -0
  63. package/dist/contracts/chat-content.d.ts +191 -0
  64. package/dist/contracts/chat-content.js +4 -0
  65. package/dist/contracts/console-api.d.ts +2 -0
  66. package/dist/contracts/console-api.js +2 -0
  67. package/dist/contracts/flow-propose.d.ts +8 -1
  68. package/dist/contracts/flow-propose.js +3 -1
  69. package/dist/contracts/flow-send.d.ts +126 -0
  70. package/dist/contracts/flow-send.js +3 -0
  71. package/dist/contracts/lead-tools.d.ts +173 -0
  72. package/dist/contracts/lead-tools.js +15 -0
  73. package/dist/contracts/store-json.d.ts +16 -0
  74. package/dist/discord-chat.js +14 -0
  75. package/dist/dispatch.d.ts +1 -1
  76. package/dist/dispatch.js +7 -1
  77. package/dist/dispose.d.ts +19 -0
  78. package/dist/dispose.js +47 -7
  79. package/dist/eval-oracle.js +4 -1
  80. package/dist/evidence-pair.d.ts +53 -0
  81. package/dist/evidence-pair.js +128 -0
  82. package/dist/exhaustion.d.ts +17 -4
  83. package/dist/exhaustion.js +44 -4
  84. package/dist/export.js +1 -1
  85. package/dist/failing-first.d.ts +31 -0
  86. package/dist/failing-first.js +44 -0
  87. package/dist/fallback-schema.d.ts +8 -0
  88. package/dist/fallback-schema.js +40 -0
  89. package/dist/fallback.d.ts +138 -0
  90. package/dist/fallback.js +605 -0
  91. package/dist/findings.d.ts +224 -0
  92. package/dist/findings.js +449 -0
  93. package/dist/fleet-schema.d.ts +17 -0
  94. package/dist/fleet-schema.js +135 -0
  95. package/dist/flow-browser.js +2 -2
  96. package/dist/flow-code.d.ts +2 -2
  97. package/dist/flow-code.js +9 -5
  98. package/dist/flow-result-card.d.ts +22 -0
  99. package/dist/flow-result-card.js +55 -0
  100. package/dist/flow-send.js +12 -9
  101. package/dist/flow-steps.js +1 -1
  102. package/dist/flow-triggers.js +1 -1
  103. package/dist/flow-unattended.d.ts +26 -0
  104. package/dist/flow-unattended.js +12 -3
  105. package/dist/flows-ui.js +10 -2
  106. package/dist/follow-through-brief.d.ts +12 -0
  107. package/dist/follow-through-brief.js +31 -0
  108. package/dist/follow-through-cli.d.ts +23 -0
  109. package/dist/follow-through-cli.js +153 -0
  110. package/dist/git-incident.d.ts +16 -0
  111. package/dist/git-incident.js +69 -6
  112. package/dist/handover-note.d.ts +33 -0
  113. package/dist/handover-note.js +106 -0
  114. package/dist/invoke.d.ts +20 -1
  115. package/dist/invoke.js +108 -44
  116. package/dist/lead-cli.js +26 -24
  117. package/dist/lead-contract.js +1 -0
  118. package/dist/lead-doors.d.ts +4 -0
  119. package/dist/lead-doors.js +2 -2
  120. package/dist/lead-progress.js +2 -0
  121. package/dist/lead-tools.js +111 -32
  122. package/dist/lead.js +1 -1
  123. package/dist/local-owner.js +5 -0
  124. package/dist/modes.d.ts +6 -6
  125. package/dist/modes.js +3 -1
  126. package/dist/needs-you.d.ts +5 -2
  127. package/dist/needs-you.js +14 -2
  128. package/dist/operate-remote-arguments.js +1 -0
  129. package/dist/operate-remote.js +1 -1
  130. package/dist/operate.d.ts +1 -1
  131. package/dist/operate.js +165 -14
  132. package/dist/plan-login.d.ts +14 -0
  133. package/dist/plan-login.js +58 -0
  134. package/dist/planner.d.ts +1 -1
  135. package/dist/planner.js +2 -1
  136. package/dist/project-delete-ui.d.ts +12 -0
  137. package/dist/project-delete-ui.js +18 -1
  138. package/dist/project-delete.js +6 -0
  139. package/dist/project-knowledge.d.ts +6 -6
  140. package/dist/project-tools.d.ts +4 -4
  141. package/dist/project-tools.js +23 -7
  142. package/dist/protected-storage-look.d.ts +1 -0
  143. package/dist/protected-storage-look.js +13 -0
  144. package/dist/protected-storage.d.ts +70 -39
  145. package/dist/protected-storage.js +196 -147
  146. package/dist/provider-capture.d.ts +34 -0
  147. package/dist/provider-capture.js +53 -0
  148. package/dist/release-carry.js +10 -1
  149. package/dist/release.js +9 -1
  150. package/dist/scope.d.ts +22 -3
  151. package/dist/scope.js +71 -20
  152. package/dist/scout.d.ts +1 -1
  153. package/dist/scout.js +2 -1
  154. package/dist/serve.js +2 -0
  155. package/dist/server/chat.js +2 -2
  156. package/dist/server/console.css +50 -0
  157. package/dist/server/render-chat.js +14 -1
  158. package/dist/server/render-follow-through.d.ts +11 -0
  159. package/dist/server/render-follow-through.js +68 -0
  160. package/dist/server/render-results.js +1 -1
  161. package/dist/server/render-tasks.d.ts +3 -0
  162. package/dist/server/render-tasks.js +24 -5
  163. package/dist/server/route-table.js +3 -0
  164. package/dist/server/settings.js +37 -12
  165. package/dist/server/task-views.js +5 -0
  166. package/dist/server/tasks.js +76 -0
  167. package/dist/settings-groups-ui.d.ts +1 -0
  168. package/dist/settings-groups-ui.js +3 -1
  169. package/dist/settings-kit.js +3 -2
  170. package/dist/settings-proofs.js +3 -1
  171. package/dist/setup-guide.d.ts +2 -2
  172. package/dist/slack-chat.js +14 -1
  173. package/dist/steer-mailbox.d.ts +8 -1
  174. package/dist/steer-mailbox.js +164 -6
  175. package/dist/store.d.ts +11 -4
  176. package/dist/store.js +93 -30
  177. package/dist/surface.js +24 -4
  178. package/dist/task-control.d.ts +2 -1
  179. package/dist/task-status.js +5 -0
  180. package/dist/teardown-git.d.ts +2 -0
  181. package/dist/teardown-git.js +42 -16
  182. package/dist/telegram-lead.js +23 -6
  183. package/dist/telegram.js +12 -1
  184. package/dist/toolroll-update.js +2 -0
  185. package/dist/work-index.js +22 -7
  186. package/dist/work-summary.d.ts +1 -1
  187. package/dist/work-summary.js +5 -2
  188. package/dist/workspace-ui.d.ts +3 -1
  189. package/dist/workspace-ui.js +10 -1
  190. package/dist/worktree.js +23 -1
  191. package/package.json +2 -1
package/dist/surface.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { KNOWLEDGE_DESCRIPTORS } from "./knowledge-cli.js";
2
2
  import { MEMORY_DESCRIPTORS } from "./memory-cli.js";
3
+ import { CARD_DESCRIPTOR } from "./card-cli.js";
3
4
  import { FLAKY_DESCRIPTORS } from "./flaky-cli.js";
4
5
  import { MODELS_DESCRIPTORS } from "./models-cli.js";
5
6
  import { MAP_DESCRIPTORS } from "./code-map-cli.js";
@@ -119,6 +120,7 @@ const GUIDE_ROWS = [
119
120
  operator("demo", "a seeded throwaway sandbox"),
120
121
  ...KNOWLEDGE_DESCRIPTORS.map(spec => ({ invocation: `knowledge ${spec.action}`, synopsis: spec.synopsis, audience: "audience" in spec ? spec.audience : "agent", agentMayInvoke: !("audience" in spec), mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text or source file for impact" }] } : {}) })),
121
122
  ...MEMORY_DESCRIPTORS.map(spec => ({ invocation: `memory ${spec.action}`, synopsis: spec.synopsis, audience: "audience" in spec ? spec.audience : "agent", agentMayInvoke: !("audience" in spec), mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text, a decision id, or the decision sentence" }] } : {}) })),
123
+ { invocation: CARD_DESCRIPTOR.invocation, synopsis: CARD_DESCRIPTOR.synopsis, audience: "agent", agentMayInvoke: true, mutation: "none", flags: CARD_DESCRIPTOR.flags, positionals: [{ name: "source", required: true, meaning: "which data to show" }] },
122
124
  ...FLAKY_DESCRIPTORS.map(spec => ({ invocation: `flaky ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "identity", required: true, meaning: "a row's key from flaky list" }] } : {}) })),
123
125
  // Flow writes carry an approver's credential: an agent runs them only as the person asked, after showing the preview.
124
126
  // Letting a flow run on its own raises authority: the owner's own password ceremony, never an agent's act.
@@ -327,7 +329,19 @@ const GUIDE_ROWS = [
327
329
  { name: "digest", takesValue: true, meaning: "the exact receipt from assignment show; accepts and finishes exactly that result" },
328
330
  { name: "despite", takesValue: true, meaning: "accept with this reason, without finishing; never combines with --finish or --digest (--note is its alias)" }],
329
331
  notableReasons: ["usage", "unknown-task", "no-run", "not-ready"] },
330
- operator("task repair", "inspect or approve an existing historical repair draft; use task revise for new feedback"),
332
+ operator("task repair", "inspect or approve a repair draft; --yes starts the repair a failed check is waiting for, under the normal plan approval"),
333
+ operatorRead("task handover", "where a task stands, from Toolroll's records: revision, decisions, rejected approaches, open findings, checks and the next action"),
334
+ operator("task before-after", "before-and-after evidence: read it, save a failing result, or rerun the same check on the latest commit with no agent"),
335
+ operator("task failing-first", "ask a bug fix's next attempt to write a failing test first (on|off); shown in the result as the agent's report"),
336
+ operatorRead("finding list", "findings still waiting on someone, or every finding of --task <id>"),
337
+ operatorRead("finding show", "one finding: its text, evidence and how it stands"),
338
+ operator("finding send", "send a finding to the task that owns the code; the owner must accept it or reject it with a reason"),
339
+ operator("finding resend", "send an answered finding back, one more round; past the limit it goes to a person"),
340
+ operator("finding close", "a person's decision on a finding"),
341
+ operatorRead("captures list", "what providers said when attempts failed, the ones mentioning limits first; nothing routes on these"),
342
+ operatorRead("captures show", "one saved provider failure"),
343
+ operatorRead("captures export", "one saved provider failure as a fixture"),
344
+ operator("captures mark", "a person's reading of a capture: exhausted or not-exhausted"),
331
345
  operator("task route", "which configured agents plan, build and repair this task; phase and size changes require renewed approval"),
332
346
  operator("task reopen", "resume external work its tracker closed and has been SEEN open again"),
333
347
  operator("task stop", "stop ONE exact live attempt (--run <id>): durable before any process is signalled, answers 'stopping' until its own processes are established gone; work, branch, and evidence preserved; no strike, no retry"),
@@ -509,6 +523,11 @@ const REMOTE_NO = [
509
523
  // Reads this machine owner's own Claude and Codex session files (~/.claude, ~/.codex).
510
524
  "memory propose",
511
525
  ];
526
+ const REMOTE_FOLLOW_THROUGH = [
527
+ // On the server's machine or in the console: each writes through a verified person, and they read saved model text.
528
+ "task handover", "task before-after", "task failing-first", "finding list", "finding show", "finding send", "finding resend", "finding close",
529
+ "captures list", "captures show", "captures export", "captures mark",
530
+ ];
512
531
  const REMOTE_STEP_UP = [
513
532
  "proposals", "decide", "task regate", "task approve", "task accept", "task merge", "run settle",
514
533
  // Putting tasks away and restoring them: a person's act, in the console or on the server's machine.
@@ -533,7 +552,7 @@ const REMOTE_YES = [
533
552
  "knowledge search", "knowledge impact", "knowledge refresh",
534
553
  "map", "map area", "map file", "map search", "map refresh",
535
554
  "memory search", "memory decisions", "memory show", "memory review", "memory status",
536
- "flaky list", "flaky show",
555
+ "flaky list", "flaky show", "card",
537
556
  "flows list", "flows show", "flows export", "flows create", "flows edit", "flows trigger add", "flows trigger pause", "flows trigger resume",
538
557
  "flows trigger remove", "flows card add", "flows archive",
539
558
  "models status", "models list",
@@ -541,11 +560,12 @@ const REMOTE_YES = [
541
560
  /** Every row's remote policy, by invocation. Exported for the contract test, which holds it to the guide exactly. */
542
561
  export const REMOTE_POLICY = new Map([
543
562
  ...REMOTE_NO.map(invocation => [invocation, "no"]),
563
+ ...REMOTE_FOLLOW_THROUGH.map(invocation => [invocation, "no"]),
544
564
  ...REMOTE_STEP_UP.map(invocation => [invocation, "step-up"]),
545
565
  ...REMOTE_YES.map(invocation => [invocation, "yes"]),
546
566
  ]);
547
567
  /** How many times each invocation was classified — the contract test refuses a row stated twice. */
548
- export const REMOTE_POLICY_COUNT = REMOTE_NO.length + REMOTE_STEP_UP.length + REMOTE_YES.length;
568
+ export const REMOTE_POLICY_COUNT = REMOTE_NO.length + REMOTE_FOLLOW_THROUGH.length + REMOTE_STEP_UP.length + REMOTE_YES.length;
549
569
  /**
550
570
  * Easy-start C3: the commands a laptop's local owner may run through the terminal UI (local-owner.ts runAsLocalOwner),
551
571
  * each with the act it is; every other row (and every command with no row) is refused to the local owner before any
@@ -554,7 +574,7 @@ export const REMOTE_POLICY_COUNT = REMOTE_NO.length + REMOTE_STEP_UP.length + RE
554
574
  const LOCAL_ALLOW = {
555
575
  // Reads.
556
576
  pulls: "read", graph: "read", repos: "read", map: "read", "map area": "read", "map file": "read", "map search": "read", status: "read", ready: "read", gaps: "read", contract: "read", "skills list": "read", "skills get": "read",
557
- "flaky list": "read", "flaky show": "read", "memory review": "read", "memory status": "read", "memory decisions": "read", "memory show": "read",
577
+ "flaky list": "read", "flaky show": "read", card: "read", "memory review": "read", "memory status": "read", "memory decisions": "read", "memory show": "read",
558
578
  // Learning loops (D3): a lesson note or a clean-up, decided by a person at the laptop (the writers check the act).
559
579
  "memory apply": "memory-review", "memory tidy": "memory-review", "memory restore": "memory-review", "memory unmerge": "memory-review",
560
580
  "task list": "read", "task show": "read", "task state": "read", "task wait": "read", "check-progress": "read", "review show": "read", welcome: "read",
@@ -19,6 +19,7 @@
19
19
  * hold, recovery, and invocation-gateway machinery. This
20
20
  * module only names the door and the fence.
21
21
  */
22
+ import type { StopVia } from "./store.js";
22
23
  import { type DispatchDiagnosis } from "./dispatch.js";
23
24
  import type { Run, RunStop, Store } from "./store.js";
24
25
  /** How often a running attempt re-reads its stop row while a child runs.
@@ -86,7 +87,7 @@ export type ResumeRequest = {
86
87
  taskId: string;
87
88
  runId: number;
88
89
  by: string;
89
- via: "cli" | "web";
90
+ via: StopVia;
90
91
  /** The workspace occupancy probe (the worktree pool's `inUse`): a
91
92
  * checkout some process still holds is not quiescent, whatever the
92
93
  * rows say — the concrete gate names the pid. */
@@ -385,6 +385,9 @@ export function pullRequestFactOf(publication) {
385
385
  /** The assignment's stage. The work status, when the caller read one, names
386
386
  * the exact reason (a running planner, a pause, a failed attempt, a sign-in). */
387
387
  export function assignmentStageOf(assignment, work, planning = false) {
388
+ // A finding held for a person outlives the stage the work is in, a saved result included: only a person closes it.
389
+ if (work?.token === "finding-held" && assignment.state !== "cancelled")
390
+ return { stage: "needs-you", need: "finding" };
388
391
  // The assignment's own reading (a build Toolroll can't confirm stopped) comes first.
389
392
  const own = assignment.need ?? null;
390
393
  if (own !== null && (assignment.state === "working" || assignment.state === "needs-decision"))
@@ -407,6 +410,8 @@ export function assignmentStageOf(assignment, work, planning = false) {
407
410
  return { stage: "needs-you", need: "answer" };
408
411
  if (action === "inspect-decisions")
409
412
  return { stage: "needs-you", need: "questions" };
413
+ if (action === "inspect-findings")
414
+ return { stage: "needs-you", need: "finding" };
410
415
  const token = work?.token ?? "";
411
416
  // A failed attempt is Failed. A saved result that still needs a decision
412
417
  // (its scope, a hold, a process exit) is Needs you, even if its checks
@@ -50,6 +50,8 @@ export type GitBinding = {
50
50
  * real `.git` folder, holding no `commondir`. No pointer file or `commondir` an agent can write decides it, and no git
51
51
  * runs (a page visit reads this). Throws with the reason otherwise. */
52
52
  export declare function recordedCommonDir(repo: string): string;
53
+ /** Where git incidents are recorded: beside the fenced views folder (git-incident.ts). */
54
+ export declare function gitIncidentRecord(): string;
53
55
  /** Settings fixed on every write through a view: `worktree add` records absolute paths (relative ones would need a
54
56
  * config write, which a view keeps); no transport at all (a caller that fetches allows its one protocol by name, as the
55
57
  * nightly check's local fetch does with protocol.file.allow); and no split index (its shared index files would be
@@ -191,7 +191,7 @@ function nestedRepository(path) {
191
191
  * repository (boundDirs; `binding.repo` when the caller has the project's), is refused; so is a command whose
192
192
  * repository changed under it while git ran.
193
193
  */
194
- async function inView(cwd, binding, work, after = true, lockTimeoutMs, gitFile = "git") {
194
+ async function inView(cwd, binding, work, after = true, lockTimeoutMs, doing, gitFile = "git") {
195
195
  const root = checkoutRootOf(cwd);
196
196
  if (root === null)
197
197
  return NOT_A_REPOSITORY;
@@ -207,7 +207,7 @@ async function inView(cwd, binding, work, after = true, lockTimeoutMs, gitFile =
207
207
  return await withCheckoutLock(realpathSync(homedir()), key, async () => {
208
208
  let view;
209
209
  try {
210
- view = gitView(root, "write", binding, gitFile);
210
+ view = gitView(root, "write", binding, gitFile, undefined, doing);
211
211
  }
212
212
  catch (error) {
213
213
  return refused(error instanceof Error ? error.message : "the checkout couldn't be read");
@@ -255,6 +255,7 @@ export function teardownGit(runner = run, binding = {}, lockTimeoutMs, gitFile =
255
255
  const forced = removal && args.includes("--force");
256
256
  const inspection = args.includes("status") || args.includes("diff");
257
257
  const target = removal ? resolve(cwd, args.at(-1)) : cwd;
258
+ const doing = teardownDoing(args);
258
259
  const safeArgs = [...args];
259
260
  if (inspection && !safeArgs.includes("--ignore-submodules=all")) {
260
261
  const command = safeArgs.findIndex(arg => arg === "status" || arg === "diff");
@@ -288,8 +289,22 @@ export function teardownGit(runner = run, binding = {}, lockTimeoutMs, gitFile =
288
289
  // the target's view, inspected above, so it judges the target by the target's own settings.
289
290
  return ran(removal && !forced ? { ...view.env, GIT_COMMON_DIR: targetView.env["GIT_COMMON_DIR"] } : view.env);
290
291
  // A removed target has no git directory left to prove: the folder it was removed from is proven instead.
291
- }, !removal, lockTimeoutMs, gitFile);
292
- }, true, lockTimeoutMs, gitFile);
292
+ }, !removal, lockTimeoutMs, doing, gitFile);
293
+ }, true, lockTimeoutMs, teardownDoing(args), gitFile);
294
+ }
295
+ /** What teardown git is doing, for an incident's notice: removing a checkout, or looking one over to tidy it. */
296
+ function teardownDoing(args) {
297
+ return args.includes("worktree") && args.includes("remove") ? "removing an old checkout" : "tidying an old checkout";
298
+ }
299
+ /** What a build's git is doing, for an incident's notice. */
300
+ function checkoutDoing(args) {
301
+ if (args.includes("worktree"))
302
+ return args.includes("remove") ? "removing a checkout" : args.includes("add") ? "making a checkout" : "listing checkouts";
303
+ if (args.some(arg => arg === "commit" || arg === "add" || arg === "merge" || arg === "rebase" || arg === "reset" || arg === "checkout" || arg === "switch"))
304
+ return "saving a build's work";
305
+ if (args.some(arg => arg === "fetch" || arg === "push"))
306
+ return "updating a repository";
307
+ return "reading a build's checkout";
293
308
  }
294
309
  /** A setup call's time limit: its own five seconds, or less when a caller's deadline leaves less (never below 1 ms;
295
310
  * a caller checks the deadline itself before calling). */
@@ -330,7 +345,7 @@ function regularFile(path) {
330
345
  }
331
346
  /** A checkout's git directory and common directory, as git finds them from its `.git` (a folder, or a one-line
332
347
  * `gitdir: ` file); null when the pointer isn't one git would read the same way. */
333
- function gitDirsOf(checkout) {
348
+ function gitDirsOf(checkout, vouched = NO_VOUCHED) {
334
349
  let gitdir = join(checkout, ".git");
335
350
  try {
336
351
  if (!lstatSync(gitdir).isDirectory()) {
@@ -350,11 +365,14 @@ function gitDirsOf(checkout) {
350
365
  // Where the checkout's own files point (a build can write both): read, in the console, only while that folder
351
366
  // answered a look lately, and never vouched for by the checkout pool's spelling (folder-gate.ts). Held back: not one
352
367
  // git would read the same way, so every caller refuses.
353
- if (gitdir !== join(checkout, ".git") && !folderAnswers(gitdir, true))
368
+ // `vouched`: the very folders this view was made from, read again after git ran (settle). They answered moments ago
369
+ // and git just ran in them; the gate's staleness is no evidence about them. Anything they now point elsewhere is
370
+ // gated as ever, so a re-pointed `.git` or `commondir` is never read on the console's thread on its say.
371
+ if (gitdir !== join(checkout, ".git") && !vouched.has(gitdir) && !folderAnswers(gitdir, true))
354
372
  return null;
355
373
  const named = regularFile(join(gitdir, "commondir"))?.trim();
356
374
  const common = named === undefined || named === "" ? gitdir : resolve(gitdir, named);
357
- if (common !== gitdir && !folderAnswers(common, true))
375
+ if (common !== gitdir && !vouched.has(common) && !folderAnswers(common, true))
358
376
  return null;
359
377
  return { gitdir, common };
360
378
  }
@@ -382,7 +400,8 @@ function includeIn(path, gitFile = "git", limit) {
382
400
  return named.toLowerCase();
383
401
  return /^\s*\[\s*include(if)?\b/im.test(text) ? "include" : null;
384
402
  }
385
- function boundDirs(checkout, binding = {}) {
403
+ const NO_VOUCHED = new Set();
404
+ function boundDirs(checkout, binding = {}, vouched = NO_VOUCHED) {
386
405
  const fail = (why) => { throw new Error(`the checkout's .git ${why}`); };
387
406
  let top;
388
407
  try {
@@ -403,12 +422,12 @@ function boundDirs(checkout, binding = {}) {
403
422
  if (held.isDirectory()) {
404
423
  if (existsSync(join(dotgit, "commondir")))
405
424
  fail("names another repository (commondir)");
406
- dirs = { checkout: top, gitdir: dotgit, common: dotgit, linked: false };
425
+ dirs = { checkout: top, gitdir: dotgit, common: dotgit, linked: false, spelled: [dotgit] };
407
426
  }
408
427
  else {
409
428
  if (!held.isFile())
410
429
  fail("is a link");
411
- const named = gitDirsOf(top);
430
+ const named = gitDirsOf(top, vouched);
412
431
  if (named === null)
413
432
  return fail("couldn't be read as git reads it");
414
433
  let gitdir;
@@ -444,7 +463,7 @@ function boundDirs(checkout, binding = {}) {
444
463
  }
445
464
  if (backReal !== dotgit)
446
465
  fail("belongs to a git directory that names another checkout");
447
- dirs = { checkout: top, gitdir, common, linked: true };
466
+ dirs = { checkout: top, gitdir, common, linked: true, spelled: [named.gitdir, named.common, gitdir, common] };
448
467
  }
449
468
  // The common folder is a main checkout's own `.git` folder, a real one.
450
469
  if (basename(dirs.common) !== ".git")
@@ -490,7 +509,12 @@ export function recordedCommonDir(repo) {
490
509
  const ADMIN_LINKED = ["index", "logs", "refs", "reftable"];
491
510
  /** What git keeps in a common directory (its path.c common_list, and reftable), linked whether or not it exists yet. */
492
511
  const COMMON_NAMES = ["branches", "gc.pid", "logs", "lost-found", "modules", "objects", "packed-refs", "reftable", "refs", "remotes", "rr-cache", "shallow", "svn", "worktrees"];
493
- function gitView(checkout, kind, binding = {}, gitFile = "git", limit) {
512
+ /** Where git incidents are recorded: beside the fenced views folder (git-incident.ts). */
513
+ export function gitIncidentRecord() {
514
+ return join(dirname(ensureGitViewsRoot(realpathSync(homedir()))), "git-incidents.jsonl");
515
+ }
516
+ /** `doing`: what the plane was doing, in a person's words ("tidying an old checkout"), for an incident's notice. */
517
+ function gitView(checkout, kind, binding = {}, gitFile = "git", limit, doing) {
494
518
  const dirs = boundDirs(checkout, binding);
495
519
  const root = ensureGitViewsRoot(realpathSync(homedir()));
496
520
  const view = realpathSync(mkdtempSync(join(root, `${kind}-`)));
@@ -658,8 +682,10 @@ function gitView(checkout, kind, binding = {}, gitFile = "git", limit) {
658
682
  const settle = () => {
659
683
  const now = pinned(dirs);
660
684
  let bound = false;
685
+ // The same proof as before git ran, of the same folders: re-read past the console's folder gate only where they are
686
+ // the very ones this view was made from (vouched), so a gate whose answer went stale mid-run is never read as a swap.
661
687
  try {
662
- const again = boundDirs(checkout, binding);
688
+ const again = boundDirs(checkout, binding, new Set(dirs.spelled));
663
689
  bound = again.gitdir === dirs.gitdir && again.common === dirs.common;
664
690
  }
665
691
  catch {
@@ -669,7 +695,7 @@ function gitView(checkout, kind, binding = {}, gitFile = "git", limit) {
669
695
  // Too late to prevent: recorded and told (git-incident.ts), never rolled back where the swap leads.
670
696
  if (kind === "write") {
671
697
  gitIncident({ checkout: dirs.checkout, repository: dirs.common, swapped: now.swapped.length > 0 ? now.swapped.map(({ path, leadsTo }) => ({ path, leadsTo })) : [{ path: join(dirs.checkout, ".git"), leadsTo: null }],
672
- record: join(dirname(ensureGitViewsRoot(realpathSync(homedir()))), "git-incidents.jsonl") });
698
+ cause: now.swapped.length > 0 ? "swapped" : "rebound", ...(doing === undefined ? {} : { doing }), record: gitIncidentRecord() });
673
699
  }
674
700
  return "the checkout's repository changed while git ran";
675
701
  }
@@ -683,7 +709,7 @@ function gitView(checkout, kind, binding = {}, gitFile = "git", limit) {
683
709
  catch (error) {
684
710
  if (!(error instanceof AnchorMoved))
685
711
  return "the checkout's git state couldn't be carried back after git ran";
686
- gitIncident({ checkout: dirs.checkout, repository: dirs.common, swapped: [{ path: error.message, leadsTo: null }], record: join(dirname(ensureGitViewsRoot(realpathSync(homedir()))), "git-incidents.jsonl") });
712
+ gitIncident({ checkout: dirs.checkout, repository: dirs.common, swapped: [{ path: error.message, leadsTo: null }], cause: "moved", ...(doing === undefined ? {} : { doing }), record: gitIncidentRecord() });
687
713
  return "the checkout's repository changed while git ran";
688
714
  }
689
715
  if (left.length > 0)
@@ -1110,7 +1136,7 @@ export function checkoutGit(inner = run, mode = "build", binding = {}) {
1110
1136
  const once = async () => {
1111
1137
  let view;
1112
1138
  try {
1113
- view = gitView(cwd, mode === "read" ? "read" : "write", binding);
1139
+ view = gitView(cwd, mode === "read" ? "read" : "write", binding, "git", undefined, checkoutDoing(args));
1114
1140
  }
1115
1141
  catch (error) {
1116
1142
  return refused(error instanceof Error ? error.message : "the checkout couldn't be read");
@@ -1,5 +1,7 @@
1
+ import { telegramCardMessage } from "./card-render.js";
2
+ import { cardOfProposal, cardPlainOf, cardsFirst, cardWithin, isCardProposal } from "./chat-card.js";
1
3
  import { CHAT_ACTIONS, sharedActionAllowsChallenge, sharedActionPayload } from "./chat-actions.js";
2
- import { taskInCeiling, mirrorToTaskChat, couldNotAnswerText, channelRepos as telegramConversationRepos, resolveChannelLead as resolveTelegramLead, NO_PHONE_LINK, NO_TASK_LINK, phoneProposalLink, confirmedLink, phoneProposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
4
+ import { armedQuestion, taskInCeiling, mirrorToTaskChat, couldNotAnswerText, channelRepos as telegramConversationRepos, resolveChannelLead as resolveTelegramLead, NO_PHONE_LINK, NO_TASK_LINK, phoneProposalLink, confirmedLink, phoneProposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
3
5
  export { channelRepos as telegramConversationRepos, resolveChannelLead as resolveTelegramLead, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText, CHAT_ACTION_PARITY as TELEGRAM_ACTION_PARITY, parityGaps } from "./chat-channel.js";
4
6
  /**
5
7
  * The paired phone as one more way to use the same assistant.
@@ -260,7 +262,7 @@ export function applyProposalTap(store, binding, token, message, repos, options,
260
262
  const body = preview.text.split("\n\nConfirm or Dismiss below")[0];
261
263
  if (challenged) {
262
264
  ack("confirm it");
263
- edit(`${body}\n\nThis records that you handled this exact result. Confirm?`, [
265
+ edit(`${body}\n\n${armedQuestion(proposal)}`, [
264
266
  [telegramButton(`✓ Yes, ${CHAT_ACTIONS[sharedAction.operation].label.toLowerCase()}`, yes)],
265
267
  [telegramButton("Cancel", cancel)],
266
268
  ]);
@@ -288,6 +290,7 @@ export function applyProposalTap(store, binding, token, message, repos, options,
288
290
  // The paired phone speaks for its account's full authority (pairing is a full session's act).
289
291
  authority: "full",
290
292
  via: "telegram",
293
+ binding: binding.id,
291
294
  evidenceRoot: options.evidenceRoot,
292
295
  confirm: action.phase === "yes",
293
296
  deferSignal: signal => effects.push({ kind: "signal", run: signal }),
@@ -549,17 +552,26 @@ async function runTelegramConversation(row, args) {
549
552
  const origin = proposal === null ? null : options.phoneOrigin?.() ?? null;
550
553
  const wanted = proposal === null ? null : phoneProposalLink(store, proposal, repos);
551
554
  const keyboard = keyboardWith(part.keyboard, phoneLinkButton(origin, wanted));
555
+ // A Toolroll card: read again now, shown only where every project behind it is still reachable. An unreadable
556
+ // one goes out as its saved plain words.
557
+ const isCard = proposal !== null && isCardProposal(proposal);
558
+ const rich = isCard ? cardOfProposal(proposal) : null;
559
+ const shown = rich !== null && cardWithin(rich, repos) ? telegramCardMessage(rich) : null;
560
+ const links = rich === null ? [] : rich.buttons.flatMap(one => phoneLinkButton(origin, one) ?? []).map(one => [one]);
552
561
  // The lead's own words carry their bold anchors and labelled links as entities, never as Markdown characters.
553
562
  const voiced = part.kind === "reply" ? telegramReply(part.text) : null;
554
- const text = proposal !== null && part.keyboard === null ? handoffCardText(part.text, linkNote(origin, wanted)) : voiced?.text ?? part.text;
563
+ const text = isCard ? shown?.text ?? (rich === null ? part.text : "This card's projects changed, so it wasn't sent.")
564
+ : proposal !== null && part.keyboard === null ? handoffCardText(part.text, linkNote(origin, wanted)) : voiced?.text ?? part.text;
565
+ const entities = isCard ? shown?.entities ?? [] : voiced?.entities ?? [];
566
+ const buttons = isCard ? (shown === null || links.length === 0 ? undefined : links) : keyboard;
555
567
  method = "sendMessage";
556
568
  send = () => transport("sendMessage", {
557
569
  chat_id: chatId,
558
570
  text,
559
- ...(voiced === null || voiced.entities.length === 0 ? {} : { entities: voiced.entities }),
571
+ ...(entities.length === 0 ? {} : { entities }),
560
572
  link_preview_options: { is_disabled: true },
561
573
  ...(part.replyTo === null ? {} : { reply_parameters: { message_id: Number(part.replyTo) } }),
562
- ...(keyboard === undefined ? {} : { reply_markup: { inline_keyboard: keyboard } }),
574
+ ...(buttons === undefined ? {} : { reply_markup: { inline_keyboard: buttons } }),
563
575
  });
564
576
  }
565
577
  // The first time the answer itself fails to go out, the person hears so once,
@@ -626,7 +638,12 @@ async function runTelegramConversation(row, args) {
626
638
  const selected = store.getLeadTurn(turn)?.state === "answered" ? store.listLeadTurnEvidence(turn) : [];
627
639
  for (const image of selected)
628
640
  parts.push({ kind: "image", text: image.caption, taskId: image.taskId, run: image.run, artifact: image.artifact, sha256: image.sha256 });
629
- for (const proposal of proposals) {
641
+ // Cards first, so what a card leads to sits right under it. A card mints no tokens: it confirms nothing.
642
+ for (const proposal of cardsFirst(proposals)) {
643
+ if (isCardProposal(proposal)) {
644
+ parts.push({ kind: "card", text: cardPlainOf(proposal), proposal: proposal.id, keyboard: null });
645
+ continue;
646
+ }
630
647
  const preview = phoneProposalPreview(store, proposal, repos);
631
648
  const keyboard = preview.buttons ? mintCardTokens(store, binding, proposal.id, now).keyboard : null;
632
649
  parts.push({ kind: "card", text: preview.text, proposal: proposal.id, keyboard });
package/dist/telegram.js CHANGED
@@ -18,6 +18,8 @@
18
18
  * BotFather token plus `bridge telegram unpair` is the recovery, and both
19
19
  * are one command.
20
20
  */
21
+ import { telegramCardMessage } from "./card-render.js";
22
+ import { flowDoneCard } from "./flow-result-card.js";
21
23
  import { applyTelegramQuestionReply, applyTelegramQuestionTap, openQuestionOf, telegramQuestionButtons } from "./subagent-question.js";
22
24
  import { acceptanceEvidenceText } from "./chat-acceptance.js";
23
25
  import { verifyApproverStanding } from "./principal.js";
@@ -1288,6 +1290,15 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
1288
1290
  parts = voiced.map(one => one.text);
1289
1291
  entities = voiced.map(one => one.entities);
1290
1292
  }
1293
+ // A flow's result as a card (flow-result-card.ts): one more message, its tables as monospaced text.
1294
+ const resultCard = offer === null ? sent?.card ?? flowDoneCard(store, notification, clock()) : null;
1295
+ if (resultCard != null) {
1296
+ const message = telegramCardMessage(resultCard);
1297
+ entities = [...parts.map((_, at) => entities[at] ?? []), message.entities];
1298
+ parts = [...parts, message.text];
1299
+ }
1300
+ // Its links stay on the words: the card follows them.
1301
+ const keyedPart = parts.length - (resultCard != null ? 2 : 1);
1291
1302
  // A subagent's question (v93): its options and "Answer in words", for the person it asks.
1292
1303
  const asked = flowKeys === null && choiceKeys === null ? openQuestionOf(store, notification.dedupeKey) : null;
1293
1304
  const questionKeys = asked === null ? null : telegramQuestionButtons(store, binding, asked, clock());
@@ -1301,7 +1312,7 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
1301
1312
  }
1302
1313
  }
1303
1314
  for (const [index, part] of parts.entries()) {
1304
- const final = index === parts.length - 1;
1315
+ const final = index === keyedPart;
1305
1316
  const decideKeys = decided === null ? [] : decideKeyboard(phoneOrigin, decided.rows);
1306
1317
  const keyboard = !final ? undefined : keys !== null ? [...keys.keyboard, ...(button === null ? [] : [button])] : decideKeys.length > 0 ? decideKeys : button !== null ? [button] : undefined;
1307
1318
  const sent = await sender(part, keyboard, undefined, entities[index]?.length ? entities[index] : undefined);
@@ -427,6 +427,8 @@ const HISTORY_RULES = {
427
427
  // v152: a saved project's code map reads on, with no enrollment yet; the code map's tables are new.
428
428
  // v153: a saved task reads no fast start and a saved project no fast-start setting (NULL); its tables are new.
429
429
  // v154 adds a table only (check_stamp): no saved row changes, so no rule is needed for it.
430
+ // v155 adds tables only (fleet-schema.ts): no saved row changes, so no rule is needed for it.
431
+ // v156 adds tables only (fallback chains): likewise.
430
432
  ...Object.fromEntries(Object.entries(addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS, V143_ADDED_COLUMNS, V147_ADDED_COLUMNS, V148_ADDED_COLUMNS, V150_ADDED_COLUMNS, V151_ADDED_COLUMNS, V152_ADDED_COLUMNS, V153_ADDED_COLUMNS))
431
433
  .map(([table, rule]) => [table, { ...EARLIER_RULES[table], ...rule }])),
432
434
  };
@@ -19,7 +19,7 @@ import { isCheckLevel, requiredCheckCommandFor } from './check-levels.js';
19
19
  import { MARKER } from './worktree.js';
20
20
  import { withFollowUps } from './result-follow-ups.js';
21
21
  import { LANDED_ACTOR, landedReasonOf } from './result-completion.js';
22
- import { ASKS, CODE_READING, NEED_ASK, NEEDS, askChipOf, failedAttemptSentence, isWaitCode, processNeedOf, resultHoldUpSentence } from './needs-you.js';
22
+ import { ASKS, CODE_READING, STORAGE_HOLD_PREFIX, NEED_ASK, NEEDS, askChipOf, failedAttemptSentence, isWaitCode, processNeedOf, resultHoldUpSentence } from './needs-you.js';
23
23
  import { retryWhenWords } from './dispatch.js';
24
24
  import { LEAD_CLAIM_SQL, LEAD_IDLE_MS } from './lead-voice.js';
25
25
  export const WORK_INDEX_PAGE_LIMIT = 40;
@@ -45,9 +45,11 @@ const GATE_PAUSE_ON_TASK = pauseOnTaskProviders('p', 'c.ref_id');
45
45
  const INDEX_NEED = Object.fromEntries(['earlier-active', 'process-needs-attention', 'invalid-scope', 'decision-queue', 'history-problem']
46
46
  .map(code => [code, CODE_READING[code].need]));
47
47
  /** Every index code that can wait on a person. A wait (stopping, waiting its turn, a retry later) is never one. */
48
- const PERSON_CODES = ['history-problem', 'earlier-active', 'ready-to-check', 'waiting-decision', 'decision-queue', 'result-needs-attention',
48
+ const PERSON_CODES = ['finding-held', 'history-problem', 'earlier-active', 'ready-to-check', 'waiting-decision', 'decision-queue', 'result-needs-attention',
49
49
  'process-needs-attention', 'failed', 'vanished-run', 'stopped', 'held', 'waiting-incident', 'terminal-dependency', 'needs-project',
50
- 'needs-scope', 'invalid-scope', 'needs-agent-profile', 'needs-approval', 'missing-requirement', 'no-worker-registered', 'no-worker-online', 'signed-out'];
50
+ 'needs-scope', 'invalid-scope', 'needs-agent-profile', 'needs-approval', 'missing-requirement', 'no-worker-registered', 'no-worker-online', 'signed-out', 'storage-blocked'];
51
+ /** A family's findings held for a person (findings.ts). A database not yet at v155 has no such table: see registerValidators. */
52
+ const HELD_FINDINGS = `(SELECT COUNT(*) FROM finding fd WHERE fd.family_root=root.id AND fd.state='with-person')`;
51
53
  /** The index codes that wait on nobody: finished or running work, and every wait (needs-you.ts CODE_READING). */
52
54
  export const WAITING_CODES = ['cancelled', 'complete', 'running', 'queued',
53
55
  ...['stopping', 'updating', 'retry-scheduled', 'waiting-dependency', 'worker-at-capacity', 'planning-ready', 'scouting-ready'].filter(code => isWaitCode(code))];
@@ -179,6 +181,7 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
179
181
  (SELECT run FROM run_stop WHERE task_ref=c.ref_id AND resumed_at IS NULL ORDER BY requested_at DESC LIMIT 1) stop_run,
180
182
  (SELECT settled_at FROM run_stop WHERE task_ref=c.ref_id AND resumed_at IS NULL ORDER BY requested_at DESC LIMIT 1) stop_settled,
181
183
  COALESCE((SELECT substr(actor,13) FROM action_ledger WHERE task_id=root.id AND action='assignment claimed' AND source='work' ORDER BY id DESC LIMIT 1),root.coordinator_cid) owner_id,
184
+ ${HELD_FINDINGS} held_findings,
182
185
  pub.pr_url publication_url
183
186
  FROM ordered c JOIN families f USING(root_ref) JOIN admitted root ON root.ref_id=c.root_ref
184
187
  LEFT JOIN run result INDEXED BY work_result ON result.task_ref=c.ref_id AND result.id=c.result_id
@@ -216,6 +219,7 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
216
219
  WHEN state='cancelled' THEN 'cancelled'
217
220
  WHEN earlier_active>0 AND state='done' THEN 'earlier-active'
218
221
  WHEN stop_run IS NOT NULL THEN CASE WHEN stop_settled IS NULL THEN 'stopping' ELSE 'stopped' END
222
+ WHEN held_findings>0 AND state<>'failed' THEN 'finding-held'
219
223
  WHEN ready THEN CASE WHEN checked_id IS NULL THEN 'ready-to-check' ELSE 'complete' END
220
224
  WHEN state='done' AND question_id IS NOT NULL AND hold_id IS NULL THEN 'waiting-decision'
221
225
  WHEN state='done' THEN 'result-needs-attention'
@@ -224,6 +228,7 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
224
228
  WHEN live_lease IS NOT NULL THEN CASE WHEN question_id IS NOT NULL OR agent_ask IS NOT NULL THEN 'waiting-decision' ELSE 'running' END
225
229
  WHEN state='running' OR unfinished IS NOT NULL THEN 'vanished-run'
226
230
  WHEN EXISTS(SELECT 1 FROM sqlite_master WHERE type='trigger' AND name GLOB 'so_desktop_update_*') THEN 'updating'
231
+ WHEN hold_kind='backoff' AND substr(hold_reason,1,${STORAGE_HOLD_PREFIX.length})='${STORAGE_HOLD_PREFIX}' THEN 'storage-blocked'
227
232
  WHEN hold_kind='backoff' THEN 'retry-scheduled'
228
233
  WHEN hold_kind='decision' THEN 'waiting-decision'
229
234
  WHEN hold_kind='incident' THEN 'waiting-incident'
@@ -304,8 +309,11 @@ function registerValidators(store) {
304
309
  const asks = db.prepare("SELECT 1 AS hit FROM sqlite_master WHERE type='table' AND name='agent_question'").get() !== undefined;
305
310
  const projection = asks ? indexed : indexed.replace(AGENT_ASK, 'NULL')
306
311
  .replace(`'agent_ask_run',(SELECT run FROM agent_question WHERE id=page.agent_ask),'agent_ask_text',(SELECT substr(question,1,300) FROM agent_question WHERE id=page.agent_ask),`, '');
307
- registered.set(db, projection);
308
- return projection;
312
+ // Nor, before v155, any held findings: readingFleet's rule for a list read.
313
+ const findings = db.prepare("SELECT 1 AS hit FROM sqlite_master WHERE type='table' AND name='finding'").get() !== undefined;
314
+ const read = findings ? projection : projection.replace(HELD_FINDINGS, '0');
315
+ registered.set(db, read);
316
+ return read;
309
317
  }
310
318
  /** The list can't see processes: each task whose build Toolroll can't yet
311
319
  * confirm stopped, as the store reads it now (the task page's own reading).
@@ -430,7 +438,7 @@ function readWorkIndexPage(store, now, access, options, taskId = null) {
430
438
  'proof_verdict',(SELECT COALESCE(machine_verdict,verdict) FROM proof_verdict WHERE run=page_result_id),
431
439
  'proof_failed_check',(SELECT verdict='refuted' AND reasons_json LIKE '%approved verification command exited%' FROM proof_verdict WHERE run=page_result_id),'code',code,'needs',needs,'person_need',person_need,'lead_claim',lead_claim,'family_running',family_running,'rank',rank,'ask_rank',ask_rank,'list_order',${ORDER},'sort_at',sort_at,
432
440
  'checked_actor',checked_actor,'checked_at',checked_at,'checked_digest',checked_digest,'checked_detail',checked_detail,'question_id',question_id,
433
- 'dependency_id',dependency_id,'dependency_state',dependency_state,'hold_until',hold_until,'attempt_run',(SELECT id FROM run WHERE task_ref IN (SELECT ref_id FROM members WHERE root_ref=page.root_ref) AND (finished_at IS NOT NULL OR outcome IS NOT NULL) AND role NOT IN ('planner','reviewer') ORDER BY id DESC LIMIT 1),'custody_unresolved',custody_unresolved,'hold_kind',hold_kind,'hold_reason',substr(hold_reason,1,300),'hold_id',hold_id,'live_lease',live_lease IS NOT NULL,'approved',COALESCE(approved,0),
441
+ 'dependency_id',dependency_id,'dependency_state',dependency_state,'hold_until',hold_until,'attempt_run',(SELECT id FROM run WHERE task_ref IN (SELECT ref_id FROM members WHERE root_ref=page.root_ref) AND (finished_at IS NOT NULL OR outcome IS NOT NULL) AND role NOT IN ('planner','reviewer') ORDER BY id DESC LIMIT 1),'custody_unresolved',custody_unresolved,'hold_kind',hold_kind,'hold_reason',substr(hold_reason,1,600),'hold_id',hold_id,'live_lease',live_lease IS NOT NULL,'approved',COALESCE(approved,0),
434
442
  'plan_changed',scope_digest IS NOT NULL AND digest IS NOT scope_digest,'head_ok',length(head_revision)=40 AND head_revision NOT GLOB '*[^a-f0-9]*','stop_run',stop_run,'unfinished',unfinished,'signed_out',signed_out,
435
443
  'question_run',(SELECT run FROM decision WHERE id=page.question_id),'agent_ask',page.agent_ask,
436
444
  'agent_ask_run',(SELECT run FROM agent_question WHERE id=page.agent_ask),'agent_ask_text',(SELECT substr(question,1,300) FROM agent_question WHERE id=page.agent_ask),'replaced_by',(SELECT successor FROM task_replacement WHERE task_ref=page.ref_id),
@@ -612,6 +620,13 @@ function itemOf(row, principal, probe, leadName, readiness, readable = false, no
612
620
  }
613
621
  if (code === 'stopped')
614
622
  detail = 'Paused by a person. The work so far is kept.';
623
+ // The storage check couldn't finish (dispose.ts holdForStorage): its own words, when it retries, and Try again.
624
+ if (code === 'storage-blocked') {
625
+ const until = s(row, 'hold_until');
626
+ detail = `${(s(row, 'hold_reason') ?? '').slice(STORAGE_HOLD_PREFIX.length)}${until === null ? '' : ` It tries again on its own ${retryWhenWords(until, now)}.`}`;
627
+ actionCode = 'unhold';
628
+ actionLabel = NEEDS.storage.action.label;
629
+ }
615
630
  if (code === 'cancelled')
616
631
  detail = 'This task was cancelled.';
617
632
  // The same failure again and again: what failed, and the two ways on.
@@ -671,7 +686,7 @@ function itemOf(row, principal, probe, leadName, readiness, readable = false, no
671
686
  actionCode = code === 'running' ? 'inspect-run' : 'inspect-task';
672
687
  actionLabel = code === 'running' ? 'Watch the build' : 'View task details';
673
688
  }
674
- const read = actionCode !== null && ['inspect-task', 'inspect-run', 'inspect-stop', 'inspect-decisions', 'open-result'].includes(actionCode);
689
+ const read = actionCode !== null && ['inspect-task', 'inspect-findings', 'inspect-run', 'inspect-stop', 'inspect-decisions', 'open-result'].includes(actionCode);
675
690
  const primaryAction = actionCode === null ? null : { code: actionCode, label: actionLabel,
676
691
  target: { taskId: successor ?? (code === 'earlier-active' ? s(row, 'earlier_id') ?? id : actionCode === 'answer-decision' ? s(row, 'question_task') ?? id : id),
677
692
  runId: actionCode === 'answer-decision' ? n(row, 'question_run') || null : actionCode === 'answer-agent' ? n(row, 'agent_ask_run') || null : actionCode === 'inspect-run' && code === 'running' ? n(row, 'live_run') || null : actionCode === 'open-result' || actionCode === 'inspect-run' ? n(row, 'result_id') || null
@@ -10,7 +10,7 @@ export type WorkTarget = {
10
10
  export type WorkAction = {
11
11
  /** `recheck-stopped`: Toolroll looks again whether a finished build stopped (nobody vouches for it);
12
12
  * `sign-in`: the agent's sign-in, in Settings; `answer-agent`: Reply to a running agent's question on the Agents board. */
13
- code: DispatchAction | "inspect-task" | "inspect-run" | "inspect-stop" | "inspect-decisions" | "reconcile-run" | "open-pr" | "recheck-stopped" | "sign-in" | "answer-agent";
13
+ code: DispatchAction | "inspect-task" | "inspect-findings" | "inspect-run" | "inspect-stop" | "inspect-decisions" | "reconcile-run" | "open-pr" | "recheck-stopped" | "sign-in" | "answer-agent";
14
14
  label: string;
15
15
  target: WorkTarget;
16
16
  access: "read" | "operator-control" | "proposal-only" | "operator-handoff";
@@ -2,6 +2,7 @@
2
2
  * State, decisions, approval, process custody and proof still belong to
3
3
  * their existing owners. Action hints are navigation/proposal guidance,
4
4
  * never credentials or permission to bypass an owning operation. */
5
+ import { heldFindingCount } from "./findings.js";
5
6
  import { machineChecked } from "./machine-check.js";
6
7
  import { resultCheckLevel } from "./result-follow-ups.js";
7
8
  import { diagnoseTaskDispatch } from "./dispatch.js";
@@ -28,7 +29,7 @@ export function workDecisionAction(taskId, decision, principal) {
28
29
  access: principal === "operator" ? "operator-control" : "proposal-only", retry: "refresh-before-acting" };
29
30
  }
30
31
  const PROPOSABLE = new Set(["answer-decision", "write-scope", "unhold"]);
31
- const READ_ACTIONS = new Set(["open-result", "open-pr", "inspect-task", "inspect-run", "inspect-stop", "inspect-decisions"]);
32
+ const READ_ACTIONS = new Set(["open-result", "open-pr", "inspect-task", "inspect-findings", "inspect-run", "inspect-stop", "inspect-decisions"]);
32
33
  /** Pure counterpart for callers already holding the same facts. */
33
34
  export function workSummaryOf(facts, principal, resultDisplay) {
34
35
  const status = workStatusOf(facts, resultDisplay);
@@ -48,6 +49,8 @@ export function workSummaryOf(facts, principal, resultDisplay) {
48
49
  primary = make("resume-run", status.action.label, { runId: facts.control.run });
49
50
  else if (status.token === "waiting-decision" && questionAction !== null)
50
51
  primary = questionAction;
52
+ else if (status.token === "finding-held")
53
+ primary = make("inspect-findings", status.action.label);
51
54
  else if (facts.dispatch?.code === "vanished-run")
52
55
  primary = make("reconcile-run", "Check the unfinished attempt", { runId: facts.unfinishedRunId ?? null });
53
56
  else if (status.action.kind === "open-result" || status.action.kind === "open-review")
@@ -98,7 +101,7 @@ export function taskWorkSummaryOf(store, taskId, now, access) {
98
101
  ...(machineChecked(store, task.id) ? { machine: true } : {}),
99
102
  dispatch: diagnoseTaskDispatch(store, taskId, now, access.dependencyRepos ?? null), liveRunId: live?.id ?? null,
100
103
  unfinishedRunId: runs.find(one => one.outcome === null && one.role !== "reviewer")?.id ?? null,
101
- control: taskControlOf(store, ref.id, now), openDecision: openWorkDecisionOf(store, ref.id, now),
104
+ control: taskControlOf(store, ref.id, now), openDecision: openWorkDecisionOf(store, ref.id, now), heldFindings: heldFindingCount(store, taskId),
102
105
  result: result === null ? null : {
103
106
  runId: result.id, role: result.role, outcome: result.outcome,
104
107
  verdict: proof?.verdict ?? null, reasons: proof?.reasons ?? [], accepted: store.proofAcceptance(result.id) !== null,
@@ -106,7 +106,7 @@ export type NextAction = {
106
106
  label: string;
107
107
  /** Where the act lives — the page, not a URL, so every surface can
108
108
  * build its own link (a task page anchors, a row links out). */
109
- kind: "open-result" | "open-review" | "open-task" | "open-run" | "open-pr";
109
+ kind: "open-result" | "open-review" | "open-task" | "open-findings" | "open-run" | "open-pr";
110
110
  };
111
111
  export type DisplayStatus = {
112
112
  /** The stable `data-work-status` token pages and tests key off. */
@@ -175,6 +175,8 @@ export type WorkFacts = {
175
175
  question: string;
176
176
  overdue: boolean;
177
177
  } | null;
178
+ /** Findings of this task's family that used their rounds and wait on a person (findings.ts). */
179
+ heldFindings?: number;
178
180
  };
179
181
  export type WorkStatus = DisplayStatus & {
180
182
  /** Which shortcut views list this row; `all` always does. */
@@ -470,7 +470,8 @@ function workStatusWordsOf(facts, resultDisplay) {
470
470
  const result = facts.state === "done" ? resultDisplay ?? resultStatusOf(facts.result, facts.publication) : null;
471
471
  const running = facts.liveRunId !== null || (dispatch?.condition === "running" && !REVIEW_TOKENS.has(dispatch.code));
472
472
  const question = (facts.state === "queued" || facts.state === "running" || facts.state === "done") ? facts.openDecision ?? null : null;
473
- const needs = (dispatch !== null && !REVIEW_TOKENS.has(dispatch.code) && needsPerson(dispatch)) || question !== null;
473
+ const held = (facts.heldFindings ?? 0) > 0 && facts.state !== "cancelled";
474
+ const needs = (dispatch !== null && !REVIEW_TOKENS.has(dispatch.code) && needsPerson(dispatch)) || question !== null || held;
474
475
  const views = ["all"];
475
476
  if (needs)
476
477
  views.push("needs-you");
@@ -480,6 +481,10 @@ function workStatusWordsOf(facts, resultDisplay) {
480
481
  views.push("queued");
481
482
  if (facts.state === "done")
482
483
  views.push("completed");
484
+ const findingWords = (behind) => ({
485
+ token: "finding-held", label: "Needs you", detail: NEEDS.finding.sentence({}), tone: "attention", action: { label: NEEDS.finding.action.label, kind: "open-findings" }, views, rank: 0,
486
+ ...(behind === null ? {} : { diagnostics: [behind] }),
487
+ });
483
488
  const dispatchWords = (tone, rank, action) => ({
484
489
  token: dispatch?.code ?? "unknown",
485
490
  label: dispatch?.summary ?? "Status unknown",
@@ -505,6 +510,8 @@ function workStatusWordsOf(facts, resultDisplay) {
505
510
  tone: "attention", action: { label: "Answer question", kind: "open-task" }, views, rank: 0,
506
511
  diagnostics: [{ token: result.token, label: result.label, detail: result.detail, tone: result.tone }] };
507
512
  }
513
+ if (held && result.tone !== "problem")
514
+ return findingWords({ token: result.token, label: result.label, detail: result.detail, tone: result.tone });
508
515
  return { ...result, views, rank: needs ? 0 : 3 };
509
516
  }
510
517
  if (facts.state === "cancelled") {
@@ -519,6 +526,8 @@ function workStatusWordsOf(facts, resultDisplay) {
519
526
  }),
520
527
  };
521
528
  }
529
+ if (held && facts.state !== "failed")
530
+ return findingWords(dispatch === null ? null : { token: dispatch.code, label: dispatch.summary, detail: dispatch.detail, tone: running ? "live" : "attention" });
522
531
  if (running) {
523
532
  return dispatchWords("live", 1, { label: "Watch the build", kind: facts.liveRunId === null ? "open-task" : "open-run" });
524
533
  }