@openthink/stamp 1.1.0 → 1.2.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/index.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  firstParentCommits,
12
12
  formatTrailers,
13
13
  generateKeypair,
14
+ gitCommonDir,
14
15
  isPathTracked,
15
16
  latestReviews,
16
17
  latestVerdicts,
@@ -38,18 +39,19 @@ import {
38
39
  stampReviewersDir,
39
40
  stampStateDbPath,
40
41
  stampTrustedKeysDir,
42
+ userConfigPath,
41
43
  userKeysDir,
42
44
  userServerConfigPath,
43
45
  verifyBytes
44
- } from "./chunk-TTOMORIY.js";
46
+ } from "./chunk-UBRQLZON.js";
45
47
 
46
48
  // src/index.ts
47
49
  import { Command } from "commander";
48
50
 
49
51
  // src/commands/bootstrap.ts
50
52
  import { execFileSync } from "child_process";
51
- import { existsSync as existsSync5, readFileSync as readFileSync4, readdirSync, statSync, writeFileSync as writeFileSync4 } from "fs";
52
- import { dirname as dirname2, join as join3 } from "path";
53
+ import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync, statSync, writeFileSync as writeFileSync5 } from "fs";
54
+ import { dirname as dirname3, join as join3 } from "path";
53
55
 
54
56
  // src/lib/agentsMd.ts
55
57
  import { existsSync, readFileSync, writeFileSync } from "fs";
@@ -464,9 +466,19 @@ function validateConfig(input) {
464
466
  throw new Error(`config.branches.${name}.required must be an array`);
465
467
  }
466
468
  const required_checks = parseChecks(r.required_checks, name);
469
+ let require_human_merge;
470
+ if (r.require_human_merge !== void 0) {
471
+ if (typeof r.require_human_merge !== "boolean") {
472
+ throw new Error(
473
+ `config.branches.${name}.require_human_merge must be a boolean`
474
+ );
475
+ }
476
+ require_human_merge = r.require_human_merge;
477
+ }
467
478
  branches[name] = {
468
479
  required: r.required.map(String),
469
- ...required_checks ? { required_checks } : {}
480
+ ...required_checks ? { required_checks } : {},
481
+ ...require_human_merge !== void 0 ? { require_human_merge } : {}
470
482
  };
471
483
  }
472
484
  const reviewers2 = {};
@@ -484,10 +496,20 @@ function validateConfig(input) {
484
496
  }
485
497
  const tools = parseTools(d.tools, name);
486
498
  const mcp_servers = parseMcpServers(d.mcp_servers, name);
499
+ let enforce_reads_on_dotstamp;
500
+ if (d.enforce_reads_on_dotstamp !== void 0) {
501
+ if (typeof d.enforce_reads_on_dotstamp !== "boolean") {
502
+ throw new Error(
503
+ `config.reviewers.${name}.enforce_reads_on_dotstamp must be a boolean (got ${JSON.stringify(d.enforce_reads_on_dotstamp)})`
504
+ );
505
+ }
506
+ enforce_reads_on_dotstamp = d.enforce_reads_on_dotstamp;
507
+ }
487
508
  reviewers2[name] = {
488
509
  prompt: d.prompt,
489
510
  ...tools ? { tools } : {},
490
- ...mcp_servers ? { mcp_servers } : {}
511
+ ...mcp_servers ? { mcp_servers } : {},
512
+ ...enforce_reads_on_dotstamp !== void 0 ? { enforce_reads_on_dotstamp } : {}
491
513
  };
492
514
  }
493
515
  return { branches, reviewers: reviewers2 };
@@ -707,8 +729,8 @@ function parseStringMap(input, path2) {
707
729
  }
708
730
  return out;
709
731
  }
