toolroll 0.9.75 → 0.9.77

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 (129) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +36 -8
  5. package/dist/approval-authority.js +6 -2
  6. package/dist/approval-rules-ui.d.ts +11 -0
  7. package/dist/approval-rules-ui.js +13 -1
  8. package/dist/assignment.js +3 -0
  9. package/dist/batch-checks.js +3 -1
  10. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  11. package/dist/browser/workspace.css +2 -2
  12. package/dist/browser/workspace.js +31 -31
  13. package/dist/builder.d.ts +14 -0
  14. package/dist/builder.js +141 -42
  15. package/dist/chat-decide.js +4 -0
  16. package/dist/check-failure.d.ts +92 -0
  17. package/dist/check-failure.js +267 -0
  18. package/dist/check-progress.d.ts +28 -1
  19. package/dist/check-progress.js +105 -6
  20. package/dist/check-report.d.ts +52 -6
  21. package/dist/check-report.js +193 -8
  22. package/dist/checkout-cleanup.js +2 -2
  23. package/dist/cli.js +8 -5
  24. package/dist/coding-handoff.d.ts +1 -0
  25. package/dist/coding-handoff.js +20 -11
  26. package/dist/coding-workspace.js +7 -5
  27. package/dist/commit-extract.d.ts +6 -0
  28. package/dist/commit-extract.js +63 -8
  29. package/dist/console-chrome.d.ts +41 -0
  30. package/dist/console-chrome.js +166 -0
  31. package/dist/contracts/console-api.d.ts +1 -0
  32. package/dist/contracts/console-api.js +1 -0
  33. package/dist/contracts/release-check.d.ts +12 -0
  34. package/dist/contracts/release-check.js +17 -0
  35. package/dist/contracts/verification-receipt.d.ts +20 -2
  36. package/dist/contracts/verification-receipt.js +15 -3
  37. package/dist/contracts/verification-view.d.ts +8 -0
  38. package/dist/controller-supervisor.d.ts +8 -0
  39. package/dist/controller-supervisor.js +33 -1
  40. package/dist/demo.js +3 -2
  41. package/dist/deploy-gate.d.ts +3 -0
  42. package/dist/deploy-gate.js +8 -2
  43. package/dist/desktop-update.d.ts +1 -1
  44. package/dist/discover.js +6 -1
  45. package/dist/dispose.d.ts +3 -0
  46. package/dist/dispose.js +6 -3
  47. package/dist/doctor.d.ts +7 -0
  48. package/dist/doctor.js +10 -2
  49. package/dist/envelope.js +2 -1
  50. package/dist/evidence.js +4 -1
  51. package/dist/exec.d.ts +7 -0
  52. package/dist/exec.js +7 -1
  53. package/dist/first-run.js +3 -1
  54. package/dist/git-floor.d.ts +16 -0
  55. package/dist/git-floor.js +36 -0
  56. package/dist/git-incident.d.ts +49 -0
  57. package/dist/git-incident.js +69 -0
  58. package/dist/git-views.d.ts +20 -0
  59. package/dist/git-views.js +120 -0
  60. package/dist/grant.js +7 -1
  61. package/dist/guides.js +4 -3
  62. package/dist/operate.d.ts +13 -0
  63. package/dist/operate.js +779 -319
  64. package/dist/planner.js +5 -1
  65. package/dist/prepared-evidence.d.ts +2 -1
  66. package/dist/prepared-evidence.js +8 -3
  67. package/dist/project-knowledge.js +6 -2
  68. package/dist/project-learning.js +23 -3
  69. package/dist/proof.js +6 -0
  70. package/dist/pull-request-flow.js +4 -0
  71. package/dist/read-executor-worker.js +8 -4
  72. package/dist/read-executor.d.ts +18 -1
  73. package/dist/read-executor.js +8 -0
  74. package/dist/release-adapters.d.ts +71 -2
  75. package/dist/release-adapters.js +471 -12
  76. package/dist/release-authority.d.ts +46 -0
  77. package/dist/release-authority.js +68 -0
  78. package/dist/release-auto.d.ts +184 -0
  79. package/dist/release-auto.js +612 -0
  80. package/dist/release-card.d.ts +15 -3
  81. package/dist/release-card.js +25 -9
  82. package/dist/release-ci-hold.d.ts +41 -0
  83. package/dist/release-ci-hold.js +53 -0
  84. package/dist/release-cli.js +77 -14
  85. package/dist/release-coverage.js +5 -2
  86. package/dist/release-recheck.d.ts +1 -1
  87. package/dist/release-recheck.js +6 -1
  88. package/dist/release.d.ts +207 -6
  89. package/dist/release.js +495 -20
  90. package/dist/repository-context.js +4 -6
  91. package/dist/result-follow-ups.js +3 -1
  92. package/dist/review-context.js +8 -6
  93. package/dist/runner.js +2 -1
  94. package/dist/sandbox-runtime.d.ts +8 -0
  95. package/dist/sandbox-runtime.js +14 -2
  96. package/dist/scope.d.ts +31 -2
  97. package/dist/scope.js +51 -4
  98. package/dist/scout.js +5 -1
  99. package/dist/secret-mint.d.ts +1 -0
  100. package/dist/secret-mint.js +14 -0
  101. package/dist/serve.js +101 -51
  102. package/dist/server/chat.js +5 -5
  103. package/dist/server/render-tasks.d.ts +12 -0
  104. package/dist/server/render-tasks.js +25 -14
  105. package/dist/server/request-context.d.ts +6 -0
  106. package/dist/server/route-table.js +3 -0
  107. package/dist/server/runtime.d.ts +2 -0
  108. package/dist/server/settings.js +29 -2
  109. package/dist/server/task-views.js +15 -2
  110. package/dist/setup-guide.d.ts +2 -2
  111. package/dist/storage.d.ts +5 -0
  112. package/dist/storage.js +28 -1
  113. package/dist/store.d.ts +11 -1
  114. package/dist/store.js +44 -5
  115. package/dist/task-base.d.ts +4 -0
  116. package/dist/task-base.js +22 -2
  117. package/dist/teardown-git.d.ts +90 -13
  118. package/dist/teardown-git.js +1027 -129
  119. package/dist/test-infra.d.ts +26 -0
  120. package/dist/test-infra.js +321 -0
  121. package/dist/up-processes.d.ts +137 -0
  122. package/dist/up-processes.js +165 -0
  123. package/dist/up-role-child.d.ts +1 -0
  124. package/dist/up-role-child.js +77 -0
  125. package/dist/verification-evidence.d.ts +1 -0
  126. package/dist/verification-evidence.js +13 -1
  127. package/dist/worktree.d.ts +4 -0
  128. package/dist/worktree.js +13 -7
  129. package/package.json +2 -1
