toolroll 0.9.8 → 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 (69) hide show
  1. package/CHANGELOG.md +22 -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 +39 -7
  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/check-progress.js +4 -3
  18. package/dist/checkout-cleanup.d.ts +40 -2
  19. package/dist/checkout-cleanup.js +129 -9
  20. package/dist/desktop-update.d.ts +52 -0
  21. package/dist/desktop-update.js +147 -5
  22. package/dist/evidence.d.ts +6 -0
  23. package/dist/evidence.js +8 -0
  24. package/dist/first-run.d.ts +1 -1
  25. package/dist/first-run.js +7 -5
  26. package/dist/lead-context.d.ts +1 -1
  27. package/dist/lead-context.js +4 -2
  28. package/dist/lead-status.d.ts +10 -1
  29. package/dist/lead-status.js +21 -24
  30. package/dist/needs-you.d.ts +55 -0
  31. package/dist/needs-you.js +83 -0
  32. package/dist/operate.d.ts +2 -0
  33. package/dist/operate.js +112 -14
  34. package/dist/plane-review.js +4 -0
  35. package/dist/process-recovery-eligibility.d.ts +1 -0
  36. package/dist/project-learning.d.ts +3 -3
  37. package/dist/project-learning.js +28 -17
  38. package/dist/proof.d.ts +3 -1
  39. package/dist/proof.js +4 -2
  40. package/dist/result-completion.d.ts +4 -0
  41. package/dist/result-completion.js +11 -0
  42. package/dist/review-context.d.ts +3 -0
  43. package/dist/review-context.js +45 -5
  44. package/dist/run-closeout.d.ts +89 -0
  45. package/dist/run-closeout.js +302 -0
  46. package/dist/serve.d.ts +15 -1
  47. package/dist/serve.js +129 -14
  48. package/dist/setup-guide.d.ts +2 -2
  49. package/dist/shared-deps.d.ts +94 -0
  50. package/dist/shared-deps.js +365 -0
  51. package/dist/storage.js +2 -0
  52. package/dist/store.d.ts +36 -0
  53. package/dist/store.js +125 -29
  54. package/dist/task-status.d.ts +18 -4
  55. package/dist/task-status.js +35 -18
  56. package/dist/telegram-progress.js +2 -1
  57. package/dist/telegram-status.js +3 -3
  58. package/dist/test-temp.d.ts +19 -0
  59. package/dist/test-temp.js +98 -0
  60. package/dist/toolroll-update-ui.js +7 -2
  61. package/dist/toolroll-update.d.ts +21 -1
  62. package/dist/toolroll-update.js +135 -5
  63. package/dist/work-index.d.ts +11 -0
  64. package/dist/work-index.js +103 -16
  65. package/dist/work-summary.d.ts +3 -1
  66. package/dist/workspace-ui.js +1 -1
  67. package/dist/worktree.d.ts +16 -0
  68. package/dist/worktree.js +47 -0
  69. package/package.json +1 -1
