toolroll 0.9.19 → 0.9.21

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 (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/browser/workspace.css +1 -1
  3. package/dist/browser/workspace.js +22 -21
  4. package/dist/browser-workspace.d.ts +43 -1
  5. package/dist/chat-actions.d.ts +5 -0
  6. package/dist/chat-actions.js +57 -6
  7. package/dist/chat-delivery-state.d.ts +15 -0
  8. package/dist/chat-delivery-state.js +27 -2
  9. package/dist/chat-delivery.js +24 -4
  10. package/dist/chat-flow.d.ts +26 -3
  11. package/dist/chat-flow.js +217 -5
  12. package/dist/desktop-update.d.ts +1 -1
  13. package/dist/discord-chat.js +2 -2
  14. package/dist/evidence.d.ts +13 -1
  15. package/dist/evidence.js +10 -1
  16. package/dist/flow-engine.d.ts +7 -0
  17. package/dist/flow-engine.js +71 -27
  18. package/dist/flow-gallery-ui.d.ts +1 -1
  19. package/dist/flow-gallery-ui.js +8 -7
  20. package/dist/flow-gallery.d.ts +8 -0
  21. package/dist/flow-gallery.js +37 -3
  22. package/dist/flow-send.d.ts +80 -0
  23. package/dist/flow-send.js +249 -0
  24. package/dist/flow-share.js +7 -3
  25. package/dist/flow-steps.js +1 -1
  26. package/dist/flow-triggers.d.ts +7 -0
  27. package/dist/flow-triggers.js +17 -5
  28. package/dist/flows-cli.js +11 -3
  29. package/dist/flows-ui.js +22 -6
  30. package/dist/flows.d.ts +27 -1
  31. package/dist/flows.js +104 -18
  32. package/dist/invoke.d.ts +3 -0
  33. package/dist/invoke.js +1 -0
  34. package/dist/mate-tools.js +33 -10
  35. package/dist/operate.js +41 -0
  36. package/dist/plane-review.d.ts +15 -0
  37. package/dist/plane-review.js +62 -20
  38. package/dist/project-delete.js +4 -0
  39. package/dist/project-demo.d.ts +12 -0
  40. package/dist/project-demo.js +71 -0
  41. package/dist/provider.d.ts +14 -0
  42. package/dist/provider.js +8 -1
  43. package/dist/report-summary.d.ts +9 -0
  44. package/dist/report-summary.js +9 -0
  45. package/dist/result-shots.d.ts +2 -0
  46. package/dist/result-shots.js +24 -5
  47. package/dist/scout-net.d.ts +22 -0
  48. package/dist/scout-net.js +214 -0
  49. package/dist/scout-report.d.ts +160 -0
  50. package/dist/scout-report.js +171 -1
  51. package/dist/scout.d.ts +42 -0
  52. package/dist/scout.js +380 -69
  53. package/dist/serve.js +43 -5
  54. package/dist/slack-chat.js +3 -3
  55. package/dist/store.d.ts +53 -0
  56. package/dist/store.js +95 -4
  57. package/dist/teams-chat.js +1 -1
  58. package/dist/telegram-flow.d.ts +16 -2
  59. package/dist/telegram-flow.js +58 -0
  60. package/dist/telegram.js +46 -10
  61. package/package.json +1 -1
@@ -46,6 +46,7 @@ import { agentsSummary, chosenWords, isRiskLevel, PHASES, postureWords, RISK_CHO
46
46
  import { TOOL_CATALOG, discoverTools, projectToolsOf, secretsSetFor, toolCommandLine, toolStanding } from "./project-tools.js";
47
47
  import { deciderOf, durationWords, FLOW_KIND_WORDS, FLOW_STAGE_KINDS, FLOW_TEMPLATES, flowFromSteps, stepsFor } from "./flows.js";
48
48
  import { flowDefinitionOf } from "./flow-engine.js";
49
+ import { flowPersonOf } from "./flow-send.js";
49
50
  import { flowInsights } from "./flow-insights.js";
50
51
  import { describeTrigger, FLOW_TRIGGER_KINDS, triggerConfigOf } from "./flow-triggers.js";
51
52
  export const MATE_MAX_PROPOSALS_PER_TURN = 5;
@@ -796,8 +797,11 @@ export const MATE_TOOLS = [
796
797
  inputSchema: schema({ repo: REPO_ARG, flow: { type: "integer", minimum: 1 }, card: { type: "integer", minimum: 1 } }),
797
798
  handle: (ctx, args) => {
798
799
  const reachable = (repo) => ctx.who.repos.includes(repo) && ctx.store.accountCanAccess(ctx.who.name, repo);
799
- const needsYou = (flow, definition, stage) => {
800
- const at = definition?.stages.find(one => one.id === stage);
800
+ const needsYou = (flow, definition, card) => {
801
+ const at = definition?.stages.find(one => one.id === card.stage);
802
+ // A "Person chooses" zone waits for the card's person; a "Person decides" one for its decider.
803
+ if (at?.kind === "choose")
804
+ return flowPersonOf(card, flow) === ctx.who.name;
801
805
  if (at?.kind !== "approval")
802
806
  return false;
803
807
  const decider = deciderOf(at, flow);
@@ -832,15 +836,19 @@ export const MATE_TOOLS = [
832
836
  ...(stage.merge === undefined ? {} : { merge: stage.merge }),
833
837
  ...(stage.teammate === undefined ? {} : { teammate: stage.teammate }),
834
838
  ...(stage.kind === "teammate" && stage.routes !== undefined ? { routes: stage.routes.map(one => ({ answer: one.answer, goesTo: titleOf(one.to) })) } : {}),
839
+ ...(stage.kind === "choose" ? { options: (stage.options ?? []).map((one, index) => ({ choice: index + 1, label: one.label, goesTo: one.to === "end" ? "end" : titleOf(one.to) })) } : {}),
840
+ ...(stage.kind === "task" && stage.repo !== undefined ? { repo: repoIdOf(ctx.who, stage.repo) ?? "another project" } : {}),
835
841
  ...(stage.limit === undefined ? {} : { remindAfter: durationWords(stage.limit.minutes), ...(stage.limit.to === null ? {} : { thenMoveTo: titleOf(stage.limit.to) }) }),
836
- next: titleOf(stage.next), ...(stage.kind === "sort" ? { ifNotSure: titleOf(stage.onFail) } : stage.kind === "wait" ? { ifNoReply: titleOf(stage.onFail) } : { ifFails: titleOf(stage.onFail) }),
842
+ next: titleOf(stage.next), ...(stage.kind === "sort" ? { ifNotSure: titleOf(stage.onFail) } : stage.kind === "wait" ? { ifNoReply: titleOf(stage.onFail) } : stage.kind === "choose" ? { ifReplied: titleOf(stage.onFail) } : { ifFails: titleOf(stage.onFail) }),
837
843
  })),
838
844
  cards: [...active, ...finished].map(card => ({
839
845
  card: card.id, title: card.title, ...(card.description === null ? {} : { description: card.description.slice(0, 300) }),
840
- at: titleOf(card.stage) ?? card.stage, state: card.state, needsYou: card.state === "active" && needsYou(flow, definition, card.stage),
846
+ at: titleOf(card.stage) ?? card.stage, state: card.state, needsYou: card.state === "active" && needsYou(flow, definition, card),
841
847
  // Names never reach the model; a decision says whose it is in its own terms.
842
- waiting: card.state !== "active" || definition.stages.find(one => one.id === card.stage)?.kind !== "approval" || card.waiting === null ? card.waiting
843
- : needsYou(flow, definition, card.stage) ? "Waiting for you to approve or send it back" : "Waiting for someone else to decide",
848
+ waiting: card.state === "active" && definition.stages.find(one => one.id === card.stage)?.kind === "choose" && card.waiting !== null
849
+ ? (flowPersonOf(card, flow) === ctx.who.name ? "Waiting for you to choose (operation choose: choice by number, or note to reply)" : "Waiting for someone else to choose")
850
+ : card.state !== "active" || definition.stages.find(one => one.id === card.stage)?.kind !== "approval" || card.waiting === null ? card.waiting
851
+ : needsYou(flow, definition, card) ? "Waiting for you to approve or send it back" : "Waiting for someone else to decide",
844
852
  task: card.task ?? card.primaryTask, ...(card.note === null ? {} : { lastNote: card.note.slice(0, 300) }),
845
853
  // Names never reach the model: people read as you or a teammate.
846
854
  owner: card.owner === null ? null : card.owner === ctx.who.name ? "you" : "a teammate", following: ctx.store.flowCardWatchers(card.id).includes(ctx.who.name),
@@ -867,7 +875,7 @@ export const MATE_TOOLS = [
867
875
  const definition = flowDefinitionOf(flow);
868
876
  const cards = ctx.store.flowCards(flow.id, false);
869
877
  return { flow: flow.id, name: flow.name, project: repoIdOf(ctx.who, flow.repo), steps: definition === null ? "can't be read" : definition.stages.map(one => one.title).join(" → "),
870
- cards: cards.length, needYou: cards.filter(card => needsYou(flow, definition, card.stage)).length, triggers: ctx.store.flowTriggers(flow.id).length };
878
+ cards: cards.length, needYou: cards.filter(card => needsYou(flow, definition, card)).length, triggers: ctx.store.flowTriggers(flow.id).length };
871
879
  }),
872
880
  templates: FLOW_TEMPLATES.map(one => ({ template: one.id, about: one.about })),
873
881
  // Scripts belong to a project, not a flow: they exist (and can be saved) before any flow does.
@@ -878,9 +886,9 @@ export const MATE_TOOLS = [
878
886
  },
879
887
  {
880
888
  name: "propose_flow",
881
- 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. Read get_flows first except to create.",
889
+ 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.",
882
890
  inputSchema: schema({
883
- operation: { type: "string", enum: ["create", "edit", "add_card", "move_card", "approve", "send_back", "cancel_card", "comment", "assign", "follow", "unfollow", "save_script", "add_trigger", "pause_trigger", "resume_trigger", "remove_trigger", "kit", "starter"] },
891
+ 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"] },
884
892
  repo: REPO_ARG, flow: { type: "integer", minimum: 1 }, card: { type: "integer", minimum: 1 }, kit: { type: "string", enum: KITS.map(one => one.id) },
885
893
  starter: { type: "string", enum: [...STARTER_IDS] },
886
894
  name: { type: "string", maxLength: 80 }, template: { type: "string", enum: FLOW_TEMPLATES.map(one => one.id) },
@@ -901,9 +909,12 @@ export const MATE_TOOLS = [
901
909
  waitFor: { type: "string", enum: ["reply", "time", "hours"] }, wait: { type: "string", maxLength: 40 }, ifNoReply: { type: "string", maxLength: 60 },
902
910
  from: { type: "string", maxLength: 5 }, until: { type: "string", maxLength: 5 }, merge: { type: "string", enum: ["squash", "merge", "rebase"] },
903
911
  remindAfter: { type: "string", maxLength: 40 }, thenMoveTo: { type: "string", maxLength: 60 }, teammate: { type: "string", maxLength: 40 },
912
+ options: { type: "array", minItems: 2, maxItems: 4, items: { type: "object", additionalProperties: false, properties: { label: { type: "string", maxLength: 40 }, goesTo: { type: "string", maxLength: 60 } } } },
913
+ ifReplied: { type: "string", maxLength: 60 }, repo: REPO_ARG,
904
914
  next: { type: "string", maxLength: 60 }, ifFails: { type: "string", maxLength: 60 }, ifNotSure: { type: "string", maxLength: 60 },
905
915
  } } },
906
916
  title: { type: "string", maxLength: 200 }, description: { type: "string", maxLength: 4000 }, zone: { type: "string", maxLength: 60 }, note: { type: "string", maxLength: 2000 },
917
+ choice: { type: "integer", minimum: 1, maximum: 4 },
907
918
  trigger: { type: "integer", minimum: 1 }, owner: { type: "string", maxLength: 64 },
908
919
  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 },
909
920
  language: { type: "string", enum: ["python", "node", "shell"] }, file: { type: "string", maxLength: 200 } } },
@@ -921,7 +932,15 @@ export const MATE_TOOLS = [
921
932
  handle: (ctx, args) => {
922
933
  const pick = (keys) => Object.fromEntries(keys.filter(key => args[key] !== undefined).map(key => [key, args[key]]));
923
934
  // "me" in a step means the operator; the drawing stores their name.
924
- const steps = () => stepsFor(Array.isArray(args["steps"]) ? args["steps"] : [], ctx.who.name);
935
+ // A build step in another project names it as list_repos does (r2); the drawing keeps its path.
936
+ const steps = () => stepsFor(Array.isArray(args["steps"]) ? args["steps"] : [], ctx.who.name).map(step => {
937
+ if (step === null || typeof step !== "object" || step.repo === undefined)
938
+ return step;
939
+ const path = repoPathOf(ctx.who, step.repo);
940
+ if (path === null)
941
+ throw new Error("A build step's repo is a project from list_repos.");
942
+ return { ...step, repo: path };
943
+ });
925
944
  const flowOf = () => {
926
945
  const flow = Number.isSafeInteger(args["flow"]) ? ctx.store.getFlow(Number(args["flow"])) : null;
927
946
  return flow !== null && ctx.who.repos.includes(flow.repo) ? flow : null;
@@ -988,6 +1007,10 @@ export const MATE_TOOLS = [
988
1007
  operation = "flow_card_send_back";
989
1008
  input = pick(["card", "note"]);
990
1009
  break;
1010
+ case "choose":
1011
+ operation = "flow_card_choose";
1012
+ input = pick(["card", "choice", "note"]);
1013
+ break;
991
1014
  case "cancel_card":
992
1015
  operation = "flow_card_cancel";
993
1016
  input = pick(["card"]);
package/dist/operate.js CHANGED
@@ -180,6 +180,7 @@ import { isDirectChatProvider, isSubscriptionChatProvider, priceOf, PRICED_MODEL
180
180
  import { resolvePhaseAgent, resolveScopeProfile, resolveScopeChain, resolveRouteCandidates, routeOfTask, INSTALLATION_SCOPE } from "./agentconfig.js";
181
181
  import { isRiskLevel, legOf, projectRoute, riskConsequence, routeDigestOf, routeWords, RISK_LEVELS, PHASES as ROUTE_PHASES } from "./phase-routing.js";
182
182
  import { observeProviderReadiness, reportProviderReadinessAuthed } from "./runner.js";
183
+ import { parseDemoUrl, projectDemoUrl, saveProjectDemo } from "./project-demo.js";
183
184
  import { effectiveConcurrency, maySlotTake, parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, ProjectPasses, projectConcurrency, saveProjectConcurrency, savedProjectConcurrency } from "./project-concurrency.js";
184
185
  import { clearWebhook, effectivePrimary, isMessagingChannel, loadConsoleUrl, loadPrimary, loadWebhookTargets, phoneOrigin, saveConsoleUrl, savePrimary, saveWebhook, webhookPass, SLACK_ENV, DISCORD_ENV } from "./webhooks.js";
185
186
  import { auditOf, inspectionOf, isProviderId, MONEY_CAPABILITIES, PROVIDER_IDS, validModelId, validateSpec, ALL_CREDENTIAL_ENV } from "./provider.js";
@@ -233,6 +234,9 @@ export const OPERATE_HELP = `toolroll — operating the queue
233
234
  toolroll project concurrency [<n>] --repo <p>
234
235
  how many of a project's tasks build at once (default 2, never past the
235
236
  worker's capacity); an approver changes it with <n>
237
+ toolroll project demo [<url>|off] --repo <p>
238
+ the project's own demo or dev server, the one non-public address a
239
+ report's scout may screenshot
236
240
  toolroll task evidence <id> the task's evidence pack as JSON (--html for a printable page; --out <file>)
237
241
  toolroll storage where the disk goes: database, build checkouts, releases, evidence, leftover test temp folders
238
242
  toolroll storage clean [--yes] preview removing finished tasks' clean checkouts (their branches stay) and test temp folders older than a day; --yes removes them
@@ -971,6 +975,8 @@ async function dispatch(command, positional, flags, context) {
971
975
  return projectDeleteCommand(positional, flags, context);
972
976
  if (positional[0] === "concurrency")
973
977
  return projectConcurrencyCommand(positional, flags, context);
978
+ if (positional[0] === "demo")
979
+ return projectDemoCommand(positional, flags, context);
974
980
  return runProjectCommand(positional, flags, context);
975
981
  case "assignment":
976
982
  // The person's own lead (its lead token) claims as the lead; coordinator credentials keep their own path.
@@ -3465,6 +3471,7 @@ async function tickCommand(flags, context) {
3465
3471
  onProviderSpawn: pid => { worktrees.recordProviderOccupancy(scoutLeased.worktree.path, runner, pid, scoutLeased.worktree.leaseEpoch); },
3466
3472
  evidenceRoot: context.evidenceRoot,
3467
3473
  answers: scoutAnswers,
3474
+ demoUrl: projectDemoUrl(context.databaseFile, repo),
3468
3475
  provider: spec.provider,
3469
3476
  model: proof.effective.model,
3470
3477
  ...(proof.effective.maxTurns === undefined ? {} : { maxTurns: proof.effective.maxTurns }),
@@ -12024,6 +12031,40 @@ async function projectConcurrencyCommand(positional, flags, context) {
12024
12031
  ...(changed.before > changed.after ? ["Builds already running carry on; the new number applies to the next one."] : []),
12025
12032
  ]);
12026
12033
  }
12034
+ /** `project demo [<url>|off] --repo <p>` (review 827): the project's own demo or dev server, which its scouts may
12035
+ * screenshot. Changing it is an approver's act. */
12036
+ async function projectDemoCommand(positional, flags, context) {
12037
+ const command = "project demo";
12038
+ const allowed = new Set(["repo", "as", "token", "token-file", "token-env", "db", "json"]);
12039
+ for (const name of flags.keys())
12040
+ if (!allowed.has(name))
12041
+ return fail(context.write, context.json, command, "usage", `--${name} is not a project demo option.`, EXIT.usage);
12042
+ const repoFlag = text(flags, "repo");
12043
+ if (positional.length > 2 || repoFlag === undefined)
12044
+ return fail(context.write, context.json, command, "usage", "Use project demo [<url>|off] --repo <project path>.", EXIT.usage);
12045
+ const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
12046
+ const known = [...new Set([...context.store.knownRepos(), ...("error" in registered ? [] : registered.repos)])];
12047
+ const repo = known.find(one => one === repoFlag || one === resolve(repoFlag) || one === canonicalProject(repoFlag));
12048
+ if (repo === undefined)
12049
+ return fail(context.write, context.json, command, "not-found", "That isn't a project Toolroll knows.", EXIT.refused);
12050
+ const words = (url) => url === null ? `${repo}: no demo; scouts screenshot public pages only.` : `${repo}: scouts may also screenshot ${url}.`;
12051
+ const given = positional[1];
12052
+ if (given === undefined) {
12053
+ const current = projectDemoUrl(context.databaseFile, repo);
12054
+ return succeed(context.write, context.json, command, { repo, demoUrl: current }, () => [words(current)]);
12055
+ }
12056
+ const url = given.trim().toLowerCase() === "off" ? null : parseDemoUrl(given);
12057
+ if (url === null && given.trim().toLowerCase() !== "off")
12058
+ return fail(context.write, context.json, command, "usage", "The demo is an http or https address without a sign-in, or off.", EXIT.usage);
12059
+ const acting = await askCredentials(flags, context);
12060
+ const verified = acting === null ? null : authenticateApprover(context.store, acting.name, acting.token);
12061
+ if (acting === null || verified === null || !verified.ok || !context.store.accountCanAccess(acting.name, repo)) {
12062
+ return fail(context.write, context.json, command, "refused", "An approver for this project sets its demo: pass --as and --token (or use the remembered login).", EXIT.refused);
12063
+ }
12064
+ const changed = saveProjectDemo(context.databaseFile, repo, url);
12065
+ context.store.recordProjectDemo(acting.name, repo, changed.before, changed.after, context.clock());
12066
+ return succeed(context.write, context.json, command, { repo, before: changed.before, demoUrl: changed.after }, () => [words(changed.after)]);
12067
+ }
12027
12068
  /** Write a pack or export where asked (never over an existing file), or to the terminal. */
12028
12069
  function writeOut(context, command, out, content, summary, line, data) {
12029
12070
  if (out === undefined) {
@@ -11,6 +11,8 @@ export type PlaneProblem = {
11
11
  evidence: string[];
12
12
  };
13
13
  export declare const REVIEW_HOURS = 24;
14
+ /** A person's wait this long is mentioned in the review's summary line. */
15
+ export declare const LONG_WAIT_DAYS = 3;
14
16
  export declare const RUN_CAUSES: readonly ["provider", "sign-in", "check", "timeout", "no-handoff", "stuck-lease", "orphaned", "no-change", "other"];
15
17
  export type RunCause = (typeof RUN_CAUSES)[number];
16
18
  /** Anything shaped like a key is hidden; one short line. */
@@ -30,6 +32,19 @@ export declare function runCause(run: {
30
32
  * project counts only when every project may be seen).
31
33
  */
32
34
  export declare function reviewPlane(store: Store, now: Date, canSee?: (repo: string | null) => boolean): PlaneProblem[];
35
+ /**
36
+ * Results nobody has opened, and tasks waiting on a person's answer, approval or hold, for over `LONG_WAIT_DAYS`
37
+ * before `now`. Counted for the review's summary line only; they are never problems.
38
+ */
39
+ export declare function longPersonWaits(store: Store, now: Date, canSee?: (repo: string | null) => boolean): {
40
+ results: number;
41
+ tasks: number;
42
+ };
43
+ /** "3 results have waited over 3 days for you." — or null when nothing has. */
44
+ export declare function longWaitWords(waits: {
45
+ results: number;
46
+ tasks: number;
47
+ }): string | null;
33
48
  /** A problem as a card's details (or a day's note on a card it joins). */
34
49
  export declare function problemText(problem: PlaneProblem, day: string): string;
35
50
  /** The card's opening details: the day's evidence, and what it is. */
@@ -6,7 +6,9 @@
6
6
  * - failed or no-change runs, grouped by cause (provider error, sign-in
7
7
  * expiry, check failure, timeout, quit without handoff, stuck lease,
8
8
  * orphaned process, no change, other);
9
- * - tasks waiting on a person for over a day, grouped by what they wait for;
9
+ * - tasks stuck for over a day on something the system must fix (unfinished
10
+ * work no worker holds, repeated failures, an incident hold), grouped by
11
+ * what they wait for;
10
12
  * - sign-in and plan-limit pauses, per provider;
11
13
  * - chat replies that couldn't be delivered, per app;
12
14
  * - integrations that are Broken (integration_check);
@@ -17,6 +19,13 @@
17
19
  * problem (flow-triggers.ts joins it to its card), and carries counts, run
18
20
  * ids and short evidence excerpts with anything shaped like a secret hidden.
19
21
  * A clean day is no problems at all. Only projects the reader may see count.
22
+ *
23
+ * Waits only a person can end — a result to open, a question to answer, a
24
+ * plan or task to approve, a hold to lift, a scope to write — are never
25
+ * problems: each card starts a find-the-cause → fix → pull request flow, and
26
+ * no code change opens a result for someone. Needs you and chat already ask
27
+ * the person. `longPersonWaits` counts the ones over `LONG_WAIT_DAYS` so the
28
+ * review's own summary line can mention them once, filing nothing.
20
29
  */
21
30
  import { scanForSecrets } from "./evidence.js";
22
31
  import { scrubIntegrationText } from "./integrations.js";
@@ -24,8 +33,10 @@ import { taskWaitSnapshot } from "./lead-status.js";
24
33
  export const REVIEW_HOURS = 24;
25
34
  const EVIDENCE_LINES = 5;
26
35
  const WAITING_DAYS = 1;
27
- /** A Ready result older than this is history, not today's problem. */
28
- const READY_WINDOW_DAYS = 7;
36
+ /** A person's wait this long is mentioned in the review's summary line. */
37
+ export const LONG_WAIT_DAYS = 3;
38
+ /** What a person, not the code, must do next (lead-status.ts `taskWaitSnapshot`). The rest have a system cause. */
39
+ const PERSON_NEXT = new Set(["Open result", "Answer the task's question", "Answer question", "Remove hold", "Review plan", "Resume or close attempt", "Choose a result", "Approve plan", "Add scope", "Approve task"]);
29
40
  export const RUN_CAUSES = ["provider", "sign-in", "check", "timeout", "no-handoff", "stuck-lease", "orphaned", "no-change", "other"];
30
41
  const CAUSE_TITLES = {
31
42
  provider: "Runs failed on a provider error",
@@ -140,24 +151,12 @@ export function reviewPlane(store, now, canSee = () => true) {
140
151
  const run = Number(row["run"]);
141
152
  add("run/orphaned", CAUSE_TITLES.orphaned, ["run", "runs"], { run, evidence: `Run ${run} (task ${str(row["task"]) ?? "?"}): process ${str(row["pid"]) ?? "?"} on ${excerpt(str(row["host"]), 40)} was still running after it finished` });
142
153
  }
143
- // Tasks waiting on a person for over a day, by what they wait for.
144
- const waited = new Date(now.getTime() - WAITING_DAYS * 86_400_000).toISOString();
145
- const readyFrom = new Date(now.getTime() - READY_WINDOW_DAYS * 86_400_000).toISOString();
146
- const tasks = store.handle.prepare(`SELECT task.id, task.title, task.updated_at, ref.repo FROM task JOIN task_ref ref ON ref.backend = 'built-in' AND ref.external_id = task.id
147
- WHERE task.updated_at <= ? AND (task.state IN ('queued', 'running') OR (task.state = 'done' AND task.updated_at >= ?)) ORDER BY task.updated_at LIMIT 500`).all(waited, readyFrom);
148
- for (const row of tasks) {
149
- if (!canSee(str(row["repo"])))
150
- continue;
151
- // A version a later revision replaced never waits on its own.
152
- const family = store.taskFamilyOf(String(row["id"]), null, true);
153
- if (family !== null && family.current.id !== String(row["id"]))
154
- continue;
155
- const snapshot = taskWaitSnapshot(store, String(row["id"]), now);
156
- if (snapshot === null || (snapshot.reason !== "needs-person" && snapshot.reason !== "ready"))
154
+ // Tasks stuck for over a day on a system cause, by what they wait for. Waits on a person are never cards.
155
+ for (const wait of openWaits(store, new Date(now.getTime() - WAITING_DAYS * 86_400_000), now, canSee)) {
156
+ if (wait.person)
157
157
  continue;
158
- const days = Math.floor((now.getTime() - Date.parse(String(row["updated_at"]))) / 86_400_000);
159
- add(`waiting/${keyPart(snapshot.next)}`, `Tasks waited over a day: ${snapshot.next}`, ["task", "tasks"], { run: snapshot.run,
160
- evidence: `Task ${String(row["id"])} “${excerpt(str(row["title"]), 80)}”: ${plural(days, "day")} waiting` });
158
+ add(`waiting/${keyPart(wait.next)}`, `Tasks waited over a day: ${wait.next}`, ["task", "tasks"], { run: wait.run,
159
+ evidence: `Task ${wait.id} “${excerpt(wait.title, 80)}”: ${plural(wait.days, "day")} waiting` });
161
160
  }
162
161
  // Sign-in and plan-limit pauses.
163
162
  if (tableExists(store, "provider_auth_pause")) {
@@ -227,6 +226,49 @@ export function reviewPlane(store, now, canSee = () => true) {
227
226
  summary: `${plural(bucket.count, bucket.noun[0], bucket.noun[1])} in the last ${REVIEW_HOURS} hours${runsList.length === 0 ? "" : ` (${runsList.length === 1 ? "run" : "runs"} ${runsList.slice(0, 12).join(", ")}${runsList.length > 12 ? ", …" : ""})`}.` };
228
227
  }).sort((a, b) => b.count - a.count || a.key.localeCompare(b.key));
229
228
  }
229
+ /** Queued or running tasks last touched by `before` that wait, and on what; replaced versions never wait on their own. */
230
+ function openWaits(store, before, now, canSee) {
231
+ const rows = store.handle.prepare(`SELECT task.id, task.title, task.updated_at, ref.repo FROM task JOIN task_ref ref ON ref.backend = 'built-in' AND ref.external_id = task.id
232
+ WHERE task.updated_at <= ? AND task.state IN ('queued', 'running') ORDER BY task.updated_at LIMIT 500`).all(before.toISOString());
233
+ return waitsOf(store, rows, now, canSee);
234
+ }
235
+ function waitsOf(store, rows, now, canSee) {
236
+ const waits = [];
237
+ for (const row of rows) {
238
+ if (!canSee(str(row["repo"])))
239
+ continue;
240
+ const id = String(row["id"]);
241
+ const family = store.taskFamilyOf(id, null, true);
242
+ if (family !== null && family.current.id !== id)
243
+ continue;
244
+ const snapshot = taskWaitSnapshot(store, id, now);
245
+ if (snapshot === null || (snapshot.reason !== "needs-person" && snapshot.reason !== "ready"))
246
+ continue;
247
+ waits.push({ id, title: str(row["title"]), next: snapshot.next, run: snapshot.run, days: Math.floor((now.getTime() - Date.parse(String(row["updated_at"]))) / 86_400_000),
248
+ person: snapshot.reason === "ready" || PERSON_NEXT.has(snapshot.next) });
249
+ }
250
+ return waits;
251
+ }
252
+ /**
253
+ * Results nobody has opened, and tasks waiting on a person's answer, approval or hold, for over `LONG_WAIT_DAYS`
254
+ * before `now`. Counted for the review's summary line only; they are never problems.
255
+ */
256
+ export function longPersonWaits(store, now, canSee = () => true) {
257
+ const before = new Date(now.getTime() - LONG_WAIT_DAYS * 86_400_000);
258
+ // Newest first, so years of Complete work never crowds out this week's unopened results.
259
+ const finished = store.handle.prepare(`SELECT task.id, task.title, task.updated_at, ref.repo FROM task JOIN task_ref ref ON ref.backend = 'built-in' AND ref.external_id = task.id
260
+ WHERE task.updated_at <= ? AND task.state = 'done' ORDER BY task.updated_at DESC LIMIT 500`).all(before.toISOString());
261
+ const results = waitsOf(store, finished, now, canSee).filter(wait => wait.person).length;
262
+ const tasks = openWaits(store, before, now, canSee).filter(wait => wait.person).length;
263
+ return { results, tasks };
264
+ }
265
+ /** "3 results have waited over 3 days for you." — or null when nothing has. */
266
+ export function longWaitWords(waits) {
267
+ const parts = [waits.results > 0 ? plural(waits.results, "result") : null, waits.tasks > 0 ? plural(waits.tasks, "task") : null].filter(one => one !== null);
268
+ if (parts.length === 0)
269
+ return null;
270
+ return `${parts.join(" and ")} ${waits.results + waits.tasks === 1 ? "has" : "have"} waited over ${LONG_WAIT_DAYS} days for you.`;
271
+ }
230
272
  /** A problem as a card's details (or a day's note on a card it joins). */
231
273
  export function problemText(problem, day) {
232
274
  return [`${day}: ${problem.summary}`, ...problem.evidence.map(line => `- ${line}`)].join("\n");
@@ -216,6 +216,8 @@ function deleteRows(store, repo, d, now) {
216
216
  del(`${surface}_action`, IN("proposal"), proposals);
217
217
  del(`${surface}_flow_action`, IN("card"), C);
218
218
  del(`${surface}_flow_prompt`, IN("card"), C);
219
+ del(`${surface}_flow_choice`, IN("card"), C);
220
+ del(`${surface}_flow_note`, IN("card"), C);
219
221
  del(`${surface}_question_action`, IN("question"), questions);
220
222
  del(`${surface}_question_prompt`, IN("question"), questions);
221
223
  del(`${surface}_ask_action`, IN("turn"), U);
@@ -226,6 +228,8 @@ function deleteRows(store, repo, d, now) {
226
228
  del("telegram_proposal_action", IN("proposal"), proposals);
227
229
  del("telegram_flow_action", IN("card"), C);
228
230
  del("telegram_flow_prompt", IN("card"), C);
231
+ del("telegram_flow_choice", IN("card"), C);
232
+ del("flow_send", IN("card"), C);
229
233
  del("telegram_question_action", IN("question"), questions);
230
234
  del("telegram_question_prompt", IN("question"), questions);
231
235
  del("chat_decide_action", IN("task_id"), K);
@@ -0,0 +1,12 @@
1
+ export declare const projectDemoPath: (databaseFile: string) => string;
2
+ /** A demo URL as typed: an http(s) address with no sign-in details, at most 500 characters, or null. */
3
+ export declare function parseDemoUrl(value: string): string | null;
4
+ /** Every saved demo URL, by canonical project path. An unreadable file reads as none saved. */
5
+ export declare function savedProjectDemos(databaseFile: string): Map<string, string>;
6
+ /** The project's demo URL, or null when it has none. */
7
+ export declare function projectDemoUrl(databaseFile: string, repo: string): string | null;
8
+ /** Save (or, with null, clear) one project's demo URL atomically; returns it before and after. */
9
+ export declare function saveProjectDemo(databaseFile: string, repo: string, url: string | null): {
10
+ before: string | null;
11
+ after: string | null;
12
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A project's own demo or dev server (review 827): the one address a scout
3
+ * may reach that is not on the public internet, so it can take screenshots
4
+ * of the project's UI. An approver sets it with
5
+ * `toolroll project demo --repo <p> <url>|off`; it is kept in a small file
6
+ * beside the database, like a project's builds-at-once number, and recorded
7
+ * in the ledger as before → after. A project without one has none.
8
+ */
9
+ import { lstatSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { randomBytes } from "node:crypto";
11
+ import { dirname, join } from "node:path";
12
+ export const projectDemoPath = (databaseFile) => join(dirname(databaseFile), "project-demo.json");
13
+ /** A demo URL as typed: an http(s) address with no sign-in details, at most 500 characters, or null. */
14
+ export function parseDemoUrl(value) {
15
+ const typed = value.trim();
16
+ if (typed === "" || typed.length > 500)
17
+ return null;
18
+ try {
19
+ const url = new URL(typed);
20
+ if (url.protocol !== "http:" && url.protocol !== "https:")
21
+ return null;
22
+ if (url.username !== "" || url.password !== "")
23
+ return null;
24
+ return url.href;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ /** Every saved demo URL, by canonical project path. An unreadable file reads as none saved. */
31
+ export function savedProjectDemos(databaseFile) {
32
+ const saved = new Map();
33
+ const file = projectDemoPath(databaseFile);
34
+ try {
35
+ const stat = lstatSync(file, { throwIfNoEntry: false });
36
+ if (stat === undefined || !stat.isFile() || stat.size > 262_144)
37
+ return saved;
38
+ const value = JSON.parse(readFileSync(file, "utf8"));
39
+ const projects = value !== null && typeof value === "object" ? value.projects : undefined;
40
+ if (projects === null || typeof projects !== "object")
41
+ return saved;
42
+ for (const [repo, url] of Object.entries(projects)) {
43
+ const parsed = typeof url === "string" ? parseDemoUrl(url) : null;
44
+ if (parsed !== null)
45
+ saved.set(repo, parsed);
46
+ }
47
+ }
48
+ catch {
49
+ // A damaged file never stops a scout: it simply has no demo to open.
50
+ }
51
+ return saved;
52
+ }
53
+ /** The project's demo URL, or null when it has none. */
54
+ export function projectDemoUrl(databaseFile, repo) {
55
+ return savedProjectDemos(databaseFile).get(repo) ?? null;
56
+ }
57
+ /** Save (or, with null, clear) one project's demo URL atomically; returns it before and after. */
58
+ export function saveProjectDemo(databaseFile, repo, url) {
59
+ const saved = savedProjectDemos(databaseFile);
60
+ const before = saved.get(repo) ?? null;
61
+ if (url === null)
62
+ saved.delete(repo);
63
+ else
64
+ saved.set(repo, url);
65
+ const file = projectDemoPath(databaseFile);
66
+ const temp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
67
+ mkdirSync(dirname(file), { recursive: true });
68
+ writeFileSync(temp, `${JSON.stringify({ version: 1, projects: Object.fromEntries([...saved].sort(([a], [b]) => a.localeCompare(b))) }, null, 2)}\n`, { mode: 0o600, flag: "wx" });
69
+ renameSync(temp, file);
70
+ return { before, after: url };
71
+ }
@@ -68,6 +68,16 @@ export type Invocation = {
68
68
  * Codex renders it as its own sandbox profile; Claude as deny rules for
69
69
  * its file tools (its shell is fenced by the macOS sandbox around it). */
70
70
  fence?: readonly string[];
71
+ /** Claude's structured-output floor for a non-review phase: the scout's
72
+ * report rides the terminal result event instead of a file plan mode
73
+ * would refuse to write. Ignored by providers without `--json-schema`. */
74
+ jsonSchema?: Readonly<Record<string, unknown>>;
75
+ /** Claude tools allowed without asking, for a `dontAsk` launch: the scout's
76
+ * research and screenshot tools, and nothing that edits or runs commands. */
77
+ allowedTools?: readonly string[];
78
+ /** More MCP servers for this launch only, beside the project's own (the
79
+ * scout's headless browser). Claude-only; ignored for a review. */
80
+ extraMcpServers?: Readonly<Record<string, unknown>>;
71
81
  };
72
82
  export type ProviderRunner = (file: string, args: readonly string[], options?: RunOptions) => Promise<ExecResult>;
73
83
  /** The harness's own account of how a turn ended. */
@@ -86,6 +96,10 @@ export type ParsedEnvelope = {
86
96
  protocolError: string | null;
87
97
  /** The agent's spoken conclusion — diagnostics only, never the handoff. */
88
98
  finalMessage: string | null;
99
+ /** Claude's schema-validated `structured_output` alone, re-serialized —
100
+ * never the prose result, even when that prose is JSON. Absent or null
101
+ * when the turn ran without `--json-schema` or returned none. */
102
+ structuredOutput?: string | null;
89
103
  /** How the harness said the turn ended (claude: the result's subtype and
90
104
  * turn count) — absent on dialects that carry no such record. */
91
105
  ending?: AgentEnding | null;
package/dist/provider.js CHANGED
@@ -229,7 +229,11 @@ const claudeArgv = (invocation) => [
229
229
  // Headless (run 2085): a wakeup, cron job or monitor needs a later
230
230
  // turn that a -p process never gets. Denied at the harness too.
231
231
  "--disallowedTools", HEADLESS_DISALLOWED_TOOLS.join(","),
232
- ...(invocation.toolArgv ?? []), ...(invocation.fence !== undefined && invocation.fence.length > 0 ? ["--settings", claudeFenceSettings(invocation.fence)] : [])
232
+ ...(invocation.allowedTools === undefined || invocation.allowedTools.length === 0 ? [] : ["--allowedTools", invocation.allowedTools.join(",")]),
233
+ ...(invocation.toolArgv ?? []),
234
+ ...(invocation.extraMcpServers === undefined ? [] : ["--mcp-config", JSON.stringify({ mcpServers: invocation.extraMcpServers })]),
235
+ ...(invocation.fence !== undefined && invocation.fence.length > 0 ? ["--settings", claudeFenceSettings(invocation.fence)] : []),
236
+ ...(invocation.jsonSchema === undefined ? [] : ["--json-schema", JSON.stringify(invocation.jsonSchema)])
233
237
  ]),
234
238
  ];
235
239
  /**
@@ -276,6 +280,9 @@ function claudeEnvelopeOf(result, sessionFromInit, initObserved, initSessionConf
276
280
  ? "the Claude init event and terminal result announced different session ids"
277
281
  : null,
278
282
  finalMessage: claudeFinalMessage(result),
283
+ structuredOutput: result === null || result.structured_output === undefined || result.structured_output === null
284
+ ? null
285
+ : JSON.stringify(result.structured_output),
279
286
  ending: result === null ? null : {
280
287
  subtype: typeof result.subtype === "string" ? result.subtype : null,
281
288
  turns: typeof result.num_turns === "number" && result.num_turns >= 0 ? result.num_turns : null,
@@ -1,4 +1,5 @@
1
1
  import type { Store } from "./store.js";
2
+ import type { ParsedReport } from "./scout-report.js";
2
3
  /** The report a scout delivered, for get_task: title, summary, follow-ups —
3
4
  * verified before it is read; the document itself stays on the task page. */
4
5
  export declare function reportSummaryFor(store: Store, evidenceRoot: string | undefined, taskRef: number): {
@@ -11,3 +12,11 @@ export declare function reportSummaryFor(store: Store, evidenceRoot: string | un
11
12
  } | {
12
13
  problem: string;
13
14
  } | null;
15
+ /** The longest full report a later flow step is given through {{stage.<id>.report}}. */
16
+ export declare const REPORT_FILL_LIMIT = 20000;
17
+ /** What a report zone gives the zones after it besides its summary: its items as a numbered list (title, why, URL)
18
+ * and the report's markdown, capped. Empty strings when it has neither. */
19
+ export declare function reportFillIns(report: Pick<ParsedReport, "items" | "report">): {
20
+ items: string;
21
+ report: string;
22
+ };
@@ -13,3 +13,12 @@ export function reportSummaryFor(store, evidenceRoot, taskRef) {
13
13
  return { problem: view.problem };
14
14
  return { title: view.report.title, summary: view.report.summary, followUps: view.report.followUps };
15
15
  }
16
+ /** The longest full report a later flow step is given through {{stage.<id>.report}}. */
17
+ export const REPORT_FILL_LIMIT = 20_000;
18
+ /** What a report zone gives the zones after it besides its summary: its items as a numbered list (title, why, URL)
19
+ * and the report's markdown, capped. Empty strings when it has neither. */
20
+ export function reportFillIns(report) {
21
+ const items = report.items.map((one, index) => `${index + 1}. ${one.title}\n ${one.why.replace(/\s*\n\s*/g, " ")}\n ${one.url}`).join("\n");
22
+ const document = report.report.length <= REPORT_FILL_LIMIT ? report.report : `${report.report.slice(0, REPORT_FILL_LIMIT)}\n… (the rest of the report is on its task)`;
23
+ return { items, report: document };
24
+ }
@@ -39,6 +39,8 @@ export type ResultShotsPlan =
39
39
  * no wait in between, or asks again.
40
40
  */
41
41
  export declare function resultShotsFor(store: Store, evidenceRoot: string | undefined, repos: readonly string[], row: Pick<Notification, "kind" | "recipient" | "taskId" | "taskRef" | "run">, sent?: ReadonlySet<number>): ResultShotsPlan;
42
+ /** A row that carries a result's screenshots: one a person asked for with results, or one a flow sends them. */
43
+ export declare function isShotsKind(kind: string): boolean;
42
44
  /** A screenshot whose result message is not posted yet waits for that message's own next try (at least a second). */
43
45
  export declare function shotWaitsUntil(resultNextAt: unknown, now: Date): string;
44
46
  /** Retention removed this result's files: its screenshots go quietly, never as a complaint. */