toolroll 0.9.8 → 0.9.10

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 (85) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assignment-brief.d.ts +2 -0
  3. package/dist/assignment-brief.js +2 -1
  4. package/dist/assignment-presentation.js +3 -1
  5. package/dist/assignment-status.d.ts +1 -1
  6. package/dist/assignment-ui.js +5 -0
  7. package/dist/assignment.d.ts +9 -0
  8. package/dist/assignment.js +42 -8
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/browser/workspace.js +18 -18
  11. package/dist/browser-crew.js +4 -0
  12. package/dist/browser-workspace.d.ts +40 -0
  13. package/dist/builder.d.ts +2 -0
  14. package/dist/builder.js +114 -8
  15. package/dist/chat-controls.d.ts +1 -1
  16. package/dist/chat-controls.js +1 -1
  17. package/dist/chat-delivery-state.d.ts +5 -0
  18. package/dist/chat-delivery.js +45 -8
  19. package/dist/chat-quiet.d.ts +10 -4
  20. package/dist/chat-quiet.js +87 -22
  21. package/dist/chat-voice.d.ts +68 -0
  22. package/dist/chat-voice.js +175 -0
  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/desktop-update.d.ts +52 -0
  27. package/dist/desktop-update.js +147 -5
  28. package/dist/discord-chat.js +1 -0
  29. package/dist/evidence.d.ts +6 -0
  30. package/dist/evidence.js +8 -0
  31. package/dist/first-run.d.ts +1 -1
  32. package/dist/first-run.js +7 -5
  33. package/dist/lead-context.d.ts +1 -1
  34. package/dist/lead-context.js +4 -2
  35. package/dist/lead-status.d.ts +10 -1
  36. package/dist/lead-status.js +21 -24
  37. package/dist/needs-you.d.ts +55 -0
  38. package/dist/needs-you.js +83 -0
  39. package/dist/operate.d.ts +2 -0
  40. package/dist/operate.js +122 -15
  41. package/dist/plane-review.js +4 -0
  42. package/dist/process-recovery-eligibility.d.ts +1 -0
  43. package/dist/project-delete.js +1 -0
  44. package/dist/project-learning.d.ts +3 -3
  45. package/dist/project-learning.js +28 -17
  46. package/dist/proof.d.ts +3 -1
  47. package/dist/proof.js +4 -2
  48. package/dist/releases.d.ts +9 -0
  49. package/dist/releases.js +30 -0
  50. package/dist/result-completion.d.ts +4 -0
  51. package/dist/result-completion.js +11 -0
  52. package/dist/review-context.d.ts +3 -0
  53. package/dist/review-context.js +45 -5
  54. package/dist/run-closeout.d.ts +89 -0
  55. package/dist/run-closeout.js +302 -0
  56. package/dist/serve.d.ts +15 -1
  57. package/dist/serve.js +129 -14
  58. package/dist/setup-guide.d.ts +2 -2
  59. package/dist/shared-deps.d.ts +94 -0
  60. package/dist/shared-deps.js +365 -0
  61. package/dist/slack-chat.js +2 -0
  62. package/dist/storage.js +2 -0
  63. package/dist/store.d.ts +60 -0
  64. package/dist/store.js +207 -30
  65. package/dist/task-status.d.ts +18 -4
  66. package/dist/task-status.js +35 -18
  67. package/dist/teams-chat.js +1 -1
  68. package/dist/telegram-progress.d.ts +6 -1
  69. package/dist/telegram-progress.js +13 -5
  70. package/dist/telegram-status.d.ts +2 -1
  71. package/dist/telegram-status.js +6 -5
  72. package/dist/telegram.d.ts +1 -1
  73. package/dist/telegram.js +93 -43
  74. package/dist/test-temp.d.ts +19 -0
  75. package/dist/test-temp.js +98 -0
  76. package/dist/toolroll-update-ui.js +7 -2
  77. package/dist/toolroll-update.d.ts +21 -1
  78. package/dist/toolroll-update.js +135 -5
  79. package/dist/work-index.d.ts +11 -0
  80. package/dist/work-index.js +103 -16
  81. package/dist/work-summary.d.ts +3 -1
  82. package/dist/workspace-ui.js +1 -1
  83. package/dist/worktree.d.ts +16 -0
  84. package/dist/worktree.js +47 -0
  85. package/package.json +1 -1
package/dist/operate.js CHANGED
@@ -7,8 +7,11 @@ import { parseProtectedPaths } from "./approval-policy.js";
7
7
  import { rulesSummary } from "./approval-rules-ui.js";
8
8
  import { evidencePack, exportDay, ledgerExportChunks, standaloneEvidenceHtml } from "./evidence-pack.js";
9
9
  import { matchesOutsideCheckpoint } from "./ledger-chain.js";
10
- import { CLEANUP_CHOICES, bytesWords, cleanupWords, parseCleanup, storageReport } from "./storage.js";
11
- import { checkoutPlan, cleanCheckouts, discardCheckout, whyWords } from "./checkout-cleanup.js";
10
+ import { CLEANUP_CHOICES, bytesWords, cleanupWords, diskBytes, parseCleanup, storageReport } from "./storage.js";
11
+ import { removeStaleTestTemp, tempRoots, testTempFolders } from "./test-temp.js";
12
+ import { removeShared, sharedDepsRoot, sharedUse, unusedShared } from "./shared-deps.js";
13
+ import { checkoutPlan, cleanCheckouts, discardCheckout, finishedCheckouts, slimKeptCheckouts, whyWords } from "./checkout-cleanup.js";
14
+ import { closeOutCheckouts, closeOutRuns, stoppedWords } from "./run-closeout.js";
12
15
  import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodLabel, periodWords, retentionPlan, sweepWords } from "./retention.js";
13
16
  import { checkPolicy, parseList, policyParts } from "./policy.js";
14
17
  import { billingOf, budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendItems, teammateNames, usd as spendUsd } from "./spend.js";
@@ -73,6 +76,7 @@ import { pullRequestLines, runTaskMergeCommand, runTaskOutcomeCommand } from "./
73
76
  */
74
77
  import { homedir, hostname, tmpdir } from "node:os";
75
78
  import { basename, dirname, join, resolve } from "node:path";
79
+ import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
76
80
  import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, DEFAULT_ACTOR, parseCapabilityKey, verifiedAuthor, contestantProfileOf, isDigestTime, } from "./store.js";
77
81
  import { randomBytes, randomInt, randomUUID } from "node:crypto";
78
82
  import { authorizePlanUnderMode } from "./plan-auto.js";
@@ -193,8 +197,8 @@ import { runModelsCommand } from "./models-cli.js";
193
197
  import { updateAdmissionPaused, UPDATE_PAUSED } from "./desktop-update-gate.js";
194
198
  import { beads } from "./beads.js";
195
199
  import { githubIssues } from "./issues.js";
