toolroll 0.9.30 → 0.9.31

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.31 — 2026-10-04
4
+
5
+ - **Flow templates for your integrations.** Nine new templates put connected
6
+ tools to work: UI inspiration (Mobbin), Metrics digest and Fix the biggest
7
+ drop-off (PostHog), Outage to cause (Better Stack), Figma frame to pull
8
+ request, Worker errors to fix (Cloudflare), Failed deploy to fix (Vercel),
9
+ Linear issues to PRs, and Support to bug fix (Intercom). Error to fix now
10
+ reads stack traces through Sentry. Each card shows its tool's logo and
11
+ whether it's connected; one that isn't offers Connect first. Research steps
12
+ only read, and nothing merges.
13
+ - **Templates show their steps.** The unlabeled boxes on each template are
14
+ now a named step strip, like "Find the drop-off → You choose → Build the fix
15
+ → Pull request → Sent to you", with your steps marked.
16
+
3
17
  ## 0.9.30 — 2026-10-04
4
18
 
5
19
  - **Long tasks can be planned.** 0.9.28 let task goals run to 8,000
@@ -1,17 +1,19 @@
1
- /**
2
- * The flow gallery in the console (Flows → New and Settings → Flows): cards grouped by what they're for, each with
3
- * its promise, a small drawing of its zones, what it needs and "Use this". A template's page asks only what it
4
- * needs, previews in plain words what it will do and never do, and creates it — the same answers it previewed.
5
- */
6
- import { type GalleryAnswers, type GalleryPreview, type GalleryTemplate } from "./flow-gallery.js";
7
- import { type FlowDefinition } from "./flows.js";
1
+ import { type GalleryAnswers, type GalleryPreview, type GalleryTemplate, type GalleryTool } from "./flow-gallery.js";
2
+ import { type FlowDefinition, type FlowStage } from "./flows.js";
8
3
  export declare const GALLERY_CSS: string;
9
- /** A small drawing of a flow's zones where they sit on its canvas, with its paths; a person's decisions stand out. */
10
- export declare function zonesDiagram(definition: FlowDefinition, label: string): string;
4
+ /** A flow's main path, as a person reads it: from the start, each step's first way on (a sort's first answer, a choose's
5
+ * first option that goes somewhere, else next; a check's failure when passing just ends), never back to a step shown. */
6
+ export declare function mainPath(definition: FlowDefinition): FlowStage[];
7
+ /** A flow's steps in order, joined by arrows; a person's decisions carry the accent. */
8
+ export declare function stepStrip(definition: FlowDefinition): string;
11
9
  /** The gallery: every template, grouped, each with one "Use this". `repo` carries a chosen project to the template's page. */
12
10
  export declare function galleryHtml(input: {
13
11
  repo: string | null;
14
12
  canUse: boolean;
13
+ connections?: readonly {
14
+ id: string;
15
+ state: "connected" | "open" | "taken";
16
+ }[] | null;
15
17
  }): string;
16
18
  /** A template's page: its questions, then what it will do and never do, then Create. */
