toolroll 0.9.6 → 0.9.8

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 (102) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/dist/actor.d.ts +21 -0
  3. package/dist/actor.js +48 -0
  4. package/dist/assignment-adapters.d.ts +2 -0
  5. package/dist/assignment-adapters.js +2 -0
  6. package/dist/assignment-ui.js +3 -1
  7. package/dist/assignment.d.ts +15 -0
  8. package/dist/assignment.js +80 -16
  9. package/dist/browser/workspace.css +2 -2
  10. package/dist/browser/workspace.js +28 -24
  11. package/dist/browser-workspace.d.ts +117 -2
  12. package/dist/build-review.d.ts +38 -0
  13. package/dist/build-review.js +341 -0
  14. package/dist/builder.d.ts +1 -0
  15. package/dist/builder.js +33 -13
  16. package/dist/chat-actions.js +2 -1
  17. package/dist/chat-delivery.js +2 -0
  18. package/dist/chat-quiet.js +11 -1
  19. package/dist/chat-task-actions.d.ts +13 -0
  20. package/dist/chat-task-actions.js +19 -1
  21. package/dist/check-levels-ui.d.ts +26 -0
  22. package/dist/check-levels-ui.js +84 -0
  23. package/dist/check-levels.d.ts +67 -0
  24. package/dist/check-levels.js +171 -0
  25. package/dist/claim.d.ts +22 -0
  26. package/dist/claim.js +43 -0
  27. package/dist/cli.d.ts +1 -1
  28. package/dist/cli.js +6 -0
  29. package/dist/demo-chat.js +4 -1
  30. package/dist/dispatch.js +9 -3
  31. package/dist/dispose.d.ts +12 -3
  32. package/dist/dispose.js +19 -5
  33. package/dist/exec.d.ts +5 -0
  34. package/dist/exec.js +30 -4
  35. package/dist/export.js +3 -2
  36. package/dist/first-run.d.ts +34 -0
  37. package/dist/first-run.js +37 -1
  38. package/dist/flow-gallery-ui.d.ts +30 -0
  39. package/dist/flow-gallery-ui.js +85 -0
  40. package/dist/flow-gallery.d.ts +111 -0
  41. package/dist/flow-gallery.js +445 -0
  42. package/dist/flow-share.d.ts +102 -0
  43. package/dist/flow-share.js +614 -0
  44. package/dist/flow-starters.d.ts +2 -0
  45. package/dist/flow-starters.js +1 -1
  46. package/dist/flow-steps.js +7 -0
  47. package/dist/flow-triggers.js +2 -0
  48. package/dist/flows-cli.d.ts +56 -0
  49. package/dist/flows-cli.js +109 -3
  50. package/dist/flows-ui.d.ts +15 -1
  51. package/dist/flows-ui.js +31 -6
  52. package/dist/guides.js +27 -2
  53. package/dist/invoke.js +5 -0
  54. package/dist/lead-context.js +6 -2
  55. package/dist/mate-doors.d.ts +0 -15
  56. package/dist/mate-doors.js +5 -1
  57. package/dist/mate-tools.js +9 -4
  58. package/dist/models-ui.js +1 -1
  59. package/dist/modes.d.ts +3 -1
  60. package/dist/modes.js +3 -1
  61. package/dist/operate.d.ts +4 -1
  62. package/dist/operate.js +711 -98
  63. package/dist/process-custody.js +8 -0
  64. package/dist/project-concurrency.d.ts +62 -0
  65. package/dist/project-concurrency.js +116 -0
  66. package/dist/project-delete-ui.d.ts +7 -0
  67. package/dist/project-delete-ui.js +19 -2
  68. package/dist/project-delete.js +4 -0
  69. package/dist/provider.js +16 -0
  70. package/dist/pull-request-flow.d.ts +9 -0
  71. package/dist/pull-request-flow.js +21 -3
  72. package/dist/result-follow-ups.d.ts +101 -0
  73. package/dist/result-follow-ups.js +333 -0
  74. package/dist/retention-ui.d.ts +3 -1
  75. package/dist/retention-ui.js +4 -4
  76. package/dist/retention.d.ts +13 -6
  77. package/dist/retention.js +50 -12
  78. package/dist/review-switch.d.ts +41 -0
  79. package/dist/review-switch.js +88 -0
  80. package/dist/reviewer.d.ts +24 -2
  81. package/dist/reviewer.js +49 -1
  82. package/dist/scope.js +11 -0
  83. package/dist/serve.d.ts +211 -2
  84. package/dist/serve.js +1038 -145
  85. package/dist/store.d.ts +112 -4
  86. package/dist/store.js +447 -27
  87. package/dist/surface.js +36 -9
  88. package/dist/tailnet.d.ts +11 -0
  89. package/dist/tailnet.js +47 -0
  90. package/dist/task-outcome-cli.js +2 -2
  91. package/dist/task-status.d.ts +13 -3
  92. package/dist/task-status.js +38 -16
  93. package/dist/team-cli.js +3 -0
  94. package/dist/telegram-status.js +2 -2
  95. package/dist/verification-evidence.js +21 -1
  96. package/dist/webhooks.js +5 -0
  97. package/dist/work-index.js +39 -6
  98. package/dist/work-summary.d.ts +0 -4
  99. package/dist/work-summary.js +4 -1
  100. package/dist/workspace-ui.d.ts +6 -1
  101. package/dist/workspace-ui.js +14 -11
  102. package/package.json +1 -1
