toolroll 0.9.51 → 0.9.53

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 (164) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +55 -80
  3. package/dist/agentconfig.d.ts +11 -36
  4. package/dist/agentconfig.js +3 -62
  5. package/dist/approval-policy.d.ts +1 -1
  6. package/dist/approval-policy.js +1 -1
  7. package/dist/backup.d.ts +8 -0
  8. package/dist/backup.js +10 -1
  9. package/dist/board.d.ts +3 -19
  10. package/dist/board.js +4 -42
  11. package/dist/browser/workspace.css +1 -1
  12. package/dist/browser/workspace.js +18 -18
  13. package/dist/browser-workspace.d.ts +3 -1
  14. package/dist/builder.d.ts +9 -40
  15. package/dist/builder.js +50 -257
  16. package/dist/chat-actions.js +3 -14
  17. package/dist/chat-controls.d.ts +0 -4
  18. package/dist/chat-controls.js +0 -1
  19. package/dist/chat-decide.js +1 -3
  20. package/dist/chat-delivery.d.ts +0 -2
  21. package/dist/chat-delivery.js +0 -1
  22. package/dist/chat-quiet.js +2 -2
  23. package/dist/chat-task-actions.d.ts +1 -1
  24. package/dist/chat-task-actions.js +0 -2
  25. package/dist/claim.d.ts +79 -244
  26. package/dist/claim.js +688 -1023
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +10 -2
  29. package/dist/contracts/cli.d.ts +0 -13
  30. package/dist/contracts/cli.js +0 -2
  31. package/dist/contracts/console-api.d.ts +5 -10
  32. package/dist/contracts/console-api.js +4 -10
  33. package/dist/contracts/flow.d.ts +50 -0
  34. package/dist/contracts/flow.js +26 -1
  35. package/dist/contracts/lead-tools.d.ts +0 -13
  36. package/dist/contracts/lead-tools.js +3 -3
  37. package/dist/contracts/store-json.d.ts +1 -25
  38. package/dist/contracts/store-json.js +3 -13
  39. package/dist/converse.d.ts +1 -11
  40. package/dist/converse.js +6 -38
  41. package/dist/coordinator.d.ts +0 -1
  42. package/dist/coordinator.js +3 -5
  43. package/dist/database-reclaim.d.ts +4 -0
  44. package/dist/database-reclaim.js +13 -0
  45. package/dist/demo.d.ts +1 -1
  46. package/dist/demo.js +17 -27
  47. package/dist/desktop-update-gate.js +0 -1
  48. package/dist/desktop-update.d.ts +1 -1
  49. package/dist/desktop-update.js +1 -3
  50. package/dist/dispatch.d.ts +1 -1
  51. package/dist/dispatch.js +1 -1
  52. package/dist/dispose.d.ts +2 -3
  53. package/dist/dispose.js +17 -92
  54. package/dist/envelope.js +0 -3
  55. package/dist/exec.d.ts +0 -64
  56. package/dist/exec.js +3 -356
  57. package/dist/exhaustion.d.ts +22 -76
  58. package/dist/exhaustion.js +14 -140
  59. package/dist/export.js +4 -4
  60. package/dist/flow-engine.js +1 -1
  61. package/dist/flow-schedule.d.ts +154 -0
  62. package/dist/flow-schedule.js +321 -0
  63. package/dist/flow-share.js +1 -1
  64. package/dist/flow-triggers.d.ts +6 -1
  65. package/dist/flow-triggers.js +65 -10
  66. package/dist/flows-cli.js +8 -4
  67. package/dist/flows-ui.js +2 -0
  68. package/dist/flows.js +1 -1
  69. package/dist/guides.js +9 -29
  70. package/dist/invoke.d.ts +1 -24
  71. package/dist/invoke.js +17 -259
  72. package/dist/lead-status.js +2 -4
  73. package/dist/lead-voice.js +1 -1
  74. package/dist/live-bus.d.ts +3 -3
  75. package/dist/live-bus.js +3 -3
  76. package/dist/mate-doors.d.ts +1 -2
  77. package/dist/mate-doors.js +12 -25
  78. package/dist/mate-tools.d.ts +2 -2
  79. package/dist/mate-tools.js +10 -22
  80. package/dist/modes.d.ts +6 -7
  81. package/dist/modes.js +7 -13
  82. package/dist/monitoring.js +32 -8
  83. package/dist/notification-delivery.d.ts +19 -0
  84. package/dist/notification-delivery.js +37 -0
  85. package/dist/operate-remote-arguments.js +2 -6
  86. package/dist/operate-remote.js +6 -7
  87. package/dist/operate.d.ts +0 -6
  88. package/dist/operate.js +283 -2378
  89. package/dist/phase-routing.d.ts +13 -23
  90. package/dist/phase-routing.js +13 -35
  91. package/dist/plan-auto.js +2 -2
  92. package/dist/planner-source.js +2 -1
  93. package/dist/policy.d.ts +1 -4
  94. package/dist/policy.js +0 -8
  95. package/dist/process-recovery-eligibility.js +2 -3
  96. package/dist/process-recovery-settlement.js +1 -0
  97. package/dist/project-delete.js +3 -15
  98. package/dist/proposal.d.ts +4 -24
  99. package/dist/proposal.js +3 -88
  100. package/dist/provider-auth.d.ts +3 -3
  101. package/dist/provider-auth.js +4 -6
  102. package/dist/provider.d.ts +11 -25
  103. package/dist/provider.js +4 -36
  104. package/dist/push.js +1 -1
  105. package/dist/recipe-ui.js +6 -6
  106. package/dist/recipes.d.ts +3 -3
  107. package/dist/recipes.js +27 -23
  108. package/dist/retention.js +6 -5
  109. package/dist/run-closeout.d.ts +1 -1
  110. package/dist/run-closeout.js +1 -4
  111. package/dist/scope.d.ts +11 -65
  112. package/dist/scope.js +17 -82
  113. package/dist/serve.js +16 -25
  114. package/dist/server/chat.js +7 -30
  115. package/dist/server/flows.js +11 -10
  116. package/dist/server/guards.js +0 -1
  117. package/dist/server/pages-assets.js +1 -1
  118. package/dist/server/pages.js +0 -2
  119. package/dist/server/people-tokens.js +0 -4
  120. package/dist/server/route-table.d.ts +1 -1
  121. package/dist/server/route-table.js +2 -17
  122. package/dist/server/settings.js +4 -9
  123. package/dist/server/shared.d.ts +29 -212
  124. package/dist/server/shared.js +73 -780
  125. package/dist/server/task-views.js +9 -76
  126. package/dist/server/tasks.d.ts +1 -50
  127. package/dist/server/tasks.js +36 -946
  128. package/dist/setup-guide.d.ts +2 -2
  129. package/dist/slack-chat.d.ts +0 -2
  130. package/dist/slack-chat.js +0 -1
  131. package/dist/store.d.ts +142 -1382
  132. package/dist/store.js +900 -4083
  133. package/dist/surface.js +10 -25
  134. package/dist/task-composer.d.ts +1 -1
  135. package/dist/task-composer.js +1 -1
  136. package/dist/task-control.d.ts +3 -10
  137. package/dist/task-control.js +2 -7
  138. package/dist/teammate-desk.js +1 -1
  139. package/dist/teammates-ui.js +1 -1
  140. package/dist/telegram-mate.d.ts +0 -3
  141. package/dist/telegram-mate.js +0 -1
  142. package/dist/templates.d.ts +11 -7
  143. package/dist/templates.js +12 -9
  144. package/dist/toolroll-update.d.ts +24 -3
  145. package/dist/toolroll-update.js +159 -16
  146. package/dist/webhooks.d.ts +10 -22
  147. package/dist/webhooks.js +43 -81
  148. package/dist/work-index.js +3 -7
  149. package/dist/workspace-revision.d.ts +28 -1
  150. package/dist/workspace-revision.js +276 -49
  151. package/dist/workspace-ui.js +1 -1
  152. package/package.json +1 -3
  153. package/scripts/recovery-canary.mjs +3 -3
  154. package/dist/contest.d.ts +0 -318
  155. package/dist/contest.js +0 -753
  156. package/dist/held.d.ts +0 -150
  157. package/dist/held.js +0 -799
  158. package/dist/liveness.d.ts +0 -30
  159. package/dist/liveness.js +0 -42
  160. package/dist/pricing.d.ts +0 -45
  161. package/dist/pricing.js +0 -77
  162. package/dist/routine.d.ts +0 -228
  163. package/dist/routine.js +0 -764
  164. package/dist/supervisor.mjs +0 -297
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Schedules (v115: flow-owned; routines became scheduled flows).
3
+ *
4
+ * A schedule trigger fires on one of four readable cadences. Its two failure modes are subtle enough to deserve
5
+ * names: advancing one interval after downtime leaves the next firing in the past — a catch-up burst; advancing to
6
+ * `now + interval` drifts the cadence forever. The rule is ALIGNED advancement: the next occurrence strictly after
7
+ * now, anchored to the occurrence that just fired (for `every`) or to the clock itself (for calendar schedules).
8
+ * Missed slots while nothing was running fire ONCE — the overdue occurrence — and are never backfilled.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import { fileTaskProposal } from "./proposal.js";
12
+ import { reportsCost } from "./provider.js";
13
+ import { legOf, routeDigestOf, routeFromJson, routeProblems } from "./phase-routing.js";
14
+ import { canonicalAcceptance, digestOf, parseAcceptanceCriteria, profileDigestOf, profileFromJson } from "./scope.js";
15
+ import { hasDisguisedText, hasForbiddenControls } from "./decision.js";
16
+ import { BUILT_IN, parseCapabilityKey } from "./store.js";
17
+ export const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
18
+ export function validTimezone(value) {
19
+ if (value.trim() !== value)
20
+ return false;
21
+ if (!/^[A-Za-z0-9_+/-]{1,80}$/.test(value))
22
+ return false;
23
+ try {
24
+ new Intl.DateTimeFormat("en", { timeZone: value }).format(0);
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
31
+ /** Bounds a person would pick on purpose: 5 minutes to 7 days. */
32
+ export const MIN_EVERY_MINUTES = 5;
33
+ export const MAX_EVERY_MINUTES = 7 * 24 * 60;
34
+ /**
35
+ * Existing daily schedules remain UTC. Calendar schedules may name an IANA
36
+ * timezone, whose wall-clock time stays fixed across daylight saving changes.
37
+ * No cron expressions in v1 — a schedule the operator cannot read at a
38
+ * glance is a schedule they cannot honestly approve.
39
+ */
40
+ export function parseSchedule(text) {
41
+ if (text.trim() !== text)
42
+ return null;
43
+ const every = /^every:([0-9]{1,5})$/.exec(text);
44
+ if (every !== null) {
45
+ const minutes = Number(every[1]);
46
+ if (minutes < MIN_EVERY_MINUTES || minutes > MAX_EVERY_MINUTES)
47
+ return null;
48
+ return { kind: "every", minutes };
49
+ }
50
+ const calendar = /^(daily|weekdays|weekly:[0-6]):([01][0-9]|2[0-3]):([0-5][0-9])(?:@([A-Za-z0-9_+/-]{1,80}))?$/.exec(text);
51
+ if (calendar !== null) {
52
+ const timezone = calendar[4];
53
+ if (timezone !== undefined && !validTimezone(timezone))
54
+ return null;
55
+ const time = { hhmm: `${calendar[2]}:${calendar[3]}`, ...(timezone === undefined ? {} : { timezone }) };
56
+ return calendar[1] === "daily" ? { kind: "daily", ...time } : calendar[1] === "weekdays" ? { kind: "weekdays", ...time } : { kind: "weekly", day: Number(calendar[1].slice(-1)), ...time };
57
+ }
58
+ return null;
59
+ }
60
+ export function scheduleText(schedule) {
61
+ return schedule.kind === "every" ? `every:${schedule.minutes}` : `${schedule.kind === "weekly" ? `weekly:${schedule.day}` : schedule.kind}:${schedule.hhmm}${schedule.timezone === undefined ? "" : `@${schedule.timezone}`}`;
62
+ }
63
+ /** The schedule, in words an operator agrees to. */
64
+ export function describeSchedule(schedule) {
65
+ if (schedule.kind !== "every")
66
+ return `${schedule.kind === "daily" ? "daily" : schedule.kind === "weekdays" ? "weekdays" : `every ${WEEKDAYS[schedule.day]}`} at ${schedule.hhmm} ${schedule.timezone ?? "UTC"}`;
67
+ const { minutes } = schedule;
68
+ if (minutes % (24 * 60) === 0)
69
+ return `every ${minutes / (24 * 60)} day(s)`;
70
+ if (minutes % 60 === 0)
71
+ return `every ${minutes / 60} hour(s)`;
72
+ return `every ${minutes} minutes`;
73
+ }
74
+ /**
75
+ * The first occurrence after approval: the schedule starts counting from the
76
+ * yes, because "approved at 14:07, every 60 minutes" firing instantly would
77
+ * spend before the approver's hand left the keyboard.
78
+ */
79
+ export function firstFireAt(schedule, now) {
80
+ if (schedule.kind === "every") {
81
+ return new Date(now.getTime() + schedule.minutes * 60_000).toISOString();
82
+ }
83
+ return nextCalendar(schedule, now);
84
+ }
85
+ /**
86
+ * Aligned advancement: the smallest cadence occurrence STRICTLY after now.
87
+ * `anchor` is the occurrence that just fired (or was just skipped) — the
88
+ * cadence grid grows from it, so a pass that ran late does not tilt every
89
+ * later firing by its lateness.
90
+ */
91
+ export function nextFireAt(schedule, anchorIso, now) {
92
+ if (schedule.kind !== "every")
93
+ return nextCalendar(schedule, now);
94
+ const interval = schedule.minutes * 60_000;
95
+ const anchor = new Date(anchorIso).getTime();
96
+ const elapsed = now.getTime() - anchor;
97
+ // Strictly after now: an occurrence landing exactly on now already fired.
98
+ const steps = Math.max(1, Math.floor(elapsed / interval) + 1);
99
+ return new Date(anchor + steps * interval).toISOString();
100
+ }
101
+ function nextCalendar(schedule, now) {
102
+ const format = new Intl.DateTimeFormat("en-US", { timeZone: schedule.timezone ?? "UTC", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
103
+ // Represent wall-clock components as a UTC number solely for arithmetic.
104
+ const wall = (instant) => {
105
+ const parts = Object.fromEntries(format.formatToParts(instant).map(part => [part.type, part.value]));
106
+ return Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute));
107
+ };
108
+ const localNow = new Date(wall(now.getTime()));
109
+ const [hour, minute] = schedule.hhmm.split(":").map(Number);
110
+ const today = Date.UTC(localNow.getUTCFullYear(), localNow.getUTCMonth(), localNow.getUTCDate(), hour, minute);
111
+ for (let days = 0; days <= 15; days++) {
112
+ const target = today + days * 86_400_000;
113
+ if (schedule.kind === "weekly" && new Date(target).getUTCDay() !== schedule.day)
114
+ continue;
115
+ if (schedule.kind === "weekdays" && (new Date(target).getUTCDay() === 0 || new Date(target).getUTCDay() === 6))
116
+ continue;
117
+ // Nearby dates expose both offsets around a daylight-saving transition.
118
+ // A missing clock time skips its slot. An ambiguous time uses the first
119
+ // occurrence only, even if the scheduler advances during the repeated hour.
120
+ const offsets = [...new Set([-36, 0, 36].map(hours => { const sample = target + hours * 3_600_000; return wall(sample) - sample; }))];
121
+ const matches = offsets.map(offset => target - offset).filter(instant => wall(instant) === target);
122
+ if (matches.length > 0) {
123
+ const first = Math.min(...matches);
124
+ if (first > now.getTime())
125
+ return new Date(first).toISOString();
126
+ }
127
+ }
128
+ throw new Error("No calendar occurrence within the next fifteen days");
129
+ }
130
+ // ------------------------------------------------------------------ standing orders
131
+ /** The rolling window a standing order's cost ceiling counts over: seven days, and every surface says so. */
132
+ export const BUDGET_WINDOW_MS = 7 * 24 * 60 * 60_000;
133
+ /** A firing's task id: the order's stem stamped with its slot (`nightly-deps-20261008-0700`). */
134
+ export function instanceId(stem, slotIso) {
135
+ return `${stem}-${slotIso.replace(/[-:]/g, "").slice(0, 13).replace("T", "-")}`;
136
+ }
137
+ /**
138
+ * The exact standing order an approver agreed to, as 128 bits — byte for byte what a routine's digest was, so a
139
+ * routine's approval moved onto a scheduled flow (v115) still verifies: the project, the terms, the schedule, the
140
+ * ceiling, and the agents the approval froze. Editing any of them (or moving the flow) strands the approval.
141
+ */
142
+ export function standingDigestOf(repo, schedule, order, acceptance, profile, route) {
143
+ return createHash("sha256").update(JSON.stringify({
144
+ repo,
145
+ goal: order.goal.trim(),
146
+ outOfScope: order.outOfScope?.trim() ?? null,
147
+ touches: [...order.touches].sort(),
148
+ ...(acceptance.length === 0 ? {} : { acceptance: canonicalAcceptance(acceptance) }),
149
+ requirements: [...order.requirements].sort(),
150
+ schedule,
151
+ singleFlight: order.singleFlight,
152
+ costCeilingUsd: order.costCeilingUsd,
153
+ ...(order.budgetPerRunMicrousd == null ? {} : { budgetPerRun: order.budgetPerRunMicrousd }),
154
+ profileDigest: profileDigestOf(profile),
155
+ route: routeDigestOf(route),
156
+ }), "utf8").digest("hex").slice(0, 32);
157
+ }
158
+ /**
159
+ * Whether a standing order's carried approval still stands, read whole before anything is written: the frozen agents
160
+ * read back, re-hash with the order's terms, this schedule and project to the digest the approver signed, state no
161
+ * problem, and build and repair on the approved profile's own agents. `problem` is null when there is no approval.
162
+ */
163
+ export function standingApproval(order, repo, schedule) {
164
+ const read = parseAcceptanceCriteria(order.acceptance);
165
+ const acceptance = read.problems.length === 0 && read.criteria.length > 0 ? read.criteria : null;
166
+ if (order.approval === null)
167
+ return { live: false, problem: null, acceptance };
168
+ const refused = (problem) => ({ live: false, problem, acceptance });
169
+ if (acceptance === null)
170
+ return refused("its success checks can't be read");
171
+ const profile = profileFromJson(order.approval.profileJson), route = routeFromJson(order.approval.routeJson);
172
+ if (profile === null || route === null)
173
+ return refused("the agents its approval froze can't be read back");
174
+ if (standingDigestOf(repo, schedule, order, acceptance, profile, route) !== order.approval.digest)
175
+ return refused("it changed after it was approved");
176
+ const problems = routeProblems(route);
177
+ if (problems.length > 0)
178
+ return refused(problems.join("; "));
179
+ const build = legOf(route, "build"), repair = legOf(route, "repair");
180
+ const repairModel = profile.repairModel === "inherit" ? profile.model : profile.repairModel;
181
+ if (build.provider !== profile.provider || build.model !== profile.model || repair.provider !== profile.provider || repair.model !== repairModel) {
182
+ return refused("its approved agents disagree with its approved route");
183
+ }
184
+ return { live: true, by: order.approval.by, profile, route, acceptance };
185
+ }
186
+ /** A firing that must not stand: thrown inside the transaction so nothing it wrote survives. */
187
+ export class StandingOrderRefused extends Error {
188
+ }
189
+ /**
190
+ * File one firing of a standing order (v115, what firing a routine did), inside the caller's transaction. It skips,
191
+ * saying why, while the last task is unfinished or the rolling 7-day ceiling is reached (an unmeasured paid run fails
192
+ * closed). Otherwise it files the order's terms unchanged: approved by the carried approval when that still verifies
193
+ * — on exactly the agents it froze, sealed like any automatic approval under the project's rules — or else as an
194
+ * ordinary proposal that waits for the project's approval rules like any other.
195
+ */
196
+ export function fileStandingOrder(store, input, now) {
197
+ const { order, repo } = input;
198
+ const blocker = store.standingOrderBlocker(input.trigger, order.routine, now);
199
+ if (blocker !== null)
200
+ return { made: "skipped", note: `Skipped: the last one (${blocker.taskId}, ${blocker.state}) hasn't finished.` };
201
+ const approval = standingApproval(order, repo, input.schedule);
202
+ if (approval.acceptance === null)
203
+ return { made: "skipped", note: "Skipped: its success checks can't be read. Set the flow up again." };
204
+ if (order.costCeilingUsd !== null) {
205
+ const ceiling = `$${order.costCeilingUsd.toFixed(2)}`;
206
+ if (approval.live && !reportsCost(legOf(approval.route, "build").provider)) {
207
+ return { made: "skipped", note: `Skipped: ${legOf(approval.route, "build").provider} doesn't report what it spends, so the ${ceiling} weekly limit can't be kept.` };
208
+ }
209
+ const spend = store.standingOrderSpend(input.trigger, order.routine, new Date(now.getTime() - BUDGET_WINDOW_MS).toISOString());
210
+ if (spend.unmeasuredRuns > 0)
211
+ return { made: "skipped", note: `Skipped: ${spend.unmeasuredRuns} run(s) in the last 7 days recorded no cost, so the ${ceiling} weekly limit can't be kept.` };
212
+ if (spend.costUsd >= order.costCeilingUsd)
213
+ return { made: "skipped", note: `Skipped: $${spend.costUsd.toFixed(2)} of the ${ceiling} weekly limit is spent.` };
214
+ }
215
+ let taskId = instanceId(order.stem, input.slot);
216
+ for (let n = 2; store.getTask(taskId) !== null; n++)
217
+ taskId = `${instanceId(order.stem, input.slot)}-${n}`;
218
+ const filedBy = order.filedBy === null ? { name: null, kind: "automation" } : { name: order.filedBy, kind: "person" };
219
+ if (!approval.live) {
220
+ const filed = fileTaskProposal(store, {
221
+ id: taskId, title: input.title, repo, goal: order.goal, outOfScope: order.outOfScope, touches: [...order.touches], acceptance: approval.acceptance,
222
+ budgetMicrousd: order.budgetPerRunMicrousd, filedVia: `flow:${input.flowId}`, filedBy, planning: "skip", admittedRepos: [repo],
223
+ }, now);
224
+ if (!filed.ok)
225
+ return { made: "skipped", note: `Couldn't file it: ${filed.message}.` };
226
+ const ref = store.lookupRef(filed.id);
227
+ if (ref !== null && order.requirements.length > 0)
228
+ store.setRequirements(ref.id, order.requirements);
229
+ return { made: "filed", taskId: filed.id, approved: false, note: approval.problem === null ? null : `Its earlier approval no longer applies (${approval.problem}), so this one waits for approval.` };
230
+ }
231
+ // The approved road: the frozen agents are the pin, the terms are the scope byte for byte, and the seal must read
232
+ // back as the frozen route — or nothing of this firing stands.
233
+ store.createTask({ id: taskId, title: input.title, filedBy }, now);
234
+ const ref = store.refFor(BUILT_IN, taskId, "ours");
235
+ store.placeTask(ref.id, repo);
236
+ store.pinTaskAgent(ref.id, approval.profile.provider, approval.profile.model);
237
+ if (order.requirements.length > 0)
238
+ store.setRequirements(ref.id, order.requirements);
239
+ const draft = { goal: order.goal, outOfScope: order.outOfScope, touches: [...order.touches], acceptance: approval.acceptance, ...(order.budgetPerRunMicrousd == null ? {} : { budgetMicrousd: order.budgetPerRunMicrousd }) };
240
+ const digest = digestOf(draft, approval.profile, approval.route);
241
+ store.saveScope({ taskId, ...draft, proposedAt: now.toISOString(), digest, budgetMicrousd: order.budgetPerRunMicrousd ?? null, approvedAt: order.approval.at, approvedBy: approval.by, approvedDigest: digest }, {}, { profile: approval.profile, route: approval.route });
242
+ const sealed = store.sealScopeApproval(taskId, approval.by ?? "schedule", now, {}, undefined, "automation");
243
+ const sealedRoute = sealed ? store.sealedRouteOf(taskId) : null;
244
+ if (!sealed || sealedRoute === null || !sealedRoute.ok || routeDigestOf(sealedRoute.route) !== routeDigestOf(approval.route)) {
245
+ throw new StandingOrderRefused(!sealed ? "the approval couldn't be sealed" : sealedRoute !== null && !sealedRoute.ok ? sealedRoute.detail : "the sealed agents aren't the approved ones");
246
+ }
247
+ return { made: "filed", taskId, approved: true, note: null };
248
+ }
249
+ /** A standing order in the words a person reads before turning its schedule on. */
250
+ export function describeStandingOrder(order, repo, schedule) {
251
+ const approval = standingApproval(order, repo, schedule);
252
+ return [
253
+ `Builds: ${order.goal}`,
254
+ ...(order.outOfScope === null ? [] : [`Not this: ${order.outOfScope}`]),
255
+ ...(order.touches.length === 0 ? [] : [`Only touches: ${order.touches.join(", ")}`]),
256
+ ...(order.requirements.length === 0 ? [] : [`Needs: ${order.requirements.join(", ")}`]),
257
+ ...(order.budgetPerRunMicrousd === null ? [] : [`Each run: up to $${(order.budgetPerRunMicrousd / 1_000_000).toFixed(2)}`]),
258
+ ...(order.costCeilingUsd === null ? [] : [`Every 7 days: up to $${order.costCeilingUsd.toFixed(2)}`]),
259
+ "One at a time: it skips while the last one is unfinished.",
260
+ approval.live ? `Approved by ${approval.by ?? "an approver"}: each run builds without asking, on the agents approved then.` : approval.problem === null ? "Each run waits for approval under the project's rules." : `Each run waits for approval: its earlier approval no longer applies (${approval.problem}).`,
261
+ ];
262
+ }
263
+ /** Every problem with a standing order's terms and schedule at once, as `field: problem` lines (none when it can be set up). */
264
+ export function standingTermsProblems(terms, schedule) {
265
+ const problems = [];
266
+ // What an approver reads must be what it appears to be (no control, invisible or direction-override text), bounded in bytes too.
267
+ const unfit = (text, chars, bytes) => text.length > chars || Buffer.byteLength(text, "utf8") > bytes || hasForbiddenControls(text) || hasDisguisedText(text);
268
+ if (terms.goal.trim() === "" || unfit(terms.goal, 2_000, 8_000))
269
+ problems.push("goal: required, at most 2000 characters, no control or hidden characters");
270
+ if (terms.outOfScope !== null && unfit(terms.outOfScope, 2_000, 8_000))
271
+ problems.push("outOfScope: at most 2000 characters, no control or hidden characters");
272
+ if (terms.touches.length > 50 || terms.touches.some(one => one.trim() === "" || unfit(one, 200, 800)))
273
+ problems.push("touches: at most 50 paths, each non-empty and under 200 characters");
274
+ if (terms.requirements.length > 20 || terms.requirements.some(one => parseCapabilityKey(one) === null || unfit(one, 400, 400)))
275
+ problems.push("requirements: capability keys, `kind:name`, at most 20");
276
+ // Each firing copies its success checks forward unchanged and never asks again, so there must be at least one.
277
+ const read = parseAcceptanceCriteria(terms.acceptance);
278
+ if (read.problems.length > 0)
279
+ problems.push(`acceptance: ${read.problems.map(one => one.message).join("; ")}`);
280
+ else if (read.criteria.length === 0)
281
+ problems.push("acceptance: a repeating task needs at least one success check");
282
+ if (parseSchedule(schedule) === null)
283
+ problems.push(`schedule: \`every:<minutes>\` (${MIN_EVERY_MINUTES}–${MAX_EVERY_MINUTES}), \`daily:<HH:MM>\`, \`weekdays:<HH:MM>\` or \`weekly:<0–6>:<HH:MM>\`, with an optional @IANA/timezone`);
284
+ if (terms.costCeilingUsd !== null && (!Number.isFinite(terms.costCeilingUsd) || terms.costCeilingUsd <= 0))
285
+ problems.push("costCeilingUsd: a positive dollar amount, or none for no weekly limit");
286
+ if (terms.budgetPerRunMicrousd !== null && (!Number.isSafeInteger(terms.budgetPerRunMicrousd) || terms.budgetPerRunMicrousd <= 0))
287
+ problems.push("budgetPerRunMicrousd: a positive whole micro-dollar amount, or none");
288
+ return problems;
289
+ }
290
+ /**
291
+ * A scheduled flow a person set up (a template or a recipe; v115, what filing a routine was): a Build step that follows
292
+ * the task each firing files, then Done, and a schedule trigger carrying the terms as its standing order — with no
293
+ * approval, so each firing waits for approval under the project's rules. The schedule starts PAUSED with no time:
294
+ * nothing repeats until a person turns it on, and it starts counting then. Its tasks are filed as `filedBy`'s (default
295
+ * `by`; null files them as automation's). Inside the caller's transaction, if any.
296
+ */
297
+ export function createScheduledFlow(store, input, now) {
298
+ const problems = standingTermsProblems(input.terms, input.schedule);
299
+ if (!/^[a-z0-9][a-z0-9-]{0,40}$/.test(input.stem))
300
+ problems.unshift("name: lowercase letters, digits and dashes, at most 41 — it starts each task's id");
301
+ if (input.name.trim() === "" || input.name.length > 80 || hasForbiddenControls(input.name))
302
+ problems.unshift("name: required, at most 80 characters");
303
+ if (problems.length > 0)
304
+ return { ok: false, message: problems.join("; ") };
305
+ const { terms } = input;
306
+ const order = {
307
+ stem: input.stem, goal: terms.goal, outOfScope: terms.outOfScope, touches: [...terms.touches], requirements: [...terms.requirements], acceptance: [...terms.acceptance],
308
+ budgetPerRunMicrousd: terms.budgetPerRunMicrousd, costCeilingUsd: terms.costCeilingUsd, singleFlight: true, filedBy: input.filedBy === undefined ? input.by : input.filedBy, routine: null, approval: null,
309
+ };
310
+ const zone = (x, color) => ({ x, y: 0, w: 260, h: 300, color });
311
+ const definition = { version: 1, start: "build", stages: [
312
+ { id: "build", title: "Build", kind: "task", zone: zone(0, "blue"), instructions: terms.goal, planning: "skip", approver: null, message: null, close: null, script: null, sort: null, next: "done", onFail: null },
313
+ { id: "done", title: "Done", kind: "done", zone: zone(420, "green"), instructions: null, planning: null, approver: null, message: null, close: null, script: null, sort: null, next: null, onFail: null },
314
+ ] };
315
+ return store.transact(() => {
316
+ const flow = store.createFlow({ repo: input.repo, name: input.name, definitionJson: JSON.stringify(definition), by: input.by }, now);
317
+ const trigger = store.addFlowTrigger({ flow, kind: "schedule", configJson: JSON.stringify({ kind: "schedule", schedule: input.schedule, title: input.name, description: null, zone: "build", order }), hookHash: null, cursor: null, nextAt: null, by: input.by }, now);
318
+ store.updateFlowTrigger(trigger, { state: "paused" }, now);
319
+ return { ok: true, flow, trigger };
320
+ });
321
+ }
@@ -25,7 +25,7 @@ import { durationMinutes, durationWords, FlowContractError, flowTerms, inStepWor
25
25
  import { stageReferenceProblems } from "./contracts/stage-output.js";
26
26
  import { readVersioned } from "./contracts/contract.js";
27
27
  import { FLOW_ALIASES, FLOW_FILE_FORMAT, FLOW_FILE_VERSION, flowFileSchema, PARAMETER_ID } from "./contracts/flow.js";
28
- import { parseSchedule } from "./routine.js";
28
+ import { parseSchedule } from "./flow-schedule.js";
29
29
  export { FLOW_FILE_FORMAT, FLOW_FILE_VERSION };
30
30
  /** The largest flow file Toolroll reads or fetches. */
31
31
  export const FLOW_FILE_MAX_BYTES = 256 * 1024;
@@ -20,7 +20,7 @@ export declare const CARDS_PER_CHECK = 10;
20
20
  * say, named by its path: no hidden characters and nothing that looks like a key, anywhere in the settings.
21
21
  */
22
22
  export declare function readTriggerSettings(raw: unknown): ContractResult<TriggerInput>;
23
- /** A schedule said in words ("every 2 hours", "daily 09:00 Europe/London", "monday 09:00") or in the routines' own form. */
23
+ /** A schedule said in words ("every 2 hours", "daily 09:00 Europe/London", "monday 09:00") or in its stored form ("daily:09:00"). */
24
24
  export declare function scheduleFromWords(text: string): string | null;
25
25
  /** A GitHub repository as owner/name, read from a project's origin remote when it is on GitHub. */
26
26
  export declare function githubRepoOf(path: string): string | null;
@@ -92,6 +92,11 @@ export type TriggerMade = {
92
92
  export declare function addFlowTriggerTo(store: Store, flow: FlowRow, raw: unknown, actor: string, now: Date, dir: string | null): TriggerMade;
93
93
  /** A new secret address for a webhook trigger (the old one stops working at once). */
94
94
  export declare function renewFlowHook(store: Store, trigger: FlowTriggerRow, now: Date, dir: string): TriggerMade;
95
+ /**
96
+ * Pause a trigger, or turn it on again. A schedule that never had a time (a routine moved to a flow while it waited
97
+ * for approval, v115) starts counting from now, as a new schedule does; one that was due while it was off fires once.
98
+ */
99
+ export declare function setFlowTriggerOn(store: Store, trigger: FlowTriggerRow, on: boolean, now: Date): void;
95
100
  export declare function removeFlowTrigger(store: Store, trigger: FlowTriggerRow, now: Date, dir: string | null): void;
96
101
  export type Incoming = {
97
102
  key: string;
@@ -4,7 +4,7 @@ import { adapterPolicy } from "./server/route-policy.js";
4
4
  * them by hand.
5
5
  *
6
6
  * - button — a named button with questions; pressing it makes a card from the answers.
7
- * - schedule — a card on a schedule (the routines' schedule rules: every N minutes, daily, weekly);
7
+ * - schedule — a card on a schedule (every N minutes, daily, weekdays, weekly), or a standing order's task;
8
8
  * v90: or a project script run on the schedule, each item it prints a card.
9
9
  * - github — new issues (optionally with a label), new pull requests, or failed checks on a branch.
10
10
  * - linear — Linear issues in a team, moving into a state, and/or with a label.
@@ -37,7 +37,7 @@ import { flowCardText, flowDefinitionOf } from "./flow-engine.js";
37
37
  import { FlowContractError } from "./flows.js";
38
38
  import { parseContract } from "./contracts/contract.js";
39
39
  import { FLOW_ALIASES, FLOW_TRIGGER_KINDS, SECRETS_MAX, triggerConfigSchema, triggerInputSchema } from "./contracts/flow.js";
40
- import { describeSchedule, firstFireAt, nextFireAt, parseSchedule, WEEKDAYS } from "./routine.js";
40
+ import { describeSchedule, fileStandingOrder, firstFireAt, nextFireAt, parseSchedule, StandingOrderRefused, WEEKDAYS } from "./flow-schedule.js";
41
41
  import { takeReply } from "./flow-replies.js";
42
42
  import { mailboxAccess, mailCursorOf, mailCursorText, readThroughImap } from "./mailbox.js";
43
43
  import { readFlowSecrets } from "./flow-secrets.js";
@@ -84,7 +84,7 @@ export function readTriggerSettings(raw) {
84
84
  }
85
85
  return read;
86
86
  }
87
- /** A schedule said in words ("every 2 hours", "daily 09:00 Europe/London", "monday 09:00") or in the routines' own form. */
87
+ /** A schedule said in words ("every 2 hours", "daily 09:00 Europe/London", "monday 09:00") or in its stored form ("daily:09:00"). */
88
88
  export function scheduleFromWords(text) {
89
89
  const raw = text.trim().replace(/\s+/g, " ");
90
90
  if (parseSchedule(raw) !== null)
@@ -223,7 +223,7 @@ export function validateTriggerConfig(raw, context) {
223
223
  }
224
224
  }
225
225
  }
226
- /** The computer's time zone, as the routines name one. */
226
+ /** The computer's time zone, as a schedule names one. */
227
227
  function localTimeZone() {
228
228
  try {
229
229
  return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
@@ -253,6 +253,9 @@ export function describeTrigger(config, store) {
253
253
  case "schedule": {
254
254
  const schedule = parseSchedule(config.schedule);
255
255
  const when = schedule === null ? config.schedule : describeSchedule(schedule);
256
+ // Its limits and terms are said once, in "What each run files" (describeStandingOrder).
257
+ if (config.order !== undefined)
258
+ return `${when.charAt(0).toUpperCase()}${when.slice(1)}: files the task “${config.title}”`;
256
259
  return config.script !== undefined ? `${when.charAt(0).toUpperCase()}${when.slice(1)}: runs the ${config.script} script, and each item it prints becomes a card` : `${when.charAt(0).toUpperCase()}${when.slice(1)}: “${config.title}”`;
257
260
  }
258
261
  case "github":
@@ -281,7 +284,7 @@ export function triggerHeadline(config, store) {
281
284
  case "schedule": {
282
285
  const schedule = parseSchedule(config.schedule);
283
286
  const when = schedule === null ? config.schedule : schedule.kind === "every" ? describeSchedule(schedule) : `${schedule.kind === "daily" ? "daily" : schedule.kind === "weekdays" ? "weekdays" : `${WEEKDAYS[schedule.day]}s`} at ${schedule.hhmm}`;
284
- return { name: `${when.charAt(0).toUpperCase()}${when.slice(1)}`, detail: config.script !== undefined ? `Runs ${config.script}` : `“${config.title}”` };
287
+ return { name: `${when.charAt(0).toUpperCase()}${when.slice(1)}`, detail: config.script !== undefined ? `Runs ${config.script}` : config.order !== undefined ? `Files “${config.title}”` : `“${config.title}”` };
285
288
  }
286
289
  case "github": return { name: config.watch === "checks" ? `Failed checks on ${config.branch}` : config.watch === "pulls" ? `New pull requests${config.label === null ? "" : ` labeled ${config.label}`}` : config.label === null ? "New issues" : `Issues labeled ${config.label}`, detail: `GitHub · ${config.repo}` };
287
290
  case "linear": return { name: `Linear${config.team === null ? "" : ` ${config.team}`}${config.state === null ? "" : ` → ${config.state}`}`, detail: config.label === null ? "Linear issues" : `Labeled ${config.label}` };
@@ -449,6 +452,15 @@ export function renewFlowHook(store, trigger, now, dir) {
449
452
  const base = readHooksBase(dir);
450
453
  return { ok: true, id: trigger.id, said: "New address made; the old one no longer works. Copy it now; it isn't shown again.", reveal: { path: `${HOOK_PATH}${token}`, address: base === null ? null : `${base}${HOOK_PATH}${token}`, secret } };
451
454
  }
455
+ /**
456
+ * Pause a trigger, or turn it on again. A schedule that never had a time (a routine moved to a flow while it waited
457
+ * for approval, v115) starts counting from now, as a new schedule does; one that was due while it was off fires once.
458
+ */
459
+ export function setFlowTriggerOn(store, trigger, on, now) {
460
+ const config = on && trigger.nextAt === null ? triggerConfigOf(trigger) : null;
461
+ const schedule = config !== null && (config.kind === "schedule" || config.kind === "plane-review") ? parseSchedule(config.schedule) : null;
462
+ store.updateFlowTrigger(trigger.id, { state: on ? "active" : "paused", ...(schedule === null ? {} : { nextAt: firstFireAt(schedule, now) }) }, now);
463
+ }
452
464
  export function removeFlowTrigger(store, trigger, now, dir) {
453
465
  store.updateFlowTrigger(trigger.id, { state: "removed", hookHash: null }, now);
454
466
  if (dir !== null)
@@ -497,6 +509,46 @@ function makeCard(store, trigger, config, item, by, now) {
497
509
  return { made: "added", note: null, card };
498
510
  });
499
511
  }
512
+ /**
513
+ * v115: a schedule with a standing order (what a routine was) files its task and the card that follows it, together or
514
+ * not at all; a slot it skips says why on the trigger. The card starts in the schedule's build step, which follows the
515
+ * task it was filed with.
516
+ */
517
+ function fileOrderCard(store, trigger, config, order, item, slot, now) {
518
+ const flow = store.getFlow(trigger.flow);
519
+ const definition = flow === null ? null : flowDefinitionOf(flow);
520
+ if (flow === null || definition === null || flow.state !== "active")
521
+ return { made: "skipped", note: "the flow isn't active", card: null };
522
+ const zone = startZone(config, definition);
523
+ if (definition.stages.find(one => one.id === zone)?.kind !== "task")
524
+ return { made: "skipped", note: "Skipped: the schedule's first step isn't a build step.", card: null };
525
+ if (store.flowTriggerSaw(trigger.id, item.key))
526
+ return { made: "seen", note: null, card: null };
527
+ const text = flowCardText(item.title, item.description);
528
+ if ("problem" in text)
529
+ return { made: "skipped", note: `Skipped: ${text.problem}`, card: null };
530
+ try {
531
+ return store.transact(() => {
532
+ if (store.flowTriggerSaw(trigger.id, item.key))
533
+ return { made: "seen", note: null, card: null };
534
+ const fired = fileStandingOrder(store, { flowId: flow.id, repo: flow.repo, trigger: trigger.id, schedule: config.schedule, order, title: text.title, slot }, now);
535
+ if (fired.made === "skipped") {
536
+ store.recordFlowTriggerEvent(trigger.id, item.key, null, fired.note, now);
537
+ return { made: "skipped", note: fired.note, card: null };
538
+ }
539
+ const card = store.addFlowCard({ flow: flow.id, title: text.title, description: text.description, stage: zone, by: "Schedule", source: item.source }, now);
540
+ store.updateFlowCard(card, { task: fired.taskId, primaryTask: fired.taskId, waiting: "Filed as a task" }, now);
541
+ // The firing keeps its task: one-at-a-time and the weekly limit count it however the card moves later.
542
+ store.recordFlowTriggerEvent(trigger.id, item.key, card, null, now, fired.taskId);
543
+ return { made: "added", note: fired.note ?? (fired.approved ? `Filed ${fired.taskId}, approved.` : `Filed ${fired.taskId}; it waits for approval.`), card };
544
+ });
545
+ }
546
+ catch (error) {
547
+ if (!(error instanceof StandingOrderRefused))
548
+ throw error;
549
+ return { made: "skipped", note: `Skipped: ${error.message}. Nothing was filed.`, card: null };
550
+ }
551
+ }
500
552
  const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
501
553
  function outcomeWords(results, more) {
502
554
  const added = results.filter(one => one.made === "added").length;
@@ -589,10 +641,11 @@ function fireSchedule(store, trigger, config, now) {
589
641
  const slot = trigger.nextAt;
590
642
  const next = nextFireAt(schedule, slot, now);
591
643
  // One card at a time: while the last one still waits where it started, the slot is skipped, not piled up.
592
- const last = store.lastFlowTriggerCard(trigger.id);
644
+ const last = config.order === undefined ? store.lastFlowTriggerCard(trigger.id) : null;
593
645
  const card = last === null ? null : store.getFlowCard(last);
594
646
  const flow = store.getFlow(trigger.flow);
595
647
  const definition = flow === null ? null : flowDefinitionOf(flow);
648
+ // A standing order says itself when it waits (its last task unfinished); other schedules wait on their last card.
596
649
  if (card !== null && definition !== null && card.state === "active" && card.stage === startZone(config, definition) && card.entry === 1) {
597
650
  store.recordFlowTriggerEvent(trigger.id, `slot:${slot}`, null, "the last one hadn't been picked up", now);
598
651
  store.updateFlowTrigger(trigger.id, { nextAt: next, lastAt: now.toISOString(), lastOutcome: "Skipped: the last card hasn't been picked up yet." }, now);
@@ -601,8 +654,9 @@ function fireSchedule(store, trigger, config, now) {
601
654
  const day = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: schedule.kind === "every" ? "UTC" : schedule.timezone ?? "UTC" }).format(new Date(slot));
602
655
  const fill = (text) => text.replace(/\{\{\s*date\s*\}\}/g, day);
603
656
  const title = /\{\{\s*date\s*\}\}/.test(config.title) ? fill(config.title) : `${config.title} — ${day}`;
604
- const made = makeCard(store, trigger, config, { key: `slot:${slot}`, title, description: config.description === null ? null : fill(config.description), source: { kind: "schedule", label: "Schedule", url: null } }, "Schedule", now);
605
- store.updateFlowTrigger(trigger.id, { nextAt: next, lastAt: now.toISOString(), lastOutcome: made.made === "added" ? "Added a card." : made.note ?? "Nothing new." }, now);
657
+ const item = { key: `slot:${slot}`, title, description: config.description === null ? null : fill(config.description), source: { kind: "schedule", label: "Schedule", url: null } };
658
+ const made = config.order === undefined ? makeCard(store, trigger, config, item, "Schedule", now) : fileOrderCard(store, trigger, config, config.order, item, slot, now);
659
+ store.updateFlowTrigger(trigger.id, { nextAt: next, lastAt: now.toISOString(), lastOutcome: made.made === "added" ? made.note ?? "Added a card." : made.note ?? "Nothing new." }, now);
606
660
  return made.made === "added" ? 1 : 0;
607
661
  }
608
662
  /** The problem a plane review's event key names, and the day: `plane:<problem>:<YYYY-MM-DD>`. */
@@ -683,10 +737,11 @@ export function runScheduleNow(store, trigger, actor, now) {
683
737
  return { ok: false, said: "Only a schedule without a script runs this way." };
684
738
  if (trigger.state !== "active")
685
739
  return { ok: false, said: "It's paused." };
686
- const made = makeCard(store, trigger, config, { key: `now:${now.toISOString()}`, title: config.title.replace(/\{\{\s*date\s*\}\}/g, "today"), description: config.description, source: { kind: "schedule", label: `Run now by ${actor}`, url: null } }, "Schedule", now);
740
+ const item = { key: `now:${now.toISOString()}`, title: config.title.replace(/\{\{\s*date\s*\}\}/g, "today"), description: config.description, source: { kind: "schedule", label: `Run now by ${actor}`, url: null } };
741
+ const made = config.order === undefined ? makeCard(store, trigger, config, item, "Schedule", now) : fileOrderCard(store, trigger, config, config.order, item, now.toISOString(), now);
687
742
  if (made.made !== "added" || made.card === null)
688
743
  return { ok: false, said: made.note ?? "Nothing was added." };
689
- store.updateFlowTrigger(trigger.id, { lastAt: now.toISOString(), lastOutcome: "Added a card (run now)." }, now);
744
+ store.updateFlowTrigger(trigger.id, { lastAt: now.toISOString(), lastOutcome: `${made.note ?? "Added a card."} (run now)` }, now);
690
745
  return { ok: true, card: made.card };
691
746
  }
692
747
  /** A schedule's script (v90): it runs in a clean folder inside the agents' fence; each item it prints is a card, once. */
package/dist/flows-cli.js CHANGED
@@ -14,8 +14,9 @@ import { addCardToFlow, advanceFlows, crossProjectProblem, decideFlowCard, flowD
14
14
  import { chooseFlowCard } from './flow-send.js';
15
15
  import { saveScript } from './flow-scripts.js';
16
16
  import { exportFlow, fetchFlowFile, FLOW_FILE_MAX_BYTES, FlowFileError, importFlow, parseFlowFile, planFlowImport } from './flow-share.js';
17
+ import { describeStandingOrder } from './flow-schedule.js';
17
18
  import { STARTER_FLOWS, starterFlowOf, starterOf, startersFor, starterTerms, switchOnStarter } from './flow-starters.js';
18
- import { addFlowTriggerTo, checkFlowTriggerNow, describeTrigger, removeFlowTrigger, triggerConfigOf, validateTriggerConfig } from './flow-triggers.js';
19
+ import { addFlowTriggerTo, checkFlowTriggerNow, describeTrigger, removeFlowTrigger, setFlowTriggerOn, triggerConfigOf, validateTriggerConfig } from './flow-triggers.js';
19
20
  import { deciderOf, FLOW_KIND_WORDS, FLOW_TEMPLATES, flowDigest, flowFromSteps, flowTerms, stepsFor } from './flows.js';
20
21
  import { verifyApproverByPassword } from './principal.js';
21
22
  import { projectName } from './project.js';
@@ -491,7 +492,7 @@ export async function runFlowsCommand(positional, flags, context) {
491
492
  if (action === 'trigger remove')
492
493
  removeFlowTrigger(store, trigger, now, context.configDir);
493
494
  else
494
- store.updateFlowTrigger(trigger.id, { state: action === 'trigger pause' ? 'paused' : 'active' }, now);
495
+ setFlowTriggerOn(store, trigger, action !== 'trigger pause', now);
495
496
  record(flow.repo, 'accepted', `flow #${flow.id} trigger #${trigger.id}`);
496
497
  settle(flow.repo);
497
498
  const said = action === 'trigger remove' ? 'Trigger removed.' : action === 'trigger pause' ? 'Trigger paused.' : 'Trigger on again.';
@@ -532,7 +533,8 @@ function describeFlow(store, flow) {
532
533
  triggers: store.flowTriggers(flow.id).map(trigger => {
533
534
  const config = triggerConfigOf(trigger);
534
535
  return { id: trigger.id, kind: trigger.kind, state: trigger.state, words: config === null ? 'This trigger can\'t be read.' : describeTrigger(config, store),
535
- zone: config?.zone ?? definition?.start ?? null, nextAt: trigger.nextAt, lastAt: trigger.lastAt, lastOutcome: trigger.lastOutcome, failing: trigger.failures > 0 };
536
+ zone: config?.zone ?? definition?.start ?? null, nextAt: trigger.nextAt, lastAt: trigger.lastAt, lastOutcome: trigger.lastOutcome, failing: trigger.failures > 0,
537
+ ...(config?.kind === 'schedule' && config.order !== undefined ? { order: describeStandingOrder(config.order, flow.repo, config.schedule) } : {}) };
536
538
  }),
537
539
  cards: store.flowCards(flow.id, true).slice(0, 10).map(card => cardOf(definition, card)),
538
540
  };
@@ -552,7 +554,9 @@ function showLines(flow) {
552
554
  ...(zone.ifFails === null ? [] : [` ${zone.kind === 'approval' ? 'sent back' : zone.kind === 'sort' ? 'not sure' : zone.kind === 'wait' ? 'no reply' : zone.kind === 'choose' ? 'a reply' : 'if it fails'} → ${title(zone.ifFails)}`]),
553
555
  ]),
554
556
  '', 'Triggers',
555
- ...(flow.triggers.length === 0 ? [' none — add one with toolroll flows trigger add'] : flow.triggers.map(one => ` #${one.id} ${one.state === 'active' ? '' : `(${one.state}) `}${one.words} → ${title(one.zone)}${one.lastOutcome === null ? '' : ` · last: ${one.lastOutcome}`}`)),
557
+ ...(flow.triggers.length === 0 ? [' none — add one with toolroll flows trigger add'] : flow.triggers.flatMap(one => [` #${one.id} ${one.state === 'active' ? '' : `(${one.state}) `}${one.words} → ${title(one.zone)}${one.lastOutcome === null ? '' : ` · last: ${one.lastOutcome}`}`,
558
+ ...(one.order === undefined ? [] : one.order.map(line => ` ${line}`)),
559
+ ...(one.order !== undefined && one.state === 'paused' ? [` Off: toolroll flows trigger resume ${flow.id} ${one.id} turns it on.`] : [])])),
556
560
  '', 'Recent cards',
557
561
  ...(flow.cards.length === 0 ? [' none'] : flow.cards.map(one => ` #${one.id} ${one.title} · ${one.state === 'active' ? one.zoneTitle : one.state}${one.waiting === null ? '' : ` · ${one.waiting}`}`)),
558
562
  ];
package/dist/flows-ui.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { draftFor, flowDefinitionOf } from "./flow-engine.js";
2
+ import { describeStandingOrder } from "./flow-schedule.js";
2
3
  import { describeTrigger, triggerHeadline, FLOW_TRIGGER_KINDS, FLOW_TRIGGER_WORDS, githubRepoOf, HOOK_PATH, hookReady, planeStatusLink, readHooksBase, readLinearKey, takesDeliveries, triggerConfigOf } from "./flow-triggers.js";
3
4
  import { deciderOf, FLOW_COLORS, FLOW_KIND_WORDS, FLOW_STAGE_KINDS } from "./flows.js";
4
5
  import { flowInsights, troubleWords } from "./flow-insights.js";
@@ -190,6 +191,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
190
191
  hook: config !== null && takesDeliveries(config) ? { ready: hookReady(trigger, setup.dir), needsSecret: config.kind === "linear" } : null,
191
192
  checkable: ((config?.kind === "github" || config?.kind === "linear") && config.delivery === "poll") || config?.kind === "email" || (config?.kind === "schedule" && config.script !== undefined) || config?.kind === "plane-review",
192
193
  shared: config?.kind === "button" && trigger.hookHash !== null,
194
+ ...(config?.kind === "schedule" && config.order !== undefined ? { order: describeStandingOrder(config.order, flow.repo, config.schedule) } : {}),
193
195
  };
194
196
  });
195
197
  return {
package/dist/flows.js CHANGED
@@ -45,7 +45,7 @@
45
45
  * - done — the end.
46
46
  *
47
47
  * The engine is deterministic and model-free: it runs in the worker's pass
48
- * beside routines, and no card ever skips an approval the task itself needs.
48
+ * beside builds, and no card ever skips an approval the task itself needs.
49
49
  */
50
50
  import { TEXT_LIMITS } from "./text-limits.js";
51
51
  import { createHash } from "node:crypto";