toolroll 0.9.27 → 0.9.29

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 (70) hide show
  1. package/CHANGELOG.md +28 -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/lead-status.js +17 -12
  32. package/dist/mate-doors.js +8 -2
  33. package/dist/mate-tools.js +27 -10
  34. package/dist/mate.js +13 -2
  35. package/dist/needs-you.d.ts +2 -2
  36. package/dist/needs-you.js +3 -0
  37. package/dist/operate.d.ts +3 -0
  38. package/dist/operate.js +144 -9
  39. package/dist/phase-routing.d.ts +86 -5
  40. package/dist/phase-routing.js +229 -17
  41. package/dist/process-liveness.d.ts +1 -0
  42. package/dist/process-liveness.js +5 -0
  43. package/dist/project-skills.d.ts +10 -0
  44. package/dist/proposal.d.ts +10 -2
  45. package/dist/proposal.js +25 -10
  46. package/dist/recipe-ui.js +2 -1
  47. package/dist/scout-report.d.ts +10 -4
  48. package/dist/scout-report.js +23 -6
  49. package/dist/scout.d.ts +2 -0
  50. package/dist/scout.js +83 -10
  51. package/dist/serve.js +71 -15
  52. package/dist/setup-guide.d.ts +2 -2
  53. package/dist/store.d.ts +72 -8
  54. package/dist/store.js +230 -29
  55. package/dist/task-sizing.d.ts +78 -0
  56. package/dist/task-sizing.js +228 -0
  57. package/dist/task-text.d.ts +4 -4
  58. package/dist/task-text.js +10 -6
  59. package/dist/teammate-work.js +31 -7
  60. package/dist/teammates.d.ts +20 -1
  61. package/dist/teammates.js +18 -5
  62. package/dist/teams-chat.d.ts +4 -1
  63. package/dist/teams-chat.js +17 -6
  64. package/dist/telegram-flow.js +6 -2
  65. package/dist/test-temp.js +1 -1
  66. package/dist/text-limits.d.ts +67 -0
  67. package/dist/text-limits.js +73 -0
  68. package/dist/tier-report.d.ts +44 -0
  69. package/dist/tier-report.js +71 -0
  70. package/package.json +1 -1
@@ -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
  },
package/dist/mate.js CHANGED
@@ -9,7 +9,8 @@ import { redactSecretAssignments } from "./builder.js";
9
9
  import { MATE_CONTRACT } from "./mate-contract.js";
10
10
  import { MATE_MAX_PROPOSALS_PER_TURN, MATE_TOOL_SCHEMAS, executeMateTool, isMateTool, mateViewContextFor, projectLabelForMate, redactForMate, toolResultBytes } from "./mate-tools.js";
11
11
  import { composeSubscriptionMatePrompt, performSubscriptionMateRequest } from "./subscription-chat.js";
12
- import { leadContext } from './lead-context.js';
12
+ import { LEAD_REPLY_LIMITS, leadContext } from './lead-context.js';
13
+ import { shortenAsk } from './text-limits.js';
13
14
  import { envValue } from "./names.js";
14
15
  import { deliverableClaim, deliverableRepair, dropDeliverableClaims, replyCarriesDeliverable } from "./reply-shape.js";
15
16
  export const MATE_MESSAGE_MAX_CHARS = 2_000;
