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
@@ -34,7 +34,7 @@ import { chatMoney, oneLineOf, relativeAge, safePrUrl, screen, sentenceCase, str
34
34
  import { chatResultHref, projectChatHref, taskChatHref, taskHref } from "./http.js";
35
35
  import {} from "./render-pages.js";
36
36
  import { completionReceiptCard } from "./render-results.js";
37
- import { agentsAvailabilityHtml, agentsBadgesHtml, agentsStripHtml, agentsSummaryWords, agentsWhyHtml, approvalSheetHtml, checkProgressHtml, consentClosedHtml, consentDoorOf, decisionAnswerCard, dispatchHeadline, milestoneProgressHtml, planRevisionLedgerHtml, profileWords, sizeConsequence, taskControlDetailsHtml, taskStatusCard, taskViewSwitch } from "./render-tasks.js";
37
+ import { routeEvidenceHtml, agentsAvailabilityHtml, agentsBadgesHtml, agentsStripHtml, agentsSummaryWords, agentsWhyHtml, approvalSheetHtml, checkProgressHtml, consentClosedHtml, consentDoorOf, decisionAnswerCard, dispatchHeadline, milestoneProgressHtml, planRevisionLedgerHtml, profileWords, sizeConsequence, taskControlDetailsHtml, taskStatusCard, taskViewSwitch } from "./render-tasks.js";
38
38
  /** The installation fact that the lead was turned on by default, once (its value: the provider). */
39
39
  export const LEAD_BY_DEFAULT_FACT = "lead-on-by-default";
40
40
  /** The installation fact that the phone card was put away. */
@@ -51,7 +51,7 @@ export function taskChatContext(focus) {
51
51
  // conversation never hides which agent runs.
52
52
  focus.route === null
53
53
  ? ""
54
- : html `<div class="task-chat-agents-aside"><span class="eyebrow">agents</span><p class="agents-summary">${agentsSummaryWords(focus.route)}</p><div class="agents-badges">${agentsBadgesHtml(focus.route)}</div>${focus.route.projection === null ? "" : html `${agentsAvailabilityHtml(focus.route.projection)}${agentsWhyHtml(focus.route.projection)}`}<p class="meta"><a href="${taskHref(focus.id)}#agents">Change agents on the task →</a></p></div>`}<div class="task-chat-overview-actions"><a class="task-chat-overview-link" href="${taskHref(focus.id)}">Open full overview →</a></div></aside>`;
54
+ : html `<div class="task-chat-agents-aside"><span class="eyebrow">agents</span><p class="agents-summary">${agentsSummaryWords(focus.route)}</p>${routeEvidenceHtml(focus.route)}<div class="agents-badges">${agentsBadgesHtml(focus.route)}</div>${focus.route.projection === null ? "" : html `${agentsAvailabilityHtml(focus.route.projection)}${agentsWhyHtml(focus.route.projection)}`}<p class="meta"><a href="${taskHref(focus.id)}#agents">Change agents on the task →</a></p></div>`}<div class="task-chat-overview-actions"><a class="task-chat-overview-link" href="${taskHref(focus.id)}">Open full overview →</a></div></aside>`;
55
55
  }
