toolroll 0.9.70 → 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 (91) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/dist/agent-fence.d.ts +7 -0
  3. package/dist/agent-fence.js +10 -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-report.d.ts +109 -0
  11. package/dist/check-report.js +204 -0
  12. package/dist/claim.js +1 -1
  13. package/dist/commit-extract.d.ts +94 -0
  14. package/dist/commit-extract.js +436 -0
  15. package/dist/contracts/console-api.d.ts +1 -1
  16. package/dist/contracts/console-api.js +1 -0
  17. package/dist/contracts/flow.d.ts +16 -0
  18. package/dist/contracts/flow.js +3 -0
  19. package/dist/contracts/release-check.d.ts +335 -0
  20. package/dist/contracts/release-check.js +159 -0
  21. package/dist/contracts/release-terms.d.ts +60 -0
  22. package/dist/contracts/release-terms.js +70 -0
  23. package/dist/contracts/verification-receipt.d.ts +42 -8
  24. package/dist/contracts/verification-receipt.js +24 -7
  25. package/dist/contracts/verification-view.d.ts +1 -0
  26. package/dist/deploy-gate.d.ts +17 -0
  27. package/dist/deploy-gate.js +68 -0
  28. package/dist/dispatch.js +3 -10
  29. package/dist/dispose.js +4 -1
  30. package/dist/evidence-pack.d.ts +1 -1
  31. package/dist/evidence-pack.js +20 -2
  32. package/dist/evidence.d.ts +1 -0
  33. package/dist/evidence.js +2 -1
  34. package/dist/flow-engine.js +5 -1
  35. package/dist/flow-pull-request.d.ts +5 -2
  36. package/dist/flow-pull-request.js +14 -5
  37. package/dist/flow-schedule.js +25 -6
  38. package/dist/flows-ui.js +7 -1
  39. package/dist/invoke.d.ts +19 -1
  40. package/dist/invoke.js +49 -2
  41. package/dist/lead-status.js +5 -2
  42. package/dist/live-terminal.d.ts +10 -6
  43. package/dist/live-terminal.js +55 -14
  44. package/dist/nightly-check.d.ts +104 -0
  45. package/dist/nightly-check.js +362 -0
  46. package/dist/operate.js +39 -2
  47. package/dist/person-proof.d.ts +2 -0
  48. package/dist/person-proof.js +14 -0
  49. package/dist/plan-auto.js +4 -4
  50. package/dist/planner-source.d.ts +1 -1
  51. package/dist/proof.js +2 -0
  52. package/dist/publish.js +156 -144
  53. package/dist/pull-request-flow.d.ts +2 -0
  54. package/dist/pull-request-flow.js +24 -2
  55. package/dist/release-adapters.d.ts +8 -0
  56. package/dist/release-adapters.js +212 -15
  57. package/dist/release-authority.d.ts +112 -0
  58. package/dist/release-authority.js +126 -0
  59. package/dist/release-carry.d.ts +233 -0
  60. package/dist/release-carry.js +645 -0
  61. package/dist/release-cli.d.ts +3 -0
  62. package/dist/release-cli.js +124 -5
  63. package/dist/release-coverage.js +3 -0
  64. package/dist/release-recheck.d.ts +146 -0
  65. package/dist/release-recheck.js +657 -0
  66. package/dist/release-view.d.ts +34 -0
  67. package/dist/release-view.js +27 -0
  68. package/dist/release.d.ts +131 -1
  69. package/dist/release.js +562 -244
  70. package/dist/remote-main.d.ts +2 -0
  71. package/dist/remote-main.js +1 -1
  72. package/dist/result-completion.d.ts +6 -0
  73. package/dist/result-completion.js +15 -1
  74. package/dist/result-follow-ups.js +29 -1
  75. package/dist/sandbox-runtime.d.ts +59 -1
  76. package/dist/sandbox-runtime.js +159 -5
  77. package/dist/scope.d.ts +115 -5
  78. package/dist/scope.js +219 -7
  79. package/dist/server/render-tasks.d.ts +8 -0
  80. package/dist/server/render-tasks.js +50 -9
  81. package/dist/server/route-table.js +1 -0
  82. package/dist/server/task-views.js +4 -0
  83. package/dist/server/tasks.js +12 -0
  84. package/dist/store.d.ts +227 -5
  85. package/dist/store.js +727 -37
  86. package/dist/task-outcome-cli.js +7 -3
  87. package/dist/toolroll-update.js +2 -2
  88. package/dist/verification-evidence.d.ts +25 -1
  89. package/dist/verification-evidence.js +57 -2
  90. package/dist/work-index.js +3 -6
  91. package/package.json +1 -1
