toolroll 0.9.92 → 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 (65) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/assignment-status.d.ts +1 -1
  3. package/dist/assignment-ui.js +2 -2
  4. package/dist/assignment.js +11 -3
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +30 -30
  7. package/dist/browser-crew.js +2 -2
  8. package/dist/browser-workspace.d.ts +3 -5
  9. package/dist/builder.js +21 -2
  10. package/dist/check-facts.d.ts +124 -0
  11. package/dist/check-facts.js +272 -0
  12. package/dist/check-report.d.ts +5 -0
  13. package/dist/check-report.js +4 -3
  14. package/dist/cli.js +5 -0
  15. package/dist/contracts/console-api.d.ts +2 -2
  16. package/dist/contracts/console-api.js +2 -2
  17. package/dist/contracts/release-check.d.ts +25 -1
  18. package/dist/contracts/release-check.js +19 -0
  19. package/dist/desktop-update.d.ts +2 -2
  20. package/dist/desktop-update.js +13 -9
  21. package/dist/eval-hooks.d.ts +55 -0
  22. package/dist/eval-hooks.js +227 -4
  23. package/dist/flaky.d.ts +17 -0
  24. package/dist/flaky.js +9 -0
  25. package/dist/needs-you.d.ts +7 -7
  26. package/dist/needs-you.js +12 -10
  27. package/dist/operate.js +138 -18
  28. package/dist/project-delete.js +5 -1
  29. package/dist/project-knowledge.d.ts +6 -6
  30. package/dist/push.d.ts +10 -3
  31. package/dist/push.js +22 -6
  32. package/dist/release-adapters.js +11 -1
  33. package/dist/release-approval.d.ts +191 -0
  34. package/dist/release-approval.js +382 -0
  35. package/dist/release-auto.js +13 -4
  36. package/dist/release-card.d.ts +73 -4
  37. package/dist/release-card.js +168 -23
  38. package/dist/release-cli.js +9 -1
  39. package/dist/release.d.ts +30 -0
  40. package/dist/release.js +42 -3
  41. package/dist/result-acts.d.ts +3 -3
  42. package/dist/result-acts.js +1 -1
  43. package/dist/server/guards.js +1 -0
  44. package/dist/server/pages-assets.js +1 -1
  45. package/dist/server/render-release.d.ts +17 -0
  46. package/dist/server/render-release.js +84 -0
  47. package/dist/server/render-results.d.ts +0 -2
  48. package/dist/server/render-results.js +4 -6
  49. package/dist/server/render-tasks.js +3 -3
  50. package/dist/server/route-table.d.ts +1 -1
  51. package/dist/server/route-table.js +7 -1
  52. package/dist/server/tasks.d.ts +15 -0
  53. package/dist/server/tasks.js +102 -20
  54. package/dist/setup-guide.d.ts +2 -2
  55. package/dist/stop-proof.d.ts +67 -0
  56. package/dist/stop-proof.js +293 -0
  57. package/dist/store.d.ts +70 -31
  58. package/dist/store.js +335 -95
  59. package/dist/surface.js +1 -1
  60. package/dist/task-outcome-cli.js +19 -0
  61. package/dist/toolroll-update-ui.js +2 -2
  62. package/dist/toolroll-update.js +1 -0
  63. package/dist/work-index.js +7 -9
  64. package/dist/work-summary.d.ts +2 -2
  65. package/package.json +1 -1
@@ -707,8 +707,6 @@ export declare function needActionOf(assignment: AssignmentSnapshot | null, stat
707
707
  rebuildable?: boolean;
708
708
  refuted?: boolean;
709
709
  }): BrowserNeedAction | null;
710
- /** A build Toolroll can't check at all: the approver ticks that they checked before confirming. */
711
- export declare const needsCheck: (assignment: AssignmentSnapshot) => boolean;
712
710
  /** The server page's form of the same: the need's action first, Request changes as the quiet second. */
