toolroll 0.9.97 → 0.9.99

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 (90) hide show
  1. package/CHANGELOG.md +15 -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/checkout-cleanup.js +2 -1
  17. package/dist/contracts/chat-actions.d.ts +6 -0
  18. package/dist/contracts/chat-actions.js +2 -0
  19. package/dist/contracts/cli.d.ts +6 -0
  20. package/dist/contracts/cli.js +3 -0
  21. package/dist/contracts/lead-tools.d.ts +17 -0
  22. package/dist/contracts/lead-tools.js +4 -1
  23. package/dist/contracts/store-json.d.ts +1 -0
  24. package/dist/deploy-phase-data.json +21 -0
  25. package/dist/deploy-phase.d.ts +37 -0
  26. package/dist/deploy-phase.js +64 -0
  27. package/dist/deploy-rescue.d.ts +2 -1
  28. package/dist/deploy-rescue.js +6 -7
  29. package/dist/desktop-update.d.ts +2 -2
  30. package/dist/fast-start.d.ts +83 -6
  31. package/dist/fast-start.js +112 -15
  32. package/dist/home-groups.d.ts +39 -0
  33. package/dist/home-groups.js +33 -0
  34. package/dist/lead-answer-eval.d.ts +7 -0
  35. package/dist/lead-answer-eval.js +6 -2
  36. package/dist/lead-capabilities.js +1 -1
  37. package/dist/lead-context.js +2 -2
  38. package/dist/lead-contract.d.ts +1 -1
  39. package/dist/lead-contract.js +10 -7
  40. package/dist/lead-doors.d.ts +32 -0
  41. package/dist/lead-doors.js +80 -14
  42. package/dist/lead-status.d.ts +3 -0
  43. package/dist/lead-status.js +8 -3
  44. package/dist/lead-tools.d.ts +20 -0
  45. package/dist/lead-tools.js +66 -3
  46. package/dist/lead-voice.js +1 -1
  47. package/dist/lead.js +18 -2
  48. package/dist/local-owner.js +2 -0
  49. package/dist/one-step-chat-schema.d.ts +22 -0
  50. package/dist/one-step-chat-schema.js +37 -0
  51. package/dist/operate.d.ts +27 -1
  52. package/dist/operate.js +206 -32
  53. package/dist/project-delete.js +9 -0
  54. package/dist/project-knowledge.d.ts +7 -7
  55. package/dist/publish.d.ts +2 -0
  56. package/dist/publish.js +1 -1
  57. package/dist/pull-request-flow.d.ts +4 -0
  58. package/dist/pull-request-flow.js +16 -1
  59. package/dist/release-adapters.js +30 -14
  60. package/dist/release-gate.d.ts +3 -3
  61. package/dist/release-gate.js +7 -4
  62. package/dist/release-next.d.ts +3 -3
  63. package/dist/release-next.js +13 -16
  64. package/dist/reply-voice.d.ts +23 -0
  65. package/dist/reply-voice.js +37 -0
  66. package/dist/scope.d.ts +6 -1
  67. package/dist/scope.js +3 -2
  68. package/dist/server/chat.js +4 -4
  69. package/dist/server/chrome.js +2 -2
  70. package/dist/server/home.d.ts +4 -3
  71. package/dist/server/home.js +33 -28
  72. package/dist/server/render-chat.js +11 -6
  73. package/dist/server/render-pages.js +2 -2
  74. package/dist/setup-guide.d.ts +2 -2
  75. package/dist/staged-runtimes.js +2 -1
  76. package/dist/store.d.ts +4 -2
  77. package/dist/store.js +35 -12
  78. package/dist/telegram-lead.js +69 -5
  79. package/dist/telegram-status.js +9 -4
  80. package/dist/telegram.d.ts +10 -0
  81. package/dist/telegram.js +92 -8
  82. package/dist/toolroll-update.js +3 -1
  83. package/dist/tui-contract.d.ts +2 -0
  84. package/dist/tui-source.js +3 -0
  85. package/dist/tui.js +41 -41
  86. package/dist/typed-start.d.ts +51 -0
  87. package/dist/typed-start.js +98 -0
  88. package/dist/wake-hub.d.ts +72 -0
  89. package/dist/wake-hub.js +151 -0
  90. package/package.json +1 -1
@@ -1,15 +1,16 @@
1
1
  import { browserWorkActionHref } from "../browser-crew.js";
2
- import { ASK_LABEL } from "../needs-you.js";
2
+ import { HOME_GROUP_LABEL, homeGroupOf } from "../home-groups.js";
3
3
  import { projectName } from "../project.js";
4
4
  import { workIndexPage } from "../work-index.js";
5
5
  import { relativeAge } from "./chrome.js";
6
6
  import { taskHref } from "./http.js";
7
7
  const TONE = { attention: "needs", ready: "needs", live: "live", done: "done", problem: "failed", muted: "", neutral: "" };
8
- const ASKS = ["decide", "review", "unblock"];
9
8
  const ASK_WORD = { decide: "Decision", review: "Result", unblock: "Blocked" };
10
9
  /** How many waiting rows Home lists; the rest open in Tasks. */
11
10
  export const HOME_NEEDS_SHOWN = 30;
12
11
  const HOME_RUNNING_SHOWN = 8;
12
+ /** How many rows finished in the last 7 days Home lists; the rest open in Tasks. */
13
+ const HOME_DONE_SHOWN = 8;
13
14
  /** "7 need you across 2 projects." — the sentence and the figures read the same totals. */
14
15
  export function homeSentence(needs, perProject, scope, any) {
15
16
  if (!any)
@@ -51,50 +52,54 @@ function rowOf(store, item, now, multi, canAnswer, closedDoor) {
51
52
  : href === null || action === null ? null : { label: action.label, href }, question,
52
53
  };
53
54
  }
