@openthink/stamp 1.5.1 → 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 });
@@ -5536,8 +5612,16 @@ function runUsersList(opts) {
5536
5612
  );
5537
5613
  }
5538
5614
  const rows = payload.users ?? [];
5615
+ const warning = ownerlessWarning(rows);
5616
+ if (warning) process.stderr.write(warning);
5539
5617
  process.stdout.write(formatUsersTable(rows));
5540
5618
  }
5619
+ function ownerlessWarning(rows) {
5620
+ if (rows.length === 0) return null;
5621
+ const hasOwner = rows.some((r) => r.role === "owner");
5622
+ if (hasOwner) return null;
5623
+ return "warning: this stamp server has NO OWNER configured.\nwarning: an admin can self-promote to owner ONCE, but only while no\nwarning: owner exists \u2014 and any admin (yours or someone else's) can\nwarning: race to claim it. Until you do, the server can't promote\nwarning: anyone to admin or appoint other owners.\nwarning:\nwarning: claim ownership now from THIS machine before anyone beats you:\nwarning: stamp users promote <your-short-name> --to owner\nwarning:\n";
5624
+ }
5541
5625
  function runUsersPromote(opts) {
5542
5626
  const server2 = resolveServer3();
5543
5627
  const result = callRemote(server2, [
@@ -5850,6 +5934,291 @@ function printReviewHistory(repoRoot, limit, diff) {
5850
5934
  );
5851
5935
  }
5852
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
+
5853
6222
  // src/commands/prune.ts
5854
6223
  import { existsSync as existsSync16, readdirSync as readdirSync4, statSync as statSync2, unlinkSync as unlinkSync3 } from "fs";
5855
6224
  import { join as join10 } from "path";
@@ -6115,7 +6484,7 @@ function loadOrEmpty() {
6115
6484
  }
6116
6485
 
6117
6486
  // src/commands/reviewers.ts
6118
- import { spawnSync as spawnSync10 } from "child_process";
6487
+ import { spawnSync as spawnSync13 } from "child_process";
6119
6488
  import {
6120
6489
  existsSync as existsSync19,
6121
6490
  readFileSync as readFileSync13,
@@ -6759,7 +7128,7 @@ function buildConfigYamlHint(reviewerName, tools, mcpServers) {
6759
7128
  }
6760
7129
  function launchEditor(path2) {
6761
7130
  const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? (process.platform === "win32" ? "notepad" : "vi");
6762
- const result = spawnSync10(editor, [path2], { stdio: "inherit" });
7131
+ const result = spawnSync13(editor, [path2], { stdio: "inherit" });
6763
7132
  if (result.error) {
6764
7133
  throw new Error(
6765
7134
  `failed to launch editor "${editor}": ${result.error.message}`
@@ -6843,14 +7212,14 @@ function printGate(result, base_sha, head_sha) {
6843
7212
  }
6844
7213
 
6845
7214
  // src/commands/update.ts
6846
- import { spawnSync as spawnSync11 } from "child_process";
7215
+ import { spawnSync as spawnSync14 } from "child_process";
6847
7216
 
6848
7217
  // src/lib/version.ts
6849
7218
  import { readFileSync as readFileSync14 } from "fs";
6850
- import { dirname as dirname5, join as join13 } from "path";
7219
+ import { dirname as dirname6, join as join13 } from "path";
6851
7220
  import { fileURLToPath } from "url";
6852
7221
  function readPackageVersion() {
6853
- const here = dirname5(fileURLToPath(import.meta.url));
7222
+ const here = dirname6(fileURLToPath(import.meta.url));
6854
7223
  for (let dir = here, i = 0; i < 6; i++) {
6855
7224
  try {
6856
7225
  const raw = readFileSync14(join13(dir, "package.json"), "utf8");
@@ -6858,7 +7227,7 @@ function readPackageVersion() {
6858
7227
  if (pkg.name === "@openthink/stamp" && pkg.version) return pkg.version;
6859
7228
  } catch {
6860
7229
  }
6861
- const parent = dirname5(dir);
7230
+ const parent = dirname6(dir);
6862
7231
  if (parent === dir) break;
6863
7232
  dir = parent;
6864
7233
  }
@@ -6884,7 +7253,7 @@ function runUpdate() {
6884
7253
  `);
6885
7254
  process.stdout.write(`checking npm registry for latest...
6886
7255
  `);
6887
- const viewResult = spawnSync11("npm", ["view", PKG_NAME, "version"], {
7256
+ const viewResult = spawnSync14("npm", ["view", PKG_NAME, "version"], {
6888
7257
  encoding: "utf8"
6889
7258
  });
6890
7259
  if (viewResult.error || viewResult.status !== 0) {
@@ -6918,7 +7287,7 @@ function runUpdate() {
6918
7287
  }
6919
7288
  process.stdout.write(`installing ${PKG_NAME}@${latest}...
6920
7289
  `);
6921
- const installResult = spawnSync11(
7290
+ const installResult = spawnSync14(
6922
7291
  "npm",
6923
7292
  ["install", "-g", `${PKG_NAME}@${latest}`],
6924
7293
  { stdio: "inherit" }
@@ -6939,9 +7308,9 @@ through that tool instead \u2014 this command only uses 'npm install -g'.`
6939
7308
  }
6940
7309
 
6941
7310
  // src/commands/verify.ts
6942
- import { execFileSync as execFileSync2, spawnSync as spawnSync12 } from "child_process";
7311
+ import { execFileSync as execFileSync2, spawnSync as spawnSync15 } from "child_process";
6943
7312
  function loadConfigAtSha(sha, repoRoot) {
6944
- const result = spawnSync12(
7313
+ const result = spawnSync15(
6945
7314
  "git",
6946
7315
  ["show", `${sha}:.stamp/config.yml`],
6947
7316
  { cwd: repoRoot, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }
@@ -7151,6 +7520,194 @@ function git2(args, cwd) {
7151
7520
  }
7152
7521
  }
7153
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
+
7154
7711
  // src/index.ts
7155
7712
  process.removeAllListeners("warning");
7156
7713
  process.on("warning", (warn) => {
@@ -7190,6 +7747,9 @@ program.command("init").description(
7190
7747
  ).option(
7191
7748
  "--no-oteam",
7192
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."
7193
7753
  ).action(
7194
7754
  (opts) => {
7195
7755
  try {
@@ -7211,7 +7771,12 @@ program.command("init").description(
7211
7771
  ghProtect: opts.ghProtect,
7212
7772
  mode,
7213
7773
  remote: opts.remote,
7214
- 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
7215
7780
  });
7216
7781
  } catch (err) {
7217
7782
  const message = err instanceof Error ? err.message : String(err);
@@ -7465,6 +8030,21 @@ program.command("merge <branch>").description("merge <branch> into --into <targe
7465
8030
  handleCliError(err);
7466
8031
  }
7467
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
+ );
7468
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) => {
7469
8049
  try {
7470
8050
  runPush({ target, remote: opts.remote });
@@ -7479,6 +8059,18 @@ program.command("verify <sha>").description("verify an existing merge commit's a
7479
8059
  handleCliError(err);
7480
8060
  }
7481
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
+ });
7482
8074
  program.command("update").description(
7483
8075
  "upgrade stamp to the latest npm release (runs 'npm install -g @openthink/stamp@latest')"
7484
8076
  ).action(() => wrap(() => runUpdate()));