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
@@ -14,6 +14,7 @@ import { INSTALLATION_SCOPE, resolvePhaseAgent, resolveRouteCandidates } from ".
14
14
  import { seenModels } from "./model-catalog.js";
15
15
  import { passwordlessOn } from "./passwordless.js";
16
16
  import { exactModelId, recommendRoute, routeProblems } from "./phase-routing.js";
17
+ import { liveEarnedRules } from "./route-store.js";
17
18
  import { isProviderId, validateSpec } from "./provider.js";
18
19
  /** Every row the editor owns, in the order the page and the diff show them. Alternates (`--also`) and the strong
19
20
  * repair row stay CLI-only; presets clear the strong repair row so repairs follow the builder. */
@@ -120,24 +121,29 @@ export const ROUTE_CASES = Object.freeze([
120
121
  { label: "large", size: "large", risky: false, strict: false }, { label: "risky", size: "small", risky: true, strict: false },
121
122
  { label: "strict", size: "medium", risky: false, strict: true },
122
123
  ]);
123
- export function effectiveRoutes(store, repo) {
124
+ export function effectiveRoutes(store, repo, options = {}) {
124
125
  const candidates = resolveRouteCandidates(store, repo);
125
126
  if (!candidates.ok)
126
127
  return { ok: false, problem: candidates.problem };
127
- return { ok: true, routes: routesOf(candidates.candidates) };
128
+ // A project's live earned rules route its eligible small and routine tasks (the one precedence order,
129
+ // phase-routing.ts selectBuildLeg); `rules: false` asks for the configured rows alone.
130
+ const rules = options.rules === false ? [] : options.rules ?? liveEarnedRules(store, repo);
131
+ return { ok: true, routes: routesOf(candidates.candidates, rules) };
128
132
  }
129
- /** Every size's and demand's route over these candidates. (A build moved to another provider for plan headroom takes
130
- * its repairs with it, so that move can't break a repair; a tier whose only candidate is another provider's is its
131
- * primary here, and checked.) */
132
- function routesOf(candidates) {
133
+ /** Every size's and demand's route over these candidates, with a project's earned rules where they apply. (A build
134
+ * moved to another provider for plan headroom takes its repairs with it, so that move can't break a repair; a tier
135
+ * whose only candidate is another provider's is its primary here, and checked.) */
136
+ function routesOf(candidates, rules = []) {
133
137
  const routes = ROUTE_CASES.map(one => ({ label: one.label, route: recommendRoute({ qualityMode: one.strict ? "strict" : "default", evidence: [], publication: "none",
134
- candidates, overrides: [], size: { size: one.size, risky: one.risky, source: "heuristic", reason: "" } }) }));
138
+ candidates, overrides: [], size: { size: one.size, risky: one.risky, source: "heuristic", reason: "" },
139
+ earned: rules.length === 0 ? null : { rules, release: false, protected: false } }) }));
135
140
  return routes;
136
141
  }
137
142
  /** Which preset a scope's EFFECTIVE configuration matches (what each size would really run, inherited rows
138
143
  * included), or null ("a custom set"). */
