@openthink/stamp 1.3.1 → 1.5.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
@@ -520,15 +520,34 @@ function validateConfig(input) {
520
520
  }
521
521
  enforce_reads_on_dotstamp = d.enforce_reads_on_dotstamp;
522
522
  }
523
+ const max_turns = parsePositiveInt(
524
+ d.max_turns,
525
+ `config.reviewers.${name}.max_turns`
526
+ );
527
+ const timeout_ms = parsePositiveInt(
528
+ d.timeout_ms,
529
+ `config.reviewers.${name}.timeout_ms`
530
+ );
523
531
  reviewers2[name] = {
524
532
  prompt: d.prompt,
525
533
  ...tools ? { tools } : {},
526
534
  ...mcp_servers ? { mcp_servers } : {},
527
- ...enforce_reads_on_dotstamp !== void 0 ? { enforce_reads_on_dotstamp } : {}
535
+ ...enforce_reads_on_dotstamp !== void 0 ? { enforce_reads_on_dotstamp } : {},
536
+ ...max_turns !== void 0 ? { max_turns } : {},
537
+ ...timeout_ms !== void 0 ? { timeout_ms } : {}
528
538
  };
529
539
  }
530
540
  return { branches, reviewers: reviewers2 };
531
541
  }
542
+ function parsePositiveInt(input, path2) {
543
+ if (input === void 0 || input === null) return void 0;
544
+ if (typeof input !== "number" || !Number.isFinite(input) || !Number.isInteger(input) || input <= 0) {
545
+ throw new Error(
546
+ `${path2} must be a positive integer (got ${JSON.stringify(input)})`
547
+ );
548
+ }
549
+ return input;
550
+ }
532
551
  function parseChecks(input, branchName) {
533
552
  if (input === void 0 || input === null) return void 0;
534
553
  if (!Array.isArray(input)) {
@@ -1354,13 +1373,13 @@ function readLineSync() {
1354
1373
  function runMerge(opts) {
1355
1374
  const repoRoot = findRepoRoot();
1356
1375
  const config2 = loadConfig(stampConfigFile(repoRoot));
1357
- const currentBranch2 = git(
1376
+ const currentBranch3 = git(
1358
1377
  ["rev-parse", "--abbrev-ref", "HEAD"],
1359
1378
  repoRoot
1360
1379
  ).trim();
1361
- if (currentBranch2 !== opts.into) {
1380
+ if (currentBranch3 !== opts.into) {
1362
1381
  throw new Error(
1363
- `must be on target branch "${opts.into}" to merge into it (currently on "${currentBranch2}"). Run \`git checkout ${opts.into}\` first.`
1382
+ `must be on target branch "${opts.into}" to merge into it (currently on "${currentBranch3}"). Run \`git checkout ${opts.into}\` first.`
1364
1383
  );
1365
1384
  }
1366
1385
  const dirty = git(
@@ -2027,14 +2046,14 @@ async function invokeReviewer(params) {
2027
2046
  ...mcpServersResolved ?? {},
2028
2047
  "stamp-verdict": verdictServer
2029
2048
  };
2030
- const maxTurns = parseIntEnv("STAMP_REVIEWER_MAX_TURNS", 8);
2031
- const timeoutMs = parseIntEnv("STAMP_REVIEWER_TIMEOUT_MS", 5 * 60 * 1e3);
2049
+ const maxTurns = def.max_turns ?? parseIntEnv("STAMP_REVIEWER_MAX_TURNS", 8);
2050
+ const timeoutMs = def.timeout_ms ?? parseIntEnv("STAMP_REVIEWER_TIMEOUT_MS", 5 * 60 * 1e3);
2032
2051
  const modelOverride = resolveReviewerModel(params.reviewer);
2033
2052
  const abortController = new AbortController();
2034
2053
  const timeoutHandle = setTimeout(() => {
2035
2054
  abortController.abort(
2036
2055
  new Error(
2037
- `reviewer "${params.reviewer}" exceeded ${timeoutMs}ms wall-clock budget \u2014 raise STAMP_REVIEWER_TIMEOUT_MS to extend it`
2056
+ `reviewer "${params.reviewer}" exceeded ${timeoutMs}ms wall-clock budget`
2038
2057
  )
2039
2058
  );
2040
2059
  }, timeoutMs);
@@ -2119,7 +2138,14 @@ async function invokeReviewer(params) {
2119
2138
  if (msg.subtype === "success") {
2120
2139
  finalText = msg.result;
2121
2140
  } else {
2122
- errorMessage = `reviewer "${params.reviewer}" run failed (subtype=${msg.subtype})`;
2141
+ errorMessage = formatRunFailureMessage({
2142
+ reviewer: params.reviewer,
2143
+ subtype: msg.subtype,
2144
+ repoRoot: params.repoRoot,
2145
+ toolCalls,
2146
+ maxTurns,
2147
+ timeoutMs
2148
+ });
2123
2149
  }
2124
2150
  break;
2125
2151
  }
@@ -2127,7 +2153,16 @@ async function invokeReviewer(params) {
2127
2153
  } catch (err) {
2128
2154
  if (abortController.signal.aborted) {
2129
2155
  const reason = abortController.signal.reason instanceof Error ? abortController.signal.reason.message : String(abortController.signal.reason ?? "aborted");
2130
- throw new Error(reason);
2156
+ throw new Error(
2157
+ formatAbortMessage({
2158
+ reviewer: params.reviewer,
2159
+ reason,
2160
+ repoRoot: params.repoRoot,
2161
+ toolCalls,
2162
+ maxTurns,
2163
+ timeoutMs
2164
+ })
2165
+ );
2131
2166
  }
2132
2167
  throw err;
2133
2168
  } finally {
@@ -2339,6 +2374,66 @@ function writeFailedParseSpool(repoRoot, reviewer, text) {
2339
2374
  const lineCount = text === "" ? 0 : text.split("\n").length;
2340
2375
  return { path: filepath, lineCount };
2341
2376
  }
2377
+ function writeFailedRunSpool(args) {
2378
+ const dir = path.join(gitCommonDir(args.repoRoot), "stamp", "failed-runs");
2379
+ mkdirSync2(dir, { recursive: true, mode: 448 });
2380
+ chmodSync(dir, 448);
2381
+ const slug = sanitizeReviewerSlug(args.reviewer);
2382
+ const filename = `${Date.now()}-${slug}.log`;
2383
+ const filepath = path.join(dir, filename);
2384
+ const payload = {
2385
+ reviewer: args.reviewer,
2386
+ failure: args.failure,
2387
+ max_turns: args.maxTurns,
2388
+ timeout_ms: args.timeoutMs,
2389
+ tool_call_count: args.toolCalls.length,
2390
+ tool_calls: args.toolCalls,
2391
+ captured_at: (/* @__PURE__ */ new Date()).toISOString()
2392
+ };
2393
+ writeFileSync3(filepath, JSON.stringify(payload, null, 2) + "\n", {
2394
+ flag: "wx",
2395
+ mode: 384
2396
+ });
2397
+ chmodSync(filepath, 384);
2398
+ return filepath;
2399
+ }
2400
+ function formatRunFailureMessage(args) {
2401
+ const spool = trySpool({
2402
+ repoRoot: args.repoRoot,
2403
+ reviewer: args.reviewer,
2404
+ failure: `subtype=${args.subtype}`,
2405
+ toolCalls: args.toolCalls,
2406
+ maxTurns: args.maxTurns,
2407
+ timeoutMs: args.timeoutMs
2408
+ });
2409
+ const tracePart = formatTracePart(spool, args.toolCalls.length);
2410
+ const hintPart = args.subtype === "error_max_turns" ? ` \u2014 raise STAMP_REVIEWER_MAX_TURNS (currently ${args.maxTurns}) or set reviewers.${args.reviewer}.max_turns in .stamp/config.yml to extend it` : "";
2411
+ return `reviewer "${args.reviewer}" run failed (subtype=${args.subtype})${tracePart}${hintPart}`;
2412
+ }
2413
+ function formatAbortMessage(args) {
2414
+ const spool = trySpool({
2415
+ repoRoot: args.repoRoot,
2416
+ reviewer: args.reviewer,
2417
+ failure: `abort: ${args.reason}`,
2418
+ toolCalls: args.toolCalls,
2419
+ maxTurns: args.maxTurns,
2420
+ timeoutMs: args.timeoutMs
2421
+ });
2422
+ const tracePart = formatTracePart(spool, args.toolCalls.length);
2423
+ const hintPart = ` \u2014 raise STAMP_REVIEWER_TIMEOUT_MS (currently ${args.timeoutMs}ms) or set reviewers.${args.reviewer}.timeout_ms in .stamp/config.yml to extend it`;
2424
+ return `${args.reason}${tracePart}${hintPart}`;
2425
+ }
2426
+ function trySpool(args) {
2427
+ try {
2428
+ return writeFailedRunSpool(args);
2429
+ } catch {
2430
+ return null;
2431
+ }
2432
+ }
2433
+ function formatTracePart(spool, toolCallCount) {
2434
+ if (spool === null) return "";
2435
+ return ` \u2014 turn trace at ${spool} (${toolCallCount} tool call${toolCallCount === 1 ? "" : "s"} captured)`;
2436
+ }
2342
2437
  function parseIntEnv(name, fallback) {
2343
2438
  const raw = process.env[name];
2344
2439
  if (!raw) return fallback;
@@ -3717,60 +3812,109 @@ function resolveMode(userMode, remoteClass) {
3717
3812
  }
3718
3813
  }
3719
3814
 
3720
- // src/commands/provision.ts
3721
- import { spawnSync as spawnSync6 } from "child_process";
3722
- import { existsSync as existsSync11, mkdtempSync, readFileSync as readFileSync8, rmSync, writeFileSync as writeFileSync9 } from "fs";
3723
- import { tmpdir } from "os";
3724
- import { join as join6, resolve as resolvePath } from "path";
3725
- import { parse as parseYaml5 } from "yaml";
3726
-
3727
- // src/commands/server.ts
3815
+ // src/commands/invites.ts
3728
3816
  import { spawnSync as spawnSync5 } from "child_process";
3729
- import { existsSync as existsSync10, mkdirSync as mkdirSync4, renameSync as renameSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync8 } from "fs";
3730
- import { dirname as dirname4 } from "path";
3731
- import { stringify as stringifyYaml2 } from "yaml";
3732
-
3733
- // src/lib/perRepoKey.ts
3734
- var VALID_OWNER = /^[A-Za-z0-9-]+$/;
3735
- var VALID_REPO = /^[A-Za-z0-9._-]+$/;
3736
- var SSH_CLIENT_KEY_DIR = "/srv/git/.ssh-client-keys";
3737
- function computePerRepoKeyPath(githubRepo) {
3738
- if (typeof githubRepo !== "string" || githubRepo.length === 0) {
3739
- throw new Error("computePerRepoKeyPath: githubRepo must be a non-empty string");
3817
+ import { request as httpRequest } from "http";
3818
+ import { request as httpsRequest } from "https";
3819
+ import { existsSync as existsSync10, readFileSync as readFileSync8 } from "fs";
3820
+ import { homedir as homedir2, hostname, userInfo } from "os";
3821
+ import { join as join6 } from "path";
3822
+ import { createInterface as createInterface2 } from "readline/promises";
3823
+
3824
+ // src/lib/inviteUrl.ts
3825
+ var TOKEN_RE = /^[A-Za-z0-9_-]{20,128}$/;
3826
+ var ShareUrlError = class extends Error {
3827
+ constructor(message) {
3828
+ super(message);
3829
+ this.name = "ShareUrlError";
3740
3830
  }
3741
- if (githubRepo.startsWith("-")) {
3742
- throw new Error(
3743
- `computePerRepoKeyPath: githubRepo must not start with '-': ${githubRepo}`
3744
- );
3831
+ };
3832
+ function parseShareUrl(input, serverFlag) {
3833
+ const trimmed = input.trim();
3834
+ if (trimmed.startsWith("stamp+invite://")) {
3835
+ const remainder = trimmed.slice("stamp+invite://".length);
3836
+ const firstSlash = remainder.indexOf("/");
3837
+ if (firstSlash < 0) {
3838
+ throw new ShareUrlError(`share URL has no token: ${JSON.stringify(input)}`);
3839
+ }
3840
+ const host = remainder.slice(0, firstSlash);
3841
+ let tokenPart = remainder.slice(firstSlash + 1);
3842
+ let insecure = false;
3843
+ const queryIdx = tokenPart.indexOf("?");
3844
+ if (queryIdx >= 0) {
3845
+ const query2 = tokenPart.slice(queryIdx + 1);
3846
+ tokenPart = tokenPart.slice(0, queryIdx);
3847
+ if (query2 === "insecure=1") insecure = true;
3848
+ }
3849
+ if (!TOKEN_RE.test(tokenPart)) {
3850
+ throw new ShareUrlError(
3851
+ `share URL has a malformed token: ${JSON.stringify(tokenPart)}`
3852
+ );
3853
+ }
3854
+ if (host.length === 0) {
3855
+ throw new ShareUrlError(`share URL has no host: ${JSON.stringify(input)}`);
3856
+ }
3857
+ return { host, token: tokenPart, insecure };
3745
3858
  }
3746
- if (githubRepo.includes("..")) {
3747
- throw new Error(
3748
- `computePerRepoKeyPath: githubRepo must not contain '..': ${githubRepo}`
3859
+ if (!TOKEN_RE.test(trimmed)) {
3860
+ throw new ShareUrlError(
3861
+ `expected a stamp+invite:// URL or a bare token (got ${JSON.stringify(input)})`
3749
3862
  );
3750
3863
  }
3751
- const slashCount = (githubRepo.match(/\//g) ?? []).length;
3752
- if (slashCount !== 1) {
3753
- throw new Error(
3754
- `computePerRepoKeyPath: githubRepo must be exactly <owner>/<repo>: ${githubRepo}`
3864
+ if (!serverFlag) {
3865
+ throw new ShareUrlError(
3866
+ "bare token requires --server <host>:<port> so we know where to POST"
3755
3867
  );
3756
3868
  }
3757
- const [owner, repo] = githubRepo.split("/");
3758
- if (!owner || !repo) {
3759
- throw new Error(
3760
- `computePerRepoKeyPath: owner and repo halves must both be non-empty: ${githubRepo}`
3761
- );
3869
+ return { host: serverFlag, token: trimmed, insecure: false };
3870
+ }
3871
+
3872
+ // src/lib/sshKeys.ts
3873
+ import { createHash as createHash5 } from "crypto";
3874
+ var ALLOWED_ALGOS = /* @__PURE__ */ new Set([
3875
+ "ssh-ed25519",
3876
+ "ssh-rsa",
3877
+ "ecdsa-sha2-nistp256",
3878
+ "ecdsa-sha2-nistp384",
3879
+ "ecdsa-sha2-nistp521"
3880
+ ]);
3881
+ function parseSshPubkey(line) {
3882
+ const trimmed = line.trim();
3883
+ if (trimmed.length === 0) {
3884
+ throw new Error("ssh pubkey line is empty");
3762
3885
  }
3763
- if (!VALID_OWNER.test(owner)) {
3764
- throw new Error(
3765
- `computePerRepoKeyPath: owner must match [A-Za-z0-9-]+ (got "${owner}" in "${githubRepo}")`
3766
- );
3886
+ if (trimmed.startsWith("#")) {
3887
+ throw new Error("ssh pubkey line is a comment");
3767
3888
  }
3768
- if (!VALID_REPO.test(repo)) {
3889
+ const parts = trimmed.split(/\s+/);
3890
+ if (parts.length < 2) {
3769
3891
  throw new Error(
3770
- `computePerRepoKeyPath: repo must match [A-Za-z0-9._-]+ (got "${repo}" in "${githubRepo}")`
3892
+ "ssh pubkey line must have at least <algorithm> <base64> tokens"
3771
3893
  );
3772
3894
  }
3773
- return `${SSH_CLIENT_KEY_DIR}/${owner}_${repo}_ed25519`;
3895
+ const [algorithm, b64, ...rest] = parts;
3896
+ if (!ALLOWED_ALGOS.has(algorithm)) {
3897
+ throw new Error(`unsupported ssh pubkey algorithm: ${algorithm}`);
3898
+ }
3899
+ const keyBlob = Buffer.from(b64, "base64");
3900
+ if (keyBlob.length === 0) {
3901
+ throw new Error("ssh pubkey base64 blob is empty");
3902
+ }
3903
+ if (keyBlob.toString("base64").replace(/=+$/, "") !== b64.replace(/=+$/, "")) {
3904
+ throw new Error("ssh pubkey base64 blob has trailing junk");
3905
+ }
3906
+ return {
3907
+ algorithm,
3908
+ keyBlob,
3909
+ comment: rest.join(" "),
3910
+ full: trimmed,
3911
+ fingerprint: sshFingerprintFromBlob(keyBlob)
3912
+ };
3913
+ }
3914
+ function sshFingerprintFromBlob(keyBlob) {
3915
+ const hash = createHash5("sha256").update(keyBlob).digest();
3916
+ const b64 = hash.toString("base64").replace(/=+$/, "");
3917
+ return `SHA256:${b64}`;
3774
3918
  }
3775
3919
 
3776
3920
  // src/commands/serverRepo.ts
@@ -3951,12 +4095,301 @@ function validateGithubRepoSpec(spec) {
3951
4095
  }
3952
4096
  function prompt(question) {
3953
4097
  const rl = createInterface({ input: process.stdin, output: process.stdout });
3954
- return new Promise((resolve2) => {
4098
+ return new Promise((resolve3) => {
3955
4099
  rl.question(question, (answer) => {
3956
4100
  rl.close();
3957
- resolve2(answer);
4101
+ resolve3(answer);
4102
+ });
4103
+ });
4104
+ }
4105
+
4106
+ // src/commands/invites.ts
4107
+ var MINT_EXIT = {
4108
+ CONFIG: 1,
4109
+ USAGE: 2,
4110
+ ROLE_FORBIDDEN: 3,
4111
+ NAME_TAKEN: 4
4112
+ };
4113
+ function runInvitesMint(opts) {
4114
+ const server2 = loadServerConfig();
4115
+ if (!server2) {
4116
+ throw new UsageError(
4117
+ "no ~/.stamp/server.yml \u2014 run `stamp server config <host>:<port>` first (or `stamp provision` to set up a new server)"
4118
+ );
4119
+ }
4120
+ const result = spawnSync5(
4121
+ "ssh",
4122
+ [
4123
+ "-p",
4124
+ String(server2.port),
4125
+ "--",
4126
+ `${server2.user}@${server2.host}`,
4127
+ "stamp-mint-invite",
4128
+ opts.shortName,
4129
+ "--role",
4130
+ opts.role
4131
+ ],
4132
+ {
4133
+ // Capture stdout (the share URL) for clean re-emit; let stderr
4134
+ // flow through to the operator's terminal verbatim — the
4135
+ // server-side wrapper writes its prose there.
4136
+ stdio: ["ignore", "pipe", "inherit"],
4137
+ encoding: "utf8"
4138
+ }
4139
+ );
4140
+ if (result.status === 0) {
4141
+ const shareUrl = result.stdout.trim();
4142
+ if (!shareUrl.startsWith("stamp+invite://")) {
4143
+ throw new Error(
4144
+ `unexpected server output (no stamp+invite:// URL on stdout). Got: ${JSON.stringify(shareUrl.slice(0, 120))}`
4145
+ );
4146
+ }
4147
+ process.stdout.write(shareUrl + "\n");
4148
+ return;
4149
+ }
4150
+ switch (result.status) {
4151
+ case MINT_EXIT.ROLE_FORBIDDEN:
4152
+ throw new UsageError(
4153
+ `your account on ${server2.host} doesn't permit minting invites (role must be admin or owner). Ask an admin to mint the invite for you, or to promote your account.`
4154
+ );
4155
+ case MINT_EXIT.NAME_TAKEN:
4156
+ throw new UsageError(
4157
+ `short_name ${JSON.stringify(opts.shortName)} is already in use on ${server2.host}. Pick a different name.`
4158
+ );
4159
+ case MINT_EXIT.USAGE:
4160
+ throw new UsageError(
4161
+ `server rejected the mint request (usage error). Verify the short_name is alphanumerics + . _ - (max 63 chars) and --role is 'admin' or 'member'.`
4162
+ );
4163
+ case MINT_EXIT.CONFIG:
4164
+ throw new Error(
4165
+ `server-side configuration error against ${server2.host}. The server may be missing STAMP_PUBLIC_URL or 'ExposeAuthInfo yes' in sshd_config. See server-side log for specifics.`
4166
+ );
4167
+ default:
4168
+ throw new Error(
4169
+ `mint failed against ${server2.user}@${server2.host}:${server2.port} (exit ${result.status}). Common causes: server unreachable, your SSH key isn't enrolled, or the server image is older than the invite-mint feature \u2014 redeploy if so.`
4170
+ );
4171
+ }
4172
+ }
4173
+ function defaultSshPubkeyPath() {
4174
+ return join6(homedir2(), ".ssh", "id_ed25519.pub");
4175
+ }
4176
+ function defaultStampPubkeyPath() {
4177
+ return join6(homedir2(), ".stamp", "keys", "ed25519.pub");
4178
+ }
4179
+ function defaultShortName() {
4180
+ const u = userInfo().username || "user";
4181
+ const h = hostname().split(".")[0] || "host";
4182
+ return `${u}-${h}`.toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
4183
+ }
4184
+ function loadSshPubkey(path2) {
4185
+ if (!existsSync10(path2)) {
4186
+ throw new UsageError(
4187
+ `SSH pubkey not found at ${path2}. Generate one with \`ssh-keygen -t ed25519\` or pass --ssh-pubkey <path>.`
4188
+ );
4189
+ }
4190
+ const raw = readFileSync8(path2, "utf8");
4191
+ const parsed = parseSshPubkey(raw);
4192
+ return { path: path2, full: parsed.full, fingerprint: parsed.fingerprint };
4193
+ }
4194
+ function loadStampPubkey(path2) {
4195
+ if (!existsSync10(path2)) return null;
4196
+ const pem = readFileSync8(path2, "utf8");
4197
+ try {
4198
+ const fp = fingerprintFromPem(pem);
4199
+ return { path: path2, pem, fingerprint: fp };
4200
+ } catch (e) {
4201
+ throw new UsageError(
4202
+ `stamp signing pubkey at ${path2} is malformed: ${e.message}`
4203
+ );
4204
+ }
4205
+ }
4206
+ async function postAccept(target, body) {
4207
+ const payload = Buffer.from(JSON.stringify(body), "utf8");
4208
+ const requestFn = target.insecure ? httpRequest : httpsRequest;
4209
+ return new Promise((resolve3, reject) => {
4210
+ const req = requestFn(
4211
+ `${target.insecure ? "http" : "https"}://${target.host}/invite/accept`,
4212
+ {
4213
+ method: "POST",
4214
+ headers: {
4215
+ "Content-Type": "application/json; charset=utf-8",
4216
+ "Content-Length": payload.length.toString(),
4217
+ Accept: "application/json"
4218
+ }
4219
+ },
4220
+ (res) => {
4221
+ const chunks = [];
4222
+ res.on("data", (c) => chunks.push(c));
4223
+ res.on("end", () => {
4224
+ const text = Buffer.concat(chunks).toString("utf8");
4225
+ let parsed;
4226
+ try {
4227
+ parsed = JSON.parse(text);
4228
+ } catch {
4229
+ parsed = { raw: text };
4230
+ }
4231
+ resolve3({ status: res.statusCode ?? 0, body: parsed });
4232
+ });
4233
+ }
4234
+ );
4235
+ req.on("error", reject);
4236
+ req.write(payload);
4237
+ req.end();
4238
+ });
4239
+ }
4240
+ function nextStepForError(error, opts) {
4241
+ switch (error) {
4242
+ case "invite_not_found":
4243
+ return "the token doesn't match any pending invite. Ask the inviter to mint a fresh one.";
4244
+ case "invite_expired":
4245
+ return "the invite expired (15-minute TTL). Ask the inviter to mint a fresh one.";
4246
+ case "invite_already_consumed":
4247
+ return "the invite has already been redeemed. Ask the inviter to mint a fresh one.";
4248
+ case "ssh_pubkey_already_registered":
4249
+ return "your SSH public key is already enrolled on this server. Try `git push` / `stamp` directly \u2014 you may already have access. If not, ask an admin to look up the existing account.";
4250
+ case "short_name_taken":
4251
+ return `pass --short-name <other-name> (current attempt: ${JSON.stringify(opts.shortName ?? defaultShortName())}).`;
4252
+ case "short_name_malformed":
4253
+ return "pass --short-name with alphanumerics + . _ - (must start with alnum, max 63 chars).";
4254
+ case "ssh_pubkey_required":
4255
+ case "token_required":
4256
+ return "this is a client bug \u2014 file an issue at https://github.com/OpenThinkAi/stamp-cli/issues with the command you ran.";
4257
+ case "content_type_must_be_application_json":
4258
+ case "body_too_large":
4259
+ case "body_not_json":
4260
+ case "body_read_failed":
4261
+ return "this is a client bug \u2014 file an issue at https://github.com/OpenThinkAi/stamp-cli/issues with the command you ran.";
4262
+ default:
4263
+ if (error.startsWith("ssh_pubkey_invalid")) {
4264
+ return "the SSH pubkey is malformed. Verify ~/.ssh/id_ed25519.pub or pass --ssh-pubkey <path>.";
4265
+ }
4266
+ return "see the server error code above for context.";
4267
+ }
4268
+ }
4269
+ async function runInvitesAccept(opts) {
4270
+ let target;
4271
+ try {
4272
+ target = parseShareUrl(opts.urlOrToken, opts.server);
4273
+ } catch (e) {
4274
+ if (e instanceof ShareUrlError) throw new UsageError(e.message);
4275
+ throw e;
4276
+ }
4277
+ const sshPath = opts.sshPubkeyPath ?? defaultSshPubkeyPath();
4278
+ const ssh = loadSshPubkey(sshPath);
4279
+ const stampPath = opts.stampPubkeyPath ?? defaultStampPubkeyPath();
4280
+ const stamp = loadStampPubkey(stampPath);
4281
+ const shortName = opts.shortName ?? defaultShortName();
4282
+ const isInteractive = process.stdin.isTTY === true;
4283
+ let confirmed = opts.yes === true;
4284
+ if (!confirmed) {
4285
+ if (!isInteractive) {
4286
+ throw new UsageError(
4287
+ "non-interactive stdin: pass --yes to skip confirmation (after supplying any overrides via --ssh-pubkey / --stamp-pubkey / --short-name)"
4288
+ );
4289
+ }
4290
+ const rl = createInterface2({
4291
+ input: process.stdin,
4292
+ output: process.stdout
3958
4293
  });
4294
+ try {
4295
+ process.stdout.write(
4296
+ `Accepting invite at ${target.insecure ? "http" : "https"}://${target.host}
4297
+
4298
+ ssh pubkey: ${ssh.path}
4299
+ ${ssh.fingerprint}
4300
+ ` + (stamp ? ` stamp pubkey: ${stamp.path}
4301
+ ${stamp.fingerprint}
4302
+ ` : ` stamp pubkey: (none detected at ${stampPath} \u2014 okay; trust grants need it later)
4303
+ `) + ` short_name: ${shortName}
4304
+
4305
+ `
4306
+ );
4307
+ const answer = (await rl.question("Send these to the server? [Y/n] ")).trim().toLowerCase();
4308
+ if (answer === "" || answer === "y" || answer === "yes") {
4309
+ confirmed = true;
4310
+ } else {
4311
+ process.stdout.write("aborted.\n");
4312
+ return;
4313
+ }
4314
+ } finally {
4315
+ rl.close();
4316
+ }
4317
+ }
4318
+ const response = await postAccept(target, {
4319
+ token: target.token,
4320
+ ssh_pubkey: ssh.full,
4321
+ stamp_pubkey: stamp?.pem ?? null,
4322
+ short_name: shortName
3959
4323
  });
4324
+ const body = response.body;
4325
+ if (response.status === 200 && body.ok) {
4326
+ process.stdout.write(
4327
+ `\u2713 enrolled as ${body.role} (user_id=${body.user_id}). You can now \`git push\` and \`stamp\` against ${target.host}.
4328
+ `
4329
+ );
4330
+ return;
4331
+ }
4332
+ const errorText = body.error ?? `http_${response.status}`;
4333
+ const nextStep = nextStepForError(errorText, opts);
4334
+ throw new Error(
4335
+ `accept-invite failed: ${errorText} (status ${response.status}). ${nextStep}`
4336
+ );
4337
+ }
4338
+
4339
+ // src/commands/provision.ts
4340
+ import { spawnSync as spawnSync7 } from "child_process";
4341
+ import { existsSync as existsSync12, mkdtempSync, readFileSync as readFileSync9, rmSync, writeFileSync as writeFileSync9 } from "fs";
4342
+ import { tmpdir } from "os";
4343
+ import { join as join7, resolve as resolvePath } from "path";
4344
+ import { parse as parseYaml5 } from "yaml";
4345
+
4346
+ // src/commands/server.ts
4347
+ import { spawnSync as spawnSync6 } from "child_process";
4348
+ 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";
4350
+ import { stringify as stringifyYaml2 } from "yaml";
4351
+
4352
+ // src/lib/perRepoKey.ts
4353
+ var VALID_OWNER = /^[A-Za-z0-9-]+$/;
4354
+ var VALID_REPO = /^[A-Za-z0-9._-]+$/;
4355
+ var SSH_CLIENT_KEY_DIR = "/srv/git/.ssh-client-keys";
4356
+ function computePerRepoKeyPath(githubRepo) {
4357
+ if (typeof githubRepo !== "string" || githubRepo.length === 0) {
4358
+ throw new Error("computePerRepoKeyPath: githubRepo must be a non-empty string");
4359
+ }
4360
+ if (githubRepo.startsWith("-")) {
4361
+ throw new Error(
4362
+ `computePerRepoKeyPath: githubRepo must not start with '-': ${githubRepo}`
4363
+ );
4364
+ }
4365
+ if (githubRepo.includes("..")) {
4366
+ throw new Error(
4367
+ `computePerRepoKeyPath: githubRepo must not contain '..': ${githubRepo}`
4368
+ );
4369
+ }
4370
+ const slashCount = (githubRepo.match(/\//g) ?? []).length;
4371
+ if (slashCount !== 1) {
4372
+ throw new Error(
4373
+ `computePerRepoKeyPath: githubRepo must be exactly <owner>/<repo>: ${githubRepo}`
4374
+ );
4375
+ }
4376
+ const [owner, repo] = githubRepo.split("/");
4377
+ if (!owner || !repo) {
4378
+ throw new Error(
4379
+ `computePerRepoKeyPath: owner and repo halves must both be non-empty: ${githubRepo}`
4380
+ );
4381
+ }
4382
+ if (!VALID_OWNER.test(owner)) {
4383
+ throw new Error(
4384
+ `computePerRepoKeyPath: owner must match [A-Za-z0-9-]+ (got "${owner}" in "${githubRepo}")`
4385
+ );
4386
+ }
4387
+ if (!VALID_REPO.test(repo)) {
4388
+ throw new Error(
4389
+ `computePerRepoKeyPath: repo must match [A-Za-z0-9._-]+ (got "${repo}" in "${githubRepo}")`
4390
+ );
4391
+ }
4392
+ return `${SSH_CLIENT_KEY_DIR}/${owner}_${repo}_ed25519`;
3960
4393
  }
3961
4394
 
3962
4395
  // src/commands/server.ts
@@ -3989,7 +4422,7 @@ function runServerConfig(opts) {
3989
4422
  }
3990
4423
  function showConfig() {
3991
4424
  const path2 = userServerConfigPath();
3992
- if (!existsSync10(path2)) {
4425
+ if (!existsSync11(path2)) {
3993
4426
  console.log(`note: no stamp server configured (${path2} does not exist)`);
3994
4427
  console.log(`note: run \`stamp server config <host:port>\` to create one`);
3995
4428
  return;
@@ -4007,7 +4440,7 @@ function showConfig() {
4007
4440
  }
4008
4441
  function unsetConfig() {
4009
4442
  const path2 = userServerConfigPath();
4010
- if (!existsSync10(path2)) {
4443
+ if (!existsSync11(path2)) {
4011
4444
  console.log(`note: ${path2} does not exist; nothing to remove`);
4012
4445
  return;
4013
4446
  }
@@ -4015,7 +4448,7 @@ function unsetConfig() {
4015
4448
  console.log(`removed ${path2}`);
4016
4449
  }
4017
4450
  function fetchServerPubkey(server2, mirror) {
4018
- const sshArgs = [
4451
+ const sshArgs2 = [
4019
4452
  "-p",
4020
4453
  String(server2.port),
4021
4454
  "--",
@@ -4023,9 +4456,9 @@ function fetchServerPubkey(server2, mirror) {
4023
4456
  "stamp-server-pubkey"
4024
4457
  ];
4025
4458
  if (mirror) {
4026
- sshArgs.push(`${mirror.owner}/${mirror.repo}`);
4459
+ sshArgs2.push(`${mirror.owner}/${mirror.repo}`);
4027
4460
  }
4028
- const result = spawnSync5("ssh", sshArgs, {
4461
+ const result = spawnSync6("ssh", sshArgs2, {
4029
4462
  stdio: ["ignore", "pipe", "inherit"],
4030
4463
  encoding: "utf8"
4031
4464
  });
@@ -4073,7 +4506,7 @@ function writeConfig(opts) {
4073
4506
  });
4074
4507
  const path2 = userServerConfigPath();
4075
4508
  const dir = dirname4(path2);
4076
- if (!existsSync10(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
4509
+ if (!existsSync11(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
4077
4510
  const tmp = `${path2}.tmp.${process.pid}`;
4078
4511
  writeFileSync8(tmp, yaml, { mode: 384 });
4079
4512
  renameSync3(tmp, path2);
@@ -4135,7 +4568,7 @@ See docs/quickstart-server.md for how to deploy a stamp server first.`
4135
4568
  return;
4136
4569
  }
4137
4570
  const cloneTarget = resolvePath(opts.into ?? opts.name);
4138
- if (existsSync11(cloneTarget)) {
4571
+ if (existsSync12(cloneTarget)) {
4139
4572
  throw new Error(
4140
4573
  `clone destination already exists: ${cloneTarget}. Move or remove it, or pass --into <other-path>.`
4141
4574
  );
@@ -4212,7 +4645,7 @@ function printPlan2(args) {
4212
4645
  console.log(bar);
4213
4646
  }
4214
4647
  function provisionBareRepoOnServer(server2, name) {
4215
- const result = spawnSync6(
4648
+ const result = spawnSync7(
4216
4649
  "ssh",
4217
4650
  [
4218
4651
  "-p",
@@ -4238,7 +4671,7 @@ function createGithubMirrorRepo(owner, repo, privateRepo) {
4238
4671
  );
4239
4672
  }
4240
4673
  const visibility = privateRepo ? "--private" : "--public";
4241
- const result = spawnSync6(
4674
+ const result = spawnSync7(
4242
4675
  "gh",
4243
4676
  ["repo", "create", `${owner}/${repo}`, visibility],
4244
4677
  { stdio: ["ignore", "inherit", "inherit"] }
@@ -4385,9 +4818,9 @@ async function runMigrateExisting(opts, server2) {
4385
4818
  console.log("\n(dry run \u2014 no changes made)");
4386
4819
  return;
4387
4820
  }
4388
- const stagingDir = mkdtempSync(join6(tmpdir(), "stamp-migrate-"));
4389
- const bareCloneDir = join6(stagingDir, `${opts.name}.git`);
4390
- const tarballPath = join6(stagingDir, `${opts.name}.tar.gz`);
4821
+ const stagingDir = mkdtempSync(join7(tmpdir(), "stamp-migrate-"));
4822
+ const bareCloneDir = join7(stagingDir, `${opts.name}.git`);
4823
+ const tarballPath = join7(stagingDir, `${opts.name}.tar.gz`);
4391
4824
  try {
4392
4825
  console.log(`
4393
4826
  Building bare-clone tarball of existing repo`);
@@ -4423,9 +4856,9 @@ function ensureCwdIsGitRepo(cwd) {
4423
4856
  }
4424
4857
  }
4425
4858
  function ensureStampInitDone(cwd) {
4426
- if (!existsSync11(join6(cwd, ".stamp", "config.yml"))) {
4859
+ if (!existsSync12(join7(cwd, ".stamp", "config.yml"))) {
4427
4860
  throw new Error(
4428
- `--migrate-existing expects this repo to already be stamp-init'd (${join6(cwd, ".stamp/config.yml")} not found). Run \`stamp init --mode local-only\` first, calibrate your reviewers, then re-run with --migrate-existing.`
4861
+ `--migrate-existing expects this repo to already be stamp-init'd (${join7(cwd, ".stamp/config.yml")} not found). Run \`stamp init --mode local-only\` first, calibrate your reviewers, then re-run with --migrate-existing.`
4429
4862
  );
4430
4863
  }
4431
4864
  }
@@ -4455,7 +4888,7 @@ function ensureWorkingTreeClean(cwd) {
4455
4888
  }
4456
4889
  }
4457
4890
  function runTarGz(parentDir, dirName, outputPath) {
4458
- const result = spawnSync6("tar", ["-czf", outputPath, "-C", parentDir, dirName], {
4891
+ const result = spawnSync7("tar", ["-czf", outputPath, "-C", parentDir, dirName], {
4459
4892
  stdio: ["ignore", "inherit", "inherit"]
4460
4893
  });
4461
4894
  if (result.status !== 0) {
@@ -4465,7 +4898,7 @@ function runTarGz(parentDir, dirName, outputPath) {
4465
4898
  }
4466
4899
  }
4467
4900
  function scpToServer(server2, localPath, remotePath) {
4468
- const result = spawnSync6(
4901
+ const result = spawnSync7(
4469
4902
  "scp",
4470
4903
  [
4471
4904
  "-P",
@@ -4483,7 +4916,7 @@ function scpToServer(server2, localPath, remotePath) {
4483
4916
  }
4484
4917
  }
4485
4918
  function sshRunNewStampRepoFromTarball(server2, name, remoteTarballPath) {
4486
- const result = spawnSync6(
4919
+ const result = spawnSync7(
4487
4920
  "ssh",
4488
4921
  [
4489
4922
  "-p",
@@ -4550,15 +4983,15 @@ mirror.yml was added to .stamp/. Commit it through the normal stamp flow:`);
4550
4983
  }
4551
4984
  }
4552
4985
  function readMirrorYmlGithubRepo(repoRoot) {
4553
- const path2 = join6(repoRoot, ".stamp", "mirror.yml");
4554
- if (!existsSync11(path2)) {
4986
+ const path2 = join7(repoRoot, ".stamp", "mirror.yml");
4987
+ if (!existsSync12(path2)) {
4555
4988
  throw new Error(
4556
4989
  `${path2} not found \u2014 --migrate-bypass operates on an already-server-gated repo, but this cwd has no .stamp/mirror.yml. If the repo is not yet server-gated, provision it first with \`stamp provision --migrate-existing\`.`
4557
4990
  );
4558
4991
  }
4559
4992
  let raw;
4560
4993
  try {
4561
- raw = readFileSync8(path2, "utf8");
4994
+ raw = readFileSync9(path2, "utf8");
4562
4995
  } catch (err) {
4563
4996
  throw new Error(
4564
4997
  `could not read ${path2}: ${err instanceof Error ? err.message : String(err)}`
@@ -4789,9 +5222,366 @@ Direct \`git push origin main\` from any non-stamp source will be rejected.`
4789
5222
  }
4790
5223
  }
4791
5224
 
5225
+ // src/commands/trust.ts
5226
+ import { spawnSync as spawnSync8 } from "child_process";
5227
+ import {
5228
+ createPublicKey,
5229
+ createHash as createHash6
5230
+ } from "crypto";
5231
+ import {
5232
+ existsSync as existsSync13,
5233
+ readdirSync as readdirSync2,
5234
+ readFileSync as readFileSync10,
5235
+ writeFileSync as writeFileSync10
5236
+ } from "fs";
5237
+ import { join as join8, resolve } from "path";
5238
+ var USERS_EXIT = {
5239
+ OK: 0,
5240
+ CONFIG: 1,
5241
+ USAGE: 2,
5242
+ AUTHORITY: 3,
5243
+ NOT_FOUND: 4
5244
+ };
5245
+ var SHORT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
5246
+ function resolveServer2() {
5247
+ const server2 = loadServerConfig();
5248
+ if (!server2) {
5249
+ throw new UsageError(
5250
+ "no ~/.stamp/server.yml \u2014 run `stamp server config <host>:<port>` first"
5251
+ );
5252
+ }
5253
+ return server2;
5254
+ }
5255
+ function fetchStampPubkey(server2, shortName) {
5256
+ const result = spawnSync8(
5257
+ "ssh",
5258
+ [
5259
+ "-p",
5260
+ String(server2.port),
5261
+ "--",
5262
+ `${server2.user}@${server2.host}`,
5263
+ "stamp-users",
5264
+ "get-stamp-pubkey",
5265
+ shortName
5266
+ ],
5267
+ { stdio: ["ignore", "pipe", "inherit"], encoding: "utf8" }
5268
+ );
5269
+ if (result.status === 0) {
5270
+ return result.stdout;
5271
+ }
5272
+ if (result.status === USERS_EXIT.NOT_FOUND) {
5273
+ throw new UsageError(
5274
+ `${server2.host}: user ${JSON.stringify(shortName)} is either not enrolled or has no stamp signing pubkey on file. Run \`stamp users list\` to confirm enrollment; ask the user to re-run \`stamp invites accept --stamp-pubkey <path>\` if their signing key was missing at invite time.`
5275
+ );
5276
+ }
5277
+ if (result.status === USERS_EXIT.CONFIG) {
5278
+ throw new Error(
5279
+ `server-side identity binding failed against ${server2.host}. Your SSH key may not be enrolled in the membership DB yet, or the server is missing 'ExposeAuthInfo yes' in sshd_config.`
5280
+ );
5281
+ }
5282
+ throw new Error(
5283
+ `fetching stamp_pubkey for ${JSON.stringify(shortName)} from ${server2.user}@${server2.host}:${server2.port} failed (exit ${result.status}). Common causes: server unreachable, your SSH key isn't enrolled, or the server image predates the get-stamp-pubkey subcommand.`
5284
+ );
5285
+ }
5286
+ function fingerprintFromPem2(pem) {
5287
+ const pub = createPublicKey(pem);
5288
+ const raw = pub.export({ type: "spki", format: "der" });
5289
+ return `sha256:${createHash6("sha256").update(raw).digest("hex")}`;
5290
+ }
5291
+ function findExistingTrustedKey(repoRoot, fingerprint) {
5292
+ const dir = stampTrustedKeysDir(repoRoot);
5293
+ if (!existsSync13(dir)) return null;
5294
+ for (const f of readdirSync2(dir)) {
5295
+ if (!f.endsWith(".pub")) continue;
5296
+ const fullPath = join8(dir, f);
5297
+ let pem;
5298
+ try {
5299
+ pem = readFileSync10(fullPath, "utf8");
5300
+ } catch {
5301
+ continue;
5302
+ }
5303
+ try {
5304
+ if (fingerprintFromPem2(pem) === fingerprint) return f;
5305
+ } catch {
5306
+ }
5307
+ }
5308
+ return null;
5309
+ }
5310
+ function runGit2(args, cwd) {
5311
+ const result = spawnSync8("git", args, {
5312
+ cwd,
5313
+ stdio: ["ignore", "pipe", "pipe"],
5314
+ encoding: "utf8"
5315
+ });
5316
+ if (result.status !== 0) {
5317
+ throw new Error(
5318
+ `git ${args.join(" ")} failed (exit ${result.status}):
5319
+ ${result.stderr ?? ""}`.trim()
5320
+ );
5321
+ }
5322
+ return result.stdout ?? "";
5323
+ }
5324
+ function workingTreeClean(repoRoot) {
5325
+ const result = spawnSync8("git", ["status", "--porcelain"], {
5326
+ cwd: repoRoot,
5327
+ stdio: ["ignore", "pipe", "inherit"],
5328
+ encoding: "utf8"
5329
+ });
5330
+ if (result.status !== 0) return false;
5331
+ return (result.stdout ?? "").trim().length === 0;
5332
+ }
5333
+ function branchExists2(repoRoot, branch) {
5334
+ const result = spawnSync8(
5335
+ "git",
5336
+ ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
5337
+ { cwd: repoRoot, stdio: "ignore" }
5338
+ );
5339
+ return result.status === 0;
5340
+ }
5341
+ function currentBranch2(repoRoot) {
5342
+ return runGit2(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot).trim();
5343
+ }
5344
+ function runTrustGrant(opts) {
5345
+ if (!SHORT_NAME_RE.test(opts.shortName)) {
5346
+ throw new UsageError(
5347
+ `short-name ${JSON.stringify(opts.shortName)} has an invalid shape (allowed: alphanumerics + . _ -, must start with alnum, max 63 chars).`
5348
+ );
5349
+ }
5350
+ const repoRoot = resolve(opts.repoPath ?? process.cwd());
5351
+ if (!existsSync13(join8(repoRoot, ".git"))) {
5352
+ throw new UsageError(`${repoRoot} is not a git repository`);
5353
+ }
5354
+ if (!existsSync13(stampConfigDir(repoRoot))) {
5355
+ throw new UsageError(
5356
+ `${repoRoot} has no .stamp/ directory \u2014 run \`stamp init\` first or pass --repo <path> pointing at a stamp-gated repo`
5357
+ );
5358
+ }
5359
+ if (!opts.forceDirty && !workingTreeClean(repoRoot)) {
5360
+ throw new UsageError(
5361
+ `${repoRoot} has uncommitted changes. Commit or stash them first, then re-run. (Use --force-dirty to override, but the resulting branch will include unrelated changes.)`
5362
+ );
5363
+ }
5364
+ const branch = `stamp-trust/${opts.shortName}`;
5365
+ if (branchExists2(repoRoot, branch)) {
5366
+ throw new UsageError(
5367
+ `branch ${branch} already exists. Delete it (\`git branch -D ${branch}\`) or finish the in-flight grant by switching to it and pushing/merging.`
5368
+ );
5369
+ }
5370
+ const server2 = resolveServer2();
5371
+ const pemRaw = fetchStampPubkey(server2, opts.shortName);
5372
+ const pem = pemRaw.trim() + "\n";
5373
+ let fingerprint;
5374
+ try {
5375
+ fingerprint = fingerprintFromPem2(pem);
5376
+ } catch (e) {
5377
+ throw new Error(
5378
+ `server returned an invalid PEM for ${opts.shortName}: ${e.message}. This is almost always a server-side issue (corrupted membership DB, or a future server version emitting a key format this client can't parse). Contact the server admin with the PEM body.`
5379
+ );
5380
+ }
5381
+ const existingFile = findExistingTrustedKey(repoRoot, fingerprint);
5382
+ if (existingFile) {
5383
+ process.stdout.write(
5384
+ `note: repo already trusts ${opts.shortName}'s stamp signing key (matching .stamp/trusted-keys/${existingFile}). No changes.
5385
+ `
5386
+ );
5387
+ return;
5388
+ }
5389
+ const startingBranch = currentBranch2(repoRoot);
5390
+ runGit2(["checkout", "-b", branch], repoRoot);
5391
+ const keysDir = stampTrustedKeysDir(repoRoot);
5392
+ ensureDir(keysDir, 493);
5393
+ const keyFile = join8(keysDir, `${opts.shortName}.pub`);
5394
+ writeFileSync10(keyFile, pem, { mode: 420 });
5395
+ runGit2(["add", keyFile], repoRoot);
5396
+ runGit2(
5397
+ [
5398
+ "commit",
5399
+ "-m",
5400
+ `Trust grant: add ${opts.shortName} to .stamp/trusted-keys/
5401
+
5402
+ Stages ${opts.shortName}'s stamp signing pubkey (fingerprint ${fingerprint}) so their stamp-signed merges into protected branches verify against this repo's trust set.
5403
+
5404
+ Source: stamp trust grant ${opts.shortName} (server ${server2.host})`
5405
+ ],
5406
+ repoRoot
5407
+ );
5408
+ process.stdout.write(
5409
+ `\u2713 staged trust-grant for ${opts.shortName} on branch ${branch}
5410
+ trusted-keys file: ${keyFile}
5411
+ fingerprint: ${fingerprint}
5412
+ started from: ${startingBranch}
5413
+
5414
+ Next steps:
5415
+ stamp review --diff ${startingBranch}..${branch}
5416
+ git checkout ${startingBranch}
5417
+ stamp merge ${branch} --into ${startingBranch}
5418
+ stamp push ${startingBranch}
5419
+ `
5420
+ );
5421
+ }
5422
+
5423
+ // src/commands/users.ts
5424
+ import { spawnSync as spawnSync9 } from "child_process";
5425
+ var EXIT = {
5426
+ OK: 0,
5427
+ CONFIG: 1,
5428
+ USAGE: 2,
5429
+ AUTHORITY: 3,
5430
+ NOT_FOUND: 4,
5431
+ LAST_OWNER: 5,
5432
+ CANNOT_REMOVE_SELF: 6
5433
+ };
5434
+ function resolveServer3() {
5435
+ const server2 = loadServerConfig();
5436
+ if (!server2) {
5437
+ throw new UsageError(
5438
+ "no ~/.stamp/server.yml \u2014 run `stamp server config <host>:<port>` first"
5439
+ );
5440
+ }
5441
+ return server2;
5442
+ }
5443
+ function sshArgs(server2, remoteArgs) {
5444
+ return [
5445
+ "-p",
5446
+ String(server2.port),
5447
+ "--",
5448
+ `${server2.user}@${server2.host}`,
5449
+ "stamp-users",
5450
+ ...remoteArgs
5451
+ ];
5452
+ }
5453
+ function callRemote(server2, remoteArgs) {
5454
+ const result = spawnSync9("ssh", sshArgs(server2, remoteArgs), {
5455
+ // Stdout is always piped: `list` needs it for JSON parsing; write
5456
+ // ops don't emit on stdout so an empty pipe is harmless. Stderr is
5457
+ // always inherited so server-side `note:` confirmations and
5458
+ // `error:` prose land in the operator's terminal verbatim.
5459
+ stdio: ["ignore", "pipe", "inherit"],
5460
+ encoding: "utf8"
5461
+ });
5462
+ return { status: result.status, stdout: result.stdout ?? "" };
5463
+ }
5464
+ function explainExit(status, context, details) {
5465
+ switch (status) {
5466
+ case EXIT.AUTHORITY:
5467
+ return new UsageError(
5468
+ `${context}: your role on ${details.server.host} doesn't permit this action. Owners may manage any user; admins may manage members only (and may self-promote to owner if no owners exist yet \u2014 the bootstrap path). Ask an existing owner to perform this action.`
5469
+ );
5470
+ case EXIT.NOT_FOUND:
5471
+ return new UsageError(
5472
+ `${context}: short_name ${JSON.stringify(details.shortName ?? "?")} isn't in the membership DB on ${details.server.host}. Run \`stamp users list\` to see who's enrolled.`
5473
+ );
5474
+ case EXIT.LAST_OWNER:
5475
+ return new UsageError(
5476
+ `${context}: this would leave the server with zero owners. Promote another user to owner first (\`stamp users promote <name> --to owner\`).`
5477
+ );
5478
+ case EXIT.CANNOT_REMOVE_SELF:
5479
+ return new UsageError(
5480
+ `${context}: you can't remove your own account. Ask another admin or owner to do it \u2014 prevents accidentally locking yourself out.`
5481
+ );
5482
+ case EXIT.USAGE:
5483
+ return new UsageError(
5484
+ `${context}: server rejected the request as a usage error. Double-check the short_name (alphanumerics + . _ -, must start with alnum, max 63 chars) and --to value.`
5485
+ );
5486
+ case EXIT.CONFIG:
5487
+ return new Error(
5488
+ `${context}: server-side configuration error on ${details.server.host}. Most likely your account isn't in the membership DB yet, or the server is missing 'ExposeAuthInfo yes' in sshd_config. See server logs for specifics.`
5489
+ );
5490
+ default:
5491
+ return new Error(
5492
+ `${context}: failed against ${details.server.user}@${details.server.host}:${details.server.port} (exit ${status}). Common causes: server unreachable, your SSH key isn't enrolled, or the server image is older than the user-management feature.`
5493
+ );
5494
+ }
5495
+ }
5496
+ function pad(s, width) {
5497
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
5498
+ }
5499
+ function formatUsersTable(rows) {
5500
+ if (rows.length === 0) return "(no users)\n";
5501
+ const widths = {
5502
+ short_name: Math.max(10, ...rows.map((r) => r.short_name.length)),
5503
+ role: Math.max(6, ...rows.map((r) => r.role.length)),
5504
+ source: Math.max(6, ...rows.map((r) => r.source.length))
5505
+ };
5506
+ const out = [];
5507
+ out.push(
5508
+ pad("short_name", widths.short_name) + " " + pad("role", widths.role) + " " + pad("source", widths.source) + " ssh_fp"
5509
+ );
5510
+ out.push(
5511
+ pad("-".repeat(widths.short_name), widths.short_name) + " " + pad("-".repeat(widths.role), widths.role) + " " + pad("-".repeat(widths.source), widths.source) + " " + "-".repeat(20)
5512
+ );
5513
+ for (const r of rows) {
5514
+ out.push(
5515
+ pad(r.short_name, widths.short_name) + " " + pad(r.role, widths.role) + " " + pad(r.source, widths.source) + " " + r.ssh_fp
5516
+ );
5517
+ }
5518
+ return out.join("\n") + "\n";
5519
+ }
5520
+ function runUsersList(opts) {
5521
+ const server2 = resolveServer3();
5522
+ const result = callRemote(server2, ["list"]);
5523
+ if (result.status !== 0) {
5524
+ throw explainExit(result.status, "stamp users list", { server: server2 });
5525
+ }
5526
+ if (opts.json) {
5527
+ process.stdout.write(result.stdout);
5528
+ return;
5529
+ }
5530
+ let payload;
5531
+ try {
5532
+ payload = JSON.parse(result.stdout);
5533
+ } catch (e) {
5534
+ throw new Error(
5535
+ `server returned non-JSON output: ${e.message}. Raw: ${JSON.stringify(result.stdout.slice(0, 200))}`
5536
+ );
5537
+ }
5538
+ const rows = payload.users ?? [];
5539
+ process.stdout.write(formatUsersTable(rows));
5540
+ }
5541
+ function runUsersPromote(opts) {
5542
+ const server2 = resolveServer3();
5543
+ const result = callRemote(server2, [
5544
+ "promote",
5545
+ opts.shortName,
5546
+ "--to",
5547
+ opts.to
5548
+ ]);
5549
+ if (result.status !== 0) {
5550
+ throw explainExit(result.status, `stamp users promote ${opts.shortName}`, {
5551
+ server: server2,
5552
+ shortName: opts.shortName
5553
+ });
5554
+ }
5555
+ }
5556
+ function runUsersDemote(opts) {
5557
+ const server2 = resolveServer3();
5558
+ const result = callRemote(server2, [
5559
+ "demote",
5560
+ opts.shortName,
5561
+ "--to",
5562
+ opts.to
5563
+ ]);
5564
+ if (result.status !== 0) {
5565
+ throw explainExit(result.status, `stamp users demote ${opts.shortName}`, {
5566
+ server: server2,
5567
+ shortName: opts.shortName
5568
+ });
5569
+ }
5570
+ }
5571
+ function runUsersRemove(opts) {
5572
+ const server2 = resolveServer3();
5573
+ const result = callRemote(server2, ["remove", opts.shortName]);
5574
+ if (result.status !== 0) {
5575
+ throw explainExit(result.status, `stamp users remove ${opts.shortName}`, {
5576
+ server: server2,
5577
+ shortName: opts.shortName
5578
+ });
5579
+ }
5580
+ }
5581
+
4792
5582
  // src/commands/keys.ts
4793
- import { existsSync as existsSync12, readdirSync as readdirSync2, readFileSync as readFileSync9, writeFileSync as writeFileSync10 } from "fs";
4794
- import { basename, join as join7 } from "path";
5583
+ import { existsSync as existsSync14, readdirSync as readdirSync3, readFileSync as readFileSync11, writeFileSync as writeFileSync11 } from "fs";
5584
+ import { basename, join as join9 } from "path";
4795
5585
  function keysGenerate() {
4796
5586
  const existing = loadUserKeypair();
4797
5587
  if (existing) {
@@ -4824,18 +5614,18 @@ function keysList() {
4824
5614
  const repoRoot = findRepoRoot();
4825
5615
  const trustedDir = stampTrustedKeysDir(repoRoot);
4826
5616
  console.log(`repo trusted keys: ${trustedDir}/`);
4827
- if (!existsSync12(trustedDir)) {
5617
+ if (!existsSync14(trustedDir)) {
4828
5618
  console.log(" (directory does not exist \u2014 run `stamp init`)");
4829
5619
  return;
4830
5620
  }
4831
- const pubFiles = readdirSync2(trustedDir).filter((f) => f.endsWith(".pub"));
5621
+ const pubFiles = readdirSync3(trustedDir).filter((f) => f.endsWith(".pub"));
4832
5622
  if (pubFiles.length === 0) {
4833
5623
  console.log(" (none)");
4834
5624
  return;
4835
5625
  }
4836
5626
  for (const file of pubFiles.sort()) {
4837
5627
  try {
4838
- const pem = readFileSync9(join7(trustedDir, file), "utf8");
5628
+ const pem = readFileSync11(join9(trustedDir, file), "utf8");
4839
5629
  const fp = fingerprintFromPem(pem);
4840
5630
  const marker = local && fp === local.fingerprint ? " (you)" : "";
4841
5631
  console.log(` ${fp}${marker} [${file}]`);
@@ -4854,15 +5644,15 @@ function keysExport() {
4854
5644
  function keysTrust(pubFile) {
4855
5645
  const repoRoot = findRepoRoot();
4856
5646
  const trustedDir = stampTrustedKeysDir(repoRoot);
4857
- if (!existsSync12(trustedDir)) {
5647
+ if (!existsSync14(trustedDir)) {
4858
5648
  throw new Error(
4859
5649
  `no ${trustedDir} \u2014 run \`stamp init\` first to create the trust store`
4860
5650
  );
4861
5651
  }
4862
- if (!existsSync12(pubFile)) {
5652
+ if (!existsSync14(pubFile)) {
4863
5653
  throw new Error(`public key file not found: ${pubFile}`);
4864
5654
  }
4865
- const pem = readFileSync9(pubFile, "utf8");
5655
+ const pem = readFileSync11(pubFile, "utf8");
4866
5656
  let fingerprint;
4867
5657
  try {
4868
5658
  fingerprint = fingerprintFromPem(pem);
@@ -4872,12 +5662,12 @@ function keysTrust(pubFile) {
4872
5662
  );
4873
5663
  }
4874
5664
  const filename = publicKeyFingerprintFilename(fingerprint);
4875
- const dest = join7(trustedDir, filename);
4876
- if (existsSync12(dest)) {
5665
+ const dest = join9(trustedDir, filename);
5666
+ if (existsSync14(dest)) {
4877
5667
  console.log(`${fingerprint} is already trusted (${basename(dest)})`);
4878
5668
  return;
4879
5669
  }
4880
- writeFileSync10(dest, pem);
5670
+ writeFileSync11(dest, pem);
4881
5671
  console.log(`trusted ${fingerprint}`);
4882
5672
  console.log(` \u2192 ${dest}`);
4883
5673
  console.log();
@@ -4885,11 +5675,11 @@ function keysTrust(pubFile) {
4885
5675
  }
4886
5676
 
4887
5677
  // src/commands/log.ts
4888
- import { existsSync as existsSync13 } from "fs";
5678
+ import { existsSync as existsSync15 } from "fs";
4889
5679
  function runLog(opts) {
4890
5680
  const repoRoot = findRepoRoot();
4891
5681
  const configPath = stampConfigFile(repoRoot);
4892
- if (!existsSync13(configPath)) {
5682
+ if (!existsSync15(configPath)) {
4893
5683
  throw new Error(
4894
5684
  `no .stamp/config.yml at ${configPath}. Run \`stamp init\` first.`
4895
5685
  );
@@ -5006,7 +5796,7 @@ function printCommitDetail(sha, repoRoot) {
5006
5796
  }
5007
5797
  function collectReviewProse(repoRoot, payload) {
5008
5798
  const dbPath = stampStateDbPath(repoRoot);
5009
- if (!existsSync13(dbPath)) return [];
5799
+ if (!existsSync15(dbPath)) return [];
5010
5800
  const db = openDb(dbPath);
5011
5801
  try {
5012
5802
  const rows = latestReviews(db, payload.base_sha, payload.head_sha);
@@ -5020,7 +5810,7 @@ function printReviewHistory(repoRoot, limit, diff) {
5020
5810
  const configPath = stampConfigFile(repoRoot);
5021
5811
  loadConfig(configPath);
5022
5812
  const dbPath = stampStateDbPath(repoRoot);
5023
- if (!existsSync13(dbPath)) {
5813
+ if (!existsSync15(dbPath)) {
5024
5814
  console.log("No reviews recorded yet.");
5025
5815
  return;
5026
5816
  }
@@ -5061,8 +5851,8 @@ function printReviewHistory(repoRoot, limit, diff) {
5061
5851
  }
5062
5852
 
5063
5853
  // src/commands/prune.ts
5064
- import { existsSync as existsSync14, readdirSync as readdirSync3, statSync as statSync2, unlinkSync as unlinkSync3 } from "fs";
5065
- import { join as join8 } from "path";
5854
+ import { existsSync as existsSync16, readdirSync as readdirSync4, statSync as statSync2, unlinkSync as unlinkSync3 } from "fs";
5855
+ import { join as join10 } from "path";
5066
5856
 
5067
5857
  // src/lib/duration.ts
5068
5858
  function parseRetentionDuration(input) {
@@ -5091,15 +5881,16 @@ function runPrune(opts) {
5091
5881
  );
5092
5882
  const repoRoot = findRepoRoot();
5093
5883
  const dbPath = stampStateDbPath(repoRoot);
5094
- const spoolDir = join8(gitCommonDir(repoRoot), "stamp", "failed-parses");
5884
+ const parsesDir = join10(gitCommonDir(repoRoot), "stamp", "failed-parses");
5885
+ const runsDir = join10(gitCommonDir(repoRoot), "stamp", "failed-runs");
5095
5886
  const spoolCutoffMs = Date.now() - durationMs;
5096
- if (!existsSync14(dbPath) && !existsSync14(spoolDir)) {
5887
+ if (!existsSync16(dbPath) && !existsSync16(parsesDir) && !existsSync16(runsDir)) {
5097
5888
  console.log(
5098
- `note: nothing to prune (neither ${dbPath} nor ${spoolDir} exists \u2014 both are created on first \`stamp review\`)`
5889
+ `note: nothing to prune (none of ${dbPath}, ${parsesDir}, ${runsDir} exist \u2014 all are created on first \`stamp review\`)`
5099
5890
  );
5100
5891
  return;
5101
5892
  }
5102
- const db = existsSync14(dbPath) ? openDb(dbPath) : null;
5893
+ const db = existsSync16(dbPath) ? openDb(dbPath) : null;
5103
5894
  try {
5104
5895
  if (opts.dryRun) {
5105
5896
  let any2 = false;
@@ -5113,14 +5904,19 @@ function runPrune(opts) {
5113
5904
  any2 = true;
5114
5905
  }
5115
5906
  }
5116
- const spoolPeek = peekFailedParseSpools(spoolDir, spoolCutoffMs);
5117
- if (spoolPeek.length > 0) {
5118
- if (any2) console.log("");
5119
- console.log(
5120
- `would prune ${spoolPeek.length} failed-parse spool file${spoolPeek.length === 1 ? "" : "s"} older than ${humanLabel}:`
5121
- );
5122
- for (const f of spoolPeek) console.log(` ${f}`);
5123
- any2 = true;
5907
+ for (const [label, dir] of [
5908
+ ["failed-parse", parsesDir],
5909
+ ["failed-run", runsDir]
5910
+ ]) {
5911
+ const peek = peekSpools(dir, spoolCutoffMs);
5912
+ if (peek.length > 0) {
5913
+ if (any2) console.log("");
5914
+ console.log(
5915
+ `would prune ${peek.length} ${label} spool file${peek.length === 1 ? "" : "s"} older than ${humanLabel}:`
5916
+ );
5917
+ for (const f of peek) console.log(` ${f}`);
5918
+ any2 = true;
5919
+ }
5124
5920
  }
5125
5921
  if (!any2) {
5126
5922
  console.log(`note: nothing to prune (no rows or spools older than ${humanLabel})`);
@@ -5143,13 +5939,18 @@ function runPrune(opts) {
5143
5939
  any = true;
5144
5940
  }
5145
5941
  }
5146
- const spoolDeleted = pruneFailedParseSpools(spoolDir, spoolCutoffMs);
5147
- if (spoolDeleted > 0) {
5148
- if (any) console.log("");
5149
- console.log(
5150
- `${spoolDeleted} failed-parse spool file${spoolDeleted === 1 ? "" : "s"} pruned`
5151
- );
5152
- any = true;
5942
+ for (const [label, dir] of [
5943
+ ["failed-parse", parsesDir],
5944
+ ["failed-run", runsDir]
5945
+ ]) {
5946
+ const deleted = pruneSpools(dir, spoolCutoffMs);
5947
+ if (deleted > 0) {
5948
+ if (any) console.log("");
5949
+ console.log(
5950
+ `${deleted} ${label} spool file${deleted === 1 ? "" : "s"} pruned`
5951
+ );
5952
+ any = true;
5953
+ }
5153
5954
  }
5154
5955
  if (!any) {
5155
5956
  console.log(`note: nothing to prune (no rows or spools older than ${humanLabel})`);
@@ -5158,11 +5959,11 @@ function runPrune(opts) {
5158
5959
  db?.close();
5159
5960
  }
5160
5961
  }
5161
- function peekFailedParseSpools(spoolDir, cutoffMs) {
5162
- if (!existsSync14(spoolDir)) return [];
5962
+ function peekSpools(spoolDir, cutoffMs) {
5963
+ if (!existsSync16(spoolDir)) return [];
5163
5964
  const out = [];
5164
- for (const entry of readdirSync3(spoolDir)) {
5165
- const filepath = join8(spoolDir, entry);
5965
+ for (const entry of readdirSync4(spoolDir)) {
5966
+ const filepath = join10(spoolDir, entry);
5166
5967
  let stat;
5167
5968
  try {
5168
5969
  stat = statSync2(filepath);
@@ -5174,8 +5975,8 @@ function peekFailedParseSpools(spoolDir, cutoffMs) {
5174
5975
  }
5175
5976
  return out.sort();
5176
5977
  }
5177
- function pruneFailedParseSpools(spoolDir, cutoffMs) {
5178
- const targets = peekFailedParseSpools(spoolDir, cutoffMs);
5978
+ function pruneSpools(spoolDir, cutoffMs) {
5979
+ const targets = peekSpools(spoolDir, cutoffMs);
5179
5980
  let deleted = 0;
5180
5981
  for (const filepath of targets) {
5181
5982
  try {
@@ -5196,7 +5997,7 @@ function printPerReviewer(rows) {
5196
5997
  }
5197
5998
 
5198
5999
  // src/commands/config.ts
5199
- import { existsSync as existsSync15 } from "fs";
6000
+ import { existsSync as existsSync17 } from "fs";
5200
6001
  function runConfigReviewersSet(opts) {
5201
6002
  if (!isValidReviewerName(opts.reviewer)) {
5202
6003
  throw new UsageError(
@@ -5269,7 +6070,7 @@ function runConfigReviewersClear(opts) {
5269
6070
  }
5270
6071
  function runConfigReviewersShow() {
5271
6072
  const path2 = userConfigPath();
5272
- if (!existsSync15(path2)) {
6073
+ if (!existsSync17(path2)) {
5273
6074
  console.log(`note: no per-user stamp config (${path2} does not exist).`);
5274
6075
  console.log(
5275
6076
  ` Defaults will apply on next \`stamp init\` or \`stamp review\`:`
@@ -5314,30 +6115,30 @@ function loadOrEmpty() {
5314
6115
  }
5315
6116
 
5316
6117
  // src/commands/reviewers.ts
5317
- import { spawnSync as spawnSync7 } from "child_process";
6118
+ import { spawnSync as spawnSync10 } from "child_process";
5318
6119
  import {
5319
- existsSync as existsSync17,
5320
- readFileSync as readFileSync11,
6120
+ existsSync as existsSync19,
6121
+ readFileSync as readFileSync13,
5321
6122
  statSync as statSync3,
5322
6123
  unlinkSync as unlinkSync4,
5323
- writeFileSync as writeFileSync12
6124
+ writeFileSync as writeFileSync13
5324
6125
  } from "fs";
5325
- import { join as join10, relative, resolve } from "path";
6126
+ import { join as join12, relative, resolve as resolve2 } from "path";
5326
6127
  import { parse as parseYaml6, stringify as stringifyYaml3 } from "yaml";
5327
6128
 
5328
6129
  // src/lib/reviewerLock.ts
5329
- import { existsSync as existsSync16, readFileSync as readFileSync10, writeFileSync as writeFileSync11 } from "fs";
5330
- import { join as join9 } from "path";
6130
+ import { existsSync as existsSync18, readFileSync as readFileSync12, writeFileSync as writeFileSync12 } from "fs";
6131
+ import { join as join11 } from "path";
5331
6132
  var LOCK_FILE_VERSION = 1;
5332
6133
  var LOCK_DRIFT_EXIT = 3;
5333
6134
  function lockFilePath(repoRoot, reviewerName) {
5334
- return join9(repoRoot, ".stamp", "reviewers", `${reviewerName}.lock.json`);
6135
+ return join11(repoRoot, ".stamp", "reviewers", `${reviewerName}.lock.json`);
5335
6136
  }
5336
6137
  function readLockFile(repoRoot, reviewerName) {
5337
6138
  const path2 = lockFilePath(repoRoot, reviewerName);
5338
- if (!existsSync16(path2)) return null;
6139
+ if (!existsSync18(path2)) return null;
5339
6140
  try {
5340
- const raw = readFileSync10(path2, "utf8");
6141
+ const raw = readFileSync12(path2, "utf8");
5341
6142
  const parsed = JSON.parse(raw);
5342
6143
  if (typeof parsed.version !== "number" || typeof parsed.source !== "string" || typeof parsed.ref !== "string" || typeof parsed.reviewer !== "string" || typeof parsed.prompt_sha256 !== "string" || typeof parsed.tools_sha256 !== "string" || typeof parsed.mcp_sha256 !== "string") {
5343
6144
  throw new Error(`malformed lock file at ${path2}`);
@@ -5351,20 +6152,20 @@ function readLockFile(repoRoot, reviewerName) {
5351
6152
  }
5352
6153
  function writeLockFile(repoRoot, reviewerName, lock) {
5353
6154
  const path2 = lockFilePath(repoRoot, reviewerName);
5354
- writeFileSync11(path2, JSON.stringify(lock, null, 2) + "\n", "utf8");
6155
+ writeFileSync12(path2, JSON.stringify(lock, null, 2) + "\n", "utf8");
5355
6156
  }
5356
6157
  function checkReviewerDrift(repoRoot, reviewerName, def) {
5357
6158
  const lock = readLockFile(repoRoot, reviewerName);
5358
6159
  if (!lock) {
5359
6160
  return unpinnedResult();
5360
6161
  }
5361
- const promptPath = join9(repoRoot, def.prompt);
5362
- if (!existsSync16(promptPath)) {
6162
+ const promptPath = join11(repoRoot, def.prompt);
6163
+ if (!existsSync18(promptPath)) {
5363
6164
  throw new Error(
5364
6165
  `reviewer "${reviewerName}" has a lock file but its prompt "${def.prompt}" does not exist on disk. Re-run 'stamp reviewers fetch ${reviewerName} --from ${lock.source}@${lock.ref}' to restore it, or delete the lock file to un-pin the reviewer.`
5365
6166
  );
5366
6167
  }
5367
- const promptBytes = readFileSync10(promptPath);
6168
+ const promptBytes = readFileSync12(promptPath);
5368
6169
  const observedPrompt = hashPromptBytes(promptBytes);
5369
6170
  const observedTools = hashTools(def.tools);
5370
6171
  const observedMcp = hashMcpServers(def.mcp_servers);
@@ -5443,9 +6244,9 @@ function reviewersList() {
5443
6244
  const maxNameLen = Math.max(...names.map((n) => n.length));
5444
6245
  for (const name of names) {
5445
6246
  const def = config2.reviewers[name];
5446
- const abs = resolve(repoRoot, def.prompt);
6247
+ const abs = resolve2(repoRoot, def.prompt);
5447
6248
  let annotation = "";
5448
- if (!existsSync17(abs)) {
6249
+ if (!existsSync19(abs)) {
5449
6250
  annotation = " MISSING";
5450
6251
  } else {
5451
6252
  const size = statSync3(abs).size;
@@ -5469,7 +6270,7 @@ function reviewersEdit(name) {
5469
6270
  `reviewer "${name}" is not configured. Run \`stamp reviewers list\` to see available reviewers.`
5470
6271
  );
5471
6272
  }
5472
- const target = resolve(repoRoot, def.prompt);
6273
+ const target = resolve2(repoRoot, def.prompt);
5473
6274
  launchEditor(target);
5474
6275
  }
5475
6276
  function reviewersAdd(name, opts = {}) {
@@ -5483,20 +6284,20 @@ function reviewersAdd(name, opts = {}) {
5483
6284
  );
5484
6285
  }
5485
6286
  const promptRel = `.stamp/reviewers/${name}.md`;
5486
- const promptAbs = resolve(repoRoot, promptRel);
5487
- if (existsSync17(promptAbs)) {
6287
+ const promptAbs = resolve2(repoRoot, promptRel);
6288
+ if (existsSync19(promptAbs)) {
5488
6289
  throw new Error(
5489
6290
  `${promptRel} already exists on disk but is not in config. Either delete the file or add it to config manually.`
5490
6291
  );
5491
6292
  }
5492
- writeFileSync12(
6293
+ writeFileSync13(
5493
6294
  promptAbs,
5494
6295
  `# ${name}
5495
6296
 
5496
6297
  ${EXAMPLE_REVIEWER_PROMPT.split("\n").slice(2).join("\n")}`
5497
6298
  );
5498
6299
  config2.reviewers[name] = { prompt: promptRel };
5499
- writeFileSync12(configPath, stringifyConfig(config2));
6300
+ writeFileSync13(configPath, stringifyConfig(config2));
5500
6301
  console.log(`reviewer "${name}" added.`);
5501
6302
  console.log(` prompt file: ${promptRel}`);
5502
6303
  console.log(` registered in .stamp/config.yml`);
@@ -5531,11 +6332,11 @@ function reviewersRemove(name, opts = {}) {
5531
6332
  );
5532
6333
  }
5533
6334
  delete config2.reviewers[name];
5534
- writeFileSync12(configPath, stringifyConfig(config2));
6335
+ writeFileSync13(configPath, stringifyConfig(config2));
5535
6336
  console.log(`reviewer "${name}" removed from .stamp/config.yml`);
5536
6337
  if (opts.deleteFile) {
5537
- const promptAbs = resolve(repoRoot, def.prompt);
5538
- if (existsSync17(promptAbs)) {
6338
+ const promptAbs = resolve2(repoRoot, def.prompt);
6339
+ if (existsSync19(promptAbs)) {
5539
6340
  unlinkSync4(promptAbs);
5540
6341
  console.log(`deleted ${def.prompt}`);
5541
6342
  }
@@ -5566,8 +6367,8 @@ async function reviewersTest(name, diff) {
5566
6367
  console.log(` prompt sourced from working tree (test/iteration use case)`);
5567
6368
  console.log();
5568
6369
  const def = config2.reviewers[name];
5569
- const promptPath = join10(repoRoot, def.prompt);
5570
- const systemPrompt = readFileSync11(promptPath, "utf8");
6370
+ const promptPath = join12(repoRoot, def.prompt);
6371
+ const systemPrompt = readFileSync13(promptPath, "utf8");
5571
6372
  const result = await invokeReviewer({
5572
6373
  reviewer: name,
5573
6374
  config: config2,
@@ -5594,7 +6395,7 @@ function reviewersShow(name, opts) {
5594
6395
  );
5595
6396
  }
5596
6397
  const dbPath = stampStateDbPath(repoRoot);
5597
- if (!existsSync17(dbPath)) {
6398
+ if (!existsSync19(dbPath)) {
5598
6399
  console.log("No reviews recorded yet (no state.db).");
5599
6400
  return;
5600
6401
  }
@@ -5684,7 +6485,7 @@ async function reviewersFetch(reviewerName, opts) {
5684
6485
  opts.expectMcpSha
5685
6486
  );
5686
6487
  const reviewersDir = stampReviewersDir(repoRoot);
5687
- if (!existsSync17(reviewersDir)) {
6488
+ if (!existsSync19(reviewersDir)) {
5688
6489
  throw new Error(
5689
6490
  `${reviewersDir} does not exist \u2014 run \`stamp init\` first.`
5690
6491
  );
@@ -5705,7 +6506,7 @@ async function reviewersFetch(reviewerName, opts) {
5705
6506
  mcpServers = validateMcpServersFromSource(parsed.mcp_servers, source, ref);
5706
6507
  }
5707
6508
  }
5708
- const promptPath = join10(reviewersDir, `${reviewerName}.md`);
6509
+ const promptPath = join12(reviewersDir, `${reviewerName}.md`);
5709
6510
  const promptBytes = Buffer.from(promptText, "utf8");
5710
6511
  const promptSha = hashPromptBytes(promptBytes);
5711
6512
  const toolsSha = hashTools(tools);
@@ -5729,7 +6530,7 @@ async function reviewersFetch(reviewerName, opts) {
5729
6530
  mcpSha
5730
6531
  );
5731
6532
  }
5732
- writeFileSync12(promptPath, promptBytes);
6533
+ writeFileSync13(promptPath, promptBytes);
5733
6534
  const lock = {
5734
6535
  version: LOCK_FILE_VERSION,
5735
6536
  source,
@@ -5958,7 +6759,7 @@ function buildConfigYamlHint(reviewerName, tools, mcpServers) {
5958
6759
  }
5959
6760
  function launchEditor(path2) {
5960
6761
  const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? (process.platform === "win32" ? "notepad" : "vi");
5961
- const result = spawnSync7(editor, [path2], { stdio: "inherit" });
6762
+ const result = spawnSync10(editor, [path2], { stdio: "inherit" });
5962
6763
  if (result.error) {
5963
6764
  throw new Error(
5964
6765
  `failed to launch editor "${editor}": ${result.error.message}`
@@ -5970,11 +6771,11 @@ function launchEditor(path2) {
5970
6771
  }
5971
6772
 
5972
6773
  // src/commands/status.ts
5973
- import { existsSync as existsSync18 } from "fs";
6774
+ import { existsSync as existsSync20 } from "fs";
5974
6775
  function runStatus(opts) {
5975
6776
  const repoRoot = findRepoRoot();
5976
6777
  const configPath = stampConfigFile(repoRoot);
5977
- if (!existsSync18(configPath)) {
6778
+ if (!existsSync20(configPath)) {
5978
6779
  throw new Error(
5979
6780
  `no .stamp/config.yml at ${configPath}. Run \`stamp init\` first.`
5980
6781
  );
@@ -6042,17 +6843,17 @@ function printGate(result, base_sha, head_sha) {
6042
6843
  }
6043
6844
 
6044
6845
  // src/commands/update.ts
6045
- import { spawnSync as spawnSync8 } from "child_process";
6846
+ import { spawnSync as spawnSync11 } from "child_process";
6046
6847
 
6047
6848
  // src/lib/version.ts
6048
- import { readFileSync as readFileSync12 } from "fs";
6049
- import { dirname as dirname5, join as join11 } from "path";
6849
+ import { readFileSync as readFileSync14 } from "fs";
6850
+ import { dirname as dirname5, join as join13 } from "path";
6050
6851
  import { fileURLToPath } from "url";
6051
6852
  function readPackageVersion() {
6052
6853
  const here = dirname5(fileURLToPath(import.meta.url));
6053
6854
  for (let dir = here, i = 0; i < 6; i++) {
6054
6855
  try {
6055
- const raw = readFileSync12(join11(dir, "package.json"), "utf8");
6856
+ const raw = readFileSync14(join13(dir, "package.json"), "utf8");
6056
6857
  const pkg = JSON.parse(raw);
6057
6858
  if (pkg.name === "@openthink/stamp" && pkg.version) return pkg.version;
6058
6859
  } catch {
@@ -6083,7 +6884,7 @@ function runUpdate() {
6083
6884
  `);
6084
6885
  process.stdout.write(`checking npm registry for latest...
6085
6886
  `);
6086
- const viewResult = spawnSync8("npm", ["view", PKG_NAME, "version"], {
6887
+ const viewResult = spawnSync11("npm", ["view", PKG_NAME, "version"], {
6087
6888
  encoding: "utf8"
6088
6889
  });
6089
6890
  if (viewResult.error || viewResult.status !== 0) {
@@ -6117,7 +6918,7 @@ function runUpdate() {
6117
6918
  }
6118
6919
  process.stdout.write(`installing ${PKG_NAME}@${latest}...
6119
6920
  `);
6120
- const installResult = spawnSync8(
6921
+ const installResult = spawnSync11(
6121
6922
  "npm",
6122
6923
  ["install", "-g", `${PKG_NAME}@${latest}`],
6123
6924
  { stdio: "inherit" }
@@ -6138,9 +6939,9 @@ through that tool instead \u2014 this command only uses 'npm install -g'.`
6138
6939
  }
6139
6940
 
6140
6941
  // src/commands/verify.ts
6141
- import { execFileSync as execFileSync2, spawnSync as spawnSync9 } from "child_process";
6942
+ import { execFileSync as execFileSync2, spawnSync as spawnSync12 } from "child_process";
6142
6943
  function loadConfigAtSha(sha, repoRoot) {
6143
- const result = spawnSync9(
6944
+ const result = spawnSync12(
6144
6945
  "git",
6145
6946
  ["show", `${sha}:.stamp/config.yml`],
6146
6947
  { cwd: repoRoot, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }
@@ -6629,7 +7430,7 @@ serverRepo.command("restore <name>").description("restore the most recent soft-d
6629
7430
  }
6630
7431
  );
6631
7432
  program.command("review").description(
6632
- "run configured reviewer(s) against a diff. Reviewer config + prompts are sourced from the merge-base tree (security: prevents feature-branch self-review). For lock-file drift checks, use `stamp reviewers verify` (which exits 3 on drift). Reviewer execution budgets are env-tunable: STAMP_REVIEWER_MAX_TURNS (default 8) caps the model/tool round-trip count, STAMP_REVIEWER_TIMEOUT_MS (default 300000) bounds wall-clock time. Raise them when a reviewer with heavy lookup tools (Linear / GitHub MCP, multi-file Read) fails with subtype=error_max_turns or the abort message \u2014 see docs/troubleshooting.md."
7433
+ "run configured reviewer(s) against a diff. Reviewer config + prompts are sourced from the merge-base tree (security: prevents feature-branch self-review). For lock-file drift checks, use `stamp reviewers verify` (which exits 3 on drift). Reviewer execution budgets resolve narrowest-wins: `.stamp/config.yml` per-reviewer fields (`reviewers.<name>.max_turns` / `timeout_ms`, committed, sourced from the merge-base tree), then env overrides (`STAMP_REVIEWER_MAX_TURNS` default 8, `STAMP_REVIEWER_TIMEOUT_MS` default 300000), then defaults. On failure a structured turn trace is written to `.git/stamp/failed-runs/` \u2014 see docs/troubleshooting.md."
6633
7434
  ).requiredOption("--diff <revspec>", "git revspec to review, e.g. main..HEAD").option("--only <reviewer>", "run a single reviewer by name").option(
6634
7435
  "--allow-large",
6635
7436
  "bypass the 200KB diff size cap (raise STAMP_REVIEW_DIFF_CAP_BYTES for a different threshold)"
@@ -6739,6 +7540,88 @@ function wrap(fn) {
6739
7540
  process.exit(1);
6740
7541
  }
6741
7542
  }
7543
+ var invites = program.command("invites").description("mint and accept single-use invite tokens for teammate onboarding");
7544
+ invites.command("mint <short-name>").description("mint an invite for a teammate (15-min TTL, admin/owner only)").option("--role <admin|member>", "role to grant on accept", "member").action((shortName, opts) => {
7545
+ try {
7546
+ if (opts.role !== "admin" && opts.role !== "member") {
7547
+ throw new Error(
7548
+ `--role must be 'admin' or 'member' (got ${JSON.stringify(opts.role)})`
7549
+ );
7550
+ }
7551
+ runInvitesMint({ shortName, role: opts.role });
7552
+ } catch (err) {
7553
+ handleCliError(err);
7554
+ }
7555
+ });
7556
+ invites.command("accept <share-url-or-token>").description("redeem an invite token; auto-detects local keys, prompts to confirm").option("--server <host:port>", "server endpoint when passing a bare token (no URL)").option("--ssh-pubkey <path>", "override SSH pubkey path (default ~/.ssh/id_ed25519.pub)").option("--stamp-pubkey <path>", "override stamp signing pubkey path (default ~/.stamp/keys/ed25519.pub)").option("--short-name <name>", "override the short_name (default derived from user@host)").option("--yes", "skip the confirmation prompt (required for non-TTY stdin)").action(
7557
+ async (urlOrToken, opts) => {
7558
+ try {
7559
+ await runInvitesAccept({
7560
+ urlOrToken,
7561
+ server: opts.server,
7562
+ sshPubkeyPath: opts.sshPubkey,
7563
+ stampPubkeyPath: opts.stampPubkey,
7564
+ shortName: opts.shortName,
7565
+ yes: opts.yes
7566
+ });
7567
+ } catch (err) {
7568
+ handleCliError(err);
7569
+ }
7570
+ }
7571
+ );
7572
+ var users = program.command("users").description("list, promote, demote, and remove enrolled users on the stamp server");
7573
+ users.command("list").description("list enrolled users (everyone authenticated may run this)").option("--json", "emit the raw server JSON instead of the formatted table").action((opts) => {
7574
+ try {
7575
+ runUsersList({ json: opts.json });
7576
+ } catch (err) {
7577
+ handleCliError(err);
7578
+ }
7579
+ });
7580
+ users.command("promote <short-name>").description("promote a user; admins may not promote to admin/owner except for the no-owners bootstrap path").requiredOption("--to <admin|owner>", "target role").action((shortName, opts) => {
7581
+ try {
7582
+ if (opts.to !== "admin" && opts.to !== "owner") {
7583
+ throw new Error(
7584
+ `promote --to must be 'admin' or 'owner' (got ${JSON.stringify(opts.to)})`
7585
+ );
7586
+ }
7587
+ runUsersPromote({ shortName, to: opts.to });
7588
+ } catch (err) {
7589
+ handleCliError(err);
7590
+ }
7591
+ });
7592
+ users.command("demote <short-name>").description("demote a user (admin/owner only; last-owner guard prevents zeroing out ownership)").requiredOption("--to <admin|member>", "target role").action((shortName, opts) => {
7593
+ try {
7594
+ if (opts.to !== "admin" && opts.to !== "member") {
7595
+ throw new Error(
7596
+ `demote --to must be 'admin' or 'member' (got ${JSON.stringify(opts.to)})`
7597
+ );
7598
+ }
7599
+ runUsersDemote({ shortName, to: opts.to });
7600
+ } catch (err) {
7601
+ handleCliError(err);
7602
+ }
7603
+ });
7604
+ users.command("remove <short-name>").description("remove a user from the membership DB (admins may remove members only; cannot remove self)").action((shortName) => {
7605
+ try {
7606
+ runUsersRemove({ shortName });
7607
+ } catch (err) {
7608
+ handleCliError(err);
7609
+ }
7610
+ });
7611
+ var trust = program.command("trust").description("manage per-repo stamp signing trust (committed under .stamp/trusted-keys/)");
7612
+ trust.command("grant <short-name>").description("stage a trust-grant for an enrolled user on a new branch; review + merge through the usual gate").option("--repo <path>", "repo root to operate on (default: cwd)").option("--force-dirty", "stage the grant even if the working tree has uncommitted changes").action(
7613
+ (shortName, opts) => {
7614
+ try {
7615
+ runTrustGrant({
7616
+ shortName,
7617
+ repoPath: opts.repo,
7618
+ forceDirty: opts.forceDirty
7619
+ });
7620
+ } catch (err) {
7621
+ handleCliError(err);
7622
+ }
7623
+ }
7624
+ );
6742
7625
  var reviewers = program.command("reviewers").description("manage reviewer prompts");
6743
7626
  reviewers.command("list").description("list configured reviewers and their prompt file status").action(() => wrap(() => reviewersList()));
6744
7627
  reviewers.command("add <name>").description("scaffold a new reviewer: create prompt file, register in config, open in editor").option("--no-edit", "skip opening $EDITOR after scaffolding").action(