toolroll 0.9.71 → 0.9.72

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 (93) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/dist/agent-fence.d.ts +5 -2
  3. package/dist/agent-fence.js +5 -2
  4. package/dist/approval-authority.d.ts +20 -0
  5. package/dist/approval-authority.js +67 -0
  6. package/dist/assignment.js +26 -4
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/builder.js +48 -6
  9. package/dist/chat-decide.js +17 -2
  10. package/dist/check-access.js +2 -2
  11. package/dist/check-report.d.ts +109 -0
  12. package/dist/check-report.js +204 -0
  13. package/dist/claim.js +1 -1
  14. package/dist/commit-extract.d.ts +94 -0
  15. package/dist/commit-extract.js +436 -0
  16. package/dist/contracts/console-api.d.ts +1 -1
  17. package/dist/contracts/console-api.js +1 -0
  18. package/dist/contracts/flow.d.ts +16 -0
  19. package/dist/contracts/flow.js +3 -0
  20. package/dist/contracts/release-check.d.ts +335 -0
  21. package/dist/contracts/release-check.js +159 -0
  22. package/dist/contracts/release-terms.d.ts +60 -0
  23. package/dist/contracts/release-terms.js +70 -0
  24. package/dist/contracts/verification-receipt.d.ts +42 -8
  25. package/dist/contracts/verification-receipt.js +24 -7
  26. package/dist/contracts/verification-view.d.ts +1 -0
  27. package/dist/deploy-gate.d.ts +17 -0
  28. package/dist/deploy-gate.js +68 -0
  29. package/dist/dispatch.js +3 -10
  30. package/dist/dispose.js +4 -1
  31. package/dist/evidence-pack.d.ts +1 -1
  32. package/dist/evidence-pack.js +20 -2
  33. package/dist/evidence.d.ts +1 -0
  34. package/dist/evidence.js +2 -1
  35. package/dist/flow-engine.js +5 -1
  36. package/dist/flow-pull-request.d.ts +5 -2
  37. package/dist/flow-pull-request.js +14 -5
  38. package/dist/flow-schedule.js +25 -6
  39. package/dist/flows-ui.js +7 -1
  40. package/dist/invoke.d.ts +17 -6
  41. package/dist/invoke.js +48 -18
  42. package/dist/lead-status.js +5 -2
  43. package/dist/live-terminal.d.ts +10 -6
  44. package/dist/live-terminal.js +55 -14
  45. package/dist/nightly-check.d.ts +104 -0
  46. package/dist/nightly-check.js +362 -0
  47. package/dist/operate.js +39 -2
  48. package/dist/person-proof.d.ts +2 -0
  49. package/dist/person-proof.js +14 -0
  50. package/dist/plan-auto.js +4 -4
  51. package/dist/planner-source.d.ts +1 -1
  52. package/dist/project-delete-ui.js +1 -1
  53. package/dist/proof.js +2 -0
  54. package/dist/publish.js +156 -144
  55. package/dist/pull-request-flow.d.ts +2 -0
  56. package/dist/pull-request-flow.js +24 -2
  57. package/dist/release-adapters.d.ts +8 -0
  58. package/dist/release-adapters.js +212 -15
  59. package/dist/release-authority.d.ts +112 -0
  60. package/dist/release-authority.js +126 -0
  61. package/dist/release-carry.d.ts +233 -0
  62. package/dist/release-carry.js +645 -0
  63. package/dist/release-cli.d.ts +3 -0
  64. package/dist/release-cli.js +124 -5
  65. package/dist/release-coverage.js +3 -0
  66. package/dist/release-recheck.d.ts +146 -0
  67. package/dist/release-recheck.js +657 -0
  68. package/dist/release-view.d.ts +34 -0
  69. package/dist/release-view.js +27 -0
  70. package/dist/release.d.ts +131 -1
  71. package/dist/release.js +562 -244
  72. package/dist/remote-main.d.ts +2 -0
  73. package/dist/remote-main.js +1 -1
  74. package/dist/result-completion.d.ts +6 -0
  75. package/dist/result-completion.js +15 -1
  76. package/dist/result-follow-ups.js +29 -1
  77. package/dist/sandbox-runtime.d.ts +16 -5
  78. package/dist/sandbox-runtime.js +19 -6
  79. package/dist/scope.d.ts +115 -5
  80. package/dist/scope.js +219 -7
  81. package/dist/server/render-tasks.d.ts +8 -0
  82. package/dist/server/render-tasks.js +50 -9
  83. package/dist/server/route-table.js +1 -0
  84. package/dist/server/task-views.js +4 -0
  85. package/dist/server/tasks.js +12 -0
  86. package/dist/store.d.ts +227 -5
  87. package/dist/store.js +727 -37
  88. package/dist/task-outcome-cli.js +7 -3
  89. package/dist/toolroll-update.js +2 -2
  90. package/dist/verification-evidence.d.ts +25 -1
  91. package/dist/verification-evidence.js +57 -2
  92. package/dist/work-index.js +3 -6
  93. package/package.json +1 -1
package/dist/scope.js CHANGED
@@ -571,6 +571,7 @@ export function canonicalAcceptance(criteria) {
571
571
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
572
572
  .map(c => ({ id: c.id, statement: c.statement.trim(), evidence: [...c.evidence].sort() }));
573
573
  }
