@expo/code-review-cli 0.12.1 → 0.12.3
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 +71 -17
- package/build/core/claude-code.js +23 -3
- package/build/core/opencode.js +23 -4
- package/build/core/prompts.js +53 -6
- package/build/core/render.js +13 -0
- package/build/core/research.js +352 -34
- package/build/core/review.js +101 -24
- package/build/core/schema.js +32 -0
- package/build/core/tools.js +5 -0
- package/build/research-mcp/audit.js +170 -0
- package/build/research-mcp/brave-search.js +2 -11
- package/build/research-mcp/cli.js +19 -2
- package/build/research-mcp/direct-fetch.js +160 -0
- package/build/research-mcp/html.js +4 -1
- package/build/research-mcp/providers.js +38 -0
- package/build/research-mcp/query-sanitizer.js +146 -0
- package/build/research-mcp/remote-search.js +4 -0
- package/build/research-mcp/search-index.js +4 -0
- package/build/research-mcp/server.js +221 -109
- package/build/research-mcp/types.js +1 -0
- package/package.json +1 -1
- package/research/sources.json +12 -0
- package/templates/config.jsonc +6 -6
- package/templates/coordinator.md +2 -0
- package/templates/shared.md +21 -2
package/build/core/research.js
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
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_DECISION_COUNT_LIMIT = 16;
|
|
14
|
+
export const RESEARCH_DECISION_BYTES_LIMIT = 20_000;
|
|
15
|
+
export const RESEARCH_MCP_SERVER_NAME = "platform_docs";
|
|
16
|
+
export const CLAUDE_RESEARCH_TOOLS = [
|
|
17
|
+
`mcp__${RESEARCH_MCP_SERVER_NAME}__search_platform_docs`,
|
|
18
|
+
`mcp__${RESEARCH_MCP_SERVER_NAME}__fetch_platform_doc`,
|
|
19
|
+
];
|
|
8
20
|
const APPLE_EXTENSIONS = /\.(?:swift|m|mm)$/i;
|
|
9
21
|
const ANDROID_EXTENSIONS = /\.(?:kt|java|gradle|gradle\.kts)$/i;
|
|
10
22
|
const REACT_NATIVE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/i;
|
|
@@ -231,40 +243,46 @@ const REACT_NATIVE_PROVIDERS = new Set([
|
|
|
231
243
|
function providersFor(file, code, signals) {
|
|
232
244
|
const path = file.path.toLowerCase();
|
|
233
245
|
const text = code.toLowerCase();
|
|
246
|
+
const platform = platformFor(file);
|
|
234
247
|
const providers = [];
|
|
235
248
|
const add = (provider, matches) => {
|
|
236
249
|
if (matches && !providers.includes(provider))
|
|
237
250
|
providers.push(provider);
|
|
238
251
|
};
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
add("react-native-worklets", /react-native-worklets/.test(signals) || path.includes("react-native-worklets"));
|
|
243
|
-
add("expo", /(?:^|\n)(?:expo|expo-[a-z0-9-]+|@expo\/[a-z0-9-]+)(?:\n|$)/.test(signals) ||
|
|
244
|
-
/(?:^|\/)packages\/expo(?:-[^/]+)?(?:\/|$)/.test(path));
|
|
245
|
-
add("react-native", /(?:^|\n)react-native(?:\/[^\n]+)?(?:\n|$)/.test(signals));
|
|
246
|
-
if (providers.length > 0)
|
|
247
|
-
return providers;
|
|
248
|
-
if (/androidx\.media3|mediasessionservice|\bexoplayer\b/.test(text))
|
|
249
|
-
return ["media3"];
|
|
250
|
-
if (/com\.bumptech\.glide|\bglide\b/.test(text))
|
|
251
|
-
return ["glide"];
|
|
252
|
-
if (/okhttp3|\bokhttpclient\b|\brequest\.builder\b/.test(text))
|
|
253
|
-
return ["okhttp"];
|
|
254
|
-
if (/kotlinx\.coroutines|\bcoroutinescope\b|\bmutable(?:state|shared)flow\b/.test(text)) {
|
|
255
|
-
return ["kotlin-coroutines"];
|
|
256
|
-
}
|
|
257
|
-
if (/\.gradle(?:\.kts)?$/.test(path) || /(?:^|\/)build\.gradle/.test(path)) {
|
|
258
|
-
return /com\.android|android\s*\{|compilesdk|targetsdk/.test(text)
|
|
259
|
-
? ["agp", "gradle"]
|
|
260
|
-
: ["gradle"];
|
|
261
|
-
}
|
|
262
|
-
const platform = platformFor(file);
|
|
252
|
+
// Native source is owned by the native platform first. An Expo package path is
|
|
253
|
+
// repository ownership, not documentation ownership: packages/expo-image/ios
|
|
254
|
+
// must search Apple and SDWebImage contracts rather than Expo's JavaScript docs.
|
|
263
255
|
if (platform === "apple")
|
|
264
|
-
|
|
256
|
+
add("apple", true);
|
|
265
257
|
if (platform === "android")
|
|
266
|
-
|
|
267
|
-
|
|
258
|
+
add("android", true);
|
|
259
|
+
if (platform === "react-native") {
|
|
260
|
+
add("react-native-reanimated", /react-native-reanimated/.test(signals) || path.includes("react-native-reanimated"));
|
|
261
|
+
add("react-native-gesture-handler", /react-native-gesture-handler/.test(signals) || path.includes("react-native-gesture-handler"));
|
|
262
|
+
add("react-native-screens", /react-native-screens/.test(signals) || path.includes("react-native-screens"));
|
|
263
|
+
add("react-native-worklets", /react-native-worklets/.test(signals) || path.includes("react-native-worklets"));
|
|
264
|
+
add("expo", /(?:^|\n)(?:expo|expo-[a-z0-9-]+|@expo\/[a-z0-9-]+)(?:\n|$)/.test(signals) ||
|
|
265
|
+
/(?:^|\/)packages\/expo(?:-[^/]+)?(?:\/|$)/.test(path));
|
|
266
|
+
add("react-native", /(?:^|\n)react-native(?:\/[^\n]+)?(?:\n|$)/.test(signals));
|
|
267
|
+
}
|
|
268
|
+
// Explicit framework/dependency signals are additive. Keeping the platform
|
|
269
|
+
// provider alongside the dependency lets one pass check both the OS contract and
|
|
270
|
+
// the wrapper/library behavior without a package-path heuristic hiding either.
|
|
271
|
+
if (platform === "apple") {
|
|
272
|
+
add("sdwebimage", /\bsdwebimage(?:manager|options|context|cache|loader)?\b/.test(text));
|
|
273
|
+
add("expo", /\bexpomodulescore\b/.test(text));
|
|
274
|
+
}
|
|
275
|
+
if (platform === "android") {
|
|
276
|
+
add("media3", /androidx\.media3|mediasessionservice|\bexoplayer\b/.test(text));
|
|
277
|
+
add("glide", /com\.bumptech\.glide|\bglide\b/.test(text));
|
|
278
|
+
add("okhttp", /okhttp3|\bokhttpclient\b|\brequest\.builder\b/.test(text));
|
|
279
|
+
add("kotlin-coroutines", /kotlinx\.coroutines|\bcoroutinescope\b|\bmutable(?:state|shared)flow\b/.test(text));
|
|
280
|
+
const isGradle = /\.gradle(?:\.kts)?$/.test(path) || /(?:^|\/)build\.gradle/.test(path);
|
|
281
|
+
add("agp", isGradle && /com\.android|android\s*\{|compilesdk|targetsdk/.test(text));
|
|
282
|
+
add("gradle", isGradle);
|
|
283
|
+
add("expo", /\bexpo\.modules\.kotlin\b/.test(text));
|
|
284
|
+
}
|
|
285
|
+
return providers.length > 0 ? providers : ["react-native"];
|
|
268
286
|
}
|
|
269
287
|
function lineQuery(line) {
|
|
270
288
|
const declared = line.match(/\b(?:class|struct|enum|interface|protocol)\s+([A-Z][A-Za-z0-9_]*)/)?.[1];
|
|
@@ -345,12 +363,13 @@ const ToolResultSchema = z.object({
|
|
|
345
363
|
const SearchPayloadSchema = z.object({
|
|
346
364
|
warnings: z.array(z.string().max(500)).max(10).optional(),
|
|
347
365
|
results: z.array(z.object({
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
366
|
+
id: z.string().min(1).max(240).optional(),
|
|
367
|
+
provider: z.string().min(1).max(80),
|
|
368
|
+
sourceKind: z.string().min(1).max(80),
|
|
369
|
+
title: z.string().min(1).max(500),
|
|
370
|
+
url: z.string().url().max(2_000),
|
|
371
|
+
passage: z.string().max(5_000),
|
|
372
|
+
availability: z.array(z.string().max(240)).max(20).optional(),
|
|
354
373
|
})),
|
|
355
374
|
});
|
|
356
375
|
const RESEARCH_PROXY_ENV_KEYS = [
|
|
@@ -377,7 +396,7 @@ export function researchChildEnvironment(source = process.env) {
|
|
|
377
396
|
}
|
|
378
397
|
return environment;
|
|
379
398
|
}
|
|
380
|
-
function bundledResearchServer() {
|
|
399
|
+
export function bundledResearchServer() {
|
|
381
400
|
const builtEntry = fileURLToPath(new URL("../research-mcp/cli.js", import.meta.url));
|
|
382
401
|
const sourceEntry = fileURLToPath(new URL("../research-mcp/cli.ts", import.meta.url));
|
|
383
402
|
return {
|
|
@@ -385,6 +404,92 @@ function bundledResearchServer() {
|
|
|
385
404
|
args: [existsSync(builtEntry) ? builtEntry : sourceEntry],
|
|
386
405
|
};
|
|
387
406
|
}
|
|
407
|
+
/**
|
|
408
|
+
* Create one owner-only MCP configuration and append-only audit for a review run.
|
|
409
|
+
* The model process receives only the config path; the Brave credential is passed
|
|
410
|
+
* directly to the bounded MCP child and never added to the model process env.
|
|
411
|
+
*/
|
|
412
|
+
export async function createResearchMcpRuntime(config) {
|
|
413
|
+
if (!config.enabled)
|
|
414
|
+
return undefined;
|
|
415
|
+
const directory = await mkdtemp(path.join(tmpdir(), "ecr-research-"));
|
|
416
|
+
const auditPath = path.join(directory, "audit.jsonl");
|
|
417
|
+
const claudeConfigPath = path.join(directory, "mcp.json");
|
|
418
|
+
const server = bundledResearchServer();
|
|
419
|
+
const args = [
|
|
420
|
+
...server.args,
|
|
421
|
+
"serve",
|
|
422
|
+
...(config.indexPath ? ["--index", config.indexPath] : []),
|
|
423
|
+
];
|
|
424
|
+
const child = researchChildEnvironment();
|
|
425
|
+
const environment = Object.fromEntries(Object.entries({
|
|
426
|
+
...child,
|
|
427
|
+
REVIEW_RESEARCH_AUDIT_PATH: auditPath,
|
|
428
|
+
REVIEW_RESEARCH_MAX_CALLS: String(config.maxQueries),
|
|
429
|
+
REVIEW_RESEARCH_MAX_RESULTS: String(config.resultsPerQuery),
|
|
430
|
+
}).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])));
|
|
431
|
+
await writeFile(claudeConfigPath, `${JSON.stringify({
|
|
432
|
+
mcpServers: {
|
|
433
|
+
[RESEARCH_MCP_SERVER_NAME]: {
|
|
434
|
+
type: "stdio",
|
|
435
|
+
command: server.command,
|
|
436
|
+
args,
|
|
437
|
+
env: environment,
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
})}\n`, { encoding: "utf8", mode: 0o600 });
|
|
441
|
+
return {
|
|
442
|
+
auditPath,
|
|
443
|
+
claudeConfigPath,
|
|
444
|
+
command: server.command,
|
|
445
|
+
args,
|
|
446
|
+
environment,
|
|
447
|
+
cleanup: () => rm(directory, { recursive: true, force: true }),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
export async function researchProvenanceFromAudit(auditPath) {
|
|
451
|
+
const records = await readResearchAudit(auditPath);
|
|
452
|
+
const queries = [];
|
|
453
|
+
const evidence = [];
|
|
454
|
+
const warnings = [];
|
|
455
|
+
for (const record of records) {
|
|
456
|
+
const firstResult = record.results[0];
|
|
457
|
+
const platformValue = record.input.platform ?? firstResult?.platform ?? "react-native";
|
|
458
|
+
const platform = platformValue === "apple" || platformValue === "android" ? platformValue : "react-native";
|
|
459
|
+
const providers = record.input.providers ??
|
|
460
|
+
record.results.flatMap((result) => (result.provider ? [result.provider] : []));
|
|
461
|
+
const query = {
|
|
462
|
+
platform,
|
|
463
|
+
providers: [...new Set(providers)],
|
|
464
|
+
query: record.input.query ?? record.input.url ?? record.tool,
|
|
465
|
+
};
|
|
466
|
+
queries.push(query);
|
|
467
|
+
warnings.push(...record.warnings);
|
|
468
|
+
if (record.error)
|
|
469
|
+
warnings.push(`${record.tool}: ${record.error}`);
|
|
470
|
+
for (const result of record.results) {
|
|
471
|
+
if (!result.provider || !result.sourceKind)
|
|
472
|
+
continue;
|
|
473
|
+
evidence.push({
|
|
474
|
+
id: result.id,
|
|
475
|
+
query,
|
|
476
|
+
provider: result.provider,
|
|
477
|
+
sourceKind: result.sourceKind,
|
|
478
|
+
title: result.title,
|
|
479
|
+
url: result.url,
|
|
480
|
+
passage: result.passage,
|
|
481
|
+
...(result.availability ? { availability: result.availability } : {}),
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const run = {
|
|
486
|
+
queries,
|
|
487
|
+
evidence,
|
|
488
|
+
warnings: [...new Set(warnings)].slice(0, 10),
|
|
489
|
+
promptText: "",
|
|
490
|
+
};
|
|
491
|
+
return { provenance: toResearchProvenance(run), evidence };
|
|
492
|
+
}
|
|
388
493
|
function cleanEvidenceText(value, maxLength) {
|
|
389
494
|
return (value
|
|
390
495
|
.replace(/^\s*-{3,}\s*(?:BEGIN|END)\s+PLATFORM RESEARCH.*$/gim, "")
|
|
@@ -414,6 +519,219 @@ export function formatResearchEvidence(evidence) {
|
|
|
414
519
|
.join("\n\n");
|
|
415
520
|
return cleanEvidenceText(body, 16_000);
|
|
416
521
|
}
|
|
522
|
+
function researchQueryKey(query) {
|
|
523
|
+
return `${query.platform}\0${query.providers.join(",")}\0${query.query}`;
|
|
524
|
+
}
|
|
525
|
+
export function toResearchProvenance(run) {
|
|
526
|
+
return {
|
|
527
|
+
queries: run.queries,
|
|
528
|
+
results: run.evidence.map((item) => ({
|
|
529
|
+
...(item.id ? { id: cleanEvidenceText(item.id, 240) } : {}),
|
|
530
|
+
query: item.query,
|
|
531
|
+
provider: cleanEvidenceText(item.provider, 80),
|
|
532
|
+
sourceKind: cleanEvidenceText(item.sourceKind, 80),
|
|
533
|
+
title: cleanEvidenceText(item.title, 240),
|
|
534
|
+
url: item.url,
|
|
535
|
+
passage: cleanEvidenceText(item.passage, 20_000),
|
|
536
|
+
...(item.availability?.length
|
|
537
|
+
? { availability: item.availability.map((value) => cleanEvidenceText(value, 240)) }
|
|
538
|
+
: {}),
|
|
539
|
+
})),
|
|
540
|
+
warnings: run.warnings.map((warning) => cleanEvidenceText(warning, 500)),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
export function formatResearchProgress(provenance) {
|
|
544
|
+
const lines = [
|
|
545
|
+
` research: ${provenance.results.length} result(s) from ${provenance.queries.length} bounded query(s)`,
|
|
546
|
+
];
|
|
547
|
+
const byQuery = new Map();
|
|
548
|
+
for (const result of provenance.results) {
|
|
549
|
+
const key = researchQueryKey(result.query);
|
|
550
|
+
const bucket = byQuery.get(key) ?? [];
|
|
551
|
+
bucket.push(result);
|
|
552
|
+
byQuery.set(key, bucket);
|
|
553
|
+
}
|
|
554
|
+
for (const [index, query] of provenance.queries.entries()) {
|
|
555
|
+
lines.push(` research query ${index + 1}/${provenance.queries.length} — ${query.platform} [${query.providers.join(", ")}]: ${cleanEvidenceText(query.query, 120)}`);
|
|
556
|
+
const results = byQuery.get(researchQueryKey(query)) ?? [];
|
|
557
|
+
if (results.length === 0) {
|
|
558
|
+
lines.push(" result: none");
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
for (const result of results) {
|
|
562
|
+
lines.push(` result: ${cleanEvidenceText(result.title, 160)} (${result.provider}/${result.sourceKind}) — ${result.url}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
for (const warning of provenance.warnings) {
|
|
566
|
+
lines.push(` research warning: ${cleanEvidenceText(warning, 500)}`);
|
|
567
|
+
}
|
|
568
|
+
if (provenance.error) {
|
|
569
|
+
lines.push(` research error: ${cleanEvidenceText(provenance.error, 500)}`);
|
|
570
|
+
}
|
|
571
|
+
return lines;
|
|
572
|
+
}
|
|
573
|
+
function escapeMarkdownLabel(value) {
|
|
574
|
+
return cleanEvidenceText(value, 240)
|
|
575
|
+
.replace(/&/g, "&")
|
|
576
|
+
.replace(/</g, "<")
|
|
577
|
+
.replace(/>/g, ">")
|
|
578
|
+
.replace(/([\\[\]])/g, "\\$1");
|
|
579
|
+
}
|
|
580
|
+
export function renderResearchMarkdown(provenance) {
|
|
581
|
+
const lines = [
|
|
582
|
+
"### 🔎 Documentation research",
|
|
583
|
+
"",
|
|
584
|
+
`${provenance.results.length} result(s) from ${provenance.queries.length} bounded query(s).`,
|
|
585
|
+
"",
|
|
586
|
+
];
|
|
587
|
+
const byQuery = new Map();
|
|
588
|
+
for (const result of provenance.results) {
|
|
589
|
+
const key = researchQueryKey(result.query);
|
|
590
|
+
const bucket = byQuery.get(key) ?? [];
|
|
591
|
+
bucket.push(result);
|
|
592
|
+
byQuery.set(key, bucket);
|
|
593
|
+
}
|
|
594
|
+
for (const query of provenance.queries) {
|
|
595
|
+
lines.push(`- \`${cleanEvidenceText(query.query, 120)}\` — ${query.platform}; ${query.providers.join(", ")}`);
|
|
596
|
+
const results = byQuery.get(researchQueryKey(query)) ?? [];
|
|
597
|
+
if (results.length === 0) {
|
|
598
|
+
lines.push(" - _No allowlisted result._");
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
for (const result of results) {
|
|
602
|
+
lines.push(` - [${escapeMarkdownLabel(result.title)}](<${result.url}>) — ${escapeMarkdownLabel(result.provider)}/${escapeMarkdownLabel(result.sourceKind)}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
for (const warning of provenance.warnings) {
|
|
606
|
+
lines.push(`- ⚠️ ${escapeMarkdownLabel(warning)}`);
|
|
607
|
+
}
|
|
608
|
+
if (provenance.error) {
|
|
609
|
+
lines.push(`- ⚠️ Research failed: ${escapeMarkdownLabel(provenance.error)}`);
|
|
610
|
+
}
|
|
611
|
+
return lines.join("\n");
|
|
612
|
+
}
|
|
613
|
+
export function mergeResearchSources(...groups) {
|
|
614
|
+
const seen = new Set();
|
|
615
|
+
return groups
|
|
616
|
+
.flatMap((group) => group ?? [])
|
|
617
|
+
.filter((source) => {
|
|
618
|
+
if (seen.has(source.url))
|
|
619
|
+
return false;
|
|
620
|
+
seen.add(source.url);
|
|
621
|
+
return true;
|
|
622
|
+
})
|
|
623
|
+
.slice(0, 5);
|
|
624
|
+
}
|
|
625
|
+
/** Keep only exact URLs returned by this review's MCP calls and restore canonical titles. */
|
|
626
|
+
export function groundResearchSources(findings, evidence) {
|
|
627
|
+
const allowed = new Map(evidence.map((item) => [
|
|
628
|
+
item.url,
|
|
629
|
+
{ title: cleanEvidenceText(item.title, 240), url: item.url },
|
|
630
|
+
]));
|
|
631
|
+
return findings.map((finding) => {
|
|
632
|
+
const { sources: claimed, ...withoutSources } = finding;
|
|
633
|
+
const sources = mergeResearchSources(claimed?.flatMap((source) => {
|
|
634
|
+
const canonical = allowed.get(source.url);
|
|
635
|
+
return canonical ? [canonical] : [];
|
|
636
|
+
}));
|
|
637
|
+
return sources.length > 0 ? { ...withoutSources, sources } : withoutSources;
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Keep only reviewer decisions backed by an exact URL from this run's MCP audit.
|
|
642
|
+
* An ungrounded declaration is discarded so model output cannot inflate usefulness.
|
|
643
|
+
*/
|
|
644
|
+
export function groundResearchDecisions(decisions, evidence, agent) {
|
|
645
|
+
const allowed = new Map(evidence.map((item) => [
|
|
646
|
+
item.url,
|
|
647
|
+
{ title: cleanEvidenceText(item.title, 240), url: item.url },
|
|
648
|
+
]));
|
|
649
|
+
return decisions.flatMap((decision) => {
|
|
650
|
+
const sources = mergeResearchSources(decision.sources.flatMap((source) => {
|
|
651
|
+
const canonical = allowed.get(source.url);
|
|
652
|
+
return canonical ? [canonical] : [];
|
|
653
|
+
}));
|
|
654
|
+
if (sources.length === 0)
|
|
655
|
+
return [];
|
|
656
|
+
return [
|
|
657
|
+
{
|
|
658
|
+
outcome: decision.outcome,
|
|
659
|
+
summary: cleanEvidenceText(decision.summary, 240),
|
|
660
|
+
sources,
|
|
661
|
+
agent: cleanEvidenceText(agent, 120),
|
|
662
|
+
},
|
|
663
|
+
];
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Bound the cross-agent decision channel after grounding. Reviewer tasks finish
|
|
668
|
+
* concurrently, so sort before applying limits to keep the retained set stable.
|
|
669
|
+
*/
|
|
670
|
+
export function boundResearchDecisions(decisions) {
|
|
671
|
+
const sorted = [...decisions].sort((left, right) => {
|
|
672
|
+
const leftKey = `${left.agent}\0${left.outcome}\0${left.summary}\0${left.sources[0]?.url ?? ""}`;
|
|
673
|
+
const rightKey = `${right.agent}\0${right.outcome}\0${right.summary}\0${right.sources[0]?.url ?? ""}`;
|
|
674
|
+
return leftKey.localeCompare(rightKey);
|
|
675
|
+
});
|
|
676
|
+
const kept = sorted.slice(0, RESEARCH_DECISION_COUNT_LIMIT);
|
|
677
|
+
let omitted = sorted.length - kept.length;
|
|
678
|
+
while (kept.length > 0 &&
|
|
679
|
+
Buffer.byteLength(JSON.stringify(kept), "utf8") > RESEARCH_DECISION_BYTES_LIMIT) {
|
|
680
|
+
kept.pop();
|
|
681
|
+
omitted++;
|
|
682
|
+
}
|
|
683
|
+
return { decisions: kept, omitted };
|
|
684
|
+
}
|
|
685
|
+
/** Count unique audited results that materially affected the final review. */
|
|
686
|
+
export function summarizeResearchUsefulness(provenance, findings) {
|
|
687
|
+
const resultUrls = new Set(provenance.results.map((result) => result.url));
|
|
688
|
+
const citedUrls = new Set(findings.flatMap((finding) => (finding.sources ?? []).flatMap((source) => (resultUrls.has(source.url) ? [source.url] : []))));
|
|
689
|
+
const decisions = provenance.decisions ?? [];
|
|
690
|
+
const decisionUrls = new Set(decisions.flatMap((decision) => decision.sources.flatMap((source) => (resultUrls.has(source.url) ? [source.url] : []))));
|
|
691
|
+
const utilizedUrls = new Set([...citedUrls, ...decisionUrls]);
|
|
692
|
+
return {
|
|
693
|
+
finalFindingsWithSources: findings.filter((finding) => (finding.sources ?? []).some((source) => resultUrls.has(source.url))).length,
|
|
694
|
+
citedResultCount: citedUrls.size,
|
|
695
|
+
supportedFindingCandidates: decisions.filter((decision) => decision.outcome === "supported-finding").length,
|
|
696
|
+
dismissedCandidates: decisions.filter((decision) => decision.outcome === "dismissed-candidate")
|
|
697
|
+
.length,
|
|
698
|
+
decisionResultCount: decisionUrls.size,
|
|
699
|
+
utilizedResultCount: utilizedUrls.size,
|
|
700
|
+
unusedResultCount: Math.max(0, resultUrls.size - utilizedUrls.size),
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
export function formatResearchUsefulness(usefulness) {
|
|
704
|
+
return (` research usefulness: ${usefulness.finalFindingsWithSources} final finding(s) cited ` +
|
|
705
|
+
`${usefulness.citedResultCount} unique result(s); ` +
|
|
706
|
+
`${usefulness.supportedFindingCandidates} supported and ` +
|
|
707
|
+
`${usefulness.dismissedCandidates} dismissed candidate(s); ` +
|
|
708
|
+
`${usefulness.utilizedResultCount} result(s) materially used, ` +
|
|
709
|
+
`${usefulness.unusedResultCount} unused`);
|
|
710
|
+
}
|
|
711
|
+
export function renderResearchUsefulnessMarkdown(provenance) {
|
|
712
|
+
const usefulness = provenance.usefulness;
|
|
713
|
+
if (!usefulness)
|
|
714
|
+
return "";
|
|
715
|
+
const totalUniqueResults = usefulness.utilizedResultCount + usefulness.unusedResultCount;
|
|
716
|
+
const lines = [
|
|
717
|
+
"### 📚 Documentation research usefulness",
|
|
718
|
+
"",
|
|
719
|
+
`- Final findings with grounded citations: **${usefulness.finalFindingsWithSources}**`,
|
|
720
|
+
`- Unique results cited by final findings: **${usefulness.citedResultCount}**`,
|
|
721
|
+
`- Candidate decisions: **${usefulness.supportedFindingCandidates} supported**, **${usefulness.dismissedCandidates} dismissed**`,
|
|
722
|
+
`- Unique results materially used: **${usefulness.utilizedResultCount}/${totalUniqueResults}**`,
|
|
723
|
+
];
|
|
724
|
+
if (provenance.decisions?.length) {
|
|
725
|
+
lines.push("", "Grounded candidate decisions:");
|
|
726
|
+
for (const decision of provenance.decisions) {
|
|
727
|
+
const sources = decision.sources
|
|
728
|
+
.map((source) => `[${escapeMarkdownLabel(source.title)}](<${source.url}>)`)
|
|
729
|
+
.join(", ");
|
|
730
|
+
lines.push(`- **${decision.outcome === "supported-finding" ? "Supported finding" : "Dismissed candidate"}** (${escapeMarkdownLabel(decision.agent)}): ${escapeMarkdownLabel(decision.summary)} — ${sources}`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return lines.join("\n");
|
|
734
|
+
}
|
|
417
735
|
export async function collectPlatformResearch(files, config) {
|
|
418
736
|
const queries = deriveResearchQueries(files, config.maxQueries);
|
|
419
737
|
if (!config.enabled || queries.length === 0) {
|