54
- /** Home's view for one person and scope, from two reads of the work index (what waits, what runs). */
55
+ /** Home's view for one person and scope, from four reads of the work index (what waits on you, what runs, what waits on
56
+ * Toolroll, what was done), grouped by the one rule every surface shares (home-groups.ts). */
55
57
  export function homeView(facts) {
56
58
  const { store, now, access, project } = facts;
57
59
  const read = (view, limit) => workIndexPage(store, now, access, { view, limit, project, ...(facts.evidenceRoot === undefined ? {} : { root: facts.evidenceRoot }) });
58
60
  const needs = read("needs-you", HOME_NEEDS_SHOWN);
59
61
  const running = read("running", HOME_RUNNING_SHOWN);
60
- // Waiting (docs/plans/plain-toolroll.md P1.4): what Toolroll or another task moves on, with no person asked.
61
- const waiting = workIndexPage(store, now, access, { view: "queued", limit: HOME_RUNNING_SHOWN, project, ...(facts.evidenceRoot === undefined ? {} : { root: facts.evidenceRoot }) });
62
+ // Queued: what waits its turn, or what Toolroll or another task moves on, with no person asked.
63
+ const waiting = read("queued", HOME_RUNNING_SHOWN);
64
+ // Done: the Completed list is ordered by completion time, newest first, so its first rows are the newest finished;
65
+ // only those finished in the last 7 days are listed, and that filter runs before the cap.
66
+ const completed = read("completed", HOME_DONE_SHOWN);
62
67
  const totals = needs.totals;
63
68
  // Queued: the Tasks list's own Queued count (the same predicate its view=queued filter lists).
64
69
  const queued = totals.queued;
65
70
  // Every project is said by name (scope=all): /tasks alone would narrow to the session's open project.
66
71
  const scopeQuery = project === null ? "scope=all&" : `project=${encodeURIComponent(project)}&`;
72
+ // Each figure keeps the name of the list it opens; Done is the whole Completed list, not just this week.
67
73
  const counts = [
68
- { key: "needs", label: "Needs you", value: totals["needs-you"], href: `/tasks?${scopeQuery}view=needs-you` },
74
+ { key: "needs", label: HOME_GROUP_LABEL.you, value: totals["needs-you"], href: `/tasks?${scopeQuery}view=needs-you` },
69
75
  { key: "running", label: "Running", value: totals.running, href: `/tasks?${scopeQuery}view=running` },
70
76
  { key: "queued", label: "Queued", value: queued, href: `/tasks?${scopeQuery}view=queued` },
71
- { key: "finished", label: "Finished", value: totals.completed, href: `/tasks?${scopeQuery}view=completed` },
77
+ { key: "finished", label: HOME_GROUP_LABEL.done, value: totals.completed, href: `/tasks?${scopeQuery}view=completed` },
72
78
  ];
73
- const multi = project === null && new Set([...needs.items, ...running.items].map(one => one.repo ?? "")).size > 1;
79
+ // One row per family, in the first group that claims it.
80
+ const seen = new Set();
81
+ const take = (items, group) => items.filter(one => {
82
+ if (homeGroupOf(one) !== group || seen.has(one.rootId))
83
+ return false;
84
+ seen.add(one.rootId);
85
+ return true;
86
+ });
87
+ // Waiting on you, in today's order of what it asks: decide (with any row whose ask the index couldn't name, which
88
+ // still waits on this person and is never lost), then review, then unblock. Each row's chip names its ask.
89
+ const yours = take(["decide", null, "review", "unblock"].flatMap(ask => needs.items.filter(one => one.ask === ask)), "you");
90
+ // Working: what runs, then what is queued (waits its turn, or on Toolroll or another task), each read with its own bound.
91
+ const working = [...take(running.items, "working"), ...take(waiting.items, "working")];
92
+ const weekAgo = new Date(now.getTime() - 7 * 86_400_000).toISOString();
93
+ const done = take(completed.items.filter(one => (one.completion?.at ?? one.updatedAt) >= weekAgo), "done").slice(0, HOME_DONE_SHOWN);
94
+ const multi = project === null && new Set([...yours, ...working, ...done].map(one => one.repo ?? "")).size > 1;
74
95
  const groups = [];
75
- for (const ask of ASKS) {
76
- const rows = needs.items.filter(one => one.ask === ask).map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor));
77
- if (rows.length > 0)
78
- groups.push({ key: ask, label: ASK_LABEL[ask], rows });
96
+ for (const [key, items] of [["you", yours], ["working", working], ["done", done]]) {
97
+ if (items.length > 0)
98
+ groups.push({ key, label: HOME_GROUP_LABEL[key], rows: items.map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor)) });
79
99
  }
80
- // A waiting row whose ask the index couldn't name still waits on this person: it goes under Decide, never lost.
81
- const unnamed = needs.items.filter(one => one.ask === null).map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor));
82
- if (unnamed.length > 0) {
83
- const decide = groups.find(one => one.key === "decide");
84
- if (decide)
85
- decide.rows.push(...unnamed);
86
- else
87
- groups.unshift({ key: "decide", label: ASK_LABEL.decide, rows: unnamed });
88
- }
89
- const live = running.items.filter(one => !one.status.views.includes("needs-you")).map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor));
90
- if (live.length > 0)
91
- groups.push({ key: "running", label: "Running", rows: live });
92
- const waits = waiting.items.filter(one => one.status.label === "Waiting" && !one.status.views.includes("needs-you")).map(one => rowOf(store, one, now, multi, facts.canAnswer, facts.closedDoor));
93
- if (waits.length > 0)
94
- groups.push({ key: "waiting", label: "Waiting", rows: waits });
95
100
  const perProject = needs.projects.map(one => ({ name: one.repo === null ? "Unplaced" : projectName(one.repo), needs: one.totals["needs-you"] }));
96
101
  const any = totals.all > 0 || totals.completed > 0 || (totals.archived ?? 0) > 0;
