toolroll 0.9.85 → 0.9.86

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 (79) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/agent-fence.d.ts +6 -2
  3. package/dist/agent-fence.js +3 -2
  4. package/dist/assignment.js +4 -3
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +31 -29
  7. package/dist/browser-workspace.d.ts +40 -0
  8. package/dist/chat-actions.d.ts +11 -1
  9. package/dist/chat-actions.js +53 -1
  10. package/dist/chat-channel.js +1 -0
  11. package/dist/chat-quiet.js +3 -2
  12. package/dist/claim.d.ts +3 -0
  13. package/dist/claim.js +4 -1
  14. package/dist/contracts/chat-actions.d.ts +9 -0
  15. package/dist/contracts/chat-actions.js +3 -0
  16. package/dist/contracts/console-api.d.ts +4 -1
  17. package/dist/contracts/console-api.js +4 -1
  18. package/dist/contracts/flow-propose.d.ts +4 -1
  19. package/dist/contracts/flow-propose.js +1 -1
  20. package/dist/contracts/flow.d.ts +41 -0
  21. package/dist/contracts/flow.js +4 -2
  22. package/dist/contracts/lead-tools.d.ts +21 -0
  23. package/dist/contracts/lead-tools.js +2 -0
  24. package/dist/contracts/store-json.d.ts +2 -0
  25. package/dist/contracts/trigger-payloads.d.ts +20 -0
  26. package/dist/contracts/trigger-payloads.js +4 -0
  27. package/dist/coordinator.js +1 -0
  28. package/dist/deploy-gate.js +4 -0
  29. package/dist/dispatch.d.ts +3 -0
  30. package/dist/dispatch.js +4 -1
  31. package/dist/flow-browser.d.ts +103 -0
  32. package/dist/flow-browser.js +204 -0
  33. package/dist/flow-code.d.ts +5 -1
  34. package/dist/flow-code.js +4 -1
  35. package/dist/flow-engine.js +16 -0
  36. package/dist/flow-live.js +9 -1
  37. package/dist/flow-pull-request.js +15 -7
  38. package/dist/flow-share.js +3 -1
  39. package/dist/flow-steps.d.ts +9 -1
  40. package/dist/flow-steps.js +55 -5
  41. package/dist/flow-triggers.js +55 -0
  42. package/dist/flows-cli.d.ts +19 -0
  43. package/dist/flows-cli.js +47 -2
  44. package/dist/flows-ui.js +11 -2
  45. package/dist/flows.js +5 -2
  46. package/dist/lead-tools.js +35 -6
  47. package/dist/lead-voice.js +16 -5
  48. package/dist/operate.d.ts +1 -1
  49. package/dist/operate.js +215 -6
  50. package/dist/project-delete.js +7 -3
  51. package/dist/pull-request-flow.js +35 -4
  52. package/dist/result-follow-ups.js +4 -0
  53. package/dist/scope.d.ts +5 -0
  54. package/dist/scope.js +7 -0
  55. package/dist/server/chat.js +4 -2
  56. package/dist/server/console.css +29 -0
  57. package/dist/server/flows.js +15 -0
  58. package/dist/server/render-pages.d.ts +13 -0
  59. package/dist/server/render-pages.js +45 -2
  60. package/dist/server/render-tasks.d.ts +8 -0
  61. package/dist/server/render-tasks.js +17 -6
  62. package/dist/server/route-table.js +11 -0
  63. package/dist/server/task-views.js +5 -0
  64. package/dist/server/tasks.d.ts +10 -0
  65. package/dist/server/tasks.js +54 -2
  66. package/dist/settings-proofs.js +1 -0
  67. package/dist/store.d.ts +71 -3
  68. package/dist/store.js +222 -20
  69. package/dist/surface.js +18 -2
  70. package/dist/task-archive.d.ts +10 -0
  71. package/dist/task-archive.js +16 -0
  72. package/dist/task-control.d.ts +1 -1
  73. package/dist/tidy.d.ts +130 -0
  74. package/dist/tidy.js +503 -0
  75. package/dist/work-index.d.ts +24 -0
  76. package/dist/work-index.js +52 -30
  77. package/dist/workspace-ui.d.ts +1 -1
  78. package/dist/workspace-ui.js +3 -2
  79. package/package.json +1 -1
