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