@event4u/agent-config 14.20.0 → 14.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +243 -0
  5. package/README.md +1 -1
  6. package/agents/templates/.ai-council.yml.example +10 -0
  7. package/dist/agent-src/commands/analyze/inbox.md +1 -1
  8. package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +247 -32
  9. package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
  10. package/dist/agent-src/commands/analyze.md +12 -7
  11. package/dist/agent-src/commands/feature/plan.md +55 -16
  12. package/dist/agent-src/commands/feature/refactor.md +8 -3
  13. package/dist/agent-src/commands/humanize.md +48 -4
  14. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
  15. package/dist/agent-src/contexts/execution/host-capability-manifest.md +4 -1
  16. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
  17. package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
  18. package/dist/agent-src/rules/evaluator-independence.md +4 -4
  19. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
  20. package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
  21. package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
  22. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
  23. package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
  24. package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
  25. package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
  26. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  27. package/dist/agent-src/templates/features.md +5 -0
  28. package/dist/agent-src/templates/roadmaps.md +15 -0
  29. package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
  30. package/dist/cli-delegate/{chunk-PI2GU3DP.js → chunk-2TAQ2K4V.js} +4 -1
  31. package/dist/cli-delegate/{chunk-C6IPZRDA.js → chunk-643Z7FIC.js} +1 -1
  32. package/dist/cli-delegate/{chunk-NUMQHLS5.js → chunk-J3RFNKG4.js} +21 -2
  33. package/dist/cli-delegate/cmd_conformance.js +17 -3
  34. package/dist/cli-delegate/cmd_doctor.js +3 -3
  35. package/dist/cli-delegate/cmd_export.js +2 -2
  36. package/dist/cli-delegate/cmd_handoff.js +1 -1
  37. package/dist/cli-delegate/cmd_prune.js +2 -2
  38. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  39. package/dist/cli-delegate/cmd_sync.js +2 -2
  40. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  41. package/dist/cli-delegate/cmd_validate.js +2 -2
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +56 -25
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +3 -3
  46. package/dist/discovery/dormancy-report.md +1 -1
  47. package/dist/discovery/orphan-report.md +1 -1
  48. package/dist/discovery/packs.json +7 -6
  49. package/dist/discovery/trust-report.md +2 -2
  50. package/dist/discovery/workspaces.json +6 -5
  51. package/dist/hooks/dispatch.js +479 -241
  52. package/dist/install/install.mjs +4 -1
  53. package/dist/mcp/registry-manifest.json +2 -2
  54. package/dist/mcp/server.json +2 -2
  55. package/dist/mcp/server.mjs +313 -63
  56. package/dist/scripts/ai_council/content_ceiling.js +63 -0
  57. package/dist/scripts/ai_council/seat_policy.js +106 -0
  58. package/dist/scripts/ai_council/transport_resolver.js +21 -0
  59. package/llms.txt +14 -14
  60. package/package.json +1 -1
  61. package/src/config/gate-violation-baselines.json +3 -3
  62. package/src/config/release-gate-locality.yml +4 -3
  63. package/src/scripts/_lib/blocked_by_marker.ts +189 -0
  64. package/src/scripts/_lib/host_capability.ts +38 -1
  65. package/src/scripts/_lib/release_highlights.ts +19 -211
  66. package/src/scripts/_lib/release_material.ts +27 -77
  67. package/src/scripts/_lib/structured_ask.ts +117 -0
  68. package/src/scripts/_lib/tty_prompt.ts +76 -0
  69. package/src/scripts/ai_council/api_transport.ts +163 -0
  70. package/src/scripts/ai_council/clients.ts +58 -63
  71. package/src/scripts/ai_council/config.ts +13 -0
  72. package/src/scripts/ai_council/content_ceiling.ts +79 -0
  73. package/src/scripts/ai_council/jury_aggregate.ts +178 -0
  74. package/src/scripts/ai_council/seat_policy.ts +137 -0
  75. package/src/scripts/ai_council/status_surface.ts +79 -0
  76. package/src/scripts/ai_council/transport_resolver.ts +59 -1
  77. package/src/scripts/ai_tells_rules.ts +446 -5
  78. package/src/scripts/ask_block_census.ts +499 -0
  79. package/src/scripts/bench_humanizer_eval.ts +264 -40
  80. package/src/scripts/bench_prose_tells_fp.ts +198 -0
  81. package/src/scripts/check_branch_work_committed.ts +234 -0
  82. package/src/scripts/check_pack_size.ts +1 -1
  83. package/src/scripts/check_release_highlights.ts +0 -157
  84. package/src/scripts/council_cli.ts +18 -27
  85. package/src/scripts/detect_ai_tells.ts +142 -20
  86. package/src/scripts/harvest_reference_tokens.ts +698 -0
  87. package/src/scripts/hook_manifest.json +1 -1
  88. package/src/scripts/hook_manifest.yaml +31 -1
  89. package/src/scripts/hooks/concern_registry.ts +2 -0
  90. package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
  91. package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
  92. package/src/scripts/install-hooks.sh +52 -0
  93. package/src/scripts/lint_roadmap_blockers.ts +51 -1
  94. package/src/scripts/migrate_command_suggestions.ts +1 -1
  95. package/src/scripts/probe_unblocked_ask.ts +135 -2
  96. package/src/scripts/release.ts +26 -71
  97. package/src/scripts/release_drill.ts +6 -10
  98. package/src/scripts/release_env.ts +1 -0
  99. package/src/scripts/release_publication.ts +34 -29
  100. package/src/scripts/self_review_gate.ts +409 -15
