@expo/code-review-cli 0.7.0 → 0.8.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 (54) hide show
  1. package/README.md +118 -13
  2. package/build/cli.js +7 -0
  3. package/build/commands/ci.js +299 -28
  4. package/build/commands/dismiss.js +6 -0
  5. package/build/commands/doctor.js +3 -0
  6. package/build/commands/feedback.js +433 -0
  7. package/build/commands/init.js +231 -15
  8. package/build/commands/review.js +191 -51
  9. package/build/commands/setup-auth.js +3 -0
  10. package/build/commands/verify-config.js +3 -0
  11. package/build/config/load.js +39 -0
  12. package/build/config/routing.js +7 -0
  13. package/build/config/schema.js +92 -0
  14. package/build/core/adjudicate.js +194 -0
  15. package/build/core/auth.js +5 -1
  16. package/build/core/claude-code.js +12 -1
  17. package/build/core/context-file.js +42 -0
  18. package/build/core/coordinator.js +2 -2
  19. package/build/core/diff.js +1 -0
  20. package/build/core/exec.js +4 -0
  21. package/build/core/log.js +1 -0
  22. package/build/core/noise.js +5 -0
  23. package/build/core/opencode.js +22 -0
  24. package/build/core/prompts.js +311 -3
  25. package/build/core/render.js +255 -45
  26. package/build/core/responses.js +158 -0
  27. package/build/core/review.js +290 -15
  28. package/build/core/schema.js +213 -2
  29. package/build/core/scrub.js +4 -0
  30. package/build/core/stack-confirm.js +137 -0
  31. package/build/core/stack.js +25 -0
  32. package/build/core/step-summary.js +1 -0
  33. package/build/core/suppress.js +2 -0
  34. package/build/core/throttle.js +2 -0
  35. package/build/core/util.js +1 -0
  36. package/build/core/verify.js +5 -0
  37. package/build/reporters/github.js +465 -31
  38. package/build/reporters/terminal.js +2 -0
  39. package/build/sources/github-pr.js +272 -0
  40. package/build/sources/local-git.js +3 -0
  41. package/build/sources/source.js +35 -0
  42. package/package.json +2 -1
  43. package/templates/agents/consistency.md +2 -0
  44. package/templates/agents/correctness.md +2 -0
  45. package/templates/agents/security.md +3 -0
  46. package/templates/atlantis.yml +123 -0
  47. package/templates/command.yml +4 -0
  48. package/templates/config.jsonc +50 -1
  49. package/templates/coordinator.md +34 -9
  50. package/templates/dismiss.yml +4 -0
  51. package/templates/routing.jsonc +3 -0
  52. package/templates/scope-config.jsonc +1 -0
  53. package/templates/shared.md +96 -1
  54. package/templates/workflow.yml +5 -0