@@ -0,0 +1,83 @@
1
+ /** Every reason a task, result or flow card can wait on a person, in one
2
+ * place: the plain sentence it shows and the one action that resolves it
3
+ * (task-status.ts reads it for every surface). A reason no person can act
4
+ * on is not Needs you: it is a Waiting reason, worded as what it waits for.
5
+ *
6
+ * The words never name the machine's internals; `plainSentence` keeps a
7
+ * recorded reason only when it is free of them. */
8
+ const always = () => true;
9
+ const build = (context) => context.build == null ? "the last build" : `build #${context.build}`;
10
+ const Build = (context) => context.build == null ? "The last build" : `Build #${context.build}`;
11
+ export const NEEDS = {
12
+ approval: { sentence: () => "Review the plan and approve it to start.", action: { code: "approve-scope", label: "Approve plan" } },
13
+ card: { sentence: () => "This card is waiting for your approval.", action: { code: "approve-card", label: "Approve card" } },
14
+ answer: { sentence: () => "Answer the question so the work can continue.", action: { code: "answer-decision", label: "Answer the question" }, useReason: always },
15
+ questions: { sentence: () => "Too many questions are open. Answer one so this task can start.", action: { code: "inspect-decisions", label: "Answer questions" } },
16
+ "sign-in": { sentence: context => `${context.provider ?? "Your agent"} needs you to sign in again. The task starts on its own after.`, action: { code: "sign-in", label: "Sign in again" }, useReason: reason => /sign in|API key/i.test(reason) },
17
+ "confirm-stopped": { sentence: context => `Toolroll can't confirm ${build(context)} stopped. Nothing from it is running.`, action: { code: "confirm-stopped", label: "Confirm it stopped" } },
18
+ // Toolroll can't look at all: never "nothing is running"; the person checks, says so, then confirms.
19
+ "check-stopped": { sentence: context => `Toolroll can't check whether ${build(context)} stopped. Make sure nothing from it is running, then confirm.`, action: { code: "confirm-stopped", label: "Confirm it stopped" } },
20
+ hold: { sentence: () => "A hold is keeping this task from starting. Review it to release it.", action: { code: "inspect-hold", label: "Review hold" } },
21
+ "choose-project": { sentence: () => "Choose a project so a builder can start.", action: { code: "place-task", label: "Choose a project" } },
22
+ "define-task": { sentence: () => "Say what the task should do so it can be planned.", action: { code: "write-scope", label: "Define the task" } },
23
+ "fix-request": { sentence: () => "The request can't be planned as written. Edit it so the planner can start.", action: { code: "write-scope", label: "Edit the request" } },
24
+ "choose-agent": { sentence: () => "Choose an agent to build this.", action: { code: "select-agent", label: "Choose an agent" } },
25
+ "fix-dependency": { sentence: () => "A task this one waits on didn't finish. Fix or remove it so this one can start.", action: { code: "repair-dependency", label: "Fix the required task" },
26
+ useReason: reason => /didn't finish|did not finish|before it finished/i.test(reason), withReason: reason => `${reason.replace(/[.\s]*$/, ".")} Fix or remove it so this one can start.` },
27
+ "add-requirement": { sentence: () => "This project is missing something the task needs. Add it so a builder can start.", action: { code: "repair-capability", label: "Add the requirement" } },
28
+ "connect-builder": { sentence: () => "No builder works on this project yet. Connect one so the task can start.", action: { code: "start-worker", label: "Connect a builder" } },
29
+ "start-builder": { sentence: () => "The builder for this project is offline. Start it and the task begins on its own.", action: { code: "start-worker", label: "Start the builder" } },
30
+ vanished: { sentence: context => `${Build(context)} stopped without finishing. Check it, then retry.`, action: { code: "reconcile-run", label: "Check the build" } },
31
+ "review-result": { sentence: () => "Check the result, then accept it or ask for changes.", action: { code: "open-result", label: "Review result" } },
32
+ // Accepting it would not help: it was built to terms that are no longer the plan.
33
+ rebuild: { sentence: context => `${Build(context)} was made to an earlier plan. Build it again to the current plan.`, action: { code: "retry-task", label: "Build again" } },
34
+ "earlier-version": { sentence: () => "An earlier version of this task still needs you. Finish it first.", action: { code: "inspect-task", label: "Open earlier version" } },
35
+ other: { sentence: () => "Open the task to see what it needs from you.", action: { code: "inspect-task", label: "Open the task" }, useReason: always },
36
+ };
37
+ export const WAITS = {
38
+ "build-stopping": context => `Waiting for ${build(context)} to stop. Nothing is needed from you.`,
39
+ "other-computer": context => `Waiting for the computer that ran ${build(context)} to confirm it stopped.`,
40
+ "card-reply": () => "Waiting for a reply.",
41
+ "card-time": () => "Waiting before moving on.",
42
+ "card-ci": () => "Waiting for the pull request's checks.",
43
+ };
44
+ /** Words that name the machine's internals; no Needs you or Waiting sentence carries them. */
45
+ export const BANNED_WORDS = ["witness", "unproven", "quiescence", "quiescent", "digest", "scope digest"];
46
+ const BANNED = new RegExp(`\\b(?:${BANNED_WORDS.join("|")})\\b`, "i");
47
+ export const hasInternalWords = (text) => BANNED.test(text);
48
+ /** The sentence for a need: its recorded reason when the need keeps one and
49
+ * it reads plainly, otherwise the need's own words. */
50
+ export function needSentence(key, reason, context = {}) {
51
+ const need = NEEDS[key];
52
+ const plain = reason?.trim() || null;
53
+ if (need.useReason === undefined || plain === null || !need.useReason(plain) || hasInternalWords(plain))
54
+ return need.sentence(context);
55
+ return need.withReason === undefined ? plain : need.withReason(plain);
56
+ }
57
+ export function waitSentence(key, reason, context = {}) {
58
+ if (key !== undefined)
59
+ return WAITS[key](context);
60
+ const plain = reason?.trim() || null;
61
+ return plain !== null && !hasInternalWords(plain) ? plain : "Waiting for the work to continue on its own.";
62
+ }
63
+ /** A finished run Toolroll can't prove stopped (store.stopQuiescenceFact):
64
+ * a person can confirm it only when nothing of it may still be running.
65
+ * `unknown`: Toolroll can't check, so the person confirms they checked. */
66
+ export function processNeedOf(fact) {
67
+ if (fact === null)
68
+ return null;
69
+ if (fact.kind === "unprovable")
70
+ return { need: "confirm-stopped", build: fact.run };
71
+ if (fact.kind === "unknown")
72
+ return { need: "check-stopped", build: fact.run };
73
+ return { wait: fact.kind === "elsewhere" ? "other-computer" : "build-stopping", build: fact.run };
74
+ }
75
+ /** Dispatch and work-index codes that wait on a person, as their need. */
76
+ export const CODE_NEED = {
77
+ "signed-out": "sign-in", "needs-approval": "approval", "waiting-decision": "answer", "decision-queue": "answer",
78
+ "needs-project": "choose-project", "needs-scope": "define-task", "planner-source": "fix-request", "needs-agent-profile": "choose-agent",
79
+ "terminal-dependency": "fix-dependency", "missing-requirement": "add-requirement", "no-worker-registered": "connect-builder",
80
+ "no-worker-online": "start-builder", "vanished-run": "vanished", "needs-verification": "review-result", "proof-refuted": "review-result",
81
+ "no-build-record": "review-result", "verification-needed": "review-result", "evidence-mismatch": "review-result",
82
+ "record-incomplete": "review-result", "evidence-damaged": "review-result",
83
+ };
package/dist/operate.d.ts CHANGED
@@ -37,6 +37,8 @@ export type OperateOptions = {
37
37
  releaseIo?: ReleaseIo;
38
38
  /** Injected by tests: the bin whose real path says how Toolroll was installed. */
39
39
  installBin?: string;
40
+ /** Injected by tests: where `storage` looks for leftover test temp folders (default: the temp folder and /tmp). */
41
+ tempRoots?: readonly string[];
40
42
  openDatabase?: (file: string) => Store;
41
43
  now?: Date;
42
44
  /**
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,7 +197,7 @@ 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";
200
+ import { installationStatus, renderInstallationStatus, renderTaskWait, repairStaleStatuses, taskWaitSnapshot } from "./lead-status.js";
197
201
  import { latestRelease, newerRelease, notifySecurityRelease, recordRunnerVersion, startUpdateChecks, updateLine } from "./releases.js";
198
202
  import { installMethod } from "./install-method.js";
199
203
  import { PACKAGE_VERSION } from "./version.js";
@@ -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;
@@ -7296,6 +7326,14 @@ async function runWatchLoop(args) {
7296
7326
  store.startWatchEpisode({ repo, runner, incarnation }, new Date());
7297
7327
  recordRunnerVersion(dirname(context.databaseFile), runner, PACKAGE_VERSION, new Date());
7298
7328
  args.onReady?.();
7329
+ // Once per worker start, before any pass: statuses an older build left
7330
+ // stale (unrecorded exits of finished runs) are re-derived and clear.
7331
+ if (!staleStatusesRepaired) {
7332
+ staleStatusesRepaired = true;
7333
+ const repaired = repairStaleStatuses(store, new Date());
7334
+ if (repaired.exitsRecorded > 0)
7335
+ progress(`watch: recorded ${repaired.exitsRecorded} finished process exit(s) left open by an earlier version`);
7336
+ }
7299
7337
  // A false renewal is FATAL (arc 2 finding 15): the lease or the
7300
7338
  // credential is gone, and admitting one more pass would be work done for
7301
7339
  // an authority this process no longer holds.
@@ -7792,7 +7830,8 @@ async function watchCommand(flags, context) {
7792
7830
  token,
7793
7831
  repo,
7794
7832
  progress,
7795
- isStopping: () => stopping,
7833
+ // An injected fence (tests) stops admission exactly as a signal does.
7834
+ isStopping: () => stopping || context.shouldStop?.() === true,
7796
7835
  onFollowController: controller => {
7797
7836
  followAbort = controller;
7798
7837
  },
@@ -8836,6 +8875,10 @@ async function bridgeCommand(positional, flags, context) {
8836
8875
  const runFor = text(flags, "for");
8837
8876
  const timer = runFor === undefined ? null : setTimeout(() => controller.abort(), Number(runFor));
8838
8877
  timer?.unref?.();
8878
+ // An injected fence (tests) ends the follow exactly as a signal does.
8879
+ const fence = context.shouldStop === undefined ? null : setInterval(() => { if (context.shouldStop?.() === true)
8880
+ controller.abort(); }, 50);
8881
+ fence?.unref?.();
8839
8882
  if (!json)
8840
8883
  write(`Following bot ${source.botId} — taps apply as they arrive. Ctrl-C stops it.`);
8841
8884
  try {
@@ -8865,6 +8908,8 @@ async function bridgeCommand(positional, flags, context) {
8865
8908
  finally {
8866
8909
  if (timer !== null)
8867
8910
  clearTimeout(timer);
8911
+ if (fence !== null)
8912
+ clearInterval(fence);
8868
8913
  process.removeListener("SIGINT", stop);
8869
8914
  process.removeListener("SIGTERM", stop);
8870
8915
  }
@@ -9528,10 +9573,13 @@ async function statusCommand(positional, flags, context) {
9528
9573
  const brokenIntegrations = await savedIntegrations(context).then(list => list.filter(one => one.state === "broken"), () => []);
9529
9574
  const brokenLine = integrationsBrokenLine(brokenIntegrations);
9530
9575
  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 })) } };
9576
+ // An update waiting, on what, and the action: before anything else, as it holds up new work.
9577
+ const waiting = waitingUpdate(context.databaseFile, run => context.store.stopQuiescenceProblem(run) !== null, context.clock());
9578
+ const updateWaiting = waiting === null ? {} : { updateWaiting: { app: waiting.app, version: waiting.version, stopped: waiting.stopped, run: waiting.run, on: waiting.on, action: waiting.action } };
9531
9579
  const projects = projectBuildsStatus(context);
9532
9580
  const projectsLine = projects.length === 0 ? null
9533
9581
  : `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])]);
9582
+ 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
9583
  }
9536
9584
  /** Each known project's running builds against its limit: its own number, never past its workers' capacity. */
9537
9585
  function projectBuildsStatus(context) {
@@ -12145,13 +12193,35 @@ async function storageCommand(positional, flags, context) {
12145
12193
  `A finished task's clean checkout is removed ${cleanupWords(plan.cleanup)}; its branch stays.`,
12146
12194
  ];
12147
12195
  const itemLine = (one) => ` ${bytesWords(one.bytes).padStart(8)} ${one.path}${one.why === null ? "" : ` (${whyWords(one)})`}`;
12196
+ // Test temp folders the tests, journeys and scripts left (a killed run, an older version): those nothing touched for a day go.
12197
+ const roots = context.tempRoots ?? tempRoots();
12198
+ // Counting is quick; sizing thousands of folders isn't, so only a clean-up measures them.
12199
+ const leftovers = (measure) => {
12200
+ const found = testTempFolders(roots, context.clock());
12201
+ const sizes = measure ? diskBytes(found.stale.map(one => one.path)) : new Map();
12202
+ return { count: found.all.length, stale: found.stale.length, staleBytes: [...sizes.values()].reduce((sum, one) => sum + one, 0) };
12203
+ };
12204
+ const leftoverLine = (temp) => temp.count === 0 ? "Test temp folders: none." :
12205
+ `Test temp folders: ${temp.count}; ${temp.stale === 0 ? "none older than a day." : `${temp.stale} older than a day: toolroll storage clean removes them.`}`;
12206
+ // Shared dependencies (shared-deps.ts): one install per lockfile, linked into checkouts.
12207
+ const depsRoot = sharedDepsRoot(context.databaseFile);
12208
+ const checkoutRows = () => store.listWorktrees().map(row => ({ path: row.path, repo: row.repo }));
12209
+ const sharedFacts = (copies) => {
12210
+ const sizes = diskBytes(copies.map(one => one.dir));
12211
+ 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 }));
12212
+ };
12213
+ 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
12214
  if (action === undefined) {
12149
12215
  const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
12150
12216
  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) }, () => [
12217
+ const { staleBytes: _, ...temp } = leftovers(false);
12218
+ const shared = sharedFacts(sharedUse(depsRoot, checkoutRows()));
12219
+ return succeed(context.write, context.json, "storage", { ...report, checkouts: checkoutSummary(plan), testTemp: temp, shared }, () => [
12152
12220
  `${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}`}`),
12221
+ ...report.lines.map(one => ` ${one.what.padEnd(19)} ${bytesWords(one.bytes).padStart(8)}${one.count === undefined ? "" : ` (${one.count})`}${one.note === undefined ? "" : ` ${one.note}`}`),
12222
+ ...(shared.length === 0 ? [] : ["Shared dependencies:", ...shared.map(sharedLine)]),
12154
12223
  ...summary(plan),
12224
+ leftoverLine({ ...temp, staleBytes: 0 }),
12155
12225
  ]);
12156
12226
  }
12157
12227
  if (action === "cleanup") {
@@ -12173,20 +12243,48 @@ async function storageCommand(positional, flags, context) {
12173
12243
  return fail(context.write, context.json, command, "usage", "Use storage clean to preview, then storage clean --yes.", EXIT.usage);
12174
12244
  if (!flags.has("yes")) {
12175
12245
  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)]),
12246
+ const temp = leftovers(true);
12247
+ // A shared copy goes when no checkout uses it, counting the checkouts this clean-up would remove.
12248
+ const leaving = new Set(plan.go.map(one => one.path));
12249
+ const unused = unusedShared(depsRoot, checkoutRows().filter(row => !leaving.has(row.path)), context.clock());
12250
+ const sharedGo = sharedFacts(unused.copies);
12251
+ const sharedBytes = sharedGo.reduce((sum, one) => sum + one.bytes, 0);
12252
+ const anything = plan.go.length > 0 || temp.stale > 0 || sharedGo.length > 0;
12253
+ return succeed(context.write, context.json, command, { preview: true, go: plan.go, stay: plan.stay, freeBytes: plan.freeBytes, testTemp: temp, sharedGo, sharedFreeBytes: sharedBytes }, () => [
12254
+ ...(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)]),
12255
+ ...(sharedGo.length === 0 ? [] : [`Would remove ${sharedGo.length} shared dependency install${sharedGo.length === 1 ? "" : "s"} no checkout uses, about ${bytesWords(sharedBytes)}:`, ...sharedGo.map(sharedLine)]),
12178
12256
  ...(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.",
12257
+ ...(temp.stale === 0 ? [] : [`Would remove ${temp.stale} test temp folder${temp.stale === 1 ? "" : "s"} older than a day, about ${bytesWords(temp.staleBytes)}.`]),
12258
+ anything ? "Nothing was removed. Run toolroll storage clean --yes to remove them." : "Nothing was removed.",
12180
12259
  ]);
12181
12260
  }
12182
12261
  const actor = await operator();
12183
12262
  if (actor === null)
12184
12263
  return refused("cleans up checkouts");
12185
12264
  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.`,
12265
+ const temp = leftovers(true);
12266
+ const swept = removeStaleTestTemp(roots, context.clock());
12267
+ if (swept.removed.length > 0) {
12268
+ store.recordAction({ at: context.clock().toISOString(), actor, repo: null, taskId: null, runId: null, action: "test temp folders removed", outcome: "removed", source: "request",
12269
+ detail: `${swept.removed.length} older than a day, about ${bytesWords(temp.staleBytes)}${swept.failed.length === 0 ? "" : `; ${swept.failed.length} couldn't be removed`}` });
12270
+ }
12271
+ // After the checkouts go: a shared copy none of the remaining ones uses goes too.
12272
+ const unused = unusedShared(depsRoot, checkoutRows(), context.clock());
12273
+ const sharedRemoved = sharedFacts(unused.copies).filter(one => removeShared(one.path));
12274
+ for (const one of sharedRemoved) {
12275
+ store.recordAction({ at: context.clock().toISOString(), actor, repo: one.repo, taskId: null, runId: null, action: "shared dependencies removed", outcome: "removed", source: "request",
12276
+ detail: `${one.key} (about ${bytesWords(one.bytes)}); no checkout used it` });
12277
+ }
12278
+ for (const debris of unused.debris)
12279
+ removeShared(debris);
12280
+ const sharedFreed = sharedRemoved.reduce((sum, one) => sum + one.bytes, 0);
12281
+ 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 }, () => [
12282
+ 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
12283
  ...done.removed.map(itemLine),
12284
+ ...(sharedRemoved.length === 0 ? [] : [`Removed ${sharedRemoved.length} shared dependency install${sharedRemoved.length === 1 ? "" : "s"} no checkout used, about ${bytesWords(sharedFreed)}.`, ...sharedRemoved.map(sharedLine)]),
12189
12285
  ...done.kept.map(one => ` kept ${one.path} (${whyWords({ why: one.why })})`),
12286
+ ...(swept.removed.length === 0 ? [] : [`Removed ${swept.removed.length} test temp folder${swept.removed.length === 1 ? "" : "s"} older than a day, about ${bytesWords(temp.staleBytes)}.`]),
12287
+ ...(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
12288
  ]);
12191
12289
  }
12192
12290
  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;
@@ -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) {
@@ -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