@expo/code-review-cli 0.12.0 → 0.12.2
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 +79 -43
- package/build/commands/init.js +3 -2
- package/build/config/schema.js +0 -9
- package/build/core/auth.js +1 -0
- package/build/core/claude-code.js +23 -3
- package/build/core/opencode.js +23 -4
- package/build/core/prompts.js +35 -6
- package/build/core/render.js +13 -0
- package/build/core/research.js +232 -9
- package/build/core/review.js +72 -24
- package/build/core/schema.js +14 -0
- package/build/core/tools.js +5 -0
- package/build/research-mcp/apple-docc.js +75 -36
- package/build/research-mcp/audit.js +163 -0
- package/build/research-mcp/brave-search.js +68 -0
- package/build/research-mcp/cli.js +29 -7
- package/build/research-mcp/crawler.js +2 -48
- package/build/research-mcp/direct-fetch.js +100 -0
- package/build/research-mcp/fetch-document.js +76 -0
- package/build/research-mcp/html.js +7 -1
- package/build/research-mcp/okhttp-search.js +94 -0
- package/build/research-mcp/query-sanitizer.js +146 -0
- package/build/research-mcp/remote-search.js +175 -0
- package/build/research-mcp/server.js +236 -65
- package/package.json +1 -1
- package/templates/atlantis.yml +2 -0
- package/templates/command.yml +2 -0
- package/templates/config.jsonc +5 -8
- package/templates/coordinator.md +2 -0
- package/templates/shared.md +7 -1
- package/templates/workflow.yml +2 -0
package/build/core/research.js
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
// @ref LLP 0013#query-and-prompt-boundary [implements] — derive identifiers only; validate, bound, and sanitize MCP evidence
|
|
2
2
|
// @ref LLP 0013#one-package-two-binaries [implements] — resolve the package-relative MCP entry instead of PATH/configured commands
|
|
3
|
+
// @ref LLP 0013#research-provenance-and-citations [implements] — bounded query/result audit records plus exact citation grounding
|
|
3
4
|
import { existsSync } from "node:fs";
|
|
5
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
6
|
import { tmpdir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
5
8
|
import { fileURLToPath } from "node:url";
|
|
6
9
|
import { z } from "zod";
|
|
10
|
+
import { readResearchAudit } from "../research-mcp/audit.js";
|
|
7
11
|
import { run } from "./exec.js";
|
|
12
|
+
export { OPENCODE_RESEARCH_TOOLS } from "./tools.js";
|
|
13
|
+
export const RESEARCH_MCP_SERVER_NAME = "platform_docs";
|
|
14
|
+
export const CLAUDE_RESEARCH_TOOLS = [
|
|
15
|
+
`mcp__${RESEARCH_MCP_SERVER_NAME}__search_platform_docs`,
|
|
16
|
+
`mcp__${RESEARCH_MCP_SERVER_NAME}__fetch_platform_doc`,
|
|
17
|
+
];
|
|
8
18
|
const APPLE_EXTENSIONS = /\.(?:swift|m|mm)$/i;
|
|
9
19
|
const ANDROID_EXTENSIONS = /\.(?:kt|java|gradle|gradle\.kts)$/i;
|
|
10
20
|
const REACT_NATIVE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/i;
|
|
@@ -345,12 +355,13 @@ const ToolResultSchema = z.object({
|
|
|
345
355
|
const SearchPayloadSchema = z.object({
|
|
346
356
|
warnings: z.array(z.string().max(500)).max(10).optional(),
|
|
347
357
|
results: z.array(z.object({
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
358
|
+
id: z.string().min(1).max(240).optional(),
|
|
359
|
+
provider: z.string().min(1).max(80),
|
|
360
|
+
sourceKind: z.string().min(1).max(80),
|
|
361
|
+
title: z.string().min(1).max(500),
|
|
362
|
+
url: z.string().url().max(2_000),
|
|
363
|
+
passage: z.string().max(5_000),
|
|
364
|
+
availability: z.array(z.string().max(240)).max(20).optional(),
|
|
354
365
|
})),
|
|
355
366
|
});
|
|
356
367
|
const RESEARCH_PROXY_ENV_KEYS = [
|
|
@@ -361,6 +372,7 @@ const RESEARCH_PROXY_ENV_KEYS = [
|
|
|
361
372
|
"https_proxy",
|
|
362
373
|
"no_proxy",
|
|
363
374
|
];
|
|
375
|
+
const RESEARCH_SEARCH_API_KEY = "BRAVE_SEARCH_API_KEY";
|
|
364
376
|
export function researchChildEnvironment(source = process.env) {
|
|
365
377
|
const environment = {
|
|
366
378
|
LANG: "C.UTF-8",
|
|
@@ -371,9 +383,12 @@ export function researchChildEnvironment(source = process.env) {
|
|
|
371
383
|
if (source[key])
|
|
372
384
|
environment[key] = source[key];
|
|
373
385
|
}
|
|
386
|
+
if (source[RESEARCH_SEARCH_API_KEY]) {
|
|
387
|
+
environment[RESEARCH_SEARCH_API_KEY] = source[RESEARCH_SEARCH_API_KEY];
|
|
388
|
+
}
|
|
374
389
|
return environment;
|
|
375
390
|
}
|
|
376
|
-
function bundledResearchServer() {
|
|
391
|
+
export function bundledResearchServer() {
|
|
377
392
|
const builtEntry = fileURLToPath(new URL("../research-mcp/cli.js", import.meta.url));
|
|
378
393
|
const sourceEntry = fileURLToPath(new URL("../research-mcp/cli.ts", import.meta.url));
|
|
379
394
|
return {
|
|
@@ -381,6 +396,92 @@ function bundledResearchServer() {
|
|
|
381
396
|
args: [existsSync(builtEntry) ? builtEntry : sourceEntry],
|
|
382
397
|
};
|
|
383
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* Create one owner-only MCP configuration and append-only audit for a review run.
|
|
401
|
+
* The model process receives only the config path; the Brave credential is passed
|
|
402
|
+
* directly to the bounded MCP child and never added to the model process env.
|
|
403
|
+
*/
|
|
404
|
+
export async function createResearchMcpRuntime(config) {
|
|
405
|
+
if (!config.enabled)
|
|
406
|
+
return undefined;
|
|
407
|
+
const directory = await mkdtemp(path.join(tmpdir(), "ecr-research-"));
|
|
408
|
+
const auditPath = path.join(directory, "audit.jsonl");
|
|
409
|
+
const claudeConfigPath = path.join(directory, "mcp.json");
|
|
410
|
+
const server = bundledResearchServer();
|
|
411
|
+
const args = [
|
|
412
|
+
...server.args,
|
|
413
|
+
"serve",
|
|
414
|
+
...(config.indexPath ? ["--index", config.indexPath] : []),
|
|
415
|
+
];
|
|
416
|
+
const child = researchChildEnvironment();
|
|
417
|
+
const environment = Object.fromEntries(Object.entries({
|
|
418
|
+
...child,
|
|
419
|
+
REVIEW_RESEARCH_AUDIT_PATH: auditPath,
|
|
420
|
+
REVIEW_RESEARCH_MAX_CALLS: String(config.maxQueries),
|
|
421
|
+
REVIEW_RESEARCH_MAX_RESULTS: String(config.resultsPerQuery),
|
|
422
|
+
}).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])));
|
|
423
|
+
await writeFile(claudeConfigPath, `${JSON.stringify({
|
|
424
|
+
mcpServers: {
|
|
425
|
+
[RESEARCH_MCP_SERVER_NAME]: {
|
|
426
|
+
type: "stdio",
|
|
427
|
+
command: server.command,
|
|
428
|
+
args,
|
|
429
|
+
env: environment,
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
})}\n`, { encoding: "utf8", mode: 0o600 });
|
|
433
|
+
return {
|
|
434
|
+
auditPath,
|
|
435
|
+
claudeConfigPath,
|
|
436
|
+
command: server.command,
|
|
437
|
+
args,
|
|
438
|
+
environment,
|
|
439
|
+
cleanup: () => rm(directory, { recursive: true, force: true }),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
export async function researchProvenanceFromAudit(auditPath) {
|
|
443
|
+
const records = await readResearchAudit(auditPath);
|
|
444
|
+
const queries = [];
|
|
445
|
+
const evidence = [];
|
|
446
|
+
const warnings = [];
|
|
447
|
+
for (const record of records) {
|
|
448
|
+
const firstResult = record.results[0];
|
|
449
|
+
const platformValue = record.input.platform ?? firstResult?.platform ?? "react-native";
|
|
450
|
+
const platform = platformValue === "apple" || platformValue === "android" ? platformValue : "react-native";
|
|
451
|
+
const providers = record.input.providers ??
|
|
452
|
+
record.results.flatMap((result) => (result.provider ? [result.provider] : []));
|
|
453
|
+
const query = {
|
|
454
|
+
platform,
|
|
455
|
+
providers: [...new Set(providers)],
|
|
456
|
+
query: record.input.query ?? record.input.url ?? record.tool,
|
|
457
|
+
};
|
|
458
|
+
queries.push(query);
|
|
459
|
+
warnings.push(...record.warnings);
|
|
460
|
+
if (record.error)
|
|
461
|
+
warnings.push(`${record.tool}: ${record.error}`);
|
|
462
|
+
for (const result of record.results) {
|
|
463
|
+
if (!result.provider || !result.sourceKind)
|
|
464
|
+
continue;
|
|
465
|
+
evidence.push({
|
|
466
|
+
id: result.id,
|
|
467
|
+
query,
|
|
468
|
+
provider: result.provider,
|
|
469
|
+
sourceKind: result.sourceKind,
|
|
470
|
+
title: result.title,
|
|
471
|
+
url: result.url,
|
|
472
|
+
passage: result.passage,
|
|
473
|
+
...(result.availability ? { availability: result.availability } : {}),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const run = {
|
|
478
|
+
queries,
|
|
479
|
+
evidence,
|
|
480
|
+
warnings: [...new Set(warnings)].slice(0, 10),
|
|
481
|
+
promptText: "",
|
|
482
|
+
};
|
|
483
|
+
return { provenance: toResearchProvenance(run), evidence };
|
|
484
|
+
}
|
|
384
485
|
function cleanEvidenceText(value, maxLength) {
|
|
385
486
|
return (value
|
|
386
487
|
.replace(/^\s*-{3,}\s*(?:BEGIN|END)\s+PLATFORM RESEARCH.*$/gim, "")
|
|
@@ -410,9 +511,127 @@ export function formatResearchEvidence(evidence) {
|
|
|
410
511
|
.join("\n\n");
|
|
411
512
|
return cleanEvidenceText(body, 16_000);
|
|
412
513
|
}
|
|
514
|
+
function researchQueryKey(query) {
|
|
515
|
+
return `${query.platform}\0${query.providers.join(",")}\0${query.query}`;
|
|
516
|
+
}
|
|
517
|
+
export function toResearchProvenance(run) {
|
|
518
|
+
return {
|
|
519
|
+
queries: run.queries,
|
|
520
|
+
results: run.evidence.map((item) => ({
|
|
521
|
+
...(item.id ? { id: cleanEvidenceText(item.id, 240) } : {}),
|
|
522
|
+
query: item.query,
|
|
523
|
+
provider: cleanEvidenceText(item.provider, 80),
|
|
524
|
+
sourceKind: cleanEvidenceText(item.sourceKind, 80),
|
|
525
|
+
title: cleanEvidenceText(item.title, 240),
|
|
526
|
+
url: item.url,
|
|
527
|
+
passage: cleanEvidenceText(item.passage, 1_200),
|
|
528
|
+
...(item.availability?.length
|
|
529
|
+
? { availability: item.availability.map((value) => cleanEvidenceText(value, 240)) }
|
|
530
|
+
: {}),
|
|
531
|
+
})),
|
|
532
|
+
warnings: run.warnings.map((warning) => cleanEvidenceText(warning, 500)),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
export function formatResearchProgress(provenance) {
|
|
536
|
+
const lines = [
|
|
537
|
+
` research: ${provenance.results.length} result(s) from ${provenance.queries.length} bounded query(s)`,
|
|
538
|
+
];
|
|
539
|
+
const byQuery = new Map();
|
|
540
|
+
for (const result of provenance.results) {
|
|
541
|
+
const key = researchQueryKey(result.query);
|
|
542
|
+
const bucket = byQuery.get(key) ?? [];
|
|
543
|
+
bucket.push(result);
|
|
544
|
+
byQuery.set(key, bucket);
|
|
545
|
+
}
|
|
546
|
+
for (const [index, query] of provenance.queries.entries()) {
|
|
547
|
+
lines.push(` research query ${index + 1}/${provenance.queries.length} — ${query.platform} [${query.providers.join(", ")}]: ${cleanEvidenceText(query.query, 120)}`);
|
|
548
|
+
const results = byQuery.get(researchQueryKey(query)) ?? [];
|
|
549
|
+
if (results.length === 0) {
|
|
550
|
+
lines.push(" result: none");
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
for (const result of results) {
|
|
554
|
+
lines.push(` result: ${cleanEvidenceText(result.title, 160)} (${result.provider}/${result.sourceKind}) — ${result.url}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
for (const warning of provenance.warnings) {
|
|
558
|
+
lines.push(` research warning: ${cleanEvidenceText(warning, 500)}`);
|
|
559
|
+
}
|
|
560
|
+
if (provenance.error) {
|
|
561
|
+
lines.push(` research error: ${cleanEvidenceText(provenance.error, 500)}`);
|
|
562
|
+
}
|
|
563
|
+
return lines;
|
|
564
|
+
}
|
|
565
|
+
function escapeMarkdownLabel(value) {
|
|
566
|
+
return cleanEvidenceText(value, 240)
|
|
567
|
+
.replace(/&/g, "&")
|
|
568
|
+
.replace(/</g, "<")
|
|
569
|
+
.replace(/>/g, ">")
|
|
570
|
+
.replace(/([\\[\]])/g, "\\$1");
|
|
571
|
+
}
|
|
572
|
+
export function renderResearchMarkdown(provenance) {
|
|
573
|
+
const lines = [
|
|
574
|
+
"### 🔎 Documentation research",
|
|
575
|
+
"",
|
|
576
|
+
`${provenance.results.length} result(s) from ${provenance.queries.length} bounded query(s).`,
|
|
577
|
+
"",
|
|
578
|
+
];
|
|
579
|
+
const byQuery = new Map();
|
|
580
|
+
for (const result of provenance.results) {
|
|
581
|
+
const key = researchQueryKey(result.query);
|
|
582
|
+
const bucket = byQuery.get(key) ?? [];
|
|
583
|
+
bucket.push(result);
|
|
584
|
+
byQuery.set(key, bucket);
|
|
585
|
+
}
|
|
586
|
+
for (const query of provenance.queries) {
|
|
587
|
+
lines.push(`- \`${cleanEvidenceText(query.query, 120)}\` — ${query.platform}; ${query.providers.join(", ")}`);
|
|
588
|
+
const results = byQuery.get(researchQueryKey(query)) ?? [];
|
|
589
|
+
if (results.length === 0) {
|
|
590
|
+
lines.push(" - _No allowlisted result._");
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
for (const result of results) {
|
|
594
|
+
lines.push(` - [${escapeMarkdownLabel(result.title)}](<${result.url}>) — ${escapeMarkdownLabel(result.provider)}/${escapeMarkdownLabel(result.sourceKind)}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
for (const warning of provenance.warnings) {
|
|
598
|
+
lines.push(`- ⚠️ ${escapeMarkdownLabel(warning)}`);
|
|
599
|
+
}
|
|
600
|
+
if (provenance.error) {
|
|
601
|
+
lines.push(`- ⚠️ Research failed: ${escapeMarkdownLabel(provenance.error)}`);
|
|
602
|
+
}
|
|
603
|
+
return lines.join("\n");
|
|
604
|
+
}
|
|
605
|
+
export function mergeResearchSources(...groups) {
|
|
606
|
+
const seen = new Set();
|
|
607
|
+
return groups
|
|
608
|
+
.flatMap((group) => group ?? [])
|
|
609
|
+
.filter((source) => {
|
|
610
|
+
if (seen.has(source.url))
|
|
611
|
+
return false;
|
|
612
|
+
seen.add(source.url);
|
|
613
|
+
return true;
|
|
614
|
+
})
|
|
615
|
+
.slice(0, 5);
|
|
616
|
+
}
|
|
617
|
+
/** Keep only exact URLs returned by the trusted research prepass and restore their canonical titles. */
|
|
618
|
+
export function groundResearchSources(findings, evidence) {
|
|
619
|
+
const allowed = new Map(evidence.map((item) => [
|
|
620
|
+
item.url,
|
|
621
|
+
{ title: cleanEvidenceText(item.title, 240), url: item.url },
|
|
622
|
+
]));
|
|
623
|
+
return findings.map((finding) => {
|
|
624
|
+
const { sources: claimed, ...withoutSources } = finding;
|
|
625
|
+
const sources = mergeResearchSources(claimed?.flatMap((source) => {
|
|
626
|
+
const canonical = allowed.get(source.url);
|
|
627
|
+
return canonical ? [canonical] : [];
|
|
628
|
+
}));
|
|
629
|
+
return sources.length > 0 ? { ...withoutSources, sources } : withoutSources;
|
|
630
|
+
});
|
|
631
|
+
}
|
|
413
632
|
export async function collectPlatformResearch(files, config) {
|
|
414
633
|
const queries = deriveResearchQueries(files, config.maxQueries);
|
|
415
|
-
if (!config.enabled ||
|
|
634
|
+
if (!config.enabled || queries.length === 0) {
|
|
416
635
|
return { queries, evidence: [], warnings: [], promptText: "" };
|
|
417
636
|
}
|
|
418
637
|
const calls = queries.map((query, index) => ({
|
|
@@ -444,7 +663,11 @@ export async function collectPlatformResearch(files, config) {
|
|
|
444
663
|
...calls,
|
|
445
664
|
];
|
|
446
665
|
const server = bundledResearchServer();
|
|
447
|
-
const serverArgs = [
|
|
666
|
+
const serverArgs = [
|
|
667
|
+
...server.args,
|
|
668
|
+
"serve",
|
|
669
|
+
...(config.indexPath ? ["--index", config.indexPath] : []),
|
|
670
|
+
];
|
|
448
671
|
const result = await run(server.command, serverArgs, {
|
|
449
672
|
input: `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`,
|
|
450
673
|
cwd: tmpdir(),
|
package/build/core/review.js
CHANGED
|
@@ -18,7 +18,7 @@ import { errorMessage, sleep } from "./util.js";
|
|
|
18
18
|
import { reviewSetupRefNotes } from "./config-refs.js";
|
|
19
19
|
import { verifyFindings } from "./verify.js";
|
|
20
20
|
import { applyInlineIgnores } from "./suppress.js";
|
|
21
|
-
import {
|
|
21
|
+
import { createResearchMcpRuntime, formatResearchProgress, groundResearchSources, mergeResearchSources, researchProvenanceFromAudit, renderResearchMarkdown, } from "./research.js";
|
|
22
22
|
/**
|
|
23
23
|
* Filter changed files down to an explicit include set (exact-path membership, not
|
|
24
24
|
* globs — scope assignment already happened in resolveScopes). With no include set,
|
|
@@ -133,25 +133,8 @@ export async function runReview(source, options) {
|
|
|
133
133
|
});
|
|
134
134
|
return output;
|
|
135
135
|
}
|
|
136
|
-
let
|
|
137
|
-
|
|
138
|
-
progress("Researching platform documentation from changed API identifiers…");
|
|
139
|
-
try {
|
|
140
|
-
const research = await collectPlatformResearch(kept, config.research);
|
|
141
|
-
researchText = research.promptText;
|
|
142
|
-
progress(research.queries.length === 0
|
|
143
|
-
? " research: no native platform identifiers found"
|
|
144
|
-
: ` research: ${research.evidence.length} passage(s) from ${research.queries.length} bounded query(s)`);
|
|
145
|
-
for (const warning of research.warnings) {
|
|
146
|
-
progress(` research warning: ${warning}`);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
catch (error) {
|
|
150
|
-
// Documentation is supporting evidence, not a prerequisite for reviewing the
|
|
151
|
-
// code. Fail open with a visible diagnostic; never weaken or skip the review.
|
|
152
|
-
progress(` research unavailable; continuing without it (${errorMessage(error)})`);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
136
|
+
let researchEvidence = [];
|
|
137
|
+
let researchRecord;
|
|
155
138
|
// Materialize the PR-head tree (not the current checkout) when the source can, so
|
|
156
139
|
// the agents' surrounding-source reads and the verifier's re-reads see the versions
|
|
157
140
|
// that match the diff. Config is already fully loaded in memory, so the chdir below
|
|
@@ -208,6 +191,18 @@ export async function runReview(source, options) {
|
|
|
208
191
|
for (const note of setupNotes) {
|
|
209
192
|
progress(` setup: ${note}`);
|
|
210
193
|
}
|
|
194
|
+
let researchRuntime;
|
|
195
|
+
try {
|
|
196
|
+
researchRuntime = await createResearchMcpRuntime(config.research);
|
|
197
|
+
if (researchRuntime) {
|
|
198
|
+
progress(`Documentation MCP enabled for reviewer passes (${config.research.maxQueries} calls max; queries and results will be reported).`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
await auth.cleanup();
|
|
203
|
+
await restoreCwd();
|
|
204
|
+
throw new Error(`Failed to prepare the documentation MCP: ${errorMessage(error)}`);
|
|
205
|
+
}
|
|
211
206
|
const starting = [
|
|
212
207
|
usesClaude ? "Claude Code engine" : null,
|
|
213
208
|
usesOpencode ? "OpenCode server" : null,
|
|
@@ -221,23 +216,25 @@ export async function runReview(source, options) {
|
|
|
221
216
|
let claudeHandle = null;
|
|
222
217
|
try {
|
|
223
218
|
if (usesOpencode) {
|
|
224
|
-
opencodeHandle = await startOpencode(buildOpencodeConfig(config));
|
|
219
|
+
opencodeHandle = await startOpencode(buildOpencodeConfig(config, researchRuntime));
|
|
225
220
|
}
|
|
226
221
|
}
|
|
227
222
|
catch (error) {
|
|
228
223
|
await auth.cleanup();
|
|
224
|
+
await researchRuntime?.cleanup();
|
|
229
225
|
await restoreCwd();
|
|
230
226
|
throw new Error(`Failed to start the OpenCode server. Ensure the \`opencode\` CLI is installed and ` +
|
|
231
227
|
`model credentials are configured (\`ecr doctor\` checks both).\n${errorMessage(error)}`);
|
|
232
228
|
}
|
|
233
229
|
try {
|
|
234
230
|
if (usesClaude) {
|
|
235
|
-
claudeHandle = await startClaudeCode(config);
|
|
231
|
+
claudeHandle = await startClaudeCode(config, researchRuntime);
|
|
236
232
|
}
|
|
237
233
|
}
|
|
238
234
|
catch (error) {
|
|
239
235
|
opencodeHandle?.close();
|
|
240
236
|
await auth.cleanup();
|
|
237
|
+
await researchRuntime?.cleanup();
|
|
241
238
|
await restoreCwd();
|
|
242
239
|
throw new Error(`Failed to start the Claude Code engine. Ensure the \`claude\` CLI is installed and ` +
|
|
243
240
|
`logged into a Max/Team subscription (\`ecr doctor\` checks both).\n${errorMessage(error)}`);
|
|
@@ -299,6 +296,7 @@ export async function runReview(source, options) {
|
|
|
299
296
|
catch (error) {
|
|
300
297
|
handle.close();
|
|
301
298
|
await auth.cleanup();
|
|
299
|
+
await researchRuntime?.cleanup();
|
|
302
300
|
await restoreCwd();
|
|
303
301
|
throw error;
|
|
304
302
|
}
|
|
@@ -319,6 +317,9 @@ export async function runReview(source, options) {
|
|
|
319
317
|
// run log stay byte-identical (attribution is engine metadata, never sent to a model).
|
|
320
318
|
// @ref LLP 0011#attribution-and-identity [constrained-by] — engine-set, excluded from fingerprintFinding, so attribution never re-keys a dismissal
|
|
321
319
|
const agentByFp = new Map();
|
|
320
|
+
// Grounded source citations ride through coordinator rewrites by the same stable
|
|
321
|
+
// fingerprint. The model may select an injected source, but cannot invent its URL.
|
|
322
|
+
const sourcesByFp = new Map();
|
|
322
323
|
// Every model request's usage lands in the run total AND its bucket, so the run
|
|
323
324
|
// log can show cache effectiveness per pass and not just run-wide.
|
|
324
325
|
const trackTokens = (bucket, tokens) => {
|
|
@@ -480,8 +481,8 @@ export async function runReview(source, options) {
|
|
|
480
481
|
// smaller file set); a fallback task forbids tools and reviews the inlined diff.
|
|
481
482
|
const buildTaskText = (task) => {
|
|
482
483
|
const base = task.kind === "cross-cutting"
|
|
483
|
-
? buildCrossCuttingTask(task.files, selectedAgents, filtered, { noTools: task.fallback }, options.contextText,
|
|
484
|
-
: buildReviewerTask(task.files, workspace.files, filtered, options.contextText,
|
|
484
|
+
? buildCrossCuttingTask(task.files, selectedAgents, filtered, { noTools: task.fallback }, options.contextText, Boolean(researchRuntime) && !task.fallback)
|
|
485
|
+
: buildReviewerTask(task.files, workspace.files, filtered, options.contextText, Boolean(researchRuntime) && !task.fallback);
|
|
485
486
|
return task.fallback ? `${base}\n\n${NO_TOOLS_INSTRUCTION}` : base;
|
|
486
487
|
};
|
|
487
488
|
const filesLabel = (files) => files.length === 1
|
|
@@ -616,6 +617,33 @@ export async function runReview(source, options) {
|
|
|
616
617
|
}
|
|
617
618
|
}
|
|
618
619
|
});
|
|
620
|
+
if (researchRuntime) {
|
|
621
|
+
try {
|
|
622
|
+
const audited = await researchProvenanceFromAudit(researchRuntime.auditPath);
|
|
623
|
+
researchRecord = audited.provenance;
|
|
624
|
+
researchEvidence = audited.evidence;
|
|
625
|
+
for (const line of formatResearchProgress(researchRecord))
|
|
626
|
+
progress(line);
|
|
627
|
+
await appendStepSummary(renderResearchMarkdown(researchRecord));
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
researchRecord = { queries: [], results: [], warnings: [], error: errorMessage(error) };
|
|
631
|
+
progress(` research audit unavailable: ${researchRecord.error}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// Citations are accepted only when their exact canonical URL appeared in this
|
|
635
|
+
// run's MCP audit. This strips invented URLs even if a model copied a plausible
|
|
636
|
+
// official-looking address into its structured output.
|
|
637
|
+
for (const [bucket, findings] of Object.entries(agentFindings)) {
|
|
638
|
+
const grounded = groundResearchSources(findings, researchEvidence);
|
|
639
|
+
agentFindings[bucket] = grounded;
|
|
640
|
+
for (const finding of grounded) {
|
|
641
|
+
if (!finding.sources?.length)
|
|
642
|
+
continue;
|
|
643
|
+
const fp = fingerprintFinding(finding);
|
|
644
|
+
sourcesByFp.set(fp, mergeResearchSources(sourcesByFp.get(fp), finding.sources));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
619
647
|
// A substituted model means the review did not run on the model this repo
|
|
620
648
|
// configured — the findings may be from a weaker (or free-tier) model entirely.
|
|
621
649
|
// Never silent: it goes to the log, the coverage notes, and the run log.
|
|
@@ -693,6 +721,12 @@ export async function runReview(source, options) {
|
|
|
693
721
|
: consolidated.decision;
|
|
694
722
|
output = { ...consolidated, decision, incomplete: [...new Set(coverageNotes)] };
|
|
695
723
|
}
|
|
724
|
+
// The coordinator remains model output. Revalidate every citation against the
|
|
725
|
+
// allowlisted prepass before verification, persistence, or rendering.
|
|
726
|
+
output = {
|
|
727
|
+
...output,
|
|
728
|
+
findings: groundResearchSources(output.findings, researchEvidence),
|
|
729
|
+
};
|
|
696
730
|
// Guard against hallucinated findings before surfacing: quote-ground every
|
|
697
731
|
// finding against the real file, and adversarially verify criticals. This is
|
|
698
732
|
// what stops a confident but wrong critical from shipping.
|
|
@@ -796,6 +830,17 @@ export async function runReview(source, options) {
|
|
|
796
830
|
else if (output.decision !== decisionBeforeChecks) {
|
|
797
831
|
output = { ...output, summary: reconcileRequalifiedSummary(output.summary) };
|
|
798
832
|
}
|
|
833
|
+
// Carry a reviewer's grounded citations through a coordinator rewrite. A changed
|
|
834
|
+
// fingerprint fails closed, so the engine never guesses which source applies.
|
|
835
|
+
if (output.findings.length > 0) {
|
|
836
|
+
output = {
|
|
837
|
+
...output,
|
|
838
|
+
findings: output.findings.map((finding) => {
|
|
839
|
+
const sources = mergeResearchSources(finding.sources, sourcesByFp.get(fingerprintFinding(finding)));
|
|
840
|
+
return sources.length > 0 ? { ...finding, sources } : finding;
|
|
841
|
+
}),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
799
844
|
// Attribution: carry each surviving finding's originating agent onto the output. The
|
|
800
845
|
// coordinator merges and rewrites findings, so match by fingerprint and keep the
|
|
801
846
|
// first agent that produced it; a finding the coordinator changed enough to break the
|
|
@@ -891,6 +936,7 @@ export async function runReview(source, options) {
|
|
|
891
936
|
const reviewTrace = buildReviewTrace(agentTrace);
|
|
892
937
|
await safeLog(logPath, {
|
|
893
938
|
...baseRecord,
|
|
939
|
+
...(researchRecord ? { research: researchRecord } : {}),
|
|
894
940
|
agentCosts,
|
|
895
941
|
totalCost: sum(agentCosts),
|
|
896
942
|
tokens: tokenTotals,
|
|
@@ -927,6 +973,7 @@ export async function runReview(source, options) {
|
|
|
927
973
|
catch (error) {
|
|
928
974
|
await safeLog(logPath, {
|
|
929
975
|
...baseRecord,
|
|
976
|
+
...(researchRecord ? { research: researchRecord } : {}),
|
|
930
977
|
agentCosts,
|
|
931
978
|
totalCost: sum(agentCosts),
|
|
932
979
|
tokens: tokenTotals,
|
|
@@ -944,6 +991,7 @@ export async function runReview(source, options) {
|
|
|
944
991
|
finally {
|
|
945
992
|
handle.close();
|
|
946
993
|
await auth.cleanup();
|
|
994
|
+
await researchRuntime?.cleanup();
|
|
947
995
|
await restoreCwd();
|
|
948
996
|
}
|
|
949
997
|
}
|
package/build/core/schema.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// @ref LLP 0005#finding-identity-fingerprints
|
|
2
|
+
// @ref LLP 0013#research-provenance-and-citations [implements] — optional citations are annotations, not finding identity or decision inputs
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { normalizeCode } from "./util.js";
|
|
@@ -8,6 +9,14 @@ export const SEVERITIES = ["critical", "warning", "suggestion"];
|
|
|
8
9
|
export const SEVERITY_RANK = { critical: 0, warning: 1, suggestion: 2 };
|
|
9
10
|
export const CATEGORIES = ["correctness", "quality", "security", "secrets"];
|
|
10
11
|
export const DECISIONS = ["approve", "approve_with_comments", "request_changes"];
|
|
12
|
+
export const FindingSourceSchema = z.object({
|
|
13
|
+
title: z.string().min(1).max(240),
|
|
14
|
+
url: z
|
|
15
|
+
.string()
|
|
16
|
+
.url()
|
|
17
|
+
.max(2_000)
|
|
18
|
+
.refine((value) => new URL(value).protocol === "https:", "source URL must use HTTPS"),
|
|
19
|
+
});
|
|
11
20
|
export const FindingSchema = z.object({
|
|
12
21
|
severity: z.enum(SEVERITIES),
|
|
13
22
|
category: z.enum(CATEGORIES),
|
|
@@ -16,6 +25,11 @@ export const FindingSchema = z.object({
|
|
|
16
25
|
title: z.string(),
|
|
17
26
|
rationale: z.string(),
|
|
18
27
|
suggestion: z.string().optional(),
|
|
28
|
+
sources: z
|
|
29
|
+
.array(FindingSourceSchema)
|
|
30
|
+
.max(5)
|
|
31
|
+
.optional()
|
|
32
|
+
.describe("Exact documentation sources used to support this finding; copy title and URL from the injected research evidence and omit when unused"),
|
|
19
33
|
/**
|
|
20
34
|
* Verbatim snippet of the flagged code, copied from the file. Used to
|
|
21
35
|
* quote-ground the finding: if this text isn't actually present in the file,
|
package/build/core/tools.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** The OpenCode tool names the reviewer toggles. Single source of truth so the
|
|
2
2
|
* agent and coordinator tool maps can't drift apart. */
|
|
3
|
+
export const OPENCODE_RESEARCH_TOOLS = [
|
|
4
|
+
"platform_docs_search_platform_docs",
|
|
5
|
+
"platform_docs_fetch_platform_doc",
|
|
6
|
+
];
|
|
3
7
|
export const TOOL_NAMES = [
|
|
4
8
|
"read",
|
|
5
9
|
"grep",
|
|
@@ -9,6 +13,7 @@ export const TOOL_NAMES = [
|
|
|
9
13
|
"write",
|
|
10
14
|
"edit",
|
|
11
15
|
"patch",
|
|
16
|
+
...OPENCODE_RESEARCH_TOOLS,
|
|
12
17
|
];
|
|
13
18
|
/** Build a full tool map with only the listed tools enabled. */
|
|
14
19
|
export function toolMap(enabled) {
|