toolroll 0.9.41 → 0.9.43

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 (89) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/chat-actions.js +1 -1
  3. package/dist/chat-approval.d.ts +45 -0
  4. package/dist/chat-approval.js +81 -0
  5. package/dist/chat-channel.js +5 -0
  6. package/dist/chat-decide.d.ts +18 -6
  7. package/dist/chat-decide.js +54 -20
  8. package/dist/chat-flow.js +2 -2
  9. package/dist/cli.js +4 -2
  10. package/dist/coding-types.d.ts +2 -24
  11. package/dist/coding-workspace.js +12 -11
  12. package/dist/contracts/cli.d.ts +736 -0
  13. package/dist/contracts/cli.js +223 -0
  14. package/dist/contracts/coding-activity.d.ts +64 -0
  15. package/dist/contracts/coding-activity.js +58 -0
  16. package/dist/contracts/console-api.d.ts +484 -0
  17. package/dist/contracts/console-api.js +274 -0
  18. package/dist/contracts/diff-stat.d.ts +58 -0
  19. package/dist/contracts/diff-stat.js +59 -0
  20. package/dist/contracts/integration-metadata.d.ts +99 -0
  21. package/dist/contracts/integration-metadata.js +101 -0
  22. package/dist/contracts/lead-tools.d.ts +11 -0
  23. package/dist/contracts/lead-tools.js +2 -0
  24. package/dist/contracts/model-catalog.d.ts +21 -0
  25. package/dist/contracts/model-catalog.js +14 -0
  26. package/dist/contracts/native-census.d.ts +42 -0
  27. package/dist/contracts/native-census.js +36 -0
  28. package/dist/contracts/observation-request.d.ts +37 -0
  29. package/dist/contracts/observation-request.js +25 -0
  30. package/dist/contracts/provider.d.ts +30 -0
  31. package/dist/contracts/provider.js +15 -0
  32. package/dist/contracts/recipes.d.ts +166 -0
  33. package/dist/contracts/recipes.js +52 -0
  34. package/dist/contracts/retention.d.ts +42 -0
  35. package/dist/contracts/retention.js +24 -0
  36. package/dist/contracts/storage.d.ts +23 -0
  37. package/dist/contracts/storage.js +10 -0
  38. package/dist/contracts/store-json.d.ts +315 -0
  39. package/dist/contracts/store-json.js +196 -0
  40. package/dist/contracts/trigger-payloads.d.ts +369 -0
  41. package/dist/contracts/trigger-payloads.js +144 -0
  42. package/dist/contracts/update-journal.d.ts +254 -0
  43. package/dist/contracts/update-journal.js +155 -0
  44. package/dist/contracts/verification-view.d.ts +93 -0
  45. package/dist/contracts/verification-view.js +43 -0
  46. package/dist/control-setup.d.ts +1 -1
  47. package/dist/desktop-update.d.ts +7 -40
  48. package/dist/desktop-update.js +36 -8
  49. package/dist/evidence.d.ts +3 -19
  50. package/dist/flow-engine.d.ts +2 -0
  51. package/dist/flow-engine.js +10 -2
  52. package/dist/flow-triggers.js +61 -60
  53. package/dist/flows-cli.d.ts +28 -0
  54. package/dist/flows-cli.js +43 -1
  55. package/dist/guides.js +2 -0
  56. package/dist/mate-contract.js +1 -1
  57. package/dist/mate-tools.js +43 -1
  58. package/dist/mcp-connect.js +37 -22
  59. package/dist/model-catalog.js +8 -6
  60. package/dist/observations.d.ts +2 -6
  61. package/dist/observations.js +13 -9
  62. package/dist/operate.js +55 -4
  63. package/dist/process-recovery-eligibility.d.ts +1 -1
  64. package/dist/process-recovery-native.d.ts +2 -13
  65. package/dist/process-recovery-native.js +8 -5
  66. package/dist/project-tools.d.ts +6 -24
  67. package/dist/project-tools.js +22 -18
  68. package/dist/provider.d.ts +3 -8
  69. package/dist/provider.js +15 -11
  70. package/dist/recipes.d.ts +3 -28
  71. package/dist/recipes.js +79 -52
  72. package/dist/retention.d.ts +4 -5
  73. package/dist/retention.js +5 -4
  74. package/dist/scope.d.ts +4 -2
  75. package/dist/serve.js +193 -30
  76. package/dist/storage.d.ts +2 -1
  77. package/dist/storage.js +12 -9
  78. package/dist/store.d.ts +11 -16
  79. package/dist/store.js +122 -160
  80. package/dist/surface.d.ts +6 -54
  81. package/dist/surface.js +10 -2
  82. package/dist/teammate-work.js +1 -1
  83. package/dist/telegram-flow.d.ts +41 -1
  84. package/dist/telegram-flow.js +96 -12
  85. package/dist/telegram.js +27 -3
  86. package/dist/toolroll-update.d.ts +8 -99
  87. package/dist/toolroll-update.js +28 -18
  88. package/dist/verification-evidence.js +27 -12
  89. package/package.json +1 -1
