toolroll 0.9.7 → 0.9.9

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 (97) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/dist/assignment-adapters.d.ts +1 -0
  3. package/dist/assignment-adapters.js +2 -0
  4. package/dist/assignment-brief.d.ts +2 -0
  5. package/dist/assignment-brief.js +2 -1
  6. package/dist/assignment-presentation.js +3 -1
  7. package/dist/assignment-status.d.ts +1 -1
  8. package/dist/assignment-ui.js +5 -0
  9. package/dist/assignment.d.ts +15 -0
  10. package/dist/assignment.js +56 -9
  11. package/dist/browser/workspace.css +1 -1
  12. package/dist/browser/workspace.js +18 -18
  13. package/dist/browser-crew.js +4 -0
  14. package/dist/browser-workspace.d.ts +40 -0
  15. package/dist/build-review.d.ts +38 -0
  16. package/dist/build-review.js +341 -0
  17. package/dist/builder.d.ts +2 -0
  18. package/dist/builder.js +114 -8
  19. package/dist/chat-controls.d.ts +1 -1
  20. package/dist/chat-controls.js +1 -1
  21. package/dist/check-levels-ui.d.ts +5 -0
  22. package/dist/check-levels-ui.js +16 -2
  23. package/dist/check-progress.js +4 -3
  24. package/dist/checkout-cleanup.d.ts +40 -2
  25. package/dist/checkout-cleanup.js +129 -9
  26. package/dist/cli.js +2 -0
  27. package/dist/desktop-update.d.ts +52 -0
  28. package/dist/desktop-update.js +147 -5
  29. package/dist/dispatch.js +2 -1
  30. package/dist/dispose.d.ts +4 -3
  31. package/dist/dispose.js +12 -4
  32. package/dist/evidence.d.ts +6 -0
  33. package/dist/evidence.js +8 -0
  34. package/dist/exec.d.ts +5 -0
  35. package/dist/exec.js +30 -4
  36. package/dist/first-run.d.ts +1 -1
  37. package/dist/first-run.js +8 -6
  38. package/dist/invoke.js +5 -0
  39. package/dist/lead-context.d.ts +1 -1
  40. package/dist/lead-context.js +4 -2
  41. package/dist/lead-status.d.ts +10 -1
  42. package/dist/lead-status.js +21 -24
  43. package/dist/modes.d.ts +3 -1
  44. package/dist/modes.js +3 -1
  45. package/dist/needs-you.d.ts +55 -0
  46. package/dist/needs-you.js +83 -0
  47. package/dist/operate.d.ts +2 -0
  48. package/dist/operate.js +231 -36
  49. package/dist/plane-review.js +4 -0
  50. package/dist/process-custody.js +8 -0
  51. package/dist/process-recovery-eligibility.d.ts +1 -0
  52. package/dist/project-learning.d.ts +3 -3
  53. package/dist/project-learning.js +28 -17
  54. package/dist/proof.d.ts +3 -1
  55. package/dist/proof.js +4 -2
  56. package/dist/provider.js +16 -0
  57. package/dist/result-completion.d.ts +4 -0
  58. package/dist/result-completion.js +11 -0
  59. package/dist/retention-ui.d.ts +3 -1
  60. package/dist/retention-ui.js +4 -4
  61. package/dist/retention.d.ts +13 -6
  62. package/dist/retention.js +50 -12
  63. package/dist/review-context.d.ts +3 -0
  64. package/dist/review-context.js +45 -5
  65. package/dist/review-switch.d.ts +41 -0
  66. package/dist/review-switch.js +88 -0
  67. package/dist/reviewer.d.ts +24 -2
  68. package/dist/reviewer.js +49 -1
  69. package/dist/run-closeout.d.ts +89 -0
  70. package/dist/run-closeout.js +302 -0
  71. package/dist/serve.d.ts +15 -1
  72. package/dist/serve.js +158 -18
  73. package/dist/setup-guide.d.ts +2 -2
  74. package/dist/shared-deps.d.ts +94 -0
  75. package/dist/shared-deps.js +365 -0
  76. package/dist/storage.js +2 -0
  77. package/dist/store.d.ts +72 -1
  78. package/dist/store.js +258 -31
  79. package/dist/surface.js +5 -1
  80. package/dist/task-status.d.ts +22 -5
  81. package/dist/task-status.js +45 -24
  82. package/dist/telegram-progress.js +2 -1
  83. package/dist/telegram-status.js +3 -3
  84. package/dist/test-temp.d.ts +19 -0
  85. package/dist/test-temp.js +98 -0
  86. package/dist/toolroll-update-ui.js +7 -2
  87. package/dist/toolroll-update.d.ts +21 -1
  88. package/dist/toolroll-update.js +135 -5
  89. package/dist/work-index.d.ts +11 -0
  90. package/dist/work-index.js +103 -16
  91. package/dist/work-summary.d.ts +3 -1
  92. package/dist/work-summary.js +2 -1
  93. package/dist/workspace-ui.d.ts +3 -1
  94. package/dist/workspace-ui.js +10 -9
  95. package/dist/worktree.d.ts +16 -0
  96. package/dist/worktree.js +47 -0
  97. package/package.json +1 -1
package/dist/builder.js CHANGED
@@ -59,11 +59,23 @@ import { TELEGRAM_TOKEN_ENVS } from "./names.js";
59
59
  import { OPENROUTER_ENV_KEY, auditOf, ALL_CREDENTIAL_ENV } from "./provider.js";
60
60
  import { openLiveLog } from "./live.js";
61
61
  import { CheckProgressTracker } from "./check-progress.js";