139
144
  export function presetInUse(store, scope) {
140
- const effective = effectiveRoutes(store, scope === INSTALLATION_SCOPE ? null : scope);
145
+ // The configured rows, not a project's earned rules: a rule never edits the owner's Models rows.
146
+ const effective = effectiveRoutes(store, scope === INSTALLATION_SCOPE ? null : scope, { rules: false });
141
147
  if (!effective.ok)
142
148
  return null;
143
149
  const legs = (label) => effective.routes.find(one => one.label === label).route.legs;
@@ -224,13 +230,22 @@ function projectsWithRows(store) {
224
230
  * or a route of any size or demand (small, routine, large, risky, strict) whose repair can't resume its builder,
225
231
  * inherited rows included. An invalid result is never saved.
226
232
  */
227
- export function checkScope(store, repo) {
233
+ export function checkScope(store, repo, options = {}) {
228
234
  const build = resolvePhaseAgent(store, "build", repo, {});
229
235
  if (!build.ok)
230
236
  return { kind: "invalid", problem: build.problem };
231
237
  const plan = resolvePhaseAgent(store, "plan", repo, {});
232
238
  if (!plan.ok)
233
239
  return { kind: "invalid", problem: plan.problem };
240
+ // Every live earned rule's route is checked with the configured ones (results routing): a rule's builder must
241
+ // share any configured repair row's provider, since repairs resume the builder's session. `rules` is the set to
242
+ // check (an acceptance adds its candidate rule); by default the project's live rules.
243
+ const rules = options.rules ?? liveEarnedRules(store, repo);
244
+ const repairRow = (repo === null ? null : store.phaseConfig(repo, "repair")) ?? store.phaseConfig(INSTALLATION_SCOPE, "repair");
245
+ for (const rule of rules) {
246
+ if (repairRow !== null && repairRow.provider !== rule.provider)
247
+ return { kind: "invalid", problem: `Repairs must use the builder's provider (${rule.provider}): an earned rule builds ${rule.cohortSize === "small" ? "small" : "routine"} tasks on it.` };
248
+ }
234
249
  // The reviewer is not checked here: an unset reviewer follows the planner, and routing deliberately never lets a
235
250
  // reviewer setting stop a task from planning or building (agentconfig.ts resolveRouteCandidates; that leg never
236
251
  // runs). So a Gemini planner with no reviewer of its own stays a valid configuration. A reviewer row written here is
@@ -244,12 +259,12 @@ export function checkScope(store, repo) {
244
259
  const configured = resolveRouteCandidates(store, repo, { plan: standIn(plan.spec), build: standIn(build.spec) });
245
260
  if (!configured.ok)
246
261
  return { kind: /has no exact model/.test(configured.problem) ? "incomplete" : "invalid", problem: configured.problem };
247
- for (const route of routesOf(configured.candidates)) {
262
+ for (const route of routesOf(configured.candidates, rules)) {
248
263
  const problems = routeProblems(route.route);
249
264
  if (problems.length > 0)
250
265
  return { kind: "invalid", problem: `${route.label} tasks: ${problems[0]}` };
251
266
  }
252
- const effective = effectiveRoutes(store, repo);
267
+ const effective = effectiveRoutes(store, repo, { rules });
253
268
  if (!effective.ok)
254
269
  return { kind: /has no exact model/.test(effective.problem) ? "incomplete" : "invalid", problem: effective.problem };
255
270
  return { kind: "ok" };
@@ -90,7 +90,207 @@ export declare const MODELS_DESCRIPTORS: readonly [{
90
90
  readonly takesValue: true;
91
91
  readonly meaning: "local Toolroll database";
92
92
  }];
93
+ }, {
94
+ readonly action: "results";
95
+ readonly synopsis: "each finished task's result record: which model built it, and whether it counts";
96
+ readonly mutation: "none";
97
+ readonly takesQuery: false;
98
+ readonly flags: readonly [{
99
+ readonly name: "repo";
100
+ readonly takesValue: true;
101
+ readonly meaning: "one project's records (default: every project you can open)";
102
+ }, {
103
+ readonly name: "json";
104
+ readonly takesValue: false;
105
+ readonly meaning: "versioned structured response";
106
+ }, {
107
+ readonly name: "db";
108
+ readonly takesValue: true;
109
+ readonly meaning: "local Toolroll database";
110
+ }];
111
+ }, {
112
+ readonly action: "suggestions";
113
+ readonly synopsis: "cheaper models a project's own results suggest, and its earned rules";
114
+ readonly mutation: "none";
115
+ readonly takesQuery: false;
116
+ readonly flags: readonly [{
117
+ readonly name: "repo";
118
+ readonly takesValue: true;
119
+ readonly meaning: "one project's records (default: every project you can open)";
120
+ }, {
121
+ readonly name: "json";
122
+ readonly takesValue: false;
123
+ readonly meaning: "versioned structured response";
124
+ }, {
125
+ readonly name: "db";
126
+ readonly takesValue: true;
127
+ readonly meaning: "local Toolroll database";
128
+ }];
129
+ }, {
130
+ readonly action: "accept";
131
+ readonly synopsis: "accept a suggestion as an earned rule, with its switch-back terms: models accept <id> --repo <p>";
132
+ readonly mutation: "unkeyed";
133
+ readonly takesQuery: true;
134
+ readonly positional: "the suggestion's id, as models suggestions lists it";
135
+ readonly flags: readonly [{
136
+ readonly name: "repo";
137
+ readonly takesValue: true;
138
+ readonly meaning: "the project (default: the one project you can open)";
139
+ }, {
140
+ readonly name: "as";
141
+ readonly takesValue: true;
142
+ readonly meaning: "your approver name";
143
+ }, {
144
+ readonly name: "token";
145
+ readonly takesValue: true;
146
+ readonly meaning: "your password (accepting and keeping take one typed now)";
147
+ }, {
148
+ readonly name: "json";
149
+ readonly takesValue: false;
150
+ readonly meaning: "versioned structured response";
151
+ }, {
152
+ readonly name: "db";
153
+ readonly takesValue: true;
154
+ readonly meaning: "local Toolroll database";
155
+ }];
156
+ }, {
157
+ readonly action: "dismiss";
158
+ readonly synopsis: "Not now: keep a suggestion quiet for 30 days: models dismiss <id> --repo <p>";
159
+ readonly mutation: "unkeyed";
160
+ readonly takesQuery: true;
161
+ readonly positional: "the suggestion's id, as models suggestions lists it";
162
+ readonly flags: readonly [{
163
+ readonly name: "repo";
164
+ readonly takesValue: true;
165
+ readonly meaning: "the project (default: the one project you can open)";
166
+ }, {
167
+ readonly name: "as";
168
+ readonly takesValue: true;
169
+ readonly meaning: "your approver name";
170
+ }, {
171
+ readonly name: "token";
172
+ readonly takesValue: true;
173
+ readonly meaning: "your password (accepting and keeping take one typed now)";
174
+ }, {
175
+ readonly name: "json";
176
+ readonly takesValue: false;
177
+ readonly meaning: "versioned structured response";
178
+ }, {
179
+ readonly name: "db";
180
+ readonly takesValue: true;
181
+ readonly meaning: "local Toolroll database";
182
+ }];
183
+ }, {
184
+ readonly action: "turn-off";
185
+ readonly synopsis: "turn an earned rule off, or drop a paused one: models turn-off <rule>";
186
+ readonly mutation: "unkeyed";
187
+ readonly takesQuery: true;
188
+ readonly positional: "the rule's number, as models suggestions lists it";
189
+ readonly flags: readonly [{
190
+ readonly name: "repo";
191
+ readonly takesValue: true;
192
+ readonly meaning: "the project (default: the one project you can open)";
193
+ }, {
194
+ readonly name: "as";
195
+ readonly takesValue: true;
196
+ readonly meaning: "your approver name";
197
+ }, {
198
+ readonly name: "token";
199
+ readonly takesValue: true;
200
+ readonly meaning: "your password (accepting and keeping take one typed now)";
201
+ }, {
202
+ readonly name: "json";
203
+ readonly takesValue: false;
204
+ readonly meaning: "versioned structured response";
205
+ }, {
206
+ readonly name: "db";
207
+ readonly takesValue: true;
208
+ readonly meaning: "local Toolroll database";
209
+ }];
210
+ }, {
211
+ readonly action: "keep";
212
+ readonly synopsis: "keep a paused earned rule: models keep <rule>";
213
+ readonly mutation: "unkeyed";
214
+ readonly takesQuery: true;
215
+ readonly positional: "the rule's number, as models suggestions lists it";
216
+ readonly flags: readonly [{
217
+ readonly name: "repo";
218
+ readonly takesValue: true;
219
+ readonly meaning: "the project (default: the one project you can open)";
220
+ }, {
221
+ readonly name: "as";
222
+ readonly takesValue: true;
223
+ readonly meaning: "your approver name";
224
+ }, {
225
+ readonly name: "token";
226
+ readonly takesValue: true;
227
+ readonly meaning: "your password (accepting and keeping take one typed now)";
228
+ }, {
229
+ readonly name: "json";
230
+ readonly takesValue: false;
231
+ readonly meaning: "versioned structured response";
232
+ }, {
233
+ readonly name: "db";
234
+ readonly takesValue: true;
235
+ readonly meaning: "local Toolroll database";
236
+ }];
237
+ }, {
238
+ readonly action: "try";
239
+ readonly synopsis: "try a cheaper model on the next 10 small or routine tasks, each approved by a person: models try <id> --repo <p>";
240
+ readonly mutation: "unkeyed";
241
+ readonly takesQuery: true;
242
+ readonly positional: "the trial offer's id, as models suggestions lists it";
243
+ readonly flags: readonly [{
244
+ readonly name: "repo";
245
+ readonly takesValue: true;
246
+ readonly meaning: "the project (default: the one project you can open)";
247
+ }, {
248
+ readonly name: "as";
249
+ readonly takesValue: true;
250
+ readonly meaning: "your approver name";
251
+ }, {
252
+ readonly name: "token";
253
+ readonly takesValue: true;
254
+ readonly meaning: "your password (accepting and keeping take one typed now)";
255
+ }, {
256
+ readonly name: "json";
257
+ readonly takesValue: false;
258
+ readonly meaning: "versioned structured response";
259
+ }, {
260
+ readonly name: "db";
261
+ readonly takesValue: true;
262
+ readonly meaning: "local Toolroll database";
263
+ }];
264
+ }, {
265
+ readonly action: "stop";
266
+ readonly synopsis: "stop a trial; tasks already approved on it keep their model: models stop <trial>";
267
+ readonly mutation: "unkeyed";
268
+ readonly takesQuery: true;
269
+ readonly positional: "the trial's number, as models suggestions lists it";
270
+ readonly flags: readonly [{
271
+ readonly name: "repo";
272
+ readonly takesValue: true;
273
+ readonly meaning: "the project (default: the one project you can open)";
274
+ }, {
275
+ readonly name: "as";
276
+ readonly takesValue: true;
277
+ readonly meaning: "your approver name";
278
+ }, {
279
+ readonly name: "token";
280
+ readonly takesValue: true;
281
+ readonly meaning: "your password (accepting and keeping take one typed now)";
282
+ }, {
283
+ readonly name: "json";
284
+ readonly takesValue: false;
285
+ readonly meaning: "versioned structured response";
286
+ }, {
287
+ readonly name: "db";
288
+ readonly takesValue: true;
289
+ readonly meaning: "local Toolroll database";
290
+ }];
93
291
  }];
292
+ /** `proof`: the person who proved themselves for this command — at the credential generation read before the proof,
293
+ * and how — or null. The rule doors re-prove it inside their own transaction. */
94
294
  export type ModelsCliContext = {
95
295
  store: Store;
96
296
  write: (line: string) => void;
@@ -98,5 +298,10 @@ export type ModelsCliContext = {
98
298
  now: Date;
99
299
  actor: string | null;
100
300
  seams?: CatalogSeams;
301
+ proof?: {
302
+ actor: string;
303
+ generation: number;
304
+ method: string;
305
+ } | null;
101
306
  };
102
307
  export declare function runModelsCommand(positional: readonly string[], options: Map<string, string | true>, context: ModelsCliContext): Promise<number>;
@@ -3,17 +3,44 @@
3
3
  import { envelopeJson } from "./envelope.js";
4
4
  import { checkModels, isNewModel, modelOptions, priceWords, RUNTIME_TOOLS, runtimeStates, seenModels, setWatch, updateRuntime, watchState } from "./model-catalog.js";
5
5
  import { isProviderId } from "./provider.js";
6
+ import { canonicalProject } from "./project.js";
7
+ import { resultRecords } from "./route-results.js";
8
+ import { acceptSuggestion, dismissSuggestion, grantWords, keepRule, ruleById, ruleWords, rulesOf, startTrial, stopTrial, suggestionsFor, suggestionWords, trialOffersFor, trialOfferWords, trialsOf, trialWords, turnOffRule } from "./route-rules.js";
9
+ import { trialPlacesOf } from "./route-store.js";
10
+ import { currentLocalOwner } from "./local-owner.js";
11
+ import { REASON_WORDS } from "./contracts/route-results.js";
12
+ import { resolve } from "node:path";
6
13
  const flags = [
7
14
  { name: "provider", takesValue: true, meaning: "claude, codex, gemini or openrouter, for models list" },
8
15
  { name: "json", takesValue: false, meaning: "versioned structured response" },
9
16
  { name: "db", takesValue: true, meaning: "local Toolroll database" },
10
17
  ];
18
+ const resultFlags = [
19
+ { name: "repo", takesValue: true, meaning: "one project's records (default: every project you can open)" },
20
+ { name: "json", takesValue: false, meaning: "versioned structured response" },
21
+ { name: "db", takesValue: true, meaning: "local Toolroll database" },
22
+ ];
23
+ const ruleFlags = [
24
+ { name: "repo", takesValue: true, meaning: "the project (default: the one project you can open)" },
25
+ { name: "as", takesValue: true, meaning: "your approver name" },
26
+ { name: "token", takesValue: true, meaning: "your password (accepting and keeping take one typed now)" },
27
+ { name: "json", takesValue: false, meaning: "versioned structured response" },
28
+ { name: "db", takesValue: true, meaning: "local Toolroll database" },
29
+ ];
11
30
  export const MODELS_DESCRIPTORS = [
12
31
  { action: "status", synopsis: "installed CLI versions, available updates, new models and the automatic check", mutation: "none", takesQuery: false, flags },
13
32
  { action: "list", synopsis: "models a provider can run, newest first, with prices (--provider claude|codex|gemini|openrouter)", mutation: "none", takesQuery: false, flags },
14
33
  { action: "check", synopsis: "fetch the public model lists and check CLI versions now", mutation: "unkeyed", takesQuery: false, flags },
15
34
  { action: "update", synopsis: "update one CLI while no work is running: models update claude|codex|gemini", mutation: "unkeyed", takesQuery: true, flags },
16
35
  { action: "watch", synopsis: "turn the 6-hourly check and its messages on or off: models watch on|off", mutation: "unkeyed", takesQuery: true, flags },
36
+ { action: "results", synopsis: "each finished task's result record: which model built it, and whether it counts", mutation: "none", takesQuery: false, flags: resultFlags },
37
+ { action: "suggestions", synopsis: "cheaper models a project's own results suggest, and its earned rules", mutation: "none", takesQuery: false, flags: resultFlags },
38
+ { action: "accept", synopsis: "accept a suggestion as an earned rule, with its switch-back terms: models accept <id> --repo <p>", mutation: "unkeyed", takesQuery: true, positional: "the suggestion's id, as models suggestions lists it", flags: ruleFlags },
39
+ { action: "dismiss", synopsis: "Not now: keep a suggestion quiet for 30 days: models dismiss <id> --repo <p>", mutation: "unkeyed", takesQuery: true, positional: "the suggestion's id, as models suggestions lists it", flags: ruleFlags },
40
+ { action: "turn-off", synopsis: "turn an earned rule off, or drop a paused one: models turn-off <rule>", mutation: "unkeyed", takesQuery: true, positional: "the rule's number, as models suggestions lists it", flags: ruleFlags },
41
+ { action: "keep", synopsis: "keep a paused earned rule: models keep <rule>", mutation: "unkeyed", takesQuery: true, positional: "the rule's number, as models suggestions lists it", flags: ruleFlags },
42
+ { action: "try", synopsis: "try a cheaper model on the next 10 small or routine tasks, each approved by a person: models try <id> --repo <p>", mutation: "unkeyed", takesQuery: true, positional: "the trial offer's id, as models suggestions lists it", flags: ruleFlags },
43
+ { action: "stop", synopsis: "stop a trial; tasks already approved on it keep their model: models stop <trial>", mutation: "unkeyed", takesQuery: true, positional: "the trial's number, as models suggestions lists it", flags: ruleFlags },
17
44
  ];
18
45
  export async function runModelsCommand(positional, options, context) {
19
46
  const action = positional[0] ?? "status";
@@ -21,10 +48,20 @@ export async function runModelsCommand(positional, options, context) {
21
48
  const fail = (reason, message) => { context.write(context.json ? envelopeJson({ ok: false, command, reason, message }) : message); return reason === "usage" ? 2 : 1; };
22
49
  const ok = (result, lines) => { context.write(context.json ? envelopeJson({ ok: true, command, ...result }) : lines.join("\n")); return 0; };
23
50
  if (!MODELS_DESCRIPTORS.some(one => one.action === action))
24
- return fail("usage", "Use models status | list --provider <p> | check | update <claude|codex|gemini> | watch on|off.");
25
- if (context.actor === null)
51
+ return fail("usage", "Use models status | list --provider <p> | check | update <claude|codex|gemini> | watch on|off | results | suggestions | accept <id> | dismiss <id> | turn-off <rule> | keep <rule> | try <id> | stop <trial>.");
52
+ // This computer's local owner (local mode, through the terminal UI) may turn a rule off and say Not now: the door
53
+ // re-checks that its in-flight act is exactly that one.
54
+ const local = currentLocalOwner();
55
+ if (context.actor === null && !(local !== null && (action === "turn-off" || action === "dismiss" || action === "suggestions" || action === "stop")))
26
56
  return fail("unauthenticated", "Sign in first: the remembered local login drives models commands.");
27
57
  const { store, now } = context;
58
+ if (action === "results")
59
+ return modelsResults(options, context, ok, fail);
60
+ if (action === "suggestions" || action === "accept" || action === "dismiss" || action === "turn-off" || action === "keep" || action === "try" || action === "stop")
61
+ return modelsRules(action, positional, options, { ...context, actor: context.actor ?? local.account }, ok, fail, local !== null && context.proof == null);
62
+ const actor = context.actor;
63
+ if (actor === null)
64
+ return fail("unauthenticated", "Sign in first: the remembered local login drives models commands.");
28
65
  if (action === "status") {
29
66
  const runtimes = runtimeStates(store), watch = watchState(store);
30
67
  const fresh = ["claude", "codex", "gemini"].flatMap(source => seenModels(store, source)).filter(model => isNewModel(model, now));
@@ -55,12 +92,114 @@ export async function runModelsCommand(positional, options, context) {
55
92
  const tool = positional[1] ?? "";
56
93
  if (!(tool in RUNTIME_TOOLS))
57
94
  return fail("usage", "Name one CLI: models update claude|codex|gemini.");
58
- const updated = await updateRuntime(store, tool, context.actor, now, context.seams);
95
+ const updated = await updateRuntime(store, tool, actor, now, context.seams);
59
96
  return updated.ok ? ok({ tool, message: updated.message }, [updated.message]) : fail("refused", updated.message);
60
97
  }
61
98
  const wanted = positional[1];
62
99
  if (wanted !== "on" && wanted !== "off")
63
100
  return fail("usage", "Use models watch on or models watch off.");
64
- setWatch(store, wanted === "on", context.actor, now);
101
+ setWatch(store, wanted === "on", actor, now);
65
102
  return ok({ enabled: wanted === "on" }, [wanted === "on" ? "Automatic checks are on: every 6 hours, with a message for each new model or CLI update." : "Automatic checks are off."]);
66
103
  }
104
+ /** `models results [--repo <p>]`: the saved result records of the projects this person can open, newest first — the
105
+ * same records the task page's "Result record" line reads. Read-only. */
106
+ function modelsResults(options, context, ok, fail) {
107
+ const { store } = context;
108
+ for (const key of options.keys())
109
+ if (!["repo", "json", "db", "as", "token", "token-file", "token-env"].includes(key))
110
+ return fail("usage", `--${key} is not a models results option.`);
111
+ const open = store.knownRepos().filter(repo => store.accountCanAccess(context.actor, repo));
112
+ const asked = options.get("repo");
113
+ if (asked === true)
114
+ return fail("usage", "--repo takes a project path.");
115
+ const wanted = asked === undefined ? null : canonicalProject(asked) ?? resolve(asked);
116
+ if (wanted !== null && !open.includes(wanted))
117
+ return fail("refused", "That project isn't one you can open here.");
118
+ const records = resultRecords(store, wanted === null ? open : [wanted]);
119
+ const counted = (state) => records.filter(one => one.state === state).length;
120
+ const summary = `${records.length} record${records.length === 1 ? "" : "s"}: ${counted("mature")} counted, ${counted("rejected")} counted against, ${counted("provisional")} accepted and waiting, ${counted("pending")} pending, ${counted("not-counted")} not counted`;
121
+ return ok({ records, reasons: REASON_WORDS }, records.length === 0
122
+ ? ["No result records yet. A record starts when a task's first build runs."]
123
+ : [summary, ...records.map(one => `${one.task} · ${one.model ?? "unknown model"}${one.size === null ? "" : ` · ${one.size}`} · ${one.words}`)]);
124
+ }
125
+ /**
126
+ * Earned rules on the command line (docs/plans/results-routing.md, slice 2), the same doors Settings → Models uses:
127
+ * `suggestions` reads; `accept` and `keep` take the person's own proof, re-proved inside the write (a lead token or a
128
+ * remote token is refused there); `turn-off` and `dismiss` take the person, or this computer's local owner inside
129
+ * exactly that act.
130
+ */
131
+ function modelsRules(action, positional, options, context, ok, fail, asLocal) {
132
+ const { store, now } = context;
133
+ const allowed = action === "suggestions" ? ["repo", "json", "db", "as", "token", "token-file", "token-env"] : ["repo", "json", "db", "as", "token", "token-file", "token-env"];
134
+ for (const key of options.keys())
135
+ if (!allowed.includes(key))
136
+ return fail("usage", `--${key} is not a models ${action} option.`);
137
+ const open = store.knownRepos().filter(repo => store.accountCanAccess(context.actor, repo));
138
+ const asked = options.get("repo");
139
+ if (asked === true)
140
+ return fail("usage", "--repo takes a project path.");
141
+ const wanted = asked === undefined ? null : canonicalProject(asked) ?? resolve(asked);
142
+ if (wanted !== null && !open.includes(wanted))
143
+ return fail("refused", "That project isn't one you can open here.");
144
+ if (action === "suggestions") {
145
+ const repos = wanted === null ? open : [wanted];
146
+ const suggestions = repos.flatMap(repo => suggestionsFor(store, repo, now));
147
+ const rules = repos.flatMap(repo => rulesOf(store, repo, now));
148
+ const trialOffers = repos.flatMap(repo => trialOffersFor(store, repo, now));
149
+ const trials = repos.flatMap(repo => trialsOf(store, repo, now));
150
+ return ok({
151
+ suggestions: suggestions.map(one => ({ id: one.id, repo: one.repo, size: one.cohortSize, model: one.model, name: one.name, configured: one.configured,
152
+ evidence: one.evidence, configuredEvidence: one.configuredEvidence, prices: one.prices, terms: grantWords(one.grant), words: suggestionWords(one) })),
153
+ rules: rules.map(one => ({ id: one.id, repo: one.repo, size: one.cohortSize, model: one.model, configured: one.configured, state: one.state, acceptedAt: one.acceptedAt,
154
+ acceptedBy: one.acceptedBy, expires: one.grant?.expires ?? null, endedAt: one.endedAt, endReason: one.endReason, words: ruleWords(store, one) })),
155
+ // Slice 3: cheaper models to try where results don't yet suggest one, and the trials in use.
156
+ trialOffers: trialOffers.map(one => ({ id: one.id, repo: one.repo, size: one.cohortSize, model: one.model, name: one.name, configured: one.configured,
157
+ evidence: one.evidence, configuredEvidence: one.configuredEvidence, prices: one.prices, places: one.places, words: trialOfferWords(one) })),
158
+ trials: trials.map(one => ({ id: one.id, repo: one.repo, size: one.cohortSize, model: one.model, configured: one.configured, state: one.state, startedAt: one.acceptedAt,
159
+ startedBy: one.acceptedBy, endedAt: one.endedAt, endReason: one.endReason, places: trialPlacesOf(store, one.id), words: trialWords(store, one) })),
160
+ }, suggestions.length === 0 && rules.length === 0 && trialOffers.length === 0 && trials.length === 0 ? ["No suggestions yet. A cheaper model is suggested once its results here match the configured one's."] : [
161
+ ...suggestions.flatMap(one => [`Suggested · ${one.repo} · ${suggestionWords(one)}`, ` accept: toolroll models accept ${one.id} --repo ${one.repo}`, ` not now: toolroll models dismiss ${one.id} --repo ${one.repo}`]),
162
+ ...rules.map(one => `Rule ${one.id} · ${one.repo} · ${ruleWords(store, one)}`),
163
+ ...trialOffers.flatMap(one => [`Try · ${one.repo} · ${trialOfferWords(one)}`, ` try: toolroll models try ${one.id} --repo ${one.repo}`, ` not now: toolroll models dismiss ${one.id} --repo ${one.repo}`]),
164
+ ...trials.map(one => `Trial ${one.id} · ${one.repo} · ${trialWords(store, one)}`),
165
+ ]);
166
+ }
167
+ const target = positional[1];
168
+ if (target === undefined || positional.length !== 2)
169
+ return fail("usage", action === "accept" || action === "dismiss" || action === "try" ? `Name one offer: models ${action} <id> --repo <p>.` : action === "stop" ? "Name one trial: models stop <trial>." : `Name one rule: models ${action} <rule>.`);
170
+ const proof = context.proof ?? null;
171
+ const door = proof !== null ? { kind: "person", person: { ...proof, live: () => true } } : asLocal ? { kind: "local-owner", actor: context.actor } : null;
172
+ if (door === null)
173
+ return fail("unauthenticated", "This takes your own sign-in: --as <you> --token <password>.");
174
+ const said = (result, payload, line) => result.ok ? ok(payload, [line]) : fail("refused", result.problem);
175
+ if (action === "accept" || action === "dismiss" || action === "try") {
176
+ const repo = wanted ?? (open.length === 1 ? open[0] : null);
177
+ if (repo === null)
178
+ return fail("usage", "Name the project: --repo <p>.");
179
+ if (action === "try") {
180
+ const started = startTrial(store, { repo, id: target }, door, now);
181
+ return started.ok ? ok({ trial: started.trial, repo, model: started.offer.model, size: started.offer.cohortSize, places: started.offer.places }, [`Trial ${started.trial}: the next ${started.offer.places} ${started.offer.cohortSize === "small" ? "small" : "routine"} tasks try ${started.offer.name}. Each waits for a person's approval.`]) : fail("refused", started.problem);
182
+ }
183
+ if (action === "accept") {
184
+ const accepted = acceptSuggestion(store, { repo, id: target }, door, now);
185
+ return accepted.ok ? ok({ rule: accepted.rule, repo, model: accepted.suggestion.model, size: accepted.suggestion.cohortSize, terms: grantWords(accepted.suggestion.grant) }, [`Rule ${accepted.rule}: ${suggestionWords(accepted.suggestion)}`, ...grantWords(accepted.suggestion.grant).map(line => ` ${line}`)]) : fail("refused", accepted.problem);
186
+ }
187
+ return said(dismissSuggestion(store, { repo, id: target }, door, now), { repo, id: target }, "Not now: this suggestion stays quiet for 30 days.");
188
+ }
189
+ const id = /^[1-9][0-9]{0,9}$/.test(target) ? Number(target) : null;
190
+ const rule = id === null ? null : ruleById(store, id);
191
+ if (action === "stop") {
192
+ if (rule === null || rule.kind !== "trial" || !open.includes(rule.repo))
193
+ return fail("refused", "That trial isn't one you can see here.");
194
+ const stopped = stopTrial(store, rule.id, door, now);
195
+ return stopped.ok ? ok({ trial: stopped.trial.id, state: stopped.trial.state }, [`Trial ${stopped.trial.id} stopped. Tasks already approved on it keep their model.`]) : fail("refused", stopped.problem);
196
+ }
197
+ if (rule === null || rule.kind !== "rule" || !open.includes(rule.repo))
198
+ return fail("refused", "That rule isn't one you can see here.");
199
+ if (action === "turn-off") {
200
+ const turned = turnOffRule(store, rule.id, door, now);
201
+ return turned.ok ? ok({ rule: turned.rule.id, state: turned.rule.state }, [`Rule ${turned.rule.id} is off. Tasks already approved keep the model they were approved with.`]) : fail("refused", turned.problem);
202
+ }
203
+ const kept = keepRule(store, rule.id, door, now);
204
+ return kept.ok ? ok({ rule: kept.rule.id, state: kept.rule.state }, [`Rule ${kept.rule.id} is on again: ${ruleWords(store, kept.rule)}`]) : fail("refused", kept.problem);
205
+ }
@@ -98,6 +98,63 @@ export type ModelsView = {
98
98
  }[];
99
99
  /** What this request's approval shows (approvalFactsNow): a step-up asks for the password unless Passwordless is on. */
100
100
  facts?: ApprovalFacts;
101
+ /** Results routing (slice 2): cheaper models this project's own results suggest, its earned rules, its switch. */
102
+ suggested?: SuggestedView;
103
+ };
104
+ /** The Suggested group: offers, a reviewed offer, the project's rules, and its "Suggest cheaper models" switch. */
105
+ export type SuggestedView = {
106
+ /** The project shown, or null on the installation scope (offers from every project, each reviewed on its own). */
107
+ repo: string | null;
108
+ offers: {
109
+ id: string;
110
+ repo: string;
111
+ project: string;
112
+ title: string;
113
+ words: string;
114
+ }[];
115
+ /** "Review change": the evidence, the switch-back terms and the step-up save; `stale` when the offer reviewed is
116
+ * no longer the one on offer. */
117
+ review: {
118
+ id: string;
119
+ repo: string;
120
+ title: string;
121
+ evidence: string[];
122
+ prices: string;
123
+ terms: string[];
124
+ save: string;
125
+ } | {
126
+ stale: true;
127
+ repo: string;
128
+ } | null;
129
+ rules: {
130
+ id: number;
131
+ repo: string;
132
+ project: string;
133
+ title: string;
134
+ words: string;
135
+ state: "active" | "paused" | "ended";
136
+ }[];
137
+ /** The project's switch (null on the installation scope). */
138
+ suggest: {
139
+ on: boolean;
140
+ } | null;
141
+ /** Slice 3: cheaper models to try on the next few tasks (a step-up starts one), and the trials in use (Stop). */
142
+ trialOffers?: {
143
+ id: string;
144
+ repo: string;
145
+ project: string;
146
+ title: string;
147
+ words: string;
148
+ save: string;
149
+ }[];
150
+ trials?: {
151
+ id: number;
152
+ repo: string;
153
+ project: string;
154
+ title: string;
155
+ words: string;
156
+ live: boolean;
157
+ }[];
101
158
  };
102
159
  export declare function modelsHtml(view: ModelsView): Html;
103
160
  /** Filters one long picker as you type; the saved choice always stays listed. */
package/dist/models-ui.js CHANGED
@@ -59,7 +59,7 @@ ${formRow("Automatic checks", `${view.watch.enabled ? "On" : "Off"}. Every 6 hou
59
59
  : note(`Checked ${when(view.watch.checkedAt)}.`);
60
60
  const fresh = view.fresh.length === 0 ? "" : sec("new", "New models", "", view.fresh.map(model => formRow(model.name, `${priceWords(model)}${model.releasedAt === null ? "" : ` · released ${model.releasedAt.slice(0, 10)}`}`, chip("New", "", { dot: false }), "inline")));
61
61
  const chat = view.chat === null ? "" : formRow("Chat", view.chat.words, btn("Change", { sm: true, variant: "ghost", href: "/settings/lead" }), "inline");
62
- const body = html `<div class="models">${outcome}${preview}${choiceSection(view, manage, previewing)}${recentSection(view)}${sec("tools", "AI tools on this computer", "", html `${tools}${checks}`)}${fresh}${chat}</div>`;
62
+ const body = html `<div class="models">${outcome}${preview}${choiceSection(view, manage, previewing)}${suggestedSection(view)}${recentSection(view)}${sec("tools", "AI tools on this computer", "", html `${tools}${checks}`)}${fresh}${chat}</div>`;
63
63
  return settingsShell({ group: "agents", sub: true, title: "Models", lead: "Which model plans, builds and reviews each task. Changes apply to tasks filed from now on.", body });
64
64
  }
65
65
  /** Filters one long picker as you type; the saved choice always stays listed. */
@@ -123,3 +123,46 @@ function recentSection(view) {
123
123
  return "";
124
124
  return sec("recent", "Recent work", "", view.recent.map(one => formRow(one.role, `Chosen ${one.chosen} → ran on ${one.ranOn}`, html `<a class="k-link k-mono" href="/t/${encodeURIComponent(one.taskId)}">${one.taskId}</a>`, "inline")));
125
125
  }
126
+ /** Settings → Models → Suggested (docs/plans/results-routing.md slice 2): what a project's own results suggest, each
127
+ * reviewed before it's saved with a step-up; the rules in force with Turn off (Keep or Drop when paused); and the
128
+ * project's "Suggest cheaper models" switch. Turning off and Not now only take authority away: no password. */
129
+ function suggestedSection(view) {
130
+ const s = view.suggested;
131
+ if (s === undefined)
132
+ return "";
133
+ const manage = view.csrf !== "";
134
+ const scoped = (repo) => `/settings/models?scope=${encodeURIComponent(repo)}`;
135
+ const review = s.review === null ? ""
136
+ : "stale" in s.review
137
+ ? html `${notice("This suggestion is out of date: its results, a price or Models changed since. Nothing was saved.")}${acts(btn("Back", { sm: true, href: `${scoped(s.review.repo)}#models-suggested` }))}`
138
+ : html `<div class="models-diff">${factList([
139
+ ["Change", s.review.title],
140
+ ["Evidence", html `${s.review.evidence.map(line => html `<span>${line}</span><br>`)}`],
141
+ ["Prices", s.review.prices],
142
+ ["Switches back", html `${s.review.terms.map(line => html `<span>${line}</span><br>`)}`],
143
+ ])}${manage ? postForm("/settings/models/accept", html `${note(`Every task is still approved, and keeps the model it was approved with. ${termsOf("step-up", factsOf(view))}`)}${password(view, "models-accept-pw")}${acts(btn(s.review.save, { variant: "primary", type: "submit" }), btn("Cancel", { variant: "ghost", href: `${scoped(s.review.repo)}#models-suggested` }))}`, { hidden: { scope: s.review.repo, id: s.review.id }, attrs: { class: "models-editor" } }) : ""}</div>`;
144
+ const offers = s.offers.map(one => formRow(s.repo === null ? `${one.title} · ${one.project}` : one.title, one.words, manage
145
+ ? acts(btn("Review change", { sm: true, href: `${scoped(one.repo)}&suggestion=${encodeURIComponent(one.id)}#models-suggested` }), postForm("/settings/models/not-now", btn("Not now", { sm: true, variant: "ghost", type: "submit" }), { hidden: { scope: one.repo, id: one.id } }))
146
+ : html ``, "top"));
147
+ const rules = s.rules.map(one => formRow(s.repo === null ? `${one.title} · ${one.project}` : one.title, one.words, !manage || one.state === "ended" ? html ``
148
+ : one.state === "paused"
149
+ ? acts(postForm("/settings/models/keep", html `${password(view, `models-keep-${one.id}`)}${btn("Keep", { sm: true, type: "submit" })}`, { hidden: { scope: one.repo, rule: String(one.id) } }), postForm("/settings/models/turn-off", btn("Drop", { sm: true, variant: "ghost", type: "submit" }), { hidden: { scope: one.repo, rule: String(one.id) } }))
150
+ : postForm("/settings/models/turn-off", btn("Turn off", { sm: true, type: "submit" }), { hidden: { scope: one.repo, rule: String(one.id) } }), "top"));
151
+ // Trials (slice 3): an offer to try a cheaper model on the next few tasks — started with a step-up, each task still
152
+ // approved by a person — and the trials in use, each with its progress and Stop (stopping only lowers: no password).
153
+ const trialOffers = (s.trialOffers ?? []).map(one => formRow(s.repo === null ? `${one.title} · ${one.project}` : one.title, one.words, manage
154
+ ? acts(postForm("/settings/models/try", html `${password(view, `models-try-${one.id.replace(/[^a-z0-9]/gi, "-")}`)}${btn(one.save, { sm: true, type: "submit" })}`, { hidden: { scope: one.repo, id: one.id } }), postForm("/settings/models/not-now", btn("Not now", { sm: true, variant: "ghost", type: "submit" }), { hidden: { scope: one.repo, id: one.id } }))
155
+ : html ``, "top"));
156
+ const trials = (s.trials ?? []).map(one => formRow(s.repo === null ? `${one.title} · ${one.project}` : one.title, one.words, !manage || !one.live ? html ``
157
+ : postForm("/settings/models/stop", btn("Stop", { sm: true, type: "submit" }), { hidden: { scope: one.repo, rule: String(one.id) } }), "top"));
158
+ const toggle = s.suggest === null || s.repo === null ? "" : formRow("Suggest cheaper models", s.suggest.on
159
+ ? "On. When a cheaper model does as well on this project's small or routine tasks, it's suggested here. Nothing changes until you accept it."
160
+ : "Off. No new suggestions for this project.", !manage ? html `` : s.suggest.on
161
+ ? postForm("/settings/models/suggest-off", btn("Turn off", { sm: true, type: "submit" }), { hidden: { scope: s.repo } })
162
+ : postForm("/settings/models/suggest-on", html `${password(view, "models-suggest-pw")}${btn("Turn on", { sm: true, type: "submit" })}`, { hidden: { scope: s.repo } }), "inline");
163
+ const emptyWords = s.repo === null ? "No suggestions yet. Choose a project above to see its own." : "No suggestions yet. When a cheaper model's results on this project's small or routine tasks match the configured model's, it's suggested here.";
164
+ const nothing = offers.length === 0 && rules.length === 0 && trialOffers.length === 0 && trials.length === 0 && s.review === null;
165
+ const body = html `${review}${nothing ? note(emptyWords) : ""}${offers}${trialOffers}${rules.length === 0 ? "" : html `${group("Rules in use")}${rules}`}${trials.length === 0 ? "" : html `${group("Trials")}${trials}`}${toggle}`;
166
+ const waiting = s.offers.length + trialOffers.length;
167
+ return sec("suggested", "Suggested", waiting === 0 ? "" : `${waiting} to review`, body);
168
+ }