@@ -1,13 +1,17 @@
1
+ // @ref LLP 0007#ecr-ci-the-trusted-root-run — fails CLOSED: trusted-root materialization failure never falls back to reading the checkout
1
2
  import { readFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { CONFIG_DIRNAME, hasScopeConfig, loadAuthFromRoot, loadReviewConfig, loadScopeConfig, tokenEnvMismatch, } from "../config/load.js";
4
5
  import { loadRoutingManifest, resolveScopes, scopedCommentTag, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
5
6
  import { repoRoot, resolveTrustedTool, run } from "../core/exec.js";
6
7
  import { errorMessage, publicFailureReason } from "../core/util.js";
8
+ import { readContextFile } from "../core/context-file.js";
7
9
  import { buildDiffLineIndex } from "../core/render.js";
10
+ import { applyPins, collectPins, scopedFingerprint } from "../core/schema.js";
11
+ import { dropStaleVerdict, feedbackApplied, feedbackNeedsRunSeam } from "../core/adjudicate.js";
8
12
  import { runReview } from "../core/review.js";
9
13
  import { GitHubPRSource } from "../sources/github-pr.js";
10
- import { memoizeSource } from "../sources/source.js";
14
+ import { memoizeSource, stackConfirmFromConfig, stackWalkFromConfig } from "../sources/source.js";
11
15
  import { GitHubReporter } from "../reporters/github.js";
12
16
  /** Resolve the PR number from the Actions event payload or GITHUB_REF. */
13
17
  async function resolvePrNumber() {
@@ -63,7 +67,11 @@ Options:
63
67
  prompts, model, auth mapping) that evaluates itself.
64
68
  Never scaffolded; prints a security warning; will be
65
69
  removed on a scheduled minor boundary.
70
+ --context-file <p> Inject <p>'s UTF-8 text into reviewer prompts as UNTRUSTED
71
+ external context (missing/oversized file: warn, continue)
66
72
  --comment <mode> Override manifest comment mode: single | per-scope
73
+ --no-stack-aware Force stack-aware requalification off for this run (it is
74
+ otherwise auto-enabled from the trusted-base stack.enabled)
67
75
  --force Manual override: review even if the trigger policy (label
68
76
  trigger / ai-review:skip) would skip. Break-glass and the
69
77
  auth lock still apply. A /review comment command implies this.
@@ -82,12 +90,18 @@ export async function ciCommand(argv = []) {
82
90
  const route = argv.includes("--route");
83
91
  const scopesFilter = parseListFlag(argv, "--scopes");
84
92
  const commentOverride = parseCommentMode(argv);
93
+ // Stack-aware review auto-enables from the trusted-base config; this argv escape
94
+ // hatch forces it off for one run. Safe to expose: it only ever makes the review
95
+ // more conservative (findings stay blocking).
96
+ const noStackAware = argv.includes("--no-stack-aware");
85
97
  // The ROOT config dir escape hatch (mirrors `ecr review`): an explicit
86
98
  // --config-dir wins, else resolveConfigDir falls back to ECR_CONFIG_DIR, else
87
99
  // the default .expo-code-review/. Applies to config.jsonc AND routing.jsonc.
88
100
  let configDir;
101
+ let contextFile;
89
102
  try {
90
103
  configDir = parseValueFlag(argv, "--config-dir");
104
+ contextFile = parseValueFlag(argv, "--context-file");
91
105
  }
92
106
  catch (error) {
93
107
  process.stderr.write(`${errorMessage(error)}\n\n${CI_USAGE}`);
@@ -103,6 +117,25 @@ export async function ciCommand(argv = []) {
103
117
  process.chdir(root);
104
118
  }
105
119
  const cwd = process.cwd();
120
+ // @ref LLP 0007#ecr-ci-the-trusted-root-run [implements] — --context-file degrades to no-context on read error; never fails checks
121
+ // Read the context file ONCE (routed CI runs runReview per scope; reading inside
122
+ // would re-read it N times). CI WARNS and continues on any read error: a broken
123
+ // Atlantis-provided plan file must never turn the PR's check red.
124
+ let contextText;
125
+ if (contextFile) {
126
+ try {
127
+ contextText = await readContextFile(contextFile);
128
+ }
129
+ catch (error) {
130
+ process.stderr.write(`CI reviewer: --context-file unusable, continuing without it: ${errorMessage(error)}\n`);
131
+ }
132
+ // Empty/whitespace-only plan file (e.g. Atlantis wrote nothing): warn and
133
+ // continue with no context — never fail the check on it.
134
+ if (contextText != null && !contextText.trim()) {
135
+ process.stderr.write(`CI reviewer: --context-file ${contextFile} is empty; continuing without context.\n`);
136
+ contextText = undefined;
137
+ }
138
+ }
106
139
  const repo = process.env.GITHUB_REPOSITORY;
107
140
  const prNumber = await resolvePrNumber();
108
141
  if (!repo || prNumber == null) {
@@ -113,6 +146,7 @@ export async function ciCommand(argv = []) {
113
146
  // the PR-head read root are each fetched once and shared across scopes.
114
147
  const ghSource = new GitHubPRSource({ prNumber, repo, cwd });
115
148
  const source = memoizeSource(ghSource);
149
+ // @ref LLP 0007#ecr-ci-the-trusted-root-run [implements] — trusted base config; fail closed with one hardcoded-tag terminal comment
116
150
  // Trusted configuration root: review policy and reviewer config load from the
117
151
  // PR's immutable BASE commit, so the PR head is data, never policy. Fail CLOSED:
118
152
  // when the base can't be materialized, post the one terminal comment and stop —
@@ -155,6 +189,8 @@ export async function ciCommand(argv = []) {
155
189
  route,
156
190
  bypassTriggerGate,
157
191
  configDir,
192
+ contextText,
193
+ noStackAware,
158
194
  });
159
195
  return;
160
196
  }
@@ -166,6 +202,8 @@ export async function ciCommand(argv = []) {
166
202
  commentOverride,
167
203
  bypassTriggerGate,
168
204
  configDir,
205
+ contextText,
206
+ noStackAware,
169
207
  });
170
208
  }
171
209
  catch (error) {
@@ -206,6 +244,144 @@ async function postTerminalFailureNote(repo, prNumber, cwd, reason) {
206
244
  process.stderr.write(`CI reviewer: also failed to post the failure notice: ${errorMessage(postError)}\n`);
207
245
  }
208
246
  }
247
+ // @ref LLP 0011#the-rebuttal-is-a-hypothesis [constrained-by] — only "adjudicate" mode runs the model; the reporter reads the same comment it posts to, and `fpOf` keys records the same way that comment stores them, so a prior verdict carries and the same words are not re-judged
248
+ /**
249
+ * The runReview feedback seam for a single-comment reporter. Wired when the mode
250
+ * judges replies ("adjudicate") OR when `dismiss` is opted in — a maintainer reply
251
+ * clearing a finding under `mode: "annotate"` needs the seam to compute `applied`,
252
+ * with no model call (see feedbackNeedsRunSeam). Otherwise the reporter matches
253
+ * replies itself at report time and the seam is absent (undefined). `fpOf` maps a
254
+ * finding to the fingerprint the target comment stores its feedback under
255
+ * (scope-namespaced for the aggregate comment, plain otherwise); omit it for the
256
+ * plain default.
257
+ */
258
+ function adjudicationSeam(config, reporter, fpOf) {
259
+ if (!feedbackNeedsRunSeam(config.feedback)) {
260
+ return undefined;
261
+ }
262
+ return {
263
+ config: config.feedback,
264
+ match: (review) => reporter.matchAdjudicationItems(review, fpOf),
265
+ };
266
+ }
267
+ // @ref LLP 0011#hard-floors-in-code [constrained-by] — one reporter per comment, so the
268
+ // PR-author lookup and the paginated comment list are fetched once per scope, not twice
269
+ /**
270
+ * Memoize one value per scope name. Used for the per-scope GitHubReporter: a scope's
271
+ * feedback seam reads the very comment that scope's report writes, and each reporter
272
+ * instance holds its own comment-list TTL cache and bot-login memo, so a second instance
273
+ * for the same comment tag re-fetches the identical paginated comment list and re-resolves
274
+ * the login. Exported so that sharing is unit-testable.
275
+ */
276
+ export function memoizeByScope(build) {
277
+ const cache = new Map();
278
+ return (scope) => {
279
+ let value = cache.get(scope);
280
+ if (value === undefined) {
281
+ value = build(scope);
282
+ cache.set(scope, value);
283
+ }
284
+ return value;
285
+ };
286
+ }
287
+ /**
288
+ * A freshly-reviewed scope's adjudicated feedback records, already keyed under the
289
+ * scope-namespaced ids the aggregate comment renders (the single-mode seam builds them
290
+ * with a scoped `fpOf`, see runRoutedCi), so they merge into the aggregate view as-is.
291
+ * A carried-over prior scope (no fresh feedback) contributes nothing here.
292
+ */
293
+ function scopeFeedbackRecords(result) {
294
+ return result.review.feedback ?? [];
295
+ }
296
+ // @ref LLP 0011#the-rebuttal-is-a-hypothesis [constrained-by] — matchAdjudicationItems
297
+ // throws on a seam fetch error BY DESIGN so the legacy/per-scope paths fall back to
298
+ // computeFeedback's stored-state preservation; runReview leaves `review.feedback`
299
+ // undefined in exactly that case (never `[]` — a successful-but-empty seam sets `[]`,
300
+ // which is truthy, see scopeFeedbackRecords). This merge honors the same rule for the
301
+ // aggregate comment: a scope's fresh records are authoritative for its findings'
302
+ // fingerprints ONLY when its own seam actually returned records this run.
303
+ // @ref LLP 0011#suppression-is-never-silent [implements] — the aggregate path applies
304
+ // the SAME dropStaleVerdict rule as the reporter's mergeFeedback, so a verdict can never
305
+ // clear a finding against a head it never judged just because this path was the one used.
306
+ // @ref LLP 0011#the-rebuttal-is-a-hypothesis — fresh records come ONLY from `results`,
307
+ // never `finalResults`: a carried scope's `finalResults` entry can still embed a stale
308
+ // per-scope `review.feedback` copy from a past full run, which would resurrect a record
309
+ // a human /undismiss already overrode at the top level.
310
+ /**
311
+ * The aggregate comment's feedback records for comment:'single' mode, merging:
312
+ * - fresh records for scopes whose seam succeeded this run (`review.feedback` is an
313
+ * array, possibly empty) — authoritative for their findings' fingerprints, so a
314
+ * prior record with no fresh counterpart there means the reply is gone
315
+ * (deleted/edited) and is dropped;
316
+ * - prior records for every OTHER fingerprint: carried-over scopes (a `--scopes`
317
+ * partial run) AND a re-reviewed scope whose seam itself failed (`review.feedback`
318
+ * stayed undefined) — so a transient GitHub fetch error can never delete a scope's
319
+ * prior reply attributions/verdicts with nothing to replace them.
320
+ *
321
+ * Every record taken from `prior` first goes through `dropStaleVerdict` against
322
+ * `headSha` — the SAME rule mergeFeedback applies on the reporter side. Without it this
323
+ * path would re-apply a verdict decided against a head the run no longer reviews: a
324
+ * scope whose seam threw keeps its prior record, `feedbackApplied` never looks at
325
+ * `sourceSha`, and reportAggregate skips computeFeedback when it gets explicit records,
326
+ * so nothing else would ever check. Fresh records are NOT re-checked — they were just
327
+ * stamped with this run's own source.
328
+ *
329
+ * `applied` is recomputed on every kept record (idempotent for fresh ones) so a
330
+ * carried/fallback record also honors a `dismiss` policy that changed since it was
331
+ * stored. `results` is this run's freshly-reviewed scopes — the SOLE source of fresh
332
+ * records AND the set whose fingerprints are fresh-authoritative; a carried scope's
333
+ * prior records already ride the `prior` top-level map, so reading records back out of
334
+ * `finalResults` would re-inject a stale per-scope copy (e.g. one a human /undismiss
335
+ * already overrode at the top level). `finalResults` is the full set the aggregate
336
+ * comment renders (includes scopes carried over via `mergePartialAggregate`), used only
337
+ * to resolve each kept record's current finding. Pure so it's unit-testable.
338
+ */
339
+ export function mergeAggregateFeedback(results, finalResults, prior, feedbackConfig, headSha, pins) {
340
+ const scopedFpOf = (result, finding) => scopedFingerprint(result.isDefault ? null : result.scope, finding);
341
+ const seamOkByScope = new Map(results.map((result) => [
342
+ result.scope,
343
+ result.review.feedback !== undefined,
344
+ ]));
345
+ const freshFps = new Set(results
346
+ .filter((result) => seamOkByScope.get(result.scope))
347
+ .flatMap((result) => result.review.findings.map((f) => scopedFpOf(result, f))));
348
+ const byFp = new Map(prior
349
+ .filter((record) => !freshFps.has(record.fp))
350
+ // A verdict decided against a head this run no longer reviews is dropped here,
351
+ // exactly as mergeFeedback drops it: the reply is judged again instead of
352
+ // clearing the finding against source it never saw.
353
+ .map((record) => [record.fp, dropStaleVerdict(record, headSha)]));
354
+ // Fresh records come only from THIS run's reviewed scopes; carried scopes'
355
+ // records already sit in `prior` above (a re-inject from `finalResults` would
356
+ // resurrect a stale per-scope copy over a newer top-level record).
357
+ for (const record of results.flatMap(scopeFeedbackRecords)) {
358
+ byFp.set(record.fp, record);
359
+ }
360
+ const findingByFp = new Map(finalResults.flatMap((result) => result.review.findings.map((f) => [scopedFpOf(result, f), f])));
361
+ // The maintainer `/undismiss` pins are state about FINDINGS, so they apply to fresh
362
+ // and carried records alike — a scope whose seam matched no reply this run must not
363
+ // hand back a record that a pin no longer covers.
364
+ const { records } = applyPins([...byFp.values()], collectPins(pins, prior));
365
+ return records.map((record) => {
366
+ const finding = findingByFp.get(record.fp);
367
+ return finding
368
+ ? { ...record, applied: feedbackApplied(finding, record, feedbackConfig) }
369
+ : record;
370
+ });
371
+ }
372
+ // @ref LLP 0010#config-and-cli-surface [implements] — under ci, stack-aware is gated by the trusted-base config; a stack walk failure only ever warns (source fails open), never a check failure
373
+ /** Resolve the walk bounds when stack-aware is on (trusted-base enabled AND not
374
+ * forced off), else undefined (feature off → the manifest fetch is skipped). */
375
+ function resolveStackWalk(stack, noStackAware) {
376
+ return stack.enabled && !noStackAware ? stackWalkFromConfig(stack) : undefined;
377
+ }
378
+ // @ref LLP 0010#patch-level-confirmation-v2 [constrained-by] — v2 rides the same trusted-base gate as the walk, plus stack.confirmWithPatch (default false, so it ships dark)
379
+ /** Resolve the v2 patch-confirmation cap when the walk is on AND confirmWithPatch is
380
+ * set in the trusted-base config, else undefined (v2 off → grounding is the floor). */
381
+ function resolveStackConfirm(stack, noStackAware) {
382
+ return stack.enabled && !noStackAware ? stackConfirmFromConfig(stack) : undefined;
383
+ }
384
+ // @ref LLP 0007#ecr-ci-the-trusted-root-run [constrained-by] — run logs anchor at the workspace, never the removed-on-exit trusted root
209
385
  /**
210
386
  * Run-log + patch-workspace anchor: ALWAYS the workspace checkout, never the
211
387
  * (temporary, removed-on-exit) trusted config root — the workflow uploads
@@ -219,7 +395,7 @@ function workspaceRunsDir(cwd) {
219
395
  * routing.jsonc the CLI behaves exactly as before (backcompat invariant).
220
396
  */
221
397
  async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
222
- const { agents, route, bypassTriggerGate, configDir } = options;
398
+ const { agents, route, bypassTriggerGate, configDir, contextText, noStackAware } = options;
223
399
  let config;
224
400
  try {
225
401
  config = await loadReviewConfig(configRoot, { configDir });
@@ -228,6 +404,7 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
228
404
  process.stderr.write(`CI reviewer: ${errorMessage(error)}\n`);
229
405
  return;
230
406
  }
407
+ // @ref LLP 0007#verify-config-the-config-guard [implements] — runtime auth lock; the workflow bash sweep is only layer 2
231
408
  // Layer-1 auth lock (mirrors doctor): when the workflow pins the expected token
232
409
  // env var name, refuse to run if the config names anything else. The workflow's
233
410
  // bash guard is a text sweep (layer 2) and can't see through JSON escapes; this
@@ -240,17 +417,32 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
240
417
  return;
241
418
  }
242
419
  }