@@ -36,7 +36,9 @@ import { claudeDraftRunner, DRAFT_CHARS, DRAFT_TIMEOUT, draftPrompt, wholeDraft
36
36
  import { overruns, passOn, TEXT_LIMITS, writeWithin } from "./text-limits.js";
37
37
  import { readFlowSecrets, sendingReady, runRequest, sendEmail, toolWaiting, useTool } from "./flow-actions.js";
38
38
  import { cleanFolder, flowLaunch, runCode } from "./flow-code.js";
39
+ import { browserStanding } from "./flow-browser.js";
39
40
  import { readProviderKey } from "./keys.js";
41
+ import { cardTaskArchived } from "./task-archive.js";
40
42
  import { replyInChannel } from "./chat-inbox.js";
41
43
  import { recordSent, threadOf } from "./flow-replies.js";
42
44
  import { ASKED, subagentReady, subagentTurn } from "./subagent-work.js";
@@ -70,6 +72,9 @@ export async function runFlowSteps(store, repo, now, io) {
70
72
  const stage = known.definition?.stages.find(one => one.id === card.stage);
71
73
  if (stage === undefined)
72
74
  continue;
75
+ // A card whose task is put away (v145) runs nothing — no step, subagent turn or pull request — until it's restored.
76
+ if (cardTaskArchived(store, card.id))
77
+ continue;
73
78
  // v92: a subagent's turn — a zone it handles, or a decision it staffs (a person decides when it's paused or gone).
74
79
  if (stage.kind === "subagent" || (stage.kind === "approval" && stage.subagent !== undefined)) {
75
80
  await subagentStep(store, known.flow, known.definition, stage, card, now, io, pass);
@@ -145,6 +150,14 @@ export async function runFlowSteps(store, repo, now, io) {
145
150
  store.updateFlowCard(card.id, { waiting }, now);
146
151
  continue;
147
152
  }
153
+ // A script that starts a browser waits, saying so, until a person approves browser use for the flow (flow-browser.ts),
154
+ // rather than run and crash inside the sandbox; it runs on its own once approved.
155
+ const browser = stage.kind === "check" && stage.browser === true ? (script === null || known.definition === null ? { allowed: false, said: "This zone's script can't be read." } : browserStanding(store, known.flow.id, stage.id, { definition: known.definition, script })) : null;
156
+ if (browser !== null && !browser.allowed) {
157
+ if (card.waiting !== browser.said)
158
+ store.updateFlowCard(card.id, { waiting: browser.said }, now);
159
+ continue;
160
+ }
148
161
  if (!claimStep(store, card, { stage: stage.id, kind: stage.kind, script: script?.name ?? null, scriptVersion: script?.version ?? null }, now))
149
162
  continue;
150
163
  pass.ran++;
@@ -174,12 +187,21 @@ export async function runFlowSteps(store, repo, now, io) {
174
187
  }
175
188
  /** Claim this card's visit to its step zone, only while the card is still there on that visit: a pass that read the card
176
189
  * before another pass (or a person) moved it never runs the step for a visit that's over. */
177
- function claimStep(store, card, step, now) {
190
+ export function claimStep(store, card, step, now) {
178
191
  return store.transact(() => {
179
192
  const fresh = store.getFlowCard(card.id);
180
193
  if (fresh === null || fresh.state !== "active" || fresh.entry !== card.entry || fresh.stage !== step.stage)
181
194
  return false;
182
- return store.claimFlowStep({ card: card.id, entry: card.entry, ...step }, now);
195
+ // A card whose task is put away (v145) runs no step until the task is restored (both of its tasks, re-read here).
196
+ if (cardTaskArchived(store, card.id))
197
+ return false;
198
+ if (!store.claimFlowStep({ card: card.id, entry: card.entry, ...step }, now))
199
+ return false;
200
+ // The tasks this step was admitted for stay held while it runs, wherever the card moves meanwhile.
201
+ for (const task of [fresh.task, fresh.primaryTask])
202
+ if (task !== null)
203
+ store.handle.prepare("INSERT OR IGNORE INTO flow_step_task (card, entry, task_id) VALUES (?, ?, ?)").run(card.id, card.entry, task);
204
+ return true;
183
205
  });
184
206
  }
185
207
  /** One subagent turn, when one is due for this card: claimed like any step, retried on failure, and waiting while its question is open. */
@@ -361,16 +383,31 @@ async function runCheck(store, flow, stage, script, card, now, io) {
361
383
  if (added.code !== 0)
362
384
  return { state: "retry", said: `Couldn't make a copy of${where}: ${added.stderr.trim().split("\n")[0]?.slice(0, 160) ?? "git refused"}.` };
363
385
  }
364
- const launch = flowLaunch(store, { repo: flow.repo, folder: path, scratch: io.scratch, copy, policy: store.flowFencePolicy(flow.id), ...(io.fenceAvailable === undefined ? {} : { available: io.fenceAvailable }), ...(io.home === undefined ? {} : { home: io.home }) });
386
+ // Browser use, proved at the moment of launch: a zone that says it starts a browser runs only with a person's standing
387
+ // approval (flow-browser.ts); then its script, and only its script, gets Chromium's one service.
388
+ const wantsBrowser = stage.browser === true;
389
+ // Read once: the drawing and the script this launch runs are the very objects the browser decision is about.
390
+ const running = flowDefinitionOf(flow);
391
+ const standing = !wantsBrowser ? null : running === null || running.stages.find(one => one.id === stage.id) === undefined || JSON.stringify(running.stages.find(one => one.id === stage.id)) !== JSON.stringify(stage)
392
+ ? { allowed: false, said: "This zone changed while it was starting, so it didn't run." } : browserStanding(store, flow.id, stage.id, { definition: running, script });
393
+ if (standing !== null && !standing.allowed) {
394
+ if (copy)
395
+ await io.git("git", ["-C", flow.repo, "worktree", "remove", "--force", path], { timeoutMs: 60_000 }).catch(() => undefined);
396
+ rmSync(path, { recursive: true, force: true });
397
+ return { state: "failed", said: standing.said };
398
+ }
399
+ const launch = flowLaunch(store, { repo: flow.repo, folder: path, scratch: io.scratch, copy, policy: store.flowFencePolicy(flow.id), ...(standing?.allowed === true ? { browser: true } : {}), ...(io.fenceAvailable === undefined ? {} : { available: io.fenceAvailable }), ...(io.home === undefined ? {} : { home: io.home }) });
365
400
  try {
366
401
  if (launch !== null && "problem" in launch)
367
402
  return { state: "failed", said: launch.problem };
403
+ // The project's setup never gets the browser rule: only the zone's script asked for it.
404
+ const setupFence = launch?.fence === undefined ? undefined : { ...launch.fence, fenceGrants: withoutBrowser(launch.fence.fenceGrants) };
368
405
  let log = "";
369
406
  const setup = copy ? store.liveWorktreeSetup(flow.repo) : null;
370
407
  if (setup !== null) {
371
408
  const shell = approvedCommandShell(setup.command);
372
409
  const prepared = await io.shell(shell.file, shell.args, { cwd: path, timeoutMs: setup.timeoutMs, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST, processGroup: true,
373
- ...(launch === null ? {} : { env: launch.env, ...(launch.fence ?? {}) }) });
410
+ ...(launch === null ? {} : { env: launch.env, ...(setupFence ?? {}) }) });
374
411
  log += `$ ${setup.command}\n${prepared.stdout}${prepared.stderr}\n`;
375
412
  if (prepared.code !== 0)
376
413
  return { state: "failed", said: `The project's setup failed before ${script.name} ran (exit ${prepared.code}).\n${tail(`${prepared.stdout}\n${prepared.stderr}`)}`, log, exitCode: prepared.code };
@@ -385,7 +422,15 @@ async function runCheck(store, flow, stage, script, card, now, io) {
385
422
  answers: (stage.routes ?? []).map(one => one.answer),
386
423
  };
387
424
  const env = { FLOW_NAME: flow.name, FLOW_CARD_ID: String(card.id), FLOW_CARD_TITLE: card.title, FLOW_COMMIT: commit ?? "", FLOW_SCRIPT: script.name, FLOW_PROJECT: flow.repo };
388
- const ran = await runCode({ script, cwd: path, root: copy ? path : flow.repo, input, env, secrets, scratch: io.scratch, shell: io.shell, launch });
425
+ // Checked again right before the script: the flow or its script may have changed while the setup ran.
426
+ if (wantsBrowser) {
427
+ const still = running === null ? { allowed: false, said: "This flow's drawing can't be read." } : browserStanding(store, flow.id, stage.id, { definition: running, script });
428
+ if (!still.allowed)
429
+ return { state: "failed", said: still.said, log };
430
+ }
431
+ // And at every spawn attempt (a retry after a failed spawn too): revoked or changed meanwhile, nothing starts.
432
+ const stillApproved = !wantsBrowser || running === null ? undefined : () => browserStanding(store, flow.id, stage.id, { definition: running, script }).allowed;
433
+ const ran = await runCode({ script, cwd: path, root: copy ? path : flow.repo, input, env, secrets, scratch: io.scratch, shell: io.shell, launch, ...(stillApproved === undefined ? {} : { beforeSpawn: stillApproved }) });
389
434
  log += ran.log;
390
435
  const output = ran.output === "" ? undefined : ran.output;
391
436
  // Where it ran goes at the end of the first line: "run-tests failed (exit 1) on main."
@@ -407,6 +452,11 @@ async function runCheck(store, flow, stage, script, card, now, io) {
407
452
  rmSync(path, { recursive: true, force: true });
408
453
  }
409
454
  }
455
+ /** A fence's grants without Chromium's service (a flow's setup, beside a script zone that has it). */
456
+ function withoutBrowser(grants) {
457
+ const { browser: _scriptOnly, ...rest } = grants;
458
+ return rest;
459
+ }
410
460
  /** An update: a comment on the issue the card came from, and closing it if the zone says so. */
411
461
  async function updateSource(store, stage, card, io, now) {
412
462
  const source = card.source;
@@ -49,6 +49,7 @@ import { readFlowSecrets } from "./flow-secrets.js";
49
49
  import { prepareUnattendedCandidates, grantSignsScriptOf, scriptProvenanceOf, scriptRanDigestOf } from "./flow-unattended.js";
50
50
  import { tmpdir } from "node:os";
51
51
  import { envValue } from "./names.js";
52
+ import { recordBranchCheckRun, unprovenNoticeRuns } from "./tidy.js";
52
53
  import { linearLabelNames, readFormSubmission, readGithubEvent, readGithubIssue, readGithubIssueEventList, readGithubIssueList, readGithubRun, readGithubRunList, readInboundMail, readLinearAnswer, readLinearEvent, readLinearIssue, readWebhookPayload } from "./contracts/trigger-payloads.js";
53
54
  import { isRecord } from "./is-record.js";
54
55
  export { FLOW_TRIGGER_KINDS };
@@ -925,6 +926,9 @@ async function checkTrigger(store, trigger, config, now, io) {
925
926
  store.updateFlowTrigger(trigger.id, { failures, lastAt: now.toISOString(), lastOutcome: fetched.problem, nextAt: new Date(now.getTime() + BACKOFF_MS[Math.min(failures, BACKOFF_MS.length) - 1]).toISOString() }, now);
926
927
  return { ok: false, said: fetched.problem, added: 0 };
927
928
  }
929
+ // Tidy's CI evidence (task-tidy.md): the watched branch's completed runs, green and red, beside the failures it carded.
930
+ if (config.kind === "github" && config.watch === "checks")
931
+ await observeBranchRuns(store, trigger, config, io.gh, now);
928
932
  const by = config.kind === "github" ? "GitHub" : config.kind === "linear" ? "Linear" : "Email";
929
933
  // Known items are left out before the batch is taken (v126): a page of things already received can't keep the unseen
930
934
  // ones behind it waiting.
@@ -1206,6 +1210,14 @@ export function receiveFlowHook(store, token, delivery, dir, now) {
1206
1210
  : config.watch === "pulls" && event === "pull_request" && ["opened", "ready_for_review", "labeled", "reopened"].includes(action) ? githubItem(config, body.pull_request, "pull")
1207
1211
  : config.watch === "checks" && event === "workflow_run" && action === "completed" ? githubRunItem(config, body.workflow_run) : null;
1208
1212
  items = found === null ? [] : [found];
1213
+ // A signed workflow run on the watched branch is tidy's CI evidence too (task-tidy.md), green or red: recorded only
1214
+ // inside `written`, which re-proves the trigger is still active with the same config, credential and address.
1215
+ if (config.watch === "checks" && config.branch !== null && event === "workflow_run" && action === "completed") {
1216
+ const branch = config.branch;
1217
+ const recorded = written(() => { recordBranchCheckRun(store, { repo: config.repo, branch }, readGithubRun(body.workflow_run), now); return { status: 200, said: "Recorded." }; });
1218
+ if (recorded.status !== 200)
1219
+ return recorded;
1220
+ }
1209
1221
  }
1210
1222
  else if (config.kind === "linear") {
1211
1223
  const body = readLinearEvent(payload), sent = Number(body.webhookTimestamp);
@@ -1648,3 +1660,46 @@ export function receiveFlowForm(store, token, fields, now) {
1648
1660
  store.updateFlowTrigger(trigger.id, { lastAt: now.toISOString(), lastOutcome: "Someone sent the form." }, now);
1649
1661
  return { status: 200, html: thanks };
1650
1662
  }
1663
+ /** Tidy's CI evidence (docs/plans/task-tidy.md): the watched branch's recent completed workflow runs, read through the
1664
+ * person's own gh login. Recorded only while the trigger still watches exactly that repository and branch once GitHub
1665
+ * answered; any failure records nothing (tidy then keeps the notices). */
1666
+ async function observeBranchRuns(store, trigger, config, gh, now) {
1667
+ if (config.branch === null)
1668
+ return;
1669
+ const branch = config.branch;
1670
+ let result;
1671
+ try {
1672
+ result = await gh("gh", ["api", "-H", "Accept: application/vnd.github+json", `repos/${config.repo}/actions/runs?branch=${encodeURIComponent(branch)}&status=completed&per_page=50`], { timeoutMs: 20_000 });
1673
+ }
1674
+ catch {
1675
+ return;
1676
+ }
1677
+ if (result.code !== 0)
1678
+ return;
1679
+ let body;
1680
+ try {
1681
+ body = JSON.parse(result.stdout);
1682
+ }
1683
+ catch {
1684
+ return;
1685
+ }
1686
+ const runs = [...readGithubRunList(body).workflow_runs ?? []];
1687
+ // A few older failure cards' own runs, so tidy can tell whether each was a push to this branch (task-tidy.md).
1688
+ for (const id of unprovenNoticeRuns(store, trigger.id, config.repo)) {
1689
+ try {
1690
+ const one = await gh("gh", ["api", "-H", "Accept: application/vnd.github+json", `repos/${config.repo}/actions/runs/${id}`], { timeoutMs: 20_000 });
1691
+ if (one.code === 0)
1692
+ runs.push(readGithubRun(JSON.parse(one.stdout)));
1693
+ }
1694
+ catch { /* read again next time */ }
1695
+ }
1696
+ // Re-proved and recorded in one transaction: a trigger paused or changed meanwhile records nothing.
1697
+ store.transact(() => {
1698
+ const current = store.getFlowTrigger(trigger.id);
1699
+ const still = current === null ? null : triggerConfigOf(current);
1700
+ if (current === null || current.state !== "active" || current.configJson !== trigger.configJson || still === null || still.kind !== "github" || still.watch !== "checks" || still.repo !== config.repo || still.branch !== branch)
1701
+ return;
1702
+ for (const run of runs)
1703
+ recordBranchCheckRun(store, { repo: config.repo, branch }, run, now);
1704
+ });
1705
+ }
@@ -296,6 +296,25 @@ export declare const FLOWS_DESCRIPTORS: readonly [{
296
296
  readonly takesValue: true;
297
297
  readonly meaning: 'also cover these script or plane-review schedules (trigger ids, like 12,14); plain schedules are covered already';
298
298
  }];
299
+ }, {
300
+ readonly action: 'browser';
301
+ readonly synopsis: "let a flow's script zones marked browser: true start Chromium in their sandbox (on: a person approves with their own password, previews until --yes; off: any approver revokes)";
302
+ readonly mutation: 'identity-idempotent';
303
+ readonly audience: 'operator';
304
+ readonly positionals: readonly ['flow', 'on|off'];
305
+ readonly flags: readonly [...{
306
+ name: string;
307
+ takesValue: boolean;
308
+ meaning: string;
309
+ }[], {
310
+ name: string;
311
+ takesValue: boolean;
312
+ meaning: string;
313
+ }, {
314
+ readonly name: 'digest';
315
+ readonly takesValue: true;
316
+ readonly meaning: "required with --yes: the approval digest the preview showed; refuses if the flow or its scripts changed since";
317
+ }];
299
318
  }, {
300
319
  readonly action: 'archive';
301
320
  readonly synopsis: 'archive a flow: its cards stop moving and its triggers stop; previews until --yes';
package/dist/flows-cli.js CHANGED
@@ -19,6 +19,7 @@ import { STARTER_FLOWS, starterFlowOf, starterOf, startersFor, starterTerms, swi
19
19
  import { addFlowTriggerTo, checkFlowTriggerNow, describeTrigger, removeFlowTrigger, setFlowTriggerOn, triggerConfigOf, validateTriggerConfig, rawTakesDeliveries } from './flow-triggers.js';
20
20
  import { deciderOf, FLOW_KIND_WORDS, FLOW_TEMPLATES, flowDigest, flowFromSteps, flowTerms, stepsFor } from './flows.js';
21
21
  import { prepareUnattendedCandidates, activeUnattendedGrant, parseUnattendedAsk, revokeUnattended, signUnattended, unattendedViewOf, unattendedWords, untilWords, UNATTENDED_MAX_RUNS } from './flow-unattended.js';
22
+ import { browserApprovalDigest, browserTerms, browserTermsOf, browserViewOf, revokeFlowBrowser, signFlowBrowser } from './flow-browser.js';
22
23
  import { MODE_MAX_DAYS } from './modes.js';
23
24
  import { verifyApproverByPassword } from './principal.js';
24
25
  import { authenticateApprover, proveCredential } from './scope.js';
@@ -71,6 +72,8 @@ export const FLOWS_DESCRIPTORS = [
71
72
  { name: 'budget', takesValue: true, meaning: 'optional dollar cap per attempt, like 5 or 2.50' },
72
73
  { name: 'runs', takesValue: true, meaning: `optional limit on runs a day (UTC), 1 to ${UNATTENDED_MAX_RUNS}` },
73
74
  { name: 'include', takesValue: true, meaning: 'also cover these script or plane-review schedules (trigger ids, like 12,14); plain schedules are covered already' }] },
75
+ { action: 'browser', synopsis: "let a flow's script zones marked browser: true start Chromium in their sandbox (on: a person approves with their own password, previews until --yes; off: any approver revokes)", mutation: 'identity-idempotent', audience: 'operator', positionals: ['flow', 'on|off'], flags: [...write, yes,
76
+ { name: 'digest', takesValue: true, meaning: "required with --yes: the approval digest the preview showed; refuses if the flow or its scripts changed since" }] },
74
77
  { 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] },
75
78
  ];
76
79
  /** `--file` is a value here (a path in the project), where elsewhere it is a switch; so are unattended's `--budget`, `--runs` and `--include`. */
@@ -160,10 +163,12 @@ export async function runFlowsCommand(positional, flags, context) {
160
163
  const refuse = (project, reason, message, code = EXIT.refused) => { record(project, 'refused', reason); return fail(reason, message, code); };
161
164
  // A trigger from anyone widens what comes in (chat-management.md §0a.3): only the person's own proof counts, never a
162
165
  // machine credential standing in for them (a remote run's API token, a lead's token), though both pass the check above.
163
- const widening = (project) => {
166
+ // The same proof approves browser use in a flow's script zones (flow-browser.ts): the method is recorded with it.
167
+ const personMethod = (project) => {
164
168
  const proved = authenticateApprover(store, acting.name, acting.token, project);
165
- return proved.ok && personProof(proved.method) ? { widening: 'proved' } : {};
169
+ return proved.ok && personProof(proved.method) ? proved.method : null;
166
170
  };
171
+ const widening = (project) => personMethod(project) === null ? {} : { widening: 'proved' };
167
172
  const settle = (project) => { try {
168
173
  advanceFlows(store, project, context.clock(), { evidenceRoot: context.evidenceRoot });
169
174
  }
@@ -396,6 +401,42 @@ export async function runFlowsCommand(positional, flags, context) {
396
401
  `Revoke: toolroll flows unattended ${flow.id} off`,
397
402
  ]);
398
403
  }
404
+ if (action === 'browser') {
405
+ const flow = flowOf(args[0], reachable);
406
+ if (flow === null)
407
+ return refuse(null, 'unknown-flow', 'No such flow in your projects. toolroll flows list names them.');
408
+ const turn = args[1];
409
+ if (turn !== 'on' && turn !== 'off')
410
+ return fail('usage', `Use flows browser ${flow.id} on, or flows browser ${flow.id} off.`, EXIT.usage);
411
+ if (turn === 'off') {
412
+ // Lowering authority is one step for any approver on the project.
413
+ if (!revokeFlowBrowser(store, flow.id, who, now))
414
+ return refuse(flow.repo, 'not-on', `${flow.name} has no approved browser use. Nothing was changed.`);
415
+ record(flow.repo, 'accepted', `flow #${flow.id} browser off`);
416
+ return ok({ applied: true, browser: null }, [`${flow.name}'s scripts can no longer start a browser.`]);
417
+ }
418
+ if (flowDefinitionOf(flow) === null)
419
+ return refuse(flow.repo, 'unreadable', 'This flow\'s drawing can\'t be read. Save it again from the editor.');
420
+ const terms = browserTermsOf(store, flow);
421
+ if (terms === null || terms.zones.length === 0)
422
+ return refuse(flow.repo, 'refused', terms === null ? 'A zone that starts a browser has no script it can approve: missing, waiting for approval, or one that runs a project file (write the script on the zone instead). Nothing was changed.'
423
+ : `No zone in ${flow.name} says it starts a browser. Mark its script zone with browser: true (flows edit), then approve. Nothing was changed.`);
424
+ const digest = browserApprovalDigest(terms);
425
+ if (!flags.has('yes'))
426
+ return preview(flow.repo, `Approve browser use in the ${flow.name} flow`, [...browserTerms(terms.zones), `Approval digest ${digest}: approve exactly this with --yes --digest ${digest}.`], { flowId: flow.id, digest, zones: terms.zones });
427
+ // A person's own proof only: a lead's or remote run's token passes the approver check above but never approves this.
428
+ // The digest the person read is required: approving with --yes alone could approve something they never saw.
429
+ const given = text('digest');
430
+ if (given === null)
431
+ return fail('usage', `Approve exactly what you read: flows browser ${flow.id} on --yes --digest <the digest the preview showed>.`, EXIT.usage);
432
+ const signed = signFlowBrowser(store, { flow: flow.id, signer: who, proof: personMethod(flow.repo), sawDigest: given }, now);
433
+ if (!signed.ok)
434
+ return refuse(flow.repo, 'refused', `${signed.message} Nothing was changed.`);
435
+ record(flow.repo, 'accepted', `flow #${flow.id} browser on`);
436
+ return ok({ applied: true, browser: { digest: signed.grant.digest, zones: signed.grant.zones, signedBy: signed.grant.signedBy } }, [
437
+ `${flow.name} may start a browser. Approved by ${who}:`, ...browserTerms(signed.grant.zones).map(line => ` ${line}`), `Revoke: toolroll flows browser ${flow.id} off`,
438
+ ]);
439
+ }
399
440
  if (action === 'archive') {
400
441
  const cards = store.flowCards(flow.id, false).length, triggers = store.flowTriggers(flow.id).filter(one => one.state === 'active').length;
401
442
  if (!flags.has('yes'))
@@ -566,6 +607,7 @@ function zoneOf(stage, flow) {
566
607
  ...(stage.limit === undefined ? {} : { remindAfterMinutes: stage.limit.minutes, thenMoveTo: stage.limit.to }),
567
608
  ...(stage.kind === 'approval' ? { decider: deciderOf(stage, flow) } : {}),
568
609
  ...(stage.script === null ? {} : { script: stage.script }),
610
+ ...(stage.browser === true ? { browser: true } : {}),
569
611
  ...(stage.merge === undefined ? {} : { merge: stage.merge }),
570
612
  ...(stage.subagent === undefined ? {} : { subagent: stage.subagent }),
571
613
  ...(stage.options === undefined ? {} : { options: stage.options.map(one => ({ label: one.label, to: one.to })) }),
@@ -589,6 +631,8 @@ function describeFlow(store, flow, now) {
589
631
  }),
590
632
  cards: store.flowCards(flow.id, true).slice(0, 10).map(card => cardOf(definition, card)),
591
633
  unattended: unattendedJson(store, flow, now),
634
+ // Browser use, only for a flow with a zone that starts one: the same status line the flow page shows.
635
+ ...(() => { const view = browserViewOf(store, flow); return view === null ? {} : { browser: { status: view.status, words: view.words, signedBy: view.signedBy, signedAt: view.signedAt } }; })(),
592
636
  };
593
637
  }
594
638
  /** Whether a flow runs on its own, and until when: null when it doesn't. */
@@ -606,6 +650,7 @@ function showLines(flow) {
606
650
  ? `Runs on its own: no · turn on: toolroll flows unattended ${flow.id} on --days N`
607
651
  : `Runs on its own until ${untilWords(flow.unattended.until)} · revoke: toolroll flows unattended ${flow.id} off`,
608
652
  ...(flow.unattended === null ? [] : [` Covers: ${flow.unattended.covers.join('; ')}`]),
653
+ ...(flow.browser === undefined ? [] : [`${flow.browser.words} · ${flow.browser.status === 'allowed' ? `turn off: toolroll flows browser ${flow.id} off` : `allow: toolroll flows browser ${flow.id} on`}`]),
609
654
  '', 'Zones',
610
655
  ...flow.zones.flatMap((zone, index) => [
611
656
  ` ${index + 1}. ${zone.title} (${zone.id}) — ${zone.does}${zone.id === flow.start ? ' · start' : ''}`,
package/dist/flows-ui.js CHANGED
@@ -24,6 +24,7 @@ import { callWords, receiptWords } from "./subagent-tools.js";
24
24
  import { undoFor } from "./subagent-week.js";
25
25
  import { flowCardTaskLine } from "./flow-card-task.js";
26
26
  import { unattendedViewOf } from "./flow-unattended.js";
27
+ import { browserViewOf } from "./flow-browser.js";
27
28
  import { readFlowSecrets } from "./flow-secrets.js";
28
29
  import { html, joinHtml, postForm } from "./html.js";
29
30
  const projectName = (repo) => repo.split(/[\\/]/).filter(Boolean).pop() ?? repo;
@@ -114,6 +115,8 @@ const historyText = (event, title, sorts = new Set()) => {
114
115
  export function flowView(store, flow, viewer, selectedCard, setup = { dir: null, repos: [] }) {
115
116
  const definition = flowDefinitionOf(flow);
116
117
  const stages = definition?.stages ?? [];
118
+ const browserNow = browserViewOf(store, flow);
119
+ const browser = browserNow === null ? null : { ...browserNow, canApprove: viewer.approver && store.accountOf(viewer.name)?.projects === null };
117
120
  const title = (id) => stages.find(one => one.id === id)?.title ?? id;
118
121
  const sortZones = new Set(stages.filter(one => one.kind === "sort").map(one => one.id));
119
122
  const decisions = sortZones.size === 0 ? new Map() : store.flowSortDecisions(flow.id);
@@ -179,10 +182,15 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
179
182
  // After research: the same numbered items the message carried, each beside its screenshot.
180
183
  ...(content.items === undefined ? {} : { items: content.items.map((item, index) => ({ number: index + 1, title: item.title, lines: whyLines(item.why), source: item.source, url: item.url,
181
184
  image: item.shot === null || content.shots === null ? null : { src: `/r/${content.shots.run}/evidence/${item.shot}`, caption: itemCaption(index + 1, item) } })) }) });
185
+ const browserWait = card.state === "active" && stage?.kind === "check" && stage.browser === true && browser !== null && browser.status !== "allowed"
186
+ && card.waiting !== null && card.waiting.startsWith(`${stage.title} starts a browser`);
182
187
  return {
183
188
  id: card.id, title: card.title, description: card.description, stage: card.stage, state: card.state,
184
189
  waiting: card.state === "active" && card.task !== null && (stage?.kind === "task" || stage?.kind === "report")
185
- ? canReadTask ? flowCardTaskLine(store, card.task, taskRepo, now, secretsFor(taskRepo)) : "Task unavailable" : card.waiting,
190
+ ? canReadTask ? flowCardTaskLine(store, card.task, taskRepo, now, secretsFor(taskRepo)) : "Task unavailable"
191
+ // Waiting for browser use: the page offers Allow browser itself, so the command line the CLI shows is left off.
192
+ : browserWait ? card.waiting.replace(/ Approve it with: toolroll flows browser \d+ on$/, "") : card.waiting,
193
+ ...(browserWait ? { browserWait: true } : {}),
186
194
  task: task === null ? null : { id: task, href: `/t/${encodeURIComponent(task)}`,
187
195
  // Its running attempt's composer, for an approver who may read the task (steer-controls.ts).
188
196
  ...(() => {
@@ -262,6 +270,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
262
270
  live: flowFingerprint(store, flow.id),
263
271
  canEdit: viewer.approver,
264
272
  unattended: unattendedViewOf(store, flow, viewer, setup.now ?? now),
273
+ browser,
265
274
  approvers: store.listApprovers().map(one => one.name).filter(name => store.accountCanAccess(name, flow.repo)),
266
275
  projects: [flow.repo, ...setup.repos.filter(one => one !== flow.repo)].map(path => ({ path, name: projectName(path) })),
267
276
  kinds: FLOW_STAGE_KINDS.map(kind => ({ kind, label: FLOW_KIND_WORDS[kind].label, about: FLOW_KIND_WORDS[kind].about })),
@@ -271,7 +280,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
271
280
  /** The page the canvas replaces when scripts can't run: each zone and its cards, in order. */
272
281
  export function flowFallbackHtml(view) {
273
282
  const own = view.unattended.active;
274
- return html `<section class="flows flow-fallback"><p class="meta">${view.flow.project}</p>${own !== null && html `<p data-flow-unattended>Runs on its own until ${own.untilWords}</p>`}<ol>${view.stages.map(stage => {
283
+ return html `<section class="flows flow-fallback"><p class="meta">${view.flow.project}</p>${own !== null && html `<p data-flow-unattended>Runs on its own until ${own.untilWords}</p>`}${view.browser !== null && html `<p data-flow-browser="${view.browser.status}">${view.browser.words}</p>`}<ol>${view.stages.map(stage => {
275
284
  const cards = view.cards.filter(card => card.stage === stage.id && card.state === "active");
276
285
  return html `<li><strong>${stage.title}</strong> · ${FLOW_KIND_WORDS[stage.kind].label}${cards.length > 0 && html `<ul>${cards.map(card => html `<li>${card.title}${card.waiting !== null && ` — ${card.waiting}`}</li>`)}</ul>`}</li>`;
277
286
  })}</ol></section>`;
package/dist/flows.js CHANGED
@@ -224,7 +224,9 @@ function canonicalStage(input, index) {
224
224
  close: kind === "update" ? stage["close"] !== false : null,
225
225
  script: kind === "check" ? said(stage["script"]) : null,
226
226
  // Left out, a script runs where every script ran before v90: in a copy of the card's work.
227
- ...(kind === "check" ? { ...(runIn === undefined ? {} : { runIn }), ...(routes === undefined ? {} : { routes }), ...(secrets === undefined ? {} : { secrets }) } : {}),
227
+ ...(kind === "check" ? { ...(runIn === undefined ? {} : { runIn }), ...(routes === undefined ? {} : { routes }), ...(secrets === undefined ? {} : { secrets }),
228
+ // v144: a script that starts a browser says so; false or none is a script that doesn't (and keeps its digest).
229
+ ...(!given(stage["browser"]) || stage["browser"] === false ? {} : { browser: stage["browser"] }) } : {}),
228
230
  sort: kind === "sort" ? canonicalSort(stage["sort"]) : null,
229
231
  ...(kind === "request" ? { request: { method, url: said(request["url"]), headers: Object.fromEntries(Object.entries(isRecord(request["headers"]) ? request["headers"] : {}).flatMap(([name, value]) => { const one = said(value); return one === null ? [] : [[name, one]]; })), body: method === "GET" || method === "DELETE" ? null : said(request["body"]) } } : {}),
230
232
  ...(kind === "email" ? { email: { to: said(email["to"]), subject: said(email["subject"]), body: said(email["body"]) } } : {}),
@@ -732,6 +734,7 @@ export function flowFromSteps(input, previous = null) {
732
734
  ...(step.runIn !== undefined ? { runIn: step.runIn } : old?.runIn === undefined ? {} : { runIn: old.runIn }),
733
735
  ...(step.routes !== undefined ? routesOf(step.routes) : old?.routes === undefined ? {} : { routes: old.routes }),
734
736
  ...(step.secrets !== undefined ? step.secrets.length === 0 ? {} : { secrets: step.secrets } : old?.secrets === undefined ? {} : { secrets: old.secrets }),
737
+ ...((step.browser ?? old?.browser) === true ? { browser: true } : {}),
735
738
  } : {}),
736
739
  sort: kind === "sort" ? sortFromStep(step, old?.sort ?? null, find) : null,
737
740
  ...(kind === "request" ? { request: { method: step.method ?? old?.request?.method ?? "POST", url: step.url ?? old?.request?.url ?? "", headers: step.headers ?? old?.request?.headers ?? {}, body: step.body ?? old?.request?.body ?? null } } : {}),
@@ -912,7 +915,7 @@ export function flowTerms(definition, previous) {
912
915
  lines.push("Opens a pull request for the card's built result, then waits for CI.", `Checks pass → ${stage.merge === undefined ? to(stage.next) : `merges it (${stage.merge}) and deletes its branch, only when a person approved it${approval === undefined ? "" : ` at ${approval.title}`} since it was built. Then → ${to(stage.next)}`}`, `Checks fail → ${stage.onFail === null ? "waits there, naming the failing check." : `${to(stage.onFail)} The failing check is named on the card.`}`);
913
916
  }
914
917
  else if (stage.kind === "check")
915
- lines.push(`Runs the project's script “${stage.script}” with no AI.`, `Passes → ${to(stage.next)}${stage.onFail === null ? " Fails → waits there." : ` Fails → ${to(stage.onFail)}`}`);
918
+ lines.push(`Runs the project's script “${stage.script}” with no AI.`, ...(stage.browser === true ? ["Starts a browser (Playwright's Chromium): it runs only once a person approves browser use for this flow."] : []), `Passes → ${to(stage.next)}${stage.onFail === null ? " Fails → waits there." : ` Fails → ${to(stage.onFail)}`}`);
916
919
  else if (stage.kind === "wait" && stage.wait !== undefined)
917
920
  lines.push(stage.wait.for === "hours" ? `Waits until it's between ${stage.wait.from} and ${stage.wait.to}${stage.wait.timeZone === undefined ? "" : ` (${stage.wait.timeZone})`}; cards that arrive then go straight on. Then → ${to(stage.next)}`
918
921
  : stage.wait.for === "time" ? `Waits ${durationWords(stage.wait.minutes)}. Then → ${to(stage.next)}`
@@ -8,6 +8,7 @@ import { dirname, join } from 'node:path';
8
8
  import { projectOutcomes, projectOutcomesPage } from './project-outcomes.js';
9
9
  import { repositoryContextRead } from './repository-context.js';
10
10
  import { assignmentCatchUp } from './assignment-brief.js';
11
+ import { browserViewOf } from './flow-browser.js';
11
12
  import { CHAT_ACTIONS, CHAT_ACTION_FIELDS, isChatAction, prepareSharedAction, sharedActionNeedsReview, sharedActionPayload } from './chat-actions.js';
12
13
  import { conversationSkills, skillsView } from "./project-skills.js";
13
14
  import { readAcceptanceEvidence } from "./chat-acceptance.js";
@@ -39,7 +40,8 @@ import { CHAT_TASK_ACTIONS, chatTaskRun, chatTaskStamp, isChatTaskAction } from
39
40
  * projects after scrubbing; full paths and arbitrary text remain redacted.
40
41
  */
41
42
  import { Buffer } from "node:buffer";
42
- import { LEAD_ASK_OTHER } from "./store.js";
43
+ import { LEAD_ASK_OTHER, refArchived } from "./store.js";
44
+ import { releaseFactsOf, tidyDigest, tidyGroups, tidyPreview, TIDY_DEFAULT_DAYS } from "./tidy.js";
43
45
  import { hasDisguisedText, hasForbiddenControls } from "./decision.js";
44
46
  import { readVerifiedArtifact, scanForSecrets } from "./evidence.js";
45
47
  import { parseAcceptanceCriteria } from "./scope.js";
@@ -913,13 +915,19 @@ const LEAD_TOOL_HANDLERS = {
913
915
  const active = cards.filter(one => one.state === "active"), finished = cards.filter(one => one.state !== "active").slice(-10);
914
916
  return { ok: true, body: {
915
917
  flow: flow.id, name: flow.name, project: repoIdOf(ctx.who, flow.repo),
918
+ // Browser use (flow-browser.ts), only for a flow whose script zones start one. Names never reach the model.
919
+ ...(() => {
920
+ const view = browserViewOf(ctx.store, flow);
921
+ return view === null ? {} : { browser: view.status === "allowed" ? "allowed: its browser zones may start Chromium (turn_off_browser stops it)"
922
+ : `${view.status === "ended" ? "approval ended: the flow or a script changed" : "not allowed"}; its browser zones wait. Propose allow_browser: a person allows it with their own password in the secure screen.` };
923
+ })(),
916
924
  steps: definition.stages.map(stage => ({
917
925
  id: stage.id, title: stage.title, does: FLOW_KIND_WORDS[stage.kind].label, kind: stage.kind,
918
926
  ...(stage.instructions === null ? {} : { instructions: stage.instructions.slice(0, 600) }),
919
927
  ...(stage.kind === "task" ? { planning: stage.planning } : {}),
920
928
  ...(stage.kind === "approval" ? { decider: stage.toOwner === true ? `the flow's owner (${flow.owner === ctx.who.name ? "you" : "someone else"})` : stage.approver === null ? "anyone who approves" : stage.approver === ctx.who.name ? "you" : "someone else" } : {}),
921
929
  ...(stage.message === null ? {} : { message: stage.message }),
922
- ...(stage.kind === "check" ? { script: stage.script, scriptExists: stage.script !== null && ctx.store.flowScript(flow.repo, stage.script) !== null } : {}),
930
+ ...(stage.kind === "check" ? { script: stage.script, scriptExists: stage.script !== null && ctx.store.flowScript(flow.repo, stage.script) !== null, ...(stage.browser === true ? { startsBrowser: true } : {}) } : {}),
923
931
  ...(stage.kind === "update" ? { closesIssue: stage.close === true } : {}),
924
932
  ...(stage.request === undefined ? {} : { method: stage.request.method, url: stage.request.url, headers: Object.keys(stage.request.headers), ...(stage.request.body === null ? {} : { body: stage.request.body.slice(0, 600) }) }),
925
933
  ...(stage.email === undefined ? {} : { to: stage.email.to, subject: stage.email.subject, body: stage.email.body.slice(0, 600) }),
@@ -985,7 +993,7 @@ const LEAD_TOOL_HANDLERS = {
985
993
  },
986
994
  },
987
995
  propose_flow: {
988
- description: "Draft a flow change as a card the operator confirms. To make a 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, 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; comment (with @name to ping someone), assign and follow here too. For triage, spam, lead, effort or exception routing, start from the matching template, with the sort as the first step. Use remindAfter and thenMoveTo for follow-ups and decisions that stall. 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; each is one yes, and none merges without a person. When someone new wants a support desk, bug triage, sales follow-up or requests handled, a kit sets up the subagent and its flow in one card. Subagents: propose_subagent adds one (from a template: support, sales, ops, triage), changes one rule section (edit_section), pauses, resumes or removes it, passes it a note, or answers its question for the operator; put it to work with a flow here. 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. Subagents use the project's tools under a rule per action (do it, ask first: the operator approves each exact call, never, or up to a limit on a number); propose_subagent 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. forget or edit_memory when the operator says a subagent has something wrong; a rule change it suggests is a question for the operator, answered only as they say. propose_subagent add_routine puts a card on its desk on a schedule; propose_subagent undo undoes a tool call only when its action has an undo set (tool_rule undoWith). Actions: 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 subagent, 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). subagents (get_subagents): an approval step with subagent (its short name) is decided by that subagent within its rules, and it hands hard ones to the step's decider; a 'subagent' step (subagent, 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. A 'send' step ('Send to me') sends the card's owner what the step before produced (summary, links, screenshots) in their chat apps, then moves on; a 'choose' step sends the same with 2 to 4 options (label, goesTo: a step, or 'end' to ignore the card) as buttons, ifReplied (where a reply goes as {{note}}; the build before by default), and remindAfter with ifNoReply. choose (card, and choice by number or a note to reply) answers one for the operator. A 'task' step may build in another project: repo (from list_repos). Read get_flows first except to create.",
996
+ description: "Draft a flow change as a card the operator confirms. To make a 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, 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; comment (with @name to ping someone), assign and follow here too. For triage, spam, lead, effort or exception routing, start from the matching template, with the sort as the first step. Use remindAfter and thenMoveTo for follow-ups and decisions that stall. 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; each is one yes, and none merges without a person. When someone new wants a support desk, bug triage, sales follow-up or requests handled, a kit sets up the subagent and its flow in one card. Subagents: propose_subagent adds one (from a template: support, sales, ops, triage), changes one rule section (edit_section), pauses, resumes or removes it, passes it a note, or answers its question for the operator; put it to work with a flow here. 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. Subagents use the project's tools under a rule per action (do it, ask first: the operator approves each exact call, never, or up to a limit on a number); propose_subagent 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. forget or edit_memory when the operator says a subagent has something wrong; a rule change it suggests is a question for the operator, answered only as they say. propose_subagent add_routine puts a card on its desk on a schedule; propose_subagent undo undoes a tool call only when its action has an undo set (tool_rule undoWith). Actions: 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 subagent, 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). subagents (get_subagents): an approval step with subagent (its short name) is decided by that subagent within its rules, and it hands hard ones to the step's decider; a 'subagent' step (subagent, 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. A 'send' step ('Send to me') sends the card's owner what the step before produced (summary, links, screenshots) in their chat apps, then moves on; a 'choose' step sends the same with 2 to 4 options (label, goesTo: a step, or 'end' to ignore the card) as buttons, ifReplied (where a reply goes as {{note}}; the build before by default), and remindAfter with ifNoReply. choose (card, and choice by number or a note to reply) answers one for the operator. A 'task' step may build in another project: repo (from list_repos). Read get_flows first except to create. allow_browser (flow): when get_flows says a flow's browser zones wait, propose allowing them to start Chromium; the operator allows it with their own password in the secure screen, never on the card. turn_off_browser (flow) stops it.",
989
997
  // An edit's kept steps may leave their kind and name to the zone they keep: they are filled in before the call is read.
990
998
  prepare: (ctx, given) => {
991
999
  const editing = given["operation"] === "edit" && Number.isSafeInteger(given["flow"]) ? ctx.store.getFlow(Number(given["flow"])) : null;
@@ -1125,7 +1133,16 @@ const LEAD_TOOL_HANDLERS = {
1125
1133
  operation = "flow_trigger_remove";
1126
1134
  input = pick(["trigger"]);
1127
1135
  break;
1128
- default: return { ok: false, message: "Choose create, edit, add_card, move_card, approve, send_back, cancel_card, comment, assign, follow, unfollow, add_trigger, pause_trigger, resume_trigger or remove_trigger." };
1136
+ // Browser use in the flow's script zones: a proposal only; the person allows it with their own password.
1137
+ case "allow_browser":
1138
+ operation = "flow_browser";
1139
+ input = pick(["flow"]);
1140
+ break;
1141
+ case "turn_off_browser":
1142
+ operation = "flow_browser_off";
1143
+ input = pick(["flow"]);
1144
+ break;
1145
+ default: return { ok: false, message: "Choose create, edit, add_card, move_card, approve, send_back, cancel_card, comment, assign, follow, unfollow, add_trigger, pause_trigger, resume_trigger, remove_trigger, allow_browser or turn_off_browser." };
1129
1146
  }
1130
1147
  const action = prepareSharedAction(ctx.store, ctx.who, operation, input, ctx.evidenceRoot, ctx.now);
1131
1148
  const id = ctx.draft("action", { ...action });
@@ -1608,11 +1625,12 @@ const LEAD_TOOL_HANDLERS = {
1608
1625
  return { ok: false, message: "search needs a word that names the work, such as a page, feature or file" };
1609
1626
  // A search reads further back than the newest page, then ranks by how many of the words each task's title and goal hold.
1610
1627
  const listed = ctx.store.taskFamiliesAdmitted(repo === null ? ctx.who.repos : [repo], false, { limit: words.length === 0 ? limit + 1 : SEARCH_FAMILIES, order: words.length === 0 ? "created" : "updated", ...(args["state"] === undefined ? {} : { states: [args["state"]] }) });
1611
- const families = words.length === 0 ? listed : listed
1628
+ // A put-away task (v145) is out of every list, the lead's included.
1629
+ const families = (words.length === 0 ? listed : listed
1612
1630
  .map((one, index) => ({ one, index, hits: searchHits(words, [one.root.id, one.root.title, one.current.title, ctx.store.getScope(one.current.id)?.goal ?? "", ctx.store.getScope(one.root.id)?.goal ?? ""]) }))
1613
1631
  .filter(match => match.hits > 0)
1614
1632
  .sort((a, b) => b.hits - a.hits || a.index - b.index)
1615
- .map(match => match.one);
1633
+ .map(match => match.one)).filter(one => !refArchived(ctx.store, one.current.refId));
1616
1634
  const tasks = families.slice(0, limit).map(one => ({
1617
1635
  repo: `r${ctx.who.repos.indexOf(one.current.repo) + 1}`, task: one.root.id, execution: one.current.id,
1618
1636
  title: one.root.title, state: one.current.state, historyProblem: one.problem, otherActive: one.otherActive.map(version => version.id),
@@ -1622,6 +1640,17 @@ const LEAD_TOOL_HANDLERS = {
1622
1640
  return { ok: true, body: { tasks, truncated: families.length > limit } };
1623
1641
  },
1624
1642
  },
1643
+ tidy_preview: {
1644
+ description: "Read-only: what Put away old tasks would put away now and why, grouped (old releases, old routine runs, resolved CI notices, finished more than `days` days ago, default 14). Only a person puts tasks away; you can't.",
1645
+ handle: (ctx, args) => {
1646
+ const days = typeof args["days"] === "number" ? args["days"] : TIDY_DEFAULT_DAYS;
1647
+ const items = tidyPreview(ctx.store, ctx.now, { releases: releaseFactsOf(ctx.store), days, access: { principal: "operator", repos: ctx.who.repos, includeUnplaced: false } });
1648
+ const digest = tidyDigest(items, days);
1649
+ return { ok: true, body: { count: items.length, days, digest,
1650
+ groups: tidyGroups(items, days).map(group => ({ label: group.label, tasks: group.items.map(one => ({ task: one.taskId, title: one.title, why: one.reason })) })),
1651
+ apply: items.length === 0 ? "Nothing to put away." : `A person puts these away: Tasks → Put away old tasks, or \`toolroll task tidy --apply --digest ${digest}${days === TIDY_DEFAULT_DAYS ? "" : ` --days ${days}`}\`.` } };
1652
+ },
1653
+ },
1625
1654
  get_task: {
1626
1655
  description: "Read currentExecution, state, dispatch, history, scope, dependencies, holds, queue, attempts, decisions and whether the agent has read your notes before acting.",
1627
1656
  handle: (ctx, args) => {