@@ -9,7 +9,11 @@
9
9
  * After the check, the report is the last `TOOLROLL_CHECK_REPORT <json>` line of its output that carries that nonce
10
10
  * (a line a test prints, or one a leftover process writes, has no nonce and is ignored). It is read strictly and sealed
11
11
  * as `check-report.json`, beside `check-inventory.json` (what was sealed, and the groups and versions the check
12
- * declared). A run with no valid report still passes or fails as before; it just can't be the base of a targeted check.
12
+ * declared). A Full release check qualifies only with all of it (fullCheckProblem): a valid report carrying this run's
13
+ * nonce, a complete inventory, a terminal result for every part, file, journey group and upgrade version it owed, and a
14
+ * successful exit. Anything less — no report, a malformed or cut-short one, one whose exit disagrees, a part that never
15
+ * finished, a file with no result — is an error, "no result", and never a pass. A failed check's report only has to be
16
+ * valid: what it failed on is its result.
13
17
  *
14
18
  * Coverage trusts test code (the trust decision in the plan). Nothing here decides who may release or deploy.
15
19
  */
@@ -19,6 +23,7 @@ import { join } from "node:path";
19
23
  import { readVerifiedCommit, verifiedTreeEntries } from "./commit-extract.js";
20
24
  import { CHECK_PARTS, CHECK_REPORT_MAX_BYTES, CHECK_REPORT_PREFIX, readCheckReport, } from "./contracts/release-check.js";
21
25
  import { readVerifiedArtifact, storeEvidence } from "./evidence.js";
26
+ import { releaseBaselineRecord } from "./release-card.js";
22
27
  import { checkAttemptOf, recordCheckAttempt } from "./store.js";
23
28
  /** The sealed artifacts' capture labels: what a reader looks for, by exact name. */
24
29
  export const CHECK_REPORT_CAPTURE = "release check report v1";
