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
@@ -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;
@@ -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)
@@ -599,7 +806,7 @@ export function projectRoute(route, readiness) {
599
806
  return {
600
807
  digest: routeDigestOf(route),
601
808
  risk: route.risk,
602
- riskTitle: riskTitle(route.risk),
809
+ riskTitle: route.risk === "routine" && route.size?.risky ? "Risky" : riskTitle(route.risk),
603
810
  posture: drawsStrong(legs) ? "strong" : "economy",
604
811
  postureWords: postureWords(route),
605
812
  demands: route.demands.filter(reason => !/review/i.test(reason)),
@@ -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`] : []),
@@ -1,6 +1,7 @@
1
1
  type HistoricalWitness = {
2
2
  observedAt: unknown;
3
3
  finishedAt: unknown;
4
+ exitedAt?: unknown;
4
5
  };
5
6
  /** Read-only checks of retained process witnesses. Saved PIDs authorize no
6
7
  * signal. ESRCH or proven macOS PID reuse establishes absence; everything
@@ -43,6 +43,11 @@ function newerMacProcess(pid, witness) {
43
43
  * unknown stays occupied. This concerns the historical identity, not whether
44
44
  * the numeric PID/PGID is currently populated by unrelated processes. */
45
45
  export function processMayBeAlive(pid, group, witness) {
46
+ // A recorded single-process exit is final: this number can now belong to
47
+ // someone else, even before the run ends. A leader's exit proves nothing
48
+ // about its group's remaining members, so groups keep the OS probe.
49
+ if (!group && witness?.exitedAt != null)
50
+ return false;
46
51
  if (!Number.isSafeInteger(pid) || pid <= 0)
47
52
  return true;
48
53
  if (group && process.platform !== "win32") {
@@ -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;