toolroll 0.9.0 → 0.9.1

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 (43) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/agent-onboard.d.ts +21 -1
  3. package/dist/agent-onboard.js +50 -1
  4. package/dist/backup-ui.js +4 -2
  5. package/dist/browser/workspace.css +2 -2
  6. package/dist/browser/workspace.js +31 -31
  7. package/dist/browser-workspace.d.ts +12 -2
  8. package/dist/chat-actions.d.ts +5 -0
  9. package/dist/chat-actions.js +43 -3
  10. package/dist/cli.d.ts +1 -1
  11. package/dist/cli.js +1 -1
  12. package/dist/desktop-update.d.ts +1 -1
  13. package/dist/flow-engine.js +13 -1
  14. package/dist/flow-pull-request.d.ts +11 -0
  15. package/dist/flow-pull-request.js +123 -0
  16. package/dist/flow-starters-ui.d.ts +20 -0
  17. package/dist/flow-starters-ui.js +35 -0
  18. package/dist/flow-starters.d.ts +55 -0
  19. package/dist/flow-starters.js +104 -0
  20. package/dist/flow-steps.js +14 -0
  21. package/dist/flows-ui.d.ts +1 -1
  22. package/dist/flows-ui.js +4 -3
  23. package/dist/flows.d.ts +30 -6
  24. package/dist/flows.js +110 -6
  25. package/dist/integrations-ui.js +9 -4
  26. package/dist/lead-context.d.ts +1 -1
  27. package/dist/lead-context.js +1 -1
  28. package/dist/mate-contract.js +1 -1
  29. package/dist/mate-tools.js +18 -5
  30. package/dist/models-ui.js +3 -2
  31. package/dist/monitoring-ui.js +2 -1
  32. package/dist/operate.js +18 -0
  33. package/dist/policy-ui.js +2 -1
  34. package/dist/pull-request-flow.d.ts +32 -0
  35. package/dist/pull-request-flow.js +43 -8
  36. package/dist/pull-request-ui.js +2 -2
  37. package/dist/serve.js +108 -43
  38. package/dist/skills-ui.js +2 -1
  39. package/dist/store.js +2 -0
  40. package/dist/surface.js +1 -1
  41. package/dist/when-html.d.ts +8 -0
  42. package/dist/when-html.js +36 -0
  43. package/package.json +1 -1
@@ -322,6 +322,11 @@ export type BrowserTaskView = {
322
322
  html: string;
323
323
  open: boolean;
324
324
  } | null;
325
+ /** "Do this every time…": Settings → Flows with the matching starter flow marked. */
326
+ everyTime?: {
327
+ href: string;
328
+ starter: string;
329
+ } | null;
325
330
  };
326
331
  /** One project on the Projects page; opening it is a POST to /projects/open. */
