toolroll 0.9.91 → 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 (78) hide show
  1. package/CHANGELOG.md +22 -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-auto.js +12 -3
  47. package/dist/result-actions.d.ts +4 -0
  48. package/dist/result-actions.js +3 -0
  49. package/dist/route-hooks.d.ts +54 -0
  50. package/dist/route-hooks.js +179 -0
  51. package/dist/route-results.d.ts +70 -0
  52. package/dist/route-results.js +340 -0
  53. package/dist/route-rules.d.ts +324 -0
  54. package/dist/route-rules.js +781 -0
  55. package/dist/route-savings.d.ts +136 -0
  56. package/dist/route-savings.js +193 -0
  57. package/dist/route-store.d.ts +300 -0
  58. package/dist/route-store.js +598 -0
  59. package/dist/scope.d.ts +3 -1
  60. package/dist/scope.js +4 -1
  61. package/dist/server/render-chat.js +2 -2
  62. package/dist/server/render-tasks.d.ts +7 -0
  63. package/dist/server/render-tasks.js +13 -0
  64. package/dist/server/route-table.js +12 -0
  65. package/dist/server/settings.d.ts +2 -1
  66. package/dist/server/settings.js +87 -1
  67. package/dist/server/task-views.js +6 -0
  68. package/dist/server/tasks.js +3 -0
  69. package/dist/settings-proofs.js +2 -0
  70. package/dist/setup-guide.d.ts +2 -2
  71. package/dist/spend-ui.d.ts +11 -0
  72. package/dist/spend-ui.js +49 -2
  73. package/dist/store.d.ts +38 -2
  74. package/dist/store.js +320 -35
  75. package/dist/surface.js +16 -4
  76. package/dist/task-outcome-cli.js +5 -2
  77. package/dist/toolroll-update.js +3 -2
  78. package/package.json +1 -1
@@ -21,11 +21,11 @@ export const MEMORY_DESCRIPTORS = [
21
21
  { action: 'search', synopsis: 'search decisions, instructions, references, lessons and your conversations', mutation: 'none', takesQuery: true, flags },
22
22
  { action: 'decisions', synopsis: "list a project's active decisions (--all for history)", mutation: 'none', takesQuery: false, flags },
23
23
  { action: 'show', synopsis: 'read one decision with its reason and history', mutation: 'none', takesQuery: true, flags },
24
- { action: 'decide', synopsis: 'record a settled choice: memory decide "<choice>" --why "<reason>" --repo PATH', mutation: 'unkeyed', takesQuery: true, flags },
25
- { action: 'retire', synopsis: 'retire a decision that no longer holds: memory retire <id> --reason "<why>"', mutation: 'unkeyed', takesQuery: true, flags },
24
+ { action: 'decide', synopsis: 'record a settled choice: memory decide "<choice>" --why "<reason>" --repo PATH', mutation: 'unkeyed', takesQuery: true, flags, audience: 'operator' },
25
+ { action: 'retire', synopsis: 'retire a decision that no longer holds: memory retire <id> --reason "<why>"', mutation: 'unkeyed', takesQuery: true, flags, audience: 'operator' },
26
26
  { action: 'propose', synopsis: 'the backward pass: read recent sessions, keep a ledger of evidenced gaps, propose a few edits (writes nothing to memory)', mutation: 'unkeyed', takesQuery: false, flags },
27
27
  { action: 'review', synopsis: 'list pending memory proposals with their evidence', mutation: 'none', takesQuery: false, flags },
28
- { action: 'apply', synopsis: 'decide one proposal: memory apply <id> --decision accept|reject — the only writer', mutation: 'unkeyed', takesQuery: true, flags },
28
+ { action: 'apply', synopsis: 'decide one proposal: memory apply <id> --decision accept|reject — the only writer', mutation: 'unkeyed', takesQuery: true, flags, audience: 'operator' },
29
29
  { action: 'status', synopsis: 'surface version, instruction budget, sessions analysed, open gaps and pending proposals', mutation: 'none', takesQuery: false, flags },
30
30
  ];
31
31
  const line = (d) => `#${d.id} ${d.claim} — ${d.decidedBy}, ${d.decidedAt.slice(0, 10)}${d.status === 'active' ? '' : ` (${d.status})`}`;
@@ -17,7 +17,7 @@ import { homedir } from 'node:os';
17
17
  import { join } from 'node:path';
18
18
  import { scanForSecrets, readVerifiedArtifact } from './evidence.js';
19
19
  import { changeKnowledge, knowledgeView } from './project-knowledge.js';
20
- import { listDecisions, recordDecision } from './project-memory.js';
20
+ import { listDecisions, recordDecision, refuseLeadMemoryWrite } from './project-memory.js';
21
21
  import { composeLeadRequest, performLeadRequest, isDirectChatProvider, CHAT_KEY_ENV } from './converse.js';
22
22
  import { performSubscriptionLeadRequest } from './subscription-chat.js';
23
23
  import { TEXT_LIMITS } from './text-limits.js';
@@ -399,7 +399,11 @@ export function listProposals(store, repo, status = 'pending') {
399
399
  }
400
400
  /** The one writer: accept applies the edit through the knowledge or decision store as the person; reject remembers the fingerprint and its corroboration. */
401
401
  export function decideProposal(store, args, now = new Date()) {
402
+ refuseLeadMemoryWrite();
402
403
  return store.transact(() => {
404
+ // A person who may change this project's memory, read inside the write (a revoked account or lost access refuses).
405
+ if (!store.accountCanAccess(args.actor, args.repo) || store.accountOf(args.actor)?.role !== 'approver')
406
+ throw Error('Project memory is outside your access.');
403
407
  const row = store.handle.prepare('SELECT * FROM memory_proposal WHERE id=? AND repo=?').get(args.id, args.repo);
404
408
  if (!row)
405
409
  throw Error('That proposal is unavailable.');
@@ -1,5 +1,5 @@
1
1
  import { type SeenModel } from "./model-catalog.js";
2
- import { type PhaseRoute } from "./phase-routing.js";
2
+ import { type EarnedRule, type PhaseRoute } from "./phase-routing.js";
3
3
  import type { Store } from "./store.js";
4
4
  export type SlotPhase = "plan" | "build" | "repair" | "review";
5
5
  export type SlotTier = "routine" | "light" | "strong";
@@ -76,7 +76,9 @@ export declare const ROUTE_CASES: readonly {
76
76
  risky: boolean;
77
77
  strict: boolean;
78
78
  }[];
79
- export declare function effectiveRoutes(store: Store, repo: string | null): {
79
+ export declare function effectiveRoutes(store: Store, repo: string | null, options?: {
80
+ rules?: readonly EarnedRule[] | false;
81
+ }): {
80
82
  ok: true;
81
83
  routes: {
82
84
  label: string;
@@ -164,7 +166,9 @@ export type ScopeCheck = {
164
166
  * or a route of any size or demand (small, routine, large, risky, strict) whose repair can't resume its builder,
165
167
  * inherited rows included. An invalid result is never saved.
166
168
  */
167
- export declare function checkScope(store: Store, repo: string | null): ScopeCheck;
169
+ export declare function checkScope(store: Store, repo: string | null, options?: {
170
+ rules?: readonly EarnedRule[];
171
+ }): ScopeCheck;
168
172
  /** A resolver's words for a browser: what is wrong, never a terminal command to fix it. */
169
173
  export declare function browserWords(problem: string): string;
170
174
  /** The problem alone, or null when tasks can be filed. */
@@ -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. */