@expo/code-review-cli 0.9.2 → 0.11.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 +83 -60
- package/build/commands/ci.js +137 -26
- package/build/commands/init.js +18 -13
- 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/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 +81 -13
- package/build/core/router.js +2 -1
- package/build/core/schema.js +78 -23
- 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/shared.md +18 -3
- 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
|
/**
|
|
@@ -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);
|
package/build/core/opencode.js
CHANGED
|
@@ -799,20 +799,19 @@ export async function withTransientRetry(label, onActivity, fn) {
|
|
|
799
799
|
}
|
|
800
800
|
}
|
|
801
801
|
/**
|
|
802
|
-
* Prompt an agent and parse its reply.
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
809
|
-
* the task instead of retrying a non-convergent run.
|
|
802
|
+
* Prompt an agent and parse its reply. Claude-routed production parsers hand their
|
|
803
|
+
* JSON Schema to the CLI, which repairs validation failures in-session before
|
|
804
|
+
* returning. OpenCode retries a JSON-parse failure in the SAME session: the model
|
|
805
|
+
* still holds all the file context it read, so the corrective is a cheap cache-read
|
|
806
|
+
* re-emit with better recall than re-investigating. Only if that also fails does it
|
|
807
|
+
* use a fresh session. A timeout is NOT a parse failure: AgentTimeoutError propagates
|
|
808
|
+
* so the caller abandons the non-convergent task.
|
|
810
809
|
*/
|
|
811
810
|
export async function promptAndParse(handle, args, parse) {
|
|
812
811
|
const dispatch = resolveEngineDispatch(handle, args.agent);
|
|
813
812
|
if (dispatch.engine === CLAUDE_CODE_ENGINE) {
|
|
814
813
|
const { claudeCodePromptAndParse } = await import("./claude-code.js");
|
|
815
|
-
return claudeCodePromptAndParse(dispatch.claudeHandle, args, parse);
|
|
814
|
+
return claudeCodePromptAndParse(dispatch.claudeHandle, { ...args, jsonSchema: parse.jsonSchema }, parse);
|
|
816
815
|
}
|
|
817
816
|
let cost = 0;
|
|
818
817
|
let truncated = false;
|
package/build/core/render.js
CHANGED
|
@@ -138,7 +138,7 @@ function location(finding, link) {
|
|
|
138
138
|
* per-PR dismissals. Findings whose fingerprint appears in `dismissed` render in a
|
|
139
139
|
* collapsed "Dismissed" section instead of the main list.
|
|
140
140
|
*/
|
|
141
|
-
export function renderMarkdown(review, tag, dismissed = [], link, feedback = [], pins = []) {
|
|
141
|
+
export function renderMarkdown(review, tag, dismissed = [], link, feedback = [], pins = [], inputHash) {
|
|
142
142
|
const dismissedByFp = new Map(dismissed.map((record) => [record.fp, record]));
|
|
143
143
|
const withFp = review.findings.map((finding) => ({ finding, fp: fingerprintFinding(finding) }));
|
|
144
144
|
const feedbackByFp = matchedFeedback(feedback, new Set(withFp.map((entry) => entry.fp)));
|
|
@@ -181,7 +181,7 @@ export function renderMarkdown(review, tag, dismissed = [], link, feedback = [],
|
|
|
181
181
|
// and dismissals, so `/dismiss` can re-render this comment without re-running.
|
|
182
182
|
const fingerprints = review.findings.map(fingerprintFinding);
|
|
183
183
|
lines.push("", `<!-- ${tag}:fingerprints=${JSON.stringify(fingerprints)} -->`);
|
|
184
|
-
lines.push(`<!-- ${tag}:state=${encodeState(reviewState({ review, dismissed }, feedbackByFp, pins))} -->`);
|
|
184
|
+
lines.push(`<!-- ${tag}:state=${encodeState(reviewState({ review, dismissed, ...(inputHash ? { inputHash } : {}) }, feedbackByFp, pins))} -->`);
|
|
185
185
|
return lines.join("\n");
|
|
186
186
|
}
|
|
187
187
|
/**
|
|
@@ -501,7 +501,7 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts, fee
|
|
|
501
501
|
// review) so /undismiss can restore them and the Dismissed section persists
|
|
502
502
|
// across re-renders. The per-scope data (`scopes`) plus a merged v1 `review`
|
|
503
503
|
// keep both v2 and v1 consumers working.
|
|
504
|
-
const stateScopes = rendered.map(({ result, shown, requalified, dropped }) => {
|
|
504
|
+
const stateScopes = rendered.map(({ result, shown, hidden, requalified, requalifiedHidden, dropped }) => {
|
|
505
505
|
// @ref LLP 0011#suppression-is-never-silent — strip any per-scope `feedback`
|
|
506
506
|
// a freshly-reviewed scope's ReviewRunResult carries: the top-level feedback
|
|
507
507
|
// array (reviewState below) is the single source of truth. A stale per-scope
|
|
@@ -511,6 +511,13 @@ export function renderAggregateMarkdown(results, tag, dismissed, link, opts, fee
|
|
|
511
511
|
return {
|
|
512
512
|
scope: result.scope,
|
|
513
513
|
isDefault: result.isDefault,
|
|
514
|
+
// A truncated state is not the full result and therefore cannot be a cache
|
|
515
|
+
// source. Omit its hash so the next run retries instead of reusing a subset.
|
|
516
|
+
...(hidden === 0 && requalifiedHidden === 0
|
|
517
|
+
? result.inputHash
|
|
518
|
+
? { inputHash: result.inputHash }
|
|
519
|
+
: {}
|
|
520
|
+
: {}),
|
|
514
521
|
// Requalified findings ride the embedded state (like dismissed ones) so a
|
|
515
522
|
// re-render (/dismiss) round-trips them and the addressed section persists.
|
|
516
523
|
// Under truncation they are trimmed exactly like `shown` — state bytes count
|