toolroll 0.9.74 → 0.9.76

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 (119) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/agent-fence.js +8 -0
  3. package/dist/approval-authority.js +6 -2
  4. package/dist/approval-rules-ui.d.ts +11 -0
  5. package/dist/approval-rules-ui.js +13 -1
  6. package/dist/assignment.d.ts +3 -0
  7. package/dist/assignment.js +27 -8
  8. package/dist/batch-checks.js +3 -1
  9. package/dist/browser/workspace.js +27 -27
  10. package/dist/browser-workspace.d.ts +1 -1
  11. package/dist/builder.js +1 -1
  12. package/dist/chat-acceptance.d.ts +4 -1
  13. package/dist/chat-acceptance.js +13 -3
  14. package/dist/cli.js +1 -1
  15. package/dist/coding-handoff.js +4 -3
  16. package/dist/coding-ui.js +2 -2
  17. package/dist/coding-workspace.js +23 -9
  18. package/dist/contracts/console-api.d.ts +6 -1
  19. package/dist/contracts/console-api.js +11 -0
  20. package/dist/control-ui.d.ts +3 -2
  21. package/dist/control-ui.js +7 -2
  22. package/dist/demo.js +5 -3
  23. package/dist/deploy-gate.d.ts +3 -0
  24. package/dist/deploy-gate.js +3 -2
  25. package/dist/desktop-update.d.ts +4 -1
  26. package/dist/desktop-update.js +43 -9
  27. package/dist/discover.js +6 -1
  28. package/dist/first-run.d.ts +8 -5
  29. package/dist/first-run.js +42 -7
  30. package/dist/flow-card-task.js +3 -2
  31. package/dist/flow-unattended.d.ts +2 -0
  32. package/dist/flow-unattended.js +12 -1
  33. package/dist/git-incident.d.ts +49 -0
  34. package/dist/git-incident.js +69 -0
  35. package/dist/git-views.d.ts +20 -0
  36. package/dist/git-views.js +120 -0
  37. package/dist/grant.js +3 -1
  38. package/dist/integrations.d.ts +4 -3
  39. package/dist/integrations.js +5 -6
  40. package/dist/lead-contract.d.ts +1 -1
  41. package/dist/lead-contract.js +2 -2
  42. package/dist/lead-follow.js +3 -1
  43. package/dist/openrouter-models.d.ts +5 -2
  44. package/dist/openrouter-models.js +7 -4
  45. package/dist/operate.js +9 -1
  46. package/dist/planner.js +5 -1
  47. package/dist/project-knowledge.js +1 -1
  48. package/dist/project-learning.js +65 -7
  49. package/dist/project-tools.js +19 -1
  50. package/dist/pull-request-flow.js +4 -0
  51. package/dist/pull-request-ui.d.ts +1 -1
  52. package/dist/pull-request-ui.js +5 -1
  53. package/dist/push.d.ts +2 -0
  54. package/dist/push.js +24 -19
  55. package/dist/read-only-request.d.ts +4 -0
  56. package/dist/read-only-request.js +15 -0
  57. package/dist/release-adapters.d.ts +24 -2
  58. package/dist/release-adapters.js +136 -10
  59. package/dist/release-authority.d.ts +46 -0
  60. package/dist/release-authority.js +64 -0
  61. package/dist/release-auto.d.ts +153 -0
  62. package/dist/release-auto.js +496 -0
  63. package/dist/release-cli.js +56 -2
  64. package/dist/release-coverage.js +1 -1
  65. package/dist/release.d.ts +105 -1
  66. package/dist/release.js +229 -9
  67. package/dist/repository-context.js +5 -2
  68. package/dist/result-follow-ups.js +23 -2
  69. package/dist/result-review.js +5 -1
  70. package/dist/scope.d.ts +31 -2
  71. package/dist/scope.js +44 -3
  72. package/dist/scout.js +5 -1
  73. package/dist/serve.js +162 -6
  74. package/dist/server/agents-live-filed.d.ts +17 -0
  75. package/dist/server/agents-live-filed.js +97 -0
  76. package/dist/server/agents-live-room.js +4 -1
  77. package/dist/server/chat.js +31 -23
  78. package/dist/server/http.d.ts +5 -0
  79. package/dist/server/last-checks.d.ts +17 -0
  80. package/dist/server/last-checks.js +22 -0
  81. package/dist/server/pages.js +18 -4
  82. package/dist/server/people-tokens.js +7 -4
  83. package/dist/server/render-pages.d.ts +2 -1
  84. package/dist/server/render-pages.js +28 -24
  85. package/dist/server/render-results.d.ts +11 -2
  86. package/dist/server/render-results.js +54 -12
  87. package/dist/server/render-settings.js +3 -3
  88. package/dist/server/render-tasks.d.ts +10 -0
  89. package/dist/server/render-tasks.js +30 -17
  90. package/dist/server/route-table.js +17 -1
  91. package/dist/server/runtime.d.ts +16 -1
  92. package/dist/server/settings.js +111 -31
  93. package/dist/server/task-views.js +8 -1
  94. package/dist/server/tasks.js +11 -2
  95. package/dist/storage-ui.d.ts +2 -0
  96. package/dist/storage-ui.js +1 -1
  97. package/dist/storage.js +2 -1
  98. package/dist/store.d.ts +4 -1
  99. package/dist/store.js +52 -13
  100. package/dist/subagent-work.js +3 -0
  101. package/dist/task-base.js +1 -1
  102. package/dist/task-status.d.ts +28 -1
  103. package/dist/task-status.js +93 -11
  104. package/dist/teardown-git.d.ts +59 -5
  105. package/dist/teardown-git.js +1022 -90
  106. package/dist/telegram-progress.js +4 -4
  107. package/dist/telegram-status.js +8 -3
  108. package/dist/test-infra.d.ts +26 -0
  109. package/dist/test-infra.js +321 -0
  110. package/dist/tool-secret-store.js +8 -0
  111. package/dist/toolroll-update-ui.d.ts +2 -0
  112. package/dist/toolroll-update-ui.js +4 -3
  113. package/dist/toolroll-update.js +10 -9
  114. package/dist/work-index.js +29 -12
  115. package/dist/workspace-ui.d.ts +5 -0
  116. package/dist/workspace-ui.js +38 -10
  117. package/dist/worktree.d.ts +4 -0
  118. package/dist/worktree.js +13 -7
  119. package/package.json +1 -1
