toolroll 0.9.0 → 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.
Files changed (55) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/agent-fence.d.ts +11 -2
  3. package/dist/agent-fence.js +29 -3
  4. package/dist/agent-onboard.d.ts +21 -1
  5. package/dist/agent-onboard.js +50 -1
  6. package/dist/backup-ui.js +4 -2
  7. package/dist/browser/workspace.css +2 -2
  8. package/dist/browser/workspace.js +31 -31
  9. package/dist/browser-workspace.d.ts +12 -2
  10. package/dist/chat-actions.d.ts +5 -0
  11. package/dist/chat-actions.js +43 -3
  12. package/dist/cli.d.ts +1 -1
  13. package/dist/cli.js +4 -1
  14. package/dist/flow-engine.js +13 -1
  15. package/dist/flow-insights.d.ts +9 -0
  16. package/dist/flow-insights.js +16 -0
  17. package/dist/flow-pull-request.d.ts +11 -0
  18. package/dist/flow-pull-request.js +123 -0
  19. package/dist/flow-starters-ui.d.ts +20 -0
  20. package/dist/flow-starters-ui.js +35 -0
  21. package/dist/flow-starters.d.ts +60 -0
  22. package/dist/flow-starters.js +147 -0
  23. package/dist/flow-steps.js +14 -0
  24. package/dist/flow-triggers.d.ts +20 -1
  25. package/dist/flow-triggers.js +105 -6
  26. package/dist/flows-cli.d.ts +228 -0
  27. package/dist/flows-cli.js +403 -0
  28. package/dist/flows-ui.d.ts +1 -1
  29. package/dist/flows-ui.js +5 -4
  30. package/dist/flows.d.ts +32 -6
  31. package/dist/flows.js +114 -6
  32. package/dist/guides.js +23 -0
  33. package/dist/integrations-ui.js +9 -4
  34. package/dist/keys.js +4 -1
  35. package/dist/lead-context.d.ts +1 -1
  36. package/dist/lead-context.js +1 -1
  37. package/dist/mate-contract.js +1 -1
  38. package/dist/mate-tools.js +21 -7
  39. package/dist/models-ui.js +3 -2
  40. package/dist/monitoring-ui.js +2 -1
  41. package/dist/operate.d.ts +1 -1
  42. package/dist/operate.js +57 -4
  43. package/dist/plane-review.d.ts +39 -0
  44. package/dist/plane-review.js +233 -0
  45. package/dist/policy-ui.js +2 -1
  46. package/dist/pull-request-flow.d.ts +32 -0
  47. package/dist/pull-request-flow.js +43 -8
  48. package/dist/pull-request-ui.js +2 -2
  49. package/dist/serve.js +108 -43
  50. package/dist/skills-ui.js +2 -1
  51. package/dist/store.js +2 -0
  52. package/dist/surface.js +5 -1
  53. package/dist/when-html.d.ts +8 -0
  54. package/dist/when-html.js +36 -0
  55. package/package.json +1 -1
