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
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)
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 { installFilingSizer, ownerSizer, 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);
@@ -11348,7 +11445,9 @@ async function routeTaskCommand(positional, flags, context) {
11348
11445
  const providerGiven = text(flags, "provider");
11349
11446
  const modelGiven = text(flags, "model");
11350
11447
  const digestGiven = text(flags, "digest");
11351
- const editing = riskGiven !== undefined || phaseGiven !== undefined || clearGiven !== undefined;
11448
+ const sizeGiven = text(flags, "size");
11449
+ const riskyGiven = text(flags, "risky");
11450
+ const editing = riskGiven !== undefined || phaseGiven !== undefined || clearGiven !== undefined || sizeGiven !== undefined || riskyGiven !== undefined;
11352
11451
  const show = () => {
11353
11452
  const scope = store.getScope(id);
11354
11453
  const current = store.refFor(BUILT_IN, id);
@@ -11356,7 +11455,7 @@ async function routeTaskCommand(positional, flags, context) {
11356
11455
  const readiness = store.readinessLookupFor(ref.repo, ref.assignedRunner, clock());
11357
11456
  const view = taskRouteView(routed, readiness);
11358
11457
  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
11458
+ 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
11459
  ? [`${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
11460
  : [
11362
11461
  ...(projection === null
@@ -11393,6 +11492,17 @@ async function routeTaskCommand(positional, flags, context) {
11393
11492
  if (riskGiven !== undefined && !isRiskLevel(riskGiven)) {
11394
11493
  return fail(write, json, "task route", "usage", `--risk is one of ${RISK_LEVELS.join(", ")}`, EXIT.usage);
11395
11494
  }
11495
+ if (sizeGiven !== undefined && !isTaskSize(sizeGiven)) {
11496
+ return fail(write, json, "task route", "usage", `--size is one of ${TASK_SIZES.join(", ")}`, EXIT.usage);
11497
+ }
11498
+ if (riskyGiven !== undefined && riskyGiven !== "yes" && riskyGiven !== "no") {
11499
+ return fail(write, json, "task route", "usage", "--risky is yes or no", EXIT.usage);
11500
+ }
11501
+ // A person's size outranks the classifier's; an unnamed half keeps what the task has.
11502
+ const sizeEdit = sizeGiven === undefined && riskyGiven === undefined ? undefined : {
11503
+ size: sizeGiven ?? ref.sizing?.size ?? "medium",
11504
+ risky: riskyGiven === undefined ? ref.sizing?.risky ?? false : riskyGiven === "yes",
11505
+ };
11396
11506
  if (phaseGiven !== undefined && clearGiven !== undefined) {
11397
11507
  return fail(write, json, "task route", "usage", "say --phase … --provider … --model … OR --clear-phase …, not both", EXIT.usage);
11398
11508
  }
@@ -11420,6 +11530,7 @@ async function routeTaskCommand(positional, flags, context) {
11420
11530
  return authenticated.ok ? { ok: true } : { ok: false, reason: authenticated.reason };
11421
11531
  },
11422
11532
  ...(riskGiven === undefined ? {} : { risk: riskGiven }),
11533
+ ...(sizeEdit === undefined ? {} : { size: sizeEdit }),
11423
11534
  ...(phase === undefined
11424
11535
  ? {}
11425
11536
  : { override: phaseGiven !== undefined ? { phase: phase, provider: providerGiven, model: modelGiven } : { phase: phase, clear: true } }),
@@ -12215,11 +12326,14 @@ function spendCommand(flags, context) {
12215
12326
  // v105: subscription work is $0; what binds it is its plan's windows, as the provider last said.
12216
12327
  const limits = context.store.providerLimits();
12217
12328
  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 }, () => [
12329
+ // Sized routing: each tier's tasks, time to a result and plan use, and what the tiers saved.
12330
+ const tiers = tierReport(context.store.handle, month.from, month.to, items);
12331
+ 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
12332
  `${month.name}: ${spendUsd(total)} (${items.length} pieces of work${unpriced > 0 ? `, ${unpriced} unpriced` : ""}; subscription work is $0)`,
12220
12333
  ...windows.map(tile => ` ${`${tile.name} ${tile.window}`.padEnd(24)} ${tile.value}% · ${tile.detail}`),
12221
12334
  ...[...byProject.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([repo, microusd]) => ` ${basename(repo).padEnd(24)} ${spendUsd(microusd)}`),
12222
12335
  ...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"}`),
12336
+ ...tierLines(tiers, spendUsd),
12223
12337
  ]);
12224
12338
  }
12225
12339
  /** `backup now | list` (sprint 8): back the database up now, or list backups and how the latest ones went. */
@@ -56,11 +56,16 @@ export declare const MODEL_ID_SHAPE: RegExp;
56
56
  /** The same exactness for callers outside this policy (the profile
57
57
  * rehydrator): one shape, one definition. */
