toolroll 0.8.2 → 0.9.1

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 (68) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/agent-onboard.d.ts +24 -1
  3. package/dist/agent-onboard.js +53 -1
  4. package/dist/backup-ui.js +4 -2
  5. package/dist/browser/workspace.css +2 -2
  6. package/dist/browser/workspace.js +33 -32
  7. package/dist/browser-workspace.d.ts +22 -2
  8. package/dist/chat-actions.d.ts +5 -0
  9. package/dist/chat-actions.js +43 -3
  10. package/dist/chat-delivery.js +51 -3
  11. package/dist/chat-quiet.d.ts +28 -0
  12. package/dist/chat-quiet.js +161 -0
  13. package/dist/cli.d.ts +1 -1
  14. package/dist/cli.js +4 -1
  15. package/dist/desktop-update.d.ts +1 -1
  16. package/dist/digest-times.d.ts +3 -0
  17. package/dist/digest-times.js +7 -0
  18. package/dist/dispose.js +2 -0
  19. package/dist/export.js +1 -1
  20. package/dist/flow-engine.js +13 -1
  21. package/dist/flow-pull-request.d.ts +11 -0
  22. package/dist/flow-pull-request.js +123 -0
  23. package/dist/flow-starters-ui.d.ts +20 -0
  24. package/dist/flow-starters-ui.js +35 -0
  25. package/dist/flow-starters.d.ts +55 -0
  26. package/dist/flow-starters.js +104 -0
  27. package/dist/flow-steps.js +14 -0
  28. package/dist/flows-ui.d.ts +1 -1
  29. package/dist/flows-ui.js +4 -3
  30. package/dist/flows.d.ts +30 -6
  31. package/dist/flows.js +110 -6
  32. package/dist/integrations-ui.d.ts +13 -0
  33. package/dist/integrations-ui.js +70 -0
  34. package/dist/integrations.d.ts +113 -0
  35. package/dist/integrations.js +526 -0
  36. package/dist/lead-context.d.ts +1 -1
  37. package/dist/lead-context.js +1 -1
  38. package/dist/mate-contract.js +1 -1
  39. package/dist/mate-tools.js +18 -5
  40. package/dist/models-ui.js +3 -2
  41. package/dist/monitoring-ui.js +2 -1
  42. package/dist/operate.d.ts +5 -2
  43. package/dist/operate.js +169 -4
  44. package/dist/policy-ui.js +2 -1
  45. package/dist/project-delete.js +1 -0
  46. package/dist/publish.d.ts +11 -0
  47. package/dist/publish.js +9 -2
  48. package/dist/pull-request-flow.d.ts +172 -0
  49. package/dist/pull-request-flow.js +475 -0
  50. package/dist/pull-request-schema.d.ts +5 -0
  51. package/dist/pull-request-schema.js +21 -0
  52. package/dist/pull-request-ui.d.ts +23 -0
  53. package/dist/pull-request-ui.js +55 -0
  54. package/dist/pulls.d.ts +5 -0
  55. package/dist/pulls.js +11 -0
  56. package/dist/serve.d.ts +6 -0
  57. package/dist/serve.js +371 -58
  58. package/dist/setup-guide.d.ts +2 -2
  59. package/dist/skills-ui.js +2 -1
  60. package/dist/store.d.ts +61 -0
  61. package/dist/store.js +144 -3
  62. package/dist/surface.js +3 -1
  63. package/dist/telegram-progress.d.ts +1 -0
  64. package/dist/telegram-progress.js +1 -1
  65. package/dist/telegram.js +95 -15
  66. package/dist/when-html.d.ts +8 -0
  67. package/dist/when-html.js +36 -0
  68. package/package.json +1 -1
@@ -201,6 +201,11 @@ export type BrowserSettingsView = {
201
201
  removable: boolean;
202
202
  }[];
203
203
  } | null;
204
+ /** Quiet chat: how chats reach this person, and their evening digest time (null: off). */
205
+ chat?: {
206
+ mode: 'quiet' | 'all';
207
+ digestAt: string | null;
208
+ } | null;
204
209
  digest: {
205
210
  every: string;
206
211
  held: string | null;
@@ -317,6 +322,11 @@ export type BrowserTaskView = {
317
322
  html: string;
318
323
  open: boolean;
319
324
  } | null;
325
+ /** "Do this every time…": Settings → Flows with the matching starter flow marked. */
326
+ everyTime?: {
327
+ href: string;
328
+ starter: string;
329
+ } | null;
320
330
  };
321
331
  /** One project on the Projects page; opening it is a POST to /projects/open. */