62
- import { captureParkEvidence, captureTerminalDiff, captureBaseTree, evidenceRoot, handoffName, storeHandoffArtifact, looksLikeProtocolFile, mailboxName, progressFileName, proofFileName, rubricFileName, proposalFileName, quarantineMailboxes, readMailbox, readVerifiedArtifact, scanForSecrets, redactSecretLines, storeEvidence, validateScreenshotBytes, imageDimensions, SCREENSHOT_BYTE_CAP, boundStreamHeadTail } from "./evidence.js";
62
+ import { captureParkEvidence, captureTerminalDiff, captureBaseTree, evidenceRoot, handoffName, isImagePath, WORKTREE_EVIDENCE_DIR, storeHandoffArtifact, looksLikeProtocolFile, mailboxName, progressFileName, proofFileName, rubricFileName, proposalFileName, quarantineMailboxes, readMailbox, readVerifiedArtifact, scanForSecrets, redactSecretLines, storeEvidence, validateScreenshotBytes, imageDimensions, SCREENSHOT_BYTE_CAP, boundStreamHeadTail } from "./evidence.js";
63
63
  import { PROOF_LIMITS, parseProof, serializeProof, adjudicate, artifactManifestOnly, sameDiffStatFacts } from "./proof.js";
64
64
  import { captureReviewContext } from "./review-context.js";
65
65
  import { authoritySnapshotDigest, classifyRevisionAuthority, isMilestoneRegression, milestonesOf, parseExecutionPlanDocument, parsePlanRevisionProposal, parseProgressSnapshot, renderExecutionPlanDocument, PROGRESS_LIMITS, REVISION_LIMITS, } from "./plan.js";
66
66
  import { maybeSettleRepairChain } from "./dispose.js";
