toolroll 0.9.94 → 0.9.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/dist/agent-fence.d.ts +6 -0
  3. package/dist/agent-fence.js +2 -2
  4. package/dist/assignment-status.d.ts +1 -1
  5. package/dist/assignment-ui.js +2 -0
  6. package/dist/attest.d.ts +3 -0
  7. package/dist/attest.js +10 -0
  8. package/dist/browser/terminal.js +1 -1
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/browser/workspace.js +24 -24
  11. package/dist/browser-crew.js +2 -0
  12. package/dist/browser-workspace.d.ts +5 -0
  13. package/dist/build-review.js +2 -0
  14. package/dist/builder.d.ts +7 -2
  15. package/dist/builder.js +183 -79
  16. package/dist/card-catalog.d.ts +209 -0
  17. package/dist/card-catalog.js +133 -0
  18. package/dist/card-cli.d.ts +43 -0
  19. package/dist/card-cli.js +44 -0
  20. package/dist/card-render.d.ts +26 -0
  21. package/dist/card-render.js +223 -0
  22. package/dist/card-sources.d.ts +68 -0
  23. package/dist/card-sources.js +234 -0
  24. package/dist/chat-actions.d.ts +95 -8
  25. package/dist/chat-actions.js +449 -45
  26. package/dist/chat-approval.d.ts +13 -0
  27. package/dist/chat-approval.js +39 -9
  28. package/dist/chat-card-part.d.ts +11 -0
  29. package/dist/chat-card-part.js +18 -0
  30. package/dist/chat-card.d.ts +46 -0
  31. package/dist/chat-card.js +99 -0
  32. package/dist/chat-channel.d.ts +2 -0
  33. package/dist/chat-channel.js +26 -14
  34. package/dist/chat-controls.d.ts +4 -0
  35. package/dist/chat-controls.js +4 -0
  36. package/dist/chat-decide.d.ts +3 -14
  37. package/dist/chat-decide.js +43 -54
  38. package/dist/chat-delivery.js +13 -4
  39. package/dist/chat-flow.d.ts +0 -27
  40. package/dist/chat-flow.js +31 -2
  41. package/dist/chat-quiet.js +1 -1
  42. package/dist/chat-task-actions.d.ts +3 -2
  43. package/dist/chat-task-actions.js +19 -5
  44. package/dist/chat-terms.d.ts +14 -2
  45. package/dist/chat-terms.js +35 -0
  46. package/dist/check-failure.d.ts +2 -0
  47. package/dist/check-failure.js +34 -2
  48. package/dist/check-repair.d.ts +47 -0
  49. package/dist/check-repair.js +240 -0
  50. package/dist/claim.d.ts +6 -0
  51. package/dist/claim.js +13 -2
  52. package/dist/cli-help.d.ts +1 -1
  53. package/dist/cli-help.js +2 -0
  54. package/dist/cli.js +4 -0
  55. package/dist/coding-provider.d.ts +1 -1
  56. package/dist/coding-provider.js +9 -5
  57. package/dist/coding-workspace.d.ts +1 -1
  58. package/dist/coding-workspace.js +4 -4
  59. package/dist/contracts/chat-actions.d.ts +83 -0
  60. package/dist/contracts/chat-actions.js +18 -0
  61. package/dist/contracts/chat-card.d.ts +91 -0
  62. package/dist/contracts/chat-card.js +54 -0
  63. package/dist/contracts/chat-content.d.ts +191 -0
  64. package/dist/contracts/chat-content.js +4 -0
  65. package/dist/contracts/console-api.d.ts +2 -0
  66. package/dist/contracts/console-api.js +2 -0
  67. package/dist/contracts/flow-propose.d.ts +8 -1
  68. package/dist/contracts/flow-propose.js +3 -1
  69. package/dist/contracts/flow-send.d.ts +126 -0
  70. package/dist/contracts/flow-send.js +3 -0
  71. package/dist/contracts/lead-tools.d.ts +173 -0
  72. package/dist/contracts/lead-tools.js +15 -0
  73. package/dist/contracts/store-json.d.ts +16 -0
  74. package/dist/discord-chat.js +14 -0
  75. package/dist/dispatch.d.ts +1 -1
  76. package/dist/dispatch.js +7 -1
  77. package/dist/dispose.d.ts +19 -0
  78. package/dist/dispose.js +47 -7
  79. package/dist/eval-oracle.js +4 -1
  80. package/dist/evidence-pair.d.ts +53 -0
  81. package/dist/evidence-pair.js +128 -0
  82. package/dist/exhaustion.d.ts +17 -4
  83. package/dist/exhaustion.js +44 -4
  84. package/dist/export.js +1 -1
  85. package/dist/failing-first.d.ts +31 -0
  86. package/dist/failing-first.js +44 -0
  87. package/dist/fallback-schema.d.ts +8 -0
  88. package/dist/fallback-schema.js +40 -0
  89. package/dist/fallback.d.ts +138 -0
  90. package/dist/fallback.js +605 -0
  91. package/dist/findings.d.ts +224 -0
  92. package/dist/findings.js +449 -0
  93. package/dist/fleet-schema.d.ts +17 -0
  94. package/dist/fleet-schema.js +135 -0
  95. package/dist/flow-browser.js +2 -2
  96. package/dist/flow-code.d.ts +2 -2
  97. package/dist/flow-code.js +9 -5
  98. package/dist/flow-result-card.d.ts +22 -0
  99. package/dist/flow-result-card.js +55 -0
  100. package/dist/flow-send.js +12 -9
  101. package/dist/flow-steps.js +1 -1
  102. package/dist/flow-triggers.js +1 -1
  103. package/dist/flow-unattended.d.ts +26 -0
  104. package/dist/flow-unattended.js +12 -3
  105. package/dist/flows-ui.js +10 -2
  106. package/dist/follow-through-brief.d.ts +12 -0
  107. package/dist/follow-through-brief.js +31 -0
  108. package/dist/follow-through-cli.d.ts +23 -0
  109. package/dist/follow-through-cli.js +153 -0
  110. package/dist/git-incident.d.ts +16 -0
  111. package/dist/git-incident.js +69 -6
  112. package/dist/handover-note.d.ts +33 -0
  113. package/dist/handover-note.js +106 -0
  114. package/dist/invoke.d.ts +20 -1
  115. package/dist/invoke.js +108 -44
  116. package/dist/lead-cli.js +26 -24
  117. package/dist/lead-contract.js +1 -0
  118. package/dist/lead-doors.d.ts +4 -0
  119. package/dist/lead-doors.js +2 -2
  120. package/dist/lead-progress.js +2 -0
  121. package/dist/lead-tools.js +111 -32
  122. package/dist/lead.js +1 -1
  123. package/dist/local-owner.js +5 -0
  124. package/dist/modes.d.ts +6 -6
  125. package/dist/modes.js +3 -1
  126. package/dist/needs-you.d.ts +5 -2
  127. package/dist/needs-you.js +14 -2
  128. package/dist/operate-remote-arguments.js +1 -0
  129. package/dist/operate-remote.js +1 -1
  130. package/dist/operate.d.ts +1 -1
  131. package/dist/operate.js +165 -14
  132. package/dist/plan-login.d.ts +14 -0
  133. package/dist/plan-login.js +58 -0
  134. package/dist/planner.d.ts +1 -1
  135. package/dist/planner.js +2 -1
  136. package/dist/project-delete-ui.d.ts +12 -0
  137. package/dist/project-delete-ui.js +18 -1
  138. package/dist/project-delete.js +6 -0
  139. package/dist/project-knowledge.d.ts +6 -6
  140. package/dist/project-tools.d.ts +4 -4
  141. package/dist/project-tools.js +23 -7
  142. package/dist/protected-storage-look.d.ts +1 -0
  143. package/dist/protected-storage-look.js +13 -0
  144. package/dist/protected-storage.d.ts +70 -39
  145. package/dist/protected-storage.js +196 -147
  146. package/dist/provider-capture.d.ts +34 -0
  147. package/dist/provider-capture.js +53 -0
  148. package/dist/release-carry.js +10 -1
  149. package/dist/release.js +9 -1
  150. package/dist/scope.d.ts +22 -3
  151. package/dist/scope.js +71 -20
  152. package/dist/scout.d.ts +1 -1
  153. package/dist/scout.js +2 -1
  154. package/dist/serve.js +2 -0
  155. package/dist/server/chat.js +2 -2
  156. package/dist/server/console.css +50 -0
  157. package/dist/server/render-chat.js +14 -1
  158. package/dist/server/render-follow-through.d.ts +11 -0
  159. package/dist/server/render-follow-through.js +68 -0
  160. package/dist/server/render-results.js +1 -1
  161. package/dist/server/render-tasks.d.ts +3 -0
  162. package/dist/server/render-tasks.js +24 -5
  163. package/dist/server/route-table.js +3 -0
  164. package/dist/server/settings.js +37 -12
  165. package/dist/server/task-views.js +5 -0
  166. package/dist/server/tasks.js +76 -0
  167. package/dist/settings-groups-ui.d.ts +1 -0
  168. package/dist/settings-groups-ui.js +3 -1
  169. package/dist/settings-kit.js +3 -2
  170. package/dist/settings-proofs.js +3 -1
  171. package/dist/setup-guide.d.ts +2 -2
  172. package/dist/slack-chat.js +14 -1
  173. package/dist/steer-mailbox.d.ts +8 -1
  174. package/dist/steer-mailbox.js +164 -6
  175. package/dist/store.d.ts +11 -4
  176. package/dist/store.js +93 -30
  177. package/dist/surface.js +24 -4
  178. package/dist/task-control.d.ts +2 -1
  179. package/dist/task-status.js +5 -0
  180. package/dist/teardown-git.d.ts +2 -0
  181. package/dist/teardown-git.js +42 -16
  182. package/dist/telegram-lead.js +23 -6
  183. package/dist/telegram.js +12 -1
  184. package/dist/toolroll-update.js +2 -0
  185. package/dist/work-index.js +22 -7
  186. package/dist/work-summary.d.ts +1 -1
  187. package/dist/work-summary.js +5 -2
  188. package/dist/workspace-ui.d.ts +3 -1
  189. package/dist/workspace-ui.js +10 -1
  190. package/dist/worktree.js +23 -1
  191. package/package.json +2 -1
