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,272 @@
1
+ /**
2
+ * Check facts (docs/plans/learning-loops.md, "Foundation: check facts"): what each settled check ran under and what it
3
+ * reported, recorded at disposition, never trusted for authority. The only writer of `check_fact` and `check_result`
4
+ * (the v149 migration aside); release-auto's learning scan (eval-hooks.ts learningWriteProblem) needs a person for
5
+ * SQL that writes them from anywhere else. A policy path (release-auto.ts EVAL_POLICY): any change needs a person.
6
+ *
7
+ * - Conditions are captured when the check's command starts (captureCheckConditions, called from the builder's
8
+ * runVerification), held in memory and passed here: the commit's tree read through verified objects (never the
9
+ * checkout), the approved command's digest, the check's access, the harness digest and lockfile, and the values of
10
+ * every environment variable the check inherits (the setup allowlist), hashed with checkContextOf's fields as
11
+ * `conditions_digest`. Not re-read later.
12
+ * - Results are tagged by source: `receipt` (the plane's own whole-check result: trusted for "the check passed or
13
+ * failed", nothing finer), `report` (the candidate-written report's lines), `log` (Vitest's FAIL lines in the sealed
14
+ * log, failures only, file level) and `learn` (the advisory learning line). Every failure is recorded; the check's
15
+ * blocking failure is the check's and is never changed here.
16
+ * - Explicit execution evidence is a `report` or `learn` line naming an identity. No line is no evidence: never
17
+ * skipped, removed or passed.
18
+ * - Fail closed: an unreadable tree, or conditions never captured (the command never started), records the fact row
19
+ * with no conditions and no results at all.
20
+ *
21
+ * Nothing here changes a check's outcome, a verdict, an approval or a rerun: every reader of a check's outcome reads
22
+ * the receipt and run_check, which this module never writes.
23
+ */
24
+ import { createHash } from "node:crypto";
25
+ import { failingTestsOf } from "./gate-failure.js";
26
+ import { checkContextOf, projectGitDir, treeFactsOf } from "./check-report.js";
27
+ import { readVerifiedArtifact } from "./evidence.js";
28
+ import { CHECK_REPORT_PREFIX, isExactCase, learnLineSchema, learnRequest, LEARN_MAX_BYTES, LEARN_PREFIX } from "./contracts/release-check.js";
29
+ import { nightlyTaskRef, releaseTaskRef } from "./store.js";
30
+ /** The sources a result can come from, and the states it can have. */
31
+ export const CHECK_RESULT_SOURCES = Object.freeze(["receipt", "report", "log", "learn"]);
32
+ /** The receipt's whole-check identity, and the one a failed check with nothing else named records. */
33
+ export const WHOLE_CHECK = "check";
34
+ export const UNEXPLAINED = "unexplained";
35
+ /** How many result rows one check records at most (a report's own bounds are far above any real check). */
36
+ export const MAX_RESULTS_PER_CHECK = 20_000;
37
+ /** How old a tracked observation may be and still be asked about (docs/plans/learning-loops.md, 90-day window). */
38
+ export const OBSERVATION_WATCH_MS = 90 * 24 * 60 * 60 * 1000;
39
+ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
40
+ /**
41
+ * The conditions a check's command starts under: its commit's verified tree facts, the approved command's digest, its
42
+ * access, and the values of `envNames` (the variables the check inherits, builder.ts SETUP_ENV_ALLOWLIST) as this
43
+ * process holds them now — what the command is given. Hashed with checkContextOf's fields; the class reads
44
+ * `<platform>-<arch>-node<major>`. Values are hashed, never stored.
45
+ */
46
+ export function captureCheckConditions(input) {
47
+ const none = (problem) => ({ head: input.head, tree: null, commandDigest: input.command.digest, access: input.access, harnessDigest: null, lockfile: null, conditionsDigest: null, conditionsClass: null, problem });
48
+ if (input.repo === null)
49
+ return none("the check's project isn't recorded");
50
+ const gitDir = projectGitDir(input.repo);
51
+ if (gitDir === null)
52
+ return none("the project's Git directory can't be read");
53
+ let read;
54
+ // Advisory: whatever happens reading the tree, the check itself is never stopped by it.
55
+ try {
56
+ read = treeFactsOf(gitDir, input.head);
57
+ }
58
+ catch (error) {
59
+ return none(`the checked commit can't be read from verified objects (${error instanceof Error ? error.message : String(error)})`);
60
+ }
61
+ if (!read.ok)
62
+ return none(`the checked commit can't be read from verified objects (${read.problem})`);
63
+ const context = checkContextOf(read.facts, input.command, input.access);
64
+ const env = input.env ?? process.env;
65
+ const environment = Object.fromEntries([...new Set(input.envNames)].sort().map(name => [name, env[name] ?? null]));
66
+ const digest = sha256(JSON.stringify({ version: 1, context, environment }));
67
+ const major = /^v?(\d+)/.exec(context.node)?.[1] ?? "unknown";
68
+ return { head: input.head, tree: read.facts.tree, commandDigest: input.command.digest, access: input.access, harnessDigest: read.facts.harnessDigest, lockfile: read.facts.lockfile,
69
+ conditionsDigest: digest, conditionsClass: `${context.platform}-${context.arch}-node${major}`, problem: null };
70
+ }
71
+ /** Why the task's check ran: a release's or the nightly's purpose, read from the plane's own records. */
72
+ export function checkPurposeOf(store, taskRef) {
73
+ if (nightlyTaskRef(store, taskRef))
74
+ return "nightly";
75
+ if (releaseTaskRef(store, taskRef))
76
+ return "release";
77
+ const terms = store.handle.prepare(`SELECT s.release_terms_json, s.release_check_json FROM task_scope s JOIN task_ref r ON r.external_id = s.task_id AND r.backend = 'built-in' WHERE r.id = ?`).get(taskRef);
78
+ return terms !== undefined && (terms["release_terms_json"] != null || terms["release_check_json"] != null) ? "release" : "ordinary";
79
+ }
80
+ /** The advisory learning line of a check's output, read strictly: exactly one line carrying this run's nonce, at most
81
+ * 64 KiB, before the report, matching its contract, naming only requested exact cases, each once. Anything else is
82
+ * not recorded, with why; that loses learning evidence only. */
83
+ export function learningLinesOf(stdout, nonce, asked) {
84
+ const lines = stdout.split("\n");
85
+ let reportAt = -1;
86
+ for (let at = lines.length - 1; at >= 0; at--)
87
+ if (lines[at].startsWith(CHECK_REPORT_PREFIX)) {
88
+ reportAt = at;
89
+ break;
90
+ }
91
+ const ours = [];
92
+ let oversized = false, others = false, unreadable = false;
93
+ lines.forEach((line, at) => {
94
+ if (!line.startsWith(LEARN_PREFIX))
95
+ return;
96
+ if (Buffer.byteLength(line) > LEARN_MAX_BYTES) {
97
+ oversized = true;
98
+ return;
99
+ }
100
+ let value;
101
+ try {
102
+ value = JSON.parse(line.slice(LEARN_PREFIX.length));
103
+ }
104
+ catch {
105
+ unreadable = true;
106
+ return;
107
+ }
108
+ if (value === null || typeof value !== "object" || value["nonce"] !== nonce) {
109
+ others = true;
110
+ return;
111
+ }
112
+ ours.push({ at, value });
113
+ });
114
+ if (ours.length === 0)
115
+ return { ok: false, why: oversized ? "the learning line was over 64 KiB" : unreadable ? "the learning line couldn't be read" : others ? "no learning line carried this run's nonce" : "the check printed no learning line" };
116
+ if (ours.length > 1)
117
+ return { ok: false, why: "the check printed more than one learning line" };
118
+ if (oversized)
119
+ return { ok: false, why: "a learning line was over 64 KiB" };
120
+ const only = ours[0];
121
+ if (reportAt >= 0 && only.at > reportAt)
122
+ return { ok: false, why: "the learning line came after the report" };
123
+ const read = learnLineSchema.safeParse(only.value);
124
+ if (!read.success)
125
+ return { ok: false, why: "the learning line doesn't match its contract" };
126
+ const wanted = new Set(asked);
127
+ const seen = new Set();
128
+ for (const one of read.data.results) {
129
+ if (!wanted.has(one.identity) || !isExactCase(one.identity))
130
+ return { ok: false, why: "the learning line named an identity it wasn't asked about" };
131
+ if (seen.has(one.identity))
132
+ return { ok: false, why: "the learning line named an identity twice" };
133
+ seen.add(one.identity);
134
+ }
135
+ return { ok: true, results: read.data.results };
136
+ }
137
+ const RANK = { passed: 0, skipped: 1, "passed-on-retry": 2, failed: 3 };
138
+ /** The report's lines as results: every case it names; a file, group, version or part only when it didn't simply pass. */
139
+ export function reportResults(report) {
140
+ const rows = [];
141
+ const add = (identity, state) => rows.push({ identity, state, source: "report" });
142
+ for (const one of report.units?.identities ?? [])
143
+ add(`unit:${one.file}::${one.name}`, one.state === "passed" && one.flaky ? "passed-on-retry" : one.state);
144
+ for (const one of report.units?.files ?? []) {
145
+ if (one.state !== "passed")
146
+ add(`file:${one.file}`, one.state);
147
+ else if (one.flaky)
148
+ add(`file:${one.file}`, "passed-on-retry");
149
+ }
150
+ for (const run of report.journeys)
151
+ for (const group of run.groups) {
152
+ if (group.state === "failed")
153
+ add(`group:${run.script}:${run.kind}:${group.group}`, "failed");
154
+ else if (group.state === "passed" && group.retried)
155
+ add(`group:${run.script}:${run.kind}:${group.group}`, "passed-on-retry");
156
+ if (group.state === "not-run")
157
+ continue;
158
+ for (const journey of group.journeys)
159
+ add(`journey:${run.script}:${run.kind}:${group.group}::${journey.name}`, journey.state === "passed" && group.retried ? "passed-on-retry" : journey.state);
160
+ }
161
+ for (const one of report.upgrades?.versions ?? [])
162
+ if (one.state === "failed")
163
+ add(`upgrade:${one.version}`, "failed");
164
+ for (const part of report.parts)
165
+ if (part.selected && part.ran && (part.code !== 0 || part.timedOut))
166
+ add(`part:${part.part}`, "failed");
167
+ return rows;
168
+ }
169
+ /** The sealed log's FAIL lines (Vitest), failures only, file level; nothing when the log's own count disagrees. */
170
+ function logResults(store, root, runId) {
171
+ const logs = store.artifactsFor(runId).filter(one => one.kind === "check-log");
172
+ if (logs.length !== 1)
173
+ return [];
174
+ const read = readVerifiedArtifact(root, logs[0]);
175
+ if (!read.ok)
176
+ return [];
177
+ const failing = failingTestsOf(read.content.toString("utf8")) ?? [];
178
+ return failing.filter(one => /^(?!\/)(?!.*(?:^|\/)\.\.?(?:\/|$))[^\u0000-\u001f\\]{1,512}$/.test(one.file)).map(one => ({ identity: `file:${one.file}`, state: "failed", source: "log" }));
179
+ }
180
+ /**
181
+ * Record what a settled check ran under and reported, once (first write wins), in one transaction: its fact row and
182
+ * every result row. Called at disposition by the builder right after it records the check's own outcome. Advisory:
183
+ * nothing reads these for a verdict, a rerun or an approval.
184
+ */
185
+ export function recordCheckFacts(store, root, input) {
186
+ const conditions = input.conditions;
187
+ const comparable = conditions !== null && conditions.tree !== null && conditions.conditionsDigest !== null;
188
+ const asked = input.attempt === null ? [] : learnRequest(input.attempt.learn);
189
+ let learnState = asked.length === 0 ? "not-asked" : "not-recorded";
190
+ let learnWhy = null;
191
+ const rows = [];
192
+ if (comparable) {
193
+ if (input.receipt.status !== "not-run")
194
+ rows.push({ identity: WHOLE_CHECK, state: input.receipt.status, source: "receipt" });
195
+ if (input.report !== null)
196
+ rows.push(...reportResults(input.report));
197
+ rows.push(...logResults(store, root, input.runId));
198
+ }
199
+ if (asked.length > 0) {
200
+ if (!comparable)
201
+ learnWhy = conditions === null ? "the check's command never started" : conditions.problem ?? "the check's conditions weren't recorded";
202
+ else if (input.stdout === null || input.attempt === null)
203
+ learnWhy = "the check's output wasn't kept";
204
+ else {
205
+ const learned = learningLinesOf(input.stdout, input.attempt.nonce, asked);
206
+ if (!learned.ok)
207
+ learnWhy = learned.why;
208
+ else {
209
+ learnState = "recorded";
210
+ for (const one of learned.results)
211
+ rows.push({ identity: one.identity, state: one.state, source: "learn" });
212
+ }
213
+ }
214
+ }
215
+ if (comparable && input.receipt.status === "failed" && !rows.some(one => one.source !== "receipt" && one.state === "failed"))
216
+ rows.push({ identity: UNEXPLAINED, state: "failed", source: "receipt" });
217
+ // One row per (identity, source), its worst state; bounded.
218
+ const kept = new Map();
219
+ for (const row of rows) {
220
+ if (row.identity.length < 1 || row.identity.length > 2600)
221
+ continue;
222
+ const key = `${row.source}\u0000${row.identity}`;
223
+ const before = kept.get(key);
224
+ if (before === undefined || RANK[row.state] > RANK[before.state])
225
+ kept.set(key, row);
226
+ }
227
+ const at = input.now.toISOString();
228
+ store.transact(() => {
229
+ const inserted = store.handle.prepare(`INSERT INTO check_fact (run, repo, task_ref, head, tree, command_digest, access, harness_digest, lockfile, conditions_digest,
230
+ conditions_class, purpose, learn_state, learn_why, recorded_at) SELECT id, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM run WHERE id = ?
231
+ ON CONFLICT (run) DO NOTHING`).run(input.repo, input.taskRef, conditions?.head ?? null, comparable ? conditions.tree : null, conditions?.commandDigest ?? null, conditions?.access ?? null, comparable ? conditions.harnessDigest : null, comparable ? conditions.lockfile : null, comparable ? conditions.conditionsDigest : null, comparable ? conditions.conditionsClass : null, checkPurposeOf(store, input.taskRef), learnState, learnState === "not-recorded" ? learnWhy ?? "not recorded" : null, at, input.runId);
232
+ if (Number(inserted.changes) !== 1)
233
+ return;
234
+ const insert = store.handle.prepare("INSERT INTO check_result (run, identity, state, source, recorded_at) VALUES (?, ?, ?, ?, ?)");
235
+ for (const row of [...kept.values()].slice(0, MAX_RESULTS_PER_CHECK))
236
+ insert.run(input.runId, row.identity, row.state, row.source, at);
237
+ });
238
+ }
239
+ export function checkFactOf(store, runId) {
240
+ const row = store.handle.prepare("SELECT * FROM check_fact WHERE run = ?").get(runId);
241
+ if (row === undefined)
242
+ return null;
243
+ const text = (key) => row[key] === null ? null : String(row[key]);
244
+ return { run: Number(row["run"]), repo: text("repo"), taskRef: Number(row["task_ref"]), head: text("head"), tree: text("tree"), commandDigest: text("command_digest"), access: text("access"),
245
+ harnessDigest: text("harness_digest"), lockfile: text("lockfile"), conditionsDigest: text("conditions_digest"), conditionsClass: text("conditions_class"),
246
+ purpose: String(row["purpose"]), learnState: String(row["learn_state"]), learnWhy: text("learn_why"), recordedAt: String(row["recorded_at"]) };
247
+ }
248
+ /** A check's recorded results, by source then identity. */
249
+ export function checkResultsOf(store, runId) {
250
+ return store.handle.prepare("SELECT identity, state, source FROM check_result WHERE run = ? ORDER BY source, identity").all(runId)
251
+ .map(row => ({ identity: String(row["identity"]), state: String(row["state"]), source: String(row["source"]) }));
252
+ }
253
+ /**
254
+ * What a project's Full checks are asked about for learning (`learn`): its tracked identities (learning_watch) — an
255
+ * accepted note's until it ends, an observation's for 90 days — exact cases only, the most recently tracked first, at
256
+ * most 200. Those left out are counted (`leftOut`): a coverage gap, said, never hidden.
257
+ */
258
+ export function learnRequestFor(store, repo, now) {
259
+ const since = new Date(now.getTime() - OBSERVATION_WATCH_MS).toISOString();
260
+ let tracked;
261
+ // Advisory: a read that fails asks about nothing, and the check runs as it would have.
262
+ try {
263
+ tracked = store.handle.prepare(`SELECT identity, MAX(since) AS latest FROM learning_watch
264
+ WHERE repo = ? AND ((reason = 'note' AND until IS NULL) OR (reason = 'observation' AND since >= ?))
265
+ GROUP BY identity ORDER BY latest DESC, identity`).all(repo, since).map(row => String(row["identity"])).filter(isExactCase);
266
+ }
267
+ catch {
268
+ return { learn: [], leftOut: 0 };
269
+ }
270
+ const learn = learnRequest(tracked);
271
+ return { learn, leftOut: tracked.length - learn.length };
272
+ }
@@ -88,6 +88,8 @@ export type PreparedAttempt = {
88
88
  snapshot: ObligationSnapshot | null;
89
89
  /** The identities the check was asked to report one by one (required ones). */
90
90
  watch: string[];
91
+ /** The exact cases the check was asked about for learning only (docs/plans/learning-loops.md): never gate evidence. */
92
+ learn: string[];
91
93
  };
