toolroll 0.9.92 → 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 (65) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/assignment-status.d.ts +1 -1
  3. package/dist/assignment-ui.js +2 -2
  4. package/dist/assignment.js +11 -3
  5. package/dist/browser/workspace.css +1 -1
  6. package/dist/browser/workspace.js +30 -30
  7. package/dist/browser-crew.js +2 -2
  8. package/dist/browser-workspace.d.ts +3 -5
  9. package/dist/builder.js +21 -2
  10. package/dist/check-facts.d.ts +124 -0
  11. package/dist/check-facts.js +272 -0
  12. package/dist/check-report.d.ts +5 -0
  13. package/dist/check-report.js +4 -3
  14. package/dist/cli.js +5 -0
  15. package/dist/contracts/console-api.d.ts +2 -2
  16. package/dist/contracts/console-api.js +2 -2
  17. package/dist/contracts/release-check.d.ts +25 -1
  18. package/dist/contracts/release-check.js +19 -0
  19. package/dist/desktop-update.d.ts +2 -2
  20. package/dist/desktop-update.js +13 -9
  21. package/dist/eval-hooks.d.ts +55 -0
  22. package/dist/eval-hooks.js +227 -4
  23. package/dist/flaky.d.ts +17 -0
  24. package/dist/flaky.js +9 -0
  25. package/dist/needs-you.d.ts +7 -7
  26. package/dist/needs-you.js +12 -10
  27. package/dist/operate.js +138 -18
  28. package/dist/project-delete.js +5 -1
  29. package/dist/project-knowledge.d.ts +6 -6
  30. package/dist/push.d.ts +10 -3
  31. package/dist/push.js +22 -6
  32. package/dist/release-adapters.js +11 -1
  33. package/dist/release-approval.d.ts +191 -0
  34. package/dist/release-approval.js +382 -0
  35. package/dist/release-auto.js +13 -4
  36. package/dist/release-card.d.ts +73 -4
  37. package/dist/release-card.js +168 -23
  38. package/dist/release-cli.js +9 -1
  39. package/dist/release.d.ts +30 -0
  40. package/dist/release.js +42 -3
  41. package/dist/result-acts.d.ts +3 -3
  42. package/dist/result-acts.js +1 -1
  43. package/dist/server/guards.js +1 -0
  44. package/dist/server/pages-assets.js +1 -1
  45. package/dist/server/render-release.d.ts +17 -0
  46. package/dist/server/render-release.js +84 -0
  47. package/dist/server/render-results.d.ts +0 -2
  48. package/dist/server/render-results.js +4 -6
  49. package/dist/server/render-tasks.js +3 -3
  50. package/dist/server/route-table.d.ts +1 -1
  51. package/dist/server/route-table.js +7 -1
  52. package/dist/server/tasks.d.ts +15 -0
  53. package/dist/server/tasks.js +102 -20
  54. package/dist/setup-guide.d.ts +2 -2
  55. package/dist/stop-proof.d.ts +67 -0
  56. package/dist/stop-proof.js +293 -0
  57. package/dist/store.d.ts +70 -31
  58. package/dist/store.js +335 -95
  59. package/dist/surface.js +1 -1
  60. package/dist/task-outcome-cli.js +19 -0
  61. package/dist/toolroll-update-ui.js +2 -2
  62. package/dist/toolroll-update.js +1 -0
  63. package/dist/work-index.js +7 -9
  64. package/dist/work-summary.d.ts +2 -2
  65. package/package.json +1 -1