97
- const shown = groups.filter(one => one.key !== "running" && one.key !== "waiting").reduce((sum, one) => sum + one.rows.length, 0);
102
+ const shown = yours.length;
98
103
  return {
99
104
  kind: "home",
100
105
  say: homeSentence(totals["needs-you"], perProject, project === null ? null : projectName(project), any),
@@ -396,7 +396,7 @@ export function chatLimitsHtml(facts) {
396
396
  html `<span>${facts.turnsToday} / ${facts.dailyTurns} turns today</span>`,
397
397
  ];
398
398
  if (facts.subscription)
399
- rows.push(html `<span>membership login · no dollar ceiling</span>`);
399
+ rows.push(html `<span>membership login · no dollar limit</span>`);
400
400
  else if (facts.weekly !== null)
401
401
  rows.push(html `<span>this week ${chatMoney(facts.weekly.spent)} of ${chatMoney(facts.weekly.ceiling)}</span>`);
402
402
  return html `<details class="chat-limits"><summary>Model &amp; limits<span class="meta">${facts.subscription ? "membership" : facts.provider}</span></summary><div class="chat-budget">${rows}</div></details>`;
@@ -564,7 +564,7 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
564
564
  hold: { label: "Pause work", action: "hold", icon: html `<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>` },
565
565
  unhold: { label: "Resume work", action: "release hold", icon: html `<path d="m7 4 13 8-13 8z"/>` },
566
566
  steer: { label: "Guidance for the agent", action: "add guidance", icon: html `<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>` },
567
- scope: { label: "Scope revision", action: "save scope", icon: html `<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4z"/>` },
567
+ scope: { label: "Plan change", action: "save plan", icon: html `<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4z"/>` },
568
568
  answer: { label: "Decision answer", action: "confirm answer", icon: html `<path d="M9.1 9a3 3 0 1 1 5.8 1c0 2-3 2-3 4"/><path d="M12 18h.01"/><circle cx="12" cy="12" r="9"/>` },
569
569
  cancel: { label: "Cancel task", action: "open task", icon: html `<path d="m15 9-6 6"/><path d="m9 9 6 6"/><circle cx="12" cy="12" r="9"/>` },
570
570
  repair: {
@@ -599,7 +599,8 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
599
599
  }
600
600
  else if (view.kind === "action") {
601
601
  const action = sharedActionPayload(payload);
602
- what = action === null ? html `<p>This action is unavailable.</p>` : (sharedActionNeedsReview(action) ? html `` : html `${action.terms.map(term => html `<p style="white-space:pre-wrap;overflow-wrap:anywhere">${term}</p>`)}`);
602
+ // A Start card (one-step-chat.md C2) names what starts and where; its whole terms and the confirm are on the review page.
603
+ what = action === null ? html `<p>This action is unavailable.</p>` : action.operation === "task_start" ? html `<h3>${action.title}</h3>${facts(["project", mono(action.repo.split(/[\\/]/).filter(Boolean).at(-1) ?? action.repo)])}` : (sharedActionNeedsReview(action) ? html `` : html `${action.terms.map(term => html `<p style="white-space:pre-wrap;overflow-wrap:anywhere">${term}</p>`)}`);
603
604
  }
604
605
  else if (view.kind === "task_action") {
605
606
  const operation = payload["operation"];
@@ -723,7 +724,7 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
723
724
  if (view.state === "pending" && !inert) {
724
725
  acts =
725
726
  view.kind === "action" && sharedActionPayload(payload) !== null && sharedActionNeedsReview(sharedActionPayload(payload))
726
- ? html `<a class="button-link" href="${sharedActionReviewPath(view.id)}">Review action</a>${dismissForm(html `<button class="quiet">Dismiss</button>`)}`
727
+ ? html `<a class="button-link" href="${sharedActionReviewPath(view.id)}">${reviewWords(sharedActionPayload(payload))}</a>${dismissForm(html `<button class="quiet">Dismiss</button>`)}`
727
728
  : view.kind === "control"
728
729
  ? (isChatControl(payload["control"]) ? html `<a class="button-link" href="${chatControlHref(payload["control"], task, payload["run"], payload["project"])}">${CHAT_CONTROLS[payload["control"]].label}</a>` : html `<p>Control unavailable.</p>`)
729
730
  : view.kind === "cancel"
@@ -758,7 +759,7 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
758
759
  const sentence = (words) => words.charAt(0).toUpperCase() + words.slice(1);
759
760
  const pending = view.state === "pending" && !inert;
760
761
  const primary = !pending ? null
761
- : reviewAction !== null && sharedActionNeedsReview(reviewAction) ? { kind: "link", label: "Review action", href: sharedActionReviewPath(view.id) }
762
+ : reviewAction !== null && sharedActionNeedsReview(reviewAction) ? { kind: "link", label: reviewWords(reviewAction), href: sharedActionReviewPath(view.id) }
762
763
  : view.kind === "control" ? (isChatControl(payload["control"]) ? { kind: "link", label: CHAT_CONTROLS[payload["control"]].label, href: chatControlHref(payload["control"], task, payload["run"], payload["project"]) } : null)
763
764
  : view.kind === "cancel" ? { kind: "link", label: "Open task", href: taskHref(task) }
764
765
  : { kind: "confirm", label: sentence(presentation.action), irreversible, native: view.kind === "task_action" && payload["operation"] === "resume" };
@@ -777,6 +778,10 @@ export function proposalCardParts(view, inert, decision, returnTo = null) {
777
778
  };
778
779
  return { html: cardHtml, card };
779
780
  }
781
+ /** The review link's words: a Start card's is "Review and start". */
782
+ function reviewWords(action) {
783
+ return action?.operation === "task_start" ? "Review and start" : "Review action";
784
+ }
780
785
  export function leadProposalCard(proposal, inert, decision, returnTo = null) {
781
786
  return leadProposalCardParts(proposal, inert, decision, returnTo).html;
782
787
  }
@@ -981,7 +986,7 @@ export function leadPage(chrome, data) {
981
986
  data.session === null ? "" : html `<details class="lead-follow"><summary>Automatic crew updates${data.follow?.enabled ? ' · On' : ''}</summary><p class="meta">${data.follow?.detail ?? 'Automatic crew updates are off.'}</p>${postForm("/chat/mate/follow", html `${data.follow?.enabled ? '' : html `<p>The lead responds when results or decisions arrive. Uses this conversation’s ${subscription ? 'membership usage, with no dollar maximum' : 'remaining spend allowance'} and daily turn limit. Existing task approvals still apply.</p>`}<button type="submit" class="quiet">${data.follow?.enabled ? 'Pause updates' : 'Enable updates'}</button>`, { returnTo, hidden: { enabled: data.follow?.enabled ? 'no' : 'yes' } })}</details>`,
982
987
  html `<details class="chat-limits chat-session-details"><summary>Conversation details<span class="meta">${subscription ? "membership" : data.config.provider}</span></summary>\n${joinHtml([
983
988
  html `<div class="chat-budget"><span class="mono">${data.config.provider} · ${data.config.model}</span><span>${data.turnsToday} / ${data.config.dailyTurns} turns today</span>${subscription
984
- ? html `<span>membership login · no dollar ceiling</span>`
989
+ ? html `<span>membership login · no dollar limit</span>`
985
990
  : html `<span>this conversation: ${chatMoney(data.session?.spentMicrousd ?? 0)} of ${chatMoney(data.session?.ceilingMicrousd ?? 0)}</span><span>this week ${chatMoney(data.weeklySpent)} of ${chatMoney(data.config.weeklyCeilingMicrousd)}</span>`}</div>`,
986
991
  data.session === null
987
992
  ? html `<p class="meta">No conversation yet. Send a message to start one.</p>`
@@ -439,7 +439,7 @@ export function workPage(chrome, data) {
439
439
  ['finished', 'Finished', data.board.finished, 'completed', 'Nothing has finished yet.'],
440
440
  ].map(([key, label, page, view, empty]) => ({ key, label, count: page.totals[view], href: query({ view, layout: 'list' }), empty, rows: page.items.map(rowOf) }));
441
441
  const putAwayLink = data.canPutAway === true && data.view !== 'archived' ? { label: 'Archive old tasks', href: '/tasks/archive' } : null;
442
- const toolLinks = [['/tasks?view=order', 'Order the queue'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/tasks?scope=all', 'Every project']]), ['/ledger', 'Action ledger']];
442
+ const toolLinks = [['/tasks?view=order', 'Order the queue'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/tasks?scope=all', 'Every project']]), ['/ledger', 'Audit log']];
443
443
  const newTaskHref = lens === null ? '/tasks/new' : `/tasks/new?project=${encodeURIComponent(lens)}`;
444
444
  const emptyNow = data.work.items.length > 0 ? null : filtered && !data.previous ? { text: `No tasks are ${data.state} in this view.`, action: { label: 'Show every state', href: href(data.view, undefined, null) } } : {
445
445
  text: data.previous ? 'There are no more tasks on this page.' : current.empty,
@@ -496,7 +496,7 @@ export function workPage(chrome, data) {
496
496
  ? html `<div class="work-empty" data-work-empty="${data.view}"><p>${data.previous ? 'There are no more tasks on this page.' : current.empty}</p>${data.view === 'all' && !data.previous ? html `<p><a class="button-link" href="${chrome.chat === true ? '/chat' : '/tasks/new'}">${chrome.chat === true ? 'Start in chat' : 'Add a task'}</a></p>` : html `<a href="${href('all')}">See all tasks →</a>`}</div>`
497
497
  : html `<div class="work-list">${data.work.items.map(rowHtml)}</div>`;
498
498
  const pages = !data.previous && data.work.nextCursor === null ? null : html `<nav class="row work-pagination" aria-label="Task pages">${data.previous ? html `<a class="button-link" href="${href(data.view)}">First page</a>` : null}${data.work.nextCursor === null ? null : html `<a class="button-link" rel="next" href="${href(data.view, data.work.nextCursor)}">Next page</a>`}</nav>`;
499
- const tools = html `<details class="work-tools"><summary>Work tools${CHEVRON_ICON}</summary><nav class="work-tools-menu">${[['/', 'Home'], ['/tasks?view=board', 'Board'], ['/tasks?view=order', 'Order'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/tasks?scope=all', 'Every project']]), ['/ledger', 'Action ledger']]
499
+ const tools = html `<details class="work-tools"><summary>Work tools${CHEVRON_ICON}</summary><nav class="work-tools-menu">${[['/', 'Home'], ['/tasks?view=board', 'Board'], ['/tasks?view=order', 'Order'], ['/recipes', 'Recipes'], ...(chrome.projectScoped ? [] : [['/tasks?scope=all', 'Every project']]), ['/ledger', 'Audit log']]
500
500
  .map(([path, label]) => html `<a href="${path}">${label}</a>`)}</nav></details>`;
501
501
  return screen('work', html `<div class="work-head"><h1>Tasks</h1>${putAwayLink === null ? null : html `<a class="button-link" href="${putAwayLink.href}" data-put-away-old>${putAwayLink.label}</a>`}${tools}</div>${limitsHtml(data.limits ?? null)}${legacyTabs}${stateLinks}${list}${pages}`, { chrome, workspace: { view } });
502
502
  }
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
+ content?: never;
18
19
  ok: false;
19
20
  message: string;
20
21
  plan?: never;
21
- content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
+ content?: never;
33
34
  ok: false;
34
35
  message: string;
35
36
  plan?: never;
36
- content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;
@@ -12,6 +12,7 @@ import { lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSyn
12
12
  import { execFileSync } from "node:child_process";
13
13
  import { homedir } from "node:os";
14
14
  import { basename, dirname, join, resolve, sep } from "node:path";
15
+ import { isDeployed } from "./deploy-phase.js";
15
16
  import { quarantine, quarantineEntries, claimQuarantine, releaseQuarantine, restoreQuarantine, deleteQuarantineSync } from "./sweep-quarantine.js";
16
17
  import { processCensus } from "./run-closeout.js";
17
18
  const DAY_MS = 86_400_000;
@@ -84,7 +85,7 @@ function readStage(path, name, fallbackAt) {
84
85
  at = 0;
85
86
  }
86
87
  }
87
- const finished = deploy !== null ? deploy["phase"] === "deployed" : update !== null ? typeof update["finishedAt"] === "string" : false;
88
+ const finished = deploy !== null ? isDeployed(deploy["phase"]) : update !== null ? typeof update["finishedAt"] === "string" : false;
88
89
  const from = update?.["from"];
89
90
  const prior = typeof deploy?.["priorRuntime"] === "string" ? deploy["priorRuntime"] : typeof from?.dist === "string" ? from.dist : null;
90
91
  let keptAside = false;
package/dist/store.d.ts CHANGED
@@ -10,6 +10,7 @@ import { V155_STEPS } from "./fleet-schema.js";
10
10
  import { V156_STEPS } from "./fallback-schema.js";
11
11
  import { V158_STEPS, type LeadFailureDetail, type LeadRoute } from "./lead-latency-schema.js";
12
12
  import { V157_STEPS } from "./chat-live-schema.js";
13
+ import { V159_STEPS } from "./one-step-chat-schema.js";
13
14
  export { refArchived, taskArchived } from "./task-archive.js";
14
15
  import { type ChatBinding } from "./chat-core.js";
15
16
  import { ChatMessages, TELEGRAM_HOLD_REASONS, TELEGRAM_SKIPPED_ELSEWHERE, TELEGRAM_SKIPPED_OTHER_CHAT, TELEGRAM_SKIPPED_QUIET } from "./chat-messages.js";
@@ -250,7 +251,7 @@ export type SealedLedgerEntry = LedgerEntry & {
250
251
  hash: string;
251
252
  } | null;
252
253
  };
253
- export declare const SCHEMA_VERSION = 158;
254
+ export declare const SCHEMA_VERSION = 159;
254
255
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
255
256
  export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
256
257
  /**
@@ -442,7 +443,7 @@ export declare const V147_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record
442
443
  }>>>>;
443
444
  /** The v147 migration's steps after the epoch, in order: a test seam (OpenOptions.migrationStep) may stop after each. */
444
445
  export declare const V147_STEPS: readonly ["epoch", "local-mode", "password-set", "web-session", "console-instance"];
445
- export type MigrationStep = (typeof V147_STEPS)[number] | (typeof V148_STEPS)[number] | (typeof V149_STEPS)[number] | (typeof V150_STEPS)[number] | (typeof V151_STEPS)[number] | (typeof V152_STEPS)[number] | (typeof V153_STEPS)[number] | (typeof V154_STEPS)[number] | (typeof V156_STEPS)[number] | (typeof V155_STEPS)[number] | (typeof V157_STEPS)[number] | (typeof V158_STEPS)[number];
446
+ export type MigrationStep = (typeof V147_STEPS)[number] | (typeof V148_STEPS)[number] | (typeof V149_STEPS)[number] | (typeof V150_STEPS)[number] | (typeof V151_STEPS)[number] | (typeof V152_STEPS)[number] | (typeof V153_STEPS)[number] | (typeof V154_STEPS)[number] | (typeof V156_STEPS)[number] | (typeof V155_STEPS)[number] | (typeof V157_STEPS)[number] | (typeof V158_STEPS)[number] | (typeof V159_STEPS)[number];
446
447
  /**
447
448
  * v148 (docs/plans/results-routing.md): every table the results-routing arc uses, landed at once because a current
448
449
  * store runs no DDL. Slice 1 writes run_cohort, route_record and models_generation; route_rule, route_exposure* and
@@ -532,6 +533,7 @@ export { V155_STEPS };
532
533
  export { V156_STEPS };
533
534
  export { V157_STEPS };
534
535
  export { V158_STEPS };
536
+ export { V159_STEPS };
535
537
  /**
536
538
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
537
539
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
package/dist/store.js CHANGED
@@ -15,6 +15,7 @@ import { FINDING_SOURCES, FINDING_SOURCES_BEFORE_LEAD, V155_SCHEMA, V155_STEPS }
15
15
  import { V156_SCHEMA, V156_STEPS } from "./fallback-schema.js";
16
16
  import { V158_ADDED_COLUMNS, V158_SCHEMA, V158_STEPS, LEAD_FAILURE_DETAILS, LEAD_ROUTES } from "./lead-latency-schema.js";
17
17
  import { V157_SCHEMA, V157_STEPS } from "./chat-live-schema.js";
18
+ import { V159_ADDED_COLUMNS, V159_SCHEMA, V159_STEPS } from "./one-step-chat-schema.js";
18
19
  import { refArchived, taskArchived } from "./task-archive.js";
19
20
  export { refArchived, taskArchived } from "./task-archive.js";
20
21
  import { CHAT_PROVIDERS, CHAT_SCHEMA } from "./contracts/chat-tables.js";
@@ -1148,7 +1149,10 @@ const REGISTERED_AT_FILING = "again, as it was filed";
1148
1149
  // v158 adds, and changes no saved row (docs/plans/responsive-chat.md, slice 5): the tools each lead step asked for, an
1149
1150
  // exact failure detail on a step and a reply, which model answered a reply (mate_turn.route), and the quick-answer
1150
1151
  // switch (lead_quick_setting). V158_SCHEMA, V158_ADDED_COLUMNS (lead-latency-schema.ts): saved rows read NULL.
1151
- export const SCHEMA_VERSION = 158;
1152
+ // v159 adds, and changes no saved row (docs/plans/one-step-chat.md): the lead turn a chat start's preview was prepared
1153
+ // in (fast_preview.turn) and a typed yes's record (chat_typed_start, a new table). V159_SCHEMA, V159_ADDED_COLUMNS
1154
+ // (one-step-chat-schema.ts): saved rows read NULL.
1155
+ export const SCHEMA_VERSION = 159;
1152
1156
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
1153
1157
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
1154
1158
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -1281,7 +1285,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
1281
1285
  * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
1282
1286
  * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
1283
1287
  */
1284
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158]);
1288
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159]);
1285
1289
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
1286
1290
  export const V119_ADDED_COLUMNS = Object.freeze({
1287
1291
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -1944,6 +1948,7 @@ export { V155_STEPS };
1944
1948
  export { V156_STEPS };
1945
1949
  export { V157_STEPS };
1946
1950
  export { V158_STEPS };
1951
+ export { V159_STEPS };
1947
1952
  /**
1948
1953
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
1949
1954
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -5293,6 +5298,13 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
5293
5298
  for (const [column, { definition }] of Object.entries(columns))
5294
5299
  addColumn(db, table, column, definition);
5295
5300
  step("lead-latency-columns");
5301
+ // v159: one-step start from chat (V159_SCHEMA, V159_ADDED_COLUMNS: saved rows read NULL). Idempotent, as v158's are.
5302
+ db.exec(V159_SCHEMA);
5303
+ step("one-step-chat-tables");
5304
+ for (const [table, columns] of Object.entries(V159_ADDED_COLUMNS))
5305
+ for (const [column, { definition }] of Object.entries(columns))
5306
+ addColumn(db, table, column, definition);
5307
+ step("one-step-chat-columns");
5296
5308
  // Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
5297
5309
  // nothing to reclaim.
5298
5310
  if (!fresh)
@@ -8244,6 +8256,9 @@ export class Store {
8244
8256
  throw new ReleaseSealRefused();
8245
8257
  }
8246
8258
  if (changed.changes > 0) {
8259
+ // A real seal is work a builder can take now: wake the loops (live-wakeups.md). Inside the seal's own
8260
+ // transaction, so a seal that rolls back wakes nothing. The claim re-proves the approval regardless.
8261
+ this.bumpWake();
8247
8262
  // The project's tools this yes saw (v80): a later addition needs a later yes.
8248
8263
  this.sealProjectTools(taskId, String(working["digest"]), now);
8249
8264
  // A fresh yes lifts the stale-approval hold and closes its page
@@ -11754,17 +11769,23 @@ export class Store {
11754
11769
  * enough, and neither is a stale yes).
11755
11770
  */
11756
11771
  markCapability(repo, kind, name, outcome, now, expiresAt = null) {
11757
- const { changes } = this.db
11758
- .prepare(`UPDATE capability SET status = ?, last_verified_at = ?, verified_by = ?, last_result = ?, expires_at = COALESCE(?, expires_at)
11772
+ return this.transact(() => {
11773
+ // The status before this answer, read in the same transaction: only a gap that fills wakes the loop. A
11774
+ // repeated passing probe refreshes the timestamp and says nothing new, so it must not wake (live-wakeups.md).
11775
+ const prior = this.db.prepare("SELECT status FROM capability WHERE repo = ? AND kind = ? AND name = ?").get(repo, kind, name);
11776
+ const { changes } = this.db
11777
+ .prepare(`UPDATE capability SET status = ?, last_verified_at = ?, verified_by = ?, last_result = ?, expires_at = COALESCE(?, expires_at)
11759
11778
  WHERE repo = ? AND kind = ? AND name = ?`)
11760
- .run(outcome.status, outcome.status === "verified" ? now.toISOString() : null, outcome.by, outcome.detail ?? null, expiresAt, repo, kind, name);
11761
- // A gap that just filled ends its notification episode: the next failure
11762
- // is a new fact, and must be allowed to say so.
11763
- if (Number(changes) > 0 && outcome.status === "verified") {
11764
- this.clearGapEpisode(repo, kind, name);
11765
- this.bumpWake();
11766
- }
11767
- return Number(changes) > 0;
11779
+ .run(outcome.status, outcome.status === "verified" ? now.toISOString() : null, outcome.by, outcome.detail ?? null, expiresAt, repo, kind, name);
11780
+ // A gap that just filled ends its notification episode: the next failure
11781
+ // is a new fact, and must be allowed to say so.
11782
+ if (Number(changes) > 0 && outcome.status === "verified") {
11783
+ this.clearGapEpisode(repo, kind, name);
11784
+ if (prior?.["status"] !== "verified")
11785
+ this.bumpWake();
11786
+ }
11787
+ return Number(changes) > 0;
11788
+ });
11768
11789
  }
11769
11790
  /** Set what a task needs, as `kind:name` keys, replacing what it needed before. */
11770
11791
  setRequirements(taskRef, keys, mutation = {}) {
@@ -16452,6 +16473,8 @@ export class Store {
16452
16473
  generation = generation + 1, last_error = NULL
16453
16474
  WHERE id = ? AND state = 'waiting-human'`)
16454
16475
  .run(id);
16476
+ // The person's yes makes the merge actionable now: wake the loops (live-wakeups.md). The firing CAS re-proves it.
16477
+ this.bumpWake();
16455
16478
  return { ok: true };
16456
16479
  });
16457
16480
  }
