@openthink/stamp 1.2.0 → 1.3.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/README.md +27 -0
- package/dist/hooks/post-receive.cjs +91 -9
- package/dist/hooks/post-receive.cjs.map +1 -1
- package/dist/index.js +1166 -442
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -292,6 +292,33 @@ models pinned, each operator records their own verdict in their own
|
|
|
292
292
|
state.db (same as today's reviewer-prompt model). Stamp does not assume
|
|
293
293
|
verdicts are model-portable.
|
|
294
294
|
|
|
295
|
+
### Reviewer execution budgets
|
|
296
|
+
|
|
297
|
+
Each reviewer subprocess runs under two bounds, set on the operator's
|
|
298
|
+
machine via env vars (they are operator infrastructure, not committed
|
|
299
|
+
policy — different operators on the same repo can pick different values):
|
|
300
|
+
|
|
301
|
+
| Env var | Default | What it caps |
|
|
302
|
+
|---|---|---|
|
|
303
|
+
| `STAMP_REVIEWER_MAX_TURNS` | `8` | Model/tool round-trips per reviewer call. Hitting it surfaces as `reviewer "<name>" run failed (subtype=error_max_turns)`. |
|
|
304
|
+
| `STAMP_REVIEWER_TIMEOUT_MS` | `300000` (5 min) | Wall-clock budget per reviewer. Hitting it surfaces as `reviewer "<name>" exceeded <N>ms wall-clock budget — raise STAMP_REVIEWER_TIMEOUT_MS to extend it`. |
|
|
305
|
+
|
|
306
|
+
The defaults are tight enough that a pathological reviewer gives up in
|
|
307
|
+
single-digit minutes rather than racking up Anthropic spend silently.
|
|
308
|
+
Raise them when a reviewer with legitimately heavy lookup tools (Linear
|
|
309
|
+
MCP, multi-file `Read`, ticket reconciliation) repeatedly trips the cap
|
|
310
|
+
on a non-trivial diff. Example:
|
|
311
|
+
|
|
312
|
+
```sh
|
|
313
|
+
STAMP_REVIEWER_MAX_TURNS=20 STAMP_REVIEWER_TIMEOUT_MS=600000 \
|
|
314
|
+
stamp review --diff main..HEAD
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
If a reviewer trips the cap consistently on small diffs too, the prompt
|
|
318
|
+
is probably looping rather than working — diagnose before raising the
|
|
319
|
+
budget. See [`docs/troubleshooting.md`](./docs/troubleshooting.md) for
|
|
320
|
+
the runbook.
|
|
321
|
+
|
|
295
322
|
## Deployment shapes
|
|
296
323
|
|
|
297
324
|
Three ways to run stamp-cli in a real setting, trading setup cost for
|
|
@@ -7497,6 +7497,58 @@ function buildMirrorPushInvocation(githubRepo, newSha, refname, token, parentEnv
|
|
|
7497
7497
|
};
|
|
7498
7498
|
return { args, env };
|
|
7499
7499
|
}
|
|
7500
|
+
function buildMirrorPushInvocationSsh(githubRepo, newSha, refname, parentEnv = process.env, sshKeyPath) {
|
|
7501
|
+
const remoteUrl = `git@github.com:${githubRepo}.git`;
|
|
7502
|
+
const args = ["push", remoteUrl, `${newSha}:${refname}`];
|
|
7503
|
+
const env = { ...parentEnv };
|
|
7504
|
+
if (sshKeyPath) {
|
|
7505
|
+
env["GIT_SSH_COMMAND"] = `ssh -F /dev/null -i ${sshKeyPath} -o IdentitiesOnly=yes -o UserKnownHostsFile=/etc/ssh/ssh_known_hosts -o StrictHostKeyChecking=yes`;
|
|
7506
|
+
}
|
|
7507
|
+
return { args, env };
|
|
7508
|
+
}
|
|
7509
|
+
|
|
7510
|
+
// src/lib/perRepoKey.ts
|
|
7511
|
+
var VALID_OWNER = /^[A-Za-z0-9-]+$/;
|
|
7512
|
+
var VALID_REPO = /^[A-Za-z0-9._-]+$/;
|
|
7513
|
+
var SSH_CLIENT_KEY_DIR = "/srv/git/.ssh-client-keys";
|
|
7514
|
+
function computePerRepoKeyPath(githubRepo) {
|
|
7515
|
+
if (typeof githubRepo !== "string" || githubRepo.length === 0) {
|
|
7516
|
+
throw new Error("computePerRepoKeyPath: githubRepo must be a non-empty string");
|
|
7517
|
+
}
|
|
7518
|
+
if (githubRepo.startsWith("-")) {
|
|
7519
|
+
throw new Error(
|
|
7520
|
+
`computePerRepoKeyPath: githubRepo must not start with '-': ${githubRepo}`
|
|
7521
|
+
);
|
|
7522
|
+
}
|
|
7523
|
+
if (githubRepo.includes("..")) {
|
|
7524
|
+
throw new Error(
|
|
7525
|
+
`computePerRepoKeyPath: githubRepo must not contain '..': ${githubRepo}`
|
|
7526
|
+
);
|
|
7527
|
+
}
|
|
7528
|
+
const slashCount = (githubRepo.match(/\//g) ?? []).length;
|
|
7529
|
+
if (slashCount !== 1) {
|
|
7530
|
+
throw new Error(
|
|
7531
|
+
`computePerRepoKeyPath: githubRepo must be exactly <owner>/<repo>: ${githubRepo}`
|
|
7532
|
+
);
|
|
7533
|
+
}
|
|
7534
|
+
const [owner, repo] = githubRepo.split("/");
|
|
7535
|
+
if (!owner || !repo) {
|
|
7536
|
+
throw new Error(
|
|
7537
|
+
`computePerRepoKeyPath: owner and repo halves must both be non-empty: ${githubRepo}`
|
|
7538
|
+
);
|
|
7539
|
+
}
|
|
7540
|
+
if (!VALID_OWNER.test(owner)) {
|
|
7541
|
+
throw new Error(
|
|
7542
|
+
`computePerRepoKeyPath: owner must match [A-Za-z0-9-]+ (got "${owner}" in "${githubRepo}")`
|
|
7543
|
+
);
|
|
7544
|
+
}
|
|
7545
|
+
if (!VALID_REPO.test(repo)) {
|
|
7546
|
+
throw new Error(
|
|
7547
|
+
`computePerRepoKeyPath: repo must match [A-Za-z0-9._-]+ (got "${repo}" in "${githubRepo}")`
|
|
7548
|
+
);
|
|
7549
|
+
}
|
|
7550
|
+
return `${SSH_CLIENT_KEY_DIR}/${owner}_${repo}_ed25519`;
|
|
7551
|
+
}
|
|
7500
7552
|
|
|
7501
7553
|
// src/lib/refPatterns.ts
|
|
7502
7554
|
function globToRegex(pattern) {
|
|
@@ -7598,20 +7650,41 @@ function readMirrorConfigFromHeadBranch() {
|
|
|
7598
7650
|
return readMirrorConfig(sha);
|
|
7599
7651
|
}
|
|
7600
7652
|
var ZERO_SHA = "0000000000000000000000000000000000000000";
|
|
7653
|
+
var SSH_DEPLOY_KEY_PATH = "/srv/git/.ssh-client-keys/github_ed25519";
|
|
7601
7654
|
async function mirrorRef(label, refname, oldSha, newSha, githubRepo) {
|
|
7602
7655
|
const token = process.env["GITHUB_BOT_TOKEN"];
|
|
7603
|
-
|
|
7656
|
+
let perRepoKeyPath;
|
|
7657
|
+
try {
|
|
7658
|
+
perRepoKeyPath = computePerRepoKeyPath(githubRepo);
|
|
7659
|
+
} catch (err) {
|
|
7604
7660
|
warn(
|
|
7605
|
-
`mirror:
|
|
7661
|
+
`mirror: skipping per-repo SSH path \u2014 could not derive key path for ${githubRepo}: ${err instanceof Error ? err.message : String(err)}`
|
|
7662
|
+
);
|
|
7663
|
+
perRepoKeyPath = null;
|
|
7664
|
+
}
|
|
7665
|
+
const sshKeyPath = perRepoKeyPath && (0, import_node_fs3.existsSync)(perRepoKeyPath) ? perRepoKeyPath : (0, import_node_fs3.existsSync)(SSH_DEPLOY_KEY_PATH) ? null : void 0;
|
|
7666
|
+
const useSsh = sshKeyPath !== void 0;
|
|
7667
|
+
if (!useSsh && !token) {
|
|
7668
|
+
warn(
|
|
7669
|
+
`mirror: GITHUB_BOT_TOKEN not set in environment and no deploy key on disk (checked per-repo path ${perRepoKeyPath ?? "<derivation failed>"} and legacy ${SSH_DEPLOY_KEY_PATH}); skipping mirror of ${label} \u2192 ${githubRepo}`
|
|
7606
7670
|
);
|
|
7607
7671
|
return;
|
|
7608
7672
|
}
|
|
7609
|
-
|
|
7673
|
+
if (useSsh && !token) {
|
|
7674
|
+
warn(
|
|
7675
|
+
`mirror: GITHUB_BOT_TOKEN not set; SSH push to ${githubRepo} will proceed but stamp/verified statuses will be skipped`
|
|
7676
|
+
);
|
|
7677
|
+
}
|
|
7678
|
+
const { args, env } = useSsh ? buildMirrorPushInvocationSsh(
|
|
7610
7679
|
githubRepo,
|
|
7611
7680
|
newSha,
|
|
7612
7681
|
refname,
|
|
7613
|
-
|
|
7614
|
-
|
|
7682
|
+
process.env,
|
|
7683
|
+
// sshKeyPath is null for legacy (no override) or a string for
|
|
7684
|
+
// per-repo (override via GIT_SSH_COMMAND). The builder ignores
|
|
7685
|
+
// null/undefined and emits no override; passes through strings.
|
|
7686
|
+
sshKeyPath ?? void 0
|
|
7687
|
+
) : buildMirrorPushInvocation(githubRepo, newSha, refname, token);
|
|
7615
7688
|
const result = (0, import_node_child_process.spawnSync)("git", args, {
|
|
7616
7689
|
encoding: "utf8",
|
|
7617
7690
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -7621,16 +7694,25 @@ async function mirrorRef(label, refname, oldSha, newSha, githubRepo) {
|
|
|
7621
7694
|
info(
|
|
7622
7695
|
`mirror: pushed ${label} (${newSha.slice(0, 8)}) \u2192 github.com/${githubRepo}`
|
|
7623
7696
|
);
|
|
7624
|
-
|
|
7697
|
+
if (token) {
|
|
7698
|
+
await postStatuses(label, refname, oldSha, newSha, githubRepo, token);
|
|
7699
|
+
}
|
|
7625
7700
|
} else {
|
|
7626
7701
|
const errOut = scrubTokenUrls((result.stderr ?? "").trim());
|
|
7627
7702
|
warn(
|
|
7628
7703
|
`mirror: push of ${label} to github.com/${githubRepo} failed (exit ${result.status})`
|
|
7629
7704
|
);
|
|
7630
7705
|
if (errOut) warn(`mirror: ${errOut.replace(/\n/g, "\nmirror: ")}`);
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7706
|
+
if (useSsh) {
|
|
7707
|
+
const keyHint = sshKeyPath !== null && sshKeyPath !== void 0 ? `GIT_SSH_COMMAND="ssh -F /dev/null -i ${sshKeyPath} -o IdentitiesOnly=yes -o UserKnownHostsFile=/etc/ssh/ssh_known_hosts -o StrictHostKeyChecking=yes" ` : "";
|
|
7708
|
+
warn(
|
|
7709
|
+
`mirror: stamp-server push already accepted; mirror out-of-sync. Retry manually from a host with the deploy key: ${keyHint}git push git@github.com:${githubRepo}.git ${newSha}:${refname}`
|
|
7710
|
+
);
|
|
7711
|
+
} else {
|
|
7712
|
+
warn(
|
|
7713
|
+
`mirror: stamp-server push already accepted; mirror out-of-sync. Retry manually with the bot token in env: GITHUB_BOT_TOKEN=<pat> GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: Basic $(printf '%s' "x-access-token:$GITHUB_BOT_TOKEN" | base64 | tr -d '\\n')" git push https://github.com/${githubRepo}.git ${refname}`
|
|
7714
|
+
);
|
|
7715
|
+
}
|
|
7634
7716
|
}
|
|
7635
7717
|
}
|
|
7636
7718
|
async function postStatuses(label, refname, oldSha, newSha, githubRepo, token) {
|