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/CHANGELOG.md CHANGED
@@ -1,5 +1,84 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.72 — 2026-10-25
4
+
5
+ - **One approval per release.** A person approves a release once. Later
6
+ commits on the same pull request carry that approval only when every change
7
+ since is unit tests, journey scripts, Markdown under docs/, the README or
8
+ the changelog, within 400 changed lines, 20 files and 800 lines of churn,
9
+ and the production build is byte-for-byte the approved one. Anything else
10
+ asks again, and so does any change after the deploy has started. The
11
+ approval sheet states the release and what asks again; the release summary
12
+ and the squash message list every commit that landed after the approval.
13
+ **Stop carrying later commits** on the task page (or `toolroll release
14
+ revoke`) ends it, and `toolroll release ask-every-commit on` makes a
15
+ project's releases ask for every commit. A release approval expires after
16
+ 3 days.
17
+ - **Re-checks of a release re-run only what failed.** On the second and later
18
+ release checks, only the failed unit files, journey groups and upgrade
19
+ versions run again, with every test changed since and a short sanity pass.
20
+ A check that can't prove it covered everything counts as failed, and a
21
+ test that disappears or is newly skipped stops carrying.
22
+ - **A nightly full check of main.** `toolroll release nightly --repo <path>`
23
+ sets up a daily full check of main (03:00 local by default). A failure
24
+ becomes an attention notice naming what failed, holds re-checks that run
25
+ only part of the suite until a full check passes it again, and a nightly
26
+ that hasn't run for 26 hours says so.
27
+ - **Sandboxed checks can start Chromium on macOS.** Playwright's default
28
+ browser (its Chromium headless shell) now starts inside the check sandbox:
29
+ the sandbox may register and look up Chromium's port rendezvous service,
30
+ `org.chromium.Chromium.MachPortRendezvousServer.<pid>`, and nothing else.
31
+ Builds, flows and tool servers don't get this; SECURITY.md says what a
32
+ hostile check could do with it. A project set to **Not sandboxed** only for
33
+ a headless browser check can go back to **Sandboxed** in Settings → Project
34
+ → Check access. Chrome for Testing (`channel: "chromium"`, headed runs)
35
+ still needs **Not sandboxed**.
36
+ - **Checks with sign-ins no longer reach your keychain.** Toolroll reads
37
+ Claude Code's and gh's sign-ins from the keychain itself, outside the
38
+ sandbox, and hands the check just those tokens in its environment. A check
39
+ gets Claude's current access token and can't renew it.
40
+ - **A wrong password on an approval says "That password didn't match."**
41
+ instead of "not approved: not-an-approver". Someone who isn't an approver
42
+ gets the same answer.
43
+ - **Closing a live terminal signals only its own processes.** It stops them
44
+ before it kills them, so a child one starts at that moment can't slip out
45
+ from under the close; each signal goes only to a process still the one it
46
+ saw (same pid, same start); and after the program ends, its terminal's
47
+ name no longer counts as proof, since the device may already be someone
48
+ else's. A process it can't prove is its own is never signalled, and the
49
+ close says it's uncertain (the worktree stays).
50
+ - **Only a person approves work.** The lead's token used to approve a task's
51
+ scope and be recorded as its owner's password approval, including on a
52
+ release. Now `task approve` (and `task repair --yes`) with a lead token, or
53
+ any approval made with a remote API token, is refused: "Only a person can
54
+ approve this." Approve on the task page or from your paired chat
55
+ (docs/plans/approval-policy.md, phase 1; the setting that lets the lead
56
+ approve, recorded as the lead's, comes next).
57
+ - **Only a person merges by hand.** `task merge` (including `--anyway`) and
58
+ the merge button refuse a lead or API token. A flow's Pull request zone
59
+ merges only after a person's recorded decision; a subagent's approval, or
60
+ a decision from before decisions recorded who made them, waits.
61
+ - **Protected work counts only people.** A vote a machine credential left, or
62
+ one with no proof on record, doesn't count as one of the two people, and a
63
+ lead or API token's completion is never the second person. A person can
64
+ mark an already-complete result complete again to release a held push.
65
+ - **Releases and deployments need a person's approval with its proof on
66
+ record.** The release gate and `deploy-browser.mjs` (every phase, resumed
67
+ ones included) refuse an approval a lead token made, a mode, a flow, a
68
+ standing order, or an approval with no recorded proof. `toolroll release` proves the
69
+ candidate in its own runtime, and that the checkout is exactly that commit,
70
+ before it runs anything from the candidate's checkout. Paired-chat
71
+ approvals now record their own proof, so they still count. Update the
72
+ plane before running `toolroll release`: an older plane can't say who
73
+ approved, and the release waits.
74
+ - Approvals a lead token made before this change are read for what they were:
75
+ they no longer dispatch, and the task asks for a person's approval. Seals
76
+ with no recorded authority stand on nothing. A standing order's firing is
77
+ approved by its own authority (`schedule`), which stands only while its
78
+ approver can still approve in the project.
79
+ - Schema v134 and v135 only add columns and tables; `toolroll update` runs
80
+ them in place.
81
+
3
82
  ## 0.9.71 — 2026-10-25
4
83
 
5
84
  - **Sandboxed launches can open a terminal of their own on macOS.** A check
@@ -93,9 +93,12 @@ export type FenceGrants = {
93
93
  /** macOS: the project's sealed mach services beyond the runtime's baseline (its `allowMachLookup`). */
94
94
  machLookup?: string[];
95
95
  /** macOS: the keychain's services (securityd), denied to every launch without it (sandbox-runtime.ts
96
- * SEATBELT_NO_KEYCHAIN). Only fenceFor gives it, to a build's agent; a check with sign-ins is given it at its launch
97
- * (invoke.ts). Never a tool server's (serverGrantsOf). */
96
+ * SEATBELT_NO_KEYCHAIN). Only fenceFor gives it, to a build's agent. Never a check's (one with sign-ins gets those in
97
+ * its environment, invoke.ts keychainSignIns), never a tool server's (serverGrantsOf). */
98
98
  keychain?: true;
99
+ /** macOS: a project check's launch, whose Chromium may start (sandbox-runtime.ts SEATBELT_CHROMIUM). Only fenceFor
100
+ * sets it, only for kind "check". */
101
+ browser?: true;
99
102
  /** A `sign-ins` check's exact sign-in files, read again inside the home denies (check-access.ts, signInGrants). */
100
103
  signIns?: SignInGrants;
101
104
  /** Never written, wherever the launch writes (homeWriteDenies): what runs for the operator next or signs them in. */
@@ -12,7 +12,7 @@
12
12
  * checkout writable; other tasks' checkouts read, never written; the project's read-deny list; under policy v1 (an
13
13
  * approval sealed before 0.9.70) the home configuration denied by name; a sign-ins check's exact files read again; the
14
14
  * run's proxy as its only way out when its network is confined. On macOS every launch also gets a pseudo-terminal of its
15
- * own and no other terminal, and only a build's agent (and a check with sign-ins) reaches the keychain
15
+ * own and no other terminal, a project check's launch Chromium's own service, and only a build's agent the keychain
16
16
  * (sandbox-runtime.ts seatbeltRulesFor, static rules).
17
17
  *
18
18
  * Codex (every platform) fences its own agent's commands with its own sandbox: a permission profile with the same
@@ -985,6 +985,7 @@ export function srtTermsOf(fence, grants = NO_GRANTS) {
985
985
  readConfig,
986
986
  writeConfig: { allowOnly: [...new Set(allowOnly)].sort(), denyWithinAllow: [...denyWrite].sort() },
987
987
  allowMachLookup: [...grants.machLookup ?? [], ...grants.signIns?.mach ?? []],
988
+ ...(grants.browser === true ? { browser: true } : {}),
988
989
  ...(grants.keychain === true ? { keychain: true } : {}),
989
990
  network: network === undefined || network.mode === "open" ? { kind: "open" } : {
990
991
  kind: "proxy", httpPort: network.proxyPort, ...(network.brokerPorts[0] === undefined ? {} : { socksPort: network.brokerPorts[0] }),
@@ -1088,6 +1089,8 @@ export function fenceFor(kind, ctx) {
1088
1089
  ...(written === null ? {} : { writes: written.writes, readOnly: [...new Set([...made.readOnly, ...written.readOnly])].sort() }),
1089
1090
  ...(ctx.network === undefined ? {} : { network: ctx.network }),
1090
1091
  ...(ctx.machLookup === undefined || ctx.machLookup.length === 0 ? {} : { machLookup: [...ctx.machLookup] }),
1092
+ // A project's checks may drive a browser (Playwright's Chromium); builds, flows and tool servers don't get it.
1093
+ ...(kind === "check" ? { browser: true } : {}),
1091
1094
  // A build's agent signs in from the keychain (Claude Code does); nothing else it launches reaches it.
1092
1095
  ...(kind === "build" ? { keychain: true } : {}),
1093
1096
  // Under a write allowlist these are out of reach already; where the launch writes anywhere, they are denied by name.
@@ -1232,7 +1235,7 @@ export function homeWriteDenies(home, binding, provider, platform = process.plat
1232
1235
  * where it always has), and never the build's own checkout or launch folder read back (nothing a build wrote loads in a
1233
1236
  * process that holds a tool's secrets). */
1234
1237
  export function serverGrantsOf(grants) {
1235
- const { network: _agentOnly, writes: _agentWrites, keychain: _agentKeychain, ...rest } = grants;
1238
+ const { network: _agentOnly, writes: _agentWrites, keychain: _agentKeychain, browser: _checkOnly, ...rest } = grants;
1236
1239
  return {
1237
1240
  ...rest,
1238
1241
  ...(rest.pool === undefined ? {} : { pool: { ...rest.pool, own: null } }),
@@ -0,0 +1,20 @@
1
+ /** SQL over a `task_scope` alias and a repo expression: whether that row's approval authority stands at `now` (an SQL
2
+ * expression, used exactly three times: the mode and grant clauses, and the release's expiry). It never asks whether the row is approved.
3
+ * COALESCE: a NULL basis would make the OR chain NULL, and NOT NULL reads as "not dead"; NULL stands on nothing. */
4
+ export declare const authorityStandsSql: (scope: string, repo: string, now: string) => string;
5
+ /** SQL: a `carried` seal stands only on its own carry row — this task, this approved digest, this candidate, and the
6
+ * reference `<authority>:<carry>` the carry transaction wrote (release-once.md). */
7
+ export declare const carrySealSql: (scope: string) => string;
8
+ /**
9
+ * SQL: a task that belongs to a release authority (its anchor, or a carried commit) dispatches only as the current
10
+ * generation's tip while the release is open and unexpired, with the anchor's approver still an active approver who
11
+ * can reach the project and the anchor's approval on its exact sealed digest. A task that belongs to no release passes.
12
+ * The claim and build belts read the whole predicate (scope.ts liveReleaseOf) besides.
13
+ */
14
+ export declare const releaseStandsSql: (scope: string, now: string) => string;
15
+ /** SQL: a machine made this seal — any proof at the seal's instant that isn't a person's (whatever its detail says,
16
+ * parsed or not: fail closed), or the lead's own approval act then. */
17
+ export declare const machineSealSql: (scope: string) => string;
18
+ /** SQL: a person's proof at the seal's instant for these exact bytes, its detail parsed (the versioned JSON record,
19
+ * whitespace and all, or the legacy text). */
20
+ export declare const personSealSql: (scope: string) => string;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Which approval authorities stand, as ONE SQL predicate every dispatch belt shares (docs/plans/approval-policy.md
3
+ * §1.5): the queue's dispatch query, the work index, and the claim and build belts (`Store.modeApprovalLive`).
4
+ *
5
+ * An allowlist, never a deny-list: a person's yes (`password`, `chat`) stands unless a machine made that seal; a mode
6
+ * or a flow grant stands while its signature does; a standing order's firing (`schedule`, or an old firing stored as
7
+ * `password`) stands while its approver can still approve in the project. Everything else — the lead (`lead`, which
8
+ * phase 1 never lets stand), a null basis, a word this code doesn't write — stands on nothing.
9
+ *
10
+ * The seal's origin is read from the acts recorded AT the seal's own instant, exactly as scope.ts sealOriginOf reads
11
+ * it: an older proof for the same bytes never vouches for a later seal.
12
+ */
13
+ import { grantStandsSql } from "./flow-unattended.js";
14
+ import { accountReachesSql } from "./project-access.js";
15
+ /** The proofs a person gives (src/person-proof.ts), as an SQL list. */
16
+ const PERSON_PROOFS = "('password-now', 'sso-now', 'fresh-password', 'fresh-sso', 'session', 'cli-login', 'chat', 'passkey')";
17
+ /** SQL over a `task_scope` alias and a repo expression: whether that row's approval authority stands at `now` (an SQL
18
+ * expression, used exactly three times: the mode and grant clauses, and the release's expiry). It never asks whether the row is approved.
19
+ * COALESCE: a NULL basis would make the OR chain NULL, and NOT NULL reads as "not dead"; NULL stands on nothing. */
20
+ export const authorityStandsSql = (scope, repo, now) => `COALESCE((((COALESCE(${scope}.approval_basis, '') IN ('password', 'chat') AND NOT ${machineSealSql(scope)}
21
+ AND (${personSealSql(scope)} OR NOT ${automationSql(scope)} OR ${approverStandsSql(scope, repo)}))
22
+ OR (${scope}.approval_basis = 'carried' AND ${carrySealSql(scope)})
23
+ OR (${scope}.approval_basis = 'mode' AND EXISTS (SELECT 1 FROM operating_mode om
24
+ JOIN approver signer ON signer.name = om.signed_by
25
+ WHERE om.repo = ${repo} AND om.revoked_at IS NULL AND om.absolute_expiry > ${now} AND om.digest = ${scope}.mode_digest
26
+ AND signer.revoked_at IS NULL AND signer.role = 'approver' AND ${accountReachesSql("signer", repo)}))
27
+ OR (${scope}.approval_basis = 'grant' AND ${grantStandsSql(`${scope}.mode_digest`, now, repo)})
28
+ OR (${scope}.approval_basis = 'schedule' AND ${approverStandsSql(scope, repo)}))
29
+ AND ${releaseStandsSql(scope, now)}), 0)`;
30
+ /** SQL: a `carried` seal stands only on its own carry row — this task, this approved digest, this candidate, and the
31
+ * reference `<authority>:<carry>` the carry transaction wrote (release-once.md). */
32
+ export const carrySealSql = (scope) => `EXISTS (SELECT 1 FROM release_carry rc2 JOIN release_authority ra2 ON ra2.id = rc2.authority
33
+ WHERE rc2.task_id = ${scope}.task_id AND rc2.scope_digest = ${scope}.approved_digest AND rc2.to_sha = ${scope}.candidate
34
+ AND ${scope}.mode_digest = ra2.id || ':' || rc2.id)`;
35
+ /**
36
+ * SQL: a task that belongs to a release authority (its anchor, or a carried commit) dispatches only as the current
37
+ * generation's tip while the release is open and unexpired, with the anchor's approver still an active approver who
38
+ * can reach the project and the anchor's approval on its exact sealed digest. A task that belongs to no release passes.
39
+ * The claim and build belts read the whole predicate (scope.ts liveReleaseOf) besides.
40
+ */
41
+ export const releaseStandsSql = (scope, now) => `((NOT EXISTS (SELECT 1 FROM release_authority rx WHERE rx.anchor_task = ${scope}.task_id)
42
+ AND NOT EXISTS (SELECT 1 FROM release_carry cx WHERE cx.task_id = ${scope}.task_id))
43
+ OR EXISTS (SELECT 1 FROM release_authority ra WHERE ra.tip_task = ${scope}.task_id AND ra.state = 'open' AND ra.expires_at > ${now}
44
+ AND (ra.anchor_task = ${scope}.task_id OR EXISTS (SELECT 1 FROM release_carry rc WHERE rc.authority = ra.id AND rc.task_id = ${scope}.task_id))
45
+ AND NOT EXISTS (SELECT 1 FROM release_authority rn WHERE rn.repo = ra.repo AND rn.version = ra.version AND rn.pr = ra.pr AND rn.generation > ra.generation)
46
+ AND EXISTS (SELECT 1 FROM task_scope an JOIN approver ap ON ap.name = an.approved_by
47
+ WHERE an.task_id = ra.anchor_task AND an.approved_digest = ra.anchor_digest AND an.digest = ra.anchor_digest
48
+ AND ap.revoked_at IS NULL AND ap.role = 'approver' AND ${accountReachesSql("ap", "ra.repo")})))`;
49
+ /** SQL: a machine made this seal — any proof at the seal's instant that isn't a person's (whatever its detail says,
50
+ * parsed or not: fail closed), or the lead's own approval act then. */
51
+ export const machineSealSql = (scope) => `(EXISTS (SELECT 1 FROM action_ledger mp WHERE mp.task_id = ${scope}.task_id AND mp.action = 'approval proved'
52
+ AND mp.actor = ${scope}.approved_by AND mp.at = ${scope}.approved_at AND mp.outcome NOT IN ${PERSON_PROOFS})
53
+ OR EXISTS (SELECT 1 FROM task_act la JOIN task_ref lr ON lr.id = la.task_ref AND lr.backend = 'built-in'
54
+ WHERE lr.external_id = ${scope}.task_id AND la.act = 'approved' AND la.lead = 1
55
+ AND la.account = ${scope}.approved_by AND la.at = ${scope}.approved_at))`;
56
+ /** SQL: a person's proof at the seal's instant for these exact bytes, its detail parsed (the versioned JSON record,
57
+ * whitespace and all, or the legacy text). */
58
+ export const personSealSql = (scope) => `EXISTS (SELECT 1 FROM action_ledger pp WHERE pp.task_id = ${scope}.task_id AND pp.action = 'approval proved'
59
+ AND pp.actor = ${scope}.approved_by AND pp.at = ${scope}.approved_at AND pp.outcome IN ${PERSON_PROOFS}
60
+ AND CASE WHEN json_valid(pp.detail) THEN json_extract(pp.detail, '$.v') = 1 AND json_type(pp.detail, '$.digest') = 'text'
61
+ AND json_extract(pp.detail, '$.digest') = ${scope}.approved_digest
62
+ ELSE pp.detail LIKE '% · generation % · digest ' || ${scope}.approved_digest END)`;
63
+ /** SQL: the task a standing order's firing filed (its own record of the firing). */
64
+ const automationSql = (scope) => `EXISTS (SELECT 1 FROM flow_trigger_event fe WHERE fe.task = ${scope}.task_id)`;
65
+ /** SQL: the seal's approver can still approve in the project. */
66
+ const approverStandsSql = (scope, repo) => `EXISTS (SELECT 1 FROM approver sched WHERE sched.name = ${scope}.approved_by AND sched.revoked_at IS NULL AND sched.role = 'approver'
67
+ AND ${accountReachesSql("sched", repo)})`;
@@ -4,10 +4,10 @@
4
4
  import { createHash } from "node:crypto";
5
5
  import { currentActor, withActor } from "./actor.js";
6
6
  import { replacedWords } from "./task-status.js";
7
- import { COMPLETER_ACTOR, COMPLETION_ACTION, familyChangedFiles, landedReasonOf } from "./result-completion.js";
7
+ import { COMPLETER_ACTOR, COMPLETION_ACTION, COMPLETION_BY_MACHINE, COMPLETION_BY_PERSON, familyChangedFiles, landedReasonOf, personCompletionOwed } from "./result-completion.js";
8
8
  import { homedir } from "node:os";
9
9
  import { readSchemaVersion, stopFactOf } from "./store.js";
10
- import { approvalOf } from "./scope.js";
10
+ import { approvalOf, liveReleaseOf, machineActing } from "./scope.js";
11
11
  import { openWorkDecisionOf, taskWorkSummaryOf, workDecisionAction } from "./work-summary.js";
12
12
  import { evidenceRoot, readVerifiedArtifact, readVerifiedReport, verifyArtifact } from "./evidence.js";
13
13
  import { sweepIfMoved } from "./sweep-gate.js";
@@ -119,6 +119,10 @@ function buildChecks(store, root, runId) {
119
119
  if (receipt === null)
120
120
  return { ...unavailable(level === "off" ? "Checks were off for this build." : "No machine check is recorded."), status: "not-run" };
121
121
  const ran = receipt.result.ran === true, exitCode = ran ? receipt.result.exitCode : null;
122
+ // A release check whose coverage fell short (release-once.md) failed, whatever its command exited with.
123
+ if (receipt.result.failure === "coverage-incomplete")
124
+ return { status: "failed", exitCode: null, command: receipt.command.command, logArtifactId: receipt.log.artifactId, level,
125
+ detail: "Checks didn't cover everything this release owed, or a required test went missing." };
122
126
  // The sealed receipt names the grant that ran: the quick one, or the full one.
123
127
  const sealedLevel = typeof receipt.command?.repo === "string" && receipt.command.repo.startsWith("quick:") ? "quick" : "full";
124
128
  const words = sealedLevel === "quick" ? "Quick checks" : "Checks";
@@ -450,8 +454,19 @@ function acknowledgeCurrent(store, current, receiptDigest, actor, now, root, acc
450
454
  if (!/^[a-f0-9]{64}$/.test(receiptDigest) || current.receipt?.digest !== receiptDigest)
451
455
  return { ok: false, reason: "stale", message: "The result changed. Read the current assignment before checking it." };
452
456
  const receipt = current.receipt;
457
+ // The nightly check of main is a check only (release-once.md, part 3): nobody completes it.
458
+ if (store.handle.prepare("SELECT 1 AS hit FROM nightly_check WHERE task = ?").get(current.receipt.taskId) !== undefined)
459
+ return { ok: false, reason: "check-only", message: "The nightly check of main is only a check; it isn't completed." };
453
460
  if (current.state !== "ready-to-check" && current.state !== "complete")
454
461
  return { ok: false, reason: "not-ready", message: "This assignment still has unresolved execution, scope or decisions." };
462
+ // A release task completes only while its release approval stands for it (release-once.md, liveReleaseOf).
463
+ const release = liveReleaseOf(store, current.receipt.taskId, "complete", now);
464
+ if (release.applies && !release.ok)
465
+ return { ok: false, reason: "release", message: `This release can't be completed: ${release.why}.` };
466
+ // Who is completing, from trusted context (approval policy §1.7a): a person, or a machine credential (the lead's
467
+ // token, a remote API run) that may complete ordinary work but is never the second person on protected work.
468
+ const person = actor.startsWith("operator:") && !machineActing() ? actor.slice("operator:".length) : null;
469
+ const marked = actor.startsWith("operator:") ? (person === null ? COMPLETION_BY_MACHINE : COMPLETION_BY_PERSON) : null;
455
470
  if (current.state !== "complete") {
456
471
  // The one readiness guard: a failed or missing required check, an unresolved requirement or HIGH finding refuses.
457
472
  const blockers = current.readiness?.blockers ?? [];
@@ -459,11 +474,11 @@ function acknowledgeCurrent(store, current, receiptDigest, actor, now, root, acc
459
474
  return { ok: false, reason: "not-ready", message: blockers.map(one => one.message).join(" ") };
460
475
  // v102: declared paths are only a promise. A result whose actual diff reaches protected files, on a
461
476
  // scope one person approved, completes only by someone else — two people have then seen the work.
462
- const problem = store.protectedResultProblem(receipt.taskId, familyChangedFiles(store, receipt.taskId, receipt.runId, root), actor.startsWith("operator:") ? actor.slice("operator:".length) : null);
477
+ const problem = store.protectedResultProblem(receipt.taskId, familyChangedFiles(store, receipt.taskId, receipt.runId, root), person);
463
478
  if (problem !== null)
464
479
  return { ok: false, reason: "approval-rules", message: problem };
465
480
  store.recordAction({ at: now.toISOString(), actor, repo: current.repo,
466
- taskId: current.rootId, runId: receipt.runId, action: CHECK_ACTION, outcome: receiptDigest, source: "work" });
481
+ taskId: current.rootId, runId: receipt.runId, action: CHECK_ACTION, outcome: receiptDigest, source: "work", detail: marked });
467
482
  const ref = store.lookupRef(current.rootId);
468
483
  // Whose act this is, even outside a command that named its person: the operator who marked it.
469
484
  const by = currentActor() ?? (actor.startsWith("operator:") ? { account: actor.slice("operator:".length), lead: false } : null);
@@ -471,6 +486,13 @@ function acknowledgeCurrent(store, current, receiptDigest, actor, now, root, acc
471
486
  store.noteTaskAct(ref.id, "completed", now, undefined, by);
472
487
  store.bumpWake();
473
488
  }
489
+ else if (person !== null && personCompletionOwed(store, receipt.taskId, receipt.runId, receiptDigest, person, root)) {
490
+ // Already complete, but no person's completion is on record that lets protected work publish (a machine's, or one
491
+ // from before completions said who made them): this person's completion is recorded now (approval policy §1.7a).
492
+ store.recordAction({ at: now.toISOString(), actor, repo: current.repo,
493
+ taskId: current.rootId, runId: receipt.runId, action: CHECK_ACTION, outcome: receiptDigest, source: "work", detail: COMPLETION_BY_PERSON });
494
+ store.bumpWake();
495
+ }
474
496
  const assignment = assignmentOf(store, current.rootId, now, access, root);
475
497
  noteAssignmentStatus(store, assignment, now);
476
498
  return { ok: true, assignment };