toolroll 0.9.97 → 0.9.98

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 (78) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/approval-authority.d.ts +10 -1
  3. package/dist/approval-authority.js +18 -2
  4. package/dist/browser/workspace.js +1 -1
  5. package/dist/browser-workspace.d.ts +4 -3
  6. package/dist/chat-actions.d.ts +40 -5
  7. package/dist/chat-actions.js +124 -13
  8. package/dist/chat-card.js +4 -1
  9. package/dist/chat-channel.d.ts +3 -0
  10. package/dist/chat-channel.js +24 -7
  11. package/dist/chat-decide.js +4 -2
  12. package/dist/chat-task-actions.js +1 -1
  13. package/dist/chat-terms.d.ts +8 -0
  14. package/dist/chat-terms.js +14 -0
  15. package/dist/chat-voice.js +1 -1
  16. package/dist/contracts/chat-actions.d.ts +6 -0
  17. package/dist/contracts/chat-actions.js +2 -0
  18. package/dist/contracts/cli.d.ts +6 -0
  19. package/dist/contracts/cli.js +3 -0
  20. package/dist/contracts/lead-tools.d.ts +17 -0
  21. package/dist/contracts/lead-tools.js +4 -1
  22. package/dist/contracts/store-json.d.ts +1 -0
  23. package/dist/desktop-update.d.ts +1 -1
  24. package/dist/fast-start.d.ts +83 -6
  25. package/dist/fast-start.js +112 -15
  26. package/dist/home-groups.d.ts +39 -0
  27. package/dist/home-groups.js +33 -0
  28. package/dist/lead-answer-eval.d.ts +7 -0
  29. package/dist/lead-answer-eval.js +6 -2
  30. package/dist/lead-capabilities.js +1 -1
  31. package/dist/lead-context.js +2 -2
  32. package/dist/lead-contract.d.ts +1 -1
  33. package/dist/lead-contract.js +10 -7
  34. package/dist/lead-doors.d.ts +32 -0
  35. package/dist/lead-doors.js +80 -14
  36. package/dist/lead-status.d.ts +3 -0
  37. package/dist/lead-status.js +8 -3
  38. package/dist/lead-tools.d.ts +20 -0
  39. package/dist/lead-tools.js +66 -3
  40. package/dist/lead-voice.js +1 -1
  41. package/dist/lead.js +18 -2
  42. package/dist/local-owner.js +2 -0
  43. package/dist/one-step-chat-schema.d.ts +22 -0
  44. package/dist/one-step-chat-schema.js +37 -0
  45. package/dist/operate.d.ts +27 -1
  46. package/dist/operate.js +206 -32
  47. package/dist/project-delete.js +9 -0
  48. package/dist/project-knowledge.d.ts +7 -7
  49. package/dist/publish.d.ts +2 -0
  50. package/dist/publish.js +1 -1
  51. package/dist/pull-request-flow.d.ts +4 -0
  52. package/dist/pull-request-flow.js +16 -1
  53. package/dist/reply-voice.d.ts +23 -0
  54. package/dist/reply-voice.js +37 -0
  55. package/dist/scope.d.ts +6 -1
  56. package/dist/scope.js +3 -2
  57. package/dist/server/chat.js +4 -4
  58. package/dist/server/chrome.js +2 -2
  59. package/dist/server/home.d.ts +4 -3
  60. package/dist/server/home.js +33 -28
  61. package/dist/server/render-chat.js +11 -6
  62. package/dist/server/render-pages.js +2 -2
  63. package/dist/setup-guide.d.ts +2 -2
  64. package/dist/store.d.ts +4 -2
  65. package/dist/store.js +35 -12
  66. package/dist/telegram-lead.js +69 -5
  67. package/dist/telegram-status.js +9 -4
  68. package/dist/telegram.d.ts +10 -0
  69. package/dist/telegram.js +92 -8
  70. package/dist/toolroll-update.js +3 -1
  71. package/dist/tui-contract.d.ts +2 -0
  72. package/dist/tui-source.js +3 -0
  73. package/dist/tui.js +41 -41
  74. package/dist/typed-start.d.ts +51 -0
  75. package/dist/typed-start.js +98 -0
  76. package/dist/wake-hub.d.ts +72 -0
  77. package/dist/wake-hub.js +151 -0
  78. package/package.json +1 -1
@@ -1660,7 +1660,7 @@ export type BrowserWorkspace = {
1660
1660
  };
1661
1661
  };
