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.
- package/CHANGELOG.md +28 -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/lead-status.js +17 -12
- package/dist/mate-doors.js +8 -2
- package/dist/mate-tools.js +27 -10
- package/dist/mate.js +13 -2
- package/dist/needs-you.d.ts +2 -2
- package/dist/needs-you.js +3 -0
- package/dist/operate.d.ts +3 -0
- package/dist/operate.js +144 -9
- package/dist/phase-routing.d.ts +86 -5
- package/dist/phase-routing.js +229 -17
- package/dist/process-liveness.d.ts +1 -0
- package/dist/process-liveness.js +5 -0
- 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 +71 -15
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +72 -8
- package/dist/store.js +230 -29
- 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/telegram-flow.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* replies go. Both go through chooseFlowCard, for exactly that visit.
|
|
21
21
|
*/
|
|
22
22
|
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { LIMITS } from "./decision.js";
|
|
23
24
|
import { keptDraft } from "./flow-draft.js";
|
|
24
25
|
import { decideFlowCard, draftFor, flowDefinitionOf } from "./flow-engine.js";
|
|
25
26
|
import { chooseFlowCard, flowChoiceAt, flowSendPaths, readFlowSend } from "./flow-send.js";
|
|
@@ -148,7 +149,7 @@ export function applyFlowReply(store, binding, prompt, text, repos, now) {
|
|
|
148
149
|
if (waiting.draft === null)
|
|
149
150
|
return [{ kind: "say", text: "This decision has no draft to edit." }];
|
|
150
151
|
if (said.length > DRAFT_LIMIT)
|
|
151
|
-
return [{ kind: "say", text: `Keep it
|
|
152
|
+
return [{ kind: "say", text: `That's ${said.length.toLocaleString("en-US")} characters. Keep it to ${DRAFT_LIMIT.toLocaleString("en-US")}, or change it in Toolroll.` }];
|
|
152
153
|
const kept = keptDraft(said);
|
|
153
154
|
store.updateFlowCard(waiting.card.id, { outputs: { ...waiting.card.outputs, [waiting.draft.id]: kept } }, now);
|
|
154
155
|
store.addFlowComment({ card: waiting.card.id, author: binding.approver, body: "Edited the draft in Telegram.", mentions: [] }, now);
|
|
@@ -158,9 +159,12 @@ export function applyFlowReply(store, binding, prompt, text, repos, now) {
|
|
|
158
159
|
return [{ kind: "decide", keyboard: buttons.keyboard, tokens: buttons.tokens,
|
|
159
160
|
text: `${waiting.flow.name}: your version of the draft for “${waiting.card.title}”\n\n${kept}\n\nApprove to send it as written.` }];
|
|
160
161
|
}
|
|
162
|
+
// A note over its limit is refused with the limit, never cut.
|
|
163
|
+
if (said.length > LIMITS.note)
|
|
164
|
+
return [{ kind: "say", text: `That's ${said.length.toLocaleString("en-US")} characters. Keep the note to ${LIMITS.note.toLocaleString("en-US")}, or send it back in Toolroll.` }];
|
|
161
165
|
if (repos === null)
|
|
162
166
|
return [{ kind: "say", text: "Couldn't check your projects just now. Reply again in a moment." }];
|
|
163
|
-
const decided = decideFlowCard(store, { card: waiting.card.id, decision: "send-back", note: said
|
|
167
|
+
const decided = decideFlowCard(store, { card: waiting.card.id, decision: "send-back", note: said, actor: binding.approver, repos, entry: prompt.entry }, now);
|
|
164
168
|
if (!decided.ok)
|
|
165
169
|
return [{ kind: "say", text: decided.message }];
|
|
166
170
|
store.retireTelegramFlowVisit(prompt.card, prompt.entry, now);
|
package/dist/test-temp.js
CHANGED
|
@@ -18,7 +18,7 @@ export const TEST_TEMP_PREFIXES = [
|
|
|
18
18
|
"flow-goal-", "flow-scratch", "flows-chat-", "invoke-attest-", "knowledge-test-", "learning-", "peek-", "phone-origin-",
|
|
19
19
|
"proposal-repo-", "race-", "refresh-migration-", "refresh-refusal-", "release-check-", "shared-chat-", "skills-test-",
|
|
20
20
|
"task-status-surfaces-", "template-repo-", "template-test-", "v14-migrate-", "v17-migrate-", "watch-anchor-",
|
|
21
|
-
"needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-", "deploy-restore-", "result-review-", "one-result-page-", "deploy-before-swap-", "one-vocabulary-", "accept-finishes-", "lead-promises-", "lead-people-",
|
|
21
|
+
"needs-you-", "deps-lock-", "deps-state-", "upgrade-fresh-", "upgrade-path-", "deploy-restore-", "result-review-", "one-result-page-", "deploy-before-swap-", "one-vocabulary-", "accept-finishes-", "lead-promises-", "lead-people-", "sizing-repo-",
|
|
22
22
|
// Playwright's browser profiles and artifacts.
|
|
23
23
|
"playwright_chromiumdev_profile-", "playwright_firefoxdev_profile-", "playwright-artifacts-",
|
|
24
24
|
];
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text limits, in one place, and the one way a limit is met: a model is told the exact limit before it writes, an
|
|
3
|
+
* answer still over it is asked once to shorten (one repair turn), and an answer still over after that is kept whole
|
|
4
|
+
* in the record and linked, never chopped. Chat platforms' own message limits are met by splitting across messages.
|
|
5
|
+
*
|
|
6
|
+
* Limits apply when text is written. Reading never re-validates length, so a runtime with lower limits (0.9.23 to
|
|
7
|
+
* 0.9.26) still opens a store holding longer text (scripts/upgrade-path.mjs, the rollback leg).
|
|
8
|
+
*/
|
|
9
|
+
/** UTF-16 code units, the JavaScript and HTML maxlength measure, unless a name says bytes. */
|
|
10
|
+
export declare const TEXT_LIMITS: {
|
|
11
|
+
/** A task's goal and its out-of-scope text. */
|
|
12
|
+
readonly goal: 8000;
|
|
13
|
+
readonly goalBytes: 32000;
|
|
14
|
+
/** Revise feedback, steering and decision notes. */
|
|
15
|
+
readonly note: 4000;
|
|
16
|
+
readonly noteBytes: 16000;
|
|
17
|
+
/** A flow work zone's instructions; nothing is held back for the card's details (they are attached when long). */
|
|
18
|
+
readonly flowInstructions: 8000;
|
|
19
|
+
/** A research report's summary, in UTF-8 bytes. */
|
|
20
|
+
readonly reportSummary: 2500;
|
|
21
|
+
/** What one flow step passes on to the steps after it. */
|
|
22
|
+
readonly stageOutput: 12000;
|
|
23
|
+
};
|
|
24
|
+
/** One chat message on each platform. Longer text is split across messages, never cut. Teams: the text one Adaptive Card
|
|
25
|
+
* holds, well inside its 28 KB message. */
|
|
26
|
+
export declare const PLATFORM_LIMITS: {
|
|
27
|
+
readonly telegram: 4096;
|
|
28
|
+
readonly discord: 2000;
|
|
29
|
+
readonly slack: 4000;
|
|
30
|
+
readonly teams: 6000;
|
|
31
|
+
};
|
|
32
|
+
export type Platform = keyof typeof PLATFORM_LIMITS;
|
|
33
|
+
/** The line a model reads before it writes a field with a limit. */
|
|
34
|
+
export declare function limitRule(what: string, limit: number, unit?: "characters" | "bytes"): string;
|
|
35
|
+
/** One field over its limit: what it is, its limit, and how long it came back. */
|
|
36
|
+
export type Overrun = {
|
|
37
|
+
field: string;
|
|
38
|
+
limit: number;
|
|
39
|
+
length: number;
|
|
40
|
+
unit?: "characters" | "bytes";
|
|
41
|
+
};
|
|
42
|
+
/** How long a text is in a limit's unit. */
|
|
43
|
+
export declare const lengthIn: (text: string, unit?: "characters" | "bytes") => number;
|
|
44
|
+
/** The fields of `values` over their limits. */
|
|
45
|
+
export declare function overruns(values: Record<string, string>, limits: Record<string, number>, unit?: "characters" | "bytes"): Overrun[];
|
|
46
|
+
/** The repair turn's ask: each field over its limit, by how much, and the same answer again within it. */
|
|
47
|
+
export declare function shortenAsk(over: readonly Overrun[]): string;
|
|
48
|
+
/**
|
|
49
|
+
* Ask, check, and ask once more to shorten. `write` gets null the first time and the shorten ask the second.
|
|
50
|
+
* The second answer is returned whatever its length: the caller keeps it whole (and links it when it must).
|
|
51
|
+
*/
|
|
52
|
+
export declare function writeWithin<T>(write: (shorten: string | null) => Promise<T>, check: (answer: T) => Overrun[]): Promise<{
|
|
53
|
+
answer: T;
|
|
54
|
+
over: Overrun[];
|
|
55
|
+
repaired: boolean;
|
|
56
|
+
}>;
|
|
57
|
+
/**
|
|
58
|
+
* Text passed on under a limit: whole when it fits; otherwise kept whole in the record named by `where` and passed on
|
|
59
|
+
* as a pointer to it. Never a cut.
|
|
60
|
+
*/
|
|
61
|
+
export declare function passOn(text: string, limit: number, where: {
|
|
62
|
+
label: string;
|
|
63
|
+
href: string | null;
|
|
64
|
+
}, holds?: string): {
|
|
65
|
+
text: string;
|
|
66
|
+
kept: boolean;
|
|
67
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text limits, in one place, and the one way a limit is met: a model is told the exact limit before it writes, an
|
|
3
|
+
* answer still over it is asked once to shorten (one repair turn), and an answer still over after that is kept whole
|
|
4
|
+
* in the record and linked, never chopped. Chat platforms' own message limits are met by splitting across messages.
|
|
5
|
+
*
|
|
6
|
+
* Limits apply when text is written. Reading never re-validates length, so a runtime with lower limits (0.9.23 to
|
|
7
|
+
* 0.9.26) still opens a store holding longer text (scripts/upgrade-path.mjs, the rollback leg).
|
|
8
|
+
*/
|
|
9
|
+
/** UTF-16 code units, the JavaScript and HTML maxlength measure, unless a name says bytes. */
|
|
10
|
+
export const TEXT_LIMITS = {
|
|
11
|
+
/** A task's goal and its out-of-scope text. */
|
|
12
|
+
goal: 8_000,
|
|
13
|
+
goalBytes: 32_000,
|
|
14
|
+
/** Revise feedback, steering and decision notes. */
|
|
15
|
+
note: 4_000,
|
|
16
|
+
noteBytes: 16_000,
|
|
17
|
+
/** A flow work zone's instructions; nothing is held back for the card's details (they are attached when long). */
|
|
18
|
+
flowInstructions: 8_000,
|
|
19
|
+
/** A research report's summary, in UTF-8 bytes. */
|
|
20
|
+
reportSummary: 2_500,
|
|
21
|
+
/** What one flow step passes on to the steps after it. */
|
|
22
|
+
stageOutput: 12_000,
|
|
23
|
+
};
|
|
24
|
+
/** One chat message on each platform. Longer text is split across messages, never cut. Teams: the text one Adaptive Card
|
|
25
|
+
* holds, well inside its 28 KB message. */
|
|
26
|
+
export const PLATFORM_LIMITS = { telegram: 4_096, discord: 2_000, slack: 4_000, teams: 6_000 };
|
|
27
|
+
const count = (n) => n.toLocaleString("en-US");
|
|
28
|
+
/** The line a model reads before it writes a field with a limit. */
|
|
29
|
+
export function limitRule(what, limit, unit = "characters") {
|
|
30
|
+
return `${what}: at most ${count(limit)} ${unit}. Put the most important part first. Longer text is not cut: you will be asked to shorten it.`;
|
|
31
|
+
}
|
|
32
|
+
/** How long a text is in a limit's unit. */
|
|
33
|
+
export const lengthIn = (text, unit = "characters") => unit === "bytes" ? Buffer.byteLength(text, "utf8") : text.length;
|
|
34
|
+
/** The fields of `values` over their limits. */
|
|
35
|
+
export function overruns(values, limits, unit = "characters") {
|
|
36
|
+
return Object.entries(limits).flatMap(([field, limit]) => {
|
|
37
|
+
const value = values[field];
|
|
38
|
+
if (value === undefined)
|
|
39
|
+
return [];
|
|
40
|
+
const length = lengthIn(value, unit);
|
|
41
|
+
return length > limit ? [{ field, limit, length, ...(unit === "bytes" ? { unit } : {}) }] : [];
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** The repair turn's ask: each field over its limit, by how much, and the same answer again within it. */
|
|
45
|
+
export function shortenAsk(over) {
|
|
46
|
+
return [
|
|
47
|
+
"Your answer is over a limit:",
|
|
48
|
+
...over.map(one => `- ${one.field} is ${count(one.length)} ${one.unit ?? "characters"}; the limit is ${count(one.limit)}.`),
|
|
49
|
+
"Answer again with the same decision and meaning, each field within its limit. Shorten by dropping repetition and detail, not facts that matter.",
|
|
50
|
+
].join("\n");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Ask, check, and ask once more to shorten. `write` gets null the first time and the shorten ask the second.
|
|
54
|
+
* The second answer is returned whatever its length: the caller keeps it whole (and links it when it must).
|
|
55
|
+
*/
|
|
56
|
+
export async function writeWithin(write, check) {
|
|
57
|
+
const first = await write(null);
|
|
58
|
+
const over = check(first);
|
|
59
|
+
if (over.length === 0)
|
|
60
|
+
return { answer: first, over, repaired: false };
|
|
61
|
+
const second = await write(shortenAsk(over));
|
|
62
|
+
return { answer: second, over: check(second), repaired: true };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Text passed on under a limit: whole when it fits; otherwise kept whole in the record named by `where` and passed on
|
|
66
|
+
* as a pointer to it. Never a cut.
|
|
67
|
+
*/
|
|
68
|
+
export function passOn(text, limit, where, holds = "a step passes on") {
|
|
69
|
+
if (text.length <= limit)
|
|
70
|
+
return { text, kept: false };
|
|
71
|
+
const at = where.href === null ? where.label : `${where.label}: ${where.href}`;
|
|
72
|
+
return { text: `This is ${count(text.length)} characters, more than the ${count(limit)} ${holds}, so it is kept whole on ${at}.`, kept: true };
|
|
73
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What sized routing saved (v2 routing), for `toolroll spend`: each task's
|
|
3
|
+
* tier (recorded on the task when its route is filed), how long it took
|
|
4
|
+
* from its first run to its first result, and whether it used a planner —
|
|
5
|
+
* grouped by tier, with what the light tier saved: the plans small changes
|
|
6
|
+
* skipped, results that came sooner, and spend below the everyday tier.
|
|
7
|
+
*/
|
|
8
|
+
import type { SpendItem } from "./spend.js";
|
|
9
|
+
import type { Database } from "./store.js";
|
|
10
|
+
export type TierTask = {
|
|
11
|
+
taskId: string;
|
|
12
|
+
tier: string;
|
|
13
|
+
size: string | null;
|
|
14
|
+
planRuns: number;
|
|
15
|
+
minutesToResult: number | null;
|
|
16
|
+
microusd: number;
|
|
17
|
+
};
|
|
18
|
+
export type TierRow = {
|
|
19
|
+
tier: string;
|
|
20
|
+
tasks: number;
|
|
21
|
+
results: number;
|
|
22
|
+
medianMinutesToResult: number | null;
|
|
23
|
+
planRuns: number;
|
|
24
|
+
microusd: number;
|
|
25
|
+
};
|
|
26
|
+
export type TierSaved = {
|
|
27
|
+
/** Small changes that built with no planner run. */
|
|
28
|
+
plansSkipped: number;
|
|
29
|
+
/** Those skipped plans at this month's median planner run, in minutes; null when no planner run was measured. */
|
|
30
|
+
planMinutesSaved: number | null;
|
|
31
|
+
/** How much sooner a light result came than an everyday one (medians), in minutes; null without both. */
|
|
32
|
+
minutesSoonerEach: number | null;
|
|
33
|
+
/** Light tasks' spend below the everyday tier's average task, in micro-USD; null when either is unpriced or $0. */
|
|
34
|
+
microusdSaved: number | null;
|
|
35
|
+
};
|
|
36
|
+
export type TierReport = {
|
|
37
|
+
tasks: TierTask[];
|
|
38
|
+
rows: TierRow[];
|
|
39
|
+
saved: TierSaved;
|
|
40
|
+
};
|
|
41
|
+
/** Every routed task with a run in [from, to), by tier, and what the light tier saved. `items` are the month's spend. */
|
|
42
|
+
export declare function tierReport(db: Database, from: string, to: string, items: readonly SpendItem[]): TierReport;
|
|
43
|
+
/** The spend report's lines: one per tier, then what the tiers saved. Empty when no routed task ran this month. */
|
|
44
|
+
export declare function tierLines(report: TierReport, usd: (microusd: number) => string): string[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const TIER_ORDER = ["light", "routine", "strong", "override", "pinned"];
|
|
2
|
+
function median(values) {
|
|
3
|
+
if (values.length === 0)
|
|
4
|
+
return null;
|
|
5
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
6
|
+
const mid = Math.floor(sorted.length / 2);
|
|
7
|
+
return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
8
|
+
}
|
|
9
|
+
const minutesBetween = (from, to) => {
|
|
10
|
+
const ms = Date.parse(to) - Date.parse(from);
|
|
11
|
+
return Number.isFinite(ms) && ms >= 0 ? Math.round(ms / 6_000) / 10 : null;
|
|
12
|
+
};
|
|
13
|
+
/** Every routed task with a run in [from, to), by tier, and what the light tier saved. `items` are the month's spend. */
|
|
14
|
+
export function tierReport(db, from, to, items) {
|
|
15
|
+
const spendOf = new Map();
|
|
16
|
+
for (const item of items)
|
|
17
|
+
if (item.taskId !== null)
|
|
18
|
+
spendOf.set(item.taskId, (spendOf.get(item.taskId) ?? 0) + (item.microusd ?? 0));
|
|
19
|
+
const rows = db.prepare(`SELECT r.external_id AS task, r.route_tier AS tier, r.size AS size,
|
|
20
|
+
(SELECT COUNT(*) FROM run p WHERE p.task_ref = r.id AND p.role = 'planner') AS plan_runs,
|
|
21
|
+
(SELECT MIN(f.started_at) FROM run f WHERE f.task_ref = r.id) AS first_started,
|
|
22
|
+
(SELECT MIN(b.finished_at) FROM run b WHERE b.task_ref = r.id AND b.role = 'builder' AND b.outcome IN ('built', 'no-change') AND b.finished_at IS NOT NULL) AS result_at
|
|
23
|
+
FROM task_ref r
|
|
24
|
+
WHERE r.route_tier IS NOT NULL AND EXISTS (SELECT 1 FROM run x WHERE x.task_ref = r.id AND x.started_at >= ? AND x.started_at < ?)
|
|
25
|
+
ORDER BY r.id`).all(from, to);
|
|
26
|
+
const tasks = rows.map(row => ({
|
|
27
|
+
taskId: String(row["task"]),
|
|
28
|
+
tier: String(row["tier"]),
|
|
29
|
+
size: row["size"] == null ? null : String(row["size"]),
|
|
30
|
+
planRuns: Number(row["plan_runs"] ?? 0),
|
|
31
|
+
minutesToResult: row["first_started"] == null || row["result_at"] == null ? null : minutesBetween(String(row["first_started"]), String(row["result_at"])),
|
|
32
|
+
microusd: spendOf.get(String(row["task"])) ?? 0,
|
|
33
|
+
}));
|
|
34
|
+
const tiers = [...new Set(tasks.map(one => one.tier))].sort((a, b) => (TIER_ORDER.indexOf(a) + 1 || 99) - (TIER_ORDER.indexOf(b) + 1 || 99));
|
|
35
|
+
const byTier = tiers.map(tier => {
|
|
36
|
+
const mine = tasks.filter(one => one.tier === tier);
|
|
37
|
+
const done = mine.flatMap(one => (one.minutesToResult === null ? [] : [one.minutesToResult]));
|
|
38
|
+
return { tier, tasks: mine.length, results: done.length, medianMinutesToResult: median(done), planRuns: mine.reduce((sum, one) => sum + one.planRuns, 0), microusd: mine.reduce((sum, one) => sum + one.microusd, 0) };
|
|
39
|
+
});
|
|
40
|
+
const plansSkipped = tasks.filter(one => one.size === "small" && one.planRuns === 0).length;
|
|
41
|
+
const plannerMinutes = db.prepare("SELECT started_at, finished_at FROM run WHERE role = 'planner' AND finished_at IS NOT NULL AND started_at >= ? AND started_at < ?").all(from, to)
|
|
42
|
+
.flatMap(row => { const m = minutesBetween(String(row["started_at"]), String(row["finished_at"])); return m === null ? [] : [m]; });
|
|
43
|
+
const perPlan = median(plannerMinutes);
|
|
44
|
+
const light = byTier.find(one => one.tier === "light") ?? null;
|
|
45
|
+
const routine = byTier.find(one => one.tier === "routine") ?? null;
|
|
46
|
+
const sooner = light?.medianMinutesToResult != null && routine?.medianMinutesToResult != null && routine.medianMinutesToResult > light.medianMinutesToResult
|
|
47
|
+
? Math.round((routine.medianMinutesToResult - light.medianMinutesToResult) * 10) / 10 : null;
|
|
48
|
+
const perTask = (row) => (row === null || row.tasks === 0 || row.microusd <= 0 ? null : row.microusd / row.tasks);
|
|
49
|
+
const lightEach = light === null || light.tasks === 0 ? null : light.microusd / light.tasks;
|
|
50
|
+
const routineEach = perTask(routine);
|
|
51
|
+
const microusdSaved = light !== null && lightEach !== null && routineEach !== null && routineEach > lightEach ? Math.round((routineEach - lightEach) * light.tasks) : null;
|
|
52
|
+
return {
|
|
53
|
+
tasks,
|
|
54
|
+
rows: byTier,
|
|
55
|
+
saved: { plansSkipped, planMinutesSaved: perPlan === null || plansSkipped === 0 ? null : Math.round(perPlan * plansSkipped), minutesSoonerEach: sooner, microusdSaved },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const minutesWords = (minutes) => (minutes >= 90 ? `${Math.round(minutes / 6) / 10}h` : `${Math.round(minutes)}m`);
|
|
59
|
+
/** The spend report's lines: one per tier, then what the tiers saved. Empty when no routed task ran this month. */
|
|
60
|
+
export function tierLines(report, usd) {
|
|
61
|
+
if (report.rows.length === 0)
|
|
62
|
+
return [];
|
|
63
|
+
const lines = report.rows.map(row => ` ${`${row.tier} tier`.padEnd(24)} ${row.tasks} ${row.tasks === 1 ? "task" : "tasks"} · ${row.medianMinutesToResult === null ? "no result yet" : `${minutesWords(row.medianMinutesToResult)} to a result`} · ${row.planRuns} ${row.planRuns === 1 ? "plan" : "plans"} · ${usd(row.microusd)}`);
|
|
64
|
+
const saved = report.saved;
|
|
65
|
+
const parts = [
|
|
66
|
+
...(saved.plansSkipped === 0 ? [] : [`${saved.plansSkipped} ${saved.plansSkipped === 1 ? "plan" : "plans"} skipped${saved.planMinutesSaved === null ? "" : ` (~${minutesWords(saved.planMinutesSaved)} of planning)`}`]),
|
|
67
|
+
...(saved.minutesSoonerEach === null ? [] : [`light results ~${minutesWords(saved.minutesSoonerEach)} sooner each`]),
|
|
68
|
+
...(saved.microusdSaved === null ? [] : [`~${usd(saved.microusdSaved)} below the everyday tier`]),
|
|
69
|
+
];
|
|
70
|
+
return [...lines, ` ${"tiers saved".padEnd(24)} ${parts.length === 0 ? "nothing yet — no small change has run on the light tier" : parts.join(", ")}`];
|
|
71
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.29",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|