toolroll 0.9.90 → 0.9.92

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 (80) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/approval-authority.d.ts +9 -1
  3. package/dist/approval-authority.js +15 -2
  4. package/dist/build-review.js +2 -0
  5. package/dist/chat-actions.d.ts +11 -3
  6. package/dist/chat-actions.js +47 -2
  7. package/dist/chat-decide.js +2 -0
  8. package/dist/chat-review.d.ts +2 -1
  9. package/dist/chat-review.js +2 -2
  10. package/dist/contracts/chat-actions.d.ts +10 -0
  11. package/dist/contracts/chat-actions.js +4 -0
  12. package/dist/contracts/console-api.d.ts +1 -0
  13. package/dist/contracts/console-api.js +3 -0
  14. package/dist/contracts/route-results.d.ts +121 -0
  15. package/dist/contracts/route-results.js +71 -0
  16. package/dist/contracts/route.d.ts +343 -5
  17. package/dist/contracts/route.js +36 -7
  18. package/dist/contracts/store-json.d.ts +2 -0
  19. package/dist/dispose.js +6 -0
  20. package/dist/flow-engine.js +1 -1
  21. package/dist/flow-pull-request.js +1 -1
  22. package/dist/lead-doors.js +4 -2
  23. package/dist/lead-tools.js +10 -2
  24. package/dist/local-owner.d.ts +1 -1
  25. package/dist/local-owner.js +11 -0
  26. package/dist/memory-cli.d.ts +3 -0
  27. package/dist/memory-cli.js +3 -3
  28. package/dist/memory-pass.js +5 -1
  29. package/dist/model-choices.d.ts +7 -3
  30. package/dist/model-choices.js +26 -11
  31. package/dist/models-cli.d.ts +205 -0
  32. package/dist/models-cli.js +143 -4
  33. package/dist/models-ui.d.ts +57 -0
  34. package/dist/models-ui.js +44 -1
  35. package/dist/operate-remote-arguments.js +4 -4
  36. package/dist/operate-remote.js +1 -1
  37. package/dist/operate.js +53 -5
  38. package/dist/phase-routing.d.ts +82 -2
  39. package/dist/phase-routing.js +132 -18
  40. package/dist/project-delete.d.ts +4 -0
  41. package/dist/project-delete.js +12 -4
  42. package/dist/project-knowledge.d.ts +6 -6
  43. package/dist/project-memory.d.ts +8 -0
  44. package/dist/project-memory.js +20 -1
  45. package/dist/pull-request-flow.js +1 -1
  46. package/dist/release-adapters.d.ts +2 -0
  47. package/dist/release-adapters.js +30 -1
  48. package/dist/release-auto.js +12 -3
  49. package/dist/result-actions.d.ts +4 -0
  50. package/dist/result-actions.js +3 -0
  51. package/dist/route-hooks.d.ts +54 -0
  52. package/dist/route-hooks.js +179 -0
  53. package/dist/route-results.d.ts +70 -0
  54. package/dist/route-results.js +340 -0
  55. package/dist/route-rules.d.ts +324 -0
  56. package/dist/route-rules.js +781 -0
  57. package/dist/route-savings.d.ts +136 -0
  58. package/dist/route-savings.js +193 -0
  59. package/dist/route-store.d.ts +300 -0
  60. package/dist/route-store.js +598 -0
  61. package/dist/scope.d.ts +3 -1
  62. package/dist/scope.js +4 -1
  63. package/dist/server/render-chat.js +2 -2
  64. package/dist/server/render-tasks.d.ts +7 -0
  65. package/dist/server/render-tasks.js +13 -0
  66. package/dist/server/route-table.js +12 -0
  67. package/dist/server/settings.d.ts +2 -1
  68. package/dist/server/settings.js +87 -1
  69. package/dist/server/task-views.js +6 -0
  70. package/dist/server/tasks.js +3 -0
  71. package/dist/settings-proofs.js +2 -0
  72. package/dist/setup-guide.d.ts +2 -2
  73. package/dist/spend-ui.d.ts +11 -0
  74. package/dist/spend-ui.js +49 -2
  75. package/dist/store.d.ts +38 -2
  76. package/dist/store.js +320 -35
  77. package/dist/surface.js +16 -4
  78. package/dist/task-outcome-cli.js +5 -2
  79. package/dist/toolroll-update.js +3 -2
  80. package/package.json +1 -1
