toolroll 0.9.34 → 0.9.37

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 (195) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +7 -0
  3. package/dist/assignment-adapters.d.ts +12 -48
  4. package/dist/assignment-adapters.js +33 -43
  5. package/dist/assignment.d.ts +1 -0
  6. package/dist/assignment.js +2 -2
  7. package/dist/browser-workspace.d.ts +3 -3
  8. package/dist/build-review.d.ts +1 -1
  9. package/dist/build-review.js +2 -1
  10. package/dist/builder.js +4 -7
  11. package/dist/chat-acceptance.d.ts +2 -2
  12. package/dist/chat-actions.d.ts +18 -12
  13. package/dist/chat-actions.js +15 -65
  14. package/dist/chat-ask.js +5 -5
  15. package/dist/chat-decide.d.ts +14 -1
  16. package/dist/chat-decide.js +12 -6
  17. package/dist/chat-delivery-state.d.ts +12 -70
  18. package/dist/chat-delivery-state.js +17 -135
  19. package/dist/chat-delivery.js +58 -30
  20. package/dist/chat-flow.js +8 -6
  21. package/dist/chat-inbox.js +1 -1
  22. package/dist/coding-context.d.ts +2 -39
  23. package/dist/coding-context.js +2 -1
  24. package/dist/coding-handoff.d.ts +6 -25
  25. package/dist/coding-handoff.js +11 -5
  26. package/dist/coding-types.d.ts +2 -26
  27. package/dist/coding-workspace.d.ts +2 -0
  28. package/dist/coding-workspace.js +50 -14
  29. package/dist/contracts/acceptance-terms.d.ts +14 -0
  30. package/dist/contracts/acceptance-terms.js +16 -0
  31. package/dist/contracts/chat-actions.d.ts +328 -0
  32. package/dist/contracts/chat-actions.js +133 -0
  33. package/dist/contracts/chat-callback-rows.d.ts +59 -0
  34. package/dist/contracts/chat-callback-rows.js +46 -0
  35. package/dist/contracts/chat-content.d.ts +276 -0
  36. package/dist/contracts/chat-content.js +127 -0
  37. package/dist/contracts/chat-tables.d.ts +6 -0
  38. package/dist/contracts/chat-tables.js +136 -0
  39. package/dist/contracts/coding-context.d.ts +97 -0
  40. package/dist/contracts/coding-context.js +60 -0
  41. package/dist/contracts/coding-handoff.d.ts +44 -0
  42. package/dist/contracts/coding-handoff.js +47 -0
  43. package/dist/contracts/coding-workspace.d.ts +165 -0
  44. package/dist/contracts/coding-workspace.js +71 -0
  45. package/dist/contracts/contract.d.ts +40 -7
  46. package/dist/contracts/contract.js +155 -19
  47. package/dist/contracts/decision.d.ts +45 -0
  48. package/dist/contracts/decision.js +45 -0
  49. package/dist/contracts/discord-callback.d.ts +45 -0
  50. package/dist/contracts/discord-callback.js +34 -0
  51. package/dist/contracts/flow-card.d.ts +50 -0
  52. package/dist/contracts/flow-card.js +46 -0
  53. package/dist/contracts/flow-propose.d.ts +325 -0
  54. package/dist/contracts/flow-propose.js +41 -0
  55. package/dist/contracts/flow-send.d.ts +119 -0
  56. package/dist/contracts/flow-send.js +112 -0
  57. package/dist/contracts/flow.d.ts +19405 -0
  58. package/dist/contracts/flow.js +364 -0
  59. package/dist/contracts/gateway-tools.d.ts +374 -0
  60. package/dist/contracts/gateway-tools.js +94 -0
  61. package/dist/contracts/handoff.d.ts +91 -0
  62. package/dist/contracts/handoff.js +82 -0
  63. package/dist/contracts/lead-context.d.ts +165 -0
  64. package/dist/contracts/lead-context.js +73 -0
  65. package/dist/contracts/lead-tools.d.ts +1905 -0
  66. package/dist/contracts/lead-tools.js +265 -0
  67. package/dist/contracts/memory-pass.d.ts +158 -0
  68. package/dist/contracts/memory-pass.js +102 -0
  69. package/dist/contracts/project-knowledge.d.ts +126 -0
  70. package/dist/contracts/project-knowledge.js +93 -0
  71. package/dist/contracts/project-memory.d.ts +117 -0
  72. package/dist/contracts/project-memory.js +51 -0
  73. package/dist/contracts/project-skills.d.ts +121 -0
  74. package/dist/contracts/project-skills.js +76 -0
  75. package/dist/contracts/proof.d.ts +136 -0
  76. package/dist/contracts/proof.js +110 -0
  77. package/dist/contracts/review-findings.d.ts +46 -0
  78. package/dist/contracts/review-findings.js +48 -0
  79. package/dist/contracts/route.d.ts +484 -0
  80. package/dist/contracts/route.js +128 -0
  81. package/dist/contracts/scope.d.ts +105 -0
  82. package/dist/contracts/scope.js +114 -0
  83. package/dist/contracts/scout-report.d.ts +234 -0
  84. package/dist/contracts/scout-report.js +141 -0
  85. package/dist/contracts/slack-callback.d.ts +43 -0
  86. package/dist/contracts/slack-callback.js +35 -0
  87. package/dist/contracts/sort-answer.d.ts +83 -0
  88. package/dist/contracts/sort-answer.js +77 -0
  89. package/dist/contracts/stage-output.d.ts +127 -0
  90. package/dist/contracts/stage-output.js +166 -0
  91. package/dist/contracts/task-sizing.d.ts +24 -0
  92. package/dist/contracts/task-sizing.js +28 -0
  93. package/dist/contracts/teammate-turn.d.ts +43 -0
  94. package/dist/contracts/teammate-turn.js +51 -0
  95. package/dist/contracts/teams-callback.d.ts +14 -0
  96. package/dist/contracts/teams-callback.js +14 -0
  97. package/dist/contracts/telegram-callback.d.ts +120 -0
  98. package/dist/contracts/telegram-callback.js +97 -0
  99. package/dist/contracts/verification-receipt.d.ts +148 -0
  100. package/dist/contracts/verification-receipt.js +87 -0
  101. package/dist/decision.d.ts +11 -20
  102. package/dist/decision.js +86 -167
  103. package/dist/demo.js +28 -11
  104. package/dist/discord-chat.js +31 -20
  105. package/dist/evidence.d.ts +3 -40
  106. package/dist/evidence.js +5 -1
  107. package/dist/flow-engine.js +14 -17
  108. package/dist/flow-gallery.d.ts +3 -2
  109. package/dist/flow-items.d.ts +4 -13
  110. package/dist/flow-items.js +1 -15
  111. package/dist/flow-send.d.ts +6 -37
  112. package/dist/flow-send.js +18 -14
  113. package/dist/flow-share.d.ts +10 -32
  114. package/dist/flow-share.js +72 -84
  115. package/dist/flow-sort.d.ts +7 -26
  116. package/dist/flow-sort.js +25 -20
  117. package/dist/flow-starters.d.ts +2 -1
  118. package/dist/flow-steps.js +3 -2
  119. package/dist/flow-triggers.d.ts +15 -72
  120. package/dist/flow-triggers.js +94 -83
  121. package/dist/flows.d.ts +37 -220
  122. package/dist/flows.js +356 -387
  123. package/dist/invoke.js +4 -4
  124. package/dist/lead-context.d.ts +4 -2
  125. package/dist/lead-context.js +24 -17
  126. package/dist/mate-tools.d.ts +4 -0
  127. package/dist/mate-tools.js +111 -308
  128. package/dist/mcp-connect.d.ts +27 -4
  129. package/dist/mcp-connect.js +92 -17
  130. package/dist/mcp.js +84 -216
  131. package/dist/memory-pass.d.ts +17 -42
  132. package/dist/memory-pass.js +54 -26
  133. package/dist/operate.js +31 -2
  134. package/dist/phase-routing.d.ts +19 -72
  135. package/dist/phase-routing.js +34 -164
  136. package/dist/plan.d.ts +5 -5
  137. package/dist/plan.js +5 -25
  138. package/dist/process-recovery-eligibility.d.ts +2 -2
  139. package/dist/process-recovery-eligibility.js +13 -8
  140. package/dist/project-knowledge.d.ts +16 -35
  141. package/dist/project-knowledge.js +42 -21
  142. package/dist/project-memory.d.ts +2 -28
  143. package/dist/project-memory.js +28 -13
  144. package/dist/project-skills.d.ts +16 -38
  145. package/dist/project-skills.js +41 -32
  146. package/dist/project-tools.d.ts +1 -0
  147. package/dist/project-tools.js +4 -4
  148. package/dist/proof.d.ts +15 -91
  149. package/dist/proof.js +90 -249
  150. package/dist/provider.d.ts +2 -0
  151. package/dist/provider.js +4 -16
  152. package/dist/result-evidence-readers.d.ts +42 -0
  153. package/dist/result-evidence-readers.js +91 -0
  154. package/dist/result-shots.d.ts +1 -1
  155. package/dist/result-shots.js +2 -1
  156. package/dist/review-switch.d.ts +3 -8
  157. package/dist/reviewer.d.ts +8 -12
  158. package/dist/reviewer.js +11 -35
  159. package/dist/scope.d.ts +21 -43
  160. package/dist/scope.js +42 -128
  161. package/dist/scout-report.d.ts +9 -194
  162. package/dist/scout-report.js +113 -264
  163. package/dist/scout.js +15 -11
  164. package/dist/serve.js +17 -101
  165. package/dist/slack-chat.js +26 -20
  166. package/dist/slack-state.js +2 -1
  167. package/dist/store.d.ts +9 -32
  168. package/dist/store.js +18 -15
  169. package/dist/structured-output.js +2 -1
  170. package/dist/subscription-chat.d.ts +4 -0
  171. package/dist/subscription-chat.js +4 -1
  172. package/dist/surface.js +8 -1
  173. package/dist/task-review-brief.d.ts +69 -0
  174. package/dist/task-review-brief.js +179 -0
  175. package/dist/task-sizing.d.ts +3 -27
  176. package/dist/task-sizing.js +11 -26
  177. package/dist/task-text.d.ts +0 -5
  178. package/dist/task-text.js +1 -3
  179. package/dist/teammate-question.js +7 -5
  180. package/dist/teammate-work.js +9 -5
  181. package/dist/teammates.d.ts +13 -64
  182. package/dist/teammates.js +32 -33
  183. package/dist/teams-chat.js +20 -11
  184. package/dist/telegram-flow.js +4 -3
  185. package/dist/telegram-mate.d.ts +4 -14
  186. package/dist/telegram-mate.js +8 -7
  187. package/dist/telegram-team.d.ts +2 -19
  188. package/dist/telegram.d.ts +1 -1
  189. package/dist/telegram.js +52 -42
  190. package/dist/text-limits.d.ts +212 -1
  191. package/dist/text-limits.js +214 -1
  192. package/dist/tools-ui.d.ts +2 -1
  193. package/dist/tools-ui.js +7 -4
  194. package/dist/verification-evidence.js +24 -12
  195. package/package.json +1 -1