92
94
  /** A Full check's preparation refused (eval-harness review 2, finding 4): the plane recorded a release whose commit it
93
95
  * can't verify now, so the check's obligations can't be decided. The check isn't run (builder.ts: `baseline-unverifiable`). */
@@ -112,6 +114,9 @@ export declare function prepareCheckAttempt(store: Store, root: string, input: {
112
114
  /** A release's check (signed release terms or a release authority): CI never stands in for its unit suite. Sealed in
113
115
  * the inventory (whose digest the attempt row holds); only an explicit false lets CI stand in. */
114
116
  release?: boolean;
117
+ /** Exact cases asked about for learning only (docs/plans/learning-loops.md): a separate handshake field, at most 200,
118
+ * that never joins `watch`, the inventory or anything the check owes. */
119
+ learn?: readonly string[];
115
120
  }, now: Date): PreparedAttempt | null;
116
121
  /** Seal one JSON payload as a structured-output artifact under its capture label. */
117
122
  export declare function sealJson(store: Store, root: string, runId: number, name: string, value: unknown, capture: string, now: Date): number;
@@ -21,7 +21,7 @@ import { createHash, randomBytes } from "node:crypto";
21
21
  import { lstatSync, realpathSync } from "node:fs";
22
22
  import { join } from "node:path";
