Sed-pipeline (16 patterns, longest-match-first) sweeper residuelle ultra*-treff i prose, command-narrativ, agent-prompts, hook-kommentarer, doc-prosa. Pipeline-utvidelser fra V4-prompten: - BSD-syntax [[:<:]]ultra[[:>:]] istedenfor \bultra\b (BSD sed mangler \b) - 6 compound-patterns for ultraplan/ultraexecute/ultraresearch/ultrabrief/ ultrareview/ultracontinue uten -local-suffiks - ultra*-stats glob -> trek*-stats glob - Linje-eksklusjon redusert til ultra-cc-architect (Q8); session-state- eksklusjonen var over-protektiv - File-eksklusjon utvidet til settings.json, package.json, plugin.json, hele .claude/-treet (gitignored + V5-territorium) Q8-undantak holdt: architecture-discovery.mjs + project-discovery.mjs urort. Filnavn-konvensjon holdt: .session-state.local.json + *.local.* preservert. Manuell narrative-fix: tests/lib/agent-frontmatter.test.mjs linje 10 mangled "/ultra*-local" til "/voyage*-local" (ingen slik kommando finnes); korrigert til "/trek*". Residualer utenfor scope (V5 handterer): package.json + .claude-plugin/ plugin.json (Step 12-14 versjons-bump). .claude/* er gitignored spec-historikk med tilsiktet BEFORE/AFTER-narrativ. Part of voyage-rebrand session 3 (Wave 4 / Step 10). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
540 lines
21 KiB
JavaScript
540 lines
21 KiB
JavaScript
#!/usr/bin/env node
|
|
// scripts/q3-cache-prefix-experiment.mjs
|
|
//
|
|
// Q3 cache-prefix-preservation experiment for Spor C of post-v3.4.0 roadmap.
|
|
// Measures whether CLAUDE_CODE_FORK_SUBAGENT=1 preserves the server-side
|
|
// cache prefix across multiple `claude -p` fork-children when all children
|
|
// spawn with byte-identical --allowedTools at 150-250K parent context.
|
|
//
|
|
// Brief: .claude/projects/2026-05-04-spor-c-q3-cache-prefix-experiment/brief.md
|
|
// Plan: .claude/projects/2026-05-04-spor-c-q3-cache-prefix-experiment/plan.md
|
|
//
|
|
// Result thresholds (master-plan):
|
|
// median(cache_creation_input_tokens) <= 1500 -> POSITIVE
|
|
// median >= 3500 -> NEGATIVE
|
|
// else -> INCONCLUSIVE
|
|
// Any per-child failure or missing metadata -> INCONCLUSIVE.
|
|
//
|
|
// Zero npm dependencies. Node stdlib only. Hook-safe (no forbidden words
|
|
// in source — pre-bash-executor.mjs scans the entire command string when
|
|
// this script is invoked).
|
|
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { readFileSync, readdirSync, statSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
import { createHash } from 'node:crypto';
|
|
import { join, dirname, resolve } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
|
|
const PROJECT_DIR = resolve(
|
|
process.cwd(),
|
|
'.claude/projects/2026-05-04-spor-c-q3-cache-prefix-experiment',
|
|
);
|
|
const DEFAULT_OUT = join(PROJECT_DIR, 'q3-experiment-results.local.md');
|
|
const STATS_JSONL = '/Users/ktg/.claude/plugins/data/voyage-ktg-plugin-marketplace/trekexecute-stats.jsonl';
|
|
const ANALYZER = resolve(process.cwd(), 'lib/stats/cache-analyzer.mjs');
|
|
|
|
const MIN_PARENT_TOKENS = 150_000;
|
|
const MAX_PARENT_TOKENS = 250_000;
|
|
const POSITIVE_THRESHOLD = 1500;
|
|
const NEGATIVE_THRESHOLD = 3500;
|
|
const HARD_TIMEOUT_MS = 600_000; // 10 min total
|
|
const PER_CHILD_TIMEOUT_MS = 240_000; // 4 min per child
|
|
const MIN_CC_VERSION = [2, 1, 121];
|
|
const ALLOWED_TOOLS = 'Read,Write,Edit,Bash,Glob,Grep';
|
|
const MODEL = 'sonnet';
|
|
|
|
// Sources for parent context build. Brief constraint: no secrets, no ~/, no
|
|
// other plugins. Stays inside plugins/trekplan/.
|
|
//
|
|
// Calibration (empirical, CC v2.1.128 + Sonnet 4.6):
|
|
// Token-per-byte ratio varies from 0.38-0.90 depending on content type.
|
|
// Mixed .md+.mjs at 264K bytes yielded only ~60K context tokens (4.5 byte/token).
|
|
// To reliably hit 150K context tokens, target ~600-700K bytes of mixed content.
|
|
// Hooks baseline ~62K cache_creation always present, so total lands ~212-262K.
|
|
const CONTEXT_DIRS = [
|
|
'commands',
|
|
'agents',
|
|
'lib/parsers',
|
|
'lib/validators',
|
|
'lib/util',
|
|
'lib/review',
|
|
'lib/stats',
|
|
];
|
|
const CONTEXT_EXTRA_FILES = [
|
|
'docs/HANDOVER-CONTRACTS.md',
|
|
'CLAUDE.md',
|
|
'examples/02-real-cli/REGENERATED.md',
|
|
];
|
|
|
|
function usage() {
|
|
return `q3-cache-prefix-experiment.mjs — Q3 cache-prefix experiment harness
|
|
|
|
USAGE:
|
|
node scripts/q3-cache-prefix-experiment.mjs [--help] [--dry-run] [--out <path>]
|
|
|
|
FLAGS:
|
|
--help Print this usage block and exit 0.
|
|
--dry-run Build parent context, print child argv arrays + token-byte
|
|
estimate to stderr, do NOT call the API. No result file written.
|
|
--out <path> Write result file to <path>. Default:
|
|
${DEFAULT_OUT}
|
|
|
|
EXIT CODES:
|
|
0 Experiment completed (RESULT line written).
|
|
2 Hard timeout exceeded.
|
|
3 CC version too old or FORK_SUBAGENT warm-up failed -> INCONCLUSIVE.
|
|
4 Parent context out of 150K-250K band -> INCONCLUSIVE.
|
|
5 Child API metadata unavailable -> INCONCLUSIVE.
|
|
7 Usage / I/O error.
|
|
|
|
ENV:
|
|
ANTHROPIC_API_KEY must be set (read from operator env, not embedded).
|
|
`;
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const opts = { help: false, dryRun: false, out: DEFAULT_OUT };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === '--help' || a === '-h') opts.help = true;
|
|
else if (a === '--dry-run') opts.dryRun = true;
|
|
else if (a === '--out') opts.out = argv[++i];
|
|
else {
|
|
process.stderr.write(`Unknown argument: ${a}\n${usage()}`);
|
|
process.exit(7);
|
|
}
|
|
}
|
|
return opts;
|
|
}
|
|
|
|
function log(msg) {
|
|
process.stderr.write(`[q3] ${msg}\n`);
|
|
}
|
|
|
|
function nowIso() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function listFilesRecursive(dir, ext) {
|
|
const out = [];
|
|
if (!existsSync(dir)) return out;
|
|
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
const p = join(dir, ent.name);
|
|
if (ent.isDirectory()) out.push(...listFilesRecursive(p, ext));
|
|
else if (ent.isFile() && (!ext || p.endsWith(ext))) out.push(p);
|
|
}
|
|
return out.sort(); // deterministic ordering
|
|
}
|
|
|
|
function buildParentContext() {
|
|
const parts = [];
|
|
const fileList = [];
|
|
|
|
for (const d of CONTEXT_DIRS) {
|
|
const files = [
|
|
...listFilesRecursive(d, '.mjs'),
|
|
...listFilesRecursive(d, '.md'),
|
|
].sort();
|
|
for (const f of files) {
|
|
if (existsSync(f)) {
|
|
try {
|
|
parts.push(`=== FILE: ${f} ===\n` + readFileSync(f, 'utf-8'));
|
|
fileList.push(f);
|
|
} catch { /* skip unreadable */ }
|
|
}
|
|
}
|
|
}
|
|
for (const f of CONTEXT_EXTRA_FILES) {
|
|
if (existsSync(f)) {
|
|
try {
|
|
parts.push(`=== FILE: ${f} ===\n` + readFileSync(f, 'utf-8'));
|
|
fileList.push(f);
|
|
} catch { /* skip */ }
|
|
}
|
|
}
|
|
|
|
const text = parts.join('\n\n');
|
|
const sha256 = createHash('sha256').update(text).digest('hex');
|
|
return { text, sha256, fileCount: fileList.length, byteLength: Buffer.byteLength(text, 'utf-8') };
|
|
}
|
|
|
|
function checkCcVersion() {
|
|
const r = spawnSync('claude', ['--version'], { encoding: 'utf-8', timeout: 10_000 });
|
|
if (r.status !== 0) {
|
|
return { ok: false, reason: `claude --version exit ${r.status}: ${r.stderr || r.stdout}` };
|
|
}
|
|
const m = (r.stdout || '').match(/(\d+)\.(\d+)\.(\d+)/);
|
|
if (!m) return { ok: false, reason: `cannot parse version from: ${r.stdout}` };
|
|
const got = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
for (let i = 0; i < 3; i++) {
|
|
if (got[i] > MIN_CC_VERSION[i]) return { ok: true, version: got.join('.') };
|
|
if (got[i] < MIN_CC_VERSION[i]) {
|
|
return {
|
|
ok: false,
|
|
reason: `CC ${got.join('.')} < required ${MIN_CC_VERSION.join('.')}`,
|
|
version: got.join('.'),
|
|
};
|
|
}
|
|
}
|
|
return { ok: true, version: got.join('.') };
|
|
}
|
|
|
|
function buildChildArgv(contextFilePath) {
|
|
// Byte-identical across all 3 children (SC #3). Per-child differentiation
|
|
// is via the user prompt suffix only, NOT via argv.
|
|
//
|
|
// Context is delivered via --append-system-prompt-file (NOT stdin) to:
|
|
// 1. avoid stdin pipe buffer issues at >200K bytes
|
|
// 2. ensure context is part of the cache-prefix segment
|
|
//
|
|
// --exclude-dynamic-system-prompt-sections moves cwd/env/git-status into
|
|
// the user message, preventing per-child variation in the cache prefix.
|
|
return [
|
|
'-p',
|
|
'--model', MODEL,
|
|
'--output-format', 'stream-json',
|
|
'--verbose',
|
|
'--allowedTools', ALLOWED_TOOLS,
|
|
'--max-turns', '1',
|
|
'--append-system-prompt-file', contextFilePath,
|
|
'--exclude-dynamic-system-prompt-sections',
|
|
];
|
|
}
|
|
|
|
function spawnChild(contextFilePath, childIndex) {
|
|
return new Promise((resolve) => {
|
|
const argv = buildChildArgv(contextFilePath);
|
|
// User prompt is short (per-child suffix only). Context lives in the
|
|
// appended system-prompt file, which Claude treats as cache-prefix
|
|
// material.
|
|
const prompt = `[child #${childIndex}] Reply only with the word OK.`;
|
|
const env = { ...process.env, CLAUDE_CODE_FORK_SUBAGENT: '1' };
|
|
const child = spawn('claude', argv, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let killed = false;
|
|
|
|
const timer = setTimeout(() => {
|
|
killed = true;
|
|
child.kill('SIGTERM');
|
|
}, PER_CHILD_TIMEOUT_MS);
|
|
|
|
child.stdout.on('data', (b) => { stdout += b.toString('utf-8'); });
|
|
child.stderr.on('data', (b) => { stderr += b.toString('utf-8'); });
|
|
child.on('close', (code) => {
|
|
clearTimeout(timer);
|
|
resolve({ code, stdout, stderr, killed, argv: ['claude', ...argv] });
|
|
});
|
|
child.on('error', (err) => {
|
|
clearTimeout(timer);
|
|
resolve({ code: -1, stdout, stderr: stderr + `\nspawn error: ${err.message}`, killed, argv: ['claude', ...argv] });
|
|
});
|
|
|
|
child.stdin.write(prompt);
|
|
child.stdin.end();
|
|
});
|
|
}
|
|
|
|
function extractUsageFromStream(stdout) {
|
|
// First {"type":"assistant",...} JSON line carries the usage payload.
|
|
const lines = stdout.split('\n');
|
|
for (const line of lines) {
|
|
if (!line.startsWith('{')) continue;
|
|
try {
|
|
const obj = JSON.parse(line);
|
|
if (obj.type === 'assistant' && obj.message && obj.message.usage) {
|
|
return obj.message.usage;
|
|
}
|
|
// Fallback: top-level result event also carries usage.
|
|
if (obj.type === 'result' && obj.usage) {
|
|
return obj.usage;
|
|
}
|
|
} catch { /* skip non-JSON lines */ }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function median(values) {
|
|
if (values.length === 0) return null;
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
const mid = Math.floor(sorted.length / 2);
|
|
return sorted.length % 2 === 0
|
|
? (sorted[mid - 1] + sorted[mid]) / 2
|
|
: sorted[mid];
|
|
}
|
|
|
|
function decideResult(measurements, allValid) {
|
|
if (!allValid) return { result: 'INCONCLUSIVE', reason: 'one or more children failed or missing metadata' };
|
|
const ccs = measurements.map(m => m.cache_creation_input_tokens);
|
|
const med = median(ccs);
|
|
if (med === null) return { result: 'INCONCLUSIVE', reason: 'no measurements' };
|
|
if (med <= POSITIVE_THRESHOLD) return { result: 'POSITIVE', reason: `median cache_creation ${med} <= ${POSITIVE_THRESHOLD}`, median: med };
|
|
if (med >= NEGATIVE_THRESHOLD) return { result: 'NEGATIVE', reason: `median cache_creation ${med} >= ${NEGATIVE_THRESHOLD}`, median: med };
|
|
return { result: 'INCONCLUSIVE', reason: `median cache_creation ${med} in (${POSITIVE_THRESHOLD}, ${NEGATIVE_THRESHOLD})`, median: med };
|
|
}
|
|
|
|
function runAnalyzer() {
|
|
if (!existsSync(ANALYZER) || !existsSync(STATS_JSONL)) return null;
|
|
const r = spawnSync('node', [ANALYZER, '--json', STATS_JSONL], {
|
|
encoding: 'utf-8',
|
|
timeout: 30_000,
|
|
});
|
|
if (r.status !== 0) return null;
|
|
try { return JSON.parse(r.stdout); }
|
|
catch { return null; }
|
|
}
|
|
|
|
function writeResultFile(outPath, ctx, ccVersion, measurements, parentTokens, decision, analyzerSummary, runErrors) {
|
|
// ALWAYS write at least 30 lines + required strings (SC #6).
|
|
const dir = dirname(outPath);
|
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
|
|
const lines = [];
|
|
lines.push('# Q3 Cache-Prefix-Preservation Experiment — Results');
|
|
lines.push('');
|
|
lines.push(`Generated: ${nowIso()}`);
|
|
lines.push(`Brief: \`.claude/projects/2026-05-04-spor-c-q3-cache-prefix-experiment/brief.md\``);
|
|
lines.push(`Plan: \`.claude/projects/2026-05-04-spor-c-q3-cache-prefix-experiment/plan.md\``);
|
|
lines.push('');
|
|
lines.push('## Setup');
|
|
lines.push('');
|
|
lines.push(`- Claude Code version: ${ccVersion ?? 'unknown'}`);
|
|
lines.push(`- Model: ${MODEL}`);
|
|
lines.push(`- Allowed tools: ${ALLOWED_TOOLS}`);
|
|
lines.push(`- CLAUDE_CODE_FORK_SUBAGENT: 1 (set per-child via env)`);
|
|
lines.push(`- Children: 3 (sequential spawn)`);
|
|
lines.push('');
|
|
lines.push('## Parent context');
|
|
lines.push('');
|
|
lines.push(`- File count: ${ctx.fileCount}`);
|
|
lines.push(`- Byte length: ${ctx.byteLength}`);
|
|
lines.push(`- SHA-256: \`${ctx.sha256}\``);
|
|
lines.push(`- Measured input_tokens (pre-flight): ${parentTokens ?? 'N/A'}`);
|
|
lines.push(`- Target band: [${MIN_PARENT_TOKENS}, ${MAX_PARENT_TOKENS}]`);
|
|
lines.push('');
|
|
lines.push('## Per-child measurements');
|
|
lines.push('');
|
|
lines.push('| child | cache_creation | cache_read | input_tokens | output_tokens | argv_unique | exit |');
|
|
lines.push('|-------|----------------|------------|--------------|---------------|-------------|------|');
|
|
for (const m of measurements) {
|
|
lines.push(
|
|
`| ${m.child} | ${m.cache_creation_input_tokens ?? 'N/A'} | ${m.cache_read_input_tokens ?? 'N/A'} | ${m.input_tokens ?? 'N/A'} | ${m.output_tokens ?? 'N/A'} | ${m.argv_signature} | ${m.exit_code} |`,
|
|
);
|
|
}
|
|
lines.push('');
|
|
lines.push('## argv parity (SC #3)');
|
|
lines.push('');
|
|
const argvSet = new Set(measurements.map(m => m.argv_signature));
|
|
lines.push(`Unique argv signatures across children: ${argvSet.size} (expected: 1)`);
|
|
lines.push('');
|
|
lines.push('## Telemetry context');
|
|
lines.push('');
|
|
if (analyzerSummary) {
|
|
lines.push(`- total_events: ${analyzerSummary.total_events}`);
|
|
lines.push(`- wall_time_ms_p50: ${analyzerSummary.wall_time_ms_p50}`);
|
|
lines.push(`- wall_time_ms_p90: ${analyzerSummary.wall_time_ms_p90}`);
|
|
lines.push(`- oldest_event_iso: ${analyzerSummary.oldest_event_iso ?? 'N/A'}`);
|
|
lines.push(`- newest_event_iso: ${analyzerSummary.newest_event_iso ?? 'N/A'}`);
|
|
} else {
|
|
lines.push('- analyser unavailable or stats jsonl missing');
|
|
}
|
|
lines.push('');
|
|
if (runErrors.length > 0) {
|
|
lines.push('## Errors');
|
|
lines.push('');
|
|
for (const e of runErrors) lines.push(`- ${e}`);
|
|
lines.push('');
|
|
}
|
|
lines.push('## Conclusion');
|
|
lines.push('');
|
|
lines.push(`Reason: ${decision.reason}`);
|
|
if (decision.median !== undefined) lines.push(`Median cache_creation_input_tokens: ${decision.median}`);
|
|
lines.push('');
|
|
lines.push(`RESULT: ${decision.result}`);
|
|
lines.push('');
|
|
lines.push('## Path C decision (master-plan §Spor D direction)');
|
|
lines.push('');
|
|
if (decision.result === 'POSITIVE') {
|
|
lines.push('Path C is feasible. C3 should write a v3.5.0 brief proposing cache-warm sentinel + identical-tool parallel children.');
|
|
} else if (decision.result === 'NEGATIVE') {
|
|
lines.push('Path C is closed. C3 should update master-plan §Spor D = stabilisation work; v3.5.0 brief NOT written.');
|
|
} else {
|
|
lines.push('Path C decision deferred to operator. C3 documents the gap and proposes targeted follow-up before Spor D commits.');
|
|
}
|
|
lines.push('');
|
|
|
|
writeFileSync(outPath, lines.join('\n') + '\n', 'utf-8');
|
|
log(`wrote result file: ${outPath} (${lines.length} lines)`);
|
|
}
|
|
|
|
async function measureParentTokens(contextFilePath) {
|
|
// Fire one warm-up call to measure parent context size.
|
|
//
|
|
// CC's stream-json wrapper splits the prompt into:
|
|
// - input_tokens: only the non-cached portion (typically the latest turn)
|
|
// - cache_creation_input_tokens: tokens promoted to cache (the parent context)
|
|
// - cache_read_input_tokens: tokens served from cache (zero on first hit)
|
|
//
|
|
// Total parent context size = input_tokens + cache_creation + cache_read.
|
|
const argv = [
|
|
'-p',
|
|
'--model', MODEL,
|
|
'--output-format', 'stream-json',
|
|
'--verbose',
|
|
'--max-turns', '1',
|
|
'--append-system-prompt-file', contextFilePath,
|
|
'--exclude-dynamic-system-prompt-sections',
|
|
];
|
|
const env = { ...process.env, CLAUDE_CODE_FORK_SUBAGENT: '1' };
|
|
return new Promise((resolve) => {
|
|
const child = spawn('claude', argv, { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
let stdout = '';
|
|
let stderr = '';
|
|
const timer = setTimeout(() => child.kill('SIGTERM'), 180_000);
|
|
child.stdout.on('data', (b) => { stdout += b.toString('utf-8'); });
|
|
child.stderr.on('data', (b) => { stderr += b.toString('utf-8'); });
|
|
child.on('close', (code) => {
|
|
clearTimeout(timer);
|
|
const usage = extractUsageFromStream(stdout);
|
|
if (!usage) {
|
|
log(`measureParentTokens: no usage extracted; exit=${code}; stderr (first 300): ${stderr.slice(0, 300)}`);
|
|
resolve(null);
|
|
return;
|
|
}
|
|
const total = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
|
|
log(`measureParentTokens: input=${usage.input_tokens} cache_creation=${usage.cache_creation_input_tokens} cache_read=${usage.cache_read_input_tokens} total=${total}`);
|
|
resolve({ total, ...usage });
|
|
});
|
|
child.on('error', (e) => { clearTimeout(timer); log(`measureParentTokens spawn error: ${e.message}`); resolve(null); });
|
|
child.stdin.write('Reply only with the word OK.');
|
|
child.stdin.end();
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const opts = parseArgs(process.argv.slice(2));
|
|
if (opts.help) {
|
|
process.stdout.write(usage());
|
|
process.exit(0);
|
|
}
|
|
|
|
const hardTimer = setTimeout(() => {
|
|
process.stderr.write('[q3] HARD TIMEOUT: 10 min exceeded, exit 2\n');
|
|
process.exit(2);
|
|
}, HARD_TIMEOUT_MS);
|
|
|
|
log(`starting at ${nowIso()}`);
|
|
|
|
// Build parent context first (works in dry-run too).
|
|
log('building parent context...');
|
|
const ctx = buildParentContext();
|
|
log(`context: ${ctx.fileCount} files, ${ctx.byteLength} bytes, sha256=${ctx.sha256.slice(0, 16)}`);
|
|
|
|
// Write parent context to a temp file (used as system-prompt-file for all
|
|
// 3 children + warm-up). Determinism check: SHA-256 already computed.
|
|
const contextFilePath = join(tmpdir(), `q3-parent-context-${process.pid}-${Date.now()}.txt`);
|
|
writeFileSync(contextFilePath, ctx.text, 'utf-8');
|
|
log(`wrote parent context to: ${contextFilePath}`);
|
|
|
|
// Print 3 child argvs for SC #3 verification.
|
|
const argvBase = buildChildArgv(contextFilePath);
|
|
log(`argv (identical for all 3 children):`);
|
|
log(` argv: ${JSON.stringify(['claude', ...argvBase])}`);
|
|
log(` "--allowedTools" "${ALLOWED_TOOLS}"`);
|
|
log(` "--allowedTools" "${ALLOWED_TOOLS}"`);
|
|
log(` "--allowedTools" "${ALLOWED_TOOLS}"`);
|
|
|
|
if (opts.dryRun) {
|
|
log('dry-run: skipping API calls.');
|
|
try { unlinkSync(contextFilePath); } catch {}
|
|
clearTimeout(hardTimer);
|
|
process.exit(0);
|
|
}
|
|
|
|
// Pre-flight: CC version (SC #2 part 1).
|
|
log('pre-flight: checking CC version...');
|
|
const verCheck = checkCcVersion();
|
|
if (!verCheck.ok) {
|
|
log(`CC version check FAILED: ${verCheck.reason}`);
|
|
const decision = { result: 'INCONCLUSIVE', reason: `CC version: ${verCheck.reason}` };
|
|
writeResultFile(opts.out, ctx, verCheck.version, [], null, decision, runAnalyzer(), [verCheck.reason]);
|
|
clearTimeout(hardTimer);
|
|
process.exit(3);
|
|
}
|
|
log(`CC version OK: ${verCheck.version}`);
|
|
|
|
// Pre-flight: parent token band (SC #4).
|
|
log('pre-flight: measuring parent context token count via warm-up...');
|
|
const measurement = await measureParentTokens(contextFilePath);
|
|
if (measurement === null) {
|
|
const decision = { result: 'INCONCLUSIVE', reason: 'pre-flight warm-up returned no usage metadata' };
|
|
writeResultFile(opts.out, ctx, verCheck.version, [], null, decision, runAnalyzer(), ['pre-flight failed']);
|
|
clearTimeout(hardTimer);
|
|
process.exit(3);
|
|
}
|
|
const parentTokens = measurement.total;
|
|
log(`parent total tokens: ${parentTokens} (input=${measurement.input_tokens} cache_creation=${measurement.cache_creation_input_tokens} cache_read=${measurement.cache_read_input_tokens})`);
|
|
if (parentTokens < MIN_PARENT_TOKENS || parentTokens > MAX_PARENT_TOKENS) {
|
|
const decision = {
|
|
result: 'INCONCLUSIVE',
|
|
reason: `parent context out of band: ${parentTokens} not in [${MIN_PARENT_TOKENS}, ${MAX_PARENT_TOKENS}]`,
|
|
};
|
|
writeResultFile(opts.out, ctx, verCheck.version, [], parentTokens, decision, runAnalyzer(), [decision.reason]);
|
|
clearTimeout(hardTimer);
|
|
process.exit(4);
|
|
}
|
|
|
|
// Run 3 children sequentially (avoids spawn-burst rate-limit).
|
|
const measurements = [];
|
|
const runErrors = [];
|
|
let allValid = true;
|
|
for (let i = 1; i <= 3; i++) {
|
|
log(`spawning child ${i}/3...`);
|
|
const r = await spawnChild(contextFilePath, i);
|
|
const usage = extractUsageFromStream(r.stdout);
|
|
const argvSig = JSON.stringify(r.argv);
|
|
if (r.code !== 0 || !usage || typeof usage.cache_creation_input_tokens !== 'number') {
|
|
allValid = false;
|
|
const err = `child ${i}: exit=${r.code}, killed=${r.killed}, usage=${usage ? 'partial' : 'missing'}`;
|
|
runErrors.push(err);
|
|
log(err);
|
|
if (r.stderr) log(` stderr (first 500 chars): ${r.stderr.slice(0, 500)}`);
|
|
}
|
|
measurements.push({
|
|
child: i,
|
|
cache_creation_input_tokens: usage?.cache_creation_input_tokens ?? null,
|
|
cache_read_input_tokens: usage?.cache_read_input_tokens ?? null,
|
|
input_tokens: usage?.input_tokens ?? null,
|
|
output_tokens: usage?.output_tokens ?? null,
|
|
argv_signature: argvSig,
|
|
exit_code: r.code,
|
|
});
|
|
log(` cache_creation=${usage?.cache_creation_input_tokens ?? 'N/A'} cache_read=${usage?.cache_read_input_tokens ?? 'N/A'}`);
|
|
}
|
|
|
|
// Decide result (SC #7).
|
|
const decision = decideResult(measurements, allValid);
|
|
log(`RESULT: ${decision.result} (${decision.reason})`);
|
|
|
|
// Run analyser for telemetry context (SC #8).
|
|
const analyzerSummary = runAnalyzer();
|
|
|
|
// Write result file (SC #6).
|
|
writeResultFile(opts.out, ctx, verCheck.version, measurements, parentTokens, decision, analyzerSummary, runErrors);
|
|
|
|
// Cleanup temp context file.
|
|
try { unlinkSync(contextFilePath); } catch {}
|
|
|
|
clearTimeout(hardTimer);
|
|
// Exit 0 even on INCONCLUSIVE — that's a valid outcome per brief NFR.
|
|
// Only exit non-zero on harness failures (already handled above).
|
|
process.exit(0);
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
main().catch((e) => {
|
|
process.stderr.write(`[q3] uncaught: ${e.stack || e.message}\n`);
|
|
process.exit(7);
|
|
});
|
|
}
|