toolroll 0.9.91 → 0.9.93

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 (124) 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/assignment-status.d.ts +1 -1
  5. package/dist/assignment-ui.js +2 -2
  6. package/dist/assignment.js +11 -3
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/browser/workspace.js +30 -30
  9. package/dist/browser-crew.js +2 -2
  10. package/dist/browser-workspace.d.ts +3 -5
  11. package/dist/build-review.js +2 -0
  12. package/dist/builder.js +21 -2
  13. package/dist/chat-actions.d.ts +11 -3
  14. package/dist/chat-actions.js +47 -2
  15. package/dist/chat-decide.js +2 -0
  16. package/dist/chat-review.d.ts +2 -1
  17. package/dist/chat-review.js +2 -2
  18. package/dist/check-facts.d.ts +124 -0
  19. package/dist/check-facts.js +272 -0
  20. package/dist/check-report.d.ts +5 -0
  21. package/dist/check-report.js +4 -3
  22. package/dist/cli.js +5 -0
  23. package/dist/contracts/chat-actions.d.ts +10 -0
  24. package/dist/contracts/chat-actions.js +4 -0
  25. package/dist/contracts/console-api.d.ts +3 -2
  26. package/dist/contracts/console-api.js +5 -2
  27. package/dist/contracts/release-check.d.ts +25 -1
  28. package/dist/contracts/release-check.js +19 -0
  29. package/dist/contracts/route-results.d.ts +121 -0
  30. package/dist/contracts/route-results.js +71 -0
  31. package/dist/contracts/route.d.ts +343 -5
  32. package/dist/contracts/route.js +36 -7
  33. package/dist/contracts/store-json.d.ts +2 -0
  34. package/dist/desktop-update.d.ts +2 -2
  35. package/dist/desktop-update.js +13 -9
  36. package/dist/dispose.js +6 -0
  37. package/dist/eval-hooks.d.ts +55 -0
  38. package/dist/eval-hooks.js +227 -4
  39. package/dist/flaky.d.ts +17 -0
  40. package/dist/flaky.js +9 -0
  41. package/dist/flow-engine.js +1 -1
  42. package/dist/flow-pull-request.js +1 -1
  43. package/dist/lead-doors.js +4 -2
  44. package/dist/lead-tools.js +10 -2
  45. package/dist/local-owner.d.ts +1 -1
  46. package/dist/local-owner.js +11 -0
  47. package/dist/memory-cli.d.ts +3 -0
  48. package/dist/memory-cli.js +3 -3
  49. package/dist/memory-pass.js +5 -1
  50. package/dist/model-choices.d.ts +7 -3
  51. package/dist/model-choices.js +26 -11
  52. package/dist/models-cli.d.ts +205 -0
  53. package/dist/models-cli.js +143 -4
  54. package/dist/models-ui.d.ts +57 -0
  55. package/dist/models-ui.js +44 -1
  56. package/dist/needs-you.d.ts +7 -7
  57. package/dist/needs-you.js +12 -10
  58. package/dist/operate-remote-arguments.js +4 -4
  59. package/dist/operate-remote.js +1 -1
  60. package/dist/operate.js +191 -23
  61. package/dist/phase-routing.d.ts +82 -2
  62. package/dist/phase-routing.js +132 -18
  63. package/dist/project-delete.d.ts +4 -0
  64. package/dist/project-delete.js +16 -4
  65. package/dist/project-memory.d.ts +8 -0
  66. package/dist/project-memory.js +20 -1
  67. package/dist/pull-request-flow.js +1 -1
  68. package/dist/push.d.ts +10 -3
  69. package/dist/push.js +22 -6
  70. package/dist/release-adapters.js +11 -1
  71. package/dist/release-approval.d.ts +191 -0
  72. package/dist/release-approval.js +382 -0
  73. package/dist/release-auto.js +22 -4
  74. package/dist/release-card.d.ts +73 -4
  75. package/dist/release-card.js +168 -23
  76. package/dist/release-cli.js +9 -1
  77. package/dist/release.d.ts +30 -0
  78. package/dist/release.js +42 -3
  79. package/dist/result-actions.d.ts +4 -0
  80. package/dist/result-actions.js +3 -0
  81. package/dist/result-acts.d.ts +3 -3
  82. package/dist/result-acts.js +1 -1
  83. package/dist/route-hooks.d.ts +54 -0
  84. package/dist/route-hooks.js +179 -0
  85. package/dist/route-results.d.ts +70 -0
  86. package/dist/route-results.js +340 -0
  87. package/dist/route-rules.d.ts +324 -0
  88. package/dist/route-rules.js +781 -0
  89. package/dist/route-savings.d.ts +136 -0
  90. package/dist/route-savings.js +193 -0
  91. package/dist/route-store.d.ts +300 -0
  92. package/dist/route-store.js +598 -0
  93. package/dist/scope.d.ts +3 -1
  94. package/dist/scope.js +4 -1
  95. package/dist/server/guards.js +1 -0
  96. package/dist/server/pages-assets.js +1 -1
  97. package/dist/server/render-chat.js +2 -2
  98. package/dist/server/render-release.d.ts +17 -0
  99. package/dist/server/render-release.js +84 -0
  100. package/dist/server/render-results.d.ts +0 -2
  101. package/dist/server/render-results.js +4 -6
  102. package/dist/server/render-tasks.d.ts +7 -0
  103. package/dist/server/render-tasks.js +16 -3
  104. package/dist/server/route-table.d.ts +1 -1
  105. package/dist/server/route-table.js +19 -1
  106. package/dist/server/settings.d.ts +2 -1
  107. package/dist/server/settings.js +87 -1
  108. package/dist/server/task-views.js +6 -0
  109. package/dist/server/tasks.d.ts +15 -0
  110. package/dist/server/tasks.js +105 -20
  111. package/dist/settings-proofs.js +2 -0
  112. package/dist/spend-ui.d.ts +11 -0
  113. package/dist/spend-ui.js +49 -2
  114. package/dist/stop-proof.d.ts +67 -0
  115. package/dist/stop-proof.js +293 -0
  116. package/dist/store.d.ts +106 -31
  117. package/dist/store.js +653 -128
  118. package/dist/surface.js +17 -5
  119. package/dist/task-outcome-cli.js +24 -2
  120. package/dist/toolroll-update-ui.js +2 -2
  121. package/dist/toolroll-update.js +4 -2
  122. package/dist/work-index.js +7 -9
  123. package/dist/work-summary.d.ts +2 -2
  124. package/package.json +1 -1
