@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.
- package/README.md +67 -56
- package/build/commands/ci.js +137 -26
- package/build/commands/init.js +18 -13
- package/build/commands/ref-check.js +1 -1
- package/build/core/adjudicate.js +1 -0
- package/build/core/auth.js +4 -1
- package/build/core/claude-code.js +315 -43
- package/build/core/config-refs.js +44 -15
- package/build/core/coordinator.js +2 -1
- package/build/core/exec.js +10 -0
- package/build/core/opencode.js +8 -9
- package/build/core/render.js +10 -3
- package/build/core/review-cache.js +108 -0
- package/build/core/review.js +18 -11
- package/build/core/router.js +2 -1
- package/build/core/schema.js +23 -21
- package/build/core/stack-confirm.js +2 -1
- package/build/core/verify.js +1 -0
- package/build/reporters/github.js +3 -3
- package/package.json +1 -1
- package/templates/agents/security.md +3 -3
- package/templates/atlantis.yml +11 -5
- package/templates/command.yml +14 -7
- package/templates/config.jsonc +31 -39
- package/templates/coordinator.md +2 -2
- package/templates/workflow.yml +15 -7
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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`
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
384
|
-
//
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
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
|
|
467
|
-
*
|
|
468
|
-
*
|
|
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
|
-
|
|
480
|
-
record(first);
|
|
729
|
+
let first;
|
|
481
730
|
try {
|
|
482
|
-
|
|
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(
|
|
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
|
-
|
|
491
|
-
|
|
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
|
|
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 [
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
//
|
|
680
|
-
//
|
|
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
|
|
686
|
-
//
|
|
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
|
-
//
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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
|
}
|
package/build/core/exec.js
CHANGED
|
@@ -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);
|