toolroll 0.9.30 → 0.9.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/assignment.d.ts +3 -0
  3. package/dist/assignment.js +8 -2
  4. package/dist/batch-checks.d.ts +15 -0
  5. package/dist/batch-checks.js +244 -0
  6. package/dist/batch-policy.d.ts +23 -0
  7. package/dist/batch-policy.js +25 -0
  8. package/dist/browser/workspace.js +13 -13
  9. package/dist/builder.js +14 -3
  10. package/dist/chat-actions.js +2 -2
  11. package/dist/check-levels-ui.d.ts +4 -0
  12. package/dist/check-levels-ui.js +21 -2
  13. package/dist/checkout-cleanup.d.ts +2 -1
  14. package/dist/checkout-cleanup.js +24 -8
  15. package/dist/desktop-update.d.ts +1 -1
  16. package/dist/exec.d.ts +2 -0
  17. package/dist/exec.js +11 -0
  18. package/dist/flow-gallery-ui.d.ts +14 -9
  19. package/dist/flow-gallery-ui.js +71 -29
  20. package/dist/flow-gallery.d.ts +26 -2
  21. package/dist/flow-gallery.js +182 -7
  22. package/dist/lead-capabilities.d.ts +0 -7
  23. package/dist/lead-capabilities.js +11 -1
  24. package/dist/mate-doors.js +2 -2
  25. package/dist/mate.d.ts +6 -2
  26. package/dist/mate.js +63 -38
  27. package/dist/mcp-connect.d.ts +3 -1
  28. package/dist/mcp-connect.js +11 -6
  29. package/dist/never-index.d.ts +3 -0
  30. package/dist/never-index.js +21 -0
  31. package/dist/operate.js +86 -2
  32. package/dist/orphan-sweep.d.ts +63 -0
  33. package/dist/orphan-sweep.js +235 -0
  34. package/dist/result-follow-ups.d.ts +22 -2
  35. package/dist/result-follow-ups.js +44 -10
  36. package/dist/serve.js +52 -19
  37. package/dist/shared-deps.d.ts +18 -2
  38. package/dist/shared-deps.js +129 -15
  39. package/dist/staged-runtimes.d.ts +42 -0
  40. package/dist/staged-runtimes.js +191 -0
  41. package/dist/storage-sweep.d.ts +50 -0
  42. package/dist/storage-sweep.js +203 -0
  43. package/dist/storage-ui.d.ts +5 -0
  44. package/dist/storage-ui.js +30 -4
  45. package/dist/storage.d.ts +2 -0
  46. package/dist/storage.js +15 -3
  47. package/dist/store.d.ts +17 -2
  48. package/dist/store.js +37 -5
  49. package/dist/subscription-chat.d.ts +3 -1
  50. package/dist/subscription-chat.js +1 -0
  51. package/dist/surface.js +1 -0
  52. package/dist/task-status.d.ts +13 -0
  53. package/dist/task-status.js +37 -3
  54. package/dist/team-leads.js +6 -2
  55. package/dist/team-runtime.js +2 -1
  56. package/dist/test-temp.d.ts +20 -2
  57. package/dist/test-temp.js +53 -13
  58. package/dist/toolroll-update.js +2 -0
  59. package/dist/workspace-ui.js +3 -1
  60. package/dist/worktree.js +9 -0
  61. package/package.json +1 -1
package/dist/builder.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { OBSERVATION_MAILBOX, observationBrief, parseObservationCases, collectObservations } from "./observations.js";
2
2
  import { checkCommandFor, effectiveCheckLevel, recordRunCheckLevel } from "./check-levels.js";
3
+ import { queueBatchCheck } from "./batch-checks.js";
4
+ import { batchesFullCheck } from "./batch-policy.js";
3
5
  import { skillsContext } from "./project-skills.js";
4
6
  import { failedVerificationEvidence, sealVerificationReceipt, verificationEvidence, reuseObservationVerification } from "./verification-evidence.js";