package/dist/demo.js CHANGED
@@ -38,6 +38,7 @@ import { fileTaskProposal, fileRoutineProposal } from "./proposal.js";
38
38
  import { storeEvidence, budgetedStatJson, imageDimensions } from "./evidence.js";
39
39
  import { parseProof, adjudicate } from "./proof.js";
40
40
  import { sealVerificationReceipt } from "./verification-evidence.js";
41
+ import { HANDOFF_VERSION, readHandoffArtifact } from "./contracts/handoff.js";
41
42
  import { parseExecutionPlanDocument, milestonesOf } from "./plan.js";
42
43
  import { maybeTriggerRepair } from "./dispose.js";
43
44
  import { deflateSync } from "node:zlib";
@@ -84,8 +85,21 @@ index 11aa0b2..c44d1f7 100644
84
85
  + expect(settle(1005, 0.031)).toBe(31.16);
85
86
  + });
86
87
  `;
88
+ /** A demo run's handoff artifact: the run's own identity around the story's conclusion and lists, written through
89
+ * the handoff schema like a real one. */
90
+ function demoHandoffBytes(store, runId, told) {
91
+ const run = store.getRun(runId);
92
+ const head = run.headRevision ?? run.baseRevision ?? "0".repeat(40);
93
+ const read = readHandoffArtifact({
94
+ version: HANDOFF_VERSION, taskId: store.externalIdFor(run.taskRef) ?? String(run.taskRef), runId, provider: run.provider,
95
+ sessionId: null, branch: run.branch ?? "", worktree: run.worktree ?? "", base: run.baseRevision ?? head, head, ...told,
96
+ freshness: { stampedAt: run.startedAt, currentAsOf: head },
97
+ });
98
+ if (!read.ok)
99
+ throw new Error(`demo handoff: ${read.issues.map(issue => issue.line).join("; ")}`);
100
+ return Buffer.from(JSON.stringify(read.value, null, 2), "utf8");
101
+ }
87
102
  const DEMO_HANDOFF = {
88
- schema: 1,
89
103
  outcome: "built",
90
104
  committed: true,
91
105
  conclusion: "Fixed the payout rounding drift: settle() now rounds at cent precision instead of accumulating half-cent errors. Added boundary tests against the ledger fixtures. All 214 tests pass.",
@@ -123,7 +137,6 @@ index 2b3c4d5..8e9f0a1 100644
123
137
  };
124
138
  `;
