toolroll 0.9.30 → 0.9.32

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 (61) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/assignment.d.ts +3 -0
  3. package/dist/assignment.js +8 -2
  4. package/dist/batch-checks.d.ts +15 -0
  5. package/dist/batch-checks.js +244 -0
  6. package/dist/batch-policy.d.ts +23 -0
  7. package/dist/batch-policy.js +25 -0
  8. package/dist/browser/workspace.js +13 -13
  9. package/dist/builder.js +14 -3
  10. package/dist/chat-actions.js +2 -2
  11. package/dist/check-levels-ui.d.ts +4 -0
  12. package/dist/check-levels-ui.js +21 -2
  13. package/dist/checkout-cleanup.d.ts +2 -1
  14. package/dist/checkout-cleanup.js +24 -8
  15. package/dist/desktop-update.d.ts +1 -1
  16. package/dist/exec.d.ts +2 -0
  17. package/dist/exec.js +11 -0
  18. package/dist/flow-gallery-ui.d.ts +14 -9
  19. package/dist/flow-gallery-ui.js +71 -29
  20. package/dist/flow-gallery.d.ts +26 -2
  21. package/dist/flow-gallery.js +182 -7
  22. package/dist/lead-capabilities.d.ts +0 -7
  23. package/dist/lead-capabilities.js +11 -1
  24. package/dist/mate-doors.js +2 -2
  25. package/dist/mate.d.ts +6 -2
  26. package/dist/mate.js +63 -38
  27. package/dist/mcp-connect.d.ts +3 -1
  28. package/dist/mcp-connect.js +11 -6
  29. package/dist/never-index.d.ts +3 -0
  30. package/dist/never-index.js +21 -0
  31. package/dist/operate.js +86 -2
  32. package/dist/orphan-sweep.d.ts +63 -0
  33. package/dist/orphan-sweep.js +235 -0
  34. package/dist/result-follow-ups.d.ts +22 -2
  35. package/dist/result-follow-ups.js +44 -10
  36. package/dist/serve.js +52 -19
  37. package/dist/shared-deps.d.ts +18 -2
  38. package/dist/shared-deps.js +129 -15
  39. package/dist/staged-runtimes.d.ts +42 -0
  40. package/dist/staged-runtimes.js +191 -0
  41. package/dist/storage-sweep.d.ts +50 -0
  42. package/dist/storage-sweep.js +203 -0
  43. package/dist/storage-ui.d.ts +5 -0
  44. package/dist/storage-ui.js +30 -4
  45. package/dist/storage.d.ts +2 -0
  46. package/dist/storage.js +15 -3
  47. package/dist/store.d.ts +17 -2
  48. package/dist/store.js +37 -5
  49. package/dist/subscription-chat.d.ts +3 -1
  50. package/dist/subscription-chat.js +1 -0
  51. package/dist/surface.js +1 -0
  52. package/dist/task-status.d.ts +13 -0
  53. package/dist/task-status.js +37 -3
  54. package/dist/team-leads.js +6 -2
  55. package/dist/team-runtime.js +2 -1
  56. package/dist/test-temp.d.ts +20 -2
  57. package/dist/test-temp.js +53 -13
  58. package/dist/toolroll-update.js +2 -0
  59. package/dist/workspace-ui.js +3 -1
  60. package/dist/worktree.js +9 -0
  61. package/package.json +1 -1
@@ -9,8 +9,11 @@
9
9
  * shared flow file later. Every one is built only from existing zone and trigger kinds, and none merges:
10
10
  * a pull request it opens waits for a person.
11
11
  *
12
- * Every template can end with "Send me the result" (a "Send to me" zone before its end), off unless asked for
13
- * (`send-result: yes`): what the flow did reaches the person in their chat apps.
12
+ * A template that puts a connected tool to work names it (`tools`): a one-click service (ONE_CLICK) or the Figma
13
+ * desktop app. Its research steps read that tool and only read; a build gets the project's tools as any build does.
14
+ *
15
+ * Every template without its own send zone can end with "Send me the result" (a "Send to me" zone before its end),
16
+ * off unless asked for (`send-result: yes`): what the flow did reaches the person in their chat apps.
14
17
  */
15
18
  import { createHash } from "node:crypto";
16
19
  import { scanForSecrets } from "./evidence.js";
@@ -19,6 +22,7 @@ import { saveScript, validateScript } from "./flow-scripts.js";
19
22
  import { isToolrollRepo, planeReviewSteps } from "./flow-starters.js";
20
23
  import { addFlowTriggerTo, describeTrigger, githubRepoOf, scheduleFromWords, validateTriggerConfig } from "./flow-triggers.js";
21
24
  import { choiceTargets, clockTime, flowFromSteps, flowTerms, FLOW_TEMPLATES, ISSUE_LABEL } from "./flows.js";
25
+ import { connectionsOf, localConnectOf, oneClickOf } from "./mcp-connect.js";
22
26
  import { publishingOf } from "./pull-request-flow.js";
