toolroll 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
package/dist/flows-ui.js CHANGED
@@ -130,7 +130,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
130
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,
131
131
  button: config?.kind === "button" ? { label: config.label, questions: config.questions } : null,
132
132
  hook: config !== null && takesDeliveries(config) ? { ready: hookReady(trigger, setup.dir), needsSecret: config.kind === "linear" } : null,
133
- 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",
134
134
  shared: config?.kind === "button" && trigger.hookHash !== null,
135
135
  };
136
136
  });
package/dist/flows.d.ts CHANGED
@@ -231,6 +231,8 @@ export type FlowStepInput = {
231
231
  };
232
232
  /** What a draft zone asks for when the steps leave it out. */
233
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;
234
236
  /**
235
237
  * A flow from an ordered list of steps: ids from names, each step leading
236
238
  * to the next, a Done zone at the end when none is listed, and a decision
package/dist/flows.js CHANGED
@@ -493,6 +493,10 @@ function defaultInstructions(kind, earlier) {
493
493
  ? `{{card.title}}\n\n{{card.description}}${notes}\n\nChanges asked for (if any): {{note}}`
494
494
  : `Investigate this and write a short, clear report with a summary first: {{card.title}}\n\n{{card.description}}${notes}\n\nFeedback to address (if any): {{note}}`;
495
495
  }
496
+ /** Steps as the lead's flow tool and `toolroll flows` take them: "me" as a decider is the person asking, stored by name. */
497
+ export function stepsFor(steps, name) {
498
+ return Array.isArray(steps) ? steps.map(step => step !== null && typeof step === "object" && typeof step.decider === "string" && /^(me|myself|i|you|the operator)$/i.test(step.decider.trim()) ? { ...step, decider: name } : step) : steps;
499
+ }
496
500
  /**
497
501
  * A flow from an ordered list of steps: ids from names, each step leading
498
502
  * to the next, a Done zone at the end when none is listed, and a decision
package/dist/guides.js CHANGED
@@ -79,6 +79,29 @@ ${AUTHORITY_LINE}
79
79
  detail says why; \`toolroll sync\` refreshes trackers.
80
80
  - \`contest-open\`: a tournament is running on the task; a person picks.
81
81
 
82
+ ## Flows
83
+
84
+ A flow is a process cards move through, drawn as zones. The terminal
85
+ applies the console's rules exactly.
86
+
87
+ - Read: \`toolroll flows list [--repo PATH] --json\` (id, name, project,
88
+ zones, triggers, cards waiting) and \`flows show <id> --json\` (each
89
+ zone's \`next\` and \`ifFails\` paths, triggers, recent cards).
90
+ - Change, as an approver (\`--as <you> --token <t>\`, or the remembered
91
+ login): \`flows create --repo PATH --name <n> (--template <id> |
92
+ --steps <file|->)\` — steps in the lead's flow-tool format, in order;
93
+ \`flows edit <id> --steps <file|->\`; \`flows trigger add <id> <json|file>\`;
94
+ \`flows trigger pause|resume|remove|check <id> <trigger>\`; \`flows script
95
+ save --repo PATH --name <n> (--file <path in project> | --body <file>)
96
+ --about "<line>"\`; \`flows card add <id> --title <t> [--zone <z>]\`;
97
+ \`flows archive <id>\`.
98
+ - create, edit, archive and trigger add answer with a preview
99
+ (\`applied: false\`, \`terms\`) until \`--yes\`. Show the person the terms
100
+ and run \`--yes\` only when they asked for exactly that change.
101
+ - Refusals: \`unknown-project\`, \`unknown-flow\`, \`invalid-steps\` (such as a
102
+ pull request that merges without a decision before it), \`invalid-trigger\`,
103
+ \`invalid-script\`, \`unauthenticated\`, \`stale\`.
104
+
82
105
  ## What you may never do
83
106
 
84
107
  - Never approve scopes, answer decisions, or acquire approver tokens —
package/dist/keys.js CHANGED
@@ -87,7 +87,10 @@ export function readAuthModeStrict(provider, home = homedir()) {
87
87
  raw = readFileSync(authModeFileFor(provider, home), "utf8");
88
88
  }
89
89
  catch (error) {
90
- if (error.code === "ENOENT")
90
+ // Inside the agents' fence the keys folder is out of reach: Linux masks it (the file reads as absent), and macOS
91
+ // refuses it (EPERM), which reads the same way — a Toolroll a flow's check zone starts has only its CLIs' own sign-ins.
92
+ // Any other unreadable file (EACCES: its permissions) is still a stated problem.
93
+ if (error.code === "ENOENT" || error.code === "EPERM")
91
94
  return { ok: true, mode: DEFAULT_AUTH_MODE[provider] };
92
95
  return { ok: false, problem: `the ${provider} auth-mode file cannot be read (${error.code ?? "error"}) — run \`keys auth ${provider} subscription|api-key\` to restate it` };
93
96
  }
@@ -19,7 +19,7 @@ export const MATE_CONTRACT = [
19
19
  "For intake, a plain-language outcome is enough to draft a task. Infer title, narrow goal, safe non-goals and testable criteria; leave touches empty for discovery. Do not ask the operator for a title, paths, implementation details, acceptance wording, model, budget or safely inferable fields. Ask at most three questions with defaults for material ambiguity, conflicting goals or unresolved irreversible/public/security/data-loss/migration choices. With 'use your judgment', use reversible defaults; real blockers may still arise.",
20
20
  "Set propose_task planning to 'required' for broad, risky or plan-first work, 'skip' for explicitly small direct builds, otherwise 'auto'. report:true investigates without a branch; follow-ups require confirmed proposals.",
21
21
  "Tools are MCP servers a project's builds get, and builds get nothing else: read get_project_tools. A service in connectBySigningIn (Stripe, Notion, Linear, Sentry, Jira, Intercom, Attio and more) connects in one click: give the operator its connect link, where they sign in on the service's own page; never tool_add it and never ask for its key. To add another, prefer its commonTools entry (propose_action tool_add with catalog); otherwise use exactly the command or address the operator gives, never a package you are unsure exists. A tool reaches work approved after it was added. Never ask for, accept or repeat a secret's value: name it and open the Tools page with show_control tools.",
22
- "Flows are a project's process drawn as zones that cards move through: Holding, Build and Research (each files an ordinary task), Person decides, Message, Done. Read get_flows. To make one, propose_flow create with a template or the steps in plain names; leave instructions out unless the operator gave them, and use decider 'me' when they decide. To change one, propose_flow edit with the whole step list, keeping existing steps by id. For cards use add_card, move_card, approve, send_back with the operator's note, or cancel_card; find the card by what it is about. Triggers start cards on their own: a button with questions, a schedule, GitHub (new issues, a label being added, new pull requests, failed checks), Linear, another flow's cards reaching a zone, or email arriving in the operator's mailbox (optionally only from some senders or with words in the subject; reading mail is set up in Settings → Email); propose_flow add_trigger. A Slack, Discord or Teams channel feeds a flow when a paired approver sends 'flow <the flow's number>' in that channel (never from here); each message there becomes a card, and an 'update' step answers in its thread. Webhook addresses and the Linear key are set on the flow's Triggers panel, never in chat. Cards have an owner, followers and a discussion: read a card's discussion with get_flows flow and card before acting on what teammates said; comment (with @name to ping someone), assign and follow through propose_flow. Two steps run without a model: 'check' runs one of the project's scripts (named scripts in Python, Node or shell that belong to the project, not a flow — save one with propose_flow save_script and the repo even before any flow exists; keep them short, or point them at a file already in the repository) with the card as its input; what it prints is passed to later steps, a last line 'goto: <answer>' picks where the card goes, it can run in an empty folder or a copy of the card's work, and it takes the failure path if it fails; a schedule trigger can run a script and make a card of each item it prints; 'update' comments on the GitHub or Linear issue the card came from and can close it, or answers in the chat thread it came from. A Build whose project checks fail takes its failure path too. A 'sort' step has Jev (a fast decision model on the operator's OpenRouter account) read the card and send it where the answer it picks leads, or to its not-sure step, noting scores or yes/no answers too; make the sort the first step, because cards wait in a holding step until a person moves them; for triage, spam, lead, effort or exception routing, start from the matching template. A 'draft' step has Claude write a reply, summary or note from the card in seconds and sends nothing itself: put a decision after it (decider 'owner' asks the flow's owner in their chat app to approve, edit or send it back), then the step that posts it. Steps can reach outside too: 'request' calls a web address, 'email' sends mail from the operator's email account (answering the sender of a card that came from email keeps the reply in that thread), 'tool' uses one of the project's tools (get_project_tools); secrets for them are saved on the canvas or Tools page, never in chat, and a decision should come before any that sends what a model wrote or an outsider sent. A 'wait' step after an 'email' step waits for the person to reply (the reply moves the card on and is kept for later steps; with none by the time it waits, the card takes its no-reply step, like a follow-up email), or just waits a set time. Any step can remind whoever it waits on after a while (remindAfter), and holding and decision steps can then move the card on (thenMoveTo): use these for follow-ups and decisions that stall. AI teammates are agents on the operator's team with a soul file (who they are, how they write, what they know, what they decide on their own, what they ask first, what they never do); read get_teammates. They work flow cards within those rules: a teammate named on an approval step decides it (and hands hard ones to that step's person), and a 'teammate' step has one pick where the card goes and write what the next steps send, asking the flow's owner when its rules say to. They never approve code tasks or merges. With propose_teammate you add one (from a template: support, sales, ops, triage), change one rule section (edit_section), pause, resume or remove it, pass it a note, or answer its question for the operator; put it to work with propose_flow. When the operator says something like 'Maya can approve refunds up to $100 now', change that section of her soul file; when it's for this week or a one-off, pass it as a note. Teammates can use the project's tools under a rule per action: do it, ask first (the operator approves each exact call), never, or do it up to a limit on a number; propose_teammate use_tool, stop_tool and tool_rule change them, and when a rule has a limit also change the soul file so its words agree. Each teammate has a memory (get_teammates): what people told it (a note adds one) and facts it kept from cards; forget or edit_memory when the operator says it has something wrong. When it suggests a rule change, that is a question for the operator; answer it only as they say. Each teammate has a desk (a flow): the operator can message it by name in their chat app ('@maya, …'), and routines (propose_teammate add_routine) put a card there on a schedule; its answer goes back to whoever asked. Its desk can send a card to a Build zone that files an ordinary task under the usual approvals. When someone new wants a support desk, bug triage, sales follow-up or requests handled, a starter kit (propose_flow kit) sets up the teammate and its flow in one card. get_teammates with a teammate gives its week (what it did, cost, what the operator overrode); a tool call can be undone (propose_teammate undo) only when its action has an undo set (tool_rule undoWith). A 'pull-request' step opens a pull request for a Build's result and follows its CI (green moves on, red goes back to the build naming the failing check); it merges only when set to, and only after a person approved the card. The Issues to PRs template runs GitHub issues labelled toolroll through build, approval, pull request and a comment on the issue. When the operator asks for something to happen every time (fix CI when it fails, turn labelled issues into tasks, queue work overnight), offer the matching starter flow with propose_flow starter; each is one yes, and none merges without a person. For where flows break, read get_flow_insights, and read a failed run's log before explaining it.",
22
+ "Flows are a project's process drawn as zones that cards move through: Holding, Build and Research (each files an ordinary task), Person decides, Message, Done. Read get_flows. To make one, propose_flow create with a template or the steps in plain names; leave instructions out unless the operator gave them, and use decider 'me' when they decide. To change one, propose_flow edit with the whole step list, keeping existing steps by id. For cards use add_card, move_card, approve, send_back with the operator's note, or cancel_card; find the card by what it is about. Triggers start cards on their own: a button with questions, a schedule, GitHub (new issues, a label being added, new pull requests, failed checks), Linear, another flow's cards reaching a zone, or email arriving in the operator's mailbox (optionally only from some senders or with words in the subject; reading mail is set up in Settings → Email), or a plane review (every morning, one card per problem from Toolroll's last 24 hours; a problem that comes back joins its card); propose_flow add_trigger. A Slack, Discord or Teams channel feeds a flow when a paired approver sends 'flow <the flow's number>' in that channel (never from here); each message there becomes a card, and an 'update' step answers in its thread. Webhook addresses and the Linear key are set on the flow's Triggers panel, never in chat. Cards have an owner, followers and a discussion: read a card's discussion with get_flows flow and card before acting on what teammates said; comment (with @name to ping someone), assign and follow through propose_flow. Two steps run without a model: 'check' runs one of the project's scripts (named scripts in Python, Node or shell that belong to the project, not a flow — save one with propose_flow save_script and the repo even before any flow exists; keep them short, or point them at a file already in the repository) with the card as its input; what it prints is passed to later steps, a last line 'goto: <answer>' picks where the card goes, it can run in an empty folder or a copy of the card's work, and it takes the failure path if it fails; a schedule trigger can run a script and make a card of each item it prints; 'update' comments on the GitHub or Linear issue the card came from and can close it, or answers in the chat thread it came from. A Build whose project checks fail takes its failure path too. A 'sort' step has Jev (a fast decision model on the operator's OpenRouter account) read the card and send it where the answer it picks leads, or to its not-sure step, noting scores or yes/no answers too; make the sort the first step, because cards wait in a holding step until a person moves them; for triage, spam, lead, effort or exception routing, start from the matching template. A 'draft' step has Claude write a reply, summary or note from the card in seconds and sends nothing itself: put a decision after it (decider 'owner' asks the flow's owner in their chat app to approve, edit or send it back), then the step that posts it. Steps can reach outside too: 'request' calls a web address, 'email' sends mail from the operator's email account (answering the sender of a card that came from email keeps the reply in that thread), 'tool' uses one of the project's tools (get_project_tools); secrets for them are saved on the canvas or Tools page, never in chat, and a decision should come before any that sends what a model wrote or an outsider sent. A 'wait' step after an 'email' step waits for the person to reply (the reply moves the card on and is kept for later steps; with none by the time it waits, the card takes its no-reply step, like a follow-up email), or just waits a set time. Any step can remind whoever it waits on after a while (remindAfter), and holding and decision steps can then move the card on (thenMoveTo): use these for follow-ups and decisions that stall. AI teammates are agents on the operator's team with a soul file (who they are, how they write, what they know, what they decide on their own, what they ask first, what they never do); read get_teammates. They work flow cards within those rules: a teammate named on an approval step decides it (and hands hard ones to that step's person), and a 'teammate' step has one pick where the card goes and write what the next steps send, asking the flow's owner when its rules say to. They never approve code tasks or merges. With propose_teammate you add one (from a template: support, sales, ops, triage), change one rule section (edit_section), pause, resume or remove it, pass it a note, or answer its question for the operator; put it to work with propose_flow. When the operator says something like 'Maya can approve refunds up to $100 now', change that section of her soul file; when it's for this week or a one-off, pass it as a note. Teammates can use the project's tools under a rule per action: do it, ask first (the operator approves each exact call), never, or do it up to a limit on a number; propose_teammate use_tool, stop_tool and tool_rule change them, and when a rule has a limit also change the soul file so its words agree. Each teammate has a memory (get_teammates): what people told it (a note adds one) and facts it kept from cards; forget or edit_memory when the operator says it has something wrong. When it suggests a rule change, that is a question for the operator; answer it only as they say. Each teammate has a desk (a flow): the operator can message it by name in their chat app ('@maya, …'), and routines (propose_teammate add_routine) put a card there on a schedule; its answer goes back to whoever asked. Its desk can send a card to a Build zone that files an ordinary task under the usual approvals. When someone new wants a support desk, bug triage, sales follow-up or requests handled, a starter kit (propose_flow kit) sets up the teammate and its flow in one card. get_teammates with a teammate gives its week (what it did, cost, what the operator overrode); a tool call can be undone (propose_teammate undo) only when its action has an undo set (tool_rule undoWith). A 'pull-request' step opens a pull request for a Build's result and follows its CI (green moves on, red goes back to the build naming the failing check); it merges only when set to, and only after a person approved the card. The Issues to PRs template runs GitHub issues labelled toolroll through build, approval, pull request and a comment on the issue. When the operator asks for something to happen every time (fix CI when it fails, turn labelled issues into tasks, queue work overnight, review what went wrong every morning), offer the matching starter flow with propose_flow starter; each is one yes, and none merges without a person. For where flows break, read get_flow_insights, and read a failed run's log before explaining it.",
23
23
  "For skills read get_skills and exact instructions. Enabled means supplied, not proven used or connected. Manage/test through get_actions/propose_action with project/version; require a receipt before claiming deployment/test start.",
24
24
  "To see what a result changed, read get_diff: the file list, then one file's changes. To see why checks failed, read get_check_log: its end, or search for the error. When the operator asks for a change, read the relevant file's diff first, then propose_review revise with the exact path and line and one precise instruction in their words.",
25
25
  "Each task also has its own chat with the operator (its Ask panel and phone replies). When they refer to what was said or asked about a task, read get_task_conversation. Cards you draft about a task are recorded in that task's chat once confirmed.",
@@ -39,7 +39,7 @@ import { agentChoicesFor, routeOfTask, INSTALLATION_SCOPE } from "./agentconfig.
39
39
  import { isNewModel, modelWords, priceWords, runtimeStates, seenModels } from "./model-catalog.js";
40
40
  import { agentsSummary, chosenWords, isRiskLevel, PHASES, postureWords, RISK_CHOICES, riskConsequence, riskTitle, routeProblems, sameSpec, specWords } from "./phase-routing.js";
41
41
  import { TOOL_CATALOG, discoverTools, projectToolsOf, secretsSetFor, toolCommandLine, toolStanding } from "./project-tools.js";
42
- import { deciderOf, durationWords, FLOW_KIND_WORDS, FLOW_STAGE_KINDS, FLOW_TEMPLATES, flowFromSteps } from "./flows.js";
42
+ import { deciderOf, durationWords, FLOW_KIND_WORDS, FLOW_STAGE_KINDS, FLOW_TEMPLATES, flowFromSteps, stepsFor } from "./flows.js";
43
43
  import { flowDefinitionOf } from "./flow-engine.js";
44
44
  import { flowInsights } from "./flow-insights.js";
45
45
  import { describeTrigger, FLOW_TRIGGER_KINDS, triggerConfigOf } from "./flow-triggers.js";
@@ -871,7 +871,7 @@ export const MATE_TOOLS = [
871
871
  },
872
872
  {
873
873
  name: "propose_flow",
874
- description: "Draft a flow change as a card the operator confirms. starter: switch on a starter flow in a project (repo, starter: ci-fix files a fix task when CI fails on the main branch, issue-task makes a task of each GitHub issue labelled toolroll, overnight holds cards added in the day until 22:00 and leaves results for the morning) — its zones and trigger in one card; offer the matching one when the operator says to do something every time. kit: set up a starter kit in a project (repo, kit: support-desk, bug-triage, sales-follow-up or ops-requests) — a teammate, the flow it works and its buttons, in one card; prefer it when the operator wants a support desk, bug triage, sales follow-up or requests handled. create: a template, or the steps in order (each leads to the next; Done is added; instructions may be left out). 'request' calls a web address (method, url with its host written out, headers — {{secret.NAME}} uses a secret the operator saved on the step, never in chat — and body); 'email' sends mail (to, subject, body; {{card.email}} is the card's email address); 'tool' calls one of the project's tools (server: the tool's name from get_project_tools, tool: its function, args: an object). Put a decision before any of these when they send what a model wrote or what an outsider sent. A 'draft' step has Claude write something from the card (instructions: what to write); follow it with an approval step (decider 'owner' asks the flow's owner in their chat app, where they can approve, edit or send it back), then an 'update' or 'notify' step whose message is '{{stage.<draft id>}}'. A 'sort' step has Jev pick one of its answers; make it the first step (never a holding step before it, or new cards wait unsorted): question, answers (answer, means: a few words Jev reads, goesTo: a step), sureAt (percent, default 80), ifNotSure (a step; otherwise the card waits for a person), and up to 3 alsoNote (score with levels lowest first, or yes-no); a sort has no next, so give each branch's last step its own next. A 'pull-request' step opens a pull request for the card's built result and waits for CI: next when it passes, ifFails when it fails (only this step defaults it: to the build before it, as a revision carrying the failing check); merge ('squash', 'merge' or 'rebase') merges once checks pass, and needs an approval step before it on every path. waitFor 'hours' with from and until (like '22:00' and '06:00') holds cards until the clock is inside those hours. A 'wait' step, after an 'email' step, waits for a reply to that email from someone it went to (waitFor 'reply', the default): next is where a reply goes (the reply is {{stage.<wait id>}}), ifNoReply where the card goes when none comes within wait (like '3 days', up to 30 days); waitFor 'time' just waits, then next. Any step but wait and done can have remindAfter (like '2 days': whoever it waits on is reminded once; 'none' removes it) and, on holding and approval steps, thenMoveTo (a step the card moves to then). AI teammates (get_teammates): an approval step with teammate (its short name) is decided by that teammate within its rules, and it hands hard ones to the step's decider; a 'teammate' step (teammate, instructions, routes of answer and goesTo) has it read the card, pick where it goes and write what the next steps send (the email body is then {{stage.<id>}}), asking the flow's owner when its rules say to. edit: the full step list, keeping existing steps by id — what a kept step leaves out carries over. add_card (starts in the first zone unless zone is named), move_card, approve, send_back (needs a note), cancel_card, comment (note; @name pings that person), assign (owner: a name, 'me', or 'nobody'), follow, unfollow, save_script (repo, and script: name, about, language python|node|shell, and either body (short) or file (a path in the project, like scripts/enrich.py), and timeoutMinutes; scripts belong to the project, so no flow is needed; a 'check' step in any of its flows names it). A 'check' step runs its script with the card as JSON on stdin (and in $FLOW_INPUT); what it prints is its result for later steps ({{stage.<id>}}); runIn 'folder' (an empty folder: for scripts that work on data) or 'copy' (a copy of the card's work, after setup: for tests on code; the default); routes (answer, goesTo) that a last printed line 'goto: <answer>' picks; ifFails is where a failing script sends the card, such as back to the build (it has no default: without it the card waits there); secrets (names of saved secrets it gets as variables). add_trigger with settings (kind button: label, questions; schedule: schedule like 'daily 09:00 Europe/London', and title, or script (a saved script whose printed items — one per line, a title or JSON with title, description, key — each become a card, once) with secrets; github: repo owner/name, watch issues|pulls|checks, label, branch, from team|anyone; linear: team, state, label; flow: follow (another flow's id), when (its zone); email: folder (default INBOX), sender (addresses or domains), subject (words it must contain)); pause_trigger, resume_trigger, remove_trigger with trigger. Read get_flows first except to create.",
874
+ description: "Draft a flow change as a card the operator confirms. starter: switch on a starter flow in a project (repo, starter: ci-fix files a fix task when CI fails on the main branch, issue-task makes a task of each GitHub issue labelled toolroll, overnight holds cards added in the day until 22:00 and leaves results for the morning, plane-review reviews the plane's last 24 hours every morning and turns each problem worth fixing into a researched fix and a pull request) — its zones and trigger in one card; offer the matching one when the operator says to do something every time. kit: set up a starter kit in a project (repo, kit: support-desk, bug-triage, sales-follow-up or ops-requests) — a teammate, the flow it works and its buttons, in one card; prefer it when the operator wants a support desk, bug triage, sales follow-up or requests handled. create: a template, or the steps in order (each leads to the next; Done is added; instructions may be left out). 'request' calls a web address (method, url with its host written out, headers — {{secret.NAME}} uses a secret the operator saved on the step, never in chat — and body); 'email' sends mail (to, subject, body; {{card.email}} is the card's email address); 'tool' calls one of the project's tools (server: the tool's name from get_project_tools, tool: its function, args: an object). Put a decision before any of these when they send what a model wrote or what an outsider sent. A 'draft' step has Claude write something from the card (instructions: what to write); follow it with an approval step (decider 'owner' asks the flow's owner in their chat app, where they can approve, edit or send it back), then an 'update' or 'notify' step whose message is '{{stage.<draft id>}}'. A 'sort' step has Jev pick one of its answers; make it the first step (never a holding step before it, or new cards wait unsorted): question, answers (answer, means: a few words Jev reads, goesTo: a step), sureAt (percent, default 80), ifNotSure (a step; otherwise the card waits for a person), and up to 3 alsoNote (score with levels lowest first, or yes-no); a sort has no next, so give each branch's last step its own next. A 'pull-request' step opens a pull request for the card's built result and waits for CI: next when it passes, ifFails when it fails (only this step defaults it: to the build before it, as a revision carrying the failing check); merge ('squash', 'merge' or 'rebase') merges once checks pass, and needs an approval step before it on every path. waitFor 'hours' with from and until (like '22:00' and '06:00') holds cards until the clock is inside those hours. A 'wait' step, after an 'email' step, waits for a reply to that email from someone it went to (waitFor 'reply', the default): next is where a reply goes (the reply is {{stage.<wait id>}}), ifNoReply where the card goes when none comes within wait (like '3 days', up to 30 days); waitFor 'time' just waits, then next. Any step but wait and done can have remindAfter (like '2 days': whoever it waits on is reminded once; 'none' removes it) and, on holding and approval steps, thenMoveTo (a step the card moves to then). AI teammates (get_teammates): an approval step with teammate (its short name) is decided by that teammate within its rules, and it hands hard ones to the step's decider; a 'teammate' step (teammate, instructions, routes of answer and goesTo) has it read the card, pick where it goes and write what the next steps send (the email body is then {{stage.<id>}}), asking the flow's owner when its rules say to. edit: the full step list, keeping existing steps by id — what a kept step leaves out carries over. add_card (starts in the first zone unless zone is named), move_card, approve, send_back (needs a note), cancel_card, comment (note; @name pings that person), assign (owner: a name, 'me', or 'nobody'), follow, unfollow, save_script (repo, and script: name, about, language python|node|shell, and either body (short) or file (a path in the project, like scripts/enrich.py), and timeoutMinutes; scripts belong to the project, so no flow is needed; a 'check' step in any of its flows names it). A 'check' step runs its script with the card as JSON on stdin (and in $FLOW_INPUT); what it prints is its result for later steps ({{stage.<id>}}); runIn 'folder' (an empty folder: for scripts that work on data) or 'copy' (a copy of the card's work, after setup: for tests on code; the default); routes (answer, goesTo) that a last printed line 'goto: <answer>' picks; ifFails is where a failing script sends the card, such as back to the build (it has no default: without it the card waits there); secrets (names of saved secrets it gets as variables). add_trigger with settings (kind button: label, questions; schedule: schedule like 'daily 09:00 Europe/London', and title, or script (a saved script whose printed items — one per line, a title or JSON with title, description, key — each become a card, once) with secrets; github: repo owner/name, watch issues|pulls|checks, label, branch, from team|anyone; linear: team, state, label; flow: follow (another flow's id), when (its zone); email: folder (default INBOX), sender (addresses or domains), subject (words it must contain); plane-review: at (HH:MM, default 07:30), timeZone — every day it reads the plane's last 24 hours and makes one card per problem worth fixing, a returning problem joining its card); pause_trigger, resume_trigger, remove_trigger with trigger. Read get_flows first except to create.",
875
875
  inputSchema: schema({
876
876
  operation: { type: "string", enum: ["create", "edit", "add_card", "move_card", "approve", "send_back", "cancel_card", "comment", "assign", "follow", "unfollow", "save_script", "add_trigger", "pause_trigger", "resume_trigger", "remove_trigger", "kit", "starter"] },
877
877
  repo: REPO_ARG, flow: { type: "integer", minimum: 1 }, card: { type: "integer", minimum: 1 }, kit: { type: "string", enum: KITS.map(one => one.id) },
@@ -908,12 +908,13 @@ export const MATE_TOOLS = [
908
908
  team: { type: "string", maxLength: 12 }, state: { type: "string", maxLength: 40 }, follow: { type: "integer", minimum: 1 }, when: { type: "string", maxLength: 60 },
909
909
  folder: { type: "string", maxLength: 100 }, sender: { type: "string", maxLength: 300 }, subject: { type: "string", maxLength: 100 },
910
910
  script: { type: "string", maxLength: 40 }, secrets: { type: "array", maxItems: 10, items: { type: "string", maxLength: 40 } },
911
+ at: { type: "string", maxLength: 5 }, timeZone: { type: "string", maxLength: 60 },
911
912
  } },
912
913
  }, ["operation"]),
913
914
  handle: (ctx, args) => {
914
915
  const pick = (keys) => Object.fromEntries(keys.filter(key => args[key] !== undefined).map(key => [key, args[key]]));
915
916
  // "me" in a step means the operator; the drawing stores their name.
916
- const steps = () => (Array.isArray(args["steps"]) ? args["steps"] : []).map(step => typeof step?.decider === "string" && /^(me|myself|i|you|the operator)$/i.test(step.decider.trim()) ? { ...step, decider: ctx.who.name } : step);
917
+ const steps = () => stepsFor(Array.isArray(args["steps"]) ? args["steps"] : [], ctx.who.name);
917
918
  const flowOf = () => {
918
919
  const flow = Number.isSafeInteger(args["flow"]) ? ctx.store.getFlow(Number(args["flow"])) : null;
919
920
  return flow !== null && ctx.who.repos.includes(flow.repo) ? flow : null;
package/dist/operate.d.ts CHANGED
@@ -106,7 +106,7 @@ export declare const KEYS_ACTIONS: readonly ["status", "set", "clear", "verify",
106
106
  */
107
107
  export declare const OPERATE_VALUE_FLAGS: ReadonlySet<string>;
108
108
  export declare const OPERATE_BOOLEAN_FLAGS: ReadonlySet<string>;
109
- export declare function parseOperateArgs(argv: readonly string[]): Args | {
109
+ export declare function parseOperateArgs(argv: readonly string[], ownValues?: ReadonlySet<string>): Args | {
110
110
  error: string;
111
111
  };
112
112
  /** Route an `operate` command. Returns the process exit code. */