toolroll 0.9.33 → 0.9.36

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 (74) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +7 -0
  3. package/dist/assignment.d.ts +1 -0
  4. package/dist/assignment.js +2 -2
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +17 -17
  7. package/dist/browser-crew.js +1 -0
  8. package/dist/browser-workspace.d.ts +5 -3
  9. package/dist/builder.js +4 -7
  10. package/dist/chat-acceptance.d.ts +1 -1
  11. package/dist/cli.d.ts +1 -1
  12. package/dist/cli.js +124 -63
  13. package/dist/contracts/contract-test.d.ts +45 -0
  14. package/dist/contracts/contract-test.js +69 -0
  15. package/dist/contracts/contract.d.ts +87 -0
  16. package/dist/contracts/contract.js +308 -0
  17. package/dist/contracts/flow-propose.d.ts +325 -0
  18. package/dist/contracts/flow-propose.js +41 -0
  19. package/dist/contracts/flow.d.ts +19400 -0
  20. package/dist/contracts/flow.js +359 -0
  21. package/dist/contracts/handoff.d.ts +91 -0
  22. package/dist/contracts/handoff.js +82 -0
  23. package/dist/contracts/plan.d.ts +58 -0
  24. package/dist/contracts/plan.js +67 -0
  25. package/dist/contracts/proof.d.ts +136 -0
  26. package/dist/contracts/proof.js +110 -0
  27. package/dist/contracts/verification-receipt.d.ts +148 -0
  28. package/dist/contracts/verification-receipt.js +87 -0
  29. package/dist/demo.js +28 -11
  30. package/dist/desktop-update.d.ts +1 -1
  31. package/dist/evidence.d.ts +3 -40
  32. package/dist/evidence.js +7 -3
  33. package/dist/flow-gallery.d.ts +3 -2
  34. package/dist/flow-share.d.ts +10 -32
  35. package/dist/flow-share.js +65 -83
  36. package/dist/flow-starters.d.ts +2 -1
  37. package/dist/flow-triggers.d.ts +15 -72
  38. package/dist/flow-triggers.js +94 -83
  39. package/dist/flows.d.ts +37 -218
  40. package/dist/flows.js +350 -385
  41. package/dist/mate-tools.js +14 -44
  42. package/dist/mcp-connect.d.ts +3 -0
  43. package/dist/mcp-connect.js +16 -4
  44. package/dist/operate.js +83 -12
  45. package/dist/plan.d.ts +12 -12
  46. package/dist/plan.js +109 -72
  47. package/dist/planner-source.js +2 -1
  48. package/dist/planner.js +55 -25
  49. package/dist/process-recovery-eligibility.d.ts +2 -2
  50. package/dist/process-recovery-eligibility.js +13 -8
  51. package/dist/project-admission.d.ts +93 -0
  52. package/dist/project-admission.js +134 -0
  53. package/dist/project-delete-ui.js +5 -2
  54. package/dist/proof.d.ts +15 -91
  55. package/dist/proof.js +90 -249
  56. package/dist/result-evidence-readers.d.ts +42 -0
  57. package/dist/result-evidence-readers.js +91 -0
  58. package/dist/runner.d.ts +10 -0
  59. package/dist/runner.js +16 -0
  60. package/dist/scope.js +4 -3
  61. package/dist/serve.d.ts +4 -0
  62. package/dist/serve.js +112 -160
  63. package/dist/setup-guide.d.ts +2 -2
  64. package/dist/store.d.ts +2 -0
  65. package/dist/store.js +4 -0
  66. package/dist/surface.js +16 -3
  67. package/dist/task-review-brief.d.ts +75 -0
  68. package/dist/task-review-brief.js +179 -0
  69. package/dist/text-limits.d.ts +87 -0
  70. package/dist/text-limits.js +86 -0
  71. package/dist/verification-evidence.js +24 -12
  72. package/dist/when-html.d.ts +2 -0
  73. package/dist/when-html.js +13 -0
  74. package/package.json +3 -2
@@ -45,7 +45,10 @@ import { agentChoicesFor, routeOfTask, INSTALLATION_SCOPE } from "./agentconfig.
45
45
  import { isNewModel, modelWords, priceWords, runtimeStates, seenModels } from "./model-catalog.js";
46
46
  import { agentsSummary, chosenWords, isRiskLevel, isTaskSize, PHASES, postureWords, RISK_CHOICES, riskConsequence, riskTitle, routeProblems, sameSpec, sizeSourceWords, sizeWords, specWords } from "./phase-routing.js";
47
47
  import { TOOL_CATALOG, discoverTools, projectToolsOf, secretsSetFor, toolCommandLine, toolStanding } from "./project-tools.js";
48
- import { deciderOf, durationWords, FLOW_KIND_WORDS, FLOW_STAGE_KINDS, FLOW_TEMPLATES, flowFromSteps, stepsFor } from "./flows.js";
48
+ import { deciderOf, durationWords, FLOW_KIND_WORDS, FLOW_TEMPLATES, flowFromSteps, stepsFor, withKeptSteps } from "./flows.js";
49
+ import { parseContract } from "./contracts/contract.js";
50
+ import { FLOW_ALIASES } from "./contracts/flow.js";
51
+ import { PROPOSE_FLOW_MODEL_SCHEMA, proposeFlowInputSchema } from "./contracts/flow-propose.js";
49
52
  import { flowDefinitionOf } from "./flow-engine.js";