23
27
  export const GALLERY_GROUPS = [
24
28
  { id: "ship", label: "Ship code" },
@@ -32,6 +36,9 @@ export const OUTDATED_COMMANDS = {
32
36
  pip: { label: "pip (pip list --outdated)", command: "out=\"$(python3 -m pip list --outdated 2>/dev/null | tail -n +3)\"\nif [ -n \"$out\" ]; then echo \"$out\"; exit 1; fi" },
33
37
  cargo: { label: "cargo (cargo outdated)", command: "cargo outdated --root-deps-only --exit-code 1" },
34
38
  };
39
+ /** The card's Linear issue key, read from its source as the Linear trigger stored it ("Linear ENG-123"); "none" skips the comment. */
40
+ export const LINEAR_KEY = `key="$(sed -n 's/.*"source":{"kind":"linear","label":"Linear \\([A-Z][A-Z0-9]*-[0-9][0-9]*\\)".*/\\1/p' | head -n 1)"
41
+ if [ -n "$key" ]; then echo "$key"; echo "goto: Found"; else echo none; echo "goto: None"; fi`;
35
42
  const NEVER_MERGES = "Never merges. Each pull request waits for you.";
36
43
  const work = (text) => `${text}\n\nChanges asked for (if any): {{note}}`;
37
44
  const research = (text) => `${text}\n\nFeedback to address (if any): {{note}}`;
@@ -40,6 +47,11 @@ const pr = { id: "pull-request", title: "Pull request", kind: "pull-request" };
40
47
  const drawn = (id) => structuredClone(FLOW_TEMPLATES.find(one => one.id === id).definition);
41
48
  const about = (id) => FLOW_TEMPLATES.find(one => one.id === id).about;
42
49
  const SORTS = "OpenRouter (Jev sorts)";
50
+ const WEEKLY = { key: "schedule", default: "monday 09:00", label: "When", hint: "Like “monday 09:00”." };
51
+ const weekly = (title) => ({ kind: "schedule", schedule: "{{ask.schedule}}", title });
52
+ const send = (title) => ({ id: "send", title, kind: "send" });
53
+ /** A person's choice: build it, or end there. A reply goes to the build as its note. */
54
+ const choose = (title, yes, build) => ({ id: "choose", title, kind: "choose", options: [{ label: yes, goesTo: build }, { label: "Ignore", goesTo: "end" }], ifReplied: build });
43
55
  export const GALLERY = [
44
56
  // ---------------------------------------------------------------- Ship code
45
57
  {
@@ -137,6 +149,50 @@ export const GALLERY = [
137
149
  id: "effort-routing", name: "Effort routing", group: "ship", promise: "Small changes go straight to a build; big ones get a plan and a person first.", needs: [SORTS], asks: [], definition: drawn("effort-routing"), triggers: [],
138
150
  does: [about("effort-routing")], never: "Never builds a big change before you approve its plan.",
139
151
  },
152
+ {
153
+ id: "ui-inspiration", name: "UI inspiration", group: "ship", tools: ["mobbin"],
154
+ promise: "Each week, three real app examples for one part of your UI, ready to build.",
155
+ needs: ["GitHub"], asks: [WEEKLY],
156
+ steps: [
157
+ { id: "find", title: "Find examples", kind: "report", instructions: research("Pick one surface of this project (a screen or a flow) that no recent commit or earlier inspiration covered.\n\nWith Mobbin, read only: find 3 real app examples for it. For each: the pattern, why it fits here, how it plugs into this code (files and components), and a Mobbin screenshot.") },
158
+ choose("Implement, remix or ignore?", "Implement", "build"),
159
+ { id: "build", title: "Build it", kind: "task", instructions: work("Build the chosen example from the report into this project with its own components. A note below is a remix: follow it.\n\nThe report:\n{{stage.find}}") },
160
+ pr,
161
+ send("Send the result"),
162
+ ],
163
+ triggers: [weekly("UI inspiration")],
164
+ does: ["Picks one part of your UI {{ask.schedule-words}} and finds 3 Mobbin examples for it, each with a screenshot.", "You choose Implement or Ignore, or reply with a remix note.", "What you choose is built as a task, under your usual approvals, and opens a pull request.", "The result is sent to you."],
165
+ never: NEVER_MERGES,
166
+ },
167
+ {
168
+ id: "figma-to-pr", name: "Figma frame to pull request", group: "ship", tools: ["figma-desktop"],
169
+ promise: "Paste a Figma frame link; get it built in your own components.",
170
+ needs: ["GitHub"], asks: [],
171
+ steps: [
172
+ { id: "build", title: "Build the frame", kind: "task", instructions: work("Build this Figma frame in the project's own components and styles: {{card.title}}\n\n{{card.description}}\n\nRead the frame with the Figma desktop app's tools (its code, screenshot and variables). Reuse existing components; add one only where none fits. Take screenshots before and after, on desktop and phone.") },
173
+ pr,
174
+ send("Send before and after"),
175
+ ],
176
+ triggers: [{ kind: "button", label: "Build a Figma frame", questions: ["Figma frame link", "Notes (optional)"] }],
177
+ does: ["Adds a “Build a Figma frame” button that asks for the frame link.", "Builds the frame in your components as a task, under your usual approvals, with desktop and phone screenshots.", "Opens a pull request and sends you before and after."],
178
+ never: NEVER_MERGES,
179
+ },
180
+ {
181
+ id: "linear-to-prs", name: "Linear issues to PRs", group: "ship", tools: ["linear"],
182
+ promise: "Labelled Linear issues are built, and the issue gets the pull request link.",
183
+ needs: ["GitHub", "A Linear API key"], asks: [{ key: "team", default: "ENG", label: "Linear team", hint: "Its short key, like ENG." }, { key: "label", default: ISSUE_LABEL, label: "Issue label" }],
184
+ // The issue's key comes from the card's source ("Linear ENG-123"), as the trigger stored it, never from a model.
185
+ scripts: [{ name: "linear-issue-key", about: "Prints the Linear issue the card came from, or none.", body: LINEAR_KEY, timeoutMinutes: 1 }],
186
+ steps: [
187
+ { id: "build", title: "Build it", kind: "task", instructions: work("Build this issue: {{card.title}}\n\n{{card.description}}") },
188
+ pr,
189
+ { id: "issue-key", title: "Find the issue key", kind: "check", script: "{{script.linear-issue-key}}", runIn: "folder", routes: [{ answer: "Found", goesTo: "comment" }, { answer: "None", goesTo: "Done" }] },
190
+ { id: "comment", title: "Comment on the issue", kind: "tool", server: "linear", tool: "create_comment", args: { issueId: "{{stage.issue-key}}", body: "A pull request is ready: {{stage.pull-request}}" } },
191
+ ],
192
+ triggers: [{ kind: "linear", team: "{{ask.team}}", label: "{{ask.label}}" }],
193
+ does: ["Watches {{ask.team}} issues labelled “{{ask.label}}” in Linear.", "Builds each one as a task, under your usual approvals, and opens a pull request.", "Comments the pull request link on the issue, through Linear."],
194
+ never: NEVER_MERGES,
195
+ },
140
196
  // ---------------------------------------------------------- Keep it healthy
141
197
  {
142
198
  id: "fix-ci", name: "Fix failing CI", group: "health",
@@ -211,7 +267,7 @@ export const GALLERY = [
211
267
  never: NEVER_MERGES,
212
268
  },
213
269
  {
214
- id: "error-to-fix", name: "Error to fix", group: "health",
270
+ id: "error-to-fix", name: "Error to fix", group: "health", tools: ["sentry"],
215
271
  promise: "New errors from Sentry or any error tracker are sorted, researched and fixed.",
216
272
  needs: ["A webhook from Sentry or another tracker", SORTS], asks: [],
217
273
  steps: [
@@ -220,7 +276,7 @@ export const GALLERY = [
220
276
  { answer: "Fix it", means: "It comes from our code and is new, frequent, or hurts people", goesTo: "find-cause" },
221
277
  { answer: "Ignore", means: "Noise: bots, browser extensions, a third party's outage, or something known and expected", goesTo: "ignored" },
222
278
  ], ifNotSure: "by-hand" },
223
- { id: "find-cause", title: "Find the cause", kind: "report", instructions: research("An error came in: {{card.title}}\n\n{{card.description}}\n\nFind the cause in this code: cite the files and lines, say why it happens and what change fixes it.") },
279
+ { id: "find-cause", title: "Find the cause", kind: "report", instructions: research("An error came in: {{card.title}}\n\n{{card.description}}\n\nWith Sentry, read the issue's stack trace and latest events. Read only. Find the cause in this code: cite the files and lines, say why it happens and what change fixes it.") },
224
280
  { id: "fix", title: "Fix it", kind: "task", instructions: work("Fix this error, with a regression check where one fits: {{card.title}}\n\nThe report:\n{{stage.find-cause}}") },
225
281
  owner("review", "Review the fix"),
226
282
  { ...pr, next: "Done" },
@@ -233,6 +289,56 @@ export const GALLERY = [
233
289
  "Each kept error is researched, then fixed as a task, under your usual approvals.", "What you approve opens a pull request."],
234
290
  never: NEVER_MERGES,
235
291
  },
292
+ {
293
+ id: "outage-to-cause", name: "Outage to cause", group: "health", tools: ["betterstack"],
294
+ promise: "A Better Stack incident gets its likely cause, and a fix if you want one.",
295
+ needs: ["A webhook from Better Stack", "GitHub"], asks: [],
296
+ steps: [
297
+ { id: "find-cause", title: "Find the cause", kind: "report", instructions: research("A Better Stack incident: {{card.title}}\n\n{{card.description}}\n\nWith Better Stack, read the incident and its monitor's recent history. Read this project's commits and deploys from before it started. Read only. Say the likely cause, with times, and what change fixes it.") },
298
+ choose("Fix or ignore?", "Fix", "fix"),
299
+ { id: "fix", title: "Fix it", kind: "task", instructions: work("Fix the cause of this outage, with a regression check where one fits.\n\nThe report:\n{{stage.find-cause}}") },
300
+ pr,
301
+ ],
302
+ triggers: [{ kind: "webhook", title: "Incident", titleField: "data.attributes.name" }],
303
+ does: ["Adds a webhook. Once the flow is made, open its Triggers and choose New address, then paste that into Better Stack.", "Reads the incident, its monitor's history and your recent commits and deploys, and sends you the likely cause.", "You choose Fix or Ignore; a fix is built as a task, under your usual approvals, and opens a pull request."],
304
+ never: NEVER_MERGES,
305
+ },
306
+ {
307
+ id: "worker-errors", name: "Worker errors to fix", group: "health", tools: ["cloudflare"],
308
+ promise: "Each week, Cloudflare Workers and Pages errors worth fixing get a fix.",
309
+ needs: ["GitHub", SORTS], asks: [WEEKLY],
310
+ steps: [
311
+ { id: "read", title: "Read the errors", kind: "report", instructions: research("With Cloudflare, read this week's Workers and Pages errors and analytics. Read only. List each error with its count, its Worker or Pages project, and when it started. Say “No errors” if there are none.") },
312
+ { id: "worth-fixing", title: "Worth fixing?", kind: "sort", question: "Do these errors come from this project's code, often enough to fix now?",
313
+ answers: [
314
+ { answer: "Fix it", means: "Errors from our own code that are new, frequent or hurt people", goesTo: "find-cause" },
315
+ { answer: "Ignore", means: "No errors, or only noise: bots, a provider's outage, something known", goesTo: "ignored" },
316
+ ], ifNotSure: "find-cause" },
317
+ { id: "find-cause", title: "Find the cause", kind: "report", instructions: research("Errors worth fixing:\n{{stage.read}}\n\nWith Cloudflare, read the worst one's logs. Read only. Find the cause in this code, citing files and lines, and say what change fixes it.") },
318
+ choose("Fix or ignore?", "Fix", "fix"),
319
+ { id: "fix", title: "Fix it", kind: "task", instructions: work("Fix this error, with a regression check where one fits.\n\nThe report:\n{{stage.find-cause}}") },
320
+ { ...pr, next: "Done" },
321
+ { id: "done", title: "Done", kind: "done" },
322
+ { id: "ignored", title: "Ignored", kind: "done" },
323
+ ],
324
+ triggers: [weekly("Worker errors")],
325
+ does: ["Reads your Cloudflare Workers and Pages errors and analytics {{ask.schedule-words}}.", "Jev decides whether they're worth fixing; if so, finds the cause and sends you a report.", "You choose Fix or Ignore; a fix is built as a task, under your usual approvals, and opens a pull request."],
326
+ never: NEVER_MERGES,
327
+ },
328
+ {
329
+ id: "failed-deploy", name: "Failed deploy to fix", group: "health", tools: ["vercel"],
330
+ promise: "A failed Vercel deployment gets its cause found and a fix opened.",
331
+ needs: ["A webhook from Vercel", "GitHub"], asks: [],
332
+ steps: [
333
+ { id: "find-cause", title: "Find the cause", kind: "report", instructions: research("A Vercel deployment failed: {{card.title}}\n\n{{card.description}}\n\nWith Vercel, read the deployment's build log. Read only. Find the cause in this code, citing files and lines, and say what change fixes it.") },
334
+ { id: "fix", title: "Fix it", kind: "task", instructions: work("Fix this failed deployment without weakening any check.\n\nThe report:\n{{stage.find-cause}}") },
335
+ pr,
336
+ send("Send the fix"),
337
+ ],
338
+ triggers: [{ kind: "webhook", title: "Failed deployment", titleField: "payload.deployment.name" }],
339
+ does: ["Adds a webhook. Once the flow is made, open its Triggers and choose New address, then paste that into Vercel for failed deployments.", "Reads the build log and finds the cause.", "Builds a fix as a task, under your usual approvals, opens a pull request and sends it to you."],
340
+ never: NEVER_MERGES,
341
+ },
236
342
  // ---------------------------------------------------------- Hear from users
237
343
  {
238
344
  id: "feedback-to-feature", name: "Feedback to feature", group: "users",
@@ -265,6 +371,48 @@ export const GALLERY = [
265
371
  id: "triage", name: "Issue triage", group: "users", promise: "Jev sorts new issues into bugs, ideas and questions, and says how urgent each is.", needs: [SORTS], asks: [], definition: drawn("triage"), triggers: [],
266
372
  does: [about("triage")], never: "Never closes an issue or replies without your approval.",
267
373
  },
374
+ {
375
+ id: "metrics-digest", name: "Metrics digest", group: "users", tools: ["posthog"],
376
+ promise: "Each week, this week against last: signups, activation, retention and top events.",
377
+ needs: [], asks: [WEEKLY],
378
+ steps: [
379
+ { id: "read", title: "Read the numbers", kind: "report", instructions: research("With PostHog, compare this week with last week. Read only. Give signups, activation, retention and the top 10 events, each with its change. Name the biggest change and its likely cause (a release, a campaign, a broken step), citing this week's commits where they fit.") },
380
+ send("Send the digest"),
381
+ ],
382
+ triggers: [weekly("Metrics digest")],
383
+ does: ["Compares this week's PostHog numbers with last week's, {{ask.schedule-words}}.", "Names the biggest change and its likely cause.", "Sends you the digest."],
384
+ never: "Never changes PostHog or your code.",
385
+ },
386
+ {
387
+ id: "fix-drop-off", name: "Fix the biggest drop-off", group: "users", tools: ["posthog"],
388
+ promise: "Each week, the funnel step losing the most people gets a proposed fix.",
389
+ needs: ["GitHub"], asks: [WEEKLY],
390
+ steps: [
391
+ { id: "find", title: "Find the drop-off", kind: "report", instructions: research("With PostHog, find the funnel step that lost the most people this week. Read only. Give the funnel, the step, how many reached it and how many left. Propose one fix in this code, citing files.") },
392
+ choose("Implement or ignore?", "Implement", "build"),
393
+ { id: "build", title: "Build the fix", kind: "task", instructions: work("Build the proposed fix for this drop-off.\n\nThe report:\n{{stage.find}}") },
394
+ pr,
395
+ send("Send the result"),
396
+ ],
397
+ triggers: [weekly("Biggest drop-off")],
398
+ does: ["Finds the PostHog funnel step losing the most people {{ask.schedule-words}}, with the numbers and one proposed fix.", "You choose Implement or Ignore.", "A fix is built as a task, under your usual approvals, opens a pull request and is sent to you."],
399
+ never: NEVER_MERGES,
400
+ },
401
+ {
402
+ id: "support-to-fix", name: "Support to bug fix", group: "users", tools: ["intercom"],
403
+ promise: "Each week, the bugs your customers hit most, ranked, with a fix for the one you pick.",
404
+ needs: ["GitHub"], asks: [WEEKLY],
405
+ steps: [
406
+ { id: "group", title: "Rank the bugs", kind: "report", instructions: research("With Intercom, read this week's conversations. Read only. Group the bug reports by problem, rank them by how many people hit each, and number them, with a count and one example conversation each.") },
407
+ { id: "choose", title: "Which to fix?", kind: "choose", options: [{ label: "Fix the top one", goesTo: "fix" }, { label: "Ignore", goesTo: "end" }], ifReplied: "fix" },
408
+ { id: "fix", title: "Fix it", kind: "task", instructions: work("Fix the bug chosen from this ranking: the top one, unless the note names another by number. Add a regression check where one fits.\n\nThe ranking:\n{{stage.group}}") },
409
+ pr,
410
+ send("Send the result"),
411
+ ],
412
+ triggers: [weekly("Support bugs")],
413
+ does: ["Reads your Intercom conversations {{ask.schedule-words}} and ranks the bug reports by how many people hit each.", "You fix the top one, reply with the number of another, or ignore them.", "The fix is built as a task, under your usual approvals, opens a pull request and is sent to you."],
414
+ never: NEVER_MERGES,
415
+ },
268
416
  // ------------------------------------------------------------------ Operations
269
417
  ...[
270
418
  ["research", "Research a question, have a person check it, then share it.", [], "Never shares an answer you haven't checked."],
@@ -281,6 +429,20 @@ export const GALLERY = [
281
429
  /** The blank flow, for starting from nothing. */
282
430
  export const BLANK = { id: "blank", name: "Blank flow", group: "ops", promise: about("blank"), needs: [], asks: [], definition: drawn("blank"), triggers: [], does: [about("blank")], never: "Does nothing until you add zones." };
283
431
  export const galleryTemplateOf = (id) => id === BLANK.id ? BLANK : GALLERY.find(one => one.id === id) ?? null;
432
+ /** A tool's name ("Mobbin", "Figma (desktop app)"); null for an id that's neither a one-click service nor an app on this computer. */
433
+ export const galleryToolLabel = (id) => oneClickOf(id)?.label ?? localConnectOf(id)?.label ?? null;
434
+ /** The zones that use a tool: a tool step on it, or a research or build step that names it ("With Mobbin, …"). */
435
+ export function zonesUsing(definition, id) {
436
+ const label = galleryToolLabel(id);
437
+ if (label === null)
438
+ return [];
439
+ const word = new RegExp(`\\b${label.replace(/\s*\(.*\)$/, "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
440
+ return definition.stages.filter(one => one.tool?.server === id || ((one.kind === "report" || one.kind === "task") && word.test(one.instructions ?? ""))).map(one => one.title);
441
+ }
442
+ /** A template's tools, each with its state in a project's connections (connectionsOf), or none without a project. */
443
+ export function galleryToolsOf(template, definition, connections) {
444
+ return (template.tools ?? []).map(id => ({ id, label: galleryToolLabel(id) ?? id, state: connections === null ? null : connections.find(one => one.id === id)?.state ?? "open", zones: zonesUsing(definition, id) }));
445
+ }
284
446
  /** Each question's default for a project: the branch pull requests go to, when that's set up. */
285
447
  export function galleryDefaults(store, template, repo) {
286
448
  const publishing = repo === null ? { on: false } : publishingOf(store, repo);
@@ -296,12 +458,17 @@ const oneLine = (value, cap, what) => {
296
458
  };
297
459
  /** The optional last step every template offers, and the answer that turns it on. */
298
460
  export const SEND_RESULT = { key: "send-result", title: "Send me the result", does: "When a card finishes, sends you what was done (its summary, links and screenshots) in your chat apps." };
299
- /** A flow with "Send me the result" before its end: every path into its main Done zone passes through it first. */
461
+ /** A template that already sends its result to the person: it isn't offered "Send me the result" again. */
462
+ export const sendsAlready = (template) => galleryDiagram(template).stages.some(one => one.kind === "send");
463
+ /** A flow with "Send me the result" before its end: every path into its main Done zone passes through it first.
464
+ * A flow that already has a send zone is left as it is, so nobody gets the result twice. */
300
465
  export function withSendResult(definition) {
466
+ if (definition.stages.some(one => one.kind === "send"))
467
+ return definition;
301
468
  // Its main end: Done, else an end that isn't a filtered-out one (drawn in rose), else any end.
302
469
  const ends = definition.stages.filter(one => one.kind === "done");
303
470
  const end = ends.find(one => one.id === "done") ?? ends.find(one => one.zone.color !== "rose") ?? ends[0];
304
- if (end === undefined || definition.stages.some(one => one.kind === "send" && one.next === end.id))
471
+ if (end === undefined)
305
472
  return definition;
306
473
  let id = "send-result";
307
474
  for (let n = 2; definition.stages.some(one => one.id === id); n++)
@@ -352,6 +519,11 @@ export function checkAnswers(template, given) {
352
519
  }
353
520
  else if (ask.key === "command")
354
521
  answers["command"] = oneLine(value, 500, "test command");
522
+ else if (ask.key === "team") {
523
+ if (!/^[A-Za-z0-9]{1,12}$/.test(value))
524
+ throw new Error("Say the Linear team as its short key, like ENG.");
525
+ answers["team"] = value.toUpperCase();
526
+ }
355
527
  else if (ask.key === "outdated") {
356
528
  const manager = OUTDATED_COMMANDS[value];
357
529
  if (manager === undefined)
@@ -420,6 +592,9 @@ export function buildFromGallery(store, template, repo, given) {
420
592
  const github = githubRepoOf(repo);
421
593
  const drawn = template.definition !== undefined ? structuredClone(template.definition)
422
594
  : flowFromSteps(fill(template.ownSteps !== undefined && isToolrollRepo(repo) ? template.ownSteps : template.steps, answers, names, github), null);
595
+ // One that already sends doesn't take the answer, so it reads as it was asked.
596
+ if (drawn.stages.some(one => one.kind === "send"))
597
+ delete answers[SEND_RESULT.key];
423
598
  const sends = answers[SEND_RESULT.key] === "yes";
424
599
  const definition = sends ? withSendResult(drawn) : drawn;
425
600
  return { template, repo, answers, definition, triggers: fill(template.triggers, answers, names, github), scripts,
@@ -440,7 +615,7 @@ export function previewGallery(store, template, repo, given, actor, now) {
440
615
  const steps = flowTerms(built.definition, null);
441
616
  if (built.scripts.some(one => !one.existing))
442
617
  steps.push(...built.scripts.filter(one => !one.existing).map(one => `Adds the ${one.name} script to this project: ${one.body.split("\n")[0]}${one.body.includes("\n") ? " …" : ""}`));
443
- return { built, startsFrom, steps, digest: galleryDigest(template.id, repo, built.answers) };
618
+ return { built, startsFrom, steps, tools: galleryToolsOf(template, built.definition, connectionsOf(store, repo)), digest: galleryDigest(template.id, repo, built.answers) };
444
619
  }
445
620
  /** What a preview showed: the template, the project and the answers. Creating checks the person saw these. */
446
621
  export function galleryDigest(id, repo, answers) {
@@ -1,10 +1,3 @@
1
- /**
2
- * What the lead can rely on right now, in one read (get_capabilities): the agents a project's route would run, the
3
- * workers that would run them, the project's tools and skills, the integrations, and the project's checks. Every entry
4
- * says what it lets the lead do and, when it can't, the one next step and the settings control (show_control) that
5
- * takes the owner there. Everything here reads what is already recorded — runner readiness reports, sign-in pauses,
6
- * plan-limit readings, tool tests, skill tests, integration checks — and never probes anything that spends.
7
- */
8
1
  import type { Store } from "./store.js";
9
2
  import type { ChatControl } from "./chat-controls.js";
10
3
  import type { ProviderId } from "./provider.js";
@@ -1,3 +1,11 @@
1
+ /**
2
+ * What the lead can rely on right now, in one read (get_capabilities): the agents a project's route would run, the
3
+ * workers that would run them, the project's tools and skills, the integrations, and the project's checks. Every entry
4
+ * says what it lets the lead do and, when it can't, the one next step and the settings control (show_control) that
5
+ * takes the owner there. Everything here reads what is already recorded — runner readiness reports, sign-in pauses,
6
+ * plan-limit readings, tool tests, skill tests, integration checks — and never probes anything that spends.
7
+ */
8
+ import { projectBatchChecks } from "./batch-policy.js";
1
9
  import { resolveRouteCandidates } from "./agentconfig.js";
2
10
  import { authPauseOf, messageCommand, providerName } from "./provider-auth.js";
3
11
  import { readAuthMode, SUBSCRIPTION_CAPABLE } from "./keys.js";
@@ -136,7 +144,9 @@ function checksOf(store, repo) {
136
144
  return { ...base, state: "Off", ok: false, next: "Builds are not checked. Turn checks on for the project if results need them.", link: "projects" };
137
145
  if (!releaseCheck)
138
146
  return { ...base, state: `${CHECK_LEVEL_WORDS[level]} · no release check`, ok: false, next: "No release check is set up, so no result can show a passing check. Set one for the project.", link: "projects" };
139
- return { ...base, state: level === "quick" && !quickCheck ? "Quick · runs the release check" : `${CHECK_LEVEL_WORDS[level]} · release check set`, ok: true, next: null, link: null };
147
+ // Batch checks are changed in Settings → Projects → Checks (or `project checks --batch on|off`), never from chat.
148
+ const batched = level === "full" && projectBatchChecks(store, repo).on ? " · batched with results that finish together" : "";
149
+ return { ...base, state: level === "quick" && !quickCheck ? "Quick · runs the release check" : `${CHECK_LEVEL_WORDS[level]} · release check set${batched}`, ok: true, next: null, link: null };
140
150
  }
141
151
  function workersOf(store, repos, now) {
142
152
  const live = store.handle.prepare(`SELECT ref.external_id AS task, ref.repo AS repo FROM claim JOIN task_ref AS ref ON ref.id = claim.task_ref
@@ -15,7 +15,7 @@ import { executeSharedAction, sharedActionPayload, sharedActionAllowsChallenge,
15
15
  * silent re-read.
16
16
  */
17
17
  import { isCheckLevel, setTaskCheckLevel } from "./check-levels.js";
18
- import { verifiedAuthor } from "./store.js";
18
+ import { mateTurnKeepsProposals, verifiedAuthor } from "./store.js";
19
19
  import { isVerifiedApprover, reproveApprover } from "./principal.js";
20
20
  import { TeamLeads } from './team-leads.js';
21
21
  import { fileTaskProposal } from "./proposal.js";
@@ -97,7 +97,7 @@ export function confirmMateProposal(store, who, proposalId, now, options) {
97
97
  return { ok: false, kind: proposal.kind, reason: "ceiling-changed", said: "the admitted projects changed since this was proposed — it cannot be confirmed" };
98
98
  }
99
99
  const turn = store.getMateTurn(proposal.turn);
100
- if (turn === null || turn.state !== "answered") {
100
+ if (!mateTurnKeepsProposals(turn) || proposal.state === "drafting") {
101
101
  return { ok: false, kind: proposal.kind, reason: "turn-not-answered", said: "this proposal's turn did not finish — it cannot be confirmed" };
102
102
  }
103
103
  if (proposal.kind === "cancel") {
package/dist/mate.d.ts CHANGED
@@ -93,6 +93,7 @@ export type MateTurnOutcome = {
93
93
  failed: MateFailure;
94
94
  message: string;
95
95
  unknownSpend: boolean;
96
+ saved?: boolean;
96
97
  };
97
98
  /** What happened, what it means, and one next step: plain words a person can act on, never internal terms. */
98
99
  export declare const MATE_REFUSAL_COPY: Record<MateRefusal, string>;
@@ -102,7 +103,7 @@ export declare const MATE_CHANNEL_COPY: Record<MateChannelProblem, string>;
102
103
  export declare const MATE_FAILURE_COPY: {
103
104
  readonly stopped: "This reply was stopped before it finished, from another window or by a newer message. Nothing it proposed was kept. Send your message again if you still need an answer.";
104
105
  readonly ended: "Your sign-in or this conversation ended while the lead was answering. Nothing it proposed was kept. Sign in again or start a new chat, then send your message again.";
105
- readonly tooLong: "The reply took too long and was stopped. Nothing it proposed was kept. Send your message again, or ask for less at once.";
106
+ readonly tooLong: string;
106
107
  readonly secretRead: "Something the lead read looked like a password or key, so it stopped before sending it anywhere. Nothing was kept. Remove the password or key from that task or note, then ask again.";
107
108
  readonly secretReply: "The lead's reply contained something that looked like a password or key, so it was thrown away. Nothing was kept. Ask again, without asking for a password or key.";
108
109
  readonly paused: "An earlier reply stopped before its cost was known, so chat is paused. This message wasn't answered. Confirm that earlier reply on the Chat page, then send again.";
@@ -111,7 +112,7 @@ export declare const MATE_FAILURE_COPY: {
111
112
  readonly unreadable: "The chat provider sent back an answer that couldn't be read, so it was thrown away. Nothing was kept. Send your message again.";
112
113
  readonly signIn: "The chat provider couldn't answer; its sign-in may have expired. Nothing was kept. Sign in to it again on this computer, then send your message again.";
113
114
  readonly refused: "The chat provider turned the request down. Nothing was kept or charged. Try again in a minute; if it keeps happening, check the provider in Settings → Lead.";
114
- readonly tooLongUnknownCost: "The reply took too long and was stopped, and its cost isn't known yet, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.";
115
+ readonly tooLongUnknownCost: string;
115
116
  readonly lostUnknownCost: "The chat provider stopped responding partway through, and the cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.";
116
117
  readonly unreadableUnknownCost: "The chat provider sent back an answer that couldn't be read, and its cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.";
117
118
  readonly empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.";
@@ -119,6 +120,9 @@ export declare const MATE_FAILURE_COPY: {
119
120
  };
120
121
  /** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry). */
121
122
  export declare function mateFailureText(reason: string | null): string;
123
+ /** How long a provider that ignored its abort at the turn's deadline is waited for before the turn ends without it.
124
+ * Its process group was already killed at the deadline; this only bounds the wait for the transport to say so. */
125
+ export declare const MATE_ABORT_GRACE_MS = 5000;
122
126
  /** The last messages of the thread as provider-neutral history, newest kept first until the byte cap. */
123
127
  export declare function historyFor(store: Store, thread: number, queuedMessageId?: number): MateHistoryMessage[];
124
128
  /** "read 3 · proposed 1 · 2 steps" — counts only, never results (ruling 11). */
package/dist/mate.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { mateToolLabel } from "./mate-progress.js";
3
3
  import { createHash } from "node:crypto";
4
+ import { mateTimeoutNotice } from "./store.js";
4
5
  import { RESULT_IMAGES_PER_TURN_CAP } from "./chat-evidence.js";
5
6
  import { isVerifiedApprover, reproveApprover } from "./principal.js";
6
7
  import { MATE_MAX_CALLS_PER_STEP, MATE_MAX_STEPS, MATE_TOOL_RESULT_CAP_BYTES, TURN_WALL_CLOCK_MS, composeMateRequest, credentialKeyOf, isDirectChatProvider, mateWorstCaseForPrice, performMateRequest, priceForConfig, settleForPrice, subscriptionCredentialKey, } from "./converse.js";
@@ -56,7 +57,7 @@ export const MATE_CHANNEL_COPY = {
56
57
  export const MATE_FAILURE_COPY = {
57
58
  stopped: "This reply was stopped before it finished, from another window or by a newer message. Nothing it proposed was kept. Send your message again if you still need an answer.",
58
59
  ended: "Your sign-in or this conversation ended while the lead was answering. Nothing it proposed was kept. Sign in again or start a new chat, then send your message again.",
59
- tooLong: "The reply took too long and was stopped. Nothing it proposed was kept. Send your message again, or ask for less at once.",
60
+ tooLong: mateTimeoutNotice(false),
60
61
  secretRead: "Something the lead read looked like a password or key, so it stopped before sending it anywhere. Nothing was kept. Remove the password or key from that task or note, then ask again.",
61
62
  secretReply: "The lead's reply contained something that looked like a password or key, so it was thrown away. Nothing was kept. Ask again, without asking for a password or key.",
62
63
  paused: "An earlier reply stopped before its cost was known, so chat is paused. This message wasn't answered. Confirm that earlier reply on the Chat page, then send again.",
@@ -65,7 +66,7 @@ export const MATE_FAILURE_COPY = {
65
66
  unreadable: "The chat provider sent back an answer that couldn't be read, so it was thrown away. Nothing was kept. Send your message again.",
66
67
  signIn: "The chat provider couldn't answer; its sign-in may have expired. Nothing was kept. Sign in to it again on this computer, then send your message again.",
67
68
  refused: "The chat provider turned the request down. Nothing was kept or charged. Try again in a minute; if it keeps happening, check the provider in Settings → Lead.",
68
- tooLongUnknownCost: "The reply took too long and was stopped, and its cost isn't known yet, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
69
+ tooLongUnknownCost: mateTimeoutNotice(false, true),
69
70
  lostUnknownCost: "The chat provider stopped responding partway through, and the cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
70
71
  unreadableUnknownCost: "The chat provider sent back an answer that couldn't be read, and its cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
71
72
  empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.",
@@ -74,7 +75,8 @@ export const MATE_FAILURE_COPY = {
74
75
  /** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry). */
75
76
  export function mateFailureText(reason) {
76
77
  switch (reason) {
77
- case "timeout": return MATE_FAILURE_COPY.tooLong;
78
+ case "timeout":
79
+ case "crashed": return MATE_FAILURE_COPY.tooLong;
78
80
  case "malformed-reply": return MATE_FAILURE_COPY.unreadable;
79
81
  case "secret-refused": return MATE_FAILURE_COPY.secretReply;
80
82
  case "latched": return MATE_FAILURE_COPY.paused;
@@ -84,6 +86,16 @@ export function mateFailureText(reason) {
84
86
  default: return "The lead's reply didn't finish. Nothing it proposed was kept. Send your message again.";
85
87
  }
86
88
  }
89
+ /** How long a provider that ignored its abort at the turn's deadline is waited for before the turn ends without it.
90
+ * Its process group was already killed at the deadline; this only bounds the wait for the transport to say so. */
91
+ export const MATE_ABORT_GRACE_MS = 5_000;
92
+ const LATE = Symbol("late");
93
+ /** The work's own result, or LATE once `ms` passes: the turn's clock, not the work, decides when the person hears back. */
94
+ function settleBy(work, ms) {
95
+ let timer;
96
+ const late = new Promise(resolve => { timer = setTimeout(() => resolve(LATE), Math.max(0, ms)); });
97
+ return Promise.race([work, late]).finally(() => clearTimeout(timer));
98
+ }
87
99
  const READ_TOOLS = new Set(["get_brief", "get_project_context", "get_actions", "get_action_status", "get_skills", "get_acceptance_evidence", "recap", "list_repos", "list_tasks", "get_task", "get_result", "get_result_images", "get_controls", "get_agents", "get_project_knowledge", "get_task_conversation", "get_diff", "get_check_log", "get_project_tools", "get_flows", "get_flow_insights", "list_decisions", "get_decision", "queue"]);
88
100
  /** The last messages of the thread as provider-neutral history, newest kept first until the byte cap. */
89
101
  export function historyFor(store, thread, queuedMessageId) {
@@ -291,6 +303,20 @@ export async function runMateTurn(input) {
291
303
  store.dropMateAsk(turnId);
292
304
  return { ok: false, turn: turnId, failed, message, unknownSpend };
293
305
  };
306
+ /** The turn stopped at its deadline: the notice is saved in the thread with whatever its completed tool calls proposed, in
307
+ * the same write that ends the turn, so a refresh, another device or a restart shows the same outcome. */
308
+ const timedOut = (unknownSpend) => {
309
+ now = clock();
310
+ const message = mateTimeoutNotice(proposals > 0, unknownSpend);
311
+ const saved = store.finalizeMateTurn(turnId, started.generation, { state: "failed", settledMicrousd: settled, unknownSpend, tokensIn, tokensOut, failureReason: "timeout", keepProposals: true, message: { text: message, activity: activitySummary(reads, proposals, steps) } }, now);
312
+ store.dropMateAsk(turnId);
313
+ if (!saved)
314
+ return { ok: false, turn: turnId, failed: "superseded", message: MATE_FAILURE_COPY.stopped, unknownSpend: false };
315
+ return { ok: false, turn: turnId, failed: "timeout", message, unknownSpend, saved: true };
316
+ };
317
+ const deadlineLeft = () => TURN_WALL_CLOCK_MS - (clock().getTime() - turnStartedAt);
318
+ /** The channel's standing, bounded by the turn's deadline: a lookup that never answers cannot hold the turn open. */
319
+ const revalidated = async () => input.revalidate === undefined ? { ok: true } : settleBy(input.revalidate(), deadlineLeft() + MATE_ABORT_GRACE_MS);
294
320
  /** The turn's own row, re-read: still running under our generation, or someone ended it under us. */
295
321
  const stillOurs = () => {
296
322
  const row = store.getMateTurn(turnId);
@@ -301,6 +327,8 @@ export async function runMateTurn(input) {
301
327
  const guard = (channel) => {
302
328
  if (!stillOurs())
303
329
  return { ok: false, turn: turnId, failed: "superseded", message: MATE_FAILURE_COPY.stopped, unknownSpend: false };
330
+ if (channel === LATE)
331
+ return timedOut(false);
304
332
  if (!channel.ok)
305
333
  return fail("revoked", MATE_CHANNEL_COPY[channel.reason] ?? MATE_CHANNEL_COPY["access-changed"], false);
306
334
  const standing = reproveApprover(store, who);
@@ -328,13 +356,13 @@ export async function runMateTurn(input) {
328
356
  return claim;
329
357
  };
330
358
  while (steps < maxSteps) {
331
- const blocked = guard(input.revalidate === undefined ? { ok: true } : await input.revalidate());
359
+ const blocked = guard(await revalidated());
332
360
  if (blocked !== null)
333
361
  return blocked;
334
362
  now = clock();
335
363
  const remainingMs = TURN_WALL_CLOCK_MS - (now.getTime() - turnStartedAt);
336
364
  if (remainingMs <= 0)
337
- return fail("timeout", MATE_FAILURE_COPY.tooLong, false);
365
+ return timedOut(false);
338
366
  const request = direct ? composeDirect(input.key) : composeSubscription();
339
367
  // Tool results join the outbound body: scanned again before every dispatch.
340
368
  const outbound = typeof request === "string" ? request : request.body;
@@ -354,39 +382,34 @@ export async function runMateTurn(input) {
354
382
  progress({ kind: "step", turn: turnId, step: steps });
355
383
  const requestBytes = Buffer.byteLength(outbound, "utf8");
356
384
  let result;
385
+ // The turn owns its deadline (lead-stall, release check 2438): at the deadline the request is aborted, which ends a
386
+ // harness's whole process group, and a provider that still has not settled MATE_ABORT_GRACE_MS later is left behind.
387
+ // Whatever it says after that is never read; the turn has already ended with its notice.
388
+ const controller = new AbortController();
389
+ const abortAt = setTimeout(() => controller.abort(), remainingMs);
390
+ const settle = (work) => settleBy(work, remainingMs + MATE_ABORT_GRACE_MS)
391
+ .then(value => value === LATE || controller.signal.aborted ? { ok: false, problem: "timeout" } : value)
392
+ .finally(() => clearTimeout(abortAt));
357
393
  if (direct) {
358
- const controller = new AbortController();
359
- const timer = setTimeout(() => controller.abort(), remainingMs);
360
- try {
361
- result = await performMateRequest(request, directProvider, controller.signal, fetcher);
362
- }
363
- catch {
364
- result = { ok: false, problem: "network" };
365
- }
366
- finally {
367
- clearTimeout(timer);
368
- }
394
+ result = await settle(performMateRequest(request, directProvider, controller.signal, fetcher)
395
+ .catch(() => ({ ok: false, problem: "network" })));
369
396
  }
370
397
  else {
371
398
  const runner = input.subscriptionRunner ?? performSubscriptionMateRequest;
372
- try {
373
- result = await runner({
374
- provider: subscriptionProvider,
375
- model: config.model,
376
- system: MATE_CONTRACT,
377
- dataDocument: document,
378
- history,
379
- tools: MATE_TOOL_SCHEMAS,
380
- timeoutMs: remainingMs,
381
- // The reply as it is written; text that looks like a secret is
382
- // never shown (the finished reply is scanned again before saving).
383
- ...(input.onProgress === undefined ? {} : { onText: (text) => { if (scanForSecrets(text).length === 0)
384
- progress({ kind: "text", turn: turnId, step: steps, text }); } }),
385
- });
386
- }
387
- catch {
388
- result = { ok: false, problem: "provider-error" };
389
- }
399
+ result = await settle((async () => runner({
400
+ provider: subscriptionProvider,
401
+ model: config.model,
402
+ system: MATE_CONTRACT,
403
+ dataDocument: document,
404
+ history,
405
+ tools: MATE_TOOL_SCHEMAS,
406
+ timeoutMs: remainingMs,
407
+ signal: controller.signal,
408
+ // The reply as it is written; text that looks like a secret is
409
+ // never shown (the finished reply is scanned again before saving).
410
+ ...(input.onProgress === undefined ? {} : { onText: (text) => { if (!controller.signal.aborted && scanForSecrets(text).length === 0)
411
+ progress({ kind: "text", turn: turnId, step: steps, text }); } }),
412
+ }))().catch(() => ({ ok: false, problem: "provider-error" })));
390
413
  }
391
414
  now = clock();
392
415
  const finishStep = (outcome) => store.finalizeChatTurn(step.id, stepStarted.generation, outcome, now);
@@ -400,7 +423,9 @@ export async function runMateTurn(input) {
400
423
  : result.problem === "malformed-reply"
401
424
  ? MATE_FAILURE_COPY.unreadable
402
425
  : MATE_FAILURE_COPY.signIn;
403
- return fail(result.problem === "timeout" ? "timeout" : result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", message, false);
426
+ if (result.problem === "timeout")
427
+ return timedOut(false);
428
+ return fail(result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", message, false);
404
429
  }
405
430
  if (result.problem.startsWith("status-")) {
406
431
  // The provider ANSWERED with an error: nothing billed for this step.
@@ -409,7 +434,7 @@ export async function runMateTurn(input) {
409
434
  }
410
435
  if (result.problem === "timeout") {
411
436
  finishStep({ state: "failed", failureReason: "timeout", settledMicrousd: null, unknownSpend: true });
412
- return fail("timeout", MATE_FAILURE_COPY.tooLongUnknownCost, true);
437
+ return timedOut(true);
413
438
  }
414
439
  if (result.problem === "network") {
415
440
  finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: null, unknownSpend: true });
@@ -437,7 +462,7 @@ export async function runMateTurn(input) {
437
462
  // After the wait (finding 5): the row may have been failed under us by
438
463
  // a revocation or the sweep — then nothing the model said runs; and the
439
464
  // approver must still stand before any tool runs as them.
440
- const changed = guard(input.revalidate === undefined ? { ok: true } : await input.revalidate());
465
+ const changed = guard(await revalidated());
441
466
  if (changed !== null)
442
467
  return changed;
443
468
  if (answer.calls.length === 0) {
@@ -470,7 +495,7 @@ export async function runMateTurn(input) {
470
495
  history.push({ role: "assistant", text: answer.text, calls: answer.calls });
471
496
  for (const [index, call] of answer.calls.entries()) {
472
497
  if (index > 0) {
473
- const changed = guard(input.revalidate === undefined ? { ok: true } : await input.revalidate());
498
+ const changed = guard(await revalidated());
474
499
  if (changed !== null)
475
500
  return changed;
476
501
  }
@@ -80,12 +80,13 @@ type Server = {
80
80
  scopes: string[];
81
81
  resource: string | null;
82
82
  };
83
- /** A sign-in on its way: what the callback needs to finish it (kept in the console's memory, 15 minutes). */
83
+ /** A sign-in on its way: what the callback needs to finish it (kept in the console's memory, 15 minutes). `kit` or `template` is the page it returns to. */
84
84
  export type ConnectVisit = {
85
85
  service: string;
86
86
  repo: string;
87
87
  by: string;
88
88
  kit: string | null;
89
+ template: string | null;
89
90
  verifier: string;
90
91
  clientId: string;
91
92
  clientSecret: string | null;
@@ -107,6 +108,7 @@ export declare function startConnect(input: {
107
108
  by: string;
108
109
  origin: string;
109
110
  kit?: string | null;
111
+ template?: string | null;
110
112
  }, fetcher?: Fetch, now?: number): Promise<{
111
113
  ok: true;
112
114
  go: string;