420
+ // @ref LLP 0007#trigger-policy-and-break-glass [implements] — exact-match labels; /review bypasses only the trigger gate
243
421
  // Config-driven trigger policy (.expo-code-review/config.jsonc → review): decide
244
422
  // whether this PR should be reviewed at all (bypassed by a manual /review).
245
423
  if (!passesTriggerGate(await fetchPrLabels(repo, prNumber, cwd), config.review, bypassTriggerGate)) {
246
424
  return;
247
425
  }
426
+ // The head commit this run reviews: it binds every adjudication verdict to the source
427
+ // it judged, so a stored verdict carries to the next run only while the head is
428
+ // unchanged (see mergeFeedback). Memoized on the source — no extra API call. A
429
+ // metadata failure leaves it unknown, which re-judges replies instead of trusting a
430
+ // verdict against source we cannot pin.
431
+ let headSha;
432
+ try {
433
+ headSha = (await source.getMetadata()).headOid;
434
+ }
435
+ catch {
436
+ // leave headSha unset → stored verdicts do not carry (fail safe, costs budget only)
437
+ }
248
438
  const reporter = new GitHubReporter({
249
439
  prNumber,
250
440
  repo,
251
441
  commentTag: config.commentTag,
252
442
  breakGlassMarker: config.breakGlassMarker,
253
443
  cwd,
444
+ feedback: config.feedback,
445
+ headSha,
254
446
  });