58
58
  export declare function exactModelId(v: unknown): v is string;
59
- export declare const ROUTE_VERSION = 1;
59
+ /** v2 adds the task's size (small / medium / large, risky or not), the
60
+ * light tier, and plan headroom. A v1 route — sealed before sizing — still
61
+ * reads back exactly and keeps its own digest domain. */
62
+ export declare const ROUTE_VERSION = 2;
63
+ export type RouteVersion = 1 | 2;
60
64
  /** The durable route ERA a scope row carries once it was filed under this
61
65
  * policy: NULL on a row proven to predate v47 (legacy — the sealed profile
62
66
  * alone governs), this value on every row filed since. A row with an era
63
- * and no readable route is corrupt and fails closed. */
67
+ * and no readable route is corrupt and fails closed. The era marks a ROUTED
68
+ * row; which route version it holds rides in the snapshot itself. */
64
69
  export declare const ROUTE_ERA = 1;
65
70
  export declare const PHASES: readonly Phase[];
66
71
  /** The review leg remains readable in signed history but is no longer scheduled. */
@@ -68,6 +73,25 @@ export declare const ACTIVE_PHASES: readonly Phase[];
68
73
  export type RiskLevel = "routine" | "elevated" | "high";
69
74
  export declare const RISK_LEVELS: readonly RiskLevel[];
70
75
  export declare function isRiskLevel(value: unknown): value is RiskLevel;
76
+ /** How big a change the task is, sized once at filing (by the classifier,
77
+ * by the description when it cannot answer, or by a person). */
78
+ export type TaskSize = "small" | "medium" | "large";
79
+ export declare const TASK_SIZES: readonly TaskSize[];
80
+ export type SizeSource = "classifier" | "heuristic" | "person";
81
+ export type TaskSizing = {
82
+ size: TaskSize;
83
+ risky: boolean;
84
+ source: SizeSource;
85
+ reason: string;
86
+ };
87
+ export declare function isTaskSize(value: unknown): value is TaskSize;
88
+ /** Whether a task makes no plan: a small change that is not risky, at
89
+ * routine risk. Elevated or high risk always plans, whatever the size. */
90
+ export declare function makesNoPlan(size: TaskSizing | null | undefined, risk: RiskLevel): boolean;
91
+ /** Where a size came from, in words. */
92
+ export declare function sizeSourceWords(source: SizeSource): string;
93
+ /** Strict read of a stored sizing: exact keys, known words, bounded reason. */
94
+ export declare function parseSizing(raw: unknown): TaskSizing | null;
71
95
  export declare function riskTitle(risk: RiskLevel): string;
72
96
  /**
73
97
  * What declaring each risk level DOES, in one plain sentence — derived
@@ -95,7 +119,7 @@ export type ReadinessObservation = {
95
119
  observedAt: string;
96
120
  runner: string;
97
121
  };
98
- export type CandidateTier = "routine" | "strong";
122
+ export type CandidateTier = "light" | "routine" | "strong";
99
123
  /** An exact agent: provider and model id, both required. */