@@ -317,6 +318,9 @@ export async function runMateTurn(input) {
317
318
  let shownControl = false;
318
319
  /** The turn's one repair step for a reply that claims an attachment it does not carry. */
319
320
  let repaired = false;
321
+ /** The turn's one shorten step for a reply over its channel's message limit (LEAD_REPLY_LIMITS); still over, it is split. */
322
+ let shortened = false;
323
+ const replyLimit = input.channel === undefined ? null : LEAD_REPLY_LIMITS[input.channel];
320
324
  const unbacked = (text) => {
321
325
  const claim = deliverableClaim(text);
322
326
  if (claim === null || shownControl || replyCarriesDeliverable(text) || store.listMateTurnEvidence(turnId).length > 0)
@@ -447,7 +451,14 @@ export async function runMateTurn(input) {
447
451
  history.push({ role: "operator", text: deliverableRepair(claim) });
448
452
  continue;
449
453
  }
450
- reply = claim === null ? answer.text : dropDeliverableClaims(answer.text);
454
+ const candidate = claim === null ? answer.text : dropDeliverableClaims(answer.text);
455
+ if (replyLimit !== null && candidate.length > replyLimit && !shortened && steps < maxSteps) {
456
+ shortened = true;
457
+ history.push({ role: "assistant", text: answer.text, calls: [] });
458
+ history.push({ role: "operator", text: shortenAsk([{ field: "Your reply", limit: replyLimit, length: candidate.length }]) });
459
+ continue;
460
+ }
461
+ reply = candidate;
451
462
  break;
452
463
  }
453
464
  if (answer.calls.length > MATE_MAX_CALLS_PER_STEP)
@@ -8,7 +8,7 @@
8
8
  /** What a person is asked for. */
9
9
  export type NeedKey = "approval" | "card" | "answer" | "questions" | "sign-in" | "confirm-stopped" | "check-stopped" | "hold" | "choose-project" | "define-task" | "fix-request" | "choose-agent" | "fix-dependency" | "add-requirement" | "connect-builder" | "start-builder" | "vanished" | "review-result" | "rebuild" | "earlier-version" | "other";
10
10
  /** What the work waits for when no person can act. */
11
- export type WaitKey = "build-stopping" | "other-computer" | "card-reply" | "card-time" | "card-ci";
11
+ export type WaitKey = "build-stopping" | "build-finishing" | "other-computer" | "card-reply" | "card-time" | "card-ci";
12
12
  /** The action's code: the WorkAction that owns the act, or one of the acts a surface renders itself. */
13
13
  export type NeedActionCode = "approve-scope" | "approve-card" | "answer-decision" | "inspect-decisions" | "sign-in" | "confirm-stopped" | "inspect-hold" | "place-task" | "write-scope" | "select-agent" | "repair-dependency" | "repair-capability" | "start-worker" | "reconcile-run" | "open-result" | "retry-task" | "inspect-task";
14
14
  export type NeedAction = {
@@ -73,7 +73,7 @@ export declare function waitSentence(key: WaitKey | undefined, reason: string |
73
73
  * `unknown`: Toolroll can't check, so the person confirms they checked. */
74
74
  export declare function processNeedOf(fact: {
75
75
  run: number;
76
- kind: "open" | "alive" | "elsewhere" | "unprovable" | "unknown";
76
+ kind: "open" | "alive" | "elsewhere" | "unprovable" | "unknown" | "settling";
77
77
  } | null): {
78
78
  need: "confirm-stopped" | "check-stopped";
79
79
  build: number;
package/dist/needs-you.js CHANGED
@@ -79,6 +79,7 @@ export const NEED_ASK = {
79
79
  "add-requirement": "unblock", "connect-builder": "unblock", "start-builder": "unblock", vanished: "unblock", "earlier-version": "unblock", other: "unblock",
80
80
  };
81
81
  export const WAITS = {
82
+ "build-finishing": () => "Finishing up; this clears on its own.",
82
83
  "build-stopping": context => `Waiting for ${build(context)} to stop. Nothing is needed from you.`,
83
84
  "other-computer": context => `Waiting for the computer that ran ${build(context)} to confirm it stopped.`,
84
85
  "card-reply": () => "Waiting for a reply.",
@@ -110,6 +111,8 @@ export function waitSentence(key, reason, context = {}) {
110
111
  export function processNeedOf(fact) {
111
112
  if (fact === null)
112
113
  return null;
114
+ if (fact.kind === "settling")
115
+ return { wait: "build-finishing", build: fact.run };
113
116
  if (fact.kind === "unprovable")
114
117
  return { need: "confirm-stopped", build: fact.run };
115
118
  if (fact.kind === "unknown")
package/dist/operate.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type IntegrationIo } from "./integrations.js";
2
2
  import { type OnboardIo } from "./agent-onboard.js";
3
+ import { type Sizer } from "./task-sizing.js";
3
4
  import { type TriggerIo } from "./flow-triggers.js";
4
5
  import type { StepIo } from "./flow-steps.js";
5
6
  import type { FetchLike } from "./flow-share.js";
@@ -83,6 +84,8 @@ export type OperateOptions = {
83
84
  env?: Record<string, string | undefined>;
84
85
  openBrowser?: (url: string) => void;
85
86
  };
87
+ /** Injected by tests: the classifier that sizes filed tasks (none by default under test). */
88
+ filingSizer?: Sizer;
86
89
  };
87
90
  export type OnboardSeams = Partial<Pick<OnboardIo, "home" | "env" | "cwd" | "interactive" | "confirm" | "findRepo" | "checkConnection" | "pullRequests">>;
88
91
  export declare const OPERATE_HELP: string;
package/dist/operate.js CHANGED
@@ -26,6 +26,8 @@ import { checkIntegrations, integrationsBrokenLine, integrationsNow, renderInteg
26
26
  import { buildHandoff, handoffLines, loginAccount, runOnboard } from "./agent-onboard.js";
27
27
  import { backupFiles, backupFolderOf, backupOwner, backupNow, restoreDatabase, startBackups } from "./backup.js";
28
28
  import { pushLimitSink } from "./provider-limits.js";
29
+ import { heuristicSizing, installFilingSizer, ownerSizer, refineFiledSizing, settleSizings } from "./task-sizing.js";
30
+ import { tierLines, tierReport } from "./tier-report.js";
29
31
  import { limitsView } from "./limits-ui.js";
30
32
  import { startCodexLimits } from "./codex-limits.js";
31
33
  import { packageVersion, startMonitoring, targetOf } from "./monitoring.js";
@@ -176,7 +178,7 @@ import { TEMPLATES, templateByName } from "./templates.js";
176
178
  import { planTournament, planComparison, contestNoun, jointApprovalDigest, admitContest, crossReadyBarrier, finalizeContestant, recoverContests, maybeAggregate as contestMaybeAggregate, sweepContestCleanup, escalateOverdueContests } from "./contest.js";
177
179
  import { isDirectChatProvider, isSubscriptionChatProvider, priceOf, PRICED_MODELS } from "./converse.js";
178
180
  import { resolvePhaseAgent, resolveScopeProfile, resolveScopeChain, resolveRouteCandidates, routeOfTask, INSTALLATION_SCOPE } from "./agentconfig.js";
179
- import { isRiskLevel, legOf, projectRoute, riskConsequence, routeDigestOf, routeWords, RISK_LEVELS, PHASES as ROUTE_PHASES } from "./phase-routing.js";
181
+ import { isRiskLevel, isTaskSize, legOf, projectRoute, riskConsequence, routeDigestOf, routeWords, RISK_LEVELS, TASK_SIZES, PHASES as ROUTE_PHASES } from "./phase-routing.js";
180
182
  import { observeProviderReadiness, reportProviderReadinessAuthed } from "./runner.js";
181
183
  import { parseDemoUrl, projectDemoUrl, saveProjectDemo } from "./project-demo.js";
182
184
  import { effectiveConcurrency, maySlotTake, parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, ProjectPasses, projectConcurrency, saveProjectConcurrency, savedProjectConcurrency } from "./project-concurrency.js";
@@ -423,6 +425,7 @@ Capabilities — what the work needs, recorded and probed, never valued
423
425
  the repo, asks you questions, and
424
426
  proposes a scope you approve
425
427
  toolroll task route <id> [--risk routine|elevated|high]
428
+ [--size small|medium|large] [--risky yes|no]
426
429
  [--phase plan|build|repair --provider <p> [--model <m>] | --clear-phase <phase>]
427
430
  --as <you> --token <t> which agent plans, builds, and repairs
428
431
  this task, with the reason for
@@ -500,6 +503,17 @@ Agents — which provider and model each phase runs on
500
503
  the STRONG agent high-risk, strict,
501
504
  screenshot-proof, and automerge
502
505
  routes reach for; never inferred
506
+ toolroll config set build --tier light --provider <p> --model <m>
507
+ [--repo <path>] --as <you> --token <t>
508
+ the fast agent small changes build
509
+ on, with no plan
510
+ toolroll config set <plan|build> [--tier light|strong] --also
511
+ --provider <p> --model <m> [--repo <path>] --as <you> --token <t>
512
+ another provider's agent on the same
513
+ tier; each task runs the one whose
514
+ plan has more room
515
+ toolroll config clear <plan|build> [--tier light|strong] --also
516
+ [--provider <p>] [--repo <path>] --as <you> --token <t>
503
517
  toolroll config clear <phase> [--repo <path>] --as <you> --token <t>
504
518
 
505
519
  toolroll setup show --repo <path> what a fresh checkout runs first
@@ -632,7 +646,7 @@ export const OPERATE_VALUE_FLAGS = new Set([
632
646
  "provider", "plan-model", "plan-provider", "public-url", "editor",
633
647
  "command", "timeout-seconds", "setup-digest", "stop-grace", "title", "name", "every", "lines",
634
648
  "label", "reviewers", "limit", "role", "key-file", "weekly-usd", "daily-turns", "per-hour", "token-file", "race", "compare", "race-per-usd", "race-total-usd", "race-count", "race-agents", "budget-usd", "build-usd", "sync-max-age", "merge-method",
635
- "phase", "risk", "tier", "clear-phase",
649
+ "phase", "risk", "tier", "clear-phase", "size", "risky",
636
650
  "run", "containment", "agent",
637
651
  // onboard: the starter flows to switch on.
638
652
  "starter",
@@ -664,6 +678,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
664
678
  "revoke",
665
679
  // task merge: merge without waiting for the full check (Quick or Off results).
666
680
  "anyway",
681
+ // config set/clear: another provider's candidate on the same tier.
682
+ "also",
667
683
  ]);
668
684
  export function parseOperateArgs(argv, ownValues = new Set()) {
669
685
  const positional = [];
@@ -799,6 +815,9 @@ export async function runOperate(command, argv, write, options = {}) {
799
815
  }
800
816
  // v105: a Claude turn anywhere in this command says its plan's usage windows; keep the latest.
801
817
  const dropLimitSink = pushLimitSink(reading => store.recordProviderLimits(reading, new Date()));
818
+ // Filed tasks are sized by the owner's own fast classifier (never in tests, the demo, or with NO_TASK_CLASSIFIER).
819
+ const sizer = options.filingSizer ?? (process.env["VITEST"] !== undefined || envValue(process.env, "NO_TASK_CLASSIFIER") !== undefined || store.isDemo() ? null : ownerSizer());
820
+ const dropSizer = sizer === null ? () => { } : installFilingSizer(sizer);
802
821
  // One `Date` per command is fine for a lookup and wrong for a pass that
803
822
  // runs an agent for half an hour: leases granted, extended, and released
804
823
  // with the same stale stamp. Injected time stays frozen — a test's clock
@@ -853,6 +872,9 @@ export async function runOperate(command, argv, write, options = {}) {
853
872
  return fail(write, json, command, "failed", describe(error), EXIT.failed);
854
873
  }
855
874
  finally {
875
+ // A classification still in flight (at most its five-second budget) lands before the database closes.
876
+ await settleSizings();
877
+ dropSizer();
856
878
  dropLimitSink();
857
879
  store.close();
858
880
  }
@@ -5787,8 +5809,11 @@ async function configCommand(positional, flags, context) {
5787
5809
  phase: one,
5788
5810
  strong: candidates.ok ? candidates.candidates[one].strong : null,
5789
5811
  }));
5812
+ const alsoRows = [...store.listPhaseTierAlternates(INSTALLATION_SCOPE), ...(scope === INSTALLATION_SCOPE ? [] : store.listPhaseTierAlternates(scope))];
5813
+ // A light planner from before light became build-only is never used: said, with how to clear it.
5814
+ const unusedLight = [INSTALLATION_SCOPE, ...(scope === INSTALLATION_SCOPE ? [] : [scope])].filter(one => store.phaseTierConfig(one, "plan", "light") !== null);
5790
5815
  if (json) {
5791
- write(envelopeJson({ ok: true, command: "config show", installation, project, resolved, fallback, strong, installationStrong: store.listPhaseTierConfig(INSTALLATION_SCOPE), projectStrong: scope === INSTALLATION_SCOPE ? [] : store.listPhaseTierConfig(scope) }));
5816
+ write(envelopeJson({ ok: true, command: "config show", installation, project, resolved, fallback, strong, installationStrong: store.listPhaseTierConfig(INSTALLATION_SCOPE), projectStrong: scope === INSTALLATION_SCOPE ? [] : store.listPhaseTierConfig(scope), installationAlso: store.listPhaseTierAlternates(INSTALLATION_SCOPE), projectAlso: scope === INSTALLATION_SCOPE ? [] : store.listPhaseTierAlternates(scope) }));
5792
5817
  return EXIT.ok;
5793
5818
  }
5794
5819
  write(`Effective phase agents${scope === INSTALLATION_SCOPE ? "" : ` for ${scope}`}:`);
@@ -5807,6 +5832,22 @@ async function configCommand(positional, flags, context) {
5807
5832
  }
5808
5833
  write(" set one with: toolroll config set <phase> --tier strong --provider <p> --model <m> --as <you> --token <t>");
5809
5834
  write("");
5835
+ write(" light tier (small changes build on this, with no plan):");
5836
+ const light = candidates.ok ? candidates.candidates.build.light ?? null : null;
5837
+ write(` ${"build".padEnd(8)} ${light === null ? "none configured — small changes keep the default above and say so" : `${light.provider} · ${light.model} [${light.source}]`}`);
5838
+ for (const one of unusedLight)
5839
+ write(` ${"plan".padEnd(8)} not used — a small change makes no plan; \`config clear plan --tier light${one === INSTALLATION_SCOPE ? "" : ` --repo ${one}`}\` removes it`);
5840
+ write(" set one with: toolroll config set build --tier light --provider <p> --model <m> --as <you> --token <t>");
5841
+ write("");
5842
+ if (candidates.ok) {
5843
+ const also = ["plan", "build"].flatMap(one => Object.entries(candidates.candidates[one].alternates ?? {}).flatMap(([tier, list]) => (list ?? []).map(spec => ` ${one.padEnd(8)} ${tier.padEnd(8)} also ${spec.provider} · ${spec.model} [${spec.source}]`)));
5844
+ if (also.length > 0 || alsoRows.length > 0) {
5845
+ write(" other providers on a tier (each task runs the one whose plan has more room):");
5846
+ for (const line of also)
5847
+ write(line);
5848
+ write("");
5849
+ }
5850
+ }
5810
5851
  write(" repair note: the repair PROVIDER always inherits the build it mends — only its model is configurable.");
5811
5852
  if (fallback.length > 0) {
5812
5853
  write("");
@@ -6046,11 +6087,57 @@ async function configCommand(positional, flags, context) {
6046
6087
  // reaches for when risk, quality, evidence, or publication demand it.
6047
6088
  // Authenticated and audited exactly like the routine row; existing
6048
6089
  // approvals are untouched — a sealed route never re-resolves.
6090
+ // The LIGHT tier (v2): the fast agent a small change builds on.
6049
6091
  const tierGiven = text(flags, "tier");
6050
- if (tierGiven !== undefined && tierGiven !== "strong") {
6051
- return fail(write, json, `config ${action}`, "usage", "--tier is `strong` — the routine tier is the plain phase row", EXIT.usage);
6092
+ if (tierGiven !== undefined && tierGiven !== "strong" && tierGiven !== "light") {
6093
+ return fail(write, json, `config ${action}`, "usage", "--tier is `light` or `strong` — the routine tier is the plain phase row", EXIT.usage);
6094
+ }
6095
+ // A light planner would never run (a small change makes no plan): refused rather than kept and ignored.
6096
+ // Clearing one left from before stays possible.
6097
+ if (tierGiven === "light" && phase !== "build" && !(action === "clear" && phase === "plan" && !flag(flags, "also"))) {
6098
+ return fail(write, json, `config ${action}`, "usage", "--tier light is for build only — a small change makes no plan, and repairs resume the builder's session", EXIT.usage);
6099
+ }
6100
+ // Another provider's candidate on the same tier (`--also`): one per provider, and at filing the one whose plan
6101
+ // has more room runs.
6102
+ if (flag(flags, "also")) {
6103
+ if (phase !== "plan" && phase !== "build") {
6104
+ return fail(write, json, `config ${action}`, "usage", "--also is for plan or build — repairs follow the build's provider", EXIT.usage);
6105
+ }
6106
+ const tier = (tierGiven ?? "routine");
6107
+ const alsoProvider = text(flags, "provider");
6108
+ if (alsoProvider !== undefined && !isProviderId(alsoProvider)) {
6109
+ return fail(write, json, `config ${action}`, "usage", `--provider is one of ${PROVIDER_IDS.join(", ")}`, EXIT.usage);
6110
+ }
6111
+ if (action === "clear") {
6112
+ const removed = store.clearPhaseTierAlternates(scope, phase, tier, alsoProvider ?? null);
6113
+ return succeed(write, json, "config clear", { scope, phase, tier, also: true, provider: alsoProvider ?? null, cleared: removed }, () => [
6114
+ removed > 0 ? `Removed ${removed} other-provider ${tier} ${phase} agent${removed === 1 ? "" : "s"} at ${scope}; sealed routes are untouched.` : `No other-provider ${tier} ${phase} agent${alsoProvider === undefined ? "" : ` on ${alsoProvider}`} was configured at ${scope}.`,
6115
+ ]);
6116
+ }
6117
+ const alsoModel = text(flags, "model");
6118
+ if (alsoProvider === undefined || alsoModel === undefined) {
6119
+ return fail(write, json, "config set", "usage", "--also names an exact --provider and --model — approvals bind exact routing", EXIT.usage);
6120
+ }
6121
+ const alsoValid = validateSpec({ provider: alsoProvider, model: alsoModel });
6122
+ if (!alsoValid.ok)
6123
+ return fail(write, json, "config set", "invalid", alsoValid.problem, EXIT.usage);
6124
+ store.setPhaseTierAlternate(scope, phase, tier, alsoProvider, alsoModel, acting.name, clock());
6125
+ const resolvedNow = resolveRouteCandidates(store, scope === INSTALLATION_SCOPE ? null : scope);
6126
+ const own = !resolvedNow.ok ? null : tier === "routine" ? resolvedNow.candidates[phase].routine : tier === "light" ? resolvedNow.candidates[phase].light ?? null : resolvedNow.candidates[phase].strong;
6127
+ return succeed(write, json, "config set", { scope, phase, tier, also: true, provider: alsoProvider, model: alsoModel }, () => [
6128
+ `${tier} ${phase} at ${scope === INSTALLATION_SCOPE ? "the installation" : scope} can also run ${alsoProvider} · ${alsoModel}, set by ${acting.name}.`,
6129
+ own !== null && own.provider === alsoProvider && !own.source.includes("also")
6130
+ ? ` ! the ${tier} ${phase} agent already runs ${alsoProvider} (${own.model}) — this one is not used while it does.`
6131
+ : " tasks filed from now on run on whichever provider's plan has more room; sealed routes are untouched.",
6132
+ ]);
6052
6133
  }
6053
6134
  if (action === "clear") {
6135
+ if (tierGiven === "light") {
6136
+ const clearedLight = store.clearPhaseTierConfig(scope, phase, "light");
6137
+ return succeed(write, json, "config clear", { scope, phase, tier: "light", cleared: clearedLight }, () => [
6138
+ clearedLight ? `Cleared the light ${phase} agent at ${scope} — small changes keep the default and say so; sealed routes are untouched.` : `No light ${phase} agent was configured at ${scope}.`,
6139
+ ]);
6140
+ }
6054
6141
  if (tierGiven === "strong") {
6055
6142
  const clearedStrong = store.clearPhaseTierConfig(scope, phase, "strong");
6056
6143
  return succeed(write, json, "config clear", { scope, phase, tier: "strong", cleared: clearedStrong }, () => [
@@ -6071,6 +6158,16 @@ async function configCommand(positional, flags, context) {
6071
6158
  if (!valid.ok) {
6072
6159
  return fail(write, json, "config set", "invalid", valid.problem, EXIT.usage);
6073
6160
  }
6161
+ if (tierGiven === "light") {
6162
+ if (modelGiven === null) {
6163
+ return fail(write, json, "config set", "usage", `a light ${phase} agent names an exact --model — approvals bind exact routing`, EXIT.usage);
6164
+ }
6165
+ store.setPhaseTierConfig(scope, phase, "light", providerGiven, modelGiven, acting.name, clock());
6166
+ return succeed(write, json, "config set", { scope, phase, tier: "light", provider: providerGiven, model: modelGiven }, () => [
6167
+ `light ${phase} at ${scope === INSTALLATION_SCOPE ? "the installation" : scope} is ${providerGiven} · ${modelGiven}, set by ${acting.name}.`,
6168
+ " small changes filed from now on build on it with no plan; sealed routes are untouched.",
6169
+ ]);
6170
+ }
6074
6171
  if (tierGiven === "strong") {
6075
6172
  if (phase === "review" && providerGiven === "gemini") {
6076
6173
  return fail(write, json, "config set", "invalid", "gemini has no isolation posture for the review phase yet — name claude or codex as the strong reviewer", EXIT.usage);
@@ -11013,7 +11110,7 @@ async function syncCommand(flags, context) {
11013
11110
  * separate acts by, usually, separate parties: an agent may draft a scope, and
11014
11111
  * only a person may agree to it.
11015
11112
  */
11016
- function scopeTask(positional, flags, context) {
11113
+ async function scopeTask(positional, flags, context) {
11017
11114
  const { store, write, json, now } = context;
11018
11115
  const id = positional[0];
11019
11116
  const goal = text(flags, "goal");
@@ -11168,6 +11265,27 @@ function scopeTask(positional, flags, context) {
11168
11265
  const actor = asGiven !== undefined && tokenGiven !== undefined && authenticateApprover(store, asGiven, tokenGiven).ok
11169
11266
  ? asGiven
11170
11267
  : null;
11268
+ // Size the new goal before filing its route. A replay must not classify
11269
+ // again or change the task behind the original answer. This probe does
11270
+ // not record a result; the composite filing below still owns the receipt.
11271
+ let refinement = null;
11272
+ store.replay(mutationFrom(flags, now), "task-scope-filed", () => {
11273
+ const ref = store.lookupRef(id);
11274
+ const previous = store.getScope(id);
11275
+ if (ref === null || ref.deliverable === "report" || candidateGiven !== undefined ||
11276
+ ref.sizing?.source === "person" || approvalOf(previous).approved ||
11277
+ previous?.proposedVia === "coordinator" || store.filedViaOf(id)?.startsWith("mcp:") ||
11278
+ ((plannedRace !== null || plannedComparison !== null) && store.openAuthorizationFor(ref.id) !== null))
11279
+ return null;
11280
+ const input = { title: store.getTask(id).title, goal, outOfScope: text(flags, "not") ?? null, touches };
11281
+ const sized = store.applySizing(id, heuristicSizing(input), { followPlanning: true }, now);
11282
+ if (sized.ok)
11283
+ refinement = refineFiledSizing(store, id, input, true, context.clock);
11284
+ return null;
11285
+ }, () => false);
11286
+ // Wait for bounded classification before filing so any approval in the
11287
+ // transaction below binds the final size and route.
11288
+ await refinement;
11171
11289
  // ONE replayed composite (surfaces round 1, finding 3): the filing, any
11172
11290
  // race/comparison terms, AND the mode seal record as a single operation —
11173
11291
  // a replayed key returns the FIRST answer whole instead of re-sealing
@@ -11348,7 +11466,9 @@ async function routeTaskCommand(positional, flags, context) {
11348
11466
  const providerGiven = text(flags, "provider");
11349
11467
  const modelGiven = text(flags, "model");
11350
11468
  const digestGiven = text(flags, "digest");
11351
- const editing = riskGiven !== undefined || phaseGiven !== undefined || clearGiven !== undefined;
11469
+ const sizeGiven = text(flags, "size");
11470
+ const riskyGiven = text(flags, "risky");
11471
+ const editing = riskGiven !== undefined || phaseGiven !== undefined || clearGiven !== undefined || sizeGiven !== undefined || riskyGiven !== undefined;
11352
11472
  const show = () => {
11353
11473
  const scope = store.getScope(id);
11354
11474
  const current = store.refFor(BUILT_IN, id);
@@ -11356,7 +11476,7 @@ async function routeTaskCommand(positional, flags, context) {
11356
11476
  const readiness = store.readinessLookupFor(ref.repo, ref.assignedRunner, clock());
11357
11477
  const view = taskRouteView(routed, readiness);
11358
11478
  const projection = routed !== null && routed.kind === "route" ? projectRoute(routed.route, readiness) : null;
11359
- return succeed(write, json, "task route", { id, risk: current.riskLevel ?? scope?.riskLevel ?? "routine", riskConsequence: riskConsequence(current.riskLevel ?? scope?.riskLevel ?? "routine"), source: routed !== null && routed.kind === "route" ? routed.source : routed?.kind ?? null, route: view, overrides: current.routeOverrides ?? [], digest: scope?.digest ?? null, approval: approvalOf(scope) }, () => routed === null
11479
+ return succeed(write, json, "task route", { id, risk: current.riskLevel ?? scope?.riskLevel ?? "routine", riskConsequence: riskConsequence(current.riskLevel ?? scope?.riskLevel ?? "routine"), size: current.sizing ?? null, source: routed !== null && routed.kind === "route" ? routed.source : routed?.kind ?? null, route: view, overrides: current.routeOverrides ?? [], digest: scope?.digest ?? null, approval: approvalOf(scope) }, () => routed === null
11360
11480
  ? [`${id}: no route can be recommended yet — ${scope === null ? "place the task in a repository and file a scope" : scope.unresolvedReason ?? "the phase configuration cannot resolve"}`]
11361
11481
  : [
11362
11482
  ...(projection === null
@@ -11393,6 +11513,17 @@ async function routeTaskCommand(positional, flags, context) {
11393
11513
  if (riskGiven !== undefined && !isRiskLevel(riskGiven)) {
11394
11514
  return fail(write, json, "task route", "usage", `--risk is one of ${RISK_LEVELS.join(", ")}`, EXIT.usage);
11395
11515
  }
11516
+ if (sizeGiven !== undefined && !isTaskSize(sizeGiven)) {
11517
+ return fail(write, json, "task route", "usage", `--size is one of ${TASK_SIZES.join(", ")}`, EXIT.usage);
11518
+ }
11519
+ if (riskyGiven !== undefined && riskyGiven !== "yes" && riskyGiven !== "no") {
11520
+ return fail(write, json, "task route", "usage", "--risky is yes or no", EXIT.usage);
11521
+ }
11522
+ // A person's size outranks the classifier's; an unnamed half keeps what the task has.
11523
+ const sizeEdit = sizeGiven === undefined && riskyGiven === undefined ? undefined : {
11524
+ size: sizeGiven ?? ref.sizing?.size ?? "medium",
11525
+ risky: riskyGiven === undefined ? ref.sizing?.risky ?? false : riskyGiven === "yes",
11526
+ };
11396
11527
  if (phaseGiven !== undefined && clearGiven !== undefined) {
11397
11528
  return fail(write, json, "task route", "usage", "say --phase … --provider … --model … OR --clear-phase …, not both", EXIT.usage);
11398
11529
  }
@@ -11420,6 +11551,7 @@ async function routeTaskCommand(positional, flags, context) {
11420
11551
  return authenticated.ok ? { ok: true } : { ok: false, reason: authenticated.reason };
11421
11552
  },
11422
11553
  ...(riskGiven === undefined ? {} : { risk: riskGiven }),
11554
+ ...(sizeEdit === undefined ? {} : { size: sizeEdit }),
11423
11555
  ...(phase === undefined
11424
11556
  ? {}
11425
11557
  : { override: phaseGiven !== undefined ? { phase: phase, provider: providerGiven, model: modelGiven } : { phase: phase, clear: true } }),
@@ -12215,11 +12347,14 @@ function spendCommand(flags, context) {
12215
12347
  // v105: subscription work is $0; what binds it is its plan's windows, as the provider last said.
12216
12348
  const limits = context.store.providerLimits();
12217
12349
  const windows = limitsView(limits, [], { project: repo => basename(repo), teammate: id => String(id) }, context.clock())?.tiles ?? [];
12218
- return succeed(context.write, context.json, command, { month: month.name, totalMicrousd: total, unpriced, items: items.length, budgets, limits }, () => [
12350
+ // Sized routing: each tier's tasks, time to a result and plan use, and what the tiers saved.
12351
+ const tiers = tierReport(context.store.handle, month.from, month.to, items);
12352
+ return succeed(context.write, context.json, command, { month: month.name, totalMicrousd: total, unpriced, items: items.length, budgets, limits, tiers: { rows: tiers.rows, saved: tiers.saved } }, () => [
12219
12353
  `${month.name}: ${spendUsd(total)} (${items.length} pieces of work${unpriced > 0 ? `, ${unpriced} unpriced` : ""}; subscription work is $0)`,
12220
12354
  ...windows.map(tile => ` ${`${tile.name} ${tile.window}`.padEnd(24)} ${tile.value}% · ${tile.detail}`),
12221
12355
  ...[...byProject.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([repo, microusd]) => ` ${basename(repo).padEnd(24)} ${spendUsd(microusd)}`),
12222
12356
  ...budgets.map(one => ` budget ${budgetLabel(one, names.get(Number(one.key))).replace(/'s$/, "").padEnd(24)} ${spendUsd(one.spentMicrousd)} of ${spendUsd(one.limitMicrousd)} (${one.percent}%)${one.hardStop ? "" : " alerts only"}`),
12357
+ ...tierLines(tiers, spendUsd),
12223
12358
  ]);
12224
12359
  }
12225
12360
  /** `backup now | list` (sprint 8): back the database up now, or list backups and how the latest ones went. */