@openthink/stamp 1.3.1 → 1.4.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)) {
@@ -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;
@@ -5091,11 +5186,12 @@ function runPrune(opts) {
5091
5186
  );
5092
5187
  const repoRoot = findRepoRoot();
5093
5188
  const dbPath = stampStateDbPath(repoRoot);
5094
- const spoolDir = join8(gitCommonDir(repoRoot), "stamp", "failed-parses");
5189
+ const parsesDir = join8(gitCommonDir(repoRoot), "stamp", "failed-parses");
5190
+ const runsDir = join8(gitCommonDir(repoRoot), "stamp", "failed-runs");
5095
5191
  const spoolCutoffMs = Date.now() - durationMs;
5096
- if (!existsSync14(dbPath) && !existsSync14(spoolDir)) {
5192
+ if (!existsSync14(dbPath) && !existsSync14(parsesDir) && !existsSync14(runsDir)) {
5097
5193
  console.log(
5098
- `note: nothing to prune (neither ${dbPath} nor ${spoolDir} exists \u2014 both are created on first \`stamp review\`)`
5194
+ `note: nothing to prune (none of ${dbPath}, ${parsesDir}, ${runsDir} exist \u2014 all are created on first \`stamp review\`)`
5099
5195
  );
5100
5196
  return;
5101
5197
  }
@@ -5113,14 +5209,19 @@ function runPrune(opts) {
5113
5209
  any2 = true;
5114
5210
  }
5115
5211
  }
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;
5212
+ for (const [label, dir] of [
5213
+ ["failed-parse", parsesDir],
5214
+ ["failed-run", runsDir]
5215
+ ]) {
5216
+ const peek = peekSpools(dir, spoolCutoffMs);
5217
+ if (peek.length > 0) {
5218
+ if (any2) console.log("");
5219
+ console.log(
5220
+ `would prune ${peek.length} ${label} spool file${peek.length === 1 ? "" : "s"} older than ${humanLabel}:`
5221
+ );
5222
+ for (const f of peek) console.log(` ${f}`);
5223
+ any2 = true;
5224
+ }
5124
5225
  }
5125
5226
  if (!any2) {
5126
5227
  console.log(`note: nothing to prune (no rows or spools older than ${humanLabel})`);
@@ -5143,13 +5244,18 @@ function runPrune(opts) {
5143
5244
  any = true;
5144
5245
  }
5145
5246
  }
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;
5247
+ for (const [label, dir] of [
5248
+ ["failed-parse", parsesDir],
5249
+ ["failed-run", runsDir]
5250
+ ]) {
5251
+ const deleted = pruneSpools(dir, spoolCutoffMs);
5252
+ if (deleted > 0) {
5253
+ if (any) console.log("");
5254
+ console.log(
5255
+ `${deleted} ${label} spool file${deleted === 1 ? "" : "s"} pruned`
5256
+ );
5257
+ any = true;
5258
+ }
5153
5259
  }
5154
5260
  if (!any) {
5155
5261
  console.log(`note: nothing to prune (no rows or spools older than ${humanLabel})`);
@@ -5158,7 +5264,7 @@ function runPrune(opts) {
5158
5264
  db?.close();
5159
5265
  }
5160
5266
  }
5161
- function peekFailedParseSpools(spoolDir, cutoffMs) {
5267
+ function peekSpools(spoolDir, cutoffMs) {
5162
5268
  if (!existsSync14(spoolDir)) return [];
5163
5269
  const out = [];
5164
5270
  for (const entry of readdirSync3(spoolDir)) {
@@ -5174,8 +5280,8 @@ function peekFailedParseSpools(spoolDir, cutoffMs) {
5174
5280
  }
5175
5281
  return out.sort();
5176
5282
  }
5177
- function pruneFailedParseSpools(spoolDir, cutoffMs) {
5178
- const targets = peekFailedParseSpools(spoolDir, cutoffMs);
5283
+ function pruneSpools(spoolDir, cutoffMs) {
5284
+ const targets = peekSpools(spoolDir, cutoffMs);
5179
5285
  let deleted = 0;
5180
5286
  for (const filepath of targets) {
5181
5287
  try {
@@ -6629,7 +6735,7 @@ serverRepo.command("restore <name>").description("restore the most recent soft-d
6629
6735
  }
6630
6736
  );
6631
6737
  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."
6738
+ "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
6739
  ).requiredOption("--diff <revspec>", "git revspec to review, e.g. main..HEAD").option("--only <reviewer>", "run a single reviewer by name").option(
6634
6740
  "--allow-large",
6635
6741
  "bypass the 200KB diff size cap (raise STAMP_REVIEW_DIFF_CAP_BYTES for a different threshold)"