255
447
  try {
256
448
  if (await reporter.checkBreakGlass()) {
@@ -268,10 +460,18 @@ async function runLegacyCi(source, repo, prNumber, cwd, configRoot, options) {
268
460
  mode: "ci",
269
461
  agents,
270
462
  route,
463
+ contextText,
464
+ stack: resolveStackWalk(config.stack, noStackAware),
465
+ stackConfirm: resolveStackConfirm(config.stack, noStackAware),
271
466
  runsDir: workspaceRunsDir(cwd),
467
+ // Adjudicate mode judges the matched replies against the source before the
468
+ // comment is rendered; annotate mode lets the reporter match them at report
469
+ // time. Either way the feedback path is fail-open (runReview swallows its own
470
+ // errors), so it never fails the PR's checks.
471
+ feedback: adjudicationSeam(config, reporter),
272
472
  onProgress: (message) => process.stderr.write(`${message}\n`),
273
473
  });
274
- await reporter.report(review);
474
+ await reporter.report(review, review.feedback);
275
475
  process.stderr.write(`CI reviewer: posted review (${review.decision}).\n`);
276
476
  }
277
477
  catch (error) {
@@ -303,9 +503,10 @@ function failureReview(scopeName, reason) {
303
503
  incomplete: [],
304
504
  };
305
505
  }
