@openthink/stamp 1.1.0 → 1.3.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,26 +39,30 @@ 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";
56
58
  import { join } from "path";
57
- var STAMP_BEGIN = "<!-- stamp:begin (managed by stamp-cli \u2014 do not edit between markers) -->";
59
+ var STAMP_BEGIN = "<!-- stamp:begin (managed by `stamp init` \u2014 do not edit between markers) -->";
58
60
  var STAMP_END = "<!-- stamp:end -->";
59
- var STAMP_CLAUDE_BEGIN = "<!-- stamp:claude:begin (managed by stamp-cli \u2014 do not edit between markers) -->";
60
- var STAMP_CLAUDE_END = "<!-- stamp:claude:end -->";
61
+ var STAMP_BEGIN_LEGACY = "<!-- stamp:begin (managed by stamp-cli \u2014 do not edit between markers) -->";
62
+ var STAMP_CLAUDE_BEGIN_LEGACY = "<!-- stamp:claude:begin (managed by stamp-cli \u2014 do not edit between markers) -->";
63
+ var STAMP_CLAUDE_END_LEGACY = "<!-- stamp:claude:end -->";
64
+ var STAMP_BEGIN_PREFIX = "<!-- stamp:begin ";
65
+ var STAMP_CLAUDE_BEGIN_PREFIX = "<!-- stamp:claude:begin ";
61
66
  var REVIEW_LOOP_HEURISTIC = `### Knowing when to stop the review loop (diminishing returns)
62
67
 
63
68
  Each \`stamp review\` run is non-trivial \u2014 reviewer LLM calls, your context, and amend
@@ -246,6 +251,20 @@ attestation, so the audit trail is preserved even without server-side rejection.
246
251
 
247
252
  ${REVIEW_LOOP_HEURISTIC}
248
253
  `;
254
+ function findManagedBlock(text, openPrefix, closeMarker) {
255
+ let searchStart = 0;
256
+ while (searchStart < text.length) {
257
+ const candidateIdx = text.indexOf(openPrefix, searchStart);
258
+ if (candidateIdx === -1) return null;
259
+ if (candidateIdx === 0 || text[candidateIdx - 1] === "\n") {
260
+ const closeStart = text.indexOf(closeMarker, candidateIdx);
261
+ if (closeStart === -1) return null;
262
+ return { beginIdx: candidateIdx, afterEnd: closeStart + closeMarker.length };
263
+ }
264
+ searchStart = candidateIdx + 1;
265
+ }
266
+ return null;
267
+ }
249
268
  function injectStampSection(existing, mode = "server-gated") {
250
269
  const body = mode === "server-gated" ? STAMP_AGENTS_SECTION_SERVER_GATED : STAMP_AGENTS_SECTION_LOCAL_ONLY;
251
270
  const stampBlock = `${STAMP_BEGIN}
@@ -261,12 +280,10 @@ Guidance for AI agents working in this repository.
261
280
  ${stampBlock}
262
281
  `;
263
282
  }
264
- const beginIdx = existing.indexOf(STAMP_BEGIN);
265
- const endIdx = existing.indexOf(STAMP_END);
266
- if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
267
- const before = existing.slice(0, beginIdx);
268
- const afterStart = endIdx + STAMP_END.length;
269
- const after = existing.slice(afterStart);
283
+ const found = findManagedBlock(existing, STAMP_BEGIN_PREFIX, STAMP_END);
284
+ if (found) {
285
+ const before = existing.slice(0, found.beginIdx);
286
+ const after = existing.slice(found.afterEnd);
270
287
  return `${before}${stampBlock}${after}`;
271
288
  }
272
289
  return `${existing.trimEnd()}
@@ -283,7 +300,7 @@ function ensureAgentsMd(repoRoot, mode = "server-gated") {
283
300
  const existing = readFileSync(path2, "utf8");
284
301
  const updated = injectStampSection(existing, mode);
285
302
  if (updated === existing) return "unchanged";
286
- const action = existing.includes(STAMP_BEGIN) ? "replaced" : "appended";
303
+ const action = existing.includes(STAMP_BEGIN) || existing.includes(STAMP_BEGIN_LEGACY) ? "replaced" : "appended";
287
304
  writeFileSync(path2, updated);
288
305
  return action;
289
306
  }
@@ -304,6 +321,8 @@ stamp merge feature --into main # signs the merge
304
321
  git push origin main # OR \`stamp push main\` if origin is a stamp server
305
322
  \`\`\`
306
323
 
324
+ Key commands: \`stamp provision\` \u2014 provision a new repo; \`stamp review\` \u2014 run reviewers; \`stamp merge\` \u2014 sign a merge; \`stamp push\` \u2014 push to a stamp server.
325
+
307
326
  **The full reference is at [\`AGENTS.md\`](./AGENTS.md) at the repo root** \u2014
308
327
  read it before any git command. It covers the mode (server-gated vs.
309
328
  local-only), what NOT to do, where things live, and how to recover when stamp
@@ -314,11 +333,11 @@ blocks you.
314
333
  (there's nothing to review against). Recent \`stamp init\` runs do this commit
315
334
  automatically. Every subsequent change goes through the stamp flow.`;
316
335
  function injectClaudeSection(existing) {
317
- const stampBlock = `${STAMP_CLAUDE_BEGIN}
336
+ const stampBlock = `${STAMP_BEGIN}
318
337
 
319
338
  ${STAMP_CLAUDE_SECTION.trimEnd()}
320
339
 
321
- ${STAMP_CLAUDE_END}`;
340
+ ${STAMP_END}`;
322
341
  if (existing === void 0 || existing.trim() === "") {
323
342
  return `# CLAUDE.md
324
343
 
@@ -327,12 +346,10 @@ Project-specific instructions for Claude Code (auto-loaded into the model's cont
327
346
  ${stampBlock}
328
347
  `;
329
348
  }
330
- const beginIdx = existing.indexOf(STAMP_CLAUDE_BEGIN);
331
- const endIdx = existing.indexOf(STAMP_CLAUDE_END);
332
- if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
333
- const before = existing.slice(0, beginIdx);
334
- const afterStart = endIdx + STAMP_CLAUDE_END.length;
335
- const after = existing.slice(afterStart);
349
+ const found = findManagedBlock(existing, STAMP_BEGIN_PREFIX, STAMP_END) ?? findManagedBlock(existing, STAMP_CLAUDE_BEGIN_PREFIX, STAMP_CLAUDE_END_LEGACY);
350
+ if (found) {
351
+ const before = existing.slice(0, found.beginIdx);
352
+ const after = existing.slice(found.afterEnd);
336
353
  return `${before}${stampBlock}${after}`;
337
354
  }
338
355
  return `${existing.trimEnd()}
@@ -349,7 +366,7 @@ function ensureClaudeMd(repoRoot) {
349
366
  const existing = readFileSync(path2, "utf8");
350
367
  const updated = injectClaudeSection(existing);
351
368
  if (updated === existing) return "unchanged";
352
- const action = existing.includes(STAMP_CLAUDE_BEGIN) ? "replaced" : "appended";
369
+ const action = existing.includes(STAMP_BEGIN) || existing.includes(STAMP_BEGIN_LEGACY) || existing.includes(STAMP_CLAUDE_BEGIN_LEGACY) ? "replaced" : "appended";
353
370
  writeFileSync(path2, updated);
354
371
  return action;
355
372
  }
@@ -464,9 +481,19 @@ function validateConfig(input) {
464
481
  throw new Error(`config.branches.${name}.required must be an array`);
465
482
  }
466
483
  const required_checks = parseChecks(r.required_checks, name);
484
+ let require_human_merge;
485
+ if (r.require_human_merge !== void 0) {
486
+ if (typeof r.require_human_merge !== "boolean") {
487
+ throw new Error(
488
+ `config.branches.${name}.require_human_merge must be a boolean`
489
+ );
490
+ }
491
+ require_human_merge = r.require_human_merge;
492
+ }
467
493
  branches[name] = {
468
494
  required: r.required.map(String),
469
- ...required_checks ? { required_checks } : {}
495
+ ...required_checks ? { required_checks } : {},
496
+ ...require_human_merge !== void 0 ? { require_human_merge } : {}
470
497
  };
471
498
  }
472
499
  const reviewers2 = {};
@@ -484,10 +511,20 @@ function validateConfig(input) {
484
511
  }
485
512
  const tools = parseTools(d.tools, name);
486
513
  const mcp_servers = parseMcpServers(d.mcp_servers, name);
514
+ let enforce_reads_on_dotstamp;
515
+ if (d.enforce_reads_on_dotstamp !== void 0) {
516
+ if (typeof d.enforce_reads_on_dotstamp !== "boolean") {
517
+ throw new Error(
518
+ `config.reviewers.${name}.enforce_reads_on_dotstamp must be a boolean (got ${JSON.stringify(d.enforce_reads_on_dotstamp)})`
519
+ );
520
+ }
521
+ enforce_reads_on_dotstamp = d.enforce_reads_on_dotstamp;
522
+ }
487
523
  reviewers2[name] = {
488
524
  prompt: d.prompt,
489
525
  ...tools ? { tools } : {},
490
- ...mcp_servers ? { mcp_servers } : {}
526
+ ...mcp_servers ? { mcp_servers } : {},
527
+ ...enforce_reads_on_dotstamp !== void 0 ? { enforce_reads_on_dotstamp } : {}
491
528
  };
492
529
  }
493
530
  return { branches, reviewers: reviewers2 };
@@ -707,8 +744,8 @@ function parseStringMap(input, path2) {
707
744
  }
708
745
  return out;
709
746
  }
710
- function stringifyConfig(config) {
711
- return stringify(config);
747
+ function stringifyConfig(config2) {
748
+ return stringify(config2);
712
749
  }
713
750
  function findBranchRule(branches, branchName) {
714
751
  const exact = branches[branchName];
@@ -1262,14 +1299,61 @@ function redactMcpToolName(tool2) {
1262
1299
  return `mcp__sha256:${h(server2)}__sha256:${h(name)}`;
1263
1300
  }
1264
1301
  function redactToolCallsForAttestation(calls) {
1265
- if (process.env.STAMP_HASH_MCP_NAMES !== "1") return calls;
1302
+ if (process.env.STAMP_HASH_MCP_NAMES === "0") return calls;
1266
1303
  return calls.map((c) => ({ ...c, tool: redactMcpToolName(c.tool) }));
1267
1304
  }
1268
1305
 
1306
+ // src/lib/humanMerge.ts
1307
+ import { readSync } from "fs";
1308
+ function requireHumanMerge(args) {
1309
+ if (args.branchRule.require_human_merge === false) return;
1310
+ if (args.yes) return;
1311
+ if (process.env.STAMP_REQUIRE_HUMAN_MERGE === "0") return;
1312
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1313
+ throw new Error(
1314
+ `confirmation required: stamp merge needs interactive confirmation for protected branch "${args.target}", but no TTY is attached.
1315
+
1316
+ Opt out explicitly \u2014 pick one:
1317
+ - per-invocation: stamp merge ${args.source} --into ${args.target} --yes
1318
+ - per-shell: STAMP_REQUIRE_HUMAN_MERGE=0 stamp merge ...
1319
+ - per-branch: add 'require_human_merge: false' under branches.${args.target} in .stamp/config.yml (and merge that change through the normal review flow)
1320
+
1321
+ 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.`
1322
+ );
1323
+ }
1324
+ const prompt2 = `Sign + merge '${args.source}' (${args.head_sha.slice(0, 8)}) \u2192 '${args.target}' (base ${args.base_sha.slice(0, 8)})? [y/N] `;
1325
+ process.stdout.write(prompt2);
1326
+ const answer = readLineSync().trim().toLowerCase();
1327
+ if (answer !== "y" && answer !== "yes") {
1328
+ throw new Error(
1329
+ `merge cancelled: operator answered '${answer || "<empty>"}' to the confirmation prompt for ${args.source} \u2192 ${args.target}.`
1330
+ );
1331
+ }
1332
+ }
1333
+ function readLineSync() {
1334
+ const buf = Buffer.alloc(1);
1335
+ let out = "";
1336
+ const fd = 0;
1337
+ for (; ; ) {
1338
+ let n;
1339
+ try {
1340
+ n = readSync(fd, buf, 0, 1, null);
1341
+ } catch {
1342
+ break;
1343
+ }
1344
+ if (n === 0) break;
1345
+ const ch = buf.toString("utf8", 0, 1);
1346
+ if (ch === "\n") break;
1347
+ out += ch;
1348
+ }
1349
+ if (out.endsWith("\r")) out = out.slice(0, -1);
1350
+ return out;
1351
+ }
1352
+
1269
1353
  // src/commands/merge.ts
1270
1354
  function runMerge(opts) {
1271
1355
  const repoRoot = findRepoRoot();
1272
- const config = loadConfig(stampConfigFile(repoRoot));
1356
+ const config2 = loadConfig(stampConfigFile(repoRoot));
1273
1357
  const currentBranch2 = git(
1274
1358
  ["rev-parse", "--abbrev-ref", "HEAD"],
1275
1359
  repoRoot
@@ -1290,7 +1374,7 @@ function runMerge(opts) {
1290
1374
  }
1291
1375
  const revspec = `${opts.into}..${opts.branch}`;
1292
1376
  const resolved = resolveDiff(revspec, repoRoot);
1293
- const rule = findBranchRule(config.branches, opts.into);
1377
+ const rule = findBranchRule(config2.branches, opts.into);
1294
1378
  if (!rule) {
1295
1379
  throw new Error(
1296
1380
  `no branch rule for "${opts.into}" in .stamp/config.yml`
@@ -1317,6 +1401,14 @@ function runMerge(opts) {
1317
1401
  `gate CLOSED: missing approved verdicts for: ${missing.join(", ")}. Run \`stamp status --diff ${revspec}\` to inspect, then \`stamp review --diff ${revspec}\` to review.`
1318
1402
  );
1319
1403
  }
