pitroom 0.9.0 → 0.10.0

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/pitroom.mjs CHANGED
@@ -1413,7 +1413,8 @@ var SCHEMA = {
1413
1413
  maxParallel: "number",
1414
1414
  models: "record",
1415
1415
  tiers: "record",
1416
- costs: "numbers"
1416
+ costs: "numbers",
1417
+ audit: "number"
1417
1418
  };
1418
1419
  function configPath() {
1419
1420
  if (process.env.PITROOM_CONFIG) return path7.resolve(process.env.PITROOM_CONFIG);
@@ -1457,6 +1458,7 @@ function matches(v, type) {
1457
1458
  if (type === "numbers") {
1458
1459
  return typeof v === "object" && v !== null && !Array.isArray(v) && Object.values(v).every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0);
1459
1460
  }
1461
+ if (type === "number") return typeof v === "number" && Number.isFinite(v) && v >= 0;
1460
1462
  return typeof v === type;
1461
1463
  }
1462
1464
  var DEFAULT_PARALLEL = 20;
@@ -1466,6 +1468,10 @@ var positiveInt = (v) => {
1466
1468
  const n = Number(v);
1467
1469
  return v !== void 0 && v !== "" && Number.isInteger(n) && n > 0 ? n : void 0;
1468
1470
  };
1471
+ var rate = (v) => {
1472
+ const n = typeof v === "string" && v.trim() !== "" ? Number(v) : v;
1473
+ return typeof n === "number" && Number.isFinite(n) && n >= 0 ? Math.min(n, 1) : void 0;
1474
+ };
1469
1475
  var list = (s) => s?.split(",").map((x) => x.trim()).filter(Boolean);
1470
1476
  function setting(flag2, env, conf, fallback) {
1471
1477
  if (flag2 !== void 0) return { value: flag2, source: "flag" };
@@ -1489,7 +1495,8 @@ function effective(flags = {}) {
1489
1495
  maxParallel: clampParallel(setting(void 0, positiveInt(e.PITROOM_MAX_PARALLEL), positiveInt(c.maxParallel), DEFAULT_PARALLEL)),
1490
1496
  models: setting(void 0, void 0, c.models, {}),
1491
1497
  tiers: setting(void 0, void 0, c.tiers, {}),
1492
- costs: setting(void 0, void 0, c.costs, {})
1498
+ costs: setting(void 0, void 0, c.costs, {}),
1499
+ audit: setting(void 0, rate(e.PITROOM_AUDIT), rate(c.audit), 0)
1493
1500
  };
1494
1501
  }
1495
1502
 