322
332
  export type BrowserProjectRow = {
@@ -327,6 +337,11 @@ export type BrowserProjectRow = {
327
337
  /** When it was last opened here; null for a project only seen in the queue. */
328
338
  openedAt: string | null;
329
339
  knowledgeHref: string;
340
+ /** Settings → Projects → Pull requests for this project, and whether they are on. */
341
+ pullRequests?: {
342
+ href: string;
343
+ on: boolean;
344
+ };
330
345
  /** What waits, runs, queues or finished today; null when not scanned. */
331
346
  peek: {
332
347
  label: string;
@@ -457,7 +472,7 @@ export type BrowserResultView = {
457
472
  export type BrowserFlowStage = {
458
473
  id: string;
459
474
  title: string;
460
- kind: "inbox" | "task" | "report" | "approval" | "check" | "update" | "notify" | "sort" | "draft" | "request" | "email" | "tool" | "wait" | "teammate" | "done";
475
+ kind: "inbox" | "task" | "report" | "approval" | "check" | "pull-request" | "update" | "notify" | "sort" | "draft" | "request" | "email" | "tool" | "wait" | "teammate" | "done";
461
476
  zone: {
462
477
  x: number;
463
478
  y: number;
@@ -515,9 +530,14 @@ export type BrowserFlowStage = {
515
530
  } | null;
516
531
  /** v91: a Wait zone waits for a reply to the card's email (onFail: no reply in time) or a set time; any other zone may have a time limit. */
517
532
  wait?: {
518
- for: "reply" | "time";
533
+ for: "reply" | "time" | "hours";
519
534
  minutes: number;
535
+ from?: string;
536
+ to?: string;
537
+ timeZone?: string;
520
538
  };
539
+ /** A Pull request zone merges once checks pass (after a person approved), this way. */
540
+ merge?: "squash" | "merge" | "rebase" | undefined;
521
541
  limit?: {
522
542
  minutes: number;
523
543
  to: string | null;
@@ -61,6 +61,11 @@ export declare const CHAT_ACTIONS: {
61
61
  readonly protected: false;
62
62
  readonly password: false;
63
63
  };
64
+ readonly flow_starter: {
65
+ readonly label: "Switch on";
66
+ readonly protected: false;
67
+ readonly password: false;
68
+ };
64
69
  readonly flow_edit: {
65
70
  readonly label: "Save flow";
66
71
  readonly protected: false;
@@ -28,6 +28,7 @@ import { cleanMemory, editMemory, forgetMemory, tellTeammate } from "./teammate-
28
28
  import { addRoutine, removeRoutine, routineSchedule, routinesOf } from "./teammate-desk.js";
29
29
  import { requestUndo, undoFor } from "./teammate-week.js";
30
30
  import { kitInstalled, kitOf, KITS, setUpKitNow } from "./kits.js";
31
+ import { starterFlowOf, starterOf, startersFor, starterTerms, STARTER_FLOWS, switchOnStarter } from "./flow-starters.js";
31
32
  import { answerTeammateQuestion } from "./teammate-work.js";
32
33
  import { callWords, checkRule, defaultRule, grantListed, revokeTool, ruleWords, setToolRules } from "./teammate-tools.js";
33
34
  /** A tool from the lead's card: a common tool by its id, or the operator's own program or address. */
@@ -77,6 +78,8 @@ export const CHAT_ACTIONS = {
77
78
  tool_remove: { label: "Remove tool", protected: false, password: false },
78
79
  // Flows: a drawing and cards on it. Any work a card files is an ordinary task under the usual approvals.
79
80
  flow_create: { label: "Create flow", protected: false, password: false },
81
+ // A starter flow: its zones and its trigger, switched on with one yes (flow-starters.ts).
82
+ flow_starter: { label: "Switch on", protected: false, password: false },
80
83
  flow_edit: { label: "Save flow", protected: false, password: false },
81
84
  flow_card_add: { label: "Add card", protected: false, password: false },
82
85
  flow_card_move: { label: "Move card", protected: false, password: false },
@@ -128,7 +131,8 @@ export const CHAT_ACTION_FIELDS = {
128
131
  knowledge_restore: ["repo", "restore"],
129
132
  tool_add: ["repo", "catalog", "name", "command", "args", "url", "secrets", "about"],
130
133
  tool_remove: ["repo", "name"],
131
- flow_create: ["repo", "name", "definition"],
134
+ flow_create: ["repo", "name", "definition", "trigger"],
135
+ flow_starter: ["repo", "starter"],
132
136
  flow_edit: ["flow", "name", "definition"],
133
137
  flow_card_add: ["flow", "title", "description", "zone"],
134
138
  flow_card_move: ["card", "zone"],
@@ -265,7 +269,7 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
265
269
  const task = operation.startsWith("skill_") || operation.startsWith("knowledge_") || operation.startsWith("decision_") || operation.startsWith("tool_") || operation.startsWith("flow_") || operation.startsWith("teammate_") || operation.startsWith("kit_")
266
270
  ? null
267
271
  : text(input, "task", 64);
268
- const flowTarget = operation.startsWith("flow_") && operation !== "flow_create" && operation !== "flow_script_save" ? flowTargetOf(store, input) : null;
272
+ const flowTarget = operation.startsWith("flow_") && operation !== "flow_create" && operation !== "flow_script_save" && operation !== "flow_starter" ? flowTargetOf(store, input) : null;
269
273
  // v92: a teammate names its project; a question names it through its card's flow.
270
274
  const mateTarget = operation.startsWith("teammate_") && operation !== "teammate_create" && operation !== "teammate_answer" ? store.getTeammate(integer(input, "teammate")) : null;
271
275
  if (mateTarget !== null && mateTarget.state === "removed")
@@ -551,6 +555,28 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
551
555
  state = {};
552
556
  title = `Create the ${name} flow in ${project}`;
553
557
  terms.push(...flowTerms(definition, null));
558
+ // A template's trigger (Issues to PRs: labelled issues) comes with it, checked as the flow will have it.
559
+ if (input["trigger"] !== undefined) {
560
+ const draft = { id: 0, repo, name, definitionJson: JSON.stringify(definition), revision: 1, state: "active", createdBy: who.name, createdAt: now.toISOString(), updatedBy: who.name, updatedAt: now.toISOString(), owner: who.name };
561
+ const config = validateTriggerConfig(input["trigger"], { store, flow: draft, definition, actor: who.name });
562
+ if (takesDeliveries(config))
563
+ throw Error("Webhook addresses are secrets, so they are set up on the flow's Triggers panel.");
564
+ request["trigger"] = config;
565
+ terms.push(`Starts cards from: ${describeTrigger(config, store)}. Only what happens from now on counts.`);
566
+ }
567
+ }
568
+ else if (operation === "flow_starter") {
569
+ const starter = starterOf(String(input["starter"] ?? ""));
570
+ if (starter === null)
571
+ throw Error(`Choose a starter flow: ${STARTER_FLOWS.map(one => one.id).join(", ")}.`);
572
+ if (starterFlowOf(store, starter, repo) !== null)
573
+ throw Error(`${starter.name} is already on in ${project}.`);
574
+ const blocked = startersFor(store, repo).find(one => one.id === starter.id)?.blocked ?? null;
575
+ if (blocked !== null)
576
+ throw Error(blocked);
577
+ state = {};
578
+ title = `Switch on ${starter.name} in ${project}`;
579
+ terms.push(...starterTerms(store, starter, repo));
554
580
  }
555
581
  else if (operation === "flow_edit") {
556
582
  const { flow, definition: before } = flowTarget;
@@ -1214,9 +1240,23 @@ function runFlowAction(store, payload, actor, repos, root, now) {
1214
1240
  return { said: `${said}${filed}`, href: card === null ? `/flows/${flow}` : flowCardHref(flow, card) };
1215
1241
  };
1216
1242
  if (payload.operation === "flow_create") {
1217
- const id = store.createFlow({ repo: payload.repo, name: String(req["name"]), definitionJson: JSON.stringify(req["definition"]), by: actor }, now);
1243
+ const id = store.transact(() => {
1244
+ const made = store.createFlow({ repo: payload.repo, name: String(req["name"]), definitionJson: JSON.stringify(req["definition"]), by: actor }, now);
1245
+ if (req["trigger"] !== undefined) {
1246
+ const trigger = addFlowTriggerTo(store, store.getFlow(made), req["trigger"], actor, now, null);
1247
+ if (!trigger.ok)
1248
+ throw Error(trigger.message);
1249
+ }
1250
+ return made;
1251
+ });
1218
1252
  return { said: "Flow created. Add cards to it here or on its canvas.", href: `/flows/${id}` };
1219
1253
  }
1254
+ if (payload.operation === "flow_starter") {
1255
+ const switched = switchOnStarter(store, starterOf(String(req["starter"])), payload.repo, actor, now, configDirOf(store));
1256
+ if (!switched.ok)
1257
+ throw Error(switched.said);
1258
+ return { said: switched.said, href: `/flows/${switched.flow}` };
1259
+ }
1220
1260
  if (payload.operation === "flow_edit") {
1221
1261
  const flow = Number(payload.state["flow"]);
1222
1262
  if (!store.saveFlow(flow, { name: String(req["name"]), definitionJson: JSON.stringify(req["definition"]), sawRevision: Number(payload.state["revision"]), by: actor }, now))
@@ -9,6 +9,7 @@ import { answerChatFlowPrompt, applyChatFlowTap, flowDecisionParts } from "./cha
9
9
  import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
10
10
  import { triggerConfigOf } from "./flow-triggers.js";
11
11
  import { telegramProgressCard } from "./telegram-progress.js";
12
+ import { enqueueEveningDigests, isTaskFact, needsPerson, quietCardView, readyPingView } from "./chat-quiet.js";
12
13
  import { phoneText, PHONE_HELP, phoneCommand, phoneStatus, phoneTaskView, phoneTaskChoices, phoneTaskListText, resolvePhoneTask, phoneFocusText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
13
14
  import { applyRoomInbound, conversationRow, roomCardApprover, roomCommand, roomGrantAllowed, roomMessagesAfter, roomMessageText, teamDomain } from "./chat-rooms.js";
14
15
  import { isTelegramProgressNotification, proposalTaskOf } from "./store.js";
@@ -574,6 +575,33 @@ export async function planRoomMessages(options) {
574
575
  }
575
576
  }
576
577
  }
578
+ /** Quiet mode: the task's one card (or its group's) in this person's chat, planned once and then repainted in
579
+ * place whenever what it says changes. */
580
+ function planQuietCard(options, binding, notification, now) {
581
+ const { state, store, identity } = options;
582
+ if (notification.taskRef === null || notification.taskId === null)
583
+ return;
584
+ const card = store.chatCardFor(`${state.channel}:${binding.id}`, notification.taskRef, store.getTask(notification.taskId)?.createdAt ?? notification.createdAt, now);
585
+ const view = quietCardView(store, card.tasks, now, options.evidenceRoot);
586
+ if (view === null)
587
+ return;
588
+ const content = { text: view.text, link: view.link, ...(card.tasks.length === 1 ? { task: notification.taskId } : {}) };
589
+ const shown = chatHash(JSON.stringify(content));
590
+ if (card.message !== null) {
591
+ if (card.digest === shown)
592
+ return;
593
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',created=?,next_at=NULL WHERE id=? AND state!='dropped'").run(JSON.stringify(content), now.toISOString(), Number(card.message));
594
+ store.setChatCardMessage(card.id, card.message, shown);
595
+ return;
596
+ }
597
+ const id = chatHash(`${state.channel}:card:${binding.id}:${card.id}`);
598
+ state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
599
+ ts: "", thread: "", payload: "{}", created: now.toISOString() });
600
+ state.plan(id, [content], now);
601
+ const part = state.prepare("SELECT id FROM chat_part WHERE event=?").get(id);
602
+ if (part !== undefined)
603
+ store.setChatCardMessage(card.id, String(part.id), shown);
604
+ }
577
605
  /** One progress card per exact result; separate urgent facts retain their own review link. */
578
606
  export async function planChatNotifications(options) {
579
607
  const state = options.state, { store, identity } = options, bindings = state.bindings(identity.installation).filter(one => state.live(one));
@@ -582,6 +610,11 @@ export async function planChatNotifications(options) {
582
610
  !options.current())
583
611
  return;
584
612
  const registry = await options.readProjects();
613
+ // An evening digest someone asked for is due: record it as their own notification, delivered below.
614
+ try {
615
+ enqueueEveningDigests(store, nowOf(options), options.evidenceRoot);
616
+ }
617
+ catch { /* Retried on the next pass. */ }
585
618
  const cursor = Number(state
586
619
  .prepare("SELECT notification FROM chat_runtime WHERE installation=?")
587
620
  .get(identity.installation)?.notification ?? 0);
@@ -602,7 +635,22 @@ export async function planChatNotifications(options) {
602
635
  : (notification.taskId || notification.kind === "flow-decision") && notification.project !== null && repos.includes(notification.project))) {
603
636
  const run = store.telegramProgressRun(notification);
604
637
  const id = chatHash(`${options.state.channel}:notice:${binding.id}:${notification.id}`), now = nowOf(options);
605
- if (run && notification.taskId && notification.project !== null && isTelegramProgressNotification(notification)) {
638
+ // Only when I'm needed (the default): the task's one card is edited in place, and a new message
639
+ // follows only when this person is needed. Every step keeps the branches below unchanged.
640
+ const quiet = !personal && isTaskFact(notification) && store.notificationPreference(binding.approver).mode === "quiet";
641
+ if (quiet) {
642
+ planQuietCard(options, binding, notification, now);
643
+ if (!needsPerson(notification))
644
+ return;
645
+ if (notification.kind === "run-finished" && run) {
646
+ const ping = readyPingView(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot);
647
+ state.enqueue({ id, installation: identity.installation, binding: binding.id, kind: "notice", channel: binding.channel, member: binding.member,
648
+ ts: "", thread: "", payload: "{}", created: now.toISOString() });
649
+ state.plan(id, [{ text: ping.text, task: notification.taskId, run: run.id, link: ping.link }], now);
650
+ return;
651
+ }
652
+ }
653
+ if (!quiet && run && notification.taskId && notification.project !== null && isTelegramProgressNotification(notification)) {
606
654
  const card = telegramProgressCard(store, store.getRun(run.id), notification.taskId, notification.project, now, options.evidenceRoot);
607
655
  const content = {
608
656
  text: card.text,
@@ -661,7 +709,7 @@ export async function planChatNotifications(options) {
661
709
  ts: "", thread: "", payload: "{}", created: now.toISOString() });
662
710
  state.plan(id, questionParts(store, notification, binding), now);
663
711
  }
664
- else if (notification.pushClass !== null || personal) {
712
+ else if (notification.pushClass !== null || personal || quiet) {
665
713
  state.enqueue({
666
714
  id,
667
715
  installation: identity.installation,
@@ -680,7 +728,7 @@ export async function planChatNotifications(options) {
680
728
  ...(notification.taskId ? { task: notification.taskId } : {}),
681
729
  ...(run ? { run: run.id } : {}),
682
730
  ...(notification.link
683
- ? { link: { label: personal ? "Open" : "Review", path: notification.link } }
731
+ ? { link: { label: notification.kind === "pull-request-ready" ? "Merge" : personal ? "Open" : "Review", path: notification.link } }
684
732
  : {}),
685
733
  },
686
734
  ], now);
@@ -0,0 +1,28 @@
1
+ import { type ProgressEntity } from "./telegram-progress.js";
2
+ import { type PhoneTaskLink } from "./telegram-status.js";
3
+ import { type Notification, type Run, type Store } from "./store.js";
4
+ export type QuietView = {
5
+ text: string;
6
+ entities: ProgressEntity[];
7
+ link: PhoneTaskLink;
8
+ };
9
+ export declare function needsPerson(row: Pick<Notification, "dedupeKey" | "kind" | "pushClass" | "recipient">): boolean;
10
+ /** A fact about one placed task: in quiet mode it lands on that task's card. */
11
+ export declare function isTaskFact(row: Pick<Notification, "taskRef" | "taskId" | "project">): row is typeof row & {
12
+ taskRef: number;
13
+ taskId: string;
14
+ project: string;
15
+ };
16
+ /** The card's words now: one task's progress card, or one line per task when several were filed together. */
17
+ export declare function quietCardView(store: Store, taskRefs: readonly number[], now: Date, root?: string): QuietView | null;
18
+ /** The one ping for a Ready result: its state and title, what to do next, and a link to that exact result. */
19
+ export declare function readyPingView(store: Store, run: Run, taskId: string, project: string, now: Date, root?: string): QuietView;
20
+ /** This computer's local day and time, which is what a person means by "the evening". */
21
+ export declare function localDay(now: Date): string;
22
+ export declare function localTime(now: Date): string;
23
+ /** One person's evening: what finished and what failed in the last day, and what waits for them now. Null when
24
+ * nothing happened, so a quiet day sends nothing. */
25
+ export declare function eveningDigestText(store: Store, account: string, now: Date, root?: string): string | null;
26
+ /** Record each due evening digest once per person per local day, as one notification addressed to that person:
27
+ * every chat they paired delivers it with the usual receipts, and the console keeps it. */
28
+ export declare function enqueueEveningDigests(store: Store, now: Date, root?: string): number;
@@ -0,0 +1,161 @@
1
+ /** Quiet chat: a task's whole life is one chat message edited in place, and a
2
+ * new message arrives only when a person is needed. Every transport (Telegram,
3
+ * Slack, Discord, Teams) asks the same questions here: does this fact need a
4
+ * person, what does the task's card say now, and what does the Ready ping say.
5
+ * Delivery receipts, claims and retries stay with each transport; the
6
+ * console's activity keeps every fact whatever a chat showed. */
7
+ import { assignmentOf } from "./assignment.js";
8
+ import { chatControlHref } from "./chat-controls.js";
9
+ import { telegramProgressCard } from "./telegram-progress.js";
10
+ import { phoneText, projectLabel } from "./telegram-status.js";
11
+ import { isLifecycleNotification } from "./store.js";
12
+ /** What always makes a new message, even in quiet mode: a question or approval waiting, a failure or other
13
+ * attention fact, a result Ready for review, a security alert or release, and anything addressed to one person
14
+ * (a sign-in or plan-limit pause, an evening digest). */
15
+ const NEEDED_KINDS = new Set([
16
+ "acceptance-ready", "acceptance-evidence", "flow-decision", "flow-card", "plan-ready", "report-ready",
17
+ "stale-approval", "security-release", "secret-detected",
18
+ ]);
19
+ export function needsPerson(row) {
20
+ if (row.recipient !== null)
21
+ return true;
22
+ if (/^decision:\d+$/.test(row.dedupeKey))
23
+ return true;
24
+ if (row.pushClass !== null && row.pushClass !== "progress")
25
+ return true;
26
+ if (NEEDED_KINDS.has(row.kind))
27
+ return true;
28
+ // A built or unchanged result is Ready: the one lifecycle step that asks for a person.
29
+ return isLifecycleNotification(row) && row.kind === "run-finished";
30
+ }
31
+ /** A fact about one placed task: in quiet mode it lands on that task's card. */
32
+ export function isTaskFact(row) {
33
+ return row.taskRef !== null && row.taskId !== null && row.project !== null;
34
+ }
35
+ /** The attempt a task's card follows: its newest builder attempt, outside any contest. */
36
+ function cardRun(store, taskRef) {
37
+ return store.runsFor(taskRef).find(run => run.role === "builder" && run.contestant === null) ?? null;
38
+ }
39
+ /** Before any attempt starts, the card says where the task stands in the words of its last lifecycle fact. */
40
+ const BEFORE_RUN = {
41
+ "task-filed": { icon: "📝", status: "Filed" },
42
+ "scope-approved": { icon: "✓", status: "Approved · Waiting to start" },
43
+ "approval-withdrawn": { icon: "📝", status: "Approval withdrawn" },
44
+ "task-held": { icon: "⏸", status: "Paused" },
45
+ "task-released": { icon: "✓", status: "Waiting to start" },
46
+ "task-queued": { icon: "✓", status: "Waiting to start" },
47
+ "task-requeued": { icon: "✓", status: "Waiting to start" },
48
+ "task-cancelled": { icon: "⏹", status: "Cancelled" },
49
+ };
50
+ function taskLine(store, taskRef, now, root) {
51
+ const ref = store.refById(taskRef);
52
+ if (ref === null || ref.repo === null)
53
+ return null;
54
+ const task = store.getTask(ref.externalId);
55
+ const title = phoneText(task?.title ?? ref.externalId, 88);
56
+ const run = cardRun(store, taskRef);
57
+ if (run !== null) {
58
+ const view = telegramProgressCard(store, run, ref.externalId, ref.repo, now, root);
59
+ const heading = view.entities[1] === undefined ? "" : view.text.slice(view.entities[1].offset, view.entities[1].offset + view.entities[1].length);
60
+ const split = heading.indexOf(" ");
61
+ return { title, icon: heading.slice(0, split), status: heading.slice(split + 1), project: ref.repo, view };
62
+ }
63
+ const fact = store.latestTaskFact(taskRef);
64
+ const words = task?.state === "cancelled" ? BEFORE_RUN["task-cancelled"] : BEFORE_RUN[fact?.kind ?? ""] ?? BEFORE_RUN["task-filed"];
65
+ const heading = `${words.icon} ${words.status}`;
66
+ const text = [title, heading, "", `${projectLabel(ref.repo)} · ${ref.externalId}`].join("\n");
67
+ return { title, icon: words.icon, status: words.status, project: ref.repo, view: {
68
+ text, entities: [{ type: "bold", offset: 0, length: title.length }, { type: "bold", offset: title.length + 1, length: heading.length }],
69
+ link: { label: "Open task", path: chatControlHref("task", ref.externalId) },
70
+ } };
71
+ }
72
+ /** The card's words now: one task's progress card, or one line per task when several were filed together. */
73
+ export function quietCardView(store, taskRefs, now, root) {
74
+ const lines = taskRefs.map(ref => taskLine(store, ref, now, root)).filter((one) => one !== null);
75
+ if (lines.length === 0)
76
+ return null;
77
+ if (lines.length === 1)
78
+ return lines[0].view;
79
+ const heading = `${lines.length} tasks`;
80
+ const projects = [...new Set(lines.map(one => projectLabel(one.project)))];
81
+ const text = [heading, ...lines.map(one => `${one.icon} ${phoneText(one.title, 60)} · ${one.status}`), "", projects.join(", ")].join("\n");
82
+ return { text, entities: [{ type: "bold", offset: 0, length: heading.length }], link: { label: "Open tasks", path: "/tasks" } };
83
+ }
84
+ /** The one ping for a Ready result: its state and title, what to do next, and a link to that exact result. */
85
+ export function readyPingView(store, run, taskId, project, now, root) {
86
+ const card = telegramProgressCard(store, run, taskId, project, now, root);
87
+ const [title = "", shown = ""] = card.entities.map(one => card.text.slice(one.offset, one.offset + one.length));
88
+ // A saved result is Ready even in the moment before its worker lets go of the task.
89
+ const saved = shown.endsWith(" Result saved");
90
+ const heading = saved ? "✅ Ready" : shown;
91
+ const next = card.next !== "" ? card.next : saved ? "Next: open the result, then mark it complete or request changes." : "";
92
+ const first = `${heading} · ${title}`;
93
+ return { text: next === "" ? first : `${first}\n${next}`, entities: [{ type: "bold", offset: 0, length: heading.length }], link: card.link };
94
+ }
95
+ // ---- the evening digest ----------------------------------------------------------
96
+ const FAILURE = /fail|exhausted|fenced/;
97
+ const pad = (n) => String(n).padStart(2, "0");
98
+ /** This computer's local day and time, which is what a person means by "the evening". */
99
+ export function localDay(now) {
100
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
101
+ }
102
+ export function localTime(now) {
103
+ return `${pad(now.getHours())}:${pad(now.getMinutes())}`;
104
+ }
105
+ /** One person's evening: what finished and what failed in the last day, and what waits for them now. Null when
106
+ * nothing happened, so a quiet day sends nothing. */
107
+ export function eveningDigestText(store, account, now, root) {
108
+ const since = new Date(now.getTime() - 86_400_000).toISOString();
109
+ const recent = new Date(now.getTime() - 14 * 86_400_000).toISOString();
110
+ const visible = (row) => row.project !== null && store.accountCanAccess(account, row.project);
111
+ const title = (taskId) => phoneText(store.getTask(taskId)?.title ?? taskId, 80);
112
+ const finished = new Map(), failed = new Map(), waits = new Map();
113
+ const facts = store.taskFactsSince(recent).filter(visible);
114
+ for (const row of facts) {
115
+ if (row.createdAt < since || row.taskId === null)
116
+ continue;
117
+ if (isLifecycleNotification(row) && row.kind === "run-finished")
118
+ finished.set(row.taskId, title(row.taskId));
119
+ else if (FAILURE.test(row.kind))
120
+ failed.set(row.taskId, title(row.taskId));
121
+ }
122
+ for (const taskId of new Set(facts.map(row => row.taskId).filter((one) => one !== null))) {
123
+ let state = null;
124
+ try {
125
+ const project = facts.find(row => row.taskId === taskId).project;
126
+ state = assignmentOf(store, taskId, now, { principal: "operator", repos: [project] }, root)?.state ?? null;
127
+ }
128
+ catch {
129
+ state = null;
130
+ }
131
+ if (state === "ready-to-check")
132
+ waits.set(taskId, `${title(taskId)} · Ready for review`);
133
+ else if (state === "needs-decision")
134
+ waits.set(taskId, `${title(taskId)} · Needs your decision`);
135
+ }
136
+ if (finished.size + failed.size + waits.size === 0)
137
+ return null;
138
+ const section = (name, items) => items.size === 0 ? [] : ["", `${name} (${items.size})`, ...[...items.values()].slice(0, 12).map(one => `• ${one}`), ...(items.size > 12 ? [`• and ${items.size - 12} more`] : [])];
139
+ return [...section("Finished", finished), ...section("Waiting for you", waits), ...section("Failed", failed)].slice(1).join("\n");
140
+ }
141
+ /** Record each due evening digest once per person per local day, as one notification addressed to that person:
142
+ * every chat they paired delivers it with the usual receipts, and the console keeps it. */
143
+ export function enqueueEveningDigests(store, now, root) {
144
+ const day = localDay(now), time = localTime(now);
145
+ let queued = 0;
146
+ for (const person of store.eveningDigestPeople()) {
147
+ if (person.digestAt > time || (person.digestOn !== null && person.digestOn >= day))
148
+ continue;
149
+ store.transact(() => {
150
+ if (!store.claimEveningDigestDay(person.account, day))
151
+ return;
152
+ const body = eveningDigestText(store, person.account, now, root);
153
+ if (body === null)
154
+ return;
155
+ if (store.enqueueNotification({ dedupeKey: `digest:evening:${person.account}:${day}`, kind: "evening-digest", subject: "Evening digest", body,
156
+ link: "/tasks", source: { installation: true }, recipient: person.account }, now))
157
+ queued++;
158
+ });
159
+ }
160
+ return queued;
161
+ }
package/dist/cli.d.ts CHANGED
@@ -39,7 +39,7 @@ export type ParseResult = {
39
39
  error: string;
40
40
  };
41
41
  type Write = (line: string) => void;
42
- export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> connect one (no path: the repo you are in)\n toolroll repos remove <path>\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll session native coding sessions through the running service\n toolroll connect save a private connection to your central service\n toolroll lead named leads on the connected service\n toolroll conversation shared and private conversations on that service\n toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll chat --as <you> --token <t>\n talk to the mate: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
42
+ export declare const HELP = "toolroll \u2014 a control plane for unattended coding agents\n\nUsage\n toolroll [path...] report what is in flight\n toolroll pulls report what is waiting on a person\n toolroll graph report which work graph is already here\n toolroll repos list connected repositories, and how to adjust\n toolroll repos add <path> connect one (no path: the repo you are in)\n toolroll repos remove <path>\n toolroll repos add-from-github <owner/name> --root <dir>\n preview, then clone and connect (--yes)\n toolroll link put `toolroll` on your PATH\n toolroll unlink take it off again\n toolroll update update to the latest release: verified, drained, undoable\n (preview first; --yes, --now, --at HH:MM, --rollback)\n toolroll contract the machine contract: envelope version + capabilities\n (--commands dumps the declared command guide)\n toolroll skills install --claude-code [--dir <path>]\n install this binary's guides for Claude Code (preview first)\n toolroll skills install teach a repo's agents this queue exists (preview first)\n toolroll skills list the guides this exact binary serves\n toolroll skills get <name> print one guide (version-matched, never stale)\n toolroll demo a seeded throwaway sandbox \u2014 see it working in 90 seconds\n toolroll up app + builder for every saved project \u2014 the normal start\n toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)\n toolroll status running, queued, ready results, release check and plan windows\n toolroll integrations which integrations work, and what to do about the ones that don't (--json)\n toolroll session native coding sessions through the running service\n toolroll connect save a private connection to your central service\n toolroll lead named leads on the connected service\n toolroll conversation shared and private conversations on that service\n toolroll chat --lead <id> --conversation <id> central chat (use --local for local chat)\n\nOperating the queue \u2014 `toolroll task` prints the whole surface,\nand any queue command + --help prints it too\n toolroll approver add <name>\n mint the credential that lets a person say yes\n toolroll ready what could be dispatched right now\n toolroll check-progress <run> current or final approved check progress\n toolroll task wait <id> [--timeout <seconds>]\n wait for this attempt to finish or need a person\n toolroll task add <title> queue work\n toolroll task scope <id> --goal <text>\n state what success is; approval binds to it\n toolroll task approve <id>\n the yes \u2014 nothing builds without one\n toolroll claim <id> --runner <name>\n toolroll heartbeat <lease> / release <lease> / reap\n toolroll tick --runner <name> --token <t> --repo <path>\n one unattended pass over the ready set\n toolroll chat --as <you> --token <t>\n talk to the mate: one conversation across every\n project; it proposes, you confirm (--say \"\u2026\" for one turn)\n toolroll serve --repo <path> advanced: run only the console\n (--editor vscode with --runner: file links\n open in VS Code on the device you browse from)\n toolroll watch / daemon install advanced: run only the builder\n toolroll reconcile recover what the last stretch left behind\n\nWith nothing connected it reports everything it can find below the working\ndirectory. Once you connect repositories it reports those instead.\n\nOptions\n --all report everything discoverable, ignoring connected repos\n --depth <n> directory levels to descend (default: 6)\n --hidden include dot-directories, which are skipped by default\n --dirty also read each working tree for uncommitted files\n --local branches only: no network, no pull requests, no issues\n --json emit a machine-readable envelope instead of a report\n -o <file> also write that envelope to a file (requires --json)\n -h, --help show this\n\nNothing is written, installed, or configured. Every repository is read\nthrough git with --no-optional-locks, so a scan never contends for the\nindex lock with an editor you have open.\n\n--dirty is off by default because reading a working tree is unbounded\nwork: on a large or cloud-synced repo `git status` can take minutes,\nwhere listing branches takes milliseconds.\n\nlink shows what it would do and changes nothing; add --yes to apply it,\nor --to <dir> to choose where. It never uses sudo, never leaves your\nhome directory, and never touches a file it did not create.";
43
43
  export declare function parseArgs(argv: readonly string[]): ParseResult;
44
44
  /** The commands that operate the queue rather than report on the world. */
45
45
  /** Every verb the CLI routes OUTSIDE runOperate. Exported (with
package/dist/cli.js CHANGED
@@ -59,8 +59,9 @@ Usage
59
59
  toolroll skills get <name> print one guide (version-matched, never stale)
60
60
  toolroll demo a seeded throwaway sandbox — see it working in 90 seconds
61
61
  toolroll up app + builder for every saved project — the normal start
62
- toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes)
62
+ toolroll onboard make your agent Toolroll's lead: add this repo, install its skill (--yes), offer starter flows (--starter)
63
63
  toolroll status running, queued, ready results, release check and plan windows
64
+ toolroll integrations which integrations work, and what to do about the ones that don't (--json)
64
65
  toolroll session native coding sessions through the running service
65
66
  toolroll connect save a private connection to your central service
66
67
  toolroll lead named leads on the connected service
@@ -197,10 +198,12 @@ export const OPERATE_COMMANDS = new Set([
197
198
  "ledger",
198
199
  "storage",
199
200
  "monitoring",
201
+ "integrations",
200
202
  "check-progress",
201
203
  "spend",
202
204
  "budget",
203
205
  "retention",
206
+ "notifications",
204
207
  "backup",
205
208
  "restore",
206
209
  "export",
@@ -66,6 +66,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
66
66
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
67
67
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
68
68
  export declare function desktopUpdateStatus(stateDir: string): {
69
+ version?: never;
69
70
  active: boolean;
70
71
  phase: string;
71
72
  detail: string;
@@ -79,7 +80,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
79
80
  wasRunning?: never;
80
81
  backupPath?: never;
81
82
  updateId?: never;
82
- version?: never;
83
83
  buildId?: never;
84
84
  workDir?: never;
85
85
  } | {
@@ -0,0 +1,3 @@
1
+ /** The evening digest choices Settings offers: off, or an evening hour — plus a time set from the command line,
2
+ * kept as chosen. Shared by the console page and its no-script fallback. */
3
+ export declare function digestTimes(current: string | null): [string, string][];
@@ -0,0 +1,7 @@
1
+ /** The evening digest choices Settings offers: off, or an evening hour — plus a time set from the command line,
2
+ * kept as chosen. Shared by the console page and its no-script fallback. */
3
+ export function digestTimes(current) {
4
+ const hours = ["17:00", "18:00", "19:00", "20:00", "21:00", "22:00"];
5
+ const times = current === null || hours.includes(current) ? hours : [...hours, current].sort();
6
+ return [["off", "Off"], ...times.map(one => [one, `At ${one}`])];
7
+ }
package/dist/dispose.js CHANGED
@@ -132,6 +132,7 @@ function disposeBuildOutcomeLocked(context, result) {
132
132
  const grant = store.publicationGrantFor(repo);
133
133
  const headSha = store.getRun(runId)?.headRevision ?? null;
134
134
  if (grant !== null &&
135
+ grant.publishOn !== "complete" &&
135
136
  headSha !== null &&
136
137
  headWithin(branch, grant.headPrefix) &&
137
138
  (grant.selector === "all" || origin === "ours")) {
@@ -203,6 +204,7 @@ function disposeBuildOutcomeLocked(context, result) {
203
204
  const grant = store.publicationGrantFor(repo);
204
205
  const headSha = store.getRun(runId)?.headRevision ?? null;
205
206
  if (grant !== null &&
207
+ grant.publishOn !== "complete" &&
206
208
  headSha !== null &&
207
209
  headWithin(branch, grant.headPrefix) &&
208
210
  (grant.selector === "all" || origin === "ours")) {
package/dist/export.js CHANGED
@@ -60,7 +60,7 @@ export function categoryOf(table) {
60
60
  [/^(run|artifact|claim|worktree$|execution_slot|contest|tournament_terms|fallback_cycle|fallback_transition|held_session|session_turn|attended_authorization|criterion_review|diff_comment|proof_|review_request|repair_chain|incident|publication$|merge_|side_spend|knowledge_snapshot|learning_snapshot|skill_snapshot)/, "runs"],
61
61
  [/^(task|hold$|plan_|scope_|tool_seal|decision|external_|skill_test)/, "tasks"],
62
62
  [/^(project|approval_policy|capability|verify_command|worktree_setup|backend_grant|intake_grant|publication_grant|operating_mode|knowledge_|learning_|memory_|skill_)/, "projects"],
63
- [/(_config|_default|_defaults)$|^(budget|model_|provider_|installation_fact|runtime_check|monitoring_status|quota|mode_rail|schema_version)/, "settings"],
63
+ [/(_config|_default|_defaults)$|^(budget|model_|provider_|installation_fact|runtime_check|monitoring_status|integration_check|quota|mode_rail|schema_version)/, "settings"],
64
64
  ];
65
65
  return rules.find(([pattern]) => pattern.test(table))?.[1] ?? "other";
66
66
  }
@@ -8,7 +8,7 @@
8
8
  import { assignmentOf } from "./assignment.js";
9
9
  import { filerFor } from "./approval-policy.js";
10
10
  import { diagnoseTaskDispatch } from "./dispatch.js";
11
- import { deciderOf, durationWords, fillFlowText, validateFlowDefinition } from "./flows.js";
11
+ import { deciderOf, durationWords, fillFlowText, validateFlowDefinition, withinHours } from "./flows.js";
12
12
  import { reportSummaryFor } from "./report-summary.js";
13
13
  import { fileTaskProposal } from "./proposal.js";
14
14
  import { requestResultChanges } from "./result-actions.js";
@@ -138,6 +138,17 @@ function advanceCard(store, flow, definition, card, now, options, outcome) {
138
138
  return;
139
139
  case "wait": {
140
140
  const wait = stage.wait ?? { for: "reply", minutes: 24 * 60 };
141
+ // Set hours (like 22:00–06:00): a card goes on once the clock is inside them, at once when it already is.
142
+ if (wait.for === "hours") {
143
+ if (withinHours(wait, now)) {
144
+ onward("ok");
145
+ return;
146
+ }
147
+ const waiting = `Waiting until ${wait.from}.`;
148
+ if (card.waiting !== waiting)
149
+ store.updateFlowCard(card.id, { waiting }, now);
150
+ return;
151
+ }
141
152
  const entered = Date.parse(store.flowCardEnteredAt(card.id) ?? card.updatedAt);
142
153
  const words = durationWords(wait.minutes);
143
154
  if (now.getTime() < entered + wait.minutes * 60_000) {
@@ -217,6 +228,7 @@ function advanceCard(store, flow, definition, card, now, options, outcome) {
217
228
  workStage(store, flow, stage, card, now, options, outcome, onward);
218
229
  return;
219
230
  case "check":
231
+ case "pull-request":
220
232
  case "update":
221
233
  case "sort":
222
234
  case "draft":
@@ -0,0 +1,11 @@
1
+ import type { Runner } from "./backend.js";
2
+ import type { FlowDefinition, FlowStage } from "./flows.js";
3
+ import type { FlowCardRow, FlowRow, Store } from "./store.js";
4
+ export type PullRequestIo = {
5
+ gh: Runner;
6
+ evidenceRoot?: string;
7
+ };
8
+ /** Whether a person approved this card at a "Person decides" zone since it was last built; their name when so. */
9
+ export declare function approvedSinceBuild(store: Store, definition: FlowDefinition, card: FlowCardRow): string | null;
10
+ /** One pass of a Pull request zone for one card. Returns whether it changed anything. */
11
+ export declare function pullRequestStep(store: Store, flow: FlowRow, definition: FlowDefinition, stage: FlowStage, card: FlowCardRow, now: Date, io: PullRequestIo): Promise<boolean>;