@openthink/stamp 1.5.2 → 1.6.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
@@ -490,10 +490,20 @@ function validateConfig(input) {
490
490
  }
491
491
  require_human_merge = r.require_human_merge;
492
492
  }
493
+ let strict_base;
494
+ if (r.strict_base !== void 0) {
495
+ if (typeof r.strict_base !== "boolean") {
496
+ throw new Error(
497
+ `config.branches.${name}.strict_base must be a boolean`
498
+ );
499
+ }
500
+ strict_base = r.strict_base;
501
+ }
493
502
  branches[name] = {
494
503
  required: r.required.map(String),
495
504
  ...required_checks ? { required_checks } : {},
496
- ...require_human_merge !== void 0 ? { require_human_merge } : {}
505
+ ...require_human_merge !== void 0 ? { require_human_merge } : {},
506
+ ...strict_base !== void 0 ? { strict_base } : {}
497
507
  };
498
508
  }
499
509
  const reviewers2 = {};
@@ -2943,7 +2953,7 @@ function branchExists(name, cwd) {
2943
2953
 
2944
2954
  // src/commands/init.ts
2945
2955
  import { existsSync as existsSync9, writeFileSync as writeFileSync7 } from "fs";
2946
- import { join as join5 } from "path";
2956
+ import { dirname as dirname4, join as join5 } from "path";
2947
2957
 
2948
2958
  // src/lib/ghRuleset.ts
2949
2959
  import { spawnSync as spawnSync3 } from "child_process";
@@ -3476,6 +3486,11 @@ For local-only / advisory use against this GitHub repo: re-run with \`stamp init
3476
3486
  const dbExisted = existsSync9(stateDbPath);
3477
3487
  const db = openDb(stateDbPath);
3478
3488
  db.close();
3489
+ const prCheckResult = maybeWriteVerifyWorkflow(
3490
+ repoRoot,
3491
+ opts.prCheck,
3492
+ effectiveMode
3493
+ );
3479
3494
  const agentsMdAction = opts.agentsMd === false ? "skipped" : ensureAgentsMd(repoRoot, effectiveMode);
3480
3495
  const claudeMdAction = opts.claudeMd === false ? "skipped" : ensureClaudeMd(repoRoot);
3481
3496
  const scaffoldOrSync = alreadyHasConfig ? "sync" : "scaffold";
@@ -3506,6 +3521,11 @@ For local-only / advisory use against this GitHub repo: re-run with \`stamp init
3506
3521
  ` CLAUDE.md: ${claudeMdAction} at repo root (auto-loaded by Claude Code)`
3507
3522
  );
3508
3523
  }
3524
+ if (prCheckResult.action !== "skipped") {
3525
+ console.log(
3526
+ ` PR check: ${prCheckResult.action} ${prCheckResult.path} (stamp/verify-attestation@${VERIFY_ACTION_REF})`
3527
+ );
3528
+ }
3509
3529
  console.log(
3510
3530
  ` models: ${userCfg.path}${userCfg.created ? " (created \u2014 Sonnet defaults; tweak with `stamp config reviewers set <name> <model-id>`)" : " (existing)"}`
3511
3531
  );
@@ -3524,6 +3544,11 @@ For local-only / advisory use against this GitHub repo: re-run with \`stamp init
3524
3544
  console.error(warning);
3525
3545
  console.error();
3526
3546
  }
3547
+ if (prCheckResult.action !== "skipped") {
3548
+ console.log(
3549
+ "PR-check mode notes:\n - The workflow runs the verifier on every PR but does NOT block\n merge by itself. Wire it into branch protection so green-check\n is required before merge:\n Settings \u2192 Branches \u2192 main \u2192 Protect \u2192 Require status checks \u2192\n add `stamp verify` (the workflow's job name) as required.\n - Operator workflow per PR: stamp review \u2192 stamp attest --into main\n --push origin \u2192 open PR \u2192 check goes green \u2192 human merges.\n"
3550
+ );
3551
+ }
3527
3552
  if (scaffoldOrSync === "scaffold") {
3528
3553
  if (effectiveMode === "local-only") {
3529
3554
  console.log(
@@ -3811,6 +3836,57 @@ function resolveMode(userMode, remoteClass) {
3811
3836
  return { effectiveMode: "local-only", warnings };
3812
3837
  }
3813
3838
  }
3839
+ var VERIFY_ACTION_REF = "v1.6.0";
3840
+ function maybeWriteVerifyWorkflow(repoRoot, prCheckOpt, effectiveMode) {
3841
+ const path2 = ".github/workflows/stamp-verify.yml";
3842
+ const fullPath = join5(repoRoot, path2);
3843
+ const defaultForMode = effectiveMode !== "server-gated";
3844
+ const shouldWrite = prCheckOpt ?? defaultForMode;
3845
+ if (!shouldWrite) return { action: "skipped", path: path2 };
3846
+ if (existsSync9(fullPath)) {
3847
+ return { action: "exists", path: path2 };
3848
+ }
3849
+ ensureDir(dirname4(fullPath));
3850
+ writeFileSync7(fullPath, renderVerifyWorkflow());
3851
+ return { action: "wrote", path: path2 };
3852
+ }
3853
+ function renderVerifyWorkflow() {
3854
+ return [
3855
+ "name: stamp verify",
3856
+ "",
3857
+ `# Runs stamp/verify-attestation@${VERIFY_ACTION_REF} on every PR.`,
3858
+ "# Wire `stamp verify` (this job's name) into branch protection",
3859
+ "# Required Status Checks to make a green attestation a merge",
3860
+ "# precondition.",
3861
+ "",
3862
+ "on:",
3863
+ " pull_request:",
3864
+ " branches: [main]",
3865
+ "",
3866
+ "permissions:",
3867
+ " # checkout + read .stamp/{config,trusted-keys}/ from the base ref",
3868
+ " contents: read",
3869
+ " # for the workflow's check-run summary on the PR",
3870
+ " checks: write",
3871
+ "",
3872
+ "jobs:",
3873
+ " stamp-verify:",
3874
+ " name: stamp verify",
3875
+ " runs-on: ubuntu-latest",
3876
+ " timeout-minutes: 5",
3877
+ " steps:",
3878
+ " - name: checkout",
3879
+ " uses: actions/checkout@v4",
3880
+ " with:",
3881
+ " # Full history so the action can fetch the base ref's tree",
3882
+ " # and resolve refs/stamp/attestations/*. Shallow clones",
3883
+ " # would force per-step refetches.",
3884
+ " fetch-depth: 0",
3885
+ " - name: stamp/verify-attestation",
3886
+ ` uses: OpenThinkAi/stamp-cli/.github/actions/verify-attestation@${VERIFY_ACTION_REF}`,
3887
+ ""
3888
+ ].join("\n");
3889
+ }
3814
3890
 
