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
@@ -67,12 +67,16 @@ function exactModel(v) {
67
67
  export function exactModelId(v) {
68
68
  return exactModel(v);
69
69
  }
70
- export const ROUTE_VERSION = 1;
70
+ /** v2 adds the task's size (small / medium / large, risky or not), the
71
+ * light tier, and plan headroom. A v1 route — sealed before sizing — still
72
+ * reads back exactly and keeps its own digest domain. */
73
+ export const ROUTE_VERSION = 2;
71
74
  /** The durable route ERA a scope row carries once it was filed under this
72
75
  * policy: NULL on a row proven to predate v47 (legacy — the sealed profile
73
76
  * alone governs), this value on every row filed since. A row with an era
74
- * and no readable route is corrupt and fails closed. */
75
- export const ROUTE_ERA = ROUTE_VERSION;
77
+ * and no readable route is corrupt and fails closed. The era marks a ROUTED
78
+ * row; which route version it holds rides in the snapshot itself. */
79
+ export const ROUTE_ERA = 1;
76
80
  export const PHASES = ["plan", "build", "repair", "review"];
77
81
  /** The review leg remains readable in signed history but is no longer scheduled. */
78
82
  export const ACTIVE_PHASES = ["plan", "build", "repair"];
@@ -80,6 +84,34 @@ export const RISK_LEVELS = ["routine", "elevated", "high"];
80
84
  export function isRiskLevel(value) {
81
85
  return value === "routine" || value === "elevated" || value === "high";
82
86
  }
87
+ export const TASK_SIZES = ["small", "medium", "large"];
88
+ export function isTaskSize(value) {
89
+ return value === "small" || value === "medium" || value === "large";
90
+ }
91
+ /** Whether a task makes no plan: a small change that is not risky, at
92
+ * routine risk. Elevated or high risk always plans, whatever the size. */
93
+ export function makesNoPlan(size, risk) {
94
+ return size != null && size.size === "small" && !size.risky && risk === "routine";
95
+ }
96
+ /** Where a size came from, in words. */
97
+ export function sizeSourceWords(source) {
98
+ return source === "classifier" ? "sized automatically" : source === "person" ? "set by a person" : "sized from the description";
99
+ }
100
+ /** Strict read of a stored sizing: exact keys, known words, bounded reason. */
101
+ export function parseSizing(raw) {
102
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
103
+ return null;
104
+ const v = raw;
105
+ if (!exactKeysOf(v, ["size", "risky", "source", "reason"]))
106
+ return null;
107
+ if (!isTaskSize(v["size"]) || typeof v["risky"] !== "boolean")
108
+ return null;
109
+ if (v["source"] !== "classifier" && v["source"] !== "heuristic" && v["source"] !== "person")
110
+ return null;
111
+ if (typeof v["reason"] !== "string" || v["reason"].length > 300)
112
+ return null;
113
+ return { size: v["size"], risky: v["risky"], source: v["source"], reason: v["reason"] };
114
+ }
83
115
  export function riskTitle(risk) {
84
116
  return risk === "high" ? "High risk" : risk === "elevated" ? "Elevated risk" : "Routine";
85
117
  }
@@ -93,10 +125,59 @@ export function riskConsequence(risk) {
93
125
  if (risk === "high")
94
126
  return "every active role — planner, builder, and repair — uses the strongest agent you have configured";
95
127
  if (risk === "elevated")
96
- return "planning and building keep the everyday agents unless the work itself asks for more (strict quality or screenshots)";
128
+ return "planning and building use the strongest agent you have configured";
97
129
  return "every role uses the everyday configured agent unless the work itself asks for more (strict quality, screenshots, or a self-merging mode)";
98
130
  }
99
131
  export const RISK_CHOICES = RISK_LEVELS.map(risk => ({ risk, title: riskTitle(risk), consequence: riskConsequence(risk) }));
132
+ const TIER_RANK = { light: 0, routine: 1, strong: 2 };
133
+ /** Above these a provider has no headroom: even a candidate one tier
134
+ * stronger on another provider takes the phase. Below them, a tier's
135
+ * candidates on different providers still go to the one with more room. */
136
+ export const HEADROOM_FIVE_HOUR_LIMIT = 80;
137
+ export const HEADROOM_WEEKLY_LIMIT = 90;
138
+ /** The latest plan-window readings as headroom: a window whose reset time
139
+ * has passed counts as empty; a reading older than its own window is
140
+ * unknown. Weekly is the plan-wide week (`seven_day`), else the fullest
141
+ * model-specific week. */
142
+ export function headroomFrom(limits, now) {
143
+ const out = new Map();
144
+ const value = (one) => {
145
+ if (one.resetsAt !== null && Date.parse(one.resetsAt) <= now.getTime())
146
+ return 0;
147
+ const age = now.getTime() - Date.parse(one.observedAt);
148
+ if (!Number.isFinite(age) || (one.windowMinutes !== null && age > one.windowMinutes * 60_000))
149
+ return null;
150
+ return one.usedPercent;
151
+ };
152
+ for (const one of limits) {
153
+ if (!isProviderId(one.provider))
154
+ continue;
155
+ const room = out.get(one.provider) ?? { provider: one.provider, fiveHour: null, weekly: null };
156
+ const used = value(one);
157
+ if (one.window === "five_hour")
158
+ room.fiveHour = used;
159
+ else if (one.window === "seven_day")
160
+ room.weekly = used;
161
+ else if (/^seven_day_/.test(one.window) && used !== null && room.weekly === null)
162
+ room.weekly = used;
163
+ out.set(one.provider, room);
164
+ }
165
+ // A plan-wide week beats a model's week: re-read seven_day last.
166
+ for (const one of limits)
167
+ if (isProviderId(one.provider) && one.window === "seven_day")
168
+ out.get(one.provider).weekly = value(one);
169
+ return [...out.values()].sort((a, b) => PROVIDER_ID_LIST.indexOf(a.provider) - PROVIDER_ID_LIST.indexOf(b.provider));
170
+ }
171
+ function tightWords(room) {
172
+ if (room.fiveHour !== null && room.fiveHour > HEADROOM_FIVE_HOUR_LIMIT)
173
+ return `${room.provider} has used ${room.fiveHour}% of its 5-hour window`;
174
+ if (room.weekly !== null && room.weekly > HEADROOM_WEEKLY_LIMIT)
175
+ return `${room.provider} has used ${room.weekly}% of its weekly window`;
176
+ return null;
177
+ }
178
+ function usedOf(room) {
179
+ return Math.max(room.fiveHour ?? 0, room.weekly ?? 0);
180
+ }
100
181
  const PHASE_NOUN = { plan: "planner", build: "builder", repair: "repair", review: "reviewer" };
101
182
  const PHASE_VERB = { plan: "plans", build: "builds", repair: "repairs", review: "reviews" };
102
183
  export function specWords(spec) {
@@ -110,8 +191,18 @@ const DEMANDS = [
110
191
  },
111
192
  {
112
193
  when: input => input.risk === "elevated",
113
- phases: ["review"],
114
- reason: "risk is elevated — the review runs on the strongest configured reviewer",
194
+ phases: ["plan", "build", "repair", "review"],
195
+ reason: "risk is elevated — planning and building use the strongest configured agent",
196
+ },
197
+ {
198
+ when: input => input.size?.size === "large",
199
+ phases: ["plan", "build", "repair"],
200
+ reason: "a large change — planning and building use the strongest configured agent",
201
+ },
202
+ {
203
+ when: input => input.size?.risky === true,
204
+ phases: ["plan", "build", "repair"],
205
+ reason: "a risky change — planning and building use the strongest configured agent",
115
206
  },
116
207
  {
117
208
  when: input => input.qualityMode === "strict",
@@ -164,9 +255,24 @@ export function recommendRoute(input) {
164
255
  .sort((a, b) => PHASES.indexOf(a.phase) - PHASES.indexOf(b.phase));
165
256
  const overrideFor = (phase) => overrides.find(one => one.phase === phase) ?? null;
166
257
  const legs = [];
258
+ // Elevated or high risk lifts both legs through the table, so it never reaches the small branches.
259
+ const small = input.size?.size === "small";
167
260
  const pick = (phase) => {
261
+ const chosen = pickTier(phase);
262
+ return phase === "review" ? { ...chosen, moved: false } : withHeadroom(input, phase, chosen);
263
+ };
264
+ const pickTier = (phase) => {
168
265
  const demanded = demandedTier(input, phase);
169
266
  const candidates = input.candidates[phase];
267
+ if (demanded.tier === "routine" && small && phase === "build") {
268
+ const light = candidates.light ?? null;
269
+ if (light !== null)
270
+ return { spec: light, tier: "light", reasons: ["small change — a fast model builds it and no plan is made", `light builder from ${light.source}`] };
271
+ return { spec: candidates.routine, tier: "routine", reasons: ["small change — no plan is made; no light builder is configured, so the configured builder runs it", `configured builder from ${candidates.routine.source}`] };
272
+ }
273
+ if (demanded.tier === "routine" && small && phase === "plan") {
274
+ return { spec: candidates.routine, tier: "routine", reasons: ["small change — no plan is made", `configured planner from ${candidates.routine.source}`] };
275
+ }
170
276
  if (demanded.tier === "strong") {
171
277
  if (candidates.strong !== null) {
172
278
  return { spec: candidates.strong, tier: "strong", reasons: [...demanded.reasons, `strong ${PHASE_NOUN[phase]} from ${candidates.strong.source}`] };
@@ -199,11 +305,15 @@ export function recommendRoute(input) {
199
305
  }
200
306
  }
201
307
  // build — pin > override > recommendation.
308
+ let buildMoved = false;
202
309
  const build = (() => {
203
310
  const rec = pick("build");
311
+ buildMoved = rec.moved;
204
312
  const pin = input.pins?.build ?? null;
205
313
  const override = overrideFor("build");
206
314
  const recommended = { provider: rec.spec.provider, model: rec.spec.model, tier: rec.tier };
315
+ if (pin !== null || override !== null)
316
+ buildMoved = false;
207
317
  const leg = pin !== null
208
318
  ? { phase: "build", provider: pin.provider, model: pin.model, tier: rec.tier, chosen: "pinned", recommended, reasons: [`pinned to ${specWords(pin)} by the firing that filed this task — nothing overrides a pin`], problem: null }
209
319
  : override !== null
@@ -225,7 +335,15 @@ export function recommendRoute(input) {
225
335
  let tier;
226
336
  let reasons;
227
337
  let problem = null;
228
- if (demanded.tier === "strong" && candidates.strong !== null) {
338
+ // A build moved to another provider for headroom takes its repairs
339
+ // with it: repair rows on the provider it left cannot resume its session.
340
+ const usedRow = demanded.tier === "strong" && candidates.strong !== null ? candidates.strong : candidates.routine;
341
+ if (buildMoved && usedRow !== null && usedRow.provider !== build.provider) {
342
+ model = build.model;
343
+ tier = "routine";
344
+ reasons = [`the build moved to ${build.provider} for plan headroom — repairs resume its session with the build model (${build.model})`];
345
+ }
346
+ else if (demanded.tier === "strong" && candidates.strong !== null) {
229
347
  if (candidates.strong.provider === build.provider) {
230
348
  model = candidates.strong.model;
231
349
  tier = "strong";
@@ -311,6 +429,64 @@ export function recommendRoute(input) {
311
429
  demands,
312
430
  legs,
313
431
  overrides,
432
+ size: input.size ?? null,
433
+ };
434
+ }
435
+ /**
436
+ * PLAN HEADROOM (v2), at filing:
437
+ *
438
+ * - a tier with candidates on more than one provider (`--also`) runs on
439
+ * the provider with more plan room — the lower window use — so work
440
+ * spreads across plans before either fills;
441
+ * - a chosen provider past its limits (80% of the 5-hour window, 90% of
442
+ * the weekly) gives way to the roomiest other provider at the same
443
+ * tier or one tier stronger, never weaker.
444
+ *
445
+ * Readings are the providers' own; a provider with no reading is unknown,
446
+ * never "empty", so nothing moves toward or away from it on a guess. Ties
447
+ * keep the tier's own candidate. The choice and its reason are sealed with
448
+ * the route.
449
+ */
450
+ function withHeadroom(input, phase, chosen) {
451
+ const rooms = input.headroom ?? [];
452
+ const roomOf = (provider) => {
453
+ const room = rooms.find(one => one.provider === provider) ?? null;
454
+ return room !== null && (room.fiveHour !== null || room.weekly !== null) ? room : null;
455
+ };
456
+ const current = roomOf(chosen.spec.provider);
457
+ if (current === null)
458
+ return { ...chosen, moved: false };
459
+ const tight = tightWords(current);
460
+ const candidates = input.candidates[phase];
461
+ const pool = [];
462
+ for (const tier of ["light", "routine", "strong"]) {
463
+ const own = tier === "light" ? candidates.light ?? null : tier === "routine" ? candidates.routine : candidates.strong;
464
+ for (const spec of [own, ...(candidates.alternates?.[tier] ?? [])]) {
465
+ if (spec === null || spec.provider === chosen.spec.provider || pool.some(one => one.spec.provider === spec.provider && one.tier === tier))
466
+ continue;
467
+ // Not tight: only the same tier competes on room. Tight: one tier stronger may take it too.
468
+ if (tight === null ? tier !== chosen.tier : TIER_RANK[tier] < TIER_RANK[chosen.tier] || TIER_RANK[tier] > TIER_RANK[chosen.tier] + 1)
469
+ continue;
470
+ pool.push({ spec, tier });
471
+ }
472
+ }
473
+ if (pool.length === 0)
474
+ return { ...chosen, moved: false };
475
+ const open = pool
476
+ .map(one => ({ ...one, room: roomOf(one.spec.provider) }))
477
+ .filter((one) => one.room !== null && tightWords(one.room) === null)
478
+ .filter(one => tight !== null || usedOf(one.room) < usedOf(current))
479
+ .sort((a, b) => usedOf(a.room) - usedOf(b.room) || TIER_RANK[a.tier] - TIER_RANK[b.tier]);
480
+ const best = open[0];
481
+ if (best === undefined) {
482
+ return tight === null ? { ...chosen, moved: false } : { ...chosen, reasons: [...chosen.reasons, `plan headroom: ${tight}, and no other configured ${PHASE_NOUN[phase]} has room — kept`], moved: false };
483
+ }
484
+ const why = tight ?? `${chosen.spec.provider} has used ${usedOf(current)}% of its plan`;
485
+ return {
486
+ spec: best.spec,
487
+ tier: best.tier,
488
+ reasons: [...chosen.reasons.slice(0, -1), `plan headroom: ${why} — ${best.spec.provider} has more room (${usedOf(best.room)}% used), so its ${best.tier} ${PHASE_NOUN[phase]} from ${best.spec.source} runs it`],
489
+ moved: true,
314
490
  };
315
491
  }
316
492
  /** Whether any leg actually runs a configured strong agent. */
@@ -367,6 +543,8 @@ export function canonicalRouteJson(route) {
367
543
  problem: leg.problem,
368
544
  })),
369
545
  overrides: route.overrides.map(one => ({ phase: one.phase, provider: one.provider, model: one.model, by: one.by, at: one.at })),
546
+ // v2 carries the size it was routed for; a v1 snapshot's bytes never change.
547
+ ...(route.version === 1 ? {} : { size: route.size === null ? null : { size: route.size.size, risky: route.size.risky, source: route.size.source, reason: route.size.reason } }),
370
548
  });
371
549
  }
372
550
  /** sha256 over a domain-separated canonical encoding, truncated to the
@@ -374,7 +552,7 @@ export function canonicalRouteJson(route) {
374
552
  * route digest can never collide with a profile or chain digest. */
375
553
  export function routeDigestOf(route) {
376
554
  return createHash("sha256")
377
- .update(`standing-orders:route:v${ROUTE_VERSION}:${canonicalRouteJson(route)}`, "utf8")
555
+ .update(`standing-orders:route:v${route.version}:${canonicalRouteJson(route)}`, "utf8")
378
556
  .digest("hex")
379
557
  .slice(0, 32);
380
558
  }
@@ -413,9 +591,17 @@ export function routeFromJson(json) {
413
591
  // Exact keys (v48 integrity): the snapshot carries what canonicalRouteJson
414
592
  // writes and nothing else — on the route, on every leg, on every leg's
415
593
  // recommendation, and on every override.
416
- if (!exactKeysOf(r, ["version", "risk", "qualityMode", "publication", "evidence", "posture", "demands", "legs", "overrides"]))
594
+ // A v1 route (sealed before sizing) reads back exactly as it was written:
595
+ // no size, routine and strong tiers only.
596
+ const version = r["version"];
597
+ if (version !== 1 && version !== 2)
598
+ return null;
599
+ const v1Keys = ["version", "risk", "qualityMode", "publication", "evidence", "posture", "demands", "legs", "overrides"];
600
+ if (!exactKeysOf(r, version === 1 ? v1Keys : [...v1Keys, "size"]))
417
601
  return null;
418
- if (r["version"] !== ROUTE_VERSION)
602
+ const tierOk = (tier) => tier === "routine" || tier === "strong" || (version === 2 && tier === "light");
603
+ const size = version === 1 || r["size"] === null ? null : parseSizing(r["size"]);
604
+ if (version === 2 && r["size"] !== null && size === null)
419
605
  return null;
420
606
  if (!isRiskLevel(r["risk"]))
421
607
  return null;
@@ -445,7 +631,7 @@ export function routeFromJson(json) {
445
631
  return null;
446
632
  if (!exactModel(leg["model"]))
447
633
  return null;
448
- if (leg["tier"] !== "routine" && leg["tier"] !== "strong")
634
+ if (!tierOk(leg["tier"]))
449
635
  return null;
450
636
  if (leg["chosen"] !== "recommended" && leg["chosen"] !== "override" && leg["chosen"] !== "pinned")
451
637
  return null;
@@ -459,7 +645,7 @@ export function routeFromJson(json) {
459
645
  return null;
460
646
  if (!exactModel(recommended["model"]))
461
647
  return null;
462
- if (recommended["tier"] !== "routine" && recommended["tier"] !== "strong")
648
+ if (!tierOk(recommended["tier"]))
463
649
  return null;
464
650
  if (!stringList(leg["reasons"]))
465
651
  return null;
@@ -484,7 +670,7 @@ export function routeFromJson(json) {
484
670
  if (overrides === null)
485
671
  return null;
486
672
  return {
487
- version: ROUTE_VERSION,
673
+ version,
488
674
  risk: r["risk"],
489
675
  qualityMode: r["qualityMode"],
490
676
  publication: r["publication"],
@@ -493,6 +679,7 @@ export function routeFromJson(json) {
493
679
  demands: [...r["demands"]],
494
680
  legs,
495
681
  overrides,
682
+ size,
496
683
  };
497
684
  }
498
685
  /** Strict parse of an override list: one per phase, every field proved,
@@ -545,7 +732,25 @@ export function canonicalOverridesJson(overrides) {
545
732
  }
546
733
  /** What actually runs: stronger agents only when a leg draws from one. */
547
734
  export function postureWords(route) {
548
- return drawsStrong(route.legs.filter(leg => leg.phase !== "review")) ? "stronger configured agents" : "everyday configured agents";
735
+ const active = route.legs.filter(leg => leg.phase !== "review");
736
+ if (drawsStrong(active))
737
+ return "stronger configured agents";
738
+ return active.some(leg => leg.phase === "build" && leg.chosen === "recommended" && leg.tier === "light") ? "a fast configured agent" : "everyday configured agents";
739
+ }
740
+ /** The size line, plainly: what the size does to this route. */
741
+ export function sizeWords(route) {
742
+ if (route.size === null)
743
+ return null;
744
+ const build = route.legs.find(leg => leg.phase === "build");
745
+ // Said from the build leg that actually runs: another demand (strict quality, screenshots) can lift it.
746
+ const model = build?.tier === "light" ? "fast model" : build?.tier === "strong" ? "strongest model" : "everyday model";
747
+ if (makesNoPlan(route.size, route.risk))
748
+ return `Small change: ${model}, no plan`;
749
+ // Elevated or high risk plans a small change too.
750
+ const label = route.size.size === "large" ? "Large change" : route.size.size === "small" ? (route.size.risky ? "Small but risky change" : "Small change") : route.size.risky ? "Risky change" : "Medium change";
751
+ if (route.size.size !== "large" && !route.size.risky && route.risk === "routine")
752
+ return build?.tier === "strong" ? "Medium change: strongest agents" : "Medium change: everyday agents";
753
+ return build?.tier === "strong" ? `${label}: strongest agents plan and build` : `${label}: planned first, everyday agents (no stronger agent is configured)`;
549
754
  }
550
755
  export function readinessWords(state, reason) {
551
756
  if (state === "ready")
@@ -555,7 +760,7 @@ export function readinessWords(state, reason) {
555
760
  return `readiness unknown${reason === null ? "" : ` — ${reason}`}`;
556
761
  }
557
762
  export function chosenWords(leg) {
558
- return leg.chosen === "override" ? "overridden" : leg.chosen === "pinned" ? "pinned" : leg.tier === "strong" ? "recommended · strong" : "recommended";
763
+ return leg.chosen === "override" ? "overridden" : leg.chosen === "pinned" ? "pinned" : leg.tier === "strong" ? "recommended · strong" : leg.tier === "light" ? "recommended · fast" : "recommended";
559
764
  }
560
765
  /**
561
766
  * The one-line Agents summary every surface can show above the details:
@@ -565,7 +770,9 @@ export function chosenWords(leg) {
565
770
  */
566
771
  export function agentsSummary(route) {
567
772
  const groups = [];
568
- for (const leg of route.legs.filter(leg => leg.phase !== "review")) {
773
+ // A small change makes no plan: its planner is not one of the agents that work on it.
774
+ const unplanned = makesNoPlan(route.size, route.risk ?? "routine");
775
+ for (const leg of route.legs.filter(leg => leg.phase !== "review" && !(unplanned && leg.phase === "plan" && leg.chosen === "recommended"))) {
569
776
  const spec = specWords(leg);
570
777
  const group = groups.find(one => one.spec === spec);
571
778
  if (group === undefined)
@@ -607,6 +814,10 @@ export function projectRoute(route, readiness) {
607
814
  summary: agentsSummary(route),
608
815
  halted: legs.some(leg => leg.readiness === "unavailable"),
609
816
  problems: routeProblems(route),
817
+ size: route.size,
818
+ sizeWords: sizeWords(route),
819
+ // A person's size already says who set it.
820
+ sizeReason: route.size === null ? null : route.size.source === "person" && route.size.reason !== "" ? route.size.reason : `${route.size.reason === "" ? "" : `${route.size.reason} · `}${sizeSourceWords(route.size.source)}`,
610
821
  };
611
822
  }
612
823
  /** The approval-card lines: one route header, one line per leg, then the
@@ -615,6 +826,7 @@ export function routeWords(projection, indent = " ") {
615
826
  const pad = `${indent} `;
616
827
  return [
617
828
  `${indent}route ${projection.riskTitle.toLowerCase()} · ${projection.postureWords} · ${projection.digest}`,
829
+ ...(projection.sizeWords === null ? [] : [`${indent}size ${projection.sizeWords} — ${projection.sizeReason}`]),
618
830
  `${pad}${projection.summary}`,
619
831
  ...projection.legs.flatMap(leg => [`${pad}${leg.words}`, ...leg.reasons.map(reason => `${pad} ${reason}`)]),
620
832
  ...(projection.halted ? [`${pad}HALTED: a provider on this route is reported unavailable — nothing substitutes; override the phase or restore the provider`] : []),
@@ -123,6 +123,11 @@ export declare function testSkill(store: Store, args: {
123
123
  ok: true;
124
124
  id: string;
125
125
  planning: boolean;
126
+ sizing: import("./phase-routing.js").TaskSizing;
127
+ } | {
128
+ ok: true;
129
+ id: string;
130
+ planning: boolean;
126
131
  };
127
132
  /** Exact finished test result; report feedback never pretends a code diff exists. */
128
133
  export declare function skillTestResult(store: Store, runId: number, actor: string): {
@@ -143,6 +148,11 @@ export declare function reviseSkillTest(store: Store, args: {
143
148
  ok: true;
144
149
  id: string;
145
150
  planning: boolean;
151
+ sizing: import("./phase-routing.js").TaskSizing;
152
+ } | {
153
+ ok: true;
154
+ id: string;
155
+ planning: boolean;
146
156
  };
147
157
  /** Public GitHub folders only, pinned to a commit before reading any package bytes. */
148
158
  export declare function githubSkill(url: string, fetcher?: typeof fetch): Promise<{
@@ -3,6 +3,7 @@ export { validateTaskText } from "./task-text.js";
3
3
  import type { Store } from "./store.js";
4
4
  import { type UnattendedPermissionMode } from "./scope.js";
5
5
  import type { QualityMode } from "./quality.js";
6
+ import type { TaskSizing } from "./phase-routing.js";
6
7
  export type ProposalRefusal = {
7
8
  ok: false;
8
9
  /** Stable, machine-readable — joins the envelope like every reason. */
@@ -47,11 +48,17 @@ export type TaskProposalInput = {
47
48
  * which is the fail-closed reading of an empty ceiling (finding 4).
48
49
  */
49
50
  admittedRepos?: readonly string[];
51
+ /** v2 routing: the task's size when the caller already knows it (a
52
+ * person's choice). Absent, filing sizes it from the description at
53
+ * once and the process's classifier refines it within five seconds. */
54
+ sizing?: TaskSizing;
50
55
  };
51
56
  /** One deterministic planning policy for console, chat, MCP, and sync
52
57
  * filings. A planner needs a repository to inspect; report/scout work is
53
- * already the discovery phase and never recursively plans itself. */
54
- export declare function shouldPlanTask(input: TaskProposalInput): boolean;
58
+ * already the discovery phase and never recursively plans itself. With a
59
+ * size (v2 routing), a small change is not planned and a large or risky
60
+ * one is; a medium one keeps the description's signals. */
61
+ export declare function shouldPlanTask(input: TaskProposalInput, sizing?: TaskSizing): boolean;
55
62
  export type RoutineProposalInput = {
56
63
  name: string;
57
64
  /** v102: who is making it (its instances are filed as theirs). */
@@ -74,6 +81,7 @@ export declare function fileTaskProposal(store: Store, input: TaskProposalInput,
74
81
  ok: true;
75
82
  id: string;
76
83
  planning: boolean;
84
+ sizing: TaskSizing;
77
85
  } | ProposalRefusal;
78
86
  export declare function fileRoutineProposal(store: Store, input: RoutineProposalInput, now: Date): {
79
87
  ok: true;
package/dist/proposal.js CHANGED
@@ -33,14 +33,16 @@ import { hasForbiddenControls, hasDisguisedText } from "./decision.js";
33
33
  import { canonicalProject } from "./project.js";
34
34
  import { ROUTINE_NAME, routineDigestOf, validateRoutineTerms, } from "./routine.js";
35
35
  import { parseAcceptanceCriteria } from "./scope.js";
36
+ import { heuristicSizing, planningSignals, refineFiledSizing } from "./task-sizing.js";
36
37
  /** Provenance tokens are part of the audit surface: lowercase, bounded,
37
38
  * nothing that could render as anything but itself. */
38
39
  const FILED_VIA = /^[a-z][a-z0-9:-]{0,63}$/;
39
- const SUBSTANTIAL_WORK = /\b(?:architecture|end[- ]to[- ]end|migrat(?:e|ion)|moderni[sz]e|redesign|refactor|rework|workflow|navigation|accessibility|responsive|performance|security|unif(?:y|ied)|multi[- ](?:step|project|service))\b/i;
40
40
  /** One deterministic planning policy for console, chat, MCP, and sync
41
41
  * filings. A planner needs a repository to inspect; report/scout work is
42
- * already the discovery phase and never recursively plans itself. */
43
- export function shouldPlanTask(input) {
42
+ * already the discovery phase and never recursively plans itself. With a
43
+ * size (v2 routing), a small change is not planned and a large or risky
44
+ * one is; a medium one keeps the description's signals. */
45
+ export function shouldPlanTask(input, sizing) {
44
46
  if (input.deliverable === "report" || input.planning === "skip")
45
47
  return false;
46
48
  // Remote coordinators only propose work. Keep their submissions quarantined
@@ -52,11 +54,13 @@ export function shouldPlanTask(input) {
52
54
  return false;
53
55
  if (input.planning === "required")
54
56
  return true;
55
- const goal = input.goal ?? "";
56
- const descriptionSize = input.title.length + goal.length + (input.outOfScope?.length ?? 0);
57
- return ((input.touches?.length ?? 0) >= 2 ||
58
- descriptionSize >= 280 ||
59
- SUBSTANTIAL_WORK.test(`${input.title}\n${goal}`));
57
+ if (sizing !== undefined) {
58
+ if (sizing.size === "small" && !sizing.risky)
59
+ return false;
60
+ if (sizing.size === "large" || sizing.risky)
61
+ return true;
62
+ }
63
+ return planningSignals(input);
60
64
  }
61
65
  function refuse(reason, message) {
62
66
  return { ok: false, reason, message };
@@ -100,6 +104,8 @@ export function fileTaskProposal(store, input, now) {
100
104
  const badText = validateTaskText(input);
101
105
  if (badText !== null)
102
106
  return badText;
107
+ // Sized at once from the description; filing never waits for a model.
108
+ const sizing = input.sizing ?? heuristicSizing(input);
103
109
  const outOfScope = input.outOfScope ?? null;
104
110
  const touches = input.touches ?? [];
105
111
  const repo = checkRepo(input.repo, input.admittedRepos);
@@ -120,6 +126,7 @@ export function fileTaskProposal(store, input, now) {
120
126
  ...(input.filedBy === undefined ? {} : { filedBy: input.filedBy }),
121
127
  proposedVia: input.proposedVia ?? null,
122
128
  ...(input.deliverable === undefined ? {} : { deliverable: input.deliverable }),
129
+ sizing,
123
130
  }, now);
124
131
  if (!made.ok) {
125
132
  return refuse(made.reason, made.reason === "backlog-full"
@@ -131,12 +138,20 @@ export function fileTaskProposal(store, input, now) {
131
138
  : `the store refused the filing: ${made.reason}`);
132
139
  }
133
140
  let planning = false;
134
- if (shouldPlanTask({ ...input, ...(repo.repo === undefined ? {} : { repo: repo.repo }) })) {
141
+ const placed = { ...input, ...(repo.repo === undefined ? {} : { repo: repo.repo }) };
142
+ if (shouldPlanTask(placed, sizing)) {
135
143
  const ref = store.lookupRef(made.id);
136
144
  if (ref !== null)
137
145
  planning = store.requestPlan(ref.id, now).ok;
138
146
  }
139
- return { ok: true, id: made.id, planning };
147
+ // The classifier (when this process has one) answers within five seconds
148
+ // and re-files the still-unapproved scope with its size. Never for work
149
+ // that builds nothing, or for an untrusted remote proposal.
150
+ if (input.sizing === undefined && repo.repo !== undefined && input.goal !== undefined && input.deliverable !== "report" &&
151
+ input.proposedVia !== "coordinator" && !input.filedVia.startsWith("mcp:")) {
152
+ void refineFiledSizing(store, made.id, placed, input.planning === undefined || input.planning === "auto");
153
+ }
154
+ return { ok: true, id: made.id, planning, sizing };
140
155
  }
141
156
  export function fileRoutineProposal(store, input, now) {
142
157
  if (!FILED_VIA.test(input.filedVia)) {
package/dist/recipe-ui.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { TASK_TEXT_LIMITS } from "./task-text.js";
1
2
  import { composerSchedule, scheduleEditorHtml, scheduleEditorScript } from "./task-composer.js";
2
3
  import { parseRecipe, workflowSteps, recipeScheduleWords, RecipeError, RECIPE_INPUT_LIMIT } from "./recipes.js";
3
4
  import { ACCEPTANCE_LIMITS } from "./scope.js";
@@ -76,7 +77,7 @@ export function recipeEditorHtml(recipe, repo, csrf, revision, problem = null, v
76
77
  return `<a href="/recipes" class="meta">← All recipes</a><div class="recipe-heading"><p class="recipe-kicker">${creator ? "1 · Describe → 2 · Preview → 3 · Save" : "1 · Choose → 2 · Customize → 3 · Preview"}</p><h1>${creator ? "Create a reusable recipe" : "Make this workflow yours"}</h1><p class="hint">${e(repo.split(/[\\/]/).pop() || repo)} · Choose the outcome, boundaries, and proof you want back.</p></div>` +
77
78
  (problem === null ? "" : `<p role="alert" class="card recipe-error">${e(problem)}</p>`) +
78
79
  `<form method="post" action="/recipes/preview" class="recipe-editor">${hidden("csrf", csrf)}${hidden("repo", repo)}${hidden("projectRevision", String(revision))}${hidden("source", recipe.id)}${hidden("sourceRevision", String(recipe.revision))}${hidden("sourceFormat", value("sourceFormat", String(d.version)))}` +
79
- `<section class="card"><h2>The outcome</h2><label>${creator ? "Recipe name" : "Workflow name"}<input type="text" name="name" value="${e(value("name", d.name))}" maxlength="200" required></label><label>Short description<input type="text" name="description" value="${e(value("description", d.description))}" maxlength="400"></label><label>What should the agent do?<textarea name="goal" rows="5" maxlength="2000" required>${e(value("goal", d.goal))}</textarea></label><label>What should it leave alone?<textarea name="not" rows="3" maxlength="2000">${e(value("not", d.outOfScope ?? ""))}</textarea></label><label>Allowed files or folders <span class="meta">optional; one per line</span><textarea name="touches" rows="2" placeholder="src/&#10;tests/">${e(value("touches", d.touches.join("\n")))}</textarea></label><p class="meta">Blank allows work across this project. Add paths to keep the workflow focused.</p></section>` +
80
+ `<section class="card"><h2>The outcome</h2><label>${creator ? "Recipe name" : "Workflow name"}<input type="text" name="name" value="${e(value("name", d.name))}" maxlength="200" required></label><label>Short description<input type="text" name="description" value="${e(value("description", d.description))}" maxlength="400"></label><label>What should the agent do?<textarea name="goal" rows="5" maxlength="${TASK_TEXT_LIMITS.text}" required>${e(value("goal", d.goal))}</textarea></label><label>What should it leave alone?<textarea name="not" rows="3" maxlength="${TASK_TEXT_LIMITS.text}">${e(value("not", d.outOfScope ?? ""))}</textarea></label><label>Allowed files or folders <span class="meta">optional; one per line</span><textarea name="touches" rows="2" placeholder="src/&#10;tests/">${e(value("touches", d.touches.join("\n")))}</textarea></label><p class="meta">Blank allows work across this project. Add paths to keep the workflow focused.</p></section>` +
80
81
  recipeQuestionsHtml(d, values) +
81
82
  `<section class="card"><h2>What does success look like?</h2><p class="hint">Give each outcome the evidence you want to see. The workflow checks these before calling the work done.</p><div data-recipe-criteria>${Array.from({ length: count }, (_, i) => criterionHtml(i, d.acceptance[i], values)).join("")}</div><button type="button" data-add-criterion hidden>Add another success check</button><template data-criterion-template>${criterionHtml(11).replaceAll("criterion-11-", "criterion-INDEX-").replaceAll('value="c12"', 'value="cNEXT"')}</template></section>` +
82
83
  `<section class="card"><h2>How it should run</h2><div class="recipe-options"><label>Result<select name="deliverable"><option value="branch"${deliverable === "branch" ? " selected" : ""}>Code change with evidence</option><option value="report"${deliverable === "report" ? " selected" : ""}>Report without code changes</option></select></label><label>Planning<select name="planning">${[["auto", "Plan when helpful"], ["required", "Always plan first"], ["skip", "Use this scope directly"]].map(([id, label]) => `<option value="${id}"${planning === id ? " selected" : ""}>${label}</option>`).join("")}</select></label></div><p class="meta">Report work investigates directly. Repeating work reuses its approved scope; plan-first workflows run once.</p><label>Frequency<select name="cadence" data-recipe-cadence><option value="once"${cadence === "once" ? " selected" : ""}>Run once</option><option value="repeat"${cadence === "repeat" ? " selected" : ""}>Repeat on a schedule</option></select></label><div data-recipe-recurring>${scheduleEditorHtml(d.schedule, values)}<label>Weekly spending limit <span class="meta">optional, USD</span><input type="number" name="ceiling" min="0.01" step="0.01" value="${e(value("ceiling", d.costCeilingUsd === null ? "" : String(d.costCeilingUsd)))}"></label><p class="meta">One instance at a time. A spending limit requires an agent that reports dollar costs; approval checks this.</p></div></section>` +
@@ -47,15 +47,17 @@ export type ReportParseResult = {
47
47
  problems: ReportProblem[];
48
48
  };
49
49
  /** Caps are BYTES of UTF-8 (v4 review, finding 11): a 64 KiB report is
50
- * 64 KiB whatever script it is written in. */
50
+ * 64 KiB whatever script it is written in. They hold what a scout writes;
51
+ * a stored report is read without them (parseReport's `stored`). */
51
52
  export declare const REPORT_LIMITS: {
52
53
  readonly payload: number;
53
54
  readonly title: 200;
54
- readonly summary: 1000;
55
+ readonly summary: 2500;
55
56
  readonly document: number;
56
57
  readonly followUps: 5;
57
58
  readonly followUpTitle: 200;
58
- readonly followUpGoal: 2000;
59
+ /** A follow-up files as a task: its goal is held to the task goal limit, in bytes of the same count. */
60
+ readonly followUpGoal: 8000;
59
61
  readonly items: 6;
60
62
  readonly itemTitle: 200;
61
63
  readonly itemWhy: 1000;
@@ -199,4 +201,8 @@ export declare const SCOUT_OUTPUT_JSON_SCHEMA: {
199
201
  readonly required: readonly ["kind"];
200
202
  readonly additionalProperties: false;
201
203
  };
202
- export declare function parseReport(raw: string): ReportParseResult;
204
+ /** A report, checked. `stored`: one already kept as evidence, read without the length caps (a report written under
205
+ * higher limits stays readable); every other check still holds. */
206
+ export declare function parseReport(raw: string, options?: {
207
+ stored?: boolean;
208
+ }): ReportParseResult;
@@ -7,16 +7,20 @@
7
7
  * the park discipline exactly as the plan does.
8
8
  */
9
9
  import { hasForbiddenControls } from "./decision.js";
10
+ import { TASK_TEXT_LIMITS } from "./task-text.js";
11
+ import { TEXT_LIMITS } from "./text-limits.js";
10
12
  /** Caps are BYTES of UTF-8 (v4 review, finding 11): a 64 KiB report is
11
- * 64 KiB whatever script it is written in. */
13
+ * 64 KiB whatever script it is written in. They hold what a scout writes;
14
+ * a stored report is read without them (parseReport's `stored`). */
12
15
  export const REPORT_LIMITS = {
13
16
  payload: 96 * 1024,
14
17
  title: 200,
15
- summary: 1_000,
18
+ summary: TEXT_LIMITS.reportSummary,
16
19
  document: 64 * 1024,
17
20
  followUps: 5,
18
21
  followUpTitle: 200,
19
- followUpGoal: 2_000,
22
+ /** A follow-up files as a task: its goal is held to the task goal limit, in bytes of the same count. */
23
+ followUpGoal: TASK_TEXT_LIMITS.text,
20
24
  items: 6,
21
25
  itemTitle: 200,
22
26
  itemWhy: 1_000,
@@ -128,7 +132,7 @@ function prose(value, field, cap, problems) {
128
132
  problems.push({ reason: `bad-${field}`, message: `${field} must be a string (got ${describe(value)})` });
129
133
  return null;
130
134
  }
131
- if (Buffer.byteLength(value, "utf8") > cap) {
135
+ if (!readingStored && Buffer.byteLength(value, "utf8") > cap) {
132
136
  problems.push({ reason: `${field}-too-long`, message: `${field} is over ${cap} bytes` });
133
137
  return null;
134
138
  }
@@ -138,8 +142,21 @@ function prose(value, field, cap, problems) {
138
142
  }
139
143
  return value;
140
144
  }
141
- export function parseReport(raw) {
142
- if (Buffer.byteLength(raw, "utf8") > REPORT_LIMITS.payload) {
145
+ /** True while a stored report is read: a limit is for writing, and reading never re-checks a text's length. */
146
+ let readingStored = false;
147
+ /** A report, checked. `stored`: one already kept as evidence, read without the length caps (a report written under
148
+ * higher limits stays readable); every other check still holds. */
149
+ export function parseReport(raw, options = {}) {
150
+ readingStored = options.stored === true;
151
+ try {
152
+ return parseReportBody(raw);
153
+ }
154
+ finally {
155
+ readingStored = false;
156
+ }
157
+ }
158
+ function parseReportBody(raw) {
159
+ if (!readingStored && Buffer.byteLength(raw, "utf8") > REPORT_LIMITS.payload) {
143
160
  return refuse("too-large", `the payload is over ${REPORT_LIMITS.payload} bytes`);
144
161
  }
145
162
  let parsed;