125
139
  const DEMO_FAILED_HANDOFF = {
126
- schema: 1,
127
140
  outcome: "built",
128
141
  committed: true,
129
142
  conclusion: "Removed LEGACY_PAYOUT from settlement and config. The admin override still reads it, so one reference remains.",
@@ -193,7 +206,6 @@ index a1b2c3d..d4e5f6a 100644
193
206
  +export const EMPTY_STATE_ILLUSTRATION = "quiet-inbox";
194
207
  `;
195
208
  const DEMO_COPY_HANDOFF = {
196
- schema: 1,
197
209
  outcome: "built",
198
210
  committed: true,
199
211
  conclusion: "Rewrote the inbox's empty-state copy so it explains why the list is empty instead of just saying so.",
@@ -605,7 +617,7 @@ export function seedDemo(store, repos, evidenceRoot, now) {
605
617
  filesTruncated: false,
606
618
  };
607
619
  storeEvidence(store, evidenceRoot, doneRun, "diff-stat", "diff-stat.json", budgetedStatJson(stat), "parsed from git diff --numstat -z [demo: synthetic]", hoursAgo(8.5));
608
- storeEvidence(store, evidenceRoot, doneRun, "handoff", "handoff.json", Buffer.from(JSON.stringify(DEMO_HANDOFF, null, 2), "utf8"), "composed at completion [demo: synthetic]", hoursAgo(8.4));
620
+ storeEvidence(store, evidenceRoot, doneRun, "handoff", "handoff.json", demoHandoffBytes(store, doneRun, DEMO_HANDOFF), "composed at completion [demo: synthetic]", hoursAgo(8.4));
609
621
  // The evidence bundle (Priority 2): a validated proof, its claimed
610
622
  // screenshot stored as immutable image evidence, the plane's own re-run
611
623
  // check, and the closed verdict — computed once, exactly as the real
@@ -669,7 +681,7 @@ export function seedDemo(store, repos, evidenceRoot, now) {
669
681
  filesTruncated: false,
670
682
  };
671
683
  storeEvidence(store, evidenceRoot, failedRun, "diff-stat", "diff-stat.json", budgetedStatJson(failedStat), "parsed from git diff --numstat -z [demo: synthetic]", hoursAgo(5.6));
672
- storeEvidence(store, evidenceRoot, failedRun, "handoff", "handoff.json", Buffer.from(JSON.stringify(DEMO_FAILED_HANDOFF, null, 2), "utf8"), "composed at completion [demo: synthetic]", hoursAgo(5.6));
684
+ storeEvidence(store, evidenceRoot, failedRun, "handoff", "handoff.json", demoHandoffBytes(store, failedRun, DEMO_FAILED_HANDOFF), "composed at completion [demo: synthetic]", hoursAgo(5.6));
673
685
  storeEvidence(store, evidenceRoot, failedRun, "proof", "proof.json", Buffer.from(JSON.stringify(DEMO_FAILED_PROOF, null, 2), "utf8"), "agent-authored proof (validated, re-serialized) [demo: synthetic]", hoursAgo(5.6));
674
686
  const failedAdjudicated = adjudicate({
675
687
  proofArtifactPresent: true,
@@ -734,7 +746,7 @@ export function seedDemo(store, repos, evidenceRoot, now) {
734
746
  filesTruncated: false,
735
747
  };
736
748
  storeEvidence(store, evidenceRoot, copyReviewRun, "diff-stat", "diff-stat.json", budgetedStatJson(copyStat), "parsed from git diff --numstat -z [demo: synthetic]", hoursAgo(3.6));
737
- storeEvidence(store, evidenceRoot, copyReviewRun, "handoff", "handoff.json", Buffer.from(JSON.stringify(DEMO_COPY_HANDOFF, null, 2), "utf8"), "composed at completion [demo: synthetic]", hoursAgo(3.5));
749
+ storeEvidence(store, evidenceRoot, copyReviewRun, "handoff", "handoff.json", demoHandoffBytes(store, copyReviewRun, DEMO_COPY_HANDOFF), "composed at completion [demo: synthetic]", hoursAgo(3.5));
738
750
  storeEvidence(store, evidenceRoot, copyReviewRun, "proof", "proof.json", Buffer.from(JSON.stringify(DEMO_COPY_PROOF, null, 2), "utf8"), "agent-authored proof (validated, re-serialized) [demo: synthetic]", hoursAgo(3.5));
739
751
  const copyReviewProofParse = parseProof(JSON.stringify(DEMO_COPY_PROOF));
740
752
  const copyReviewAdjudicated = adjudicate({
@@ -815,10 +827,15 @@ export function seedDemo(store, repos, evidenceRoot, now) {
815
827
  };
816
828
  storeEvidence(store, evidenceRoot, reviewedRun, "diff-stat", "diff-stat.json", budgetedStatJson(reviewedStat), "parsed from git diff --numstat -z [demo: synthetic]", hoursAgo(4.6));
817
829
  const DEMO_REPAIR_HANDOFF = {
818
- summary: "Added a TODO comment marking the per-account lock — ran out of turns before wiring the actual guard.",
819
- filesTouched: ["src/payout-limiter.ts"],
830
+ outcome: "built",
831
+ committed: true,
832
+ conclusion: "Added a TODO comment marking the per-account lock — ran out of turns before wiring the actual guard.",
833
+ changes: ["Marked the per-account lock in src/payout-limiter.ts."],
834
+ verification: [],
835
+ followUps: [],
836
+ decisionsIncorporated: [],
820
837
  };
821
- storeEvidence(store, evidenceRoot, reviewedRun, "handoff", "handoff.json", Buffer.from(JSON.stringify(DEMO_REPAIR_HANDOFF, null, 2), "utf8"), "composed at completion [demo: synthetic]", hoursAgo(4.5));
838
+ storeEvidence(store, evidenceRoot, reviewedRun, "handoff", "handoff.json", demoHandoffBytes(store, reviewedRun, DEMO_REPAIR_HANDOFF), "composed at completion [demo: synthetic]", hoursAgo(4.5));
822
839
  const DEMO_REPAIR_PROOF = {
823
840
  version: 1,
824
841
  criteria: [
@@ -1389,7 +1406,7 @@ export function createDemoLead(input) {
1389
1406
  };
1390
1407
  const shotPath = `evidence/${plan.kind}.png`;
1391
1408
  const png = demoScreenshot(plan.screenshot.accent);
1392
- const handoff = { schema: 1, outcome: "built", committed: true, conclusion: plan.conclusion, changes: diffs.map(one => `Changed ${one.path}.`), verification: [plan.checks[0]], followUps: [], decisionsIncorporated: [] };
1409
+ const handoff = { outcome: "built", committed: true, conclusion: plan.conclusion, changes: diffs.map(one => `Changed ${one.path}.`), verification: [plan.checks[0]], followUps: [], decisionsIncorporated: [] };
1393
1410
  const proof = {
1394
1411
  version: 1,
1395
1412
  criteria: [
@@ -1404,7 +1421,7 @@ export function createDemoLead(input) {
1404
1421
  const synthetic = "[demo: scripted]";
1405
1422
  storeEvidence(store, evidenceRoot, run, "terminal-diff", "terminal-diff.patch", Buffer.from(diffs.map(one => one.patch).join(""), "utf8"), `git diff --no-ext-diff --no-textconv --no-color ${base.slice(0, 8)}..HEAD (exit 0) ${synthetic}`, at);
1406
1423
  storeEvidence(store, evidenceRoot, run, "diff-stat", "diff-stat.json", budgetedStatJson(stat), `parsed from git diff --numstat -z ${synthetic}`, at);
1407
- storeEvidence(store, evidenceRoot, run, "handoff", "handoff.json", Buffer.from(JSON.stringify(handoff, null, 2), "utf8"), `composed at completion ${synthetic}`, at);
1424
+ storeEvidence(store, evidenceRoot, run, "handoff", "handoff.json", demoHandoffBytes(store, run, handoff), `composed at completion ${synthetic}`, at);
1408
1425
  storeEvidence(store, evidenceRoot, run, "proof", "proof.json", Buffer.from(JSON.stringify(proof, null, 2), "utf8"), `agent-authored proof (validated, re-serialized) ${synthetic}`, at);
1409
1426
  storeEvidence(store, evidenceRoot, run, "screenshot", `screenshot-${plan.kind}.png`, png, `agent-claimed screenshot at ${shotPath} (validated png) ${synthetic}`, at);
1410
1427
  const log = `=== Attempt summary ===\n- Project check · attempt 1: (exit 0)\n\n=== Project check · attempt 1 ===\n$ ${DEMO_CHECK_COMMAND}\n(exit 0)\n\n--- stdout ---\n${plan.checkOutput}\n--- stderr ---\n`;
@@ -5,7 +5,9 @@ import { chatFlowButtons } from "./chat-flow.js";
5
5
  import { refuseResultShots, resultShotsPruned, shotWaitsUntil } from "./result-shots.js";
6
6
  import { channelInbox } from "./chat-inbox.js";
7
7
  import { roomCommand } from "./chat-rooms.js";
8
- import { ChatState, ChatDeliveryError, chatHash, } from "./chat-delivery-state.js";
8
+ import { ChatState, ChatDeliveryError, chatHash, partContent, } from "./chat-delivery-state.js";
9
+ import { readChatPart } from "./contracts/chat-content.js";
10
+ import { readDiscordButtonToken, readDiscordComponentInteraction } from "./contracts/discord-callback.js";
9
11
  import { processChatEvent, planChatNotifications, channelAccess, chatObject as object, planRoomMessages, } from "./chat-delivery.js";
10
12
  import { chatResultHref } from "./chat-controls.js";
11
13
  import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
@@ -69,21 +71,25 @@ export function receiveDiscord(state, identity, type, raw, now) {
69
71
  };
70
72
  }
71
73
  else if (type === "INTERACTION_CREATE") {
72
- const data = object(body.data), message = object(body.message);
73
- if (body.type !== 3 ||
74
- body.application_id !== identity.app ||
75
- (isRoom ? object(body.channel).type !== 0 : object(body.channel).type !== 1) ||
76
- object(message.author).id !== identity.bot ||
77
- message.channel_id !== body.channel_id ||
78
- !/^so_[a-f0-9]{32}$/.test(String(data.custom_id)))
74
+ // Only a tap on one of this bot's own messages, in a DM (or a followed guild text channel), from this app.
75
+ const interaction = readDiscordComponentInteraction(body);
76
+ if (!interaction.ok)
79
77
  return false;
80
- member = isRoom ? object(object(body.member).user).id : object(body.user).id;
81
- channel = body.channel_id;
82
- id = body.id;
83
- ts = message.id;
84
- thread = message.id;
78
+ const tap = interaction.value;
79
+ if (tap.application_id !== identity.app ||
80
+ tap.channel.type !== (isRoom ? 0 : 1) ||
81
+ tap.message.author.id !== identity.bot ||
82
+ tap.message.channel_id !== tap.channel_id)
83
+ return false;
84
+ member = isRoom ? tap.member?.user.id : tap.user?.id;
85
+ channel = tap.channel_id;
86
+ id = tap.id;
87
+ ts = tap.message.id;
88
+ thread = tap.message.id;
85
89
  kind = "action";
86
- payload = { token: String(data.custom_id).slice(3) };
90
+ // A button that isn't one Toolroll made is answered with why, once the tap is proved to be the paired person's below.
91
+ const button = readDiscordButtonToken(tap);
92
+ payload = button.ok ? { token: button.value } : { problem: button.issues.map(issue => issue.line).join("; ") };
87
93
  }
88
94
  else
89
95
  return false;
@@ -94,9 +100,10 @@ export function receiveDiscord(state, identity, type, raw, now) {
94
100
  !discordId(thread) ||
95
101
  member === identity.bot)
96
102
  return false;
103
+ const words = "text" in payload ? payload.text ?? "" : "";
97
104
  // v89: a channel that feeds a flow takes anyone's message (as a card, with no say over anything); "flow 12" connects one.
98
- const inbox = isRoom && kind === "message" ? channelInbox(state.store, "discord", identity.installation, String(channel), String(payload.text ?? "")) : { watched: false, command: false };
99
- const roomish = isRoom && (state.room(identity.installation, String(channel)) !== null || (kind === "message" && roomCommand(String(payload.text ?? "")) !== null) || inbox.watched || inbox.command);
105
+ const inbox = isRoom && kind === "message" ? channelInbox(state.store, "discord", identity.installation, String(channel), words) : { watched: false, command: false };
106
+ const roomish = isRoom && (state.room(identity.installation, String(channel)) !== null || (kind === "message" && roomCommand(words) !== null) || inbox.watched || inbox.command);
100
107
  if (isRoom && !roomish)
101
108
  return false;
102
109
  const binding = state.bindingFor(identity.installation, member);
@@ -116,7 +123,7 @@ export function receiveDiscord(state, identity, type, raw, now) {
116
123
  member,
117
124
  ts,
118
125
  thread,
119
- payload: JSON.stringify(payload),
126
+ payload,
120
127
  created: now.toISOString(),
121
128
  });
122
129
  }
@@ -200,7 +207,7 @@ export async function deliverDiscordPart(options) {
200
207
  const session = event.session === null ? null : store.getMateSession(event.session), repos = await channelAccess(shared, binding, session?.ceilingDigest);
201
208
  if (event.kind === "notice" && options.canNotify?.() === false)
202
209
  return false;
203
- const content = JSON.parse(row.payload);
210
+ const content = partContent(row.payload);
204
211
  const destination = content.channel ?? binding.channel;
205
212
  if (content.task &&
206
213
  !repos.includes(store.lookupRef(content.task)?.repo ?? ""))
@@ -398,9 +405,13 @@ export async function deliverDiscordPart(options) {
398
405
  return true;
399
406
  }
400
407
  catch (error) {
408
+ if (error instanceof ChatDeliveryError && error.permanent) {
409
+ state.prepare("UPDATE chat_part SET state='dropped',next_at=NULL,problem=? WHERE id=?").run(error.message, row.id);
410
+ return true;
411
+ }
401
412
  // No permission to attach files here: one plain line instead of the result's screenshots.
402
- const content = JSON.parse(row.payload);
403
- if (content.image && content.shot && error instanceof DiscordError && error.code === DISCORD_REFUSED) {
413
+ const saved = readChatPart(row.payload), content = saved.ok ? saved.value : null;
414
+ if (content !== null && content.image && content.shot && error instanceof DiscordError && error.code === DISCORD_REFUSED) {
404
415
  refuseResultShots(state, store, row, content, "Discord", options.clock?.() ?? new Date());
405
416
  return true;
406
417
  }
@@ -15,6 +15,8 @@
15
15
  */
16
16
  import { type ParsedReport, type ReportImage } from "./scout-report.js";
17
17
  import { type ParsedProof } from "./proof.js";
18
+ import { type HandoffArtifact } from "./contracts/handoff.js";
19
+ export type { HandoffArtifact } from "./contracts/handoff.js";
18
20
  import type { Artifact, Store } from "./store.js";
19
21
  import type { ExecResult } from "./exec.js";
20
22
  /** The park mailbox pattern. Each run's actual name carries a nonce; see `mailboxName`. */
@@ -254,45 +256,6 @@ export type DiffStat = {
254
256
  * recorded as null — never coerced to zero.
255
257
  */
256
258
  export declare function parseNumstat(raw: string, base: string, head: string): DiffStat;
257
- /** The freshness-stamped handoff artifact's shape (M6.10). */
258
- export type HandoffArtifact = {
259
- schema: 1;
260
- taskId: string;
261
- runId: number;
262
- provider: string;
263
- /** v47: the exact model that ran, and the sealed route leg it ran as —
264
- * provenance a later reader can hold against the approval. Absent on
265
- * artifacts written before routes existed. */
266
- model?: string;
267
- route?: {
268
- digest: string;
269
- phase: "plan" | "build" | "repair" | "review";
270
- provider: string;
271
- model: string | null;
272
- chosen: "recommended" | "override" | "pinned" | "legacy" | "fallback";
273
- };
274
- sessionId: string | null;
275
- branch: string;
276
- worktree: string;
277
- base: string;
278
- head: string;
279
- outcome: "built" | "no-change";
280
- committed: boolean;
281
- /** Decision ids whose answers were in this run's brief — causality, not time. */
282
- decisionsIncorporated: number[];
283
- /** The agent's conclusion — agent-reported, and labeled so by its position here. */
284
- conclusion: string;
285
- /** Structured operator-facing output. Optional for artifacts written by
286
- * older versions; readers treat absence as an empty list. */
287
- changes?: string[];
288
- verification?: string[];
289
- followUps?: string[];
290
- freshness: {
291
- stampedAt: string;
292
- /** A successor proves this against the branch before trusting anything above. */
293
- currentAsOf: string;
294
- };
295
- };
296
259
  /**
297
260
  * Write the handoff artifact (M6.10): the machine's own statement of where
298
261
  * a finished run left the world — workspace identity, exact base and head,
@@ -301,7 +264,7 @@ export type HandoffArtifact = {
301
264
  * context. This is what makes a cold takeover context-bearing rather than
302
265
  * repo-only; a provider session is memory, not a freshness proof.
303
266
  */
304
- export declare function storeHandoffArtifact(store: Store, root: string, payload: HandoffArtifact, now: Date): number;
267
+ export declare function storeHandoffArtifact(store: Store, root: string, payload: Omit<HandoffArtifact, "version">, now: Date): number;
305
268
  /**
306
269
  * High-confidence secret shapes ONLY (audit IV-7): a detector that cries
307
270
  * wolf trains people to approve wolves. Each pattern is a format no
package/dist/evidence.js CHANGED
@@ -21,6 +21,7 @@ import { LIMITS } from "./decision.js";
21
21
  import { TEXT_LIMITS } from "./text-limits.js";
22
22
  import { parseReport, REPORT_LIMITS } from "./scout-report.js";
23
23
  import { parseProof, PROOF_LIMITS } from "./proof.js";
24
+ import { HANDOFF_VERSION, readHandoffArtifact } from "./contracts/handoff.js";
24
25
  import { encodeBaseTreeSnapshot, parseBaseTreeSnapshot } from "./peek.js";
25
26
  /** The park mailbox pattern. Each run's actual name carries a nonce; see `mailboxName`. */
26
27
  export const MAILBOX_PREFIX = "STANDING-ORDERS-PARK-";
@@ -618,7 +619,10 @@ export function parseNumstat(raw, base, head) {
618
619
  * repo-only; a provider session is memory, not a freshness proof.
619
620
  */
620
621
  export function storeHandoffArtifact(store, root, payload, now) {
621
- return storeEvidence(store, root, payload.runId, "handoff", "handoff.json", Buffer.from(JSON.stringify(payload, null, 2), "utf8"), "machine-authored handoff (exit 0)", now);
622
+ const read = readHandoffArtifact({ version: HANDOFF_VERSION, ...payload });
623
+ if (!read.ok)
624
+ throw new Error(`The handoff does not match its contract: ${read.issues.map(issue => issue.line).join("; ")}`);
625
+ return storeEvidence(store, root, payload.runId, "handoff", "handoff.json", Buffer.from(JSON.stringify(read.value, null, 2), "utf8"), "machine-authored handoff (exit 0)", now);
622
626
  }
623
627
  /**
624
628
  * High-confidence secret shapes ONLY (audit IV-7): a detector that cries
@@ -19,6 +19,7 @@ import { cardFollowers, notifyPeople } from "./flow-people.js";
19
19
  import { DRAFT_CHARS, keptDraft } from "./flow-draft.js";
20
20
  import { chooseStep, flowPersonOf, sendStep } from "./flow-send.js";
21
21
  import { parseSoul, teammateLabel } from "./teammates.js";
22
+ import { withStageHandoff } from "./contracts/stage-output.js";
22
23
  /** The flow's drawing, or null when it can't be read (the card then says so and waits). */
23
24
  export function flowDefinitionOf(flow) {
24
25
  try {
@@ -302,7 +303,7 @@ function workStage(store, flow, definition, stage, card, now, options, outcome,
302
303
  const checks = assignmentOf(store, current, now, { principal: "operator", repos: [repo] }, options.evidenceRoot)?.receipt?.checks ?? null;
303
304
  if (checks?.status === "failed") {
304
305
  const said = `The checks failed on its result: ${checks.detail}`;
305
- store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: `${said} (task ${current})` }, primaryTask: current }, now);
306
+ store.updateFlowCard(card.id, { outputs: withStageHandoff(card.outputs, stage.id, { text: `${said} (task ${current})` }), primaryTask: current }, now);
306
307
  if (stage.onFail !== null)
307
308
  onward("fail", said);
308
309
  else if (card.waiting !== `${said} Look at the result, then move the card on or back.`)
@@ -310,27 +311,21 @@ function workStage(store, flow, definition, stage, card, now, options, outcome,
310
311
  return;
311
312
  }
312
313
  }
313
- const outputs = { ...card.outputs };
314
+ let handoff = { text: `Result ready on task ${current}.` };
314
315
  if (stage.kind === "report") {
315
316
  const ref = store.lookupRef(current);
316
317
  const summary = ref === null ? null : reportSummaryFor(store, options.evidenceRoot, ref.id);
317
- // The summary whole (a report's summary is within TEXT_LIMITS.reportSummary bytes; an older, longer one is linked).
318
- outputs[stage.id] = summary !== null && "summary" in summary ? passOn(summary.summary, TEXT_LIMITS.stageOutput, { label: `task ${current}`, href: null }).text : "The report is ready on its task.";
319
318
  // {{stage.<id>.items}} and {{stage.<id>.report}}: what it found and the whole report, for the zones after it.
320
319
  // A visit's report replaces the last visit's, so nothing stale is left behind.
321
320
  const view = ref === null || options.evidenceRoot === undefined ? null : readVerifiedReport(store, options.evidenceRoot, ref.id);
322
321
  const found = view?.ok === true ? reportFillIns(view.report) : { items: "", report: "" };
323
- for (const [part, text] of Object.entries(found)) {
324
- if (text === "")
325
- delete outputs[`${stage.id}.${part}`];
326
- else
327
- outputs[`${stage.id}.${part}`] = text;
328
- }
329
- }
330
- else {
331
- outputs[stage.id] = `Result ready on task ${current}.`;
322
+ handoff = {
323
+ // The summary whole (a report's summary is within TEXT_LIMITS.reportSummary bytes; an older, longer one is linked).
324
+ text: summary !== null && "summary" in summary ? passOn(summary.summary, TEXT_LIMITS.stageOutput, { label: `task ${current}`, href: null }).text : "The report is ready on its task.",
325
+ ...(found.items === "" ? {} : { items: found.items }), ...(found.report === "" ? {} : { report: found.report }),
326
+ };
332
327
  }
333
- store.updateFlowCard(card.id, { outputs, ...(stage.kind === "task" ? { primaryTask: current } : {}), waiting: null }, now);
328
+ store.updateFlowCard(card.id, { outputs: withStageHandoff(card.outputs, stage.id, handoff), ...(stage.kind === "task" ? { primaryTask: current } : {}), waiting: null }, now);
334
329
  // A "Send to me" or "Person chooses" zone next reads what this task produced: the task goes with the card there.
335
330
  const following = stage.next === null ? undefined : flowDefinitionStage(definition, stage.next);
336
331
  onward("ok", undefined, following?.kind === "send" || following?.kind === "choose" ? current : undefined);
@@ -380,8 +375,10 @@ function goalCuts(store, taskId, goal) {
380
375
  if (seen.has(match[1]))
381
376
  continue;
382
377
  seen.add(match[1]);
383
- const text = fillFlowText(`{{${match[1]}}}`, card);
384
- if (text === "" || goal.includes(text))
378
+ // An output too long to pass on is kept whole on the card (src/contracts/stage-output.ts): the agent gets it whole.
379
+ const whole = match[2] !== undefined && match[3] === undefined ? card.attached[match[2]] : undefined;
380
+ const text = whole ?? fillFlowText(`{{${match[1]}}}`, card);
381
+ if (text === "" || (whole === undefined && goal.includes(text)))
385
382
  continue;
386
383
  cuts.push({ label: flowValueLabel(match[1], id => titleIn(definition, id)), text: text.slice(0, FLOW_ATTACHED_LIMIT) });
387
384
  }
@@ -496,7 +493,7 @@ export function decideFlowCard(store, input, now) {
496
493
  return { ok: false, message: `Keep the draft to ${DRAFT_CHARS.toLocaleString("en-US")} characters; this is ${input.draft.trim().length.toLocaleString("en-US")}.` };
497
494
  }
498
495
  if (draft !== null && typeof input.draft === "string" && input.draft.trim() !== "" && input.draft.trim() !== card.outputs[draft.id]?.trim()) {
499
- store.updateFlowCard(card.id, { outputs: { ...card.outputs, [draft.id]: keptDraft(input.draft) } }, now);
496
+ store.updateFlowCard(card.id, { outputs: withStageHandoff(card.outputs, draft.id, { text: keptDraft(input.draft) }) }, now);
500
497
  store.addFlowComment({ card: card.id, author: input.actor, body: "Edited the draft before approving it.", mentions: [] }, now);
501
498
  }
502
499
  if (stage.next === null) {
@@ -1,4 +1,5 @@
1
1
  import { type FlowDefinition, type FlowStepInput } from "./flows.js";
2
+ import type { TriggerInput } from "./contracts/flow.js";
2
3
  import type { Store } from "./store.js";
3
4
  export declare const GALLERY_GROUPS: readonly [{
4
5
  readonly id: "ship";
@@ -46,7 +47,7 @@ export type GalleryTemplate = {
46
47
  /** The steps in Toolroll's own repository, when they differ. */
47
48
  ownSteps?: FlowStepInput[];
48
49
  definition?: FlowDefinition;
49
- triggers: Record<string, unknown>[];
50
+ triggers: TriggerInput[];
50
51
  scripts?: {
51
52
  name: string;
52
53
  about: string;
@@ -100,7 +101,7 @@ export type GalleryBuilt = {
100
101
  repo: string;
101
102
  answers: GalleryAnswers;
102
103
  definition: FlowDefinition;
103
- triggers: Record<string, unknown>[];
104
+ triggers: TriggerInput[];
104
105
  scripts: {
105
106
  name: string;
106
107
  about: string;
@@ -8,17 +8,10 @@
8
8
  * in its own format. Its words go through the channel's own cleaner first. Fitted to a channel's limit, each why is
9
9
  * shortened evenly first; then the summary, then the titles; past that it is split into parts. Links are never cut.
10
10
  */
11
- export type FlowSendItem = {
12
- title: string;
13
- why: string;
14
- url: string;
15
- /** Where the link goes, by name: "Mobbin", "Linear", "Apple". */
16
- source: string;
17
- /** One line: how it plugs in (from the why, else the image's own caption). */
18
- plug: string;
19
- /** The item's screenshot (its evidence id in the report's run), when it has one. */
20
- shot: number | null;
21
- };
11
+ import type { FlowSendItem } from "./contracts/flow-send.js";
12
+ /** One item as the message lists it, and kept items read back (src/contracts/flow-send.ts). */
13
+ export type { FlowSendItem };
14
+ export { readFlowItems } from "./contracts/flow-send.js";
22
15
  /** A link's source by name: a few known sites as they write themselves, else the site's own name from its address. */
23
16
  export declare function itemSource(url: string): string;
24
17
  /** A why as plain lines: its own lines, or "the pattern; why it fits us; how it plugs in" one to a line. */
@@ -42,5 +35,3 @@ export declare function cleanFlowMessage(message: FlowMessage, clean: (text: str
42
35
  * blocks (else between lines). Every link stays.
43
36
  */
44
37
  export declare function fitFlowMessage(message: FlowMessage, limit: number, measure: (shaped: string) => number): string[];
45
- /** Items read back from a kept content; anything malformed is left out. */
46
- export declare function readFlowItems(raw: unknown): FlowSendItem[];
@@ -8,6 +8,7 @@
8
8
  * in its own format. Its words go through the channel's own cleaner first. Fitted to a channel's limit, each why is
9
9
  * shortened evenly first; then the summary, then the titles; past that it is split into parts. Links are never cut.
10
10
  */
11
+ export { readFlowItems } from "./contracts/flow-send.js";
11
12
  const PLUG_CHARS = 160;
12
13
  const NAMES = {
13
14
  "mobbin.com": "Mobbin", "github.com": "GitHub", "figma.com": "Figma", "dribbble.com": "Dribbble", "behance.net": "Behance",
@@ -135,18 +136,3 @@ export function fitFlowMessage(message, limit, measure) {
135
136
  return [compose(message, 0, 0, title)];
136
137
  return pack(blocks(message, Infinity, Infinity, Infinity), "\n\n", fits, block => pack(block.split("\n"), "\n", fits, line => chop(line, fits)));
137
138
  }
138
- /** Items read back from a kept content; anything malformed is left out. */
139
- export function readFlowItems(raw) {
140
- if (!Array.isArray(raw))
141
- return [];
142
- return raw.flatMap((one) => {
143
- if (typeof one !== "object" || one === null)
144
- return [];
145
- const item = one;
146
- const text = (key) => typeof item[key] === "string" ? item[key] : null;
147
- const [title, why, url, source, plug] = [text("title"), text("why"), text("url"), text("source"), text("plug")];
148
- if (title === null || why === null || url === null || source === null || plug === null || !/^https?:\/\//.test(url))
149
- return [];
150
- return [{ title, why, url, source, plug, shot: typeof item["shot"] === "number" && Number.isSafeInteger(item["shot"]) ? item["shot"] : null }];
151
- });
152
- }
@@ -1,5 +1,5 @@
1
1
  import { type FlowDecision } from "./flow-engine.js";
2
- import { type FlowSendItem } from "./flow-items.js";
2
+ import { type FlowChoiceAnswer, type FlowSendContent, type FlowSendLink } from "./contracts/flow-send.js";
3
3
  import { type FlowDefinition, type FlowStage } from "./flows.js";
4
4
  import { type FlowCardRow, type FlowRow, type Store } from "./store.js";
5
5
  /** The notice a "Send to me" visit sends: `flow-send:<card>:<entry>`. */
@@ -8,39 +8,14 @@ export declare const FLOW_SEND_KEY: RegExp;
8
8
  export declare const FLOW_CHOOSE_KEY: RegExp;
9
9
  /** What the person is asked under the buttons. */
10
10
  export declare const REPLY_ASK = "Or reply with what you'd change.";
11
- export type FlowSendLink = {
12
- label: string;
13
- path: string;
14
- } | {
15
- label: string;
16
- url: string;
17
- };
18
- export type FlowSendContent = {
19
- /** The card's title and the step it comes from. */
20
- title: string;
21
- /** The step it comes from, when known. */
22
- from?: string;
23
- summary: string;
24
- links: FlowSendLink[];
25
- /** The build's result whose screenshots go with it, when it saved any. */
26
- shots: {
27
- taskId: string;
28
- run: number;
29
- } | null;
30
- /** After research: the report's items, numbered in this order in every message and screenshot caption (flow-items.ts). */
31
- items?: FlowSendItem[];
32
- /** choose: the options as they were offered, and whether a reply is taken. */
33
- options?: {
34
- choice: number;
35
- label: string;
36
- }[];
37
- reply?: boolean;
38
- };
11
+ /** A visit's content and its links (src/contracts/flow-send.ts). */
12
+ export type { FlowSendContent, FlowSendLink };
39
13
  /** Who a card's "Send to me" and "Person chooses" zones are for: its owner, else the flow's. */
40
14
  export declare function flowPersonOf(card: Pick<FlowCardRow, "owner">, flow: Pick<FlowRow, "owner">): string;
41
15
  /** What a visit sends its person, worked out from the card as it is now. */
42
16
  export declare function flowSendContent(store: Store, flow: FlowRow, definition: FlowDefinition, stage: FlowStage, card: FlowCardRow, now: Date, evidenceRoot?: string): FlowSendContent;
43
- /** A kept content, read back; null when it can't be. */
17
+ /** A kept content, read back (src/contracts/flow-send.ts: one kept before it carried a version reads as it did); null
18
+ * when it can't be, and the visit's content is worked out again. */
44
19
  export declare function readFlowSend(json: string): FlowSendContent | null;
45
20
  /** The words of a sent visit: its title, summary and pull request (the one link that isn't a page of Toolroll's). */
46
21
  export declare function flowSendText(content: FlowSendContent): string;
@@ -71,13 +46,7 @@ export declare function flowChoiceAt(store: Store, cardId: number, entry: number
71
46
  * reply (`choice` null) that becomes the note for where replies go. `where` names the place it was made, for the card's
72
47
  * history and the ledger: "Telegram", "Slack", "the console".
73
48
  */
74
- export declare function chooseFlowCard(store: Store, input: {
75
- card: number;
76
- entry?: number;
77
- choice: number | null;
78
- label?: string;
79
- to?: string;
80
- note: string | null;
49
+ export declare function chooseFlowCard(store: Store, input: FlowChoiceAnswer & {
81
50
  actor: string;
82
51
  where: string;
83
52
  repos: readonly string[];
package/dist/flow-send.js CHANGED
@@ -17,7 +17,9 @@ import { assignmentOf } from "./assignment.js";
17
17
  import { TEXT_LIMITS } from "./text-limits.js";
18
18
  import { withActor } from "./actor.js";
19
19
  import { flowCardHref, flowDefinitionOf, revisionWithNote } from "./flow-engine.js";
20
- import { itemPlug, itemSource, readFlowItems } from "./flow-items.js";
20
+ import { itemPlug, itemSource } from "./flow-items.js";
21
+ import { withStageHandoff } from "./contracts/stage-output.js";
22
+ import { flowSendForStore, readFlowChoiceAnswer, readFlowItems, readFlowSendPayload } from "./contracts/flow-send.js";
21
23
  import { notifyPeople } from "./flow-people.js";
22
24
  import { readVerifiedReport } from "./evidence.js";
23
25
  import { FLOW_END, replyTarget } from "./flows.js";
@@ -120,21 +122,18 @@ export function flowSendContent(store, flow, definition, stage, card, now, evide
120
122
  ...(options === undefined ? {} : { options, reply: replyTarget(stage) !== null }),
121
123
  };
122
124
  }
123
- /** A kept content, read back; null when it can't be. */
125
+ /** A kept content, read back (src/contracts/flow-send.ts: one kept before it carried a version reads as it did); null
126
+ * when it can't be, and the visit's content is worked out again. */
124
127
  export function readFlowSend(json) {
128
+ let raw;
125
129
  try {
126
- const raw = JSON.parse(json);
127
- if (typeof raw.title !== "string" || typeof raw.summary !== "string" || !Array.isArray(raw.links))
128
- return null;
129
- if (raw.items === undefined)
130
- return raw;
131
- const items = readFlowItems(raw.items);
132
- const { items: _, ...rest } = raw;
133
- return items.length === 0 ? rest : { ...rest, items };
130
+ raw = JSON.parse(json);
134
131
  }
135
132
  catch {
136
133
  return null;
137
134
  }
135
+ const read = readFlowSendPayload(raw);
136
+ return read.ok ? read.value : null;
138
137
  }
139
138
  /** The words of a sent visit: its title, summary and pull request (the one link that isn't a page of Toolroll's). */
140
139
  export function flowSendText(content) {
@@ -153,7 +152,7 @@ function keep(store, flow, definition, stage, card, now, evidenceRoot) {
153
152
  if (kept !== null && read !== null)
154
153
  return { content: read, person: kept.person, fresh: false };
155
154
  const content = flowSendContent(store, flow, definition, stage, card, now, evidenceRoot);
156
- const fresh = store.recordFlowSend({ card: card.id, entry: card.entry, stage: stage.id, person, contentJson: JSON.stringify(content) }, now);
155
+ const fresh = store.recordFlowSend({ card: card.id, entry: card.entry, stage: stage.id, person, contentJson: flowSendForStore(content) }, now);
157
156
  return { content, person, fresh };
158
157
  }
159
158
  /** One notice to the person, and its screenshots after it. Never as anyone's own act: they asked for it in the flow. */
@@ -174,7 +173,7 @@ export function sendStep(store, flow, definition, stage, card, now, evidenceRoot
174
173
  const { content, person, fresh } = keep(store, flow, definition, stage, card, now, evidenceRoot);
175
174
  if (!fresh)
176
175
  return;
177
- store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: flowSendText(content) } }, now);
176
+ store.updateFlowCard(card.id, { outputs: withStageHandoff(card.outputs, stage.id, { text: flowSendText(content) }) }, now);
178
177
  enqueue(store, flow, card, person, content, { key: `flow-send:${card.id}:${card.entry}`, kind: "flow-card", attention: false,
179
178
  subject: `${flow.name}: ${content.title}`, body: [content.summary, ...content.links.filter((one) => "url" in one).map(one => `${one.label}: ${one.url}`)].join("\n\n") }, now);
180
179
  }
@@ -217,11 +216,16 @@ const via = (where) => where === "" ? "" : ` in ${where}`;
217
216
  * history and the ledger: "Telegram", "Slack", "the console".
218
217
  */
219
218
  export function chooseFlowCard(store, input, now) {
220
- const card = store.getFlowCard(input.card);
219
+ // The answer itself (src/contracts/flow-send.ts): an option that isn't one of the places offered is a changed option.
220
+ const { actor: _actor, where: _where, repos: _repos, evidenceRoot: _root, ...given } = input;
221
+ const answer = readFlowChoiceAnswer(given);
222
+ if (!answer.ok)
223
+ return { ok: false, message: answer.issues.every(one => one.path === "choice") ? "Those options changed since; nothing was changed." : "That card is no longer waiting." };
224
+ const card = store.getFlowCard(answer.value.card);
221
225
  const flow = card === null ? null : store.getFlow(card.flow);
222
226
  if (card === null || flow === null || card.state !== "active" || !input.repos.includes(flow.repo))
223
227
  return { ok: false, message: "That card is no longer waiting." };
224
- const visit = flowChoiceAt(store, card.id, input.entry ?? card.entry);
228
+ const visit = flowChoiceAt(store, card.id, answer.value.entry ?? card.entry);
225
229
  if (visit === null)
226
230
  return { ok: false, message: "That card has moved on since; nothing was changed." };
227
231
  const { stage, definition, person } = visit;