50
53
  import { flowPersonOf } from "./flow-send.js";
51
54
  import { flowInsights } from "./flow-insights.js";
@@ -890,49 +893,16 @@ export const MATE_TOOLS = [
890
893
  {
891
894
  name: "propose_flow",
892
895
  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 teammate and its flow in one card. Teammates: propose_teammate 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. Teammates 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_teammate use_tool, stop_tool and tool_rule change them, and when a rule has a limit also change the soul file so its words agree. forget or edit_memory when the operator says a teammate has something wrong; a rule change it suggests is a question for the operator, answered only as they say. propose_teammate add_routine puts a card on its desk on a schedule; propose_teammate 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 teammate, the flow it works and its buttons, in one card; prefer it when the operator wants a support desk, bug triage, sales follow-up or requests handled. create: a template, or the steps in order (each leads to the next; Done is added; instructions may be left out). 'request' calls a web address (method, url with its host written out, headers — {{secret.NAME}} uses a secret the operator saved on the step, never in chat — and body); 'email' sends mail (to, subject, body; {{card.email}} is the card's email address); 'tool' calls one of the project's tools (server: the tool's name from get_project_tools, tool: its function, args: an object). Put a decision before any of these when they send what a model wrote or what an outsider sent. A 'draft' step has Claude write something from the card (instructions: what to write); follow it with an approval step (decider 'owner' asks the flow's owner in their chat app, where they can approve, edit or send it back), then an 'update' or 'notify' step whose message is '{{stage.<draft id>}}'. A 'sort' step has Jev pick one of its answers; make it the first step (never a holding step before it, or new cards wait unsorted): question, answers (answer, means: a few words Jev reads, goesTo: a step), sureAt (percent, default 80), ifNotSure (a step; otherwise the card waits for a person), and up to 3 alsoNote (score with levels lowest first, or yes-no); a sort has no next, so give each branch's last step its own next. A 'pull-request' step opens a pull request for the card's built result and waits for CI: next when it passes, ifFails when it fails (only this step defaults it: to the build before it, as a revision carrying the failing check); merge ('squash', 'merge' or 'rebase') merges once checks pass, and needs an approval step before it on every path. waitFor 'hours' with from and until (like '22:00' and '06:00') holds cards until the clock is inside those hours. A 'wait' step, after an 'email' step, waits for a reply to that email from someone it went to (waitFor 'reply', the default): next is where a reply goes (the reply is {{stage.<wait id>}}), ifNoReply where the card goes when none comes within wait (like '3 days', up to 30 days); waitFor 'time' just waits, then next. Any step but wait and done can have remindAfter (like '2 days': whoever it waits on is reminded once; 'none' removes it) and, on holding and approval steps, thenMoveTo (a step the card moves to then). AI teammates (get_teammates): an approval step with teammate (its short name) is decided by that teammate within its rules, and it hands hard ones to the step's decider; a 'teammate' step (teammate, instructions, routes of answer and goesTo) has it read the card, pick where it goes and write what the next steps send (the email body is then {{stage.<id>}}), asking the flow's owner when its rules say to. edit: the full step list, keeping existing steps by id — what a kept step leaves out carries over. add_card (starts in the first zone unless zone is named), move_card, approve, send_back (needs a note), cancel_card, comment (note; @name pings that person), assign (owner: a name, 'me', or 'nobody'), follow, unfollow, save_script (repo, and script: name, about, language python|node|shell, and either body (short) or file (a path in the project, like scripts/enrich.py), and timeoutMinutes; scripts belong to the project, so no flow is needed; a 'check' step in any of its flows names it). A 'check' step runs its script with the card as JSON on stdin (and in $FLOW_INPUT); what it prints is its result for later steps ({{stage.<id>}}); runIn 'folder' (an empty folder: for scripts that work on data) or 'copy' (a copy of the card's work, after setup: for tests on code; the default); routes (answer, goesTo) that a last printed line 'goto: <answer>' picks; ifFails is where a failing script sends the card, such as back to the build (it has no default: without it the card waits there); secrets (names of saved secrets it gets as variables). add_trigger with settings (kind button: label, questions; schedule: schedule like 'daily 09:00 Europe/London', and title, or script (a saved script whose printed items — one per line, a title or JSON with title, description, key — each become a card, once) with secrets; github: repo owner/name, watch issues|pulls|checks, label, branch, from team|anyone; linear: team, state, label; flow: follow (another flow's id), when (its zone); email: folder (default INBOX), sender (addresses or domains), subject (words it must contain); plane-review: at (HH:MM, default 07:30), timeZone — every day it reads the plane's last 24 hours and makes one card per problem worth fixing, a returning problem joining its card); pause_trigger, resume_trigger, remove_trigger with trigger. 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.",
893
- inputSchema: schema({
894
- operation: { type: "string", enum: ["create", "edit", "add_card", "move_card", "approve", "send_back", "choose", "cancel_card", "comment", "assign", "follow", "unfollow", "save_script", "add_trigger", "pause_trigger", "resume_trigger", "remove_trigger", "kit", "starter"] },
895
- repo: REPO_ARG, flow: { type: "integer", minimum: 1 }, card: { type: "integer", minimum: 1 }, kit: { type: "string", enum: KITS.map(one => one.id) },
896
- starter: { type: "string", enum: [...STARTER_IDS] },
897
- name: { type: "string", maxLength: 80 }, template: { type: "string", enum: FLOW_TEMPLATES.map(one => one.id) },
898
- steps: { type: "array", minItems: 1, maxItems: 24, items: { type: "object", additionalProperties: false, properties: {
899
- id: { type: "string", maxLength: 32 }, title: { type: "string", maxLength: 60 }, kind: { type: "string", enum: [...FLOW_STAGE_KINDS] },
900
- instructions: { type: "string", maxLength: TEXT_LIMITS.flowInstructions }, planning: { type: "string", enum: ["auto", "required", "skip"] },
901
- decider: { type: "string", maxLength: 64 }, message: { type: "string", maxLength: 1000 },
902
- script: { type: "string", maxLength: 40 }, close: { type: "boolean" },
903
- question: { type: "string", maxLength: 300 }, sureAt: { type: "integer", minimum: 50, maximum: 99 },
904
- answers: { type: "array", minItems: 2, maxItems: 12, items: { type: "object", additionalProperties: false, properties: { answer: { type: "string", maxLength: 40 }, means: { type: "string", maxLength: 200 }, goesTo: { type: "string", maxLength: 60 } } } },
905
- alsoNote: { type: "array", maxItems: 3, items: { type: "object", additionalProperties: false, properties: { question: { type: "string", maxLength: 300 }, kind: { type: "string", enum: ["score", "yes-no"] }, levels: { type: "array", minItems: 2, maxItems: 10, items: { type: "string", maxLength: 120 } } } } },
906
- method: { type: "string", enum: ["GET", "POST", "PUT", "PATCH", "DELETE"] }, url: { type: "string", maxLength: 2000 },
907
- headers: { type: "object", additionalProperties: { type: "string", maxLength: 500 } }, body: { type: "string", maxLength: 8000 },
908
- to: { type: "string", maxLength: 500 }, subject: { type: "string", maxLength: 200 },
909
- server: { type: "string", maxLength: 64 }, tool: { type: "string", maxLength: 100 }, args: { type: "object" },
910
- runIn: { type: "string", enum: ["folder", "copy"] }, routes: { type: "array", maxItems: 12, items: { type: "object", additionalProperties: false, properties: { answer: { type: "string", maxLength: 40 }, goesTo: { type: "string", maxLength: 60 } } } },
911
- secrets: { type: "array", maxItems: 10, items: { type: "string", maxLength: 40 } },
912
- waitFor: { type: "string", enum: ["reply", "time", "hours"] }, wait: { type: "string", maxLength: 40 }, ifNoReply: { type: "string", maxLength: 60 },
913
- from: { type: "string", maxLength: 5 }, until: { type: "string", maxLength: 5 }, merge: { type: "string", enum: ["squash", "merge", "rebase"] },
914
- remindAfter: { type: "string", maxLength: 40 }, thenMoveTo: { type: "string", maxLength: 60 }, teammate: { type: "string", maxLength: 40 },
915
- options: { type: "array", minItems: 2, maxItems: 4, items: { type: "object", additionalProperties: false, properties: { label: { type: "string", maxLength: 40 }, goesTo: { type: "string", maxLength: 60 } } } },
916
- ifReplied: { type: "string", maxLength: 60 }, repo: REPO_ARG,
917
- next: { type: "string", maxLength: 60 }, ifFails: { type: "string", maxLength: 60 }, ifNotSure: { type: "string", maxLength: 60 },
918
- } } },
919
- title: { type: "string", maxLength: 200 }, description: { type: "string", maxLength: 4000 }, zone: { type: "string", maxLength: 60 }, note: { type: "string", maxLength: LIMITS.note, description: `At most ${LIMITS.note} characters; longer is refused with its length, not cut.` },
920
- choice: { type: "integer", minimum: 1, maximum: 4 },
921
- trigger: { type: "integer", minimum: 1 }, owner: { type: "string", maxLength: 64 },
922
- script: { type: "object", additionalProperties: false, properties: { name: { type: "string", maxLength: 40 }, about: { type: "string", maxLength: 160 }, body: { type: "string", maxLength: 1600 }, timeoutMinutes: { type: "integer", minimum: 1, maximum: 60 },
923
- language: { type: "string", enum: ["python", "node", "shell"] }, file: { type: "string", maxLength: 200 } } },
924
- settings: { type: "object", additionalProperties: false, properties: {
925
- kind: { type: "string", enum: FLOW_TRIGGER_KINDS.filter(one => one !== "webhook" && one !== "chat") }, zone: { type: "string", maxLength: 60 },
926
- label: { type: "string", maxLength: 50 }, questions: { type: "array", maxItems: 6, items: { type: "string", maxLength: 80 } },
927
- schedule: { type: "string", maxLength: 80 }, title: { type: "string", maxLength: 200 }, description: { type: "string", maxLength: 2000 },
928
- repo: { type: "string", maxLength: 140 }, watch: { type: "string", enum: ["issues", "pulls", "checks"] }, branch: { type: "string", maxLength: 100 }, from: { type: "string", enum: ["team", "anyone"] },
929
- team: { type: "string", maxLength: 12 }, state: { type: "string", maxLength: 40 }, follow: { type: "integer", minimum: 1 }, when: { type: "string", maxLength: 60 },
930
- folder: { type: "string", maxLength: 100 }, sender: { type: "string", maxLength: 300 }, subject: { type: "string", maxLength: 100 },
931
- script: { type: "string", maxLength: 40 }, secrets: { type: "array", maxItems: 10, items: { type: "string", maxLength: 40 } },
932
- at: { type: "string", maxLength: 5 }, timeZone: { type: "string", maxLength: 60 },
933
- } },
934
- }, ["operation"]),
935
- handle: (ctx, args) => {
896
+ inputSchema: { ...PROPOSE_FLOW_MODEL_SCHEMA },
897
+ handle: (ctx, given) => {
898
+ // The call is read by the schema the lead was given: a refusal names each path (`steps[0].routes[0].goesTo: required`).
899
+ // An edit's kept steps may leave their kind and name to the zone they keep.
900
+ const editing = given["operation"] === "edit" && Number.isSafeInteger(given["flow"]) ? ctx.store.getFlow(Number(given["flow"])) : null;
901
+ const kept = editing !== null && ctx.who.repos.includes(editing.repo) ? flowDefinitionOf(editing) : null;
902
+ const read = parseContract(proposeFlowInputSchema, given["steps"] === undefined ? given : { ...given, steps: withKeptSteps(given["steps"], kept) }, FLOW_ALIASES);
903
+ if (!read.ok)
904
+ return { ok: false, message: read.issues.map(one => one.line).join("\n") };
905
+ const args = read.value;
936
906
  const pick = (keys) => Object.fromEntries(keys.filter(key => args[key] !== undefined).map(key => [key, args[key]]));
937
907
  // "me" in a step means the operator; the drawing stores their name.
938
908
  // A build step in another project names it as list_repos does (r2); the drawing keeps its path.
@@ -11,6 +11,9 @@ export type OneClick = {
11
11
  url: string;
12
12
  about: string;
13
13
  reads?: readonly string[];
14
+ /** Send no scope: the server applies its own preset for this resource (PostHog's MCP preset). Naming scopes there
15
+ * shows a 75-group permission grid instead, and the person can stop on it without ever coming back. */
16
+ serverScopes?: true;
14
17
  };
15
18
  /**
16
19
  * Services that connect this way: their servers speak streamable HTTP and
@@ -52,7 +52,7 @@ export const ONE_CLICK = [
52
52
  // Mobbin's sign-in is its Supabase auth server, on another origin: its protected-resource metadata names it.
53
53
  { id: "mobbin", label: "Mobbin", url: "https://api.mobbin.com/mcp", about: "Real app screens and flows to learn from.",
54
54
  reads: ["screen", "flow", "app", "site", "element", "pattern", "ui", "ios", "android", "web"] },
55
- { id: "posthog", label: "PostHog", url: "https://mcp.posthog.com/mcp", about: "Product analytics, funnels and events.",
55
+ { id: "posthog", label: "PostHog", url: "https://mcp.posthog.com/mcp", about: "Product analytics, funnels and events.", serverScopes: true,
56
56
  reads: ["insight", "run", "event", "definition", "property", "properties"] },
57
57
  { id: "betterstack", label: "Better Stack", url: "https://mcp.betterstack.com", about: "Uptime checks and incidents.",
58
58
  reads: ["uptime", "monitor", "incident", "availability", "response", "time"] },
@@ -147,6 +147,9 @@ const ACCESS = "OAUTH_ACCESS_TOKEN", REFRESH = "OAUTH_REFRESH_TOKEN", CLIENT = "
147
147
  export const CONNECT_CALLBACK = "/settings/tools/connected";
148
148
  const VISIT_MS = 15 * 60_000, RENEW_RETRY_MS = 5 * 60_000;
149
149
  const renewTried = new Map();
150
+ /** RFC 6749 scope tokens; and the most scope text a sign-in address carries (addresses stay well under 8 KB). */
151
+ const SCOPE_TOKEN = /^[\x21\x23-\x5b\x5d-\x7e]{1,128}$/;
152
+ const SCOPE_PARAM_LIMIT = 6_000;
150
153
  const json = async (fetcher, url) => {
151
154
  try {
152
155
  const answer = await fetcher(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10_000) });
@@ -195,10 +198,19 @@ export async function discoverSignIn(mcpUrl, fetcher = fetch) {
195
198
  const methods = Array.isArray(meta["code_challenge_methods_supported"]) ? meta["code_challenge_methods_supported"] : ["S256"];
196
199
  if (!methods.includes("S256"))
197
200
  return null;
198
- const scopes = Array.isArray(resource?.["scopes_supported"]) ? resource["scopes_supported"].filter((one) => typeof one === "string") : [];
201
+ // Ask for every scope the server lists for this resource: a token missing one the server itself requires is
202
+ // refused after a successful sign-in (PostHog lists 155 and needs user:read, the 140th). Only well-formed scope
203
+ // tokens, and at most what fits a sign-in address.
204
+ const listed = Array.isArray(resource?.["scopes_supported"]) ? resource["scopes_supported"].filter((one) => typeof one === "string" && SCOPE_TOKEN.test(one)) : [];
205
+ const scopes = [];
206
+ for (const one of listed) {
207
+ if (scopes.join(" ").length + one.length + 1 > SCOPE_PARAM_LIMIT)
208
+ break;
209
+ scopes.push(one);
210
+ }
199
211
  // A token is for the resource the server names (Stripe's has no trailing slash), when it names one.
200
212
  const named = resource?.["resource"];
201
- return { authorize: meta["authorization_endpoint"], token: meta["token_endpoint"], register: meta["registration_endpoint"], scopes: scopes.slice(0, 20), resource: https(named) ? named : null };
213
+ return { authorize: meta["authorization_endpoint"], token: meta["token_endpoint"], register: meta["registration_endpoint"], scopes, resource: https(named) ? named : null };
202
214
  }
203
215
  /**
204
216
  * Start connecting a service: find its sign-in, register this console as a
@@ -240,7 +252,7 @@ export async function startConnect(input, fetcher = fetch, now = Date.now()) {
240
252
  go.searchParams.set("code_challenge_method", "S256");
241
253
  go.searchParams.set("state", state);
242
254
  go.searchParams.set("resource", resource);
243
- if (server.scopes.length > 0)
255
+ if (server.scopes.length > 0 && service.serverScopes !== true)
244
256
  go.searchParams.set("scope", server.scopes.join(" "));
245
257
  return { ok: true, go: go.toString(), state, visit: { service: service.id, repo: input.repo, by: input.by, kit: input.kit ?? null, template: input.template ?? null, verifier, clientId, clientSecret, token: server.token, resource, redirect, expires: now + VISIT_MS } };
246
258
  }
package/dist/operate.js CHANGED
@@ -53,6 +53,7 @@ import { FLOWS_VALUE_FLAGS, runFlowsCommand } from "./flows-cli.js";
53
53
  import { runAssignmentCommand } from "./assignment-adapters.js";
54
54
  import { applyProjectProfile, runProjectCommand } from "./project-cli.js";
55
55
  import { pullRequestLines, runTaskMergeCommand, runTaskOutcomeCommand } from "./task-outcome-cli.js";
56
+ import { taskReviewBrief, renderReviewBrief } from "./task-review-brief.js";
56
57
  /**
57
58
  * The commands that actually move work: authoring tasks, and the claim loop.
58
59
  *
@@ -93,6 +94,7 @@ import { pushPass } from "./push.js";
93
94
  import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync, writeFileSync, mkdirSync } from "node:fs";
94
95
  import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, namedPath, taskBranches } from "./names.js";
95
96
  import { claimActor, currentActor, parseLeadToken, withActor } from "./actor.js";
97
+ import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
96
98
  import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
97
99
  import { createServer as createNetServer } from "node:net";
98
100
  import { spawn as spawnChild } from "node:child_process";
@@ -162,7 +164,7 @@ import { attendedLivenessState } from "./liveness.js";
162
164
  import { HeldSessionCoordinator, sweepHeldOrphans } from "./held.js";
163
165
  import { proposeGrant, describeGrant, describeWithheld, permits, MUTATION_CLASSES, DEFAULT_MUTATIONS, } from "./grant.js";
164
166
  import { builtIn, guarded } from "./backend.js";
165
- import { register, authenticate, heartbeat as heartbeatRunner, isAlive, recoverDead, recoveredAnything, acquireWatchLeaseAuthed, heartbeatWatchLeaseAuthed, addRunnerReposAuthed, registerRunnerIfIdle, retireRunnerIfCurrent, normalizeRunnerName, validRunnerName, canonicalRepos, RUNNER_NAME_MAX, RUNNER_CAPACITY_MAX, parseRunnerCapacity, } from "./runner.js";
167
+ import { register, authenticate, heartbeat as heartbeatRunner, isAlive, recoverDead, recoveredAnything, acquireWatchLeaseAuthed, heartbeatWatchLeaseAuthed, addRunnerReposAuthed, removeRunnerRepoAuthed, registerRunnerIfIdle, retireRunnerIfCurrent, normalizeRunnerName, validRunnerName, canonicalRepos, RUNNER_NAME_MAX, RUNNER_CAPACITY_MAX, parseRunnerCapacity, } from "./runner.js";
166
168
  import { mintCoordinator, revokeCoordinator, listCoordinators } from "./coordinator.js";
167
169
  import { serveMcp } from "./mcp.js";
168
170
  import { createInterface } from "node:readline";
@@ -260,6 +262,7 @@ export const OPERATE_HELP = `toolroll — operating the queue
260
262
  toolroll backup now|list back the database up now; list backups and how the last ones went
261
263
  toolroll restore <file> [--dry-run] put a backup back (Toolroll stopped; the current database is kept)
262
264
  toolroll review show|on|off --repo <path> one automatic review of each finished build; only HIGH findings send it back
265
+ toolroll task review <id> --brief compact saved result, checks and findings; --run <id>, --all, --json
263
266
  toolroll policy show|set the organisation policy; an instance operator sets it with --providers claude,codex|any,
264
267
  --models <m,…>|any, --tools <t,…>|any, --ceiling safe|standard|escalated
265
268
  toolroll ledger verify check the action ledger's hash chain (--checkpoint <n:hash> to compare a copied head)
@@ -664,7 +667,7 @@ export const OPERATE_VALUE_FLAGS = new Set([
664
667
  "task",
665
668
  ]);
666
669
  export const OPERATE_BOOLEAN_FLAGS = new Set([
667
- "json", "yes", "all", "local", "history", "latest-watch", "dry-run", "file", "allow-paid-fallback",
670
+ "json", "yes", "all", "brief", "local", "history", "latest-watch", "dry-run", "file", "allow-paid-fallback",
668
671
  "clear", "follow", "ready", "all-tasks", "inbound-only", "help", "undo", "anyone", "allow-dispatch", "allow-merge", "merge-delete-branch",
669
672
  "no-open", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
670
673
  "self-heal", "plan-auto", "chat-approve", "repair-auto", "review-retry-auto", "no-local",
@@ -4934,6 +4937,7 @@ async function startConsole(options) {
4934
4937
  ...(options.repos === undefined ? {} : { repos: options.repos }),
4935
4938
  ...(options.projectRoots === undefined ? {} : { projectRoots: options.projectRoots }),
4936
4939
  ...(options.currentRepos === undefined ? {} : { currentRepos: options.currentRepos }),
4940
+ ...(options.admittedRepos === undefined ? {} : { admittedRepos: options.admittedRepos }),
4937
4941
  ...(options.publicUrl === undefined ? {} : { publicUrl: options.publicUrl }),
4938
4942
  ...(options.registryPath === undefined ? {} : { registryPath: options.registryPath }),
4939
4943
  ...(options.upConsole === undefined ? {} : { upConsole: options.upConsole }),
@@ -5169,9 +5173,36 @@ async function planTaskCommand(positional, flags, context) {
5169
5173
  autoPlan ? "An unchanged, verified plan can auto-approve under your signed mode. Amendments and questions still wait for you." : "Its questions reach you like any decision; its plan lands as a scope for you to edit and approve.",
5170
5174
  ]);
5171
5175
  }
5172
- /** The retired command is a typed refusal, never a new provider request. */
5176
+ /** Read saved work; neither form requests a new provider review. */
5173
5177
  async function reviewTaskCommand(positional, flags, context) {
5174
5178
  const { store, write, json } = context;
5179
+ if (flags.has("brief")) {
5180
+ const allowed = new Set(["brief", "all", "run", "json", "db", "as", "token"]);
5181
+ for (const name of flags.keys())
5182
+ if (!allowed.has(name))
5183
+ return fail(write, json, "task review", "usage", `--${name} is not a task review --brief option.`, EXIT.usage);
5184
+ const task = positional[0], selected = flags.get("run");
5185
+ if (positional.length !== 1 || !task || task.length > 64 || /[\x00-\x1f\x7f]/.test(task) ||
5186
+ selected !== undefined && (typeof selected !== "string" || !/^[1-9]\d*$/.test(selected) || !Number.isSafeInteger(Number(selected)))) {
5187
+ return fail(write, json, "task review", "usage", "Use toolroll task review <task> --brief [--run <id>] [--all] [--json].", EXIT.usage);
5188
+ }
5189
+ // The normal CLI entry has already authenticated lead tokens. Local reads
5190
+ // stay available; a signed-in lead sees only its owner's admitted projects.
5191
+ let viewer = currentActor()?.account ?? null;
5192
+ if (viewer === null && (flags.has("as") || flags.has("token"))) {
5193
+ const acting = await askCredentials(flags, context);
5194
+ if (acting === null || !authenticateApprover(store, acting.name, acting.token).ok)
5195
+ return fail(write, json, "task review", "unauthenticated", "That sign-in is not valid.", EXIT.refused);
5196
+ viewer = acting.name;
5197
+ }
5198
+ const repos = viewer === null ? null : store.knownRepos().filter(repo => store.accountCanAccess(viewer, repo));
5199
+ const result = taskReviewBrief(store, task, context.evidenceRoot, context.now, { principal: "operator", repos, includeUnplaced: viewer === null }, { ...(selected === undefined ? {} : { run: Number(selected) }), all: flags.has("all"), secrets: [context.leadToken ?? "", text(flags, "token") ?? ""] });
5200
+ if (!result.ok)
5201
+ return fail(write, json, "task review", result.reason, result.message, EXIT.refused);
5202
+ return succeed(write, json, "task review", { brief: result.brief }, () => renderReviewBrief(result.brief));
5203
+ }
5204
+ if (flags.has("run") || flags.has("all"))
5205
+ return fail(write, json, "task review", "usage", "--run and --all require --brief.", EXIT.usage);
5175
5206
  const [runText] = positional;
5176
5207
  const runId = Number(runText ?? "");
5177
5208
  if (runText === undefined || !Number.isInteger(runId) || runId < 1) {
@@ -8360,12 +8391,15 @@ async function onboardCommand(flags, context) {
8360
8391
  return !("error" in registry) && registry.repos.includes(repo);
8361
8392
  },
8362
8393
  enroll: async (repo) => {
8394
+ // The same admission as `repos add` and the console's Add a project.
8395
+ const actor = commandLineActor(store, { databaseFile: context.databaseFile });
8396
+ if ("ok" in actor)
8397
+ return { ok: false, message: actor.message };
8363
8398
  const before = await loadProjectRegistry(registryPath);
8364
- const enrolled = await updateRepos(registryPath, repos => addRepos(repos, [repo]));
8365
- if (!enrolled.ok)
8366
- return { ok: false, message: enrolled.message };
8367
- store.upsertProject(repo, projectName(repo), clock());
8368
- return { ok: true, added: "error" in before || !before.repos.includes(repo) };
8399
+ const admitted = await admitProject(store, { registryFile: registryPath, path: repo, actor, now: clock() });
8400
+ if (!admitted.ok)
8401
+ return { ok: false, message: admitted.message };
8402
+ return { ok: true, added: "error" in before || !before.repos.includes(admitted.repo) };
8369
8403
  },
8370
8404
  checkConnection: seams.checkConnection ?? (agent => checker(agent)),
8371
8405
  pullRequests: seams.pullRequests ?? (async (repo) => {
@@ -8495,6 +8529,9 @@ async function upCommand(flags, context) {
8495
8529
  }
8496
8530
  }
8497
8531
  const explicitRepos = context.repoList ?? [];
8532
+ // Repositories this start was told to serve stay served while it runs: removing one from the saved list takes
8533
+ // effect at the next start, never under the process that was pinned to it.
8534
+ const pinnedRepos = new Set();
8498
8535
  if (explicitRepos.length > 0) {
8499
8536
  for (const input of explicitRepos) {
8500
8537
  const root = await proveRepo(input);
@@ -8503,12 +8540,15 @@ async function upCommand(flags, context) {
8503
8540
  }
8504
8541
  if (!repos.includes(root))
8505
8542
  repos.push(root);
8543
+ pinnedRepos.add(root);
8506
8544
  }
8507
8545
  }
8508
8546
  else if (context.inferProjectFromCwd !== false) {
8509
8547
  const cwdRepo = await proveRepo(process.cwd());
8510
8548
  if (cwdRepo !== null && !repos.includes(cwdRepo))
8511
8549
  repos.push(cwdRepo);
8550
+ if (cwdRepo !== null)
8551
+ pinnedRepos.add(cwdRepo);
8512
8552
  }
8513
8553
  if (repos.length === 0 && projectRoots.length === 0) {
8514
8554
  return fail(write, json, "up", "no-projects", "choose where your projects live once: `toolroll up --project-root <dir>`", EXIT.refused);
@@ -8660,6 +8700,7 @@ async function upCommand(flags, context) {
8660
8700
  poolRoot: pool,
8661
8701
  repos,
8662
8702
  currentRepos: () => [...activeRepos],
8703
+ admittedRepos: () => [...activeRepos],
8663
8704
  upConsole: true,
8664
8705
  attended: {
8665
8706
  runner: runnerName,
@@ -8752,6 +8793,8 @@ async function upCommand(flags, context) {
8752
8793
  // One record of project passes for the whole service: fair scheduling
8753
8794
  // across its projects (project-concurrency.ts).
8754
8795
  const projectPasses = new ProjectPasses();
8796
+ // Projects removed while running: each one's watch stops admitting work, finishes what it holds, then lets go.
8797
+ const releasedRepos = new Set();
8755
8798
  const launchRepo = async (repo, bind) => {
8756
8799
  if (loopResults.has(repo) || stopping)
8757
8800
  return;
@@ -8776,7 +8819,7 @@ async function upCommand(flags, context) {
8776
8819
  token: runnerToken,
8777
8820
  repo,
8778
8821
  progress: line => progress(`${prefix(repo)}${line}`),
8779
- isStopping: () => stopping,
8822
+ isStopping: () => stopping || releasedRepos.has(repo),
8780
8823
  onFollowController: controller => followControllers.push(controller),
8781
8824
  containmentLines,
8782
8825
  passes: projectPasses,
@@ -8789,6 +8832,19 @@ async function upCommand(flags, context) {
8789
8832
  }).then(result => ({ repo, result }), error => ({ repo, result: { ok: false, reason: "loop-failed", detail: describe(error), ticks: 0, built: 0, broke: 0 } }));
8790
8833
  loopResults.set(repo, loop);
8791
8834
  void loop.then(({ repo: endedRepo, result }) => {
8835
+ if (releasedRepos.has(endedRepo) && !stopping) {
8836
+ // A removed project's watch has stopped: only now does the builder let go of it.
8837
+ const unbound = removeRunnerRepoAuthed(store, { name: runnerName, token: runnerToken, repo: endedRepo }, clock());
8838
+ if (!unbound.ok) {
8839
+ fatal = `the builder identity changed while removing ${endedRepo} (${unbound.reason})`;
8840
+ stop();
8841
+ }
8842
+ loopResults.delete(endedRepo);
8843
+ releasedRepos.delete(endedRepo);
8844
+ progress(`project removed — ${projectName(endedRepo)} is no longer built here`);
8845
+ markReady();
8846
+ return;
8847
+ }
8792
8848
  // A loop that dies while its siblings live must not leave a partial
8793
8849
  // cockpit standing silently (finding 5).
8794
8850
  if (!result.ok && !stopping) {
@@ -8842,19 +8898,34 @@ async function upCommand(flags, context) {
8842
8898
  stop();
8843
8899
  break;
8844
8900
  }
8901
+ // Removed from the saved list: stop building it and take it out of the console. A pinned start keeps its own.
8902
+ const saved = new Set(loaded.repos.map(one => canonicalProject(one) ?? one));
8903
+ for (const repo of [...activeRepos]) {
8904
+ if (stopping || pinnedRepos.has(repo) || saved.has(repo))
8905
+ continue;
8906
+ activeRepos.delete(repo);
8907
+ releasedRepos.add(repo);
8908
+ if (!loopResults.has(repo))
8909
+ releasedRepos.delete(repo);
8910
+ }
8845
8911
  for (const candidate of loaded.repos) {
8846
8912
  if (stopping)
8847
8913
  break;
8848
8914
  const canonical = canonicalProject(candidate);
8849
- if (canonical !== null && activeRepos.has(canonical))
8915
+ // Still letting go of an earlier removal: it rejoins on the next pass after its watch has stopped.
8916
+ if (canonical !== null && (activeRepos.has(canonical) || releasedRepos.has(canonical)))
8850
8917
  continue;
8851
8918
  const approvedAdditions = context.additionalProjectRepos?.() ?? [];
8852
8919
  const admittedCeiling = resolveCeiling([...dynamicCeiling.repos, ...approvedAdditions], dynamicCeiling.roots).ceiling;
8853
- const allowed = canonical !== null && (await authorizedProject(admittedCeiling, canonical)) && (await proveRepo(canonical)) === canonical;
8920
+ // A project added after this start (`repos add`, the lead, the console) carries its recorded admission; a
8921
+ // registry line alone is not authority. Either way the folder is proved to be that Git checkout again.
8922
+ const allowed = canonical !== null &&
8923
+ ((await authorizedProject(admittedCeiling, canonical)) || admissionRecorded(store, canonical)) &&
8924
+ (await proveRepo(canonical)) === canonical;
8854
8925
  if (!allowed) {
8855
8926
  if (!rejected.has(candidate)) {
8856
8927
  rejected.add(candidate);
8857
- progress(`project not connected — ${candidate} is not a Git repository inside a saved projects folder`);
8928
+ progress(`project not connected — ${candidate} isn't a Git repository added with \`toolroll repos add\` or the console`);
8858
8929
  }
8859
8930
  continue;
8860
8931
  }
package/dist/plan.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * the scope proposal is authority-bearing, so it gets the park discipline).
7
7
  */
8
8
  import { type AcceptanceCriterion } from "./scope.js";
9
+ import { type ContractIssue } from "./contracts/contract.js";
9
10
  export type PlanProblem = {
10
11
  reason: string;
11
12
  message: string;
@@ -69,18 +70,17 @@ export type PlanParseResult = {
69
70
  ok: false;
70
71
  problems: PlanProblem[];
71
72
  };
72
- /** Caps matching the scope ritual's fields, plus the document itself. The goal and out-of-scope text share the
73
- * task's own limit: a plan reproduces the filed contract exactly, so a lower cap here would refuse every plan for a
74
- * long goal. */
75
- export declare const PLAN_LIMITS: {
76
- readonly payload: number;
77
- readonly goal: 8000;
78
- readonly outOfScope: 8000;
79
- readonly touch: 200;
80
- readonly touches: 32;
81
- readonly document: number;
82
- readonly amendment: 1000;
83
- };
73
+ /**
74
+ * A contract issue as a plan problem (`contractProblemOf`): the reason keeps the codes the rest of the code relies on
75
+ * — `missing-goal`, `bad-touches`, `plan-too-long`, `amendment-too-long`, `acceptance-too-many` — so repair eligibility
76
+ * (document-only problems) and durable outcomes read the same as before the contract.
77
+ */
78
+ export declare const planProblemOf: (issue: ContractIssue) => PlanProblem;
79
+ /**
80
+ * The planner's handoff, read against the plan contract (src/contracts/plan.ts): fail closed, every problem at once,
81
+ * each one naming its path (`acceptance[0].evidence[0]: must be one of ...`). A plan saved before plans carried
82
+ * `version` reads as it always did.
83
+ */
84
84
  export declare function parsePlan(raw: string): PlanParseResult;
85
85
  export type MilestoneState = "pending" | "current" | "completed" | "blocked";
86
86
  export type Milestone = {