1662
1662
  /** Home ("/"): one sentence that answers "what needs me?", four counts that agree with the Tasks filters they open,
1663
- * what waits on this person grouped by what it asks, what is running, and the selected item answered in place. */
1663
+ * the work grouped as Waiting on you, Working and Done, and the selected item answered in place. */
1664
1664
  export type BrowserHomeView = {
1665
1665
  kind: 'home';
1666
1666
  /** "7 need you across 2 projects.", derived from the same counts as the figures. */
@@ -1679,12 +1679,13 @@ export type BrowserHomeView = {
1679
1679
  value: number;
1680
1680
  href: string;
1681
1681
  }[];
1682
+ /** The work in three groups, in this order (home-groups.ts): Waiting on you, Working, Done (finished in the last 7 days). */
1682
1683
  groups: {
1683
- key: 'decide' | 'review' | 'unblock' | 'running' | 'waiting';
1684
+ key: 'you' | 'working' | 'done';
1684
1685
  label: string;
1685
1686
  rows: BrowserHomeRow[];
1686
1687
  }[];
1687
- /** More waits than the list shows: open them all in Tasks. */
1688
+ /** More waits on you than the list shows: open them all in Tasks. */
1688
1689
  more: {
1689
1690
  count: number;
1690
1691
  href: string;
@@ -1,4 +1,5 @@
1
1
  import { type Authority } from "./local-owner.js";
2
+ import { type CardChannel } from "./chat-terms.js";
2
3
  import { type Store } from "./store.js";
3
4
  import { type VerifiedApprover } from "./principal.js";
4
5
  import { CHAT_ACTION_FIELDS, type SharedAction } from "./contracts/chat-actions.js";
@@ -354,6 +355,11 @@ export declare const CHAT_ACTIONS: {
354
355
  readonly protected: true;
355
356
  readonly password: false;
356
357
  };
358
+ readonly task_start: {
359
+ readonly label: "Start work";
360
+ readonly protected: true;
361
+ readonly password: false;
362
+ };
357
363
  };
358
364
  export type ChatAction = keyof typeof CHAT_ACTIONS;
359
365
  /** Approvals (owner's decision, docs/plans/toolroll-cards.md): a person's own yes from their chat stands without a password.
@@ -368,19 +374,28 @@ export declare const OWNER_ACTIONS: ReadonlySet<ChatAction>;
368
374
  export declare function isChatAction(value: unknown): value is ChatAction;
369
375
  /** No writes, including at preview. Store-derived snapshots are never accepted
370
376
  * from a model. Rebuilding the same request before execution catches changes. */
371
- export declare function prepareSharedAction(store: Store, who: VerifiedApprover, operation: ChatAction, input: Record<string, unknown>, root?: string, now?: Date): SharedAction;
377
+ export declare function prepareSharedAction(store: Store, who: VerifiedApprover, operation: ChatAction, input: Record<string, unknown>, root?: string, now?: Date,
378
+ /** The lead turn the card is (or is being) drafted in: a Start card's preview must have been prepared in it. Read
379
+ * from the proposal row (or the running turn), never from the request. */
380
+ turn?: number): SharedAction;
372
381
  /** A saved proposal's action, read by its schema (contracts/chat-actions.ts); null when it can't be read. */
373
382
  export declare function sharedActionPayload(value: unknown): SharedAction | null;
374
383
  export declare function sharedActionNeedsReview(action: SharedAction): boolean;
375
- /** Whether a paired phone may confirm this protected action behind its own
384
+ /** Whether a linked chat may confirm this protected action behind its own
376
385
  * explicit challenge: the action is on the challenge list, needs no
377
- * password, and every term fits the card unshortened. */
378
- export declare function sharedActionAllowsChallenge(action: SharedAction): boolean;
386
+ * password, and every term fits the card unshortened. An approval's yes is
387
+ * a yes to the words the card showed (they are sealed as shown), so it also
388
+ * needs a chat app that shows them exactly as stored (shownAsWritten: never
389
+ * Teams, whose card reads Markdown; not Slack when a word would become an
390
+ * emoji). There the card links to the review page and no tap approves. The
391
+ * chat app is the one the card is drawn for or the tap came through, never
392
+ * something the action or a caller's input names. */
393
+ export declare function sharedActionAllowsChallenge(action: SharedAction, channel: CardChannel): boolean;
379
394
  export declare function sharedActionReviewPath(id: number): string;
380
395
  export declare function mintSharedActionReview(store: Store, who: VerifiedApprover, id: number, root: string, now: Date): {
381
396
  payload: {
382
397
  version: 1;
383
- operation: "code_map_holdout_end" | "code_map_switch" | "decision_record" | "decision_retire" | "finding_resend" | "finding_send" | "flow_archive" | "flow_browser" | "flow_browser_off" | "flow_card_add" | "flow_card_approve" | "flow_card_assign" | "flow_card_cancel" | "flow_card_choose" | "flow_card_comment" | "flow_card_move" | "flow_card_send_back" | "flow_card_watch" | "flow_create" | "flow_edit" | "flow_script_save" | "flow_starter" | "flow_trigger_add" | "flow_trigger_pause" | "flow_trigger_remove" | "flow_trigger_resume" | "flow_unattended" | "flow_unattended_off" | "incident_resolve" | "kit_setup" | "knowledge_instructions" | "knowledge_remove" | "knowledge_restore" | "knowledge_save" | "lead_about_you" | "lead_identity" | "memory_decide" | "mode_revoke" | "provider_resume" | "result_accept" | "revision_accept" | "revision_reject" | "scope_approve" | "skill_disable" | "skill_enable" | "skill_import" | "skill_restore" | "skill_test" | "subagent_answer" | "subagent_ask" | "subagent_create" | "subagent_memory" | "subagent_note" | "subagent_routine" | "subagent_soul" | "subagent_state" | "subagent_tools" | "subagent_undo" | "suggestion_not_now" | "sweep_cancel_rest" | "sweep_file" | "task_archive" | "task_cancel" | "task_reopen" | "task_restore" | "task_resume" | "tool_add" | "tool_remove" | "trial_stop" | "worker_note";
398
+ operation: "code_map_holdout_end" | "code_map_switch" | "decision_record" | "decision_retire" | "finding_resend" | "finding_send" | "flow_archive" | "flow_browser" | "flow_browser_off" | "flow_card_add" | "flow_card_approve" | "flow_card_assign" | "flow_card_cancel" | "flow_card_choose" | "flow_card_comment" | "flow_card_move" | "flow_card_send_back" | "flow_card_watch" | "flow_create" | "flow_edit" | "flow_script_save" | "flow_starter" | "flow_trigger_add" | "flow_trigger_pause" | "flow_trigger_remove" | "flow_trigger_resume" | "flow_unattended" | "flow_unattended_off" | "incident_resolve" | "kit_setup" | "knowledge_instructions" | "knowledge_remove" | "knowledge_restore" | "knowledge_save" | "lead_about_you" | "lead_identity" | "memory_decide" | "mode_revoke" | "provider_resume" | "result_accept" | "revision_accept" | "revision_reject" | "scope_approve" | "skill_disable" | "skill_enable" | "skill_import" | "skill_restore" | "skill_test" | "subagent_answer" | "subagent_ask" | "subagent_create" | "subagent_memory" | "subagent_note" | "subagent_routine" | "subagent_soul" | "subagent_state" | "subagent_tools" | "subagent_undo" | "suggestion_not_now" | "sweep_cancel_rest" | "sweep_file" | "task_archive" | "task_cancel" | "task_reopen" | "task_restore" | "task_resume" | "task_start" | "tool_add" | "tool_remove" | "trial_stop" | "worker_note";
384
399
  request: {
385
400
  [x: string]: unknown;
386
401
  };
@@ -408,7 +423,17 @@ export type SharedActionOptions = {
408
423
  person?: boolean;
409
424
  /** A linked chat's own binding: which chat the yes came from, for the ledger. */
410
425
  binding?: number;
426
+ /** A typed yes to a Start card on Telegram (one-step-chat.md C5): the inbound message that was the yes. The only other
427
+ * way past the "confirm this" gate, for `task_start` alone, on Telegram alone, and only while that message's unsettled
428
+ * typed-yes record names this proposal (typedStartOpens). It is never `confirm`, mints no review and changes no `via`. */
429
+ typedStart?: {
430
+ provider: string;
431
+ event: string;
432
+ };
411
433
  };
434
+ /** Whether a typed yes may stand in for the challenge's tap on this action: a Start card, on Telegram, a person (never a
435
+ * machine), terms whole on the card, and an unsettled typed-yes record naming exactly this proposal. */
436
+ export declare function typedStartOpens(store: Store, proposal: number, action: SharedAction, options: Pick<SharedActionOptions, "via" | "typedStart" | "authority">): boolean;
412
437
  /** Called inside the proposal door's transaction, after ownership and session
413
438
  * checks. No password or review receipt is ever part of a saved proposal. */
414
439
  export declare function executeSharedAction(store: Store, who: VerifiedApprover, id: number, payload: SharedAction, now: Date, options: SharedActionOptions): {
@@ -423,3 +448,13 @@ export declare function executeSharedAction(store: Store, who: VerifiedApprover,
423
448
  reason: "needs-confirm" | "stale" | "refused";
424
449
  said: string;
425
450
  };
451
+ /**
452
+ * A value safe to paste into a shell as ONE word, single-quoted, or null. The value is model- or person-written (the
453
+ * lead's title, a project path), so only a strict allowlist passes: letters, digits, spaces and `. , : _ / - ( ) + # @`,
454
+ * 1 to 120 characters, no leading `-`. Anything else (quotes, backticks, `$`, `\`, newlines, globs, redirections…) is
455
+ * null, and the caller shows a placeholder instead.
456
+ */
457
+ export declare function shellWordOrNull(text: string): string | null;
458
+ /** Where `toolroll chat` sends a Start card: the terminal's own fast start, with the words the lead drafted when they are
459
+ * safe to paste as one shell word, else the placeholders. Never a value unquoted or in double quotes. */
460
+ export declare function startFromTerminalWords(store: Store, action: Pick<SharedAction, "repo" | "state">): string;
@@ -23,7 +23,10 @@ import { authPauseOf, liftAuthPause } from "./provider-auth.js";
23
23
  import { isProviderId } from "./provider.js";
24
24
  import { approverStandsNow, familyRepoOf } from "./tidy.js";
25
25
  import { chatApproveMode, effectiveChatApproval } from "./chat-approval.js";
26
- import { cardTermsDigest, chatLimitProblem, scopeTermsText } from "./chat-terms.js";
26
+ import { cardTermsDigest, chatLimitProblem, scopeTermsText, shownAsWritten } from "./chat-terms.js";
27
+ import { chatStartDryRun, chatStartPreview, fastRefusalWords, fastStartBuild, FAST_MAX_FILES, FAST_MAX_LINES } from "./fast-start.js";
28
+ import { fastTermsLines } from "./fast-terms.js";
29
+ import { TYPED_START_REFUSED, typedStartNames } from "./typed-start.js";
27
30
  import { createHash, randomBytes } from "node:crypto";
28
31
  import { archiveTaskFamily, leadTurnKeepsProposals, restoreTaskFamily, verifiedAuthor } from "./store.js";
29
32
  import { isVerifiedApprover, reproveApprover, } from "./principal.js";
@@ -176,13 +179,16 @@ export const CHAT_ACTIONS = {
176
179
  result_accept: { label: "Accept", protected: true, password: false },
177
180
  task_cancel: { label: "Cancel task", protected: true, password: false },
178
181
  task_resume: { label: "Resume task", protected: true, password: false },
182
+ // One-step start from chat (docs/plans/one-step-chat.md C2): files and approves one small task the lead drafted, under
183
+ // exactly the fast start rules. Only the lead's turn loop drafts one (propose_start); a person's own yes starts it.
184
+ task_start: { label: "Start work", protected: true, password: false },
179
185
  };
180
186
  // Every action has a schema (contracts/chat-actions.ts) and every schema an action: a mismatch fails to compile.
181
187
  const SAME_ACTIONS = true;
182
188
  void SAME_ACTIONS;
183
189
  /** Approvals (owner's decision, docs/plans/toolroll-cards.md): a person's own yes from their chat stands without a password.
184
190
  * Only a person's tap in a linked chat, or their click in a signed-in browser, ever runs one. */
185
- export const APPROVAL_ACTIONS = new Set(["scope_approve", "task_resume", "sweep_file", "flow_browser", "task_reopen", "revision_accept", "revision_reject", "flow_unattended"]);
191
+ export const APPROVAL_ACTIONS = new Set(["scope_approve", "task_resume", "sweep_file", "flow_browser", "task_reopen", "revision_accept", "revision_reject", "flow_unattended", "task_start"]);
186
192
  /** Protected actions a linked chat may confirm behind its own explicit yes/cancel challenge instead of the review screen.
187
193
  * Password actions and long or redacted terms never qualify: the card must show the exact terms. */
188
194
  export const APPROVAL_TERM_CHARS = 3000;
@@ -296,7 +302,10 @@ function flowDrawingOf(store, input, repo) {
296
302
  const quoted = (value) => `“${value}”`;
297
303
  /** No writes, including at preview. Store-derived snapshots are never accepted
298
304
  * from a model. Rebuilding the same request before execution catches changes. */
299
- export function prepareSharedAction(store, who, operation, input, root, now = new Date()) {
305
+ export function prepareSharedAction(store, who, operation, input, root, now = new Date(),
306
+ /** The lead turn the card is (or is being) drafted in: a Start card's preview must have been prepared in it. Read
307
+ * from the proposal row (or the running turn), never from the request. */
308
+ turn) {
300
309
  if (!isChatAction(operation))
301
310
  throw Error("Choose an available action.");
302
311
  // The action's own schema: its fields and their kinds, refused by path. The request itself is used as given, so a
@@ -334,7 +343,7 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
334
343
  const stamp = hash({ operation, request, repo: "", state, terms, actor: who.name, generation: who.generation, ceiling: who.ceilingDigest });
335
344
  return { version: SHARED_ACTION_VERSION, operation, request, repo: "", title, terms, stamp, state };
336
345
  }
337
- const task = operation === "mode_revoke" || operation === "memory_decide" || operation === "incident_resolve" || operation === "finding_resend" || operation.startsWith("code_map_") || operation.startsWith("skill_") || operation.startsWith("knowledge_") || operation.startsWith("decision_") || operation.startsWith("tool_") || operation.startsWith("flow_") || operation.startsWith("subagent_") || operation.startsWith("kit_") || operation.startsWith("sweep_") || operation.startsWith("suggestion_") || operation.startsWith("trial_")
346
+ const task = operation === "mode_revoke" || operation === "memory_decide" || operation === "incident_resolve" || operation === "finding_resend" || operation.startsWith("code_map_") || operation.startsWith("skill_") || operation.startsWith("knowledge_") || operation.startsWith("decision_") || operation.startsWith("tool_") || operation.startsWith("flow_") || operation.startsWith("subagent_") || operation.startsWith("kit_") || operation.startsWith("sweep_") || operation.startsWith("suggestion_") || operation.startsWith("trial_") || operation === "task_start"
338
347
  ? null
339
348
  : text(input, "task", 64);
340
349
  const flowTarget = operation.startsWith("flow_") && operation !== "flow_create" && operation !== "flow_script_save" && operation !== "flow_starter" ? flowTargetOf(store, input) : null;
@@ -636,6 +645,31 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
636
645
  terms.push(question.question, `Your answer: ${[choice?.label, said].filter(Boolean).join(" — ")}`);
637
646
  }
638
647
  }
648
+ else if (operation === "task_start") {
649
+ // A Start card (docs/plans/one-step-chat.md C2). The preview is the one the lead's turn loop prepared in this card's
650
+ // own turn, for this person, on chat (never an id the model chose: the request only names it back), unexpired and
651
+ // unused. Every word below is Toolroll's, from a filing dry-run in a savepoint that is always rolled back, with the
652
+ // fast start rules read on it: the tap files again, and the stamp, approve()'s digest and its whole-terms check refuse
653
+ // anything that differs.
654
+ const preview = text(input, "preview", 64);
655
+ const saved = turn === undefined ? null : chatStartPreview(store, preview, who.name, turn, now);
656
+ if (saved === null || saved.repo !== repo)
657
+ throw Error("This expired. Ask again.");
658
+ const read = chatStartDryRun(store, { actor: who.name, repo, title: saved.title, taskId: saved.taskId, sizing: saved.sizing }, now);
659
+ if (!read.ok)
660
+ throw Error(`${fastRefusalWords(read.reason)} Ask for an ordinary task instead.`);
661
+ if (read.scope.digest !== saved.digest)
662
+ throw Error("This changed since it was drafted. Ask again.");
663
+ const authority = scopeAuthorityOf(read.scope, { authMode: readAuthModeStrict });
664
+ if (!authority.ok)
665
+ throw Error(authority.problem);
666
+ state = { preview, taskId: saved.taskId, digest: read.scope.digest };
667
+ const project = repo.split(/[\\/]/).filter(Boolean).at(-1) ?? repo;
668
+ title = `Start work in ${project}: ${saved.title}`;
669
+ terms.push(`Starts now as a small change. If it grows past ${FAST_MAX_FILES} files or ${FAST_MAX_LINES} changed lines, it waits for you to approve it again.`,
670
+ // The goal and its one success test are the lead's words, shown exactly and labelled as the lead's.
671
+ `Written by the lead from your request: ${quoted(saved.title)}`, scopeTermsText(read.scope), ...fastTermsLines(store, read.scope, repo).map(line => `${line.label}: ${line.words}`), `Agent permissions and limits\n${runtimeTerms(authority.profile)}\n${authority.authMode === "api-key" ? "Uses your API key; spend is charged to that account." : authority.authMode === "subscription" ? "Uses your subscription login." : ""}`);
672
+ }
639
673
  else if (operation === "sweep_cancel_rest") {
640
674
  // Exactly the items open now: if any settles or starts in between, the card is stale and a fresh one is needed.
641
675
  const table = sweepTable(store, integer(input, "sweep"), now, who.repos);
@@ -1144,15 +1178,22 @@ export function sharedActionNeedsReview(action) {
1144
1178
  return (CHAT_ACTIONS[action.operation].protected ||
1145
1179
  sharedActionContentNeedsReview(action));
1146
1180
  }
1147
- /** Whether a paired phone may confirm this protected action behind its own
1181
+ /** Whether a linked chat may confirm this protected action behind its own
1148
1182
  * explicit challenge: the action is on the challenge list, needs no
1149
- * password, and every term fits the card unshortened. */
1150
- export function sharedActionAllowsChallenge(action) {
1183
+ * password, and every term fits the card unshortened. An approval's yes is
1184
+ * a yes to the words the card showed (they are sealed as shown), so it also
1185
+ * needs a chat app that shows them exactly as stored (shownAsWritten: never
1186
+ * Teams, whose card reads Markdown; not Slack when a word would become an
1187
+ * emoji). There the card links to the review page and no tap approves. The
1188
+ * chat app is the one the card is drawn for or the tap came through, never
1189
+ * something the action or a caller's input names. */
1190
+ export function sharedActionAllowsChallenge(action, channel) {
1151
1191
  const config = CHAT_ACTIONS[action.operation];
1152
1192
  return (CHALLENGE_ACTIONS.has(action.operation) &&
1153
1193
  config.protected &&
1154
1194
  !config.password &&
1155
- !sharedActionContentNeedsReview(action));
1195
+ !sharedActionContentNeedsReview(action) &&
1196
+ (!APPROVAL_ACTIONS.has(action.operation) || shownAsWritten([action.title, ...action.terms].join("\n"), channel)));
1156
1197
  }
1157
1198
  export function sharedActionReviewPath(id) {
1158
1199
  return `/chat/action/${id}`;
@@ -1200,7 +1241,7 @@ function sameSharedAction(store, who, live, saved) {
1200
1241
  }
1201
1242
  export function mintSharedActionReview(store, who, id, root, now) {
1202
1243
  const payload = savedActionContext(store, who, id, "pending");
1203
- const live = prepareSharedAction(store, who, payload.operation, payload.request, root, now);
1244
+ const live = prepareSharedAction(store, who, payload.operation, payload.request, root, now, store.getLeadProposal(id)?.turn);
1204
1245
  if (!sameSharedAction(store, who, live, payload))
1205
1246
  throw Error("This action changed. Ask for a fresh proposal.");
1206
1247
  const nonce = randomBytes(24).toString("hex");
@@ -1215,6 +1256,13 @@ export function mintSharedActionReview(store, who, id, root, now) {
1215
1256
  throw Error("Too many reviews are open. Close older reviews and try again.");
1216
1257
  return { payload: live, nonce };
1217
1258
  }
1259
+ /** Whether a typed yes may stand in for the challenge's tap on this action: a Start card, on Telegram, a person (never a
1260
+ * machine), terms whole on the card, and an unsettled typed-yes record naming exactly this proposal. */
1261
+ export function typedStartOpens(store, proposal, action, options) {
1262
+ const typed = options.typedStart;
1263
+ return typed !== undefined && action.operation === "task_start" && options.via === "telegram" && typed.provider === "telegram"
1264
+ && options.authority !== "local-owner" && !machineActing() && sharedActionAllowsChallenge(action, "telegram") && typedStartNames(store, typed, proposal);
1265
+ }
1218
1266
  /** Called inside the proposal door's transaction, after ownership and session
1219
1267
  * checks. No password or review receipt is ever part of a saved proposal. */
1220
1268
  export function executeSharedAction(store, who, id, payload, now, options) {
@@ -1231,7 +1279,7 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1231
1279
  drafted = saved;
1232
1280
  if (hash(saved) !== hash(payload))
1233
1281
  return refuse("refused", "This action does not match the saved proposal.");
1234
- live = prepareSharedAction(store, who, saved.operation, saved.request, options.root, now);
1282
+ live = prepareSharedAction(store, who, saved.operation, saved.request, options.root, now, store.getLeadProposal(id)?.turn);
1235
1283
  if (!sameSharedAction(store, who, live, saved))
1236
1284
  return refuse("stale", "This action changed. Ask for a fresh proposal.");
1237
1285
  }
@@ -1244,10 +1292,13 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1244
1292
  const savedStamp = payload.stamp;
1245
1293
  payload = live;
1246
1294
  const config = CHAT_ACTIONS[live.operation], req = live.request;
1295
+ // A typed yes (C5) is checked on the live action and the live record; any use it doesn't open refuses.
1296
+ const typed = typedStartOpens(store, id, payload, options);
1297
+ if (options.typedStart !== undefined && !typed)
1298
+ return refuse("refused", TYPED_START_REFUSED);
1247
1299
  const challenged = options.via !== "web" &&
1248
1300
  options.via !== "cli" &&
1249
- options.confirm === true &&
1250
- sharedActionAllowsChallenge(payload);
1301
+ ((options.confirm === true && sharedActionAllowsChallenge(payload, options.via)) || typed);
1251
1302
  if (APPROVAL_ACTIONS.has(live.operation) && machineActing())
1252
1303
  return refuse("needs-confirm", "Only a person approves this.");
1253
1304
  // A lead's or remote token reaches the same web door: an approval needs a person's own session (or tap, above).
@@ -1330,6 +1381,10 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1330
1381
  const said = cancelSweepRest(store, who.name, payload.state["open"], now, "web");
1331
1382
  return { ok: true, taskId: null, said, href: `/sweeps/${Number(req["sweep"])}` };
1332
1383
  }
1384
+ else if (payload.operation === "task_start") {
1385
+ const started = startFromCard(store, who, id, drafted, options, now);
1386
+ return { ok: true, taskId: started, said: "Started. It's building now.", href: `/t/${encodeURIComponent(started)}` };
1387
+ }
1333
1388
  else if (payload.operation === "sweep_file") {
1334
1389
  const done = fileSweep(store, who, id, payload, approvalBy(store, who, payload.repo, now, options, drafted), now);
1335
1390
  return { ok: true, taskId: null, said: done.said, href: done.href };
@@ -1452,7 +1507,7 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1452
1507
  return {
1453
1508
  ok: true,
1454
1509
  taskId,
1455
- said: `Stop released. ${result.gate ? "Other requirements still prevent the next attempt." : "The next attempt may start."}`,
1510
+ said: `Stop lifted. ${result.gate ? "Something else still holds the next attempt." : "The next attempt may start."}`,
1456
1511
  };
1457
1512
  }
1458
1513
  const said = payload.operation === "skill_test"
@@ -1550,6 +1605,62 @@ function sweepChildScopes(store, who, repo, brief, items, acceptance, now) {
1550
1605
  });
1551
1606
  return found;
1552
1607
  }
1608
+ /**
1609
+ * A Start card's yes (docs/plans/one-step-chat.md C2): file and approve in one step through fastStartBuild, in this
1610
+ * action's savepoint. Two proofs of the person and no third: their own yes from chat (approvalBy gives `chat`, with the
1611
+ * card's whole words and their digest), under their own chat limits; or this computer's local owner on web chat, whose
1612
+ * approve() proves the owner itself and allows `build-now` only with a fast marker. Anything else refuses, and a refusal
1613
+ * throws, taking the filing back.
1614
+ */
1615
+ function startFromCard(store, who, proposal, drafted, options, now) {
1616
+ // `toolroll chat` keeps refusing approvals; a Start card names the terminal's own fast start instead.
1617
+ if (options.via === "cli")
1618
+ throw Error(startFromTerminalWords(store, drafted));
1619
+ const by = approvalBy(store, who, drafted.repo, now, options, drafted);
1620
+ if (by.ok === false)
1621
+ throw Error(by.why);
1622
+ let proof, approveNow;
1623
+ if (by.chat !== undefined) {
1624
+ const chat = by.chat;
1625
+ proof = { chat: by.limits };
1626
+ // A typed yes (C5) is recorded beside the fast marker: which message was the yes. The executor's gate has proved it.
1627
+ const typed = options.typedStart;
1628
+ approveNow = (taskId, saw, fast) => approve(store, taskId, who.name, now, saw, "", {}, typed === undefined ? fast : { ...fast, typed: { provider: typed.provider, event: typed.event } }, chat);
1629
+ }
1630
+ else if (options.authority === "local-owner" && currentLocalOwner() !== null) {
1631
+ proof = { password: { localOwner: true, passwordless: false, freshAt: null, returnTo: "/chat" } };
1632
+ approveNow = (taskId, saw, fast) => approve(store, taskId, who.name, now, saw, "", {}, fast);
1633
+ }
1634
+ else
1635
+ throw Error("Only a person starts work this way.");
1636
+ const turn = store.getLeadProposal(proposal)?.turn;
1637
+ const built = fastStartBuild(store, { preview: String(drafted.state["preview"] ?? ""), digest: String(drafted.state["digest"] ?? ""), actor: who.name, via: "chat",
1638
+ ...(turn === undefined ? {} : { turn }), proof, approveNow }, now);
1639
+ if (!built.ok)
1640
+ throw Error(built.message);
1641
+ return built.taskId;
1642
+ }
1643
+ /**
1644
+ * A value safe to paste into a shell as ONE word, single-quoted, or null. The value is model- or person-written (the
1645
+ * lead's title, a project path), so only a strict allowlist passes: letters, digits, spaces and `. , : _ / - ( ) + # @`,
1646
+ * 1 to 120 characters, no leading `-`. Anything else (quotes, backticks, `$`, `\`, newlines, globs, redirections…) is
1647
+ * null, and the caller shows a placeholder instead.
1648
+ */
1649
+ export function shellWordOrNull(text) {
1650
+ if (text.length < 1 || text.length > 120 || text.startsWith("-"))
1651
+ return null;
1652
+ if (!/^[\p{L}\p{N} .,:_/()+#@-]+$/u.test(text))
1653
+ return null;
1654
+ return `'${text}'`;
1655
+ }
1656
+ /** Where `toolroll chat` sends a Start card: the terminal's own fast start, with the words the lead drafted when they are
1657
+ * safe to paste as one shell word, else the placeholders. Never a value unquoted or in double quotes. */
1658
+ export function startFromTerminalWords(store, action) {
1659
+ const row = store.handle.prepare("SELECT title FROM fast_preview WHERE id = ?").get(String(action.state["preview"] ?? ""));
1660
+ const title = row === undefined ? null : shellWordOrNull(String(row["title"]));
1661
+ const project = shellWordOrNull(action.repo);
1662
+ return `Start it from the terminal with \`toolroll task new ${title ?? "\"<title>\""} --repo ${project ?? "<project>"}\`.`;
1663
+ }
1553
1664
  /** How this yes stands, and under whose limits: a person's own from chat, by their own chat setting, with the words and
1554
1665
  * digest the card was made with. A local owner's road has none: approve() proves it itself. */
1555
1666
  function approvalBy(store, who, repo, now, options, drafted) {
package/dist/chat-card.js CHANGED
@@ -93,7 +93,10 @@ export function isCardProposal(proposal) {
93
93
  export const cardPlainOf = (proposal) => typeof proposal.payload["plain"] === "string" ? proposal.payload["plain"] : "This card can't be shown.";
94
94
  /** Cards before the proposals they lead to, so each Confirm sits under its card. */
95
95
  export function cardsFirst(proposals) {
96
- return [...proposals.filter(isCardProposal), ...proposals.filter(one => !isCardProposal(one))];
96
+ // A Start card goes last (docs/plans/one-step-chat.md C5): nothing Toolroll sends for the turn comes after it.
97
+ const start = (one) => one.kind === "action" && one.payload["operation"] === "task_start";
98
+ const rest = proposals.filter(one => !start(one));
99
+ return [...rest.filter(isCardProposal), ...rest.filter(one => !isCardProposal(one)), ...proposals.filter(start)];
97
100
  }
98
101
  /** A card goes only where every project behind it is still reachable. */
99
102
  export const cardWithin = (card, repos) => card.repos.every(one => repos.includes(one));
@@ -121,3 +121,6 @@ export declare function handoffCardText(text: string, note: string | null): stri
121
121
  * a signed mode asks for nothing more. A staged resume says what remains.
122
122
  */
123
123
  export declare function confirmedCardText(store: Store, outcome: DoorOutcome, proposal: LeadProposal, note: string | null): string;
124
+ /** The link to a task a Start card just started (a tapped card, and Toolroll's reply to a typed yes): the task's own
125
+ * page, only while the task is one of the person's projects. Navigation only. */
126
+ export declare function startedTaskLink(store: Store, taskId: string, repos: readonly string[]): PhoneLink | null;
@@ -1,5 +1,6 @@
1
1
  import { parseFeedbackImage } from "./result-review.js";
2
2
  /** Shared membership-chat identity, proposal previews and result links. Transport adapters never grant authority. */
3
+ import { TYPED_START_LINE } from "./typed-start.js";
3
4
  import { FLOW_HREF } from "./flow-engine.js";
4
5
  import { closedQuestionWords, LIMITS } from "./decision.js";
5
6
  import { createHash } from "node:crypto";
@@ -225,8 +226,8 @@ export function proposalLink(store, proposal, repos, channel = null) {
225
226
  return action &&
226
227
  repos.includes(action.repo) &&
227
228
  sharedActionNeedsReview(action) &&
228
- !(channel !== null && sharedActionAllowsChallenge(action))
229
- ? { label: "Review action", path: sharedActionReviewPath(proposal.id) }
229
+ !(channel !== null && sharedActionAllowsChallenge(action, channel))
230
+ ? { label: action.operation === "task_start" ? "Review and start" : "Review action", path: sharedActionReviewPath(proposal.id) }
230
231
  : null;
231
232
  }
232
233
  const task = typeof payload["task"] === "string" ? payload["task"] : "";
@@ -281,8 +282,9 @@ export function confirmedLink(store, outcome, proposal, repos) {
281
282
  return { label: "Open the flow", path: outcome.href };
282
283
  if (!outcome.ok || outcome.taskId === null)
283
284
  return null;
285
+ // A skill test's task, and the task a Start card started: the card opens it.
284
286
  if (proposal.kind === "action" &&
285
- proposal.payload["operation"] === "skill_test")
287
+ (proposal.payload["operation"] === "skill_test" || proposal.payload["operation"] === "task_start"))
286
288
  return taskLink(store, outcome.taskId, repos);
287
289
  const staged = proposal.kind === "task" ||
288
290
  proposal.kind === "scope" ||
@@ -315,7 +317,9 @@ export function proposalPreview(store, proposal, repos, channel = null) {
315
317
  })();
316
318
  // Telegram's card leads with the action and what confirming does, then the exact terms; its buttons say the rest.
317
319
  const phone = channel === "telegram";
318
- const card = (headline, body = [], consequence = null) => ({
320
+ const card = (headline, body = [], consequence = null,
321
+ /** Telegram only: a fixed last line outside the terms (a Start card's "reply yes", one-step-chat.md C5). */
322
+ last = null) => ({
319
323
  text: (phone
320
324
  ? [
321
325
  headline,
@@ -323,6 +327,7 @@ export function proposalPreview(store, proposal, repos, channel = null) {
323
327
  ...(body.length === 0 ? [] : ["", ...body]),
324
328
  "",
325
329
  "Nothing changes until you confirm.",
330
+ ...(last === null ? [] : [last]),
326
331
  ]
327
332
  : [
328
333
  headline,
@@ -345,8 +350,10 @@ export function proposalPreview(store, proposal, repos, channel = null) {
345
350
  const action = sharedActionPayload(payload);
346
351
  if (!action)
347
352
  return handoff("This action is unavailable.");
348
- if (channel !== null && sharedActionAllowsChallenge(action))
349
- return card(phoneText(action.title, 200), action.terms.map((term) => phoneText(term, APPROVAL_TERM_CHARS)), "Confirm asks once more before anything is recorded.");
353
+ if (channel !== null && sharedActionAllowsChallenge(action, channel))
354
+ return card(phoneText(action.title, 200), action.terms.map((term) => phoneText(term, APPROVAL_TERM_CHARS)), "Confirm asks once more before anything is recorded.",
355
+ // A Start card on Telegram says what a typed yes does; no part of the lead's reply is ever inspected for it.
356
+ action.operation === "task_start" && channel === "telegram" ? TYPED_START_LINE : null);
350
357
  return sharedActionNeedsReview(action)
351
358
  ? handoff(phoneText(action.title, 200), [
352
359
  "Review the full details and confirm this exact action.",
@@ -652,6 +659,11 @@ export const CHAT_ACTION_PARITY = {
652
659
  how: "Card with Confirm/Dismiss through confirmLeadProposal (filed as a lead proposal, via telegram). The confirmed card links Review & start for the filed task while its scope waits; under a signed automatic mode it says the scope is approved and links the task.",
653
660
  gap: "Under manual approval the plan then needs the person's own yes, on its card.",
654
661
  },
662
+ propose_start: {
663
+ support: "direct",
664
+ how: "A protected shared action (task_start): Start work arms Yes and Cancel, and the person's own Yes files and approves one small task under the fast start rules (fastStartBuild), or a typed yes as a reply to the card or as the very next message. When Toolroll can't start it right away it is an ordinary task card.",
665
+ gap: "Terms too long for one message open the review page, in the signed-in console.",
666
+ },
655
667
  propose_sweep: {
656
668
  support: "direct",
657
669
  how: "A protected shared action (sweep_file): the card lists every item, the shared rules and the success test; confirming files them all and approves each scope.",
@@ -763,7 +775,7 @@ export function confirmedCardText(store, outcome, proposal, note) {
763
775
  // a revision; under a signed automatic mode the recorded scope is already
764
776
  // approved, and the card says that instead of asking again.
765
777
  const words = proposal.kind === "task" && state === "approved"
766
- ? `${said.split(" — ")[0]} — approved under your automatic approval settings; nothing more is needed from you.`
778
+ ? `${said.split(/ — |\. /)[0]} — approved under your automatic approval settings; nothing more is needed from you.`
767
779
  : revise && state === "approved"
768
780
  ? "Revision created under your automatic approval settings."
769
781
  : said;
@@ -775,3 +787,8 @@ export function confirmedCardText(store, outcome, proposal, note) {
775
787
  const setup = (approval || resume) && note !== null ? note : "";
776
788
  return [`✓ ${words}`, ...[next, setup].filter((one) => one !== "")].join("\n\n");
777
789
  }
790
+ /** The link to a task a Start card just started (a tapped card, and Toolroll's reply to a typed yes): the task's own
791
+ * page, only while the task is one of the person's projects. Navigation only. */
792
+ export function startedTaskLink(store, taskId, repos) {
793
+ return taskLink(store, taskId, repos);
794
+ }
@@ -33,7 +33,7 @@ import { gateWords } from "./approval-policy.js";
33
33
  import { releaseCardOf } from "./release-card.js";
34
34
  import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
35
35
  import { phoneText, projectLabel } from "./telegram-status.js";
36
- import { cardTermsDigest, chatLimitProblem, permissionPlainWords, scopeTermsText, shownTermsDigest } from "./chat-terms.js";
36
+ import { cardTermsDigest, chatLimitProblem, permissionPlainWords, scopeTermsText, shownAsWritten, shownTermsDigest } from "./chat-terms.js";
37
37
  import { chainTermsLines } from "./scope.js";
38
38
  import { YOUIYOUX_CARD_KIND, YOUIYOUX_FILED_VIA, YOUIYOUX_HOME_PATH } from "./contracts/youiyoux-request.js";
39
39
  import { FILED_PER_PASS, requestKindWord, requestPassOf } from "./youiyoux-intake.js";
@@ -191,9 +191,11 @@ function listedOf(digest) {
191
191
  * (after its escapes) Discord's limits, with room for the question a tap adds. Teams draws card text as Markdown, where
192
192
  * a link's label hides its address, and Slack turns `:name:` into an emoji: there such words never approve. */
193
193
  export function shownWhole(text, channel) {
194
+ if (!shownAsWritten(text, channel))
195
+ return false;
194
196
  switch (channel) {
195
197
  case "telegram": return text.length + ROOM <= 3_900;
196
- case "slack": return text.length + ROOM <= PLATFORM_LIMITS.slack && !/:[a-z0-9_+'-]+:/i.test(text);
198
+ case "slack": return text.length + ROOM <= PLATFORM_LIMITS.slack;
197
199
  case "discord": return discordPlain(text).length + ROOM <= PLATFORM_LIMITS.discord;
198
200
  case "teams": return false;
199
201
  }
@@ -67,7 +67,7 @@ export function applyChatTaskAction(store, who, payload, now, web, controls = {}
67
67
  if (!allowed.ok && controls.via === "web")
68
68
  return { ok: false, message: allowed.why };
69
69
  const resumed = resumeTaskStop(store, { taskId: task, runId: run, by: verifiedAuthor(who.name), via: controls.via ?? "web" }, now);
70
- return resumed.ok ? { ok: true, taskId: task, said: `Stop released. ${resumed.gate ? "Other requirements still prevent the next attempt." : "The next attempt may start."}` } : { ok: false, message: resumed.detail };
70
+ return resumed.ok ? { ok: true, taskId: task, said: `Stop lifted. ${resumed.gate ? "Something else still holds the next attempt." : "The next attempt may start."}` } : { ok: false, message: resumed.detail };
71
71
  }
72
72
  const { via, evidenceRoot: _root, ...rest } = controls;
73
73
  const result = requestTaskStop(store, { taskId: task, runId: run, by: verifiedAuthor(who.name), via: via ?? (web ? "web" : "cli"), ...rest }, now);
@@ -19,3 +19,11 @@ export declare function chatLimitProblem(scope: Pick<Scope, "profile" | "budgetM
19
19
  fullAccess: boolean;
20
20
  capMicrousd: number | null;
21
21
  }, yours: string): string | null;
22
+ /** The chat apps a card goes to. */
23
+ export type CardChannel = "telegram" | "slack" | "discord" | "teams";
24
+ /** Whether a chat app shows a card's words exactly as they are stored, so a tap there is a yes to those words. Teams
25
+ * draws card text as Markdown, where a link's label hides its address, and Slack turns `:name:` into an emoji: there
26
+ * the words go to the review page and no tap approves them. Telegram's card is plain text and Discord's escapes its
27
+ * presentation syntax. Both approval roads read this one rule (chat-decide.ts shownWhole, chat-actions.ts
28
+ * sharedActionAllowsChallenge), so neither can seal words the other would refuse. */
29
+ export declare function shownAsWritten(text: string, channel: CardChannel): boolean;
@@ -79,3 +79,17 @@ export function chatLimitProblem(scope, limits, yours) {
79
79
  return `This plan has ${scope.budgetMicrousd === null ? "no attempt limit" : `a ${money(scope.budgetMicrousd)} attempt limit`}, more than ${yours === "your mode" ? "your mode's" : "your chat approval limit of"} ${money(cap)}.`;
80
80
  return null;
81
81
  }
82
+ /** Whether a chat app shows a card's words exactly as they are stored, so a tap there is a yes to those words. Teams
83
+ * draws card text as Markdown, where a link's label hides its address, and Slack turns `:name:` into an emoji: there
84
+ * the words go to the review page and no tap approves them. Telegram's card is plain text and Discord's escapes its
85
+ * presentation syntax. Both approval roads read this one rule (chat-decide.ts shownWhole, chat-actions.ts
86
+ * sharedActionAllowsChallenge), so neither can seal words the other would refuse. */
87
+ export function shownAsWritten(text, channel) {
88
+ switch (channel) {
89
+ case "telegram":
90
+ case "discord": return true;
91
+ // Emoji names: letters and digits of any script (workspaces add their own), `_`, `+`, `'`, `-`.
92
+ case "slack": return !/:[\p{L}\p{N}_+'-]+:/u.test(text);
93
+ case "teams": return false;
94
+ }
95
+ }
@@ -4,7 +4,7 @@
4
4
  * task id, a "— revision" suffix or a person's own act never reaches a chat.
5
5
  * The console keeps every exact name and fact. */
6
6
  import { leadOnIt } from "./task-status.js";
7
- import { phoneText } from "./telegram-status.js";
7
+ import { publicChatText as phoneText } from "./chat-display.js";
8
8
  /** The bot's display name in every chat app. */
9
9
  export const BOT_NAME = "Toolroll";
10
10
  /** Updates for one person landing within this window become one message, edited in place as it grows. */
@@ -353,6 +353,11 @@ export declare const chatActionRequestSchemas: {
353
353
  task: z.ZodString;
354
354
  run: z.ZodInt;
355
355
  }, z.core.$strict>;
356
+ /** One-step start from chat: the project and the preview the lead's turn loop prepared (never the model's id). */
357
+ readonly task_start: z.ZodObject<{
358
+ repo: z.ZodString;
359
+ preview: z.ZodString;
360
+ }, z.core.$strict>;
356
361
  };
357
362
  export type ChatActionOperation = keyof typeof chatActionRequestSchemas;
358
363
  export declare const CHAT_ACTION_OPERATIONS: [ChatActionOperation, ...ChatActionOperation[]];
@@ -435,6 +440,7 @@ export declare const sharedActionSchema: z.ZodObject<{
435
440
  task_reopen: "task_reopen";
436
441
  task_restore: "task_restore";
437
442
  task_resume: "task_resume";
443
+ task_start: "task_start";
438
444
  tool_add: "tool_add";
439
445
  tool_remove: "tool_remove";
440
446
  trial_stop: "trial_stop";
@@ -111,6 +111,8 @@ export const chatActionRequestSchemas = {
111
111
  result_accept: z.strictObject({ task: words, run: id }),
112
112
  task_cancel: z.strictObject({ task: words }),
113
113
  task_resume: z.strictObject({ task: words, run: id }),
114
+ /** One-step start from chat: the project and the preview the lead's turn loop prepared (never the model's id). */
115
+ task_start: z.strictObject({ repo: words, preview: words }),
114
116
  };
115
117
  export const CHAT_ACTION_OPERATIONS = Object.keys(chatActionRequestSchemas);
116
118
  /** The fields each action takes, from its schema. */
@@ -209,6 +209,12 @@ export declare const COMMAND_OUTPUTS: {
209
209
  task: z.ZodNullable<z.ZodString>;
210
210
  line: z.ZodString;
211
211
  }, z.core.$loose>>;
212
+ figures: z.ZodOptional<z.ZodObject<{
213
+ waitingOnYou: z.ZodNumber;
214
+ running: z.ZodNumber;
215
+ queued: z.ZodNumber;
216
+ done: z.ZodNumber;
217
+ }, z.core.$loose>>;
212
218
  projects: z.ZodArray<z.ZodObject<{
213
219
  repo: z.ZodString;
214
220
  name: z.ZodString;
@@ -85,6 +85,9 @@ const statusOutput = answers("status", {
85
85
  signIn: rows({ provider: str, reason: str, command: str, since: str }),
86
86
  tasks: rows({ task: str, title: str, headline: str, sentence: str }),
87
87
  lead: nullable(z.looseObject({ owner: str, doing: str, at: str, task: nullable(str), line: str })),
88
+ // Home's figures (home-groups.ts), the opening line's counts: each is the count of the Tasks list it names. An
89
+ // older Toolroll's answer has none.
90
+ figures: z.looseObject({ waitingOnYou: int, running: int, queued: int, done: int }).optional(),
88
91
  projects: rows({ repo: str, name: str, running: int, limit: int }),
89
92
  update: z.looseObject({ current: str, latest: str, security: z.boolean(), updateCommand: str, url: str }).optional(),
90
93
  integrations: z.looseObject({ broken: rows({ key: str, name: str, fix: nullable(str) }) }).optional(),