@@ -660,6 +660,8 @@ async function fireScript(store, trigger, config, now, io, scheduled) {
660
660
  const script = flow === null ? null : store.flowScript(flow.repo, config.script);
661
661
  if (flow === null || script === null)
662
662
  return done(false, `There's no script called ${config.script} in this project any more.`);
663
+ if (script.held !== null)
664
+ return done(false, `The ${script.name} script came with an imported flow. Approve it on the Scripts panel to run it.`);
663
665
  const saved = readFlowSecrets(io.dir, flow.repo);
664
666
  const missing = (config.secrets ?? []).filter(name => saved[name] === undefined);
665
667
  if (missing.length > 0)
@@ -1,3 +1,4 @@
1
+ import { type FetchLike } from './flow-share.js';
1
2
  import { type TriggerIo } from './flow-triggers.js';
2
3
  import type { Store } from './store.js';
3
4
  export declare const FLOWS_DESCRIPTORS: readonly [{
@@ -187,6 +188,59 @@ export declare const FLOWS_DESCRIPTORS: readonly [{
187
188
  readonly takesValue: true;
188
189
  readonly meaning: 'a zone id or name';
189
190
  }];
191
+ }, {
192
+ readonly action: 'export';
193
+ readonly synopsis: 'write a flow as a *.toolroll-flow.json file: zones, paths, trigger settings and scripts; never secrets, webhook addresses, names or cards';
194
+ readonly mutation: 'none';
195
+ readonly positionals: readonly ['flow'];
196
+ readonly flags: readonly [...{
197
+ name: string;
198
+ takesValue: boolean;
199
+ meaning: string;
200
+ }[], {
201
+ readonly name: 'out';
202
+ readonly takesValue: true;
203
+ readonly meaning: 'write the file here (default: print it)';
204
+ }];
205
+ }, {
206
+ readonly action: 'import';
207
+ readonly synopsis: 'make a flow from a flow file or a gist/GitHub https address; previews in plain words until --yes; triggers arrive off and scripts wait for approval';
208
+ readonly mutation: 'unkeyed';
209
+ readonly positionals: readonly ['file'];
210
+ readonly flags: readonly [...{
211
+ name: string;
212
+ takesValue: boolean;
213
+ meaning: string;
214
+ }[], {
215
+ name: string;
216
+ takesValue: boolean;
217
+ meaning: string;
218
+ }, {
219
+ name: string;
220
+ takesValue: boolean;
221
+ meaning: string;
222
+ }, {
223
+ readonly name: 'param';
224
+ readonly takesValue: true;
225
+ readonly meaning: 'a value the file asks for, as name=value (repeat for more)';
226
+ }];
227
+ }, {
228
+ readonly action: 'script approve';
229
+ readonly synopsis: 'approve a script an imported flow brought, so it runs';
230
+ readonly mutation: 'identity-idempotent';
231
+ readonly flags: readonly [...{
232
+ name: string;
233
+ takesValue: boolean;
234
+ meaning: string;
235
+ }[], {
236
+ name: string;
237
+ takesValue: boolean;
238
+ meaning: string;
239
+ }, {
240
+ readonly name: 'name';
241
+ readonly takesValue: true;
242
+ readonly meaning: "the script's name";
243
+ }];
190
244
  }, {
191
245
  readonly action: 'archive';
192
246
  readonly synopsis: 'archive a flow: its cards stop moving and its triggers stop; previews until --yes';
@@ -222,6 +276,8 @@ export type FlowsCliContext = {
222
276
  triggerIo: TriggerIo;
223
277
  /** Reads a file, or stdin for "-". */
224
278
  readInput?: (path: string) => string;
279
+ /** Fetches a flow file's address (tests stand in for the network). */
280
+ fetchFlow?: FetchLike;
225
281
  };
226
282
  type Flags = Map<string, string | true>;
227
283
  export declare function runFlowsCommand(positional: readonly string[], flags: Flags, context: FlowsCliContext): Promise<number>;
package/dist/flows-cli.js CHANGED
@@ -4,12 +4,15 @@
4
4
  * addCardToFlow — so a flow made here is checked exactly as one drawn there.
5
5
  * Reads need no login; every write is an approver's, proved by --as/--token
6
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';
7
+ * does. create, edit, archive, import and trigger add preview until --yes.
8
+ * export writes a flow file (flow-share.ts); import reads one, from a path
9
+ * or a gist/GitHub address, and previews it in plain words first. */
10
+ import { readFileSync, statSync, writeFileSync } from 'node:fs';
9
11
  import { resolve } from 'node:path';
10
12
  import { envelopeJson } from './envelope.js';
11
13
  import { addCardToFlow, advanceFlows, flowDefinitionOf } from './flow-engine.js';
12
14
  import { saveScript } from './flow-scripts.js';
15
+ import { exportFlow, fetchFlowFile, FLOW_FILE_MAX_BYTES, FlowFileError, importFlow, parseFlowFile, planFlowImport } from './flow-share.js';
13
16
  import { STARTER_FLOWS, starterFlowOf, starterOf, startersFor, starterTerms, switchOnStarter } from './flow-starters.js';
14
17
  import { addFlowTriggerTo, checkFlowTriggerNow, describeTrigger, removeFlowTrigger, triggerConfigOf, validateTriggerConfig } from './flow-triggers.js';
15
18
  import { deciderOf, FLOW_KIND_WORDS, FLOW_TEMPLATES, flowDigest, flowFromSteps, flowTerms, stepsFor } from './flows.js';
@@ -48,10 +51,15 @@ export const FLOWS_DESCRIPTORS = [
48
51
  { name: 'title', takesValue: true, meaning: "the card's title" },
49
52
  { name: 'description', takesValue: true, meaning: 'its details' },
50
53
  { name: 'zone', takesValue: true, meaning: 'a zone id or name' }] },
54
+ { action: 'export', synopsis: 'write a flow as a *.toolroll-flow.json file: zones, paths, trigger settings and scripts; never secrets, webhook addresses, names or cards', mutation: 'none', positionals: ['flow'], flags: [...read,
55
+ { name: 'out', takesValue: true, meaning: 'write the file here (default: print it)' }] },
56
+ { action: 'import', synopsis: 'make a flow from a flow file or a gist/GitHub https address; previews in plain words until --yes; triggers arrive off and scripts wait for approval', mutation: 'unkeyed', positionals: ['file'], flags: [...write, yes, repo,
57
+ { name: 'param', takesValue: true, meaning: 'a value the file asks for, as name=value (repeat for more)' }] },
58
+ { action: 'script approve', synopsis: 'approve a script an imported flow brought, so it runs', mutation: 'identity-idempotent', flags: [...write, repo, { name: 'name', takesValue: true, meaning: "the script's name" }] },
51
59
  { 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
60
  ];
53
61
  /** `--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']);
62
+ export const FLOWS_VALUE_FLAGS = new Set(['file', 'param']);
55
63
  export async function runFlowsCommand(positional, flags, context) {
56
64
  const [first, second] = positional;
57
65
  const action = first === 'trigger' || first === 'script' || first === 'card' ? `${first} ${second ?? ''}`.trim() : first ?? 'list';
@@ -93,6 +101,33 @@ export async function runFlowsCommand(positional, flags, context) {
93
101
  const shown = describeFlow(store, flow);
94
102
  return ok({ flow: shown }, showLines(shown));
95
103
  }
104
+ if (action === 'export') {
105
+ const flow = flowOf(args[0], context.projects);
106
+ if (flow === null)
107
+ return fail('unknown-flow', 'No such flow in your projects. toolroll flows list names them.');
108
+ let exported;
109
+ try {
110
+ exported = exportFlow(store, flow, context.configDir);
111
+ }
112
+ catch (error) {
113
+ return fail('unreadable', error instanceof Error ? error.message : 'This flow can\'t be exported.');
114
+ }
115
+ const left = exported.left.length === 0 ? [] : [`Left out: ${exported.left.join('; ')}.`];
116
+ const out = text('out');
117
+ if (out === null) {
118
+ if (context.json)
119
+ return ok({ file: exported.file, fileName: exported.fileName, left: exported.left }, []);
120
+ context.write(exported.json.trimEnd());
121
+ return EXIT.ok;
122
+ }
123
+ try {
124
+ writeFileSync(resolve(out), exported.json);
125
+ }
126
+ catch {
127
+ return fail('unwritable', `Couldn't write ${out}.`, EXIT.failed);
128
+ }
129
+ return ok({ path: resolve(out), fileName: exported.fileName, left: exported.left }, [`Wrote ${flow.name} to ${resolve(out)}.`, ...left]);
130
+ }
96
131
  // ---- writes: an approver's, on a project they can reach
97
132
  if (context.credentials === null)
98
133
  return fail('unauthenticated', 'Changing a flow needs an approver: pass --as <you> --token <your password> (or sign in once with toolroll up).');
@@ -232,6 +267,77 @@ export async function runFlowsCommand(positional, flags, context) {
232
267
  settle(project);
233
268
  return ok({ applied: true, repo: project, script: store.flowScript(project, String(text('name')).trim().toLowerCase()), said: saved.said }, [saved.said]);
234
269
  }
270
+ if (action === 'import') {
271
+ const given = text('repo');
272
+ if (given === null || args[0] === undefined)
273
+ return fail('usage', 'Use flows import <file|https address> --repo <path> [--param name=value …] [--yes].', EXIT.usage);
274
+ const project = projectOf(given);
275
+ if (project === null || !reachable.includes(project))
276
+ return refuse(null, 'unknown-project', 'That isn\'t one of your projects. toolroll repos lists them.');
277
+ const source = args[0];
278
+ const params = {};
279
+ for (const line of (text('param') ?? '').split('\n').filter(one => one.trim() !== '')) {
280
+ const equals = line.indexOf('=');
281
+ if (equals < 1)
282
+ return fail('usage', `Give --param as name=value, not ${line}.`, EXIT.usage);
283
+ params[line.slice(0, equals).trim()] = line.slice(equals + 1);
284
+ }
285
+ let plan;
286
+ try {
287
+ let raw;
288
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(source))
289
+ raw = await fetchFlowFile(source, context.fetchFlow);
290
+ else {
291
+ let size = 0;
292
+ try {
293
+ size = source === '-' ? 0 : statSync(resolve(source)).size;
294
+ }
295
+ catch { /* readInput says it can't be read */ }
296
+ if (size > FLOW_FILE_MAX_BYTES)
297
+ throw new FlowFileError(`That file is too big: a flow file is at most ${FLOW_FILE_MAX_BYTES / 1024} KB.`);
298
+ try {
299
+ raw = readInput(source);
300
+ }
301
+ catch {
302
+ throw new FlowFileError(`${source} couldn't be read.`);
303
+ }
304
+ }
305
+ plan = planFlowImport(store, project, parseFlowFile(raw), params, who);
306
+ }
307
+ catch (error) {
308
+ if (!(error instanceof FlowFileError))
309
+ throw error;
310
+ return refuse(project, 'invalid-file', error.message);
311
+ }
312
+ if (!flags.has('yes'))
313
+ return preview(project, plan.title, plan.terms, { name: plan.name, definition: plan.definition, parameters: plan.values });
314
+ let made;
315
+ try {
316
+ made = importFlow(store, plan, who, now, context.configDir);
317
+ }
318
+ catch (error) {
319
+ return refuse(project, 'invalid-file', error instanceof Error ? error.message : 'That flow couldn\'t be imported.');
320
+ }
321
+ record(project, 'accepted', `flow #${made.id}`);
322
+ return ok({ applied: true, flow: describeFlow(store, store.getFlow(made.id)), said: made.said }, [`${made.said} Flow #${made.id}.`]);
323
+ }
324
+ if (action === 'script approve') {
325
+ const given = text('repo'), name = text('name');
326
+ if (given === null || name === null)
327
+ return fail('usage', 'Use flows script approve --repo <path> --name <name>.', EXIT.usage);
328
+ const project = projectOf(given);
329
+ if (project === null || !reachable.includes(project))
330
+ return refuse(null, 'unknown-project', 'That isn\'t one of your projects. toolroll repos lists them.');
331
+ const script = store.flowScript(project, name.trim().toLowerCase());
332
+ if (script === null)
333
+ return refuse(project, 'unknown-script', `There's no script called ${name} in ${projectName(project)}.`);
334
+ if (script.held === null)
335
+ return ok({ applied: false, repo: project, said: `${script.name} already runs.` }, [`${script.name} already runs.`]);
336
+ store.approveFlowScript(project, script.name);
337
+ record(project, 'accepted', `script ${script.name} approved`);
338
+ settle(project);
339
+ return ok({ applied: true, repo: project, said: `Approved ${script.name}. Zones that run it go on.` }, [`Approved ${script.name}. Zones that run it go on.`]);
340
+ }
235
341
  // Everything else names a flow in one of this person's projects.