713
711
  export declare function resultNeedAction(need: BrowserNeedAction, o: ResultPanelOptions, canRequest: boolean): Html;
714
712
  /** The outcome in one bounded sentence (repair 2026-09-14): the handoff's
@@ -731,7 +731,7 @@ export function reviewCockpitDetailParts(view, csrf, noted, canRetryReview, tab,
731
731
  checksRunning,
732
732
  blocked,
733
733
  canRequest: panel.panel.canRequest && panel.panel.request !== null,
734
- need: need === null || need.accept != null ? null : need.rebuild != null ? "rebuild" : need.confirm !== null ? "confirm-stopped" : null,
734
+ need: need === null || need.accept != null ? null : need.rebuild != null ? "rebuild" : need.confirm !== null ? "recheck-stopped" : null,
735
735
  next: nextKind === "revise" || nextKind === "draft-repair" ? nextKind : null,
736
736
  unanswered: settled === null ? 0 : unanswered.length,
737
737
  notRight: 0,
@@ -2009,12 +2009,10 @@ export function needActionOf(assignment, status, csrf, returnTo, result) {
2009
2009
  if (status.need.key === "review-result" || here)
2010
2010
  return null;
2011
2011
  }
2012
- const confirm = action?.code === "confirm-stopped" && action.target.runId !== null && csrf !== ""
2013
- ? { action: `${taskHref(assignment.rootId)}/confirm-stopped`, run: action.target.runId, returnTo, checked: needsCheck(assignment) } : null;
2012
+ const confirm = action?.code === "recheck-stopped" && action.target.runId !== null && csrf !== ""
2013
+ ? { action: `${taskHref(assignment.rootId)}/recheck-stopped`, run: action.target.runId, returnTo } : null;
2014
2014
  return { label: status.need.action.label, href: assignmentActionHref(assignment) ?? taskHref(assignment.rootId), confirm };
2015
2015
  }
2016
- /** A build Toolroll can't check at all: the approver ticks that they checked before confirming. */
2017
- export const needsCheck = (assignment) => assignment.need != null && "key" in assignment.need && assignment.need.key === "check-stopped";
2018
2016
  /** The server page's form of the same: the need's action first, Request changes as the quiet second. */