@@ -100,7 +100,13 @@ export function isReviewablePath(p: string): boolean {
100
100
  }
101
101
 
102
102
  function changedFiles(baseRef: string, cwd: string = REPO_ROOT): string[] {
103
- const r = spawnSync('git', ['diff', '--name-only', `${baseRef}...HEAD`], {
103
+ // `-c core.quotePath=false`: by default git renders a non-ASCII path as an
104
+ // escaped, QUOTED string (`"gr\303\274e.ts"`), which then matches no
105
+ // pathspec when fed back to `git diff`. The file was silently skipped by
106
+ // `perFileDiffs` AND counted as reviewed by the coverage arithmetic — an
107
+ // unreviewed file reported as read, which is the failure this whole change
108
+ // exists to prevent.
109
+ const r = spawnSync('git', ['-c', 'core.quotePath=false', 'diff', '--name-only', `${baseRef}...HEAD`], {
104
110
  cwd,
105
111
  encoding: 'utf8',
106
112
  });
@@ -124,6 +130,32 @@ function diffText(baseRef: string, files: string[], cwd: string = REPO_ROOT): st
124
130
  return (r.stdout ?? '').toString();
125
131
  }
126
132
 
133
+ /**
134
+ * The diff split per file, so it can be packed into budgeted requests.
135
+ *
136
+ * One `git diff` per file rather than splitting the combined output, because
137
+ * packing needs a SIZE PER FILE before any text is assembled — a combined diff
138
+ * gives one blob and its per-file sizes only by re-deriving path boundaries
139
+ * from the text.
140
+ *
141
+ * The rationale this replaces claimed the combined form "breaks that parse
142
+ * silently" on separator-bearing paths. That was a straw man: the pre-change
143
+ * code never parsed the combined diff at all, it sent it whole.
144
+ *
145
+ * It is not free either. Each three-dot diff re-resolves the merge base at
146
+ * ~93 ms, so a 316-file span costs ~30 s. That is paid ONCE now — `buildPlan`
147
+ * stores the partition and `main` reuses it, where it used to recompute and
148
+ * pay twice.
149
+ */
150
+ function perFileDiffs(baseRef: string, files: readonly string[], cwd: string = REPO_ROOT): FileDiff[] {
151
+ const out: FileDiff[] = [];
152
+ for (const path of files) {
153
+ const diff = diffText(baseRef, [path], cwd);
154
+ if (diff !== '') out.push({ path, diff });
155
+ }
156
+ return out;
157
+ }
158
+
127
159
  /** Sum of added+deleted lines across `files` (binary files count 0). */
128
160
  function changedLineCount(baseRef: string, files: string[], cwd: string = REPO_ROOT): number {
129
161
  if (files.length === 0) return 0;
@@ -241,10 +273,196 @@ function buildSystemPrompt(release?: ReleaseInfo, baseRef?: string): string {
241
273
  return release && baseRef ? base + releaseNoteText(release, baseRef) : base;
242
274
  }
243
275
 
276
+ // Prompt budget and partitioning.
277
+ //
278
+ // Measured across FOUR consecutive releases: the live call returned
279
+ // `HTTP 400 prompt is too long` every time and the gate reviewed nothing. The
280
+ // release path sets `analysisBase` to the previous tag, so the whole release
281
+ // span goes into one request. ALL FOUR recorded a figure against the 200000
282
+ // cap — 235472 (14.17.0), 413191 (14.18.0), 450336 (14.19.0), 260998
283
+ // (14.20.0). The smallest exceeds the cap by 17.7 %.
284
+ //
285
+ // Corrected here: an earlier version of this comment said three releases
286
+ // recorded a figure and put the smallest at 30 % over. Both were wrong, and the
287
+ // omitted measurement was the one NEAREST the cap — the reading that most
288
+ // constrains the budget below. The conclusion is unchanged and does not depend
289
+ // on the error: every span observed exceeds the cap, so this is structural and
290
+ // waiting for smaller releases is not a fix.
291
+ //
292
+ // `promptChars` was already computed and only REPORTED. Nothing consulted it
293
+ // before spending the call.
294
+ //
295
+ // What this deliberately does NOT do is truncate. A silently shortened diff
296
+ // produces findings about a fragment while reading as a review of the whole
297
+ // change, which is a false green — the failure mode this repository's
298
+ // honest-null discipline exists to prevent. Instead the diff is partitioned per
299
+ // FILE, each chunk is reviewed, findings are merged, and whatever did not fit
300
+ // is NAMED as unreviewed.
301
+
302
+ /**
303
+ * Chars per token the budget assumes, and the measurement behind it.
304
+ *
305
+ * A proxy, not a measurement of the real thing: the cap is enforced by the
306
+ * provider's tokenizer, which this repository cannot run — `js-tiktoken` is a
307
+ * DIFFERENT tokenizer, so agreeing with it would prove nothing.
308
+ *
309
+ * The ratio is DERIVED from the four failures, dividing measured diff chars by
310
+ * the token count the API itself reported:
311
+ *
312
+ * 14.19.0…14.20.0 817,805 chars / 260,998 tokens = 3.13
313
+ * 14.18.0…14.19.0 1,432,333 chars / 450,336 tokens = 3.18
314
+ *
315
+ * An earlier version of this constant used 3.0 and called it "deliberately
316
+ * pessimistic". It was not: against a measured 3.13 that is 4 % of margin, and
317
+ * the ratio is strongly content-dependent — cl100k over this repo's own diffs
318
+ * reads ~3.9 for `src/`, ~4.1 for `agents/` prose and ~2.75 for a lockfile,
319
+ * a 1.5x spread. Dense JSON, CSV and YAML paths are not excluded from the
320
+ * review scope, so a chunk of them tokenizes far worse than the average the
321
+ * two spans above describe. 2.4 covers the lockfile-shaped end with the same
322
+ * ~22 % gap those spans show between cl100k and the provider's count.
323
+ *
324
+ * A call that still overflows a chunk built under this budget falsifies this
325
+ * number, not the partitioning — and the failure is graceful either way: the
326
+ * chunk is reported unreviewed and the run continues.
327
+ */
328
+ export const BUDGET_CHARS_PER_TOKEN = 2.4;
329
+
330
+ /**
331
+ * Input budget for one request, in characters.
332
+ *
333
+ * 180000 rather than the full 200000 cap because the system prompt rides along
334
+ * with EVERY request and is not part of the chunk: the two review skill bodies
335
+ * are ~14000 chars, and in release mode `releaseNoteText` joins the entire
336
+ * packaging file list into it — ~12600 chars at 316 files. Both are charged per
337
+ * chunk, so the reservation has to cover them at the same pessimistic ratio.
338
+ */
339
+ export const PROMPT_BUDGET_CHARS = Math.floor(180_000 * BUDGET_CHARS_PER_TOKEN);
340
+
341
+ /**
342
+ * Cost ceiling, in requests per run.
343
+ *
344
+ * The gate is advisory and single-maintainer-funded, so an unbounded chunk
345
+ * count would turn one failed call into an arbitrary bill. A span that needs
346
+ * more is reviewed up to the ceiling and the remainder reported unreviewed — a
347
+ * partial review that says so, never a silent one.
348
+ *
349
+ * SIX, and the number moved for a measured reason rather than for comfort. It
350
+ * was four while the budget assumed 3.0 chars/token. Correcting that assumption
351
+ * to the measured 2.4 shrank each request, so the same span needs more of them:
352
+ * the live 14.19.0...HEAD span went to FIVE requests at the corrected budget and
353
+ * reported 82 files unreviewed. Buying tokenizer safety with coverage is not a
354
+ * trade this gate should make silently, so the ceiling absorbs it with one
355
+ * request of headroom above the largest span measured.
356
+ *
357
+ * The cost is real and is the reason this is a constant rather than unbounded:
358
+ * six requests of up to ~180000 input tokens is the worst case per run, and
359
+ * `--dry-run` prints the count before anything is spent.
360
+ */
361
+ export const MAX_REVIEW_CHUNKS = 6;
362
+
363
+ /** How many unreviewed paths `--dry-run` names before collapsing to a count. */
364
+ export const DRY_RUN_UNREVIEWED_SAMPLE = 5;
365
+
366
+ export interface FileDiff {
367
+ path: string;
368
+ diff: string;
369
+ }
370
+
371
+ export interface DiffChunk {
372
+ /** Concatenated diff text for one request. */
373
+ text: string;
374
+ /** The files this chunk carries — so coverage counts what was READ. */
375
+ files: string[];
376
+ }
377
+
378
+ export interface DiffPartition {
379
+ chunks: DiffChunk[];
380
+ /** Files no chunk carries, with the reason — rendered into the review. */
381
+ unreviewed: { path: string; reason: string }[];
382
+ }
383
+
384
+ /**
385
+ * Pack per-file diffs into as few chunks as fit the budget.
386
+ *
387
+ * Deterministic: input order is preserved and the fill is greedy, so the same
388
+ * span always partitions the same way and a finding's chunk is reproducible.
389
+ *
390
+ * A single file larger than the whole budget is NOT split. A diff cut at an
391
+ * arbitrary byte offset produces half a hunk, and a reviewer reading half a
392
+ * hunk reports on code that does not exist — worse than not reading it, because
393
+ * the finding looks authoritative. Such a file is reported unreviewed instead.
394
+ */
395
+ export function partitionDiff(
396
+ files: readonly FileDiff[],
397
+ budgetChars: number = PROMPT_BUDGET_CHARS,
398
+ maxChunks: number = MAX_REVIEW_CHUNKS,
399
+ ): DiffPartition {
400
+ const ceiling = Math.max(0, maxChunks);
401
+ const chunks: DiffChunk[] = [];
402
+ const unreviewed: { path: string; reason: string }[] = [];
403
+ let current: DiffChunk = { text: '', files: [] };
404
+
405
+ for (const f of files) {
406
+ if (f.diff.length > budgetChars) {
407
+ unreviewed.push({
408
+ path: f.path,
409
+ reason:
410
+ `single-file diff of ${String(f.diff.length)} chars exceeds the ` +
411
+ `${String(budgetChars)}-char request budget; not split, because a diff cut ` +
412
+ 'mid-hunk yields findings about code that does not exist',
413
+ });
414
+ continue;
415
+ }
416
+ if (current.text !== '' && current.text.length + f.diff.length > budgetChars) {
417
+ chunks.push(current);
418
+ current = { text: '', files: [] };
419
+ }
420
+ current.text += f.diff;
421
+ current.files.push(f.path);
422
+ }
423
+ if (current.text !== '') chunks.push(current);
424
+
425
+ if (chunks.length > ceiling) {
426
+ // Every dropped chunk names its own files. The first version packed to
427
+ // opaque strings and reported ONE aggregate entry for all of them, which
428
+ // made the coverage subtraction downstream count every dropped file as
429
+ // reviewed — 59 of 60 for a run that read 4. That was not an inherent
430
+ // limit of the problem, as its comment claimed; it was a consequence of
431
+ // throwing the file list away here.
432
+ const total = chunks.length;
433
+ for (const dropped of chunks.slice(ceiling)) {
434
+ for (const path of dropped.files) {
435
+ unreviewed.push({
436
+ path,
437
+ reason:
438
+ `the span needs ${String(total)} requests at this budget and the ` +
439
+ `per-run ceiling is ${String(ceiling)}; this file's chunk was not sent`,
440
+ });
441
+ }
442
+ }
443
+ chunks.length = ceiling;
444
+ }
445
+ return { chunks, unreviewed };
446
+ }
447
+
244
448
  // ── Plan (dry-run) ────────────────────────────────────────────────────
245
449
  export interface ReviewPlan {
246
450
  skills: string[];
247
451
  files: string[];
452
+ /**
453
+ * The partition the live run will send, computed ONCE.
454
+ *
455
+ * `main` used to call `partitionDiff(perFileDiffs(...))` again over the same
456
+ * span. Measured: `git diff <base>...HEAD -- <one file>` costs ~93 ms
457
+ * because every three-dot form re-resolves the merge base, so 316 files
458
+ * meant ~632 spawns and about a minute of wall clock per live run, half of
459
+ * it thrown away.
460
+ */
461
+ partition: DiffPartition;
462
+ /** Model calls the live run will make — one per budgeted chunk. */
463
+ requests: number;
464
+ /** Files no request will carry, with the reason. */
465
+ unreviewed: { path: string; reason: string }[];
248
466
  promptChars: number;
249
467
  note: string;
250
468
  escalation: string[];
@@ -277,7 +495,15 @@ export function buildPlan(baseRef: string, cwd: string = REPO_ROOT): ReviewPlan
277
495
  const diff = diffText(analysisBase, files, cwd);
278
496
  const escalation = escalationReasons(files, changedLineCount(analysisBase, files, cwd));
279
497
  const systemPrompt = buildSystemPrompt(release, baseRef);
498
+ // The same partition the live path will use, so `--dry-run` previews the
499
+ // REQUEST COUNT rather than only a token estimate. This gate spends money
500
+ // per request, and a plan that reports "~N tokens" without saying how many
501
+ // calls that becomes hides the number the maintainer is deciding about.
502
+ const partition = partitionDiff(perFileDiffs(analysisBase, files, cwd));
280
503
  return {
504
+ partition,
505
+ requests: partition.chunks.length,
506
+ unreviewed: partition.unreviewed,
281
507
  skills: [...REVIEW_SKILLS],
282
508
  files,
283
509
  analysisBase,
@@ -288,8 +514,12 @@ export function buildPlan(baseRef: string, cwd: string = REPO_ROOT): ReviewPlan
288
514
  files.length === 0
289
515
  ? 'No reviewable (non-generated) files changed — the live review would no-op.'
290
516
  : `${files.length} reviewable file(s); live review would send ~${Math.ceil(
291
- (systemPrompt.length + diff.length) / 4,
292
- )} input tokens.`,
517
+ (systemPrompt.length * Math.max(1, partition.chunks.length) + diff.length) /
518
+ BUDGET_CHARS_PER_TOKEN,
519
+ )} input tokens across ${partition.chunks.length} request(s)` +
520
+ (partition.unreviewed.length > 0
521
+ ? `, leaving ${partition.unreviewed.length} path(s) UNREVIEWED.`
522
+ : '.'),
293
523
  };
294
524
  }
295
525
 
@@ -320,6 +550,28 @@ function parseFindings(text: string): Finding[] {
320
550
  }));
321
551
  }
322
552
 
553
+ /**
554
+ * Collapse findings that two chunks both reported.
555
+ *
556
+ * Chunks are disjoint by file, so a duplicate means the same defect was
557
+ * described from two files' diffs — a shared helper and one of its callers, for
558
+ * instance. `findingId` is sha256(kind|title|file), already the identity the
559
+ * ledger and the rendered table use, so deduplicating on it keeps the id in the
560
+ * comment matching the id in the artifact. First occurrence wins, which keeps
561
+ * the order deterministic for a given partition.
562
+ */
563
+ export function dedupeFindings(findings: readonly Finding[]): Finding[] {
564
+ const seen = new Set<string>();
565
+ const out: Finding[] = [];
566
+ for (const f of findings) {
567
+ const id = findingId(f);
568
+ if (seen.has(id)) continue;
569
+ seen.add(id);
570
+ out.push(f);
571
+ }
572
+ return out;
573
+ }
574
+
323
575
  /** Advisory escalation banner, appended when a diff warrants full ai-council. */
324
576
  function escalationBlock(reasons: readonly string[]): string {
325
577
  if (reasons.length === 0) return '';
@@ -332,14 +584,54 @@ function escalationBlock(reasons: readonly string[]): string {
332
584
  );
333
585
  }
334
586
 
335
- export function renderReview(findings: Finding[], enforce: boolean, escalation: readonly string[] = []): string {
587
+ /**
588
+ * What the review did NOT read, stated in the comment itself.
589
+ *
590
+ * Without this line a chunked review is indistinguishable from a whole-span
591
+ * one, which is the false green the partitioning exists to avoid — the reader
592
+ * of the PR comment is the person who has to know the coverage, and they never
593
+ * see the workflow log.
594
+ */
595
+ export function coverageBlock(coverage?: ReviewCoverage): string {
596
+ if (!coverage) return '';
597
+ const parts = [
598
+ `\n\n**Coverage.** ${String(coverage.chunks)} request(s) over ` +
599
+ `${String(coverage.filesReviewed)} of ${String(coverage.filesTotal)} changed file(s).`,
600
+ ];
601
+ if (coverage.unreviewed.length > 0) {
602
+ parts.push(
603
+ ` **NOT reviewed (${String(coverage.unreviewed.length)}):**\n` +
604
+ coverage.unreviewed.map((u) => `- \`${u.path}\` — ${u.reason}`).join('\n') +
605
+ // Blank line, not a single newline: GFM treats one newline after
606
+ // a list item as lazy continuation, so the sentence rendered
607
+ // INSIDE the last bullet instead of standing on its own. The
608
+ // `toContain` assertion passed either way, which is why the
609
+ // reviewer had to render it to see it.
610
+ '\n\nFindings above cover the reviewed part only; absence of a finding for an ' +
611
+ 'unreviewed path is not evidence about that path.',
612
+ );
613
+ }
614
+ return parts.join('');
615
+ }
616
+
617
+ export interface ReviewCoverage {
618
+ chunks: number;
619
+ /** Files carried by chunks whose call RETURNED. */
620
+ filesReviewed: number;
621
+ /** Files the span changed, so the reader sees the fraction, not a bare count. */
622
+ filesTotal: number;
623
+ unreviewed: { path: string; reason: string }[];
624
+ }
625
+
626
+ export function renderReview(findings: Finding[], enforce: boolean, escalation: readonly string[] = [], coverage?: ReviewCoverage): string {
336
627
  const banner =
337
628
  '> HUMAN REVIEW REQUIRED — dogfooded AI adversarial-review + security gate. ' +
338
629
  'Findings are decision support, not a guarantee; detection is probabilistic. ' +
339
630
  'This is a floor, **not** independent human review.';
340
631
  const esc = escalationBlock(escalation);
632
+ const cov = coverageBlock(coverage);
341
633
  if (findings.length === 0) {
342
- return `${banner}\n\n✅ Self-review gate: no findings.${esc}`;
634
+ return `${banner}\n\n✅ Self-review gate: no findings.${cov}${esc}`;
343
635
  }
344
636
  const blocking = findings.filter(classifyBlocking);
345
637
  // Each row carries an explicit (Blocking)/(Advisory) marker so a
@@ -372,7 +664,7 @@ export function renderReview(findings: Finding[], enforce: boolean, escalation:
372
664
  const machine = `\n\n<!-- release-findings-json: ${JSON.stringify(
373
665
  findings.map((f) => ({ finding_id: findingId(f), severity: f.severity, kind: f.kind, title: f.title, file: f.file ?? null })),
374
666
  )} -->`;
375
- return `${banner}\n\n${verdictLine}\n\n| id | severity | kind | file | finding |\n|---|---|---|---|---|\n${rows}${idNote}${esc}${machine}`;
667
+ return `${banner}\n\n${verdictLine}\n\n| id | severity | kind | file | finding |\n|---|---|---|---|---|\n${rows}${idNote}${cov}${esc}${machine}`;
376
668
  }
377
669
 
378
670
  function postReview(body: string): void {
@@ -417,6 +709,40 @@ export function main(argv: string[]): 0 | 2 {
417
709
  `packaging diff ${plan.release.packagingFiles.length} file(s))\n`
418
710
  : '') +
419
711
  ` files: ${plan.files.length}\n` +
712
+ ` calls: ${plan.requests} (budget ${String(PROMPT_BUDGET_CHARS)} chars/request, ` +
713
+ `ceiling ${String(MAX_REVIEW_CHUNKS)})\n` +
714
+ (plan.unreviewed.length > 0
715
+ ? ` UNREVIEWED (${String(plan.unreviewed.length)}): ` +
716
+ // Capped: a real run reported 82 paths, and 82 paths on one
717
+ // line is not a report anybody reads. The count is the
718
+ // decision-relevant number; the names are a sample.
719
+ `${plan.unreviewed
720
+ .slice(0, DRY_RUN_UNREVIEWED_SAMPLE)
721
+ .map((u) => u.path)
722
+ .join(', ')}` +
723
+ (plan.unreviewed.length > DRY_RUN_UNREVIEWED_SAMPLE
724
+ ? ` … +${String(plan.unreviewed.length - DRY_RUN_UNREVIEWED_SAMPLE)} more`
725
+ : '') +
726
+ '\n' +
727
+ // The reason was computed and never printed, so the
728
+ // operator saw what WOULD be sent and never what the span
729
+ // needs.
730
+ ` reason: ${plan.unreviewed[0]?.reason ?? 'unknown'}\n`
731
+ : '') +
732
+ // At the ceiling nothing is lost YET, and the next slightly
733
+ // larger span loses its remainder silently apart from the
734
+ // coverage line. Saying so while it is still a warning is the
735
+ // only cheap moment: the alternative is reading it in a
736
+ // published review's coverage block.
737
+ // Gated on the ceiling ALONE. The first version also required
738
+ // `unreviewed.length === 0`, which silenced the warning in the
739
+ // states nearest to loss: once a span has one over-budget file
740
+ // the two facts are independent, and conflating them hid the
741
+ // warning exactly when it mattered.
742
+ (plan.requests >= MAX_REVIEW_CHUNKS
743
+ ? ` ⚠️ at the per-run ceiling (${String(MAX_REVIEW_CHUNKS)}) — a larger span ` +
744
+ 'loses its remainder\n'
745
+ : '') +
420
746
  ` ${plan.note}\n` +
421
747
  (plan.escalation.length
422
748
  ? ` escalation: ${plan.escalation.join('; ')} → recommend /council:pr (run-time authorized)\n`
@@ -455,15 +781,79 @@ export function main(argv: string[]): 0 | 2 {
455
781
  // findings returns 2 — an error is not a finding.)
456
782
  try {
457
783
  const client = new AnthropicClient({ api_key: key });
458
- const resp = client.ask(buildSystemPrompt(plan.release, baseRef), diffText(plan.analysisBase, plan.files), 4096);
459
- if (resp.error) {
784
+ const systemPrompt = buildSystemPrompt(plan.release, baseRef);
785
+ const partition = plan.partition;
786
+
787
+ if (partition.chunks.length === 0) {
788
+ // Reviewing nothing while saying nothing is the state four releases
789
+ // were already in, so this reports NEUTRAL with the reason. The
790
+ // reason is DERIVED rather than asserted: the first version said
791
+ // "every changed file exceeds the per-request budget", which is one
792
+ // of several ways to get here and was simply wrong for the others
793
+ // (an empty per-file diff, for instance).
794
+ const why =
795
+ partition.unreviewed.length > 0
796
+ ? `${String(partition.unreviewed.length)} path(s) could not be placed in a ` +
797
+ `request: ${partition.unreviewed[0]?.reason ?? 'unknown'}`
798
+ : 'no file produced a diff to review';
460
799
  process.stdout.write(
461
- `::warning::self-review-gate NEUTRAL — model call did not complete (${resp.error}); ` +
462
- 'nothing was reviewed, not a blocker.\n',
800
+ `::warning::self-review-gate NEUTRAL — ${why}; nothing was reviewed, not a ` +
801
+ 'blocker.\n',
463
802
  );
464
- return 0; // no credit / transport / API error → never blocks the merge
803
+ return 0;
465
804
  }
466
- const findings = parseFindings(resp.text);
805
+
806
+ // One request per chunk, findings merged. A chunk that fails does NOT
807
+ // discard the chunks that succeeded: four releases produced no review at
808
+ // all because one failure was the whole review, and a partial review
809
+ // that names its coverage is worth more than another honest null.
810
+ const findings: Finding[] = [];
811
+ const unreviewed = [...partition.unreviewed];
812
+ let reviewedChunks = 0;
813
+ let filesRead = 0;
814
+ for (const [i, chunk] of partition.chunks.entries()) {
815
+ const resp = client.ask(systemPrompt, chunk.text, 4096);
816
+ if (resp.error) {
817
+ process.stdout.write(
818
+ `::warning::self-review-gate — chunk ${String(i + 1)}/` +
819
+ `${String(partition.chunks.length)} did not complete (${resp.error}); ` +
820
+ 'continuing with the remaining chunks.\n',
821
+ );
822
+ // The chunk's OWN files, not a synthetic entry: a failed chunk
823
+ // used to be recorded as one unnamed row while the coverage
824
+ // count still credited all its files as read.
825
+ for (const path of chunk.files) {
826
+ unreviewed.push({
827
+ path,
828
+ reason: `the model call for this file's chunk did not complete: ${resp.error}`,
829
+ });
830
+ }
831
+ continue;
832
+ }
833
+ reviewedChunks += 1;
834
+ filesRead += chunk.files.length;
835
+ findings.push(...parseFindings(resp.text));
836
+ }
837
+
838
+ if (reviewedChunks === 0) {
839
+ process.stdout.write(
840
+ '::warning::self-review-gate NEUTRAL — no chunk completed, nothing was reviewed, ' +
841
+ 'not a blocker.\n',
842
+ );
843
+ return 0;
844
+ }
845
+
846
+ // Counted from the chunks that actually returned, never derived by
847
+ // subtracting rows from the file total. The subtraction was wrong in
848
+ // both of this change's own new states: one aggregate row for N dropped
849
+ // chunks credited N-1 unread files as read, and per-chunk failures were
850
+ // appended AFTER the number was computed.
851
+ const coverage: ReviewCoverage = {
852
+ chunks: reviewedChunks,
853
+ filesReviewed: filesRead,
854
+ filesTotal: plan.files.length,
855
+ unreviewed,
856
+ };
467
857
  const outIdx = argv.indexOf('--findings-out');
468
858
  if (outIdx >= 0 && argv[outIdx + 1]) {
469
859
  // Durable ingestion input for the disposition ledger (release-truth
@@ -478,17 +868,21 @@ export function main(argv: string[]): 0 | 2 {
478
868
  {
479
869
  schema_version: 1,
480
870
  ...independenceFields(['anthropic']),
481
- findings: findings.map((f) => ({ finding_id: findingId(f), ...f })),
871
+ coverage,
872
+ findings: dedupeFindings(findings).map((f) => ({
873
+ finding_id: findingId(f),
874
+ ...f,
875
+ })),
482
876
  },
483
877
  null,
484
878
  2,
485
879
  ) + '\n',
486
880
  );
487
881
  }
488
- const body = renderReview(findings, enforce, plan.escalation);
882
+ const body = renderReview(dedupeFindings(findings), enforce, plan.escalation, coverage);
489
883
  postReview(body);
490
884
 
491
- const code = gateVerdict(findings, { enforce });
885
+ const code = gateVerdict(dedupeFindings(findings), { enforce });
492
886
  if (code === 2) {
493
887
  process.stdout.write('::error::self-review-gate — merge-blocking findings under enforce mode.\n');
494
888
  }