@expo/code-review-cli 0.9.1 → 0.10.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.
@@ -11,9 +11,9 @@ const CLAUDE_MAX_TURNS = 60;
11
11
  /** Fallback per-pass ceiling when a caller passes no maxWaitMs. */
12
12
  const DEFAULT_MAX_WAIT_MS = 8 * 60 * 1000;
13
13
  /**
14
- * A stateless `claude -p` pass emits no incremental tool lines to the caller, so a
15
- * long pass would look hung. Emit a "still working" heartbeat this often (matching
16
- * opencode.ts's HEARTBEAT_MS) to keep the progress signal alive.
14
+ * Emit a "still working" heartbeat after this long with no structured stream
15
+ * activity (matching opencode.ts's HEARTBEAT_MS), so a thinking stretch still has
16
+ * a progress signal between tool calls.
17
17
  */
18
18
  const CLAUDE_HEARTBEAT_MS = 45_000;
19
19
  /**
@@ -215,11 +215,13 @@ export function buildClaudeArgs(opts) {
215
215
  return [
216
216
  "-p",
217
217
  "--output-format",
218
- "json",
218
+ "stream-json",
219
+ "--verbose",
219
220
  "--model",
220
221
  opts.model,
221
222
  "--append-system-prompt",
222
223
  opts.system,
224
+ ...(opts.jsonSchema ? ["--json-schema", JSON.stringify(opts.jsonSchema)] : []),
223
225
  ...(enabled.length > 0 ? ["--allowedTools", ...enabled.map(scope)] : []),
224
226
  "--disallowedTools",
225
227
  ...deniedReadTools,
@@ -232,6 +234,165 @@ export function buildClaudeArgs(opts) {
232
234
  String(opts.maxTurns ?? CLAUDE_MAX_TURNS),
233
235
  ];
234
236
  }
237
+ const CLAUDE_STREAM_MISSING_RESULT = "Claude Code stream ended without a final result event";
238
+ const CLAUDE_RESULT_MISSING_ERROR = "Claude Code returned an error without a message";
239
+ function jsonRecord(value) {
240
+ return value !== null && typeof value === "object" && !Array.isArray(value)
241
+ ? value
242
+ : null;
243
+ }
244
+ /** The final result object from either legacy single JSON or JSONL stream output. */
245
+ function finalClaudeResult(stdout) {
246
+ try {
247
+ const parsed = jsonRecord(JSON.parse(stdout));
248
+ return parsed?.type === "result" ? parsed : null;
249
+ }
250
+ catch {
251
+ let result = null;
252
+ for (const line of stdout.split(/\r?\n/)) {
253
+ if (!line.trim()) {
254
+ continue;
255
+ }
256
+ try {
257
+ const event = jsonRecord(JSON.parse(line));
258
+ if (event?.type === "result") {
259
+ result = event;
260
+ }
261
+ }
262
+ catch {
263
+ // A malformed/non-JSON line cannot be the structured final result.
264
+ }
265
+ }
266
+ return result;
267
+ }
268
+ }
269
+ const MAX_ACTIVITY_DETAIL = 180;
270
+ /** Collapse control/newline injection and bound provider/model-originated log text. */
271
+ function safeActivityDetail(value) {
272
+ if (typeof value !== "string") {
273
+ return undefined;
274
+ }
275
+ const withoutControls = [...value]
276
+ .map((char) => {
277
+ const code = char.charCodeAt(0);
278
+ return code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? " " : char;
279
+ })
280
+ .join("");
281
+ const clean = withoutControls.replace(/\s+/g, " ").trim();
282
+ if (!clean) {
283
+ return undefined;
284
+ }
285
+ return clean.length > MAX_ACTIVITY_DETAIL ? `${clean.slice(0, MAX_ACTIVITY_DETAIL - 1)}…` : clean;
286
+ }
287
+ /** Render an in-tree tool target without exposing attempted host paths. */
288
+ function activityPath(value, cwd) {
289
+ const candidate = safeActivityDetail(value);
290
+ if (!candidate) {
291
+ return undefined;
292
+ }
293
+ const root = path.resolve(cwd);
294
+ const absolute = path.resolve(root, candidate);
295
+ if (absolute !== root && !pathInside(absolute, root)) {
296
+ return undefined;
297
+ }
298
+ const relative = path.relative(root, absolute).replace(/\\/g, "/");
299
+ return relative || ".";
300
+ }
301
+ /**
302
+ * Convert one Claude stream event into safe progress metadata. Raw assistant text,
303
+ * tool results, and grep patterns are deliberately never logged: PR/model content is
304
+ * untrusted and may contain secrets or terminal-control/log-injection payloads.
305
+ */
306
+ // @ref LLP 0003#claude-code-cli-containment [implements] — stream only bounded lifecycle/tool metadata; raw model text and tool results never become progress logs
307
+ export function claudeActivities(eventValue, cwd) {
308
+ const event = jsonRecord(eventValue);
309
+ if (!event) {
310
+ return [];
311
+ }
312
+ if (event.type === "system" && event.subtype === "init") {
313
+ const model = safeActivityDetail(event.model);
314
+ return [{ line: model ? `started ${model}` : "started" }];
315
+ }
316
+ if (event.type === "result") {
317
+ if (event.is_error === true) {
318
+ return [];
319
+ }
320
+ const duration = typeof event.duration_ms === "number"
321
+ ? `${Math.max(0, Math.round(event.duration_ms / 1000))}s`
322
+ : null;
323
+ const turns = typeof event.num_turns === "number" ? `${event.num_turns} turn(s)` : null;
324
+ const detail = [duration, turns].filter(Boolean).join(", ");
325
+ return [{ line: detail ? `completed (${detail})` : "completed" }];
326
+ }
327
+ if (event.type !== "assistant") {
328
+ return [];
329
+ }
330
+ const message = jsonRecord(event.message);
331
+ const content = Array.isArray(message?.content) ? message.content : [];
332
+ const activities = [];
333
+ for (const value of content) {
334
+ const block = jsonRecord(value);
335
+ if (block?.type !== "tool_use" || typeof block.name !== "string") {
336
+ continue;
337
+ }
338
+ const input = jsonRecord(block.input) ?? {};
339
+ const key = typeof block.id === "string" ? block.id : undefined;
340
+ if (block.name === "Read") {
341
+ const target = activityPath(input.file_path, cwd);
342
+ activities.push({ key, line: target ? `Read ${target}` : "Read" });
343
+ }
344
+ else if (block.name === "Grep") {
345
+ const target = activityPath(input.path, cwd);
346
+ activities.push({ key, line: target ? `Grep ${target}` : "Grep" });
347
+ }
348
+ else if (block.name === "Glob") {
349
+ const target = activityPath(input.path, cwd);
350
+ activities.push({ key, line: target && target !== "." ? `Glob ${target}` : "Glob" });
351
+ }
352
+ }
353
+ return activities;
354
+ }
355
+ /** Incremental JSONL decoder for Claude's stream-json stdout. */
356
+ export function createClaudeActivityStream(cwd, onActivity) {
357
+ let buffered = "";
358
+ const reported = new Set();
359
+ const consume = (line) => {
360
+ if (!line.trim()) {
361
+ return;
362
+ }
363
+ try {
364
+ for (const activity of claudeActivities(JSON.parse(line), cwd)) {
365
+ if (activity.key && reported.has(activity.key)) {
366
+ continue;
367
+ }
368
+ if (activity.key) {
369
+ reported.add(activity.key);
370
+ }
371
+ onActivity(activity.line);
372
+ }
373
+ }
374
+ catch {
375
+ // Ignore malformed progress events; final result parsing still reports errors.
376
+ }
377
+ };
378
+ return {
379
+ push(chunk) {
380
+ buffered += chunk;
381
+ for (;;) {
382
+ const newline = buffered.indexOf("\n");
383
+ if (newline < 0) {
384
+ break;
385
+ }
386
+ consume(buffered.slice(0, newline).replace(/\r$/, ""));
387
+ buffered = buffered.slice(newline + 1);
388
+ }
389
+ },
390
+ finish() {
391
+ consume(buffered);
392
+ buffered = "";
393
+ },
394
+ };
395
+ }
235
396
  /**
236
397
  * The model that actually answered, out of the result's modelUsage keys. The CLI
237
398
  * also bills its own internal helper calls there (a haiku entry appears alongside
@@ -248,22 +409,26 @@ export function pickAnsweringModel(requested, modelOutputTokens) {
248
409
  return keys.sort((a, b) => (modelOutputTokens[b] ?? 0) - (modelOutputTokens[a] ?? 0))[0];
249
410
  }
250
411
  /**
251
- * Parse the `--output-format json` result object. Keys off `is_error` / a parse
412
+ * Parse the final result from `--output-format stream-json` JSONL (also accepts the
413
+ * former single JSON object for compatibility/tests). Keys off `is_error` / a parse
252
414
  * failure, NOT `subtype` — `subtype` stays `"success"` on some API errors.
253
415
  */