327
332
  export type BrowserProjectRow = {
@@ -467,7 +472,7 @@ export type BrowserResultView = {
467
472
  export type BrowserFlowStage = {
468
473
  id: string;
469
474
  title: string;
470
- kind: "inbox" | "task" | "report" | "approval" | "check" | "update" | "notify" | "sort" | "draft" | "request" | "email" | "tool" | "wait" | "teammate" | "done";
475
+ kind: "inbox" | "task" | "report" | "approval" | "check" | "pull-request" | "update" | "notify" | "sort" | "draft" | "request" | "email" | "tool" | "wait" | "teammate" | "done";
471
476
  zone: {
472
477
  x: number;
473
478
  y: number;
@@ -525,9 +530,14 @@ export type BrowserFlowStage = {
525
530
  } | null;
526
531
  /** v91: a Wait zone waits for a reply to the card's email (onFail: no reply in time) or a set time; any other zone may have a time limit. */
527
532
  wait?: {
528
- for: "reply" | "time";
533
+ for: "reply" | "time" | "hours";
529
534
  minutes: number;
535
+ from?: string;
536
+ to?: string;
537
+ timeZone?: string;
530
538
  };
539
+ /** A Pull request zone merges once checks pass (after a person approved), this way. */
540
+ merge?: "squash" | "merge" | "rebase" | undefined;
531
541
  limit?: {
532
542
  minutes: number;
533
543
  to: string | null;
@@ -61,6 +61,11 @@ export declare const CHAT_ACTIONS: {
61
61
  readonly protected: false;
62
62
  readonly password: false;
63
63
  };
64
+ readonly flow_starter: {
65
+ readonly label: "Switch on";
66
+ readonly protected: false;
67
+ readonly password: false;
68
+ };
64
69
  readonly flow_edit: {
65
70
  readonly label: "Save flow";
66
71
  readonly protected: false;
@@ -28,6 +28,7 @@ import { cleanMemory, editMemory, forgetMemory, tellTeammate } from "./teammate-
28
28
  import { addRoutine, removeRoutine, routineSchedule, routinesOf } from "./teammate-desk.js";
29
29
  import { requestUndo, undoFor } from "./teammate-week.js";
30
30
  import { kitInstalled, kitOf, KITS, setUpKitNow } from "./kits.js";
31
+ import { starterFlowOf, starterOf, startersFor, starterTerms, STARTER_FLOWS, switchOnStarter } from "./flow-starters.js";
31
32
  import { answerTeammateQuestion } from "./teammate-work.js";
32
33
  import { callWords, checkRule, defaultRule, grantListed, revokeTool, ruleWords, setToolRules } from "./teammate-tools.js";
33
34
  /** A tool from the lead's card: a common tool by its id, or the operator's own program or address. */
@@ -77,6 +78,8 @@ export const CHAT_ACTIONS = {
77
78
  tool_remove: { label: "Remove tool", protected: false, password: false },
78
79
  // Flows: a drawing and cards on it. Any work a card files is an ordinary task under the usual approvals.
79
80
  flow_create: { label: "Create flow", protected: false, password: false },
81
+ // A starter flow: its zones and its trigger, switched on with one yes (flow-starters.ts).
82
+ flow_starter: { label: "Switch on", protected: false, password: false },
80
83
  flow_edit: { label: "Save flow", protected: false, password: false },
81
84
  flow_card_add: { label: "Add card", protected: false, password: false },
82
85
  flow_card_move: { label: "Move card", protected: false, password: false },
@@ -128,7 +131,8 @@ export const CHAT_ACTION_FIELDS = {
128
131
  knowledge_restore: ["repo", "restore"],
129
132
  tool_add: ["repo", "catalog", "name", "command", "args", "url", "secrets", "about"],
130
133
  tool_remove: ["repo", "name"],
131
- flow_create: ["repo", "name", "definition"],
134
+ flow_create: ["repo", "name", "definition", "trigger"],
135
+ flow_starter: ["repo", "starter"],
132
136
  flow_edit: ["flow", "name", "definition"],
133
137
  flow_card_add: ["flow", "title", "description", "zone"],
134
138
  flow_card_move: ["card", "zone"],
@@ -265,7 +269,7 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
265
269
  const task = operation.startsWith("skill_") || operation.startsWith("knowledge_") || operation.startsWith("decision_") || operation.startsWith("tool_") || operation.startsWith("flow_") || operation.startsWith("teammate_") || operation.startsWith("kit_")
266
270
  ? null
267
271
  : text(input, "task", 64);
268
- const flowTarget = operation.startsWith("flow_") && operation !== "flow_create" && operation !== "flow_script_save" ? flowTargetOf(store, input) : null;
272
+ const flowTarget = operation.startsWith("flow_") && operation !== "flow_create" && operation !== "flow_script_save" && operation !== "flow_starter" ? flowTargetOf(store, input) : null;
269
273
  // v92: a teammate names its project; a question names it through its card's flow.
270
274
  const mateTarget = operation.startsWith("teammate_") && operation !== "teammate_create" && operation !== "teammate_answer" ? store.getTeammate(integer(input, "teammate")) : null;
271
275
  if (mateTarget !== null && mateTarget.state === "removed")
@@ -551,6 +555,28 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
551
555
  state = {};
552
556
  title = `Create the ${name} flow in ${project}`;
553
557
  terms.push(...flowTerms(definition, null));
558
+ // A template's trigger (Issues to PRs: labelled issues) comes with it, checked as the flow will have it.
559
+ if (input["trigger"] !== undefined) {
560
+ const draft = { id: 0, repo, name, definitionJson: JSON.stringify(definition), revision: 1, state: "active", createdBy: who.name, createdAt: now.toISOString(), updatedBy: who.name, updatedAt: now.toISOString(), owner: who.name };
561
+ const config = validateTriggerConfig(input["trigger"], { store, flow: draft, definition, actor: who.name });
562
+ if (takesDeliveries(config))
563
+ throw Error("Webhook addresses are secrets, so they are set up on the flow's Triggers panel.");
564
+ request["trigger"] = config;
565
+ terms.push(`Starts cards from: ${describeTrigger(config, store)}. Only what happens from now on counts.`);
566
+ }
567
+ }
568
+ else if (operation === "flow_starter") {
569
+ const starter = starterOf(String(input["starter"] ?? ""));
570
+ if (starter === null)
571
+ throw Error(`Choose a starter flow: ${STARTER_FLOWS.map(one => one.id).join(", ")}.`);
572
+ if (starterFlowOf(store, starter, repo) !== null)
573
+ throw Error(`${starter.name} is already on in ${project}.`);
574
+ const blocked = startersFor(store, repo).find(one => one.id === starter.id)?.blocked ?? null;
575
+ if (blocked !== null)
576
+ throw Error(blocked);
577
+ state = {};
578
+ title = `Switch on ${starter.name} in ${project}`;
579
+ terms.push(...starterTerms(store, starter, repo));
554
580
  }
555
581
  else if (operation === "flow_edit") {
556
582
  const { flow, definition: before } = flowTarget;
@@ -1214,9 +1240,23 @@ function runFlowAction(store, payload, actor, repos, root, now) {
1214
1240
  return { said: `${said}${filed}`, href: card === null ? `/flows/${flow}` : flowCardHref(flow, card) };
1215
1241
  };
1216
1242
  if (payload.operation === "flow_create") {
1217
- const id = store.createFlow({ repo: payload.repo, name: String(req["name"]), definitionJson: JSON.stringify(req["definition"]), by: actor }, now);
1243
+ const id = store.transact(() => {
1244
+ const made = store.createFlow({ repo: payload.repo, name: String(req["name"]), definitionJson: JSON.stringify(req["definition"]), by: actor }, now);
1245
+ if (req["trigger"] !== undefined) {
1246
+ const trigger = addFlowTriggerTo(store, store.getFlow(made), req["trigger"], actor, now, null);
1247
+ if (!trigger.ok)
1248
+ throw Error(trigger.message);
1249
+ }
1250
+ return made;
1251
+ });
1218
1252
  return { said: "Flow created. Add cards to it here or on its canvas.", href: `/flows/${id}` };
1219
1253
  }
1254
+ if (payload.operation === "flow_starter") {
1255
+ const switched = switchOnStarter(store, starterOf(String(req["starter"])), payload.repo, actor, now, configDirOf(store));
1256
+ if (!switched.ok)
1257
+ throw Error(switched.said);
1258
+ return { said: switched.said, href: `/flows/${switched.flow}` };
1259
+ }
1220
1260
  if (payload.operation === "flow_edit") {
1221
1261
  const flow = Number(payload.state["flow"]);
1222
1262
  if (!store.saveFlow(flow, { name: String(req["name"]), definitionJson: JSON.stringify(req["definition"]), sawRevision: Number(payload.state["revision"]), by: actor }, now))
package/dist/cli.d.ts CHANGED
@@ -39,7 +39,7 @@ export type ParseResult = {
39
39
  error: string;
40
40
  };
41
41
  type Write = (line: string) => void;
42
- export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> connect one (no path: the repo you are in)\n toolroll repos remove <path>\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll integrations which integrations work, and what to do about the ones that don't (--json)\n toolroll session native coding sessions through the running service\n toolroll connect save a private connection to your central service\n toolroll lead named leads on the connected service\n toolroll conversation shared and private conversations on that service\n toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll chat --as <you> --token <t>\n talk to the mate: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
42
+ export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> connect one (no path: the repo you are in)\n toolroll repos remove <path>\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll integrations which integrations work, and what to do about the ones that don't (--json)\n toolroll session native coding sessions through the running service\n toolroll connect save a private connection to your central service\n toolroll lead named leads on the connected service\n toolroll conversation shared and private conversations on that service\n toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll chat --as <you> --token <t>\n talk to the mate: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
43
43
  export declare function parseArgs(argv: readonly string[]): ParseResult;
44
44
  /** The commands that operate the queue rather than report on the world. */
45
45
  /** Every verb the CLI routes OUTSIDE runOperate. Exported (with
package/dist/cli.js CHANGED
@@ -59,7 +59,7 @@ Usage
59
59
  toolroll skills get <name> print one guide (version-matched, never stale)
60
60
  toolroll demo a seeded throwaway sandbox — see it working in 90 seconds
61
61
  toolroll up app + builder for every saved project — the normal start
62
- toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes)
62
+ toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)
63
63
  toolroll status running, queued, ready results, release check and plan windows
64
64
  toolroll integrations which integrations work, and what to do about the ones that don't (--json)
65
65
  toolroll session native coding sessions through the running service
@@ -66,6 +66,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
66
66
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
67
67
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
68
68
  export declare function desktopUpdateStatus(stateDir: string): {
69
+ version?: never;
69
70
  active: boolean;
70
71
  phase: string;
71
72
  detail: string;
@@ -79,7 +80,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
79
80
  wasRunning?: never;
80
81
  backupPath?: never;
81
82
  updateId?: never;
82
- version?: never;
83
83
  buildId?: never;
84
84
  workDir?: never;
85
85
  } | {
@@ -8,7 +8,7 @@
8
8
  import { assignmentOf } from "./assignment.js";
9
9
  import { filerFor } from "./approval-policy.js";
10
10
  import { diagnoseTaskDispatch } from "./dispatch.js";
11
- import { deciderOf, durationWords, fillFlowText, validateFlowDefinition } from "./flows.js";
11
+ import { deciderOf, durationWords, fillFlowText, validateFlowDefinition, withinHours } from "./flows.js";
12
12
  import { reportSummaryFor } from "./report-summary.js";
13
13
  import { fileTaskProposal } from "./proposal.js";
14
14
  import { requestResultChanges } from "./result-actions.js";
@@ -138,6 +138,17 @@ function advanceCard(store, flow, definition, card, now, options, outcome) {
138
138
  return;
139
139
  case "wait": {
140
140
  const wait = stage.wait ?? { for: "reply", minutes: 24 * 60 };
141
+ // Set hours (like 22:00–06:00): a card goes on once the clock is inside them, at once when it already is.
142
+ if (wait.for === "hours") {
143
+ if (withinHours(wait, now)) {
144
+ onward("ok");
145
+ return;
146
+ }
147
+ const waiting = `Waiting until ${wait.from}.`;
148
+ if (card.waiting !== waiting)
149
+ store.updateFlowCard(card.id, { waiting }, now);
150
+ return;
151
+ }
141
152
  const entered = Date.parse(store.flowCardEnteredAt(card.id) ?? card.updatedAt);
142
153
  const words = durationWords(wait.minutes);
143
154
  if (now.getTime() < entered + wait.minutes * 60_000) {
@@ -217,6 +228,7 @@ function advanceCard(store, flow, definition, card, now, options, outcome) {
217
228
  workStage(store, flow, stage, card, now, options, outcome, onward);
218
229
  return;
219
230
  case "check":
231
+ case "pull-request":
220
232
  case "update":
221
233
  case "sort":
222
234
  case "draft":
@@ -0,0 +1,11 @@
1
+ import type { Runner } from "./backend.js";
2
+ import type { FlowDefinition, FlowStage } from "./flows.js";
3
+ import type { FlowCardRow, FlowRow, Store } from "./store.js";
4
+ export type PullRequestIo = {
5
+ gh: Runner;
6
+ evidenceRoot?: string;
7
+ };
8
+ /** Whether a person approved this card at a "Person decides" zone since it was last built; their name when so. */
9
+ export declare function approvedSinceBuild(store: Store, definition: FlowDefinition, card: FlowCardRow): string | null;
10
+ /** One pass of a Pull request zone for one card. Returns whether it changed anything. */
11
+ export declare function pullRequestStep(store: Store, flow: FlowRow, definition: FlowDefinition, stage: FlowStage, card: FlowCardRow, now: Date, io: PullRequestIo): Promise<boolean>;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * A flow's Pull request zone (flows.ts): the card's built result becomes a pull request under the project's pull
3
+ * request setup — the same grant, publisher and merge as Complete → pull request (pull-request-flow.ts) — and CI
4
+ * decides where the card goes: on when checks pass (merging first when the zone says so), down the failure path
5
+ * when they fail, with the failing check named on the card. A failure path back to a Build zone becomes a revision
6
+ * of that same work carrying the failure, as a person's send back does.
7
+ *
8
+ * A zone that merges only ever merges a card a person approved (a "Person decides" zone) after it was last built:
9
+ * the flow can't be drawn otherwise (validateFlowDefinition), and a card moved in by hand or by a trigger is checked
10
+ * here too. Without that approval the checks passing leave it waiting for a person.
11
+ *
12
+ * Model-free, in the worker's step pass. The publisher and CI observation run in the watch loop; this reads what
13
+ * they recorded, and reads GitHub itself only to name a failing check and to merge.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { assignmentOf } from "./assignment.js";
17
+ import { cardFollowers, notifyPeople } from "./flow-people.js";
18
+ import { failingCheckOf, mergePullRequest, owePullRequest, pullRequestBlocker, pullRequestFollowOf } from "./pull-request-flow.js";
19
+ import { requestResultChanges } from "./result-actions.js";
20
+ import { revisionSourceOf } from "./result-review.js";
21
+ /** Whether a person approved this card at a "Person decides" zone since it was last built; their name when so. */
22
+ export function approvedSinceBuild(store, definition, card) {
23
+ const kindOf = (id) => definition.stages.find(one => one.id === id)?.kind ?? null;
24
+ const events = store.flowEvents(card.id);
25
+ let built = -1;
26
+ events.forEach((event, index) => { if (kindOf(event.fromStage) === "task" || kindOf(event.toStage) === "task")
27
+ built = index; });
28
+ const approval = events.slice(built + 1).reverse().find(event => event.outcome === "approved" && kindOf(event.fromStage) === "approval");
29
+ return approval === undefined ? null : approval.actor;
30
+ }
31
+ /** One pass of a Pull request zone for one card. Returns whether it changed anything. */
32
+ export async function pullRequestStep(store, flow, definition, stage, card, now, io) {
33
+ const titleOf = (id) => definition.stages.find(one => one.id === id)?.title ?? "another zone";
34
+ const waiting = (said) => {
35
+ if (card.waiting === said)
36
+ return false;
37
+ store.updateFlowCard(card.id, { waiting: said }, now);
38
+ return true;
39
+ };
40
+ const passed = (said, output) => {
41
+ store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: output }, waiting: null }, now);
42
+ if (stage.next === null)
43
+ return waiting(`${said} Move the card on when you're ready.`);
44
+ return store.moveFlowCard(card.id, { to: stage.next, outcome: "ok", actor: "flow", historyNote: said, expectEntry: card.entry }, now);
45
+ };
46
+ const failed = (said, revision = null) => {
47
+ store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: said } }, now);
48
+ if (stage.onFail === null) {
49
+ if (!waiting(`${said} Fix it, then move the card to try again.`))
50
+ return false;
51
+ }
52
+ else if (!store.moveFlowCard(card.id, { to: stage.onFail, outcome: "fail", actor: "flow", note: said, ...(revision === null ? {} : { task: revision }), expectEntry: card.entry }, now))
53
+ return false;
54
+ const people = card.owner === null ? cardFollowers(store, card) : [card.owner];
55
+ notifyPeople(store, card, people, null, { key: `step-failed:${card.entry}`, subject: `${stage.title} didn't pass for “${card.title}”`, attention: true,
56
+ body: `${said}${stage.onFail === null ? "" : `\n\nIt's back in ${titleOf(stage.onFail)}${revision === null ? "" : `, as revision ${revision}`}.`}` }, now);
57
+ return true;
58
+ };
59
+ // With no failure path, a card that didn't pass waits for a person to move it (a move is a fresh visit).
60
+ if (stage.onFail === null && card.waiting !== null && card.waiting.endsWith(" Fix it, then move the card to try again."))
61
+ return false;
62
+ const task = card.primaryTask;
63
+ const receipt = task === null ? null : assignmentOf(store, task, now, { principal: "operator", repos: [flow.repo] }, io.evidenceRoot)?.receipt ?? null;
64
+ if (receipt === null)
65
+ return failed("There's no built result on this card to open a pull request for.");
66
+ const runId = receipt.runId;
67
+ let publication = store.publicationForRun(runId);
68
+ if (publication === null || pullRequestFollowOf(store, publication.id) === null) {
69
+ if (publication === null) {
70
+ const blocked = pullRequestBlocker(store, runId);
71
+ if (blocked !== null)
72
+ return failed(blocked === "Pull requests aren't set up for this project." ? "Pull requests aren't set up for this project. Turn them on in Projects → Pull requests, then move the card back here." : blocked);
73
+ }
74
+ publication = owePullRequest(store, runId, `flow ${flow.name}`, card.id, now);
75
+ }
76
+ const pr = publication.prNumber === null ? "the pull request" : `PR #${publication.prNumber}`;
77
+ if (publication.state === "failed")
78
+ return failed(`Couldn't open the pull request: ${publication.lastError ?? "GitHub refused it"}. The commit is safe locally.`);
79
+ if (publication.state !== "opened")
80
+ return waiting(publication.lastError === null ? "Opening the pull request…" : `Opening the pull request. Retrying: ${publication.lastError}`);
81
+ const link = publication.prUrl ?? pr;
82
+ if (publication.remoteState === "MERGED")
83
+ return passed(`${pr} was merged on GitHub.`, `Merged: ${link}`);
84
+ if (publication.remoteState === "CLOSED")
85
+ return failed(`${pr} was closed on GitHub without merging.`);
86
+ // Only what CI said since the card came here counts: a card moved back to try again waits for a fresh look.
87
+ const entered = store.flowCardEnteredAt(card.id) ?? card.updatedAt;
88
+ const fresh = publication.lastCheckAt !== null && publication.lastCheckAt >= entered;
89
+ if (!fresh || publication.lastCheckState === "running")
90
+ return waiting(`Waiting for CI on ${pr}.`);
91
+ if (publication.lastCheckState !== "passing" && publication.lastCheckState !== "failing")
92
+ return waiting(`No CI checks have reported on ${pr} yet. If this project has none, move the card on.`);
93
+ if (publication.lastCheckState === "failing") {
94
+ const found = await failingCheckOf(io.gh, publication);
95
+ const said = found === null ? `CI failed on ${pr}. GitHub couldn't be read to name the check.` : `CI check “${found.check}” failed on ${pr}.`;
96
+ // Back to a Build zone: a revision of the same work, carrying the failure (fenced as untrusted CI output).
97
+ let revision = null;
98
+ const target = definition.stages.find(one => one.id === stage.onFail);
99
+ if (target?.kind === "task" && io.evidenceRoot !== undefined) {
100
+ const runTask = store.externalIdFor(store.getRun(runId)?.taskRef ?? -1);
101
+ const revised = runTask === null ? null : requestResultChanges(store, io.evidenceRoot, {
102
+ run: runId, source: revisionSourceOf(store.getScope(runTask)?.digest ?? null), actor: "toolroll", repos: [flow.repo], batch: "",
103
+ note: found?.note ?? said, path: "", line: "", allowMode: true,
104
+ request: createHash("sha256").update(`flow-ci ${card.id} ${card.entry} ${publication.id}`).digest("hex").slice(0, 32),
105
+ }, now);
106
+ if (revised?.ok === true)
107
+ revision = revised.id;
108
+ }
109
+ return failed(said, revision);
110
+ }
111
+ // Checks pass.
112
+ if (stage.merge === undefined)
113
+ return passed(`${pr} passed its checks.`, `Pull request: ${link} (checks passed)`);
114
+ const approver = approvedSinceBuild(store, definition, card);
115
+ if (approver === null)
116
+ return waiting(`Checks passed on ${pr}, but no person approved this card since it was built, so it wasn't merged. Merge it from its task, or send it through a decision.`);
117
+ const merged = await mergePullRequest(store, { runId, by: `${approver} (approved in ${flow.name})`, exec: io.gh, method: stage.merge, clock: () => now });
118
+ if (merged.ok)
119
+ return passed(`Merged ${pr} (${stage.merge}).`, `Merged: ${link}`);
120
+ if (merged.reason === "checks" || merged.reason === "unread")
121
+ return waiting(`${merged.message} (${pr})`);
122
+ return failed(`Couldn't merge ${pr}: ${merged.message}`);
123
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Settings → Flows: the starter flows for a project, each switched on with one yes. A row says what it's for,
3
+ * what it will do and what it never does — the whole of what the yes agrees to — then one button. A starter
4
+ * that's on links to its flow; one that can't run here says why.
5
+ */
6
+ import type { StarterView } from "./flow-starters.js";
7
+ export declare const STARTERS_CSS: string;
8
+ export declare function startersHtml(input: {
9
+ repo: string;
10
+ projects: readonly {
11
+ path: string;
12
+ name: string;
13
+ }[];
14
+ starters: readonly StarterView[];
15
+ csrf: string;
16
+ canSwitch: boolean;
17
+ suggested: string | null;
18
+ said: string | null;
19
+ problem: string | null;
20
+ }): string;
@@ -0,0 +1,35 @@
1
+ const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
2
+ export const STARTERS_CSS = `.starters{max-width:760px;min-width:0}.starters h2{margin:24px 0 4px;font-size:.9375rem}` +
3
+ `.starter{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 16px;align-items:start;padding:14px 0;border-top:1px solid var(--so-line);min-width:0}` +
4
+ `.starter:first-of-type{border-top:0}.starter>:not(.starter-action){grid-column:1}.starter h3{margin:0;font-size:.9375rem;display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center}` +
5
+ `.starter p,.starter ul{margin:2px 0 0;min-width:0;overflow-wrap:anywhere}.starter ul{padding-left:18px;color:var(--so-muted);font-size:.875rem;line-height:1.5}` +
6
+ `.starter .starter-never{font-size:.875rem;font-weight:500}.starter-action{grid-column:2;grid-row:1 / span 4;align-self:center;margin:0}` +
7
+ `.starter-action button,.starter-action .button-link{min-height:44px;white-space:nowrap}.starter .starter-action button{width:auto}` +
8
+ `.starter .starter-action a.starter-open{background:var(--so-paper);color:var(--so-ink);border:1px solid var(--so-input-line)}` +
9
+ `.starter[data-suggested="true"]{margin:0 -12px;padding:14px 12px;border:1px solid var(--so-accent,var(--so-line));border-radius:10px}` +
10
+ `.starter-on{display:inline-flex;align-items:center;gap:6px;font-size:.75rem;font-weight:500;padding:0 8px;border-radius:5px;line-height:20px;color:var(--so-success);background:var(--so-success-soft)}` +
11
+ `.starter-on i{width:6px;height:6px;border-radius:50%;background:currentColor}.starters .starter-blocked{color:var(--so-muted);font-size:.875rem}` +
12
+ `.starters form.starters-project{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin:0 0 8px}.starters form.starters-project label{display:grid;gap:4px}` +
13
+ `@media (max-width:560px){.starter{grid-template-columns:minmax(0,1fr)}.starter .starter-action{grid-column:1;grid-row:auto;margin-top:10px}` +
14
+ `.starter .starter-action button,.starter .starter-action .button-link{width:100%;justify-content:center}.starters select{font-size:16px}}`;
15
+ export function startersHtml(input) {
16
+ const note = input.problem ? `<p class="problem" role="alert">${e(input.problem)}</p>` : input.said ? `<p role="status">${e(input.said)}</p>` : "";
17
+ const selector = input.projects.length > 1
18
+ ? `<form class="starters-project" method="get" action="/settings/flows"><label>Project<select name="repo">${input.projects.map(one => `<option value="${e(one.path)}"${one.path === input.repo ? " selected" : ""}>${e(one.name)}</option>`).join("")}</select></label><button>Show</button></form>` : "";
19
+ // The one asked about ("Do this every time…") comes first, marked.
20
+ const ordered = [...input.starters].sort((a, b) => Number(b.id === input.suggested) - Number(a.id === input.suggested));
21
+ const rows = ordered.map(one => {
22
+ const action = one.on !== null ? `<p class="starter-action"><a class="button-link starter-open" href="/flows/${one.on.flow}">Open flow</a></p>`
23
+ : one.blocked !== null || !input.canSwitch ? ""
24
+ : `<form method="post" action="/settings/flows/on" class="starter-action"><input type="hidden" name="csrf" value="${e(input.csrf)}"><input type="hidden" name="repo" value="${e(input.repo)}">` +
25
+ `<input type="hidden" name="starter" value="${e(one.id)}"><button type="submit">Switch on</button></form>`;
26
+ return `<div class="starter" id="starter-${e(one.id)}" data-starter="${e(one.id)}" data-suggested="${one.id === input.suggested && one.on === null}">` +
27
+ `<h3>${e(one.name)}${one.on === null ? "" : ` <span class="starter-on"><i aria-hidden="true"></i>On</span>`}</h3>` +
28
+ `<p>${e(one.summary)}</p>` +
29
+ // What the yes agrees to, in full, before it; nothing to agree to once it's on or can't be.
30
+ (one.on === null && one.blocked === null ? `<ul>${one.does.map(line => `<li>${e(line)}</li>`).join("")}</ul><p class="starter-never">${e(one.never)}</p>` : "") +
31
+ (one.on === null && one.blocked !== null ? `<p class="starter-blocked">${e(one.blocked)}</p>` : "") + action + `</div>`;
32
+ }).join("");
33
+ const nobody = !input.canSwitch && input.starters.some(one => one.on === null) ? `<p class="meta">An approver switches these on.</p>` : "";
34
+ return `<section class="starters">${note}${selector}<h2>Starter flows</h2>${rows}${nobody}<p class="meta"><a href="/flows">All flows</a></p></section>`;
35
+ }
@@ -0,0 +1,55 @@
1
+ import { type FlowStepInput } from "./flows.js";
2
+ import type { FlowRow, Store } from "./store.js";
3
+ export declare const STARTER_IDS: readonly ["ci-fix", "issue-task", "overnight"];
4
+ export type StarterId = (typeof STARTER_IDS)[number];
5
+ export type StarterFlow = {
6
+ id: StarterId;
7
+ /** The flow's name, and how the starter is called everywhere. */
8
+ name: string;
9
+ /** One line: what it's for. */
10
+ summary: string;
11
+ /** What switching it on does, in order. */
12
+ does: (where: {
13
+ github: string | null;
14
+ branch: string;
15
+ }) => string[];
16
+ /** What it never does. */
17
+ never: string;
18
+ github: boolean;
19
+ steps: FlowStepInput[];
20
+ trigger: (where: {
21
+ branch: string;
22
+ }) => Record<string, unknown>;
23
+ };
24
+ export declare const STARTER_FLOWS: readonly StarterFlow[];
25
+ export declare const starterOf: (id: string) => StarterFlow | null;
26
+ /** The flow a starter made in a project, while it is on. */
27
+ export declare function starterFlowOf(store: Store, starter: StarterFlow, repo: string): FlowRow | null;
28
+ export type StarterView = {
29
+ id: StarterId;
30
+ name: string;
31
+ summary: string;
32
+ does: string[];
33
+ never: string;
34
+ on: {
35
+ flow: number;
36
+ } | null;
37
+ blocked: string | null;
38
+ };
39
+ /** Each starter for a project: what it does there, whether it's on, and why it can't be switched on when it can't. */
40
+ export declare function startersFor(store: Store, repo: string): StarterView[];
41
+ export type SwitchedOn = {
42
+ ok: true;
43
+ flow: number;
44
+ said: string;
45
+ already: boolean;
46
+ } | {
47
+ ok: false;
48
+ said: string;
49
+ };
50
+ /** Switch a starter on in a project: its flow (zones) and its trigger, together or not at all. Once is enough. */
51
+ export declare function switchOnStarter(store: Store, starter: StarterFlow, repo: string, by: string, now: Date, dir: string | null): SwitchedOn;
52
+ /** The starter that would do a task's kind of work every time: fixing CI, an issue, or a queued job. */
53
+ export declare function starterForWork(text: string): StarterFlow;
54
+ /** What switching a starter on means, for a chat card's terms. */
55
+ export declare function starterTerms(store: Store, starter: StarterFlow, repo: string): string[];
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Starter flows: flows that are on from day one, each switched on with one yes from `toolroll onboard`,
3
+ * Settings → Flows, a task's "Do this every time…" or chat. Switching one on makes an ordinary flow (its zones)
4
+ * and its trigger, exactly as drawing them by hand would; it is on while that flow is. Each says in plain words
5
+ * what it will do and what it never does: no starter merges or ships anything without a person.
6
+ */
7
+ import { flowDefinitionOf } from "./flow-engine.js";
8
+ import { addFlowTriggerTo, githubRepoOf } from "./flow-triggers.js";
9
+ import { flowFromSteps, ISSUE_LABEL } from "./flows.js";
10
+ import { publishingOf } from "./pull-request-flow.js";
11
+ export const STARTER_IDS = ["ci-fix", "issue-task", "overnight"];
12
+ export const STARTER_FLOWS = [
13
+ {
14
+ id: "ci-fix",
15
+ name: "Fix failing CI",
16
+ summary: "When CI fails on the main branch, a task to fix it is filed.",
17
+ does: ({ github, branch }) => [`Watches checks on ${branch} in ${github ?? "your GitHub repository"}.`, "Files a task to fix each failure, under your usual approvals.", "Asks you to review the fix."],
18
+ never: "Never merges or pushes to your branch. You decide what ships.",
19
+ github: true,
20
+ steps: [
21
+ { id: "fix", title: "Fix it", kind: "task", instructions: "CI failed on the main branch: {{card.title}}\n\n{{card.description}}\n\nFind the cause and make the failing check pass without weakening it.\n\nChanges asked for (if any): {{note}}" },
22
+ { id: "review", title: "Review the fix", kind: "approval", decider: "owner" },
23
+ ],
24
+ trigger: ({ branch }) => ({ kind: "github", watch: "checks", branch }),
25
+ },
26
+ {
27
+ id: "issue-task",
28
+ name: "Issues become tasks",
29
+ summary: `A GitHub issue labelled “${ISSUE_LABEL}” becomes a task.`,
30
+ does: ({ github }) => [`Watches issues labelled “${ISSUE_LABEL}” in ${github ?? "your GitHub repository"}, from people with write access.`, "Builds each one as a task, under your usual approvals.", "Asks you to review it, then comments on the issue."],
31
+ never: "Never merges or closes anything without you.",
32
+ github: true,
33
+ steps: [
34
+ { id: "build", title: "Build", kind: "task" },
35
+ { id: "review", title: "Review", kind: "approval", decider: "owner" },
36
+ { id: "comment", title: "Comment on the issue", kind: "update", message: "Done: {{card.title}}. The result is ready in Toolroll.", close: false },
37
+ ],
38
+ trigger: () => ({ kind: "github", watch: "issues", label: ISSUE_LABEL }),
39
+ },
40
+ {
41
+ id: "overnight",
42
+ name: "Overnight queue",
43
+ summary: "Cards you add during the day start after 22:00; results wait for you in the morning.",
44
+ does: () => ["Adds a “Queue for tonight” button.", "Holds each card until 22:00, then builds it as a task, under your usual approvals.", "Results wait for your review in the morning."],
45
+ never: "Never merges or ships anything without you.",
46
+ github: false,
47
+ steps: [
48
+ { id: "tonight", title: "Tonight", kind: "wait", waitFor: "hours", from: "22:00", until: "06:00" },
49
+ { id: "build", title: "Build", kind: "task" },
50
+ { id: "morning", title: "Morning review", kind: "approval", decider: "owner" },
51
+ ],
52
+ trigger: () => ({ kind: "button", label: "Queue for tonight", questions: ["What needs doing?", "Details"] }),
53
+ },
54
+ ];
55
+ export const starterOf = (id) => STARTER_FLOWS.find(one => one.id === id) ?? null;
56
+ /** The flow a starter made in a project, while it is on. */
57
+ export function starterFlowOf(store, starter, repo) {
58
+ return store.listFlows([repo]).find(one => one.name === starter.name && one.state === "active") ?? null;
59
+ }
60
+ /** Each starter for a project: what it does there, whether it's on, and why it can't be switched on when it can't. */
61
+ export function startersFor(store, repo) {
62
+ const github = githubRepoOf(repo);
63
+ const publishing = publishingOf(store, repo);
64
+ const branch = publishing.on ? publishing.base : "main";
65
+ return STARTER_FLOWS.map(starter => {
66
+ const flow = starterFlowOf(store, starter, repo);
67
+ return { id: starter.id, name: starter.name, summary: starter.summary, does: starter.does({ github, branch }), never: starter.never,
68
+ on: flow === null ? null : { flow: flow.id }, blocked: starter.github && github === null ? "This project isn't on GitHub, so this can't watch it." : null };
69
+ });
70
+ }
71
+ /** Switch a starter on in a project: its flow (zones) and its trigger, together or not at all. Once is enough. */
72
+ export function switchOnStarter(store, starter, repo, by, now, dir) {
73
+ const had = starterFlowOf(store, starter, repo);
74
+ if (had !== null)
75
+ return { ok: true, flow: had.id, said: `${starter.name} is already on.`, already: true };
76
+ if (starter.github && githubRepoOf(repo) === null)
77
+ return { ok: false, said: "This project isn't on GitHub, so this can't watch it." };
78
+ const publishing = publishingOf(store, repo);
79
+ try {
80
+ const flow = store.transact(() => {
81
+ const id = store.createFlow({ repo, name: starter.name, definitionJson: JSON.stringify(flowFromSteps(starter.steps, null)), by }, now);
82
+ const made = store.getFlow(id);
83
+ const trigger = addFlowTriggerTo(store, made, { ...starter.trigger({ branch: publishing.on ? publishing.base : "main" }), zone: flowDefinitionOf(made).start }, by, now, dir);
84
+ if (!trigger.ok)
85
+ throw new Error(trigger.message);
86
+ return id;
87
+ });
88
+ return { ok: true, flow, said: `${starter.name} is on.`, already: false };
89
+ }
90
+ catch (error) {
91
+ return { ok: false, said: `${starter.name} couldn't be switched on: ${error instanceof Error ? error.message : "unknown"}` };
92
+ }
93
+ }
94
+ /** The starter that would do a task's kind of work every time: fixing CI, an issue, or a queued job. */
95
+ export function starterForWork(text) {
96
+ const id = /\b(ci|checks?|build|pipeline|workflow)\b[^.\n]{0,60}\b(fail|failed|failing|failure|red|broken)\b|\b(fail|failed|failing|broken)\b[^.\n]{0,60}\b(ci|checks?|pipeline)\b/i.test(text) ? "ci-fix"
97
+ : /github\.com\/[^/\s]+\/[^/\s]+\/issues\/\d+|\bissue\s*#?\d+|\bGitHub issue\b/i.test(text) ? "issue-task" : "overnight";
98
+ return starterOf(id);
99
+ }
100
+ /** What switching a starter on means, for a chat card's terms. */
101
+ export function starterTerms(store, starter, repo) {
102
+ const view = startersFor(store, repo).find(one => one.id === starter.id);
103
+ return [view.summary, ...view.does, view.never];
104
+ }
@@ -15,6 +15,8 @@
15
15
  * - sort — (v85) asks Jev through OpenRouter which of the zone's answers
16
16
  * fits the card (flow-sort.ts), and sends it where that answer
17
17
  * leads, or down the not-sure path.
18
+ * - pull-request — opens a pull request for the card's result and follows
19
+ * its CI (flow-pull-request.ts).
18
20
  *
19
21
  * One run per visit to the zone (flow_step_run): two workers never run it
20
22
  * twice. Trouble reaching a service is retried three times, 5 then 15
@@ -40,6 +42,7 @@ import { recordSent, threadOf } from "./flow-replies.js";
40
42
  import { ASKED, teammateReady, teammateTurn } from "./teammate-work.js";
41
43
  import { budgetHoldWords, claudeMachineBilling, monthOf } from "./spend.js";
42
44
  import { toolRefusal } from "./policy.js";
45
+ import { pullRequestStep } from "./flow-pull-request.js";
43
46
  const RETRY_MS = [5 * 60_000, 15 * 60_000];
44
47
  /** A teammate's turn tries again sooner: a card waiting on a person's colleague shouldn't wait long for a hiccup. */
45
48
  const TEAMMATE_RETRY_MS = [60_000, 5 * 60_000];
@@ -71,6 +74,17 @@ export async function runFlowSteps(store, repo, now, io) {
71
74
  await teammateStep(store, known.flow, known.definition, stage, card, now, io, pass);
72
75
  continue;
73
76
  }
77
+ // A pull request: opened for the card's result, then CI decides where it goes (flow-pull-request.ts).
78
+ if (stage.kind === "pull-request") {
79
+ try {
80
+ if (await pullRequestStep(store, known.flow, known.definition, stage, card, now, { gh: io.gh, ...(io.evidenceRoot === undefined ? {} : { evidenceRoot: io.evidenceRoot }) }))
81
+ pass.ran++;
82
+ }
83
+ catch (error) {
84
+ pass.problems.push(`flow card ${card.id}: ${error instanceof Error ? error.message : "the pull request step couldn't run"}`);
85
+ }
86
+ continue;
87
+ }
74
88
  if (!["check", "update", "sort", "draft", "request", "email", "tool"].includes(stage.kind))
75
89
  continue;
76
90
  // Email and tools wait, saying why, until what they need is set up.