toolroll 0.8.2 → 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 (68) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/agent-onboard.d.ts +24 -1
  3. package/dist/agent-onboard.js +53 -1
  4. package/dist/backup-ui.js +4 -2
  5. package/dist/browser/workspace.css +2 -2
  6. package/dist/browser/workspace.js +33 -32
  7. package/dist/browser-workspace.d.ts +22 -2
  8. package/dist/chat-actions.d.ts +5 -0
  9. package/dist/chat-actions.js +43 -3
  10. package/dist/chat-delivery.js +51 -3
  11. package/dist/chat-quiet.d.ts +28 -0
  12. package/dist/chat-quiet.js +161 -0
  13. package/dist/cli.d.ts +1 -1
  14. package/dist/cli.js +4 -1
  15. package/dist/desktop-update.d.ts +1 -1
  16. package/dist/digest-times.d.ts +3 -0
  17. package/dist/digest-times.js +7 -0
  18. package/dist/dispose.js +2 -0
  19. package/dist/export.js +1 -1
  20. package/dist/flow-engine.js +13 -1
  21. package/dist/flow-pull-request.d.ts +11 -0
  22. package/dist/flow-pull-request.js +123 -0
  23. package/dist/flow-starters-ui.d.ts +20 -0
  24. package/dist/flow-starters-ui.js +35 -0
  25. package/dist/flow-starters.d.ts +55 -0
  26. package/dist/flow-starters.js +104 -0
  27. package/dist/flow-steps.js +14 -0
  28. package/dist/flows-ui.d.ts +1 -1
  29. package/dist/flows-ui.js +4 -3
  30. package/dist/flows.d.ts +30 -6
  31. package/dist/flows.js +110 -6
  32. package/dist/integrations-ui.d.ts +13 -0
  33. package/dist/integrations-ui.js +70 -0
  34. package/dist/integrations.d.ts +113 -0
  35. package/dist/integrations.js +526 -0
  36. package/dist/lead-context.d.ts +1 -1
  37. package/dist/lead-context.js +1 -1
  38. package/dist/mate-contract.js +1 -1
  39. package/dist/mate-tools.js +18 -5
  40. package/dist/models-ui.js +3 -2
  41. package/dist/monitoring-ui.js +2 -1
  42. package/dist/operate.d.ts +5 -2
  43. package/dist/operate.js +169 -4
  44. package/dist/policy-ui.js +2 -1
  45. package/dist/project-delete.js +1 -0
  46. package/dist/publish.d.ts +11 -0
  47. package/dist/publish.js +9 -2
  48. package/dist/pull-request-flow.d.ts +172 -0
  49. package/dist/pull-request-flow.js +475 -0
  50. package/dist/pull-request-schema.d.ts +5 -0
  51. package/dist/pull-request-schema.js +21 -0
  52. package/dist/pull-request-ui.d.ts +23 -0
  53. package/dist/pull-request-ui.js +55 -0
  54. package/dist/pulls.d.ts +5 -0
  55. package/dist/pulls.js +11 -0
  56. package/dist/serve.d.ts +6 -0
  57. package/dist/serve.js +371 -58
  58. package/dist/setup-guide.d.ts +2 -2
  59. package/dist/skills-ui.js +2 -1
  60. package/dist/store.d.ts +61 -0
  61. package/dist/store.js +144 -3
  62. package/dist/surface.js +3 -1
  63. package/dist/telegram-progress.d.ts +1 -0
  64. package/dist/telegram-progress.js +1 -1
  65. package/dist/telegram.js +95 -15
  66. package/dist/when-html.d.ts +8 -0
  67. package/dist/when-html.js +36 -0
  68. package/package.json +1 -1
