toolroll 0.9.71 → 0.9.73

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 (131) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/dist/agent-fence.d.ts +10 -2
  3. package/dist/agent-fence.js +47 -9
  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/browser/workspace.js +28 -28
  9. package/dist/builder.js +48 -6
  10. package/dist/chat-actions.js +13 -8
  11. package/dist/chat-decide.d.ts +47 -1
  12. package/dist/chat-decide.js +186 -5
  13. package/dist/check-access.js +2 -2
  14. package/dist/check-report.d.ts +109 -0
  15. package/dist/check-report.js +205 -0
  16. package/dist/claim.js +1 -1
  17. package/dist/coding-provider.js +4 -2
  18. package/dist/commit-extract.d.ts +111 -0
  19. package/dist/commit-extract.js +514 -0
  20. package/dist/contracts/console-api.d.ts +4 -4
  21. package/dist/contracts/console-api.js +5 -4
  22. package/dist/contracts/flow.d.ts +16 -0
  23. package/dist/contracts/flow.js +3 -0
  24. package/dist/contracts/release-check.d.ts +335 -0
  25. package/dist/contracts/release-check.js +159 -0
  26. package/dist/contracts/release-terms.d.ts +66 -0
  27. package/dist/contracts/release-terms.js +74 -0
  28. package/dist/contracts/verification-receipt.d.ts +42 -8
  29. package/dist/contracts/verification-receipt.js +24 -7
  30. package/dist/contracts/verification-view.d.ts +1 -0
  31. package/dist/deploy-gate.d.ts +17 -0
  32. package/dist/deploy-gate.js +68 -0
  33. package/dist/dispatch.js +3 -10
  34. package/dist/dispose.js +4 -1
  35. package/dist/evidence-pack.d.ts +1 -1
  36. package/dist/evidence-pack.js +20 -2
  37. package/dist/evidence.d.ts +1 -0
  38. package/dist/evidence.js +2 -1
  39. package/dist/exec.d.ts +1 -1
  40. package/dist/exec.js +7 -3
  41. package/dist/flow-engine.js +5 -1
  42. package/dist/flow-gallery-ui.js +4 -2
  43. package/dist/flow-gallery.d.ts +3 -0
  44. package/dist/flow-gallery.js +6 -2
  45. package/dist/flow-pull-request.d.ts +5 -2
  46. package/dist/flow-pull-request.js +14 -5
  47. package/dist/flow-schedule.js +25 -6
  48. package/dist/flow-triggers.d.ts +8 -0
  49. package/dist/flow-triggers.js +15 -0
  50. package/dist/flows-cli.js +13 -3
  51. package/dist/flows-ui.js +7 -1
  52. package/dist/guides.js +10 -1
  53. package/dist/invoke.d.ts +17 -6
  54. package/dist/invoke.js +48 -18
  55. package/dist/kits-ui.js +4 -2
  56. package/dist/kits.d.ts +6 -4
  57. package/dist/kits.js +9 -11
  58. package/dist/lead-status.js +5 -2
  59. package/dist/live-terminal.d.ts +10 -6
  60. package/dist/live-terminal.js +55 -14
  61. package/dist/mcp-connect.d.ts +10 -0
  62. package/dist/mcp-connect.js +2 -0
  63. package/dist/nightly-check.d.ts +104 -0
  64. package/dist/nightly-check.js +362 -0
  65. package/dist/operate.js +39 -2
  66. package/dist/person-proof.d.ts +2 -0
  67. package/dist/person-proof.js +14 -0
  68. package/dist/plan-auto.js +4 -4
  69. package/dist/planner-source.d.ts +1 -1
  70. package/dist/project-delete-ui.js +1 -1
  71. package/dist/project-tools.js +3 -1
  72. package/dist/proof.js +2 -0
  73. package/dist/protected-storage.d.ts +67 -0
  74. package/dist/protected-storage.js +398 -0
  75. package/dist/publish.js +156 -144
  76. package/dist/pull-request-flow.d.ts +2 -0
  77. package/dist/pull-request-flow.js +24 -2
  78. package/dist/release-adapters.d.ts +16 -1
  79. package/dist/release-adapters.js +292 -29
  80. package/dist/release-authority.d.ts +118 -0
  81. package/dist/release-authority.js +135 -0
  82. package/dist/release-card.d.ts +76 -0
  83. package/dist/release-card.js +252 -0
  84. package/dist/release-carry.d.ts +236 -0
  85. package/dist/release-carry.js +664 -0
  86. package/dist/release-cli.d.ts +3 -0
  87. package/dist/release-cli.js +158 -6
  88. package/dist/release-coverage.js +3 -0
  89. package/dist/release-git.d.ts +43 -0
  90. package/dist/release-git.js +106 -0
  91. package/dist/release-recheck.d.ts +146 -0
  92. package/dist/release-recheck.js +657 -0
  93. package/dist/release-view.d.ts +34 -0
  94. package/dist/release-view.js +27 -0
  95. package/dist/release.d.ts +139 -3
  96. package/dist/release.js +641 -250
  97. package/dist/remote-main.d.ts +2 -0
  98. package/dist/remote-main.js +1 -1
  99. package/dist/result-completion.d.ts +6 -0
  100. package/dist/result-completion.js +15 -1
  101. package/dist/result-follow-ups.js +29 -1
  102. package/dist/sandbox-runtime.d.ts +16 -5
  103. package/dist/sandbox-runtime.js +19 -6
  104. package/dist/scope.d.ts +122 -5
  105. package/dist/scope.js +236 -7
  106. package/dist/server/flows.js +40 -10
  107. package/dist/server/render-settings.js +1 -1
  108. package/dist/server/render-tasks.d.ts +8 -0
  109. package/dist/server/render-tasks.js +50 -9
  110. package/dist/server/route-table.js +1 -0
  111. package/dist/server/settings.js +26 -5
  112. package/dist/server/task-views.js +4 -0
  113. package/dist/server/tasks.js +12 -0
  114. package/dist/setup-guide.d.ts +2 -2
  115. package/dist/store.d.ts +230 -5
  116. package/dist/store.js +733 -37
  117. package/dist/subagent-memory.d.ts +1 -0
  118. package/dist/subagent-memory.js +1 -1
  119. package/dist/subagent-tools.d.ts +22 -6
  120. package/dist/subagent-tools.js +44 -8
  121. package/dist/subagent-work.d.ts +2 -0
  122. package/dist/subagent-work.js +5 -2
  123. package/dist/subagents-ui.js +6 -4
  124. package/dist/task-outcome-cli.js +7 -3
  125. package/dist/telegram.d.ts +6 -1
  126. package/dist/telegram.js +58 -6
  127. package/dist/toolroll-update.js +2 -2
  128. package/dist/verification-evidence.d.ts +25 -1
  129. package/dist/verification-evidence.js +57 -2
  130. package/dist/work-index.js +3 -6
  131. package/package.json +1 -1
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The release authority record, read (docs/plans/release-once.md, schema v135): one generation of a release a person
3
+ * approved, its carried commits and its deploy and merge operations. Kept apart from store.ts so the one live-release
4
+ * reader (scope.ts liveReleaseOf) can read it without a module cycle; every write is in store.ts.
5
+ */
6
+ import { readReleasePolicy } from "./contracts/release-terms.js";
7
+ export const LIVE_RELEASE_STATES = ["open", "deploying", "merge-reserved"];
8
+ function readReleaseAuthority(row) {
9
+ const text = (key) => row[key] == null ? null : String(row[key]);
10
+ let policy = null;
11
+ try {
12
+ const parsed = readReleasePolicyStrict(String(row["policy_json"]));
13
+ policy = parsed;
14
+ }
15
+ catch {
16
+ policy = null;
17
+ }
18
+ const state = String(row["state"]);
19
+ return {
20
+ id: Number(row["id"]), repo: String(row["repo"]), generation: Number(row["generation"]), previous: row["previous"] == null ? null : Number(row["previous"]),
21
+ version: String(row["version"]), pr: Number(row["pr"]), branch: String(row["branch"]), base: String(row["base"]),
22
+ anchorTask: String(row["anchor_task"]), anchorDigest: String(row["anchor_digest"]), approvedSha: String(row["approved_sha"]), approvedTree: String(row["approved_tree"]),
23
+ artifactDigest: text("artifact_digest"), tipSha: String(row["tip_sha"]), tipTask: String(row["tip_task"]), policyJson: String(row["policy_json"]), policy,
24
+ policyDigest: String(row["policy_digest"]), verifyCommand: Number(row["verify_command"]), verifyDigest: String(row["verify_digest"]), accessDigest: String(row["access_digest"]),
25
+ state: ["open", "deploying", "merge-reserved", "merged", "revoked", "expired", "superseded"].includes(state) ? state : "revoked",
26
+ rev: Number(row["rev"]), leaseOwner: text("lease_owner"), leaseEpoch: Number(row["lease_epoch"]), leaseExpires: text("lease_expires"),
27
+ expiresAt: String(row["expires_at"]), createdAt: String(row["created_at"]), endedAt: text("ended_at"), endedBy: text("ended_by"),
28
+ };
29
+ }
30
+ /** A sealed policy read strictly; null when this runtime doesn't know it (and so refuses to carry under it). */
31
+ function readReleasePolicyStrict(json) {
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(json);
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ const read = readReleasePolicy(parsed);
40
+ return read.ok && JSON.stringify(read.value) === json ? read.value : null;
41
+ }
42
+ function readReleaseCarry(row) {
43
+ let commits = [];
44
+ try {
45
+ const parsed = JSON.parse(String(row["commits_json"]));
46
+ if (Array.isArray(parsed))
47
+ commits = parsed;
48
+ }
49
+ catch {
50
+ commits = [];
51
+ }
52
+ return {
53
+ id: Number(row["id"]), authority: Number(row["authority"]), taskId: String(row["task_id"]), scopeDigest: String(row["scope_digest"]), requestId: String(row["request_id"]),
54
+ fromSha: String(row["from_sha"]), toSha: String(row["to_sha"]), toTree: String(row["to_tree"]), artifactDigest: String(row["artifact_digest"]), commits,
55
+ changedLines: Number(row["changed_lines"]), files: Number(row["files"]), churn: Number(row["churn"]), runtime: String(row["runtime"]), provedAt: String(row["proved_at"]),
56
+ };
57
+ }
58
+ function readReleaseOperation(row) {
59
+ const text = (key) => row[key] == null ? null : String(row[key]);
60
+ return {
61
+ id: String(row["id"]), authority: Number(row["authority"]), kind: row["kind"] === "merge" ? "merge" : "deploy", taskId: String(row["task_id"]), candidate: String(row["candidate"]),
62
+ epoch: Number(row["epoch"]), state: String(row["state"]), externalRef: text("external_ref"), reservedAt: String(row["reserved_at"]),
63
+ finishedAt: text("finished_at"), detail: text("detail"), direct: Number(row["direct"] ?? 0) === 1,
64
+ issuer: row["issuer_pid"] == null || row["issued_at"] == null ? null : { pid: Number(row["issuer_pid"]), started: text("issuer_started"), at: String(row["issued_at"]) },
65
+ stage: text("stage"),
66
+ };
67
+ }
68
+ export function releaseAuthority(store, id) {
69
+ const row = store.handle.prepare("SELECT * FROM release_authority WHERE id = ?").get(id);
70
+ return row === undefined ? null : readReleaseAuthority(row);
71
+ }
72
+ /** The newest generation of a release, whatever its state. */
73
+ export function newestReleaseGeneration(store, repo, version, pr) {
74
+ const row = store.handle.prepare("SELECT * FROM release_authority WHERE repo = ? AND version = ? AND pr = ? ORDER BY generation DESC LIMIT 1").get(repo, version, pr);
75
+ return row === undefined ? null : readReleaseAuthority(row);
76
+ }
77
+ /** The release authority a task belongs to — as an anchor (any generation) or as a carried commit — or null. */
78
+ export function releaseAuthorityOfTask(store, taskId) {
79
+ const carryRow = store.handle.prepare("SELECT * FROM release_carry WHERE task_id = ?").get(taskId);
80
+ if (carryRow !== undefined) {
81
+ const carry = readReleaseCarry(carryRow);
82
+ const authority = releaseAuthority(store, carry.authority);
83
+ return authority === null ? null : { authority, carry };
84
+ }
85
+ const row = store.handle.prepare("SELECT * FROM release_authority WHERE anchor_task = ? ORDER BY id DESC LIMIT 1").get(taskId);
86
+ return row === undefined ? null : { authority: readReleaseAuthority(row), carry: null };
87
+ }
88
+ /** Whether a task belongs to any release authority (anchor or carried), from the records alone. */
89
+ /** "Carry release approvals when checks aren't sandboxed": a project setting, newest choice wins (a ledger row each);
90
+ * off by default. Turning it on is a person's own proven act (release-cli.ts); turning it off is anyone's. */
91
+ export const UNSANDBOXED_CARRY_ACTION = "release unsandboxed carrying changed";
92
+ export function carriesUnsandboxed(store, repo) {
93
+ const row = store.handle.prepare("SELECT outcome FROM action_ledger WHERE repo = ? AND action = ? ORDER BY id DESC LIMIT 1").get(repo, UNSANDBOXED_CARRY_ACTION);
94
+ return row !== undefined && row["outcome"] === "on";
95
+ }
96
+ /** How far a check reaches, narrowest first. */
97
+ export const CHECK_ACCESS_WIDTH = Object.freeze({ sandboxed: 0, "sign-ins": 1, unsandboxed: 2 });
98
+ export function belongsToRelease(store, taskId) {
99
+ return store.handle.prepare("SELECT 1 AS hit FROM release_authority WHERE anchor_task = ? UNION ALL SELECT 1 FROM release_carry WHERE task_id = ? LIMIT 1").get(taskId, taskId) !== undefined;
100
+ }
101
+ export function releaseCarries(store, authority) {
102
+ return store.handle.prepare("SELECT * FROM release_carry WHERE authority = ? ORDER BY id").all(authority).map(readReleaseCarry);
103
+ }
104
+ export function releaseCarryTo(store, authority, toSha) {
105
+ const row = store.handle.prepare("SELECT * FROM release_carry WHERE authority = ? AND to_sha = ?").get(authority, toSha);
106
+ return row === undefined ? null : readReleaseCarry(row);
107
+ }
108
+ export function releaseOperations(store, authority) {
109
+ return store.handle.prepare("SELECT * FROM release_operation WHERE authority = ? ORDER BY reserved_at, id").all(authority).map(readReleaseOperation);
110
+ }
111
+ export function releaseOperation(store, id) {
112
+ const row = store.handle.prepare("SELECT * FROM release_operation WHERE id = ?").get(id);
113
+ return row === undefined ? null : readReleaseOperation(row);
114
+ }
115
+ /** Operations nobody has settled yet: until each is reconciled, nothing new starts. */
116
+ export function outstandingReleaseOperations(store, authority) {
117
+ return releaseOperations(store, authority).filter(one => one.state === "reserved" || one.state === "running" || one.state === "unknown");
118
+ }
119
+ /**
120
+ * Why a person's seal of these release terms can't open a new generation now, in words, or null. A live generation is
121
+ * superseded by the new one — but never while one of its operations is unsettled, and never once its merge started.
122
+ */
123
+ export function releaseSealProblem(store, terms) {
124
+ const live = newestReleaseGeneration(store, terms.repo, terms.packageVersion, terms.pr);
125
+ if (live === null)
126
+ return null;
127
+ if (live.state === "merged")
128
+ return `Release ${terms.packageVersion} from pull request #${terms.pr} was already merged.`;
129
+ if (live.state === "merge-reserved")
130
+ return `The merge of release ${terms.packageVersion} already started; it can't be approved again.`;
131
+ if (LIVE_RELEASE_STATES.includes(live.state) && outstandingReleaseOperations(store, live.id).length > 0) {
132
+ return `Release ${terms.packageVersion} has a deploy that hasn't been settled yet. Run \`toolroll release ${live.branch}\` to finish or recover it first.`;
133
+ }
134
+ return null;
135
+ }
@@ -0,0 +1,76 @@
1
+ import { type ReleaseTerms } from "./contracts/release-terms.js";
2
+ import type { Scope } from "./scope.js";
3
+ import type { Store } from "./store.js";
4
+ /** One signed term as the card says it. `key` names the scope field or the release term's path. */
5
+ export type TermLine = {
6
+ key: string;
7
+ label: string;
8
+ value: string;
9
+ };
10
+ /** The scope's own terms the card names, in order. A contract test holds `goal`, `outOfScope`, `touches` and
11
+ * `acceptance` (contracts/scope.ts) and the scope's other signed fields to this list; the agent profile and the phase
12
+ * route follow as one line per signed setting (`profile.…`, `route.…`). */
13
+ export declare const SCOPE_TERM_LABELS: {
14
+ readonly title: "Title";
15
+ readonly project: "Project";
16
+ readonly filedBy: "Filed by";
17
+ readonly goal: "Goal";
18
+ readonly outOfScope: "Not in scope";
19
+ readonly touches: "Paths it may touch";
20
+ readonly acceptance: "Done when";
21
+ readonly qualityMode: "Evidence standard";
22
+ readonly agents: "Agents and model";
23
+ readonly permission: "Agents may";
24
+ readonly checks: "Checks";
25
+ readonly checkAccess: "Check access";
26
+ readonly budgetMicrousd: "Budget per attempt";
27
+ readonly candidate: "Candidate commit";
28
+ };
29
+ /** Every field of the release terms, by its path in the terms (contracts/release-terms.ts), against the schema's
30
+ * own leaves: the contract test checks every one has a label. */
31
+ export declare const RELEASE_TERM_LABELS: Readonly<Record<string, string>>;
32
+ /** The leaf paths of a zod object schema (nested objects joined with dots). */
33
+ export declare function schemaLeaves(schema: unknown, prefix?: string): string[];
34
+ /** Every signed term of `scope`, its release terms included, one line each. Reads only. */
35
+ export declare function approvalTermsOf(store: Store, scope: Scope, words: {
36
+ profile: (profile: Scope["profile"]) => string | null;
37
+ }): TermLine[];
38
+ export declare const termsText: (lines: readonly TermLine[]) => string;
39
+ export type ChangeList = {
40
+ /** The merge commit of the last verified release, or null when none is recorded. */
41
+ baseline: string | null;
42
+ /** Newest first; null when it couldn't be read. */
43
+ commits: {
44
+ sha: string;
45
+ subject: string;
46
+ }[] | null;
47
+ source: "recorded" | "pull-request" | "unread";
48
+ digest: string;
49
+ };
50
+ /** The plane's own record of the last verified release of `repo`: the merge commit GitHub answered with, recorded once
51
+ * the release verified the merged tree (the newest merged authority whose merge operation succeeded). Never a tag. */
52
+ export declare function releaseBaseline(store: Store, repo: string): string | null;
53
+ /** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
54
+ * release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
55
+ * a shallow boundary, grafts or replacement refs refused, and the range proved to stop only in the last release's own
56
+ * history). Null when they can't be read, so a card never shows a shortened list. */
57
+ export declare function commitsBetween(repo: string, from: string, to: string): {
58
+ sha: string;
59
+ subject: string;
60
+ }[] | null;
61
+ /** What changed since the last verified release, for a release's terms, from Toolroll's records only. */
62
+ export declare function releaseChangeList(store: Store, terms: ReleaseTerms): ChangeList;
63
+ export type ReleaseCard = {
64
+ /** Binds the scope digest, the plan form's digest, the baseline and the change list. */
65
+ digest: string;
66
+ short: string;
67
+ full: string;
68
+ fileName: string;
69
+ changes: ChangeList;
70
+ };
71
+ /** The card digest: everything the person is shown that can change under them. */
72
+ export declare function cardDigestOf(scopeDigest: string, formDigest: string, changes: Pick<ChangeList, "baseline" | "digest">): string;
73
+ /** The release card for a deploy gate, against the live records now. */
74
+ export declare function releaseCardOf(store: Store, scope: Scope, formDigest: string, words: {
75
+ profile: (profile: Scope["profile"]) => string | null;
76
+ }): ReleaseCard;
@@ -0,0 +1,252 @@
1
+ /**
2
+ * The release card (docs/plans/chat-management.md §0a.5): how a deploy gate is approved from chat until the
3
+ * authenticator ships. Alex's decision (2026-10-25): chat keeps approving releases by tap, so the tap is made to mean
4
+ * exactly what was shown.
5
+ *
6
+ * - `approvalTermsOf`: every signed term of the scope (its agent profile and phase route setting by setting) and every
7
+ * field of its release terms, one line each, "none" for an empty one and "any file in the project" for unnamed paths
8
+ * (a contract test changes each input of the approval digest in turn and holds the text to change with it).
9
+ * - `releaseChangeList`: what changed since the last verified release, from the plane's own record of it (the merge
10
+ * commit of the newest merged release whose merge operation succeeded), read as verified Git objects from the
11
+ * project's recorded Git directory: never a tag or a ref, so moving one changes nothing, and history rewritten around
12
+ * the objects (a shallow boundary, grafts, replacement refs) refuses the list rather than shortening it.
13
+ * With no earlier release on record, the pull request's own commits from GitHub, for the recorded owner/name.
14
+ * - `releaseCardOf`: the short card, the full terms (sent as a file where the chat app limits a message), and
15
+ * `cardDigest`, which binds the scope digest, the plan form's digest, the baseline and the change list: Continue and
16
+ * Approve carry it, and anything different at the tap refuses.
17
+ */
18
+ import { spawnSync } from "node:child_process";
19
+ import { createHash } from "node:crypto";
20
+ import { lstatSync, realpathSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { checkAccessWords } from "./check-access.js";
23
+ import { CHECK_LEVEL_WORDS, effectiveCheckLevel } from "./check-levels.js";
24
+ import { verifiedRange } from "./commit-extract.js";
25
+ import { releaseTermsSchema } from "./contracts/release-terms.js";
26
+ import { routeFromJson } from "./phase-routing.js";
27
+ import { projectName } from "./project.js";
28
+ import { RELEASE_PATH, releaseConfigProblem, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
29
+ const NONE = "none";
30
+ /** A budget in dollars from integer micro-dollars, in integer arithmetic: cents when it is whole cents, else every digit. */
31
+ const money = (micro) => {
32
+ const exact = BigInt(micro), whole = exact / 1000000n, rest = (exact < 0n ? -exact : exact) % 1000000n;
33
+ const fraction = rest.toString().padStart(6, "0");
34
+ return `${exact < 0n && whole === 0n ? "-" : ""}$${whole}.${rest % 10000n === 0n ? fraction.slice(0, 2) : fraction}`;
35
+ };
36
+ /** Words the card itself says in place of a value: a signed string spelled the same is quoted, never mistaken for one. */
37
+ const RESERVED = new Set([NONE, "yes", "no", "null", "empty", "not set", "any file in the project", "the whole project", "unreadable"]);
38
+ /** Text that reads the same plain as quoted: ASCII letters, digits and ordinary punctuation only (no lookalike letters
39
+ * from other scripts, no direction marks), never a comma, semicolon, parenthesis, quote, backslash, line break or
40
+ * control character, nor a trailing space. */
41
+ const PLAIN = /^[A-Za-z0-9~/.#$@][A-Za-z0-9 ._/@~+#=!?&%$*:<>|^-]*$/;
42
+ /** JSON-quoted with everything outside printable ASCII \u-escaped (JSON.stringify leaves non-ASCII as it is). */
43
+ const quoted = (value) => JSON.stringify(value).replace(/[^\x20-\x7e]/g, one => `\\u${one.charCodeAt(0).toString(16).padStart(4, "0")}`);
44
+ /** One signed string, unambiguous: as it is when plain, else quoted; an empty, number-like or colon-bearing string, or
45
+ * one of the card's own words, is quoted too. */
46
+ const text = (value) => PLAIN.test(value) && !value.endsWith(" ") && !RESERVED.has(value.toLowerCase()) && !/^-?\d+(\.\d+)?$/.test(value) ? value : quoted(value);
47
+ /** One signed value as the card says it, every shape distinct: "none" for null, "not set" for absent, "empty" for an
48
+ * empty list, `""` for an empty string; a list's entries each said the same way, so a comma never joins two into one. */
49
+ const said = (value) => {
50
+ if (value === undefined)
51
+ return "not set";
52
+ if (value === null)
53
+ return NONE;
54
+ if (typeof value === "boolean")
55
+ return value ? "yes" : "no";
56
+ if (typeof value === "number")
57
+ return String(value);
58
+ if (Array.isArray(value))
59
+ return value.length === 0 ? "empty" : value.map(one => typeof one === "string" ? text(one) : one === null ? "null" : said(one)).join(", ");
60
+ return text(String(value));
61
+ };
62
+ /** The scope's own terms the card names, in order. A contract test holds `goal`, `outOfScope`, `touches` and
63
+ * `acceptance` (contracts/scope.ts) and the scope's other signed fields to this list; the agent profile and the phase
64
+ * route follow as one line per signed setting (`profile.…`, `route.…`). */
65
+ export const SCOPE_TERM_LABELS = {
66
+ title: "Title", project: "Project", filedBy: "Filed by", goal: "Goal", outOfScope: "Not in scope", touches: "Paths it may touch",
67
+ acceptance: "Done when", qualityMode: "Evidence standard", agents: "Agents and model", permission: "Agents may", checks: "Checks", checkAccess: "Check access",
68
+ budgetMicrousd: "Budget per attempt", candidate: "Candidate commit",
69
+ };
70
+ /** Every leaf of a signed value, at its path (`a.b`, `legs.0.model`), as the card says it. */
71
+ function leavesOf(value, path) {
72
+ if (Array.isArray(value))
73
+ return value.every(one => one === null || typeof one !== "object") ? [{ path, value: said(value) }]
74
+ : value.flatMap((one, index) => leavesOf(one, `${path}.${index}`));
75
+ if (value !== null && typeof value === "object") {
76
+ const keys = Object.keys(value).sort();
77
+ return keys.length === 0 ? [{ path, value: "no settings" }] : keys.flatMap(key => leavesOf(value[key], `${path}.${key}`));
78
+ }
79
+ return [{ path, value: said(value) }];
80
+ }
81
+ /** Every field of the release terms, by its path in the terms (contracts/release-terms.ts), against the schema's
82
+ * own leaves: the contract test checks every one has a label. */
83
+ export const RELEASE_TERM_LABELS = {
84
+ version: "Release terms version", repo: "Repository", packageVersion: "Package version", branch: "Branch", pr: "Pull request", base: "Base",
85
+ approvedSha: "Approved commit", approvedTree: "Approved tree",
86
+ "policy.version": "Carry policy version", "policy.rules": "Carry rules", "policy.carry": "Carry later commits",
87
+ "policy.protectProject": "Protected: the whole project", "policy.protectedPaths": "Protected paths",
88
+ "policy.limits.lines": "Carry limit: lines", "policy.limits.files": "Carry limit: files", "policy.limits.churn": "Carry limit: churn",
89
+ "policy.unsandboxedCarry": "Carry when checks aren't sandboxed",
90
+ "grant.id": "Release check grant", "grant.digest": "Release check grant digest", access: "Release check access", expiresAt: "Expires",
91
+ };
92
+ /** The leaf paths of a zod object schema (nested objects joined with dots). */
93
+ export function schemaLeaves(schema, prefix = "") {
94
+ const shape = schema.shape;
95
+ if (shape === undefined || shape === null)
96
+ return [prefix];
97
+ return Object.entries(shape).flatMap(([key, child]) => schemaLeaves(child, prefix === "" ? key : `${prefix}.${key}`));
98
+ }
99
+ const valueAt = (terms, path) => path.split(".").reduce((at, key) => at !== null && typeof at === "object" ? at[key] : undefined, terms);
100
+ /** Every signed term of `scope`, its release terms included, one line each. Reads only. */
101
+ export function approvalTermsOf(store, scope, words) {
102
+ const ref = store.lookupRef(scope.taskId);
103
+ const repo = ref?.repo ?? null;
104
+ const filer = store.taskFiler(scope.taskId);
105
+ const profile = scope.profile ?? null;
106
+ const scopeValues = {
107
+ title: said(store.getTask(scope.taskId)?.title),
108
+ project: repo === null ? NONE : `${text(projectName(repo))} (${text(repo)})`,
109
+ filedBy: filer === null ? NONE : filer.name === null ? filer.kind : `${text(filer.name)}${filer.kind === "person" ? "" : ` (${filer.kind})`}`,
110
+ goal: said(scope.goal),
111
+ // null (nothing excluded) and an empty exclusion are signed apart, and read apart.
112
+ outOfScope: said(scope.outOfScope),
113
+ // No paths named is no limit: the work may touch any file in the project.
114
+ touches: scope.touches.length === 0 ? "any file in the project" : said(scope.touches),
115
+ // Each criterion's fields quoted, always: a colon or a parenthesis in one never moves the boundary to the next.
116
+ acceptance: scope.acceptance.length === 0 ? NONE : scope.acceptance.map(one => `${quoted(one.id)}: ${quoted(one.statement)} (evidence: ${said(one.evidence)})`).join("; "),
117
+ qualityMode: scope.qualityMode === "strict" ? "strict" : "standard",
118
+ agents: profile === null ? NONE : `${profile.provider} · ${text(profile.model)}`,
119
+ permission: said(words.profile(profile)),
120
+ checks: CHECK_LEVEL_WORDS[effectiveCheckLevel(store, repo, scope.taskId).level],
121
+ checkAccess: checkAccessWords(profile?.checkAccess ?? "sandboxed"),
122
+ budgetMicrousd: scope.budgetMicrousd === null ? NONE : money(scope.budgetMicrousd),
123
+ candidate: said(scope.candidate),
124
+ };
125
+ const lines = Object.keys(SCOPE_TERM_LABELS).map(key => ({ key, label: SCOPE_TERM_LABELS[key], value: scopeValues[key] }));
126
+ // Every signed setting of the agent profile (what it may read, reach and write, its limits) and the phase route the
127
+ // digest binds, one line each: the summary lines above never stand in for them.
128
+ if (profile !== null)
129
+ for (const leaf of leavesOf(profile, "profile"))
130
+ lines.push({ key: leaf.path, label: `Agent setting ${leaf.path.slice("profile.".length)}`, value: leaf.value });
131
+ if (scope.proposedRouteJson != null) {
132
+ const route = routeFromJson(scope.proposedRouteJson);
133
+ // Unreadable (it then never seals): its stored text, exactly, so two different ones never read alike.
134
+ if (route === null)
135
+ lines.push({ key: "route", label: "Phase route (unreadable)", value: quoted(scope.proposedRouteJson) });
136
+ else
137
+ for (const leaf of leavesOf(route, "route"))
138
+ lines.push({ key: leaf.path, label: `Phase route ${leaf.path.slice("route.".length)}`, value: leaf.value });
139
+ }
140
+ const terms = scope.releaseTerms ?? null;
141
+ for (const path of schemaLeaves(releaseTermsSchema)) {
142
+ const label = RELEASE_TERM_LABELS[path] ?? path;
143
+ const value = terms === null ? NONE : path === "pr" ? `#${terms.pr}`
144
+ // The whole project is protected, and the signed list is said too (it is part of what was signed).
145
+ : path === "policy.protectedPaths" && terms.policy.protectProject ? `the whole project (listed: ${said(terms.policy.protectedPaths)})`
146
+ // Absent (sealed before the setting existed) is off, said apart from a signed "no".
147
+ : path === "policy.unsandboxedCarry" && terms.policy.unsandboxedCarry === undefined ? "no (not set when sealed)" : said(valueAt(terms, path));
148
+ lines.push({ key: `release.${path}`, label, value });
149
+ }
150
+ return lines;
151
+ }
152
+ export const termsText = (lines) => lines.map(line => `${line.label}: ${line.value}`).join("\n");
153
+ const SHA = /^[0-9a-f]{40}$/;
154
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
155
+ /** The plane's own record of the last verified release of `repo`: the merge commit GitHub answered with, recorded once
156
+ * the release verified the merged tree (the newest merged authority whose merge operation succeeded). Never a tag. */
157
+ export function releaseBaseline(store, repo) {
158
+ const row = store.handle.prepare(`SELECT o.external_ref FROM release_authority a JOIN release_operation o ON o.authority = a.id
159
+ WHERE a.repo = ? AND a.state = 'merged' AND o.kind = 'merge' AND o.state = 'succeeded' AND o.external_ref IS NOT NULL ORDER BY a.id DESC, o.finished_at DESC LIMIT 1`).get(repo);
160
+ const ref = row === undefined ? null : String(row["external_ref"]);
161
+ return ref !== null && SHA.test(ref) ? ref : null;
162
+ }
163
+ /** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
164
+ * release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
165
+ * a shallow boundary, grafts or replacement refs refused, and the range proved to stop only in the last release's own
166
+ * history). Null when they can't be read, so a card never shows a shortened list. */
167
+ export function commitsBetween(repo, from, to) {
168
+ if (!SHA.test(from) || !SHA.test(to))
169
+ return null;
170
+ let gitDir;
171
+ try {
172
+ const named = join(realpathSync(repo), ".git");
173
+ if (!lstatSync(named).isDirectory())
174
+ return null;
175
+ gitDir = realpathSync(named);
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ const tools = releaseTools();
181
+ const env = { ...releaseGitEnv(), PATH: RELEASE_PATH };
182
+ const listing = spawnSync(tools.git, [`--git-dir=${gitDir}`, "--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], { cwd: "/", env, encoding: "utf8", timeout: 15_000 });
183
+ if (listing.status !== 0 || releaseConfigProblem(listing.stdout) !== null)
184
+ return null;
185
+ const range = verifiedRange(gitDir, from, to, { git: tools.git, env });
186
+ if (!range.ok)
187
+ return null;
188
+ return range.value.map(one => ({ sha: one.id, subject: one.message.split("\n")[0].trim() }));
189
+ }
190
+ /** A pull request's own commits from GitHub, for the recorded owner/name. Null when GitHub can't be read. */
191
+ function pullRequestCommits(slug, pr) {
192
+ const tools = releaseTools();
193
+ const read = spawnSync(tools.gh, ["api", `repos/${slug}/pulls/${pr}/commits?per_page=100`, "--jq", '.[] | .sha + "\\t" + (.commit.message | split("\\n")[0])'], { cwd: "/", env: releaseGitEnv(), encoding: "utf8", timeout: 15_000 });
194
+ if (read.status !== 0)
195
+ return null;
196
+ return read.stdout.split("\n").filter(line => line !== "").map(line => { const tab = line.indexOf("\t"); return { sha: line.slice(0, tab), subject: line.slice(tab + 1) }; })
197
+ .filter(one => SHA.test(one.sha)).reverse();
198
+ }
199
+ /** What changed since the last verified release, for a release's terms, from Toolroll's records only. */
200
+ export function releaseChangeList(store, terms) {
201
+ const baseline = releaseBaseline(store, terms.repo);
202
+ let commits;
203
+ let source;
204
+ if (baseline !== null) {
205
+ commits = commitsBetween(terms.repo, baseline, terms.approvedSha);
206
+ source = commits === null ? "unread" : "recorded";
207
+ }
208
+ else {
209
+ const remote = store.projectRemote(terms.repo);
210
+ const parsed = remote === null ? null : releaseRemote(remote);
211
+ commits = parsed === null ? null : pullRequestCommits(parsed.slug, terms.pr);
212
+ source = commits === null ? "unread" : "pull-request";
213
+ }
214
+ return { baseline, commits, source, digest: sha256(`release-changes:${JSON.stringify({ baseline, source, commits })}`) };
215
+ }
216
+ /** The card digest: everything the person is shown that can change under them. */
217
+ export function cardDigestOf(scopeDigest, formDigest, changes) {
218
+ return sha256(`release-card:v1:${JSON.stringify([scopeDigest, formDigest, changes.baseline, changes.digest])}`);
219
+ }
220
+ function changeWords(changes, terms, cap) {
221
+ const head = changes.baseline === null ? `No earlier verified release is recorded. ${changes.source === "pull-request" ? `Pull request #${terms?.pr ?? "?"}'s commits:` : ""}`.trim()
222
+ : `Changes since the last verified release (${changes.baseline.slice(0, 7)}):`;
223
+ if (changes.commits === null)
224
+ return [head, changes.baseline === null ? `The pull request's commits couldn't be read; see #${terms?.pr ?? "?"} on GitHub.` : "The change list couldn't be read."];
225
+ if (changes.commits.length === 0)
226
+ return [head, "No commits."];
227
+ return [head, ...changes.commits.slice(0, cap).map(one => `• ${one.sha.slice(0, 7)} ${one.subject.slice(0, 100)}`), ...(changes.commits.length > cap ? [`• and ${changes.commits.length - cap} more`] : [])];
228
+ }
229
+ /** The release card for a deploy gate, against the live records now. */
230
+ export function releaseCardOf(store, scope, formDigest, words) {
231
+ const terms = scope.releaseTerms ?? null;
232
+ const lines = approvalTermsOf(store, scope, words);
233
+ const value = (key) => lines.find(line => line.key === key)?.value ?? NONE;
234
+ const changes = terms === null
235
+ ? { baseline: null, commits: null, source: "unread", digest: sha256("release-changes:none") }
236
+ : releaseChangeList(store, terms);
237
+ const version = terms?.packageVersion ?? null;
238
+ const fileName = `release-${version ?? scope.taskId.replace(/[^A-Za-z0-9._-]/g, "-")}-terms.txt`;
239
+ const short = [
240
+ version === null ? `Deploy gate: ${value("title")}` : `Release ${version}: ${value("title")}`,
241
+ terms === null ? `Candidate ${said(scope.candidate).slice(0, 12)} · ${value("project")}` : `${projectName(terms.repo)} · ${terms.branch} · pull request #${terms.pr} · commit ${terms.approvedSha.slice(0, 7)}`,
242
+ terms === null ? `Checks: ${value("checks")} · check access: ${value("checkAccess")}` : `Carry later commits: ${terms.policy.carry ? "yes" : "no"} · checks: ${value("checks")} · expires ${terms.expiresAt}`,
243
+ // Option A's disclosure, on the card as on the approval sheet: never approved from chat without it.
244
+ ...(terms !== null && terms.policy.carry && terms.access !== "sandboxed" ? ["This project's checks don't run in the sandbox: later test-only commits will run outside the sandbox without being shown to you first."] : []),
245
+ "",
246
+ ...changeWords(changes, terms, 5),
247
+ "",
248
+ `Every term is in ${fileName}.`,
249
+ ].join("\n");
250
+ const full = [`${version === null ? "Deploy gate" : `Release ${version}`}: every term`, "", termsText(lines), "", ...changeWords(changes, terms, 200)].join("\n");
251
+ return { digest: cardDigestOf(scope.digest, formDigest, changes), short, full, fileName, changes };
252
+ }