toolroll 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/agent-fence.d.ts +11 -2
  3. package/dist/agent-fence.js +29 -3
  4. package/dist/agent-onboard.d.ts +21 -1
  5. package/dist/agent-onboard.js +50 -1
  6. package/dist/backup-ui.js +4 -2
  7. package/dist/browser/workspace.css +2 -2
  8. package/dist/browser/workspace.js +31 -31
  9. package/dist/browser-workspace.d.ts +12 -2
  10. package/dist/chat-actions.d.ts +5 -0
  11. package/dist/chat-actions.js +43 -3
  12. package/dist/cli.d.ts +1 -1
  13. package/dist/cli.js +4 -1
  14. package/dist/flow-engine.js +13 -1
  15. package/dist/flow-insights.d.ts +9 -0
  16. package/dist/flow-insights.js +16 -0
  17. package/dist/flow-pull-request.d.ts +11 -0
  18. package/dist/flow-pull-request.js +123 -0
  19. package/dist/flow-starters-ui.d.ts +20 -0
  20. package/dist/flow-starters-ui.js +35 -0
  21. package/dist/flow-starters.d.ts +60 -0
  22. package/dist/flow-starters.js +147 -0
  23. package/dist/flow-steps.js +14 -0
  24. package/dist/flow-triggers.d.ts +20 -1
  25. package/dist/flow-triggers.js +105 -6
  26. package/dist/flows-cli.d.ts +228 -0
  27. package/dist/flows-cli.js +403 -0
  28. package/dist/flows-ui.d.ts +1 -1
  29. package/dist/flows-ui.js +5 -4
  30. package/dist/flows.d.ts +32 -6
  31. package/dist/flows.js +114 -6
  32. package/dist/guides.js +23 -0
  33. package/dist/integrations-ui.js +9 -4
  34. package/dist/keys.js +4 -1
  35. package/dist/lead-context.d.ts +1 -1
  36. package/dist/lead-context.js +1 -1
  37. package/dist/mate-contract.js +1 -1
  38. package/dist/mate-tools.js +21 -7
  39. package/dist/models-ui.js +3 -2
  40. package/dist/monitoring-ui.js +2 -1
  41. package/dist/operate.d.ts +1 -1
  42. package/dist/operate.js +57 -4
  43. package/dist/plane-review.d.ts +39 -0
  44. package/dist/plane-review.js +233 -0
  45. package/dist/policy-ui.js +2 -1
  46. package/dist/pull-request-flow.d.ts +32 -0
  47. package/dist/pull-request-flow.js +43 -8
  48. package/dist/pull-request-ui.js +2 -2
  49. package/dist/serve.js +108 -43
  50. package/dist/skills-ui.js +2 -1
  51. package/dist/store.js +2 -0
  52. package/dist/surface.js +5 -1
  53. package/dist/when-html.d.ts +8 -0
  54. package/dist/when-html.js +36 -0
  55. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,52 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.9.2 — 2026-10-01
