toolroll 0.9.1 → 0.9.2

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/dist/cli.js CHANGED
@@ -85,6 +85,8 @@ and any queue command + --help prints it too
85
85
  toolroll heartbeat <lease> / release <lease> / reap
86
86
  toolroll tick --runner <name> --token <t> --repo <path>
87
87
  one unattended pass over the ready set
88
+ toolroll flows list | show <id> | create | edit | trigger | script | card | archive
89
+ set up and inspect flows (writes preview until --yes)
88
90
  toolroll chat --as <you> --token <t>
89
91
  talk to the mate: one conversation across every
90
92
  project; it proposes, you confirm (--say "…" for one turn)
@@ -239,6 +241,7 @@ export const OPERATE_COMMANDS = new Set([
239
241
  "publish",
240
242
  "reconcile",
241
243
  "routine",
244
+ "flows",
242
245
  "config", "chat", "proposals", "mode", "people", "keys",
243
246
  "setup",
244
247
  "verify",
@@ -66,7 +66,6 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
66
66
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
67
67
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
68
68
  export declare function desktopUpdateStatus(stateDir: string): {
69
- version?: never;
70
69
  active: boolean;
71
70
  phase: string;
72
71
  detail: string;
@@ -80,6 +79,7 @@ export declare function desktopUpdateStatus(stateDir: string): {
80
79
  wasRunning?: never;
81
80
  backupPath?: never;
82
81
  updateId?: never;
82
+ version?: never;
83
83
  buildId?: never;
84
84
  workDir?: never;
85
85
  } | {
@@ -88,6 +88,15 @@ export type FlowInsights = {
88
88
  scripts: ScriptInsight[];
89
89
  sorts: SortInsight[];
90
90
  runs: StepRunInsight[];
91
+ /** A plane review's problems, the ones seen on the most days first: which causes keep coming back. */
92
+ recurring: RecurringProblem[];
93
+ };
94
+ export type RecurringProblem = {
95
+ problem: string;
96
+ title: string;
97
+ days: number;
98
+ lastSeen: string;
99
+ card: number | null;
91
100
  };
92
101
  /** One flow's insights over the last `days` days. */
93
102
  export declare function flowInsights(store: Store, flow: FlowRow, now: Date, days?: number): FlowInsights;
@@ -83,12 +83,28 @@ export function flowInsights(store, flow, now, days = 30) {
83
83
  .map(one => ({ zone: one.zone, title: one.title, problems: one.failed + one.sentBack, of: Math.max(one.entered, one.failed + one.sentBack) })),
84
84
  scripts: [...scripts].map(([script, one]) => ({ script, runs: one.runs, passed: one.passed, failed: one.failed, typicalSeconds: median(one.durations) === null ? null : Math.round(median(one.durations)), lastFailure: one.lastFailure })),
85
85
  sorts,
86
+ recurring: recurringProblems(store, flow, since),
86
87
  runs: runs.slice(0, 40).map(run => ({
87
88
  card: run.card, cardTitle: run.cardTitle, entry: run.entry, zone: run.stage, zoneTitle: titleOf(run.stage), kind: run.kind, script: run.script, version: run.scriptVersion,
88
89
  state: run.state, result: run.result, exitCode: run.exitCode, durationMs: run.durationMs, at: run.finishedAt ?? run.startedAt, hasLog: run.log !== null && run.log !== "",
89
90
  })),
90
91
  };
91
92
  }
93
+ /** Each problem a plane review trigger of this flow noted in the window (one note per problem a day), and on how many days. */
94
+ function recurringProblems(store, flow, since) {
95
+ const rows = store.handle.prepare(`SELECT e.key, e.card, e.at, c.title FROM flow_trigger_event e JOIN flow_trigger t ON t.id = e.trigger LEFT JOIN flow_card c ON c.id = e.card
96
+ WHERE t.flow = ? AND json_extract(t.config_json, '$.kind') = 'plane-review' AND substr(e.key, 1, 6) = 'plane:' AND e.at >= ? ORDER BY e.rowid`).all(flow.id, since.toISOString());
97
+ const problems = new Map();
98
+ for (const row of rows) {
99
+ const match = /^plane:(.+):(\d{4}-\d{2}-\d{2})$/.exec(String(row["key"]));
100
+ if (match === null)
101
+ continue;
102
+ const had = problems.get(match[1]);
103
+ const card = row["card"] === null ? had?.card ?? null : Number(row["card"]);
104
+ problems.set(match[1], { problem: match[1], title: row["title"] === null ? had?.title ?? match[1] : String(row["title"]), days: (had?.days ?? 0) + 1, lastSeen: match[2], card });
105
+ }
106
+ return [...problems.values()].sort((a, b) => b.days - a.days || b.lastSeen.localeCompare(a.lastSeen)).slice(0, 20);
107
+ }
92
108
  const BANDS = [[90, 101], [80, 90], [70, 80], [50, 70]];
93
109
  function sortInsight(stage, runs, byCard) {
94
110
  const sort = stage.sort;
@@ -1,6 +1,6 @@
1
1
  import { type FlowStepInput } from "./flows.js";
2
2
  import type { FlowRow, Store } from "./store.js";
3
- export declare const STARTER_IDS: readonly ["ci-fix", "issue-task", "overnight"];
3
+ export declare const STARTER_IDS: readonly ["ci-fix", "issue-task", "overnight", "plane-review"];
4
4
  export type StarterId = (typeof STARTER_IDS)[number];
5
5
  export type StarterFlow = {
6
6
  id: StarterId;
@@ -12,16 +12,21 @@ export type StarterFlow = {
12
12
  does: (where: {
13
13
  github: string | null;
14
14
  branch: string;
15
+ own: boolean;
15
16
  }) => string[];
16
17
  /** What it never does. */
17
18
  never: string;
18
19
  github: boolean;
19
20
  steps: FlowStepInput[];
21
+ /** The steps in Toolroll's own repository, when they differ: there, every fix lands in Toolroll. */
22
+ ownSteps?: FlowStepInput[];
20
23
  trigger: (where: {
21
24
  branch: string;
22
25
  }) => Record<string, unknown>;
23
26
  };
24
27
  export declare const STARTER_FLOWS: readonly StarterFlow[];
28
+ /** Whether a project is Toolroll's own source. */
29
+ export declare function isToolrollRepo(repo: string): boolean;
25
30
  export declare const starterOf: (id: string) => StarterFlow | null;
26
31
  /** The flow a starter made in a project, while it is on. */
27
32
  export declare function starterFlowOf(store: Store, starter: StarterFlow, repo: string): FlowRow | null;
@@ -4,11 +4,13 @@
4
4
  * and its trigger, exactly as drawing them by hand would; it is on while that flow is. Each says in plain words
5
5
  * what it will do and what it never does: no starter merges or ships anything without a person.
6
6
  */
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
7
9
  import { flowDefinitionOf } from "./flow-engine.js";
8
10
  import { addFlowTriggerTo, githubRepoOf } from "./flow-triggers.js";
9
11
  import { flowFromSteps, ISSUE_LABEL } from "./flows.js";
10
12
  import { publishingOf } from "./pull-request-flow.js";
11
- export const STARTER_IDS = ["ci-fix", "issue-task", "overnight"];
13
+ export const STARTER_IDS = ["ci-fix", "issue-task", "overnight", "plane-review"];
12
14
  export const STARTER_FLOWS = [
13
15
  {
14
16
  id: "ci-fix",
@@ -51,7 +53,47 @@ export const STARTER_FLOWS = [
51
53
  ],
52
54
  trigger: () => ({ kind: "button", label: "Queue for tonight", questions: ["What needs doing?", "Details"] }),
53
55
  },
56
+ {
57
+ id: "plane-review",
58
+ name: "Morning plane review",
59
+ summary: "What went wrong in Toolroll yesterday becomes cards, researched and fixed.",
60
+ does: ({ own }) => [
61
+ "Every day at 07:30, looks at failed runs, waiting tasks, sign-ins, plan limits, chat, integrations and checks.",
62
+ "One card per problem; one that comes back joins its card. A clean day adds nothing.",
63
+ own ? "Finds the cause in Toolroll's code, builds a fix under your usual approvals, and opens a pull request."
64
+ : "Finds the cause. One in this project gets a fix under your usual approvals and a pull request; one in Toolroll's settings gets a note of what to change.",
65
+ "Anything that fails waits in “Needs a look”.",
66
+ ],
67
+ never: "Never merges or ships anything without you. Each fix waits for your approval.",
68
+ github: false,
69
+ steps: planeReviewSteps(false),
70
+ ownSteps: planeReviewSteps(true),
71
+ trigger: () => ({ kind: "plane-review", at: "07:30" }),
72
+ },
54
73
  ];
74
+ /** The morning plane review's steps: find the cause, fix it, open a pull request; any failure waits for a person. */
75
+ function planeReviewSteps(own) {
76
+ const where = own
77
+ ? "This project is Toolroll itself, so the fix belongs here. Find the root cause in this code: cite the files and lines, say why it happened, and say what change fixes it."
78
+ : "This project uses Toolroll; it isn't Toolroll's own code. Find the root cause. If it is in this project's code, cite the files and lines and say what change fixes it. If it is in Toolroll or its settings (a sign-in, a plan limit, a chat app, an integration, the worker), say exactly what to change in Toolroll's settings, and start the report with “No build needed”.";
79
+ return [
80
+ { id: "find-cause", title: "Find the cause", kind: "report", ifFails: "needs-a-look",
81
+ instructions: `The morning plane review found a problem: {{card.title}}\n\n{{card.description}}\n\n${where}\n\nFeedback to address (if any): {{note}}` },
82
+ { id: "fix", title: "Fix it", kind: "task", ifFails: "needs-a-look",
83
+ instructions: `Fix the root cause of this problem, with a regression check where one fits: {{card.title}}\n\nThe report:\n{{stage.find-cause}}${own ? "" : "\n\nIf the report says “No build needed”, the cause is outside this project: change nothing and say so."}\n\nChanges asked for (if any): {{note}}` },
84
+ { id: "pull-request", title: "Pull request", kind: "pull-request", next: "done", ifFails: "needs-a-look" },
85
+ { id: "needs-a-look", title: "Needs a look", kind: "inbox", next: "done" },
86
+ ];
87
+ }
88
+ /** Whether a project is Toolroll's own source. */
89
+ export function isToolrollRepo(repo) {
90
+ try {
91
+ return JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).name === "toolroll";
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
55
97
  export const starterOf = (id) => STARTER_FLOWS.find(one => one.id === id) ?? null;
56
98
  /** The flow a starter made in a project, while it is on. */
57
99
  export function starterFlowOf(store, starter, repo) {
@@ -64,7 +106,7 @@ export function startersFor(store, repo) {
64
106
  const branch = publishing.on ? publishing.base : "main";
65
107
  return STARTER_FLOWS.map(starter => {
66
108
  const flow = starterFlowOf(store, starter, repo);
67
- return { id: starter.id, name: starter.name, summary: starter.summary, does: starter.does({ github, branch }), never: starter.never,
109
+ return { id: starter.id, name: starter.name, summary: starter.summary, does: starter.does({ github, branch, own: isToolrollRepo(repo) }), never: starter.never,
68
110
  on: flow === null ? null : { flow: flow.id }, blocked: starter.github && github === null ? "This project isn't on GitHub, so this can't watch it." : null };
69
111
  });
70
112
  }
@@ -78,7 +120,8 @@ export function switchOnStarter(store, starter, repo, by, now, dir) {
78
120
  const publishing = publishingOf(store, repo);
79
121
  try {
80
122
  const flow = store.transact(() => {
81
- const id = store.createFlow({ repo, name: starter.name, definitionJson: JSON.stringify(flowFromSteps(starter.steps, null)), by }, now);
123
+ const steps = starter.ownSteps !== undefined && isToolrollRepo(repo) ? starter.ownSteps : starter.steps;
124
+ const id = store.createFlow({ repo, name: starter.name, definitionJson: JSON.stringify(flowFromSteps(steps, null)), by }, now);
82
125
  const made = store.getFlow(id);
83
126
  const trigger = addFlowTriggerTo(store, made, { ...starter.trigger({ branch: publishing.on ? publishing.base : "main" }), zone: flowDefinitionOf(made).start }, by, now, dir);
84
127
  if (!trigger.ok)
@@ -3,7 +3,7 @@ import { type FlowAct } from "./flow-engine.js";
3
3
  import type { FlowDefinition } from "./flows.js";
4
4
  import { type MailReader } from "./mailbox.js";
5
5
  import type { FlowCardSource, FlowRow, FlowTriggerRow, Store } from "./store.js";
6
- export declare const FLOW_TRIGGER_KINDS: readonly ["button", "schedule", "github", "linear", "flow", "webhook", "email", "chat"];
6
+ export declare const FLOW_TRIGGER_KINDS: readonly ["button", "schedule", "github", "linear", "flow", "webhook", "email", "chat", "plane-review"];
7
7
  export type FlowTriggerKind = (typeof FLOW_TRIGGER_KINDS)[number];
8
8
  export type TriggerConfig = {
9
9
  kind: "button";
@@ -64,6 +64,12 @@ export type TriggerConfig = {
64
64
  chat: string;
65
65
  binding: number;
66
66
  zone: string | null;
67
+ }
68
+ /** `schedule`: always daily, in the routines' form ("daily:07:30@Europe/London"). */
69
+ | {
70
+ kind: "plane-review";
71
+ schedule: string;
72
+ zone: string | null;
67
73
  };
68
74
  export type ChatApp = "slack" | "discord" | "teams" | "telegram";
69
75
  export declare const CHAT_APP_NAMES: Record<ChatApp, string>;
@@ -180,6 +186,19 @@ export declare function checkFlowTriggerNow(store: Store, trigger: FlowTriggerRo
180
186
  ok: boolean;
181
187
  said: string;
182
188
  }>;
189
+ /**
190
+ * A plane review: the last 24 hours, one card per problem worth fixing. Each problem is noted once a day; a problem
191
+ * whose card is still open joins it (a note with the day's counts and evidence) instead of making another, and one
192
+ * whose card is finished starts a new card, since it came back. A clean day adds nothing and tells nobody.
193
+ * `scheduled` moves the trigger to its next morning; "Run now" leaves its times alone.
194
+ */
195
+ export declare function firePlaneReview(store: Store, trigger: FlowTriggerRow, config: Extract<TriggerConfig, {
196
+ kind: "plane-review";
197
+ }>, now: Date, scheduled: boolean): Promise<{
198
+ added: number;
199
+ joined: number;
200
+ said: string;
201
+ }>;
183
202
  /** v96: a schedule (a teammate's routine) makes its card now, once per press, and keeps its own times. */
184
203
  export declare function runScheduleNow(store: Store, trigger: FlowTriggerRow, actor: string, now: Date): {
185
204
  ok: true;
@@ -13,6 +13,11 @@
13
13
  * read-only over IMAP; optionally only from some senders or with words in the subject.
14
14
  * - chat — messages in a Slack, Discord or Teams channel (v89), connected from the
15
15
  * channel itself (chat-inbox.ts), never from here.
16
+ * - plane-review — every day at a set time (07:30 in the computer's time zone unless it says),
17
+ * the plane's last 24 hours read straight from the store (plane-review.ts): one card
18
+ * per distinct problem worth fixing, and the same problem on a later day joins its
19
+ * card while that card is still open. A clean day makes nothing. Its row is kept as a
20
+ * 'schedule' (the table's kinds are fixed); its settings say what it is.
16
21
  *
17
22
  * GitHub and Linear are checked by the worker's pass — `gh` and one HTTPS
18
23
  * request, never a model — or pushed to a secret webhook address when the
@@ -32,11 +37,11 @@ import { mailboxAccess, mailCursorOf, mailCursorText, readThroughImap } from "./
32
37
  import { readFlowSecrets } from "./flow-secrets.js";
33
38
  import { tmpdir } from "node:os";
34
39
  import { envValue } from "./names.js";
35
- export const FLOW_TRIGGER_KINDS = ["button", "schedule", "github", "linear", "flow", "webhook", "email", "chat"];
40
+ export const FLOW_TRIGGER_KINDS = ["button", "schedule", "github", "linear", "flow", "webhook", "email", "chat", "plane-review"];
36
41
  export const CHAT_APP_NAMES = { slack: "Slack", discord: "Discord", teams: "Teams", telegram: "Telegram" };
37
42
  /** What each kind is called on the canvas. */
38
43
  export const FLOW_TRIGGER_WORDS = {
39
- button: "Button", schedule: "Schedule", github: "GitHub", linear: "Linear", flow: "Another flow", webhook: "Webhook", email: "Email inbox", chat: "Chat channel",
44
+ button: "Button", schedule: "Schedule", github: "GitHub", linear: "Linear", flow: "Another flow", webhook: "Webhook", email: "Email inbox", chat: "Chat channel", "plane-review": "Plane review",
40
45
  };
41
46
  /** How often the worker checks GitHub and Linear, and how it backs off when they don't answer. */
42
47
  export const POLL_EVERY_MS = 2 * 60_000;
@@ -116,7 +121,7 @@ export function validateTriggerConfig(raw, context) {
116
121
  const input = raw;
117
122
  const kind = input["kind"];
118
123
  if (!FLOW_TRIGGER_KINDS.includes(kind))
119
- throw new Error("Choose what starts cards: a button, a schedule, GitHub, Linear, another flow, a webhook, an email inbox or a chat channel.");
124
+ throw new Error("Choose what starts cards: a button, a schedule, GitHub, Linear, another flow, a webhook, an email inbox, a chat channel or a plane review.");
120
125
  const named = words(input, "zone", 60, false);
121
126
  const zoneStage = named === null ? null : context.definition.stages.find(one => one.id === named || one.title.toLowerCase() === named.toLowerCase());
122
127
  if (named !== null && zoneStage === undefined)
@@ -199,6 +204,15 @@ export function validateTriggerConfig(raw, context) {
199
204
  case "chat":
200
205
  // Connecting a channel proves it's one the person is in: it happens in the channel, not here.
201
206
  throw new Error(`Connect a chat channel from the channel itself: where Toolroll is in Slack, Discord, Teams or a Telegram group, send “flow ${context.flow.id}”.`);
207
+ case "plane-review": {
208
+ const at = words(input, "at", 5, false) ?? "07:30";
209
+ const clock = /^(\d{1,2}):(\d{2})$/.exec(at);
210
+ const zoneName = words(input, "timeZone", 60, false) ?? localTimeZone();
211
+ const schedule = clock === null ? null : scheduleFromWords(`daily ${clock[1]}:${clock[2]} ${zoneName}`);
212
+ if (schedule === null)
213
+ throw new Error("Say the time it reviews the day as HH:MM, like 07:30, and a time zone like Europe/London.");
214
+ return { kind: "plane-review", schedule, zone };
215
+ }
202
216
  case "email": {
203
217
  const folder = words(input, "folder", 100, false) ?? "INBOX";
204
218
  const sender = words(input, "sender", 300, false);
@@ -208,6 +222,15 @@ export function validateTriggerConfig(raw, context) {
208
222
  }
209
223
  }
210
224
  }
225
+ /** The computer's time zone, as the routines name one. */
226
+ function localTimeZone() {
227
+ try {
228
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
229
+ }
230
+ catch {
231
+ return "UTC";
232
+ }
233
+ }
211
234
  /** Addresses and domains from "priya@example.com, @shop.com, example.org". */
212
235
  function sendersOf(text) {
213
236
  return [...new Set(text.split(/[\s,;]+/).map(one => one.trim().toLowerCase().replace(/^@/, "")).filter(one => /^([a-z0-9._%+-]+@)?[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/.test(one)))].slice(0, 20);
@@ -243,6 +266,10 @@ export function describeTrigger(config, store) {
243
266
  }
244
267
  case "webhook": return "Anything posted to its webhook address";
245
268
  case "chat": return `Every new message in a ${CHAT_APP_NAMES[config.app]} channel; replies in a card's thread join its discussion`;
269
+ case "plane-review": {
270
+ const schedule = parseSchedule(config.schedule);
271
+ return `Every day at ${schedule !== null && schedule.kind !== "every" ? schedule.hhmm : config.schedule}: reviews the last 24 hours of runs, tasks, sign-ins, chat, integrations and checks, and makes a card for each problem worth fixing`;
272
+ }
246
273
  case "email": return `Email arriving in ${config.folder === "INBOX" ? "the inbox" : config.folder}${config.sender === null ? "" : ` from ${config.sender}`}${config.subject === null ? "" : ` with “${config.subject}” in the subject`} (checked every 2 minutes)`;
247
274
  }
248
275
  }
@@ -263,6 +290,10 @@ export function triggerHeadline(config, store) {
263
290
  }
264
291
  case "webhook": return { name: "Webhook", detail: "Anything posted to its address" };
265
292
  case "chat": return { name: `${CHAT_APP_NAMES[config.app]} channel`, detail: "Each message is a card" };
293
+ case "plane-review": {
294
+ const schedule = parseSchedule(config.schedule);
295
+ return { name: `Plane review at ${schedule !== null && schedule.kind !== "every" ? schedule.hhmm : "a set time"}`, detail: "Problems from the last 24 hours" };
296
+ }
266
297
  case "email": return { name: config.folder === "INBOX" ? "New email" : `New email in ${config.folder}`, detail: config.sender !== null ? `From ${config.sender}` : config.subject !== null ? `Subject has “${config.subject}”` : "Email inbox" };
267
298
  }
268
299
  }
@@ -370,9 +401,10 @@ export function addFlowTriggerTo(store, flow, raw, actor, now, dir) {
370
401
  if (takesDeliveries(config) && dir === null)
371
402
  return { ok: false, message: "Webhook addresses are set up on the console." };
372
403
  const token = takesDeliveries(config) ? randomBytes(24).toString("base64url") : null;
373
- const schedule = config.kind === "schedule" ? parseSchedule(config.schedule) : null;
404
+ const schedule = config.kind === "schedule" || config.kind === "plane-review" ? parseSchedule(config.schedule) : null;
374
405
  const id = store.addFlowTrigger({
375
- flow: flow.id, kind: config.kind, configJson: JSON.stringify(config), hookHash: token === null ? null : hookHash(token),
406
+ // The table's kinds were fixed before plane reviews: one is kept as the daily schedule it is.
407
+ flow: flow.id, kind: config.kind === "plane-review" ? "schedule" : config.kind, configJson: JSON.stringify(config), hookHash: token === null ? null : hookHash(token),
376
408
  // Only what happens from now on: never the backlog of issues, runs or finished cards.
377
409
  cursor: config.kind === "flow" ? String(store.latestFlowEvent()) : config.kind === "github" || config.kind === "linear" ? now.toISOString() : null,
378
410
  // Email: the first check notes where the mailbox stands (no cursor yet), so only mail after this becomes cards.
@@ -484,6 +516,8 @@ export async function runFlowTriggers(store, repo, now, io) {
484
516
  }
485
517
  else if (config.kind === "schedule" && trigger.nextAt !== null && Date.parse(trigger.nextAt) <= now.getTime())
486
518
  pass.added += fireSchedule(store, trigger, config, now);
519
+ else if (config.kind === "plane-review" && trigger.nextAt !== null && Date.parse(trigger.nextAt) <= now.getTime())
520
+ pass.added += (await firePlaneReview(store, trigger, config, now, true)).added;
487
521
  else if (config.kind === "flow")
488
522
  pass.added += followFlow(store, trigger, config, now);
489
523
  else if (polled(config) && (trigger.nextAt === null || Date.parse(trigger.nextAt) <= now.getTime())) {
@@ -508,8 +542,12 @@ export async function checkFlowTriggerNow(store, trigger, now, io) {
508
542
  const fired = await fireScript(store, trigger, config, now, io, false);
509
543
  return { ok: fired.ok, said: fired.said };
510
544
  }
545
+ if (config?.kind === "plane-review") {
546
+ const fired = await firePlaneReview(store, trigger, config, now, false);
547
+ return { ok: true, said: fired.said };
548
+ }
511
549
  if (config === null || !polled(config))
512
- return { ok: false, said: "Only GitHub, Linear and email triggers that are checked, and schedules that run a script, can be run now." };
550
+ return { ok: false, said: "Only GitHub, Linear and email triggers that are checked, schedules that run a script, and plane reviews can be run now." };
513
551
  const checked = await checkTrigger(store, trigger, config, now, io);
514
552
  return { ok: checked.ok, said: checked.said };
515
553
  }
@@ -536,6 +574,67 @@ function fireSchedule(store, trigger, config, now) {
536
574
  store.updateFlowTrigger(trigger.id, { nextAt: next, lastAt: now.toISOString(), lastOutcome: made.made === "added" ? "Added a card." : made.note ?? "Nothing new." }, now);
537
575
  return made.made === "added" ? 1 : 0;
538
576
  }
577
+ /** The problem a plane review's event key names, and the day: `plane:<problem>:<YYYY-MM-DD>`. */
578
+ const planeKey = (problem, day) => `plane:${problem}:${day}`;
579
+ /** The card a plane review last made or joined for one problem, if any. */
580
+ function planeCardOf(store, trigger, problem) {
581
+ const prefix = `plane:${problem}:`;
582
+ const row = store.handle.prepare("SELECT card FROM flow_trigger_event WHERE trigger = ? AND substr(key, 1, ?) = ? AND card IS NOT NULL ORDER BY rowid DESC LIMIT 1").get(trigger, prefix.length, prefix);
583
+ return row === undefined ? null : Number(row["card"]);
584
+ }
585
+ /**
586
+ * A plane review: the last 24 hours, one card per problem worth fixing. Each problem is noted once a day; a problem
587
+ * whose card is still open joins it (a note with the day's counts and evidence) instead of making another, and one
588
+ * whose card is finished starts a new card, since it came back. A clean day adds nothing and tells nobody.
589
+ * `scheduled` moves the trigger to its next morning; "Run now" leaves its times alone.
590
+ */
591
+ export async function firePlaneReview(store, trigger, config, now, scheduled) {
592
+ const schedule = parseSchedule(config.schedule);
593
+ const next = scheduled && schedule !== null && trigger.nextAt !== null ? nextFireAt(schedule, trigger.nextAt, now) : trigger.nextAt;
594
+ const flow = store.getFlow(trigger.flow);
595
+ const done = (said, added = 0, joined = 0) => {
596
+ store.updateFlowTrigger(trigger.id, { nextAt: next, lastAt: now.toISOString(), lastOutcome: said }, now);
597
+ return { added, joined, said };
598
+ };
599
+ if (flow === null || flow.state !== "active")
600
+ return done("The flow isn't active.");
601
+ // Loaded when a review first runs: the reader reaches the integrations and task status, which reach back to these triggers.
602
+ const { problemCard, problemText, reviewPlane } = await import("./plane-review.js");
603
+ // Only the projects the flow's owner may see: a flow's cards are read by everyone on its project.
604
+ const owner = store.accountOf(flow.owner);
605
+ const problems = reviewPlane(store, now, repo => owner === null || (repo !== null && store.accountCanAccess(flow.owner, repo)) || (repo === null && owner.projects === null));
606
+ const timeZone = schedule !== null && schedule.kind !== "every" ? schedule.timezone ?? "UTC" : "UTC";
607
+ const day = new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone }).format(now);
608
+ const dayWords = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone }).format(now);
609
+ let added = 0, joined = 0;
610
+ const skipped = [];
611
+ for (const problem of problems) {
612
+ const key = planeKey(problem.key, day);
613
+ if (store.flowTriggerSaw(trigger.id, key))
614
+ continue;
615
+ const had = planeCardOf(store, trigger.id, problem.key);
616
+ const card = had === null ? null : store.getFlowCard(had);
617
+ if (card !== null && card.state === "active") {
618
+ store.transact(() => {
619
+ store.addFlowComment({ card: card.id, author: "Plane review", body: `Again — ${problemText(problem, dayWords)}`.slice(0, 4000), mentions: [] }, now);
620
+ store.recordFlowTriggerEvent(trigger.id, key, card.id, problem.summary, now);
621
+ });
622
+ joined++;
623
+ continue;
624
+ }
625
+ const text = problemCard(problem, dayWords);
626
+ const made = makeCard(store, trigger, config, { key, title: text.title, description: text.description, source: { kind: "plane-review", label: "Plane review", url: null } }, "Plane review", now);
627
+ if (made.made === "added")
628
+ added++;
629
+ else if (made.note !== null)
630
+ skipped.push(made.note);
631
+ }
632
+ if (problems.length === 0)
633
+ return done("A clean day: nothing to fix.");
634
+ const parts = [added > 0 ? `Added ${plural(added, "card")}.` : null, joined > 0 ? `${plural(joined, "problem")} came back and joined ${joined === 1 ? "its card" : "their cards"}.` : null,
635
+ skipped.length > 0 ? `Left out ${plural(skipped.length, "problem")}: ${skipped[0]}.` : null].filter(one => one !== null);
636
+ return done(parts.length === 0 ? "Nothing new since this morning's review." : parts.join(" "), added, joined);
637
+ }
539
638
  /** v96: a schedule (a teammate's routine) makes its card now, once per press, and keeps its own times. */
540
639
  export function runScheduleNow(store, trigger, actor, now) {
541
640
  const config = triggerConfigOf(trigger);
@@ -0,0 +1,228 @@
1
+ import { type TriggerIo } from './flow-triggers.js';
2
+ import type { Store } from './store.js';
3
+ export declare const FLOWS_DESCRIPTORS: readonly [{
4
+ readonly action: 'list';
5
+ readonly synopsis: 'list flows: id, name, project, zones, triggers and cards waiting';
6
+ readonly mutation: 'none';
7
+ readonly flags: readonly [...{
8
+ name: string;
9
+ takesValue: boolean;
10
+ meaning: string;
11
+ }[], {
12
+ name: string;
13
+ takesValue: boolean;
14
+ meaning: string;
15
+ }];
16
+ }, {
17
+ readonly action: 'show';
18
+ readonly synopsis: 'one flow: zones with their next and failure paths, triggers and recent cards';
19
+ readonly mutation: 'none';
20
+ readonly positionals: readonly ['flow'];
21
+ readonly flags: {
22
+ name: string;
23
+ takesValue: boolean;
24
+ meaning: string;
25
+ }[];
26
+ }, {
27
+ readonly action: 'create';
28
+ readonly synopsis: 'create a flow from --template <id> (a template or starter flow) or --steps <file|->; previews until --yes';
29
+ readonly mutation: 'unkeyed';
30
+ readonly flags: readonly [...{
31
+ name: string;
32
+ takesValue: boolean;
33
+ meaning: string;
34
+ }[], {
35
+ name: string;
36
+ takesValue: boolean;
37
+ meaning: string;
38
+ }, {
39
+ name: string;
40
+ takesValue: boolean;
41
+ meaning: string;
42
+ }, {
43
+ readonly name: 'name';
44
+ readonly takesValue: true;
45
+ readonly meaning: "the flow's name (a template's label when left out)";
46
+ }, {
47
+ readonly name: 'template';
48
+ readonly takesValue: true;
49
+ readonly meaning: `one of ${string}`;
50
+ }, {
51
+ name: string;
52
+ takesValue: boolean;
53
+ meaning: string;
54
+ }];
55
+ }, {
56
+ readonly action: 'edit';
57
+ readonly synopsis: "replace a flow's steps (kept steps by id) and optionally rename it; previews until --yes";
58
+ readonly mutation: 'unkeyed';
59
+ readonly positionals: readonly ['flow'];
60
+ readonly flags: readonly [...{
61
+ name: string;
62
+ takesValue: boolean;
63
+ meaning: string;
64
+ }[], {
65
+ name: string;
66
+ takesValue: boolean;
67
+ meaning: string;
68
+ }, {
69
+ name: string;
70
+ takesValue: boolean;
71
+ meaning: string;
72
+ }, {
73
+ readonly name: 'name';
74
+ readonly takesValue: true;
75
+ readonly meaning: 'a new name';
76
+ }];
77
+ }, {
78
+ readonly action: 'trigger add';
79
+ readonly synopsis: 'add a trigger from JSON or a JSON file (any kind: button, schedule, github, linear, flow, webhook, email, chat); previews until --yes';
80
+ readonly mutation: 'unkeyed';
81
+ readonly positionals: readonly ['flow', 'trigger'];
82
+ readonly flags: readonly [...{
83
+ name: string;
84
+ takesValue: boolean;
85
+ meaning: string;
86
+ }[], {
87
+ name: string;
88
+ takesValue: boolean;
89
+ meaning: string;
90
+ }];
91
+ }, {
92
+ readonly action: 'trigger pause';
93
+ readonly synopsis: 'pause a trigger';
94
+ readonly mutation: 'identity-idempotent';
95
+ readonly positionals: readonly ['flow', 'trigger'];
96
+ readonly flags: {
97
+ name: string;
98
+ takesValue: boolean;
99
+ meaning: string;
100
+ }[];
101
+ }, {
102
+ readonly action: 'trigger resume';
103
+ readonly synopsis: 'turn a paused trigger on again';
104
+ readonly mutation: 'identity-idempotent';
105
+ readonly positionals: readonly ['flow', 'trigger'];
106
+ readonly flags: {
107
+ name: string;
108
+ takesValue: boolean;
109
+ meaning: string;
110
+ }[];
111
+ }, {
112
+ readonly action: 'trigger remove';
113
+ readonly synopsis: 'remove a trigger; cards it added stay';
114
+ readonly mutation: 'identity-idempotent';
115
+ readonly positionals: readonly ['flow', 'trigger'];
116
+ readonly flags: {
117
+ name: string;
118
+ takesValue: boolean;
119
+ meaning: string;
120
+ }[];
121
+ }, {
122
+ readonly action: 'trigger check';
123
+ readonly synopsis: 'run a checked trigger (or a schedule\'s script) now, like Check now';
124
+ readonly mutation: 'unkeyed';
125
+ readonly positionals: readonly ['flow', 'trigger'];
126
+ readonly flags: {
127
+ name: string;
128
+ takesValue: boolean;
129
+ meaning: string;
130
+ }[];
131
+ }, {
132
+ readonly action: 'script save';
133
+ readonly synopsis: "save a project script (a new version when it exists)";
134
+ readonly mutation: 'identity-idempotent';
135
+ readonly flags: readonly [...{
136
+ name: string;
137
+ takesValue: boolean;
138
+ meaning: string;
139
+ }[], {
140
+ name: string;
141
+ takesValue: boolean;
142
+ meaning: string;
143
+ }, {
144
+ readonly name: 'name';
145
+ readonly takesValue: true;
146
+ readonly meaning: 'lowercase letters, numbers and dashes';
147
+ }, {
148
+ readonly name: 'file';
149
+ readonly takesValue: true;
150
+ readonly meaning: 'run this path in the project';
151
+ }, {
152
+ readonly name: 'body';
153
+ readonly takesValue: true;
154
+ readonly meaning: 'a local file holding the script itself';
155
+ }, {
156
+ readonly name: 'about';
157
+ readonly takesValue: true;
158
+ readonly meaning: 'one line: what it checks or does';
159
+ }, {
160
+ readonly name: 'language';
161
+ readonly takesValue: true;
162
+ readonly meaning: 'shell (default), python or node';
163
+ }, {
164
+ readonly name: 'timeout-minutes';
165
+ readonly takesValue: true;
166
+ readonly meaning: '1 to 60 (default 15)';
167
+ }];
168
+ }, {
169
+ readonly action: 'card add';
170
+ readonly synopsis: 'add a card, in the first zone unless --zone names one';
171
+ readonly mutation: 'unkeyed';
172
+ readonly positionals: readonly ['flow'];
173
+ readonly flags: readonly [...{
174
+ name: string;
175
+ takesValue: boolean;
176
+ meaning: string;
177
+ }[], {
178
+ readonly name: 'title';
179
+ readonly takesValue: true;
180
+ readonly meaning: "the card's title";
181
+ }, {
182
+ readonly name: 'description';
183
+ readonly takesValue: true;
184
+ readonly meaning: 'its details';
185
+ }, {
186
+ readonly name: 'zone';
187
+ readonly takesValue: true;
188
+ readonly meaning: 'a zone id or name';
189
+ }];
190
+ }, {
191
+ readonly action: 'archive';
192
+ readonly synopsis: 'archive a flow: its cards stop moving and its triggers stop; previews until --yes';
193
+ readonly mutation: 'identity-idempotent';
194
+ readonly positionals: readonly ['flow'];
195
+ readonly flags: readonly [...{
196
+ name: string;
197
+ takesValue: boolean;
198
+ meaning: string;
199
+ }[], {
200
+ name: string;
201
+ takesValue: boolean;
202
+ meaning: string;
203
+ }];
204
+ }];
205
+ /** `--file` is a value here (a path in the project), where elsewhere it is a switch. */
206
+ export declare const FLOWS_VALUE_FLAGS: ReadonlySet<string>;
207
+ export type FlowsCliContext = {
208
+ store: Store;
209
+ write: (line: string) => void;
210
+ json: boolean;
211
+ clock: () => Date;
212
+ /** --as/--token, or the remembered login; null when neither. */
213
+ credentials: {
214
+ name: string;
215
+ token: string;
216
+ } | null;
217
+ /** Every project the installation knows (enrolled, opened or worked on). */
218
+ projects: readonly string[];
219
+ /** Beside the database: webhook secrets, the hooks address. */
220
+ configDir: string;
221
+ evidenceRoot: string;
222
+ triggerIo: TriggerIo;
223
+ /** Reads a file, or stdin for "-". */
224
+ readInput?: (path: string) => string;
225
+ };
226
+ type Flags = Map<string, string | true>;
227
+ export declare function runFlowsCommand(positional: readonly string[], flags: Flags, context: FlowsCliContext): Promise<number>;
228
+ export {};