toolroll 0.9.7 → 0.9.8

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.
package/dist/dispose.js CHANGED
@@ -17,7 +17,9 @@ import { createHash } from "node:crypto";
17
17
  import { completeFenced, finalizeFailureFenced, finalizeHandBackFenced, finalizeMalformedFenced, finalizeParkFenced, interruptIfStopped, release, } from "./claim.js";
18
18
  import { bodyHashOf, publicationBody } from "./publish.js";
19
19
  import { modeTermsFromJson } from "./modes.js";
20
- import { readVerifiedArtifact, writeEvidenceFile } from "./evidence.js";
20
+ import { evidenceRoot, readVerifiedArtifact, writeEvidenceFile } from "./evidence.js";
21
+ import { queueBuildReview } from "./build-review.js";
22
+ import { homedir } from "node:os";
21
23
  import {} from "./proof.js";
22
24
  import { verificationEvidence, failedVerificationEvidence } from "./verification-evidence.js";
23
25
  import { classifyGateFailure, describeGateFailure, gateFailureSummary } from "./gate-failure.js";
@@ -55,9 +57,14 @@ const STANDALONE_BROKE_REASONS = new Set([
55
57
  "timeout",
56
58
  "git",
57
59
  ]);
58
- /** Compatibility for historical integrations: finished work is handed to the
59
- * lead. No model review is requested and no signed history is rewritten. */
60
- export function maybeRequestAutoReview(_store, _repo, _runId, _committed, _noChange, _now) { }
60
+ /** A committed build whose approved check passed gets the project's one
61
+ * automatic review when its review switch is on (build-review.ts). No-change
62
+ * results and failed checks go straight to the person, as before. */
63
+ export function maybeRequestAutoReview(store, repo, runId, committed, noChange, now, root = evidenceRoot(homedir())) {
64
+ if (!committed || noChange)
65
+ return;
66
+ queueBuildReview(store, repo, runId, root, now);
67
+ }
61
68
  export function disposeBuildOutcome(context, result) {
62
69
  // Stop and every terminal side effect compete under the same SQLite
63
70
  // write lock. A separate preliminary transaction leaves a completion gap.
@@ -238,6 +245,7 @@ function disposeBuildOutcomeLocked(context, result) {
238
245
  // proves the credential, clearing any quota stamp.
239
246
  store.resetStrikes(taskRef);
240
247
  store.clearQuota(runner, provider, model ?? "");
248
+ maybeRequestAutoReview(store, repo, runId, result.committed, result.noChange === true, clock(), context.evidenceRoot);
241
249
  return { kind: "built", committed: result.committed, noChange: result.noChange === true };
242
250
  }
243
251
  store.transact(() => {
package/dist/exec.d.ts CHANGED
@@ -113,6 +113,11 @@ export type RunOptions = ProcessTreeObserver & {
113
113
  /** Fires once the child exists, with its pid (the process-group id when
114
114
  * processGroup is set) — the slot ledger records it (v14 finding 26). */
115
115
  onSpawn?: (pid: number) => void;
116
+ /** Fires instead of onSpawn when a spawn that beforeSpawn admitted made no
117
+ * process at all (the OS call threw — ENOENT, EAGAIN — returned no pid, or
118
+ * its OS object could not be made), so its reserved witness can be settled
119
+ * as never started in the same step. Never fires once a child exists. */
120
+ onSpawnFailed?: () => void;
116
121
  /** Fires before any target spawn, so a crash cannot lose its OS object. */
117
122
  onContainer?: (info: {
118
123
  backend: ContainmentBackendId;
package/dist/exec.js CHANGED
@@ -84,9 +84,24 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
84
84
  throw new ContainmentRefusal(effective.refusal);
85
85
  if (bag.beforeSpawn?.() === false)
86
86
  throw new Error("the attempt stopped before this process could spawn");
87
- const made = effective === null ? { container: null } : createContainer(effective, label);
88
- if ("refused" in made)
87
+ // From here a witness may be reserved: every road that makes no process
88
+ // says so through onSpawnFailed before it returns or throws.
89
+ const neverStarted = () => { try {
90
+ bag.onSpawnFailed?.();
91
+ }
92
+ catch { /* the reservation stays unproven */ } };
93
+ let made;
94
+ try {
95
+ made = effective === null ? { container: null } : createContainer(effective, label);
96
+ }
97
+ catch (error) {
98
+ neverStarted();
99
+ throw error;
100
+ }
101
+ if ("refused" in made) {
102
+ neverStarted();
89
103
  throw new ContainmentRefusal(made.refused);
104
+ }
90
105
  const container = made.container;
91
106
  try {
92
107
  if (container !== null)
@@ -94,6 +109,7 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
94
109
  }
95
110
  catch (error) {
96
111
  container?.release();
112
+ neverStarted();
97
113
  throw error;
98
114
  }
99
115
  let launch;
@@ -102,6 +118,7 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
102
118
  }
103
119
  catch (error) {
104
120
  container?.release();
121
+ neverStarted();
105
122
  throw error;
106
123
  }
107
124
  let child;
@@ -123,6 +140,7 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
123
140
  }
124
141
  catch { }
125
142
  container?.release();
143
+ neverStarted();
126
144
  throw error;
127
145
  }
128
146
  if (container !== null) {
@@ -143,6 +161,12 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
143
161
  if (child.pid !== undefined) {
144
162
  bag.onSpawn?.(child.pid);
145
163
  }
164
+ else if (container === null) {
165
+ // No pid: the OS refused the spawn (ENOENT, EACCES) and only an
166
+ // 'error' event follows. A contained spawn settles once its object is
167
+ // proven empty, and its transport's return finishes the witness.
168
+ neverStarted();
169
+ }
146
170
  }
147
171
  catch (error) {
148
172
  throw new SpawnCustodyFailure(child, error);
@@ -201,7 +225,7 @@ function containmentOf(container, empty, attachFailure) {
201
225
  }
202
226
  /** The streaming transports' spawn: contained when the spawn is a provider (process group), plain otherwise. */
203
227
  function spawnStream(file, args, options, stdio, childEnv) {
204
- return spawnContained(file, args, { cwd: options.cwd, env: childEnv, stdio, detached: options.processGroup === true && process.platform !== "win32" }, options.owner ?? "stream", { beforeSpawn: options.beforeSpawn, onSpawn: options.onSpawn, onContainer: options.onContainer, onContainerEmpty: options.onContainerEmpty, onUnknown: options.onUnknown, fence: options.fence }, options.processGroup === true);
228
+ return spawnContained(file, args, { cwd: options.cwd, env: childEnv, stdio, detached: options.processGroup === true && process.platform !== "win32" }, options.owner ?? "stream", { beforeSpawn: options.beforeSpawn, onSpawn: options.onSpawn, onSpawnFailed: options.onSpawnFailed, onContainer: options.onContainer, onContainerEmpty: options.onContainerEmpty, onUnknown: options.onUnknown, fence: options.fence }, options.processGroup === true);
205
229
  }
206
230
  /** The refusal-before-spawn shape shared by every containable transport. */
207
231
  function refusedResult(error) {
@@ -506,6 +530,7 @@ export function run(file, args, options = {}) {
506
530
  ...(cwd === undefined ? {} : { cwd }),
507
531
  ...(childEnv === undefined ? {} : { childEnv }),
508
532
  ...(options.onSpawn === undefined ? {} : { onSpawn: options.onSpawn }),
533
+ ...(options.onSpawnFailed === undefined ? {} : { onSpawnFailed: options.onSpawnFailed }),
509
534
  ...(options.owner === undefined ? {} : { owner: options.owner }),
510
535
  ...(options.beforeSpawn === undefined ? {} : { beforeSpawn: options.beforeSpawn }),
511
536
  ...(options.onStdout === undefined ? {} : { onStdout: options.onStdout }),
@@ -553,7 +578,7 @@ function runBufferedGroup(file, args, bag) {
553
578
  let container = null;
554
579
  let attached;
555
580
  try {
556
- const spawned = spawnContained(file, args, { cwd: bag.cwd, env: bag.childEnv, stdio: [bag.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], detached: process.platform !== "win32" }, bag.owner ?? "buffered", { beforeSpawn: bag.beforeSpawn, onSpawn: bag.onSpawn, onContainer: bag.onContainer, onContainerEmpty: bag.onContainerEmpty, onUnknown: bag.onUnknown, fence: bag.fence });
581
+ const spawned = spawnContained(file, args, { cwd: bag.cwd, env: bag.childEnv, stdio: [bag.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], detached: process.platform !== "win32" }, bag.owner ?? "buffered", { beforeSpawn: bag.beforeSpawn, onSpawn: bag.onSpawn, onSpawnFailed: bag.onSpawnFailed, onContainer: bag.onContainer, onContainerEmpty: bag.onContainerEmpty, onUnknown: bag.onUnknown, fence: bag.fence });
557
582
  child = spawned.child;
558
583
  container = spawned.container;
559
584
  attached = spawned.attached;
@@ -1578,6 +1603,7 @@ export function startClaudeHeldSession(file, args, options) {
1578
1603
  }, options.owner ?? "held", {
1579
1604
  beforeSpawn: options.beforeSpawn,
1580
1605
  onSpawn: options.onSpawn,
1606
+ onSpawnFailed: options.onSpawnFailed,
1581
1607
  onContainer: options.onContainer,
1582
1608
  onContainerEmpty: options.onContainerEmpty,
1583
1609
  onUnknown: options.onUnknown,
@@ -88,7 +88,7 @@ export type JourneyStep = {
88
88
  * the build finished with a result.
89
89
  */
90
90
  export declare function firstTaskJourney(read: {
91
- stage: "queued" | "planning" | "needs-you" | "building" | "checking" | "finished" | "complete" | "failed" | "stopped";
91
+ stage: "queued" | "planning" | "needs-you" | "building" | "checking" | "reviewing" | "finished" | "complete" | "failed" | "stopped";
92
92
  need?: string | undefined;
93
93
  }, approved: boolean, built?: boolean): JourneyStep[];
94
94
  export {};
package/dist/first-run.js CHANGED
@@ -129,7 +129,7 @@ export const JOURNEY_STEPS = [["plan", "Plan"], ["approve", "You approve"], ["bu
129
129
  */
130
130
  export function firstTaskJourney(read, approved, built = false) {
131
131
  const at = read.stage === "finished" || read.stage === "complete" ? { index: 5, stuck: false }
132
- : read.stage === "checking" ? { index: 3, stuck: false }
132
+ : read.stage === "checking" || read.stage === "reviewing" ? { index: 3, stuck: false }
133
133
  : read.stage === "building" ? { index: 2, stuck: false }
134
134
  : read.stage === "needs-you" && read.need === "approval" ? { index: 1, stuck: false }
135
135
  : read.stage === "planning" ? { index: 0, stuck: false }
package/dist/invoke.js CHANGED
@@ -646,6 +646,11 @@ export async function invokeHeldAgent(store, runId, spec, argv, options) {
646
646
  store.recordRunProcess(runId, pid, clock(), false, heldWitness);
647
647
  runOptions.onSpawn?.(pid);
648
648
  },
649
+ onSpawnFailed: () => {
650
+ if (heldWitness !== undefined)
651
+ store.finishUnspawnedProcess(heldWitness, clock());
652
+ runOptions.onSpawnFailed?.();
653
+ },
649
654
  onContainer: info => {
650
655
  nativeHeld = true;
651
656
  if (heldWitness !== undefined)
package/dist/modes.d.ts CHANGED
@@ -29,7 +29,9 @@ export type ModeTerms = {
29
29
  planAuto: boolean;
30
30
  /** Attended mint without the per-mint password — signer only (D8). */
31
31
  quickMint: boolean;
32
- /** Historical signed field; new modes leave it false and no worker consumes it. */
32
+ /** Historical signed field; new modes leave it false and no worker consumes
33
+ * it. Automatic review is the project's review switch (review-switch.ts),
34
+ * on by default while a hands-off mode is active. */
33
35
  reviewAuto: boolean;
34
36
  /** Historical signed retry term, retained for digest and audit compatibility. */
35
37
  reviewRetryAuto: boolean;
package/dist/modes.js CHANGED
@@ -157,7 +157,9 @@ export function modeWords(terms) {
157
157
  terms.planAuto
158
158
  ? "plans for your pre-authorized filings auto-approve only when the goal, exclusions, paths, acceptance criteria, risk, budget, and agent route remain exactly unchanged; provide a goal, paths and acceptance criteria upfront; amendments and unresolved questions still wait for you"
159
159
  : "planner-generated plans wait for your approval",
160
- "finished work and saved checks go to the lead or user; no separate model review runs",
160
+ terms.name === "hands-off"
161
+ ? "automatic review is on for this project unless you turn it off (`toolroll review off --repo <path>`): each finished build whose check passes (or that finishes, with checks Off) gets one read-only review by the project's review agent; a HIGH finding sends it back once as a revision filed under this mode, a HIGH on that revision comes to you, MEDIUM and LOW findings come to you as suggested follow-ups, and a review that fails or times out never holds the work — it reaches you marked not reviewed"
162
+ : "finished work and saved checks go to the lead or user; automatic review runs only where a project turns it on (`toolroll review on --repo <path>`)",
161
163
  ...(terms.reviewAuto || terms.reviewRetryAuto
162
164
  ? ["historical review grants are retained on record but no longer schedule work"] : []),
163
165
  terms.perAttemptBudgetMicrousd === null
package/dist/operate.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import { maybeTriggerRepair } from "./dispose.js";
2
2
  import { CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, setTaskCheckLevel, suggestQuickCommand } from "./check-levels.js";
3
3
  import { fileAddTestsTask, followUpChecksOf, requestFollowUpChecks, runFollowUpCheck, runWaitingChecks } from "./result-follow-ups.js";
4
+ import { buildReviewPass } from "./build-review.js";
5
+ import { buildReviewLines, buildReviewOf } from "./review-switch.js";
4
6
  import { parseProtectedPaths } from "./approval-policy.js";
5
7
  import { rulesSummary } from "./approval-rules-ui.js";
6
8
  import { evidencePack, exportDay, ledgerExportChunks, standaloneEvidenceHtml } from "./evidence-pack.js";
7
9
  import { matchesOutsideCheckpoint } from "./ledger-chain.js";
8
10
  import { CLEANUP_CHOICES, bytesWords, cleanupWords, parseCleanup, storageReport } from "./storage.js";
9
11
  import { checkoutPlan, cleanCheckouts, discardCheckout, whyWords } from "./checkout-cleanup.js";
10
- import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodWords, retentionPlan, sweepWords } from "./retention.js";
12
+ import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodLabel, periodWords, retentionPlan, sweepWords } from "./retention.js";
11
13
  import { checkPolicy, parseList, policyParts } from "./policy.js";
12
14
  import { billingOf, budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendItems, teammateNames, usd as spendUsd } from "./spend.js";
13
15
  import { spendCsv } from "./spend-ui.js";
@@ -239,9 +241,10 @@ export const OPERATE_HELP = `toolroll — operating the queue
239
241
  toolroll notifications digest <HH:MM>|off one evening message: what finished, what waits, what failed
240
242
  toolroll notifications mute|unmute --repo <p> no pings for a project; the console and digest keep it
241
243
  toolroll retention show|preview how long evidence, checkout records, chat and notifications are kept; what the daily sweep would remove
242
- toolroll retention set <kind> <period> evidence|checkouts|chat|notifications, 30d|1y|forever (instance operator)
244
+ toolroll retention set <kind> <period> evidence|checkouts|chat|notifications, 1d|30d|1y|forever (instance operator)
243
245
  toolroll backup now|list back the database up now; list backups and how the last ones went
244
246
  toolroll restore <file> [--dry-run] put a backup back (Toolroll stopped; the current database is kept)
247
+ toolroll review show|on|off --repo <path> one automatic review of each finished build; only HIGH findings send it back
245
248
  toolroll policy show|set the organisation policy; an instance operator sets it with --providers claude,codex|any,
246
249
  --models <m,…>|any, --tools <t,…>|any, --ceiling safe|standard|escalated
247
250
  toolroll ledger verify check the action ledger's hash chain (--checkpoint <n:hash> to compare a copied head)
@@ -932,6 +935,8 @@ async function dispatch(command, positional, flags, context) {
932
935
  return storageCommand(positional, flags, context);
933
936
  case "integrations":
934
937
  return integrationsCommand(positional, flags, context);
938
+ case "review":
939
+ return reviewSwitchCommand(positional, flags, context);
935
940
  case "monitoring": {
936
941
  // Where the audit stream and traces go, and how each destination is doing. Settings → Monitoring changes it.
937
942
  const settings = readMonitoring(dirname(context.databaseFile));
@@ -992,6 +997,8 @@ async function dispatch(command, positional, flags, context) {
992
997
  return tickCommand(flags, context);
993
998
  case "reconcile":
994
999
  return reconcileCommand(flags, context);
1000
+ case "run":
1001
+ return runCommand(positional, flags, context);
995
1002
  case "cap":
996
1003
  return capCommand(positional, flags, context);
997
1004
  case "gaps":
@@ -4277,19 +4284,20 @@ async function tickCommand(flags, context) {
4277
4284
  broke++;
4278
4285
  }
4279
4286
  }
4280
- // Retire queued model-review work without creating a run or a passing verdict.
4281
- // Reauthenticate inside the write transaction and touch only admitted projects.
4282
- if (!buildsOnly)
4283
- store.transact(() => {
4284
- const current = authenticate(store, runner, token);
4285
- if (!current.ok)
4286
- return;
4287
- for (const request of store.openReviewRequests()) {
4288
- if (request.repo !== null && current.runner.repos.includes(request.repo)) {
4289
- store.consumeReviewRequest(request.id, "model-review-retired", clock());
4290
- }
4291
- }
4287
+ // The one automatic review each finished build gets when its project's
4288
+ // switch is on, before the result is handed on below. Other queued review
4289
+ // asks close unrun. Reauthenticated first; only admitted projects.
4290
+ if (!buildsOnly && authenticate(store, runner, token).ok) {
4291
+ const reviewed = await buildReviewPass(store, {
4292
+ runner, token, repos: auth.runner.repos, clock,
4293
+ root: context.evidenceRoot,
4294
+ ...(text(flags, "incarnation") === undefined ? {} : { watchIncarnation: text(flags, "incarnation") }),
4295
+ ...(context.agentRunner === undefined ? {} : { agent: context.agentRunner }),
4296
+ shouldStop: () => context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true,
4292
4297
  });
4298
+ for (const one of reviewed)
4299
+ dispatched.push({ id: `review of run ${one.run}`, outcome: one.outcome === "not-reviewed" ? "not-reviewed" : "reviewed", reason: one.detail });
4300
+ }
4293
4301
  // Existing worker pass owns durable lead handoffs; reads never create events.
4294
4302
  if (!buildsOnly)
4295
4303
  syncAssignmentHandoffs(store, clock(), auth.runner.repos, context.evidenceRoot);
@@ -4327,7 +4335,7 @@ async function tickCommand(flags, context) {
4327
4335
  routines,
4328
4336
  });
4329
4337
  }
4330
- if (built > 0 || parked > 0 || dispatched.some(one => one.outcome === "planned" || one.outcome === "reported" || one.outcome === "held")) {
4338
+ if (built > 0 || parked > 0 || dispatched.some(one => one.outcome === "planned" || one.outcome === "reported" || one.outcome === "held" || one.outcome === "reviewed" || one.outcome === "not-reviewed")) {
4331
4339
  return succeed(write, json, "tick", { considered, dispatched, routines, ...flows }, summary);
4332
4340
  }
4333
4341
  if (considered === 0) {
@@ -4389,6 +4397,9 @@ async function reconcileCommand(flags, context) {
4389
4397
  }
4390
4398
  }
4391
4399
  const recovered = recoverDead(store, clock());
4400
+ // A witness reserved for a spawn that never made a process (a crash or a
4401
+ // thrown spawn) settles once its run's process groups are proven gone.
4402
+ store.settleUnspawnedWitnesses(clock());
4392
4403
  store.settleQuiescentStops(clock());
4393
4404
  for (const one of recovered) {
4394
4405
  for (const leaseId of one.claims) {
@@ -5045,13 +5056,18 @@ async function planTaskCommand(positional, flags, context) {
5045
5056
  }
5046
5057
  /** The retired command is a typed refusal, never a new provider request. */
5047
5058
  async function reviewTaskCommand(positional, flags, context) {
5048
- const { store, write, json, clock } = context;
5059
+ const { store, write, json } = context;
5049
5060
  const [runText] = positional;
5050
5061
  const runId = Number(runText ?? "");
5051
5062
  if (runText === undefined || !Number.isInteger(runId) || runId < 1) {
5052
5063
  return fail(write, json, "task review", "usage", "`toolroll task review <run-id>`", EXIT.usage);
5053
5064
  }
5054
- return fail(write, json, "task review", "model-review-retired", `Separate model review has been removed. Inspect run ${runId}'s saved work and checks, then give feedback or request a revision.`, EXIT.refused);
5065
+ // Read-only: the run's one automatic review, when it had one.
5066
+ const review = buildReviewOf(store, runId);
5067
+ if (review !== null) {
5068
+ return succeed(write, json, "task review", { run: runId, review }, () => [`Run ${runId}`, ...buildReviewLines(review)]);
5069
+ }
5070
+ return fail(write, json, "task review", "model-review-retired", `Run ${runId} has no automatic review. Manual review requests have been removed; a project's builds are reviewed automatically when its switch is on (\`toolroll review on --repo <path>\`). Inspect the saved work and checks, then give feedback or request a revision.`, EXIT.refused);
5055
5071
  }
5056
5072
  /**
5057
5073
  * `toolroll incident list|resolve <id>` — the parks that never became
@@ -10147,6 +10163,9 @@ function showTask(positional, context) {
10147
10163
  // v50: the latest build's bounded review history — every root attempt
10148
10164
  // in order, the open request, and the one state they add up to.
10149
10165
  review: latestFinished === null ? null : store.reviewRetryStateOf(latestFinished.id),
10166
+ // The one automatic review of the latest build: HIGH findings, suggested
10167
+ // follow-ups (MEDIUM/LOW), or why it was not reviewed.
10168
+ automaticReview: latestFinished === null ? null : buildReviewOf(store, latestFinished.id),
10150
10169
  dispatch: diagnoseTaskDispatch(store, id, now),
10151
10170
  // v52: the exact-run control the console shows — Stop, Stopping,
10152
10171
  // Paused (resume), or the review-retry door — and every stop on record.
@@ -10199,7 +10218,7 @@ function showTask(positional, context) {
10199
10218
  : detail.control.kind === "paused"
10200
10219
  ? [` control: paused — run #${detail.control.run} was stopped by ${detail.control.stop.requestedBy} (${detail.control.stop.settlement ?? "?"})${detail.control.committed ? "; its commit is on the branch" : ""}; work preserved${detail.control.worktree === null ? "" : ` in ${detail.control.worktree}`} — \`task resume ${task.id} --run ${detail.control.run} --as <you> --token <t>\``]
10201
10220
  : [` control: review #${detail.control.run} was stopped by ${detail.control.stop.requestedBy} — inspect its saved result and give feedback; separate model review has been removed`]),
10202
- ...reviewStatusLines(detail.review, latestFinished?.id ?? null),
10221
+ ...(detail.automaticReview === null ? reviewStatusLines(detail.review, latestFinished?.id ?? null) : buildReviewLines(detail.automaticReview)),
10203
10222
  ...(scope === null
10204
10223
  ? [" no scope — nothing will build this until one is written and approved"]
10205
10224
  : describeScope(scope, readiness)),
@@ -11662,6 +11681,48 @@ async function resumeTaskCommand(positional, flags, context) {
11662
11681
  : ` Not starting yet — ${resumed.gate.summary}: ${resumed.gate.detail}`,
11663
11682
  ]);
11664
11683
  }
11684
+ /**
11685
+ * `toolroll run settle <id> --why "<text>" --as <you> --token <t>`: an
11686
+ * approver's last resort for a finished run whose process witnesses can be
11687
+ * proven neither exited nor alive (a spawn interrupted before its pid was
11688
+ * written). Refuses while anything of the run is alive; the reason is kept
11689
+ * in the ledger against the approver's name. Signals nothing.
11690
+ */
11691
+ async function runCommand(positional, flags, context) {
11692
+ const { store, write, json, clock } = context;
11693
+ const [action, id, ...extra] = positional;
11694
+ const command = "run settle";
11695
+ const usage = "`toolroll run settle <run-id> --why \"<reason>\" --as <you> --token <t>`";
11696
+ if (action !== "settle")
11697
+ return fail(write, json, "run", "usage", usage, EXIT.usage);
11698
+ const runId = Number(id ?? "");
11699
+ const why = text(flags, "why")?.trim();
11700
+ if (id === undefined || extra.length > 0 || !/^[1-9]\d*$/.test(id) || !Number.isSafeInteger(runId))
11701
+ return fail(write, json, command, "usage", usage, EXIT.usage);
11702
+ if (why === undefined || why === "" || why.length > 300 || /[\x00-\x1f\x7f]/.test(why)) {
11703
+ return fail(write, json, command, "usage", "say why in one line with --why (at most 300 characters) — it is kept in the ledger", EXIT.usage);
11704
+ }
11705
+ const run = store.getRun(runId);
11706
+ if (run === null)
11707
+ return fail(write, json, command, "unknown-run", `no run #${runId}`, EXIT.refused);
11708
+ const acting = await askCredentials(flags, context);
11709
+ if (acting === null) {
11710
+ return fail(write, json, command, "usage", "settling takes `--as <you> --token <t>` — it is an approver's act and is recorded against your name", EXIT.usage);
11711
+ }
11712
+ const authenticated = authenticateApprover(store, acting.name, acting.token);
11713
+ const repo = store.refById(run.taskRef)?.repo ?? null;
11714
+ if (!authenticated.ok || !store.accountCanAccess(acting.name, repo)) {
11715
+ return fail(write, json, command, "not-an-approver", "that is not an approver for this run's project, or the token does not match", EXIT.refused);
11716
+ }
11717
+ const settled = store.settleRunWitnessesByApprover({ runId, by: acting.name, why }, clock());
11718
+ if (!settled.ok)
11719
+ return fail(write, json, command, settled.reason, settled.detail, EXIT.refused, { run: runId });
11720
+ return succeed(write, json, command, { run: runId, witnesses: settled.witnesses, repeated: settled.repeated }, () => [
11721
+ settled.repeated
11722
+ ? `Run #${runId} has no unsettled process witness; nothing changed.`
11723
+ : `Settled run #${runId}: ${settled.witnesses.length} process witness${settled.witnesses.length === 1 ? "" : "es"} recorded as ended, with your reason in the ledger.`,
11724
+ ]);
11725
+ }
11665
11726
  // ---- shared ---------------------------------------------------------------
11666
11727
  /**
11667
11728
  * `project rules --repo <path>` shows a project's approval rules (v102);
@@ -12026,9 +12087,11 @@ async function retentionCommand(positional, flags, context) {
12026
12087
  const label = (kind) => RETENTION_KINDS.find(one => one.kind === kind).label;
12027
12088
  if (action === undefined || action === "show") {
12028
12089
  const periods = store.retentionPeriods();
12090
+ const chosen = store.retentionChosen();
12091
+ const defaulted = RETENTION_KINDS.map(one => one.kind).filter(kind => !(kind in chosen));
12029
12092
  const last = lastSweepAt(store);
12030
- return succeed(context.write, context.json, command, { periods, lastSweep: last }, () => [
12031
- ...RETENTION_KINDS.map(one => ` ${one.kind.padEnd(14)} ${periodWords(periods[one.kind]).padEnd(9)} ${one.label}`),
12093
+ return succeed(context.write, context.json, command, { periods, defaulted, lastSweep: last }, () => [
12094
+ ...RETENTION_KINDS.map(one => ` ${one.kind.padEnd(14)} ${periodLabel(one.kind, periods[one.kind], one.kind in chosen).toLowerCase().padEnd(18)} ${one.label}`),
12032
12095
  last === null ? "No sweep yet; the worker runs one a day." : `Last sweep ${last.slice(0, 16).replace("T", " ")} UTC.`,
12033
12096
  ]);
12034
12097
  }
@@ -12044,11 +12107,11 @@ async function retentionCommand(positional, flags, context) {
12044
12107
  return fail(context.write, context.json, command, "usage", "Use retention show, retention preview or retention set <kind> <period>.", EXIT.usage);
12045
12108
  const [kind, period, ...extra] = rest;
12046
12109
  if (kind === undefined || !isRetentionKind(kind) || period === undefined || extra.length > 0) {
12047
- return fail(context.write, context.json, command, "usage", `Use retention set <${RETENTION_KINDS.map(one => one.kind).join("|")}> <days, like 90d or 1y, or forever>.`, EXIT.usage);
12110
+ return fail(context.write, context.json, command, "usage", `Use retention set <${RETENTION_KINDS.map(one => one.kind).join("|")}> <days, like 1d, 90d or 1y, or forever>.`, EXIT.usage);
12048
12111
  }
12049
12112
  const days = parsePeriod(period);
12050
12113
  if (days === undefined)
12051
- return fail(context.write, context.json, command, "usage", `A period is forever, or ${MIN_DAYS} days to 10 years (90d, 12w, 1y).`, EXIT.usage);
12114
+ return fail(context.write, context.json, command, "usage", `A period is forever, or ${MIN_DAYS} day to 10 years (1d, 90d, 12w, 1y).`, EXIT.usage);
12052
12115
  const acting = await askCredentials(flags, context);
12053
12116
  const verified = acting === null ? null : authenticateApprover(store, acting.name, acting.token);
12054
12117
  if (acting === null || verified === null || !verified.ok || !store.isInstanceOperator(acting.name)) {
@@ -12148,6 +12211,40 @@ async function storageCommand(positional, flags, context) {
12148
12211
  function checkoutSummary(plan) {
12149
12212
  return { count: plan.count, totalBytes: plan.totalBytes, waitingReview: plan.waitingReview, withChanges: plan.withChanges, cleanBytes: plan.freeBytes, cleanCount: plan.go.length, cleanup: plan.cleanup };
12150
12213
  }
12214
+ /** `review [show] | on | off --repo <path>`: the project's one automatic review
12215
+ * of finished builds. Changing it takes an approver of that project; the
12216
+ * policy log keeps before → after. */
12217
+ async function reviewSwitchCommand(positional, flags, context) {
12218
+ const { store, write, json } = context;
12219
+ const [action = "show", ...extra] = positional;
12220
+ const command = `review ${action}`;
12221
+ if (extra.length > 0 || !["show", "on", "off"].includes(action))
12222
+ return fail(write, json, command, "usage", "Use toolroll review show|on|off --repo <path>.", EXIT.usage);
12223
+ const allowed = new Set(["repo", "db", "json", ...(action === "show" ? [] : ["as", "token", "token-file", "token-env"])]);
12224
+ for (const name of flags.keys())
12225
+ if (!allowed.has(name))
12226
+ return fail(write, json, command, "usage", `--${name} is not a ${command} option.`, EXIT.usage);
12227
+ const given = text(flags, "repo");
12228
+ if (given === undefined)
12229
+ return fail(write, json, command, "usage", "Name the project: toolroll review " + action + " --repo <path>.", EXIT.usage);
12230
+ const repo = canonicalProject(given) ?? resolve(given);
12231
+ const words = (on, source) => `${projectName(repo)}: automatic review is ${on ? "on" : "off"}${source === "hands-off" ? " (on while the hands-off mode lasts)" : source === "default" ? " (the default)" : ""}.`;
12232
+ if (action === "show") {
12233
+ const state = store.reviewSwitch(repo, context.clock());
12234
+ return succeed(write, json, command, { repo, ...state }, () => [words(state.on, state.source)]);
12235
+ }
12236
+ const acting = await askCredentials(flags, context);
12237
+ if (acting === null)
12238
+ return fail(write, json, command, "usage", `Sign in first, or pass --as <you> --token <t>: toolroll review ${action} --repo <path>.`, EXIT.usage);
12239
+ const verified = authenticateApprover(store, acting.name, acting.token, repo);
12240
+ if (!verified.ok)
12241
+ return fail(write, json, command, verified.reason, `Only an approver of ${projectName(repo)} can change its review switch.`, EXIT.refused);
12242
+ const changed = store.setReviewSwitch(repo, action === "on", acting.name, context.clock());
12243
+ return succeed(write, json, command, { repo, ...changed.after, before: changed.before }, () => [
12244
+ words(changed.after.on, changed.after.source),
12245
+ changed.after.on ? "Each finished build whose check passes gets one read-only review; only HIGH findings send it back, once." : "Finished builds go straight to you.",
12246
+ ]);
12247
+ }
12151
12248
  /** `policy show | set` (sprint 8): the organisation policy. Setting it takes an instance operator's credentials; each rule
12152
12249
  * that changed is in the ledger, before → after. A flag left out keeps its rule; "any" lifts it. */
12153
12250
  async function policyCommand(positional, flags, context) {
@@ -69,6 +69,14 @@ export function witnessedRunner(store, runId, clock, runner) {
69
69
  store.recordRunProcess(runId, pid, clock(), options.processGroup === true, witnesses.at(-1));
70
70
  options.onSpawn?.(pid);
71
71
  },
72
+ // The spawn made no process (ENOENT, EAGAIN, a throw): its reservation
73
+ // settles as never started now, not only if the transport returns.
74
+ onSpawnFailed: () => {
75
+ const witness = witnesses.at(-1);
76
+ if (witness !== undefined)
77
+ store.finishUnspawnedProcess(witness, clock());
78
+ options.onSpawnFailed?.();
79
+ },
72
80
  onContainer: info => {
73
81
  native = true;
74
82
  const witness = witnesses.at(-1);
package/dist/provider.js CHANGED
@@ -169,6 +169,22 @@ const CLAUDE_REVIEW_JSON_SCHEMA = {
169
169
  additionalProperties: false,
170
170
  },
171
171
  },
172
+ // The automatic build review's reply (build-review.ts). Shape only;
173
+ // parseBuildFindings is the validator.
174
+ findings: {
175
+ type: "array",
176
+ items: {
177
+ type: "object",
178
+ properties: {
179
+ severity: { type: "string", enum: ["HIGH", "MEDIUM", "LOW"] },
180
+ file: { type: "string" },
181
+ line: { type: "integer", minimum: 1 },
182
+ scenario: { type: "string" },
183
+ },
184
+ required: ["severity", "file", "line", "scenario"],
185
+ additionalProperties: false,
186
+ },
187
+ },
172
188
  // The read request rides the same structured reply channel and
173
189
  // session. The machine validates the exact allowlist/hash/range
174
190
  // before supplying bytes; this only shapes the request.
@@ -1,7 +1,9 @@
1
- import { type RetentionCount, type RetentionPeriods } from "./retention.js";
1
+ import { type RetentionCount, type RetentionKind, type RetentionPeriods } from "./retention.js";
2
2
  export declare const RETENTION_CSS: string;
3
+ /** `chosen`: the kinds someone chose a period for; the rest keep their default. */
3
4
  export type RetentionView = {
4
5
  periods: RetentionPeriods;
6
+ chosen: readonly RetentionKind[];
5
7
  next: RetentionCount[];
6
8
  lastSweep: string | null;
7
9
  csrf: string;
@@ -4,7 +4,7 @@
4
4
  * take a step-up.
5
5
  */
6
6
  import { bytesWords } from "./storage.js";
7
- import { PERIOD_CHOICES, RETENTION_KINDS, countWords, periodWords } from "./retention.js";
7
+ import { RETENTION_KINDS, countWords, periodChoices, periodLabel } from "./retention.js";
8
8
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
9
9
  export const RETENTION_CSS = `.retention{max-width:720px;min-width:0}.retention-next{margin:0 0 16px}` +
10
10
  `.retention form{display:grid;gap:0}.retention .kind{display:grid;grid-template-columns:minmax(0,1fr) 11rem;gap:4px 16px;align-items:center;padding:12px 0;border-bottom:1px solid var(--border)}` +
@@ -26,8 +26,8 @@ export function retentionHtml(view, notice) {
26
26
  const note = notice.problem ? `<p class="problem" role="alert">${e(notice.problem)}</p>` : notice.said ? `<p role="status">${e(notice.said)}</p>` : "";
27
27
  const rows = RETENTION_KINDS.map(({ kind, label, detail }) => {
28
28
  const current = view.periods[kind];
29
- const choices = PERIOD_CHOICES.includes(current) ? PERIOD_CHOICES : [...PERIOD_CHOICES.slice(0, -1), current, null].sort((a, b) => (a ?? Infinity) - (b ?? Infinity));
30
- const options = choices.map(days => `<option value="${days === null ? "forever" : days}"${days === current ? " selected" : ""}>${days === null ? "Forever" : `${periodWords(days)[0].toUpperCase()}${periodWords(days).slice(1)}`}</option>`).join("");
29
+ const chosen = view.chosen.includes(kind);
30
+ const options = periodChoices(kind, current).map(days => `<option value="${days === null ? "forever" : days}"${days === current ? " selected" : ""}>${e(periodLabel(kind, days, chosen || days !== current))}</option>`).join("");
31
31
  return `<div class="kind" data-kind="${kind}"><label class="name" for="keep-${kind}">${e(label)}</label><p class="meta">${e(detail)}</p><select id="keep-${kind}" name="${kind}">${options}</select></div>`;
32
32
  }).join("");
33
33
  const last = view.lastSweep === null ? "" : ` Last sweep ${e(view.lastSweep.slice(0, 10))}.`;
@@ -35,5 +35,5 @@ export function retentionHtml(view, notice) {
35
35
  `<form method="post" action="/settings/retention"><input type="hidden" name="csrf" value="${e(view.csrf)}">${rows}` +
36
36
  `<label class="step-up">Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>` +
37
37
  `<button type="submit">Save</button></form>` +
38
- `<p class="meta">Never removed: the action ledger, and anything a task still needs (unfinished tasks, results not yet completed, anything on hold).</p></article>`;
38
+ `<p class="meta">Never removed: the action ledger, and anything a task still needs (unfinished tasks, results Ready for review, anything on hold).</p></article>`;
39
39
  }
@@ -1,5 +1,8 @@
1
- import type { Store } from "./store.js";
2
- export declare const RETENTION_SCHEMA = "\nCREATE TABLE IF NOT EXISTS retention_setting (\n kind TEXT PRIMARY KEY CHECK (kind IN ('evidence', 'checkouts', 'chat', 'notifications')),\n -- NULL is forever; a row only exists once someone chose.\n days INTEGER CHECK (days IS NULL OR (days >= 7 AND days <= 3650)),\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n";
1
+ import type { Database, Store } from "./store.js";
2
+ export declare const RETENTION_SCHEMA: string;
3
+ /** A file from before 1-day evidence keeps `days >= 7` in its CHECK: rebuild the table with the wider one, keeping
4
+ * every row, in one transaction. A file already widened is untouched. */
5
+ export declare function widenRetentionSchema(db: Database): void;
3
6
  export type RetentionKind = "evidence" | "checkouts" | "chat" | "notifications";
4
7
  export type RetentionPeriods = Record<RetentionKind, number | null>;
5
8
  export declare const RETENTION_KINDS: readonly {
@@ -7,19 +10,23 @@ export declare const RETENTION_KINDS: readonly {
7
10
  label: string;
8
11
  detail: string;
9
12
  }[];
10
- /** The periods the page offers; the command line takes any number of days from 7 to 3650. */
11
- export declare const PERIOD_CHOICES: readonly (number | null)[];
12
- export declare const MIN_DAYS = 7;
13
+ export declare const PERIOD_CHOICES: Readonly<Record<RetentionKind, readonly (number | null)[]>>;
14
+ export declare const MIN_DAYS = 1;
13
15
  export declare const MAX_DAYS = 3650;
14
16
  /** A sweep is due once a day. */
15
17
  export declare const SWEEP_EVERY_MS = 86400000;
16
18
  /** At most this many runs, messages, notifications or records go per kind per sweep; the next sweep takes the rest. */
17
19
  export declare const SWEEP_BATCH = 2000;
18
- export declare const FOREVER: RetentionPeriods;
20
+ /** What each kind keeps until someone chooses: evidence 28 days, everything else forever. */
21
+ export declare const DEFAULT_PERIODS: RetentionPeriods;
19
22
  export declare function isRetentionKind(value: string): value is RetentionKind;
20
23
  /** "forever", or a number of days ("90", "90d"), weeks ("12w") or years ("1y"); undefined when it's none of those or out of range. */
21
24
  export declare function parsePeriod(text: string): number | null | undefined;
22
25
  export declare function periodWords(days: number | null): string;
26
+ /** What the page offers for `kind`, with `current` among them (in order) when it was set some other way. */
27
+ export declare function periodChoices(kind: RetentionKind, current: number | null): readonly (number | null)[];
28
+ /** A period as the page and `retention show` say it: "28 days (default)" when nobody chose, "30 days (custom)" when it's not one the page offers. */
29
+ export declare function periodLabel(kind: RetentionKind, days: number | null, chosen: boolean): string;
23
30
  export type RetentionCount = {
24
31
  kind: RetentionKind;
25
32
  days: number | null;