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
@@ -12,12 +12,24 @@ import { withActor } from "./actor.js";
12
12
  import { TEXT_LIMITS } from "./text-limits.js";
13
13
  import { publicChatText } from "./chat-display.js";
14
14
  import { gateWords } from "./approval-policy.js";
15
+ import { checkLeadIdentity } from "./lead-identity.js";
16
+ import { hasForbiddenControls } from "./decision.js";
17
+ import { activeUnattendedGrant, candidatesDigestOf, FLOW_ACCEPTANCE, parseUnattendedAsk, revokeUnattended, sealUnattended, unattendedCandidates, unattendedSignProblem, untilWords, workReposOf } from "./flow-unattended.js";
18
+ import { UNATTENDED_RULE_WORDS, unattendedCoverWords, unattendedHeadWords, unattendedLimitWords } from "./flow-unattended-words.js";
19
+ import { CHECK_ACTION, checkFindingAt, cleanFindingText, familyInReach, FINDING_EXCERPT_LIMIT, FINDING_ROUND_LIMIT, FINDING_TEXT_LIMIT, findingLine, findingStandingWords, getFinding, resendFinding, REVIEW_ACTION, reviewFindingAt, sendCheckFinding, sendFinding, sendReviewFinding } from "./findings.js";
20
+ import { lessonNoteOf } from "./lesson-notes.js";
21
+ import { decideProposal, listProposals } from "./memory-pass.js";
22
+ import { authPauseOf, liftAuthPause } from "./provider-auth.js";
23
+ import { isProviderId } from "./provider.js";
24
+ import { approverStandsNow, familyRepoOf } from "./tidy.js";
25
+ import { chatApproveMode, effectiveChatApproval } from "./chat-approval.js";
26
+ import { cardTermsDigest, chatLimitProblem, scopeTermsText } from "./chat-terms.js";
15
27
  import { createHash, randomBytes } from "node:crypto";
16
- import { leadTurnKeepsProposals, verifiedAuthor } from "./store.js";
28
+ import { archiveTaskFamily, leadTurnKeepsProposals, restoreTaskFamily, verifiedAuthor } from "./store.js";
17
29
  import { isVerifiedApprover, reproveApprover, } from "./principal.js";
18
30
  import { changeSkills, importSkill, skillsView, skillsVersion, testSkill, validateSkill, } from "./project-skills.js";
19
31
  import { changeKnowledge, knowledgeVersion, knowledgeView, } from "./project-knowledge.js";
20
- import { passwordlessSessionFor, approve, authenticateApprover, describeScope, scopeAuthorityOf, freshnessFor, localApprovalEligible, spendingOnlyOnSuccess, ssoNowAvailable, withFreshness, } from "./scope.js";
32
+ import { passwordlessSessionFor, approve, authenticateApprover, describeScope, scopeAuthorityOf, machineActing, freshnessFor, localApprovalEligible, spendingOnlyOnSuccess, ssoNowAvailable, withFreshness, } from "./scope.js";
21
33
  import { readVerifiedArtifact, scanForSecrets } from "./evidence.js";
22
34
  import { readAuthModeStrict } from "./keys.js";
23
35
  import { acceptAndCompleteAsOperator, assignmentOf } from "./assignment.js";
