232 lines
7.9 KiB
JavaScript
232 lines
7.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// Emits the ENTIRE prompt a sub-agent sees for one candidate (candidate +
|
|
// deep-dive evidence + filtered citations + playbook + protocol + output
|
|
// schema). --list emits a manifest the orchestrator uses to decide fan-out
|
|
// vs serial. Brief → stdout, status → stderr.
|
|
|
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
import {
|
|
buildBrief,
|
|
inferPlaybook,
|
|
inferFrameworkPlaybook,
|
|
resolveFiles,
|
|
citationSubset,
|
|
} from '../lib/investigation-brief.mjs';
|
|
import { supportTopicSubset } from '../lib/support-topics.mjs';
|
|
import { candidateRefFor } from '../lib/reconcile-candidates.mjs';
|
|
import { formatCandidateLabel } from '../lib/display-labels.mjs';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const PLAYBOOKS_DIR = join(HERE, '..', 'references', 'playbooks');
|
|
|
|
const log = (...a) => console.error('[prepare-brief]', ...a);
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (!args.mergedPath || !args.investigationPath) {
|
|
console.error('usage: node scripts/prepare-investigation-brief.mjs <merged.json> <investigation.json> [--index N] [--group toLaunch|platform] [--out FILE]');
|
|
console.error(' or: node scripts/prepare-investigation-brief.mjs <merged.json> <investigation.json> --list');
|
|
process.exit(1);
|
|
}
|
|
|
|
const [merged, investigation] = await Promise.all([
|
|
readFile(args.mergedPath, 'utf-8').then(JSON.parse),
|
|
readFile(args.investigationPath, 'utf-8').then(JSON.parse),
|
|
]);
|
|
|
|
if (args.list) {
|
|
const manifest = buildManifest(merged, investigation);
|
|
process.stdout.write(JSON.stringify(manifest, null, 2) + '\n');
|
|
return;
|
|
}
|
|
|
|
const group = args.group ?? 'toLaunch';
|
|
const index = args.index ?? 0;
|
|
const pool = Array.isArray(investigation[group]) ? investigation[group] : [];
|
|
if (index < 0 || index >= pool.length) {
|
|
console.error(`[prepare-brief] FATAL: ${group}[${index}] out of range (${group} has ${pool.length} entries)`);
|
|
process.exit(2);
|
|
}
|
|
let candidate = pool[index];
|
|
|
|
// Scan output may live at merged.codebase (older shape) or merged.signals.codebase
|
|
// (current shape, after the jq merge nests it under signals). Resolve either.
|
|
const codebase = pickCodebase(merged);
|
|
const signals = {
|
|
...merged,
|
|
codebase,
|
|
};
|
|
const files = resolveFiles(candidate, signals);
|
|
candidate = {
|
|
...candidate,
|
|
candidateRef: candidate.candidateRef ?? candidateRefFor(candidate, files),
|
|
};
|
|
const playbookId = inferPlaybook(signals);
|
|
const playbookBody = playbookId ? await tryReadPlaybook(playbookId) : null;
|
|
const frameworkPlaybookId = inferFrameworkPlaybook(signals);
|
|
const frameworkPlaybookBody = frameworkPlaybookId ? await tryReadPlaybook(frameworkPlaybookId) : null;
|
|
|
|
const stack = signals.stack ?? signals.codebase?.stack ?? {};
|
|
const framework = stack.framework ?? 'unknown';
|
|
const version = stack.frameworkVersion ?? 'unknown';
|
|
const citations = await citationSubset(candidate.kind, framework, version);
|
|
const supportTopics = await supportTopicSubset({
|
|
candidate,
|
|
signals,
|
|
framework,
|
|
version,
|
|
profile: playbookId,
|
|
frameworkPlaybookId,
|
|
});
|
|
|
|
const brief = buildBrief({
|
|
candidate,
|
|
candidateIndex: index,
|
|
candidateGroup: group,
|
|
files,
|
|
signals,
|
|
citations,
|
|
playbookId,
|
|
playbookBody,
|
|
frameworkPlaybookId,
|
|
frameworkPlaybookBody,
|
|
supportTopics,
|
|
generatedAt: args.deterministic ? null : new Date().toISOString(),
|
|
});
|
|
|
|
if (args.outPath) {
|
|
await mkdir(dirname(args.outPath), { recursive: true });
|
|
await writeBriefFile(args.outPath, brief, { force: args.force });
|
|
log(`wrote ${brief.length}B → ${args.outPath}`);
|
|
} else {
|
|
process.stdout.write(brief + '\n');
|
|
}
|
|
}
|
|
|
|
function buildManifest(merged, investigation) {
|
|
const out = [];
|
|
const groups = ['toLaunch', 'platform'];
|
|
for (const group of groups) {
|
|
const pool = Array.isArray(investigation[group]) ? investigation[group] : [];
|
|
pool.forEach((c, i) => {
|
|
const files = resolveFiles(c, { ...merged, codebase: pickCodebase(merged) });
|
|
const candidateRef = c.candidateRef ?? candidateRefFor(c, files);
|
|
out.push({
|
|
group,
|
|
index: i,
|
|
kind: c.kind,
|
|
route: c.route ?? c.hostname ?? null,
|
|
scope: c.scope ?? null,
|
|
priority: c.priority ?? null,
|
|
confidence: c.confidence ?? null,
|
|
o11ySignal: c.o11ySignal ?? null,
|
|
files,
|
|
candidateRef,
|
|
label: formatCandidateLabel({ ...c, files }),
|
|
});
|
|
});
|
|
}
|
|
return {
|
|
schemaVersion: '1.0',
|
|
totalBriefs: out.length,
|
|
toLaunchCount: out.filter((b) => b.group === 'toLaunch').length,
|
|
platformCount: out.filter((b) => b.group === 'platform').length,
|
|
preResolvedRecords: Array.isArray(investigation.preResolvedRecords)
|
|
? investigation.preResolvedRecords
|
|
: [],
|
|
fanoutPlan: buildFanoutPlan(out),
|
|
briefs: out,
|
|
};
|
|
}
|
|
|
|
function buildFanoutPlan(briefs) {
|
|
const groups = new Map();
|
|
for (const brief of briefs) {
|
|
const key = candidateFamilyKey(brief);
|
|
const existing = groups.get(key) ?? {
|
|
familyKey: key,
|
|
label: brief.label,
|
|
kind: brief.kind,
|
|
primaryBrief: { group: brief.group, index: brief.index, candidateRef: brief.candidateRef },
|
|
relatedBriefs: [],
|
|
};
|
|
if (existing.primaryBrief.candidateRef !== brief.candidateRef) {
|
|
existing.relatedBriefs.push({ group: brief.group, index: brief.index, candidateRef: brief.candidateRef });
|
|
}
|
|
groups.set(key, existing);
|
|
}
|
|
return {
|
|
totalFamilies: groups.size,
|
|
families: [...groups.values()].map((g) => ({
|
|
...g,
|
|
totalBriefs: 1 + g.relatedBriefs.length,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function candidateFamilyKey(brief) {
|
|
const file = Array.isArray(brief.files) && brief.files.length > 0 ? brief.files[0] : null;
|
|
const target = file ?? brief.route ?? brief.scope ?? '<account>';
|
|
return `${brief.kind ?? 'unknown'}:${target}`;
|
|
}
|
|
|
|
// Prefer merged.codebase, fall back to merged.signals.codebase, then empty.
|
|
// Also accepts a fully-shaped scan doc directly (used in tests).
|
|
function pickCodebase(merged) {
|
|
if (!merged || typeof merged !== 'object') return {};
|
|
if (merged.codebase && typeof merged.codebase === 'object' && (merged.codebase.routes || merged.codebase.findings)) {
|
|
return merged.codebase;
|
|
}
|
|
if (merged.signals?.codebase && typeof merged.signals.codebase === 'object') {
|
|
return merged.signals.codebase;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
async function tryReadPlaybook(id) {
|
|
try {
|
|
return await readFile(join(PLAYBOOKS_DIR, `${id}.md`), 'utf-8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const out = { positional: [] };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === '--index') out.index = Number(argv[++i]);
|
|
else if (a.startsWith('--index=')) out.index = Number(a.slice('--index='.length));
|
|
else if (a === '--group') out.group = argv[++i];
|
|
else if (a.startsWith('--group=')) out.group = a.slice('--group='.length);
|
|
else if (a === '--out') out.outPath = resolve(argv[++i]);
|
|
else if (a.startsWith('--out=')) out.outPath = resolve(a.slice('--out='.length));
|
|
else if (a === '--list') out.list = true;
|
|
else if (a === '--deterministic') out.deterministic = true;
|
|
else if (a === '--force') out.force = true;
|
|
else out.positional.push(a);
|
|
}
|
|
out.mergedPath = out.positional[0];
|
|
out.investigationPath = out.positional[1];
|
|
return out;
|
|
}
|
|
|
|
async function writeBriefFile(outPath, brief, { force = false } = {}) {
|
|
try {
|
|
await writeFile(outPath, brief + '\n', { encoding: 'utf-8', flag: force ? 'w' : 'wx' });
|
|
} catch (err) {
|
|
if (err?.code === 'EEXIST') {
|
|
throw new Error(`output file already exists: ${outPath}. Use a fresh run directory or pass --force to overwrite.`);
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('[prepare-brief] FAILED:', err.message);
|
|
console.error(err.stack);
|
|
process.exit(1);
|
|
});
|