@@ -7,7 +7,7 @@
7
7
  import { assignmentOf } from "./assignment.js";
8
8
  import { chatResultHref, chatControlHref } from "./chat-controls.js";
9
9
  import { phoneText, projectLabel } from "./telegram-status.js";
10
- import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, unverifiedWhenRefuted, taskStatusOf } from "./task-status.js";
10
+ import { approvedCheckOf, assignmentStatusFacts, checksFactOf, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, unverifiedWhenRefuted, taskStatusOf } from "./task-status.js";
11
11
  import { leadClaimOf } from "./lead-voice.js";
12
12
  import { leadNameOf } from "./lead-identity.js";
13
13
  import { chatTitle } from "./chat-voice.js";
@@ -46,10 +46,10 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
46
46
  const proof = store.proofVerdictFor(run.id);
47
47
  const machine = proof?.machineVerdict ?? proof?.verdict ?? null;
48
48
  const exit = failedCheckExit(proof?.reasons ?? []);
49
- const checks = recorded !== null ? { status: recorded.status, exitCode: recorded.exitCode, head: run.headRevision }
49
+ // The one answer (task-status.ts approvedCheckOf) when a saved check was read; a verdict alone claims no pass.
50
+ const checks = recorded !== null ? approvedCheckOf(machine, proof?.reasons ?? [], checksFactOf(recorded, run.headRevision))
50
51
  : exit !== null ? { status: "failed", exitCode: exit, head: run.headRevision }
51
- : machine === "verified" ? { status: "passed", exitCode: null, head: run.headRevision }
52
- : machine === "attested" ? { status: "not-run", exitCode: null, head: null } : null;
52
+ : machine === "attested" ? { status: "not-run", exitCode: null, head: null } : null;
53
53
  facts = { stage: "finished", report: run.role === "scout", checks, requirements: unverifiedWhenRefuted(requirementsOf(proof?.matrix), proof?.verdict),
54
54
  ...(pullRequest === undefined ? {} : { pullRequest }) };
55
55
  }
@@ -4,8 +4,8 @@ import { manualReviewOnly } from "./proof.js";
4
4
  * No provider calls, repository access, new workflow state, or inferred success. */
5
5
  import { diagnoseTaskDispatch, withDispatchDiagnoses } from "./dispatch.js";
6
6
  import { familyCompleted } from "./result-completion.js";
7
- import { assignmentStatusFacts, plainReasonOf, stageOfDispatch, taskStatusOf } from "./task-status.js";
8
- import { assignmentOf } from "./assignment.js";
7
+ import { approvedCheckOf, approvedCheckStateOf, assignmentStatusFacts, checksFactOf, plainReasonOf, stageOfDispatch, taskStatusOf } from "./task-status.js";
8
+ import { assignmentChecksForRun, assignmentOf } from "./assignment.js";
9
9
  import { CHAT_CONTROLS, chatControlHref, chatResultHref } from "./chat-controls.js";