@@ -0,0 +1,362 @@
1
+ /**
2
+ * The nightly full check on main (docs/plans/release-once.md, part 3): a safety net for accidental omissions, not a
3
+ * deadline.
4
+ *
5
+ * - A scheduled flow (flow-schedule.ts) whose standing order names `check: main-full`. A person approves it once, with
6
+ * their password: the approval binds the release check grant (id and digest), the fenced check access and the
7
+ * check-only capability, for future main commits. Each firing files `nightly-<date>-<sha7>` at main's head — never a
8
+ * release branch — sealed with basis `schedule`; that task can't deploy, merge or complete.
9
+ * - A failing nightly writes one `main_check_failure` row per failed item (each failed identity, each group or version,
10
+ * or the whole part for a part-level outcome) and sends one attention notification.
11
+ * - A row clears only when a qualifying full pass covers that exact item: intended mode full and coverage full, on a
12
+ * commit that contains the failed main commit (verified ancestry), the item or its whole part ran and passed there.
13
+ * A composed result never clears anything; a pass that covers one item never clears another.
14
+ * - Open rows refuse targeted planning and block the deploy and merge of a composed release (scope.ts liveReleaseOf).
15
+ * - A missed, stalled or disabled nightly gives no signal but its flow's own health notice after 26 hours.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+ import { projectGitDir } from "./check-report.js";
19
+ import { readVerifiedCommit, verifiedFirstParentWalk } from "./commit-extract.js";
20
+ import { fileIdentity, groupIdentity, journeyIdentity, partIdentity, unitIdentity, versionIdentity } from "./contracts/release-check.js";
21
+ import { propose, authenticateApprover, liveReleaseOf, personProof } from "./scope.js";
22
+ import { failedIdentities, sealedRun } from "./release-recheck.js";
23
+ import { verificationEvidence } from "./verification-evidence.js";
24
+ import { BUILT_IN, TRUSTED_MAIN_ACTION, forgetTrustedMain, recordTrustedMain, trustedRemoteIdentity } from "./store.js";
25
+ import { teardownGit } from "./teardown-git.js";
26
+ /** Where the project's Git store keeps the objects of the remote main the plane read (they matter, the ref doesn't). */
27
+ const NIGHTLY_MAIN_REF = "refs/toolroll/remote-main";
28
+ import { RemoteMains } from "./remote-main.js";
29
+ import { run } from "./exec.js";
30
+ /** How long a nightly may be missing before its flow says so. */
31
+ export const NIGHTLY_MISSED_MS = 26 * 60 * 60_000;
32
+ export const NIGHTLY_STEM = "nightly";
33
+ /** What every nightly firing files: fixed here, never read from the trigger's settings. */
34
+ export const NIGHTLY_GOAL = "Run the full release check on main's head.";
35
+ export const NIGHTLY_NOT = "Changing anything: this is a check only.";
36
+ export const NIGHTLY_ACCEPTANCE = Object.freeze([{ id: "c1", statement: "The full release check passes on main", how: null, evidence: ["check"] }]);
37
+ /** The ledger act that records a person's approval of a nightly check, keyed by its trigger. */
38
+ export const NIGHTLY_APPROVED = "nightly check approved";
39
+ /** The digest a person's approval of the nightly signs: the trigger, the project, the schedule, the check-only terms
40
+ * and the fixed words each firing files. */
41
+ export function nightlyDigestOf(trigger, repo, schedule, check) {
42
+ return createHash("sha256").update(`nightly-check:${JSON.stringify({ trigger, repo, schedule, check, goal: NIGHTLY_GOAL, not: NIGHTLY_NOT, acceptance: NIGHTLY_ACCEPTANCE })}`).digest("hex").slice(0, 32);
43
+ }
44
+ /**
45
+ * The approval of a nightly check as the plane recorded it when a person proved themselves (createNightlyCheckFlow):
46
+ * the newest ledger row for this trigger. The trigger's own settings never stand in for it — anyone who can edit them
47
+ * could write one.
48
+ */
49
+ export function nightlyApprovalOf(store, trigger) {
50
+ for (const row of store.handle.prepare("SELECT outcome, detail FROM action_ledger WHERE action = ? ORDER BY id DESC LIMIT 20").all(NIGHTLY_APPROVED)) {
51
+ try {
52
+ const read = JSON.parse(String(row["detail"]));
53
+ if (read["v"] !== 1 || read["trigger"] !== trigger)
54
+ continue;
55
+ if (typeof read["digest"] !== "string" || typeof read["by"] !== "string" || typeof read["proof"] !== "string" || typeof read["at"] !== "string" || !personProof(read["proof"]))
56
+ return null;
57
+ return { digest: read["digest"], by: read["by"], proof: read["proof"], at: read["at"] };
58
+ }
59
+ catch {
60
+ continue;
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+ /** How often the watch loop reads each recorded remote's main again, and how old a read may be when a nightly fires
66
+ * or debt is cleared against it (review round 2: one window, two refreshes wide, for both decisions). */
67
+ export const TRUSTED_MAIN_REFRESH_MS = 30 * 60_000;
68
+ export const TRUSTED_MAIN_FIRE_MS = 60 * 60_000;
69
+ export const TRUSTED_MAIN_CLEAR_MS = TRUSTED_MAIN_FIRE_MS;
70
+ /**
71
+ * Main's head as Toolroll's own process last read it from the project's RECORDED remote (remote-main.ts: the recorded
72
+ * URL, a private plane-owned Git directory, objects that arrive only by fetch) — never a ref in the project's Git
73
+ * directory, which a build shares and can move (review round 1). Null when there is no read young enough, when the
74
+ * last read failed, or when the read was of another remote than the one recorded now (review round 2).
75
+ */
76
+ export function trustedMainOf(store, repo, now, maxAgeMs) {
77
+ const row = store.handle.prepare("SELECT outcome, at, detail FROM action_ledger WHERE repo = ? AND action = ? ORDER BY id DESC LIMIT 1").get(repo, TRUSTED_MAIN_ACTION);
78
+ if (row === undefined || now.getTime() - Date.parse(String(row["at"])) > maxAgeMs)
79
+ return null;
80
+ const sha = String(row["outcome"]);
81
+ if (!/^[0-9a-f]{40}$/.test(sha))
82
+ return null;
83
+ const identity = trustedRemoteIdentity(store, repo);
84
+ try {
85
+ const read = JSON.parse(String(row["detail"]));
86
+ if (read.v !== 2 || read.sha !== sha || identity === null || read.url !== identity.url || read.record !== identity.record)
87
+ return null;
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ return sha;
93
+ }
94
+ /** The watch loop's read of each project's recorded remote main that the nightly check or main's debt needs, at most
95
+ * every 30 minutes. Each read is a fresh fetch into a private directory. A failed read forgets the last one. A good
96
+ * one hands its verified objects to the project's Git store (hardened git, content-addressed: git checks every object
97
+ * against its hash, and filing verifies the commit again), so the nightly can check a merge the checkout never pulled. */
98
+ export async function refreshTrustedMains(store, root, now, runner = run) {
99
+ const repos = new Set([
100
+ ...store.handle.prepare("SELECT DISTINCT repo FROM main_check_failure WHERE cleared_at IS NULL").all().map(row => String(row["repo"])),
101
+ ...store.handle.prepare("SELECT DISTINCT repo FROM action_ledger WHERE action = ?").all(NIGHTLY_APPROVED).map(row => String(row["repo"])),
102
+ ]);
103
+ let read = 0;
104
+ const remotes = new RemoteMains(store, runner, root);
105
+ try {
106
+ for (const repo of repos) {
107
+ if (trustedMainOf(store, repo, now, TRUSTED_MAIN_REFRESH_MS) !== null)
108
+ continue;
109
+ const got = await remotes.read(repo);
110
+ if (!got.ok) {
111
+ forgetTrustedMain(store, repo, got.why, now);
112
+ continue;
113
+ }
114
+ await teardownGit(runner)(repo, ["-c", "protocol.file.allow=always", "fetch", "--quiet", "--no-tags", "--no-write-fetch-head", "--", got.remote.gitDir, `+refs/toolroll/remote-main:${NIGHTLY_MAIN_REF}`]);
115
+ if (recordTrustedMain(store, repo, got.remote.main, got.remote.url, now))
116
+ read++;
117
+ }
118
+ }
119
+ finally {
120
+ remotes.close();
121
+ }
122
+ return read;
123
+ }
124
+ /**
125
+ * Set up the nightly full check for a project, approved by a person proved now (their password or a re-check; never a
126
+ * lead or remote token). Its schedule starts paused unless `start`; turning it on is the flow's own switch.
127
+ */
128
+ export function createNightlyCheckFlow(store, input, now) {
129
+ const proved = authenticateApprover(store, input.as, input.token, input.repo);
130
+ if (!proved.ok || !personProof(proved.method))
131
+ return { ok: false, message: "Only an approver of this project, proved as a person, sets up the nightly check." };
132
+ const grant = store.liveVerifyCommand(input.repo);
133
+ if (grant === null)
134
+ return { ok: false, message: "The project has no approved release check to run." };
135
+ if (store.projectCheckAccess(input.repo) !== "sandboxed")
136
+ return { ok: false, message: "The project's checks run outside the sandbox; the nightly check runs only inside it." };
137
+ const check = { kind: "main-full", grant: { id: grant.id, digest: grant.digest }, access: "sandboxed" };
138
+ // The order carries no approval: the person's yes is the plane's own ledger row, written below.
139
+ const order = {
140
+ stem: NIGHTLY_STEM, goal: NIGHTLY_GOAL, outOfScope: NIGHTLY_NOT, touches: [], requirements: [], acceptance: [...NIGHTLY_ACCEPTANCE], budgetPerRunMicrousd: null, costCeilingUsd: null,
141
+ singleFlight: true, filedBy: input.as, routine: null, approval: null, check,
142
+ };
143
+ const definition = { version: 1, start: "build", stages: [
144
+ { id: "build", title: "Check main", kind: "task", zone: { x: 0, y: 0, w: 260, h: 300, color: "blue" }, instructions: order.goal, planning: "skip", approver: null, message: null, close: null, script: null, sort: null, next: "done", onFail: null },
145
+ { id: "done", title: "Done", kind: "done", zone: { x: 420, y: 0, w: 260, h: 300, color: "green" }, instructions: null, planning: null, approver: null, message: null, close: null, script: null, sort: null, next: null, onFail: null },
146
+ ] };
147
+ return store.transact(() => {
148
+ const flow = store.createFlow({ repo: input.repo, name: "Nightly check of main", definitionJson: JSON.stringify(definition), by: input.as }, now);
149
+ const trigger = store.addFlowTrigger({ flow, kind: "schedule", configJson: JSON.stringify({ kind: "schedule", schedule: input.schedule, title: "Nightly check of main", description: null, zone: "build", order }), hookHash: null, cursor: null, nextAt: null, by: input.as }, now);
150
+ if (!input.start)
151
+ store.updateFlowTrigger(trigger, { state: "paused" }, now);
152
+ store.recordAction({ at: now.toISOString(), actor: input.as, repo: input.repo, taskId: null, runId: null, action: NIGHTLY_APPROVED, outcome: input.start ? "on" : "paused", source: "request",
153
+ detail: JSON.stringify({ v: 1, trigger, flow, digest: nightlyDigestOf(trigger, input.repo, input.schedule, check), by: input.as, proof: proved.method, at: now.toISOString(), schedule: input.schedule, grant: grant.id }) });
154
+ return { ok: true, flow, trigger };
155
+ });
156
+ }
157
+ /** A nightly firing (fileStandingOrder routes here): the approval checked again, main's head read, one check-only task
158
+ * filed and sealed by its standing order. */
159
+ export function fileNightlyCheck(store, input, now) {
160
+ const { order, repo } = input;
161
+ const check = order.check;
162
+ // The approval is the plane's own record of the person's yes for this trigger; anything in the settings is ignored.
163
+ const approval = nightlyApprovalOf(store, input.trigger);
164
+ if (approval === null || approval.digest !== nightlyDigestOf(input.trigger, repo, input.schedule, check))
165
+ return { made: "skipped", note: "Skipped: the nightly check isn't approved as it stands. Set it up again." };
166
+ const account = store.accountOf(approval.by);
167
+ if (account === null || account.revokedAt !== null || account.role !== "approver" || !store.accountCanAccess(approval.by, repo))
168
+ return { made: "skipped", note: `Skipped: ${approval.by} can no longer approve in this project.` };
169
+ const grant = store.liveVerifyCommand(repo);
170
+ if (grant === null || grant.id !== check.grant.id || grant.digest !== check.grant.digest)
171
+ return { made: "skipped", note: "Skipped: the release check changed since the nightly check was approved." };
172
+ if (store.projectCheckAccess(repo) !== "sandboxed")
173
+ return { made: "skipped", note: "Skipped: the project's checks run outside the sandbox." };
174
+ const blocker = store.standingOrderBlocker(input.trigger, order.routine, now);
175
+ if (blocker !== null)
176
+ return { made: "skipped", note: `Skipped: the last one (${blocker.taskId}, ${blocker.state}) hasn't finished.` };
177
+ // Main as the plane read it from the recorded remote, recently; never a ref in the shared Git directory.
178
+ const trusted = trustedMainOf(store, repo, now, TRUSTED_MAIN_FIRE_MS);
179
+ if (trusted === null)
180
+ return { made: "skipped", note: "Skipped: main hasn't been read from the project's recorded remote lately." };
181
+ const gitDir = projectGitDir(repo);
182
+ if (gitDir === null || !readVerifiedCommit(gitDir, trusted).ok)
183
+ return { made: "skipped", note: `Skipped: main's ${trusted.slice(0, 7)} isn't in the project's history yet.` };
184
+ const head = { sha: trusted };
185
+ const taskId = `${NIGHTLY_STEM}-${now.toISOString().slice(0, 10).replace(/-/g, "")}-${head.sha.slice(0, 7)}`;
186
+ if (store.getTask(taskId) !== null)
187
+ return { made: "skipped", note: `Skipped: ${head.sha.slice(0, 7)} was already checked today (${taskId}).` };
188
+ store.createTask({ id: taskId, title: `Nightly check of main at ${head.sha.slice(0, 7)}`, filedBy: { name: null, kind: "automation" } }, now);
189
+ const ref = store.refFor(BUILT_IN, taskId, "ours");
190
+ store.placeTask(ref.id, repo, {}, now);
191
+ store.recordAction({ at: now.toISOString(), actor: "toolroll", repo, taskId, runId: null, action: "task checks chosen", outcome: "full", source: "request", detail: "Checks for this task: Full" });
192
+ // Fixed words, never the trigger's: what the person approved is what files.
193
+ propose(store, { taskId, goal: NIGHTLY_GOAL, outOfScope: NIGHTLY_NOT, candidate: head.sha, acceptance: [...NIGHTLY_ACCEPTANCE], now });
194
+ const sealed = store.sealScopeApproval(taskId, approval.by, now, {}, { kind: "schedule", modeDigest: approval.digest }, "automation");
195
+ store.handle.prepare("INSERT INTO nightly_check (repo, sha, task, at) VALUES (?, ?, ?, ?)").run(repo, head.sha, taskId, now.toISOString());
196
+ return sealed ? { made: "filed", taskId, approved: true, note: `Checking main at ${head.sha.slice(0, 7)}.` }
197
+ : { made: "filed", taskId, approved: false, note: "Its approval can't stand on its own here, so this one waits for approval." };
198
+ }
199
+ /** Whether a task is a nightly check of main (check only: it never deploys, merges or completes). */
200
+ export const isNightlyTask = (store, taskId) => store.handle.prepare("SELECT 1 AS hit FROM nightly_check WHERE task = ?").get(taskId) !== undefined;
201
+ /** Every item a failed check failed: each failed identity, each failed group or version, and the whole part for a
202
+ * part that couldn't say which items (it stopped, never reported, or crashed). No report: every part. */
203
+ export function failedItems(report) {
204
+ if (report === null)
205
+ return [partIdentity("all")];
206
+ const items = new Set(failedIdentities(report));
207
+ for (const part of report.parts) {
208
+ if (!part.selected)
209
+ continue;
210
+ const attributed = part.part === "unit" ? report.units !== null && report.units.finished && report.units.runErrors === 0
211
+ : part.part === "upgrade" ? report.upgrades !== null && report.upgrades.finished
212
+ : part.part === "typecheck" || part.part === "build" ? false
213
+ : report.journeys.some(one => (one.kind === "real" ? `${one.script}-real` : one.script) === part.part && one.finished && one.groups.every(group => group.state !== "not-run"));
214
+ if ((!part.ran || part.code !== 0) && (!attributed || part.part === "typecheck" || part.part === "build"))
215
+ items.add(partIdentity(part.part));
216
+ }
217
+ return [...items].sort();
218
+ }
219
+ /** The words of one failure notice: "2 unit files, app/pages". */
220
+ export function failureSummary(items) {
221
+ const files = new Set(items.flatMap(one => /^unit:(.+?)::/.exec(one)?.[1] ?? /^file:(.+)$/.exec(one)?.[1] ?? []));
222
+ const groups = new Set(items.flatMap(one => { const m = /^(?:group|journey):(app|flows):(?:scripted|real):([^:]+?)(?:::|$)/.exec(one); return m === null ? [] : [`${m[1]}/${m[2]}`]; }));
223
+ const versions = items.flatMap(one => /^upgrade:(.+)$/.exec(one)?.[1] ?? []);
224
+ const parts = items.flatMap(one => /^part:(.+)$/.exec(one)?.[1] ?? []);
225
+ return [
226
+ ...(files.size === 0 ? [] : [`${files.size} unit file${files.size === 1 ? "" : "s"}`]), ...groups,
227
+ ...(versions.length === 0 ? [] : [`upgrade ${versions.join(", ")}`]), ...parts.map(one => one === "all" ? "the whole check" : `the ${one} part`),
228
+ ].join(", ") || "the check";
229
+ }
230
+ /**
231
+ * Whether a full run's report shows `item` itself ran and PASSED there (security review): a failed identity has to be
232
+ * reported passed by name — missing, skipped, or a passing part around it never clears it. A part-level item clears
233
+ * when that whole part ran and passed.
234
+ */
235
+ export function coversItem(report, item) {
236
+ const part = (name) => report.parts.find(one => one.part === name);
237
+ const passedPart = (name) => { const one = part(name); return one !== undefined && one.ran && one.code === 0; };
238
+ const unit = /^unit:(.+?)::(.+)$/s.exec(item);
239
+ if (unit !== null)
240
+ return report.units?.identities.some(one => one.file === unit[1] && one.name === unit[2] && one.state === "passed") === true;
241
+ const file = /^file:(.+)$/.exec(item)?.[1];
242
+ if (file !== undefined)
243
+ return report.units?.files.some(one => one.file === file && one.state === "passed" && one.failed === 0) === true;
244
+ const journey = /^journey:(app|flows):(scripted|real):([^:]+?)::(.+)$/s.exec(item);
245
+ if (journey !== null) {
246
+ const found = report.journeys.find(one => one.script === journey[1] && one.kind === journey[2])?.groups.find(one => one.group === journey[3]);
247
+ return found?.journeys.some(one => one.name === journey[4] && one.state === "passed") === true;
248
+ }
249
+ const group = /^group:(app|flows):(scripted|real):(.+)$/.exec(item);
250
+ if (group !== null) {
251
+ const found = report.journeys.find(one => one.script === group[1] && one.kind === group[2])?.groups.find(one => one.group === group[3]);
252
+ return found !== undefined && found.state === "passed" && !found.unattributed;
253
+ }
254
+ const version = /^upgrade:(.+)$/.exec(item)?.[1];
255
+ if (version !== undefined)
256
+ return report.upgrades?.versions.some(one => one.version === version && one.state === "passed") === true;
257
+ const whole = /^part:(.+)$/.exec(item)?.[1];
258
+ if (whole === "all")
259
+ return report.parts.filter(one => one.selected).every(one => one.ran && one.code === 0) && report.exitCode === 0;
260
+ return whole !== undefined && passedPart(whole);
261
+ }
262
+ /**
263
+ * The plane's sweep (watch's follow-up checks): settle each finished nightly (failure rows and one attention notice),
264
+ * then let each finished full release check clear the main failures it covers. Returns how many it settled.
265
+ */
266
+ export function settleReleaseChecks(store, root, now) {
267
+ let settled = 0;
268
+ for (const row of store.handle.prepare("SELECT * FROM nightly_check WHERE outcome IS NULL AND task IS NOT NULL ORDER BY id").all()) {
269
+ const ref = store.lookupRef(String(row["task"]));
270
+ if (ref === null)
271
+ continue;
272
+ const runs = store.runsFor(ref.id).filter(one => one.role === "builder" && one.finishedAt !== null).sort((a, b) => b.id - a.id);
273
+ const run = runs[0];
274
+ if (run === undefined)
275
+ continue;
276
+ const check = store.handle.prepare("SELECT status FROM run_check WHERE run = ?").get(run.id);
277
+ const status = check === undefined ? (run.outcome === "built" ? null : "failed") : String(check["status"]);
278
+ if (status === null || status === "running" || status === "pending")
279
+ continue;
280
+ const repo = String(row["repo"]), sha = String(row["sha"]);
281
+ store.transact(() => {
282
+ store.handle.prepare("UPDATE nightly_check SET run = ?, outcome = ? WHERE id = ?").run(run.id, status, Number(row["id"]));
283
+ if (status !== "passed") {
284
+ const items = failedItems(sealedRun(store, root, run.id)?.report ?? null);
285
+ for (const item of items)
286
+ store.handle.prepare("INSERT OR IGNORE INTO main_check_failure (repo, sha, run, item, opened_at) VALUES (?, ?, ?, ?, ?)").run(repo, sha, run.id, item, now.toISOString());
287
+ store.enqueueNotification({ dedupeKey: `nightly-failed:${Number(row["id"])}`, kind: "nightly-failed", subject: "Nightly check failed on main",
288
+ body: `Nightly full check failed on main at ${sha.slice(0, 7)}: ${failureSummary(items)}.`, pushClass: "attention", source: { taskRef: ref.id } }, now);
289
+ }
290
+ });
291
+ settled++;
292
+ }
293
+ for (const row of store.handle.prepare("SELECT a.run, a.mode, r.task_ref, r.head_revision, r.finished_at FROM release_check_attempt a JOIN run r ON r.id = a.run WHERE a.settled_at IS NULL AND r.finished_at IS NOT NULL ORDER BY a.run").all()) {
294
+ const runId = Number(row["run"]);
295
+ const repo = store.refById(Number(row["task_ref"]))?.repo ?? null;
296
+ store.transact(() => {
297
+ store.handle.prepare("UPDATE release_check_attempt SET settled_at = ? WHERE run = ?").run(now.toISOString(), runId);
298
+ if (repo === null || row["mode"] !== "full" || row["head_revision"] == null)
299
+ return;
300
+ // Only a full pass with coverage full clears anything; a composed result never does.
301
+ const gate = verificationEvidence(store, root, runId);
302
+ if (!gate.ok || gate.bytes === null)
303
+ return;
304
+ // A SUCCESSFUL executed full check only (review round 1): its receipt says it ran and passed, its coverage is full,
305
+ // and its own report exited 0. A failed full check clears nothing, whatever items it reports passed.
306
+ let receipt;
307
+ try {
308
+ receipt = JSON.parse(gate.bytes);
309
+ }
310
+ catch {
311
+ return;
312
+ }
313
+ if (receipt.coverage !== "full" || receipt.result?.ran !== true || receipt.result.exitCode !== 0)
314
+ return;
315
+ const report = sealedRun(store, root, runId)?.report ?? null;
316
+ if (report === null || report.exitCode !== 0)
317
+ return;
318
+ const gitDir = projectGitDir(repo);
319
+ if (gitDir === null)
320
+ return;
321
+ const head = String(row["head_revision"]);
322
+ // The pass has to be on main (its head an ancestor of, or main's head itself) or the exact candidate of a release
323
+ // whose approval stands: a pass on a side branch clears nothing (security review).
324
+ const task = store.refById(Number(row["task_ref"]))?.externalId ?? null;
325
+ const main = trustedMainOf(store, repo, now, TRUSTED_MAIN_CLEAR_MS);
326
+ const onMain = main !== null && (head === main || verifiedFirstParentWalk(gitDir, main, head, 5000).ok);
327
+ const release = task === null ? null : liveReleaseOf(store, task, "dispatch", now);
328
+ const candidate = task === null ? null : store.getScope(task)?.candidate ?? null;
329
+ // The run must have checked that very candidate: its head is the candidate, or the commit a prepared build recorded
330
+ // for it, holding exactly the candidate's verified tree.
331
+ const sameTree = (a, b) => { const x = readVerifiedCommit(gitDir, a), y = readVerifiedCommit(gitDir, b); return x.ok && y.ok && x.value.tree === y.value.tree; };
332
+ const releaseTip = release !== null && release.applies && release.ok && candidate !== null && release.authority.tipSha === candidate
333
+ && (head === candidate || sameTree(head, candidate));
334
+ if (!onMain && !releaseTip)
335
+ return;
336
+ for (const failure of store.handle.prepare("SELECT id, sha, item FROM main_check_failure WHERE repo = ? AND cleared_at IS NULL").all(repo)) {
337
+ const sha = String(failure["sha"]);
338
+ const contains = sha === head || verifiedFirstParentWalk(gitDir, head, sha, 2000).ok;
339
+ if (!contains || !coversItem(report, String(failure["item"])))
340
+ continue;
341
+ store.handle.prepare("UPDATE main_check_failure SET cleared_by_run = ?, cleared_at = ? WHERE id = ? AND cleared_at IS NULL").run(runId, now.toISOString(), Number(failure["id"]));
342
+ store.recordAction({ at: now.toISOString(), actor: "toolroll", repo, taskId: null, runId, action: "main failure cleared", outcome: "cleared", source: "work", detail: `${String(failure["item"])} at ${sha.slice(0, 7)} · by run ${runId}` });
343
+ }
344
+ });
345
+ settled++;
346
+ }
347
+ return settled;
348
+ }
349
+ /** The nightly's flow health: "the nightly check hasn't run since …" after 26 hours without a firing. Null when it ran
350
+ * recently, or the project has no nightly check. */
351
+ export function nightlyHealthWords(store, repo, configuredAt, now) {
352
+ const last = store.handle.prepare("SELECT at FROM nightly_check WHERE repo = ? ORDER BY id DESC LIMIT 1").get(repo);
353
+ const since = last === undefined ? configuredAt : String(last["at"]);
354
+ if (now.getTime() - Date.parse(since) < NIGHTLY_MISSED_MS)
355
+ return null;
356
+ return last === undefined ? `the nightly check hasn't run since it was set up (${since.slice(0, 16).replace("T", " ")})` : `the nightly check hasn't run since ${since.slice(0, 16).replace("T", " ")}`;
357
+ }
358
+ export { fileIdentity, groupIdentity, journeyIdentity, unitIdentity, versionIdentity };
359
+ /** The items the nightly check of main found failing that are still open: a full check reports each by name. */
360
+ export function openMainFailureItems(store, repo) {
361
+ return store.handle.prepare("SELECT DISTINCT item FROM main_check_failure WHERE repo = ? AND cleared_at IS NULL ORDER BY item").all(repo).map(row => String(row["item"]));
362
+ }
package/dist/operate.js CHANGED
@@ -56,6 +56,8 @@ import { readHooksBase, scheduleFromWords } from "./flow-triggers.js";
56
56
  import { sendSubagentSummaries } from "./subagent-admin.js";
57
57
  import { runRequestedUndos, sendSubagentWeeklies } from "./subagent-week.js";
58
58
  import { refreshConnections } from "./mcp-connect.js";
59
+ import { releaseTermsFor } from "./release-carry.js";
60
+ import { refreshTrustedMains, settleReleaseChecks } from "./nightly-check.js";
59
61
  import { SECRETS_FIRST, startToolSecrets, toolSecretStorage } from "./project-tools.js";
60
62
  import { followDiscord } from "./discord.js";
61
63
  import { followTeams } from "./teams.js";
@@ -192,7 +194,7 @@ import { register, authenticate, heartbeat as heartbeatRunner, isAlive, recoverD
192
194
  import { mintCoordinator, revokeCoordinator, listCoordinators } from "./coordinator.js";
193
195
  import { serveMcp } from "./mcp.js";
194
196
  import { createInterface } from "node:readline";
195
- import { propose, approve, addApprover, authenticateAccount, authenticateApprover as passwordApprover, describeScope, approvalOf, hashToken as hashApproverToken, modeFilingCoverage, acceptanceLinesToInput, parseAcceptanceCriteria, splitAcceptanceRubric, rubricIsPlaceholder, isCommitSha, credentialProof, rememberedLogin } from "./scope.js";
197
+ import { propose, approve, addApprover, authenticateAccount, authenticateApprover as passwordApprover, describeScope, approvalOf, hashToken as hashApproverToken, modeFilingCoverage, acceptanceLinesToInput, parseAcceptanceCriteria, splitAcceptanceRubric, rubricIsPlaceholder, isCommitSha, credentialProof, rememberedLogin, personApprovalOf } from "./scope.js";
196
198
  import { CHAT_APPROVE_ALL, presetTerms, modeTermsJson, modeDigestOf, modeTermsFromJson, modeWords, MODE_MAX_DAYS } from "./modes.js";
197
199
  import { WorktreePool } from "./worktree.js";
198
200
  import { baseStandingWords, freshBaseFor, taskBaseOf } from "./task-base.js";
@@ -694,8 +696,12 @@ export const OPERATE_VALUE_FLAGS = new Set([
694
696
  "request",
695
697
  // project sandbox (sandbox parity C9): read denies and macOS services.
696
698
  "read-deny", "mach",
699
+ // task scope --release <version>:<pr>:<branch>: a release scope's terms (release-once.md).
700
+ "release",
697
701
  ]);
698
702
  export const OPERATE_BOOLEAN_FLAGS = new Set([
703
+ // task scope --release ... --no-carry: every later commit asks again.
704
+ "no-carry",
699
705
  "json", "yes", "all", "brief", "local", "history", "latest-watch", "dry-run", "file",
700
706
  "clear", "follow", "ready", "all-tasks", "inbound-only", "help", "undo", "anyone", "allow-dispatch", "allow-merge", "merge-delete-branch",
701
707
  "no-open", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
@@ -6225,6 +6231,11 @@ async function runWatchLoop(args) {
6225
6231
  const ran = await runWaitingChecks(store, context.evidenceRoot, { now: context.clock, shouldStop: stopping });
6226
6232
  if (ran > 0)
6227
6233
  progress(`watch: ran ${ran} follow-up check(s)`);
6234
+ // The nightly check of main and the release checks that clear its failures (release-once.md, part 3).
6235
+ await refreshTrustedMains(store, context.evidenceRoot, context.clock(), context.gitRunner);
6236
+ const settledChecks = settleReleaseChecks(store, context.evidenceRoot, context.clock());
6237
+ if (settledChecks > 0)
6238
+ progress(`watch: settled ${settledChecks} release check(s)`);
6228
6239
  // A finished check may send a build's card down its failure path: now, not on the next beat.
6229
6240
  if (ran + batched > 0)
6230
6241
  moveCards(store, repo, context.clock(), context.evidenceRoot);
@@ -9031,6 +9042,8 @@ function showTask(positional, context) {
9031
9042
  claim: currentClaim(store, ref.id, now),
9032
9043
  scope,
9033
9044
  approval: approvalOf(scope),
9045
+ // Whether a person gave that approval, with their proof (approval policy): what the release gate reads.
9046
+ personApproval: personApprovalOf(store, id),
9034
9047
  route: taskRouteView(routed, readiness),
9035
9048
  // Fresh base: the commit this task's work starts from, and how far current main has moved past it.
9036
9049
  base,
@@ -9719,6 +9732,25 @@ async function scopeTask(positional, flags, context) {
9719
9732
  const candidateGiven = text(flags, "candidate");
9720
9733
  if (candidateGiven !== undefined && !isCommitSha(candidateGiven))
9721
9734
  return fail(write, json, "task scope", "usage", "--candidate is the full 40-character commit hash the machine will check out", EXIT.usage);
9735
+ // A release scope's terms (release-once.md), filled in from the plane's own records: `--release
9736
+ // <version>:<pull request>:<branch>` with the candidate. Terms that can't be made (the checks run outside the
9737
+ // sandbox, say) file the scope without them: every later commit then asks again, as before.
9738
+ const releaseGiven = text(flags, "release");
9739
+ let releaseTerms;
9740
+ let releaseNote = null;
9741
+ if (releaseGiven !== undefined) {
9742
+ const spec = /^(\d+\.\d+\.\d+):([1-9][0-9]*):([A-Za-z0-9._/-]+)$/.exec(releaseGiven);
9743
+ if (spec === null || candidateGiven === undefined)
9744
+ return fail(write, json, "task scope", "usage", "--release takes <version>:<pull request>:<branch>, with --candidate <commit>", EXIT.usage);
9745
+ const repo = store.lookupRef(id)?.repo ?? null;
9746
+ if (repo === null)
9747
+ return fail(write, json, "task scope", "usage", "a release scope's task has to be placed in its project first", EXIT.usage);
9748
+ const made = releaseTermsFor(store, { repo, version: spec[1], pr: Number(spec[2]), branch: spec[3], candidate: candidateGiven, now, noCarry: flags.get("no-carry") === true });
9749
+ if (made.ok)
9750
+ releaseTerms = made.terms;
9751
+ else
9752
+ releaseNote = `Later commits on this release will each ask for approval: ${made.why}.`;
9753
+ }
9722
9754
  if (acceptanceParse.criteria.length === 0)
9723
9755
  return fail(write, json, "task scope", "acceptance-required", "--acceptance named no valid criteria", EXIT.usage);
9724
9756
  const touches = (text(flags, "touches") ?? "").split(",").map(one => one.trim()).filter(Boolean);
@@ -9785,6 +9817,7 @@ async function scopeTask(positional, flags, context) {
9785
9817
  : null;
9786
9818
  const proposed = propose(store, {
9787
9819
  ...(candidateGiven === undefined ? {} : { candidate: candidateGiven }),
9820
+ ...(releaseTerms === undefined ? {} : { releaseTerms }),
9788
9821
  taskId: id,
9789
9822
  goal,
9790
9823
  outOfScope: text(flags, "not") ?? null,
@@ -9836,7 +9869,8 @@ async function scopeTask(positional, flags, context) {
9836
9869
  }
9837
9870
  // A changed scope on a planning task plans again (v119): say so once, instead of leaving the old plan implied.
9838
9871
  const replanned = (store.lookupRef(id)?.planningGeneration ?? 0) > generationBefore;
9839
- return succeed(write, json, "task scope", { scope, ...(replanned ? { planningRestarted: true } : {}) }, () => [
9872
+ return succeed(write, json, "task scope", { scope, ...(replanned ? { planningRestarted: true } : {}), ...(releaseNote === null ? {} : { releaseNote }) }, () => [
9873
+ ...(releaseNote === null ? [] : [releaseNote]),
9840
9874
  replanned
9841
9875
  ? `Scope written for ${id}. The scope changed, so planning starts again; nothing builds until somebody approves the new plan.`
9842
9876
  : `Scope written for ${id}. Nothing will build it until somebody approves it.`,
@@ -10059,6 +10093,9 @@ function describeApproveFailure(reason, id) {
10059
10093
  return `you filed ${id}, and this project needs someone else to approve it`;
10060
10094
  if (reason === "person-required")
10061
10095
  return `${id} is protected work: a person has to approve it, not an operating mode, a schedule or a subagent`;
10096
+ // Approval policy, phase 1: the lead's token (or a remote API token) proves a credential, not a person.
10097
+ if (reason === "machine-proof")
10098
+ return "only a person can approve this — approve it on the task page or from your paired chat";
10062
10099
  if (reason === "second-approver")
10063
10100
  return `your approval of ${id} is recorded; it is protected work, so a second person needs to approve it (toolroll task approve ${id} as them)`;
10064
10101
  if (reason === "no-approvers") {
@@ -0,0 +1,2 @@
1
+ /** Whether a recorded proof is a person's. A runtime string: a missing, corrupt or future value is never a person. */
2
+ export declare function personProof(proof: string | null | undefined): boolean;
@@ -0,0 +1,14 @@
1
+ /** Every credential method, classified: a person proved now (or by their own remembered sign-in), or a machine
2
+ * credential standing in for them. A new method fails to compile here until someone decides which it is. */
3
+ const METHOD_IS_PERSON = {
4
+ "password-now": true, "sso-now": true, "fresh-password": true, "fresh-sso": true, "session": true, "cli-login": true,
5
+ "remote-token": false, "lead-token": false,
6
+ };
7
+ /** Proofs that aren't credential methods: the paired-chat tap, and the phone plan's passkey ceremony. */
8
+ const OTHER_PERSON_PROOFS = new Set(["chat", "passkey"]);
9
+ /** Whether a recorded proof is a person's. A runtime string: a missing, corrupt or future value is never a person. */
10
+ export function personProof(proof) {
11
+ if (typeof proof !== "string")
12
+ return false;
13
+ return Object.prototype.hasOwnProperty.call(METHOD_IS_PERSON, proof) ? METHOD_IS_PERSON[proof] : OTHER_PERSON_PROOFS.has(proof);
14
+ }
package/dist/plan-auto.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { releaseUnattendedRun, reserveUnattendedRun, unattendedGrantStands } from "./flow-unattended.js";
2
2
  import { modeTermsFromJson } from "./modes.js";
3
3
  import { plannerContractOf, plannerSourceDigest } from "./planner-source.js";
4
- import { modeFilingCoverage, propose } from "./scope.js";
4
+ import { modeFilingCoverage, propose, quarantinedAuthor } from "./scope.js";
5
5
  export const PLAN_AUTO_SCHEMA = `CREATE TABLE IF NOT EXISTS plan_authorization (
6
6
  task_id TEXT PRIMARY KEY REFERENCES task(id),
7
7
  source_digest TEXT NOT NULL,
@@ -21,7 +21,7 @@ export function applyModeToNewFiling(store, taskId, actor, now) {
21
21
  const ref = store.lookupRef(taskId);
22
22
  const scope = store.getScope(taskId);
23
23
  const coverage = modeFilingCoverage(store, ref?.repo ?? null, actor, now);
24
- if (ref === null || scope === null || scope.proposedVia != null || coverage === null)
24
+ if (ref === null || scope === null || quarantinedAuthor(scope.proposedVia) || coverage === null)
25
25
  return;
26
26
  const filed = propose(store, { taskId, goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, acceptance: scope.acceptance,
27
27
  budgetMicrousd: scope.budgetMicrousd ?? coverage.defaultBudgetMicrousd,
@@ -48,7 +48,7 @@ export function authorizePlanUnderMode(store, taskId, actor, now) {
48
48
  if (!source.ok || source.contract.scope === null || source.contract.revision !== null)
49
49
  return false;
50
50
  const scope = source.contract.scope;
51
- if (scope.proposedVia !== null || scope.acceptance.length === 0 || scope.touches.length === 0 || scope.terms.profileState !== "resolved" || store.scopeSealed(taskId))
51
+ if (quarantinedAuthor(scope.proposedVia) || scope.acceptance.length === 0 || scope.touches.length === 0 || scope.terms.profileState !== "resolved" || store.scopeSealed(taskId))
52
52
  return false;
53
53
  store.raw().prepare(`INSERT INTO plan_authorization(task_id,source_digest,scope_digest,mode_digest,signed_by,created_at,basis) VALUES (?,?,?,?,?,?,'mode')
54
54
  ON CONFLICT(task_id) DO UPDATE SET source_digest=excluded.source_digest,scope_digest=excluded.scope_digest,mode_digest=excluded.mode_digest,signed_by=excluded.signed_by,created_at=excluded.created_at,basis='mode'`)
@@ -72,7 +72,7 @@ function escalatedPosture(store, taskId) {
72
72
  export function sealFlowFilingUnderGrant(store, taskId, grant, card, now) {
73
73
  const ref = store.lookupRef(taskId);
74
74
  const scope = store.getScope(taskId);
75
- if (ref === null || scope === null || scope.proposedVia != null || scope.profileState !== "resolved" || escalatedPosture(store, taskId))
75
+ if (ref === null || scope === null || quarantinedAuthor(scope.proposedVia) || scope.profileState !== "resolved" || escalatedPosture(store, taskId))
76
76
  return false;
77
77
  if (!unattendedGrantStands(store, grant.digest, grant.signedBy, ref.repo, scope.touches, now))
78
78
  return false;
@@ -81,7 +81,7 @@ export type PlannerContractScope = ContractTerms & {
81
81
  approvedDigest: string | null;
82
82
  approvedBy: string | null;
83
83
  };
84
- proposedVia: "mate" | "coordinator" | "scout" | null;
84
+ proposedVia: import("./scope.js").ScopeAuthor | null;
85
85
  };
86
86
  export type PlannerContract = {
87
87
  /** null: no scope was filed — the title is the only filed intent, and
package/dist/proof.js CHANGED
@@ -620,6 +620,8 @@ function verificationFailureWords(failure) {
620
620
  return "the retried verification command timed out after automatic recovery";
621
621
  case "custody-lost":
622
622
  return "automatic recovery stopped because this worker no longer owned the build";
623
+ case "coverage-incomplete":
624
+ return "the release check didn't cover everything it owed, or a required test went missing or was skipped";
623
625
  case "timed-out":
624
626
  return "the approved verification command timed out before checks finished";
625
627
  case "spawn-failed":