506
+ // @ref LLP 0007#routed-ci-fan-out [implements] — root tag wins; sequential per-scope budgets; partial --scopes merges prior state
306
507
  /** The routing fan-out: one process, N scopes reviewed sequentially, one render. */
307
508
  async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, options) {
308
- const { agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir } = options;
509
+ const { agents, route, scopesFilter, commentOverride, bypassTriggerGate, configDir, contextText, noStackAware, } = options;
309
510
  // The root config + manifest follow the override; scope configs stay
310
511
  // relative to the TRUSTED root (loadScopeConfig reads
311
512
  // <configRoot>/<scope.config>/.expo-code-review).
@@ -397,11 +598,15 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
397
598
  prNumber,
398
599
  diffLines: buildDiffLineIndex(changed.map((file) => ({ path: file.path, patch: file.patch }))),
399
600
  };
601
+ // The reviewed head OID also binds each adjudication verdict to the source it judged
602
+ // (see mergeFeedback); unresolved leaves it unknown, so verdicts are re-judged.
603
+ let headSha;
400
604
  try {
401
- const { baseOid } = await source.getMetadata();
605
+ const { baseOid, headOid } = await source.getMetadata();
402
606
  if (baseOid) {
403
607
  link.baseSha = baseOid;
404
608
  }
609
+ headSha = headOid;
405
610
  }