6
+
7
+ - **Flows from a terminal.** `toolroll flows` lists and shows flows (zones
8
+ with their next and failure paths, triggers, recent cards) and sets them
9
+ up under the console's own rules: `create` from a template, a starter flow
10
+ or the lead's step format, `edit`, `trigger add|pause|resume|remove|check`,
11
+ `script save`, `card add` and `archive`. Writes take an approver's
12
+ password (`--as/--token` or the remembered login) and land in the ledger;
13
+ create, edit, archive and trigger add preview until `--yes`. It is in
14
+ `contract --commands` and `skills get operating`.
15
+ - **Toolroll reviews its own last day, every morning.** A new plane review
16
+ trigger reads the last 24 hours from the store at 07:30 and makes one card
17
+ per problem worth fixing — failed runs by cause, tasks waiting on a person,
18
+ sign-in and plan-limit pauses, chat delivery failures, Broken integrations,
19
+ failed release checks and worker breakages — with counts, run ids and
20
+ redacted excerpts. A problem that comes back joins its card; a clean day
21
+ adds nothing. The **Morning plane review** starter (Settings → Flows, or
22
+ `toolroll onboard --starter plane-review`) researches each one, builds a fix
23
+ under the usual approvals and opens a pull request; failures wait in
24
+ **Needs a look**. Flow insights show which problems recur.
25
+ - **Flows that test Toolroll.** `scripts/flows/real-model-journeys.mjs` runs
26
+ the journeys that use real models (unit tests and CI never do), retrying a
27
+ failure once before calling it real, and `scripts/flows/weekly-upkeep.mjs`
28
+ reports outdated or vulnerable dependencies and new agent CLI versions.
29
+ Both run in a flow's check zone. A Toolroll started inside the agents'
30
+ fence now runs its agents inside that outer fence instead of failing to
31
+ nest a second one.
32
+
33
+ ## 0.9.1 — 2026-09-30
34
+
35
+ - **Phones show more on a screen.** On a phone the console is about a third
36
+ denser: a row's action sits on its title line, status chips beside the
37
+ name, facts on one line, and times shorten to "16:39", "Yesterday 16:39"
38
+ or "Sep 28" (the full time stays on desktop and on hover). Every tap target
39
+ is still at least 44px and text fields 16px; nothing scrolls sideways at
40
+ 320px, and the desktop layout is unchanged.
41
+ - **Flows open pull requests, and starter flows are one yes away.** A new
42
+ Pull request zone opens a PR for a card's built result under the project's
43
+ pull request setup, waits for CI, moves on when it passes and takes the
44
+ failure path (naming the failing check) when it fails; it can merge, squash
45
+ by default, only after a person approved the card. The Issues to PRs
46
+ template runs labelled GitHub issues through build, approval, pull request
47
+ and a closing comment. Settings → Flows and `toolroll onboard --starter`
48
+ switch on Fix failing CI, Issues become tasks and Overnight queue; a task's
49
+ "Do this every time…" and chat offer the matching one.
50
+
5
51
  ## 0.9.0 — 2026-09-30
6
52
 
7
53
  - **Chat pings only when you're needed.** In Telegram, Slack, Discord and
@@ -27,8 +27,17 @@ export declare function claudeFenceSettings(fence: readonly string[]): string;
27
27
  /** A Seatbelt profile that allows everything except reading or writing the fenced paths. */
28
28
  export declare function macosFenceProfile(fence: readonly string[]): string;
29
29
  export declare const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
30
- /** Whether this machine can run an agent inside the macOS fence. */
31
- export declare function macosFenceAvailable(platform?: NodeJS.Platform): boolean;
30
+ /**
31
+ * Whether this machine can run an agent inside the macOS fence (checked once). Not from inside another
32
+ * sandbox: a Toolroll a fenced process starts (the end-to-end journeys a flow's check zone runs) can't nest
33
+ * its fence, and would otherwise fail every launch. Its agents stay inside the outer fence, which every
34
+ * child inherits, and are recorded with the fence they actually got. Any other probe failure keeps the
35
+ * fence on, so a launch fails loudly rather than running unfenced.
36
+ */
37
+ export declare function macosFenceAvailable(platform?: NodeJS.Platform, probe?: () => {
38
+ status: number | null;
39
+ stderr: string;
40
+ }): boolean;
32
41
  /** The spawn that runs `file args` inside the macOS fence (same process: sandbox-exec replaces itself with the agent). */