17
19
  export declare function galleryUseHtml(input: {
@@ -27,4 +29,7 @@ export declare function galleryUseHtml(input: {
27
29
  problem: string | null;
28
30
  csrf: string;
29
31
  diagram: FlowDefinition | null;
32
+ /** The template's tools in this project; `said` is how the last Connect went. */
33
+ tools?: readonly GalleryTool[];
34
+ said?: string | null;
30
35
  }): string;
@@ -1,22 +1,30 @@
1
1
  /**
2
2
  * The flow gallery in the console (Flows → New and Settings → Flows): cards grouped by what they're for, each with
3
- * its promise, a small drawing of its zones, what it needs and "Use this". A template's page asks only what it
3
+ * its promise, its steps in order, what it needs and "Use this". A template's page asks only what it
4
4
  * needs, previews in plain words what it will do and never do, and creates it — the same answers it previewed.
5
+ * A template's tools show their marks and whether the project has them; one it lacks offers Connect first.
5
6
  */
6
- import { BLANK, GALLERY, GALLERY_GROUPS, galleryDiagram, OUTDATED_COMMANDS, SEND_RESULT } from "./flow-gallery.js";
7
+ import { brandMarkHtml } from "./brand-mark.js";
8
+ import { BLANK, GALLERY, GALLERY_GROUPS, galleryDiagram, galleryToolsOf, OUTDATED_COMMANDS, SEND_RESULT, sendsAlready } from "./flow-gallery.js";
7
9
  import { choiceTargets } from "./flows.js";
8
10
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
9
11
  export const GALLERY_CSS = `.gallery{max-width:1120px;min-width:0}.gallery h2{margin:28px 0 10px;font-size:.9375rem}.gallery h2:first-of-type{margin-top:8px}` +
10
12
  `.gallery-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px}` +
11
13
  `.gallery-card{display:flex;flex-direction:column;gap:8px;padding:16px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);min-width:0}` +
12
14
  `.gallery-card h3{margin:0;font-size:.9375rem}.gallery-card p{margin:0;overflow-wrap:anywhere}.gallery-card .gallery-promise{line-height:1.5;flex:1}` +
13
- `.gallery-zones{display:block;width:100%;height:56px;border-radius:8px;background:var(--so-raised)}.gallery-zones rect{fill:var(--so-paper);stroke:var(--so-input-line);stroke-width:1.5}` +
14
- `.gallery-zones rect[data-person="true"]{fill:var(--so-signal-soft,var(--so-raised));stroke:var(--so-ink)}.gallery-zones line{stroke:var(--so-input-line);stroke-width:1.5}` +
15
+ // A template's steps in order, joined by arrows; a person's own steps carry the attention chip.
16
+ `.gallery-steps{display:flex;flex-wrap:wrap;align-items:center;gap:4px 6px;list-style:none;padding:0;margin:0;font-size:12px;font-weight:500;line-height:1.5;color:var(--so-ink)}` +
17
+ `.gallery-steps li{display:inline-flex;align-items:center;gap:6px;min-width:0;overflow-wrap:anywhere}.gallery-steps li>span[aria-hidden]{color:var(--so-muted)}` +
18
+ `.gallery-steps b{font-weight:500;font-size:11.5px;line-height:18px;padding:1px 6px;border-radius:5px;background:var(--so-signal-soft);color:var(--so-signal)}` +
15
19
  `.gallery-needs{display:flex;flex-wrap:wrap;gap:4px;list-style:none;padding:0;margin:0}.gallery-needs li{font-size:11.5px;font-weight:500;line-height:18px;padding:1px 6px;border-radius:5px;background:var(--so-neutral-soft,var(--so-raised));color:var(--so-neutral-ink,var(--so-muted))}` +
16
20
  // An outline action in a list (the Quiet List Rule), over the workspace's ink default for .button-link.
17
21
  `.gallery .gallery-card a.button-link{align-self:flex-start;min-height:36px;background:var(--so-paper);color:var(--so-ink);border:1px solid var(--so-input-line)}` +
18
22
  `.gallery-blank{margin-top:20px}` +
19
- `.gallery-use{max-width:720px;min-width:0}.gallery-use .gallery-zones{height:88px;margin:4px 0 12px}.gallery-use form{display:grid;gap:12px;margin:0}.gallery-use label{display:grid;gap:4px;font-weight:500}` +
23
+ // A template's tools: the mark Settings → Integrations shows, its name and where the project stands.
24
+ `.gallery-tools{display:grid;gap:6px;list-style:none;padding:0;margin:0}.gallery-tools li{display:flex;align-items:center;gap:8px;min-width:0;flex-wrap:wrap}.gallery-tools li>span:not(.brand-mark){min-width:0;overflow-wrap:anywhere}` +
25
+ `.gallery-tools strong{font-weight:500;overflow-wrap:anywhere}.gallery-connect{display:grid;gap:10px;padding:12px 0;border-top:1px solid var(--so-line)}.gallery-connect:last-child{border-bottom:1px solid var(--so-line);margin-bottom:4px}` +
26
+ `.gallery-connect form{display:grid;gap:8px;margin:0}.gallery-connect p{margin:0;overflow-wrap:anywhere}.gallery-connect .gallery-tool-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.gallery-connect button{justify-self:start;min-height:36px;white-space:nowrap;background:var(--primary);color:var(--primary-foreground);border-color:var(--primary);font-weight:600}` +
27
+ `.gallery-use{max-width:720px;min-width:0}.gallery-use .gallery-steps{margin:4px 0 12px}.gallery-use form{display:grid;gap:12px;margin:0}.gallery-use label{display:grid;gap:4px;font-weight:500}` +
20
28
  `.gallery-use label small{font-weight:400;color:var(--so-muted)}.gallery-use label.gallery-check{display:flex;align-items:flex-start;gap:8px;font-weight:500}.gallery-use label.gallery-check input{margin-top:3px;width:16px;height:16px;min-height:0;flex:none}.gallery-use label.gallery-check small{display:block}` +
21
29
  `.gallery-use input:not([type=hidden]),.gallery-use select{box-sizing:border-box;width:100%;max-width:100%;min-height:32px;padding:0 10px;border:1px solid var(--so-input-line);border-radius:8px;background:var(--so-paper);color:var(--so-ink);font:inherit}` +
22
30
  `.gallery-preview{display:grid;gap:8px;padding:16px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);min-width:0}.gallery-preview h2{margin:0;font-size:.9375rem}` +
@@ -24,33 +32,47 @@ export const GALLERY_CSS = `.gallery{max-width:1120px;min-width:0}.gallery h2{ma
24
32
  `.gallery-preview details ol{margin:6px 0 0;padding-left:20px;white-space:pre-line;font-size:.875rem;line-height:1.5;color:var(--so-muted)}.gallery-preview details li{margin:0 0 6px;overflow-wrap:anywhere}` +
25
33
  `.gallery-preview summary{cursor:pointer;min-height:36px;display:flex;align-items:center;font-weight:500}` +
26
34
  `.gallery-actions{display:flex;flex-wrap:wrap;gap:8px;margin:0}.gallery-actions button{min-height:36px;white-space:nowrap}` +
27
- `.gallery-use .gallery-actions button[value=create]{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary);font-weight:600}.gallery-heading{margin:36px 0 0;font-size:1.05rem}` +
28
- `@media(max-width:760px){.gallery-grid{grid-template-columns:minmax(0,1fr)}.gallery .gallery-card a.button-link{min-height:44px;align-self:stretch;justify-content:center}` +
35
+ `.gallery-use .gallery-actions button[value=create]:not(.secondary){background:var(--primary);color:var(--primary-foreground);border-color:var(--primary);font-weight:600}.gallery-heading{margin:36px 0 0;font-size:1.05rem}` +
36
+ `@media(max-width:760px){.gallery-grid{grid-template-columns:minmax(0,1fr)}.gallery-connect button{min-height:44px;justify-self:stretch}.gallery .gallery-card a.button-link{min-height:44px;align-self:stretch;justify-content:center}` +
29
37
  `.gallery-use input:not([type=hidden]),.gallery-use select{font-size:16px;min-height:44px}.gallery-actions button{flex:1 1 auto;min-height:44px}}`;
30
- /** A small drawing of a flow's zones where they sit on its canvas, with its paths; a person's decisions stand out. */
31
- export function zonesDiagram(definition, label) {
32
- const zones = definition.stages.map(one => one.zone);
33
- const minX = Math.min(...zones.map(one => one.x)), minY = Math.min(...zones.map(one => one.y));
34
- const maxX = Math.max(...zones.map(one => one.x + one.w)), maxY = Math.max(...zones.map(one => one.y + one.h));
35
- const pad = 40;
36
- const box = `${minX - pad} ${minY - pad} ${maxX - minX + pad * 2} ${maxY - minY + pad * 2}`;
37
- const centre = (id) => { const at = definition.stages.find(one => one.id === id)?.zone; return at === undefined ? null : { x: at.x + at.w / 2, y: at.y + at.h / 2 }; };
38
- const lines = definition.stages.flatMap(stage => [stage.next, ...(stage.sort?.answers.map(one => one.to) ?? []), ...(stage.routes?.map(one => one.to) ?? []), ...choiceTargets(stage)].map(to => {
39
- const from = centre(stage.id), end = centre(to);
40
- return from === null || end === null ? "" : `<line x1="${from.x}" y1="${from.y}" x2="${end.x}" y2="${end.y}" vector-effect="non-scaling-stroke"/>`;
41
- })).join("");
42
- const rects = definition.stages.map(one => `<rect x="${one.zone.x}" y="${one.zone.y}" width="${one.zone.w}" height="${one.zone.h}" rx="36" data-person="${one.kind === "approval" || one.kind === "choose"}" vector-effect="non-scaling-stroke"/>`).join("");
43
- return `<svg class="gallery-zones" viewBox="${box}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${e(label)}">${lines}${rects}</svg>`;
38
+ /** A flow's main path, as a person reads it: from the start, each step's first way on (a sort's first answer, a choose's
39
+ * first option that goes somewhere, else next; a check's failure when passing just ends), never back to a step shown. */
40
+ export function mainPath(definition) {
41
+ const byId = new Map(definition.stages.map(one => [one.id, one]));
42
+ const path = [];
43
+ for (let at = byId.get(definition.start); at !== undefined && at.kind !== "done" && !path.includes(at);) {
44
+ path.push(at);
45
+ const ways = [...(at.sort?.answers.map(one => one.to) ?? []), ...choiceTargets(at), at.next, ...(at.routes?.map(one => one.to) ?? [])].map(id => id === null ? undefined : byId.get(id));
46
+ if (at.kind === "check")
47
+ ways.push(at.onFail === null ? undefined : byId.get(at.onFail));
48
+ at = at.kind === "check" ? ways.find(one => one !== undefined && one.kind !== "done") : ways.find(one => one !== undefined);
49
+ }
50
+ return path;
44
51
  }
45
- const needsList = (template) => template.needs.length === 0 ? `<ul class="gallery-needs"><li>Nothing to connect</li></ul>`
52
+ /** A step's short name in the strip: a person's own steps from their side. */
53
+ const stepName = (stage) => stage.kind === "choose" ? "You choose" : stage.kind === "approval" ? "You approve" : stage.kind === "send" ? "Sent to you" : stage.title;
54
+ /** A flow's steps in order, joined by arrows; a person's decisions carry the accent. */
55
+ export function stepStrip(definition) {
56
+ return `<ol class="gallery-steps" aria-label="Steps">${mainPath(definition).map((one, i) => {
57
+ const name = e(stepName(one));
58
+ return `<li>${i === 0 ? "" : `<span aria-hidden="true">→</span>`}${one.kind === "choose" || one.kind === "approval" ? `<b data-person>${name}</b>` : name}</li>`;
59
+ }).join("")}</ol>`;
60
+ }
61
+ const needsList = (template) => template.needs.length === 0 ? (template.tools ?? []).length > 0 ? "" : `<ul class="gallery-needs"><li>Nothing to connect</li></ul>`
46
62
  : `<ul class="gallery-needs" aria-label="Needs">${template.needs.map(one => `<li>${e(one)}</li>`).join("")}</ul>`;
63
+ /** A tool's state in words, as Settings → Integrations badges it; nothing without a project. */
64
+ const STATE = { connected: ["connected", "Connected"], open: ["not-set-up", "Not connected"], taken: ["not-set-up", "Added another way"] };
65
+ const toolState = (tool) => tool.state === null ? "" : `<span class="integration-state integration-state--${STATE[tool.state][0]}"><i aria-hidden="true"></i>${STATE[tool.state][1]}</span>`;
66
+ const toolMark = (tool) => brandMarkHtml(tool.id.replace(/-desktop$/, ""), tool.label, tool.state === "connected");
67
+ const toolsList = (tools) => tools.length === 0 ? ""
68
+ : `<ul class="gallery-tools" aria-label="Tools">${tools.map(one => `<li data-tool="${e(one.id)}" data-state="${one.state ?? "unknown"}">${toolMark(one)}<strong>${e(one.label)}</strong>${toolState(one)}</li>`).join("")}</ul>`;
47
69
  /** The gallery: every template, grouped, each with one "Use this". `repo` carries a chosen project to the template's page. */
48
70
  export function galleryHtml(input) {
49
71
  const href = (id) => `/flows/new/${id}${input.repo === null ? "" : `?repo=${encodeURIComponent(input.repo)}`}`;
50
72
  const card = (template) => {
51
73
  const definition = galleryDiagram(template);
52
74
  return `<article class="gallery-card" data-template="${e(template.id)}"><h3>${e(template.name)}</h3><p class="gallery-promise">${e(template.promise)}</p>` +
53
- zonesDiagram(definition, `Zones: ${definition.stages.map(one => one.title).join(", ")}`) + needsList(template) +
75
+ stepStrip(definition) + toolsList(galleryToolsOf(template, definition, input.repo === null ? null : input.connections ?? null)) + needsList(template) +
54
76
  (input.canUse ? `<a class="button-link" href="${e(href(template.id))}" aria-label="Use ${e(template.name)}">Use this</a>` : "") + `</article>`;
55
77
  };
56
78
  const groups = GALLERY_GROUPS.map(group => `<h2 id="gallery-${group.id}">${e(group.label)}</h2><div class="gallery-grid" data-group="${group.id}">${GALLERY.filter(one => one.group === group.id).map(card).join("")}</div>`).join("");
@@ -59,28 +81,48 @@ export function galleryHtml(input) {
59
81
  /** A template's page: its questions, then what it will do and never do, then Create. */
60
82
  export function galleryUseHtml(input) {
61
83
  const { template } = input;
84
+ const tools = input.tools ?? input.preview?.tools ?? [];
85
+ // A tool added another way is there: only one the project lacks is missing.
86
+ const missing = tools.filter(one => one.state !== "connected" && one.state !== "taken");
87
+ // Each tool with its state; one the project lacks offers its own Connect (the Tools page's, with the same password), and comes back here.
88
+ const connect = (tool) => {
89
+ const head = `<p class="gallery-tool-head">${toolMark(tool)} <strong>${e(tool.label)}</strong> ${toolState(tool)}</p>`;
90
+ // Added another way, it is there: nothing to connect.
91
+ if (tool.state === "connected" || tool.state === "taken" || input.csrf === "")
92
+ return `<div class="gallery-connect" data-tool="${e(tool.id)}" data-state="${tool.state}">${head}</div>`;
93
+ const how = tool.id === "figma-desktop" ? "Open the Figma desktop app and turn on its Dev Mode MCP server in Preferences." : `You sign in on ${tool.label}, then come back here.`;
94
+ return `<div class="gallery-connect" data-tool="${e(tool.id)}" data-state="open">${head}<form method="post" action="/settings/tools/connect">` +
95
+ `<input type="hidden" name="csrf" value="${e(input.csrf)}"><input type="hidden" name="repo" value="${e(input.repo)}"><input type="hidden" name="shown" value="${e(input.repo)}"><input type="hidden" name="template" value="${e(template.id)}">` +
96
+ `<p class="meta">${e(how)}</p><label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>` +
97
+ `<button name="service" value="${e(tool.id)}">Connect ${e(tool.label)}</button></form></div>`;
98
+ };
99
+ const toolsBlock = tools.length === 0 ? "" : `<section class="gallery-tools-use" aria-label="Tools">${tools.map(connect).join("")}</section>`;
100
+ // Made without a tool, the preview names the zone that needs it.
101
+ const zones = (tool) => tool.zones.map(zone => `“${e(zone)}”`).join(" and ");
102
+ const needing = missing.filter(one => one.zones.length > 0).map(one => `<p class="meta" data-needs-tool="${e(one.id)}">${zones(one)} need${one.zones.length === 1 ? "s" : ""} ${e(one.label)}, which isn't connected.</p>`).join("") +
103
+ tools.filter(one => one.state === "taken" && one.zones.length > 0).map(one => `<p class="meta" data-tool-taken="${e(one.id)}">${zones(one)} use${one.zones.length === 1 ? "s" : ""} ${e(one.label)}, added another way.</p>`).join("");
62
104
  const field = (ask) => {
63
105
  const value = input.answers[ask.key] ?? ask.default;
64
106
  const hint = ask.hint === undefined ? "" : ` <small>${e(ask.hint)}</small>`;
65
107
  if (ask.key === "outdated")
66
108
  return `<label>${e(ask.label)}<select name="outdated">${Object.entries(OUTDATED_COMMANDS).map(([id, one]) => `<option value="${id}"${id === value ? " selected" : ""}>${e(one.label)}</option>`).join("")}</select></label>`;
67
- return `<label>${e(ask.label)}${hint}<input name="${ask.key}" value="${e(value)}" required maxlength="${ask.key === "command" ? 500 : 100}"${ask.key === "command" ? ' spellcheck="false" autocapitalize="off"' : ""}></label>`;
109
+ return `<label>${e(ask.label)}${hint}<input name="${ask.key}" value="${e(value)}" required maxlength="${ask.key === "command" ? 500 : ask.key === "team" ? 12 : 100}"${ask.key === "command" ? ' spellcheck="false" autocapitalize="off"' : ask.key === "team" ? ' spellcheck="false" autocapitalize="characters"' : ""}></label>`;
68
110
  };
69
111
  const project = input.projects.length > 1
70
112
  ? `<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>`
71
113
  : `<input type="hidden" name="repo" value="${e(input.repo)}">`;
72
114
  const preview = input.preview === null ? "" : `<section class="gallery-preview" aria-labelledby="gallery-will">` +
73
115
  `<h2 id="gallery-will">What it will do</h2><ul>${input.preview.built.does.map(one => `<li>${e(one)}</li>`).join("")}</ul>` +
74
- `<p class="gallery-never">${e(input.preview.built.never)}</p>` +
116
+ `<p class="gallery-never">${e(input.preview.built.never)}</p>${needing}` +
75
117
  (input.preview.startsFrom.length === 0 ? `<p class="meta">Cards start when you add them.</p>` : input.preview.startsFrom.map(one => `<p class="meta">Starts from: ${e(one)}</p>`).join("")) +
76
118
  `<details><summary>Every step</summary><ol>${input.preview.steps.map(one => `<li>${e(one.replace(/^\d+\.\s/, ""))}</li>`).join("")}</ol></details></section>`;
77
119
  return `<section class="gallery-use" data-gallery-template="${e(template.id)}">` +
78
- (input.problem === null ? "" : `<p class="problem" role="alert">${e(input.problem)}</p>`) +
79
- `<p>${e(template.promise)}</p>${input.diagram === null ? "" : zonesDiagram(input.diagram, `Zones: ${input.diagram.stages.map(one => one.title).join(", ")}`)}${needsList(template)}` +
120
+ (input.problem === null ? "" : `<p class="problem" role="alert">${e(input.problem)}</p>`) + (input.said ? `<p role="status">${e(input.said)}</p>` : "") +
121
+ `<p>${e(template.promise)}</p>${input.diagram === null ? "" : stepStrip(input.diagram)}${needsList(template)}${toolsBlock}` +
80
122
  `<form method="post" action="/flows/new/${e(template.id)}" data-gallery-use><input type="hidden" name="csrf" value="${e(input.csrf)}">` +
81
123
  `<input type="hidden" name="previewed" value="${e(input.preview?.digest ?? "")}">${project}${template.asks.map(field).join("")}` +
82
124
  `<label>Name<input name="name" value="${e(input.name)}" maxlength="80"></label>` +
83
- `<label class="gallery-check"><input type="checkbox" name="${SEND_RESULT.key}" value="yes"${input.answers[SEND_RESULT.key] === "yes" ? " checked" : ""} data-send-result><span>${e(SEND_RESULT.title)}<small>When a card finishes, what was done comes to you in your chat apps.</small></span></label>${preview}` +
84
- `<p class="gallery-actions">${input.preview === null ? "" : `<button type="submit" name="intent" value="create">Create flow</button>`}<button type="submit" name="intent" value="preview" class="secondary">${input.preview === null ? "Preview" : "Update preview"}</button></p>` +
125
+ (sendsAlready(template) ? "" : `<label class="gallery-check"><input type="checkbox" name="${SEND_RESULT.key}" value="yes"${input.answers[SEND_RESULT.key] === "yes" ? " checked" : ""} data-send-result><span>${e(SEND_RESULT.title)}<small>When a card finishes, what was done comes to you in your chat apps.</small></span></label>`) + preview +
126
+ `<p class="gallery-actions">${input.preview === null ? "" : `<button type="submit" name="intent" value="create"${missing.length > 0 ? ' class="secondary" data-without-tools' : ""}>Create flow</button>`}<button type="submit" name="intent" value="preview" class="secondary">${input.preview === null ? "Preview" : "Update preview"}</button></p>` +
85
127
  `</form><p class="meta"><a href="/flows/new">All templates</a></p></section>`;
86
128
  }
@@ -15,7 +15,7 @@ export declare const GALLERY_GROUPS: readonly [{
15
15
  }];
16
16
  export type GalleryGroup = (typeof GALLERY_GROUPS)[number]["id"];
17
17
  /** What a template may ask, besides the project. */
18
- export type AskKey = "label" | "branch" | "schedule" | "time" | "command" | "outdated";
18
+ export type AskKey = "label" | "branch" | "schedule" | "time" | "command" | "outdated" | "team";
19
19
  export type GalleryAsk = {
20
20
  key: AskKey;
21
21
  default: string;
@@ -36,6 +36,8 @@ export type GalleryTemplate = {
36
36
  promise: string;
37
37
  /** What it needs to work, in a word or two each. */
38
38
  needs: string[];
39
+ /** The project tools it puts to work: one-click service ids (ONE_CLICK) or "figma-desktop". */
40
+ tools?: readonly string[];
39
41
  /** Watches a GitHub repository, so the project must be on GitHub. */
40
42
  github?: boolean;
41
43
  asks: GalleryAsk[];
@@ -55,10 +57,28 @@ export type GalleryTemplate = {
55
57
  does: string[];
56
58
  never: string;
57
59
  };
60
+ /** The card's Linear issue key, read from its source as the Linear trigger stored it ("Linear ENG-123"); "none" skips the comment. */
61
+ export declare 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)\"\nif [ -n \"$key\" ]; then echo \"$key\"; echo \"goto: Found\"; else echo none; echo \"goto: None\"; fi";
58
62
  export declare const GALLERY: readonly GalleryTemplate[];
59
63
  /** The blank flow, for starting from nothing. */
60
64
  export declare const BLANK: GalleryTemplate;
61
65
  export declare const galleryTemplateOf: (id: string) => GalleryTemplate | null;
66
+ /** A tool a template uses: where the chosen project stands with it (null with no project), and the zones that use it. */
67
+ export type GalleryTool = {
68
+ id: string;
69
+ label: string;
70
+ state: "connected" | "open" | "taken" | null;
71
+ zones: string[];
72
+ };
73
+ /** 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. */
74
+ export declare const galleryToolLabel: (id: string) => string | null;
75
+ /** The zones that use a tool: a tool step on it, or a research or build step that names it ("With Mobbin, …"). */
76
+ export declare function zonesUsing(definition: FlowDefinition, id: string): string[];
77
+ /** A template's tools, each with its state in a project's connections (connectionsOf), or none without a project. */
78
+ export declare function galleryToolsOf(template: GalleryTemplate, definition: FlowDefinition, connections: readonly {
79
+ id: string;
80
+ state: "connected" | "open" | "taken";
81
+ }[] | null): GalleryTool[];
62
82
  export type GalleryAnswers = Record<string, string>;
63
83
  /** Each question's default for a project: the branch pull requests go to, when that's set up. */
64
84
  export declare function galleryDefaults(store: Store, template: GalleryTemplate, repo: string | null): GalleryAnswers;
@@ -68,7 +88,10 @@ export declare const SEND_RESULT: {
68
88
  readonly title: "Send me the result";
69
89
  readonly does: "When a card finishes, sends you what was done (its summary, links and screenshots) in your chat apps.";
70
90
  };
71
- /** A flow with "Send me the result" before its end: every path into its main Done zone passes through it first. */
91
+ /** A template that already sends its result to the person: it isn't offered "Send me the result" again. */
92
+ export declare const sendsAlready: (template: GalleryTemplate) => boolean;
93
+ /** A flow with "Send me the result" before its end: every path into its main Done zone passes through it first.
94
+ * A flow that already has a send zone is left as it is, so nobody gets the result twice. */
72
95
  export declare function withSendResult(definition: FlowDefinition): FlowDefinition;
73
96
  /** The answers, checked in plain words, with what they fill in. Throws. */
74
97
  export declare function checkAnswers(template: GalleryTemplate, given: GalleryAnswers): GalleryAnswers;
@@ -96,6 +119,7 @@ export type GalleryPreview = {
96
119
  built: GalleryBuilt;
97
120
  startsFrom: string[];
98
121
  steps: string[];
122
+ tools: GalleryTool[];
99
123
  digest: string;
100
124
  };
101
125
  /** What using a template will do, in words, before anything is made: its triggers checked as the flow will have them. Throws. */
@@ -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) {
@@ -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;
@@ -30,10 +30,13 @@ import { envValue } from "./names.js";
30
30
  export const ONE_CLICK = [
31
31
  { id: "stripe", label: "Stripe", url: "https://mcp.stripe.com/", about: "Payments, customers, refunds and invoices." },
32
32
  { id: "notion", label: "Notion", url: "https://mcp.notion.com/mcp", about: "Pages and databases: search, read and write." },
33
- { id: "linear", label: "Linear", url: "https://mcp.linear.app/mcp", about: "Issues, projects and comments." },
34
- { id: "sentry", label: "Sentry", url: "https://mcp.sentry.dev/mcp", about: "Errors and issues, with their stack traces." },
33
+ { id: "linear", label: "Linear", url: "https://mcp.linear.app/mcp", about: "Issues, projects and comments.",
34
+ reads: ["issue", "project", "team", "comment", "label", "cycle", "document", "status", "statuses", "user"] },
35
+ { id: "sentry", label: "Sentry", url: "https://mcp.sentry.dev/mcp", about: "Errors and issues, with their stack traces.",
36
+ reads: ["organization", "project", "issue", "detail", "details", "event", "trace", "release", "tag", "attachment"] },
35
37
  { id: "atlassian", label: "Jira and Confluence", url: "https://mcp.atlassian.com/v1/mcp", about: "Jira issues and Confluence pages." },
36
- { id: "intercom", label: "Intercom", url: "https://mcp.intercom.com/mcp", about: "Customer conversations and contacts." },
38
+ { id: "intercom", label: "Intercom", url: "https://mcp.intercom.com/mcp", about: "Customer conversations and contacts.",
39
+ reads: ["conversation", "contact"] },
37
40
  { id: "attio", label: "Attio", url: "https://mcp.attio.com/mcp", about: "Your CRM: people, companies, deals and notes." },
38
41
  { id: "zapier", label: "Zapier", url: "https://mcp.zapier.com/api/mcp/mcp", about: "Thousands of apps, through the actions you choose in Zapier." },
39
42
  { id: "square", label: "Square", url: "https://mcp.squareup.com/mcp", about: "Payments, orders, customers and catalog." },
@@ -42,8 +45,10 @@ export const ONE_CLICK = [
42
45
  { id: "webflow", label: "Webflow", url: "https://mcp.webflow.com/mcp", about: "Sites, pages and CMS items." },
43
46
  { id: "wix", label: "Wix", url: "https://mcp.wix.com/mcp", about: "Your Wix site, store and bookings." },
44
47
  { id: "canva", label: "Canva", url: "https://mcp.canva.com/mcp", about: "Designs: search, create and export." },
45
- { id: "vercel", label: "Vercel", url: "https://mcp.vercel.com/", about: "Projects, deployments and logs." },
46
- { id: "cloudflare", label: "Cloudflare", url: "https://mcp.cloudflare.com/mcp", about: "Workers, DNS and your Cloudflare account." },
48
+ { id: "vercel", label: "Vercel", url: "https://mcp.vercel.com/", about: "Projects, deployments and logs.",
49
+ reads: ["team", "project", "deployment", "build", "runtime", "log", "logs", "vercel", "documentation"] },
50
+ { id: "cloudflare", label: "Cloudflare", url: "https://mcp.cloudflare.com/mcp", about: "Workers, DNS and your Cloudflare account.",
51
+ reads: ["account", "worker", "workers", "observability", "analytics", "log", "logs", "page", "pages", "project", "deployment"] },
47
52
  // Mobbin's sign-in is its Supabase auth server, on another origin: its protected-resource metadata names it.
48
53
  { id: "mobbin", label: "Mobbin", url: "https://api.mobbin.com/mcp", about: "Real app screens and flows to learn from.",
49
54
  reads: ["screen", "flow", "app", "site", "element", "pattern", "ui", "ios", "android", "web"] },
@@ -237,7 +242,7 @@ export async function startConnect(input, fetcher = fetch, now = Date.now()) {
237
242
  go.searchParams.set("resource", resource);
238
243
  if (server.scopes.length > 0)
239
244
  go.searchParams.set("scope", server.scopes.join(" "));
240
- return { ok: true, go: go.toString(), state, visit: { service: service.id, repo: input.repo, by: input.by, kit: input.kit ?? null, verifier, clientId, clientSecret, token: server.token, resource, redirect, expires: now + VISIT_MS } };
245
+ return { ok: true, go: go.toString(), state, visit: { service: service.id, repo: input.repo, by: input.by, kit: input.kit ?? null, template: input.template ?? null, verifier, clientId, clientSecret, token: server.token, resource, redirect, expires: now + VISIT_MS } };
241
246
  }
242
247
  /** The spec a connected service joins the project with: its MCP address, signed in with the token its sign-in gave. */
243
248
  export function connectedSpec(serviceId) {
package/dist/serve.js CHANGED
@@ -34,7 +34,7 @@ import { CONNECT_CALLBACK, connectionsOf, finishConnect, localConnectOf, oneClic
34
34
  import { KITS_CSS, kitPageHtml, kitsGalleryHtml } from "./kits-ui.js";
35
35
  import { STARTERS_CSS, startersHtml } from "./flow-starters-ui.js";
36
36
  import { GALLERY_CSS, galleryHtml, galleryUseHtml } from "./flow-gallery-ui.js";
37
- import { galleryDefaults, galleryDiagram, galleryTemplateOf, previewGallery, useGalleryTemplate } from "./flow-gallery.js";
37
+ import { galleryDefaults, galleryDiagram, galleryTemplateOf, galleryToolsOf, previewGallery, useGalleryTemplate } from "./flow-gallery.js";
38
38
  import { starterForWork, starterOf, startersFor, switchOnStarter } from "./flow-starters.js";
39
39
  import { createTeammateFrom, labelOf, nameOf, saveSoul, setTeammateState, teammateSettings, sendTeammateSummaries } from "./teammate-admin.js";
40
40
  import { editMemory, forgetMemory, tellTeammate } from "./teammate-memory.js";
@@ -1116,7 +1116,10 @@ export function createDecisionServer(options) {
1116
1116
  return done("/settings/tools", "problem", "That sign-in expired. Connect again.");
1117
1117
  const service = oneClickOf(visit.service);
1118
1118
  const kit = visit.kit === null ? null : kitOf(visit.kit);
1119
- const back = kit === null ? `/settings/tools?repo=${encodeURIComponent(visit.repo)}` : `/kits/${kit.id}?repo=${encodeURIComponent(visit.repo)}`;
1119
+ // Started from a gallery template's page: back to it, for the same project.
1120
+ const template = visit.template === null ? null : galleryTemplateOf(visit.template);
1121
+ const back = template !== null ? `/flows/new/${template.id}?repo=${encodeURIComponent(visit.repo)}`
1122
+ : kit === null ? `/settings/tools?repo=${encodeURIComponent(visit.repo)}` : `/kits/${kit.id}?repo=${encodeURIComponent(visit.repo)}`;
1120
1123
  const to = projectName(visit.repo);
1121
1124
  if (url.searchParams.has("error"))
1122
1125
  return done(back, "problem", url.searchParams.get("error") === "access_denied" ? `${service.label} wasn't connected to ${to}: access was declined.` : `${service.label} wasn't connected to ${to}.`);
@@ -1748,7 +1751,7 @@ export function createDecisionServer(options) {
1748
1751
  }
1749
1752
  // ---- reads ---------------------------------------------------------------
1750
1753
  /** A gallery template's page (Flows → New → Use this): its questions, the preview of these answers, and Create. */
1751
- function sendGalleryUse(response, who, template, projects, repo, given, name, notice) {
1754
+ function sendGalleryUse(response, who, template, projects, repo, given, name, notice, said = null) {
1752
1755
  const answers = given ?? galleryDefaults(store, template, repo);
1753
1756
  let preview = null, problem = notice;
1754
1757
  try {
@@ -1757,8 +1760,9 @@ export function createDecisionServer(options) {
1757
1760
  catch (error) {
1758
1761
  problem = error instanceof Error ? error.message : "That can't be made here.";
1759
1762
  }
1763
+ const diagram = preview?.built.definition ?? galleryDiagram(template);
1760
1764
  const html = galleryUseHtml({ template, projects: projects.map(path => ({ path, name: projectName(path) })), repo, answers, name, preview, problem,
1761
- csrf: who.via === "cookie" ? who.session.csrf : "", diagram: preview?.built.definition ?? galleryDiagram(template) });
1765
+ csrf: who.via === "cookie" ? who.session.csrf : "", diagram, tools: preview?.tools ?? galleryToolsOf(template, diagram, connectionsOf(store, repo)), said });
1762
1766
  return sendScreen(response, problem !== null && preview === null && notice !== null ? 400 : 200, screen(template.name, `<p><a href="/flows/new">New flow</a></p><h1>${escape(template.name)}</h1>${html}`, { chrome: chromeFor(repo, "flows") }));
1763
1767
  }
1764
1768
  async function handleGet(url, who, request, response) {
@@ -3188,8 +3192,10 @@ export function createDecisionServer(options) {
3188
3192
  if (url.pathname === "/flows/new") {
3189
3193
  const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3190
3194
  const canUse = who.via === "cookie" && who.role === "approver" && projects.length > 0;
3191
- const repo = url.searchParams.get("repo");
3192
- return sendScreen(response, 200, screen("New flow", `<p><a href="/flows">Flows</a></p><h1>New flow</h1>${galleryHtml({ repo: repo !== null && projects.includes(repo) ? repo : null, canUse })}`, { chrome: chromeFor(project, "flows") }));
3195
+ // The chosen project, else the one being looked at: its tools are what each card says is connected.
3196
+ const asked = url.searchParams.get("repo");
3197
+ const repo = asked !== null && projects.includes(asked) ? asked : project !== null && projects.includes(project) ? project : null;
3198
+ return sendScreen(response, 200, screen("New flow", `<p><a href="/flows">Flows</a></p><h1>New flow</h1>${galleryHtml({ repo, canUse, connections: repo === null ? null : connectionsOf(store, repo) })}`, { chrome: chromeFor(repo ?? project, "flows") }));
3193
3199
  }
3194
3200
  const galleryRead = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
3195
3201
  if (galleryRead !== null) {
@@ -3203,7 +3209,9 @@ export function createDecisionServer(options) {
3203
3209
  return refuse(response, who, 409, "Add a project first.", "/projects");
3204
3210
  const asked = url.searchParams.get("repo");
3205
3211
  const repo = asked !== null && projects.includes(asked) ? asked : project !== null && projects.includes(project) ? project : projects[0];
3206
- return sendGalleryUse(response, who, template, projects, repo, null, template.name, null);
3212
+ // Back from a Connect started on this page: how it went.
3213
+ const problem = url.searchParams.get("problem"), said = url.searchParams.get("said");
3214
+ return sendGalleryUse(response, who, template, projects, repo, null, template.name, problem === null ? null : problem.slice(0, 400), said === null ? null : said.slice(0, 400));
3207
3215
  }
3208
3216
  if (url.pathname === "/flows") {
3209
3217
  const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
@@ -3969,7 +3977,7 @@ export function createDecisionServer(options) {
3969
3977
  said: url.searchParams.get("said"), problem: url.searchParams.get("problem")
3970
3978
  }) +
3971
3979
  // Every template, beside the starters: Use this opens its page for this project.
3972
- `<h2 class="gallery-heading">Templates</h2>${galleryHtml({ repo: chosen, canUse: who.via === "cookie" && who.role === "approver" && !store.isDemo() })}`;
3980
+ `<h2 class="gallery-heading">Templates</h2>${galleryHtml({ repo: chosen, canUse: who.via === "cookie" && who.role === "approver" && !store.isDemo(), connections: connectionsOf(store, chosen) })}`;
3973
3981
  return sendScreen(response, 200, screen("Flows", `<p><a href="/settings">Settings</a></p><h1>Flows</h1>${content}`, { chrome: chromeFor(chosen || project, "settings") }));
3974
3982
  }
3975
3983
  // Settings → Integrations: which integrations work. The list is the last checks; a render never waits on one.
@@ -7737,10 +7745,14 @@ export function createDecisionServer(options) {
7737
7745
  const local = service === null && also === null ? localConnectOf(body.get("service") ?? "") : null;
7738
7746
  const chosen = service ?? local;
7739
7747
  const target = also === null ? repo : split > 0 ? also.slice(split + 1) : "";
7740
- const kit = kitOf(body.get("kit") ?? "")?.id ?? null;
7748
+ // From a gallery template that uses this tool: it comes back to that template's page, for this project.
7749
+ const gallery = also === null && chosen !== null ? galleryTemplateOf(body.get("template") ?? "") : null;
7750
+ const template = gallery !== null && (gallery.tools ?? []).includes(chosen.id) ? gallery.id : null;
7751
+ const kit = template === null ? kitOf(body.get("kit") ?? "")?.id ?? null : null;
7741
7752
  // A Connect that fails comes back to its own button, where the reason is said (no #fragment: one stops the alert's
7742
7753
  // autofocus); an "Also connect to" refusal goes back to the Connect card.
7743
- const back = (words, page = repo) => redirect(response, `/settings/tools?repo=${encodeURIComponent(page)}${kit === null ? "" : `&kit=${kit}`}${chosen === null || also !== null ? "" : `&connect=${chosen.id}`}&problem=${encodeURIComponent(words)}${also === null ? "" : "#connect"}`);
7754
+ const back = (words, page = repo) => template !== null ? redirect(response, `/flows/new/${template}?repo=${encodeURIComponent(page)}&problem=${encodeURIComponent(words)}`)
7755
+ : redirect(response, `/settings/tools?repo=${encodeURIComponent(page)}${kit === null ? "" : `&kit=${kit}`}${chosen === null || also !== null ? "" : `&connect=${chosen.id}`}&problem=${encodeURIComponent(words)}${also === null ? "" : "#connect"}`);
7744
7756
  // The page posts the project it showed; a post from a page showing another project is stale.
7745
7757
  const shown = body.get("shown") ?? "";
7746
7758
  if (shown !== repo)
@@ -7769,8 +7781,10 @@ export function createDecisionServer(options) {
7769
7781
  return back(added.message);
7770
7782
  }
7771
7783
  const tested = await testToolOf(store, repo, local.id, clock(), { home: toolHome, omitEnv: ALL_CREDENTIAL_ENV });
7784
+ const connected = `${local.label} is connected: ${tested?.tools.length ?? 0} action${tested?.tools.length === 1 ? "" : "s"}.`;
7772
7785
  if (tested?.ok)
7773
- return redirect(response, `/settings/tools?repo=${encodeURIComponent(repo)}${kit === null ? "" : `&kit=${kit}`}&said=${encodeURIComponent(`${local.label} is connected: ${tested.tools.length} action${tested.tools.length === 1 ? "" : "s"}.`)}#tool-${local.id}`);
7786
+ return redirect(response, template !== null ? `/flows/new/${template}?repo=${encodeURIComponent(repo)}&said=${encodeURIComponent(connected)}`
7787
+ : `/settings/tools?repo=${encodeURIComponent(repo)}${kit === null ? "" : `&kit=${kit}`}&said=${encodeURIComponent(connected)}#tool-${local.id}`);
7774
7788
  if (had === undefined)
7775
7789
  removeToolFrom(store, repo, local.id, who.name, clock(), toolHome);
7776
7790
  return back(tested?.problem ?? `${local.label} didn't answer.`);
@@ -7780,7 +7794,7 @@ export function createDecisionServer(options) {
7780
7794
  const origin = consoleOrigin(request.headers.host);
7781
7795
  if (origin === null)
7782
7796
  return back("Connect tools from this computer (localhost) or from your https address.");
7783
- const started = await startConnect({ service: service.id, repo: target, by: who.name, origin, kit: also === null ? kit : null }, options.connectFetch ?? fetch);
7797
+ const started = await startConnect({ service: service.id, repo: target, by: who.name, origin, kit: also === null ? kit : null, template }, options.connectFetch ?? fetch);
7784
7798
  if (!started.ok)
7785
7799
  return back(started.said);
7786
7800
  for (const [key, visit] of connectVisits)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.30",
3
+ "version": "0.9.31",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"