@@ -1,7 +1,8 @@
1
1
  import { telegramCardMessage } from "./card-render.js";
2
2
  import { cardOfProposal, cardPlainOf, cardsFirst, cardWithin, isCardProposal } from "./chat-card.js";
3
3
  import { CHAT_ACTIONS, sharedActionAllowsChallenge, sharedActionPayload } from "./chat-actions.js";
4
- import { armedQuestion, taskInCeiling, mirrorToTaskChat, couldNotAnswerText, channelRepos as telegramConversationRepos, resolveChannelLead as resolveTelegramLead, NO_PHONE_LINK, NO_TASK_LINK, phoneProposalLink, confirmedLink, phoneProposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
4
+ import { typedStartOf } from "./typed-start.js";
5
+ import { armedQuestion, taskInCeiling, mirrorToTaskChat, couldNotAnswerText, channelRepos as telegramConversationRepos, resolveChannelLead as resolveTelegramLead, NO_PHONE_LINK, NO_TASK_LINK, phoneProposalLink, confirmedLink, startedTaskLink, phoneProposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
5
6
  export { channelRepos as telegramConversationRepos, resolveChannelLead as resolveTelegramLead, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText, CHAT_ACTION_PARITY as TELEGRAM_ACTION_PARITY, parityGaps } from "./chat-channel.js";
6
7
  /**
7
8
  * The paired phone as one more way to use the same assistant.
@@ -26,7 +27,7 @@ export { channelRepos as telegramConversationRepos, resolveChannelLead as resolv
26
27
  */
27
28
  import { createHash, randomBytes } from "node:crypto";
28
29
  import { leadFailureText, runLeadTurn } from "./lead.js";
29
- import { confirmLeadProposal, dismissLeadProposal } from "./lead-doors.js";
30
+ import { confirmLeadProposal, confirmTypedStart, dismissLeadProposal, TYPED_START_STARTED } from "./lead-doors.js";
30
31
  import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
31
32
  import { LEAD_ASK_OTHER } from "./store.js";
32
33
  import { phoneText } from "./telegram-status.js";
@@ -129,7 +130,10 @@ export function mintCardTokens(store, binding, proposal, now, messageId, chatId
129
130
  for (const [token, phase] of [[confirm, "confirm"], [dismiss, "dismiss"]]) {
130
131
  store.createTelegramProposalAction({ token, binding: binding.id, proposal, phase, chatId, ttlMs: CARD_TTL_MS, ...(messageId === undefined ? {} : { messageId }) }, now);
131
132
  }
132
- return { keyboard: [[telegramButton("Confirm", confirm), telegramButton("Dismiss", dismiss)]], tokens: [confirm, dismiss] };
133
+ // A Start card's button says what it does: the card's last line tells the person to tap it (or reply yes).
134
+ const saved = store.getLeadProposal(proposal);
135
+ const label = saved?.kind === "action" && sharedActionPayload(saved.payload)?.operation === "task_start" ? "Start work" : "Confirm";
136
+ return { keyboard: [[telegramButton(label, confirm), telegramButton("Dismiss", dismiss)]], tokens: [confirm, dismiss] };
133
137
  }
134
138
  /**
135
139
  * One tap on a proposal card, applied inside the update's own transaction
@@ -249,7 +253,7 @@ export function applyProposalTap(store, binding, token, message, repos, options,
249
253
  }
250
254
  const irreversible = proposal.kind === "answer" && proposal.payload["reversible"] === false;
251
255
  const sharedAction = proposal.kind === "action" ? sharedActionPayload(proposal.payload) : null;
252
- const challenged = sharedAction !== null && sharedActionAllowsChallenge(sharedAction);
256
+ const challenged = sharedAction !== null && sharedActionAllowsChallenge(sharedAction, "telegram");
253
257
  // The arm: two fresh one-time tokens make a real challenge, exactly as a
254
258
  // decision button does. Nothing is answered or recorded here.
255
259
  const arm = () => {
@@ -573,7 +577,11 @@ async function runTelegramConversation(row, args) {
573
577
  const proposal = part.kind === "card" && part.proposal !== null ? store.getLeadProposal(part.proposal) : null;
574
578
  const origin = proposal === null ? null : options.phoneOrigin?.() ?? null;
575
579
  const wanted = proposal === null ? null : phoneProposalLink(store, proposal, repos);
576
- const keyboard = keyboardWith(part.keyboard, phoneLinkButton(origin, wanted));
580
+ // A typed yes that started a task (one-step-chat.md C5): Toolroll's reply carries the task's link, as a tapped
581
+ // card does. From the record and the origin read now, like every link.
582
+ const yes = part.kind === "reply" ? typedStartOf(store, "telegram", `m${row.id}`) : null;
583
+ const started = yes !== null && yes.outcome === "started" && yes.taskId !== null ? phoneLinkButton(options.phoneOrigin?.() ?? null, startedTaskLink(store, yes.taskId, repos)) : null;
584
+ const keyboard = keyboardWith(part.keyboard, started ?? phoneLinkButton(origin, wanted));
577
585
  // A Toolroll card: read again now, shown only where every project behind it is still reachable. An unreadable
578
586
  // one goes out as its saved plain words.
579
587
  const isCard = proposal !== null && isCardProposal(proposal);
@@ -751,6 +759,62 @@ async function runTelegramConversation(row, args) {
751
759
  return;
752
760
  }
753
761
  const repos = telegramConversationRepos(store, binding.approver, registry);
762
+ // A typed yes to a Start card (docs/plans/one-step-chat.md C5), recorded when the message arrived: Toolroll acts on it
763
+ // here and no lead turn runs for it. Its outcome and Toolroll's own reply are written in ONE transaction (the claim
764
+ // fences the reply; a lapsed claim takes the whole act back), then sent like any reply. A settled record returns its
765
+ // first outcome, so a redelivery or a crash starts nothing more.
766
+ const typed = typedStartOf(store, "telegram", `m${row.id}`);
767
+ if (typed !== null) {
768
+ const verified = verifyApproverStanding(store, binding.approver, binding.approverGeneration, repos);
769
+ // The card's turn is gone when the chat was ended (its proposals are deleted) or its project deleted: there is then
770
+ // no session to send a reply under, and nothing can have started. Toolroll says so itself. The yes is never passed on.
771
+ const unanchored = { said: null };
772
+ try {
773
+ store.transact(() => {
774
+ const now = clock();
775
+ const answered = confirmTypedStart(store, verified.ok ? verified.who : null, { provider: "telegram", event: typed.event }, now, { binding: binding.id, evidenceRoot: options.evidenceRoot });
776
+ // The reply is fenced on the session the card was composed under.
777
+ const card = store.getLeadProposal(typed.proposal);
778
+ const turn = card === null ? null : store.getLeadTurn(card.turn);
779
+ if (turn === null) {
780
+ // A start without its card can't happen (the door re-reads the card); if it ever did, take it back.
781
+ if (answered.ok)
782
+ throw new PlanRefused();
783
+ unanchored.said = answered.said;
784
+ return;
785
+ }
786
+ // The card is answered: its buttons are spent with it.
787
+ if (answered.ok)
788
+ store.consumeTelegramProposalActions(typed.proposal, now);
789
+ if (!store.planTelegramConversationParts(row.id, owner, { session: turn.session, turn: turn.id }, [{ kind: "reply", text: answered.said, replyTo: row.messageId }], now))
790
+ throw new PlanRefused();
791
+ });
792
+ }
793
+ catch (error) {
794
+ if (error instanceof PlanRefused)
795
+ return;
796
+ throw error;
797
+ }
798
+ if (unanchored.said !== null) {
799
+ await notify(unanchored.said);
800
+ finish({ state: "failed", outcome: "typed-start:not-counted" });
801
+ report.refused++;
802
+ return;
803
+ }
804
+ // The card it answered is repainted as a tap's would be, best effort: its buttons are already spent, and the
805
+ // reply below says the same words with the same link.
806
+ const settled = typedStartOf(store, "telegram", typed.event);
807
+ if (settled?.outcome === "started" && settled.taskId !== null && pairingProblem() === null && held()) {
808
+ const link = phoneLinkButton(options.phoneOrigin?.() ?? null, startedTaskLink(store, settled.taskId, repos));
809
+ try {
810
+ await transport("editMessageText", { chat_id: chatId, message_id: Number(typed.cardMessage), text: `✓ ${TYPED_START_STARTED}`, link_preview_options: { is_disabled: true },
811
+ reply_markup: { inline_keyboard: link === null ? [] : [link] } });
812
+ }
813
+ catch { /* the reply below still says it */ }
814
+ }
815
+ await sendOrHandOff("typed-start");
816
+ return;
817
+ }
754
818
  // 4. A session bound before an earlier dispatch: the receipt lives there,
