toolroll 0.9.94 → 0.9.96

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 +17 -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 +43 -0
  21. package/dist/card-render.js +260 -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 +75 -19
  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
@@ -4,10 +4,9 @@
4
4
  * and each button is one opaque token bound to the exact result, receipt, plan or commit the card showed: a card a
5
5
  * newer result, a finished task or a changed plan has overtaken says so and acts on nothing.
6
6
  *
7
- * Approving a plan or merging needs the password, which never goes in chat, so they act in chat only where the owner
8
- * turned approving from chat on (their lasting setting, chat-approval.ts, or a signed mode's chatApprove term), for
9
- * their own chat; otherwise, and for a plan that widens permissions, exceeds the limit or touches protected paths, the
10
- * card keeps its link. The change requests one youiyoux pass filed come as one card that shows every word of each
7
+ * A person with the authority approves a plan or merges from their own linked chat by default (owner's decision,
8
+ * docs/plans/toolroll-cards.md); their own off or limits (chat-approval.ts) and a signed mode's chatApprove term still
9
+ * hold. A tap is theirs alone, and the card binds it to the exact terms. The change requests one youiyoux pass filed come as one card that shows every word of each
11
10
  * task's terms, with Approve all and each one's own Approve (the requests card, below).
12
11
  *
13
12
  * The same in every chat app: Telegram's bridge and the shared engine Slack, Discord and Teams run on (chat-delivery.ts)
@@ -23,18 +22,19 @@ import { chatControlHref, chatResultHref } from "./chat-controls.js";
23
22
  import { applyChatTaskAction, chatTaskStamp } from "./chat-task-actions.js";
24
23
  import { chatTitle } from "./chat-voice.js";
25
24
  import { readVerifiedArtifact, scanForSecrets } from "./evidence.js";
26
- import { effectiveChatApproval } from "./chat-approval.js";
27
- import { chatApproveChatsOf, chatNames, modeTermsFromJson } from "./modes.js";
25
+ import { chatApproveMode } from "./chat-approval.js";
28
26
  import { parseExecutionPlanDocument } from "./plan.js";
29
27
  import { verifyApproverStanding } from "./principal.js";
30
28
  import { pullRequestFollowOf } from "./pull-request-flow.js";
31
29
  import { requestResultChanges } from "./result-actions.js";
32
30
  import { revisionSourceOf } from "./result-review.js";
33
- import { APPROVAL_PROOF, approvalOf, approvalProofDetail, deployGateScope, quarantinedAuthor } from "./scope.js";
31
+ import { APPROVAL_PROOF, approvalOf, approvalProofDetail, approve, deployGateScope, quarantinedAuthor } from "./scope.js";
32
+ import { gateWords } from "./approval-policy.js";
34
33
  import { releaseCardOf } from "./release-card.js";
35
34
  import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
36
35
  import { phoneText, projectLabel } from "./telegram-status.js";
37
- import { permissionPlainWords, scopeTermsText, shownTermsDigest } from "./chat-terms.js";
36
+ import { cardTermsDigest, chatLimitProblem, permissionPlainWords, scopeTermsText, shownTermsDigest } from "./chat-terms.js";
37
+ import { chainTermsLines } from "./scope.js";
38
38
  import { YOUIYOUX_CARD_KIND, YOUIYOUX_FILED_VIA, YOUIYOUX_HOME_PATH } from "./contracts/youiyoux-request.js";
39
39
  import { FILED_PER_PASS, requestKindWord, requestPassOf } from "./youiyoux-intake.js";
40
40
  import { discordPlain } from "./reply-shape.js";
@@ -67,33 +67,7 @@ const sha = (text) => createHash("sha256").update(text).digest("hex");
67
67
  const isAct = (one) => "act" in one;
68
68
  const title = (store, taskId) => phoneText(chatTitle(store, taskId), 60);
69
69
  // ---- the chatApprove term ----------------------------------------------------------
70
- /** What lets this person's chat on `channel` approve on this repository now: their lasting chat-approval setting
71
- * (chat-approval.ts), or else a live signed mode carrying the chatApprove term, signed by them, that names this chat
72
- * app (a mode signed before the term named its apps covers Telegram alone). `digest` names that authority for the
73
- * ledger; the yes itself is the person's tap and is sealed as theirs, so it doesn't lapse when the authority does. */
74
- export function chatApproveMode(store, repo, approver, now, channel) {
75
- if (repo === null)
76
- return { ok: false, why: "Approving from chat isn't turned on for this project." };
77
- const setting = effectiveChatApproval(store, repo, approver);
78
- if (setting.ok)
79
- return { ok: true, digest: setting.setting.digest, limits: setting.limits, source: "setting" };
80
- const mode = store.activeMode(repo, now);
81
- const terms = mode === null ? null : modeTermsFromJson(mode.termsJson);
82
- if (mode === null || terms === null || !terms.chatApprove)
83
- return setting;
84
- if (mode.signedBy !== approver || !store.accountCanAccess(approver, repo))
85
- return { ok: false, why: "Only the person who signed this project's mode can approve from chat." };
86
- const chats = chatApproveChatsOf(terms);
87
- if (!chats.includes(channel))
88
- return { ok: false, why: `Your mode approves from ${chatNames(chats)} only. Sign it again to approve from ${chatNames([channel])}.` };
89
- return { ok: true, digest: mode.digest, limits: { fullAccess: terms.permissionDefault === "escalated", capMicrousd: terms.perAttemptBudgetMicrousd }, source: "mode" };
90
- }
91
- function fullAccess(profile) {
92
- if (profile === null || profile === undefined)
93
- return false;
94
- return profile.provider === "claude" ? profile.permissionArgv === "bypassPermissions"
95
- : profile.provider === "gemini" ? profile.approvalArgv === "yolo" : isAcpProfile(profile) ? true : profile.sandboxMode === "danger-full-access";
96
- }
70
+ export { chatApproveMode };
97
71
  const money = (micro) => `$${(micro / 1_000_000).toFixed(2)}`;
98
72
  /** A plan this person's chat may approve now: the term, the plan's exact bytes, and none of the reasons it must open
99
73
  * Toolroll instead. `digest` binds the scope and the plan document the card shows. */
@@ -121,19 +95,16 @@ from) {
121
95
  const family = store.taskFamilyOf(taskId, [ref.repo], false);
122
96
  if (family === null || family.problem !== null || family.current.id !== taskId)
123
97
  return { ok: false, why: "A newer version of this task is current." };
124
- // What the mode's seal refuses anyway, said first: model- or coordinator-written terms, an unreadable route.
125
- if (ref.coordinatorCid !== null || quarantinedAuthor(scope.proposedVia ?? null))
126
- return { ok: false, why: "This plan was written for you, so you approve it in Toolroll." };
98
+ // Terms a machine wrote, or checks that reach past the sandbox: a person reads every word before a yes, so the card shows them whole.
99
+ const whole = ref.coordinatorCid !== null || quarantinedAuthor(scope.proposedVia ?? null);
127
100
  if (scope.profileState === "unresolved" || scope.routeEra == null || (scope.termsProblem ?? null) !== null)
128
101
  return { ok: false, why: "This plan can't say exactly which agents run, so you approve it in Toolroll." };
129
- const yours = mode.source === "setting" ? "your chat approval setting" : "your mode";
130
- if (fullAccess(scope.profile) && !mode.limits.fullAccess)
131
- return { ok: false, why: `This plan asks for more access than ${yours} allows.` };
132
- const cap = mode.limits.capMicrousd;
133
- if (cap !== null && (scope.budgetMicrousd === null || scope.budgetMicrousd > cap))
134
- return { ok: false, why: `This plan has ${scope.budgetMicrousd === null ? "no attempt limit" : `a ${money(scope.budgetMicrousd)} attempt limit`}, more than ${yours === "your mode" ? "your mode's" : "your chat approval limit of"} ${money(cap)}.` };
135
- if (store.approvalGate(taskId, approver, "mode").verdict !== "seal")
136
- return { ok: false, why: "This plan touches protected work, so two people approve it in Toolroll." };
102
+ const limited = chatLimitProblem(scope, mode.limits, mode.source === "setting" ? "your chat approval setting" : "your mode");
103
+ if (limited !== null)
104
+ return { ok: false, why: limited };
105
+ const gate = store.approvalGate(taskId, approver, "person");
106
+ if (gate.verdict === "refuse")
107
+ return { ok: false, why: gateWords(gate) };
137
108
  if (store.scopePolicyRefusal(taskId) !== null)
138
109
  return { ok: false, why: "Your organisation's policy needs this plan approved in Toolroll." };
139
110
  // A revision approves only against notes that still verify, as on the console.
@@ -162,7 +133,8 @@ from) {
162
133
  if (!verified)
163
134
  return { ok: false, why: "The saved plan can't be verified here, so you approve it in Toolroll." };
164
135
  }
