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/store.js CHANGED
@@ -67,8 +67,9 @@ import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
67
67
  import { RECIPE_SCHEMA } from "./recipes.js";
68
68
  import { POLICY_SCHEMA, agentRefusal, approvalRefusal, attendedRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
69
69
  import { PROVIDER_AUTH_SCHEMA } from "./provider-auth.js";
70
+ import { REVIEW_SCHEMA, reviewSwitchWords } from "./review-switch.js";
70
71
  import { SPEND_SCHEMA, billingOf, budgetStates, canPrice, claudeMachineBilling, countsToward, filersOf, monthOf, priceWork, seenBilling as seenBillingOf, spendItems, teammateFilers, usd } from "./spend.js";
71
- import { FOREVER, RETENTION_SCHEMA, periodWords } from "./retention.js";
72
+ import { DEFAULT_PERIODS, RETENTION_SCHEMA, periodWords, widenRetentionSchema } from "./retention.js";
72
73
  import { CHECKOUT_CLEANUP_SCHEMA, DEFAULT_CLEANUP, cleanupWords } from "./storage.js";
73
74
  import { IN_RANGE, LEDGER_CHAIN_SCHEMA, safeWhole, sealLedger, verifyLedgerChain } from "./ledger-chain.js";
74
75
  import { NO_RULES, filerFor, isProtectedWork, protectedChanges, rulesWords } from "./approval-policy.js";
@@ -3801,7 +3802,10 @@ function initializeStore(db, file) {
3801
3802
  db.exec(PROVIDER_AUTH_SCHEMA);
3802
3803
  // The sign-in pause the dispatch gate last left this task waiting on (null: none), for the work index.
3803
3804
  addColumn(db, "task_ref", "auth_wait_pause", "INTEGER REFERENCES provider_auth_pause(id)");
3805
+ db.exec(REVIEW_SCHEMA);
3804
3806
  db.exec(RETENTION_SCHEMA);
3807
+ // 1-day evidence: an older file's retention_setting only allowed 7 days or more (no version bump: its rows carry over).
3808
+ widenRetentionSchema(db);
3805
3809
  db.exec(CHECKOUT_CLEANUP_SCHEMA);
3806
3810
  db.exec(BACKUP_SCHEMA);
3807
3811
  // Sprint 8: the organisation policy (one row, or none: nothing restricted).
@@ -9333,6 +9337,27 @@ export class Store {
9333
9337
  return;
9334
9338
  this.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action, outcome, source: "policy", detail: `${before} → ${after}` });
9335
9339
  }
9340
+ /** The project's automatic review switch: its explicit setting, else on
9341
+ * while a hands-off mode is active on it, else off. */
9342
+ reviewSwitch(repo, now) {
9343
+ const row = this.db.prepare("SELECT enabled, changed_by, changed_at FROM review_switch WHERE repo = ?").get(repo);
9344
+ if (row !== undefined)
9345
+ return { on: Number(row["enabled"]) === 1, source: "project", changedBy: String(row["changed_by"]), changedAt: String(row["changed_at"]) };
9346
+ const handsOff = this.activeMode(repo, now)?.name === "hands-off";
9347
+ return { on: handsOff, source: handsOff ? "hands-off" : "default", changedBy: null, changedAt: null };
9348
+ }
9349
+ /** Turn a project's automatic review on or off; the policy log keeps before → after. */
9350
+ setReviewSwitch(repo, on, by, now) {
9351
+ return this.transact(() => {
9352
+ const before = this.reviewSwitch(repo, now);
9353
+ this.db.prepare(`INSERT INTO review_switch (repo, enabled, changed_by, changed_at) VALUES (?, ?, ?, ?)
9354
+ ON CONFLICT(repo) DO UPDATE SET enabled = excluded.enabled, changed_by = excluded.changed_by, changed_at = excluded.changed_at`)
9355
+ .run(repo, on ? 1 : 0, by, now.toISOString());
9356
+ const after = this.reviewSwitch(repo, now);
9357
+ this.recordPolicy(by, repo, "automatic review", reviewSwitchWords(before), reviewSwitchWords(after), now);
9358
+ return { before, after };
9359
+ });
9360
+ }
9336
9361
  /** A project deleted (project-delete.ts): who, and what it held → how it went. Returns the entry's id. */
9337
9362
  recordProjectDeleted(by, repo, held, how, now) {
9338
9363
  this.recordPolicy(by, repo, "project deleted", held, how, now, "deleted");
@@ -16353,13 +16378,17 @@ export class Store {
16353
16378
  });