@@ -1536,6 +1543,8 @@ var BOOL_FLAGS = {
1536
1543
  "-i": "isolate",
1537
1544
  "--isolate": "isolate",
1538
1545
  "--bg": "bg",
1546
+ "--audit": "audit",
1547
+ "--no-audit": "no-audit",
1539
1548
  "--web": "web",
1540
1549
  "--no-fallback": "no-fallback",
1541
1550
  "--json": "json",
@@ -1622,8 +1631,11 @@ function runOptions(p, task) {
1622
1631
  const modes = ["read", "write", "isolate"].filter((m) => has(p, m));
1623
1632
  if (modes.length > 1) throw new UserError("choose one of --read, --write, --isolate");
1624
1633
  const cont = flag(p, "continue");
1634
+ if (has(p, "audit") && has(p, "no-audit")) throw new UserError("choose one of --audit, --no-audit");
1635
+ if (has(p, "audit") && (has(p, "write") || has(p, "isolate"))) throw new UserError("audits re-check the answer of a read run: drop -w/-i");
1625
1636
  return {
1626
1637
  mode: modes[0] ?? "read",
1638
+ auditRate: has(p, "no-audit") ? 0 : has(p, "audit") ? 1 : void 0,
1627
1639
  task,
1628
1640
  dir: flag(p, "dir") ?? process.cwd(),
1629
1641
  files: p.flags.get("file") ?? [],
@@ -1782,6 +1794,93 @@ function formatInit(plan, written) {
1782
1794
  return out.join("\n");
1783
1795
  }
1784
1796
 
1797
+ // src/core/audit.ts
1798
+ import crypto2 from "node:crypto";
1799
+
1800
+ // src/core/target.ts
1801
+ function parseTarget(spec, defaultBackend) {
1802
+ const s = spec.trim();
1803
+ if (!s) throw new UserError("empty worker target");
1804
+ const i = s.indexOf(":");
1805
+ if (i > 0 && isBackendId(s.slice(0, i))) {
1806
+ const model = s.slice(i + 1).trim();
1807
+ return model ? { backend: s.slice(0, i), model } : { backend: s.slice(0, i) };
1808
+ }
1809
+ if (isBackendId(s)) return { backend: s };
1810
+ return { backend: defaultBackend, model: s };
1811
+ }
1812
+ var formatTarget = (t) => t.model ? `${t.backend}:${t.model}` : t.backend;
1813
+ var describeTarget = (t) => t.model ? formatTarget(t) : `${t.backend} (default model)`;
1814
+ var sameTarget = (a, b) => a.backend === b.backend && a.model === b.model;
1815
+
1816
+ // src/core/audit.ts
1817
+ var ANSWER_MAX = 8e3;
1818
+ function auditRate(meta) {
1819
+ return meta.auditRate ?? effective().audit.value;
1820
+ }
1821
+ function draw(id) {
1822
+ return crypto2.createHash("sha256").update(id).digest().readUInt32BE(0) / 4294967296;
1823
+ }
1824
+ function auditable(meta, answer) {
1825
+ return meta.mode === "read" && meta.state === "done" && !meta.reviewOf && !meta.auditOf && !meta.audit && !!answer.trim();
1826
+ }
1827
+ function sampled(meta) {
1828
+ const rate2 = auditRate(meta);
1829
+ return rate2 > 0 && (rate2 >= 1 || draw(meta.id) < rate2);
1830
+ }
1831
+ function pickAuditor(meta) {
1832
+ const eff = effective();
1833
+ const tiers = eff.tiers.value;
1834
+ const def = parseTarget(eff.worker.value, DEFAULT_BACKEND).backend;
1835
+ const ran = meta.ran ?? meta.worker;
1836
+ const candidates = [tiers.audit, tiers.cheap, ...eff.fallback.value, eff.worker.value].filter((s) => !!s);
1837
+ return candidates.find((c) => !sameTarget(parseTarget(c, def), ran));
1838
+ }
1839
+ function auditTask(meta, answer) {
1840
+ const text = answer.trim();
1841
+ const shown = text.length > ANSWER_MAX ? `${text.slice(0, ANSWER_MAX)}
1842
+ \u2026 (cut: ${text.length - ANSWER_MAX} more characters)` : text;
1843
+ return [
1844
+ "You are auditing another worker's answer to a read-only question about this project. Do not take the answer on trust:",
1845
+ "check its key claims yourself against the files here (open the cited files and lines, search for what it says exists or is missing).",
1846
+ "Do not edit anything and do not run anything that changes files.",
1847
+ "",
1848
+ "QUESTION:",
1849
+ meta.task.trim(),
1850
+ "",
1851
+ "ANSWER TO AUDIT:",
1852
+ shown,
1853
+ "",
1854
+ "Reply in exactly this form and nothing else:",
1855
+ "AUDIT: AGREE | PARTIAL | DISAGREE",
1856
+ "CHECKED: <how many key claims you checked>",
1857
+ "DISPUTED:",
1858
+ "- <the claim> \u2014 <what is actually true, with path:line>",
1859
+ "",
1860
+ "AGREE: every key claim you checked holds. PARTIAL: some are wrong or could not be verified. DISAGREE: the main conclusion is wrong.",
1861
+ 'Under DISPUTED list only claims you checked and found wrong or unsupported; write "- (none)" when there are none.'
1862
+ ].join("\n");
1863
+ }
1864
+ function auditLines(meta) {
1865
+ if (meta.auditOf) {
1866
+ return meta.state === "done" ? [`\u2500\u2500 audit of ${meta.auditOf}: ${(meta.auditVerdict ?? "unclear").toUpperCase()}${meta.auditDisputed?.length ? ` \xB7 ${meta.auditDisputed.length} disputed` : ""}`] : [];
1867
+ }
1868
+ const a = meta.audit;
1869
+ if (!a) return [];
1870
+ if (a.state === "done") {
1871
+ const head = `\u2500\u2500 audit (run ${a.id}): ${(a.verdict ?? "unclear").toUpperCase()}${a.verdict === "agree" ? "" : a.verdict === "unclear" ? " (the auditor did not answer in the expected form)" : ""}`;
1872
+ return [head, ...(a.disputed ?? []).map((d) => ` disputed: ${d}`)];
1873
+ }
1874
+ if (a.state === "running" || a.state === "queued") return [`\u2500\u2500 audit: another worker is re-checking this answer in the background (pitroom show ${a.id})`];
1875
+ return [`\u2500\u2500 audit (run ${a.id}) ${a.state}: no verdict`];
1876
+ }
1877
+ function auditBadge(meta) {
1878
+ const a = meta.audit;
1879
+ if (!a) return void 0;
1880
+ if (a.state === "done") return (a.verdict ?? "unclear").toUpperCase();
1881
+ return a.state === "running" || a.state === "queued" ? "PENDING" : "FAILED";
1882
+ }
1883
+
1785
1884
  // src/core/history.ts
1786
1885
  import fs12 from "node:fs";
1787
1886
  import { createRequire } from "node:module";
@@ -1812,7 +1911,7 @@ function savedUsd(usage2, returnedTokens, price = primaryPrice()) {
1812
1911
  return Math.max(0, wouldCost - (usage2.cost ?? 0) - readingTheReport);
1813
1912
  }
1814
1913
  function record(meta) {
1815
- if (!meta.usage?.steps) return;
1914
+ if (!meta.usage?.steps || meta.auditOf) return;
1816
1915
  const entry = {
1817
1916
  id: meta.id,
1818
1917
  at: meta.endedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
@@ -1939,6 +2038,7 @@ var RUN_ID = /\b\d{8}-\d{6}-[0-9a-f]{4}\b/g;
1939
2038
  var PITROOM_CALL = /(^|[\s;&|(`])(\S*\/)?pitroom(\.mjs)?(\s|$)/;
1940
2039
  var ICON = { done: "\u2714", failed: "\u2718", timeout: "\u23F1", stopped: "\u25A0" };
1941
2040
  function kind(m) {
2041
+ if (m.auditOf) return "audit";
1942
2042
  if (m.reviewOf) return m.reviewKind === "range" ? "branch review" : m.reviewKind === "fix" ? "re-review" : "review";
1943
2043
  return m.mode === "read" ? "research" : m.mode === "isolate" ? "change (isolated copy)" : "change";
1944
2044
  }
@@ -1962,6 +2062,7 @@ function ranTarget(m) {
1962
2062
  }
1963
2063
  function what(m) {
1964
2064
  if (m.plan) return short(`Task ${m.plan.step}: ${m.plan.title}`);
2065
+ if (m.auditOf) return `of ${m.auditOf}`;
1965
2066
  if (m.reviewOf) return `of ${m.reviewOf.replace(/\b([0-9a-f]{9})[0-9a-f]{31}\b/g, "$1")}`;
1966
2067
  const line = m.task.split("\n").find((l) => l.trim()) ?? "";
1967
2068
  const brief2 = /^You are implementing Task (\d+)\b.*?\bplan\s+(\S+)/i.exec(line);
@@ -1981,6 +2082,7 @@ function endedCard(m) {
1981
2082
  what(m),
1982
2083
  elapsed(m),
1983
2084
  v && `SPEC ${v.spec.toUpperCase()} \xB7 QUALITY ${v.quality.toUpperCase()}`,
2085
+ m.auditVerdict && `${m.auditVerdict.toUpperCase()}${m.auditDisputed?.length ? ` \xB7 ${m.auditDisputed.length} disputed` : ""}`,
1984
2086
  m.changes?.length ? `${m.changes.length} file${m.changes.length === 1 ? "" : "s"} changed` : "",
1985
2087
  m.savedUsd ? `~${usd(m.savedUsd)} saved` : ""
1986
2088
  ];
@@ -2031,7 +2133,7 @@ ${output}`.match(RUN_ID) ?? [])].filter((id) => fs11.existsSync(`${runsDir()}/${
2031
2133
  // src/core/history.ts
2032
2134
  var SCHEMA_VERSION = 1;
2033
2135
  var PATCH_MAX = 1e6;
2034
- var ANSWER_MAX = 2e5;
2136
+ var ANSWER_MAX2 = 2e5;
2035
2137
  var cached2;
2036
2138
  var historyFile = () => path10.join(home(), "history.db");
2037
2139
  function openDb() {
@@ -2127,7 +2229,7 @@ function recordRun(meta) {
2127
2229
  try {
2128
2230
  const u = meta.usage;
2129
2231
  const known = db.prepare("SELECT answer IS NOT NULL AS has FROM runs WHERE id = ?").get(meta.id);
2130
- const answer = known?.has ? void 0 : clip5(readRunFile(meta.id, "summary.md")?.trim(), ANSWER_MAX);
2232
+ const answer = known?.has ? void 0 : clip5(readRunFile(meta.id, "summary.md")?.trim(), ANSWER_MAX2);
2131
2233
  const patch = known?.has ? void 0 : clip5(readRunFile(meta.id, "changes.patch"), PATCH_MAX);
2132
2234
  const seconds = meta.endedAt ? Math.max(0, Math.round((Date.parse(meta.endedAt) - Date.parse(meta.startedAt)) / 1e3)) : null;
2133
2235
  const t = meta.ran ?? meta.worker;
@@ -2143,8 +2245,8 @@ function recordRun(meta) {
2143
2245
  task: clip5(meta.task, 2e4) ?? "",
2144
2246
  grp: meta.group ?? null,
2145
2247
  dir: meta.dir,
2146
- review_of: meta.reviewOf ?? null,
2147
- verdict: meta.verdict ? `SPEC ${meta.verdict.spec.toUpperCase()} \xB7 QUALITY ${meta.verdict.quality.toUpperCase()}` : null,
2248
+ review_of: meta.reviewOf ?? meta.auditOf ?? null,
2249
+ verdict: meta.verdict ? `SPEC ${meta.verdict.spec.toUpperCase()} \xB7 QUALITY ${meta.verdict.quality.toUpperCase()}` : meta.auditVerdict ? `AUDIT ${meta.auditVerdict.toUpperCase()}` : null,
2148
2250
  seconds,
2149
2251
  steps: u?.steps ?? null,
2150
2252
  tool_calls: u?.toolCalls ?? null,
@@ -2261,7 +2363,7 @@ function listHistory(q = {}) {
2261
2363
  try {
2262
2364
  const total = db.prepare(`SELECT COUNT(*) AS n FROM runs r ${base2}`).get(...args).n;
2263
2365
  const page = q.beforeId ? `${base2 ? `${base2} AND` : "WHERE"} r.id < ?` : base2;
2264
- const rows = db.prepare(`SELECT r.* FROM runs r ${page} ORDER BY r.id DESC LIMIT ?`).all(...args, ...q.beforeId ? [q.beforeId] : [], Math.min(Math.max(q.limit ?? 30, 1), 200));
2366
+ const rows = db.prepare(`SELECT r.*, json_extract(r.meta_json, '$.audit.state') AS audit_state, json_extract(r.meta_json, '$.audit.verdict') AS audit_verdict FROM runs r ${page} ORDER BY r.id DESC LIMIT ?`).all(...args, ...q.beforeId ? [q.beforeId] : [], Math.min(Math.max(q.limit ?? 30, 1), 200));
2265
2367
  return {
2266
2368
  total,
2267
2369
  rows: rows.map((r) => ({
@@ -2274,6 +2376,7 @@ function listHistory(q = {}) {
2274
2376
  task: r.review_of ? `of ${r.review_of.replace(/\b([0-9a-f]{9})[0-9a-f]{31}\b/g, "$1")}` : r.task.split("\n").find((l) => l.trim()) ?? "",
2275
2377
  group: r.grp ?? void 0,
2276
2378
  verdict: r.verdict ?? void 0,
2379
+ audit: auditBadge({ audit: r.audit_state ? { id: "", state: r.audit_state, verdict: r.audit_verdict ?? void 0 } : void 0 }),
2277
2380
  seconds: r.seconds ?? void 0,
2278
2381
  steps: r.steps ?? void 0,
2279
2382
  tokens: r.tokens ?? void 0,
@@ -2286,37 +2389,68 @@ function listHistory(q = {}) {
2286
2389
  return { rows: [], total: 0 };
2287
2390
  }
2288
2391
  }
2289
- function workerRows(rows, ledger) {
2290
- const out = rows.map((r) => ({ backend: r.backend, model: r.model ?? void 0, runs: r.runs, ok: r.ok ?? 0, avgSeconds: r.avg_s, avgTokens: r.avg_t, saved: 0 }));
2392
+ var NOT_AUDIT = "json_extract(meta_json, '$.auditOf') IS NULL";
2393
+ function auditStats(db, since) {
2394
+ const total = { runs: 0, agree: 0, partial: 0, disagree: 0, unclear: 0, tokens: 0 };
2395
+ const byWorker = /* @__PURE__ */ new Map();
2396
+ try {
2397
+ const t = db.prepare("SELECT COUNT(*) runs, COALESCE(SUM(tokens),0) tokens FROM runs WHERE started_at >= ? AND json_extract(meta_json, '$.auditOf') IS NOT NULL").get(since);
2398
+ total.runs = t.runs;
2399
+ total.tokens = t.tokens;
2400
+ const rows = db.prepare(`SELECT r.backend, r.model, json_extract(a.meta_json, '$.auditVerdict') v, COUNT(*) n
2401
+ FROM runs a JOIN runs r ON r.id = json_extract(a.meta_json, '$.auditOf')
2402
+ WHERE a.started_at >= ? AND a.state = 'done' AND json_extract(a.meta_json, '$.auditOf') IS NOT NULL
2403
+ GROUP BY r.backend, r.model, v`).all(since);
2404
+ for (const r of rows) {
2405
+ const v = r.v ?? "unclear";
2406
+ if (v in total && v !== "runs" && v !== "tokens") total[v] += r.n;
2407
+ const k = `${r.backend ?? ""}\0${r.model ?? ""}`;
2408
+ const w = byWorker.get(k) ?? { audited: 0, agreed: 0 };
2409
+ w.audited += r.n;
2410
+ if (r.v === "agree") w.agreed += r.n;
2411
+ byWorker.set(k, w);
2412
+ }
2413
+ } catch {
2414
+ }
2415
+ return { total, byWorker };
2416
+ }
2417
+ function workerRows(rows, ledger, audited) {
2418
+ const out = rows.map((r) => ({ backend: r.backend, model: r.model ?? void 0, runs: r.runs, ok: r.ok ?? 0, avgSeconds: r.avg_s, avgTokens: r.avg_t, saved: 0, audited: 0, agreed: 0 }));
2291
2419
  const key = (backend, model) => `${backend ?? ""}\0${model ?? ""}`;
2292
2420
  const index = new Map(out.map((r) => [key(r.backend, r.model), r]));
2293
2421
  for (const e of ledger) {
2294
2422
  const backend = e.backend ?? "opencode";
2295
2423
  let row = index.get(key(backend, e.model)) ?? (e.model ? out.find((r) => r.backend === backend && r.model?.endsWith(`/${e.model}`)) : void 0);
2296
2424
  if (!row) {
2297
- row = { backend, model: e.model, runs: 0, ok: 0, avgSeconds: null, avgTokens: null, saved: 0 };
2425
+ row = { backend, model: e.model, runs: 0, ok: 0, avgSeconds: null, avgTokens: null, saved: 0, audited: 0, agreed: 0 };
2298
2426
  index.set(key(backend, e.model), row);
2299
2427
  out.push(row);
2300
2428
  }
2301
2429
  row.saved += e.saved;
2302
2430
  }
2431
+ for (const r of out) {
2432
+ const a = audited.get(key(r.backend, r.model));
2433
+ if (a) Object.assign(r, a);
2434
+ }
2303
2435
  return out;
2304
2436
  }
2305
2437
  function historyStats(sinceMs2) {
2306
- const empty = { totals: { runs: 0, ok: 0, failed: 0, seconds: 0, tokens: 0, saved: 0 }, byWorker: [], byDay: [] };
2438
+ const empty = { totals: { runs: 0, ok: 0, failed: 0, seconds: 0, tokens: 0, saved: 0 }, audits: { runs: 0, agree: 0, partial: 0, disagree: 0, unclear: 0, tokens: 0 }, byWorker: [], byDay: [] };
2307
2439
  const db = openDb();
2308
2440
  if (!db) return empty;
2309
2441
  const since = sinceMs2 ? new Date(sinceMs2).toISOString() : "";
2310
2442
  try {
2311
- const t = db.prepare("SELECT COUNT(*) runs, COALESCE(SUM(state='done'),0) ok, COALESCE(SUM(seconds),0) seconds, COALESCE(SUM(tokens),0) tokens, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ?").get(since);
2312
- const w = db.prepare("SELECT backend, model, COUNT(*) runs, SUM(state='done') ok, AVG(seconds) avg_s, AVG(tokens) avg_t, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? GROUP BY backend, model ORDER BY runs DESC LIMIT 40").all(since);
2313
- const d = db.prepare("SELECT substr(started_at,1,10) day, COUNT(*) runs, SUM(state='done') ok, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? GROUP BY day ORDER BY day DESC LIMIT 60").all(since);
2443
+ const t = db.prepare(`SELECT COUNT(*) runs, COALESCE(SUM(state='done'),0) ok, COALESCE(SUM(seconds),0) seconds, COALESCE(SUM(tokens),0) tokens, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT}`).get(since);
2444
+ const w = db.prepare(`SELECT backend, model, COUNT(*) runs, SUM(state='done') ok, AVG(seconds) avg_s, AVG(tokens) avg_t, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT} GROUP BY backend, model ORDER BY runs DESC LIMIT 40`).all(since);
2445
+ const d = db.prepare(`SELECT substr(started_at,1,10) day, COUNT(*) runs, SUM(state='done') ok, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT} GROUP BY day ORDER BY day DESC LIMIT 60`).all(since);
2446
+ const audits = auditStats(db, since);
2314
2447
  const ledger = readLedger(sinceMs2);
2315
2448
  const byDay = /* @__PURE__ */ new Map();
2316
2449
  for (const e of ledger) byDay.set(e.at.slice(0, 10), (byDay.get(e.at.slice(0, 10)) ?? 0) + e.saved);
2317
2450
  return {
2318
2451
  totals: { runs: t.runs, ok: t.ok, failed: t.runs - t.ok, seconds: t.seconds, tokens: t.tokens, saved: totals(ledger).saved },
2319
- byWorker: workerRows(w, ledger),
2452
+ audits: audits.total,
2453
+ byWorker: workerRows(w, ledger, audits.byWorker),
2320
2454
  byDay: d.reverse().map((r) => ({ day: r.day, runs: r.runs, ok: r.ok ?? 0, saved: byDay.get(r.day) ?? 0 }))
2321
2455
  };
2322
2456
  } catch {
@@ -2447,6 +2581,7 @@ function formatReport(meta, finalText = readSummary(meta), maxLines = 400) {
2447
2581
  if (lines.length > maxLines) out.push(`\u2026 (${lines.length - maxLines} more lines: pitroom show ${meta.id} --full)`);
2448
2582
  }
2449
2583
  out.push("");
2584
+ out.push(...auditLines(meta));
2450
2585
  if (meta.refs) {
2451
2586
  const r = meta.refs;
2452
2587
  const bad = r.invalid.slice(0, 8).map((i) => `${i.ref} (${i.reason})`).join(", ");
@@ -2508,22 +2643,6 @@ function progress(meta) {
2508
2643
  return `pitroom \u2026 running \xB7 ${meta.mode} \xB7 ${duration(meta)} \xB7 ${l.steps} steps, ${l.toolCalls} tool calls${last} \xB7 run ${meta.id}`;
2509
2644
  }
2510
2645
 
2511
- // src/core/target.ts
2512
- function parseTarget(spec, defaultBackend) {
2513
- const s = spec.trim();
2514
- if (!s) throw new UserError("empty worker target");
2515
- const i = s.indexOf(":");
2516
- if (i > 0 && isBackendId(s.slice(0, i))) {
2517
- const model = s.slice(i + 1).trim();
2518
- return model ? { backend: s.slice(0, i), model } : { backend: s.slice(0, i) };
2519
- }
2520
- if (isBackendId(s)) return { backend: s };
2521
- return { backend: defaultBackend, model: s };
2522
- }
2523
- var formatTarget = (t) => t.model ? `${t.backend}:${t.model}` : t.backend;
2524
- var describeTarget = (t) => t.model ? formatTarget(t) : `${t.backend} (default model)`;
2525
- var sameTarget = (a, b) => a.backend === b.backend && a.model === b.model;
2526
-
2527
2646
  // src/core/group.ts
2528
2647
  function groupIds(group) {
2529
2648
  return listRunIds().filter((id) => {
@@ -2758,11 +2877,11 @@ function installedSkills() {
2758
2877
  }
2759
2878
 
2760
2879
  // src/core/plan-status.ts
2761
- import crypto2 from "node:crypto";
2880
+ import crypto3 from "node:crypto";
2762
2881
  import fs16 from "node:fs";
2763
2882
  import path13 from "node:path";
2764
2883
  function notesFile(plan) {
2765
- const id = crypto2.createHash("sha1").update(plan.file).digest("hex").slice(0, 12);
2884
+ const id = crypto3.createHash("sha1").update(plan.file).digest("hex").slice(0, 12);
2766
2885
  return path13.join(home(), "plans", id, "notes.md");
2767
2886
  }
2768
2887
  function readNotes(plan) {
@@ -2847,13 +2966,13 @@ ${rulings}`,
2847
2966
  }
2848
2967
 
2849
2968
  // src/core/review.ts
2850
- import crypto4 from "node:crypto";
2969
+ import crypto5 from "node:crypto";
2851
2970
  import fs18 from "node:fs";
2852
2971
  import path15 from "node:path";
2853
2972
 
2854
2973
  // src/vcs/git.ts
2855
2974
  import { spawnSync as spawnSync5 } from "node:child_process";
2856
- import crypto3 from "node:crypto";
2975
+ import crypto4 from "node:crypto";
2857
2976
  import fs17 from "node:fs";
2858
2977
  import os9 from "node:os";
2859
2978
  import path14 from "node:path";
@@ -2891,7 +3010,7 @@ function repoRoot(dir) {
2891
3010
  return r.code === 0 ? path14.resolve(r.stdout.trim()) : void 0;
2892
3011
  }
2893
3012
  function snapshotTree(root, exclude = []) {
2894
- const tmp = path14.join(os9.tmpdir(), `pitroom-index-${process.pid}-${crypto3.randomBytes(4).toString("hex")}`);
3013
+ const tmp = path14.join(os9.tmpdir(), `pitroom-index-${process.pid}-${crypto4.randomBytes(4).toString("hex")}`);
2895
3014
  const real = path14.resolve(root, must(root, ["rev-parse", "--git-path", "index"]).trim());
2896
3015
  const env = { ...process.env, GIT_INDEX_FILE: tmp };
2897
3016
  try {
@@ -3102,7 +3221,7 @@ function pickReviewer(job) {
3102
3221
  function writePackage(job) {
3103
3222
  const g = gitDir(job.dir);
3104
3223
  if (!g) throw new UserError(`not a git repository: ${job.dir}`);
3105
- const file = path15.join(g, "pitroom", `review-${crypto4.randomBytes(4).toString("hex")}.md`);
3224
+ const file = path15.join(g, "pitroom", `review-${crypto5.randomBytes(4).toString("hex")}.md`);
3106
3225
  fs18.mkdirSync(path15.dirname(file), { recursive: true });
3107
3226
  fs18.writeFileSync(file, job.package);
3108
3227
  return file;
@@ -3226,7 +3345,7 @@ function formatModels(t) {
3226
3345
 
3227
3346
  // src/core/dash.ts
3228
3347
  import { spawn } from "node:child_process";
3229
- import crypto5 from "node:crypto";
3348
+ import crypto6 from "node:crypto";
3230
3349
  import fs20 from "node:fs";
3231
3350
  import http from "node:http";
3232
3351
  import path17 from "node:path";
@@ -3385,10 +3504,11 @@ function toRun(m) {
3385
3504
  steps: l ? l.steps : m.usage?.steps ?? 0,
3386
3505
  tokens: m.usage?.total,
3387
3506
  saved: m.savedUsd || void 0,
3388
- verdict: m.verdict ? `SPEC ${m.verdict.spec.toUpperCase()} \xB7 QUALITY ${m.verdict.quality.toUpperCase()}` : void 0,
3507
+ verdict: m.verdict ? `SPEC ${m.verdict.spec.toUpperCase()} \xB7 QUALITY ${m.verdict.quality.toUpperCase()}` : m.auditVerdict ? `AUDIT ${m.auditVerdict.toUpperCase()}` : void 0,
3508
+ audit: auditBadge(m),
3389
3509
  changes: m.changes?.length || void 0,
3390
3510
  applied: m.applied || void 0,
3391
- note: l?.last ? oneLine6(String(l.last), 140) : isActive(m.state) ? "" : m.verdict ? findings(m) : headline(m, 200) || oneLine6(m.error ?? "", 200)
3511
+ note: l?.last ? oneLine6(String(l.last), 140) : isActive(m.state) ? "" : m.verdict ? findings(m) : m.auditOf && m.state === "done" ? m.auditDisputed?.length ? `${m.auditDisputed.length} disputed` : "nothing disputed" : headline(m, 200) || oneLine6(m.error ?? "", 200)
3392
3512
  };
3393
3513
  }
3394
3514
  function dashState(opts = {}) {
@@ -3448,7 +3568,7 @@ function runDetail(id) {
3448
3568
  id,
3449
3569
  state: m.state,
3450
3570
  card: toRun(m),
3451
- task: (m.reviewOf ? `Review of ${m.reviewOf.replace(/\b([0-9a-f]{9})[0-9a-f]{31}\b/g, "$1")}` : m.task).slice(0, TASK_MAX),
3571
+ task: (m.auditOf ? `Audit of ${m.auditOf}` : m.reviewOf ? `Review of ${m.reviewOf.replace(/\b([0-9a-f]{9})[0-9a-f]{31}\b/g, "$1")}` : m.task).slice(0, TASK_MAX),
3452
3572
  steps,
3453
3573
  answer,
3454
3574
  changes: m.changes ?? [],
@@ -3476,6 +3596,7 @@ function runDetail(id) {
3476
3596
  refs: m.refs ? { valid: m.refs.valid, total: m.refs.total, invalid: m.refs.invalid.map((r) => `${r.ref} (${r.reason})`) } : void 0,
3477
3597
  verify: m.verifyResult && m.verify ? { command: m.verify, ok: m.verifyResult.ok, tail: m.verifyResult.tail } : void 0,
3478
3598
  error: m.error,
3599
+ audit: m.auditOf ? m.state === "done" ? { state: m.state, verdict: m.auditVerdict, disputed: m.auditDisputed ?? [] } : void 0 : m.audit && { id: m.audit.id, state: m.audit.state, verdict: m.audit.verdict, disputed: m.audit.disputed ?? [] },
3479
3600
  report: isActive(m.state) ? progress(m) : formatReport(m, void 0, 120)
3480
3601
  };
3481
3602
  }
@@ -3484,7 +3605,7 @@ var HEADERS = {
3484
3605
  "x-content-type-options": "nosniff",
3485
3606
  "referrer-policy": "no-referrer",
3486
3607
  // Own files only, plus the hash of the one inline theme script; styles may be inline (the components position popups with them).
3487
- "content-security-policy": `default-src 'none'; script-src 'self' 'sha256-${crypto5.createHash("sha256").update(THEME_SCRIPT).digest("base64")}'; style-src 'self' 'unsafe-inline'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'`
3608
+ "content-security-policy": `default-src 'none'; script-src 'self' 'sha256-${crypto6.createHash("sha256").update(THEME_SCRIPT).digest("base64")}'; style-src 'self' 'unsafe-inline'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'`
3488
3609
  };
3489
3610
  var ASSETS = { "/assets/app.js": "text/javascript; charset=utf-8", "/assets/app.css": "text/css; charset=utf-8" };
3490
3611
  var assetsDir = () => fileURLToPath3(new URL("./ui/", import.meta.url));
@@ -3910,9 +4031,16 @@ function parseVerdict(text) {
3910
4031
  minor: count("minor")
3911
4032
  };
3912
4033
  }
4034
+ function parseAudit(text) {
4035
+ const word = /^\s*AUDIT:\s*(AGREE|PARTIAL|DISAGREE)\b/im.exec(text)?.[1]?.toLowerCase();
4036
+ const verdict = word === "agree" || word === "partial" || word === "disagree" ? word : "unclear";
4037
+ const after = /^\s*DISPUTED:\s*$/im.exec(text);
4038
+ const disputed = after ? text.slice(after.index + after[0].length).split("\n").map((l) => l.trim()).filter((l) => /^[-*•]\s+\S/.test(l)).map((l) => l.replace(/^[-*•]\s+/, "")).filter((l) => !/^\(?none\)?\.?$/i.test(l)).slice(0, 8) : [];
4039
+ return { verdict, disputed };
4040
+ }
3913
4041
 
3914
4042
  // src/core/slots.ts
3915
- import crypto6 from "node:crypto";
4043
+ import crypto7 from "node:crypto";
3916
4044
  import fs23 from "node:fs";
3917
4045
  import path19 from "node:path";
3918
4046
  var slotsDir = () => path19.join(home(), "slots");
@@ -3967,7 +4095,7 @@ function tryAcquireSlot(runId, maxParallel) {
3967
4095
  return void 0;
3968
4096
  }
3969
4097
  var releaseSlot = (file, runId) => releaseIfOwner(file, runId);
3970
- var lockFile = (repoRoot2) => path19.join(locksDir(), `write-${crypto6.createHash("sha1").update(path19.resolve(repoRoot2)).digest("hex").slice(0, 16)}`);
4098
+ var lockFile = (repoRoot2) => path19.join(locksDir(), `write-${crypto7.createHash("sha1").update(path19.resolve(repoRoot2)).digest("hex").slice(0, 16)}`);
3971
4099
  function acquireWriteLock(repoRoot2, runId) {
3972
4100
  const file = lockFile(repoRoot2);
3973
4101
  if (tryClaim(file, runId)) return;
@@ -4225,7 +4353,7 @@ function secretWarning(files, mode) {
4225
4353
  }
4226
4354
 
4227
4355
  // src/core/run.ts
4228
- var VERSION2 = true ? "0.9.0" : "0.0.0-dev";
4356
+ var VERSION2 = true ? "0.10.0" : "0.0.0-dev";
4229
4357
  function planWork(o) {
4230
4358
  if (!o.plan) return { task: o.task.trim(), tier: o.tier };
4231
4359
  if (o.continueFrom) throw new UserError("a follow-up continues its parent's task; drop --plan/--step");
@@ -4315,6 +4443,8 @@ function prepareRun(o) {
4315
4443
  baseTree: parent?.mode === "isolate" ? parent.baseTree : void 0,
4316
4444
  worktree: parent?.mode === "isolate" ? parent.worktree : void 0,
4317
4445
  reviewOf: o.review?.of,
4446
+ auditOf: o.audit?.of,
4447
+ auditRate: o.auditRate,
4318
4448
  plan: work.plan ?? o.review?.plan ?? parent?.plan,
4319
4449
  reviewKind: o.review?.kind,
4320
4450
  packageFile: o.review?.packageFile
@@ -4416,7 +4546,48 @@ async function execute(meta) {
4416
4546
  } finally {
4417
4547
  releaseSlot(slot, meta.id);
4418
4548
  }
4419
- return finalize(meta, result);
4549
+ const done = finalize(meta, result);
4550
+ autoAudit(done);
4551
+ return done;
4552
+ }
4553
+ function autoAudit(meta) {
4554
+ try {
4555
+ if (!auditable(meta, read(runFile(meta.id, "summary.md"))) || !sampled(meta)) return;
4556
+ startAudit(meta);
4557
+ } catch {
4558
+ }
4559
+ }
4560
+ function startAudit(meta, worker) {
4561
+ const answer = read(runFile(meta.id, "summary.md"));
4562
+ const auditor = worker ?? pickAuditor(meta);
4563
+ if (!auditor) return void 0;
4564
+ const a = prepareRun({
4565
+ mode: "read",
4566
+ task: auditTask(meta, answer),
4567
+ dir: meta.dir,
4568
+ files: [],
4569
+ link: [],
4570
+ worker: auditor,
4571
+ timeoutSec: Math.min(meta.timeoutSec, 15 * 60),
4572
+ allowNonGit: true,
4573
+ web: false,
4574
+ // never fall back to the worker being audited
4575
+ noFallback: true,
4576
+ group: meta.group,
4577
+ audit: { of: meta.id }
4578
+ });
4579
+ meta.audit = { id: a.id, state: "running" };
4580
+ writeMeta(meta);
4581
+ startInBackground(a);
4582
+ return a;
4583
+ }
4584
+ function settleAudit(a) {
4585
+ try {
4586
+ const target = readMeta(a.auditOf);
4587
+ target.audit = { id: a.id, state: a.state, verdict: a.state === "done" ? a.auditVerdict : void 0, disputed: a.state === "done" ? a.auditDisputed : void 0 };
4588
+ writeMeta(target);
4589
+ } catch {
4590
+ }
4420
4591
  }
4421
4592
  function prepareTree(meta) {
4422
4593
  const root = meta.repoRoot;
@@ -4496,14 +4667,20 @@ function finalize(meta, res) {
4496
4667
  }
4497
4668
  }
4498
4669
  meta.state = res.timedOut ? "timeout" : res.stopped ? "stopped" : meta.error ? "failed" : "done";
4670
+ if (meta.auditOf && meta.state === "done") {
4671
+ const found = parseAudit(run.finalText);
4672
+ meta.auditVerdict = found.verdict;
4673
+ meta.auditDisputed = found.disputed;
4674
+ }
4499
4675
  if (meta.state === "done" && !run.finalText) meta.warnings.push("worker finished without a written answer");
4500
4676
  if (meta.state === "done" && meta.verify) meta.verifyResult = runVerify(meta);
4501
4677
  meta.endedAt = (/* @__PURE__ */ new Date()).toISOString();
4502
4678
  meta.returnedTokens = estimateTokens(formatReport(meta, run.finalText));
4503
- meta.savedUsd = savedUsd(meta.usage, meta.returnedTokens);
4679
+ meta.savedUsd = meta.auditOf ? 0 : savedUsd(meta.usage, meta.returnedTokens);
4504
4680
  writeMeta(meta);
4505
4681
  if (meta.mode === "write" && meta.repoRoot) releaseWriteLock(meta.repoRoot, meta.id);
4506
4682
  record(meta);
4683
+ if (meta.auditOf) settleAudit(meta);
4507
4684
  return meta;
4508
4685
  }
4509
4686
  function captureChanges(meta) {
@@ -4628,6 +4805,33 @@ async function cmdReview(p) {
4628
4805
  fs27.writeFileSync(runFile(meta.id, "package.md"), job.package);
4629
4806
  return launch(p, meta);
4630
4807
  }
4808
+ async function cmdAudit(p) {
4809
+ if (!p.positional[0]) throw new UserError("pitroom audit RUN: which run?");
4810
+ if (has(p, "write") || has(p, "isolate")) throw new UserError("audits are read-only; drop -w/-i");
4811
+ const m = freshMeta(resolveRun(p.positional[0]));
4812
+ if (isActive(m.state)) throw new UserError(`run ${m.id} is still ${m.state}; pitroom wait ${m.id} first`, 3);
4813
+ if (m.reviewOf || m.auditOf) throw new UserError(`run ${m.id} is itself a ${m.reviewOf ? "review" : "audit"}`);
4814
+ if (m.mode !== "read") throw new UserError(`run ${m.id} changed files; an audit re-checks a read run's answer: pitroom review ${m.id} judges a change`);
4815
+ const answer = readSummary(m);
4816
+ if (!answer.trim()) throw new UserError(`run ${m.id} gave no answer to audit`);
4817
+ const worker = flag(p, "worker") ?? (flag(p, "tier") ? void 0 : pickAuditor(m));
4818
+ if (!worker && !flag(p, "tier")) {
4819
+ throw new UserError('no other worker to audit with: name one with -W, or configure tiers "audit" or "cheap", or fallback workers, that differ from the one that answered', 3);
4820
+ }
4821
+ const a = prepareRun({
4822
+ ...runOptions(p, auditTask(m, answer)),
4823
+ mode: "read",
4824
+ dir: m.dir,
4825
+ worker,
4826
+ group: flag(p, "group") ?? m.group,
4827
+ noFallback: true,
4828
+ audit: { of: m.id },
4829
+ auditRate: void 0
4830
+ });
4831
+ m.audit = { id: a.id, state: "running" };
4832
+ writeMeta(m);
4833
+ return launch(p, a);
4834
+ }
4631
4835
  function cmdPlan(p) {
4632
4836
  const [sub, file, ...rest] = p.positional;
4633
4837
  if (sub === "status" && file) {
@@ -5045,6 +5249,11 @@ function doctor5(probe) {
5045
5249
  add("fail", e.message);
5046
5250
  }
5047
5251
  if (chain.length) add("ok", `worker chain: ${chain.map(describeTarget).join(" \u2192 ")}`);
5252
+ const rate2 = effective().audit.value;
5253
+ if (rate2 > 0 && chain.length) {
5254
+ const auditor = pickAuditor({ worker: chain[0], fallback: chain.slice(1) });
5255
+ add(auditor ? "ok" : "warn", auditor ? `audit: ${Math.round(rate2 * 100)}% of read runs are re-checked by ${auditor}` : `audit is on (${Math.round(rate2 * 100)}%) but no other worker could do it: add tiers "audit" or "cheap", or a fallback, that differs from ${describeTarget(chain[0])}`);
5256
+ }
5048
5257
  const tierTargets = [];
5049
5258
  const tierNames = [];
5050
5259
  for (const name of Object.keys(effective().tiers.value)) {
@@ -5268,6 +5477,8 @@ Usage
5268
5477
  pitroom review [run | --range A..B [--plan PLAN]] [--tier T | -W T] [--bg]
5269
5478
  read-only review of a run's change (a follow-up: only its
5270
5479
  fix round) or of a commit range; by default on another worker
5480
+ pitroom audit RUN [-W worker] another worker re-checks a read run's answer (AGREE / PARTIAL / DISAGREE);
5481
+ "audit" in the config (0 to 1) does it for a share of read runs
5271
5482
  pitroom plan status PLAN [--json] a plan's progress: runs, STATUS, review, fix rounds, applied
5272
5483
  pitroom plan note PLAN "Task N: \u2026" record a completion, deferred finding or ruling (outside the repo)
5273
5484
  pitroom status [run | -g NAME] state / live progress (default: latest run)
@@ -5315,6 +5526,7 @@ Run options
5315
5526
  -W, --worker T worker target "backend[:model]" (default: config "worker", else opencode)
5316
5527
  -m, --model M model for that worker (default: the worker CLI's own default)
5317
5528
  --tier NAME a worker from the config's "tiers" (e.g. cheap, standard, capable); -W wins
5529
+ --audit have another worker re-check this read run's answer; --no-audit skips it (default: the config's "audit")
5318
5530
  --effort LEVEL reasoning effort for the worker: low, medium, high, xhigh, \u2026 (model#level)
5319
5531
  --plan PLAN with --step N: implement Task N of a plan (-i or -w); the task text is your notes
5320
5532
  --step N the plan task for --plan
@@ -5343,6 +5555,7 @@ var COMMANDS = {
5343
5555
  run: cmdRun,
5344
5556
  crew: cmdCrew,
5345
5557
  review: cmdReview,
5558
+ audit: cmdAudit,
5346
5559
  plan: cmdPlan,
5347
5560
  status: cmdStatus,
5348
5561
  wait: cmdWait,