@@ -0,0 +1,781 @@
1
+ /**
2
+ * Earned rules (docs/plans/results-routing.md, slice 2): suggesting a cheaper model from a project's own result
3
+ * records, accepting it as a rule with a bounded switch-back grant, and the rule switching itself off when its sealed
4
+ * terms trip. A rule is a separate layer: it never edits the owner's Models rows, and it routes only through the one
5
+ * precedence order (phase-routing.ts selectBuildLeg). Every task is still approved, and its route stays sealed.
6
+ *
7
+ * Doors (Authority): accepting, keeping a paused rule and switching suggestions on take a person with a step-up;
8
+ * turning a rule off, dropping a paused one, Not now and switching suggestions off also take this computer's local
9
+ * owner inside exactly that act. A lead token or a remote API token is refused. The writer (route-store.ts) re-proves
10
+ * the door inside the write's transaction.
11
+ *
12
+ * A policy path (release-auto.ts EVAL_POLICY): the thresholds, the grant's terms and the trigger decide when a model
13
+ * routes and when it stops, so a change here needs a person.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { INSTALLATION_SCOPE } from "./agentconfig.js";
17
+ import { canonicalJson } from "./canonical-json.js";
18
+ import { REASON_WORDS } from "./contracts/route-results.js";
19
+ import { checkScope, effectiveRoutes } from "./model-choices.js";
20
+ import { seenModel, seenModels } from "./model-catalog.js";
21
+ import { exactModelId, legOf, routeFromJson } from "./phase-routing.js";
22
+ import { endRuleByDoor, endRuleByPlane, endTrialByDoor, insertEarnedRule, insertTrial, keepPausedRule, liveEarnedRules, liveTrialFor, modelsGenerationOf, proveRuleDoor, recordNotNow, ruleHoldOf, trialOrdinalOf, trialPlaceLive, trialPlacesOf, writeSuggestModels, } from "./route-store.js";
23
+ import { BUILT_IN } from "./store.js";
24
+ // ---- the terms ----------------------------------------------------------------------------------------------------
25
+ /** The policy version a grant is sealed under: a grant sealed under another version pauses its rule. */
26
+ export const ROUTING_POLICY_VERSION = 1;
27
+ const DAY = 24 * 60 * 60_000;
28
+ /** When to suggest (Suggest and trigger terms): records of the last 90 days, the newest 20 decided. */
29
+ export const SUGGEST_TERMS = Object.freeze({ windowDays: 90, newest: 20, minDecided: 10, minMature: 0.9, lastClean: 3, configuredGap: 0.05, maxNotCounted: 1 / 3 });
30
+ /** How long a grant lasts, how long Not now keeps a suggestion quiet, and when Needs you says a grant ends soon. */
31
+ export const GRANT_DAYS = 90;
32
+ export const NOT_NOW_DAYS = 30;
33
+ export const EXPIRY_WARNING_DAYS = 7;
34
+ export function grantFor(now) {
35
+ return { policyVersion: ROUTING_POLICY_VERSION, cutoff: now.toISOString(), expires: new Date(now.getTime() + GRANT_DAYS * DAY).toISOString(),
36
+ reject: { window: 5, max: 1 }, mature: { window: 10, min: 0.8 }, declined: { window: 10, max: 2 } };
37
+ }
38
+ function readGrant(json) {
39
+ try {
40
+ const value = JSON.parse(String(json));
41
+ const pair = (one, key) => typeof one === "object" && one !== null && Number.isInteger(one["window"]) && typeof one[key] === "number";
42
+ if (typeof value.policyVersion !== "number" || typeof value.cutoff !== "string" || typeof value.expires !== "string"
43
+ || !Number.isFinite(Date.parse(value.cutoff)) || !Number.isFinite(Date.parse(value.expires))
44
+ || !pair(value.reject, "max") || !pair(value.mature, "min") || !pair(value.declined, "max"))
45
+ return null;
46
+ return value;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /** The switch-back terms in words, as the review shows them before acceptance and Settings after. */
53
+ export function grantWords(grant) {
54
+ return [
55
+ `It switches itself off if ${grant.reject.max + 1} of the latest ${grant.reject.window} decided tasks are rejected,`,
56
+ `if fewer than ${Math.round(grant.mature.min * 100)}% of the latest ${grant.mature.window} decided tasks are accepted and checked (once there are ${grant.mature.window}),`,
57
+ `or if ${grant.declined.max + 1} of the latest ${grant.declined.window} approvals picked another model.`,
58
+ `It ends on ${grant.expires.slice(0, 10)} unless you accept it again. Tasks already approved keep the model they were approved with.`,
59
+ ];
60
+ }
61
+ function readRule(row) {
62
+ const parse = (json) => { try {
63
+ return JSON.parse(String(json));
64
+ }
65
+ catch {
66
+ return null;
67
+ } };
68
+ return {
69
+ id: Number(row["id"]), repo: String(row["repo"]), cohortSize: row["cohort_size"] === "medium" ? "medium" : "small", kind: row["kind"] === "trial" ? "trial" : "rule",
70
+ provider: String(row["provider"]), model: String(row["model"]),
71
+ configured: { provider: String(row["configured_provider"]), model: row["configured_model"] == null ? null : String(row["configured_model"]) },
72
+ state: String(row["state"]), generation: Number(row["generation"]), grant: readGrant(row["grant_json"]),
73
+ prices: parse(row["prices_json"]), depends: parse(row["depends_json"]),
74
+ acceptedBy: String(row["accepted_by"]), acceptedAt: String(row["accepted_at"]),
75
+ endedAt: row["ended_at"] == null ? null : String(row["ended_at"]), endedBy: row["ended_by"] == null ? null : String(row["ended_by"]),
76
+ endReason: row["end_reason"] == null ? null : String(row["end_reason"]),
77
+ limit: row["trial_limit"] == null ? null : Number(row["trial_limit"]),
78
+ };
79
+ }
80
+ export function ruleById(store, id) {
81
+ const row = store.handle.prepare("SELECT * FROM route_rule WHERE id = ?").get(id);
82
+ return row === undefined ? null : readRule(row);
83
+ }
84
+ /** A project's rules a person sees: the live ones (active, paused) and, for 30 days, the ones that ended (a Not now
85
+ * offer is not a rule and is left out). */
86
+ export function rulesOf(store, repo, now) {
87
+ return store.handle.prepare(`SELECT * FROM route_rule WHERE repo = ? AND kind = 'rule' AND COALESCE(end_reason, '') != 'not-now'
88
+ AND (state IN ('active','trialling','paused') OR ended_at >= ?) ORDER BY cohort_size, id DESC`)
89
+ .all(repo, new Date(now.getTime() - NOT_NOW_DAYS * DAY).toISOString()).map(readRule);
90
+ }
91
+ /** Whether a project offers suggestions (its "Suggest cheaper models" switch; a project not saved here reads on). */
92
+ export function suggestModelsOn(store, repo) {
93
+ const row = store.handle.prepare("SELECT suggest_models FROM project WHERE path = ?").get(repo);
94
+ return row === undefined || Number(row["suggest_models"]) === 1;
95
+ }
96
+ /** The configuration generations a rule for this project depends on: the installation's and the project's build and
97
+ * repair rows. */
98
+ export function dependsOf(store, repo) {
99
+ const out = {};
100
+ for (const scope of [INSTALLATION_SCOPE, repo])
101
+ for (const phase of ["build", "repair"])
102
+ out[`${scope}|${phase}`] = modelsGenerationOf(store, scope, phase);
103
+ return out;
104
+ }
105
+ /** A cohort's records of one model, newest first (by their family's first code-writing run, then task id). */
106
+ function cohortRecords(store, repo, size, provider, model, since) {
107
+ return store.handle.prepare(`SELECT t.external_id AS task, r.state, r.reason, r.first_started_at, r.api_microusd, r.unknown_runs, r.subscription_runs
108
+ FROM route_record r JOIN task_ref t ON t.id = r.task_ref
109
+ WHERE r.repo = ? AND r.cohort_size = ? AND r.provider = ? AND r.model = ? AND r.first_started_at > ?
110
+ ORDER BY r.first_started_at DESC, t.external_id DESC`).all(repo, size, provider, model, since)
111
+ .map(row => ({ task: String(row["task"]), state: String(row["state"]), reason: row["reason"] == null ? null : String(row["reason"]), firstStartedAt: String(row["first_started_at"]),
112
+ apiMicrousd: Number(row["api_microusd"]), unknownRuns: Number(row["unknown_runs"]), subscriptionRuns: Number(row["subscription_runs"]) }));
113
+ }
114
+ const decidedOf = (rows) => rows.filter(one => one.state === "mature" || one.state === "rejected");
115
+ function evidenceOf(rows) {
116
+ const decided = decidedOf(rows).slice(0, SUGGEST_TERMS.newest);
117
+ const notCounted = rows.filter(one => one.state === "not-counted");
118
+ const reasons = new Map();
119
+ for (const one of notCounted)
120
+ if (one.reason !== null)
121
+ reasons.set(one.reason, (reasons.get(one.reason) ?? 0) + 1);
122
+ const priced = decided.filter(one => one.state === "mature" && one.unknownRuns === 0 && one.subscriptionRuns === 0 && one.apiMicrousd > 0).map(one => one.apiMicrousd).sort((a, b) => a - b);
123
+ const median = priced.length < 5 ? null : priced.length % 2 === 1 ? priced[(priced.length - 1) / 2] : Math.round((priced[priced.length / 2 - 1] + priced[priced.length / 2]) / 2);
124
+ return {
125
+ decided: decided.length, mature: decided.filter(one => one.state === "mature").length, rejected: decided.filter(one => one.state === "rejected").length,
126
+ notCounted: notCounted.length, notCountedReasons: [...reasons].map(([reason, count]) => ({ reason, count })).sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)),
127
+ total: rows.length, tasks: decided.map(one => one.task), medianMicrousd: median,
128
+ };
129
+ }
130
+ function pricesOf(store, provider, model) {
131
+ if (provider !== "claude")
132
+ return null;
133
+ const seen = seenModel(store, "claude", model);
134
+ return seen === null || seen.inputUsd === null || seen.outputUsd === null ? null : { in: seen.inputUsd, out: seen.outputUsd };
135
+ }
136
+ /** The model's own name when the catalog knows it ("Claude Haiku 5.5"), else its id. */
137
+ export function modelName(store, model) {
138
+ return seenModel(store, "claude", model)?.name ?? model;
139
+ }
140
+ const short = (value) => createHash("sha256").update(canonicalJson(value)).digest("hex").slice(0, 12);
141
+ const sizeWord = (size) => size === "small" ? "small" : "routine";
142
+ /** One cohort's configured builder now (the configured rows alone, never a rule), or null when it can't be read. */
143
+ function configuredBuilder(store, repo, size) {
144
+ const routes = effectiveRoutes(store, repo, { rules: false });
145
+ if (!routes.ok)
146
+ return null;
147
+ const route = routes.routes.find(one => one.label === (size === "small" ? "small" : "routine"))?.route;
148
+ const leg = route === undefined ? null : legOf(route, "build");
149
+ return leg === null ? null : { provider: leg.provider, model: leg.model };
150
+ }
151
+ /** What an offer for this cohort and model would say now, whether or not it qualifies: the parts its digests bind,
152
+ * and why it doesn't qualify (null: it does). */
153
+ export function offerNow(store, repo, size, model, now) {
154
+ const configured = configuredBuilder(store, repo, size);
155
+ if (configured === null)
156
+ return { suggestion: null, why: "the configured builder can't be read", parts: null };
157
+ if (configured.provider !== "claude")
158
+ return { suggestion: null, why: "only Claude reports the model it ran, so only a Claude builder can earn a rule", parts: null };
159
+ if (model === configured.model)
160
+ return { suggestion: null, why: "that is the configured builder already", parts: null };
161
+ const since = new Date(now.getTime() - SUGGEST_TERMS.windowDays * DAY).toISOString();
162
+ const rows = cohortRecords(store, repo, size, "claude", model, since);
163
+ const evidence = evidenceOf(rows);
164
+ const configuredEvidence = evidenceOf(cohortRecords(store, repo, size, "claude", configured.model, since));
165
+ const cheaper = pricesOf(store, "claude", model), standing = pricesOf(store, "claude", configured.model);
166
+ const prices = cheaper === null || standing === null ? null : { model: cheaper, configured: standing };
167
+ const depends = dependsOf(store, repo);
168
+ const parts = { evidence: short({ repo, size, model, configured, rows: rows.map(one => [one.task, one.state, one.reason]), standing: decidedOf(cohortRecords(store, repo, size, "claude", configured.model, since)).slice(0, SUGGEST_TERMS.newest).map(one => [one.task, one.state]) }),
169
+ prices: short(prices), depends: short(depends) };
170
+ const decided = decidedOf(rows).slice(0, SUGGEST_TERMS.newest);
171
+ const share = (one) => one.decided === 0 ? 0 : one.mature / one.decided;
172
+ const why = evidence.decided < SUGGEST_TERMS.minDecided ? `${evidence.decided} decided tasks so far; ${SUGGEST_TERMS.minDecided} are needed`
173
+ : share(evidence) < SUGGEST_TERMS.minMature ? "fewer than 90% of its decided tasks were accepted with checks passing"
174
+ : decided.slice(0, SUGGEST_TERMS.lastClean).some(one => one.state === "rejected") ? "one of its latest 3 decided tasks was rejected"
175
+ : configuredEvidence.decided >= SUGGEST_TERMS.minDecided && share(configuredEvidence) - share(evidence) > SUGGEST_TERMS.configuredGap + 1e-9 ? "the configured model does more than 5 points better here"
176
+ : evidence.total > 0 && evidence.notCounted / evidence.total > SUGGEST_TERMS.maxNotCounted + 1e-9 ? "more than a third of its tasks couldn't be counted"
177
+ : prices === null ? "one of the two models has no listed price"
178
+ : !(prices.model.in < prices.configured.in && prices.model.out < prices.configured.out) ? "it isn't cheaper than the configured model on both input and output"
179
+ : null;
180
+ if (why !== null)
181
+ return { suggestion: null, why, parts };
182
+ return {
183
+ suggestion: {
184
+ id: `${size}:${model}:${parts.evidence.slice(0, 4)}${parts.prices.slice(0, 4)}${parts.depends.slice(0, 4)}`,
185
+ repo, cohortSize: size, provider: "claude", model, name: modelName(store, model),
186
+ configured: { ...configured, name: modelName(store, configured.model) }, evidence, configuredEvidence, prices: prices, depends, offer: parts, grant: grantFor(now),
187
+ },
188
+ why: null, parts,
189
+ };
190
+ }
191
+ /** A suggestion id read back: its cohort, model and the three digest prefixes it was offered under. */
192
+ export function readSuggestionId(id) {
193
+ const match = /^(small|medium):([A-Za-z0-9][A-Za-z0-9._/-]{0,127}):([0-9a-f]{12})$/.exec(id);
194
+ if (match === null)
195
+ return null;
196
+ return { size: match[1], model: match[2], evidence: match[3].slice(0, 4), prices: match[3].slice(4, 8), depends: match[3].slice(8, 12) };
197
+ }
198
+ /** Whether a Not now on this cohort and model is still within its 30 days. */
199
+ function notNowStands(store, repo, size, model, now) {
200
+ return store.handle.prepare(`SELECT 1 FROM route_rule WHERE repo = ? AND cohort_size = ? AND model = ? AND state = 'dropped' AND end_reason = 'not-now' AND ended_at > ? LIMIT 1`)
201
+ .get(repo, size, model, new Date(now.getTime() - NOT_NOW_DAYS * DAY).toISOString()) !== undefined;
202
+ }
203
+ /**
204
+ * The suggestions a project offers now: per size (small, routine), the cheapest Claude model whose records here earn
205
+ * it, unless a rule for that size is live, the project's switch is off, or a person said Not now within 30 days.
206
+ */
207
+ export function suggestionsFor(store, repo, now) {
208
+ if (!suggestModelsOn(store, repo))
209
+ return [];
210
+ const out = [];
211
+ for (const size of ["small", "medium"]) {
212
+ if (store.handle.prepare("SELECT 1 FROM route_rule WHERE repo = ? AND cohort_size = ? AND state IN ('active','trialling','paused')").get(repo, size) !== undefined)
213
+ continue;
214
+ const since = new Date(now.getTime() - SUGGEST_TERMS.windowDays * DAY).toISOString();
215
+ const models = store.handle.prepare(`SELECT DISTINCT model FROM route_record WHERE repo = ? AND cohort_size = ? AND provider = 'claude' AND model IS NOT NULL AND first_started_at > ? ORDER BY model`)
216
+ .all(repo, size, since).map(row => String(row["model"]));
217
+ const offers = models.filter(model => !notNowStands(store, repo, size, model, now)).flatMap(model => { const one = offerNow(store, repo, size, model, now).suggestion; return one === null ? [] : [one]; });
218
+ offers.sort((a, b) => a.prices.model.in - b.prices.model.in || a.prices.model.out - b.prices.model.out || a.model.localeCompare(b.model));
219
+ if (offers[0] !== undefined)
220
+ out.push(offers[0]);
221
+ }
222
+ return out;
223
+ }
224
+ /** A suggestion in plain words, the evidence first. */
225
+ export function suggestionWords(suggestion) {
226
+ const e = suggestion.evidence;
227
+ return `${suggestion.name} for ${sizeWord(suggestion.cohortSize)} tasks instead of ${suggestion.configured.name}: ${e.mature} of the last ${e.decided} decided similar tasks were accepted with checks passing${notCountedWords(e)}.`;
228
+ }
229
+ function notCountedWords(e) {
230
+ if (e.notCounted === 0)
231
+ return "";
232
+ const reasons = e.notCountedReasons.slice(0, 2).map(one => REASON_WORDS[one.reason]).join("; ");
233
+ return ` (${e.notCounted} more not counted${reasons === "" ? "" : `: ${reasons}`})`;
234
+ }
235
+ const dollarsOf = (micro) => `$${(micro / 1_000_000).toFixed(2)}`;
236
+ const REFUSAL_WORDS = {
237
+ "lead-refused": "A lead or an API token can't change which models run. A person does this.",
238
+ authority: "Only an instance operator who can open this project, signed in now, can do this.",
239
+ "stale-suggestion": "This suggestion is out of date: its results changed. Nothing was saved. Review the current suggestion.",
240
+ "price-changed": "A listed price changed since this suggestion was made. Nothing was saved. Review it again.",
241
+ "config-changed": "Models changed since this suggestion was made. Nothing was saved. Review it again.",
242
+ invalid: "This rule can't run with the current models. Nothing was saved.",
243
+ taken: "Another model already has a rule for these tasks. Nothing was saved.",
244
+ "not-found": "That rule or suggestion isn't here any more.",
245
+ "unknown-project": "That isn't a project here.",
246
+ };
247
+ const refused = (reason, problem = REFUSAL_WORDS[reason]) => ({ ok: false, reason, problem });
248
+ class Refused extends Error {
249
+ refusal;
250
+ constructor(refusal) {
251
+ super(refusal.problem);
252
+ this.refusal = refusal;
253
+ }
254
+ }
255
+ /** One door act in one transaction, with a savepoint so a refusal rolls back its writes even inside a caller's. */
256
+ function act(store, body) {
257
+ try {
258
+ return store.transact(() => {
259
+ store.handle.exec("SAVEPOINT route_rules");
260
+ try {
261
+ const result = body();
262
+ store.handle.exec("RELEASE route_rules");
263
+ return result;
264
+ }
265
+ catch (error) {
266
+ store.handle.exec("ROLLBACK TO route_rules");
267
+ store.handle.exec("RELEASE route_rules");
268
+ throw error;
269
+ }
270
+ });
271
+ }
272
+ catch (error) {
273
+ if (error instanceof Refused)
274
+ return error.refusal;
275
+ throw error;
276
+ }
277
+ }
278
+ function prove(store, door, which, repo) {
279
+ const proved = proveRuleDoor(store, door, which, repo);
280
+ if (!proved.ok)
281
+ throw new Refused(refused(proved.reason));
282
+ return proved.door;
283
+ }
284
+ const known = (store, repo) => store.knownRepos().includes(repo) || store.handle.prepare("SELECT 1 FROM project WHERE path = ?").get(repo) !== undefined;
285
+ /**
286
+ * Accept a suggestion as a rule, in one transaction: the person re-proved; the offer still earned, at the same
287
+ * prices and on the same configuration generations it was offered under; the configuration valid with the rule
288
+ * added (checkScope); and the size's one winner free. A refusal names which: `stale-suggestion`, `price-changed`,
289
+ * `config-changed`, `invalid` or `taken`.
290
+ */
291
+ export function acceptSuggestion(store, input, door, now) {
292
+ return act(store, () => {
293
+ if (!known(store, input.repo))
294
+ throw new Refused(refused("unknown-project"));
295
+ const proved = prove(store, door, "accept", input.repo);
296
+ const asked = readSuggestionId(input.id);
297
+ if (asked === null)
298
+ throw new Refused(refused("not-found"));
299
+ if (store.handle.prepare("SELECT 1 FROM route_rule WHERE repo = ? AND cohort_size = ? AND state IN ('active','trialling','paused')").get(input.repo, asked.size) !== undefined)
300
+ throw new Refused(refused("taken"));
301
+ const now_ = offerNow(store, input.repo, asked.size, asked.model, now);
302
+ if (now_.parts !== null && now_.parts.depends.slice(0, 4) !== asked.depends)
303
+ throw new Refused(refused("config-changed"));
304
+ if (now_.parts !== null && now_.parts.prices.slice(0, 4) !== asked.prices)
305
+ throw new Refused(refused("price-changed"));
306
+ if (now_.suggestion === null || now_.parts === null || now_.parts.evidence.slice(0, 4) !== asked.evidence || !suggestModelsOn(store, input.repo))
307
+ throw new Refused(refused("stale-suggestion"));
308
+ const suggestion = now_.suggestion;
309
+ const candidate = { id: 0, generation: 1, cohortSize: suggestion.cohortSize, provider: "claude", model: suggestion.model, configured: { provider: suggestion.configured.provider, model: suggestion.configured.model } };
310
+ const checked = checkScope(store, input.repo, { rules: [...liveEarnedRules(store, input.repo), candidate] });
311
+ if (checked.kind !== "ok")
312
+ throw new Refused(refused("invalid", `${REFUSAL_WORDS.invalid} ${checked.problem}`));
313
+ const rule = insertEarnedRule(store, proved, { repo: input.repo, cohortSize: suggestion.cohortSize, provider: "claude", model: suggestion.model,
314
+ configuredProvider: suggestion.configured.provider, configuredModel: suggestion.configured.model, grant: suggestion.grant, prices: suggestion.prices, depends: suggestion.depends }, now);
315
+ if (rule === null)
316
+ throw new Refused(refused("taken"));
317
+ store.recordAction({ at: now.toISOString(), actor: proved.actor, repo: input.repo, taskId: null, runId: null, action: "earned rule accepted", outcome: "changed", source: "policy",
318
+ detail: `${sizeWord(suggestion.cohortSize)} tasks build on ${suggestion.model} instead of ${suggestion.configured.model} (rule ${rule})` });
319
+ return { ok: true, rule, suggestion };
320
+ });
321
+ }
322
+ /** Turn a live rule off, or drop a paused one. Tasks already approved keep the model they were approved with. */
323
+ export function turnOffRule(store, ruleId, door, now) {
324
+ return act(store, () => {
325
+ const rule = ruleById(store, ruleId);
326
+ if (rule === null || rule.kind !== "rule" || !["active", "trialling", "paused"].includes(rule.state))
327
+ throw new Refused(refused("not-found"));
328
+ const proved = prove(store, door, "turn-off", rule.repo);
329
+ if (!endRuleByDoor(store, proved, rule.id, rule.generation, now))
330
+ throw new Refused(refused("not-found"));
331
+ store.recordAction({ at: now.toISOString(), actor: proved.actor, repo: rule.repo, taskId: null, runId: null, action: rule.state === "paused" ? "earned rule dropped" : "earned rule turned off",
332
+ outcome: "changed", source: "policy", detail: `${sizeWord(rule.cohortSize)} tasks build on the configured model again (rule ${rule.id})` });
333
+ resolveRuleNotices(store, rule.id, now);
334
+ return { ok: true, rule: ruleById(store, rule.id) };
335
+ });
336
+ }
337
+ /** Keep a paused rule: live again on the configuration as it stands, if that configuration can run it. */
338
+ export function keepRule(store, ruleId, door, now) {
339
+ return act(store, () => {
340
+ const rule = ruleById(store, ruleId);
341
+ if (rule === null || rule.kind !== "rule" || rule.state !== "paused")
342
+ throw new Refused(refused("not-found"));
343
+ const proved = prove(store, door, "keep", rule.repo);
344
+ if (rule.grant === null || rule.grant.policyVersion !== ROUTING_POLICY_VERSION || Date.parse(rule.grant.expires) <= now.getTime())
345
+ throw new Refused(refused("stale-suggestion", "This rule's terms ended. Nothing was saved."));
346
+ const configured = configuredBuilder(store, rule.repo, rule.cohortSize);
347
+ if (configured === null || configured.provider !== rule.configured.provider || configured.model !== rule.configured.model) {
348
+ throw new Refused(refused("config-changed", "The configured builder for these tasks changed, so this rule's evidence no longer compares with it. Drop it; a new suggestion follows from new results."));
349
+ }
350
+ const candidate = { id: rule.id, generation: rule.generation + 1, cohortSize: rule.cohortSize, provider: "claude", model: rule.model, configured: rule.configured };
351
+ const checked = checkScope(store, rule.repo, { rules: [...liveEarnedRules(store, rule.repo), candidate] });
352
+ if (checked.kind !== "ok")
353
+ throw new Refused(refused("invalid", `${REFUSAL_WORDS.invalid} ${checked.problem}`));
354
+ if (!keepPausedRule(store, proved, rule.id, rule.generation, dependsOf(store, rule.repo)))
355
+ throw new Refused(refused("not-found"));
356
+ store.recordAction({ at: now.toISOString(), actor: proved.actor, repo: rule.repo, taskId: null, runId: null, action: "earned rule kept", outcome: "changed", source: "policy",
357
+ detail: `${sizeWord(rule.cohortSize)} tasks build on ${rule.model} again (rule ${rule.id})` });
358
+ resolveRuleNotices(store, rule.id, now);
359
+ return { ok: true, rule: ruleById(store, rule.id) };
360
+ });
361
+ }
362
+ /** Not now: the suggestion stays quiet for 30 days. */
363
+ export function dismissSuggestion(store, input, door, now) {
364
+ return act(store, () => {
365
+ if (!known(store, input.repo))
366
+ throw new Refused(refused("unknown-project"));
367
+ const proved = prove(store, door, "dismiss", input.repo);
368
+ const asked = readSuggestionId(input.id) ?? readTrialOfferId(input.id);
369
+ if (asked === null)
370
+ throw new Refused(refused("not-found"));
371
+ const configured = configuredBuilder(store, input.repo, asked.size);
372
+ recordNotNow(store, proved, { repo: input.repo, cohortSize: asked.size, provider: "claude", model: asked.model, configuredProvider: configured?.provider ?? "claude",
373
+ configuredModel: configured?.model ?? null, prices: { model: pricesOf(store, "claude", asked.model), configured: configured === null ? null : pricesOf(store, configured.provider, configured.model) } }, now);
374
+ return { ok: true };
375
+ });
376
+ }
377
+ /** A project's "Suggest cheaper models" switch: on takes a person's step-up, off also the local owner. */
378
+ export function setSuggestModels(store, repo, on, door) {
379
+ return act(store, () => {
380
+ const proved = prove(store, door, on ? "suggest-on" : "suggest-off", repo);
381
+ if (!writeSuggestModels(store, proved, repo, on))
382
+ throw new Refused(refused("unknown-project"));
383
+ return { ok: true };
384
+ });
385
+ }
386
+ /** Whether a grant's sealed terms trip on these decided records (newest first) and closed exposures (newest first,
387
+ * true = approved with another model). Pure. Pending and provisional records are never passed in. */
388
+ export function tripOf(grant, decided, exposures) {
389
+ const rejected = decided.slice(0, grant.reject.window).filter(one => one === "rejected").length;
390
+ if (rejected > grant.reject.max)
391
+ return { trips: true, why: `${rejected} of the latest ${Math.min(grant.reject.window, decided.length)} decided tasks were rejected` };
392
+ if (decided.length >= grant.mature.window) {
393
+ const latest = decided.slice(0, grant.mature.window);
394
+ const mature = latest.filter(one => one === "mature").length;
395
+ if (mature / latest.length < grant.mature.min)
396
+ return { trips: true, why: `only ${mature} of the latest ${latest.length} decided tasks were accepted with checks passing` };
397
+ }
398
+ const declined = exposures.slice(0, grant.declined.window).filter(Boolean).length;
399
+ if (declined > grant.declined.max)
400
+ return { trips: true, why: `${declined} of the latest ${Math.min(grant.declined.window, exposures.length)} approvals picked another model` };
401
+ return { trips: false };
402
+ }
403
+ /** The evidence a rule's trigger reads: its model's decided records in its cohort since the grant's cutoff, and its
404
+ * closed exposures, both newest first and in a fixed order. */
405
+ function triggerInputs(store, rule, grant) {
406
+ const decided = decidedOf(cohortRecords(store, rule.repo, rule.cohortSize, rule.provider, rule.model, grant.cutoff)).map(one => one.state);
407
+ const exposures = store.handle.prepare(`SELECT e.id, (SELECT kind FROM route_exposure_event c WHERE c.exposure = e.id AND c.kind IN ('approved-as-proposed','approved-with-other','cancelled') ORDER BY c.id LIMIT 1) AS closer,
408
+ (SELECT MIN(p.id) FROM route_exposure_event p WHERE p.exposure = e.id AND p.kind = 'proposed') AS first
409
+ FROM route_exposure e WHERE e.rule = ? AND e.closed_at IS NOT NULL AND e.created_at >= ? ORDER BY first DESC, e.id DESC`).all(rule.id, grant.cutoff)
410
+ .map(row => row["closer"] === "approved-with-other");
411
+ return { decided, exposures };
412
+ }
413
+ /**
414
+ * One rule's check, as ONE transaction: re-read the rule and its generation; any configuration generation it depends
415
+ * on that moved pauses it (never a switch-off on terms it no longer has); an ended grant ends it, a grant sealed under
416
+ * another policy or by an account that's gone pauses it; then the trigger, recomputed from records with the sealed
417
+ * terms, switches it off by compare-and-swap on its generation — and the configuration is checked without it, or the
418
+ * switch-off rolls back and the rule pauses instead.
419
+ */
420
+ export function switchOffIfTripped(store, ruleId, now) {
421
+ return store.transact(() => {
422
+ const rule = ruleById(store, ruleId);
423
+ if (rule === null || rule.state !== "active" || rule.kind !== "rule")
424
+ return { outcome: "moved", why: null };
425
+ const pause = (why) => endRuleByPlane(store, rule.id, rule.generation, "paused", why, now) ? { outcome: "paused", why } : { outcome: "moved", why: null };
426
+ const depends = rule.depends ?? {};
427
+ const moved = Object.keys(depends).length === 0 || Object.entries(depends).some(([key, generation]) => {
428
+ const at = key.lastIndexOf("|");
429
+ return at <= 0 || modelsGenerationOf(store, key.slice(0, at), key.slice(at + 1)) !== generation;
430
+ });
431
+ if (moved)
432
+ return pause("config-changed");
433
+ const grant = rule.grant;
434
+ if (grant === null)
435
+ return pause("grant-unreadable");
436
+ if (Date.parse(grant.expires) <= now.getTime())
437
+ return endRuleByPlane(store, rule.id, rule.generation, "expired", "grant-ended", now) ? { outcome: "expired", why: "its 90 days ended" } : { outcome: "moved", why: null };
438
+ if (grant.policyVersion !== ROUTING_POLICY_VERSION)
439
+ return pause("policy-changed");
440
+ const granter = store.accountOf(rule.acceptedBy);
441
+ if (granter === null || granter.revokedAt !== null)
442
+ return pause("granter-gone");
443
+ const inputs = triggerInputs(store, rule, grant);
444
+ const trip = tripOf(grant, inputs.decided, inputs.exposures);
445
+ if (!trip.trips)
446
+ return { outcome: "kept", why: null };
447
+ store.handle.exec("SAVEPOINT switch_off");
448
+ if (!endRuleByPlane(store, rule.id, rule.generation, "switched-off", trip.why, now)) {
449
+ store.handle.exec("RELEASE switch_off");
450
+ return { outcome: "moved", why: null };
451
+ }
452
+ if (checkScope(store, rule.repo).kind === "invalid") {
453
+ store.handle.exec("ROLLBACK TO switch_off");
454
+ store.handle.exec("RELEASE switch_off");
455
+ return pause("config-invalid");
456
+ }
457
+ store.handle.exec("RELEASE switch_off");
458
+ return { outcome: "switched-off", why: trip.why };
459
+ });
460
+ }
461
+ // ---- the pass, held tasks, Needs you -------------------------------------------------------------------------------
462
+ /** The selection a stored route's build leg carries (route v3), or undefined. */
463
+ function selectionOf(json) {
464
+ const route = typeof json === "string" ? routeFromJson(json) : null;
465
+ return route === null ? undefined : legOf(route, "build").selection;
466
+ }
467
+ const settingsHref = (repo) => `/settings/models?scope=${encodeURIComponent(repo)}#models-suggested`;
468
+ function notify(store, repo, dedupeKey, subject, body, now, link = settingsHref(repo)) {
469
+ store.enqueueNotification({ dedupeKey, kind: "routing", subject, body, pushClass: "attention", link, source: { project: repo } }, now);
470
+ }
471
+ function resolveRuleNotices(store, ruleId, now) {
472
+ store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE kind = 'routing' AND resolved_at IS NULL AND (dedupe_key LIKE ? OR dedupe_key LIKE ?)")
473
+ .run(now.toISOString(), `route-rule-paused:${ruleId}:%`, `route-rule-expiring:${ruleId}`);
474
+ }
475
+ /** Hold a task approved under a rule that switched itself off: nothing starts on it (no strike: nothing ran) until a
476
+ * person approves it again — the hold goes with that yes (store.ts sealScopeApproval). */
477
+ export function holdRuleOff(store, taskRef, taskId, model, now) {
478
+ const name = modelName(store, model);
479
+ store.holdOwned({ taskRef, ownerKind: "backoff", ownerId: `rule-off:${taskRef}`, reason: `Results for ${name} dropped after you approved this. Approve it again or re-file it.`, until: null }, now);
480
+ }
481
+ /**
482
+ * The reconcile pass's routing step (operate.ts, after the records are evaluated again): each live rule checked
483
+ * against its sealed terms; Needs you told about a rule that switched off, paused or ends soon; tasks approved under a
484
+ * switched-off rule held, with their plans re-filed on the configured builder so a person can approve them again; and
485
+ * plans still waiting for approval under a rule that is no longer live re-filed the same way. Each task is its own
486
+ * transaction; one that can't be re-filed now (a live claim) is tried again next pass.
487
+ */
488
+ export function routeRulesPass(store, repo, now) {
489
+ const counts = { switchedOff: 0, paused: 0, held: 0, refiled: 0 };
490
+ for (const rule of rulesOf(store, repo, now).filter(one => one.state === "active")) {
491
+ const checked = switchOffIfTripped(store, rule.id, now);
492
+ if (checked.outcome === "switched-off")
493
+ counts.switchedOff++;
494
+ if (checked.outcome === "paused")
495
+ counts.paused++;
496
+ if (checked.outcome === "kept" && rule.grant !== null && Date.parse(rule.grant.expires) - now.getTime() <= EXPIRY_WARNING_DAYS * DAY) {
497
+ notify(store, repo, `route-rule-expiring:${rule.id}`, `${modelName(store, rule.model)} for ${sizeWord(rule.cohortSize)} tasks ends on ${rule.grant.expires.slice(0, 10)}`, "Its 90 days are nearly over. After that, these tasks build on the configured model again. A new suggestion follows if its results still earn it.", now);
498
+ }
499
+ }
500
+ for (const rule of rulesOf(store, repo, now)) {
501
+ const name = modelName(store, rule.model);
502
+ if (rule.state === "switched-off")
503
+ notify(store, repo, `route-rule-off:${rule.id}`, `${name} switched itself off for ${sizeWord(rule.cohortSize)} tasks`, `Its results dropped: ${rule.endReason ?? "its terms tripped"}. New ${sizeWord(rule.cohortSize)} tasks build on the configured model. Tasks approved on ${name} that haven't started wait for you to approve them again.`, now);
504
+ if (rule.state === "paused")
505
+ notify(store, repo, `route-rule-paused:${rule.id}:${rule.generation}`, `${name} for ${sizeWord(rule.cohortSize)} tasks is paused`, "Models changed since you accepted it, so its evidence may not compare any more. Keep it or drop it in Settings → Models.", now);
506
+ if (rule.state === "expired")
507
+ notify(store, repo, `route-rule-expired:${rule.id}`, `${name} for ${sizeWord(rule.cohortSize)} tasks ended`, "Its 90 days are over. These tasks build on the configured model again; tasks already approved keep their model.", now);
508
+ }
509
+ // Trials (slice 3): one past its days ends; tasks already approved on it keep their place and model.
510
+ for (const trial of trialsOf(store, repo, now).filter(one => one.state === "trialling" || one.state === "paused")) {
511
+ const expires = trialExpiry(store, trial);
512
+ if (expires === null || expires <= now.getTime())
513
+ endRuleByPlane(store, trial.id, trial.generation, "expired", "trial-ended", now);
514
+ }
515
+ // Tasks under a rule that is no longer live, and plans waiting on a trial place that no longer stands.
516
+ const live = new Set(liveEarnedRules(store, repo).map(one => one.id));
517
+ const rows = store.handle.prepare(`SELECT ts.task_id, tr.id AS ref, ts.proposed_route_json, ts.approved_route_json, ts.approved_digest, ts.digest
518
+ FROM task_scope ts JOIN task_ref tr ON tr.backend = ? AND tr.external_id = ts.task_id JOIN task t ON t.id = ts.task_id
519
+ WHERE tr.repo = ? AND t.state NOT IN ('done','cancelled','failed') AND (ts.proposed_route_json LIKE '%"selection"%' OR ts.approved_route_json LIKE '%"selection"%')`).all(BUILT_IN, repo);
520
+ for (const row of rows) {
521
+ const taskId = String(row["task_id"]), ref = Number(row["ref"]);
522
+ const approved = row["approved_digest"] != null && row["approved_digest"] === row["digest"];
523
+ const proposed = selectionOf(row["proposed_route_json"]);
524
+ try {
525
+ store.transact(() => {
526
+ if (approved) {
527
+ const held = ruleHoldOf(store, taskId);
528
+ if (held === null)
529
+ return;
530
+ holdRuleOff(store, ref, taskId, held.model, now);
531
+ counts.held++;
532
+ }
533
+ else if (proposed?.kind === "rule" ? live.has(proposed.rule) : proposed?.kind === "trial" ? trialPlaceLive(store, proposed, ref) : true) {
534
+ return;
535
+ }
536
+ if (store.hasLiveClaim(ref, now))
537
+ return;
538
+ if (store.refileScope(taskId, now) !== null)
539
+ counts.refiled++;
540
+ });
541
+ }
542
+ catch { /* the next pass tries again */ }
543
+ }
544
+ return counts;
545
+ }
546
+ // ---- what the person sees ------------------------------------------------------------------------------------------
547
+ /**
548
+ * The evidence line beside a route whose build leg an earned rule selected (approval sheet, approval step, chat side
549
+ * panel, text card), or null for any other route. The selection is sealed; the counts read "so far", from the
550
+ * records as they stand.
551
+ */
552
+ export function selectionEvidenceWords(store, route, now) {
553
+ if (route === null)
554
+ return null;
555
+ const selection = legOf(route, "build").selection;
556
+ if (selection?.kind === "trial")
557
+ return trialEvidenceWords(store, selection);
558
+ if (selection?.kind !== "rule")
559
+ return null;
560
+ const rule = ruleById(store, selection.rule);
561
+ if (rule === null)
562
+ return "Why this model: an earned rule chose it; that rule is gone now. Change the builder if you want another model.";
563
+ const name = modelName(store, rule.model);
564
+ const since = new Date(now.getTime() - SUGGEST_TERMS.windowDays * DAY).toISOString();
565
+ const evidence = evidenceOf(cohortRecords(store, rule.repo, rule.cohortSize, rule.provider, rule.model, since));
566
+ const standing = rule.configured.model === null ? null : evidenceOf(cohortRecords(store, rule.repo, rule.cohortSize, rule.configured.provider, rule.configured.model, since));
567
+ const counts = evidence.decided === 0 ? "No decided similar tasks yet." : `So far, ${evidence.mature} of the last ${evidence.decided} decided similar tasks were accepted with checks passing${notCountedWords(evidence)}.`;
568
+ const cost = evidence.medianMicrousd !== null && standing?.medianMicrousd != null
569
+ ? ` Observed median ${dollarsOf(evidence.medianMicrousd)} a task, ${dollarsOf(standing.medianMicrousd)} on ${modelName(store, rule.configured.model)} (observed, not a promise).` : "";
570
+ const state = rule.state === "active" ? "" : rule.state === "switched-off" ? ` This rule switched itself off since: approve again after re-filing, or change the builder.` : ` This rule is ${rule.state === "paused" ? "paused" : "off"} now; approving keeps ${name}.`;
571
+ return `Why this model: ${name} builds ${sizeWord(rule.cohortSize)} tasks in this project. ${counts}${cost}${state}`;
572
+ }
573
+ /** The evidence line for a task's proposed (or, once approved, sealed) route. */
574
+ export function taskEvidenceWords(store, taskId, now) {
575
+ try {
576
+ const row = store.handle.prepare("SELECT proposed_route_json, approved_route_json, approved_digest, digest FROM task_scope WHERE task_id = ?").get(taskId);
577
+ if (row === undefined)
578
+ return null;
579
+ const json = row["approved_digest"] != null && row["approved_digest"] === row["digest"] ? row["approved_route_json"] : row["proposed_route_json"];
580
+ return selectionEvidenceWords(store, typeof json === "string" ? routeFromJson(json) : null, now);
581
+ }
582
+ catch {
583
+ return null;
584
+ }
585
+ }
586
+ /** A rule in words for Settings and the command line. */
587
+ export function ruleWords(store, rule) {
588
+ const name = modelName(store, rule.model);
589
+ const what = `${name} builds ${sizeWord(rule.cohortSize)} tasks instead of ${rule.configured.model === null ? "the configured model" : modelName(store, rule.configured.model)}`;
590
+ if (rule.state === "active")
591
+ return `${what}, since ${rule.acceptedAt.slice(0, 10)}${rule.grant === null ? "" : `, until ${rule.grant.expires.slice(0, 10)}`}.`;
592
+ if (rule.state === "paused")
593
+ return `${what}: paused because Models changed. Keep or drop it.`;
594
+ if (rule.state === "switched-off")
595
+ return `${what}: switched itself off on ${rule.endedAt?.slice(0, 10) ?? "?"} (${rule.endReason ?? "its results dropped"}).`;
596
+ if (rule.state === "expired")
597
+ return `${what}: ended on ${rule.endedAt?.slice(0, 10) ?? "?"}.`;
598
+ return `${what}: turned off${rule.endedBy === null ? "" : ` by ${rule.endedBy}`} on ${rule.endedAt?.slice(0, 10) ?? "?"}.`;
599
+ }
600
+ // ---- trials (slice 3) ------------------------------------------------------------------------------------------------
601
+ /** When to offer a trial (docs/plans/results-routing.md, "Trials"): the configured Claude builder of a cohort has at
602
+ * least 10 decided records here at ≥ 90% accepted with checks passing, and a cheaper Claude model has too few results
603
+ * here to be suggested on its own. A trial tries it on the next 10 eligible tasks, each approved by a person. */
604
+ export const TRIAL_TERMS = Object.freeze({ places: 10, configuredMinDecided: 10, configuredMinMature: 0.9, days: 90 });
605
+ /** A trial offer's id read back: its cohort, model and the three digest prefixes it was offered under. */
606
+ export function readTrialOfferId(id) {
607
+ return id.startsWith("trial:") ? readSuggestionId(id.slice("trial:".length)) : null;
608
+ }
609
+ /** What a trial offer for this cohort and model would say now, whether or not it qualifies: the parts its digests
610
+ * bind, and why it doesn't qualify (null: it does). */
611
+ export function trialOfferNow(store, repo, size, model, now) {
612
+ const configured = configuredBuilder(store, repo, size);
613
+ if (configured === null)
614
+ return { offer: null, why: "the configured builder can't be read", parts: null };
615
+ if (configured.provider !== "claude")
616
+ return { offer: null, why: "only Claude reports the model it ran, so only a Claude builder runs a trial", parts: null };
617
+ if (model === configured.model || !exactModelId(model) || readSuggestionId(`${size}:${model}:000000000000`) === null)
618
+ return { offer: null, why: "that model can't be tried here", parts: null };
619
+ const since = new Date(now.getTime() - SUGGEST_TERMS.windowDays * DAY).toISOString();
620
+ const rows = cohortRecords(store, repo, size, "claude", model, since);
621
+ const standingRows = cohortRecords(store, repo, size, "claude", configured.model, since);
622
+ const evidence = evidenceOf(rows), configuredEvidence = evidenceOf(standingRows);
623
+ const cheaper = pricesOf(store, "claude", model), standing = pricesOf(store, "claude", configured.model);
624
+ const prices = cheaper === null || standing === null ? null : { model: cheaper, configured: standing };
625
+ const depends = dependsOf(store, repo);
626
+ const parts = {
627
+ evidence: short({ trial: true, repo, size, model, configured, own: decidedOf(rows).slice(0, SUGGEST_TERMS.newest).map(one => [one.task, one.state]),
628
+ standing: decidedOf(standingRows).slice(0, SUGGEST_TERMS.newest).map(one => [one.task, one.state]) }),
629
+ prices: short(prices), depends: short(depends),
630
+ };
631
+ const share = configuredEvidence.decided === 0 ? 0 : configuredEvidence.mature / configuredEvidence.decided;
632
+ const why = configuredEvidence.decided < TRIAL_TERMS.configuredMinDecided ? `the configured model has ${configuredEvidence.decided} decided tasks here; ${TRIAL_TERMS.configuredMinDecided} are needed to compare`
633
+ : share < TRIAL_TERMS.configuredMinMature - 1e-9 ? "fewer than 90% of the configured model's own decided tasks here were accepted with checks passing"
634
+ : evidence.decided >= SUGGEST_TERMS.minDecided ? "it has enough results here to be judged on them"
635
+ : prices === null ? "one of the two models has no listed price"
636
+ : !(prices.model.in < prices.configured.in && prices.model.out < prices.configured.out) ? "it isn't cheaper than the configured model on both input and output"
637
+ : null;
638
+ if (why !== null)
639
+ return { offer: null, why, parts };
640
+ return {
641
+ offer: {
642
+ id: `trial:${size}:${model}:${parts.evidence.slice(0, 4)}${parts.prices.slice(0, 4)}${parts.depends.slice(0, 4)}`,
643
+ repo, cohortSize: size, provider: "claude", model, name: modelName(store, model),
644
+ configured: { ...configured, name: modelName(store, configured.model) }, evidence, configuredEvidence, prices: prices, depends, places: TRIAL_TERMS.places, offer: parts,
645
+ },
646
+ why: null, parts,
647
+ };
648
+ }
649
+ /**
650
+ * The trials a project offers now: per size, where nothing is suggested (a suggestion has enough evidence to stand on
651
+ * its own) and no rule or trial is live, the closest cheaper Claude model the catalog lists now (the dearest of those
652
+ * cheaper on both prices, then the newest), unless the project's switch is off or a person said Not now to it.
653
+ */
654
+ export function trialOffersFor(store, repo, now) {
655
+ if (!suggestModelsOn(store, repo))
656
+ return [];
657
+ const suggested = new Set(suggestionsFor(store, repo, now).map(one => one.cohortSize));
658
+ const out = [];
659
+ for (const size of ["small", "medium"]) {
660
+ if (suggested.has(size))
661
+ continue;
662
+ if (store.handle.prepare("SELECT 1 FROM route_rule WHERE repo = ? AND cohort_size = ? AND state IN ('active','trialling','paused')").get(repo, size) !== undefined)
663
+ continue;
664
+ const offers = seenModels(store, "claude").filter(one => exactModelId(one.id) && !notNowStands(store, repo, size, one.id, now))
665
+ .flatMap(one => { const offer = trialOfferNow(store, repo, size, one.id, now).offer; return offer === null ? [] : [{ offer, released: one.releasedAt ?? "" }]; });
666
+ offers.sort((a, b) => b.offer.prices.model.in - a.offer.prices.model.in || b.offer.prices.model.out - a.offer.prices.model.out || b.released.localeCompare(a.released) || a.offer.model.localeCompare(b.offer.model));
667
+ if (offers[0] !== undefined)
668
+ out.push(offers[0].offer);
669
+ }
670
+ return out;
671
+ }
672
+ /** A trial offer in plain words, the evidence first. */
673
+ export function trialOfferWords(offer) {
674
+ const c = offer.configuredEvidence, e = offer.evidence;
675
+ const own = e.decided === 0 ? `${offer.name} has no decided tasks here yet` : `${offer.name} has ${e.decided} decided task${e.decided === 1 ? "" : "s"} here so far; ${SUGGEST_TERMS.minDecided} are needed to suggest it`;
676
+ return `${offer.configured.name} does well on ${sizeWord(offer.cohortSize)} tasks here (${c.mature} of the last ${c.decided} accepted with checks passing). ${own}. Try it on the next ${offer.places}; a person approves each one.`;
677
+ }
678
+ /** A project's trials a person sees: live (trialling, paused) and, for 30 days, the ones that ended. */
679
+ export function trialsOf(store, repo, now) {
680
+ return store.handle.prepare(`SELECT * FROM route_rule WHERE repo = ? AND kind = 'trial' AND (state IN ('trialling','paused') OR ended_at >= ?) ORDER BY cohort_size, id DESC`)
681
+ .all(repo, new Date(now.getTime() - NOT_NOW_DAYS * DAY).toISOString()).map(readRule);
682
+ }
683
+ /** When a trial's days end, from its sealed grant; null when that can't be read (it then ends: fail closed). */
684
+ function trialExpiry(store, trial) {
685
+ try {
686
+ const grant = JSON.parse(String(store.handle.prepare("SELECT grant_json FROM route_rule WHERE id = ?").get(trial.id)?.["grant_json"]));
687
+ const expires = typeof grant.expires === "string" ? Date.parse(grant.expires) : NaN;
688
+ return Number.isFinite(expires) ? expires : null;
689
+ }
690
+ catch {
691
+ return null;
692
+ }
693
+ }
694
+ /**
695
+ * Start a trial, in one transaction: the person re-proved (a step-up); the offer still standing, at the same prices
696
+ * and on the same configuration generations it was offered under, with its eligibility re-checked now (a stale
697
+ * offer refuses); the configuration valid with the trial's builder added (checkScope: repairs resume its session);
698
+ * and the size's one winner free (concurrent starts: one wins). A refusal names which.
699
+ */
700
+ export function startTrial(store, input, door, now) {
701
+ return act(store, () => {
702
+ if (!known(store, input.repo))
703
+ throw new Refused(refused("unknown-project"));
704
+ const proved = prove(store, door, "trial-start", input.repo);
705
+ const asked = readTrialOfferId(input.id);
706
+ if (asked === null)
707
+ throw new Refused(refused("not-found"));
708
+ if (store.handle.prepare("SELECT 1 FROM route_rule WHERE repo = ? AND cohort_size = ? AND state IN ('active','trialling','paused')").get(input.repo, asked.size) !== undefined)
709
+ throw new Refused(refused("taken"));
710
+ const now_ = trialOfferNow(store, input.repo, asked.size, asked.model, now);
711
+ if (now_.parts !== null && now_.parts.depends.slice(0, 4) !== asked.depends)
712
+ throw new Refused(refused("config-changed"));
713
+ if (now_.parts !== null && now_.parts.prices.slice(0, 4) !== asked.prices)
714
+ throw new Refused(refused("price-changed"));
715
+ if (now_.offer === null || now_.parts === null || now_.parts.evidence.slice(0, 4) !== asked.evidence || !suggestModelsOn(store, input.repo)
716
+ || suggestionsFor(store, input.repo, now).some(one => one.cohortSize === asked.size))
717
+ throw new Refused(refused("stale-suggestion"));
718
+ const offer = now_.offer;
719
+ const candidate = { id: 0, generation: 1, cohortSize: offer.cohortSize, provider: "claude", model: offer.model, configured: { provider: offer.configured.provider, model: offer.configured.model } };
720
+ const checked = checkScope(store, input.repo, { rules: [...liveEarnedRules(store, input.repo), candidate] });
721
+ if (checked.kind !== "ok")
722
+ throw new Refused(refused("invalid", `${REFUSAL_WORDS.invalid} ${checked.problem}`));
723
+ const grant = { policyVersion: ROUTING_POLICY_VERSION, cutoff: now.toISOString(), expires: new Date(now.getTime() + TRIAL_TERMS.days * DAY).toISOString(), places: offer.places };
724
+ const trial = insertTrial(store, proved, { repo: input.repo, cohortSize: offer.cohortSize, model: offer.model, configuredProvider: offer.configured.provider,
725
+ configuredModel: offer.configured.model, limit: offer.places, grant, prices: offer.prices, depends: offer.depends }, now);
726
+ if (trial === null)
727
+ throw new Refused(refused("taken"));
728
+ store.recordAction({ at: now.toISOString(), actor: proved.actor, repo: input.repo, taskId: null, runId: null, action: "model trial started", outcome: "changed", source: "policy",
729
+ detail: `the next ${offer.places} ${sizeWord(offer.cohortSize)} tasks try ${offer.model} instead of ${offer.configured.model}, each approved by a person (trial ${trial})` });
730
+ return { ok: true, trial, offer };
731
+ });
732
+ }
733
+ /** Stop a live or paused trial. Plans waiting on it are re-filed on the configured builder; tasks a person already
734
+ * approved on it keep that model. */
735
+ export function stopTrial(store, trialId, door, now) {
736
+ return act(store, () => {
737
+ const trial = ruleById(store, trialId);
738
+ if (trial === null || trial.kind !== "trial" || (trial.state !== "trialling" && trial.state !== "paused"))
739
+ throw new Refused(refused("not-found"));
740
+ const proved = prove(store, door, "trial-stop", trial.repo);
741
+ if (!endTrialByDoor(store, proved, trial.id, trial.generation, now))
742
+ throw new Refused(refused("not-found"));
743
+ const places = trialPlacesOf(store, trial.id);
744
+ store.recordAction({ at: now.toISOString(), actor: proved.actor, repo: trial.repo, taskId: null, runId: null, action: "model trial stopped", outcome: "changed", source: "policy",
745
+ detail: `${sizeWord(trial.cohortSize)} tasks build on the configured model again after ${places.consumed} of ${places.limit} trial tasks (trial ${trial.id})` });
746
+ return { ok: true, trial: ruleById(store, trial.id) };
747
+ });
748
+ }
749
+ /** A trial's progress in words, as Settings, the command line and chat say it. */
750
+ export function trialWords(store, trial) {
751
+ const name = modelName(store, trial.model);
752
+ const places = trialPlacesOf(store, trial.id);
753
+ const used = places.consumed + places.reserved;
754
+ const progress = `${used} of ${places.limit} so far (${places.consumed} started, ${places.reserved} waiting)`;
755
+ const instead = trial.configured.model === null ? "the configured model" : modelName(store, trial.configured.model);
756
+ if (trial.state === "trialling")
757
+ return `Trying ${name} instead of ${instead} on the next ${places.limit} ${sizeWord(trial.cohortSize)} tasks: ${progress}. A person approves each one.`;
758
+ if (trial.state === "paused")
759
+ return `Trying ${name} on ${sizeWord(trial.cohortSize)} tasks: paused because Models changed (${progress}). Stop it; a new offer follows.`;
760
+ if (trial.state === "finished")
761
+ return `Tried ${name} on ${places.limit} ${sizeWord(trial.cohortSize)} tasks, finished on ${trial.endedAt?.slice(0, 10) ?? "?"}. Their results count as they're decided.`;
762
+ if (trial.state === "expired")
763
+ return `Trying ${name} on ${sizeWord(trial.cohortSize)} tasks ended on ${trial.endedAt?.slice(0, 10) ?? "?"} after ${places.consumed} of ${places.limit}.`;
764
+ return `Trying ${name} on ${sizeWord(trial.cohortSize)} tasks: stopped${trial.endedBy === null ? "" : ` by ${trial.endedBy}`} on ${trial.endedAt?.slice(0, 10) ?? "?"} after ${places.consumed} of ${places.limit}.`;
765
+ }
766
+ /** The evidence line for a build leg a trial selected: which model, and which trial task this is ("trial 3 of 10"). */
767
+ function trialEvidenceWords(store, selection) {
768
+ const trial = ruleById(store, selection.rule);
769
+ if (trial === null || trial.kind !== "trial")
770
+ return "Trying a cheaper model: that trial is gone now. Change the builder if you want another model.";
771
+ const place = trialOrdinalOf(store, selection.reservation);
772
+ const name = modelName(store, trial.model);
773
+ const which = place === null || place.trial !== trial.id || place.state === "released" ? "its trial place was given up" : `trial ${place.ordinal} of ${trial.limit ?? "?"}`;
774
+ const state = trial.state === "trialling" || trial.state === "finished" ? "" : ` The trial is ${trial.state === "paused" ? "paused" : "over"} now; approving keeps ${name}.`;
775
+ return `Trying a cheaper model: ${name}, ${which}. A person approves each trial task; you can pick another model before approving.${state}`;
776
+ }
777
+ /** The project's live trial for a size, as Settings reads it (null when none). */
778
+ export function liveTrialOf(store, repo, size) {
779
+ const live = liveTrialFor(store, repo, size);
780
+ return live === null ? null : ruleById(store, live.id);
781
+ }