574
+ export const APPROVAL_BASES = ["password", "mode", "chat", "grant", "schedule", "lead", "carried"];
574
575
  /**
575
576
  * The exact bytes an operator agreed to.
576
577
  *
@@ -615,6 +616,7 @@ route) {
615
616
  // like an absent v41 field. Strict is an explicit signed promise.
616
617
  ...(scope.qualityMode === "strict" ? { qualityMode: "strict" } : {}),
617
618
  ...(scope.candidate ? { candidate: scope.candidate } : {}),
619
+ ...(scope.releaseTerms == null ? {} : { releaseTerms: releaseTermsDigest(scope.releaseTerms) }),
618
620
  // The single outer key, whichever target produced its inner value:
619
621
  // absent => the golden and every profileless approval are untouched;
620
622
  // a legacy profile => its exact profileDigestOf; an explicit chain
@@ -667,6 +669,7 @@ function proposeLocked(store, input) {
667
669
  ...(permissionMode === undefined ? {} : { permissionMode }),
668
670
  ...(input.qualityMode === undefined ? {} : { qualityMode: input.qualityMode }),
669
671
  ...(posture === undefined ? {} : { posture }),
672
+ ...(input.releaseTerms === undefined ? {} : { releaseTerms: input.releaseTerms }),
670
673
  proposedVia,
671
674
  });
672
675
  // The store may have RECOMPUTED the digest to bind the resolved profile
@@ -1060,6 +1063,10 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
1060
1063
  const authenticated = authenticateApprover(store, by, token, store.lookupRef(taskId)?.repo ?? null);
1061
1064
  if (!authenticated.ok)
1062
1065
  return authenticated;
1066
+ // ONLY A PERSON APPROVES (docs/plans/approval-policy.md, phase 1): a lead token or a remote API token proves the
1067
+ // credential, not a person at a ceremony. Refused before any vote or proof row is written.
1068
+ if (!personProof(authenticated.method))
1069
+ return { ok: false, reason: "machine-proof" };
1063
1070
  const scope = store.getScope(taskId);
1064
1071
  if (scope === null)
1065
1072
  return { ok: false, reason: "no-scope" };
@@ -1087,6 +1094,10 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
1087
1094
  const policy = store.scopePolicyRefusal(taskId);
1088
1095
  if (policy !== null)
1089
1096
  return { ok: false, reason: "policy", message: policy };
1097
+ // A release scope's yes opens its authority record (release-once.md); say first why it can't.
1098
+ const releaseProblem = scope.releaseTerms == null ? null : releaseSealProblem(store, scope.releaseTerms);
1099
+ if (releaseProblem !== null)
1100
+ return { ok: false, reason: "policy", message: releaseProblem };
1090
1101
  const gate = store.approvalGate(taskId, by, "person");
1091
1102
  if (gate.verdict === "refuse")
1092
1103
  return { ok: false, reason: gate.reason };
@@ -1103,7 +1114,7 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
1103
1114
  // The same person again adds nothing: no new vote, no proof row (and the approval roads refund a re-check).
1104
1115
  if (gate.already)
1105
1116
  return { ok: false, reason: "second-approver", have: gate.have, need: gate.need, already: true };
1106
- store.recordApprovalVote(taskId, scope.digest, by, now);
1117
+ store.recordApprovalVote(taskId, scope.digest, by, now, authenticated.method);
1107
1118
  proved();
1108
1119
  return { ok: false, reason: "second-approver", have: gate.have, need: gate.need, already: gate.already };
1109
1120
  }
@@ -1113,7 +1124,7 @@ export function approve(store, taskId, by, now, sawDigest, token, mutation = {})
1113
1124
  try {
1114
1125
  return store.savepoint(() => {
1115
1126
  if (gate.protectedWork)
1116
- store.recordApprovalVote(taskId, scope.digest, by, now);
1127
+ store.recordApprovalVote(taskId, scope.digest, by, now, authenticated.method);
1117
1128
  // SEAL, never re-resolve: the approval snapshots the stored working
1118
1129
  // profile — the exact bytes the digest the approver signed was bound
1119
1130
  // to. Routing saveScope here would re-run resolution and could sign a
@@ -1143,10 +1154,10 @@ export const approvalProofDetail = (at, generation, digest) => `${new Date(at).t
1143
1154
  export function approvalProofOf(store, taskId, by, digest) {
1144
1155
  const rows = store.handle.prepare("SELECT outcome, detail FROM action_ledger WHERE task_id = ? AND action = ? AND actor = ? ORDER BY id DESC LIMIT 50").all(taskId, APPROVAL_PROOF, by);
1145
1156
  for (const row of rows) {
1146
- const parts = typeof row["detail"] === "string" ? row["detail"].split(" · ") : [];
1147
- if (parts.length !== 3 || parts[2] !== `digest ${digest}` || Number.isNaN(Date.parse(parts[0])))
1157
+ const read = proofDetailOf(row["detail"]);
1158
+ if (read === null || read.digest !== digest)
1148
1159
  continue;
1149
- return { method: String(row["outcome"]), at: parts[0] };
1160
+ return { method: String(row["outcome"]), at: read.at };
1150
1161
  }
1151
1162
  return null;
1152
1163
  }
@@ -1161,7 +1172,7 @@ export function freshlyApprovable(store, taskId, scope, singleApprover) {
1161
1172
  if (ref === undefined || ref["coordinator_cid"] != null)
1162
1173
  return false;
1163
1174
  const via = store.handle.prepare("SELECT proposed_via FROM task_scope WHERE task_id = ?").get(taskId)?.["proposed_via"];
1164
- return !(via === "mate" || via === "coordinator" || via === "scout");
1175
+ return !quarantinedAuthor(typeof via === "string" ? via : null);
1165
1176
  }
1166
1177
  /**
1167
1178
  * Whether this task may be built.
@@ -1179,6 +1190,15 @@ export function approvalOf(scope) {
1179
1190
  }
1180
1191
  if (scope.approvedDigest !== scope.digest)
1181
1192
  return { approved: false, reason: "changed" };
1193
+ // A seal stands on a known authority (approval-policy plan §1.5): one left out or undefined, null (a row from before
1194
+ // the basis column) or a word this code doesn't write is no approval.
1195
+ if (scope.approvalBasis === undefined || scope.approvalBasis === null || !APPROVAL_BASES.includes(scope.approvalBasis)) {
1196
+ return { approved: false, reason: "none" };
1197
+ }
1198
+ // Phase 1: only people approve, so a lead's seal (including an old one a lead token made through the person's road,
1199
+ // which Store.getScope reads as `lead`) is no approval, and the person sees the Approve control again.
1200
+ if (scope.approvalBasis === "lead")
1201
+ return { approved: false, reason: "none" };
1182
1202
  return { approved: true, at: scope.approvedAt, by: scope.approvedBy };
1183
1203
  }
1184
1204
  /**
@@ -1239,7 +1259,7 @@ export function scopeAuthorityOf(scope, env = {}) {
1239
1259
  const parity = routeParityProblem(route, profile, { riskLevel: scope.riskLevel ?? "routine", qualityMode: scope.qualityMode ?? "default" });
1240
1260
  if (parity !== null)
1241
1261
  return { ok: false, reason: "parity", problem: parity };
1242
- const digest = digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default" }, profile, route);
1262
+ const digest = digestOf({ goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null }, profile, route);
1243
1263
  if (digest !== scope.digest) {
1244
1264
  return { ok: false, reason: "digest", problem: "the scope's reference does not re-derive from its own terms, agents, and route — file it again" };
1245
1265
  }
@@ -1298,10 +1318,34 @@ export function describeScope(scope, readiness = NO_READINESS) {
1298
1318
  ...(scope.profile?.checkAccess === undefined ? [] : [` checks ${checkAccessWords(scope.profile.checkAccess)}`]),
1299
1319
  // Its sandbox terms (project-sandbox.ts), part of what the yes agrees to: what it reads, and any widening.
1300
1320
  ...(scope.profile === null || scope.profile === undefined ? [] : ((terms) => [` reads ${terms.reads}`, ...(terms.mach === null ? [] : [` services ${terms.mach}`])])(sandboxTermsWords(scope.profile))),
1321
+ ...releaseTermsWords(scope.releaseTerms ?? null),
1301
1322
  ` reference ${scope.digest}`,
1302
1323
  ` approved ${describeApproval(approval)}`,
1303
1324
  ];
1304
1325
  }
1326
+ /** The approval sheet's release lines (release-once.md): the release in one sentence, then what asks again. */
1327
+ export function releaseTermsSentence(terms) {
1328
+ if (!terms.policy.carry)
1329
+ return `Release ${terms.packageVersion} from pull request #${terms.pr} at ${terms.approvedSha.slice(0, 7)}. Every later commit asks for your approval again.`;
1330
+ return `Release ${terms.packageVersion} from pull request #${terms.pr} at ${terms.approvedSha.slice(0, 7)}. Later commits on this pull request that only change tests, docs or the changelog ship without asking you again, until it merges or for 3 days. You'll see each one in the release summary.`;
1331
+ }
1332
+ /** What asks again, in full: the terms behind the sentence. */
1333
+ export function releaseAsksAgainWords(terms) {
1334
+ const p = terms.policy;
1335
+ return [
1336
+ "A later commit asks again when it changes anything but unit tests, the journey scripts, docs under docs/, the README or the changelog;",
1337
+ "when it touches AGENTS.md, CLAUDE.md, SECURITY.md, agent or CI settings, the test harness, helpers or fixtures;",
1338
+ `when it deletes, renames or changes the mode of a file, adds a binary, a symlink or a submodule; when it changes the production build;`,
1339
+ `when more than ${p.limits.lines} lines or ${p.limits.files} files change after this approval, or ${p.limits.churn} lines of churn;`,
1340
+ ...(p.protectProject ? ["when it changes anything: this project is protected;"] : p.protectedPaths.length === 0 ? [] : [`when it changes a protected path: ${p.protectedPaths.join(", ")};`]),
1341
+ `and always once the deploy starts. Repository ${terms.repo}, branch ${terms.branch} into ${terms.base}, check grant ${terms.grant.id}, checks in the sandbox, until ${terms.expiresAt}.`,
1342
+ ];
1343
+ }
1344
+ function releaseTermsWords(terms) {
1345
+ if (terms === null)
1346
+ return [];
1347
+ return [` release ${releaseTermsSentence(terms)}`, ...(terms.policy.carry ? releaseAsksAgainWords(terms).map(line => ` ${line}`) : [])];
1348
+ }
1305
1349
  function describeApproval(approval) {
1306
1350
  if (approval.approved)
1307
1351
  return `yes, by ${approval.by} at ${approval.at}`;
@@ -1310,3 +1354,171 @@ function describeApproval(approval) {
1310
1354
  }
1311
1355
  return "no — nothing will build this until somebody agrees to it";
1312
1356
  }