23
23
  import { readVerifiedCommit, verifiedBlobs, verifiedTreeEntries } from "./commit-extract.js";
24
- import { CHECK_PARTS, CHECK_REPORT_MAX_BYTES, CHECK_REPORT_PREFIX, readCheckReport, } from "./contracts/release-check.js";
24
+ import { CHECK_PARTS, CHECK_REPORT_MAX_BYTES, CHECK_REPORT_PREFIX, learnRequest, readCheckReport, } from "./contracts/release-check.js";
25
25
  import { readVerifiedArtifact, storeEvidence } from "./evidence.js";
26
26
  import { releaseBaselineRecord } from "./release-card.js";
27
27
  import { checkAttemptOf, recordCheckAttempt } from "./store.js";
@@ -219,8 +219,9 @@ export function prepareCheckAttempt(store, root, input, now) {
219
219
  // The command's own limit rides along, so the check ends itself (logs and report printed) before the plane stops it.
220
220
  // `reportBytes`: the largest report this plane reads back once sealed. A plane that doesn't say (0.9.77 and older) reads
221
221
  // only 64 KiB, so the check gives it no larger report rather than one that poisons the receipt (scripts/release-check.mjs).
222
- const stdin = JSON.stringify({ nonce, mode, plan, watch: [...(input.watch ?? [])], limitMs: input.command.timeoutMs, reportBytes: CHECK_ARTIFACT_CAP, ...(plan === null ? { owed, ciSubstitute } : {}) });
223
- return { runId: input.runId, nonce, stdin, mode, inventory, context, plan, snapshot, watch: [...(input.watch ?? [])] };
222
+ const learn = learnRequest(input.learn ?? []);
223
+ const stdin = JSON.stringify({ nonce, mode, plan, watch: [...(input.watch ?? [])], limitMs: input.command.timeoutMs, reportBytes: CHECK_ARTIFACT_CAP, ...(plan === null ? { owed, ciSubstitute } : {}), ...(learn.length > 0 ? { learn } : {}) });
224
+ return { runId: input.runId, nonce, stdin, mode, inventory, context, plan, snapshot, watch: [...(input.watch ?? [])], learn };
224
225
  }