10
10
  export function phoneCommand(text) {
11
11
  // Slash forms everywhere; the bare words too, because Slack and Discord
@@ -257,7 +257,12 @@ export function phoneTaskView(store, repos, id, now, root) {
257
257
  const proof = store.proofVerdictFor(result.id);
258
258
  link = taskLinkFor(id, d, { run: result.id, verdict: proof !== null });
259
259
  const accepted = store.proofAcceptance(result.id) !== null;
260
- const proofWords = { verified: "Checks passed at completion", attested: "Checks reported by the agent, not run by Toolroll", short: "Required saved material is missing", refuted: "The saved result conflicts with the approved scope" };
260
+ // Did the approved check pass (task-status.ts approvedCheckOf): read with the saved files, else no claim at all.
261
+ const checkNow = root === undefined || proof?.verdict !== "verified" ? null
262
+ : approvedCheckStateOf(approvedCheckOf(proof.verdict, proof.reasons, checksFactOf(assignmentChecksForRun(store, root, result.id, now), result.headRevision)));
263
+ const verifiedWords = root === undefined ? "Open the result to see its checks" : checkNow === "passed" ? "Checks passed at completion"
264
+ : checkNow === "failed" ? "Checks failed" : checkNow === "running" ? "Checks running" : "The check result couldn't be read";
265
+ const proofWords = { verified: verifiedWords, attested: "Checks reported by the agent, not run by Toolroll", short: "Required saved material is missing", refuted: "The saved result conflicts with the approved scope" };
261
266
  lines.push(`Checks: ${proof === null ? "No completion record saved" : manualReviewOnly(proof) ? accepted ? "Accepted by a person; the recorded checks are unchanged" : "A person must inspect this result; no recorded check failed" : proofWords[proof.verdict]}.`);
262
267
  if (manualReviewOnly(proof) && !accepted)
263
268
  lines.push('Reply “Send the result summary” for the checks, notes and screenshots.');
@@ -0,0 +1,26 @@
1
+ /** The product's entry points: the CLI binary, the processes it starts by file name, and the browser bundles. */
2
+ export declare const PRODUCT_ENTRIES: readonly string[];
3
+ /** A specifier reached from the tests that names no repository file and no known package: `prefix` is the repository
4
+ * path it might name (a folder or file stem), or null when it might name anything. */
5
+ export type Unresolved = {
6
+ from: string;
7
+ specifier: string | null;
8
+ prefix: string | null;
9
+ };
10
+ export type TestInfrastructure = {
11
+ infra: Set<string>;
12
+ unresolved: Unresolved[];
13
+ /** What the product's entry points reach (trusted product code, by Alex's decision). */
14
+ product: Set<string>;
15
+ };
16
+ /**
17
+ * The infrastructure among `files` (path → text: the tree's sources, and its tsconfig.json and package.json): reachable
18
+ * from a test root — every test file, every file under `test/`, every vitest config and its setup files — not itself a
19
+ * test, and not reachable from PRODUCT_ENTRIES; plus every specifier reached from a test root that couldn't be resolved.
20
+ */
21
+ export declare function testInfrastructure(files: ReadonlyMap<string, string>, allPaths?: ReadonlySet<string>): TestInfrastructure;
22
+ /**
23
+ * Whether a changed path is something an unresolved specifier might name. A computed one (no prefix) might name anything
24
+ * the other rules don't already trust: any source file that is neither a test nor product code.
25
+ */
26
+ export declare function mightName(open: Unresolved, path: string, found: TestInfrastructure): boolean;
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Test infrastructure, found structurally (docs/plans/auto-approve-green.md): every non-test source file that the tests
3
+ * or the test runner's own setup import, directly or through other files, and that the product's entry points never
4
+ * reach. Changing one changes how tests check things — an automatic release approval leaves that to a person.
5
+ *
6
+ * Imports are read with the TypeScript compiler (typescript-parser, a runtime dependency): `preProcessFile` for static,
7
+ * dynamic and `require` specifiers (comments never count), and the syntax tree for dynamic imports or requires whose
8
+ * specifier isn't a plain string. Specifiers resolve as the build resolves them: relative paths (`./x.js` → `./x.ts`),
9
+ * the project's tsconfig `paths`, and package.json `imports`. Fail closed: a specifier reached from the tests that
10
+ * resolves to no file and to no known package is "unresolved", with what it might name, and the caller refuses when the
11
+ * change touches anything it might name.
12
+ */
13
+ import { createRequire } from "node:module";
14
+ import { builtinModules } from "node:module";
15
+ let compiler;
16
+ const typescript = () => compiler ??= createRequire(import.meta.url)("typescript-parser");
17
+ const SOURCE = /\.(?:[cm]?[jt]sx?)$/;
18
+ const TEST = /\.test\.[cm]?[jt]sx?$/;
19
+ const VITEST_CONFIG = /(^|\/)vitest\.config\.[cm]?[jt]s$/;
20
+ const EXTENSIONS = ["ts", "tsx", "mts", "cts", "js", "mjs", "cjs", "jsx"];
21
+ /** The product's entry points: the CLI binary, the processes it starts by file name, and the browser bundles. */
22
+ export const PRODUCT_ENTRIES = Object.freeze([
23
+ "src/bin.ts", "src/cli.ts", "src/controller-service.ts", "src/desktop-host.ts", "src/sandbox-launch.ts", "src/tool-launcher.ts",
24
+ "src/browser/app.tsx", "src/browser/terminal-app.tsx",
25
+ ]);
26
+ const join = (dir, spec) => {
27
+ const parts = dir === "" ? [] : dir.split("/");
28
+ for (const part of spec.split("/")) {
29
+ if (part === "." || part === "")
30
+ continue;
31
+ if (part === "..") {
32
+ if (parts.length === 0)
33
+ return null;
34
+ parts.pop();
35
+ }
36
+ else
37
+ parts.push(part);
38
+ }
39
+ return parts.join("/");
40
+ };
41
+ const dirOf = (path) => path.split("/").slice(0, -1).join("/");
42
+ /** A repository path, tried as the build tries it (with or without its extension, or as a folder's index). */
43
+ function fileAt(target, files) {
44
+ const stem = target.replace(/\.(?:[cm]?js|jsx)$/, "");
45
+ const tries = [target, ...EXTENSIONS.map(ext => `${stem}.${ext}`), ...EXTENSIONS.map(ext => `${target}/index.${ext}`)];
46
+ return tries.find(one => files.has(one)) ?? null;
47
+ }
48
+ /** The project's resolution rules, read from its own tsconfig.json and package.json (as text in the same tree). */
49
+ function resolverFor(files, names, allPaths, aliases) {
50
+ const tsc = typescript();
51
+ let paths = {}, baseUrl = "";
52
+ const config = files.get("tsconfig.json");
53
+ if (config !== undefined) {
54
+ const parsed = tsc.parseConfigFileTextToJson("tsconfig.json", config).config;
55
+ paths = parsed?.compilerOptions?.paths ?? {};
56
+ baseUrl = join("", parsed?.compilerOptions?.baseUrl ?? ".") ?? "";
57
+ }
58
+ let pkg = {};
59
+ try {
60
+ pkg = JSON.parse(files.get("package.json") ?? "{}");
61
+ }
62
+ catch {
63
+ pkg = {};
64
+ }
65
+ const packages = new Set([pkg.dependencies, pkg.devDependencies, pkg.peerDependencies, pkg.optionalDependencies].flatMap(one => Object.keys(one ?? {})));
66
+ // The package's own name resolves to itself: never "some installed package".
67
+ const own = typeof pkg.name === "string" ? pkg.name : null;
68
+ const builtins = new Set(builtinModules);
69
+ const pattern = (key, spec) => {
70
+ if (!key.includes("*"))
71
+ return key === spec ? "" : null;
72
+ const [head, tail] = key.split("*");
73
+ return spec.startsWith(head) && spec.endsWith(tail) && spec.length >= head.length + tail.length ? spec.slice(head.length, spec.length - tail.length) : null;
74
+ };
75
+ return (from, spec) => {
76
+ // vitest's own aliases come first, as vitest applies them.
77
+ for (const alias of aliases) {
78
+ if (spec !== alias.find && !spec.startsWith(`${alias.find}/`))
79
+ continue;
80
+ const target = join(alias.replacement, spec.slice(alias.find.length).replace(/^\//, ""));
81
+ const file = target === null ? null : fileAt(target, names);
82
+ return file !== null ? { file } : { unresolved: target };
83
+ }
84
+ if (spec.startsWith("./") || spec.startsWith("../")) {
85
+ const target = join(dirOf(from), spec);
86
+ const file = target === null ? null : fileAt(target, names);
87
+ if (file !== null)
88
+ return { file };
89
+ // A file that isn't source (a stylesheet, JSON, an asset) is a leaf: it imports nothing.
90
+ if (target !== null && allPaths.has(target))
91
+ return { external: true };
92
+ return { unresolved: target };
93
+ }
94
+ for (const [key, targets] of Object.entries(paths)) {
95
+ const star = pattern(key, spec);
96
+ if (star === null)
97
+ continue;
98
+ for (const one of targets) {
99
+ const target = join(baseUrl, one.replace("*", star));
100
+ const file = target === null ? null : fileAt(target, names);
101
+ if (file !== null)
102
+ return { file };
103
+ }
104
+ return { unresolved: join(baseUrl, (targets[0] ?? "").replace("*", star)) };
105
+ }
106
+ if (spec.startsWith("#")) {
107
+ for (const [key, value] of Object.entries(pkg.imports ?? {})) {
108
+ const star = pattern(key, spec);
109
+ if (star === null)
110
+ continue;
111
+ const target = typeof value === "string" ? value : typeof value?.default === "string" ? value.default : null;
112
+ if (target === null)
113
+ return { unresolved: null };
114
+ const path = join("", target.replace("*", star));
115
+ const file = path === null ? null : fileAt(path, names) ?? fileAt(path.replace(/^dist\//, "src/"), names);
116
+ return file !== null ? { file } : { unresolved: path };
117
+ }
118
+ return { unresolved: null };
119
+ }
120
+ const bare = spec.startsWith("node:") ? spec.slice(5) : spec;
121
+ const name = bare.startsWith("@") ? bare.split("/").slice(0, 2).join("/") : bare.split("/")[0];
122
+ if (name === own)
123
+ return { unresolved: null };
124
+ if (spec.startsWith("node:") || builtins.has(name) || packages.has(name))
125
+ return { external: true };
126
+ // A package the manifest doesn't declare: what it names can't be known here.
127
+ return { unresolved: null };
128
+ };
129
+ }
130
+ /** The module-loading helpers vitest offers: each takes a specifier first. */
131
+ const VI_LOADERS = new Set(["mock", "doMock", "unmock", "doUnmock", "importActual", "importMock"]);
132
+ /**
133
+ * Every specifier a file names: string ones (preProcessFile: static, dynamic, `require`; comments don't count); every
134
+ * call that loads a module from its first argument — `require`, `import()`, `createRequire(…)(…)` and any name bound to
135
+ * `createRequire(…)`, `vi.*`/`vitest.*` mock and import helpers — whose argument isn't a plain string is computed (`null`,
136
+ * or the template's literal head); and `soft`: every other relative string literal, an edge when it names a repo file.
137
+ */
138
+ function specifiersOf(path, text) {
139
+ const tsc = typescript();
140
+ const literal = tsc.preProcessFile(text, true, true).importedFiles.map(one => one.fileName);
141
+ const computed = [], soft = [];
142
+ const source = tsc.createSourceFile(path, text, tsc.ScriptTarget.Latest, false, /x$/.test(path) ? tsc.ScriptKind.TSX : tsc.ScriptKind.TS);
143
+ // Names bound to createRequire(…) anywhere in the file (a loader by another name).
144
+ const requires = new Set(["require"]);
145
+ const isCreateRequire = (node) => tsc.isCallExpression(node) && ((tsc.isIdentifier(node.expression) && node.expression.text === "createRequire")
146
+ || (tsc.isPropertyAccessExpression(node.expression) && node.expression.name.text === "createRequire"));
147
+ const bind = (node) => {
148
+ if (tsc.isVariableDeclaration(node) && tsc.isIdentifier(node.name) && node.initializer !== undefined && isCreateRequire(node.initializer))
149
+ requires.add(node.name.text);
150
+ if (tsc.isBinaryExpression(node) && node.operatorToken.kind === tsc.SyntaxKind.EqualsToken && tsc.isIdentifier(node.left) && isCreateRequire(node.right))
151
+ requires.add(node.left.text);
152
+ tsc.forEachChild(node, bind);
153
+ };
154
+ bind(source);
155
+ const loader = (callee) => callee.kind === tsc.SyntaxKind.ImportKeyword
156
+ || (tsc.isIdentifier(callee) && requires.has(callee.text))
157
+ || isCreateRequire(callee)
158
+ || (tsc.isPropertyAccessExpression(callee) && tsc.isIdentifier(callee.expression) && (callee.expression.text === "vi" || callee.expression.text === "vitest") && VI_LOADERS.has(callee.name.text))
159
+ || (tsc.isPropertyAccessExpression(callee) && callee.name.text === "require");
160
+ const visit = (node) => {
161
+ if (tsc.isCallExpression(node)) {
162
+ const first = node.arguments[0];
163
+ if (loader(node.expression) && first !== undefined) {
164
+ if (tsc.isStringLiteral(first) || tsc.isNoSubstitutionTemplateLiteral(first))
165
+ literal.push(first.text);
166
+ else
167
+ computed.push(tsc.isTemplateExpression(first) && (first.head.text.startsWith("./") || first.head.text.startsWith("../")) ? first.head.text : null);
168
+ }
169
+ }
170
+ if ((tsc.isStringLiteral(node) || tsc.isNoSubstitutionTemplateLiteral(node)) && (node.text.startsWith("./") || node.text.startsWith("../")))
171
+ soft.push(node.text);
172
+ tsc.forEachChild(node, visit);
173
+ };
174
+ visit(source);
175
+ return { literal, computed, soft };
176
+ }
177
+ /**
178
+ * The vitest configs' `resolve.alias` and `test.alias` (string keys to string paths, as an object or a list of
179
+ * `{ find, replacement }`), and whether any alias couldn't be read statically.
180
+ */
181
+ function vitestAliases(files) {
182
+ const tsc = typescript(), aliases = [], unreadable = [];
183
+ for (const [path, text] of files) {
184
+ if (!VITEST_CONFIG.test(path))
185
+ continue;
186
+ const source = tsc.createSourceFile(path, text, tsc.ScriptTarget.Latest, false, tsc.ScriptKind.TS);
187
+ const text_ = (node) => node !== undefined && (tsc.isStringLiteral(node) || tsc.isNoSubstitutionTemplateLiteral(node)) ? node.text : null;
188
+ const visit = (node) => {
189
+ if (tsc.isPropertyAssignment(node) && (tsc.isIdentifier(node.name) || tsc.isStringLiteral(node.name)) && node.name.text === "alias") {
190
+ const value = node.initializer;
191
+ const add = (find, replacement) => {
192
+ if (find === null || replacement === null || !(replacement.startsWith("./") || replacement.startsWith("../"))) {
193
+ unreadable.push(path);
194
+ return;
195
+ }
196
+ const target = join(dirOf(path), replacement);
197
+ if (target === null)
198
+ unreadable.push(path);
199
+ else
200
+ aliases.push({ find, replacement: target });
201
+ };
202
+ if (tsc.isObjectLiteralExpression(value)) {
203
+ for (const property of value.properties) {
204
+ if (!tsc.isPropertyAssignment(property)) {
205
+ unreadable.push(path);
206
+ continue;
207
+ }
208
+ add(tsc.isIdentifier(property.name) || tsc.isStringLiteral(property.name) ? property.name.text : null, text_(property.initializer));
209
+ }
210
+ }
211
+ else if (tsc.isArrayLiteralExpression(value)) {
212
+ for (const element of value.elements) {
213
+ if (!tsc.isObjectLiteralExpression(element)) {
214
+ unreadable.push(path);
215
+ continue;
216
+ }
217
+ const field = (name) => element.properties.find((one) => tsc.isPropertyAssignment(one) && (tsc.isIdentifier(one.name) || tsc.isStringLiteral(one.name)) && one.name.text === name)?.initializer;
218
+ add(text_(field("find")), text_(field("replacement")));
219
+ }
220
+ }
221
+ else
222
+ unreadable.push(path);
223
+ }
224
+ tsc.forEachChild(node, visit);
225
+ };
226
+ visit(source);
227
+ }
228
+ return { aliases, unreadable };
229
+ }
230
+ /** The vitest configs' setupFiles and globalSetup entries (string literals), as repository paths. */
231
+ function setupRoots(files, names) {
232
+ const tsc = typescript(), roots = [], unresolved = [];
233
+ for (const [path, text] of files) {
234
+ if (!VITEST_CONFIG.test(path))
235
+ continue;
236
+ const source = tsc.createSourceFile(path, text, tsc.ScriptTarget.Latest, false, tsc.ScriptKind.TS);
237
+ const visit = (node) => {
238
+ if (tsc.isPropertyAssignment(node) && (tsc.isIdentifier(node.name) || tsc.isStringLiteral(node.name)) && (node.name.text === "setupFiles" || node.name.text === "globalSetup")) {
239
+ const values = tsc.isArrayLiteralExpression(node.initializer) ? [...node.initializer.elements] : [node.initializer];
240
+ for (const value of values) {
241
+ if (!tsc.isStringLiteral(value)) {
242
+ unresolved.push({ from: path, specifier: null, prefix: null });
243
+ continue;
244
+ }
245
+ // vitest resolves them from its root: the config's own folder, or the repository root a config names.
246
+ const found = [dirOf(path), ""].map(dir => { const target = join(dir, value.text); return target === null ? null : fileAt(target, names); }).find(one => one !== null) ?? null;
247
+ if (found !== null)
248
+ roots.push(found);
249
+ else
250
+ unresolved.push({ from: path, specifier: value.text, prefix: join("", value.text) });
251
+ }
252
+ }
253
+ tsc.forEachChild(node, visit);
254
+ };
255
+ visit(source);
256
+ }
257
+ return { roots, unresolved };
258
+ }
259
+ /**
260
+ * The infrastructure among `files` (path → text: the tree's sources, and its tsconfig.json and package.json): reachable
261
+ * from a test root — every test file, every file under `test/`, every vitest config and its setup files — not itself a
262
+ * test, and not reachable from PRODUCT_ENTRIES; plus every specifier reached from a test root that couldn't be resolved.
263
+ */
264
+ export function testInfrastructure(files, allPaths = new Set(files.keys())) {
265
+ const names = new Set([...files.keys()].filter(path => SOURCE.test(path)));
266
+ const vitest = vitestAliases(files);
267
+ const resolve = resolverFor(files, names, allPaths, vitest.aliases);
268
+ const edges = new Map(), loose = new Map();
269
+ for (const path of names) {
270
+ const { literal, computed, soft } = specifiersOf(path, files.get(path));
271
+ const targets = new Set(), open = [];
272
+ for (const spec of literal) {
273
+ const found = resolve(path, spec);
274
+ if ("file" in found)
275
+ targets.add(found.file);
276
+ else if ("unresolved" in found)
277
+ open.push({ from: path, specifier: spec, prefix: found.unresolved });
278
+ }
279
+ for (const head of computed)
280
+ open.push({ from: path, specifier: head, prefix: head === null ? null : join(dirOf(path), head) });
281
+ // Any other relative string that names a repository source file is an edge (a path handed to a loader by any name).
282
+ for (const spec of soft) {
283
+ const found = resolve(path, spec);
284
+ if ("file" in found)
285
+ targets.add(found.file);
286
+ }
287
+ edges.set(path, [...targets]);
288
+ loose.set(path, open);
289
+ }
290
+ const setup = setupRoots(files, names);
291
+ const testRoots = [...[...names].filter(path => TEST.test(path) || path.startsWith("test/") || VITEST_CONFIG.test(path)), ...setup.roots];
292
+ const reach = (roots) => {
293
+ const seen = new Set(), todo = [...roots];
294
+ while (todo.length > 0) {
295
+ const at = todo.pop();
296
+ if (seen.has(at))
297
+ continue;
298
+ seen.add(at);
299
+ for (const next of edges.get(at) ?? [])
300
+ if (!seen.has(next))
301
+ todo.push(next);
302
+ }
303
+ return seen;
304
+ };
305
+ const product = reach(PRODUCT_ENTRIES.filter(path => names.has(path)));
306
+ const tested = reach(testRoots);
307
+ const infra = new Set([...tested].filter(path => !TEST.test(path) && !product.has(path)));
308
+ const unresolved = [...setup.unresolved, ...vitest.unreadable.map(from => ({ from, specifier: null, prefix: null })), ...[...tested].flatMap(path => loose.get(path) ?? [])];
309
+ return { infra, unresolved, product };
310
+ }
311
+ /**
312
+ * Whether a changed path is something an unresolved specifier might name. A computed one (no prefix) might name anything
313
+ * the other rules don't already trust: any source file that is neither a test nor product code.
314
+ */
315
+ export function mightName(open, path, found) {
316
+ if (open.prefix === null)
317
+ return SOURCE.test(path) && !TEST.test(path) && !found.product.has(path);
318
+ const prefix = open.prefix.replace(/\/$/, "");
319
+ const stem = path.replace(/\.[^/.]+$/, "");
320
+ return path === prefix || stem === prefix.replace(/\.(?:[cm]?js|jsx)$/, "") || path.startsWith(`${prefix}/`) || (open.specifier !== null && open.specifier.endsWith("/") === false && path.startsWith(prefix));
321
+ }
@@ -27,6 +27,7 @@
27
27
  * database records which key Toolroll made: a folder, key, link or lock that
28
28
  * something else put there first is refused, never used.
29
29
  */
30
+ import { inReadOnlyRequest } from "./read-only-request.js";
30
31
  import { spawnSync } from "node:child_process";
31
32
  import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
32
33
  import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeSync } from "node:fs";
@@ -541,6 +542,10 @@ export function secretFolder(state) {
541
542
  const known = folders.get(root);
542
543
  if (known !== undefined && unchanged(known))
543
544
  return known;
545
+ // A page visit prepares nothing (docs/plans/get-side-effects.md): the console prepares its folder as it starts, and
546
+ // an action checks a changed one again. Until then the page says secrets can't be read right now.
547
+ if (inReadOnlyRequest())
548
+ throw new SecretStoreError(NOT_READY);
544
549
  // Changed since it was checked (or never seen): checked against the same record (the database's, for Toolroll's
545
550
  // own folder; this process's, for a test's), and refused if it doesn't match.
546
551
  const named = pending.get(root);
@@ -797,6 +802,9 @@ export function readHeldSecret(state, name, file) {
797
802
  const backend = secretBackend();
798
803
  if (backend === null)
799
804
  return readOwnFile(path);
805
+ // A page visit moves nothing and takes no lock (docs/plans/get-side-effects.md): the keychain, else the old file.
806
+ if (inReadOnlyRequest())
807
+ return readSealed(backend, folder, heldItem(folder, name)) ?? readOwnFile(path);
800
808
  return withSecretLock(folder, `held.${name}`, () => {
801
809
  moveHeldIn(backend, folder, name, path);
802
810
  return readSealed(backend, folder, heldItem(folder, name));
@@ -17,6 +17,8 @@ export type UpdatesView = {
17
17
  problem: string;
18
18
  } | {
19
19
  off: true;
20
+ } | {
21
+ pending: true;
20
22
  };
21
23
  method: InstallMethod;
22
24
  journal: RuntimeUpdateJournal | null;
@@ -65,9 +65,10 @@ export function updatesHtml(view, notice) {
65
65
  : view.method.kind === "source" ? html `<h2>Toolroll ${view.current}</h2><p class="meta">This runs from a source checkout. Update it with git.</p>`
66
66
  : newer && view.method.kind === "desktop" ? html `<h2>Toolroll ${latest} is available</h2><p class="meta">You have ${view.current}. Update it from the Toolroll app.</p>`
67
67
  : newer ? html `<h2>Toolroll ${latest} is available</h2><p class="meta">You have ${view.current}. Running work finishes first, and your current version is kept so you can go back.</p>`
68
- : "off" in view.latest ? html `<h2>Toolroll ${view.current}</h2><p class="meta">Update checks are off.</p><form method="get" action="/settings/updates" class="update-actions"><input type="hidden" name="check" value="now"><button type="submit">Check now</button></form>`
69
- : latest === null ? html `<h2>Toolroll ${view.current}</h2><p class="meta">Couldn’t check for a newer release: ${"problem" in view.latest ? view.latest.problem : ""}</p>`
70
- : html `<h2>Toolroll ${view.current} is up to date</h2>`;
68
+ : "off" in view.latest ? html `<h2>Toolroll ${view.current}</h2><p class="meta">Update checks are off.</p>${postForm("/settings/updates/check", html `<button type="submit">Check now</button>`, { attrs: { class: "update-actions" } })}`
69
+ : "pending" in view.latest ? html `<h2>Toolroll ${view.current}</h2><p class="meta">Not checked for a newer release yet.</p>${postForm("/settings/updates/check", html `<button type="submit">Check now</button>`, { attrs: { class: "update-actions" } })}`
70
+ : latest === null ? html `<h2>Toolroll ${view.current}</h2><p class="meta">Couldn’t check for a newer release: ${"problem" in view.latest ? view.latest.problem : ""}</p>`
71
+ : html `<h2>Toolroll ${view.current} is up to date</h2>`;
71
72
  const form = newer && !["npx", "source", "desktop"].includes(view.method.kind)
72
73
  ? postForm("/settings/updates", html `${reauth(html `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}<div class="update-actions"><button type="submit" name="when" value="now" class="primary">Update now</button><button type="submit" name="when" value="when-idle">When idle</button><button type="submit" name="when" value="tonight">Tonight (03:00)</button></div>`, { hidden: { version: latest } })
73
74
  : "";
@@ -22,6 +22,7 @@
22
22
  * previous runtime and its backups. Every update, rollback, refusal and
23
23
  * failure is a ledger entry.
24
24
  */
25
+ import { inReadOnlyRequest } from "./read-only-request.js";
25
26
  import { createHash, randomUUID, verify as signatureValid, X509Certificate } from "node:crypto";
26
27
  import { spawnSync } from "node:child_process";
27
28
  import { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statfsSync, statSync, symlinkSync, writeFileSync } from "node:fs";
@@ -29,7 +30,7 @@ import { homedir, userInfo } from "node:os";
29
30
  import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
30
31
  import { fileURLToPath, pathToFileURL } from "node:url";
31
32
  import { createRequire } from "node:module";
32
- import { clearWords, durableJson, LINGERING_LIMIT_MS, lingeringRun, lingeringWords, settleLeftoverRecords, sqliteLock, desktopUpdateWaitingOf, stoppedWaitingWords, updateWaitingWords, verifiedDatabaseBackup } from "./desktop-update.js";
33
+ import { clearWords, durableJson, LINGERING_LIMIT_MS, lingeringRun, lingeringWords, settleLeftoverRecords, sqliteLock, lockHeld, desktopUpdateWaitingOf, stoppedWaitingWords, updateWaitingWords, verifiedDatabaseBackup } from "./desktop-update.js";
33
34
  import { activeUpdateWork, freezeUpdateGate, installUpdateGate, removeUpdateGate, updateAdmissionPaused, updateGateOwned } from "./desktop-update-gate.js";
34
35
  import { processMayBeAlive } from "./process-liveness.js";
35
36
  import { removeTerminalUpdateGate } from "./terminal-catalog.js";
@@ -1445,11 +1446,8 @@ export function runtimeUpdateStatus(stateDir) {
1445
1446
  j = null;
1446
1447
  }
1447
1448
  let running = false;
1448
- if (j && !runtimeUpdateTerminal(j.phase)) {
1449
- const lock = sqliteLock(join(j.stageDir, "worker.sqlite"));
1450
- running = lock === null;
1451
- lock?.close();
1452
- }
1449
+ if (j && !runtimeUpdateTerminal(j.phase))
1450
+ running = lockHeld(join(j.stageDir, "worker.sqlite"));
1453
1451
  const whatsNew = j && j.kind === "update" && j.phase === "complete" && !j.seen ? { version: j.to.version, notes: j.notes ?? [] } : null;
1454
1452
  let last = null;
1455
1453
  try {
@@ -1525,10 +1523,13 @@ export function releaseStalledUpdate(stateDir, now) {
1525
1523
  * waiting update, `toolroll update`'s or the desktop app's. */
1526
1524
  export function waitingUpdate(databaseFile, inTheWay, now, desktopStates) {
1527
1525
  const stateDir = dirname(databaseFile);
1528
- try {
1529
- releaseStalledUpdate(stateDir, now);
1526
+ // A page visit only reads (docs/plans/get-side-effects.md): the console's background pass releases a stalled update.
1527
+ if (!inReadOnlyRequest()) {
1528
+ try {
1529
+ releaseStalledUpdate(stateDir, now);
1530
+ }
1531
+ catch { /* an unreadable record is shown as it is */ }
1530
1532
  }
1531
- catch { /* an unreadable record is shown as it is */ }
1532
1533
  return updateWaitingOf(stateDir, inTheWay) ?? desktopUpdateWaitingOf(databaseFile, inTheWay, desktopStates);
1533
1534
  }
1534
1535
  export function markWhatsNewSeen(stateDir) {
@@ -10,7 +10,7 @@ import { readProjectAccess } from './project-access.js';
10
10
  import { pauseOnTaskProviders, providerName, signInReason } from './provider-auth.js';
11
11
  import { scopeTermsProblem, stopFactOf } from './store.js';
12
12
  import { assignmentChecksForRun } from './assignment.js';
13
- import { checkBackingOf, completionBlockersOf, leadOnIt, plainReasonOf, replacedWords, requirementsOf, stageOfCode, taskStatusOf, unverifiedWhenRefuted, workToneOf } from './task-status.js';
13
+ import { approvedCheckOf, checkBackingOf, completionBlockersOf, leadOnIt, plainReasonOf, replacedWords, requirementsOf, stageOfCode, taskStatusOf, unverifiedWhenRefuted, workToneOf } from './task-status.js';
14
14
  import { releaseCoverageOf } from './release-coverage.js';
15
15
  import { buildReviewOf } from './review-switch.js';
16
16
  import { shownVerdictOf } from './proof.js';
@@ -429,7 +429,7 @@ function readWorkIndexPage(store, now, access, options, taskId = null) {
429
429
  // automatic review and, with checks Off, the release check that covers it.
430
430
  const memo = new Map();
431
431
  const readiness = (row, checks) => resultReadinessFacts(store, now, options.root, row, checks, memo);
432
- return { items: page.map(row => itemOf(row, access.principal, probe, leadName, readiness)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]),
432
+ return { items: page.map(row => itemOf(row, access.principal, probe, leadName, readiness, options.root !== undefined)), projects, totals: rows[0] === undefined ? { ...EMPTY } : counts(rows[0]),
433
433
  groups: rows[0] === undefined ? { ...NO_GROUPS } : groupCounts(rows[0]), nextCursor, limit, view };
434
434
  }
435
435
  export function workCountsByProject(store, now, access) {
@@ -489,22 +489,36 @@ function listChecksOf(own, level, follows) {
489
489
  return null;
490
490
  return { status: read.status, exitCode: null, head: null, level: read.level, running: read.running };
491
491
  }
492
+ /** A check as a read without the evidence files may show it: only what never claims a pass it can't prove. */
493
+ function unreadChecksOf(checks) {
494
+ if (checks === null)
495
+ return null;
496
+ return checks.status === 'failed' || (checks.level === 'off' && checks.status !== 'passed' && checks.release == null) ? checks : null;
497
+ }
492
498
  /** A ready result's requirements, acceptance and completion blockers from its saved records, as assignmentOf reads them. */
493
499
  function resultReadinessFacts(store, now, root, row, recorded, memo) {
494
500
  const run = n(row, 'result_id'), repo = s(row, 'repo');
495
501
  if (!run)
496
- return { checks: recorded };
502
+ return { checks: root === undefined ? unreadChecksOf(recorded) : recorded };
497
503
  const report = s(row, 'result_role') === 'scout';
498
- // No recorded check result (a build from before results were recorded, or none ran): read its saved receipt, as the page does.
499
- const saved = root !== undefined && (recorded === null || recorded.status === 'not-run') ? assignmentChecksForRun(store, root, run, now) : null;
504
+ // Read its saved receipt, as the page does: a recorded result (or a verdict alone) is not a check that can still be
505
+ // read, and the row must never say passed while the task page says the check couldn't be read.
506
+ const read = root !== undefined && !report ? assignmentChecksForRun(store, root, run, now) : null;
507
+ // A recorded failure is never lost because its saved files can't be read now (fail closed: negatives stand).
508
+ const saved = read !== null && recorded?.status === 'failed' && (read.status === 'unavailable' || read.status === 'not-run')
509
+ ? { ...read, status: 'failed', exitCode: recorded.exitCode } : read;
500
510
  const listed = saved === null ? recorded : { status: saved.status, exitCode: saved.exitCode, head: s(row, 'result_head'),
501
511
  ...(saved.level == null ? {} : { level: saved.level }), ...(saved.running == null ? {} : { running: saved.running }), ...(saved.batch == null ? {} : { batch: saved.batch }) };
502
- const checks = listed?.level === 'off' && listed.status !== 'passed' && listed.status !== 'failed'
512
+ const covered = listed?.level === 'off' && listed.status !== 'passed' && listed.status !== 'failed'
503
513
  ? { ...listed, release: releaseCoverageOf(store, root, repo, s(row, 'result_head'), { memo }) } : listed;
504
514
  const proof = store.proofVerdictFor(run), accepted = store.proofAcceptance(run) !== null;
515
+ // The approved check as the task page reads it (task-status.ts): a recorded pass with no readable check doesn't count.
516
+ // Without the evidence root nothing can be read, so the row makes no claim about the check at all: a recorded
517
+ // failure or checks Off still say so; a pass, or a missing check, is the result's own page to say.
518
+ const checks = report ? covered : root === undefined ? unreadChecksOf(covered) : approvedCheckOf(proof?.verdict ?? null, proof?.reasons ?? [], covered);
505
519
  const review = buildReviewOf(store, run);
506
520
  const high = review === null || review.sentBackAs !== null || review.state !== 'reviewed' ? 0 : review.high.length;
507
- const checkRequired = requiredCheckCommandFor(store, repo, String(row['id']), checks?.level) !== null;
521
+ const checkRequired = root === undefined && checks === null ? false : requiredCheckCommandFor(store, repo, String(row['id']), checks?.level) !== null;
508
522
  const backing = checkBackingOf(checks);
509
523
  const requirements = requirementsOf(proof?.matrix, backing);
510
524
  const verdict = shownVerdictOf(proof?.verdict ?? null, { checkFailed: !report && checks?.status === 'failed', highFindings: high, reviewPending: review?.state === 'pending',
@@ -512,7 +526,7 @@ function resultReadinessFacts(store, now, root, row, recorded, memo) {
512
526
  const blockers = completionBlockersOf({ report, checks, checkRequired, matrix: proof?.matrix, verdict: proof?.verdict ?? null, accepted, high });
513
527
  return { checks, requirements: unverifiedWhenRefuted(requirements, verdict), ...(accepted ? { accepted } : {}), ...(blockers.length === 0 ? {} : { blockers }) };
514
528
  }
515
- function itemOf(row, principal, probe, leadName, readiness) {
529
+ function itemOf(row, principal, probe, leadName, readiness, readable = false) {
516
530
  const code = String(row['code']), id = String(row['id']);
517
531
  const custody = code === 'process-needs-attention' || (code === 'result-needs-attention' && n(row, 'custody_unresolved') === 1);
518
532
  const process = custody && probe !== undefined ? processNeedOf(probe(id)) : null;
@@ -624,15 +638,18 @@ function itemOf(row, principal, probe, leadName, readiness) {
624
638
  : coded.stage === 'needs-you' && (coded.need === undefined || coded.need === 'other') && INDEX_NEED[code] !== undefined ? { stage: 'needs-you', need: INDEX_NEED[code] } : coded;
625
639
  const checkStatus = s(row, 'check_status'), verdict = s(row, 'proof_verdict');
626
640
  const finished = reading.stage === 'finished' || reading.stage === 'complete';
627
- const own = checkStatus === 'failed' || n(row, 'proof_failed_check') === 1 ? 'failed' : checkStatus === 'passed' || verdict === 'verified' ? 'passed' : null;
628
- const listedChecks = !finished ? null : listChecksOf(own, s(row, 'check_level'), row['check_follows']);
629
- const ready = reading.stage === 'finished' && readiness !== undefined ? readiness(row, listedChecks) : null;
641
+ // Only a recorded check is a pass; a verdict alone isn't one (task-status.ts approvedCheckOf reads it with the receipt).
642
+ const own = checkStatus === 'failed' || n(row, 'proof_failed_check') === 1 ? 'failed' : checkStatus === 'passed' ? 'passed' : null;
643
+ const recordedChecks = !finished ? null : listChecksOf(own, s(row, 'check_level'), row['check_follows']);
644
+ // A read without the evidence files claims no pass (unreadChecksOf); the readiness read below says the rest.
645
+ const listedChecks = readable ? recordedChecks : unreadChecksOf(recordedChecks);
646
+ const ready = reading.stage === 'finished' && readiness !== undefined ? readiness(row, recordedChecks) : null;
630
647
  // Toolroll marked it complete because its work landed: the reason, said once.
631
648
  const landed = code === 'complete' ? landedReasonOf(s(row, 'checked_actor'), s(row, 'checked_detail')) : null;
632
649
  const shared = taskStatusOf({ stage: reading.stage, ...(reading.need === undefined ? {} : { need: reading.need }), ...(reading.wait === undefined ? {} : { wait: reading.wait }),
633
650
  ...(process === null ? {} : { needContext: { build: process.build } }),
634
651
  reason: finished ? null : plainReasonOf(reading.stage, code, detail || null), report: s(row, 'result_role') === 'scout',
635
- checks: ready?.checks ?? listedChecks,
652
+ checks: ready !== null ? ready.checks : listedChecks,
636
653
  ...(ready === null ? {} : { requirements: ready.requirements ?? null, ...(ready.accepted ? { accepted: true } : {}), ...(ready.blockers === undefined ? {} : { blockers: ready.blockers }) }),
637
654
  completedBy: code !== 'complete' || landed !== null ? null : n(row, 'completed_by_lead') === 1 ? 'the lead' : String(row['checked_actor']).replace(/^(?:operator|coordinator|lead):/, ''),
638
655
  ...(landed === null ? {} : { landed }),
@@ -22,6 +22,7 @@ export { ACCEPT_NEEDS_REASON, cantAcceptYetOf, evidenceProblemOf, type EvidenceP
22
22
  import { type ProofVerdict } from "./proof.js";
23
23
  import type { ReviewRetryState, TaskState } from "./store.js";
24
24
  import type { TaskControlView } from "./task-control.js";
25
+ import { type ChecksFact } from "./task-status.js";
25
26
  import { type Html } from "./html.js";
26
27
  /** The Work destination's views — shortcuts over the same rows, never a
27
28
  * persisted state. All is the default. */
@@ -53,6 +54,10 @@ export type ResultFacts = {
53
54
  * per run so an older selected result keeps its own words; undefined
54
55
  * when the caller did not read it, null when never requested. */
55
56
  review?: ReviewFacts | null;
57
+ /** The saved approved check as read now (assignmentChecksForRun), for `approvedCheckOf`: the words say the check
58
+ * passed only when this reading does. Undefined: the caller read no check (a list or dispatch read), so the words
59
+ * name only what the verdict recorded when the build finished. */
60
+ checks?: ChecksFact | null;
56
61
  };
57
62
  /** The result's review facts as `reviewRetryStateOf` records them, plus
58
63
  * the one liveness fact the words depend on. */