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/retention.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Retention (v105): how long Toolroll keeps run evidence and logs,
3
- * finished checkouts' records, chat messages and notifications. Each kind
4
- * keeps everything ("forever") until an instance operator chooses a period;
5
- * a daily sweep then deletes what is older than it and writes one action
6
- * ledger entry saying what went and about how much space it freed.
3
+ * finished checkouts' records, chat messages and notifications. Until an
4
+ * instance operator chooses a period, evidence is kept 28 days and every
5
+ * other kind forever; a daily sweep deletes what is older than its period
6
+ * and writes one action ledger entry saying what went and about how much
7
+ * space it freed.
7
8
  *
8
9
  * Never deleted, whatever the settings: the action ledger (it is append-only),
9
10
  * and anything a task still needs. A task still needs its things until it is
@@ -21,24 +22,47 @@ import { join } from "node:path";
21
22
  import { RETENTION_NOTE } from "./evidence.js";
22
23
  import { COMPLETION_ACTION } from "./result-completion.js";
23
24
  import { bytesWords, treeBytes } from "./storage.js";
24
- export const RETENTION_SCHEMA = `
25
- CREATE TABLE IF NOT EXISTS retention_setting (
25
+ const RETENTION_TABLE = (name) => `
26
+ CREATE TABLE IF NOT EXISTS ${name} (
26
27
  kind TEXT PRIMARY KEY CHECK (kind IN ('evidence', 'checkouts', 'chat', 'notifications')),
27
28
  -- NULL is forever; a row only exists once someone chose.
28
- days INTEGER CHECK (days IS NULL OR (days >= 7 AND days <= 3650)),
29
+ days INTEGER CHECK (days IS NULL OR (days >= 1 AND days <= 3650)),
29
30
  updated_by TEXT NOT NULL,
30
31
  updated_at TEXT NOT NULL
31
32
  );