100
124
  export type ExactSpec = {
101
125
  provider: ProviderId;
@@ -106,10 +130,21 @@ export type RouteCandidate = ExactSpec & {
106
130
  source: string;
107
131
  };
108
132
  export type PhaseCandidates = {
133
+ /** The operator's named LIGHT (fast) builder (`config set build --tier
134
+ * light …`), or null/absent when none is configured. A small change
135
+ * builds on it. Never inferred; the plan phase has none (a small change
136
+ * makes no plan). */
137
+ light?: RouteCandidate | null;
109
138
  routine: RouteCandidate;
110
139
  /** The operator's named strong agent for this phase, or null when none
111
140
  * is configured. Never inferred. */
112
141
  strong: RouteCandidate | null;
142
+ /** v2: a tier may hold one more candidate per OTHER provider (`config
143
+ * set <phase> [--tier …] --also --provider … --model …`) — e.g. a
144
+ * routine builder on claude and on codex. At filing the one whose
145
+ * provider has more plan room runs. Each list holds providers other
146
+ * than its tier's own candidate, at most one each. */
147
+ alternates?: Partial<Record<CandidateTier, readonly RouteCandidate[]>>;
113
148
  };
114
149
  /** The repair phase's candidates: with no repair row configured the
115
150
  * routine candidate is null and the leg INHERITS the build leg's exact
@@ -138,6 +173,30 @@ export type RouteEvidenceKind = "check" | "screenshot" | "changed-path" | "manua
138
173
  * grant at all, a grant whose merges wait for a human (notify), or a live
139
174
  * mode that merges by itself (automerge). */
140
175
  export type PublicationAuthority = "none" | "notify" | "automerge";
176
+ /** How much of one provider's plan is used, as it last said: the 5-hour
177
+ * window and the weekly window, in percent; null when unknown. */
178
+ export type ProviderRoom = {
179
+ provider: ProviderId;
180
+ fiveHour: number | null;
181
+ weekly: number | null;
182
+ };
183
+ /** Above these a provider has no headroom: even a candidate one tier
184
+ * stronger on another provider takes the phase. Below them, a tier's
185
+ * candidates on different providers still go to the one with more room. */
186
+ export declare const HEADROOM_FIVE_HOUR_LIMIT = 80;
187
+ export declare const HEADROOM_WEEKLY_LIMIT = 90;
188
+ /** The latest plan-window readings as headroom: a window whose reset time
189
+ * has passed counts as empty; a reading older than its own window is
190
+ * unknown. Weekly is the plan-wide week (`seven_day`), else the fullest
191
+ * model-specific week. */
192
+ export declare function headroomFrom(limits: readonly {
193
+ provider: string;
194
+ window: string;
195
+ usedPercent: number;
196
+ windowMinutes: number | null;
197
+ resetsAt: string | null;
198
+ observedAt: string;
199
+ }[], now: Date): ProviderRoom[];
141
200
  export type RouteInput = {
142
201
  risk: RiskLevel;
143
202
  qualityMode: QualityMode;
@@ -153,6 +212,13 @@ export type RouteInput = {
153
212
  build?: ExactSpec | null;
154
213
  repair?: ExactSpec | null;
155
214
  };
215
+ /** How big the change is (v2): small builds on the light tier with no
216
+ * plan; large or risky plans and builds on the strong tier. */
217
+ size?: TaskSizing | null;
218
+ /** Each provider's plan headroom at filing (v2): when a tier has
219
+ * candidates on more than one provider, the one with more room runs; a
220
+ * provider past its limits gives way even to a stronger tier. */
221
+ headroom?: readonly ProviderRoom[];
156
222
  };
157
223
  export type RouteLeg = {
158
224
  phase: Phase;
@@ -178,7 +244,7 @@ export type RouteLeg = {
178
244
  problem: string | null;
179
245
  };
180
246
  export type PhaseRoute = {
181
- version: typeof ROUTE_VERSION;
247
+ version: RouteVersion;
182
248
  risk: RiskLevel;
183
249
  qualityMode: QualityMode;
184
250
  publication: PublicationAuthority;
@@ -192,6 +258,9 @@ export type PhaseRoute = {
192
258
  demands: string[];
193
259
  legs: RouteLeg[];
194
260
  overrides: RouteOverride[];
261
+ /** The task's size as it was routed (v2); null on a v1 route or an
262
+ * unsized task. */
263
+ size: TaskSizing | null;
195
264
  };
196
265
  export declare function specWords(spec: {
197
266
  provider: string;
@@ -259,9 +328,18 @@ export type RouteProjection = {
259
328
  halted: boolean;
260
329
  /** Every stated leg problem, phase-prefixed. */
261
330
  problems: string[];
331
+ /** The size the route was made for (v2), or null. */
332
+ size: TaskSizing | null;
333
+ /** The plain line an approval card leads with — "Small change: fast
334
+ * model, no plan" — or null on an unsized route. */
335
+ sizeWords: string | null;
336
+ /** Why it was sized so, and by whom, or null. */
337
+ sizeReason: string | null;
262
338
  };
263
339
  /** What actually runs: stronger agents only when a leg draws from one. */
264
340
  export declare function postureWords(route: Pick<PhaseRoute, "legs">): string;
341
+ /** The size line, plainly: what the size does to this route. */
342
+ export declare function sizeWords(route: Pick<PhaseRoute, "legs" | "size" | "risk">): string | null;
265
343
  export declare function readinessWords(state: ReadinessState, reason: string | null): string;
266
344
  export declare function chosenWords(leg: Pick<RouteLeg, "chosen" | "tier">): string;
267
345
  /**
@@ -270,7 +348,10 @@ export declare function chosenWords(leg: Pick<RouteLeg, "chosen" | "tier">): str
270
348
  * plans, builds, and repairs; claude · opus reviews" — with the posture
271
349
  * said honestly (stronger agents only when one is actually selected).
272
350
  */
273
- export declare function agentsSummary(route: Pick<PhaseRoute, "legs">): string;
351
+ export declare function agentsSummary(route: Pick<PhaseRoute, "legs"> & {
352
+ size?: TaskSizing | null;
353
+ risk?: RiskLevel;
354
+ }): string;
274
355
  export declare function legLine(leg: RouteLegProjection): string;
275
356
  /** The one projection CLI, task page, and chat all render from. */
276
357
  export declare function projectRoute(route: PhaseRoute, readiness: ReadinessLookup): RouteProjection;