196
- import { installationStatus, renderInstallationStatus, renderTaskWait, taskWaitSnapshot } from "./lead-status.js";
197
- import { latestRelease, newerRelease, notifySecurityRelease, recordRunnerVersion, startUpdateChecks, updateLine } from "./releases.js";
200
+ import { installationStatus, renderInstallationStatus, renderTaskWait, repairStaleStatuses, taskWaitSnapshot } from "./lead-status.js";
201
+ import { latestRelease, newerRelease, notifySecurityRelease, notifyVersionLive, recordRunnerVersion, startUpdateChecks, updateLine } from "./releases.js";
198
202
  import { installMethod } from "./install-method.js";
199
203
  import { PACKAGE_VERSION } from "./version.js";
200
204
  /**
@@ -227,8 +231,8 @@ export const OPERATE_HELP = `toolroll — operating the queue
227
231
  how many of a project's tasks build at once (default 2, never past the
228
232
  worker's capacity); an approver changes it with <n>
229
233
  toolroll task evidence <id> the task's evidence pack as JSON (--html for a printable page; --out <file>)
230
- toolroll storage where the disk goes: database, build checkouts, releases, evidence
231
- toolroll storage clean [--yes] preview removing finished tasks' clean checkouts (their branches stay); --yes removes them
234
+ toolroll storage where the disk goes: database, build checkouts, releases, evidence, leftover test temp folders
235
+ toolroll storage clean [--yes] preview removing finished tasks' clean checkouts (their branches stay) and test temp folders older than a day; --yes removes them
232
236
  toolroll storage cleanup <when> remove a finished task's clean checkout: finished|2d|7d|never (instance operator)
233
237
  toolroll storage discard <path> --yes throw away a checkout kept for its changes; its branch stays
234
238
  toolroll integrations [--json] which integrations work: Connected, Not set up or Broken, and what to do (--saved: no new checks)
@@ -807,6 +811,7 @@ export async function runOperate(command, argv, write, options = {}) {
807
811
  // it matters.
808
812
  evidenceRoot: join(dirname(file), "evidence"),
809
813
  databaseFile: file,
814
+ ...(options.tempRoots === undefined ? {} : { tempRoots: options.tempRoots }),
810
815
  // The bot token's file home. Never a column; see telegram.ts.
811
816
  telegramTokenFile: join(dirname(file), "telegram-token"),
812
817
  ...(options.desktopIdentity === undefined ? {} : { desktopIdentity: options.desktopIdentity }),
@@ -4397,10 +4402,21 @@ async function reconcileCommand(flags, context) {
4397
4402
  }
4398
4403
  }
4399
4404
  const recovered = recoverDead(store, clock());
4405
+ // A run that ended, whatever its outcome, leaves nothing running: what is still in its process groups is stopped
4406
+ // (SIGTERM, then SIGKILL after 10 s) and the ledger names it. Never the live service.
4407
+ const closedRuns = await closeOutRuns(store, clock, { repo });
4408
+ // A finished run's processes that have since gone get their exits recorded,
4409
+ // so its task stops waiting on a person by itself.
4410
+ store.recordFinishedRunExits(clock());
4400
4411
  // A witness reserved for a spawn that never made a process (a crash or a
4401
4412
  // thrown spawn) settles once its run's process groups are proven gone.
4402
4413
  store.settleUnspawnedWitnesses(clock());
4403
4414
  store.settleQuiescentStops(clock());
4415
+ // An update whose updater ended while letting work finish must not leave new work paused.
4416
+ try {
4417
+ releaseStalledUpdate(dirname(context.databaseFile), clock());
4418
+ }
4419
+ catch { /* status and the console say it */ }
4404
4420
  for (const one of recovered) {
4405
4421
  for (const leaseId of one.claims) {
4406
4422
  // Lease ids are unique forever, so each recovery is its own episode.
@@ -4452,7 +4468,11 @@ async function reconcileCommand(flags, context) {
4452
4468
  }
4453
4469
  // Checkout cleanup (Settings → Storage): finished work's clean checkouts go as the setting says (by default when
4454
4470
  // the task is complete or cancelled); their branches stay. Each removal is in the ledger.
4471
+ // Completing or cancelling a task stops anything still running from its checkout first.
4472
+ const closedCheckouts = await closeOutCheckouts(store, clock, finishedCheckouts(store, clock(), repo));
4455
4473
  const pruned = await cleanCheckouts(store, worktrees, clock, { manual: false, actor: "worker", repo });
4474
+ // What stays for review or for its changes drops its dependencies and build output; the next run's setup restores them.
4475
+ const slimmed = await slimKeptCheckouts(store, worktrees, clock, { actor: "worker", repo });
4456
4476
  // Retention (Settings → Retention): once a day, what's older than its setting goes; one ledger entry says what.
4457
4477
  let retention = null;
4458
4478
  try {
@@ -4468,6 +4488,9 @@ async function reconcileCommand(flags, context) {
4468
4488
  adoption.adopted.length === 0 &&
4469
4489
  adoption.forgotten.length === 0 &&
4470
4490
  pruned.removed.length === 0 &&
4491
+ closedRuns.length === 0 &&
4492
+ closedCheckouts.length === 0 &&
4493
+ slimmed.slimmed.length === 0 &&
4471
4494
  liveSwept.removed.length === 0;
4472
4495
  return succeed(write, json, "reconcile", {
4473
4496
  recovered,
@@ -4476,6 +4499,8 @@ async function reconcileCommand(flags, context) {
4476
4499
  forgotten: adoption.forgotten,
4477
4500
  checkoutsRemoved: pruned.removed.map(one => one.path),
4478
4501
  checkoutsKept: pruned.kept,
4502
+ processesStopped: [...closedRuns.map(one => ({ run: one.runId, pids: one.done.stopped.map(row => row.pid) })), ...closedCheckouts.map(one => ({ checkout: one.path, pids: one.done.stopped.map(row => row.pid) }))],
4503
+ checkoutsSlimmed: slimmed.slimmed.map(one => one.path),
4479
4504
  liveViewsSwept: liveSwept.removed.length,
4480
4505
  retention: retention === null ? null : { counts: retention.counts, freedBytes: retention.freed },
4481
4506
  }, () => nothing
@@ -4486,6 +4511,9 @@ async function reconcileCommand(flags, context) {
4486
4511
  ...adoption.adopted.map(path => `Adopted ${path} — released, unverified, somebody should look.`),
4487
4512
  ...adoption.forgotten.map(path => `Forgot ${path} — its directory is gone.`),
4488
4513
  ...(pruned.removed.length === 0 ? [] : [`Removed ${pruned.removed.length} finished task checkout(s), about ${bytesWords(pruned.freed)}; their branches stay.`]),
4514
+ ...closedRuns.map(one => `Run #${one.runId} ended; stopped ${stoppedWords(one.done)}.`),
4515
+ ...closedCheckouts.map(one => `${one.path}: its task finished; stopped ${stoppedWords(one.done)}.`),
4516
+ ...(slimmed.slimmed.length === 0 ? [] : [`Dropped dependencies and build output from ${slimmed.slimmed.length} kept checkout(s), about ${bytesWords(slimmed.slimmed.reduce((sum, one) => sum + one.bytes, 0))}.`]),
4489
4517
  ...(liveSwept.removed.length === 0 ? [] : [`Cleared ${liveSwept.removed.length} finished live view(s).`]),
4490
4518
  ...(retention === null || retained.length === 0 ? [] : [`Retention removed ${sweepWords(retention.counts, retention.freed)}.`]),
4491
4519
  ]);
@@ -7259,6 +7287,8 @@ async function daemonCommand(positional, flags, context) {
7259
7287
  // ---- the watch loop --------------------------------------------------------
7260
7288
  const WATCH_LEASE_MS = 90_000;
7261
7289
  const WATCH_HEARTBEAT_MS = 30_000;
7290
+ /** The start repair runs once per process, not once per project loop. */
7291
+ let staleStatusesRepaired = false;
7262
7292
  async function runWatchLoop(args) {
7263
7293
  const { context, flags, runner, token, repo, progress } = args;
7264
7294
  const { store } = context;
@@ -7294,8 +7324,21 @@ async function runWatchLoop(args) {
7294
7324
  // attributes to this episode by runner and window, and `brief
7295
7325
  // --latest-watch` bounds itself to exactly it.
7296
7326
  store.startWatchEpisode({ repo, runner, incarnation }, new Date());
7327
+ // A deploy's one chat line, "Toolroll <version> is live.", when this worker last ran an older version.
7328
+ try {
7329
+ notifyVersionLive(store, dirname(context.databaseFile), PACKAGE_VERSION, new Date());
7330
+ }
7331
+ catch { /* A display fact; never blocks the worker. */ }
7297
7332
  recordRunnerVersion(dirname(context.databaseFile), runner, PACKAGE_VERSION, new Date());
7298
7333
  args.onReady?.();
7334
+ // Once per worker start, before any pass: statuses an older build left
7335
+ // stale (unrecorded exits of finished runs) are re-derived and clear.
7336
+ if (!staleStatusesRepaired) {
7337
+ staleStatusesRepaired = true;
7338
+ const repaired = repairStaleStatuses(store, new Date());
7339
+ if (repaired.exitsRecorded > 0)
7340
+ progress(`watch: recorded ${repaired.exitsRecorded} finished process exit(s) left open by an earlier version`);
7341
+ }
7299
7342
  // A false renewal is FATAL (arc 2 finding 15): the lease or the
7300
7343
  // credential is gone, and admitting one more pass would be work done for
7301
7344
  // an authority this process no longer holds.
@@ -7792,7 +7835,8 @@ async function watchCommand(flags, context) {
7792
7835
  token,
7793
7836
  repo,
7794
7837
  progress,
7795
- isStopping: () => stopping,
7838
+ // An injected fence (tests) stops admission exactly as a signal does.
7839
+ isStopping: () => stopping || context.shouldStop?.() === true,
7796
7840
  onFollowController: controller => {
7797
7841
  followAbort = controller;
7798
7842
  },
@@ -8574,6 +8618,10 @@ async function upCommand(flags, context) {
8574
8618
  });
8575
8619
  };
8576
8620
  observeReadiness();
8621
+ try {
8622
+ notifyVersionLive(context.store, dirname(context.databaseFile), PACKAGE_VERSION, clock());
8623
+ }
8624
+ catch { /* A display fact; never blocks the worker. */ }
8577
8625
  recordRunnerVersion(dirname(context.databaseFile), runnerName, PACKAGE_VERSION, clock());
8578
8626
  const runnerHeartbeat = setInterval(() => {
8579
8627
  const beat = heartbeatRunner(store, runnerName, runnerToken, clock());
@@ -8836,6 +8884,10 @@ async function bridgeCommand(positional, flags, context) {
8836
8884
  const runFor = text(flags, "for");
8837
8885
  const timer = runFor === undefined ? null : setTimeout(() => controller.abort(), Number(runFor));
8838
8886
  timer?.unref?.();
8887
+ // An injected fence (tests) ends the follow exactly as a signal does.
8888
+ const fence = context.shouldStop === undefined ? null : setInterval(() => { if (context.shouldStop?.() === true)
8889
+ controller.abort(); }, 50);
8890
+ fence?.unref?.();
8839
8891
  if (!json)
8840
8892
  write(`Following bot ${source.botId} — taps apply as they arrive. Ctrl-C stops it.`);
8841
8893
  try {
@@ -8865,6 +8917,8 @@ async function bridgeCommand(positional, flags, context) {
8865
8917
  finally {
8866
8918
  if (timer !== null)
8867
8919
  clearTimeout(timer);
8920
+ if (fence !== null)
8921
+ clearInterval(fence);
8868
8922
  process.removeListener("SIGINT", stop);
8869
8923
  process.removeListener("SIGTERM", stop);
8870
8924
  }
@@ -9528,10 +9582,13 @@ async function statusCommand(positional, flags, context) {
9528
9582
  const brokenIntegrations = await savedIntegrations(context).then(list => list.filter(one => one.state === "broken"), () => []);
9529
9583
  const brokenLine = integrationsBrokenLine(brokenIntegrations);
9530
9584
  const integrations = brokenIntegrations.length === 0 ? {} : { integrations: { broken: brokenIntegrations.map(one => ({ key: one.key, name: one.name, fix: one.action.kind === "fix" ? one.action.words : null })) } };
9585
+ // An update waiting, on what, and the action: before anything else, as it holds up new work.
9586
+ const waiting = waitingUpdate(context.databaseFile, run => context.store.stopQuiescenceProblem(run) !== null, context.clock());
9587
+ const updateWaiting = waiting === null ? {} : { updateWaiting: { app: waiting.app, version: waiting.version, stopped: waiting.stopped, run: waiting.run, on: waiting.on, action: waiting.action } };
9531
9588
  const projects = projectBuildsStatus(context);
9532
9589
  const projectsLine = projects.length === 0 ? null
9533
9590
  : `Builds by project: ${projects.slice(0, 8).map(one => `${one.name} ${one.running} of ${one.limit}`).join(", ")}${projects.length > 8 ? ", …" : ""}`;
9534
- return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations }, () => [...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
9591
+ return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations, ...updateWaiting }, () => [...(waiting === null ? [] : [waiting.words]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
9535
9592
  }
9536
9593
  /** Each known project's running builds against its limit: its own number, never past its workers' capacity. */
9537
9594
  function projectBuildsStatus(context) {
@@ -12145,13 +12202,35 @@ async function storageCommand(positional, flags, context) {
12145
12202
  `A finished task's clean checkout is removed ${cleanupWords(plan.cleanup)}; its branch stays.`,
12146
12203
  ];
12147
12204
  const itemLine = (one) => ` ${bytesWords(one.bytes).padStart(8)} ${one.path}${one.why === null ? "" : ` (${whyWords(one)})`}`;
12205
+ // Test temp folders the tests, journeys and scripts left (a killed run, an older version): those nothing touched for a day go.
12206
+ const roots = context.tempRoots ?? tempRoots();
12207
+ // Counting is quick; sizing thousands of folders isn't, so only a clean-up measures them.
12208
+ const leftovers = (measure) => {
12209
+ const found = testTempFolders(roots, context.clock());
12210
+ const sizes = measure ? diskBytes(found.stale.map(one => one.path)) : new Map();
12211
+ return { count: found.all.length, stale: found.stale.length, staleBytes: [...sizes.values()].reduce((sum, one) => sum + one, 0) };
12212
+ };
12213
+ const leftoverLine = (temp) => temp.count === 0 ? "Test temp folders: none." :
12214
+ `Test temp folders: ${temp.count}; ${temp.stale === 0 ? "none older than a day." : `${temp.stale} older than a day: toolroll storage clean removes them.`}`;
12215
+ // Shared dependencies (shared-deps.ts): one install per lockfile, linked into checkouts.
12216
+ const depsRoot = sharedDepsRoot(context.databaseFile);
12217
+ const checkoutRows = () => store.listWorktrees().map(row => ({ path: row.path, repo: row.repo }));
12218
+ const sharedFacts = (copies) => {
12219
+ const sizes = diskBytes(copies.map(one => one.dir));
12220
+ return copies.map(one => ({ key: one.key, repo: one.repo, path: one.dir, bytes: sizes.get(one.dir) ?? 0, checkouts: one.checkouts.length, usedBy: one.checkouts, retired: one.retired, node: one.node, createdAt: one.createdAt, usedAt: one.usedAt }));
12221
+ };
12222
+ const sharedLine = (one) => ` ${bytesWords(one.bytes).padStart(8)} ${projectName(one.repo)} ${one.key.slice(0, 8)}: ${one.checkouts === 1 ? "1 checkout uses it" : `${one.checkouts} checkouts use it`}${one.retired ? " (changed after install; never linked again)" : ""}`;
12148
12223
  if (action === undefined) {
12149
12224
  const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
12150
12225
  const report = storageReport(store, context.databaseFile, plan.go.length === 0 ? "nothing to clean up" : `about ${bytesWords(plan.freeBytes)} to clean up`);
12151
- return succeed(context.write, context.json, "storage", { ...report, checkouts: checkoutSummary(plan) }, () => [
12226
+ const { staleBytes: _, ...temp } = leftovers(false);
12227
+ const shared = sharedFacts(sharedUse(depsRoot, checkoutRows()));
12228
+ return succeed(context.write, context.json, "storage", { ...report, checkouts: checkoutSummary(plan), testTemp: temp, shared }, () => [
12152
12229
  `${report.folder}: ${bytesWords(report.total)}`,
12153
- ...report.lines.map(one => ` ${one.what.padEnd(17)} ${bytesWords(one.bytes).padStart(8)}${one.count === undefined ? "" : ` (${one.count})`}${one.note === undefined ? "" : ` ${one.note}`}`),
12230
+ ...report.lines.map(one => ` ${one.what.padEnd(19)} ${bytesWords(one.bytes).padStart(8)}${one.count === undefined ? "" : ` (${one.count})`}${one.note === undefined ? "" : ` ${one.note}`}`),
12231
+ ...(shared.length === 0 ? [] : ["Shared dependencies:", ...shared.map(sharedLine)]),
12154
12232
  ...summary(plan),
12233
+ leftoverLine({ ...temp, staleBytes: 0 }),
12155
12234
  ]);
12156
12235
  }
12157
12236
  if (action === "cleanup") {
@@ -12173,20 +12252,48 @@ async function storageCommand(positional, flags, context) {
12173
12252
  return fail(context.write, context.json, command, "usage", "Use storage clean to preview, then storage clean --yes.", EXIT.usage);
12174
12253
  if (!flags.has("yes")) {
12175
12254
  const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
12176
- return succeed(context.write, context.json, command, { preview: true, go: plan.go, stay: plan.stay, freeBytes: plan.freeBytes }, () => [
12177
- ...(plan.go.length === 0 ? ["Nothing to clean up."] : [`Would remove ${plan.go.length} checkout${plan.go.length === 1 ? "" : "s"}, freeing about ${bytesWords(plan.freeBytes)} (their branches stay):`, ...plan.go.map(itemLine)]),
12255
+ const temp = leftovers(true);
12256
+ // A shared copy goes when no checkout uses it, counting the checkouts this clean-up would remove.
12257
+ const leaving = new Set(plan.go.map(one => one.path));
12258
+ const unused = unusedShared(depsRoot, checkoutRows().filter(row => !leaving.has(row.path)), context.clock());
12259
+ const sharedGo = sharedFacts(unused.copies);
12260
+ const sharedBytes = sharedGo.reduce((sum, one) => sum + one.bytes, 0);
12261
+ const anything = plan.go.length > 0 || temp.stale > 0 || sharedGo.length > 0;
12262
+ return succeed(context.write, context.json, command, { preview: true, go: plan.go, stay: plan.stay, freeBytes: plan.freeBytes, testTemp: temp, sharedGo, sharedFreeBytes: sharedBytes }, () => [
12263
+ ...(plan.go.length === 0 ? [anything ? "No checkouts to clean up." : "Nothing to clean up."] : [`Would remove ${plan.go.length} checkout${plan.go.length === 1 ? "" : "s"}, freeing about ${bytesWords(plan.freeBytes)} (their branches stay):`, ...plan.go.map(itemLine)]),
12264
+ ...(sharedGo.length === 0 ? [] : [`Would remove ${sharedGo.length} shared dependency install${sharedGo.length === 1 ? "" : "s"} no checkout uses, about ${bytesWords(sharedBytes)}:`, ...sharedGo.map(sharedLine)]),
12178
12265
  ...(plan.stay.length === 0 ? [] : [`Stays (${plan.stay.length}):`, ...plan.stay.map(itemLine)]),
12179
- plan.go.length === 0 ? "Nothing was removed." : "Nothing was removed. Run toolroll storage clean --yes to remove them.",
12266
+ ...(temp.stale === 0 ? [] : [`Would remove ${temp.stale} test temp folder${temp.stale === 1 ? "" : "s"} older than a day, about ${bytesWords(temp.staleBytes)}.`]),
12267
+ anything ? "Nothing was removed. Run toolroll storage clean --yes to remove them." : "Nothing was removed.",
12180
12268
  ]);
12181
12269
  }
12182
12270
  const actor = await operator();
12183
12271
  if (actor === null)
12184
12272
  return refused("cleans up checkouts");
12185
12273
  const done = await cleanCheckouts(store, pool, context.clock, { manual: true, actor });
12186
- return succeed(context.write, context.json, command, { removed: done.removed, kept: done.kept, freedBytes: done.freed }, () => [
12187
- done.removed.length === 0 ? "Nothing to clean up." : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}; their branches stay.`,
12274
+ const temp = leftovers(true);
12275
+ const swept = removeStaleTestTemp(roots, context.clock());
12276
+ if (swept.removed.length > 0) {
12277
+ store.recordAction({ at: context.clock().toISOString(), actor, repo: null, taskId: null, runId: null, action: "test temp folders removed", outcome: "removed", source: "request",
12278
+ detail: `${swept.removed.length} older than a day, about ${bytesWords(temp.staleBytes)}${swept.failed.length === 0 ? "" : `; ${swept.failed.length} couldn't be removed`}` });
12279
+ }
12280
+ // After the checkouts go: a shared copy none of the remaining ones uses goes too.
12281
+ const unused = unusedShared(depsRoot, checkoutRows(), context.clock());
12282
+ const sharedRemoved = sharedFacts(unused.copies).filter(one => removeShared(one.path));
12283
+ for (const one of sharedRemoved) {
12284
+ store.recordAction({ at: context.clock().toISOString(), actor, repo: one.repo, taskId: null, runId: null, action: "shared dependencies removed", outcome: "removed", source: "request",
12285
+ detail: `${one.key} (about ${bytesWords(one.bytes)}); no checkout used it` });
12286
+ }
12287
+ for (const debris of unused.debris)
12288
+ removeShared(debris);
12289
+ const sharedFreed = sharedRemoved.reduce((sum, one) => sum + one.bytes, 0);
12290
+ return succeed(context.write, context.json, command, { removed: done.removed, kept: done.kept, freedBytes: done.freed, testTempRemoved: swept.removed.length, testTempFailed: swept.failed, sharedRemoved, sharedFreedBytes: sharedFreed }, () => [
12291
+ done.removed.length === 0 ? (swept.removed.length === 0 && sharedRemoved.length === 0 ? "Nothing to clean up." : "No checkouts to clean up.") : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}; their branches stay.`,
12188
12292
  ...done.removed.map(itemLine),
12293
+ ...(sharedRemoved.length === 0 ? [] : [`Removed ${sharedRemoved.length} shared dependency install${sharedRemoved.length === 1 ? "" : "s"} no checkout used, about ${bytesWords(sharedFreed)}.`, ...sharedRemoved.map(sharedLine)]),
12189
12294
  ...done.kept.map(one => ` kept ${one.path} (${whyWords({ why: one.why })})`),
12295
+ ...(swept.removed.length === 0 ? [] : [`Removed ${swept.removed.length} test temp folder${swept.removed.length === 1 ? "" : "s"} older than a day, about ${bytesWords(temp.staleBytes)}.`]),
12296
+ ...(swept.failed.length === 0 ? [] : [`Couldn't remove ${swept.failed.length} test temp folder${swept.failed.length === 1 ? "" : "s"}: ${swept.failed.slice(0, 3).join(", ")}${swept.failed.length > 3 ? " …" : ""}`]),
12190
12297
  ]);
12191
12298
  }
12192
12299
  if (action === "discard") {
@@ -148,6 +148,10 @@ export function reviewPlane(store, now, canSee = () => true) {
148
148
  for (const row of tasks) {
149
149
  if (!canSee(str(row["repo"])))
150
150
  continue;
151
+ // A version a later revision replaced never waits on its own.
152
+ const family = store.taskFamilyOf(String(row["id"]), null, true);
153
+ if (family !== null && family.current.id !== String(row["id"]))
154
+ continue;
151
155
  const snapshot = taskWaitSnapshot(store, String(row["id"]), now);
152
156
  if (snapshot === null || (snapshot.reason !== "needs-person" && snapshot.reason !== "ready"))
153
157
  continue;
@@ -95,6 +95,7 @@ export declare function preparedCandidateObserverGapEligibility(store: Store, in
95
95
  readonly items: readonly string[];
96
96
  readonly gaps: readonly string[];
97
97
  readonly priorSupport: "eligible" | "invalid" | "none";
98
+ readonly assets?: string;
98
99
  };
99
100
  }[];
100
101
  readonly machineVerdict: import("./store.js").ProofVerdictRow["verdict"] | null;
@@ -232,6 +232,7 @@ function deleteRows(store, repo, d, now) {
232
232
  del("telegram_conversation", IN("id"), conversations);
233
233
  del("telegram_outbound_message", `project = ? OR ${IN("notification")} OR ${IN("task_ref")} OR ${IN("source_run")}`, repo, notifications, T, R);
234
234
  del("chat_card_task", IN("task_ref"), T);
235
+ del("chat_batch_item", IN("task_ref"), T);
235
236
  del("push_delivery", IN("notification"), notifications);
236
237
  del("notification_actor", IN("notification"), notifications);
237
238
  del("notification_delivery", IN("notification"), notifications);
@@ -48,9 +48,9 @@ export type LearningEvent = {
48
48
  };
49
49
  export declare const learningSha: (text: string) => string;
50
50
  /** Path plus the physical shared Git database: replacing a repository does not inherit its lessons. */
51
- export declare function learningIdentity(repo: string): string;
51
+ export declare function learningIdentity(repo: string, seen?: Map<string, string>): string;
52
52
  /** Compare the applicable code and configuration, not model confidence or a claimed passing command. */
53
- export declare function learningFingerprint(repo: string, head: string, paths: readonly string[]): string;
53
+ export declare function learningFingerprint(repo: string, head: string, paths: readonly string[], seen?: Map<string, string>): string;
54
54
  export declare function parseLearning(value: unknown): LearningCandidate[];
55
55
  /** A write problem is optional: preserve core completion, leave a visible diagnostic, retry on the next read/admission. */
56
56
  export declare function learningFailure(store: Store, repo: string, run: number | null, now: Date): void;
@@ -60,7 +60,7 @@ export declare function queueLearning(store: Store, source: number, reviewer: nu
60
60
  sha256: string;
61
61
  }[], now: Date, assessment?: unknown): void;
62
62
  /** Validate after core commit; both suggestions commit or neither. */
63
- export declare function recoverLearning(store: Store, root: string, repo: string, now?: Date): void;
63
+ export declare function recoverLearning(store: Store, root: string, repo: string, now?: Date, seen?: Map<string, string>): void;
64
64
  export declare function learningView(store: Store, root: string, repo: string, actor: string, before?: number): {
65
65
  repo: string;
66
66
  identity: string;
@@ -44,12 +44,21 @@ CREATE TRIGGER IF NOT EXISTS learning_capture_no_delete BEFORE DELETE ON learnin
44
44
  export const learningSha = (text) => createHash('sha256').update(text).digest('hex');
45
45
  const text = (x, max) => typeof x === 'string' && x.trim().length > 0 && Buffer.byteLength(x) <= max && !/[\x00-\x1f\x7f]/.test(x);
46
46
  const pathOk = (x) => text(x, 300) && !x.startsWith('/') && !x.includes('\\') && !/^[A-Za-z]:/.test(x) && x.split('/').every(p => p !== '.' && p !== '..' && p !== '') && !/[\*?\[\]:]/.test(x);
47
- const git = (repo, args) => execFileSync('git', ['--no-optional-locks', '-C', repo, ...args], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd();
47
+ /** One read per distinct command within a single selection pass (`seen`), so a pass over many lessons reads the repository once. */
48
+ const git = (repo, args, seen) => {
49
+ const key = JSON.stringify([repo, args]);
50
+ const known = seen?.get(key);
51
+ if (known !== undefined)
52
+ return known;
53
+ const out = execFileSync('git', ['--no-optional-locks', '-C', repo, ...args], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }).trimEnd();
54
+ seen?.set(key, out);
55
+ return out;
56
+ };
48
57
  /** Path plus the physical shared Git database: replacing a repository does not inherit its lessons. */
49
- export function learningIdentity(repo) {
58
+ export function learningIdentity(repo, seen) {
50
59
  if (canonicalProject(repo) !== repo)
51
60
  throw new Error('Project identity changed.');
52
- const common = realpathSync(resolve(repo, git(repo, ['rev-parse', '--git-common-dir'])));
61
+ const common = realpathSync(resolve(repo, git(repo, ['rev-parse', '--git-common-dir'], seen)));
53
62
  const st = statSync(common);
54
63
  return learningSha(JSON.stringify([repo, common, st.dev, st.ino, st.birthtimeMs]));
55
64
  }
@@ -59,10 +68,10 @@ function environmentFingerprint(store, repo) {
59
68
  }
60
69
  const configPath = (p) => /(^|\/)(AGENTS\.md|package(-lock)?\.json|[^/]*lock[^/]*|[^/]*config[^/]*|Cargo\.toml|go\.mod|pyproject\.toml|\.tool-versions|\.nvmrc)$/.test(p);
61
70
  /** Compare the applicable code and configuration, not model confidence or a claimed passing command. */
62
- export function learningFingerprint(repo, head, paths) {
71
+ export function learningFingerprint(repo, head, paths, seen) {
63
72
  if (!/^[a-f0-9]{40,64}$/.test(head))
64
73
  throw new Error('No exact source revision.');
65
- const entries = git(repo, ['ls-tree', '-r', head]).split('\n').filter(line => {
74
+ const entries = git(repo, ['ls-tree', '-r', head], seen).split('\n').filter(line => {
66
75
  const p = line.slice(line.indexOf('\t') + 1);
67
76
  return paths.includes(p) || configPath(p);
68
77
  });
@@ -198,7 +207,7 @@ function verifyEvidence(store, root, source, evidence, catalog) {
198
207
  // missing work, so newer reviews cannot starve an older recovery.
199
208
  const recoveryCursors = new WeakMap();
200
209
  /** Validate after core commit; both suggestions commit or neither. */
201
- export function recoverLearning(store, root, repo, now = new Date()) {
210
+ export function recoverLearning(store, root, repo, now = new Date(), seen = new Map()) {
202
211
  let projects = recoveryCursors.get(store);
203
212
  if (!projects)
204
213
  recoveryCursors.set(store, projects = new Map());
@@ -225,7 +234,7 @@ export function recoverLearning(store, root, repo, now = new Date()) {
225
234
  if (!source || !reviewer || source.taskRef !== reviewer.taskRef)
226
235
  continue;
227
236
  const snapshot = reviewSnapshot(store, reviewer.id);
228
- if (!snapshot || snapshot['identity'] !== learningIdentity(repo))
237
+ if (!snapshot || snapshot['identity'] !== learningIdentity(repo, seen))
229
238
  continue;
230
239
  const response = store.artifactsFor(reviewer.id).find(a => a.kind === 'structured-output' && a.captureStatus === 'ok');
231
240
  const read = response && readVerifiedArtifact(root, response);
@@ -260,7 +269,7 @@ export function recoverLearning(store, root, repo, now = new Date()) {
260
269
  if (!run || !reviewed || reviewed.outcome !== 'no-change' || reviewed.taskRef !== run.taskRef || reviewed.role !== 'reviewer' || reviewedSource(store, reviewer) !== source || store.refForId(run.taskRef)?.repo !== repo)
261
270
  throw new Error('Learning review provenance is unavailable.');
262
271
  const candidates = parseLearning(JSON.parse(String(row['payload'])));
263
- const identity = learningIdentity(repo);
272
+ const identity = learningIdentity(repo, seen);
264
273
  if (identity !== row['identity'])
265
274
  throw new Error('Learning project identity changed.');
266
275
  const head = run.headRevision ?? run.baseRevision ?? '';
@@ -273,7 +282,7 @@ export function recoverLearning(store, root, repo, now = new Date()) {
273
282
  verifyEvidence(store, root, source, c.evidence, JSON.parse(String(row['catalog'])));
274
283
  if (c.paths.some(p => !patch.split('\n').some(l => l === `+++ b/${p}` || l === `--- a/${p}`)))
275
284
  throw new Error('Learning applicability must name reviewed files.');
276
- const payload = JSON.stringify({ ...c, environment: String(row['environment']), fingerprint: learningFingerprint(repo, head, c.paths), platform: process.platform, head });
285
+ const payload = JSON.stringify({ ...c, environment: String(row['environment']), fingerprint: learningFingerprint(repo, head, c.paths, seen), platform: process.platform, head });
277
286
  const finding = learningSha(JSON.stringify(c));
278
287
  store.handle.prepare("INSERT OR IGNORE INTO project_lesson(repo,identity,source,reviewer,finding,payload,sha,status) VALUES (?,?,?,?,?,?,?,'proposed')").run(repo, identity, source, reviewer, finding, payload, learningSha(payload));
279
288
  const id = Number(store.handle.prepare('SELECT id FROM project_lesson WHERE source=? AND finding=?').get(source, finding)['id']);
@@ -302,15 +311,15 @@ function lessonOf(row) {
302
311
  parseLearning([payload]);
303
312
  return { id: Number(row['id']), repo: String(row['repo']), source: Number(row['source']), reviewer: Number(row['reviewer']), status: String(row['status']), version: Number(row['version']), payload, sha: String(row['sha']), identity: String(row['identity']), adoptedBy: row['adopted_by'] == null ? null : String(row['adopted_by']) };
304
313
  }
305
- function supported(store, root, lesson) {
306
- if (lesson.identity !== learningIdentity(lesson.repo))
314
+ function supported(store, root, lesson, seen) {
315
+ if (lesson.identity !== learningIdentity(lesson.repo, seen))
307
316
  throw new Error('Project identity changed.');
308
317
  const capture = store.handle.prepare('SELECT catalog FROM learning_capture WHERE source=? AND reviewer=? AND repo=?').get(lesson.source, lesson.reviewer, lesson.repo);
309
318
  if (!capture || reviewedSource(store, lesson.reviewer) !== lesson.source)
310
319
  throw new Error('Learning source is unavailable.');
311
320
  verifyEvidence(store, root, lesson.source, lesson.payload.evidence, JSON.parse(String(capture['catalog'])));
312
321
  const run = store.getRun(lesson.source);
313
- if ((run?.headRevision ?? run?.baseRevision) !== lesson.payload.head || learningFingerprint(lesson.repo, lesson.payload.head, lesson.payload.paths) !== lesson.payload.fingerprint)
322
+ if ((run?.headRevision ?? run?.baseRevision) !== lesson.payload.head || learningFingerprint(lesson.repo, lesson.payload.head, lesson.payload.paths, seen) !== lesson.payload.fingerprint)
314
323
  throw new Error('Learning source changed.');
315
324
  }
316
325
  export function learningView(store, root, repo, actor, before = 0) {
@@ -377,15 +386,17 @@ export function learningContext(store, root, runId, phase, now = new Date(), val
377
386
  const run = store.getRun(runId), ref = run && store.refForId(run.taskRef), repo = ref?.repo;
378
387
  if (!run || !repo)
379
388
  return '';
389
+ // One read of the repository per command for this whole pass, recovery included.
390
+ const seen = new Map();
380
391
  try {
381
- recoverLearning(store, root, repo, now);
392
+ recoverLearning(store, root, repo, now, seen);
382
393
  return store.transact(() => {
383
394
  if (!store.schemaCurrent() || run.outcome !== null || !['planner', 'builder', 'repair', 'reviewer'].includes(run.role))
384
395
  return '';
385
396
  const runner = store.getRunner(run.runner)?.runner;
386
397
  if (!runner || runner.retiredAt !== null || !runner.repos.includes(repo) || (phase === 'review') !== (run.role === 'reviewer') || (phase === 'plan') !== (run.role === 'planner'))
387
398
  return '';
388
- const identity = learningIdentity(repo);
399
+ const identity = learningIdentity(repo, seen);
389
400
  const existing = store.handle.prepare('SELECT * FROM learning_snapshot WHERE run=?').get(runId);
390
401
  if (existing) {
391
402
  if (existing['repo'] !== repo || existing['identity'] !== identity || learningSha(String(existing['payload'])) !== existing['sha'])
@@ -409,11 +420,11 @@ export function learningContext(store, root, runId, phase, now = new Date(), val
409
420
  const l = lessonOf(row);
410
421
  if (!store.handle.prepare("SELECT 1 FROM learning_event WHERE lesson=? AND action='adopt' AND actor=?").get(l.id, l.adoptedBy ?? '') || l.source === source?.id || l.payload.kind !== 'project' || !l.adoptedBy || !store.accountCanAccess(l.adoptedBy, repo) || store.accountOf(l.adoptedBy)?.role !== 'approver' || !l.payload.phases.includes(phase) || l.payload.platform !== process.platform || (!unscopedPlan && !l.payload.paths.some(p => paths.some(t => p === t || p.startsWith(t.replace(/\/$/, '') + '/')))))
411
422
  continue;
412
- supported(store, root, l);
413
- if (l.payload.environment !== environmentFingerprint(store, repo) || learningFingerprint(repo, head, l.payload.paths) !== l.payload.fingerprint)
423
+ supported(store, root, l, seen);
424
+ if (l.payload.environment !== environmentFingerprint(store, repo) || learningFingerprint(repo, head, l.payload.paths, seen) !== l.payload.fingerprint)
414
425
  continue;
415
426
  const cwd = run.worktree ?? repo;
416
- const dirty = git(cwd, ['status', '--porcelain', '--untracked-files=all']).split('\n').some(line => { const p = line.slice(3); return configPath(p) || l.payload.paths.includes(p); });
427
+ const dirty = git(cwd, ['status', '--porcelain', '--untracked-files=all'], seen).split('\n').some(line => { const p = line.slice(3); return configPath(p) || l.payload.paths.includes(p); });
417
428
  if (!dirty)
418
429
  eligible.push(l);
419
430
  }
package/dist/proof.d.ts CHANGED
@@ -266,7 +266,7 @@ export type VerifyCommandFacts = {
266
266
  configured: true;
267
267
  ran: false;
268
268
  attemptFailed: true;
269
- failure?: "spawn-failed" | "timed-out" | "dependency-missing" | "setup-stale" | "setup-failed" | "tracked-files-changed" | "setup-changed-files" | "checkout-moved" | "cleanliness-unavailable" | "dependency-still-missing" | "retry-spawn-failed" | "retry-timed-out" | "custody-lost";
269
+ failure?: "spawn-failed" | "timed-out" | "dependency-missing" | "setup-stale" | "setup-failed" | "own-install-failed" | "tracked-files-changed" | "setup-changed-files" | "checkout-moved" | "cleanliness-unavailable" | "dependency-still-missing" | "retry-spawn-failed" | "retry-timed-out" | "custody-lost";
270
270
  } | {
271
271
  configured: true;
272
272
  ran: true;
@@ -390,6 +390,8 @@ export type CriterionCoverage = {
390
390
  * context: eligible only with the same text, verified ancestry, and
391
391
  * byte-identical relevant code. Never a judgement on this run. */
392
392
  priorSupport: "eligible" | "invalid" | "none";
393
+ /** The change's images and other binary files in one line, when it has any. */
394
+ assets?: string;
393
395
  };
394
396
  /** v40: the three words an evidence reviewer can say about one signed
395
397
  * criterion, and nothing else — `cannot-tell` is a CORRECT answer whenever
package/dist/proof.js CHANGED
@@ -722,7 +722,7 @@ function assessCapturedEvidence(input) {
722
722
  id: criterion.id, statement: criterion.statement, requiredEvidence: criterion.evidence,
723
723
  assessment: { evidenceState, detail }, state: evidenceState === "pass" ? "missing" : evidenceState,
724
724
  detail: [...detail, `criterion "${criterion.id}" awaits independent goal assessment`], answered, review: null,
725
- ...(coverage === undefined ? {} : { coverage: { state: coverage.state, inherited: coverage.inherited, items: [...coverage.items], gaps: [...coverage.gaps], priorSupport: coverage.priorSupport } }),
725
+ ...(coverage === undefined ? {} : { coverage: { state: coverage.state, inherited: coverage.inherited, items: [...coverage.items], gaps: [...coverage.gaps], priorSupport: coverage.priorSupport, ...(coverage.assets === undefined ? {} : { assets: coverage.assets }) } }),
726
726
  };
727
727
  });
728
728
  return { machineVerdict, verdict: machineVerdict === "refuted" ? "refuted" : "short", matrix,
@@ -761,6 +761,8 @@ function verificationFailureWords(failure) {
761
761
  return "automatic recovery stopped because the project setup or check changed";
762
762
  case "setup-failed":
763
763
  return "the approved setup command failed during automatic recovery";
764
+ case "own-install-failed":
765
+ return "the checkout's own dependency install failed after its package.json or lockfile changed";
764
766
  case "tracked-files-changed":
765
767
  return "automatic recovery stopped because tracked files no longer matched the built result";
766
768
  case "setup-changed-files":
@@ -828,7 +830,7 @@ export function adjudicate(input) {
828
830
  // must show, never a silent "judged from the patch".
829
831
  const coverage = found === undefined
830
832
  ? { state: "gap", inherited: false, items: [], gaps: ["the sealed review context carries no coverage entry for this criterion"], priorSupport: "none" }
831
- : { state: found.state, inherited: found.inherited, items: [...found.items], gaps: [...found.gaps], priorSupport: found.priorSupport };
833
+ : { state: found.state, inherited: found.inherited, items: [...found.items], gaps: [...found.gaps], priorSupport: found.priorSupport, ...(found.assets === undefined ? {} : { assets: found.assets }) };
832
834
  return { ...row, coverage };
833
835
  });
834
836
  if (!input.proofArtifactPresent) {
@@ -83,3 +83,12 @@ export declare function latestVersionNow(io?: {
83
83
  }): Promise<{
84
84
  version: string;
85
85
  }>;
86
+ /** What a release says about itself in one line ("Release 0.9.9: lighter tests, cleaner status"), when its notes
87
+ * have such a line; null otherwise. */
88
+ export declare function releaseSummary(notes: string, version: string): string | null;
89
+ /**
90
+ * A deploy says one thing in chat: "Toolroll 0.9.9 is live." (with the release's own one-line summary when its
91
+ * notes have one). Once per version, addressed to each instance operator, and only when a worker on this
92
+ * computer last ran an older version: a fresh install is not news. Call before recording this runner's version.
93
+ */
94
+ export declare function notifyVersionLive(store: Store, dir: string, version: string, now: Date): number;
package/dist/releases.js CHANGED
@@ -236,3 +236,33 @@ export async function latestVersionNow(io = {}) {
236
236
  throw new Error("npm did not name a version");
237
237
  return { version };
238
238
  }
239
+ /** What a release says about itself in one line ("Release 0.9.9: lighter tests, cleaner status"), when its notes
240
+ * have such a line; null otherwise. */
241
+ export function releaseSummary(notes, version) {
242
+ const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
243
+ const line = new RegExp(`^\\s*(?:#{1,6}\\s*)?(?:Toolroll\\s+)?(?:Release\\s+)?v?${escaped}\\s*[:—–-]\\s*(.+?)\\s*$`, "im").exec(notes);
244
+ const summary = line?.[1]?.replace(/[*_`]/g, "").replace(/\s+/g, " ").replace(/[.\s]+$/, "").trim() ?? "";
245
+ if (summary === "")
246
+ return null;
247
+ return summary.length <= 80 ? summary : `${summary.slice(0, 79).replace(/[\s,;:]+\S*$/, "")}…`;
248
+ }
249
+ /**
250
+ * A deploy says one thing in chat: "Toolroll 0.9.9 is live." (with the release's own one-line summary when its
251
+ * notes have one). Once per version, addressed to each instance operator, and only when a worker on this
252
+ * computer last ran an older version: a fresh install is not news. Call before recording this runner's version.
253
+ */
254
+ export function notifyVersionLive(store, dir, version, now) {
255
+ const before = runnerVersions(dir);
256
+ if (!VERSION.test(version) || !before.some(one => isNewer(version, one.version)) || before.some(one => isNewer(one.version, version)))
257
+ return 0;
258
+ const cached = cachedRelease(dir);
259
+ const summary = cached?.version === version ? releaseSummary(cached.notes, version) : null;
260
+ const line = summary === null ? `Toolroll ${version} is live.` : `Toolroll ${version} is live: ${summary}.`;
261
+ let queued = 0;
262
+ for (const recipient of store.accountFacts().map(one => one.name).filter(name => store.isInstanceOperator(name))) {
263
+ if (store.enqueueNotification({ dedupeKey: `release:live:${version}:${recipient}`, kind: "version-live", subject: line, body: "",
264
+ recipient, source: { installation: true } }, now))
265
+ queued++;
266
+ }
267
+ return queued;
268
+ }
@@ -6,6 +6,10 @@
6
6
  import type { Store } from "./store.js";
7
7
  /** The ledger action a person's (or lead's) completion of a result writes. */
8
8
  export declare const COMPLETION_ACTION = "assignment handoff checked";
9
+ /** Whether a task's family is marked complete. Completion is recorded on the
10
+ * family's root against its current version's latest result, so the root, a
11
+ * superseded revision and the current version all read Complete together. */
12
+ export declare function familyCompleted(store: Store, taskId: string): boolean;
9
13
  /** The files a build changed, renames on both sides; null when the inventory can't be read whole. */
10
14
  export declare function changedFilesOf(store: Store, runId: number, root: string): string[] | null;
11
15
  /** Everything a result's branch carries: this build's changes and every build of every task it revises (a
@@ -1,6 +1,17 @@
1
1
  import { readVerifiedArtifact } from "./evidence.js";
2
2
  /** The ledger action a person's (or lead's) completion of a result writes. */
3
3
  export const COMPLETION_ACTION = "assignment handoff checked";
4
+ /** Whether a task's family is marked complete. Completion is recorded on the
5
+ * family's root against its current version's latest result, so the root, a
6
+ * superseded revision and the current version all read Complete together. */
7
+ export function familyCompleted(store, taskId) {
8
+ const family = store.taskFamilyOf(taskId, null, true);
9
+ return store.handle.prepare(`SELECT 1 AS hit FROM action_ledger INDEXED BY work_completion
10
+ WHERE task_id IN (?, ?) AND action = ? AND source = 'work'
11
+ AND run_id = (SELECT run.id FROM run INDEXED BY work_result WHERE run.task_ref = ?
12
+ AND run.finished_at IS NOT NULL AND run.role IN ('builder','scout') ORDER BY run.id DESC LIMIT 1) LIMIT 1`)
13
+ .get(taskId, family?.root.id ?? taskId, COMPLETION_ACTION, family?.current.refId ?? store.lookupRef(taskId)?.id ?? -1) !== undefined;
14
+ }
4
15
  /** The files a build changed, renames on both sides; null when the inventory can't be read whole. */
5
16
  export function changedFilesOf(store, runId, root) {
6
17
  const stat = store.artifactsFor(runId).find(one => one.kind === "diff-stat");
@@ -121,6 +121,9 @@ export type CriterionContextCoverage = {
121
121
  items: string[];
122
122
  gaps: string[];
123
123
  priorSupport: "eligible" | "invalid" | "none";
124
+ /** One line for the change's images and other binary files, which are
125
+ * never read as text and so are neither items nor gaps. */
126
+ assets?: string;
124
127
  };
125
128
  export type ReviewContextInventory = {
126
129
  schema: 1 | 2 | 3;