@openthink/stamp 2.0.2 → 2.1.1

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
@@ -52,7 +52,7 @@ import {
52
52
  userKeysDir,
53
53
  userServerConfigPath,
54
54
  verifyBytes
55
- } from "./chunk-4PFD2DSY.js";
55
+ } from "./chunk-MJULVH4B.js";
56
56
 
57
57
  // src/index.ts
58
58
  import { Command } from "commander";
@@ -541,8 +541,12 @@ function validateConfig(input) {
541
541
  throw new Error(`config.reviewers.${name} must be an object`);
542
542
  }
543
543
  const d = def;
544
- if (typeof d.prompt !== "string") {
545
- throw new Error(`config.reviewers.${name}.prompt must be a string`);
544
+ let prompt2;
545
+ if (d.prompt !== void 0) {
546
+ if (typeof d.prompt !== "string") {
547
+ throw new Error(`config.reviewers.${name}.prompt must be a string`);
548
+ }
549
+ prompt2 = d.prompt;
546
550
  }
547
551
  const tools = parseTools(d.tools, name);
548
552
  const mcp_servers = parseMcpServers(d.mcp_servers, name);
@@ -564,7 +568,7 @@ function validateConfig(input) {
564
568
  `config.reviewers.${name}.timeout_ms`
565
569
  );
566
570
  reviewers2[name] = {
567
- prompt: d.prompt,
571
+ ...prompt2 !== void 0 ? { prompt: prompt2 } : {},
568
572
  ...tools ? { tools } : {},
569
573
  ...mcp_servers ? { mcp_servers } : {},
570
574
  ...enforce_reads_on_dotstamp !== void 0 ? { enforce_reads_on_dotstamp } : {},
@@ -1412,8 +1416,8 @@ import { createHash as createHash3, createPublicKey, verify } from "crypto";
1412
1416
  import { spawn } from "child_process";
1413
1417
 
1414
1418
  // src/lib/attestationV4.ts
1415
- var CURRENT_V4_SCHEMA_VERSION = 4;
1416
- var MIN_ACCEPTED_V4_SCHEMA_VERSION = 4;
1419
+ var CURRENT_V4_SCHEMA_VERSION = 5;
1420
+ var MIN_ACCEPTED_V4_SCHEMA_VERSION = 5;
1417
1421
  var MAX_V4_ENVELOPE_BYTES = 64 * 1024;
1418
1422
  function sortKeysDeep(value) {
1419
1423
  if (value === null || typeof value !== "object") return value;
@@ -1547,7 +1551,6 @@ function parseResponseJson(raw) {
1547
1551
  "diff_sha256",
1548
1552
  "base_sha",
1549
1553
  "head_sha",
1550
- "trusted_keys_snapshot_sha256",
1551
1554
  "issued_at",
1552
1555
  "server_key_id"
1553
1556
  ]) {
@@ -1945,6 +1948,7 @@ function buildTrustAnchorPayload(input) {
1945
1948
  head_sha: input.headSha,
1946
1949
  target_branch: input.targetBranch,
1947
1950
  diff_sha256: input.diffSha256,
1951
+ manifest_snapshot_sha256: input.manifestSnapshotSha256,
1948
1952
  approvals: input.approvals,
1949
1953
  checks: input.checks,
1950
1954
  trust_anchor_signatures: [],
@@ -2027,6 +2031,11 @@ var COMMIT_PHASES_V4 = [
2027
2031
  { name: "verifyV4TargetBranch", fn: verifyV4TargetBranch },
2028
2032
  { name: "verifyV4SignerTrust", fn: verifyV4SignerTrust },
2029
2033
  { name: "verifyV4OuterSignature", fn: verifyV4OuterSignature },
2034
+ // AGT-370: envelope-level manifest snapshot binding (lifted from
2035
+ // the per-approval slot in v4). Runs once before the per-approval
2036
+ // loop in verifyV4ApprovalSignatures — a single check replaces the
2037
+ // N-checks per envelope the v4 verifier did.
2038
+ { name: "verifyV4ManifestSnapshot", fn: verifyV4ManifestSnapshot },
2030
2039
  { name: "verifyV4Approvals", fn: verifyV4Approvals },
2031
2040
  { name: "verifyV4DiffHash", fn: verifyV4DiffHash },
2032
2041
  { name: "verifyV4ApprovalSignatures", fn: verifyV4ApprovalSignatures },
@@ -2159,10 +2168,19 @@ function verifyV4DiffHash(input) {
2159
2168
  }
2160
2169
  return { ok: true };
2161
2170
  }
2171
+ function verifyV4ManifestSnapshot(input) {
2172
+ const { sha, payload, manifest } = input;
2173
+ const computed = snapshotSha256(manifest);
2174
+ if (payload.manifest_snapshot_sha256 !== computed) {
2175
+ return {
2176
+ ok: false,
2177
+ reason: `commit ${sha.slice(0, 8)}: v4 manifest_snapshot_sha256 (${payload.manifest_snapshot_sha256.slice(0, 16)}\u2026) does not match the manifest at base ${payload.base_sha.slice(0, 8)} (${computed.slice(0, 16)}\u2026). The envelope was signed against a different snapshot of the trust set than the one committed at the merge base. Re-run \`stamp merge\` (or \`stamp attest\`) so the outer signature binds to the current manifest.`
2178
+ };
2179
+ }
2180
+ return { ok: true };
2181
+ }
2162
2182
  function verifyV4ApprovalSignatures(input) {
2163
2183
  const { sha, payload, manifest, pubkeyByFingerprint } = input;
2164
- const manifestSnapshot = snapshotSha256(manifest);
2165
- const reviewerDefs = readReviewerDefsAtRef(payload.base_sha);
2166
2184
  for (const entry of payload.approvals) {
2167
2185
  const a = entry.approval;
2168
2186
  const reviewerLabel = `"${a.reviewer}"`;
@@ -2184,12 +2202,6 @@ function verifyV4ApprovalSignatures(input) {
2184
2202
  reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: server_attestation.server_key_id (${entry.server_attestation.server_key_id}) does not match inner approval.server_key_id (${a.server_key_id}). The inner signed payload is authoritative; one of the two was tampered with after signing.`
2185
2203
  };
2186
2204
  }
2187
- if (a.trusted_keys_snapshot_sha256 !== manifestSnapshot) {
2188
- return {
2189
- ok: false,
2190
- reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: trusted_keys_snapshot_sha256 (${a.trusted_keys_snapshot_sha256.slice(0, 16)}\u2026) does not match the manifest at base ${payload.base_sha.slice(0, 8)} (${manifestSnapshot.slice(0, 16)}\u2026). The server signed against a different snapshot of the trust set than the one committed at the merge base.`
2191
- };
2192
- }
2193
2205
  const caps = resolveCapability(manifest, a.server_key_id);
2194
2206
  if (caps === null) {
2195
2207
  return {
@@ -2229,29 +2241,6 @@ function verifyV4ApprovalSignatures(input) {
2229
2241
  reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: server signature does not verify against ${a.server_key_id} over canonical approval bytes`
2230
2242
  };
2231
2243
  }
2232
- const def = reviewerDefs[a.reviewer];
2233
- if (!def) {
2234
- return {
2235
- ok: false,
2236
- reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: reviewer is not defined in .stamp/config.yml at base ${payload.base_sha.slice(0, 8)}`
2237
- };
2238
- }
2239
- let promptText;
2240
- try {
2241
- promptText = run(["show", `${payload.base_sha}:${def.prompt}`]);
2242
- } catch {
2243
- return {
2244
- ok: false,
2245
- reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: prompt "${def.prompt}" is unreadable at base ${payload.base_sha.slice(0, 8)}`
2246
- };
2247
- }
2248
- const recomputedPromptSha = hashPromptBytes(Buffer.from(promptText, "utf8"));
2249
- if (recomputedPromptSha !== a.prompt_sha256) {
2250
- return {
2251
- ok: false,
2252
- reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: prompt_sha256 mismatch \u2014 server signed ${a.prompt_sha256.slice(0, 12)}\u2026 but prompt file at base hashes to ${recomputedPromptSha.slice(0, 12)}\u2026. The reviewer prompt the server reviewed differs from the one in the merge-base tree.`
2253
- };
2254
- }
2255
2244
  }
2256
2245
  return { ok: true };
2257
2246
  }
@@ -2417,22 +2406,6 @@ function readPubkeyMapAt(ref) {
2417
2406
  }
2418
2407
  return buildPubkeyMap(names, (relPath) => run(["show", `${ref}:${relPath}`]));
2419
2408
  }
2420
- function readReviewerDefsAtRef(ref) {
2421
- let yaml;
2422
- try {
2423
- yaml = run(["show", `${ref}:.stamp/config.yml`]);
2424
- } catch {
2425
- return {};
2426
- }
2427
- const defs = readReviewersFromYaml(yaml);
2428
- const out = {};
2429
- for (const [name, def] of Object.entries(defs)) {
2430
- if (def && typeof def.prompt === "string") {
2431
- out[name] = { prompt: def.prompt };
2432
- }
2433
- }
2434
- return out;
2435
- }
2436
2409
  function readChangedFilesAtRef(baseSha, headSha) {
2437
2410
  let out;
2438
2411
  try {
@@ -2820,6 +2793,12 @@ function verifyReviewerHashesAtMergeBase(input) {
2820
2793
  reason: `${prefix} reviewer "${approval.reviewer}" not defined in .stamp/config.yml at merge-base`
2821
2794
  };
2822
2795
  }
2796
+ if (def.prompt === void 0) {
2797
+ return {
2798
+ ok: false,
2799
+ reason: `${prefix} reviewer "${approval.reviewer}" has no \`prompt:\` in .stamp/config.yml at merge-base; v3 attestation references prompt_sha256 but the producer flow for server-bundled prompts is v4 (server-attested). The attestation envelope and the config shape are inconsistent.`
2800
+ };
2801
+ }
2823
2802
  let promptBytes;
2824
2803
  try {
2825
2804
  promptBytes = run2(["show", `${baseSha}:${def.prompt}`]);
@@ -3403,6 +3382,11 @@ function buildV3Trailers(input) {
3403
3382
  `reviewer "${a.reviewer}" approved the diff but is not defined in .stamp/config.yml at base ${input.baseSha.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. Merge rolled back.`
3404
3383
  );
3405
3384
  }
3385
+ if (def.prompt === void 0) {
3386
+ throw new Error(
3387
+ `reviewer "${a.reviewer}": no \`prompt:\` configured and no \`review_server:\` on branch rule \u2014 set \`reviewers.${a.reviewer}.prompt\` in .stamp/config.yml or configure a \`review_server:\` for server-attested mode. Merge rolled back.`
3388
+ );
3389
+ }
3406
3390
  const promptText = showAtRef(input.baseSha, def.prompt, input.repoRoot);
3407
3391
  const source = readReviewerSource(a.reviewer, input.repoRoot);
3408
3392
  return {
@@ -3489,7 +3473,6 @@ function buildV4Trailers(input) {
3489
3473
  "diff_sha256",
3490
3474
  "base_sha",
3491
3475
  "head_sha",
3492
- "trusted_keys_snapshot_sha256",
3493
3476
  "issued_at",
3494
3477
  "server_key_id"
3495
3478
  ]) {
@@ -3569,12 +3552,14 @@ function buildV4Trailers(input) {
3569
3552
  exit_code: c.exit_code,
3570
3553
  output_sha: c.output_sha
3571
3554
  }));
3555
+ const manifestSnapshot = snapshotSha256(manifest);
3572
3556
  const trustAnchorSigs = collectTrustAnchorSignatures({
3573
3557
  repoRoot: input.repoRoot,
3574
3558
  baseSha: input.baseSha,
3575
3559
  headSha: input.headSha,
3576
3560
  targetBranch: input.targetBranch,
3577
3561
  diffSha256,
3562
+ manifestSnapshotSha256: manifestSnapshot,
3578
3563
  approvals: entries,
3579
3564
  checks: v4Checks,
3580
3565
  operatorFingerprint: input.operatorFingerprint,
@@ -3587,6 +3572,7 @@ function buildV4Trailers(input) {
3587
3572
  head_sha: input.headSha,
3588
3573
  target_branch: input.targetBranch,
3589
3574
  diff_sha256: diffSha256,
3575
+ manifest_snapshot_sha256: manifestSnapshot,
3590
3576
  approvals: entries,
3591
3577
  checks: v4Checks,
3592
3578
  trust_anchor_signatures: trustAnchorSigs,
@@ -3638,6 +3624,7 @@ function collectTrustAnchorSignatures(input) {
3638
3624
  headSha: input.headSha,
3639
3625
  targetBranch: input.targetBranch,
3640
3626
  diffSha256: input.diffSha256,
3627
+ manifestSnapshotSha256: input.manifestSnapshotSha256,
3641
3628
  approvals: input.approvals,
3642
3629
  checks: input.checks,
3643
3630
  signerKeyId: input.operatorFingerprint
@@ -4731,6 +4718,11 @@ function buildReviewPlan(opts) {
4731
4718
  const reviewers2 = [];
4732
4719
  for (const name of reviewerNames) {
4733
4720
  const def = config2.reviewers[name];
4721
+ if (def.prompt === void 0) {
4722
+ throw new Error(
4723
+ `reviewer "${name}": no \`prompt:\` configured and no \`review_server:\` on branch rule \u2014 set \`reviewers.${name}.prompt\` in .stamp/config.yml or configure a \`review_server:\` for server-attested mode.`
4724
+ );
4725
+ }
4734
4726
  let prompt2;
4735
4727
  try {
4736
4728
  prompt2 = showAtRef(resolved.base_sha, def.prompt, opts.repoRoot);
@@ -5340,19 +5332,6 @@ async function runReview(opts) {
5340
5332
  `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.`
5341
5333
  );
5342
5334
  }
5343
- const promptBytesByReviewer = /* @__PURE__ */ new Map();
5344
- for (const name of reviewerNames) {
5345
- const def = config2.reviewers[name];
5346
- let bytes;
5347
- try {
5348
- bytes = showAtRef(resolved.base_sha, def.prompt, repoRoot);
5349
- } catch (err) {
5350
- throw new Error(
5351
- `failed to read prompt for reviewer "${name}" from base ${resolved.base_sha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}. (The reviewer is configured at the base but its prompt file is missing there.)`
5352
- );
5353
- }
5354
- promptBytesByReviewer.set(name, bytes);
5355
- }
5356
5335
  const targetBranch = opts.into ?? inferTargetBranch(opts.diff);
5357
5336
  const branchRule = targetBranch ? findBranchRule(config2.branches, targetBranch) : void 0;
5358
5337
  if (branchRule?.review_server) {
@@ -5360,7 +5339,7 @@ async function runReview(opts) {
5360
5339
  opts,
5361
5340
  config: config2,
5362
5341
  reviewerNames,
5363
- promptBytesByReviewer,
5342
+ promptBytesByReviewer: /* @__PURE__ */ new Map(),
5364
5343
  resolved,
5365
5344
  repoRoot,
5366
5345
  reviewServerUrl: branchRule.review_server,
@@ -5368,6 +5347,24 @@ async function runReview(opts) {
5368
5347
  });
5369
5348
  return;
5370
5349
  }
5350
+ const promptBytesByReviewer = /* @__PURE__ */ new Map();
5351
+ for (const name of reviewerNames) {
5352
+ const def = config2.reviewers[name];
5353
+ if (def.prompt === void 0) {
5354
+ throw new Error(
5355
+ `reviewer "${name}": no \`prompt:\` configured and no \`review_server:\` on branch rule \u2014 set \`reviewers.${name}.prompt\` in .stamp/config.yml or configure a \`review_server:\` for server-attested mode.`
5356
+ );
5357
+ }
5358
+ let bytes;
5359
+ try {
5360
+ bytes = showAtRef(resolved.base_sha, def.prompt, repoRoot);
5361
+ } catch (err) {
5362
+ throw new Error(
5363
+ `failed to read prompt for reviewer "${name}" from base ${resolved.base_sha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}. (The reviewer is configured at the base but its prompt file is missing there.)`
5364
+ );
5365
+ }
5366
+ promptBytesByReviewer.set(name, bytes);
5367
+ }
5371
5368
  maybePrintLlmNotice(repoRoot);
5372
5369
  const userCfg = loadOrCreateUserConfig();
5373
5370
  if (userCfg.created) {
@@ -5896,6 +5893,7 @@ function buildPlan(current, targetBranch, targetRule, opts) {
5896
5893
  }
5897
5894
  newReviewersConfig = seed.config.reviewers;
5898
5895
  for (const [name, def] of Object.entries(seed.config.reviewers)) {
5896
+ if (def.prompt === void 0) continue;
5899
5897
  const promptBody = seed.reviewerFiles.get(def.prompt);
5900
5898
  if (promptBody === void 0) {
5901
5899
  throw new Error(
@@ -9487,11 +9485,13 @@ function signPending(repoRoot, rawSha, opts) {
9487
9485
  }
9488
9486
  predictedSigner = opts.signerKeyId;
9489
9487
  }
9488
+ const manifestSnapshotSha256 = snapshotSha256(manifest);
9490
9489
  const signingBytes = trustAnchorSigningBytes({
9491
9490
  baseSha,
9492
9491
  headSha,
9493
9492
  targetBranch,
9494
9493
  diffSha256,
9494
+ manifestSnapshotSha256,
9495
9495
  approvals,
9496
9496
  checks: [],
9497
9497
  // see trustAnchorPayload.ts "Operational caveat"
@@ -10140,8 +10140,9 @@ function printReviewHistory(repoRoot, limit, diff) {
10140
10140
  }
10141
10141
 
10142
10142
  // src/commands/attest.ts
10143
- import { spawnSync as spawnSync14 } from "child_process";
10143
+ import { spawnSync as spawnSync15 } from "child_process";
10144
10144
  import { createHash as createHash12 } from "crypto";
10145
+ import { parse as parseYaml11 } from "yaml";
10145
10146
 
10146
10147
  // src/lib/patchId.ts
10147
10148
  import { spawnSync as spawnSync12 } from "child_process";
@@ -10215,8 +10216,18 @@ function parseEnvelope(bytes) {
10215
10216
  return null;
10216
10217
  }
10217
10218
  if (typeof p.diff_sha256 !== "string") return null;
10219
+ if (typeof p.manifest_snapshot_sha256 !== "string") return null;
10218
10220
  if (!Array.isArray(p.trust_anchor_signatures)) return null;
10219
10221
  if (typeof p.target_branch_tip_sha !== "string") return null;
10222
+ if (p.migration_bootstrap !== void 0) {
10223
+ const m = p.migration_bootstrap;
10224
+ if (!m || typeof m !== "object" || Array.isArray(m)) return null;
10225
+ if (!Array.isArray(m.activated_paths)) return null;
10226
+ if (m.activated_paths.length === 0) return null;
10227
+ for (const ap of m.activated_paths) {
10228
+ if (typeof ap !== "string" || ap.length === 0) return null;
10229
+ }
10230
+ }
10220
10231
  return env;
10221
10232
  }
10222
10233
  function peekSchemaVersion(bytes) {
@@ -10289,154 +10300,572 @@ function readAttestationBlobBytes(patch_id, repoRoot) {
10289
10300
  return cat.stdout;
10290
10301
  }
10291
10302
 
10292
- // src/commands/attest.ts
10293
- function runAttest(opts) {
10294
- const repoRoot = findRepoRoot();
10295
- const config2 = loadConfig(stampConfigFile(repoRoot));
10296
- const rule = findBranchRule(config2.branches, opts.into);
10297
- if (!rule) {
10298
- throw new Error(
10299
- `no branch rule for "${opts.into}" in .stamp/config.yml. Configured branches: ${Object.keys(config2.branches).join(", ") || "(none)"}.`
10300
- );
10301
- }
10302
- const branchRef = opts.branch ?? "HEAD";
10303
- const revspec = `${opts.into}..${branchRef}`;
10304
- const resolved = resolveDiff(revspec, repoRoot);
10305
- const patch_id = patchIdForSpan(resolved.base_sha, resolved.head_sha, repoRoot);
10306
- const target_branch_tip_sha = runGit(
10307
- ["rev-parse", `${opts.into}^{commit}`],
10308
- repoRoot
10309
- ).trim();
10310
- const { keypair } = ensureUserKeypair();
10311
- const result = rule.review_server ? buildV3Envelope({
10312
- repoRoot,
10313
- revspec,
10314
- baseSha: resolved.base_sha,
10315
- headSha: resolved.head_sha,
10316
- diff: resolved.diff,
10317
- targetBranch: opts.into,
10318
- targetBranchTipSha: target_branch_tip_sha,
10319
- patchId: patch_id,
10320
- requiredReviewers: rule.required,
10321
- operatorPrivateKeyPem: keypair.privateKeyPem,
10322
- operatorFingerprint: keypair.fingerprint
10323
- }) : buildV2Envelope({
10324
- repoRoot,
10325
- revspec,
10326
- baseSha: resolved.base_sha,
10327
- headSha: resolved.head_sha,
10328
- targetBranch: opts.into,
10329
- targetBranchTipSha: target_branch_tip_sha,
10330
- patchId: patch_id,
10331
- requiredReviewers: rule.required,
10332
- operatorPrivateKeyPem: keypair.privateKeyPem,
10333
- operatorFingerprint: keypair.fingerprint
10334
- });
10335
- const { ref, blob_sha } = writeAttestationRef(
10336
- { payload: result.payload, signature: result.signature },
10337
- repoRoot
10338
- );
10339
- const bar = "\u2500".repeat(72);
10340
- console.log(bar);
10341
- console.log(`attested ${branchRef} for merge into '${opts.into}'`);
10342
- console.log(bar);
10343
- console.log(` patch-id: ${patch_id}`);
10344
- console.log(
10345
- ` base\u2192head: ${resolved.base_sha.slice(0, 8)} \u2192 ${resolved.head_sha.slice(0, 8)}`
10346
- );
10347
- console.log(` signed by: ${keypair.fingerprint}`);
10348
- console.log(` approvals: ${result.reviewerNames.join(", ")}`);
10349
- console.log(
10350
- ` schema: v${result.payload.schema_version}${result.payload.schema_version === PR_ATTESTATION_SCHEMA_VERSION ? " (server-attested)" : " (legacy)"}`
10303
+ // src/lib/migrationBootstrap.ts
10304
+ import { spawnSync as spawnSync14 } from "child_process";
10305
+ import { parse as parseYaml10 } from "yaml";
10306
+ function validateShape4ActivationDiff(input) {
10307
+ const { repoRoot, baseSha, headSha } = input;
10308
+ const changedFilesResult = spawnSync14(
10309
+ "git",
10310
+ ["diff", "-z", "--name-status", `${baseSha}...${headSha}`],
10311
+ { cwd: repoRoot, encoding: "utf8" }
10351
10312
  );
10352
- console.log(` ref: ${ref}`);
10353
- console.log(` blob: ${blob_sha.slice(0, 12)}`);
10354
- console.log(bar);
10355
- if (opts.pushTo) {
10356
- pushBranchAndAttestation(opts.pushTo, ref, repoRoot);
10357
- console.log(
10358
- `
10359
- \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.`
10360
- );
10361
- } else {
10362
- console.log(
10363
- `
10364
- 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:
10365
-
10366
- git push <remote> HEAD ${ref}
10367
-
10368
- Or re-run with --push <remote> next time.`
10369
- );
10313
+ if (changedFilesResult.status !== 0) {
10314
+ return {
10315
+ ok: false,
10316
+ reason: `could not enumerate diff between ${baseSha.slice(0, 8)} and ${headSha.slice(0, 8)}: ${(changedFilesResult.stderr ?? "").trim()}`
10317
+ };
10370
10318
  }
10371
- }
10372
- function buildV3Envelope(input) {
10373
- const diffBytes = Buffer.from(input.diff, "utf8");
10374
- const diffSha256 = createHash12("sha256").update(diffBytes).digest("hex");
10375
- let manifestYaml;
10376
- try {
10377
- manifestYaml = showAtRef(
10378
- input.baseSha,
10379
- ".stamp/trusted-keys/manifest.yml",
10380
- input.repoRoot
10381
- );
10382
- } catch (err) {
10383
- throw new Error(
10384
- `review_server is configured but .stamp/trusted-keys/manifest.yml is missing at base ${input.baseSha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}. Server-attested PR mode requires the manifest in the merge-base tree so each approval's server signature can be checked against the keys the repo trusted at attestation time. Commit a manifest with capabilities: [server] entries for the review server before attesting.`
10385
- );
10319
+ const tokens = (changedFilesResult.stdout ?? "").split("\0").filter((s) => s.length > 0);
10320
+ const entries = [];
10321
+ for (let i = 0; i < tokens.length; ) {
10322
+ const status = tokens[i];
10323
+ if (/^[RC]\d*$/.test(status)) {
10324
+ return {
10325
+ ok: false,
10326
+ reason: `bootstrap diff contains a rename/copy change (status "${status}") \u2014 only add/modify is permitted in a Shape 4 activation`
10327
+ };
10328
+ }
10329
+ const path2 = tokens[i + 1];
10330
+ if (path2 === void 0) {
10331
+ return {
10332
+ ok: false,
10333
+ reason: `bootstrap diff: malformed git output (status "${status}" with no path)`
10334
+ };
10335
+ }
10336
+ entries.push({ status, path: path2 });
10337
+ i += 2;
10386
10338
  }
10387
- const manifest = parseManifest(manifestYaml);
10388
- if (!manifest) {
10389
- throw new Error(
10390
- `.stamp/trusted-keys/manifest.yml at base ${input.baseSha.slice(0, 8)} failed to parse as a valid trusted-keys manifest. Fix the YAML (syntax error, duplicate fingerprint, unknown capability, etc.) and re-run \`stamp attest\`.`
10391
- );
10339
+ if (entries.length === 0) {
10340
+ return {
10341
+ ok: false,
10342
+ reason: "bootstrap diff is empty \u2014 nothing to activate"
10343
+ };
10392
10344
  }
10393
- const pubFilenames = listFilesAtRef(
10394
- input.baseSha,
10395
- ".stamp/trusted-keys",
10396
- input.repoRoot
10397
- );
10398
- const pubkeyByFingerprint = buildPubkeyMap(
10399
- pubFilenames,
10400
- (relPath) => showAtRef(input.baseSha, relPath, input.repoRoot)
10401
- );
10402
- const db = openDb(stampStateDbPath(input.repoRoot));
10403
- let entries;
10404
- try {
10405
- const rows = serverApprovalsFor(db, input.baseSha, input.headSha);
10406
- const byReviewer = new Map(rows.map((r) => [r.reviewer, r]));
10407
- entries = input.requiredReviewers.map((reviewerName) => {
10408
- const row = byReviewer.get(reviewerName);
10409
- if (!row) {
10410
- throw new Error(
10411
- `missing server signature for reviewer "${reviewerName}" at base\u2192head ${input.baseSha.slice(0, 8)}\u2192${input.headSha.slice(0, 8)}. Server-attested PR mode requires every required reviewer to have a stamp-server-signed approval in the local DB. Possible causes:
10412
- \u2022 the stamp-server is older than 2.0.1 and doesn't produce PR-attestation v3 payloads (upgrade the server)
10413
- \u2022 \`stamp review --diff ${input.revspec}\` hasn't been run against this exact (base, head) pair yet
10414
- \u2022 the review was run in local-mode (no \`review_server\` configured at review time)
10415
- Run \`stamp review --diff ${input.revspec}\` to populate the signed row, then re-run \`stamp attest\`.`
10416
- );
10345
+ for (const e of entries) {
10346
+ if (!e.path.startsWith(".stamp/")) {
10347
+ return {
10348
+ ok: false,
10349
+ reason: `bootstrap diff touches "${e.path}" outside .stamp/ \u2014 the bootstrap flag accepts only Shape-4-activation changes under .stamp/**. Use the normal flow for non-bootstrap changes.`
10350
+ };
10351
+ }
10352
+ }
10353
+ let sawReviewServerAdd = false;
10354
+ for (const e of entries) {
10355
+ if (e.path === ".stamp/config.yml") {
10356
+ if (e.status !== "M" && e.status !== "A") {
10357
+ return {
10358
+ ok: false,
10359
+ reason: `bootstrap diff has unexpected status "${e.status}" on .stamp/config.yml \u2014 only modification (M) or initial add (A) is permitted`
10360
+ };
10417
10361
  }
10418
- let parsedJson;
10419
- try {
10420
- parsedJson = JSON.parse(row.approval_json);
10421
- } catch (err) {
10422
- throw new Error(
10423
- `server approval row for reviewer "${reviewerName}" has malformed JSON in server_approval_json \u2014 DB corruption or a writer-side bug. Re-run \`stamp review --diff ${input.revspec}\` to write a fresh row. (parse error: ${err instanceof Error ? err.message : String(err)})`
10424
- );
10362
+ const cfgResult = validateConfigYamlChange({
10363
+ repoRoot,
10364
+ baseSha,
10365
+ headSha,
10366
+ statusAdd: e.status === "A"
10367
+ });
10368
+ if (!cfgResult.ok) return cfgResult;
10369
+ sawReviewServerAdd = sawReviewServerAdd || cfgResult.activatesReviewServer;
10370
+ continue;
10371
+ }
10372
+ if (e.path === ".stamp/trusted-keys/manifest.yml") {
10373
+ if (e.status !== "M" && e.status !== "A") {
10374
+ return {
10375
+ ok: false,
10376
+ reason: `bootstrap diff has unexpected status "${e.status}" on .stamp/trusted-keys/manifest.yml \u2014 only modification (M) or initial add (A) is permitted`
10377
+ };
10425
10378
  }
10426
- if (!parsedJson || typeof parsedJson !== "object" || Array.isArray(parsedJson)) {
10427
- throw new Error(
10428
- `server approval row for reviewer "${reviewerName}" parsed to a non-object value \u2014 DB corruption or a writer-side bug. Re-run \`stamp review --diff ${input.revspec}\` to write a fresh row.`
10429
- );
10379
+ const manifestResult = validateManifestChange({
10380
+ repoRoot,
10381
+ baseSha,
10382
+ headSha,
10383
+ statusAdd: e.status === "A"
10384
+ });
10385
+ if (!manifestResult.ok) return manifestResult;
10386
+ continue;
10387
+ }
10388
+ if (e.path.startsWith(".stamp/trusted-keys/") && e.path.endsWith(".pub")) {
10389
+ if (e.status !== "A") {
10390
+ return {
10391
+ ok: false,
10392
+ reason: `bootstrap diff modifies or removes "${e.path}" (status "${e.status}") \u2014 the bootstrap flag only permits adding new .pub files, never modifying or removing existing ones`
10393
+ };
10430
10394
  }
10431
- const obj = parsedJson;
10432
- for (const field of [
10433
- "reviewer",
10434
- "verdict",
10435
- "prompt_sha256",
10436
- "diff_sha256",
10437
- "base_sha",
10438
- "head_sha",
10439
- "trusted_keys_snapshot_sha256",
10395
+ continue;
10396
+ }
10397
+ return {
10398
+ ok: false,
10399
+ reason: `bootstrap diff touches "${e.path}" (status "${e.status}") which is not in the Shape-4-activation whitelist (.stamp/config.yml, .stamp/trusted-keys/manifest.yml, or new .stamp/trusted-keys/*.pub files)`
10400
+ };
10401
+ }
10402
+ if (!sawReviewServerAdd) {
10403
+ return {
10404
+ ok: false,
10405
+ reason: `bootstrap diff does not add review_server: to any branch rule in .stamp/config.yml \u2014 a Shape 4 activation must add review_server. If your diff only adds trust-anchor keys, use the normal admin-sign flow.`
10406
+ };
10407
+ }
10408
+ const activatedPaths = entries.map((e) => e.path).sort();
10409
+ return { ok: true, activatedPaths };
10410
+ }
10411
+ function validateConfigYamlChange(args) {
10412
+ if (args.statusAdd) {
10413
+ return {
10414
+ ok: false,
10415
+ reason: `.stamp/config.yml does not exist at base ${args.baseSha.slice(0, 8)} \u2014 the bootstrap flag is for activating Shape 4 on an existing stamp-gated repo, not for first-time stamp init`,
10416
+ activatesReviewServer: false
10417
+ };
10418
+ }
10419
+ const baseYaml = readAtRef(args.repoRoot, args.baseSha, ".stamp/config.yml");
10420
+ const headYaml = readAtRef(args.repoRoot, args.headSha, ".stamp/config.yml");
10421
+ if (baseYaml === null || headYaml === null) {
10422
+ return {
10423
+ ok: false,
10424
+ reason: `.stamp/config.yml is unreadable at base ${args.baseSha.slice(0, 8)} or head ${args.headSha.slice(0, 8)}`,
10425
+ activatesReviewServer: false
10426
+ };
10427
+ }
10428
+ let baseParsed;
10429
+ let headParsed;
10430
+ try {
10431
+ baseParsed = parseYaml10(baseYaml);
10432
+ headParsed = parseYaml10(headYaml);
10433
+ } catch (err) {
10434
+ return {
10435
+ ok: false,
10436
+ reason: `.stamp/config.yml parse error: ${err instanceof Error ? err.message : String(err)}`,
10437
+ activatesReviewServer: false
10438
+ };
10439
+ }
10440
+ if (!baseParsed || typeof baseParsed !== "object" || Array.isArray(baseParsed) || !headParsed || typeof headParsed !== "object" || Array.isArray(headParsed)) {
10441
+ return {
10442
+ ok: false,
10443
+ reason: `.stamp/config.yml at base or head did not parse to an object`,
10444
+ activatesReviewServer: false
10445
+ };
10446
+ }
10447
+ const baseObj = baseParsed;
10448
+ const headObj = headParsed;
10449
+ const allowedDiffKeys = /* @__PURE__ */ new Set(["branches", "reviewers"]);
10450
+ const allKeys = /* @__PURE__ */ new Set([...Object.keys(baseObj), ...Object.keys(headObj)]);
10451
+ for (const k of allKeys) {
10452
+ if (allowedDiffKeys.has(k)) continue;
10453
+ if (!deepEqual(baseObj[k], headObj[k])) {
10454
+ return {
10455
+ ok: false,
10456
+ reason: `.stamp/config.yml: bootstrap diff modifies "${k}" outside the allowed (branches, reviewers) set \u2014 refused`,
10457
+ activatesReviewServer: false
10458
+ };
10459
+ }
10460
+ }
10461
+ const baseBranches = baseObj.branches ?? {};
10462
+ const headBranches = headObj.branches ?? {};
10463
+ if (typeof baseBranches !== "object" || baseBranches === null || Array.isArray(baseBranches) || typeof headBranches !== "object" || headBranches === null || Array.isArray(headBranches)) {
10464
+ return {
10465
+ ok: false,
10466
+ reason: `.stamp/config.yml: branches must be objects at both base and head`,
10467
+ activatesReviewServer: false
10468
+ };
10469
+ }
10470
+ const baseBranchKeys = Object.keys(baseBranches);
10471
+ const headBranchKeys = Object.keys(headBranches);
10472
+ if (baseBranchKeys.length !== headBranchKeys.length || !baseBranchKeys.every((k) => headBranchKeys.includes(k))) {
10473
+ return {
10474
+ ok: false,
10475
+ reason: `.stamp/config.yml: bootstrap diff adds or removes branch entries (base: [${baseBranchKeys.join(", ")}], head: [${headBranchKeys.join(", ")}]) \u2014 only review_server addition on existing branches is permitted`,
10476
+ activatesReviewServer: false
10477
+ };
10478
+ }
10479
+ let anyBranchActivatesReviewServer = false;
10480
+ for (const name of baseBranchKeys) {
10481
+ const baseRule = baseBranches[name];
10482
+ const headRule = headBranches[name];
10483
+ if (!baseRule || !headRule) {
10484
+ return {
10485
+ ok: false,
10486
+ reason: `.stamp/config.yml: branch "${name}" is missing at base or head`,
10487
+ activatesReviewServer: false
10488
+ };
10489
+ }
10490
+ if (Array.isArray(baseRule) || Array.isArray(headRule)) {
10491
+ return {
10492
+ ok: false,
10493
+ reason: `.stamp/config.yml: branch "${name}" must be an object`,
10494
+ activatesReviewServer: false
10495
+ };
10496
+ }
10497
+ const baseKeys = Object.keys(baseRule);
10498
+ const headKeys = Object.keys(headRule);
10499
+ for (const k of baseKeys) {
10500
+ if (!(k in headRule)) {
10501
+ return {
10502
+ ok: false,
10503
+ reason: `.stamp/config.yml: branch "${name}" removes field "${k}" \u2014 bootstrap diff cannot remove branch-rule fields`,
10504
+ activatesReviewServer: false
10505
+ };
10506
+ }
10507
+ if (!deepEqual(baseRule[k], headRule[k])) {
10508
+ return {
10509
+ ok: false,
10510
+ reason: `.stamp/config.yml: branch "${name}" modifies field "${k}" \u2014 bootstrap diff can only ADD review_server, not modify existing fields`,
10511
+ activatesReviewServer: false
10512
+ };
10513
+ }
10514
+ }
10515
+ for (const k of headKeys) {
10516
+ if (k in baseRule) continue;
10517
+ if (k !== "review_server") {
10518
+ return {
10519
+ ok: false,
10520
+ reason: `.stamp/config.yml: branch "${name}" adds field "${k}" \u2014 bootstrap diff can only add "review_server"`,
10521
+ activatesReviewServer: false
10522
+ };
10523
+ }
10524
+ if (typeof headRule.review_server !== "string" || !headRule.review_server) {
10525
+ return {
10526
+ ok: false,
10527
+ reason: `.stamp/config.yml: branch "${name}".review_server must be a non-empty string`,
10528
+ activatesReviewServer: false
10529
+ };
10530
+ }
10531
+ anyBranchActivatesReviewServer = true;
10532
+ }
10533
+ }
10534
+ const baseReviewers = baseObj.reviewers ?? {};
10535
+ const headReviewers = headObj.reviewers ?? {};
10536
+ if (typeof baseReviewers !== "object" || baseReviewers === null || Array.isArray(baseReviewers) || typeof headReviewers !== "object" || headReviewers === null || Array.isArray(headReviewers)) {
10537
+ return {
10538
+ ok: false,
10539
+ reason: `.stamp/config.yml: reviewers must be objects at both base and head`,
10540
+ activatesReviewServer: false
10541
+ };
10542
+ }
10543
+ const baseRevKeys = Object.keys(baseReviewers).sort();
10544
+ const headRevKeys = Object.keys(headReviewers).sort();
10545
+ if (baseRevKeys.length !== headRevKeys.length || !baseRevKeys.every((k, i) => k === headRevKeys[i])) {
10546
+ return {
10547
+ ok: false,
10548
+ reason: `.stamp/config.yml: bootstrap diff adds or removes reviewer entries (base: [${baseRevKeys.join(", ")}], head: [${headRevKeys.join(", ")}]) \u2014 Shape 4 activation may only cleanup prompt paths, not add or remove reviewers`,
10549
+ activatesReviewServer: false
10550
+ };
10551
+ }
10552
+ for (const name of baseRevKeys) {
10553
+ const baseRev = baseReviewers[name];
10554
+ const headRev = headReviewers[name];
10555
+ if (deepEqual(baseRev, headRev)) continue;
10556
+ if (headRev !== null && typeof headRev === "object" && !Array.isArray(headRev) && Object.keys(headRev).length === 0) {
10557
+ continue;
10558
+ }
10559
+ return {
10560
+ ok: false,
10561
+ reason: `.stamp/config.yml: reviewer "${name}" modified outside the Shape-4 cleanup pattern (HEAD must equal BASE or be {}); refused`,
10562
+ activatesReviewServer: false
10563
+ };
10564
+ }
10565
+ return { ok: true, activatedPaths: [], activatesReviewServer: anyBranchActivatesReviewServer };
10566
+ }
10567
+ function validateManifestChange(args) {
10568
+ const headYaml = readAtRef(args.repoRoot, args.headSha, ".stamp/trusted-keys/manifest.yml");
10569
+ if (headYaml === null) {
10570
+ return {
10571
+ ok: false,
10572
+ reason: `.stamp/trusted-keys/manifest.yml unreadable at head ${args.headSha.slice(0, 8)}`
10573
+ };
10574
+ }
10575
+ let headParsed;
10576
+ try {
10577
+ headParsed = parseYaml10(headYaml);
10578
+ } catch (err) {
10579
+ return {
10580
+ ok: false,
10581
+ reason: `.stamp/trusted-keys/manifest.yml parse error at head: ${err instanceof Error ? err.message : String(err)}`
10582
+ };
10583
+ }
10584
+ if (!headParsed || typeof headParsed !== "object" || Array.isArray(headParsed) || !headParsed.keys || typeof headParsed.keys !== "object") {
10585
+ return {
10586
+ ok: false,
10587
+ reason: `.stamp/trusted-keys/manifest.yml: missing or malformed top-level "keys" map at head`
10588
+ };
10589
+ }
10590
+ const headKeys = headParsed.keys;
10591
+ let baseKeys = {};
10592
+ if (!args.statusAdd) {
10593
+ const baseYaml = readAtRef(args.repoRoot, args.baseSha, ".stamp/trusted-keys/manifest.yml");
10594
+ if (baseYaml === null) {
10595
+ return {
10596
+ ok: false,
10597
+ reason: `.stamp/trusted-keys/manifest.yml unreadable at base ${args.baseSha.slice(0, 8)}`
10598
+ };
10599
+ }
10600
+ let baseParsed;
10601
+ try {
10602
+ baseParsed = parseYaml10(baseYaml);
10603
+ } catch (err) {
10604
+ return {
10605
+ ok: false,
10606
+ reason: `.stamp/trusted-keys/manifest.yml parse error at base: ${err instanceof Error ? err.message : String(err)}`
10607
+ };
10608
+ }
10609
+ if (!baseParsed || typeof baseParsed !== "object" || Array.isArray(baseParsed) || !baseParsed.keys || typeof baseParsed.keys !== "object") {
10610
+ return {
10611
+ ok: false,
10612
+ reason: `.stamp/trusted-keys/manifest.yml: missing or malformed top-level "keys" map at base`
10613
+ };
10614
+ }
10615
+ baseKeys = baseParsed.keys;
10616
+ }
10617
+ for (const name of Object.keys(baseKeys)) {
10618
+ if (!(name in headKeys)) {
10619
+ return {
10620
+ ok: false,
10621
+ reason: `.stamp/trusted-keys/manifest.yml: bootstrap diff removes entry "${name}" \u2014 refused (only additions of [server]+role_source:server entries are permitted)`
10622
+ };
10623
+ }
10624
+ if (!deepEqual(baseKeys[name], headKeys[name])) {
10625
+ return {
10626
+ ok: false,
10627
+ reason: `.stamp/trusted-keys/manifest.yml: bootstrap diff modifies existing entry "${name}" \u2014 refused (only additions are permitted)`
10628
+ };
10629
+ }
10630
+ }
10631
+ for (const name of Object.keys(headKeys)) {
10632
+ if (name in baseKeys) continue;
10633
+ const entry = headKeys[name];
10634
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
10635
+ return {
10636
+ ok: false,
10637
+ reason: `.stamp/trusted-keys/manifest.yml: new entry "${name}" is not a YAML object`
10638
+ };
10639
+ }
10640
+ const e = entry;
10641
+ if (!Array.isArray(e.capabilities)) {
10642
+ return {
10643
+ ok: false,
10644
+ reason: `.stamp/trusted-keys/manifest.yml: new entry "${name}" missing capabilities array`
10645
+ };
10646
+ }
10647
+ const caps = e.capabilities.filter((c) => typeof c === "string");
10648
+ if (caps.length !== 1 || caps[0] !== "server") {
10649
+ return {
10650
+ ok: false,
10651
+ reason: `.stamp/trusted-keys/manifest.yml: new entry "${name}" has capabilities [${caps.join(", ")}] \u2014 bootstrap diff only permits adding entries with capabilities: [server] exactly`
10652
+ };
10653
+ }
10654
+ if (e.role_source !== "server") {
10655
+ return {
10656
+ ok: false,
10657
+ reason: `.stamp/trusted-keys/manifest.yml: new entry "${name}" must have role_source: server (got ${JSON.stringify(e.role_source)}). This invariant flags entries auto-published by stamp-server.`
10658
+ };
10659
+ }
10660
+ if (typeof e.fingerprint !== "string" || !/^sha256:[0-9a-f]{64}$/.test(e.fingerprint)) {
10661
+ return {
10662
+ ok: false,
10663
+ reason: `.stamp/trusted-keys/manifest.yml: new entry "${name}" has invalid fingerprint`
10664
+ };
10665
+ }
10666
+ }
10667
+ return { ok: true, activatedPaths: [] };
10668
+ }
10669
+ var MIGRATION_BOOTSTRAP_KEY = "migration_bootstrap";
10670
+ function bootstrapAdminSigningBytes(args) {
10671
+ const augmented = {
10672
+ ...args.payloadV4,
10673
+ [MIGRATION_BOOTSTRAP_KEY]: args.marker,
10674
+ trust_anchor_signatures: []
10675
+ };
10676
+ return canonicalSerializePayload(augmented);
10677
+ }
10678
+ function readAtRef(repoRoot, ref, relPath) {
10679
+ const result = spawnSync14("git", ["show", `${ref}:${relPath}`], {
10680
+ cwd: repoRoot,
10681
+ encoding: "utf8"
10682
+ });
10683
+ if (result.status !== 0) return null;
10684
+ return result.stdout ?? "";
10685
+ }
10686
+ function deepEqual(a, b) {
10687
+ if (a === b) return true;
10688
+ if (a === null || b === null) return false;
10689
+ if (typeof a !== typeof b) return false;
10690
+ if (typeof a !== "object") return false;
10691
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
10692
+ if (Array.isArray(a) && Array.isArray(b)) {
10693
+ if (a.length !== b.length) return false;
10694
+ for (let i = 0; i < a.length; i++) {
10695
+ if (!deepEqual(a[i], b[i])) return false;
10696
+ }
10697
+ return true;
10698
+ }
10699
+ const ao = a;
10700
+ const bo = b;
10701
+ const aKeys = Object.keys(ao).sort();
10702
+ const bKeys = Object.keys(bo).sort();
10703
+ if (aKeys.length !== bKeys.length) return false;
10704
+ for (let i = 0; i < aKeys.length; i++) {
10705
+ if (aKeys[i] !== bKeys[i]) return false;
10706
+ if (!deepEqual(ao[aKeys[i]], bo[bKeys[i]])) return false;
10707
+ }
10708
+ return true;
10709
+ }
10710
+
10711
+ // src/commands/attest.ts
10712
+ function runAttest(opts) {
10713
+ const repoRoot = findRepoRoot();
10714
+ const config2 = loadConfig(stampConfigFile(repoRoot));
10715
+ const rule = findBranchRule(config2.branches, opts.into);
10716
+ if (!rule) {
10717
+ throw new Error(
10718
+ `no branch rule for "${opts.into}" in .stamp/config.yml. Configured branches: ${Object.keys(config2.branches).join(", ") || "(none)"}.`
10719
+ );
10720
+ }
10721
+ const branchRef = opts.branch ?? "HEAD";
10722
+ const revspec = `${opts.into}..${branchRef}`;
10723
+ const resolved = resolveDiff(revspec, repoRoot);
10724
+ const patch_id = patchIdForSpan(resolved.base_sha, resolved.head_sha, repoRoot);
10725
+ const target_branch_tip_sha = runGit(
10726
+ ["rev-parse", `${opts.into}^{commit}`],
10727
+ repoRoot
10728
+ ).trim();
10729
+ const { keypair } = ensureUserKeypair();
10730
+ const result = opts.migrateExisting ? buildBootstrapEnvelope({
10731
+ repoRoot,
10732
+ revspec,
10733
+ baseSha: resolved.base_sha,
10734
+ headSha: resolved.head_sha,
10735
+ diff: resolved.diff,
10736
+ targetBranch: opts.into,
10737
+ targetBranchTipSha: target_branch_tip_sha,
10738
+ patchId: patch_id,
10739
+ operatorPrivateKeyPem: keypair.privateKeyPem,
10740
+ operatorPublicKeyPem: keypair.publicKeyPem,
10741
+ operatorFingerprint: keypair.fingerprint
10742
+ }) : rule.review_server ? buildV3Envelope({
10743
+ repoRoot,
10744
+ revspec,
10745
+ baseSha: resolved.base_sha,
10746
+ headSha: resolved.head_sha,
10747
+ diff: resolved.diff,
10748
+ targetBranch: opts.into,
10749
+ targetBranchTipSha: target_branch_tip_sha,
10750
+ patchId: patch_id,
10751
+ requiredReviewers: rule.required,
10752
+ operatorPrivateKeyPem: keypair.privateKeyPem,
10753
+ operatorFingerprint: keypair.fingerprint
10754
+ }) : buildV2Envelope({
10755
+ repoRoot,
10756
+ revspec,
10757
+ baseSha: resolved.base_sha,
10758
+ headSha: resolved.head_sha,
10759
+ targetBranch: opts.into,
10760
+ targetBranchTipSha: target_branch_tip_sha,
10761
+ patchId: patch_id,
10762
+ requiredReviewers: rule.required,
10763
+ operatorPrivateKeyPem: keypair.privateKeyPem,
10764
+ operatorFingerprint: keypair.fingerprint
10765
+ });
10766
+ const { ref, blob_sha } = writeAttestationRef(
10767
+ { payload: result.payload, signature: result.signature },
10768
+ repoRoot
10769
+ );
10770
+ const bar = "\u2500".repeat(72);
10771
+ console.log(bar);
10772
+ console.log(`attested ${branchRef} for merge into '${opts.into}'`);
10773
+ console.log(bar);
10774
+ console.log(` patch-id: ${patch_id}`);
10775
+ console.log(
10776
+ ` base\u2192head: ${resolved.base_sha.slice(0, 8)} \u2192 ${resolved.head_sha.slice(0, 8)}`
10777
+ );
10778
+ console.log(` signed by: ${keypair.fingerprint}`);
10779
+ console.log(` approvals: ${result.reviewerNames.length > 0 ? result.reviewerNames.join(", ") : "(none \u2014 bootstrap envelope)"}`);
10780
+ const schemaLabel = result.payload.migration_bootstrap ? " (Shape 4 migration bootstrap)" : result.payload.schema_version === PR_ATTESTATION_SCHEMA_VERSION ? " (server-attested)" : " (legacy)";
10781
+ console.log(` schema: v${result.payload.schema_version}${schemaLabel}`);
10782
+ console.log(` ref: ${ref}`);
10783
+ console.log(` blob: ${blob_sha.slice(0, 12)}`);
10784
+ console.log(bar);
10785
+ if (opts.pushTo) {
10786
+ pushBranchAndAttestation(opts.pushTo, ref, repoRoot);
10787
+ console.log(
10788
+ `
10789
+ \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.`
10790
+ );
10791
+ } else {
10792
+ console.log(
10793
+ `
10794
+ 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:
10795
+
10796
+ git push <remote> HEAD ${ref}
10797
+
10798
+ Or re-run with --push <remote> next time.`
10799
+ );
10800
+ }
10801
+ }
10802
+ function buildV3Envelope(input) {
10803
+ const diffBytes = Buffer.from(input.diff, "utf8");
10804
+ const diffSha256 = createHash12("sha256").update(diffBytes).digest("hex");
10805
+ let manifestYaml;
10806
+ try {
10807
+ manifestYaml = showAtRef(
10808
+ input.baseSha,
10809
+ ".stamp/trusted-keys/manifest.yml",
10810
+ input.repoRoot
10811
+ );
10812
+ } catch (err) {
10813
+ throw new Error(
10814
+ `review_server is configured but .stamp/trusted-keys/manifest.yml is missing at base ${input.baseSha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}. Server-attested PR mode requires the manifest in the merge-base tree so each approval's server signature can be checked against the keys the repo trusted at attestation time. Commit a manifest with capabilities: [server] entries for the review server before attesting.`
10815
+ );
10816
+ }
10817
+ const manifest = parseManifest(manifestYaml);
10818
+ if (!manifest) {
10819
+ throw new Error(
10820
+ `.stamp/trusted-keys/manifest.yml at base ${input.baseSha.slice(0, 8)} failed to parse as a valid trusted-keys manifest. Fix the YAML (syntax error, duplicate fingerprint, unknown capability, etc.) and re-run \`stamp attest\`.`
10821
+ );
10822
+ }
10823
+ const pubFilenames = listFilesAtRef(
10824
+ input.baseSha,
10825
+ ".stamp/trusted-keys",
10826
+ input.repoRoot
10827
+ );
10828
+ const pubkeyByFingerprint = buildPubkeyMap(
10829
+ pubFilenames,
10830
+ (relPath) => showAtRef(input.baseSha, relPath, input.repoRoot)
10831
+ );
10832
+ const db = openDb(stampStateDbPath(input.repoRoot));
10833
+ let entries;
10834
+ try {
10835
+ const rows = serverApprovalsFor(db, input.baseSha, input.headSha);
10836
+ const byReviewer = new Map(rows.map((r) => [r.reviewer, r]));
10837
+ entries = input.requiredReviewers.map((reviewerName) => {
10838
+ const row = byReviewer.get(reviewerName);
10839
+ if (!row) {
10840
+ throw new Error(
10841
+ `missing server signature for reviewer "${reviewerName}" at base\u2192head ${input.baseSha.slice(0, 8)}\u2192${input.headSha.slice(0, 8)}. Server-attested PR mode requires every required reviewer to have a stamp-server-signed approval in the local DB. Possible causes:
10842
+ \u2022 the stamp-server is older than 2.0.1 and doesn't produce PR-attestation v3 payloads (upgrade the server)
10843
+ \u2022 \`stamp review --diff ${input.revspec}\` hasn't been run against this exact (base, head) pair yet
10844
+ \u2022 the review was run in local-mode (no \`review_server\` configured at review time)
10845
+ Run \`stamp review --diff ${input.revspec}\` to populate the signed row, then re-run \`stamp attest\`.`
10846
+ );
10847
+ }
10848
+ let parsedJson;
10849
+ try {
10850
+ parsedJson = JSON.parse(row.approval_json);
10851
+ } catch (err) {
10852
+ throw new Error(
10853
+ `server approval row for reviewer "${reviewerName}" has malformed JSON in server_approval_json \u2014 DB corruption or a writer-side bug. Re-run \`stamp review --diff ${input.revspec}\` to write a fresh row. (parse error: ${err instanceof Error ? err.message : String(err)})`
10854
+ );
10855
+ }
10856
+ if (!parsedJson || typeof parsedJson !== "object" || Array.isArray(parsedJson)) {
10857
+ throw new Error(
10858
+ `server approval row for reviewer "${reviewerName}" parsed to a non-object value \u2014 DB corruption or a writer-side bug. Re-run \`stamp review --diff ${input.revspec}\` to write a fresh row.`
10859
+ );
10860
+ }
10861
+ const obj = parsedJson;
10862
+ for (const field of [
10863
+ "reviewer",
10864
+ "verdict",
10865
+ "prompt_sha256",
10866
+ "diff_sha256",
10867
+ "base_sha",
10868
+ "head_sha",
10440
10869
  "issued_at",
10441
10870
  "server_key_id"
10442
10871
  ]) {
@@ -10511,6 +10940,7 @@ Run \`stamp review --diff ${input.revspec}\` to populate the signed row, then re
10511
10940
  db.close();
10512
10941
  }
10513
10942
  const trustAnchorSignatures = [];
10943
+ const manifestSnapshot = snapshotSha256(manifest);
10514
10944
  const payload = {
10515
10945
  schema_version: PR_ATTESTATION_SCHEMA_VERSION,
10516
10946
  patch_id: input.patchId,
@@ -10519,6 +10949,7 @@ Run \`stamp review --diff ${input.revspec}\` to populate the signed row, then re
10519
10949
  target_branch: input.targetBranch,
10520
10950
  target_branch_tip_sha: input.targetBranchTipSha,
10521
10951
  diff_sha256: diffSha256,
10952
+ manifest_snapshot_sha256: manifestSnapshot,
10522
10953
  approvals: entries,
10523
10954
  checks: [],
10524
10955
  // Phase-1 deliberate omission — see file-level comment.
@@ -10579,6 +11010,11 @@ function buildV2Envelope(input) {
10579
11010
  `reviewer "${a.reviewer}" approved the diff but is not defined in .stamp/config.yml at base ${input.baseSha.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.`
10580
11011
  );
10581
11012
  }
11013
+ if (def.prompt === void 0) {
11014
+ throw new Error(
11015
+ `reviewer "${a.reviewer}": no \`prompt:\` configured and no \`review_server:\` on branch rule \u2014 set \`reviewers.${a.reviewer}.prompt\` in .stamp/config.yml or configure a \`review_server:\` for server-attested PR mode.`
11016
+ );
11017
+ }
10582
11018
  const promptText = showAtRef(input.baseSha, def.prompt, input.repoRoot);
10583
11019
  const source = readReviewerSource2(a.reviewer, input.repoRoot);
10584
11020
  return {
@@ -10611,8 +11047,187 @@ function buildV2Envelope(input) {
10611
11047
  reviewerNames: approvals.map((a) => a.reviewer)
10612
11048
  };
10613
11049
  }
11050
+ function buildBootstrapEnvelope(input) {
11051
+ const validation = validateShape4ActivationDiff({
11052
+ repoRoot: input.repoRoot,
11053
+ baseSha: input.baseSha,
11054
+ headSha: input.headSha
11055
+ });
11056
+ if (!validation.ok) {
11057
+ throw new Error(
11058
+ `--migrate-existing refused: ${validation.reason}
11059
+
11060
+ The bootstrap flag accepts ONLY a narrow Shape 4 activation diff: adding \`review_server:\` to a branch rule in .stamp/config.yml, adding new [server]+role_source:server entries to .stamp/trusted-keys/manifest.yml, and adding the corresponding new .pub files. Land any unrelated changes through the normal \`stamp attest\` flow AFTER the bootstrap PR merges.`
11061
+ );
11062
+ }
11063
+ const activatedPaths = validation.activatedPaths;
11064
+ const baseConfigYaml = readAtBaseSha(
11065
+ input.repoRoot,
11066
+ input.baseSha,
11067
+ ".stamp/config.yml"
11068
+ );
11069
+ if (baseConfigYaml === null) {
11070
+ throw new Error(
11071
+ `--migrate-existing refused: .stamp/config.yml is missing at base ${input.baseSha.slice(0, 8)}. Bootstrap requires the config file in the merge-base tree (the activation diff modifies it).`
11072
+ );
11073
+ }
11074
+ const baseRules = extractPathRulesFromYaml(baseConfigYaml);
11075
+ if (baseRules.length === 0) {
11076
+ throw new Error(
11077
+ `--migrate-existing refused: no \`path_rules\` configured at base ${input.baseSha.slice(0, 8)}. The bootstrap path needs a path_rules entry covering the activated paths with \`bypass_review_cycle: true\` so the verifier knows the reviewer cycle is intentionally skipped for this PR. Add a \`path_rules\` entry for \`.stamp/**\` in a separate prior PR before bootstrapping.`
11078
+ );
11079
+ }
11080
+ const matchedRule = matchAnyCoveringRule(activatedPaths, baseRules);
11081
+ if (!matchedRule) {
11082
+ throw new Error(
11083
+ `--migrate-existing refused: \`path_rules\` at base ${input.baseSha.slice(0, 8)} does not cover every activated path. Activated: ${activatedPaths.join(", ")}. Configured patterns: ${baseRules.map((r) => `"${r.pattern}"`).join(", ")}. Add or widen a path_rules entry that matches these paths (in a separate prior PR \u2014 the bootstrap diff cannot modify path_rules).`
11084
+ );
11085
+ }
11086
+ if (!matchedRule.bypass_review_cycle) {
11087
+ throw new Error(
11088
+ `--migrate-existing refused: matched path_rule "${matchedRule.pattern}" at base has \`bypass_review_cycle: false\` \u2014 bootstrap requires the rule to \`bypass_review_cycle: true\` (otherwise the reviewer cycle is still nominally required and the bootstrap envelope is structurally invalid).`
11089
+ );
11090
+ }
11091
+ if (matchedRule.minimum_signatures > 1) {
11092
+ throw new Error(
11093
+ `--migrate-existing refused: matched path_rule "${matchedRule.pattern}" at base requires \`minimum_signatures: ${matchedRule.minimum_signatures}\` admin signatures, but the bootstrap path only collects a single operator-self admin signature today. Options: (a) temporarily lower the rule to \`minimum_signatures: 1\` in a prior PR; (b) for the migration commit only, use the standard \`stamp admin sign\` flow against the eventual landed commit.`
11094
+ );
11095
+ }
11096
+ const workingTreeManifestYaml = readWorkingTreeManifestYaml(input.repoRoot);
11097
+ if (workingTreeManifestYaml === null) {
11098
+ throw new Error(
11099
+ `--migrate-existing refused: .stamp/trusted-keys/manifest.yml is missing from the working tree. Bootstrap requires the working-tree manifest to bind the operator's local stamp key to the \`admin\` capability.`
11100
+ );
11101
+ }
11102
+ const workingTreeManifest = parseManifest(workingTreeManifestYaml);
11103
+ if (!workingTreeManifest) {
11104
+ throw new Error(
11105
+ `--migrate-existing refused: .stamp/trusted-keys/manifest.yml in the working tree failed to parse (bad YAML, duplicate fingerprint, unknown capability, etc.).`
11106
+ );
11107
+ }
11108
+ const operatorCaps = resolveCapability(workingTreeManifest, input.operatorFingerprint);
11109
+ if (operatorCaps === null) {
11110
+ throw new Error(
11111
+ `--migrate-existing refused: your local stamp key (${input.operatorFingerprint}) is not listed in the working-tree .stamp/trusted-keys/manifest.yml. Add your key with \`capabilities: [admin]\` (in a separate prior PR) before bootstrapping the Shape 4 migration.`
11112
+ );
11113
+ }
11114
+ if (!operatorCaps.includes("admin")) {
11115
+ throw new Error(
11116
+ `--migrate-existing refused: your local stamp key (${input.operatorFingerprint}) has capabilities [${operatorCaps.join(", ")}] in the working-tree manifest \u2014 needs \`admin\` for bootstrap. Either grant your key admin capability (in a separate prior PR) or have a different admin run \`stamp attest --migrate-existing\`.`
11117
+ );
11118
+ }
11119
+ const baseManifestYaml = readAtBaseSha(
11120
+ input.repoRoot,
11121
+ input.baseSha,
11122
+ ".stamp/trusted-keys/manifest.yml"
11123
+ );
11124
+ if (baseManifestYaml === null) {
11125
+ throw new Error(
11126
+ `--migrate-existing refused: .stamp/trusted-keys/manifest.yml is missing at base ${input.baseSha.slice(0, 8)}. Bootstrap requires the manifest in the merge-base tree (the operator's outer signature commits to its snapshot hash). Run \`stamp init --migrate-to-server-attested\` first to seed the manifest.`
11127
+ );
11128
+ }
11129
+ const baseManifest = parseManifest(baseManifestYaml);
11130
+ if (!baseManifest) {
11131
+ throw new Error(
11132
+ `--migrate-existing refused: .stamp/trusted-keys/manifest.yml at base ${input.baseSha.slice(0, 8)} failed to parse.`
11133
+ );
11134
+ }
11135
+ const manifestSnapshot = snapshotSha256(baseManifest);
11136
+ const diffBytes = Buffer.from(input.diff, "utf8");
11137
+ const diffSha256 = createHash12("sha256").update(diffBytes).digest("hex");
11138
+ const marker = {
11139
+ activated_paths: activatedPaths
11140
+ };
11141
+ const payloadV4Shape = {
11142
+ schema_version: PR_ATTESTATION_SCHEMA_VERSION,
11143
+ base_sha: input.baseSha,
11144
+ head_sha: input.headSha,
11145
+ target_branch: input.targetBranch,
11146
+ diff_sha256: diffSha256,
11147
+ manifest_snapshot_sha256: manifestSnapshot,
11148
+ approvals: [],
11149
+ // bootstrap: no server signatures
11150
+ checks: [],
11151
+ trust_anchor_signatures: [],
11152
+ signer_key_id: input.operatorFingerprint
11153
+ };
11154
+ const adminSigningBytes = bootstrapAdminSigningBytes({
11155
+ payloadV4: payloadV4Shape,
11156
+ marker
11157
+ });
11158
+ const adminSignatureB64 = signBytes(input.operatorPrivateKeyPem, adminSigningBytes);
11159
+ const selfOk = verifyBytes(
11160
+ input.operatorPublicKeyPem,
11161
+ adminSigningBytes,
11162
+ adminSignatureB64
11163
+ );
11164
+ if (!selfOk) {
11165
+ throw new Error(
11166
+ `internal error: just-produced bootstrap admin signature failed self-verification. Refusing to write a bad envelope. File a bug at https://github.com/OpenThinkAi/stamp-cli/issues.`
11167
+ );
11168
+ }
11169
+ const trustAnchorSignatures = [
11170
+ {
11171
+ signer_key_id: input.operatorFingerprint,
11172
+ signature: adminSignatureB64
11173
+ }
11174
+ ];
11175
+ const payload = {
11176
+ schema_version: PR_ATTESTATION_SCHEMA_VERSION,
11177
+ patch_id: input.patchId,
11178
+ base_sha: input.baseSha,
11179
+ head_sha: input.headSha,
11180
+ target_branch: input.targetBranch,
11181
+ target_branch_tip_sha: input.targetBranchTipSha,
11182
+ diff_sha256: diffSha256,
11183
+ manifest_snapshot_sha256: manifestSnapshot,
11184
+ approvals: [],
11185
+ checks: [],
11186
+ trust_anchor_signatures: trustAnchorSignatures,
11187
+ signer_key_id: input.operatorFingerprint,
11188
+ migration_bootstrap: marker
11189
+ };
11190
+ const signature = signBytes(input.operatorPrivateKeyPem, serializePayload2(payload));
11191
+ return {
11192
+ payload,
11193
+ signature,
11194
+ reviewerNames: []
11195
+ };
11196
+ }
11197
+ function readWorkingTreeManifestYaml(repoRoot) {
11198
+ try {
11199
+ return showAtRef("HEAD", ".stamp/trusted-keys/manifest.yml", repoRoot);
11200
+ } catch {
11201
+ return null;
11202
+ }
11203
+ }
11204
+ function readAtBaseSha(repoRoot, baseSha, relPath) {
11205
+ try {
11206
+ return showAtRef(baseSha, relPath, repoRoot);
11207
+ } catch {
11208
+ return null;
11209
+ }
11210
+ }
11211
+ function extractPathRulesFromYaml(yamlText) {
11212
+ let parsed;
11213
+ try {
11214
+ parsed = parseYaml11(yamlText);
11215
+ } catch {
11216
+ return [];
11217
+ }
11218
+ if (!parsed || typeof parsed !== "object") return [];
11219
+ const { rules } = parsePathRules(parsed.path_rules);
11220
+ return rules;
11221
+ }
11222
+ function matchAnyCoveringRule(activatedPaths, rules) {
11223
+ for (const rule of rules) {
11224
+ const allMatch = activatedPaths.every((p) => pathMatchesAny(p, [rule.pattern]));
11225
+ if (allMatch) return rule;
11226
+ }
11227
+ return null;
11228
+ }
10614
11229
  function pushBranchAndAttestation(remote, attestationRef, repoRoot) {
10615
- const result = spawnSync14(
11230
+ const result = spawnSync15(
10616
11231
  "git",
10617
11232
  ["push", "--atomic", remote, "HEAD", attestationRef],
10618
11233
  { cwd: repoRoot, stdio: "inherit" }
@@ -10907,7 +11522,7 @@ function loadOrEmpty() {
10907
11522
  }
10908
11523
 
10909
11524
  // src/commands/reviewers.ts
10910
- import { spawnSync as spawnSync15 } from "child_process";
11525
+ import { spawnSync as spawnSync16 } from "child_process";
10911
11526
  import {
10912
11527
  existsSync as existsSync21,
10913
11528
  readFileSync as readFileSync18,
@@ -10916,7 +11531,7 @@ import {
10916
11531
  writeFileSync as writeFileSync15
10917
11532
  } from "fs";
10918
11533
  import { join as join16, relative as relative3, resolve as resolve2 } from "path";
10919
- import { parse as parseYaml10, stringify as stringifyYaml3 } from "yaml";
11534
+ import { parse as parseYaml12, stringify as stringifyYaml3 } from "yaml";
10920
11535
 
10921
11536
  // src/lib/reviewerLock.ts
10922
11537
  import { existsSync as existsSync20, readFileSync as readFileSync17, writeFileSync as writeFileSync14 } from "fs";
@@ -10951,6 +11566,11 @@ function checkReviewerDrift(repoRoot, reviewerName, def) {
10951
11566
  if (!lock) {
10952
11567
  return unpinnedResult();
10953
11568
  }
11569
+ if (def.prompt === void 0) {
11570
+ throw new Error(
11571
+ `reviewer "${reviewerName}" has a lock file but no \`prompt:\` configured (server-bundled in Shape 4). Lock files pin local prompt bytes and don't apply in server-attested mode. Delete .stamp/reviewers/${reviewerName}.lock.json to un-pin, or set \`reviewers.${reviewerName}.prompt\` in .stamp/config.yml if you intend to author the prompt locally.`
11572
+ );
11573
+ }
10954
11574
  const promptPath = join15(repoRoot, def.prompt);
10955
11575
  if (!existsSync20(promptPath)) {
10956
11576
  throw new Error(
@@ -11036,6 +11656,10 @@ function reviewersList() {
11036
11656
  const maxNameLen = Math.max(...names.map((n) => n.length));
11037
11657
  for (const name of names) {
11038
11658
  const def = config2.reviewers[name];
11659
+ if (def.prompt === void 0) {
11660
+ console.log(` ${name.padEnd(maxNameLen)} (server-bundled \u2014 no local prompt)`);
11661
+ continue;
11662
+ }
11039
11663
  const abs = resolve2(repoRoot, def.prompt);
11040
11664
  let annotation = "";
11041
11665
  if (!existsSync21(abs)) {
@@ -11062,6 +11686,11 @@ function reviewersEdit(name) {
11062
11686
  `reviewer "${name}" is not configured. Run \`stamp reviewers list\` to see available reviewers.`
11063
11687
  );
11064
11688
  }
11689
+ if (def.prompt === void 0) {
11690
+ throw new Error(
11691
+ `reviewer "${name}" has no \`prompt:\` configured (server-bundled in Shape 4). There is no local prompt file to edit. To author the prompt locally, set \`reviewers.${name}.prompt\` to a path under .stamp/reviewers/ in .stamp/config.yml.`
11692
+ );
11693
+ }
11065
11694
  const target = resolve2(repoRoot, def.prompt);
11066
11695
  launchEditor(target);
11067
11696
  }
@@ -11126,6 +11755,10 @@ function reviewersRemove(name, opts = {}) {
11126
11755
  delete config2.reviewers[name];
11127
11756
  writeFileSync15(configPath, stringifyConfig(config2));
11128
11757
  console.log(`reviewer "${name}" removed from .stamp/config.yml`);
11758
+ if (def.prompt === void 0) {
11759
+ console.log(`(no local prompt file \u2014 reviewer was server-bundled)`);
11760
+ return;
11761
+ }
11129
11762
  if (opts.deleteFile) {
11130
11763
  const promptAbs = resolve2(repoRoot, def.prompt);
11131
11764
  if (existsSync21(promptAbs)) {
@@ -11159,6 +11792,11 @@ async function reviewersTest(name, diff) {
11159
11792
  console.log(` prompt sourced from working tree (test/iteration use case)`);
11160
11793
  console.log();
11161
11794
  const def = config2.reviewers[name];
11795
+ if (def.prompt === void 0) {
11796
+ throw new Error(
11797
+ `reviewer "${name}" has no \`prompt:\` configured (server-bundled in Shape 4). \`stamp reviewers test\` is a local prompt-iteration helper and needs a local prompt file to invoke. Set \`reviewers.${name}.prompt\` in .stamp/config.yml to a path under .stamp/reviewers/ to use this command.`
11798
+ );
11799
+ }
11162
11800
  const promptPath = join16(repoRoot, def.prompt);
11163
11801
  const systemPrompt = readFileSync18(promptPath, "utf8");
11164
11802
  const result = await invokeReviewer({
@@ -11203,7 +11841,9 @@ function reviewersShow(name, opts) {
11203
11841
  const bar = "\u2500".repeat(72);
11204
11842
  console.log(bar);
11205
11843
  console.log(`reviewer: ${name}`);
11206
- console.log(`prompt: ${config2.reviewers[name].prompt}`);
11844
+ console.log(
11845
+ `prompt: ${config2.reviewers[name].prompt ?? "(server-bundled \u2014 no local prompt)"}`
11846
+ );
11207
11847
  console.log(bar);
11208
11848
  if (stats.total === 0) {
11209
11849
  console.log(" no verdicts recorded yet");
@@ -11290,7 +11930,7 @@ async function reviewersFetch(reviewerName, opts) {
11290
11930
  let tools;
11291
11931
  let mcpServers;
11292
11932
  if (configYaml !== null) {
11293
- const parsed = parseYaml10(configYaml) ?? {};
11933
+ const parsed = parseYaml12(configYaml) ?? {};
11294
11934
  if (Array.isArray(parsed.tools)) {
11295
11935
  tools = parseToolsLoose(parsed.tools);
11296
11936
  }
@@ -11551,7 +12191,7 @@ function buildConfigYamlHint(reviewerName, tools, mcpServers) {
11551
12191
  }
11552
12192
  function launchEditor(path2) {
11553
12193
  const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? (process.platform === "win32" ? "notepad" : "vi");
11554
- const result = spawnSync15(editor, [path2], { stdio: "inherit" });
12194
+ const result = spawnSync16(editor, [path2], { stdio: "inherit" });
11555
12195
  if (result.error) {
11556
12196
  throw new Error(
11557
12197
  `failed to launch editor "${editor}": ${result.error.message}`
@@ -11635,7 +12275,7 @@ function printGate(result, base_sha, head_sha) {
11635
12275
  }
11636
12276
 
11637
12277
  // src/commands/update.ts
11638
- import { spawnSync as spawnSync16 } from "child_process";
12278
+ import { spawnSync as spawnSync17 } from "child_process";
11639
12279
  var PKG_NAME = "@openthink/stamp";
11640
12280
  function compareSemver(a, b) {
11641
12281
  const parse2 = (v) => (v.split("-")[0] ?? "0.0.0").split(".").map((n) => parseInt(n, 10) || 0);
@@ -11654,7 +12294,7 @@ function runUpdate() {
11654
12294
  `);
11655
12295
  process.stdout.write(`checking npm registry for latest...
11656
12296
  `);
11657
- const viewResult = spawnSync16("npm", ["view", PKG_NAME, "version"], {
12297
+ const viewResult = spawnSync17("npm", ["view", PKG_NAME, "version"], {
11658
12298
  encoding: "utf8"
11659
12299
  });
11660
12300
  if (viewResult.error || viewResult.status !== 0) {
@@ -11688,7 +12328,7 @@ function runUpdate() {
11688
12328
  }
11689
12329
  process.stdout.write(`installing ${PKG_NAME}@${latest}...
11690
12330
  `);
11691
- const installResult = spawnSync16(
12331
+ const installResult = spawnSync17(
11692
12332
  "npm",
11693
12333
  ["install", "-g", `${PKG_NAME}@${latest}`],
11694
12334
  { stdio: "inherit" }
@@ -11709,9 +12349,9 @@ through that tool instead \u2014 this command only uses 'npm install -g'.`
11709
12349
  }
11710
12350
 
11711
12351
  // src/commands/verify.ts
11712
- import { execFileSync as execFileSync7, spawnSync as spawnSync17 } from "child_process";
12352
+ import { execFileSync as execFileSync7, spawnSync as spawnSync18 } from "child_process";
11713
12353
  function loadConfigAtSha(sha, repoRoot) {
11714
- const result = spawnSync17(
12354
+ const result = spawnSync18(
11715
12355
  "git",
11716
12356
  ["show", `${sha}:.stamp/config.yml`],
11717
12357
  { cwd: repoRoot, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }
@@ -11849,6 +12489,12 @@ function verifyReviewerHashes(sha, payload, repoRoot, config2) {
11849
12489
  `v2 attestation: reviewer "${approval.reviewer}" is in payload but not defined in config.reviewers at the merge commit`
11850
12490
  );
11851
12491
  }
12492
+ if (def.prompt === void 0) {
12493
+ fail(
12494
+ sha,
12495
+ `v2 attestation: reviewer "${approval.reviewer}" has no \`prompt:\` in .stamp/config.yml at the merge commit. v2 attestations cite prompt_sha256 over the on-tree prompt file, but the producer flow for server-bundled prompts is v4 (server-attested) \u2014 the envelope and config shape are inconsistent.`
12496
+ );
12497
+ }
11852
12498
  const promptBytes = tryGitShow(`${sha}:${def.prompt}`, repoRoot);
11853
12499
  if (promptBytes === null) {
11854
12500
  fail(
@@ -11922,8 +12568,9 @@ function git2(args, cwd) {
11922
12568
  }
11923
12569
 
11924
12570
  // src/commands/verifyPr.ts
11925
- import { spawnSync as spawnSync18 } from "child_process";
11926
- import { parse as parseYaml11 } from "yaml";
12571
+ import { spawnSync as spawnSync19 } from "child_process";
12572
+ import { createHash as createHash13 } from "crypto";
12573
+ import { parse as parseYaml13 } from "yaml";
11927
12574
  function runVerifyPr(opts) {
11928
12575
  const repoRoot = opts.repoPath ?? findRepoRoot();
11929
12576
  const resolved = resolveDiff(`${opts.base}..${opts.head}`, repoRoot);
@@ -11964,6 +12611,10 @@ function runVerifyPr(opts) {
11964
12611
  );
11965
12612
  }
11966
12613
  if (envelope.payload.schema_version >= MIN_ACCEPTED_PR_ATTESTATION_VERSION) {
12614
+ if (envelope.payload.migration_bootstrap !== void 0) {
12615
+ verifyBootstrapEnvelope(envelope, opts, resolved, patch_id, repoRoot);
12616
+ return;
12617
+ }
11967
12618
  verifyV3Envelope(envelope, opts, resolved, patch_id, repoRoot);
11968
12619
  return;
11969
12620
  }
@@ -11981,6 +12632,7 @@ function verifyV3Envelope(envelope, opts, resolved, patch_id, repoRoot) {
11981
12632
  head_sha: envelope.payload.head_sha,
11982
12633
  target_branch: envelope.payload.target_branch,
11983
12634
  diff_sha256: envelope.payload.diff_sha256,
12635
+ manifest_snapshot_sha256: envelope.payload.manifest_snapshot_sha256,
11984
12636
  approvals: envelope.payload.approvals,
11985
12637
  checks: envelope.payload.checks,
11986
12638
  trust_anchor_signatures: envelope.payload.trust_anchor_signatures,
@@ -12091,6 +12743,310 @@ function verifyV3Envelope(envelope, opts, resolved, patch_id, repoRoot) {
12091
12743
  schema_version: envelope.payload.schema_version
12092
12744
  });
12093
12745
  }
12746
+ function verifyBootstrapEnvelope(envelope, opts, resolved, patch_id, repoRoot) {
12747
+ const payload = envelope.payload;
12748
+ const marker = payload.migration_bootstrap;
12749
+ if (!marker) {
12750
+ fail2(
12751
+ `internal error: bootstrap dispatch with no marker (should be unreachable)`,
12752
+ patch_id,
12753
+ resolved.base_sha,
12754
+ resolved.head_sha
12755
+ );
12756
+ }
12757
+ if (payload.approvals.length !== 0) {
12758
+ fail2(
12759
+ `bootstrap envelope has ${payload.approvals.length} approvals \u2014 bootstrap envelopes MUST have approvals: []. Non-empty server signatures should go through the normal (non-bootstrap) attest flow.`,
12760
+ patch_id,
12761
+ resolved.base_sha,
12762
+ resolved.head_sha
12763
+ );
12764
+ }
12765
+ const validation = validateShape4ActivationDiff({
12766
+ repoRoot,
12767
+ baseSha: payload.base_sha,
12768
+ headSha: payload.head_sha
12769
+ });
12770
+ if (!validation.ok) {
12771
+ fail2(
12772
+ `bootstrap envelope rejected: diff does not match the Shape 4 activation whitelist \u2014 ${validation.reason}`,
12773
+ patch_id,
12774
+ resolved.base_sha,
12775
+ resolved.head_sha
12776
+ );
12777
+ }
12778
+ const claimed = [...marker.activated_paths].sort();
12779
+ const actual = [...validation.activatedPaths].sort();
12780
+ if (claimed.length !== actual.length || !claimed.every((p, i) => p === actual[i])) {
12781
+ fail2(
12782
+ `bootstrap envelope's migration_bootstrap.activated_paths (${claimed.join(", ")}) does not match the actual changed-files set (${actual.join(", ")}). The marker must declare exactly the paths the bootstrap PR activates.`,
12783
+ patch_id,
12784
+ resolved.base_sha,
12785
+ resolved.head_sha
12786
+ );
12787
+ }
12788
+ const baseManifest = loadManifestAtBase(payload.base_sha, repoRoot);
12789
+ if (!baseManifest) {
12790
+ fail2(
12791
+ `bootstrap envelope rejected: .stamp/trusted-keys/manifest.yml is missing or malformed at base ${payload.base_sha.slice(0, 8)}. Bootstrap requires the manifest in the merge-base tree (it's the trust root for the operator's outer signature and the admin counter-signature).`,
12792
+ patch_id,
12793
+ resolved.base_sha,
12794
+ resolved.head_sha
12795
+ );
12796
+ }
12797
+ if (!payload.manifest_snapshot_sha256) {
12798
+ fail2(
12799
+ `bootstrap envelope is missing manifest_snapshot_sha256 \u2014 required for v3+ envelopes`,
12800
+ patch_id,
12801
+ resolved.base_sha,
12802
+ resolved.head_sha
12803
+ );
12804
+ }
12805
+ const computedSnapshot = snapshotSha256(baseManifest);
12806
+ if (payload.manifest_snapshot_sha256 !== computedSnapshot) {
12807
+ fail2(
12808
+ `bootstrap envelope manifest_snapshot_sha256 (${payload.manifest_snapshot_sha256.slice(0, 16)}\u2026) does not match the manifest at base ${payload.base_sha.slice(0, 8)} (${computedSnapshot.slice(0, 16)}\u2026)`,
12809
+ patch_id,
12810
+ resolved.base_sha,
12811
+ resolved.head_sha
12812
+ );
12813
+ }
12814
+ const pubkeyByFingerprint = loadPubkeysAtBase(payload.base_sha, repoRoot);
12815
+ const operatorCaps = resolveCapability(baseManifest, payload.signer_key_id);
12816
+ if (operatorCaps === null) {
12817
+ fail2(
12818
+ `bootstrap envelope signer ${payload.signer_key_id} is not listed in the manifest at base ${payload.base_sha.slice(0, 8)}`,
12819
+ patch_id,
12820
+ resolved.base_sha,
12821
+ resolved.head_sha
12822
+ );
12823
+ }
12824
+ if (!operatorCaps.includes("operator") && !operatorCaps.includes("admin")) {
12825
+ fail2(
12826
+ `bootstrap envelope signer ${payload.signer_key_id} has capabilities [${operatorCaps.join(", ")}] at base ${payload.base_sha.slice(0, 8)} \u2014 needs operator or admin`,
12827
+ patch_id,
12828
+ resolved.base_sha,
12829
+ resolved.head_sha
12830
+ );
12831
+ }
12832
+ const operatorPem = pubkeyByFingerprint.get(payload.signer_key_id);
12833
+ if (!operatorPem) {
12834
+ fail2(
12835
+ `bootstrap envelope signer ${payload.signer_key_id} has no matching .pub file in .stamp/trusted-keys/ at base ${payload.base_sha.slice(0, 8)}`,
12836
+ patch_id,
12837
+ resolved.base_sha,
12838
+ resolved.head_sha
12839
+ );
12840
+ }
12841
+ const operatorBytes = serializePayload2(payload);
12842
+ let operatorSigOk = false;
12843
+ try {
12844
+ operatorSigOk = verifyBytes(operatorPem, operatorBytes, envelope.signature);
12845
+ } catch (err) {
12846
+ fail2(
12847
+ `bootstrap envelope operator-signature verification threw: ${err instanceof Error ? err.message : String(err)}`,
12848
+ patch_id,
12849
+ resolved.base_sha,
12850
+ resolved.head_sha
12851
+ );
12852
+ }
12853
+ if (!operatorSigOk) {
12854
+ fail2(
12855
+ `bootstrap envelope operator signature does not verify against the operator's pubkey ${payload.signer_key_id}`,
12856
+ patch_id,
12857
+ resolved.base_sha,
12858
+ resolved.head_sha
12859
+ );
12860
+ }
12861
+ const sigs = payload.trust_anchor_signatures ?? [];
12862
+ if (sigs.length !== 1) {
12863
+ fail2(
12864
+ `bootstrap envelope must carry exactly one trust_anchor_signatures entry (got ${sigs.length}) \u2014 multi-admin bootstrap collection is not supported by the bootstrap path. Lower path_rules minimum_signatures or use the standard admin-sign flow.`,
12865
+ patch_id,
12866
+ resolved.base_sha,
12867
+ resolved.head_sha
12868
+ );
12869
+ }
12870
+ const adminSig = sigs[0];
12871
+ const adminCaps = resolveCapability(baseManifest, adminSig.signer_key_id);
12872
+ if (adminCaps === null) {
12873
+ fail2(
12874
+ `bootstrap envelope admin signer ${adminSig.signer_key_id} is not listed in the manifest at base ${payload.base_sha.slice(0, 8)}`,
12875
+ patch_id,
12876
+ resolved.base_sha,
12877
+ resolved.head_sha
12878
+ );
12879
+ }
12880
+ if (!adminCaps.includes("admin")) {
12881
+ fail2(
12882
+ `bootstrap envelope admin signer ${adminSig.signer_key_id} has capabilities [${adminCaps.join(", ")}] at base ${payload.base_sha.slice(0, 8)} \u2014 needs admin`,
12883
+ patch_id,
12884
+ resolved.base_sha,
12885
+ resolved.head_sha
12886
+ );
12887
+ }
12888
+ const adminPem = pubkeyByFingerprint.get(adminSig.signer_key_id);
12889
+ if (!adminPem) {
12890
+ fail2(
12891
+ `bootstrap envelope admin signer ${adminSig.signer_key_id} has no matching .pub file in .stamp/trusted-keys/ at base ${payload.base_sha.slice(0, 8)}`,
12892
+ patch_id,
12893
+ resolved.base_sha,
12894
+ resolved.head_sha
12895
+ );
12896
+ }
12897
+ const v4View = {
12898
+ schema_version: payload.schema_version,
12899
+ base_sha: payload.base_sha,
12900
+ head_sha: payload.head_sha,
12901
+ target_branch: payload.target_branch,
12902
+ diff_sha256: payload.diff_sha256,
12903
+ manifest_snapshot_sha256: payload.manifest_snapshot_sha256,
12904
+ approvals: [],
12905
+ checks: payload.checks,
12906
+ trust_anchor_signatures: [],
12907
+ signer_key_id: payload.signer_key_id
12908
+ };
12909
+ const adminBytes = bootstrapAdminSigningBytes({ payloadV4: v4View, marker });
12910
+ let adminSigOk = false;
12911
+ try {
12912
+ adminSigOk = verifyBytes(adminPem, adminBytes, adminSig.signature);
12913
+ } catch (err) {
12914
+ fail2(
12915
+ `bootstrap envelope admin-signature verification threw: ${err instanceof Error ? err.message : String(err)}`,
12916
+ patch_id,
12917
+ resolved.base_sha,
12918
+ resolved.head_sha
12919
+ );
12920
+ }
12921
+ if (!adminSigOk) {
12922
+ fail2(
12923
+ `bootstrap envelope admin signature by ${adminSig.signer_key_id} does not verify against the bootstrap signing-bytes (payload-with-marker, trust_anchor_signatures: []).`,
12924
+ patch_id,
12925
+ resolved.base_sha,
12926
+ resolved.head_sha
12927
+ );
12928
+ }
12929
+ const pathRules = loadPathRulesAtBase(payload.base_sha, repoRoot);
12930
+ if (pathRules.length === 0) {
12931
+ fail2(
12932
+ `bootstrap envelope rejected: no path_rules configured at base ${payload.base_sha.slice(0, 8)}. The bootstrap path requires path_rules covering the activated paths with bypass_review_cycle: true \u2014 typically already in place from a prior Shape 2 migration.`,
12933
+ patch_id,
12934
+ resolved.base_sha,
12935
+ resolved.head_sha
12936
+ );
12937
+ }
12938
+ for (const p of marker.activated_paths) {
12939
+ let coveredBy = null;
12940
+ for (const rule2 of pathRules) {
12941
+ if (pathMatchesAny(p, [rule2.pattern])) {
12942
+ coveredBy = rule2;
12943
+ break;
12944
+ }
12945
+ }
12946
+ if (!coveredBy) {
12947
+ fail2(
12948
+ `bootstrap envelope rejected: path_rules at base ${payload.base_sha.slice(0, 8)} does not cover activated path "${p}". Configured patterns: ${pathRules.map((r) => `"${r.pattern}"`).join(", ")}.`,
12949
+ patch_id,
12950
+ resolved.base_sha,
12951
+ resolved.head_sha
12952
+ );
12953
+ }
12954
+ if (!coveredBy.bypass_review_cycle) {
12955
+ fail2(
12956
+ `bootstrap envelope rejected: path_rule "${coveredBy.pattern}" at base ${payload.base_sha.slice(0, 8)} has bypass_review_cycle: false. Bootstrap requires the matched rule to opt out of the reviewer cycle.`,
12957
+ patch_id,
12958
+ resolved.base_sha,
12959
+ resolved.head_sha
12960
+ );
12961
+ }
12962
+ }
12963
+ if (payload.target_branch !== opts.into) {
12964
+ fail2(
12965
+ `bootstrap envelope target_branch ("${payload.target_branch}") does not match --into ("${opts.into}")`,
12966
+ patch_id,
12967
+ resolved.base_sha,
12968
+ resolved.head_sha
12969
+ );
12970
+ }
12971
+ if (!payload.diff_sha256) {
12972
+ fail2(
12973
+ `bootstrap envelope is missing diff_sha256 \u2014 required for v3+ envelopes`,
12974
+ patch_id,
12975
+ resolved.base_sha,
12976
+ resolved.head_sha
12977
+ );
12978
+ }
12979
+ const actualDiff = spawnSync19(
12980
+ "git",
12981
+ ["diff", `${payload.base_sha}...${payload.head_sha}`],
12982
+ { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }
12983
+ );
12984
+ if (actualDiff.status !== 0) {
12985
+ fail2(
12986
+ `bootstrap envelope rejected: could not compute base...head diff for diff_sha256 check`,
12987
+ patch_id,
12988
+ resolved.base_sha,
12989
+ resolved.head_sha
12990
+ );
12991
+ }
12992
+ const diffText = actualDiff.stdout ?? "";
12993
+ const actualDiffSha256 = createHash13("sha256").update(Buffer.from(diffText, "utf8")).digest("hex");
12994
+ if (actualDiffSha256 !== payload.diff_sha256) {
12995
+ fail2(
12996
+ `bootstrap envelope diff_sha256 mismatch \u2014 payload claims ${payload.diff_sha256.slice(0, 12)}\u2026 but base...head hashes to ${actualDiffSha256.slice(0, 12)}\u2026. The operator signed against a different diff.`,
12997
+ patch_id,
12998
+ resolved.base_sha,
12999
+ resolved.head_sha
13000
+ );
13001
+ }
13002
+ let configYaml;
13003
+ try {
13004
+ configYaml = showAtRef(payload.base_sha, ".stamp/config.yml", repoRoot);
13005
+ } catch (e) {
13006
+ fail2(
13007
+ `bootstrap envelope rejected: could not read .stamp/config.yml at base ${payload.base_sha.slice(0, 8)}: ${e.message}`,
13008
+ patch_id,
13009
+ resolved.base_sha,
13010
+ resolved.head_sha
13011
+ );
13012
+ }
13013
+ const config2 = parseConfigFromYaml(configYaml);
13014
+ const rule = findBranchRule(config2.branches, opts.into);
13015
+ if (!rule) {
13016
+ fail2(
13017
+ `bootstrap envelope rejected: no branch rule for "${opts.into}" in .stamp/config.yml at base ${payload.base_sha.slice(0, 8)}`,
13018
+ patch_id,
13019
+ resolved.base_sha,
13020
+ resolved.head_sha
13021
+ );
13022
+ }
13023
+ if (rule.strict_base) {
13024
+ const currentTip = runGit(
13025
+ ["rev-parse", `${opts.into}^{commit}`],
13026
+ repoRoot
13027
+ ).trim();
13028
+ if (envelope.payload.target_branch_tip_sha !== currentTip) {
13029
+ fail2(
13030
+ `bootstrap strict_base check failed: attestation was signed when ${opts.into} was at ${envelope.payload.target_branch_tip_sha?.slice(0, 8)}, but ${opts.into} is now at ${currentTip.slice(0, 8)}`,
13031
+ patch_id,
13032
+ resolved.base_sha,
13033
+ resolved.head_sha
13034
+ );
13035
+ }
13036
+ }
13037
+ printSuccess3({
13038
+ patch_id,
13039
+ base_sha: resolved.base_sha,
13040
+ head_sha: resolved.head_sha,
13041
+ target_branch: opts.into,
13042
+ signer_key_id: payload.signer_key_id,
13043
+ approvals: [],
13044
+ strict_base: rule.strict_base ?? false,
13045
+ schema_version: envelope.payload.schema_version,
13046
+ bootstrap: true,
13047
+ activated_paths: marker.activated_paths
13048
+ });
13049
+ }
12094
13050
  function loadManifestAtBase(base_sha, repoRoot) {
12095
13051
  let yaml;
12096
13052
  try {
@@ -12101,7 +13057,7 @@ function loadManifestAtBase(base_sha, repoRoot) {
12101
13057
  return parseManifest(yaml);
12102
13058
  }
12103
13059
  function loadPubkeysAtBase(base_sha, repoRoot) {
12104
- const lsResult = spawnSync18(
13060
+ const lsResult = spawnSync19(
12105
13061
  "git",
12106
13062
  ["ls-tree", "--name-only", base_sha, ".stamp/trusted-keys/"],
12107
13063
  { cwd: repoRoot, encoding: "utf8" }
@@ -12113,7 +13069,7 @@ function loadPubkeysAtBase(base_sha, repoRoot) {
12113
13069
  return l.startsWith(prefix) ? l.slice(prefix.length) : l;
12114
13070
  }).filter((n) => n.endsWith(".pub"));
12115
13071
  return buildPubkeyMap(pubFiles, (relPath) => {
12116
- const show = spawnSync18(
13072
+ const show = spawnSync19(
12117
13073
  "git",
12118
13074
  ["show", `${base_sha}:${relPath}`],
12119
13075
  { cwd: repoRoot, encoding: "utf8" }
@@ -12131,7 +13087,7 @@ function loadPathRulesAtBase(base_sha, repoRoot) {
12131
13087
  }
12132
13088
  let raw;
12133
13089
  try {
12134
- raw = parseYaml11(yaml);
13090
+ raw = parseYaml13(yaml);
12135
13091
  } catch {
12136
13092
  return [];
12137
13093
  }
@@ -12144,7 +13100,7 @@ function loadPathRulesAtBase(base_sha, repoRoot) {
12144
13100
  return parsedRules.rules;
12145
13101
  }
12146
13102
  function loadChangedFiles(base_sha, head_sha, repoRoot) {
12147
- const result = spawnSync18(
13103
+ const result = spawnSync19(
12148
13104
  "git",
12149
13105
  ["diff", "-z", "--name-only", `${base_sha}...${head_sha}`],
12150
13106
  { cwd: repoRoot, encoding: "utf8" }
@@ -12160,12 +13116,18 @@ function printSuccess3(s) {
12160
13116
  );
12161
13117
  console.log(bar);
12162
13118
  console.log(` patch-id: ${s.patch_id}`);
12163
- console.log(` schema: v${s.schema_version} (v4-trust)`);
13119
+ console.log(
13120
+ ` schema: v${s.schema_version}${s.bootstrap ? " (Shape 4 migration bootstrap)" : " (v4-trust)"}`
13121
+ );
12164
13122
  console.log(` signer: ${s.signer_key_id}`);
12165
13123
  console.log(` base mode: ${s.strict_base ? "strict" : "loose"}`);
12166
- for (const a of s.approvals) {
12167
- const mark = a.verdict === "approved" ? "\u2713" : "\u2717";
12168
- console.log(` ${mark} ${a.reviewer.padEnd(16)} ${a.verdict}`);
13124
+ if (s.bootstrap && s.activated_paths) {
13125
+ console.log(` activated: ${s.activated_paths.join(", ")}`);
13126
+ } else {
13127
+ for (const a of s.approvals) {
13128
+ const mark = a.verdict === "approved" ? "\u2713" : "\u2717";
13129
+ console.log(` ${mark} ${a.reviewer.padEnd(16)} ${a.verdict}`);
13130
+ }
12169
13131
  }
12170
13132
  console.log(bar);
12171
13133
  console.log("result: VERIFIED");
@@ -12562,11 +13524,19 @@ program.command("attest [branch]").description(
12562
13524
  ).requiredOption("--into <target>", "target branch whose rule the gate is checked against").option(
12563
13525
  "--push [remote]",
12564
13526
  "after attesting locally, push the current branch + the attestation ref to <remote> in one atomic git push (default remote: origin)"
13527
+ ).option(
13528
+ "--migrate-existing",
13529
+ "Shape 4 migration bootstrap (AGT-398): attest a narrowly-scoped diff that ADDS `review_server:` + `[server]`-capability trust-anchor entries (the chicken-and-egg PR that activates server-attested reviews on an existing repo). Produces a v3 envelope with empty server_signatures, a migration-bootstrap marker in the operator-signed payload, and exactly one operator-self admin counter-signature in `trust_anchor_signatures`. The flag is REFUSED on any diff outside the narrow Shape-4-activation whitelist (no files outside .stamp/, no modifications to existing trust-anchor entries, no removals). Requires the operator's local key to have `admin` capability in the working-tree manifest and `path_rules` to cover the activated paths with `bypass_review_cycle: true` and `minimum_signatures: 1`. See docs/migration-1.x-to-2.x.md for the full Shape 4 bootstrap walkthrough."
12565
13530
  ).action(
12566
13531
  (branch, opts) => {
12567
13532
  try {
12568
13533
  const pushTo = opts.push === true ? "origin" : typeof opts.push === "string" ? opts.push : void 0;
12569
- runAttest({ branch, into: opts.into, pushTo });
13534
+ runAttest({
13535
+ branch,
13536
+ into: opts.into,
13537
+ pushTo,
13538
+ migrateExisting: opts.migrateExisting === true
13539
+ });
12570
13540
  } catch (err) {
12571
13541
  handleCliError(err);
12572
13542
  }
@@ -12618,7 +13588,7 @@ program.command("prune").description(
12618
13588
  });
12619
13589
  program.command("ui").description("launch the interactive terminal UI").action(async () => {
12620
13590
  try {
12621
- const { runUi } = await import("./ui-P5DRAT3P.js");
13591
+ const { runUi } = await import("./ui-67BDQPER.js");
12622
13592
  runUi();
12623
13593
  } catch (err) {
12624
13594
  handleCliError(err);