package/dist/scope.d.ts CHANGED
@@ -205,6 +205,7 @@ export declare function canonicalProfileJson(profile: ExecutionProfile): string;
205
205
  * same 128 bits every other safety digest here uses (finding 21). */
206
206
  export declare function profileDigestOf(profile: ExecutionProfile): string;
207
207
  export declare const CHAIN_DIGEST_VERSION = 1;
208
+ export declare const LIVE_CHAIN_VERSION = 2;
208
209
  /** One entry of an approved fallback chain: the WHOLE execution profile
209
210
  * (so a claude->codex switch cannot inherit one provider's repair config)
210
211
  * and the auth mode it runs under. Order is authority. */
@@ -214,15 +215,15 @@ export type ChainEntry = {
214
215
  };
215
216
  /** The stored snapshot bytes for a chain: the version rides IN the
216
217
  * snapshot, exactly like a single profile's. */
217
- export declare function canonicalChainJson(entries: readonly ChainEntry[]): string;
218
+ export declare function canonicalChainJson(entries: readonly ChainEntry[], version?: number): string;
218
219
  /** sha256 over a domain-separated canonical chain encoding — a DIFFERENT
219
220
  * domain from a single profile ("standing-orders:chain:" vs
220
221
  * ":profile:"), so a chain digest can never collide with a profile
221
222
  * digest, truncated to the same 128 bits. */
222
- export declare function chainDigestOf(entries: readonly ChainEntry[]): string;
223
+ export declare function chainDigestOf(entries: readonly ChainEntry[], version?: number): string;
223
224
  /** Strict re-hydration of a stored chain — every entry proved through the
224
225
  * single-profile rehydrator; anything unexpected is null. */
225
- export declare function chainFromJson(json: string | null): ChainEntry[] | null;
226
+ export declare function chainFromJson(json: string | null, version?: number): ChainEntry[] | null;
226
227
  /**
227
228
  * THE STRICT RAW TERMS READ (raw authority repair): the stored columns of
228
229
  * a scope or a standing order, proved EXACTLY — a JSON list is a list of
@@ -499,6 +500,7 @@ export declare function digestOf(scope: Pick<Scope, "goal" | "outOfScope" | "tou
499
500
  releaseCheck?: ReleaseCheckPurpose | null;
500
501
  }, target?: ExecutionProfile | null | {
501
502
  chain: readonly ChainEntry[];
503
+ version?: number;
502
504
  }, route?: PhaseRoute | null): string;
503
505
  export declare function propose(store: Store, input: ScopeInput): Scope;
504
506
  export type GuardedProposeResult = {
@@ -798,6 +800,17 @@ export declare function approve(store: Store, taskId: string, by: string, now: D
798
800
  fastStart?: {
799
801
  rule: string;
800
802
  at: string;
803
+ },
804
+ /** A person's own yes in chat (owner's decision, docs/plans/toolroll-cards.md): their tap or signed-in click stands in
805
+ * for a password, so `token` is unused. Every rule below still applies. The caller has proved the person is the one
806
+ * tapping. `shown` is what a card that showed its terms whole showed: those words, and the digest kept when it was
807
+ * made. Null when the card showed less: the seal then opens none of the walls a person's reading opens. */
808
+ chat?: {
809
+ modeDigest: string;
810
+ shown: {
811
+ expect: string;
812
+ terms: string;
813
+ } | null;
801
814
  }): ApproveResult;