16354
16379
  }
16355
16380
  // ---- Retention (v105) -----------------------------------------------------
16356
- /** How long each kind of data is kept; null is forever (every kind, until someone chooses). */
16381
+ /** How long each kind of data is kept; null is forever. A kind nobody chose keeps its default (evidence 28 days, the rest forever). */
16357
16382
  retentionPeriods() {
16358
- const periods = { ...FOREVER };
16383
+ return { ...DEFAULT_PERIODS, ...this.retentionChosen() };
16384
+ }
16385
+ /** The periods someone chose: only kinds with a saved row. */
16386
+ retentionChosen() {
16387
+ const chosen = {};
16359
16388
  for (const row of this.db.prepare("SELECT kind, days FROM retention_setting").all()) {
16360
- periods[String(row["kind"])] = row["days"] === null ? null : Number(row["days"]);
16389
+ chosen[String(row["kind"])] = row["days"] === null ? null : Number(row["days"]);
16361
16390
  }
16362
- return periods;
16391
+ return chosen;
16363
16392
  }
16364
16393
  /** Set how long one kind is kept (null: forever); the ledger keeps before → after. */
16365
16394
  setRetentionPeriod(kind, days, by, now) {
@@ -21060,6 +21089,108 @@ export class Store {
21060
21089
  this.db.prepare("UPDATE run_process SET exited_at = ? WHERE id = ? AND pid IS NULL AND ((containment IS NULL AND container IS NULL) OR container_empty_at IS NOT NULL)")
21061
21090
  .run(now.toISOString(), witness);
21062
21091
  }
21092
+ /** The reconcile road for a pid-less witness a crash or a thrown spawn
21093
+ * left behind: once its run has finished, no live child is tracked for it
21094
+ * here, every other witness of the run has exited and none of the run's
21095
+ * process groups is alive, it settles as never started, with a ledger
21096
+ * entry. A sole pid-less witness has no group to prove gone and stays for
21097
+ * `run settle`. Native witnesses keep their OS-only road. */
21098
+ settleUnspawnedWitnesses(now) {
21099
+ const candidates = this.db.prepare(`SELECT DISTINCT p.run FROM run_process p JOIN run r ON r.id = p.run
21100
+ WHERE p.exited_at IS NULL AND p.pid IS NULL AND p.containment IS NULL AND p.container IS NULL
21101
+ AND p.host = ? AND r.outcome IS NOT NULL AND r.finished_at IS NOT NULL ORDER BY p.run`).all(hostname());
21102
+ let settled = 0;
21103
+ for (const candidate of candidates) {
21104
+ const runId = Number(candidate["run"]);
21105
+ this.recordRunProcessExits(runId, now);
21106
+ settled += this.transact(() => {
21107
+ const run = this.getRun(runId);
21108
+ if (run === null || run.outcome === null)
21109
+ return 0;
21110
+ if (ownedProcessCount(runOwnerTag(this, runId)) > 0)
21111
+ return 0;
21112
+ const held = this.heldSessionOf(runId);
21113
+ if (held !== null && held.endedAt === null)
21114
+ return 0;
21115
+ const rows = this.db.prepare("SELECT * FROM run_process WHERE run = ? ORDER BY id").all(runId);
21116
+ const pidless = rows.filter(row => row["exited_at"] === null && row["pid"] === null && row["containment"] === null && row["container"] === null && row["host"] === hostname());
21117
+ const others = rows.filter(row => !pidless.includes(row));
21118
+ if (pidless.length === 0 || others.length === 0)
21119
+ return 0;
21120
+ if (others.some(row => row["exited_at"] === null))
21121
+ return 0;
21122
+ // Exit was recorded once; the group's members are asked again now.
21123
+ const groups = others.filter(row => row["pid"] !== null && row["host"] === hostname());
21124
+ if (groups.length === 0 || groups.some(row => processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, { observedAt: row["observed_at"], finishedAt: run.finishedAt })))
21125
+ return 0;
21126
+ const ref = this.refById(run.taskRef);
21127
+ let count = 0;
21128
+ for (const row of pidless) {
21129
+ const changed = this.db.prepare(`UPDATE run_process SET exited_at = ?
21130
+ WHERE id = ? AND run = ? AND pid IS NULL AND exited_at IS NULL AND containment IS NULL AND container IS NULL`)
21131
+ .run(now.toISOString(), row["id"], runId);
21132
+ if (Number(changed.changes) !== 1)
21133
+ continue;
21134
+ count++;
21135
+ this.recordAction({ at: now.toISOString(), actor: "system", repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId,
21136
+ action: "process witness settled", outcome: "never started", source: "work",
21137
+ detail: `witness ${String(row["id"])}: no pid; the run finished, every other witness exited and none of its process groups is alive` });
21138
+ }
21139
+ return count;
21140
+ });
21141
+ }
21142
+ return settled;
21143
+ }
21144
+ /** `run settle` (an approver's last resort): every witness of a finished
21145
+ * run that cannot be proven either way is recorded settled under the
21146
+ * approver's reason, in the ledger. Refuses while anything of the run is
21147
+ * alive or could be checked only from another host. Never signals. */
21148
+ settleRunWitnessesByApprover(args, now) {
21149
+ return this.transact(() => {
21150
+ const run = this.getRun(args.runId);
21151
+ if (run === null)
21152
+ return { ok: false, reason: "unknown-run", detail: `no run #${args.runId}` };
21153
+ if (run.outcome === null)
21154
+ return { ok: false, reason: "still-running", detail: `run #${args.runId} is still open` };
21155
+ const alive = (detail) => ({ ok: false, reason: "alive", detail });
21156
+ if (ownedProcessCount(runOwnerTag(this, args.runId)) > 0)
21157
+ return alive(`run #${args.runId} still has an owned subprocess`);
21158
+ const held = this.heldSessionOf(args.runId);
21159
+ if (held !== null && held.endedAt === null)
21160
+ return alive(`run #${args.runId}'s held supervisor has not finished shutdown`);
21161
+ const rows = this.db.prepare("SELECT * FROM run_process WHERE run = ? ORDER BY id").all(args.runId);
21162
+ const open = rows.filter(row => row["exited_at"] === null);
21163
+ for (const row of rows) {
21164
+ if (row["pid"] === null)
21165
+ continue;
21166
+ if (row["host"] !== hostname()) {
21167
+ if (row["exited_at"] === null)
21168
+ return alive(`run #${args.runId}'s process ${String(row["pid"])} belongs to ${String(row["host"])}; settle it there`);
21169
+ continue;
21170
+ }
21171
+ if (processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, { observedAt: row["observed_at"], finishedAt: run.finishedAt }))
21172
+ return alive(`run #${args.runId}'s process ${String(row["pid"])} is still running`);
21173
+ }
21174
+ for (const row of open) {
21175
+ if (row["pid"] === null && row["host"] !== hostname())
21176
+ return alive(`run #${args.runId} has a witness from ${String(row["host"])}; settle it there`);
21177
+ if (row["containment"] != null && row["container"] != null && row["container_empty_at"] == null &&
21178
+ containerEmptiness(String(row["containment"]), String(row["container"]), process.platform, row["container_identity"] == null ? null : String(row["container_identity"])) === "populated") {
21179
+ return alive(`run #${args.runId}'s ${String(row["containment"])} object ${String(row["container"])} still has members`);
21180
+ }
21181
+ }
21182
+ if (open.length === 0)
21183
+ return { ok: true, witnesses: [], repeated: true };
21184
+ const ids = open.map(row => Number(row["id"]));
21185
+ for (const id of ids)
21186
+ this.db.prepare("UPDATE run_process SET exited_at = ? WHERE id = ? AND run = ? AND exited_at IS NULL").run(now.toISOString(), id, args.runId);
21187
+ const ref = this.refById(run.taskRef);
21188
+ this.recordAction({ at: now.toISOString(), actor: args.by, repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId: args.runId,
21189
+ action: "process witness settled by approver", outcome: `witness ${ids.join(", ")}`, source: "request", detail: args.why });
21190
+ this.settleQuiescentStops(now);
21191
+ return { ok: true, witnesses: ids, repeated: false };
21192
+ });
21193
+ }
21063
21194
  recordRunProcess(runId, pid, now, group = true, witness) {
21064
21195
  if (!Number.isSafeInteger(pid) || pid <= 0)
21065
21196
  throw new Error("a process witness needs a valid spawned PID");
package/dist/surface.js CHANGED
@@ -265,12 +265,13 @@ export const COMMAND_GUIDE = [
265
265
  operator("task approve", "the yes — nothing builds without one; binds to the scope digest"),
266
266
  operator("task requeue", "exit a stall: incidents resolved, strikes cleared, queued again"),
267
267
  operator("task regate", "explicitly rerun the approved check on the last exact commit — no agent or automatic retry"),
268
- operator("task review", "retired compatibility command; inspect the saved result and explicitly complete or revise it"),
268
+ operator("task review", "show a run's automatic review: HIGH findings, suggested follow-ups, or why it was not reviewed"),
269
269
  operator("task accept", "record an explicit acceptance with limitations; does not change checks or prove deployment"),
270
270
  operator("task repair", "inspect or approve an existing historical repair draft; use task revise for new feedback"),
271
271
  operator("task route", "which configured agents plan, build and repair this task; phase and risk changes require renewed approval"),
272
272
  operator("task reopen", "resume external work its tracker closed and has been SEEN open again"),
273
273
  operator("task stop", "stop ONE exact live attempt (--run <id>): durable before any process is signalled, answers 'stopping' until its own processes are established gone; work, branch, and evidence preserved; no strike, no retry"),
274
+ operator("run settle", "last resort for a finished run whose process witness can't be proven either way: records your --why in the ledger; refuses while any of the run's processes is alive"),
274
275
  operator("task resume", "resume the exact stopped attempt (--run <id>): refuses until it is quiescent, lifts only that stop's hold, approves nothing — the next pass re-proves the scope and inherits the draft with fresh proof"),
275
276
  // ---- leases (runner surface) ----
276
277
  { invocation: "claim", synopsis: "take one ready, approved task; answers with a lease", audience: "agent", agentMayInvoke: true, mutation: "keyed",
@@ -358,6 +359,9 @@ export const COMMAND_GUIDE = [
358
359
  operator("mode set", "sign a per-repository operating mode (password ceremony; standard or hands-off, always expiring)"),
359
360
  operator("publish merge", "say yes to ONE waiting merge — it fires only when CI is seen green on the exact commit you authorized"),
360
361
  operator("publish refire", "recover a merge whose issued call went silent — re-enters the full merge road, never auto-retried"),
362
+ operatorRead("review show", "whether a project's finished builds get one automatic review"),
363
+ operator("review on", "one automatic review of each finished build whose check passes; only HIGH findings send it back, once"),
364
+ operator("review off", "finished builds go straight to the person, unreviewed"),
361
365
  operator("mode revoke", "revoke a repository's mode — one click, every act it covered falls back to its own ceremony"),
362
366
  operator("config set", "set phase or spend configuration"),
363
367
  operator("config clear", "clear phase or spend configuration"),
@@ -42,7 +42,8 @@ export type TaskStatus = {
42
42
  primaryAction: StatusAction | null;
43
43
  why: string[];
44
44
  };
45
- export type TaskStage = "queued" | "planning" | "needs-you" | "building" | "checking" | "finished" | "complete" | "failed" | "stopped";
45
+ /** `reviewing`: the one automatic review (build-review.ts) is reading a finished build; it reads Building, not a headline of its own. */
46
+ export type TaskStage = "queued" | "planning" | "needs-you" | "building" | "checking" | "reviewing" | "finished" | "complete" | "failed" | "stopped";
46
47
  export type ChecksFact = {
47
48
  status: "passed" | "failed" | "running" | "not-run" | "unavailable";
48
49
  exitCode: number | null;
@@ -121,7 +122,9 @@ export declare function pullRequestFactOf(publication: {
121
122
  } | null): PullRequestFact | null;
122
123
  /** The assignment's stage. The work status, when the caller read one, names
123
124
  * the exact reason (a running planner, a pause, a failed attempt, a sign-in). */
124
- export declare function assignmentStageOf(assignment: Pick<AssignmentSnapshot, "state" | "primaryAction">, work?: {
125
+ export declare function assignmentStageOf(assignment: Pick<AssignmentSnapshot, "state" | "primaryAction"> & {
126
+ review?: AssignmentSnapshot["review"];
127
+ }, work?: {
125
128
  token: string;
126
129
  views?: readonly string[];
127
130
  } | null, planning?: boolean): {
@@ -13,7 +13,8 @@ export function headlineOf(facts) {
13
13
  case "planning": return "Planning";
14
14
  case "needs-you": return "Needs you";
15
15
  case "building":
16
- case "checking": return "Building";
16
+ case "checking":
17
+ case "reviewing": return "Building";
17
18
  case "finished":
18
19
  if (facts.report)
19
20
  return "Ready for review";
@@ -36,7 +37,8 @@ function sentenceOf(headline, facts) {
36
37
  case "Needs you": return facts.need === "approval" ? "Review the plan and approve it to start." : reason ?? (facts.need === "answer" ? "Answer the question so the work can continue."
37
38
  : facts.need === "sign-in" ? "Sign in again; the task starts on its own after."
38
39
  : "Something needs your decision before the work can continue.");
39
- case "Building": return facts.stage === "checking" ? "Checks are running on the change." : reason ?? "An agent is working on it.";
40
+ case "Building": return facts.stage === "checking" ? "Checks are running on the change."
41
+ : facts.stage === "reviewing" ? "Reviewing the change before it reaches you." : reason ?? "An agent is working on it.";
40
42
  case "Ready for review":
41
43
  if (facts.report)
42
44
  return "The report is ready to read. Read it, then mark it complete.";
@@ -140,7 +142,7 @@ export function taskStatusOf(facts) {
140
142
  const CODE_STAGE = {
141
143
  queued: ["queued"], ready: ["queued"], "worker-at-capacity": ["queued"], "retry-scheduled": ["queued"], updating: ["queued"],
142
144
  "waiting-dependency": ["queued"], "scouting-ready": ["queued"], "planning-ready": ["queued"], "provider-quota": ["queued"], "planner-source": ["queued"],
143
- running: ["building"], reviewing: ["building"], "review-pending": ["building"],
145
+ running: ["building"], reviewing: ["reviewing"], "review-pending": ["building"],
144
146
  "signed-out": ["needs-you", "sign-in"],
145
147
  "needs-approval": ["needs-you", "approval"],
146
148
  "waiting-decision": ["needs-you", "answer"], "decision-queue": ["needs-you", "answer"],
@@ -154,7 +156,7 @@ const CODE_STAGE = {
154
156
  * building reason is scheduler wording, and the default sentence is plainer. */
155
157
  const QUEUED_REASONS = new Set(["worker-at-capacity", "waiting-dependency", "retry-scheduled", "updating", "planning-ready", "scouting-ready", "provider-quota"]);
156
158
  export function plainReasonOf(stage, code, detail) {
157
- if (stage === "building" || stage === "checking" || stage === "planning")
159
+ if (stage === "building" || stage === "checking" || stage === "reviewing" || stage === "planning")
158
160
  return null;
159
161
  if (stage === "queued")
160
162
  return QUEUED_REASONS.has(code) ? detail ?? null : null;
@@ -199,7 +201,7 @@ export function assignmentStageOf(assignment, work, planning = false) {
199
201
  case "cancelled": return { stage: "stopped" };
200
202
  case "complete": return { stage: "complete" };
201
203
  case "ready-to-check": return { stage: "finished" };
202
- case "checking": return { stage: "checking" };
204
+ case "checking": return { stage: assignment.review?.state === "pending" ? "reviewing" : "checking" };
203
205
  case "working": {
204
206
  const token = work?.token ?? "running";
205
207
  const read = stageOfCode(token, { needsPerson: work?.views?.includes("needs-you") ?? false, planning });
@@ -325,7 +327,9 @@ export const TASK_STATUS_CSS = `.status-headline{display:flex;align-items:center
325
327
  * no assignment. A done task is Complete once a person marked its result;
326
328
  * a refuted result whose project check failed is Failed. */
327
329
  export function stageOfDispatch(d, options = {}) {
328
- if (d.code === "complete" || d.code.startsWith("review-") || d.code === "reviewing")
330
+ if (d.code === "reviewing")
331
+ return { stage: "reviewing" };
332
+ if (d.code === "complete" || d.code.startsWith("review-"))
329
333
  return { stage: options.completed ? "complete" : "finished" };
330
334
  if (d.code === "proof-refuted")
331
335
  return /approved check failed|checks failed/i.test(d.detail ?? "") ? { stage: "failed" } : { stage: "needs-you", need: "other" };
@@ -6,6 +6,7 @@ import { resultCheckLevel } from "./result-follow-ups.js";
6
6
  import { diagnoseTaskDispatch } from "./dispatch.js";
7
7
  import { isAlive } from "./runner.js";
8
8
  import { taskControlOf } from "./task-control.js";
9
+ import { buildReviewOf } from "./review-switch.js";
9
10
  import { reviewFactsOf, workStatusOf } from "./workspace-ui.js";
10
11
  /** Call only after admitting the task's project. Keep the question's
11
12
  * exact run and id; never substitute a family's newest attempt. Expiry
@@ -103,7 +104,7 @@ export function taskWorkSummaryOf(store, taskId, now, access) {
103
104
  review: reviewFactsOf(store.reviewRetryStateOf(result.id), name => {
104
105
  const runner = store.getRunner(name)?.runner;
105
106
  return runner !== undefined && isAlive(runner, now);
106
- }),
107
+ }, buildReviewOf(store, result.id)?.state === "pending"),
107
108
  ...(result.outcome !== "no-change" ? {} : { recordComplete: (() => {
108
109
  const kinds = new Set(store.artifactsFor(result.id).map(one => one.kind));
109
110
  return kinds.has("handoff") && kinds.has("terminal-diff");
@@ -68,11 +68,13 @@ export type ReviewFacts = {
68
68
  queuedOrigin?: "operator" | "automatic" | null;
69
69
  /** Whether the live attempt's reviewer worker is answering. */
70
70
  reviewerAlive: boolean;
71
+ /** The build's one automatic review is still pending (build-review.ts). */
72
+ automaticPending?: boolean;
71
73
  };
72
74
  /** The review facts from the store's bounded retry projection — the same
73
75
  * reading `diagnoseTaskDispatch` makes, so every surface starts from one
74
76
  * record. `reviewerAlive` answers for the live attempt's runner. */
75
- export declare function reviewFactsOf(retry: ReviewRetryState | null, reviewerAlive: (runner: string) => boolean): ReviewFacts | null;
77
+ export declare function reviewFactsOf(retry: ReviewRetryState | null, reviewerAlive: (runner: string) => boolean, automaticPending?: boolean): ReviewFacts | null;
76
78
  /** The tokens a review in flight wears — the dispatch codes, unchanged. */
77
79
  export declare const REVIEW_TOKENS: ReadonlySet<string>;
78
80
  /**
@@ -30,7 +30,7 @@ export function parseWorkView(raw) {
30
30
  /** The review facts from the store's bounded retry projection — the same
31
31
  * reading `diagnoseTaskDispatch` makes, so every surface starts from one
32
32
  * record. `reviewerAlive` answers for the live attempt's runner. */
33
- export function reviewFactsOf(retry, reviewerAlive) {
33
+ export function reviewFactsOf(retry, reviewerAlive, automaticPending = false) {
34
34
  if (retry === null || retry.state === "unrequested")
35
35
  return null;
36
36
  const current = retry.live ?? (retry.state === "queued" ? null : retry.latest);
@@ -45,6 +45,7 @@ export function reviewFactsOf(retry, reviewerAlive) {
45
45
  queuedBy: retry.openRequest?.requestedBy ?? null,
46
46
  queuedOrigin: retry.openRequest?.origin ?? null,
47
47
  reviewerAlive: retry.live !== null && reviewerAlive(retry.live.runner),
48
+ automaticPending,
48
49
  };
49
50
  }
50
51
  /** The tokens a review in flight wears — the dispatch codes, unchanged. */
@@ -59,12 +60,12 @@ const retriesLeft = (count, explicit = true) => `${count}${explicit ? " explicit
59
60
  * the v50 dispatch contract; nothing here changes the lifecycle.
60
61
  */
61
62
  export function reviewStatusOf(review) {
62
- // Model review is retired (2026-09-21): nothing schedules, retries or
63
- // waits for a reviewer, so a recorded review request or attempt is
64
- // history and never the result's primary status. The stored verdict and
65
- // the assignment's Ready/Complete state speak instead.
66
- void review;
67
- return null;
63
+ // The one automatic review (build-review.ts) holds the result only while it
64
+ // is queued or running. A failed review never blocks: the result reads as
65
+ // it would unreviewed, marked "not reviewed" by the result itself.
66
+ if (review === null || review.automaticPending !== true || (review.state !== "queued" && review.state !== "running"))
67
+ return null;
68
+ return { token: "reviewing", label: "Reviewing", detail: "An automatic review is reading this result before it reaches you.", tone: "live", action: { label: "Open the result", kind: "open-result" } };
68
69
  }
69
70
  const FAILED_CHECK = /^the repository's approved verification command exited (-?[0-9]+)/;
70
71
  export function evidenceProblemOf(verdict, reasons) {
@@ -123,7 +124,7 @@ export function publicationStatusOf(publication) {
123
124
  * the detail).
124
125
  */
125
126
  export function resultStatusOf(result, publication = null) {
126
- return storedResultStatusOf(result, publication);
127
+ return (result?.role === "builder" ? reviewStatusOf(result.review ?? null) : null) ?? storedResultStatusOf(result, publication);
127
128
  }
128
129
  /** What the machine itself recorded about a result, in a clause. */
129
130
  function machineVerdictWords(result) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"