@jphutchins/code-review 0.1.0-alpha.39 → 0.1.0-alpha.40

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,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from 'citty';
3
- import { readFileSync, writeFileSync, statSync, copyFileSync, readdirSync } from 'fs';
3
+ import { readFileSync, writeFileSync, copyFileSync, statSync, readdirSync } from 'fs';
4
4
  import { randomBytes } from 'crypto';
5
5
  import { resolve as resolve$1, join, dirname, basename, extname } from 'path';
6
6
  import { Eta } from 'eta';
@@ -37,6 +37,15 @@ var SchemaVersion = t.refinement(
37
37
  (s) => SCHEMA_VERSION_RE.test(s),
38
38
  "SchemaVersion"
39
39
  );
40
+ var UriString = t.refinement(
41
+ t.string,
42
+ (s) => !/\s/.test(s) && URL.canParse(s),
43
+ "UriString"
44
+ );
45
+ var FindingRuleCodec = t.partial({
46
+ code: t.string,
47
+ code_url: UriString
48
+ });
40
49
  var FindingShape = t.intersection([
41
50
  t.type({
42
51
  path: t.string,
@@ -48,10 +57,9 @@ var FindingShape = t.intersection([
48
57
  reasoning: t.string,
49
58
  confidence: Confidence
50
59
  }),
60
+ FindingRuleCodec,
51
61
  t.partial({
52
62
  side: SideCodec,
53
- code: t.string,
54
- code_url: t.string,
55
63
  recommendation: t.string,
56
64
  patch: t.string
57
65
  })
@@ -62,13 +70,41 @@ var EndGeStart = t.refinement(
62
70
  "EndGeStart"
63
71
  );
64
72
  var FindingCodec = t.exact(EndGeStart);
73
+ var SystemicRequired = t.type({
74
+ title: t.string,
75
+ description: t.string,
76
+ severity: SeverityCodec,
77
+ reasoning: t.string,
78
+ confidence: Confidence
79
+ });
80
+ var SystemicOptional = t.partial({
81
+ finding_codes: t.array(t.string),
82
+ paths: t.array(t.string)
83
+ });
84
+ var SystemicProblemShape = t.intersection([SystemicRequired, FindingRuleCodec, SystemicOptional]);
85
+ var SYSTEMIC_KEYS = /* @__PURE__ */ new Set([
86
+ ...Object.keys(SystemicRequired.props),
87
+ ...Object.keys(FindingRuleCodec.props),
88
+ ...Object.keys(SystemicOptional.props)
89
+ ]);
90
+ var SystemicProblemStrict = t.refinement(
91
+ SystemicProblemShape,
92
+ (s) => Object.keys(s).every((k) => SYSTEMIC_KEYS.has(k)),
93
+ "SystemicProblemStrict"
94
+ );
95
+ var SystemicProblemCodec = t.exact(SystemicProblemStrict);
65
96
  var FindingsCodec = t.exact(
66
- t.type({
67
- schema_version: SchemaVersion,
68
- summary: t.string,
69
- verdict: VerdictCodec,
70
- findings: t.array(FindingCodec)
71
- })
97
+ t.intersection([
98
+ t.type({
99
+ schema_version: SchemaVersion,
100
+ summary: t.string,
101
+ verdict: VerdictCodec,
102
+ findings: t.array(FindingCodec)
103
+ }),
104
+ t.partial({
105
+ systemic_problems: t.array(SystemicProblemCodec)
106
+ })
107
+ ])
72
108
  );
73
109
  var TriageCodec = t.type({
74
110
  safe: t.boolean,
@@ -134,13 +170,7 @@ var TestSummaryCodec = t.intersection([
134
170
  failures: t.array(TestFailureCodec)
135
171
  })
136
172
  ]);
137
- var DEFAULT_SCHEMA_VERSION = "0.5.0";
138
- var emptyFindings = (summary) => ({
139
- schema_version: DEFAULT_SCHEMA_VERSION,
140
- summary,
141
- verdict: "comment",
142
- findings: []
143
- });
173
+ var DEFAULT_SCHEMA_VERSION = "0.6.0";
144
174
  var incompleteFindings = (summary) => ({
145
175
  schema_version: DEFAULT_SCHEMA_VERSION,
146
176
  summary,
@@ -308,8 +338,9 @@ var severityEmoji = (s) => {
308
338
  return "\u2753";
309
339
  }
310
340
  };
311
- var EMBED_LIMIT = 4e4;
341
+ var EMBED_LIMIT = 40200;
312
342
  var AGENTS_STOP_DIRECTIVE = "<!-- AGENTS: STOP \u2014 do not parse the prose below; decode this findings JSON and read schema_version first. -->";
343
+ var b64LengthOf = (document) => Buffer.from(JSON.stringify(document), "utf-8").toString("base64").length;
313
344
  var encodeMarker = (document, jsonUrl, limit) => {
314
345
  const b64 = Buffer.from(JSON.stringify(document), "utf-8").toString("base64");
315
346
  const marker = b64.length <= limit ? `<!-- code-review:findings-json;base64 ${b64} -->` : jsonUrl ? `<!-- code-review:findings-json ${jsonUrl} -->` : "";
@@ -324,12 +355,24 @@ var decodeBase64Json = (b64) => {
324
355
  }
325
356
  };
326
357
  var findingsPointer = (findings, jsonUrl, limit = EMBED_LIMIT) => encodeMarker(findings, jsonUrl, limit);
358
+ var findingsMarkerForm = (findings, jsonUrl, limit = EMBED_LIMIT) => {
359
+ if (b64LengthOf(findings) <= limit) return "embedded";
360
+ return jsonUrl ? "link" : "omitted";
361
+ };
327
362
  var findingPointer = (finding, schemaVersion, jsonUrl, limit = EMBED_LIMIT) => encodeMarker({ schema_version: schemaVersion, findings: [finding] }, jsonUrl, limit);
328
363
  var ZERO_SHA = "0000000000000000000000000000000000000000";
329
364
  var parseReviewedSha = (body) => {
330
365
  const sha = /<!-- reviewed-sha: ([0-9a-fA-F]{40}) -->/.exec(body)?.[1]?.toLowerCase();
331
366
  return sha && sha !== ZERO_SHA ? sha : null;
332
367
  };
368
+ var ROUTE_RE = /<!-- reviewed-route: ([^>]*) -->/;
369
+ var parseReviewedRoute = (body) => ROUTE_RE.exec(body)?.[1] || null;
370
+ var isFullReviewSticky = (body) => {
371
+ const route = parseReviewedRoute(body);
372
+ return route === "full review" || route !== "mechanic" && parseRounds(body).length > 0;
373
+ };
374
+ var COMPLETED_ANCESTOR_MARKER = "<!-- review-complete-ancestor -->";
375
+ var parseCompletedAncestor = (body) => body.includes(COMPLETED_ANCESTOR_MARKER);
333
376
  var REVIEW_COMPLETE_MARKER = "<!-- review-complete -->";
334
377
  var parseReviewComplete = (body) => body.includes(REVIEW_COMPLETE_MARKER);
335
378
  var parseFindingsMarker = (body) => {
@@ -343,38 +386,233 @@ var isSeverityCounts = (u) => typeof u === "object" && u !== null && SEVERITIES.
343
386
  const v = u[k];
344
387
  return typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
345
388
  });
389
+ var MAX_CODES_PER_ROUND = 8;
390
+ var hasCode = (codes, code) => codes !== void 0 && Object.prototype.hasOwnProperty.call(codes, code);
391
+ var escapeCodeBackticks = (code) => code.replace(/`/g, "-").replace(/\r?\n/g, " ");
392
+ var normalizeCodeCounts = (codes, priorCodes) => {
393
+ if (typeof codes !== "object" || codes === null || Array.isArray(codes)) return void 0;
394
+ const entries = Object.entries(codes).filter(
395
+ (e) => typeof e[1] === "number" && Number.isSafeInteger(e[1]) && e[1] > 0
396
+ ).sort((a, b) => {
397
+ if (b[1] !== a[1]) return b[1] - a[1];
398
+ const aPrior = hasCode(priorCodes, a[0]) ? 1 : 0;
399
+ const bPrior = hasCode(priorCodes, b[0]) ? 1 : 0;
400
+ if (aPrior !== bPrior) return bPrior - aPrior;
401
+ return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
402
+ });
403
+ if (entries.length === 0) return void 0;
404
+ const sorted = entries.sort((a, b) => {
405
+ if (b[1] !== a[1]) return b[1] - a[1];
406
+ const aPrior = hasCode(priorCodes, a[0]) ? 1 : 0;
407
+ const bPrior = hasCode(priorCodes, b[0]) ? 1 : 0;
408
+ if (aPrior !== bPrior) return bPrior - aPrior;
409
+ return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
410
+ });
411
+ const base = sorted.slice(0, MAX_CODES_PER_ROUND);
412
+ const priorKept = sorted.slice(MAX_CODES_PER_ROUND).filter(([code]) => hasCode(priorCodes, code)).slice(0, MAX_CODES_PER_ROUND);
413
+ return Object.fromEntries([...base, ...priorKept]);
414
+ };
346
415
  var parseRounds = (body) => {
347
416
  const b64 = ROUNDS_RE.exec(body)?.[1];
348
417
  if (b64 === void 0) return [];
349
418
  const decoded = decodeBase64Json(b64);
350
- return Array.isArray(decoded) ? decoded.filter(isSeverityCounts) : [];
419
+ if (!Array.isArray(decoded)) return [];
420
+ return decoded.filter(isSeverityCounts).map((u) => {
421
+ const rec = u;
422
+ const codes = normalizeCodeCounts(rec["codes"]);
423
+ const sha = rec["sha"];
424
+ const shaStr = typeof sha === "string" && sha !== "" ? sha : void 0;
425
+ const round = rec["round"];
426
+ const roundNum = typeof round === "number" && Number.isSafeInteger(round) && round >= 1 ? round : void 0;
427
+ const base = codes === void 0 ? { critical: u.critical, major: u.major, minor: u.minor, nit: u.nit } : { critical: u.critical, major: u.major, minor: u.minor, nit: u.nit, codes };
428
+ const kept = shaStr === void 0 ? base : { ...base, sha: shaStr };
429
+ return roundNum === void 0 ? kept : { ...kept, round: roundNum };
430
+ });
431
+ };
432
+ var ROUNDS_MARKER_LIMIT = 8e3;
433
+ var roundsMarker = (rounds) => {
434
+ if (rounds.length === 0) return "";
435
+ const serialize = (kept2) => `<!-- code-review:rounds;base64 ${Buffer.from(JSON.stringify(kept2), "utf-8").toString("base64")} -->`;
436
+ const stripCodes = (n) => rounds.map(
437
+ (r, i) => i < n ? { critical: r.critical, major: r.major, minor: r.minor, nit: r.nit } : r
438
+ );
439
+ let stripped = 0;
440
+ while (stripped < rounds.length && serialize(stripCodes(stripped)).length > ROUNDS_MARKER_LIMIT) {
441
+ stripped += 1;
442
+ }
443
+ const kept = stripCodes(stripped);
444
+ const bounded = serialize(kept).length > ROUNDS_MARKER_LIMIT ? kept.slice(-8) : kept;
445
+ return serialize(bounded);
351
446
  };
352
- var roundsMarker = (rounds) => rounds.length === 0 ? "" : `<!-- code-review:rounds;base64 ${Buffer.from(JSON.stringify(rounds), "utf-8").toString("base64")} -->`;
353
447
  var roundChip = (c) => {
354
448
  const parts = SEVERITIES.filter((k) => c[k] > 0).map((k) => `${severityEmoji(k)}${String(c[k])}`);
355
449
  return parts.length === 0 ? "clean" : parts.join(" ");
356
450
  };
357
451
  var TRAJECTORY_CHIPS = 8;
358
- var roundsSummary = (rounds) => {
359
- if (rounds.length === 0) return "";
452
+ var roundsSummary = (rounds, count = rounds.length) => {
453
+ if (count === 0) return "";
360
454
  const chips = rounds.slice(-TRAJECTORY_CHIPS).map(roundChip);
361
- const trajectory = rounds.length > TRAJECTORY_CHIPS ? `\u2026 \u2192 ${chips.join(" \u2192 ")}` : chips.join(" \u2192 ");
362
- return `**Round ${String(rounds.length)}** \xB7 ${trajectory}`;
455
+ const trajectory = chips.length === 0 ? "" : rounds.length > TRAJECTORY_CHIPS ? `\u2026 \u2192 ${chips.join(" \u2192 ")}` : chips.join(" \u2192 ");
456
+ return trajectory === "" ? `**Round ${String(count)}**` : `**Round ${String(count)}** \xB7 ${trajectory}`;
457
+ };
458
+ var computeCodeCounts = (findings, systemic = []) => {
459
+ const counts = /* @__PURE__ */ new Map();
460
+ for (const code of [
461
+ ...findings.map((f) => f.code),
462
+ ...systemic.flatMap((s) => [s.code, ...s.finding_codes ?? []])
463
+ ]) {
464
+ if (code === void 0 || code === "") continue;
465
+ counts.set(code, (counts.get(code) ?? 0) + 1);
466
+ }
467
+ return Object.fromEntries(counts);
468
+ };
469
+ var roundRecord = (counts, codes, priorCodes, sha, round) => {
470
+ const normalized = normalizeCodeCounts(codes, priorCodes);
471
+ const record3 = normalized === void 0 ? { ...counts } : { ...counts, codes: normalized };
472
+ return {
473
+ ...record3,
474
+ ...sha !== void 0 ? { sha } : {},
475
+ ...round !== void 0 ? { round } : {}
476
+ };
477
+ };
478
+ var consecutiveCodeStreaks = (rounds) => {
479
+ const entries = [];
480
+ if (rounds.length === 0) return {};
481
+ const lastCodes = rounds[rounds.length - 1]?.codes;
482
+ if (lastCodes === void 0) return {};
483
+ for (const code of Object.keys(lastCodes)) {
484
+ let streak = 0;
485
+ let startIndex = rounds.length;
486
+ for (let i = rounds.length - 1; i >= 0; i--) {
487
+ const codes = rounds[i]?.codes;
488
+ if (codes === void 0 || !hasCode(codes, code)) break;
489
+ if (i > 0 && rounds[i]?.sha !== void 0 && rounds[i]?.sha === rounds[i - 1]?.sha && hasCode(rounds[i - 1]?.codes, code)) {
490
+ continue;
491
+ }
492
+ streak += 1;
493
+ startIndex = i;
494
+ }
495
+ if (streak > 0) {
496
+ entries.push([code, { streak, startRound: rounds[startIndex]?.round ?? startIndex + 1 }]);
497
+ }
498
+ }
499
+ return Object.fromEntries(entries);
500
+ };
501
+ var DEFAULT_METASTASIS_STREAK = 3;
502
+ var metastasisNote = (rounds, minStreak = DEFAULT_METASTASIS_STREAK) => {
503
+ const flagged = Object.entries(consecutiveCodeStreaks(rounds)).filter(([, s]) => s.streak >= minStreak).sort((a, b) => b[1].streak - a[1].streak);
504
+ if (flagged.length === 0) return "";
505
+ const lines = flagged.map(
506
+ ([code, s]) => `> **\`${escapeCodeBackticks(code)}\`** \u2014 findings in ${String(s.streak)} consecutive rounds.`
507
+ );
508
+ return [
509
+ "> [!WARNING]",
510
+ "> **Scope metastasis** \u2014 findings keep recurring in the same mechanism across consecutive rounds; each fix keeps enabling the next finding in that machinery. Consider whether a structural fix (change the shape, not the edge case) or a scope narrowing would converge this faster.",
511
+ ...lines
512
+ ].join("\n");
513
+ };
514
+ var computeSameRootNotes = (priorRounds, findings, currentSha) => {
515
+ const codes = findings.map((f) => f.code).filter((c) => c !== void 0 && c !== "");
516
+ const entries = [];
517
+ for (const code of codes) {
518
+ let lastRound = 0;
519
+ for (let i = priorRounds.length - 1; i >= 0; i--) {
520
+ if (currentSha !== void 0 && priorRounds[i]?.sha === currentSha) continue;
521
+ if (i > 0 && priorRounds[i]?.sha !== void 0 && priorRounds[i]?.sha === priorRounds[i - 1]?.sha && hasCode(priorRounds[i - 1]?.codes, code))
522
+ continue;
523
+ const count = priorRounds[i]?.codes?.[code];
524
+ if (count !== void 0 && count > 0) {
525
+ lastRound = priorRounds[i]?.round ?? i + 1;
526
+ break;
527
+ }
528
+ }
529
+ if (lastRound > 0) {
530
+ entries.push([
531
+ code,
532
+ `Same mechanism as round ${String(lastRound)} (\`${escapeCodeBackticks(code)}\`) \u2014 the prior fix in this area re-opened it; consider a structural fix or a scope narrowing.`
533
+ ]);
534
+ }
535
+ }
536
+ return Object.fromEntries(entries);
363
537
  };
364
538
  var CONVERGENCE_WEIGHTS = { critical: 4, major: 2, minor: 1, nit: 0 };
365
539
  var DEFAULT_CONVERGENCE_THRESHOLD = 1;
366
540
  var convergenceScore = (counts) => SEVERITIES.reduce((sum, k) => sum + counts[k] * CONVERGENCE_WEIGHTS[k], 0);
367
541
  var convergenceSummary = (counts, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => {
542
+ const { score, converged } = convergenceSignal(counts, threshold);
543
+ return converged ? `**Convergence** \u{1F3C1} ${String(score)} \u2264 ${String(threshold)} \u2014 converged` : `**Convergence** \u{1F504} ${String(score)} > ${String(threshold)} \u2014 iterating`;
544
+ };
545
+ var SURFACE_SCHEMA_VERSION = "0.7.0";
546
+ var convergenceSignal = (counts, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => {
368
547
  const score = convergenceScore(counts);
369
- return score <= threshold ? `**Convergence** \u{1F3C1} ${String(score)} \u2264 ${String(threshold)} \u2014 converged` : `**Convergence** \u{1F504} ${String(score)} > ${String(threshold)} \u2014 iterating`;
548
+ return { score, threshold, converged: score <= threshold };
549
+ };
550
+ var signalForRound = (round, counts, threshold = DEFAULT_CONVERGENCE_THRESHOLD) => ({ round, convergence: convergenceSignal(counts, threshold) });
551
+ var surfaceFindings = (findings, signal) => {
552
+ const agentDoc = Object.fromEntries(
553
+ Object.entries(findings).filter(([key2]) => key2 !== "round" && key2 !== "convergence")
554
+ );
555
+ return {
556
+ ...agentDoc,
557
+ schema_version: SURFACE_SCHEMA_VERSION,
558
+ ...signal === null ? {} : signal
559
+ };
560
+ };
561
+ var signalMarker = (signal) => `<!-- code-review:signal;base64 ${Buffer.from(
562
+ JSON.stringify({ schema_version: SURFACE_SCHEMA_VERSION, ...signal }),
563
+ "utf-8"
564
+ ).toString("base64")} -->`;
565
+ var surfacedFindingsPointer = (findings, signal, jsonUrl) => {
566
+ const marker = findingsPointer(surfaceFindings(findings, signal), jsonUrl);
567
+ if (signal === null || marker.includes("<!-- code-review:findings-json;base64 ")) return marker;
568
+ return marker === "" ? signalMarker(signal) : `${marker}
569
+ ${signalMarker(signal)}`;
570
+ };
571
+ var SIGNAL_RE = /<!-- code-review:signal;base64 ([A-Za-z0-9+/=]+) -->/;
572
+ var parseSignalMarker = (body) => {
573
+ const b64 = SIGNAL_RE.exec(body)?.[1];
574
+ if (b64 === void 0) return null;
575
+ return parseSurfaceSignal(decodeBase64Json(b64));
576
+ };
577
+ var parseSurfaceSignal = (doc) => {
578
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return null;
579
+ const o = doc;
580
+ const declared = o["schema_version"];
581
+ if (typeof declared !== "string" || !SURFACE_SCHEMA_VERSIONS.includes(declared)) return null;
582
+ const round = o["round"];
583
+ const convergence = o["convergence"];
584
+ if (typeof round !== "number" || !Number.isSafeInteger(round) || round < 1) return null;
585
+ if (typeof convergence !== "object" || convergence === null) return null;
586
+ const c = convergence;
587
+ if (typeof c["score"] !== "number" || !Number.isFinite(c["score"]) || typeof c["threshold"] !== "number" || !Number.isFinite(c["threshold"]) || typeof c["converged"] !== "boolean") {
588
+ return null;
589
+ }
590
+ return {
591
+ round,
592
+ convergence: { score: c["score"], threshold: c["threshold"], converged: c["converged"] }
593
+ };
594
+ };
595
+ var SURFACE_SCHEMA_VERSIONS = [SURFACE_SCHEMA_VERSION];
596
+ var stripSurfaceFields = (doc) => {
597
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return doc;
598
+ const o = doc;
599
+ const declared = o["schema_version"];
600
+ if (typeof declared !== "string" || !SURFACE_SCHEMA_VERSIONS.includes(declared)) return doc;
601
+ const rest = Object.fromEntries(
602
+ Object.entries(o).filter(([key2]) => key2 !== "convergence" && key2 !== "round")
603
+ );
604
+ return { ...rest, schema_version: DEFAULT_SCHEMA_VERSION };
370
605
  };
371
606
  var carryForwardMarkers = (body) => {
372
607
  const findings = /<!-- code-review:findings-json[^>]*-->/.exec(body)?.[0];
373
608
  const reviewedSha = /<!-- reviewed-sha: [0-9a-fA-F]{40} -->/.exec(body)?.[0];
609
+ const reviewedRoute = ROUTE_RE.exec(body)?.[0];
374
610
  const rounds = ROUNDS_RE.exec(body)?.[0];
611
+ const signal = SIGNAL_RE.exec(body)?.[0];
612
+ const completedAncestor = parseReviewComplete(body) || parseCompletedAncestor(body) ? COMPLETED_ANCESTOR_MARKER : void 0;
375
613
  const findingsBlock = findings ? `${AGENTS_STOP_DIRECTIVE}
376
614
  ${findings}` : void 0;
377
- return [findingsBlock, reviewedSha, rounds].filter((m) => m !== void 0).join("\n\n");
615
+ return [findingsBlock, reviewedSha, reviewedRoute, completedAncestor, rounds, signal].filter((m) => m !== void 0).join("\n\n");
378
616
  };
379
617
  var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
380
618
  var projectPatch = (patch) => {
@@ -393,13 +631,18 @@ ${linkLine}` : linkLine;
393
631
 
394
632
  // src/render.ts
395
633
  var escapePipes = (text) => text.replace(/\|/g, "\\|");
396
- var escapeCodeBackticks = (text) => text.replace(/`/g, "-");
397
634
  var sanitizeFinding = (f) => ({
398
635
  ...f,
399
636
  title: escapePipes(f.title),
400
637
  path: escapeCodeBackticks(f.path),
401
638
  patchProjection: projectPatch(f.patch)
402
639
  });
640
+ var sanitizeSystemic = (s) => ({
641
+ ...s,
642
+ title: escapePipes(s.title),
643
+ ...s.paths !== void 0 ? { paths: s.paths.map(escapeCodeBackticks) } : {},
644
+ ...s.finding_codes !== void 0 ? { finding_codes: s.finding_codes.map(escapeCodeBackticks) } : {}
645
+ });
403
646
  var emptySeverityCounts = () => ({
404
647
  critical: 0,
405
648
  major: 0,
@@ -410,6 +653,11 @@ var computeSeverityCounts = (findings) => findings.reduce(
410
653
  (acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
411
654
  emptySeverityCounts()
412
655
  );
656
+ var isReviewVerdict = (verdict) => verdict !== "error";
657
+ var computeRoundCounts = (findings) => (findings.systemic_problems ?? []).reduce(
658
+ (acc, s) => s.severity in acc ? { ...acc, [s.severity]: acc[s.severity] + 1 } : acc,
659
+ computeSeverityCounts(findings.findings)
660
+ );
413
661
  var isConvergenceRound = (route, incomplete) => route === "full review" && !incomplete;
414
662
  var render = (input) => {
415
663
  const eta = new Eta({ autoTrim: false });
@@ -423,7 +671,10 @@ var render = (input) => {
423
671
  const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
424
672
  const severityCounts = input.severityCounts ?? computeSeverityCounts(input.findings.findings);
425
673
  const rounds = input.rounds ?? [];
426
- const isFullReviewRound = (input.convergenceRound ?? isConvergenceRound(route, incomplete)) && input.findings.verdict !== "error";
674
+ const sameRootNotes = input.sameRootNotes ?? computeSameRootNotes(rounds.slice(0, -1), input.findings.findings);
675
+ const isFullReviewRound = (input.convergenceRound ?? (isConvergenceRound(route, incomplete) && rounds.length > 0)) && isReviewVerdict(input.findings.verdict);
676
+ const convergenceCounts = rounds[rounds.length - 1] ?? computeRoundCounts(input.findings);
677
+ const advisoryAllowed = isFullReviewRound;
427
678
  return eta.renderString(input.template, {
428
679
  findings: input.findings,
429
680
  envelope: input.envelope,
@@ -439,15 +690,27 @@ var render = (input) => {
439
690
  reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
440
691
  postedAt: input.postedAt ?? "",
441
692
  severityCounts,
442
- convergenceSummary: isFullReviewRound ? convergenceSummary(severityCounts, input.convergenceThreshold) : "",
693
+ convergenceSummary: isFullReviewRound ? convergenceSummary(convergenceCounts, input.convergenceThreshold) : "",
443
694
  strays: (input.strays ?? []).map(sanitizeFinding),
695
+ systemic: (input.findings.systemic_problems ?? []).map(sanitizeSystemic),
444
696
  unanchoredCount: input.unanchoredCount ?? 0,
445
697
  inlineDisposition: input.inlineDisposition ?? null,
446
698
  runUrl: input.runUrl ?? null,
447
699
  jsonUrl: input.jsonUrl ?? null,
448
- findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
700
+ findingsPointer: input.findingsPointer ?? surfacedFindingsPointer(
701
+ input.findings,
702
+ // The fallback embeds a signal exactly when the badge renders — never beside a suppressed
703
+ // badge, and from the same counts the badge reads. It assumes a post-style history (the
704
+ // caller appends this run's counts last), numbering the round exactly as the trajectory
705
+ // label does; post always supplies the marker, so this path cannot disagree with it in
706
+ // production (issue #141 review r4).
707
+ isFullReviewRound && rounds.length > 0 ? signalForRound(rounds.length, convergenceCounts, input.convergenceThreshold) : null,
708
+ input.jsonUrl
709
+ ),
449
710
  roundsMarker: roundsMarker(rounds),
450
- roundsSummary: roundsSummary(rounds),
711
+ roundsSummary: roundsSummary(rounds, input.roundCount),
712
+ metastasisNote: advisoryAllowed ? metastasisNote(rounds) : "",
713
+ sameRootNotes: advisoryAllowed ? sameRootNotes : {},
451
714
  reviewUrl: input.reviewUrl ?? null,
452
715
  formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
453
716
  // N/A (never a false $0.00) when no real price map was provided — real tokens, no rates to price them.
@@ -530,7 +793,7 @@ var partitionFindings = (findings, index) => {
530
793
 
531
794
  // src/inline.ts
532
795
  var formatModels = (models) => models.length > 0 ? models.map((m) => `\`${m}\``).join("/") : "an AI model";
533
- var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer) => (
796
+ var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer, sameRootNote) => (
534
797
  // Eta.renderString returns string | Promise<string>; with autoTrim:false it's always sync.
535
798
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
536
799
  eta.renderString(template, {
@@ -540,7 +803,8 @@ var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer) => (
540
803
  formatConfidence,
541
804
  modelsText,
542
805
  jsonUrl: jsonUrl ?? null,
543
- findingsPointer: pointer
806
+ findingsPointer: pointer,
807
+ sameRootNote
544
808
  })
545
809
  );
546
810
  var buildInlineComments = (findings, diff, context) => {
@@ -551,11 +815,12 @@ var buildInlineComments = (findings, diff, context) => {
551
815
  const modelsText = formatModels(models);
552
816
  const comments = inDiff.map((f) => {
553
817
  const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
818
+ const sameRootNote = f.code !== void 0 && f.code !== "" && context.sameRootNotes !== void 0 && Object.prototype.hasOwnProperty.call(context.sameRootNotes, f.code) ? context.sameRootNotes[f.code] ?? "" : "";
554
819
  const comment = {
555
820
  path: f.path,
556
821
  line: f.end_line,
557
822
  side: defaultSide(f.side),
558
- body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer)
823
+ body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer, sameRootNote)
559
824
  };
560
825
  if (f.start_line < f.end_line) {
561
826
  return {
@@ -901,13 +1166,20 @@ var writesToDraft = (toolName, toolInput, draftPath) => {
901
1166
  return false;
902
1167
  };
903
1168
  var singleWriterMessage = (draftPath) => `Only the main agent may write ${draftPath}. When a subagent writes it too, the concurrent writers clobber each other and the review comes out empty. Do NOT write, edit, or redirect into ${draftPath} \u2014 instead, return the findings you discovered in your reply (the field names are in the schema); the main agent collects every subagent's reported findings and writes the draft itself.`;
904
- var seedMarkerPath = (draftPath) => `${draftPath}.seed`;
905
- var lastValidPath = (draftPath) => {
1169
+ var SEED_SENTINEL = "code-review seed sentinel \u2014 not a review; replace this file with your findings review.";
1170
+ var normalizedDraft = (draftText) => typeof draftText === "string" ? draftText.replace(/^\uFEFF/, "").trim() : "";
1171
+ var isSeedSentinel = (draftText) => normalizedDraft(draftText) === SEED_SENTINEL;
1172
+ var mainHasWrittenDraft = (draftText) => {
1173
+ const normalized = normalizedDraft(draftText);
1174
+ return normalized !== "" && normalized !== SEED_SENTINEL;
1175
+ };
1176
+ var sidecarPath = (draftPath, postfix) => {
906
1177
  const ext = extname(draftPath);
907
- return join(dirname(draftPath), `${basename(draftPath, ext)}.last-valid${ext}`);
1178
+ return join(dirname(draftPath), `${basename(draftPath, ext)}${postfix}${ext}`);
908
1179
  };
909
- var mainHasWrittenDraft = (draftMtimeMs, seedMarkerMtimeMs) => draftMtimeMs !== null && (seedMarkerMtimeMs === null || draftMtimeMs > seedMarkerMtimeMs);
910
- var spawnFloorMessage = (draftPath) => `Write your own first-pass findings to ${draftPath} before spawning subagents \u2014 a review must never depend on subagents alone, and a pre-seeded draft does not count until you have revised it yourself this run. Write ${draftPath} from what you have read so far (preliminary findings are fine), run \`code-review validate ${draftPath} --explain\` until it passes, then fan out; your subagents run in the background, so keep refining the draft as their reports arrive.`;
1180
+ var priorContextPath = (draftPath) => sidecarPath(draftPath, ".prior");
1181
+ var lastValidPath = (draftPath) => sidecarPath(draftPath, ".last-valid");
1182
+ var spawnFloorMessage = (draftPath) => `Write your own first-pass findings to ${draftPath} before spawning subagents \u2014 a review must never depend on subagents alone, and the pre-seeded $DRAFT is a non-review sentinel: it does not count until you have replaced it with your own review. Write ${draftPath} from what you have read so far (preliminary findings are fine), run \`code-review validate ${draftPath} --explain\` until it passes, then fan out; your subagents run in the background, so keep refining the draft as their reports arrive.`;
911
1183
  var forceBackgroundSpawn = (toolInput) => ({
912
1184
  hookSpecificOutput: {
913
1185
  hookEventName: "PreToolUse",
@@ -953,7 +1225,7 @@ var evaluateBudgetHook = (input, params) => {
953
1225
  if (phase.kind === "hard" && blockedDuringConvergence(toolName, rec["tool_input"]))
954
1226
  return denyPreTool(budgetMessage(inputs, phase, params.draftPath, isSubagent));
955
1227
  if (SPAWN_TOOLS.has(toolName)) {
956
- if (!isSubagent && !params.mainDraftWritten)
1228
+ if (!isSubagent && !params.mainDraftWritten())
957
1229
  return denyPreTool(spawnFloorMessage(params.draftPath));
958
1230
  return forceBackgroundSpawn(rec["tool_input"]);
959
1231
  }
@@ -1110,6 +1382,14 @@ var findingsTable = [
1110
1382
  },
1111
1383
  {
1112
1384
  minor: "0.5",
1385
+ defaultVersion: "0.5.0",
1386
+ schemaFile: "findings.schema.json",
1387
+ codec: FindingsCodec,
1388
+ normalize: identity,
1389
+ latest: false
1390
+ },
1391
+ {
1392
+ minor: "0.6",
1113
1393
  defaultVersion: DEFAULT_SCHEMA_VERSION,
1114
1394
  schemaFile: "findings.schema.json",
1115
1395
  codec: FindingsCodec,
@@ -1152,9 +1432,9 @@ var formatErrors = (errors) => errors.map(describeValidationError);
1152
1432
  var declaredVersion = (raw) => typeof raw === "object" && raw !== null && "schema_version" in raw ? typeof raw.schema_version === "string" ? raw.schema_version : void 0 : void 0;
1153
1433
  var supportedVersions = (kind) => tableFor(kind).map((entry) => entry.minor);
1154
1434
  var defaultVersion = (kind) => {
1155
- const latest2 = tableFor(kind).find((entry) => entry.latest);
1156
- if (!latest2) throw new Error(`Registry invariant violated \u2014 no latest entry for "${kind}"`);
1157
- return latest2.defaultVersion;
1435
+ const latest = tableFor(kind).find((entry) => entry.latest);
1436
+ if (!latest) throw new Error(`Registry invariant violated \u2014 no latest entry for "${kind}"`);
1437
+ return latest.defaultVersion;
1158
1438
  };
1159
1439
  var bundledSchemaPath = (relativePath) => resolve$1(import.meta.dirname, "..", "schema", relativePath);
1160
1440
  var schemaPathFor = (kind, version) => {
@@ -1282,8 +1562,111 @@ var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
1282
1562
  "Accept: application/vnd.github.v3.diff"
1283
1563
  ]);
1284
1564
 
1565
+ // src/checkrun.ts
1566
+ var CHECK_RUN_NAME = "Code review";
1567
+ var settled = /* @__PURE__ */ new Set(["success", "neutral", "skipped"]);
1568
+ var runIdFromUrl = (runUrl) => {
1569
+ const m = /\/actions\/runs\/(\d+)\/?$/.exec(runUrl);
1570
+ return m?.[1] ?? null;
1571
+ };
1572
+ var ownedCheck = (checks, runUrl) => {
1573
+ const runId = runIdFromUrl(runUrl);
1574
+ if (runId === null) return null;
1575
+ return checks.find((c) => c.detailsUrl !== null && c.detailsUrl.endsWith(`/runs/${runId}`)) ?? null;
1576
+ };
1577
+ var decideCheckAction = (checks, intent, runUrl) => {
1578
+ const owned = ownedCheck(checks, runUrl);
1579
+ switch (intent) {
1580
+ case "in_progress":
1581
+ return owned !== null ? { kind: "noop", reason: "this run's check is already in progress" } : { kind: "create", status: "in_progress" };
1582
+ case "neutral":
1583
+ if (owned === null) return { kind: "create", status: "completed", conclusion: "neutral" };
1584
+ return owned.status === "completed" && owned.conclusion === "neutral" ? { kind: "noop", reason: "the check already records this completed review" } : { kind: "patch", id: owned.id, status: "completed", conclusion: "neutral" };
1585
+ case "failure":
1586
+ if (owned === null) return { kind: "create", status: "completed", conclusion: "failure" };
1587
+ if (owned.status === "completed" && owned.conclusion !== null && settled.has(owned.conclusion))
1588
+ return { kind: "noop", reason: "a completed review already recorded this head" };
1589
+ return owned.status === "completed" && owned.conclusion === "failure" ? { kind: "noop", reason: "the check already records this failure" } : { kind: "patch", id: owned.id, status: "completed", conclusion: "failure" };
1590
+ case "cancelled":
1591
+ return { kind: "noop", reason: "cancelled is handled via decideCancelledAction" };
1592
+ }
1593
+ };
1594
+ var decideCancelledAction = (checks, runUrl) => {
1595
+ const owned = ownedCheck(checks, runUrl);
1596
+ if (owned === null) return { kind: "noop", reason: "no check was created by this run to settle" };
1597
+ if (owned.status === "completed" && owned.conclusion === "cancelled")
1598
+ return { kind: "noop", reason: "the check already records this cancelled run" };
1599
+ if (owned.status === "completed" && owned.conclusion !== null && settled.has(owned.conclusion))
1600
+ return { kind: "noop", reason: "a completed review already recorded this head" };
1601
+ return { kind: "patch", id: owned.id, status: "completed", conclusion: "cancelled" };
1602
+ };
1603
+ var CHECK_JQ = ".check_runs[] | {id: .id, status: .status, conclusion: .conclusion, detailsUrl: .details_url}";
1604
+ var fetchChecks = async (repo, headSha, ghApi) => parseJsonl(
1605
+ await ghApi([
1606
+ `repos/${repo}/commits/${headSha}/check-runs?check_name=${encodeURIComponent(CHECK_RUN_NAME)}&per_page=100`,
1607
+ "--paginate",
1608
+ "--jq",
1609
+ CHECK_JQ
1610
+ ])
1611
+ );
1612
+ var output = (intent, runUrl) => {
1613
+ switch (intent) {
1614
+ case "in_progress":
1615
+ return {
1616
+ title: "Code review in progress",
1617
+ summary: `The review is running \u2014 [see the run](${runUrl}).`
1618
+ };
1619
+ case "neutral":
1620
+ return {
1621
+ title: "Code review complete",
1622
+ summary: `The review was posted \u2014 [see the run](${runUrl}).`
1623
+ };
1624
+ case "failure":
1625
+ return {
1626
+ title: "Code review did not complete",
1627
+ summary: `The review job failed \u2014 [see the run](${runUrl}). Re-request the review; do not treat this round as spent.`
1628
+ };
1629
+ case "cancelled":
1630
+ return {
1631
+ title: "Code review superseded",
1632
+ summary: `This review run was cancelled \u2014 [see the run](${runUrl}). No action needed.`
1633
+ };
1634
+ }
1635
+ };
1636
+ var checkRun = async (input, ghApi = runGhApi) => {
1637
+ const checks = await fetchChecks(input.repo, input.headSha, ghApi);
1638
+ const action = input.intent === "cancelled" ? decideCancelledAction(checks, input.runUrl) : decideCheckAction(checks, input.intent, input.runUrl);
1639
+ if (action.kind === "noop") {
1640
+ process.stderr.write(`code-review check-run: ${action.reason} \u2014 leaving it
1641
+ `);
1642
+ return;
1643
+ }
1644
+ const body = action.kind === "create" ? {
1645
+ name: CHECK_RUN_NAME,
1646
+ head_sha: input.headSha,
1647
+ status: action.status,
1648
+ details_url: input.runUrl,
1649
+ ...action.conclusion ? { conclusion: action.conclusion } : {},
1650
+ output: output(input.intent, input.runUrl)
1651
+ } : {
1652
+ status: action.status,
1653
+ conclusion: action.conclusion,
1654
+ details_url: input.runUrl,
1655
+ output: output(input.intent, input.runUrl)
1656
+ };
1657
+ const endpoint = action.kind === "create" ? [`--method`, `POST`, `repos/${input.repo}/check-runs`, `--input`, `-`] : [
1658
+ `--method`,
1659
+ `PATCH`,
1660
+ `repos/${input.repo}/check-runs/${String(action.id)}`,
1661
+ `--input`,
1662
+ `-`
1663
+ ];
1664
+ await ghApi(endpoint, JSON.stringify(body));
1665
+ };
1666
+
1285
1667
  // src/post.ts
1286
1668
  var DEFAULT_MARKER = "<!-- code-review -->";
1669
+ var EMPTY_MECHANIC_LEAVE_MESSAGE = "The CI-fix pass found no issues and the sticky already reflects a completed full review \u2014 leaving it in place\n";
1287
1670
  var MAX_SUGGESTION_LINES = 10;
1288
1671
  var countSuggestionLines = (text) => text.split("\n").length;
1289
1672
  var checkLongSuggestions = (comments) => {
@@ -1608,12 +1991,33 @@ var post = async (input, ghApi = runGhApi) => {
1608
1991
  );
1609
1992
  const existingComplete = existingSticky !== null && parseReviewComplete(existingSticky.body);
1610
1993
  const wouldBuryCompleted = (incomplete) => incomplete && existingComplete;
1994
+ const priorIsFullReview = (body) => isFullReviewSticky(body) || parseReviewedRoute(body) === null && (parseReviewComplete(body) || parseCompletedAncestor(body));
1995
+ const emptyMechanicWouldBury = (route, incomplete) => route === "mechanic" && !incomplete && findings.findings.length === 0 && existingSticky !== null && priorIsFullReview(existingSticky.body);
1611
1996
  const priorRounds = existingSticky !== null ? parseRounds(existingSticky.body) : [];
1612
- const leaveInPlace = () => {
1997
+ const priorSignal = existingSticky === null ? null : parseSignalMarker(existingSticky.body) ?? parseSurfaceSignal(parseFindingsMarker(existingSticky.body));
1998
+ const findingsMarkerFor = (findings2, signal2) => {
1999
+ const pointer = surfacedFindingsPointer(findings2, signal2, input.jsonUrl);
2000
+ return signal2 !== null || priorSignal === null ? pointer : `${pointer}
2001
+ ${signalMarker(priorSignal)}`;
2002
+ };
2003
+ const leaveInPlace = (message) => {
1613
2004
  process.stderr.write(
1614
- `Review did not complete and the sticky already reflects a completed review \u2014 leaving it in place
1615
- `
2005
+ message ?? "Review did not complete and the sticky already reflects a completed review \u2014 leaving it in place\n"
2006
+ );
2007
+ process.exit(0);
2008
+ };
2009
+ const emptyMechanicLeaveOrNote = async (sticky) => {
2010
+ if (existingComplete) leaveInPlace(EMPTY_MECHANIC_LEAVE_MESSAGE);
2011
+ const priorSha = parseReviewedSha(sticky.body);
2012
+ const body = formatMarkdown(
2013
+ noticeBody(
2014
+ `${DEFAULT_MARKER}
2015
+
2016
+ \u26A0\uFE0F **CI-fix pass completed with no findings** for \`${input.headSha.slice(0, 7)}\` \u2014 the completed full review of \`${priorSha ? priorSha.slice(0, 7) : "an earlier commit"}\` is preserved below.`,
2017
+ sticky.body
2018
+ )
1616
2019
  );
2020
+ await upsertSticky(input.repo, prNumber, sticky, body, ghApi);
1617
2021
  process.exit(0);
1618
2022
  };
1619
2023
  const prices = JSON.parse(readFileSync(input.pricesPath, "utf-8"));
@@ -1623,24 +2027,34 @@ var post = async (input, ghApi = runGhApi) => {
1623
2027
  }
1624
2028
  const template = readFileSync(input.templatePath, "utf-8");
1625
2029
  const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
1626
- const renderNotice = (message) => formatMarkdown(
1627
- render({
1628
- findings: incompleteFindings(`### \u26A0\uFE0F ${message}`),
1629
- envelope: null,
1630
- incomplete: true,
1631
- prices: decodedPrices.right,
1632
- pricesProvided: input.pricesProvided,
1633
- template,
1634
- route: input.route,
1635
- reviewedSha: input.headSha,
1636
- effort: input.effort,
1637
- rounds: priorRounds,
1638
- convergenceRound: false,
1639
- runUrl: input.runUrl,
1640
- jsonUrl: input.jsonUrl,
1641
- postedAt: input.postedAt
1642
- })
1643
- );
2030
+ const renderNotice = (message) => {
2031
+ const findings2 = incompleteFindings(`### \u26A0\uFE0F ${message}`);
2032
+ return formatMarkdown(
2033
+ render({
2034
+ findings: findings2,
2035
+ envelope: null,
2036
+ incomplete: true,
2037
+ prices: decodedPrices.right,
2038
+ pricesProvided: input.pricesProvided,
2039
+ template,
2040
+ route: input.route,
2041
+ reviewedSha: input.headSha,
2042
+ effort: input.effort,
2043
+ rounds: priorRounds,
2044
+ sameRootNotes: {},
2045
+ roundCount: priorSignal?.round ?? priorRounds.length,
2046
+ convergenceRound: false,
2047
+ runUrl: input.runUrl,
2048
+ jsonUrl: input.jsonUrl,
2049
+ // A notice's own blob stays clean: verdict "error" + a carried "converged" would read as a
2050
+ // stop signal for a run that produced no verdict (issue #141 review r2). The prior signal
2051
+ // survives on the sticky in the compact marker (findingsMarkerFor), and the carried-forward
2052
+ // trajectory (rounds marker) remains the historical record.
2053
+ findingsPointer: findingsMarkerFor(findings2, null),
2054
+ postedAt: input.postedAt
2055
+ })
2056
+ );
2057
+ };
1644
2058
  if (isEmptyDiff(diff)) {
1645
2059
  if (wouldBuryCompleted(true)) leaveInPlace();
1646
2060
  await upsertSticky(
@@ -1667,9 +2081,12 @@ var post = async (input, ghApi = runGhApi) => {
1667
2081
  const findings = findingsResult.findings;
1668
2082
  const envelope = loadEnvelope(input.envelopePath);
1669
2083
  const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
2084
+ const effectiveRoute = input.route ?? envelope?.route;
1670
2085
  if (envelope === null) {
1671
2086
  const envelopelessIncomplete = isIncompleteFindings(findings);
1672
2087
  if (wouldBuryCompleted(envelopelessIncomplete)) leaveInPlace();
2088
+ if (emptyMechanicWouldBury(effectiveRoute, envelopelessIncomplete) && existingSticky !== null)
2089
+ await emptyMechanicLeaveOrNote(existingSticky);
1673
2090
  const body = formatMarkdown(
1674
2091
  render({
1675
2092
  findings,
@@ -1678,15 +2095,23 @@ var post = async (input, ghApi = runGhApi) => {
1678
2095
  prices: decodedPrices.right,
1679
2096
  pricesProvided: input.pricesProvided,
1680
2097
  template,
1681
- route: input.route,
2098
+ route: effectiveRoute,
1682
2099
  reviewedSha: input.headSha,
1683
2100
  effort: input.effort,
1684
2101
  rounds: priorRounds,
2102
+ sameRootNotes: {},
2103
+ roundCount: priorSignal?.round ?? priorRounds.length,
1685
2104
  convergenceRound: false,
1686
2105
  testReport,
1687
2106
  inlineDisposition: { kind: "no-envelope" },
1688
2107
  runUrl: input.runUrl,
1689
2108
  jsonUrl: input.jsonUrl,
2109
+ // Same signal rule as the main path: only a completed-review doc carries the prior signal
2110
+ // in its blob; an error-verdict doc preserves it in the compact marker instead.
2111
+ findingsPointer: findingsMarkerFor(
2112
+ findings,
2113
+ isReviewVerdict(findings.verdict) ? priorSignal : null
2114
+ ),
1690
2115
  postedAt: input.postedAt
1691
2116
  })
1692
2117
  );
@@ -1698,7 +2123,10 @@ var post = async (input, ghApi = runGhApi) => {
1698
2123
  }
1699
2124
  const thisIncomplete = envelope.incomplete === true || isIncompleteFindings(findings);
1700
2125
  if (wouldBuryCompleted(thisIncomplete)) leaveInPlace();
1701
- const findingsMarker = findingsPointer(findings, input.jsonUrl);
2126
+ if (emptyMechanicWouldBury(effectiveRoute, thisIncomplete) && existingSticky !== null)
2127
+ await emptyMechanicLeaveOrNote(existingSticky);
2128
+ const isRound = isConvergenceRound(effectiveRoute, thisIncomplete) && isReviewVerdict(findings.verdict);
2129
+ const sameRootNotes = isRound ? computeSameRootNotes(priorRounds, findings.findings, input.headSha.slice(0, 12)) : {};
1702
2130
  const {
1703
2131
  comments: rawComments,
1704
2132
  strays,
@@ -1707,7 +2135,8 @@ var post = async (input, ghApi = runGhApi) => {
1707
2135
  inlineTemplate,
1708
2136
  models: envelope.models.map((m) => m.model),
1709
2137
  findings,
1710
- jsonUrl: input.jsonUrl
2138
+ jsonUrl: input.jsonUrl,
2139
+ sameRootNotes
1711
2140
  });
1712
2141
  const { comments, longFiles } = checkLongSuggestions(rawComments);
1713
2142
  for (const wf of longFiles) {
@@ -1719,9 +2148,31 @@ var post = async (input, ghApi = runGhApi) => {
1719
2148
  const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
1720
2149
  const initialDisposition = comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
1721
2150
  const currentCounts = computeSeverityCounts(findings.findings);
1722
- const effectiveRoute = input.route ?? envelope.route;
1723
- const isRound = isConvergenceRound(effectiveRoute, thisIncomplete);
1724
- const rounds = isRound ? [...priorRounds, currentCounts] : priorRounds;
2151
+ const currentCodes = computeCodeCounts(findings.findings, findings.systemic_problems ?? []);
2152
+ const priorLastCodes = priorRounds.length > 0 ? priorRounds[priorRounds.length - 1]?.codes : void 0;
2153
+ const roundNumber = Math.max(priorSignal?.round ?? priorRounds.length, priorRounds.length) + 1;
2154
+ const rounds = isRound ? [
2155
+ ...priorRounds,
2156
+ roundRecord(
2157
+ computeRoundCounts(findings),
2158
+ currentCodes,
2159
+ priorLastCodes,
2160
+ input.headSha.slice(0, 12),
2161
+ roundNumber
2162
+ )
2163
+ ] : priorRounds;
2164
+ const signal = isRound ? signalForRound(roundNumber, computeRoundCounts(findings), input.convergenceThreshold) : thisIncomplete || !isReviewVerdict(findings.verdict) ? null : priorSignal;
2165
+ const findingsMarker = findingsMarkerFor(findings, signal);
2166
+ const markerForm = findingsMarkerForm(surfaceFindings(findings, signal), input.jsonUrl);
2167
+ if (markerForm === "link") {
2168
+ process.stderr.write(
2169
+ "Warning: the findings-json marker exceeds the embed limit \u2014 degraded to the jsonUrl-link form; a decoding agent must fetch the artifact instead of the embedded JSON\n"
2170
+ );
2171
+ } else if (markerForm === "omitted") {
2172
+ process.stderr.write(
2173
+ "Warning: the findings-json marker exceeds the embed limit and no --json-url was given \u2014 the machine-readable channel is omitted from the posted surfaces\n"
2174
+ );
2175
+ }
1725
2176
  const commonRenderInput = {
1726
2177
  findings,
1727
2178
  envelope,
@@ -1729,12 +2180,14 @@ var post = async (input, ghApi = runGhApi) => {
1729
2180
  prices: decodedPrices.right,
1730
2181
  pricesProvided: input.pricesProvided,
1731
2182
  template,
1732
- route: input.route,
2183
+ route: effectiveRoute,
1733
2184
  reviewedSha: input.headSha,
1734
2185
  effort: input.effort,
1735
2186
  testReport,
1736
2187
  severityCounts: currentCounts,
1737
2188
  rounds,
2189
+ sameRootNotes,
2190
+ roundCount: signal?.round ?? priorSignal?.round ?? priorRounds.length,
1738
2191
  convergenceThreshold: input.convergenceThreshold,
1739
2192
  convergenceRound: isRound,
1740
2193
  strays,
@@ -1817,15 +2270,22 @@ var post = async (input, ghApi = runGhApi) => {
1817
2270
  }
1818
2271
  }
1819
2272
  };
1820
- var announceBody = (headSha, runUrl, existingBody) => {
1821
- const notice = `${DEFAULT_MARKER}
1822
-
1823
- \u{1F504} **Code review in progress** for \`${headSha.slice(0, 7)}\` \u2014 see the [workflow run](${runUrl}) for progress; this comment is updated with the review when it completes.`;
2273
+ var noticeBody = (lead, existingBody) => {
1824
2274
  const carried = existingBody ? carryForwardMarkers(existingBody) : "";
1825
- return carried ? `${notice}
2275
+ return carried ? `${lead}
1826
2276
 
1827
- ${carried}` : notice;
2277
+ ${carried}` : lead;
2278
+ };
2279
+ var bodyRefsRun = (body, runUrl) => {
2280
+ const runId = runIdFromUrl(runUrl);
2281
+ return runId === null ? body.includes(runUrl) : new RegExp(`/actions/runs/${runId}(?!\\d)`).test(body);
1828
2282
  };
2283
+ var announceBody = (headSha, runUrl, existingBody) => noticeBody(
2284
+ `${DEFAULT_MARKER}
2285
+
2286
+ \u{1F504} **Code review in progress** for \`${headSha.slice(0, 7)}\` \u2014 see the [workflow run](${runUrl}) for progress; this comment is updated with the review when it completes.`,
2287
+ existingBody
2288
+ );
1829
2289
  var announce = async (input, ghApi = runGhApi) => {
1830
2290
  const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
1831
2291
  const resolution = resolvePr(candidates, input.headBranch);
@@ -1858,15 +2318,18 @@ var announce = async (input, ghApi = runGhApi) => {
1858
2318
  ghApi
1859
2319
  );
1860
2320
  };
1861
- var incompleteBody = (headSha, runUrl, existingBody) => {
1862
- const notice = `${DEFAULT_MARKER}
2321
+ var incompleteBody = (headSha, runUrl, existingBody) => noticeBody(
2322
+ `${DEFAULT_MARKER}
1863
2323
 
1864
- \u26A0\uFE0F **Code review did not complete** for \`${headSha.slice(0, 7)}\` \u2014 the review job failed ([run](${runUrl})). Re-request the review; do not treat this round as spent.`;
1865
- const carried = existingBody ? carryForwardMarkers(existingBody) : "";
1866
- return carried ? `${notice}
2324
+ \u26A0\uFE0F **Code review did not complete** for \`${headSha.slice(0, 7)}\` \u2014 the review job failed ([run](${runUrl})). Re-request the review; do not treat this round as spent.`,
2325
+ existingBody
2326
+ );
2327
+ var cancelledBody = (headSha, runUrl, existingBody) => noticeBody(
2328
+ `${DEFAULT_MARKER}
1867
2329
 
1868
- ${carried}` : notice;
1869
- };
2330
+ \u21A9\uFE0F **Code review superseded** for \`${headSha.slice(0, 7)}\` \u2014 this run was cancelled before completing, typically because a newer review run started on this branch. No action needed. [View the cancelled run](${runUrl}) for the record.`,
2331
+ existingBody
2332
+ );
1870
2333
  var reportIncomplete = async (input, ghApi = runGhApi) => {
1871
2334
  const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
1872
2335
  const resolution = resolvePr(candidates, input.headBranch);
@@ -1889,8 +2352,13 @@ var reportIncomplete = async (input, ghApi = runGhApi) => {
1889
2352
  `);
1890
2353
  return;
1891
2354
  }
1892
- if (existing !== null && !existing.body.includes(input.runUrl)) {
2355
+ if (existing !== null && !bodyRefsRun(existing.body, input.runUrl)) {
1893
2356
  process.stderr.write(`Sticky belongs to another run \u2014 leaving it in place
2357
+ `);
2358
+ return;
2359
+ }
2360
+ if (input.cancelled && existing === null) {
2361
+ process.stderr.write(`Cancelled review has no sticky to supersede \u2014 leaving it absent
1894
2362
  `);
1895
2363
  return;
1896
2364
  }
@@ -1898,94 +2366,10 @@ var reportIncomplete = async (input, ghApi = runGhApi) => {
1898
2366
  input.repo,
1899
2367
  resolution.prNumber,
1900
2368
  existing,
1901
- incompleteBody(input.headSha, input.runUrl, existing?.body),
2369
+ input.cancelled ? cancelledBody(input.headSha, input.runUrl, existing?.body) : incompleteBody(input.headSha, input.runUrl, existing?.body),
1902
2370
  ghApi
1903
2371
  );
1904
2372
  };
1905
-
1906
- // src/checkrun.ts
1907
- var CHECK_RUN_NAME = "Code review";
1908
- var latest = (checks) => checks.reduce(
1909
- (best, c) => best === null || c.id > best.id ? c : best,
1910
- null
1911
- );
1912
- var isOpen = (status) => status === "in_progress" || status === "queued";
1913
- var settled = /* @__PURE__ */ new Set(["success", "neutral", "skipped"]);
1914
- var decideCheckAction = (checks, intent) => {
1915
- const head = latest(checks);
1916
- switch (intent) {
1917
- case "in_progress":
1918
- return head !== null && isOpen(head.status) ? { kind: "noop", reason: "a check is already in progress for this head" } : { kind: "create", status: "in_progress" };
1919
- case "neutral":
1920
- if (head === null) return { kind: "create", status: "completed", conclusion: "neutral" };
1921
- return head.status === "completed" && head.conclusion === "neutral" ? { kind: "noop", reason: "the check already records this completed review" } : { kind: "patch", id: head.id, status: "completed", conclusion: "neutral" };
1922
- case "failure":
1923
- if (head === null) return { kind: "create", status: "completed", conclusion: "failure" };
1924
- if (head.status === "completed" && head.conclusion !== null && settled.has(head.conclusion))
1925
- return { kind: "noop", reason: "a completed review already recorded this head" };
1926
- return head.status === "completed" && head.conclusion === "failure" ? { kind: "noop", reason: "the check already records this failure" } : { kind: "patch", id: head.id, status: "completed", conclusion: "failure" };
1927
- }
1928
- };
1929
- var CHECK_JQ = ".check_runs[] | {id: .id, status: .status, conclusion: .conclusion}";
1930
- var fetchChecks = async (repo, headSha, ghApi) => parseJsonl(
1931
- await ghApi([
1932
- `repos/${repo}/commits/${headSha}/check-runs?check_name=${encodeURIComponent(CHECK_RUN_NAME)}&per_page=100`,
1933
- "--paginate",
1934
- "--jq",
1935
- CHECK_JQ
1936
- ])
1937
- );
1938
- var output = (intent, runUrl) => {
1939
- switch (intent) {
1940
- case "in_progress":
1941
- return {
1942
- title: "Code review in progress",
1943
- summary: `The review is running \u2014 [see the run](${runUrl}).`
1944
- };
1945
- case "neutral":
1946
- return {
1947
- title: "Code review complete",
1948
- summary: `The review was posted \u2014 [see the run](${runUrl}).`
1949
- };
1950
- case "failure":
1951
- return {
1952
- title: "Code review did not complete",
1953
- summary: `The review job failed \u2014 [see the run](${runUrl}). Re-request the review; do not treat this round as spent.`
1954
- };
1955
- }
1956
- };
1957
- var checkRun = async (input, ghApi = runGhApi) => {
1958
- const action = decideCheckAction(
1959
- await fetchChecks(input.repo, input.headSha, ghApi),
1960
- input.intent
1961
- );
1962
- if (action.kind === "noop") {
1963
- process.stderr.write(`code-review check-run: ${action.reason} \u2014 leaving it
1964
- `);
1965
- return;
1966
- }
1967
- const body = action.kind === "create" ? {
1968
- name: CHECK_RUN_NAME,
1969
- head_sha: input.headSha,
1970
- status: action.status,
1971
- details_url: input.runUrl,
1972
- ...action.conclusion ? { conclusion: action.conclusion } : {},
1973
- output: output(input.intent, input.runUrl)
1974
- } : {
1975
- status: action.status,
1976
- conclusion: action.conclusion,
1977
- details_url: input.runUrl,
1978
- output: output(input.intent, input.runUrl)
1979
- };
1980
- const endpoint = action.kind === "create" ? [`--method`, `POST`, `repos/${input.repo}/check-runs`, `--input`, `-`] : [
1981
- `--method`,
1982
- `PATCH`,
1983
- `repos/${input.repo}/check-runs/${String(action.id)}`,
1984
- `--input`,
1985
- `-`
1986
- ];
1987
- await ghApi(endpoint, JSON.stringify(body));
1988
- };
1989
2373
  var DURATION_RE = /^(\d+)(h|m|s)$/;
1990
2374
  var USD_RE = /^\$(\d+(?:\.\d+)?)$/;
1991
2375
  var toSeconds = (n, unit) => unit === "h" ? n * 3600 : unit === "m" ? n * 60 : n;
@@ -2192,12 +2576,12 @@ var resolveCiRun = async (repo, headSha, workflowName, ghApi) => {
2192
2576
  `
2193
2577
  );
2194
2578
  }
2195
- const latest2 = runs.filter((r) => r.name === workflowName).reduce(
2579
+ const latest = runs.filter((r) => r.name === workflowName).reduce(
2196
2580
  (best, r) => best === null || r.run_number > best.run_number ? r : best,
2197
2581
  null
2198
2582
  );
2199
2583
  return {
2200
- run: latest2 === null ? null : { id: latest2.id, status: latest2.status ?? "unknown", conclusion: latest2.conclusion },
2584
+ run: latest === null ? null : { id: latest.id, status: latest.status ?? "unknown", conclusion: latest.conclusion },
2201
2585
  seenNames: [...new Set(runs.flatMap((r) => r.name === null ? [] : [r.name]))]
2202
2586
  };
2203
2587
  };
@@ -2748,28 +3132,21 @@ var nativeTelemetry = (native, meta) => resolveTelemetry(
2748
3132
  );
2749
3133
  var absentTelemetry = (meta) => resolveTelemetry({ models: [], turns: 0, durationMs: 0, vendorCostUsd: null }, meta);
2750
3134
  var buildEnvelope = (telemetry, native, agentFilePath, agentFileFallbackPath, seedUnrevised) => {
2751
- if (seedUnrevised)
2752
- return {
2753
- schema_version: DEFAULT_SCHEMA_VERSION,
2754
- findings: incompleteFindings(
2755
- "### \u26A0\uFE0F Review did not complete\n\nThe review agent did not write a review (its draft is the untouched pre-seed). See the workflow logs."
2756
- ),
2757
- incomplete: true,
2758
- ...telemetry
2759
- };
2760
3135
  const outcome = findingsOutcome(native, agentFilePath, agentFileFallbackPath);
2761
3136
  switch (outcome.kind) {
2762
3137
  case "ok":
2763
3138
  return { schema_version: outcome.version, findings: outcome.findings, ...telemetry };
2764
- case "telemetry-only":
3139
+ case "telemetry-only": {
3140
+ const reason = seedUnrevised ? "the review agent did not write a review (its draft is still the pre-seeded sentinel)" : outcome.reason;
2765
3141
  return {
2766
3142
  schema_version: DEFAULT_SCHEMA_VERSION,
2767
3143
  findings: incompleteFindings(`### \u26A0\uFE0F Review did not complete
2768
3144
 
2769
- ${outcome.reason}`),
3145
+ ${reason}`),
2770
3146
  incomplete: true,
2771
3147
  ...telemetry
2772
3148
  };
3149
+ }
2773
3150
  }
2774
3151
  };
2775
3152
  var adapt = (adapterName, native, agentFilePath, meta = {}) => {
@@ -2848,6 +3225,21 @@ var buildSandboxConfig = (opts) => ({
2848
3225
  filesystem: { allowRead: [], denyRead: [], allowWrite: ["/"], denyWrite: [] }
2849
3226
  });
2850
3227
 
3228
+ // src/scope.ts
3229
+ var SCOPE_SEPARATOR_RE = /[\s,;]+/;
3230
+ var parseScope = (raw) => {
3231
+ const trimmed = raw?.trim();
3232
+ if (trimmed === void 0 || trimmed === "") return { kind: "absent" };
3233
+ if (UNSAFE_IN_SUMMARY.test(trimmed)) {
3234
+ return {
3235
+ kind: "invalid",
3236
+ reason: 'scope contains a character (newline, carriage return, backtick, "<", ">", or "|") that would corrupt the review prompt \u2014 use plain language names/tags'
3237
+ };
3238
+ }
3239
+ const languages = Array.from(new Set(trimmed.split(SCOPE_SEPARATOR_RE).filter((t7) => t7 !== "")));
3240
+ return languages.length === 0 ? { kind: "absent" } : { kind: "ok", languages };
3241
+ };
3242
+
2851
3243
  // src/index.ts
2852
3244
  var readJSON = (path) => {
2853
3245
  try {
@@ -3010,6 +3402,9 @@ var renderCmd = defineCommand({
3010
3402
  const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
3011
3403
  const template = readFileSync(templatePath, "utf-8");
3012
3404
  const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
3405
+ const route = args.route || envelope.route || null;
3406
+ const isRound = isConvergenceRound(route, envelope.incomplete === true || isIncompleteFindings(findings)) && isReviewVerdict(findings.verdict);
3407
+ const counts = computeRoundCounts(findings);
3013
3408
  const output2 = render({
3014
3409
  findings,
3015
3410
  envelope,
@@ -3020,6 +3415,7 @@ var renderCmd = defineCommand({
3020
3415
  route: args.route,
3021
3416
  effort: args.effort,
3022
3417
  testReport,
3418
+ rounds: isRound ? [counts] : [],
3023
3419
  convergenceThreshold: parseConvergenceThreshold(args["convergence-threshold"]),
3024
3420
  postedAt: formatUtc(/* @__PURE__ */ new Date())
3025
3421
  });
@@ -3153,21 +3549,33 @@ var parseConvergenceThreshold = (raw) => {
3153
3549
  }
3154
3550
  return n;
3155
3551
  };
3156
- var mtimeMsOf = (path) => {
3552
+ var transcriptPathOf = (input) => {
3553
+ const tp = (typeof input === "object" && input !== null ? input : {})["transcript_path"];
3554
+ return typeof tp === "string" ? tp : void 0;
3555
+ };
3556
+ var statMtimeMsOrNull = (path) => {
3157
3557
  try {
3158
3558
  return statSync(path).mtimeMs;
3159
3559
  } catch {
3160
3560
  return null;
3161
3561
  }
3162
3562
  };
3163
- var transcriptPathOf = (input) => {
3164
- const tp = (typeof input === "object" && input !== null ? input : {})["transcript_path"];
3165
- return typeof tp === "string" ? tp : void 0;
3563
+ var statSizeOrNull = (path) => {
3564
+ try {
3565
+ return statSync(path).size;
3566
+ } catch {
3567
+ return null;
3568
+ }
3166
3569
  };
3167
3570
  var snapshotIfValid = (draftPath) => {
3168
3571
  try {
3572
+ const snapPath = lastValidPath(draftPath);
3573
+ const draftMtime = statMtimeMsOrNull(draftPath);
3574
+ if (draftMtime === null) return;
3575
+ const snapMtime = statMtimeMsOrNull(snapPath);
3576
+ if (snapMtime !== null && draftMtime <= snapMtime) return;
3169
3577
  if (extractStructured({ kind: "findings", native: void 0, agentFilePath: draftPath }).kind === "ok")
3170
- copyFileSync(draftPath, lastValidPath(draftPath));
3578
+ copyFileSync(draftPath, snapPath);
3171
3579
  } catch (err) {
3172
3580
  process.stderr.write(
3173
3581
  `code-review: could not snapshot the last-valid draft (${errMsg(err)}) \u2014 any prior snapshot is unchanged
@@ -3242,10 +3650,8 @@ var budgetHookCmd = defineCommand({
3242
3650
  flatMs: args["reserve-wall"] ? parseWallMs(args["reserve-wall"]) ?? DEFAULT_RESERVE.flatMs : DEFAULT_RESERVE.flatMs
3243
3651
  },
3244
3652
  draftPath,
3245
- mainDraftWritten: mainHasWrittenDraft(
3246
- mtimeMsOf(draftPath),
3247
- mtimeMsOf(seedMarkerPath(draftPath))
3248
- )
3653
+ // Lazy: the spawn gate is the only consumer, and the hook fires on every tool event.
3654
+ mainDraftWritten: () => mainHasWrittenDraft(readFileOrNull(draftPath))
3249
3655
  });
3250
3656
  if (asRecord(input)?.["hook_event_name"] === "PostToolBatch" && !isSubagentHookInput(input))
3251
3657
  snapshotIfValid(draftPath);
@@ -3428,12 +3834,12 @@ ${printableSchema(schemaPath)}
3428
3834
  var seedDraftCmd = defineCommand({
3429
3835
  meta: {
3430
3836
  name: "seed-draft",
3431
- description: "Write a valid findings $DRAFT before the review runs: the decoded findings from a prior review when one exists and still validates (incremental re-review), else an empty-but-valid scaffold \u2014 so a valid draft exists from turn 0. Also drops a sidecar marker beside the seed so the budget hook can tell the untouched seed from a draft the agent wrote itself. Prints the mode to stdout (prior-same|prior-new when seeded, by whether the prior review examined this same commit; empty-had-prior when a prior review exists but its findings could not be loaded; empty on a first review; none when even the scaffold write failed) and always exits 0"
3837
+ description: "Initialize $DRAFT before the review runs: write a NON-REVIEW SENTINEL (no recovery path can validate it as a review), and deliver the decoded findings of a prior review OUT-OF-BAND to a read-only context file beside the draft when one exists and still validates (incremental re-review). Skips a prior that never completed (an error-verdict notice) or that a CI-fix mechanic pass produced \u2014 the seed chain is route-aware. Prints the mode to stdout (prior-same|prior-new when prior context was delivered, by whether the prior review examined this same commit; empty-had-prior when a prior review exists but its findings could not be loaded; empty on a first review; none when even the sentinel write failed) and always exits 0"
3432
3838
  },
3433
3839
  args: {
3434
3840
  prior: {
3435
3841
  type: "string",
3436
- description: "Path to the prior-review JSON gather staged ({ id, body }, or the literal null); its embedded base64 findings marker is decoded and becomes the seed when it validates against the schema"
3842
+ description: "Path to the prior-review JSON gather staged ({ id, body }, or the literal null); its embedded base64 findings marker is decoded and delivered as re-review context when it validates against the schema"
3437
3843
  },
3438
3844
  "head-sha": {
3439
3845
  type: "string",
@@ -3441,7 +3847,7 @@ var seedDraftCmd = defineCommand({
3441
3847
  },
3442
3848
  out: {
3443
3849
  type: "string",
3444
- description: "Path to write the seed $DRAFT to (an absolute path outside the worktree)",
3850
+ description: "Path to write the sentinel $DRAFT to (an absolute path outside the worktree)",
3445
3851
  required: true
3446
3852
  },
3447
3853
  kind: {
@@ -3454,7 +3860,7 @@ var seedDraftCmd = defineCommand({
3454
3860
  },
3455
3861
  "schema-version": {
3456
3862
  type: "string",
3457
- description: "Schema major.minor to validate the prior findings against (default: the kind's latest \u2014 an older-shaped prior review then falls back to the empty scaffold)"
3863
+ description: "Schema major.minor to validate the prior findings against (default: the kind's latest \u2014 an older-shaped prior review then delivers no context)"
3458
3864
  }
3459
3865
  },
3460
3866
  run: async ({ args }) => {
@@ -3467,29 +3873,17 @@ var seedDraftCmd = defineCommand({
3467
3873
  `
3468
3874
  );
3469
3875
  }
3470
- const writeSeedMarker = () => {
3876
+ const writeSentinel = () => {
3471
3877
  try {
3472
- writeFileSync(seedMarkerPath(outPath), "code-review seed marker\n");
3473
- } catch (err) {
3878
+ writeFileSync(outPath, SEED_SENTINEL);
3474
3879
  process.stderr.write(
3475
- `Warning: could not write the seed marker beside ${outPath} (${errMsg(err)}) \u2014 the seeded draft will count as agent-written
3476
- `
3477
- );
3478
- }
3479
- };
3480
- const writeScaffold = () => {
3481
- try {
3482
- writeFileSync(outPath, `${JSON.stringify(emptyFindings(""), null, 2)}
3483
- `);
3484
- writeSeedMarker();
3485
- process.stderr.write(
3486
- `Seeded ${outPath} with an empty valid scaffold \u2014 no decodable prior findings to build on
3880
+ `Seeded ${outPath} with the non-review sentinel \u2014 the agent must replace it with its review
3487
3881
  `
3488
3882
  );
3489
3883
  return true;
3490
3884
  } catch (err) {
3491
3885
  process.stderr.write(
3492
- `Warning: could not write the seed scaffold to ${outPath} (${errMsg(err)}) \u2014 the agent will create $DRAFT itself
3886
+ `Warning: could not write the seed sentinel to ${outPath} (${errMsg(err)}) \u2014 the agent will create $DRAFT itself
3493
3887
  `
3494
3888
  );
3495
3889
  return false;
@@ -3506,24 +3900,30 @@ var seedDraftCmd = defineCommand({
3506
3900
  })();
3507
3901
  return typeof raw === "object" && raw !== null && "body" in raw && typeof raw.body === "string" ? raw.body : null;
3508
3902
  })();
3509
- const priorFindings = priorBody === null ? null : parseFindingsMarker(priorBody);
3903
+ const priorFindings = priorBody === null ? null : stripSurfaceFields(parseFindingsMarker(priorBody));
3510
3904
  const seededFromPrior = priorFindings === null ? false : (() => {
3511
3905
  try {
3512
3906
  const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
3513
3907
  if (!validateAgainstSchema(priorFindings, schemaPath).valid) return false;
3514
- writeFileSync(outPath, `${JSON.stringify(priorFindings, null, 2)}
3515
- `);
3516
- writeSeedMarker();
3517
- const priorList = priorFindings.findings;
3518
- const count = Array.isArray(priorList) ? priorList.length : 0;
3908
+ const resolution = resolve("findings", priorFindings);
3909
+ if (resolution.kind !== "ok") return false;
3910
+ if (isIncompleteFindings(resolution.value)) return false;
3911
+ if (parseReviewedRoute(priorBody ?? "") !== "full review") return false;
3912
+ writeFileSync(outPath, SEED_SENTINEL);
3913
+ writeFileSync(
3914
+ priorContextPath(outPath),
3915
+ `${JSON.stringify(priorFindings, null, 2)}
3916
+ `
3917
+ );
3918
+ const count = resolution.value.findings.length;
3519
3919
  process.stderr.write(
3520
- `Seeded ${outPath} from the prior review (${String(count)} finding(s))
3920
+ `Seeded ${outPath} with the sentinel and wrote the prior review (${String(count)} finding(s)) to ${priorContextPath(outPath)} as context
3521
3921
  `
3522
3922
  );
3523
3923
  return true;
3524
3924
  } catch (err) {
3525
3925
  process.stderr.write(
3526
- `Warning: could not seed from the prior review (${errMsg(err)}) \u2014 falling back to the empty scaffold
3926
+ `Warning: could not seed from the prior review (${errMsg(err)}) \u2014 seeding the sentinel only
3527
3927
  `
3528
3928
  );
3529
3929
  return false;
@@ -3534,7 +3934,7 @@ var seedDraftCmd = defineCommand({
3534
3934
  const priorSha = priorBody === null ? null : parseReviewedSha(priorBody);
3535
3935
  return args["head-sha"] && priorSha && priorSha === args["head-sha"].toLowerCase() ? "prior-same" : "prior-new";
3536
3936
  }
3537
- if (!writeScaffold()) return "none";
3937
+ if (!writeSentinel()) return "none";
3538
3938
  return priorBody === null ? "empty" : "empty-had-prior";
3539
3939
  })();
3540
3940
  process.stdout.write(`${mode}
@@ -3580,7 +3980,8 @@ var adaptCmd = defineCommand({
3580
3980
  },
3581
3981
  run: async ({ args }) => {
3582
3982
  const agentFile = args["agent-file"];
3583
- const seedUnrevised = agentFile ? !mainHasWrittenDraft(mtimeMsOf(agentFile), mtimeMsOf(seedMarkerPath(agentFile))) : false;
3983
+ const agentFileSize = agentFile ? statSizeOrNull(agentFile) : null;
3984
+ const seedUnrevised = agentFileSize !== null && agentFileSize <= Buffer.byteLength(SEED_SENTINEL) + 2 && isSeedSentinel(readFileOrNull(agentFile));
3584
3985
  const envelope = unwrapAdapt(
3585
3986
  adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), agentFile, {
3586
3987
  route: args.route,
@@ -4077,11 +4478,11 @@ var announceCmd = defineCommand({
4077
4478
  );
4078
4479
  }
4079
4480
  });
4080
- var isCheckIntent = (s) => s === "in_progress" || s === "neutral" || s === "failure";
4481
+ var isCheckIntent = (s) => s === "in_progress" || s === "neutral" || s === "failure" || s === "cancelled";
4081
4482
  var checkRunCmd = defineCommand({
4082
4483
  meta: {
4083
4484
  name: "check-run",
4084
- description: "Upsert the native 'Code review' check-run on the head SHA \u2014 the attribution surface that appears in the PR's own checks list and (writing to the base repo) works for fork PRs too. `in_progress` at review start, `neutral` when the review completes, `failure` when it didn't. Forward-only: `failure` never overwrites a completed review."
4485
+ description: "Upsert the native 'Code review' check-run on the head SHA \u2014 the attribution surface that appears in the PR's own checks list and (writing to the base repo) works for fork PRs too. `in_progress` at review start, `neutral` when the review completes, `failure` when it didn't, `cancelled` when a cancelled review settles its own check (matched by details_url, so it never touches a superseding run's check). Forward-only: `failure`/`cancelled` never overwrite a completed review."
4085
4486
  },
4086
4487
  args: {
4087
4488
  repo: { type: "string", description: "Repository (owner/name)", required: true },
@@ -4092,19 +4493,19 @@ var checkRunCmd = defineCommand({
4092
4493
  },
4093
4494
  status: {
4094
4495
  type: "positional",
4095
- description: "One of: in_progress, neutral, failure",
4496
+ description: "One of: in_progress, neutral, failure, cancelled",
4096
4497
  required: true
4097
4498
  },
4098
4499
  "run-url": {
4099
4500
  type: "string",
4100
- description: "Workflow run URL the check-run's details link to",
4501
+ description: "Workflow run URL the check-run's details link to (also the ownership key for `cancelled`)",
4101
4502
  required: true
4102
4503
  }
4103
4504
  },
4104
4505
  run: async ({ args }) => {
4105
4506
  if (!isCheckIntent(args.status)) {
4106
4507
  process.stderr.write(
4107
- `::warning::code-review check-run: unrecognized status "${annotationSafe(args.status)}" \u2014 expected in_progress, neutral, or failure; skipping
4508
+ `::warning::code-review check-run: unrecognized status "${annotationSafe(args.status)}" \u2014 expected in_progress, neutral, failure, or cancelled; skipping
4108
4509
  `
4109
4510
  );
4110
4511
  return;
@@ -4125,7 +4526,7 @@ var checkRunCmd = defineCommand({
4125
4526
  var reportIncompleteCmd = defineCommand({
4126
4527
  meta: {
4127
4528
  name: "report-incomplete",
4128
- description: "Post (or update) the sticky when a review job hard-failed and posted nothing \u2014 an attributed 'did not complete' notice linking the run, telling the reader to re-request. Never buries a completed review, and never overwrites a superseding run's live in-progress placeholder. (A cancelled review is left to the superseding run that took over.)"
4529
+ description: "Post (or update) the sticky when a review job hard-failed and posted nothing \u2014 an attributed 'did not complete' notice linking the run, telling the reader to re-request; with --cancelled, the informational 'superseded \u2014 no action needed' notice instead (issue #139). Never buries a completed review, and never overwrites a superseding run's live in-progress placeholder."
4129
4530
  },
4130
4531
  args: {
4131
4532
  repo: { type: "string", description: "Repository (owner/name)", required: true },
@@ -4146,6 +4547,10 @@ var reportIncompleteCmd = defineCommand({
4146
4547
  "head-branch": {
4147
4548
  type: "string",
4148
4549
  description: "Head branch to disambiguate the PR when multiple share a commit"
4550
+ },
4551
+ cancelled: {
4552
+ type: "boolean",
4553
+ description: "This run was CANCELLED before completing (typically superseded by a newer run on the same branch) \u2014 post the informational 'superseded' notice, not the failure notice (issue #139)"
4149
4554
  }
4150
4555
  },
4151
4556
  run: async ({ args }) => {
@@ -4154,10 +4559,11 @@ var reportIncompleteCmd = defineCommand({
4154
4559
  headSha: args["head-sha"],
4155
4560
  botLogin: args["bot-login"] || "github-actions[bot]",
4156
4561
  runUrl: args["run-url"],
4157
- headBranch: args["head-branch"]
4562
+ headBranch: args["head-branch"],
4563
+ cancelled: args.cancelled
4158
4564
  }).catch(
4159
4565
  (err) => process.stderr.write(
4160
- `::warning::code-review report-incomplete: could not post the failure notice (${annotationSafe(errMsg(err))}) \u2014 continuing
4566
+ `::warning::code-review report-incomplete: could not post the notice (${annotationSafe(errMsg(err))}) \u2014 continuing
4161
4567
  `
4162
4568
  )
4163
4569
  );
@@ -4321,6 +4727,30 @@ var awaitCiCmd = defineCommand({
4321
4727
  process.stdout.write(renderCiOutputs(outcome));
4322
4728
  }
4323
4729
  });
4730
+ var checkScopeCmd = defineCommand({
4731
+ meta: {
4732
+ name: "check-scope",
4733
+ description: "Validate + normalize the workflow's `scope` input \u2014 the languages/inputs the project accepts (issue #139). Prints the normalized space-separated language list for splicing into the review prompt, or nothing when the scope is empty (the reviewer then infers it from the README's first paragraph). Fails loudly on a malformed value, so a config typo never silently corrupts the prompt it is spliced into."
4734
+ },
4735
+ args: {
4736
+ scope: {
4737
+ type: "string",
4738
+ description: 'The raw scope value \u2014 whitespace/comma/semicolon-separated language names (e.g. "C C++"); empty \u21D2 absent'
4739
+ }
4740
+ },
4741
+ run: ({ args }) => {
4742
+ const parsed = parseScope(args.scope);
4743
+ switch (parsed.kind) {
4744
+ case "absent":
4745
+ return;
4746
+ case "invalid":
4747
+ return fail(`check-scope: ${parsed.reason}`);
4748
+ case "ok":
4749
+ process.stdout.write(`${parsed.languages.join(" ")}
4750
+ `);
4751
+ }
4752
+ }
4753
+ });
4324
4754
  var sandboxConfigCmd = defineCommand({
4325
4755
  meta: {
4326
4756
  name: "sandbox-config",
@@ -4391,6 +4821,7 @@ var main = defineCommand({
4391
4821
  post: postCmd,
4392
4822
  announce: announceCmd,
4393
4823
  "check-run": checkRunCmd,
4824
+ "check-scope": checkScopeCmd,
4394
4825
  "report-incomplete": reportIncompleteCmd,
4395
4826
  cost: costCmd,
4396
4827
  "check-cost": checkCostCmd,