5
7
  import { learningContext } from "./project-learning.js";
@@ -684,7 +686,7 @@ export async function build(store, request) {
684
686
  // copy links it instead of installing its own; a setup that is about to
685
687
  // run never reaches through an old link into a shared copy.
686
688
  const depsRoot = sharedDepsRootOf(store);
687
- const sharing = depsRoot === null ? null : sharingFor({ repo: leased.repo, worktree, setup: setupWanted });
689
+ const sharing = depsRoot === null ? null : sharingFor({ repo: leased.repo, worktree, setup: setupWanted, root: depsRoot });
688
690
  if (depsRoot !== null)
689
691
  detachShared(worktree, depsRoot);
690
692
  if (depsRoot !== null && sharing !== null) {
@@ -2175,6 +2177,11 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2175
2177
  const configured = chosenCheck?.command ?? null;
2176
2178
  if (repo !== null && chosenCheck !== null)
2177
2179
  recordRunCheckLevel(store, { id: runId, taskId: request.taskId, repo }, chosenCheck.level, checkChoice.from, now());
2180
+ // Batch checks (batch-checks.ts): with the project's batching on, a Full check waits to run once with
2181
+ // other results on a temporary batch commit. The build seals no check of its own; the batch's sealed
2182
+ // receipt is this result's check, and Merge waits for it.
2183
+ const batched = repo !== null && chosenCheck !== null && batchesFullCheck(store, repo, chosenCheck.level, configured);
2184
+ const gateCommand = batched ? null : configured;
2178
2185
  let verifyCommand;
2179
2186
  const checkLog = [];
2180
2187
  const checkOutcomes = [];
@@ -2210,6 +2217,10 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2210
2217
  else if (configured === null) {
2211
2218
  verifyCommand = { configured: false };
2212
2219
  }
2220
+ else if (batched && repo !== null) {
2221
+ verifyCommand = { configured: false };
2222
+ queueBatchCheck(store, { runId, taskId: request.taskId, repo, head: sealedHead, base: captured.baseRevision, command: configured }, now());
2223
+ }
2213
2224
  else if (!store.proveRunnerCustodyForSpawn(runId, now())) {
2214
2225
  verifyCommand = { configured: true, ran: false, attemptFailed: true, failure: "custody-lost" };
2215
2226
  recordCheckNote("Verification did not start: this worker no longer owned the build.");
@@ -2258,7 +2269,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2258
2269
  const live = linked === null ? null : store.liveWorktreeSetup(repo);
2259
2270
  if (linked === null || live === null)
2260
2271
  return null;
2261
- if (sharingFor({ repo, worktree, setup: live })?.key === linked && readyCopy(depsRoot, linked) !== null)
2272
+ if (sharingFor({ repo, worktree, setup: live, root: depsRoot })?.key === linked && readyCopy(depsRoot, linked) !== null)
2262
2273
  return null;
2263
2274
  if (!store.proveRunnerCustodyForSpawn(runId, now()))
2264
2275
  return "custody-lost";
@@ -2497,7 +2508,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2497
2508
  }
2498
2509
  const { verdict, reasons, matrix, machineVerdict } = adjudicate({
2499
2510
  directAssessment: true,
2500
- ...(configured === null ? {} : { verificationCommand: configured.command }),
2511
+ ...(gateCommand === null ? {} : { verificationCommand: gateCommand.command }),
2501
2512
  proofArtifactPresent,
2502
2513
  proofParse,
2503
2514
  handoffPresent: handoffArtifact !== null,
@@ -5,7 +5,7 @@ import { TEXT_LIMITS } from "./text-limits.js";
5
5
  import { publicChatText } from "./chat-display.js";
6
6
  import { gateWords } from "./approval-policy.js";
7
7
  import { createHash, randomBytes } from "node:crypto";
8
- import { verifiedAuthor } from "./store.js";
8
+ import { mateTurnKeepsProposals, verifiedAuthor } from "./store.js";
9
9
  import { isVerifiedApprover, reproveApprover, } from "./principal.js";
10
10
  import { changeSkills, importSkill, skillsView, skillsVersion, testSkill, validateSkill, } from "./project-skills.js";
11
11
  import { changeKnowledge, knowledgeVersion, knowledgeView, } from "./project-knowledge.js";
@@ -1025,7 +1025,7 @@ function savedActionContext(store, who, id, state) {
1025
1025
  session.ceilingDigest !== who.ceilingDigest ||
1026
1026
  proposal.ceilingDigest !== who.ceilingDigest ||
1027
1027
  (!shared && turn?.session !== session.id) ||
1028
- turn?.state !== "answered")
1028
+ !mateTurnKeepsProposals(turn))
1029
1029
  throw Error("This conversation ended or its project access changed. Ask for a fresh proposal.");
1030
1030
  requireActor(store, who, payload.repo, payload.operation);
1031
1031
  return payload;
@@ -19,6 +19,10 @@ export type CheckSettingsView = {
19
19
  on: boolean;
20
20
  source: "project" | "hands-off" | "default";
21
21
  };
22
+ /** Batch checks (batch-checks.ts): off by default. */
23
+ batch?: {
24
+ on: boolean;
25
+ };
22
26
  said: string | null;
23
27
  problem: string | null;
24
28
  };
@@ -3,6 +3,7 @@
3
3
  * one. Every change takes the password and lands in the ledger; approving a
4
4
  * command restates exactly what will run, unattended, before the yes. */
5
5
  import { CHECK_LEVELS, CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS } from "./check-levels.js";
6
+ import { BATCH_HINT } from "./batch-policy.js";
6
7
  const escape = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
7
8
  export function checkSettingsHtml(view) {
8
9
  const hidden = (act) => `<input type="hidden" name="csrf" value="${escape(view.csrf)}"><input type="hidden" name="repo" value="${escape(view.repo)}"><input type="hidden" name="act" value="${act}">`;
@@ -19,7 +20,7 @@ export function checkSettingsHtml(view) {
19
20
  (fallsBack ? `<p class="meta">No quick command yet, so builds run the full check.</p>` : "") +
20
21
  (view.level !== "full" && view.full !== null ? `<p class="meta">The full check still runs when a pull request opens, and Merge waits for it.</p>` : "");
21
22
  if (!view.canChange) {
22
- return head + `<section class="card check-settings">${current}</section>` + reviewHtml(view, "") +
23
+ return head + `<section class="card check-settings">${current}</section>` + batchHtml(view, "") + reviewHtml(view, "") +
23
24
  `<section class="card check-settings"><h2>Quick check</h2>${view.quick === null ? `<p class="meta">None yet.</p>` : command(view.quick)}</section>` +
24
25
  `<section class="card check-settings"><h2>Full check</h2>${view.full === null ? `<p class="meta">None yet.</p>` : command(view.full)}</section>`;
25
26
  }
@@ -45,7 +46,25 @@ export function checkSettingsHtml(view) {
45
46
  `<p class="meta">Quick builds run the full check instead.</p>${password}<button type="submit" class="danger">Remove</button></form></details></section>`;
46
47
  const full = `<details class="settings-more"><summary>Full check</summary><section class="card check-settings">` +
47
48
  (view.full === null ? `<p class="meta">None yet. Approve one with <span class="mono">toolroll verify set --repo … --command "…"</span>.</p>` : command(view.full)) + `</section></details>`;
48
- return head + levelForm + reviewHtml(view, password) + quick + full;
49
+ return head + levelForm + batchHtml(view, password) + reviewHtml(view, password) + quick + full;
50
+ }
51
+ /** Batch checks: one state and one sentence; turning it on restates exactly what happens, behind a disclosure with the password. */
52
+ function batchHtml(view, password) {
53
+ const batch = view.batch;
54
+ if (batch === undefined)
55
+ return "";
56
+ const act = batch.on ? "Turn off" : "Turn on";
57
+ const terms = batch.on ? `<p class="meta">Each result runs its own full check again. Results already waiting are checked now.</p>`
58
+ : `<ul class="check-terms"><li>When results finish within 10 minutes of each other, they're merged in a temporary checkout and the full check runs once.</li>` +
59
+ `<li>If it fails, the batch is split until the result that breaks it is found. Results that conflict are checked on their own.</li>` +
60
+ `<li>Nothing is merged into a real branch. Each result still lands on its own.</li></ul>`;
61
+ const change = password === "" ? ""
62
+ : `<details class="settings-more"><summary>${act}</summary><form method="post" action="/settings/checks" class="check-settings">` +
63
+ `<input type="hidden" name="csrf" value="${escape(view.csrf)}"><input type="hidden" name="repo" value="${escape(view.repo)}"><input type="hidden" name="act" value="batch">` +
64
+ `<input type="hidden" name="on" value="${batch.on ? "0" : "1"}">${terms}${password}<button type="submit">${act}</button></form></details>`;
65
+ return `<section class="card check-settings" data-batch-checks="${batch.on ? "on" : "off"}"><h2>Batch checks</h2>` +
66
+ `<p><strong>${batch.on ? "On" : "Off"}</strong> · ${escape(BATCH_HINT)}</p>` +
67
+ (batch.on && view.level !== "full" ? `<p class="meta">Only Full checks are batched.</p>` : "") + `${change}</section>`;
49
68
  }
50
69
  /** Automatic review: one state, one sentence, and its one change behind a disclosure (the password). */
51
70
  function reviewHtml(view, password) {
@@ -1,7 +1,7 @@
1
1
  import { type CheckoutCleanup } from "./storage.js";
2
2
  import type { Store, WorktreeRow } from "./store.js";
3
3
  import { type WorktreePool } from "./worktree.js";
4
- /** A release candidate's checkout holds the build a deploy installs: kept a week after its task was done. */
4
+ /** A release candidate's checkout holds the build a deploy installs: the newest complete one waits at most a week for it. */
5
5
  export declare const CANDIDATE_KEEP_MS: number;
6
6
  /** The worker's pass removes at most this many; a clean-up by hand has no cap. */
7
7
  export declare const AUTO_MAX = 20;
@@ -43,6 +43,7 @@ type Status = {
43
43
  export type Deployed = {
44
44
  head: string | null;
45
45
  run: number | null;
46
+ at?: string | null;
46
47
  };
47
48
  export declare function deployedRelease(stateDir: string | null): Deployed | null;
48
49
  /**
@@ -7,9 +7,10 @@
7
7
  *
8
8
  * Always kept: a checkout in use, one whose task isn't finished (on hold, failed, a revision under way), a result
9
9
  * still waiting for review, a checkout with changes (files Toolroll wrote itself aside), and one with commits on no
10
- * branch. A release candidate's checkout (a deploy installs from it) is kept only while it is the newest gate for its
11
- * release (for a week after it was done) or the one deployed; a superseded or cancelled gate goes at once, whatever
12
- * its task's state (staged releases keep the rollback copy). A checkout no task names (adopted after a crash) goes only
10
+ * branch. A release check's checkout (a deploy installs from it) goes once its task is complete, with its dependency
11
+ * install and journey output, except the one deployed now and the project's newest complete one still waiting for its
12
+ * deploy (no deploy since it was complete; at most a week). A superseded or cancelled gate goes at once, whatever its
13
+ * task's state (staged releases keep the rollback copy). A checkout no task names (adopted after a crash) goes only
13
14
  * in a clean-up by hand. Every removal is in the ledger.
14
15
  *
15
16
  * A kept checkout waiting for review or with changes drops its dependencies and build output once its run ends
@@ -23,7 +24,7 @@ import { COMPLETION_ACTION } from "./result-completion.js";
23
24
  import { CLEANUP_CHOICES, bytesWords, diskBytes } from "./storage.js";
24
25
  import { worktreePath } from "./worktree.js";
25
26
  const DAY_MS = 86_400_000;
26
- /** A release candidate's checkout holds the build a deploy installs: kept a week after its task was done. */
27
+ /** A release candidate's checkout holds the build a deploy installs: the newest complete one waits at most a week for it. */
27
28
  export const CANDIDATE_KEEP_MS = 7 * DAY_MS;
28
29
  /** The worker's pass removes at most this many; a clean-up by hand has no cap. */
29
30
  export const AUTO_MAX = 20;
@@ -56,7 +57,7 @@ export function deployedRelease(stateDir) {
56
57
  continue;
57
58
  newest = { at, head: typeof journal["candidate"] === "string" ? journal["candidate"] : null, run: Number.isSafeInteger(journal["builder"]) ? Number(journal["builder"]) : null };
58
59
  }
59
- return newest === null ? null : { head: newest.head, run: newest.run };
60
+ return newest === null ? null : { head: newest.head, run: newest.run, at: newest.at === "" ? null : newest.at };
60
61
  }
61
62
  /** The folder beside the database (where staged releases live), or null for a database in memory. */
62
63
  function stateDirOf(store) {
@@ -117,6 +118,19 @@ export function taskStatuses(store, now, deployed) {
117
118
  if (ref !== undefined && (completedRef.get(ref) ?? "") < at)
118
119
  completedRef.set(ref, at);
119
120
  }
121
+ const completedAtOf = (row) => [completed.get(String(row["id"])), completedRef.get(Number(row["ref"]))].filter((one) => one !== undefined).sort().at(-1) ?? null;
122
+ // Per project, the newest complete gate (by when it was complete): the one a deploy may still be about to install.
123
+ // Once the project deployed after it was complete, it waits for nothing.
124
+ const deployedRepo = deployedRef === undefined ? (live?.head == null ? undefined : gates.find(row => String(row["candidate"]) === live.head)?.["repo"]) : refs.find(row => Number(row["ref"]) === deployedRef)?.["repo"];
125
+ const newestDone = new Map();
126
+ for (const row of gates) {
127
+ if (row["state"] !== "done")
128
+ continue;
129
+ const at = completedAtOf(row) ?? (row["updated_at"] === null ? "" : String(row["updated_at"]));
130
+ const seen = newestDone.get(String(row["repo"]));
131
+ if (seen === undefined || at > seen.at || (at === seen.at && Number(row["ref"]) > seen.ref))
132
+ newestDone.set(String(row["repo"]), { ref: Number(row["ref"]), at });
133
+ }
120
134
  const byRef = new Map();
121
135
  const byId = new Map();
122
136
  const parentOf = new Map();
@@ -139,11 +153,13 @@ export function taskStatuses(store, now, deployed) {
139
153
  else if (state === "cancelled")
140
154
  status = { keep: null, taskId: id, finishedAt: updated };
141
155
  else {
142
- const completedAt = [completed.get(id), completedRef.get(ref)].filter((one) => one !== undefined).sort().at(-1) ?? null;
156
+ const completedAt = completedAtOf(row);
143
157
  if (gate) {
144
158
  const since = completedAt ?? updated ?? now.toISOString();
145
159
  const until = new Date(Date.parse(since) + CANDIDATE_KEEP_MS).toISOString();
146
- status = until > now.toISOString() ? { keep: "release candidate", taskId: id, finishedAt: since, dueAt: until } : { keep: null, taskId: id, finishedAt: since };
160
+ const deployedSince = live?.at != null && deployedRepo !== undefined && String(row["repo"]) === String(deployedRepo) && live.at >= since;
161
+ const waiting = newestDone.get(String(row["repo"]))?.ref === ref && !deployedSince && until > now.toISOString();
162
+ status = waiting ? { keep: "release candidate", taskId: id, finishedAt: since, dueAt: until } : { keep: null, taskId: id, finishedAt: since, atOnce: true };
147
163
  }
148
164
  else
149
165
  status = completedAt === null ? { keep: "waiting for review", taskId: id, finishedAt: null } : { keep: null, taskId: id, finishedAt: completedAt };
@@ -353,7 +369,7 @@ export function whyWords(item) {
353
369
  case "in use": return "in use";
354
370
  case "task not finished": return "its task isn't finished";
355
371
  case "waiting for review": return "its result is waiting for review";
356
- case "release candidate": return item.dueAt === undefined ? "the deployed release candidate" : `newest release candidate, kept until ${item.dueAt.slice(0, 10)}`;
372
+ case "release candidate": return item.dueAt === undefined ? "the deployed release candidate" : `newest release candidate, kept for its deploy until ${item.dueAt.slice(0, 10)}`;
357
373
  case "has changes": return "has changes";
358
374
  case "has commits": return "has commits on no branch";
359
375
  case "unreadable": return "git couldn't read it";
@@ -125,13 +125,13 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
125
125
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
126
126
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
127
127
  export declare function desktopUpdateStatus(stateDir: string): {
128
- error?: never;
129
128
  active: boolean;
130
129
  phase: string;
131
130
  detail: string;
132
131
  running: boolean;
133
132
  canResume: boolean;
134
133
  canCancel: boolean;
134
+ error?: never;
135
135
  automaticRecovery?: never;
136
136
  recoveryAttempts?: never;
137
137
  recoveryRepairs?: never;
package/dist/exec.d.ts CHANGED
@@ -56,6 +56,8 @@ export type RunOptions = ProcessTreeObserver & {
56
56
  * timeoutMs there is deliberately no absolute wall-clock ceiling.
57
57
  */
58
58
  idleTimeoutMs?: number;
59
+ /** Ends a process-group run early, exactly as its timeout does: the whole group is killed and the result reads timed out. */
60
+ signal?: AbortSignal;
59
61
  maxBuffer?: number;
60
62
  /**
61
63
  * Extra environment, merged over the process's own. A capability probe is a
package/dist/exec.js CHANGED
@@ -526,6 +526,7 @@ export function run(file, args, options = {}) {
526
526
  return retryTransientSpawn(() => runBufferedGroup(file, args, {
527
527
  timeoutMs,
528
528
  maxBuffer,
529
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
529
530
  ...(options.stdin === undefined ? {} : { stdin: options.stdin }),
530
531
  ...(cwd === undefined ? {} : { cwd }),
531
532
  ...(childEnv === undefined ? {} : { childEnv }),
@@ -610,6 +611,15 @@ function runBufferedGroup(file, args, bag) {
610
611
  timedOut = true;
611
612
  killGroup(child);
612
613
  }, bag.timeoutMs);
614
+ // The caller's deadline, when it owns one: the same group kill as the timer.
615
+ const aborted = () => {
616
+ timedOut = true;
617
+ killGroup(child);
618
+ };
619
+ if (bag.signal?.aborted === true)
620
+ aborted();
621
+ else
622
+ bag.signal?.addEventListener("abort", aborted, { once: true });
613
623
  child.stdout?.setEncoding("utf8");
614
624
  child.stdout?.on("data", (chunk) => {
615
625
  stdout += chunk;
@@ -638,6 +648,7 @@ function runBufferedGroup(file, args, bag) {
638
648
  return;
639
649
  settled = true;
640
650
  clearTimeout(timer);
651
+ bag.signal?.removeEventListener("abort", aborted);
641
652
  liveProviders.delete(child);
642
653
  // The OS object is settled BEFORE the answer: a root that exited is
643
654
  // not an empty container until the OS says so.
@@ -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. */