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.
- package/CHANGELOG.md +14 -0
- package/dist/agentconfig.js +71 -3
- package/dist/browser/workspace.js +3 -3
- package/dist/build-review.js +3 -2
- package/dist/builder.js +10 -8
- package/dist/chat-actions.js +7 -4
- package/dist/chat-flow.js +10 -6
- package/dist/converse.js +3 -2
- package/dist/coordinator-proposals.js +2 -2
- package/dist/decision.d.ts +3 -3
- package/dist/decision.js +4 -3
- package/dist/desktop-update.d.ts +1 -1
- package/dist/evidence.js +1 -1
- package/dist/flow-actions.d.ts +1 -0
- package/dist/flow-actions.js +6 -7
- package/dist/flow-code.d.ts +3 -2
- package/dist/flow-code.js +6 -4
- package/dist/flow-draft.d.ts +10 -4
- package/dist/flow-draft.js +19 -8
- package/dist/flow-engine.d.ts +0 -3
- package/dist/flow-engine.js +17 -10
- package/dist/flow-people.d.ts +0 -1
- package/dist/flow-people.js +9 -1
- package/dist/flow-send.js +3 -2
- package/dist/flow-steps.js +33 -10
- package/dist/flows.d.ts +23 -8
- package/dist/flows.js +106 -52
- package/dist/guides.js +1 -1
- package/dist/lead-context.d.ts +3 -9
- package/dist/lead-context.js +21 -5
- package/dist/mate-doors.js +8 -2
- package/dist/mate-tools.js +27 -10
- package/dist/mate.js +13 -2
- package/dist/operate.d.ts +3 -0
- package/dist/operate.js +122 -8
- package/dist/phase-routing.d.ts +86 -5
- package/dist/phase-routing.js +228 -16
- package/dist/project-skills.d.ts +10 -0
- package/dist/proposal.d.ts +10 -2
- package/dist/proposal.js +25 -10
- package/dist/recipe-ui.js +2 -1
- package/dist/scout-report.d.ts +10 -4
- package/dist/scout-report.js +23 -6
- package/dist/scout.d.ts +2 -0
- package/dist/scout.js +83 -10
- package/dist/serve.js +60 -10
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +64 -5
- package/dist/store.js +192 -7
- package/dist/task-sizing.d.ts +78 -0
- package/dist/task-sizing.js +228 -0
- package/dist/task-text.d.ts +4 -4
- package/dist/task-text.js +10 -6
- package/dist/teammate-work.js +31 -7
- package/dist/teammates.d.ts +20 -1
- package/dist/teammates.js +18 -5
- package/dist/teams-chat.d.ts +4 -1
- package/dist/teams-chat.js +17 -6
- package/dist/telegram-flow.js +6 -2
- package/dist/test-temp.js +1 -1
- package/dist/text-limits.d.ts +67 -0
- package/dist/text-limits.js +73 -0
- package/dist/tier-report.d.ts +44 -0
- package/dist/tier-report.js +71 -0
- package/package.json +1 -1
package/dist/phase-routing.js
CHANGED
|
@@ -67,12 +67,16 @@ function exactModel(v) {
|
|
|
67
67
|
export function exactModelId(v) {
|
|
68
68
|
return exactModel(v);
|
|
69
69
|
}
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
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 —
|
|
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
|
-
|
|
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${
|
|
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
|
-
|
|
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
|
-
|
|
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"]
|
|
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"]
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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`] : []),
|
package/dist/project-skills.d.ts
CHANGED
|
@@ -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<{
|
package/dist/proposal.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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/ 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>` +
|
package/dist/scout-report.d.ts
CHANGED
|
@@ -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:
|
|
55
|
+
readonly summary: 2500;
|
|
55
56
|
readonly document: number;
|
|
56
57
|
readonly followUps: 5;
|
|
57
58
|
readonly followUpTitle: 200;
|
|
58
|
-
|
|
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
|
-
|
|
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;
|
package/dist/scout-report.js
CHANGED
|
@@ -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:
|
|
18
|
+
summary: TEXT_LIMITS.reportSummary,
|
|
16
19
|
document: 64 * 1024,
|
|
17
20
|
followUps: 5,
|
|
18
21
|
followUpTitle: 200,
|
|
19
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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;
|