165
- return { ok: true, modeDigest: mode.digest, source: mode.source, scope, digest: sha(`${scope.digest}:${plan?.sha256 ?? ""}`) };
136
+ // Terms shown whole are part of what the card is bound to, so the digest kept on its buttons covers those words.
137
+ return { ok: true, modeDigest: mode.digest, source: mode.source, scope, whole, digest: sha(`${scope.digest}:${plan?.sha256 ?? ""}${whole ? `:${cardTermsDigest(scopeTermsText(scope))}` : ""}`) };
166
138
  }
167
139
  /** A ready pull request this person's chat may merge now, bound to the exact commit. */
168
140
  export function mergeInChat(store, taskId, run, approver, now, channel) {
@@ -397,10 +369,13 @@ export function planCardText(store, taskId, scope, root, channel) {
397
369
  const allowing = [permissionPlainWords(scope.profile), scope.budgetMicrousd === null ? "no attempt limit" : `up to ${money(scope.budgetMicrousd)} per attempt`]
398
370
  .filter((one) => one !== null);
399
371
  const changed = routeChangeOf(store, taskId);
372
+ // Fallback agents are terms: in full on every card, near the top, and never cut.
373
+ const chain = chainTermsLines(scope);
400
374
  if (channel === "telegram") {
401
375
  return [
402
376
  `Plan ready: ${title(store, taskId)}`,
403
377
  ...(changed === null ? [] : [changed]),
378
+ ...chain,
404
379
  `Starting allows: ${allowing.join(" · ")}`,
405
380
  "",
406
381
  ...goal,
@@ -414,6 +389,7 @@ export function planCardText(store, taskId, scope, root, channel) {
414
389
  return [
415
390
  `Plan ready: ${title(store, taskId)}`,
416
391
  ...(changed === null ? [] : [changed]),
392
+ ...chain,
417
393
  "",
418
394
  ...goal,
419
395
  "",
@@ -470,7 +446,7 @@ export function decideOffer(store, target, who, now, channel, root) {
470
446
  catch {
471
447
  head = [title(store, target.taskId)];
472
448
  }
473
- return { text: [...head, "", needsPassword(plan.scope)].join("\n"), rows: [[{ label: "Approve in Toolroll", path: chatControlHref("approval", target.taskId) }]] };
449
+ return { text: [...head, "", needsFullCard(plan.scope)].join("\n"), rows: [[{ label: "Approve in Toolroll", path: chatControlHref("approval", target.taskId) }]] };
474
450
  }
475
451
  // A deploy gate is approved from chat only through its release card (chat-management.md §0a.5).
476
452
  if (release) {
@@ -576,7 +552,7 @@ export function isDecideToken(store, token) {
576
552
  /** A release card's acts carry its card digest (release-card.ts), never a plan's. */
577
553
  const RELEASE_DIGEST = "release:";
578
554
  /** Why a password-only scope (passwordOnlySeal) isn't approved from chat, for a release or a plan. */
579
- const needsPassword = (scope) => `This ${deployGateScope(scope) ? "release" : "plan"}'s checks run outside the sandbox, so it's approved in Toolroll with your password.`;
555
+ const needsFullCard = (scope) => `This ${deployGateScope(scope) ? "release" : "plan"}'s checks run outside the sandbox, so this card can't approve it. Approve it in Toolroll, or ask your lead for its approval card, which shows every term.`;
580
556
  /** Whether only a person's password ceremony seals this scope (store.ts sealScopeApproval): checks that reach beyond the
581
557
  * sandbox (sign-ins, or none at all) or extra macOS services, sealed into its profile. */
582
558
  const passwordOnlySeal = (scope) => {
@@ -615,7 +591,7 @@ seat, root) {
615
591
  if (!deployGateScope(plan.scope))
616
592
  return { ok: false, why: "This plan isn't a release." };
617
593
  if (passwordOnlySeal(plan.scope))
618
- return { ok: false, why: needsPassword(plan.scope) };
594
+ return { ok: false, why: needsFullCard(plan.scope) };
619
595
  const ref = store.lookupRef(taskId);
620
596
  if (ref === null || nightlyTaskRef(store, ref.id))
621
597
  return { ok: false, why: NIGHTLY_CHECK_ONLY };
@@ -709,7 +685,7 @@ function staleWhy(store, row, who, now, channel, root) {
709
685
  if (deployGateScope(plan.scope))
710
686
  return "This is a release: approve it from its release card.";
711
687
  if (passwordOnlySeal(plan.scope))
712
- return needsPassword(plan.scope);
688
+ return needsFullCard(plan.scope);
713
689
  return plan.digest === row.digest ? null : "The plan changed since this card was sent.";
714
690
  }
715
691
  case "merge": {
@@ -868,7 +844,10 @@ export function applyDecideTap(store, seat, input) {
868
844
  const spec = { act: "approve", run: null, digest: row.digest };
869
845
  const yes = mint(store, seat, input.message, spec, row.taskId, "yes", now);
870
846
  const later = mint(store, seat, input.message, { act: "not-now", run: null, digest: row.digest }, row.taskId, "offer", now);
871
- return { ack: "Read the plan, then approve.", edit: { text: `${planCardText(store, row.taskId, plan.scope, input.root, seat.channel)}\n\n${QUESTIONS.approve(name)} This approves exactly this plan.`,
847
+ // Terms a machine wrote go on the card word for word, or the yes isn't to what they say.
848
+ if (plan.whole && scopeTermsText(plan.scope).length > PLATFORM_LIMITS[seat.channel] - ROOM)
849
+ return stale(`This plan's ${TOO_LONG}, so you approve it in Toolroll.`);
850
+ return { ack: "Read the plan, then approve.", edit: { text: `${plan.whole ? `${name}\n\n${scopeTermsText(plan.scope)}` : planCardText(store, row.taskId, plan.scope, input.root, seat.channel)}\n\n${QUESTIONS.approve(name)} This approves exactly this plan.`,
872
851
  rows: [[{ label: DECIDE_LABELS.approve, token: yes }], [{ label: DECIDE_LABELS.edit, link: { label: DECIDE_LABELS.edit, path: chatControlHref("approval", row.taskId) } }, { label: DECIDE_LABELS["not-now"], token: later }]], tokens: [yes, later] } };
873
852
  }
874
853
  // The first tap asks; only the yes acts, bound to exactly what this button was.
@@ -929,9 +908,19 @@ export function applyDecideTap(store, seat, input) {
929
908
  if (deployGateScope(plan.scope))
930
909
  return notDone("This is a release: approve it from its release card.");
931
910
  if (passwordOnlySeal(plan.scope))
932
- return notDone(needsPassword(plan.scope));
933
- if (!sealPlanInChat(store, who, seat, ref.repo, row.taskId, plan, now))
911
+ return notDone(needsFullCard(plan.scope));
912
+ // The one approval every road shares (scope.ts approve): the rules, two-person votes and the proof, as this person.
913
+ // plan.digest matched this button's kept digest above (staleWhy), so these are the words its card showed.
914
+ const words = scopeTermsText(plan.scope);
915
+ if (plan.whole && (plan.digest !== row.digest || words.length > PLATFORM_LIMITS[seat.channel] - ROOM))
916
+ return notDone("this plan changed. Open it in Toolroll.");
917
+ const approved = approve(store, row.taskId, who.name, now, plan.scope.digest, "", {}, undefined, { modeDigest: plan.modeDigest, shown: plan.whole ? { expect: cardTermsDigest(words), terms: words } : null });
918
+ if (!approved.ok && approved.reason === "second-approver")
919
+ return done(gateWords({ verdict: "vote", have: approved.have, need: 2, already: approved.already }));
920
+ if (!approved.ok)
934
921
  return notDone("the plan couldn't be approved from chat. Open it in Toolroll.");
922
+ store.recordAction({ at: now.toISOString(), actor: who.name, repo: ref.repo, taskId: row.taskId, runId: null, action: "plan approved in chat", outcome: "approved",
923
+ source: "request", detail: `via ${seat.channel} · chat binding #${seat.binding} · ${plan.source} ${plan.modeDigest}` });
935
924
  return done("✓ Approved. Work starts when a worker is free.");
936
925
  }
937
926
  case "merge": {
@@ -10,6 +10,9 @@ import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
10
10
  import { ChatState, ChatDeliveryError, chatHash, savedChatPart, } from "./chat-delivery-state.js";
11
11
  import { readChatActionBody, readChatMessageBody, readChatPairBody, readChatPart } from "./contracts/chat-content.js";
12
12
  import { readProposalActionRow } from "./contracts/chat-callback-rows.js";
13
+ import { cardContent, cardPart } from "./chat-card-part.js";
14
+ import { flowDoneCard } from "./flow-result-card.js";
15
+ import { cardsFirst, isCardProposal } from "./chat-card.js";
13
16
  import { answerChatFlowPrompt, answerChoiceMessage, applyChatFlowTap, flowDecisionParts, flowSendParts } from "./chat-flow.js";
14
17
  import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
15
18
  import { triggerConfigOf } from "./flow-triggers.js";
@@ -484,10 +487,9 @@ export async function processChatEvent(options) {
484
487
  task: image.taskId,
485
488
  run: image.run,
486
489
  });
487
- for (const proposal of store
488
- .listLeadProposals(turn.thread, ["pending"])
489
- .filter((p) => p.turn === turn.id))
490
- parts.push({ text: "", proposal: proposal.id });
490
+ // Cards first, so what a card leads to sits right under it. A card is an ordinary part: it confirms nothing.
491
+ for (const proposal of cardsFirst(store.listLeadProposals(turn.thread, ["pending"]).filter((p) => p.turn === turn.id)))
492
+ parts.push(isCardProposal(proposal) ? cardPart(proposal) : { text: "", proposal: proposal.id });
491
493
  state.plan(event.id, parts, nowOf(options));
492
494
  return true;
493
495
  }
@@ -655,6 +657,7 @@ export function applyChatAction(options, event, binding, repos) {
655
657
  // A paired chat seat speaks for its account's full authority (pairing is a full session's act).
656
658
  authority: "full",
657
659
  via: options.state.provider,
660
+ binding: binding.id,
658
661
  evidenceRoot: options.evidenceRoot,
659
662
  confirm: phase === "yes",
660
663
  deferSignal: (signal) => signals.push(signal),
@@ -1053,6 +1056,12 @@ function offerNotice(options, notification, binding, registry) {
1053
1056
  ts: "", thread: "", payload: {}, created: now.toISOString() });
1054
1057
  state.plan(id, flowSendParts(store, notification, state.provider), now);
1055
1058
  }
1059
+ else if (personal && flowDoneCard(store, notification, now) !== null) {
1060
+ // A card that reached Done: what it went through, as a card with its links.
1061
+ state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
1062
+ ts: "", thread: "", payload: {}, created: now.toISOString() });
1063
+ state.plan(id, [cardContent(flowDoneCard(store, notification, now))], now);
1064
+ }
1056
1065
  else if (notification.kind === "flow-card" && questionParts(store, notification, binding) !== null) {
1057
1066
  // A subagent's question (v93): its options and "Answer in words" on the notice, for the person it asks.
1058
1067
  state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
@@ -1,30 +1,3 @@
1
- /**
2
- * Flow decisions in Slack, Discord and Teams (v88): the Approve / Edit /
3
- * Send back that Telegram has (telegram-flow.ts), on the notice itself.
4
- *
5
- * - The notice carries the draft (split over as many parts as it needs, so
6
- * what is approved is what was read) and the buttons on its last part.
7
- * - Approve decides through the same door as the console (decideFlowCard),
8
- * as the paired person, for exactly the visit the notice was about, then
9
- * repaints the notice with what happened and no buttons.
10
- * - Edit and Send back open a prompt: the person's next message in their
11
- * chat with Toolroll is the new draft (which comes back with fresh
12
- * buttons) or the note. "cancel" leaves it, and a prompt lapses after
13
- * 30 minutes. Chat apps can't all name the message a reply answers, so
14
- * the prompt says plainly what the next message does.
15
- *
16
- * Every button is one opaque token for one visit of one card, on the part it
17
- * rides: a tap on any other message, from anyone but the paired person, or
18
- * for a card that moved on, changes nothing.
19
- *
20
- * A "Send to me" visit (flow-send.ts) is one notice with its links; a "Person
21
- * chooses" visit is the same with the zone's own options as buttons. Instead
22
- * of a tap, a reply in the notice's thread is the note for where replies go;
23
- * the first other message in their chat is asked about once ("Use this as
24
- * your note?"), and everything else goes to the lead as usual. Choices are
25
- * made through chooseFlowCard, by that person alone, for exactly that visit,
26
- * and spend that visit's buttons in every chat app.
27
- */
28
1
  import type { ChatProvider } from "./contracts/chat-tables.js";
29
2
  import { type ChatBinding, type ChatContent, type ChatEvent, type ChatState } from "./chat-delivery-state.js";
30
3
  import type { Store } from "./store.js";
package/dist/chat-flow.js CHANGED
@@ -1,3 +1,31 @@
1
+ /**
2
+ * Flow decisions in Slack, Discord and Teams (v88): the Approve / Edit /
3
+ * Send back that Telegram has (telegram-flow.ts), on the notice itself.
4
+ *
5
+ * - The notice carries the draft (split over as many parts as it needs, so
6
+ * what is approved is what was read) and the buttons on its last part.
7
+ * - Approve decides through the same door as the console (decideFlowCard),
8
+ * as the paired person, for exactly the visit the notice was about, then
9
+ * repaints the notice with what happened and no buttons.
10
+ * - Edit and Send back open a prompt: the person's next message in their
11
+ * chat with Toolroll is the new draft (which comes back with fresh
12
+ * buttons) or the note. "cancel" leaves it, and a prompt lapses after
13
+ * 30 minutes. Chat apps can't all name the message a reply answers, so
14
+ * the prompt says plainly what the next message does.
15
+ *
16
+ * Every button is one opaque token for one visit of one card, on the part it
17
+ * rides: a tap on any other message, from anyone but the paired person, or
18
+ * for a card that moved on, changes nothing.
19
+ *
20
+ * A "Send to me" visit (flow-send.ts) is one notice with its links; a "Person
21
+ * chooses" visit is the same with the zone's own options as buttons. Instead
22
+ * of a tap, a reply in the notice's thread is the note for where replies go;
23
+ * the first other message in their chat is asked about once ("Use this as
24
+ * your note?"), and everything else goes to the lead as usual. Choices are
25
+ * made through chooseFlowCard, by that person alone, for exactly that visit,
26
+ * and spend that visit's buttons in every chat app.
27
+ */
28
+ import { cardContent } from "./chat-card-part.js";
1
29
  import { LIMITS } from "./decision.js";
2
30
  import { keptDraft } from "./flow-draft.js";
3
31
  import { decideFlowCard, flowCardHref, flowDefinitionOf } from "./flow-engine.js";
@@ -66,11 +94,12 @@ export function flowSendParts(store, notification, app) {
66
94
  const content = kept === null ? null : readFlowSend(kept.contentJson);
67
95
  if (content === null)
68
96
  return null;
97
+ const card = content.card === undefined ? [] : [cardContent(content.card)];
69
98
  const voiced = itemsText(content, notification.subject, app);
70
99
  if (voiced !== null)
71
- return voiced.map((text, index) => index < voiced.length - 1 ? { text, voice: true } : { text, voice: true, ...sendLinks(flowSendPaths(content)) });
100
+ return [...voiced.map((text, index) => index < voiced.length - 1 ? { text, voice: true } : { text, voice: true, ...sendLinks(flowSendPaths(content)) }), ...card];
72
101
  const all = pieces(chatFlowText(`${notification.subject}\n\n${notification.body}`));
73
- return all.map((piece, index) => index < all.length - 1 ? { text: piece } : { text: piece, ...sendLinks(flowSendPaths(content)) });
102
+ return [...all.map((piece, index) => index < all.length - 1 ? { text: piece } : { text: piece, ...sendLinks(flowSendPaths(content)) }), ...card];
74
103
  }
75
104
  /**
76
105
  * A "flow-decision" notice as chat parts, or null when it isn't one (or the
@@ -19,7 +19,7 @@ import { YOUIYOUX_CARD_KIND } from "./contracts/youiyoux-request.js";
19
19
  * (a sign-in or plan-limit pause, an evening digest). */
20
20
  const NEEDED_KINDS = new Set([
21
21
  "acceptance-ready", "acceptance-evidence", "flow-decision", "flow-card", "plan-ready", "report-ready",
22
- "stale-approval", "security-release", "secret-detected",
22
+ "stale-approval", "security-release", "secret-detected", "protected-storage",
23
23
  ]);
24
24
  /** What still wants a person who pairs after it landed, in every chat app: an open decision or an unresolved attention
25
25
  * notice. Routine history is never replayed to a chat that just arrived (store.ts WANTED_BEFORE_PAIRING, the same rule). */
@@ -7,8 +7,8 @@ export declare const CHAT_TASK_ACTIONS: {
7
7
  readonly detail: "Pauses this task once the run acknowledges the stop. Saved work remains; other tasks continue.";
8
8
  };
9
9
  readonly resume: {
10
- readonly label: "Review resume";
11
- readonly detail: "Opens password confirmation to lift this stop’s hold and continue from saved work. Other holds and approval requirements still apply.";
10
+ readonly label: "Resume task";
11
+ readonly detail: "Lifts this stop’s hold and continues from saved work. Other holds and approval requirements still apply.";
12
12
  };
13
13
  readonly retry: {
14
14
  readonly label: "Try again";
@@ -48,6 +48,7 @@ export declare function chatTaskRun(store: Store, task: string, operation: "stop
48
48
  export declare function applyChatTaskAction(store: Store, who: VerifiedApprover, payload: Record<string, unknown>, now: Date, web: boolean, controls?: Pick<StopRequest, "deferSignal"> & {
49
49
  via?: StopRequest["via"];
50
50
  evidenceRoot?: string;
51
+ person?: boolean;
51
52
  }): {
52
53
  ok: true;
53
54
  taskId: string;
@@ -1,12 +1,13 @@
1
1
  import { verifiedAuthor } from "./store.js";
2
- import { requestTaskStop, taskControlOf } from "./task-control.js";
2
+ import { chatApproveMode, effectiveChatApproval } from "./chat-approval.js";
3
+ import { requestTaskStop, resumeTaskStop, taskControlOf } from "./task-control.js";
3
4
  import { authorizePlanUnderMode } from "./plan-auto.js";
4
5
  import { fileAddTestsTask, requestFollowUpChecks } from "./result-follow-ups.js";
5
6
  export const CHAT_TASK_ACTIONS = {
6
7
  // Build #1604 feedback: one short consequence that still says saved work
7
8
  // remains, other tasks continue, and the pause waits for acknowledgement.
8
9
  stop: { label: "Stop task", detail: "Pauses this task once the run acknowledges the stop. Saved work remains; other tasks continue." },
9
- resume: { label: "Review resume", detail: "Opens password confirmation to lift this stop’s hold and continue from saved work. Other holds and approval requirements still apply." },
10
+ resume: { label: "Resume task", detail: "Lifts this stop’s hold and continues from saved work. Other holds and approval requirements still apply." },
10
11
  retry: { label: "Try again", detail: "Queues another attempt. Existing approvals and holds still apply." },
11
12
  plan: { label: "Plan first", detail: "Requests a plan. Building still requires approval." },
12
13
  wait_for: { label: "Add dependency", detail: "Waits for the selected task to finish." },
@@ -52,9 +53,22 @@ export function applyChatTaskAction(store, who, payload, now, web, controls = {}
52
53
  const run = chatTaskRun(store, task, operation, now);
53
54
  if (run === null || run !== payload["run"])
54
55
  return { ok: false, message: "This attempt changed. Review the current task before confirming." };
55
- // Resume remains the existing nonce + password ceremony. A proposal only opens it.
56
- if (operation === "resume")
57
- return { ok: true, taskId: task, said: `Resume review requested for ${task}. Complete the password confirmation on the task to resume.` };
56
+ // Resuming is an approval: a person's own tap or signed-in click lifts the stop; any other road only asks for it.
57
+ if (operation === "resume") {
58
+ if (controls.person !== true)
59
+ return { ok: true, taskId: task, said: `Resume requested for ${task}. Finish it in your chat or on the task.` };
60
+ // Resuming is an approval: a person's own off holds here as on every other one.
61
+ const allowed = effectiveChatApproval(store, ref.repo, who.name);
62
+ if (!allowed.ok && controls.via !== "web") {
63
+ const mode = controls.via === undefined || controls.via === "cli" ? allowed : chatApproveMode(store, ref.repo, who.name, now, controls.via);
64
+ if (!mode.ok)
65
+ return { ok: false, message: mode.why };
66
+ }
67
+ if (!allowed.ok && controls.via === "web")
68
+ return { ok: false, message: allowed.why };
69
+ const resumed = resumeTaskStop(store, { taskId: task, runId: run, by: verifiedAuthor(who.name), via: controls.via ?? "web" }, now);
70
+ return resumed.ok ? { ok: true, taskId: task, said: `Stop released. ${resumed.gate ? "Other requirements still prevent the next attempt." : "The next attempt may start."}` } : { ok: false, message: resumed.detail };
71
+ }
58
72
  const { via, evidenceRoot: _root, ...rest } = controls;
59
73
  const result = requestTaskStop(store, { taskId: task, runId: run, by: verifiedAuthor(who.name), via: via ?? (web ? "web" : "cli"), ...rest }, now);
60
74
  return result.ok ? { ok: true, taskId: task, said: `Stop requested for ${task}, run #${run}.` }
@@ -1,9 +1,21 @@
1
- import type { Scope } from "./scope.js";
1
+ import { type Scope } from "./scope.js";
2
2
  /** What a plan's yes allows an agent to do, in a few plain words (the approval sheet's "You're allowing"). */
3
3
  export declare function permissionPlainWords(profile: Scope["profile"] | null | undefined): string | null;
4
- export type ShownScope = Pick<Scope, "goal" | "outOfScope" | "touches" | "acceptance" | "profile" | "budgetMicrousd">;
4
+ export type ShownScope = Pick<Scope, "goal" | "outOfScope" | "touches" | "acceptance" | "profile" | "budgetMicrousd"> & Partial<Pick<Scope, "proposedChainJson">>;
5
5
  /** Every word of the terms a yes agrees to, as the card shows them: the goal, what's excluded, the files (or plainly any
6
6
  * file), each requirement with the evidence it needs, and what the agent may do and spend. Nothing is shortened. */
7
7
  export declare function scopeTermsText(scope: ShownScope): string;
8
8
  /** The digest of the terms shown, bound to the scope's own digest: a changed word, limit or route is another digest. */
9
9
  export declare function shownTermsDigest(scope: ShownScope & Pick<Scope, "digest">): string;
10
+ /** The digest of the exact words a card showed, taken when the card was made and kept with it. */
11
+ export declare function cardTermsDigest(terms: string): string;
12
+ /** Whether these words show this scope as it is now: its whole terms text, word for word, and what only a person's own
13
+ * yes may agree to (checks past the sandbox, extra services). Part of the terms, or an earlier version's, is not. */
14
+ export declare function cardShowsScope(scope: ShownScope, terms: string): boolean;
15
+ /** Whether a scope lets its agent do anything, nothing asking first. */
16
+ export declare function scopeFullAccess(profile: Scope["profile"] | null | undefined): boolean;
17
+ /** Why a person's own limits refuse this scope, or null. */
18
+ export declare function chatLimitProblem(scope: Pick<Scope, "profile" | "budgetMicrousd">, limits: {
19
+ fullAccess: boolean;
20
+ capMicrousd: number | null;
21
+ }, yours: string): string | null;
@@ -6,6 +6,9 @@
6
6
  */
7
7
  import { createHash } from "node:crypto";
8
8
  import { isAcpProfile } from "./acp-agents.js";
9
+ import { checkAccessWords } from "./check-access.js";
10
+ import { sandboxTermsWords } from "./project-sandbox.js";
11
+ import { chainTermsLines } from "./scope.js";
9
12
  /** What a plan's yes allows an agent to do, in a few plain words (the approval sheet's "You're allowing"). */
10
13
  export function permissionPlainWords(profile) {
11
14
  if (profile === null || profile === undefined)
@@ -38,9 +41,41 @@ export function scopeTermsText(scope) {
38
41
  ...(scope.acceptance.length === 0 ? ["You decide when you review the result."] : scope.acceptance.map(one => `• ${one.statement} (${one.evidence.join(", ")})`)),
39
42
  "",
40
43
  `Starting allows: ${allows.join(" · ")}`,
44
+ ...((chain) => chain.length === 0 ? [] : ["", "Fallback agents:", ...chain])(chainTermsLines(scope)),
41
45
  ].join("\n");
42
46
  }
43
47
  /** The digest of the terms shown, bound to the scope's own digest: a changed word, limit or route is another digest. */
44
48
  export function shownTermsDigest(scope) {
45
49
  return createHash("sha256").update(JSON.stringify({ v: 1, scope: scope.digest, shown: scopeTermsText(scope) })).digest("hex");
46
50
  }
51
+ /** The digest of the exact words a card showed, taken when the card was made and kept with it. */
52
+ export function cardTermsDigest(terms) {
53
+ return createHash("sha256").update(JSON.stringify({ v: 2, shown: terms })).digest("hex");
54
+ }
55
+ /** Whether these words show this scope as it is now: its whole terms text, word for word, and what only a person's own
56
+ * yes may agree to (checks past the sandbox, extra services). Part of the terms, or an earlier version's, is not. */
57
+ export function cardShowsScope(scope, terms) {
58
+ if (!terms.includes(scopeTermsText(scope)))
59
+ return false;
60
+ const profile = scope.profile;
61
+ if (profile === null || profile === undefined)
62
+ return true;
63
+ const access = profile.checkAccess, mach = sandboxTermsWords(profile).mach;
64
+ return (access === undefined || terms.includes(checkAccessWords(access))) && (mach === null || terms.includes(mach));
65
+ }
66
+ /** Whether a scope lets its agent do anything, nothing asking first. */
67
+ export function scopeFullAccess(profile) {
68
+ if (profile === null || profile === undefined)
69
+ return false;
70
+ return profile.provider === "claude" ? profile.permissionArgv === "bypassPermissions"
71
+ : profile.provider === "gemini" ? profile.approvalArgv === "yolo" : isAcpProfile(profile) ? true : profile.sandboxMode === "danger-full-access";
72
+ }
73
+ /** Why a person's own limits refuse this scope, or null. */
74
+ export function chatLimitProblem(scope, limits, yours) {
75
+ if (scopeFullAccess(scope.profile) && !limits.fullAccess)
76
+ return `This plan asks for more access than ${yours} allows.`;
77
+ const cap = limits.capMicrousd;
78
+ if (cap !== null && (scope.budgetMicrousd === null || scope.budgetMicrousd > cap))
79
+ return `This plan has ${scope.budgetMicrousd === null ? "no attempt limit" : `a ${money(scope.budgetMicrousd)} attempt limit`}, more than ${yours === "your mode" ? "your mode's" : "your chat approval limit of"} ${money(cap)}.`;
80
+ return null;
81
+ }
@@ -64,6 +64,8 @@ export declare function ranNoCheck(store: Store, row: {
64
64
  head: unknown;
65
65
  checkRow: unknown;
66
66
  }): boolean;
67
+ /** The ledger entry the build writes when it refuses a prepared candidate before any process (builder.ts). */
68
+ export declare const NOTHING_STARTED_ACTION = "attempt refused before anything ran";
67
69
  /**
68
70
  * Whether a passing check of a release task stands, given every earlier attempt on the same candidate and signed scope:
69
71
  * each earlier failure must be confirmed infrastructure the release itself reran (at most two), and nothing in the
@@ -208,11 +208,43 @@ export function setupReplayWords(first) {
208
208
  * got as far as its check keeps every rule below.
209
209
  */
210
210
  export function ranNoCheck(store, row) {
211
- if (row.outcome !== "failed" || row.reason !== "interrupted" || row.head !== null || row.checkRow !== null)
211
+ if (row.outcome !== "failed" || (row.reason !== "interrupted" && row.reason !== "no-op") || row.head !== null || row.checkRow !== null)
212
212
  return false;
213
213
  if (checkAttemptOf(store, row.id) !== null)
214
214
  return false;
215
- return !store.artifactsFor(row.id).some(artifact => artifact.kind === "check-log" || isVerificationReceipt(artifact));
215
+ if (store.artifactsFor(row.id).some(artifact => artifact.kind === "check-log" || isVerificationReceipt(artifact)))
216
+ return false;
217
+ return row.reason === "interrupted" || refusedBeforeAnything(store, row.id);
218
+ }
219
+ /** The ledger entry the build writes when it refuses a prepared candidate before any process (builder.ts). */
220
+ export const NOTHING_STARTED_ACTION = "attempt refused before anything ran";
221
+ /** Every table a process, an agent or a check of this run writes. */
222
+ const RUN_TRACES = [["artifact", "run"], ["run_process", "run"], ["run_process_summary", "run"], ["run_tool", "run"], ["run_activity", "run"],
223
+ ["check_stamp", "run_id"], ["release_check_attempt", "run"], ["run_note", "run"], ["agent_turn", "run"], ["run_checkpoint", "run"], ["run_decision", "run"]];
224
+ /**
225
+ * An attempt the build refused before anything ran (run 3332: Git couldn't read HEAD while it proved the candidate; the
226
+ * next attempt passed). A failed attempt with reason `no-op` proves nothing alone: an agent's empty result and a check's
227
+ * refusal end that way too. So the build's own ledger entry says so, written only where nothing has run, and the run's
228
+ * records agree: no base stamped, no provider start, session, tokens or commit, and not one row of any process, agent or
229
+ * check. All of them, or it stands as a failure, and a record that can't be read (an older database) never excuses one.
230
+ */
231
+ function refusedBeforeAnything(store, runId) {
232
+ try {
233
+ const said = store.handle.prepare("SELECT COUNT(*) AS n FROM action_ledger WHERE run_id = ? AND action = ? AND outcome = 'refused'").get(runId, NOTHING_STARTED_ACTION)?.["n"];
234
+ if (Number(said) !== 1)
235
+ return false;
236
+ const run = store.handle.prepare("SELECT base_revision, head_revision, provider_started_at, session_id, tokens_in, tokens_out, cost_usd, committed FROM run WHERE id = ?").get(runId);
237
+ if (run === undefined || Object.values(run).some(value => value !== null))
238
+ return false;
239
+ // check_fact is a learning-loops table (eval-hooks.ts): read here in its own literal statement, never through the
240
+ // table list, so no SQL text that could name it is assembled outside its sole writer.
241
+ if (store.handle.prepare("SELECT 1 FROM check_fact WHERE run = ? LIMIT 1").get(runId) !== undefined)
242
+ return false;
243
+ return RUN_TRACES.every(([table, column]) => store.handle.prepare(`SELECT 1 FROM ${table} WHERE ${column} = ? LIMIT 1`).get(runId) === undefined);
244
+ }
245
+ catch {
246
+ return false;
247
+ }
216
248
  }
217
249
  /** The task's finished builder runs under `digest`, oldest first, each with whether its check passed or never ran. */
218
250
  function builderRuns(store, taskRef, digest) {
@@ -0,0 +1,47 @@
1
+ import type { Store } from "./store.js";
2
+ export type CheckRepairState = "waiting" | "repairing" | "rerunning" | "with-person" | "parked" | "fixed" | "closed";
3
+ export type CheckRepair = {
4
+ id: number;
5
+ familyRoot: string;
6
+ ownerTask: string;
7
+ repo: string;
8
+ revision: string;
9
+ sourceRun: number;
10
+ state: CheckRepairState;
11
+ why: string;
12
+ repairTask: string | null;
13
+ attempt: number | null;
14
+ createdAt: string;
15
+ updatedAt: string;
16
+ };
17
+ export declare function checkRepairsOfTask(store: Store, taskId: string): CheckRepair[];
18
+ export declare function checkRepairOfRun(store: Store, runId: number): CheckRepair | null;
19
+ /** What makes this failure this failure: the command, how it ended, and which files failed. Never the log's own bytes,
20
+ * which differ on every run. */
21
+ export declare function failureKeyOf(receipt: string, log: string): string;
22
+ /**
23
+ * Route one failed check. Runs at disposition. A failure already on record for this family and revision changes nothing,
24
+ * unless it was the one rerun of an unrelated failure, which now goes to a person through the ledger's own rerun stop.
25
+ */
26
+ export declare function routeFailedCheck(store: Store, repo: string, root: string, runId: number, now: Date): CheckRepair | null;
27
+ export type StartRepair = {
28
+ ok: true;
29
+ draft: string;
30
+ attempt: number;
31
+ approved: boolean;
32
+ } | {
33
+ ok: false;
34
+ reason: "unknown" | "refused";
35
+ message: string;
36
+ };
37
+ /** A person asks for the repair. The draft takes the normal plan approval: this is not that approval. */
38
+ export declare function startCheckRepair(store: Store, root: string, runId: number, now: Date): StartRepair;
39
+ /** A passing result in the family ends its failed-check rows. Rows the person already holds end too: the code passes now. */
40
+ export declare function settleCheckRepairs(store: Store, taskId: string, verdict: string, now: Date): void;
41
+ /** Each sweep: a parked failure whose task is free again takes its next step, unless a newer attempt replaced it. */
42
+ export declare function resumeParkedRepairs(store: Store, root: string, now: Date): number;
43
+ /** A build was accepted as finished: count what it answered, then route its check if it failed. Bookkeeping that never
44
+ * costs the build its result: a problem here leaves the finding open and the failed check on the result. */
45
+ export declare function followBuiltRun(store: Store, repo: string, root: string, runId: number, worktree: string, now: Date): void;
46
+ /** How a failed check stands, for a person, in one line. */
47
+ export declare function checkRepairWords(row: CheckRepair): string;