225
226
  /** Seal one JSON payload as a structured-output artifact under its capture label. */
226
227
  export function sealJson(store, root, runId, name, value, capture, now) {
package/dist/cli.js CHANGED
@@ -113,6 +113,8 @@ export const OPERATE_COMMANDS = new Set([
113
113
  "evals",
114
114
  // Only `lead token` reaches here; the central service's lead verbs answer first.
115
115
  "lead",
116
+ // Only `release approve` reaches here: every other `release` form is release-cli.ts, routed before this set.
117
+ "release",
116
118
  "onboard",
117
119
  "status",
118
120
  "ready",
@@ -344,6 +346,9 @@ async function dispatch(argv, write, mainOptions) {
344
346
  }
345
347
  if (first === "update")
346
348
  return (await import("./toolroll-update-cli.js")).runUpdateCommand(rest, write, mainOptions.update);
349
+ // `toolroll release approve`: a person's approval, with the operate commands' own credential prompt and context.
350
+ if (first === "release" && rest[0] === "approve")
351
+ return runOperate("release", rest, write, mainOptions.operate ?? {});
347
352
  if (first === "release") {
348
353
  const { runReleaseCommand } = await import("./release-cli.js");
349
354
  return runReleaseCommand(rest, write, { releaseLease: lease => runOperate("worker", ["release", ...lease], write, mainOptions.operate ?? {}), ...mainOptions.release });
@@ -252,6 +252,14 @@ export declare const chatActionRequestSchemas: {
252
252
  decision: z.ZodInt;
253
253
  reason: z.ZodString;
254
254
  }, z.core.$strict>;
255
+ readonly suggestion_not_now: z.ZodObject<{
256
+ repo: z.ZodString;
257
+ suggestion: z.ZodString;
258
+ }, z.core.$strict>;
259
+ readonly trial_stop: z.ZodObject<{
260
+ repo: z.ZodString;
261
+ trial: z.ZodInt;
262
+ }, z.core.$strict>;
255
263
  /** The line, and (from 1) the line it replaces; 0 or none adds it. */
256
264
  readonly lead_about_you: z.ZodObject<{
257
265
  line: z.ZodString;
@@ -331,12 +339,14 @@ export declare const sharedActionSchema: z.ZodObject<{
331
339
  subagent_state: "subagent_state";
332
340
  subagent_tools: "subagent_tools";
333
341
  subagent_undo: "subagent_undo";
342
+ suggestion_not_now: "suggestion_not_now";
334
343
  sweep_cancel_rest: "sweep_cancel_rest";
335
344
  sweep_file: "sweep_file";
336
345
  task_cancel: "task_cancel";
337
346
  task_resume: "task_resume";
338
347
  tool_add: "tool_add";
339
348
  tool_remove: "tool_remove";
349
+ trial_stop: "trial_stop";
340
350
  }>;
341
351
  request: z.ZodObject<{}, z.core.$catchall<z.ZodUnknown>>;
342
352
  repo: z.ZodString;
@@ -81,6 +81,10 @@ export const chatActionRequestSchemas = {
81
81
  kit_setup: z.strictObject({ repo: words, kit: words }),
82
82
  decision_record: z.strictObject({ repo: words, claim: words, why: words, supersedes: id.optional(), source: words.optional() }),
83
83
  decision_retire: z.strictObject({ repo: words, decision: id, reason: words }),
84
+ // Results routing: Not now on a suggested cheaper model (accepting stays in Settings → Models, behind a step-up).
85
+ suggestion_not_now: z.strictObject({ repo: words, suggestion: words }),
86
+ // Slice 3: stopping a trial (starting one stays in Settings → Models, behind a step-up).
87
+ trial_stop: z.strictObject({ repo: words, trial: id }),
84
88
  /** The line, and (from 1) the line it replaces; 0 or none adds it. */
85
89
  lead_about_you: z.strictObject({ line: words, replaces: z.int().min(0).optional() }),
86
90
  scope_approve: z.strictObject({ task: words }),
@@ -112,6 +112,7 @@ export declare const CONSOLE_FORMS: {
112
112
  readonly appearance: FormContract<"accent" | "quiet" | "return" | "theme">;
113
113
  readonly models: FormContract<"enabled" | "tool">;
114
114
  readonly modelsChange: FormContract<"build" | "build-light" | "build-strong" | "change" | "digest" | "password" | "plan" | "plan-strong" | "preset" | "repair" | "review" | "review-strong" | "scope" | "switch" | "to">;
115
+ readonly modelsRules: FormContract<"id" | "password" | "rule" | "scope">;
115
116
  readonly knowledgeRefresh: FormContract<"repo">;
116
117
  readonly knowledgeProposal: FormContract<"decision" | "proposal" | "repo">;
117
118
  readonly knowledgeDecision: FormContract<"action" | "claim" | "decision" | "reason" | "repo" | "why">;
@@ -149,7 +150,7 @@ export declare const CONSOLE_FORMS: {
149
150
  readonly peopleRevoke: FormContract<"name" | "token">;
150
151
  readonly runnerRetire: FormContract<"name" | "token">;
151
152
  readonly decisionAnswer: FormContract<"choice" | "confirm" | "note" | "return">;
152
- readonly confirmStopped: FormContract<"checked" | "return" | "run" | "token">;
153
+ readonly recheckStopped: FormContract<"return" | "run">;
153
154
  readonly merge: FormContract<"anyway" | "run" | "token">;
154
155
  readonly onboard: FormContract<"big-ok" | "nonce" | "repo" | "root" | "token">;
155
156
  readonly pushSubscribe: FormContract<"auth" | "endpoint" | "p256dh" | "token">;
@@ -198,7 +199,7 @@ export declare const CONSOLE_FORMS: {
198
199
  readonly taskCancel: FormContract<"reason">;
199
200
  readonly taskRoute: FormContract<"agent" | "clear-phase" | "model" | "phase" | "provider" | "return" | "risky" | "sawDigest" | "size">;
200
201
  readonly taskScope: FormContract<"acceptance" | "budget-microusd" | "budget-usd" | "goal" | "goal-brief" | "not" | "permission-mode" | "quality-mode" | "requirement" | "requirement-new" | "sawDigest" | "touches">;
201
- readonly taskApprove: FormContract<"digest" | "nonce" | "rebuild" | "return" | "token">;
202
+ readonly taskApprove: FormContract<"digest" | "nonce" | "rebuild" | "return" | "sheet" | "token">;
202
203
  readonly taskAcceptProof: FormContract<"note" | "return" | "run">;
203
204
  readonly taskComplete: FormContract<"accept" | "note" | "publish" | "receipt" | "return" | "run">;
204
205
  readonly taskStop: FormContract<"return" | "run">;
@@ -124,6 +124,9 @@ export const CONSOLE_FORMS = {
124
124
  // preset or switch; the digest is what the page showed, and the change digest what the preview showed.
125
125
  modelsChange: formContract("POST /settings/models/agent, /settings/models/apply", ["password", "scope", "digest", "change", "preset", "switch", "to",
126
126
  "plan", "plan-strong", "build-light", "build", "build-strong", "repair", "review", "review-strong"]),
127
+ // Settings → Models → Suggested (results routing): the project, a suggestion's id or a rule's number, and the step-up
128
+ // password for accepting, keeping and switching suggestions on.
129
+ modelsRules: formContract("POST /settings/models/accept, /settings/models/keep, /settings/models/suggest-on, /settings/models/turn-off, /settings/models/not-now, /settings/models/suggest-off, /settings/models/try, /settings/models/stop", ["password", "scope", "id", "rule"]),
127
130
  knowledgeRefresh: formContract("POST /settings/knowledge/refresh", ["repo"]),
128
131
  knowledgeProposal: formContract("POST /settings/knowledge/proposal", ["repo", "decision", "proposal"]),
129
132
  knowledgeDecision: formContract("POST /settings/knowledge/decision", ["repo", "action", "claim", "why", "decision", "reason"]),
@@ -162,7 +165,7 @@ export const CONSOLE_FORMS = {
162
165
  peopleRevoke: formContract("POST /people/revoke", ["token", "name"]),
163
166
  runnerRetire: formContract("POST /fleet/runner/retire", ["token", "name"]),
164
167
  decisionAnswer: formContract("POST /d/<id>/answer", ["return", "choice", "confirm", "note"]),
165
- confirmStopped: formContract("POST /t/<task>/confirm-stopped", ["run", "token", "checked", "return"]),
168
+ recheckStopped: formContract("POST /t/<task>/recheck-stopped", ["run", "return"]),
166
169
  merge: formContract("POST /t/<task>/merge", ["run", "token", "anyway"]),
167
170
  onboard: formContract("POST /projects/onboard-preview, /projects/onboard-confirm", ["repo", "root", "token", "nonce", "big-ok"]),
168
171
  pushSubscribe: formContract("POST /push/subscribe", ["token", "endpoint", "p256dh", "auth"]),
@@ -214,7 +217,7 @@ export const CONSOLE_FORMS = {
214
217
  taskCancel: formContract("POST /t/<task>/cancel", ["reason"]),
215
218
  taskRoute: formContract("POST /t/<task>/route", ["size", "risky", "phase", "clear-phase", "agent", "provider", "model", "sawDigest", "return"]),
216
219
  taskScope: formContract("POST /t/<task>/scope", ["sawDigest", "permission-mode", "quality-mode", "budget-usd", "budget-microusd", "requirement", "requirement-new", "goal-brief", "goal", "not", "touches", "acceptance"]),
217
- taskApprove: formContract("POST /t/<task>/approve", ["return", "digest", "token", "nonce", "rebuild"]),
220
+ taskApprove: formContract("POST /t/<task>/approve", ["return", "digest", "token", "nonce", "rebuild", "sheet"]),
218
221
  taskAcceptProof: formContract("POST /t/<task>/accept-proof", ["run", "note", "return"]),
219
222
  taskComplete: formContract("POST /t/<task>/complete", ["receipt", "run", "accept", "note", "publish", "return"]),
220
223
  taskStop: formContract("POST /t/<task>/stop", ["run", "return"]),
@@ -233,7 +233,9 @@ export declare const obligationSnapshotSchema: z.ZodObject<{
233
233
  }, z.core.$strict>>;
234
234
  }, z.core.$strict>;
235
235
  export type ObligationSnapshot = z.infer<typeof obligationSnapshotSchema>;
236
- /** What the plane writes on the check's stdin before closing it. */
236
+ /** What the plane writes on the check's stdin before closing it. `learn` (docs/plans/learning-loops.md) is advisory and
237
+ * apart from everything else: exact case identities the check is asked to say it ran, on their own line
238
+ * (LEARN_PREFIX), never in the report and never part of what the check owes. */
237
239
  export type CheckHandshake = {
238
240
  nonce: string;
239
241
  mode: "full" | "targeted";
@@ -241,7 +243,29 @@ export type CheckHandshake = {
241
243
  watch: string[];
242
244
  owed?: CheckPart[];
243
245
  ciSubstitute?: boolean;
246
+ learn?: string[];
244
247
  };
248
+ /** The advisory learning line a check prints before its report: `TOOLROLL-LEARN <json>`, at most 64 KiB, holding only
249
+ * the requested identities that ran. Never part of the report: buildReport, reportLine and fullCheckProblem don't read
250
+ * it, and a missing, malformed or oversized one loses learning evidence only. */
251
+ export declare const LEARN_PREFIX = "TOOLROLL-LEARN ";
252
+ export declare const LEARN_MAX_BYTES: number;
253
+ export declare const LEARN_MAX_IDENTITIES = 200;
254
+ export declare const isExactCase: (identity: unknown) => identity is string;
255
+ /** What the plane asks a check to report for learning: exact cases only, first seen first, at most 200. */
256
+ export declare function learnRequest(identities: readonly unknown[]): string[];
257
+ export declare const learnLineSchema: z.ZodObject<{
258
+ nonce: z.ZodString;
259
+ results: z.ZodArray<z.ZodObject<{
260
+ identity: z.ZodString;
261
+ state: z.ZodEnum<{
262
+ failed: "failed";
263
+ passed: "passed";
264
+ skipped: "skipped";
265
+ }>;
266
+ }, z.core.$strict>>;
267
+ }, z.core.$strict>;
268
+ export type LearnLine = z.infer<typeof learnLineSchema>;
245
269
  /** A unit test: its file and full name. */
246
270
  export declare const unitIdentity: (file: string, testName: string) => string;
247
271
  /** A unit file. */
@@ -146,6 +146,25 @@ export const obligationSnapshotSchema = versioned(1, {
146
146
  base: z.int().min(1),
147
147
  earlier: z.array(z.strictObject({ run: z.int().min(1), reportDigest: z.string().max(64).nullable(), failed: z.array(z.string().min(1).max(2600)).max(5000) })).max(200),
148
148
  });
149
+ // ------------------------------------------------------------------ advisory learning lines (docs/plans/learning-loops.md)
150
+ /** The advisory learning line a check prints before its report: `TOOLROLL-LEARN <json>`, at most 64 KiB, holding only
151
+ * the requested identities that ran. Never part of the report: buildReport, reportLine and fullCheckProblem don't read
152
+ * it, and a missing, malformed or oversized one loses learning evidence only. */
153
+ export const LEARN_PREFIX = "TOOLROLL-LEARN ";
154
+ export const LEARN_MAX_BYTES = 64 * 1024;
155
+ export const LEARN_MAX_IDENTITIES = 200;
156
+ /** An exact case: a unit test (`unit:<file>::<name>`) or a journey (`journey:<script>:<kind>:<group>::<name>`), never a
157
+ * file, group, part or version; one line, no control characters, at most 2600 characters. */
158
+ const EXACT_CASE = /^(?:unit:[^\u0000-\u001f\\:][^\u0000-\u001f\\]*?::[^\u0000-\u001f]+|journey:(?:app|flows):(?:scripted|real):[A-Za-z0-9._/-]{1,200}::[^\u0000-\u001f]+)$/;
159
+ export const isExactCase = (identity) => typeof identity === "string" && identity.length <= 2600 && EXACT_CASE.test(identity);
160
+ /** What the plane asks a check to report for learning: exact cases only, first seen first, at most 200. */
161
+ export function learnRequest(identities) {
162
+ return [...new Set(identities.filter(isExactCase))].slice(0, LEARN_MAX_IDENTITIES);
163
+ }
164
+ export const learnLineSchema = z.strictObject({
165
+ nonce: hex64,
166
+ results: z.array(z.strictObject({ identity: z.string().min(1).max(2600), state: z.enum(["passed", "failed", "skipped"]) })).max(LEARN_MAX_IDENTITIES),
167
+ });
149
168
  // ------------------------------------------------------------------ identities
150
169
  /** A unit test: its file and full name. */
151
170
  export const unitIdentity = (file, testName) => `unit:${file}::${testName}`;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Result records (docs/plans/results-routing.md, slice 1): the words and shapes a task family's result record is kept
3
+ * and shown in. A policy path (release-auto.ts EVAL_POLICY): the states, reasons and the execution-class table decide
4
+ * what a cheaper model's evidence is, so a change to them needs a person.
5
+ */
6
+ import { z } from "zod";
7
+ /** A record's lifecycle. Only mature and rejected records are decided; pending and provisional never count anywhere. */
8
+ export declare const RECORD_STATES: readonly ["pending", "provisional", "mature", "rejected", "not-counted"];
9
+ export type RecordState = (typeof RECORD_STATES)[number];
10
+ /** Why a record was rejected, in the order they are checked. */
11
+ export declare const REJECT_REASONS: readonly ["model-failures", "task-failed", "cancelled-after-result", "high-finding", "person-revision", "proof-refuted", "check-failed"];
12
+ /** Why a record is not counted. Never counted as good, never as 0. */
13
+ export declare const NOT_COUNTED_REASONS: readonly ["before-records", "not-eligible", "mixed-cohort", "mixed-models", "model-not-reported", "infrastructure-only", "cancelled-before-result", "no-check", "check-unavailable", "touched-protected", "changed-files-unknown", "unanswered"];
14
+ export type RejectReason = (typeof REJECT_REASONS)[number];
15
+ export type NotCountedReason = (typeof NOT_COUNTED_REASONS)[number];
16
+ export type RecordReason = RejectReason | NotCountedReason;
17
+ /** The plain words for each reason. */
18
+ export declare const REASON_WORDS: Readonly<Record<RecordReason, string>>;
19
+ /** Why a run was refused a cohort before dispatch: any of these makes its record not counted ("not eligible"). */
20
+ export declare const INELIGIBLE_REASONS: readonly ["no-sealed-route", "not-built-in", "large", "risky", "unsized", "strict", "evidence-demands", "automerge", "release", "protected"];
21
+ export type IneligibleReason = (typeof INELIGIBLE_REASONS)[number];
22
+ /** Who a revision came from (task_ref.revision_origin). Only `person` rejects a record. */
23
+ export declare const REVISION_ORIGINS: readonly ["person", "automatic-review", "flow", "lead", "machine"];
24
+ export type RevisionOrigin = (typeof REVISION_ORIGINS)[number];
25
+ /** A failed run's execution class, kept before the tick normalizes its reason (run.exec_class). */
26
+ export declare const EXEC_CLASSES: readonly ["model", "infrastructure"];
27
+ export type ExecClass = (typeof EXEC_CLASSES)[number];
28
+ /** The build leg's sealed selection, as a run's cohort keeps it (route v3 `selection`; earlier routes by `chosen`). */
29
+ export declare const selectionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
30
+ kind: z.ZodLiteral<"rule">;
31
+ rule: z.ZodNumber;
32
+ ruleGeneration: z.ZodNumber;
33
+ }, z.core.$strict>, z.ZodObject<{
34
+ kind: z.ZodLiteral<"trial">;
35
+ rule: z.ZodNumber;
36
+ reservation: z.ZodNumber;
37
+ }, z.core.$strict>, z.ZodObject<{
38
+ kind: z.ZodEnum<{
39
+ configured: "configured";
40
+ override: "override";
41
+ pin: "pin";
42
+ }>;
43
+ }, z.core.$strict>], "kind">;
44
+ export type Selection = z.infer<typeof selectionSchema>;
45
+ /** One record as `toolroll models results --json` returns it. */
46
+ export declare const resultRecordSchema: z.ZodObject<{
47
+ task: z.ZodString;
48
+ repo: z.ZodNullable<z.ZodString>;
49
+ size: z.ZodNullable<z.ZodEnum<{
50
+ medium: "medium";
51
+ small: "small";
52
+ }>>;
53
+ provider: z.ZodNullable<z.ZodString>;
54
+ model: z.ZodNullable<z.ZodString>;
55
+ state: z.ZodEnum<{
56
+ mature: "mature";
57
+ "not-counted": "not-counted";
58
+ pending: "pending";
59
+ provisional: "provisional";
60
+ rejected: "rejected";
61
+ }>;
62
+ reason: z.ZodNullable<z.ZodEnum<{
63
+ "before-records": "before-records";
64
+ "cancelled-after-result": "cancelled-after-result";
65
+ "cancelled-before-result": "cancelled-before-result";
66
+ "changed-files-unknown": "changed-files-unknown";
67
+ "check-failed": "check-failed";
68
+ "check-unavailable": "check-unavailable";
69
+ "high-finding": "high-finding";
70
+ "infrastructure-only": "infrastructure-only";
71
+ "mixed-cohort": "mixed-cohort";
72
+ "mixed-models": "mixed-models";
73
+ "model-failures": "model-failures";
74
+ "model-not-reported": "model-not-reported";
75
+ "no-check": "no-check";
76
+ "not-eligible": "not-eligible";
77
+ "person-revision": "person-revision";
78
+ "proof-refuted": "proof-refuted";
79
+ "task-failed": "task-failed";
80
+ "touched-protected": "touched-protected";
81
+ unanswered: "unanswered";
82
+ }>>;
83
+ words: z.ZodString;
84
+ resultRun: z.ZodNullable<z.ZodNumber>;
85
+ acceptedAt: z.ZodNullable<z.ZodString>;
86
+ decidedAt: z.ZodNullable<z.ZodString>;
87
+ failures: z.ZodObject<{
88
+ model: z.ZodNumber;
89
+ infrastructure: z.ZodNumber;
90
+ }, z.core.$strict>;
91
+ spend: z.ZodObject<{
92
+ apiMicrousd: z.ZodNumber;
93
+ subscriptionRuns: z.ZodNumber;
94
+ subscriptionTokens: z.ZodNumber;
95
+ unknownRuns: z.ZodNumber;
96
+ remediationRuns: z.ZodNumber;
97
+ }, z.core.$strict>;
98
+ personRevisions: z.ZodNumber;
99
+ firstCheckPassed: z.ZodNullable<z.ZodBoolean>;
100
+ turns: z.ZodObject<{
101
+ known: z.ZodNumber;
102
+ unknownRuns: z.ZodNumber;
103
+ }, z.core.$strict>;
104
+ closed: z.ZodBoolean;
105
+ selections: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
106
+ kind: z.ZodLiteral<"rule">;
107
+ rule: z.ZodNumber;
108
+ ruleGeneration: z.ZodNumber;
109
+ }, z.core.$strict>, z.ZodObject<{
110
+ kind: z.ZodLiteral<"trial">;
111
+ rule: z.ZodNumber;
112
+ reservation: z.ZodNumber;
113
+ }, z.core.$strict>, z.ZodObject<{
114
+ kind: z.ZodEnum<{
115
+ configured: "configured";
116
+ override: "override";
117
+ pin: "pin";
118
+ }>;
119
+ }, z.core.$strict>], "kind">>;
120
+ }, z.core.$strict>;
121
+ export type ResultRecordView = z.infer<typeof resultRecordSchema>;