toolroll 0.9.27 → 0.9.28

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 (65) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/agentconfig.js +71 -3
  3. package/dist/browser/workspace.js +3 -3
  4. package/dist/build-review.js +3 -2
  5. package/dist/builder.js +10 -8
  6. package/dist/chat-actions.js +7 -4
  7. package/dist/chat-flow.js +10 -6
  8. package/dist/converse.js +3 -2
  9. package/dist/coordinator-proposals.js +2 -2
  10. package/dist/decision.d.ts +3 -3
  11. package/dist/decision.js +4 -3
  12. package/dist/desktop-update.d.ts +1 -1
  13. package/dist/evidence.js +1 -1
  14. package/dist/flow-actions.d.ts +1 -0
  15. package/dist/flow-actions.js +6 -7
  16. package/dist/flow-code.d.ts +3 -2
  17. package/dist/flow-code.js +6 -4
  18. package/dist/flow-draft.d.ts +10 -4
  19. package/dist/flow-draft.js +19 -8
  20. package/dist/flow-engine.d.ts +0 -3
  21. package/dist/flow-engine.js +17 -10
  22. package/dist/flow-people.d.ts +0 -1
  23. package/dist/flow-people.js +9 -1
  24. package/dist/flow-send.js +3 -2
  25. package/dist/flow-steps.js +33 -10
  26. package/dist/flows.d.ts +23 -8
  27. package/dist/flows.js +106 -52
  28. package/dist/guides.js +1 -1
  29. package/dist/lead-context.d.ts +3 -9
  30. package/dist/lead-context.js +21 -5
  31. package/dist/mate-doors.js +8 -2
  32. package/dist/mate-tools.js +27 -10
  33. package/dist/mate.js +13 -2
  34. package/dist/operate.d.ts +3 -0
  35. package/dist/operate.js +122 -8
  36. package/dist/phase-routing.d.ts +86 -5
  37. package/dist/phase-routing.js +228 -16
  38. package/dist/project-skills.d.ts +10 -0
  39. package/dist/proposal.d.ts +10 -2
  40. package/dist/proposal.js +25 -10
  41. package/dist/recipe-ui.js +2 -1
  42. package/dist/scout-report.d.ts +10 -4
  43. package/dist/scout-report.js +23 -6
  44. package/dist/scout.d.ts +2 -0
  45. package/dist/scout.js +83 -10
  46. package/dist/serve.js +60 -10
  47. package/dist/setup-guide.d.ts +2 -2
  48. package/dist/store.d.ts +64 -5
  49. package/dist/store.js +192 -7
  50. package/dist/task-sizing.d.ts +78 -0
  51. package/dist/task-sizing.js +228 -0
  52. package/dist/task-text.d.ts +4 -4
  53. package/dist/task-text.js +10 -6
  54. package/dist/teammate-work.js +31 -7
  55. package/dist/teammates.d.ts +20 -1
  56. package/dist/teammates.js +18 -5
  57. package/dist/teams-chat.d.ts +4 -1
  58. package/dist/teams-chat.js +17 -6
  59. package/dist/telegram-flow.js +6 -2
  60. package/dist/test-temp.js +1 -1
  61. package/dist/text-limits.d.ts +67 -0
  62. package/dist/text-limits.js +73 -0
  63. package/dist/tier-report.d.ts +44 -0
  64. package/dist/tier-report.js +71 -0
  65. package/package.json +1 -1
@@ -27,12 +27,13 @@ import { join } from "node:path";
27
27
  import { approvedCommandShell, redactSecretAssignments, SETUP_ENV_ALLOWLIST, SETUP_ENV_DENYLIST } from "./builder.js";
28
28
  import { assignmentOf } from "./assignment.js";
29
29
  import { redactSecretLines, scanForSecrets } from "./evidence.js";
30
- import { flowDefinitionOf } from "./flow-engine.js";
30
+ import { flowCardHref, flowDefinitionOf } from "./flow-engine.js";
31
31
  import { cardFollowers, notifyPeople } from "./flow-people.js";
32
32
  import { LINEAR_URL, readLinearKey } from "./flow-triggers.js";
33
33
  import { cardEmailOf, fillFlowText } from "./flows.js";
34
34
  import { JEV_MODEL, askJev, readJevAnswers, sortLog, sortRequest, sortState, sortWords } from "./flow-sort.js";
35
- import { claudeDraftRunner, DRAFT_TIMEOUT, draftPrompt, keptDraft } from "./flow-draft.js";
35
+ import { claudeDraftRunner, DRAFT_CHARS, DRAFT_TIMEOUT, draftPrompt, wholeDraft } from "./flow-draft.js";
36
+ import { overruns, passOn, TEXT_LIMITS, writeWithin } from "./text-limits.js";
36
37
  import { readFlowSecrets, sendingReady, runRequest, sendEmail, toolWaiting, useTool } from "./flow-actions.js";
37
38
  import { cleanFolder, runCode } from "./flow-code.js";
38
39
  import { agentFence } from "./agent-fence.js";
@@ -246,7 +247,13 @@ function settle(store, definition, stage, card, outcome, now, durationMs) {
246
247
  outcome = { state: "failed", said: `${outcome.said} It didn't work after three tries.` };
247
248
  }
248
249
  store.finishFlowStep(card.id, card.entry, { state: outcome.state === "passed" ? "passed" : "failed", result: outcome.said, ...kept }, now);
249
- store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: outcome.output ?? outcome.said }, waiting: null }, now);
250
+ // What the steps after it read: whole up to TEXT_LIMITS.stageOutput; longer is attached whole to the card's discussion,
251
+ // and they read a link to it. Never cut.
252
+ const whole = outcome.output ?? outcome.said;
253
+ const passed = passOn(whole, TEXT_LIMITS.stageOutput, { label: "the card's discussion", href: flowCardHref(card.flow, card.id) });
254
+ if (passed.kept)
255
+ store.addFlowComment({ card: card.id, author: "flow", body: `What ${stage.title} produced, in full (${whole.length.toLocaleString("en-US")} characters):\n\n${whole}`, mentions: [] }, now);
256
+ store.updateFlowCard(card.id, { outputs: { ...card.outputs, [stage.id]: passed.text }, waiting: null }, now);
250
257
  const titleOf = (id) => definition.stages.find(one => one.id === id)?.title ?? "another zone";