1404
+ requireHumanMerge({
1405
+ target: opts.into,
1406
+ source: opts.branch,
1407
+ base_sha: resolved.base_sha,
1408
+ head_sha: resolved.head_sha,
1409
+ branchRule: rule,
1410
+ yes: opts.yes ?? false
1411
+ });
1320
1412
  approvals = rule.required.map((name) => {
1321
1413
  const rev = byReviewer.get(name);
1322
1414
  const toolCalls = redactToolCallsForAttestation(parseToolCalls(rev.tool_calls));
@@ -1485,11 +1577,11 @@ function runPush(opts) {
1485
1577
  }
1486
1578
 
1487
1579
  // src/commands/review.ts
1488
- import { existsSync as existsSync4 } from "fs";
1580
+ import { existsSync as existsSync5 } from "fs";
1489
1581
 
1490
1582
  // src/lib/reviewer.ts
1491
1583
  import { randomBytes } from "crypto";
1492
- import { chmodSync, mkdirSync, writeFileSync as writeFileSync2 } from "fs";
1584
+ import { chmodSync, mkdirSync as mkdirSync2, realpathSync, writeFileSync as writeFileSync3 } from "fs";
1493
1585
  import path from "path";
1494
1586
  import { createSdkMcpServer, query, tool } from "@anthropic-ai/claude-agent-sdk";
1495
1587
  import { z as z2 } from "zod";
@@ -1525,10 +1617,137 @@ ${body}
1525
1617
  ${close}`;
1526
1618
  }
1527
1619
 
1620
+ // src/lib/userConfig.ts
1621
+ import {
1622
+ existsSync as existsSync3,
1623
+ mkdirSync,
1624
+ readFileSync as readFileSync4,
1625
+ renameSync,
1626
+ unlinkSync,
1627
+ writeFileSync as writeFileSync2
1628
+ } from "fs";
1629
+ import { dirname } from "path";
1630
+ import { parse as parseYaml3, stringify as stringifyYaml } from "yaml";
1631
+ var DEFAULT_REVIEWER_MODELS = {
1632
+ security: "claude-sonnet-4-6",
1633
+ standards: "claude-sonnet-4-6",
1634
+ product: "claude-sonnet-4-6"
1635
+ };
1636
+ var REVIEWER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
1637
+ var MODEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/;
1638
+ function isValidReviewerName(name) {
1639
+ return REVIEWER_NAME_RE.test(name);
1640
+ }
1641
+ function isValidModelId(id) {
1642
+ return MODEL_ID_RE.test(id) && id.length <= 128;
1643
+ }
1644
+ function loadUserConfig() {
1645
+ const path2 = userConfigPath();
1646
+ if (!existsSync3(path2)) return null;
1647
+ let raw;
1648
+ try {
1649
+ raw = readFileSync4(path2, "utf8");
1650
+ } catch (err) {
1651
+ throw new Error(
1652
+ `failed to read ${path2}: ${err instanceof Error ? err.message : String(err)}`
1653
+ );
1654
+ }
1655
+ return parseUserConfig(raw, path2);
1656
+ }
1657
+ function parseUserConfig(raw, contextPath = "<inline>") {
1658
+ const trimmed = raw.trim();
1659
+ if (trimmed === "") {
1660
+ return { reviewers: {} };
1661
+ }
1662
+ const parsed = parseYaml3(raw);
1663
+ if (parsed === null || parsed === void 0) {
1664
+ return { reviewers: {} };
1665
+ }
1666
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
1667
+ throw new Error(
1668
+ `${contextPath}: must be a YAML mapping (got ${Array.isArray(parsed) ? "array" : typeof parsed})`
1669
+ );
1670
+ }
1671
+ const obj = parsed;
1672
+ const reviewersRaw = obj.reviewers;
1673
+ const reviewers2 = {};
1674
+ if (reviewersRaw !== void 0 && reviewersRaw !== null) {
1675
+ if (typeof reviewersRaw !== "object" || Array.isArray(reviewersRaw)) {
1676
+ throw new Error(
1677
+ `${contextPath}: 'reviewers' must be a mapping of <reviewer-name> to <model-id>`
1678
+ );
1679
+ }
1680
+ for (const [name, value] of Object.entries(
1681
+ reviewersRaw
1682
+ )) {
1683
+ if (!isValidReviewerName(name)) {
1684
+ throw new Error(
1685
+ `${contextPath}: reviewer name '${name}' under 'reviewers' is invalid (letters, digits, underscores, hyphens; max 64 chars; no leading hyphen)`
1686
+ );
1687
+ }
1688
+ if (typeof value !== "string" || value.trim() === "") {
1689
+ throw new Error(
1690
+ `${contextPath}: reviewers.${name} must be a non-empty string (model id)`
1691
+ );
1692
+ }
1693
+ const id = value.trim();
1694
+ if (!isValidModelId(id)) {
1695
+ throw new Error(
1696
+ `${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)`
1697
+ );
1698
+ }
1699
+ reviewers2[name] = id;
1700
+ }
1701
+ }
1702
+ return { reviewers: reviewers2 };
1703
+ }
1704
+ function stringifyUserConfig(cfg) {
1705
+ return stringifyYaml({ reviewers: cfg.reviewers });
1706
+ }
1707
+ function writeUserConfig(cfg) {
1708
+ const path2 = userConfigPath();
1709
+ const dir = dirname(path2);
1710
+ if (!existsSync3(dir)) mkdirSync(dir, { recursive: true, mode: 448 });
1711
+ const tmp = `${path2}.tmp.${process.pid}`;
1712
+ writeFileSync2(tmp, stringifyUserConfig(cfg), { mode: 384 });
1713
+ renameSync(tmp, path2);
1714
+ return path2;
1715
+ }
1716
+ function loadOrCreateUserConfig() {
1717
+ const path2 = userConfigPath();
1718
+ const existed = existsSync3(path2);
1719
+ if (!existed) {
1720
+ const defaults = {
1721
+ reviewers: { ...DEFAULT_REVIEWER_MODELS }
1722
+ };
1723
+ writeUserConfig(defaults);
1724
+ return { config: defaults, created: true, path: path2 };
1725
+ }
1726
+ const config2 = loadUserConfig() ?? { reviewers: {} };
1727
+ return { config: config2, created: false, path: path2 };
1728
+ }
1729
+ function resolveReviewerModel(reviewer) {
1730
+ let cfg;
1731
+ try {
1732
+ cfg = loadUserConfig();
1733
+ } catch {
1734
+ return null;
1735
+ }
1736
+ if (!cfg) return null;
1737
+ const id = cfg.reviewers[reviewer];
1738
+ return typeof id === "string" && id.length > 0 ? id : null;
1739
+ }
1740
+ function deleteUserConfig() {
1741
+ const path2 = userConfigPath();
1742
+ if (!existsSync3(path2)) return false;
1743
+ unlinkSync(path2);
1744
+ return true;
1745
+ }
1746
+
1528
1747
  // src/lib/reviewer.ts
1529
1748
  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/"];
1749
+ var REVIEWER_INTERNAL_DENY_PATHS = [];
1750
+ var REVIEWER_INTERNAL_DENY_PREFIXES = [".git/stamp/", ".stamp/trusted-keys/"];
1532
1751
  function denyIfOutsideRepo(inputPath, repoRoot, toolName) {
1533
1752
  if (typeof inputPath !== "string" || inputPath.length === 0) {
1534
1753
  return `${toolName} input path must be a non-empty string`;
@@ -1540,6 +1759,40 @@ function denyIfOutsideRepo(inputPath, repoRoot, toolName) {
1540
1759
  }
1541
1760
  return null;
1542
1761
  }
1762
+ function denyIfRealpathOutsideRepo(resolved, resolvedRoot, inputPath, toolName) {
1763
+ let canonRoot;
1764
+ try {
1765
+ canonRoot = realpathSync.native(resolvedRoot);
1766
+ } catch {
1767
+ return { canon: null, canonRoot: null, deny: null };
1768
+ }
1769
+ let probe = resolved;
1770
+ let realPrefix = null;
1771
+ const tail = [];
1772
+ for (; ; ) {
1773
+ try {
1774
+ realPrefix = realpathSync.native(probe);
1775
+ break;
1776
+ } catch {
1777
+ const parent = path.dirname(probe);
1778
+ if (parent === probe) break;
1779
+ tail.unshift(path.basename(probe));
1780
+ probe = parent;
1781
+ }
1782
+ }
1783
+ if (realPrefix === null) {
1784
+ return { canon: null, canonRoot: null, deny: null };
1785
+ }
1786
+ const canon = tail.length === 0 ? realPrefix : path.join(realPrefix, ...tail);
1787
+ if (canon !== canonRoot && !canon.startsWith(canonRoot + path.sep)) {
1788
+ return {
1789
+ canon,
1790
+ canonRoot,
1791
+ 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.`
1792
+ };
1793
+ }
1794
+ return { canon, canonRoot, deny: null };
1795
+ }
1543
1796
  function denyIfReviewerInternal(resolvedAbs, resolvedRoot, inputPath) {
1544
1797
  const rel = path.relative(resolvedRoot, resolvedAbs);
1545
1798
  for (const denied of REVIEWER_INTERNAL_DENY_PATHS) {
@@ -1549,7 +1802,7 @@ function denyIfReviewerInternal(resolvedAbs, resolvedRoot, inputPath) {
1549
1802
  }
1550
1803
  for (const prefix of REVIEWER_INTERNAL_DENY_PREFIXES) {
1551
1804
  if (rel === prefix.replace(/\/$/, "") || rel.startsWith(prefix)) {
1552
- return `Read of "${inputPath}" denied: ${prefix}* holds reviewer trust anchors and is exfil-attractive.`;
1805
+ return `Read of "${inputPath}" denied: ${prefix}* is reviewer-internal (trust anchors / verdict DB / spools) and is exfil-attractive.`;
1553
1806
  }
1554
1807
  }
1555
1808
  return null;
@@ -1593,9 +1846,18 @@ function checkReviewerTool(args) {
1593
1846
  if (denied) return { allow: false, reason: denied };
1594
1847
  const resolvedRoot = path.resolve(repoRoot);
1595
1848
  const resolved = path.resolve(resolvedRoot, filePath);
1596
- const internal = denyIfReviewerInternal(
1849
+ const realpathCheck = denyIfRealpathOutsideRepo(
1597
1850
  resolved,
1598
1851
  resolvedRoot,
1852
+ filePath,
1853
+ "Read"
1854
+ );
1855
+ if (realpathCheck.deny) return { allow: false, reason: realpathCheck.deny };
1856
+ const internalProbe = realpathCheck.canon ?? resolved;
1857
+ const internalRoot = realpathCheck.canonRoot ?? resolvedRoot;
1858
+ const internal = denyIfReviewerInternal(
1859
+ internalProbe,
1860
+ internalRoot,
1599
1861
  filePath
1600
1862
  );
1601
1863
  if (internal) return { allow: false, reason: internal };
@@ -1606,6 +1868,15 @@ function checkReviewerTool(args) {
1606
1868
  if (grepPath !== void 0) {
1607
1869
  const denied = denyIfOutsideRepo(grepPath, repoRoot, "Grep");
1608
1870
  if (denied) return { allow: false, reason: denied };
1871
+ const resolvedRoot = path.resolve(repoRoot);
1872
+ const resolved = path.resolve(resolvedRoot, grepPath);
1873
+ const realpathCheck = denyIfRealpathOutsideRepo(
1874
+ resolved,
1875
+ resolvedRoot,
1876
+ grepPath,
1877
+ "Grep"
1878
+ );
1879
+ if (realpathCheck.deny) return { allow: false, reason: realpathCheck.deny };
1609
1880
  }
1610
1881
  return { allow: true };
1611
1882
  }
@@ -1614,6 +1885,15 @@ function checkReviewerTool(args) {
1614
1885
  if (globPath !== void 0) {
1615
1886
  const denied = denyIfOutsideRepo(globPath, repoRoot, "Glob");
1616
1887
  if (denied) return { allow: false, reason: denied };
1888
+ const resolvedRoot = path.resolve(repoRoot);
1889
+ const resolved = path.resolve(resolvedRoot, globPath);
1890
+ const realpathCheck = denyIfRealpathOutsideRepo(
1891
+ resolved,
1892
+ resolvedRoot,
1893
+ globPath,
1894
+ "Glob"
1895
+ );
1896
+ if (realpathCheck.deny) return { allow: false, reason: realpathCheck.deny };
1617
1897
  }
1618
1898
  const pattern = input.pattern;
1619
1899
  if (typeof pattern === "string") {
@@ -1635,6 +1915,11 @@ function checkReviewerTool(args) {
1635
1915
  return { allow: true };
1636
1916
  }
1637
1917
  async function invokeReviewer(params) {
1918
+ if (process.env.STAMP_NO_LLM === "1") {
1919
+ throw new Error(
1920
+ `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.`
1921
+ );
1922
+ }
1638
1923
  const def = params.config.reviewers[params.reviewer];
1639
1924
  if (!def) {
1640
1925
  throw new Error(
@@ -1744,6 +2029,7 @@ async function invokeReviewer(params) {
1744
2029
  };
1745
2030
  const maxTurns = parseIntEnv("STAMP_REVIEWER_MAX_TURNS", 8);
1746
2031
  const timeoutMs = parseIntEnv("STAMP_REVIEWER_TIMEOUT_MS", 5 * 60 * 1e3);
2032
+ const modelOverride = resolveReviewerModel(params.reviewer);
1747
2033
  const abortController = new AbortController();
1748
2034
  const timeoutHandle = setTimeout(() => {
1749
2035
  abortController.abort(
@@ -1761,6 +2047,10 @@ async function invokeReviewer(params) {
1761
2047
  mcpServers,
1762
2048
  maxTurns,
1763
2049
  abortController,
2050
+ // Spread the model option so a null resolution leaves the SDK to
2051
+ // pick its own default rather than landing as `model: null` (which
2052
+ // some SDK versions treat as a typed override of the default).
2053
+ ...modelOverride !== null ? { model: modelOverride } : {},
1764
2054
  // PreToolUse fires for every tool call regardless of `allowedTools`
1765
2055
  // membership, which is what we want for security gating: pre-approving
1766
2056
  // a tool name in `allowedTools` should not bypass per-call validation.
@@ -1797,6 +2087,7 @@ async function invokeReviewer(params) {
1797
2087
  let finalText = null;
1798
2088
  let errorMessage = null;
1799
2089
  const toolCalls = [];
2090
+ const readPaths = /* @__PURE__ */ new Set();
1800
2091
  try {
1801
2092
  for await (const msg of q) {
1802
2093
  if (msg.type === "assistant") {
@@ -1810,6 +2101,14 @@ async function invokeReviewer(params) {
1810
2101
  tool: b.name,
1811
2102
  input_sha256: hashToolInput(b.input)
1812
2103
  });
2104
+ if (b.name === "Read" && b.input && typeof b.input === "object") {
2105
+ const fp = b.input.file_path;
2106
+ if (typeof fp === "string" && fp.length > 0) {
2107
+ const resolved = path.resolve(params.repoRoot, fp);
2108
+ const rel = path.relative(params.repoRoot, resolved);
2109
+ if (rel && !rel.startsWith("..")) readPaths.add(rel);
2110
+ }
2111
+ }
1813
2112
  }
1814
2113
  }
1815
2114
  }
@@ -1845,6 +2144,26 @@ async function invokeReviewer(params) {
1845
2144
  verdict = parseLastLineVerdict(fallbackText, params.reviewer, params.repoRoot);
1846
2145
  prose = stripLastLineVerdict(fallbackText);
1847
2146
  }
2147
+ if (def.enforce_reads_on_dotstamp && verdict === "approved") {
2148
+ const missing = findMissingDotstampReads(
2149
+ params.base_sha,
2150
+ params.head_sha,
2151
+ params.repoRoot,
2152
+ readPaths
2153
+ );
2154
+ if (missing.length > 0) {
2155
+ const list = missing.map((p) => ` - ${p}`).join("\n");
2156
+ verdict = "changes_requested";
2157
+ 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:
2158
+
2159
+ ${list}
2160
+
2161
+ 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 ? `
2162
+
2163
+ Original prose:
2164
+ ${prose}` : ""}`;
2165
+ }
2166
+ }
1848
2167
  return {
1849
2168
  reviewer: params.reviewer,
1850
2169
  prose,
@@ -1853,6 +2172,23 @@ async function invokeReviewer(params) {
1853
2172
  retros: submittedRetros
1854
2173
  };
1855
2174
  }
2175
+ function findMissingDotstampReads(baseSha, headSha, repoRoot, readPaths) {
2176
+ let raw;
2177
+ try {
2178
+ raw = runGit(
2179
+ ["diff", "--name-only", "--diff-filter=AMR", `${baseSha}..${headSha}`],
2180
+ repoRoot
2181
+ );
2182
+ } catch {
2183
+ return [];
2184
+ }
2185
+ const modified = raw.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && l.startsWith(".stamp/"));
2186
+ const missing = [];
2187
+ for (const p of modified) {
2188
+ if (!readPaths.has(p)) missing.push(p);
2189
+ }
2190
+ return missing.sort();
2191
+ }
1856
2192
  function resolveMcpServers(def, reviewerName) {
1857
2193
  if (!def.mcp_servers) return void 0;
1858
2194
  const operatorAllowlist = parseEnvAllowlist(
@@ -1992,13 +2328,13 @@ function sanitizeReviewerSlug(name) {
1992
2328
  return cleaned === "" ? "_" : cleaned;
1993
2329
  }
1994
2330
  function writeFailedParseSpool(repoRoot, reviewer, text) {
1995
- const dir = path.join(repoRoot, ".git", "stamp", "failed-parses");
1996
- mkdirSync(dir, { recursive: true, mode: 448 });
2331
+ const dir = path.join(gitCommonDir(repoRoot), "stamp", "failed-parses");
2332
+ mkdirSync2(dir, { recursive: true, mode: 448 });
1997
2333
  chmodSync(dir, 448);
1998
2334
  const slug = sanitizeReviewerSlug(reviewer);
1999
2335
  const filename = `${Date.now()}-${slug}.txt`;
2000
2336
  const filepath = path.join(dir, filename);
2001
- writeFileSync2(filepath, text, { flag: "wx", mode: 384 });
2337
+ writeFileSync3(filepath, text, { flag: "wx", mode: 384 });
2002
2338
  chmodSync(filepath, 384);
2003
2339
  const lineCount = text === "" ? 0 : text.split("\n").length;
2004
2340
  return { path: filepath, lineCount };
@@ -2022,12 +2358,12 @@ function stripLastLineVerdict(text) {
2022
2358
  }
2023
2359
 
2024
2360
  // src/lib/llmNotice.ts
2025
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
2026
- import { dirname } from "path";
2361
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
2362
+ import { dirname as dirname2 } from "path";
2027
2363
  function maybePrintLlmNotice(repoRoot) {
2028
2364
  if (process.env.STAMP_SUPPRESS_LLM_NOTICE === "1") return;
2029
2365
  const marker = stampLlmNoticeMarkerPath(repoRoot);
2030
- if (existsSync3(marker)) return;
2366
+ if (existsSync4(marker)) return;
2031
2367
  process.stderr.write(
2032
2368
  `note: stamp review ships the diff to Anthropic via the Claude Agent SDK.
2033
2369
  See README "Data flow / privacy" for what's sent and how to opt out.
@@ -2036,8 +2372,8 @@ function maybePrintLlmNotice(repoRoot) {
2036
2372
  `
2037
2373
  );
2038
2374
  try {
2039
- mkdirSync2(dirname(marker), { recursive: true });
2040
- writeFileSync3(marker, `${(/* @__PURE__ */ new Date()).toISOString()}
2375
+ mkdirSync3(dirname2(marker), { recursive: true });
2376
+ writeFileSync4(marker, `${(/* @__PURE__ */ new Date()).toISOString()}
2041
2377
  `);
2042
2378
  } catch {
2043
2379
  }
@@ -2046,9 +2382,14 @@ function maybePrintLlmNotice(repoRoot) {
2046
2382
  // src/commands/review.ts
2047
2383
  var DEFAULT_DIFF_SIZE_CAP_BYTES = 200 * 1024;
2048
2384
  async function runReview(opts) {
2385
+ if (process.env.STAMP_NO_LLM === "1") {
2386
+ throw new Error(
2387
+ `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.`
2388
+ );
2389
+ }
2049
2390
  const repoRoot = findRepoRoot();
2050
2391
  const configPath = stampConfigFile(repoRoot);
2051
- if (!existsSync4(configPath)) {
2392
+ if (!existsSync5(configPath)) {
2052
2393
  throw new Error(
2053
2394
  `no .stamp/config.yml at ${configPath}. Run \`stamp init\` first.`
2054
2395
  );
@@ -2088,16 +2429,16 @@ async function runReview(opts) {
2088
2429
  `failed to read .stamp/config.yml at base ${resolved.base_sha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`
2089
2430
  );
2090
2431
  }
2091
- const config = parseConfigFromYaml(baseConfigYaml);
2092
- const reviewerNames = chooseReviewers(config, opts.only);
2432
+ const config2 = parseConfigFromYaml(baseConfigYaml);
2433
+ const reviewerNames = chooseReviewers(config2, opts.only);
2093
2434
  if (reviewerNames.length === 0) {
2094
2435
  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.`
2436
+ `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
2437
  );
2097
2438
  }
2098
2439
  const promptBytesByReviewer = /* @__PURE__ */ new Map();
2099
2440
  for (const name of reviewerNames) {
2100
- const def = config.reviewers[name];
2441
+ const def = config2.reviewers[name];
2101
2442
  let bytes;
2102
2443
  try {
2103
2444
  bytes = showAtRef(resolved.base_sha, def.prompt, repoRoot);
@@ -2109,6 +2450,16 @@ async function runReview(opts) {
2109
2450
  promptBytesByReviewer.set(name, bytes);
2110
2451
  }
2111
2452
  maybePrintLlmNotice(repoRoot);
2453
+ const userCfg = loadOrCreateUserConfig();
2454
+ if (userCfg.created) {
2455
+ process.stderr.write(
2456
+ `note: per-user reviewer-model config written to ${userCfg.path} (Sonnet defaults).
2457
+ Inspect with \`stamp config reviewers show\`; pin a different model with
2458
+ \`stamp config reviewers set <reviewer> <model-id>\`.
2459
+
2460
+ `
2461
+ );
2462
+ }
2112
2463
  console.log(
2113
2464
  `running ${reviewerNames.length} reviewer${reviewerNames.length === 1 ? "" : "s"} in parallel: ${reviewerNames.join(", ")}`
2114
2465
  );
@@ -2125,7 +2476,7 @@ async function runReview(opts) {
2125
2476
  reviewerNames.map(
2126
2477
  (name) => invokeReviewer({
2127
2478
  reviewer: name,
2128
- config,
2479
+ config: config2,
2129
2480
  repoRoot,
2130
2481
  diff: resolved.diff,
2131
2482
  base_sha: resolved.base_sha,
@@ -2160,16 +2511,16 @@ async function runReview(opts) {
2160
2511
  db.close();
2161
2512
  }
2162
2513
  }
2163
- function chooseReviewers(config, only) {
2514
+ function chooseReviewers(config2, only) {
2164
2515
  if (only) {
2165
- if (!(only in config.reviewers)) {
2516
+ if (!(only in config2.reviewers)) {
2166
2517
  throw new Error(
2167
- `reviewer "${only}" is not configured. Available: ${Object.keys(config.reviewers).join(", ") || "(none)"}`
2518
+ `reviewer "${only}" is not configured. Available: ${Object.keys(config2.reviewers).join(", ") || "(none)"}`
2168
2519
  );
2169
2520
  }
2170
2521
  return [only];
2171
2522
  }
2172
- return Object.keys(config.reviewers);
2523
+ return Object.keys(config2.reviewers);
2173
2524
  }
2174
2525
  function printReview(result, base_sha, head_sha) {
2175
2526
  const bar = "\u2500".repeat(72);
@@ -2211,9 +2562,14 @@ var STARTER_PROMPTS = {
2211
2562
  };
2212
2563
  var BOOTSTRAP_BRANCH = "stamp/bootstrap";
2213
2564
  async function runBootstrap(opts = {}) {
2565
+ if (process.env.STAMP_NO_LLM === "1") {
2566
+ throw new Error(
2567
+ `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.`
2568
+ );
2569
+ }
2214
2570
  const repoRoot = findRepoRoot();
2215
2571
  const configFile = stampConfigFile(repoRoot);
2216
- if (!existsSync5(configFile)) {
2572
+ if (!existsSync6(configFile)) {
2217
2573
  throw new Error(
2218
2574
  `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
2575
  );
@@ -2412,45 +2768,45 @@ function buildPlan(current, targetBranch, targetRule, opts) {
2412
2768
  };
2413
2769
  }
2414
2770
  function readSeedDir(seedDir) {
2415
- if (!existsSync5(seedDir) || !statSync(seedDir).isDirectory()) {
2771
+ if (!existsSync6(seedDir) || !statSync(seedDir).isDirectory()) {
2416
2772
  throw new Error(`--from path is not a directory: ${seedDir}`);
2417
2773
  }
2418
2774
  const configPath = join3(seedDir, "config.yml");
2419
- if (!existsSync5(configPath)) {
2775
+ if (!existsSync6(configPath)) {
2420
2776
  throw new Error(`--from dir missing config.yml: ${configPath}`);
2421
2777
  }
2422
2778
  const reviewersDir = join3(seedDir, "reviewers");
2423
- if (!existsSync5(reviewersDir) || !statSync(reviewersDir).isDirectory()) {
2779
+ if (!existsSync6(reviewersDir) || !statSync(reviewersDir).isDirectory()) {
2424
2780
  throw new Error(`--from dir missing reviewers/ subdirectory: ${reviewersDir}`);
2425
2781
  }
2426
- const yaml = readFileSync4(configPath, "utf8");
2427
- const config = parseConfigFromYaml(yaml);
2782
+ const yaml = readFileSync5(configPath, "utf8");
2783
+ const config2 = parseConfigFromYaml(yaml);
2428
2784
  const reviewerFiles = /* @__PURE__ */ new Map();
2429
2785
  for (const entry of readdirSync(reviewersDir)) {
2430
2786
  const full = join3(reviewersDir, entry);
2431
2787
  if (statSync(full).isFile()) {
2432
- reviewerFiles.set(`.stamp/reviewers/${entry}`, readFileSync4(full, "utf8"));
2788
+ reviewerFiles.set(`.stamp/reviewers/${entry}`, readFileSync5(full, "utf8"));
2433
2789
  }
2434
2790
  }
2435
2791
  let mirrorYml;
2436
2792
  const mirrorPath = join3(seedDir, "mirror.yml");
2437
- if (existsSync5(mirrorPath)) {
2438
- mirrorYml = readFileSync4(mirrorPath, "utf8");
2793
+ if (existsSync6(mirrorPath)) {
2794
+ mirrorYml = readFileSync5(mirrorPath, "utf8");
2439
2795
  }
2440
- return { config, reviewerFiles, mirrorYml };
2796
+ return { config: config2, reviewerFiles, mirrorYml };
2441
2797
  }
2442
2798
  function writeBootstrapFiles(repoRoot, plan) {
2443
2799
  ensureDir(stampConfigDir(repoRoot));
2444
2800
  ensureDir(stampReviewersDir(repoRoot));
2445
2801
  for (const { path: path2, content } of plan.reviewerFiles.values()) {
2446
2802
  const full = join3(repoRoot, path2);
2447
- ensureDir(dirname2(full));
2448
- writeFileSync4(full, content);
2803
+ ensureDir(dirname3(full));
2804
+ writeFileSync5(full, content);
2449
2805
  }
2450
2806
  if (plan.mirrorYml !== void 0) {
2451
- writeFileSync4(join3(repoRoot, ".stamp/mirror.yml"), plan.mirrorYml);
2807
+ writeFileSync5(join3(repoRoot, ".stamp/mirror.yml"), plan.mirrorYml);
2452
2808
  }
2453
- writeFileSync4(stampConfigFile(repoRoot), stringifyConfig(plan.newConfig));
2809
+ writeFileSync5(stampConfigFile(repoRoot), stringifyConfig(plan.newConfig));
2454
2810
  }
2455
2811
  function printPlan(plan, opts) {
2456
2812
  const bar = "\u2500".repeat(72);
@@ -2491,11 +2847,12 @@ function branchExists(name, cwd) {
2491
2847
  }
2492
2848
 
2493
2849
  // src/commands/init.ts
2494
- import { existsSync as existsSync6, writeFileSync as writeFileSync5 } from "fs";
2495
- import { join as join4 } from "path";
2850
+ import { existsSync as existsSync9, writeFileSync as writeFileSync7 } from "fs";
2851
+ import { join as join5 } from "path";
2496
2852
 
2497
2853
  // src/lib/ghRuleset.ts
2498
2854
  import { spawnSync as spawnSync3 } from "child_process";
2855
+ var STAMP_MIRROR_DEPLOY_KEY_TITLE = "stamp-mirror";
2499
2856
  function checkGhAvailable() {
2500
2857
  const v = spawnSync3("gh", ["--version"], {
2501
2858
  stdio: ["ignore", "pipe", "pipe"],
@@ -2639,340 +2996,709 @@ function applyStampRuleset(owner, repo, actor) {
2639
2996
  return { status: "created" };
2640
2997
  }
2641
2998
  }
2642
-
2643
- // src/commands/init.ts
2644
- function runInit(opts = {}) {
2645
- const repoRoot = findRepoRoot();
2646
- const configDir = stampConfigDir(repoRoot);
2647
- const configFile = stampConfigFile(repoRoot);
2648
- const reviewersDir = stampReviewersDir(repoRoot);
2649
- const trustedKeysDir = stampTrustedKeysDir(repoRoot);
2650
- const stateDbPath = stampStateDbPath(repoRoot);
2651
- const remoteName = opts.remote ?? "origin";
2652
- const remoteClass = classifyRemote(remoteName, repoRoot);
2653
- const { effectiveMode, warnings } = resolveMode(opts.mode, remoteClass);
2654
- if (opts.mode === "server-gated" && remoteClass.shape === "forge-direct") {
2655
- throw new Error(
2656
- `--mode server-gated requires origin to be a stamp server, but ${describeShape(remoteClass)}.
2657
-
2658
- For server-gated enforcement, the recommended one-command path is:
2659
- stamp provision <name> --org <github-org>
2660
- (needs ~/.stamp/server.yml with your stamp server's host + port, or --server <host>:<port>).
2661
- That command handles the bare-repo creation, clone, bootstrap merge, GitHub mirror, and Ruleset.
2662
-
2663
- 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
- );
2665
- }
2666
- const alreadyHasConfig = existsSync6(configFile);
2667
- ensureDir(configDir);
2668
- ensureDir(reviewersDir);
2669
- ensureDir(trustedKeysDir);
2670
- if (!alreadyHasConfig) {
2671
- if (opts.minimal) {
2672
- writeFileSync5(configFile, stringifyConfig(MINIMAL_CONFIG));
2673
- writeFileSync5(join4(reviewersDir, "example.md"), EXAMPLE_REVIEWER_PROMPT);
2674
- } else {
2675
- writeFileSync5(configFile, stringifyConfig(DEFAULT_CONFIG));
2676
- writeFileSync5(
2677
- join4(reviewersDir, "security.md"),
2678
- DEFAULT_SECURITY_PROMPT
2679
- );
2680
- writeFileSync5(
2681
- join4(reviewersDir, "standards.md"),
2682
- DEFAULT_STANDARDS_PROMPT
2683
- );
2684
- writeFileSync5(
2685
- join4(reviewersDir, "product.md"),
2686
- DEFAULT_PRODUCT_PROMPT
2687
- );
2688
- }
2689
- }
2690
- const { keypair, created: keyCreated } = ensureUserKeypair();
2691
- const pubKeyPath = join4(
2692
- trustedKeysDir,
2693
- publicKeyFingerprintFilename(keypair.fingerprint)
2999
+ function findDeployKey(owner, repo, title) {
3000
+ const r = spawnSync3(
3001
+ "gh",
3002
+ [
3003
+ "api",
3004
+ `/repos/${owner}/${repo}/keys`,
3005
+ "--jq",
3006
+ // JSON.stringify produces a valid jq string literal (double-quoted,
3007
+ // with backslash/quote escapes), so a title containing quotes or
3008
+ // backslashes can't break the jq filter or smuggle a different
3009
+ // selector.
3010
+ `[.[] | select(.title == ${JSON.stringify(title)})][0].id // empty`
3011
+ ],
3012
+ { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }
2694
3013
  );
2695
- const keyDeposited = !existsSync6(pubKeyPath);
2696
- if (keyDeposited) {
2697
- writeFileSync5(pubKeyPath, keypair.publicKeyPem);
3014
+ if (r.status !== 0) return null;
3015
+ const trimmed = r.stdout.trim();
3016
+ if (!trimmed) return null;
3017
+ const id = Number(trimmed);
3018
+ return Number.isFinite(id) ? id : null;
3019
+ }
3020
+ function registerDeployKey(owner, repo, title, publicKey) {
3021
+ const ctx = `${owner}/${repo} (deploy key "${title}")`;
3022
+ const existing = findDeployKey(owner, repo, title);
3023
+ if (existing !== null) {
3024
+ return { status: "exists", keyId: existing };
2698
3025
  }
2699
- const dbExisted = existsSync6(stateDbPath);
2700
- const db = openDb(stateDbPath);
2701
- db.close();
2702
- const agentsMdAction = opts.agentsMd === false ? "skipped" : ensureAgentsMd(repoRoot, effectiveMode);
2703
- const claudeMdAction = opts.claudeMd === false ? "skipped" : ensureClaudeMd(repoRoot);
2704
- const scaffoldOrSync = alreadyHasConfig ? "sync" : "scaffold";
2705
- console.log(
2706
- scaffoldOrSync === "scaffold" ? `stamp initialized (scaffolded fresh repo${opts.minimal ? " \u2014 minimal mode, single placeholder reviewer" : " with three starter reviewers"}).
2707
- ` : "stamp initialized (synced to existing .stamp/ config).\n"
2708
- );
2709
- console.log(` repo root: ${repoRoot}`);
2710
- console.log(` mode: ${effectiveMode}${opts.mode ? "" : " (auto-detected)"}`);
2711
- console.log(` remote: ${describeShape(remoteClass)}`);
2712
- console.log(
2713
- ` config: ${configFile}${alreadyHasConfig ? " (existing)" : " (created)"}`
2714
- );
2715
- console.log(` trust store: ${trustedKeysDir}/`);
2716
- console.log(
2717
- ` state db: ${stateDbPath}${dbExisted ? " (existing)" : " (created)"}`
2718
- );
2719
- console.log(
2720
- ` your key: ${keypair.fingerprint} ${keyCreated ? "(generated)" : "(existing)"}`
3026
+ const body = { title, key: publicKey, read_only: false };
3027
+ const r = spawnSync3(
3028
+ "gh",
3029
+ [
3030
+ "api",
3031
+ "-X",
3032
+ "POST",
3033
+ `/repos/${owner}/${repo}/keys`,
3034
+ "--input",
3035
+ "-"
3036
+ ],
3037
+ {
3038
+ input: JSON.stringify(body),
3039
+ stdio: ["pipe", "pipe", "pipe"],
3040
+ encoding: "utf8"
3041
+ }
2721
3042
  );
2722
- if (agentsMdAction !== "unchanged" && agentsMdAction !== "skipped") {
2723
- console.log(
2724
- ` AGENTS.md: ${agentsMdAction} at repo root (${effectiveMode} guidance)`
2725
- );
3043
+ if (r.status !== 0) {
3044
+ const stderr = (r.stderr ?? "").trim();
3045
+ const stdout = (r.stdout ?? "").trim();
3046
+ const detail = stderr || stdout || `gh api exited ${r.status}`;
3047
+ return {
3048
+ status: "failed",
3049
+ error: `${ctx}: ${detail}`
3050
+ };
2726
3051
  }
2727
- if (claudeMdAction !== "unchanged" && claudeMdAction !== "skipped") {
2728
- console.log(
2729
- ` CLAUDE.md: ${claudeMdAction} at repo root (auto-loaded by Claude Code)`
2730
- );
3052
+ let parsed;
3053
+ try {
3054
+ parsed = JSON.parse(r.stdout);
3055
+ } catch (err) {
3056
+ return {
3057
+ status: "failed",
3058
+ error: `${ctx}: registered, but couldn't parse keyId from gh response (${err instanceof Error ? err.message : String(err)}). A keyId is required to wire the key into a Ruleset bypass actor; inspect with \`gh api /repos/${owner}/${repo}/keys\`.`
3059
+ };
2731
3060
  }
2732
- console.log();
2733
- if (opts.bootstrapCommit !== false) {
2734
- printBootstrapCommitResult(runBootstrapCommit(repoRoot, scaffoldOrSync));
3061
+ const keyId = typeof parsed.id === "number" ? parsed.id : NaN;
3062
+ if (!Number.isFinite(keyId)) {
3063
+ return {
3064
+ status: "failed",
3065
+ error: `${ctx}: registered, but gh response did not include a numeric keyId. A keyId is required to wire the key into a Ruleset bypass actor; inspect with \`gh api /repos/${owner}/${repo}/keys\`.`
3066
+ };
2735
3067
  }
2736
- const ghProtectOpt = opts.ghProtect !== false;
2737
- if (ghProtectOpt && remoteClass.shape === "forge-direct" && remoteClass.forge === "github.com" && remoteClass.url) {
2738
- applyGitHubRulesetWithReporting(remoteClass.url);
2739
- }
2740
- for (const warning of warnings) {
2741
- console.error(warning);
2742
- console.error();
2743
- }
2744
- if (scaffoldOrSync === "scaffold") {
2745
- if (effectiveMode === "local-only") {
2746
- console.log(
2747
- "Local-only mode \u2014 your stamp config is committed but NOT enforced server-side."
2748
- );
2749
- console.log(
2750
- "Direct `git push origin main` will succeed. To enforce, deploy a stamp"
2751
- );
2752
- console.log(
2753
- "server (see docs/quickstart-server.md) and re-init with --mode server-gated."
2754
- );
2755
- console.log();
2756
- }
2757
- console.log("Next steps:");
2758
- if (opts.minimal) {
2759
- console.log(
2760
- " 1. Replace .stamp/reviewers/example.md with your own reviewer prompt."
2761
- );
2762
- console.log(" 2. Or add more reviewers: `stamp reviewers add <name>`.");
2763
- } else {
2764
- console.log(
2765
- " 1. Read the scaffolded prompts in .stamp/reviewers/ \u2014 they're"
2766
- );
2767
- console.log(
2768
- " starting points calibrated for generic TS/JS projects; customize"
2769
- );
2770
- console.log(" for your codebase. See docs/personas.md for guidance.");
2771
- console.log(
2772
- " 2. Optionally add `required_checks` to .stamp/config.yml (e.g."
2773
- );
2774
- console.log(` \`npm run build\`, \`npm run typecheck\`).`);
2775
- }
2776
- console.log(" 3. Commit the .stamp/ directory.");
2777
- console.log(
2778
- " 4. Share your public key (in .stamp/trusted-keys/) with any other"
2779
- );
2780
- console.log(" machines that will push to this repo.");
2781
- } else if (keyDeposited) {
2782
- console.log(
2783
- `Your public key was deposited at ${pubKeyPath}.`
2784
- );
2785
- console.log(
2786
- `Commit + push it so the remote will accept merges signed by this machine.`
2787
- );
2788
- } else {
2789
- console.log(
2790
- "Your key is already in .stamp/trusted-keys/. You're ready to review + merge."
2791
- );
2792
- }
2793
- console.log();
2794
- console.log(
2795
- "Privacy: every `stamp review` ships the diff to Anthropic via the Claude"
2796
- );
2797
- console.log(
2798
- `Agent SDK. See README "Data flow / privacy" for what's sent and how to`
3068
+ return { status: "created", keyId };
3069
+ }
3070
+ function fetchDeployKeyPublic(owner, repo, keyId) {
3071
+ const r = spawnSync3(
3072
+ "gh",
3073
+ ["api", `/repos/${owner}/${repo}/keys/${keyId}`, "--jq", ".key"],
3074
+ { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }
2799
3075
  );
2800
- console.log(
2801
- "opt out of the per-repo notice (STAMP_SUPPRESS_LLM_NOTICE=1)."
3076
+ if (r.status !== 0) return null;
3077
+ const trimmed = r.stdout.trim();
3078
+ return trimmed.length > 0 ? trimmed : null;
3079
+ }
3080
+ function deleteDeployKey(owner, repo, keyId) {
3081
+ const r = spawnSync3(
3082
+ "gh",
3083
+ ["api", "-X", "DELETE", `/repos/${owner}/${repo}/keys/${keyId}`],
3084
+ { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }
2802
3085
  );
2803
- if (agentsMdAction !== "skipped" || claudeMdAction !== "skipped") {
2804
- console.log();
2805
- const bar = "\u2500".repeat(72);
2806
- console.log(bar);
2807
- console.log("note: AI agents \u2014 read AGENTS.md at the repo root before any git command.");
2808
- console.log();
2809
- console.log("This repo is stamp-protected. Direct `git commit` to main and direct");
2810
- console.log("`git push origin main` are violations of the process, even when the");
2811
- console.log("remote accepts them. The required flow is:");
2812
- console.log();
2813
- console.log(" git checkout -b feature; <edit/commit on feature>;");
2814
- console.log(" stamp review --diff main..feature; stamp merge feature --into main;");
2815
- console.log(" git push origin main (or `stamp push main` if origin is a stamp server).");
2816
- console.log();
2817
- console.log("Full reference: AGENTS.md (and CLAUDE.md) at the repo root.");
2818
- console.log(bar);
3086
+ if (r.status === 0) return { status: "deleted" };
3087
+ const stderr = (r.stderr ?? "").trim();
3088
+ if (stderr.includes("HTTP 404") || /Not Found/i.test(stderr)) {
3089
+ return { status: "deleted" };
2819
3090
  }
3091
+ return {
3092
+ status: "failed",
3093
+ error: `${owner}/${repo} keyId=${keyId}: ${stderr || `gh api exited ${r.status}`}`
3094
+ };
2820
3095
  }
2821
- function runBootstrapCommit(repoRoot, scaffoldOrSync) {
2822
- if (scaffoldOrSync === "sync" || isPathTracked(".stamp/config.yml", repoRoot)) {
2823
- return { kind: "skipped-already-tracked" };
2824
- }
2825
- 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");
2828
- runGit(["add", ...toAdd], repoRoot);
2829
- let hasStagedChanges = false;
3096
+ function getRulesetBypassActors(owner, repo, rulesetId) {
3097
+ const r = spawnSync3(
3098
+ "gh",
3099
+ [
3100
+ "api",
3101
+ `/repos/${owner}/${repo}/rulesets/${rulesetId}`,
3102
+ "--jq",
3103
+ ".bypass_actors"
3104
+ ],
3105
+ { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }
3106
+ );
3107
+ if (r.status !== 0) return null;
2830
3108
  try {
2831
- runGit(["diff", "--cached", "--quiet"], repoRoot);
3109
+ const parsed = JSON.parse(r.stdout);
3110
+ if (!Array.isArray(parsed)) return null;
3111
+ return parsed.filter((e) => {
3112
+ if (typeof e !== "object" || e === null) return false;
3113
+ const o = e;
3114
+ return (typeof o["actor_id"] === "number" || o["actor_id"] === null) && typeof o["actor_type"] === "string" && typeof o["bypass_mode"] === "string";
3115
+ });
2832
3116
  } catch {
2833
- hasStagedChanges = true;
3117
+ return null;
2834
3118
  }
2835
- if (!hasStagedChanges) return { kind: "skipped-no-changes" };
2836
- runGit(
3119
+ }
3120
+ function replaceBypassActors(owner, repo, rulesetId, desiredActors) {
3121
+ const current = getRulesetBypassActors(owner, repo, rulesetId);
3122
+ if (current === null) {
3123
+ return {
3124
+ status: "failed",
3125
+ error: `${owner}/${repo} ruleset ${rulesetId}: could not read current bypass_actors`
3126
+ };
3127
+ }
3128
+ if (bypassActorListsEqual(current, desiredActors)) {
3129
+ return { status: "unchanged" };
3130
+ }
3131
+ const body = { bypass_actors: desiredActors };
3132
+ const r = spawnSync3(
3133
+ "gh",
2837
3134
  [
2838
- "commit",
2839
- "-m",
2840
- "stamp: bootstrap config (one-time exception \u2014 every later commit goes through stamp review/merge)"
3135
+ "api",
3136
+ "-X",
3137
+ "PUT",
3138
+ `/repos/${owner}/${repo}/rulesets/${rulesetId}`,
3139
+ "--input",
3140
+ "-"
2841
3141
  ],
2842
- repoRoot
3142
+ {
3143
+ input: JSON.stringify(body),
3144
+ stdio: ["pipe", "pipe", "pipe"],
3145
+ encoding: "utf8"
3146
+ }
2843
3147
  );
2844
- try {
2845
- runGit(["remote", "get-url", "origin"], repoRoot);
2846
- } catch {
2847
- return { kind: "did-commit" };
3148
+ if (r.status !== 0) {
3149
+ const stderr = (r.stderr ?? "").trim();
3150
+ const stdout = (r.stdout ?? "").trim();
3151
+ return {
3152
+ status: "failed",
3153
+ error: `${owner}/${repo} ruleset ${rulesetId}: ${stderr || stdout || `gh api exited ${r.status}`}`
3154
+ };
3155
+ }
3156
+ return { status: "updated" };
3157
+ }
3158
+ function bypassActorListsEqual(a, b) {
3159
+ if (a.length !== b.length) return false;
3160
+ const aSet = new Set(a.map(normalizeBypassActor));
3161
+ return b.every((e) => aSet.has(normalizeBypassActor(e)));
3162
+ }
3163
+ function normalizeBypassActor(e) {
3164
+ const id = e.actor_type === "OrganizationAdmin" && (e.actor_id === null || e.actor_id === 1) ? "ORGADMIN" : String(e.actor_id);
3165
+ return `${e.actor_type}:${id}:${e.bypass_mode}`;
3166
+ }
3167
+ function computeDesiredBypassActors(current, deployKeyId, flags) {
3168
+ const out = [];
3169
+ for (const a of current) {
3170
+ if (a.actor_type === "OrganizationAdmin" && flags.removeOrgadmin) {
3171
+ continue;
3172
+ }
3173
+ if (a.actor_type === "DeployKey") {
3174
+ continue;
3175
+ }
3176
+ out.push(a);
2848
3177
  }
3178
+ out.push({
3179
+ actor_id: deployKeyId,
3180
+ actor_type: "DeployKey",
3181
+ bypass_mode: "always"
3182
+ });
3183
+ return out;
3184
+ }
3185
+
3186
+ // src/lib/oteamConfig.ts
3187
+ import { existsSync as existsSync7, readFileSync as readFileSync6, renameSync as renameSync2, writeFileSync as writeFileSync6 } from "fs";
3188
+ import { homedir } from "os";
3189
+ import { join as join4 } from "path";
3190
+ var OTEAM_CONFIG_PATH = join4(homedir(), ".open-team", "config.json");
3191
+ function readOteamConfig(configPath = OTEAM_CONFIG_PATH) {
3192
+ if (!existsSync7(configPath)) return null;
2849
3193
  try {
2850
- const branch = runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot).trim();
2851
- runGit(["push", "origin", branch], repoRoot);
2852
- return { kind: "did-commit-and-push" };
3194
+ const parsed = JSON.parse(readFileSync6(configPath, "utf8"));
3195
+ if (Array.isArray(parsed)) {
3196
+ throw new Error("config must be a JSON object, not an array");
3197
+ }
3198
+ return parsed;
2853
3199
  } catch (err) {
2854
- return {
2855
- kind: "push-failed",
2856
- error: err instanceof Error ? err.message : String(err)
2857
- };
3200
+ throw new Error(
3201
+ `${configPath}: ${err instanceof Error ? err.message : String(err)}`
3202
+ );
2858
3203
  }
2859
3204
  }
2860
- function printBootstrapCommitResult(result) {
2861
- switch (result.kind) {
2862
- case "did-commit-and-push":
2863
- console.log(
2864
- "Bootstrap commit: created and pushed to origin. Every commit from now on goes through stamp review/merge."
2865
- );
2866
- break;
2867
- case "did-commit":
2868
- console.log(
2869
- "Bootstrap commit: created locally (no `origin` remote configured). Push when you've added one."
2870
- );
2871
- break;
2872
- case "push-failed":
2873
- console.log(
2874
- "warning: bootstrap commit created locally but `git push origin` failed."
2875
- );
2876
- console.log(` underlying error: ${result.error}`);
2877
- console.log(
2878
- " Resolve auth/network/branch-protection and run `git push origin` manually."
3205
+ function patchStampHost(host, configPath = OTEAM_CONFIG_PATH) {
3206
+ let config2 = {};
3207
+ if (existsSync7(configPath)) {
3208
+ try {
3209
+ const parsed = JSON.parse(readFileSync6(configPath, "utf8"));
3210
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
3211
+ config2 = parsed;
3212
+ }
3213
+ } catch (err) {
3214
+ throw new Error(
3215
+ `${configPath}: ${err instanceof Error ? err.message : String(err)}`
2879
3216
  );
2880
- break;
2881
- case "skipped-no-changes":
2882
- break;
2883
- case "skipped-already-tracked":
2884
- break;
3217
+ }
3218
+ }
3219
+ const existing = config2.stamp;
3220
+ const stamp = typeof existing === "object" && existing !== null ? { ...existing } : {};
3221
+ stamp["host"] = host;
3222
+ config2["stamp"] = stamp;
3223
+ const tmp = `${configPath}.tmp`;
3224
+ try {
3225
+ writeFileSync6(tmp, JSON.stringify(config2, null, 2) + "\n", "utf8");
3226
+ renameSync2(tmp, configPath);
3227
+ } catch (err) {
3228
+ throw new Error(
3229
+ `${configPath}: ${err instanceof Error ? err.message : String(err)}`
3230
+ );
2885
3231
  }
2886
3232
  }
2887
- function applyGitHubRulesetWithReporting(remoteUrl) {
2888
- const parsed = parseGithubOriginUrl(remoteUrl);
2889
- if (!parsed) {
2890
- return;
3233
+
3234
+ // src/lib/serverConfig.ts
3235
+ import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
3236
+ import { parse as parseYaml4 } from "yaml";
3237
+ var DEFAULT_USER = "git";
3238
+ var DEFAULT_REPO_ROOT = "/srv/git";
3239
+ var USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
3240
+ var HOST_RE = /^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/;
3241
+ var REPO_ROOT_RE = /^(\/[A-Za-z0-9_-][A-Za-z0-9._-]*)+\/?$/;
3242
+ function describeShape2(field) {
3243
+ switch (field) {
3244
+ case "user":
3245
+ return "alphanumerics + . _ -, must not start with -";
3246
+ case "host":
3247
+ return "hostname-shaped (alphanumerics + . -, must start and end with alphanumeric)";
3248
+ case "repo_root_prefix":
3249
+ return "absolute path with alphanumeric/. _ - segments, no .. components";
2891
3250
  }
2892
- const ghCheck = checkGhAvailable();
2893
- if (!ghCheck.available) {
2894
- console.log(
2895
- `note: GitHub Ruleset auto-apply skipped \u2014 ${ghCheck.reason}.`
2896
- );
2897
- console.log(
2898
- ` For manual setup, see docs/github-ruleset-setup.md.`
3251
+ }
3252
+ function validateField(field, value, contextPath) {
3253
+ const re = field === "user" ? USER_RE : field === "host" ? HOST_RE : REPO_ROOT_RE;
3254
+ if (!re.test(value)) {
3255
+ throw new Error(
3256
+ `${contextPath}: '${field}' has an invalid shape (got ${JSON.stringify(value)}). Allowed: ${describeShape2(field)}.`
2899
3257
  );
2900
- console.log();
2901
- return;
2902
3258
  }
2903
- const user = lookupAuthenticatedUserId();
2904
- if (!user) {
2905
- console.log(
2906
- `note: GitHub Ruleset auto-apply skipped \u2014 couldn't look up the gh-authenticated user.`
2907
- );
2908
- console.log(
2909
- ` Try \`gh auth status\` to confirm authentication, then re-run \`stamp init\`.`
3259
+ }
3260
+ function loadServerConfig() {
3261
+ const path2 = userServerConfigPath();
3262
+ if (!existsSync8(path2)) return null;
3263
+ let raw;
3264
+ try {
3265
+ raw = readFileSync7(path2, "utf8");
3266
+ } catch (err) {
3267
+ throw new Error(
3268
+ `failed to read ${path2}: ${err instanceof Error ? err.message : String(err)}`
2910
3269
  );
2911
- console.log();
2912
- return;
2913
3270
  }
2914
- const ownerType = lookupRepoOwnerType(parsed.owner, parsed.repo);
2915
- if (ownerType === null) {
3271
+ return parseServerConfig(raw, path2);
3272
+ }
3273
+ function parseServerConfig(raw, contextPath = "<inline>") {
3274
+ const parsed = parseYaml4(raw);
3275
+ if (!parsed || typeof parsed !== "object") {
3276
+ throw new Error(`${contextPath}: must be a YAML mapping with at least 'host' and 'port'`);
3277
+ }
3278
+ const obj = parsed;
3279
+ if (typeof obj.host !== "string" || !obj.host.trim()) {
3280
+ throw new Error(`${contextPath}: 'host' is required and must be a non-empty string`);
3281
+ }
3282
+ if (typeof obj.port !== "number" || !Number.isInteger(obj.port) || obj.port < 1 || obj.port > 65535) {
3283
+ throw new Error(`${contextPath}: 'port' is required and must be an integer 1..65535`);
3284
+ }
3285
+ const host = obj.host.trim();
3286
+ validateField("host", host, contextPath);
3287
+ const user = typeof obj.user === "string" && obj.user.trim() ? obj.user.trim() : DEFAULT_USER;
3288
+ validateField("user", user, contextPath);
3289
+ const repoRootPrefix = typeof obj.repo_root_prefix === "string" && obj.repo_root_prefix.trim() ? obj.repo_root_prefix.trim() : DEFAULT_REPO_ROOT;
3290
+ validateField("repo_root_prefix", repoRootPrefix, contextPath);
3291
+ return {
3292
+ host,
3293
+ port: obj.port,
3294
+ user,
3295
+ repoRootPrefix
3296
+ };
3297
+ }
3298
+ function parseServerFlag(value, context = "--server") {
3299
+ const m = value.trim().match(/^([^:]+):(\d+)$/);
3300
+ if (!m) {
3301
+ throw new Error(
3302
+ `${context} must be in the form <host>:<port> (got "${value}")`
3303
+ );
3304
+ }
3305
+ const port = Number(m[2]);
3306
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
3307
+ throw new Error(
3308
+ `${context}: port must be an integer 1..65535 (got "${m[2]}")`
3309
+ );
3310
+ }
3311
+ const host = m[1];
3312
+ validateField("host", host, context);
3313
+ return {
3314
+ host,
3315
+ port,
3316
+ user: DEFAULT_USER,
3317
+ repoRootPrefix: DEFAULT_REPO_ROOT
3318
+ };
3319
+ }
3320
+ function bareRepoSshUrl(cfg, repoName) {
3321
+ return `ssh://${cfg.user}@${cfg.host}:${cfg.port}${cfg.repoRootPrefix}/${repoName}.git`;
3322
+ }
3323
+
3324
+ // src/commands/init.ts
3325
+ function runInit(opts = {}) {
3326
+ const repoRoot = findRepoRoot();
3327
+ const configDir = stampConfigDir(repoRoot);
3328
+ const configFile = stampConfigFile(repoRoot);
3329
+ const reviewersDir = stampReviewersDir(repoRoot);
3330
+ const trustedKeysDir = stampTrustedKeysDir(repoRoot);
3331
+ const stateDbPath = stampStateDbPath(repoRoot);
3332
+ const remoteName = opts.remote ?? "origin";
3333
+ const remoteClass = classifyRemote(remoteName, repoRoot);
3334
+ const { effectiveMode, warnings } = resolveMode(opts.mode, remoteClass);
3335
+ if (opts.mode === "server-gated" && remoteClass.shape === "forge-direct") {
3336
+ throw new Error(
3337
+ `--mode server-gated requires origin to be a stamp server, but ${describeShape(remoteClass)}.
3338
+
3339
+ For server-gated enforcement, the recommended one-command path is:
3340
+ stamp provision <name> --org <github-org>
3341
+ (needs ~/.stamp/server.yml with your stamp server's host + port, or --server <host>:<port>).
3342
+ That command handles the bare-repo creation, clone, bootstrap merge, GitHub mirror, and Ruleset.
3343
+
3344
+ 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).`
3345
+ );
3346
+ }
3347
+ const alreadyHasConfig = existsSync9(configFile);
3348
+ ensureDir(configDir);
3349
+ ensureDir(reviewersDir);
3350
+ ensureDir(trustedKeysDir);
3351
+ if (!alreadyHasConfig) {
3352
+ if (opts.minimal) {
3353
+ writeFileSync7(configFile, stringifyConfig(MINIMAL_CONFIG));
3354
+ writeFileSync7(join5(reviewersDir, "example.md"), EXAMPLE_REVIEWER_PROMPT);
3355
+ } else {
3356
+ writeFileSync7(configFile, stringifyConfig(DEFAULT_CONFIG));
3357
+ writeFileSync7(
3358
+ join5(reviewersDir, "security.md"),
3359
+ DEFAULT_SECURITY_PROMPT
3360
+ );
3361
+ writeFileSync7(
3362
+ join5(reviewersDir, "standards.md"),
3363
+ DEFAULT_STANDARDS_PROMPT
3364
+ );
3365
+ writeFileSync7(
3366
+ join5(reviewersDir, "product.md"),
3367
+ DEFAULT_PRODUCT_PROMPT
3368
+ );
3369
+ }
3370
+ }
3371
+ const { keypair, created: keyCreated } = ensureUserKeypair();
3372
+ const userCfg = loadOrCreateUserConfig();
3373
+ const pubKeyPath = join5(
3374
+ trustedKeysDir,
3375
+ publicKeyFingerprintFilename(keypair.fingerprint)
3376
+ );
3377
+ const keyDeposited = !existsSync9(pubKeyPath);
3378
+ if (keyDeposited) {
3379
+ writeFileSync7(pubKeyPath, keypair.publicKeyPem);
3380
+ }
3381
+ const dbExisted = existsSync9(stateDbPath);
3382
+ const db = openDb(stateDbPath);
3383
+ db.close();
3384
+ const agentsMdAction = opts.agentsMd === false ? "skipped" : ensureAgentsMd(repoRoot, effectiveMode);
3385
+ const claudeMdAction = opts.claudeMd === false ? "skipped" : ensureClaudeMd(repoRoot);
3386
+ const scaffoldOrSync = alreadyHasConfig ? "sync" : "scaffold";
3387
+ console.log(
3388
+ scaffoldOrSync === "scaffold" ? `stamp initialized (scaffolded fresh repo${opts.minimal ? " \u2014 minimal mode, single placeholder reviewer" : " with three starter reviewers"}).
3389
+ ` : "stamp initialized (synced to existing .stamp/ config).\n"
3390
+ );
3391
+ console.log(` repo root: ${repoRoot}`);
3392
+ console.log(` mode: ${effectiveMode}${opts.mode ? "" : " (auto-detected)"}`);
3393
+ console.log(` remote: ${describeShape(remoteClass)}`);
3394
+ console.log(
3395
+ ` config: ${configFile}${alreadyHasConfig ? " (existing)" : " (created)"}`
3396
+ );
3397
+ console.log(` trust store: ${trustedKeysDir}/`);
3398
+ console.log(
3399
+ ` state db: ${stateDbPath}${dbExisted ? " (existing)" : " (created)"}`
3400
+ );
3401
+ console.log(
3402
+ ` your key: ${keypair.fingerprint} ${keyCreated ? "(generated)" : "(existing)"}`
3403
+ );
3404
+ if (agentsMdAction !== "unchanged" && agentsMdAction !== "skipped") {
2916
3405
  console.log(
2917
- `note: GitHub Ruleset auto-apply skipped \u2014 couldn't determine whether ${parsed.owner}/${parsed.repo} is a personal or org repo.`
3406
+ ` AGENTS.md: ${agentsMdAction} at repo root (${effectiveMode} guidance)`
2918
3407
  );
2919
- console.log(` For manual setup, see docs/github-ruleset-setup.md.`);
2920
- console.log();
2921
- return;
2922
3408
  }
2923
- const actor = ownerType === "Organization" ? { type: "OrganizationAdmin", id: 1 } : { type: "User", id: user.id };
2924
- const actorDescription = actor.type === "OrganizationAdmin" ? "any org admin (your gh-authed user must be one to push as bypass)" : `${user.login}, id ${user.id}`;
2925
- const result = applyStampRuleset(parsed.owner, parsed.repo, actor);
2926
- switch (result.status) {
2927
- case "created":
3409
+ if (claudeMdAction !== "unchanged" && claudeMdAction !== "skipped") {
3410
+ console.log(
3411
+ ` CLAUDE.md: ${claudeMdAction} at repo root (auto-loaded by Claude Code)`
3412
+ );
3413
+ }
3414
+ console.log(
3415
+ ` models: ${userCfg.path}${userCfg.created ? " (created \u2014 Sonnet defaults; tweak with `stamp config reviewers set <name> <model-id>`)" : " (existing)"}`
3416
+ );
3417
+ console.log();
3418
+ if (opts.bootstrapCommit !== false) {
3419
+ printBootstrapCommitResult(runBootstrapCommit(repoRoot, scaffoldOrSync));
3420
+ }
3421
+ if (opts.oteam !== false) {
3422
+ maybeOfferOteamHostFill();
3423
+ }
3424
+ const ghProtectOpt = opts.ghProtect !== false;
3425
+ if (ghProtectOpt && remoteClass.shape === "forge-direct" && remoteClass.forge === "github.com" && remoteClass.url) {
3426
+ applyGitHubRulesetWithReporting(remoteClass.url);
3427
+ }
3428
+ for (const warning of warnings) {
3429
+ console.error(warning);
3430
+ console.error();
3431
+ }
3432
+ if (scaffoldOrSync === "scaffold") {
3433
+ if (effectiveMode === "local-only") {
2928
3434
  console.log(
2929
- `GitHub Ruleset: created stamp-mirror-only on ${parsed.owner}/${parsed.repo} (bypass actor: ${actorDescription}).`
3435
+ "Local-only mode \u2014 your stamp config is committed but NOT enforced server-side."
2930
3436
  );
2931
3437
  console.log(
2932
- ` Direct \`git push origin main\` from any other identity will now be rejected by GitHub.`
3438
+ "Direct `git push origin main` will succeed. To enforce, deploy a stamp"
2933
3439
  );
2934
- console.log();
2935
- break;
2936
- case "exists":
2937
3440
  console.log(
2938
- `GitHub Ruleset: stamp-mirror-only already present on ${parsed.owner}/${parsed.repo} (id ${result.rulesetId}). Not modified.`
3441
+ "server (see docs/quickstart-server.md) and re-init with --mode server-gated."
2939
3442
  );
2940
3443
  console.log();
2941
- break;
2942
- case "failed":
3444
+ }
3445
+ console.log("Next steps:");
3446
+ if (opts.minimal) {
2943
3447
  console.log(
2944
- `warning: GitHub Ruleset auto-apply failed: ${result.error}`
3448
+ " 1. Replace .stamp/reviewers/example.md with your own reviewer prompt."
3449
+ );
3450
+ console.log(" 2. Or add more reviewers: `stamp reviewers add <name>`.");
3451
+ } else {
3452
+ console.log(
3453
+ " 1. Read the scaffolded prompts in .stamp/reviewers/ \u2014 they're"
2945
3454
  );
2946
3455
  console.log(
2947
- ` For manual setup, see docs/github-ruleset-setup.md.`
3456
+ " starting points calibrated for generic TS/JS projects; customize"
2948
3457
  );
2949
- console.log();
2950
- break;
3458
+ console.log(" for your codebase. See docs/personas.md for guidance.");
3459
+ console.log(
3460
+ " 2. Optionally add `required_checks` to .stamp/config.yml (e.g."
3461
+ );
3462
+ console.log(` \`npm run build\`, \`npm run typecheck\`).`);
3463
+ }
3464
+ console.log(" 3. Commit the .stamp/ directory.");
3465
+ console.log(
3466
+ " 4. Share your public key (in .stamp/trusted-keys/) with any other"
3467
+ );
3468
+ console.log(" machines that will push to this repo.");
3469
+ } else if (keyDeposited) {
3470
+ console.log(
3471
+ `Your public key was deposited at ${pubKeyPath}.`
3472
+ );
3473
+ console.log(
3474
+ `Commit + push it so the remote will accept merges signed by this machine.`
3475
+ );
3476
+ } else {
3477
+ console.log(
3478
+ "Your key is already in .stamp/trusted-keys/. You're ready to review + merge."
3479
+ );
3480
+ }
3481
+ console.log();
3482
+ console.log(
3483
+ "Privacy: every `stamp review` ships the diff to Anthropic via the Claude"
3484
+ );
3485
+ console.log(
3486
+ `Agent SDK. See README "Data flow / privacy" for what's sent and how to`
3487
+ );
3488
+ console.log(
3489
+ "opt out of the per-repo notice (STAMP_SUPPRESS_LLM_NOTICE=1)."
3490
+ );
3491
+ if (agentsMdAction !== "skipped" || claudeMdAction !== "skipped") {
3492
+ console.log();
3493
+ const bar = "\u2500".repeat(72);
3494
+ console.log(bar);
3495
+ console.log("note: AI agents \u2014 read AGENTS.md at the repo root before any git command.");
3496
+ console.log();
3497
+ console.log("This repo is stamp-protected. Direct `git commit` to main and direct");
3498
+ console.log("`git push origin main` are violations of the process, even when the");
3499
+ console.log("remote accepts them. The required flow is:");
3500
+ console.log();
3501
+ console.log(" git checkout -b feature; <edit/commit on feature>;");
3502
+ console.log(" stamp review --diff main..feature; stamp merge feature --into main;");
3503
+ console.log(" git push origin main (or `stamp push main` if origin is a stamp server).");
3504
+ console.log();
3505
+ console.log("Full reference: AGENTS.md (and CLAUDE.md) at the repo root.");
3506
+ console.log(bar);
2951
3507
  }
2952
3508
  }
2953
- function resolveMode(userMode, remoteClass) {
2954
- const warnings = [];
2955
- if (userMode === "local-only") {
2956
- return { effectiveMode: "local-only", warnings };
3509
+ function runBootstrapCommit(repoRoot, scaffoldOrSync) {
3510
+ if (scaffoldOrSync === "sync" || isPathTracked(".stamp/config.yml", repoRoot)) {
3511
+ return { kind: "skipped-already-tracked" };
2957
3512
  }
2958
- if (userMode === "server-gated") {
2959
- return { effectiveMode: "server-gated", warnings };
3513
+ const toAdd = [".stamp"];
3514
+ if (existsSync9(join5(repoRoot, "AGENTS.md"))) toAdd.push("AGENTS.md");
3515
+ if (existsSync9(join5(repoRoot, "CLAUDE.md"))) toAdd.push("CLAUDE.md");
3516
+ runGit(["add", ...toAdd], repoRoot);
3517
+ let hasStagedChanges = false;
3518
+ try {
3519
+ runGit(["diff", "--cached", "--quiet"], repoRoot);
3520
+ } catch {
3521
+ hasStagedChanges = true;
2960
3522
  }
2961
- switch (remoteClass.shape) {
2962
- case "stamp-server":
2963
- return { effectiveMode: "server-gated", warnings };
2964
- case "forge-direct":
2965
- warnings.push(
2966
- `warning: ${describeShape(remoteClass)}.
2967
- Defaulting to --mode local-only because there's no stamp server in this picture.
2968
- The committed .stamp/ config will NOT be enforced \u2014 direct \`git push origin main\`
2969
- will succeed against this remote.
2970
- To enforce: deploy a stamp server (docs/quickstart-server.md) and re-run with
2971
- --mode server-gated. To silence this warning: pass --mode local-only explicitly.`
2972
- );
2973
- return { effectiveMode: "local-only", warnings };
2974
- case "unset":
2975
- warnings.push(
3523
+ if (!hasStagedChanges) return { kind: "skipped-no-changes" };
3524
+ runGit(
3525
+ [
3526
+ "commit",
3527
+ "-m",
3528
+ "stamp: bootstrap config (one-time exception \u2014 every later commit goes through stamp review/merge)"
3529
+ ],
3530
+ repoRoot
3531
+ );
3532
+ try {
3533
+ runGit(["remote", "get-url", "origin"], repoRoot);
3534
+ } catch {
3535
+ return { kind: "did-commit" };
3536
+ }
3537
+ try {
3538
+ const branch = runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot).trim();
3539
+ runGit(["push", "origin", branch], repoRoot);
3540
+ return { kind: "did-commit-and-push" };
3541
+ } catch (err) {
3542
+ return {
3543
+ kind: "push-failed",
3544
+ error: err instanceof Error ? err.message : String(err)
3545
+ };
3546
+ }
3547
+ }
3548
+ function printBootstrapCommitResult(result) {
3549
+ switch (result.kind) {
3550
+ case "did-commit-and-push":
3551
+ console.log(
3552
+ "Bootstrap commit: created and pushed to origin. Every commit from now on goes through stamp review/merge."
3553
+ );
3554
+ break;
3555
+ case "did-commit":
3556
+ console.log(
3557
+ "Bootstrap commit: created locally (no `origin` remote configured). Push when you've added one."
3558
+ );
3559
+ break;
3560
+ case "push-failed":
3561
+ console.log(
3562
+ "warning: bootstrap commit created locally but `git push origin` failed."
3563
+ );
3564
+ console.log(` underlying error: ${result.error}`);
3565
+ console.log(
3566
+ " Resolve auth/network/branch-protection and run `git push origin` manually."
3567
+ );
3568
+ break;
3569
+ case "skipped-no-changes":
3570
+ break;
3571
+ case "skipped-already-tracked":
3572
+ break;
3573
+ }
3574
+ }
3575
+ function applyGitHubRulesetWithReporting(remoteUrl) {
3576
+ const parsed = parseGithubOriginUrl(remoteUrl);
3577
+ if (!parsed) {
3578
+ return;
3579
+ }
3580
+ const ghCheck = checkGhAvailable();
3581
+ if (!ghCheck.available) {
3582
+ console.log(
3583
+ `note: GitHub Ruleset auto-apply skipped \u2014 ${ghCheck.reason}.`
3584
+ );
3585
+ console.log(
3586
+ ` For manual setup, see docs/github-ruleset-setup.md.`
3587
+ );
3588
+ console.log();
3589
+ return;
3590
+ }
3591
+ const user = lookupAuthenticatedUserId();
3592
+ if (!user) {
3593
+ console.log(
3594
+ `note: GitHub Ruleset auto-apply skipped \u2014 couldn't look up the gh-authenticated user.`
3595
+ );
3596
+ console.log(
3597
+ ` Try \`gh auth status\` to confirm authentication, then re-run \`stamp init\`.`
3598
+ );
3599
+ console.log();
3600
+ return;
3601
+ }
3602
+ const ownerType = lookupRepoOwnerType(parsed.owner, parsed.repo);
3603
+ if (ownerType === null) {
3604
+ console.log(
3605
+ `note: GitHub Ruleset auto-apply skipped \u2014 couldn't determine whether ${parsed.owner}/${parsed.repo} is a personal or org repo.`
3606
+ );
3607
+ console.log(` For manual setup, see docs/github-ruleset-setup.md.`);
3608
+ console.log();
3609
+ return;
3610
+ }
3611
+ const actor = ownerType === "Organization" ? { type: "OrganizationAdmin", id: 1 } : { type: "User", id: user.id };
3612
+ const actorDescription = actor.type === "OrganizationAdmin" ? "any org admin (your gh-authed user must be one to push as bypass)" : `${user.login}, id ${user.id}`;
3613
+ const result = applyStampRuleset(parsed.owner, parsed.repo, actor);
3614
+ switch (result.status) {
3615
+ case "created":
3616
+ console.log(
3617
+ `GitHub Ruleset: created stamp-mirror-only on ${parsed.owner}/${parsed.repo} (bypass actor: ${actorDescription}).`
3618
+ );
3619
+ console.log(
3620
+ ` Direct \`git push origin main\` from any other identity will now be rejected by GitHub.`
3621
+ );
3622
+ console.log();
3623
+ break;
3624
+ case "exists":
3625
+ console.log(
3626
+ `GitHub Ruleset: stamp-mirror-only already present on ${parsed.owner}/${parsed.repo} (id ${result.rulesetId}). Not modified.`
3627
+ );
3628
+ console.log();
3629
+ break;
3630
+ case "failed":
3631
+ console.log(
3632
+ `warning: GitHub Ruleset auto-apply failed: ${result.error}`
3633
+ );
3634
+ console.log(
3635
+ ` For manual setup, see docs/github-ruleset-setup.md.`
3636
+ );
3637
+ console.log();
3638
+ break;
3639
+ }
3640
+ }
3641
+ function maybeOfferOteamHostFill() {
3642
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return;
3643
+ let oteamCfg;
3644
+ try {
3645
+ oteamCfg = readOteamConfig();
3646
+ } catch {
3647
+ return;
3648
+ }
3649
+ if (oteamCfg === null) return;
3650
+ const cfg = oteamCfg;
3651
+ const stamp = cfg.stamp;
3652
+ if (stamp?.host) return;
3653
+ let serverCfg;
3654
+ try {
3655
+ serverCfg = loadServerConfig();
3656
+ } catch {
3657
+ return;
3658
+ }
3659
+ if (!serverCfg) return;
3660
+ const host = serverCfg.host;
3661
+ process.stdout.write(
3662
+ `Set oteam's \`stamp.host\` to "${host}"? [y/N] `
3663
+ );
3664
+ const answer = readLineSync().trim().toLowerCase();
3665
+ if (answer !== "y" && answer !== "yes") return;
3666
+ try {
3667
+ patchStampHost(host);
3668
+ console.log(
3669
+ `oteam config: stamp.host set to "${host}" in ~/.open-team/config.json`
3670
+ );
3671
+ console.log();
3672
+ } catch (err) {
3673
+ console.log(
3674
+ `warning: could not patch ~/.open-team/config.json: ${err instanceof Error ? err.message : String(err)}`
3675
+ );
3676
+ console.log();
3677
+ }
3678
+ }
3679
+ function resolveMode(userMode, remoteClass) {
3680
+ const warnings = [];
3681
+ if (userMode === "local-only") {
3682
+ return { effectiveMode: "local-only", warnings };
3683
+ }
3684
+ if (userMode === "server-gated") {
3685
+ return { effectiveMode: "server-gated", warnings };
3686
+ }
3687
+ switch (remoteClass.shape) {
3688
+ case "stamp-server":
3689
+ return { effectiveMode: "server-gated", warnings };
3690
+ case "forge-direct":
3691
+ warnings.push(
3692
+ `warning: ${describeShape(remoteClass)}.
3693
+ Defaulting to --mode local-only because there's no stamp server in this picture.
3694
+ The committed .stamp/ config will NOT be enforced \u2014 direct \`git push origin main\`
3695
+ will succeed against this remote.
3696
+ To enforce: deploy a stamp server (docs/quickstart-server.md) and re-run with
3697
+ --mode server-gated. To silence this warning: pass --mode local-only explicitly.`
3698
+ );
3699
+ return { effectiveMode: "local-only", warnings };
3700
+ case "unset":
3701
+ warnings.push(
2976
3702
  `note: ${describeShape(remoteClass)}.
2977
3703
  Defaulting to --mode local-only because no remote means no detectable
2978
3704
  server-side enforcement. If you're about to point this at a stamp server,
@@ -2992,104 +3718,401 @@ function resolveMode(userMode, remoteClass) {
2992
3718
  }
2993
3719
 
2994
3720
  // src/commands/provision.ts
2995
- import { spawnSync as spawnSync4 } from "child_process";
2996
- import { existsSync as existsSync8, mkdtempSync, rmSync, writeFileSync as writeFileSync6 } from "fs";
3721
+ import { spawnSync as spawnSync6 } from "child_process";
3722
+ import { existsSync as existsSync11, mkdtempSync, readFileSync as readFileSync8, rmSync, writeFileSync as writeFileSync9 } from "fs";
2997
3723
  import { tmpdir } from "os";
2998
- import { join as join5, resolve as resolvePath } from "path";
3724
+ import { join as join6, resolve as resolvePath } from "path";
3725
+ import { parse as parseYaml5 } from "yaml";
2999
3726
 
3000
- // src/lib/serverConfig.ts
3001
- import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
3002
- import { parse as parseYaml3 } from "yaml";
3003
- var DEFAULT_USER = "git";
3004
- var DEFAULT_REPO_ROOT = "/srv/git";
3005
- var USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
3006
- var HOST_RE = /^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/;
3007
- var REPO_ROOT_RE = /^(\/[A-Za-z0-9_-][A-Za-z0-9._-]*)+\/?$/;
3008
- function describeShape2(field) {
3009
- switch (field) {
3010
- case "user":
3011
- return "alphanumerics + . _ -, must not start with -";
3012
- case "host":
3013
- return "hostname-shaped (alphanumerics + . -, must start and end with alphanumeric)";
3014
- case "repo_root_prefix":
3015
- return "absolute path with alphanumeric/. _ - segments, no .. components";
3727
+ // src/commands/server.ts
3728
+ import { spawnSync as spawnSync5 } from "child_process";
3729
+ import { existsSync as existsSync10, mkdirSync as mkdirSync4, renameSync as renameSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync8 } from "fs";
3730
+ import { dirname as dirname4 } from "path";
3731
+ import { stringify as stringifyYaml2 } from "yaml";
3732
+
3733
+ // src/lib/perRepoKey.ts
3734
+ var VALID_OWNER = /^[A-Za-z0-9-]+$/;
3735
+ var VALID_REPO = /^[A-Za-z0-9._-]+$/;
3736
+ var SSH_CLIENT_KEY_DIR = "/srv/git/.ssh-client-keys";
3737
+ function computePerRepoKeyPath(githubRepo) {
3738
+ if (typeof githubRepo !== "string" || githubRepo.length === 0) {
3739
+ throw new Error("computePerRepoKeyPath: githubRepo must be a non-empty string");
3740
+ }
3741
+ if (githubRepo.startsWith("-")) {
3742
+ throw new Error(
3743
+ `computePerRepoKeyPath: githubRepo must not start with '-': ${githubRepo}`
3744
+ );
3745
+ }
3746
+ if (githubRepo.includes("..")) {
3747
+ throw new Error(
3748
+ `computePerRepoKeyPath: githubRepo must not contain '..': ${githubRepo}`
3749
+ );
3750
+ }
3751
+ const slashCount = (githubRepo.match(/\//g) ?? []).length;
3752
+ if (slashCount !== 1) {
3753
+ throw new Error(
3754
+ `computePerRepoKeyPath: githubRepo must be exactly <owner>/<repo>: ${githubRepo}`
3755
+ );
3756
+ }
3757
+ const [owner, repo] = githubRepo.split("/");
3758
+ if (!owner || !repo) {
3759
+ throw new Error(
3760
+ `computePerRepoKeyPath: owner and repo halves must both be non-empty: ${githubRepo}`
3761
+ );
3762
+ }
3763
+ if (!VALID_OWNER.test(owner)) {
3764
+ throw new Error(
3765
+ `computePerRepoKeyPath: owner must match [A-Za-z0-9-]+ (got "${owner}" in "${githubRepo}")`
3766
+ );
3016
3767
  }
3768
+ if (!VALID_REPO.test(repo)) {
3769
+ throw new Error(
3770
+ `computePerRepoKeyPath: repo must match [A-Za-z0-9._-]+ (got "${repo}" in "${githubRepo}")`
3771
+ );
3772
+ }
3773
+ return `${SSH_CLIENT_KEY_DIR}/${owner}_${repo}_ed25519`;
3017
3774
  }
3018
- function validateField(field, value, contextPath) {
3019
- const re = field === "user" ? USER_RE : field === "host" ? HOST_RE : REPO_ROOT_RE;
3020
- if (!re.test(value)) {
3775
+
3776
+ // src/commands/serverRepo.ts
3777
+ import { spawnSync as spawnSync4 } from "child_process";
3778
+ import { createInterface } from "readline";
3779
+ async function runServerRepoDelete(opts) {
3780
+ const name = normalizeRepoName(opts.name);
3781
+ if (opts.alsoGithub !== void 0) validateGithubRepoSpec(opts.alsoGithub);
3782
+ const server2 = resolveServer(opts.server);
3783
+ const action = opts.purge ? "PURGE (irreversible)" : "soft-delete (recoverable via restore)";
3784
+ console.log(`About to ${action} bare repo: ${name}`);
3785
+ console.log(`On server: ${server2.user}@${server2.host}:${server2.port}`);
3786
+ if (opts.alsoGithub) {
3787
+ console.log(
3788
+ `Also: gh repo delete ${opts.alsoGithub} (PERMANENT, no GitHub-side undo)`
3789
+ );
3790
+ }
3791
+ console.log();
3792
+ if (!opts.yes) {
3793
+ const expected = opts.purge ? `purge ${name}` : `delete ${name}`;
3794
+ const got = await prompt(`Type "${expected}" to confirm: `);
3795
+ if (got.trim() !== expected) {
3796
+ console.log("note: aborted");
3797
+ return;
3798
+ }
3799
+ }
3800
+ const args = ["delete-stamp-repo", name];
3801
+ if (opts.purge) args.push("--purge");
3802
+ const result = spawnSync4(
3803
+ "ssh",
3804
+ ["-p", String(server2.port), "--", `${server2.user}@${server2.host}`, ...args],
3805
+ { stdio: ["ignore", "inherit", "inherit"] }
3806
+ );
3807
+ if (result.status !== 0) {
3021
3808
  throw new Error(
3022
- `${contextPath}: '${field}' has an invalid shape (got ${JSON.stringify(value)}). Allowed: ${describeShape2(field)}.`
3809
+ `server-side delete failed (exit ${result.status}). The bare repo on the stamp server was NOT touched. If you see "command not found", the server image is older than 0.7.3 \u2014 redeploy it first.`
3023
3810
  );
3024
3811
  }
3812
+ if (!opts.purge) {
3813
+ console.log();
3814
+ console.log(`Recovery:`);
3815
+ console.log(` stamp server-repos restore ${name} # bring it back`);
3816
+ console.log(` stamp server-repos delete ${name} --purge # nuke for real`);
3817
+ }
3818
+ if (opts.alsoGithub) {
3819
+ if (!opts.yes) {
3820
+ const expected = `delete github ${opts.alsoGithub}`;
3821
+ const got = await prompt(
3822
+ `Server-side done. To ALSO delete the GitHub mirror, type "${expected}" (or anything else to skip): `
3823
+ );
3824
+ if (got.trim() !== expected) {
3825
+ console.log(
3826
+ `note: skipped GitHub delete; mirror at https://github.com/${opts.alsoGithub} is intact`
3827
+ );
3828
+ return;
3829
+ }
3830
+ }
3831
+ const ghResult = spawnSync4(
3832
+ "gh",
3833
+ ["repo", "delete", opts.alsoGithub, "--yes"],
3834
+ { stdio: ["ignore", "inherit", "inherit"] }
3835
+ );
3836
+ if (ghResult.status !== 0) {
3837
+ throw new Error(
3838
+ `GitHub repo delete failed (exit ${ghResult.status}). Server-side delete already succeeded; the GitHub mirror is still present at https://github.com/${opts.alsoGithub}.`
3839
+ );
3840
+ }
3841
+ }
3025
3842
  }
3026
- function loadServerConfig() {
3843
+ async function runServerRepoRestore(opts) {
3844
+ const name = normalizeRepoName(opts.name);
3845
+ const asName = opts.asName !== void 0 ? normalizeRepoName(opts.asName) : void 0;
3846
+ if (opts.from !== void 0) validateTrashEntryName(opts.from);
3847
+ const server2 = resolveServer(opts.server);
3848
+ const args = ["restore-stamp-repo", name];
3849
+ if (opts.from) {
3850
+ args.push("--from", opts.from);
3851
+ }
3852
+ if (asName) {
3853
+ args.push("--as", asName);
3854
+ }
3855
+ const result = spawnSync4(
3856
+ "ssh",
3857
+ ["-p", String(server2.port), "--", `${server2.user}@${server2.host}`, ...args],
3858
+ { stdio: ["ignore", "inherit", "inherit"] }
3859
+ );
3860
+ if (result.status !== 0) {
3861
+ throw new Error(
3862
+ `server-side restore failed (exit ${result.status}). Run \`stamp server-repos list --trash\` to see what's available.`
3863
+ );
3864
+ }
3865
+ }
3866
+ function runServerRepoList(opts) {
3867
+ const server2 = resolveServer(opts.server);
3868
+ if (opts.trash) {
3869
+ const result2 = spawnSync4(
3870
+ "ssh",
3871
+ ["-p", String(server2.port), "--", `${server2.user}@${server2.host}`, "list-trash"],
3872
+ { stdio: ["ignore", "inherit", "inherit"] }
3873
+ );
3874
+ if (result2.status !== 0) {
3875
+ throw new Error(
3876
+ `list --trash failed (exit ${result2.status}). If you see "command not found", the server image is older than 0.7.3 \u2014 redeploy it first.`
3877
+ );
3878
+ }
3879
+ return;
3880
+ }
3881
+ const result = spawnSync4(
3882
+ "ssh",
3883
+ [
3884
+ "-p",
3885
+ String(server2.port),
3886
+ "--",
3887
+ `${server2.user}@${server2.host}`,
3888
+ "ls",
3889
+ "-1",
3890
+ "/srv/git/"
3891
+ ],
3892
+ { stdio: ["ignore", "pipe", "inherit"], encoding: "utf8" }
3893
+ );
3894
+ if (result.status !== 0) {
3895
+ throw new Error(`list failed (exit ${result.status}).`);
3896
+ }
3897
+ const entries = filterLiveBareRepoNames(result.stdout);
3898
+ if (entries.length === 0) {
3899
+ console.log("(no live bare repos)");
3900
+ return;
3901
+ }
3902
+ for (const e of entries) console.log(e);
3903
+ }
3904
+ function resolveServer(serverFlag) {
3905
+ const server2 = serverFlag ? parseServerFlag(serverFlag) : loadServerConfig();
3906
+ if (!server2) {
3907
+ throw new Error(
3908
+ `no stamp server configured. Either:
3909
+ - create ~/.stamp/server.yml with at least:
3910
+ host: <ssh-host>
3911
+ port: <ssh-port>
3912
+ - or pass --server <host>:<port> on the command line.`
3913
+ );
3914
+ }
3915
+ return server2;
3916
+ }
3917
+ var UsageError = class extends Error {
3918
+ constructor(message) {
3919
+ super(message);
3920
+ this.name = "UsageError";
3921
+ }
3922
+ };
3923
+ function normalizeRepoName(name) {
3924
+ const canonical = name.endsWith(".git") ? name.slice(0, -4) : name;
3925
+ validateRepoName(canonical);
3926
+ return canonical;
3927
+ }
3928
+ function filterLiveBareRepoNames(rawOutput) {
3929
+ return rawOutput.split("\n").map((s) => s.trim()).filter(Boolean).filter((s) => s.endsWith(".git")).map((s) => s.slice(0, -4)).filter((s) => s.length > 0);
3930
+ }
3931
+ function validateRepoName(name) {
3932
+ if (!/^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(name) || name.includes("..")) {
3933
+ throw new UsageError(
3934
+ `repo name must start with [A-Za-z0-9_], match [A-Za-z0-9._-]+, and not contain '..' (got "${name}")`
3935
+ );
3936
+ }
3937
+ }
3938
+ function validateTrashEntryName(entry) {
3939
+ if (!/^[0-9]{8}T[0-9]{6}Z-[A-Za-z0-9_][A-Za-z0-9._-]*\.git$/.test(entry)) {
3940
+ throw new UsageError(
3941
+ `--from must match <YYYYMMDDTHHMMSSZ>-<name>.git (got "${entry}"). Run \`stamp server-repos list --trash\` to see valid entry names.`
3942
+ );
3943
+ }
3944
+ }
3945
+ function validateGithubRepoSpec(spec) {
3946
+ if (!/^[A-Za-z0-9_][A-Za-z0-9-]*\/[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(spec)) {
3947
+ throw new UsageError(
3948
+ `--also-github must be <owner>/<repo> with no leading '-' on either segment (got "${spec}")`
3949
+ );
3950
+ }
3951
+ }
3952
+ function prompt(question) {
3953
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3954
+ return new Promise((resolve2) => {
3955
+ rl.question(question, (answer) => {
3956
+ rl.close();
3957
+ resolve2(answer);
3958
+ });
3959
+ });
3960
+ }
3961
+
3962
+ // src/commands/server.ts
3963
+ function formatServerConfigYaml(opts) {
3964
+ const body = {
3965
+ host: opts.host,
3966
+ port: opts.port
3967
+ };
3968
+ if (opts.user && opts.user.trim()) body.user = opts.user.trim();
3969
+ if (opts.repoRootPrefix && opts.repoRootPrefix.trim()) {
3970
+ body.repo_root_prefix = opts.repoRootPrefix.trim();
3971
+ }
3972
+ return stringifyYaml2(body);
3973
+ }
3974
+ function runServerConfig(opts) {
3975
+ const modes = [opts.hostPort, opts.show, opts.unset].filter(Boolean).length;
3976
+ if (modes !== 1) {
3977
+ throw new UsageError(
3978
+ "stamp server config: provide exactly one of <host:port>, --show, or --unset"
3979
+ );
3980
+ }
3981
+ if ((opts.show || opts.unset) && (opts.user || opts.repoRootPrefix)) {
3982
+ throw new UsageError(
3983
+ "stamp server config: --user and --repo-root-prefix only apply when writing (they conflict with --show / --unset)"
3984
+ );
3985
+ }
3986
+ if (opts.show) return showConfig();
3987
+ if (opts.unset) return unsetConfig();
3988
+ return writeConfig(opts);
3989
+ }
3990
+ function showConfig() {
3027
3991
  const path2 = userServerConfigPath();
3028
- if (!existsSync7(path2)) return null;
3029
- let raw;
3030
- try {
3031
- raw = readFileSync5(path2, "utf8");
3032
- } catch (err) {
3992
+ if (!existsSync10(path2)) {
3993
+ console.log(`note: no stamp server configured (${path2} does not exist)`);
3994
+ console.log(`note: run \`stamp server config <host:port>\` to create one`);
3995
+ return;
3996
+ }
3997
+ const cfg = loadServerConfig();
3998
+ if (!cfg) {
3999
+ console.log(`note: no stamp server configured`);
4000
+ return;
4001
+ }
4002
+ console.log(`config: ${path2}`);
4003
+ console.log(`host: ${cfg.host}`);
4004
+ console.log(`port: ${cfg.port}`);
4005
+ console.log(`user: ${cfg.user}`);
4006
+ console.log(`repo_root_prefix: ${cfg.repoRootPrefix}`);
4007
+ }
4008
+ function unsetConfig() {
4009
+ const path2 = userServerConfigPath();
4010
+ if (!existsSync10(path2)) {
4011
+ console.log(`note: ${path2} does not exist; nothing to remove`);
4012
+ return;
4013
+ }
4014
+ unlinkSync2(path2);
4015
+ console.log(`removed ${path2}`);
4016
+ }
4017
+ function fetchServerPubkey(server2, mirror) {
4018
+ const sshArgs = [
4019
+ "-p",
4020
+ String(server2.port),
4021
+ "--",
4022
+ `${server2.user}@${server2.host}`,
4023
+ "stamp-server-pubkey"
4024
+ ];
4025
+ if (mirror) {
4026
+ sshArgs.push(`${mirror.owner}/${mirror.repo}`);
4027
+ }
4028
+ const result = spawnSync5("ssh", sshArgs, {
4029
+ stdio: ["ignore", "pipe", "inherit"],
4030
+ encoding: "utf8"
4031
+ });
4032
+ if (result.status !== 0) {
4033
+ const target = mirror ? ` for ${mirror.owner}/${mirror.repo}` : "";
3033
4034
  throw new Error(
3034
- `failed to read ${path2}: ${err instanceof Error ? err.message : String(err)}`
4035
+ `stamp server pubkey${target} failed (exit ${result.status}) against ${server2.user}@${server2.host}:${server2.port}. If you see "command not found", the server image predates the deploy-key feature \u2014 redeploy it first.`
3035
4036
  );
3036
4037
  }
3037
- return parseServerConfig(raw, path2);
4038
+ return result.stdout.trim();
3038
4039
  }
3039
- function parseServerConfig(raw, contextPath = "<inline>") {
3040
- const parsed = parseYaml3(raw);
3041
- if (!parsed || typeof parsed !== "object") {
3042
- throw new Error(`${contextPath}: must be a YAML mapping with at least 'host' and 'port'`);
4040
+ function runServerPubkey(opts) {
4041
+ const server2 = resolveServer(opts.server);
4042
+ let mirror;
4043
+ if (opts.repo) {
4044
+ try {
4045
+ computePerRepoKeyPath(opts.repo);
4046
+ } catch (err) {
4047
+ throw new UsageError(
4048
+ `--repo ${err instanceof Error ? err.message.replace(/^computePerRepoKeyPath:\s*/, "") : String(err)}`
4049
+ );
4050
+ }
4051
+ const slashIdx = opts.repo.indexOf("/");
4052
+ mirror = {
4053
+ owner: opts.repo.slice(0, slashIdx),
4054
+ repo: opts.repo.slice(slashIdx + 1)
4055
+ };
3043
4056
  }
3044
- const obj = parsed;
3045
- if (typeof obj.host !== "string" || !obj.host.trim()) {
3046
- throw new Error(`${contextPath}: 'host' is required and must be a non-empty string`);
4057
+ const pubkey = fetchServerPubkey(server2, mirror);
4058
+ process.stdout.write(`${pubkey}
4059
+ `);
4060
+ }
4061
+ function writeConfig(opts) {
4062
+ let parsed;
4063
+ try {
4064
+ parsed = parseServerFlag(opts.hostPort, "stamp server config: <host:port>");
4065
+ } catch (err) {
4066
+ throw new UsageError(err instanceof Error ? err.message : String(err));
3047
4067
  }
3048
- if (typeof obj.port !== "number" || !Number.isInteger(obj.port) || obj.port < 1 || obj.port > 65535) {
3049
- throw new Error(`${contextPath}: 'port' is required and must be an integer 1..65535`);
4068
+ const yaml = formatServerConfigYaml({
4069
+ host: parsed.host,
4070
+ port: parsed.port,
4071
+ user: opts.user,
4072
+ repoRootPrefix: opts.repoRootPrefix
4073
+ });
4074
+ const path2 = userServerConfigPath();
4075
+ const dir = dirname4(path2);
4076
+ if (!existsSync10(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
4077
+ const tmp = `${path2}.tmp.${process.pid}`;
4078
+ writeFileSync8(tmp, yaml, { mode: 384 });
4079
+ renameSync3(tmp, path2);
4080
+ console.log(`wrote ${path2}`);
4081
+ console.log(`host: ${parsed.host}`);
4082
+ console.log(`port: ${parsed.port}`);
4083
+ if (opts.user && opts.user.trim()) {
4084
+ console.log(`user: ${opts.user.trim()}`);
4085
+ }
4086
+ if (opts.repoRootPrefix && opts.repoRootPrefix.trim()) {
4087
+ console.log(`repo_root_prefix: ${opts.repoRootPrefix.trim()}`);
3050
4088
  }
3051
- const host = obj.host.trim();
3052
- validateField("host", host, contextPath);
3053
- const user = typeof obj.user === "string" && obj.user.trim() ? obj.user.trim() : DEFAULT_USER;
3054
- validateField("user", user, contextPath);
3055
- const repoRootPrefix = typeof obj.repo_root_prefix === "string" && obj.repo_root_prefix.trim() ? obj.repo_root_prefix.trim() : DEFAULT_REPO_ROOT;
3056
- validateField("repo_root_prefix", repoRootPrefix, contextPath);
3057
- return {
3058
- host,
3059
- port: obj.port,
3060
- user,
3061
- repoRootPrefix
3062
- };
3063
4089
  }
3064
- function parseServerFlag(value, context = "--server") {
3065
- const m = value.trim().match(/^([^:]+):(\d+)$/);
3066
- if (!m) {
4090
+
4091
+ // src/commands/provision.ts
4092
+ async function runProvision(opts) {
4093
+ if (opts.migrateExisting && opts.migrateBypass) {
3067
4094
  throw new Error(
3068
- `${context} must be in the form <host>:<port> (got "${value}")`
4095
+ `--migrate-existing and --migrate-bypass are mutually exclusive: the first moves a forge-direct repo to server-gated topology, the second changes the bypass-actor shape on an already-server-gated repo.`
3069
4096
  );
3070
4097
  }
3071
- const port = Number(m[2]);
3072
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
4098
+ if (opts.removeOrgadmin && !opts.migrateBypass) {
3073
4099
  throw new Error(
3074
- `${context}: port must be an integer 1..65535 (got "${m[2]}")`
4100
+ `--remove-orgadmin is only meaningful with --migrate-bypass`
3075
4101
  );
3076
4102
  }
3077
- const host = m[1];
3078
- validateField("host", host, context);
3079
- return {
3080
- host,
3081
- port,
3082
- user: DEFAULT_USER,
3083
- repoRootPrefix: DEFAULT_REPO_ROOT
3084
- };
3085
- }
3086
- function bareRepoSshUrl(cfg, repoName) {
3087
- return `ssh://${cfg.user}@${cfg.host}:${cfg.port}${cfg.repoRootPrefix}/${repoName}.git`;
3088
- }
3089
-
3090
- // src/commands/provision.ts
3091
- async function runProvision(opts) {
3092
- validateRepoName(opts.name);
4103
+ if (!opts.migrateBypass) {
4104
+ if (!opts.name) {
4105
+ throw new Error(
4106
+ `stamp provision requires a <name> argument (the bare repo name on the stamp server). If you meant to migrate an already-server-gated repo's Ruleset bypass instead, pass --migrate-bypass (identifies the target via cwd's .stamp/mirror.yml; no <name> needed).`
4107
+ );
4108
+ }
4109
+ validateRepoName2(opts.name);
4110
+ } else if (opts.name) {
4111
+ console.log(
4112
+ `note: <name> argument ignored under --migrate-bypass; the target is identified by .stamp/mirror.yml in the cwd.`
4113
+ );
4114
+ console.log();
4115
+ }
3093
4116
  if (opts.org !== void 0) validateOrgName(opts.org);
3094
4117
  const server2 = opts.server ? parseServerFlag(opts.server) : loadServerConfig();
3095
4118
  if (!server2) {
@@ -3103,12 +4126,16 @@ async function runProvision(opts) {
3103
4126
  See docs/quickstart-server.md for how to deploy a stamp server first.`
3104
4127
  );
3105
4128
  }
4129
+ if (opts.migrateBypass) {
4130
+ await runMigrateBypass(opts, server2);
4131
+ return;
4132
+ }
3106
4133
  if (opts.migrateExisting) {
3107
4134
  await runMigrateExisting(opts, server2);
3108
4135
  return;
3109
4136
  }
3110
4137
  const cloneTarget = resolvePath(opts.into ?? opts.name);
3111
- if (existsSync8(cloneTarget)) {
4138
+ if (existsSync11(cloneTarget)) {
3112
4139
  throw new Error(
3113
4140
  `clone destination already exists: ${cloneTarget}. Move or remove it, or pass --into <other-path>.`
3114
4141
  );
@@ -3136,11 +4163,11 @@ Provisioning bare repo on ${server2.host}:${server2.port}`);
3136
4163
  process.chdir(cloneTarget);
3137
4164
  await runBootstrap({});
3138
4165
  if (mirrorRepo && !opts.noRuleset) {
3139
- applyMirrorRuleset(mirrorRepo);
4166
+ applyMirrorRuleset(mirrorRepo, server2);
3140
4167
  }
3141
4168
  printSuccess({ cloneTarget, server: server2, repoName: opts.name, mirrorRepo });
3142
4169
  }
3143
- function validateRepoName(name) {
4170
+ function validateRepoName2(name) {
3144
4171
  if (!/^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(name)) {
3145
4172
  throw new Error(
3146
4173
  `repo name must start with [A-Za-z0-9_] and match [A-Za-z0-9._-]+ (got "${name}")`
@@ -3173,13 +4200,19 @@ function printPlan2(args) {
3173
4200
  }
3174
4201
  if (args.opts.org && !args.opts.noMirror && !args.opts.noRuleset) {
3175
4202
  console.log(fmt("GitHub Ruleset", "apply stamp-mirror-only on the mirror repo"));
4203
+ console.log(
4204
+ fmt(
4205
+ "bypass actor",
4206
+ `org repo \u2192 stamp-server deploy key "${STAMP_MIRROR_DEPLOY_KEY_TITLE}" (auto-registered or reused); personal repo \u2192 your gh-authed user`
4207
+ )
4208
+ );
3176
4209
  } else {
3177
4210
  console.log(fmt("GitHub Ruleset", "skipped"));
3178
4211
  }
3179
4212
  console.log(bar);
3180
4213
  }
3181
4214
  function provisionBareRepoOnServer(server2, name) {
3182
- const result = spawnSync4(
4215
+ const result = spawnSync6(
3183
4216
  "ssh",
3184
4217
  [
3185
4218
  "-p",
@@ -3205,7 +4238,7 @@ function createGithubMirrorRepo(owner, repo, privateRepo) {
3205
4238
  );
3206
4239
  }
3207
4240
  const visibility = privateRepo ? "--private" : "--public";
3208
- const result = spawnSync4(
4241
+ const result = spawnSync6(
3209
4242
  "gh",
3210
4243
  ["repo", "create", `${owner}/${repo}`, visibility],
3211
4244
  { stdio: ["ignore", "inherit", "inherit"] }
@@ -3228,28 +4261,67 @@ function writeMirrorYml(cloneTarget, mirror) {
3228
4261
  # - "v*"
3229
4262
  `;
3230
4263
  const path2 = `${cloneTarget}/.stamp/mirror.yml`;
3231
- writeFileSync6(path2, yml);
4264
+ writeFileSync9(path2, yml);
3232
4265
  console.log(`Wrote mirror.yml \u2192 .stamp/mirror.yml (${mirror.owner}/${mirror.repo})`);
3233
4266
  }
3234
- function applyMirrorRuleset(mirror) {
3235
- const user = lookupAuthenticatedUserId();
3236
- if (!user) {
4267
+ function applyMirrorRuleset(mirror, server2) {
4268
+ const existing = findExistingStampRuleset(mirror.owner, mirror.repo);
4269
+ if (existing !== null) {
3237
4270
  console.log(
3238
- `note: GitHub Ruleset auto-apply skipped \u2014 couldn't look up the gh-authenticated user.`
4271
+ `GitHub Ruleset: stamp-mirror-only already present on ${mirror.owner}/${mirror.repo}. Not modified.`
3239
4272
  );
3240
- console.log(` Try \`gh auth status\` and re-apply manually via docs/github-ruleset-setup.md.`);
3241
4273
  return;
3242
4274
  }
3243
4275
  const ownerType = lookupRepoOwnerType(mirror.owner, mirror.repo);
3244
4276
  if (ownerType === null) {
3245
4277
  console.log(
3246
- `note: GitHub Ruleset auto-apply skipped \u2014 couldn't determine whether ${mirror.owner}/${mirror.repo} is a personal or org repo.`
4278
+ `warning: GitHub Ruleset auto-apply skipped \u2014 couldn't determine whether ${mirror.owner}/${mirror.repo} is a personal or org repo.`
3247
4279
  );
3248
- console.log(` For manual setup, see docs/github-ruleset-setup.md.`);
4280
+ console.log(` For manual setup, see docs/github-ruleset-setup.md.`);
3249
4281
  return;
3250
4282
  }
3251
- const actor = ownerType === "Organization" ? { type: "OrganizationAdmin", id: 1 } : { type: "User", id: user.id };
3252
- const actorDescription = actor.type === "OrganizationAdmin" ? "any org admin (your gh-authed user must be one to push as bypass)" : `${user.login}, id ${user.id}`;
4283
+ let actor;
4284
+ let actorDescription;
4285
+ if (ownerType === "Organization") {
4286
+ let pubkey;
4287
+ try {
4288
+ pubkey = fetchServerPubkey(server2, mirror);
4289
+ } catch (err) {
4290
+ console.log(
4291
+ `warning: GitHub Ruleset auto-apply skipped \u2014 couldn't fetch stamp server pubkey: ${err instanceof Error ? err.message : String(err)}`
4292
+ );
4293
+ console.log(` For manual setup, see docs/github-ruleset-setup.md.`);
4294
+ return;
4295
+ }
4296
+ const reg = registerDeployKey(
4297
+ mirror.owner,
4298
+ mirror.repo,
4299
+ STAMP_MIRROR_DEPLOY_KEY_TITLE,
4300
+ pubkey
4301
+ );
4302
+ if (reg.status === "failed") {
4303
+ console.log(`warning: GitHub Ruleset auto-apply skipped \u2014 deploy-key registration failed: ${reg.error}`);
4304
+ console.log(` For manual setup, see docs/github-ruleset-setup.md.`);
4305
+ return;
4306
+ }
4307
+ const verb = reg.status === "created" ? "registered" : "reused";
4308
+ console.log(
4309
+ `Deploy key: ${verb} "${STAMP_MIRROR_DEPLOY_KEY_TITLE}" on ${mirror.owner}/${mirror.repo} (id ${reg.keyId}).`
4310
+ );
4311
+ actor = { type: "DeployKey", id: reg.keyId };
4312
+ actorDescription = `stamp-server deploy key "${STAMP_MIRROR_DEPLOY_KEY_TITLE}", id ${reg.keyId}`;
4313
+ } else {
4314
+ const user = lookupAuthenticatedUserId();
4315
+ if (!user) {
4316
+ console.log(
4317
+ `warning: GitHub Ruleset auto-apply skipped \u2014 couldn't look up the gh-authenticated user.`
4318
+ );
4319
+ console.log(` Try \`gh auth status\` and re-apply manually via docs/github-ruleset-setup.md.`);
4320
+ return;
4321
+ }
4322
+ actor = { type: "User", id: user.id };
4323
+ actorDescription = `${user.login}, id ${user.id}`;
4324
+ }
3253
4325
  const result = applyStampRuleset(mirror.owner, mirror.repo, actor);
3254
4326
  switch (result.status) {
3255
4327
  case "created":
@@ -3313,9 +4385,9 @@ async function runMigrateExisting(opts, server2) {
3313
4385
  console.log("\n(dry run \u2014 no changes made)");
3314
4386
  return;
3315
4387
  }
3316
- const stagingDir = mkdtempSync(join5(tmpdir(), "stamp-migrate-"));
3317
- const bareCloneDir = join5(stagingDir, `${opts.name}.git`);
3318
- const tarballPath = join5(stagingDir, `${opts.name}.tar.gz`);
4388
+ const stagingDir = mkdtempSync(join6(tmpdir(), "stamp-migrate-"));
4389
+ const bareCloneDir = join6(stagingDir, `${opts.name}.git`);
4390
+ const tarballPath = join6(stagingDir, `${opts.name}.tar.gz`);
3319
4391
  try {
3320
4392
  console.log(`
3321
4393
  Building bare-clone tarball of existing repo`);
@@ -3337,7 +4409,7 @@ Building bare-clone tarball of existing repo`);
3337
4409
  writeMirrorYml(repoRoot, mirrorParse);
3338
4410
  }
3339
4411
  if (!opts.noMirror && !opts.noRuleset) {
3340
- applyMirrorRuleset(mirrorParse);
4412
+ applyMirrorRuleset(mirrorParse, server2);
3341
4413
  }
3342
4414
  printMigrateSuccess({ repoRoot, server: server2, repoName: opts.name, mirror: mirrorParse, opts });
3343
4415
  }
@@ -3351,9 +4423,9 @@ function ensureCwdIsGitRepo(cwd) {
3351
4423
  }
3352
4424
  }
3353
4425
  function ensureStampInitDone(cwd) {
3354
- if (!existsSync8(join5(cwd, ".stamp", "config.yml"))) {
4426
+ if (!existsSync11(join6(cwd, ".stamp", "config.yml"))) {
3355
4427
  throw new Error(
3356
- `--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.`
4428
+ `--migrate-existing expects this repo to already be stamp-init'd (${join6(cwd, ".stamp/config.yml")} not found). Run \`stamp init --mode local-only\` first, calibrate your reviewers, then re-run with --migrate-existing.`
3357
4429
  );
3358
4430
  }
3359
4431
  }
@@ -3383,7 +4455,7 @@ function ensureWorkingTreeClean(cwd) {
3383
4455
  }
3384
4456
  }
3385
4457
  function runTarGz(parentDir, dirName, outputPath) {
3386
- const result = spawnSync4("tar", ["-czf", outputPath, "-C", parentDir, dirName], {
4458
+ const result = spawnSync6("tar", ["-czf", outputPath, "-C", parentDir, dirName], {
3387
4459
  stdio: ["ignore", "inherit", "inherit"]
3388
4460
  });
3389
4461
  if (result.status !== 0) {
@@ -3393,7 +4465,7 @@ function runTarGz(parentDir, dirName, outputPath) {
3393
4465
  }
3394
4466
  }
3395
4467
  function scpToServer(server2, localPath, remotePath) {
3396
- const result = spawnSync4(
4468
+ const result = spawnSync6(
3397
4469
  "scp",
3398
4470
  [
3399
4471
  "-P",
@@ -3411,7 +4483,7 @@ function scpToServer(server2, localPath, remotePath) {
3411
4483
  }
3412
4484
  }
3413
4485
  function sshRunNewStampRepoFromTarball(server2, name, remoteTarballPath) {
3414
- const result = spawnSync4(
4486
+ const result = spawnSync6(
3415
4487
  "ssh",
3416
4488
  [
3417
4489
  "-p",
@@ -3477,196 +4549,249 @@ mirror.yml was added to .stamp/. Commit it through the normal stamp flow:`);
3477
4549
  console.log(` stamp push main`);
3478
4550
  }
3479
4551
  }
3480
-
3481
- // src/commands/serverRepo.ts
3482
- import { spawnSync as spawnSync5 } from "child_process";
3483
- import { createInterface } from "readline";
3484
- async function runServerRepoDelete(opts) {
3485
- const name = normalizeRepoName(opts.name);
3486
- if (opts.alsoGithub !== void 0) validateGithubRepoSpec(opts.alsoGithub);
3487
- const server2 = resolveServer(opts.server);
3488
- const action = opts.purge ? "PURGE (irreversible)" : "soft-delete (recoverable via restore)";
3489
- console.log(`About to ${action} bare repo: ${name}`);
3490
- console.log(`On server: ${server2.user}@${server2.host}:${server2.port}`);
3491
- if (opts.alsoGithub) {
3492
- console.log(
3493
- `Also: gh repo delete ${opts.alsoGithub} (PERMANENT, no GitHub-side undo)`
4552
+ function readMirrorYmlGithubRepo(repoRoot) {
4553
+ const path2 = join6(repoRoot, ".stamp", "mirror.yml");
4554
+ if (!existsSync11(path2)) {
4555
+ throw new Error(
4556
+ `${path2} not found \u2014 --migrate-bypass operates on an already-server-gated repo, but this cwd has no .stamp/mirror.yml. If the repo is not yet server-gated, provision it first with \`stamp provision --migrate-existing\`.`
3494
4557
  );
3495
4558
  }
3496
- console.log();
3497
- if (!opts.yes) {
3498
- const expected = opts.purge ? `purge ${name}` : `delete ${name}`;
3499
- const got = await prompt(`Type "${expected}" to confirm: `);
3500
- if (got.trim() !== expected) {
3501
- console.log("note: aborted");
3502
- return;
3503
- }
3504
- }
3505
- const args = ["delete-stamp-repo", name];
3506
- if (opts.purge) args.push("--purge");
3507
- const result = spawnSync5(
3508
- "ssh",
3509
- ["-p", String(server2.port), "--", `${server2.user}@${server2.host}`, ...args],
3510
- { stdio: ["ignore", "inherit", "inherit"] }
3511
- );
3512
- if (result.status !== 0) {
4559
+ let raw;
4560
+ try {
4561
+ raw = readFileSync8(path2, "utf8");
4562
+ } catch (err) {
3513
4563
  throw new Error(
3514
- `server-side delete failed (exit ${result.status}). The bare repo on the stamp server was NOT touched. If you see "command not found", the server image is older than 0.7.3 \u2014 redeploy it first.`
4564
+ `could not read ${path2}: ${err instanceof Error ? err.message : String(err)}`
3515
4565
  );
3516
4566
  }
3517
- if (!opts.purge) {
3518
- console.log();
3519
- console.log(`Recovery:`);
3520
- console.log(` stamp server-repos restore ${name} # bring it back`);
3521
- console.log(` stamp server-repos delete ${name} --purge # nuke for real`);
3522
- }
3523
- if (opts.alsoGithub) {
3524
- if (!opts.yes) {
3525
- const expected = `delete github ${opts.alsoGithub}`;
3526
- const got = await prompt(
3527
- `Server-side done. To ALSO delete the GitHub mirror, type "${expected}" (or anything else to skip): `
3528
- );
3529
- if (got.trim() !== expected) {
3530
- console.log(
3531
- `note: skipped GitHub delete; mirror at https://github.com/${opts.alsoGithub} is intact`
3532
- );
3533
- return;
3534
- }
3535
- }
3536
- const ghResult = spawnSync5(
3537
- "gh",
3538
- ["repo", "delete", opts.alsoGithub, "--yes"],
3539
- { stdio: ["ignore", "inherit", "inherit"] }
4567
+ let parsed;
4568
+ try {
4569
+ parsed = parseYaml5(raw);
4570
+ } catch (err) {
4571
+ throw new Error(
4572
+ `${path2} failed to parse as YAML: ${err instanceof Error ? err.message : String(err)}`
3540
4573
  );
3541
- if (ghResult.status !== 0) {
3542
- throw new Error(
3543
- `GitHub repo delete failed (exit ${ghResult.status}). Server-side delete already succeeded; the GitHub mirror is still present at https://github.com/${opts.alsoGithub}.`
3544
- );
3545
- }
3546
4574
  }
3547
- }
3548
- async function runServerRepoRestore(opts) {
3549
- const name = normalizeRepoName(opts.name);
3550
- const asName = opts.asName !== void 0 ? normalizeRepoName(opts.asName) : void 0;
3551
- if (opts.from !== void 0) validateTrashEntryName(opts.from);
3552
- const server2 = resolveServer(opts.server);
3553
- const args = ["restore-stamp-repo", name];
3554
- if (opts.from) {
3555
- args.push("--from", opts.from);
4575
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4576
+ throw new Error(`${path2} is empty or not a map`);
3556
4577
  }
3557
- if (asName) {
3558
- args.push("--as", asName);
4578
+ const obj = parsed;
4579
+ const gh = obj["github"];
4580
+ if (!gh || typeof gh !== "object" || Array.isArray(gh)) {
4581
+ throw new Error(`${path2} has no usable 'github' map`);
3559
4582
  }
3560
- const result = spawnSync5(
3561
- "ssh",
3562
- ["-p", String(server2.port), "--", `${server2.user}@${server2.host}`, ...args],
3563
- { stdio: ["ignore", "inherit", "inherit"] }
3564
- );
3565
- if (result.status !== 0) {
4583
+ const repoStr = gh["repo"];
4584
+ if (typeof repoStr !== "string" || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repoStr)) {
3566
4585
  throw new Error(
3567
- `server-side restore failed (exit ${result.status}). Run \`stamp server-repos list --trash\` to see what's available.`
4586
+ `${path2} github.repo is missing or not of form 'owner/repo' (got ${JSON.stringify(repoStr)})`
3568
4587
  );
3569
4588
  }
4589
+ const slashIdx = repoStr.indexOf("/");
4590
+ return {
4591
+ owner: repoStr.slice(0, slashIdx),
4592
+ repo: repoStr.slice(slashIdx + 1)
4593
+ };
3570
4594
  }
3571
- function runServerRepoList(opts) {
3572
- const server2 = resolveServer(opts.server);
3573
- if (opts.trash) {
3574
- const result2 = spawnSync5(
3575
- "ssh",
3576
- ["-p", String(server2.port), "--", `${server2.user}@${server2.host}`, "list-trash"],
3577
- { stdio: ["ignore", "inherit", "inherit"] }
4595
+ async function runMigrateBypass(opts, server2) {
4596
+ const repoRoot = process.cwd();
4597
+ const mirror = readMirrorYmlGithubRepo(repoRoot);
4598
+ const ghCheck = checkGhAvailable();
4599
+ if (!ghCheck.available) {
4600
+ throw new Error(
4601
+ `--migrate-bypass requires gh: ${ghCheck.reason}. Install/authenticate gh, then re-run.`
3578
4602
  );
3579
- if (result2.status !== 0) {
3580
- throw new Error(
3581
- `list --trash failed (exit ${result2.status}). If you see "command not found", the server image is older than 0.7.3 \u2014 redeploy it first.`
3582
- );
3583
- }
4603
+ }
4604
+ printMigrateBypassPlan({ mirror, server: server2, opts });
4605
+ if (opts.dryRun) {
4606
+ console.log("\n(dry run \u2014 no changes made)");
3584
4607
  return;
3585
4608
  }
3586
- const result = spawnSync5(
3587
- "ssh",
3588
- [
3589
- "-p",
3590
- String(server2.port),
3591
- "--",
3592
- `${server2.user}@${server2.host}`,
3593
- "ls",
3594
- "-1",
3595
- "/srv/git/"
3596
- ],
3597
- { stdio: ["ignore", "pipe", "inherit"], encoding: "utf8" }
4609
+ console.log(`
4610
+ Fetching per-repo deploy key from stamp server`);
4611
+ let pubkey;
4612
+ try {
4613
+ pubkey = fetchServerPubkey(server2, mirror);
4614
+ } catch (err) {
4615
+ throw new Error(
4616
+ `failed to fetch per-repo pubkey for ${mirror.owner}/${mirror.repo} from ${server2.user}@${server2.host}:${server2.port}: ${err instanceof Error ? err.message : String(err)}`
4617
+ );
4618
+ }
4619
+ console.log(`Checking existing deploy keys on ${mirror.owner}/${mirror.repo}`);
4620
+ const existingKeyId = findDeployKey(
4621
+ mirror.owner,
4622
+ mirror.repo,
4623
+ STAMP_MIRROR_DEPLOY_KEY_TITLE
3598
4624
  );
3599
- if (result.status !== 0) {
3600
- throw new Error(`list failed (exit ${result.status}).`);
4625
+ let deployKeyId;
4626
+ if (existingKeyId !== null) {
4627
+ const existingBody = fetchDeployKeyPublic(
4628
+ mirror.owner,
4629
+ mirror.repo,
4630
+ existingKeyId
4631
+ );
4632
+ if (existingBody === pubkey) {
4633
+ console.log(
4634
+ `Deploy key: "${STAMP_MIRROR_DEPLOY_KEY_TITLE}" already matches per-repo pubkey (keyId ${existingKeyId}). No change.`
4635
+ );
4636
+ deployKeyId = existingKeyId;
4637
+ } else {
4638
+ console.log(
4639
+ `Deploy key: "${STAMP_MIRROR_DEPLOY_KEY_TITLE}" is registered but doesn't match the per-repo pubkey (keyId ${existingKeyId}). Deleting before re-registering.`
4640
+ );
4641
+ const del = deleteDeployKey(mirror.owner, mirror.repo, existingKeyId);
4642
+ if (del.status === "failed") {
4643
+ throw new Error(`deploy-key cleanup failed: ${del.error}`);
4644
+ }
4645
+ deployKeyId = registerStampMirrorKey(mirror, pubkey);
4646
+ }
4647
+ } else {
4648
+ deployKeyId = registerStampMirrorKey(mirror, pubkey);
3601
4649
  }
3602
- const entries = filterLiveBareRepoNames(result.stdout);
3603
- if (entries.length === 0) {
3604
- console.log("(no live bare repos)");
4650
+ console.log(`Looking up stamp-mirror-only ruleset on ${mirror.owner}/${mirror.repo}`);
4651
+ const rulesetId = findExistingStampRuleset(mirror.owner, mirror.repo);
4652
+ if (rulesetId === null) {
4653
+ console.log(
4654
+ `note: no \`stamp-mirror-only\` Ruleset on ${mirror.owner}/${mirror.repo}. Deploy key is registered; no bypass list to update.`
4655
+ );
4656
+ console.log(
4657
+ ` If this repo is server-gated only (no GitHub-side enforcement), that's expected and you're done.`
4658
+ );
4659
+ console.log(
4660
+ ` If you EXPECTED a Ruleset, it may use a non-canonical name (e.g. think-cli's \`Protect Main\`) \u2014 rename to \`stamp-mirror-only\` in the GitHub UI and re-run, or migrate by hand.`
4661
+ );
4662
+ if (opts.removeOrgadmin) {
4663
+ console.log(
4664
+ ` --remove-orgadmin requested but there's no Ruleset bypass list to modify; ignoring.`
4665
+ );
4666
+ }
4667
+ printMigrateBypassSuccess({
4668
+ mirror,
4669
+ server: server2,
4670
+ opts,
4671
+ rulesetUpdated: false
4672
+ });
3605
4673
  return;
3606
4674
  }
3607
- for (const e of entries) console.log(e);
3608
- }
3609
- function resolveServer(serverFlag) {
3610
- const server2 = serverFlag ? parseServerFlag(serverFlag) : loadServerConfig();
3611
- if (!server2) {
4675
+ console.log(`Reading current bypass_actors on ruleset ${rulesetId}`);
4676
+ const current = getRulesetBypassActors(mirror.owner, mirror.repo, rulesetId);
4677
+ if (current === null) {
3612
4678
  throw new Error(
3613
- `no stamp server configured. Either:
3614
- - create ~/.stamp/server.yml with at least:
3615
- host: <ssh-host>
3616
- port: <ssh-port>
3617
- - or pass --server <host>:<port> on the command line.`
4679
+ `could not read bypass_actors on ${mirror.owner}/${mirror.repo} ruleset ${rulesetId}`
3618
4680
  );
3619
4681
  }
3620
- return server2;
3621
- }
3622
- var UsageError = class extends Error {
3623
- constructor(message) {
3624
- super(message);
3625
- this.name = "UsageError";
4682
+ const desired = computeDesiredBypassActors(current, deployKeyId, {
4683
+ removeOrgadmin: opts.removeOrgadmin === true
4684
+ });
4685
+ const result = replaceBypassActors(
4686
+ mirror.owner,
4687
+ mirror.repo,
4688
+ rulesetId,
4689
+ desired
4690
+ );
4691
+ if (result.status === "failed") {
4692
+ throw new Error(`ruleset bypass update failed: ${result.error}`);
3626
4693
  }
3627
- };
3628
- function normalizeRepoName(name) {
3629
- const canonical = name.endsWith(".git") ? name.slice(0, -4) : name;
3630
- validateRepoName2(canonical);
3631
- return canonical;
4694
+ if (result.status === "updated") {
4695
+ console.log(
4696
+ `Ruleset bypass: updated to [${desired.map((a) => a.actor_type).join(", ")}].`
4697
+ );
4698
+ } else {
4699
+ console.log(`Ruleset bypass: already up to date. No change.`);
4700
+ }
4701
+ printMigrateBypassSuccess({ mirror, server: server2, opts, rulesetUpdated: true });
3632
4702
  }
3633
- function filterLiveBareRepoNames(rawOutput) {
3634
- return rawOutput.split("\n").map((s) => s.trim()).filter(Boolean).filter((s) => s.endsWith(".git")).map((s) => s.slice(0, -4)).filter((s) => s.length > 0);
4703
+ function registerStampMirrorKey(mirror, pubkey) {
4704
+ const reg = registerDeployKey(
4705
+ mirror.owner,
4706
+ mirror.repo,
4707
+ STAMP_MIRROR_DEPLOY_KEY_TITLE,
4708
+ pubkey
4709
+ );
4710
+ if (reg.status === "failed") {
4711
+ throw new Error(`deploy-key registration failed: ${reg.error}`);
4712
+ }
4713
+ console.log(
4714
+ `Deploy key: registered "${STAMP_MIRROR_DEPLOY_KEY_TITLE}" on ${mirror.owner}/${mirror.repo} (id ${reg.keyId}).`
4715
+ );
4716
+ return reg.keyId;
3635
4717
  }
3636
- function validateRepoName2(name) {
3637
- if (!/^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(name) || name.includes("..")) {
3638
- throw new UsageError(
3639
- `repo name must start with [A-Za-z0-9_], match [A-Za-z0-9._-]+, and not contain '..' (got "${name}")`
4718
+ function printMigrateBypassPlan(args) {
4719
+ const bar = "\u2500".repeat(72);
4720
+ console.log(bar);
4721
+ console.log("stamp provision --migrate-bypass \u2014 plan");
4722
+ console.log(bar);
4723
+ console.log(fmt("mirror", `${args.mirror.owner}/${args.mirror.repo}`));
4724
+ console.log(fmt("stamp server", `${args.server.user}@${args.server.host}:${args.server.port}`));
4725
+ console.log(
4726
+ fmt(
4727
+ "deploy key",
4728
+ `fetch per-repo pubkey from server; register as "${STAMP_MIRROR_DEPLOY_KEY_TITLE}" on the mirror (replacing any prior entry under that title)`
4729
+ )
4730
+ );
4731
+ console.log(
4732
+ fmt(
4733
+ "ruleset",
4734
+ `add DeployKey actor to stamp-mirror-only bypass list` + (args.opts.removeOrgadmin ? `; remove OrganizationAdmin (--remove-orgadmin)` : `; preserve OrganizationAdmin`)
4735
+ )
4736
+ );
4737
+ console.log(bar);
4738
+ if (args.opts.removeOrgadmin) {
4739
+ console.log(
4740
+ `warning: --remove-orgadmin strips the OrganizationAdmin bypass before any push-verification step runs. Verify the DeployKey transport works (one stamp push) before running this.`
3640
4741
  );
3641
4742
  }
3642
4743
  }
3643
- function validateTrashEntryName(entry) {
3644
- if (!/^[0-9]{8}T[0-9]{6}Z-[A-Za-z0-9_][A-Za-z0-9._-]*\.git$/.test(entry)) {
3645
- throw new UsageError(
3646
- `--from must match <YYYYMMDDTHHMMSSZ>-<name>.git (got "${entry}"). Run \`stamp server-repos list --trash\` to see valid entry names.`
4744
+ function printMigrateBypassSuccess(args) {
4745
+ const bar = "\u2500".repeat(72);
4746
+ console.log(`
4747
+ ${bar}`);
4748
+ console.log(
4749
+ args.rulesetUpdated ? `\u2713 bypass migrated` : `\u2713 deploy key registered (no ruleset to migrate)`
4750
+ );
4751
+ console.log(bar);
4752
+ console.log(fmt("mirror", `${args.mirror.owner}/${args.mirror.repo}`));
4753
+ if (args.rulesetUpdated) {
4754
+ console.log(
4755
+ fmt(
4756
+ "bypass actors",
4757
+ args.opts.removeOrgadmin ? `DeployKey (OrganizationAdmin removed)` : `OrganizationAdmin + DeployKey`
4758
+ )
4759
+ );
4760
+ } else {
4761
+ console.log(
4762
+ fmt(
4763
+ "bypass actors",
4764
+ `n/a (no stamp-mirror-only Ruleset on this repo \u2014 server-gated only)`
4765
+ )
3647
4766
  );
3648
4767
  }
3649
- }
3650
- function validateGithubRepoSpec(spec) {
3651
- if (!/^[A-Za-z0-9_][A-Za-z0-9-]*\/[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(spec)) {
3652
- throw new UsageError(
3653
- `--also-github must be <owner>/<repo> with no leading '-' on either segment (got "${spec}")`
4768
+ console.log(bar);
4769
+ if (!args.rulesetUpdated) {
4770
+ console.log(
4771
+ `
4772
+ Deploy key is registered; the next stamp push's mirror leg will use it.
4773
+ No GitHub Ruleset was found on this repo, so there's no bypass enforcement
4774
+ to verify. If you want GitHub-side protection, apply the stamp-mirror-only
4775
+ Ruleset separately (see docs/github-ruleset-setup.md).`
4776
+ );
4777
+ } else if (!args.opts.removeOrgadmin) {
4778
+ console.log(
4779
+ `
4780
+ Next: do a stamp merge + push to verify the DeployKey transport works,
4781
+ then re-run with --remove-orgadmin to drop the OrganizationAdmin fallback.`
4782
+ );
4783
+ } else {
4784
+ console.log(
4785
+ `
4786
+ The stamp-mirror-only Ruleset now bypasses ONLY via the per-repo deploy key.
4787
+ Direct \`git push origin main\` from any non-stamp source will be rejected.`
3654
4788
  );
3655
4789
  }
3656
4790
  }
3657
- function prompt(question) {
3658
- const rl = createInterface({ input: process.stdin, output: process.stdout });
3659
- return new Promise((resolve2) => {
3660
- rl.question(question, (answer) => {
3661
- rl.close();
3662
- resolve2(answer);
3663
- });
3664
- });
3665
- }
3666
4791
 
3667
4792
  // src/commands/keys.ts
3668
- import { existsSync as existsSync9, readdirSync as readdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync7 } from "fs";
3669
- import { basename, join as join6 } from "path";
4793
+ import { existsSync as existsSync12, readdirSync as readdirSync2, readFileSync as readFileSync9, writeFileSync as writeFileSync10 } from "fs";
4794
+ import { basename, join as join7 } from "path";
3670
4795
  function keysGenerate() {
3671
4796
  const existing = loadUserKeypair();
3672
4797
  if (existing) {
@@ -3699,7 +4824,7 @@ function keysList() {
3699
4824
  const repoRoot = findRepoRoot();
3700
4825
  const trustedDir = stampTrustedKeysDir(repoRoot);
3701
4826
  console.log(`repo trusted keys: ${trustedDir}/`);
3702
- if (!existsSync9(trustedDir)) {
4827
+ if (!existsSync12(trustedDir)) {
3703
4828
  console.log(" (directory does not exist \u2014 run `stamp init`)");
3704
4829
  return;
3705
4830
  }
@@ -3710,7 +4835,7 @@ function keysList() {
3710
4835
  }
3711
4836
  for (const file of pubFiles.sort()) {
3712
4837
  try {
3713
- const pem = readFileSync6(join6(trustedDir, file), "utf8");
4838
+ const pem = readFileSync9(join7(trustedDir, file), "utf8");
3714
4839
  const fp = fingerprintFromPem(pem);
3715
4840
  const marker = local && fp === local.fingerprint ? " (you)" : "";
3716
4841
  console.log(` ${fp}${marker} [${file}]`);
@@ -3729,15 +4854,15 @@ function keysExport() {
3729
4854
  function keysTrust(pubFile) {
3730
4855
  const repoRoot = findRepoRoot();
3731
4856
  const trustedDir = stampTrustedKeysDir(repoRoot);
3732
- if (!existsSync9(trustedDir)) {
4857
+ if (!existsSync12(trustedDir)) {
3733
4858
  throw new Error(
3734
4859
  `no ${trustedDir} \u2014 run \`stamp init\` first to create the trust store`
3735
4860
  );
3736
4861
  }
3737
- if (!existsSync9(pubFile)) {
4862
+ if (!existsSync12(pubFile)) {
3738
4863
  throw new Error(`public key file not found: ${pubFile}`);
3739
4864
  }
3740
- const pem = readFileSync6(pubFile, "utf8");
4865
+ const pem = readFileSync9(pubFile, "utf8");
3741
4866
  let fingerprint;
3742
4867
  try {
3743
4868
  fingerprint = fingerprintFromPem(pem);
@@ -3747,12 +4872,12 @@ function keysTrust(pubFile) {
3747
4872
  );
3748
4873
  }
3749
4874
  const filename = publicKeyFingerprintFilename(fingerprint);
3750
- const dest = join6(trustedDir, filename);
3751
- if (existsSync9(dest)) {
4875
+ const dest = join7(trustedDir, filename);
4876
+ if (existsSync12(dest)) {
3752
4877
  console.log(`${fingerprint} is already trusted (${basename(dest)})`);
3753
4878
  return;
3754
4879
  }
3755
- writeFileSync7(dest, pem);
4880
+ writeFileSync10(dest, pem);
3756
4881
  console.log(`trusted ${fingerprint}`);
3757
4882
  console.log(` \u2192 ${dest}`);
3758
4883
  console.log();
@@ -3760,11 +4885,11 @@ function keysTrust(pubFile) {
3760
4885
  }
3761
4886
 
3762
4887
  // src/commands/log.ts
3763
- import { existsSync as existsSync10 } from "fs";
4888
+ import { existsSync as existsSync13 } from "fs";
3764
4889
  function runLog(opts) {
3765
4890
  const repoRoot = findRepoRoot();
3766
4891
  const configPath = stampConfigFile(repoRoot);
3767
- if (!existsSync10(configPath)) {
4892
+ if (!existsSync13(configPath)) {
3768
4893
  throw new Error(
3769
4894
  `no .stamp/config.yml at ${configPath}. Run \`stamp init\` first.`
3770
4895
  );
@@ -3881,7 +5006,7 @@ function printCommitDetail(sha, repoRoot) {
3881
5006
  }
3882
5007
  function collectReviewProse(repoRoot, payload) {
3883
5008
  const dbPath = stampStateDbPath(repoRoot);
3884
- if (!existsSync10(dbPath)) return [];
5009
+ if (!existsSync13(dbPath)) return [];
3885
5010
  const db = openDb(dbPath);
3886
5011
  try {
3887
5012
  const rows = latestReviews(db, payload.base_sha, payload.head_sha);
@@ -3895,7 +5020,7 @@ function printReviewHistory(repoRoot, limit, diff) {
3895
5020
  const configPath = stampConfigFile(repoRoot);
3896
5021
  loadConfig(configPath);
3897
5022
  const dbPath = stampStateDbPath(repoRoot);
3898
- if (!existsSync10(dbPath)) {
5023
+ if (!existsSync13(dbPath)) {
3899
5024
  console.log("No reviews recorded yet.");
3900
5025
  return;
3901
5026
  }
@@ -3936,7 +5061,8 @@ function printReviewHistory(repoRoot, limit, diff) {
3936
5061
  }
3937
5062
 
3938
5063
  // src/commands/prune.ts
3939
- import { existsSync as existsSync11, statSync as statSync2 } from "fs";
5064
+ import { existsSync as existsSync14, readdirSync as readdirSync3, statSync as statSync2, unlinkSync as unlinkSync3 } from "fs";
5065
+ import { join as join8 } from "path";
3940
5066
 
3941
5067
  // src/lib/duration.ts
3942
5068
  function parseRetentionDuration(input) {
@@ -3949,53 +5075,116 @@ function parseRetentionDuration(input) {
3949
5075
  const n = match[1];
3950
5076
  const unit = match[2];
3951
5077
  const unitWord = unit === "d" ? "days" : unit === "h" ? "hours" : "minutes";
5078
+ const nNum = Number(n);
5079
+ const msPerUnit = unit === "d" ? 864e5 : unit === "h" ? 36e5 : 6e4;
3952
5080
  return {
3953
5081
  sqliteModifier: `-${n} ${unitWord}`,
3954
- humanLabel: `${n}${unit}`
5082
+ humanLabel: `${n}${unit}`,
5083
+ durationMs: nNum * msPerUnit
3955
5084
  };
3956
5085
  }
3957
5086
 
3958
5087
  // src/commands/prune.ts
3959
5088
  function runPrune(opts) {
3960
- const { sqliteModifier, humanLabel } = parseRetentionDuration(opts.olderThan);
5089
+ const { sqliteModifier, humanLabel, durationMs } = parseRetentionDuration(
5090
+ opts.olderThan
5091
+ );
3961
5092
  const repoRoot = findRepoRoot();
3962
5093
  const dbPath = stampStateDbPath(repoRoot);
3963
- if (!existsSync11(dbPath)) {
5094
+ const spoolDir = join8(gitCommonDir(repoRoot), "stamp", "failed-parses");
5095
+ const spoolCutoffMs = Date.now() - durationMs;
5096
+ if (!existsSync14(dbPath) && !existsSync14(spoolDir)) {
3964
5097
  console.log(
3965
- `note: ${dbPath} does not exist; nothing to prune (state.db is created on first \`stamp review\`)`
5098
+ `note: nothing to prune (neither ${dbPath} nor ${spoolDir} exists \u2014 both are created on first \`stamp review\`)`
3966
5099
  );
3967
5100
  return;
3968
5101
  }
3969
- const sizeBefore = statSync2(dbPath).size;
3970
- const db = openDb(dbPath);
5102
+ const db = existsSync14(dbPath) ? openDb(dbPath) : null;
3971
5103
  try {
3972
5104
  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;
5105
+ let any2 = false;
5106
+ if (db) {
5107
+ const peek = peekPrunable(db, sqliteModifier);
5108
+ if (peek.total > 0) {
5109
+ console.log(
5110
+ `would prune ${peek.total} review row${peek.total === 1 ? "" : "s"} older than ${humanLabel} (${peek.perReviewer.length} reviewer${peek.perReviewer.length === 1 ? "" : "s"} affected):`
5111
+ );
5112
+ printPerReviewer(peek.perReviewer);
5113
+ any2 = true;
5114
+ }
5115
+ }
5116
+ const spoolPeek = peekFailedParseSpools(spoolDir, spoolCutoffMs);
5117
+ if (spoolPeek.length > 0) {
5118
+ if (any2) console.log("");
5119
+ console.log(
5120
+ `would prune ${spoolPeek.length} failed-parse spool file${spoolPeek.length === 1 ? "" : "s"} older than ${humanLabel}:`
5121
+ );
5122
+ for (const f of spoolPeek) console.log(` ${f}`);
5123
+ any2 = true;
3977
5124
  }
5125
+ if (!any2) {
5126
+ console.log(`note: nothing to prune (no rows or spools older than ${humanLabel})`);
5127
+ } else {
5128
+ console.log("\n(dry run \u2014 no changes made)");
5129
+ }
5130
+ return;
5131
+ }
5132
+ let any = false;
5133
+ if (db) {
5134
+ const sizeBefore = statSync2(dbPath).size;
5135
+ const result = pruneReviews(db, sqliteModifier);
5136
+ if (result.total > 0) {
5137
+ db.exec("VACUUM");
5138
+ const sizeAfter = statSync2(dbPath).size;
5139
+ console.log(
5140
+ `${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`
5141
+ );
5142
+ printPerReviewer(result.perReviewer);
5143
+ any = true;
5144
+ }
5145
+ }
5146
+ const spoolDeleted = pruneFailedParseSpools(spoolDir, spoolCutoffMs);
5147
+ if (spoolDeleted > 0) {
5148
+ if (any) console.log("");
3978
5149
  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):`
5150
+ `${spoolDeleted} failed-parse spool file${spoolDeleted === 1 ? "" : "s"} pruned`
3980
5151
  );
3981
- printPerReviewer(peek.perReviewer);
3982
- console.log("\n(dry run \u2014 no changes made)");
3983
- return;
5152
+ any = true;
3984
5153
  }
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;
5154
+ if (!any) {
5155
+ console.log(`note: nothing to prune (no rows or spools older than ${humanLabel})`);
3989
5156
  }
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
5157
  } finally {
3997
- db.close();
5158
+ db?.close();
5159
+ }
5160
+ }
5161
+ function peekFailedParseSpools(spoolDir, cutoffMs) {
5162
+ if (!existsSync14(spoolDir)) return [];
5163
+ const out = [];
5164
+ for (const entry of readdirSync3(spoolDir)) {
5165
+ const filepath = join8(spoolDir, entry);
5166
+ let stat;
5167
+ try {
5168
+ stat = statSync2(filepath);
5169
+ } catch {
5170
+ continue;
5171
+ }
5172
+ if (!stat.isFile()) continue;
5173
+ if (stat.mtimeMs < cutoffMs) out.push(filepath);
5174
+ }
5175
+ return out.sort();
5176
+ }
5177
+ function pruneFailedParseSpools(spoolDir, cutoffMs) {
5178
+ const targets = peekFailedParseSpools(spoolDir, cutoffMs);
5179
+ let deleted = 0;
5180
+ for (const filepath of targets) {
5181
+ try {
5182
+ unlinkSync3(filepath);
5183
+ deleted++;
5184
+ } catch {
5185
+ }
3998
5186
  }
5187
+ return deleted;
3999
5188
  }
4000
5189
  function printPerReviewer(rows) {
4001
5190
  const maxNameLen = Math.max(16, ...rows.map((r) => r.reviewer.length));
@@ -4006,119 +5195,149 @@ function printPerReviewer(rows) {
4006
5195
  }
4007
5196
  }
4008
5197
 
4009
- // 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";
4013
- function formatServerConfigYaml(opts) {
4014
- const body = {
4015
- host: opts.host,
4016
- port: opts.port
5198
+ // src/commands/config.ts
5199
+ import { existsSync as existsSync15 } from "fs";
5200
+ function runConfigReviewersSet(opts) {
5201
+ if (!isValidReviewerName(opts.reviewer)) {
5202
+ throw new UsageError(
5203
+ `invalid reviewer name '${opts.reviewer}'. Names must be alphanumerics + '_' / '-', max 64 chars, no leading hyphen \u2014 same shape as \`stamp reviewers add\` accepts.`
5204
+ );
5205
+ }
5206
+ const id = opts.modelId.trim();
5207
+ if (id === "") {
5208
+ throw new UsageError(
5209
+ `model id is required and must be a non-empty string (e.g. 'claude-sonnet-4-6' or 'claude-opus-4-7')`
5210
+ );
5211
+ }
5212
+ if (!isValidModelId(id)) {
5213
+ throw new UsageError(
5214
+ `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.`
5215
+ );
5216
+ }
5217
+ const existing = loadOrEmpty();
5218
+ const prior = existing.reviewers[opts.reviewer];
5219
+ const next = {
5220
+ reviewers: { ...existing.reviewers, [opts.reviewer]: id }
4017
5221
  };
4018
- if (opts.user && opts.user.trim()) body.user = opts.user.trim();
4019
- if (opts.repoRootPrefix && opts.repoRootPrefix.trim()) {
4020
- body.repo_root_prefix = opts.repoRootPrefix.trim();
5222
+ const path2 = writeUserConfig(next);
5223
+ if (prior === id) {
5224
+ console.log(`reviewers.${opts.reviewer} = ${id} (unchanged)`);
5225
+ } else if (prior) {
5226
+ console.log(`reviewers.${opts.reviewer}: ${prior} -> ${id}`);
5227
+ } else {
5228
+ console.log(`reviewers.${opts.reviewer} = ${id} (new)`);
4021
5229
  }
4022
- return stringifyYaml(body);
5230
+ console.log(`wrote ${path2}`);
4023
5231
  }
4024
- function runServerConfig(opts) {
4025
- const modes = [opts.hostPort, opts.show, opts.unset].filter(Boolean).length;
4026
- if (modes !== 1) {
5232
+ function runConfigReviewersClear(opts) {
5233
+ if (opts.all && opts.reviewer) {
4027
5234
  throw new UsageError(
4028
- "stamp server config: provide exactly one of <host:port>, --show, or --unset"
5235
+ `\`stamp config reviewers clear\`: pass either <reviewer> or --all, not both`
4029
5236
  );
4030
5237
  }
4031
- if ((opts.show || opts.unset) && (opts.user || opts.repoRootPrefix)) {
5238
+ if (!opts.all && !opts.reviewer) {
4032
5239
  throw new UsageError(
4033
- "stamp server config: --user and --repo-root-prefix only apply when writing (they conflict with --show / --unset)"
5240
+ `\`stamp config reviewers clear\`: pass <reviewer> to clear one entry or --all to remove the whole config`
4034
5241
  );
4035
5242
  }
4036
- if (opts.show) return showConfig();
4037
- if (opts.unset) return unsetConfig();
4038
- return writeConfig(opts);
4039
- }
4040
- function showConfig() {
4041
- const path2 = userServerConfigPath();
4042
- if (!existsSync12(path2)) {
4043
- console.log(`note: no stamp server configured (${path2} does not exist)`);
4044
- console.log(`note: run \`stamp server config <host:port>\` to create one`);
5243
+ if (opts.all) {
5244
+ const removed = deleteUserConfig();
5245
+ const path3 = userConfigPath();
5246
+ if (removed) {
5247
+ console.log(`removed ${path3}`);
5248
+ } else {
5249
+ console.log(`note: ${path3} does not exist; nothing to remove`);
5250
+ }
4045
5251
  return;
4046
5252
  }
4047
- const cfg = loadServerConfig();
4048
- if (!cfg) {
4049
- console.log(`note: no stamp server configured`);
5253
+ const reviewer = opts.reviewer;
5254
+ if (!isValidReviewerName(reviewer)) {
5255
+ throw new UsageError(
5256
+ `invalid reviewer name '${reviewer}'. Names must be alphanumerics + '_' / '-', max 64 chars, no leading hyphen \u2014 same shape as \`stamp reviewers add\` accepts.`
5257
+ );
5258
+ }
5259
+ const existing = loadOrEmpty();
5260
+ if (!(reviewer in existing.reviewers)) {
5261
+ console.log(`note: reviewers.${reviewer} is not set; nothing to clear`);
4050
5262
  return;
4051
5263
  }
4052
- console.log(`config: ${path2}`);
4053
- console.log(`host: ${cfg.host}`);
4054
- console.log(`port: ${cfg.port}`);
4055
- console.log(`user: ${cfg.user}`);
4056
- console.log(`repo_root_prefix: ${cfg.repoRootPrefix}`);
5264
+ const next = { reviewers: { ...existing.reviewers } };
5265
+ delete next.reviewers[reviewer];
5266
+ const path2 = writeUserConfig(next);
5267
+ console.log(`cleared reviewers.${reviewer}`);
5268
+ console.log(`wrote ${path2}`);
4057
5269
  }
4058
- function unsetConfig() {
4059
- const path2 = userServerConfigPath();
4060
- if (!existsSync12(path2)) {
4061
- console.log(`note: ${path2} does not exist; nothing to remove`);
5270
+ function runConfigReviewersShow() {
5271
+ const path2 = userConfigPath();
5272
+ if (!existsSync15(path2)) {
5273
+ console.log(`note: no per-user stamp config (${path2} does not exist).`);
5274
+ console.log(
5275
+ ` Defaults will apply on next \`stamp init\` or \`stamp review\`:`
5276
+ );
5277
+ for (const [name, id] of Object.entries(DEFAULT_REVIEWER_MODELS)) {
5278
+ console.log(` ${name}: ${id} (default)`);
5279
+ }
5280
+ console.log(
5281
+ ` Pin a different model: \`stamp config reviewers set <reviewer> <model-id>\``
5282
+ );
4062
5283
  return;
4063
5284
  }
4064
- unlinkSync(path2);
4065
- console.log(`removed ${path2}`);
4066
- }
4067
- function writeConfig(opts) {
4068
- let parsed;
4069
- try {
4070
- parsed = parseServerFlag(opts.hostPort, "stamp server config: <host:port>");
4071
- } catch (err) {
4072
- throw new UsageError(err instanceof Error ? err.message : String(err));
5285
+ const cfg = loadUserConfig() ?? { reviewers: {} };
5286
+ console.log(`config: ${path2}`);
5287
+ const names = Object.keys(cfg.reviewers).sort();
5288
+ if (names.length === 0) {
5289
+ console.log(`(no reviewer overrides; SDK default model in use for every reviewer)`);
5290
+ console.log(
5291
+ `Pin one with: \`stamp config reviewers set <reviewer> <model-id>\``
5292
+ );
5293
+ return;
4073
5294
  }
4074
- const yaml = formatServerConfigYaml({
4075
- host: parsed.host,
4076
- port: parsed.port,
4077
- user: opts.user,
4078
- repoRootPrefix: opts.repoRootPrefix
4079
- });
4080
- const path2 = userServerConfigPath();
4081
- const dir = dirname3(path2);
4082
- if (!existsSync12(dir)) mkdirSync3(dir, { recursive: true, mode: 448 });
4083
- const tmp = `${path2}.tmp.${process.pid}`;
4084
- writeFileSync8(tmp, yaml, { mode: 384 });
4085
- renameSync(tmp, path2);
4086
- console.log(`wrote ${path2}`);
4087
- console.log(`host: ${parsed.host}`);
4088
- console.log(`port: ${parsed.port}`);
4089
- if (opts.user && opts.user.trim()) {
4090
- console.log(`user: ${opts.user.trim()}`);
5295
+ console.log(`reviewers:`);
5296
+ const maxNameLen = Math.max(...names.map((n) => n.length));
5297
+ for (const name of names) {
5298
+ const id = cfg.reviewers[name];
5299
+ const tag = DEFAULT_REVIEWER_MODELS[name] === id ? " (matches default)" : DEFAULT_REVIEWER_MODELS[name] ? ` (default: ${DEFAULT_REVIEWER_MODELS[name]})` : "";
5300
+ console.log(` ${name.padEnd(maxNameLen)} ${id}${tag}`);
4091
5301
  }
4092
- if (opts.repoRootPrefix && opts.repoRootPrefix.trim()) {
4093
- console.log(`repo_root_prefix: ${opts.repoRootPrefix.trim()}`);
5302
+ const unpinned = Object.keys(DEFAULT_REVIEWER_MODELS).filter(
5303
+ (n) => !(n in cfg.reviewers)
5304
+ );
5305
+ if (unpinned.length > 0) {
5306
+ console.log(`unpinned (will use default at review time):`);
5307
+ for (const name of unpinned) {
5308
+ console.log(` ${name.padEnd(maxNameLen)} ${DEFAULT_REVIEWER_MODELS[name]} (default)`);
5309
+ }
4094
5310
  }
4095
5311
  }
5312
+ function loadOrEmpty() {
5313
+ return loadUserConfig() ?? { reviewers: {} };
5314
+ }
4096
5315
 
4097
5316
  // src/commands/reviewers.ts
4098
- import { spawnSync as spawnSync6 } from "child_process";
5317
+ import { spawnSync as spawnSync7 } from "child_process";
4099
5318
  import {
4100
- existsSync as existsSync14,
4101
- readFileSync as readFileSync8,
5319
+ existsSync as existsSync17,
5320
+ readFileSync as readFileSync11,
4102
5321
  statSync as statSync3,
4103
- unlinkSync as unlinkSync2,
4104
- writeFileSync as writeFileSync10
5322
+ unlinkSync as unlinkSync4,
5323
+ writeFileSync as writeFileSync12
4105
5324
  } from "fs";
4106
- import { join as join8, relative, resolve } from "path";
4107
- import { parse as parseYaml4, stringify as stringifyYaml2 } from "yaml";
5325
+ import { join as join10, relative, resolve } from "path";
5326
+ import { parse as parseYaml6, stringify as stringifyYaml3 } from "yaml";
4108
5327
 
4109
5328
  // src/lib/reviewerLock.ts
4110
- import { existsSync as existsSync13, readFileSync as readFileSync7, writeFileSync as writeFileSync9 } from "fs";
4111
- import { join as join7 } from "path";
5329
+ import { existsSync as existsSync16, readFileSync as readFileSync10, writeFileSync as writeFileSync11 } from "fs";
5330
+ import { join as join9 } from "path";
4112
5331
  var LOCK_FILE_VERSION = 1;
4113
5332
  var LOCK_DRIFT_EXIT = 3;
4114
5333
  function lockFilePath(repoRoot, reviewerName) {
4115
- return join7(repoRoot, ".stamp", "reviewers", `${reviewerName}.lock.json`);
5334
+ return join9(repoRoot, ".stamp", "reviewers", `${reviewerName}.lock.json`);
4116
5335
  }
4117
5336
  function readLockFile(repoRoot, reviewerName) {
4118
5337
  const path2 = lockFilePath(repoRoot, reviewerName);
4119
- if (!existsSync13(path2)) return null;
5338
+ if (!existsSync16(path2)) return null;
4120
5339
  try {
4121
- const raw = readFileSync7(path2, "utf8");
5340
+ const raw = readFileSync10(path2, "utf8");
4122
5341
  const parsed = JSON.parse(raw);
4123
5342
  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
5343
  throw new Error(`malformed lock file at ${path2}`);
@@ -4132,20 +5351,20 @@ function readLockFile(repoRoot, reviewerName) {
4132
5351
  }
4133
5352
  function writeLockFile(repoRoot, reviewerName, lock) {
4134
5353
  const path2 = lockFilePath(repoRoot, reviewerName);
4135
- writeFileSync9(path2, JSON.stringify(lock, null, 2) + "\n", "utf8");
5354
+ writeFileSync11(path2, JSON.stringify(lock, null, 2) + "\n", "utf8");
4136
5355
  }
4137
5356
  function checkReviewerDrift(repoRoot, reviewerName, def) {
4138
5357
  const lock = readLockFile(repoRoot, reviewerName);
4139
5358
  if (!lock) {
4140
5359
  return unpinnedResult();
4141
5360
  }
4142
- const promptPath = join7(repoRoot, def.prompt);
4143
- if (!existsSync13(promptPath)) {
5361
+ const promptPath = join9(repoRoot, def.prompt);
5362
+ if (!existsSync16(promptPath)) {
4144
5363
  throw new Error(
4145
5364
  `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
5365
  );
4147
5366
  }
4148
- const promptBytes = readFileSync7(promptPath);
5367
+ const promptBytes = readFileSync10(promptPath);
4149
5368
  const observedPrompt = hashPromptBytes(promptBytes);
4150
5369
  const observedTools = hashTools(def.tools);
4151
5370
  const observedMcp = hashMcpServers(def.mcp_servers);
@@ -4211,8 +5430,8 @@ function requireValidReviewerName(name) {
4211
5430
  }
4212
5431
  function reviewersList() {
4213
5432
  const repoRoot = findRepoRoot();
4214
- const config = loadConfig(stampConfigFile(repoRoot));
4215
- const names = Object.keys(config.reviewers);
5433
+ const config2 = loadConfig(stampConfigFile(repoRoot));
5434
+ const names = Object.keys(config2.reviewers);
4216
5435
  if (names.length === 0) {
4217
5436
  console.log("No reviewers configured in .stamp/config.yml.");
4218
5437
  return;
@@ -4223,10 +5442,10 @@ function reviewersList() {
4223
5442
  console.log(bar);
4224
5443
  const maxNameLen = Math.max(...names.map((n) => n.length));
4225
5444
  for (const name of names) {
4226
- const def = config.reviewers[name];
5445
+ const def = config2.reviewers[name];
4227
5446
  const abs = resolve(repoRoot, def.prompt);
4228
5447
  let annotation = "";
4229
- if (!existsSync14(abs)) {
5448
+ if (!existsSync17(abs)) {
4230
5449
  annotation = " MISSING";
4231
5450
  } else {
4232
5451
  const size = statSync3(abs).size;
@@ -4236,15 +5455,15 @@ function reviewersList() {
4236
5455
  }
4237
5456
  console.log(bar);
4238
5457
  console.log("branch rules:");
4239
- for (const [branch, rule] of Object.entries(config.branches)) {
5458
+ for (const [branch, rule] of Object.entries(config2.branches)) {
4240
5459
  console.log(` ${branch} required: [${rule.required.join(", ")}]`);
4241
5460
  }
4242
5461
  console.log(bar);
4243
5462
  }
4244
5463
  function reviewersEdit(name) {
4245
5464
  const repoRoot = findRepoRoot();
4246
- const config = loadConfig(stampConfigFile(repoRoot));
4247
- const def = config.reviewers[name];
5465
+ const config2 = loadConfig(stampConfigFile(repoRoot));
5466
+ const def = config2.reviewers[name];
4248
5467
  if (!def) {
4249
5468
  throw new Error(
4250
5469
  `reviewer "${name}" is not configured. Run \`stamp reviewers list\` to see available reviewers.`
@@ -4257,27 +5476,27 @@ function reviewersAdd(name, opts = {}) {
4257
5476
  requireValidReviewerName(name);
4258
5477
  const repoRoot = findRepoRoot();
4259
5478
  const configPath = stampConfigFile(repoRoot);
4260
- const config = loadConfig(configPath);
4261
- if (config.reviewers[name]) {
5479
+ const config2 = loadConfig(configPath);
5480
+ if (config2.reviewers[name]) {
4262
5481
  throw new Error(
4263
5482
  `reviewer "${name}" already exists. Use \`stamp reviewers edit ${name}\` to change its prompt.`
4264
5483
  );
4265
5484
  }
4266
5485
  const promptRel = `.stamp/reviewers/${name}.md`;
4267
5486
  const promptAbs = resolve(repoRoot, promptRel);
4268
- if (existsSync14(promptAbs)) {
5487
+ if (existsSync17(promptAbs)) {
4269
5488
  throw new Error(
4270
5489
  `${promptRel} already exists on disk but is not in config. Either delete the file or add it to config manually.`
4271
5490
  );
4272
5491
  }
4273
- writeFileSync10(
5492
+ writeFileSync12(
4274
5493
  promptAbs,
4275
5494
  `# ${name}
4276
5495
 
4277
5496
  ${EXAMPLE_REVIEWER_PROMPT.split("\n").slice(2).join("\n")}`
4278
5497
  );
4279
- config.reviewers[name] = { prompt: promptRel };
4280
- writeFileSync10(configPath, stringifyConfig(config));
5498
+ config2.reviewers[name] = { prompt: promptRel };
5499
+ writeFileSync12(configPath, stringifyConfig(config2));
4281
5500
  console.log(`reviewer "${name}" added.`);
4282
5501
  console.log(` prompt file: ${promptRel}`);
4283
5502
  console.log(` registered in .stamp/config.yml`);
@@ -4295,15 +5514,15 @@ Opening ${promptRel} in $EDITOR...`);
4295
5514
  function reviewersRemove(name, opts = {}) {
4296
5515
  const repoRoot = findRepoRoot();
4297
5516
  const configPath = stampConfigFile(repoRoot);
4298
- const config = loadConfig(configPath);
4299
- const def = config.reviewers[name];
5517
+ const config2 = loadConfig(configPath);
5518
+ const def = config2.reviewers[name];
4300
5519
  if (!def) {
4301
5520
  throw new Error(
4302
5521
  `reviewer "${name}" is not configured. Nothing to remove.`
4303
5522
  );
4304
5523
  }
4305
5524
  const referencedBy = [];
4306
- for (const [branch, rule] of Object.entries(config.branches)) {
5525
+ for (const [branch, rule] of Object.entries(config2.branches)) {
4307
5526
  if (rule.required.includes(name)) referencedBy.push(branch);
4308
5527
  }
4309
5528
  if (referencedBy.length > 0) {
@@ -4311,13 +5530,13 @@ function reviewersRemove(name, opts = {}) {
4311
5530
  `reviewer "${name}" is required by branch(es): ${referencedBy.join(", ")}. Remove it from those branches' \`required\` list in .stamp/config.yml before removing.`
4312
5531
  );
4313
5532
  }
4314
- delete config.reviewers[name];
4315
- writeFileSync10(configPath, stringifyConfig(config));
5533
+ delete config2.reviewers[name];
5534
+ writeFileSync12(configPath, stringifyConfig(config2));
4316
5535
  console.log(`reviewer "${name}" removed from .stamp/config.yml`);
4317
5536
  if (opts.deleteFile) {
4318
5537
  const promptAbs = resolve(repoRoot, def.prompt);
4319
- if (existsSync14(promptAbs)) {
4320
- unlinkSync2(promptAbs);
5538
+ if (existsSync17(promptAbs)) {
5539
+ unlinkSync4(promptAbs);
4321
5540
  console.log(`deleted ${def.prompt}`);
4322
5541
  }
4323
5542
  } else {
@@ -4328,8 +5547,8 @@ function reviewersRemove(name, opts = {}) {
4328
5547
  }
4329
5548
  async function reviewersTest(name, diff) {
4330
5549
  const repoRoot = findRepoRoot();
4331
- const config = loadConfig(stampConfigFile(repoRoot));
4332
- if (!config.reviewers[name]) {
5550
+ const config2 = loadConfig(stampConfigFile(repoRoot));
5551
+ if (!config2.reviewers[name]) {
4333
5552
  throw new Error(
4334
5553
  `reviewer "${name}" is not configured. Run \`stamp reviewers list\`.`
4335
5554
  );
@@ -4346,12 +5565,12 @@ async function reviewersTest(name, diff) {
4346
5565
  );
4347
5566
  console.log(` prompt sourced from working tree (test/iteration use case)`);
4348
5567
  console.log();
4349
- const def = config.reviewers[name];
4350
- const promptPath = join8(repoRoot, def.prompt);
4351
- const systemPrompt = readFileSync8(promptPath, "utf8");
5568
+ const def = config2.reviewers[name];
5569
+ const promptPath = join10(repoRoot, def.prompt);
5570
+ const systemPrompt = readFileSync11(promptPath, "utf8");
4352
5571
  const result = await invokeReviewer({
4353
5572
  reviewer: name,
4354
- config,
5573
+ config: config2,
4355
5574
  repoRoot,
4356
5575
  diff: resolved.diff,
4357
5576
  base_sha: resolved.base_sha,
@@ -4368,14 +5587,14 @@ async function reviewersTest(name, diff) {
4368
5587
  }
4369
5588
  function reviewersShow(name, opts) {
4370
5589
  const repoRoot = findRepoRoot();
4371
- const config = loadConfig(stampConfigFile(repoRoot));
4372
- if (!config.reviewers[name]) {
5590
+ const config2 = loadConfig(stampConfigFile(repoRoot));
5591
+ if (!config2.reviewers[name]) {
4373
5592
  throw new Error(
4374
5593
  `reviewer "${name}" is not configured. Run \`stamp reviewers list\`.`
4375
5594
  );
4376
5595
  }
4377
5596
  const dbPath = stampStateDbPath(repoRoot);
4378
- if (!existsSync14(dbPath)) {
5597
+ if (!existsSync17(dbPath)) {
4379
5598
  console.log("No reviews recorded yet (no state.db).");
4380
5599
  return;
4381
5600
  }
@@ -4391,7 +5610,7 @@ function reviewersShow(name, opts) {
4391
5610
  const bar = "\u2500".repeat(72);
4392
5611
  console.log(bar);
4393
5612
  console.log(`reviewer: ${name}`);
4394
- console.log(`prompt: ${config.reviewers[name].prompt}`);
5613
+ console.log(`prompt: ${config2.reviewers[name].prompt}`);
4395
5614
  console.log(bar);
4396
5615
  if (stats.total === 0) {
4397
5616
  console.log(" no verdicts recorded yet");
@@ -4465,7 +5684,7 @@ async function reviewersFetch(reviewerName, opts) {
4465
5684
  opts.expectMcpSha
4466
5685
  );
4467
5686
  const reviewersDir = stampReviewersDir(repoRoot);
4468
- if (!existsSync14(reviewersDir)) {
5687
+ if (!existsSync17(reviewersDir)) {
4469
5688
  throw new Error(
4470
5689
  `${reviewersDir} does not exist \u2014 run \`stamp init\` first.`
4471
5690
  );
@@ -4478,7 +5697,7 @@ async function reviewersFetch(reviewerName, opts) {
4478
5697
  let tools;
4479
5698
  let mcpServers;
4480
5699
  if (configYaml !== null) {
4481
- const parsed = parseYaml4(configYaml) ?? {};
5700
+ const parsed = parseYaml6(configYaml) ?? {};
4482
5701
  if (Array.isArray(parsed.tools)) {
4483
5702
  tools = parseToolsLoose(parsed.tools);
4484
5703
  }
@@ -4486,7 +5705,7 @@ async function reviewersFetch(reviewerName, opts) {
4486
5705
  mcpServers = validateMcpServersFromSource(parsed.mcp_servers, source, ref);
4487
5706
  }
4488
5707
  }
4489
- const promptPath = join8(reviewersDir, `${reviewerName}.md`);
5708
+ const promptPath = join10(reviewersDir, `${reviewerName}.md`);
4490
5709
  const promptBytes = Buffer.from(promptText, "utf8");
4491
5710
  const promptSha = hashPromptBytes(promptBytes);
4492
5711
  const toolsSha = hashTools(tools);
@@ -4510,7 +5729,7 @@ async function reviewersFetch(reviewerName, opts) {
4510
5729
  mcpSha
4511
5730
  );
4512
5731
  }
4513
- writeFileSync10(promptPath, promptBytes);
5732
+ writeFileSync12(promptPath, promptBytes);
4514
5733
  const lock = {
4515
5734
  version: LOCK_FILE_VERSION,
4516
5735
  source,
@@ -4546,8 +5765,8 @@ async function reviewersFetch(reviewerName, opts) {
4546
5765
  function reviewersVerify(opts) {
4547
5766
  if (opts.only) requireValidReviewerName(opts.only);
4548
5767
  const repoRoot = findRepoRoot();
4549
- const config = loadConfig(stampConfigFile(repoRoot));
4550
- const names = opts.only ? [opts.only] : Object.keys(config.reviewers);
5768
+ const config2 = loadConfig(stampConfigFile(repoRoot));
5769
+ const names = opts.only ? [opts.only] : Object.keys(config2.reviewers);
4551
5770
  if (names.length === 0) {
4552
5771
  console.log("No reviewers configured.");
4553
5772
  return;
@@ -4560,7 +5779,7 @@ function reviewersVerify(opts) {
4560
5779
  let anyDrift = false;
4561
5780
  let anyLocked = false;
4562
5781
  for (const name of names) {
4563
- const def = config.reviewers[name];
5782
+ const def = config2.reviewers[name];
4564
5783
  if (!def) {
4565
5784
  console.error(
4566
5785
  `error: reviewer '${name}' is not in .stamp/config.yml. Add it with \`stamp reviewers add ${name}\` or remove its lock file.`
@@ -4735,11 +5954,11 @@ function buildConfigYamlHint(reviewerName, tools, mcpServers) {
4735
5954
  if (mcpServers && Object.keys(mcpServers).length > 0) {
4736
5955
  reviewerBlock.mcp_servers = mcpServers;
4737
5956
  }
4738
- return stringifyYaml2({ reviewers: { [reviewerName]: reviewerBlock } }).trimEnd();
5957
+ return stringifyYaml3({ reviewers: { [reviewerName]: reviewerBlock } }).trimEnd();
4739
5958
  }
4740
5959
  function launchEditor(path2) {
4741
5960
  const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? (process.platform === "win32" ? "notepad" : "vi");
4742
- const result = spawnSync6(editor, [path2], { stdio: "inherit" });
5961
+ const result = spawnSync7(editor, [path2], { stdio: "inherit" });
4743
5962
  if (result.error) {
4744
5963
  throw new Error(
4745
5964
  `failed to launch editor "${editor}": ${result.error.message}`
@@ -4751,22 +5970,22 @@ function launchEditor(path2) {
4751
5970
  }
4752
5971
 
4753
5972
  // src/commands/status.ts
4754
- import { existsSync as existsSync15 } from "fs";
5973
+ import { existsSync as existsSync18 } from "fs";
4755
5974
  function runStatus(opts) {
4756
5975
  const repoRoot = findRepoRoot();
4757
5976
  const configPath = stampConfigFile(repoRoot);
4758
- if (!existsSync15(configPath)) {
5977
+ if (!existsSync18(configPath)) {
4759
5978
  throw new Error(
4760
5979
  `no .stamp/config.yml at ${configPath}. Run \`stamp init\` first.`
4761
5980
  );
4762
5981
  }
4763
- const config = loadConfig(configPath);
5982
+ const config2 = loadConfig(configPath);
4764
5983
  const resolved = resolveDiff(opts.diff, repoRoot);
4765
5984
  const target = opts.into ?? inferTarget(opts.diff);
4766
- const rule = findBranchRule(config.branches, target);
5985
+ const rule = findBranchRule(config2.branches, target);
4767
5986
  if (!rule) {
4768
5987
  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.`
5988
+ `no branch rule for "${target}" in .stamp/config.yml. Configured branches: ${Object.keys(config2.branches).join(", ") || "(none)"}. Use --into <target> to override.`
4770
5989
  );
4771
5990
  }
4772
5991
  const db = openDb(stampStateDbPath(repoRoot));
@@ -4823,22 +6042,22 @@ function printGate(result, base_sha, head_sha) {
4823
6042
  }
4824
6043
 
4825
6044
  // src/commands/update.ts
4826
- import { spawnSync as spawnSync7 } from "child_process";
6045
+ import { spawnSync as spawnSync8 } from "child_process";
4827
6046
 
4828
6047
  // src/lib/version.ts
4829
- import { readFileSync as readFileSync9 } from "fs";
4830
- import { dirname as dirname4, join as join9 } from "path";
6048
+ import { readFileSync as readFileSync12 } from "fs";
6049
+ import { dirname as dirname5, join as join11 } from "path";
4831
6050
  import { fileURLToPath } from "url";
4832
6051
  function readPackageVersion() {
4833
- const here = dirname4(fileURLToPath(import.meta.url));
6052
+ const here = dirname5(fileURLToPath(import.meta.url));
4834
6053
  for (let dir = here, i = 0; i < 6; i++) {
4835
6054
  try {
4836
- const raw = readFileSync9(join9(dir, "package.json"), "utf8");
6055
+ const raw = readFileSync12(join11(dir, "package.json"), "utf8");
4837
6056
  const pkg = JSON.parse(raw);
4838
6057
  if (pkg.name === "@openthink/stamp" && pkg.version) return pkg.version;
4839
6058
  } catch {
4840
6059
  }
4841
- const parent = dirname4(dir);
6060
+ const parent = dirname5(dir);
4842
6061
  if (parent === dir) break;
4843
6062
  dir = parent;
4844
6063
  }
@@ -4864,7 +6083,7 @@ function runUpdate() {
4864
6083
  `);
4865
6084
  process.stdout.write(`checking npm registry for latest...
4866
6085
  `);
4867
- const viewResult = spawnSync7("npm", ["view", PKG_NAME, "version"], {
6086
+ const viewResult = spawnSync8("npm", ["view", PKG_NAME, "version"], {
4868
6087
  encoding: "utf8"
4869
6088
  });
4870
6089
  if (viewResult.error || viewResult.status !== 0) {
@@ -4898,7 +6117,7 @@ function runUpdate() {
4898
6117
  }
4899
6118
  process.stdout.write(`installing ${PKG_NAME}@${latest}...
4900
6119
  `);
4901
- const installResult = spawnSync7(
6120
+ const installResult = spawnSync8(
4902
6121
  "npm",
4903
6122
  ["install", "-g", `${PKG_NAME}@${latest}`],
4904
6123
  { stdio: "inherit" }
@@ -4919,9 +6138,9 @@ through that tool instead \u2014 this command only uses 'npm install -g'.`
4919
6138
  }
4920
6139
 
4921
6140
  // src/commands/verify.ts
4922
- import { execFileSync as execFileSync2, spawnSync as spawnSync8 } from "child_process";
6141
+ import { execFileSync as execFileSync2, spawnSync as spawnSync9 } from "child_process";
4923
6142
  function loadConfigAtSha(sha, repoRoot) {
4924
- const result = spawnSync8(
6143
+ const result = spawnSync9(
4925
6144
  "git",
4926
6145
  ["show", `${sha}:.stamp/config.yml`],
4927
6146
  { cwd: repoRoot, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }
@@ -4938,7 +6157,7 @@ function loadConfigAtSha(sha, repoRoot) {
4938
6157
  }
4939
6158
  function runVerify(sha) {
4940
6159
  const repoRoot = findRepoRoot();
4941
- const config = loadConfigAtSha(sha, repoRoot);
6160
+ const config2 = loadConfigAtSha(sha, repoRoot);
4942
6161
  const commitMessage2 = git2(["show", "-s", "--format=%B", sha], repoRoot);
4943
6162
  const parsed = parseCommitAttestation(commitMessage2);
4944
6163
  if (!parsed) {
@@ -4983,7 +6202,7 @@ function runVerify(sha) {
4983
6202
  `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
6203
  );
4985
6204
  }
4986
- const rule = findBranchRule(config.branches, payload.target_branch);
6205
+ const rule = findBranchRule(config2.branches, payload.target_branch);
4987
6206
  if (!rule) {
4988
6207
  fail(
4989
6208
  sha,
@@ -5029,12 +6248,12 @@ function runVerify(sha) {
5029
6248
  );
5030
6249
  }
5031
6250
  if ((payload.schema_version ?? 1) >= 2) {
5032
- verifyReviewerHashes(sha, payload, repoRoot, config);
6251
+ verifyReviewerHashes(sha, payload, repoRoot, config2);
5033
6252
  }
5034
6253
  printSuccess2(sha, payload);
5035
6254
  }
5036
- function verifyReviewerHashes(sha, payload, repoRoot, config) {
5037
- const reviewers2 = config.reviewers;
6255
+ function verifyReviewerHashes(sha, payload, repoRoot, config2) {
6256
+ const reviewers2 = config2.reviewers;
5038
6257
  if (Object.keys(reviewers2).length === 0) {
5039
6258
  fail(
5040
6259
  sha,
@@ -5167,6 +6386,9 @@ program.command("init").description(
5167
6386
  "--remote <name>",
5168
6387
  "remote name to inspect for deployment-shape detection (default: origin)",
5169
6388
  "origin"
6389
+ ).option(
6390
+ "--no-oteam",
6391
+ "bypass the oteam-detection prompt that offers to fill stamp.host in ~/.open-team/config.json"
5170
6392
  ).action(
5171
6393
  (opts) => {
5172
6394
  try {
@@ -5187,7 +6409,8 @@ program.command("init").description(
5187
6409
  bootstrapCommit: opts.bootstrapCommit,
5188
6410
  ghProtect: opts.ghProtect,
5189
6411
  mode,
5190
- remote: opts.remote
6412
+ remote: opts.remote,
6413
+ oteam: opts.oteam
5191
6414
  });
5192
6415
  } catch (err) {
5193
6416
  const message = err instanceof Error ? err.message : String(err);
@@ -5230,8 +6453,8 @@ program.command("bootstrap").description(
5230
6453
  }
5231
6454
  }
5232
6455
  );
5233
- program.command("provision <name>").description(
5234
- "single-command server-gated repo setup: provision a bare repo on the stamp server (~/.stamp/server.yml or --server), clone it, run bootstrap, optionally create a GitHub mirror + apply the Ruleset"
6456
+ program.command("provision [name]").description(
6457
+ "single-command server-gated repo setup: provision a bare repo on the stamp server (~/.stamp/server.yml or --server), clone it, run bootstrap, optionally create a GitHub mirror + apply the Ruleset. With --migrate-bypass, migrate an existing server-gated repo's Ruleset bypass from OrganizationAdmin to a per-repo DeployKey actor (cwd's .stamp/mirror.yml identifies the target; <name> is ignored)."
5235
6458
  ).option(
5236
6459
  "--server <host:port>",
5237
6460
  "override ~/.stamp/server.yml with an inline endpoint"
@@ -5247,11 +6470,22 @@ program.command("provision <name>").description(
5247
6470
  ).option("--no-mirror", "skip GitHub mirror creation + .stamp/mirror.yml").option("--no-ruleset", "skip applying the GitHub Ruleset on the mirror").option("--dry-run", "print the plan without making changes").option(
5248
6471
  "--migrate-existing",
5249
6472
  "brownfield: migrate the existing repo at cwd (with .stamp/ committed and origin \u2192 github) to server-gated; preserves history, renames origin \u2192 github, points new origin at the stamp server"
6473
+ ).option(
6474
+ "--migrate-bypass",
6475
+ "migrate an existing server-gated repo's stamp-mirror-only Ruleset bypass actor from OrganizationAdmin to a per-repo DeployKey. Identifies the target via cwd's .stamp/mirror.yml. Additive by default (DeployKey added alongside existing actors); pair with --remove-orgadmin to also strip OrganizationAdmin from the bypass list"
6476
+ ).option(
6477
+ "--remove-orgadmin",
6478
+ "under --migrate-bypass, also remove OrganizationAdmin from the ruleset's bypass list. Verify the DeployKey transport works (one stamp push) before running this \u2014 there is no automated push-verification step"
5250
6479
  ).action(
5251
6480
  async (name, opts) => {
5252
6481
  try {
5253
6482
  await runProvision({
5254
- name,
6483
+ // ProvisionOptions.name is typed `string` so the rest of the
6484
+ // downstream readers don't have to narrow. Empty placeholder
6485
+ // for --migrate-bypass (which doesn't read it); the validation
6486
+ // block in runProvision requires a non-empty name in all other
6487
+ // modes.
6488
+ name: name ?? "",
5255
6489
  server: opts.server,
5256
6490
  org: opts.org,
5257
6491
  into: opts.into,
@@ -5259,7 +6493,9 @@ program.command("provision <name>").description(
5259
6493
  noMirror: !opts.mirror,
5260
6494
  noRuleset: !opts.ruleset,
5261
6495
  dryRun: opts.dryRun,
5262
- migrateExisting: opts.migrateExisting
6496
+ migrateExisting: opts.migrateExisting,
6497
+ migrateBypass: opts.migrateBypass,
6498
+ removeOrgadmin: opts.removeOrgadmin
5263
6499
  });
5264
6500
  } catch (err) {
5265
6501
  const message = err instanceof Error ? err.message : String(err);
@@ -5294,6 +6530,57 @@ server.command("config [host:port]").description(
5294
6530
  }
5295
6531
  }
5296
6532
  );
6533
+ server.command("pubkey").description(
6534
+ "print a stamp-server-managed GitHub mirror-push deploy-key public half \u2014 single OpenSSH line, pipe-able into `gh api -X POST /repos/:o/:r/keys --field key=@-` to register as a deploy key. Without --repo, returns the legacy shared key (back-compat). With --repo <owner/repo>, returns a per-repo key that the server lazily generates on first request \u2014 preferred for new migrations because GitHub rejects re-registering the same key on a second repo."
6535
+ ).option(
6536
+ "--server <host:port>",
6537
+ "override ~/.stamp/server.yml for this call"
6538
+ ).option(
6539
+ "--repo <owner>/<repo>",
6540
+ "fetch the per-repo deploy key for this GitHub mirror (lazy-generated server-side on first request)"
6541
+ ).action((opts) => {
6542
+ try {
6543
+ runServerPubkey({ server: opts.server, repo: opts.repo });
6544
+ } catch (err) {
6545
+ handleCliError(err);
6546
+ }
6547
+ });
6548
+ var config = program.command("config").description(
6549
+ "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`."
6550
+ );
6551
+ var configReviewers = config.command("reviewers").description(
6552
+ "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`."
6553
+ );
6554
+ configReviewers.command("set <reviewer> <model-id>").description(
6555
+ "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."
6556
+ ).action((reviewer, modelId) => {
6557
+ try {
6558
+ runConfigReviewersSet({ reviewer, modelId });
6559
+ } catch (err) {
6560
+ handleCliError(err);
6561
+ }
6562
+ });
6563
+ configReviewers.command("clear [reviewer]").description(
6564
+ "remove a reviewer's model pin (resolver falls back to the SDK default), or pass --all to delete the whole ~/.stamp/config.yml."
6565
+ ).option(
6566
+ "--all",
6567
+ "remove the entire ~/.stamp/config.yml file (every reviewer falls back to the SDK default)"
6568
+ ).action((reviewer, opts) => {
6569
+ try {
6570
+ runConfigReviewersClear({ reviewer, all: opts.all });
6571
+ } catch (err) {
6572
+ handleCliError(err);
6573
+ }
6574
+ });
6575
+ configReviewers.command("show").description(
6576
+ "print the resolved per-reviewer model config (or note that no config is set and which defaults will apply)."
6577
+ ).action(() => {
6578
+ try {
6579
+ runConfigReviewersShow();
6580
+ } catch (err) {
6581
+ handleCliError(err);
6582
+ }
6583
+ });
5297
6584
  var serverRepo = program.command("server-repos").description(
5298
6585
  "manage bare repos on the stamp server (list / delete / restore). Uses ~/.stamp/server.yml or --server."
5299
6586
  );
@@ -5367,9 +6654,12 @@ program.command("status").description("show gate state for a diff; exit 0 if gat
5367
6654
  handleCliError(err);
5368
6655
  }
5369
6656
  });
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) => {
6657
+ program.command("merge <branch>").description("merge <branch> into --into <target> if the gate is open").requiredOption("--into <target>", "target branch to merge into").option(
6658
+ "-y, --yes",
6659
+ "skip the operator-confirmation prompt for this invocation (equivalent to STAMP_REQUIRE_HUMAN_MERGE=0; see audit H1)"
6660
+ ).action((branch, opts) => {
5371
6661
  try {
5372
- runMerge({ branch, into: opts.into });
6662
+ runMerge({ branch, into: opts.into, yes: opts.yes });
5373
6663
  } catch (err) {
5374
6664
  handleCliError(err);
5375
6665
  }
@@ -5392,13 +6682,13 @@ program.command("update").description(
5392
6682
  "upgrade stamp to the latest npm release (runs 'npm install -g @openthink/stamp@latest')"
5393
6683
  ).action(() => wrap(() => runUpdate()));
5394
6684
  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."
6685
+ "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
6686
  ).requiredOption(
5397
6687
  "--older-than <duration>",
5398
6688
  "retention cutoff, e.g. 30d (days), 12h (hours), 90m (minutes)"
5399
6689
  ).option(
5400
6690
  "--dry-run",
5401
- "print the per-reviewer breakdown that would be pruned without modifying the DB"
6691
+ "print the per-reviewer breakdown of state.db rows AND the list of spool file paths that would be pruned, without modifying anything"
5402
6692
  ).action((opts) => {
5403
6693
  try {
5404
6694
  runPrune({ olderThan: opts.olderThan, dryRun: opts.dryRun });
@@ -5408,7 +6698,7 @@ program.command("prune").description(
5408
6698
  });
5409
6699
  program.command("ui").description("launch the interactive terminal UI").action(async () => {
5410
6700
  try {
5411
- const { runUi } = await import("./ui-4V2HDHOS.js");
6701
+ const { runUi } = await import("./ui-TKLZWCPL.js");
5412
6702
  runUi();
5413
6703
  } catch (err) {
5414
6704
  handleCliError(err);