toolroll 0.9.91 → 0.9.93

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 (124) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/approval-authority.d.ts +9 -1
  3. package/dist/approval-authority.js +15 -2
  4. package/dist/assignment-status.d.ts +1 -1
  5. package/dist/assignment-ui.js +2 -2
  6. package/dist/assignment.js +11 -3
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/browser/workspace.js +30 -30
  9. package/dist/browser-crew.js +2 -2
  10. package/dist/browser-workspace.d.ts +3 -5
  11. package/dist/build-review.js +2 -0
  12. package/dist/builder.js +21 -2
  13. package/dist/chat-actions.d.ts +11 -3
  14. package/dist/chat-actions.js +47 -2
  15. package/dist/chat-decide.js +2 -0
  16. package/dist/chat-review.d.ts +2 -1
  17. package/dist/chat-review.js +2 -2
  18. package/dist/check-facts.d.ts +124 -0
  19. package/dist/check-facts.js +272 -0
  20. package/dist/check-report.d.ts +5 -0
  21. package/dist/check-report.js +4 -3
  22. package/dist/cli.js +5 -0
  23. package/dist/contracts/chat-actions.d.ts +10 -0
  24. package/dist/contracts/chat-actions.js +4 -0
  25. package/dist/contracts/console-api.d.ts +3 -2
  26. package/dist/contracts/console-api.js +5 -2
  27. package/dist/contracts/release-check.d.ts +25 -1
  28. package/dist/contracts/release-check.js +19 -0
  29. package/dist/contracts/route-results.d.ts +121 -0
  30. package/dist/contracts/route-results.js +71 -0
  31. package/dist/contracts/route.d.ts +343 -5
  32. package/dist/contracts/route.js +36 -7
  33. package/dist/contracts/store-json.d.ts +2 -0
  34. package/dist/desktop-update.d.ts +2 -2
  35. package/dist/desktop-update.js +13 -9
  36. package/dist/dispose.js +6 -0
  37. package/dist/eval-hooks.d.ts +55 -0
  38. package/dist/eval-hooks.js +227 -4
  39. package/dist/flaky.d.ts +17 -0
  40. package/dist/flaky.js +9 -0
  41. package/dist/flow-engine.js +1 -1
  42. package/dist/flow-pull-request.js +1 -1
  43. package/dist/lead-doors.js +4 -2
  44. package/dist/lead-tools.js +10 -2
  45. package/dist/local-owner.d.ts +1 -1
  46. package/dist/local-owner.js +11 -0
  47. package/dist/memory-cli.d.ts +3 -0
  48. package/dist/memory-cli.js +3 -3
  49. package/dist/memory-pass.js +5 -1
  50. package/dist/model-choices.d.ts +7 -3
  51. package/dist/model-choices.js +26 -11
  52. package/dist/models-cli.d.ts +205 -0
  53. package/dist/models-cli.js +143 -4
  54. package/dist/models-ui.d.ts +57 -0
  55. package/dist/models-ui.js +44 -1
  56. package/dist/needs-you.d.ts +7 -7
  57. package/dist/needs-you.js +12 -10
  58. package/dist/operate-remote-arguments.js +4 -4
  59. package/dist/operate-remote.js +1 -1
  60. package/dist/operate.js +191 -23
  61. package/dist/phase-routing.d.ts +82 -2
  62. package/dist/phase-routing.js +132 -18
  63. package/dist/project-delete.d.ts +4 -0
  64. package/dist/project-delete.js +16 -4
  65. package/dist/project-memory.d.ts +8 -0
  66. package/dist/project-memory.js +20 -1
  67. package/dist/pull-request-flow.js +1 -1
  68. package/dist/push.d.ts +10 -3
  69. package/dist/push.js +22 -6
  70. package/dist/release-adapters.js +11 -1
  71. package/dist/release-approval.d.ts +191 -0
  72. package/dist/release-approval.js +382 -0
  73. package/dist/release-auto.js +22 -4
  74. package/dist/release-card.d.ts +73 -4
  75. package/dist/release-card.js +168 -23
  76. package/dist/release-cli.js +9 -1
  77. package/dist/release.d.ts +30 -0
  78. package/dist/release.js +42 -3
  79. package/dist/result-actions.d.ts +4 -0
  80. package/dist/result-actions.js +3 -0
  81. package/dist/result-acts.d.ts +3 -3
  82. package/dist/result-acts.js +1 -1
  83. package/dist/route-hooks.d.ts +54 -0
  84. package/dist/route-hooks.js +179 -0
  85. package/dist/route-results.d.ts +70 -0
  86. package/dist/route-results.js +340 -0
  87. package/dist/route-rules.d.ts +324 -0
  88. package/dist/route-rules.js +781 -0
  89. package/dist/route-savings.d.ts +136 -0
  90. package/dist/route-savings.js +193 -0
  91. package/dist/route-store.d.ts +300 -0
  92. package/dist/route-store.js +598 -0
  93. package/dist/scope.d.ts +3 -1
  94. package/dist/scope.js +4 -1
  95. package/dist/server/guards.js +1 -0
  96. package/dist/server/pages-assets.js +1 -1
  97. package/dist/server/render-chat.js +2 -2
  98. package/dist/server/render-release.d.ts +17 -0
  99. package/dist/server/render-release.js +84 -0
  100. package/dist/server/render-results.d.ts +0 -2
  101. package/dist/server/render-results.js +4 -6
  102. package/dist/server/render-tasks.d.ts +7 -0
  103. package/dist/server/render-tasks.js +16 -3
  104. package/dist/server/route-table.d.ts +1 -1
  105. package/dist/server/route-table.js +19 -1
  106. package/dist/server/settings.d.ts +2 -1
  107. package/dist/server/settings.js +87 -1
  108. package/dist/server/task-views.js +6 -0
  109. package/dist/server/tasks.d.ts +15 -0
  110. package/dist/server/tasks.js +105 -20
  111. package/dist/settings-proofs.js +2 -0
  112. package/dist/spend-ui.d.ts +11 -0
  113. package/dist/spend-ui.js +49 -2
  114. package/dist/stop-proof.d.ts +67 -0
  115. package/dist/stop-proof.js +293 -0
  116. package/dist/store.d.ts +106 -31
  117. package/dist/store.js +653 -128
  118. package/dist/surface.js +17 -5
  119. package/dist/task-outcome-cli.js +24 -2
  120. package/dist/toolroll-update-ui.js +2 -2
  121. package/dist/toolroll-update.js +4 -2
  122. package/dist/work-index.js +7 -9
  123. package/dist/work-summary.d.ts +2 -2
  124. package/package.json +1 -1