755
819
  // whatever session is live today.
756
820
  if (row.session !== null) {
@@ -8,6 +8,8 @@ import { familyCompleted } from "./result-completion.js";
8
8
  import { approvedCheckOf, approvedCheckStateOf, assignmentStatusFacts, checksFactOf, plainReasonOf, stageOfDispatch, taskStatusOf } from "./task-status.js";
9
9
  import { assignmentChecksForRun, assignmentOf } from "./assignment.js";
10
10
  import { CHAT_CONTROLS, chatControlHref, chatResultHref } from "./chat-controls.js";
11
+ import { homeFiguresLine, homeFiguresOf } from "./home-groups.js";
12
+ import { workIndexPage } from "./work-index.js";
11
13
  export function phoneCommand(text) {
12
14
  // Slash forms everywhere; the bare words too, because Slack and Discord
13
15
  // keep unregistered slash text for themselves. Only a whole message that
@@ -106,10 +108,13 @@ function nextStep(d) {
106
108
  * history. This snapshot is intentionally bounded and advertises that bound. */
107
109
  export function phoneStatus(store, repos, now, focused = null, root) {
108
110
  if (repos.length === 0)
109
- return "No connected projects are available to this bridge. Add a project in Toolroll, then send /status again.";
111
+ return "No projects are connected here yet. Add a project in Toolroll, then send /status again.";
110
112
  return store.transact(() => {
111
113
  const snapshot = withDispatchDiagnoses(store, store.chatSnapshot(repos, now), now);
112
- const lines = [...(focused === null ? [] : [`Talking about: ${focused} · /lead to switch back`, ""]), "Recent work", `As of ${now.toISOString().replace("T", " ").slice(0, 19)} UTC · ${repos.length} project(s)`, ""];
114
+ // It opens with Home's figures, from the work index totals over these projects (the Tasks lists' own counts);
115
+ // the groups below are dispatch diagnoses, which are not the work index's families, so they keep their headings.
116
+ const figures = homeFiguresOf(workIndexPage(store, now, { principal: "operator", repos, includeUnplaced: false }, { limit: 1, ...(root === undefined ? {} : { root }) }).totals);
117
+ const lines = [homeFiguresLine(figures), "", ...(focused === null ? [] : [`Talking about: ${focused} · /lead to switch back`, ""]), "Recent work", `As of ${now.toISOString().replace("T", " ").slice(0, 19)} UTC · ${repos.length} project(s)`, ""];
113
118
  if (snapshot.tasksSaturated)
114
119
  lines.push("Newest 60 tasks only — older work may still need attention.", "");
115
120
  if (snapshot.tasks.length === 0)
@@ -126,7 +131,7 @@ export function phoneStatus(store, repos, now, focused = null, root) {
126
131
  lines.push(` +${rows.length - 2} more in the console`);
127
132
  lines.push("");
128
133
  }
129
- lines.push("Send /tasks to pick one, or /task <name> for its next step. Status is a snapshot, not a promise that the next attempt will succeed.");
134
+ lines.push("Send /tasks to pick one, or /task <name> for its next step. This is how things stand right now.");
130
135
  return lines.join("\n");
131
136
  });
132
137
  }
@@ -251,7 +256,7 @@ export function phoneTaskView(store, repos, id, now, root) {
251
256
  lines.push(`Attempt ended: ${plain(latest.finishedAt, 40)}`);
252
257
  else if (d.condition === "running") {
253
258
  const checkpoint = store.latestCheckpointForRun(latest.id);
254
- lines.push(checkpoint === null ? "No milestone progress has been recorded for this attempt." : `Latest agent-reported milestone update: ${plain(checkpoint.createdAt, 40)} (not independent proof).`);
259
+ lines.push(checkpoint === null ? "No milestone progress has been recorded for this attempt." : `Latest update from the agent, not verified: ${plain(checkpoint.createdAt, 40)}.`);
255
260
  }
256
261
  }
257
262
  let link = null;
@@ -148,6 +148,8 @@ export type BridgeReport = {
148
148
  contention?: number;
149
149
  /** v98: Telegram answered that it pushes this bot's updates to a webhook. */
150
150
  webhookActive?: boolean;
151
+ /** The long poll was ended on purpose because a message was due to go out (live-wakeups.md): not a failure. */
152
+ deliveryWake?: boolean;
151
153
  /** v98: pushed updates applied this pass. */
152
154
  pushed?: number;
153
155
  };
@@ -242,6 +244,8 @@ export declare function followBridge(store: Store, options: {
242
244
  } | null;
243
245
  /** v98: how often to check Telegram still pushes to us (tests shorten it). */
244
246
  pushCheckMs?: number;
247
+ /** While a long poll is open, how often to look for a message due to go out (tests shorten it). */
248
+ deliveryCheckMs?: number;
245
249
  }): Promise<FollowReport>;
246
250
  /** The digest text: a header with the count and the window, then one
247
251
  * fact per entry — its subject, then its body's first line, indented.
@@ -260,3 +264,9 @@ export declare function digestText(rows: readonly Notification[], since: string
260
264
  export declare const TELEGRAM_MAX_WAITING = 10;
261
265
  /** A pushed update was kept for this bot: wake its bridge if it runs in this process (docs/plans/responsive-chat.md §3.1). */
262
266
  export declare function wakeTelegramBridge(botId: string): void;
267
+ /**
268
+ * Whether a message for this bot was written after `mark` and is still due: unresolved, addressed to everyone or to a
269
+ * person paired with this bot, and the bot not held back by Telegram's rate limit. Read-only; delivery itself still
270
+ * decides each row under its own fences.
271
+ */
272
+ export declare function telegramOutboxDue(store: Store, botId: string, mark: number, now: Date): boolean;