236
342
  const flow = flowOf(args[0], reachable);
237
343
  if (flow === null)
@@ -2,9 +2,23 @@
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}}@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}}";
5
+ import { type FlowFile, type FlowImportPlan } from "./flow-share.js";
6
+ 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;align-items:center;gap:8px;margin:10px 0 0}.flows .flow-import{margin-top:18px;padding-top:14px;border-top:1px solid var(--so-line)}.flows .flow-import h3{font-size:.95rem;margin:0}.flows .flow-terms{list-style:none;padding:0;margin:8px 0 16px;display:grid;gap:10px}.flows .flow-terms li{white-space:pre-line;overflow-wrap:anywhere}.flows .flow-terms pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.8rem;margin:6px 0 0}.flows .flow-import-preview h2{font-size:1rem;margin:20px 0 4px}.flows .flow-untrusted{font-weight:600}.flows button.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}.flows fieldset{border:0;padding:0;margin:0;display:grid;gap:10px;min-width:0}.flows legend{font-weight:600;padding:0;margin-bottom:6px}.flows textarea[hidden]{display:none}.flows button.secondary{background:transparent;color:var(--foreground);border:1px solid var(--border)}@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
7
  /** The flows a person can open, with how many cards wait in each, and the new-flow form. */
7
8
  export declare function flowsListHtml(store: Store, flows: readonly FlowRow[], projects: readonly string[], csrf: string, canCreate: boolean, problem: string | null): string;