@@ -0,0 +1,191 @@
1
+ import { type PullRequestReader, type ReleaseCard, type ReleaseSeal, type TermLine } from "./release-card.js";
2
+ import { type ApproveResult, type Scope } from "./scope.js";
3
+ import { type Notification, type Store } from "./store.js";
4
+ /** The ledger act holding the coordinator's latest reading of the pull request's CI on the exact candidate. */
5
+ export declare const RELEASE_CI_SEEN_ACTION = "release CI observed";
6
+ /** One check's newest attempt, as the coordinator's own gh read it. A re-run is a new check run id. */
7
+ export type ReleaseCiRun = {
8
+ id: number | null;
9
+ name: string;
10
+ status: string;
11
+ conclusion: string | null;
12
+ };
13
+ export type ReleaseCiSnapshot = {
14
+ candidate: string;
15
+ at: string;
16
+ state: "passed" | "running" | "failed";
17
+ failed: string[];
18
+ runs: ReleaseCiRun[];
19
+ };
20
+ /**
21
+ * Record what the coordinator just read of the pull request's CI on `candidate` (its verdict and every check's newest
22
+ * attempt), when it differs from the last reading recorded: the sheet and the CLI show it, and the card digest binds
23
+ * it. The coordinator is Toolroll's own runtime reading GitHub through the plane's recorded repository.
24
+ */
25
+ export declare function recordReleaseCi(store: Store, input: {
26
+ taskId: string;
27
+ candidate: string;
28
+ state: ReleaseCiSnapshot["state"];
29
+ failed: readonly string[];
30
+ runs: readonly {
31
+ id?: number | null;
32
+ name: string;
33
+ status: string;
34
+ conclusion: string | null;
35
+ }[];
36
+ now: Date;
37
+ }): boolean;
38
+ /** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
39
+ export declare function releaseCiOf(store: Store, taskId: string, candidate: string): ReleaseCiSnapshot | null;
40
+ /** What of a CI reading the card binds: its state and each check's attempt, whether it finished, and how. Queued and
41
+ * in progress read alike (neither is finished); the time it was read is not bound. */
42
+ export declare function ciBinding(ci: ReleaseCiSnapshot | null): unknown;
43
+ /** GitHub CI in one line, as the sheet and the CLI say it. */
44
+ export declare function ciWords(ci: ReleaseCiSnapshot | null, candidate: string): string;
45
+ export type ReleaseApprovalCard = {
46
+ taskId: string;
47
+ taskRef: number;
48
+ repo: string;
49
+ scope: Scope;
50
+ seal: ReleaseSeal;
51
+ /** The chat release card (release-card.ts): the change list and its own digest. */
52
+ card: ReleaseCard;
53
+ /** Every signed term, one line each (release-card.ts approvalTermsOf). */
54
+ terms: TermLine[];
55
+ ci: ReleaseCiSnapshot | null;
56
+ /** Why it can't be approved now, in words; null when it can (or already is). */
57
+ refusal: string | null;
58
+ approved: {
59
+ by: string;
60
+ at: string;
61
+ } | null;
62
+ /** The release coordinator stopped (its last step failed): approving needs `toolroll release` run again. */
63
+ stopped: {
64
+ step: string;
65
+ message: string;
66
+ } | null;
67
+ /** Binds everything above that a person is shown and can change under them. */
68
+ digest: string;
69
+ };
70
+ /** A release task from the plane's own state: a sealed form (terms or a release check's purpose) whose repository is the
71
+ * task's project and whose approved commit is the scope's candidate. Never from a title or a task id. */
72
+ export declare function releaseTaskOf(store: Store, taskId: string): {
73
+ ref: {
74
+ id: number;
75
+ repo: string;
76
+ };
77
+ scope: Scope;
78
+ seal: ReleaseSeal;
79
+ } | null;
80
+ /** The authoritative card for a release task waiting for its person, or why there is none. Reads only. */
81
+ export declare function releaseApprovalCard(store: Store, taskId: string, options?: {
82
+ read?: PullRequestReader;
83
+ }): {
84
+ ok: true;
85
+ card: ReleaseApprovalCard;
86
+ } | {
87
+ ok: false;
88
+ why: string;
89
+ };
90
+ /** What approving does, in the sheet's and the CLI's words: the consequence, the check's access, and whether later
91
+ * commits carry. */
92
+ export declare function releaseConsequence(card: ReleaseApprovalCard): string[];
93
+ export type ReleaseCardRefusal = {
94
+ ok: false;
95
+ reason: "not-release" | "changed" | "approved" | "refused";
96
+ message: string;
97
+ } | {
98
+ ok: false;
99
+ reason: "approve";
100
+ result: Exclude<ApproveResult, {
101
+ ok: true;
102
+ }>;
103
+ };
104
+ /**
105
+ * The one card-approval wrapper (Codex round 2, finding 2): rebuild the authoritative card and compare the digest the
106
+ * person saw; then, in one transaction, re-read the store's parts (the scope digest, the CI reading, the refusal state)
107
+ * and refuse on any change; then hand THAT snapshot's scope digest to `approveNow`, the caller's unchanged
108
+ * transactional `approve()`, which proves the person and seals. Nothing here proves anyone.
109
+ */
110
+ export declare function approveReleaseCard(store: Store, input: {
111
+ taskId: string;
112
+ cardDigest: string;
113
+ read?: PullRequestReader;
114
+ approveNow: (scopeDigest: string) => ApproveResult;
115
+ }): {
116
+ ok: true;
117
+ scope: Scope;
118
+ } | ReleaseCardRefusal;
119
+ export declare const RELEASE_READY_KIND = "release-ready";
120
+ export declare const RELEASE_STOPPED_KIND = "release-stopped";
121
+ /** The one push phrase for a release waiting on its person (push.ts PUSH_WORDS.release). */
122
+ export declare const RELEASE_PUSH_WORDS = "A release is ready for your approval";
123
+ /** The sheet's own address for a task (the in-app link; a push carries the opaque `/approve/<task ref>` instead). */
124
+ export declare const releaseSheetHref: (taskId: string) => string;
125
+ /**
126
+ * The coordinator asked a person (on entering the approval step, a fresh filing or a resumed one): one notification per
127
+ * task and stable scope digest, so a crash between filing and asking is healed by the next run and a repeat says nothing
128
+ * new. Every older `release-ready` row of the project (another task or digest) is resolved first. Nothing when the task
129
+ * isn't a release waiting for a person.
130
+ */
131
+ export declare function noteReleaseReady(store: Store, input: {
132
+ taskId: string;
133
+ now: Date;
134
+ }): boolean;
135
+ /** Resolve a task's `release-ready` rows (it was approved, or the release stopped): a push not sent yet never is. */
136
+ export declare function retireReleaseReady(store: Store, taskRef: number, now: Date): number;
137
+ /**
138
+ * The push class `release` and its link, derived at send time from the plane's own state, never from a title, a task id,
139
+ * the stored link or the stored class: the row is a `release-ready` row for exactly its task ref and a scope digest, and
140
+ * that task is a release scope (terms or a release check's purpose, its candidate the approved commit), not put away,
141
+ * not approved, at that same digest. Null otherwise.
142
+ */
143
+ export declare function releasePushOf(store: Store, notification: Pick<Notification, "kind" | "dedupeKey" | "taskRef" | "resolvedAt">): {
144
+ link: string;
145
+ } | null;
146
+ /** A `release-ready` row that no longer stands (the push pass resolves it instead of sending it). */
147
+ export declare function releaseReadyObsolete(store: Store, notification: Pick<Notification, "kind" | "dedupeKey" | "taskRef" | "resolvedAt">): boolean;
148
+ /**
149
+ * A person's release stopped (CI failed, the check failed, the deploy, the merge, the tag or publication failed, or it
150
+ * stopped waiting): one attention notification per task, step and reason, with the stop's words in the app (the push
151
+ * says only the fixed attention phrase). Its `release-ready` row is resolved, except while it only stopped waiting
152
+ * for the approval (the sheet still approves it then).
153
+ */
154
+ export declare function noteReleaseStopped(store: Store, input: {
155
+ taskId: string;
156
+ step: string;
157
+ reason: string;
158
+ message: string;
159
+ now: Date;
160
+ }): boolean;
161
+ /** The card as the CLI prints it, every line control-free (src/terminal-text.ts). */
162
+ export declare function releaseCardLines(card: ReleaseApprovalCard): string[];
163
+ /** One release journal as discovery reads it: strictly, every field it relies on in its exact shape. */
164
+ export type WaitingJournal = {
165
+ file: string;
166
+ branch: string;
167
+ checkout: string;
168
+ taskId: string;
169
+ scopeDigest: string;
170
+ candidate: string;
171
+ version: string;
172
+ pr: number;
173
+ };
174
+ /** A journal's own fields, strictly; null when any is missing or not its exact shape. */
175
+ export declare function readWaitingJournal(file: string, raw: unknown, expectedFile: (branch: string) => string): WaitingJournal | null;
176
+ /**
177
+ * Whether a journal (discovery only) names exactly the release the plane sealed: the task is a release scope whose
178
+ * sealed identity has this exact branch, version, pull request and candidate at this scope digest, and the journal's
179
+ * checkout, the task's project and the sealed repository are one real folder (`repo` too, when given). Why not, in words.
180
+ */
181
+ export declare function journalMatchesPlane(store: Store, journal: WaitingJournal, repo: string | null): {
182
+ ok: true;
183
+ } | {
184
+ ok: false;
185
+ why: string;
186
+ };
187
+ /** Every release journal in the state folder, read strictly; unreadable ones are counted, never guessed at. */
188
+ export declare function waitingJournals(stateDir: string, expectedFile: (branch: string) => string): {
189
+ journals: WaitingJournal[];
190
+ unreadable: string[];
191
+ };
@@ -0,0 +1,382 @@
1
+ /**
2
+ * One-tap release approval (docs/plans/trusted-release-reports.md, slice A v3): the plane's side of one push, one
3
+ * screen and one proof for a release a person must approve.
4
+ *
5
+ * - `releaseApprovalCard`: the authoritative card snapshot of a release task waiting for its person, for the sheet
6
+ * (`/t/<id>/release`) and `toolroll release approve`: either sealed form (release terms or a release check's purpose),
7
+ * the change list, GitHub CI as the coordinator last read it on the exact commit, why it can't be approved now (shown,
8
+ * never hidden) and the card digest, which binds all of it.
9
+ * - `approveReleaseCard`: the one wrapper both doors call. It rebuilds the card, compares the digest the person saw,
10
+ * re-reads the store's parts inside one transaction and only then passes THAT snapshot's scope digest into the
11
+ * unchanged transactional `approve()` the caller hands in. The digest is never swapped after the person was asked.
12
+ * - The notification's whole life: `noteReleaseReady` (one per task and scope digest, older ones resolved), the push
13
+ * class derived at send time from the plane's own state (`releasePushOf`), `noteReleaseStopped` for a manual
14
+ * release's stop, and the coordinator's CI snapshots (`recordReleaseCi`).
15
+ * Nothing here proves a person, widens a proof or seals anything itself.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+ import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { permissionPlainWords } from "./chat-terms.js";
21
+ import { approvalTermsOf, releaseAccessWords, releaseCardOf, releaseSealOf } from "./release-card.js";
22
+ import { releaseCiHoldOf, releaseCiHoldWords } from "./release-ci-hold.js";
23
+ import { approvalOf } from "./scope.js";
24
+ import { releaseProgressFor } from "./store.js";
25
+ import { taskArchived } from "./task-archive.js";
26
+ import { cleanTerminalLine } from "./terminal-text.js";
27
+ const SHA = /^[0-9a-f]{40}$/;
28
+ const DIGEST = /^[0-9a-f]{64}$/;
29
+ /** A scope digest as scope.ts writes it. */
30
+ const SCOPE_DIGEST = /^[0-9a-f]{32,64}$/;
31
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
32
+ // ---- GitHub CI as the coordinator read it -------------------------------------------------------------------------
33
+ /** The ledger act holding the coordinator's latest reading of the pull request's CI on the exact candidate. */
34
+ export const RELEASE_CI_SEEN_ACTION = "release CI observed";
35
+ const ciName = (value) => cleanTerminalLine(String(value ?? "")).trim().slice(0, 120);
36
+ /**
37
+ * Record what the coordinator just read of the pull request's CI on `candidate` (its verdict and every check's newest
38
+ * attempt), when it differs from the last reading recorded: the sheet and the CLI show it, and the card digest binds
39
+ * it. The coordinator is Toolroll's own runtime reading GitHub through the plane's recorded repository.
40
+ */
41
+ export function recordReleaseCi(store, input) {
42
+ if (!SHA.test(input.candidate) || store.lookupRef(input.taskId) === null)
43
+ return false;
44
+ // The newest attempt of each check (the adapter lists attempts oldest first), by name.
45
+ const latest = new Map();
46
+ for (const run of input.runs.slice(0, 2_000)) {
47
+ const name = ciName(run.name);
48
+ if (name === "")
49
+ continue;
50
+ latest.set(name, { id: typeof run.id === "number" && Number.isSafeInteger(run.id) ? run.id : null, name, status: ciName(run.status).slice(0, 40), conclusion: run.conclusion === null ? null : ciName(run.conclusion).slice(0, 40) });
51
+ }
52
+ const runs = [...latest.values()].sort((a, b) => a.name.localeCompare(b.name)).slice(0, 200);
53
+ const detail = JSON.stringify({ v: 1, candidate: input.candidate, state: input.state, failed: input.failed.map(ciName).slice(0, 50), runs });
54
+ const last = store.handle.prepare("SELECT detail FROM action_ledger WHERE task_id = ? AND action = ? ORDER BY id DESC LIMIT 1").get(input.taskId, RELEASE_CI_SEEN_ACTION);
55
+ if (last !== undefined && last["detail"] === detail)
56
+ return false;
57
+ store.recordAction({ at: input.now.toISOString(), actor: "toolroll", repo: store.lookupRef(input.taskId)?.repo ?? null, taskId: input.taskId, runId: null,
58
+ action: RELEASE_CI_SEEN_ACTION, outcome: input.state, source: "work", detail });
59
+ return true;
60
+ }
61
+ /** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
62
+ export function releaseCiOf(store, taskId, candidate) {
63
+ const row = store.handle.prepare("SELECT detail, at FROM action_ledger WHERE task_id = ? AND action = ? ORDER BY id DESC LIMIT 1").get(taskId, RELEASE_CI_SEEN_ACTION);
64
+ if (row === undefined)
65
+ return null;
66
+ let parsed;
67
+ try {
68
+ parsed = JSON.parse(String(row["detail"]));
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ const one = parsed;
74
+ if (one.v !== 1 || one.candidate !== candidate || !["passed", "running", "failed"].includes(String(one.state)) || !Array.isArray(one.failed) || !Array.isArray(one.runs))
75
+ return null;
76
+ return { candidate, at: String(row["at"]), state: one.state, failed: one.failed.map(ciName),
77
+ runs: one.runs.map(run => { const r = run; return { id: typeof r["id"] === "number" ? r["id"] : null, name: ciName(r["name"]), status: ciName(r["status"]), conclusion: r["conclusion"] == null ? null : ciName(r["conclusion"]) }; }) };
78
+ }
79
+ /** What of a CI reading the card binds: its state and each check's attempt, whether it finished, and how. Queued and
80
+ * in progress read alike (neither is finished); the time it was read is not bound. */
81
+ export function ciBinding(ci) {
82
+ return ci === null ? null : { candidate: ci.candidate, state: ci.state, failed: ci.failed, runs: ci.runs.map(run => [run.id, run.name, run.status === "completed", run.conclusion]) };
83
+ }
84
+ /** GitHub CI in one line, as the sheet and the CLI say it. */
85
+ export function ciWords(ci, candidate) {
86
+ const short = candidate.slice(0, 7);
87
+ if (ci === null)
88
+ return `GitHub CI on ${short} hasn't been read yet.`;
89
+ if (ci.state === "passed")
90
+ return `GitHub CI passed on ${short}.`;
91
+ if (ci.state === "failed")
92
+ return `GitHub CI failed on ${short}: ${ci.failed.join(", ") || "a check"}.`;
93
+ const finished = ci.runs.filter(run => run.status === "completed").length;
94
+ return `GitHub CI is running on ${short}: ${finished} of ${ci.runs.length} checks finished.`;
95
+ }
96
+ /** The release check failed on this task already (the coordinator's record): nobody approves it again here. */
97
+ function checkFailed(store, taskId) {
98
+ return store.handle.prepare("SELECT 1 FROM release_step WHERE task_id = ? AND step = 'check' AND state = 'failed' LIMIT 1").get(taskId) !== undefined;
99
+ }
100
+ /** Why a release can't be approved now, from the plane's own state, or null. */
101
+ function refusalOf(store, taskId, scope, ci) {
102
+ if (taskArchived(store, taskId))
103
+ return "This release is put away. Restore it before approving.";
104
+ const hold = releaseCiHoldOf(store, taskId);
105
+ if (hold !== null)
106
+ return releaseCiHoldWords(hold);
107
+ if (ci?.state === "failed")
108
+ return `${ciWords(ci, scope.candidate)} Fix the branch or rerun the failed job on GitHub, then release again.`;
109
+ if (checkFailed(store, taskId))
110
+ return "The release check already failed on this commit. Fix the branch and release the new commit.";
111
+ if (scope.approvedAt !== null && scope.approvedDigest !== scope.digest)
112
+ return "The release's terms changed after an earlier approval. Read them again.";
113
+ return null;
114
+ }
115
+ /** A release task from the plane's own state: a sealed form (terms or a release check's purpose) whose repository is the
116
+ * task's project and whose approved commit is the scope's candidate. Never from a title or a task id. */
117
+ export function releaseTaskOf(store, taskId) {
118
+ const ref = store.lookupRef(taskId);
119
+ const scope = store.getScope(taskId);
120
+ const seal = releaseSealOf(scope);
121
+ if (ref === null || ref.repo === null || scope === null || seal === null || scope.candidate == null || scope.candidate !== seal.identity.approvedSha || seal.identity.repo !== ref.repo)
122
+ return null;
123
+ return { ref: { id: ref.id, repo: ref.repo }, scope, seal };
124
+ }
125
+ /** The authoritative card for a release task waiting for its person, or why there is none. Reads only. */
126
+ export function releaseApprovalCard(store, taskId, options = {}) {
127
+ const found = releaseTaskOf(store, taskId);
128
+ if (found === null)
129
+ return { ok: false, why: "This isn't a release waiting for approval." };
130
+ const { ref, scope, seal } = found;
131
+ const terms = approvalTermsOf(store, scope, { profile: permissionPlainWords });
132
+ const card = releaseCardOf(store, scope, scope.digest, { profile: permissionPlainWords }, options.read);
133
+ const ci = releaseCiOf(store, taskId, scope.candidate);
134
+ const approval = approvalOf(scope);
135
+ const approved = approval.approved ? { by: approval.by, at: approval.at } : null;
136
+ const refusal = approved === null ? refusalOf(store, taskId, scope, ci) : null;
137
+ const progress = releaseProgressFor(store, taskId);
138
+ const stopped = progress?.stopped == null ? null : { step: progress.stopped.step, message: cleanTerminalLine(progress.stopped.message).slice(0, 300) };
139
+ const digest = sha256(`release-sheet:v1:${JSON.stringify([card.digest, scope.digest, ciBinding(ci), refusal, approved])}`);
140
+ return { ok: true, card: { taskId, taskRef: ref.id, repo: ref.repo, scope, seal, card, terms, ci, refusal, approved, stopped, digest } };
141
+ }
142
+ /** What approving does, in the sheet's and the CLI's words: the consequence, the check's access, and whether later
143
+ * commits carry. */
144
+ export function releaseConsequence(card) {
145
+ const id = card.seal.identity;
146
+ const carries = card.seal.kind === "terms" && card.seal.terms.policy.carry;
147
+ return [
148
+ `Approving runs the release check on ${id.approvedSha.slice(0, 7)}, then deploys, merges, tags and publishes once GitHub CI and the check pass.`,
149
+ ...(releaseAccessWords(id.access) === null ? [] : [releaseAccessWords(id.access)]),
150
+ carries ? "Later commits that only change tests, docs or the changelog carry this approval." : "Every later commit asks again.",
151
+ ];
152
+ }
153
+ /**
154
+ * The one card-approval wrapper (Codex round 2, finding 2): rebuild the authoritative card and compare the digest the
155
+ * person saw; then, in one transaction, re-read the store's parts (the scope digest, the CI reading, the refusal state)
156
+ * and refuse on any change; then hand THAT snapshot's scope digest to `approveNow`, the caller's unchanged
157
+ * transactional `approve()`, which proves the person and seals. Nothing here proves anyone.
158
+ */
159
+ export function approveReleaseCard(store, input) {
160
+ if (!DIGEST.test(input.cardDigest))
161
+ return { ok: false, reason: "changed", message: "This release changed while you were reading it. Read it again." };
162
+ const built = releaseApprovalCard(store, input.taskId, input.read === undefined ? {} : { read: input.read });
163
+ if (!built.ok)
164
+ return { ok: false, reason: "not-release", message: built.why };
165
+ const card = built.card;
166
+ if (card.digest !== input.cardDigest)
167
+ return { ok: false, reason: "changed", message: "This release changed while you were reading it. Read it again." };
168
+ if (card.approved !== null)
169
+ return { ok: false, reason: "approved", message: `Already approved by ${card.approved.by}.` };
170
+ if (card.refusal !== null)
171
+ return { ok: false, reason: "refused", message: card.refusal };
172
+ return store.transact(() => {
173
+ const scope = store.getScope(input.taskId);
174
+ if (scope === null || scope.digest !== card.scope.digest || approvalOf(scope).approved)
175
+ return { ok: false, reason: "changed", message: "This release changed while you were reading it. Read it again." };
176
+ const ci = releaseCiOf(store, input.taskId, card.scope.candidate);
177
+ const refusal = refusalOf(store, input.taskId, scope, ci);
178
+ if (JSON.stringify(ciBinding(ci)) !== JSON.stringify(ciBinding(card.ci)) || refusal !== card.refusal) {
179
+ return { ok: false, reason: "changed", message: "This release changed while you were reading it. Read it again." };
180
+ }
181
+ const result = input.approveNow(card.scope.digest);
182
+ return result.ok ? { ok: true, scope: result.scope } : { ok: false, reason: "approve", result };
183
+ });
184
+ }
185
+ // ---- notifications --------------------------------------------------------------------------------------------
186
+ export const RELEASE_READY_KIND = "release-ready";
187
+ export const RELEASE_STOPPED_KIND = "release-stopped";
188
+ /** The one push phrase for a release waiting on its person (push.ts PUSH_WORDS.release). */
189
+ export const RELEASE_PUSH_WORDS = "A release is ready for your approval";
190
+ const READY_KEY = /^release-ready:([1-9][0-9]{0,14}):([0-9a-f]{32,64})$/;
191
+ /** The sheet's own address for a task (the in-app link; a push carries the opaque `/approve/<task ref>` instead). */
192
+ export const releaseSheetHref = (taskId) => `/t/${encodeURIComponent(taskId)}/release`;
193
+ /**
194
+ * The coordinator asked a person (on entering the approval step, a fresh filing or a resumed one): one notification per
195
+ * task and stable scope digest, so a crash between filing and asking is healed by the next run and a repeat says nothing
196
+ * new. Every older `release-ready` row of the project (another task or digest) is resolved first. Nothing when the task
197
+ * isn't a release waiting for a person.
198
+ */
199
+ export function noteReleaseReady(store, input) {
200
+ return store.transact(() => {
201
+ const found = releaseTaskOf(store, input.taskId);
202
+ if (found === null || approvalOf(found.scope).approved || taskArchived(store, input.taskId))
203
+ return false;
204
+ const key = `release-ready:${found.ref.id}:${found.scope.digest}`;
205
+ store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE kind = ? AND resolved_at IS NULL AND project = ? AND dedupe_key <> ?")
206
+ .run(input.now.toISOString(), RELEASE_READY_KIND, found.ref.repo, key);
207
+ const id = found.seal.identity;
208
+ const short = id.approvedSha.slice(0, 7);
209
+ const ci = releaseCiOf(store, input.taskId, id.approvedSha);
210
+ const body = ci?.state === "passed"
211
+ ? `GitHub CI passed on ${short}. Approve to run the release check, deploy, merge and publish.`
212
+ : `Release ${id.packageVersion} (${short}) is ready to approve. You can approve now: Toolroll runs the release check after your yes, and deploys, merges and publishes only once GitHub CI and the check pass. You're told if something fails.`;
213
+ return store.enqueueNotification({ dedupeKey: key, kind: RELEASE_READY_KIND, pushClass: "decision", link: releaseSheetHref(input.taskId), source: { taskRef: found.ref.id },
214
+ subject: `Release ${id.packageVersion} is ready for your approval`, body }, input.now);
215
+ });
216
+ }
217
+ /** Resolve a task's `release-ready` rows (it was approved, or the release stopped): a push not sent yet never is. */
218
+ export function retireReleaseReady(store, taskRef, now) {
219
+ return Number(store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE kind = ? AND resolved_at IS NULL AND task_ref = ?").run(now.toISOString(), RELEASE_READY_KIND, taskRef).changes);
220
+ }
221
+ /**
222
+ * The push class `release` and its link, derived at send time from the plane's own state, never from a title, a task id,
223
+ * the stored link or the stored class: the row is a `release-ready` row for exactly its task ref and a scope digest, and
224
+ * that task is a release scope (terms or a release check's purpose, its candidate the approved commit), not put away,
225
+ * not approved, at that same digest. Null otherwise.
226
+ */
227
+ export function releasePushOf(store, notification) {
228
+ if (notification.kind !== RELEASE_READY_KIND || notification.taskRef === null || notification.resolvedAt !== null)
229
+ return null;
230
+ const key = READY_KEY.exec(notification.dedupeKey);
231
+ if (key === null || Number(key[1]) !== notification.taskRef)
232
+ return null;
233
+ const ref = store.refForId(notification.taskRef);
234
+ if (ref === null || ref.backend !== "built-in")
235
+ return null;
236
+ const found = releaseTaskOf(store, ref.externalId);
237
+ if (found === null || found.ref.id !== notification.taskRef)
238
+ return null;
239
+ if (found.scope.digest !== key[2] || approvalOf(found.scope).approved || taskArchived(store, ref.externalId))
240
+ return null;
241
+ return { link: `/approve/${notification.taskRef}` };
242
+ }
243
+ /** A `release-ready` row that no longer stands (the push pass resolves it instead of sending it). */
244
+ export function releaseReadyObsolete(store, notification) {
245
+ return notification.kind === RELEASE_READY_KIND && releasePushOf(store, notification) === null;
246
+ }
247
+ /**
248
+ * A person's release stopped (CI failed, the check failed, the deploy, the merge, the tag or publication failed, or it
249
+ * stopped waiting): one attention notification per task, step and reason, with the stop's words in the app (the push
250
+ * says only the fixed attention phrase). Its `release-ready` row is resolved, except while it only stopped waiting
251
+ * for the approval (the sheet still approves it then).
252
+ */
253
+ export function noteReleaseStopped(store, input) {
254
+ const ref = store.lookupRef(input.taskId);
255
+ if (ref === null)
256
+ return false;
257
+ const step = /^[a-z-]{1,20}$/.test(input.step) ? input.step : "release";
258
+ const reason = /^[a-z-]{1,40}$/.test(input.reason) ? input.reason : "error";
259
+ return store.transact(() => {
260
+ if (!(step === "approval" && reason === "timeout"))
261
+ retireReleaseReady(store, ref.id, input.now);
262
+ const seal = releaseSealOf(store.getScope(input.taskId));
263
+ const words = cleanTerminalLine(input.message).replace(/\s+/g, " ").trim().slice(0, 600);
264
+ return store.enqueueNotification({ dedupeKey: `release-stopped:${ref.id}:${step}:${reason}`, kind: RELEASE_STOPPED_KIND, pushClass: "attention",
265
+ link: `/t/${encodeURIComponent(input.taskId)}`, source: { taskRef: ref.id },
266
+ subject: `Release${seal === null ? "" : ` ${seal.identity.packageVersion}`} stopped at ${step}`, body: words === "" ? "The release stopped. Open it to see why." : words }, input.now);
267
+ });
268
+ }
269
+ // ---- the terminal -------------------------------------------------------------------------------------------------
270
+ /** The card as the CLI prints it, every line control-free (src/terminal-text.ts). */
271
+ export function releaseCardLines(card) {
272
+ const id = card.seal.identity;
273
+ const changes = card.card.changes;
274
+ const lines = [
275
+ `Release ${id.packageVersion} · pull request #${id.pr} · ${id.branch}`,
276
+ `Commit ${id.approvedSha}`,
277
+ ciWords(card.ci, id.approvedSha),
278
+ ...releaseConsequence(card),
279
+ ...(card.stopped === null ? [] : [`The release isn't running (it stopped at ${card.stopped.step}). After you approve, run \`toolroll release ${id.branch}\`.`]),
280
+ "",
281
+ changes.commits === null
282
+ ? `What changed couldn't be read${changes.why === undefined ? "" : ` (${changes.why})`}.`
283
+ : changes.baseline === null ? `Pull request #${id.pr}'s commits (${changes.commits.length}):` : `Changes since the last verified release (${changes.baseline.slice(0, 7)}), ${changes.commits.length}:`,
284
+ ...(changes.commits ?? []).map(one => ` ${one.sha.slice(0, 7)} ${one.subject}`),
285
+ "",
286
+ "Every term:",
287
+ ...card.terms.map(line => ` ${line.label}: ${line.value}`),
288
+ ...(card.refusal === null ? [] : ["", `Can't approve now: ${card.refusal}`]),
289
+ ];
290
+ return lines.map(cleanTerminalLine);
291
+ }
292
+ const BRANCH = /^[A-Za-z0-9._/-]{1,255}$/;
293
+ const TASK_ID = /^[A-Za-z0-9._-]{1,200}$/;
294
+ const VERSION = /^\d+\.\d+\.\d+$/;
295
+ /** A journal's own fields, strictly; null when any is missing or not its exact shape. */
296
+ export function readWaitingJournal(file, raw, expectedFile) {
297
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
298
+ return null;
299
+ const j = raw;
300
+ const task = j["task"];
301
+ const candidate = j["candidate"];
302
+ if (j["version"] !== 1 || (j["step"] !== "approval" && j["step"] !== "gate"))
303
+ return null;
304
+ if (typeof j["branch"] !== "string" || !BRANCH.test(j["branch"]) || j["branch"].startsWith("-") || j["branch"].includes(".."))
305
+ return null;
306
+ if (typeof j["checkout"] !== "string" || !j["checkout"].startsWith("/") || typeof j["packageVersion"] !== "string" || !VERSION.test(j["packageVersion"]))
307
+ return null;
308
+ if (typeof j["pr"] !== "number" || !Number.isSafeInteger(j["pr"]) || j["pr"] < 1)
309
+ return null;
310
+ if (task === null || typeof task !== "object" || typeof task["id"] !== "string" || !TASK_ID.test(task["id"]) || typeof task["scopeDigest"] !== "string" || !SCOPE_DIGEST.test(task["scopeDigest"]))
311
+ return null;
312
+ if (candidate === null || typeof candidate !== "object" || typeof candidate["sha"] !== "string" || !SHA.test(candidate["sha"]))
313
+ return null;
314
+ // The file must be the one its own branch maps to: two branches that map to one file name never stand in for each other.
315
+ if (expectedFile(j["branch"]) !== file)
316
+ return null;
317
+ return { file, branch: j["branch"], checkout: j["checkout"], taskId: task["id"], scopeDigest: task["scopeDigest"], candidate: candidate["sha"], version: j["packageVersion"], pr: j["pr"] };
318
+ }
319
+ const real = (path) => { try {
320
+ return realpathSync(path);
321
+ }
322
+ catch {
323
+ return null;
324
+ } };
325
+ /**
326
+ * Whether a journal (discovery only) names exactly the release the plane sealed: the task is a release scope whose
327
+ * sealed identity has this exact branch, version, pull request and candidate at this scope digest, and the journal's
328
+ * checkout, the task's project and the sealed repository are one real folder (`repo` too, when given). Why not, in words.
329
+ */
330
+ export function journalMatchesPlane(store, journal, repo) {
331
+ const found = releaseTaskOf(store, journal.taskId);
332
+ if (found === null)
333
+ return { ok: false, why: `${journal.branch}'s release task isn't a release this plane can approve` };
334
+ const id = found.seal.identity;
335
+ if (id.branch !== journal.branch || id.packageVersion !== journal.version || id.pr !== journal.pr || id.approvedSha !== journal.candidate) {
336
+ return { ok: false, why: `${journal.branch}'s journal doesn't name the release the plane sealed (branch ${id.branch}, ${id.packageVersion}, #${id.pr}, ${id.approvedSha.slice(0, 7)})` };
337
+ }
338
+ if (found.scope.digest !== journal.scopeDigest)
339
+ return { ok: false, why: `the release's terms changed since ${journal.branch} was filed; run \`toolroll release\` again` };
340
+ const checkout = real(journal.checkout), project = real(found.ref.repo), sealed = real(id.repo);
341
+ if (checkout === null || project === null || checkout !== project || sealed !== project)
342
+ return { ok: false, why: `${journal.branch}'s checkout isn't the project the release was sealed for` };
343
+ if (repo !== null && real(repo) !== project)
344
+ return { ok: false, why: `${journal.branch} isn't a release of ${repo}` };
345
+ return { ok: true };
346
+ }
347
+ /** Every release journal in the state folder, read strictly; unreadable ones are counted, never guessed at. */
348
+ export function waitingJournals(stateDir, expectedFile) {
349
+ const folder = join(stateDir, "releases");
350
+ let names = [];
351
+ try {
352
+ names = readdirSync(folder).filter(name => name.endsWith(".json"));
353
+ }
354
+ catch {
355
+ return { journals: [], unreadable: [] };
356
+ }
357
+ const journals = [], unreadable = [];
358
+ for (const name of names.sort()) {
359
+ const file = join(folder, name);
360
+ let raw;
361
+ try {
362
+ if (!lstatSync(file).isFile()) {
363
+ unreadable.push(name);
364
+ continue;
365
+ }
366
+ raw = JSON.parse(readFileSync(file, "utf8"));
367
+ }
368
+ catch {
369
+ unreadable.push(name);
370
+ continue;
371
+ }
372
+ const step = raw?.step;
373
+ if (step !== "approval" && step !== "gate")
374
+ continue;
375
+ const read = readWaitingJournal(file, raw, expectedFile);
376
+ if (read === null)
377
+ unreadable.push(name);
378
+ else
379
+ journals.push(read);
380
+ }
381
+ return { journals, unreadable };
382
+ }
@@ -12,12 +12,13 @@
12
12
  * Git directory. No command or console road reaches these functions: `toolroll release` calls them through its store
13
13
  * adapter. Any refusal unseals `auto-check`, so the task asks a person, its passing check kept.
14
14
  */
15
+ import { ROUTING_HOOK_FILES, routingHookProblem } from "./route-hooks.js";
15
16
  import { assignmentOf } from "./assignment.js";
16
17
  import { fullCheckProblem, projectGitDir } from "./check-report.js";
17
18
  import { sealedRun } from "./release-recheck.js";
18
19
  import { runCheckLevel } from "./check-levels.js";
19
20
  import { readVerifiedCommit, verifiedBlobs, verifiedTreeDiff, verifiedTreeEntries } from "./commit-extract.js";
20
- import { EVAL_HOOK_FILES, evalHookProblem, storeRewriteProblem } from "./eval-hooks.js";
21
+ import { EVAL_HOOK_FILES, evalHookProblem, LEARNING_HOOK_FILES, learningHookProblem, storeRewriteProblem } from "./eval-hooks.js";
21
22
  import { mightName, testInfrastructure } from "./test-infra.js";
22
23
  import { chatControlHref } from "./chat-controls.js";
23
24
  import { INFRA_RERUNS_PER_CANDIDATE, REQUIRED_PR_CHECKS, REQUIRED_PR_CHECKS_WORKFLOW, prChecksVerdict } from "./release.js";
@@ -182,8 +183,13 @@ const TEST_FILE = /^(?!.*(^|\/)fixtures?\/)[^]*\.test\.ts$/;
182
183
  /** The eval set's and GitHub nightly's policy code (docs/plans/evals-nightly.md, part C, finding 10): the cases and
183
184
  * oracles, the evaluator, its gate, hooks and world, reviewer scoring, nightly ingestion, their contracts, and the
184
185
  * accounting readings evals trust (reported-usage.ts, run-efficiency.ts). Where they are called from, and the store's
185
- * accounting reads, are held by eval-hooks.ts instead (evalHookProblem). */
186
- const EVAL_POLICY = /^(evals\/|src\/evals[^/]*\.ts$|src\/eval-[^/]*\.ts$|src\/reviewer-evals\.ts$|src\/nightly-github\.ts$|src\/contracts\/evals[^/]*\.ts$|src\/reported-usage\.ts$|src\/run-efficiency\.ts$)/;
186
+ * accounting reads, are held by eval-hooks.ts instead (evalHookProblem). Results routing's policy is here too
187
+ * (docs/plans/results-routing.md, Protection 1): the record evaluator, the rules, the one writer module, this scan's
188
+ * manifest and the record contract; their callers and pinned bodies are route-hooks.ts's (routingHookProblem). The
189
+ * learning-loops policy modules are here whole (docs/plans/learning-loops.md, Protection 1): check facts, flaky tests
190
+ * (local and CI), lesson notes, memory use and tidy-up; their callers, pinned bodies and SQL writers are eval-hooks.ts's
191
+ * (learningHookProblem). */
192
+ const EVAL_POLICY = /^(evals\/|src\/evals[^/]*\.ts$|src\/eval-[^/]*\.ts$|src\/reviewer-evals\.ts$|src\/nightly-github\.ts$|src\/contracts\/evals[^/]*\.ts$|src\/reported-usage\.ts$|src\/run-efficiency\.ts$|src\/route-(?:results|rules|store|hooks)\.ts$|src\/contracts\/route-results\.ts$|src\/(?:check-facts|flaky|flaky-ci|lesson-notes|memory-use|memory-tidy)\.ts$)/;
187
193
  /** Build or type configuration anywhere: a person approves it. */
188
194
  const BUILD_CONFIG = /(^|\/)(tsconfig[^/]*\.json|[^/]*\.config\.[cm]?[jt]s|\.npmrc|\.nvmrc|\.node-version)$/;
189
195
  /**
@@ -209,7 +215,7 @@ export function autoPathProblem(path) {
209
215
  // Test files under test/ carry the same trust as src's (Alex: test authors are trusted) — only the `.test.ts` files
210
216
  // themselves, never a helper, fixture, setup or config file beside them, nor one inside a fixtures folder.
211
217
  if (EVAL_POLICY.test(normal))
212
- return "it's eval or nightly policy code";
218
+ return "it's eval, nightly, model routing or learning policy code";
213
219
  if (testUnderTest)
214
220
  return null;
215
221
  if (BUILD_CONFIG.test(normal))
@@ -334,6 +340,18 @@ function changeProblem(repo, base, candidate) {
334
340
  const rewrites = storeRewriteProblem(changed, changedAt(after.value.tree), changedAt(before.value.tree));
335
341
  if (rewrites !== null)
336
342
  return `${rewrites}: a person decides`;
343
+ // Results routing (route-hooks.ts): its required calls and pinned bodies the base holds, and no changed file writing
344
+ // its rows (or the Models rows) from anywhere but their writers.
345
+ const routingAt = (tree) => { const blobs = verifiedBlobs(gitDir, tree, ROUTING_HOOK_FILES); return (file) => blobs.ok ? blobs.value.get(file)?.bytes ?? null : null; };
346
+ const routing = routingHookProblem({ candidate: routingAt(after.value.tree), base: routingAt(before.value.tree) }, { paths: changed, candidate: changedAt(after.value.tree), base: changedAt(before.value.tree) });
347
+ if (routing !== null)
348
+ return `${routing}: a person decides`;
349
+ // Learning loops (eval-hooks.ts learningHookProblem): the calls that record a check's facts, the strictly pinned
350
+ // memory and check bodies the base holds, and no changed file writing a learning-loops table outside its writers.
351
+ const learningAt = (tree) => { const blobs = verifiedBlobs(gitDir, tree, LEARNING_HOOK_FILES); return (file) => blobs.ok ? blobs.value.get(file)?.bytes ?? null : null; };
352
+ const learning = learningHookProblem({ candidate: learningAt(after.value.tree), base: learningAt(before.value.tree) }, { paths: changed, candidate: changedAt(after.value.tree), base: changedAt(before.value.tree) });
353
+ if (learning !== null)
354
+ return `${learning}: a person decides`;
337
355
  for (const change of diff.value) {
338
356
  const modes = [change.before?.mode, change.after?.mode].filter((one) => one !== undefined);
339
357
  if (modes.includes("120000") || modes.includes("160000"))