2019
2017
  export function resultNeedAction(need, o, canRequest) {
2020
2018
  const control = need.accept != null
@@ -2022,7 +2020,7 @@ export function resultNeedAction(need, o, canRequest) {
2022
2020
  : need.rebuild != null
2023
2021
  ? postForm(need.rebuild.action, html `<button type="submit" data-primary-action data-rebuild style="min-height:44px">${need.label}</button>`, { attrs: { class: "rebuild" } })
2024
2022
  : need.confirm !== null
2025
- ? postForm(need.confirm.action, html `${need.confirm.checked === true ? html `<label><input type="checkbox" name="checked" value="yes" required> Nothing from build #${need.confirm.run} is running</label>` : ""}${reauth(html `<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>`)}<button type="submit" style="min-height:44px">${need.label}</button>`, { attrs: { id: "confirm-stopped", class: "confirm-stopped" }, returnTo: need.confirm.returnTo, hidden: { run: need.confirm.run } })
2023
+ ? postForm(need.confirm.action, html `<button type="submit" data-primary-action style="min-height:44px">${need.label}</button>`, { attrs: { id: "recheck-stopped", class: "recheck-stopped" }, returnTo: need.confirm.returnTo, hidden: { run: need.confirm.run } })
2026
2024
  : html `<a class="button-link" href="${need.href ?? "#"}" data-primary-action>${need.label}</a>`;
2027
2025
  return html `<div class="result-action" data-result-action="need">${control}${canRequest && o.csrf !== "" ? html `<a class="result-feedback-link" href="#request-changes">Request changes</a>` : ""}</div>`;
2028
2026
  }
@@ -50,7 +50,7 @@ import { isOverdue, oneLineOf, projectChip, regionScript, safePrUrl, screen, SEN
50
50
  import { steerDestinationWords, steerStandingParts } from "../steer-status.js";
51
51
  import { reviewHref, taskChatHref, taskHref } from "./http.js";
52
52
  import { coordinatorProposalsSection } from "./render-chat.js";
53
- import { completionReceiptCard, criterionMatrixHtml, evidenceWords, homePhaseWords, incidentWords, needsCheck, pullRequestCardHtml, reasonWords, receiptStatusOf, repairChainHtml, reportItemsHtml, reviewRetryPanel, runNoun, runOutcomeBadge, semanticCoverageHtml, verificationExplanation, verificationRecovered } from "./render-results.js";
53
+ import { completionReceiptCard, criterionMatrixHtml, evidenceWords, homePhaseWords, incidentWords, pullRequestCardHtml, reasonWords, receiptStatusOf, repairChainHtml, reportItemsHtml, reviewRetryPanel, runNoun, runOutcomeBadge, semanticCoverageHtml, verificationExplanation, verificationRecovered } from "./render-results.js";
54
54
  import {} from "./session.js";
55
55
  import { linkWords, originNote, requestName, requestWords } from "../task-source.js";
56
56
  export function contractChangeHtml(change) {
@@ -2408,8 +2408,8 @@ export function taskBodyParts(data) {
2408
2408
  approve: data.archived != null || sheetInput === null || dependencyChoiceNeeded || data.csrf === "" ? null : approveKitOf(sheetInput, data.approvalFresh ?? null),
2409
2409
  approved: scope !== null && approval.approved ? approvedKitOf(scope, approval, data.approvalProof ?? null) : null,
2410
2410
  more,
2411
- confirmStopped: data.csrf !== "" && data.assignment?.primaryAction?.code === "confirm-stopped" && data.assignment.primaryAction.target.runId !== null
2412
- ? { action: `${taskHref(data.assignment.rootId)}/confirm-stopped`, run: data.assignment.primaryAction.target.runId, checked: needsCheck(data.assignment) } : null,
2411
+ recheckStopped: data.csrf !== "" && data.assignment?.primaryAction?.code === "recheck-stopped" && data.assignment.primaryAction.target.runId !== null
2412
+ ? { action: `${taskHref(data.assignment.rootId)}/recheck-stopped`, run: data.assignment.primaryAction.target.runId } : null,
2413
2413
  rebuild,
2414
2414
  // The plan, progress and plan changes are thread entries now; the rest still needs a person here.
2415
2415
  lead: [
@@ -22,7 +22,7 @@ export type Caller = "anonymous" | "service" | "cookie" | "bearer" | "oauth" | "
22
22
  /** What the caller must hold: nothing, read, act (an approver or an `act` token), or a fresh password/provider check on top. */
23
23
  export type RouteScope = "none" | "read" | "act" | "step-up";
24
24
  /** Where the request's project comes from. */
25
- export type ProjectResolver = "none" | "session" | "form" | "form-path" | "task" | "run" | "decision" | "incident" | "flow" | "proposal" | "conversation" | "coding" | "adapter";
25
+ export type ProjectResolver = "none" | "session" | "form" | "form-path" | "task" | "task-ref" | "run" | "decision" | "incident" | "flow" | "proposal" | "conversation" | "coding" | "adapter";
26
26
  /**
27
27
  * What a project-limited account may do here:
28
28
  * - `deny`: instance access only.
@@ -286,6 +286,10 @@ get("tasks", "welcome", "/welcome", { limited: "collection", ...free });
286
286
  post("tasks", "welcome.sample", "/welcome/sample", { callers: ["cookie"], project: "none", ...approver("An approver adds projects.", "/welcome") });
287
287
  get("tasks", "task.page", `^\\/t\\/${TASK}$`, { project: "task", limited: "resource", ...free, sample: "/t/one" });
288
288
  get("tasks", "task.evidence", `^\\/t\\/${TASK}\\/evidence$`, { project: "task", limited: "resource", ...free, sample: "/t/one/evidence" });
289
+ // One-tap release approval (trusted-release-reports.md A6/A7): the sheet, and the push's opaque locator, which only
290
+ // redirects to it once the plane's own state says the task ref is a release in the person's projects.
291
+ get("tasks", "task.release", `^\\/t\\/${TASK}\\/release$`, { project: "task", limited: "resource", ...free, sample: "/t/one/release" });
292
+ get("tasks", "release.locate", `^\\/approve\\/${ID}$`, { project: "task-ref", limited: "resource", ...free, sample: "/approve/1" });
289
293
  get("pages", "ledger.export", "/ledger/export", { limited: "unscoped", ...free });
290
294
  get("tasks", "runs", "/runs", { limited: "collection", ...free }); // a redirect to /agents now
291
295
  get("pages", "menu", "/menu", { limited: "collection", ...free });
@@ -556,7 +560,9 @@ post("tasks", "task.act.route", `^\\/t\\/${TASK}\\/route$`, { project: "task", l
556
560
  post("tasks", "task.act.retry-review", `^\\/t\\/${TASK}\\/retry-review$`, { project: "task", limited: "resource", sample: "/t/one/retry-review" });
557
561
  post("tasks", "task.act.complete", `^\\/t\\/${TASK}\\/complete$`, { project: "task", limited: "resource", sample: "/t/one/complete", ...approver("Only an approver can mark a result complete.") });
558
562
  post("tasks", "task.act.merge", `^\\/t\\/${TASK}\\/merge$`, { project: "task", limited: "resource", sample: "/t/one/merge", ...approver("Only an approver can merge.") });
559
- post("tasks", "task.act.confirm-stopped", `^\\/t\\/${TASK}\\/confirm-stopped$`, { scope: "step-up", project: "task", limited: "resource", sample: "/t/one/confirm-stopped", ...approver("Only an approver can confirm a build stopped.") });
563
+ // Check again: Toolroll looks at this computer's processes for a finished build once more (stop-proof.ts). Nobody
564
+ // vouches that a build stopped, so no password: it only asks for the look the worker makes on its own anyway.
565
+ post("tasks", "task.act.recheck-stopped", `^\\/t\\/${TASK}\\/recheck-stopped$`, { project: "task", limited: "resource", sample: "/t/one/recheck-stopped", ...approver("Only an approver can have Toolroll check a build again.") });
560
566
  post("tasks", "task.act.stop-carrying", `^\\/t\\/${TASK}\\/stop-carrying$`, { project: "task", limited: "resource", sample: "/t/one/stop-carrying", ...approver("Only an approver can stop carrying a release.") });
561
567
  post("tasks", "task.act.stop", `^\\/t\\/${TASK}\\/stop$`, { project: "task", limited: "resource", sample: "/t/one/stop" });
562
568
  // Putting one task away and bringing it back (task-tidy.md): a person's act, so only a browser session.
@@ -64,3 +64,18 @@ export declare function createAgentSignalHandlers(runtime: ServerRuntime): {
64
64
  "agents.seen": (ctx: HandlerContext) => Promise<void>;
65
65
  "agents.watch": (ctx: HandlerContext) => Promise<void>;
66
66
  };
67
+ /**
68
+ * One-tap release approval (docs/plans/trusted-release-reports.md, slice A): the sheet (`/t/<id>/release`), the push's
69
+ * opaque locator (`/approve/<task ref>`, a GET that only redirects to the sheet) and the sheet's approval, which the
70
+ * task approval POST hands here once its shared checks (cookie session, CSRF and the browser key at admission, the
71
+ * single-use nonce, a password or a standing proof) passed. The approval itself is the shared card wrapper around the
72
+ * unchanged approve() (release-approval.ts approveReleaseCard).
73
+ */
74
+ export declare function createReleaseSheetHandlers(runtime: ServerRuntime): {
75
+ routes: {
76
+ "task.release": (ctx: HandlerContext) => Promise<void>;
77
+ "release.locate": (ctx: HandlerContext) => Promise<void>;
78
+ };
79
+ sheetScreen: (ctx: Pick<HandlerContext, "who" | "response" | "project">, taskId: string, problem: string | null, status: number) => void;
80
+ approveFromSheet: (ctx: Pick<HandlerContext, "who" | "response" | "project">, taskId: string, cardDigest: string, token: string) => void;
81
+ };
@@ -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
  }
@@ -2458,7 +2463,11 @@ export function createTasksHandlers(runtime) {
2458
2463
  : requestedReturn === null
2459
2464
  ? taskHref(taskId)
2460
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";
2461
2468
  const approvalProblem = (message, status) => {
2469
+ if (sheet)
2470
+ return releaseSheet.sheetScreen({ who, response, project: null }, taskId, message, status);
2462
2471
  if (requestedReturn !== null && requestedReturn !== "next") {
2463
2472
  return redirect(response, chatReturnWithSaid(approvalBack, message));
2464
2473
  }
@@ -2484,6 +2493,9 @@ export function createTasksHandlers(runtime) {
2484
2493
  && !(who.authority === "local-owner" && localApprovalEligible(store, who.name, taskId).ok)) {
2485
2494
  return approvalProblem("approval requires your password, typed again", 400);
2486
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);
2487
2499
  const scopeRow = store.getScope(taskId);
2488
2500
  if (scopeRow === null)
2489
2501
  return approvalProblem("this task has no scope to approve", 409);
@@ -3078,6 +3090,7 @@ export function createTasksHandlers(runtime) {
3078
3090
  return live;
3079
3091
  }
3080
3092
  const tidyHandlers = createTidyHandlers(runtime);
3093
+ const releaseSheet = createReleaseSheetHandlers(runtime);
3081
3094
  /** The question page, with a refusal in its box when an answer was refused (the box keeps the choice to make). */
3082
3095
  function decisionScreen(ctx, id, back, refusal, code, now, draft) {
3083
3096
  const { who, response, project } = ctx;
@@ -3095,6 +3108,7 @@ export function createTasksHandlers(runtime) {
3095
3108
  }
3096
3109
  const registrations = handlersOf("tasks", {
3097
3110
  ...tidyHandlers,
3111
+ ...releaseSheet.routes,
3098
3112
  ...createAgentSignalHandlers(runtime),
3099
3113
  "code.page": showCode("workspace"),
3100
3114
  "code.session": showCode("session"),
@@ -3155,7 +3169,7 @@ export function createTasksHandlers(runtime) {
3155
3169
  "task.act.retry-review": taskAct("retry-review"),
3156
3170
  "task.act.complete": taskAct("complete"),
3157
3171
  "task.act.merge": taskMerge,
3158
- "task.act.confirm-stopped": taskConfirmStopped,
3172
+ "task.act.recheck-stopped": taskRecheckStopped,
3159
3173
  "task.act.stop": taskAct("stop"),
3160
3174
  "task.act.stop-carrying": taskAct("stop-carrying"),
3161
3175
  "task.act.pause": taskAct("pause"),
@@ -3314,3 +3328,71 @@ export function createAgentSignalHandlers(runtime) {
3314
3328
  }
3315
3329
  return { "agents.seen": agentSeen, "agents.watch": agentWatch };
3316
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
+ }
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
- content?: never;
19
18
  ok: false;
20
19
  message: string;
21
20
  plan?: never;
21
+ content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
- content?: never;
34
33
  ok: false;
35
34
  message: string;
36
35
  plan?: never;
36
+ content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;
@@ -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[]>;