251
258
  if (outcome.state === "passed") {
252
259
  // A sort names where the card goes; one it isn't sure about is a person's to place.
@@ -289,16 +296,32 @@ function draftModel(store) {
289
296
  /** A draft: Claude writes what the zone asks, from the card, with the lead chat's Claude model; the text stays on the card. */
290
297
  async function draftCard(store, definition, stage, card, io, repo, now) {
291
298
  const model = draftModel(store);
292
- const answer = await (io.draft ?? claudeDraftRunner())({ model, prompt: draftPrompt(stage, card, definition), timeoutMs: DRAFT_TIMEOUT });
299
+ const runner = io.draft ?? claudeDraftRunner();
300
+ // Claude is told the limit before it writes; a draft over it is asked once to shorten, and one still over is kept whole
301
+ // in the step's log and attached to the card's discussion, linked from what the next steps read (settle): never cut.
302
+ let first = null;
303
+ const written = await writeWithin(async (shorten) => {
304
+ const answer = await runner({ model, prompt: draftPrompt(stage, card, definition, shorten === null || first === null ? null : { draft: first, ask: shorten }), timeoutMs: DRAFT_TIMEOUT });
305
+ // v105: what it cost, billed as this computer's Claude does (a plan's is $0).
306
+ if (answer.ok) {
307
+ store.recordDraftSpend({ repo, model: model === "default" ? null : model, costUsd: answer.costUsd ?? null }, now);
308
+ first ??= wholeDraft(answer.text);
309
+ }
310
+ return answer;
311
+ }, answer => answer.ok ? overruns({ draft: wholeDraft(answer.text) }, { draft: DRAFT_CHARS }) : []);
312
+ const answer = written.answer;
293
313
  if (!answer.ok)
294
- return { state: "retry", said: answer.said };
295
- // v105: what it cost, billed as this computer's Claude does (a plan's is $0).
296
- store.recordDraftSpend({ repo, model: model === "default" ? null : model, costUsd: answer.costUsd ?? null }, now);
297
- const text = keptDraft(answer.text);
314
+ return written.repaired && first !== null ? draftOutcome(first, model, null, true) : { state: "retry", said: answer.said };
315
+ const text = wholeDraft(answer.text);
298
316
  if (text === "")
299
- return { state: "retry", said: "Claude's draft came back empty." };
317
+ return written.repaired && first !== null ? draftOutcome(first, model, answer.ms, true) : { state: "retry", said: "Claude's draft came back empty." };
318
+ return draftOutcome(text, model, answer.ms, written.repaired);
319
+ }
320
+ /** A draft step's result: the draft whole, on the card and in its log. */
321
+ function draftOutcome(text, model, ms, shortened) {
300
322
  const words = text.split(/\s+/).filter(Boolean).length;
301
- return { state: "passed", said: `Drafted ${words} word${words === 1 ? "" : "s"}.`, output: text, log: `Claude (${model}) · ${(answer.ms / 1000).toFixed(1)} s\n\n${text}` };
323
+ const over = text.length > DRAFT_CHARS ? ` It is still over the ${DRAFT_CHARS.toLocaleString("en-US")}-character limit after one ask to shorten, so it is kept whole.` : shortened ? " Shortened once to fit." : "";
324
+ return { state: "passed", said: `Drafted ${words} word${words === 1 ? "" : "s"}.${over}`, output: text, log: `Claude (${model})${ms === null ? "" : ` · ${(ms / 1000).toFixed(1)} s`}\n\n${text}` };
302
325
  }
303
326
  /** A sort: Jev picks one of the zone's answers for the card; the answer (or the not-sure path) says where it goes. */
304
327
  async function sortCard(definition, stage, card, key, io) {
package/dist/flows.d.ts CHANGED
@@ -167,6 +167,14 @@ export declare function deciderOf(stage: Pick<FlowStage, "approver" | "toOwner">
167
167
  export declare function validateFlowDefinition(input: unknown, options?: {
168
168
  stored?: boolean;
169
169
  }): FlowDefinition;
170
+ /** The instructions 0.9.26 and earlier read from a saved zone: they re-check this length on every read, and a flow with a
171
+ * longer one won't load. A saved zone keeps its first part under it, and the rest in `instructionsMore`, which they ignore. */
172
+ export declare const FLOW_INSTRUCTIONS_STORED = 4000;
173
+ /** A flow's definition as the store saves it: any zone's instructions over FLOW_INSTRUCTIONS_STORED are split, so a
174
+ * person who goes back a version still opens the flow (with its instructions' first part). Unchanged otherwise. */
175
+ export declare function flowDefinitionForStore(json: string): string;
176
+ /** A saved flow's definition with every zone's instructions whole again (flowDefinitionForStore). */
177
+ export declare function flowDefinitionFromStore(json: string): string;
170
178
  /** The zones a card can reach from `start` without passing through a zone `stop` picks (those zones aren't included). */
171
179
  export declare function reachableWithout(stages: readonly FlowStage[], start: string, stop: (stage: FlowStage) => boolean): Set<string>;
172
180
  /** The zones a choose zone's options lead to (an option that ends the card leads to none). */
@@ -183,20 +191,27 @@ type FlowFillCard = {
183
191
  };
184
192
  /** Fill a zone's text from the card: title, description, the latest note and earlier zones' reports. */
185
193
  export declare function fillFlowText(template: string, card: FlowFillCard, encode?: (value: string) => string): string;
186
- /** The task door's goal limit (task-text.ts). A work zone's goal is kept under it. */
187
- export declare const FLOW_GOAL_LIMIT = 2000;
188
- /** What stands in for the middle of a filled-in value the goal had no room for. */
189
- export declare const FLOW_CUT = "\u2026 (cut; the full text is on the card)";
194
+ /** The task door's goal limit (task-text.ts). A work zone's goal is kept within it. */
195
+ export declare const FLOW_GOAL_LIMIT: 8000;
196
+ /** What stands in a goal for a filled-in value too long to hold whole: the agent is given it whole, attached (flowGoalCuts). */
197
+ export declare const flowAttachedMark: (label: string) => string;
190
198
  /** A work zone's words, plus the card and any send-back note when the words leave them out. "Fix the bug on the card"
191
199
  * says nothing on its own (found in the real e2e: the builder saw only the branch name). */
192
200
  export declare function flowWorkTemplate(instructions: string, card: {
193
201
  description: string | null;
194
202
  note: string | null;
195
203
  }): string;
196
- /** A work zone's goal, filled from the card and kept under FLOW_GOAL_LIMIT: the zone's own words stay whole, and only the
197
- * filled-in values are shortened (the longest first), each keeping its start and end. Null when the words alone leave no
198
- * room, which a saved flow can't have (validateFlowDefinition). */
199
- export declare function fitFlowText(template: string, card: FlowFillCard, limit?: number): string | null;
204
+ /** How a filled-in value is named where it is attached: the same words flowGoalCuts labels it with. */
205
+ export declare function flowValueLabel(key: string, stageTitle?: (id: string) => string): string;
206
+ /** A work zone's goal, filled from the card and kept within `limit`: the zone's own words stay whole, and every filled-in
207
+ * value goes in whole. When they don't all fit, the longest values are attached instead (each named in the goal by
208
+ * flowAttachedMark, and given to the agent whole by flowGoalCuts) until the rest fits: nothing is cut. Words that fill
209
+ * the limit to the last character leave attached values a shorter mark, then none (they are still attached). Null only
210
+ * when the template's own words are over the limit: the caller then files the zone's words alone (flowGoal). */
211
+ export declare function fitFlowText(template: string, card: FlowFillCard, limit?: 8000, stageTitle?: (id: string) => string): string | null;
212
+ /** A work zone's goal for a card: its words, the card and any note, fitted (fitFlowText); when the zone's words leave no
213
+ * room for the card's own lines, its words alone, with the card attached. Null only for words over the limit. */
214
+ export declare function flowGoal(instructions: string, card: FlowFillCard, stageTitle?: (id: string) => string): string | null;
200
215
  /** The first email address a card mentions, or "" — what {{card.email}} holds. */
201
216
  export declare function cardEmailOf(card: {
202
217
  title: string;
package/dist/flows.js CHANGED
@@ -47,6 +47,7 @@
47
47
  * The engine is deterministic and model-free: it runs in the worker's pass
48
48
  * beside routines, and no card ever skips an approval the task itself needs.
49
49
  */
50
+ import { TEXT_LIMITS } from "./text-limits.js";
50
51
  import { createHash } from "node:crypto";
51
52
  export const FLOW_STAGE_KINDS = ["inbox", "task", "report", "approval", "check", "pull-request", "update", "notify", "sort", "draft", "request", "email", "tool", "wait", "teammate", "send", "choose", "done"];
52
53
  export const FLOW_COLORS = ["slate", "blue", "violet", "amber", "green", "rose"];
@@ -310,7 +311,8 @@ export function validateFlowDefinition(input, options = {}) {
310
311
  return {
311
312
  id, title, kind: kind,
312
313
  zone: { x: coordinate(zone["x"], -20000, 20000, index * 320), y: coordinate(zone["y"], -20000, 20000, 0), w: coordinate(zone["w"], 220, 1200, 280), h: coordinate(zone["h"], 160, 1600, 360), color },
313
- instructions: text(stage["instructions"], 4000),
314
+ // Reading a saved flow never re-checks length: a limit is for writing.
315
+ instructions: text(stage["instructions"], options.stored ? Number.POSITIVE_INFINITY : TEXT_LIMITS.flowInstructions),
314
316
  planning: kind === "task" ? planning ?? "auto" : null,
315
317
  approver: kind === "approval" && stage["toOwner"] !== true ? text(stage["approver"], 64) : null,
316
318
  ...(kind === "approval" && stage["toOwner"] === true ? { toOwner: true } : {}),
@@ -356,11 +358,6 @@ export function validateFlowDefinition(input, options = {}) {
356
358
  throw new Error(`Zone ${stage.title}: an answer can't send cards back into the same zone.`);
357
359
  if ((stage.kind === "task" || stage.kind === "report") && stage.instructions === null)
358
360
  throw new Error(`Zone ${stage.title}: say what the agent should do.`);
359
- // A work zone's goal must fit a task: its own words whole, with room left for the card's details. Refused here, when the flow
360
- // is saved, never when a card arrives. A flow saved before this rule is still read, and its card says why it can't file.
361
- const over = !options.stored && (stage.kind === "task" || stage.kind === "report") && stage.instructions !== null ? flowGoalOverflow(stage.instructions) : 0;
362
- if (over > 0)
363
- throw new Error(`Zone ${stage.title}: its instructions leave no room for the card's details. Shorten them by ${over} characters.`);
364
361
  if (stage.kind === "draft" && stage.instructions === null)
365
362
  throw new Error(`Zone ${stage.title}: say what Claude should write.`);
366
363
  if (stage.kind === "notify" && stage.message === null)
@@ -381,6 +378,59 @@ export function validateFlowDefinition(input, options = {}) {
381
378
  }
382
379
  return { version: 1, start, stages };
383
380
  }
381
+ /** The instructions 0.9.26 and earlier read from a saved zone: they re-check this length on every read, and a flow with a
382
+ * longer one won't load. A saved zone keeps its first part under it, and the rest in `instructionsMore`, which they ignore. */
383
+ export const FLOW_INSTRUCTIONS_STORED = 4000;
384
+ /** A flow's definition as the store saves it: any zone's instructions over FLOW_INSTRUCTIONS_STORED are split, so a
385
+ * person who goes back a version still opens the flow (with its instructions' first part). Unchanged otherwise. */
386
+ export function flowDefinitionForStore(json) {
387
+ let raw;
388
+ try {
389
+ raw = JSON.parse(json);
390
+ }
391
+ catch {
392
+ return json;
393
+ }
394
+ const stages = raw?.stages;
395
+ if (!Array.isArray(stages))
396
+ return json;
397
+ let split = false;
398
+ const kept = stages.map(one => {
399
+ const stage = one;
400
+ const words = stage?.["instructions"];
401
+ if (stage === null || typeof stage !== "object" || typeof words !== "string" || words.length <= FLOW_INSTRUCTIONS_STORED)
402
+ return one;
403
+ let at = FLOW_INSTRUCTIONS_STORED;
404
+ if (/[\uD800-\uDBFF]/.test(words[at - 1]))
405
+ at--;
406
+ split = true;
407
+ return { ...stage, instructions: words.slice(0, at), instructionsMore: words.slice(at) };
408
+ });
409
+ return split ? JSON.stringify({ ...raw, stages: kept }) : json;
410
+ }
411
+ /** A saved flow's definition with every zone's instructions whole again (flowDefinitionForStore). */
412
+ export function flowDefinitionFromStore(json) {
413
+ if (!json.includes("\"instructionsMore\""))
414
+ return json;
415
+ let raw;
416
+ try {
417
+ raw = JSON.parse(json);
418
+ }
419
+ catch {
420
+ return json;
421
+ }
422
+ const stages = raw?.stages;
423
+ if (!Array.isArray(stages))
424
+ return json;
425
+ const whole = stages.map(one => {
426
+ const stage = one;
427
+ if (stage === null || typeof stage !== "object" || typeof stage["instructionsMore"] !== "string")
428
+ return one;
429
+ const { instructionsMore, ...rest } = stage;
430
+ return { ...rest, instructions: `${typeof stage["instructions"] === "string" ? stage["instructions"] : ""}${instructionsMore}` };
431
+ });
432
+ return JSON.stringify({ ...raw, stages: whole });
433
+ }
384
434
  /** A Pull request zone's merge: true is squash, the default. */
385
435
  function validateMerge(value, title) {
386
436
  if (value === true)
@@ -498,12 +548,10 @@ const FILLED = /\{\{\s*(card\.title|card\.description|card\.email|note|stage\.([
498
548
  export function fillFlowText(template, card, encode = value => value) {
499
549
  return template.replace(FILLED, (_match, key, stage) => encode(fillOne(key, stage, card))).trim();
500
550
  }
501
- /** The task door's goal limit (task-text.ts). A work zone's goal is kept under it. */
502
- export const FLOW_GOAL_LIMIT = 2000;
503
- /** What stands in for the middle of a filled-in value the goal had no room for. */
504
- export const FLOW_CUT = "… (cut; the full text is on the card)";
505
- /** The least room each filled-in value keeps: the cut mark and 20 characters from each end. */
506
- const VALUE_ROOM = FLOW_CUT.length + 40;
551
+ /** The task door's goal limit (task-text.ts). A work zone's goal is kept within it. */
552
+ export const FLOW_GOAL_LIMIT = TEXT_LIMITS.goal;
553
+ /** What stands in a goal for a filled-in value too long to hold whole: the agent is given it whole, attached (flowGoalCuts). */
554
+ export const flowAttachedMark = (label) => `[${label}: attached in full below]`;
507
555
  /** A work zone's words, plus the card and any send-back note when the words leave them out. "Fix the bug on the card"
508
556
  * says nothing on its own (found in the real e2e: the builder saw only the branch name). */
509
557
  export function flowWorkTemplate(instructions, card) {
@@ -515,50 +563,56 @@ export function flowWorkTemplate(instructions, card) {
515
563
  ];
516
564
  return [instructions, ...extra].join("\n\n");
517
565
  }
518
- /** Characters a work zone's instructions leave no room for, with every filled-in value kept to its least room. 0 when they fit. */
519
- function flowGoalOverflow(instructions) {
520
- const widest = flowWorkTemplate(instructions, { description: "x", note: "x" });
521
- const values = [...widest.matchAll(FILLED)].length;
522
- return Math.max(0, widest.replace(FILLED, "").length + values * VALUE_ROOM - (FLOW_GOAL_LIMIT - 1));
523
- }
524
566
  /** Script output can carry carriage returns, colour codes and hidden marks a task goal refuses; the goal drops them. */
525
567
  const goalSafe = (value) => value.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F\u2028\u2029\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "");
526
- /** A value kept to `room` characters: its start and end, with the cut mark between. */
527
- function cutToRoom(value, room) {
528
- if (value.length <= room)
529
- return value;
530
- const keep = room - FLOW_CUT.length;
531
- let head = Math.ceil(keep / 2), from = value.length - (keep - head);
532
- if (/[\uD800-\uDBFF]/.test(value[head - 1] ?? ""))
533
- head--;
534
- if (/[\uDC00-\uDFFF]/.test(value[from] ?? ""))
535
- from++;
536
- return value.slice(0, head) + FLOW_CUT + value.slice(from);
568
+ /** How a filled-in value is named where it is attached: the same words flowGoalCuts labels it with. */
569
+ export function flowValueLabel(key, stageTitle = id => id) {
570
+ if (key === "card.title")
571
+ return "The card's title";
572
+ if (key === "card.description")
573
+ return "The card's description";
574
+ if (key === "card.email")
575
+ return "The card's email address";
576
+ if (key === "note")
577
+ return "The note it was sent back with";
578
+ const match = /^stage\.([a-z0-9-]+)(?:\.(items|report))?$/.exec(key);
579
+ if (match === null)
580
+ return key;
581
+ return `What ${stageTitle(match[1])} ${match[2] === "items" ? "listed" : match[2] === "report" ? "reported in full" : "found"}`;
537
582
  }
538
- /** A work zone's goal, filled from the card and kept under FLOW_GOAL_LIMIT: the zone's own words stay whole, and only the
539
- * filled-in values are shortened (the longest first), each keeping its start and end. Null when the words alone leave no
540
- * room, which a saved flow can't have (validateFlowDefinition). */
541
- export function fitFlowText(template, card, limit = FLOW_GOAL_LIMIT) {
542
- const values = [...template.matchAll(FILLED)].map(match => goalSafe(fillOne(match[1], match[2], card)));
543
- const room = limit - 1 - template.replace(FILLED, "").length;
544
- let each = Math.max(0, ...values.map(one => one.length));
545
- const used = (cap) => values.reduce((sum, one) => sum + Math.min(one.length, cap), 0);
546
- if (used(each) > room) {
547
- // The widest cap every value can share: the shortest values stay whole, the longest are cut to it.
548
- let low = 0, high = each;
549
- while (low < high) {
550
- const mid = Math.ceil((low + high) / 2);
551
- if (used(mid) <= room)
552
- low = mid;
553
- else
554
- high = mid - 1;
555
- }
556
- each = low;
557
- if (each < FLOW_CUT.length + 2)
558
- return null;
583
+ /** A work zone's goal, filled from the card and kept within `limit`: the zone's own words stay whole, and every filled-in
584
+ * value goes in whole. When they don't all fit, the longest values are attached instead (each named in the goal by
585
+ * flowAttachedMark, and given to the agent whole by flowGoalCuts) until the rest fits: nothing is cut. Words that fill
586
+ * the limit to the last character leave attached values a shorter mark, then none (they are still attached). Null only
587
+ * when the template's own words are over the limit: the caller then files the zone's words alone (flowGoal). */
588
+ export function fitFlowText(template, card, limit = FLOW_GOAL_LIMIT, stageTitle) {
589
+ const matches = [...template.matchAll(FILLED)];
590
+ const values = matches.map(match => goalSafe(fillOne(match[1], match[2], card)));
591
+ const marks = matches.map(match => flowAttachedMark(flowValueLabel(match[1], stageTitle)));
592
+ const attached = new Set();
593
+ const build = () => { let at = 0; return template.replace(FILLED, () => { const i = at++; return attached.has(i) ? marks[i] : values[i]; }).trim(); };
594
+ let goal = build();
595
+ // The longest first, so the fewest values leave the goal.
596
+ const order = values.map((value, i) => i).filter(i => values[i].length > marks[i].length).sort((a, b) => values[b].length - values[a].length);
597
+ for (const i of order) {
598
+ if (goal.length <= limit)
599
+ break;
600
+ attached.add(i);
601
+ goal = build();
559
602
  }
560
- let at = 0;
561
- return template.replace(FILLED, () => cutToRoom(values[at++], each)).trim();
603
+ for (const shorter of ["[attached]", ""]) {
604
+ if (goal.length <= limit)
605
+ break;
606
+ for (const i of attached)
607
+ marks[i] = shorter;
608
+ goal = build();
609
+ }
610
+ return goal.length <= limit ? goal : null;
611
+ }
612
+ /** A work zone's goal for a card: its words, the card and any note, fitted (fitFlowText); when the zone's words leave no
613
+ * room for the card's own lines, its words alone, with the card attached. Null only for words over the limit. */
614
+ export function flowGoal(instructions, card, stageTitle) {
615
+ return fitFlowText(flowWorkTemplate(instructions, card), card, FLOW_GOAL_LIMIT, stageTitle) ?? fitFlowText(instructions, card, FLOW_GOAL_LIMIT, stageTitle);
562
616
  }
563
617
  /** The first email address a card mentions, or "" — what {{card.email}} holds. */
564
618
  export function cardEmailOf(card) {
package/dist/guides.js CHANGED
@@ -207,7 +207,7 @@ const steering = {
207
207
  ${AUTHORITY_LINE}
208
208
 
209
209
  Steering is an OPERATOR ceremony. \`task steer <id> --note "..." --as
210
- <you> --token <t>\` files one short note (≤500 characters) for the
210
+ <you> --token <t>\` files one note (≤4000 characters) for the
211
211
  task's NEXT attempt, and the credential is the point: the note appears
212
212
  in the agent's brief under an OPERATOR STEERING heading, so only a
213
213
  verified person may put words there. Anonymous or agent-authored notes
@@ -1,17 +1,11 @@
1
- /** Bounded DB memory shared by browser and terminal lead turns. No provider,
2
- * repository scan, mutation, or hidden approval happens while catching up.
3
- *
4
- * The bundle is ordered by importance and built fresh each turn: who the lead
5
- * is (name, persona), who it is talking to (first name, time zone, today), what
6
- * it knows about them (their confirmed lines), the people, AI teammates and team
7
- * chats it works with (one line each), the channel, what needs them now,
8
- * projects by name with their active decisions, then the rest. Over 8 KB, the
9
- * least important goes first: the rest, then people, then decisions. */
10
1
  import type { Store } from './store.js';
11
2
  import { type AssignmentCatchUp } from './assignment-brief.js';
12
3
  export declare const LEAD_CONTEXT_MAX_BYTES = 8000;
13
4
  /** Where this turn's conversation happens. */
14
5
  export type LeadChannel = 'console' | 'terminal' | 'telegram' | 'slack' | 'discord' | 'teams';
6
+ /** One message on the channel, in characters: the lead writes a reply within it (it is told before it writes and asked
7
+ * once to shorten one over it); a longer reply is split across messages, never cut. Null: no platform limit. */
8
+ export declare const LEAD_REPLY_LIMITS: Record<LeadChannel, number | null>;
15
9
  export type LeadContextOptions = {
16
10
  evidenceRoot?: string;
17
11
  /** The person this turn talks to (their account name). */
@@ -1,3 +1,13 @@
1
+ /** Bounded DB memory shared by browser and terminal lead turns. No provider,
2
+ * repository scan, mutation, or hidden approval happens while catching up.
3
+ *
4
+ * The bundle is ordered by importance and built fresh each turn: who the lead
5
+ * is (name, persona), who it is talking to (first name, time zone, today), what
6
+ * it knows about them (their confirmed lines), the people, AI teammates and team
7
+ * chats it works with (one line each), the channel, what needs them now,
8
+ * projects by name with their active decisions, then the rest. Over 8 KB, the
9
+ * least important goes first: the rest, then people, then decisions. */
10
+ import { PLATFORM_LIMITS } from './text-limits.js';
1
11
  import { activeDecisionsOf, assignmentCatchUp } from './assignment-brief.js';
2
12
  import { publicChatText } from './chat-display.js';
3
13
  import { leadIdentityOf } from './lead-identity.js';
@@ -37,13 +47,19 @@ function followThrough(store, owner, thread) {
37
47
  ...(corrections.length === 0 ? {} : { followThrough: 'The operator confirmed these corrections since your last reply. Re-check the open proposals and promises listed here; release or replace any they affect and say in one line what you changed.' }) };
38
48
  }
39
49
  export const LEAD_CONTEXT_MAX_BYTES = 8_000;
50
+ /** One message on the channel, in characters: the lead writes a reply within it (it is told before it writes and asked
51
+ * once to shorten one over it); a longer reply is split across messages, never cut. Null: no platform limit. */
52
+ export const LEAD_REPLY_LIMITS = {
53
+ console: null, terminal: null, telegram: PLATFORM_LIMITS.telegram, slack: PLATFORM_LIMITS.slack, discord: PLATFORM_LIMITS.discord, teams: PLATFORM_LIMITS.teams,
54
+ };
55
+ const within = (channel) => { const limit = LEAD_REPLY_LIMITS[channel]; return limit === null ? '' : ` Keep each reply within ${limit.toLocaleString('en-US')} characters, one message; a longer one is split across messages.`; };
40
56
  const CHANNEL_WORDS = {
41
57
  console: 'The Toolroll console in a browser: cards and links show beside your reply.',
42
58
  terminal: 'The Toolroll CLI in a terminal: plain text only.',
43
- telegram: 'Telegram on their phone: a few short lines, the most important first.',
44
- slack: 'A Slack thread: a few short lines; teammates may read it.',
45
- discord: 'A Discord thread: a few short lines; teammates may read it.',
46
- teams: 'A Microsoft Teams thread: a few short lines; teammates may read it.',
59
+ telegram: `Telegram on their phone: a few short lines, the most important first.${within('telegram')}`,
60
+ slack: `A Slack thread: a few short lines; teammates may read it.${within('slack')}`,
61
+ discord: `A Discord thread: a few short lines; teammates may read it.${within('discord')}`,
62
+ teams: `A Microsoft Teams thread: a few short lines; teammates may read it.${within('teams')}`,
47
63
  };
48
64
  /** "alex.pelletier@example.com" → "Alex". */
49
65
  export function firstNameOf(account) {
@@ -114,7 +130,7 @@ export function leadContext(store, repos, now, options = {}) {
114
130
  you: { firstName, ...localNow(now, options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone) },
115
131
  aboutYou,
116
132
  people: { people: [], teammates: [], teams: [] },
117
- channel: options.channel === undefined ? null : { id: options.channel, fit: CHANNEL_WORDS[options.channel] },
133
+ channel: options.channel === undefined ? null : { id: options.channel, fit: CHANNEL_WORDS[options.channel], replyLimit: LEAD_REPLY_LIMITS[options.channel] },
118
134
  needsYou: brief.assignments.filter(needs).map(task),
119
135
  ...(options.owner === undefined || options.thread === undefined ? {} : followThrough(store, options.owner, options.thread)),
120
136
  projects,
@@ -20,7 +20,7 @@ import { isVerifiedApprover, reproveApprover } from "./principal.js";
20
20
  import { TeamLeads } from './team-leads.js';
21
21
  import { fileTaskProposal } from "./proposal.js";
22
22
  import { proposeGuarded } from "./scope.js";
23
- import { isRiskLevel, PHASES, riskTitle, specWords } from "./phase-routing.js";
23
+ import { isRiskLevel, isTaskSize, PHASES, riskTitle, specWords } from "./phase-routing.js";
24
24
  import { isProviderId } from "./provider.js";
25
25
  import { applyChatReview } from "./chat-review.js";
26
26
  import { applyChatTaskAction } from "./chat-task-actions.js";
@@ -454,11 +454,15 @@ function executeProposal(store, actor, kind, payload, now, options) {
454
454
  const clear = payload["clear"] === true;
455
455
  const provider = payloadString(payload, "provider");
456
456
  const model = payloadString(payload, "model");
457
+ const size = payload["size"];
458
+ const risky = payload["risky"];
457
459
  if (risk !== undefined && !isRiskLevel(risk))
458
460
  return refuse("refused", "this proposal carries an unknown risk level");
461
+ if (size !== undefined && (!isTaskSize(size) || typeof risky !== "boolean"))
462
+ return refuse("refused", "this proposal carries an unknown size");
459
463
  if (phase !== undefined && (typeof phase !== "string" || !PHASES.includes(phase)))
460
464
  return refuse("refused", "this proposal names an unknown role");
461
- if (risk === undefined && phase === undefined)
465
+ if (risk === undefined && phase === undefined && size === undefined)
462
466
  return refuse("refused", "this proposal changes nothing about the agents");
463
467
  let override;
464
468
  if (typeof phase === "string") {
@@ -478,6 +482,7 @@ function executeProposal(store, actor, kind, payload, now, options) {
478
482
  return standing.ok ? { ok: true } : { ok: false, reason: `your approver standing changed (${standing.reason}) — sign in again` };
479
483
  },
480
484
  ...(isRiskLevel(risk) ? { risk } : {}),
485
+ ...(isTaskSize(size) && typeof risky === "boolean" ? { size: { size, risky } } : {}),
481
486
  ...(override === undefined ? {} : { override }),
482
487
  expectDigest: payloadString(payload, "sawDigest"),
483
488
  // The pair is re-proved against the role's configured choices
@@ -508,6 +513,7 @@ function executeProposal(store, actor, kind, payload, now, options) {
508
513
  const roleWord = typeof phase === "string" ? { plan: "planner", build: "builder", repair: "repair", review: "reviewer" }[phase] ?? phase : null;
509
514
  const changed = [
510
515
  ...(isRiskLevel(risk) ? [`risk is now ${riskTitle(risk).toLowerCase()}`] : []),
516
+ ...(isTaskSize(size) ? [`it is now a ${size}${risky === true ? ", risky" : ""} change`] : []),
511
517
  ...(roleWord === null ? [] : clear ? [`the ${roleWord} choice was cleared — the recommendation stands again`] : [`the ${roleWord} is now ${specWords({ provider: provider, model: model })}`]),
512
518
  ];
513
519
  return {
@@ -1,3 +1,4 @@
1
+ import { TEXT_LIMITS } from "./text-limits.js";
1
2
  import { connectionsOf, oneClickOf } from "./mcp-connect.js";
2
3
  import { checkLevelFromWords, isCheckLevel } from "./check-levels.js";
3
4
  import { policyParts } from "./policy.js";
@@ -42,7 +43,7 @@ import { parseAcceptanceCriteria, ACCEPTANCE_LIMITS, EVIDENCE_KINDS } from "./sc
42
43
  import { diagnoseTaskDispatch, withDispatchDiagnoses } from "./dispatch.js";
43
44
  import { agentChoicesFor, routeOfTask, INSTALLATION_SCOPE } from "./agentconfig.js";
44
45
  import { isNewModel, modelWords, priceWords, runtimeStates, seenModels } from "./model-catalog.js";
45
- import { agentsSummary, chosenWords, isRiskLevel, PHASES, postureWords, RISK_CHOICES, riskConsequence, riskTitle, routeProblems, sameSpec, specWords } from "./phase-routing.js";
46
+ import { agentsSummary, chosenWords, isRiskLevel, isTaskSize, PHASES, postureWords, RISK_CHOICES, riskConsequence, riskTitle, routeProblems, sameSpec, sizeSourceWords, sizeWords, specWords } from "./phase-routing.js";
46
47
  import { TOOL_CATALOG, discoverTools, projectToolsOf, secretsSetFor, toolCommandLine, toolStanding } from "./project-tools.js";
47
48
  import { deciderOf, durationWords, FLOW_KIND_WORDS, FLOW_STAGE_KINDS, FLOW_TEMPLATES, flowFromSteps, stepsFor } from "./flows.js";
48
49
  import { flowDefinitionOf } from "./flow-engine.js";
@@ -359,6 +360,7 @@ export function agentsOver(store, taskId, now) {
359
360
  const base = {
360
361
  task: taskId,
361
362
  risk: { level: risk, title: riskTitle(risk), consequence: riskConsequence(risk) },
363
+ size: ref.sizing == null ? null : { size: ref.sizing.size, risky: ref.sizing.risky, reason: ref.sizing.reason, source: sizeSourceWords(ref.sizing.source) },
362
364
  riskChoices: RISK_CHOICES.map(one => ({ risk: one.risk, title: one.title, consequence: one.consequence })),
363
365
  editable,
364
366
  editableWhy,
@@ -378,6 +380,7 @@ export function agentsOver(store, taskId, now) {
378
380
  standing: routed.source === "approved" ? "approved" : routed.source === "proposed" ? "awaiting approval" : "recommended",
379
381
  summary: agentsSummary(route),
380
382
  posture: postureWords(route),
383
+ sizeWords: sizeWords(route),
381
384
  demands: route.demands.filter(reason => !/review/i.test(reason)),
382
385
  agents: route.legs.filter(leg => leg.phase !== "review").map(leg => ({ role: ROLE_WORD[leg.phase], provider: leg.provider, model: leg.model, chosen: chosenWords(leg), reasons: leg.reasons.map(reason => reason.replace("builder and reviewer", "builder")), problem: leg.problem })),
383
386
  problems: routeProblems(route),
@@ -433,7 +436,7 @@ export const MATE_TOOLS = [
433
436
  {
434
437
  name: "propose_action",
435
438
  description: "Read get_actions and relevant skills/evidence first. Save an exact-state proposal only; protected or long terms require full secure review.",
436
- inputSchema: schema({ operation: { type: 'string', enum: Object.keys(CHAT_ACTIONS).filter(one => !one.startsWith('flow_') && !one.startsWith('teammate_')) }, repo: { type: 'string' }, task: TASK_ARG, version: { type: 'string' }, restore: { type: 'integer', minimum: 1 }, sample: { type: 'string', maxLength: 800 }, content: { type: 'string', maxLength: 12000 }, instructions: { type: 'string', maxLength: 4000 }, title: { type: 'string', maxLength: 120 }, id: { type: 'string' }, run: { type: 'integer', minimum: 1 }, note: { type: 'string', maxLength: 2000 }, catalog: { type: 'string', maxLength: 40 }, name: { type: 'string', maxLength: 40 }, command: { type: 'string', maxLength: 400 }, args: { type: 'array', items: { type: 'string', maxLength: 400 }, maxItems: 40 }, url: { type: 'string', maxLength: 500 }, secrets: { type: 'array', items: { type: 'string', maxLength: 64 }, maxItems: 12 }, about: { type: 'string', maxLength: 240 } }, ['operation']),
439
+ inputSchema: schema({ operation: { type: 'string', enum: Object.keys(CHAT_ACTIONS).filter(one => !one.startsWith('flow_') && !one.startsWith('teammate_')) }, repo: { type: 'string' }, task: TASK_ARG, version: { type: 'string' }, restore: { type: 'integer', minimum: 1 }, sample: { type: 'string', maxLength: 800 }, content: { type: 'string', maxLength: 12000 }, instructions: { type: 'string', maxLength: TEXT_LIMITS.flowInstructions }, title: { type: 'string', maxLength: 120 }, id: { type: 'string' }, run: { type: 'integer', minimum: 1 }, note: { type: 'string', maxLength: LIMITS.note, description: `At most ${LIMITS.note} characters; longer is refused with its length, not cut.` }, catalog: { type: 'string', maxLength: 40 }, name: { type: 'string', maxLength: 40 }, command: { type: 'string', maxLength: 400 }, args: { type: 'array', items: { type: 'string', maxLength: 400 }, maxItems: 40 }, url: { type: 'string', maxLength: 500 }, secrets: { type: 'array', items: { type: 'string', maxLength: 64 }, maxItems: 12 }, about: { type: 'string', maxLength: 240 } }, ['operation']),
437
440
  handle: (ctx, args) => {
438
441
  const operation = args['operation'];
439
442
  if (!isChatAction(operation))
@@ -894,7 +897,7 @@ export const MATE_TOOLS = [
894
897
  name: { type: "string", maxLength: 80 }, template: { type: "string", enum: FLOW_TEMPLATES.map(one => one.id) },
895
898
  steps: { type: "array", minItems: 1, maxItems: 24, items: { type: "object", additionalProperties: false, properties: {
896
899
  id: { type: "string", maxLength: 32 }, title: { type: "string", maxLength: 60 }, kind: { type: "string", enum: [...FLOW_STAGE_KINDS] },
897
- instructions: { type: "string", maxLength: 4000 }, planning: { type: "string", enum: ["auto", "required", "skip"] },
900
+ instructions: { type: "string", maxLength: TEXT_LIMITS.flowInstructions }, planning: { type: "string", enum: ["auto", "required", "skip"] },
898
901
  decider: { type: "string", maxLength: 64 }, message: { type: "string", maxLength: 1000 },
899
902
  script: { type: "string", maxLength: 40 }, close: { type: "boolean" },
900
903
  question: { type: "string", maxLength: 300 }, sureAt: { type: "integer", minimum: 50, maximum: 99 },
@@ -913,7 +916,7 @@ export const MATE_TOOLS = [
913
916
  ifReplied: { type: "string", maxLength: 60 }, repo: REPO_ARG,
914
917
  next: { type: "string", maxLength: 60 }, ifFails: { type: "string", maxLength: 60 }, ifNotSure: { type: "string", maxLength: 60 },
915
918
  } } },
916
- title: { type: "string", maxLength: 200 }, description: { type: "string", maxLength: 4000 }, zone: { type: "string", maxLength: 60 }, note: { type: "string", maxLength: 2000 },
919
+ title: { type: "string", maxLength: 200 }, description: { type: "string", maxLength: 4000 }, zone: { type: "string", maxLength: 60 }, note: { type: "string", maxLength: LIMITS.note, description: `At most ${LIMITS.note} characters; longer is refused with its length, not cut.` },
917
920
  choice: { type: "integer", minimum: 1, maximum: 4 },
918
921
  trigger: { type: "integer", minimum: 1 }, owner: { type: "string", maxLength: 64 },
919
922
  script: { type: "object", additionalProperties: false, properties: { name: { type: "string", maxLength: 40 }, about: { type: "string", maxLength: 160 }, body: { type: "string", maxLength: 1600 }, timeoutMinutes: { type: "integer", minimum: 1, maximum: 60 },
@@ -1829,15 +1832,17 @@ export const MATE_TOOLS = [
1829
1832
  {
1830
1833
  name: "propose_steer",
1831
1834
  description: "Guide the next attempt within existing scope; never interrupt work.",
1832
- inputSchema: schema({ task: TASK_ARG, note: { type: "string", maxLength: 2_000 } }, ["task", "note"]),
1835
+ inputSchema: schema({ task: TASK_ARG, note: { type: "string", maxLength: LIMITS.note, description: `At most ${LIMITS.note} characters; longer is refused with its length, not cut.` } }, ["task", "note"]),
1833
1836
  handle: (ctx, args) => {
1834
1837
  const taskId = taskIdOf(args);
1835
1838
  const ref = taskId === null ? null : admittedRef(ctx, taskId);
1836
1839
  const task = taskId === null ? null : ctx.store.getTask(taskId);
1837
1840
  if (taskId === null || ref === null || task === null)
1838
1841
  return notFound();
1839
- if (!honest(args["note"], 2_000))
1840
- return { ok: false, message: "guidance is plain text up to 2,000 characters" };
1842
+ if (typeof args["note"] === "string" && args["note"].length > LIMITS.note)
1843
+ return { ok: false, message: `guidance is ${args["note"].length} characters; the limit is ${LIMITS.note}. Shorten it and propose again.` };
1844
+ if (!honest(args["note"], LIMITS.note))
1845
+ return { ok: false, message: `guidance is plain text up to ${LIMITS.note} characters` };
1841
1846
  if (task.state === "done" || task.state === "cancelled")
1842
1847
  return { ok: false, message: "that task is finished, so guidance has no next attempt to reach" };
1843
1848
  if (ctx.store.openContestFor(ref.id) !== null)
@@ -1945,10 +1950,12 @@ export const MATE_TOOLS = [
1945
1950
  },
1946
1951
  {
1947
1952
  name: "propose_agents",
1948
- description: "Read get_agents. Change risk/configured role model or clear override; stales approval, refuses running work.",
1953
+ description: "Read get_agents. Change risk, size (small: fast model, no plan; large or risky: strongest agents), configured role model, or clear override; stales approval, refuses running work.",
1949
1954
  inputSchema: schema({
1950
1955
  task: TASK_ARG,
1951
1956
  risk: { type: "string", enum: ["routine", "elevated", "high"] },
1957
+ size: { type: "string", enum: ["small", "medium", "large"] },
1958
+ risky: { type: "boolean" },
1952
1959
  role: { type: "string", enum: ["planner", "builder", "repair"] },
1953
1960
  agent: schema({ provider: { type: "string", maxLength: 20 }, model: { type: "string", maxLength: 120 } }, ["provider", "model"]),
1954
1961
  clear: { type: "boolean" },
@@ -1969,8 +1976,16 @@ export const MATE_TOOLS = [
1969
1976
  return { ok: false, message: "role is planner, builder, or repair" };
1970
1977
  const clear = args["clear"] === true;
1971
1978
  const agent = args["agent"];
1972
- if (risk === undefined && phase === null)
1973
- return { ok: false, message: "say what changes: a risk, or a role with an agent (or clear: true)" };
1979
+ const size = args["size"];
1980
+ const risky = args["risky"];
1981
+ if (size !== undefined && !isTaskSize(size))
1982
+ return { ok: false, message: "size is small, medium, or large" };
1983
+ if (risky !== undefined && typeof risky !== "boolean")
1984
+ return { ok: false, message: "risky is true or false" };
1985
+ const sized = ctx.store.refForId(ref.id)?.sizing ?? null;
1986
+ const sizeChange = size === undefined && risky === undefined ? null : { size: size ?? sized?.size ?? "medium", risky: risky ?? sized?.risky ?? false };
1987
+ if (risk === undefined && phase === null && sizeChange === null)
1988
+ return { ok: false, message: "say what changes: a risk, a size, or a role with an agent (or clear: true)" };
1974
1989
  if (phase !== null && !clear && (agent === null || typeof agent !== "object"))
1975
1990
  return { ok: false, message: "a role change names an agent from get_agents, or clear: true" };
1976
1991
  if (phase === null && (clear || agent !== undefined))
@@ -2014,6 +2029,7 @@ export const MATE_TOOLS = [
2014
2029
  taskTitle: task.title,
2015
2030
  repoId: ref.repoId,
2016
2031
  ...(risk === undefined ? {} : { risk, riskConsequence: riskConsequence(risk) }),
2032
+ ...(sizeChange === null ? {} : { size: sizeChange.size, risky: sizeChange.risky }),
2017
2033
  ...(phase === null ? {} : { phase, role: ROLE_WORD[phase] }),
2018
2034
  ...(chosen === null ? {} : { provider: chosen.provider, model: chosen.model }),
2019
2035
  ...(clear ? { clear: true } : {}),
@@ -2031,6 +2047,7 @@ export const MATE_TOOLS = [
2031
2047
  kind: "agents",
2032
2048
  task: taskId,
2033
2049
  ...(risk === undefined ? {} : { risk }),
2050
+ ...(sizeChange === null ? {} : { size: sizeChange.size, risky: sizeChange.risky }),
2034
2051
  ...(phase === null ? {} : { role: ROLE_WORD[phase], ...(chosen === null ? { clear: true } : { agent: chosen }) }),
2035
2052
  awaiting: view["approval"] === "approved" ? "the operator's confirmation — the current approval will then need renewing" : "the operator's confirmation",
2036
2053
  },