32
33
  `;
34
+ export const RETENTION_SCHEMA = RETENTION_TABLE("retention_setting");
35
+ /** A file from before 1-day evidence keeps `days >= 7` in its CHECK: rebuild the table with the wider one, keeping
36
+ * every row, in one transaction. A file already widened is untouched. */
37
+ export function widenRetentionSchema(db) {
38
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'retention_setting'").get();
39
+ if (row === undefined || !/days\s*>=\s*7\b/.test(String(row["sql"])))
40
+ return;
41
+ db.exec("BEGIN IMMEDIATE");
42
+ try {
43
+ db.exec(RETENTION_TABLE("retention_setting_next"));
44
+ db.exec("INSERT INTO retention_setting_next (kind, days, updated_by, updated_at) SELECT kind, days, updated_by, updated_at FROM retention_setting");
45
+ db.exec("DROP TABLE retention_setting");
46
+ db.exec("ALTER TABLE retention_setting_next RENAME TO retention_setting");
47
+ db.exec("COMMIT");
48
+ }
49
+ catch (error) {
50
+ db.exec("ROLLBACK");
51
+ throw error;
52
+ }
53
+ }
33
54
  export const RETENTION_KINDS = [
34
55
  { kind: "evidence", label: "Run evidence and logs", detail: "Diffs, check logs, screenshots and reports saved for each run" },
35
56
  { kind: "checkouts", label: "Finished checkout records", detail: "Records of build checkouts that were let go and are no longer on disk" },
36
57
  { kind: "chat", label: "Chat messages", detail: "Messages in lead and team chats" },
37
58
  { kind: "notifications", label: "Notifications", detail: "Delivered or dismissed notifications" },
38
59
  ];
39
- /** The periods the page offers; the command line takes any number of days from 7 to 3650. */
40
- export const PERIOD_CHOICES = [30, 90, 180, 365, 730, null];
41
- export const MIN_DAYS = 7;
60
+ /** The periods the page offers for each kind; the command line takes any number of days from 1 to 3650. */
61
+ const LONG_CHOICES = [30, 90, 180, 365, 730, null];
62
+ export const PERIOD_CHOICES = {
63
+ evidence: [1, 7, 14, 28, null], checkouts: LONG_CHOICES, chat: LONG_CHOICES, notifications: LONG_CHOICES,
64
+ };
65
+ export const MIN_DAYS = 1;
42
66
  export const MAX_DAYS = 3650;
43
67
  const DAY_MS = 86_400_000;
44
68
  /** A sweep is due once a day. */
@@ -46,7 +70,8 @@ export const SWEEP_EVERY_MS = DAY_MS;
46
70
  /** At most this many runs, messages, notifications or records go per kind per sweep; the next sweep takes the rest. */
47
71
  export const SWEEP_BATCH = 2000;
48
72
  const SWEEP_CURSOR = "retention:last-sweep";
49
- export const FOREVER = { evidence: null, checkouts: null, chat: null, notifications: null };
73
+ /** What each kind keeps until someone chooses: evidence 28 days, everything else forever. */
74
+ export const DEFAULT_PERIODS = { evidence: 28, checkouts: null, chat: null, notifications: null };
50
75
  export function isRetentionKind(value) {
51
76
  return RETENTION_KINDS.some(one => one.kind === value);
52
77
  }
@@ -67,7 +92,20 @@ export function periodWords(days) {
67
92
  return "forever";
68
93
  if (days % 365 === 0)
69
94
  return days === 365 ? "1 year" : `${days / 365} years`;
70
- return `${days} days`;
95
+ return days === 1 ? "1 day" : `${days} days`;
96
+ }
97
+ /** What the page offers for `kind`, with `current` among them (in order) when it was set some other way. */
98
+ export function periodChoices(kind, current) {
99
+ const offered = PERIOD_CHOICES[kind];
100
+ return offered.includes(current) ? offered : [...offered, current].sort((a, b) => (a ?? Infinity) - (b ?? Infinity));
101
+ }
102
+ /** 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. */
103
+ export function periodLabel(kind, days, chosen) {
104
+ const words = periodWords(days);
105
+ const label = `${words[0].toUpperCase()}${words.slice(1)}`;
106
+ if (!chosen)
107
+ return `${label} (default)`;
108
+ return PERIOD_CHOICES[kind].includes(days) ? label : `${label} (custom)`;
71
109
  }
72
110
  /** Tasks that no longer need anything kept: cancelled, or done with their result marked complete; not on hold, not
73
111
  * running, not a release candidate, and no revision below it (at any depth) still unfinished — cancelled or completed,
@@ -0,0 +1,41 @@
1
+ /** The per-project automatic review switch and the one review each finished
2
+ * build gets (build-review.ts runs it): tables, and the read-only views every
3
+ * surface shows. No schema version bump. Kept free of heavy imports so the
4
+ * status readers can use it without an import cycle. */
5
+ import type { Store } from "./store.js";
6
+ export type FindingSeverity = "HIGH" | "MEDIUM" | "LOW";
7
+ /** One automatic-review finding: how bad, where, and how it fails. */
8
+ export type BuildFinding = {
9
+ severity: FindingSeverity;
10
+ file: string;
11
+ line: number;
12
+ scenario: string;
13
+ };
14
+ export declare const REVIEW_SCHEMA = "\n-- A project's explicit review switch. No row: on while the project is under a hands-off mode, off otherwise.\nCREATE TABLE IF NOT EXISTS review_switch (\n repo TEXT PRIMARY KEY,\n enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),\n changed_by TEXT NOT NULL,\n changed_at TEXT NOT NULL\n);\n-- One row per reviewed build run: pending until its one reviewer run settles it.\nCREATE TABLE IF NOT EXISTS build_review (\n run INTEGER PRIMARY KEY,\n task_id TEXT NOT NULL,\n repo TEXT NOT NULL,\n state TEXT NOT NULL CHECK (state IN ('pending', 'reviewed', 'not-reviewed')),\n request INTEGER,\n reviewer_run INTEGER,\n findings_json TEXT,\n reason TEXT,\n sent_back_as TEXT,\n queued_at TEXT NOT NULL,\n finished_at TEXT\n);\nCREATE INDEX IF NOT EXISTS build_review_pending ON build_review (repo) WHERE state = 'pending';\nCREATE INDEX IF NOT EXISTS build_review_sent_back ON build_review (sent_back_as) WHERE sent_back_as IS NOT NULL;\n";
15
+ export type ReviewSwitch = {
16
+ on: boolean;
17
+ source: "project" | "hands-off" | "default";
18
+ changedBy: string | null;
19
+ changedAt: string | null;
20
+ };
21
+ /** How the policy log words a switch state. */
22
+ export declare function reviewSwitchWords(state: ReviewSwitch): string;
23
+ export type BuildReviewView = {
24
+ run: number;
25
+ state: "pending" | "reviewed" | "not-reviewed";
26
+ reviewerRun: number | null;
27
+ /** Why it was not reviewed, in words. */
28
+ reason: string | null;
29
+ /** HIGH findings; they sent the task back when `sentBackAs` is set. */
30
+ high: BuildFinding[];
31
+ /** MEDIUM and LOW findings: suggested follow-ups, never blocking. */
32
+ followUps: BuildFinding[];
33
+ sentBackAs: string | null;
34
+ finishedAt: string | null;
35
+ };
36
+ export declare function buildReviewOf(store: Pick<Store, "handle">, runId: number): BuildReviewView | null;
37
+ export declare const findingWords: (finding: BuildFinding) => string;
38
+ /** The review's standing in one line, or null while nothing was asked. */
39
+ export declare function buildReviewHeadline(view: BuildReviewView | null): string | null;
40
+ /** Plain lines for `task show` and `assignment show`. */
41
+ export declare function buildReviewLines(view: BuildReviewView | null): string[];
@@ -0,0 +1,88 @@
1
+ export const REVIEW_SCHEMA = `
2
+ -- A project's explicit review switch. No row: on while the project is under a hands-off mode, off otherwise.
3
+ CREATE TABLE IF NOT EXISTS review_switch (
4
+ repo TEXT PRIMARY KEY,
5
+ enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
6
+ changed_by TEXT NOT NULL,
7
+ changed_at TEXT NOT NULL
8
+ );
9
+ -- One row per reviewed build run: pending until its one reviewer run settles it.
10
+ CREATE TABLE IF NOT EXISTS build_review (
11
+ run INTEGER PRIMARY KEY,
12
+ task_id TEXT NOT NULL,
13
+ repo TEXT NOT NULL,
14
+ state TEXT NOT NULL CHECK (state IN ('pending', 'reviewed', 'not-reviewed')),
15
+ request INTEGER,
16
+ reviewer_run INTEGER,
17
+ findings_json TEXT,
18
+ reason TEXT,
19
+ sent_back_as TEXT,
20
+ queued_at TEXT NOT NULL,
21
+ finished_at TEXT
22
+ );
23
+ CREATE INDEX IF NOT EXISTS build_review_pending ON build_review (repo) WHERE state = 'pending';
24
+ CREATE INDEX IF NOT EXISTS build_review_sent_back ON build_review (sent_back_as) WHERE sent_back_as IS NOT NULL;
25
+ `;
26
+ /** How the policy log words a switch state. */
27
+ export function reviewSwitchWords(state) {
28
+ return state.source === "project" ? (state.on ? "on" : "off") : state.on ? "on (hands-off default)" : "off (default)";
29
+ }
30
+ export function buildReviewOf(store, runId) {
31
+ let row;
32
+ // A deploy reads this with the candidate's code over the INSTALLED runtime's file, before this version's migration
33
+ // made the table: no table reads as no review.
34
+ try {
35
+ row = store.handle.prepare("SELECT * FROM build_review WHERE run = ?").get(runId);
36
+ }
37
+ catch (error) {
38
+ if (/no such table: build_review/.test(String(error)))
39
+ return null;
40
+ throw error;
41
+ }
42
+ if (row === undefined)
43
+ return null;
44
+ // The row was validated when it was written (parseBuildFindings).
45
+ let findings = [];
46
+ try {
47
+ const saved = row["findings_json"] === null ? null : JSON.parse(String(row["findings_json"]));
48
+ if (Array.isArray(saved?.findings))
49
+ findings = saved.findings;
50
+ }
51
+ catch { /* An unreadable saved row shows no findings; its state stays. */ }
52
+ return {
53
+ run: runId,
54
+ state: String(row["state"]),
55
+ reviewerRun: row["reviewer_run"] === null ? null : Number(row["reviewer_run"]),
56
+ reason: row["reason"] === null ? null : String(row["reason"]),
57
+ high: findings.filter(one => one.severity === "HIGH"),
58
+ followUps: findings.filter(one => one.severity !== "HIGH"),
59
+ sentBackAs: row["sent_back_as"] === null ? null : String(row["sent_back_as"]),
60
+ finishedAt: row["finished_at"] === null ? null : String(row["finished_at"]),
61
+ };
62
+ }
63
+ export const findingWords = (finding) => `${finding.severity} ${finding.file}:${finding.line} — ${finding.scenario}`;
64
+ /** The review's standing in one line, or null while nothing was asked. */
65
+ export function buildReviewHeadline(view) {
66
+ if (view === null)
67
+ return null;
68
+ if (view.state === "pending")
69
+ return "Reviewing — an automatic review is reading this result.";
70
+ if (view.state === "not-reviewed")
71
+ return `Not reviewed${view.reason === null ? "." : `: ${view.reason}`}`;
72
+ if (view.sentBackAs !== null)
73
+ return `Sent back as ${view.sentBackAs}: ${view.high.length} high finding${view.high.length === 1 ? "" : "s"}.`;
74
+ if (view.high.length > 0)
75
+ return `Reviewed: ${view.high.length} high finding${view.high.length === 1 ? "" : "s"} for you to decide.`;
76
+ return `Reviewed: no high findings${view.followUps.length === 0 ? "." : `; ${view.followUps.length} suggested follow-up${view.followUps.length === 1 ? "" : "s"}.`}`;
77
+ }
78
+ /** Plain lines for `task show` and `assignment show`. */
79
+ export function buildReviewLines(view) {
80
+ const headline = buildReviewHeadline(view);
81
+ if (view === null || headline === null)
82
+ return [];
83
+ return [
84
+ ` review: ${headline}`,
85
+ ...view.high.map(one => ` ${findingWords(one)}`),
86
+ ...view.followUps.map(one => ` suggested follow-up: ${findingWords(one)}`),
87
+ ];
88
+ }
@@ -1,6 +1,8 @@
1
- /** Pure parser for retained historical review payloads. Model-review execution
2
- * is retired; this module cannot launch providers, create runs or retry work. */
1
+ /** Pure parsers for review payloads: retained historical reviews, and the one
2
+ * automatic build review's findings (build-review.ts runs it). This module
3
+ * cannot launch providers, create runs or retry work. */
3
4
  import type { CriterionJudgementWord } from "./proof.js";
5
+ import type { BuildFinding } from "./review-switch.js";
4
6
  /** The files the pass writes INTO the scratch directory for the agent —
5
7
  * the patch always; the rubric, re-serialized proof, check log, and
6
8
  * screenshots only when this run actually has one (v40) — a grandfathered
@@ -95,3 +97,23 @@ export declare function parseReview(raw: string, patchPaths: ReadonlySet<string>
95
97
  ok: false;
96
98
  problems: ReviewProblem[];
97
99
  };
100
+ export type { BuildFinding, FindingSeverity } from "./review-switch.js";
101
+ export declare const FINDING_LIMITS: {
102
+ readonly findings: 40;
103
+ readonly file: 300;
104
+ readonly scenario: 500;
105
+ };
106
+ /**
107
+ * The automatic build review's reply, strict and wholesale like parseReview:
108
+ * `{"version":1,"findings":[{"severity","file","line","scenario"}]}`. Any
109
+ * invalid finding refuses the whole reply — a review that did not answer
110
+ * what it was asked is "not reviewed", never a partial pass. Text is kept
111
+ * to one line so it can travel in a revision note and a terminal.
112
+ */
113
+ export declare function parseBuildFindings(raw: string): {
114
+ ok: true;
115
+ findings: BuildFinding[];
116
+ } | {
117
+ ok: false;
118
+ problem: string;
119
+ };
package/dist/reviewer.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { citesSuppliedProvenance } from "./review-context.js";
2
- import { REVIEW_OUTPUT_LIMITS } from "./structured-output.js";
2
+ import { normalizeStructuredJson, REVIEW_OUTPUT_LIMITS } from "./structured-output.js";
3
3
  /** The files the pass writes INTO the scratch directory for the agent —
4
4
  * the patch always; the rubric, re-serialized proof, check log, and
5
5
  * screenshots only when this run actually has one (v40) — a grandfathered
@@ -200,3 +200,51 @@ export function parseReview(raw, patchPaths, approvedCriteriaIds = new Set(), pr
200
200
  return { ok: false, problems };
201
201
  return { ok: true, comments, criteria, ...(payload["learning"] === undefined ? {} : { learning: payload["learning"] }), ...(payload["learningAssessment"] === undefined ? {} : { learningAssessment: payload["learningAssessment"] }) };
202
202
  }
203
+ export const FINDING_LIMITS = { findings: 40, file: REVIEW_OUTPUT_LIMITS.path, scenario: REVIEW_OUTPUT_LIMITS.note };
204
+ /**
205
+ * The automatic build review's reply, strict and wholesale like parseReview:
206
+ * `{"version":1,"findings":[{"severity","file","line","scenario"}]}`. Any
207
+ * invalid finding refuses the whole reply — a review that did not answer
208
+ * what it was asked is "not reviewed", never a partial pass. Text is kept
209
+ * to one line so it can travel in a revision note and a terminal.
210
+ */
211
+ export function parseBuildFindings(raw) {
212
+ let parsed;
213
+ try {
214
+ parsed = JSON.parse(normalizeStructuredJson(raw).text);
215
+ }
216
+ catch {
217
+ return { ok: false, problem: "the reply is not JSON" };
218
+ }
219
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
220
+ return { ok: false, problem: "the reply is not an object" };
221
+ const payload = parsed;
222
+ if (payload["version"] !== 1)
223
+ return { ok: false, problem: "version must be 1" };
224
+ const list = payload["findings"];
225
+ if (!Array.isArray(list))
226
+ return { ok: false, problem: "findings must be an array" };
227
+ if (list.length > FINDING_LIMITS.findings)
228
+ return { ok: false, problem: `at most ${FINDING_LIMITS.findings} findings` };
229
+ const findings = [];
230
+ const oneLine = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/g, " ").replace(/\s+/g, " ").trim();
231
+ for (const [index, one] of list.entries()) {
232
+ if (one === null || typeof one !== "object" || Array.isArray(one))
233
+ return { ok: false, problem: `finding ${index}: not an object` };
234
+ const finding = one;
235
+ const severity = finding["severity"];
236
+ if (severity !== "HIGH" && severity !== "MEDIUM" && severity !== "LOW")
237
+ return { ok: false, problem: `finding ${index}: severity must be HIGH, MEDIUM or LOW` };
238
+ const file = typeof finding["file"] === "string" ? oneLine(finding["file"]) : "";
239
+ if (file.length === 0 || file.length > FINDING_LIMITS.file)
240
+ return { ok: false, problem: `finding ${index}: file must be a path of 1..${FINDING_LIMITS.file} characters` };
241
+ const line = finding["line"];
242
+ if (typeof line !== "number" || !Number.isInteger(line) || line < 1)
243
+ return { ok: false, problem: `finding ${index}: line must be a positive whole number` };
244
+ const scenario = typeof finding["scenario"] === "string" ? oneLine(finding["scenario"]) : "";
245
+ if (scenario.length === 0 || scenario.length > FINDING_LIMITS.scenario)
246
+ return { ok: false, problem: `finding ${index}: scenario must be one sentence of 1..${FINDING_LIMITS.scenario} characters` };
247
+ findings.push({ severity, file, line, scenario });
248
+ }
249
+ return { ok: true, findings };
250
+ }
package/dist/serve.js CHANGED
@@ -229,11 +229,12 @@ import { checkoutPlan, cleanCheckouts, discardCheckout, previewDigest } from "./
229
229
  import { UPDATES_CSS, newerThan, updateStepsHtml, updatesHtml, updatesScript } from "./toolroll-update-ui.js";
230
230
  import { abandonRuntimeUpdate, launchRuntimeUpdate, markWhatsNewSeen, prepareRuntimeUpdate, requestRuntimeUpdateCancel, runningWorkWords, runtimeUpdateStatus, runtimeUpdateTerminal, currentRuntime } from "./toolroll-update.js";
231
231
  import { latestVersionNow } from "./releases.js";
232
- import { RETENTION_KINDS, lastSweepAt, parsePeriod, retentionPlan } from "./retention.js";
232
+ import { RETENTION_KINDS, lastSweepAt, parsePeriod, periodChoices, retentionPlan } from "./retention.js";
233
233
  import { EXPORT_CSS, dataExportHtml } from "./export-ui.js";
234
234
  import { buildExport, exportZip } from "./export.js";
235
235
  import { PROJECT_DELETE_CSS, projectDeleteConfirmHtml, projectSettingsHtml } from "./project-delete-ui.js";
236
236
  import { parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, projectConcurrency, saveProjectConcurrency } from "./project-concurrency.js";
237
+ import { buildReviewOf, findingWords } from "./review-switch.js";
237
238
  import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
238
239
  import { POLICY_CSS, policyHtml } from "./policy-ui.js";
239
240
  import { checkPolicy, parseList, policyParts } from "./policy.js";
@@ -3898,7 +3899,7 @@ export function createDecisionServer(options) {
3898
3899
  if (url.pathname === "/settings/retention") {
3899
3900
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
3900
3901
  return refuse(response, who, 403, "An instance operator sets retention.", "/settings");
3901
- const view = { periods: store.retentionPeriods(), next: retentionPlan(store, evidenceRoot, now).counts, lastSweep: lastSweepAt(store), csrf: who.session.csrf };
3902
+ const view = { periods: store.retentionPeriods(), chosen: Object.keys(store.retentionChosen()), next: retentionPlan(store, evidenceRoot, now).counts, lastSweep: lastSweepAt(store), csrf: who.session.csrf };
3902
3903
  return sendScreen(response, 200, screen("Retention", `<p><a href="/settings">Settings</a></p><h1>Retention</h1>${retentionHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
3903
3904
  }