33
42
  export declare function macosFenced(file: string, args: readonly string[], fence: readonly string[]): {
34
43
  file: string;
@@ -144,9 +144,35 @@ export function macosFenceProfile(fence) {
144
144
  return ["(version 1)", "(allow default)", `(deny file-read* file-write* ${fence.map(path => `(subpath ${seatbelt(path)})`).join(" ")})`].join("\n");
145
145
  }
146
146
  export const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
147
- /** Whether this machine can run an agent inside the macOS fence. */
148
- export function macosFenceAvailable(platform = process.platform) {
149
- return platform === "darwin" && existsSync(SANDBOX_EXEC);
147
+ /** A process already inside a Seatbelt sandbox (an agent's, or a flow check zone's) can't apply another: sandbox-exec says so. */
148
+ const NESTED_SANDBOX = /sandbox_apply: Operation not permitted/;
149
+ let seatbeltWorks = null;
150
+ /**
151
+ * Whether this machine can run an agent inside the macOS fence (checked once). Not from inside another
152
+ * sandbox: a Toolroll a fenced process starts (the end-to-end journeys a flow's check zone runs) can't nest
153
+ * its fence, and would otherwise fail every launch. Its agents stay inside the outer fence, which every
154
+ * child inherits, and are recorded with the fence they actually got. Any other probe failure keeps the
155
+ * fence on, so a launch fails loudly rather than running unfenced.
156
+ */
157
+ export function macosFenceAvailable(platform = process.platform, probe) {
158
+ if (platform !== "darwin" || !existsSync(SANDBOX_EXEC))
159
+ return false;
160
+ const works = (check) => {
161
+ try {
162
+ const ran = check();
163
+ return !(ran.status !== 0 && NESTED_SANDBOX.test(ran.stderr));
164
+ }
165
+ catch {
166
+ return true;
167
+ }
168
+ };
169
+ if (probe !== undefined)
170
+ return works(probe);
171
+ seatbeltWorks ??= works(() => {
172
+ const ran = spawnSync(SANDBOX_EXEC, ["-p", "(version 1)(allow default)", "/usr/bin/true"], { timeout: 5_000, encoding: "utf8" });
173
+ return { status: ran.status, stderr: ran.stderr ?? "" };
174
+ });
175
+ return seatbeltWorks;
150
176
  }
151
177
  /** The spawn that runs `file args` inside the macOS fence (same process: sandbox-exec replaces itself with the agent). */
152
178
  export function macosFenced(file, args, fence) {
@@ -8,13 +8,16 @@
8
8
  * without running it, and ends with a handoff the agent can relay.
9
9
  *
10
10
  * Adding the project and writing the skill both need --yes or one yes
11
- * typed at a terminal. The project is the main checkout (never a linked
11
+ * typed at a terminal. Then it offers the starter flows (flow-starters.ts),
12
+ * each switched on by its own yes at a terminal or by --starter; --yes alone
13
+ * never switches one on. The project is the main checkout (never a linked
12
14
  * worktree, the home folder, or one of Toolroll's own worktrees). The skill
13
15
  * is replaced by the next onboard and deleted by `onboard --remove` only
14
16
  * while it is exactly a version Toolroll wrote; a file of the same name
15
17
  * that is not ours, or ours with the person's edits, is never touched.
16
18
  */
17
19
  import type { ProviderConnection } from "./provider-connection.js";
20
+ import type { StarterView, SwitchedOn } from "./flow-starters.js";
18
21
  export type OnboardAgent = "claude" | "codex";
19
22
  export declare const ONBOARD_AGENTS: readonly OnboardAgent[];
20
23
  export declare const AGENT_NAMES: Record<OnboardAgent, string>;
@@ -122,6 +125,23 @@ export type OnboardIo = {
122
125
  /** Whether the project can open pull requests at Complete, in one line: on, the one command that turns it on, or
123
126
  * what to fix first. Turning it on stays a person's password step. */
124
127
  pullRequests?: (repo: string) => Promise<string>;
128
+ /** --starter, as typed: a comma list of starter flow ids to switch on. */
129
+ starterFlag?: string | undefined;
130
+ /** The project's starter flows, and switching one on — its trigger and zones. */
131
+ starters?: {
132
+ list: (repo: string) => Promise<StarterView[]>;
133
+ switchOn: (repo: string, id: string) => Promise<SwitchedOn>;
134
+ };
135
+ };
136
+ export type StarterAnswer = {
137
+ id: string;
138
+ name: string;
139
+ summary: string;
140
+ does: string[];
141
+ never: string;
142
+ state: "on" | "switched-on" | "off" | "unavailable" | "failed";
143
+ flow?: number;
144
+ said?: string;
125
145
  };
126
146
  /** Why a found repository cannot be the project, or null when it can. */
127
147
  export declare function projectRefusal(found: {
@@ -8,7 +8,9 @@
8
8
  * without running it, and ends with a handoff the agent can relay.
9
9
  *
10
10
  * Adding the project and writing the skill both need --yes or one yes
11
- * typed at a terminal. The project is the main checkout (never a linked
11
+ * typed at a terminal. Then it offers the starter flows (flow-starters.ts),
12
+ * each switched on by its own yes at a terminal or by --starter; --yes alone
13
+ * never switches one on. The project is the main checkout (never a linked
12
14
  * worktree, the home folder, or one of Toolroll's own worktrees). The skill
13
15
  * is replaced by the next onboard and deleted by `onboard --remove` only
14
16
  * while it is exactly a version Toolroll wrote; a file of the same name
@@ -215,6 +217,48 @@ export function loginAccount(file) {
215
217
  export function agentReport(agent, connection) {
216
218
  return { agent, name: AGENT_NAMES[agent], state: connection.state, words: connectionWords(connection), ...(connection.plan === undefined ? {} : { plan: connection.plan }) };
217
219
  }
220
+ /** Offer each starter flow: switch on the ones --starter names, or ask one yes each at a terminal. */
221
+ async function offerStarters(io, repo) {
222
+ if (io.starters === undefined)
223
+ return [];
224
+ const list = await io.starters.list(repo);
225
+ const named = io.starterFlag === undefined ? null : io.starterFlag.split(",").map(one => one.trim()).filter(one => one !== "");
226
+ const unknown = named?.filter(one => !list.some(starter => starter.id === one)) ?? [];
227
+ if (named !== null && (named.length === 0 || unknown.length > 0))
228
+ return { usage: `--starter takes ${list.map(one => one.id).join(", ")}` };
229
+ const answers = [];
230
+ for (const one of list) {
231
+ const base = { id: one.id, name: one.name, summary: one.summary, does: one.does, never: one.never };
232
+ if (one.on !== null) {
233
+ answers.push({ ...base, state: "on", flow: one.on.flow });
234
+ continue;
235
+ }
236
+ if (one.blocked !== null) {
237
+ answers.push({ ...base, state: "unavailable", said: one.blocked });
238
+ continue;
239
+ }
240
+ const wanted = named !== null ? named.includes(one.id) : io.interactive && !io.json && await io.confirm(`Switch on ${one.name}? ${one.summary} ${one.never} [y/N]`);
241
+ if (!wanted) {
242
+ answers.push({ ...base, state: "off" });
243
+ continue;
244
+ }
245
+ const switched = await io.starters.switchOn(repo, one.id);
246
+ answers.push(switched.ok ? { ...base, state: switched.already ? "on" : "switched-on", flow: switched.flow } : { ...base, state: "failed", said: switched.said });
247
+ }
248
+ return answers;
249
+ }
250
+ function starterLines(answers) {
251
+ return answers.map((one, index) => {
252
+ const head = index === 0 ? "starters " : " ";
253
+ switch (one.state) {
254
+ case "on": return `${head} ${one.name}: on`;
255
+ case "switched-on": return `${head} ${one.name}: switched on`;
256
+ case "unavailable": return `${head} ${one.name}: ${one.said}`;
257
+ case "failed": return `${head} ${one.name}: ${one.said}`;
258
+ case "off": return `${head} ${one.name}: ${one.summary} ${one.never} Switch on: toolroll onboard --starter ${one.id}`;
259
+ }
260
+ });
261
+ }
218
262
  const real = (path) => {
219
263
  try {
220
264
  return realpathSync(path);
@@ -334,6 +378,9 @@ export async function runOnboard(io) {
334
378
  const mcp = agents.map(agent => ({ agent, command: mcpLine(agent) }));
335
379
  const projectData = project.state === "added" || project.state === "already" ? { path: project.path, added: project.state === "added" } : null;
336
380
  const pullRequests = projectData === null || io.pullRequests === undefined ? null : await io.pullRequests(projectData.path);
381
+ const offered = projectData === null ? [] : await offerStarters(io, projectData.path);
382
+ if (!Array.isArray(offered))
383
+ return refuse("usage", offered.usage, 2);
337
384
  const projectProblem = (() => {
338
385
  switch (project.state) {
339
386
  case "none": return "not inside a git repository";
@@ -351,6 +398,7 @@ export async function runOnboard(io) {
351
398
  project: projectData,
352
399
  ...(projectProblem === null ? {} : { projectProblem }),
353
400
  ...(pullRequests === null ? {} : { pullRequests }),
401
+ ...(offered.length === 0 ? {} : { starters: offered.map(one => ({ ...one, ...(one.state === "off" ? { command: `toolroll onboard --starter ${one.id}` } : {}) })) }),
354
402
  agents: reports,
355
403
  skill: skillData,
356
404
  mcp,
@@ -364,6 +412,7 @@ export async function runOnboard(io) {
364
412
  io.write([
365
413
  projectLine,
366
414
  ...(pullRequests === null ? [] : [`pull requests ${pullRequests}`]),
415
+ ...starterLines(offered),
367
416
  `agents ${reports.map(one => `${one.name}: ${[one.words, one.plan].filter(Boolean).join(" · ")}`).join("; ")}`,
368
417
  skillLine(),
369
418
  ...mcp.map((one, index) => `${index === 0 ? "tools " : " "} ${one.command} (add Toolroll as tools; not run)`),
package/dist/backup-ui.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { bytesWords } from "./storage.js";
2
+ import { whenUtc } from "./when-html.js";
2
3
  /** The schedules on offer and the most copies kept. Here rather than in backup.ts so the page never loads `node:sqlite`. */
3
4
  export const BACKUP_EVERY_HOURS = [1, 6, 12, 24];
4
5
  export const MAX_KEEP = 100;
5
6
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
6
- const when = (at) => `${e(at.slice(0, 16).replace("T", " "))} UTC`;
7
+ const when = (at) => whenUtc(at);
8
+ const whenWords = (at) => `${at.slice(0, 16).replace("T", " ")} UTC`;
7
9
  const everyWords = (hours) => hours === 1 ? "Every hour" : hours === 24 ? "Every day" : `Every ${hours} hours`;
8
10
  const fileName = (path) => path.slice(path.lastIndexOf("/") + 1);
9
11
  export const BACKUP_CSS = `.backups{max-width:720px;min-width:0;overflow-wrap:anywhere}.backups .backup-state{display:flex;gap:16px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap}` +
@@ -22,7 +24,7 @@ function state(view) {
22
24
  const good = view.runs.find(one => one.ok === true);
23
25
  const next = !view.settings.enabled ? "Scheduled backups are off."
24
26
  : good === undefined ? "The first scheduled backup runs within a minute or two of the console starting."
25
- : `Next one about ${when(new Date(Date.parse(good.startedAt) + view.settings.everyHours * 3_600_000).toISOString())}.`;
27
+ : `Next one about ${whenWords(new Date(Date.parse(good.startedAt) + view.settings.everyHours * 3_600_000).toISOString())}.`;
26
28
  if (last === undefined)
27
29
  return `<h2>No backups yet</h2><p class="meta">${e(next)}</p>`;
28
30
  if (last.ok === null)