toolroll 0.9.96 → 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 (131) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/approval-authority.d.ts +10 -1
  3. package/dist/approval-authority.js +18 -2
  4. package/dist/browser/workspace.css +2 -2
  5. package/dist/browser/workspace.js +32 -32
  6. package/dist/browser-workspace.d.ts +6 -3
  7. package/dist/chat-actions.d.ts +40 -5
  8. package/dist/chat-actions.js +124 -13
  9. package/dist/chat-card.js +4 -1
  10. package/dist/chat-channel.d.ts +3 -0
  11. package/dist/chat-channel.js +24 -7
  12. package/dist/chat-decide.js +4 -2
  13. package/dist/chat-delivery.d.ts +5 -1
  14. package/dist/chat-delivery.js +74 -25
  15. package/dist/chat-follow.d.ts +6 -3
  16. package/dist/chat-follow.js +23 -7
  17. package/dist/chat-live-schema.d.ts +17 -0
  18. package/dist/chat-live-schema.js +32 -0
  19. package/dist/chat-live.d.ts +161 -0
  20. package/dist/chat-live.js +491 -0
  21. package/dist/chat-task-actions.js +1 -1
  22. package/dist/chat-terms.d.ts +8 -0
  23. package/dist/chat-terms.js +14 -0
  24. package/dist/chat-voice.js +1 -1
  25. package/dist/contracts/chat-actions.d.ts +6 -0
  26. package/dist/contracts/chat-actions.js +2 -0
  27. package/dist/contracts/cli.d.ts +6 -0
  28. package/dist/contracts/cli.js +3 -0
  29. package/dist/contracts/console-api.d.ts +1 -0
  30. package/dist/contracts/console-api.js +1 -0
  31. package/dist/contracts/lead-tools.d.ts +17 -0
  32. package/dist/contracts/lead-tools.js +4 -1
  33. package/dist/contracts/store-json.d.ts +1 -0
  34. package/dist/discord-api.d.ts +3 -1
  35. package/dist/discord-api.js +9 -3
  36. package/dist/discord-chat.d.ts +13 -3
  37. package/dist/discord-chat.js +77 -21
  38. package/dist/discord.js +8 -1
  39. package/dist/fast-start.d.ts +83 -6
  40. package/dist/fast-start.js +112 -15
  41. package/dist/home-groups.d.ts +39 -0
  42. package/dist/home-groups.js +33 -0
  43. package/dist/lead-answer-eval.d.ts +93 -0
  44. package/dist/lead-answer-eval.js +259 -0
  45. package/dist/lead-capabilities.js +1 -1
  46. package/dist/lead-cli-live.d.ts +56 -0
  47. package/dist/lead-cli-live.js +200 -0
  48. package/dist/lead-cli.d.ts +3 -0
  49. package/dist/lead-cli.js +34 -4
  50. package/dist/lead-context.js +2 -2
  51. package/dist/lead-contract.d.ts +1 -1
  52. package/dist/lead-contract.js +10 -7
  53. package/dist/lead-doors.d.ts +32 -0
  54. package/dist/lead-doors.js +80 -14
  55. package/dist/lead-latency-schema.d.ts +24 -0
  56. package/dist/lead-latency-schema.js +36 -0
  57. package/dist/lead-progress.d.ts +10 -1
  58. package/dist/lead-progress.js +1 -1
  59. package/dist/lead-queue-runtime.d.ts +1 -1
  60. package/dist/lead-queue-runtime.js +1 -1
  61. package/dist/lead-route.d.ts +56 -0
  62. package/dist/lead-route.js +104 -0
  63. package/dist/lead-status.d.ts +3 -0
  64. package/dist/lead-status.js +8 -3
  65. package/dist/lead-tools.d.ts +20 -0
  66. package/dist/lead-tools.js +66 -3
  67. package/dist/lead-voice.js +1 -1
  68. package/dist/lead.d.ts +4 -2
  69. package/dist/lead.js +97 -28
  70. package/dist/live-reply.d.ts +83 -0
  71. package/dist/live-reply.js +302 -0
  72. package/dist/local-owner.js +2 -0
  73. package/dist/one-step-chat-schema.d.ts +22 -0
  74. package/dist/one-step-chat-schema.js +37 -0
  75. package/dist/operate.d.ts +27 -1
  76. package/dist/operate.js +245 -33
  77. package/dist/project-delete.js +9 -0
  78. package/dist/project-knowledge.d.ts +10 -10
  79. package/dist/publish.d.ts +2 -0
  80. package/dist/publish.js +1 -1
  81. package/dist/pull-request-flow.d.ts +4 -0
  82. package/dist/pull-request-flow.js +16 -1
  83. package/dist/reply-shape.d.ts +9 -0
  84. package/dist/reply-shape.js +13 -0
  85. package/dist/reply-voice.d.ts +23 -0
  86. package/dist/reply-voice.js +37 -0
  87. package/dist/scope.d.ts +6 -1
  88. package/dist/scope.js +3 -2
  89. package/dist/serve.js +11 -4
  90. package/dist/server/chat.d.ts +2 -1
  91. package/dist/server/chat.js +22 -10
  92. package/dist/server/chrome.js +2 -2
  93. package/dist/server/home.d.ts +4 -3
  94. package/dist/server/home.js +33 -28
  95. package/dist/server/live.js +18 -21
  96. package/dist/server/render-chat.d.ts +5 -0
  97. package/dist/server/render-chat.js +15 -8
  98. package/dist/server/render-pages.js +2 -2
  99. package/dist/server/render-settings.d.ts +8 -0
  100. package/dist/server/render-settings.js +13 -2
  101. package/dist/server/route-table.js +2 -0
  102. package/dist/server/settings.js +14 -1
  103. package/dist/setup-guide.d.ts +2 -2
  104. package/dist/slack-api.js +11 -0
  105. package/dist/slack-chat.d.ts +11 -1
  106. package/dist/slack-chat.js +50 -5
  107. package/dist/slack-settings.js +8 -1
  108. package/dist/slack.js +10 -3
  109. package/dist/store.d.ts +33 -2
  110. package/dist/store.js +92 -16
  111. package/dist/subscription-chat.d.ts +2 -0
  112. package/dist/subscription-chat.js +3 -1
  113. package/dist/teams-chat.d.ts +9 -2
  114. package/dist/teams-chat.js +50 -2
  115. package/dist/teams.js +5 -2
  116. package/dist/telegram-lead.d.ts +3 -0
  117. package/dist/telegram-lead.js +132 -14
  118. package/dist/telegram-live.d.ts +116 -0
  119. package/dist/telegram-live.js +224 -0
  120. package/dist/telegram-status.js +9 -4
  121. package/dist/telegram.d.ts +12 -0
  122. package/dist/telegram.js +142 -10
  123. package/dist/toolroll-update.js +6 -1
  124. package/dist/tui-contract.d.ts +2 -0
  125. package/dist/tui-source.js +3 -0
  126. package/dist/tui.js +41 -41
  127. package/dist/typed-start.d.ts +51 -0
  128. package/dist/typed-start.js +98 -0
  129. package/dist/wake-hub.d.ts +72 -0
  130. package/dist/wake-hub.js +151 -0
  131. package/package.json +1 -1
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Home's three groups (docs/plans/one-step-chat.md H1): what waits on you, what is working, what is done. One rule for
3
+ * every surface that groups work this way (Home in the browser, the terminal UI), so they never disagree. Pure: no
4
+ * server, store or rendering imports, so the terminal UI's source can use it too.
5
+ */
6
+ export type HomeGroup = "you" | "working" | "done";
7
+ /** The groups in the order every surface shows them. */
8
+ export declare const HOME_GROUPS: readonly HomeGroup[];
9
+ /** Each group's heading. */
10
+ export declare const HOME_GROUP_LABEL: Readonly<Record<HomeGroup, string>>;
11
+ /** What the rule reads of a work index item (work-index.ts WorkIndexItem): the Tasks views that list it. */
12
+ export type HomeGroupFacts = {
13
+ readonly status: {
14
+ readonly views: readonly string[];
15
+ };
16
+ };
17
+ /**
18
+ * Which group a work index item belongs to, in this order: it waits on you when the Needs you view lists it; it is
19
+ * working when the Running or the Queued view lists it (it runs, or it waits its turn or on Toolroll or another task:
20
+ * nobody is asked), so Working is exactly what the Running and Queued figures count; it is done when it is complete.
21
+ * Null for anything else: cancelled, replaced, put away.
22
+ */
23
+ export declare function homeGroupOf(item: HomeGroupFacts): HomeGroup | null;
24
+ /** The work index totals Home's figures show, each the count of the Tasks list its figure opens. */
25
+ export type HomeFigures = {
26
+ readonly waitingOnYou: number;
27
+ readonly running: number;
28
+ readonly queued: number;
29
+ readonly done: number;
30
+ };
31
+ /** Home's figures from work index totals (work-index.ts WorkIndexCounts): Done is the whole Completed list. */
32
+ export declare function homeFiguresOf(totals: {
33
+ readonly "needs-you": number;
34
+ readonly running: number;
35
+ readonly queued: number;
36
+ readonly completed: number;
37
+ }): HomeFigures;
38
+ /** "Waiting on you 2 · Running 1 · Queued 0 · Done 14": the opening line of `toolroll status` and chat /status. */
39
+ export declare function homeFiguresLine(figures: HomeFigures): string;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Home's three groups (docs/plans/one-step-chat.md H1): what waits on you, what is working, what is done. One rule for
3
+ * every surface that groups work this way (Home in the browser, the terminal UI), so they never disagree. Pure: no
4
+ * server, store or rendering imports, so the terminal UI's source can use it too.
5
+ */
6
+ /** The groups in the order every surface shows them. */
7
+ export const HOME_GROUPS = ["you", "working", "done"];
8
+ /** Each group's heading. */
9
+ export const HOME_GROUP_LABEL = { you: "Waiting on you", working: "Working", done: "Done" };
10
+ /**
11
+ * Which group a work index item belongs to, in this order: it waits on you when the Needs you view lists it; it is
12
+ * working when the Running or the Queued view lists it (it runs, or it waits its turn or on Toolroll or another task:
13
+ * nobody is asked), so Working is exactly what the Running and Queued figures count; it is done when it is complete.
14
+ * Null for anything else: cancelled, replaced, put away.
15
+ */
16
+ export function homeGroupOf(item) {
17
+ const views = item.status.views;
18
+ if (views.includes("needs-you"))
19
+ return "you";
20
+ if (views.includes("running") || views.includes("queued"))
21
+ return "working";
22
+ if (views.includes("completed"))
23
+ return "done";
24
+ return null;
25
+ }
26
+ /** Home's figures from work index totals (work-index.ts WorkIndexCounts): Done is the whole Completed list. */
27
+ export function homeFiguresOf(totals) {
28
+ return { waitingOnYou: totals["needs-you"], running: totals.running, queued: totals.queued, done: totals.completed };
29
+ }
30
+ /** "Waiting on you 2 · Running 1 · Queued 0 · Done 14": the opening line of `toolroll status` and chat /status. */
31
+ export function homeFiguresLine(figures) {
32
+ return `Waiting on you ${figures.waitingOnYou} · Running ${figures.running} · Queued ${figures.queued} · Done ${figures.done}`;
33
+ }
@@ -0,0 +1,93 @@
1
+ import { type ChatConfig, type Store } from "./store.js";
2
+ import { type VerifiedApprover } from "./principal.js";
3
+ import { type ReplyVoice, type VoiceSummary } from "./reply-voice.js";
4
+ import type { SubscriptionLeadRunner } from "./subscription-chat.js";
5
+ export declare const LEAD_ANSWER_K = 3;
6
+ type Seeded = {
7
+ id: string;
8
+ title: string;
9
+ state: "queued" | "running" | "done" | "failed" | "cancelled";
10
+ };
11
+ export declare const LEAD_ANSWER_TASKS: readonly Seeded[];
12
+ /** A fact an answer must state: any one of its spellings (lower case). */
13
+ type Fact = readonly string[];
14
+ export type LeadAnswerQuestion = {
15
+ id: string;
16
+ text: string;
17
+ kind: "read" | "act";
18
+ mustName: readonly Fact[];
19
+ mustNot: readonly string[];
20
+ topic: string;
21
+ };
22
+ /** The 40 questions, each read question's oracle computed from LEAD_ANSWER_TASKS (the world's records). */
23
+ export declare function leadAnswerQuestions(): LeadAnswerQuestion[];
24
+ /** The seeded world: its store, its owner, the conversation config. */
25
+ export declare function seedLeadAnswerWorld(now?: Date): {
26
+ store: Store;
27
+ who: VerifiedApprover;
28
+ config: ChatConfig;
29
+ };
30
+ /** How an answer did against its question's oracle. */
31
+ export declare function gradeAnswer(question: LeadAnswerQuestion, answer: string): {
32
+ named: boolean;
33
+ falseClaims: number;
34
+ };
35
+ /**
36
+ * The scripted stand-in model (CI): reads with list_tasks and list_decisions, then answers from those results alone; a
37
+ * request without proposal tools (a quick step) asked to change something calls find_tools, as the contract tells a
38
+ * model to search before saying something can't be done. Each step advances `clock` by the model's simulated latency.
39
+ */
40
+ export declare function scriptedLeadAnswerRunner(questions: readonly LeadAnswerQuestion[], clock: {
41
+ advance(ms: number): void;
42
+ }, quickModel: string): SubscriptionLeadRunner;
43
+ type Run = {
44
+ model: "main" | "quick";
45
+ route: string | null;
46
+ answer: string;
47
+ ms: number;
48
+ named: boolean;
49
+ falseClaims: number;
50
+ failed: string | null;
51
+ voice: ReplyVoice;
52
+ };
53
+ export type LeadAnswerReport = {
54
+ mode: "scripted" | "real";
55
+ k: number;
56
+ quickModel: string;
57
+ read: {
58
+ main: {
59
+ passK: number;
60
+ falseClaims: number;
61
+ medianMs: number;
62
+ };
63
+ quick: {
64
+ passK: number;
65
+ falseClaims: number;
66
+ medianMs: number;
67
+ };
68
+ questions: number;
69
+ };
70
+ act: {
71
+ kept: number;
72
+ total: number;
73
+ failures: string[];
74
+ };
75
+ /** How the read answers read, per model (docs/plans/lead-voice.md, V3). Reported; `pass` does not depend on it. */
76
+ voice: {
77
+ main: VoiceSummary;
78
+ quick: VoiceSummary;
79
+ };
80
+ speedup: number;
81
+ pass: boolean;
82
+ reasons: string[];
83
+ runs: ({
84
+ question: string;
85
+ } & Run)[];
86
+ };
87
+ /** Run the eval. `runner` absent: this computer's signed-in Claude (real mode). */
88
+ export declare function runLeadAnswerEval(options: {
89
+ mode: "scripted" | "real";
90
+ k?: number;
91
+ onRun?: (line: string) => void;
92
+ }): Promise<LeadAnswerReport>;
93
+ export {};
@@ -0,0 +1,259 @@
1
+ /**
2
+ * The `lead-answer` eval: the gate on quick answers (docs/plans/responsive-chat.md §8). Quick answers ship OFF and are
3
+ * turned on by default only once this eval's real mode passes.
4
+ *
5
+ * A seeded world (tasks in every state, one decision waiting on the owner) and 40 questions:
6
+ * - 30 read questions, each with an oracle computed from the world's records: the task titles, states and counts its
7
+ * answer must name, and the titles it must not claim;
8
+ * - 10 act-intent questions, which must stay on the main model or be handed to it (escalated): 8 carry an act word, and
9
+ * 2 ask for a change without one, so only the quick step's own escalation can catch them.
10
+ * Each read question runs k = 3 times on the main model (quick answers off) and k = 3 on the quick route (on); a question
11
+ * passes on a model only when all three answers do (pass^k, the eval harness's rule). An act question that the router
12
+ * already keeps on the main model needs no model call; one it routes quick runs k = 3 and must escalate every time.
13
+ *
14
+ * Pass: quick pass^k ≥ main pass^k (no regression), quick false claims ≤ main's, 10/10 act questions on the main model
15
+ * or escalated, and the quick route's median turn time at least 30% below the main model's.
16
+ *
17
+ * Modes: `scripted` (CI: a deterministic stand-in model with simulated per-model latency proves the harness, the
18
+ * router, escalation and grading end to end, with no provider call) and `real` (the owner or the release runs it:
19
+ * every turn goes to this computer's signed-in Claude). Real mode spends subscription usage: about 30×3×2 + 2×3 turns.
20
+ *
21
+ * npm run build && node scripts/lead-answer-eval.mjs # scripted
22
+ * npm run build && node scripts/lead-answer-eval.mjs --real # real model calls
23
+ *
24
+ * Kept outside src/evals.ts's canonical campaigns: those run build and ask cases through a world's CLI and are sealed
25
+ * as release policy (`evals/`); this eval drives runLeadTurn directly over an in-memory world. Folding it in as a third
26
+ * case kind is a follow-up.
27
+ */
28
+ import { createHash } from "node:crypto";
29
+ import { openStore, setLeadQuickAnswers } from "./store.js";
30
+ import { fileTaskProposal } from "./proposal.js";
31
+ import { verifyApproverStanding } from "./principal.js";
32
+ import { subscriptionCredentialKey } from "./converse.js";
33
+ import { runLeadTurn } from "./lead.js";
34
+ import { quickLeadModel, quickRoute } from "./lead-route.js";
35
+ import { replyVoice, summarizeVoice } from "./reply-voice.js";
36
+ export const LEAD_ANSWER_K = 3;
37
+ const T0 = new Date("2026-11-08T12:00:00.000Z");
38
+ const REPO = "/eval/shop";
39
+ const MAIN_MODEL = "opus";
40
+ export const LEAD_ANSWER_TASKS = Object.freeze([
41
+ { id: "csv-export", title: "Add CSV export to the orders page", state: "running" },
42
+ { id: "search-index", title: "Speed up the search index", state: "running" },
43
+ { id: "billing-tab", title: "Rename the billing settings tab", state: "queued" },
44
+ { id: "french", title: "Translate the checkout into French", state: "queued" },
45
+ { id: "invoices", title: "Archive old invoices", state: "queued" },
46
+ { id: "login-loop", title: "Fix the login redirect loop", state: "done" },
47
+ { id: "images", title: "Compress product images", state: "done" },
48
+ { id: "payment-lib", title: "Upgrade the payment library", state: "failed" },
49
+ { id: "backup-check", title: "Repair the nightly backup check", state: "failed" },
50
+ { id: "dark-emails", title: "Try a dark theme for emails", state: "cancelled" },
51
+ ]);
52
+ /** The task whose run waits on the owner's decision. */
53
+ const DECIDING = "search-index";
54
+ const titles = (state) => LEAD_ANSWER_TASKS.filter(one => one.state === state).map(one => one.title);
55
+ const facts = (state) => titles(state).map(title => [title.toLowerCase()]);
56
+ const NUMBER_WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"];
57
+ const count = (state) => { const n = titles(state).length; return [String(n), NUMBER_WORDS[n]]; };
58
+ /** The 40 questions, each read question's oracle computed from LEAD_ANSWER_TASKS (the world's records). */
59
+ export function leadAnswerQuestions() {
60
+ const read = (topic, texts, mustName, mustNot) => texts.map((text, index) => ({ id: `${topic}-${index + 1}`, text, kind: "read", mustName, mustNot, topic }));
61
+ const settled = [...titles("done"), ...titles("cancelled")];
62
+ const csv = LEAD_ANSWER_TASKS.find(one => one.id === "csv-export");
63
+ const deciding = LEAD_ANSWER_TASKS.find(one => one.id === DECIDING);
64
+ const act = (id, text) => ({ id, text, kind: "act", mustName: [], mustNot: [], topic: "act" });
65
+ return [
66
+ ...read("running", ["what's running?", "Which tasks are being worked on right now?", "What is in progress?"], facts("running"), settled),
67
+ ...read("queued", ["what's queued?", "Which tasks haven't begun yet?", "Anything queued up?"], facts("queued"), settled),
68
+ ...read("failed", ["which tasks failed?", "What didn't work?", "Did anything fail?"], facts("failed"), titles("done")),
69
+ ...read("done", ["what's finished?", "Which tasks are done?", "What got completed?"], facts("done"), titles("failed")),
70
+ ...read("needs-me", ["what needs me?", "Is anything waiting on me?", "Do I have a decision waiting?"], [[deciding.title.toLowerCase(), "search index"]], settled),
71
+ ...read("queued-count", ["how many tasks are queued?", "How many tasks are still waiting?", "Number of queued tasks?"], [count("queued")], []),
72
+ ...read("failed-count", ["how many tasks failed?", "How many failures are there?", "Count of failed tasks?"], [count("failed")], []),
73
+ ...read("csv-status", ["What's the status of the CSV export?", "Is the CSV export done?", "How is the CSV export going?"], [["running", "in progress", "being worked", "under way", "underway"]], [`${csv.title.toLowerCase()} is done`]),
74
+ ...read("cancelled", ["what was cancelled?", "Which tasks were dropped?", "Anything cancelled?"], facts("cancelled"), titles("running")),
75
+ ...read("overview", ["status", "Give me a summary of the shop project.", "What's the state of things?"], facts("running"), []),
76
+ act("act-fix", "fix the payment library upgrade"),
77
+ act("act-retry", "can you retry the failed tasks?"),
78
+ act("act-cancel", "please cancel the French translation"),
79
+ act("act-merge", "merge the CSV export"),
80
+ act("act-add", "add a task to clean up old logs"),
81
+ act("act-pause", "pause the search index work"),
82
+ act("act-approve", "approve rebuilding the index tonight"),
83
+ act("act-release", "release the shop project"),
84
+ // No act word: only the quick step's own escalation keeps these off the quick route.
85
+ act("act-soft-order", "Could the French translation go before the invoices?"),
86
+ act("act-soft-revive", "The dark theme idea for emails, can we bring it back?"),
87
+ ];
88
+ }
89
+ /** The seeded world: its store, its owner, the conversation config. */
90
+ export function seedLeadAnswerWorld(now = T0) {
91
+ const store = openStore(":memory:");
92
+ store.saveApprover("owner", createHash("sha256").update("lead-answer-eval").digest("hex"), now);
93
+ const verified = verifyApproverStanding(store, "owner", store.accountOf("owner").generation, [REPO]);
94
+ if (!verified.ok)
95
+ throw new Error(verified.reason);
96
+ for (const task of LEAD_ANSWER_TASKS) {
97
+ const filed = fileTaskProposal(store, { id: task.id, title: task.title, repo: REPO, filedVia: "cli" }, now);
98
+ if (!filed.ok)
99
+ throw new Error(filed.reason);
100
+ if (task.state !== "queued") {
101
+ const set = store.setTaskState(task.id, task.state, now, {}, task.state === "cancelled" ? "Not needed after all." : undefined);
102
+ if (!set.ok)
103
+ throw new Error(`${task.id}: ${set.reason}`);
104
+ }
105
+ }
106
+ const run = store.startRun({ taskRef: store.refFor("built-in", DECIDING).id, leaseId: "l-eval", runner: "eval", branch: `standing-orders/${DECIDING}`, worktree: `/pool/${DECIDING}`,
107
+ route: { routeDigest: "legacy", phase: "build", provider: "claude", model: null, chosen: "legacy" }, now });
108
+ store.saveDecision({ run, urgency: "blocking", recap: "The index rebuild locks search for a few minutes.", question: "Rebuild the search index now or tonight?",
109
+ options: [{ id: "now", label: "Now", consequence: "Search is down for a few minutes now.", reversible: true }, { id: "tonight", label: "Tonight", consequence: "The speed-up waits until tonight.", reversible: true }],
110
+ recommendation: "tonight" }, now);
111
+ const config = { provider: "claude-subscription", model: MAIN_MODEL, dailyTurns: 1_000, weeklyCeilingMicrousd: 0, priceInMicrousd: 0, priceOutMicrousd: 0, updatedAt: now.toISOString(), updatedBy: "owner" };
112
+ return { store, who: verified.who, config };
113
+ }
114
+ /** How an answer did against its question's oracle. */
115
+ export function gradeAnswer(question, answer) {
116
+ const text = answer.toLowerCase();
117
+ return { named: question.mustName.every(fact => fact.some(spelling => text.includes(spelling))), falseClaims: question.mustNot.filter(claim => text.includes(claim.toLowerCase())).length };
118
+ }
119
+ /**
120
+ * The scripted stand-in model (CI): reads with list_tasks and list_decisions, then answers from those results alone; a
121
+ * request without proposal tools (a quick step) asked to change something calls find_tools, as the contract tells a
122
+ * model to search before saying something can't be done. Each step advances `clock` by the model's simulated latency.
123
+ */
124
+ export function scriptedLeadAnswerRunner(questions, clock, quickModel) {
125
+ const byText = new Map(questions.map(one => [one.text, one]));
126
+ return async (request) => {
127
+ clock.advance(request.model === quickModel ? 900 : 2_000);
128
+ const asked = [...request.history].reverse().find(one => one.role === "operator");
129
+ const question = asked?.role === "operator" ? byText.get(asked.text) : undefined;
130
+ const reply = (text, calls = []) => ({ ok: true, answer: { text, calls, tokensIn: 100, tokensOut: 20, reportedCostMicrousd: null } });
131
+ const results = request.history.filter(one => one.role === "tool");
132
+ if (question?.kind === "act") {
133
+ if (!request.tools.some(one => one.name.startsWith("propose_")))
134
+ return reply("Let me find the right tool.", [{ id: "f", name: "find_tools", args: { query: question.text } }]);
135
+ return reply("That needs your confirmation; I can draft it as a card.");
136
+ }
137
+ if (results.length === 0)
138
+ return reply("Reading.", [{ id: "t", name: "list_tasks", args: { limit: 50 } }, { id: "d", name: "list_decisions", args: {} }]);
139
+ const tasks = results.find(one => one.role === "tool" && one.name === "list_tasks");
140
+ const decisions = results.find(one => one.role === "tool" && one.name === "list_decisions");
141
+ const listed = (tasks?.role === "tool" ? JSON.parse(tasks.result).tasks : undefined) ?? [];
142
+ const inState = (state) => listed.filter(one => one.state === state).map(one => one.title);
143
+ const topic = question?.topic ?? "overview";
144
+ const said = (state) => `${inState(state).length} ${state}: ${inState(state).join("; ") || "none"}.`;
145
+ switch (topic) {
146
+ case "running":
147
+ case "overview": return reply(`Running now: ${inState("running").join("; ")}.`);
148
+ case "queued": return reply(`Waiting to start: ${inState("queued").join("; ")}.`);
149
+ case "failed": return reply(`Failed: ${inState("failed").join("; ")}.`);
150
+ case "done": return reply(`Done: ${inState("done").join("; ")}.`);
151
+ case "cancelled": return reply(`Cancelled: ${inState("cancelled").join("; ")}.`);
152
+ case "queued-count": return reply(said("queued"));
153
+ case "failed-count": return reply(said("failed"));
154
+ case "csv-status": return reply(`The CSV export is ${listed.find(one => one.title.includes("CSV"))?.state === "running" ? "running" : "not running"}.`);
155
+ case "needs-me": {
156
+ const waiting = LEAD_ANSWER_TASKS.filter(one => decisions?.role === "tool" && decisions.result.includes(one.id)).map(one => one.title);
157
+ return reply(waiting.length === 0 ? "Nothing needs you." : `A decision waits on you: ${waiting.join("; ")}.`);
158
+ }
159
+ default: return reply("I'm not sure.");
160
+ }
161
+ };
162
+ }
163
+ const median = (values) => {
164
+ const sorted = [...values].sort((a, b) => a - b);
165
+ if (sorted.length === 0)
166
+ return 0;
167
+ const mid = Math.floor(sorted.length / 2);
168
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
169
+ };
170
+ /** Run the eval. `runner` absent: this computer's signed-in Claude (real mode). */
171
+ export async function runLeadAnswerEval(options) {
172
+ const k = options.k ?? LEAD_ANSWER_K;
173
+ const world = seedLeadAnswerWorld();
174
+ const { store, who, config } = world;
175
+ const questions = leadAnswerQuestions();
176
+ const quickModel = quickLeadModel(store);
177
+ let virtual = T0.getTime();
178
+ const clock = options.mode === "scripted" ? () => new Date(virtual) : () => new Date();
179
+ const runner = options.mode === "scripted" ? scriptedLeadAnswerRunner(questions, { advance: ms => { virtual += ms; } }, quickModel) : undefined;
180
+ const runs = [];
181
+ const once = async (question, model) => {
182
+ setLeadQuickAnswers(store, model === "quick", "owner", clock());
183
+ // A fresh conversation per run: no earlier answer leaks into the next.
184
+ const session = store.getLeadSession(store.mintLeadSession({ approver: "owner", approverGeneration: who.generation, credentialKey: subscriptionCredentialKey("claude-subscription"),
185
+ ceilingMicrousd: 0, ceilingDigest: who.ceilingDigest, termsDigest: "e".repeat(64) }, clock()));
186
+ store.closeLeadThreadsFor("owner", clock());
187
+ const thread = store.openLeadThread("owner", who.ceilingDigest, clock()).thread;
188
+ const started = clock().getTime();
189
+ const outcome = await runLeadTurn({ store, who, session, thread, config, key: null, message: question.text, clock, ...(runner === undefined ? {} : { subscriptionRunner: runner }) });
190
+ const ms = clock().getTime() - started;
191
+ const turn = "turn" in outcome ? store.getLeadTurn(outcome.turn) : null;
192
+ const answer = outcome.ok && !outcome.replayed ? outcome.reply : "";
193
+ const graded = gradeAnswer(question, answer);
194
+ const failed = outcome.ok ? null : "refused" in outcome ? `refused: ${outcome.refused}` : `failed: ${outcome.failed}`;
195
+ return { model, route: turn?.route ?? null, answer, ms, named: failed === null && graded.named, falseClaims: graded.falseClaims, failed, voice: replyVoice(answer) };
196
+ };
197
+ const read = questions.filter(one => one.kind === "read");
198
+ const tally = { main: { passK: 0, falseClaims: 0, ms: [], voices: [] }, quick: { passK: 0, falseClaims: 0, ms: [], voices: [] } };
199
+ for (const question of read) {
200
+ for (const model of ["main", "quick"]) {
201
+ let all = true;
202
+ for (let attempt = 0; attempt < k; attempt++) {
203
+ const one = await once(question, model);
204
+ runs.push({ question: question.id, ...one });
205
+ options.onRun?.(`${question.id} ${model} #${attempt + 1}: ${one.named ? "named" : "missed"}${one.falseClaims > 0 ? `, ${one.falseClaims} false` : ""}, ${one.ms} ms, route ${one.route}${one.failed === null ? "" : `, ${one.failed}`}`);
206
+ if (!one.named)
207
+ all = false;
208
+ tally[model].falseClaims += one.falseClaims;
209
+ tally[model].ms.push(one.ms);
210
+ if (one.answer !== "")
211
+ tally[model].voices.push(one.voice);
212
+ }
213
+ if (all)
214
+ tally[model].passK++;
215
+ }
216
+ }
217
+ const actFailures = [];
218
+ let kept = 0;
219
+ const acts = questions.filter(one => one.kind === "act");
220
+ for (const question of acts) {
221
+ const decided = quickRoute({ enabled: true, provider: config.provider, mainModel: config.model, message: question.text, hasContext: false, hasReplyImage: false, askOpen: false, quickModel, quickAllowed: true });
222
+ if (decided.route === "default") {
223
+ kept++;
224
+ options.onRun?.(`${question.id}: kept on the main model by the router`);
225
+ continue;
226
+ }
227
+ let all = true;
228
+ for (let attempt = 0; attempt < k; attempt++) {
229
+ const one = await once(question, "quick");
230
+ runs.push({ question: question.id, ...one });
231
+ options.onRun?.(`${question.id} quick #${attempt + 1}: route ${one.route}${one.failed === null ? "" : `, ${one.failed}`}`);
232
+ if (one.route !== "escalated" && one.route !== "default")
233
+ all = false;
234
+ }
235
+ if (all)
236
+ kept++;
237
+ else
238
+ actFailures.push(question.id);
239
+ }
240
+ store.close();
241
+ const mainMs = median(tally.main.ms), quickMs = median(tally.quick.ms);
242
+ const speedup = mainMs === 0 ? 0 : 1 - quickMs / mainMs;
243
+ const reasons = [];
244
+ if (tally.quick.passK < tally.main.passK)
245
+ reasons.push(`quick answers named the facts on ${tally.quick.passK} questions (all ${k} runs), the main model on ${tally.main.passK}`);
246
+ if (tally.quick.falseClaims > tally.main.falseClaims)
247
+ reasons.push(`quick answers made ${tally.quick.falseClaims} false claims, the main model ${tally.main.falseClaims}`);
248
+ if (kept !== acts.length)
249
+ reasons.push(`${acts.length - kept} of ${acts.length} act questions stayed on the quick model: ${actFailures.join(", ")}`);
250
+ if (speedup < 0.3)
251
+ reasons.push(`quick answers were ${Math.round(speedup * 100)}% faster at the median; 30% is needed`);
252
+ return {
253
+ mode: options.mode, k, quickModel,
254
+ read: { main: { passK: tally.main.passK, falseClaims: tally.main.falseClaims, medianMs: mainMs }, quick: { passK: tally.quick.passK, falseClaims: tally.quick.falseClaims, medianMs: quickMs }, questions: read.length },
255
+ act: { kept, total: acts.length, failures: actFailures },
256
+ voice: { main: summarizeVoice(tally.main.voices), quick: summarizeVoice(tally.quick.voices) },
257
+ speedup, pass: reasons.length === 0, reasons, runs,
258
+ };
259
+ }
@@ -200,7 +200,7 @@ export function capabilitiesOf(store, input) {
200
200
  * report on a task, attempt or check does (crew work must run), and a timed one does when it is reported on a chat app. */
201
201
  const FLOW_WORK = new Set(["create", "edit", "kit", "starter", "add_trigger", "resume_trigger", "add_card"]);
202
202
  export function needsCapabilities(name, args, channel) {
203
- if (name === "propose_task" || name === "propose_sweep")
203
+ if (name === "propose_task" || name === "propose_start" || name === "propose_sweep")
204
204
  return true;
205
205
  if (name === "propose_flow")
206
206
  return FLOW_WORK.has(String(args["operation"]));
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The terminal's live reply for `toolroll chat` (docs/plans/responsive-chat.md §3.6): on a TTY only, one status line
3
+ * shown at once and redrawn in place, then the answer as it is written, in a block of at most six rows wrapped to the
4
+ * terminal's width by display width (wide and combining characters). Piped output and `--json` never get here.
5
+ *
6
+ * Only this renderer emits cursor controls. Every piece of model-derived text it draws (the partial reply, and the
7
+ * status line, which can carry a tool's label) passes through `terminalText` first, on the whole accumulated text
8
+ * every time, so an escape sequence split across stream chunks can never get past it.
9
+ *
10
+ * Views come from the shared live-reply core and are drawn through its serialized sender, so `finish()` resolves only
11
+ * after the last draw: the block is erased and the caller prints the final answer (or the failure) strictly after.
12
+ * The erase moves up within the block's known height; a terminal that scrolled meanwhile keeps what it showed.
13
+ * Every draw first re-proves the caller's `standing`: once it fails, the block is erased and nothing more is drawn.
14
+ */
15
+ import type { LeadProgress } from "./lead-progress.js";
16
+ import { type ShapeOptions } from "./reply-shape.js";
17
+ /** Where the live block is drawn: the terminal, or a test's recording. */
18
+ export type TerminalLiveOutput = {
19
+ write(chunk: string): void;
20
+ /** The terminal's width now, in columns; undefined when unknown. */
21
+ columns(): number | undefined;
22
+ /** Call the listener when the terminal is resized; returns the unsubscribe. */
23
+ onResize(listener: () => void): () => void;
24
+ /** Call the listener on Ctrl-C during a turn (the process then exits, as Ctrl-C always did); returns the unsubscribe. */
25
+ onInterrupt(listener: () => void): () => void;
26
+ /** Draw the status line dimmed (off under NO_COLOR). */
27
+ dim?: boolean;
28
+ };
29
+ /** At most this many rows of the reply show under the status line. */
30
+ export declare const LIVE_TERMINAL_ROWS = 6;
31
+ /** The columns one grapheme takes: 0 for a lone combining or format mark, 2 for wide and emoji, else 1. */
32
+ export declare function graphemeWidth(cluster: string): number;
33
+ /** The columns a line of text takes on a terminal. */
34
+ export declare function displayWidth(text: string): number;
35
+ /** Text wrapped to rows of at most `width` columns: at the last space where there is one, else mid-word. */
36
+ export declare function wrapRows(text: string, width: number): string[];
37
+ export type TerminalLive = {
38
+ /** Feed one engine event (the turn's `onProgress`). */
39
+ progress(event: LeadProgress): void;
40
+ /** The turn ended: wait for the last draw, then erase the block. The final answer or failure prints after this. */
41
+ finish(): Promise<void>;
42
+ };
43
+ /**
44
+ * Start one turn's live block: the status line shows at once. `shape` is the final reply's shaping (the owner's
45
+ * message and this app's origins), so a partial reads as the final will.
46
+ */
47
+ export declare function startTerminalLive(out: TerminalLiveOutput, shape?: ShapeOptions, options?: {
48
+ spacingMs?: number;
49
+ /** Re-proved immediately before every draw (the person, the conversation, its projects and the turn, once known,
50
+ * running under the generation it was admitted with). False: the block is erased and nothing more is drawn. */
51
+ standing?: (turn: number | null) => boolean;
52
+ }): TerminalLive;
53
+ /** The real terminal: stdout, its resize events, and Ctrl-C (which still ends the process, after the block is gone). */
54
+ export declare function processTerminal(env?: Record<string, string | undefined>): TerminalLiveOutput;
55
+ /** A live block only on a real terminal that can move its cursor, and never for an agent driving the CLI. */
56
+ export declare function liveTerminalWanted(json: boolean, env: Record<string, string | undefined>, tty: boolean, agent: () => boolean): boolean;