@@ -91,17 +96,82 @@ export function treeFactsOf(gitDir, sha) {
91
96
  export function checkContextOf(facts, command, access) {
92
97
  return { version: 1, node: process.version, platform: process.platform, arch: process.arch, lockfile: facts.lockfile, harness: facts.harnessDigest, grant: { id: command.id, digest: command.digest }, access };
93
98
  }
94
- /** The parts a full check selects, as far as the plane knows them before it runs. */
99
+ /** Every part a check can have. */
95
100
  export const FULL_PARTS = CHECK_PARTS;
101
+ /** Changes no test reads: the plane's copy of scripts/release-check.mjs NOTHING (check-report.test.ts holds them equal). */
102
+ export const NOTHING_PATHS = Object.freeze([/\.md$/, /^docs\//, /^evidence\//, /^design\//, /^output\//, /^LICENSE$/, /\.png$/]);
103
+ /** Model-facing code, whose change runs the real-model journeys: the plane's copy of scripts/release-check.mjs MODEL. */
104
+ export const MODEL_PATHS = Object.freeze([
105
+ /^src\/(lead|converse|subscription-chat|chat-[a-z-]+|lead-[a-z-]+|memory-pass)\.ts$/,
106
+ /^src\/(planner|planner-[a-z-]+|plan|builder|build-review|reviewer|scout|scout-[a-z-]+|decision|evidence)\.ts$/,
107
+ /^src\/(subagents|subagent-work|task-sizing|flow-sort|flow-draft)\.ts$/,
108
+ /^src\/(provider|provider-[a-z-]+|invoke|exec|attest|coding-provider|assignment-adapters|subscription-[a-z-]+)\.ts$/,
109
+ ]);
110
+ /** Whether a changed path can affect an upgrade: the plane's copy of scripts/release-check.mjs needsUpgrade, without its
111
+ * version-only exception (the plane counts a version bump as a change, so it owes more, never less). */
112
+ export function upgradeRelevant(file) {
113
+ if (/^(docs|evidence|design|output)\//.test(file) || /\.md$/.test(file) || file === "LICENSE")
114
+ return false;
115
+ if (/\.test\.[cm]?[jt]sx?$/.test(file) || /^test\//.test(file))
116
+ return false;
117
+ if (/^src\/(browser|design)\//.test(file) || /\.css$/.test(file))
118
+ return false;
119
+ if (/^scripts\/(app-e2e|flows-e2e|e2e-parallel)\.mjs$/.test(file))
120
+ return false;
121
+ return true;
122
+ }
123
+ /** The paths that differ between two verified trees: added, removed, or with another mode or blob. */
124
+ export function changedPaths(facts, base) {
125
+ const before = new Map(base.entries.map(one => [one.path, `${one.mode} ${one.id}`]));
126
+ const after = new Map(facts.entries.map(one => [one.path, `${one.mode} ${one.id}`]));
127
+ return [...new Set([...before.keys(), ...after.keys()])].filter(path => before.get(path) !== after.get(path)).sort();
128
+ }
129
+ /**
130
+ * The parts a Full check owes, decided by the plane before it runs (eval-harness review 1, finding 2), never by the
131
+ * check's own report: the typecheck and the build always; the unit suite when anything a test reads changed since
132
+ * `base` (the plane's own record of the last release, both trees verified here) and the tree has unit files; the
133
+ * scripted journeys likewise when the tree has the journey scripts; the real-model journeys when model-facing code
134
+ * changed; the upgrade path when a change can affect an upgrade and the tree has it. With no base every one the tree
135
+ * can run is owed. The check is told these on its handshake and runs at least them.
136
+ */
137
+ export function fullObligationsOf(facts, base) {
138
+ // What the tree can run is read from the candidate AND the last release (security scan): a candidate that deletes a
139
+ // journey script, the upgrade path or its unit files doesn't take away what was owed at the base — the part stays
140
+ // owed, and a check that can't run it has no result. Any entry counts, a symlink included, never only a regular file.
141
+ const trees = [facts, ...(base === null ? [] : [base])];
142
+ const has = (path) => trees.some(tree => tree.entries.some(one => one.path === path));
143
+ const units = trees.some(tree => tree.units.length > 0 || tree.entries.some(one => isUnitFile(one.path)));
144
+ const journeys = has("scripts/flows-e2e.mjs") || has("scripts/app-e2e.mjs");
145
+ const changed = base === null ? null : changedPaths(facts, base);
146
+ const code = changed?.filter(path => !NOTHING_PATHS.some(re => re.test(path))) ?? null;
147
+ const checks = code === null || code.length > 0;
148
+ const real = code === null || code.some(path => !/\.test\.[cm]?[jt]s$/.test(path) && MODEL_PATHS.some(re => re.test(path)));
149
+ const upgrade = has("scripts/upgrade-path.mjs") && (changed === null || changed.some(upgradeRelevant));
150
+ return [
151
+ "typecheck", "build",
152
+ ...(checks && units ? ["unit"] : []),
153
+ ...(checks && journeys ? ["flows", "app"] : []),
154
+ ...(real && journeys ? ["flows-real", "app-real"] : []),
155
+ ...(upgrade ? ["upgrade"] : []),
156
+ ];
157
+ }
96
158
  /** What a check must account for, sealed before it runs: every unit file of the tree, whatever runs (a targeted
97
- * check accounts for the rest through its base), and the parts it selects. */
98
- export function inventoryOf(facts, plan) {
99
- return { version: 1, units: facts.units, parts: plan === null ? [...FULL_PARTS] : ["typecheck", "build", ...plan.parts.filter(one => one !== "typecheck" && one !== "build")] };
159
+ * check accounts for the rest through its base), and the parts it owes (a full check's from fullObligationsOf), and
160
+ * whether CI may stand in for its unit suite. */
161
+ export function inventoryOf(facts, plan, full = { owed: FULL_PARTS, release: true }) {
162
+ if (plan !== null)
163
+ return { version: 1, units: facts.units, parts: ["typecheck", "build", ...plan.parts.filter(one => one !== "typecheck" && one !== "build")] };
164
+ return { version: 1, units: facts.units, parts: [...full.owed], ciSubstitute: !full.release, release: full.release };
165
+ }
166
+ /** A Full check's preparation refused (eval-harness review 2, finding 4): the plane recorded a release whose commit it
167
+ * can't verify now, so the check's obligations can't be decided. The check isn't run (builder.ts: `baseline-unverifiable`). */
168
+ export class CheckBaselineUnverifiable extends Error {
100
169
  }
101
170
  /**
102
171
  * Seal a check's attempt row before its command is spawned: intended mode, nonce hash, inventory and context (and for a
103
172
  * targeted check its plan and obligation snapshot, sealed as artifacts first). Null when the plane can't read the
104
- * commit, which leaves the check running exactly as it always has, with no handshake.
173
+ * commit, which leaves the check running exactly as it always has, with no handshake. Throws CheckBaselineUnverifiable,
174
+ * having sealed nothing, when a recorded last release can't be verified.
105
175
  */
106
176
  export function prepareCheckAttempt(store, root, input, now) {
107
177
  if (checkAttemptOf(store, input.runId) !== null)
@@ -113,7 +183,22 @@ export function prepareCheckAttempt(store, root, input, now) {
113
183
  if (!read.ok)
114
184
  return null;
115
185
  const plan = input.plan ?? null, snapshot = input.snapshot ?? null;
116
- const inventory = inventoryOf(read.facts, plan);
186
+ // A full check owes what the plane decides here, from the last release it recorded (never a tag the checkout holds).
187
+ // No release recorded owes everything the candidate can run; a recorded one that can't be verified refuses the
188
+ // preparation (eval-harness review 2, finding 4): read as "no release", it would drop what the last release could run.
189
+ const baseline = plan === null ? releaseBaselineRecord(store, input.repo) : { recorded: false };
190
+ let baseFacts = null;
191
+ if (baseline.recorded) {
192
+ const baseRead = baseline.sha === null ? null : treeFactsOf(gitDir, baseline.sha);
193
+ if (baseRead === null || !baseRead.ok) {
194
+ throw new CheckBaselineUnverifiable(`The last release Toolroll recorded (${baseline.sha === null ? "its merge commit isn't a full id" : baseline.sha.slice(0, 7)}) couldn't be verified${baseRead !== null && !baseRead.ok ? ` (${baseRead.problem})` : ""}, so what this check owes is unknown; it wasn't run.`);
195
+ }
196
+ baseFacts = baseRead.facts;
197
+ }
198
+ const owed = fullObligationsOf(read.facts, baseFacts);
199
+ const release = input.release !== false;
200
+ const ciSubstitute = !release;
201
+ const inventory = inventoryOf(read.facts, plan, { owed, release });
117
202
  const context = checkContextOf(read.facts, input.command, input.access);
118
203
  const nonce = randomBytes(32).toString("hex");
119
204
  const mode = plan === null ? "full" : "targeted";
@@ -128,7 +213,7 @@ export function prepareCheckAttempt(store, root, input, now) {
128
213
  inventoryDigest: payloadDigest(inventory), contextDigest: payloadDigest(context), nonceHash: sha256(nonce),
129
214
  }, now);
130
215
  // The command's own limit rides along, so the check ends itself (logs and report printed) before the plane stops it.
131
- const stdin = JSON.stringify({ nonce, mode, plan, watch: [...(input.watch ?? [])], limitMs: input.command.timeoutMs });
216
+ const stdin = JSON.stringify({ nonce, mode, plan, watch: [...(input.watch ?? [])], limitMs: input.command.timeoutMs, ...(plan === null ? { owed, ciSubstitute } : {}) });
132
217
  return { runId: input.runId, nonce, stdin, mode, inventory, context, plan, snapshot, watch: [...(input.watch ?? [])] };
133
218
  }
134
219
  /** Seal one JSON payload as a structured-output artifact under its capture label. */
@@ -203,3 +288,103 @@ export function readSealedJson(store, root, runId, capture) {
203
288
  return null;
204
289
  }
205
290
  }
291
+ /**
292
+ * Why a Full release check has no result it can count — or null when it qualifies as one. The plane's own facts are the
293
+ * exit it saw (`exitCode`, null when the check didn't exit on its own: timed out or stopped) and the inventory it sealed
294
+ * before the check ran; the report must agree with both. A failed check (a nonzero exit) needs only a valid report: what
295
+ * it failed on is its result. A passing exit needs every obligation the plane sealed (the inventory's parts, never the
296
+ * report's own "selected") to have an outcome, and every part the report ran too: every part once, each run to its
297
+ * end; the unit suite finished with a result for every sealed unit file (CI stands in only when the sealed plan allowed
298
+ * it); every journey run finished, its groups listed before they ran, each passed group with a journey result; and the
299
+ * upgrade path finished, its versions listed, every one run. An explicitly empty inventory (no groups of that kind) is
300
+ * an outcome; a missing one is not. Missing, malformed, truncated or skipped-required results never read as a pass.
301
+ */
302
+ export function fullCheckProblem(input) {
303
+ const { report, inventory, exitCode } = input;
304
+ if (exitCode === null)
305
+ return "the check didn't end on its own (it timed out or was stopped), so it has no result";
306
+ if (report === null)
307
+ return "the check gave no valid report for this run (none, a malformed or cut-short one, or one without this run's nonce)";
308
+ if (report.mode !== "full")
309
+ return "the check reported a targeted run, not the full one it was asked for";
310
+ if (report.exitCode !== exitCode)
311
+ return `the check's report says exit ${report.exitCode}, but it exited ${exitCode}`;
312
+ if (inventory === null)
313
+ return "the check's sealed inventory can't be read";
314
+ // Sealed before it ran as a release's check, or it isn't one this can count (security scan: the decision is recorded
315
+ // with the attempt and a mismatch is no result).
316
+ if (inventory.sealed.release !== true)
317
+ return "the check wasn't sealed as a release's check before it ran";
318
+ if (exitCode !== 0)
319
+ return null;
320
+ const parts = new Map(report.parts.map(one => [one.part, one]));
321
+ if (parts.size !== report.parts.length || CHECK_PARTS.some(part => !parts.has(part)))
322
+ return "the report doesn't account for every part of the check exactly once";
323
+ // The obligations are the plane's, sealed before the run: the typecheck and build always, and every part it owed.
324
+ const owed = new Set(["typecheck", "build", ...inventory.sealed.parts]);
325
+ for (const part of CHECK_PARTS)
326
+ if (owed.has(part) && parts.get(part).selected !== true)
327
+ return `the report says the ${part} wasn't selected, but the check owed it`;
328
+ for (const part of report.parts) {
329
+ if (!part.selected)
330
+ continue;
331
+ if (!part.ran || part.code === null)
332
+ return `${part.part} has no result: it was selected and never ran`;
333
+ if (part.timedOut || part.stopped === true || part.signal != null)
334
+ return `${part.part} has no result: it was stopped before it finished`;
335
+ if (part.code !== 0)
336
+ return `${part.part} exited ${part.code}, yet the check exited 0`;
337
+ }
338
+ if (parts.get("unit").selected) {
339
+ const units = report.units;
340
+ if (units === null)
341
+ return "the unit suite reported nothing";
342
+ if (!units.finished)
343
+ return "the unit suite didn't reach its end";
344
+ if (units.runErrors > 0)
345
+ return `the unit suite had ${units.runErrors} errors no test owned`;
346
+ if (units.files.some(one => one.state === "failed"))
347
+ return "a unit file failed, yet the check exited 0";
348
+ // A release's check runs its own units, whatever was sealed (security scan): CI never stands in here.
349
+ if (units.reusedFromCI)
350
+ return "CI's run stood in for the unit suite, which a release's check never allows";
351
+ if (!units.reusedFromCI) {
352
+ const reported = new Set(units.files.map(one => one.file));
353
+ const missing = inventory.sealed.units.filter(file => !reported.has(file));
354
+ if (missing.length > 0)
355
+ return `${missing.length} unit file${missing.length === 1 ? " has" : "s have"} no result (${missing.slice(0, 3).join(", ")}${missing.length > 3 ? ", …" : ""})`;
356
+ }
357
+ }
358
+ for (const label of ["flows", "app", "flows-real", "app-real"]) {
359
+ if (!parts.get(label).selected)
360
+ continue;
361
+ const script = label.replace(/-real$/, ""), kind = label.endsWith("-real") ? "real" : "scripted";
362
+ const runs = report.journeys.filter(one => one.script === script && one.kind === kind);
363
+ if (runs.length === 0)
364
+ return `the ${label} journeys reported nothing`;
365
+ if (runs.length > 1)
366
+ return `the ${label} journeys reported more than one run`;
367
+ const run = runs[0];
368
+ if (!run.finished)
369
+ return `the ${label} journeys didn't reach their end`;
370
+ if (run.inventoried !== true)
371
+ return `the ${label} journeys never listed their groups, so what they owed can't be told`;
372
+ const open = run.groups.filter(group => group.state !== "passed");
373
+ if (open.length > 0)
374
+ return `the ${label} journey group ${open[0].group} ${open[0].state === "not-run" ? "never ran" : "failed"}, yet the check exited 0`;
375
+ const unread = run.groups.find(group => group.unattributed || !group.journeys.some(one => one.state === "passed"));
376
+ if (unread !== undefined)
377
+ return `the ${label} journey group ${unread.group} reported no journey's result`;
378
+ }
379
+ if (parts.get("upgrade").selected) {
380
+ const upgrades = report.upgrades;
381
+ if (upgrades === null || !upgrades.finished)
382
+ return "the upgrade path didn't reach its end";
383
+ if (upgrades.inventoried !== true)
384
+ return "the upgrade path never listed its versions, so what it owed can't be told";
385
+ const open = upgrades.versions.filter(one => one.state !== "passed");
386
+ if (open.length > 0)
387
+ return `the upgrade from ${open[0].version} ${open[0].state === "not-run" ? "never ran" : "failed"}, yet the check exited 0`;
388
+ }
389
+ return null;
390
+ }
@@ -21,7 +21,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
21
21
  import { basename, dirname, join } from "node:path";
22
22
  import { taskBranches } from "./names.js";
23
23
  import { COMPLETION_ACTION } from "./result-completion.js";
24
- import { CLEANUP_CHOICES, bytesWords, diskBytes } from "./storage.js";
24
+ import { CLEANUP_CHOICES, bytesWords, diskBytes, diskBytesAsync } from "./storage.js";
25
25
  import { worktreePath } from "./worktree.js";
26
26
  const DAY_MS = 86_400_000;
27
27
  /** A release candidate's checkout holds the build a deploy installs: the newest complete one waits at most a week for it. */
@@ -264,7 +264,7 @@ async function eachAtMost(items, limit, work) {
264
264
  export async function checkoutPlan(store, pool, now, options) {
265
265
  const cleanup = store.checkoutCleanup();
266
266
  const rows = store.listWorktrees().filter(row => existsSync(row.path) && (options.repo === undefined || row.repo === options.repo));
267
- const sizes = diskBytes(rows.map(row => row.path));
267
+ const sizes = await diskBytesAsync(rows.map(row => row.path));
268
268
  const statuses = taskStatuses(store, now);
269
269
  const items = await eachAtMost(rows, INSPECT_AT_ONCE, async (row) => {
270
270
  const kept = keptFor(pool, row, statuses, now, options.manual ? "manual" : cleanup);
package/dist/cli.js CHANGED
@@ -514,11 +514,13 @@ function renderMissingJson(roots, missingRoots, now) {
514
514
  async function runReposCommand(argv, write, onboard) {
515
515
  // `repos` honors the machine contract like everything else (audit TG-3):
516
516
  // --json answers with one envelope, whatever the outcome.
517
+ // The lead adds with its token (--token <t>, --token=<t> or TOOLROLL_LEAD_TOKEN); the owner needs none. A token is
518
+ // random base64url and may start with "--": it is the next argument, whatever it looks like.
519
+ const tokenAt = argv.findIndex(argument => argument === "--token" || argument.startsWith("--token="));
520
+ const tokenInline = tokenAt !== -1 && argv[tokenAt] !== "--token";
521
+ const tokenFlag = tokenAt === -1 ? undefined : tokenInline ? argv[tokenAt].slice("--token=".length) : argv[tokenAt + 1];
517
522
  const json = argv.includes("--json");
518
- // The lead adds with its token (--token or TOOLROLL_LEAD_TOKEN); the owner needs none.
519
- const tokenAt = argv.indexOf("--token");
520
- const tokenFlag = tokenAt === -1 ? undefined : argv[tokenAt + 1];
521
- const bare = argv.filter((argument, index) => argument !== "--json" && (tokenAt === -1 || (index !== tokenAt && index !== tokenAt + 1)));
523
+ const bare = argv.filter((argument, index) => argument !== "--json" && (tokenAt === -1 || (index !== tokenAt && (tokenInline || index !== tokenAt + 1))));
522
524
  const remoteAt = bare.indexOf("--remote");
523
525
  const remoteUrl = remoteAt === -1 ? undefined : bare[remoteAt + 1];
524
526
  const [action, ...paths] = bare.filter((_, i) => remoteAt === -1 || (i !== remoteAt && i !== remoteAt + 1));
@@ -529,7 +531,8 @@ async function runReposCommand(argv, write, onboard) {
529
531
  }
530
532
  const databaseFile = databasePath(process.env, homedir());
531
533
  const file = registryBeside(databaseFile);
532
- if (tokenAt !== -1 && (tokenFlag === undefined || tokenFlag.startsWith("-"))) {
534
+ // A dash-led token is the token, unless it is exactly one of this command's flags (never swallowed).
535
+ if (tokenAt !== -1 && (tokenFlag === undefined || tokenFlag === "" || (!tokenInline && ["--json", "--remote", "--token"].includes(tokenFlag)))) {
533
536
  const message = "--token needs the lead token";
534
537
  write(json ? envelopeJson({ ok: false, command: `repos ${action ?? ""}`.trim(), reason: "usage", message }) : message);
535
538
  return USAGE_EXIT;
@@ -17,6 +17,7 @@ type Handoff = {
17
17
  scopeDigest: string;
18
18
  receipt: CodingHandoffReceipt;
19
19
  };
20
+ export declare function branchHead(repo: string, branch: string): string | null;
20
21
  export declare function codingHandoffPreview(store: Store, input: {
21
22
  sessionId: string;
22
23
  actor: string;
@@ -1,6 +1,5 @@
1
1
  /** A committed native session enters the existing review flow, never another executor. */
2
- import { checkoutGitSync } from './teardown-git.js';
3
- import { execFileSync } from 'node:child_process';
2
+ import { checkoutGitSync, noLazyFetch, viewedGitSync } from './teardown-git.js';
4
3
  import { lstatSync, realpathSync } from 'node:fs';
5
4
  import { sqliteRuntime } from './sqlite-runtime.js';
6
5
  import { codingCatalogPath } from './coding-update.js';
@@ -57,21 +56,28 @@ function catalog(store, readOnly) {
57
56
  // checkout git, which the code page's GET preview also uses (docs/plans/get-side-effects.md).
58
57
  function git(repo, args) {
59
58
  try {
60
- return checkoutGitSync(repo, ['--no-replace-objects', '--no-lazy-fetch', ...args]).trimEnd();
59
+ return checkoutGitSync(repo, ['--no-replace-objects', ...noLazyFetch(), ...args]).trimEnd();
61
60
  }
62
61
  catch {
63
62
  throw Error('The committed coding result could not be verified in Git. Check the project and try again.');
64
63
  }
65
64
  }
66
- function branchHead(repo, branch) {
65
+ // The project's repository is shared with every build (its config, includes): read through the frozen, bounded view
66
+ // (eval-harness review 2, finding 6), and never inside a database transaction.
67
+ export function branchHead(repo, branch) {
68
+ let ran;
67
69
  try {
68
- return execFileSync('git', ['--no-optional-locks', '--no-replace-objects', '-C', repo, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
70
+ ran = viewedGitSync(repo, ['--no-replace-objects', ...noLazyFetch(), 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], { timeoutMs: 30_000 });
69
71
  }
70
- catch (error) {
71
- if (error.status === 1)
72
- return null;
72
+ catch {
73
73
  throw Error('The review branch could not be inspected.');
74
74
  }
75
+ if (ran.status === 1)
76
+ return null;
77
+ const head = ran.stdout.trim();
78
+ if (ran.status !== 0 || !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(head))
79
+ throw Error('The review branch could not be inspected.');
80
+ return head;
75
81
  }
76
82
  function actorGeneration(store, actor, repo) {
77
83
  const account = store.accountOf(actor);
@@ -200,6 +206,8 @@ export function createCodingHandoff(store, input) {
200
206
  const receipt = makeReceipt({ ...shown, version: 1, identity: learningIdentity(input.repo), actor: input.actor,
201
207
  generation: actorGeneration(store, input.actor, input.repo), title: input.title.trim(), goal: input.goal.trim(), acceptance: parsed.criteria, outOfScope: EXCLUSIONS });
202
208
  const payload = JSON.stringify(receipt), sha = learningSha(payload);
209
+ // Git is read before the reservation's transaction, never inside it (review 2, finding 6).
210
+ const before = branchHead(input.repo, receipt.branch);
203
211
  db.exec('BEGIN IMMEDIATE');
204
212
  let reserved = false;
205
213
  try {
@@ -210,7 +218,7 @@ export function createCodingHandoff(store, input) {
210
218
  reserved = true;
211
219
  }
212
220
  else {
213
- if (store.lookupRef(receipt.taskId) || branchHead(input.repo, receipt.branch) !== null)
221
+ if (store.lookupRef(receipt.taskId) || before !== null)
214
222
  throw Error('The review task or branch already exists. It has been preserved.');
215
223
  db.prepare('INSERT INTO coding_handoff VALUES(?,?,?,?)').run(receipt.id, payload, sha, new Date().toISOString());
216
224
  }
@@ -239,11 +247,12 @@ export function createCodingHandoff(store, input) {
239
247
  // No checked-out/leased branch is adopted, even if its HEAD happens to match.
240
248
  if (git(input.repo, ['worktree', 'list', '--porcelain']).split('\n').includes(`branch refs/heads/${receipt.branch}`))
241
249
  throw Error('The review branch is already checked out. Its work has been preserved.');
250
+ // The branch, read last before filing and outside the transaction: a database transaction never holds a git ref still.
251
+ if (branchHead(input.repo, receipt.branch) !== receipt.base)
252
+ throw Error('The review branch moved before filing. Its work has been preserved.');
242
253
  return store.transact(() => {
243
254
  if (actorGeneration(store, input.actor, input.repo) !== receipt.generation)
244
255
  throw Error('Your account changed. Sign in again.');
245
- if (branchHead(input.repo, receipt.branch) !== receipt.base)
246
- throw Error('The review branch moved before filing. Its work has been preserved.');
247
256
  const made = fileTaskProposal(store, { id: receipt.taskId, title: receipt.title, repo: receipt.repo, goal: receipt.goal,
248
257
  outOfScope: receipt.outOfScope, acceptance: receipt.acceptance, filedVia: PREFIX + receipt.id, filedBy: { name: input.actor, kind: "person" }, planning: 'skip', admittedRepos: [receipt.repo] }, new Date());
249
258
  if (!made.ok)
@@ -400,8 +400,10 @@ export class CodingWorkspace {
400
400
  }, 50);
401
401
  }
402
402
  }
403
- async git(repo, args) {
404
- const result = await run('git', ['--no-optional-locks', '-c', 'core.hooksPath=/dev/null', ...args], { cwd: repo, timeoutMs: 60_000, maxBuffer: 1_048_576 });
403
+ async git(repo, args, project = repo) {
404
+ // The project's repository and the session's checkout are agent-written: the hardened git, against a frozen view of
405
+ // the repository's config and info (teardown-git.ts checkoutGit).
406
+ const result = await checkoutGit(run, 'build', { repo: project })('git', ['--no-optional-locks', ...args], { cwd: repo, timeoutMs: 60_000, maxBuffer: 1_048_576 });
405
407
  if (result.code !== 0)
406
408
  throw Error('Git could not complete this workspace operation. Check the repository and try again.');
407
409
  return result.stdout;
@@ -520,13 +522,13 @@ export class CodingWorkspace {
520
522
  }
521
523
  async verifyWorktree(session) {
522
524
  const [branch, common, original] = await Promise.all([
523
- this.git(session.worktree, ['symbolic-ref', '--short', 'HEAD']),
524
- this.git(session.worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir']),
525
+ this.git(session.worktree, ['symbolic-ref', '--short', 'HEAD'], session.repo),
526
+ this.git(session.worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir'], session.repo),
525
527
  this.git(session.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir']),
526
528
  ]);
527
529
  if (branch.trim() !== session.branch || realpathSync(common.trim()) !== realpathSync(original.trim()))
528
530
  throw Error('This checkout no longer matches the saved project and branch. Restore that checkout before resuming.');
529
- await this.git(session.worktree, ['merge-base', '--is-ancestor', session.base, 'HEAD']);
531
+ await this.git(session.worktree, ['merge-base', '--is-ancestor', session.base, 'HEAD'], session.repo);
530
532
  }
531
533
  priorProcessesGone() {
532
534
  const row = this.db.prepare('SELECT payload FROM coding_custody WHERE singleton=1').get();
@@ -25,6 +25,12 @@ type TreeEntry = {
25
25
  };
26
26
  /** A tree object's entries, parsed from its verified bytes: `<mode> <name>\0<raw id>` each. Null when malformed. */
27
27
  export declare function parseTree(bytes: Buffer, idBytes: number): TreeEntry[] | null;
28
+ /**
29
+ * Why a commit's file paths can't all be written as separate regular files, or null: a `.git` part, a path twice, two
30
+ * paths that are one on a case-insensitive or Unicode-normalizing file system (macOS), or a path that is both a file
31
+ * and a folder. scripts/deploy-git.mjs writtenPathsProblem keeps the same rules (held equal by test).
32
+ */
33
+ export declare function writtenPathsProblem(paths: readonly string[]): string | null;
28
34
  /** Write commit `sha` of the repository whose Git directory is `gitDir` (a realpath Toolroll took from its own
29
35
  * records) into `dest`, an empty folder this process made. Every object on the way — the commit, each tree, each
30
36
  * blob — is hashed here and must be the id that names it: Git's own answer about an id is never taken on trust, so a
@@ -10,12 +10,10 @@
10
10
  * files are written (mode 644, or 755 when the tree says executable), each at a normalized path inside the folder;
11
11
  * symlinks, submodules and any other entry refuse the whole extraction.
12
12
  */
13
- import { spawnSync } from "node:child_process";
14
13
  import { createHash } from "node:crypto";
15
14
  import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
16
- import { join, sep } from "node:path";
17
- import { checkoutGitEnv, HARDENED_GIT_ARGS } from "./agent-fence.js";
18
- import { commandSetting } from "./teardown-git.js";
15
+ import { basename, dirname, join, sep } from "node:path";
16
+ import { commandSetting, viewedGitSync } from "./teardown-git.js";
19
17
  const MAX_BYTES = 1024 * 1024 * 1024;
20
18
  export const EXTRACTION_LIMITS = Object.freeze({ bytes: MAX_BYTES, files: 50_000, entries: 200_000, depth: 64 });
21
19
  /** A tree path that stays where it says: relative, no empty, `.` or `..` part, no backslash or control character. */
@@ -39,16 +37,45 @@ export function parseTree(bytes, idBytes) {
39
37
  }
40
38
  return entries;
41
39
  }
40
+ /**
41
+ * Why a commit's file paths can't all be written as separate regular files, or null: a `.git` part, a path twice, two
42
+ * paths that are one on a case-insensitive or Unicode-normalizing file system (macOS), or a path that is both a file
43
+ * and a folder. scripts/deploy-git.mjs writtenPathsProblem keeps the same rules (held equal by test).
44
+ */
45
+ export function writtenPathsProblem(paths) {
46
+ const folded = new Map();
47
+ for (const path of paths) {
48
+ if (!normalizedTreePath(path))
49
+ return `${JSON.stringify(path)} isn't a plain relative path`;
50
+ if (path.split("/").some(part => part.toLowerCase() === ".git"))
51
+ return `${path} names a .git folder`;
52
+ const key = path.normalize("NFC").toLowerCase();
53
+ const known = folded.get(key);
54
+ if (known !== undefined)
55
+ return known === path ? `${path} is listed twice` : `${path} and ${known} are the same file on this computer`;
56
+ folded.set(key, path);
57
+ }
58
+ for (const key of folded.keys()) {
59
+ const parts = key.split("/");
60
+ for (let at = 1; at < parts.length; at++) {
61
+ const folder = parts.slice(0, at).join("/");
62
+ if (folded.has(folder))
63
+ return `${folded.get(folder)} is both a file and a folder`;
64
+ }
65
+ }
66
+ return null;
67
+ }
42
68
  /** Write commit `sha` of the repository whose Git directory is `gitDir` (a realpath Toolroll took from its own
43
69
  * records) into `dest`, an empty folder this process made. Every object on the way — the commit, each tree, each
44
70
  * blob — is hashed here and must be the id that names it: Git's own answer about an id is never taken on trust, so a
45
71
  * corrupted or replaced object in a store a build could write to refuses the extraction. */
46
72
  export function extractCommit(gitDir, sha, dest, limits = EXTRACTION_LIMITS) {
47
73
  const refuse = (message) => ({ ok: false, message });
48
- const env = { ...checkoutGitEnv(), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_ATTR_NOSYSTEM: "1" };
49
- const git = (args, input) => spawnSync("git", ["--git-dir", gitDir, "--no-replace-objects", ...HARDENED_GIT_ARGS, ...args], { env, input, maxBuffer: MAX_BYTES, timeout: 300_000 });
74
+ const git = protectedGit(gitDir, {}, []);
50
75
  // The repository's own config, read without includes, must name no command and include nothing.
51
76
  const config = git(["config", "--list", "--no-includes", "--show-scope", "-z"]);
77
+ if (config.refused !== null)
78
+ return refuse(`The project's Git directory can't be read safely: ${config.refused}.`);
52
79
  if (config.status !== 0)
53
80
  return refuse("The project's Git config couldn't be read.");
54
81
  const listing = config.stdout.toString("utf8");
@@ -137,6 +164,9 @@ export function extractCommit(gitDir, sha, dest, limits = EXTRACTION_LIMITS) {
137
164
  }
138
165
  if (files.length === 0)
139
166
  return refuse(`The commit ${sha.slice(0, 7)} holds no files.`);
167
+ const clash = writtenPathsProblem(files.map(one => one.path));
168
+ if (clash !== null)
169
+ return refuse(`The commit's files can't be written: ${clash}.`);
140
170
  const blobs = read([...new Set(files.map(one => one.id))]);
141
171
  if (typeof blobs === "string")
142
172
  return refuse(`The approved commit couldn't be verified: ${blobs}.`);
@@ -166,12 +196,35 @@ export function extractCommit(gitDir, sha, dest, limits = EXTRACTION_LIMITS) {
166
196
  }
167
197
  return { ok: true, files: files.length };
168
198
  }
199
+ /**
200
+ * How the protected readers run git on the project's Git directory (eval-harness review 1, finding 5): never a fetch.
201
+ * Every command, on every git version, runs through a frozen view of the repository (teardown-git.ts viewedGitSync,
202
+ * findings 5 and 7): its config read once into a folder of the plane's own and inspected there — an include, a key that
203
+ * names a command, every `protocol.*` key and (on a git without `--no-lazy-fetch`) a partial clone refused — which is
204
+ * the config git then runs with, bounded in time. Every run also denies every transport (`protocol.allow=never`) and,
205
+ * on git 2.45+, takes `--no-lazy-fetch`; nothing leans on GIT_NO_LAZY_FETCH, which 2.41.0 ignores.
206
+ */
207
+ function protectedGit(gitDir, runner, extra) {
208
+ const base = ["--no-replace-objects", "-c", "protocol.allow=never", ...extra];
209
+ // The view is of a checkout: the project's own `.git` folder, as Toolroll records it.
210
+ const checkout = basename(gitDir) === ".git" ? dirname(gitDir) : null;
211
+ return (args, input) => {
212
+ if (checkout === null)
213
+ return { status: null, stdout: Buffer.alloc(0), refused: "it isn't a checkout's .git folder, which this git can only read through a view" };
214
+ try {
215
+ const ran = viewedGitSync(checkout, [...base, ...args], { buffer: true, timeoutMs: 300_000, maxBuffer: MAX_BYTES, ...(input === undefined ? {} : { input }), ...(runner.git === undefined ? {} : { git: runner.git }) });
216
+ return { status: ran.status, stdout: ran.stdout, refused: null };
217
+ }
218
+ catch (error) {
219
+ return { status: null, stdout: Buffer.alloc(0), refused: error instanceof Error ? error.message : "it couldn't be read" };
220
+ }
221
+ };
222
+ }
169
223
  /** A reader over the project's object store that hashes every object it hands back, or why it can't be one. */
170
224
  function verifiedReader(gitDir, runner = {}) {
171
225
  const refuse = (message) => ({ ok: false, message });
172
- const env = { ...(runner.env ?? checkoutGitEnv()), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_ATTR_NOSYSTEM: "1" };
173
226
  // The commit-graph file is a cache a build can write: parents come from the objects themselves, never from it.
174
- const git = (args, input) => spawnSync(runner.git ?? "git", ["--git-dir", gitDir, "--no-replace-objects", ...HARDENED_GIT_ARGS, "-c", "core.commitGraph=false", ...args], { cwd: "/", env, input, maxBuffer: MAX_BYTES, timeout: 300_000 });
227
+ const git = protectedGit(gitDir, runner, ["-c", "core.commitGraph=false"]);
175
228
  // History a build could have rewritten around the objects: a shallow boundary, grafts, or replacement refs. Each is
176
229
  // refused outright, so no reader here ever has to decide whether to believe one.
177
230
  if (existsSync(join(gitDir, "shallow")))
@@ -179,6 +232,8 @@ function verifiedReader(gitDir, runner = {}) {
179
232
  if (existsSync(join(gitDir, "info", "grafts")))
180
233
  return refuse("The project's Git directory has grafts, which rewrite history; they are refused.");
181
234
  const replaced = git(["for-each-ref", "--format=%(refname)", "refs/replace/"]);
235
+ if (replaced.refused !== null)
236
+ return refuse(`The project's Git directory can't be read safely: ${replaced.refused}.`);
182
237
  if (replaced.status !== 0)
183
238
  return refuse("The project's refs couldn't be read.");
184
239
  if (replaced.stdout.toString("utf8").trim() !== "")
@@ -0,0 +1,41 @@
1
+ import { type BrowserProjectState } from "./browser-workspace.js";
2
+ import type { Store } from "./store.js";
3
+ import { type WorkIndexOptions } from "./work-index.js";
4
+ import type { WorkSummaryAccess } from "./work-summary.js";
5
+ import type { WorkspaceRevision } from "./workspace-revision.js";
6
+ export type SidebarProject = {
7
+ path: string;
8
+ name: string;
9
+ };
10
+ /** The sidebar's projects with their states (agent-state.ts) for one reader: the page's first read and the summary
11
+ * room's snapshots are this one function, so the two never disagree. Never marks anything seen. Work with no project
12
+ * yet is its own entry when this reader may see it and something counts there. */
13
+ export declare function projectSummaryOf(store: Store, now: Date, access: WorkSummaryAccess, actor: string, projects: readonly SidebarProject[], evidenceRoot: string | undefined): BrowserProjectState[];
14
+ /** One access, canonically and in full (every field it has, so a field added later keys too): the same reader and
15
+ * scope always spell the same key, whatever order its projects came in. */
16
+ export declare function accessKey(access: WorkSummaryAccess): string;
17
+ /**
18
+ * Values read from the database, each kept while the workspace revision (workspace-revision.ts: every meaningful
19
+ * commit, from any process, moves it) is the one it was read at, and never past the revision's own clock boundary
20
+ * (a lease or liveness window ending) or `maxAgeMs`, whichever is first — time alone (an agent going quiet) shows
21
+ * within that bound. A value is stored under the revision read before it was computed, so a commit that lands while it
22
+ * is computed only makes it miss sooner. Callers must not change a value they were handed.
23
+ */
24
+ export declare class RevisionCache {
25
+ #private;
26
+ constructor(revision: WorkspaceRevision, clock: () => Date, options?: {
27
+ maxEntries?: number;
28
+ maxAgeMs?: number;
29
+ });
30
+ /** The value for `key` at the current revision, or undefined. */
31
+ peek<T>(key: string): T | undefined;
32
+ /** The cached value, or `compute()` now (stored for the next reader). Errors are never cached. */
33
+ read<T>(key: string, compute: () => T): T;
34
+ /** The cached value, or one read asynchronously (a read worker) and stored. A failure answers undefined and leaves
35
+ * the key empty, so the request thread computes it as before. */
36
+ fill<T>(key: string, compute: () => Promise<T>): Promise<T | undefined>;
37
+ }
38
+ /** The keys the console's chrome reads are kept under (serve.ts and its read-worker prefetch spell them the same). */
39
+ export declare const countsKey: (access: WorkSummaryAccess) => string;
40
+ export declare const summaryKey: (actor: string, access: WorkSummaryAccess, projects: readonly SidebarProject[]) => string;
41
+ export declare const indexKey: (access: WorkSummaryAccess, options: WorkIndexOptions) => string;