@@ -0,0 +1,403 @@
1
+ /** `toolroll flows …`: set up and inspect flows from a terminal, through the
2
+ * same helpers the console and the lead's flow tool use — flowFromSteps,
3
+ * FLOW_TEMPLATES, the starter flows, addFlowTriggerTo, saveScript and
4
+ * addCardToFlow — so a flow made here is checked exactly as one drawn there.
5
+ * Reads need no login; every write is an approver's, proved by --as/--token
6
+ * (or the remembered login), and lands in the ledger as a console request
7
+ * does. create, edit, archive and trigger add preview until --yes. */
8
+ import { readFileSync } from 'node:fs';
9
+ import { resolve } from 'node:path';
10
+ import { envelopeJson } from './envelope.js';
11
+ import { addCardToFlow, advanceFlows, flowDefinitionOf } from './flow-engine.js';
12
+ import { saveScript } from './flow-scripts.js';
13
+ import { STARTER_FLOWS, starterFlowOf, starterOf, startersFor, starterTerms, switchOnStarter } from './flow-starters.js';
14
+ import { addFlowTriggerTo, checkFlowTriggerNow, describeTrigger, removeFlowTrigger, triggerConfigOf, validateTriggerConfig } from './flow-triggers.js';
15
+ import { deciderOf, FLOW_KIND_WORDS, FLOW_TEMPLATES, flowDigest, flowFromSteps, flowTerms, stepsFor } from './flows.js';
16
+ import { verifyApproverByPassword } from './principal.js';
17
+ import { projectName } from './project.js';
18
+ const EXIT = { ok: 0, failed: 1, usage: 2, refused: 3 };
19
+ const json = { name: 'json', takesValue: false, meaning: 'answer with one machine envelope on stdout' };
20
+ const db = { name: 'db', takesValue: true, meaning: 'path to the database file (defaults to the installation\'s)' };
21
+ const as = { name: 'as', takesValue: true, meaning: 'the approver making the change (default: the remembered login)' };
22
+ const token = { name: 'token', takesValue: true, meaning: "that approver's password" };
23
+ const yes = { name: 'yes', takesValue: false, meaning: 'make the previewed change' };
24
+ const repo = { name: 'repo', takesValue: true, meaning: 'the project checkout root' };
25
+ const steps = { name: 'steps', takesValue: true, meaning: "a JSON file (or - for stdin) with the steps in order, as the lead's flow tool takes them" };
26
+ const read = [json, db];
27
+ const write = [json, db, as, token];
28
+ export const FLOWS_DESCRIPTORS = [
29
+ { action: 'list', synopsis: 'list flows: id, name, project, zones, triggers and cards waiting', mutation: 'none', flags: [...read, repo] },
30
+ { action: 'show', synopsis: 'one flow: zones with their next and failure paths, triggers and recent cards', mutation: 'none', positionals: ['flow'], flags: read },
31
+ { action: 'create', synopsis: 'create a flow from --template <id> (a template or starter flow) or --steps <file|->; previews until --yes', mutation: 'unkeyed', flags: [...write, yes, repo,
32
+ { name: 'name', takesValue: true, meaning: "the flow's name (a template's label when left out)" },
33
+ { name: 'template', takesValue: true, meaning: `one of ${[...FLOW_TEMPLATES.map(one => one.id), ...STARTER_FLOWS.map(one => one.id)].join(', ')}` }, steps] },
34
+ { action: 'edit', synopsis: "replace a flow's steps (kept steps by id) and optionally rename it; previews until --yes", mutation: 'unkeyed', positionals: ['flow'], flags: [...write, yes, steps, { name: 'name', takesValue: true, meaning: 'a new name' }] },
35
+ { action: 'trigger add', synopsis: 'add a trigger from JSON or a JSON file (any kind: button, schedule, github, linear, flow, webhook, email, chat); previews until --yes', mutation: 'unkeyed', positionals: ['flow', 'trigger'], flags: [...write, yes] },
36
+ { action: 'trigger pause', synopsis: 'pause a trigger', mutation: 'identity-idempotent', positionals: ['flow', 'trigger'], flags: write },
37
+ { action: 'trigger resume', synopsis: 'turn a paused trigger on again', mutation: 'identity-idempotent', positionals: ['flow', 'trigger'], flags: write },
38
+ { action: 'trigger remove', synopsis: 'remove a trigger; cards it added stay', mutation: 'identity-idempotent', positionals: ['flow', 'trigger'], flags: write },
39
+ { action: 'trigger check', synopsis: 'run a checked trigger (or a schedule\'s script) now, like Check now', mutation: 'unkeyed', positionals: ['flow', 'trigger'], flags: write },
40
+ { action: 'script save', synopsis: "save a project script (a new version when it exists)", mutation: 'identity-idempotent', flags: [...write, repo,
41
+ { name: 'name', takesValue: true, meaning: 'lowercase letters, numbers and dashes' },
42
+ { name: 'file', takesValue: true, meaning: 'run this path in the project' },
43
+ { name: 'body', takesValue: true, meaning: 'a local file holding the script itself' },
44
+ { name: 'about', takesValue: true, meaning: 'one line: what it checks or does' },
45
+ { name: 'language', takesValue: true, meaning: 'shell (default), python or node' },
46
+ { name: 'timeout-minutes', takesValue: true, meaning: '1 to 60 (default 15)' }] },
47
+ { action: 'card add', synopsis: 'add a card, in the first zone unless --zone names one', mutation: 'unkeyed', positionals: ['flow'], flags: [...write,
48
+ { name: 'title', takesValue: true, meaning: "the card's title" },
49
+ { name: 'description', takesValue: true, meaning: 'its details' },
50
+ { name: 'zone', takesValue: true, meaning: 'a zone id or name' }] },
51
+ { action: 'archive', synopsis: 'archive a flow: its cards stop moving and its triggers stop; previews until --yes', mutation: 'identity-idempotent', positionals: ['flow'], flags: [...write, yes] },
52
+ ];
53
+ /** `--file` is a value here (a path in the project), where elsewhere it is a switch. */
54
+ export const FLOWS_VALUE_FLAGS = new Set(['file']);
55
+ export async function runFlowsCommand(positional, flags, context) {
56
+ const [first, second] = positional;
57
+ const action = first === 'trigger' || first === 'script' || first === 'card' ? `${first} ${second ?? ''}`.trim() : first ?? 'list';
58
+ const command = `flows ${action}`;
59
+ const args = positional.slice(action.split(' ').length);
60
+ const fail = (reason, message, code = EXIT.refused, extra = {}) => {
61
+ context.write(context.json ? envelopeJson({ ok: false, command, reason, message, ...extra }) : message);
62
+ return code;
63
+ };
64
+ const ok = (result, lines) => { context.write(context.json ? envelopeJson({ ok: true, command, ...result }) : lines.join('\n')); return EXIT.ok; };
65
+ const descriptor = FLOWS_DESCRIPTORS.find(one => one.action === action);
66
+ if (descriptor === undefined)
67
+ return fail('usage', `Use flows ${FLOWS_DESCRIPTORS.map(one => one.action).join(' | ')}.`, EXIT.usage);
68
+ const known = new Set(descriptor.flags.map(one => one.name));
69
+ for (const name of flags.keys())
70
+ if (!known.has(name) && name !== 'help')
71
+ return fail('usage', `--${name} is not a flows ${action} option.`, EXIT.usage);
72
+ const text = (key) => { const value = flags.get(key); return typeof value === 'string' ? value : null; };
73
+ const readInput = context.readInput ?? ((path) => readFileSync(path === '-' ? 0 : resolve(path), 'utf8'));
74
+ const { store } = context;
75
+ // ---- reads: what the installation holds, no login needed
76
+ const projectOf = (given) => given === null ? null : context.projects.find(one => one === given || one === resolve(given)) ?? null;
77
+ const flowOf = (value, repos) => {
78
+ const flow = value !== undefined && /^[1-9][0-9]{0,9}$/.test(value) ? store.getFlow(Number(value)) : null;
79
+ return flow !== null && flow.state === 'active' && repos.includes(flow.repo) ? flow : null;
80
+ };
81
+ if (action === 'list') {
82
+ const given = text('repo');
83
+ const project = projectOf(given);
84
+ if (given !== null && project === null)
85
+ return fail('unknown-project', 'That isn\'t a project Toolroll knows.');
86
+ const flows = store.listFlows(project === null ? context.projects : [project]).map(flow => listed(store, flow));
87
+ return ok({ flows }, flows.length === 0 ? ['No flows yet. Create one: toolroll flows create --repo <path> --name <name> --template coding'] : flows.map(one => `#${one.id} ${one.name} · ${one.project} · ${one.zones.length} zone${one.zones.length === 1 ? '' : 's'} · ${one.triggers} trigger${one.triggers === 1 ? '' : 's'} · ${one.cardsWaiting} card${one.cardsWaiting === 1 ? '' : 's'} waiting`));
88
+ }
89
+ if (action === 'show') {
90
+ const flow = flowOf(args[0], context.projects);
91
+ if (flow === null)
92
+ return fail('unknown-flow', 'No such flow in your projects. toolroll flows list names them.');
93
+ const shown = describeFlow(store, flow);
94
+ return ok({ flow: shown }, showLines(shown));
95
+ }
96
+ // ---- writes: an approver's, on a project they can reach
97
+ if (context.credentials === null)
98
+ return fail('unauthenticated', 'Changing a flow needs an approver: pass --as <you> --token <your password> (or sign in once with toolroll up).');
99
+ const acting = context.credentials;
100
+ const reachable = context.projects.filter(one => store.accountCanAccess(acting.name, one));
101
+ const verified = verifyApproverByPassword(store, acting.name, acting.token, reachable);
102
+ if (!verified.ok)
103
+ return fail('unauthenticated', 'That name and password aren\'t an approver\'s. Nothing was changed.');
104
+ const who = verified.who.name;
105
+ const now = context.clock();
106
+ // Each write, taken or refused, is a line in the ledger, like a console request.
107
+ const record = (project, outcome, detail = null) => {
108
+ store.recordAction({ at: context.clock().toISOString(), actor: who, repo: project, taskId: null, runId: null, action: command, outcome, source: 'request', detail: `command line${detail === null ? '' : ` · ${detail}`}` });
109
+ };
110
+ const refuse = (project, reason, message, code = EXIT.refused) => { record(project, 'refused', reason); return fail(reason, message, code); };
111
+ const settle = (project) => { try {
112
+ advanceFlows(store, project, context.clock(), { evidenceRoot: context.evidenceRoot });
113
+ }
114
+ catch { /* the next worker pass retries */ } };
115
+ const preview = (project, title, terms, extra) => ok({ applied: false, repo: project, title, terms, ...extra }, [title, ...terms.map(one => ` ${one.replace(/\n/g, '\n ')}`), '', 'Nothing has changed. Add --yes to make this change.']);
116
+ const readJson = (value, what) => {
117
+ let raw;
118
+ try {
119
+ raw = value.trim().startsWith('{') || value.trim().startsWith('[') ? value : readInput(value);
120
+ }
121
+ catch {
122
+ return { ok: false, message: `The ${what} file couldn't be read.` };
123
+ }
124
+ try {
125
+ return { ok: true, value: JSON.parse(raw) };
126
+ }
127
+ catch {
128
+ return { ok: false, message: `The ${what} aren't valid JSON.` };
129
+ }
130
+ };
131
+ const stepsOf = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) && 'steps' in value ? value.steps : value;
132
+ if (action === 'create') {
133
+ const given = text('repo');
134
+ if (given === null)
135
+ return fail('usage', 'Use flows create --repo <path> --name <name> (--template <id> | --steps <file|->).', EXIT.usage);
136
+ const project = projectOf(given);
137
+ if (project === null || !reachable.includes(project))
138
+ return refuse(null, 'unknown-project', 'That isn\'t one of your projects. toolroll repos lists them.');
139
+ const templateId = text('template'), stepsFile = text('steps');
140
+ if ((templateId === null) === (stepsFile === null))
141
+ return fail('usage', 'Give --template or --steps, not both.', EXIT.usage);
142
+ const name = (text('name') ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 80);
143
+ const starter = templateId === null ? null : starterOf(templateId);
144
+ if (starter !== null) {
145
+ // A starter flow: its zones and its trigger, together, under its own name.
146
+ if (name !== '' && name !== starter.name)
147
+ return fail('usage', `A starter flow keeps its name, ${starter.name}.`, EXIT.usage);
148
+ if (starterFlowOf(store, starter, project) !== null)
149
+ return refuse(project, 'already-on', `${starter.name} is already on in ${projectName(project)}.`);
150
+ const blocked = startersFor(store, project).find(one => one.id === starter.id)?.blocked ?? null;
151
+ if (blocked !== null)
152
+ return refuse(project, 'blocked', blocked);
153
+ if (!flags.has('yes'))
154
+ return preview(project, `Switch on ${starter.name} in ${projectName(project)}`, starterTerms(store, starter, project), { starter: starter.id });
155
+ const switched = switchOnStarter(store, starter, project, who, now, context.configDir);
156
+ if (!switched.ok)
157
+ return refuse(project, 'refused', switched.said);
158
+ record(project, 'accepted', `flow #${switched.flow}`);
159
+ return ok({ applied: true, flow: describeFlow(store, store.getFlow(switched.flow)) }, [`${switched.said} Flow #${switched.flow}.`]);
160
+ }
161
+ const template = templateId === null ? null : FLOW_TEMPLATES.find(one => one.id === templateId) ?? null;
162
+ if (templateId !== null && template === null)
163
+ return fail('usage', `Choose a template: ${[...FLOW_TEMPLATES.map(one => one.id), ...STARTER_FLOWS.map(one => one.id)].join(', ')}.`, EXIT.usage);
164
+ let definition;
165
+ if (template !== null)
166
+ definition = structuredClone(template.definition);
167
+ else {
168
+ const input = readJson(stepsFile, 'steps');
169
+ if (!input.ok)
170
+ return refuse(project, 'invalid-steps', input.message);
171
+ try {
172
+ definition = flowFromSteps(stepsFor(stepsOf(input.value), who), null);
173
+ }
174
+ catch (error) {
175
+ return refuse(project, 'invalid-steps', error instanceof Error ? error.message : 'Those steps aren\'t a flow.');
176
+ }
177
+ }
178
+ const flowName = name || template?.label || '';
179
+ if (flowName === '')
180
+ return fail('usage', 'Name the flow with --name.', EXIT.usage);
181
+ const terms = flowTerms(definition, null);
182
+ if (template?.trigger !== undefined) {
183
+ const draft = { id: 0, repo: project, name: flowName, definitionJson: JSON.stringify(definition), revision: 1, state: 'active', createdBy: who, createdAt: now.toISOString(), updatedBy: who, updatedAt: now.toISOString(), owner: who };
184
+ try {
185
+ terms.push(`Starts cards from: ${describeTrigger(validateTriggerConfig(template.trigger, { store, flow: draft, definition, actor: who }), store)}. Only what happens from now on counts.`);
186
+ }
187
+ catch (error) {
188
+ return refuse(project, 'invalid-trigger', error instanceof Error ? error.message : 'Its trigger isn\'t valid.');
189
+ }
190
+ }
191
+ if (!flags.has('yes'))
192
+ return preview(project, `Create the ${flowName} flow in ${projectName(project)}`, terms, { definition });
193
+ let id;
194
+ try {
195
+ id = store.transact(() => {
196
+ const made = store.createFlow({ repo: project, name: flowName, definitionJson: JSON.stringify(definition), by: who }, now);
197
+ if (template?.trigger !== undefined) {
198
+ const trigger = addFlowTriggerTo(store, store.getFlow(made), template.trigger, who, now, context.configDir);
199
+ if (!trigger.ok)
200
+ throw new Error(trigger.message);
201
+ }
202
+ return made;
203
+ });
204
+ }
205
+ catch (error) {
206
+ return refuse(project, 'invalid-trigger', error instanceof Error ? error.message : 'Its trigger isn\'t valid.');
207
+ }
208
+ record(project, 'accepted', `flow #${id}`);
209
+ return ok({ applied: true, flow: describeFlow(store, store.getFlow(id)) }, [`Created ${flowName}, flow #${id}. Add cards with toolroll flows card add ${id} --title "…".`]);
210
+ }
211
+ if (action === 'script save') {
212
+ const given = text('repo');
213
+ if (given === null || text('name') === null)
214
+ return fail('usage', 'Use flows script save --repo <path> --name <name> (--file <path in project> | --body <file>) --about "<one line>".', EXIT.usage);
215
+ const project = projectOf(given);
216
+ if (project === null || !reachable.includes(project))
217
+ return refuse(null, 'unknown-project', 'That isn\'t one of your projects. toolroll repos lists them.');
218
+ let body;
219
+ const bodyFile = text('body');
220
+ if (bodyFile !== null) {
221
+ try {
222
+ body = readInput(bodyFile);
223
+ }
224
+ catch {
225
+ return refuse(project, 'unreadable', 'The --body file couldn\'t be read.');
226
+ }
227
+ }
228
+ const saved = saveScript(store, project, { name: text('name'), about: text('about') ?? undefined, body, file: text('file') ?? undefined, language: text('language') ?? undefined, timeoutMinutes: text('timeout-minutes') ?? undefined }, who, now);
229
+ if (!saved.ok)
230
+ return refuse(project, 'invalid-script', saved.message);
231
+ record(project, 'accepted', `script ${String(text('name')).trim().toLowerCase()} v${saved.version}`);
232
+ settle(project);
233
+ return ok({ applied: true, repo: project, script: store.flowScript(project, String(text('name')).trim().toLowerCase()), said: saved.said }, [saved.said]);
234
+ }
235
+ // Everything else names a flow in one of this person's projects.
236
+ const flow = flowOf(args[0], reachable);
237
+ if (flow === null)
238
+ return refuse(null, 'unknown-flow', 'No such flow in your projects. toolroll flows list names them.');
239
+ const definition = flowDefinitionOf(flow);
240
+ if (action === 'archive') {
241
+ const cards = store.flowCards(flow.id, false).length, triggers = store.flowTriggers(flow.id).filter(one => one.state === 'active').length;
242
+ if (!flags.has('yes'))
243
+ return preview(flow.repo, `Archive the ${flow.name} flow`, [
244
+ `It leaves the flows list. ${cards === 0 ? 'No cards are in it.' : `Its ${cards} active card${cards === 1 ? '' : 's'} stop moving.`}${triggers === 0 ? '' : ` Its ${triggers} trigger${triggers === 1 ? '' : 's'} stop adding cards.`}`,
245
+ 'Tasks its cards filed stay as they are.',
246
+ ], { flowId: flow.id });
247
+ if (!store.archiveFlow(flow.id, who, now))
248
+ return refuse(flow.repo, 'stale', 'Someone archived it just now.');
249
+ record(flow.repo, 'accepted', `flow #${flow.id}`);
250
+ return ok({ applied: true, flowId: flow.id }, [`Archived ${flow.name}.`]);
251
+ }
252
+ if (definition === null)
253
+ return refuse(flow.repo, 'unreadable', 'This flow\'s drawing can\'t be read. Save it again from the editor.');
254
+ if (action === 'edit') {
255
+ const stepsFile = text('steps');
256
+ if (stepsFile === null && text('name') === null)
257
+ return fail('usage', 'Use flows edit <flow> --steps <file|-> [--name <name>].', EXIT.usage);
258
+ let next = definition;
259
+ if (stepsFile !== null) {
260
+ const input = readJson(stepsFile, 'steps');
261
+ if (!input.ok)
262
+ return refuse(flow.repo, 'invalid-steps', input.message);
263
+ try {
264
+ next = flowFromSteps(stepsFor(stepsOf(input.value), who), definition);
265
+ }
266
+ catch (error) {
267
+ return refuse(flow.repo, 'invalid-steps', error instanceof Error ? error.message : 'Those steps aren\'t a flow.');
268
+ }
269
+ }
270
+ const name = (text('name') ?? flow.name).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 80) || flow.name;
271
+ const redrawn = flowDigest(next) !== flowDigest(definition);
272
+ if (!redrawn && name === flow.name)
273
+ return fail('no-change', 'That\'s the flow as it is now.');
274
+ if (!flags.has('yes'))
275
+ return preview(flow.repo, `Change the ${flow.name} flow`, [...(name === flow.name ? [] : [`Renames it to ${name}.`]), ...(redrawn ? flowTerms(next, definition) : [])], { flowId: flow.id, revision: flow.revision, definition: next });
276
+ if (!store.saveFlow(flow.id, { name, definitionJson: JSON.stringify(next), sawRevision: flow.revision, by: who }, now))
277
+ return refuse(flow.repo, 'stale', 'Someone else changed this flow. Look again, then make yours.');
278
+ record(flow.repo, 'accepted', `flow #${flow.id}`);
279
+ settle(flow.repo);
280
+ return ok({ applied: true, flow: describeFlow(store, store.getFlow(flow.id)) }, [`Saved ${name}.`]);
281
+ }
282
+ if (action === 'card add') {
283
+ const title = text('title');
284
+ if (title === null)
285
+ return fail('usage', 'Use flows card add <flow> --title "<title>" [--description "<details>"] [--zone <zone>].', EXIT.usage);
286
+ const named = text('zone');
287
+ const stage = named === null ? null : definition.stages.find(one => one.id === named.trim() || one.title.toLowerCase() === named.trim().toLowerCase()) ?? null;
288
+ if (named !== null && stage === null)
289
+ return refuse(flow.repo, 'unknown-zone', `This flow has no zone called ${named}.`);
290
+ const added = addCardToFlow(store, flow, { title, description: text('description'), stage: stage?.id ?? null }, who, now);
291
+ if (!added.ok)
292
+ return refuse(flow.repo, 'invalid-card', added.message);
293
+ record(flow.repo, 'accepted', `flow #${flow.id} card #${added.card}`);
294
+ settle(flow.repo);
295
+ const card = store.getFlowCard(added.card);
296
+ return ok({ applied: true, card: card === null ? { id: added.card } : cardOf(definition, card), said: added.said }, [`${added.said} Card #${added.card}.`]);
297
+ }
298
+ if (action === 'trigger add') {
299
+ if (args[1] === undefined)
300
+ return fail('usage', 'Use flows trigger add <flow> <json|file|->, like \'{"kind":"schedule","schedule":"daily 09:00","title":"Standup"}\'.', EXIT.usage);
301
+ const input = readJson(args[1], 'trigger settings');
302
+ if (!input.ok)
303
+ return refuse(flow.repo, 'invalid-trigger', input.message);
304
+ let config;
305
+ try {
306
+ config = validateTriggerConfig(input.value, { store, flow, definition, actor: who });
307
+ }
308
+ catch (error) {
309
+ return refuse(flow.repo, 'invalid-trigger', error instanceof Error ? error.message : 'That trigger isn\'t valid.');
310
+ }
311
+ const words = describeTrigger(config, store);
312
+ if (!flags.has('yes')) {
313
+ const zone = definition.stages.find(one => one.id === (config.zone ?? definition.start))?.title ?? 'the first zone';
314
+ return preview(flow.repo, `Add a trigger to ${flow.name}`, [words, `Cards start in ${zone}.`, 'Work a card starts still waits for your usual approvals.'], { flowId: flow.id, trigger: config });
315
+ }
316
+ const made = addFlowTriggerTo(store, flow, input.value, who, now, context.configDir);
317
+ if (!made.ok)
318
+ return refuse(flow.repo, 'invalid-trigger', made.message);
319
+ record(flow.repo, 'accepted', `flow #${flow.id} trigger #${made.id}`);
320
+ settle(flow.repo);
321
+ // A webhook's address (and GitHub's signing secret) is shown this once, never stored readable.
322
+ return ok({ applied: true, triggerId: made.id, said: made.said, ...(made.reveal === null ? {} : { reveal: made.reveal }) }, [
323
+ `${made.said} Trigger #${made.id}: ${words}`,
324
+ ...(made.reveal === null ? [] : [` address: ${made.reveal.address ?? `<your public address>${made.reveal.path}`}`, ...(made.reveal.secret === null ? [] : [` signing secret: ${made.reveal.secret}`])]),
325
+ ]);
326
+ }
327
+ // trigger pause | resume | remove | check <flow> <trigger>
328
+ const trigger = args[1] !== undefined && /^[1-9][0-9]{0,9}$/.test(args[1]) ? store.getFlowTrigger(Number(args[1])) : null;
329
+ if (trigger === null || trigger.flow !== flow.id || trigger.state === 'removed')
330
+ return refuse(flow.repo, 'unknown-trigger', 'That trigger isn\'t on this flow. toolroll flows show <flow> names them.');
331
+ if (action === 'trigger check') {
332
+ const checked = await checkFlowTriggerNow(store, trigger, now, context.triggerIo);
333
+ record(flow.repo, checked.ok ? 'accepted' : 'refused', `flow #${flow.id} trigger #${trigger.id}`);
334
+ settle(flow.repo);
335
+ return checked.ok ? ok({ applied: true, triggerId: trigger.id, said: checked.said }, [checked.said]) : fail('check-failed', checked.said);
336
+ }
337
+ if (action === 'trigger remove')
338
+ removeFlowTrigger(store, trigger, now, context.configDir);
339
+ else
340
+ store.updateFlowTrigger(trigger.id, { state: action === 'trigger pause' ? 'paused' : 'active' }, now);
341
+ record(flow.repo, 'accepted', `flow #${flow.id} trigger #${trigger.id}`);
342
+ settle(flow.repo);
343
+ const said = action === 'trigger remove' ? 'Trigger removed.' : action === 'trigger pause' ? 'Trigger paused.' : 'Trigger on again.';
344
+ return ok({ applied: true, triggerId: trigger.id, said }, [said]);
345
+ }
346
+ function listed(store, flow) {
347
+ const definition = flowDefinitionOf(flow);
348
+ const cards = store.flowCards(flow.id, false);
349
+ return { id: flow.id, name: flow.name, repo: flow.repo, project: projectName(flow.repo), zones: definition?.stages.map(one => one.title) ?? [],
350
+ triggers: store.flowTriggers(flow.id).filter(one => one.state === 'active').length, cardsWaiting: cards.length,
351
+ needDecision: cards.filter(card => definition?.stages.find(one => one.id === card.stage)?.kind === 'approval').length };
352
+ }
353
+ /** One zone and every path out of it, by zone id. */
354
+ function zoneOf(stage, flow) {
355
+ return {
356
+ id: stage.id, title: stage.title, kind: stage.kind, does: FLOW_KIND_WORDS[stage.kind].label,
357
+ next: stage.next, ifFails: stage.onFail,
358
+ ...(stage.sort === null ? {} : { answers: stage.sort.answers.map(one => ({ answer: one.answer, to: one.to })), sureAt: stage.sort.sureAt }),
359
+ ...(stage.routes === undefined || stage.routes.length === 0 ? {} : { routes: stage.routes.map(one => ({ answer: one.answer, to: one.to })) }),
360
+ ...(stage.limit === undefined ? {} : { remindAfterMinutes: stage.limit.minutes, thenMoveTo: stage.limit.to }),
361
+ ...(stage.kind === 'approval' ? { decider: deciderOf(stage, flow) } : {}),
362
+ ...(stage.script === null ? {} : { script: stage.script }),
363
+ ...(stage.merge === undefined ? {} : { merge: stage.merge }),
364
+ ...(stage.teammate === undefined ? {} : { teammate: stage.teammate }),
365
+ };
366
+ }
367
+ function cardOf(definition, card) {
368
+ return { id: card.id, title: card.title, zone: card.stage, zoneTitle: definition?.stages.find(one => one.id === card.stage)?.title ?? card.stage, state: card.state, waiting: card.waiting, task: card.task ?? card.primaryTask, updatedAt: card.updatedAt };
369
+ }
370
+ function describeFlow(store, flow) {
371
+ const definition = flowDefinitionOf(flow);
372
+ return {
373
+ id: flow.id, name: flow.name, repo: flow.repo, project: projectName(flow.repo), owner: flow.owner, revision: flow.revision, readable: definition !== null,
374
+ start: definition?.start ?? null,
375
+ zones: definition?.stages.map(stage => zoneOf(stage, flow)) ?? [],
376
+ triggers: store.flowTriggers(flow.id).map(trigger => {
377
+ const config = triggerConfigOf(trigger);
378
+ return { id: trigger.id, kind: trigger.kind, state: trigger.state, words: config === null ? 'This trigger can\'t be read.' : describeTrigger(config, store),
379
+ zone: config?.zone ?? definition?.start ?? null, nextAt: trigger.nextAt, lastAt: trigger.lastAt, lastOutcome: trigger.lastOutcome, failing: trigger.failures > 0 };
380
+ }),
381
+ cards: store.flowCards(flow.id, true).slice(0, 10).map(card => cardOf(definition, card)),
382
+ };
383
+ }
384
+ function showLines(flow) {
385
+ const title = (id) => id === null ? null : flow.zones.find(one => one.id === id)?.title ?? id;
386
+ if (!flow.readable)
387
+ return [`#${flow.id} ${flow.name} · ${flow.project}`, 'This flow\'s drawing can\'t be read. Save it again from the editor.'];
388
+ return [
389
+ `#${flow.id} ${flow.name} · ${flow.project} · owner ${flow.owner}`,
390
+ '', 'Zones',
391
+ ...flow.zones.flatMap((zone, index) => [
392
+ ` ${index + 1}. ${zone.title} (${zone.id}) — ${zone.does}${zone.id === flow.start ? ' · start' : ''}`,
393
+ ...('answers' in zone && zone.answers !== undefined ? zone.answers.map(one => ` ${one.answer} → ${title(one.to)}`) : []),
394
+ ...('routes' in zone && zone.routes !== undefined ? zone.routes.map(one => ` ${one.answer} → ${title(one.to)}`) : []),
395
+ ...(zone.next === null ? [] : [` next → ${title(zone.next)}`]),
396
+ ...(zone.ifFails === null ? [] : [` ${zone.kind === 'approval' ? 'sent back' : zone.kind === 'sort' ? 'not sure' : zone.kind === 'wait' ? 'no reply' : 'if it fails'} → ${title(zone.ifFails)}`]),
397
+ ]),
398
+ '', 'Triggers',
399
+ ...(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}`}`)),
400
+ '', 'Recent cards',
401
+ ...(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}`}`)),
402
+ ];
403
+ }
@@ -2,7 +2,7 @@
2
2
  * (the React view's data, plus a plain fallback page the view replaces). */
3
3
  import type { BrowserFlowView } from "./browser-workspace.js";
4
4
  import type { FlowRow, Store } from "./store.js";
5
- export declare const FLOWS_CSS = ".flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows .flow-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.flows .flow-row h2{font-size:1.05rem;margin:0}.flows .flow-counts{font-size:.85rem;color:var(--so-muted)}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows .flow-buttons{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 0}@media(max-width:600px){.flows input,.flows select{font-size:16px}}";
5
+ export declare const FLOWS_CSS = ".flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows .flow-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.flows .flow-row h2{font-size:1.05rem;margin:0}.flows .flow-counts{font-size:.85rem;color:var(--so-muted)}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows .flow-buttons{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 0}@media(max-width:600px){.flows input,.flows select{font-size:16px}}@media(max-width:760px){.flows .card{padding:10px 12px;margin:8px 0;position:relative}.flows .flow-row{row-gap:2px}.flows .flow-row h2 a::after{content:\"\";position:absolute;inset:0;border-radius:inherit}.flows .flow-counts{line-height:1.35}}";
6
6
  /** The flows a person can open, with how many cards wait in each, and the new-flow form. */
7
7
  export declare function flowsListHtml(store: Store, flows: readonly FlowRow[], projects: readonly string[], csrf: string, canCreate: boolean, problem: string | null): string;
8
8
  /** One flow's canvas for one person. */
package/dist/flows-ui.js CHANGED
@@ -13,7 +13,7 @@ import { callWords, receiptWords } from "./teammate-tools.js";
13
13
  import { undoFor } from "./teammate-week.js";
14
14
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
15
15
  const projectName = (repo) => repo.split(/[\\/]/).filter(Boolean).pop() ?? repo;
16
- export const FLOWS_CSS = `.flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows .flow-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.flows .flow-row h2{font-size:1.05rem;margin:0}.flows .flow-counts{font-size:.85rem;color:var(--so-muted)}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows .flow-buttons{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 0}@media(max-width:600px){.flows input,.flows select{font-size:16px}}`;
16
+ export const FLOWS_CSS = `.flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows .flow-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.flows .flow-row h2{font-size:1.05rem;margin:0}.flows .flow-counts{font-size:.85rem;color:var(--so-muted)}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows .flow-buttons{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 0}@media(max-width:600px){.flows input,.flows select{font-size:16px}}@media(max-width:760px){.flows .card{padding:10px 12px;margin:8px 0;position:relative}.flows .flow-row{row-gap:2px}.flows .flow-row h2 a::after{content:"";position:absolute;inset:0;border-radius:inherit}.flows .flow-counts{line-height:1.35}}`;
17
17
  /** The flows a person can open, with how many cards wait in each, and the new-flow form. */
18
18
  export function flowsListHtml(store, flows, projects, csrf, canCreate, problem) {
19
19
  const rows = flows.map(flow => {
@@ -71,11 +71,12 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
71
71
  const decision = parseSortDecision(decisions.get(card.id)?.decisionJson ?? null);
72
72
  const shown = stage?.kind === "approval" && definition !== null ? draftFor(definition, stage) : null;
73
73
  // v91: when a Wait zone gives up (or moves on), or a zone's time limit comes.
74
- const clock = stage?.wait ?? stage?.limit;
74
+ // An "hours" wait has no deadline of its own: it goes on when the clock reaches its hours.
75
+ const clock = stage?.wait?.for === "hours" ? stage.limit : stage?.wait ?? stage?.limit;
75
76
  const until = card.state !== "active" || stage === undefined || clock === undefined ? null : new Date(Date.parse(store.flowCardEnteredAt(card.id) ?? card.updatedAt) + clock.minutes * 60_000);
76
77
  const deadline = until === null || until.getTime() <= Date.now() ? null : {
77
78
  at: until.toISOString(),
78
- label: stage.wait !== undefined ? (stage.wait.for === "reply" ? "No reply by" : "Moves on at") : stage.limit.to !== null ? `Moves to ${title(stage.limit.to)} at` : "Reminder at",
79
+ label: stage.wait !== undefined && stage.wait.for !== "hours" ? (stage.wait.for === "reply" ? "No reply by" : "Moves on at") : stage.limit.to !== null ? `Moves to ${title(stage.limit.to)} at` : "Reminder at",
79
80
  };
80
81
  // Its open question: one of its own, or (v94) a tool call waiting for approval.
81
82
  const asked = card.state === "active" ? store.openTeammateQuestionOn(card.id, card.entry) : null;
@@ -129,7 +130,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
129
130
  zone: title(config?.zone ?? definition?.start ?? ""), zoneId: config?.zone ?? definition?.start ?? "", state: trigger.state, status: trigger.lastOutcome, statusAt: trigger.lastAt, failing: trigger.failures > 0,
130
131
  button: config?.kind === "button" ? { label: config.label, questions: config.questions } : null,
131
132
  hook: config !== null && takesDeliveries(config) ? { ready: hookReady(trigger, setup.dir), needsSecret: config.kind === "linear" } : null,
132
- checkable: ((config?.kind === "github" || config?.kind === "linear") && config.delivery === "poll") || config?.kind === "email" || (config?.kind === "schedule" && config.script !== undefined),
133
+ checkable: ((config?.kind === "github" || config?.kind === "linear") && config.delivery === "poll") || config?.kind === "email" || (config?.kind === "schedule" && config.script !== undefined) || config?.kind === "plane-review",
133
134
  shared: config?.kind === "button" && trigger.hookHash !== null,
134
135
  };
135
136
  });
package/dist/flows.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const FLOW_STAGE_KINDS: readonly ["inbox", "task", "report", "approval", "check", "update", "notify", "sort", "draft", "request", "email", "tool", "wait", "teammate", "done"];
1
+ export declare const FLOW_STAGE_KINDS: readonly ["inbox", "task", "report", "approval", "check", "pull-request", "update", "notify", "sort", "draft", "request", "email", "tool", "wait", "teammate", "done"];
2
2
  export type FlowStageKind = (typeof FLOW_STAGE_KINDS)[number];
3
3
  export declare const FLOW_COLORS: readonly ["slate", "blue", "violet", "amber", "green", "rose"];
4
4
  export type FlowColor = (typeof FLOW_COLORS)[number];
@@ -48,11 +48,18 @@ export type FlowTool = {
48
48
  name: string;
49
49
  args: string;
50
50
  };
51
- /** wait (v91): for a reply to the card's email (next: replied, onFail: no reply in time), or for a set time (then next). */
51
+ /** wait (v91): for a reply to the card's email (next: replied, onFail: no reply in time), or for a set time (then next).
52
+ * "hours": until the clock is between `from` and `to` (like 22:00–06:00), in the computer's time zone unless one is named. */
52
53
  export type FlowWait = {
53
- for: "reply" | "time";
54
+ for: "reply" | "time" | "hours";
54
55
  minutes: number;
56
+ from?: string;
57
+ to?: string;
58
+ timeZone?: string;
55
59
  };
60
+ /** How a Pull request zone merges once checks pass. */
61
+ export declare const FLOW_MERGE_METHODS: readonly ["squash", "merge", "rebase"];
62
+ export type FlowMergeMethod = (typeof FLOW_MERGE_METHODS)[number];
56
63
  /** A time limit on a zone (v91): after this long, the person it waits on is reminded, and a Holding or
57
64
  * "Person decides" zone can move the card on (`to`). */
58
65
  export type FlowLimit = {
@@ -102,6 +109,8 @@ export type FlowStage = {
102
109
  teammate?: string;
103
110
  /** v96: a "Teammate handles it" zone sends what the teammate writes back to whoever asked (the person who added the card, its chat thread, or the teammate's manager). */
104
111
  reply?: boolean;
112
+ /** pull-request: merge once checks pass, this way. Only allowed after a "Person decides" zone, and a card merges only when a person approved it after it was built. */
113
+ merge?: FlowMergeMethod;
105
114
  /** Where a card goes when this zone's step succeeds, and when it fails or is sent back (sort: when it isn't sure). */
106
115
  next: string | null;
107
116
  onFail: string | null;
@@ -132,12 +141,18 @@ export declare const LONGEST_WAIT_MINUTES: number;
132
141
  export declare function durationMinutes(value: unknown): number | null;
133
142
  /** Minutes in the words people use: "3 days", "1 day 4 hours", "45 minutes". */
134
143
  export declare function durationWords(minutes: number): string;
144
+ /** "22:00", "9:30" as "HH:MM"; null when it isn't a time of day. */
145
+ export declare function clockTime(value: unknown): string | null;
146
+ /** Whether a time falls inside an "hours" wait (22:00–06:00 runs past midnight), in its time zone or this computer's. */
147
+ export declare function withinHours(wait: Pick<FlowWait, "from" | "to" | "timeZone">, now: Date): boolean;
135
148
  /** Who decides at an approval zone: its named person, the flow's owner, or null for anyone who approves on the project. */
136
149
  export declare function deciderOf(stage: Pick<FlowStage, "approver" | "toOwner">, flow: {
137
150
  owner: string;
138
151
  }): string | null;
139
152
  /** A flow as drawn on the canvas, checked whole. Throws in plain words. */
140
153
  export declare function validateFlowDefinition(input: unknown): FlowDefinition;
154
+ /** The zones a card can reach from `start` without passing through a zone `stop` picks (those zones aren't included). */
155
+ export declare function reachableWithout(stages: readonly FlowStage[], start: string, stop: (stage: FlowStage) => boolean): Set<string>;
141
156
  /** What a card's work is held to: the zones' steps and paths, never where they sit on the canvas. */
142
157
  export declare function flowDigest(definition: FlowDefinition): string;
143
158
  /** Fill a zone's text from the card: title, description, the latest note and earlier zones' reports. */
@@ -194,10 +209,15 @@ export type FlowStepInput = {
194
209
  server?: string;
195
210
  tool?: string;
196
211
  args?: Record<string, unknown> | string;
197
- /** wait (v91): for a reply (the default) or a set time, and how long ("3 days"); where a card goes when no reply comes. */
198
- waitFor?: "reply" | "time";
212
+ /** wait (v91): for a reply (the default) or a set time, and how long ("3 days"); where a card goes when no reply comes.
213
+ * "hours": until the clock is between `from` and `until` ("22:00", "06:00"). */
214
+ waitFor?: "reply" | "time" | "hours";
199
215
  wait?: string | number;
200
216
  ifNoReply?: string;
217
+ from?: string;
218
+ until?: string;
219
+ /** pull-request: merge once checks pass (true is squash). Needs a decision before it. */
220
+ merge?: boolean | FlowMergeMethod;
201
221
  /** Any step but wait and done (v91): remind after this long ("2 days"; "none" removes it), and on holding and approval steps, move the card to this step then. */
202
222
  remindAfter?: string | number;
203
223
  thenMoveTo?: string;
@@ -211,6 +231,8 @@ export type FlowStepInput = {
211
231
  };
212
232
  /** What a draft zone asks for when the steps leave it out. */
213
233
  export declare const DRAFT_DEFAULT = "Write a short, friendly reply to the person who sent this card, in plain words.";
234
+ /** Steps as the lead's flow tool and `toolroll flows` take them: "me" as a decider is the person asking, stored by name. */
235
+ export declare function stepsFor(steps: unknown, name: string): unknown;
214
236
  /**
215
237
  * A flow from an ordered list of steps: ids from names, each step leading
216
238
  * to the next, a Done zone at the end when none is listed, and a decision
@@ -220,10 +242,14 @@ export declare const DRAFT_DEFAULT = "Write a short, friendly reply to the perso
220
242
  export declare function flowFromSteps(input: unknown, previous?: FlowDefinition | null): FlowDefinition;
221
243
  /** What confirming a new or changed flow means, in the canvas's words: each step's job and path, what changed, and that the usual approvals still apply. */
222
244
  export declare function flowTerms(definition: FlowDefinition, previous: FlowDefinition | null): string[];
223
- /** Ready-made flows: the coding flow is the whole business process around a change. */
245
+ /** The label an issue gets to become work: what the Issues to PRs template and the issue starter flow watch for. */
246
+ export declare const ISSUE_LABEL = "toolroll";
247
+ /** Ready-made flows: the coding flow is the whole business process around a change. A template's `trigger`
248
+ * (a trigger's settings, as flow-triggers.ts reads them) is added with it. */
224
249
  export declare const FLOW_TEMPLATES: readonly {
225
250
  id: string;
226
251
  label: string;
227
252
  about: string;
228
253
  definition: FlowDefinition;
254
+ trigger?: Record<string, unknown>;
229
255
  }[];