67
+ import { detachShared, linkInto, linkedKey, promoteInstall, readyCopy, sharedDepsRoot, sharingFor } from "./shared-deps.js";
68
+ /** Where this store keeps shared dependencies; null for an in-memory store, which shares nothing. */
69
+ function sharedDepsRootOf(store) {
70
+ const file = store.databaseFile();
71
+ return file === null ? null : sharedDepsRoot(file);
72
+ }
73
+ /** The one line an agent needs when its node_modules is a shared copy: it is read-only, and how to get its own. */
74
+ function sharedDepsBrief(store, worktree) {
75
+ const root = sharedDepsRootOf(store);
76
+ return root === null || linkedKey(worktree, root) === null ? "" :
77
+ "node_modules here links a shared, read-only install. If you change package.json or the lockfile, replace it with your own install first: `rm -rf node_modules && npm install`.\n";
78
+ }
67
79
  /**
68
80
  * The native shell for an operator-approved repository command. There are
69
81
  * exactly two consumers: dependency setup before an agent starts, and the
@@ -668,6 +680,20 @@ export async function build(store, request) {
668
680
  message: "runner custody lapsed before the setup spawn — the lease, the runner, or its repo binding no longer stands",
669
681
  };
670
682
  }
683
+ // Shared dependencies: a checkout whose lockfile already has an installed
684
+ // copy links it instead of installing its own; a setup that is about to
685
+ // run never reaches through an old link into a shared copy.
686
+ const depsRoot = sharedDepsRootOf(store);
687
+ const sharing = depsRoot === null ? null : sharingFor({ repo: leased.repo, worktree, setup: setupWanted });
688
+ if (depsRoot !== null)
689
+ detachShared(worktree, depsRoot);
690
+ if (depsRoot !== null && sharing !== null) {
691
+ const ready = readyCopy(depsRoot, sharing.key);
692
+ if (ready !== null && linkInto(worktree, ready) !== null) {
693
+ store.stampWorktreeSetup(worktree, setupWanted.digest);
694
+ return null;
695
+ }
696
+ }
671
697
  const runSetup = request.setup ?? run;
672
698
  const shell = approvedCommandShell(setupWanted.command);
673
699
  // Setup runs under the stop watch (v52), owned by this run: an
@@ -700,6 +726,9 @@ export async function build(store, request) {
700
726
  message: `the approved setup for ${leased.repo} ${made.timedOut ? `ran past ${Math.round(setupWanted.timeoutMs / 60_000)}m` : `exited ${made.code}`} — ${redactSecretText(firstLine(made.stderr)) || "no stderr"}; no agent spawns in a checkout whose setup failed`,
701
727
  };
702
728
  }
729
+ if (depsRoot !== null && sharing !== null) {
730
+ promoteInstall({ root: depsRoot, repo: leased.repo, worktree, ...sharing, setupDigest: setupWanted.digest, now: (request.clock ?? (() => now))() });
731
+ }
703
732
  store.stampWorktreeSetup(worktree, setupWanted.digest);
704
733
  }
705
734
  return null;
@@ -1128,7 +1157,7 @@ export async function build(store, request) {
1128
1157
  // replacement for.
1129
1158
  milestones.length === 0 || planRevisionHash === null
1130
1159
  ? null
1131
- : { revision: planRevisionNumber, hash: planRevisionHash, milestones, progress, proposal }, scope === null ? [] : flowGoalCuts(store, scope.taskId, scope.goal)) + `\nCanonical signed rubric: ${rubric}. Its statement fields are exact; evidence requirements are separate fields. Do not edit this input. The lead or user reads these criteria directly; no restatement is needed.\n`;
1160
+ : { revision: planRevisionNumber, hash: planRevisionHash, milestones, progress, proposal }, scope === null ? [] : flowGoalCuts(store, scope.taskId, scope.goal)) + `\nCanonical signed rubric: ${rubric}. Its statement fields are exact; evidence requirements are separate fields. Do not edit this input. The lead or user reads these criteria directly; no restatement is needed.\n` + sharedDepsBrief(store, worktree);
1132
1161
  // THE HELD BRANCH (Phase 2, v2 S0d + v6 W8): ownership transfers to the
1133
1162
  // coordinator at the spawn point. Everything build() armed that its
1134
1163
  // finally would have cleared is torn down or handed over HERE — the pulse
@@ -1786,7 +1815,7 @@ export async function settleProviderOutcome(captured, result) {
1786
1815
  // result is that commit, sealed below against the task's original base.
1787
1816
  const made = finishesKeptWork
1788
1817
  ? await keptWorkResult(captured, baseRevision, handoff.conclusion)
1789
- : await commit(git, worktree, branch, taskId, scope, handoff.conclusion, request.attended === undefined ? scope?.candidate ?? null : null);
1818
+ : await commit(git, worktree, branch, taskId, scope, handoff.conclusion, request.attended === undefined ? scope?.candidate ?? null : null, words => store.addRunNote(request.runId, "Toolroll", words, clock()));
1790
1819
  if (made.ok && made.parked === undefined && made.committed) {
1791
1820
  const newHead = await git(GIT, ["--no-optional-locks", "rev-parse", "HEAD"], { cwd: worktree });
1792
1821
  if (newHead.code === 0) {
@@ -2221,8 +2250,48 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2221
2250
  checkLog.push(attemptLog(label, configured.command, result));
2222
2251
  return result;
2223
2252
  };
2224
- const first = await runVerification("Project check · attempt 1");
2225
- if (first.notFound || first.timedOut) {
2253
+ const ownInstallIfChanged = async () => {
2254
+ const depsRoot = sharedDepsRootOf(store);
2255
+ if (depsRoot === null || repo === null)
2256
+ return null;
2257
+ const linked = linkedKey(worktree, depsRoot);
2258
+ const live = linked === null ? null : store.liveWorktreeSetup(repo);
2259
+ if (linked === null || live === null)
2260
+ return null;
2261
+ if (sharingFor({ repo, worktree, setup: live })?.key === linked && readyCopy(depsRoot, linked) !== null)
2262
+ return null;
2263
+ if (!store.proveRunnerCustodyForSpawn(runId, now()))
2264
+ return "custody-lost";
2265
+ detachShared(worktree, depsRoot);
2266
+ const label = "Own install · approved project setup (package.json or lockfile changed)";
2267
+ const setupShell = approvedCommandShell(live.command);
2268
+ const installed = await underStopWatch(store, runId, () => runWithIsolatedDatabase(witnessedRunner(store, runId, now, request.setup ?? run), setupShell.file, setupShell.args, {
2269
+ cwd: worktree,
2270
+ timeoutMs: live.timeoutMs,
2271
+ processGroup: true,
2272
+ owner: runOwnerTag(store, runId),
2273
+ beforeSpawn: () => !stopRequestedFor(store, runId, request.shouldStop),
2274
+ onSpawn: pid => {
2275
+ request.onProviderSpawn?.(pid);
2276
+ if (stopRequestedFor(store, runId, request.shouldStop))
2277
+ throw new Error("the attempt was stopped before spawn custody completed");
2278
+ },
2279
+ envAllowlist: SETUP_ENV_ALLOWLIST,
2280
+ omitEnv: SETUP_ENV_DENYLIST,
2281
+ }));
2282
+ checkOutcomes.push(attemptOutcome(label, installed));
2283
+ noteCheckSuite(label, installed);
2284
+ checkLog.push(attemptLog(label, live.command, installed));
2285
+ if (installed.notFound || installed.timedOut || installed.code !== 0)
2286
+ return "own-install-failed";
2287
+ return await sealedTreeState(request.git ?? run) === "clean" ? null : "setup-changed-files";
2288
+ };
2289
+ const ownInstall = await ownInstallIfChanged();
2290
+ const first = ownInstall === null ? await runVerification("Project check · attempt 1") : null;
2291
+ if (first === null) {
2292
+ verifyCommand = { configured: true, ran: false, attemptFailed: true, failure: ownInstall ?? "own-install-failed" };
2293
+ }
2294
+ else if (first.notFound || first.timedOut) {
2226
2295
  verifyCommand = { configured: true, ran: false, attemptFailed: true, failure: first.timedOut ? "timed-out" : "spawn-failed" };
2227
2296
  }
2228
2297
  else if (!verificationExecutableMissing(first)) {
@@ -2419,7 +2488,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2419
2488
  now,
2420
2489
  });
2421
2490
  if (captureResult !== null) {
2422
- reviewContext = captureResult.inventory.coverage.map(one => ({ id: one.id, state: one.state, inherited: one.inherited, items: one.items, gaps: one.gaps, priorSupport: one.priorSupport }));
2491
+ reviewContext = captureResult.inventory.coverage.map(one => ({ id: one.id, state: one.state, inherited: one.inherited, items: one.items, gaps: one.gaps, priorSupport: one.priorSupport, ...(one.assets === undefined ? {} : { assets: one.assets }) }));
2423
2492
  }
2424
2493
  }
2425
2494
  catch {
@@ -3230,9 +3299,12 @@ flowCuts = []) {
3230
3299
  " changed-file inventory or final check results. This applies to no-change",
3231
3300
  " results too. Put useful caveats in the short handoff; do not invent evidence.",
3232
3301
  " Run focused checks for your edits. The machine runs the approved full check.",
3302
+ " Save screenshots and journey output only under evidence/ (the run's",
3303
+ " evidence folder). They are never committed: images added there are left",
3304
+ " out of the commit, so do not save them anywhere else in the repository.",
3233
3305
  " If screenshots are required, capture the actual candidate and list them in",
3234
3306
  ` ${proof}: { "version": 1, "screenshots": [`,
3235
- ' { "path": "<repository-relative PNG or JPEG>", "caption": "<what it shows>" } ] }.',
3307
+ ' { "path": "<evidence/… PNG or JPEG>", "caption": "<what it shows>" } ] }.',
3236
3308
  ` List at most ${PROOF_LIMITS.screenshots} screenshots, with paths/captions under ${PROOF_LIMITS.evidenceRef} UTF-8 bytes,`,
3237
3309
  " on one line each; no absolute paths or dot segments. Images must be real,",
3238
3310
  " at least 320 by 200 pixels. A list is optional; missing required images",
@@ -3355,7 +3427,7 @@ function fence(text) {
3355
3427
  * code and concluded the task needed no change has done its job, and turning
3356
3428
  * that into a failure would teach the loop to prefer writing something.
3357
3429
  */