9
+ /** Reads the chosen flow file into the form (the server reads it again, whole, before anything is made). */
10
+ export declare const FLOW_IMPORT_SCRIPT = "(function(){var form=document.querySelector('[data-flow-import]');if(!form)return;var input=form.querySelector('[data-flow-file]'),text=form.querySelector('[data-flow-document]'),note=form.querySelector('[data-flow-file-note]'),address=form.querySelector('[name=url]');function say(words){note.textContent=words;note.hidden=words==='';}var reading=null;input.addEventListener('change',function(){text.value='';say('');var file=input.files&&input.files[0];if(!file){reading=null;return;}if(file.size>262144){say('That file is too big: a flow file is at most 256 KB.');input.value='';reading=null;return;}reading=file.text().then(function(value){text.value=value;});});form.addEventListener('submit',function(event){if(reading!==null&&text.value===''){event.preventDefault();reading.then(function(){form.requestSubmit();});return;}if(text.value===''&&address.value.trim()===''){event.preventDefault();say('Choose a flow file or give its address.');}});})();";
11
+ /** An import's preview: the plan in plain words, what it asks for, and one Import button after the terms. */
12
+ export declare function flowImportHtml(input: {
13
+ plan: FlowImportPlan | null;
14
+ file: FlowFile;
15
+ document: string;
16
+ repo: string;
17
+ csrf: string;
18
+ values: Record<string, string>;
19
+ previewed: string;
20
+ problem: string | null;
21
+ }): string;
8
22
  /** One flow's canvas for one person. */
9
23
  export declare function flowView(store: Store, flow: FlowRow, viewer: {
10
24
  name: string;
package/dist/flows-ui.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { draftFor, flowDefinitionOf } from "./flow-engine.js";
2
2
  import { describeTrigger, triggerHeadline, FLOW_TRIGGER_KINDS, FLOW_TRIGGER_WORDS, githubRepoOf, HOOK_PATH, hookReady, readHooksBase, readLinearKey, takesDeliveries, triggerConfigOf } from "./flow-triggers.js";
3
- import { deciderOf, FLOW_COLORS, FLOW_KIND_WORDS, FLOW_STAGE_KINDS, FLOW_TEMPLATES } from "./flows.js";
3
+ import { deciderOf, FLOW_COLORS, FLOW_KIND_WORDS, FLOW_STAGE_KINDS } from "./flows.js";
4
4
  import { flowInsights, troubleWords } from "./flow-insights.js";
5
5
  import { parseSortDecision, sortChip } from "./flow-sort.js";
6
6
  import { flowSecretNames, readEmailSettings, sendingReady } from "./flow-actions.js";
7
7
  import { projectToolsOf, secretsSetFor, toolStanding } from "./project-tools.js";
8
8
  import { flowFingerprint } from "./flow-live.js";
9
+ import { FLOW_FILE_MAX_BYTES } from "./flow-share.js";
9
10
  import { googleConnected } from "./google-mail.js";
10
11
  import { mailboxReady } from "./mailbox.js";
11
12
  import { labelOf, nameOf } from "./teammate-admin.js";
@@ -13,7 +14,7 @@ import { callWords, receiptWords } from "./teammate-tools.js";
13
14
  import { undoFor } from "./teammate-week.js";
14
15
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
15
16
  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}}@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
+ 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;align-items:center;gap:8px;margin:10px 0 0}.flows .flow-import{margin-top:18px;padding-top:14px;border-top:1px solid var(--so-line)}.flows .flow-import h3{font-size:.95rem;margin:0}.flows .flow-terms{list-style:none;padding:0;margin:8px 0 16px;display:grid;gap:10px}.flows .flow-terms li{white-space:pre-line;overflow-wrap:anywhere}.flows .flow-terms pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.8rem;margin:6px 0 0}.flows .flow-import-preview h2{font-size:1rem;margin:20px 0 4px}.flows .flow-untrusted{font-weight:600}.flows button.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}.flows fieldset{border:0;padding:0;margin:0;display:grid;gap:10px;min-width:0}.flows legend{font-weight:600;padding:0;margin-bottom:6px}.flows textarea[hidden]{display:none}.flows button.secondary{background:transparent;color:var(--foreground);border:1px solid var(--border)}@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
18
  /** The flows a person can open, with how many cards wait in each, and the new-flow form. */