1357
+ // ---- Only a person approves (docs/plans/approval-policy.md, phase 1). Kept last: the freshness tripwire pins places.
1358
+ export { personProof } from "./person-proof.js";
1359
+ import { personProof } from "./person-proof.js";
1360
+ import { releaseTermsDigest } from "./contracts/release-terms.js";
1361
+ import { newestReleaseGeneration, releaseAuthorityOfTask, releaseSealProblem } from "./release-authority.js";
1362
+ /** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
1363
+ export const machineActing = () => currentActor()?.lead === true || activeRemote() !== null;
1364
+ /** A proof row's detail, either form: the legacy text (`<at> · generation <n> · digest <d>`) or the versioned record
1365
+ * (`{"v":1,"at":…,"digest":…}`, more fields allowed). Any other shape is no proof. */
1366
+ export function proofDetailOf(detail) {
1367
+ if (typeof detail !== "string")
1368
+ return null;
1369
+ if (detail.startsWith("{")) {
1370
+ try {
1371
+ const parsed = JSON.parse(detail);
1372
+ if (parsed === null || typeof parsed !== "object" || parsed["v"] !== 1 || typeof parsed["at"] !== "string" || typeof parsed["digest"] !== "string")
1373
+ return null;
1374
+ if (Number.isNaN(Date.parse(parsed["at"])) || parsed["digest"] === "")
1375
+ return null;
1376
+ return { at: parsed["at"], digest: parsed["digest"] };
1377
+ }
1378
+ catch {
1379
+ return null;
1380
+ }
1381
+ }
1382
+ const parts = detail.split(" · ");
1383
+ if (parts.length !== 3 || !parts[2].startsWith("digest ") || !/^generation \d+$/.test(parts[1]) || Number.isNaN(Date.parse(parts[0])))
1384
+ return null;
1385
+ return { at: parts[0], digest: parts[2].slice("digest ".length) };
1386
+ }
1387
+ /**
1388
+ * A person's approval of this task as it stands: approved, on the current digest, by the ceremony (`password`) or a
1389
+ * paired-chat tap (`chat`), with a person's proof recorded for that approver and those exact bytes. Release, deploy
1390
+ * and the protected-work counters ask this; a lead, a mode, a grant, a schedule or an approval with no proof is not
1391
+ * a person's.
1392
+ */
1393
+ export function personApprovalOf(store, taskId) {
1394
+ const scope = store.getScope(taskId);
1395
+ if (scope === null || scope.approvedAt === null || scope.approvedBy === null || scope.approvedDigest === null)
1396
+ return { ok: false, why: "not approved yet" };
1397
+ if (scope.approvedDigest !== scope.digest)
1398
+ return { ok: false, why: "the approval is for different scope text" };
1399
+ const basis = scope.approvalBasis ?? null;
1400
+ if (basis === "lead")
1401
+ return { ok: false, why: "approved by the lead, not by a person" };
1402
+ if (basis !== "password" && basis !== "chat")
1403
+ return { ok: false, why: `approved by ${basis === null ? "an unknown road" : basis === "grant" ? "a flow" : basis}, not by a person` };
1404
+ const origin = sealOriginOf(store, taskId, scope.approvedBy, scope.approvedDigest, scope.approvedAt);
1405
+ if (origin.origin === "machine")
1406
+ return { ok: false, why: origin.lead ? "approved by the lead, not by a person" : "approved with a machine credential, not by a person" };
1407
+ if (origin.origin !== "person")
1408
+ return { ok: false, why: "no record shows a person approved these exact words; approve it again" };
1409
+ return { ok: true, by: scope.approvedBy, at: scope.approvedAt, method: origin.method };
1410
+ }
1411
+ /**
1412
+ * Who made a seal that says it came through a person's road (`password`, `chat`), from the acts recorded AT THAT SEAL
1413
+ * (approval policy §1.5): the proof rows its approver left at the seal's own instant, and the lead's task act then.
1414
+ * - `machine`: any proof there that isn't a person's (a lead or remote token, or a word this code doesn't know), or the
1415
+ * lead's act — fail closed, whatever its digest says;
1416
+ * - `person`: a person's proof there for these exact bytes (parsed, either form);
1417
+ * - `automation`: a standing order's firing that no person's proof stands behind;
1418
+ * - `unproven`: none of these (a seal from before proof rows): it dispatches as before, and is never a person's yes.
1419
+ * An older proof for the same bytes never vouches for a later seal. The dispatch belts ask the same in SQL
1420
+ * (approval-authority.ts).
1421
+ */
1422
+ export function sealOriginOf(store, taskId, by, digest, at) {
1423
+ const rows = store.handle.prepare("SELECT outcome, detail FROM action_ledger WHERE task_id = ? AND action = ? AND actor = ? AND at = ? ORDER BY id DESC").all(taskId, APPROVAL_PROOF, by, at);
1424
+ const leadAct = store.handle.prepare(`SELECT 1 AS hit FROM task_act a JOIN task_ref r ON r.id = a.task_ref AND r.backend = 'built-in'
1425
+ WHERE r.external_id = ? AND a.act = 'approved' AND a.lead = 1 AND a.account = ? AND a.at = ?`).get(taskId, by, at) !== undefined;
1426
+ const machine = rows.filter(row => !personProof(String(row["outcome"])));
1427
+ if (leadAct || machine.length > 0)
1428
+ return { origin: "machine", lead: leadAct || machine.some(row => row["outcome"] === "lead-token") };
1429
+ const person = rows.find(row => proofDetailOf(row["detail"])?.digest === digest);
1430
+ if (person !== undefined)
1431
+ return { origin: "person", method: String(person["outcome"]) };
1432
+ if (store.handle.prepare("SELECT 1 AS hit FROM flow_trigger_event WHERE task = ? LIMIT 1").get(taskId) !== undefined)
1433
+ return { origin: "automation" };
1434
+ return { origin: "unproven" };
1435
+ }
1436
+ /** Text another machine wrote (a confirmed chat proposal, a coordinator, a scout): no mode, grant, remembered password
1437
+ * or chat tap seals it. The lead's own CLI text (`lead`) is not quarantined. */
1438
+ export const quarantinedAuthor = (via) => via === "mate" || via === "coordinator" || via === "scout";
1439
+ /** Which states each action may start from (deploy and merge also resume their own reservation). */
1440
+ const RELEASE_ACTION_STATES = {
1441
+ dispatch: ["open"], complete: ["open"], deploy: ["open", "deploying"], merge: ["deploying", "merge-reserved"],
1442
+ };
1443
+ /**
1444
+ * Whether a task that belongs to a release authority may do `action` now. It applies to EVERY such task, whether its
1445
+ * seal is the person's own or `carried`, and requires all of: the authority's current generation (never superseded,
1446
+ * revoked or expired before it started); a state that fits the action; the task is the authority's current tip; the
1447
+ * anchor's seal is a person's (personApprovalOf, unchanged) on the exact sealed anchor digest; the anchor's approver is
1448
+ * still an active approver with access to the project; for a carried task, a carry row for exactly this task, scope
1449
+ * digest, candidate and generation; the release check grant and check access as sealed (access only ever the fence);
1450
+ * a sealed policy this runtime knows; and, for deploy and merge, no open main-check failure blocking a composed check.
1451
+ * A task that belongs to no release reads `applies: false` — the ordinary rules decide it.
1452
+ */
1453
+ export function liveReleaseOf(store, taskId, action, now = new Date()) {
1454
+ const found = releaseAuthorityOfTask(store, taskId);
1455
+ if (found === null)
1456
+ return { applies: false };
1457
+ const { authority: a, carry } = found;
1458
+ const refuse = (why) => ({ applies: true, ok: false, why, authority: a });
1459
+ const newest = newestReleaseGeneration(store, a.repo, a.version, a.pr);
1460
+ if (newest === null || newest.id !== a.id)
1461
+ return refuse("a newer approval of this release replaced this one");
1462
+ if (a.state === "superseded")
1463
+ return refuse("a newer approval of this release replaced this one");
1464
+ if (a.state === "revoked")
1465
+ return refuse(`${a.endedBy ?? "an approver"} stopped carrying commits for this release`);
1466
+ // Expiry stops every live state (security review): an operation already reserved finishes only through reconciliation.
1467
+ if (a.state === "expired" || ((a.state === "open" || a.state === "deploying" || a.state === "merge-reserved") && a.expiresAt <= now.toISOString()))
1468
+ return refuse("the release approval expired");
1469
+ if (a.state === "merged")
1470
+ return refuse("the release was already merged");
1471
+ if (!RELEASE_ACTION_STATES[action].includes(a.state))
1472
+ return refuse(a.state === "open" ? "the release hasn't started deploying" : `the release is already ${a.state === "deploying" ? "deploying" : "merging"}`);
1473
+ if (a.tipTask !== taskId)
1474
+ return refuse("this is not the release's current commit");
1475
+ const anchor = store.getScope(a.anchorTask);
1476
+ const person = personApprovalOf(store, a.anchorTask);
1477
+ if (!person.ok)
1478
+ return refuse(`the release approval no longer stands: ${person.why}`);
1479
+ if (anchor === null || anchor.approvedDigest !== a.anchorDigest || anchor.digest !== a.anchorDigest)
1480
+ return refuse("the release approval is for different words");
1481
+ const account = store.accountOf(person.by);
1482
+ if (account === null || account.revokedAt !== null || account.role !== "approver" || !store.accountCanAccess(person.by, a.repo))
1483
+ return refuse(`${person.by} can no longer approve in this project`);
1484
+ const scope = store.getScope(taskId);
1485
+ if (scope === null || scope.approvedDigest !== scope.digest)
1486
+ return refuse("this task's approved words changed");
1487
+ if (carry !== null) {
1488
+ if (carry.authority !== a.id || scope.approvalBasis !== "carried" || carry.scopeDigest !== scope.digest || carry.toSha !== scope.candidate || scope.modeDigest !== `${a.id}:${carry.id}`) {
1489
+ return refuse("this commit's carry doesn't match its record");
1490
+ }
1491
+ }
1492
+ else if (scope.candidate !== a.approvedSha)
1493
+ return refuse("this task isn't the approved commit");
1494
+ const grant = store.liveVerifyCommand(a.repo);
1495
+ if (grant === null || grant.id !== a.verifyCommand || grant.digest !== a.verifyDigest)
1496
+ return refuse("the release check command changed since the approval");
1497
+ if (a.accessDigest !== "sandboxed" || store.projectCheckAccess(a.repo) !== "sandboxed")
1498
+ return refuse("the project's checks no longer run in the sandbox");
1499
+ if (a.policy === null)
1500
+ return refuse("this Toolroll doesn't know the release's carry rules; update Toolroll first");
1501
+ if (action === "deploy" || action === "merge") {
1502
+ const debt = mainDebtProblem(store, a.repo, taskId);
1503
+ if (debt !== null)
1504
+ return refuse(debt);
1505
+ }
1506
+ return { applies: true, ok: true, authority: a, carry };
1507
+ }
1508
+ /**
1509
+ * Open main-check failures (the nightly full check, part 3) block the deploy and the merge of a release whose gate
1510
+ * check was composed from a targeted run: a full pass is needed first. Null when nothing blocks.
1511
+ */
1512
+ export function mainDebtProblem(store, repo, taskId) {
1513
+ const open = Number(store.handle.prepare("SELECT COUNT(*) AS n FROM main_check_failure WHERE repo = ? AND cleared_at IS NULL").get(repo)?.["n"] ?? 0);
1514
+ if (open === 0)
1515
+ return null;
1516
+ const ref = store.lookupRef(taskId);
1517
+ if (ref === null)
1518
+ return null;
1519
+ const newest = store.handle.prepare(`SELECT a.mode FROM run r JOIN release_check_attempt a ON a.run = r.id
1520
+ WHERE r.task_ref = ? AND r.role = 'builder' ORDER BY r.id DESC LIMIT 1`).get(ref.id);
1521
+ if (newest === undefined || newest["mode"] !== "targeted")
1522
+ return null;
1523
+ return `the nightly full check on main has ${open} open failure${open === 1 ? "" : "s"}, so a check made from a re-run of what failed can't deploy; a full check has to pass first`;
1524
+ }
@@ -1,3 +1,4 @@
1
+ import type { ReleaseView } from "../release-view.js";
1
2
  import { type Html } from "../html.js";