@@ -15,8 +15,8 @@ export function browserWorkActionHref(item) {
15
15
  return '/settings/agents#providers';
16
16
  if (action.code === 'answer-agent' && runId !== null)
17
17
  return `/agents?run=${runId}`;
18
- if (action.code === 'confirm-stopped')
19
- return `/t/${encodeURIComponent(item.rootId)}#confirm-stopped`;
18
+ if (action.code === 'recheck-stopped')
19
+ return `/t/${encodeURIComponent(item.rootId)}#recheck-stopped`;
20
20
  if (action.code === 'open-result' && runId !== null)
21
21
  return chatResultHref(taskId, runId);
22
22
  if (action.code === 'open-pr' && /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+$/.test(item.publicationUrl ?? ''))
@@ -381,11 +381,10 @@ export type BrowserTaskView = {
381
381
  statusHtml: string;
382
382
  /** The approval ceremony or its updated terms, exactly as signed. */
383
383
  approval: string;
384
- /** Confirm it stopped, behind the password, when the status asks for it. */
385
- confirmStopped?: {
384
+ /** Check again (a build Toolroll could not prove stopped from this computer), when the status asks for it. */
385
+ recheckStopped?: {
386
386
  action: string;
387
387
  run: number;
388
- checked?: boolean;
389
388
  } | null;
390
389
  /** Build again, in place, when the status asks for it (a result built to an earlier plan). */
391
390
  rebuild?: {
@@ -703,7 +702,7 @@ export type BrowserResultDecision = {
703
702
  why: string | null;
704
703
  };
705
704
  };
706
- /** A Needs you action: a link, (Confirm it stopped) a form behind the password, (Build again) one
705
+ /** A Needs you action: a link, (Check again, Build again) one
707
706
  * button, or (on the result itself) Accept, which records the person's acceptance; `note` asks why
708
707
  * when the evidence disagrees. */
709
708
  export type BrowserNeedAction = {
@@ -713,7 +712,6 @@ export type BrowserNeedAction = {
713
712
  action: string;
714
713
  run: number;
715
714
  returnTo: string;
716
- checked?: boolean;
717
715
  } | null;
718
716
  accept?: {
719
717
  action: string;
package/dist/builder.js CHANGED
@@ -5,6 +5,7 @@ import { checkoutGit } from "./teardown-git.js";
5
5
  import { isAcpProvider } from "./acp-agents.js";
6
6
  import { OBSERVATION_MAILBOX, observationBrief, parseObservationCases, collectObservations } from "./observations.js";
7
7
  import { checkCommandFor, effectiveCheckLevel, recordRunCheckLevel } from "./check-levels.js";
8
+ import { captureCheckConditions, learnRequestFor, recordCheckFacts } from "./check-facts.js";
8
9
  import { queueBatchCheck } from "./batch-checks.js";
9
10
  import { batchesFullCheck } from "./batch-policy.js";
10
11
  import { skillsContext } from "./project-skills.js";
@@ -2324,9 +2325,12 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2324
2325
  let checkAttempt = null;
2325
2326
  let releaseFacts = null;
2326
2327
  const verification = { last: null };
2328
+ // What the check ran under, captured as its command starts (check-facts.ts): advisory, recorded at disposition.
2329
+ const checkConditions = { last: null };
2327
2330
  const checkLog = [];
2328
2331
  const checkOutcomes = [];
2329
2332
  const checkSuites = [];
2333
+ let settledReport = null;
2330
2334
  const noteCheckSuite = (name, result) => {
2331
2335
  checkSuites.push({
2332
2336
  name,
@@ -2382,7 +2386,9 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2382
2386
  const release = decision.authority !== null || releaseCheckScope(store.getScope(request.taskId));
2383
2387
  try {
2384
2388
  checkAttempt = prepareCheckAttempt(store, root, { runId, repo, head: sealedHead, command: configured, access, authority: decision.authority,
2385
- ...(decision.mode === "targeted" ? { plan: decision.plan, snapshot: decision.snapshot } : {}), watch: decision.watch, release }, now());
2389
+ ...(decision.mode === "targeted" ? { plan: decision.plan, snapshot: decision.snapshot } : {}), watch: decision.watch, release,
2390
+ // Advisory only (docs/plans/learning-loops.md): the project's tracked identities, a separate handshake field.
2391
+ learn: learnRequestFor(store, repo, now()).learn }, now());
2386
2392
  }
2387
2393
  catch (error) {
2388
2394
  // A recorded last release that can't be verified (eval-harness review 2, finding 4): nothing runs.
@@ -2404,6 +2410,9 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2404
2410
  const checkProgress = new CheckProgressTracker(snapshot => progressWrites.push(snapshot));
2405
2411
  const runVerification = async (label, missing = null) => {
2406
2412
  const streamed = { stdout: false, stderr: false };
2413
+ // The conditions it starts under (verified tree, command, access, inherited environment): advisory, never authority.
2414
+ if (missing === null)
2415
+ checkConditions.last = captureCheckConditions({ repo, head: sealedHead, command: configured, access: checkLaunchOf(store, runId, captured.effective.profile).access, envNames: SETUP_ENV_ALLOWLIST });
2407
2416
  // The check runs under the stop watch (v52), owned by this run.
2408
2417
  const result = missing !== null
2409
2418
  ? { code: 127, stdout: "", stderr: `Toolroll did not start the check: ${missing} wasn't found.`, timedOut: false, notFound: false }
@@ -2624,7 +2633,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2624
2633
  const last = verification.last;
2625
2634
  let sealedReport = null;
2626
2635
  if (checkAttempt !== null && last !== null && !last.notFound) {
2627
- sealedReport = sealCheckOutcome(store, root, checkAttempt, { stdout: last.stdout, exitCode: last.timedOut ? null : last.code }, now());
2636
+ sealedReport = settledReport = sealCheckOutcome(store, root, checkAttempt, { stdout: last.stdout, exitCode: last.timedOut ? null : last.code }, now());
2628
2637
  // Where the check's time went (latency-timeline.ts): each part's start and end, display only.
2629
2638
  keepCheckTimeline(store, { runId, taskId: request.taskId, report: sealedReport, stdout: last.stdout, now: now() });
2630
2639
  }
@@ -2683,6 +2692,16 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2683
2692
  exitCode: checkExitCode,
2684
2693
  suites,
2685
2694
  }, now());
2695
+ // What the check ran under and reported, every failure in it, each tagged by source (check-facts.ts): advisory, never
2696
+ // read for this outcome, a verdict or an approval.
2697
+ // A failure to record them is said on the run and never reaches the check's outcome.
2698
+ try {
2699
+ recordCheckFacts(store, root, { runId, taskRef: captured.taskRef, repo, conditions: checkConditions.last, receipt: { status: checkStatus }, report: settledReport,
2700
+ attempt: checkAttempt === null ? null : { nonce: checkAttempt.nonce, learn: checkAttempt.learn }, stdout: verification.last?.stdout ?? null, now: now() });
2701
+ }
2702
+ catch (error) {
2703
+ store.addRunNote(runId, "Toolroll", `This check's facts for the flaky-test list weren't recorded (${redactSecretText(error instanceof Error ? error.message : String(error))}); its result is unchanged.`, now());
2704
+ }
2686
2705
  // 4. The sealed diff-stat was restated above, before the correction; it
2687
2706
  // is re-read now, after the gate, and the cached facts are adjudicated
2688
2707
  // only when the artifact still states exactly them. The gate receipt
@@ -0,0 +1,124 @@
1
+ import { type CheckReport } from "./contracts/release-check.js";
2
+ import { type Store, type VerifyCommand } from "./store.js";
3
+ /** The sources a result can come from, and the states it can have. */
4
+ export declare const CHECK_RESULT_SOURCES: readonly ["receipt", "report", "log", "learn"];
5
+ export type CheckResultSource = typeof CHECK_RESULT_SOURCES[number];
6
+ export type CheckResultState = "failed" | "passed" | "skipped" | "passed-on-retry";
7
+ /** The receipt's whole-check identity, and the one a failed check with nothing else named records. */
8
+ export declare const WHOLE_CHECK = "check";
9
+ export declare const UNEXPLAINED = "unexplained";
10
+ /** How many result rows one check records at most (a report's own bounds are far above any real check). */
11
+ export declare const MAX_RESULTS_PER_CHECK = 20000;
12
+ /** How old a tracked observation may be and still be asked about (docs/plans/learning-loops.md, 90-day window). */
13
+ export declare const OBSERVATION_WATCH_MS: number;
14
+ /** What a check ran under, captured when its command started. `tree` null: its commit couldn't be read from verified
15
+ * objects, and nothing about the check is comparable. */
16
+ export type CheckConditions = {
17
+ head: string;
18
+ tree: string | null;
19
+ commandDigest: string;
20
+ access: string;
21
+ harnessDigest: string | null;
22
+ lockfile: string | null;
23
+ conditionsDigest: string | null;
24
+ conditionsClass: string | null;
25
+ problem: string | null;
26
+ };
27
+ /**
28
+ * The conditions a check's command starts under: its commit's verified tree facts, the approved command's digest, its
29
+ * access, and the values of `envNames` (the variables the check inherits, builder.ts SETUP_ENV_ALLOWLIST) as this
30
+ * process holds them now — what the command is given. Hashed with checkContextOf's fields; the class reads
31
+ * `<platform>-<arch>-node<major>`. Values are hashed, never stored.
32
+ */
33
+ export declare function captureCheckConditions(input: {
34
+ repo: string | null;
35
+ head: string;
36
+ command: VerifyCommand;
37
+ access: string;
38
+ envNames: readonly string[];
39
+ env?: NodeJS.ProcessEnv;
40
+ }): CheckConditions;
41
+ /** Why the task's check ran: a release's or the nightly's purpose, read from the plane's own records. */
42
+ export declare function checkPurposeOf(store: Store, taskRef: number): "release" | "nightly" | "ordinary";
43
+ /** The advisory learning line of a check's output, read strictly: exactly one line carrying this run's nonce, at most
44
+ * 64 KiB, before the report, matching its contract, naming only requested exact cases, each once. Anything else is
45
+ * not recorded, with why; that loses learning evidence only. */
46
+ export declare function learningLinesOf(stdout: string, nonce: string, asked: readonly string[]): {
47
+ ok: true;
48
+ results: {
49
+ identity: string;
50
+ state: "passed" | "failed" | "skipped";
51
+ }[];
52
+ } | {
53
+ ok: false;
54
+ why: string;
55
+ };
56
+ type Row = {
57
+ identity: string;
58
+ state: CheckResultState;
59
+ source: CheckResultSource;
60
+ };
61
+ /** The report's lines as results: every case it names; a file, group, version or part only when it didn't simply pass. */
62
+ export declare function reportResults(report: CheckReport): Row[];
63
+ export type CheckFactsInput = {
64
+ runId: number;
65
+ taskRef: number;
66
+ repo: string | null;
67
+ /** Captured when the command started; null when no command started (nothing comparable was run). */
68
+ conditions: CheckConditions | null;
69
+ /** What the plane recorded as the check's own outcome (run_check), unchanged. */
70
+ receipt: {
71
+ status: "passed" | "failed" | "not-run";
72
+ };
73
+ /** The sealed report, when the check gave a valid one. */
74
+ report: CheckReport | null;
75
+ /** The check's handshake facts, when it had one: its nonce and what it was asked about for learning. */
76
+ attempt: {
77
+ nonce: string;
78
+ learn: readonly string[];
79
+ } | null;
80
+ /** The check's final command output, for the learning line (the same bytes the report was read from). */
81
+ stdout: string | null;
82
+ now: Date;
83
+ };
84
+ /**
85
+ * Record what a settled check ran under and reported, once (first write wins), in one transaction: its fact row and
86
+ * every result row. Called at disposition by the builder right after it records the check's own outcome. Advisory:
87
+ * nothing reads these for a verdict, a rerun or an approval.
88
+ */
89
+ export declare function recordCheckFacts(store: Store, root: string, input: CheckFactsInput): void;
90
+ /** A check's recorded facts, or null. */
91
+ export type CheckFact = {
92
+ run: number;
93
+ repo: string | null;
94
+ taskRef: number;
95
+ head: string | null;
96
+ tree: string | null;
97
+ commandDigest: string | null;
98
+ access: string | null;
99
+ harnessDigest: string | null;
100
+ lockfile: string | null;
101
+ conditionsDigest: string | null;
102
+ conditionsClass: string | null;
103
+ purpose: "release" | "nightly" | "ordinary";
104
+ learnState: "recorded" | "not-recorded" | "not-asked";
105
+ learnWhy: string | null;
106
+ recordedAt: string;
107
+ };
108
+ export declare function checkFactOf(store: Store, runId: number): CheckFact | null;
109
+ /** A check's recorded results, by source then identity. */
110
+ export declare function checkResultsOf(store: Store, runId: number): {
111
+ identity: string;
112
+ state: CheckResultState;
113
+ source: CheckResultSource;
114
+ }[];
115
+ /**
116
+ * What a project's Full checks are asked about for learning (`learn`): its tracked identities (learning_watch) — an
117
+ * accepted note's until it ends, an observation's for 90 days — exact cases only, the most recently tracked first, at
118
+ * most 200. Those left out are counted (`leftOut`): a coverage gap, said, never hidden.
119
+ */
120
+ export declare function learnRequestFor(store: Store, repo: string, now: Date): {
121
+ learn: string[];
122
+ leftOut: number;
123
+ };
124
+ export {};
@@ -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 });
@@ -150,7 +150,7 @@ export declare const CONSOLE_FORMS: {
150
150
  readonly peopleRevoke: FormContract<"name" | "token">;
151
151
  readonly runnerRetire: FormContract<"name" | "token">;
152
152
  readonly decisionAnswer: FormContract<"choice" | "confirm" | "note" | "return">;
153
- readonly confirmStopped: FormContract<"checked" | "return" | "run" | "token">;
153
+ readonly recheckStopped: FormContract<"return" | "run">;
154
154
  readonly merge: FormContract<"anyway" | "run" | "token">;
155
155
  readonly onboard: FormContract<"big-ok" | "nonce" | "repo" | "root" | "token">;
156
156
  readonly pushSubscribe: FormContract<"auth" | "endpoint" | "p256dh" | "token">;
@@ -199,7 +199,7 @@ export declare const CONSOLE_FORMS: {
199
199
  readonly taskCancel: FormContract<"reason">;
200
200
  readonly taskRoute: FormContract<"agent" | "clear-phase" | "model" | "phase" | "provider" | "return" | "risky" | "sawDigest" | "size">;
201
201
  readonly taskScope: FormContract<"acceptance" | "budget-microusd" | "budget-usd" | "goal" | "goal-brief" | "not" | "permission-mode" | "quality-mode" | "requirement" | "requirement-new" | "sawDigest" | "touches">;
202
- readonly taskApprove: FormContract<"digest" | "nonce" | "rebuild" | "return" | "token">;
202
+ readonly taskApprove: FormContract<"digest" | "nonce" | "rebuild" | "return" | "sheet" | "token">;
203
203
  readonly taskAcceptProof: FormContract<"note" | "return" | "run">;
204
204
  readonly taskComplete: FormContract<"accept" | "note" | "publish" | "receipt" | "return" | "run">;
205
205
  readonly taskStop: FormContract<"return" | "run">;
@@ -165,7 +165,7 @@ export const CONSOLE_FORMS = {
165
165
  peopleRevoke: formContract("POST /people/revoke", ["token", "name"]),
166
166
  runnerRetire: formContract("POST /fleet/runner/retire", ["token", "name"]),
167
167
  decisionAnswer: formContract("POST /d/<id>/answer", ["return", "choice", "confirm", "note"]),
168
- confirmStopped: formContract("POST /t/<task>/confirm-stopped", ["run", "token", "checked", "return"]),
168
+ recheckStopped: formContract("POST /t/<task>/recheck-stopped", ["run", "return"]),
169
169
  merge: formContract("POST /t/<task>/merge", ["run", "token", "anyway"]),
170
170
  onboard: formContract("POST /projects/onboard-preview, /projects/onboard-confirm", ["repo", "root", "token", "nonce", "big-ok"]),
171
171
  pushSubscribe: formContract("POST /push/subscribe", ["token", "endpoint", "p256dh", "auth"]),
@@ -217,7 +217,7 @@ export const CONSOLE_FORMS = {
217
217
  taskCancel: formContract("POST /t/<task>/cancel", ["reason"]),
218
218
  taskRoute: formContract("POST /t/<task>/route", ["size", "risky", "phase", "clear-phase", "agent", "provider", "model", "sawDigest", "return"]),
219
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"]),
220
- taskApprove: formContract("POST /t/<task>/approve", ["return", "digest", "token", "nonce", "rebuild"]),
220
+ taskApprove: formContract("POST /t/<task>/approve", ["return", "digest", "token", "nonce", "rebuild", "sheet"]),
221
221
  taskAcceptProof: formContract("POST /t/<task>/accept-proof", ["run", "note", "return"]),
222
222
  taskComplete: formContract("POST /t/<task>/complete", ["receipt", "run", "accept", "note", "publish", "return"]),
223
223
  taskStop: formContract("POST /t/<task>/stop", ["run", "return"]),