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
@@ -44,6 +44,7 @@ import { validKeyTail } from "../live-records.js";
44
44
  import { runNotes } from "../live-notes.js";
45
45
  import { latestFinishedAttempt, retryNoteOf } from '../needs-you.js';
46
46
  import { REMOTE_MESSAGES } from "../operate-remote.js";
47
+ import { proveRunStopped } from "../stop-proof.js";
47
48
  import { aggregateNewNames, observeWorktree, parseBaseTreeSnapshot, PEEK_LIMITS } from "../peek.js";
48
49
  import { isTaskSize, PHASES as ROUTE_PHASES } from "../phase-routing.js";
49
50
  import { applyModeToNewFiling, authorizePlanUnderMode } from "../plan-auto.js";
@@ -59,7 +60,7 @@ import { structuredHandoffView, terminalDiffView } from "../result-evidence-read
59
60
  import { fileAddTestsTask, requestFollowUpChecks } from '../result-follow-ups.js';
60
61
  import { commentSourceKey, isRevisionFeedback, parseResultTab, resultReturnTarget } from "../result-review.js";
61
62
  import { isAlive as runnerAlive } from "../runner.js";
62
- import { passwordlessSessionFor, acceptanceLinesToInput, acceptanceToLines, approvalOf, approve as approveScope, freshnessFor, localApprovalEligible, spendingOnlyOnSuccess, ssoNowAvailable, withFreshness, modeFilingCoverage, PLACEHOLDER_RUBRIC, proposeGuarded } from "../scope.js";
63
+ import { passwordlessSessionFor, acceptanceLinesToInput, acceptanceToLines, approvalOf, approve as approveScope, freshnessFor, freshlyApprovable, localApprovalEligible, spendingOnlyOnSuccess, ssoNowAvailable, withFreshness, modeFilingCoverage, PLACEHOLDER_RUBRIC, proposeGuarded } from "../scope.js";
63
64
  import { subagentNames as subagentNamesOf } from "../spend.js";
64
65
  import { verifiedAuthor } from "../store.js";
65
66
  import { runCostWords } from "../summary.js";
@@ -78,6 +79,10 @@ import { timingFor } from "../latency-timeline.js";
78
79
  import { ACCEPT_ANYWAY_NEEDS_REASON, editorFileHref, proofBundleView, rankReviewQueue, RESULT_REFUSALS, REVIEW_QUEUE_CAP, reviewCockpitDetailParts, reviewCockpitPage, reviewPriorityOf, runFactsFragment, runOutcomeBadge, withRefusal } from "./render-results.js";
79
80
  import { approvalFormDigest, approveRefusalWords, checkProgressHtml, consentDoorOf, nonceHashOf, PermissionsWouldChange, requirementsFromEditor, resumeDigestOf, taskOf } from "./render-tasks.js";
80
81
  import {} from "./session.js";
82
+ import { approveReleaseCard, releaseApprovalCard, releaseSheetHref, releaseTaskOf } from "../release-approval.js";
83
+ import { releaseSheetPage } from "./render-release.js";
84
+ import { approvalFactsNow } from "./kit.js";
85
+ import { localOwnerRefusal } from "../local-owner.js";
81
86
  export function createTasksHandlers(runtime) {
82
87
  const { identities, store, peekSay, peekCache, PEEK_CACHE_TTL_MS, peekInFlight, PEEK_GLOBAL_INFLIGHT, peekBySession, PEEK_SESSION_INFLIGHT, clock, peekName, PEEK_FRAGMENT_BYTES, peekEvict, evidenceRoot, sendScreen, chromeFor, visible, consumeApprovalNonce, authenticateApprover, options, mintApprovalNonce, taskScreen, unscopedMode, admissionList, planViewOf, revisionViewOf, runIsTaskResult, leadPrincipal, familyOf, armTaskResume, runIsLive, revisionLedgerOf, resultDetailOf, reviewFactsFor, taskRepoOf, runVisible, restricted, codingActorAllowed, codingProjectAllowed, managedRepos, routeViewOf, workAccess, firstRunStepsNow, revisionDocOf, failureOf, dockedConversation, planContractViewOf, familiesInView, explainAttempt, taskChatFocus, familyTasksInView, liveCeiling, projectOf, ceiling, bustBadge, revisionDestination } = runtime;
83
88
  /** A row admitted the address but its path parameter does not parse: the console's unknown page. */
@@ -1284,14 +1289,14 @@ export function createTasksHandlers(runtime) {
1284
1289
  }
1285
1290
  return redirect(response, decisionBack);
1286
1291
  }