2
3
  import { type RunActivity } from "../activity-line.js";
3
4
  import { type AgentChoice } from "../agentconfig.js";
@@ -522,6 +523,9 @@ export declare function taskBodyParts(data: {
522
523
  method: string;
523
524
  at: string;
524
525
  } | null;
526
+ /** One approval per release (release-once.md): the release this task belongs to, and whether this reader may stop it. */
527
+ release?: ReleaseView | null;
528
+ canStopRelease?: boolean;
525
529
  /** Workers that are both alive and authorized for this task's project. */
526
530
  worker?: {
527
531
  answering: number;
@@ -670,3 +674,7 @@ export declare function qualityModeChoices(name: string, selected: QualityMode):
670
674
  export declare function decisionOptionForms(decision: Decision, returnTo: string | null): Html;
671
675
  export declare function decisionPage(chrome: Chrome, decision: Decision, taskId: string, artifacts: Artifact[], who: Who, now: Date, returnTo?: string | null): Screen;
672
676
  export declare function taskOf(store: Store, decision: Decision): string;
677
+ /** A release task's own card (release-once.md, "What people see"): the release it's approved for, the later commits
678
+ * carried under it (on demand), what a later check must run again, the last nightly check of main, and one action to
679
+ * stop carrying. A carried task says whose approval it was carried from. */
680
+ export declare function releaseCardHtml(taskId: string, release: ReleaseView | null, canStop: boolean): Html;
@@ -1,5 +1,6 @@
1
1
  /** Task pages: approval, agents, plans, controls, decisions and the task body. */
2
2
  import { networkWords } from "../egress.js";
3
+ import { releaseAsksAgainWords, releaseTermsSentence } from "../scope.js";
3
4
  import { writesWords } from "../writes-mode.js";
4
5
  import { checkAccessWords } from "../check-access.js";
5
6
  import { sandboxTermsWords } from "../project-sandbox.js";
@@ -757,18 +758,28 @@ export function approvalProofWords(proof) {
757
758
  return " (passwordless: your signed-in session)";
758
759
  if (proof.method === "cli-login")
759
760
  return " (passwordless: this computer's remembered login)";
761
+ if (proof.method === "chat")
762
+ return " (from your paired chat)";
763
+ if (proof.method === "carried")
764
+ return " (carried from the release approval)";
760
765
  return "";
761
766
  }
762
767
  const whenText = (iso) => new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit" }).format(new Date(iso));
763
768
  export function approveRefusalWords(reason) {
764
769
  if (reason === "requester" || reason === "person-required")
765
770
  return gateWords({ verdict: "refuse", reason });
771
+ if (reason === "machine-proof")
772
+ return "not approved: only a person can approve this. Approve it on the task page or from your paired chat";
766
773
  if (reason === "unrouted")
767
774
  return "not approved: this scope predates agent routing and its old approval no longer stands — edit and re-file the scope so it is routed under today’s agents, then approve it";
768
775
  if (reason === "profile-unresolved")
769
776
  return "not approved: the scope cannot name an exact agent for every role — fix the agent setup and re-file it";
770
777
  if (reason === "password-now")
771
778
  return "not approved: this needs your password typed now — protected work, raised permissions, or a plan an agent wrote can't use a remembered one";
779
+ // A wrong password, someone who isn't an approver (or not for this project), a revoked login: one answer for all, so
780
+ // the refusal never tells a guesser which it was.
781
+ if (reason === "not-an-approver")
782
+ return "That password didn't match.";
772
783
  return `not approved: ${reason}`;
773
784
  }
774
785
  /** The browser's one-click approval binds not only the signed scope but the
@@ -1480,9 +1491,12 @@ export function taskBodyParts(data) {
1480
1491
  scope.touches.length === 0 ? "" : html `<p class="scope-paths"><strong>Touches</strong> ${scope.touches.join(", ")}</p>`,
1481
1492
  html `<p><strong>Quality</strong> ${qualityModeTitle(scope.qualityMode ?? "default")}</p>`,
1482
1493
  acceptanceCeremonyHtml(scope.acceptance),
1483
- approval.approved
1484
- ? html `<p class="meta scope-seal">Approved by ${approval.by}${approvalProofWords(data.approvalProof)} · ${whenTime(approval.at)} · <span class="seal">signs ${shortDigest(scope.digest)}</span><span class="so-sr-only"> — approval binds to this exact wording</span></p>`
1485
- : html `<p class="meta"><span class="seal">signs ${shortDigest(scope.digest)}</span> — approval binds to this exact wording</p><p class="meta">Not approved${approval.reason === "changed" ? " — approved once, then rewritten" : ""}</p>`,
1494
+ scope.releaseTerms == null ? "" : html `<p><strong>Release</strong></p><p class="recap">${releaseTermsSentence(scope.releaseTerms)}</p>${scope.releaseTerms.policy.carry ? html `<details><summary>What asks again</summary><ul>${joinHtml(releaseAsksAgainWords(scope.releaseTerms).map(line => html `<li>${line}</li>`), "")}</ul></details>` : ""}`,
1495
+ approval.approved && data.release?.kind === "carried"
1496
+ ? html `<p class="meta scope-seal">Carried from ${data.release.approver ?? "a person"}'s release approval · <a href="${taskHref(data.release.anchorTask)}">Release ${data.release.version}</a> · <span class="seal">signs ${shortDigest(scope.digest)}</span></p>`
1497
+ : approval.approved
1498
+ ? html `<p class="meta scope-seal">Approved by ${approval.by}${approvalProofWords(data.approvalProof)} · ${whenTime(approval.at)} · <span class="seal">signs ${shortDigest(scope.digest)}</span><span class="so-sr-only"> — approval binds to this exact wording</span></p>`
1499
+ : html `<p class="meta"><span class="seal">signs ${shortDigest(scope.digest)}</span> — approval binds to this exact wording</p><p class="meta">Not approved${approval.reason === "changed" ? " — approved once, then rewritten" : ""}</p>`,
1486
1500
  html `</div>`,
1487
1501
  ], "\n");
1488
1502
  // The scout's report (mate arc §10): title, summary, the document inert,
@@ -1773,9 +1787,11 @@ export function taskBodyParts(data) {
1773
1787
  : "";
1774
1788
  const scopeRow = scope === null
1775
1789
  ? prop("scope", "none yet")
1776
- : approval.approved
1777
- ? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}${approvalProofWords(data.approvalProof)} · ${whenTime(approval.at)}`)
1778
- : prop("scope", approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved");
1790
+ : approval.approved && data.release?.kind === "carried"
1791
+ ? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · carried from ${data.release.approver ?? "a person"}'s release approval · <a href="${taskHref(data.release.anchorTask)}">Release ${data.release.version}</a>`)
1792
+ : approval.approved
1793
+ ? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}${approvalProofWords(data.approvalProof)} · ${whenTime(approval.at)}`)
1794
+ : prop("scope", approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved");
1779
1795
  const strikesRow = data.strikes > 0 ? prop("strikes", `${data.strikes} failed attempt(s)`) : "";
1780
1796
  const propsCard = html `<div class="card props">${workerRow}${queueRow}${scopeRow}${publishesRow}${economics}${strikesRow}</div>`;
1781
1797
  // The same facts for the rebuilt page, row for row.
@@ -1790,8 +1806,11 @@ export function taskBodyParts(data) {
1790
1806
  facts.push({ label: "Queue", parts: [`${data.position.position} of ${data.position.total}${data.position.column === null ? " in the shared queue" : ` in ${data.position.column}'s queue`} · `, { label: "Reorder", href: "/board?view=order" }] });
1791
1807
  }
1792
1808
  facts.push(scope === null ? { label: "Scope", parts: ["none yet"] }
1793
- : approval.approved ? { label: "Approved scope", parts: [{ seal: `signs ${scope.digest.length <= 12 ? scope.digest : `${scope.digest.slice(0, 12)}…`}` }, ` · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}${approvalProofWords(data.approvalProof)}`, ...(approval.at === null ? [] : [" · ", { at: approval.at }])] }
1794
- : { label: "Scope", parts: [approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved"] });
1809
+ : approval.approved && data.release?.kind === "carried" ? { label: "Approved scope", parts: [`Carried from ${data.release.approver ?? "a person"}'s release approval · `, { label: `Release ${data.release.version}`, href: taskHref(data.release.anchorTask) }] }
1810
+ : approval.approved ? { label: "Approved scope", parts: [{ seal: `signs ${scope.digest.length <= 12 ? scope.digest : `${scope.digest.slice(0, 12)}…`}` }, ` · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}${approvalProofWords(data.approvalProof)}`, ...(approval.at === null ? [] : [" · ", { at: approval.at }])] }
1811
+ : { label: "Scope", parts: [approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved"] });
1812
+ if (data.release?.kind === "anchor")
1813
+ facts.push({ label: "Release", parts: [`Approved for release ${data.release.version}`, ...(data.release.landed.length === 0 ? [] : [` · later commits: ${data.release.landed.map(one => one.sha.slice(0, 7)).join(", ")}`]), ...(data.release.carrying ? [] : [` · later commits ask again`])] });
1795
1814
  facts.push({ label: "Publishes as", parts: [publishesAs] });
1796
1815
  // v102: who asked for it, and what the project's approval rules need.
1797
1816
  if (data.filer != null)
@@ -2068,7 +2087,7 @@ export function taskBodyParts(data) {
2068
2087
  section("scope",
2069
2088
  // The recipe road rides with the scope it reuses (UI polish
2070
2089
  // 2026-09-13), off the title-to-action path.
2071
- joinHtml([html `<h2>Scope</h2>`, scopeCard, data.repo !== null && data.scope !== null ? html `<p class="meta"><a href="/recipes/from-task?task=${encodeURIComponent(task.id)}">Reuse this scope as a recipe →</a></p>` : "", agentsCardHtml(task.id, data.route, data.canEditRoute === true), revisionInApproval ? "" : revisionCard, data.completion != null ? "" : repairChainHtml(data.repairChain ?? null), scopeForm], "\n"), scopeDraft !== undefined || (data.plan !== "requested" && approveForm === "" && !(scope === null && canPlan))),
2090
+ joinHtml([html `<h2>Scope</h2>`, scopeCard, releaseCardHtml(task.id, data.release ?? null, data.canStopRelease === true), data.repo !== null && data.scope !== null ? html `<p class="meta"><a href="/recipes/from-task?task=${encodeURIComponent(task.id)}">Reuse this scope as a recipe →</a></p>` : "", agentsCardHtml(task.id, data.route, data.canEditRoute === true), revisionInApproval ? "" : revisionCard, data.completion != null ? "" : repairChainHtml(data.repairChain ?? null), scopeForm], "\n"), scopeDraft !== undefined || (data.plan !== "requested" && approveForm === "" && !(scope === null && canPlan))),
2072
2091
  dependencyChoiceNeeded ? "" : section("waits for", waitsForCard, (data.waitsFor ?? []).length > 0, (data.waitsFor ?? []).length),
2073
2092
  section("holds", holds, true, data.holds.length),
2074
2093
  cancelAct,
@@ -2461,3 +2480,25 @@ export function taskOf(store, decision) {
2461
2480
  const run = store.getRun(decision.run);
2462
2481
  return run === null ? "?" : store.externalIdFor(run.taskRef) ?? "?";
2463
2482
  }
2483
+ /** A release task's own card (release-once.md, "What people see"): the release it's approved for, the later commits
2484
+ * carried under it (on demand), what a later check must run again, the last nightly check of main, and one action to
2485
+ * stop carrying. A carried task says whose approval it was carried from. */
2486
+ export function releaseCardHtml(taskId, release, canStop) {
2487
+ if (release === null)
2488
+ return html ``;
2489
+ if (release.kind === "carried")
2490
+ return html `<div class="card" id="release"><p><strong>Carried from ${release.approver ?? "a person"}'s release approval</strong></p><p class="meta"><a href="${taskHref(release.anchorTask)}">Release ${release.version}</a> · pull request #${release.pr}</p></div>`;
2491
+ const until = new Intl.DateTimeFormat("en-US", { weekday: "short", hour: "numeric", minute: "2-digit" }).format(new Date(release.expiresAt));
2492
+ const landed = release.landed.length === 0 ? html `<p class="meta">No later commits yet.</p>`
2493
+ : html `<ul>${joinHtml(release.landed.map(one => html `<li><a href="${taskHref(one.task)}">${one.sha.slice(0, 7)}</a> · ${one.files.map(file => `${file.path} (+${file.added} −${file.deleted})`).join(", ")}</li>`), "")}</ul>`;
2494
+ return joinHtml([
2495
+ html `<div class="card" id="release">`,
2496
+ html `<p><strong>Approved for release ${release.version}</strong></p>`,
2497
+ html `<p class="meta">${release.carrying ? `Later commits on pull request #${release.pr} that only change tests, docs or the changelog ship without asking again, until it merges or ${until}.` : `Later commits ask for approval again: ${release.why ?? `the release is ${release.state}`}.`}</p>`,
2498
+ html `<details><summary>Later commits (${release.landed.length})</summary>${landed}</details>`,
2499
+ release.obligations.length === 0 ? "" : html `<details><summary>Still to pass (${release.obligations.length})</summary><ul>${joinHtml(release.obligations.map(one => html `<li>${one}</li>`), "")}</ul></details>`,
2500
+ release.nightly === null ? "" : html `<p class="meta">Nightly check of main: ${release.nightly.outcome ?? "running"} at ${release.nightly.sha.slice(0, 7)}${release.openMainFailures > 0 ? ` · ${release.openMainFailures} open failure${release.openMainFailures === 1 ? "" : "s"}` : ""}</p>`,
2501
+ release.carrying && canStop ? postForm(`${taskHref(taskId)}/stop-carrying`, html `<button type="submit" class="secondary">Stop carrying later commits</button>`, { attrs: { class: "inline" } }) : "",
2502
+ html `</div>`,
2503
+ ], "\n");
2504
+ }
@@ -381,6 +381,7 @@ post("tasks", "task.act.retry-review", `^\\/t\\/${TASK}\\/retry-review$`, { proj
381
381
  post("tasks", "task.act.complete", `^\\/t\\/${TASK}\\/complete$`, { project: "task", limited: "resource", sample: "/t/one/complete", ...approver("Only an approver can mark a result complete.") });
382
382
  post("tasks", "task.act.merge", `^\\/t\\/${TASK}\\/merge$`, { project: "task", limited: "resource", sample: "/t/one/merge", ...approver("Only an approver can merge.") });
383
383
  post("tasks", "task.act.confirm-stopped", `^\\/t\\/${TASK}\\/confirm-stopped$`, { scope: "step-up", project: "task", limited: "resource", sample: "/t/one/confirm-stopped", ...approver("Only an approver can confirm a build stopped.") });
384
+ post("tasks", "task.act.stop-carrying", `^\\/t\\/${TASK}\\/stop-carrying$`, { project: "task", limited: "resource", sample: "/t/one/stop-carrying", ...approver("Only an approver can stop carrying a release.") });
384
385
  post("tasks", "task.act.stop", `^\\/t\\/${TASK}\\/stop$`, { project: "task", limited: "resource", sample: "/t/one/stop" });
385
386
  post("tasks", "task.act.pause", `^\\/t\\/${TASK}\\/pause$`, { project: "task", limited: "resource", sample: "/t/one/pause" });
386
387
  post("tasks", "task.act.resume-arm", `^\\/t\\/${TASK}\\/resume-arm$`, { project: "task", limited: "resource", sample: "/t/one/resume-arm" });
@@ -1,3 +1,5 @@
1
+ import { releaseViewOf } from "../release-view.js";
2
+ import { releaseObligationsOf } from "../release-recheck.js";
1
3
  import { approvalProofOf, freshlyApprovable, freshnessFor, withFreshness } from "../scope.js";
2
4
  import { liveAttemptOf, steerControlsFor } from "../steer-controls.js";
3
5
  import { originOf } from "../task-source.js";
@@ -493,6 +495,8 @@ export function createTaskViews(runtime) {
493
495
  base: taskBaseOf(runtime.store, taskId),
494
496
  steeringLive: ref !== null && runtime.store.steeringLiveBuild(ref.id, now) !== null,
495
497
  steeringRun: ref === null ? null : runtime.store.steeringLiveBuild(ref.id, now),
498
+ release: ref === null ? null : releaseViewOf(runtime.store, ref.externalId, now, authority => releaseObligationsOf(runtime.store, runtime.evidenceRoot, authority)),
499
+ canStopRelease: who.role === "approver" && who.via === "cookie",
496
500
  approvalProof: (() => {
497
501
  // The proof of the approval shown: its approver, its exact sealed digest (approvedDigest === digest when approved).
498
502
  const scope = ref === null ? null : runtime.store.getScope(ref.externalId);
@@ -1,3 +1,4 @@
1
+ import { releaseAuthorityOfTask, revokeRelease } from "../store.js";
1
2
  import { steerCapabilityOf } from "../steer-capabilities.js";
2
3
  import { canReveal, localCheckoutOf, revealInFinder } from "../checkout-proof.js";
3
4
  /** A form's way back to a flow card ("flow:<flow>:<card>"), read strictly: numbers only, never a path. */
@@ -1972,6 +1973,16 @@ export function createTasksHandlers(runtime) {
1972
1973
  store.unhold(ref.id);
1973
1974
  return redirect(response, taskHref(taskId));
1974
1975
  }
1976
+ case "stop-carrying": {
1977
+ // One click, any active approver (release-once.md): later commits of this release ask again. Only ever stricter.
1978
+ const found = releaseAuthorityOfTask(store, taskId);
1979
+ if (found === null)
1980
+ return taskScreen(response, who, taskId, "This task isn't part of a release.", 409);
1981
+ const stopped = revokeRelease(store, found.authority.id, who.name, now);
1982
+ if (!stopped.ok)
1983
+ return taskScreen(response, who, taskId, stopped.why, 409);
1984
+ return redirect(response, `${taskHref(taskId)}#release`);
1985
+ }
1975
1986
  case "requeue": {
1976
1987
  const body = readForm(posted, CONSOLE_FORMS.taskRequeue);
1977
1988
  // Retry may carry a note for the next attempt: the same steering note, checked before anything changes.
@@ -3036,6 +3047,7 @@ export function createTasksHandlers(runtime) {
3036
3047
  "task.act.merge": taskMerge,
3037
3048
  "task.act.confirm-stopped": taskConfirmStopped,
3038
3049
  "task.act.stop": taskAct("stop"),
3050
+ "task.act.stop-carrying": taskAct("stop-carrying"),
3039
3051
  "task.act.pause": taskAct("pause"),
3040
3052
  "task.act.resume-arm": taskAct("resume-arm"),
3041
3053
  "task.act.resume": taskAct("resume"),