406
611
  catch {
407
612
  // leave baseSha unset → out-of-diff findings degrade to plain text
@@ -417,6 +622,35 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
417
622
  const expectedMinutes = Math.round((active.length * budget) / 60_000);
418
623
  process.stderr.write(`CI reviewer: ⚠ ${active.length} scopes × floor = ${expectedMinutes}min exceeds budget.totalPassesMinutes (${manifest.budget.totalPassesMinutes}m); expect longer runs — raise the job timeout or trim scopes.\n`);
419
624
  }
625
+ // One PR has one stack: resolve the walk once from the ROOT (trusted-base) config
626
+ // and share it across every scope. The source memoizes the actual fetch.
627
+ const stackWalk = resolveStackWalk(rootConfig.stack, noStackAware);
628
+ const stackConfirm = resolveStackConfirm(rootConfig.stack, noStackAware);
629
+ const reporterFor = (tag, withLink = false) => new GitHubReporter({
630
+ prNumber,
631
+ repo,
632
+ commentTag: tag,
633
+ breakGlassMarker: rootConfig.breakGlassMarker,
634
+ cwd,
635
+ linkContext: withLink ? link : undefined,
636
+ // Root-only feedback config (see loadScopeConfig): lets a reporter posting with
637
+ // no explicit records match replies itself (annotate mode) at report time.
638
+ feedback: rootConfig.feedback,
639
+ headSha,
640
+ });
641
+ // comment:'single' mode: every active scope's feedback seam AND the final
642
+ // aggregate post target the SAME root-tag comment, so share one reporter
643
+ // instance (and its comment-list/login cache, see GitHubReporter's
644
+ // `fetchAllComments` TTL cache) across the whole run — a fresh reporter per
645
+ // scope would otherwise re-fetch the paginated comment list and re-resolve
646
+ // the bot login once per scope for the identical comment.
647
+ const singleModeReporter = mode === "single" ? reporterFor(rootTag, true) : undefined;
648
+ // Per-scope mode has the same property, one comment tag at a time: a scope's feedback
649
+ // seam reads the very comment that scope's review is posted to, so both must use the
650
+ // same instance or each scope re-fetches its comment list and re-resolves the bot
651
+ // login. `withLink` is inert for the seam (matchAdjudicationItems renders nothing), so
652
+ // one link-carrying reporter serves both uses.
653
+ const scopeReporter = memoizeByScope((name) => reporterFor(scopedCommentTag(rootTag, name), true));
420
654
  const results = [];
421
655
  for (const scope of active) {
422
656
  const scopeDef = manifest.scopes.find((entry) => entry.name === scope.name);
@@ -435,14 +669,28 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
435
669
  effectiveScopeDef = { ...scopeDef, config: "." };
436
670
  }
437
671
  const config = await loadScopeConfig(configRoot, effectiveScopeDef, manifest, rootConfig);
672
+ // The seam reads the replies off the comment this scope's review will land
673
+ // in — the aggregate (root) comment in "single" mode, the scoped comment
674
+ // otherwise — so prior verdicts carry. The fingerprint the records are keyed
675
+ // under must match how THAT comment stores them: scope-namespaced for the
676
+ // aggregate comment, plain for the scoped one. Feedback is root-only, so gate on it.
677
+ const feedbackSeam = feedbackNeedsRunSeam(rootConfig.feedback)
678
+ ? adjudicationSeam(rootConfig, mode === "single" ? singleModeReporter : scopeReporter(scope.name), mode === "single"
679
+ ? (finding) => scopedFingerprint(isDefault ? null : scope.name, finding)
680
+ : undefined)
681
+ : undefined;
438
682
  review = await runReview(source, {
439
683
  config,
440
684
  mode: "ci",
441
685
  agents,
442
686
  route,
443
687
  includePaths: scope.files,
688
+ contextText,
689
+ stack: stackWalk,
690
+ stackConfirm,
444
691
  passesBudgetMs: budget,
445
692
  runsDir: workspaceRunsDir(cwd),
693
+ feedback: feedbackSeam,
446
694
  onProgress: (message) => process.stderr.write(`[${scope.name}] ${message}\n`),
447
695
  });