3815
3891
  // src/commands/invites.ts
3816
3892
  import { spawnSync as spawnSync5 } from "child_process";
@@ -4346,7 +4422,7 @@ import { parse as parseYaml5 } from "yaml";
4346
4422
  // src/commands/server.ts
4347
4423
  import { spawnSync as spawnSync6 } from "child_process";
4348
4424
  import { existsSync as existsSync11, mkdirSync as mkdirSync4, renameSync as renameSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync8 } from "fs";
4349
- import { dirname as dirname4 } from "path";
4425
+ import { dirname as dirname5 } from "path";
4350
4426
  import { stringify as stringifyYaml2 } from "yaml";
4351
4427
 
4352
4428
  // src/lib/perRepoKey.ts
@@ -4505,7 +4581,7 @@ function writeConfig(opts) {
4505
4581
  repoRootPrefix: opts.repoRootPrefix
4506
4582
  });
4507
4583
  const path2 = userServerConfigPath();
4508
- const dir = dirname4(path2);
4584
+ const dir = dirname5(path2);
4509
4585
  if (!existsSync11(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
4510
4586
  const tmp = `${path2}.tmp.${process.pid}`;
4511
4587
  writeFileSync8(tmp, yaml, { mode: 384 });
@@ -5858,6 +5934,291 @@ function printReviewHistory(repoRoot, limit, diff) {
5858
5934
  );
5859
5935
  }
5860
5936
 
5937
+ // src/commands/attest.ts
5938
+ import { spawnSync as spawnSync12 } from "child_process";
5939
+ import { createHash as createHash7 } from "crypto";
5940
+
5941
+ // src/lib/patchId.ts
5942
+ import { spawnSync as spawnSync10 } from "child_process";
5943
+ function patchIdForSpan(base_sha, head_sha, repoRoot) {
5944
+ const diff = spawnSync10("git", ["diff", `${base_sha}..${head_sha}`], {
5945
+ cwd: repoRoot,
5946
+ // Buffer because diffs can be large and stable across encodings;
5947
+ // patch-id consumes the raw bytes.
5948
+ encoding: "buffer",
5949
+ maxBuffer: 64 * 1024 * 1024
5950
+ });
5951
+ if (diff.status !== 0) {
5952
+ const stderr = diff.stderr?.toString("utf8") ?? "";
5953
+ throw new Error(
5954
+ `git diff ${base_sha}..${head_sha} failed: ${stderr.trim() || "exit " + diff.status}`
5955
+ );
5956
+ }
5957
+ if (diff.stdout.length === 0) {
5958
+ throw new Error(
5959
+ `empty diff between ${base_sha.slice(0, 8)}..${head_sha.slice(0, 8)} \u2014 nothing to attest`
5960
+ );
5961
+ }
5962
+ const result = spawnSync10("git", ["patch-id", "--stable"], {
5963
+ cwd: repoRoot,
5964
+ input: diff.stdout,
5965
+ encoding: "utf8",
5966
+ maxBuffer: 1024
5967
+ });
5968
+ if (result.status !== 0) {
5969
+ const stderr = result.stderr ?? "";
5970
+ throw new Error(
5971
+ `git patch-id --stable failed: ${stderr.trim() || "exit " + result.status}`
5972
+ );
5973
+ }
5974
+ const firstLine = (result.stdout ?? "").trim().split("\n")[0] ?? "";
5975
+ const token = firstLine.split(/\s+/)[0];
5976
+ if (!token || !/^[0-9a-f]{40}$/.test(token)) {
5977
+ throw new Error(
5978
+ `unexpected git patch-id output: ${JSON.stringify(result.stdout.slice(0, 200))}`
5979
+ );
5980
+ }
5981
+ return token;
5982
+ }
5983
+
5984
+ // src/lib/prAttestation.ts
5985
+ import { spawnSync as spawnSync11 } from "child_process";
5986
+ var PR_ATTESTATION_SCHEMA_VERSION = 2;
5987
+ var MIN_ACCEPTED_PR_ATTESTATION_VERSION = 1;
5988
+ var MAX_PR_ATTESTATION_BYTES = 64 * 1024;
5989
+ function serializePayload2(p) {
5990
+ return Buffer.from(JSON.stringify(p), "utf8");
5991
+ }
5992
+ function serializeEnvelope(env) {
5993
+ return Buffer.from(JSON.stringify(env), "utf8");
5994
+ }
5995
+ function parseEnvelope(bytes) {
5996
+ if (bytes.length === 0 || bytes.length > MAX_PR_ATTESTATION_BYTES) return null;
5997
+ let parsed;
5998
+ try {
5999
+ parsed = JSON.parse(bytes.toString("utf8"));
6000
+ } catch {
6001
+ return null;
6002
+ }
6003
+ if (!parsed || typeof parsed !== "object") return null;
6004
+ const env = parsed;
6005
+ if (typeof env.signature !== "string" || !env.signature) return null;
6006
+ if (!env.payload || typeof env.payload !== "object") return null;
6007
+ const p = env.payload;
6008
+ if (typeof p.schema_version !== "number" || p.schema_version < MIN_ACCEPTED_PR_ATTESTATION_VERSION || typeof p.patch_id !== "string" || typeof p.base_sha !== "string" || typeof p.head_sha !== "string" || typeof p.target_branch !== "string" || !Array.isArray(p.approvals) || !Array.isArray(p.checks) || typeof p.signer_key_id !== "string") {
6009
+ return null;
6010
+ }
6011
+ if (p.schema_version >= 2 && typeof p.target_branch_tip_sha !== "string") {
6012
+ return null;
6013
+ }
6014
+ return env;
6015
+ }
6016
+ function attestationRefName(patch_id) {
6017
+ if (!/^[0-9a-f]{40}$/.test(patch_id)) {
6018
+ throw new Error(
6019
+ `patch_id must be a 40-char lowercase hex string (got ${JSON.stringify(patch_id)})`
6020
+ );
6021
+ }
6022
+ return `refs/stamp/attestations/${patch_id}`;
6023
+ }
6024
+ function writeAttestationRef(envelope, repoRoot) {
6025
+ const ref = attestationRefName(envelope.payload.patch_id);
6026
+ const bytes = serializeEnvelope(envelope);
6027
+ const hashObject = spawnSync11(
6028
+ "git",
6029
+ ["hash-object", "-w", "--stdin"],
6030
+ { cwd: repoRoot, input: bytes, encoding: "utf8" }
6031
+ );
6032
+ if (hashObject.status !== 0) {
6033
+ const stderr = hashObject.stderr ?? "";
6034
+ throw new Error(
6035
+ `git hash-object failed: ${stderr.trim() || "exit " + hashObject.status}`
6036
+ );
6037
+ }
6038
+ const blob_sha = (hashObject.stdout ?? "").trim();
6039
+ if (!/^[0-9a-f]{40}$/.test(blob_sha)) {
6040
+ throw new Error(`unexpected git hash-object output: ${JSON.stringify(blob_sha)}`);
6041
+ }
6042
+ const updateRef = spawnSync11(
6043
+ "git",
6044
+ ["update-ref", ref, blob_sha],
6045
+ { cwd: repoRoot, encoding: "utf8" }
6046
+ );
6047
+ if (updateRef.status !== 0) {
6048
+ const stderr = updateRef.stderr ?? "";
6049
+ throw new Error(
6050
+ `git update-ref ${ref} failed: ${stderr.trim() || "exit " + updateRef.status}`
6051
+ );
6052
+ }
6053
+ return { ref, blob_sha };
6054
+ }
6055
+ function readAttestationRef(patch_id, repoRoot) {
6056
+ const ref = attestationRefName(patch_id);
6057
+ const showRef = spawnSync11(
6058
+ "git",
6059
+ ["show-ref", "--verify", "--quiet", ref],
6060
+ { cwd: repoRoot }
6061
+ );
6062
+ if (showRef.status !== 0) return null;
6063
+ const cat = spawnSync11(
6064
+ "git",
6065
+ ["cat-file", "blob", ref],
6066
+ { cwd: repoRoot, encoding: "buffer", maxBuffer: MAX_PR_ATTESTATION_BYTES * 2 }
6067
+ );
6068
+ if (cat.status !== 0) return null;
6069
+ return parseEnvelope(cat.stdout);
6070
+ }
6071
+
6072
+ // src/commands/attest.ts
6073
+ function runAttest(opts) {
6074
+ const repoRoot = findRepoRoot();
6075
+ const config2 = loadConfig(stampConfigFile(repoRoot));
6076
+ const rule = findBranchRule(config2.branches, opts.into);
6077
+ if (!rule) {
6078
+ throw new Error(
6079
+ `no branch rule for "${opts.into}" in .stamp/config.yml. Configured branches: ${Object.keys(config2.branches).join(", ") || "(none)"}.`
6080
+ );
6081
+ }
6082
+ const branchRef = opts.branch ?? "HEAD";
6083
+ const revspec = `${opts.into}..${branchRef}`;
6084
+ const resolved = resolveDiff(revspec, repoRoot);
6085
+ const db = openDb(stampStateDbPath(repoRoot));
6086
+ let approvals;
6087
+ try {
6088
+ const reviews = latestReviews(db, resolved.base_sha, resolved.head_sha);
6089
+ const byReviewer = new Map(reviews.map((r) => [r.reviewer, r]));
6090
+ const missing = [];
6091
+ for (const r of rule.required) {
6092
+ const rev = byReviewer.get(r);
6093
+ if (!rev || rev.verdict !== "approved") missing.push(r);
6094
+ }
6095
+ if (missing.length > 0) {
6096
+ throw new Error(
6097
+ `gate CLOSED: missing approved verdicts for: ${missing.join(", ")}. Run \`stamp status --diff ${revspec}\` to inspect, then \`stamp review --diff ${revspec}\` to review.`
6098
+ );
6099
+ }
6100
+ approvals = rule.required.map((name) => {
6101
+ const rev = byReviewer.get(name);
6102
+ const toolCalls = redactToolCallsForAttestation(
6103
+ parseToolCalls(rev.tool_calls)
6104
+ );
6105
+ return {
6106
+ reviewer: rev.reviewer,
6107
+ verdict: rev.verdict,
6108
+ review_sha: hashHex(rev.issues ?? ""),
6109
+ ...toolCalls.length > 0 ? { tool_calls: toolCalls } : {}
6110
+ };
6111
+ });
6112
+ } finally {
6113
+ db.close();
6114
+ }
6115
+ const baseConfigYaml = showAtRef(
6116
+ resolved.base_sha,
6117
+ ".stamp/config.yml",
6118
+ repoRoot
6119
+ );
6120
+ const baseReviewers = readReviewersFromYaml(baseConfigYaml);
6121
+ approvals = approvals.map((a) => {
6122
+ const def = baseReviewers[a.reviewer];
6123
+ if (!def) {
6124
+ throw new Error(
6125
+ `reviewer "${a.reviewer}" approved the diff but is not defined in .stamp/config.yml at base ${resolved.base_sha.slice(0, 8)}. This shouldn't happen \u2014 runReview reads from the same base. File a bug at https://github.com/OpenThinkAi/stamp-cli/issues.`
6126
+ );
6127
+ }
6128
+ const promptText = showAtRef(resolved.base_sha, def.prompt, repoRoot);
6129
+ const source = readReviewerSource2(a.reviewer, repoRoot);
6130
+ return {
6131
+ ...a,
6132
+ prompt_sha256: hashPromptBytes(Buffer.from(promptText, "utf8")),
6133
+ tools_sha256: hashTools(def.tools),
6134
+ mcp_sha256: hashMcpServers(def.mcp_servers),
6135
+ ...source ? { reviewer_source: source } : {}
6136
+ };
6137
+ });
6138
+ const patch_id = patchIdForSpan(resolved.base_sha, resolved.head_sha, repoRoot);
6139
+ const target_branch_tip_sha = runGit(
6140
+ ["rev-parse", `${opts.into}^{commit}`],
6141
+ repoRoot
6142
+ ).trim();
6143
+ const { keypair } = ensureUserKeypair();
6144
+ const payload = {
6145
+ schema_version: PR_ATTESTATION_SCHEMA_VERSION,
6146
+ patch_id,
6147
+ base_sha: resolved.base_sha,
6148
+ head_sha: resolved.head_sha,
6149
+ target_branch: opts.into,
6150
+ target_branch_tip_sha,
6151
+ approvals,
6152
+ checks: [],
6153
+ // Phase-1 deliberate omission — see file-level comment.
6154
+ signer_key_id: keypair.fingerprint
6155
+ };
6156
+ const signature = signBytes(keypair.privateKeyPem, serializePayload2(payload));
6157
+ const { ref, blob_sha } = writeAttestationRef(
6158
+ { payload, signature },
6159
+ repoRoot
6160
+ );
6161
+ const bar = "\u2500".repeat(72);
6162
+ console.log(bar);
6163
+ console.log(`attested ${branchRef} for merge into '${opts.into}'`);
6164
+ console.log(bar);
6165
+ console.log(` patch-id: ${patch_id}`);
6166
+ console.log(
6167
+ ` base\u2192head: ${resolved.base_sha.slice(0, 8)} \u2192 ${resolved.head_sha.slice(0, 8)}`
6168
+ );
6169
+ console.log(` signed by: ${keypair.fingerprint}`);
6170
+ console.log(` approvals: ${approvals.map((a) => a.reviewer).join(", ")}`);
6171
+ console.log(` ref: ${ref}`);
6172
+ console.log(` blob: ${blob_sha.slice(0, 12)}`);
6173
+ console.log(bar);
6174
+ if (opts.pushTo) {
6175
+ pushBranchAndAttestation(opts.pushTo, ref, repoRoot);
6176
+ console.log(
6177
+ `
6178
+ \u2713 pushed branch + attestation ref to ${opts.pushTo}. Open the PR; stamp/verify-attestation@v1 will look up refs/stamp/attestations/<patch-id> from your head SHA's diff against the base.`
6179
+ );
6180
+ } else {
6181
+ console.log(
6182
+ `
6183
+ Next: push the branch + attestation ref to your remote, open a PR, and let stamp/verify-attestation@v1 (the GH Action) confirm it. To do both pushes in one shot:
6184
+
6185
+ git push <remote> HEAD ${ref}
6186
+
6187
+ Or re-run with --push <remote> next time.`
6188
+ );
6189
+ }
6190
+ }
6191
+ function pushBranchAndAttestation(remote, attestationRef, repoRoot) {
6192
+ const result = spawnSync12(
6193
+ "git",
6194
+ ["push", "--atomic", remote, "HEAD", attestationRef],
6195
+ { cwd: repoRoot, stdio: "inherit" }
6196
+ );
6197
+ if (result.error) throw result.error;
6198
+ if (result.status !== 0) {
6199
+ const exit = result.status === null ? "(killed by signal)" : `exit ${result.status}`;
6200
+ throw new Error(
6201
+ `git push --atomic ${remote} HEAD ${attestationRef} failed (${exit}). The attestation ref is still in the local repo at ${attestationRef} \u2014 re-run with --push ${remote} after fixing the cause, or push manually.`
6202
+ );
6203
+ }
6204
+ }
6205
+ function hashHex(s) {
6206
+ return createHash7("sha256").update(s, "utf8").digest("hex");
6207
+ }
6208
+ function readReviewerSource2(reviewerName, repoRoot) {
6209
+ const path2 = `.stamp/reviewers/${reviewerName}.lock.json`;
6210
+ if (!pathExistsAtRef("HEAD", path2, repoRoot)) return null;
6211
+ const raw = runGit(["show", `HEAD:${path2}`], repoRoot);
6212
+ try {
6213
+ const parsed = JSON.parse(raw);
6214
+ if (typeof parsed.source === "string" && typeof parsed.ref === "string") {
6215
+ return { source: parsed.source, ref: parsed.ref };
6216
+ }
6217
+ } catch {
6218
+ }
6219
+ return null;
6220
+ }
6221
+
5861
6222
  // src/commands/prune.ts
5862
6223
  import { existsSync as existsSync16, readdirSync as readdirSync4, statSync as statSync2, unlinkSync as unlinkSync3 } from "fs";
5863
6224
  import { join as join10 } from "path";
@@ -6123,7 +6484,7 @@ function loadOrEmpty() {
6123
6484
  }
6124
6485
 
6125
6486
  // src/commands/reviewers.ts
6126
- import { spawnSync as spawnSync10 } from "child_process";
6487
+ import { spawnSync as spawnSync13 } from "child_process";
6127
6488
  import {
6128
6489
  existsSync as existsSync19,
6129
6490
  readFileSync as readFileSync13,
@@ -6767,7 +7128,7 @@ function buildConfigYamlHint(reviewerName, tools, mcpServers) {
6767
7128
  }
6768
7129
  function launchEditor(path2) {
6769
7130
  const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? (process.platform === "win32" ? "notepad" : "vi");
6770
- const result = spawnSync10(editor, [path2], { stdio: "inherit" });
7131
+ const result = spawnSync13(editor, [path2], { stdio: "inherit" });
6771
7132
  if (result.error) {
6772
7133
  throw new Error(
6773
7134
  `failed to launch editor "${editor}": ${result.error.message}`
@@ -6851,14 +7212,14 @@ function printGate(result, base_sha, head_sha) {
6851
7212
  }
6852
7213
 
6853
7214
  // src/commands/update.ts
6854
- import { spawnSync as spawnSync11 } from "child_process";
7215
+ import { spawnSync as spawnSync14 } from "child_process";
6855
7216
 
6856
7217
  // src/lib/version.ts
6857
7218
  import { readFileSync as readFileSync14 } from "fs";
6858
- import { dirname as dirname5, join as join13 } from "path";
7219
+ import { dirname as dirname6, join as join13 } from "path";
6859
7220
  import { fileURLToPath } from "url";
6860
7221
  function readPackageVersion() {
6861
- const here = dirname5(fileURLToPath(import.meta.url));
7222
+ const here = dirname6(fileURLToPath(import.meta.url));
6862
7223
  for (let dir = here, i = 0; i < 6; i++) {
6863
7224
  try {
6864
7225
  const raw = readFileSync14(join13(dir, "package.json"), "utf8");
@@ -6866,7 +7227,7 @@ function readPackageVersion() {
6866
7227
  if (pkg.name === "@openthink/stamp" && pkg.version) return pkg.version;
6867
7228
  } catch {
6868
7229
  }
6869
- const parent = dirname5(dir);
7230
+ const parent = dirname6(dir);
6870
7231
  if (parent === dir) break;
6871
7232
  dir = parent;
6872
7233
  }
@@ -6892,7 +7253,7 @@ function runUpdate() {
6892
7253
  `);
6893
7254
  process.stdout.write(`checking npm registry for latest...
6894
7255
  `);
6895
- const viewResult = spawnSync11("npm", ["view", PKG_NAME, "version"], {
7256
+ const viewResult = spawnSync14("npm", ["view", PKG_NAME, "version"], {
6896
7257
  encoding: "utf8"
6897
7258
  });
6898
7259
  if (viewResult.error || viewResult.status !== 0) {
@@ -6926,7 +7287,7 @@ function runUpdate() {
6926
7287
  }
6927
7288
  process.stdout.write(`installing ${PKG_NAME}@${latest}...
6928
7289
  `);
6929
- const installResult = spawnSync11(
7290
+ const installResult = spawnSync14(
6930
7291
  "npm",
6931
7292
  ["install", "-g", `${PKG_NAME}@${latest}`],
6932
7293
  { stdio: "inherit" }
@@ -6947,9 +7308,9 @@ through that tool instead \u2014 this command only uses 'npm install -g'.`
6947
7308
  }
6948
7309
 
6949
7310
  // src/commands/verify.ts
6950
- import { execFileSync as execFileSync2, spawnSync as spawnSync12 } from "child_process";
7311
+ import { execFileSync as execFileSync2, spawnSync as spawnSync15 } from "child_process";
6951
7312
  function loadConfigAtSha(sha, repoRoot) {
6952
- const result = spawnSync12(
7313
+ const result = spawnSync15(
6953
7314
  "git",
6954
7315
  ["show", `${sha}:.stamp/config.yml`],
6955
7316
  { cwd: repoRoot, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }
@@ -7159,6 +7520,194 @@ function git2(args, cwd) {
7159
7520
  }
7160
7521
  }
7161
7522
 
7523
+ // src/commands/verifyPr.ts
7524
+ import { spawnSync as spawnSync16 } from "child_process";
7525
+ function runVerifyPr(opts) {
7526
+ const repoRoot = opts.repoPath ?? findRepoRoot();
7527
+ const resolved = resolveDiff(`${opts.base}..${opts.head}`, repoRoot);
7528
+ const patch_id = patchIdForSpan(resolved.base_sha, resolved.head_sha, repoRoot);
7529
+ const envelope = readAttestationRef(patch_id, repoRoot);
7530
+ if (!envelope) {
7531
+ fail2(
7532
+ `no attestation found at refs/stamp/attestations/${patch_id} (diff ${resolved.base_sha.slice(0, 8)}..${resolved.head_sha.slice(0, 8)}). Operator must run \`stamp attest --into ${opts.into}\` and push the attestation ref to this remote.`,
7533
+ patch_id,
7534
+ resolved.base_sha,
7535
+ resolved.head_sha
7536
+ );
7537
+ }
7538
+ const { payload, signature } = envelope;
7539
+ const trustedKey = findTrustedKeyAtBase(
7540
+ resolved.base_sha,
7541
+ payload.signer_key_id,
7542
+ repoRoot
7543
+ );
7544
+ if (!trustedKey) {
7545
+ fail2(
7546
+ `signer_key_id ${payload.signer_key_id} is not in .stamp/trusted-keys/ at base ${resolved.base_sha.slice(0, 8)}. The reviewer's signing key must be added to this repo's trust set via \`stamp trust grant\` and landed through the standard stamp gate before their attestations verify.`,
7547
+ patch_id,
7548
+ resolved.base_sha,
7549
+ resolved.head_sha
7550
+ );
7551
+ }
7552
+ const sigOk = verifyBytes(
7553
+ trustedKey.pem,
7554
+ serializePayload2(payload),
7555
+ signature
7556
+ );
7557
+ if (!sigOk) {
7558
+ fail2(
7559
+ `signature verification failed against ${trustedKey.filename} (${payload.signer_key_id}). Either the attestation has been tampered with after signing, or the trusted-keys entry doesn't match the key that signed.`,
7560
+ patch_id,
7561
+ resolved.base_sha,
7562
+ resolved.head_sha
7563
+ );
7564
+ }
7565
+ if (payload.target_branch !== opts.into) {
7566
+ fail2(
7567
+ `attestation target_branch="${payload.target_branch}" does not match verifier --into="${opts.into}". The reviewer signed an attestation for a different merge destination \u2014 re-attest with --into ${opts.into}.`,
7568
+ patch_id,
7569
+ resolved.base_sha,
7570
+ resolved.head_sha
7571
+ );
7572
+ }
7573
+ let configYaml;
7574
+ try {
7575
+ configYaml = showAtRef(resolved.base_sha, ".stamp/config.yml", repoRoot);
7576
+ } catch (e) {
7577
+ fail2(
7578
+ `could not read .stamp/config.yml at base ${resolved.base_sha.slice(0, 8)}: ${e.message}`,
7579
+ patch_id,
7580
+ resolved.base_sha,
7581
+ resolved.head_sha
7582
+ );
7583
+ }
7584
+ const config2 = parseConfigFromYaml(configYaml);
7585
+ const rule = findBranchRule(config2.branches, opts.into);
7586
+ if (!rule) {
7587
+ fail2(
7588
+ `no branch rule for "${opts.into}" in .stamp/config.yml at base ${resolved.base_sha.slice(0, 8)}. Configured branches: ${Object.keys(config2.branches).join(", ") || "(none)"}.`,
7589
+ patch_id,
7590
+ resolved.base_sha,
7591
+ resolved.head_sha
7592
+ );
7593
+ }
7594
+ const approvalByReviewer = new Map(
7595
+ payload.approvals.map((a) => [a.reviewer, a.verdict])
7596
+ );
7597
+ const missing = [];
7598
+ for (const r of rule.required) {
7599
+ if (approvalByReviewer.get(r) !== "approved") missing.push(r);
7600
+ }
7601
+ if (missing.length > 0) {
7602
+ fail2(
7603
+ `gate CLOSED: missing approved verdicts for: ${missing.join(", ")}. Attestation has approvals for: ${payload.approvals.map((a) => `${a.reviewer}=${a.verdict}`).join(", ")}.`,
7604
+ patch_id,
7605
+ resolved.base_sha,
7606
+ resolved.head_sha
7607
+ );
7608
+ }
7609
+ if (rule.strict_base) {
7610
+ if (!payload.target_branch_tip_sha) {
7611
+ fail2(
7612
+ `strict_base check failed: attestation schema v${payload.schema_version} predates target_branch_tip_sha (v2+). Re-attest with stamp \u2265 1.6.0 to verify under strict_base; or relax the branch rule.`,
7613
+ patch_id,
7614
+ resolved.base_sha,
7615
+ resolved.head_sha
7616
+ );
7617
+ }
7618
+ const currentTip = runGit(
7619
+ ["rev-parse", `${opts.into}^{commit}`],
7620
+ repoRoot
7621
+ ).trim();
7622
+ if (payload.target_branch_tip_sha !== currentTip) {
7623
+ fail2(
7624
+ `strict_base check failed: attestation was signed when ${opts.into} was at ${payload.target_branch_tip_sha.slice(0, 8)}, but ${opts.into} is now at ${currentTip.slice(0, 8)}. Re-attest with the current tip.`,
7625
+ patch_id,
7626
+ resolved.base_sha,
7627
+ resolved.head_sha
7628
+ );
7629
+ }
7630
+ }
7631
+ printSuccess3({
7632
+ patch_id,
7633
+ base_sha: resolved.base_sha,
7634
+ head_sha: resolved.head_sha,
7635
+ target_branch: opts.into,
7636
+ signer_key_id: payload.signer_key_id,
7637
+ trusted_key_filename: trustedKey.filename,
7638
+ approvals: payload.approvals.map((a) => ({
7639
+ reviewer: a.reviewer,
7640
+ verdict: a.verdict
7641
+ })),
7642
+ strict_base: rule.strict_base ?? false
7643
+ });
7644
+ }
7645
+ function findTrustedKeyAtBase(base_sha, signer_key_id, repoRoot) {
7646
+ const lsTree = spawnSync16(
7647
+ "git",
7648
+ [
7649
+ "ls-tree",
7650
+ "--name-only",
7651
+ "-r",
7652
+ base_sha,
7653
+ ".stamp/trusted-keys/"
7654
+ ],
7655
+ { cwd: repoRoot, encoding: "utf8" }
7656
+ );
7657
+ if (lsTree.status !== 0) {
7658
+ return null;
7659
+ }
7660
+ const files = (lsTree.stdout ?? "").split("\n").map((l) => l.trim()).filter((l) => l.endsWith(".pub"));
7661
+ for (const file of files) {
7662
+ let pem;
7663
+ try {
7664
+ pem = runGit(["show", `${base_sha}:${file}`], repoRoot);
7665
+ } catch {
7666
+ continue;
7667
+ }
7668
+ let fp;
7669
+ try {
7670
+ fp = fingerprintFromPem(pem);
7671
+ } catch {
7672
+ continue;
7673
+ }
7674
+ if (fp === signer_key_id) {
7675
+ return { filename: file.replace(/^\.stamp\/trusted-keys\//, ""), pem };
7676
+ }
7677
+ }
7678
+ return null;
7679
+ }
7680
+ function printSuccess3(s) {
7681
+ const bar = "\u2500".repeat(72);
7682
+ console.log(bar);
7683
+ console.log(
7684
+ `target: ${s.target_branch} base: ${s.base_sha.slice(0, 8)} \u2192 head: ${s.head_sha.slice(0, 8)}`
7685
+ );
7686
+ console.log(bar);
7687
+ console.log(` patch-id: ${s.patch_id}`);
7688
+ console.log(` signer: ${s.signer_key_id}`);
7689
+ console.log(` trusted-key: .stamp/trusted-keys/${s.trusted_key_filename}`);
7690
+ console.log(` base mode: ${s.strict_base ? "strict" : "loose"}`);
7691
+ for (const a of s.approvals) {
7692
+ const mark = a.verdict === "approved" ? "\u2713" : "\u2717";
7693
+ console.log(` ${mark} ${a.reviewer.padEnd(16)} ${a.verdict}`);
7694
+ }
7695
+ console.log(bar);
7696
+ console.log("result: VERIFIED");
7697
+ console.log(bar);
7698
+ }
7699
+ function fail2(reason, patch_id, base_sha, head_sha) {
7700
+ const bar = "\u2500".repeat(72);
7701
+ console.error(bar);
7702
+ console.error(`base: ${base_sha.slice(0, 8)} \u2192 head: ${head_sha.slice(0, 8)}`);
7703
+ console.error(`patch-id: ${patch_id}`);
7704
+ console.error(bar);
7705
+ console.error(`error: ${reason}`);
7706
+ console.error("result: FAILED");
7707
+ console.error(bar);
7708
+ process.exit(1);
7709
+ }
7710
+
7162
7711
  // src/index.ts
7163
7712
  process.removeAllListeners("warning");
7164
7713
  process.on("warning", (warn) => {
@@ -7198,6 +7747,9 @@ program.command("init").description(
7198
7747
  ).option(
7199
7748
  "--no-oteam",
7200
7749
  "bypass the oteam-detection prompt that offers to fill stamp.host in ~/.open-team/config.json"
7750
+ ).option(
7751
+ "--no-pr-check",
7752
+ "skip dropping .github/workflows/stamp-verify.yml (PR-check mode workflow). Default behaviour: write the workflow for forge-direct + local-only modes, skip for server-gated."
7201
7753
  ).action(
7202
7754
  (opts) => {
7203
7755
  try {
@@ -7219,7 +7771,12 @@ program.command("init").description(
7219
7771
  ghProtect: opts.ghProtect,
7220
7772
  mode,
7221
7773
  remote: opts.remote,
7222
- oteam: opts.oteam
7774
+ oteam: opts.oteam,
7775
+ // commander's --no-pr-check yields opts.prCheck === false; no
7776
+ // flag yields true (the default-true sentinel). We only want
7777
+ // to forward an explicit `false` to runInit so its mode-aware
7778
+ // default fires when the operator hasn't opted out.
7779
+ prCheck: opts.prCheck === false ? false : void 0
7223
7780
  });
7224
7781
  } catch (err) {
7225
7782
  const message = err instanceof Error ? err.message : String(err);
@@ -7473,6 +8030,21 @@ program.command("merge <branch>").description("merge <branch> into --into <targe
7473
8030
  handleCliError(err);
7474
8031
  }
7475
8032
  });
8033
+ program.command("attest [branch]").description(
8034
+ "PR-check mode counterpart to `stamp merge` \u2014 sign an attestation envelope and write it to refs/stamp/attestations/<patch-id> for a GitHub Action to verify on the PR (no actual git merge happens here)"
8035
+ ).requiredOption("--into <target>", "target branch whose rule the gate is checked against").option(
8036
+ "--push [remote]",
8037
+ "after attesting locally, push the current branch + the attestation ref to <remote> in one atomic git push (default remote: origin)"
8038
+ ).action(
8039
+ (branch, opts) => {
8040
+ try {
8041
+ const pushTo = opts.push === true ? "origin" : typeof opts.push === "string" ? opts.push : void 0;
8042
+ runAttest({ branch, into: opts.into, pushTo });
8043
+ } catch (err) {
8044
+ handleCliError(err);
8045
+ }
8046
+ }
8047
+ );
7476
8048
  program.command("push <target>").description("push <target> to origin; surfaces stamp-verify hook stderr on rejection").option("--remote <name>", "remote to push to", "origin").action((target, opts) => {
7477
8049
  try {
7478
8050
  runPush({ target, remote: opts.remote });
@@ -7487,6 +8059,18 @@ program.command("verify <sha>").description("verify an existing merge commit's a
7487
8059
  handleCliError(err);
7488
8060
  }
7489
8061
  });
8062
+ program.command("verify-pr <head>").description(
8063
+ "verify a PR attestation at refs/stamp/attestations/<patch-id> for the diff <base>..<head> against the --into branch's rule (used by stamp/verify-attestation@v1; also runnable locally for debugging)"
8064
+ ).requiredOption("--base <ref>", "PR base ref (commit SHA, branch, or any rev-parse-able value)").requiredOption(
8065
+ "--into <branch>",
8066
+ "branch the PR is merging into; must equal the attestation's target_branch"
8067
+ ).action((head, opts) => {
8068
+ try {
8069
+ runVerifyPr({ head, base: opts.base, into: opts.into });
8070
+ } catch (err) {
8071
+ handleCliError(err);
8072
+ }
8073
+ });
7490
8074
  program.command("update").description(
7491
8075
  "upgrade stamp to the latest npm release (runs 'npm install -g @openthink/stamp@latest')"
7492
8076
  ).action(() => wrap(() => runUpdate()));