3904
3905
  // Settings → Storage: what task checkouts use, cleaning up now (the preview behind the password), and when a
@@ -3933,6 +3934,7 @@ export function createDecisionServer(options) {
3933
3934
  const quick = liveQuickCommand(store, repo);
3934
3935
  const html = checkSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange: who.role === "approver" && !store.isDemo(),
3935
3936
  level: projectCheckLevel(store, repo).level, full: store.liveVerifyCommand(repo), quick, suggestion: quick === null ? suggestQuickCommand(repo) : null,
3937
+ review: (({ on, source }) => ({ on, source }))(store.reviewSwitch(repo, clock())),
3936
3938
  said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
3937
3939
  return sendScreen(response, 200, screen("Checks", `<p><a href="/projects">Projects</a></p><h1>Checks</h1>${html}`, { chrome: chromeFor(project, "projects") }));
3938
3940
  }
@@ -5187,7 +5189,7 @@ export function createDecisionServer(options) {
5187
5189
  const reviewFactsFor = (runId) => reviewFactsOf(store.reviewRetryStateOf(runId), runner => {
5188
5190
  const one = store.getRunner(runner)?.runner;
5189
5191
  return one !== undefined && runnerAlive(one, clock());
5190
- });
5192
+ }, buildReviewOf(store, runId)?.state === "pending");
5191
5193
  /** Keep detailed receipt diagnostics; shared assignment reads own readiness. */