254
416
  export function parseClaudeResult(stdout) {
255
- let parsed;
256
- try {
257
- parsed = JSON.parse(stdout);
258
- }
259
- catch {
417
+ const parsed = finalClaudeResult(stdout);
418
+ if (!parsed) {
419
+ // The stream may contain assistant text and tool results sourced from the
420
+ // untrusted review tree. Never turn that transcript into an error message or
421
+ // feed it to provider-error classification.
422
+ // @ref LLP 0003#claude-code-cli-containment [constrained-by] — raw JSONL transcript content must never reach logs or error classifiers
260
423
  return {
261
424
  text: "",
262
425
  cost: 0,
263
426
  tokens: {},
264
427
  modelOutputTokens: {},
265
428
  isError: true,
266
- errorText: stdout.trim(),
429
+ errorText: CLAUDE_STREAM_MISSING_RESULT,
430
+ hasStructuredOutput: false,
431
+ structuredOutputFailure: false,
267
432
  };
268
433
  }
269
434
  const usage = (parsed.usage ?? {});
@@ -281,17 +446,41 @@ export function parseClaudeResult(stdout) {
281
446
  for (const [key, value] of Object.entries(modelUsage)) {
282
447
  modelOutputTokens[key] = num(value?.outputTokens) ?? 0;
283
448
  }
284
- const result = typeof parsed.result === "string" ? parsed.result : "";
285
449
  const isError = parsed.is_error === true;
450
+ const hasStructuredOutput = parsed.structured_output !== undefined;
451
+ // `--json-schema` returns a provider-validated object in `structured_output`.
452
+ // Serialize it back through the existing local parser so Zod remains the final
453
+ // trust boundary. Ignore any stale/retracted structured value on an error result;
454
+ // error classification must use only the CLI's explicit final error message.
455
+ const structured = isError || parsed.structured_output === undefined
456
+ ? undefined
457
+ : JSON.stringify(parsed.structured_output);
458
+ const result = structured ?? (typeof parsed.result === "string" ? parsed.result : "");
286
459
  return {
287
460
  text: result,
288
461
  cost: num(parsed.total_cost_usd) ?? 0,
289
462
  tokens,
290
463
  modelOutputTokens,
291
464
  isError,
292
- errorText: isError ? result || stdout.trim() : "",
465
+ // Only the final result event's explicit error text is safe to classify and
466
+ // surface. Falling back to stdout would expose the full JSONL transcript.
467
+ errorText: isError ? result || CLAUDE_RESULT_MISSING_ERROR : "",
468
+ hasStructuredOutput,
469
+ structuredOutputFailure: parsed.subtype === "error_max_structured_output_retries",
293
470
  };
294
471
  }
472
+ /**
473
+ * A provider-side schema failure that spent tokens and may be retried once from a
474
+ * clean process. Carrying the attempt lets the caller retain honest run metrics.
475
+ */
476
+ class ClaudeStructuredOutputError extends Error {
477
+ result;
478
+ constructor(result) {
479
+ super("Claude Code could not produce output matching the required JSON Schema");
480
+ this.result = result;
481
+ this.name = "ClaudeStructuredOutputError";
482
+ }
483
+ }
295
484
  /** Classify a Claude Code failure so the caller can pick backoff vs. hard fail. */
296
485
  export function classifyClaudeError(errorText, apiStatus) {
297
486
  if (apiStatus === 401 || apiStatus === 403) {
@@ -365,6 +554,34 @@ export function claudeTemperatureNote(config, engineOf) {
365
554
  "(for the claude-routed passes)"
366
555
  : null;
367
556
  }
557
+ /**
558
+ * Preserve actionable process diagnostics without copying arbitrary stderr into a
559
+ * public Actions log. A non-empty stream may already contain model/tool content, so
560
+ * stderr is not even classified in that case. With no stream, recognize only fixed
561
+ * CLI/setup categories and never interpolate the matched text.
562
+ */
563
+ // @ref LLP 0003#claude-code-cli-containment [constrained-by] — stderr may reflect untrusted tree content; expose only exit metadata and fixed allowlisted categories
564
+ function claudeExitDiagnostic(result) {
565
+ const exit = `Claude Code exited with code ${result.code}`;
566
+ if (result.stdout.trim() !== "") {
567
+ return exit;
568
+ }
569
+ if (/unknown (?:option|argument)|unrecognized option|unexpected argument/i.test(result.stderr)) {
570
+ return (`${exit}; the CLI rejected its arguments — verify the pinned ` +
571
+ "@anthropic-ai/claude-code version supports the configured flags");
572
+ }
573
+ if (/authentication|oauth|api.?key|unauthorized|\b401\b|\b403\b/i.test(result.stderr)) {
574
+ return (`${exit}; authentication failed before a result was emitted — check ` +
575
+ "`claude auth status` and `ecr doctor`");
576
+ }
577
+ if (/\bENOENT\b|command not found|no such file or directory/i.test(result.stderr)) {
578
+ return `${exit}; the Claude Code executable or one of its required files was not found`;
579
+ }
580
+ if (/\bEACCES\b/i.test(result.stderr)) {
581
+ return `${exit}; the Claude Code executable could not be launched due to permissions`;
582
+ }
583
+ return exit;
584
+ }
368
585
  /**
369
586
  * One prompt → text/cost/tokens/model, as a single `claude -p` subprocess (the
370
587
  * Claude analogue of OpenCode's promptAgent; no sessions/polling).
@@ -380,12 +597,23 @@ export async function runClaudePrompt(handle, args) {
380
597
  // A soft tool-call ceiling doubles as the CLI's per-pass turn bound (the closest
381
598
  // stateless analogue of OpenCode's mid-run tool-call cap).
382
599
  const maxTurns = args.maxToolCalls != null && args.maxToolCalls > 0 ? args.maxToolCalls : undefined;
383
- // A stateless `claude -p` pass streams nothing back, so emit a heartbeat while it
384
- // runs or a long pass looks hung (cleared in finally, whatever the outcome).
600
+ // Stream safe structured activity. The heartbeat fires only after a quiet window,
601
+ // rather than on a fixed cadence that can land immediately after a tool line.
385
602
  const heartbeatStart = Date.now();
603
+ let lastStreamActivityAt = heartbeatStart;
604
+ const emitActivity = (line) => {
605
+ lastStreamActivityAt = Date.now();
606
+ args.onActivity?.(line);
607
+ };
608
+ const activityStream = args.onActivity
609
+ ? createClaudeActivityStream(process.cwd(), emitActivity)
610
+ : undefined;
386
611
  const heartbeat = args.onActivity
387
612
  ? setInterval(() => {
388
- args.onActivity?.(`still working… ${Math.round((Date.now() - heartbeatStart) / 1000)}s elapsed`);
613
+ if (Date.now() - lastStreamActivityAt >= CLAUDE_HEARTBEAT_MS) {
614
+ args.onActivity?.(`still working… ${Math.round((Date.now() - heartbeatStart) / 1000)}s elapsed ` +
615
+ `(no new activity for ${Math.round((Date.now() - lastStreamActivityAt) / 1000)}s)`);
616
+ }
389
617
  }, CLAUDE_HEARTBEAT_MS)
390
618
  : undefined;
391
619
  heartbeat?.unref?.();
@@ -397,15 +625,18 @@ export async function runClaudePrompt(handle, args) {
397
625
  cwd: process.cwd(),
398
626
  tools,
399
627
  maxTurns,
628
+ jsonSchema: args.jsonSchema,
400
629
  }), {
401
630
  input: args.text,
402
631
  env: handle.childEnv,
403
632
  cwd: process.cwd(),
404
633
  timeout: maxWaitMs,
405
634
  check: false,
635
+ onStdout: activityStream?.push,
406
636
  });
407
637
  }
408
638
  finally {
639
+ activityStream?.finish();
409
640
  if (heartbeat) {
410
641
  clearInterval(heartbeat);
411
642
  }
@@ -418,7 +649,7 @@ export async function runClaudePrompt(handle, args) {
418
649
  // A non-timeout signal is a crash (SIGSEGV, OOM SIGKILL, external kill), not a
419
650
  // timeout — surface it as a hard error rather than the subdivide/retry path.
420
651
  if (result.signal) {
421
- throw new Error(`Claude Code was killed by signal ${result.signal}: ${result.stderr.trim() || "(no output)"}`);
652
+ throw new Error(`Claude Code was killed by signal ${result.signal}`);
422
653
  }
423
654
  // Truncated output can't be parsed as JSON; report the cause plainly instead of
424
655
  // letting it fall through as a generic parse failure.
@@ -426,6 +657,28 @@ export async function runClaudePrompt(handle, args) {
426
657
  throw new Error("claude output exceeded the 64MB buffer and was truncated");
427
658
  }
428
659
  const parsed = parseClaudeResult(result.stdout);
660
+ const answered = pickAnsweringModel(configuredModel, parsed.modelOutputTokens);
661
+ const model = answered
662
+ ? claudeModelMatches(configuredModel, answered)
663
+ ? configuredModel
664
+ : `anthropic/${answered}`
665
+ : configuredModel;
666
+ const promptResult = {
667
+ text: parsed.text,
668
+ cost: parsed.cost,
669
+ sessionID: "",
670
+ tokens: parsed.tokens,
671
+ model,
672
+ };
673
+ // A schema-requesting caller must receive the provider-validated object, never a
674
+ // parseable-looking fallback from `result`. Claude repairs schema mismatches in
675
+ // session first; its documented exhaustion result gets one clean-process retry in
676
+ // claudeCodePromptAndParse. A success that omits structured_output is treated the
677
+ // same way because it did not honor the requested provider contract.
678
+ if (args.jsonSchema &&
679
+ (parsed.structuredOutputFailure || (!parsed.isError && !parsed.hasStructuredOutput))) {
680
+ throw new ClaudeStructuredOutputError(promptResult);
681
+ }
429
682
  if (parsed.isError) {
430
683
  const kind = classifyClaudeError(parsed.errorText);
431
684
  if (kind === "rate-limit" || kind === "usage-limit") {
@@ -443,29 +696,26 @@ export async function runClaudePrompt(handle, args) {
443
696
  "`claude setup-token`, set CLAUDE_CODE_OAUTH_TOKEN, and check `claude auth status` / " +
444
697
  "`ecr doctor`.");
445
698
  }
446
- throw new Error(parsed.errorText ||
447
- `claude exited with code ${result.code}: ${result.stderr.trim() || result.stdout.trim() || "(no output)"}`);
699
+ const needsProcessDiagnostic = parsed.errorText === CLAUDE_STREAM_MISSING_RESULT ||
700
+ parsed.errorText === CLAUDE_RESULT_MISSING_ERROR;
701
+ throw new Error(needsProcessDiagnostic
702
+ ? `${parsed.errorText} (${claudeExitDiagnostic(result)})`
703
+ : parsed.errorText);
448
704
  }
449
- const answered = pickAnsweringModel(configuredModel, parsed.modelOutputTokens);
450
- const model = answered
451
- ? claudeModelMatches(configuredModel, answered)
452
- ? configuredModel
453
- : `anthropic/${answered}`
454
- : configuredModel;
455
- return { text: parsed.text, cost: parsed.cost, sessionID: "", tokens: parsed.tokens, model };
705
+ return promptResult;
456
706
  }
457
707
  /**
458
- * The claude analogue of opencode.ts's CORRECTIVE. NOT shared: that one says
459
- * "your previous reply could not be parsed", which is true in OpenCode's
460
- * same-session follow-up but false here each `claude -p` invocation is a fresh
461
- * stateless process with no previous reply to reference.
708
+ * Last-resort correction for a no-schema caller or disagreement between the
709
+ * provider's JSON Schema validator and our local parser. Production parsers pass
710
+ * `--json-schema`, so Claude Code already performs validation-aware repair inside
711
+ * the original session before this fresh-process fallback is needed.
462
712
  */
463
713
  const CLAUDE_CORRECTIVE = "\n\nIMPORTANT: reply with ONLY the single JSON object described above — no prose, " +
464
714
  "no code fences, no partial output.";
465
715
  /**
466
- * Prompt via the Claude Code CLI and parse the reply, mirroring OpenCode's
467
- * promptAndParse: transient retry on the first call, then one corrective re-run
468
- * (a fresh process the diff is inlined, so re-read is a cache hit).
716
+ * Prompt via the Claude Code CLI and parse the provider-validated structured
717
+ * result locally. Transient failures retry first; a remaining local parse failure
718
+ * gets one fresh-process corrective as defense in depth.
469
719
  */
470
720
  export async function claudeCodePromptAndParse(handle, args, parse) {
471
721
  let cost = 0;
@@ -476,20 +726,42 @@ export async function claudeCodePromptAndParse(handle, args, parse) {
476
726
  addTokenUsage(tokens, result.tokens);
477
727
  model = result.model ?? model;
478
728
  };
479
- const first = await withTransientRetry(`Agent "${args.agent}"`, args.onActivity, () => runClaudePrompt(handle, args));
480
- record(first);
729
+ let first;
481
730
  try {
482
- return { value: parse(first.text), cost, truncated: false, tokens, model };
483
- }
484
- catch {
485
- const retry = await runClaudePrompt(handle, { ...args, text: args.text + CLAUDE_CORRECTIVE });
486
- record(retry);
731
+ first = await withTransientRetry(`Agent "${args.agent}"`, args.onActivity, () => runClaudePrompt(handle, args));
732
+ record(first);
487
733
  try {
488
- return { value: parse(retry.text), cost, truncated: false, tokens, model };
734
+ return { value: parse(first.text), cost, truncated: false, tokens, model };
735
+ }
736
+ catch {
737
+ // Provider validation and local Zod validation disagreed. Retry once from a
738
+ // clean process rather than accepting an object the trust boundary rejected.
489
739
  }
490
- catch (finalError) {
491
- throw new Error(`Agent "${args.agent}" did not return parseable JSON after retries: ${finalError instanceof Error ? finalError.message : String(finalError)}`);
740
+ }
741
+ catch (error) {
742
+ if (!(error instanceof ClaudeStructuredOutputError)) {
743
+ throw error;
744
+ }
745
+ record(error.result);
746
+ args.onActivity?.("structured output validation failed — retrying once");
747
+ }
748
+ let retry;
749
+ try {
750
+ retry = await runClaudePrompt(handle, { ...args, text: args.text + CLAUDE_CORRECTIVE });
751
+ }
752
+ catch (error) {
753
+ if (!(error instanceof ClaudeStructuredOutputError)) {
754
+ throw error;
492
755
  }
756
+ record(error.result);
757
+ throw new Error(`Agent "${args.agent}" could not satisfy its required JSON Schema after retries`);
758
+ }
759
+ record(retry);
760
+ try {
761
+ return { value: parse(retry.text), cost, truncated: false, tokens, model };
762
+ }
763
+ catch (finalError) {
764
+ throw new Error(`Agent "${args.agent}" did not return parseable JSON after retries: ${finalError instanceof Error ? finalError.message : String(finalError)}`);
493
765
  }
494
766
  }
495
767
  /**
@@ -7,6 +7,7 @@ import { CONFIG_DIRNAME, stripJsonComments, stripTrailingCommas } from "../confi
7
7
  import { ROUTING_FILENAME } from "../config/routing.js";
8
8
  import { git, pathInside } from "./exec.js";
9
9
  import { matchesIgnore } from "./noise.js";
10
+ import { isAmbientRuntimeConfig } from "./scrub.js";
10
11
  /**
11
12
  * Built by concatenation so this module's own regexes and doc comments are not
12
13
  * themselves collected as refs by a scanner (the repo's `ref-check` does the same).
@@ -40,6 +41,7 @@ const CITATION_EXTENSIONS = new Set([
40
41
  ".css",
41
42
  ".go",
42
43
  ".graphql",
44
+ ".hcl",
43
45
  ".h",
44
46
  ".hpp",
45
47
  ".html",
@@ -63,6 +65,8 @@ const CITATION_EXTENSIONS = new Set([
63
65
  ".sh",
64
66
  ".sql",
65
67
  ".swift",
68
+ ".tf",
69
+ ".tfvars",
66
70
  ".toml",
67
71
  ".ts",
68
72
  ".tsx",
@@ -608,10 +612,11 @@ export async function checkConfigRefs(options) {
608
612
  // Does an extensionless token name something real? Cached: prompts repeat the same
609
613
  // paths, and each miss would otherwise cost a stat per occurrence.
610
614
  const resolvable = new Map();
611
- /** The root-relative path an extensionless token names, or null if it names nothing. */
615
+ /** The root-relative path a token names, or null. Scope first: a scoped prompt
616
+ * citing `alerts/` means its own tree even when the root has one too. */
612
617
  const namedPath = async (token, scopeRoot) => {
613
618
  for (const candidate of pathishCandidates(token)) {
614
- for (const base of [root, scopeRoot]) {
619
+ for (const base of [scopeRoot, root]) {
615
620
  const absolute = path.resolve(base, candidate);
616
621
  let resolved = resolvable.get(absolute);
617
622
  if (resolved === undefined) {
@@ -630,6 +635,18 @@ export async function checkConfigRefs(options) {
630
635
  }
631
636
  return null;
632
637
  };
638
+ /** The glob a wildcard token names, rebased onto the scope when that is what
639
+ * matches, so the suggested `glob:` ref actually resolves. */
640
+ const namedGlob = (token, scopeRoot) => {
641
+ const scopeRel = path.relative(root, scopeRoot).split(path.sep).join("/");
642
+ const candidates = [...(scopeRel && scopeRel !== "." ? [`${scopeRel}/${token}`] : []), token];
643
+ for (const candidate of candidates) {
644
+ if (index.files.some((file) => matchesIgnore(file, candidate))) {
645
+ return candidate;
646
+ }
647
+ }
648
+ return null;
649
+ };
633
650
  for (const dir of dirs) {
634
651
  const scopeRoot = path.dirname(dir);
635
652
  for (const file of await setupFiles(dir)) {
@@ -658,6 +675,7 @@ export async function checkConfigRefs(options) {
658
675
  line: ref.line,
659
676
  kind: problem.startsWith("cites a line number") ? "line-number-ref" : "broken-ref",
660
677
  problem,
678
+ target: ref.target,
661
679
  });
662
680
  continue;
663
681
  }
@@ -674,18 +692,15 @@ export async function checkConfigRefs(options) {
674
692
  }
675
693
  }
676
694
  for (const { line, token } of findProseCitations(text)) {
677
- // Extension or wildcard tail ⇒ a citation on shape alone. Otherwise it only
678
- // counts if it names something real: `eas-build-worker/terraform` and
679
- // `general-central/{module,production}` are paths, `anthropic/claude-opus-5`
680
- // is shaped identically and is not.
681
- const named = isCodeCitation(token) ? null : await namedPath(token, scopeRoot);
695
+ const isWild = token.includes("*");
696
+ const named = isWild ? namedGlob(token, scopeRoot) : await namedPath(token, scopeRoot);
697
+ // Shape alone makes a citation; otherwise it must name something real, or
698
+ // `anthropic/claude-opus-5` would read as a path.
682
699
  if (!isCodeCitation(token) && !named) {
683
700
  continue;
684
701
  }
685
- // Coverage must accept the path the token RESOLVED to, not just the token as
686
- // written: a prompt says `cert-manager` and the ref that pins it is
687
- // `infrastructure/cert-manager/`. Without this, the fix the message suggests
688
- // does not silence the citation it was suggested for.
702
+ // Coverage accepts the resolved path too, else the only ref that silences a
703
+ // scope-relative citation is an over-broad `glob:**/<basename>`.
689
704
  const forms = [...citationForms(token), ...(named ? coveringForms(named) : [])];
690
705
  if (ignored.has(token) ||
691
706
  forms.some((form) => covered.has(form)) ||
@@ -693,9 +708,12 @@ export async function checkConfigRefs(options) {
693
708
  continue;
694
709
  }
695
710
  const lineCitation = LINE_CITATION_RE.exec(token);
696
- // For an extensionless token the suggestion is the path that actually resolved,
697
- // which is also how a scope-relative citation learns its root-relative form.
698
- const suggestion = named ?? suggestedRef(token);
711
+ // A wildcard token means the family; anything else gets the precise resolved path.
712
+ const suggestion = isWild
713
+ ? named
714
+ ? `${GLOB_PREFIX}${named}`
715
+ : suggestedRef(token)
716
+ : (named ?? suggestedRef(token));
699
717
  problems.push({
700
718
  file: relative,
701
719
  line,
@@ -703,6 +721,7 @@ export async function checkConfigRefs(options) {
703
721
  problem: lineCitation
704
722
  ? `\`${token}\` pins a line number; cite the file or a \`#symbol\` instead, as \`${REF_MARK} ${suggestedRef(lineCitation[1])}\``
705
723
  : `\`${token}\` cites code without a ref; add \`${REF_MARK} ${suggestion} — why it matters\` (or \`${IGNORE_MARK} ${token}\` if it is not a path)`,
724
+ target: token,
706
725
  });
707
726
  }
708
727
  }
@@ -724,6 +743,12 @@ export async function checkConfigRefs(options) {
724
743
  citedPaths: [...citedPaths].sort(),
725
744
  };
726
745
  }
746
+ /** The file a target names, with any `glob:` prefix, `#anchor` or `:line` stripped. */
747
+ function citedBasename(target) {
748
+ const withoutPrefix = target.startsWith(GLOB_PREFIX) ? target.slice(GLOB_PREFIX.length) : target;
749
+ const withoutLine = LINE_CITATION_RE.exec(withoutPrefix)?.[1] ?? withoutPrefix;
750
+ return path.basename(splitAnchor(withoutLine)[0]);
751
+ }
727
752
  /** How many examples a review-side note names before saying "and N more". */
728
753
  const NOTE_EXAMPLES = 5;
729
754
  function andMore(items) {
@@ -746,7 +771,11 @@ export async function reviewSetupRefNotes(options) {
746
771
  return []; // a check that cannot run must never degrade the review
747
772
  }
748
773
  const notes = [];
749
- const broken = report.problems.filter((problem) => problem.kind !== "unannotated-citation");
774
+ // The read root is scrubbed of ambient runtime config (AGENTS.md, CLAUDE.md, .env…),
775
+ // so a ref to one is missing by design here, not stale. The template itself cites
776
+ // AGENTS.md, which would make this note cry wolf on every PR.
777
+ const broken = report.problems.filter((problem) => problem.kind !== "unannotated-citation" &&
778
+ !(problem.target && isAmbientRuntimeConfig(citedBasename(problem.target))));
750
779
  if (broken.length > 0) {
751
780
  notes.push(`The reviewer setup cites code that no longer resolves (${broken.length} ref(s)): ` +
752
781
  `${andMore(broken.map((problem) => `${problem.file}:${problem.line}`))}. ` +
@@ -9,7 +9,7 @@ import { parseCoordinatorOutput } from "./schema.js";
9
9
  // cap is a backstop. It runs AFTER all passes, so this adds to the worst-case
10
10
  // serial chain — keep it within the CI job timeout (see review.ts / workflows).
11
11
  const COORDINATOR_TIMEOUT_MS = 10 * 60 * 1000;
12
- export async function coordinate(handle, config, metadata, agentFindings, coverageNotes = [], stackManifest) {
12
+ export async function coordinate(handle, config, metadata, agentFindings, coverageNotes = [], stackManifest, onActivity) {
13
13
  const system = buildCoordinatorSystem(config);
14
14
  const text = buildCoordinatorTask(metadata, agentFindings, coverageNotes, stackManifest);
15
15
  const { value, cost, tokens, truncated, model } = await promptAndParse(handle, {
@@ -19,6 +19,7 @@ export async function coordinate(handle, config, metadata, agentFindings, covera
19
19
  title: "review-coordinator",
20
20
  maxWaitMs: COORDINATOR_TIMEOUT_MS,
21
21
  finalizeOnTimeout: true,
22
+ onActivity,
22
23
  }, parseCoordinatorOutput);
23
24
  return { output: value, cost, tokens, truncated, model };
24
25
  }
@@ -161,7 +161,17 @@ function runWithInput(command, args, options, input) {
161
161
  child.stdout.setEncoding("utf8");
162
162
  child.stderr.setEncoding("utf8");
163
163
  child.stdout.on("data", (chunk) => {
164
+ const before = stdout.length;
164
165
  stdout = cap(stdout, chunk);
166
+ const admitted = stdout.length - before;
167
+ if (admitted > 0 && options.onStdout) {
168
+ try {
169
+ options.onStdout(chunk.slice(0, admitted));
170
+ }
171
+ catch {
172
+ // Observability must never break the command whose output it observes.
173
+ }
174
+ }
165
175
  });
166
176
  child.stderr.on("data", (chunk) => {
167
177
  stderr = cap(stderr, chunk);