@jphutchins/code-review 0.1.0-alpha.64 → 0.1.0-alpha.65

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
@@ -109,7 +109,8 @@ var FindingIdRequired = t.type({ id: t.string });
109
109
  var FindingOptional = t.partial({
110
110
  side: SideCodec,
111
111
  recommendation: t.string,
112
- patch: t.string
112
+ patch: t.string,
113
+ rebuttal: t.string
113
114
  });
114
115
  var EndGeStart = (shape) => t.refinement(shape, (f) => f.end_line >= f.start_line, "EndGeStart");
115
116
  var FindingShape = t.intersection([
@@ -135,7 +136,8 @@ var SystemicRequired = t.type({
135
136
  var SystemicOptional = t.partial({
136
137
  id: t.string,
137
138
  finding_ids: t.array(t.string),
138
- paths: t.array(t.string)
139
+ paths: t.array(t.string),
140
+ rebuttal: t.string
139
141
  });
140
142
  var SystemicProblemShape = t.intersection([SystemicRequired, RuleUrlCodec, SystemicOptional]);
141
143
  var SystemicProblemCodec = strictExact("SystemicProblemStrict", SystemicProblemShape, [
@@ -245,6 +247,31 @@ var RECOVERABLE_OPTIONAL_FIELDS = /* @__PURE__ */ new Set([
245
247
  ...PIPELINE_STAMPED_FIELDS,
246
248
  "change_size"
247
249
  ]);
250
+ var ID_SHAPE_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
251
+ var priorIdsFrom = (doc) => {
252
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return [];
253
+ const rec = doc;
254
+ const idsOf = (field, systemic) => (Array.isArray(rec[field]) ? rec[field] : []).flatMap((raw) => {
255
+ const item = typeof raw === "object" && raw !== null ? asRecord(raw) : null;
256
+ if (item === null) return [];
257
+ const title = typeof item["title"] === "string" ? item["title"] : "";
258
+ const resolved = resolveRuleId({
259
+ ...typeof item["id"] === "string" ? { id: item["id"] } : {},
260
+ ...typeof item["code"] === "string" ? { code: item["code"] } : {},
261
+ ...typeof item["path"] === "string" ? { path: item["path"] } : {},
262
+ title
263
+ });
264
+ const id = resolved ?? (systemic && title !== "" ? synthesizedSystemicId(title) : void 0);
265
+ return id !== void 0 ? [id] : [];
266
+ });
267
+ return [...idsOf("findings", false), ...idsOf("systemic_problems", true)];
268
+ };
269
+ var hasRebuttal = (item) => (item.rebuttal ?? "").trim() !== "";
270
+ var withoutRebuttals = (doc) => ({
271
+ ...doc,
272
+ findings: doc.findings.map(({ rebuttal: _rebuttal, ...f }) => f),
273
+ ...doc.systemic_problems !== void 0 ? { systemic_problems: doc.systemic_problems.map(({ rebuttal: _rebuttal, ...s }) => s) } : {}
274
+ });
248
275
  var synthesizedId = (parts, prefix) => `${prefix}${createHash("sha256").update(parts.join("\0")).digest("base64url").slice(0, 12)}`;
249
276
  var synthesizedFindingId = (path, title) => synthesizedId([path, title], "f-");
250
277
  var synthesizedSystemicId = (title) => synthesizedId([title], "s-");
@@ -270,12 +297,18 @@ var LegacyRuleCodec = t.partial({
270
297
  id: t.string,
271
298
  code_url: UriString
272
299
  });
273
- var FindingShapeV09 = t.intersection([FindingCoreRequired, LegacyRuleCodec, FindingOptional]);
300
+ var FindingOptionalV09 = t.partial({
301
+ side: SideCodec,
302
+ recommendation: t.string,
303
+ patch: t.string
304
+ });
305
+ var FindingShapeV09 = t.intersection([FindingCoreRequired, LegacyRuleCodec, FindingOptionalV09]);
274
306
  var FindingCodecV09 = t.exact(EndGeStart(FindingShapeV09));
275
307
  var SystemicV09Optional = t.partial({
276
308
  finding_codes: t.array(t.string),
277
309
  finding_ids: t.array(t.string),
278
- paths: t.array(t.string)
310
+ paths: t.array(t.string),
311
+ rebuttal: t.unknown
279
312
  });
280
313
  var SystemicV09Shape = t.intersection([SystemicRequired, LegacyRuleCodec, SystemicV09Optional]);
281
314
  var SystemicProblemCodecV09 = strictExact("SystemicV09Strict", SystemicV09Shape, [
@@ -343,7 +376,7 @@ var normalizeV09 = (doc) => {
343
376
  id: resolveRuleId({ id, code, path: f.path, title: f.title }) ?? synthesizedFindingId(f.path, f.title)
344
377
  }));
345
378
  const systemic_problems = doc.systemic_problems?.map(
346
- ({ code, id, finding_codes, finding_ids, ...s }) => ({
379
+ ({ code, id, finding_codes, finding_ids, rebuttal: _rebuttal, ...s }) => ({
347
380
  ...s,
348
381
  id: resolveRuleId({ id, code, title: s.title }) ?? synthesizedSystemicId(s.title),
349
382
  ...finding_ids !== void 0 && finding_ids.length > 0 ? { finding_ids } : finding_codes !== void 0 ? { finding_ids: finding_codes } : {}
@@ -484,7 +517,7 @@ var TestSummaryCodec = t.intersection([
484
517
  failures: t.array(TestFailureCodec)
485
518
  })
486
519
  ]);
487
- var DEFAULT_SCHEMA_VERSION = "0.10.0";
520
+ var DEFAULT_SCHEMA_VERSION = "0.11.0";
488
521
  var incompleteFindings = (summary) => ({
489
522
  schema_version: DEFAULT_SCHEMA_VERSION,
490
523
  summary,
@@ -494,7 +527,7 @@ var incompleteFindings = (summary) => ({
494
527
  var isIncompleteFindings = (findings) => findings.verdict === "error" && findings.findings.length === 0;
495
528
 
496
529
  // src/released.ts
497
- var RELEASED = "2026-10-02";
530
+ var RELEASED = "2026-10-10";
498
531
 
499
532
  // src/cost.ts
500
533
  var defaultWarn = (message) => {
@@ -824,6 +857,8 @@ var isSeverityCounts = (u) => typeof u === "object" && u !== null && SEVERITIES.
824
857
  var MAX_IDS_PER_ROUND = 8;
825
858
  var hasId = (ids, code) => ids !== void 0 && Object.prototype.hasOwnProperty.call(ids, code);
826
859
  var escapeCodeBackticks = (code) => code.replace(/`/g, "-").replace(/\r?\n/g, " ");
860
+ var encodeAutolinkParens = (url) => url.replace(/\(/g, "%28").replace(/\)/g, "%29");
861
+ var linkSafeUrl = (url) => encodeAutolinkParens(escapeCodeBackticks(url));
827
862
  var normalizeIdCounts = (ids, priorCodes, preferredKeys, preValidated = false) => {
828
863
  const entries = Object.entries((preValidated ? ids : usableCountsMap(ids)) ?? {});
829
864
  if (entries.length === 0) return void 0;
@@ -1356,270 +1391,32 @@ var resolvePriorFindings = async (body, read) => {
1356
1391
  }
1357
1392
  };
1358
1393
 
1359
- // src/comment-chain.ts
1360
- function* ancestors(start, parentOf) {
1361
- const seen = /* @__PURE__ */ new Set();
1362
- let current = start;
1363
- while (current !== null && !seen.has(current)) {
1364
- yield current;
1365
- seen.add(current);
1366
- current = parentOf(current);
1367
- }
1368
- }
1369
- var numField = (rec, key2) => {
1370
- const v = rec[key2];
1371
- return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
1372
- };
1373
- var messageUsage = (entry) => {
1374
- const rec = asRecord(entry);
1375
- if (rec === null || rec["type"] !== "assistant") return null;
1376
- const msg = asRecord(rec["message"]);
1377
- if (msg === null) return null;
1378
- const model = msg["model"];
1379
- const usage = asRecord(msg["usage"]);
1380
- if (typeof model !== "string" || usage === null) return null;
1381
- const id = msg["id"];
1382
- return {
1383
- id: typeof id === "string" ? id : null,
1384
- model: modelIdentity(model),
1385
- input: numField(usage, "input_tokens"),
1386
- output: numField(usage, "output_tokens"),
1387
- cacheRead: numField(usage, "cache_read_input_tokens"),
1388
- cacheWrite: numField(usage, "cache_creation_input_tokens")
1389
- };
1390
- };
1391
- var tsMsOf = (entry) => {
1392
- const rec = asRecord(entry);
1393
- const ts = rec?.["timestamp"];
1394
- if (typeof ts !== "string") return null;
1395
- const ms = Date.parse(ts);
1396
- return Number.isNaN(ms) ? null : ms;
1397
- };
1398
- var EMPTY_TOTALS = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
1399
- var parseJsonl = (text) => text.split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
1400
- try {
1401
- return [JSON.parse(line)];
1402
- } catch {
1403
- return [];
1404
- }
1405
- });
1406
- var sumTranscriptUsage = (entries) => {
1407
- const summed = entries.reduce(
1408
- (acc, entry) => {
1409
- const u = messageUsage(entry);
1410
- if (u === null) return acc;
1411
- if (u.id !== null && acc.seen.has(u.id)) return acc;
1412
- if (u.id !== null) acc.seen.add(u.id);
1413
- const prev = acc.totals.get(u.model) ?? EMPTY_TOTALS;
1414
- acc.totals.set(u.model, {
1415
- input: prev.input + u.input,
1416
- output: prev.output + u.output,
1417
- cacheRead: prev.cacheRead + u.cacheRead,
1418
- cacheWrite: prev.cacheWrite + u.cacheWrite
1419
- });
1420
- return { totals: acc.totals, turns: acc.turns + 1, seen: acc.seen };
1421
- },
1422
- { totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
1423
- );
1424
- const models = [...summed.totals].map(([model, t8]) => ({
1425
- model,
1426
- input_tokens: t8.input,
1427
- output_tokens: t8.output,
1428
- cache_read_tokens: t8.cacheRead,
1429
- cache_write_tokens: t8.cacheWrite
1430
- }));
1431
- const bounds = entries.reduce(
1432
- (acc, entry) => {
1433
- const ms = tsMsOf(entry);
1434
- if (ms === null) return acc;
1435
- return {
1436
- min: acc.min === null || ms < acc.min ? ms : acc.min,
1437
- max: acc.max === null || ms > acc.max ? ms : acc.max
1438
- };
1439
- },
1440
- { min: null, max: null }
1441
- );
1442
- return {
1443
- models,
1444
- turns: summed.turns,
1445
- durationMs: bounds.min !== null && bounds.max !== null ? bounds.max - bounds.min : 0,
1446
- firstTsMs: bounds.min,
1447
- lastTsMs: bounds.max
1448
- };
1449
- };
1450
- var subagentFiles = (mainPath) => {
1451
- const dir = join(dirname(mainPath), basename(mainPath, ".jsonl"), "subagents");
1452
- try {
1453
- return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
1454
- } catch {
1455
- return [];
1456
- }
1457
- };
1458
- var readTranscriptTree = (mainPath) => {
1459
- const mainText = readFileOrNull(mainPath);
1460
- const mainEntries = mainText === null ? [] : parseJsonl(mainText);
1461
- const inlineSidechains = mainEntries.some((e) => asRecord(e)?.["isSidechain"] === true);
1462
- const siblings = inlineSidechains ? [] : subagentFiles(mainPath);
1463
- const siblingReads = siblings.flatMap((path) => {
1464
- const text = readFileOrNull(path);
1465
- return text === null ? [] : [{ path, entries: parseJsonl(text) }];
1466
- });
1467
- return {
1468
- entries: [...mainEntries, ...siblingReads.flatMap((r) => r.entries)],
1469
- files: [...mainText === null ? [] : [mainPath], ...siblingReads.map((r) => r.path)],
1470
- missing: mainText === null
1471
- };
1472
- };
1473
-
1474
1394
  // src/answered.ts
1475
- var THREAD_COMMENT_JQ = ".[] | {id, in_reply_to_id, user: {login: .user.login}, user_login: .user.login, user_type: .user.type, body, html_url, path, line, created_at, author_association}";
1476
- var ThreadCommentCodec = t.type({
1477
- id: t.number,
1478
- in_reply_to_id: t.union([t.number, t.null]),
1479
- user_login: t.string,
1480
- user_type: t.union([t.string, t.null]),
1481
- body: t.union([t.string, t.null]),
1482
- html_url: t.string,
1483
- path: t.union([t.string, t.null]),
1484
- line: t.union([t.number, t.null]),
1485
- created_at: t.union([t.string, t.null])
1486
- });
1487
- var AnsweredEntryCodec = t.type({
1488
- code: t.string,
1489
- title: t.string,
1490
- description: t.string,
1491
- reasoning: t.string,
1492
- severity: t.union([
1493
- t.literal("critical"),
1494
- t.literal("major"),
1495
- t.literal("minor"),
1496
- t.literal("nit")
1497
- ]),
1498
- path: t.string,
1499
- patch: t.union([t.string, t.null]),
1500
- replied_at: t.union([t.string, t.null]),
1501
- reply_id: t.number,
1502
- thread_url: t.string,
1503
- reply_url: t.string,
1504
- reply_author: t.string,
1505
- reply_excerpt: t.string
1506
- });
1507
- var encodeAnsweredEntry = (e) => ({
1508
- code: e.code,
1509
- title: e.title,
1510
- description: e.description,
1511
- reasoning: e.reasoning,
1512
- severity: e.severity,
1513
- path: e.path,
1514
- patch: e.patch,
1515
- replied_at: e.repliedAt,
1516
- reply_id: e.replyId,
1517
- thread_url: e.threadUrl,
1518
- reply_url: e.replyUrl,
1519
- reply_author: e.replyAuthor,
1520
- reply_excerpt: e.replyExcerpt
1395
+ var answeredRegistryFrom = (closures, prior) => closures.flatMap((response) => {
1396
+ const finding = prior?.findings.find((f) => resolveFindingId(f) === response.id);
1397
+ return finding === void 0 ? [] : [
1398
+ {
1399
+ code: response.id,
1400
+ title: finding.title,
1401
+ description: finding.description,
1402
+ reasoning: finding.reasoning,
1403
+ severity: finding.severity,
1404
+ path: finding.path,
1405
+ patch: finding.patch ?? null,
1406
+ answerUrl: response.source_url,
1407
+ answerAuthor: response.author
1408
+ }
1409
+ ];
1521
1410
  });
1522
- var decodeAnsweredEntry = (raw) => {
1523
- const decoded = AnsweredEntryCodec.decode(raw);
1524
- return decoded._tag === "Right" ? decoded.right : null;
1525
- };
1526
- var isHuman = (login, type8, botLogin) => login !== botLogin && type8 === "User";
1527
- var EXCERPT_LIMIT = 400;
1528
- var answeredRegistryFrom = (comments, botLogin) => {
1529
- const ordered = [...comments].sort(
1530
- (a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? "") || (a.id > b.id ? 1 : a.id < b.id ? -1 : 0)
1531
- );
1532
- const byId = /* @__PURE__ */ new Map();
1533
- for (const c of ordered) byId.set(c.id, c);
1534
- const rootOf = (c) => {
1535
- const chain = [
1536
- ...ancestors(
1537
- c,
1538
- (n) => n.in_reply_to_id === null ? null : byId.get(n.in_reply_to_id) ?? null
1539
- )
1540
- ];
1541
- const root = chain[chain.length - 1];
1542
- return root !== void 0 && root.in_reply_to_id === null ? root : null;
1543
- };
1544
- const findingOf = (root) => {
1545
- const decoded = parseFindingsMarker(root.body ?? "");
1546
- const doc = typeof decoded === "object" && decoded !== null ? decoded.findings : void 0;
1547
- const first = Array.isArray(doc) ? doc[0] : void 0;
1548
- if (first === void 0) return null;
1549
- const title = first["title"];
1550
- const description = first["description"];
1551
- const reasoning = first["reasoning"];
1552
- const id = first["id"];
1553
- const legacyCode = first["code"];
1554
- const severity = first["severity"];
1555
- const path = first["path"];
1556
- const patch = first["patch"];
1557
- return typeof title === "string" && typeof description === "string" && typeof reasoning === "string" && typeof path === "string" && (severity === "critical" || severity === "major" || severity === "minor" || severity === "nit") ? {
1558
- // resolveRuleId: the ONE legacy-spelling precedence the upcast, this reader, and the
1559
- // below-floor nit reader share — a pre-id marker (or one written before the migration)
1560
- // carries `code`; a codeless one resolves to the same synthesized id the registry's
1561
- // legacy upcast derives, so the entry keys to the identical claim on the next round.
1562
- code: resolveRuleId({
1563
- id: typeof id === "string" ? id : void 0,
1564
- code: typeof legacyCode === "string" ? legacyCode : void 0,
1565
- path,
1566
- title
1567
- }) ?? synthesizedFindingId(path, title),
1568
- title,
1569
- description,
1570
- reasoning,
1571
- severity,
1572
- path,
1573
- patch: typeof patch === "string" ? patch : null
1574
- } : null;
1575
- };
1576
- const threads = /* @__PURE__ */ new Map();
1577
- for (const c of ordered) {
1578
- const root = rootOf(c);
1579
- if (root === null) continue;
1580
- const group = threads.get(root.id);
1581
- if (group === void 0) threads.set(root.id, [c]);
1582
- else group.push(c);
1583
- }
1584
- const entries = [];
1585
- for (const [rootId, group] of threads) {
1586
- const root = byId.get(rootId);
1587
- if (root === void 0 || root.user_login !== botLogin) continue;
1588
- const finding = findingOf(root);
1589
- if (finding === null) continue;
1590
- const humanReplies = group.filter(
1591
- (c) => c.id !== root.id && isHuman(c.user_login, c.user_type, botLogin)
1592
- );
1593
- const reply = humanReplies[humanReplies.length - 1];
1594
- if (reply === void 0) continue;
1595
- entries.push({
1596
- ...finding,
1597
- repliedAt: reply.created_at,
1598
- replyId: reply.id,
1599
- threadUrl: root.html_url,
1600
- replyUrl: reply.html_url,
1601
- replyAuthor: reply.user_login,
1602
- replyExcerpt: clipText(reply.body ?? "", EXCERPT_LIMIT)
1603
- });
1604
- }
1605
- const byKey = /* @__PURE__ */ new Map();
1606
- for (const entry of [...entries].sort(
1607
- (a, b) => (b.repliedAt ?? "").localeCompare(a.repliedAt ?? "") || b.replyId - a.replyId
1608
- )) {
1609
- const key2 = answeredNoteKey({ id: entry.code, title: entry.title });
1610
- if (!byKey.has(key2)) byKey.set(key2, entry);
1611
- }
1612
- return [...byKey.values()];
1613
- };
1614
1411
  var isSynthesizedTitleMatch = (f, e) => isSynthesizedFindingId(e.code) && e.title === f.title;
1615
1412
  var matches = (resolvedId, f, e) => e.code === resolvedId || isSynthesizedTitleMatch(f, e);
1413
+ var couldMatch = (code, findings) => isSynthesizedFindingId(code) || findings.some((f) => resolveFindingId(f) === code);
1616
1414
  var VERBATIM_FIELDS = ["title", "description", "reasoning", "severity", "path", "patch"];
1617
1415
  var verbatimFieldEqual = (f, e, field) => field === "patch" ? (f.patch ?? null) === e.patch : f[field] === e[field];
1618
- var isVerbatimReRaise = (f, e) => VERBATIM_FIELDS.every((field) => verbatimFieldEqual(f, e, field));
1416
+ var isVerbatimReRaise = (f, e) => !hasRebuttal(f) && VERBATIM_FIELDS.every((field) => verbatimFieldEqual(f, e, field));
1619
1417
  var isAnsweredDrop = (resolvedId, f, e) => matches(resolvedId, f, e) && isVerbatimReRaise(f, e) && f.severity !== "critical";
1620
1418
  var verbatimMatchCount = (f, e) => VERBATIM_FIELDS.reduce((count, field) => count + (verbatimFieldEqual(f, e, field) ? 1 : 0), 0);
1621
1419
  var answeredNoteKey = (f) => f.id !== "" ? f.id : `title:${f.title}`;
1622
- var NO_TITLE_MATCH = { entry: void 0, score: -1 };
1623
1420
  var bestTitleMatch = (f, registry) => {
1624
1421
  let best;
1625
1422
  let bestScore = -1;
@@ -1632,9 +1429,10 @@ var bestTitleMatch = (f, registry) => {
1632
1429
  if (score === VERBATIM_FIELDS.length) break;
1633
1430
  }
1634
1431
  }
1635
- return { entry: best, score: bestScore };
1432
+ return best;
1636
1433
  };
1637
- var answeredNote = (e) => `Re-raised; prior answer at ${e.replyUrl} by ${e.replyAuthor} \u2014 cite the new evidence that invalidates it.`;
1434
+ var byAuthor = (e) => e.answerAuthor === null ? "" : ` by \`${escapeCodeBackticks(e.answerAuthor)}\``;
1435
+ var answeredNote = (e) => `Re-raised; prior answer at ${linkSafeUrl(e.answerUrl)}${byAuthor(e)} \u2014 cite the new evidence that invalidates it.`;
1638
1436
  var applyAnswered = (findings, registry) => {
1639
1437
  const kept = [];
1640
1438
  const noteEntries = [];
@@ -1644,15 +1442,15 @@ var applyAnswered = (findings, registry) => {
1644
1442
  for (const f of findings) {
1645
1443
  const resolvedId = resolveFindingId(f);
1646
1444
  const idMatch = registry.find((e) => e.code === resolvedId);
1647
- const { entry: titleMatch, score: titleScore } = idMatch === void 0 ? bestTitleMatch(f, registry) : NO_TITLE_MATCH;
1445
+ const titleMatch = idMatch === void 0 ? bestTitleMatch(f, registry) : void 0;
1648
1446
  const entry = idMatch ?? titleMatch;
1649
1447
  if (entry === void 0) {
1650
1448
  kept.push(f);
1651
1449
  continue;
1652
1450
  }
1653
- const dropped = (idMatch !== void 0 ? isVerbatimReRaise(f, entry) : titleScore === VERBATIM_FIELDS.length) && f.severity !== "critical";
1451
+ const dropped = isAnsweredDrop(resolvedId, f, entry);
1654
1452
  if (dropped) {
1655
- droppedByEntry.set(entry.replyId, entry);
1453
+ droppedByEntry.set(entry.code, entry);
1656
1454
  droppedFindingIds.push(resolvedId);
1657
1455
  droppedCount += 1;
1658
1456
  } else {
@@ -1672,42 +1470,33 @@ var answeredReRaiseNote = (entries, count) => {
1672
1470
  if (entries.length === 0) return "";
1673
1471
  const label = (e) => e.code !== "" ? `\`${escapeCodeBackticks(e.code)}\`` : `\u201C${escapeCodeBackticks(e.title)}\u201D`;
1674
1472
  const lines = entries.map(
1675
- (e) => `> - ${label(e)} \u2014 [prior answer](${e.replyUrl}) by ${e.replyAuthor}`
1473
+ (e) => `> - ${label(e)} \u2014 [prior answer](${linkSafeUrl(e.answerUrl)})${byAuthor(e)}`
1676
1474
  );
1677
1475
  return [
1678
- `> \u21A9\uFE0F **${String(count)} finding(s) re-raised without new evidence \u2014 treated as answered** (each has a human reply on its prior inline thread):`,
1476
+ `> \u21A9\uFE0F **${String(count)} finding(s) re-raised without new evidence \u2014 treated as answered** (a maintainer refuted or dismissed each):`,
1679
1477
  ...lines
1680
1478
  ].join("\n");
1681
1479
  };
1682
- var fetchThreadComments = async (ghApi, repo, prNumber) => {
1683
- try {
1684
- const rows = parseJsonl(
1685
- await ghApi([
1686
- // per_page rides the QUERY string — a `-f` field would flip `gh api` from GET to POST
1687
- // and 422 every answered-thread fetch.
1688
- `repos/${repo}/pulls/${String(prNumber)}/comments?per_page=100`,
1689
- "--paginate",
1690
- "--jq",
1691
- THREAD_COMMENT_JQ
1692
- ])
1693
- );
1694
- return rows.flatMap((row) => {
1695
- const decoded = ThreadCommentCodec.decode(row);
1696
- return decoded._tag === "Right" ? [decoded.right] : [];
1697
- });
1698
- } catch (err) {
1699
- process.stderr.write(
1700
- `Warning: could not fetch review threads to detect answered findings (${errMsg(err)}) \u2014 no answered-finding state for this post
1701
- `
1702
- );
1703
- return null;
1704
- }
1705
- };
1480
+ var DispositionCodec = t.keyof({ fixed: null, refuted: null, dismissed: null });
1481
+ var DISPOSITIONS = Object.keys(DispositionCodec.keys);
1482
+ var ResponseDispositionCodec = t.keyof({
1483
+ fixed: null,
1484
+ refuted: null,
1485
+ dismissed: null,
1486
+ unstated: null
1487
+ });
1488
+ var RESPONSE_FORM = `Review-Response: <id> ${DISPOSITIONS.join("|")} \u2014 <reason>`;
1489
+ var RESPONSE_TABLE_COLUMNS = ["id", "disposition", "reason"];
1490
+ `| ${RESPONSE_TABLE_COLUMNS.join(" | ")} |`;
1491
+ `| ${RESPONSE_TABLE_COLUMNS.map(() => "---").join(" | ")} |`;
1492
+ var listed = (words, conjunction) => {
1493
+ const quoted = words.map((word) => `\`${word}\``);
1494
+ return quoted.length < 2 ? quoted.join("") : `${quoted.slice(0, -1).join(", ")} ${conjunction} ${quoted.at(-1) ?? ""}`;
1495
+ };
1496
+ var RESPONSE_TEACHING = `To answer findings, post a PR conversation comment holding a rendered markdown table, not one inside a code block, whose columns are ${listed(RESPONSE_TABLE_COLUMNS, "and")}, one row per finding id, its disposition exactly one of ${listed(DISPOSITIONS, "or")}; or put \`${RESPONSE_FORM}\` lines in a commit message. Inline-thread replies are not read. A systemic problem's id, when shown, answers its whole class.`;
1706
1497
 
1707
1498
  // src/render.ts
1708
1499
  var escapePipes = (text) => text.replace(/\|/g, "\\|");
1709
- var encodeAutolinkParens = (url) => url.replace(/\(/g, "%28").replace(/\)/g, "%29");
1710
- var linkSafeUrl = (url) => encodeAutolinkParens(escapeCodeBackticks(url));
1711
1500
  var shedNote = (count, summaryAvailable, runUrl, findingsDocLinked, thisRunArtifact) => count === 0 ? "" : `<sub>**${String(count)} finding${count === 1 ? "" : "s"} left out of this comment**${summaryAvailable && runUrl !== void 0 ? ` \u2014 the [run summary](<${runUrl}>) carries the whole review` : findingsDocLinked && thisRunArtifact ? " \u2014 the machine findings document carries them" : ""}.</sub>`;
1712
1501
  var terminalNotice = (i) => [
1713
1502
  `## ${i.verdictBadge}`,
@@ -1824,6 +1613,7 @@ var sanitizeSuppressedNit = (f, discussion, discussionTruncated) => ({
1824
1613
  var carriedLines = (f) => [
1825
1614
  `description: ${clipText(f.description, BODY_CLIP_CHARS)}`,
1826
1615
  ...f.recommendation !== void 0 ? [`recommendation: ${clipText(f.recommendation, BODY_CLIP_CHARS)}`] : [],
1616
+ ...hasRebuttal(f) ? [`rebuttal: ${clipText(f.rebuttal ?? "", BODY_CLIP_CHARS)}`] : [],
1827
1617
  `reasoning: ${clipText(f.reasoning, BODY_CLIP_CHARS)}`,
1828
1618
  ...f.patch !== void 0 ? ["patch:", clipText(f.patch, BODY_CLIP_CHARS)] : []
1829
1619
  ].flatMap((block) => commentSafe(block).split("\n")).map((line) => line.trimEnd());
@@ -1992,6 +1782,9 @@ var render = (input) => {
1992
1782
  input.findingsPointer ?? findingsMarkerPair(input.jsonUrl, input.findings.convergence)
1993
1783
  ),
1994
1784
  summaryAvailable: input.summaryAvailable ?? false,
1785
+ responseForm: RESPONSE_FORM,
1786
+ responseTeaching: RESPONSE_TEACHING,
1787
+ teachesResponses: isConvergenceRound(route, incomplete),
1995
1788
  shedNote: shedNote(
1996
1789
  input.shedCount ?? 0,
1997
1790
  input.summaryAvailable ?? false,
@@ -2083,7 +1876,8 @@ var render = (input) => {
2083
1876
  },
2084
1877
  verdictBadge,
2085
1878
  severityEmoji,
2086
- formatConfidence
1879
+ formatConfidence,
1880
+ hasRebuttal
2087
1881
  });
2088
1882
  };
2089
1883
  var key = (path, line) => `${path}:${String(line)}`;
@@ -2146,9 +1940,11 @@ var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer, sameRoo
2146
1940
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
2147
1941
  eta.renderString(template, {
2148
1942
  ...f,
1943
+ idLabel: escapeCodeBackticks(f.id),
2149
1944
  patchProjection: projectPatch(f.patch, "diff-anchored"),
2150
1945
  severityEmoji,
2151
1946
  formatConfidence,
1947
+ hasRebuttal,
2152
1948
  modelsText,
2153
1949
  jsonUrl: jsonUrl ?? null,
2154
1950
  findingsPointer: pointer,
@@ -2170,6 +1966,7 @@ var buildInlineComments = (findings, diff, context) => {
2170
1966
  title: clipText(f.title, BODY_CLIP_CHARS),
2171
1967
  description: clipText(f.description, BODY_CLIP_CHARS),
2172
1968
  ...f.recommendation != null ? { recommendation: clipText(f.recommendation, BODY_CLIP_CHARS) } : {},
1969
+ ...hasRebuttal(f) ? { rebuttal: clipText(f.rebuttal ?? "", BODY_CLIP_CHARS) } : {},
2173
1970
  reasoning: clipText(f.reasoning, BODY_CLIP_CHARS),
2174
1971
  ...f.patch != null ? { patch: clipText(f.patch, BODY_CLIP_CHARS) } : {}
2175
1972
  } : f;
@@ -2204,7 +2001,7 @@ var buildInlineComments = (findings, diff, context) => {
2204
2001
  var renderStraysSection = (strays) => {
2205
2002
  if (strays.length === 0) return "";
2206
2003
  const items = strays.map(
2207
- (f) => `- **${f.severity}** \xB7 \`${f.path}:${String(f.start_line)}\` \u2014 ${f.title}`
2004
+ (f) => `- **${f.severity}** \xB7 \`${f.path}:${String(f.start_line)}\` \u2014 ${f.title}${f.id === "" ? "" : ` \xB7 id \`${escapeCodeBackticks(f.id)}\``}`
2208
2005
  );
2209
2006
  return [
2210
2007
  "",
@@ -2215,6 +2012,110 @@ var renderStraysSection = (strays) => {
2215
2012
  ...items
2216
2013
  ].join("\n");
2217
2014
  };
2015
+ var numField = (rec, key2) => {
2016
+ const v = rec[key2];
2017
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
2018
+ };
2019
+ var messageUsage = (entry) => {
2020
+ const rec = asRecord(entry);
2021
+ if (rec === null || rec["type"] !== "assistant") return null;
2022
+ const msg = asRecord(rec["message"]);
2023
+ if (msg === null) return null;
2024
+ const model = msg["model"];
2025
+ const usage = asRecord(msg["usage"]);
2026
+ if (typeof model !== "string" || usage === null) return null;
2027
+ const id = msg["id"];
2028
+ return {
2029
+ id: typeof id === "string" ? id : null,
2030
+ model: modelIdentity(model),
2031
+ input: numField(usage, "input_tokens"),
2032
+ output: numField(usage, "output_tokens"),
2033
+ cacheRead: numField(usage, "cache_read_input_tokens"),
2034
+ cacheWrite: numField(usage, "cache_creation_input_tokens")
2035
+ };
2036
+ };
2037
+ var tsMsOf = (entry) => {
2038
+ const rec = asRecord(entry);
2039
+ const ts = rec?.["timestamp"];
2040
+ if (typeof ts !== "string") return null;
2041
+ const ms = Date.parse(ts);
2042
+ return Number.isNaN(ms) ? null : ms;
2043
+ };
2044
+ var EMPTY_TOTALS = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
2045
+ var parseJsonl = (text) => text.split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
2046
+ try {
2047
+ return [JSON.parse(line)];
2048
+ } catch {
2049
+ return [];
2050
+ }
2051
+ });
2052
+ var sumTranscriptUsage = (entries) => {
2053
+ const summed = entries.reduce(
2054
+ (acc, entry) => {
2055
+ const u = messageUsage(entry);
2056
+ if (u === null) return acc;
2057
+ if (u.id !== null && acc.seen.has(u.id)) return acc;
2058
+ if (u.id !== null) acc.seen.add(u.id);
2059
+ const prev = acc.totals.get(u.model) ?? EMPTY_TOTALS;
2060
+ acc.totals.set(u.model, {
2061
+ input: prev.input + u.input,
2062
+ output: prev.output + u.output,
2063
+ cacheRead: prev.cacheRead + u.cacheRead,
2064
+ cacheWrite: prev.cacheWrite + u.cacheWrite
2065
+ });
2066
+ return { totals: acc.totals, turns: acc.turns + 1, seen: acc.seen };
2067
+ },
2068
+ { totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
2069
+ );
2070
+ const models = [...summed.totals].map(([model, t9]) => ({
2071
+ model,
2072
+ input_tokens: t9.input,
2073
+ output_tokens: t9.output,
2074
+ cache_read_tokens: t9.cacheRead,
2075
+ cache_write_tokens: t9.cacheWrite
2076
+ }));
2077
+ const bounds = entries.reduce(
2078
+ (acc, entry) => {
2079
+ const ms = tsMsOf(entry);
2080
+ if (ms === null) return acc;
2081
+ return {
2082
+ min: acc.min === null || ms < acc.min ? ms : acc.min,
2083
+ max: acc.max === null || ms > acc.max ? ms : acc.max
2084
+ };
2085
+ },
2086
+ { min: null, max: null }
2087
+ );
2088
+ return {
2089
+ models,
2090
+ turns: summed.turns,
2091
+ durationMs: bounds.min !== null && bounds.max !== null ? bounds.max - bounds.min : 0,
2092
+ firstTsMs: bounds.min,
2093
+ lastTsMs: bounds.max
2094
+ };
2095
+ };
2096
+ var subagentFiles = (mainPath) => {
2097
+ const dir = join(dirname(mainPath), basename(mainPath, ".jsonl"), "subagents");
2098
+ try {
2099
+ return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
2100
+ } catch {
2101
+ return [];
2102
+ }
2103
+ };
2104
+ var readTranscriptTree = (mainPath) => {
2105
+ const mainText = readFileOrNull(mainPath);
2106
+ const mainEntries = mainText === null ? [] : parseJsonl(mainText);
2107
+ const inlineSidechains = mainEntries.some((e) => asRecord(e)?.["isSidechain"] === true);
2108
+ const siblings = inlineSidechains ? [] : subagentFiles(mainPath);
2109
+ const siblingReads = siblings.flatMap((path) => {
2110
+ const text = readFileOrNull(path);
2111
+ return text === null ? [] : [{ path, entries: parseJsonl(text) }];
2112
+ });
2113
+ return {
2114
+ entries: [...mainEntries, ...siblingReads.flatMap((r) => r.entries)],
2115
+ files: [...mainText === null ? [] : [mainPath], ...siblingReads.map((r) => r.path)],
2116
+ missing: mainText === null
2117
+ };
2118
+ };
2218
2119
  var addFormats = _addFormats;
2219
2120
  var validatorCache = /* @__PURE__ */ new Map();
2220
2121
  var compileSchema = (schemaPath) => {
@@ -2396,7 +2297,7 @@ var writesToDraft = (toolName, toolInput, draftPath) => {
2396
2297
  const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
2397
2298
  if (WRITE_TOOLS.has(toolName)) {
2398
2299
  const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
2399
- if (typeof fp === "string" && targets.some((t8) => fp === t8 || basename(fp) === basename(t8)))
2300
+ if (typeof fp === "string" && targets.some((t9) => fp === t9 || basename(fp) === basename(t9)))
2400
2301
  return true;
2401
2302
  }
2402
2303
  if (toolName === "Bash") {
@@ -2423,7 +2324,6 @@ var sidecarPath = (draftPath, postfix) => {
2423
2324
  return join(dirname(draftPath), `${basename(draftPath, ext)}${postfix}${ext}`);
2424
2325
  };
2425
2326
  var priorContextPath = (draftPath) => sidecarPath(draftPath, ".prior");
2426
- var priorAnswersPath = (draftPath) => sidecarPath(draftPath, ".prior-answers");
2427
2327
  var priorSuppressedPath = (draftPath) => sidecarPath(draftPath, ".prior-suppressed");
2428
2328
  var lastValidPath = (draftPath) => sidecarPath(draftPath, ".last-valid");
2429
2329
  var costAxisDisengagedPath = (draftPath) => sidecarPath(draftPath, ".cost-axis-disengaged");
@@ -2502,9 +2402,9 @@ var parseWallMs = (raw) => {
2502
2402
  };
2503
2403
  var parseEpochSecMs = (raw) => {
2504
2404
  if (raw === void 0) return null;
2505
- const t8 = raw.trim();
2506
- if (!/^\d+$/.test(t8)) return null;
2507
- const n = Number.parseInt(t8, 10);
2405
+ const t9 = raw.trim();
2406
+ if (!/^\d+$/.test(t9)) return null;
2407
+ const n = Number.parseInt(t9, 10);
2508
2408
  return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
2509
2409
  };
2510
2410
  var anchoredElapsedMs = (src) => {
@@ -2625,10 +2525,12 @@ var findingsTable = [
2625
2525
  {
2626
2526
  minor: "0.4",
2627
2527
  defaultVersion: "0.4.0",
2628
- // The frozen tolerant-in legacy schema, NOT the live 0.10 file: every ajv-gated channel
2528
+ // The frozen tolerant-in legacy schema, NOT the live file: every ajv-gated channel
2629
2529
  // (validate --schema-version, the extraction ladder) dispatches the RAW doc's declared minor
2630
2530
  // through schemaPathFor, so the ajv gate must accept exactly what the tolerant legacy codec
2631
2531
  // accepts — the live file (id required) would reject the legacy docs the upcast promises to read.
2532
+ // One deliberate exception: the codec drops a draft-only `rebuttal` that the frozen file rejects.
2533
+ // A pre-0.10 document never carried one, so only a hybrid doc the rescue exists for meets it.
2632
2534
  schemaFile: "v0.9/findings.schema.json",
2633
2535
  codec: legacyFindingsCodec,
2634
2536
  normalize: legacyFindingsNormalize,
@@ -2660,6 +2562,14 @@ var findingsTable = [
2660
2562
  },
2661
2563
  {
2662
2564
  minor: "0.10",
2565
+ defaultVersion: "0.10.0",
2566
+ schemaFile: "findings.schema.json",
2567
+ codec: FindingsCodec,
2568
+ normalize: identity,
2569
+ latest: false
2570
+ },
2571
+ {
2572
+ minor: "0.11",
2663
2573
  defaultVersion: DEFAULT_SCHEMA_VERSION,
2664
2574
  schemaFile: "findings.schema.json",
2665
2575
  codec: FindingsCodec,
@@ -2714,21 +2624,28 @@ var describeValidationError = (e) => {
2714
2624
  var formatErrors = (errors) => errors.map(describeValidationError);
2715
2625
  var declaredVersion = (raw) => typeof raw === "object" && raw !== null && "schema_version" in raw ? typeof raw.schema_version === "string" ? raw.schema_version : void 0 : void 0;
2716
2626
  var supportedVersions = (kind) => tableFor(kind).map((entry) => entry.minor);
2717
- var defaultVersion = (kind) => {
2718
- const latest = tableFor(kind).find((entry) => entry.latest);
2719
- if (!latest) throw new Error(`Registry invariant violated \u2014 no latest entry for "${kind}"`);
2720
- return latest.defaultVersion;
2721
- };
2722
2627
  var bundledSchemaPath = (relativePath) => resolve(import.meta.dirname, "..", "schema", relativePath);
2723
- var schemaPathFor = (kind, version) => {
2628
+ var entryFor = (kind, version) => {
2724
2629
  const table = tableFor(kind);
2725
2630
  const entry = version === void 0 ? table.find((v) => v.latest) : table.find((v) => v.minor === majorMinor(version));
2631
+ if (!entry && version === void 0) {
2632
+ throw new Error(`Registry invariant violated \u2014 no latest entry for "${kind}"`);
2633
+ }
2726
2634
  if (!entry) {
2727
2635
  throw new Error(
2728
2636
  `Unsupported ${kind} schema version "${version ?? ""}" \u2014 supported: ${supportedVersions(kind).join(", ")}`
2729
2637
  );
2730
2638
  }
2731
- return bundledSchemaPath(entry.schemaFile);
2639
+ return entry;
2640
+ };
2641
+ var schemaPathFor = (kind, version) => bundledSchemaPath(entryFor(kind, version).schemaFile);
2642
+ var versionFor = (kind, version) => entryFor(kind, version).defaultVersion;
2643
+ var defaultVersion = (kind) => versionFor(kind);
2644
+ var livePinFor = (kind, version) => {
2645
+ const table = tableFor(kind);
2646
+ const latest = table.find((v) => v.latest);
2647
+ const entry = version === void 0 ? latest : table.find((v) => v.minor === majorMinor(version));
2648
+ return entry !== void 0 && entry.schemaFile === latest?.schemaFile ? entry.defaultVersion : void 0;
2732
2649
  };
2733
2650
  var resolveFindings = (raw) => {
2734
2651
  const version = declaredVersion(raw);
@@ -2762,7 +2679,7 @@ var resolveTolerantFindings = (doc) => {
2762
2679
  };
2763
2680
 
2764
2681
  // src/pr.ts
2765
- var CANDIDATE_JQ = ".[] | {number: .number, state: .state, headRef: .head.ref, headSha: .head.sha}";
2682
+ var CANDIDATE_JQ = ".[] | {number: .number, state: .state, headRef: .head.ref, headSha: .head.sha, headRepo: (.head.repo.full_name // null)}";
2766
2683
  var parseCandidates = (stdout) => parseJsonl(stdout);
2767
2684
  var fetchDirectCandidates = async (repo, headSha, ghApi) => {
2768
2685
  try {
@@ -2791,7 +2708,7 @@ var resolvePr = (candidates, headBranch) => {
2791
2708
  const scoped = candidates.length > 1 && headBranch ? candidates.filter((c) => c.headRef === headBranch) : candidates;
2792
2709
  const chosen = scoped.find((c) => c.state === "open") ?? scoped[0] ?? candidates[0];
2793
2710
  if (chosen === void 0) return { kind: "none" };
2794
- return chosen.state === "open" ? { kind: "open", prNumber: chosen.number } : { kind: "not-open", prNumber: chosen.number, state: chosen.state };
2711
+ return chosen.state === "open" ? { kind: "open", prNumber: chosen.number, headRepo: chosen.headRepo ?? null } : { kind: "not-open", prNumber: chosen.number, state: chosen.state };
2795
2712
  };
2796
2713
  var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
2797
2714
  `repos/${repo}/pulls/${String(prNumber)}`,
@@ -2900,6 +2817,220 @@ var checkRun = async (input, ghApi = runGhApi) => {
2900
2817
  ];
2901
2818
  await ghApi(endpoint, JSON.stringify(body));
2902
2819
  };
2820
+ var RESPONSE_REASON_CLIP_CHARS = 300;
2821
+ var RESPONSES_PER_CHANNEL = 25;
2822
+ var ChannelCodec = t.keyof({ comment: null, commit: null });
2823
+ var MAINTAINER_ASSOCIATIONS = ["OWNER", "MEMBER", "COLLABORATOR"];
2824
+ var CLOSING_DISPOSITIONS = /* @__PURE__ */ new Set(["refuted", "dismissed"]);
2825
+ var ResponseShape = t.type({
2826
+ id: t.string,
2827
+ disposition: ResponseDispositionCodec,
2828
+ reason: t.string,
2829
+ channel: ChannelCodec,
2830
+ source_url: t.string,
2831
+ author: t.union([t.string, t.null]),
2832
+ author_association: t.union([t.string, t.null]),
2833
+ created_at: t.union([t.string, t.null])
2834
+ });
2835
+ var ResponseCodec = strictExact("Response", ResponseShape, [ResponseShape]);
2836
+ var UnmatchedResponseShape = t.type({
2837
+ id: t.string,
2838
+ channel: ChannelCodec,
2839
+ source_url: t.string
2840
+ });
2841
+ var UnmatchedResponseCodec = strictExact("UnmatchedResponse", UnmatchedResponseShape, [
2842
+ UnmatchedResponseShape
2843
+ ]);
2844
+ var ResponsesFileShape = t.type({
2845
+ responses: t.array(ResponseCodec),
2846
+ unmatched: t.array(UnmatchedResponseCodec)
2847
+ });
2848
+ strictExact("ResponsesFile", ResponsesFileShape, [
2849
+ ResponsesFileShape
2850
+ ]);
2851
+ var RESPONSE_LINE_RE = /^ {0,3}review-response:\s*(\S+)\s+([A-Za-z]+)(?![A-Za-z]|\s*\|)[\s—–:-]*(.*)$/i;
2852
+ var FENCE_OPEN_RE = /^ {0,3}(`{3,}(?=[^`]*$)|~{3,})/;
2853
+ var LINE_BREAK_RE = /\r\n?|\n|\u2028|\u2029/;
2854
+ var unfencedLines = (text) => text.split(LINE_BREAK_RE).reduce(
2855
+ (state, line) => {
2856
+ const marker = FENCE_OPEN_RE.exec(line)?.[1];
2857
+ if (state.fence === null) {
2858
+ if (marker !== void 0) return { fence: marker, lines: state.lines };
2859
+ state.lines.push(line);
2860
+ return state;
2861
+ }
2862
+ const closes = marker !== void 0 && marker[0] === state.fence[0] && marker.length >= state.fence.length && line.trim() === marker;
2863
+ return closes ? { fence: null, lines: state.lines } : state;
2864
+ },
2865
+ { fence: null, lines: [] }
2866
+ ).lines;
2867
+ var WHOLE_LINE_CODE_SPAN_RE = /^ {0,3}`([^`]+)`\s*$/;
2868
+ var answerLines = (text) => unfencedLines(text).map((line) => WHOLE_LINE_CODE_SPAN_RE.exec(line)?.[1] ?? line);
2869
+ var unwrapId = (token) => token.replace(/^[`"']+|[`"'.,:;]+$/g, "");
2870
+ var lineAnswers = (lines) => lines.flatMap((line) => {
2871
+ const match = RESPONSE_LINE_RE.exec(line);
2872
+ const id = unwrapId(match?.[1] ?? "");
2873
+ const disposition = match?.[2]?.toLowerCase();
2874
+ return id !== "" && DispositionCodec.is(disposition) ? [
2875
+ {
2876
+ id,
2877
+ disposition,
2878
+ reason: clipText((match?.[3] ?? "").trim(), RESPONSE_REASON_CLIP_CHARS)
2879
+ }
2880
+ ] : [];
2881
+ });
2882
+ var ID_IN_CELL_RE = /`([^`]+)`/g;
2883
+ var ID_HEADERS = /* @__PURE__ */ new Set(["id", "ids", "finding", "findings"]);
2884
+ var DISPOSITION_HEADERS = /* @__PURE__ */ new Set(["disposition", "verdict", "resolution"]);
2885
+ var CELL_RE = /((?:\\[\s\S]?|[^\\|])*)(\||$)/g;
2886
+ var splitCells = (row) => {
2887
+ const cells = [...row.matchAll(CELL_RE)];
2888
+ return cells.slice(0, cells.findIndex((match) => match[2] !== "|") + 1).map((match) => match[1] ?? "");
2889
+ };
2890
+ var BLOCK_START_RE = /^ {0,3}([-+*]\s|\d+[.)]\s|#{1,6}(\s|$)|>)/;
2891
+ var tableRow = (line) => {
2892
+ if (!/^ {0,3}\S/.test(line) || BLOCK_START_RE.test(line)) return null;
2893
+ const split = splitCells(line.trim());
2894
+ if (split.length < 2) return null;
2895
+ const inner = split.slice(line.trim().startsWith("|") ? 1 : 0);
2896
+ return (inner.length > 1 && inner[inner.length - 1]?.trim() === "" ? inner.slice(0, -1) : inner).map((cell) => cell.trim().replace(/\\\|/g, "|"));
2897
+ };
2898
+ var isDelimiter = (cells) => cells !== null && cells.every((cell) => /^:?-+:?$/.test(cell));
2899
+ var plainCell = (cell) => cell.replace(/[*_`]/g, "").trim().toLowerCase();
2900
+ var columnName = (cell) => plainCell(cell).split(/[\s/]+/)[0] ?? "";
2901
+ var idsInCell = (cell) => {
2902
+ const ids = cell.replace(/\([^()]*\)/g, "");
2903
+ const quoted = [...ids.matchAll(ID_IN_CELL_RE)].map((match) => unwrapId((match[1] ?? "").trim())).filter((id) => id !== "");
2904
+ const bare = ids.split(",").map((piece) => unwrapId(piece.trim())).filter((id) => id !== "");
2905
+ return quoted.length > 0 ? quoted : bare.every((id) => /^\S+$/.test(id)) ? bare : [];
2906
+ };
2907
+ var tableDisposition = (cell) => {
2908
+ const firstWord = plainCell(cell).replace(/^[^a-z]+/, "").split(/[^a-z]/)[0] ?? "";
2909
+ return DispositionCodec.is(firstWord) ? firstWord : "unstated";
2910
+ };
2911
+ var rowAnswers = (cells, columns) => {
2912
+ const dispositionCell = cells[columns.disposition] ?? "";
2913
+ const disposition = tableDisposition(dispositionCell);
2914
+ const verdictOnly = DispositionCodec.is(plainCell(dispositionCell));
2915
+ const reason = clipText(
2916
+ cells.filter(
2917
+ (cell, column) => cell !== "" && column !== columns.id && !(verdictOnly && column === columns.disposition)
2918
+ ).join(" \u2014 "),
2919
+ RESPONSE_REASON_CLIP_CHARS
2920
+ );
2921
+ return idsInCell(cells[columns.id] ?? "").map((id) => ({ id, disposition, reason }));
2922
+ };
2923
+ var namesAColumn = (name) => ID_HEADERS.has(name) || DISPOSITION_HEADERS.has(name);
2924
+ var tableAnswers = (lines) => {
2925
+ const rows = lines.map(tableRow);
2926
+ return rows.reduce(
2927
+ (state, cells, index) => {
2928
+ if (state.skipDelimiter) return { ...state, skipDelimiter: false };
2929
+ if (cells === null) return { ...state, inTable: false, columns: null };
2930
+ const opensTable = !isDelimiter(cells) && isDelimiter(rows[index + 1] ?? null) && (!state.inTable || cells.map(columnName).some(namesAColumn));
2931
+ if (opensTable) {
2932
+ const header = cells.map(columnName);
2933
+ const id = header.findIndex((name) => ID_HEADERS.has(name));
2934
+ const disposition = header.findIndex((name) => DISPOSITION_HEADERS.has(name));
2935
+ return {
2936
+ ...state,
2937
+ inTable: true,
2938
+ skipDelimiter: true,
2939
+ columns: id < 0 || disposition < 0 ? null : { id, disposition }
2940
+ };
2941
+ }
2942
+ if (state.inTable && state.columns !== null && !isDelimiter(cells)) {
2943
+ state.answers.push(...rowAnswers(cells, state.columns));
2944
+ }
2945
+ return isDelimiter(rows[index + 1] ?? null) ? { ...state, skipDelimiter: true } : state;
2946
+ },
2947
+ { columns: null, inTable: false, skipDelimiter: false, answers: [] }
2948
+ ).answers;
2949
+ };
2950
+ var parseResponses = (text) => {
2951
+ const lines = answerLines(text);
2952
+ const seen = /* @__PURE__ */ new Set();
2953
+ return [...lineAnswers(lines), ...tableAnswers(lines)].filter((answer) => {
2954
+ const key2 = JSON.stringify([answer.id, answer.disposition, answer.reason]);
2955
+ if (seen.has(key2)) return false;
2956
+ seen.add(key2);
2957
+ return true;
2958
+ });
2959
+ };
2960
+ var isHuman = (login, type8, botLogin) => login !== botLogin && type8 === "User";
2961
+ var newestFirst = (a, b) => {
2962
+ const [left2, right2] = [a.created_at ?? "", b.created_at ?? ""];
2963
+ return left2 < right2 ? 1 : left2 > right2 ? -1 : b.id - a.id;
2964
+ };
2965
+ var answersFrom = (input) => {
2966
+ const comments = [...input.comments].filter((comment) => isHuman(comment.user.login, comment.user.type ?? null, input.botLogin)).sort(newestFirst).flatMap(
2967
+ (comment) => parseResponses(comment.body ?? "").map((line) => ({
2968
+ ...line,
2969
+ channel: "comment",
2970
+ source_url: `https://github.com/${input.repo}/pull/${String(input.prNumber)}#issuecomment-${String(comment.id)}`,
2971
+ author: comment.user.login,
2972
+ author_association: comment.author_association ?? null,
2973
+ created_at: comment.created_at ?? null
2974
+ }))
2975
+ );
2976
+ const commits = [...input.commits].reverse().flatMap(
2977
+ (commit) => parseResponses(commit.message).map((line) => ({
2978
+ ...line,
2979
+ channel: "commit",
2980
+ source_url: `https://github.com/${input.repo}/commit/${commit.sha}`,
2981
+ author: commit.author,
2982
+ author_association: null,
2983
+ created_at: commit.date ?? null
2984
+ }))
2985
+ );
2986
+ return { comments, commits };
2987
+ };
2988
+ var harvestResponses = (input) => {
2989
+ const { comments: fromComments, commits: fromCommits } = answersFrom(input);
2990
+ const matched = (response) => input.priorIds.has(response.id);
2991
+ const echoed = (response) => !matched(response) && ID_SHAPE_RE.test(response.id);
2992
+ const groups = [fromComments, fromCommits].flatMap((channel) => [
2993
+ channel.filter(matched),
2994
+ channel.filter(echoed)
2995
+ ]);
2996
+ const [comments, commentEchoes, commits, commitEchoes] = groups.map(
2997
+ (group) => group.slice(0, RESPONSES_PER_CHANNEL)
2998
+ );
2999
+ return {
3000
+ file: {
3001
+ responses: [...comments ?? [], ...commits ?? []],
3002
+ unmatched: [...commentEchoes ?? [], ...commitEchoes ?? []].map(
3003
+ ({ id, channel, source_url }) => ({ id, channel, source_url })
3004
+ )
3005
+ },
3006
+ dropped: groups.reduce(
3007
+ (total, group) => total + Math.max(0, group.length - RESPONSES_PER_CHANNEL),
3008
+ 0
3009
+ )
3010
+ };
3011
+ };
3012
+ var isTrustedResponse = (response, headInBaseRepo) => {
3013
+ switch (response.channel) {
3014
+ case "comment":
3015
+ return MAINTAINER_ASSOCIATIONS.includes(response.author_association ?? "");
3016
+ case "commit":
3017
+ return headInBaseRepo;
3018
+ }
3019
+ };
3020
+ var answeredInstant = (response) => response.created_at === null ? Number.NaN : Date.parse(response.created_at);
3021
+ var closingResponses = (responses, isTrusted) => {
3022
+ const byId = responses.filter((response) => response.disposition !== "unstated" && isTrusted(response)).reduce((groups, response) => {
3023
+ const timed = { response, at: answeredInstant(response) };
3024
+ groups.set(response.id, [...groups.get(response.id) ?? [], timed]);
3025
+ return groups;
3026
+ }, /* @__PURE__ */ new Map());
3027
+ return [...byId.values()].flatMap((answers) => {
3028
+ if (answers.some(({ at }) => Number.isNaN(at))) return [];
3029
+ const latest = Math.max(...answers.map(({ at }) => at));
3030
+ const newest = answers.filter(({ at }) => at === latest).map(({ response }) => response);
3031
+ return newest.every((response) => CLOSING_DISPOSITIONS.has(response.disposition)) ? [...newest].sort((a, b) => a.source_url < b.source_url ? -1 : 1).slice(0, 1) : [];
3032
+ });
3033
+ };
2903
3034
 
2904
3035
  // src/post.ts
2905
3036
  var STICKY_CHAR_LIMIT = 65536;
@@ -3095,7 +3226,8 @@ var postInlineReview = async (pr, comments, inDiff, ghApi) => {
3095
3226
  return { url, posted, inlinePosted, unposted };
3096
3227
  }
3097
3228
  };
3098
- var ISSUE_COMMENTS_JQ = '.[] | {id, user: (.user.login // "(deleted)"), created_at, html_url, body: (.body // "")}';
3229
+ var ISSUE_COMMENTS_JQ = '.[] | {id, user: (.user.login // "(deleted)"), user_type: (.user.type // null), author_association: (.author_association // null), created_at, html_url, body: (.body // "")}';
3230
+ var nullableString = (value) => typeof value === "string" ? value : null;
3099
3231
  var parseIssueCommentRows = (raw) => {
3100
3232
  let malformed = 0;
3101
3233
  const rows = [];
@@ -3114,7 +3246,15 @@ var parseIssueCommentRows = (raw) => {
3114
3246
  const url = rec?.["html_url"];
3115
3247
  const body = rec?.["body"];
3116
3248
  if (typeof id === "number" && typeof author === "string" && typeof created === "string" && typeof url === "string" && typeof body === "string") {
3117
- rows.push({ id, author, created, url, body });
3249
+ rows.push({
3250
+ id,
3251
+ author,
3252
+ created,
3253
+ url,
3254
+ body,
3255
+ authorType: nullableString(rec?.["user_type"]),
3256
+ association: nullableString(rec?.["author_association"])
3257
+ });
3118
3258
  } else {
3119
3259
  malformed += 1;
3120
3260
  }
@@ -3124,6 +3264,45 @@ var parseIssueCommentRows = (raw) => {
3124
3264
  );
3125
3265
  return { rows, malformed };
3126
3266
  };
3267
+ var COMMITS_JQ = ".[] | {sha, message: .commit.message, author: (.author.login // null), date: (.commit.author.date // null)}";
3268
+ var fetchCommitAnswersSource = async (repo, prNumber, ghApi) => {
3269
+ try {
3270
+ const raw = await ghApi([
3271
+ `repos/${repo}/pulls/${String(prNumber)}/commits?per_page=100`,
3272
+ "--paginate",
3273
+ "--jq",
3274
+ COMMITS_JQ
3275
+ ]);
3276
+ const lines = raw.split("\n").filter((line) => line.trim() !== "");
3277
+ const commits = lines.flatMap((line) => {
3278
+ const parsed = tryParseJson(line.trim());
3279
+ const rec = parsed.ok ? asRecord(parsed.value) : null;
3280
+ const sha = rec?.["sha"];
3281
+ const message = rec?.["message"];
3282
+ return typeof sha === "string" && typeof message === "string" ? [
3283
+ {
3284
+ sha,
3285
+ message,
3286
+ author: nullableString(rec?.["author"]),
3287
+ date: nullableString(rec?.["date"])
3288
+ }
3289
+ ] : [];
3290
+ });
3291
+ if (commits.length < lines.length) {
3292
+ process.stderr.write(
3293
+ `Warning: ${String(lines.length - commits.length)} of the PR's commits did not decode \u2014 their answers close nothing
3294
+ `
3295
+ );
3296
+ }
3297
+ return commits;
3298
+ } catch (err) {
3299
+ process.stderr.write(
3300
+ `Warning: could not read the PR's commits (${errMsg(err)}) \u2014 no commit answer closes a finding this round
3301
+ `
3302
+ );
3303
+ return [];
3304
+ }
3305
+ };
3127
3306
  var fetchIssueCommentRows = async (repo, prNumber, ghApi) => {
3128
3307
  const raw = await ghApi([
3129
3308
  // per_page rides the QUERY string — a `-f` field would flip `gh api` from GET to POST
@@ -3194,7 +3373,6 @@ var discussionRows = (rows, stickyId, botLogin) => {
3194
3373
  return c.id !== stickyId && c.author !== botLogin;
3195
3374
  });
3196
3375
  };
3197
- var ID_SHAPE_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
3198
3376
  var mentionsOutsideKnown = (reachable, currentIds) => {
3199
3377
  const known = new Set(currentIds);
3200
3378
  const escapedToRaw = escapedIdIndex(currentIds);
@@ -3279,24 +3457,6 @@ var buildStickyDiscussion = (reachable, currentIds, priorIds) => {
3279
3457
  orphanedTruncated: Object.fromEntries(orphanedTruncated)
3280
3458
  };
3281
3459
  };
3282
- var priorIdsFrom = (doc) => {
3283
- if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return [];
3284
- const rec = doc;
3285
- const idsOf = (field, systemic) => (Array.isArray(rec[field]) ? rec[field] : []).flatMap((raw) => {
3286
- const item = typeof raw === "object" && raw !== null ? asRecord(raw) : null;
3287
- if (item === null) return [];
3288
- const title = typeof item["title"] === "string" ? item["title"] : "";
3289
- const resolved = resolveRuleId({
3290
- ...typeof item["id"] === "string" ? { id: item["id"] } : {},
3291
- ...typeof item["code"] === "string" ? { code: item["code"] } : {},
3292
- ...typeof item["path"] === "string" ? { path: item["path"] } : {},
3293
- title
3294
- });
3295
- const id = resolved ?? (systemic && title !== "" ? synthesizedSystemicId(title) : void 0);
3296
- return id !== void 0 ? [id] : [];
3297
- });
3298
- return [...idsOf("findings", false), ...idsOf("systemic_problems", true)];
3299
- };
3300
3460
  var appendBestEffort = (path, label, body) => {
3301
3461
  if (path === void 0 || path === "") return;
3302
3462
  try {
@@ -3391,8 +3551,8 @@ var priorBotCommentIds = (raw, botLogin) => {
3391
3551
  const nodes = conn?.nodes;
3392
3552
  if (!Array.isArray(nodes)) return { ids: [], truncated };
3393
3553
  const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
3394
- const ids = nodes.flatMap((t8) => {
3395
- const cnodes = t8.comments?.nodes;
3554
+ const ids = nodes.flatMap((t9) => {
3555
+ const cnodes = t9.comments?.nodes;
3396
3556
  return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
3397
3557
  });
3398
3558
  return { ids, truncated };
@@ -3512,7 +3672,7 @@ var post = async (input, ghApi = runGhApi, readArtifact = ghArtifactReader) => {
3512
3672
  const logAnsweredDrops = () => {
3513
3673
  if (verbatimReRaised.length > 0) {
3514
3674
  process.stderr.write(
3515
- `${String(droppedCount)} verbatim re-raise(s) of answered findings were treated as answered \u2014 the preserved sticky shows each finding and its prior reply
3675
+ `${String(droppedCount)} verbatim re-raise(s) of answered findings were treated as answered \u2014 the preserved sticky shows each finding
3516
3676
  `
3517
3677
  );
3518
3678
  }
@@ -3637,8 +3797,40 @@ ${dropNote}` : ""}`,
3637
3797
  process.exit(0);
3638
3798
  }
3639
3799
  const loadedFindings = findingsResult.findings;
3640
- const threadComments = await fetchThreadComments(ghApi, input.repo, prNumber);
3641
- const answeredRegistry = threadComments === null ? [] : answeredRegistryFrom(threadComments, input.botLogin);
3800
+ const answerable = existingSticky !== null && !priorIsMechanic && loadedFindings.findings.length > 0;
3801
+ const headInBaseRepo = answerable && resolution.headRepo !== null && resolution.headRepo.toLowerCase() === input.repo.toLowerCase();
3802
+ const answers = answerable ? answersFrom({
3803
+ repo: input.repo,
3804
+ prNumber,
3805
+ botLogin: input.botLogin,
3806
+ comments: commentRows.map((row) => ({
3807
+ id: row.id,
3808
+ body: row.body,
3809
+ user: { login: row.author, type: row.authorType },
3810
+ created_at: row.created,
3811
+ author_association: row.association
3812
+ })),
3813
+ commits: headInBaseRepo ? await fetchCommitAnswersSource(input.repo, prNumber, ghApi) : []
3814
+ }) : { comments: [], commits: [] };
3815
+ const closures = closingResponses(
3816
+ [...answers.comments, ...answers.commits],
3817
+ (response) => isTrustedResponse(response, headInBaseRepo)
3818
+ ).filter((closure) => couldMatch(closure.id, loadedFindings.findings));
3819
+ const priorForAnswers = closures.length > 0 && existingSticky !== null ? { value: await resolvePriorFindings(existingSticky.body, readArtifact) } : null;
3820
+ const answeredPrior = priorForAnswers === null ? null : resolveTolerantFindings(priorForAnswers.value);
3821
+ if (priorForAnswers !== null && answeredPrior === null) {
3822
+ process.stderr.write(
3823
+ `Warning: ${String(closures.length)} maintainer answer(s) close prior findings, but the prior review's findings did not resolve \u2014 no re-raise is treated as answered
3824
+ `
3825
+ );
3826
+ }
3827
+ const answeredRegistry = answeredRegistryFrom(closures, answeredPrior);
3828
+ if (answeredPrior !== null && answeredRegistry.length < closures.length) {
3829
+ process.stderr.write(
3830
+ `Warning: ${String(closures.length - answeredRegistry.length)} maintainer answer(s) name no finding the prior review reported \u2014 they close nothing this round
3831
+ `
3832
+ );
3833
+ }
3642
3834
  const answeredFilter = applyAnswered(loadedFindings.findings, answeredRegistry);
3643
3835
  const reRaisedNotes = answeredFilter.reRaisedNotes;
3644
3836
  const verbatimReRaised = answeredFilter.verbatimReRaised;
@@ -3666,7 +3858,7 @@ ${dropNote}` : ""}`,
3666
3858
  const roundHasNit = findings.findings.some((f) => f.severity === "nit");
3667
3859
  const nitWantsPrior = roundHasNit && existingSticky !== null && (isFullReviewSticky(existingSticky.body) || isFullReviewAncestry(existingSticky.body));
3668
3860
  const broadWantsPrior = existingSticky !== null && !priorIsMechanic && mentionsOutsideKnown(reachable, broadCurrentIds);
3669
- const resolvedPrior = existingSticky !== null && (nitWantsPrior || broadWantsPrior) ? await resolvePriorFindings(existingSticky.body, readArtifact) : null;
3861
+ const resolvedPrior = priorForAnswers !== null ? priorForAnswers.value : existingSticky !== null && (nitWantsPrior || broadWantsPrior) ? await resolvePriorFindings(existingSticky.body, readArtifact) : null;
3670
3862
  const priorDocForNits = nitWantsPrior ? resolvedPrior : null;
3671
3863
  const priorSuppressedKeys = new Set(
3672
3864
  priorBelowFloorNits(priorDocForNits, input.nitVisibilityFloor).map(
@@ -4523,7 +4715,10 @@ var IssueCommentCodec = t.intersection([
4523
4715
  t.type({
4524
4716
  id: t.number,
4525
4717
  body: t.union([t.string, t.null]),
4526
- user: t.type({ login: t.string })
4718
+ user: t.intersection([
4719
+ t.type({ login: t.string }),
4720
+ t.partial({ type: t.union([t.string, t.null]) })
4721
+ ])
4527
4722
  }),
4528
4723
  t.partial({
4529
4724
  created_at: t.union([t.string, t.null]),
@@ -4577,13 +4772,16 @@ var fetchFullDiff = async (repo, defaultBranch, headSha, ghApi, gitRun) => {
4577
4772
  const base = (await gitRun(["rev-parse", "HEAD"])).trim();
4578
4773
  return gitRun(["diff", `${base}...${headSha}`]);
4579
4774
  };
4580
- var CommitCodec = t.type({
4581
- sha: t.string,
4582
- message: t.string,
4583
- author: t.union([t.string, t.null]),
4584
- email: t.union([t.string, t.null])
4585
- });
4586
- var COMMIT_JQ = ".commits[] | {sha: .sha, message: .commit.message, author: .commit.author.name, email: .commit.author.email}";
4775
+ var CommitCodec = t.intersection([
4776
+ t.type({
4777
+ sha: t.string,
4778
+ message: t.string,
4779
+ author: t.union([t.string, t.null]),
4780
+ email: t.union([t.string, t.null])
4781
+ }),
4782
+ t.partial({ date: t.union([t.string, t.null]), login: t.union([t.string, t.null]) })
4783
+ ]);
4784
+ var COMMIT_JQ = ".commits[] | {sha: .sha, message: .commit.message, author: .commit.author.name, email: .commit.author.email, date: .commit.author.date, login: (.author.login // null)}";
4587
4785
  var fetchCompareCommits = async (repo, defaultBranch, headSha, ghApi) => {
4588
4786
  const rows = parseJsonl(
4589
4787
  await ghApi([
@@ -4604,8 +4802,8 @@ var fetchCompareCommits = async (repo, defaultBranch, headSha, ghApi) => {
4604
4802
  }
4605
4803
  return commits;
4606
4804
  };
4607
- var COMMENT_JQ = ".[] | {id: .id, body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association}";
4608
- var REVIEW_COMMENT_JQ = THREAD_COMMENT_JQ;
4805
+ var COMMENT_JQ = ".[] | {id: .id, body: .body, user: {login: .user.login, type: .user.type}, created_at: .created_at, author_association: .author_association}";
4806
+ var REVIEW_COMMENT_JQ = ".[] | {body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association, path: .path, line: .line}";
4609
4807
  var REVIEW_JQ = ".[] | {body: .body, user: {login: .user.login}, submitted_at: .submitted_at, author_association: .author_association, state: .state}";
4610
4808
  var ReviewCommentCodec = t.intersection([
4611
4809
  t.type({ body: t.union([t.string, t.null]), user: t.type({ login: t.string }) }),
@@ -4788,7 +4986,6 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit, readArtifact = ghA
4788
4986
  ]);
4789
4987
  const issueComments = decodeArrayOrNull(IssueCommentCodec, issueRows);
4790
4988
  const reviewComments = decodeArrayOrNull(ReviewCommentCodec, reviewCommentRows);
4791
- const threadComments = decodeArrayOrNull(ThreadCommentCodec, reviewCommentRows);
4792
4989
  const reviews = decodeArrayOrNull(ReviewCodec, reviewRows);
4793
4990
  const prior = issueComments === null ? null : priorReviewFrom(issueComments, input.botLogin);
4794
4991
  writeFileSync(
@@ -4802,11 +4999,26 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit, readArtifact = ghA
4802
4999
  join(input.outDir, "prior_findings.json"),
4803
5000
  priorFindings === null ? "null" : JSON.stringify(priorFindings)
4804
5001
  );
4805
- const answered = threadComments === null ? [] : answeredRegistryFrom(threadComments, input.botLogin);
4806
- writeFileSync(
4807
- join(input.outDir, "answered.json"),
4808
- JSON.stringify(answered.map(encodeAnsweredEntry))
4809
- );
5002
+ const harvest = seedsFromPrior ? harvestResponses({
5003
+ repo: input.repo,
5004
+ prNumber,
5005
+ botLogin: input.botLogin,
5006
+ priorIds: new Set(priorIdsFrom(priorFindings)),
5007
+ commits: commits.map((commit) => ({ ...commit, author: commit.login ?? null })),
5008
+ comments: issueComments ?? []
5009
+ }) : { file: { responses: [], unmatched: [] }, dropped: 0 };
5010
+ writeFileSync(join(input.outDir, "responses.json"), JSON.stringify(harvest.file));
5011
+ if (seedsFromPrior && priorFindings === null) {
5012
+ process.stderr.write(
5013
+ "Note: the prior review's findings did not resolve \u2014 every id-shaped answer is staged as unmatched\n"
5014
+ );
5015
+ }
5016
+ if (harvest.dropped > 0) {
5017
+ process.stderr.write(
5018
+ `Note: ${String(harvest.dropped)} answer(s) beyond the newest ${String(RESPONSES_PER_CHANNEL)} per channel were not staged
5019
+ `
5020
+ );
5021
+ }
4810
5022
  writeFileSync(
4811
5023
  join(input.outDir, "pr_conversation.json"),
4812
5024
  JSON.stringify({
@@ -5170,7 +5382,7 @@ var parseScope = (raw) => {
5170
5382
  reason: 'scope contains a character (newline, carriage return, backtick, "<", ">", or "|") that would corrupt the review prompt \u2014 use plain language names/tags'
5171
5383
  };
5172
5384
  }
5173
- const languages = Array.from(new Set(trimmed.split(SCOPE_SEPARATOR_RE).filter((t8) => t8 !== "")));
5385
+ const languages = Array.from(new Set(trimmed.split(SCOPE_SEPARATOR_RE).filter((t9) => t9 !== "")));
5174
5386
  return languages.length === 0 ? { kind: "absent" } : { kind: "ok", languages };
5175
5387
  };
5176
5388
 
@@ -5798,18 +6010,19 @@ var deadlineCmd = defineCommand({
5798
6010
  }
5799
6011
  });
5800
6012
  var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
6013
+ var liveSchemaPin = (kind, schemaPath, requestedVersion) => kind === "findings" && schemaPath === schemaPathFor(kind) ? livePinFor(kind, requestedVersion) : void 0;
5801
6014
  var printableSchema = (schemaPath, pinVersion) => {
5802
6015
  const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
5803
6016
  const enforcementSchema = Object.fromEntries(
5804
6017
  Object.entries(schema).filter(([key2]) => key2 !== "$schema")
5805
6018
  );
5806
- if (!pinVersion) return JSON.stringify(enforcementSchema, null, 2);
6019
+ if (pinVersion === void 0) return JSON.stringify(enforcementSchema, null, 2);
5807
6020
  const properties = enforcementSchema["properties"];
5808
6021
  const schemaVersion = typeof properties === "object" && properties !== null ? properties["schema_version"] : void 0;
5809
6022
  if (typeof schemaVersion === "object" && schemaVersion !== null) {
5810
6023
  properties["schema_version"] = {
5811
6024
  ...schemaVersion,
5812
- pattern: anchoredSchemaVersionPattern(DEFAULT_SCHEMA_VERSION)
6025
+ pattern: anchoredSchemaVersionPattern(pinVersion)
5813
6026
  };
5814
6027
  }
5815
6028
  return JSON.stringify(enforcementSchema, null, 2);
@@ -5845,7 +6058,8 @@ var validateCmd = defineCommand({
5845
6058
  run: async ({ args }) => {
5846
6059
  const kind = requireSchemaKind(args.kind || "findings");
5847
6060
  const documentRaw = readJSON(args.document);
5848
- const schemaPath = args.schema ? resolve(args.schema) : requireSchemaPath(kind, args["schema-version"] || derivedSchemaVersion(kind, documentRaw));
6061
+ const requestedVersion = args["schema-version"] || derivedSchemaVersion(kind, documentRaw);
6062
+ const schemaPath = args.schema ? resolve(args.schema) : requireSchemaPath(kind, requestedVersion);
5849
6063
  const { valid, errors } = validateAgainstSchema(documentRaw, schemaPath);
5850
6064
  if (valid) {
5851
6065
  process.stdout.write("\u2705 valid\n");
@@ -5857,7 +6071,7 @@ var validateCmd = defineCommand({
5857
6071
  process.stderr.write(
5858
6072
  `
5859
6073
  The ${kind} document must conform to this schema (the field descriptions are the authoritative spec \u2014 match the property names exactly):
5860
- ${printableSchema(schemaPath, kind === "findings" && schemaPath === schemaPathFor(kind))}
6074
+ ${printableSchema(schemaPath, liveSchemaPin(kind, schemaPath, requestedVersion))}
5861
6075
  `
5862
6076
  );
5863
6077
  }
@@ -5881,10 +6095,6 @@ var seedDraftCmd = defineCommand({
5881
6095
  type: "string",
5882
6096
  description: "Path to the gather-staged prior findings document (prior_findings.json). gather resolves it \u2014 from the sticky's embedded blob, or by fetching the artifact the marker names \u2014 because gather holds the repo token and this step deliberately does not: it runs the jailed agent over untrusted PR code. Absent or null falls back to decoding an embedded blob out of --prior, which needs no token"
5883
6097
  },
5884
- "prior-answers": {
5885
- type: "string",
5886
- description: "Path to the gather-staged answered-findings registry (answered.json) \u2014 the prior inline findings whose threads a human reply answered (issue #151). Delivered out-of-band to the .prior-answers sidecar beside the prior context so the next-round agent sees the already-answered state; best-effort, never fails the seed"
5887
- },
5888
6098
  "nit-visibility-floor": {
5889
6099
  type: "string",
5890
6100
  description: "The nit visibility floor (issue #164), matched to the commenter's: the prior review's below-floor nits (confidence \xD7 likelihood below it) are re-derived from --prior and delivered to the .prior-suppressed sidecar as adjudicated context, so the next-round agent does not re-raise them as fresh nits; best-effort, never fails the seed. Empty \u21D2 the default"
@@ -5960,55 +6170,17 @@ var seedDraftCmd = defineCommand({
5960
6170
  const strippedPrior = parsedPrior === null ? null : stripSurfaceFields(
5961
6171
  isSurfaceStampedDoc(parsedPrior) ? parsedPrior : withoutScopeMetastasis(parsedPrior)
5962
6172
  );
5963
- const answeredRegistry = (() => {
5964
- if (!args["prior-answers"]) return null;
5965
- try {
5966
- const raw = JSON.parse(readFileSync(resolve(args["prior-answers"]), "utf-8"));
5967
- if (!Array.isArray(raw)) throw new Error("expected an array");
5968
- const decoded = raw.flatMap((row) => {
5969
- const entry = decodeAnsweredEntry(row);
5970
- return entry === null ? [] : [entry];
5971
- });
5972
- if (decoded.length < raw.length) {
5973
- process.stderr.write(
5974
- `Warning: ${String(raw.length - decoded.length)} of ${String(raw.length)} answered-registry row(s) failed to decode \u2014 the seed's answered state is incomplete
5975
- `
5976
- );
5977
- }
5978
- return decoded;
5979
- } catch (err) {
5980
- process.stderr.write(
5981
- `Warning: could not read the answered-findings registry ${args["prior-answers"]} (${errMsg(err)}) \u2014 no prior-answers sidecar
5982
- `
5983
- );
5984
- return null;
5985
- }
5986
- })();
5987
6173
  const priorFindings = (() => {
5988
6174
  if (strippedPrior === null || typeof strippedPrior !== "object" || Array.isArray(strippedPrior))
5989
6175
  return strippedPrior;
5990
6176
  const resolved = resolvedPriorValue(strippedPrior);
5991
- const doc = resolved === null ? strippedPrior : {
5992
- ...resolved,
5993
- findings: answeredRegistry !== null && answeredRegistry.length > 0 ? resolved.findings.filter((f) => {
5994
- const resolvedId = resolveFindingId(f);
5995
- return !answeredRegistry.some((e) => isAnsweredDrop(resolvedId, f, e));
5996
- }) : resolved.findings
5997
- };
6177
+ const doc = resolved === null ? strippedPrior : resolved;
5998
6178
  const carried = doc["scope_metastasis"];
5999
6179
  if (ScopeMetastasisCodec.decode(carried)._tag === "Right") return doc;
6000
6180
  if (doc["verdict"] === "error") return doc;
6001
6181
  const computed = computeScopeMetastasis(priorTrajectory(parsedPrior, priorBody ?? ""));
6002
6182
  return computed === null ? doc : { ...doc, scope_metastasis: computed };
6003
6183
  })();
6004
- if (answeredRegistry !== null) {
6005
- writeFileSync(priorAnswersPath(outPath), `${JSON.stringify(answeredRegistry, null, 2)}
6006
- `);
6007
- process.stderr.write(
6008
- `Seeded ${priorAnswersPath(outPath)} with ${String(answeredRegistry.length)} answered finding(s) as context
6009
- `
6010
- );
6011
- }
6012
6184
  if (parsedPrior !== null && isFullReviewAncestry(priorBody ?? "")) {
6013
6185
  try {
6014
6186
  const belowFloor = priorBelowFloorNits(
@@ -6040,7 +6212,9 @@ var seedDraftCmd = defineCommand({
6040
6212
  ) : priorFindings;
6041
6213
  const accepts = (doc) => {
6042
6214
  const resolved = resolvedPriorValue(doc);
6043
- return resolved !== null && validateAgainstSchema(resolved, schemaPath).valid ? resolved : null;
6215
+ if (resolved === null) return null;
6216
+ const carried = withoutRebuttals(resolved);
6217
+ return validateAgainstSchema(carried, schemaPath).valid ? carried : null;
6044
6218
  };
6045
6219
  const seedDoc = accepts(priorFindings) ?? (() => {
6046
6220
  const bare = accepts(barePrior);
@@ -6328,7 +6502,7 @@ var printSchemaCmd = defineCommand({
6328
6502
  const schemaKind = requireSchemaKind(args.name);
6329
6503
  const schemaPath = requireSchemaPath(schemaKind, args["schema-version"]);
6330
6504
  process.stdout.write(
6331
- `${printableSchema(schemaPath, schemaKind === "findings" && schemaPath === schemaPathFor(schemaKind))}
6505
+ `${printableSchema(schemaPath, liveSchemaPin(schemaKind, schemaPath, args["schema-version"]))}
6332
6506
  `
6333
6507
  );
6334
6508
  }