5192
5194
  function freshAssignment(assignment, receipt) {
5193
5195
  if (assignment === null)
@@ -7264,7 +7266,9 @@ export function createDecisionServer(options) {
7264
7266
  if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7265
7267
  return back("problem", "Enter your Toolroll password to change retention.");
7266
7268
  const before = store.retentionPeriods();
7267
- const chosen = RETENTION_KINDS.map(({ kind }) => ({ kind, days: body.has(kind) ? parsePeriod(body.get(kind) ?? "") : before[kind] }));
7269
+ // Only what the page offers (or the period already set): a hand-made value doesn't save.
7270
+ const offered = (kind, days) => days !== undefined && periodChoices(kind, before[kind]).includes(days) ? days : undefined;
7271
+ const chosen = RETENTION_KINDS.map(({ kind }) => ({ kind, days: body.has(kind) ? offered(kind, parsePeriod(body.get(kind) ?? "")) : before[kind] }));
7268
7272
  if (chosen.some(one => one.days === undefined))
7269
7273
  return back("problem", "Choose a period for each kind of data.");
7270
7274
  const changed = chosen.filter(one => one.days !== before[one.kind]);
@@ -7354,6 +7358,12 @@ export function createDecisionServer(options) {
7354
7358
  if (!authenticateApprover(store, who.name, body.get("password") ?? "", repo).ok)
7355
7359
  return back("problem", "Enter your Toolroll password to change checks.");
7356
7360
  const act = body.get("act");
7361
+ // The automatic review switch: the policy log keeps before → after.
7362
+ if (act === "review") {
7363
+ const on = body.get("on") === "1";
7364
+ store.setReviewSwitch(repo, on, who.name, now);
7365
+ return back("said", on ? "Automatic review is on." : "Automatic review is off.");
7366
+ }
7357
7367
  if (act === "level") {
7358
7368
  const level = body.get("level");
7359
7369
  if (!isCheckLevel(level))
@@ -11423,6 +11433,7 @@ export function createDecisionServer(options) {
11423
11433
  comments: store.liveDiffComments(run.id).filter(isRevisionFeedback),
11424
11434
  pastComments: store.allDiffComments(run.id).filter(one => one.reviewerRun === null && (one.consumedBy !== null || one.supersededBy !== null)),
11425
11435
  reviewerFindings: store.allDiffComments(run.id).filter(one => one.reviewerRun !== null),
11436
+ automaticReview: buildReviewOf(store, run.id),
11426
11437
  revisions: store.revisionsFromRun(run.id).flatMap(one => {
11427
11438
  const child = store.getTask(one.id);
11428
11439
  if (child === null || !visible(store.lookupRef(one.id)?.repo ?? null))
@@ -22115,6 +22126,16 @@ function resultPanelParts(detail, o) {
22115
22126
  if (current == null)
22116
22127
  attention.push(...receipt.caveats.filter(caveat => !historicalAssessmentReason(caveat) && !attention.some(problem => problem.includes(caveat))));
22117
22128
  attention.push(...(handoff?.followUps ?? []).map(one => `Follow-up: ${one}`));
22129
+ // The automatic review: a HIGH that did not send the work back, or a review
22130
+ // that could not finish, is the person's to see. (Said once when the
22131
+ // current assignment already carries it.)
22132
+ const automatic = detail.automaticReview ?? null;
22133
+ if (automatic !== null && automatic.sentBackAs === null) {
22134
+ const reviewed = [...(automatic.state === "not-reviewed" ? [`Not reviewed: ${automatic.reason ?? "the automatic review did not finish"}.`] : []), ...automatic.high.map(one => `Review: ${findingWords(one)}`)];
22135
+ for (const line of reviewed)
22136
+ if (!attention.includes(line))
22137
+ attention.push(line);
22138
+ }
22118
22139
  // Publication risks stay in the open (repair 2026-09-14); the routine
22119
22140
  // publication fact lives with the build details below.
22120
22141
  // With the shared status, the pull request is its own quiet row (its reason under Details).
@@ -22189,6 +22210,10 @@ function resultPanelParts(detail, o) {
22189
22210
  ];
22190
22211
  if (agentAccount.length > 0)
22191
22212
  summaryParts.push(`<details class="result-notes" data-result-notes-agent><summary>What the agent reported</summary>${agentAccount.join("")}</details>`);
22213
+ // MEDIUM and LOW never block: suggested follow-ups, on request.
22214
+ if (automatic !== null && automatic.followUps.length > 0) {
22215
+ summaryParts.push(`<details class="result-notes" data-review-followups="${automatic.followUps.length}"><summary>Suggested follow-ups · ${automatic.followUps.length}</summary><ul class="result-changes">${automatic.followUps.map(one => `<li><span class="badge">${one.severity === "MEDIUM" ? "Medium" : "Low"}</span> <span class="mono">${escape(`${one.file}:${one.line}`)}</span> ${escape(one.scenario)}</li>`).join("")}</ul></details>`);
22216
+ }
22192
22217
  const evidenceSources = [
22193
22218
  ...(report !== null ? [report.ok ? `verified report${report.truncated ? " (shortened)" : ""}` : "report (unverifiable)"] : []),
22194
22219
  ...(patch !== null ? [patchOk ? `sealed diff${patch.truncated ? " (shortened)" : ""}` : "diff (unverifiable)"] : []),
package/dist/store.d.ts CHANGED
@@ -15,6 +15,7 @@ import { type ProjectAccess } from "./project-access.js";
15
15
  import { type LedgerEntry } from "./action-ledger.js";
16
16
  import type { LimitReading, LimitWindow } from "./provider-limits.js";
17
17
  import { underCeiling, type OrgPolicy, type SavedPolicy } from "./policy.js";
18
+ import { type ReviewSwitch } from "./review-switch.js";
18
19
  import { type Billing, type Budget, type BudgetAgent, type BudgetHold, type BudgetScope, type BudgetState, type SpendItem } from "./spend.js";
19
20
  import { type RetentionKind, type RetentionPeriods } from "./retention.js";
20
21
  import { type CheckoutCleanup } from "./storage.js";
@@ -2419,6 +2420,14 @@ export declare class Store {
2419
2420
  };
2420
2421
  /** v99: a policy change, as the ledger keeps it: what changed and from what to what. Nothing is kept when nothing changed. */
2421
2422
  private recordPolicy;
2423
+ /** The project's automatic review switch: its explicit setting, else on
2424
+ * while a hands-off mode is active on it, else off. */
2425
+ reviewSwitch(repo: string, now: Date): ReviewSwitch;
2426
+ /** Turn a project's automatic review on or off; the policy log keeps before → after. */
2427
+ setReviewSwitch(repo: string, on: boolean, by: string, now: Date): {
2428
+ before: ReviewSwitch;
2429
+ after: ReviewSwitch;
2430
+ };
2422
2431
  /** A project deleted (project-delete.ts): who, and what it held → how it went. Returns the entry's id. */
2423
2432
  recordProjectDeleted(by: string, repo: string, held: string, how: string, now: Date): number;
2424
2433
  recordAction(entry: Omit<LedgerEntry, "id" | "detail"> & {
@@ -5101,8 +5110,10 @@ export declare class Store {
5101
5110
  hardStop: boolean;
5102
5111
  }, by: string, now: Date): Budget;
5103
5112
  removeBudget(id: number, by: string, now: Date): boolean;
5104
- /** How long each kind of data is kept; null is forever (every kind, until someone chooses). */
5113
+ /** How long each kind of data is kept; null is forever. A kind nobody chose keeps its default (evidence 28 days, the rest forever). */
5105
5114
  retentionPeriods(): RetentionPeriods;
5115
+ /** The periods someone chose: only kinds with a saved row. */
5116
+ retentionChosen(): Partial<RetentionPeriods>;
5106
5117
  /** Set how long one kind is kept (null: forever); the ledger keeps before → after. */
5107
5118
  setRetentionPeriod(kind: RetentionKind, days: number | null, by: string, now: Date): RetentionPeriods;
5108
5119
  /** Settings → Storage: when a finished task's clean checkout is removed (the default until someone chooses). */
@@ -7078,6 +7089,30 @@ export declare class Store {
7078
7089
  group: boolean;
7079
7090
  }): number;
7080
7091
  finishUnspawnedProcess(witness: number, now: Date): void;
7092
+ /** The reconcile road for a pid-less witness a crash or a thrown spawn
7093
+ * left behind: once its run has finished, no live child is tracked for it
7094
+ * here, every other witness of the run has exited and none of the run's
7095
+ * process groups is alive, it settles as never started, with a ledger
7096
+ * entry. A sole pid-less witness has no group to prove gone and stays for
7097
+ * `run settle`. Native witnesses keep their OS-only road. */
7098
+ settleUnspawnedWitnesses(now: Date): number;
7099
+ /** `run settle` (an approver's last resort): every witness of a finished
7100
+ * run that cannot be proven either way is recorded settled under the
7101
+ * approver's reason, in the ledger. Refuses while anything of the run is
7102
+ * alive or could be checked only from another host. Never signals. */
7103
+ settleRunWitnessesByApprover(args: {
7104
+ runId: number;
7105
+ by: string;
7106
+ why: string;
7107
+ }, now: Date): {
7108
+ ok: true;
7109
+ witnesses: number[];
7110
+ repeated: boolean;
7111
+ } | {
7112
+ ok: false;
7113
+ reason: "unknown-run" | "still-running" | "alive";
7114
+ detail: string;
7115
+ };
7081
7116
  recordRunProcess(runId: number, pid: number, now: Date, group?: boolean, witness?: number): void;
7082
7117
  /** The native OS object a witnessed spawn ran inside (v53) — recorded
7083
7118
  * with the spawn, before the target can execute. */