1287
- async function taskConfirmStopped(ctx) {
1288
- const { url, who, response, now, posted } = ctx;
1289
- const act = matchTaskPath(url.pathname, "/confirm-stopped$");
1292
+ async function taskRecheckStopped(ctx) {
1293
+ const { url, who, response, posted } = ctx;
1294
+ const act = matchTaskPath(url.pathname, "/recheck-stopped$");
1290
1295
  if (act === null)
1291
1296
  return noPage(who, response);
1292
- const body = readForm(posted, CONSOLE_FORMS.confirmStopped);
1293
- // Confirming a finished build stopped is an approver's act behind the password, typed again — the same
1294
- // record as `toolroll run settle`, refused while anything of the run may still be running.
1297
+ const body = readForm(posted, CONSOLE_FORMS.recheckStopped);
1298
+ // Check again: Toolroll looks at this computer's processes for the build once more (stop-proof.ts) and settles it
1299
+ // when none of them is the build's. Nobody vouches that a build stopped, so there is nothing to confirm here.
1295
1300
  const back = taskHref(act.taskId);
1296
1301
  const ref = store.lookupRef(act.taskId);
1297
1302
  if (ref === null || !visible(ref.repo))
@@ -1302,19 +1307,19 @@ export function createTasksHandlers(runtime) {
1302
1307
  if (run === null || runTask === null || familyOf(runTask)?.root.id !== (familyOf(act.taskId)?.root.id ?? act.taskId)) {
1303
1308
  return taskScreen(response, who, act.taskId, "That build isn't part of this task.", 409);
1304
1309
  }
1305
- if (!authenticateApprover(who, body.get("token") ?? "", ref.repo).ok || !store.accountCanAccess(who.name, ref.repo)) {
1306
- return taskScreen(response, who, act.taskId, "That password didn't match. Nothing changed.", 403);
1307
- }
1308
- // When Toolroll can't check at all, the approver also says they checked: never a one-click confirmation.
1309
- if (store.stopQuiescenceFact(run.id)?.kind === "unknown" && body.get("checked") !== "yes") {
1310
- return taskScreen(response, who, act.taskId, `Make sure nothing from build #${run.id} is running, then tick the box to confirm.`, 409);
1311
- }
1312
- const settled = store.settleRunWitnessesByApprover({ runId: run.id, by: who.name, why: "Confirmed in the console that nothing from this build is running." }, now);
1313
- if (!settled.ok) {
1314
- return taskScreen(response, who, act.taskId, settled.reason === "alive" || settled.reason === "still-running"
1315
- ? `Something from build #${run.id} may still be running, so it can't be confirmed stopped yet.` : `Build #${run.id} can't be confirmed stopped.`, 409);
1316
- }
1310
+ if (!store.accountCanAccess(who.name, ref.repo))
1311
+ return taskScreen(response, who, act.taskId, "You can't act on this project's tasks.", 403);
1312
+ const proved = await proveRunStopped(store, run.id, clock);
1317
1313
  bustBadge();
1314
+ const left = store.stopQuiescenceFact(run.id);
1315
+ if (left !== null) {
1316
+ const words = proved.outcome === "blocked" ? `Build #${proved.run} ${proved.words}.`
1317
+ : left.kind === "alive" || left.kind === "open" ? `Something from build #${left.run} may still be running. Toolroll stops what it proves is the build's on its own.`
1318
+ : left.kind === "elsewhere" ? `Build #${left.run} ran on another computer; only that computer can check it stopped.`
1319
+ : left.kind === "unknown" ? `Toolroll still can't prove build #${left.run} stopped from this computer.`
1320
+ : `Toolroll is still making sure build #${left.run} stopped. This clears on its own.`;
1321
+ return taskScreen(response, who, act.taskId, words, 409);
1322
+ }
1318
1323
  const to = resultReturnTarget(body.get("return"), run.id, runTasks(run.taskRef));
1319
1324
  return redirect(response, body.get("return") ? to : back);
1320
1325
  }
@@ -1444,6 +1449,8 @@ export function createTasksHandlers(runtime) {
1444
1449
  const result = requestResultChanges(store, evidenceRoot, {
1445
1450
  run: id, batch: body.get("batch"), source: body.get("source"), actor: who.name,
1446
1451
  repos: admissionList(), includeUnplaced: visible(null), allowMode: who.via === "cookie",
1452
+ // A browser session is a person's proof; a bearer credential is a machine's.
1453
+ origin: who.via === "cookie" ? "person" : "machine",
1447
1454
  note: body.get("note") ?? "", path: body.get("path") ?? "", line: body.get("line") ?? "", request: body.get("request"), image,
1448
1455
  }, now);
1449
1456
  if (!result.ok) {
@@ -1556,6 +1563,7 @@ export function createTasksHandlers(runtime) {
1556
1563
  const result = createResultRevision(store, evidenceRoot, {
1557
1564
  run: id, batch: body.get("batch"), source: body.get("source"), actor: who.name,
1558
1565
  repos: admissionList(), includeUnplaced: visible(null), allowMode: who.via === "cookie",
1566
+ origin: who.via === "cookie" ? "person" : "machine",
1559
1567
  }, now);
1560
1568
  if (!result.ok)
1561
1569
  return refuse(response, who, result.status, result.message, back);
@@ -2455,7 +2463,11 @@ export function createTasksHandlers(runtime) {
2455
2463
  : requestedReturn === null
2456
2464
  ? taskHref(taskId)
2457
2465
  : safeChatReturn(requestedReturn);
2466
+ // The release sheet's Approve release (trusted-release-reports.md A7): the same door, its own page for words.
2467
+ const sheet = body.get("sheet") === "release";
2458
2468
  const approvalProblem = (message, status) => {
2469
+ if (sheet)
2470
+ return releaseSheet.sheetScreen({ who, response, project: null }, taskId, message, status);
2459
2471
  if (requestedReturn !== null && requestedReturn !== "next") {
2460
2472
  return redirect(response, chatReturnWithSaid(approvalBack, message));
2461
2473
  }
@@ -2481,6 +2493,9 @@ export function createTasksHandlers(runtime) {
2481
2493
  && !(who.authority === "local-owner" && localApprovalEligible(store, who.name, taskId).ok)) {
2482
2494
  return approvalProblem("approval requires your password, typed again", 400);
2483
2495
  }
2496
+ // The sheet binds the release card's digest (the nonce was minted for it): the shared card wrapper decides.
2497
+ if (sheet)
2498
+ return releaseSheet.approveFromSheet({ who, response, project: null }, taskId, digest, token);
2484
2499
  const scopeRow = store.getScope(taskId);
2485
2500
  if (scopeRow === null)
2486
2501
  return approvalProblem("this task has no scope to approve", 409);
@@ -3075,6 +3090,7 @@ export function createTasksHandlers(runtime) {
3075
3090
  return live;
3076
3091
  }
3077
3092
  const tidyHandlers = createTidyHandlers(runtime);
3093
+ const releaseSheet = createReleaseSheetHandlers(runtime);
3078
3094
  /** The question page, with a refusal in its box when an answer was refused (the box keeps the choice to make). */
3079
3095
  function decisionScreen(ctx, id, back, refusal, code, now, draft) {
3080
3096
  const { who, response, project } = ctx;
@@ -3092,6 +3108,7 @@ export function createTasksHandlers(runtime) {
3092
3108
  }
3093
3109
  const registrations = handlersOf("tasks", {
3094
3110
  ...tidyHandlers,
3111
+ ...releaseSheet.routes,
3095
3112
  ...createAgentSignalHandlers(runtime),
3096
3113
  "code.page": showCode("workspace"),
3097
3114
  "code.session": showCode("session"),
@@ -3152,7 +3169,7 @@ export function createTasksHandlers(runtime) {
3152
3169
  "task.act.retry-review": taskAct("retry-review"),
3153
3170
  "task.act.complete": taskAct("complete"),
3154
3171
  "task.act.merge": taskMerge,
3155
- "task.act.confirm-stopped": taskConfirmStopped,
3172
+ "task.act.recheck-stopped": taskRecheckStopped,
3156
3173
  "task.act.stop": taskAct("stop"),
3157
3174
  "task.act.stop-carrying": taskAct("stop-carrying"),
3158
3175
  "task.act.pause": taskAct("pause"),
@@ -3311,3 +3328,71 @@ export function createAgentSignalHandlers(runtime) {
3311
3328
  }
3312
3329
  return { "agents.seen": agentSeen, "agents.watch": agentWatch };
3313
3330
  }
3331
+ /**
3332
+ * One-tap release approval (docs/plans/trusted-release-reports.md, slice A): the sheet (`/t/<id>/release`), the push's
3333
+ * opaque locator (`/approve/<task ref>`, a GET that only redirects to the sheet) and the sheet's approval, which the
3334
+ * task approval POST hands here once its shared checks (cookie session, CSRF and the browser key at admission, the
3335
+ * single-use nonce, a password or a standing proof) passed. The approval itself is the shared card wrapper around the
3336
+ * unchanged approve() (release-approval.ts approveReleaseCard).
3337
+ */
3338
+ export function createReleaseSheetHandlers(runtime) {
3339
+ const { store, sendScreen, chromeFor, visible, mintApprovalNonce, clock } = runtime;
3340
+ const missing = (who, response) => refuse(response, who, 404, "There's no release waiting for approval at this address.", "/work");
3341
+ /** The sheet, with a problem said where it happened. */
3342
+ function sheetScreen(ctx, taskId, problem, status) {
3343
+ const { who, response, project } = ctx;
3344
+ const ref = store.lookupRef(taskId);
3345
+ if (ref === null || !visible(ref.repo) || !store.accountCanAccess(who.name, ref.repo))
3346
+ return missing(who, response);
3347
+ const built = releaseApprovalCard(store, taskId);
3348
+ if (!built.ok)
3349
+ return missing(who, response);
3350
+ const card = built.card;
3351
+ const open = card.approved === null && card.refusal === null;
3352
+ // This computer's local owner can't approve a release: the full sign-in comes first (scope.ts localApprovalEligible).
3353
+ const local = who.via === "cookie" && who.authority === "local-owner" ? localOwnerRefusal(store.accountOf(who.name)?.passwordSet === true, releaseSheetHref(taskId)) : null;
3354
+ const nonce = open && local === null && who.via === "cookie" && who.role === "approver" ? mintApprovalNonce(who.name, taskId, card.digest) : "";
3355
+ const remembered = (() => {
3356
+ if (!open || who.via !== "cookie")
3357
+ return false;
3358
+ const fresh = withFreshness("approve-standard", () => freshnessFor(who.name, Date.now(), true));
3359
+ if (fresh === null)
3360
+ return false;
3361
+ const gate = store.approvalGate(taskId, who.name, "person");
3362
+ return freshlyApprovable(store, taskId, card.scope, gate.verdict === "seal" && !gate.protectedWork);
3363
+ })();
3364
+ return sendScreen(response, status, releaseSheetPage(chromeFor(project ?? ref.repo, "work"), { card, nonce, facts: approvalFactsNow({ remembered }), local, problem }));
3365
+ }
3366
+ async function showSheet(ctx) {
3367
+ const task = matchTaskPath(ctx.url.pathname, "/release$");
3368
+ if (task === null)
3369
+ return missing(ctx.who, ctx.response);
3370
+ return sheetScreen(ctx, task.taskId, null, 200);
3371
+ }
3372
+ /** The push's locator: proved a release in the person's projects from the plane's own state, then only redirected. */
3373
+ async function locate(ctx) {
3374
+ const { who, response, url } = ctx;
3375
+ const raw = /^\/approve\/([1-9][0-9]{0,14})$/.exec(url.pathname)?.[1];
3376
+ const ref = raw === undefined ? null : store.refForId(Number(raw));
3377
+ if (ref === null || ref.backend !== "built-in" || !visible(ref.repo) || !store.accountCanAccess(who.name, ref.repo) || releaseTaskOf(store, ref.externalId) === null)
3378
+ return missing(who, response);
3379
+ return redirect(response, releaseSheetHref(ref.externalId));
3380
+ }
3381
+ /** The sheet's Approve release, after the approval POST's shared checks. */
3382
+ function approveFromSheet(ctx, taskId, cardDigest, token) {
3383
+ const { who, response } = ctx;
3384
+ const now = clock();
3385
+ const answer = spendingOnlyOnSuccess(() => withFreshness("approve-standard", () => approveReleaseCard(store, { taskId, cardDigest, approveNow: scopeDigest => approveScope(store, taskId, who.name, now, scopeDigest, token) })), result => !result.ok && !(result.reason === "approve" && result.result.reason === "second-approver" && !result.result.already));
3386
+ if (answer.ok)
3387
+ return redirect(response, releaseSheetHref(taskId));
3388
+ if (answer.reason !== "approve")
3389
+ return sheetScreen(ctx, taskId, answer.message, answer.reason === "not-release" ? 404 : 409);
3390
+ const refused = answer.result;
3391
+ if (refused.reason === "second-approver")
3392
+ return sheetScreen(ctx, taskId, gateWords({ verdict: "vote", have: refused.have, need: 2, already: refused.already }), 200);
3393
+ if (refused.reason === "policy")
3394
+ return sheetScreen(ctx, taskId, refused.message, 403);
3395
+ return sheetScreen(ctx, taskId, approveRefusalWords(refused.reason), refused.reason === "changed" || refused.reason === "unrouted" ? 409 : 403);
3396
+ }
3397
+ return { routes: { "task.release": showSheet, "release.locate": locate }, sheetScreen, approveFromSheet };
3398
+ }
@@ -35,11 +35,13 @@ export const PROOF_INVENTORY = [
35
35
  { id: "google", action: "Connect or disconnect Google", routes: ["/settings/google", "/settings/google/disconnect"], form: "google", field: null, proof: "none", ...NONE, role: "any", secrets: ["clientSecret"], terms: [] },
36
36
  { id: "appearance", action: "Change the theme or accent", routes: ["/settings/appearance"], form: "appearance", field: null, proof: "none", ...NONE, role: "any", secrets: [], terms: [] },
37
37
  { id: "chat-approval-confirm", action: "Review approving from chat", routes: ["/settings/chat-approval/confirm", "/settings/chat-approval/off"], form: "chatApproval", field: null, proof: "none", ...NONE, role: "approver", secrets: [], terms: [] },
38
+ { id: "models-rules-off", action: "Turn an earned rule off, stop a trial, say Not now, or turn suggestions off", routes: ["/settings/models/turn-off", "/settings/models/not-now", "/settings/models/suggest-off", "/settings/models/stop"], form: "modelsRules", field: null, proof: "none", ...NONE, role: "operator", secrets: [], terms: [] },
38
39
  ...CHAT_APPS.map(([app, name, form]) => ({ id: `${app}-alerts`, action: `Send alerts to ${name}`, routes: [`/settings/${app}/alerts`], form, field: null, proof: "none", ...NONE, role: "approver", secrets: [], terms: [] })),
39
40
  // Step-up: a password typed now, the Passwordless session, or a one-use re-check.
40
41
  { id: "permission-default", action: "Start new tasks with Full access", routes: ["/settings/permission-default"], form: "permissionDefault", field: "password", proof: "step-up", when: "only when widening to Full access", ...ACCEPT, role: "any", secrets: [], terms: [] },
41
42
  { id: "spend-budget", action: "Set a monthly limit", routes: ["/spend/budget"], form: "spendBudget", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: [] },
42
43
  { id: "models-agent", action: "Change which model does each job", routes: ["/settings/models/agent"], form: "modelsChange", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["scope", "digest"] },
44
+ { id: "models-rules", action: "Accept a suggested model, try a cheaper one, keep a paused rule, or turn suggestions on", routes: ["/settings/models/accept", "/settings/models/keep", "/settings/models/suggest-on", "/settings/models/try"], form: "modelsRules", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["scope", "id", "rule"], note: "the rule door re-proves the session and its generation in the write" },
43
45
  { id: "models-apply", action: "Apply a model preset or switch", routes: ["/settings/models/apply"], form: "modelsChange", field: "password", proof: "step-up", ...ACCEPT, role: "operator", secrets: [], terms: ["scope", "digest", "change"], note: "plus the previewed preset, or switch and to" },
44
46
  ...[
45
47
  ["policy", "Change the policy", "policy", "operator"],
@@ -10,6 +10,7 @@ import { type ApprovalFacts } from "./design/kit.js";
10
10
  import { type BudgetState, type SpendItem } from "./spend.js";
11
11
  import type { SpendReport } from "./spend-report.js";
12
12
  import type { BrowserLimits } from "./browser-workspace.js";
13
+ import { type SavingsReport } from "./route-savings.js";
13
14
  /** The page's own layout: the limit rows and meters, the summary figures and the quiet bars. Colour from tokens only. */
14
15
  export declare const SPEND_CSS: string;
15
16
  /** What this page doesn't record, in plain words (docs/plans/spend-dashboard.md §2.2). */
@@ -53,6 +54,10 @@ export type SpendView = {
53
54
  facts?: ApprovalFacts;
54
55
  /** This month's name (YYYY-MM): the limits are this month's. */
55
56
  current?: string;
57
+ /** Suggestions and rules (route-savings.ts): what each rule, trial and suggestion observed, or that it can't be read. */
58
+ savings?: SavingsReport | {
59
+ error: string;
60
+ } | null;
56
61
  };
57
62
  /** Dollars for the page: a positive amount under a cent reads "<$0.01", never a false $0. */
58
63
  export declare const money: (microusd: number) => string;
@@ -62,6 +67,12 @@ export declare function spendHtml(view: SpendView, said: {
62
67
  said?: string | null;
63
68
  problem?: string | null;
64
69
  }): Html;
70
+ /** Suggestions and rules (docs/plans/results-routing.md, slice 4): per project and size, each live or recently ended
71
+ * rule or trial, and each standing suggestion, with what it observed over matched 90-day windows. Labelled "observed,
72
+ * not a promise"; a median shows only from 5 API-priced results a side; unknown is "—", never $0. Read-only. */
73
+ export declare function savingsSection(savings: SavingsReport | {
74
+ error: string;
75
+ } | null): Html | "";
65
76
  /** Checks the amount next to its field before any password is asked or the form leaves the page; the server checks
66
77
  * it again. Remove skips it. */
67
78
  export declare function spendScript(): string;
package/dist/spend-ui.js CHANGED
@@ -10,6 +10,7 @@ import { approvalAsk, termsOf } from "./design/kit.js";
10
10
  import { acts, btn, chip, disclose, dock, empty, field, input, note, notice, segLinks, settingsShell } from "./server/kit.js";
11
11
  import { budgetLabel, usd } from "./spend.js";
12
12
  import { runHref, taskHref } from "./server/http.js";
13
+ import { notEnoughWords, pricesWords, sideWords, SAVINGS_LABEL } from "./route-savings.js";
13
14
  const projectName = (repo) => repo.split("/").filter(Boolean).pop() ?? repo;
14
15
  /** The page's own layout: the limit rows and meters, the summary figures and the quiet bars. Colour from tokens only. */
15
16
  export const SPEND_CSS = `.spend{display:grid;gap:24px;min-width:0}.spend-sec{display:grid;gap:0}.spend-sec .k-secthead{margin-bottom:0}.spend-sec .k-secthead small{font-weight:400;color:var(--so-muted);font-size:12.5px}` +
@@ -29,7 +30,14 @@ export const SPEND_CSS = `.spend{display:grid;gap:24px;min-width:0}.spend-sec{di
29
30
  `.spend-win.warning .spend-meter>i,.spend-win.danger .spend-meter>i{background:var(--so-danger)}.spend .k-disclose{padding-top:4px}.spend .k-disclose[open]>summary{margin-bottom:8px}` +
30
31
  `@media(max-width:760px){.spend-lim-h{grid-template-columns:minmax(0,1fr) auto}.spend-lim-a{grid-column:1/-1}.spend-lim-a .k-btn{width:100%}.spend-sum{grid-template-columns:minmax(0,1fr)}` +
31
32
  `.spend-sum>div+div{border-left:0;border-top:1px solid var(--so-line);padding-left:0}.spend-bar{grid-template-columns:minmax(0,120px) minmax(0,1fr) 68px;gap:8px;font-size:12.5px}` +
32
- `.spend-edit-row .k-field{flex:1 1 100%}.spend .k-btn.k-btn-primary.k-docked{display:none}}`;
33
+ `.spend-edit-row .k-field{flex:1 1 100%}.spend .k-btn.k-btn-primary.k-docked{display:none}}` +
34
+ // Suggestions and rules (results-routing slice 4): each rule's two sides, side by side; stacked on a phone.
35
+ `.spend-rr{display:grid;gap:10px;padding:14px 0;border-bottom:1px solid var(--so-line);min-width:0}.spend-rr-h{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;min-width:0}` +
36
+ `.spend-rr-t{font-weight:500;overflow-wrap:anywhere;min-width:0}.spend-rr-x{font-size:12.5px;color:var(--so-muted);overflow-wrap:anywhere}` +
37
+ `.spend-rr-g{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.spend-rr-s{display:grid;gap:4px;align-content:start;min-width:0;padding:12px;border:1px solid var(--so-line);border-radius:8px}` +
38
+ `.spend-rr-k{font-size:12px;color:var(--so-muted);overflow-wrap:anywhere}.spend-rr-v{font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}.spend-rr-v small{font-size:12px;font-weight:400;color:var(--so-muted)}` +
39
+ `.spend-rr-p{display:grid;gap:2px;margin:0;padding:0;list-style:none;font-size:12.5px;color:var(--so-muted)}.spend-rr .k-note{margin:0}` +
40
+ `@media(max-width:760px){.spend-rr-g{grid-template-columns:minmax(0,1fr)}}`;
33
41
  const ROLE_WORDS = {
34
42
  builder: "Builds", repair: "Repairs", planner: "Plans", reviewer: "Reviews", scout: "Scouting",
35
43
  lead: "Lead chat", chat: "Chat", subagent: "Subagents", flow: "Flows", evals: "Evals",
@@ -148,10 +156,49 @@ export function spendHtml(view, said) {
148
156
  const docked = edit !== null && (rows.some(row => row.target === edit.target) || adding !== null)
149
157
  ? dock(btn("Save limit", { variant: "primary", type: "submit", attrs: { form: `limit-${edit.target.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").slice(0, 60) || "new"}`, name: "action", value: "save" } }))
150
158
  : "";
151
- const body = html `<div class="spend">${limits}${monthBar}${figures}${breakdowns}${windows}${notCounted}</div>${docked}`;
159
+ const body = html `<div class="spend">${limits}${monthBar}${figures}${breakdowns}${windows}${savingsSection(view.savings ?? null)}${notCounted}</div>${docked}`;
152
160
  return settingsShell({ group: "spend", title: "Spend", lead: "What agents cost, by project and by model, with the limits that stop them. Dates are UTC months.", body,
153
161
  flags: { spend: saved.some(one => one.budget !== null && limitNeedsYou(one.budget)) } });
154
162
  }
163
+ const sizeWords = (size) => size === "small" ? "Small tasks" : "Routine tasks";
164
+ const dateWords = (iso) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }).format(new Date(iso));
165
+ const STATUS_TONE = { active: "live", trialling: "live", paused: "needs" };
166
+ /** One side of a rule's comparison: its model and window, the median API dollars a task (— when hidden), and the
167
+ * completeness, plan usage, unknown cost and remediation counts, each on its own line. */
168
+ function savingsSide(label, side) {
169
+ const words = sideWords(side);
170
+ const cap = (text) => text.charAt(0).toUpperCase() + text.slice(1);
171
+ return html `<div class="spend-rr-s" role="group" aria-label="${label}: ${side.name}" data-side="${label.toLowerCase()}"><span class="spend-rr-k">${label} · ${side.name} · ${dateWords(side.from)} to ${dateWords(side.to)}</span>\
172
+ <span class="spend-rr-v" data-amount-kind="api" data-microusd="${side.medianApiMicrousd ?? ""}">${words.median} <small>median API dollars a task</small></span>\
173
+ <ul class="spend-rr-p"><li data-priced="${side.apiPriced}" data-results="${side.results}">${words.parts[0]}</li><li data-amount-kind="subscription">${cap(words.parts[1])}</li><li data-amount-kind="unknown">${cap(words.parts[2])}</li><li>${cap(words.parts[3])}</li></ul></div>`;
174
+ }
175
+ function savingsRow(row) {
176
+ const suggestion = row.kind === "suggestion";
177
+ const what = `${row.cheaper.name} instead of ${row.configured.name}${row.kind === "trial" ? " (a trial)" : suggestion ? " (suggested, not in use)" : ""}`;
178
+ const href = `/settings/models?scope=${encodeURIComponent(row.repo)}#models-suggested`;
179
+ return html `<article class="spend-rr" data-savings-row data-kind="${row.kind}" data-state="${row.state}" data-enough="${row.enough ? "yes" : "no"}">\
180
+ <div class="spend-rr-h"><span class="spend-rr-t">${row.project} · ${sizeWords(row.size)}</span>${chip(row.status, STATUS_TONE[row.state] ?? "")}</div><span class="spend-rr-x">${what}</span>\
181
+ <div class="spend-rr-g">${savingsSide(suggestion ? "Configured" : "Before", row.configured)}${savingsSide(suggestion ? "Suggested" : "After", row.cheaper)}</div>\
182
+ ${row.enough ? "" : html `<p class="k-note" data-not-enough>${notEnoughWords(row)}</p>`}<p class="k-note" data-prices>${pricesWords(row)}</p>\
183
+ <span><a class="k-link" href="${href}">${suggestion ? "Review in Models settings" : "Open Models settings"}</a></span></article>`;
184
+ }
185
+ /** Suggestions and rules (docs/plans/results-routing.md, slice 4): per project and size, each live or recently ended
186
+ * rule or trial, and each standing suggestion, with what it observed over matched 90-day windows. Labelled "observed,
187
+ * not a promise"; a median shows only from 5 API-priced results a side; unknown is "—", never $0. Read-only. */
188
+ export function savingsSection(savings) {
189
+ if (savings === null)
190
+ return "";
191
+ const state = `${SAVINGS_LABEL} · 90 days a side`;
192
+ if ("error" in savings)
193
+ return sec("savings", "Suggestions and rules", state, notice("Suggestions and rules can't be read right now. Nothing else on this page is affected."), html ` data-savings="error"`);
194
+ if (savings.rows.length === 0) {
195
+ return sec("savings", "Suggestions and rules", state, empty({ title: "No suggestions or rules yet.",
196
+ text: "When a cheaper model does as well on a project's small or routine tasks, its suggestion shows here, and once it's in use, what it cost before and after." }), html ` data-savings="empty"`);
197
+ }
198
+ const stale = savings.staleSince === null ? "" : html `<p class="k-note" data-savings-stale>Some results were last checked on ${dateWords(savings.staleSince)}; they update while Toolroll runs.</p>`;
199
+ const lead = html `<p class="k-note">Median API dollars a task, including revision and repair runs. Plan usage and unknown cost are counted apart, never added in. A median shows once each side has ${savings.minPriced} results priced in API dollars.</p>`;
200
+ return sec("savings", "Suggestions and rules", state, html `${lead}${stale}${savings.rows.map(savingsRow)}`, html ` data-savings="rows"`);
201
+ }
155
202
  /** Checks the amount next to its field before any password is asked or the form leaves the page; the server checks
156
203
  * it again. Remove skips it. */
157
204
  export function spendScript() {
@@ -0,0 +1,67 @@
1
+ import { type StopOptions } from "./run-closeout.js";
2
+ import { type Store } from "./store.js";
3
+ /** How long after a run finished its processes have to go on their own before Toolroll looks. */
4
+ export declare const STOP_PROOF_GRACE_MS = 60000;
5
+ export type StopProofOutcome =
6
+ /** Proven stopped and settled, in the ledger with its evidence. */
7
+ "settled"
8
+ /** Not yet: inside the grace period, a live child is still tracked here, or a leftover was just stopped. */
9
+ | "waiting"
10
+ /** This computer can't prove it (can't look, another computer's process, or something it didn't start is in the
11
+ * folder). Recorded once, in plain words. */
12
+ | "blocked"
13
+ /** Nothing for this proof to do: already stopped, still open, alive on its own record, or a native sandbox. */
14
+ | "skipped";
15
+ export type StopProofResult = {
16
+ run: number;
17
+ outcome: StopProofOutcome;
18
+ words: string;
19
+ stopped?: number;
20
+ };
21
+ export type StopProofOptions = StopOptions & {
22
+ /** How long after the run finished before Toolroll looks (STOP_PROOF_GRACE_MS). `graceMs` is the SIGTERM grace. */
23
+ afterFinishMs?: number;
24
+ /** Which processes have their working directory inside each path; must throw when it can't tell. */
25
+ cwds?: (paths: readonly string[]) => Map<string, number[]>;
26
+ /** Every process's command line, by pid; must throw when it can't tell. */
27
+ commandLines?: () => Map<number, string>;
28
+ /** The live service's own pid (its ancestors are never targets). */
29
+ self?: number;
30
+ /** The worktree note check (worktree.ts); throws when the note can't be read. */
31
+ occupancy?: (path: string) => {
32
+ held: true;
33
+ by: number;
34
+ } | {
35
+ held: false;
36
+ };
37
+ /** Called after processes were stopped: a cached look must be read again. */
38
+ refresh?: () => void;
39
+ };
40
+ /** One pass's shared look at this computer: the process table, folders and command lines are read once for every
41
+ * run the pass checks, and again after anything was stopped. */
42
+ export declare function sharedLook(options?: StopProofOptions): StopProofOptions;
43
+ /** Which processes work inside each path, from the OS: /proc on Linux, lsof on macOS. Unlike a best-effort listing,
44
+ * it throws when the listing can't be read: no answer is never "nobody". */
45
+ export declare function processesInStrict(paths: readonly string[], listing?: readonly {
46
+ pid: number;
47
+ cwd: string;
48
+ }[]): Map<string, number[]>;
49
+ /** Every process's working directory, from the OS; throws when the listing can't be read. */
50
+ export declare function processFolders(): {
51
+ pid: number;
52
+ cwd: string;
53
+ }[];
54
+ /** Every process's command line, by pid. Read to match a folder's path in memory only; never stored or shown. */
55
+ export declare function processCommandLines(): Map<number, string>;
56
+ /** Whether a command line names `path` or something inside it (a whole path, never a longer folder's prefix). */
57
+ export declare function namesPath(line: string, path: string): boolean;
58
+ /**
59
+ * Prove one finished run stopped, on this computer, without asking anyone (see the file's comment). Walks the run's
60
+ * owned runs one unproven stop at a time. Never signals a process that isn't proven the run's.
61
+ */
62
+ export declare function proveRunStopped(store: Store, runId: number, clock: () => Date, options?: StopProofOptions): Promise<StopProofResult>;
63
+ /** The worker's pass (operate.ts reconcile): every finished run of this computer whose stop is unproven where
64
+ * Toolroll's own look can prove it. A batch a pass, oldest first; a blocked run never holds newer ones back. */
65
+ export declare function proveFinishedRunsStopped(store: Store, clock: () => Date, options?: StopProofOptions & {
66
+ batch?: number;
67
+ }): Promise<StopProofResult[]>;