56
56
  export function taskChatApproval(focus, csrf) {
57
57
  const approval = focus.approval;
@@ -220,7 +220,12 @@ export type RouteView = {
220
220
  * every option an exact pair the operator named once in configuration.
221
221
  * The form offers these and nothing else. */
222
222
  choices: Record<Phase, AgentChoice[]>;
223
+ /** Results routing (slice 2): "Why this model: …" when an earned rule selected the build leg (route-rules.ts
224
+ * selectionEvidenceWords), else absent. The selection is sealed; the counts read "so far". */
225
+ evidence?: string | null;
223
226
  };
227
+ /** The evidence line beside the agents, only when an earned rule chose the builder. */
228
+ export declare function routeEvidenceHtml(view: RouteView | null | undefined, className?: string): Html;
224
229
  export declare const ROLE_NOUN: Record<RouteProjection["legs"][number]["phase"], string>;
225
230
  export declare function agentsStandingWords(view: RouteView): string;
226
231
  /** The size line an approval leads its agents with — "Small change: fast
@@ -609,6 +614,8 @@ export declare function taskBodyParts(data: {
609
614
  qualityMode?: QualityMode | null;
610
615
  /** The phase route (v47) and whether this session may edit it. */
611
616
  route?: RouteView | null;
617
+ /** Results routing (slice 1): this task family's result record in words; null when it has none yet. */
618
+ resultRecord?: string | null;
612
619
  canEditRoute?: boolean;
613
620
  runs: Run[];
614
621
  /** Proof produced by the newest finished attempt. The two booleans are
@@ -439,6 +439,7 @@ export function approvalSheetParts(input) {
439
439
  // The size, said once and plainly beside who builds: "Small change: fast model, no plan".
440
440
  projection === null ? "" : sizeLineHtml(projection),
441
441
  who.length === 0 ? "" : html `<p class="approval-who">${who.join(" · ")}</p>`,
442
+ routeEvidenceHtml(route, "approval-evidence"),
442
443
  consent,
443
444
  ]);
444
445
  return { lead, rows, editor, editorForm, details, submit, label: rebuild !== null ? "Approve and build again" : "Approve and start", after, allowing, passwordNote };
@@ -565,6 +566,14 @@ export function profileWords(scope) {
565
566
  const terms = sandboxTermsWords(profile);
566
567
  return html `${base}<p class="meta" data-scope-network>${networkWords(profile.network ?? null)}</p><p class="meta" data-scope-writes>Writes: ${writesWords(profile.writes)}.</p>${profile.checkAccess === undefined ? "" : html `<p class="meta" data-scope-checks>Checks: ${checkAccessWords(profile.checkAccess)}.</p>`}<p class="meta" data-scope-reads>Reads: ${terms.reads}.</p>${terms.mach === null ? "" : html `<p class="meta" data-scope-mach>${terms.mach.charAt(0).toUpperCase()}${terms.mach.slice(1)}.</p>`}`;
567
568
  }
569
+ /** The evidence line beside the agents, only when an earned rule chose the builder. */
570
+ export function routeEvidenceHtml(view, className = "meta") {
571
+ const words = view?.evidence ?? null;
572
+ if (words === null)
573
+ return html ``;
574
+ const split = words.indexOf(": ");
575
+ return html `<p class="${className} route-evidence" data-route-evidence><strong>${words.slice(0, split + 1)}</strong>${words.slice(split + 1)}</p>`;
576
+ }
568
577
  export const ROLE_NOUN = { plan: "Planner", build: "Builder", repair: "Repair", review: "Reviewer" };
569
578
  export function agentsStandingWords(view) {
570
579
  return view.kind === "legacy"
@@ -658,6 +667,7 @@ export function agentsCeremonyHtml(view) {
658
667
  html `<div class="agents-ceremony"><p class="approval-label">agents</p>`,
659
668
  sizeLineHtml(view.projection),
660
669
  html `<p class="agents-summary">${view.projection.summary}</p>`,
670
+ routeEvidenceHtml(view),
661
671
  html `<div class="agents-badges">${riskBadgeHtml(view.projection)}<span class="badge">${sentenceCase(view.projection.postureWords)}</span></div>`,
662
672
  agentsWhyHtml(view.projection),
663
673
  html `<p class="meta">These exact agents are part of what you approve; changing any of them asks for a fresh approval.</p></div>`,
@@ -735,6 +745,7 @@ export function agentsCardHtml(taskId, view, canEdit) {
735
745
  html `<section class="card agents-card" id="agents" aria-label="agents">`,
736
746
  html `<div class="agents-head"><h3>Agents</h3><div class="agents-badges">${agentsBadgesHtml(view)}</div></div>`,
737
747
  html `<p class="agents-summary">${agentsSummaryWords(view)}</p>`,
748
+ routeEvidenceHtml(view),
738
749
  p === null
739
750
  ? view.kind === "unreadable"
740
751
  ? html `<p class="agents-halted">Nothing runs for this task until its scope is filed again and approved.</p>`
@@ -2280,6 +2291,8 @@ export function taskBodyParts(data) {
2280
2291
  review.push({ ...approvedScope, label: approvedScope.label === "Approved scope" ? "Approvals" : "Scope" });
2281
2292
  review.push({ label: "Who reviews", parts: [facts.find(one => one.label === "Approval rules")?.parts[0] ?? "Any approver in this project"] });
2282
2293
  review.push({ label: "Publishes as", parts: [publishesAs] });
2294
+ if (data.resultRecord != null)
2295
+ review.push({ label: "Result record", parts: [data.resultRecord] });
2283
2296
  const about = [];
2284
2297
  const filedFact = facts.find(one => one.label === "Filed by");
2285
2298
  about.push(filedFact ?? { label: "Filed by", parts: [filedBy ?? (data.origin != null ? "An automation" : data.filedVia == null ? "Not recorded" : `via ${data.filedVia}`)] });
@@ -91,6 +91,11 @@ const LOCAL_POSTS = Object.freeze({
91
91
  "settings.lead-on": "choose-default-agent",
92
92
  // Choosing which open project the pages show: a view filter, never authorization.
93
93
  "projects.select": "read",
94
+ // Results routing: lowering only.
95
+ "settings.models-turn-off": "routing-turn-off",
96
+ "settings.models-not-now": "dismiss-suggestion",
97
+ "settings.models-suggest-off": "dismiss-suggestion",
98
+ "settings.models-stop": "trial-stop",
94
99
  "agents.seen": "mark-seen",
95
100
  "agents.watch": "watch-agent",
96
101
  });
@@ -483,6 +488,13 @@ for (const one of ["check", "watch", "update"])
483
488
  // Passwordless session; model-choices.ts re-proves the session and standing inside its one transaction).
484
489
  for (const one of ["agent", "apply"])
485
490
  post("settings", `settings.models-${one}`, `/settings/models/${one}`, { scope: "step-up", ...operator("An instance operator changes models.", "/settings/models") });
491
+ // Earned rules (results routing): accepting, keeping a paused rule and switching suggestions on are an instance
492
+ // operator's browser step-up; turning a rule off, Not now and switching suggestions off only take authority away (a
493
+ // signed-in operator, or the local owner in exactly that act: route-store.ts proveRuleDoor re-checks it in the write).
494
+ for (const one of ["accept", "keep", "suggest-on", "try"])
495
+ post("settings", `settings.models-${one}`, `/settings/models/${one}`, { scope: "step-up", ...operator("An instance operator changes which models run.", "/settings/models") });
496
+ for (const one of ["turn-off", "not-now", "suggest-off", "stop"])
497
+ post("settings", `settings.models-${one}`, `/settings/models/${one}`, operator("An instance operator changes which models run.", "/settings/models"));
486
498
  // Any other models action answers the models area's own refusal.
487
499
  post("settings", "settings.models-act", "^\\/settings\\/models\\/.*$", { sample: "/settings/models/unknown", ...MODELS });
488
500
  post("settings", "settings.knowledge-refresh", "/settings/knowledge/refresh", { project: "form", limited: "unscoped", ...approver("Sign in as an approver to refresh project context.", "/settings/knowledge") });
@@ -1,5 +1,5 @@
1
1
  import type { ServerRuntime } from './runtime.js';
2
- import { type RoleView } from "../models-ui.js";
2
+ import { type RoleView, type SuggestedView } from "../models-ui.js";
3
3
  export declare function createSettingsHandlers(runtime: ServerRuntime): {
4
4
  registrations: import("./handler-registry.js").Registration[];
5
5
  modelsView: (canManage: boolean, csrf: string, now: Date, said: string | null, problem: string | null, query?: URLSearchParams) => {
@@ -71,5 +71,6 @@ export declare function createSettingsHandlers(runtime: ServerRuntime): {
71
71
  taskId: string;
72
72
  title: string;
73
73
  }[];
74
+ suggested: SuggestedView;
74
75
  };
75
76
  };
@@ -76,6 +76,7 @@ import { startConnect } from "../mcp-connect.js";
76
76
  import { decideProposal, listProposals, memoryStatus } from "../memory-pass.js";
77
77
  import { checkModels, isNewModel, modelOptions, modelWords, RUNTIME_TOOLS, runtimeStates, seenModels, setWatch, updateRuntime, watchState } from "../model-catalog.js";
78
78
  import { modelsHtml, modelsScript } from "../models-ui.js";
79
+ import { acceptSuggestion, dismissSuggestion, grantWords, keepRule, modelName, ruleWords, rulesOf, setSuggestModels, startTrial, stopTrial, suggestionsFor, suggestionWords, suggestModelsOn, trialOffersFor, trialOfferWords, trialsOf, trialWords, turnOffRule } from "../route-rules.js";
79
80
  import { ALL_SLOTS, applyModelChoices, browserWords, checkScope, DEFAULT_PRESET, defaultAgentOf, fillDefaultRouting, isPresetName, newerModels, PRESETS, presetChange, presetInUse, previewModelChange, recentRuns, rowAt, slotKey, SLOTS, slotWords, stateDigest, switchChange } from "../model-choices.js";
80
81
  import { HEADROOM_FIVE_HOUR_LIMIT, HEADROOM_WEEKLY_LIMIT, exactModelId } from "../phase-routing.js";
81
82
  import { RISKY_MEANS, SIZE_MEANS, SIZING_CLAUDE_MODEL } from "../task-sizing.js";
@@ -118,6 +119,7 @@ import { slackSettingsHtml } from "../slack-settings.js";
118
119
  import { spendHtml, spendScript } from "../spend-ui.js";
119
120
  import { approvalFactsNow } from "./kit.js";
120
121
  import { spendReport, spendReportCsv, spendRows } from "../spend-report.js";
122
+ import { savingsReportOrError } from "../route-savings.js";
121
123
  import { limitsView } from "../limits-ui.js";
122
124
  import { budgetStates, monthNamed, spendItems, usd as spendUsd, subagentNames as subagentNamesOf } from "../spend.js";
123
125
  import { removeSsoSettings, saveSsoSettings, SSO_CALLBACK, ssoChangeWords } from "../sso-settings.js";
@@ -185,7 +187,9 @@ export function createSettingsHandlers(runtime) {
185
187
  };
186
188
  const windows = limitsView(store.providerLimits(), [], { project: projectName, subagent: id => subagentNames.get(id) ?? `Subagent ${id}` }, now);
187
189
  const view = { month: month.name, previous: shift(-1), next: month.name === current.name ? null : shift(1), current: current.name, report: spendReport(rows),
188
- budgets, unlimited, targets, windows, subagentNames, edit, facts: approvalFactsNow() };
190
+ budgets, unlimited, targets, windows, subagentNames, edit, facts: approvalFactsNow(),
191
+ // Suggestions and rules (results-routing slice 4): read-only, for every project this console shows.
192
+ savings: savingsReportOrError(store, consoleProjects(), now) };
189
193
  // A refusal for the open form shows in the form; anything else under the limits' heading.
190
194
  const said = { said: url.searchParams.get("said"), problem: edit === null ? url.searchParams.get("problem") : null };
191
195
  return sendScreen(response, 200, screen("Spend", spendHtml(view, said), { chrome: chromeFor(null, "spend"), functional: { script: spendScript() } }));
@@ -2565,6 +2569,7 @@ ${postForm("/settings/chat-approval/save", html `\
2565
2569
  chosen: one.chosen === null ? "its default" : /^(opus|sonnet|haiku|fable)$/.test(one.chosen) ? `floating ${one.chosen}` : one.chosen,
2566
2570
  ranOn: one.ranOn ?? "not reported" })),
2567
2571
  waiting: canManage ? store.scopesAwaitingApproval(null, 10, admissionList()).map(one => ({ taskId: one.taskId, title: one.title })) : [],
2572
+ suggested: suggestedView(repo, projects, query.get("suggestion"), now),
2568
2573
  };
2569
2574
  }
2570
2575
  /** Google's redirect back: the code becomes a saved refresh token, and a page sends the person back to Settings. */
@@ -2739,6 +2744,14 @@ ${postForm("/settings/chat-approval/save", html `\
2739
2744
  "settings.models-agent": modelsSave,
2740
2745
  "settings.models-apply": modelsApply,
2741
2746
  "settings.models-act": modelsAct("other"),
2747
+ "settings.models-accept": modelsRuleAct("accept"),
2748
+ "settings.models-keep": modelsRuleAct("keep"),
2749
+ "settings.models-suggest-on": modelsRuleAct("suggest-on"),
2750
+ "settings.models-turn-off": modelsRuleAct("turn-off"),
2751
+ "settings.models-not-now": modelsRuleAct("not-now"),
2752
+ "settings.models-suggest-off": modelsRuleAct("suggest-off"),
2753
+ "settings.models-try": modelsRuleAct("try"),
2754
+ "settings.models-stop": modelsRuleAct("stop"),
2742
2755
  "settings.knowledge-refresh": knowledgeRefresh,
2743
2756
  "settings.knowledge-proposal": knowledgeProposal,
2744
2757
  "settings.knowledge-decision": knowledgeDecision,
@@ -3005,5 +3018,78 @@ ${postForm("/settings/chat-approval/save", html `\
3005
3018
  return back("problem", turned.reason === "not-on" ? "Local mode is already off." : "Only an instance operator can turn off local mode.");
3006
3019
  return back("said", "Local mode is off. This computer's browser signs in with your password now.");
3007
3020
  }
3021
+ /** Settings → Models → Suggested: one project's offers, rules and switch; on the installation scope, every
3022
+ * project's offers and rules, each reviewed on its own project's page. */
3023
+ function suggestedView(repo, projects, asked, now) {
3024
+ const repos = repo === null ? projects : [repo];
3025
+ const offers = repos.flatMap(one => suggestionsFor(store, one, now));
3026
+ const sizeTitle = (size, name) => `${name} for ${size === "small" ? "small" : "routine"} tasks`;
3027
+ const reviewed = asked === null || repo === null ? null : offers.find(one => one.id === asked && one.repo === repo) ?? null;
3028
+ return {
3029
+ repo,
3030
+ offers: offers.map(one => ({ id: one.id, repo: one.repo, project: projectName(one.repo), title: sizeTitle(one.cohortSize, one.name), words: suggestionWords(one) })),
3031
+ review: asked === null || repo === null ? null : reviewed === null ? { stale: true, repo } : {
3032
+ id: reviewed.id, repo: reviewed.repo, title: `${sizeTitle(reviewed.cohortSize, reviewed.name)} instead of ${reviewed.configured.name}`,
3033
+ evidence: [suggestionWords(reviewed), reviewed.configuredEvidence.decided === 0 ? `${reviewed.configured.name}: no decided similar tasks here in the last 90 days.` : `${reviewed.configured.name}: ${reviewed.configuredEvidence.mature} of its last ${reviewed.configuredEvidence.decided} decided similar tasks accepted with checks passing.`],
3034
+ prices: `${reviewed.name} $${reviewed.prices.model.in} / $${reviewed.prices.model.out}, ${reviewed.configured.name} $${reviewed.prices.configured.in} / $${reviewed.prices.configured.out} per 1M tokens (listed now)`,
3035
+ terms: grantWords(reviewed.grant), save: `Use ${reviewed.name} for ${reviewed.cohortSize === "small" ? "small" : "routine"} tasks`,
3036
+ },
3037
+ rules: repos.flatMap(one => rulesOf(store, one, now)).map(one => ({ id: one.id, repo: one.repo, project: projectName(one.repo), title: sizeTitle(one.cohortSize, modelName(store, one.model)), words: ruleWords(store, one),
3038
+ state: one.state === "active" ? "active" : one.state === "paused" ? "paused" : "ended" })),
3039
+ suggest: repo === null ? null : { on: suggestModelsOn(store, repo) },
3040
+ trialOffers: repos.flatMap(one => trialOffersFor(store, one, now)).map(one => ({ id: one.id, repo: one.repo, project: projectName(one.repo),
3041
+ title: `Try ${sizeTitle(one.cohortSize, one.name)}`, words: trialOfferWords(one), save: `Try it on the next ${one.places}` })),
3042
+ trials: repos.flatMap(one => trialsOf(store, one, now)).map(one => ({ id: one.id, repo: one.repo, project: projectName(one.repo),
3043
+ title: `Trying ${sizeTitle(one.cohortSize, modelName(store, one.model))}`, words: trialWords(store, one), live: one.state === "trialling" || one.state === "paused" })),
3044
+ };
3045
+ }
3046
+ /**
3047
+ * Settings → Models → Suggested (results routing, slice 2): the earned-rule doors (route-rules.ts), re-proved inside
3048
+ * their own transaction. Accepting, keeping a paused rule and switching suggestions on are an instance operator's
3049
+ * step-up (the password, or a Passwordless session); turning a rule off or dropping it, Not now, and switching
3050
+ * suggestions off only take authority away, so a signed-in session does — or this computer's local owner, whose
3051
+ * route row narrows it to exactly that act.
3052
+ */
3053
+ function modelsRuleAct(kind) {
3054
+ return async (ctx) => {
3055
+ const { response, now, posted } = ctx;
3056
+ const who = browserCaller(ctx.who);
3057
+ const body = readForm(posted, CONSOLE_FORMS.modelsRules);
3058
+ const repo = body.get("scope") ?? "";
3059
+ const back = (key, words) => redirect(response, `/settings/models?scope=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}#models-suggested`);
3060
+ if (["scope", "password", "id", "rule"].some(field => body.sent.getAll(field).length > 1))
3061
+ return back("problem", "That form was sent twice over. Nothing changed.");
3062
+ if (!consoleProjects().includes(repo) || !store.accountCanAccess(who.name, repo))
3063
+ return refuse(response, who, 403, "That project is outside your access.", "/settings/models");
3064
+ let door;
3065
+ if (kind === "accept" || kind === "keep" || kind === "suggest-on" || kind === "try") {
3066
+ const proved = authenticateApprover(who, body.get("password") ?? "");
3067
+ if (!proved.ok)
3068
+ return back("problem", "Enter your Toolroll password to change which models run.");
3069
+ door = { kind: "person", person: modelsAuthority(who, proved.method) };
3070
+ }
3071
+ else {
3072
+ door = who.authority === "local-owner" ? { kind: "local-owner", actor: who.name } : { kind: "person", person: modelsAuthority(who, "session") };
3073
+ }
3074
+ const ruleId = /^[1-9][0-9]{0,9}$/.test(body.get("rule") ?? "") ? Number(body.get("rule")) : -1;
3075
+ const id = body.get("id") ?? "";
3076
+ const result = kind === "accept" ? acceptSuggestion(store, { repo, id }, door, now)
3077
+ : kind === "try" ? startTrial(store, { repo, id }, door, now)
3078
+ : kind === "stop" ? stopTrial(store, ruleId, door, now)
3079
+ : kind === "keep" ? keepRule(store, ruleId, door, now)
3080
+ : kind === "turn-off" ? turnOffRule(store, ruleId, door, now)
3081
+ : kind === "not-now" ? dismissSuggestion(store, { repo, id }, door, now)
3082
+ : setSuggestModels(store, repo, kind === "suggest-on", door);
3083
+ if (!result.ok)
3084
+ return back("problem", result.problem);
3085
+ return back("said", kind === "try" ? "Trial started. The next tasks of that size try the cheaper model; each waits for your approval."
3086
+ : kind === "stop" ? "Trial stopped. New tasks build on the configured model; tasks already approved keep their model."
3087
+ : kind === "accept" ? "Saved. New tasks of that size build on the suggested model; each is still approved by a person."
3088
+ : kind === "keep" ? "Kept. The rule is on again with its original terms."
3089
+ : kind === "turn-off" ? "Turned off. New tasks build on the configured model; tasks already approved keep their model."
3090
+ : kind === "not-now" ? "Not now. This suggestion stays quiet for 30 days."
3091
+ : kind === "suggest-on" ? "Suggestions are on for this project." : "Suggestions are off for this project.");
3092
+ };
3093
+ }
3008
3094
  return { registrations, modelsView };
3009
3095
  }
@@ -1,3 +1,5 @@
1
+ import { resultRecordWordsOf } from "../route-results.js";
2
+ import { selectionEvidenceWords } from "../route-rules.js";
1
3
  import { feedbackImageProblem } from "../result-actions.js";
2
4
  import { parseFeedbackImage } from "../result-review.js";
3
5
  import { releaseViewOf } from "../release-view.js";
@@ -473,6 +475,8 @@ export function createTaskViews(runtime) {
473
475
  // and the focused chat, with the readiness this task's runners report.
474
476
  route: routeView,
475
477
  canEditRoute: who.role === "approver",
478
+ // Results routing (slice 1): the family's saved record, or the one its rows give now; never a reason to fail the page.
479
+ resultRecord: resultRecordWordsOf(runtime.store, taskId, runtime.evidenceRoot, now),
476
480
  spendDefaults: runtime.store.getSpendDefaults(),
477
481
  permissionDefault: runtime.store.permissionDefault().mode,
478
482
  permissionMode: ref?.permissionMode ?? null,
@@ -571,6 +575,8 @@ export function createTaskViews(runtime) {
571
575
  replanOnPlanChange: ref.plan === "drafted" && scope !== null && !approvalOf(scope).approved,
572
576
  digest: scope?.digest ?? null,
573
577
  choices: agentChoicesFor(runtime.store, ref.repo, routed.kind === "route" ? routed.route : null),
578
+ // Results routing: why an earned rule's model builds it, from the records as they stand now.
579
+ evidence: routed.kind === "route" ? selectionEvidenceWords(runtime.store, routed.route, now) : null,
574
580
  };
575
581
  if (routed.kind === "route") {
576
582
  return { kind: "route", source: routed.source, projection: projectRoute(routed.route, runtime.store.readinessLookupFor(ref.repo, ref.assignedRunner, now)), legacy: null, problem: null, ...shared };
@@ -1444,6 +1444,8 @@ export function createTasksHandlers(runtime) {
1444
1444
  const result = requestResultChanges(store, evidenceRoot, {
1445
1445
  run: id, batch: body.get("batch"), source: body.get("source"), actor: who.name,
1446
1446
  repos: admissionList(), includeUnplaced: visible(null), allowMode: who.via === "cookie",
1447
+ // A browser session is a person's proof; a bearer credential is a machine's.
1448
+ origin: who.via === "cookie" ? "person" : "machine",
1447
1449
  note: body.get("note") ?? "", path: body.get("path") ?? "", line: body.get("line") ?? "", request: body.get("request"), image,
1448
1450
  }, now);
1449
1451
  if (!result.ok) {
@@ -1556,6 +1558,7 @@ export function createTasksHandlers(runtime) {
1556
1558
  const result = createResultRevision(store, evidenceRoot, {
1557
1559
  run: id, batch: body.get("batch"), source: body.get("source"), actor: who.name,
1558
1560
  repos: admissionList(), includeUnplaced: visible(null), allowMode: who.via === "cookie",
1561
+ origin: who.via === "cookie" ? "person" : "machine",
1559
1562
  }, now);
1560
1563
  if (!result.ok)
1561
1564
  return refuse(response, who, result.status, result.message, back);
@@ -35,11 +35,13 @@ export const PROOF_INVENTORY = [
35
35
  { id: "google", action: "Connect or disconnect Google", routes: ["/settings/google", "/settings/google/disconnect"], form: "google", field: null, proof: "none", ...NONE, role: "any", secrets: ["clientSecret"], terms: [] },
36
36
  { id: "appearance", action: "Change the theme or accent", routes: ["/settings/appearance"], form: "appearance", field: null, proof: "none", ...NONE, role: "any", secrets: [], terms: [] },
37
37
  { id: "chat-approval-confirm", action: "Review approving from chat", routes: ["/settings/chat-approval/confirm", "/settings/chat-approval/off"], form: "chatApproval", field: null, proof: "none", ...NONE, role: "approver", secrets: [], terms: [] },
38
+ { id: "models-rules-off", action: "Turn an earned rule off, stop a trial, say Not now, or turn suggestions off", routes: ["/settings/models/turn-off", "/settings/models/not-now", "/settings/models/suggest-off", "/settings/models/stop"], form: "modelsRules", field: null, proof: "none", ...NONE, role: "operator", secrets: [], terms: [] },
38
39
  ...CHAT_APPS.map(([app, name, form]) => ({ id: `${app}-alerts`, action: `Send alerts to ${name}`, routes: [`/settings/${app}/alerts`], form, field: null, proof: "none", ...NONE, role: "approver", secrets: [], terms: [] })),
39
40
  // Step-up: a password typed now, the Passwordless session, or a one-use re-check.
40
41
  { id: "permission-default", action: "Start new tasks with Full access", routes: ["/settings/permission-default"], form: "permissionDefault", field: "password", proof: "step-up", when: "only when widening to Full access", ...ACCEPT, role: "any", secrets: [], terms: [] },
41
42
  { id: "spend-budget", action: "Set a monthly limit", routes: ["/spend/budget"], form: "spendBudget", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: [] },
42
43
  { id: "models-agent", action: "Change which model does each job", routes: ["/settings/models/agent"], form: "modelsChange", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["scope", "digest"] },
44
+ { id: "models-rules", action: "Accept a suggested model, try a cheaper one, keep a paused rule, or turn suggestions on", routes: ["/settings/models/accept", "/settings/models/keep", "/settings/models/suggest-on", "/settings/models/try"], form: "modelsRules", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["scope", "id", "rule"], note: "the rule door re-proves the session and its generation in the write" },
43
45
  { id: "models-apply", action: "Apply a model preset or switch", routes: ["/settings/models/apply"], form: "modelsChange", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["scope", "digest", "change"], note: "plus the previewed preset, or switch and to" },
44
46
  ...[
45
47
  ["policy", "Change the policy", "policy", "operator"],
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
+ content?: never;
18
19
  ok: false;
19
20
  message: string;
20
21
  plan?: never;
21
- content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
+ content?: never;
33
34
  ok: false;
34
35
  message: string;
35
36
  plan?: never;
36
- content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;
@@ -10,6 +10,7 @@ import { type ApprovalFacts } from "./design/kit.js";
10
10
  import { type BudgetState, type SpendItem } from "./spend.js";
11
11
  import type { SpendReport } from "./spend-report.js";
12
12
  import type { BrowserLimits } from "./browser-workspace.js";
13
+ import { type SavingsReport } from "./route-savings.js";
13
14
  /** The page's own layout: the limit rows and meters, the summary figures and the quiet bars. Colour from tokens only. */
14
15
  export declare const SPEND_CSS: string;
15
16
  /** What this page doesn't record, in plain words (docs/plans/spend-dashboard.md §2.2). */
@@ -53,6 +54,10 @@ export type SpendView = {
53
54
  facts?: ApprovalFacts;
54
55
  /** This month's name (YYYY-MM): the limits are this month's. */
55
56
  current?: string;
57
+ /** Suggestions and rules (route-savings.ts): what each rule, trial and suggestion observed, or that it can't be read. */
58
+ savings?: SavingsReport | {
59
+ error: string;
60
+ } | null;
56
61
  };
57
62
  /** Dollars for the page: a positive amount under a cent reads "<$0.01", never a false $0. */
58
63
  export declare const money: (microusd: number) => string;
@@ -62,6 +67,12 @@ export declare function spendHtml(view: SpendView, said: {
62
67
  said?: string | null;
63
68
  problem?: string | null;
64
69
  }): Html;
70
+ /** Suggestions and rules (docs/plans/results-routing.md, slice 4): per project and size, each live or recently ended
71
+ * rule or trial, and each standing suggestion, with what it observed over matched 90-day windows. Labelled "observed,
72
+ * not a promise"; a median shows only from 5 API-priced results a side; unknown is "—", never $0. Read-only. */
73
+ export declare function savingsSection(savings: SavingsReport | {
74
+ error: string;
75
+ } | null): Html | "";
65
76
  /** Checks the amount next to its field before any password is asked or the form leaves the page; the server checks
66
77
  * it again. Remove skips it. */
67
78
  export declare function spendScript(): string;
package/dist/spend-ui.js CHANGED
@@ -10,6 +10,7 @@ import { approvalAsk, termsOf } from "./design/kit.js";
10
10
  import { acts, btn, chip, disclose, dock, empty, field, input, note, notice, segLinks, settingsShell } from "./server/kit.js";
11
11
  import { budgetLabel, usd } from "./spend.js";
12
12
  import { runHref, taskHref } from "./server/http.js";
13
+ import { notEnoughWords, pricesWords, sideWords, SAVINGS_LABEL } from "./route-savings.js";
13
14
  const projectName = (repo) => repo.split("/").filter(Boolean).pop() ?? repo;
14
15
  /** The page's own layout: the limit rows and meters, the summary figures and the quiet bars. Colour from tokens only. */
15
16
  export const SPEND_CSS = `.spend{display:grid;gap:24px;min-width:0}.spend-sec{display:grid;gap:0}.spend-sec .k-secthead{margin-bottom:0}.spend-sec .k-secthead small{font-weight:400;color:var(--so-muted);font-size:12.5px}` +
@@ -29,7 +30,14 @@ export const SPEND_CSS = `.spend{display:grid;gap:24px;min-width:0}.spend-sec{di
29
30
  `.spend-win.warning .spend-meter>i,.spend-win.danger .spend-meter>i{background:var(--so-danger)}.spend .k-disclose{padding-top:4px}.spend .k-disclose[open]>summary{margin-bottom:8px}` +
30
31
  `@media(max-width:760px){.spend-lim-h{grid-template-columns:minmax(0,1fr) auto}.spend-lim-a{grid-column:1/-1}.spend-lim-a .k-btn{width:100%}.spend-sum{grid-template-columns:minmax(0,1fr)}` +
31
32
  `.spend-sum>div+div{border-left:0;border-top:1px solid var(--so-line);padding-left:0}.spend-bar{grid-template-columns:minmax(0,120px) minmax(0,1fr) 68px;gap:8px;font-size:12.5px}` +
32
- `.spend-edit-row .k-field{flex:1 1 100%}.spend .k-btn.k-btn-primary.k-docked{display:none}}`;
33
+ `.spend-edit-row .k-field{flex:1 1 100%}.spend .k-btn.k-btn-primary.k-docked{display:none}}` +
34
+ // Suggestions and rules (results-routing slice 4): each rule's two sides, side by side; stacked on a phone.
35
+ `.spend-rr{display:grid;gap:10px;padding:14px 0;border-bottom:1px solid var(--so-line);min-width:0}.spend-rr-h{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;min-width:0}` +
36
+ `.spend-rr-t{font-weight:500;overflow-wrap:anywhere;min-width:0}.spend-rr-x{font-size:12.5px;color:var(--so-muted);overflow-wrap:anywhere}` +
37
+ `.spend-rr-g{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.spend-rr-s{display:grid;gap:4px;align-content:start;min-width:0;padding:12px;border:1px solid var(--so-line);border-radius:8px}` +
38
+ `.spend-rr-k{font-size:12px;color:var(--so-muted);overflow-wrap:anywhere}.spend-rr-v{font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}.spend-rr-v small{font-size:12px;font-weight:400;color:var(--so-muted)}` +
39
+ `.spend-rr-p{display:grid;gap:2px;margin:0;padding:0;list-style:none;font-size:12.5px;color:var(--so-muted)}.spend-rr .k-note{margin:0}` +
40
+ `@media(max-width:760px){.spend-rr-g{grid-template-columns:minmax(0,1fr)}}`;
33
41
  const ROLE_WORDS = {
34
42
  builder: "Builds", repair: "Repairs", planner: "Plans", reviewer: "Reviews", scout: "Scouting",
35
43
  lead: "Lead chat", chat: "Chat", subagent: "Subagents", flow: "Flows", evals: "Evals",
@@ -148,10 +156,49 @@ export function spendHtml(view, said) {
148
156
  const docked = edit !== null && (rows.some(row => row.target === edit.target) || adding !== null)
149
157
  ? dock(btn("Save limit", { variant: "primary", type: "submit", attrs: { form: `limit-${edit.target.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").slice(0, 60) || "new"}`, name: "action", value: "save" } }))
150
158
  : "";
151
- const body = html `<div class="spend">${limits}${monthBar}${figures}${breakdowns}${windows}${notCounted}</div>${docked}`;
159
+ const body = html `<div class="spend">${limits}${monthBar}${figures}${breakdowns}${windows}${savingsSection(view.savings ?? null)}${notCounted}</div>${docked}`;
152
160
  return settingsShell({ group: "spend", title: "Spend", lead: "What agents cost, by project and by model, with the limits that stop them. Dates are UTC months.", body,
153
161
  flags: { spend: saved.some(one => one.budget !== null && limitNeedsYou(one.budget)) } });
154
162
  }
163
+ const sizeWords = (size) => size === "small" ? "Small tasks" : "Routine tasks";
164
+ const dateWords = (iso) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }).format(new Date(iso));
165
+ const STATUS_TONE = { active: "live", trialling: "live", paused: "needs" };
166
+ /** One side of a rule's comparison: its model and window, the median API dollars a task (— when hidden), and the
167
+ * completeness, plan usage, unknown cost and remediation counts, each on its own line. */
168
+ function savingsSide(label, side) {
169
+ const words = sideWords(side);
170
+ const cap = (text) => text.charAt(0).toUpperCase() + text.slice(1);
171
+ return html `<div class="spend-rr-s" role="group" aria-label="${label}: ${side.name}" data-side="${label.toLowerCase()}"><span class="spend-rr-k">${label} · ${side.name} · ${dateWords(side.from)} to ${dateWords(side.to)}</span>\
172
+ <span class="spend-rr-v" data-amount-kind="api" data-microusd="${side.medianApiMicrousd ?? ""}">${words.median} <small>median API dollars a task</small></span>\
173
+ <ul class="spend-rr-p"><li data-priced="${side.apiPriced}" data-results="${side.results}">${words.parts[0]}</li><li data-amount-kind="subscription">${cap(words.parts[1])}</li><li data-amount-kind="unknown">${cap(words.parts[2])}</li><li>${cap(words.parts[3])}</li></ul></div>`;
174
+ }
175
+ function savingsRow(row) {
176
+ const suggestion = row.kind === "suggestion";
177
+ const what = `${row.cheaper.name} instead of ${row.configured.name}${row.kind === "trial" ? " (a trial)" : suggestion ? " (suggested, not in use)" : ""}`;
178
+ const href = `/settings/models?scope=${encodeURIComponent(row.repo)}#models-suggested`;
179
+ return html `<article class="spend-rr" data-savings-row data-kind="${row.kind}" data-state="${row.state}" data-enough="${row.enough ? "yes" : "no"}">\
180
+ <div class="spend-rr-h"><span class="spend-rr-t">${row.project} · ${sizeWords(row.size)}</span>${chip(row.status, STATUS_TONE[row.state] ?? "")}</div><span class="spend-rr-x">${what}</span>\
181
+ <div class="spend-rr-g">${savingsSide(suggestion ? "Configured" : "Before", row.configured)}${savingsSide(suggestion ? "Suggested" : "After", row.cheaper)}</div>\
182
+ ${row.enough ? "" : html `<p class="k-note" data-not-enough>${notEnoughWords(row)}</p>`}<p class="k-note" data-prices>${pricesWords(row)}</p>\
183
+ <span><a class="k-link" href="${href}">${suggestion ? "Review in Models settings" : "Open Models settings"}</a></span></article>`;
184
+ }
185
+ /** Suggestions and rules (docs/plans/results-routing.md, slice 4): per project and size, each live or recently ended
186
+ * rule or trial, and each standing suggestion, with what it observed over matched 90-day windows. Labelled "observed,
187
+ * not a promise"; a median shows only from 5 API-priced results a side; unknown is "—", never $0. Read-only. */
188
+ export function savingsSection(savings) {
189
+ if (savings === null)
190
+ return "";
191
+ const state = `${SAVINGS_LABEL} · 90 days a side`;
192
+ if ("error" in savings)
193
+ return sec("savings", "Suggestions and rules", state, notice("Suggestions and rules can't be read right now. Nothing else on this page is affected."), html ` data-savings="error"`);
194
+ if (savings.rows.length === 0) {
195
+ return sec("savings", "Suggestions and rules", state, empty({ title: "No suggestions or rules yet.",
196
+ text: "When a cheaper model does as well on a project's small or routine tasks, its suggestion shows here, and once it's in use, what it cost before and after." }), html ` data-savings="empty"`);
197
+ }
198
+ const stale = savings.staleSince === null ? "" : html `<p class="k-note" data-savings-stale>Some results were last checked on ${dateWords(savings.staleSince)}; they update while Toolroll runs.</p>`;
199
+ const lead = html `<p class="k-note">Median API dollars a task, including revision and repair runs. Plan usage and unknown cost are counted apart, never added in. A median shows once each side has ${savings.minPriced} results priced in API dollars.</p>`;
200
+ return sec("savings", "Suggestions and rules", state, html `${lead}${stale}${savings.rows.map(savingsRow)}`, html ` data-savings="rows"`);
201
+ }
155
202
  /** Checks the amount next to its field before any password is asked or the form leaves the page; the server checks
156
203
  * it again. Remove skips it. */
157
204
  export function spendScript() {
package/dist/store.d.ts CHANGED
@@ -234,7 +234,7 @@ export type SealedLedgerEntry = LedgerEntry & {
234
234
  hash: string;
235
235
  } | null;
236
236
  };
237
- export declare const SCHEMA_VERSION = 147;
237
+ export declare const SCHEMA_VERSION = 148;
238
238
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
239
239
  export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
240
240
  /**
@@ -426,7 +426,38 @@ export declare const V147_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record
426
426
  }>>>>;
427
427
  /** The v147 migration's steps after the epoch, in order: a test seam (OpenOptions.migrationStep) may stop after each. */
428
428
  export declare const V147_STEPS: readonly ["epoch", "local-mode", "password-set", "web-session", "console-instance"];
429
- export type MigrationStep = (typeof V147_STEPS)[number];
429
+ export type MigrationStep = (typeof V147_STEPS)[number] | (typeof V148_STEPS)[number];
430
+ /**
431
+ * v148 (docs/plans/results-routing.md): every table the results-routing arc uses, landed at once because a current
432
+ * store runs no DDL. Slice 1 writes run_cohort, route_record and models_generation; route_rule, route_exposure* and
433
+ * route_trial_reservation are written from later slices. src/route-store.ts is the only writer of all of them (the
434
+ * migration aside), which release-auto.ts's routing scan (route-hooks.ts) holds a release to.
435
+ *
436
+ * - run_cohort: one row per code-writing run (builder, repair), written in the run's admission transaction beside its
437
+ * route stamp and never updated: the sealed facts its eligibility was decided from. cohort_size NULL = not eligible,
438
+ * with the reason in `ineligible`.
439
+ * - route_record: a task family's result record (keyed by its root task_ref), recomputed by the reconcile pass.
440
+ * `settled` = its verdict (state, reason, result, acceptance, decision time) is final; its spending and counts keep
441
+ * accumulating through later revision and repair runs until `closed` (the family is quiet, or 90 days after its
442
+ * first run: route-results.ts ACCUMULATE_FOR_MS).
443
+ * - route_rule: an earned rule or a trial, with its sealed grant, price snapshot and the configuration generations it
444
+ * depends on; one live winner per project and size (route_rule_one_winner).
445
+ * - route_exposure / route_exposure_event: one exposure per task and rule, its events append-only.
446
+ * - route_trial_reservation: a trial's place, reserved, then consumed (once, at admission) or released.
447
+ * - models_generation: a counter per (scope, phase), moved by every Models row writer in the same transaction.
448
+ */
449
+ export declare const V148_SCHEMA = "\nCREATE TABLE IF NOT EXISTS run_cohort (\n run INTEGER PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,\n task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,\n root_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,\n repo TEXT,\n role TEXT NOT NULL CHECK (role IN ('builder','repair')),\n size TEXT CHECK (size IS NULL OR size IN ('small','medium','large')),\n risky INTEGER CHECK (risky IS NULL OR risky IN (0,1)),\n quality_mode TEXT NOT NULL CHECK (quality_mode IN ('default','strict')),\n evidence_demands INTEGER NOT NULL CHECK (evidence_demands IN (0,1)),\n publication TEXT NOT NULL CHECK (publication IN ('none','notify','automerge')),\n release INTEGER NOT NULL CHECK (release IN (0,1)),\n protected INTEGER NOT NULL CHECK (protected IN (0,1)),\n selection_json TEXT NOT NULL,\n route_digest TEXT NOT NULL,\n provider TEXT NOT NULL,\n model TEXT,\n cohort_size TEXT CHECK (cohort_size IS NULL OR cohort_size IN ('small','medium')),\n ineligible TEXT,\n started_at TEXT NOT NULL,\n CHECK ((cohort_size IS NULL) = (ineligible IS NOT NULL))\n);\nCREATE INDEX IF NOT EXISTS run_cohort_by_task ON run_cohort (task_ref);\nCREATE INDEX IF NOT EXISTS run_cohort_by_root ON run_cohort (repo, root_ref);\nCREATE TABLE IF NOT EXISTS route_record (\n task_ref INTEGER PRIMARY KEY REFERENCES task_ref(id) ON DELETE CASCADE,\n repo TEXT,\n cohort_size TEXT CHECK (cohort_size IS NULL OR cohort_size IN ('small','medium')),\n provider TEXT,\n model TEXT,\n state TEXT NOT NULL CHECK (state IN ('pending','provisional','mature','rejected','not-counted')),\n reason TEXT,\n result_run INTEGER REFERENCES run(id) ON DELETE SET NULL,\n first_started_at TEXT,\n accepted_at TEXT,\n decided_at TEXT,\n model_failures INTEGER NOT NULL DEFAULT 0 CHECK (model_failures >= 0),\n infra_failures INTEGER NOT NULL DEFAULT 0 CHECK (infra_failures >= 0),\n api_microusd INTEGER NOT NULL DEFAULT 0 CHECK (api_microusd >= 0),\n subscription_runs INTEGER NOT NULL DEFAULT 0 CHECK (subscription_runs >= 0),\n subscription_tokens INTEGER NOT NULL DEFAULT 0 CHECK (subscription_tokens >= 0),\n unknown_runs INTEGER NOT NULL DEFAULT 0 CHECK (unknown_runs >= 0),\n remediation_runs INTEGER NOT NULL DEFAULT 0 CHECK (remediation_runs >= 0),\n person_revisions INTEGER NOT NULL DEFAULT 0 CHECK (person_revisions >= 0),\n first_check_passed INTEGER CHECK (first_check_passed IS NULL OR first_check_passed IN (0,1)),\n agent_turns INTEGER NOT NULL DEFAULT 0 CHECK (agent_turns >= 0),\n turns_unknown_runs INTEGER NOT NULL DEFAULT 0 CHECK (turns_unknown_runs >= 0),\n provenance_json TEXT NOT NULL DEFAULT '[]',\n settled INTEGER NOT NULL DEFAULT 0 CHECK (settled IN (0,1)),\n closed INTEGER NOT NULL DEFAULT 0 CHECK (closed IN (0,1)),\n evaluated_at TEXT NOT NULL,\n CHECK ((state IN ('rejected','not-counted')) = (reason IS NOT NULL)),\n CHECK (closed = 0 OR settled = 1)\n);\nCREATE INDEX IF NOT EXISTS route_record_by_cohort ON route_record (repo, cohort_size, state, first_started_at);\nCREATE INDEX IF NOT EXISTS route_record_open ON route_record (evaluated_at) WHERE closed = 0;\nCREATE TABLE IF NOT EXISTS route_rule (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n repo TEXT NOT NULL,\n cohort_size TEXT NOT NULL CHECK (cohort_size IN ('small','medium')),\n kind TEXT NOT NULL CHECK (kind IN ('rule','trial')),\n provider TEXT NOT NULL,\n model TEXT NOT NULL,\n configured_provider TEXT NOT NULL,\n configured_model TEXT,\n state TEXT NOT NULL CHECK (state IN ('active','trialling','paused','switched-off','turned-off','expired','dropped','finished')),\n generation INTEGER NOT NULL DEFAULT 1 CHECK (generation >= 1),\n grant_json TEXT NOT NULL,\n prices_json TEXT NOT NULL,\n depends_json TEXT NOT NULL,\n trial_limit INTEGER CHECK (trial_limit IS NULL OR trial_limit >= 1),\n accepted_by TEXT NOT NULL,\n accepted_at TEXT NOT NULL,\n ended_at TEXT,\n ended_by TEXT,\n end_reason TEXT,\n CHECK ((kind = 'trial') = (trial_limit IS NOT NULL))\n);\nCREATE UNIQUE INDEX IF NOT EXISTS route_rule_one_winner ON route_rule (repo, cohort_size) WHERE state IN ('active','trialling','paused');\nCREATE TABLE IF NOT EXISTS route_exposure (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,\n rule INTEGER NOT NULL REFERENCES route_rule(id) ON DELETE CASCADE,\n created_at TEXT NOT NULL,\n closed_at TEXT,\n UNIQUE (task_ref, rule)\n);\nCREATE TABLE IF NOT EXISTS route_exposure_event (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n exposure INTEGER NOT NULL REFERENCES route_exposure(id) ON DELETE CASCADE,\n kind TEXT NOT NULL CHECK (kind IN ('proposed','refiled','approved-as-proposed','approved-with-other','cancelled')),\n model TEXT,\n at TEXT NOT NULL,\n CHECK ((kind = 'approved-with-other') = (model IS NOT NULL))\n);\nCREATE INDEX IF NOT EXISTS route_exposure_event_by_exposure ON route_exposure_event (exposure, id);\nCREATE TABLE IF NOT EXISTS route_trial_reservation (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n rule INTEGER NOT NULL REFERENCES route_rule(id) ON DELETE CASCADE,\n task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,\n scope_digest TEXT NOT NULL,\n state TEXT NOT NULL CHECK (state IN ('reserved','consumed','released')),\n consumed_run INTEGER REFERENCES run(id) ON DELETE SET NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n UNIQUE (rule, task_ref),\n CHECK (state = 'consumed' OR consumed_run IS NULL)\n);\nCREATE TABLE IF NOT EXISTS models_generation (\n scope TEXT NOT NULL,\n phase TEXT NOT NULL,\n generation INTEGER NOT NULL CHECK (generation >= 1),\n changed_at TEXT NOT NULL,\n PRIMARY KEY (scope, phase)\n);\n";
450
+ /** The tables V148_SCHEMA adds, in the order a test drops them to make the v147 shape. */
451
+ export declare const V148_TABLES: readonly ["route_trial_reservation", "route_exposure_event", "route_exposure", "route_rule", "route_record", "run_cohort", "models_generation"];
452
+ /** v148: a run's execution class (saved runs read NULL: none kept, which the records count as the model's), a
453
+ * revision's origin (saved revisions read NULL: not recorded, never a person), and a project's "Suggest cheaper models"
454
+ * switch (saved projects read 1: on). */
455
+ export declare const V148_ADDED_COLUMNS: Readonly<Record<string, Readonly<Record<string, {
456
+ definition: string;
457
+ saved: string;
458
+ }>>>>;
459
+ /** The v148 migration's steps after v147's, in order (OpenOptions.migrationStep may stop after each). */
460
+ export declare const V148_STEPS: readonly ["results-tables", "exec-class", "revision-origin", "suggest-models"];
430
461
  /**
431
462
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
432
463
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -8364,3 +8395,8 @@ export declare const LAPTOP_PORT_FACT = "laptop-port";
8364
8395
  export declare function laptopPortOf(store: Store): number | null;
8365
8396
  /** Record the laptop port, once (installation facts are never updated). */
8366
8397
  export declare function recordLaptopPort(store: Store, port: number, now: Date): void;
8398
+ /** The Models rows of a project being deleted (project-delete.ts), through the same counter as every other Models
8399
+ * writer: each phase whose rows go moves its generation (pausing what depended on it), then the project's
8400
+ * results-routing rows go (route-store.ts forgetProjectRoutes). One of the routing scan's named phase_* writers
8401
+ * (route-hooks.ts). How many rows went. */
8402
+ export declare function forgetProjectModels(store: Store, repo: string, now: Date): number;