@@ -0,0 +1,136 @@
1
+ /**
2
+ * What earned rules and trials observed (docs/plans/results-routing.md, slice 4): the Spend page's "Suggestions and
3
+ * rules" block and the matching lines of `toolroll spend [--json]`. Read-only: it reads saved result records and rules,
4
+ * and never suggests, accepts, routes or switches anything.
5
+ *
6
+ * For each project and size, each live or recently ended rule or trial (and each standing suggestion) compares two
7
+ * sides over matched 90-day windows:
8
+ * - a rule or trial: the configured model's results in the 90 days before it was accepted, against the results built
9
+ * under that rule or trial (their sealed selection names it) in the 90 days from acceptance, to its end or now;
10
+ * - a suggestion: the configured model's results and the cheaper model's, over the same last 90 days.
11
+ *
12
+ * Cost rules (item 10): API dollars, plan usage and unknown cost are kept apart and never added together. The median is
13
+ * of API dollars a task over results priced wholly in API dollars, with "API-priced N of M" beside it; it is hidden
14
+ * (null, shown "—") unless each side has at least 5 such results. A result's spending includes its revision and repair
15
+ * runs (remediation). Prices are the snapshot sealed at acceptance (a suggestion's are the listed prices now). Unknown
16
+ * is never $0. The label is "observed, not a promise".
17
+ */
18
+ import { type Prices } from "./route-rules.js";
19
+ import type { Store } from "./store.js";
20
+ /** The window on each side, the priced results each side needs before a median shows, and when an open result's
21
+ * last evaluation reads as stale. */
22
+ export declare const SAVINGS_TERMS: Readonly<{
23
+ windowDays: 90;
24
+ minPriced: 5;
25
+ staleAfterHours: 24;
26
+ }>;
27
+ export declare const SAVINGS_LABEL = "observed, not a promise";
28
+ /** One result record as this view reads it. */
29
+ export type SavingsRecord = {
30
+ state: string;
31
+ firstStartedAt: string;
32
+ apiMicrousd: number;
33
+ subscriptionRuns: number;
34
+ subscriptionTokens: number;
35
+ unknownRuns: number;
36
+ remediationRuns: number;
37
+ closed: boolean;
38
+ evaluatedAt: string;
39
+ };
40
+ /** One side of a comparison. Every amount kind is counted on its own. */
41
+ export type SavingsSide = {
42
+ provider: string;
43
+ model: string | null;
44
+ name: string;
45
+ /** The window, [from, to). */
46
+ from: string;
47
+ to: string;
48
+ /** Results with an answer (accepted and waiting, counted, counted against); pending and not counted ones apart. */
49
+ results: number;
50
+ mature: number;
51
+ rejected: number;
52
+ provisional: number;
53
+ pending: number;
54
+ notCounted: number;
55
+ /** Results whose every run was priced in API dollars, and their median API dollars a task (null: hidden). */
56
+ apiPriced: number;
57
+ medianApiMicrousd: number | null;
58
+ /** Results with work on a plan (usage, not billed), with their runs and tokens. */
59
+ planResults: number;
60
+ planRuns: number;
61
+ planTokens: number;
62
+ /** Results with a run whose cost is unknown, and how many runs. Never $0. */
63
+ unknownResults: number;
64
+ unknownRuns: number;
65
+ /** Revision and repair runs, whose spending is included in their results'. */
66
+ remediationRuns: number;
67
+ };
68
+ export type SavingsKind = "rule" | "trial" | "suggestion";
69
+ export type SavingsRow = {
70
+ kind: SavingsKind;
71
+ id: string;
72
+ repo: string;
73
+ project: string;
74
+ size: "small" | "medium";
75
+ state: string;
76
+ status: string;
77
+ /** The configured model's side (before, for a rule or trial) and the cheaper model's (after). */
78
+ configured: SavingsSide;
79
+ cheaper: SavingsSide;
80
+ /** Both sides have at least SAVINGS_TERMS.minPriced API-priced results, so the medians show. */
81
+ enough: boolean;
82
+ prices: Prices | null;
83
+ pricesFrom: "accepted" | "listed-now";
84
+ words: string[];
85
+ };
86
+ export type SavingsReport = {
87
+ label: typeof SAVINGS_LABEL;
88
+ windowDays: number;
89
+ minPriced: number;
90
+ rows: SavingsRow[];
91
+ /** The oldest last evaluation of an open result shown, when it is older than SAVINGS_TERMS.staleAfterHours. */
92
+ staleSince: string | null;
93
+ };
94
+ /** Whether a result was priced wholly in API dollars: no plan run, no unknown run, and a recorded charge. */
95
+ export declare const apiPriced: (one: Pick<SavingsRecord, "apiMicrousd" | "subscriptionRuns" | "unknownRuns">) => boolean;
96
+ /** The median of whole micro-dollars, rounded; null for none. */
97
+ export declare function medianOf(values: readonly number[]): number | null;
98
+ /** One side's figures from its records. The median is computed here, and hidden by `compare` when either side is short. */
99
+ export declare function sideOf(who: {
100
+ provider: string;
101
+ model: string | null;
102
+ name: string;
103
+ }, window: {
104
+ from: string;
105
+ to: string;
106
+ }, records: readonly SavingsRecord[]): SavingsSide;
107
+ /** Two sides compared: both medians show only when each side has enough API-priced results; otherwise neither does. */
108
+ export declare function compare(configured: SavingsSide, cheaper: SavingsSide): {
109
+ configured: SavingsSide;
110
+ cheaper: SavingsSide;
111
+ enough: boolean;
112
+ };
113
+ /** Dollars a task: under a cent reads "<$0.01", never a false $0. */
114
+ export declare const dollars: (microusd: number) => string;
115
+ /** A side's figures in words: the median (or —), then API-priced, plan usage and unknown cost, each apart. */
116
+ export declare function sideWords(side: SavingsSide): {
117
+ median: string;
118
+ parts: string[];
119
+ };
120
+ /** The listed prices in words, per million tokens. */
121
+ export declare function pricesWords(row: Pick<SavingsRow, "prices" | "pricesFrom" | "configured" | "cheaper">): string;
122
+ /** What not having enough priced results means, in words. */
123
+ export declare function notEnoughWords(row: Pick<SavingsRow, "configured" | "cheaper">): string;
124
+ export declare const statusWords: (state: string) => string;
125
+ /** The row's own lines, as the command line prints them. */
126
+ export declare function rowLines(row: SavingsRow): string[];
127
+ /** The spend report's lines for this block: a heading and each row, or one line when there is nothing yet. */
128
+ export declare function savingsLines(report: SavingsReport | {
129
+ error: string;
130
+ }): string[];
131
+ /** The block for these projects, by project then size. */
132
+ export declare function savingsReport(store: Store, repos: readonly string[], now: Date): SavingsReport;
133
+ /** The block, or that it can't be read: an unreadable block never fails the Spend page or `toolroll spend`. */
134
+ export declare function savingsReportOrError(store: Store, repos: readonly string[], now: Date): SavingsReport | {
135
+ error: string;
136
+ };
@@ -0,0 +1,193 @@
1
+ /**
2
+ * What earned rules and trials observed (docs/plans/results-routing.md, slice 4): the Spend page's "Suggestions and
3
+ * rules" block and the matching lines of `toolroll spend [--json]`. Read-only: it reads saved result records and rules,
4
+ * and never suggests, accepts, routes or switches anything.
5
+ *
6
+ * For each project and size, each live or recently ended rule or trial (and each standing suggestion) compares two
7
+ * sides over matched 90-day windows:
8
+ * - a rule or trial: the configured model's results in the 90 days before it was accepted, against the results built
9
+ * under that rule or trial (their sealed selection names it) in the 90 days from acceptance, to its end or now;
10
+ * - a suggestion: the configured model's results and the cheaper model's, over the same last 90 days.
11
+ *
12
+ * Cost rules (item 10): API dollars, plan usage and unknown cost are kept apart and never added together. The median is
13
+ * of API dollars a task over results priced wholly in API dollars, with "API-priced N of M" beside it; it is hidden
14
+ * (null, shown "—") unless each side has at least 5 such results. A result's spending includes its revision and repair
15
+ * runs (remediation). Prices are the snapshot sealed at acceptance (a suggestion's are the listed prices now). Unknown
16
+ * is never $0. The label is "observed, not a promise".
17
+ */
18
+ import { modelName, rulesOf, suggestionsFor, trialsOf } from "./route-rules.js";
19
+ import { selectionSchema } from "./contracts/route-results.js";
20
+ const DAY = 24 * 60 * 60_000;
21
+ /** The window on each side, the priced results each side needs before a median shows, and when an open result's
22
+ * last evaluation reads as stale. */
23
+ export const SAVINGS_TERMS = Object.freeze({ windowDays: 90, minPriced: 5, staleAfterHours: 24 });
24
+ export const SAVINGS_LABEL = "observed, not a promise";
25
+ // ---- the figures (pure) --------------------------------------------------------------------------------------------
26
+ const ANSWERED = new Set(["provisional", "mature", "rejected"]);
27
+ /** Whether a result was priced wholly in API dollars: no plan run, no unknown run, and a recorded charge. */
28
+ export const apiPriced = (one) => one.unknownRuns === 0 && one.subscriptionRuns === 0 && one.apiMicrousd > 0;
29
+ /** The median of whole micro-dollars, rounded; null for none. */
30
+ export function medianOf(values) {
31
+ if (values.length === 0)
32
+ return null;
33
+ const sorted = [...values].sort((a, b) => a - b);
34
+ const mid = Math.floor(sorted.length / 2);
35
+ return sorted.length % 2 === 1 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
36
+ }
37
+ /** One side's figures from its records. The median is computed here, and hidden by `compare` when either side is short. */
38
+ export function sideOf(who, window, records) {
39
+ const answered = records.filter(one => ANSWERED.has(one.state));
40
+ const priced = answered.filter(apiPriced);
41
+ const plan = answered.filter(one => one.subscriptionRuns > 0);
42
+ const unknown = answered.filter(one => one.unknownRuns > 0);
43
+ const count = (state) => records.filter(one => one.state === state).length;
44
+ return {
45
+ ...who, ...window,
46
+ results: answered.length, mature: count("mature"), rejected: count("rejected"), provisional: count("provisional"), pending: count("pending"), notCounted: count("not-counted"),
47
+ apiPriced: priced.length, medianApiMicrousd: medianOf(priced.map(one => one.apiMicrousd)),
48
+ planResults: plan.length, planRuns: plan.reduce((sum, one) => sum + one.subscriptionRuns, 0), planTokens: plan.reduce((sum, one) => sum + one.subscriptionTokens, 0),
49
+ unknownResults: unknown.length, unknownRuns: unknown.reduce((sum, one) => sum + one.unknownRuns, 0),
50
+ remediationRuns: answered.reduce((sum, one) => sum + one.remediationRuns, 0),
51
+ };
52
+ }
53
+ /** Two sides compared: both medians show only when each side has enough API-priced results; otherwise neither does. */
54
+ export function compare(configured, cheaper) {
55
+ const enough = configured.apiPriced >= SAVINGS_TERMS.minPriced && cheaper.apiPriced >= SAVINGS_TERMS.minPriced;
56
+ return enough ? { configured, cheaper, enough } : { configured: { ...configured, medianApiMicrousd: null }, cheaper: { ...cheaper, medianApiMicrousd: null }, enough };
57
+ }
58
+ // ---- words -----------------------------------------------------------------------------------------------------------
59
+ /** Dollars a task: under a cent reads "<$0.01", never a false $0. */
60
+ export const dollars = (microusd) => microusd > 0 && microusd < 10_000 ? "<$0.01" : `$${(microusd / 1_000_000).toFixed(2)}`;
61
+ const each = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
62
+ const sizeWord = (size) => size === "small" ? "small" : "routine";
63
+ const day = (iso) => iso.slice(0, 10);
64
+ const perMillion = (n) => `$${Number.isInteger(n) ? n : n.toFixed(2)}`;
65
+ /** A side's figures in words: the median (or —), then API-priced, plan usage and unknown cost, each apart. */
66
+ export function sideWords(side) {
67
+ return {
68
+ median: side.medianApiMicrousd === null ? "—" : dollars(side.medianApiMicrousd),
69
+ parts: [
70
+ `API-priced ${side.apiPriced} of ${side.results}`,
71
+ side.planResults === 0 ? "plan usage —" : `plan usage ${each(side.planResults, "result")} (${each(side.planRuns, "run")})`,
72
+ side.unknownResults === 0 ? "unknown cost —" : `unknown cost ${each(side.unknownResults, "result")} (${each(side.unknownRuns, "run")})`,
73
+ `${each(side.remediationRuns, "revision or repair run")} included`,
74
+ ],
75
+ };
76
+ }
77
+ /** The listed prices in words, per million tokens. */
78
+ export function pricesWords(row) {
79
+ if (row.prices === null)
80
+ return "Listed prices: not recorded.";
81
+ const p = row.prices;
82
+ return `${row.pricesFrom === "accepted" ? "Listed prices when accepted" : "Listed prices now"}, per million tokens in / out: ${row.cheaper.name} ${perMillion(p.model.in)} / ${perMillion(p.model.out)}, ${row.configured.name} ${perMillion(p.configured.in)} / ${perMillion(p.configured.out)}.`;
83
+ }
84
+ /** What not having enough priced results means, in words. */
85
+ export function notEnoughWords(row) {
86
+ return `Not enough priced results yet: ${row.cheaper.apiPriced} and ${row.configured.apiPriced}; ${SAVINGS_TERMS.minPriced} on each side are needed before a median shows.`;
87
+ }
88
+ const STATUS_WORDS = {
89
+ active: "In use", trialling: "Trial running", paused: "Paused", "switched-off": "Switched itself off", "turned-off": "Turned off",
90
+ expired: "Ended", dropped: "Dropped", finished: "Trial finished", suggested: "Suggested",
91
+ };
92
+ export const statusWords = (state) => STATUS_WORDS[state] ?? state;
93
+ /** The row's own lines, as the command line prints them. */
94
+ export function rowLines(row) {
95
+ const sideLine = (label, side) => {
96
+ const words = sideWords(side);
97
+ return ` ${label.padEnd(10)} ${side.name}, ${day(side.from)} to ${day(side.to)}: median ${words.median} a task · ${words.parts.join(" · ")}`;
98
+ };
99
+ const before = row.kind === "suggestion" ? "configured" : "before";
100
+ const after = row.kind === "suggestion" ? "suggested" : "after";
101
+ return [
102
+ ` ${row.project} · ${sizeWord(row.size)} tasks · ${row.cheaper.name} instead of ${row.configured.name} · ${row.status}`,
103
+ sideLine(before, row.configured), sideLine(after, row.cheaper),
104
+ ...(row.enough ? [] : [` ${notEnoughWords(row)}`]),
105
+ ` ${pricesWords(row)}`,
106
+ ];
107
+ }
108
+ /** The spend report's lines for this block: a heading and each row, or one line when there is nothing yet. */
109
+ export function savingsLines(report) {
110
+ if ("error" in report)
111
+ return [" Suggestions and rules can't be read right now."];
112
+ if (report.rows.length === 0)
113
+ return [` ${"suggestions and rules".padEnd(24)} none yet`];
114
+ return [
115
+ `Suggestions and rules (${report.label}; ${report.windowDays} days a side; a median shows from ${report.minPriced} API-priced results a side)`,
116
+ ...(report.staleSince === null ? [] : [` Some results were last checked on ${day(report.staleSince)}; they update while Toolroll runs.`]),
117
+ ...report.rows.flatMap(rowLines),
118
+ ];
119
+ }
120
+ function recordsOf(store, repo, size, from, to) {
121
+ return store.handle.prepare(`SELECT state, first_started_at, api_microusd, subscription_runs, subscription_tokens, unknown_runs, remediation_runs, closed, evaluated_at,
122
+ provider, model, provenance_json FROM route_record WHERE repo = ? AND cohort_size = ? AND first_started_at >= ? AND first_started_at < ?`).all(repo, size, from, to)
123
+ .map(row => {
124
+ let selections = [];
125
+ try {
126
+ selections = JSON.parse(String(row["provenance_json"] ?? "[]")).flatMap(one => { const read = selectionSchema.safeParse(one); return read.success ? [read.data] : []; });
127
+ }
128
+ catch {
129
+ selections = [];
130
+ }
131
+ return {
132
+ state: String(row["state"]), firstStartedAt: String(row["first_started_at"]), apiMicrousd: Number(row["api_microusd"] ?? 0),
133
+ subscriptionRuns: Number(row["subscription_runs"] ?? 0), subscriptionTokens: Number(row["subscription_tokens"] ?? 0), unknownRuns: Number(row["unknown_runs"] ?? 0),
134
+ remediationRuns: Number(row["remediation_runs"] ?? 0), closed: Number(row["closed"]) === 1, evaluatedAt: String(row["evaluated_at"]),
135
+ provider: String(row["provider"] ?? ""), model: row["model"] == null ? null : String(row["model"]), selections,
136
+ };
137
+ });
138
+ }
139
+ const underRuleOrTrial = (one) => one.selections.some(s => s.kind === "rule" || s.kind === "trial");
140
+ const projectName = (repo) => repo.split("/").filter(Boolean).pop() ?? repo;
141
+ const iso = (ms) => new Date(ms).toISOString();
142
+ /** A rule's or trial's two sides: the configured model before acceptance, and its own results after. */
143
+ function ruleRow(store, rule, now, seen) {
144
+ const accepted = Date.parse(rule.acceptedAt);
145
+ const window = SAVINGS_TERMS.windowDays * DAY;
146
+ const beforeWindow = { from: iso(accepted - window), to: iso(accepted) };
147
+ const end = Math.min(rule.endedAt === null ? now.getTime() : Date.parse(rule.endedAt), accepted + window, now.getTime());
148
+ const afterWindow = { from: iso(accepted), to: iso(Math.max(accepted, end)) };
149
+ const before = recordsOf(store, rule.repo, rule.cohortSize, beforeWindow.from, beforeWindow.to)
150
+ .filter(one => one.provider === rule.configured.provider && one.model === rule.configured.model && !underRuleOrTrial(one));
151
+ // The results built under this rule or trial: their sealed selection names it.
152
+ const after = recordsOf(store, rule.repo, rule.cohortSize, afterWindow.from, afterWindow.to)
153
+ .filter(one => one.selections.some(s => (s.kind === "rule" || s.kind === "trial") && s.rule === rule.id));
154
+ seen.push(...before, ...after);
155
+ const configuredName = rule.configured.model === null ? "the configured model" : modelName(store, rule.configured.model);
156
+ const compared = compare(sideOf({ provider: rule.configured.provider, model: rule.configured.model, name: configuredName }, beforeWindow, before), sideOf({ provider: rule.provider, model: rule.model, name: modelName(store, rule.model) }, afterWindow, after));
157
+ const row = { kind: rule.kind, id: String(rule.id), repo: rule.repo, project: projectName(rule.repo), size: rule.cohortSize, state: rule.state, status: statusWords(rule.state),
158
+ ...compared, prices: rule.prices, pricesFrom: "accepted", words: [] };
159
+ return { ...row, words: rowLines(row) };
160
+ }
161
+ /** A project's rows: its rules and trials (live, and those that ended in the last 30 days), then what it suggests now. */
162
+ function projectRows(store, repo, now, seen) {
163
+ const rows = [...rulesOf(store, repo, now), ...trialsOf(store, repo, now)].map(rule => ruleRow(store, rule, now, seen));
164
+ const window = { from: iso(now.getTime() - SAVINGS_TERMS.windowDays * DAY), to: now.toISOString() };
165
+ for (const suggestion of suggestionsFor(store, repo, now)) {
166
+ const all = recordsOf(store, repo, suggestion.cohortSize, window.from, window.to).filter(one => one.provider === "claude");
167
+ const mine = all.filter(one => one.model === suggestion.model), standing = all.filter(one => one.model === suggestion.configured.model);
168
+ seen.push(...mine, ...standing);
169
+ const compared = compare(sideOf({ ...suggestion.configured }, window, standing), sideOf({ provider: suggestion.provider, model: suggestion.model, name: suggestion.name }, window, mine));
170
+ const row = { kind: "suggestion", id: suggestion.id, repo, project: projectName(repo), size: suggestion.cohortSize, state: "suggested", status: statusWords("suggested"),
171
+ ...compared, prices: suggestion.prices, pricesFrom: "listed-now", words: [] };
172
+ rows.push({ ...row, words: rowLines(row) });
173
+ }
174
+ const order = (one) => (one.kind === "suggestion" ? 2 : ["active", "trialling", "paused"].includes(one.state) ? 0 : 1);
175
+ return rows.sort((a, b) => (a.size === b.size ? 0 : a.size === "small" ? -1 : 1) || order(a) - order(b) || Number(b.id) - Number(a.id));
176
+ }
177
+ /** The block for these projects, by project then size. */
178
+ export function savingsReport(store, repos, now) {
179
+ const seen = [];
180
+ const rows = [...new Set(repos)].sort((a, b) => projectName(a).localeCompare(projectName(b)) || a.localeCompare(b)).flatMap(repo => projectRows(store, repo, now, seen));
181
+ const staleBefore = now.getTime() - SAVINGS_TERMS.staleAfterHours * 60 * 60_000;
182
+ const stale = seen.filter(one => !one.closed && Date.parse(one.evaluatedAt) < staleBefore).map(one => one.evaluatedAt).sort();
183
+ return { label: SAVINGS_LABEL, windowDays: SAVINGS_TERMS.windowDays, minPriced: SAVINGS_TERMS.minPriced, rows, staleSince: stale[0] ?? null };
184
+ }
185
+ /** The block, or that it can't be read: an unreadable block never fails the Spend page or `toolroll spend`. */
186
+ export function savingsReportOrError(store, repos, now) {
187
+ try {
188
+ return savingsReport(store, repos, now);
189
+ }
190
+ catch {
191
+ return { error: "unreadable" };
192
+ }
193
+ }
@@ -0,0 +1,300 @@
1
+ import { type ExecClass, type IneligibleReason, type RevisionOrigin, type Selection } from "./contracts/route-results.js";
2
+ import type { RecordValue } from "./route-results.js";
3
+ import type { EarnedRule, EarnedTrial } from "./phase-routing.js";
4
+ import { type Store } from "./store.js";
5
+ /** A failed run's raw reason, by whose account it failed. Anything not listed counts as the model's (conservative: a
6
+ * missing or unknown class counts against the cheaper model, never for it). */
7
+ export declare const EXEC_CLASS_OF_REASON: Readonly<Record<string, ExecClass>>;
8
+ /** The gateway's terminal classes that say the provider, not the model, ended the attempt. */
9
+ export declare const INFRASTRUCTURE_TERMINALS: ReadonlySet<string>;
10
+ /** The class a failed run's raw reason (before the tick's normalization) and its terminal class give. */
11
+ export declare function execClassOf(reason: string, terminalClass: string | null): ExecClass;
12
+ export type CohortRow = {
13
+ run: number;
14
+ taskRef: number;
15
+ rootRef: number;
16
+ repo: string | null;
17
+ role: "builder" | "repair";
18
+ size: "small" | "medium" | "large" | null;
19
+ risky: boolean | null;
20
+ qualityMode: "default" | "strict";
21
+ evidenceDemands: boolean;
22
+ publication: "none" | "notify" | "automerge";
23
+ release: boolean;
24
+ protected: boolean;
25
+ selection: Selection;
26
+ routeDigest: string;
27
+ provider: string;
28
+ model: string | null;
29
+ cohortSize: "small" | "medium" | null;
30
+ ineligible: IneligibleReason | null;
31
+ startedAt: string;
32
+ };
33
+ export type CohortInput = {
34
+ run: number;
35
+ taskRef: number;
36
+ role: "builder" | "repair";
37
+ routeDigest: string;
38
+ provider: string;
39
+ model: string | null;
40
+ qualityMode: "default" | "strict";
41
+ startedAt: string;
42
+ };
43
+ /** The sealed facts a code-writing run's eligibility is decided from, conservatively: anything unreadable or not
44
+ * sealed is not eligible. Pure reads of the store, in the admission transaction. */
45
+ export declare function cohortOf(store: Store, input: CohortInput): CohortRow;
46
+ /** Keep a failed run's execution class from its raw reason, before the tick normalizes it (dispose.ts). First write
47
+ * wins: a later ending of the same run never rewrites the class it was kept with. */
48
+ export declare function recordExecClass(store: Store, runId: number, reason: string): void;
49
+ /** Who a revision came from, written once with the revision's seal; never on a task that revises nothing. */
50
+ export declare function recordRevisionOrigin(store: Store, taskId: string, origin: RevisionOrigin): void;
51
+ /**
52
+ * A code-writing run's cohort snapshot, in its admission transaction (store.ts admitRun), written once and never
53
+ * updated. A snapshot that can't be computed is never a refused admission: the run is recorded as not eligible, and a
54
+ * write that fails leaves no row (its record then reads "started before results were recorded"), so a cheaper model
55
+ * never gains from either.
56
+ */
57
+ export declare function writeRunCohort(store: Store, input: CohortInput): void;
58
+ /** Save a family's record as evaluated now. A decision time is kept while the state stays the same. Once a verdict
59
+ * is final (settled) it never moves — state, reason, result run, acceptance and decision time stay as decided — but
60
+ * the family's spending and counts keep being updated from later revision and repair runs until the record closes
61
+ * (route-results.ts ACCUMULATE_FOR_MS); a closed record is never written again. */
62
+ export declare function saveResultRecord(store: Store, value: RecordValue, now: Date): void;
63
+ /** The generation of one (scope, phase) Models row set: 0 before anything changed it. */
64
+ export declare function modelsGenerationOf(store: Store, scope: string, phase: string): number;
65
+ /**
66
+ * Move a (scope, phase) generation: called by every Models row writer (store.ts), inside the writer's own
67
+ * transaction, whenever a row actually changed. Rules that depend on a generation that moved are paused in the same
68
+ * transaction (pauseDependentRules), so a configuration change can never leave a rule running on terms it no longer
69
+ * has.
70
+ */
71
+ export declare function bumpModelsGeneration(store: Store, scope: string, phase: string, now: Date): number;
72
+ /** Pause every running rule or trial whose recorded dependency generations no longer match: a compare-and-swap on
73
+ * the rule's own generation, so a rule another writer already moved is left to that writer. An unreadable
74
+ * dependency list pauses too. */
75
+ export declare function pauseDependentRules(store: Store, now: Date): number;
76
+ /** A deleted project's results-routing rows (project-delete.ts, through store.ts forgetProjectModels): its rules,
77
+ * their exposures and trial places, its records and its runs' cohort snapshots. How many rows went. */
78
+ export declare function forgetProjectRoutes(store: Store, repo: string): number;
79
+ /** A person who proved themselves for this act (model-choices.ts ModelAuthority's shape): the account, the credential
80
+ * generation the proof was checked against, how they proved it, and whether the session or login still stands. */
81
+ export type RulePerson = {
82
+ actor: string;
83
+ generation: number;
84
+ method: string;
85
+ live: () => boolean;
86
+ };
87
+ /** Who asks a rule act: a person, or this computer's local owner (local mode) inside the one act it may do. */
88
+ export type RuleDoor = {
89
+ kind: "person";
90
+ person: RulePerson;
91
+ } | {
92
+ kind: "local-owner";
93
+ actor: string;
94
+ };
95
+ /** The acts and the door each takes (docs/plans/results-routing.md, Authority). Raising acts take a person with a
96
+ * step-up; lowering acts take a person, or a local owner whose in-flight act is exactly the one named here. */
97
+ export declare const RULE_ACTS: Readonly<{
98
+ readonly accept: {
99
+ readonly raising: true;
100
+ readonly localAct: null;
101
+ };
102
+ readonly keep: {
103
+ readonly raising: true;
104
+ readonly localAct: null;
105
+ };
106
+ readonly "suggest-on": {
107
+ readonly raising: true;
108
+ readonly localAct: null;
109
+ };
110
+ readonly "turn-off": {
111
+ readonly raising: false;
112
+ readonly localAct: "routing-turn-off";
113
+ };
114
+ readonly dismiss: {
115
+ readonly raising: false;
116
+ readonly localAct: "dismiss-suggestion";
117
+ };
118
+ readonly "suggest-off": {
119
+ readonly raising: false;
120
+ readonly localAct: "dismiss-suggestion";
121
+ };
122
+ readonly "trial-start": {
123
+ readonly raising: true;
124
+ readonly localAct: null;
125
+ };
126
+ readonly "trial-stop": {
127
+ readonly raising: false;
128
+ readonly localAct: "trial-stop";
129
+ };
130
+ }>;
131
+ export type RuleAct = keyof typeof RULE_ACTS;
132
+ export type RuleRefusalReason = "lead-refused" | "authority";
133
+ /** A door proved inside the write's own transaction: minted only by proveRuleDoor, and spent only there. */
134
+ export type ProvedRuleDoor = {
135
+ readonly actor: string;
136
+ readonly act: RuleAct;
137
+ readonly via: "person" | "local-owner";
138
+ };
139
+ /**
140
+ * The rule doors' one proof, run inside the write's transaction: a lead token or a remote API token never acts
141
+ * (`lead-refused`); a person is re-proved — the same account, still active, still an instance operator who can open
142
+ * the project, at the credential generation the proof was made under, by a proof that still stands (model-choices.ts
143
+ * applyModelChoices' re-proof); a local owner only for a lowering act, only while it still stands, and only when its
144
+ * in-flight act is exactly that act's.
145
+ */
146
+ export declare function proveRuleDoor(store: Store, door: RuleDoor, act: RuleAct, repo: string): {
147
+ ok: true;
148
+ door: ProvedRuleDoor;
149
+ } | {
150
+ ok: false;
151
+ reason: RuleRefusalReason;
152
+ };
153
+ export type RuleInsert = {
154
+ repo: string;
155
+ cohortSize: "small" | "medium";
156
+ provider: string;
157
+ model: string;
158
+ configuredProvider: string;
159
+ configuredModel: string | null;
160
+ grant: unknown;
161
+ prices: unknown;
162
+ depends: Readonly<Record<string, number>>;
163
+ };
164
+ /** Insert an accepted earned rule, live. The winner index makes a second live rule for the same project and size
165
+ * refuse (null); nothing else is written. */
166
+ export declare function insertEarnedRule(store: Store, door: ProvedRuleDoor, rule: RuleInsert, now: Date): number | null;
167
+ /** A person's or local owner's end of a live rule — turned off, or a paused one dropped — by compare-and-swap on its
168
+ * generation. False when the rule moved since (or isn't live). */
169
+ export declare function endRuleByDoor(store: Store, door: ProvedRuleDoor, ruleId: number, generation: number, now: Date): boolean;
170
+ /** Keep a paused rule: live again, on the configuration generations it depends on now. Its sealed grant (terms,
171
+ * cutoff, expiry) never changes. */
172
+ export declare function keepPausedRule(store: Store, door: ProvedRuleDoor, ruleId: number, generation: number, depends: Readonly<Record<string, number>>): boolean;
173
+ /** "Not now" on one suggestion: kept as an ended offer (state dropped, reason not-now) so the same suggestion stays
174
+ * quiet for its 30 days; it never blocks a rule. */
175
+ export declare function recordNotNow(store: Store, door: ProvedRuleDoor, offer: {
176
+ repo: string;
177
+ cohortSize: "small" | "medium";
178
+ provider: string;
179
+ model: string;
180
+ configuredProvider: string;
181
+ configuredModel: string | null;
182
+ prices: unknown;
183
+ }, now: Date): void;
184
+ /** A project's "Suggest cheaper models" switch. False when the project isn't saved here. */
185
+ export declare function writeSuggestModels(store: Store, door: ProvedRuleDoor, repo: string, on: boolean): boolean;
186
+ /** The plane's own end of a rule (no person): switched off by its sealed trigger, paused (a configuration or grant
187
+ * change), or expired. A compare-and-swap on the rule's generation and a live state: false when it moved since. */
188
+ export declare function endRuleByPlane(store: Store, ruleId: number, generation: number, to: "switched-off" | "paused" | "expired", reason: string, now: Date): boolean;
189
+ /** The exposure rows a filing writes (store.ts saveScope): a build leg sealed under a rule opens the task's exposure
190
+ * to it (`proposed`) or, filed again, adds `refiled`; a filing that no longer selects a rule the task was exposed to
191
+ * adds `refiled` to that open exposure, which its approval then closes as approved with another model. Re-filing
192
+ * never adds a row. */
193
+ export declare function noteExposureFiled(store: Store, taskRef: number, selection: Selection | undefined, now: Date): void;
194
+ /** An approval closes the task's open exposures: as proposed when it sealed that very rule, otherwise approved with
195
+ * the model it sealed. */
196
+ export declare function noteExposureSealed(store: Store, taskRef: number, sealed: {
197
+ selection: Selection | undefined;
198
+ model: string;
199
+ }, now: Date): void;
200
+ /** A cancel closes the task's open exposures. */
201
+ export declare function noteExposureCancelled(store: Store, taskRef: number, now: Date): void;
202
+ /** A project's live earned rules, as routing reads them (phase-routing.ts EarnedRule): kind rule, state active, a
203
+ * Claude model. A row that doesn't read is left out (it can only ever not apply). */
204
+ export declare function liveEarnedRules(store: Store, repo: string | null): EarnedRule[];
205
+ /** Why a build of this task must not start under the rule its route sealed, or null: the rule switched itself off
206
+ * after the approval and the task hasn't started (docs/plans/results-routing.md, "When a rule ends"). A rule turned off
207
+ * by a person, or expired, keeps the sealed model; a started task's later attempts finish under it. */
208
+ export declare function ruleHoldOf(store: Store, taskId: string): {
209
+ rule: number;
210
+ model: string;
211
+ } | null;
212
+ export type TrialInsert = {
213
+ repo: string;
214
+ cohortSize: "small" | "medium";
215
+ model: string;
216
+ configuredProvider: string;
217
+ configuredModel: string | null;
218
+ limit: number;
219
+ grant: unknown;
220
+ prices: unknown;
221
+ depends: Readonly<Record<string, number>>;
222
+ };
223
+ /** Start a trial, live (state trialling) with its number of places. The winner index makes a second live rule or
224
+ * trial for the same project and size refuse (null); nothing else is written. */
225
+ export declare function insertTrial(store: Store, door: ProvedRuleDoor, trial: TrialInsert, now: Date): number | null;
226
+ /**
227
+ * A person's or local owner's stop of a live or paused trial, by compare-and-swap on its generation, in the same
228
+ * transaction as releasing the places of tasks not approved on them (those plans are re-filed by the rule pass). A
229
+ * place a task was approved on stays reserved: that task keeps the model a person approved (as a rule turned off
230
+ * does). False when the trial moved since.
231
+ */
232
+ export declare function endTrialByDoor(store: Store, door: ProvedRuleDoor, trialId: number, generation: number, now: Date): boolean;
233
+ /** A project's live trial for one size, as routing reads it (without this filing's place): state trialling, a Claude
234
+ * model. Null when none, or when its row doesn't read (it can only ever not apply). */
235
+ export declare function liveTrialFor(store: Store, repo: string | null, size: string | null | undefined): Omit<EarnedTrial, "reservation"> | null;
236
+ /**
237
+ * This task's place in a trial, in the filing's own write transaction (store.ts saveScope, serialized by
238
+ * beginWriteWithin): a place it already holds (reserved, or consumed by its own earlier attempt) is reused, so
239
+ * repeated saves never take a second one; a place it gave up is never taken back; otherwise a new one only while
240
+ * reserved + consumed is under the trial's limit and the trial is live. Its digest is bound by settleTrialPlaces in
241
+ * the same transaction. Null: no place.
242
+ */
243
+ export declare function reserveTrialPlace(store: Store, trialId: number, taskRef: number, now: Date): number | null;
244
+ /**
245
+ * After a filing, in its transaction: the place its route seals (if any) is bound to the scope digest it was filed
246
+ * under (a compare-and-swap on `reserved`; a place this task's own attempt already consumed stays as it is), and every
247
+ * other place this task holds unconsumed is released. A selection that names a place this task doesn't hold throws:
248
+ * nothing files on it.
249
+ */
250
+ export declare function settleTrialPlaces(store: Store, taskRef: number, selection: Selection | undefined, digest: string, now: Date): void;
251
+ /** A cancel gives up every place the task holds unconsumed (store.ts applyCancellationLocked). A consumed place is
252
+ * never released. How many went. */
253
+ export declare function releaseTrialPlaces(store: Store, taskRef: number, now: Date): number;
254
+ /**
255
+ * Why a seal of this route must not stand, or null (store.ts sealScopeApproval): a trial task is approved only by a
256
+ * person — their own ceremony (no basis; not a machine acting, not an AI approver's name) or their paired-chat tap —
257
+ * never a mode, a grant, a schedule, a carry, an automatic seal or the lead; and only while its place stands.
258
+ */
259
+ export declare function trialSealProblem(store: Store, input: {
260
+ taskRef: number | null;
261
+ selection: Selection | undefined;
262
+ digest: string;
263
+ basis: string | null;
264
+ by: string;
265
+ machine: boolean;
266
+ }): string | null;
267
+ /**
268
+ * A code-writing run's trial check, in its admission transaction after the run row exists (store.ts admitRun): for a
269
+ * route sealed on a trial, the approval must be a person's own (approval-authority.ts trialSealStandsSql), and the
270
+ * sealed place is consumed exactly once — a compare-and-swap on `reserved` and the sealed scope digest. A retry or
271
+ * repair of the same task whose place that task already consumed is admitted without consuming again. The words, or
272
+ * null to admit; the caller refuses (rolling the row back) on words.
273
+ */
274
+ export declare function admitTrialRun(store: Store, input: {
275
+ taskId: string;
276
+ taskRef: number;
277
+ role: string;
278
+ run: number;
279
+ now: Date;
280
+ }): string | null;
281
+ /** Consume one reserved place for this run: only with the sealed scope digest it was bound to. */
282
+ export declare function consumeTrialPlace(store: Store, reservation: number, sealedDigest: string, run: number, now: Date): boolean;
283
+ /** A trial's places: its limit, how many are reserved (waiting) and consumed (started). */
284
+ export declare function trialPlacesOf(store: Store, trialId: number): {
285
+ limit: number;
286
+ reserved: number;
287
+ consumed: number;
288
+ };
289
+ /** Which place in its trial this one is (1-based, counting places held or used, in order), or null. */
290
+ export declare function trialOrdinalOf(store: Store, reservation: number): {
291
+ trial: number;
292
+ ordinal: number;
293
+ state: string;
294
+ } | null;
295
+ /** Whether a proposal's trial place still stands for re-use by its next approval: the trial is live and the place is
296
+ * held reserved by that task (the rule pass re-files a proposal whose place doesn't). */
297
+ export declare function trialPlaceLive(store: Store, selection: {
298
+ rule: number;
299
+ reservation: number;
300
+ }, taskRef: number): boolean;