448
696
  }
@@ -452,34 +700,36 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
452
700
  }
453
701
  results.push({ scope: scope.name, isDefault, review });
454
702
  }
455
- const reporterFor = (tag, withLink = false) => new GitHubReporter({
456
- prNumber,
457
- repo,
458
- commentTag: tag,
459
- breakGlassMarker: rootConfig.breakGlassMarker,
460
- cwd,
461
- linkContext: withLink ? link : undefined,
462
- });
463
703
  if (mode === "single") {
464
- const aggregate = reporterFor(rootTag, true);
704
+ const aggregate = singleModeReporter;
465
705
  let finalResults = results;
466
706
  if (scopesFilter) {
467
707
  // A partial run (--scopes) is authoritative ONLY for the named scopes: merge
468
708
  // the other scopes' previous results out of the existing aggregate comment's
469
709
  // state so re-running one scope doesn't silently discard the rest.
470
710
  const prior = (await aggregate.readState())?.scopes ?? [];
471
- const byName = new Map(results.map((result) => [result.scope, result]));
472
- for (const previous of prior) {
473
- if (!scopesFilter.includes(previous.scope) && !byName.has(previous.scope)) {
474
- byName.set(previous.scope, previous);
475
- }
476
- }
477
- // Manifest order; scopes no longer in the manifest drop out.
478
- finalResults = manifest.scopes
479
- .map((scope) => byName.get(scope.name))
480
- .filter((entry) => entry != null);
711
+ finalResults = mergePartialAggregate(results, prior, scopesFilter, manifest.scopes.map((scope) => scope.name));
712
+ }
713
+ if (finalResults.length === 0) {
714
+ // No scope has anything to report (no changed file matched any scope, and no
715
+ // prior scope results carry over): a review of nothing is not a review, so
716
+ // post nothing and delete a stale aggregate comment from an earlier run
717
+ // instead of leaving it up. The unmatched-files warning stays in the job log.
718
+ // @ref LLP 0007#routed-ci-fan-out — zero active scopes → no comment
719
+ await aggregate.clear();
720
+ }
721
+ else {
722
+ // A seam-backed run hands the reporter the computed records (re-keyed to the
723
+ // scope-namespaced ids the aggregate renders under). Without the seam it
724
+ // passes none and the reporter matches replies itself.
725
+ const aggState = feedbackNeedsRunSeam(rootConfig.feedback)
726
+ ? await aggregate.readState()
727
+ : null;
728
+ const aggFeedback = feedbackNeedsRunSeam(rootConfig.feedback)
729
+ ? mergeAggregateFeedback(results, finalResults, aggState?.feedback ?? [], rootConfig.feedback, headSha, aggState?.pins)
730
+ : undefined;
731
+ await aggregate.reportAggregate(finalResults, resolution.unmatched, aggFeedback);
481
732
  }
482
- await aggregate.reportAggregate(finalResults, resolution.unmatched);
483
733
  // Clean up any per-scope comments from a previous per-scope run. A partial run
484
734
  // only ever touches the named scopes' comments.
485
735
  for (const scope of manifest.scopes) {
@@ -488,7 +738,9 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
488
738
  }
489
739
  await reporterFor(scopedCommentTag(rootTag, scope.name)).clear();
490
740
  }
491
- process.stderr.write(`CI reviewer: posted aggregate review for ${finalResults.length} scope(s).\n`);
741
+ process.stderr.write(finalResults.length === 0
742
+ ? "CI reviewer: no scope matched the changed files; nothing posted.\n"
743
+ : `CI reviewer: posted aggregate review for ${finalResults.length} scope(s).\n`);
492
744
  }