802
815
  /** The ledger act that records how an approval's person was proved (password-now, sso-now, fresh-password, fresh-sso). */
803
816
  export declare const APPROVAL_PROOF = "approval proved";
@@ -840,6 +853,7 @@ export type ScopeAuthority = {
840
853
  route: PhaseRoute;
841
854
  digest: string;
842
855
  authMode: AuthMode | null;
856
+ chain?: ChainEntry[];
843
857
  } | {
844
858
  ok: false;
845
859
  reason: "terms" | "unrouted" | "unresolved" | "profile" | "chain" | "route" | "parity" | "digest" | "auth-mode";
@@ -1036,3 +1050,8 @@ export declare function localApprovalEligible(store: Store, by: string, taskId:
1036
1050
  reason: LocalApprovalRefusal;
1037
1051
  message: string;
1038
1052
  };
1053
+ /** A chain that may dispatch: version 2, a base and at least one fallback. An older chain is history, never this. */
1054
+ export declare function liveChainFromJson(json: string | null): ChainEntry[] | null;
1055
+ /** The chain in full, the words a yes agrees to: order, agent, model, how it bills, what it may do, and whether Toolroll
1056
+ * can see it run out. Every approval surface shows these lines; empty for a scope with no chain. */
1057
+ export declare function chainTermsLines(scope: Pick<Scope, "proposedChainJson">): string[];
package/dist/scope.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // The rubric's vocabulary loads before anything that reaches the plan contract (see contracts/acceptance-terms.ts).
2
2
  export { ACCEPTANCE_LIMITS, EVIDENCE_KINDS } from "./contracts/acceptance-terms.js";
3
+ import { cardShowsScope, cardTermsDigest } from "./chat-terms.js";
3
4
  import { mintSecret } from "./secret-mint.js";
4
5
  import { passwordlessOn } from "./passwordless.js";
5
6
  import { routeChangeProblem } from "./route-change-check.js";
@@ -154,29 +155,30 @@ export function profileDigestOf(profile) {
154
155
  .digest("hex")
155
156
  .slice(0, 32);
156
157
  }
157
- // ---- fallback chains (v30-v114; removed in v115) --------------------------
158
- // History only: an approval sealed over a fallback chain still decodes and
159
- // its digest still re-derives, so old tasks verify and render. Nothing
160
- // files, seals, or dispatches a chain any more.
158
+ // ---- fallback chains (fallback.ts, docs/plans/fallback-chains.md) ----------
159
+ // Version 1 is history (v30-v114): an old approval still decodes and its digest
160
+ // still re-derives, so old tasks verify and render, and it holds no authority.
161
+ // Version 2 is what filing writes now: only it dispatches.
161
162
  export const CHAIN_DIGEST_VERSION = 1;
163
+ export const LIVE_CHAIN_VERSION = 2;
162
164
  /** The stored snapshot bytes for a chain: the version rides IN the
163
165
  * snapshot, exactly like a single profile's. */
164
- export function canonicalChainJson(entries) {
165
- return canonicalJson({ digestVersion: CHAIN_DIGEST_VERSION, chain: entries });
166
+ export function canonicalChainJson(entries, version = CHAIN_DIGEST_VERSION) {
167
+ return canonicalJson({ digestVersion: version, chain: entries });
166
168
  }
167
169
  /** sha256 over a domain-separated canonical chain encoding — a DIFFERENT
168
170
  * domain from a single profile ("standing-orders:chain:" vs
169
171
  * ":profile:"), so a chain digest can never collide with a profile
170
172
  * digest, truncated to the same 128 bits. */
171
- export function chainDigestOf(entries) {
173
+ export function chainDigestOf(entries, version = CHAIN_DIGEST_VERSION) {
172
174
  return createHash("sha256")
173
- .update(`standing-orders:chain:v${CHAIN_DIGEST_VERSION}:${canonicalChainJson(entries)}`, "utf8")
175
+ .update(`standing-orders:chain:v${version}:${canonicalChainJson(entries, version)}`, "utf8")
174
176
  .digest("hex")
175
177
  .slice(0, 32);
176
178
  }
177
179
  /** Strict re-hydration of a stored chain — every entry proved through the
178
180
  * single-profile rehydrator; anything unexpected is null. */
179
- export function chainFromJson(json) {
181
+ export function chainFromJson(json, version = CHAIN_DIGEST_VERSION) {
180
182
  if (json === null)
181
183
  return null;
182
184
  let parsed;
@@ -189,7 +191,7 @@ export function chainFromJson(json) {
189
191
  if (parsed === null || typeof parsed !== "object")
190
192
  return null;
191
193
  const wrapper = parsed;
192
- if (wrapper.digestVersion !== CHAIN_DIGEST_VERSION)
194
+ if (wrapper.digestVersion !== version)
193
195
  return null;
194
196
  // Exact keys on the wrapper and on every entry (v48 integrity): a
195
197
  // snapshot carrying anything this code never writes is not one it wrote.
@@ -602,7 +604,7 @@ route) {
602
604
  const innerDigest = target == null
603
605
  ? null
604
606
  : "chain" in target
605
- ? chainDigestOf(target.chain)
607
+ ? chainDigestOf(target.chain, target.version)
606
608
  : profileDigestOf(target);
607
609
  return createHash("sha256")
608
610
  .update(JSON.stringify({
@@ -1074,7 +1076,12 @@ export function fileAndSealUnderMode(store, input) {
1074
1076
  }
1075
1077
  export function approve(store, taskId, by, now, sawDigest, token, mutation = {},
1076
1078
  /** A fast start (fast-start.ts): Build is this same yes, with its marker in the proof. Only that road passes it. */
1077
- fastStart) {
1079
+ fastStart,
1080
+ /** A person's own yes in chat (owner's decision, docs/plans/toolroll-cards.md): their tap or signed-in click stands in
1081
+ * for a password, so `token` is unused. Every rule below still applies. The caller has proved the person is the one
1082
+ * tapping. `shown` is what a card that showed its terms whole showed: those words, and the digest kept when it was
1083
+ * made. Null when the card showed less: the seal then opens none of the walls a person's reading opens. */
1084
+ chat) {
1078
1085
  // Read and write in one transaction, re-reading inside it — the credential
1079
1086
  // check included, so a token rotated between authentication and the write
1080
1087
  // cannot leave an approval signed by an authority that no longer exists.
@@ -1082,7 +1089,14 @@ fastStart) {
1082
1089
  // this approval — which would resurrect the old wording *and* mark it
1083
1090
  // agreed, the precise opposite of what the digest is for.
1084
1091
  return store.transact(() => {
1085
- const authenticated = authenticateApprover(store, by, token, store.lookupRef(taskId)?.repo ?? null);
1092
+ const repoNow = store.lookupRef(taskId)?.repo ?? null;
1093
+ const account = chat === undefined ? null : store.accountOf(by);
1094
+ // A machine never reaches this road, and the person's role and reach are read here, in the seal's transaction.
1095
+ if (chat !== undefined && machineActing())
1096
+ return { ok: false, reason: "machine-proof" };
1097
+ if (chat !== undefined && (account === null || account.revokedAt !== null || account.role !== "approver" || repoNow === null || !store.accountCanAccess(by, repoNow)))
1098
+ return { ok: false, reason: "not-an-approver" };
1099
+ const authenticated = chat !== undefined ? { ok: true, method: "chat", at: now.getTime() } : authenticateApprover(store, by, token, repoNow);
1086
1100
  if (!authenticated.ok)
1087
1101
  return authenticated;
1088
1102
  // ONLY A PERSON APPROVES (docs/plans/approval-policy.md, phase 1): a lead token or a remote API token proves the
@@ -1110,6 +1124,10 @@ fastStart) {
1110
1124
  // the new one in silence — which is the whole failure this guards.
1111
1125
  if (sawDigest !== scope.digest)
1112
1126
  return { ok: false, reason: "changed" };
1127
+ // What the card showed when it was made is what this yes is to: other words, or words leaving out a widening, aren't.
1128
+ const shown = chat?.shown ?? null;
1129
+ if (shown !== null && (cardTermsDigest(shown.terms) !== shown.expect || !cardShowsScope(scope, shown.terms)))
1130
+ return { ok: false, reason: "changed" };
1113
1131
  // The "what changed" line a person read describes this exact route, or this yes isn't to what they read (route-change.ts).
1114
1132
  if (routeChangeProblem(store, taskId, scope))
1115
1133
  return { ok: false, reason: "changed" };
@@ -1174,7 +1192,8 @@ fastStart) {
1174
1192
  // profile — the exact bytes the digest the approver signed was bound
1175
1193
  // to. Routing saveScope here would re-run resolution and could sign a
1176
1194
  // profile nobody saw.
1177
- const sealed = store.sealScopeApproval(taskId, by, now, mutation);
1195
+ const sealed = chat === undefined ? store.sealScopeApproval(taskId, by, now, mutation)
1196
+ : store.sealScopeApproval(taskId, by, now, mutation, { kind: "chat", modeDigest: chat.modeDigest, ...(shown === null ? {} : { card: "shown", cardDigest: shown.expect, shownTerms: shown.terms }) }, "person");
1178
1197
  const approved = sealed ? store.getScope(taskId) : null;
1179
1198
  if (!sealed || approved === null) {
1180
1199
  refused.result = { ok: false, reason: sealed ? "no-scope" : "changed" };
@@ -1294,10 +1313,11 @@ export function scopeAuthorityOf(scope, env = {}) {
1294
1313
  if (profile === null) {
1295
1314
  return { ok: false, reason: "profile", problem: profileJson === null ? "the scope carries no agent profile" : "the scope's agent profile cannot be read exactly (unknown keys, an unsafe number, or a clock no timer can hold)" };
1296
1315
  }
1297
- // A scope filed with fallback agents (v30-v114) holds no authority now
1298
- // that fallback chains are gone: saving it again chooses its agents.
1299
- if (scope.proposedChainJson != null) {
1300
- return { ok: false, reason: "chain", problem: "this scope named fallback agents, which Toolroll no longer uses — save the scope again to choose its agents" };
1316
+ // Fallback agents: a current chain whose base IS this profile. An older
1317
+ // chain (v30-v114) holds no authority: saving the scope again re-files it.
1318
+ const chain = scope.proposedChainJson == null ? null : liveChainFromJson(scope.proposedChainJson);
1319
+ if (scope.proposedChainJson != null && (chain === null || profileDigestOf(chain[0].profile) !== profileDigestOf(profile))) {
1320
+ return { ok: false, reason: "chain", problem: "this scope named fallback agents from an older version, or they can't be read — save the scope again to choose its agents" };
1301
1321
  }
1302
1322
  const routeJson = scope.proposedRouteJson ?? null;
1303
1323
  const route = routeFromJson(routeJson);
@@ -1307,7 +1327,7 @@ export function scopeAuthorityOf(scope, env = {}) {
1307
1327
  const parity = routeParityProblem(route, profile, { riskLevel: scope.riskLevel ?? "routine", qualityMode: scope.qualityMode ?? "default" });
1308
1328
  if (parity !== null)
1309
1329
  return { ok: false, reason: "parity", problem: parity };
1310
- const digest = digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null, releaseCheck: scope.releaseCheck ?? null }, profile, route);
1330
+ const digest = digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null, releaseCheck: scope.releaseCheck ?? null }, chain === null ? profile : { chain, version: LIVE_CHAIN_VERSION }, route);
1311
1331
  if (digest !== scope.digest) {
1312
1332
  return { ok: false, reason: "digest", problem: "the scope's reference does not re-derive from its own terms, agents, and route — file it again" };
1313
1333
  }
@@ -1323,8 +1343,11 @@ export function scopeAuthorityOf(scope, env = {}) {
1323
1343
  if (!read.ok)
1324
1344
  return { ok: false, reason: "auth-mode", problem: read.problem };
1325
1345
  authMode = read.mode;
1346
+ // The base was filed under one credential; a yes on another isn't a yes to these terms.
1347
+ if (chain !== null && chain[0].authMode !== authMode)
1348
+ return { ok: false, reason: "auth-mode", problem: "the agent's sign-in changed since this was filed — save the scope again" };
1326
1349
  }
1327
- return { ok: true, profile, route, digest, authMode };
1350
+ return { ok: true, profile, route, digest, authMode, ...(chain === null ? {} : { chain }) };
1328
1351
  }
1329
1352
  /** The rubric's approval-card lines (v39): one per criterion, the id in
1330
1353
  * front (the exact string a proof must answer by) and its required
@@ -1363,6 +1386,7 @@ evidence = null) {
1363
1386
  // leg with its reason and readiness — before anyone signs.
1364
1387
  ...scopeRouteWords(scope, readiness),
1365
1388
  ...(evidence === null ? [] : [` why model ${evidence.replace(/^Why this model: /, "")}`]),
1389
+ ...chainTermsLines(scope).map((line, at) => at === 0 ? ` fallback ${line}` : ` ${line}`),
1366
1390
  // What the build may reach (egress.ts), part of what the yes agrees to.
1367
1391
  ...(scope.profile === null || scope.profile === undefined ? [] : [` network ${networkWords(scope.profile.network ?? null).replace(/^Network: /, "")}`]),
1368
1392
  ...(scope.profile === null || scope.profile === undefined ? [] : [` writes ${writesWords(scope.profile.writes)}`]),
@@ -1414,6 +1438,9 @@ function describeApproval(approval) {
1414
1438
  // ---- Only a person approves (docs/plans/approval-policy.md, phase 1). Kept last: the freshness tripwire pins places.
1415
1439
  export { personProof } from "./person-proof.js";
1416
1440
  import { personProof } from "./person-proof.js";
1441
+ import { permissionPlainWords } from "./chat-terms.js";
1442
+ import { limitRecognised } from "./exhaustion.js";
1443
+ import { SUBSCRIPTION_CAPABLE } from "./keys.js";
1417
1444
  import { releaseCheckPurposeDigest, releaseTermsDigest } from "./contracts/release-terms.js";
1418
1445
  import { CHECK_ACCESS_WIDTH, autoEvidenceOf, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem } from "./release-authority.js";
1419
1446
  /** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
@@ -1703,3 +1730,27 @@ export function localApprovalEligible(store, by, taskId) {
1703
1730
  return no("unsandboxed");
1704
1731
  return { ok: true };
1705
1732
  }
1733
+ // ---- Fallback chains (fallback.ts). Kept last too.
1734
+ /** A chain that may dispatch: version 2, a base and at least one fallback. An older chain is history, never this. */
1735
+ export function liveChainFromJson(json) {
1736
+ const chain = chainFromJson(json, LIVE_CHAIN_VERSION);
1737
+ return chain === null || chain.length < 2 ? null : chain;
1738
+ }
1739
+ // The chain's four providers by name, here rather than provider-auth.ts: this file sits under the contracts, and that one
1740
+ // loads every adapter.
1741
+ const CHAIN_AGENT_NAMES = { claude: "Claude", codex: "Codex", gemini: "Gemini", openrouter: "OpenRouter" };
1742
+ /** The chain in full, the words a yes agrees to: order, agent, model, how it bills, what it may do, and whether Toolroll
1743
+ * can see it run out. Every approval surface shows these lines; empty for a scope with no chain. */
1744
+ export function chainTermsLines(scope) {
1745
+ const chain = liveChainFromJson(scope.proposedChainJson ?? null);
1746
+ if (chain === null)
1747
+ return [];
1748
+ return [
1749
+ "If an agent runs out of its plan, the work moves to the next one here:",
1750
+ ...chain.map((entry, at) => {
1751
+ const paid = entry.authMode === "api-key" || SUBSCRIPTION_CAPABLE[entry.profile.provider] !== true;
1752
+ const last = at === chain.length - 1;
1753
+ return `${at + 1}. ${CHAIN_AGENT_NAMES[entry.profile.provider] ?? entry.profile.provider} ${entry.profile.model}, ${paid ? "on an API key, which spends money, and only while the project allows paid fallback" : "its plan"}; ${permissionPlainWords(entry.profile) ?? "agent permissions not known"}. ${last ? "Last in the chain." : limitRecognised(entry.profile.provider, entry.authMode) ? "Moves on when its plan runs out." : "Won't move on: Toolroll can't yet recognise this agent running out."}`;
1754
+ }),
1755
+ ];
1756
+ }
package/dist/scout.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { ProviderId } from "./contracts/provider.js";
2
2
  import type { Store } from "./store.js";
3
3
  import { type ParsedDecision, type Problem } from "./decision.js";
4
4
  import { type ParsedReport, type ReportProblem } from "./scout-report.js";
5
- import type { Runner } from "./builder.js";
5
+ import { type Runner } from "./builder.js";
6
6
  import * as browserCheck from "./scout-browser.js";
7
7
  /** The scout's own headless browser: the pinned Playwright tool, saving its
8
8
  * screenshots straight into the run's image folder. */
package/dist/scout.js CHANGED
@@ -27,6 +27,7 @@ import { scoutHandbackSchema } from "./contracts/scout-report.js";
27
27
  import { parseReport, REPORT_IMAGE_FILE, REPORT_LIMITS, REPORT_VERSION, SCOUT_OUTPUT_JSON_SCHEMA } from "./scout-report.js";
28
28
  import { TOKEN_ENVS as TELEGRAM_TOKEN_ENVS } from "./telegram.js";
29
29
  import { evidenceRoot, mailboxName, quarantineMailboxes, readMailbox, reportFileName, SCREENSHOT_BYTE_CAP, storeEvidence, validateScreenshotBytes, writeEvidenceFile, } from "./evidence.js";
30
+ import { gitFailureWords } from "./builder.js";
30
31
  import { MARKER as LEASE_MARKER } from "./worktree.js";
31
32
  import { liveLogContext } from "./live-detail.js";
32
33
  import { openLiveLog } from "./live.js";
@@ -251,7 +252,7 @@ async function scoutWith(store, request, outputDir) {
251
252
  }
252
253
  const revision = await git(GIT, ["--no-optional-locks", "rev-parse", "HEAD"], { cwd: worktree });
253
254
  if (revision.code !== 0) {
254
- return { ok: false, kind: "failure", reason: "git", message: `could not read the base revision in ${worktree}` };
255
+ return { ok: false, kind: "failure", reason: "git", message: `could not read the base revision in ${worktree} (${gitFailureWords(revision)})` };
255
256
  }
256
257
  const baseRevision = revision.stdout.trim();
257
258
  store.stampRun(request.runId, { baseRevision });
package/dist/serve.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { secretFolder } from "./tool-secret-store.js";
2
+ import { fallbackSettingOf } from "./fallback.js";
2
3
  import { acceptedIdentities, recoverLearning } from "./project-learning.js";
3
4
  import { consoleFolderGate, syncProjectIdentities } from "./project-identity.js";
4
5
  import { inFolderScope } from "./folder-gate.js";
@@ -507,6 +508,7 @@ export function createDecisionServer(options) {
507
508
  builds: projectBuildsOf(repo, who),
508
509
  access: projectAccessOf(repo),
509
510
  network: { setting: store.projectNetwork(repo), canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo) },
511
+ fallback: { setting: fallbackSettingOf(store, repo), canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo) },
510
512
  writes: { setting: store.projectWrites(repo), canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo) },
511
513
  sandbox: { setting: store.projectSandbox(repo), canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo) },
512
514
  checkAccess: {
@@ -790,7 +790,7 @@ export function createChatHandlers(runtime) {
790
790
  }
791
791
  // A scope approval's re-check is spent only if the whole confirmation commits (one transaction: the proposal's
792
792
  // settlement included). Any other action spends it whatever comes of it (its effects aren't all in the database).
793
- const confirmIt = () => confirmLeadProposal(store, principal, id, now, { authority: who.authority, chatProvider: teamChatProvider, confirm: body.get("confirm") === "yes", via: "web", evidenceRoot, ...(body.has("nonce") ? { actionReview: { nonce: body.get("nonce") ?? "", password: body.get("token") ?? "" } } : {}) });
793
+ const confirmIt = () => confirmLeadProposal(store, principal, id, now, { authority: who.authority, chatProvider: teamChatProvider, confirm: body.get("confirm") === "yes", via: "web", person: who.via === "cookie", evidenceRoot, ...(body.has("nonce") ? { actionReview: { nonce: body.get("nonce") ?? "", password: body.get("token") ?? "" } } : {}) });
794
794
  const proposed = store.getLeadProposal(id);
795
795
  const approving = proposed?.kind === "action" && sharedActionPayload(proposed.payload)?.operation === "scope_approve";
796
796
  const outcome = approving ? spendingOnlyOnSuccess(confirmIt, result => !result.ok || ("already" in result && result.already === true)) : confirmIt();
@@ -832,7 +832,7 @@ export function createChatHandlers(runtime) {
832
832
  }
833
833
  if (outcome.ok && outcome.kind === "task_action" && outcome.taskId !== null) {
834
834
  const proposal = store.getLeadProposal(id);
835
- if (proposal.payload["operation"] === "resume") {
835
+ if (proposal.payload["operation"] === "resume" && who.via !== "cookie") {
836
836
  return armTaskResume(response, who, outcome.taskId, String(proposal.payload["run"]), "chat", now);
837
837
  }
838
838
  return redirect(response, `${proposalTaskHref(outcome.taskId)}#task-chat-live`);
@@ -2816,3 +2816,53 @@ html.so-swap::view-transition-old(*):only-child { animation: so-card-out 160ms e
2816
2816
  .flaky-rows .k-row-a { grid-column: 4; grid-row: 1 / span 2; }
2817
2817
  .flaky-rows .k-row-c { grid-row: 1 / span 2; }
2818
2818
  }
2819
+
2820
+ /* A Toolroll card (card-render.ts): tables, charts and key facts in chat, on the console's own tokens. */
2821
+ .tc-card { display: grid; gap: var(--so-space-sm); min-width: 0; font-size: 13px; line-height: 1.45; color: var(--so-ink); }
2822
+ .tc-part { display: grid; gap: 6px; min-width: 0; }
2823
+ .tc-part h4 { margin: 0; font-size: 12.5px; font-weight: 600; }
2824
+ .tc-text { margin: 0; }
2825
+ /* The lead's own words in a card: set apart from the records, never styled as one. */
2826
+ .tc-lead { display: grid; gap: 2px; padding-left: var(--so-space-sm); border-left: 2px solid var(--so-line); font-style: italic; }
2827
+ .tc-lead .tc-note { font-style: normal; }
2828
+ .tc-note { margin: 0; font-size: 11.5px; color: var(--so-muted); }
2829
+ .tc-rule { width: 100%; margin: 0; border: 0; border-top: 1px solid var(--so-line); }
2830
+ .tc-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: var(--so-space-sm); margin: 0; }
2831
+ .tc-facts > div { display: grid; gap: 1px; min-width: 0; }
2832
+ .tc-facts dt { font-size: 11.5px; color: var(--so-muted); }
2833
+ .tc-facts dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
2834
+ .tc-scroll { max-width: 100%; overflow-x: auto; }
2835
+ .tc-table { width: 100%; border-collapse: collapse; }
2836
+ .tc-table th { font-size: 11.5px; font-weight: 500; color: var(--so-muted); text-align: left; white-space: nowrap; }
2837
+ .tc-table th, .tc-table td { padding: 5px var(--so-space-md) 5px 0; border-bottom: 1px solid var(--so-line); vertical-align: top; }
2838
+ /* Cells break between words, never inside one: a narrow screen scrolls the table (.tc-scroll) instead. */
2839
+ .tc-table td { overflow-wrap: break-word; }
2840
+ .tc-table tr:last-child td { border-bottom: 0; }
2841
+ .tc-table .tc-num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
2842
+ .tc-bars { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
2843
+ .tc-bars li { display: grid; grid-template-columns: minmax(0, 8rem) minmax(0, 1fr) auto; gap: var(--so-space-sm); align-items: center; }
2844
+ .tc-bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2845
+ .tc-bar-track { height: 8px; border-radius: 4px; background: var(--so-raised); }
2846
+ .tc-bar-fill { display: block; height: 100%; border-radius: 4px; background: var(--so-ink); }
2847
+ .tc-bar-value { font-variant-numeric: tabular-nums; }
2848
+ .tc-pie { display: flex; flex-wrap: wrap; align-items: center; gap: var(--so-space-md); }
2849
+ .tc-pie svg { flex: none; width: 88px; height: 88px; }
2850
+ .tc-legend { display: grid; flex: 1 1 140px; gap: 3px; margin: 0; padding: 0; list-style: none; }
2851
+ .tc-legend li { display: flex; align-items: center; gap: 6px; min-width: 0; }
2852
+ .tc-legend strong { margin-left: auto; font-weight: 600; font-variant-numeric: tabular-nums; }
2853
+ .tc-key { flex: none; width: 9px; height: 9px; border-radius: 2px; background: currentColor; }
2854
+ .tc-c0 { color: var(--so-ink); stroke: var(--so-ink); }
2855
+ .tc-c1 { color: var(--so-info); stroke: var(--so-info); }
2856
+ .tc-c2 { color: var(--so-success); stroke: var(--so-success); }
2857
+ .tc-c3 { color: var(--so-warning); stroke: var(--so-warning); }
2858
+ .tc-c4 { color: var(--so-danger); stroke: var(--so-danger); }
2859
+ .tc-c5 { color: var(--so-muted); stroke: var(--so-muted); }
2860
+ .tc-line { margin: 0; display: grid; gap: var(--so-space-xs); }
2861
+ .tc-line svg { width: 100%; height: 90px; }
2862
+ .tc-line figcaption { font-size: 11.5px; color: var(--so-muted); }
2863
+ .tc-stroke { stroke: var(--so-ink); stroke-width: 1.5; }
2864
+ .tc-area { fill: var(--so-raised); }
2865
+ .tc-notes { display: grid; gap: var(--so-space-xs); margin: 0; padding-left: var(--so-space-md); overflow-wrap: anywhere; }
2866
+
2867
+ /* The handover note wraps on a narrow screen rather than scrolling sideways. */
2868
+ .handover-note { white-space: pre-wrap; }
@@ -2,6 +2,8 @@ import { parseFeedbackImage } from "../result-review.js";
2
2
  import { assignmentSummaryHtml } from "../assignment-ui.js";
3
3
  import {} from "../assignment.js";
4
4
  import {} from "../browser-workspace.js";
5
+ import { webCardHtml } from "../card-render.js";
6
+ import { cardOfProposal, cardPlainOf, cardsFirst, isCardProposal } from "../chat-card.js";
5
7
  import { closedQuestionReason } from "../decision.js";
6
8
  import { CHAT_ACTIONS, sharedActionNeedsReview, sharedActionPayload, sharedActionReviewPath } from "../chat-actions.js";
7
9
  import { CHAT_CONTINUITY_SCRIPT } from "../chat-continuity.js";
@@ -544,6 +546,17 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
544
546
  card: { id: view.id, kind: view.kind, label: "Question closed", state: view.state, body: htmlString(body), said: null, links: [], primary: null, dismissable: false, note: null },
545
547
  };
546
548
  }
549
+ // A Toolroll card: data to read, with its fixed links. It confirms nothing, so it waits on nobody.
550
+ if (isCardProposal(view)) {
551
+ const shown = cardOfProposal(view);
552
+ const body = shown === null ? html `<p>${cardPlainOf(view)}</p>` : webCardHtml(shown);
553
+ const links = shown?.buttons.map(one => ({ label: one.label, href: one.path })) ?? [];
554
+ const label = shown?.title ?? "Card";
555
+ return {
556
+ html: html `<article class="card proposal proposal-data" data-card-kind="data"><div class="proposal-body"><h3>${label}</h3>${body}</div>${links.length === 0 ? "" : html `<footer class="proposal-actions">${joinHtml(links.map(one => html `<a class="button-link" href="${one.href}">${one.label}</a>`))}</footer>`}</article>`,
557
+ card: { id: view.id, kind: view.kind, label, state: "pending", body: htmlString(body), said: null, links, primary: null, dismissable: false, note: null, data: true },
558
+ };
559
+ }
547
560
  const presentations = {
548
561
  task: { label: payload["report"] === true ? "Scout investigation" : "New task", action: "file task", icon: html `<path d="M12 5v14"/><path d="M5 12h14"/>` },
549
562
  next: { label: "Queue priority", action: "move to front", icon: html `<path d="M12 19V5"/><path d="m5 12 7-7 7 7"/>` },
@@ -855,7 +868,7 @@ export function leadBrowserMessages(rows, back, target = { task: null, project:
855
868
  activity: message.activity, createdAt: message.createdAt,
856
869
  ...(() => {
857
870
  // Under the lead's message only, as the server-rendered thread does: the person's message shares the turn.
858
- const parts = message.turn === null || message.role === 'operator' ? [] : rows.proposals.filter(one => one.turn === message.turn)
871
+ const parts = message.turn === null || message.role === 'operator' ? [] : cardsFirst(rows.proposals.filter(one => one.turn === message.turn))
859
872
  .map(one => leadProposalCardParts(one, rows.pending !== null, rows.decisions.get(typeof one.payload['decision'] === 'number' ? one.payload['decision'] : -1) ?? null, back));
860
873
  return { cardsHtml: htmlString(joinHtml(parts.map(one => one.html))), cards: parts.map(one => one.card) };
861
874
  })(),
@@ -0,0 +1,11 @@
1
+ import { type Html } from "../html.js";
2
+ import type { Store } from "../store.js";
3
+ export type FollowThroughView = {
4
+ findings: Html | "";
5
+ findingCount: number;
6
+ openFindings: number;
7
+ repairs: Html | "";
8
+ evidence: Html | "";
9
+ handover: Html | "";
10
+ };
11
+ export declare function followThroughView(store: Store, root: string, taskId: string, taskHref: string, canAct: boolean, now: Date): FollowThroughView;
@@ -0,0 +1,68 @@
1
+ /** The task page's follow-through sections (docs/plans/fleet-follow-through.md): findings, failed checks, before and after,
2
+ * and the handover note. All of a finding's text is model output or repository text: it renders as text, in `html`'s
3
+ * escaping, and no action here is reachable from it. Every form posts to the one action in tasks.ts. */
4
+ import { checkRepairsOfTask, checkRepairWords } from "../check-repair.js";
5
+ import { evidencePairsOf, evidenceWords, failingFirstOf, failingFirstWords } from "../evidence-pair.js";
6
+ import { findingLine, findingFromWords, findingsOfTask, findingStandingWords } from "../findings.js";
7
+ import { handoverLines, handoverNoteOf } from "../handover-note.js";
8
+ import { html, joinHtml, postForm } from "../html.js";
9
+ import { providerCaptures } from "../provider-capture.js";
10
+ import { btn, disclose, field, input, note, textarea } from "./kit.js";
11
+ const act = (taskHref, name, label, hidden = {}, variant = "outline") => postForm(`${taskHref}/follow-through`, joinHtml([
12
+ html `<input type="hidden" name="act" value="${name}">`,
13
+ ...Object.entries(hidden).map(([key, value]) => html `<input type="hidden" name="${key}" value="${String(value)}">`),
14
+ btn(label, { variant, type: "submit" }),
15
+ ]));
16
+ function findingRow(one, taskHref, canAct) {
17
+ const answered = one.state === "accepted" || one.state === "rejected";
18
+ const closing = canAct && one.state !== "closed" ? disclose("Close it", postForm(`${taskHref}/follow-through`, joinHtml([
19
+ html `<input type="hidden" name="act" value="close"><input type="hidden" name="finding" value="${String(one.id)}">`,
20
+ field({ id: `finding-close-${one.id}`, label: "Why it can close", control: input({ id: `finding-close-${one.id}`, name: "note" }) }),
21
+ btn("Close finding", { variant: "outline", type: "submit" }),
22
+ ]))) : "";
23
+ return html `<li data-finding="${one.state}"><p>${findingLine(one)}</p><p class="meta">${findingStandingWords(one)}</p><p class="meta">From ${findingFromWords(one)}. Requested: ${one.action}</p>${canAct && answered ? act(taskHref, "resend", "Send it back", { finding: one.id }) : ""}${closing}</li>`;
24
+ }
25
+ function sendForm(taskHref) {
26
+ return disclose("Send a finding", postForm(`${taskHref}/follow-through`, joinHtml([
27
+ html `<input type="hidden" name="act" value="send">`,
28
+ field({ id: "finding-note", label: "What's wrong", control: textarea({ id: "finding-note", name: "note", rows: 3 }) }),
29
+ field({ id: "finding-file", label: "File, if there is one", control: input({ id: "finding-file", name: "file" }) }),
30
+ field({ id: "finding-line", label: "Line", control: input({ id: "finding-line", name: "line", type: "number" }) }),
31
+ btn("Send to the agent", { variant: "outline", type: "submit" }),
32
+ ])));
33
+ }
34
+ const captureRow = (one, taskHref, canAct) => html `<li><p class="meta">${one.provider}, build #${one.run}${one.label === null ? "" : `, marked ${one.label}`}</p><p class="mono">${one.text.slice(0, 500)}</p>${canAct && one.label === null ? joinHtml([act(taskHref, "mark", "Used-up plan", { capture: one.id, label: "exhausted" }), act(taskHref, "mark", "Something else", { capture: one.id, label: "not-exhausted" })]) : ""}</li>`;
35
+ export function followThroughView(store, root, taskId, taskHref, canAct, now) {
36
+ const findings = findingsOfTask(store, taskId);
37
+ const open = findings.filter(one => one.state === "open" || one.state === "with-person").length;
38
+ const repairs = checkRepairsOfTask(store, taskId).filter(one => one.state !== "closed");
39
+ const pairs = evidencePairsOf(store, root, taskId, now);
40
+ const step = failingFirstOf(store, taskId);
41
+ const stepWords = failingFirstWords(step);
42
+ const hasRun = store.lookupRef(taskId) !== null && store.runsFor(store.lookupRef(taskId).id).some(one => one.role === "builder" && one.finishedAt !== null);
43
+ const family = store.taskFamilyOf(taskId, null, true);
44
+ const familyRuns = new Set((family?.versions.map(one => one.id) ?? [taskId]).flatMap(id => { const ref = store.lookupRef(id); return ref === null ? [] : store.runsFor(ref.id).map(run => run.id); }));
45
+ const captures = providerCaptures(store, null, 200).filter(one => familyRuns.has(one.run)).slice(0, 5);
46
+ const latestFailed = repairs.find(one => one.state !== "fixed");
47
+ const note1 = handoverNoteOf(store, root, taskId, now);
48
+ return {
49
+ findingCount: findings.length, openFindings: open,
50
+ findings: findings.length === 0 && !canAct ? "" : joinHtml([
51
+ findings.length === 0 ? "" : html `<ul class="result-changes">${findings.map(one => findingRow(one, taskHref, canAct))}</ul>`,
52
+ canAct ? sendForm(taskHref) : "",
53
+ ]),
54
+ repairs: repairs.length === 0 ? "" : html `<ul class="result-changes">${repairs.map(one => html `<li data-check-repair="${one.state}"><p>${checkRepairWords(one)}</p>${one.repairTask === null ? "" : html `<p class="meta"><a class="k-link" href="/t/${encodeURIComponent(one.repairTask)}">Open the repair</a></p>`}${canAct && one.repairTask === null && (one.state === "waiting" || one.state === "parked") ? act(taskHref, "repair", "Start a repair", { run: one.sourceRun }, "primary") : ""}</li>`)}</ul>`,
55
+ evidence: pairs.length === 0 && stepWords === null && captures.length === 0 && !(canAct && hasRun) ? "" : joinHtml([
56
+ pairs.length === 0 ? "" : html `<ul class="result-changes">${pairs.map(one => html `<li data-evidence="${one.reading}"><p>${evidenceWords(one)}</p><p class="meta"><a class="k-link" href="/r/${String(one.before.run)}">Before</a>${one.after === null ? "" : html ` · <a class="k-link" href="/r/${String(one.after.run)}">After</a>`}${one.before.screenshots.length + (one.after?.screenshots.length ?? 0) === 0 ? "" : ` · ${one.before.screenshots.length + (one.after?.screenshots.length ?? 0)} screenshots`}</p></li>`)}</ul>`,
57
+ stepWords === null ? "" : note(stepWords),
58
+ step?.output == null ? "" : html `<p class="mono">${step.output}</p>`,
59
+ canAct && hasRun ? html `<div class="k-acts">${joinHtml([
60
+ ...(latestFailed === undefined ? [] : [act(taskHref, "save-evidence", "Save the failing result", { run: latestFailed.sourceRun })]),
61
+ ...(pairs.length === 0 ? [] : [act(taskHref, "rerun-evidence", "Run the same check again")]),
62
+ ])}</div>` : "",
63
+ canAct ? act(taskHref, step === null ? "failing-first-on" : "failing-first-off", step === null ? "Ask for a failing test first" : "Turn off failing test first") : "",
64
+ captures.length === 0 ? "" : disclose("What the provider said", html `<ul class="result-changes">${captures.map(one => captureRow(one, taskHref, canAct))}</ul>`),
65
+ ]),
66
+ handover: note1 === null ? "" : html `<pre class="mono handover-note">${handoverLines(note1).join("\n")}</pre>`,
67
+ };
68
+ }
@@ -1698,7 +1698,7 @@ export function resultPanelParts(detail, o) {
1698
1698
  summaryParts.push(html `<details class="result-notes" data-result-notes-agent><summary>What the agent reported</summary>${agentAccount}</details>`);
1699
1699
  // MEDIUM and LOW never block: suggested follow-ups, on request.
1700
1700
  if (automatic !== null && automatic.followUps.length > 0) {
1701
- summaryParts.push(html `<details class="result-notes" data-review-followups="${automatic.followUps.length}"><summary>Suggested follow-ups · ${automatic.followUps.length}</summary><ul class="result-changes">${automatic.followUps.map(one => html `<li><span class="badge">${one.severity === "MEDIUM" ? "Medium" : "Low"}</span> <span class="mono">${`${one.file}:${one.line}`}</span> ${one.scenario}</li>`)}</ul></details>`);
1701
+ summaryParts.push(html `<details class="result-notes" data-review-followups="${automatic.followUps.length}"><summary>Suggested follow-ups · ${automatic.followUps.length}</summary><ul class="result-changes">${automatic.followUps.map((one, index) => html `<li><span class="badge">${one.severity === "MEDIUM" ? "Medium" : "Low"}</span> <span class="mono">${`${one.file}:${one.line}`}</span> ${one.scenario}${o.csrf === "" ? "" : postForm(`${taskHref(detail.taskId)}/follow-through`, html `<input type="hidden" name="act" value="send"><input type="hidden" name="pointer" value="${`${automatic.run}:${automatic.high.length + index + 1}`}"><button type="submit" class="secondary">Send to the agent</button>`)}</li>`)}</ul></details>`);
1702
1702
  }
1703
1703
  const evidenceSources = [
1704
1704
  ...(report !== null ? [report.ok ? `verified report${report.truncated ? " (shortened)" : ""}` : "report (unverifiable)"] : []),
@@ -9,6 +9,7 @@ import { type FormView } from "../contracts/console-api.js";
9
9
  import { type DispatchDiagnosis } from "../dispatch.js";
10
10
  import { type ReportView } from "../evidence.js";
11
11
  import { type Gap } from "../gaps.js";
12
+ import type { FollowThroughView } from "./render-follow-through.js";
12
13
  import { type FailureExplanation } from "../needs-you.js";
13
14
  import { type RouteOverride, type RouteProjection } from "../phase-routing.js";
14
15
  import { type MilestoneState } from "../plan.js";
@@ -572,6 +573,8 @@ export declare function taskBodyParts(data: {
572
573
  routeChange?: string | null;
573
574
  /** A fast start's result grew past a small change (fast-start.ts): its sentence, and the one act on. */
574
575
  fastGrown?: string | null;
576
+ /** Findings, failed checks, before and after, and the handover note (render-follow-through.ts). */
577
+ followThrough?: FollowThroughView | null;
575
578
  /** Easy-start C5: the viewer is a laptop's local owner — whether this plan is one it approves without a password. */
576
579
  approvalLocal?: LocalApprovalView | null;
577
580
  /** Cockpit B2: how the current approval's person was proved (approval proved ledger), when it was a remembered one. */