18
19
  export function flowsListHtml(store, flows, projects, csrf, canCreate, problem) {
19
20
  const rows = flows.map(flow => {
@@ -25,15 +26,39 @@ export function flowsListHtml(store, flows, projects, csrf, canCreate, problem)
25
26
  return `<article class="card"><div class="flow-row"><h2><a href="/flows/${flow.id}">${e(flow.name)}</a></h2><span class="flow-counts">${e(projectName(flow.repo))} · ${cards.length} card${cards.length === 1 ? "" : "s"} in progress${waiting > 0 ? ` · ${waiting} waiting for a decision` : ""}${trouble === null ? "" : ` · ${e(trouble)}`}</span></div>` +
26
27
  (buttons.length === 0 ? "" : `<p class="flow-buttons">${buttons.map(one => `<a class="button-link" href="/flows/${flow.id}?start=${one.id}">${e(one.label)}</a>`).join(" ")}</p>`) + `</article>`;
27
28
  }).join("");
28
- const create = canCreate && projects.length > 0 ? `<details class="card"${flows.length === 0 ? " open" : ""}><summary>New flow</summary><form method="post" action="/flows/new"><input type="hidden" name="csrf" value="${e(csrf)}"><label>Name<input name="name" required maxlength="80" placeholder="for example: Bug fixes"></label><label>Project<select name="repo">${projects.map(repo => `<option value="${e(repo)}">${e(projectName(repo))}</option>`).join("")}</select></label><label>Start from<select name="template">${FLOW_TEMPLATES.map(one => `<option value="${e(one.id)}">${e(one.label)}: ${e(one.about)}</option>`).join("")}</select></label><button>Create flow</button></form></details>` : "";
29
+ const projectField = projects.length === 1 ? `<input type="hidden" name="repo" value="${e(projects[0])}">` : `<label>Project<select name="repo">${projects.map(repo => `<option value="${e(repo)}">${e(projectName(repo))}</option>`).join("")}</select></label>`;
30
+ // A flow file (flow-share.ts): chosen here or fetched from a gist, previewed in plain words before anything is made.
31
+ const importForm = `<form method="post" action="/flows/import" class="flow-import" data-flow-import><input type="hidden" name="csrf" value="${e(csrf)}"><h3>Or import a flow file</h3>${projectField}<label>File<input type="file" accept=".json,application/json" data-flow-file></label><textarea name="document" hidden data-flow-document></textarea><label>Or its address<input name="url" type="url" inputmode="url" placeholder="https://gist.github.com/…"></label><p class="problem" role="alert" data-flow-file-note hidden></p><button>Preview import</button></form>`;
32
+ // A new flow starts from the gallery (Flows → New): templates grouped by what they're for.
33
+ const create = canCreate && projects.length > 0 ? `<p class="flow-buttons"><a class="button-link" href="/flows/new">New flow</a></p>${importForm}` : "";
29
34
  // No flows yet: one click makes a working example with a sample question in it.
30
35
  const example = flows.length === 0 && canCreate && projects.length > 0
31
36
  ? `<form method="post" action="/flows/example" class="card flow-example"><input type="hidden" name="csrf" value="${e(csrf)}"><h2>See a flow work</h2><p class="meta">Claude drafts a reply to a sample customer question; you approve it here or in your chat app.</p>${projects.length === 1 ? `<input type="hidden" name="repo" value="${e(projects[0])}">` : `<label>Project<select name="repo">${projects.map(repo => `<option value="${e(repo)}">${e(projectName(repo))}</option>`).join("")}</select></label>`}<button>Try an example</button></form>`
32
37
  : "";
33
38
  const intro = `<p class="meta">A flow is your process drawn as zones. Cards move through them: agents do the work, people approve, and the team hears about it.</p>` +
34
- (canCreate && projects.length > 0 ? `<p class="flow-chat">Describe how work should move and your lead drafts the flow for you to confirm, or start from a template below. <a href="/chat?draft=${encodeURIComponent("Make a flow for ")}">Describe it in chat</a></p>` : "") +
39
+ (canCreate && projects.length > 0 ? `<p class="flow-chat">Describe how work should move and your lead drafts the flow for you to confirm, or start from a template. <a href="/chat?draft=${encodeURIComponent("Make a flow for ")}">Describe it in chat</a></p>` : "") +
35
40
  `<p class="flow-chat">AI teammates can decide and handle cards for you, within rules you write. <a href="/teammates">Teammates</a> · or set one up with its flow in one click: <a href="/kits">Starter kits</a></p>`;
36
- return `<section class="flows">${problem === null ? "" : `<p class="problem" role="alert">${e(problem)}</p>`}${intro}${rows || example || '<p class="meta">No flows yet.</p>'}${create}</section>`;
41
+ return `<section class="flows">${problem === null ? "" : `<p class="problem" role="alert">${e(problem)}</p>`}${intro}${create}${rows || example || '<p class="meta">No flows yet.</p>'}</section>`;
42
+ }
43
+ /** Reads the chosen flow file into the form (the server reads it again, whole, before anything is made). */
44
+ export const FLOW_IMPORT_SCRIPT = `(function(){var form=document.querySelector('[data-flow-import]');if(!form)return;var input=form.querySelector('[data-flow-file]'),text=form.querySelector('[data-flow-document]'),note=form.querySelector('[data-flow-file-note]'),address=form.querySelector('[name=url]');function say(words){note.textContent=words;note.hidden=words==='';}var reading=null;input.addEventListener('change',function(){text.value='';say('');var file=input.files&&input.files[0];if(!file){reading=null;return;}if(file.size>${FLOW_FILE_MAX_BYTES}){say('That file is too big: a flow file is at most ${FLOW_FILE_MAX_BYTES / 1024} KB.');input.value='';reading=null;return;}reading=file.text().then(function(value){text.value=value;});});form.addEventListener('submit',function(event){if(reading!==null&&text.value===''){event.preventDefault();reading.then(function(){form.requestSubmit();});return;}if(text.value===''&&address.value.trim()===''){event.preventDefault();say('Choose a flow file or give its address.');}});})();`;
45
+ /** An import's preview: the plan in plain words, what it asks for, and one Import button after the terms. */
46
+ export function flowImportHtml(input) {
47
+ const { plan, file } = input;
48
+ const fields = file.parameters.map(one => `<label><span>${e(one.about)}${one.optional === true ? ' <span class="meta">(optional)</span>' : ""}</span><input name="param.${e(one.id)}" value="${e(input.values[one.id] ?? plan?.values[one.id] ?? one.default ?? "")}" maxlength="200" autocomplete="off"></label>`).join("");
49
+ const list = (items) => items.length === 0 ? "" : `<ul class="flow-terms">${items.map(term => `<li>${e(term)}</li>`).join("")}</ul>`;
50
+ // The steps, what they mean, the triggers and the scripts; each script's code one tap away, as on the Scripts panel.
51
+ const terms = plan === null ? "" : (plan.parts.about === null ? "" : `<p>${e(plan.parts.about)}</p>`) +
52
+ `<p class="flow-untrusted">Its instructions come from the file, not from you: read what each step is asked before you import.</p>` +
53
+ list(plan.parts.steps) +
54
+ (plan.parts.triggers.length === 0 ? "" : `<h2>Triggers</h2>${list(plan.parts.triggers)}`) +
55
+ (plan.parts.scripts.length + plan.parts.kept.length === 0 ? "" : `<h2>Scripts</h2><ul class="flow-terms">${plan.scripts.add.map((one, index) => `<li>${e(plan.parts.scripts[index])}${one.body === undefined ? "" : `<details><summary>Show the script</summary><pre>${e(one.body)}</pre></details>`}</li>`).join("")}${plan.parts.kept.map(one => `<li>${e(one)}</li>`).join("")}</ul>`) +
56
+ `<h2>Good to know</h2>${list([...plan.parts.notes, ...(plan.parts.needs === null ? [] : [plan.parts.needs])])}`;
57
+ return `<section class="flows flow-import-preview">${input.problem === null ? "" : `<p class="problem" role="alert">${e(input.problem)}</p>`}` +
58
+ `<p class="meta">Into ${e(projectName(input.repo))}. Nothing is made until you import it.</p>${terms}` +
59
+ `<form method="post" action="/flows/import" class="card"><input type="hidden" name="csrf" value="${e(input.csrf)}"><input type="hidden" name="repo" value="${e(input.repo)}"><input type="hidden" name="previewed" value="${e(input.previewed)}"><textarea name="document" hidden>${e(input.document)}</textarea>` +
60
+ (fields === "" ? "" : `<fieldset class="flow-import-fields"><legend>Fill in</legend>${fields}</fieldset>`) +
61
+ `<p class="flow-buttons">${plan === null ? "" : '<button name="confirm" value="yes" class="primary">Import flow</button>'}${fields === "" ? "" : `<button name="confirm" value="" class="${plan === null ? "" : "secondary"}">Update preview</button>`}<a href="/flows">Cancel</a></p></form></section>`;
37
62
  }
