@jphutchins/code-review 0.1.0-alpha.10 → 0.1.0-alpha.12

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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from 'citty';
3
3
  import { readFileSync, writeFileSync, readdirSync } from 'fs';
4
- import { resolve as resolve$1, join, dirname } from 'path';
4
+ import { resolve as resolve$1, join, dirname, basename } from 'path';
5
5
  import { Eta } from 'eta';
6
6
  import parseDiff from 'parse-diff';
7
7
  import { Ajv2020 } from 'ajv/dist/2020.js';
@@ -463,8 +463,9 @@ var sumTranscriptUsage = (entries) => {
463
463
  };
464
464
  };
465
465
  var subagentFiles = (mainPath) => {
466
+ const dir = join(dirname(mainPath), basename(mainPath, ".jsonl"), "subagents");
466
467
  try {
467
- return readdirSync(join(dirname(mainPath), "subagents")).filter((name) => name.endsWith(".jsonl")).map((name) => join(dirname(mainPath), "subagents", name));
468
+ return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
468
469
  } catch {
469
470
  return [];
470
471
  }
@@ -659,7 +660,7 @@ var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
659
660
  reason: [
660
661
  `This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
661
662
  `The only deliverable is a ${kind} document that validates against the ${kind} schema \u2014 run "code-review print-schema ${kind}" to see the exact shape.`,
662
- `Write it to ${draftPath}, then run "code-review validate ${draftPath} --kind ${kind}" until it exits 0 before ending your turn.`
663
+ `Write it to ${draftPath}, then run "code-review validate ${draftPath} --kind ${kind} --explain" until it exits 0 before ending your turn (--explain prints the schema when the shape is wrong).`
663
664
  ].join("\n")
664
665
  };
665
666
  };
@@ -722,19 +723,27 @@ var stopHookSettings = (command) => ({
722
723
  });
723
724
 
724
725
  // src/budget.ts
725
- var DEFAULT_RESERVE = { frac: 0.15, flatUsd: 0.02, flatMs: 12e4 };
726
+ var DEADLINE_ENV = "CODE_REVIEW_DEADLINE_EPOCH";
727
+ var DEFAULT_RESERVE = {
728
+ frac: 0.15,
729
+ growth: 0.25,
730
+ flatUsd: 0.02,
731
+ flatMs: 12e4
732
+ };
726
733
  var SOFT_MULTIPLE = 2;
727
734
  var costAxis = (i) => i.spentUsd !== null && i.budgetUsd !== null && i.budgetUsd > 0 ? { used: i.spentUsd, limit: i.budgetUsd, flat: i.reserve.flatUsd } : null;
728
735
  var timeAxis = (i) => i.elapsedMs !== null && i.wallMs !== null && i.wallMs > 0 ? { used: i.elapsedMs, limit: i.wallMs, flat: i.reserve.flatMs } : null;
729
- var axisSeverity = (a, frac) => {
730
- const hardReserve = Math.max(a.flat, frac * a.limit);
736
+ var axisSeverity = (a, reserve) => {
737
+ const usedFrac = Math.min(1, Math.max(0, a.used / a.limit));
738
+ const effFrac = reserve.frac + reserve.growth * usedFrac;
739
+ const hardReserve = Math.max(a.flat, effFrac * a.limit);
731
740
  const remaining = a.limit - a.used;
732
741
  if (remaining <= hardReserve) return 2;
733
742
  if (remaining <= SOFT_MULTIPLE * hardReserve) return 1;
734
743
  return 0;
735
744
  };
736
745
  var decideBudget = (i) => {
737
- const worst = [costAxis(i), timeAxis(i)].filter((a) => a !== null).reduce((max, a) => Math.max(max, axisSeverity(a, i.reserve.frac)), 0);
746
+ const worst = [costAxis(i), timeAxis(i)].filter((a) => a !== null).reduce((max, a) => Math.max(max, axisSeverity(a, i.reserve)), 0);
738
747
  return worst === 2 ? { kind: "hard" } : worst === 1 ? { kind: "soft" } : { kind: "ok" };
739
748
  };
740
749
  var pct = (n) => `${String(Math.round(n * 100))}%`;
@@ -742,7 +751,7 @@ var money = (n) => `$${n.toFixed(2)}`;
742
751
  var mins = (ms) => `${(ms / 6e4).toFixed(1)}m`;
743
752
  var spendClause = (i) => i.spentUsd === null ? null : i.budgetUsd !== null && i.budgetUsd > 0 ? `spent ${money(i.spentUsd)}/${money(i.budgetUsd)} (${pct(i.spentUsd / i.budgetUsd)})` : `spent ${money(i.spentUsd)}`;
744
753
  var timeClause = (i) => i.elapsedMs === null ? null : i.wallMs !== null && i.wallMs > 0 ? `${mins(i.elapsedMs)}/${mins(i.wallMs)} elapsed (${pct(i.elapsedMs / i.wallMs)})` : `${mins(i.elapsedMs)} elapsed`;
745
- var directive = (phase, draftPath) => phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now. Write your COMPLETE findings to ${draftPath} and run \`code-review validate ${draftPath}\` until it passes. Other tools are blocked until that draft is written.` : `Wind down investigation and write your COMPLETE findings to ${draftPath} now, then validate \u2014 you may run out of budget before you finish otherwise.`;
754
+ var directive = (phase, draftPath) => phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now. Write your COMPLETE findings to ${draftPath} and run \`code-review validate ${draftPath} --explain\` until it passes (--explain prints the exact schema when the shape is wrong). Other tools are blocked until that draft is written.` : `Wind down investigation and write your COMPLETE findings to ${draftPath} now, then run \`code-review validate ${draftPath} --explain\` (it prints the exact schema if the shape is wrong) \u2014 you may run out of budget before you finish otherwise.`;
746
755
  var budgetMessage = (i, phase, draftPath) => {
747
756
  const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
748
757
  return `Budget check \u2014 ${status}. ${directive(phase, draftPath)}`;
@@ -810,6 +819,20 @@ var parseWallMs = (raw) => {
810
819
  return n * 36e5;
811
820
  }
812
821
  };
822
+ var parseEpochSecMs = (raw) => {
823
+ if (raw === void 0) return null;
824
+ const t4 = raw.trim();
825
+ if (!/^\d+$/.test(t4)) return null;
826
+ const n = Number.parseInt(t4, 10);
827
+ return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
828
+ };
829
+ var anchoredElapsedMs = (src) => {
830
+ if (src.deadlineMs !== null && src.wallMs !== null)
831
+ return Math.max(0, src.wallMs - (src.deadlineMs - src.nowMs));
832
+ if (src.firstTsMs !== null) return Math.max(0, src.nowMs - src.firstTsMs);
833
+ return null;
834
+ };
835
+ var deadlineEpochSec = (wallMs, nowMs) => Math.floor(nowMs / 1e3) + Math.ceil(wallMs / 1e3);
813
836
  var parseFraction = (raw, fallback) => {
814
837
  if (raw === void 0) return fallback;
815
838
  const n = Number.parseFloat(raw);
@@ -822,6 +845,7 @@ var budgetHookCommand = (draftPath, opts) => [
822
845
  ...opts.wall ? ["--wall", shellQuote(opts.wall)] : [],
823
846
  ...opts.prices ? ["--prices", shellQuote(opts.prices)] : [],
824
847
  ...opts.reserveFrac ? ["--reserve-frac", shellQuote(opts.reserveFrac)] : [],
848
+ ...opts.reserveGrowth ? ["--reserve-growth", shellQuote(opts.reserveGrowth)] : [],
825
849
  ...opts.reserveUsd ? ["--reserve-usd", shellQuote(opts.reserveUsd)] : [],
826
850
  ...opts.reserveWall ? ["--reserve-wall", shellQuote(opts.reserveWall)] : []
827
851
  ].join(" ");
@@ -1362,7 +1386,7 @@ var post = async (input, ghApi = runGhApi) => {
1362
1386
  `
1363
1387
  );
1364
1388
  }
1365
- const botReviews = comments.length > 0 ? await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi) : [];
1389
+ const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
1366
1390
  const alreadyReviewedThisSha = botReviews.some((r) => r.commitId === input.headSha);
1367
1391
  const initialDisposition = comments.length > 0 ? alreadyReviewedThisSha ? { kind: "suppressed-existing-review", sha: input.headSha } : void 0 : strays.length > 0 ? { kind: "none-in-diff" } : void 0;
1368
1392
  const commonRenderInput = {
@@ -1396,10 +1420,9 @@ var post = async (input, ghApi = runGhApi) => {
1396
1420
  renderBody(initialDisposition),
1397
1421
  ghApi
1398
1422
  );
1399
- if (comments.length === 0) return;
1400
1423
  if (alreadyReviewedThisSha) {
1401
1424
  process.stderr.write(
1402
- `A completed bot review already exists for ${input.headSha} \u2014 updated sticky only, no new inline review
1425
+ `A completed bot review already exists for ${input.headSha} \u2014 updated sticky only, no new review
1403
1426
  `
1404
1427
  );
1405
1428
  return;
@@ -1418,11 +1441,11 @@ var post = async (input, ghApi = runGhApi) => {
1418
1441
  ghApi
1419
1442
  );
1420
1443
  process.stderr.write(
1421
- `Posted ${String(comments.length)} inline comments on PR #${String(prNumber)}
1444
+ `Posted a review with ${String(comments.length)} inline comment(s) on PR #${String(prNumber)}
1422
1445
  `
1423
1446
  );
1424
1447
  await minimizeSupersededComments(input.repo, prNumber, input.headSha, input.botLogin, ghApi);
1425
- if (stickyRef !== null) {
1448
+ if (comments.length > 0 && stickyRef !== null) {
1426
1449
  const confirmedDisposition = {
1427
1450
  kind: "posted",
1428
1451
  count: comments.length,
@@ -1776,16 +1799,34 @@ var withMeta = (base, meta) => ({
1776
1799
  ...meta.route ? { route: meta.route } : {},
1777
1800
  ...meta.effort ? { effort: meta.effort } : {}
1778
1801
  });
1779
- var nativeTelemetry = (native, meta) => withMeta(
1802
+ var resolveTelemetry = (native, meta) => {
1803
+ const fb = native.models.length === 0 ? meta.transcriptFallback?.() : void 0;
1804
+ const useFallback = fb !== void 0 && fb.models.length > 0;
1805
+ return withMeta(
1806
+ useFallback ? {
1807
+ models: [...fb.models],
1808
+ turns: fb.turns,
1809
+ duration_ms: fb.durationMs,
1810
+ vendor_cost_usd: native.vendorCostUsd
1811
+ } : {
1812
+ models: native.models,
1813
+ turns: native.turns,
1814
+ duration_ms: native.durationMs,
1815
+ vendor_cost_usd: native.vendorCostUsd
1816
+ },
1817
+ meta
1818
+ );
1819
+ };
1820
+ var nativeTelemetry = (native, meta) => resolveTelemetry(
1780
1821
  {
1781
1822
  models: mapModelUsage(native.modelUsage),
1782
1823
  turns: native.num_turns,
1783
- duration_ms: native.duration_ms,
1784
- vendor_cost_usd: native.total_cost_usd ?? null
1824
+ durationMs: native.duration_ms,
1825
+ vendorCostUsd: native.total_cost_usd ?? null
1785
1826
  },
1786
1827
  meta
1787
1828
  );
1788
- var absentTelemetry = (meta) => withMeta({ models: [], turns: 0, duration_ms: 0, vendor_cost_usd: null }, meta);
1829
+ var absentTelemetry = (meta) => resolveTelemetry({ models: [], turns: 0, durationMs: 0, vendorCostUsd: null }, meta);
1789
1830
  var buildEnvelope = (telemetry, native, agentFilePath) => {
1790
1831
  const outcome = findingsOutcome(native, agentFilePath);
1791
1832
  switch (outcome.kind) {
@@ -1908,6 +1949,16 @@ var unwrapAdapt = (either) => {
1908
1949
  }
1909
1950
  throw new Error("unreachable");
1910
1951
  };
1952
+ var transcriptFallbackFrom = (path) => {
1953
+ const tree = readTranscriptTree(resolve$1(path));
1954
+ if (tree.missing)
1955
+ process.stderr.write(
1956
+ `code-review adapt: transcript ${path} is unreadable \u2014 no telemetry fallback (issue #36)
1957
+ `
1958
+ );
1959
+ const usage = sumTranscriptUsage(tree.entries);
1960
+ return { models: usage.models, turns: usage.turns, durationMs: usage.durationMs };
1961
+ };
1911
1962
  var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
1912
1963
  var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
1913
1964
  var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
@@ -2128,7 +2179,11 @@ var budgetHookCmd = defineCommand({
2128
2179
  },
2129
2180
  "reserve-frac": {
2130
2181
  type: "string",
2131
- description: "Wind-down headroom as a fraction of each budget: converge once less than this remains (default: 0.15; the soft steer tier reserves 2\xD7 this)"
2182
+ description: "Base wind-down headroom as a fraction of each budget: converge once less than this remains (default: 0.15; the soft steer tier reserves 2\xD7 this)"
2183
+ },
2184
+ "reserve-growth": {
2185
+ type: "string",
2186
+ description: "How much the reserve grows as a budget is spent \u2014 added at full usage, so convergence lands earlier the longer the run has gone (default: 0.25; 0 = flat reserve)"
2132
2187
  },
2133
2188
  "reserve-usd": {
2134
2189
  type: "string",
@@ -2148,13 +2203,20 @@ var budgetHookCmd = defineCommand({
2148
2203
  const usage = tree ? sumTranscriptUsage(tree.entries) : void 0;
2149
2204
  const prices = args.prices ? tryReadPrices(args.prices) : null;
2150
2205
  const spentUsd = prices !== null && usage ? computeCost(usage.models, prices).totalCostUSD : null;
2206
+ const wallMs = args.wall ? parseWallMs(args.wall) : null;
2151
2207
  const output = evaluateBudgetHook(input, {
2152
2208
  spentUsd,
2153
2209
  budgetUsd: parseBudgetUsd(args["budget-usd"]),
2154
- elapsedMs: usage?.firstTsMs != null ? Math.max(0, Date.now() - usage.firstTsMs) : null,
2155
- wallMs: args.wall ? parseWallMs(args.wall) : null,
2210
+ elapsedMs: anchoredElapsedMs({
2211
+ deadlineMs: parseEpochSecMs(process.env[DEADLINE_ENV]),
2212
+ wallMs,
2213
+ firstTsMs: usage?.firstTsMs ?? null,
2214
+ nowMs: Date.now()
2215
+ }),
2216
+ wallMs,
2156
2217
  reserve: {
2157
2218
  frac: parseFraction(args["reserve-frac"], DEFAULT_RESERVE.frac),
2219
+ growth: parseFraction(args["reserve-growth"], DEFAULT_RESERVE.growth),
2158
2220
  flatUsd: parseBudgetUsd(args["reserve-usd"]) ?? DEFAULT_RESERVE.flatUsd,
2159
2221
  flatMs: args["reserve-wall"] ? parseWallMs(args["reserve-wall"]) ?? DEFAULT_RESERVE.flatMs : DEFAULT_RESERVE.flatMs
2160
2222
  },
@@ -2216,7 +2278,11 @@ var printSettingsCmd = defineCommand({
2216
2278
  },
2217
2279
  "reserve-frac": {
2218
2280
  type: "string",
2219
- description: "Wind-down headroom as a fraction of each budget (default: 0.15; soft tier is 2\xD7)"
2281
+ description: "Base wind-down headroom as a fraction of each budget (default: 0.15; soft tier is 2\xD7)"
2282
+ },
2283
+ "reserve-growth": {
2284
+ type: "string",
2285
+ description: "How much the reserve grows as a budget is spent, converging earlier the longer the run has gone (default: 0.25; 0 = flat)"
2220
2286
  },
2221
2287
  "reserve-usd": {
2222
2288
  type: "string",
@@ -2244,6 +2310,7 @@ var printSettingsCmd = defineCommand({
2244
2310
  wall: args.wall,
2245
2311
  prices: args.prices,
2246
2312
  reserveFrac: args["reserve-frac"],
2313
+ reserveGrowth: args["reserve-growth"],
2247
2314
  reserveUsd: args["reserve-usd"],
2248
2315
  reserveWall: args["reserve-wall"]
2249
2316
  }
@@ -2252,7 +2319,36 @@ var printSettingsCmd = defineCommand({
2252
2319
  `);
2253
2320
  }
2254
2321
  });
2322
+ var deadlineCmd = defineCommand({
2323
+ meta: {
2324
+ name: "deadline",
2325
+ description: "Print the run's absolute deadline as Unix epoch seconds (now + --wall). The review job exports this as CODE_REVIEW_DEADLINE_EPOCH right before `claude -p` so every budget hook \u2014 the main agent's and each fan-out subagent's \u2014 measures the SAME true remaining wall instead of its own transcript's start, which reads \u22480 in a fresh subagent and leaves the fan-out unsteered (issue #45)."
2326
+ },
2327
+ args: {
2328
+ wall: {
2329
+ type: "string",
2330
+ description: "Wall-clock budget for the run (e.g. 24m, 1200s, 2h) \u2014 the deadline is now + this",
2331
+ required: true
2332
+ }
2333
+ },
2334
+ run: async ({ args }) => {
2335
+ const wallMs = parseWallMs(args.wall);
2336
+ if (wallMs === null) {
2337
+ fail(`--wall must be a duration like 24m, 1200s, or 2h (got '${args.wall}')`);
2338
+ } else {
2339
+ process.stdout.write(`${String(deadlineEpochSec(wallMs, Date.now()))}
2340
+ `);
2341
+ }
2342
+ }
2343
+ });
2255
2344
  var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
2345
+ var printableSchema = (schemaPath) => {
2346
+ const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
2347
+ const enforcementSchema = Object.fromEntries(
2348
+ Object.entries(schema).filter(([key2]) => key2 !== "$schema")
2349
+ );
2350
+ return JSON.stringify(enforcementSchema, null, 2);
2351
+ };
2256
2352
  var validateCmd = defineCommand({
2257
2353
  meta: {
2258
2354
  name: "validate",
@@ -2275,6 +2371,10 @@ var validateCmd = defineCommand({
2275
2371
  "schema-version": {
2276
2372
  type: "string",
2277
2373
  description: "Schema major.minor version to validate against (default: the document's declared schema_version for findings, or the kind's latest)"
2374
+ },
2375
+ explain: {
2376
+ type: "boolean",
2377
+ description: "On failure, also print the schema after the errors \u2014 its field descriptions are the authoritative spec, so the document can be fixed in one pass instead of by trial and error"
2278
2378
  }
2279
2379
  },
2280
2380
  run: async ({ args }) => {
@@ -2288,6 +2388,14 @@ var validateCmd = defineCommand({
2288
2388
  process.stderr.write("\u274C invalid\n");
2289
2389
  for (const e of errors) process.stderr.write(` - ${e}
2290
2390
  `);
2391
+ if (args.explain) {
2392
+ process.stderr.write(
2393
+ `
2394
+ The ${kind} document must conform to this schema (the field descriptions are the authoritative spec \u2014 match the property names exactly):
2395
+ ${printableSchema(schemaPath)}
2396
+ `
2397
+ );
2398
+ }
2291
2399
  process.exit(1);
2292
2400
  }
2293
2401
  }
@@ -2319,13 +2427,20 @@ var adaptCmd = defineCommand({
2319
2427
  effort: {
2320
2428
  type: "string",
2321
2429
  description: 'Effort label to stamp into the envelope (e.g. "max" or "low")'
2430
+ },
2431
+ transcript: {
2432
+ type: "string",
2433
+ description: "Path to the session transcript (the main .jsonl); when the native envelope carries no per-model usage \u2014 a wall-clock kill leaves it empty (issue #39) \u2014 telemetry is recovered from the transcript tree (main + subagents) so cost is real, not $0.00 (issue #36)"
2322
2434
  }
2323
2435
  },
2324
2436
  run: async ({ args }) => {
2325
2437
  const envelope = unwrapAdapt(
2326
2438
  adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), args["agent-file"], {
2327
2439
  route: args.route,
2328
- effort: args.effort
2440
+ effort: args.effort,
2441
+ ...args.transcript ? {
2442
+ transcriptFallback: () => transcriptFallbackFrom(args.transcript)
2443
+ } : {}
2329
2444
  })
2330
2445
  );
2331
2446
  process.stdout.write(`${JSON.stringify(envelope, null, 2)}
@@ -2493,11 +2608,7 @@ var printSchemaCmd = defineCommand({
2493
2608
  run: async ({ args }) => {
2494
2609
  const schemaKind = requireSchemaKind(args.name);
2495
2610
  const schemaPath = requireSchemaPath(schemaKind, args["schema-version"]);
2496
- const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
2497
- const enforcementSchema = Object.fromEntries(
2498
- Object.entries(schema).filter(([key2]) => key2 !== "$schema")
2499
- );
2500
- process.stdout.write(`${JSON.stringify(enforcementSchema, null, 2)}
2611
+ process.stdout.write(`${printableSchema(schemaPath)}
2501
2612
  `);
2502
2613
  }
2503
2614
  });
@@ -2733,7 +2844,7 @@ var main = defineCommand({
2733
2844
  meta: {
2734
2845
  name: "code-review",
2735
2846
  version: packageVersion,
2736
- description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, validate-patches, cost, check-cost, validate, stop-gate, budget-hook, and print-settings"
2847
+ description: "Deterministic commenter for agentic PR review \u2014 gather, render, inline, post, adapt, extract, validate-patches, cost, check-cost, validate, stop-gate, budget-hook, print-settings, and deadline"
2737
2848
  },
2738
2849
  subCommands: {
2739
2850
  gather: gatherCmd,
@@ -2749,7 +2860,8 @@ var main = defineCommand({
2749
2860
  "print-schema": printSchemaCmd,
2750
2861
  "stop-gate": stopGateCmd,
2751
2862
  "budget-hook": budgetHookCmd,
2752
- "print-settings": printSettingsCmd
2863
+ "print-settings": printSettingsCmd,
2864
+ deadline: deadlineCmd
2753
2865
  }
2754
2866
  });
2755
2867
  if (!process.env["VITEST"]) {