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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.92 — 2026-11-05
4
+
5
+ - **Toolroll learns which cheaper models do the job.** Every task now gets a
6
+ result record (see it on the task page, or run `toolroll models results`).
7
+ Once a cheaper model has done well on a project's small or routine tasks,
8
+ Settings → Models suggests it, with the evidence. Accepting needs your
9
+ password and only affects that project's eligible tasks; if the cheaper
10
+ model then does badly, Toolroll switches back on its own and tells you.
11
+ Approvals show a line saying why a model was picked. Also in the terminal:
12
+ `toolroll models suggestions`, `accept`, `dismiss`, `turn-off`.
13
+ - **Try a cheaper model on the next 10 tasks.** When a cheaper model has no
14
+ track record yet, Settings → Models offers a trial. Each trial task says
15
+ "trial 3 of 10" and must be approved by a person; Stop ends it any time
16
+ (`toolroll models try` / `stop`).
17
+ - **See what it saved.** The Spend page and `toolroll spend` show cost per
18
+ task before and after each rule or trial — observed, not a promise, and
19
+ only once there are enough priced results.
20
+ - **Project memory is changed by a person only.** A personal API token used
21
+ from the remote CLI could record or retire project decisions without a
22
+ password; now it can't, and neither can the lead. Reading memory works as
23
+ before.
24
+
25
+ ## 0.9.91 — 2026-11-04
26
+
27
+ - **A deploy checks your HTTPS console before it changes anything.** If the
28
+ console you reach Toolroll through (over Tailscale, say) doesn't answer, the
29
+ deploy now stops before it pauses new work or stops the running service,
30
+ and says to bring the console back. It used to find out only at the end,
31
+ with the new version already running and new work paused.
32
+ - **A release finishes a deploy that stopped after the new version started.**
33
+ If a deploy's last check failed once the new version was already running
34
+ (an HTTPS console that didn't answer, say), `toolroll release` sent you to
35
+ `toolroll release --recover-deploy`, which sent you back, and new work
36
+ stayed paused. Rerunning the release now finishes the deploy: it checks
37
+ the new service, both consoles and the project workers, then lifts the
38
+ pause.
39
+ - **A resumed release reads its task with its own build.** It used to call
40
+ whichever `toolroll` came first on your PATH, which until the release
41
+ relinks it is still the previous version and can't open a database the
42
+ deploy just upgraded.
43
+
3
44
  ## 0.9.90 — 2026-11-04
4
45
 
5
46
  - **Type `toolroll` and you're in.** At your own terminal, bare `toolroll`
@@ -1,7 +1,15 @@
1
1
  /** SQL over a `task_scope` alias and a repo expression: whether that row's approval authority stands at `now` (an SQL
2
2
  * expression, used exactly three times: the mode and grant clauses, and the release's expiry). It never asks whether the row is approved.
3
3
  * COALESCE: a NULL basis would make the OR chain NULL, and NOT NULL reads as "not dead"; NULL stands on nothing. */
4
- export declare const authorityStandsSql: (scope: string, repo: string, now: string) => string;
4
+ export declare function authorityStandsSql(scope: string, repo: string, now: string): string;
5
+ /**
6
+ * SQL, conjunctive with every alternative above (results routing slice 3): a task whose sealed route tries a cheaper
7
+ * model (its build leg's selection is a trial) stands only on a PERSON's own yes to these exact bytes — the ceremony
8
+ * (`password`) or their paired-chat tap (`chat`), with that person's proof at the seal's instant, and no machine's.
9
+ * A mode, a grant, a schedule, a carry, an automatic seal or the lead never stand for one, whatever else allows them.
10
+ * The route is matched as text, so a route that can't be read as JSON is held to it too (fail closed).
11
+ */
12
+ export declare function trialSealStandsSql(scope: string): string;
5
13
  /**
6
14
  * SQL, conjunctive with every alternative above (easy-start plan C5): a seal a local owner gave (its proof row at the
7
15
  * seal's instant says `local-owner`) stands only while local mode is on, for that owner, at the local generation its
@@ -20,7 +20,8 @@ const PERSON_PROOFS = "('password-now', 'sso-now', 'fresh-password', 'fresh-sso'
20
20
  /** SQL over a `task_scope` alias and a repo expression: whether that row's approval authority stands at `now` (an SQL
21
21
  * expression, used exactly three times: the mode and grant clauses, and the release's expiry). It never asks whether the row is approved.
22
22
  * COALESCE: a NULL basis would make the OR chain NULL, and NOT NULL reads as "not dead"; NULL stands on nothing. */
23
- export const authorityStandsSql = (scope, repo, now) => `COALESCE((((COALESCE(${scope}.approval_basis, '') IN ('password', 'chat') AND NOT ${machineSealSql(scope)}
23
+ export function authorityStandsSql(scope, repo, now) {
24
+ return `COALESCE((((COALESCE(${scope}.approval_basis, '') IN ('password', 'chat') AND NOT ${machineSealSql(scope)}
24
25
  AND (${personSealSql(scope)} OR NOT ${automationSql(scope)} OR ${approverStandsSql(scope, repo)}))
25
26
  OR (${scope}.approval_basis = 'carried' AND ${carrySealSql(scope)})
26
27
  OR (${scope}.approval_basis = 'mode' AND EXISTS (SELECT 1 FROM operating_mode om
@@ -30,7 +31,19 @@ export const authorityStandsSql = (scope, repo, now) => `COALESCE((((COALESCE(${
30
31
  OR (${scope}.approval_basis = 'grant' AND ${grantStandsSql(`${scope}.mode_digest`, now, repo)})
31
32
  OR (${scope}.approval_basis = 'schedule' AND ${approverStandsSql(scope, repo)})
32
33
  OR (${scope}.approval_basis IN ('auto-check', 'auto-green') AND ${autoSealSql(scope, repo)} AND ${approverStandsSql(scope, repo)}))
33
- AND ${releaseStandsSql(scope, now)} AND ${localSealStandsSql(scope, repo)}), 0)`;
34
+ AND ${releaseStandsSql(scope, now)} AND ${localSealStandsSql(scope, repo)}
35
+ AND (instr(COALESCE(${scope}.approved_route_json, ''), '"trial"') = 0 OR ${trialSealStandsSql(scope)})), 0)`;
36
+ }
37
+ /**
38
+ * SQL, conjunctive with every alternative above (results routing slice 3): a task whose sealed route tries a cheaper
39
+ * model (its build leg's selection is a trial) stands only on a PERSON's own yes to these exact bytes — the ceremony
40
+ * (`password`) or their paired-chat tap (`chat`), with that person's proof at the seal's instant, and no machine's.
41
+ * A mode, a grant, a schedule, a carry, an automatic seal or the lead never stand for one, whatever else allows them.
42
+ * The route is matched as text, so a route that can't be read as JSON is held to it too (fail closed).
43
+ */
44
+ export function trialSealStandsSql(scope) {
45
+ return `(COALESCE(${scope}.approval_basis, '') IN ('password', 'chat') AND NOT ${machineSealSql(scope)} AND ${personSealSql(scope)})`;
46
+ }
34
47
  /**
35
48
  * SQL, conjunctive with every alternative above (easy-start plan C5): a seal a local owner gave (its proof row at the
36
49
  * seal's instant says `local-owner`) stands only while local mode is on, for that owner, at the local generation its
@@ -126,6 +126,8 @@ function sendBack(store, root, runId, taskId, repo, high, now) {
126
126
  run: runId, source: revisionSourceOf(store.getScope(taskId)?.digest ?? null), actor, repos: [repo], batch: "",
127
127
  note: revisionNoteOf(high), path: "", line: "",
128
128
  request: createHash("sha256").update(`build-review:${runId}`).digest("hex").slice(0, 32), allowMode: true,
129
+ // The automatic review's send-back, even when the mode's signer is its actor: never a person's act.
130
+ origin: "automatic-review",
129
131
  }, now);
130
132
  return revised.ok ? { ok: true, id: revised.id } : { ok: false, problem: revised.message };
131
133
  }
@@ -1,5 +1,3 @@
1
- /** Shared, exact-state actions. Models may prepare; only the existing human
2
- * confirmation door executes. Protected actions use a one-use review receipt. */
3
1
  import { type Authority } from "./local-owner.js";
4
2
  import { type Store } from "./store.js";
5
3
  import { type VerifiedApprover } from "./principal.js";
@@ -231,6 +229,16 @@ export declare const CHAT_ACTIONS: {
231
229
  readonly protected: false;
232
230
  readonly password: false;
233
231
  };
232
+ readonly suggestion_not_now: {
233
+ readonly label: "Not now";
234
+ readonly protected: false;
235
+ readonly password: false;
236
+ };
237
+ readonly trial_stop: {
238
+ readonly label: "Stop trial";
239
+ readonly protected: false;
240
+ readonly password: false;
241
+ };
234
242
  readonly lead_about_you: {
235
243
  readonly label: "Remember";
236
244
  readonly protected: false;
@@ -279,7 +287,7 @@ export declare function sharedActionReviewPath(id: number): string;
279
287
  export declare function mintSharedActionReview(store: Store, who: VerifiedApprover, id: number, root: string, now: Date): {
280
288
  payload: {
281
289
  version: 1;
282
- operation: "decision_record" | "decision_retire" | "flow_browser" | "flow_browser_off" | "flow_card_add" | "flow_card_approve" | "flow_card_assign" | "flow_card_cancel" | "flow_card_choose" | "flow_card_comment" | "flow_card_move" | "flow_card_send_back" | "flow_card_watch" | "flow_create" | "flow_edit" | "flow_script_save" | "flow_starter" | "flow_trigger_add" | "flow_trigger_pause" | "flow_trigger_remove" | "flow_trigger_resume" | "kit_setup" | "knowledge_instructions" | "knowledge_remove" | "knowledge_restore" | "knowledge_save" | "lead_about_you" | "result_accept" | "scope_approve" | "skill_disable" | "skill_enable" | "skill_import" | "skill_restore" | "skill_test" | "subagent_answer" | "subagent_ask" | "subagent_create" | "subagent_memory" | "subagent_note" | "subagent_routine" | "subagent_soul" | "subagent_state" | "subagent_tools" | "subagent_undo" | "sweep_cancel_rest" | "sweep_file" | "task_cancel" | "task_resume" | "tool_add" | "tool_remove";
290
+ operation: "decision_record" | "decision_retire" | "flow_browser" | "flow_browser_off" | "flow_card_add" | "flow_card_approve" | "flow_card_assign" | "flow_card_cancel" | "flow_card_choose" | "flow_card_comment" | "flow_card_move" | "flow_card_send_back" | "flow_card_watch" | "flow_create" | "flow_edit" | "flow_script_save" | "flow_starter" | "flow_trigger_add" | "flow_trigger_pause" | "flow_trigger_remove" | "flow_trigger_resume" | "kit_setup" | "knowledge_instructions" | "knowledge_remove" | "knowledge_restore" | "knowledge_save" | "lead_about_you" | "result_accept" | "scope_approve" | "skill_disable" | "skill_enable" | "skill_import" | "skill_restore" | "skill_test" | "subagent_answer" | "subagent_ask" | "subagent_create" | "subagent_memory" | "subagent_note" | "subagent_routine" | "subagent_soul" | "subagent_state" | "subagent_tools" | "subagent_undo" | "suggestion_not_now" | "sweep_cancel_rest" | "sweep_file" | "task_cancel" | "task_resume" | "tool_add" | "tool_remove" | "trial_stop";
283
291
  request: {
284
292
  [x: string]: unknown;
285
293
  };
@@ -1,5 +1,6 @@
1
1
  /** Shared, exact-state actions. Models may prepare; only the existing human
2
2
  * confirmation door executes. Protected actions use a one-use review receipt. */
3
+ import { dismissSuggestion, ruleById, stopTrial, suggestionsFor, suggestionWords, taskEvidenceWords, trialWords } from "./route-rules.js";
3
4
  import { CHAT_ACTION_LOCAL_ACTS, currentLocalOwner } from "./local-owner.js";
4
5
  import { fileTaskProposal } from "./proposal.js";
5
6
  import { heuristicSizing } from "./task-sizing.js";
@@ -128,6 +129,10 @@ export const CHAT_ACTIONS = {
128
129
  kit_setup: { label: "Set up kit", protected: false, password: false },
129
130
  decision_record: { label: "Record decision", protected: false, password: false },
130
131
  decision_retire: { label: "Retire decision", protected: false, password: false },
132
+ // Not now only takes a suggestion off the list for 30 days: nothing about which models run changes.
133
+ suggestion_not_now: { label: "Not now", protected: false, password: false },
134
+ // Stopping a trial only lowers: new tasks build on the configured model; approved ones keep theirs.
135
+ trial_stop: { label: "Stop trial", protected: false, password: false },
131
136
  // What the owner's own lead knows about them: theirs alone, in no project (lead-about.ts).
132
137
  lead_about_you: { label: "Remember", protected: false, password: false },
133
138
  scope_approve: { label: "Approve work", protected: true, password: true },
@@ -279,7 +284,7 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
279
284
  const stamp = hash({ operation, request, repo: "", state, terms, actor: who.name, generation: who.generation, ceiling: who.ceilingDigest });
280
285
  return { version: SHARED_ACTION_VERSION, operation, request, repo: "", title, terms, stamp, state };
281
286
  }
282
- const task = operation.startsWith("skill_") || operation.startsWith("knowledge_") || operation.startsWith("decision_") || operation.startsWith("tool_") || operation.startsWith("flow_") || operation.startsWith("subagent_") || operation.startsWith("kit_") || operation.startsWith("sweep_")
287
+ const task = operation.startsWith("skill_") || operation.startsWith("knowledge_") || operation.startsWith("decision_") || operation.startsWith("tool_") || operation.startsWith("flow_") || operation.startsWith("subagent_") || operation.startsWith("kit_") || operation.startsWith("sweep_") || operation.startsWith("suggestion_") || operation.startsWith("trial_")
283
288
  ? null
284
289
  : text(input, "task", 64);
285
290
  const flowTarget = operation.startsWith("flow_") && operation !== "flow_create" && operation !== "flow_script_save" && operation !== "flow_starter" ? flowTargetOf(store, input) : null;
@@ -866,6 +871,26 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
866
871
  }
867
872
  }
868
873
  }
874
+ else if (operation === "suggestion_not_now") {
875
+ // Only a suggestion on offer now, by its exact id (which binds its evidence, prices and Models generations).
876
+ const asked = text(input, "suggestion", 200);
877
+ const offer = suggestionsFor(store, repo, now).find(one => one.id === asked);
878
+ if (offer === undefined)
879
+ throw Error("That suggestion isn't on offer any more. See Settings → Models.");
880
+ state = { suggestion: offer.id };
881
+ title = `Not now: ${offer.name} for ${offer.cohortSize === "small" ? "small" : "routine"} tasks`;
882
+ terms.push(suggestionWords(offer), "Keeps this suggestion quiet for 30 days. Nothing about which models run changes. To accept it instead, review it in Settings → Models.");
883
+ }
884
+ else if (operation === "trial_stop") {
885
+ // Only a live trial of this project, by its number; the card is stale once the trial moves.
886
+ const asked = integer(input, "trial");
887
+ const trial = Number.isSafeInteger(asked) ? ruleById(store, asked) : null;
888
+ if (trial === null || trial.kind !== "trial" || trial.repo !== repo || (trial.state !== "trialling" && trial.state !== "paused"))
889
+ throw Error("That trial isn't running any more. See Settings → Models.");
890
+ state = { trial: trial.id, generation: trial.generation };
891
+ title = "Stop trying a cheaper model";
892
+ terms.push(trialWords(store, trial), "New tasks build on the configured model again. Tasks a person already approved on the trial keep that model.");
893
+ }
869
894
  else if (operation.startsWith("decision_")) {
870
895
  // The staleness fence: the newest decision id and the active count, so a
871
896
  // card drafted before a subagent recorded or retired one is refused.
@@ -947,10 +972,11 @@ export function prepareSharedAction(store, who, operation, input, root, now = ne
947
972
  if (!authority.ok)
948
973
  throw Error(authority.problem);
949
974
  state["authMode"] = authority.authMode;
950
- terms.push(...describeScope(scope)
975
+ terms.push(...describeScope(scope, undefined, taskEvidenceWords(store, scope.taskId, now))
951
976
  .filter((line) => !/^\s*(reference|approved)\s/.test(line))
952
977
  .map((line) => line
953
978
  .trimStart()
979
+ .replace(/^why model\s+/, "Why this model: ")
954
980
  .replace(/^goal\s+/, "Work: ")
955
981
  .replace(/^not this\s+/, "Excluded: ")
956
982
  .replace(/^touches\s+/, "Allowed files: ")), `Agent permissions and limits\n${runtimeTerms(authority.profile)}\n${authority.authMode === "api-key" ? "Uses your API key; spend is charged to that account." : authority.authMode === "subscription" ? "Uses your subscription login." : ""}`);
@@ -1246,6 +1272,25 @@ export function executeSharedAction(store, who, id, payload, now, options) {
1246
1272
  if (!removeToolFrom(store, repo, String(req["name"]), actor, now))
1247
1273
  throw Error("That tool was already removed.");
1248
1274
  }
1275
+ else if (payload.operation === "trial_stop") {
1276
+ const door = currentLocalOwner() !== null ? { kind: "local-owner", actor } : { kind: "person", person: { actor: who.name, generation: who.generation, method: "session", live: () => true } };
1277
+ const trial = ruleById(store, Number(payload.state["trial"]));
1278
+ if (trial === null || trial.generation !== Number(payload.state["generation"]))
1279
+ throw Error("That trial changed since this card was made. See Settings → Models.");
1280
+ const done = stopTrial(store, trial.id, door, now);
1281
+ if (!done.ok)
1282
+ throw Error(done.problem);
1283
+ return { ok: true, taskId: null, said: "Trial stopped. New tasks build on the configured model.", href: `/settings/models?scope=${encodeURIComponent(repo)}#models-suggested` };
1284
+ }
1285
+ else if (payload.operation === "suggestion_not_now") {
1286
+ // The rule door (route-store.ts proveRuleDoor) re-proves who says it: this computer's local owner only inside
1287
+ // its `dismiss-suggestion` act, or the confirming person.
1288
+ const door = currentLocalOwner() !== null ? { kind: "local-owner", actor } : { kind: "person", person: { actor: who.name, generation: who.generation, method: "session", live: () => true } };
1289
+ const done = dismissSuggestion(store, { repo, id: String(payload.state["suggestion"]) }, door, now);
1290
+ if (!done.ok)
1291
+ throw Error(done.problem);
1292
+ return { ok: true, taskId: null, said: "Not now: this suggestion stays quiet for 30 days.", href: `/settings/models?scope=${encodeURIComponent(repo)}#models-suggested` };
1293
+ }
1249
1294
  else if (payload.operation === "decision_record")
1250
1295
  recordDecision(store, { repo, actor, draft: { claim: String(req["claim"]), why: String(req["why"]), sourceKind: "conversation",
1251
1296
  ...(req["source"] === undefined ? {} : { sourceRef: String(req["source"]) }), ...(payload.state["supersedes"] === undefined ? {} : { supersedes: Number(payload.state["supersedes"]) }) } }, now);
@@ -972,6 +972,8 @@ export function applyDecideFeedback(store, seat, prompt, text, repos, root, now)
972
972
  const revised = requestResultChanges(store, root, {
973
973
  run: prompt.run, batch: "", source: prompt.digest, actor: verified.who.name, repos: verified.who.repos,
974
974
  note: text, path: "", line: "", request: sha(`chat-decide:${seat.channel}:${prompt.id}`).slice(0, 32), allowMode: true,
975
+ // The chat's own verified person, answering "What should change?" in their own words.
976
+ origin: "person",
975
977
  }, now);
976
978
  if (!revised.ok)
977
979
  return { said: `Your feedback wasn't sent: ${revised.message}`, link: null };
@@ -1,3 +1,4 @@
1
+ import type { RevisionOrigin } from "./contracts/route-results.js";
1
2
  import type { Store } from "./store.js";
2
3
  import type { VerifiedApprover } from "./principal.js";
3
4
  import { type FeedbackImage } from "./result-review.js";
@@ -41,7 +42,7 @@ export declare function reviewInputProblem(note: string | null, path: string | n
41
42
  /** Chat and the result form write the same comments and call the same revision service.
42
43
  * A failed combined note+revision rolls back the note too; no half-completed action.
43
44
  */
44
- export declare function applyChatReview(store: Store, who: VerifiedApprover, evidenceRoot: string, request: ReviewRequest, now: Date, allowMode: boolean): {
45
+ export declare function applyChatReview(store: Store, who: VerifiedApprover, evidenceRoot: string, request: ReviewRequest, now: Date, allowMode: boolean, origin: RevisionOrigin): {
45
46
  ok: true;
46
47
  taskId: string;
47
48
  run: number;
@@ -48,7 +48,7 @@ export function reviewInputProblem(note, path, line) {
48
48
  /** Chat and the result form write the same comments and call the same revision service.
49
49
  * A failed combined note+revision rolls back the note too; no half-completed action.
50
50
  */
51
- export function applyChatReview(store, who, evidenceRoot, request, now, allowMode) {
51
+ export function applyChatReview(store, who, evidenceRoot, request, now, allowMode, origin) {
52
52
  try {
53
53
  return store.transact(() => store.savepoint(() => {
54
54
  const fail = (message) => { throw new ReviewRefusal(message); };
@@ -86,7 +86,7 @@ export function applyChatReview(store, who, evidenceRoot, request, now, allowMod
86
86
  if (batch.length === 0)
87
87
  return fail("Add feedback or select saved notes before requesting changes.");
88
88
  const result = createResultRevision(store, evidenceRoot, {
89
- run: live.run, batch: batch.join(","), source: live.source, actor: who.name, repos: who.repos, allowMode,
89
+ run: live.run, batch: batch.join(","), source: live.source, actor: who.name, repos: who.repos, allowMode, origin,
90
90
  }, now);
91
91
  if (!result.ok)
92
92
  return fail(result.message);
@@ -252,6 +252,14 @@ export declare const chatActionRequestSchemas: {
252
252
  decision: z.ZodInt;
253
253
  reason: z.ZodString;
254
254
  }, z.core.$strict>;
255
+ readonly suggestion_not_now: z.ZodObject<{
256
+ repo: z.ZodString;
257
+ suggestion: z.ZodString;
258
+ }, z.core.$strict>;
259
+ readonly trial_stop: z.ZodObject<{
260
+ repo: z.ZodString;
261
+ trial: z.ZodInt;
262
+ }, z.core.$strict>;
255
263
  /** The line, and (from 1) the line it replaces; 0 or none adds it. */
256
264
  readonly lead_about_you: z.ZodObject<{
257
265
  line: z.ZodString;
@@ -331,12 +339,14 @@ export declare const sharedActionSchema: z.ZodObject<{
331
339
  subagent_state: "subagent_state";
332
340
  subagent_tools: "subagent_tools";
333
341
  subagent_undo: "subagent_undo";
342
+ suggestion_not_now: "suggestion_not_now";
334
343
  sweep_cancel_rest: "sweep_cancel_rest";
335
344
  sweep_file: "sweep_file";
336
345
  task_cancel: "task_cancel";
337
346
  task_resume: "task_resume";
338
347
  tool_add: "tool_add";
339
348
  tool_remove: "tool_remove";
349
+ trial_stop: "trial_stop";
340
350
  }>;
341
351
  request: z.ZodObject<{}, z.core.$catchall<z.ZodUnknown>>;
342
352
  repo: z.ZodString;
@@ -81,6 +81,10 @@ export const chatActionRequestSchemas = {
81
81
  kit_setup: z.strictObject({ repo: words, kit: words }),
82
82
  decision_record: z.strictObject({ repo: words, claim: words, why: words, supersedes: id.optional(), source: words.optional() }),
83
83
  decision_retire: z.strictObject({ repo: words, decision: id, reason: words }),
84
+ // Results routing: Not now on a suggested cheaper model (accepting stays in Settings → Models, behind a step-up).
85
+ suggestion_not_now: z.strictObject({ repo: words, suggestion: words }),
86
+ // Slice 3: stopping a trial (starting one stays in Settings → Models, behind a step-up).
87
+ trial_stop: z.strictObject({ repo: words, trial: id }),
84
88
  /** The line, and (from 1) the line it replaces; 0 or none adds it. */
85
89
  lead_about_you: z.strictObject({ line: words, replaces: z.int().min(0).optional() }),
86
90
  scope_approve: z.strictObject({ task: words }),
@@ -112,6 +112,7 @@ export declare const CONSOLE_FORMS: {
112
112
  readonly appearance: FormContract<"accent" | "quiet" | "return" | "theme">;
113
113
  readonly models: FormContract<"enabled" | "tool">;
114
114
  readonly modelsChange: FormContract<"build" | "build-light" | "build-strong" | "change" | "digest" | "password" | "plan" | "plan-strong" | "preset" | "repair" | "review" | "review-strong" | "scope" | "switch" | "to">;
115
+ readonly modelsRules: FormContract<"id" | "password" | "rule" | "scope">;
115
116
  readonly knowledgeRefresh: FormContract<"repo">;
116
117
  readonly knowledgeProposal: FormContract<"decision" | "proposal" | "repo">;
117
118
  readonly knowledgeDecision: FormContract<"action" | "claim" | "decision" | "reason" | "repo" | "why">;
@@ -124,6 +124,9 @@ export const CONSOLE_FORMS = {
124
124
  // preset or switch; the digest is what the page showed, and the change digest what the preview showed.
125
125
  modelsChange: formContract("POST /settings/models/agent, /settings/models/apply", ["password", "scope", "digest", "change", "preset", "switch", "to",
126
126
  "plan", "plan-strong", "build-light", "build", "build-strong", "repair", "review", "review-strong"]),
127
+ // Settings → Models → Suggested (results routing): the project, a suggestion's id or a rule's number, and the step-up
128
+ // password for accepting, keeping and switching suggestions on.
129
+ modelsRules: formContract("POST /settings/models/accept, /settings/models/keep, /settings/models/suggest-on, /settings/models/turn-off, /settings/models/not-now, /settings/models/suggest-off, /settings/models/try, /settings/models/stop", ["password", "scope", "id", "rule"]),
127
130
  knowledgeRefresh: formContract("POST /settings/knowledge/refresh", ["repo"]),
128
131
  knowledgeProposal: formContract("POST /settings/knowledge/proposal", ["repo", "decision", "proposal"]),
129
132
  knowledgeDecision: formContract("POST /settings/knowledge/decision", ["repo", "action", "claim", "why", "decision", "reason"]),
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Result records (docs/plans/results-routing.md, slice 1): the words and shapes a task family's result record is kept
3
+ * and shown in. A policy path (release-auto.ts EVAL_POLICY): the states, reasons and the execution-class table decide
4
+ * what a cheaper model's evidence is, so a change to them needs a person.
5
+ */
6
+ import { z } from "zod";
7
+ /** A record's lifecycle. Only mature and rejected records are decided; pending and provisional never count anywhere. */
8
+ export declare const RECORD_STATES: readonly ["pending", "provisional", "mature", "rejected", "not-counted"];
9
+ export type RecordState = (typeof RECORD_STATES)[number];
10
+ /** Why a record was rejected, in the order they are checked. */
11
+ export declare const REJECT_REASONS: readonly ["model-failures", "task-failed", "cancelled-after-result", "high-finding", "person-revision", "proof-refuted", "check-failed"];
12
+ /** Why a record is not counted. Never counted as good, never as 0. */
13
+ export declare const NOT_COUNTED_REASONS: readonly ["before-records", "not-eligible", "mixed-cohort", "mixed-models", "model-not-reported", "infrastructure-only", "cancelled-before-result", "no-check", "check-unavailable", "touched-protected", "changed-files-unknown", "unanswered"];
14
+ export type RejectReason = (typeof REJECT_REASONS)[number];
15
+ export type NotCountedReason = (typeof NOT_COUNTED_REASONS)[number];
16
+ export type RecordReason = RejectReason | NotCountedReason;
17
+ /** The plain words for each reason. */
18
+ export declare const REASON_WORDS: Readonly<Record<RecordReason, string>>;
19
+ /** Why a run was refused a cohort before dispatch: any of these makes its record not counted ("not eligible"). */
20
+ export declare const INELIGIBLE_REASONS: readonly ["no-sealed-route", "not-built-in", "large", "risky", "unsized", "strict", "evidence-demands", "automerge", "release", "protected"];
21
+ export type IneligibleReason = (typeof INELIGIBLE_REASONS)[number];
22
+ /** Who a revision came from (task_ref.revision_origin). Only `person` rejects a record. */
23
+ export declare const REVISION_ORIGINS: readonly ["person", "automatic-review", "flow", "lead", "machine"];
24
+ export type RevisionOrigin = (typeof REVISION_ORIGINS)[number];
25
+ /** A failed run's execution class, kept before the tick normalizes its reason (run.exec_class). */
26
+ export declare const EXEC_CLASSES: readonly ["model", "infrastructure"];
27
+ export type ExecClass = (typeof EXEC_CLASSES)[number];
28
+ /** The build leg's sealed selection, as a run's cohort keeps it (route v3 `selection`; earlier routes by `chosen`). */
29
+ export declare const selectionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
30
+ kind: z.ZodLiteral<"rule">;
31
+ rule: z.ZodNumber;
32
+ ruleGeneration: z.ZodNumber;
33
+ }, z.core.$strict>, z.ZodObject<{
34
+ kind: z.ZodLiteral<"trial">;
35
+ rule: z.ZodNumber;
36
+ reservation: z.ZodNumber;
37
+ }, z.core.$strict>, z.ZodObject<{
38
+ kind: z.ZodEnum<{
39
+ configured: "configured";
40
+ override: "override";
41
+ pin: "pin";
42
+ }>;
43
+ }, z.core.$strict>], "kind">;
44
+ export type Selection = z.infer<typeof selectionSchema>;
45
+ /** One record as `toolroll models results --json` returns it. */
46
+ export declare const resultRecordSchema: z.ZodObject<{
47
+ task: z.ZodString;
48
+ repo: z.ZodNullable<z.ZodString>;
49
+ size: z.ZodNullable<z.ZodEnum<{
50
+ medium: "medium";
51
+ small: "small";
52
+ }>>;
53
+ provider: z.ZodNullable<z.ZodString>;
54
+ model: z.ZodNullable<z.ZodString>;
55
+ state: z.ZodEnum<{
56
+ mature: "mature";
57
+ "not-counted": "not-counted";
58
+ pending: "pending";
59
+ provisional: "provisional";
60
+ rejected: "rejected";
61
+ }>;
62
+ reason: z.ZodNullable<z.ZodEnum<{
63
+ "before-records": "before-records";
64
+ "cancelled-after-result": "cancelled-after-result";
65
+ "cancelled-before-result": "cancelled-before-result";
66
+ "changed-files-unknown": "changed-files-unknown";
67
+ "check-failed": "check-failed";
68
+ "check-unavailable": "check-unavailable";
69
+ "high-finding": "high-finding";
70
+ "infrastructure-only": "infrastructure-only";
71
+ "mixed-cohort": "mixed-cohort";
72
+ "mixed-models": "mixed-models";
73
+ "model-failures": "model-failures";
74
+ "model-not-reported": "model-not-reported";
75
+ "no-check": "no-check";
76
+ "not-eligible": "not-eligible";
77
+ "person-revision": "person-revision";
78
+ "proof-refuted": "proof-refuted";
79
+ "task-failed": "task-failed";
80
+ "touched-protected": "touched-protected";
81
+ unanswered: "unanswered";
82
+ }>>;
83
+ words: z.ZodString;
84
+ resultRun: z.ZodNullable<z.ZodNumber>;
85
+ acceptedAt: z.ZodNullable<z.ZodString>;
86
+ decidedAt: z.ZodNullable<z.ZodString>;
87
+ failures: z.ZodObject<{
88
+ model: z.ZodNumber;
89
+ infrastructure: z.ZodNumber;
90
+ }, z.core.$strict>;
91
+ spend: z.ZodObject<{
92
+ apiMicrousd: z.ZodNumber;
93
+ subscriptionRuns: z.ZodNumber;
94
+ subscriptionTokens: z.ZodNumber;
95
+ unknownRuns: z.ZodNumber;
96
+ remediationRuns: z.ZodNumber;
97
+ }, z.core.$strict>;
98
+ personRevisions: z.ZodNumber;
99
+ firstCheckPassed: z.ZodNullable<z.ZodBoolean>;
100
+ turns: z.ZodObject<{
101
+ known: z.ZodNumber;
102
+ unknownRuns: z.ZodNumber;
103
+ }, z.core.$strict>;
104
+ closed: z.ZodBoolean;
105
+ selections: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
106
+ kind: z.ZodLiteral<"rule">;
107
+ rule: z.ZodNumber;
108
+ ruleGeneration: z.ZodNumber;
109
+ }, z.core.$strict>, z.ZodObject<{
110
+ kind: z.ZodLiteral<"trial">;
111
+ rule: z.ZodNumber;
112
+ reservation: z.ZodNumber;
113
+ }, z.core.$strict>, z.ZodObject<{
114
+ kind: z.ZodEnum<{
115
+ configured: "configured";
116
+ override: "override";
117
+ pin: "pin";
118
+ }>;
119
+ }, z.core.$strict>], "kind">>;
120
+ }, z.core.$strict>;
121
+ export type ResultRecordView = z.infer<typeof resultRecordSchema>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Result records (docs/plans/results-routing.md, slice 1): the words and shapes a task family's result record is kept
3
+ * and shown in. A policy path (release-auto.ts EVAL_POLICY): the states, reasons and the execution-class table decide
4
+ * what a cheaper model's evidence is, so a change to them needs a person.
5
+ */
6
+ import { z } from "zod";
7
+ /** A record's lifecycle. Only mature and rejected records are decided; pending and provisional never count anywhere. */
8
+ export const RECORD_STATES = ["pending", "provisional", "mature", "rejected", "not-counted"];
9
+ /** Why a record was rejected, in the order they are checked. */
10
+ export const REJECT_REASONS = ["model-failures", "task-failed", "cancelled-after-result", "high-finding", "person-revision", "proof-refuted", "check-failed"];
11
+ /** Why a record is not counted. Never counted as good, never as 0. */
12
+ export const NOT_COUNTED_REASONS = [
13
+ "before-records", "not-eligible", "mixed-cohort", "mixed-models", "model-not-reported", "infrastructure-only", "cancelled-before-result",
14
+ "no-check", "check-unavailable", "touched-protected", "changed-files-unknown", "unanswered",
15
+ ];
16
+ /** The plain words for each reason. */
17
+ export const REASON_WORDS = Object.freeze({
18
+ "model-failures": "two or more attempts failed on the model's own account",
19
+ "task-failed": "the task failed",
20
+ "cancelled-after-result": "it was cancelled after a result",
21
+ "high-finding": "the automatic review found a serious problem",
22
+ "person-revision": "a person asked for changes",
23
+ "proof-refuted": "its evidence didn't match the changes",
24
+ "check-failed": "its checks failed",
25
+ "before-records": "started before results were recorded",
26
+ "not-eligible": "not a small or routine task with default checks",
27
+ "mixed-cohort": "its attempts ran under different task sizes or settings",
28
+ "mixed-models": "its attempts ran on different models",
29
+ "model-not-reported": "the provider didn't report the model",
30
+ "infrastructure-only": "it failed only for reasons outside the model",
31
+ "cancelled-before-result": "it was cancelled before a result",
32
+ "no-check": "no checks configured",
33
+ "check-unavailable": "its check result can't be read",
34
+ "touched-protected": "touched protected files",
35
+ "changed-files-unknown": "changed files unknown",
36
+ "unanswered": "no answer in 30 days",
37
+ });
38
+ /** Why a run was refused a cohort before dispatch: any of these makes its record not counted ("not eligible"). */
39
+ export const INELIGIBLE_REASONS = ["no-sealed-route", "not-built-in", "large", "risky", "unsized", "strict", "evidence-demands", "automerge", "release", "protected"];
40
+ /** Who a revision came from (task_ref.revision_origin). Only `person` rejects a record. */
41
+ export const REVISION_ORIGINS = ["person", "automatic-review", "flow", "lead", "machine"];
42
+ /** A failed run's execution class, kept before the tick normalizes its reason (run.exec_class). */
43
+ export const EXEC_CLASSES = ["model", "infrastructure"];
44
+ /** The build leg's sealed selection, as a run's cohort keeps it (route v3 `selection`; earlier routes by `chosen`). */
45
+ export const selectionSchema = z.discriminatedUnion("kind", [
46
+ z.strictObject({ kind: z.literal("rule"), rule: z.number().int().positive(), ruleGeneration: z.number().int().positive() }),
47
+ z.strictObject({ kind: z.literal("trial"), rule: z.number().int().positive(), reservation: z.number().int().positive() }),
48
+ z.strictObject({ kind: z.enum(["configured", "override", "pin"]) }),
49
+ ]);
50
+ /** One record as `toolroll models results --json` returns it. */
51
+ export const resultRecordSchema = z.strictObject({
52
+ task: z.string(),
53
+ repo: z.string().nullable(),
54
+ size: z.enum(["small", "medium"]).nullable(),
55
+ provider: z.string().nullable(),
56
+ model: z.string().nullable(),
57
+ state: z.enum(RECORD_STATES),
58
+ reason: z.enum([...REJECT_REASONS, ...NOT_COUNTED_REASONS]).nullable(),
59
+ words: z.string(),
60
+ resultRun: z.number().int().nullable(),
61
+ acceptedAt: z.string().nullable(),
62
+ decidedAt: z.string().nullable(),
63
+ failures: z.strictObject({ model: z.number().int(), infrastructure: z.number().int() }),
64
+ spend: z.strictObject({ apiMicrousd: z.number().int(), subscriptionRuns: z.number().int(), subscriptionTokens: z.number().int(), unknownRuns: z.number().int(), remediationRuns: z.number().int() }),
65
+ /** Counts that keep accumulating after the verdict is final, until the record closes. */
66
+ personRevisions: z.number().int(),
67
+ firstCheckPassed: z.boolean().nullable(),
68
+ turns: z.strictObject({ known: z.number().int(), unknownRuns: z.number().int() }),
69
+ closed: z.boolean(),
70
+ selections: z.array(selectionSchema),
71
+ });