38
63
  const historyText = (event, title, sorts = new Set()) => {
39
64
  const who = event.actor === "flow" ? "" : ` by ${event.actor}`;
@@ -154,7 +179,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
154
179
  tools: projectToolsOf(store, flow.repo).map(tool => ({ name: tool.name, about: tool.spec.about, functions: tool.lastTest?.ok === true ? tool.lastTest.tools : [],
155
180
  ready: toolStanding(tool, secretsSetFor(flow.repo, tool.spec, setup.toolHome)).ready })),
156
181
  scripts: store.flowScripts(flow.repo).map(script => ({ name: script.name, about: script.about, body: script.body, timeoutMinutes: script.timeoutMinutes, version: script.version, savedBy: script.savedBy, savedAt: script.savedAt,
157
- language: script.language, file: script.file,
182
+ language: script.language, file: script.file, held: script.held !== null,
158
183
  usedHere: stages.filter(stage => stage.kind === "check" && stage.script === script.name).map(stage => stage.title) })),
159
184
  start: definition?.start ?? stages[0]?.id ?? "",
160
185
  stages,
package/dist/guides.js CHANGED
@@ -79,6 +79,27 @@ ${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
+ ## Acting as the lead
83
+
84
+ When you are the person's lead, act under your own identity, never their
85
+ login, so your housekeeping never pings them.
86
+
87
+ - The person mints it once, behind their password: \`toolroll lead token\`
88
+ (\`--revoke\` ends it). Pass it on every task, assignment and status
89
+ command as \`--token <lead token>\`, or set \`TOOLROLL_LEAD_TOKEN\`. It
90
+ signs in as them; the ledger records you as "lead for <owner>", and the
91
+ console shows "by the lead".
92
+ - Your work pings nobody: tasks you file, approve, cancel or complete stay
93
+ on the console and in the evening digest. A person hears about them only
94
+ when you hand one over, when it fails with nothing left for you to try,
95
+ or for a security alert.
96
+ - Hand a task to a person: \`task ask <id> --person <name> --why "<what
97
+ they need to do>"\` — one message, and the task is theirs from then on.
98
+ - Re-filing (a release check re-gated as \`release-<name>b\`): \`task add
99
+ "<title>" --id <new> --replaces <old>\`, or \`task state <old> cancelled
100
+ --replaced-by <new>\`. The old task reads "Replaced by <new>", never
101
+ "Cancelled".
102
+
82
103
  ## Complete, pull request, merge
83
104
 
84
105
  The console's Complete and Merge, from a terminal. Both are a person's
@@ -122,7 +143,9 @@ applies the console's rules exactly.
122
143
  ## What you may never do
123
144
 
124
145
  - Never approve scopes, answer decisions, or acquire approver tokens —
125
- those acts belong to a person, through their own ceremony.
146
+ those acts belong to a person, through their own ceremony. A lead
147
+ approves only under the lead token its person minted for it, and never
148
+ with the person's own password or remembered login.
126
149
  - Never steer a task: \`task steer\` takes the operator's credential,
127
150
  because a steering note speaks in an agent's brief WITH THE OPERATOR'S
128
151
  VOICE (see the \`steering\` guide).
@@ -311,7 +334,9 @@ ${AUTHORITY_LINE}
311
334
  their cards appear under "proposed by coordinators".
312
335
  - **settings** (\`/settings\`): alerts to this device, provider keys,
313
336
  which messaging service pages, the Telegram bot token, and the Telegram
314
- digest cadence (away mode).
337
+ digest cadence (away mode). Notifications → Projects mutes one project's
338
+ pings (\`toolroll notifications mute|unmute --repo <p>\`); it still shows
339
+ in Tasks and the evening digest.
315
340
  - The rail is four rows — inbox · board · builds · projects — and a
316
341
  **more** group: portfolio, task list, fleet, routines, system,
317
342
  requirements, people, operating mode, settings. On a phone the same
package/dist/invoke.js CHANGED
@@ -646,6 +646,11 @@ export async function invokeHeldAgent(store, runId, spec, argv, options) {
646
646
  store.recordRunProcess(runId, pid, clock(), false, heldWitness);
647
647
  runOptions.onSpawn?.(pid);
648
648
  },
649
+ onSpawnFailed: () => {
650
+ if (heldWitness !== undefined)
651
+ store.finishUnspawnedProcess(heldWitness, clock());
652
+ runOptions.onSpawnFailed?.();
653
+ },
649
654
  onContainer: info => {
650
655
  nativeHeld = true;
651
656
  if (heldWitness !== undefined)
@@ -46,9 +46,13 @@ export function leadBriefHtml(brief) {
46
46
  // it says what the person or the crew is doing next.
47
47
  const quiet = group.title === 'Finished';
48
48
  // A state chip that repeats its group's heading says nothing new.
49
- const stateChip = (state) => { const words = STATE_WORDS[state] ?? state; return words === group.title ? '' : `<span class="lead-brief-state lead-brief-state--${state}">${escape(words)}</span>`; };
49
+ const stateChip = (state, detail) => {
50
+ // Replaced, never "Cancelled".
51
+ const words = state === 'cancelled' && /^Replaced by \S+/.test(detail ?? '') ? detail.replace(/\.$/, '') : STATE_WORDS[state] ?? state;
52
+ return words === group.title ? '' : `<span class="lead-brief-state lead-brief-state--${state}">${escape(words)}</span>`;
53
+ };
50
54
  return [`<section><h3>${group.title}</h3><ul>${entries.map(one => `<li><a href="/chat?task=${encodeURIComponent(one.rootId)}">${escape(one.title)}</a>` +
51
- stateChip(one.state) +
55
+ stateChip(one.state, one.detail) +
52
56
  (quiet ? '' : `<span class="lead-brief-detail">${escape(publicChatText(one.detail || one.outcome || '', 160))}</span>`) + `</li>`).join('')}</ul></section>`];
53
57
  });
54
58
  return `<section class="lead-brief" aria-label="Project catch-up"><h2>Catch up</h2>${sections.length ? sections.join('') : '<p class="meta">Nothing needs you right now.</p>'}${brief.assignments.length > 3 || brief.omissions.candidateScanLimited || brief.omissions.assignments > 0 ? '<a href="/work">See all tasks</a>' : ''}</section>`;
@@ -1,19 +1,4 @@
1
1
  import { type SharedActionOptions } from './chat-actions.js';
2
- /**
3
- * The confirm doors (mate arc §2, ruling 7; v3 §9): a pending proposal —
4
- * the mate's, or a coordinator's over the gateway — becomes an act only
5
- * here, as the operator's own act through the plane's existing
6
- * primitives, in ONE transaction that re-proves everything the card was
7
- * rendered under. The console and the CLI both call these; the HTTP edge
8
- * adds csrf and the cookie, the CLI adds the password — neither adds
9
- * authority the door does not check itself.
10
- *
11
- * The door's answer is typed and rendered on the card: `done` with the
12
- * plain words of what happened, or `refused` with the reason the primitive
13
- * gave. A stale proposal (the queue moved, the hold changed hands, the
14
- * scope was rewritten, the decision answered) is a refusal, never a
15
- * silent re-read.
16
- */
17
2
  import { type CoordinatorProposal, type MateProposal, type Store } from "./store.js";
18
3
  import type { VerifiedApprover } from "./principal.js";
19
4
  export type ProposalKind = MateProposal["kind"];
@@ -14,6 +14,7 @@ import { executeSharedAction, sharedActionPayload, sharedActionAllowsChallenge,
14
14
  * scope was rewritten, the decision answered) is a refusal, never a
15
15
  * silent re-read.
16
16
  */
17
+ import { isCheckLevel, setTaskCheckLevel } from "./check-levels.js";
17
18
  import { verifiedAuthor } from "./store.js";
18
19
  import { isVerifiedApprover, reproveApprover } from "./principal.js";
19
20
  import { TeamLeads } from './team-leads.js';
@@ -253,6 +254,9 @@ function executeProposal(store, actor, kind, payload, now, options) {
253
254
  }, now);
254
255
  if (!filed.ok)
255
256
  return refuse("refused", `not filed: ${filed.message}`);
257
+ const checks = payload["checks"];
258
+ if (isCheckLevel(checks))
259
+ setTaskCheckLevel(store, filed.id, checks, actor.name, now);
256
260
  return {
257
261
  ok: true,
258
262
  kind,
@@ -302,7 +306,7 @@ function executeProposal(store, actor, kind, payload, now, options) {
302
306
  if (kind === "task_action") {
303
307
  // An attended surface (the console, the paired phone) honours the
304
308
  // operator's own automatic-approval mode; the CLI keeps its ceremony.
305
- const result = applyChatTaskAction(store, actor, payload, now, options.via !== "cli", { held: options.held, deferSignal: options.deferSignal, via: options.via });
309
+ const result = applyChatTaskAction(store, actor, payload, now, options.via !== "cli", { held: options.held, deferSignal: options.deferSignal, via: options.via, ...(options.evidenceRoot === undefined ? {} : { evidenceRoot: options.evidenceRoot }) });
306
310
  return result.ok ? { ok: true, kind, taskId: result.taskId, said: result.said } : refuse("stale", result.message);
307
311
  }
308
312
  if (kind === "review") {
@@ -1,4 +1,5 @@
1
1
  import { connectionsOf, oneClickOf } from "./mcp-connect.js";
2
+ import { checkLevelFromWords, isCheckLevel } from "./check-levels.js";
2
3
  import { policyParts } from "./policy.js";
3
4
  import { randomUUID } from 'node:crypto';
4
5
  import { dirname, join } from 'node:path';
@@ -451,7 +452,7 @@ export const MATE_TOOLS = [
451
452
  },
452
453
  {
453
454
  name: "propose_task_action",
454
- description: "Propose stop/resume/retry/plan/wait_for/stop_waiting for currentExecution. Stop/resume needs get_task control.run; resume requires password review.",
455
+ description: "Propose stop/resume/retry/plan/wait_for/stop_waiting/run_checks/run_full_checks/add_tests for currentExecution. Stop/resume needs get_task control.run; resume requires password review. run_checks and run_full_checks check the latest result's exact commit; add_tests files a task to write tests for it.",
455
456
  inputSchema: schema({ task: TASK_ARG, operation: { type: "string", enum: Object.keys(CHAT_TASK_ACTIONS) }, dependency: TASK_ARG, run: { type: "integer", minimum: 1 } }, ["task", "operation"]),
456
457
  handle: (ctx, args) => {
457
458
  const task = taskIdOf(args), operation = args["operation"];
@@ -1439,8 +1440,8 @@ export const MATE_TOOLS = [
1439
1440
  },
1440
1441
  {
1441
1442
  name: "propose_task",
1442
- description: "Draft inferred title/goal/criteria. report:true investigates without code; planning required plans first, skip needs explicit direct-build request, auto is default.",
1443
- inputSchema: schema({ repo: REPO_ARG, title: { type: "string", maxLength: 200 }, goal: TASK_SCOPE_TEXT_SCHEMA, not: TASK_SCOPE_TEXT_SCHEMA, touches: { type: "array", items: { type: "string", maxLength: 200 }, maxItems: 50 }, acceptance: ACCEPTANCE_ARG_SCHEMA, planning: { type: "string", enum: ["auto", "required", "skip"] }, report: { type: "boolean" } }, ["repo", "title", "goal", "acceptance"]),
1443
+ description: "Draft inferred title/goal/criteria. report:true investigates without code; planning required plans first, skip needs explicit direct-build request, auto is default. checks only when the person asked: off for \"skip the tests\", full for \"run the full checks\", quick for quick checks; omit to use the project's setting.",
1444
+ inputSchema: schema({ repo: REPO_ARG, title: { type: "string", maxLength: 200 }, goal: TASK_SCOPE_TEXT_SCHEMA, not: TASK_SCOPE_TEXT_SCHEMA, touches: { type: "array", items: { type: "string", maxLength: 200 }, maxItems: 50 }, acceptance: ACCEPTANCE_ARG_SCHEMA, planning: { type: "string", enum: ["auto", "required", "skip"] }, report: { type: "boolean" }, checks: { type: "string", maxLength: 80 } }, ["repo", "title", "goal", "acceptance"]),
1444
1445
  handle: (ctx, args) => {
1445
1446
  const repo = repoPathOf(ctx.who, args["repo"]);
1446
1447
  if (repo === null)
@@ -1467,7 +1468,11 @@ export const MATE_TOOLS = [
1467
1468
  if (args["report"] !== undefined && typeof args["report"] !== "boolean")
1468
1469
  return { ok: false, message: "report is true or false" };
1469
1470
  const report = args["report"] === true;
1470
- const id = ctx.draft("task", { repo, repoId: args["repo"], title: args["title"], goal: args["goal"], not, touches, acceptance, planning, report });
1471
+ // A level, or the person's own words ("skip the tests", "run the full checks").
1472
+ const checks = args["checks"] === undefined ? null : isCheckLevel(args["checks"]) ? args["checks"] : typeof args["checks"] === "string" ? checkLevelFromWords(args["checks"]) : null;
1473
+ if (args["checks"] !== undefined && checks === null)
1474
+ return { ok: false, message: "checks is quick, full or off" };
1475
+ const id = ctx.draft("task", { repo, repoId: args["repo"], title: args["title"], goal: args["goal"], not, touches, acceptance, planning, report, ...(checks === null || report ? {} : { checks }) });
1471
1476
  if (id === null)
1472
1477
  return tooMany();
1473
1478
  return { ok: true, body: { proposal: id, kind: "task", repo: args["repo"], deliverable: report ? "report" : "branch", planning: report ? "not needed for a scout" : planning, awaiting: "the operator's confirmation" } };
package/dist/models-ui.js CHANGED
@@ -53,7 +53,7 @@ export function modelsHtml(view) {
53
53
  fresh +
54
54
  `<h2>Default agents</h2><p class="meta">New tasks use these. Approved tasks keep the agents they were approved with.</p>` +
55
55
  view.roles.map(role => roleForm(role, manage, view.csrf)).join("") +
56
- (view.chat === null ? "" : `<h2>Chat</h2><p>${e(view.chat.words)} · <a href="/chat?settings=1#chat-settings">Change</a></p>`) +
56
+ (view.chat === null ? "" : `<h2>Chat</h2><p>${e(view.chat.words)} · <a href="/settings/lead">Change</a></p>`) +
57
57
  watch + `</section>`;
58
58
  }
59
59
  /** Filters one long picker as you type; the saved choice always stays listed. */
package/dist/modes.d.ts CHANGED
@@ -29,7 +29,9 @@ export type ModeTerms = {
29
29
  planAuto: boolean;
30
30
  /** Attended mint without the per-mint password — signer only (D8). */
31
31
  quickMint: boolean;
32
- /** Historical signed field; new modes leave it false and no worker consumes it. */
32
+ /** Historical signed field; new modes leave it false and no worker consumes
33
+ * it. Automatic review is the project's review switch (review-switch.ts),
34
+ * on by default while a hands-off mode is active. */
33
35
  reviewAuto: boolean;
34
36
  /** Historical signed retry term, retained for digest and audit compatibility. */
35
37
  reviewRetryAuto: boolean;
package/dist/modes.js CHANGED
@@ -157,7 +157,9 @@ export function modeWords(terms) {
157
157
  terms.planAuto
158
158
  ? "plans for your pre-authorized filings auto-approve only when the goal, exclusions, paths, acceptance criteria, risk, budget, and agent route remain exactly unchanged; provide a goal, paths and acceptance criteria upfront; amendments and unresolved questions still wait for you"
159
159
  : "planner-generated plans wait for your approval",
160
- "finished work and saved checks go to the lead or user; no separate model review runs",
160
+ terms.name === "hands-off"
161
+ ? "automatic review is on for this project unless you turn it off (`toolroll review off --repo <path>`): each finished build whose check passes (or that finishes, with checks Off) gets one read-only review by the project's review agent; a HIGH finding sends it back once as a revision filed under this mode, a HIGH on that revision comes to you, MEDIUM and LOW findings come to you as suggested follow-ups, and a review that fails or times out never holds the work — it reaches you marked not reviewed"
162
+ : "finished work and saved checks go to the lead or user; automatic review runs only where a project turns it on (`toolroll review on --repo <path>`)",
161
163
  ...(terms.reviewAuto || terms.reviewRetryAuto
162
164
  ? ["historical review grants are retained on record but no longer schedule work"] : []),
163
165
  terms.perAttemptBudgetMicrousd === null
package/dist/operate.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type IntegrationIo } from "./integrations.js";
2
2
  import { type OnboardIo } from "./agent-onboard.js";
3
3
  import { type TriggerIo } from "./flow-triggers.js";
4
4
  import { type StepIo } from "./flow-steps.js";
5
+ import type { FetchLike } from "./flow-share.js";
5
6
  import { type Store } from "./store.js";
6
7
  import { type DispatchAdapter } from "./sync.js";
7
8
  import { type TelegramTransport } from "./telegram.js";
@@ -56,6 +57,8 @@ export type OperateOptions = {
56
57
  flowTriggerIo?: Partial<TriggerIo>;
57
58
  /** Injected by tests: how flow check and update steps run commands and reach GitHub and Linear. */
58
59
  flowStepIo?: Partial<StepIo>;
60
+ /** Injected by tests: how `flows import` fetches a flow file's address. */
61
+ flowFetch?: FetchLike;
59
62
  /** Injected by tests: how `integrations` checks reach services (fetch, gh, sign-in checks, mail servers). */
60
63
  integrationIo?: Partial<IntegrationIo>;
61
64
  /** Injected by tests: the mate's provider fetch, key environment, and stdin lines. */
@@ -91,7 +94,7 @@ type Args = {
91
94
  * exports are behavior, not commentary — and the declared command guide
92
95
  * (surface.ts) is tested for exact equality against them.
93
96
  */
94
- export declare const TASK_ACTIONS: readonly ["add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "assign", "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan", "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge"];
97
+ export declare const TASK_ACTIONS: readonly ["add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "assign", "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan", "review", "accept", "repair", "route", "stop", "resume", "complete", "revise", "merge", "ask", "checks", "add-tests"];
95
98
  export declare const PUBLISH_ACTIONS: readonly ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
96
99
  export declare const CONFIG_ACTIONS: readonly ["show", "set", "clear"];
97
100
  export declare const APPROVER_ACTIONS: readonly ["list", "add"];