@@ -32,7 +44,7 @@ import { assignFlowCard, commentOnFlowCard, flowPeople, mentionsIn, watchFlowCar
32
44
  import { browserViewOf, liveBrowserGrant, revokeFlowBrowser, signFlowBrowser } from "./flow-browser.js";
33
45
  import { addFlowTriggerTo, describeTrigger, readLinearKey, removeFlowTrigger, takesDeliveries, triggerConfigOf, validateTriggerConfig } from "./flow-triggers.js";
34
46
  import { describeSchedule, parseSchedule } from "./flow-schedule.js";
35
- import { dirname } from "node:path";
47
+ import { basename, dirname } from "node:path";
36
48
  import { handleOf, parseSoul, SOUL_CHARS, SUBAGENT_TEMPLATES, subagentLabel } from "./subagents.js";
37
49
  import { createSubagentFrom, labelOf, nameOf, renamedSoul, saveSoul, setSubagentState } from "./subagent-admin.js";
38
50
  import { cleanMemory, editMemory, forgetMemory, tellSubagent } from "./subagent-memory.js";
@@ -96,7 +108,7 @@ export const CHAT_ACTIONS = {
96
108
  flow_starter: { label: "Switch on", protected: false, password: false },
97
109
  // Lead fan-out (docs/plans/lead-fan-out.md): files N tasks under one brief and approves each one's scope — the
98
110
  // person's ceremony (protected, with the password), N ordinary approvals of exactly the scopes shown.
99
- sweep_file: { label: "Start sweep", protected: true, password: true },
111
+ sweep_file: { label: "Start sweep", protected: true, password: false },
100
112
  // Stops every item of a sweep not yet ready; finished results stay. An ordinary act, like cancelling a task.
101
113
  sweep_cancel_rest: { label: "Cancel the rest", protected: false, password: false },
102
114
  flow_edit: { label: "Save flow", protected: false, password: false },
@@ -114,9 +126,9 @@ export const CHAT_ACTIONS = {
114
126
  flow_trigger_pause: { label: "Pause trigger", protected: false, password: false },
115
127
  flow_trigger_resume: { label: "Turn trigger on", protected: false, password: false },
116
128
  flow_trigger_remove: { label: "Remove trigger", protected: false, password: false },
117
- // Browser use widens a flow's sandbox (flow-browser.ts): the lead may only propose it; a person allows it with their
118
- // own password on the secure screen, never on a chat card or a paired phone's tap. Turning it off lowers authority.
119
- flow_browser: { label: "Allow browser", protected: true, password: true },
129
+ // Browser use widens a flow's sandbox (flow-browser.ts): the lead may only propose it; a person allows it with their own
130
+ // yes on a card that shows every term, in a chat they are signed in to or linked from. Turning it off lowers authority.
131
+ flow_browser: { label: "Allow browser", protected: true, password: false },
120
132
  flow_browser_off: { label: "Turn off browser", protected: false, password: false },
121
133
  subagent_create: { label: "Add subagent", protected: false, password: false },
122
134
  subagent_soul: { label: "Update soul file", protected: false, password: false },
@@ -138,22 +150,47 @@ export const CHAT_ACTIONS = {
138
150
  // The code map's switch and holdout (docs/plans/code-map.md §5): a person's yes on the card, re-proved in the write.
139
151
  code_map_switch: { label: "Change code map", protected: false, password: false },
140
152
  code_map_holdout_end: { label: "End holdout", protected: false, password: false },
153
+ // Chat and console parity (docs/plans/toolroll-cards.md): plain acts that lower authority or change no one's reach,
154
+ // each the console's own write. Ending a mode and turning a flow off its own only ever lower authority.
155
+ mode_revoke: { label: "End automatic approvals", protected: false, password: false },
156
+ flow_unattended_off: { label: "Stop running on its own", protected: false, password: false },
157
+ flow_archive: { label: "Archive flow", protected: false, password: false },
158
+ provider_resume: { label: "Resume agent", protected: false, password: false },
159
+ worker_note: { label: "Note on worker", protected: false, password: false },
160
+ lead_identity: { label: "Name your lead", protected: false, password: false },
161
+ task_archive: { label: "Put away task", protected: false, password: false },
162
+ task_restore: { label: "Restore task", protected: false, password: false },
163
+ incident_resolve: { label: "Resolve incident", protected: false, password: false },
164
+ memory_decide: { label: "Decide memory proposal", protected: false, password: false },
165
+ // Follow-through (findings.ts): not an approval. A person's yes to words shown on the card sends them to the agent.
166
+ finding_send: { label: "Send finding", protected: false, password: false },
167
+ finding_resend: { label: "Send finding back", protected: false, password: false },
168
+ // The rest of the approvals: a person's own yes on a card that shows every term.
169
+ task_reopen: { label: "Reopen task", protected: true, password: false },
170
+ revision_accept: { label: "Accept plan revision", protected: true, password: false },
171
+ revision_reject: { label: "Reject plan revision", protected: true, password: false },
172
+ flow_unattended: { label: "Let flow run on its own", protected: true, password: false },
141
173
  // What the owner's own lead knows about them: theirs alone, in no project (lead-about.ts).
142
174
  lead_about_you: { label: "Remember", protected: false, password: false },
143
- scope_approve: { label: "Approve work", protected: true, password: true },
175
+ scope_approve: { label: "Approve work", protected: true, password: false },
144
176
  result_accept: { label: "Accept", protected: true, password: false },
145
177
  task_cancel: { label: "Cancel task", protected: true, password: false },
146
- task_resume: { label: "Resume task", protected: true, password: true },
178
+ task_resume: { label: "Resume task", protected: true, password: false },
147
179
  };
148
180
  // Every action has a schema (contracts/chat-actions.ts) and every schema an action: a mismatch fails to compile.
149
181
  const SAME_ACTIONS = true;
150
182
  void SAME_ACTIONS;
151
- /** Protected actions a paired phone may confirm behind its own explicit
152
- * yes/cancel challenge instead of the console's secure screen. Password
153
- * actions and long or redacted terms never qualify. */
154
- export const CHALLENGE_ACTIONS = new Set(["result_accept"]);
183
+ /** Approvals (owner's decision, docs/plans/toolroll-cards.md): a person's own yes from their chat stands without a password.
184
+ * Only a person's tap in a linked chat, or their click in a signed-in browser, ever runs one. */
185
+ export const APPROVAL_ACTIONS = new Set(["scope_approve", "task_resume", "sweep_file", "flow_browser", "task_reopen", "revision_accept", "revision_reject", "flow_unattended"]);
186
+ /** Protected actions a linked chat may confirm behind its own explicit yes/cancel challenge instead of the review screen.
187
+ * Password actions and long or redacted terms never qualify: the card must show the exact terms. */
188
+ export const APPROVAL_TERM_CHARS = 3000;
189
+ export const CHALLENGE_ACTIONS = new Set(["result_accept", ...APPROVAL_ACTIONS]);
155
190
  /** Actions about the person themselves, in no project: their card carries no project (repo ""). */
156
- export const OWNER_ACTIONS = new Set(["lead_about_you"]);
191
+ export const OWNER_ACTIONS = new Set(["lead_about_you", "provider_resume", "worker_note", "lead_identity"]);
192
+ /** Actions the console refuses a project-limited account (route table: limited "deny"): the same here. */
193
+ const INSTANCE_ACTIONS = new Set(["mode_revoke", "flow_unattended_off", "flow_unattended", "provider_resume", "worker_note", "lead_identity", "incident_resolve", "memory_decide"]);
157
194
  export function isChatAction(value) {
158
195
  return typeof value === "string" && Object.hasOwn(CHAT_ACTIONS, value);
159
196
  }
@@ -267,6 +304,14 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
267
304
  const read = readChatActionRequest(operation, input);
268
305
  if (!read.ok)
269
306
  throw Error(`This action can't be read — ${read.issues.map(issue => issue.line).join("; ")}.`);
307
+ if (INSTANCE_ACTIONS.has(operation) && store.accountOf(who.name)?.projects !== null)
308
+ throw Error("That needs an account with access to every project, as in the console.");
309
+ if (operation === "provider_resume" || operation === "worker_note" || operation === "lead_identity") {
310
+ requireActor(store, who, "", operation);
311
+ const { request, terms, state, title } = instanceAction(store, who, operation, input);
312
+ const stamp = hash({ operation, request, repo: "", state, terms, actor: who.name, generation: who.generation, ceiling: who.ceilingDigest });
313
+ return { version: SHARED_ACTION_VERSION, operation, request, repo: "", title, terms, stamp, state };
314
+ }
270
315
  if (operation === "lead_about_you") {
271
316
  // The owner's own note: the card shows the line, and the line it replaces beside it.
272
317
  const checked = checkAboutYouLine(input["line"]);
@@ -289,7 +334,7 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
289
334
  const stamp = hash({ operation, request, repo: "", state, terms, actor: who.name, generation: who.generation, ceiling: who.ceilingDigest });
290
335
  return { version: SHARED_ACTION_VERSION, operation, request, repo: "", title, terms, stamp, state };
291
336
  }
292
- const task = operation.startsWith("code_map_") || operation.startsWith("skill_") || operation.startsWith("knowledge_") || operation.startsWith("decision_") || operation.startsWith("tool_") || operation.startsWith("flow_") || operation.startsWith("subagent_") || operation.startsWith("kit_") || operation.startsWith("sweep_") || operation.startsWith("suggestion_") || operation.startsWith("trial_")
337
+ const task = operation === "mode_revoke" || operation === "memory_decide" || operation === "incident_resolve" || operation === "finding_resend" || operation.startsWith("code_map_") || operation.startsWith("skill_") || operation.startsWith("knowledge_") || operation.startsWith("decision_") || operation.startsWith("tool_") || operation.startsWith("flow_") || operation.startsWith("subagent_") || operation.startsWith("kit_") || operation.startsWith("sweep_") || operation.startsWith("suggestion_") || operation.startsWith("trial_")
293
338
  ? null
294
339
  : text(input, "task", 64);
295
340
  const flowTarget = operation.startsWith("flow_") && operation !== "flow_create" && operation !== "flow_script_save" && operation !== "flow_starter" ? flowTargetOf(store, input) : null;
@@ -302,13 +347,21 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
302
347
  if (operation === "subagent_answer" && (questionTarget === null || questionFlow === null))
303
348
  throw Error("That question isn't open any more.");
304
349
  const repo = operation === "sweep_cancel_rest" ? store.getSweep(integer(input, "sweep"))?.repo
305
- : flowTarget !== null ? flowTarget.flow.repo : subagentTarget !== null ? subagentTarget.repo : questionFlow !== null ? questionFlow.repo : task === null ? text(input, "repo", 4096) : store.lookupRef(task)?.repo;
350
+ : operation === "incident_resolve" ? incidentRepoOf(store, integer(input, "incident"))
351
+ : operation === "finding_resend" ? getFinding(store, integer(input, "finding"))?.repo
352
+ : flowTarget !== null ? flowTarget.flow.repo : subagentTarget !== null ? subagentTarget.repo : questionFlow !== null ? questionFlow.repo : task === null ? text(input, "repo", 4096) : store.lookupRef(task)?.repo;
306
353
  if (!repo)
307
354
  throw Error("Choose an available project.");
308
355
  requireActor(store, who, repo);
309
356
  const request = structuredClone(input), terms = [];
310
357
  let state, title = CHAT_ACTIONS[operation].label;
311
- if (operation.startsWith("skill_")) {
358
+ if (PARITY_ACTIONS.has(operation)) {
359
+ const made = parityAction(store, who, operation, input, repo, flowTarget?.flow ?? null, now, request, root);
360
+ state = made.state;
361
+ title = made.title;
362
+ terms.push(...made.terms);
363
+ }
364
+ else if (operation.startsWith("skill_")) {
312
365
  const view = skillsView(store, repo, who.name);
313
366
  state = {
314
367
  identity: view.identity,
@@ -625,7 +678,9 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
625
678
  const children = sweepChildScopes(store, who, repo, brief, cleaned.map(({ title, item }) => ({ title, item })), input["acceptance"], now);
626
679
  state = { children: children.map(one => one.fingerprint) };
627
680
  const tests = acceptance.map((one, index) => `Success test ${index + 1}${typeof one["id"] === "string" ? ` (${one["id"]})` : ""}: ${String(one["statement"] ?? "")}${Array.isArray(one["evidence"]) ? ` — shown by ${one["evidence"].map(String).join(", ")}` : ""}`);
628
- terms.push(`${cleaned.length} tasks, each in its own worktree on its own branch, at most ${maxParallel} running at once, built directly (no separate planning step).`, `Shared rules for every item: ${brief}`, ...cleaned.map(one => `Item ${one.index + 1} — ${one.title}: ${one.item}`), `Each item's goal is the shared rules, then its own item, then: ${VERDICT_RULE}`, ...tests.map(line => `Every item — ${line}`), ...(children.every(one => one.runtime === children[0].runtime) ? [`Every item — ${children[0].runtime}`] : children.map((one, index) => `Item ${index + 1} — ${one.runtime}`)), "Confirming files all of them and approves each one's scope exactly as shown. An item that needs a second person, or that the project's rules keep from its requester, is filed and waits for its own approval.", "Each ends with a verdict (fixed, not reproducible, blocked or done) and the sweep's table shows them as they land.");
681
+ terms.push(`${cleaned.length} tasks, each in its own worktree on its own branch, at most ${maxParallel} running at once, built directly (no separate planning step).`, `Shared rules for every item: ${brief}`, ...cleaned.map(one => `Item ${one.index + 1} — ${one.title}: ${one.item}`), `Each item's goal is the shared rules, then its own item, then: ${VERDICT_RULE}`, ...tests.map(line => `Every item — ${line}`), ...(children.every(one => one.runtime === children[0].runtime) ? [`Every item — ${children[0].runtime}`] : children.map((one, index) => `Item ${index + 1} — ${one.runtime}`)),
682
+ // Each item's own terms, word for word: its yes is bound to this text.
683
+ ...children.map((one, index) => `Item ${index + 1} in full:\n${one.terms}`), "Confirming files all of them and approves each one's scope exactly as shown. An item that needs a second person, or that the project's rules keep from its requester, is filed and waits for its own approval.", "Each ends with a verdict (fixed, not reproducible, blocked or done) and the sweep's table shows them as they land.");
629
684
  }
630
685
  else if (operation.startsWith("flow_")) {
631
686
  const project = repo.split(/[\\/]/).filter(Boolean).at(-1) ?? repo;
@@ -749,7 +804,9 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
749
804
  // The approval the person reads and the one their password signs: the digest is re-derived when they confirm.
750
805
  state = { flow: flow.id, revision: flow.revision, digest: view.digest };
751
806
  title = `Allow browser use in ${flow.name}`;
752
- terms.push(`Flow: ${flow.name} · project ${project}`, ...view.zones.map(zone => `${zone.title} runs ${zone.script} v${zone.version}\nScript digest ${zone.digest}`), "Each run of these zones starts a headless Chromium.", ...view.terms, `Approval digest ${view.digest}`);
807
+ terms.push(`Flow: ${flow.name} · project ${project}`,
808
+ // Each script by its short digest: the whole approval, every script's full digest included, is bound in the state.
809
+ ...view.zones.map(zone => `${zone.title} runs ${zone.script} v${zone.version}\nScript ${zone.digest.slice(0, 12)}`), "Each run of these zones starts a headless Chromium.", ...view.terms);
753
810
  }
754
811
  }
755
812
  else if (operation === "flow_trigger_pause" || operation === "flow_trigger_resume" || operation === "flow_trigger_remove") {
@@ -988,8 +1045,9 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
988
1045
  if (!authority.ok)
989
1046
  throw Error(authority.problem);
990
1047
  state["authMode"] = authority.authMode;
991
- terms.push(...describeScope(scope, undefined, taskEvidenceWords(store, scope.taskId, now))
992
- .filter((line) => !/^\s*(reference|approved)\s/.test(line))
1048
+ // The whole terms word for word (the yes is bound to this text: chat-terms.ts cardShowsScope), then everything else.
1049
+ terms.push(scopeTermsText(scope), ...describeScope(scope, undefined, taskEvidenceWords(store, scope.taskId, now))
1050
+ .filter((line) => !/^\s*(reference|approved|goal|not this|touches)\s/.test(line))
993
1051
  .map((line) => line
994
1052
  .trimStart()
995
1053
  .replace(/^why model\s+/, "Why this model: ")
@@ -1051,6 +1109,9 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
1051
1109
  terms.push(`Limitation: ${caveat}`);
1052
1110
  }
1053
1111
  }
1112
+ // An approval keeps the digest of the exact words its card shows, from now on: the yes is to these (scope.ts approve).
1113
+ if (APPROVAL_ACTIONS.has(operation))
1114
+ state["shown"] = cardTermsDigest(terms.join("\n"));
1054
1115
  const stamp = hash({
1055
1116
  operation,
1056
1117
  request,
@@ -1071,10 +1132,13 @@ export function sharedActionPayload(value) {
1071
1132
  /** A shortened or redacted preview is not complete consent. This rule lives
1072
1133
  * in the shared door, so a forged transport callback cannot bypass it. */
1073
1134
  function sharedActionContentNeedsReview(action) {
1135
+ // An approval's card has room for longer terms. Its words are compared exactly: anything the card would hide or
1136
+ // rewrite (a long hex value, a path, a secret) sends the person to the review page. Toolroll's own digest is bound in
1137
+ // the state, never in the terms, and a commit is written short, so an ordinary approval shows every word as stored.
1138
+ const approval = APPROVAL_ACTIONS.has(action.operation), cap = approval ? APPROVAL_TERM_CHARS : 1200;
1074
1139
  const content = [action.title, ...action.terms];
1075
- return (action.terms.join("\n").length > 1200 ||
1076
- content.some((value, index) => publicChatText(value, index === 0 ? 200 : 1200) !==
1077
- value.replace(/\s+/g, " ").trim()));
1140
+ return (action.terms.join("\n").length > cap ||
1141
+ content.some((value, index) => publicChatText(value, index === 0 ? 200 : cap) !== value.replace(/\s+/g, " ").trim()));
1078
1142
  }
1079
1143
  export function sharedActionNeedsReview(action) {
1080
1144
  return (CHAT_ACTIONS[action.operation].protected ||
@@ -1161,8 +1225,10 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1161
1225
  if ((options.authority === "local-owner" || currentLocalOwner() !== null) && (!isChatAction(payload.operation) || CHAT_ACTION_LOCAL_ACTS[payload.operation] === null))
1162
1226
  return refuse("refused", "Set a password to do this.");
1163
1227
  let live;
1228
+ let drafted;
1164
1229
  try {
1165
1230
  const saved = savedActionContext(store, who, id, "confirming");
1231
+ drafted = saved;
1166
1232
  if (hash(saved) !== hash(payload))
1167
1233
  return refuse("refused", "This action does not match the saved proposal.");
1168
1234
  live = prepareSharedAction(store, who, saved.operation, saved.request, options.root, now);
@@ -1176,31 +1242,24 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1176
1242
  }
1177
1243
  // The review nonce was minted against the saved proposal's stamp: it is checked against that, whoever reviews.
1178
1244
  const savedStamp = payload.stamp;
1179
- let personProved = null;
1180
1245
  payload = live;
1181
1246
  const config = CHAT_ACTIONS[live.operation], req = live.request;
1182
1247
  const challenged = options.via !== "web" &&
1183
1248
  options.via !== "cli" &&
1184
1249
  options.confirm === true &&
1185
1250
  sharedActionAllowsChallenge(payload);
1251
+ if (APPROVAL_ACTIONS.has(live.operation) && machineActing())
1252
+ return refuse("needs-confirm", "Only a person approves this.");
1253
+ // A lead's or remote token reaches the same web door: an approval needs a person's own session (or tap, above).
1254
+ if (APPROVAL_ACTIONS.has(live.operation) && options.via === "web" && options.authority !== "local-owner" && options.person !== true)
1255
+ return refuse("needs-confirm", "Approve this yourself, in your signed-in chat.");
1186
1256
  if (sharedActionNeedsReview(payload) && !challenged) {
1187
1257
  if (options.via !== "web" || !options.review || options.confirm !== true)
1188
1258
  return refuse("needs-confirm", "Review the complete action in the secure confirmation screen.");
1189
1259
  if (!store.consumeCeremonyNonce(nonceHash(options.review.nonce), who.name, "chat-action", id, savedStamp, now))
1190
1260
  return refuse("needs-confirm", "This confirmation expired or was already used. Open the review again.");
1191
- // A scope approval proves its person inside approve() (one transaction with the seal, under the same freshness
1192
- // rule as the task page); here an empty field is only looked at, never spent (an explicit re-check is one-use).
1193
- const emptyApproval = payload.operation === "scope_approve" && options.review.password === "";
1194
- const proved = config.password && !emptyApproval ? authenticateApprover(store, who.name, options.review.password, payload.repo) : null;
1195
- // How the person was proved, for an approval that records it (browser use: flow-browser.ts refuses any but a person's).
1196
- if (proved?.ok === true)
1197
- personProved = proved.method;
1198
- if (config.password &&
1199
- (emptyApproval
1200
- ? !passwordlessSessionFor(store, who.name) && !ssoNowAvailable(who.name) && withFreshness("approve-standard", () => freshnessFor(who.name)) === null
1201
- // Easy-start C5: a local owner's eligible approval takes no password (approve() proves it again in the seal).
1202
- && !(options.authority === "local-owner" && currentLocalOwner()?.act === "approve-plan" && typeof req["task"] === "string" && localApprovalEligible(store, who.name, req["task"]).ok)
1203
- : proved?.ok !== true))
1261
+ const proved = config.password ? authenticateApprover(store, who.name, options.review.password, payload.repo) : null;
1262
+ if (config.password && proved?.ok !== true)
1204
1263
  return refuse("needs-confirm", "Confirm with your password in the secure screen.");
1205
1264
  }
1206
1265
  try {
@@ -1209,6 +1268,10 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1209
1268
  const act = () => store.savepoint(() => {
1210
1269
  const task = typeof req["task"] === "string" ? req["task"] : null, repo = payload.repo, actor = who.name;
1211
1270
  let taskId = task;
1271
+ if (LATE_APPROVALS.has(payload.operation))
1272
+ return runLateApproval(store, who, payload, repo => approvalBy(store, who, repo, now, options, drafted), approvalRoad(options), now);
1273
+ if (PARITY_ACTIONS.has(payload.operation) || OWNER_PARITY.has(payload.operation))
1274
+ return runParityAction(store, who, payload, now, options.root);
1212
1275
  if (payload.operation === "lead_about_you") {
1213
1276
  const next = withAboutYouLine(aboutYouOf(store, actor), String(req["line"]), Number(req["replaces"] ?? 0));
1214
1277
  if (!next.ok)
@@ -1245,7 +1308,10 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1245
1308
  // re-reads the flow, its zones and scripts in this transaction and refuses unless they are what the card showed.
1246
1309
  const flow = Number(payload.state["flow"]);
1247
1310
  // signFlowBrowser re-reads the account's generation and reach in its transaction (it may have narrowed since).
1248
- const signed = signFlowBrowser(store, { flow, signer: actor, proof: personProved, sawDigest: String(payload.state["digest"] ?? ""), console: { generation: who.generation } }, now);
1311
+ const by = approvalBy(store, who, repo, now, options, drafted);
1312
+ if (by.ok === false)
1313
+ throw Error(by.why);
1314
+ const signed = signFlowBrowser(store, { flow, signer: actor, proof: options.via === "web" ? "session" : "chat", sawDigest: String(payload.state["digest"] ?? ""), console: { generation: who.generation } }, now);
1249
1315
  if (!signed.ok)
1250
1316
  throw Error(signed.message);
1251
1317
  return { ok: true, taskId: null, said: "Browser allowed. Its browser zones can start Chromium now.", href: `/flows/${flow}` };
@@ -1265,7 +1331,7 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1265
1331
  return { ok: true, taskId: null, said, href: `/sweeps/${Number(req["sweep"])}` };
1266
1332
  }
1267
1333
  else if (payload.operation === "sweep_file") {
1268
- const done = fileSweep(store, who, id, payload, options.review?.password ?? "", now);
1334
+ const done = fileSweep(store, who, id, payload, approvalBy(store, who, payload.repo, now, options, drafted), now);
1269
1335
  return { ok: true, taskId: null, said: done.said, href: done.href };
1270
1336
  }
1271
1337
  else if (payload.operation === "kit_setup") {
@@ -1336,7 +1402,16 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1336
1402
  else if (payload.operation === "scope_approve") {
1337
1403
  const scope = store.getScope(task);
1338
1404
  // Cockpit B2: classified by the act, not the road — the same standard-approval freshness as /t/:id/approve.
1339
- const result = withFreshness("approve-standard", () => approve(store, task, actor, now, scope.digest, options.review.password));
1405
+ const by = approvalBy(store, who, repo, now, options, drafted);
1406
+ if (by.ok === false)
1407
+ throw Error(by.why);
1408
+ const limited = chatLimitProblem(scope, by.limits, "your chat approval setting");
1409
+ if (limited !== null)
1410
+ throw Error(limited);
1411
+ // A local owner keeps its own road (approve proves it again in the seal); everyone else, their own yes from chat,
1412
+ // to the scope and the words the card was made with.
1413
+ const sawDigest = String(drafted.state["scope"]?.digest ?? "");
1414
+ const result = withFreshness("approve-standard", () => approve(store, task, actor, now, sawDigest, options.review?.password ?? "", {}, undefined, by.chat));
1340
1415
  if (!result.ok) {
1341
1416
  // A first yes on protected work is kept, with how its person was proved: committed, never rolled back.
1342
1417
  // The card stays open (`awaiting`) for the second approver; the same person again changes nothing.
@@ -1363,11 +1438,14 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1363
1438
  throw Error(`Task was not cancelled: ${result.reason}.`);
1364
1439
  }
1365
1440
  else if (payload.operation === "task_resume") {
1441
+ const by = approvalBy(store, who, repo, now, options, drafted);
1442
+ if (by.ok === false)
1443
+ throw Error(by.why);
1366
1444
  const result = resumeTaskStop(store, {
1367
1445
  taskId: task,
1368
1446
  runId: Number(req["run"]),
1369
1447
  by: verifiedAuthor(actor),
1370
- via: "web",
1448
+ via: options.via,
1371
1449
  }, now);
1372
1450
  if (!result.ok)
1373
1451
  throw Error(result.detail);
@@ -1446,7 +1524,7 @@ function sweepChildScopeOf(store, taskId) {
1446
1524
  // The fingerprint covers the scope's every field, not only what is rendered (a profile setting no sentence names, such
1447
1525
  // as an agent's idle timeout, still makes the card stale): its exact terms with only identity left out.
1448
1526
  const { taskId: _task, digest: _digest, approvedDigest: _approved, approvedBy: _by, approvedAt: _at, proposedAt: _proposed, approvalBasis: _basis, modeDigest: _mode, approvedProfile: _approvedProfile, approvedProfileJson: _approvedProfileJson, approvedChainJson: _approvedChain, ...terms } = scope;
1449
- return { fingerprint: hash({ words, runtime, authMode: authority.authMode, profile: authority.profile, terms }), runtime, words };
1527
+ return { fingerprint: hash({ words, runtime, authMode: authority.authMode, profile: authority.profile, terms }), runtime, words, terms: scopeTermsText(scope) };
1450
1528
  }
1451
1529
  /** Every child as filing would make it, found in a savepoint that is always rolled back: prepare never writes. */
1452
1530
  function sweepChildScopes(store, who, repo, brief, items, acceptance, now) {
@@ -1472,7 +1550,24 @@ function sweepChildScopes(store, who, repo, brief, items, acceptance, now) {
1472
1550
  });
1473
1551
  return found;
1474
1552
  }
1475
- function fileSweep(store, who, proposal, payload, password, now) {
1553
+ /** How this yes stands, and under whose limits: a person's own from chat, by their own chat setting, with the words and
1554
+ * digest the card was made with. A local owner's road has none: approve() proves it itself. */
1555
+ function approvalBy(store, who, repo, now, options, drafted) {
1556
+ if (options.authority === "local-owner")
1557
+ return { ok: true, limits: { fullAccess: true, capMicrousd: null } };
1558
+ if (options.via === "cli")
1559
+ return { ok: false, why: "Approve this in chat or in Toolroll." };
1560
+ const shown = { expect: String(drafted.state["shown"] ?? ""), terms: drafted.terms.join("\n") };
1561
+ if (options.via === "web") {
1562
+ const allowed = effectiveChatApproval(store, repo, who.name);
1563
+ return allowed.ok ? { ok: true, limits: allowed.limits, chat: { modeDigest: allowed.setting.digest, shown } } : { ok: false, why: allowed.why };
1564
+ }
1565
+ const allowed = chatApproveMode(store, repo, who.name, now, options.via);
1566
+ return allowed.ok ? { ok: true, limits: allowed.limits, chat: { modeDigest: allowed.digest, shown } } : allowed;
1567
+ }
1568
+ function fileSweep(store, who, proposal, payload, by, now) {
1569
+ if (by.ok === false)
1570
+ throw Error(by.why);
1476
1571
  const req = payload.request;
1477
1572
  const already = store.sweepOfProposal(proposal);
1478
1573
  if (already !== null)
@@ -1495,7 +1590,9 @@ function fileSweep(store, who, proposal, payload, password, now) {
1495
1590
  const waiting = [];
1496
1591
  for (const one of filed) {
1497
1592
  const scope = store.getScope(one.id);
1498
- const approved = scope === null ? null : withFreshness("approve-standard", () => approve(store, one.id, who.name, now, scope.digest, password));
1593
+ // A person's own limits hold for each item: one past them is filed and waits for its own approval.
1594
+ const approved = scope === null || chatLimitProblem(scope, by.limits, "your chat approval setting") !== null ? null
1595
+ : withFreshness("approve-standard", () => approve(store, one.id, who.name, now, scope.digest, "", {}, undefined, by.chat));
1499
1596
  if (approved === null || !approved.ok)
1500
1597
  waiting.push(one.title);
1501
1598
  }
@@ -1677,3 +1774,310 @@ function runFlowAction(store, payload, actor, repos, root, now) {
1677
1774
  throw Error(acted.message);
1678
1775
  return settle(card.flow, acted.said, card.id);
1679
1776
  }
1777
+ // ---- chat and console parity (docs/plans/toolroll-cards.md) ------------------------------------------------------------
1778
+ const LATE_APPROVALS = new Set(["task_reopen", "revision_accept", "revision_reject", "flow_unattended"]);
1779
+ const PARITY_ACTIONS = new Set(["mode_revoke", "flow_unattended_off", "flow_archive", "task_archive", "task_restore", "incident_resolve", "memory_decide", "finding_send", "finding_resend", ...LATE_APPROVALS]);
1780
+ const OWNER_PARITY = new Set(["provider_resume", "worker_note", "lead_identity"]);
1781
+ function incidentRepoOf(store, id) {
1782
+ const open = store.openIncidents().find(one => one.id === id);
1783
+ return open === undefined ? null : store.lookupRef(open.taskId)?.repo ?? null;
1784
+ }
1785
+ function instanceAction(store, who, operation, input) {
1786
+ if (operation === "provider_resume") {
1787
+ const provider = text(input, "provider", 40);
1788
+ if (!isProviderId(provider))
1789
+ throw Error("Choose an agent provider.");
1790
+ const pause = authPauseOf(store, provider);
1791
+ if (pause === null)
1792
+ throw Error("That agent isn't paused.");
1793
+ // The pause is the whole installation's: every project with work waiting on it is named, and must be in reach here.
1794
+ const repos = [...new Set(store.handle.prepare(`SELECT t.repo AS repo FROM run r JOIN task_ref t ON t.id = r.task_ref WHERE r.provider = ? AND r.reason = 'auth-expired' AND r.finished_at >= ?
1795
+ UNION SELECT repo FROM task_ref WHERE auth_wait_pause = ?`).all(provider, pause.openedAt, pause.id).flatMap(row => row["repo"] === null ? [] : [String(row["repo"])]))].sort();
1796
+ if (repos.some(one => !who.repos.includes(one)))
1797
+ throw Error("That agent has work waiting in a project outside your access here.");
1798
+ return { request: { provider }, title: `Resume ${provider}`, state: { pause: pause.id, repos }, terms: [`Lifts the sign-in pause on ${provider} and sends its waiting tasks back to the queue. Existing approvals and holds still apply.`, ...(repos.length === 0 ? [] : [`Waiting work in: ${repos.map(one => basename(one)).join(", ")}`])] };
1799
+ }
1800
+ if (operation === "worker_note") {
1801
+ const runner = text(input, "runner", 120), note = String(input["note"] ?? "").trim();
1802
+ if (note.length > 200 || hasForbiddenControls(note))
1803
+ throw Error("A worker's note is at most 200 plain characters.");
1804
+ const worker = store.listRunners().find(one => one.name === runner && one.retiredAt === null);
1805
+ if (worker === undefined)
1806
+ throw Error("No such worker.");
1807
+ return { request: { runner, note }, title: `Note on ${runner}`, state: { runner }, terms: [note === "" ? "Clears this worker's note." : `Sets this worker's note to: ${note}`] };
1808
+ }
1809
+ const checked = checkLeadIdentity(String(input["name"] ?? ""), String(input["persona"] ?? ""));
1810
+ if (!checked.ok)
1811
+ throw Error(checked.message);
1812
+ return { request: { name: checked.identity.name, persona: checked.identity.persona }, title: `Call your lead ${checked.identity.name}`, state: {}, terms: [`Name: ${checked.identity.name}`, `Persona: ${checked.identity.persona}`] };
1813
+ }
1814
+ const REOPEN_REFUSED = {
1815
+ "unknown-task": "This task is not external work.", "not-latched": "The tracker never closed this: there is nothing to reopen.",
1816
+ "not-seen-open": "The tracker hasn't been seen open again since the close. Reopen it there first; the next sync notices.",
1817
+ claimed: "This task is being built right now.", held: "A hold stands: lift it first.", "question-open": "An unanswered question stands: answer or close it first.",
1818
+ "incident-open": "An unresolved incident stands: resolve it first.", "bad-state": "This task is not in a state reopen can take.",
1819
+ };
1820
+ /** The one plan revision waiting on a decision for this task, by its id or as the only one. */
1821
+ function waitingRevision(store, task, asked) {
1822
+ const ref = store.lookupRef(task);
1823
+ const waiting = ref === null ? [] : store.listPlanRevisions(ref.id).filter(one => one.status === "blocked");
1824
+ const found = asked === undefined ? (waiting.length === 1 ? waiting[0] : null) : waiting.find(one => one.id === asked) ?? null;
1825
+ if (found === null)
1826
+ throw Error("That plan revision isn't waiting on a decision.");
1827
+ return found;
1828
+ }
1829
+ /** A finding follows its task's family, so every project the family reaches must be in reach (findings.ts, one gate). */
1830
+ function familyWithin(store, who, task) {
1831
+ if (familyInReach(store, who, task) === null)
1832
+ throw Error("This task's work reaches a project outside your access here.");
1833
+ }
1834
+ function parityAction(store, who, operation, input, repo, flow, now, request, root) {
1835
+ switch (operation) {
1836
+ case "finding_send": {
1837
+ const task = String(input["task"]), row = store.getTask(task);
1838
+ if (row === null)
1839
+ throw Error("No such task.");
1840
+ familyWithin(store, who, task);
1841
+ const given = ["summary", "pointer", "run"].filter(key => input[key] !== undefined);
1842
+ if (given.length !== 1)
1843
+ throw Error("Send one thing: words in summary, a review finding's pointer, or a failed check's run.");
1844
+ const head = `Sends this to the agent on ${row.title}. It reaches a running build now, or the next attempt's brief, as data to check. The agent must accept it or reject it with a reason; a repeat counts as another round.`;
1845
+ if (input["pointer"] !== undefined) {
1846
+ const read = reviewFindingAt(store, task, text(input, "pointer", 40));
1847
+ if (!read.ok)
1848
+ throw Error(read.message);
1849
+ const place = read.finding.file === null ? [] : [`Where: ${read.finding.file}${read.finding.line === null ? "" : `:${read.finding.line}`}`];
1850
+ return { state: { task, pointer: input["pointer"], finding: read.finding }, title: "Send a review finding", terms: [head, `Finding: ${cleanFindingText(read.finding.scenario, FINDING_TEXT_LIMIT)}`, ...place, `Severity: ${read.finding.severity}`, `Asks: ${REVIEW_ACTION}`] };
1851
+ }
1852
+ if (input["run"] !== undefined) {
1853
+ if (root === undefined)
1854
+ throw Error("The check's log can't be read here.");
1855
+ const read = checkFindingAt(store, root, task, integer(input, "run"));
1856
+ if (!read.ok)
1857
+ throw Error(read.message);
1858
+ return { state: { task, run: input["run"], excerpt: read.excerpt, head: read.head }, title: "Send a failed check", terms: [head, `Finding: ${read.summary}`, `Log: ${cleanFindingText(read.excerpt, FINDING_EXCERPT_LIMIT)}`, `Asks: ${CHECK_ACTION}`] };
1859
+ }
1860
+ const summary = cleanFindingText(text(input, "summary", FINDING_TEXT_LIMIT), FINDING_TEXT_LIMIT);
1861
+ if (summary === "")
1862
+ throw Error("Say what the finding is.");
1863
+ const file = input["file"] === undefined ? null : cleanFindingText(text(input, "file", 300), 300) || null;
1864
+ const line = file === null || input["line"] === undefined ? null : integer(input, "line");
1865
+ for (const key of ["file", "line"])
1866
+ delete request[key];
1867
+ request["summary"] = summary;
1868
+ if (file !== null)
1869
+ request["file"] = file;
1870
+ if (line !== null)
1871
+ request["line"] = line;
1872
+ return { state: { task, updatedAt: row.updatedAt }, title: "Send a finding", terms: [head, `Finding: ${summary}`, ...(file === null ? [] : [`Where: ${file}${line === null ? "" : `:${line}`}`]), `Asks: ${REVIEW_ACTION}`] };
1873
+ }
1874
+ case "finding_resend": {
1875
+ const finding = getFinding(store, integer(input, "finding"));
1876
+ if (finding === null)
1877
+ throw Error("No such finding.");
1878
+ familyWithin(store, who, finding.taskId);
1879
+ if (finding.state === "open")
1880
+ throw Error("That finding is already with the agent.");
1881
+ if (finding.state === "closed")
1882
+ throw Error("That finding is closed.");
1883
+ if (finding.state === "with-person")
1884
+ throw Error("That finding has used its rounds: it needs your decision on the task.");
1885
+ const next = finding.rounds >= FINDING_ROUND_LIMIT ? "Its rounds are used, so this hands it to a person on the task." : `This is round ${finding.rounds + 1} of ${FINDING_ROUND_LIMIT}.`;
1886
+ return { state: { finding: finding.id, state: finding.state, rounds: finding.rounds, answer: finding.answer?.detail ?? null }, title: `Send finding #${finding.id} back`, terms: [`Finding: ${findingLine(finding)}`, `How it stands: ${findingStandingWords(finding)}`, next] };
1887
+ }
1888
+ case "task_reopen": {
1889
+ const task = String(input["task"]), row = store.getTask(task);
1890
+ if (row === null)
1891
+ throw Error("No such task.");
1892
+ if (store.mirrorByTask(task) === null)
1893
+ throw Error(REOPEN_REFUSED["unknown-task"]);
1894
+ return { state: { task, updatedAt: row.updatedAt, taskState: row.state }, title: `Reopen ${row.title}`, terms: ["Takes this outside work back up: the tracker shows it open again, and Toolroll may build it. Existing approvals and holds still apply."] };
1895
+ }
1896
+ case "revision_accept":
1897
+ case "revision_reject": {
1898
+ const task = String(input["task"]), revision = waitingRevision(store, task, input["revision"]);
1899
+ request["revision"] = revision.id;
1900
+ const artifact = store.getArtifact(revision.artifact);
1901
+ if (artifact === null || root === undefined)
1902
+ throw Error("The revised plan can't be read here.");
1903
+ const read = readVerifiedArtifact(root, artifact);
1904
+ if (!read.ok || artifact.truncated || artifact.redacted)
1905
+ throw Error("The revised plan couldn't be verified in full.");
1906
+ return { state: { task, revision: revision.id, artifact: artifact.sha256, authority: revision.authorityDigest }, title: `${operation === "revision_accept" ? "Accept" : "Reject"} revision ${revision.revision} of this plan`,
1907
+ terms: [`Why the builder asked: ${revision.reason}`, `Changes: ${revision.changedFields.join(", ") || "the plan only"}${revision.authorityKind === "authority-change" ? ". This changes what was approved." : "."}`, `Plan:\n${read.content.toString("utf8")}`] };
1908
+ }
1909
+ case "flow_unattended": {
1910
+ const asked = parseUnattendedAsk({ days: input["days"], budget: input["budget"], runs: input["runs"], include: input["include"] });
1911
+ if (!asked.ok)
1912
+ throw Error(asked.message);
1913
+ const problem = unattendedSignProblem(store, flow, who.name);
1914
+ if (problem !== null)
1915
+ throw Error(problem);
1916
+ const candidates = unattendedCandidates(store, flow.id), include = asked.ask.include ?? [];
1917
+ const covered = candidates.filter(one => one.optional === null || include.includes(one.id));
1918
+ if (include.some(id => !candidates.some(one => one.id === id)))
1919
+ throw Error("Name schedules from this flow's own list.");
1920
+ if (covered.length === 0)
1921
+ throw Error("Choose a schedule to include: this flow's schedules bring in outside content, so none is covered unless you pick it.");
1922
+ // Every project its work files in: the person must reach each, and each is named and checked at the yes.
1923
+ const repos = [...new Set([flow.repo, ...workReposOf(flow)])];
1924
+ if (repos.some(one => !who.repos.includes(one) || !store.accountCanAccess(who.name, one)))
1925
+ throw Error("This flow files work in a project outside your access here.");
1926
+ return { state: { flow: flow.id, revision: flow.revision, schedules: candidatesDigestOf(candidates), ask: asked.ask, repos }, title: `Let ${flow.name} run on its own`,
1927
+ terms: [...unattendedHeadWords(who.name, FLOW_ACCEPTANCE), `Works in: ${repos.map(one => basename(one)).join(", ")}`, unattendedCoverWords(covered), ...covered.flatMap(one => one.optional === null ? [] : [`${one.words}: ${one.optional}`]), ...unattendedLimitWords(asked.ask.budgetMicrousd, asked.ask.dailyRunCap), ...UNATTENDED_RULE_WORDS, `It ends after ${asked.ask.days} ${asked.ask.days === 1 ? "day" : "days"}.`] };
1928
+ }
1929
+ case "mode_revoke": {
1930
+ const mode = store.activeMode(repo, now);
1931
+ if (mode === null)
1932
+ throw Error("No automatic approvals are on for this project.");
1933
+ return { state: { mode: mode.id, digest: mode.digest }, title: "End automatic approvals", terms: [`Ends the ${mode.name} mode signed by ${mode.signedBy}. Every act goes back to its own approval.`] };
1934
+ }
1935
+ case "flow_unattended_off": {
1936
+ const grant = activeUnattendedGrant(store, flow.id, now);
1937
+ if (grant === null)
1938
+ throw Error(`${flow.name} isn't running on its own.`);
1939
+ return { state: { flow: flow.id, grant: grant.id }, title: `Stop ${flow.name} running on its own`, terms: ["New cards wait for a person again."] };
1940
+ }
1941
+ case "flow_archive":
1942
+ return { state: { flow: flow.id, revision: flow.revision }, title: `Archive ${flow.name}`, terms: ["Takes this flow off the list. Its triggers stop starting cards."] };
1943
+ case "task_archive": {
1944
+ const task = String(input["task"]), row = store.getTask(task), reason = text(input, "reason", 500, true).trim();
1945
+ if (row === null)
1946
+ throw Error("No such task.");
1947
+ if (reason === "")
1948
+ delete request["reason"];
1949
+ else
1950
+ request["reason"] = reason;
1951
+ return { state: { task, updatedAt: row.updatedAt, state: row.state }, title: `Put away ${row.title}`, terms: ["Takes this task out of every list. It stays saved and can be restored.", ...(reason === "" ? [] : [`Reason: ${reason}`])] };
1952
+ }
1953
+ case "task_restore": {
1954
+ const task = String(input["task"]), row = store.getTask(task);
1955
+ if (row === null)
1956
+ throw Error("No such task.");
1957
+ return { state: { task, updatedAt: row.updatedAt }, title: `Restore ${row.title}`, terms: ["Puts this task back in the lists."] };
1958
+ }
1959
+ case "incident_resolve": {
1960
+ const id = integer(input, "incident"), open = store.openIncidents().find(one => one.id === id);
1961
+ return { state: { incident: id }, title: `Resolve the incident on ${open.taskId}`, terms: ["Marks this incident resolved. Nothing else about the task changes."] };
1962
+ }
1963
+ case "memory_decide": {
1964
+ const id = integer(input, "proposal"), decision = input["decision"] === "accept" ? "accept" : "reject";
1965
+ const proposal = listProposals(store, repo).find(one => one.id === id);
1966
+ if (proposal === undefined)
1967
+ throw Error("That proposal isn't waiting any more.");
1968
+ // A lesson note is accepted in the person's own words (memory-pass.ts): never a model's, so never from a card.
1969
+ if (decision === "accept" && lessonNoteOf(store, id) !== null)
1970
+ throw Error("A lesson note is accepted in your own words: write it on the Knowledge page.");
1971
+ return { state: { proposal: id }, title: `${decision === "accept" ? "Accept" : "Reject"} a memory proposal`, terms: [proposal.title, ...(proposal.beforeText === null ? [] : [`Was: ${proposal.beforeText}`]), `Becomes: ${proposal.afterText}`, `Why: ${proposal.rationale}`, decision === "accept" ? "Later agent sessions in this project read this." : "Nothing is added to project memory."] };
1972
+ }
1973
+ default:
1974
+ throw Error("Choose an available action.");
1975
+ }
1976
+ }
1977
+ /** Where a yes came from, in the words the other chat approvals' ledger lines use. */
1978
+ const approvalRoad = (options) => `via ${options.via}${options.binding === undefined ? "" : ` · chat binding #${options.binding}`}`;
1979
+ /** The approvals added last: the console's own write, after the same question every approval asks. */
1980
+ function runLateApproval(store, who, payload, byFor, road, now) {
1981
+ const req = payload.request, actor = who.name, by = byFor(payload.repo);
1982
+ // Rejecting lowers: it takes a person's yes, not their approving setting.
1983
+ if (by.ok === false && payload.operation !== "revision_reject")
1984
+ throw Error(by.why);
1985
+ if (payload.operation === "task_reopen") {
1986
+ const task = String(req["task"]), reopened = store.reopenMirror(task, actor, now);
1987
+ if (!reopened.ok)
1988
+ throw Error(REOPEN_REFUSED[reopened.reason] ?? "The task could not be reopened.");
1989
+ return { ok: true, taskId: task, said: "Reopened." };
1990
+ }
1991
+ if (payload.operation === "flow_unattended") {
1992
+ const ask = payload.state["ask"];
1993
+ // The person's own setting holds in every project this flow's work files in, not only its own.
1994
+ for (const repo of payload.state["repos"]) {
1995
+ const here = byFor(repo);
1996
+ if (here.ok === false)
1997
+ throw Error(here.why);
1998
+ if (here.limits.capMicrousd !== null && (ask.budgetMicrousd === null || ask.budgetMicrousd > here.limits.capMicrousd))
1999
+ throw Error("This has a higher attempt limit than your chat approval setting allows.");
2000
+ }
2001
+ const signed = sealUnattended(store, { flow: Number(req["flow"]), signer: actor, ask, sawRevision: Number(payload.state["revision"]), sawSchedules: String(payload.state["schedules"]), road }, now);
2002
+ if (!signed.ok)
2003
+ throw Error(signed.message);
2004
+ return { ok: true, taskId: null, said: `Runs on its own until ${untilWords(signed.grant.absoluteExpiry)}.`, href: `/flows/${Number(req["flow"])}` };
2005
+ }
2006
+ const task = String(req["task"]), revision = waitingRevision(store, task, req["revision"]), accept = payload.operation === "revision_accept";
2007
+ // A saved limit can't be read against a change of authority: that one opens in Toolroll.
2008
+ if (accept && by.ok && revision.authorityKind === "authority-change" && (!by.limits.fullAccess || by.limits.capMicrousd !== null))
2009
+ throw Error("This revision changes what was approved, beyond your chat approval limits. Open it in Toolroll.");
2010
+ if (!store.resolvePlanRevision(revision.id, accept ? "applied" : "rejected", actor, now))
2011
+ throw Error("That plan revision is no longer waiting on a decision.");
2012
+ store.releaseOwnedHold("revision", String(revision.id));
2013
+ return { ok: true, taskId: task, said: accept ? "Revision accepted." : "Revision rejected." };
2014
+ }
2015
+ /** Each runs the write the console's own handler calls, as this person, inside the confirm door's transaction. */
2016
+ function runParityAction(store, who, payload, now, root) {
2017
+ const req = payload.request, actor = who.name, repo = payload.repo;
2018
+ const done = (said, href, taskId = null) => ({ ok: true, taskId, said, ...(href === undefined ? {} : { href }) });
2019
+ switch (payload.operation) {
2020
+ case "finding_send":
2021
+ case "finding_resend": {
2022
+ const task = payload.operation === "finding_send" ? String(req["task"]) : getFinding(store, Number(req["finding"]))?.taskId ?? null;
2023
+ const sent = payload.operation === "finding_resend" ? resendFinding(store, Number(req["finding"]), now)
2024
+ : req["pointer"] !== undefined ? sendReviewFinding(store, task, String(req["pointer"]), actor, now)
2025
+ : req["run"] !== undefined ? sendCheckFinding(store, root ?? "", task, Number(req["run"]), actor, now)
2026
+ // The lead drafted these words and the person confirmed them: recorded as the lead's, sent by the person.
2027
+ : sendFinding(store, { taskId: task, source: "lead", by: actor, summary: String(req["summary"]), file: typeof req["file"] === "string" ? req["file"] : null, line: typeof req["line"] === "number" ? req["line"] : null }, now);
2028
+ if (!sent.ok)
2029
+ throw Error(sent.message);
2030
+ const said = sent.standing === "with-person" ? `Finding #${sent.id} needs a person's decision on the task.` : sent.standing === "already-sent" ? `Finding #${sent.id} was already sent.`
2031
+ : sent.standing === "running" ? `Finding #${sent.id} sent to the running build.` : `Finding #${sent.id} goes to the next attempt.`;
2032
+ return done(said, task === null ? undefined : `/t/${encodeURIComponent(task)}#findings`, task);
2033
+ }
2034
+ case "mode_revoke":
2035
+ if (!store.revokeMode(repo, actor, "operator", now))
2036
+ throw Error("That mode already ended.");
2037
+ return done("Automatic approvals are off. Every act goes back to its own approval.", "/mode");
2038
+ case "flow_unattended_off":
2039
+ if (!revokeUnattended(store, Number(req["flow"]), actor, now))
2040
+ throw Error("It wasn't running on its own.");
2041
+ return done("It no longer runs on its own. New cards wait for a person.", `/flows/${Number(req["flow"])}`);
2042
+ case "flow_archive":
2043
+ store.archiveFlow(Number(req["flow"]), actor, now);
2044
+ return done("Flow archived.", "/flows");
2045
+ case "task_archive":
2046
+ case "task_restore": {
2047
+ const task = String(req["task"]);
2048
+ const standing = (refs) => approverStandsNow(store, actor, familyRepoOf(store, refs));
2049
+ const made = payload.operation === "task_archive"
2050
+ ? archiveTaskFamily(store, task, { by: actor, byKind: "person", rule: null, reason: String(req["reason"] ?? "").trim() || "Put away from chat" }, now, { guard: standing })
2051
+ : restoreTaskFamily(store, task, actor, now, standing);
2052
+ if (!made.ok)
2053
+ throw Error(made.message);
2054
+ return done(payload.operation === "task_archive" ? "Put away." : "Restored.", undefined, task);
2055
+ }
2056
+ case "incident_resolve":
2057
+ if (!store.resolveIncident(Number(req["incident"]), actor, now))
2058
+ throw Error("That incident was already resolved.");
2059
+ return done("Incident resolved.");
2060
+ case "memory_decide": {
2061
+ const decision = req["decision"] === "accept" ? "accept" : "reject";
2062
+ decideProposal(store, { repo, actor, id: Number(req["proposal"]), decision }, now);
2063
+ return done(decision === "accept" ? "Added to project memory." : "Rejected.", `/settings/knowledge?repo=${encodeURIComponent(repo)}`);
2064
+ }
2065
+ case "provider_resume": {
2066
+ const lifted = liftAuthPause(store, String(req["provider"]), "person", actor, now);
2067
+ if (lifted === null)
2068
+ throw Error("That agent isn't paused any more.");
2069
+ return done(`Resumed. ${lifted.resumed} ${lifted.resumed === 1 ? "task went" : "tasks went"} back to the queue.`);
2070
+ }
2071
+ case "worker_note": {
2072
+ const note = String(req["note"]);
2073
+ if (!store.setRunnerQueueNote(String(req["runner"]), note === "" ? null : note).ok)
2074
+ throw Error("No such worker.");
2075
+ return done("Note saved.", "/fleet");
2076
+ }
2077
+ case "lead_identity":
2078
+ store.setLeadConfig(actor, String(req["name"]), String(req["persona"]), now);
2079
+ return done(`Your lead is called ${String(req["name"])} now.`, "/settings/lead");
2080
+ default:
2081
+ throw Error("Choose an available action.");
2082
+ }
2083
+ }