710
- function stringifyConfig(config) {
711
- return stringify(config);
732
+ function stringifyConfig(config2) {
733
+ return stringify(config2);
712
734
  }
713
735
  function findBranchRule(branches, branchName) {
714
736
  const exact = branches[branchName];
@@ -1262,14 +1284,61 @@ function redactMcpToolName(tool2) {
1262
1284
  return `mcp__sha256:${h(server2)}__sha256:${h(name)}`;
1263
1285
  }
1264
1286
  function redactToolCallsForAttestation(calls) {
1265
- if (process.env.STAMP_HASH_MCP_NAMES !== "1") return calls;
1287
+ if (process.env.STAMP_HASH_MCP_NAMES === "0") return calls;
1266
1288
  return calls.map((c) => ({ ...c, tool: redactMcpToolName(c.tool) }));
1267
1289
  }
1268
1290
 
1291
+ // src/lib/humanMerge.ts
1292
+ import { readSync } from "fs";
1293
+ function requireHumanMerge(args) {
1294
+ if (args.branchRule.require_human_merge === false) return;
1295
+ if (args.yes) return;
1296
+ if (process.env.STAMP_REQUIRE_HUMAN_MERGE === "0") return;
1297
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1298
+ throw new Error(
1299
+ `confirmation required: stamp merge needs interactive confirmation for protected branch "${args.target}", but no TTY is attached.
1300
+
1301
+ Opt out explicitly \u2014 pick one:
1302
+ - per-invocation: stamp merge ${args.source} --into ${args.target} --yes
1303
+ - per-shell: STAMP_REQUIRE_HUMAN_MERGE=0 stamp merge ...
1304
+ - per-branch: add 'require_human_merge: false' under branches.${args.target} in .stamp/config.yml (and merge that change through the normal review flow)
1305
+
1306
+ Background: stamp's threat model treats LLM-verdict-as-merge-authorization as residual HIGH (audit H1). The default forces operator awareness; the env var / flag / config field are how you declare automated intent.`
1307
+ );
1308
+ }
1309
+ const prompt2 = `Sign + merge '${args.source}' (${args.head_sha.slice(0, 8)}) \u2192 '${args.target}' (base ${args.base_sha.slice(0, 8)})? [y/N] `;
1310
+ process.stdout.write(prompt2);
1311
+ const answer = readLineSync().trim().toLowerCase();
1312
+ if (answer !== "y" && answer !== "yes") {
1313
+ throw new Error(
1314
+ `merge cancelled: operator answered '${answer || "<empty>"}' to the confirmation prompt for ${args.source} \u2192 ${args.target}.`
1315
+ );
1316
+ }
1317
+ }
1318
+ function readLineSync() {
1319
+ const buf = Buffer.alloc(1);
1320
+ let out = "";
1321
+ const fd = 0;
1322
+ for (; ; ) {
1323
+ let n;
1324
+ try {
1325
+ n = readSync(fd, buf, 0, 1, null);
1326
+ } catch {
1327
+ break;
1328
+ }
1329
+ if (n === 0) break;
1330
+ const ch = buf.toString("utf8", 0, 1);
1331
+ if (ch === "\n") break;
1332
+ out += ch;
1333
+ }
1334
+ if (out.endsWith("\r")) out = out.slice(0, -1);
1335
+ return out;
1336
+ }
1337
+
1269
1338
  // src/commands/merge.ts
1270
1339
  function runMerge(opts) {
1271
1340
  const repoRoot = findRepoRoot();
1272
- const config = loadConfig(stampConfigFile(repoRoot));
1341
+ const config2 = loadConfig(stampConfigFile(repoRoot));
1273
1342
  const currentBranch2 = git(
1274
1343
  ["rev-parse", "--abbrev-ref", "HEAD"],
1275
1344
  repoRoot
@@ -1290,7 +1359,7 @@ function runMerge(opts) {
1290
1359
  }
1291
1360
  const revspec = `${opts.into}..${opts.branch}`;
1292
1361
  const resolved = resolveDiff(revspec, repoRoot);
1293
- const rule = findBranchRule(config.branches, opts.into);
1362
+ const rule = findBranchRule(config2.branches, opts.into);
1294
1363
  if (!rule) {
1295
1364
  throw new Error(
1296
1365
  `no branch rule for "${opts.into}" in .stamp/config.yml`
@@ -1317,6 +1386,14 @@ function runMerge(opts) {
1317
1386
  `gate CLOSED: missing approved verdicts for: ${missing.join(", ")}. Run \`stamp status --diff ${revspec}\` to inspect, then \`stamp review --diff ${revspec}\` to review.`
1318
1387
  );
1319
1388
  }
1389
+ requireHumanMerge({
1390
+ target: opts.into,
1391
+ source: opts.branch,
1392
+ base_sha: resolved.base_sha,
1393
+ head_sha: resolved.head_sha,
1394
+ branchRule: rule,
1395
+ yes: opts.yes ?? false
1396
+ });
1320
1397
  approvals = rule.required.map((name) => {
1321
1398
  const rev = byReviewer.get(name);
1322
1399
  const toolCalls = redactToolCallsForAttestation(parseToolCalls(rev.tool_calls));
@@ -1485,11 +1562,11 @@ function runPush(opts) {
1485
1562
  }
1486
1563
 
1487
1564
  // src/commands/review.ts
1488
- import { existsSync as existsSync4 } from "fs";
1565
+ import { existsSync as existsSync5 } from "fs";
1489
1566
 
1490
1567
  // src/lib/reviewer.ts
1491
1568
  import { randomBytes } from "crypto";
1492
- import { chmodSync, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
1569
+ import { chmodSync, mkdirSync as mkdirSync2, realpathSync, writeFileSync as writeFileSync3 } from "fs";
1493
1570
  import path from "path";
1494
1571
  import { createSdkMcpServer, query, tool } from "@anthropic-ai/claude-agent-sdk";
1495
1572
  import { z as z2 } from "zod";
@@ -1525,10 +1602,137 @@ ${body}
1525
1602
  ${close}`;
1526
1603
  }
1527
1604
 
1605
+ // src/lib/userConfig.ts
1606
+ import {
1607
+ existsSync as existsSync3,
1608
+ mkdirSync,
1609
+ readFileSync as readFileSync4,
1610
+ renameSync,
1611
+ unlinkSync,
1612
+ writeFileSync as writeFileSync2
1613
+ } from "fs";
1614
+ import { dirname } from "path";
1615
+ import { parse as parseYaml3, stringify as stringifyYaml } from "yaml";
1616
+ var DEFAULT_REVIEWER_MODELS = {
1617
+ security: "claude-sonnet-4-6",
1618
+ standards: "claude-sonnet-4-6",
1619
+ product: "claude-sonnet-4-6"
1620
+ };
1621
+ var REVIEWER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
1622
+ var MODEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/;
1623
+ function isValidReviewerName(name) {
1624
+ return REVIEWER_NAME_RE.test(name);
1625
+ }
1626
+ function isValidModelId(id) {
1627
+ return MODEL_ID_RE.test(id) && id.length <= 128;
1628
+ }
1629
+ function loadUserConfig() {
1630
+ const path2 = userConfigPath();
1631
+ if (!existsSync3(path2)) return null;
1632
+ let raw;
1633
+ try {
1634
+ raw = readFileSync4(path2, "utf8");
1635
+ } catch (err) {
1636
+ throw new Error(
1637
+ `failed to read ${path2}: ${err instanceof Error ? err.message : String(err)}`
1638
+ );
1639
+ }
1640
+ return parseUserConfig(raw, path2);
1641
+ }
1642
+ function parseUserConfig(raw, contextPath = "<inline>") {
1643
+ const trimmed = raw.trim();
1644
+ if (trimmed === "") {
1645
+ return { reviewers: {} };
1646
+ }
1647
+ const parsed = parseYaml3(raw);
1648
+ if (parsed === null || parsed === void 0) {
1649
+ return { reviewers: {} };
1650
+ }
1651
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
1652
+ throw new Error(
1653
+ `${contextPath}: must be a YAML mapping (got ${Array.isArray(parsed) ? "array" : typeof parsed})`
1654
+ );
1655
+ }
1656
+ const obj = parsed;
1657
+ const reviewersRaw = obj.reviewers;
1658
+ const reviewers2 = {};
1659
+ if (reviewersRaw !== void 0 && reviewersRaw !== null) {
1660
+ if (typeof reviewersRaw !== "object" || Array.isArray(reviewersRaw)) {
1661
+ throw new Error(
1662
+ `${contextPath}: 'reviewers' must be a mapping of <reviewer-name> to <model-id>`
1663
+ );
1664
+ }
1665
+ for (const [name, value] of Object.entries(
1666
+ reviewersRaw
1667
+ )) {
1668
+ if (!isValidReviewerName(name)) {
1669
+ throw new Error(
1670
+ `${contextPath}: reviewer name '${name}' under 'reviewers' is invalid (letters, digits, underscores, hyphens; max 64 chars; no leading hyphen)`
1671
+ );
1672
+ }
1673
+ if (typeof value !== "string" || value.trim() === "") {
1674
+ throw new Error(
1675
+ `${contextPath}: reviewers.${name} must be a non-empty string (model id)`
1676
+ );
1677
+ }
1678
+ const id = value.trim();
1679
+ if (!isValidModelId(id)) {
1680
+ throw new Error(
1681
+ `${contextPath}: reviewers.${name} = ${JSON.stringify(value)} is not a valid model id (expected a token like 'claude-sonnet-4-6'; the SDK accepts opaque strings, but stamp rejects shapes with whitespace or control chars)`
1682
+ );
1683
+ }
1684
+ reviewers2[name] = id;
1685
+ }
1686
+ }
1687
+ return { reviewers: reviewers2 };
1688
+ }
1689
+ function stringifyUserConfig(cfg) {
1690
+ return stringifyYaml({ reviewers: cfg.reviewers });
1691
+ }
1692
+ function writeUserConfig(cfg) {
1693
+ const path2 = userConfigPath();
1694
+ const dir = dirname(path2);
1695
+ if (!existsSync3(dir)) mkdirSync(dir, { recursive: true, mode: 448 });
1696
+ const tmp = `${path2}.tmp.${process.pid}`;
1697
+ writeFileSync2(tmp, stringifyUserConfig(cfg), { mode: 384 });
1698
+ renameSync(tmp, path2);
1699
+ return path2;
1700
+ }
1701
+ function loadOrCreateUserConfig() {
1702
+ const path2 = userConfigPath();
1703
+ const existed = existsSync3(path2);
1704
+ if (!existed) {
1705
+ const defaults = {
1706
+ reviewers: { ...DEFAULT_REVIEWER_MODELS }
1707
+ };
1708
+ writeUserConfig(defaults);
1709
+ return { config: defaults, created: true, path: path2 };
1710
+ }
1711
+ const config2 = loadUserConfig() ?? { reviewers: {} };
1712
+ return { config: config2, created: false, path: path2 };
1713
+ }
1714
+ function resolveReviewerModel(reviewer) {
1715
+ let cfg;
1716
+ try {
1717
+ cfg = loadUserConfig();
1718
+ } catch {
1719
+ return null;
1720
+ }
1721
+ if (!cfg) return null;
1722
+ const id = cfg.reviewers[reviewer];
1723
+ return typeof id === "string" && id.length > 0 ? id : null;
1724
+ }
1725
+ function deleteUserConfig() {
1726
+ const path2 = userConfigPath();
1727
+ if (!existsSync3(path2)) return false;
1728
+ unlinkSync(path2);
1729
+ return true;
1730
+ }
1731
+
1528
1732
  // src/lib/reviewer.ts
1529
1733
  var VERDICT_LINE_REGEX = /^VERDICT:\s*(approved|changes_requested|denied)\s*$/;
1530
- var REVIEWER_INTERNAL_DENY_PATHS = [".git/stamp/state.db"];
1531
- var REVIEWER_INTERNAL_DENY_PREFIXES = [".stamp/trusted-keys/"];
1734
+ var REVIEWER_INTERNAL_DENY_PATHS = [];
1735
+ var REVIEWER_INTERNAL_DENY_PREFIXES = [".git/stamp/", ".stamp/trusted-keys/"];
1532
1736
  function denyIfOutsideRepo(inputPath, repoRoot, toolName) {
1533
1737
  if (typeof inputPath !== "string" || inputPath.length === 0) {
1534
1738
  return `${toolName} input path must be a non-empty string`;
@@ -1540,6 +1744,40 @@ function denyIfOutsideRepo(inputPath, repoRoot, toolName) {
1540
1744
  }
1541
1745
  return null;
1542
1746
  }
1747
+ function denyIfRealpathOutsideRepo(resolved, resolvedRoot, inputPath, toolName) {
1748
+ let canonRoot;
1749
+ try {
1750
+ canonRoot = realpathSync.native(resolvedRoot);
1751
+ } catch {
1752
+ return { canon: null, canonRoot: null, deny: null };
1753
+ }
1754
+ let probe = resolved;
1755
+ let realPrefix = null;
1756
+ const tail = [];
1757
+ for (; ; ) {
1758
+ try {
1759
+ realPrefix = realpathSync.native(probe);
1760
+ break;
1761
+ } catch {
1762
+ const parent = path.dirname(probe);
1763
+ if (parent === probe) break;
1764
+ tail.unshift(path.basename(probe));
1765
+ probe = parent;
1766
+ }
1767
+ }
1768
+ if (realPrefix === null) {
1769
+ return { canon: null, canonRoot: null, deny: null };
1770
+ }
1771
+ const canon = tail.length === 0 ? realPrefix : path.join(realPrefix, ...tail);
1772
+ if (canon !== canonRoot && !canon.startsWith(canonRoot + path.sep)) {
1773
+ return {
1774
+ canon,
1775
+ canonRoot,
1776
+ deny: `${toolName} path "${inputPath}" resolves through a symlink to "${canon}", which is outside repoRoot ("${canonRoot}"). Reviewer tools are scoped to the repository; symlinks pointing out are treated the same as a literal escape.`
1777
+ };
1778
+ }
1779
+ return { canon, canonRoot, deny: null };
1780
+ }
1543
1781
  function denyIfReviewerInternal(resolvedAbs, resolvedRoot, inputPath) {
1544
1782
  const rel = path.relative(resolvedRoot, resolvedAbs);
1545
1783
  for (const denied of REVIEWER_INTERNAL_DENY_PATHS) {
@@ -1549,7 +1787,7 @@ function denyIfReviewerInternal(resolvedAbs, resolvedRoot, inputPath) {
1549
1787
  }
1550
1788
  for (const prefix of REVIEWER_INTERNAL_DENY_PREFIXES) {
1551
1789
  if (rel === prefix.replace(/\/$/, "") || rel.startsWith(prefix)) {
1552
- return `Read of "${inputPath}" denied: ${prefix}* holds reviewer trust anchors and is exfil-attractive.`;
1790
+ return `Read of "${inputPath}" denied: ${prefix}* is reviewer-internal (trust anchors / verdict DB / spools) and is exfil-attractive.`;
1553
1791
  }
1554
1792
  }
1555
1793
  return null;
@@ -1593,9 +1831,18 @@ function checkReviewerTool(args) {
1593
1831
  if (denied) return { allow: false, reason: denied };
1594
1832
  const resolvedRoot = path.resolve(repoRoot);
1595
1833
  const resolved = path.resolve(resolvedRoot, filePath);
1596
- const internal = denyIfReviewerInternal(
1834
+ const realpathCheck = denyIfRealpathOutsideRepo(
1597
1835
  resolved,
1598
1836
  resolvedRoot,
1837
+ filePath,
1838
+ "Read"
1839
+ );
1840
+ if (realpathCheck.deny) return { allow: false, reason: realpathCheck.deny };
1841
+ const internalProbe = realpathCheck.canon ?? resolved;
1842
+ const internalRoot = realpathCheck.canonRoot ?? resolvedRoot;
1843
+ const internal = denyIfReviewerInternal(
1844
+ internalProbe,
1845
+ internalRoot,
1599
1846
  filePath
1600
1847
  );
1601
1848
  if (internal) return { allow: false, reason: internal };
@@ -1606,6 +1853,15 @@ function checkReviewerTool(args) {
1606
1853
  if (grepPath !== void 0) {
1607
1854
  const denied = denyIfOutsideRepo(grepPath, repoRoot, "Grep");
1608
1855
  if (denied) return { allow: false, reason: denied };
1856
+ const resolvedRoot = path.resolve(repoRoot);
1857
+ const resolved = path.resolve(resolvedRoot, grepPath);
1858
+ const realpathCheck = denyIfRealpathOutsideRepo(
1859
+ resolved,
1860
+ resolvedRoot,
1861
+ grepPath,
1862
+ "Grep"
1863
+ );
1864
+ if (realpathCheck.deny) return { allow: false, reason: realpathCheck.deny };
1609
1865
  }
1610
1866
  return { allow: true };
1611
1867
  }
@@ -1614,6 +1870,15 @@ function checkReviewerTool(args) {
1614
1870
  if (globPath !== void 0) {
1615
1871
  const denied = denyIfOutsideRepo(globPath, repoRoot, "Glob");
1616
1872
  if (denied) return { allow: false, reason: denied };
1873
+ const resolvedRoot = path.resolve(repoRoot);
1874
+ const resolved = path.resolve(resolvedRoot, globPath);
1875
+ const realpathCheck = denyIfRealpathOutsideRepo(
1876
+ resolved,
1877
+ resolvedRoot,
1878
+ globPath,
1879
+ "Glob"
1880
+ );
1881
+ if (realpathCheck.deny) return { allow: false, reason: realpathCheck.deny };
1617
1882
  }
1618
1883
  const pattern = input.pattern;
1619
1884
  if (typeof pattern === "string") {
@@ -1635,6 +1900,11 @@ function checkReviewerTool(args) {
1635
1900
  return { allow: true };
1636
1901
  }
1637
1902
  async function invokeReviewer(params) {
1903
+ if (process.env.STAMP_NO_LLM === "1") {
1904
+ throw new Error(
1905
+ `STAMP_NO_LLM=1 is set; refusing to invoke the Claude Agent SDK for reviewer "${params.reviewer}". With this env var on, stamp's LLM-using surface (review / reviewers test / bootstrap) is disabled \u2014 no diff content will leave the host. The signing, verification, and merge primitives (stamp keys / stamp merge / stamp verify / stamp log / the pre-receive hook) all continue to work; you can attest manual review by capturing verdicts in state.db out-of-band before merge. Unset STAMP_NO_LLM (or set it to anything other than "1") to re-enable.`
1906
+ );
1907
+ }
1638
1908
  const def = params.config.reviewers[params.reviewer];
1639
1909
  if (!def) {
1640
1910
  throw new Error(
@@ -1744,6 +2014,7 @@ async function invokeReviewer(params) {
1744
2014
  };
1745
2015
  const maxTurns = parseIntEnv("STAMP_REVIEWER_MAX_TURNS", 8);
1746
2016
  const timeoutMs = parseIntEnv("STAMP_REVIEWER_TIMEOUT_MS", 5 * 60 * 1e3);
2017
+ const modelOverride = resolveReviewerModel(params.reviewer);
1747
2018
  const abortController = new AbortController();
1748
2019
  const timeoutHandle = setTimeout(() => {
1749
2020
  abortController.abort(
@@ -1761,6 +2032,10 @@ async function invokeReviewer(params) {
1761
2032
  mcpServers,
1762
2033
  maxTurns,
1763
2034
  abortController,
2035
+ // Spread the model option so a null resolution leaves the SDK to
2036
+ // pick its own default rather than landing as `model: null` (which
2037
+ // some SDK versions treat as a typed override of the default).
2038
+ ...modelOverride !== null ? { model: modelOverride } : {},
1764
2039
  // PreToolUse fires for every tool call regardless of `allowedTools`
1765
2040
  // membership, which is what we want for security gating: pre-approving
1766
2041
  // a tool name in `allowedTools` should not bypass per-call validation.
@@ -1797,6 +2072,7 @@ async function invokeReviewer(params) {
1797
2072
  let finalText = null;
1798
2073
  let errorMessage = null;
1799
2074
  const toolCalls = [];
2075
+ const readPaths = /* @__PURE__ */ new Set();
1800
2076
  try {
1801
2077
  for await (const msg of q) {
1802
2078
  if (msg.type === "assistant") {
@@ -1810,6 +2086,14 @@ async function invokeReviewer(params) {
1810
2086
  tool: b.name,
1811
2087
  input_sha256: hashToolInput(b.input)
1812
2088
  });
2089
+ if (b.name === "Read" && b.input && typeof b.input === "object") {
2090
+ const fp = b.input.file_path;
2091
+ if (typeof fp === "string" && fp.length > 0) {
2092
+ const resolved = path.resolve(params.repoRoot, fp);
2093
+ const rel = path.relative(params.repoRoot, resolved);
2094
+ if (rel && !rel.startsWith("..")) readPaths.add(rel);
2095
+ }
2096
+ }
1813
2097
  }
1814
2098
  }
1815
2099
  }
@@ -1845,6 +2129,26 @@ async function invokeReviewer(params) {
1845
2129
  verdict = parseLastLineVerdict(fallbackText, params.reviewer, params.repoRoot);
1846
2130
  prose = stripLastLineVerdict(fallbackText);
1847
2131
  }
2132
+ if (def.enforce_reads_on_dotstamp && verdict === "approved") {
2133
+ const missing = findMissingDotstampReads(
2134
+ params.base_sha,
2135
+ params.head_sha,
2136
+ params.repoRoot,
2137
+ readPaths
2138
+ );
2139
+ if (missing.length > 0) {
2140
+ const list = missing.map((p) => ` - ${p}`).join("\n");
2141
+ verdict = "changes_requested";
2142
+ prose = `verdict/trace inconsistency: this reviewer is configured with enforce_reads_on_dotstamp=true, the diff modifies the following \`.stamp/*\` paths, and none of them appeared in the reviewer's Read trace before approval:
2143
+
2144
+ ${list}
2145
+
2146
+ Approving a change to stamp's own trust anchors without inspecting the diff defeats audit H1's defense-in-depth posture. Re-run the review and call \`Read('<path>')\` for each modified \`.stamp/*\` file before submitting an approved verdict.${prose ? `
2147
+
2148
+ Original prose:
2149
+ ${prose}` : ""}`;
2150
+ }
2151
+ }
1848
2152
  return {
1849
2153
  reviewer: params.reviewer,
1850
2154
  prose,
@@ -1853,6 +2157,23 @@ async function invokeReviewer(params) {
1853
2157
  retros: submittedRetros
1854
2158
  };
1855
2159
  }
2160
+ function findMissingDotstampReads(baseSha, headSha, repoRoot, readPaths) {
2161
+ let raw;
2162
+ try {
2163
+ raw = runGit(
2164
+ ["diff", "--name-only", "--diff-filter=AMR", `${baseSha}..${headSha}`],
2165
+ repoRoot
2166
+ );
2167
+ } catch {
2168
+ return [];
2169
+ }
2170
+ const modified = raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && l.startsWith(".stamp/"));
2171
+ const missing = [];
2172
+ for (const p of modified) {
2173
+ if (!readPaths.has(p)) missing.push(p);
2174
+ }
2175
+ return missing.sort();
2176
+ }
1856
2177
  function resolveMcpServers(def, reviewerName) {
1857
2178
  if (!def.mcp_servers) return void 0;
1858
2179
  const operatorAllowlist = parseEnvAllowlist(
@@ -1992,13 +2313,13 @@ function sanitizeReviewerSlug(name) {
1992
2313
  return cleaned === "" ? "_" : cleaned;
1993
2314
  }
1994
2315
  function writeFailedParseSpool(repoRoot, reviewer, text) {
1995
- const dir = path.join(repoRoot, ".git", "stamp", "failed-parses");
1996
- mkdirSync(dir, { recursive: true, mode: 448 });
2316
+ const dir = path.join(gitCommonDir(repoRoot), "stamp", "failed-parses");
2317
+ mkdirSync2(dir, { recursive: true, mode: 448 });
1997
2318
  chmodSync(dir, 448);
1998
2319
  const slug = sanitizeReviewerSlug(reviewer);
1999
2320
  const filename = `${Date.now()}-${slug}.txt`;
2000
2321
  const filepath = path.join(dir, filename);
2001
- writeFileSync2(filepath, text, { flag: "wx", mode: 384 });
2322
+ writeFileSync3(filepath, text, { flag: "wx", mode: 384 });
2002
2323
  chmodSync(filepath, 384);
2003
2324
  const lineCount = text === "" ? 0 : text.split("\n").length;
2004
2325
  return { path: filepath, lineCount };
@@ -2022,12 +2343,12 @@ function stripLastLineVerdict(text) {
2022
2343
  }
2023
2344
 
2024
2345
  // src/lib/llmNotice.ts
2025
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
2026
- import { dirname } from "path";
2346
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
2347
+ import { dirname as dirname2 } from "path";
2027
2348
  function maybePrintLlmNotice(repoRoot) {
2028
2349
  if (process.env.STAMP_SUPPRESS_LLM_NOTICE === "1") return;
2029
2350
  const marker = stampLlmNoticeMarkerPath(repoRoot);
2030
- if (existsSync3(marker)) return;
2351
+ if (existsSync4(marker)) return;
2031
2352
  process.stderr.write(
2032
2353
  `note: stamp review ships the diff to Anthropic via the Claude Agent SDK.
2033
2354
  See README "Data flow / privacy" for what's sent and how to opt out.
@@ -2036,8 +2357,8 @@ function maybePrintLlmNotice(repoRoot) {
2036
2357
  `
2037
2358
  );
2038
2359
  try {
2039
- mkdirSync2(dirname(marker), { recursive: true });
2040
- writeFileSync3(marker, `${(/* @__PURE__ */ new Date()).toISOString()}
2360
+ mkdirSync3(dirname2(marker), { recursive: true });
2361
+ writeFileSync4(marker, `${(/* @__PURE__ */ new Date()).toISOString()}
2041
2362
  `);
2042
2363
  } catch {
2043
2364
  }
@@ -2046,9 +2367,14 @@ function maybePrintLlmNotice(repoRoot) {
2046
2367
  // src/commands/review.ts
2047
2368
  var DEFAULT_DIFF_SIZE_CAP_BYTES = 200 * 1024;
2048
2369
  async function runReview(opts) {
2370
+ if (process.env.STAMP_NO_LLM === "1") {
2371
+ throw new Error(
2372
+ `STAMP_NO_LLM=1 is set; refusing to start \`stamp review\` because it would invoke the Claude Agent SDK. With this env var on, stamp's LLM-using commands (review / reviewers test / bootstrap) are disabled \u2014 no diff content will leave the host. The signing, verification, and merge primitives (stamp keys / stamp merge / stamp verify / stamp log / the pre-receive hook) all continue to work. Unset STAMP_NO_LLM to re-enable.`
2373
+ );
2374
+ }
2049
2375
  const repoRoot = findRepoRoot();
2050
2376
  const configPath = stampConfigFile(repoRoot);
2051
- if (!existsSync4(configPath)) {
2377
+ if (!existsSync5(configPath)) {
2052
2378
  throw new Error(
2053
2379
  `no .stamp/config.yml at ${configPath}. Run \`stamp init\` first.`
2054
2380
  );
@@ -2088,16 +2414,16 @@ async function runReview(opts) {
2088
2414
  `failed to read .stamp/config.yml at base ${resolved.base_sha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`
2089
2415
  );
2090
2416
  }
2091
- const config = parseConfigFromYaml(baseConfigYaml);
2092
- const reviewerNames = chooseReviewers(config, opts.only);
2417
+ const config2 = parseConfigFromYaml(baseConfigYaml);
2418
+ const reviewerNames = chooseReviewers(config2, opts.only);
2093
2419
  if (reviewerNames.length === 0) {
2094
2420
  throw new Error(
2095
- `no reviewers to run at base ${resolved.base_sha.slice(0, 8)} (config there has ${Object.keys(config.reviewers).length} configured). If this branch ADDS a new reviewer, the new reviewer cannot review its own introduction \u2014 that's a deliberate security boundary. Land the reviewer in a separate PR first, then it can review subsequent diffs.`
2421
+ `no reviewers to run at base ${resolved.base_sha.slice(0, 8)} (config there has ${Object.keys(config2.reviewers).length} configured). If this branch ADDS a new reviewer, the new reviewer cannot review its own introduction \u2014 that's a deliberate security boundary. Land the reviewer in a separate PR first, then it can review subsequent diffs.`
2096
2422
  );
2097
2423
  }
2098
2424
  const promptBytesByReviewer = /* @__PURE__ */ new Map();
2099
2425
  for (const name of reviewerNames) {
2100
- const def = config.reviewers[name];
2426
+ const def = config2.reviewers[name];
2101
2427
  let bytes;
2102
2428
  try {
2103
2429
  bytes = showAtRef(resolved.base_sha, def.prompt, repoRoot);
@@ -2109,6 +2435,16 @@ async function runReview(opts) {
2109
2435
  promptBytesByReviewer.set(name, bytes);
2110
2436
  }
2111
2437
  maybePrintLlmNotice(repoRoot);
2438
+ const userCfg = loadOrCreateUserConfig();
2439
+ if (userCfg.created) {
2440
+ process.stderr.write(
2441
+ `note: per-user reviewer-model config written to ${userCfg.path} (Sonnet defaults).
2442
+ Inspect with \`stamp config reviewers show\`; pin a different model with
2443
+ \`stamp config reviewers set <reviewer> <model-id>\`.
2444
+
2445
+ `
2446
+ );
2447
+ }
2112
2448
  console.log(
2113
2449
  `running ${reviewerNames.length} reviewer${reviewerNames.length === 1 ? "" : "s"} in parallel: ${reviewerNames.join(", ")}`
2114
2450
  );
@@ -2125,7 +2461,7 @@ async function runReview(opts) {
2125
2461
  reviewerNames.map(
2126
2462
  (name) => invokeReviewer({
2127
2463
  reviewer: name,
2128
- config,
2464
+ config: config2,
2129
2465
  repoRoot,
2130
2466
  diff: resolved.diff,
2131
2467
  base_sha: resolved.base_sha,
@@ -2160,16 +2496,16 @@ async function runReview(opts) {
2160
2496
  db.close();
2161
2497
  }
2162
2498
  }
2163
- function chooseReviewers(config, only) {
2499
+ function chooseReviewers(config2, only) {
2164
2500
  if (only) {
2165
- if (!(only in config.reviewers)) {
2501
+ if (!(only in config2.reviewers)) {
2166
2502
  throw new Error(
2167
- `reviewer "${only}" is not configured. Available: ${Object.keys(config.reviewers).join(", ") || "(none)"}`
2503
+ `reviewer "${only}" is not configured. Available: ${Object.keys(config2.reviewers).join(", ") || "(none)"}`
2168
2504
  );
2169
2505
  }
2170
2506
  return [only];
2171
2507
  }
2172
- return Object.keys(config.reviewers);
2508
+ return Object.keys(config2.reviewers);
2173
2509
  }
2174
2510
  function printReview(result, base_sha, head_sha) {
2175
2511
  const bar = "\u2500".repeat(72);
@@ -2211,9 +2547,14 @@ var STARTER_PROMPTS = {
2211
2547
  };
2212
2548
  var BOOTSTRAP_BRANCH = "stamp/bootstrap";
2213
2549
  async function runBootstrap(opts = {}) {
2550
+ if (process.env.STAMP_NO_LLM === "1") {
2551
+ throw new Error(
2552
+ `STAMP_NO_LLM=1 is set; refusing to start \`stamp bootstrap\` because it invokes the Claude Agent SDK to install reviewers. With this env var on, stamp's LLM-using commands (review / reviewers test / bootstrap) are disabled \u2014 no diff content will leave the host. The signing, verification, and merge primitives (stamp keys / stamp merge / stamp verify / stamp log / the pre-receive hook) all continue to work. Unset STAMP_NO_LLM to re-enable.`
2553
+ );
2554
+ }
2214
2555
  const repoRoot = findRepoRoot();
2215
2556
  const configFile = stampConfigFile(repoRoot);
2216
- if (!existsSync5(configFile)) {
2557
+ if (!existsSync6(configFile)) {
2217
2558
  throw new Error(
2218
2559
  `no .stamp/config.yml at ${configFile}. This command runs against an already-provisioned stamp repo (cloned from a stamp server with the placeholder seed). For a fresh local repo, run \`stamp init\` instead.`
2219
2560
  );
@@ -2412,45 +2753,45 @@ function buildPlan(current, targetBranch, targetRule, opts) {
2412
2753
  };
2413
2754
  }
2414
2755
  function readSeedDir(seedDir) {
2415
- if (!existsSync5(seedDir) || !statSync(seedDir).isDirectory()) {
2756
+ if (!existsSync6(seedDir) || !statSync(seedDir).isDirectory()) {
2416
2757
  throw new Error(`--from path is not a directory: ${seedDir}`);
2417
2758
  }
2418
2759
  const configPath = join3(seedDir, "config.yml");
2419
- if (!existsSync5(configPath)) {
2760
+ if (!existsSync6(configPath)) {
2420
2761
  throw new Error(`--from dir missing config.yml: ${configPath}`);
2421
2762
  }
2422
2763
  const reviewersDir = join3(seedDir, "reviewers");
2423
- if (!existsSync5(reviewersDir) || !statSync(reviewersDir).isDirectory()) {
2764
+ if (!existsSync6(reviewersDir) || !statSync(reviewersDir).isDirectory()) {
2424
2765
  throw new Error(`--from dir missing reviewers/ subdirectory: ${reviewersDir}`);
2425
2766
  }
2426
- const yaml = readFileSync4(configPath, "utf8");
2427
- const config = parseConfigFromYaml(yaml);
2767
+ const yaml = readFileSync5(configPath, "utf8");
2768
+ const config2 = parseConfigFromYaml(yaml);
2428
2769
  const reviewerFiles = /* @__PURE__ */ new Map();
2429
2770
  for (const entry of readdirSync(reviewersDir)) {
2430
2771
  const full = join3(reviewersDir, entry);
2431
2772
  if (statSync(full).isFile()) {
2432
- reviewerFiles.set(`.stamp/reviewers/${entry}`, readFileSync4(full, "utf8"));
2773
+ reviewerFiles.set(`.stamp/reviewers/${entry}`, readFileSync5(full, "utf8"));
2433
2774
  }
2434
2775
  }
2435
2776
  let mirrorYml;
2436
2777
  const mirrorPath = join3(seedDir, "mirror.yml");
2437
- if (existsSync5(mirrorPath)) {
2438
- mirrorYml = readFileSync4(mirrorPath, "utf8");
2778
+ if (existsSync6(mirrorPath)) {
2779
+ mirrorYml = readFileSync5(mirrorPath, "utf8");
2439
2780
  }
2440
- return { config, reviewerFiles, mirrorYml };
2781
+ return { config: config2, reviewerFiles, mirrorYml };
2441
2782
  }
2442
2783
  function writeBootstrapFiles(repoRoot, plan) {
2443
2784
  ensureDir(stampConfigDir(repoRoot));
2444
2785
  ensureDir(stampReviewersDir(repoRoot));
2445
2786
  for (const { path: path2, content } of plan.reviewerFiles.values()) {
2446
2787
  const full = join3(repoRoot, path2);
2447
- ensureDir(dirname2(full));
2448
- writeFileSync4(full, content);
2788
+ ensureDir(dirname3(full));
2789
+ writeFileSync5(full, content);
2449
2790
  }
2450
2791
  if (plan.mirrorYml !== void 0) {
2451
- writeFileSync4(join3(repoRoot, ".stamp/mirror.yml"), plan.mirrorYml);
2792
+ writeFileSync5(join3(repoRoot, ".stamp/mirror.yml"), plan.mirrorYml);
2452
2793
  }
2453
- writeFileSync4(stampConfigFile(repoRoot), stringifyConfig(plan.newConfig));
2794
+ writeFileSync5(stampConfigFile(repoRoot), stringifyConfig(plan.newConfig));
2454
2795
  }
2455
2796
  function printPlan(plan, opts) {
2456
2797
  const bar = "\u2500".repeat(72);
@@ -2491,7 +2832,7 @@ function branchExists(name, cwd) {
2491
2832
  }
2492
2833
 
2493
2834
  // src/commands/init.ts
2494
- import { existsSync as existsSync6, writeFileSync as writeFileSync5 } from "fs";
2835
+ import { existsSync as existsSync7, writeFileSync as writeFileSync6 } from "fs";
2495
2836
  import { join as join4 } from "path";
2496
2837
 
2497
2838
  // src/lib/ghRuleset.ts
@@ -2663,40 +3004,41 @@ That command handles the bare-repo creation, clone, bootstrap merge, GitHub mirr
2663
3004
  For local-only / advisory use against this GitHub repo: re-run with \`stamp init --mode local-only\`. That mode is honest about the lack of server-side enforcement (signed merges still work, but \`git push origin main\` will not be rejected by the remote).`
2664
3005
  );
2665
3006
  }
2666
- const alreadyHasConfig = existsSync6(configFile);
3007
+ const alreadyHasConfig = existsSync7(configFile);
2667
3008
  ensureDir(configDir);
2668
3009
  ensureDir(reviewersDir);
2669
3010
  ensureDir(trustedKeysDir);
2670
3011
  if (!alreadyHasConfig) {
2671
3012
  if (opts.minimal) {
2672
- writeFileSync5(configFile, stringifyConfig(MINIMAL_CONFIG));
2673
- writeFileSync5(join4(reviewersDir, "example.md"), EXAMPLE_REVIEWER_PROMPT);
3013
+ writeFileSync6(configFile, stringifyConfig(MINIMAL_CONFIG));
3014
+ writeFileSync6(join4(reviewersDir, "example.md"), EXAMPLE_REVIEWER_PROMPT);
2674
3015
  } else {
2675
- writeFileSync5(configFile, stringifyConfig(DEFAULT_CONFIG));
2676
- writeFileSync5(
3016
+ writeFileSync6(configFile, stringifyConfig(DEFAULT_CONFIG));
3017
+ writeFileSync6(
2677
3018
  join4(reviewersDir, "security.md"),
2678
3019
  DEFAULT_SECURITY_PROMPT
2679
3020
  );
2680
- writeFileSync5(
3021
+ writeFileSync6(
2681
3022
  join4(reviewersDir, "standards.md"),
2682
3023
  DEFAULT_STANDARDS_PROMPT
2683
3024
  );
2684
- writeFileSync5(
3025
+ writeFileSync6(
2685
3026
  join4(reviewersDir, "product.md"),
2686
3027
  DEFAULT_PRODUCT_PROMPT
2687
3028
  );
2688
3029
  }
2689
3030
  }
2690
3031
  const { keypair, created: keyCreated } = ensureUserKeypair();
3032
+ const userCfg = loadOrCreateUserConfig();
2691
3033
  const pubKeyPath = join4(
2692
3034
  trustedKeysDir,
2693
3035
  publicKeyFingerprintFilename(keypair.fingerprint)
2694
3036
  );
2695
- const keyDeposited = !existsSync6(pubKeyPath);
3037
+ const keyDeposited = !existsSync7(pubKeyPath);
2696
3038
  if (keyDeposited) {
2697
- writeFileSync5(pubKeyPath, keypair.publicKeyPem);
3039
+ writeFileSync6(pubKeyPath, keypair.publicKeyPem);
2698
3040
  }
2699
- const dbExisted = existsSync6(stateDbPath);
3041
+ const dbExisted = existsSync7(stateDbPath);
2700
3042
  const db = openDb(stateDbPath);
2701
3043
  db.close();
2702
3044
  const agentsMdAction = opts.agentsMd === false ? "skipped" : ensureAgentsMd(repoRoot, effectiveMode);
@@ -2729,6 +3071,9 @@ For local-only / advisory use against this GitHub repo: re-run with \`stamp init
2729
3071
  ` CLAUDE.md: ${claudeMdAction} at repo root (auto-loaded by Claude Code)`
2730
3072
  );
2731
3073
  }
3074
+ console.log(
3075
+ ` models: ${userCfg.path}${userCfg.created ? " (created \u2014 Sonnet defaults; tweak with `stamp config reviewers set <name> <model-id>`)" : " (existing)"}`
3076
+ );
2732
3077
  console.log();
2733
3078
  if (opts.bootstrapCommit !== false) {
2734
3079
  printBootstrapCommitResult(runBootstrapCommit(repoRoot, scaffoldOrSync));
@@ -2823,8 +3168,8 @@ function runBootstrapCommit(repoRoot, scaffoldOrSync) {
2823
3168
  return { kind: "skipped-already-tracked" };
2824
3169
  }
2825
3170
  const toAdd = [".stamp"];
2826
- if (existsSync6(join4(repoRoot, "AGENTS.md"))) toAdd.push("AGENTS.md");
2827
- if (existsSync6(join4(repoRoot, "CLAUDE.md"))) toAdd.push("CLAUDE.md");
3171
+ if (existsSync7(join4(repoRoot, "AGENTS.md"))) toAdd.push("AGENTS.md");
3172
+ if (existsSync7(join4(repoRoot, "CLAUDE.md"))) toAdd.push("CLAUDE.md");
2828
3173
  runGit(["add", ...toAdd], repoRoot);
2829
3174
  let hasStagedChanges = false;
2830
3175
  try {
@@ -2993,13 +3338,13 @@ function resolveMode(userMode, remoteClass) {
2993
3338
 
2994
3339
  // src/commands/provision.ts
2995
3340
  import { spawnSync as spawnSync4 } from "child_process";
2996
- import { existsSync as existsSync8, mkdtempSync, rmSync, writeFileSync as writeFileSync6 } from "fs";
3341
+ import { existsSync as existsSync9, mkdtempSync, rmSync, writeFileSync as writeFileSync7 } from "fs";
2997
3342
  import { tmpdir } from "os";
2998
3343
  import { join as join5, resolve as resolvePath } from "path";
2999
3344
 
3000
3345
  // src/lib/serverConfig.ts
3001
- import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
3002
- import { parse as parseYaml3 } from "yaml";
3346
+ import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
3347
+ import { parse as parseYaml4 } from "yaml";
3003
3348
  var DEFAULT_USER = "git";
3004
3349
  var DEFAULT_REPO_ROOT = "/srv/git";
3005
3350
  var USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
@@ -3025,10 +3370,10 @@ function validateField(field, value, contextPath) {
3025
3370
  }
3026
3371
  function loadServerConfig() {
3027
3372
  const path2 = userServerConfigPath();
3028
- if (!existsSync7(path2)) return null;
3373
+ if (!existsSync8(path2)) return null;
3029
3374
  let raw;
3030
3375
  try {
3031
- raw = readFileSync5(path2, "utf8");
3376
+ raw = readFileSync6(path2, "utf8");
3032
3377
  } catch (err) {
3033
3378
  throw new Error(
3034
3379
  `failed to read ${path2}: ${err instanceof Error ? err.message : String(err)}`
@@ -3037,7 +3382,7 @@ function loadServerConfig() {
3037
3382
  return parseServerConfig(raw, path2);
3038
3383
  }
3039
3384
  function parseServerConfig(raw, contextPath = "<inline>") {
3040
- const parsed = parseYaml3(raw);
3385
+ const parsed = parseYaml4(raw);
3041
3386
  if (!parsed || typeof parsed !== "object") {
3042
3387
  throw new Error(`${contextPath}: must be a YAML mapping with at least 'host' and 'port'`);
3043
3388
  }
@@ -3108,7 +3453,7 @@ See docs/quickstart-server.md for how to deploy a stamp server first.`
3108
3453
  return;
3109
3454
  }
3110
3455
  const cloneTarget = resolvePath(opts.into ?? opts.name);
3111
- if (existsSync8(cloneTarget)) {
3456
+ if (existsSync9(cloneTarget)) {
3112
3457
  throw new Error(
3113
3458
  `clone destination already exists: ${cloneTarget}. Move or remove it, or pass --into <other-path>.`
3114
3459
  );
@@ -3228,7 +3573,7 @@ function writeMirrorYml(cloneTarget, mirror) {
3228
3573
  # - "v*"
3229
3574
  `;
3230
3575
  const path2 = `${cloneTarget}/.stamp/mirror.yml`;
3231
- writeFileSync6(path2, yml);
3576
+ writeFileSync7(path2, yml);
3232
3577
  console.log(`Wrote mirror.yml \u2192 .stamp/mirror.yml (${mirror.owner}/${mirror.repo})`);
3233
3578
  }
3234
3579
  function applyMirrorRuleset(mirror) {
@@ -3351,7 +3696,7 @@ function ensureCwdIsGitRepo(cwd) {
3351
3696
  }
3352
3697
  }
3353
3698
  function ensureStampInitDone(cwd) {
3354
- if (!existsSync8(join5(cwd, ".stamp", "config.yml"))) {
3699
+ if (!existsSync9(join5(cwd, ".stamp", "config.yml"))) {
3355
3700
  throw new Error(
3356
3701
  `--migrate-existing expects this repo to already be stamp-init'd (${join5(cwd, ".stamp/config.yml")} not found). Run \`stamp init --mode local-only\` first, calibrate your reviewers, then re-run with --migrate-existing.`
3357
3702
  );
@@ -3665,7 +4010,7 @@ function prompt(question) {
3665
4010
  }
3666
4011
 
3667
4012
  // src/commands/keys.ts
3668
- import { existsSync as existsSync9, readdirSync as readdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync7 } from "fs";
4013
+ import { existsSync as existsSync10, readdirSync as readdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync8 } from "fs";
3669
4014
  import { basename, join as join6 } from "path";
3670
4015
  function keysGenerate() {
3671
4016
  const existing = loadUserKeypair();
@@ -3699,7 +4044,7 @@ function keysList() {
3699
4044
  const repoRoot = findRepoRoot();
3700
4045
  const trustedDir = stampTrustedKeysDir(repoRoot);
3701
4046
  console.log(`repo trusted keys: ${trustedDir}/`);
3702
- if (!existsSync9(trustedDir)) {
4047
+ if (!existsSync10(trustedDir)) {
3703
4048
  console.log(" (directory does not exist \u2014 run `stamp init`)");
3704
4049
  return;
3705
4050
  }
@@ -3710,7 +4055,7 @@ function keysList() {
3710
4055
  }
3711
4056
  for (const file of pubFiles.sort()) {
3712
4057
  try {
3713
- const pem = readFileSync6(join6(trustedDir, file), "utf8");
4058
+ const pem = readFileSync7(join6(trustedDir, file), "utf8");
3714
4059
  const fp = fingerprintFromPem(pem);
3715
4060
  const marker = local && fp === local.fingerprint ? " (you)" : "";
3716
4061
  console.log(` ${fp}${marker} [${file}]`);
@@ -3729,15 +4074,15 @@ function keysExport() {
3729
4074
  function keysTrust(pubFile) {
3730
4075
  const repoRoot = findRepoRoot();
3731
4076
  const trustedDir = stampTrustedKeysDir(repoRoot);
3732
- if (!existsSync9(trustedDir)) {
4077
+ if (!existsSync10(trustedDir)) {
3733
4078
  throw new Error(
3734
4079
  `no ${trustedDir} \u2014 run \`stamp init\` first to create the trust store`
3735
4080
  );
3736
4081
  }
3737
- if (!existsSync9(pubFile)) {
4082
+ if (!existsSync10(pubFile)) {
3738
4083
  throw new Error(`public key file not found: ${pubFile}`);
3739
4084
  }
3740
- const pem = readFileSync6(pubFile, "utf8");
4085
+ const pem = readFileSync7(pubFile, "utf8");
3741
4086
  let fingerprint;
3742
4087
  try {
3743
4088
  fingerprint = fingerprintFromPem(pem);
@@ -3748,11 +4093,11 @@ function keysTrust(pubFile) {
3748
4093
  }
3749
4094
  const filename = publicKeyFingerprintFilename(fingerprint);
3750
4095
  const dest = join6(trustedDir, filename);
3751
- if (existsSync9(dest)) {
4096
+ if (existsSync10(dest)) {
3752
4097
  console.log(`${fingerprint} is already trusted (${basename(dest)})`);
3753
4098
  return;
3754
4099
  }
3755
- writeFileSync7(dest, pem);
4100
+ writeFileSync8(dest, pem);
3756
4101
  console.log(`trusted ${fingerprint}`);
3757
4102
  console.log(` \u2192 ${dest}`);
3758
4103
  console.log();
@@ -3760,11 +4105,11 @@ function keysTrust(pubFile) {
3760
4105
  }
3761
4106
 
3762
4107
  // src/commands/log.ts
3763
- import { existsSync as existsSync10 } from "fs";
4108
+ import { existsSync as existsSync11 } from "fs";
3764
4109
  function runLog(opts) {
3765
4110
  const repoRoot = findRepoRoot();
3766
4111
  const configPath = stampConfigFile(repoRoot);
3767
- if (!existsSync10(configPath)) {
4112
+ if (!existsSync11(configPath)) {
3768
4113
  throw new Error(
3769
4114
  `no .stamp/config.yml at ${configPath}. Run \`stamp init\` first.`
3770
4115
  );
@@ -3881,7 +4226,7 @@ function printCommitDetail(sha, repoRoot) {
3881
4226
  }
3882
4227
  function collectReviewProse(repoRoot, payload) {
3883
4228
  const dbPath = stampStateDbPath(repoRoot);
3884
- if (!existsSync10(dbPath)) return [];
4229
+ if (!existsSync11(dbPath)) return [];
3885
4230
  const db = openDb(dbPath);
3886
4231
  try {
3887
4232
  const rows = latestReviews(db, payload.base_sha, payload.head_sha);
@@ -3895,7 +4240,7 @@ function printReviewHistory(repoRoot, limit, diff) {
3895
4240
  const configPath = stampConfigFile(repoRoot);
3896
4241
  loadConfig(configPath);
3897
4242
  const dbPath = stampStateDbPath(repoRoot);
3898
- if (!existsSync10(dbPath)) {
4243
+ if (!existsSync11(dbPath)) {
3899
4244
  console.log("No reviews recorded yet.");
3900
4245
  return;
3901
4246
  }
@@ -3936,7 +4281,8 @@ function printReviewHistory(repoRoot, limit, diff) {
3936
4281
  }
3937
4282
 
3938
4283
  // src/commands/prune.ts
3939
- import { existsSync as existsSync11, statSync as statSync2 } from "fs";
4284
+ import { existsSync as existsSync12, readdirSync as readdirSync3, statSync as statSync2, unlinkSync as unlinkSync2 } from "fs";
4285
+ import { join as join7 } from "path";
3940
4286
 
3941
4287
  // src/lib/duration.ts
3942
4288
  function parseRetentionDuration(input) {
@@ -3949,53 +4295,116 @@ function parseRetentionDuration(input) {
3949
4295
  const n = match[1];
3950
4296
  const unit = match[2];
3951
4297
  const unitWord = unit === "d" ? "days" : unit === "h" ? "hours" : "minutes";
4298
+ const nNum = Number(n);
4299
+ const msPerUnit = unit === "d" ? 864e5 : unit === "h" ? 36e5 : 6e4;
3952
4300
  return {
3953
4301
  sqliteModifier: `-${n} ${unitWord}`,
3954
- humanLabel: `${n}${unit}`
4302
+ humanLabel: `${n}${unit}`,
4303
+ durationMs: nNum * msPerUnit
3955
4304
  };
3956
4305
  }
3957
4306
 
3958
4307
  // src/commands/prune.ts
3959
4308
  function runPrune(opts) {
3960
- const { sqliteModifier, humanLabel } = parseRetentionDuration(opts.olderThan);
4309
+ const { sqliteModifier, humanLabel, durationMs } = parseRetentionDuration(
4310
+ opts.olderThan
4311
+ );
3961
4312
  const repoRoot = findRepoRoot();
3962
4313
  const dbPath = stampStateDbPath(repoRoot);
3963
- if (!existsSync11(dbPath)) {
4314
+ const spoolDir = join7(gitCommonDir(repoRoot), "stamp", "failed-parses");
4315
+ const spoolCutoffMs = Date.now() - durationMs;
4316
+ if (!existsSync12(dbPath) && !existsSync12(spoolDir)) {
3964
4317
  console.log(
3965
- `note: ${dbPath} does not exist; nothing to prune (state.db is created on first \`stamp review\`)`
4318
+ `note: nothing to prune (neither ${dbPath} nor ${spoolDir} exists \u2014 both are created on first \`stamp review\`)`
3966
4319
  );
3967
4320
  return;
3968
4321
  }
3969
- const sizeBefore = statSync2(dbPath).size;
3970
- const db = openDb(dbPath);
4322
+ const db = existsSync12(dbPath) ? openDb(dbPath) : null;
3971
4323
  try {
3972
4324
  if (opts.dryRun) {
3973
- const peek = peekPrunable(db, sqliteModifier);
3974
- if (peek.total === 0) {
3975
- console.log(`note: nothing to prune (no rows older than ${humanLabel})`);
3976
- return;
4325
+ let any2 = false;
4326
+ if (db) {
4327
+ const peek = peekPrunable(db, sqliteModifier);
4328
+ if (peek.total > 0) {
4329
+ console.log(
4330
+ `would prune ${peek.total} review row${peek.total === 1 ? "" : "s"} older than ${humanLabel} (${peek.perReviewer.length} reviewer${peek.perReviewer.length === 1 ? "" : "s"} affected):`
4331
+ );
4332
+ printPerReviewer(peek.perReviewer);
4333
+ any2 = true;
4334
+ }
3977
4335
  }
4336
+ const spoolPeek = peekFailedParseSpools(spoolDir, spoolCutoffMs);
4337
+ if (spoolPeek.length > 0) {
4338
+ if (any2) console.log("");
4339
+ console.log(
4340
+ `would prune ${spoolPeek.length} failed-parse spool file${spoolPeek.length === 1 ? "" : "s"} older than ${humanLabel}:`
4341
+ );
4342
+ for (const f of spoolPeek) console.log(` ${f}`);
4343
+ any2 = true;
4344
+ }
4345
+ if (!any2) {
4346
+ console.log(`note: nothing to prune (no rows or spools older than ${humanLabel})`);
4347
+ } else {
4348
+ console.log("\n(dry run \u2014 no changes made)");
4349
+ }
4350
+ return;
4351
+ }
4352
+ let any = false;
4353
+ if (db) {
4354
+ const sizeBefore = statSync2(dbPath).size;
4355
+ const result = pruneReviews(db, sqliteModifier);
4356
+ if (result.total > 0) {
4357
+ db.exec("VACUUM");
4358
+ const sizeAfter = statSync2(dbPath).size;
4359
+ console.log(
4360
+ `${result.total} review row${result.total === 1 ? "" : "s"} pruned (${result.perReviewer.length} reviewer${result.perReviewer.length === 1 ? "" : "s"} affected); db size ${sizeBefore} \u2192 ${sizeAfter} bytes`
4361
+ );
4362
+ printPerReviewer(result.perReviewer);
4363
+ any = true;
4364
+ }
4365
+ }
4366
+ const spoolDeleted = pruneFailedParseSpools(spoolDir, spoolCutoffMs);
4367
+ if (spoolDeleted > 0) {
4368
+ if (any) console.log("");
3978
4369
  console.log(
3979
- `would prune ${peek.total} row${peek.total === 1 ? "" : "s"} older than ${humanLabel} (${peek.perReviewer.length} reviewer${peek.perReviewer.length === 1 ? "" : "s"} affected):`
4370
+ `${spoolDeleted} failed-parse spool file${spoolDeleted === 1 ? "" : "s"} pruned`
3980
4371
  );
3981
- printPerReviewer(peek.perReviewer);
3982
- console.log("\n(dry run \u2014 no changes made)");
3983
- return;
4372
+ any = true;
3984
4373
  }
3985
- const result = pruneReviews(db, sqliteModifier);
3986
- if (result.total === 0) {
3987
- console.log(`note: nothing to prune (no rows older than ${humanLabel})`);
3988
- return;
4374
+ if (!any) {
4375
+ console.log(`note: nothing to prune (no rows or spools older than ${humanLabel})`);
3989
4376
  }
3990
- db.exec("VACUUM");
3991
- const sizeAfter = statSync2(dbPath).size;
3992
- console.log(
3993
- `${result.total} row${result.total === 1 ? "" : "s"} pruned (${result.perReviewer.length} reviewer${result.perReviewer.length === 1 ? "" : "s"} affected); db size ${sizeBefore} \u2192 ${sizeAfter} bytes`
3994
- );
3995
- printPerReviewer(result.perReviewer);
3996
4377
  } finally {
3997
- db.close();
4378
+ db?.close();
4379
+ }
4380
+ }
4381
+ function peekFailedParseSpools(spoolDir, cutoffMs) {
4382
+ if (!existsSync12(spoolDir)) return [];
4383
+ const out = [];
4384
+ for (const entry of readdirSync3(spoolDir)) {
4385
+ const filepath = join7(spoolDir, entry);
4386
+ let stat;
4387
+ try {
4388
+ stat = statSync2(filepath);
4389
+ } catch {
4390
+ continue;
4391
+ }
4392
+ if (!stat.isFile()) continue;
4393
+ if (stat.mtimeMs < cutoffMs) out.push(filepath);
3998
4394
  }
4395
+ return out.sort();
4396
+ }
4397
+ function pruneFailedParseSpools(spoolDir, cutoffMs) {
4398
+ const targets = peekFailedParseSpools(spoolDir, cutoffMs);
4399
+ let deleted = 0;
4400
+ for (const filepath of targets) {
4401
+ try {
4402
+ unlinkSync2(filepath);
4403
+ deleted++;
4404
+ } catch {
4405
+ }
4406
+ }
4407
+ return deleted;
3999
4408
  }
4000
4409
  function printPerReviewer(rows) {
4001
4410
  const maxNameLen = Math.max(16, ...rows.map((r) => r.reviewer.length));
@@ -4007,9 +4416,9 @@ function printPerReviewer(rows) {
4007
4416
  }
4008
4417
 
4009
4418
  // src/commands/server.ts
4010
- import { existsSync as existsSync12, mkdirSync as mkdirSync3, renameSync, unlinkSync, writeFileSync as writeFileSync8 } from "fs";
4011
- import { dirname as dirname3 } from "path";
4012
- import { stringify as stringifyYaml } from "yaml";
4419
+ import { existsSync as existsSync13, mkdirSync as mkdirSync4, renameSync as renameSync2, unlinkSync as unlinkSync3, writeFileSync as writeFileSync9 } from "fs";
4420
+ import { dirname as dirname4 } from "path";
4421
+ import { stringify as stringifyYaml2 } from "yaml";
4013
4422
  function formatServerConfigYaml(opts) {
4014
4423
  const body = {
4015
4424
  host: opts.host,
@@ -4019,7 +4428,7 @@ function formatServerConfigYaml(opts) {
4019
4428
  if (opts.repoRootPrefix && opts.repoRootPrefix.trim()) {
4020
4429
  body.repo_root_prefix = opts.repoRootPrefix.trim();
4021
4430
  }
4022
- return stringifyYaml(body);
4431
+ return stringifyYaml2(body);
4023
4432
  }
4024
4433
  function runServerConfig(opts) {
4025
4434
  const modes = [opts.hostPort, opts.show, opts.unset].filter(Boolean).length;
@@ -4039,7 +4448,7 @@ function runServerConfig(opts) {
4039
4448
  }
4040
4449
  function showConfig() {
4041
4450
  const path2 = userServerConfigPath();
4042
- if (!existsSync12(path2)) {
4451
+ if (!existsSync13(path2)) {
4043
4452
  console.log(`note: no stamp server configured (${path2} does not exist)`);
4044
4453
  console.log(`note: run \`stamp server config <host:port>\` to create one`);
4045
4454
  return;
@@ -4057,11 +4466,11 @@ function showConfig() {
4057
4466
  }
4058
4467
  function unsetConfig() {
4059
4468
  const path2 = userServerConfigPath();
4060
- if (!existsSync12(path2)) {
4469
+ if (!existsSync13(path2)) {
4061
4470
  console.log(`note: ${path2} does not exist; nothing to remove`);
4062
4471
  return;
4063
4472
  }
4064
- unlinkSync(path2);
4473
+ unlinkSync3(path2);
4065
4474
  console.log(`removed ${path2}`);
4066
4475
  }
4067
4476
  function writeConfig(opts) {
@@ -4078,11 +4487,11 @@ function writeConfig(opts) {
4078
4487
  repoRootPrefix: opts.repoRootPrefix
4079
4488
  });
4080
4489
  const path2 = userServerConfigPath();
4081
- const dir = dirname3(path2);
4082
- if (!existsSync12(dir)) mkdirSync3(dir, { recursive: true, mode: 448 });
4490
+ const dir = dirname4(path2);
4491
+ if (!existsSync13(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
4083
4492
  const tmp = `${path2}.tmp.${process.pid}`;
4084
- writeFileSync8(tmp, yaml, { mode: 384 });
4085
- renameSync(tmp, path2);
4493
+ writeFileSync9(tmp, yaml, { mode: 384 });
4494
+ renameSync2(tmp, path2);
4086
4495
  console.log(`wrote ${path2}`);
4087
4496
  console.log(`host: ${parsed.host}`);
4088
4497
  console.log(`port: ${parsed.port}`);
@@ -4094,31 +4503,149 @@ function writeConfig(opts) {
4094
4503
  }
4095
4504
  }
4096
4505
 
4506
+ // src/commands/config.ts
4507
+ import { existsSync as existsSync14 } from "fs";
4508
+ function runConfigReviewersSet(opts) {
4509
+ if (!isValidReviewerName(opts.reviewer)) {
4510
+ throw new UsageError(
4511
+ `invalid reviewer name '${opts.reviewer}'. Names must be alphanumerics + '_' / '-', max 64 chars, no leading hyphen \u2014 same shape as \`stamp reviewers add\` accepts.`
4512
+ );
4513
+ }
4514
+ const id = opts.modelId.trim();
4515
+ if (id === "") {
4516
+ throw new UsageError(
4517
+ `model id is required and must be a non-empty string (e.g. 'claude-sonnet-4-6' or 'claude-opus-4-7')`
4518
+ );
4519
+ }
4520
+ if (!isValidModelId(id)) {
4521
+ throw new UsageError(
4522
+ `model id '${opts.modelId}' has an invalid shape \u2014 expected a token like 'claude-sonnet-4-6' or 'claude-opus-4-7'. The agent SDK treats this as an opaque string, so a typo here will fail at API-call time rather than at config-write \u2014 but stamp rejects shapes with whitespace or control chars.`
4523
+ );
4524
+ }
4525
+ const existing = loadOrEmpty();
4526
+ const prior = existing.reviewers[opts.reviewer];
4527
+ const next = {
4528
+ reviewers: { ...existing.reviewers, [opts.reviewer]: id }
4529
+ };
4530
+ const path2 = writeUserConfig(next);
4531
+ if (prior === id) {
4532
+ console.log(`reviewers.${opts.reviewer} = ${id} (unchanged)`);
4533
+ } else if (prior) {
4534
+ console.log(`reviewers.${opts.reviewer}: ${prior} -> ${id}`);
4535
+ } else {
4536
+ console.log(`reviewers.${opts.reviewer} = ${id} (new)`);
4537
+ }
4538
+ console.log(`wrote ${path2}`);
4539
+ }
4540
+ function runConfigReviewersClear(opts) {
4541
+ if (opts.all && opts.reviewer) {
4542
+ throw new UsageError(
4543
+ `\`stamp config reviewers clear\`: pass either <reviewer> or --all, not both`
4544
+ );
4545
+ }
4546
+ if (!opts.all && !opts.reviewer) {
4547
+ throw new UsageError(
4548
+ `\`stamp config reviewers clear\`: pass <reviewer> to clear one entry or --all to remove the whole config`
4549
+ );
4550
+ }
4551
+ if (opts.all) {
4552
+ const removed = deleteUserConfig();
4553
+ const path3 = userConfigPath();
4554
+ if (removed) {
4555
+ console.log(`removed ${path3}`);
4556
+ } else {
4557
+ console.log(`note: ${path3} does not exist; nothing to remove`);
4558
+ }
4559
+ return;
4560
+ }
4561
+ const reviewer = opts.reviewer;
4562
+ if (!isValidReviewerName(reviewer)) {
4563
+ throw new UsageError(
4564
+ `invalid reviewer name '${reviewer}'. Names must be alphanumerics + '_' / '-', max 64 chars, no leading hyphen \u2014 same shape as \`stamp reviewers add\` accepts.`
4565
+ );
4566
+ }
4567
+ const existing = loadOrEmpty();
4568
+ if (!(reviewer in existing.reviewers)) {
4569
+ console.log(`note: reviewers.${reviewer} is not set; nothing to clear`);
4570
+ return;
4571
+ }
4572
+ const next = { reviewers: { ...existing.reviewers } };
4573
+ delete next.reviewers[reviewer];
4574
+ const path2 = writeUserConfig(next);
4575
+ console.log(`cleared reviewers.${reviewer}`);
4576
+ console.log(`wrote ${path2}`);
4577
+ }
4578
+ function runConfigReviewersShow() {
4579
+ const path2 = userConfigPath();
4580
+ if (!existsSync14(path2)) {
4581
+ console.log(`note: no per-user stamp config (${path2} does not exist).`);
4582
+ console.log(
4583
+ ` Defaults will apply on next \`stamp init\` or \`stamp review\`:`
4584
+ );
4585
+ for (const [name, id] of Object.entries(DEFAULT_REVIEWER_MODELS)) {
4586
+ console.log(` ${name}: ${id} (default)`);
4587
+ }
4588
+ console.log(
4589
+ ` Pin a different model: \`stamp config reviewers set <reviewer> <model-id>\``
4590
+ );
4591
+ return;
4592
+ }
4593
+ const cfg = loadUserConfig() ?? { reviewers: {} };
4594
+ console.log(`config: ${path2}`);
4595
+ const names = Object.keys(cfg.reviewers).sort();
4596
+ if (names.length === 0) {
4597
+ console.log(`(no reviewer overrides; SDK default model in use for every reviewer)`);
4598
+ console.log(
4599
+ `Pin one with: \`stamp config reviewers set <reviewer> <model-id>\``
4600
+ );
4601
+ return;
4602
+ }
4603
+ console.log(`reviewers:`);
4604
+ const maxNameLen = Math.max(...names.map((n) => n.length));
4605
+ for (const name of names) {
4606
+ const id = cfg.reviewers[name];
4607
+ const tag = DEFAULT_REVIEWER_MODELS[name] === id ? " (matches default)" : DEFAULT_REVIEWER_MODELS[name] ? ` (default: ${DEFAULT_REVIEWER_MODELS[name]})` : "";
4608
+ console.log(` ${name.padEnd(maxNameLen)} ${id}${tag}`);
4609
+ }
4610
+ const unpinned = Object.keys(DEFAULT_REVIEWER_MODELS).filter(
4611
+ (n) => !(n in cfg.reviewers)
4612
+ );
4613
+ if (unpinned.length > 0) {
4614
+ console.log(`unpinned (will use default at review time):`);
4615
+ for (const name of unpinned) {
4616
+ console.log(` ${name.padEnd(maxNameLen)} ${DEFAULT_REVIEWER_MODELS[name]} (default)`);
4617
+ }
4618
+ }
4619
+ }
4620
+ function loadOrEmpty() {
4621
+ return loadUserConfig() ?? { reviewers: {} };
4622
+ }
4623
+
4097
4624
  // src/commands/reviewers.ts
4098
4625
  import { spawnSync as spawnSync6 } from "child_process";
4099
4626
  import {
4100
- existsSync as existsSync14,
4101
- readFileSync as readFileSync8,
4627
+ existsSync as existsSync16,
4628
+ readFileSync as readFileSync9,
4102
4629
  statSync as statSync3,
4103
- unlinkSync as unlinkSync2,
4104
- writeFileSync as writeFileSync10
4630
+ unlinkSync as unlinkSync4,
4631
+ writeFileSync as writeFileSync11
4105
4632
  } from "fs";
4106
- import { join as join8, relative, resolve } from "path";
4107
- import { parse as parseYaml4, stringify as stringifyYaml2 } from "yaml";
4633
+ import { join as join9, relative, resolve } from "path";
4634
+ import { parse as parseYaml5, stringify as stringifyYaml3 } from "yaml";
4108
4635
 
4109
4636
  // src/lib/reviewerLock.ts
4110
- import { existsSync as existsSync13, readFileSync as readFileSync7, writeFileSync as writeFileSync9 } from "fs";
4111
- import { join as join7 } from "path";
4637
+ import { existsSync as existsSync15, readFileSync as readFileSync8, writeFileSync as writeFileSync10 } from "fs";
4638
+ import { join as join8 } from "path";
4112
4639
  var LOCK_FILE_VERSION = 1;
4113
4640
  var LOCK_DRIFT_EXIT = 3;
4114
4641
  function lockFilePath(repoRoot, reviewerName) {
4115
- return join7(repoRoot, ".stamp", "reviewers", `${reviewerName}.lock.json`);
4642
+ return join8(repoRoot, ".stamp", "reviewers", `${reviewerName}.lock.json`);
4116
4643
  }
4117
4644
  function readLockFile(repoRoot, reviewerName) {
4118
4645
  const path2 = lockFilePath(repoRoot, reviewerName);
4119
- if (!existsSync13(path2)) return null;
4646
+ if (!existsSync15(path2)) return null;
4120
4647
  try {
4121
- const raw = readFileSync7(path2, "utf8");
4648
+ const raw = readFileSync8(path2, "utf8");
4122
4649
  const parsed = JSON.parse(raw);
4123
4650
  if (typeof parsed.version !== "number" || typeof parsed.source !== "string" || typeof parsed.ref !== "string" || typeof parsed.reviewer !== "string" || typeof parsed.prompt_sha256 !== "string" || typeof parsed.tools_sha256 !== "string" || typeof parsed.mcp_sha256 !== "string") {
4124
4651
  throw new Error(`malformed lock file at ${path2}`);
@@ -4132,20 +4659,20 @@ function readLockFile(repoRoot, reviewerName) {
4132
4659
  }
4133
4660
  function writeLockFile(repoRoot, reviewerName, lock) {
4134
4661
  const path2 = lockFilePath(repoRoot, reviewerName);
4135
- writeFileSync9(path2, JSON.stringify(lock, null, 2) + "\n", "utf8");
4662
+ writeFileSync10(path2, JSON.stringify(lock, null, 2) + "\n", "utf8");
4136
4663
  }
4137
4664
  function checkReviewerDrift(repoRoot, reviewerName, def) {
4138
4665
  const lock = readLockFile(repoRoot, reviewerName);
4139
4666
  if (!lock) {
4140
4667
  return unpinnedResult();
4141
4668
  }
4142
- const promptPath = join7(repoRoot, def.prompt);
4143
- if (!existsSync13(promptPath)) {
4669
+ const promptPath = join8(repoRoot, def.prompt);
4670
+ if (!existsSync15(promptPath)) {
4144
4671
  throw new Error(
4145
4672
  `reviewer "${reviewerName}" has a lock file but its prompt "${def.prompt}" does not exist on disk. Re-run 'stamp reviewers fetch ${reviewerName} --from ${lock.source}@${lock.ref}' to restore it, or delete the lock file to un-pin the reviewer.`
4146
4673
  );
4147
4674
  }
4148
- const promptBytes = readFileSync7(promptPath);
4675
+ const promptBytes = readFileSync8(promptPath);
4149
4676
  const observedPrompt = hashPromptBytes(promptBytes);
4150
4677
  const observedTools = hashTools(def.tools);
4151
4678
  const observedMcp = hashMcpServers(def.mcp_servers);
@@ -4211,8 +4738,8 @@ function requireValidReviewerName(name) {
4211
4738
  }
4212
4739
  function reviewersList() {
4213
4740
  const repoRoot = findRepoRoot();
4214
- const config = loadConfig(stampConfigFile(repoRoot));
4215
- const names = Object.keys(config.reviewers);
4741
+ const config2 = loadConfig(stampConfigFile(repoRoot));
4742
+ const names = Object.keys(config2.reviewers);
4216
4743
  if (names.length === 0) {
4217
4744
  console.log("No reviewers configured in .stamp/config.yml.");
4218
4745
  return;
@@ -4223,10 +4750,10 @@ function reviewersList() {
4223
4750
  console.log(bar);
4224
4751
  const maxNameLen = Math.max(...names.map((n) => n.length));
4225
4752
  for (const name of names) {
4226
- const def = config.reviewers[name];
4753
+ const def = config2.reviewers[name];
4227
4754
  const abs = resolve(repoRoot, def.prompt);
4228
4755
  let annotation = "";
4229
- if (!existsSync14(abs)) {
4756
+ if (!existsSync16(abs)) {
4230
4757
  annotation = " MISSING";
4231
4758
  } else {
4232
4759
  const size = statSync3(abs).size;
@@ -4236,15 +4763,15 @@ function reviewersList() {
4236
4763
  }
4237
4764
  console.log(bar);
4238
4765
  console.log("branch rules:");
4239
- for (const [branch, rule] of Object.entries(config.branches)) {
4766
+ for (const [branch, rule] of Object.entries(config2.branches)) {
4240
4767
  console.log(` ${branch} required: [${rule.required.join(", ")}]`);
4241
4768
  }
4242
4769
  console.log(bar);
4243
4770
  }
4244
4771
  function reviewersEdit(name) {
4245
4772
  const repoRoot = findRepoRoot();
4246
- const config = loadConfig(stampConfigFile(repoRoot));
4247
- const def = config.reviewers[name];
4773
+ const config2 = loadConfig(stampConfigFile(repoRoot));
4774
+ const def = config2.reviewers[name];
4248
4775
  if (!def) {
4249
4776
  throw new Error(
4250
4777
  `reviewer "${name}" is not configured. Run \`stamp reviewers list\` to see available reviewers.`
@@ -4257,27 +4784,27 @@ function reviewersAdd(name, opts = {}) {
4257
4784
  requireValidReviewerName(name);
4258
4785
  const repoRoot = findRepoRoot();
4259
4786
  const configPath = stampConfigFile(repoRoot);
4260
- const config = loadConfig(configPath);
4261
- if (config.reviewers[name]) {
4787
+ const config2 = loadConfig(configPath);
4788
+ if (config2.reviewers[name]) {
4262
4789
  throw new Error(
4263
4790
  `reviewer "${name}" already exists. Use \`stamp reviewers edit ${name}\` to change its prompt.`
4264
4791
  );
4265
4792
  }
4266
4793
  const promptRel = `.stamp/reviewers/${name}.md`;
4267
4794
  const promptAbs = resolve(repoRoot, promptRel);
4268
- if (existsSync14(promptAbs)) {
4795
+ if (existsSync16(promptAbs)) {
4269
4796
  throw new Error(
4270
4797
  `${promptRel} already exists on disk but is not in config. Either delete the file or add it to config manually.`
4271
4798
  );
4272
4799
  }
4273
- writeFileSync10(
4800
+ writeFileSync11(
4274
4801
  promptAbs,
4275
4802
  `# ${name}
4276
4803
 
4277
4804
  ${EXAMPLE_REVIEWER_PROMPT.split("\n").slice(2).join("\n")}`
4278
4805
  );
4279
- config.reviewers[name] = { prompt: promptRel };
4280
- writeFileSync10(configPath, stringifyConfig(config));
4806
+ config2.reviewers[name] = { prompt: promptRel };
4807
+ writeFileSync11(configPath, stringifyConfig(config2));
4281
4808
  console.log(`reviewer "${name}" added.`);
4282
4809
  console.log(` prompt file: ${promptRel}`);
4283
4810
  console.log(` registered in .stamp/config.yml`);
@@ -4295,15 +4822,15 @@ Opening ${promptRel} in $EDITOR...`);
4295
4822
  function reviewersRemove(name, opts = {}) {
4296
4823
  const repoRoot = findRepoRoot();
4297
4824
  const configPath = stampConfigFile(repoRoot);
4298
- const config = loadConfig(configPath);
4299
- const def = config.reviewers[name];
4825
+ const config2 = loadConfig(configPath);
4826
+ const def = config2.reviewers[name];
4300
4827
  if (!def) {
4301
4828
  throw new Error(
4302
4829
  `reviewer "${name}" is not configured. Nothing to remove.`
4303
4830
  );
4304
4831
  }
4305
4832
  const referencedBy = [];
4306
- for (const [branch, rule] of Object.entries(config.branches)) {
4833
+ for (const [branch, rule] of Object.entries(config2.branches)) {
4307
4834
  if (rule.required.includes(name)) referencedBy.push(branch);
4308
4835
  }
4309
4836
  if (referencedBy.length > 0) {
@@ -4311,13 +4838,13 @@ function reviewersRemove(name, opts = {}) {
4311
4838
  `reviewer "${name}" is required by branch(es): ${referencedBy.join(", ")}. Remove it from those branches' \`required\` list in .stamp/config.yml before removing.`
4312
4839
  );
4313
4840
  }
4314
- delete config.reviewers[name];
4315
- writeFileSync10(configPath, stringifyConfig(config));
4841
+ delete config2.reviewers[name];
4842
+ writeFileSync11(configPath, stringifyConfig(config2));
4316
4843
  console.log(`reviewer "${name}" removed from .stamp/config.yml`);
4317
4844
  if (opts.deleteFile) {
4318
4845
  const promptAbs = resolve(repoRoot, def.prompt);
4319
- if (existsSync14(promptAbs)) {
4320
- unlinkSync2(promptAbs);
4846
+ if (existsSync16(promptAbs)) {
4847
+ unlinkSync4(promptAbs);
4321
4848
  console.log(`deleted ${def.prompt}`);
4322
4849
  }
4323
4850
  } else {
@@ -4328,8 +4855,8 @@ function reviewersRemove(name, opts = {}) {
4328
4855
  }
4329
4856
  async function reviewersTest(name, diff) {
4330
4857
  const repoRoot = findRepoRoot();
4331
- const config = loadConfig(stampConfigFile(repoRoot));
4332
- if (!config.reviewers[name]) {
4858
+ const config2 = loadConfig(stampConfigFile(repoRoot));
4859
+ if (!config2.reviewers[name]) {
4333
4860
  throw new Error(
4334
4861
  `reviewer "${name}" is not configured. Run \`stamp reviewers list\`.`
4335
4862
  );
@@ -4346,12 +4873,12 @@ async function reviewersTest(name, diff) {
4346
4873
  );
4347
4874
  console.log(` prompt sourced from working tree (test/iteration use case)`);
4348
4875
  console.log();
4349
- const def = config.reviewers[name];
4350
- const promptPath = join8(repoRoot, def.prompt);
4351
- const systemPrompt = readFileSync8(promptPath, "utf8");
4876
+ const def = config2.reviewers[name];
4877
+ const promptPath = join9(repoRoot, def.prompt);
4878
+ const systemPrompt = readFileSync9(promptPath, "utf8");
4352
4879
  const result = await invokeReviewer({
4353
4880
  reviewer: name,
4354
- config,
4881
+ config: config2,
4355
4882
  repoRoot,
4356
4883
  diff: resolved.diff,
4357
4884
  base_sha: resolved.base_sha,
@@ -4368,14 +4895,14 @@ async function reviewersTest(name, diff) {
4368
4895
  }
4369
4896
  function reviewersShow(name, opts) {
4370
4897
  const repoRoot = findRepoRoot();
4371
- const config = loadConfig(stampConfigFile(repoRoot));
4372
- if (!config.reviewers[name]) {
4898
+ const config2 = loadConfig(stampConfigFile(repoRoot));
4899
+ if (!config2.reviewers[name]) {
4373
4900
  throw new Error(
4374
4901
  `reviewer "${name}" is not configured. Run \`stamp reviewers list\`.`
4375
4902
  );
4376
4903
  }
4377
4904
  const dbPath = stampStateDbPath(repoRoot);
4378
- if (!existsSync14(dbPath)) {
4905
+ if (!existsSync16(dbPath)) {
4379
4906
  console.log("No reviews recorded yet (no state.db).");
4380
4907
  return;
4381
4908
  }
@@ -4391,7 +4918,7 @@ function reviewersShow(name, opts) {
4391
4918
  const bar = "\u2500".repeat(72);
4392
4919
  console.log(bar);
4393
4920
  console.log(`reviewer: ${name}`);
4394
- console.log(`prompt: ${config.reviewers[name].prompt}`);
4921
+ console.log(`prompt: ${config2.reviewers[name].prompt}`);
4395
4922
  console.log(bar);
4396
4923
  if (stats.total === 0) {
4397
4924
  console.log(" no verdicts recorded yet");
@@ -4465,7 +4992,7 @@ async function reviewersFetch(reviewerName, opts) {
4465
4992
  opts.expectMcpSha
4466
4993
  );
4467
4994
  const reviewersDir = stampReviewersDir(repoRoot);
4468
- if (!existsSync14(reviewersDir)) {
4995
+ if (!existsSync16(reviewersDir)) {
4469
4996
  throw new Error(
4470
4997
  `${reviewersDir} does not exist \u2014 run \`stamp init\` first.`
4471
4998
  );
@@ -4478,7 +5005,7 @@ async function reviewersFetch(reviewerName, opts) {
4478
5005
  let tools;
4479
5006
  let mcpServers;
4480
5007
  if (configYaml !== null) {
4481
- const parsed = parseYaml4(configYaml) ?? {};
5008
+ const parsed = parseYaml5(configYaml) ?? {};
4482
5009
  if (Array.isArray(parsed.tools)) {
4483
5010
  tools = parseToolsLoose(parsed.tools);
4484
5011
  }
@@ -4486,7 +5013,7 @@ async function reviewersFetch(reviewerName, opts) {
4486
5013
  mcpServers = validateMcpServersFromSource(parsed.mcp_servers, source, ref);
4487
5014
  }
4488
5015
  }
4489
- const promptPath = join8(reviewersDir, `${reviewerName}.md`);
5016
+ const promptPath = join9(reviewersDir, `${reviewerName}.md`);
4490
5017
  const promptBytes = Buffer.from(promptText, "utf8");
4491
5018
  const promptSha = hashPromptBytes(promptBytes);
4492
5019
  const toolsSha = hashTools(tools);
@@ -4510,7 +5037,7 @@ async function reviewersFetch(reviewerName, opts) {
4510
5037
  mcpSha
4511
5038
  );
4512
5039
  }
4513
- writeFileSync10(promptPath, promptBytes);
5040
+ writeFileSync11(promptPath, promptBytes);
4514
5041
  const lock = {
4515
5042
  version: LOCK_FILE_VERSION,
4516
5043
  source,
@@ -4546,8 +5073,8 @@ async function reviewersFetch(reviewerName, opts) {
4546
5073
  function reviewersVerify(opts) {
4547
5074
  if (opts.only) requireValidReviewerName(opts.only);
4548
5075
  const repoRoot = findRepoRoot();
4549
- const config = loadConfig(stampConfigFile(repoRoot));
4550
- const names = opts.only ? [opts.only] : Object.keys(config.reviewers);
5076
+ const config2 = loadConfig(stampConfigFile(repoRoot));
5077
+ const names = opts.only ? [opts.only] : Object.keys(config2.reviewers);
4551
5078
  if (names.length === 0) {
4552
5079
  console.log("No reviewers configured.");
4553
5080
  return;
@@ -4560,7 +5087,7 @@ function reviewersVerify(opts) {
4560
5087
  let anyDrift = false;
4561
5088
  let anyLocked = false;
4562
5089
  for (const name of names) {
4563
- const def = config.reviewers[name];
5090
+ const def = config2.reviewers[name];
4564
5091
  if (!def) {
4565
5092
  console.error(
4566
5093
  `error: reviewer '${name}' is not in .stamp/config.yml. Add it with \`stamp reviewers add ${name}\` or remove its lock file.`
@@ -4735,7 +5262,7 @@ function buildConfigYamlHint(reviewerName, tools, mcpServers) {
4735
5262
  if (mcpServers && Object.keys(mcpServers).length > 0) {
4736
5263
  reviewerBlock.mcp_servers = mcpServers;
4737
5264
  }
4738
- return stringifyYaml2({ reviewers: { [reviewerName]: reviewerBlock } }).trimEnd();
5265
+ return stringifyYaml3({ reviewers: { [reviewerName]: reviewerBlock } }).trimEnd();
4739
5266
  }
4740
5267
  function launchEditor(path2) {
4741
5268
  const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? (process.platform === "win32" ? "notepad" : "vi");
@@ -4751,22 +5278,22 @@ function launchEditor(path2) {
4751
5278
  }
4752
5279
 
4753
5280
  // src/commands/status.ts
4754
- import { existsSync as existsSync15 } from "fs";
5281
+ import { existsSync as existsSync17 } from "fs";
4755
5282
  function runStatus(opts) {
4756
5283
  const repoRoot = findRepoRoot();
4757
5284
  const configPath = stampConfigFile(repoRoot);
4758
- if (!existsSync15(configPath)) {
5285
+ if (!existsSync17(configPath)) {
4759
5286
  throw new Error(
4760
5287
  `no .stamp/config.yml at ${configPath}. Run \`stamp init\` first.`
4761
5288
  );
4762
5289
  }
4763
- const config = loadConfig(configPath);
5290
+ const config2 = loadConfig(configPath);
4764
5291
  const resolved = resolveDiff(opts.diff, repoRoot);
4765
5292
  const target = opts.into ?? inferTarget(opts.diff);
4766
- const rule = findBranchRule(config.branches, target);
5293
+ const rule = findBranchRule(config2.branches, target);
4767
5294
  if (!rule) {
4768
5295
  throw new Error(
4769
- `no branch rule for "${target}" in .stamp/config.yml. Configured branches: ${Object.keys(config.branches).join(", ") || "(none)"}. Use --into <target> to override.`
5296
+ `no branch rule for "${target}" in .stamp/config.yml. Configured branches: ${Object.keys(config2.branches).join(", ") || "(none)"}. Use --into <target> to override.`
4770
5297
  );
4771
5298
  }
4772
5299
  const db = openDb(stampStateDbPath(repoRoot));
@@ -4826,19 +5353,19 @@ function printGate(result, base_sha, head_sha) {
4826
5353
  import { spawnSync as spawnSync7 } from "child_process";
4827
5354
 
4828
5355
  // src/lib/version.ts
4829
- import { readFileSync as readFileSync9 } from "fs";
4830
- import { dirname as dirname4, join as join9 } from "path";
5356
+ import { readFileSync as readFileSync10 } from "fs";
5357
+ import { dirname as dirname5, join as join10 } from "path";
4831
5358
  import { fileURLToPath } from "url";
4832
5359
  function readPackageVersion() {
4833
- const here = dirname4(fileURLToPath(import.meta.url));
5360
+ const here = dirname5(fileURLToPath(import.meta.url));
4834
5361
  for (let dir = here, i = 0; i < 6; i++) {
4835
5362
  try {
4836
- const raw = readFileSync9(join9(dir, "package.json"), "utf8");
5363
+ const raw = readFileSync10(join10(dir, "package.json"), "utf8");
4837
5364
  const pkg = JSON.parse(raw);
4838
5365
  if (pkg.name === "@openthink/stamp" && pkg.version) return pkg.version;
4839
5366
  } catch {
4840
5367
  }
4841
- const parent = dirname4(dir);
5368
+ const parent = dirname5(dir);
4842
5369
  if (parent === dir) break;
4843
5370
  dir = parent;
4844
5371
  }
@@ -4938,7 +5465,7 @@ function loadConfigAtSha(sha, repoRoot) {
4938
5465
  }
4939
5466
  function runVerify(sha) {
4940
5467
  const repoRoot = findRepoRoot();
4941
- const config = loadConfigAtSha(sha, repoRoot);
5468
+ const config2 = loadConfigAtSha(sha, repoRoot);
4942
5469
  const commitMessage2 = git2(["show", "-s", "--format=%B", sha], repoRoot);
4943
5470
  const parsed = parseCommitAttestation(commitMessage2);
4944
5471
  if (!parsed) {
@@ -4983,7 +5510,7 @@ function runVerify(sha) {
4983
5510
  `computed merge-base(${parent0.slice(0, 8)}, ${parent1.slice(0, 8)}) = ${actualMergeBase.slice(0, 8)}, does not match payload.base_sha (${payload.base_sha.slice(0, 8)})`
4984
5511
  );
4985
5512
  }
4986
- const rule = findBranchRule(config.branches, payload.target_branch);
5513
+ const rule = findBranchRule(config2.branches, payload.target_branch);
4987
5514
  if (!rule) {
4988
5515
  fail(
4989
5516
  sha,
@@ -5029,12 +5556,12 @@ function runVerify(sha) {
5029
5556
  );
5030
5557
  }
5031
5558
  if ((payload.schema_version ?? 1) >= 2) {
5032
- verifyReviewerHashes(sha, payload, repoRoot, config);
5559
+ verifyReviewerHashes(sha, payload, repoRoot, config2);
5033
5560
  }
5034
5561
  printSuccess2(sha, payload);
5035
5562
  }
5036
- function verifyReviewerHashes(sha, payload, repoRoot, config) {
5037
- const reviewers2 = config.reviewers;
5563
+ function verifyReviewerHashes(sha, payload, repoRoot, config2) {
5564
+ const reviewers2 = config2.reviewers;
5038
5565
  if (Object.keys(reviewers2).length === 0) {
5039
5566
  fail(
5040
5567
  sha,
@@ -5294,6 +5821,42 @@ server.command("config [host:port]").description(
5294
5821
  }
5295
5822
  }
5296
5823
  );
5824
+ var config = program.command("config").description(
5825
+ "manage per-user stamp config at ~/.stamp/config.yml \u2014 operator-level knobs that shouldn't be committed. Per-repo policy lives in `.stamp/config.yml`."
5826
+ );
5827
+ var configReviewers = config.command("reviewers").description(
5828
+ "pin which Anthropic model each reviewer (security/standards/product/\u2026) runs on. Defaults to claude-sonnet-4-6 for the three starter personas; opt into Opus on security with `set security claude-opus-4-7`."
5829
+ );
5830
+ configReviewers.command("set <reviewer> <model-id>").description(
5831
+ "pin <reviewer> to <model-id> (e.g. `set security claude-opus-4-7`). Model id is opaque to stamp \u2014 passed straight to the agent SDK, so any string the SDK accepts works."
5832
+ ).action((reviewer, modelId) => {
5833
+ try {
5834
+ runConfigReviewersSet({ reviewer, modelId });
5835
+ } catch (err) {
5836
+ handleCliError(err);
5837
+ }
5838
+ });
5839
+ configReviewers.command("clear [reviewer]").description(
5840
+ "remove a reviewer's model pin (resolver falls back to the SDK default), or pass --all to delete the whole ~/.stamp/config.yml."
5841
+ ).option(
5842
+ "--all",
5843
+ "remove the entire ~/.stamp/config.yml file (every reviewer falls back to the SDK default)"
5844
+ ).action((reviewer, opts) => {
5845
+ try {
5846
+ runConfigReviewersClear({ reviewer, all: opts.all });
5847
+ } catch (err) {
5848
+ handleCliError(err);
5849
+ }
5850
+ });
5851
+ configReviewers.command("show").description(
5852
+ "print the resolved per-reviewer model config (or note that no config is set and which defaults will apply)."
5853
+ ).action(() => {
5854
+ try {
5855
+ runConfigReviewersShow();
5856
+ } catch (err) {
5857
+ handleCliError(err);
5858
+ }
5859
+ });
5297
5860
  var serverRepo = program.command("server-repos").description(
5298
5861
  "manage bare repos on the stamp server (list / delete / restore). Uses ~/.stamp/server.yml or --server."
5299
5862
  );
@@ -5367,9 +5930,12 @@ program.command("status").description("show gate state for a diff; exit 0 if gat
5367
5930
  handleCliError(err);
5368
5931
  }
5369
5932
  });
5370
- program.command("merge <branch>").description("merge <branch> into --into <target> if the gate is open").requiredOption("--into <target>", "target branch to merge into").action((branch, opts) => {
5933
+ program.command("merge <branch>").description("merge <branch> into --into <target> if the gate is open").requiredOption("--into <target>", "target branch to merge into").option(
5934
+ "-y, --yes",
5935
+ "skip the operator-confirmation prompt for this invocation (equivalent to STAMP_REQUIRE_HUMAN_MERGE=0; see audit H1)"
5936
+ ).action((branch, opts) => {
5371
5937
  try {
5372
- runMerge({ branch, into: opts.into });
5938
+ runMerge({ branch, into: opts.into, yes: opts.yes });
5373
5939
  } catch (err) {
5374
5940
  handleCliError(err);
5375
5941
  }
@@ -5392,13 +5958,13 @@ program.command("update").description(
5392
5958
  "upgrade stamp to the latest npm release (runs 'npm install -g @openthink/stamp@latest')"
5393
5959
  ).action(() => wrap(() => runUpdate()));
5394
5960
  program.command("prune").description(
5395
- "delete review-history rows older than <duration> from the per-machine state.db, then VACUUM. Use --dry-run first to preview."
5961
+ "delete review-history rows older than <duration> from the per-machine state.db (then VACUUM), AND unlink failed-parse spool files under .git/stamp/failed-parses/ whose mtime is older than <duration>. Use --dry-run first to preview both passes."
5396
5962
  ).requiredOption(
5397
5963
  "--older-than <duration>",
5398
5964
  "retention cutoff, e.g. 30d (days), 12h (hours), 90m (minutes)"
5399
5965
  ).option(
5400
5966
  "--dry-run",
5401
- "print the per-reviewer breakdown that would be pruned without modifying the DB"
5967
+ "print the per-reviewer breakdown of state.db rows AND the list of spool file paths that would be pruned, without modifying anything"
5402
5968
  ).action((opts) => {
5403
5969
  try {
5404
5970
  runPrune({ olderThan: opts.olderThan, dryRun: opts.dryRun });
@@ -5408,7 +5974,7 @@ program.command("prune").description(
5408
5974
  });
5409
5975
  program.command("ui").description("launch the interactive terminal UI").action(async () => {
5410
5976
  try {
5411
- const { runUi } = await import("./ui-4V2HDHOS.js");
5977
+ const { runUi } = await import("./ui-TKLZWCPL.js");
5412
5978
  runUi();
5413
5979
  } catch (err) {
5414
5980
  handleCliError(err);