3358
- async function commit(git, worktree, branch, taskId, scope, summary, preparedCandidate = null) {
3430
+ async function commit(git, worktree, branch, taskId, scope, summary, preparedCandidate = null, notice) {
3359
3431
  const status = await git(GIT, ["--no-optional-locks", "status", "--porcelain"], { cwd: worktree });
3360
3432
  if (status.code !== 0) {
3361
3433
  return { ok: false, reason: "commit-failure", message: firstLine(status.stderr) };
@@ -3375,6 +3447,20 @@ async function commit(git, worktree, branch, taskId, scope, summary, preparedCan
3375
3447
  const add = await git(GIT, ["add", "-A", "--", ".", `:!${LEASE_MARKER}`, ":!STANDING-ORDERS-*", ":!NIGHTORDERS-*"], { cwd: worktree });
3376
3448
  if (add.code !== 0)
3377
3449
  return { ok: false, reason: "commit-failure", message: firstLine(add.stderr) };
3450
+ // Screenshots belong to the run's evidence, not the branch. Images added
3451
+ // under evidence/ are unstaged and left on disk, where the proof can still
3452
+ // name them; an exact prepared candidate is compared whole instead.
3453
+ if (preparedCandidate === null) {
3454
+ const stripped = await stripEvidenceImages(git, worktree);
3455
+ if (!stripped.ok)
3456
+ return { ok: false, reason: "commit-failure", message: stripped.message };
3457
+ if (stripped.notice !== null) {
3458
+ notice?.(stripped.notice);
3459
+ summary = `${summary}\n\n${stripped.notice}`;
3460
+ if (!stripped.staged)
3461
+ return { ok: true, committed: false, branch, summary };
3462
+ }
3463
+ }
3378
3464
  // Setup can create untracked files as well as edit tracked manifests.
3379
3465
  // Re-prove the whole staged tree after the normal path exclusions, before
3380
3466
  // committing, so neither can widen an exact prepared candidate.
@@ -3409,6 +3495,26 @@ async function commit(git, worktree, branch, taskId, scope, summary, preparedCan
3409
3495
  }
3410
3496
  return { ok: true, committed: true, branch, summary };
3411
3497
  }
3498
+ /** The plain words for images left out of a commit. */
3499
+ export function evidenceImagesNotice(paths) {
3500
+ return `Left ${paths.length} image${paths.length === 1 ? "" : "s"} under ${WORKTREE_EVIDENCE_DIR} out of the commit: screenshots belong in the run's evidence, not the repository.`;
3501
+ }
3502
+ /** Unstage images newly added under evidence/; the files stay on disk. */
3503
+ async function stripEvidenceImages(git, worktree) {
3504
+ const added = await git(GIT, ["--no-optional-locks", "diff", "--cached", "--name-only", "-z", "--no-renames", "--diff-filter=A", "--", WORKTREE_EVIDENCE_DIR], { cwd: worktree });
3505
+ if (added.code !== 0)
3506
+ return { ok: false, message: firstLine(added.stderr) };
3507
+ const images = added.stdout.split("\0").filter(path => path !== "" && isImagePath(path));
3508
+ if (images.length === 0)
3509
+ return { ok: true, notice: null, staged: true };
3510
+ for (let at = 0; at < images.length; at += 200) {
3511
+ const removed = await git(GIT, ["rm", "--cached", "--quiet", "--", ...images.slice(at, at + 200).map(path => `:(literal)${path}`)], { cwd: worktree });
3512
+ if (removed.code !== 0)
3513
+ return { ok: false, message: firstLine(removed.stderr) };
3514
+ }
3515
+ const rest = await git(GIT, ["--no-optional-locks", "diff", "--cached", "--quiet"], { cwd: worktree });
3516
+ return { ok: true, notice: evidenceImagesNotice(images), staged: rest.code !== 0 };
3517
+ }
3412
3518
  /**
3413
3519
  * `claude --output-format json` returns an envelope: the result is the
3414
3520
  * summary, and the session id is what lets a malformed park be repaired by
@@ -11,7 +11,7 @@ export declare const CHAT_CONTROLS: {
11
11
  readonly target: "task";
12
12
  };
13
13
  readonly planning: {
14
- readonly label: "Review plan";
14
+ readonly label: "Approve plan";
15
15
  readonly target: "task";
16
16
  };
17
17
  readonly recovery: {
@@ -4,7 +4,7 @@
4
4
  export const CHAT_CONTROLS = {
5
5
  task: { label: "Open task", target: "task" },
6
6
  approval: { label: "Review & start", target: "task" },
7
- planning: { label: "Review plan", target: "task" },
7
+ planning: { label: "Approve plan", target: "task" },
8
8
  recovery: { label: "Review recovery options", target: "task" },
9
9
  cancel: { label: "Cancel task", target: "task" },
10
10
  result: { label: "Open result", target: "task" },
@@ -14,6 +14,11 @@ export type CheckSettingsView = {
14
14
  quick: VerifyCommand | null;
15
15
  /** A starting point for the quick command, from the project's scripts. */
16
16
  suggestion: string | null;
17
+ /** The project's automatic review switch (review-switch.ts), beside its checks. */
18
+ review?: {
19
+ on: boolean;
20
+ source: "project" | "hands-off" | "default";
21
+ };
17
22
  said: string | null;
18
23
  problem: string | null;
19
24
  };
@@ -19,7 +19,7 @@ export function checkSettingsHtml(view) {
19
19
  (fallsBack ? `<p class="meta">No quick command yet, so builds run the full check.</p>` : "") +
20
20
  (view.level !== "full" && view.full !== null ? `<p class="meta">The full check still runs when a pull request opens, and Merge waits for it.</p>` : "");
21
21
  if (!view.canChange) {
22
- return head + `<section class="card check-settings">${current}</section>` +
22
+ return head + `<section class="card check-settings">${current}</section>` + reviewHtml(view, "") +
23
23
  `<section class="card check-settings"><h2>Quick check</h2>${view.quick === null ? `<p class="meta">None yet.</p>` : command(view.quick)}</section>` +
24
24
  `<section class="card check-settings"><h2>Full check</h2>${view.full === null ? `<p class="meta">None yet.</p>` : command(view.full)}</section>`;
25
25
  }
@@ -45,7 +45,21 @@ export function checkSettingsHtml(view) {
45
45
  `<p class="meta">Quick builds run the full check instead.</p>${password}<button type="submit" class="danger">Remove</button></form></details></section>`;
46
46
  const full = `<details class="settings-more"><summary>Full check</summary><section class="card check-settings">` +
47
47
  (view.full === null ? `<p class="meta">None yet. Approve one with <span class="mono">toolroll verify set --repo … --command "…"</span>.</p>` : command(view.full)) + `</section></details>`;
48
- return head + levelForm + quick + full;
48
+ return head + levelForm + reviewHtml(view, password) + quick + full;
49
+ }
50
+ /** Automatic review: one state, one sentence, and its one change behind a disclosure (the password). */
51
+ function reviewHtml(view, password) {
52
+ const review = view.review;
53
+ if (review === undefined)
54
+ return "";
55
+ const state = review.on ? (review.source === "hands-off" ? "On while hands-off lasts" : "On") : "Off";
56
+ const act = review.on ? "Turn off" : "Turn on";
57
+ const change = password === "" ? ""
58
+ : `<details class="settings-more"><summary>${act}</summary><form method="post" action="/settings/checks" class="check-settings">` +
59
+ `<input type="hidden" name="csrf" value="${escape(view.csrf)}"><input type="hidden" name="repo" value="${escape(view.repo)}"><input type="hidden" name="act" value="review">` +
60
+ `<input type="hidden" name="on" value="${review.on ? "0" : "1"}">${password}<button type="submit">${act}</button></form></details>`;
61
+ return `<section class="card check-settings" data-review-switch="${review.on ? "on" : "off"}"><h2>Automatic review</h2>` +
62
+ `<p><strong>${state}</strong> · One read-only review after each build passes its checks. Only high findings send it back, once.</p>${change}</section>`;
49
63
  }
50
64
  export const CHECK_SETTINGS_CSS = `
51
65
  .check-settings { display: grid; gap: .6rem; max-width: 40rem; }
@@ -153,9 +153,10 @@ export class CheckProgressTracker {
153
153
  }
154
154
  return;
155
155
  }
156
- // scripts/e2e-parallel.mjs: "Running 7 groups at once: console, task, …", then every group's
157
- // lines as "[group] [app] …", then "✅ group 3.1 min" per group and "N of M groups passed …".
158
- const starting = /^Running ([0-9]+) groups at once: (.+)$/.exec(line);
156
+ // scripts/e2e-parallel.mjs: "Running 7 groups, at most 4 at once (12.0 GB available, about 400 MB each): console,
157
+ // task, …" (before: "Running 7 groups at once: …"), then every group's lines as "[group] [app] …", then
158
+ // "✅ group 3.1 min" per group and "N of M groups passed …".
159
+ const starting = /^Running ([0-9]+) groups(?:, at most [0-9]+)? at once(?: \([^)]*\))?: (.+)$/.exec(line);
159
160
  if (starting !== null) {
160
161
  const total = safeCount(starting[1]);
161
162
  const names = starting[2].split(",").map(one => one.trim()).filter(one => one !== "");
@@ -1,5 +1,5 @@
1
1
  import { type CheckoutCleanup } from "./storage.js";
2
- import type { Store } from "./store.js";
2
+ import type { Store, WorktreeRow } from "./store.js";
3
3
  import { type WorktreePool } from "./worktree.js";
4
4
  /** A release candidate's checkout holds the build a deploy installs: kept a week after its task was done. */
5
5
  export declare const CANDIDATE_KEEP_MS: number;
@@ -36,12 +36,33 @@ type Status = {
36
36
  taskId: string;
37
37
  finishedAt: string | null;
38
38
  dueAt?: string;
39
+ atOnce?: boolean;
39
40
  };
41
+ /** The release a deploy installed last (staged-upgrades/<stage>/deployment.json at phase "deployed"): its commit and
42
+ * builder run. A deploy runs from the gate's checkout, so that checkout stays while it is what runs. */
43
+ export type Deployed = {
44
+ head: string | null;
45
+ run: number | null;
46
+ };
47
+ export declare function deployedRelease(stateDir: string | null): Deployed | null;
48
+ /**
49
+ * Which release a gate belongs to: a failed gate is re-filed as the same name plus a letter (`release-x`, then
50
+ * `release-xb`, `release-xc` …), so a gate whose name is another gate's in the same project plus one letter is that
51
+ * release's.
52
+ */
53
+ export declare function releaseOf(id: string, gates: ReadonlySet<string>): string;
40
54
  /** Where every task stands for cleanup, by task_ref id and by each branch it or its runs used. */
41
- export declare function taskStatuses(store: Store, now: Date): {
55
+ export declare function taskStatuses(store: Store, now: Date, deployed?: Deployed | null): {
42
56
  byRef: Map<number, Status>;
43
57
  byBranch: Map<string, Status>;
44
58
  };
59
+ /** The let-go checkouts whose task is complete or cancelled, and when it finished. Not a release candidate's that
60
+ * stays: a deploy runs from it. */
61
+ export declare function finishedCheckouts(store: Store, now: Date, repo?: string): {
62
+ row: WorktreeRow;
63
+ taskId: string | null;
64
+ finishedAt: string;
65
+ }[];
45
66
  /**
46
67
  * What a clean-up would remove now, how much space it would free, and what stays and why. `manual` is a clean-up by
47
68
  * hand (it doesn't wait for the setting); otherwise it is the worker's pass under the setting. Reads only.
@@ -71,6 +92,23 @@ export declare function cleanCheckouts(store: Store, pool: WorktreePool, now: ()
71
92
  repo?: string;
72
93
  only?: ReadonlySet<string>;
73
94
  }): Promise<CleanResult>;
95
+ export type SlimResult = {
96
+ slimmed: {
97
+ path: string;
98
+ taskId: string | null;
99
+ dropped: string[];
100
+ bytes: number;
101
+ }[];
102
+ };
103
+ /**
104
+ * A kept checkout (its result waiting for review, or with changes) drops its dependencies and build output once its
105
+ * run has ended; the next run's setup restores them (node_modules only goes where the project has a setup to restore
106
+ * it). Never one in use or a release candidate (a deploy installs from it). Each in the ledger.
107
+ */
108
+ export declare function slimKeptCheckouts(store: Store, pool: WorktreePool, now: () => Date, options: {
109
+ actor: string;
110
+ repo?: string;
111
+ }): Promise<SlimResult>;
74
112
  /** Throw away a checkout kept for its changes, on purpose; its branch stays. In the ledger. */
75
113
  export declare function discardCheckout(store: Store, pool: WorktreePool, path: string, now: Date, actor: string): Promise<{
76
114
  ok: true;
@@ -6,13 +6,18 @@
6
6
  * Only the working copy goes: its branch, and so every commit, stays, and a later lease of the branch makes a new one.
7
7
  *
8
8
  * Always kept: a checkout in use, one whose task isn't finished (on hold, failed, a revision under way), a result
9
- * still waiting for review, a release candidate for a week after it was done (a deploy installs from it), a checkout
10
- * with changes (files Toolroll wrote itself aside), and one with commits on no branch. A checkout no task names
11
- * (adopted after a crash) goes only in a clean-up by hand. Every removal is in the ledger.
9
+ * still waiting for review, a checkout with changes (files Toolroll wrote itself aside), and one with commits on no
10
+ * branch. A release candidate's checkout (a deploy installs from it) is kept only while it is the newest gate for its
11
+ * release (for a week after it was done) or the one deployed; a superseded or cancelled gate goes at once, whatever
12
+ * its task's state (staged releases keep the rollback copy). A checkout no task names (adopted after a crash) goes only
13
+ * in a clean-up by hand. Every removal is in the ledger.
14
+ *
15
+ * A kept checkout waiting for review or with changes drops its dependencies and build output once its run ends
16
+ * (`slimKeptCheckouts`); the next run's setup restores them.
12
17
  */
13
18
  import { createHash } from "node:crypto";
14
- import { existsSync } from "node:fs";
15
- import { basename } from "node:path";
19
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
20
+ import { basename, dirname, join } from "node:path";
16
21
  import { taskBranches } from "./names.js";
17
22
  import { COMPLETION_ACTION } from "./result-completion.js";
18
23
  import { CLEANUP_CHOICES, bytesWords, diskBytes } from "./storage.js";
@@ -24,19 +29,80 @@ export const CANDIDATE_KEEP_MS = 7 * DAY_MS;
24
29
  export const AUTO_MAX = 20;
25
30
  /** Git inspections at once. */
26
31
  const INSPECT_AT_ONCE = 8;
32
+ export function deployedRelease(stateDir) {
33
+ if (stateDir === null)
34
+ return null;
35
+ const root = join(stateDir, "staged-upgrades");
36
+ let newest = null;
37
+ let names = [];
38
+ try {
39
+ names = readdirSync(root);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ for (const name of names) {
45
+ let journal;
46
+ try {
47
+ journal = JSON.parse(readFileSync(join(root, name, "deployment.json"), "utf8"));
48
+ }
49
+ catch {
50
+ continue;
51
+ }
52
+ if (journal["phase"] !== "deployed")
53
+ continue;
54
+ const at = typeof journal["deployedAt"] === "string" ? journal["deployedAt"] : typeof journal["updatedAt"] === "string" ? journal["updatedAt"] : "";
55
+ if (newest !== null && newest.at >= at)
56
+ continue;
57
+ newest = { at, head: typeof journal["candidate"] === "string" ? journal["candidate"] : null, run: Number.isSafeInteger(journal["builder"]) ? Number(journal["builder"]) : null };
58
+ }
59
+ return newest === null ? null : { head: newest.head, run: newest.run };
60
+ }
61
+ /** The folder beside the database (where staged releases live), or null for a database in memory. */
62
+ function stateDirOf(store) {
63
+ const main = store.handle.prepare("PRAGMA database_list").all().find(row => row["name"] === "main");
64
+ return typeof main?.["file"] === "string" && main["file"] !== "" ? dirname(main["file"]) : null;
65
+ }
66
+ /**
67
+ * Which release a gate belongs to: a failed gate is re-filed as the same name plus a letter (`release-x`, then
68
+ * `release-xb`, `release-xc` …), so a gate whose name is another gate's in the same project plus one letter is that
69
+ * release's.
70
+ */
71
+ export function releaseOf(id, gates) {
72
+ return /[b-z]$/.test(id) && gates.has(id.slice(0, -1)) ? id.slice(0, -1) : id;
73
+ }
27
74
  const STRENGTH = { "in use": 5, "task not finished": 4, "waiting for review": 3, "release candidate": 2 };
28
75
  const strength = (one) => one === undefined ? -1 : one.keep === null ? 0 : STRENGTH[one.keep] ?? 1;
29
76
  const stronger = (a, b) => strength(b) > strength(a) || (strength(b) === 0 && strength(a) === 0 && (b.finishedAt ?? "") > (a.finishedAt ?? "")) ? b : a;
30
77
  /** Where every task stands for cleanup, by task_ref id and by each branch it or its runs used. */
31
- export function taskStatuses(store, now) {
78
+ export function taskStatuses(store, now, deployed) {
32
79
  const db = store.handle;
33
80
  const refs = db.prepare(`SELECT r.id AS ref, r.external_id AS id, r.revision_of AS revision_of, t.state AS state, t.updated_at AS updated_at,
34
81
  EXISTS (SELECT 1 FROM hold h WHERE h.task_ref = r.id) AS held,
35
82
  EXISTS (SELECT 1 FROM run live WHERE live.task_ref = r.id AND live.finished_at IS NULL) AS live,
36
- EXISTS (SELECT 1 FROM task_scope s WHERE s.task_id = r.external_id AND s.candidate IS NOT NULL) AS candidate
83
+ (SELECT MAX(s.candidate) FROM task_scope s WHERE s.task_id = r.external_id) AS candidate, r.repo AS repo
37
84
  FROM task_ref r LEFT JOIN task t ON t.id = r.external_id AND r.backend = 'built-in'`).all();
38
85
  const runs = db.prepare("SELECT id, task_ref, branch FROM run").all();
39
86
  const refOfRun = new Map(runs.map(row => [Number(row["id"]), Number(row["task_ref"])]));
87
+ // Release candidates: per project and release, the newest gate not cancelled; and the one deployed.
88
+ const gates = refs.filter(row => row["candidate"] !== null);
89
+ const gateIds = new Map();
90
+ for (const row of gates) {
91
+ const repo = String(row["repo"]);
92
+ gateIds.set(repo, (gateIds.get(repo) ?? new Set()).add(String(row["id"])));
93
+ }
94
+ const releaseKey = (row) => `${String(row["repo"])}\u0000${releaseOf(String(row["id"]), gateIds.get(String(row["repo"])))}`;
95
+ const newestGate = new Map();
96
+ for (const row of gates) {
97
+ if (row["state"] === "cancelled")
98
+ continue;
99
+ const key = releaseKey(row);
100
+ newestGate.set(key, Math.max(newestGate.get(key) ?? -1, Number(row["ref"])));
101
+ }
102
+ const live = deployed === undefined ? deployedRelease(stateDirOf(store)) : deployed;
103
+ const deployedRef = live === null ? undefined : live.run !== null ? refOfRun.get(live.run) : undefined;
104
+ const isDeployed = (row) => live !== null &&
105
+ (Number(row["ref"]) === deployedRef || (live.head !== null && String(row["candidate"]) === live.head));
40
106
  // The completion names its family's root; its run names the result it completes.
41
107
  const completed = new Map();
42
108
  const completedRef = new Map();
@@ -59,16 +125,22 @@ export function taskStatuses(store, now) {
59
125
  const id = String(row["id"]);
60
126
  const state = row["state"] === null ? null : String(row["state"]);
61
127
  const updated = row["updated_at"] === null ? null : String(row["updated_at"]);
128
+ const gate = row["candidate"] !== null;
62
129
  let status;
63
130
  if (Number(row["live"]) === 1)
64
131
  status = { keep: "in use", taskId: id, finishedAt: null };
132
+ else if (gate && isDeployed(row))
133
+ status = { keep: "release candidate", taskId: id, finishedAt: updated };
134
+ // A superseded or cancelled gate goes at once, whatever its task's state: a newer gate (or none) is the release.
135
+ else if (gate && (state === "cancelled" || newestGate.get(releaseKey(row)) !== ref))
136
+ status = { keep: null, taskId: id, finishedAt: updated, atOnce: true };
65
137
  else if (state === null || Number(row["held"]) === 1 || (state !== "done" && state !== "cancelled"))
66
138
  status = { keep: "task not finished", taskId: id, finishedAt: null };
67
139
  else if (state === "cancelled")
68
140
  status = { keep: null, taskId: id, finishedAt: updated };
69
141
  else {
70
142
  const completedAt = [completed.get(id), completedRef.get(ref)].filter((one) => one !== undefined).sort().at(-1) ?? null;
71
- if (Number(row["candidate"]) === 1) {
143
+ if (gate) {
72
144
  const since = completedAt ?? updated ?? now.toISOString();
73
145
  const until = new Date(Date.parse(since) + CANDIDATE_KEEP_MS).toISOString();
74
146
  status = until > now.toISOString() ? { keep: "release candidate", taskId: id, finishedAt: since, dueAt: until } : { keep: null, taskId: id, finishedAt: since };
@@ -119,6 +191,17 @@ function statusOf(row, statuses) {
119
191
  }
120
192
  return found;
121
193
  }
194
+ /** The let-go checkouts whose task is complete or cancelled, and when it finished. Not a release candidate's that
195
+ * stays: a deploy runs from it. */
196
+ export function finishedCheckouts(store, now, repo) {
197
+ const statuses = taskStatuses(store, now);
198
+ return store.listWorktrees().filter(row => row.releasedAt !== null && row.runner === null && (repo === undefined || row.repo === repo)).flatMap(row => {
199
+ const status = statusOf(row, statuses);
200
+ if (status === undefined || status.finishedAt === null || status.keep !== null)
201
+ return [];
202
+ return [{ row, taskId: status.taskId, finishedAt: status.finishedAt }];
203
+ });
204
+ }
122
205
  /** Why a checkout stays before anyone looks at its files, or null: it may go (as far as its task and the setting say). */
123
206
  function keptFor(pool, row, statuses, now, cleanup) {
124
207
  const status = statusOf(row, statuses);
@@ -136,6 +219,8 @@ function keptFor(pool, row, statuses, now, cleanup) {
136
219
  return { why: null, taskId };
137
220
  if (cleanup === "never")
138
221
  return { why: "cleanup is off", taskId };
222
+ if (status.atOnce === true)
223
+ return { why: null, taskId };
139
224
  const days = CLEANUP_CHOICES.find(one => one.value === cleanup).days;
140
225
  const since = [status.finishedAt, row.releasedAt].filter((one) => one !== null).sort().at(-1);
141
226
  const dueAt = new Date(Date.parse(since) + days * DAY_MS).toISOString();
@@ -215,6 +300,41 @@ export async function cleanCheckouts(store, pool, now, options) {
215
300
  }
216
301
  return { removed, kept: pruned.kept, freed: removed.reduce((sum, one) => sum + one.bytes, 0) };
217
302
  }
303
+ /** Checkouts this process already looked at since they were let go (path and release time): looked at once. */
304
+ const looked = new Set();
305
+ /**
306
+ * A kept checkout (its result waiting for review, or with changes) drops its dependencies and build output once its
307
+ * run has ended; the next run's setup restores them (node_modules only goes where the project has a setup to restore
308
+ * it). Never one in use or a release candidate (a deploy installs from it). Each in the ledger.
309
+ */
310
+ export async function slimKeptCheckouts(store, pool, now, options) {
311
+ const rows = store.listWorktrees().filter(row => row.releasedAt !== null && row.runner === null && (options.repo === undefined || row.repo === options.repo)
312
+ && !looked.has(`${row.path}\u0000${row.releasedAt}`) && existsSync(row.path));
313
+ const slimmed = [];
314
+ if (rows.length === 0)
315
+ return { slimmed };
316
+ const keptAs = (row) => keptFor(pool, row, taskStatuses(store, now()), now(), "manual");
317
+ const statuses = taskStatuses(store, now());
318
+ for (const row of rows) {
319
+ const kept = keptFor(pool, row, statuses, now(), "manual");
320
+ if (kept.why === "in use")
321
+ continue;
322
+ looked.add(`${row.path}\u0000${row.releasedAt}`);
323
+ if (kept.why === "release candidate")
324
+ continue;
325
+ if (kept.why !== "waiting for review" && await pool.inspect(row.path) !== "has changes")
326
+ continue;
327
+ const wanted = (fresh) => { const why = keptAs(fresh).why; return why !== "in use" && why !== "release candidate"; };
328
+ const done = await pool.slim(row, wanted, store.liveWorktreeSetup(row.repo) !== null);
329
+ if (!done.ok || done.dropped.length === 0)
330
+ continue;
331
+ const bytes = done.bytes;
332
+ slimmed.push({ path: row.path, taskId: kept.taskId, dropped: done.dropped, bytes });
333
+ store.recordAction({ at: now().toISOString(), actor: options.actor, repo: row.repo, taskId: kept.taskId, runId: null, action: "checkout slimmed", outcome: "removed", source: "work",
334
+ detail: `${basename(row.path)}: dropped ${done.dropped.join(", ")}${bytes > 0 ? ` (about ${bytesWords(bytes)})` : ""}; the next run's setup restores them` });
335
+ }
336
+ return { slimmed };
337
+ }
218
338
  /** Throw away a checkout kept for its changes, on purpose; its branch stays. In the ledger. */
219
339
  export async function discardCheckout(store, pool, path, now, actor) {
220
340
  const bytes = existsSync(path) ? diskBytes([path]).get(path) ?? 0 : 0;
@@ -233,7 +353,7 @@ export function whyWords(item) {
233
353
  case "in use": return "in use";
234
354
  case "task not finished": return "its task isn't finished";
235
355
  case "waiting for review": return "its result is waiting for review";
236
- case "release candidate": return `release candidate, kept until ${item.dueAt?.slice(0, 10) ?? "a week after"}`;
356
+ case "release candidate": return item.dueAt === undefined ? "the deployed release candidate" : `newest release candidate, kept until ${item.dueAt.slice(0, 10)}`;
237
357
  case "has changes": return "has changes";
238
358
  case "has commits": return "has commits on no branch";
239
359
  case "unreadable": return "git couldn't read it";
package/dist/cli.js CHANGED
@@ -213,6 +213,7 @@ export const OPERATE_COMMANDS = new Set([
213
213
  "restore",
214
214
  "export",
215
215
  "policy",
216
+ "review",
216
217
  "assignment",
217
218
  "knowledge",
218
219
  "memory",
@@ -243,6 +244,7 @@ export const OPERATE_COMMANDS = new Set([
243
244
  "bridge",
244
245
  "publish",
245
246
  "reconcile",
247
+ "run",
246
248
  "routine",
247
249
  "flows",
248
250
  "config", "chat", "proposals", "mode", "people", "keys",