493
745
  else {
494
746
  for (const scope of manifest.scopes) {
@@ -497,10 +749,12 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
497
749
  if (scopesFilter && !scopesFilter.includes(scope.name)) {
498
750
  continue;
499
751
  }
500
- const reporter = reporterFor(scopedCommentTag(rootTag, scope.name), true);
752
+ // The same instance the scope's feedback seam already used (its comment-list and
753
+ // login caches are per-instance), so the report reuses that fetch.
754
+ const reporter = scopeReporter(scope.name);
501
755
  const result = results.find((entry) => entry.scope === scope.name);
502
756
  if (result) {
503
- await reporter.report(result.review);
757
+ await reporter.report(result.review, result.review.feedback);
504
758
  }
505
759
  else {
506
760
  // A reconciled scope with zero matched files gets its stale comment deleted.
@@ -517,6 +771,23 @@ async function runRoutedCi(source, manifest, repo, prNumber, cwd, configRoot, op
517
771
  process.stderr.write("CI reviewer: posted per-scope reviews.\n");
518
772
  }
519
773
  }
774
+ /**
775
+ * Merge a partial run's fresh results (--scopes) with the prior aggregate
776
+ * comment's stored state, in manifest order. Fresh results win for their scope;
777
+ * scopes outside the filter keep their previous result; scopes no longer in the
778
+ * manifest drop out. Pure so it's unit-testable.
779
+ */
780
+ export function mergePartialAggregate(results, prior, scopesFilter, manifestScopeNames) {
781
+ const byName = new Map(results.map((result) => [result.scope, result]));
782
+ for (const previous of prior) {
783
+ if (!scopesFilter.includes(previous.scope) && !byName.has(previous.scope)) {
784
+ byName.set(previous.scope, previous);
785
+ }
786
+ }
787
+ return manifestScopeNames
788
+ .map((name) => byName.get(name))
789
+ .filter((entry) => entry != null);
790
+ }
520
791
  /**
521
792
  * Current PR labels via gh (more authoritative than the possibly-stale event
522
793
  * payload); on failure, returns [] so a label-read hiccup never silently skips a
@@ -1,3 +1,4 @@
1
+ // @ref LLP 0007#init-and-dismiss [implements] — display-only mutation; dismissals persist across re-reviews, never re-run the review
1
2
  import { loadReviewConfig } from "../config/load.js";
2
3
  import { repoRoot, resolveRepo } from "../core/exec.js";
3
4
  import { errorMessage } from "../core/util.js";
@@ -33,6 +34,7 @@ function parseArgs(argv) {
33
34
  if (arg.startsWith("--")) {
34
35
  throw new Error(`Unknown argument: ${arg}`);
35
36
  }
37
+ // @ref LLP 0007#init-and-dismiss — a malformed id is silently mangled to hex, not rejected; it just becomes "unmatched" later
36
38
  // Bare arg = a finding id. Sanitize to the fingerprint alphabet.
37
39
  args.ids.push(arg.replace(/[^a-f0-9]/g, ""));
38
40
  }
@@ -78,6 +80,10 @@ export async function dismissCommand(argv, mode) {
78
80
  commentTag: config.commentTag,
79
81
  breakGlassMarker: config.breakGlassMarker,
80
82
  cwd,
83
+ // The re-render re-derives every feedback record's `applied` flag, so it needs the
84
+ // feedback policy in force now — without it a reply-cleared finding would be
85
+ // un-hidden by an unrelated /dismiss.
86
+ feedback: config.feedback,
81
87
  });
82
88
  const result = await reporter.applyDismissal(mode === "add" ? args.ids : [], mode === "remove" ? args.ids : [], args.by, args.reason);
83
89
  if (mode === "add") {
@@ -1,3 +1,4 @@
1
+ // @ref LLP 0007#doctor-and-setup-auth — a preflight that must never disagree with a real run
1
2
  import { loadReviewConfig, loadScopeConfig, loadAuthFromRoot, hasConfig, resolveConfigDir, tokenEnvMismatch, } from "../config/load.js";
2
3
  import { loadRoutingManifest, resolveScopes, scopePassesBudgetMs, formatOwnerTable, } from "../config/routing.js";
3
4
  import readline from "node:readline/promises";
@@ -39,6 +40,7 @@ async function opencodeVersion(cliPath) {
39
40
  }
40
41
  return stdout.trim().split("\n")[0]?.trim() || null;
41
42
  }
43
+ // @ref LLP 0007#doctor-and-setup-auth [implements] — folds routed scopes' models in; silently skips unloadable scope configs by design
42
44
  /**
43
45
  * The engines this repo actually drives, mirroring how real reviews resolve them:
44
46
  * engineForModel over every ROOT agent + coordinator model, PLUS every loaded scope
@@ -236,6 +238,7 @@ export async function doctorCommand(argv = []) {
236
238
  if (version.code === 0) {
237
239
  line(true, `claude ${version.stdout.trim().split("\n")[0]?.trim()}`);
238
240
  }
241
+ // @ref LLP 0007#doctor-and-setup-auth [constrained-by] — mirrors startClaudeCode's condition exactly; warn() never flips the exit code
239
242
  // Mirror startClaudeCode's credential condition EXACTLY so doctor fails
240
243
  // iff a review would: a run needs an active `claude` subscription login OR
241
244
  // a token value (the configured tokenEnv, else an ambient