package/dist/serve.js CHANGED
@@ -56,6 +56,7 @@ import { addCardToFlow, advanceFlows, cancelFlowCard, crossProjectProblem, decid
56
56
  import { chooseFlowCard } from "./flow-send.js";
57
57
  import { FlowContractError, FLOW_TEMPLATES, validateFlowDefinition, withZoneNames } from "./flows.js";
58
58
  import { stageReferenceProblems } from "./contracts/stage-output.js";
59
+ import { CONSOLE_FORMS, checkResponse, readForm } from "./contracts/console-api.js";
59
60
  import { TOOL_CATALOG, addToolTo, catalogTool, discoverTools, localAppOf, projectToolsOf, removeToolFrom, secretsSetFor, setToolSecret, splitCommandLine, testToolOf } from "./project-tools.js";
60
61
  import { changeLearning, learningView } from "./project-learning.js";
61
62
  import { applySavedKnowledge, changeKnowledge, knowledgeView, knowledgeVersion, readKnowledgeSnapshot } from "./project-knowledge.js";
@@ -153,6 +154,7 @@ import { MODELS_CSS, modelsHtml, modelsScript } from "./models-ui.js";
153
154
  import { ASSISTANTS, modelChoices, detectPreparation, previewProjectInstructions, addProjectInstructions } from "./setup-guide.js";
154
155
  import { previewSetup, approveSetup } from "./control-setup.js";
155
156
  import { controlSetupHtml, setupPreviewHtml, connectionHtml, connectionWords, hiddenFields } from "./control-ui.js";
157
+ import { ALL_PROJECTS as CHAT_APPROVAL_ALL, chatApprovalWords, setChatApproval } from "./chat-approval.js";
156
158
  import { composerSchedule, scheduleEditorHtml, scheduleEditorScript } from "./task-composer.js";
157
159
  import { listCoordinators } from "./coordinator.js";
158
160
  import { diagnoseTaskDispatch, withDispatchDiagnoses } from "./dispatch.js";
@@ -1412,7 +1414,7 @@ export function createDecisionServer(options) {
1412
1414
  if (setupAttemptsLeft <= 0) {
1413
1415
  return page(response, 403, signupPage("too many wrong codes — restart the server to get a fresh code", 0));
1414
1416
  }
1415
- const body = await form(request);
1417
+ const body = readForm(await form(request), CONSOLE_FORMS.signup);
1416
1418
  const code = (body.get("code") ?? "").trim();
1417
1419
  const name = (body.get("name") ?? "").trim();
1418
1420
  const password = body.get("password") ?? "";
@@ -1451,7 +1453,7 @@ export function createDecisionServer(options) {
1451
1453
  return redirect(response, "/");
1452
1454
  }
1453
1455
  if (url.pathname === "/login" && method === "POST") {
1454
- const body = await form(request);
1456
+ const body = readForm(await form(request), CONSOLE_FORMS.login);
1455
1457
  const name = body.get("name");
1456
1458
  const token = body.get("token");
1457
1459
  // The destination a deep link asked for rides the form as a same-site
@@ -1528,7 +1530,7 @@ export function createDecisionServer(options) {
1528
1530
  }
1529
1531
  let body;
1530
1532
  try {
1531
- body = await form(request);
1533
+ body = readForm(await form(request), CONSOLE_FORMS.join);
1532
1534
  }
1533
1535
  catch {
1534
1536
  return respond(response, 413, "text/plain; charset=utf-8", "body too large");
@@ -1607,7 +1609,7 @@ export function createDecisionServer(options) {
1607
1609
  return freshIdentitySignIn.run({ actor: requestFacts.sso?.fresh === true ? who.name : null }, () => requestContext.run(requestFacts, async () => {
1608
1610
  const taskTextForm = url.pathname === "/tasks/add" || /^\/t\/[^/]+\/scope$/.test(url.pathname);
1609
1611
  const body = method === "POST" ? await form(request, url.pathname === "/settings/skills/import" ? 2 * 1024 * 1024 : url.pathname === "/flows/import" ? 1024 * 1024 : taskTextForm ? TASK_FORM_BODY_CAP : BODY_CAP) : null;
1610
- const target = actionTarget(url, who, request, body);
1612
+ const target = actionTarget(url, who, request, body === null ? null : readForm(body, CONSOLE_FORMS.ledgerTarget));
1611
1613
  const execute = async () => {
1612
1614
  if (!projectRequestAllowed(url, who, request, response))
1613
1615
  return;
@@ -1720,7 +1722,7 @@ export function createDecisionServer(options) {
1720
1722
  const ref = run === null ? null : store.refForId(run.taskRef);
1721
1723
  return { repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId, action: `${resource[1] === "d" ? "decision" : resource[1] === "i" ? "incident" : "run"} ${resource[3] ?? "view"}` };
1722
1724
  }
1723
- const known = new Set(["/recipes/prepare", "/recipes/preview", "/recipes/import", "/recipes/save", "/recipes/launch", "/tasks/add", "/queue/move", "/queue/note", "/routines/add", "/people/invite", "/people/invite-revoke", "/people/revoke", "/people/projects", "/projects/select", "/projects/open", "/mode/confirm", "/mode/sign", "/mode/revoke"]);
1725
+ const known = new Set(["/recipes/prepare", "/recipes/preview", "/recipes/import", "/recipes/save", "/recipes/launch", "/tasks/add", "/queue/move", "/queue/note", "/routines/add", "/people/invite", "/people/invite-revoke", "/people/revoke", "/people/projects", "/projects/select", "/projects/open", "/mode/confirm", "/mode/sign", "/mode/revoke", "/settings/chat-approval/save", "/settings/chat-approval/off"]);
1724
1726
  const placed = ["/tasks/add", "/routines/add"].includes(url.pathname) ? body?.get("repo")?.trim() : null;
1725
1727
  return { repo: url.pathname.startsWith("/people/") ? null : placed ? canonicalProject(placed) ?? placed : projectOf(who, request) ?? null, taskId: null, runId: null,
1726
1728
  action: known.has(url.pathname) ? url.pathname.slice(1).replaceAll("/", " ") : "console request" };
@@ -1748,6 +1750,9 @@ export function createDecisionServer(options) {
1748
1750
  if ((request.method === "GET" && path === "/settings/telegram") ||
1749
1751
  (request.method === "POST" && ["/settings/telegram/pair", "/settings/telegram/unpair", "/settings/telegram/retry"].includes(path)))
1750
1752
  return true;
1753
+ // A person's own chat-approval setting; the route proves each project it names.
1754
+ if (request.method === "POST" && ["/settings/chat-approval/confirm", "/settings/chat-approval/save", "/settings/chat-approval/off"].includes(path))
1755
+ return true;
1751
1756
  // Coding routes require an instance operator, then prove saved ownership and project access.
1752
1757
  if (path === "/code" || path.startsWith("/code/"))
1753
1758
  return true;
@@ -2633,7 +2638,7 @@ export function createDecisionServer(options) {
2633
2638
  latest: operator && latest !== null ? latest : null, csrf: operator ? who.session.csrf : null, today: now.toISOString().slice(0, 10) };
2634
2639
  const rows = store.actionLedger({ ...query, ...(before === null ? {} : { before: Number(before) }), limit: 51 });
2635
2640
  if (url.searchParams.get("format") === "json")
2636
- return respond(response, 200, "application/json; charset=utf-8", JSON.stringify({ chain: { ok: report.ok, entries: report.entries, through: report.through, head: report.head, problem: report.problem }, entries: rows.slice(0, 50), nextBefore: rows.length > 50 ? rows[49].id : null }));
2641
+ return respond(response, 200, "application/json; charset=utf-8", JSON.stringify(checkResponse("ledgerPage", { chain: { ok: report.ok, entries: report.entries, through: report.through, head: report.head, problem: report.problem }, entries: rows.slice(0, 50), nextBefore: rows.length > 50 ? rows[49].id : null })));
2637
2642
  return sendScreen(response, 200, screen("Action ledger", ledgerBody(rows, projects, url.searchParams, chainView), { chrome: chromeFor(chosen === "" ? null : chosen, "ledger", undefined, chosen === "" ? "all" : "project") }));
2638
2643
  }
2639
2644
  if (url.pathname === "/activity") {
@@ -3010,7 +3015,7 @@ export function createDecisionServer(options) {
3010
3015
  if (url.searchParams.get("format") === "json") {
3011
3016
  response.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-disposition": `attachment; filename="evidence-${family.root.id.replace(/[^A-Za-z0-9._-]/g, "_")}.json"`,
3012
3017
  "cache-control": "no-store", "x-content-type-options": "nosniff" });
3013
- return void response.end(JSON.stringify(pack, null, 2));
3018
+ return void response.end(JSON.stringify(checkResponse("evidencePack", pack), null, 2));
3014
3019
  }
3015
3020
  return sendScreen(response, 200, screen(`Evidence pack · ${family.root.id}`, evidencePackHtml(pack), { chrome: chromeFor(family.root.repo, "tasks") }));
3016
3021
  }
@@ -3358,7 +3363,7 @@ export function createDecisionServer(options) {
3358
3363
  const view = flowView(store, flow, { name: who.name, approver: who.via === "cookie" && who.role === "approver" }, Number.isSafeInteger(selected) && selected > 0 ? selected : null, { dir: options.configDir ?? null, repos: consoleProjects(), startTrigger: Number.isSafeInteger(start) && start > 0 ? start : null,
3359
3364
  sortReady: keyStatus("openrouter", providerHome).set, toolHome });
3360
3365
  if (url.searchParams.get("format") === "json")
3361
- return respond(response, 200, "application/json; charset=utf-8", JSON.stringify(view));
3366
+ return respond(response, 200, "application/json; charset=utf-8", JSON.stringify(checkResponse("flowView", view)));
3362
3367
  return sendScreen(response, 200, screen(flow.name, `<p><a href="/flows">Flows</a></p><h1>${escape(flow.name)}</h1>${flowFallbackHtml(view)}`, { chrome: chromeFor(flow.repo, "flows"), workspace: { view } }));
3363
3368
  }
3364
3369
  if (url.pathname === "/recipes" || url.pathname.startsWith("/recipes/")) {
@@ -6611,7 +6616,7 @@ export function createDecisionServer(options) {
6611
6616
  }
6612
6617
  return respond(response, 200, "application/json; charset=utf-8", JSON.stringify({ ok: true, beaten }));
6613
6618
  }
6614
- async function handlePost(url, who, request, response, body) {
6619
+ async function handlePost(url, who, request, response, posted) {
6615
6620
  // The attended beat answers BEFORE the shared mutation guard (v28): it
6616
6621
  // carries no parameters, so there is no csrf token to check — its OWN
6617
6622
  // guard is complete and STRICTER for the browsers this console
@@ -6623,12 +6628,13 @@ export function createDecisionServer(options) {
6623
6628
  if (url.pathname === "/session/attended-beats") {
6624
6629
  return attendedBeats(who, request, response, clock());
6625
6630
  }
6626
- const denied = authorizeMutation(request, who, body);
6631
+ const denied = authorizeMutation(request, who, readForm(posted, CONSOLE_FORMS.mutationGuard));
6627
6632
  if (denied !== null)
6628
6633
  return refuse(response, who, denied.status, denied.message);
6629
6634
  // The scripted demo lead's acts: ask, approve, change a plan, complete or send back a result.
6630
6635
  const demoAct = /^\/chat\/demo\/(?:ask|([0-9]{1,9})\/(approve|change|revise|complete))$/.exec(url.pathname);
6631
6636
  if (demoAct !== null) {
6637
+ const body = readForm(posted, CONSOLE_FORMS.demo);
6632
6638
  const lead = demoLeadHere();
6633
6639
  if (lead === null || who.via !== "cookie")
6634
6640
  return refuse(response, who, 404, "There's no page at this address.", "/chat");
@@ -6677,6 +6683,7 @@ export function createDecisionServer(options) {
6677
6683
  }
6678
6684
  // The update notice is dismissed per browser and per version: a newer release shows it again.
6679
6685
  if (url.pathname === "/settings/updates/dismiss") {
6686
+ const body = readForm(posted, CONSOLE_FORMS.updatesDismiss);
6680
6687
  const version = body.get("version") ?? "";
6681
6688
  if (!/^[0-9A-Za-z.-]{1,60}$/.test(version))
6682
6689
  return refuse(response, who, 400, "Choose a version to dismiss.", "/settings#updates");
@@ -6690,6 +6697,7 @@ export function createDecisionServer(options) {
6690
6697
  }
6691
6698
  // The daily check's switch: an installation setting, so an operator's. (Its own address: POST /settings/updates starts an update.)
6692
6699
  if (url.pathname === "/settings/updates/checks") {
6700
+ const body = readForm(posted, CONSOLE_FORMS.updatesChecks);
6693
6701
  if (options.configDir === undefined)
6694
6702
  return refuse(response, who, 404, "Updates are not set up on this console.", "/settings");
6695
6703
  if (!store.isInstanceOperator(who.name))
@@ -6699,6 +6707,7 @@ export function createDecisionServer(options) {
6699
6707
  return redirect(response, `/settings?said=${encodeURIComponent(on ? "Toolroll looks for a newer version once a day." : "Update checks are off.")}#updates`);
6700
6708
  }
6701
6709
  if (url.pathname === '/code' || url.pathname.startsWith('/code/')) {
6710
+ const body = readForm(posted, CONSOLE_FORMS.code);
6702
6711
  const wantsJson = request.headers.accept?.includes('application/json') === true;
6703
6712
  const fail = (status, message, delivery = 'rejected', sessionId) => wantsJson
6704
6713
  ? respond(response, status, 'application/json', JSON.stringify({ ok: false, error: message, delivery, ...(sessionId ? { sessionId } : {}) }))
@@ -6707,7 +6716,7 @@ export function createDecisionServer(options) {
6707
6716
  return fail(403, 'Coding sessions require installation-wide operator access. You can manage project tasks in Work.');
6708
6717
  if (!coding)
6709
6718
  return fail(409, codingProblem || 'Open Toolroll on the machine with your installed coding agent.');
6710
- if ([...new Set(body.keys())].some(key => body.getAll(key).length !== 1))
6719
+ if ([...new Set(body.keys())].some(key => body.sent.getAll(key).length !== 1))
6711
6720
  return fail(400, 'Submit one value for each field.');
6712
6721
  const actor = { name: who.name, generation: who.session.generation };
6713
6722
  const permitted = (repo) => visible(repo) && codingProjectAllowed(repo);
@@ -6779,7 +6788,7 @@ export function createDecisionServer(options) {
6779
6788
  if (ship && !wantsJson) {
6780
6789
  try {
6781
6790
  const preview = codingHandoffPreview(store, { sessionId: ship[1], actor: who.name });
6782
- return sendScreen(response, 409, screen('Review for shipping', codingShippingHtml(preview, who.session.csrf, body, message), { chrome: chromeFor(preview.repo, 'code') }));
6791
+ return sendScreen(response, 409, screen('Review for shipping', codingShippingHtml(preview, who.session.csrf, body.sent, message), { chrome: chromeFor(preview.repo, 'code') }));
6783
6792
  }
6784
6793
  catch { }
6785
6794
  }
@@ -6789,7 +6798,7 @@ export function createDecisionServer(options) {
6789
6798
  if (!projectRequestAllowed(url, who, request, response))
6790
6799
  return;
6791
6800
  if (restricted()) {
6792
- const repos = body.getAll("repo");
6801
+ const repos = readForm(posted, CONSOLE_FORMS.projectCeiling).getAll("repo");
6793
6802
  if (repos.length > 1 || repos.some(repo => repo.trim() !== "" && !visible(repo.trim())))
6794
6803
  return refuse(response, who, 403, "That project is outside your access.", "/projects");
6795
6804
  }
@@ -6809,6 +6818,7 @@ export function createDecisionServer(options) {
6809
6818
  // re-counts within five seconds either way, this just makes it exact.
6810
6819
  bustBadge();
6811
6820
  if (url.pathname === "/settings/skills/revise") {
6821
+ const body = readForm(posted, CONSOLE_FORMS.skillsRevise);
6812
6822
  if (who.via !== 'cookie' || who.role !== 'approver')
6813
6823
  return refuse(response, who, 403, 'Sign in to revise a test.', '/settings/skills');
6814
6824
  try {
@@ -6828,12 +6838,13 @@ export function createDecisionServer(options) {
6828
6838
  }
6829
6839
  }
6830
6840
  if (url.pathname === "/settings/skills/import" || url.pathname === "/settings/skills/change") {
6841
+ const body = readForm(posted, CONSOLE_FORMS.skillsChange);
6831
6842
  if (who.via !== 'cookie' || who.role !== 'approver')
6832
6843
  return refuse(response, who, 403, 'Sign in as an approver to manage skills.', '/settings/skills');
6833
6844
  const repo = body.get('repo') ?? '', back = `/settings/skills?repo=${encodeURIComponent(repo)}`;
6834
6845
  if (!visible(repo) || !consoleProjects().includes(repo))
6835
6846
  return refuse(response, who, 403, 'That project is outside your access.', '/projects');
6836
- if ([...new Set(body.keys())].some(k => body.getAll(k).length !== 1))
6847
+ if ([...new Set(body.keys())].some(k => body.sent.getAll(k).length !== 1))
6837
6848
  return refuse(response, who, 400, 'Invalid skills form.', back);
6838
6849
  try {
6839
6850
  const view = skillsView(store, repo, who.name);
@@ -6883,6 +6894,7 @@ export function createDecisionServer(options) {
6883
6894
  // setting a starter kit up, trying its sample card, and bringing GitHub issues in.
6884
6895
  const kitPost = /^\/kits\/([a-z-]{1,40})\/(setup|sample|github)$/.exec(url.pathname);
6885
6896
  if (kitPost !== null) {
6897
+ const body = readForm(posted, CONSOLE_FORMS.kit);
6886
6898
  const now = clock();
6887
6899
  if (who.via !== "cookie" || who.role !== "approver")
6888
6900
  return refuse(response, who, 403, "Sign in as an approver to set kits up.", "/kits");
@@ -6914,6 +6926,7 @@ export function createDecisionServer(options) {
6914
6926
  const teammatePost = url.pathname === "/teammates/new" ? ["", "0", "new"] : /^\/teammates\/([1-9][0-9]{0,9})\/(soul|state|note|settings|summary|tools|memory|routines|week)$/.exec(url.pathname);
6915
6927
  const questionPost = /^\/teammates\/questions\/([1-9][0-9]{0,9})\/answer$/.exec(url.pathname);
6916
6928
  if (teammatePost !== null || questionPost !== null) {
6929
+ const body = readForm(posted, CONSOLE_FORMS.teammates);
6917
6930
  const now = clock();
6918
6931
  const wantsJson = (request.headers.accept ?? "").includes("application/json");
6919
6932
  if (who.via !== "cookie")
@@ -6989,7 +7002,7 @@ export function createDecisionServer(options) {
6989
7002
  const grant = store.teammateGrant(mate.id, tool);
6990
7003
  const changed = op === "grant" ? await grantTool(store, mate, tool, who.name, now, { toolHome })
6991
7004
  : op === "revoke" ? revokeTool(store, mate, tool, who.name, now)
6992
- : grant === null ? { ok: false, said: `${nameOf(mate)} doesn't use ${tool}.` } : setToolRules(store, mate, tool, rulesFromForm(grant, key => body.get(key)), who.name, now);
7005
+ : grant === null ? { ok: false, said: `${nameOf(mate)} doesn't use ${tool}.` } : setToolRules(store, mate, tool, rulesFromForm(grant, key => body.sent.get(key)), who.name, now);
6993
7006
  return redirect(response, `${back}?${changed.ok ? "said" : "problem"}=${encodeURIComponent(changed.said)}#tools`);
6994
7007
  }
6995
7008
  const done = action === "state" ? setTeammateState(store, mate, body.get("state") === "removed" ? "removed" : body.get("state") === "paused" ? "paused" : "active", who.name, now)
@@ -7003,6 +7016,7 @@ export function createDecisionServer(options) {
7003
7016
  // Flows → New → a template: preview these answers, or create exactly what was previewed.
7004
7017
  const galleryPost = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
7005
7018
  if (galleryPost !== null) {
7019
+ const body = readForm(posted, CONSOLE_FORMS.galleryUse);
7006
7020
  const template = galleryTemplateOf(galleryPost[1]);
7007
7021
  const projects = consoleProjects();
7008
7022
  if (template === null)
@@ -7034,6 +7048,7 @@ export function createDecisionServer(options) {
7034
7048
  const flowPost = /^\/flows\/([1-9][0-9]{0,9})\/(save|cards|archive|triggers|linear-key|hooks-address|scripts|secrets)$/.exec(url.pathname) ?? /^\/flows\/([1-9][0-9]{0,9})\/cards\/([1-9][0-9]{0,9})\/(move|decide|choose|cancel|comment|assign|watch)$/.exec(url.pathname);
7035
7049
  const triggerPost = /^\/flows\/([1-9][0-9]{0,9})\/triggers\/([1-9][0-9]{0,9})\/(pause|resume|remove|check|press|renew|secret|share|unshare)$/.exec(url.pathname);
7036
7050
  if (url.pathname === "/flows/new" || url.pathname === "/flows/example" || url.pathname === "/flows/import" || flowPost !== null || triggerPost !== null) {
7051
+ const body = readForm(posted, CONSOLE_FORMS.flows);
7037
7052
  const now = clock();
7038
7053
  const answer = (status, payload) => respond(response, status, "application/json; charset=utf-8", JSON.stringify(payload));
7039
7054
  const projects = consoleProjects();
@@ -7276,7 +7291,7 @@ export function createDecisionServer(options) {
7276
7291
  const note = (body.get("note") ?? "").trim() || null;
7277
7292
  if (note !== null && note.length > LIMITS.note)
7278
7293
  return answer(400, { ok: false, said: `Keep the note to ${LIMITS.note.toLocaleString("en-US")} characters; this is ${note.length.toLocaleString("en-US")}.` });
7279
- const decided = decideFlowCard(store, { card: target.id, decision, note, actor: who.name, repos: projects, evidenceRoot, draft: body.get("draft") }, now);
7294
+ const decided = decideFlowCard(store, { card: target.id, decision, note, actor: who.name, repos: projects, evidenceRoot, draft: body.get("draft"), where: "the console" }, now);
7280
7295
  return decided.ok ? settle(decided.said) : answer(409, { ok: false, said: decided.message });
7281
7296
  }
7282
7297
  if (verb === "choose") {
@@ -7305,6 +7320,7 @@ export function createDecisionServer(options) {
7305
7320
  // v103: an instance operator records a checkpoint of the ledger chain (its head, to copy off the machine).
7306
7321
  // v105: set or remove a monthly budget. An instance operator, with a step-up; the ledger keeps before → after.
7307
7322
  if (url.pathname === "/spend/budget") {
7323
+ const body = readForm(posted, CONSOLE_FORMS.spendBudget);
7308
7324
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7309
7325
  return refuse(response, who, 403, "An instance operator sets budgets.", "/spend");
7310
7326
  const back = (key, words) => redirect(response, `/spend?${key}=${encodeURIComponent(words)}`);
@@ -7336,6 +7352,7 @@ export function createDecisionServer(options) {
7336
7352
  // Delete a project, in two steps: its name, then what goes and the password. An instance operator; the ledger keeps who and what.
7337
7353
  // Settings → Project → Builds at once: an approver for the project sets it; the ledger keeps before → after.
7338
7354
  if (url.pathname === "/settings/project/concurrency") {
7355
+ const body = readForm(posted, CONSOLE_FORMS.projectConcurrency);
7339
7356
  const repo = body.get("repo") ?? "";
7340
7357
  const known = consoleProjects();
7341
7358
  if (!known.includes(repo))
@@ -7355,6 +7372,7 @@ export function createDecisionServer(options) {
7355
7372
  return back("said", changed.before === changed.after ? "Nothing changed." : `Saved. Up to ${n} at once.`);
7356
7373
  }
7357
7374
  if (url.pathname === "/settings/project/delete") {
7375
+ const body = readForm(posted, CONSOLE_FORMS.projectDelete);
7358
7376
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7359
7377
  return refuse(response, who, 403, "An instance operator deletes projects.", "/settings/project");
7360
7378
  const repo = body.get("repo") ?? "";
@@ -7395,6 +7413,7 @@ export function createDecisionServer(options) {
7395
7413
  }
7396
7414
  // Sprint 8: an instance operator saves the organisation policy, with a step-up; the ledger keeps each rule's before → after.
7397
7415
  if (url.pathname === "/settings/policy") {
7416
+ const body = readForm(posted, CONSOLE_FORMS.policy);
7398
7417
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7399
7418
  return refuse(response, who, 403, "An instance operator sets the policy.", "/settings/policy");
7400
7419
  const back = (key, words) => redirect(response, `/settings/policy?${key}=${encodeURIComponent(words)}`);
@@ -7411,6 +7430,7 @@ export function createDecisionServer(options) {
7411
7430
  }
7412
7431
  // v102: an instance operator changes a project's approval rules, with a step-up; the ledger keeps before → after.
7413
7432
  if (url.pathname === "/settings/approval") {
7433
+ const body = readForm(posted, CONSOLE_FORMS.approval);
7414
7434
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7415
7435
  return refuse(response, who, 403, "An instance operator sets approval rules.", "/settings/approval");
7416
7436
  const repo = body.get("repo") ?? "";
@@ -7431,6 +7451,7 @@ export function createDecisionServer(options) {
7431
7451
  return back("said", `Saved. ${rulesSummary(next)}`);
7432
7452
  }
7433
7453
  if (url.pathname === "/settings/sessions") {
7454
+ const body = readForm(posted, CONSOLE_FORMS.sessions);
7434
7455
  if (who.via !== "cookie")
7435
7456
  return refuse(response, who, 403, "Sign in in a browser to manage sessions and tokens.", "/settings");
7436
7457
  const operator = store.isInstanceOperator(who.name);
@@ -7489,6 +7510,7 @@ export function createDecisionServer(options) {
7489
7510
  return back("problem", "Choose an action.");
7490
7511
  }
7491
7512
  if (url.pathname === "/settings/sign-in") {
7513
+ const body = readForm(posted, CONSOLE_FORMS.signIn);
7492
7514
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
7493
7515
  return refuse(response, who, 403, "An instance operator sets up sign-in.", "/settings");
7494
7516
  const back = (key, words) => redirect(response, `/settings/sign-in?${key}=${encodeURIComponent(words)}`);
@@ -7526,6 +7548,7 @@ export function createDecisionServer(options) {
7526
7548
  }
7527
7549
  // Settings → Updates: start the `toolroll update` job. A step-up; the job writes the ledger entries.
7528
7550
  if (url.pathname === "/settings/updates" || url.pathname === "/settings/updates/seen" || url.pathname === "/settings/updates/cancel") {
7551
+ const body = readForm(posted, CONSOLE_FORMS.updates);
7529
7552
  const databaseFile = store.databaseFile();
7530
7553
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
7531
7554
  return refuse(response, who, 403, "An instance operator updates Toolroll.", "/settings");
@@ -7572,6 +7595,7 @@ export function createDecisionServer(options) {
7572
7595
  }
7573
7596
  // v105: change how long each kind of data is kept. A step-up; the ledger keeps before → after for each kind.
7574
7597
  if (url.pathname === "/settings/retention") {
7598
+ const body = readForm(posted, CONSOLE_FORMS.retention);
7575
7599
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7576
7600
  return refuse(response, who, 403, "An instance operator sets retention.", "/settings");
7577
7601
  const back = (key, words) => redirect(response, `/settings/retention?${key}=${encodeURIComponent(words)}`);
@@ -7591,6 +7615,7 @@ export function createDecisionServer(options) {
7591
7615
  // Settings → Storage: when a finished task's clean checkout goes, cleaning up what the preview showed, and throwing
7592
7616
  // away a checkout kept for its changes. Each takes the password; the ledger keeps every change and removal.
7593
7617
  if (url.pathname === "/settings/storage" || url.pathname === "/settings/storage/clean" || url.pathname === "/settings/storage/discard") {
7618
+ const body = readForm(posted, CONSOLE_FORMS.storage);
7594
7619
  const databaseFile = store.databaseFile();
7595
7620
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
7596
7621
  return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
@@ -7624,6 +7649,7 @@ export function createDecisionServer(options) {
7624
7649
  // Pull requests for one project: turn on (the checks run again and must match what the page showed), merge
7625
7650
  // settings, or turn off. Each takes the password and lands in the ledger.
7626
7651
  if (url.pathname === "/settings/pull-requests") {
7652
+ const body = readForm(posted, CONSOLE_FORMS.pullRequests);
7627
7653
  const repo = body.get("repo") ?? "";
7628
7654
  const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
7629
7655
  if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
@@ -7661,6 +7687,7 @@ export function createDecisionServer(options) {
7661
7687
  // Checks for one project: the level, or the quick command (approved like `verify set --quick`). Each takes
7662
7688
  // the password and lands in the ledger.
7663
7689
  if (url.pathname === "/settings/checks") {
7690
+ const body = readForm(posted, CONSOLE_FORMS.checks);
7664
7691
  const repo = body.get("repo") ?? "";
7665
7692
  const known = [...new Set([...managedRepos(), ...store.knownRepos(), ...store.listProjects().map(one => one.path)])];
7666
7693
  if (who.via !== "cookie" || !known.includes(repo) || !visible(repo))
@@ -7722,6 +7749,7 @@ export function createDecisionServer(options) {
7722
7749
  }
7723
7750
  // Sprint 8: change the backup schedule. A step-up; the ledger keeps before → after.
7724
7751
  if (url.pathname === "/settings/backups") {
7752
+ const body = readForm(posted, CONSOLE_FORMS.backups);
7725
7753
  const databaseFile = store.databaseFile();
7726
7754
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
7727
7755
  return refuse(response, who, 403, "An instance operator looks after backups.", "/settings");
@@ -7748,6 +7776,7 @@ export function createDecisionServer(options) {
7748
7776
  }
7749
7777
  // v105: download everything as a .zip. An instance operator, with a step-up; the ledger records it.
7750
7778
  if (url.pathname === "/settings/data") {
7779
+ const body = readForm(posted, CONSOLE_FORMS.data);
7751
7780
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7752
7781
  return refuse(response, who, 403, "An instance operator exports data.", "/settings");
7753
7782
  if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok) {
@@ -7762,6 +7791,7 @@ export function createDecisionServer(options) {
7762
7791
  }
7763
7792
  // Settings → Flows → Switch on: a starter flow's zones and trigger, made together.
7764
7793
  if (url.pathname === "/settings/flows/on") {
7794
+ const body = readForm(posted, CONSOLE_FORMS.flowsOn);
7765
7795
  const repo = body.get("repo") ?? "";
7766
7796
  const back = (key, words) => redirect(response, `/settings/flows?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}`);
7767
7797
  const known = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])];
@@ -7777,6 +7807,7 @@ export function createDecisionServer(options) {
7777
7807
  }
7778
7808
  // Settings → Integrations → Send test: one harmless read-only check of one integration, now.
7779
7809
  if (url.pathname === "/settings/integrations/test") {
7810
+ const body = readForm(posted, CONSOLE_FORMS.integrationsTest);
7780
7811
  if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
7781
7812
  return refuse(response, who, 403, "An installation approver tests integrations.", "/settings");
7782
7813
  const key = body.get("key") ?? "";
@@ -7789,6 +7820,7 @@ export function createDecisionServer(options) {
7789
7820
  }
7790
7821
  // v104: change where monitoring goes. A step-up; the ledger keeps before → after (addresses, never secrets).
7791
7822
  if (url.pathname === "/settings/monitoring") {
7823
+ const body = readForm(posted, CONSOLE_FORMS.monitoring);
7792
7824
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || !options.configDir)
7793
7825
  return refuse(response, who, 403, "An instance operator sets up monitoring.", "/settings");
7794
7826
  const back = (key, words) => redirect(response, `/settings/monitoring?${key}=${encodeURIComponent(words)}`);
@@ -7812,6 +7844,7 @@ export function createDecisionServer(options) {
7812
7844
  return back("said", "Saved. Deliveries start within a few seconds.");
7813
7845
  }
7814
7846
  if (url.pathname === "/settings/tools/connect") {
7847
+ const body = readForm(posted, CONSOLE_FORMS.toolsConnect);
7815
7848
  if (who.via !== "cookie" || who.role !== "approver")
7816
7849
  return refuse(response, who, 403, "Sign in as an approver to connect tools.", "/settings/tools");
7817
7850
  const repo = body.get("repo") ?? "";
@@ -7887,6 +7920,7 @@ export function createDecisionServer(options) {
7887
7920
  return goOutside(response, started.go, `Going to ${service.label} to sign in…`, { state: started.state, path: CONNECT_CALLBACK, secure: origin.startsWith("https:") });
7888
7921
  }
7889
7922
  if (url.pathname === "/settings/tools/change") {
7923
+ const body = readForm(posted, CONSOLE_FORMS.toolsChange);
7890
7924
  if (who.via !== "cookie" || who.role !== "approver")
7891
7925
  return refuse(response, who, 403, "Sign in as an approver to manage tools.", "/settings/tools");
7892
7926
  const repo = body.get("repo") ?? "";
@@ -7974,6 +8008,7 @@ export function createDecisionServer(options) {
7974
8008
  }
7975
8009
  }
7976
8010
  if (url.pathname === "/settings/appearance") {
8011
+ const body = readForm(posted, CONSOLE_FORMS.appearance);
7977
8012
  // A per-browser preference, not a shared setting: it lives in this
7978
8013
  // person's own cookie and never in the database.
7979
8014
  const accentField = body.get("accent");
@@ -8002,6 +8037,7 @@ export function createDecisionServer(options) {
8002
8037
  return redirect(response, back);
8003
8038
  }
8004
8039
  if (url.pathname.startsWith("/settings/models/")) {
8040
+ const body = readForm(posted, CONSOLE_FORMS.models);
8005
8041
  if (who.via !== "cookie" || who.role !== "approver")
8006
8042
  return refuse(response, who, 403, "Sign in as an approver to change models.", "/settings/models");
8007
8043
  const back = (said, bad = false) => redirect(response, `/settings/models?${bad ? "problem" : "said"}=${encodeURIComponent(said)}`);
@@ -8056,6 +8092,7 @@ export function createDecisionServer(options) {
8056
8092
  return refuse(response, who, 404, "No such models action.", "/settings/models");
8057
8093
  }
8058
8094
  if (url.pathname === "/settings/knowledge/refresh") {
8095
+ const body = readForm(posted, CONSOLE_FORMS.knowledgeRefresh);
8059
8096
  if (who.via !== 'cookie' || who.role !== 'approver')
8060
8097
  return refuse(response, who, 403, 'Sign in as an approver to refresh project context.', '/settings/knowledge');
8061
8098
  const repo = body.get('repo') ?? '';
@@ -8065,6 +8102,7 @@ export function createDecisionServer(options) {
8065
8102
  return sendScreen(response, refreshed.index.status === 'ready' ? 200 : 503, screen('Project context', `<h1>Project context</h1><p>${refreshed.index.status === 'ready' ? 'Code index refreshed.' : 'The index is unavailable. Source search still works.'}</p><a class="button-link" href="/settings/knowledge?repo=${encodeURIComponent(repo)}">Open knowledge</a>`, { chrome: chromeFor(repo, 'settings') }));
8066
8103
  }
8067
8104
  if (url.pathname === "/settings/knowledge/proposal") {
8105
+ const body = readForm(posted, CONSOLE_FORMS.knowledgeProposal);
8068
8106
  if (who.via !== 'cookie' || who.role !== 'approver')
8069
8107
  return refuse(response, who, 403, 'Sign in as an approver to decide memory proposals.', '/settings/knowledge');
8070
8108
  const repo = body.get('repo') ?? '', decision = body.get('decision') ?? '';
@@ -8081,6 +8119,7 @@ export function createDecisionServer(options) {
8081
8119
  return redirect(response, `/settings/knowledge?repo=${encodeURIComponent(repo)}&saved=1`);
8082
8120
  }
8083
8121
  if (url.pathname === "/settings/knowledge/decision") {
8122
+ const body = readForm(posted, CONSOLE_FORMS.knowledgeDecision);
8084
8123
  if (who.via !== 'cookie' || who.role !== 'approver')
8085
8124
  return refuse(response, who, 403, 'Sign in as an approver to change project decisions.', '/settings/knowledge');
8086
8125
  const repo = body.get('repo') ?? '', action = body.get('action') ?? '';
@@ -8108,6 +8147,7 @@ export function createDecisionServer(options) {
8108
8147
  return redirect(response, `/settings/knowledge?repo=${encodeURIComponent(repo)}&saved=1`);
8109
8148
  }
8110
8149
  if (url.pathname === "/settings/knowledge/change") {
8150
+ const body = readForm(posted, CONSOLE_FORMS.knowledgeChange);
8111
8151
  if (who.via !== 'cookie' || who.role !== 'approver')
8112
8152
  return refuse(response, who, 403, 'Sign in as an approver to change project knowledge.', '/settings/knowledge');
8113
8153
  const repo = body.get('repo') ?? '', action = body.get('action') ?? '';
@@ -8133,6 +8173,7 @@ export function createDecisionServer(options) {
8133
8173
  return redirect(response, `/settings/knowledge?repo=${encodeURIComponent(repo)}&saved=1`);
8134
8174
  }
8135
8175
  if (url.pathname === "/settings/learning/change") {
8176
+ const body = readForm(posted, CONSOLE_FORMS.learningChange);
8136
8177
  if (who.via !== "cookie")
8137
8178
  return refuse(response, who, 403, "Sign in to change learning.", "/settings/learning");
8138
8179
  const repo = body.get("repo") ?? "", action = body.get("action") ?? "";
@@ -8149,6 +8190,7 @@ export function createDecisionServer(options) {
8149
8190
  return redirect(response, `/settings/learning?repo=${encodeURIComponent(repo)}`);
8150
8191
  }
8151
8192
  if (["/control/setup-preview", "/control/setup-approve", "/control/instructions-preview", "/control/instructions-approve"].includes(url.pathname)) {
8193
+ const body = readForm(posted, CONSOLE_FORMS.projectSetup);
8152
8194
  const project = projectOf(who, request);
8153
8195
  if (project == null || body.get("repo") !== project || !visible(project))
8154
8196
  return refuse(response, who, 409, "The selected project changed. Open project setup again.", "/control");
@@ -8188,6 +8230,7 @@ export function createDecisionServer(options) {
8188
8230
  return redirect(response, "/control");
8189
8231
  }
8190
8232
  if (["connect", "pair", "unpair", "disconnect", "alerts"].some(action => url.pathname === `/settings/slack/${action}`)) {
8233
+ const body = readForm(posted, CONSOLE_FORMS.slack);
8191
8234
  if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
8192
8235
  return refuse(response, who, 403, "An installation approver can connect Slack.", "/settings");
8193
8236
  const dir = options.configDir, state = new SlackState(store), action = url.pathname.split("/").at(-1);
@@ -8248,7 +8291,53 @@ export function createDecisionServer(options) {
8248
8291
  store.retryTelegramReplies(who.name, now);
8249
8292
  return redirect(response, "/settings/telegram");
8250
8293
  }
8294
+ // Approving from chat, as the person's lasting setting (chat-approval.ts): for all their projects or one. Turning it
8295
+ // on or changing its limits is the mode's ceremony (the exact terms, a single-use nonce bound to them, the password
8296
+ // typed again); turning it off is one step, csrf only. Every change is a ledger line.
8297
+ if (url.pathname === "/settings/chat-approval/confirm" || url.pathname === "/settings/chat-approval/save" || url.pathname === "/settings/chat-approval/off") {
8298
+ const body = readForm(posted, CONSOLE_FORMS.chatApproval);
8299
+ if (who.via !== "cookie" || who.role !== "approver")
8300
+ return refuse(response, who, 403, "An approver turns on approving from their own chat.", "/settings");
8301
+ if (["scope", "full-access", "cap-usd", "nonce", "digest", "token"].some(key => body.getAll(key).length > 1))
8302
+ return refuse(response, who, 400, "Submit one value for each field.", "/settings");
8303
+ const given = (body.get("scope") ?? "all").trim();
8304
+ const scope = given === "all" || given === "" ? CHAT_APPROVAL_ALL : canonicalProject(given) ?? given;
8305
+ if (scope !== CHAT_APPROVAL_ALL && !(visible(scope) && store.accountCanAccess(who.name, scope)))
8306
+ return refuse(response, who, 403, "That isn't one of your projects.", "/settings");
8307
+ const where = scope === CHAT_APPROVAL_ALL ? "all your projects" : projectName(scope);
8308
+ if (url.pathname.endsWith("/off")) {
8309
+ const saved = setChatApproval(store, { approver: who.name, scope, enabled: false, via: "the console" }, now);
8310
+ return saved.ok ? redirect(response, `/settings?said=${encodeURIComponent(saved.said)}`) : refuse(response, who, 400, saved.message, "/settings");
8311
+ }
8312
+ const usd = (body.get("cap-usd") ?? "").trim();
8313
+ const cap = usd === "" ? null : Number(usd);
8314
+ if (cap !== null && !(Number.isFinite(cap) && cap >= 0 && cap <= 10_000))
8315
+ return refuse(response, who, 400, "Give the attempt limit in dollars, like 5.", "/settings");
8316
+ const limits = { fullAccess: body.get("full-access") === "1", capMicrousd: cap === null ? null : Math.round(cap * 1_000_000) };
8317
+ const digest = createHash("sha256").update(JSON.stringify({ scope, ...limits })).digest("hex");
8318
+ if (url.pathname.endsWith("/confirm")) {
8319
+ const nonce = mintApprovalNonce(who.name, "chat-approval", digest);
8320
+ const html = `<h1 style="overflow-wrap:anywhere">Approve from chat in ${escape(where)}</h1>` +
8321
+ `<form method="post" action="/settings/chat-approval/save" class="card approve-form">` +
8322
+ hiddenFields({ csrf: who.session.csrf, nonce, digest, scope: scope === CHAT_APPROVAL_ALL ? "all" : scope, "full-access": limits.fullAccess ? "1" : "", "cap-usd": usd }) +
8323
+ `<p>Your own taps in your paired chat approve plans and merges until you turn this off. It doesn't expire.</p>` +
8324
+ `<p class="recap">Limits: ${escape(chatApprovalWords(limits))}.</p>` +
8325
+ `<p class="recap">Plans written for you, protected paths, two-person rules and your organisation's policy still open in Toolroll.</p>` +
8326
+ `<label>Your password<input type="password" name="token" autocomplete="current-password" required></label>` +
8327
+ `<div class="sticky-actions"><button type="submit">Turn on</button></div></form>` +
8328
+ `<p class="meta"><a href="/settings">Back</a></p>`;
8329
+ return sendScreen(response, 200, screen("Approve from chat", html, { chrome: chromeFor(projectOf(who, request) ?? null, "settings") }));
8330
+ }
8331
+ if (!consumeApprovalNonce(body.get("nonce") ?? "", who.name, "chat-approval", body.get("digest") ?? "") || body.get("digest") !== digest)
8332
+ return refuse(response, who, 409, "That form is stale. Read the terms again.", "/settings");
8333
+ const proved = authenticateAccount(store, who.name, body.get("token") ?? "");
8334
+ if (!proved.ok || proved.role !== "approver" || proved.generation !== who.session.generation)
8335
+ return refuse(response, who, 403, "Turning on approving from chat takes your password, typed again.", "/settings");
8336
+ const saved = setChatApproval(store, { approver: who.name, scope, enabled: true, limits, via: "the console" }, now);
8337
+ return saved.ok ? redirect(response, `/settings?said=${encodeURIComponent(saved.said)}`) : refuse(response, who, 400, saved.message, "/settings");
8338
+ }
8251
8339
  if (url.pathname === "/settings/telegram/pair" || url.pathname === "/settings/telegram/unpair") {
8340
+ const body = readForm(posted, CONSOLE_FORMS.telegramPair);
8252
8341
  // The person's own pairing, under their password: a code minted for
8253
8342
  // them alone, or their own chats revoked. Teammates' pairings are
8254
8343
  // never touched from here.
@@ -8274,6 +8363,7 @@ export function createDecisionServer(options) {
8274
8363
  return sendScreen(response, 200, screen("Pair Telegram", telegramSettingsHtml(store, botId, who.name, who.session.csrf, { code }), { chrome: chromeFor(projectOf(who, request) ?? null, "settings"), forceSensitive: true }));
8275
8364
  }
8276
8365
  if (["connect", "pair", "unpair", "disconnect", "alerts"].some(action => url.pathname === `/settings/teams/${action}`)) {
8366
+ const body = readForm(posted, CONSOLE_FORMS.teams);
8277
8367
  if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
8278
8368
  return refuse(response, who, 403, "An installation approver can connect Teams.", "/settings");
8279
8369
  const dir = options.configDir, state = new ChatState(store, "teams"), action = url.pathname.split("/").at(-1);
@@ -8328,6 +8418,7 @@ export function createDecisionServer(options) {
8328
8418
  return redirect(response, "/settings/teams");
8329
8419
  }
8330
8420
  if (["connect", "pair", "unpair", "disconnect", "alerts"].some(action => url.pathname === `/settings/discord/${action}`)) {
8421
+ const body = readForm(posted, CONSOLE_FORMS.discord);
8331
8422
  if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
8332
8423
  return refuse(response, who, 403, "An installation approver can connect Discord.", "/settings");
8333
8424
  const dir = options.configDir, state = new ChatState(store, "discord"), action = url.pathname.split("/").at(-1);
@@ -8382,6 +8473,7 @@ export function createDecisionServer(options) {
8382
8473
  return redirect(response, "/settings/discord");
8383
8474
  }
8384
8475
  if (url.pathname === "/settings/messaging" && options.configDir !== undefined && options.telegramTokenFile !== undefined) {
8476
+ const body = readForm(posted, CONSOLE_FORMS.messaging);
8385
8477
  const wanted = (body.get("primary") ?? "").trim();
8386
8478
  const facts = effectivePrimary(process.env, options.configDir, loadBotToken(process.env, options.telegramTokenFile) !== null);
8387
8479
  // Only a CONFIGURED service may page — choosing silence is not a
@@ -8393,6 +8485,7 @@ export function createDecisionServer(options) {
8393
8485
  return redirect(response, "/settings");
8394
8486
  }
8395
8487
  if (url.pathname === "/settings/permission-default" && options.telegramTokenFile !== undefined) {
8488
+ const body = readForm(posted, CONSOLE_FORMS.permissionDefault);
8396
8489
  const wanted = body.get("permission-mode");
8397
8490
  if (wanted !== "auto" && wanted !== "bypassPermissions") {
8398
8491
  return refuse(response, who, 400, "permissions must be Auto or Full access", "/settings");
@@ -8403,6 +8496,7 @@ export function createDecisionServer(options) {
8403
8496
  : "New tasks now start with Auto. Approved tasks keep their setting.")}`);
8404
8497
  }
8405
8498
  if (url.pathname === "/settings/quality-default" && options.telegramTokenFile !== undefined) {
8499
+ const body = readForm(posted, CONSOLE_FORMS.qualityDefault);
8406
8500
  const wanted = body.get("quality-mode");
8407
8501
  if (!isQualityMode(wanted)) {
8408
8502
  return refuse(response, who, 400, "quality must be Default or Strict / release", "/settings");
@@ -8413,6 +8507,7 @@ export function createDecisionServer(options) {
8413
8507
  : "New tasks now use Default quality. Approved tasks keep their setting.")}`);
8414
8508
  }
8415
8509
  if (url.pathname === "/settings/notifications") {
8510
+ const body = readForm(posted, CONSOLE_FORMS.notifications);
8416
8511
  // Each person's own choice: a closed list of modes and a 24-hour HH:MM, or off.
8417
8512
  const mode = body.get("mode"), time = (body.get("digest") ?? "").trim(), shots = body.get("screenshots");
8418
8513
  if (mode !== null && mode !== "quiet" && mode !== "all")
@@ -8432,6 +8527,7 @@ export function createDecisionServer(options) {
8432
8527
  return redirect(response, `/settings?said=${encodeURIComponent(said)}`);
8433
8528
  }
8434
8529
  if (url.pathname === "/settings/notifications/mute") {
8530
+ const body = readForm(posted, CONSOLE_FORMS.notificationsMute);
8435
8531
  // Each person's own pings: a muted project stays in Tasks and the evening digest.
8436
8532
  const repo = body.get("repo") ?? "";
8437
8533
  const project = notificationProjects(store, who.name).find(one => one.repo === repo);
@@ -8442,6 +8538,7 @@ export function createDecisionServer(options) {
8442
8538
  return redirect(response, `/settings?said=${encodeURIComponent(muted ? `${project.name} muted. It still shows in Tasks and your evening digest.` : `${project.name} pings you again.`)}#notifications`);
8443
8539
  }
8444
8540
  if (url.pathname === "/settings/telegram-digest" && options.telegramTokenFile !== undefined) {
8541
+ const body = readForm(posted, CONSOLE_FORMS.telegramDigest);
8445
8542
  // The cadence is a closed list of minutes — never a free number from
8446
8543
  // a form; "off" clears it. Any approver session may set it.
8447
8544
  const wanted = (body.get("every") ?? "").trim();
@@ -8453,6 +8550,7 @@ export function createDecisionServer(options) {
8453
8550
  return redirect(response, `/settings?said=${encodeURIComponent(minutes === null ? "Digest off: each update arrives as it happens." : `Digest every ${minutes >= 60 ? `${minutes / 60}h` : `${minutes}m`}. Anything that needs you still arrives at once.`)}`);
8454
8551
  }
8455
8552
  if (url.pathname === "/settings/provider-key" || url.pathname === "/settings/provider-key-clear") {
8553
+ const body = readForm(posted, CONSOLE_FORMS.providerKey);
8456
8554
  // The central gate already required an ACTIVE approver; the value is
8457
8555
  // write-only from here — status pages say set/not-set, never bytes.
8458
8556
  const provider = body.get("provider") ?? "";
@@ -8505,6 +8603,7 @@ export function createDecisionServer(options) {
8505
8603
  return redirect(response, `/settings?said=${encodeURIComponent((verdict.ok ? `${stored} and verified — it works` : `${stored}. ${verdictWords(provider, verdict)}`) + modeNote)}`);
8506
8604
  }
8507
8605
  if (url.pathname === "/settings/telegram-token" && options.telegramTokenFile !== undefined) {
8606
+ const body = readForm(posted, CONSOLE_FORMS.telegramToken);
8508
8607
  const value = body.get("token") ?? "";
8509
8608
  const saved = saveBotToken(options.telegramTokenFile, value);
8510
8609
  if (!saved.ok) {
@@ -8520,6 +8619,7 @@ export function createDecisionServer(options) {
8520
8619
  }
8521
8620
  // v87: the mail server Send email steps use, and a test email to its own address.
8522
8621
  if ((url.pathname === "/settings/email" || url.pathname === "/settings/email-test" || url.pathname === "/settings/email-read-test") && who.role === "approver" && options.configDir !== undefined) {
8622
+ const body = readForm(posted, CONSOLE_FORMS.email);
8523
8623
  const said = (words) => redirect(response, `/settings?said=${encodeURIComponent(words)}#email`);
8524
8624
  if (url.pathname === "/settings/email") {
8525
8625
  const saved = saveEmailSettings(options.configDir, { host: body.get("host"), port: body.get("port"), secure: body.get("secure"), user: body.get("user"), from: body.get("from"), password: body.get("password"), imapHost: body.get("imapHost"), imapPort: body.get("imapPort") });
@@ -8542,6 +8642,7 @@ export function createDecisionServer(options) {
8542
8642
  }
8543
8643
  // v89: a Google account for email: save the OAuth client and go to Google's consent screen, or disconnect.
8544
8644
  if ((url.pathname === "/settings/google" || url.pathname === "/settings/google/disconnect") && who.role === "approver" && options.configDir !== undefined) {
8645
+ const body = readForm(posted, CONSOLE_FORMS.google);
8545
8646
  const said = (words) => redirect(response, `/settings?said=${encodeURIComponent(words)}#email`);
8546
8647
  if (url.pathname === "/settings/google/disconnect") {
8547
8648
  await disconnectGoogle(options.configDir, options.googleFetch ?? fetch);
@@ -8561,6 +8662,7 @@ export function createDecisionServer(options) {
8561
8662
  return goOutside(response, consent.url, "Going to Google to sign in…", { state: consent.visit.state, path: GOOGLE_CALLBACK, secure: origin.startsWith("https:") });
8562
8663
  }
8563
8664
  if (url.pathname === "/projects/select") {
8665
+ const body = readForm(posted, CONSOLE_FORMS.projectsSelect);
8564
8666
  // Session-only project switching (E2): reads the registry, writes
8565
8667
  // ONLY session.project — the durable upsert lives in /projects/open,
8566
8668
  // which stays an approver's act. This is the one project verb a
@@ -8579,6 +8681,7 @@ export function createDecisionServer(options) {
8579
8681
  return redirect(response, safeReturn(body.get("return")));
8580
8682
  }
8581
8683
  if (url.pathname === "/projects/remove") {
8684
+ const body = readForm(posted, CONSOLE_FORMS.projectsRemove);
8582
8685
  // The reversible remove: off every list and the builder; tasks, results and settings stay. Deleting is
8583
8686
  // Settings → Project → Delete project. The same admission code as `repos remove` (project-admission.ts).
8584
8687
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
@@ -8598,6 +8701,7 @@ export function createDecisionServer(options) {
8598
8701
  return redirect(response, `/settings/project?said=${encodeURIComponent(`Removed ${projectName(repo)}. Its tasks and results stay saved.`)}`);
8599
8702
  }
8600
8703
  if (url.pathname === "/projects/open") {
8704
+ const body = readForm(posted, CONSOLE_FORMS.projectsOpen);
8601
8705
  // Sessions only: a bearer caller names its project per request and has
8602
8706
  // no session to mutate — refusing here keeps that boundary legible.
8603
8707
  if (who.via !== "cookie") {
@@ -8637,6 +8741,7 @@ export function createDecisionServer(options) {
8637
8741
  return redirect(response, safeReturn(body.get("return")));
8638
8742
  }
8639
8743
  if (url.pathname === "/tasks/add") {
8744
+ const body = readForm(posted, CONSOLE_FORMS.tasksAdd);
8640
8745
  const project = projectOf(who, request);
8641
8746
  if (project === undefined) {
8642
8747
  return refuse(response, who, 403, "that project is outside what this server was configured to show");
@@ -8734,7 +8839,7 @@ export function createDecisionServer(options) {
8734
8839
  });
8735
8840
  if (!made.ok) {
8736
8841
  const csrf = who.via === "cookie" ? who.session.csrf : "";
8737
- return sendScreen(response, made.reason === "backlog-full" ? 429 : 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, made.message, project, { title, goal, not: notThis, touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body }, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
8842
+ return sendScreen(response, made.reason === "backlog-full" ? 429 : 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, csrf, made.message, project, { title, goal, not: notThis, touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body.sent }, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
8738
8843
  }
8739
8844
  const actionContext = requestContext.getStore();
8740
8845
  if (actionContext !== undefined)
@@ -8748,6 +8853,7 @@ export function createDecisionServer(options) {
8748
8853
  return redirect(response, taskHref(made.id));
8749
8854
  }
8750
8855
  if (url.pathname === "/queue/move" || url.pathname === "/queue/note") {
8856
+ const body = readForm(posted, CONSOLE_FORMS.queue);
8751
8857
  const project = projectOf(who, request);
8752
8858
  if (project === undefined) {
8753
8859
  return refuse(response, who, 403, "that project is outside what this server was configured to show");
@@ -8862,6 +8968,7 @@ export function createDecisionServer(options) {
8862
8968
  // hash — the page that shows it renders no self-refreshing script, so
8863
8969
  // the token is never re-rendered.
8864
8970
  if (url.pathname === "/fleet/runner/register") {
8971
+ const body = readForm(posted, CONSOLE_FORMS.runnerRegister);
8865
8972
  if (who.via !== "cookie")
8866
8973
  return refuse(response, who, 403, "runner registration is a browser surface");
8867
8974
  const token = body.get("token") ?? "";
@@ -8891,6 +8998,7 @@ export function createDecisionServer(options) {
8891
8998
  return sendScreen(response, 200, tokenScreen);
8892
8999
  }
8893
9000
  if (url.pathname === "/mode/confirm" || url.pathname === "/mode/sign") {
9001
+ const body = readForm(posted, CONSOLE_FORMS.mode);
8894
9002
  if (who.via !== "cookie")
8895
9003
  return refuse(response, who, 403, "signing a mode is a browser ceremony");
8896
9004
  const project = projectOf(who, request);
@@ -8991,6 +9099,7 @@ export function createDecisionServer(options) {
8991
9099
  return redirect(response, ended ? "/mode?said=the%20mode%20is%20ended%20—%20every%20act%20falls%20back%20to%20its%20own%20ceremony" : "/mode");
8992
9100
  }
8993
9101
  if (url.pathname === "/people/projects") {
9102
+ const body = readForm(posted, CONSOLE_FORMS.peopleProjects);
8994
9103
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
8995
9104
  return refuse(response, who, 403, "An instance operator manages project access.", "/people");
8996
9105
  if (!authenticateApprover(store, who.name, body.get("token") ?? "", null).ok)
@@ -9005,6 +9114,7 @@ export function createDecisionServer(options) {
9005
9114
  return redirect(response, "/people?said=Project%20access%20saved");
9006
9115
  }
9007
9116
  if (url.pathname === "/people/invite") {
9117
+ const body = readForm(posted, CONSOLE_FORMS.peopleInvite);
9008
9118
  if (who.via !== "cookie")
9009
9119
  return refuse(response, who, 403, "inviting is a browser surface");
9010
9120
  const token = body.get("token") ?? "";
@@ -9035,6 +9145,7 @@ export function createDecisionServer(options) {
9035
9145
  return sendScreen(response, 200, linkScreen);
9036
9146
  }
9037
9147
  if (url.pathname === "/people/invite-revoke") {
9148
+ const body = readForm(posted, CONSOLE_FORMS.peopleInviteRevoke);
9038
9149
  if (who.via !== "cookie")
9039
9150
  return refuse(response, who, 403, "inviting is a browser surface");
9040
9151
  const token = body.get("token") ?? "";
@@ -9046,6 +9157,7 @@ export function createDecisionServer(options) {
9046
9157
  return redirect(response, `/people?said=${encodeURIComponent(revoked ? "the invite is cancelled — its link is dead" : "that invite was already gone")}`);
9047
9158
  }
9048
9159
  if (url.pathname === "/people/revoke") {
9160
+ const body = readForm(posted, CONSOLE_FORMS.peopleRevoke);
9049
9161
  if (who.via !== "cookie")
9050
9162
  return refuse(response, who, 403, "removing a person is a browser surface");
9051
9163
  const token = body.get("token") ?? "";
@@ -9065,6 +9177,7 @@ export function createDecisionServer(options) {
9065
9177
  return redirect(response, `/people?said=${encodeURIComponent(`${name} can no longer sign in — their sessions, invites, and signed modes ended with them; history stays`)}`);
9066
9178
  }
9067
9179
  if (url.pathname === "/fleet/runner/retire") {
9180
+ const body = readForm(posted, CONSOLE_FORMS.runnerRetire);
9068
9181
  if (who.via !== "cookie")
9069
9182
  return refuse(response, who, 403, "runner retirement is a browser surface");
9070
9183
  const token = body.get("token") ?? "";
@@ -9079,6 +9192,7 @@ export function createDecisionServer(options) {
9079
9192
  }
9080
9193
  const answer = /^\/d\/([0-9]{1,15})\/answer$/.exec(url.pathname);
9081
9194
  if (answer !== null) {
9195
+ const body = readForm(posted, CONSOLE_FORMS.decisionAnswer);
9082
9196
  const id = Number(answer[1]);
9083
9197
  const requestedReturn = body.get("return");
9084
9198
  const decisionBack = requestedReturn === "next"
@@ -9118,6 +9232,7 @@ export function createDecisionServer(options) {
9118
9232
  }
9119
9233
  const contestAct = /^\/contest\/([0-9]{1,15})\/(arm|pick|abandon)$/.exec(url.pathname);
9120
9234
  if (contestAct !== null) {
9235
+ const body = readForm(posted, CONSOLE_FORMS.contest);
9121
9236
  const contestId = Number(contestAct[1]);
9122
9237
  const verb = contestAct[2];
9123
9238
  const data = contestData(contestId);
@@ -9192,10 +9307,11 @@ export function createDecisionServer(options) {
9192
9307
  }
9193
9308
  const attendAct = matchTaskPath(url.pathname, "/(attend-preview|attend|attend-revoke)$");
9194
9309
  if (attendAct !== null) {
9195
- return attendMutation(response, who, attendAct.taskId, attendAct.verb, body, now);
9310
+ return attendMutation(response, who, attendAct.taskId, attendAct.verb, readForm(posted, CONSOLE_FORMS.attend), now);
9196
9311
  }
9197
9312
  const act = matchTaskPath(url.pathname, "/(hold|unhold|requeue|cancel|scope|approve|plan|plan-edit|block|unblock|repair-dependency|next|reopen|steer|follow-up|accept-proof|accept-revision|reject-revision|route|retry-review|complete|merge|confirm-stopped|stop|resume-arm|resume)$");
9198
9313
  if (act !== null && act.verb === "confirm-stopped") {
9314
+ const body = readForm(posted, CONSOLE_FORMS.confirmStopped);
9199
9315
  // Confirming a finished build stopped is an approver's act behind the password, typed again — the same
9200
9316
  // record as `toolroll run settle`, refused while anything of the run may still be running.
9201
9317
  const back = taskHref(act.taskId);
@@ -9227,6 +9343,7 @@ export function createDecisionServer(options) {
9227
9343
  return redirect(response, body.get("return") ? to : back);
9228
9344
  }
9229
9345
  if (act !== null && act.verb === "merge") {
9346
+ const body = readForm(posted, CONSOLE_FORMS.merge);
9230
9347
  // Merging is a person's act behind their password, typed again: a browser session, an approver, the exact
9231
9348
  // result's pull request. The flow re-reads GitHub before merging and records who merged in the ledger.
9232
9349
  if (who.via !== "cookie" || who.role !== "approver")
@@ -9248,9 +9365,10 @@ export function createDecisionServer(options) {
9248
9365
  return redirect(response, `${taskHref(act.taskId)}#merge`);
9249
9366
  }
9250
9367
  if (act !== null) {
9251
- return taskMutation(response, who, act.taskId, act.verb, body, now);
9368
+ return taskMutation(response, who, act.taskId, act.verb, posted, now);
9252
9369
  }
9253
9370
  if (url.pathname === "/projects/onboard-preview" || url.pathname === "/projects/onboard-confirm") {
9371
+ const body = readForm(posted, CONSOLE_FORMS.onboard);
9254
9372
  // Repo onboarding (findings 1-39): bearer and demo refuse BEFORE any
9255
9373
  // gh call; the console flow exists only on root-configured serves.
9256
9374
  if (who.via !== "cookie")
@@ -9375,6 +9493,7 @@ export function createDecisionServer(options) {
9375
9493
  : `${admitted} is ready and open`, 200);
9376
9494
  }
9377
9495
  if (url.pathname === "/push/subscribe") {
9496
+ const body = readForm(posted, CONSOLE_FORMS.pushSubscribe);
9378
9497
  // Enrolling a durable notification sink is a CEREMONY (arc 3 finding
9379
9498
  // 6): browser session + CSRF + the password typed again; bearer
9380
9499
  // identities are machines and are refused outright.
@@ -9414,6 +9533,7 @@ export function createDecisionServer(options) {
9414
9533
  return redirect(response, `/settings?said=${encodeURIComponent("this device now gets a buzz when the plane needs a person")}`);
9415
9534
  }
9416
9535
  if (url.pathname === "/push/remove") {
9536
+ const body = readForm(posted, CONSOLE_FORMS.pushRemove);
9417
9537
  if (who.via !== "cookie")
9418
9538
  return refuse(response, who, 403, "push removal is a browser session's act");
9419
9539
  const id = Number(body.get("id") ?? "");
@@ -9424,6 +9544,7 @@ export function createDecisionServer(options) {
9424
9544
  return redirect(response, `/settings?said=${encodeURIComponent("removed — that device stops receiving pushes")}`);
9425
9545
  }
9426
9546
  if (url.pathname === "/onboarding/phone/dismiss") {
9547
+ const body = readForm(posted, CONSOLE_FORMS.phoneDismiss);
9427
9548
  if (who.via !== "cookie" || who.role !== "approver")
9428
9549
  return refuse(response, who, 403, "An approver puts this away.", "/chat");
9429
9550
  store.recordInstallationFact(PHONE_CARD_FACT, who.name, now);
@@ -9433,6 +9554,7 @@ export function createDecisionServer(options) {
9433
9554
  // conversation it needs no second password; the full form (Advanced) still asks for one.
9434
9555
  // Settings → Lead: the name and persona this person's lead speaks with.
9435
9556
  if (url.pathname === "/settings/lead/identity") {
9557
+ const body = readForm(posted, CONSOLE_FORMS.leadIdentity);
9436
9558
  if (who.via !== "cookie" || who.role !== "approver")
9437
9559
  return refuse(response, who, 403, "An approver names their lead.", "/settings/lead");
9438
9560
  const checked = checkLeadIdentity(body.get("name") ?? "", body.get("persona") ?? "");
@@ -9443,6 +9565,7 @@ export function createDecisionServer(options) {
9443
9565
  }
9444
9566
  // Settings → Lead: what this person's lead knows about them, one line each.
9445
9567
  if (url.pathname === "/settings/lead/about") {
9568
+ const body = readForm(posted, CONSOLE_FORMS.leadAbout);
9446
9569
  if (who.via !== "cookie" || who.role !== "approver")
9447
9570
  return refuse(response, who, 403, "An approver edits what their lead knows about them.", "/settings/lead");
9448
9571
  const checked = checkAboutYou(body.get("about") ?? "");
@@ -9453,6 +9576,7 @@ export function createDecisionServer(options) {
9453
9576
  }
9454
9577
  // Settings → Lead: stop the lead following up on one of your promises.
9455
9578
  if (url.pathname === "/settings/lead/promise/cancel") {
9579
+ const body = readForm(posted, CONSOLE_FORMS.leadPromiseCancel);
9456
9580
  if (who.via !== "cookie" || who.role !== "approver")
9457
9581
  return refuse(response, who, 403, "An approver manages the lead's promises.", "/settings/lead");
9458
9582
  const id = Number(body.get("promise"));
@@ -9471,6 +9595,7 @@ export function createDecisionServer(options) {
9471
9595
  return redirect(response, "/chat");
9472
9596
  }
9473
9597
  if (url.pathname === "/chat/config") {
9598
+ const body = readForm(posted, CONSOLE_FORMS.chatConfig);
9474
9599
  // The console's own door into `config set chat` (operator request:
9475
9600
  // chat lives mainly in the web UI). Same ceremony weight as the CLI
9476
9601
  // verb: the password typed again authenticates the approver, the
@@ -9549,6 +9674,7 @@ export function createDecisionServer(options) {
9549
9674
  }
9550
9675
  // ---- the mate (mate arc §5) ------------------------------------------
9551
9676
  if (url.pathname === "/chat/mate/mint") {
9677
+ const body = readForm(posted, CONSOLE_FORMS.mateMint);
9552
9678
  if (who.via !== "cookie")
9553
9679
  return refuse(response, who, 403, "the mate is a browser surface");
9554
9680
  const back = safeChatReturn(body.get("return"));
@@ -9573,6 +9699,7 @@ export function createDecisionServer(options) {
9573
9699
  return redirect(response, back);
9574
9700
  }
9575
9701
  if (url.pathname === "/chat/mate/follow") {
9702
+ const body = readForm(posted, CONSOLE_FORMS.mateFollow);
9576
9703
  if (who.via !== "cookie")
9577
9704
  return refuse(response, who, 403, "Open the conversation to change automatic updates.");
9578
9705
  const principal = matePrincipal(who), session = store.activeMateSession(who.name), thread = store.liveMateThreadFor(who.name);
@@ -9581,6 +9708,7 @@ export function createDecisionServer(options) {
9581
9708
  return redirect(response, safeChatReturn(body.get("return")));
9582
9709
  }
9583
9710
  if (url.pathname === "/chat/mate/end") {
9711
+ const body = readForm(posted, CONSOLE_FORMS.mateEnd);
9584
9712
  if (who.via !== "cookie")
9585
9713
  return refuse(response, who, 403, "the mate is a browser surface");
9586
9714
  const back = safeChatReturn(body.get("return"));
@@ -9593,6 +9721,7 @@ export function createDecisionServer(options) {
9593
9721
  return redirect(response, back);
9594
9722
  }
9595
9723
  if (url.pathname === "/chat/mate/stop") {
9724
+ const body = readForm(posted, CONSOLE_FORMS.mateStop);
9596
9725
  if (who.via !== "cookie")
9597
9726
  return refuse(response, who, 403, "the mate is a browser surface");
9598
9727
  const back = safeChatReturn(body.get("return"));
@@ -9611,6 +9740,7 @@ export function createDecisionServer(options) {
9611
9740
  }
9612
9741
  const mateProposal = /^\/chat\/proposal\/([0-9]{1,15})\/(confirm|dismiss)$/.exec(url.pathname);
9613
9742
  if (mateProposal !== null) {
9743
+ const body = readForm(posted, CONSOLE_FORMS.mateProposal);
9614
9744
  if (who.via !== "cookie")
9615
9745
  return refuse(response, who, 403, "the mate is a browser surface");
9616
9746
  const back = safeChatReturn(body.get("return"));
@@ -9696,6 +9826,7 @@ export function createDecisionServer(options) {
9696
9826
  // ceiling admits the repo; the card lives on /chat and on the task.
9697
9827
  const coordinatorProposal = /^\/proposals\/([0-9]{1,15})\/(confirm|dismiss)$/.exec(url.pathname);
9698
9828
  if (coordinatorProposal !== null) {
9829
+ const body = readForm(posted, CONSOLE_FORMS.coordinatorProposal);
9699
9830
  if (who.via !== "cookie")
9700
9831
  return refuse(response, who, 403, "proposals are confirmed from the browser or the CLI");
9701
9832
  const principal = matePrincipal(who);
@@ -9715,6 +9846,7 @@ export function createDecisionServer(options) {
9715
9846
  return redirect(response, outcome.ok || outcome.reason !== "needs-confirm" ? back : `${back}${back.includes("?") ? "&" : "?"}said=${encodeURIComponent(outcome.said)}`);
9716
9847
  }
9717
9848
  if (url.pathname === "/chat") {
9849
+ const body = readForm(posted, CONSOLE_FORMS.chat);
9718
9850
  if (who.via !== "cookie")
9719
9851
  return refuse(response, who, 403, "chat is a browser surface");
9720
9852
  const requestedTask = body.get("task");
@@ -9860,6 +9992,7 @@ export function createDecisionServer(options) {
9860
9992
  }
9861
9993
  const chatFile = /^\/chat\/file\/([0-9a-f]{32})$/.exec(url.pathname);
9862
9994
  if (chatFile !== null) {
9995
+ const body = readForm(posted, CONSOLE_FORMS.chatFile);
9863
9996
  if (who.via !== "cookie")
9864
9997
  return refuse(response, who, 403, "chat is a browser surface");
9865
9998
  const key = chatFile[1];
@@ -9933,6 +10066,7 @@ export function createDecisionServer(options) {
9933
10066
  }
9934
10067
  const chatAckPost = /^\/chat\/ack\/([0-9]{1,15})$/.exec(url.pathname);
9935
10068
  if (chatAckPost !== null) {
10069
+ const body = readForm(posted, CONSOLE_FORMS.chatAck);
9936
10070
  if (who.via !== "cookie")
9937
10071
  return refuse(response, who, 403, "acknowledgement is a browser ceremony");
9938
10072
  const turn = store.getChatTurn(Number(chatAckPost[1]));
@@ -9952,6 +10086,7 @@ export function createDecisionServer(options) {
9952
10086
  return redirect(response, "/chat");
9953
10087
  }
9954
10088
  if (["/recipes/prepare", "/recipes/preview", "/recipes/import", "/recipes/launch", "/recipes/save"].includes(url.pathname)) {
10089
+ const body = readForm(posted, CONSOLE_FORMS.recipes);
9955
10090
  const project = projectOf(who, request);
9956
10091
  try {
9957
10092
  if (project === null || project === undefined || !visible(project))
@@ -9964,7 +10099,7 @@ export function createDecisionServer(options) {
9964
10099
  const rootMode = !unscopedMode && ceiling.roots.length > 0;
9965
10100
  if (rootMode && !(await authorizedProject(liveCeiling(), project)))
9966
10101
  throw new RecipeError("The project is outside this server's access.", 403);
9967
- const rechecked = authorizeMutation(request, who, body);
10102
+ const rechecked = authorizeMutation(request, who, readForm(posted, CONSOLE_FORMS.mutationGuard));
9968
10103
  if (rechecked !== null)
9969
10104
  throw new RecipeError(rechecked.message, rechecked.status);
9970
10105
  if (projectOf(who, request) !== project)
@@ -9979,13 +10114,13 @@ export function createDecisionServer(options) {
9979
10114
  if (recipe === null || recipe.repo === null)
9980
10115
  throw new RecipeError("This recipe is not available in this project.", 404);
9981
10116
  try {
9982
- const preview = prepareRecipeRun(store, who.name, project, id, Number(version), recipeAnswersFromForm(body), now);
10117
+ const preview = prepareRecipeRun(store, who.name, project, id, Number(version), recipeAnswersFromForm(body.sent), now);
9983
10118
  return redirect(response, `/recipes/preview?preview=${preview.token}`);
9984
10119
  }
9985
10120
  catch (error) {
9986
10121
  if (!(error instanceof RecipeError))
9987
10122
  throw error;
9988
- return sendScreen(response, error.status, screen("Use recipe", recipeRunHtml(recipe, project, csrf, revision, error.message, body), { chrome: chromeFor(project, "recipes"), functional: { script: recipeScript() } }));
10123
+ return sendScreen(response, error.status, screen("Use recipe", recipeRunHtml(recipe, project, csrf, revision, error.message, body.sent), { chrome: chromeFor(project, "recipes"), functional: { script: recipeScript() } }));
9989
10124
  }
9990
10125
  }
9991
10126
  if (url.pathname === "/recipes/import") {
@@ -10005,13 +10140,13 @@ export function createDecisionServer(options) {
10005
10140
  throw new RecipeError("That recipe version is not available in this project.", 404);
10006
10141
  let document;
10007
10142
  try {
10008
- document = recipeFromForm(body);
10143
+ document = recipeFromForm(body.sent);
10009
10144
  }
10010
10145
  catch (error) {
10011
10146
  if (!(error instanceof RecipeError))
10012
10147
  throw error;
10013
10148
  const fallback = original ?? { ...starterRecipes().find(one => one.id === "small-feature"), id: source };
10014
- return sendScreen(response, error.status, screen("Customize recipe", recipeEditorHtml(fallback, project, csrf, revision, error.message, body, purpose === "recipe"), { chrome: chromeFor(project, "recipes"), functional: { script: recipeScript() } }));
10149
+ return sendScreen(response, error.status, screen("Customize recipe", recipeEditorHtml(fallback, project, csrf, revision, error.message, body.sent, purpose === "recipe"), { chrome: chromeFor(project, "recipes"), functional: { script: recipeScript() } }));
10015
10150
  }
10016
10151
  const preview = createWorkflowPreview(store, who.name, project, document, source, now);
10017
10152
  return redirect(response, `/recipes/preview?preview=${preview.token}${purpose === "recipe" || document.version === 2 ? "&purpose=recipe" : ""}`);
@@ -10040,6 +10175,7 @@ export function createDecisionServer(options) {
10040
10175
  }
10041
10176
  }
10042
10177
  if (url.pathname === "/routines/add") {
10178
+ const body = readForm(posted, CONSOLE_FORMS.routinesAdd);
10043
10179
  // A standing order files into the OPEN project — never a typed path,
10044
10180
  // so the ceiling question never even arises — and lands on its own
10045
10181
  // screen where the approval step-up already lives: filing is cheap,
@@ -10075,11 +10211,11 @@ export function createDecisionServer(options) {
10075
10211
  // 7): the service validates, canonicalizes, digests, and stamps
10076
10212
  // provenance; the admission list makes the ceiling explicit even
10077
10213
  // though `project` was already proved inside it.
10078
- const scheduled = body.has("repeat") ? composerSchedule(body) : { ok: true, schedule: (body.get("schedule") ?? "").trim() };
10214
+ const scheduled = body.has("repeat") ? composerSchedule(body.sent) : { ok: true, schedule: (body.get("schedule") ?? "").trim() };
10079
10215
  if (!scheduled.ok || scheduled.schedule === null) {
10080
10216
  return sendScreen(response, 400, routinesPage(chromeFor(project, "routines"), store.routineTracks(project, now).filter(track => visible(track.routine.repo)), {
10081
10217
  csrf: who.via === "cookie" ? who.session.csrf : "", revision: who.via === "cookie" ? who.session.projectRevision : 0,
10082
- problem: scheduled.ok ? "Choose a recurring schedule." : scheduled.message, values: body,
10218
+ problem: scheduled.ok ? "Choose a recurring schedule." : scheduled.message, values: body.sent,
10083
10219
  }));
10084
10220
  }
10085
10221
  const created = fileRoutineProposal(store, {
@@ -10102,17 +10238,18 @@ export function createDecisionServer(options) {
10102
10238
  return sendScreen(response, created.reason === "duplicate" ? 409 : 400, routinesPage(chromeFor(project, "routines"), tracks, {
10103
10239
  csrf: who.via === "cookie" ? who.session.csrf : "",
10104
10240
  revision: who.via === "cookie" ? who.session.projectRevision : 0,
10105
- problem: created.message, values: body,
10241
+ problem: created.message, values: body.sent,
10106
10242
  }));
10107
10243
  }
10108
10244
  return redirect(response, `/routines/${created.id}`);
10109
10245
  }
10110
10246
  const routineAct = /^\/routines\/([0-9]{1,15})\/(approve|refresh|pause|resume|run-now)$/.exec(url.pathname);
10111
10247
  if (routineAct !== null) {
10112
- return routineMutation(response, who, Number(routineAct[1]), routineAct[2], body, now);
10248
+ return routineMutation(response, who, Number(routineAct[1]), routineAct[2], readForm(posted, CONSOLE_FORMS.routine), now);
10113
10249
  }
10114
10250
  const runNote = /^\/r\/([0-9]{1,15})\/note$/.exec(url.pathname);
10115
10251
  if (runNote !== null) {
10252
+ const body = readForm(posted, CONSOLE_FORMS.runNote);
10116
10253
  // An operator's verdict beside the machine's record (M6): immutable,
10117
10254
  // bounded by the same validator as decision notes, ceiling-checked
10118
10255
  // like every run resource. Ordinary authenticated mutation — no
@@ -10130,6 +10267,7 @@ export function createDecisionServer(options) {
10130
10267
  }
10131
10268
  const diffComment = /^\/r\/([0-9]{1,15})\/comment$/.exec(url.pathname);
10132
10269
  if (diffComment !== null) {
10270
+ const body = readForm(posted, CONSOLE_FORMS.diffComment);
10133
10271
  // A review comment on the IMMUTABLE terminal diff (M6.8): bound to
10134
10272
  // the exact artifact and its hash. Ordinary authenticated mutation —
10135
10273
  // the nonce belongs to the approval screen that later restates the
@@ -10216,6 +10354,7 @@ export function createDecisionServer(options) {
10216
10354
  return redirect(response, `${back}${back.includes("?") ? "&" : "?"}noted=${receipt}#request-changes`);
10217
10355
  }
10218
10356
  if (url.pathname === "/session/editor-links") {
10357
+ const body = readForm(posted, CONSOLE_FORMS.editorLinks);
10219
10358
  // The session half of the editor-link activation (arc 6, finding 1):
10220
10359
  // only the person at the browser can say "this device holds the
10221
10360
  // worktrees". Per-session, dies with the session, grants nothing —
@@ -10230,6 +10369,7 @@ export function createDecisionServer(options) {
10230
10369
  }
10231
10370
  const turnAct = /^\/r\/([0-9]{1,15})\/turn$/.exec(url.pathname);
10232
10371
  if (turnAct !== null) {
10372
+ const body = readForm(posted, CONSOLE_FORMS.turn);
10233
10373
  // The operator's turn (Phase 2E, v2 S1g): cookie-only — a watching
10234
10374
  // person, never a bearer machine — and every hard gate (custody,
10235
10375
  // lease, cap, budget, open decision, single flight) re-proves
@@ -10266,6 +10406,7 @@ export function createDecisionServer(options) {
10266
10406
  }
10267
10407
  const revise = /^\/r\/([0-9]{1,15})\/revise$/.exec(url.pathname);
10268
10408
  if (revise !== null) {
10409
+ const body = readForm(posted, CONSOLE_FORMS.revise);
10269
10410
  // Seal the live comment batch into ONE unapproved revision task with
10270
10411
  // an immutable brief (M6.8). Deterministic — no model reads anything
10271
10412
  // here — and every revision takes its own approval: comments can
@@ -10288,6 +10429,7 @@ export function createDecisionServer(options) {
10288
10429
  // approved command once and seals the log), or file a task to add tests.
10289
10430
  const followUp = /^\/r\/([0-9]{1,15})\/(checks|add-tests)$/.exec(url.pathname);
10290
10431
  if (followUp !== null) {
10432
+ const body = readForm(posted, CONSOLE_FORMS.followUp);
10291
10433
  const id = Number(followUp[1]);
10292
10434
  const found = store.getRun(id);
10293
10435
  if (found === null || !visible(taskRepoOf(found.taskRef)))
@@ -10385,6 +10527,7 @@ export function createDecisionServer(options) {
10385
10527
  }
10386
10528
  const resolve = /^\/i\/([0-9]{1,15})\/resolve$/.exec(url.pathname);
10387
10529
  if (resolve !== null) {
10530
+ const body = readForm(posted, CONSOLE_FORMS.resolveIncident);
10388
10531
  const id = Number(resolve[1]);
10389
10532
  // The ceiling applies to incident mutation exactly as to every other
10390
10533
  // resource (v3 review, finding 3): resolve incident → run → task, and
@@ -10668,7 +10811,7 @@ export function createDecisionServer(options) {
10668
10811
  store.beatAuthorization(minted.authorization.id, now);
10669
10812
  return redirect(response, taskHref(taskId));
10670
10813
  }
10671
- function taskMutation(response, who, taskId, verb, body, now) {
10814
+ function taskMutation(response, who, taskId, verb, posted, now) {
10672
10815
  const ref = store.lookupRef(taskId);
10673
10816
  if (ref === null || store.getTask(taskId) === null) {
10674
10817
  return refuse(response, who, 404, "no such task", "/tasks");
@@ -10678,6 +10821,7 @@ export function createDecisionServer(options) {
10678
10821
  }
10679
10822
  switch (verb) {
10680
10823
  case "steer": {
10824
+ const body = readForm(posted, CONSOLE_FORMS.taskSteer);
10681
10825
  // Steering is a browser session's act, explicitly (arc 1 v2 §3):
10682
10826
  // identify() accepts bearer credentials generically, and those are
10683
10827
  // for machines — a person watching steers, cookie + CSRF only.
@@ -10699,6 +10843,7 @@ export function createDecisionServer(options) {
10699
10843
  return redirect(response, taskHref(taskId));
10700
10844
  }
10701
10845
  case "hold": {
10846
+ const body = readForm(posted, CONSOLE_FORMS.taskHold);
10702
10847
  const reason = (body.get("reason") ?? "").trim() || "held from the console";
10703
10848
  if (reason.length > 200 || hasForbiddenControls(reason)) {
10704
10849
  return taskScreen(response, who, taskId, "that reason will not render, so it will not store", 400);
@@ -10710,6 +10855,7 @@ export function createDecisionServer(options) {
10710
10855
  return redirect(response, taskHref(taskId));
10711
10856
  }
10712
10857
  case "block": {
10858
+ const body = readForm(posted, CONSOLE_FORMS.taskBlock);
10713
10859
  // Chains are scheduling, not authority (chains-and-next review,
10714
10860
  // finding 2): the edge decides WHEN the ready set admits the task;
10715
10861
  // approval still decides WHAT may build. Both ends are re-proved
@@ -10731,6 +10877,7 @@ export function createDecisionServer(options) {
10731
10877
  return redirect(response, taskHref(taskId));
10732
10878
  }
10733
10879
  case "unblock": {
10880
+ const body = readForm(posted, CONSOLE_FORMS.taskBlock);
10734
10881
  const on = (body.get("on") ?? "").trim();
10735
10882
  if (store.openContestFor(ref.id) !== null) {
10736
10883
  return taskScreen(response, who, taskId, "a tournament is running on this task — let it finish, then pick or abandon it", 409);
@@ -10742,6 +10889,7 @@ export function createDecisionServer(options) {
10742
10889
  return redirect(response, taskHref(taskId));
10743
10890
  }
10744
10891
  case "repair-dependency": {
10892
+ const body = readForm(posted, CONSOLE_FORMS.taskRepairDependency);
10745
10893
  const blocker = (body.get("blocker") ?? "").trim();
10746
10894
  const operation = (body.get("operation") ?? "").trim();
10747
10895
  const blockerTask = blocker === "" || !store.blockers(taskId).includes(blocker) ? null : store.getTask(blocker);
@@ -10785,6 +10933,7 @@ export function createDecisionServer(options) {
10785
10933
  return taskScreen(response, who, taskId, "choose whether to try that task again, wait for a different task, or continue without it", 400);
10786
10934
  }
10787
10935
  case "next": {
10936
+ const body = readForm(posted, CONSOLE_FORMS.taskNext);
10788
10937
  if (body.get("undo") !== null) {
10789
10938
  const cleared = store.clearTaskPriority(taskId);
10790
10939
  if (!cleared.ok)
@@ -10805,6 +10954,7 @@ export function createDecisionServer(options) {
10805
10954
  return redirect(response, taskHref(taskId));
10806
10955
  }
10807
10956
  case "reopen": {
10957
+ const body = readForm(posted, CONSOLE_FORMS.taskReopen);
10808
10958
  // Authenticated like every approving act: the session alone may
10809
10959
  // read; resuming external work takes the password, typed again.
10810
10960
  const token = (body.get("token") ?? "").trim();
@@ -10834,6 +10984,7 @@ export function createDecisionServer(options) {
10834
10984
  return redirect(response, taskHref(taskId));
10835
10985
  }
10836
10986
  case "requeue": {
10987
+ const body = readForm(posted, CONSOLE_FORMS.taskRequeue);
10837
10988
  // Retry may carry a note for the next attempt: the same steering note, checked before anything changes.
10838
10989
  const note = (body.get("note") ?? "").trim();
10839
10990
  if (note !== "" && who.via !== "cookie")
@@ -10853,6 +11004,7 @@ export function createDecisionServer(options) {
10853
11004
  return redirect(response, body.get("return") === "inbox" ? "/inbox" : body.get("return") === "next" ? "/next" : taskHref(taskId));
10854
11005
  }
10855
11006
  case "follow-up": {
11007
+ const body = readForm(posted, CONSOLE_FORMS.taskFollowUp);
10856
11008
  // A scout's proposed follow-up, filed by the operator's tap (mate
10857
11009
  // arc §10): the ONE filing door, this task's repository, the scope
10858
11010
  // text stamped as the scout's — mode coverage never seals it.
@@ -10910,6 +11062,7 @@ export function createDecisionServer(options) {
10910
11062
  return redirect(response, taskHref(taskId));
10911
11063
  }
10912
11064
  case "plan-edit": {
11065
+ const body = readForm(posted, CONSOLE_FORMS.taskPlanEdit);
10913
11066
  if (who.via !== "cookie") {
10914
11067
  return refuse(response, who, 403, "editing a plan is a browser session's act");
10915
11068
  }
@@ -10967,6 +11120,7 @@ export function createDecisionServer(options) {
10967
11120
  }
10968
11121
  case "accept-revision":
10969
11122
  case "reject-revision": {
11123
+ const body = readForm(posted, CONSOLE_FORMS.taskRevision);
10970
11124
  // Adaptive execution plans (v44): resolving a revision the builder
10971
11125
  // filed while its authority snapshot no longer matched the run it
10972
11126
  // started under — the ONLY road a 'blocked' plan_revision reaches,
@@ -10997,6 +11151,7 @@ export function createDecisionServer(options) {
10997
11151
  return redirect(response, taskHref(taskId));
10998
11152
  }
10999
11153
  case "cancel": {
11154
+ const body = readForm(posted, CONSOLE_FORMS.taskCancel);
11000
11155
  const reason = body.get("reason") ?? undefined;
11001
11156
  const cancelled = withActor({ account: who.name, lead: false }, () => store.cancelTask(taskId, now, reason));
11002
11157
  if (!cancelled.ok) {
@@ -11010,6 +11165,7 @@ export function createDecisionServer(options) {
11010
11165
  return redirect(response, taskHref(taskId));
11011
11166
  }
11012
11167
  case "route": {
11168
+ const body = readForm(posted, CONSOLE_FORMS.taskRoute);
11013
11169
  // THE AGENTS EDIT (v47): an approver's session declares the risk,
11014
11170
  // overrides one role to an exact agent, or clears an override — ONE
11015
11171
  // authenticated transaction in the store that CAS-checks the scope
@@ -11095,6 +11251,7 @@ export function createDecisionServer(options) {
11095
11251
  return redirect(response, toChat ? target : `${target}#agents`);
11096
11252
  }
11097
11253
  case "scope": {
11254
+ const body = readForm(posted, CONSOLE_FORMS.taskScope);
11098
11255
  const sawDigest = body.get("sawDigest");
11099
11256
  const permissionGiven = body.get("permission-mode");
11100
11257
  if (permissionGiven !== null && permissionGiven !== "" && permissionGiven !== "auto" && permissionGiven !== "bypassPermissions") {
@@ -11290,7 +11447,7 @@ export function createDecisionServer(options) {
11290
11447
  if (!saved.ok) {
11291
11448
  // Refused because the plan moved on: the draft comes back with the version now on file, so saving it
11292
11449
  // again (having read the reason) can succeed instead of being refused the same way.
11293
- const draft = new URLSearchParams(body);
11450
+ const draft = new URLSearchParams(body.sent);
11294
11451
  if (saved.status === 409)
11295
11452
  draft.set("sawDigest", store.getScope(taskId)?.digest ?? "");
11296
11453
  return taskScreen(response, who, taskId, saved.message, saved.status, draft);
@@ -11298,6 +11455,7 @@ export function createDecisionServer(options) {
11298
11455
  return redirect(response, taskHref(taskId));
11299
11456
  }
11300
11457
  case "approve": {
11458
+ const body = readForm(posted, CONSOLE_FORMS.taskApprove);
11301
11459
  const requestedReturn = body.get("return");
11302
11460
  const approvalBack = requestedReturn === "next"
11303
11461
  ? "/next"
@@ -11384,6 +11542,7 @@ export function createDecisionServer(options) {
11384
11542
  return redirect(response, approvalBack);
11385
11543
  }
11386
11544
  case "accept-proof": {
11545
+ const body = readForm(posted, CONSOLE_FORMS.taskAcceptProof);
11387
11546
  // Accepting is a person's act, like approving a scope (Priority
11388
11547
  // 2): a cookie session only, never a bearer credential.
11389
11548
  if (who.via !== "cookie") {
@@ -11420,6 +11579,7 @@ export function createDecisionServer(options) {
11420
11579
  return redirect(response, body.get("return") === null ? taskHref(taskId) : safeReturn(body.get("return")));
11421
11580
  }
11422
11581
  case "complete": {
11582
+ const body = readForm(posted, CONSOLE_FORMS.taskComplete);
11423
11583
  if (who.via !== "cookie" || who.role !== "approver")
11424
11584
  return refuse(response, who, 403, "Only an approver can mark a result complete.");
11425
11585
  const principal = matePrincipal(who);
@@ -11465,6 +11625,7 @@ export function createDecisionServer(options) {
11465
11625
  return taskScreen(response, who, taskId, "Separate agent reviews have retired. Open the saved result to mark it complete or request a revision.", 410);
11466
11626
  }
11467
11627
  case "stop": {
11628
+ const body = readForm(posted, CONSOLE_FORMS.taskStop);
11468
11629
  // The exact-run stop (v52): a browser session's act, an approver's
11469
11630
  // act, naming ONE run. The store's transaction proves the run is
11470
11631
  // this task's current live attempt and records who asked before
@@ -11496,9 +11657,11 @@ export function createDecisionServer(options) {
11496
11657
  return redirect(response, back === "chat" ? `${taskChatHref(taskId)}#task-control` : `${taskHref(taskId)}#task-control`);
11497
11658
  }
11498
11659
  case "resume-arm": {
11660
+ const body = readForm(posted, CONSOLE_FORMS.taskResume);
11499
11661
  return armTaskResume(response, who, taskId, (body.get("run") ?? "").trim(), body.get("return") === "chat" ? "chat" : "task", now);
11500
11662
  }
11501
11663
  case "resume": {
11664
+ const body = readForm(posted, CONSOLE_FORMS.taskResume);
11502
11665
  // The resume itself (v52): password typed again + the durable nonce
11503
11666
  // consumed INSIDE the store's resume transaction against the digest
11504
11667
  // re-derived from live state. A replay finds the nonce spent; a