@@ -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.
@@ -2,7 +2,7 @@
2
2
  * (the React view's data, plus a plain fallback page the view replaces). */
3
3
  import type { BrowserFlowView } from "./browser-workspace.js";
4
4
  import type { FlowRow, Store } from "./store.js";
5
- export declare const FLOWS_CSS = ".flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows .flow-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.flows .flow-row h2{font-size:1.05rem;margin:0}.flows .flow-counts{font-size:.85rem;color:var(--so-muted)}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows .flow-buttons{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 0}@media(max-width:600px){.flows input,.flows select{font-size:16px}}";
5
+ export declare const FLOWS_CSS = ".flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows .flow-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.flows .flow-row h2{font-size:1.05rem;margin:0}.flows .flow-counts{font-size:.85rem;color:var(--so-muted)}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows .flow-buttons{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 0}@media(max-width:600px){.flows input,.flows select{font-size:16px}}@media(max-width:760px){.flows .card{padding:10px 12px;margin:8px 0;position:relative}.flows .flow-row{row-gap:2px}.flows .flow-row h2 a::after{content:\"\";position:absolute;inset:0;border-radius:inherit}.flows .flow-counts{line-height:1.35}}";
6
6
  /** The flows a person can open, with how many cards wait in each, and the new-flow form. */
7
7
  export declare function flowsListHtml(store: Store, flows: readonly FlowRow[], projects: readonly string[], csrf: string, canCreate: boolean, problem: string | null): string;
8
8
  /** One flow's canvas for one person. */
package/dist/flows-ui.js CHANGED
@@ -13,7 +13,7 @@ import { callWords, receiptWords } from "./teammate-tools.js";
13
13
  import { undoFor } from "./teammate-week.js";
14
14
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
15
15
  const projectName = (repo) => repo.split(/[\\/]/).filter(Boolean).pop() ?? repo;
16
- export const FLOWS_CSS = `.flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows .flow-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.flows .flow-row h2{font-size:1.05rem;margin:0}.flows .flow-counts{font-size:.85rem;color:var(--so-muted)}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows .flow-buttons{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 0}@media(max-width:600px){.flows input,.flows select{font-size:16px}}`;
16
+ export const FLOWS_CSS = `.flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows .flow-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.flows .flow-row h2{font-size:1.05rem;margin:0}.flows .flow-counts{font-size:.85rem;color:var(--so-muted)}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows .flow-buttons{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 0}@media(max-width:600px){.flows input,.flows select{font-size:16px}}@media(max-width:760px){.flows .card{padding:10px 12px;margin:8px 0;position:relative}.flows .flow-row{row-gap:2px}.flows .flow-row h2 a::after{content:"";position:absolute;inset:0;border-radius:inherit}.flows .flow-counts{line-height:1.35}}`;
17
17
  /** The flows a person can open, with how many cards wait in each, and the new-flow form. */
18
18
  export function flowsListHtml(store, flows, projects, csrf, canCreate, problem) {
19
19
  const rows = flows.map(flow => {
@@ -71,11 +71,12 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
71
71
  const decision = parseSortDecision(decisions.get(card.id)?.decisionJson ?? null);
72
72
  const shown = stage?.kind === "approval" && definition !== null ? draftFor(definition, stage) : null;
73
73
  // v91: when a Wait zone gives up (or moves on), or a zone's time limit comes.
74
- const clock = stage?.wait ?? stage?.limit;
74
+ // An "hours" wait has no deadline of its own: it goes on when the clock reaches its hours.
75
+ const clock = stage?.wait?.for === "hours" ? stage.limit : stage?.wait ?? stage?.limit;
75
76
  const until = card.state !== "active" || stage === undefined || clock === undefined ? null : new Date(Date.parse(store.flowCardEnteredAt(card.id) ?? card.updatedAt) + clock.minutes * 60_000);
76
77
  const deadline = until === null || until.getTime() <= Date.now() ? null : {
77
78
  at: until.toISOString(),
78
- label: stage.wait !== undefined ? (stage.wait.for === "reply" ? "No reply by" : "Moves on at") : stage.limit.to !== null ? `Moves to ${title(stage.limit.to)} at` : "Reminder at",
79
+ label: stage.wait !== undefined && stage.wait.for !== "hours" ? (stage.wait.for === "reply" ? "No reply by" : "Moves on at") : stage.limit.to !== null ? `Moves to ${title(stage.limit.to)} at` : "Reminder at",
79
80
  };
80
81
  // Its open question: one of its own, or (v94) a tool call waiting for approval.
81
82
  const asked = card.state === "active" ? store.openTeammateQuestionOn(card.id, card.entry) : null;
package/dist/flows.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const FLOW_STAGE_KINDS: readonly ["inbox", "task", "report", "approval", "check", "update", "notify", "sort", "draft", "request", "email", "tool", "wait", "teammate", "done"];
1
+ export declare const FLOW_STAGE_KINDS: readonly ["inbox", "task", "report", "approval", "check", "pull-request", "update", "notify", "sort", "draft", "request", "email", "tool", "wait", "teammate", "done"];
2
2
  export type FlowStageKind = (typeof FLOW_STAGE_KINDS)[number];
3
3
  export declare const FLOW_COLORS: readonly ["slate", "blue", "violet", "amber", "green", "rose"];
4
4
  export type FlowColor = (typeof FLOW_COLORS)[number];
@@ -48,11 +48,18 @@ export type FlowTool = {
48
48
  name: string;
49
49
  args: string;
50
50
  };
51
- /** wait (v91): for a reply to the card's email (next: replied, onFail: no reply in time), or for a set time (then next). */
51
+ /** wait (v91): for a reply to the card's email (next: replied, onFail: no reply in time), or for a set time (then next).
52
+ * "hours": until the clock is between `from` and `to` (like 22:00–06:00), in the computer's time zone unless one is named. */
52
53
  export type FlowWait = {
53
- for: "reply" | "time";
54
+ for: "reply" | "time" | "hours";
54
55
  minutes: number;
56
+ from?: string;
57
+ to?: string;
58
+ timeZone?: string;
55
59
  };
60
+ /** How a Pull request zone merges once checks pass. */
61
+ export declare const FLOW_MERGE_METHODS: readonly ["squash", "merge", "rebase"];
62
+ export type FlowMergeMethod = (typeof FLOW_MERGE_METHODS)[number];
56
63
  /** A time limit on a zone (v91): after this long, the person it waits on is reminded, and a Holding or
57
64
  * "Person decides" zone can move the card on (`to`). */
58
65
  export type FlowLimit = {
@@ -102,6 +109,8 @@ export type FlowStage = {
102
109
  teammate?: string;
103
110
  /** v96: a "Teammate handles it" zone sends what the teammate writes back to whoever asked (the person who added the card, its chat thread, or the teammate's manager). */
104
111
  reply?: boolean;
112
+ /** pull-request: merge once checks pass, this way. Only allowed after a "Person decides" zone, and a card merges only when a person approved it after it was built. */
113
+ merge?: FlowMergeMethod;
105
114
  /** Where a card goes when this zone's step succeeds, and when it fails or is sent back (sort: when it isn't sure). */
106
115
  next: string | null;
107
116
  onFail: string | null;
@@ -132,12 +141,18 @@ export declare const LONGEST_WAIT_MINUTES: number;
132
141
  export declare function durationMinutes(value: unknown): number | null;
133
142
  /** Minutes in the words people use: "3 days", "1 day 4 hours", "45 minutes". */
134
143
  export declare function durationWords(minutes: number): string;
144
+ /** "22:00", "9:30" as "HH:MM"; null when it isn't a time of day. */
145
+ export declare function clockTime(value: unknown): string | null;
146
+ /** Whether a time falls inside an "hours" wait (22:00–06:00 runs past midnight), in its time zone or this computer's. */
147
+ export declare function withinHours(wait: Pick<FlowWait, "from" | "to" | "timeZone">, now: Date): boolean;
135
148
  /** Who decides at an approval zone: its named person, the flow's owner, or null for anyone who approves on the project. */
136
149
  export declare function deciderOf(stage: Pick<FlowStage, "approver" | "toOwner">, flow: {
137
150
  owner: string;
138
151
  }): string | null;
139
152
  /** A flow as drawn on the canvas, checked whole. Throws in plain words. */
140
153
  export declare function validateFlowDefinition(input: unknown): FlowDefinition;
154
+ /** The zones a card can reach from `start` without passing through a zone `stop` picks (those zones aren't included). */
155
+ export declare function reachableWithout(stages: readonly FlowStage[], start: string, stop: (stage: FlowStage) => boolean): Set<string>;
141
156
  /** What a card's work is held to: the zones' steps and paths, never where they sit on the canvas. */
142
157
  export declare function flowDigest(definition: FlowDefinition): string;
143
158
  /** Fill a zone's text from the card: title, description, the latest note and earlier zones' reports. */
@@ -194,10 +209,15 @@ export type FlowStepInput = {
194
209
  server?: string;
195
210
  tool?: string;
196
211
  args?: Record<string, unknown> | string;
197
- /** wait (v91): for a reply (the default) or a set time, and how long ("3 days"); where a card goes when no reply comes. */
198
- waitFor?: "reply" | "time";
212
+ /** wait (v91): for a reply (the default) or a set time, and how long ("3 days"); where a card goes when no reply comes.
213
+ * "hours": until the clock is between `from` and `until` ("22:00", "06:00"). */
214
+ waitFor?: "reply" | "time" | "hours";
199
215
  wait?: string | number;
200
216
  ifNoReply?: string;
217
+ from?: string;
218
+ until?: string;
219
+ /** pull-request: merge once checks pass (true is squash). Needs a decision before it. */
220
+ merge?: boolean | FlowMergeMethod;
201
221
  /** Any step but wait and done (v91): remind after this long ("2 days"; "none" removes it), and on holding and approval steps, move the card to this step then. */
202
222
  remindAfter?: string | number;
203
223
  thenMoveTo?: string;
@@ -220,10 +240,14 @@ export declare const DRAFT_DEFAULT = "Write a short, friendly reply to the perso
220
240
  export declare function flowFromSteps(input: unknown, previous?: FlowDefinition | null): FlowDefinition;
221
241
  /** What confirming a new or changed flow means, in the canvas's words: each step's job and path, what changed, and that the usual approvals still apply. */
222
242
  export declare function flowTerms(definition: FlowDefinition, previous: FlowDefinition | null): string[];
223
- /** Ready-made flows: the coding flow is the whole business process around a change. */
243
+ /** The label an issue gets to become work: what the Issues to PRs template and the issue starter flow watch for. */
244
+ export declare const ISSUE_LABEL = "toolroll";
245
+ /** Ready-made flows: the coding flow is the whole business process around a change. A template's `trigger`
246
+ * (a trigger's settings, as flow-triggers.ts reads them) is added with it. */
224
247
  export declare const FLOW_TEMPLATES: readonly {
225
248
  id: string;
226
249
  label: string;
227
250
  about: string;
228
251
  definition: FlowDefinition;
252
+ trigger?: Record<string, unknown>;
229
253
  }[];