From 57590c32f9ffe0f904379fb4dcce131ca836ebf1 Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 15 Jul 2026 19:13:15 +0800 Subject: [PATCH 1/2] enforce orchestration evidence and evaluation contracts --- .gitignore | 1 + legionaries.yaml | 2 + scripts/extract-benchmark-metrics.ts | 95 +++++- scripts/generate-mutation-fixtures.ts | 116 ++++++++ src/agents/builder.ts | 2 + src/agents/orchestrator.ts | 2 + src/agents/verifier.ts | 2 + src/cli.ts | 93 +++++- src/config/legionaries.ts | 18 +- src/index.ts | 18 ++ src/runtime/build-agent-config.ts | 44 +++ src/runtime/domain-usage-contract.ts | 176 +++++++++-- src/runtime/evaluation-contracts.ts | 192 ++++++++++++ src/runtime/orchestration-benchmark.ts | 256 +++++++++++++++- src/shared/agent-types.ts | 6 + tests/agent-config.test.ts | 3 + tests/delegation-benchmark.test.ts | 107 +++++++ tests/domain-usage-contract.test.ts | 279 ++++++++++++++++++ tests/evaluation-contracts.test.ts | 128 ++++++++ .../fixtures/evaluation/checker-recall.jsonl | 2 + .../delegation-required-inputs.json | 39 +++ .../evaluation/delegation-required-tasks.json | 72 +++++ .../evaluation/routing-contract.jsonl | 2 + tests/legionaries.test.ts | 27 ++ tests/mutation-fixtures.test.ts | 48 +++ tests/orchestration-benchmark.test.ts | 202 ++++++++++++- tests/plugin-runtime.test.ts | 37 +++ 27 files changed, 1918 insertions(+), 51 deletions(-) create mode 100644 scripts/generate-mutation-fixtures.ts create mode 100644 src/runtime/evaluation-contracts.ts create mode 100644 tests/delegation-benchmark.test.ts create mode 100644 tests/evaluation-contracts.test.ts create mode 100644 tests/fixtures/evaluation/checker-recall.jsonl create mode 100644 tests/fixtures/evaluation/delegation-required-inputs.json create mode 100644 tests/fixtures/evaluation/delegation-required-tasks.json create mode 100644 tests/fixtures/evaluation/routing-contract.jsonl create mode 100644 tests/mutation-fixtures.test.ts diff --git a/.gitignore b/.gitignore index 5234578..90a98ae 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ task_plan.md node_modules/ findings.md node-compile-cache +.DS_Store diff --git a/legionaries.yaml b/legionaries.yaml index 4bfaf11..994bb16 100644 --- a/legionaries.yaml +++ b/legionaries.yaml @@ -35,3 +35,5 @@ domains: # Optional post-execution verification. Defaults to on when omitted. # verification: # default_check: true +# enforcement: warn +# allowed_commands: [] diff --git a/scripts/extract-benchmark-metrics.ts b/scripts/extract-benchmark-metrics.ts index 830a95b..339e189 100644 --- a/scripts/extract-benchmark-metrics.ts +++ b/scripts/extract-benchmark-metrics.ts @@ -7,13 +7,15 @@ * * Usage: * bun scripts/extract-benchmark-metrics.ts --marker \ - * [--db ] [--profile same-provider|mixed-provider] [--tasks coding-001,finance-001] > metrics.jsonl + * --acceptance [--db ] [--profile same-provider|mixed-provider] \ + * [--tasks coding-001,finance-001] > metrics.jsonl * - * Note: pass/fail is a human rubric and is NOT stored in the DB. Every row is emitted - * passed=true; edit the output to match the recorded rubric before summarizing. - * See docs/adr/0004-orchestration-value-evidence.md (D3). + * The acceptance file is a JSON object mapping task ids to deterministic boolean + * results. There is no default-pass path. See docs/adr/0006-statistical-evaluation-and-decoupled-verifier-evals.md (D2). */ import { Database } from 'bun:sqlite'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; type TokenRow = { id?: string; @@ -24,6 +26,7 @@ type TokenRow = { tokens_reasoning: number; tokens_cache_read: number; tokens_cache_write: number; + cost: number; }; function option(name: string): string | undefined { @@ -33,6 +36,7 @@ function option(name: string): string | undefined { const dbPath = option('--db') ?? `${process.env.HOME}/.local/share/opencode/opencode.db`; const marker = option('--marker'); +const acceptancePath = option('--acceptance'); const profile = option('--profile'); const taskFilter = option('--tasks') ?.split(',') @@ -43,12 +47,32 @@ if (!marker) { console.error('extract-benchmark-metrics requires --marker '); process.exit(1); } +if (!acceptancePath) { + console.error('extract-benchmark-metrics requires --acceptance (JSON object mapping task IDs to true/false)'); + process.exit(1); +} if (profile && profile !== 'same-provider' && profile !== 'mixed-provider') { console.error('--profile must be same-provider or mixed-provider'); process.exit(1); } -const TOKEN_COLUMNS = 'tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write'; +let acceptance: Record; +try { + const parsed: unknown = JSON.parse(readFileSync(resolve(acceptancePath), 'utf8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('acceptance file must contain a JSON object'); + } + const entries = Object.entries(parsed); + if (entries.some(([, passed]) => typeof passed !== 'boolean')) { + throw new Error('acceptance file values must be booleans'); + } + acceptance = Object.fromEntries(entries) as Record; +} catch (error) { + console.error(`invalid --acceptance file: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} + +const SESSION_COLUMNS = 'tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, cost'; const db = new Database(dbPath, { readonly: true }); const like = (value: string) => `%${value}%`; @@ -62,6 +86,14 @@ function tokensOf(row: TokenRow) { }; } +function costOf(row: TokenRow) { + if (typeof row.cost !== 'number') { + throw new Error('OpenCode session row is missing numeric cost'); + } + + return { cost: row.cost }; +} + function taskIDFromTitle(title: string): string | undefined { // Multi-segment ids like exec-coding-001 must not truncate to coding-001, // which would collide with the routing-baseline task ids. @@ -89,35 +121,70 @@ for (const [taskID, taskType] of [...tasks].sort(([left], [right]) => left.local continue; } - const base = { benchmarkID: marker, taskID, taskType, messages: 1, cost: 0, reworkTurns: 0, traceWarnings: 0, passed: true }; + const passed = acceptance[taskID]; + if (typeof passed !== 'boolean') { + console.error(`acceptance file is missing a boolean result for task ${taskID}`); + process.exit(1); + } + + const base = { + benchmarkID: marker, + runID: marker, + taskID, + taskType, + messages: 1, + reworkTurns: 0, + traceWarnings: 0, + passed, + }; const profileFields = profile ? { providerProfile: profile } : {}; const native = db - .query(`select ${TOKEN_COLUMNS} from session where title like ? and title like ? and agent='build'`) + .query(`select ${SESSION_COLUMNS} from session where title like ? and title like ? and agent='build'`) .get(like(marker), like(taskID)) as TokenRow | null; if (native) { - emit({ ...base, variant: 'native-builder', agent: 'builder', ...tokensOf(native) }); + emit({ + ...base, + variant: 'native-builder', + agent: 'builder', + ...costOf(native), + elapsedMs: null, + ...tokensOf(native), + }); } const root = db - .query(`select id, ${TOKEN_COLUMNS} from session where title like ? and title like ? and agent='orchestrator'`) + .query(`select id, ${SESSION_COLUMNS} from session where title like ? and title like ? and agent='orchestrator'`) .get(like(marker), like(taskID)) as TokenRow | null; if (root) { - emit({ ...base, variant: 'your-legion-orchestrated', agent: 'orchestrator', ...profileFields, ...tokensOf(root) }); - const children = db - .query(`select agent, ${TOKEN_COLUMNS} from session where parent_id=?`) + .query(`select agent, ${SESSION_COLUMNS} from session where parent_id=?`) .all(root.id) as TokenRow[]; + if (children.length === 0) { + console.error(`orchestrated task ${taskID} has no delegated child session`); + process.exit(1); + } + + emit({ + ...base, + variant: 'your-legion-orchestrated', + agent: 'orchestrator', + ...profileFields, + ...costOf(root), + elapsedMs: null, + ...tokensOf(root), + }); + for (const child of children) { emit({ ...base, variant: 'your-legion-orchestrated', agent: child.agent ?? 'specialist', ...profileFields, + ...costOf(child), + elapsedMs: null, ...tokensOf(child), }); } } } - -console.error('note: pass/fail is a human rubric, not stored in the DB. Rows are emitted passed=true; edit to match the recorded rubric before summarizing.'); diff --git a/scripts/generate-mutation-fixtures.ts b/scripts/generate-mutation-fixtures.ts new file mode 100644 index 0000000..942fdc3 --- /dev/null +++ b/scripts/generate-mutation-fixtures.ts @@ -0,0 +1,116 @@ +#!/usr/bin/env bun +/** + * Apply manifest mutations in isolated temp copies and retain survivors as JSONL + * benchmark fixtures. The original project is never edited. + * + * Usage: + * bun scripts/generate-mutation-fixtures.ts --project \ + * --mutations --test-command [--output ] + */ +import { cpSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join, relative, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; + +type MutationSpec = { + id: string; + file: string; + search: string; + replace: string; +}; + +function option(name: string) { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +} + +function requiredOption(name: string): string { + const value = option(name); + if (!value) { + console.error(`generate-mutation-fixtures requires ${name}`); + process.exit(1); + } + return value; +} + +const projectDir = resolve(requiredOption('--project')); +const manifestPath = resolve(requiredOption('--mutations')); +const testCommand = requiredOption('--test-command'); +const outputPath = option('--output'); + +if (!existsSync(projectDir) || !existsSync(manifestPath)) { + console.error('mutation project and manifest must exist'); + process.exit(1); +} + +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as unknown; +if (!Array.isArray(manifest)) { + console.error('mutation manifest must be a JSON array'); + process.exit(1); +} + +const mutations = manifest as MutationSpec[]; +for (const [index, mutation] of mutations.entries()) { + if ( + !mutation || + typeof mutation !== 'object' || + typeof mutation.id !== 'string' || + typeof mutation.file !== 'string' || + typeof mutation.search !== 'string' || + typeof mutation.replace !== 'string' + ) { + console.error(`invalid mutation manifest entry at index ${index}`); + process.exit(1); + } +} + +const fixtures: Record[] = []; +for (const mutation of mutations) { + const isolatedProject = mkdtempSync(join(tmpdir(), 'your-legion-mutant-')); + cpSync(projectDir, isolatedProject, { + recursive: true, + filter(source) { + const firstSegment = relative(projectDir, source).split('/')[0]; + return !['.git', 'dist', 'node_modules'].includes(firstSegment); + }, + }); + + const targetPath = resolve(isolatedProject, mutation.file); + if (!targetPath.startsWith(`${isolatedProject}/`) || !existsSync(targetPath)) { + console.error(`mutation ${mutation.id} targets a missing or unsafe file: ${mutation.file}`); + process.exit(1); + } + + const source = readFileSync(targetPath, 'utf8'); + const occurrences = source.split(mutation.search).length - 1; + if (occurrences !== 1) { + console.error(`mutation ${mutation.id} must match exactly once, got ${occurrences}`); + process.exit(1); + } + writeFileSync(targetPath, source.replace(mutation.search, mutation.replace)); + + const check = spawnSync(testCommand, { + cwd: isolatedProject, + shell: true, + encoding: 'utf8', + }); + if (check.status === 0) { + fixtures.push({ + id: mutation.id, + defectPresent: true, + survived: true, + file: mutation.file, + search: mutation.search, + replace: mutation.replace, + acceptanceCommand: testCommand, + }); + } +} + +const output = fixtures.map(fixture => JSON.stringify(fixture)).join('\n'); +if (outputPath) { + writeFileSync(resolve(outputPath), output ? `${output}\n` : ''); +} else if (output) { + process.stdout.write(`${output}\n`); +} +console.error(`retained ${fixtures.length}/${mutations.length} surviving mutants`); diff --git a/src/agents/builder.ts b/src/agents/builder.ts index 0a7af20..f203503 100644 --- a/src/agents/builder.ts +++ b/src/agents/builder.ts @@ -35,11 +35,13 @@ Return: - files or artifacts changed - what was delivered - Verification status: + - Delegation ID: copy the Delegation ID from the envelope - Files changed: yes | no - Verifier pass: ran | not-run - Status: verified | unverified | verification-skipped - Reason (required when Status is unverified or verification-skipped) - Loop Run Report: + - Delegation ID: copy the Delegation ID from the envelope - Loop: - Loop run: - Loop status: maker-complete | blocked | failed diff --git a/src/agents/orchestrator.ts b/src/agents/orchestrator.ts index 7addcd2..2cce72a 100644 --- a/src/agents/orchestrator.ts +++ b/src/agents/orchestrator.ts @@ -63,6 +63,8 @@ When a \`builder\` deliverable reports Verification status with "Files changed: When invoking a subagent, provide a compact Task Context Envelope. Every \`task\` tool prompt must start with \`Task Context Envelope:\`. Keep it around 120-180 tokens unless the task is genuinely multi-step. Task Context Envelope: +- Delegation ID: injected by the runtime; preserve it in maker and verifier reports. +- Checked delegation ID: include the maker delegation id when routing a verifier pass. - Scenario: - Loop: - Loop run: diff --git a/src/agents/verifier.ts b/src/agents/verifier.ts index beac332..21db8ad 100644 --- a/src/agents/verifier.ts +++ b/src/agents/verifier.ts @@ -35,6 +35,8 @@ Use this order: 1. Findings 2. Open questions or assumptions 3. Loop Run Report: + - Delegation ID: copy the Delegation ID from the envelope + - Checked delegation ID: copy the Checked delegation ID from the envelope - Loop: - Loop run: - Loop status: verifier-complete | blocked | failed diff --git a/src/cli.ts b/src/cli.ts index 786ea28..ee3c519 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,15 +16,23 @@ import { DOMAIN_USAGE_SCENARIOS, evaluateDomainUsageScenarios, findUnverifiedFileChangeLedgers, + findVerificationWarnings, readDomainUsageTraceEvents, } from './runtime/domain-usage-contract'; +import { + evaluateCheckerRecallFixtures, + evaluateRoutingContractFixtures, + parseCheckerRecallFixtures, + parseRoutingContractFixtures, +} from './runtime/evaluation-contracts'; import { diagnoseCustomAgentDomainNeutrality, runYourLegionDoctor } from './runtime/doctor'; import { type OrchestrationBenchmarkReport, parseBenchmarkMetrics, summarizeOrchestrationBenchmark, } from './runtime/orchestration-benchmark'; -import { AGENT_NAME_PATTERN, resolveLegionariesConfigPath } from './config/legionaries'; +import { AGENT_NAME_PATTERN, loadLegionariesConfig, resolveLegionariesConfigPath } from './config/legionaries'; +import { resolveDomainPacks } from './runtime/domain-packs'; function printUsage() { console.log(`Usage: @@ -49,6 +57,8 @@ Evidence: bunx @whchi/your-legion trace-check [--worktree ] [--config-dir ] [--require-evidence] bunx @whchi/your-legion domain-scenarios bunx @whchi/your-legion domain-scenario-check [--worktree ] [--config-dir ] + bunx @whchi/your-legion routing-contract-eval --fixtures [--config ] [--worktree ] [--config-dir ] + bunx @whchi/your-legion checker-recall-eval --fixtures Benchmark: bunx @whchi/your-legion benchmark-summarize --metrics `); @@ -637,18 +647,13 @@ function printLoopRuns(events: ReturnType, lo function printTraceSummary(events: ReturnType) { const readsByDelegation = new Map; skills: Set }>(); - const latestDelegationBySession = new Map(); for (const event of events) { if (event.event === 'delegation') { - if (event.delegationID && event.sessionID) { - latestDelegationBySession.set(event.sessionID, event.delegationID); - } continue; } - const delegationID = - event.delegationID ?? (event.sessionID ? latestDelegationBySession.get(event.sessionID) : undefined); + const delegationID = event.delegationID; if (!delegationID) { continue; } @@ -851,6 +856,10 @@ if (command === 'trace-check') { process.exit(1); } + for (const warning of findVerificationWarnings(events)) { + console.error(`warning: ${warning}`); + } + if (warnings.length > 0) { console.error(warnings.join('\n')); process.exit(1); @@ -891,8 +900,78 @@ if (command === 'domain-scenario-check') { process.exit(0); } +if (command === 'routing-contract-eval') { + const fixturesPath = optionValue('--fixtures'); + if (!fixturesPath) { + console.error('routing-contract-eval requires --fixtures (JSON array or JSONL)'); + process.exit(1); + } + + const worktree = resolve(optionValue('--worktree') ?? process.cwd()); + const config = loadLegionariesConfig({ + rootDir: worktree, + configPath: optionValue('--config'), + configDir: optionValue('--config-dir'), + }); + const fixtures = parseRoutingContractFixtures(readFileSync(resolve(fixturesPath), 'utf8')); + const result = evaluateRoutingContractFixtures( + fixtures, + resolveDomainPacks({ configPath: config.filePath, domains: config.domains }), + ); + + console.log(`Routing contract evaluation: ${result.passed}/${result.total} passed (${(result.passRate * 100).toFixed(2)}%)`); + for (const failure of result.failures) { + console.log(`FAIL ${failure.id}`); + for (const message of failure.messages) { + console.log(` ${message}`); + } + } + process.exit(result.failures.length === 0 ? 0 : 1); +} + +if (command === 'checker-recall-eval') { + const fixturesPath = optionValue('--fixtures'); + if (!fixturesPath) { + console.error('checker-recall-eval requires --fixtures (JSON array or JSONL)'); + process.exit(1); + } + + const result = evaluateCheckerRecallFixtures(parseCheckerRecallFixtures(readFileSync(resolve(fixturesPath), 'utf8'))); + console.log( + `Checker recall evaluation: recall=${(result.recall * 100).toFixed(2)}% precision=${(result.precision * 100).toFixed(2)}% pass-rate=${(result.passRate * 100).toFixed(2)}%`, + ); + console.log( + ` tp=${result.truePositives} fn=${result.falseNegatives} tn=${result.trueNegatives} fp=${result.falsePositives}`, + ); + process.exit(0); +} + function printBenchmarkReport(report: OrchestrationBenchmarkReport) { const pct = (value: number | null) => (value === null ? 'n/a' : `${(value * 100).toFixed(2)}%`); + const value = (number: number | null) => (number === null ? 'n/a' : String(number)); + + console.log('Economic summary (cost/time primary):'); + for (const task of report.economicTasks) { + console.log( + ` ${task.taskID} (${task.taskType}): native cost=${value(task.nativeCost)} elapsed-ms=${value(task.nativeElapsedMs)}; your-legion cost=${value(task.yourLegionCost)} elapsed-ms=${value(task.yourLegionElapsedMs)} -> ${task.economicOutcome}`, + ); + } + + console.log('Repetition summary (minimum 5 runs):'); + for (const repetition of report.repetitions) { + console.log( + ` ${repetition.taskID} ${repetition.variant}: runs=${repetition.runs} pass-rate=${(repetition.passRate * 100).toFixed(2)}% median-cost=${repetition.medianCost} cost-spread=${repetition.costSpread} median-elapsed-ms=${value(repetition.medianElapsedMs)} elapsed-spread-ms=${value(repetition.elapsedSpreadMs)} -> ${repetition.publishable ? 'publishable' : 'anecdotal'}`, + ); + } + + console.log('Paired cost statistics:'); + for (const paired of report.pairedStatistics) { + console.log( + ` ${paired.taskID}${paired.providerProfile ? ` ${paired.providerProfile}` : ''}: pairs=${paired.pairs} median-cost-delta=${paired.medianCostDelta} signs=+${paired.positiveDeltas}/-${paired.negativeDeltas}/0${paired.zeroDeltas} sign-test-p=${paired.signTestPValue} -> ${paired.publishable ? 'publishable' : 'anecdotal'}`, + ); + } + + console.log('Token detail (secondary):'); console.log('Per task:'); for (const task of report.tasks) { diff --git a/src/config/legionaries.ts b/src/config/legionaries.ts index 533541f..a143a42 100644 --- a/src/config/legionaries.ts +++ b/src/config/legionaries.ts @@ -279,7 +279,7 @@ function validateLoopConfigMap(loops: Record = {}): Resolved function validateVerificationConfig(verification: unknown): ResolvedVerificationConfig { if (verification === undefined) { - return { default_check: true }; + return { default_check: true, enforcement: 'warn', allowed_commands: [] }; } if (!verification || typeof verification !== 'object' || Array.isArray(verification)) { @@ -291,7 +291,21 @@ function validateVerificationConfig(verification: unknown): ResolvedVerification throw new Error('legionaries.yaml verification.default_check must be a boolean'); } - return { default_check: defaultCheck ?? true }; + const enforcement = (verification as Record).enforcement; + if (enforcement !== undefined && enforcement !== 'warn' && enforcement !== 'block') { + throw new Error('legionaries.yaml verification.enforcement must be warn or block'); + } + + const allowedCommands = (verification as Record).allowed_commands; + if (allowedCommands !== undefined) { + assertStringArray(allowedCommands, 'legionaries.yaml verification.allowed_commands'); + } + + return { + default_check: defaultCheck ?? true, + enforcement: enforcement ?? 'warn', + allowed_commands: (allowedCommands as string[] | undefined) ?? [], + }; } function resolveConfiguredMaps(parsed: LegionariesConfig | null) { diff --git a/src/index.ts b/src/index.ts index 83f42f8..6940ba4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ const yourLegionPlugin = { configPath: legionariesConfig.filePath, domains: legionariesConfig.domains, }), + enforcement: legionariesConfig.verification.enforcement, }); return { @@ -57,6 +58,7 @@ export { DOMAIN_USAGE_SCENARIOS, evaluateDomainUsageScenarios, findUnverifiedFileChangeLedgers, + findVerificationWarnings, getDomainUsageTracePath, parseTaskContextEnvelope, parseVerificationStatusReport, @@ -68,8 +70,24 @@ export { doctorResultHash, runYourLegionDoctor, } from './runtime/doctor'; +export { + evaluateCheckerRecallFixtures, + evaluateRoutingContractFixtures, + parseCheckerRecallFixtures, + parseRoutingContractFixtures, +} from './runtime/evaluation-contracts'; +export type { + CheckerRecallEvaluation, + CheckerRecallFixture, + RoutingContractEvaluation, + RoutingContractFailure, + RoutingContractFixture, +} from './runtime/evaluation-contracts'; export { summarizeOrchestrationBenchmark } from './runtime/orchestration-benchmark'; export type { + BenchmarkEconomicTask, + BenchmarkPairedStatistics, + BenchmarkRepetitionSummary, BenchmarkSessionMetric, BenchmarkTaskComparison, BenchmarkVariant, diff --git a/src/runtime/build-agent-config.ts b/src/runtime/build-agent-config.ts index 916bc2f..426c846 100644 --- a/src/runtime/build-agent-config.ts +++ b/src/runtime/build-agent-config.ts @@ -7,6 +7,7 @@ import { type BaseAgentDefinition, type EffectiveAgentConfig, type EffectiveAgentDefinition, + type LoopConfig, type ResolvedLegionaryEntry, } from '../shared/agent-types'; import { loadAgentDefinitionProviders } from './agent-definition-provider'; @@ -129,6 +130,47 @@ function allowDomainCatalogExternalReads(agents: EffectiveAgentConfig['agent']) ) as EffectiveAgentConfig['agent']; } +function augmentVerifierWithVerificationCommands( + agents: EffectiveAgentConfig['agent'], + loops: Record, + allowedCommands: string[], +) { + const verifier = agents.verifier; + if (!verifier) { + return agents; + } + + const commands = new Set(allowedCommands); + for (const loop of Object.values(loops)) { + for (const command of loop.verification.commands) { + commands.add(command); + } + } + + if (commands.size === 0) { + return agents; + } + + const bash = + typeof verifier.permission.bash === 'object' && verifier.permission.bash + ? { ...(verifier.permission.bash as Record) } + : {}; + for (const command of commands) { + bash[`${command}*`] = 'allow'; + } + + return { + ...agents, + verifier: { + ...verifier, + permission: { + ...verifier.permission, + bash, + }, + }, + }; +} + export async function buildEffectiveAgentConfig(options: LoadLegionariesConfigOptions): Promise { const { systemAgents: configuredSystemAgents, @@ -171,6 +213,8 @@ export async function buildEffectiveAgentConfig(options: LoadLegionariesConfigOp agent.orchestrator = augmentOrchestratorForCustomAgents(agent.orchestrator, customAgentDefinitions); } + agent = augmentVerifierWithVerificationCommands(agent, configuredLoops, configuredVerification.allowed_commands); + if (agent.orchestrator && configuredVerification.default_check === false) { agent.orchestrator = { ...agent.orchestrator, diff --git a/src/runtime/domain-usage-contract.ts b/src/runtime/domain-usage-contract.ts index 66f3b24..cd6ba1d 100644 --- a/src/runtime/domain-usage-contract.ts +++ b/src/runtime/domain-usage-contract.ts @@ -10,6 +10,8 @@ const TRACE_HASH_LENGTH = 16; type EnvelopeField = | 'scenarioID' + | 'delegationID' + | 'checkedDelegationID' | 'loopID' | 'loopRunID' | 'loopStatus' @@ -27,6 +29,8 @@ type EnvelopeField = const FIELD_NAMES: Record = { scenario: 'scenarioID', + 'delegation id': 'delegationID', + 'checked delegation id': 'checkedDelegationID', loop: 'loopID', 'loop run': 'loopRunID', 'loop run id': 'loopRunID', @@ -49,6 +53,8 @@ export type VerificationOutcome = 'passed' | 'failed' | 'not-run' | 'unknown'; export type VerificationLedgerStatus = 'verified' | 'unverified' | 'verification-skipped'; export type VerificationStatusReport = { + delegationID?: string; + checkedDelegationID?: string; filesChanged?: 'yes' | 'no'; verifierPass?: 'ran' | 'not-run'; verificationStatus?: VerificationLedgerStatus; @@ -62,6 +68,8 @@ export type ActiveDomainUsage = { export type TaskContextEnvelope = { scenarioID?: string; + delegationID?: string; + checkedDelegationID?: string; loopID?: string; loopRunID?: string; loopStatus?: LoopRunStatus; @@ -82,6 +90,7 @@ export type TaskContextEnvelope = { export type DomainUsageContractResult = { envelope: TaskContextEnvelope; warnings: string[]; + contractErrors: string[]; }; export type DomainUsageTraceEvent = { @@ -91,6 +100,7 @@ export type DomainUsageTraceEvent = { worktree: string; sessionID?: string; delegationID?: string; + checkedDelegationID?: string; scenarioID?: string; loopID?: string; loopRunID?: string; @@ -98,7 +108,7 @@ export type DomainUsageTraceEvent = { completionClaim?: string; verificationCommands?: string[]; verificationOutcome?: VerificationOutcome; - event: 'delegation' | 'domain-read' | 'loop-run-report' | 'verification-report'; + event: 'delegation' | 'domain-read' | 'loop-run-report' | 'verification-report' | 'file-change'; filesChanged?: 'yes' | 'no'; verifierPass?: 'ran' | 'not-run'; verificationStatus?: VerificationLedgerStatus; @@ -121,6 +131,7 @@ export type DomainUsageTraceOptions = { export type CreateDomainUsageTraceHooksOptions = DomainUsageTraceOptions & { domainPacks: DomainPack[]; + enforcement?: 'warn' | 'block'; }; export type DomainUsageScenario = { @@ -351,6 +362,8 @@ export function parseTaskContextEnvelope(text: string): TaskContextEnvelope { return { scenarioID: splitTokens(rawFields.scenarioID).join(', ') || undefined, + delegationID: splitTokens(rawFields.delegationID)[0], + checkedDelegationID: splitTokens(rawFields.checkedDelegationID)[0], loopID: splitTokens(rawFields.loopID).join(', ') || undefined, loopRunID: splitTokens(rawFields.loopRunID).join(', ') || undefined, loopStatus: parseLoopStatus(rawFields.loopStatus), @@ -404,10 +417,13 @@ export function validateDomainUsageContract( domainPacks: DomainPack[], ): DomainUsageContractResult { const warnings: string[] = []; + const contractErrors: string[] = []; const index = domainIndex(domainPacks); if (envelope.rawFields.activeDomains === undefined) { - warnings.push('missing active domains: use a domain-id: responsibility entry or none'); + const message = 'missing active domains: use a domain-id: responsibility entry or none'; + warnings.push(message); + contractErrors.push(message); } for (const activeDomain of envelope.activeDomains) { @@ -418,7 +434,9 @@ export function validateDomainUsageContract( } if (!activeDomain.responsibility) { - warnings.push(`active domain must include responsibility: ${activeDomain.id}`); + const message = `active domain must include responsibility: ${activeDomain.id}`; + warnings.push(message); + contractErrors.push(message); } } @@ -434,7 +452,7 @@ export function validateDomainUsageContract( } } - return { envelope, warnings }; + return { envelope, warnings, contractErrors }; } /** @@ -572,6 +590,29 @@ function extractSessionID(input: unknown): string | undefined { return undefined; } +function extractDelegationID(input: unknown): string | undefined { + if (!input || typeof input !== 'object') { + return undefined; + } + + const record = input as Record; + const direct = record.delegationID ?? record.delegationId; + if (typeof direct === 'string') { + return direct; + } + + const properties = record.properties; + if (properties && typeof properties === 'object') { + const propertyRecord = properties as Record; + const propertyDelegationID = propertyRecord.delegationID ?? propertyRecord.delegationId; + if (typeof propertyDelegationID === 'string') { + return propertyDelegationID; + } + } + + return undefined; +} + function extractToolName(input: unknown) { if (!input || typeof input !== 'object') { return undefined; @@ -671,7 +712,11 @@ export function parseVerificationStatusReport(input: unknown): VerificationStatu const field = fieldMatch[1].trim().toLowerCase(); const value = cleanToken(fieldMatch[2]).toLowerCase(); - if (field === 'files changed' && FILES_CHANGED_VALUES.has(value)) { + if (field === 'delegation id' && value) { + report.delegationID = value; + } else if (field === 'checked delegation id' && value) { + report.checkedDelegationID = value; + } else if (field === 'files changed' && FILES_CHANGED_VALUES.has(value)) { report.filesChanged = value as 'yes' | 'no'; } else if (field === 'verifier pass' && VERIFIER_PASS_VALUES.has(value)) { report.verifierPass = value as 'ran' | 'not-run'; @@ -679,7 +724,14 @@ export function parseVerificationStatusReport(input: unknown): VerificationStatu report.verificationStatus = value as VerificationLedgerStatus; } else if (field === 'reason') { report.verificationReason = cleanToken(fieldMatch[2]) || undefined; - } else if (!field.startsWith('files') && !field.startsWith('verifier') && field !== 'status' && field !== 'reason') { + } else if ( + !field.startsWith('files') && + !field.startsWith('verifier') && + field !== 'delegation id' && + field !== 'checked delegation id' && + field !== 'status' && + field !== 'reason' + ) { break; } } @@ -697,7 +749,27 @@ export function findUnverifiedFileChangeLedgers(events: DomainUsageTraceEvent[]) const failures: string[] = []; events.forEach((event, index) => { - if (event.event !== 'verification-report' || event.filesChanged !== 'yes' || event.verificationStatus !== 'unverified') { + if (event.event === 'file-change') { + const hasVerificationReport = events.slice(index + 1).some( + candidate => + candidate.event === 'verification-report' && + Boolean(event.delegationID) && + candidate.delegationID === event.delegationID, + ); + if (!hasVerificationReport) { + failures.push( + `${event.timestamp} file-change [silent-maker]: observed file changes had no verification status report`, + ); + } + return; + } + + if ( + event.event !== 'verification-report' || + event.filesChanged !== 'yes' || + (event.verificationStatus !== 'unverified' && + !(event.verificationStatus === 'verified' && event.loopID)) + ) { return; } @@ -707,11 +779,11 @@ export function findUnverifiedFileChangeLedgers(events: DomainUsageTraceEvent[]) candidate => candidate.event === 'delegation' && candidate.targetAgent === 'verifier' && - (!event.sessionID || !candidate.sessionID || candidate.sessionID === event.sessionID), + Boolean(event.delegationID) && candidate.checkedDelegationID === event.delegationID, ); if (!checkedByVerifier) { failures.push( - `${event.timestamp} verification-report [unverified-file-changes]: maker reported file changes with status unverified and no verifier delegation followed`, + `${event.timestamp} verification-report [${event.verificationStatus === 'verified' ? 'verified-without-verifier' : 'unverified-file-changes'}]: maker reported file changes with status ${event.verificationStatus} and no verifier delegation followed`, ); } }); @@ -719,6 +791,35 @@ export function findUnverifiedFileChangeLedgers(events: DomainUsageTraceEvent[]) return failures; } +export function findVerificationWarnings(events: DomainUsageTraceEvent[]): string[] { + return events.flatMap((event, index) => { + if ( + event.event !== 'verification-report' || + event.filesChanged !== 'yes' || + event.verificationStatus !== 'verified' || + Boolean(event.loopID) + ) { + return []; + } + + const checkedByVerifier = events + .slice(index + 1) + .some( + candidate => + candidate.event === 'delegation' && + candidate.targetAgent === 'verifier' && + Boolean(event.delegationID) && candidate.checkedDelegationID === event.delegationID, + ); + if (checkedByVerifier) { + return []; + } + + return [ + `${event.timestamp} verification-report [verified-without-verifier]: maker reported verified file changes with no verifier pass`, + ]; + }); +} + function createTraceEvent( options: CreateDomainUsageTraceHooksOptions, event: Omit, @@ -754,11 +855,13 @@ function delegationIDFor({ targetAgent, scenarioID, envelope, + sequence, }: { sessionID?: string; targetAgent?: string; scenarioID?: string; envelope: TaskContextEnvelope; + sequence: number; }) { const hash = createHash('sha256') .update( @@ -768,6 +871,7 @@ function delegationIDFor({ scenarioID, loopID: envelope.loopID, loopRunID: envelope.loopRunID, + sequence, loopStatus: envelope.loopStatus, objective: envelope.objective, activeDomains: envelope.activeDomains, @@ -782,20 +886,20 @@ function delegationIDFor({ return `del_${hash}`; } +function isFileMutatingBash(args: Record) { + const command = stringArg(args, ['command', 'cmd', 'script']) ?? ''; + return /(?:^|[;&|]\s*)(?:tee|mv|cp|rm|mkdir|touch|install|apply_patch)\b/.test(command) || />>?/.test(command); +} + function readEvidenceByDelegation(events: DomainUsageTraceEvent[]) { const readsByDelegation = new Map; skills: Set }>(); - const latestDelegationBySession = new Map(); for (const event of events) { if (event.event === 'delegation') { - if (event.delegationID && event.sessionID) { - latestDelegationBySession.set(event.sessionID, event.delegationID); - } continue; } - const delegationID = - event.delegationID ?? (event.sessionID ? latestDelegationBySession.get(event.sessionID) : undefined); + const delegationID = event.delegationID; if (!delegationID) { continue; } @@ -899,7 +1003,7 @@ function findDomainComponentByPath(domainPacks: DomainPack[], filePath: string) } export function createDomainUsageTraceHooks(options: CreateDomainUsageTraceHooksOptions) { - const latestDelegationBySession = new Map(); + let delegationSequence = 0; function write(event: DomainUsageTraceEvent) { appendDomainUsageTraceEvent({ @@ -930,15 +1034,19 @@ export function createDomainUsageTraceHooks(options: CreateDomainUsageTraceHooks targetAgent, scenarioID: envelope.scenarioID, envelope, + sequence: ++delegationSequence, }); - if (sessionID) { - latestDelegationBySession.set(sessionID, delegationID); + if (options.enforcement === 'block' && result.contractErrors.length > 0) { + throw new Error(`Task Context Envelope rejected: ${result.contractErrors.join('; ')}`); + } + if (typeof args.prompt === 'string') { + args.prompt = `${prompt}\nDelegation ID: ${delegationID}`; } - write( createTraceEvent(options, { sessionID, delegationID, + checkedDelegationID: envelope.checkedDelegationID, scenarioID: envelope.scenarioID, loopID: envelope.loopID, loopRunID: envelope.loopRunID, @@ -960,14 +1068,36 @@ export function createDomainUsageTraceHooks(options: CreateDomainUsageTraceHooks async 'tool.execute.after'(input: unknown, output: unknown) { const toolName = extractToolName(input); const sessionID = extractSessionID(input); + const delegationID = extractDelegationID(input); + const args = extractArgs(output); + + if (toolName === 'edit' || toolName === 'write' || (toolName === 'bash' && isFileMutatingBash(args))) { + write( + createTraceEvent(options, { + sessionID, + delegationID, + event: 'file-change', + filesChanged: 'yes', + toolName, + toolArgsShape: Object.keys(args).sort(), + activeDomains: [], + domainRefs: [], + domainSkills: [], + warnings: [], + }), + ); + return; + } if (toolName === 'task') { const envelope = parseTaskContextEnvelope(extractLoopRunReportText(output)); + const reportDelegationID = extractDelegationID(input) ?? envelope.delegationID; if (envelope.loopID && envelope.loopRunID && envelope.loopStatus) { write( createTraceEvent(options, { sessionID, - delegationID: sessionID ? latestDelegationBySession.get(sessionID) : undefined, + delegationID: reportDelegationID, + checkedDelegationID: envelope.checkedDelegationID, loopID: envelope.loopID, loopRunID: envelope.loopRunID, loopStatus: envelope.loopStatus, @@ -995,7 +1125,8 @@ export function createDomainUsageTraceHooks(options: CreateDomainUsageTraceHooks write( createTraceEvent(options, { sessionID, - delegationID: sessionID ? latestDelegationBySession.get(sessionID) : undefined, + delegationID: report.delegationID ?? reportDelegationID, + checkedDelegationID: report.checkedDelegationID, event: 'verification-report', toolName, filesChanged: report.filesChanged, @@ -1015,7 +1146,6 @@ export function createDomainUsageTraceHooks(options: CreateDomainUsageTraceHooks return; } - const args = extractArgs(output); const filePath = stringArg(args, ['filePath', 'path', 'file']); if (!filePath) { return; @@ -1029,7 +1159,7 @@ export function createDomainUsageTraceHooks(options: CreateDomainUsageTraceHooks write( createTraceEvent(options, { sessionID, - delegationID: sessionID ? latestDelegationBySession.get(sessionID) : undefined, + delegationID, event: 'domain-read', toolName, toolArgsShape: Object.keys(args).sort(), diff --git a/src/runtime/evaluation-contracts.ts b/src/runtime/evaluation-contracts.ts new file mode 100644 index 0000000..c9f28fa --- /dev/null +++ b/src/runtime/evaluation-contracts.ts @@ -0,0 +1,192 @@ +import { + parseTaskContextEnvelope, + validateDomainUsageContract, + type ActiveDomainUsage, +} from './domain-usage-contract'; +import type { DomainPack } from './domain-packs'; + +export type RoutingContractFixture = { + id: string; + prompt: string; + actualAgent: string; + expectedAgent: string; + expectedLoopID?: string | null; + expectedActiveDomainIDs: string[]; + expectedDomainRefs: string[]; + expectedDomainSkills: string[]; + output: string; +}; + +export type RoutingContractFailure = { + id: string; + messages: string[]; +}; + +export type RoutingContractEvaluation = { + total: number; + passed: number; + passRate: number; + failures: RoutingContractFailure[]; +}; + +export type CheckerRecallFixture = { + id: string; + makerOutput: string; + defectPresent: boolean; + verifierDecision: 'accept' | 'reject'; +}; + +export type CheckerRecallEvaluation = { + total: number; + truePositives: number; + falseNegatives: number; + trueNegatives: number; + falsePositives: number; + recall: number; + precision: number; + passRate: number; +}; + +function roundRatio(value: number) { + return Math.round(value * 10000) / 10000; +} + +function parseRecords(contents: string): unknown[] { + const trimmed = contents.trim(); + if (!trimmed) { + return []; + } + + return trimmed.startsWith('[') + ? (JSON.parse(trimmed) as unknown[]) + : trimmed.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line) as unknown); +} + +function recordAt(records: unknown[], index: number) { + const record = records[index]; + if (!record || typeof record !== 'object' || Array.isArray(record)) { + throw new Error(`evaluation fixture ${index} must be an object`); + } + + return record as Record; +} + +function stringField(record: Record, field: string, index: number) { + if (typeof record[field] !== 'string' || !record[field]) { + throw new Error(`evaluation fixture ${index} requires string field: ${field}`); + } + + return record[field] as string; +} + +function stringArrayField(record: Record, field: string, index: number) { + if (!Array.isArray(record[field]) || record[field].some(value => typeof value !== 'string')) { + throw new Error(`evaluation fixture ${index} requires string array field: ${field}`); + } + + return record[field] as string[]; +} + +export function parseRoutingContractFixtures(contents: string): RoutingContractFixture[] { + const records = parseRecords(contents); + return records.map((_, index) => { + const record = recordAt(records, index); + const expectedLoopID = record.expectedLoopID; + if (expectedLoopID !== undefined && expectedLoopID !== null && typeof expectedLoopID !== 'string') { + throw new Error(`evaluation fixture ${index} expectedLoopID must be a string or null`); + } + + return { + id: stringField(record, 'id', index), + prompt: stringField(record, 'prompt', index), + actualAgent: stringField(record, 'actualAgent', index), + expectedAgent: stringField(record, 'expectedAgent', index), + ...(expectedLoopID !== undefined ? { expectedLoopID: expectedLoopID as string | null } : {}), + expectedActiveDomainIDs: stringArrayField(record, 'expectedActiveDomainIDs', index), + expectedDomainRefs: stringArrayField(record, 'expectedDomainRefs', index), + expectedDomainSkills: stringArrayField(record, 'expectedDomainSkills', index), + output: stringField(record, 'output', index), + }; + }); +} + +export function parseCheckerRecallFixtures(contents: string): CheckerRecallFixture[] { + const records = parseRecords(contents); + return records.map((_, index) => { + const record = recordAt(records, index); + if (typeof record.defectPresent !== 'boolean') { + throw new Error(`evaluation fixture ${index} requires boolean field: defectPresent`); + } + if (record.verifierDecision !== 'accept' && record.verifierDecision !== 'reject') { + throw new Error(`evaluation fixture ${index} requires verifierDecision accept or reject`); + } + + return { + id: stringField(record, 'id', index), + makerOutput: stringField(record, 'makerOutput', index), + defectPresent: record.defectPresent, + verifierDecision: record.verifierDecision, + }; + }); +} + +function sameValues(left: string[], right: string[]) { + return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort()); +} + +export function evaluateRoutingContractFixtures( + fixtures: RoutingContractFixture[], + domainPacks: DomainPack[], +): RoutingContractEvaluation { + const failures = fixtures.flatMap(fixture => { + const envelope = parseTaskContextEnvelope(fixture.output); + const validation = validateDomainUsageContract(envelope, domainPacks); + const messages = [...new Set(validation.warnings)]; + + if (fixture.actualAgent !== fixture.expectedAgent) { + messages.push(`expected agent ${fixture.expectedAgent}, got ${fixture.actualAgent}`); + } + if ( + fixture.expectedLoopID !== undefined && + (fixture.expectedLoopID === null ? envelope.loopID !== undefined : envelope.loopID !== fixture.expectedLoopID) + ) { + messages.push(`expected loop ${fixture.expectedLoopID ?? 'none'}, got ${envelope.loopID ?? 'none'}`); + } + if (!sameValues(envelope.activeDomains.map((domain: ActiveDomainUsage) => domain.id), fixture.expectedActiveDomainIDs)) { + messages.push('active domains did not match the expected label'); + } + if (!sameValues(envelope.domainRefs, fixture.expectedDomainRefs)) { + messages.push('domain refs did not match the expected label'); + } + if (!sameValues(envelope.domainSkills, fixture.expectedDomainSkills)) { + messages.push('domain skills did not match the expected label'); + } + + return messages.length === 0 ? [] : [{ id: fixture.id, messages }]; + }); + + return { + total: fixtures.length, + passed: fixtures.length - failures.length, + passRate: fixtures.length === 0 ? 0 : roundRatio((fixtures.length - failures.length) / fixtures.length), + failures, + }; +} + +export function evaluateCheckerRecallFixtures(fixtures: CheckerRecallFixture[]): CheckerRecallEvaluation { + const truePositives = fixtures.filter(fixture => fixture.defectPresent && fixture.verifierDecision === 'reject').length; + const falseNegatives = fixtures.filter(fixture => fixture.defectPresent && fixture.verifierDecision === 'accept').length; + const trueNegatives = fixtures.filter(fixture => !fixture.defectPresent && fixture.verifierDecision === 'accept').length; + const falsePositives = fixtures.filter(fixture => !fixture.defectPresent && fixture.verifierDecision === 'reject').length; + + return { + total: fixtures.length, + truePositives, + falseNegatives, + trueNegatives, + falsePositives, + recall: truePositives + falseNegatives === 0 ? 0 : roundRatio(truePositives / (truePositives + falseNegatives)), + precision: truePositives + falsePositives === 0 ? 0 : roundRatio(truePositives / (truePositives + falsePositives)), + passRate: fixtures.length === 0 ? 0 : roundRatio((truePositives + trueNegatives) / fixtures.length), + }; +} diff --git a/src/runtime/orchestration-benchmark.ts b/src/runtime/orchestration-benchmark.ts index 855b65c..5bc9419 100644 --- a/src/runtime/orchestration-benchmark.ts +++ b/src/runtime/orchestration-benchmark.ts @@ -5,6 +5,7 @@ export type BenchmarkProviderProfile = 'same-provider' | 'mixed-provider'; export type BenchmarkSessionMetric = { benchmarkID: string; taskID: string; + runID?: string; taskType: string; variant: BenchmarkVariant; /** For orchestrated runs: whether every agent used one provider or the configured per-agent map. */ @@ -17,6 +18,7 @@ export type BenchmarkSessionMetric = { tokensCacheRead: number; tokensCacheWrite: number; cost: number; + elapsedMs?: number | null; passed: boolean; reworkTurns: number; traceWarnings: number; @@ -75,12 +77,53 @@ export type BenchmarkProviderProfileComparison = { byOutcome: BenchmarkOutcomeSummary[]; }; +export type BenchmarkRepetitionSummary = { + taskID: string; + variant: BenchmarkVariant; + providerProfile?: BenchmarkProviderProfile; + runs: number; + passedRuns: number; + passRate: number; + publishable: boolean; + medianCost: number; + costSpread: number; + medianElapsedMs: number | null; + elapsedSpreadMs: number | null; +}; + +export type BenchmarkEconomicTask = { + taskID: string; + taskType: string; + nativeCost: number | null; + yourLegionCost: number | null; + nativeElapsedMs: number | null; + yourLegionElapsedMs: number | null; + nativePassed: boolean; + yourLegionPassed: boolean; + economicOutcome: BenchmarkTaskOutcome; +}; + +export type BenchmarkPairedStatistics = { + taskID: string; + providerProfile?: BenchmarkProviderProfile; + pairs: number; + positiveDeltas: number; + negativeDeltas: number; + zeroDeltas: number; + medianCostDelta: number; + signTestPValue: number; + publishable: boolean; +}; + export type OrchestrationBenchmarkReport = { tasks: BenchmarkTaskComparison[]; byVariantAndTaskType: BenchmarkVariantTaskTypeSummary[]; byOutcome: BenchmarkOutcomeSummary[]; /** Present when orchestrated metrics carry a providerProfile: native vs each profile. */ byProviderProfile?: BenchmarkProviderProfileComparison[]; + repetitions: BenchmarkRepetitionSummary[]; + economicTasks: BenchmarkEconomicTask[]; + pairedStatistics: BenchmarkPairedStatistics[]; }; const OUTCOME_ORDER: BenchmarkTaskOutcome[] = [ @@ -110,6 +153,193 @@ function contextTokens(metric: BenchmarkSessionMetric) { return metric.tokensInput + metric.tokensCacheRead + metric.tokensCacheWrite; } +function runID(metric: BenchmarkSessionMetric) { + return metric.runID ?? metric.benchmarkID; +} + +function elapsedMs(metric: BenchmarkSessionMetric) { + return typeof metric.elapsedMs === 'number' ? metric.elapsedMs : null; +} + +function totalElapsedMs(metrics: BenchmarkSessionMetric[]) { + if (metrics.length === 0 || metrics.some(metric => elapsedMs(metric) === null)) { + return null; + } + + return metrics.reduce((total, metric) => total + (elapsedMs(metric) ?? 0), 0); +} + +function median(values: number[]) { + if (values.length === 0) { + return 0; + } + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[middle - 1]! + sorted[middle]!) / 2 : sorted[middle]!; +} + +function spread(values: number[]) { + return values.length === 0 ? 0 : Math.max(...values) - Math.min(...values); +} + +function binomialProbability(n: number, successes: number) { + if (successes < 0 || successes > n) { + return 0; + } + + let coefficient = 1; + for (let index = 1; index <= successes; index += 1) { + coefficient = (coefficient * (n - index + 1)) / index; + } + + return coefficient / 2 ** n; +} + +function signTestPValue(positiveDeltas: number, negativeDeltas: number) { + const trials = positiveDeltas + negativeDeltas; + if (trials === 0) { + return 1; + } + + const extreme = Math.max(positiveDeltas, negativeDeltas); + let tail = 0; + for (let successes = extreme; successes <= trials; successes += 1) { + tail += binomialProbability(trials, successes); + } + + return roundRatio(Math.min(1, 2 * tail)); +} + +function aggregateRunMetrics(metrics: BenchmarkSessionMetric[]) { + const groups = groupBy( + metrics, + metric => `${metric.taskID}\u0000${runID(metric)}\u0000${metric.variant}\u0000${metric.providerProfile ?? ''}`, + ); + + return [...groups.values()].map(group => ({ + taskID: group[0]!.taskID, + taskType: group[0]!.taskType, + runID: runID(group[0]!), + variant: group[0]!.variant, + ...(group[0]!.providerProfile ? { providerProfile: group[0]!.providerProfile } : {}), + cost: group.reduce((sum, metric) => sum + metric.cost, 0), + elapsedMs: totalElapsedMs(group), + passed: taskPassed(group), + })); +} + +function repetitionSummaries(metrics: BenchmarkSessionMetric[]): BenchmarkRepetitionSummary[] { + const groups = groupBy( + aggregateRunMetrics(metrics), + metric => `${metric.taskID}\u0000${metric.variant}\u0000${metric.providerProfile ?? ''}`, + ); + + return [...groups.values()] + .map(group => { + const elapsedValues = group + .map(metric => metric.elapsedMs) + .filter((value): value is number => typeof value === 'number'); + const elapsedMeasured = elapsedValues.length === group.length; + return { + taskID: group[0]!.taskID, + variant: group[0]!.variant, + ...(group[0]!.providerProfile ? { providerProfile: group[0]!.providerProfile } : {}), + runs: group.length, + passedRuns: group.filter(metric => metric.passed).length, + passRate: roundRatio(group.filter(metric => metric.passed).length / group.length), + publishable: group.length >= 5, + medianCost: median(group.map(metric => metric.cost)), + costSpread: spread(group.map(metric => metric.cost)), + medianElapsedMs: elapsedMeasured ? median(elapsedValues) : null, + elapsedSpreadMs: elapsedMeasured ? spread(elapsedValues) : null, + }; + }) + .sort((left, right) => left.taskID.localeCompare(right.taskID) || variantOrder(left.variant) - variantOrder(right.variant)); +} + +function economicTasks(metrics: BenchmarkSessionMetric[]): BenchmarkEconomicTask[] { + return [...groupBy(metrics, metric => metric.taskID).entries()] + .map(([taskID, group]) => { + const native = group.filter(metric => metric.variant === 'native-builder'); + const orchestrated = group.filter(metric => metric.variant === 'your-legion-orchestrated'); + const nativeCost = native.length > 0 ? native.reduce((sum, metric) => sum + metric.cost, 0) : null; + const yourLegionCost = + orchestrated.length > 0 ? orchestrated.reduce((sum, metric) => sum + metric.cost, 0) : null; + return { + taskID, + taskType: group[0]?.taskType ?? 'unknown', + nativeCost, + yourLegionCost, + nativeElapsedMs: totalElapsedMs(native), + yourLegionElapsedMs: totalElapsedMs(orchestrated), + nativePassed: taskPassed(native), + yourLegionPassed: taskPassed(orchestrated), + economicOutcome: taskOutcome({ + netDeltaTokens: nativeCost === null || yourLegionCost === null ? null : yourLegionCost - nativeCost, + passedNative: taskPassed(native), + passedYourLegion: taskPassed(orchestrated), + }), + }; + }) + .sort((left, right) => left.taskID.localeCompare(right.taskID)); +} + +function pairedStatistics(metrics: BenchmarkSessionMetric[]): BenchmarkPairedStatistics[] { + const runs = aggregateRunMetrics(metrics); + const profiles = [ + ...new Set( + runs + .filter(run => run.variant === 'your-legion-orchestrated') + .map(run => run.providerProfile), + ), + ]; + const taskIDs = [...new Set(runs.map(run => run.taskID))]; + + return taskIDs + .flatMap(taskID => + profiles + .filter(profile => + runs.some( + run => run.taskID === taskID && run.variant === 'your-legion-orchestrated' && run.providerProfile === profile, + ), + ) + .map(profile => { + const native = runs + .filter(run => run.taskID === taskID && run.variant === 'native-builder') + .sort((left, right) => left.runID.localeCompare(right.runID)); + const orchestrated = runs + .filter( + run => + run.taskID === taskID && + run.variant === 'your-legion-orchestrated' && + run.providerProfile === profile, + ) + .sort((left, right) => left.runID.localeCompare(right.runID)); + const pairs = Math.min(native.length, orchestrated.length); + const deltas = Array.from({ length: pairs }, (_, index) => orchestrated[index]!.cost - native[index]!.cost); + const positiveDeltas = deltas.filter(delta => delta > 0).length; + const negativeDeltas = deltas.filter(delta => delta < 0).length; + + return { + taskID, + providerProfile: profile, + pairs, + positiveDeltas, + negativeDeltas, + zeroDeltas: deltas.length - positiveDeltas - negativeDeltas, + medianCostDelta: median(deltas), + signTestPValue: signTestPValue(positiveDeltas, negativeDeltas), + publishable: pairs >= 5, + }; + }), + ) + .sort( + (left, right) => + left.taskID.localeCompare(right.taskID) || + (left.providerProfile ?? '').localeCompare(right.providerProfile ?? ''), + ); +} + function roundRatio(value: number) { return Math.round(value * 10000) / 10000; } @@ -279,7 +509,14 @@ export function summarizeOrchestrationBenchmark(metrics: BenchmarkSessionMetric[ ), ].sort(); - const report: OrchestrationBenchmarkReport = { tasks, byVariantAndTaskType, byOutcome }; + const report: OrchestrationBenchmarkReport = { + tasks, + byVariantAndTaskType, + byOutcome, + repetitions: repetitionSummaries(metrics), + economicTasks: economicTasks(metrics), + pairedStatistics: pairedStatistics(metrics), + }; if (profiles.length > 0) { report.byProviderProfile = profiles.map(profile => { @@ -294,7 +531,7 @@ export function summarizeOrchestrationBenchmark(metrics: BenchmarkSessionMetric[ /** * Parse a committed metrics export (JSON array or JSONL, one metric per line) into * benchmark session metrics. Pure so the CLI (which reads the file) and tests (which - * read a fixture) share the same shape. See docs/adr/0004-orchestration-value-evidence.md (D3). + * read a fixture) share the same shape. See docs/adr/0006-statistical-evaluation-and-decoupled-verifier-evals.md (D1/D2). */ export function parseBenchmarkMetrics(contents: string): BenchmarkSessionMetric[] { const trimmed = contents.trim(); @@ -320,6 +557,9 @@ export function parseBenchmarkMetrics(contents: string): BenchmarkSessionMetric[ if (metric.variant !== 'native-builder' && metric.variant !== 'your-legion-orchestrated') { throw new Error(`benchmark metric ${index} has invalid variant: ${String(metric.variant)}`); } + if (typeof metric.passed !== 'boolean') { + throw new Error(`benchmark metric ${index} missing boolean field: passed`); + } if ( metric.providerProfile !== undefined && metric.providerProfile !== 'same-provider' && @@ -328,10 +568,17 @@ export function parseBenchmarkMetrics(contents: string): BenchmarkSessionMetric[ throw new Error(`benchmark metric ${index} has invalid providerProfile: ${String(metric.providerProfile)}`); } + if (typeof metric.cost !== 'number') { + throw new Error(`benchmark metric ${index} missing numeric field: cost`); + } + if (metric.elapsedMs !== undefined && metric.elapsedMs !== null && typeof metric.elapsedMs !== 'number') { + throw new Error(`benchmark metric ${index} has invalid numeric field: elapsedMs`); + } const num = (field: string) => (typeof metric[field] === 'number' ? (metric[field] as number) : 0); return { benchmarkID: typeof metric.benchmarkID === 'string' ? metric.benchmarkID : 'unknown', taskID: metric.taskID as string, + ...(typeof metric.runID === 'string' ? { runID: metric.runID } : {}), taskType: metric.taskType as string, variant: metric.variant, ...(metric.providerProfile ? { providerProfile: metric.providerProfile as BenchmarkProviderProfile } : {}), @@ -342,8 +589,9 @@ export function parseBenchmarkMetrics(contents: string): BenchmarkSessionMetric[ tokensReasoning: num('tokensReasoning'), tokensCacheRead: num('tokensCacheRead'), tokensCacheWrite: num('tokensCacheWrite'), - cost: num('cost'), - passed: metric.passed === true, + cost: metric.cost, + elapsedMs: metric.elapsedMs === null || metric.elapsedMs === undefined ? null : metric.elapsedMs, + passed: metric.passed as boolean, reworkTurns: num('reworkTurns'), traceWarnings: num('traceWarnings'), }; diff --git a/src/shared/agent-types.ts b/src/shared/agent-types.ts index fad5d05..971e80f 100644 --- a/src/shared/agent-types.ts +++ b/src/shared/agent-types.ts @@ -115,10 +115,16 @@ export type LoopConfig = { export type VerificationConfig = { /** When true (default), the orchestrator routes an independent verifier pass for unverified builder file changes. */ default_check?: boolean; + /** Envelope validation posture at task invocation time. */ + enforcement?: 'warn' | 'block'; + /** Commands the verifier may execute in addition to its fixed read-only git commands. */ + allowed_commands?: string[]; }; export type ResolvedVerificationConfig = { default_check: boolean; + enforcement: 'warn' | 'block'; + allowed_commands: string[]; }; export type LegionariesConfig = { diff --git a/tests/agent-config.test.ts b/tests/agent-config.test.ts index d6263cf..364c70e 100644 --- a/tests/agent-config.test.ts +++ b/tests/agent-config.test.ts @@ -67,6 +67,7 @@ test('orchestrator prompt requires a compact task context envelope for delegatio assert.match(orchestrator.prompt, /Constraints:/); assert.match(orchestrator.prompt, /Expected output:/); assert.match(orchestrator.prompt, /Verification:/); + assert.match(orchestrator.prompt, /Delegation ID:/); assert.match(orchestrator.prompt, /120-180 tokens/); }); @@ -410,9 +411,11 @@ test('builder reports a verification-status ledger and orchestrator routes unver const orchestrator = BASE_AGENT_DEFINITIONS.orchestrator; assert.match(builder.prompt, /Verification status/i); + assert.match(builder.prompt, /Delegation ID/i); assert.match(builder.prompt, /verified \| unverified \| verification-skipped/i); assert.match(orchestrator.prompt, /Post-Execution Verification/i); assert.match(orchestrator.prompt, /Files changed: yes.*Status: unverified/is); assert.match(orchestrator.prompt, /route a `verifier` pass before reporting completion/i); + assert.match(BASE_AGENT_DEFINITIONS.verifier.prompt, /Checked delegation ID/i); }); diff --git a/tests/delegation-benchmark.test.ts b/tests/delegation-benchmark.test.ts new file mode 100644 index 0000000..6c1e575 --- /dev/null +++ b/tests/delegation-benchmark.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); +const taskCatalogPath = path.join( + rootDir, + 'tests/fixtures/evaluation/delegation-required-tasks.json', +); +const taskInputsPath = path.join( + rootDir, + 'tests/fixtures/evaluation/delegation-required-inputs.json', +); + +type DelegationRequiredTask = { + id: string; + taskType: string; + expectedAgent: 'explorer' | 'builder'; + inputKey?: string; + prompt: string; + requiredSources: string[]; + acceptance: { + requiresChildSession: boolean; + requiredOutputSections: string[]; + requiredOutputMarkers: string[]; + }; +}; + +test('delegation-required benchmark catalog contains one task for each bundled domain', () => { + assert.equal(fs.existsSync(taskCatalogPath), true, 'delegation-required task catalog must exist'); + + const tasks = JSON.parse(fs.readFileSync(taskCatalogPath, 'utf8')) as Array<{ id: string }>; + assert.deepEqual( + tasks.map(task => task.id), + ['coding-001', 'marketing-001', 'finance-001', 'accounting-001'], + ); +}); + +test('delegation-required tasks depend on unread workspace evidence and reject direct answers', () => { + assert.equal(fs.existsSync(taskInputsPath), true, 'delegation-required input fixture must exist'); + + const tasks = JSON.parse(fs.readFileSync(taskCatalogPath, 'utf8')) as DelegationRequiredTask[]; + const inputs = JSON.parse(fs.readFileSync(taskInputsPath, 'utf8')) as Record; + const expectedAgents = new Map([ + ['coding-001', 'explorer'], + ['marketing-001', 'builder'], + ['finance-001', 'builder'], + ['accounting-001', 'builder'], + ]); + + for (const task of tasks) { + assert.equal(task.expectedAgent, expectedAgents.get(task.id), `${task.id} target agent`); + assert.equal(task.acceptance.requiresChildSession, true, `${task.id} must require a child session`); + assert.ok(task.requiredSources.length >= 2, `${task.id} must depend on at least two workspace sources`); + assert.ok(task.acceptance.requiredOutputSections.length >= 4, `${task.id} needs multi-stage output`); + assert.ok(task.acceptance.requiredOutputMarkers.length >= 4, `${task.id} needs deterministic markers`); + assert.match(task.prompt, /current workspace sources/i, `${task.id} source-of-truth instruction`); + assert.match(task.prompt, /return BLOCKED/i, `${task.id} unread-source behavior`); + assert.match(task.prompt, /do not modify files/i, `${task.id} read-only boundary`); + + for (const source of task.requiredSources) { + assert.match(task.prompt, new RegExp(source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.equal(fs.existsSync(path.join(rootDir, source)), true, `${task.id} source exists: ${source}`); + } + + if (task.inputKey) { + assert.ok(task.inputKey in inputs, `${task.id} input key exists`); + } + } + + assert.doesNotMatch(tasks[0]?.prompt ?? '', /\breview\b/i, 'coding prompt must not collide with review routing'); +}); + +test('builder tasks lead with the execution deliverable instead of source discovery', () => { + const tasks = JSON.parse(fs.readFileSync(taskCatalogPath, 'utf8')) as DelegationRequiredTask[]; + + for (const task of tasks.filter(candidate => candidate.expectedAgent === 'builder')) { + assert.match(task.prompt, /^(Create|Produce|Prepare)\b/, `${task.id} starts with its deliverable`); + assert.match( + task.prompt, + /execution deliverable, not a repo-local discovery request/i, + `${task.id} disambiguates builder from explorer`, + ); + } +}); + +test('numeric decision tasks require scenario identity and deterministic currency formatting', () => { + const tasks = JSON.parse(fs.readFileSync(taskCatalogPath, 'utf8')) as DelegationRequiredTask[]; + const finance = tasks.find(task => task.id === 'finance-001'); + const accounting = tasks.find(task => task.id === 'accounting-001'); + + assert.match(finance?.prompt ?? '', /scenario code in the heading/i); + assert.match(accounting?.prompt ?? '', /scenario code in the heading/i); + assert.match(accounting?.prompt ?? '', /format every USD amount with a dollar sign and two decimals/i); +}); + +test('benchmark documentation uses the delegation-required catalog and rejects zero-delegation runs', () => { + const benchmarkDoc = fs.readFileSync(path.join(rootDir, 'docs/ORCHESTRATOR_BENCHMARK.md'), 'utf8'); + + assert.match(benchmarkDoc, /tests\/fixtures\/evaluation\/delegation-required-tasks\.json/); + assert.match(benchmarkDoc, /tests\/fixtures\/evaluation\/delegation-required-inputs\.json/); + assert.match(benchmarkDoc, /no specialist child session[^\n]*FAIL/i); + assert.match(benchmarkDoc, /trace-check --worktree \. --require-evidence/); + assert.match(benchmarkDoc, /The default benchmark model is:\n\n```text\nopenai\/gpt-5\.4-mini/); + assert.doesNotMatch(benchmarkDoc, /Review the Task Context Envelope parser/); +}); diff --git a/tests/domain-usage-contract.test.ts b/tests/domain-usage-contract.test.ts index b97f217..23485b0 100644 --- a/tests/domain-usage-contract.test.ts +++ b/tests/domain-usage-contract.test.ts @@ -115,6 +115,7 @@ test('domain usage parser extracts loop run completion ledger fields', async () const { parseTaskContextEnvelope } = await import('../src/runtime/domain-usage-contract'); const envelope = parseTaskContextEnvelope(`Task Context Envelope: +Delegation ID: del_report_123 Loop: daily-ci-triage Loop run: run_2026_05_20 Loop status: maker-complete @@ -127,6 +128,7 @@ Domain skills: none Verification: inspect output`); assert.equal(envelope.loopID, 'daily-ci-triage'); + assert.equal(envelope.delegationID, 'del_report_123'); assert.equal(envelope.loopRunID, 'run_2026_05_20'); assert.equal(envelope.loopStatus, 'maker-complete'); assert.equal(envelope.completionClaim, 'Fixed the actionable CI failure and updated tests.'); @@ -134,6 +136,19 @@ Verification: inspect output`); assert.equal(envelope.verificationOutcome, 'passed'); }); +test('domain usage parser extracts an explicit delegation id', async () => { + const { parseTaskContextEnvelope } = await import('../src/runtime/domain-usage-contract'); + + const envelope = parseTaskContextEnvelope(`Task Context Envelope: +Delegation ID: del_explicit_123 +Active domains: none +Domain refs: none +Domain skills: none +Verification: inspect output`); + + assert.equal(envelope.delegationID, 'del_explicit_123'); +}); + test('domain usage validator accepts explicit mixed-domain responsibilities', async t => { const { parseTaskContextEnvelope, validateDomainUsageContract } = await import( '../src/runtime/domain-usage-contract' @@ -267,6 +282,137 @@ Verification: bun test`, assert.deepEqual(events[1].domainSkills, ['coding/make-code-change']); }); +test('domain trace hooks inject delegation ids and attribute file changes explicitly', async t => { + const { createDomainUsageTraceHooks, readDomainUsageTraceEvents } = await import( + '../src/runtime/domain-usage-contract' + ); + const configDir = makeTempDir(t, 'file-change-hooks'); + const worktree = path.join(configDir, 'project'); + const hooks = createDomainUsageTraceHooks({ + configDir, + worktree, + domainPacks: [], + }); + const taskOutput = { + args: { + subagent_type: 'builder', + prompt: `Task Context Envelope: +Active domains: none +Domain refs: none +Domain skills: none +Verification: bun test`, + }, + }; + + await hooks['tool.execute.before']({ tool: 'task', sessionID: 'ses_parent' }, taskOutput); + assert.match(taskOutput.args.prompt, /Delegation ID: del_[a-f0-9]{16}/); + + const delegationID = readDomainUsageTraceEvents({ configDir, worktree })[0]?.delegationID; + assert.ok(delegationID); + + await hooks['tool.execute.after']( + { tool: 'edit', sessionID: 'ses_child', delegationID }, + { args: { filePath: 'src/app.ts' } }, + ); + + const events = readDomainUsageTraceEvents({ configDir, worktree }); + assert.equal(events[1]?.event, 'file-change'); + assert.equal(events[1]?.delegationID, delegationID); +}); + +test('domain trace hooks keep parallel delegation evidence linked by explicit ids', async t => { + const { createDomainUsageTraceHooks, readDomainUsageTraceEvents } = await import( + '../src/runtime/domain-usage-contract' + ); + const configDir = makeTempDir(t, 'parallel-delegation-hooks'); + const worktree = path.join(configDir, 'project'); + const hooks = createDomainUsageTraceHooks({ configDir, worktree, domainPacks: [] }); + const prompt = `Task Context Envelope: +Active domains: none +Domain refs: none +Domain skills: none +Verification: inspect output`; + const first = { args: { subagent_type: 'builder', prompt } }; + const second = { args: { subagent_type: 'builder', prompt } }; + + await hooks['tool.execute.before']({ tool: 'task', sessionID: 'ses_parallel' }, first); + await hooks['tool.execute.before']({ tool: 'task', sessionID: 'ses_parallel' }, second); + const delegations = readDomainUsageTraceEvents({ configDir, worktree }); + const firstDelegationID = delegations[0]?.delegationID; + const secondDelegationID = delegations[1]?.delegationID; + assert.ok(firstDelegationID); + assert.ok(secondDelegationID); + assert.notEqual(firstDelegationID, secondDelegationID); + + await hooks['tool.execute.after']( + { tool: 'task', sessionID: 'ses_child_one' }, + { + result: `Verification status: +Delegation ID: ${firstDelegationID} +- Files changed: yes +- Verifier pass: not-run +- Status: unverified +- Reason: pending checker`, + }, + ); + await hooks['tool.execute.after']( + { tool: 'read', sessionID: 'ses_child_one', delegationID: firstDelegationID }, + { args: { filePath: path.join(worktree, 'not-a-domain-file.md') } }, + ); + + const events = readDomainUsageTraceEvents({ configDir, worktree }); + assert.equal(events[2]?.delegationID, firstDelegationID); + assert.equal(events[2]?.event, 'verification-report'); + assert.equal(events[2]?.verificationStatus, 'unverified'); +}); + +test('domain trace hooks record file-mutating bash commands', async t => { + const { createDomainUsageTraceHooks, readDomainUsageTraceEvents } = await import( + '../src/runtime/domain-usage-contract' + ); + const configDir = makeTempDir(t, 'bash-file-change-hooks'); + const worktree = path.join(configDir, 'project'); + const hooks = createDomainUsageTraceHooks({ configDir, worktree, domainPacks: [] }); + + await hooks['tool.execute.after']( + { tool: 'bash', sessionID: 'ses_bash', delegationID: 'del_bash' }, + { args: { command: 'printf changed >> src/app.ts' } }, + ); + + const events = readDomainUsageTraceEvents({ configDir, worktree }); + assert.equal(events[0]?.event, 'file-change'); + assert.equal(events[0]?.delegationID, 'del_bash'); + assert.equal(events[0]?.toolName, 'bash'); +}); + +test('domain trace hooks reject malformed envelopes in block mode', async t => { + const { createDomainUsageTraceHooks } = await import('../src/runtime/domain-usage-contract'); + const configDir = makeTempDir(t, 'domain-contract-block'); + const hooks = createDomainUsageTraceHooks({ + configDir, + worktree: path.join(configDir, 'project'), + domainPacks: [], + enforcement: 'block', + }); + + await assert.rejects( + hooks['tool.execute.before']( + { tool: 'task', sessionID: 'ses_block' }, + { + args: { + subagent_type: 'builder', + prompt: `Task Context Envelope: +Active domains: coding +Domain refs: none +Domain skills: none +Verification: bun test`, + }, + }, + ), + /Task Context Envelope rejected.*responsibility/i, + ); +}); + test('domain trace hooks record loop run reports returned by task agents', async t => { const { createDomainUsageTraceHooks, readDomainUsageTraceEvents } = await import( '../src/runtime/domain-usage-contract' @@ -1020,6 +1166,7 @@ test('unverified file-change ledgers fail unless a verifier delegation follows', }; const report = { ...base, + delegationID: 'del_ledger', timestamp: '2026-07-04T00:00:00.000Z', event: 'verification-report' as const, filesChanged: 'yes' as const, @@ -1030,10 +1177,16 @@ test('unverified file-change ledgers fail unless a verifier delegation follows', timestamp: '2026-07-04T00:00:01.000Z', event: 'delegation' as const, targetAgent: 'verifier', + checkedDelegationID: 'del_ledger', + }; + const unrelatedVerifierDelegation = { + ...verifierDelegation, + checkedDelegationID: 'del_other', }; assert.match(findUnverifiedFileChangeLedgers([report]).join('\n'), /unverified-file-changes/); assert.deepEqual(findUnverifiedFileChangeLedgers([report, verifierDelegation]), []); + assert.match(findUnverifiedFileChangeLedgers([report, unrelatedVerifierDelegation]).join('\n'), /unverified-file-changes/); // A verifier delegation that happened before the maker report does not vouch for it. assert.match(findUnverifiedFileChangeLedgers([verifierDelegation, report]).join('\n'), /unverified-file-changes/); @@ -1041,6 +1194,99 @@ test('unverified file-change ledgers fail unless a verifier delegation follows', assert.deepEqual(findUnverifiedFileChangeLedgers([skipped]), []); }); +test('verified file changes without a checker produce an advisory warning', async () => { + const { findVerificationWarnings } = await import('../src/runtime/domain-usage-contract'); + const report = { + version: 1, + worktree: '/project', + sessionID: 'ses_verified_without_checker', + delegationID: 'del_verified_without_checker', + timestamp: '2026-07-04T00:00:00.000Z', + event: 'verification-report' as const, + filesChanged: 'yes' as const, + verifierPass: 'not-run' as const, + verificationStatus: 'verified' as const, + activeDomains: [], + domainRefs: [], + domainSkills: [], + warnings: [], + }; + + assert.match(findVerificationWarnings([report]).join('\n'), /verified.*no verifier pass/i); +}); + +test('verified file changes in a loop fail without a checker', async () => { + const { findUnverifiedFileChangeLedgers, findVerificationWarnings } = await import( + '../src/runtime/domain-usage-contract' + ); + const report = { + version: 1, + worktree: '/project', + sessionID: 'ses_loop_verified_without_checker', + delegationID: 'del_loop_verified_without_checker', + loopID: 'release-check', + timestamp: '2026-07-04T00:00:00.000Z', + event: 'verification-report' as const, + filesChanged: 'yes' as const, + verifierPass: 'not-run' as const, + verificationStatus: 'verified' as const, + activeDomains: [], + domainRefs: [], + domainSkills: [], + warnings: [], + }; + + assert.match(findUnverifiedFileChangeLedgers([report]).join('\n'), /verified-without-verifier/i); + assert.deepEqual(findVerificationWarnings([report]), []); +}); + +test('observed file changes without a verification report fail as silent-maker', async () => { + const { findUnverifiedFileChangeLedgers } = await import('../src/runtime/domain-usage-contract'); + const fileChange = { + version: 1, + worktree: '/project', + sessionID: 'ses_silent_maker', + delegationID: 'del_silent_maker', + timestamp: '2026-07-04T00:00:00.000Z', + event: 'file-change' as const, + filesChanged: 'yes' as const, + activeDomains: [], + domainRefs: [], + domainSkills: [], + warnings: [], + }; + + assert.match(findUnverifiedFileChangeLedgers([fileChange]).join('\n'), /silent-maker/); +}); + +test('observed file changes without an explicit delegation id cannot be closed by an unlinked report', async () => { + const { findUnverifiedFileChangeLedgers } = await import('../src/runtime/domain-usage-contract'); + const base = { + version: 1, + worktree: '/project', + sessionID: 'ses_unlinked_change', + activeDomains: [], + domainRefs: [], + domainSkills: [], + warnings: [], + }; + const fileChange = { + ...base, + timestamp: '2026-07-04T00:00:00.000Z', + event: 'file-change' as const, + filesChanged: 'yes' as const, + }; + const report = { + ...base, + timestamp: '2026-07-04T00:00:01.000Z', + event: 'verification-report' as const, + filesChanged: 'yes' as const, + verificationStatus: 'verified' as const, + }; + + assert.match(findUnverifiedFileChangeLedgers([fileChange, report]).join('\n'), /silent-maker/); +}); + test('trace-check fails when an unverified file-change ledger has no verifier pass', async t => { const { appendDomainUsageTraceEvent } = await import('../src/runtime/domain-usage-contract'); const configDir = makeTempDir(t, 'trace-check-unverified-ledger-config'); @@ -1072,3 +1318,36 @@ test('trace-check fails when an unverified file-change ledger has no verifier pa assert.notEqual(check.status, 0); assert.match(check.stdout + check.stderr, /unverified-file-changes/); }); + +test('trace-check reports verified-without-verifier as a warning without failing', async t => { + const { appendDomainUsageTraceEvent } = await import('../src/runtime/domain-usage-contract'); + const configDir = makeTempDir(t, 'trace-check-verified-warning-config'); + const worktree = makeTempDir(t, 'trace-check-verified-warning-worktree'); + + appendDomainUsageTraceEvent({ + configDir, + worktree, + event: { + version: 1, + timestamp: '2026-07-04T00:00:00.000Z', + worktree, + delegationID: 'del_verified_warning', + event: 'verification-report', + filesChanged: 'yes', + verifierPass: 'not-run', + verificationStatus: 'verified', + activeDomains: [], + domainRefs: [], + domainSkills: [], + warnings: [], + }, + }); + + const check = spawnSync('bun', ['src/cli.ts', 'trace-check', '--worktree', worktree, '--config-dir', configDir], { + cwd: rootDir, + encoding: 'utf8', + }); + + assert.equal(check.status, 0); + assert.match(check.stdout + check.stderr, /verified-without-verifier/); +}); diff --git a/tests/evaluation-contracts.test.ts b/tests/evaluation-contracts.test.ts new file mode 100644 index 0000000..9e348c8 --- /dev/null +++ b/tests/evaluation-contracts.test.ts @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +import { + evaluateCheckerRecallFixtures, + evaluateRoutingContractFixtures, + parseCheckerRecallFixtures, + parseRoutingContractFixtures, + type CheckerRecallFixture, + type RoutingContractFixture, +} from '../src/runtime/evaluation-contracts'; + +const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); + +const validEnvelope = `Task Context Envelope: +Delegation ID: del_test +Loop: none +Active domains: none +Domain refs: none +Domain skills: none +Objective: summarize the task +Expected output: a concise answer +Verification: none`; + +test('routing contract evaluator combines envelope validation with expected agent labels', async () => { + const { resolveDomainPacks } = await import('../src/runtime/domain-packs'); + const domainPacks = resolveDomainPacks({ domains: { coding: true } }); + const fixtures: RoutingContractFixture[] = [ + { + id: 'valid-builder', + prompt: 'implement a code change', + actualAgent: 'builder', + expectedAgent: 'builder', + expectedLoopID: null, + expectedActiveDomainIDs: [], + expectedDomainRefs: [], + expectedDomainSkills: [], + output: validEnvelope, + }, + { + id: 'wrong-agent', + prompt: 'inspect a local file', + actualAgent: 'builder', + expectedAgent: 'explorer', + expectedLoopID: null, + expectedActiveDomainIDs: [], + expectedDomainRefs: [], + expectedDomainSkills: [], + output: validEnvelope, + }, + { + id: 'malformed-envelope', + prompt: 'route this task', + actualAgent: 'builder', + expectedAgent: 'builder', + expectedLoopID: null, + expectedActiveDomainIDs: [], + expectedDomainRefs: [], + expectedDomainSkills: [], + output: 'Task Context Envelope:\nDomain refs: none', + }, + ]; + + const result = evaluateRoutingContractFixtures(fixtures, domainPacks); + + assert.equal(result.total, 3); + assert.equal(result.passed, 1); + assert.equal(result.passRate, 0.3333); + assert.deepEqual( + result.failures.map(failure => failure.id), + ['wrong-agent', 'malformed-envelope'], + ); + assert.match(result.failures[0]?.messages.join('\n') ?? '', /expected agent/i); + assert.match(result.failures[1]?.messages.join('\n') ?? '', /active domains/i); +}); + +test('routing contract fixture parser rejects missing labels', () => { + assert.throws( + () => parseRoutingContractFixtures('[{"id":"x","prompt":"p","actualAgent":"builder"}]'), + /expectedAgent/i, + ); +}); + +test('checker evaluator calculates recall and precision for planted defects', () => { + const fixtures: CheckerRecallFixture[] = [ + { id: 'defect-caught', makerOutput: 'missing upper bound', defectPresent: true, verifierDecision: 'reject' }, + { id: 'defect-missed', makerOutput: 'green transcript', defectPresent: true, verifierDecision: 'accept' }, + { id: 'clean-accepted', makerOutput: 'correct change', defectPresent: false, verifierDecision: 'accept' }, + { id: 'clean-rejected', makerOutput: 'valid but unusual change', defectPresent: false, verifierDecision: 'reject' }, + ]; + + assert.deepEqual(evaluateCheckerRecallFixtures(fixtures), { + total: 4, + truePositives: 1, + falseNegatives: 1, + trueNegatives: 1, + falsePositives: 1, + recall: 0.5, + precision: 0.5, + passRate: 0.5, + }); +}); + +test('checker fixture parser requires a deterministic verifier decision', () => { + assert.throws( + () => parseCheckerRecallFixtures('[{"id":"x","makerOutput":"diff","defectPresent":true}]'), + /verifierDecision/i, + ); +}); + +test('committed routing and checker fixtures are evaluable', async () => { + const { resolveDomainPacks } = await import('../src/runtime/domain-packs'); + const routing = parseRoutingContractFixtures( + fs.readFileSync(path.join(rootDir, 'tests/fixtures/evaluation/routing-contract.jsonl'), 'utf8'), + ); + const checker = parseCheckerRecallFixtures( + fs.readFileSync(path.join(rootDir, 'tests/fixtures/evaluation/checker-recall.jsonl'), 'utf8'), + ); + + const routingResult = evaluateRoutingContractFixtures(routing, resolveDomainPacks({ domains: { coding: true } })); + const checkerResult = evaluateCheckerRecallFixtures(checker); + + assert.equal(routingResult.passRate, 1); + assert.equal(checkerResult.recall, 1); + assert.equal(checkerResult.precision, 1); +}); diff --git a/tests/fixtures/evaluation/checker-recall.jsonl b/tests/fixtures/evaluation/checker-recall.jsonl new file mode 100644 index 0000000..1f25215 --- /dev/null +++ b/tests/fixtures/evaluation/checker-recall.jsonl @@ -0,0 +1,2 @@ +{"id":"surviving-upper-bound-mutant","makerOutput":"The implementation passes the existing lower-bound tests, but the upper-bound behavior is not covered.","defectPresent":true,"verifierDecision":"reject"} +{"id":"clean-options-refactor","makerOutput":"Both callers preserve their documented newline behavior and the focused tests pass.","defectPresent":false,"verifierDecision":"accept"} diff --git a/tests/fixtures/evaluation/delegation-required-inputs.json b/tests/fixtures/evaluation/delegation-required-inputs.json new file mode 100644 index 0000000..34edfd1 --- /dev/null +++ b/tests/fixtures/evaluation/delegation-required-inputs.json @@ -0,0 +1,39 @@ +{ + "marketing-001": { + "scenarioCode": "MKT-CATALOG-9", + "feature": "Domain Catalog", + "audience": "platform engineering leads", + "observableBehaviors": [ + "resolves enabled DOMAIN.md declarations at startup", + "adds a shared Domain Catalog section to managed agent prompts", + "does not create one runtime agent per domain" + ], + "forbiddenClaims": [ + "token savings", + "automatic correctness", + "professional financial, legal, or accounting outcomes" + ], + "callToAction": "Inspect the generated catalog before enabling a domain pack" + }, + "finance-001": { + "scenarioCode": "FIN-27A", + "licensedSeats": 27, + "monthlyPricePerSeatUsd": 43, + "hoursSavedPerActiveSeat": 1.6, + "loadedHourlyCostUsd": 92, + "baseAdoptionRate": 0.72, + "downsideAdoptionRate": 0.48 + }, + "accounting-001": { + "scenarioCode": "ACCT-JUN-42", + "reportingDate": "2026-06-30", + "currency": "USD", + "creditsEqualCurrencyValue": true, + "prepaidCreditsPurchased": 2400, + "prepaidCreditsConsumed": 900, + "unbilledJuneUsage": 275, + "researchUsagePercent": 65, + "generalAdminUsagePercent": 35, + "reportingFramework": "unspecified" + } +} diff --git a/tests/fixtures/evaluation/delegation-required-tasks.json b/tests/fixtures/evaluation/delegation-required-tasks.json new file mode 100644 index 0000000..5c1d2ab --- /dev/null +++ b/tests/fixtures/evaluation/delegation-required-tasks.json @@ -0,0 +1,72 @@ +[ + { + "id": "coding-001", + "taskType": "coding", + "expectedAgent": "explorer", + "prompt": "Use the current workspace sources as the only authority. Read `src/runtime/domain-usage-contract.ts`, `tests/domain-usage-contract.test.ts`, and `src/domains/coding/decisions/engineering-guardrails.md`. Trace the value `Domain skills: coding/make-code-change, finance/financial-analysis` from raw field collection through token cleanup to the returned Task Context Envelope. Produce: (1) an Evidence table with exact file:line citations for every function in the call path, (2) the exact resulting array, (3) a Test coverage matrix separating direct parser coverage from indirect shared-helper coverage, and (4) one minimal missing regression case that would fail if comma splitting or whitespace trimming broke. Treat this as repo-local discovery, not command-owned auditing. If any required source cannot be read, return BLOCKED and name it; do not substitute memory or general knowledge. Do not modify files.", + "requiredSources": [ + "src/runtime/domain-usage-contract.ts", + "tests/domain-usage-contract.test.ts", + "src/domains/coding/decisions/engineering-guardrails.md" + ], + "acceptance": { + "requiresChildSession": true, + "requiredOutputSections": ["Evidence", "Resulting array", "Test coverage", "Missing regression case"], + "requiredOutputMarkers": ["cleanToken", "splitTokens", "parseTaskContextEnvelope", "coding/make-code-change", "finance/financial-analysis"] + } + }, + { + "id": "marketing-001", + "taskType": "marketing", + "expectedAgent": "builder", + "inputKey": "marketing-001", + "prompt": "Create a final launch package for Domain Catalog. This is an execution deliverable, not a repo-local discovery request. Ground it in the current workspace sources as the only authority: read key `marketing-001` from `tests/fixtures/evaluation/delegation-required-inputs.json`, plus `src/domains/marketing/decisions/brand-voice.md`, `src/domains/marketing/examples/launch-copy.md`, and `src/domains/marketing/skills/campaign-brief/SKILL.md`; do not infer their contents from filenames. Produce: (1) an Evidence table mapping each source to an extracted constraint, (2) Target audience and Core message, (3) exactly two sentences of final launch copy incorporating the fixture audience, all three observable behaviors, and its call to action, and (4) a Claim audit mapping each sentence to observable behavior and listing every forbidden claim from the fixture. If any required source cannot be read, return BLOCKED and name it; do not substitute memory or general knowledge. Do not modify files.", + "requiredSources": [ + "tests/fixtures/evaluation/delegation-required-inputs.json", + "src/domains/marketing/decisions/brand-voice.md", + "src/domains/marketing/examples/launch-copy.md", + "src/domains/marketing/skills/campaign-brief/SKILL.md" + ], + "acceptance": { + "requiresChildSession": true, + "requiredOutputSections": ["Evidence", "Target audience", "Core message", "Final copy", "Claim audit"], + "requiredOutputMarkers": ["platform engineering leads", "resolves enabled DOMAIN.md declarations at startup", "adds a shared Domain Catalog section to managed agent prompts", "does not create one runtime agent per domain", "Inspect the generated catalog before enabling a domain pack"] + } + }, + { + "id": "finance-001", + "taskType": "finance", + "expectedAgent": "builder", + "inputKey": "finance-001", + "prompt": "Produce a go/no-go financial decision memo for the configured scenario. This is an execution deliverable, not a repo-local discovery request. Ground it in the current workspace sources as the only authority: read key `finance-001` from `tests/fixtures/evaluation/delegation-required-inputs.json`, plus `src/domains/finance/decisions/financial-guardrails.md`, `src/domains/finance/examples/financial-summary.md`, and `src/domains/finance/skills/financial-analysis/SKILL.md`; do not infer their contents from filenames. Put the fixture's scenario code in the heading. Include: (1) an Evidence table with source paths and extracted facts, (2) Known inputs separated from Assumptions, (3) a Base case showing monthly license cost, realized gross value, net value, break-even adoption rate, and break-even hours per active seat, (4) a Downside case using the fixture's downside adoption rate, and (5) Risks or missing data tied to the financial guardrails. Show formulas and round currency to cents and rates to two decimals. If any required source cannot be read, return BLOCKED and name it; do not substitute memory or general knowledge. Do not modify files.", + "requiredSources": [ + "tests/fixtures/evaluation/delegation-required-inputs.json", + "src/domains/finance/decisions/financial-guardrails.md", + "src/domains/finance/examples/financial-summary.md", + "src/domains/finance/skills/financial-analysis/SKILL.md" + ], + "acceptance": { + "requiresChildSession": true, + "requiredOutputSections": ["Evidence", "Known inputs", "Assumptions", "Base case", "Downside case", "Risks or missing data"], + "requiredOutputMarkers": ["FIN-27A", "$1,161.00", "$2,861.57", "$1,700.57", "29.21%", "$746.71"] + } + }, + { + "id": "accounting-001", + "taskType": "accounting", + "expectedAgent": "builder", + "inputKey": "accounting-001", + "prompt": "Prepare an internal period-end accounting analysis for the configured scenario. This is an execution deliverable, not a repo-local discovery request. Ground it in the current workspace sources as the only authority: read key `accounting-001` from `tests/fixtures/evaluation/delegation-required-inputs.json`, plus `src/domains/accounting/decisions/accounting-guardrails.md`, `src/domains/accounting/examples/accounting-treatment.md`, and `src/domains/accounting/skills/apply-accounting-review/SKILL.md`; do not infer their contents from filenames. Put the fixture's scenario code in the heading. Include: (1) an Evidence table with source paths and extracted facts, (2) Relevant facts separated from Assumptions, including the unresolved reporting framework, (3) a period-end schedule for remaining prepaid credits, unbilled accrual, total period expense, and functional allocation using the provided percentages, (4) journal-entry direction plus recognition timing, classification, cutoff, and disclosure considerations, and (5) Decision risks and missing details. Show calculations and format every USD amount with a dollar sign and two decimals. State that this is not professional or tax advice, and do not choose a framework that the fixture leaves unspecified. If any required source cannot be read, return BLOCKED and name it; do not substitute memory or general knowledge. Do not modify files.", + "requiredSources": [ + "tests/fixtures/evaluation/delegation-required-inputs.json", + "src/domains/accounting/decisions/accounting-guardrails.md", + "src/domains/accounting/examples/accounting-treatment.md", + "src/domains/accounting/skills/apply-accounting-review/SKILL.md" + ], + "acceptance": { + "requiresChildSession": true, + "requiredOutputSections": ["Evidence", "Relevant facts", "Assumptions", "Period-end schedule", "Journal-entry direction", "Decision risks"], + "requiredOutputMarkers": ["ACCT-JUN-42", "$1,500.00", "$275.00", "$1,175.00", "$763.75", "$411.25", "unspecified"] + } + } +] diff --git a/tests/fixtures/evaluation/routing-contract.jsonl b/tests/fixtures/evaluation/routing-contract.jsonl new file mode 100644 index 0000000..3313304 --- /dev/null +++ b/tests/fixtures/evaluation/routing-contract.jsonl @@ -0,0 +1,2 @@ +{"id":"neutral-summary","prompt":"Summarize a repo-neutral project note","actualAgent":"builder","expectedAgent":"builder","expectedLoopID":null,"expectedActiveDomainIDs":[],"expectedDomainRefs":[],"expectedDomainSkills":[],"output":"Task Context Envelope:\nDelegation ID: del_fixture_neutral\nLoop: none\nActive domains: none\nDomain refs: none\nDomain skills: none\nObjective: summarize the project note\nExpected output: concise summary\nVerification: none"} +{"id":"coding-change","prompt":"Implement and verify a code change","actualAgent":"builder","expectedAgent":"builder","expectedLoopID":null,"expectedActiveDomainIDs":["coding"],"expectedDomainRefs":["coding/implementation-loop"],"expectedDomainSkills":["coding/make-code-change"],"output":"Task Context Envelope:\nDelegation ID: del_fixture_coding\nLoop: none\nActive domains:\n- coding: implement and verify the code change\nDomain refs: coding/implementation-loop\nDomain skills: coding/make-code-change\nObjective: implement and verify the code change\nExpected output: changed files and verification\nVerification: bun test"} diff --git a/tests/legionaries.test.ts b/tests/legionaries.test.ts index 1f5c7cb..31a0bbb 100644 --- a/tests/legionaries.test.ts +++ b/tests/legionaries.test.ts @@ -180,6 +180,33 @@ test('legionaries loader defaults verification.default_check to true and validat assert.throws(() => loadLegionariesConfig({ rootDir, configPath: badPath }), /default_check must be a boolean/); }); +test('legionaries loader resolves the runtime enforcement policy and checker commands', async () => { + fs.mkdirSync(tempDir, { recursive: true }); + const tempConfigPath = path.join(tempDir, 'legionaries.verification-policy.yaml'); + const original = YAML.parse(fs.readFileSync(legionariesConfigPath, 'utf8')); + const systemAgents = systemAgentsFrom(original); + fs.writeFileSync( + tempConfigPath, + YAML.stringify({ + system_agents: systemAgents, + verification: { + default_check: true, + enforcement: 'block', + allowed_commands: ['bun test', 'bun run build'], + }, + }), + ); + + const { loadLegionariesConfig } = await import('../src/config/legionaries'); + const result = loadLegionariesConfig({ rootDir, configPath: tempConfigPath }); + + assert.deepEqual(result.verification, { + default_check: true, + enforcement: 'block', + allowed_commands: ['bun test', 'bun run build'], + }); +}); + test('legionaries loader accepts custom_agents with the same entry shape', async () => { fs.mkdirSync(tempDir, { recursive: true }); const tempConfigPath = path.join(tempDir, 'legionaries.custom-agents.yaml'); diff --git a/tests/mutation-fixtures.test.ts b/tests/mutation-fixtures.test.ts new file mode 100644 index 0000000..b992613 --- /dev/null +++ b/tests/mutation-fixtures.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); + +test('mutation fixture generator keeps mutants whose acceptance command stays green', () => { + const projectDir = mkdtempSync(path.join(os.tmpdir(), 'your-legion-mutation-project-')); + const manifestPath = path.join(projectDir, 'mutations.json'); + const sourcePath = path.join(projectDir, 'calculator.ts'); + + writeFileSync(sourcePath, 'export function total(value: number) { return value + 1; }\n'); + writeFileSync( + manifestPath, + JSON.stringify([ + { + id: 'survives-upper-bound-mutation', + file: 'calculator.ts', + search: 'value + 1', + replace: 'value + 2', + }, + ]), + ); + + const result = spawnSync( + 'bun', + [ + 'scripts/generate-mutation-fixtures.ts', + '--project', + projectDir, + '--mutations', + manifestPath, + '--test-command', + 'bun -e "process.exit(0)"', + ], + { cwd: rootDir, encoding: 'utf8' }, + ); + + assert.equal(result.status, 0, result.stderr); + const fixture = JSON.parse(result.stdout.trim()); + assert.equal(fixture.id, 'survives-upper-bound-mutation'); + assert.equal(fixture.defectPresent, true); + assert.equal(fixture.survived, true); + assert.equal(readFileSync(sourcePath, 'utf8'), 'export function total(value: number) { return value + 1; }\n'); +}); diff --git a/tests/orchestration-benchmark.test.ts b/tests/orchestration-benchmark.test.ts index e44902c..ca92c48 100644 --- a/tests/orchestration-benchmark.test.ts +++ b/tests/orchestration-benchmark.test.ts @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; +import { Database } from 'bun:sqlite'; import { parseBenchmarkMetrics, @@ -314,15 +316,213 @@ test('summarizes native against same-provider and mixed-provider orchestration s test('parseBenchmarkMetrics accepts JSON array and rejects invalid variants', () => { const rows = parseBenchmarkMetrics( - '[{"taskID":"t","taskType":"coding","variant":"native-builder","agent":"builder","tokensInput":10,"passed":true}]', + '[{"taskID":"t","taskType":"coding","variant":"native-builder","agent":"builder","tokensInput":10,"cost":0,"passed":true}]', ); assert.equal(rows.length, 1); assert.equal(rows[0].variant, 'native-builder'); assert.equal(rows[0].tokensInput, 10); + assert.equal(rows[0].elapsedMs, null); assert.throws(() => parseBenchmarkMetrics('[{"taskID":"t","taskType":"coding","variant":"bogus","agent":"x"}]')); }); +test('parseBenchmarkMetrics rejects missing cost instead of treating it as zero', () => { + assert.throws( + () => + parseBenchmarkMetrics( + '[{"taskID":"t","taskType":"coding","variant":"native-builder","agent":"builder","passed":true}]', + ), + /missing numeric field: cost/i, + ); +}); + +test('parseBenchmarkMetrics rejects rows without an explicit acceptance result', () => { + assert.throws( + () => + parseBenchmarkMetrics( + '[{"taskID":"t","taskType":"coding","variant":"native-builder","agent":"builder","cost":1}]', + ), + /missing boolean field: passed/i, + ); +}); + +test('benchmark summaries report repetition statistics and cost-time economics', () => { + const metrics: BenchmarkSessionMetric[] = []; + for (let index = 1; index <= 5; index += 1) { + metrics.push( + metric({ + taskID: 'coding-001', + runID: `run-${index}`, + variant: 'native-builder', + agent: 'builder', + cost: index, + elapsedMs: index * 100, + }), + metric({ + taskID: 'coding-001', + runID: `run-${index}`, + variant: 'your-legion-orchestrated', + agent: 'orchestrator', + cost: index * 2, + elapsedMs: index * 200, + }), + ); + } + + const report = summarizeOrchestrationBenchmark(metrics); + const repetitions = report.repetitions.filter(entry => entry.taskID === 'coding-001'); + const native = repetitions.find(entry => entry.variant === 'native-builder'); + const orchestrated = repetitions.find(entry => entry.variant === 'your-legion-orchestrated'); + + assert.equal(native?.runs, 5); + assert.equal(native?.medianCost, 3); + assert.equal(native?.costSpread, 4); + assert.equal(native?.medianElapsedMs, 300); + assert.equal(orchestrated?.medianCost, 6); + assert.equal(report.economicTasks[0]?.nativeCost, 15); + assert.equal(report.economicTasks[0]?.yourLegionCost, 30); + assert.equal(report.economicTasks[0]?.nativeElapsedMs, 1500); + assert.equal(report.economicTasks[0]?.yourLegionElapsedMs, 3000); +}); + +test('benchmark summaries report paired cost deltas and an exact sign-test result', () => { + const metrics: BenchmarkSessionMetric[] = []; + for (let index = 1; index <= 5; index += 1) { + metrics.push( + metric({ + taskID: 'coding-001', + runID: `run-${index}`, + variant: 'native-builder', + agent: 'builder', + cost: index, + }), + metric({ + taskID: 'coding-001', + runID: `run-${index}`, + variant: 'your-legion-orchestrated', + agent: 'orchestrator', + cost: index + 1, + }), + ); + } + + const report = summarizeOrchestrationBenchmark(metrics); + + assert.deepEqual(report.pairedStatistics, [ + { + taskID: 'coding-001', + providerProfile: undefined, + pairs: 5, + positiveDeltas: 5, + negativeDeltas: 0, + zeroDeltas: 0, + medianCostDelta: 1, + signTestPValue: 0.0625, + publishable: true, + }, + ]); +}); + +test('benchmark economic outcomes use cost even when token direction differs', () => { + const report = summarizeOrchestrationBenchmark([ + metric({ + taskID: 'coding-002', + variant: 'native-builder', + agent: 'builder', + tokensInput: 100, + cost: 1, + }), + metric({ + taskID: 'coding-002', + variant: 'your-legion-orchestrated', + agent: 'orchestrator', + tokensInput: 50, + cost: 2, + }), + ]); + + assert.equal(report.tasks[0]?.outcome, 'cheaper-same-quality'); + assert.equal(report.economicTasks[0]?.economicOutcome, 'more-expensive-not-better'); +}); + +test('benchmark extractor requires a machine-readable acceptance map', () => { + const result = spawnSync( + 'bun', + ['scripts/extract-benchmark-metrics.ts', '--marker', 'benchmark-test', '--db', '/does/not/exist'], + { cwd: rootDir, encoding: 'utf8' }, + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr + result.stdout, /--acceptance/i); +}); + +test('benchmark extractor rejects an orchestrated root with no delegated child session', t => { + const tempDir = fs.mkdtempSync(path.join(process.env.TMPDIR ?? '/tmp', 'yl-benchmark-delegation-')); + t.after(() => fs.rmSync(tempDir, { recursive: true, force: true })); + const dbPath = path.join(tempDir, 'opencode.db'); + const acceptancePath = path.join(tempDir, 'acceptance.json'); + const marker = 'delegation-required-test'; + const db = new Database(dbPath); + db.run(` + create table session ( + id text primary key, + parent_id text, + agent text, + title text, + tokens_input integer, + tokens_output integer, + tokens_reasoning integer, + tokens_cache_read integer, + tokens_cache_write integer, + cost real + ) + `); + const insert = db.query(` + insert into session ( + id, parent_id, agent, title, + tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, cost + ) values (?, ?, ?, ?, 1, 1, 0, 0, 0, 0) + `); + insert.run('native', null, 'build', `${marker} coding-001 native-builder`); + insert.run('root', null, 'orchestrator', `${marker} coding-001 your-legion-orchestrated`); + db.close(); + fs.writeFileSync(acceptancePath, JSON.stringify({ 'coding-001': true })); + + const result = spawnSync( + 'bun', + [ + 'scripts/extract-benchmark-metrics.ts', + '--marker', + marker, + '--db', + dbPath, + '--acceptance', + acceptancePath, + '--profile', + 'same-provider', + ], + { cwd: rootDir, encoding: 'utf8' }, + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr + result.stdout, /no delegated child session/i); +}); + +test('benchmark-summarize prints cost and time before secondary token detail', () => { + const fixture = path.join(rootDir, 'tests', 'fixtures', 'orchestration-benchmark', '2026-05-23-deepseek-v4-pro.jsonl'); + const result = spawnSync('bun', ['src/cli.ts', 'benchmark-summarize', '--metrics', fixture], { + cwd: rootDir, + encoding: 'utf8', + }); + + assert.equal(result.status, 0, result.stderr); + const output = result.stdout; + assert.match(output, /Economic summary \(cost\/time primary\):/i); + assert.match(output, /Repetition summary \(minimum 5 runs\):/i); + assert.match(output, /Token detail \(secondary\):/i); + assert.ok(output.indexOf('Economic summary') < output.indexOf('Token detail')); +}); + test('benchmark-summarize reproduces the committed 2026-05-23 run outcome', () => { const fixture = fs.readFileSync( path.join(rootDir, 'tests', 'fixtures', 'orchestration-benchmark', '2026-05-23-deepseek-v4-pro.jsonl'), diff --git a/tests/plugin-runtime.test.ts b/tests/plugin-runtime.test.ts index f4ed22b..c716da4 100644 --- a/tests/plugin-runtime.test.ts +++ b/tests/plugin-runtime.test.ts @@ -92,6 +92,43 @@ test('plugin runtime supports alternate mixed legionaries config files', async ( }); }); +test('plugin runtime grants the verifier configured loop and global verification commands', async () => { + fs.mkdirSync(tempDir, { recursive: true }); + const tempConfigPath = path.join(tempDir, 'legionaries.verifier-commands.yaml'); + const original = YAML.parse(fs.readFileSync(legionariesConfigPath, 'utf8')); + const systemAgents = systemAgentsFrom(original); + + fs.writeFileSync( + tempConfigPath, + YAML.stringify({ + system_agents: systemAgents, + verification: { + allowed_commands: ['bun run lint'], + }, + loops: { + 'docs-refresh': { + description: 'Refresh documentation', + objective: 'Update the requested documentation', + trigger: { type: 'manual' }, + inbox_path: 'docs/legion-loops/docs-refresh.md', + verification: { + commands: ['bun test', 'git diff --check'], + completion: 'Documentation is correct', + }, + }, + }, + }), + ); + + const { buildEffectiveAgentConfig } = await import('../src/runtime/build-agent-config'); + const result = await buildEffectiveAgentConfig({ rootDir, configPath: tempConfigPath }); + const bash = result.agent.verifier.permission.bash as Record; + + assert.equal(bash['bun run lint*'], 'allow'); + assert.equal(bash['bun test*'], 'allow'); + assert.equal(bash['git diff --check*'], 'allow'); +}); + test('package metadata uses the published package name', () => { const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); From 3bc9bbd6db799ea6ce41cdbb59b8c9b4cdaadbfb Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 15 Jul 2026 19:13:56 +0800 Subject: [PATCH 2/2] document observed evidence and benchmark protocol --- AGENTS.md | 2 +- README.md | 4 +- docs/CONFIGURATION.md | 4 + docs/DEVELOPMENT.md | 2 +- docs/ORCHESTRATOR_BENCHMARK.md | 200 +++++++----------- docs/ROADMAP.md | 4 +- docs/SUBAGENT_LOOP_REVIEW.md | 111 ++++++++++ ...idence-and-runtime-contract-enforcement.md | 31 +++ ...evaluation-and-decoupled-verifier-evals.md | 34 +++ 9 files changed, 270 insertions(+), 122 deletions(-) create mode 100644 docs/SUBAGENT_LOOP_REVIEW.md create mode 100644 docs/adr/0005-observed-evidence-and-runtime-contract-enforcement.md create mode 100644 docs/adr/0006-statistical-evaluation-and-decoupled-verifier-evals.md diff --git a/AGENTS.md b/AGENTS.md index dd31d4c..bee4237 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ There is no markdown frontmatter rewrite step. - `src/runtime/loop-catalog.ts`: Legion Loop prompt section formatting - `src/runtime/domain-usage-contract.ts`: Task Context Envelope parsing, warn-only domain validation, and JSONL trace evidence - `docs/academic-papers-summary.md`: paper references and claim boundaries behind domain routing and runtime evidence -- `docs/adr/0001-plugin-first-domain-aware-orchestration.md`: accepted architecture direction for plugin-first, domain-aware orchestration +- `docs/adr/*.md`: accepted architecture decisions and their rationale - `docs/ROADMAP.md`: current product plan for the OpenCode multi-agent, Legion Loop, Domain Pack, trace/doctor, and benchmark roadmap - `docs/LEGION_LOOPS.md`: user-facing Legion Loop guide - `docs/DOMAIN_PACK_AUTHORING.md`: user-facing Domain Pack authoring guide diff --git a/README.md b/README.md index 8a81506..a58b512 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,9 @@ Your Legion uses direct specialist routing along two orthogonal axes: **agents a - `bunx @whchi/your-legion doctor [--worktree ] [--config-dir ] [--scenarios]`: troubleshoots domain and loop setup. By default it validates `DOMAIN.md` declarations, loop catalogs, runtime trace evidence, and usage stats; `--scenarios` verifies the fixed domain scenario set. - `bunx @whchi/your-legion trace [--worktree ] [--config-dir ] [--limit ] [--summary]`: prints recent domain usage evidence for a workspace/project path; `--summary` groups declared refs, matching reads, and warnings by delegation. - `bunx @whchi/your-legion trace-check [--worktree ] [--config-dir ] [--require-evidence]`: low-level trace validation for contract warnings, declared domain refs or skills that were not read, and maker/checker ledger integrity (unverified file changes without a following verifier pass fail). `--require-evidence` additionally fails when no delegation or loop-run-report evidence exists at all. -- `bunx @whchi/your-legion benchmark-summarize --metrics `: summarizes an exported benchmark metrics file (JSON array or JSONL of session metrics) into the quality-plus-token outcome taxonomy. See [`ORCHESTRATOR_BENCHMARK.md`](./docs/ORCHESTRATOR_BENCHMARK.md). +- `bunx @whchi/your-legion routing-contract-eval --fixtures [--config ]`: evaluates labeled orchestrator envelopes offline against the domain contract and expected agent/loop/domain fields. +- `bunx @whchi/your-legion checker-recall-eval --fixtures `: scores isolated verifier decisions over planted-defect and clean maker-output fixtures. +- `bunx @whchi/your-legion benchmark-summarize --metrics `: summarizes an exported benchmark metrics file with cost/time economics first, k≥5 repetition statistics, and token detail second. See [`ORCHESTRATOR_BENCHMARK.md`](./docs/ORCHESTRATOR_BENCHMARK.md). - `bunx @whchi/your-legion domain-scenarios`: prints the fixed domain scenario prompts. - `bunx @whchi/your-legion domain-scenario-check [--worktree ] [--config-dir ]`: low-level fixed scenario validation; `doctor --scenarios` is the preferred entrypoint. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9c9a940..f6ba20b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -152,9 +152,13 @@ Post-execution verification controls whether the orchestrator routes an independ ```yaml verification: default_check: true + enforcement: warn + allowed_commands: [] ``` - `default_check` (boolean, default `true`): when `true`, the orchestrator routes a `verifier` pass before reporting completion for any `builder` deliverable that reports `Files changed: yes` with `Status: unverified`. Set it to `false` to keep verification opt-in (the previous behavior): the orchestrator then verifies only for loops or when the user explicitly asks. +- `enforcement` (`warn` or `block`, default `warn`): controls malformed Task Context Envelope handling at delegation time. `block` rejects the task call and returns the malformed field plus the expected shape so the router can repair it; evidence-completeness diagnostics remain warn-only. +- `allowed_commands` (string array, default `[]`): additional command prefixes the verifier may execute. Loop `verification.commands` are added automatically. Commands remain deny-by-default unless declared by the operator. `builder` always returns a Verification status ledger (files changed; verifier pass ran or not-run; `verified` / `unverified` / `verification-skipped` with a reason), so the maker/checker split stays visible even when `default_check` is off. See [ADR 0003](./adr/0003-enforced-verification-and-maker-checker-integrity.md). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 33b7a44..23a0bad 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,6 +1,6 @@ # Development -This document covers repository development for `your-legion`. User-facing installation and configuration instructions live in [`README.md`](../README.md). Architecture direction lives in [`ADR 0001`](./adr/0001-plugin-first-domain-aware-orchestration.md) and [`ADR 0002`](./adr/0002-legion-loop-contract.md), the current product plan lives in [`ROADMAP.md`](./ROADMAP.md), and user-facing domain/loop guidance lives in [`DOMAIN_PACK_AUTHORING.md`](./DOMAIN_PACK_AUTHORING.md) and [`LEGION_LOOPS.md`](./LEGION_LOOPS.md). +This document covers repository development for `your-legion`. User-facing installation and configuration instructions live in [`README.md`](../README.md). Architecture direction lives in the [ADR set](./adr/), the current product plan lives in [`ROADMAP.md`](./ROADMAP.md), and user-facing domain/loop guidance lives in [`DOMAIN_PACK_AUTHORING.md`](./DOMAIN_PACK_AUTHORING.md) and [`LEGION_LOOPS.md`](./LEGION_LOOPS.md). ## Plugin-First Runtime diff --git a/docs/ORCHESTRATOR_BENCHMARK.md b/docs/ORCHESTRATOR_BENCHMARK.md index b0688d0..593c8c1 100644 --- a/docs/ORCHESTRATOR_BENCHMARK.md +++ b/docs/ORCHESTRATOR_BENCHMARK.md @@ -13,17 +13,25 @@ C. mixed-provider orchestrated: Your Legion uses the configured per-agent provid Do not compare OpenCode native builder against `your-legion`'s `builder` in isolation. The product question is whether the `orchestrator.ts` layer is worth its full cost. The multi-provider product question is whether mixed-provider orchestrated runs keep pass rate stable while improving cost, speed, or quality over same-provider orchestrated runs. +This protocol follows [ADR 0006](./adr/0006-statistical-evaluation-and-decoupled-verifier-evals.md): a single run is smoke evidence, not a publishable result. + ## Success Claim The benchmark should only call `your-legion-orchestrated` better when it improves at least one of these without worsening the others: -- lower `tokens_per_pass` +- lower `cost` or elapsed time - lower or equal rework turns - equal or better pass rate - zero `your-legion` trace warnings - better review or rubric score for the same task -Token savings alone are not enough when the task fails or needs extra repair turns. +Token components are secondary diagnostics. Token savings alone are not enough when the task fails or needs extra repair turns. + +### Publishability + +Each task × variant (and each provider profile) requires at least five runs. The summarizer reports median and spread, paired cost deltas, and an exact two-sided sign-test p-value. Rows below five runs are labeled `anecdotal`; they remain useful for smoke checks but must not support a headline claim. + +Every task must also have a committed machine-readable acceptance result. The extractor requires an acceptance JSON object and the summarizer rejects rows without an explicit boolean `passed` value. There is no default-pass path. ## Metrics @@ -34,6 +42,8 @@ total_tokens = tokens_input + tokens_output + tokens_reasoning + tokens_cache_re context_tokens = tokens_input + tokens_cache_read + tokens_cache_write ``` +Headline economics use the session `cost` and elapsed time. The CLI prints these first; token components are shown under `Token detail (secondary)` and are not a headline total. + For each paired task: ```text @@ -74,13 +84,13 @@ The task-level `outcome` label intentionally combines quality and token cost so Use the same task prompt twice, once per variant. Keep the same model and workspace path. The default benchmark model is: ```text -opencode-go/deepseek-v4-flash +openai/gpt-5.4-mini ``` For a same-provider orchestrated run, pin both layers: -- pass `--model opencode-go/deepseek-v4-flash` to `opencode run` -- use a benchmark `legionaries.yaml` where `orchestrator`, `builder`, `explorer`, `planner`, and `librarian` all use `opencode-go/deepseek-v4-flash` +- pass `--model openai/gpt-5.4-mini` to `opencode run` +- use a benchmark `legionaries.yaml` where `orchestrator`, `builder`, `explorer`, `planner`, and `librarian` all use `openai/gpt-5.4-mini` For a mixed-provider orchestrated run, keep the same task prompt and use the intended `legionaries.yaml` model map. Record whether the run changed pass rate, rework turns, trace warnings, total tokens, elapsed time, or rubric quality compared with the same-provider orchestrated run. @@ -93,7 +103,7 @@ When passing prompts through a shell command, escape literal dollar signs in fin Native run: ```bash -opencode run --pure --agent build --model opencode-go/deepseek-v4-flash \ +opencode run --pure --agent build --model openai/gpt-5.4-mini \ --title "yl-orchestrator-vs-native-YYYYMMDD coding-001 native-builder" \ "" ``` @@ -111,7 +121,7 @@ Variant: native-builder Orchestrated run: ```bash -opencode run --agent orchestrator --model opencode-go/deepseek-v4-flash \ +opencode run --agent orchestrator --model openai/gpt-5.4-mini \ --title "yl-orchestrator-vs-native-YYYYMMDD coding-001 your-legion-orchestrated" \ "" ``` @@ -156,122 +166,34 @@ bun src/cli.ts domain-scenarios bun src/cli.ts doctor --worktree . --scenarios ``` -## Four-Domain Task Set - -These four tasks are the first benchmark prompts to run. They are derived from the bundled domain descriptions under `src/domains/` and are intentionally read-only. - -Result status in this section is a dry-run routing result: it records what the orchestrator should declare and what the checker should accept after the prompt is run. It is not a measured token result until both variants are executed in OpenCode with the benchmark marker. - -### `coding-001` - -Task type: `coding` - -Prompt: - -```text -Benchmark: yl-orchestrator-vs-native-YYYYMMDD -Task: coding-001 -Variant: - -Review the Task Context Envelope parser and explain whether comma-separated Domain skills are trimmed and parsed as separate refs. Cite the exact functions and tests that support the conclusion. Do not modify files. -``` - -Expected orchestrated result: - -| Field | Expected | -|---|---| -| Target agent | `explorer`, because the requested deliverable is repo-local parser discovery and explanation | -| Active domains | `coding: inspect parser behavior and report verification evidence` | -| Domain refs | `coding/implementation-loop` | -| Domain skills | `coding/make-code-change` | -| Verification | cites parser functions and existing tests; no files changed | -| Dry-run result | expected routing acceptance: PASS; measured token result: pending | - -### `marketing-001` - -Task type: `marketing` - -Prompt: - -```text -Benchmark: yl-orchestrator-vs-native-YYYYMMDD -Task: marketing-001 -Variant: - -Draft concise launch copy for a developer tool feature called Domain Catalog. The feature routes tasks to compact domain guidance for coding, marketing, finance, and accounting work. Keep claims concrete and supportable, write for developers and operators, and do not mention benchmark results or token savings. Do not modify files. -``` - -Expected orchestrated result: - -| Field | Expected | -|---|---| -| Target agent | `builder` as the execution specialist | -| Active domains | `marketing: write market-facing launch copy` | -| Domain refs | `marketing/campaign-planning`, `marketing/brand-voice`, or none if the copy is intentionally brief | -| Domain skills | `marketing/campaign-brief` | -| Verification | copy includes audience, core message, final copy, and claim constraints; no benchmark or token-savings claims | -| Dry-run result | expected routing acceptance: PASS; measured token result: pending | - -### `finance-001` - -Task type: `finance` - -Prompt: - -```text -Benchmark: yl-orchestrator-vs-native-YYYYMMDD -Task: finance-001 -Variant: - -Analyze a pricing tradeoff for a developer tool that costs $40 per user per month and saves each engineer 2 hours per month. Assume engineer time costs $90/hour fully loaded. Show the break-even point, state assumptions, and list risks or missing data. Do not modify files. -``` - -Expected orchestrated result: - -| Field | Expected | -|---|---| -| Target agent | `builder` as the execution specialist | -| Active domains | `finance: analyze pricing, time-savings, and break-even assumptions` | -| Domain refs | `finance/financial-review`, `finance/financial-guardrails`, or none if the answer is short | -| Domain skills | `finance/financial-analysis` | -| Verification | output separates known inputs, assumptions, analysis, and risks | -| Dry-run result | expected routing acceptance: PASS; measured token result: pending | - -### `accounting-001` +## Delegation-Required Four-Domain Task Set -Task type: `accounting` +The canonical prompt catalog is `tests/fixtures/evaluation/delegation-required-tasks.json`. Its hidden task facts live in `tests/fixtures/evaluation/delegation-required-inputs.json`. These two committed fixtures are the only current four-domain question set; do not maintain a second inline prompt version in this document. -Prompt: +The tasks remain read-only, but each answer now depends on multiple workspace sources whose contents are absent from the user prompt. Native builder can read those sources directly. The orchestrator has no read, grep, glob, or shell tools, so a grounded answer requires the expected specialist delegation. -```text -Benchmark: yl-orchestrator-vs-native-YYYYMMDD -Task: accounting-001 -Variant: +### Acceptance Contract -Review the accounting treatment considerations for recording OpenCode token usage costs as internal R&D tooling spend. Discuss recognition timing, classification, cutoff, disclosure considerations, and review risks. Do not give tax advice. Do not modify files. -``` +- Use the catalog's exact `prompt` value for both native and orchestrated variants; prepend only the benchmark, task, and variant markers. +- Required fixture values are not copied into the prompt. A model that cannot read a required source must return `BLOCKED` instead of substituting memory or general knowledge. +- Every answer must contain all `requiredOutputSections` and `requiredOutputMarkers` declared by the task. +- An orchestrated run with no specialist child session is an automatic **FAIL**. +- A child session using a different agent than `expectedAgent` is a **FAIL**. +- The delegated Task Context Envelope and domain evidence must pass `bun src/cli.ts trace-check --worktree . --require-evidence`. +- `scripts/extract-benchmark-metrics.ts` rejects an orchestrated root that has no delegated child session; there is no zero-delegation compatibility path. -Expected orchestrated result: +### Task Shape -| Field | Expected | -|---|---| -| Target agent | `builder` as the execution specialist | -| Active domains | `accounting: review treatment, classification, timing, cutoff, and disclosure considerations` | -| Domain refs | `accounting/accounting-review`, `accounting/accounting-guardrails`, or none if the answer is short | -| Domain skills | `accounting/apply-accounting-review` | -| Verification | output separates accounting question, facts, assumptions, treatment notes, and review risks | -| Dry-run result | expected routing acceptance: PASS; measured token result: pending | - -### Dry-Run Summary +| task_id | expected agent | workspace dependency | required reasoning stages | +|---|---|---|---| +| `coding-001` | `explorer` | parser implementation, parser tests, coding guardrails | trace call path, derive exact array, separate direct/indirect coverage, identify missing regression case | +| `marketing-001` | `builder` | hidden launch facts, brand voice, launch example, campaign skill | extract evidence, define audience/message, produce constrained copy, audit every claim | +| `finance-001` | `builder` | hidden pricing scenario, finance guardrails, summary example, analysis skill | separate inputs/assumptions, calculate base case, break-even and downside case, identify risks | +| `accounting-001` | `builder` | hidden period-end scenario, accounting guardrails, treatment example, accounting skill | separate facts/assumptions, calculate schedule, outline entries/cutoff/disclosure, identify unresolved framework risks | -| task_id | task_type | expected active domain | expected domain skill | dry-run routing result | measured native result | measured orchestrated result | -|---|---|---|---|---|---|---| -| `coding-001` | coding | `coding` | `coding/make-code-change` | PASS expected | PASS | PASS: direct `explorer` delegation; +135.91% tokens | -| `marketing-001` | marketing | `marketing` | `marketing/campaign-brief` | PASS expected | PASS | PASS: direct `builder` delegation; +395.19% tokens | -| `finance-001` | finance | `finance` | `finance/financial-analysis` | PASS expected | PASS | PASS: direct `builder` delegation after shell-dollar escaping was fixed; +132.58% tokens | -| `accounting-001` | accounting | `accounting` | `accounting/apply-accounting-review` | PASS expected | PASS | PASS: direct `builder` delegation; +8.27% tokens | +The coding prompt deliberately uses repo-discovery language instead of the command-owned `review` intent. The other three prompts require exact local facts and domain procedures, so a plausible generic answer still fails deterministic acceptance. -Measured same-model results are recorded below. The latest four-task run completed all four tasks, but several domain-envelope fields still produced trace warnings. +Historical results below used the former simple prompts. They are retained only as historical evidence and are not comparable with this delegation-required task set. ## Execution-Quality Task Set (Harder Suite) @@ -445,13 +367,55 @@ Interpretation: ## Summarizing A Recorded Run -Extract the paired sessions into a metrics file (JSON array or JSONL, one session-metric row each) with `scripts/extract-benchmark-metrics.ts` (or the SQL above), then summarize it into the outcome taxonomy: +Extract the paired sessions into a metrics file (JSON array or JSONL, one session-metric row each) with `scripts/extract-benchmark-metrics.ts`. Supply a committed JSON object mapping every task id to its deterministic acceptance result: + +```bash +bun scripts/extract-benchmark-metrics.ts \ + --marker \ + --acceptance \ + [--profile same-provider|mixed-provider] > metrics.jsonl + +bunx @whchi/your-legion benchmark-summarize --metrics metrics.jsonl +``` + +The acceptance file has this shape: + +```json +{ + "coding-001": true, + "marketing-001": false +} +``` + +This reuses `summarizeOrchestrationBenchmark`, so the task-level token outcome labels are computed the same way in the CLI, the library, and tests, while cost/time and repetition statistics remain primary. A committed fixture of the 2026-05-23 control run remains historical smoke evidence only; it is not a publishable k≥5 result. See [ADR 0004](./adr/0004-orchestration-value-evidence.md) and [ADR 0006](./adr/0006-statistical-evaluation-and-decoupled-verifier-evals.md). + +## Isolated Evaluations + +Routing-contract quality can be measured without running a full delegated task. Capture orchestrator outputs with labels in a JSONL fixture and run: + +```bash +bunx @whchi/your-legion routing-contract-eval --fixtures routing-contract.jsonl +``` + +The evaluator parses and validates each Task Context Envelope, then checks the expected agent, loop decision, and domain fields. Checker recall is similarly measured from committed maker-output fixtures and verifier decisions: + +```bash +bunx @whchi/your-legion checker-recall-eval --fixtures checker-recall.jsonl +``` + +Mutation-derived execution tasks should retain surviving mutants from a fixture project's test suite as planted defects. Those tasks belong in the repeated end-to-end suite; they must use the same acceptance-map and k≥5 protocol above. + +Generate survivor fixtures without modifying the source project: ```bash -bunx @whchi/your-legion benchmark-summarize --metrics +bun scripts/generate-mutation-fixtures.ts \ + --project \ + --mutations \ + --test-command "bun test" \ + --output surviving-mutants.jsonl ``` -This reuses `summarizeOrchestrationBenchmark`, so the task-level `outcome` labels are computed the same way in the CLI, the library, and tests. A committed fixture of the 2026-05-23 control run lives at `tests/fixtures/orchestration-benchmark/2026-05-23-deepseek-v4-pro.jsonl`; summarizing it reproduces `more-expensive-not-better` on all four read-only tasks (native 359,315 vs your-legion 777,249 tokens). See [ADR 0004](./adr/0004-orchestration-value-evidence.md). +The manifest uses one exact `search`/`replace` mutation per entry. Only mutations whose acceptance command stays green are retained, so the resulting cases are deliberately deceptively green and can be attached to execution-quality tasks. Trace-based acceptance: trace files are now keyed by a normalized worktree, so `trace-check --worktree .` no longer reads an empty trace and reports a false pass when events were written under a degenerate `/` worktree. And `trace-check --require-evidence` fails when the expected delegation or loop evidence is absent, so an empty trace is no longer a silent pass for acceptance (ADR 0004, D4). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e7819f3..8469254 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,7 +2,7 @@ This document tracks the current product plan and implementation status for Your Legion. -The architecture decisions behind this roadmap are recorded in [ADR 0001: Plugin-First Domain-Aware Orchestration](./adr/0001-plugin-first-domain-aware-orchestration.md) and [ADR 0002: Legion Loop Contract](./adr/0002-legion-loop-contract.md). +The architecture decisions behind this roadmap are recorded in the [ADR set](./adr/), including [ADR 0001](./adr/0001-plugin-first-domain-aware-orchestration.md), [ADR 0002](./adr/0002-legion-loop-contract.md), [ADR 0003](./adr/0003-enforced-verification-and-maker-checker-integrity.md), [ADR 0004](./adr/0004-orchestration-value-evidence.md), [ADR 0005](./adr/0005-observed-evidence-and-runtime-contract-enforcement.md), and [ADR 0006](./adr/0006-statistical-evaluation-and-decoupled-verifier-evals.md). ## Current Focus @@ -111,6 +111,8 @@ Acceptance criteria: | Loop | Add Legion Loop contract and verifier | P0 | Done: loops are config-backed, prompt-injected, trace-aware, and doctor-validated | | Verification | Enforce maker/checker integrity: same-model guard, default check for code work, custom-agent guard | P1 | Done: [ADR 0003](./adr/0003-enforced-verification-and-maker-checker-integrity.md) implemented | | Benchmark | Orchestration-value evidence: harder task set, 3-way comparison, benchmark-summarize CLI, trace worktree fix | P1 | Done: [ADR 0004](./adr/0004-orchestration-value-evidence.md) implemented | +| Benchmark | Statistical and decoupled evaluation: explicit acceptance, k≥5 summaries, paired sign test, routing-contract and checker-recall evaluators | P1 | Done: [ADR 0006](./adr/0006-statistical-evaluation-and-decoupled-verifier-evals.md) runtime/reporting surfaces implemented; mutation-derived fixture generation remains an operator workflow | +| Verification | Observed file changes, explicit delegation linkage, operator-declared checker commands, envelope enforcement | P1 | Done: [ADR 0005](./adr/0005-observed-evidence-and-runtime-contract-enforcement.md) implemented | | Verification | Blocking non-loop ledger check: capture `Verification status` blocks as `verification-report` trace events; `trace-check` fails unverified file changes without a following verifier pass | P1 | Done: extends ADR 0003 D2/D4 to the non-loop path | | Verification | Custom-agent neutrality warning at agent-definition load time | P1 | Done: the ADR 0003 D3 guard now also fires when the definition is loaded, not only in `doctor` | | DX | CI workflow, grouped CLI help, evidence-first README | P1 | Done: `.github/workflows/ci.yml` runs test/build; README leads with the maker/checker ledger | diff --git a/docs/SUBAGENT_LOOP_REVIEW.md b/docs/SUBAGENT_LOOP_REVIEW.md new file mode 100644 index 0000000..f7c5dcf --- /dev/null +++ b/docs/SUBAGENT_LOOP_REVIEW.md @@ -0,0 +1,111 @@ +# Subagent & Loop Engineering Review + +Date: 2026-07-15. Scope: a design review of whether Your Legion correctly and proactively invokes subagents and Legion Loops, whether the maker/checker guarantee holds in practice, and whether the evaluation methodology can be made stronger. Method: static review of `src/agents/`, `src/runtime/`, `src/index.ts`, the docs set, and the committed benchmark evidence. No live OpenCode runs were executed for this review; runtime claims below are anchored to the repo's own measured-run notes. + +Architecture-level directions coming out of this review are recorded as accepted ADRs: + +- [ADR 0005: Observed Evidence and Runtime Contract Enforcement](./adr/0005-observed-evidence-and-runtime-contract-enforcement.md) +- [ADR 0006: Statistical Evaluation and Decoupled Verifier Evals](./adr/0006-statistical-evaluation-and-decoupled-verifier-evals.md) + +## Verdict + +The foundations are genuinely strong and ahead of most multi-agent setups: + +- The two-axis model (agents as work modes, Domain Packs as capability) is clean, documented, and guarded by tests and doctor lints. +- Maker/checker separation with a *different model* for the checker is the right diagnosis of correlated blind spots, and `doctor` warns on same-model loops. +- Explicit-only loop invocation is the correct default; auto-triggered loops are how unattended agent systems amplify mistakes. +- The benchmark culture is honest: the protocol is allowed to conclude orchestration was not worth it, and the committed runs say exactly that for small tasks. + +What blocks "excellent" today is not the architecture but four kinds of gap: one active prompt contradiction that can cause *incorrect* proactive loop invocation (F1), trust gaps where the verification story quietly falls back to self-reporting (F2–F4), contract feedback that arrives after the run instead of during it (F5–F6), and an evaluation pipeline whose conclusions rest on n=1 runs with default-pass labels (F9). + +## Findings + +| ID | Severity | Area | Summary | +|---|---|---|---| +| F1 | High | Loops | Injected Loop Catalog text contradicts the explicit-only invocation rule | +| F2 | High | Verification | The completion ledger is built entirely from maker self-reporting | +| F3 | High | Verification | `verifier` cannot execute the verification commands it must judge | +| F4 | Medium | Traces | Delegation↔evidence linkage breaks across sessions and parallel delegations | +| F5 | Medium | Delegation UX | Envelope violations are warn-only after the fact; the router never sees them in-session | +| F6 | Medium | Delegation UX | The 15-field envelope is mostly `none` boilerplate and weaker models malform it | +| F7 | Medium | Loops UX | Loops are invisible until named; add suggest-don't-set to make loop engineering discoverable | +| F8 | Low | DX | The plugin unconditionally overrides `default_agent` to `orchestrator` | +| F9 | High | Evaluation | n=1 runs, default-pass labels, unauditable rubric, verifier value never isolated | +| F10 | Low | Loops | Preset verification defaults (`bun test`) do not prove non-code loop claims | + +### F1. Loop Catalog prompt contradicts the explicit-only invocation model (High) + +The invocation model says loops are explicit-only: `src/agents/orchestrator.ts` ("Do not set Loop from task intent, topic, similarity, or the Loop Catalog alone"), `LEGION_LOOPS.md` Invocation Model, `AGENTS.md:161-163`. But the Loop Catalog section injected into **every** agent prompt (`src/runtime/build-agent-config.ts:192`) says the opposite (`src/runtime/loop-catalog.ts:16-19`): + +> "Use the Loop Catalog when a task is part of a recurring or goal-driven engineering loop. Routing agents should pass the matching loop id in the Task Context Envelope as `Loop: `. ... If no loop clearly applies, use `Loop: none`." + +That is an intent/similarity matching instruction, and it is appended near the *end* of the orchestrator prompt, where models weight instructions heavily. A router following the catalog text will proactively set `Loop:` for topically similar tasks — exactly the failure the explicit-only rule exists to prevent. `AGENTS.md:113` ("Matching loops are passed as `Loop: `") carries the same stale phrasing. + +Fix (small, high value): + +- Rewrite the Loop Catalog header to restate the explicit-only rule: pass a loop id only when the user named it or provided a `loop-prompt` envelope; otherwise `Loop: none`; when a configured loop looks relevant, *suggest* it to the user instead of setting it (see F7). +- Align `AGENTS.md:113` with `AGENTS.md:161-163`. +- Consider injecting the full catalog only into `orchestrator` and `verifier`; makers only need the contract of the loop already named in their envelope. + +### F2. The completion ledger is self-reported end to end (High → ADR 0005 D1) + +`verification-report` and `loop-run-report` trace events are parsed from the text the maker *returns* (`src/runtime/domain-usage-contract.ts:960-1011`). Three consequences: + +- A maker that **omits** the `Verification status` block leaves no ledger event at all; `trace-check` has nothing to fail. The blocking check only catches makers that honestly report `unverified`. +- A maker that self-reports `Status: verified` is trusted; no independent check follows. The 2026-07-04 measured run confirms this shape in practice: "No verifier pass was triggered" in any orchestrated run — `builder` self-verified every time. +- `Files changed: yes|no` itself is a claim, not an observation, even though the plugin sits on `tool.execute` hooks and can *see* `edit`/`write`/`bash` calls. + +The repo's core promise is "completion claims backed by evidence, not self-reporting" (README). Today the evidence chain observes delegations and domain reads but takes the maker's word for the two facts that matter most: whether files changed and whether verification ran. ADR 0005 D1 records the fix: capture observed file-change evidence from tool events and cross-check it against the reported ledger, with "silent maker" (observed changes, no ledger block) becoming a blocking diagnostic. + +### F3. The checker cannot run the checks (High → ADR 0005 D3) + +`verifier`'s bash permission allows only `git diff*`, `git log*`, `git status*` (`src/agents/verifier.ts:63-70`). Yet its prompt requires it to judge "whether the stated verification commands actually prove the completion claim", and loop completion requires a `passed` verification outcome. The checker cannot re-run `bun test`; it must either trust the maker's transcript of the command output — self-reporting again, at the exact point the maker/checker split exists to remove — or judge tests by reading them. + +Fix: extend the verifier's allowlist with operator-declared commands — the loop's `verification.commands` and/or a global `verification.allowed_commands` in `legionaries.yaml`. These are trusted operator input, not model input, so the read-only posture is preserved while independent re-execution becomes possible. Keep deny-by-default for everything else. + +### F4. Evidence linkage is fragile across sessions and parallel delegations (Medium → ADR 0005 D2) + +Linkage between a delegation and its evidence rides on `latestDelegationBySession`, keyed by the delegating session (`src/runtime/domain-usage-contract.ts:902,934-936`). Two structural problems: + +- The maker's reads happen in the **child** session; unless the hook input carries the parent session id, `domain-read` events cannot resolve a `delegationID`, and declared refs show up as "not read". The 2026-05-23 measured run recorded exactly this: `builder` read `marketing/brand-voice` "via normal read tools; no domain-read event was recorded". +- Two parallel delegations from one session overwrite the "latest" pointer, so evidence attributes to the wrong delegation. + +The verifier-follows-maker check has a related weakness: any later `verifier` delegation in the same session satisfies the unverified-file-changes rule, with no linkage to the specific claim or files being checked. ADR 0005 D2 records the fix: carry an explicit delegation id in the envelope and require the verifier pass to reference the maker delegation it is checking. + +### F5. Contract violations are audit findings, not runtime feedback (Medium → ADR 0005 D4) + +Malformed `Active domains` entries recur in every measured run (comma-split pseudo-domains, missing responsibilities). The warnings land in JSONL traces the agent never sees; the delegation proceeds with a broken envelope, and a human discovers it later via `trace-check`. The `tool.execute.before` hook already parses and validates the envelope *before* the delegation runs — it can reject the `task` call with a structured error naming the malformed field, so the router fixes the envelope in-session on the next attempt. This should be a configurable posture (`warn` | `block`) consistent with the warn-vs-block policy in ADR 0003 D4. This is the single highest-leverage fix for envelope reliability because it converts a static-prompt problem into a closed feedback loop. + +### F6. The envelope is heavy for what most delegations need (Medium) + +The Task Context Envelope mandates 15 fields; for a typical non-loop delegation, 9–10 of them are literally `none`. That is token overhead on every delegation and, worse, attention overhead — the measured runs show weaker models corrupting exactly the structured fields (`Active domains`). Recommendation: make `none` fields omittable end to end (orchestrator prompt guidance, parser, and validator treat an absent field as `none`), and document a canonical minimal envelope (`Objective`, `Active domains`/`Domain refs`/`Domain skills` when applicable, `Constraints`, `Expected output`, `Verification`) with the loop and verification fields required only for loop and checker delegations. Combined with F5's runtime rejection, this attacks the envelope-quality problem from both sides. + +### F7. Make loop engineering discoverable: suggest, never set (Medium) + +Explicit-only is right, but today it means a configured loop is invisible unless the user remembers its id: a user who configured `daily-ci-triage` and asks "triage today's CI failures" gets a plain delegation with `Loop: none` and loses the inbox, ledger, and verifier discipline they configured. The middle ground preserves safety and adds proactivity: the orchestrator may *recognize* that a request matches a configured loop and ask one question — "This matches the configured loop `daily-ci-triage`; run it as a loop run?" — but must never set `Loop:` without the user's confirmation. This belongs in the orchestrator prompt and the rewritten Loop Catalog header (F1). It directly answers "can the system proactively invoke loop engineering" without reopening auto-trigger risk. + +### F8. `default_agent` is overridden unconditionally (Low) + +`src/index.ts:41` sets `config.default_agent = 'orchestrator'` on every load. Installing the plugin silently changes the user's default agent even if they chose another — and the repo's own benchmark says the orchestrated path is +85–146% tokens on small tasks. Operators should be able to keep the native default and opt into orchestration per session. Fix: respect an existing user-set `default_agent`, or add a `default_agent` knob to `legionaries.yaml`. + +### F9. Evaluation methodology (High → ADR 0006) + +The benchmark's *questions* are right (quality-plus-cost taxonomy, 3-way provider comparison, routing-cost vs execution-quality split). The *measurement* under them is weak: + +- **n=1 per cell.** Every headline number (+85.1%, −2.6%, +395.19%) is a single stochastic run. Direction cannot be claimed from one sample. +- **Default-pass labels.** `scripts/extract-benchmark-metrics.ts` emits every row `passed=true` and asks a human to hand-edit the JSONL afterwards (`scripts/extract-benchmark-metrics.ts:12-13,92,123`). A forgotten edit silently inflates pass rate — a default-pass bias built into the pipeline. +- **Unauditable rubric.** `completion_score` values (0.90, 0.95) have no committed rubric or judge protocol. +- **Verifier value is structurally unmeasurable end to end.** The 2026-07-04 run showed the verifier never triggers on tasks where the maker self-verifies green — so the end-to-end suite cannot observe the one effect it exists to detect. +- **Token sums conflate unlike tokens.** The mixed-provider "win" came from cache-read volume; a cache-read token is priced nothing like an output token, and the `cost` column already exists in the session DB. + +ADR 0006 records the directions: repeated trials with paired statistics; machine-checkable per-task acceptance instead of default-pass hand-edits; a cheap offline routing-contract eval that reuses the existing parser/validator as the judge; an isolated checker-recall eval over fixture maker outputs with known defects; mutation-testing-derived "deceptively green" task generation (answers the open ROADMAP item directly); and cost/time as the primary economic metric. + +### F10. Preset verification defaults do not prove non-code claims (Low) + +`docs-refresh` defaults to `bun test` — a docs loop can report `passed` without any check touching the docs claim. Suggest preset-appropriate defaults (for docs: link check, doc-drift grep, or an explicit "manual review" completion rule) or require `--verification` for non-code presets. + +## What Was Verified / Not Verified + +- Verified by reading source and docs: every file/line reference above; the F1 contradiction exists in the current `loop-catalog.ts` and `AGENTS.md`; the verifier permission map; the hook capture paths; the extractor's default-pass behavior. +- Not verified: live OpenCode runtime behavior (which session id `tool.execute` hooks receive for child-session tool calls, and whether a thrown error in `tool.execute.before` surfaces as a retryable tool error). Both are assumptions to confirm before implementing ADR 0005 D2/D4; the F4 evidence gaps are, however, already visible in the committed measured-run notes regardless of root cause. +- No code, config, or test changes were made; this review and the two ADRs are the only artifacts. diff --git a/docs/adr/0005-observed-evidence-and-runtime-contract-enforcement.md b/docs/adr/0005-observed-evidence-and-runtime-contract-enforcement.md new file mode 100644 index 0000000..5137d40 --- /dev/null +++ b/docs/adr/0005-observed-evidence-and-runtime-contract-enforcement.md @@ -0,0 +1,31 @@ +# ADR 0005: Observed Evidence and Runtime Contract Enforcement + +## Status + +Accepted. Source: [Subagent & Loop Engineering Review, 2026-07-15](../SUBAGENT_LOOP_REVIEW.md) (F2–F5). Implementation is tracked separately in `ROADMAP.md`. + +## Context + +[ADR 0003](./0003-enforced-verification-and-maker-checker-integrity.md) moved the maker/checker guarantee to "separate model plus an explicit verification ledger", and the non-loop ledger is now captured and blocking. But the ledger's inputs are still self-reported, and the contract's feedback arrives after the run: + +- **The ledger trusts the maker for both presence and content.** `verification-report` and `loop-run-report` events are parsed from the text the maker returns (`domain-usage-contract.ts:960-1011`). A maker that omits the `Verification status` block leaves no event, so `trace-check` has nothing to fail; a maker that self-reports `verified` is trusted with no independent pass. The 2026-07-04 measured run showed the practical shape: `builder` self-verified in every orchestrated run and no `verifier` delegation was ever triggered. Meanwhile the plugin already observes every `edit`, `write`, and `bash` call through `tool.execute` hooks — the facts are visible but not recorded. +- **Evidence linkage rides on fragile session heuristics.** `latestDelegationBySession` is keyed by the delegating session (`domain-usage-contract.ts:902,934-936`). Maker reads happen in the child session, so `domain-read` events may not resolve a `delegationID` (the 2026-05-23 run recorded declared refs that were read "via normal read tools" with "no `domain-read` event"). Parallel delegations from one session overwrite the latest-pointer. And the unverified-file-changes check accepts *any* later `verifier` delegation in the same session, with no linkage to the specific claim being checked (`domain-usage-contract.ts:700-714`). +- **The checker cannot execute the checks.** `verifier` bash is limited to `git diff*`, `git log*`, `git status*` (`verifier.ts:63-70`), yet it must judge whether the stated verification commands prove the completion claim and whether the outcome was `passed`. It can only trust the maker's transcript of command output — self-reporting at the exact point maker/checker separation exists to remove. +- **Envelope validation is an audit, not feedback.** Malformed `Active domains` recurred in every measured run. Warnings land in JSONL the router never sees; the delegation proceeds broken and a human finds it later. The `tool.execute.before` hook already parses and validates the envelope before the delegation runs. + +## Decision + +Adopt the following direction. Each item is a decision record; implementation is tracked separately (see `ROADMAP.md`). + +- **D1 - Observed file-change evidence.** Record `file-change` trace events from observed `edit`/`write` (and file-mutating `bash`) tool calls, attributed to the active delegation. Cross-check observed evidence against the reported ledger: observed changes with **no** `Verification status` block become a blocking `silent-maker` diagnostic in `trace-check`; observed changes with a self-reported `verified` status and no verifier pass become a warning by default and blocking for loop runs. `Files changed` moves from claim to observation; the maker's block remains the source for status and reason. +- **D2 - Delegation-scoped linkage.** Carry an explicit delegation id in the envelope (generated at delegation time, e.g. injected by the `tool.execute.before` hook or written by the orchestrator), so child-session reads, reports, and verifier passes link to the delegation they belong to instead of to "the latest delegation in this session". A verifier pass that closes an unverified ledger must reference the maker delegation id it checked. This removes the parallel-delegation overwrite and the any-verifier-counts weakness. Before implementation, confirm which session id OpenCode's `tool.execute` hooks receive for child-session tool calls; the design must not assume parent-session identity. +- **D3 - Operator-declared checker execution boundary.** Extend the `verifier` bash allowlist with trusted operator input: the loop's `verification.commands` and an optional global `verification.allowed_commands` in `legionaries.yaml`. Deny-by-default is preserved; the checker gains exactly the commands the operator already declared as the proof of completion, so it can re-run them instead of trusting the maker's transcript. +- **D4 - Runtime envelope rejection (warn | block).** Add a configurable enforcement posture for envelope contract violations. In `block` mode, `tool.execute.before` rejects a `task` call whose envelope fails validation, returning a structured error that names the malformed field and the expected shape, so the router repairs the envelope in-session. Default stays `warn` until the rejection path is proven not to strand delegations. This refines the warn-vs-block policy of ADR 0003 D4: domain-evidence completeness stays warn-only; envelope *shape* becomes enforceable at delegation time. + +## Consequences + +- The README's core promise — completion claims backed by evidence, not self-reporting — becomes true for the two facts that matter most: whether files changed and whether an independent check followed. +- New trace volume (`file-change` events) and one new config surface (`verification.allowed_commands`, envelope enforcement posture). +- D2 is the enabling fix for D1's attribution and for trusting `trace-check` in benchmarks; it extends ADR 0004 D4's keying fix from worktrees to delegations. +- D3 slightly widens the checker's execution surface; the boundary stays operator-defined and deny-by-default. +- D4 converts recurring envelope malformation from a post-hoc audit finding into a closed feedback loop, and is expected to reduce the malformed-`Active domains` class of warnings to near zero on capable models. diff --git a/docs/adr/0006-statistical-evaluation-and-decoupled-verifier-evals.md b/docs/adr/0006-statistical-evaluation-and-decoupled-verifier-evals.md new file mode 100644 index 0000000..1aa815d --- /dev/null +++ b/docs/adr/0006-statistical-evaluation-and-decoupled-verifier-evals.md @@ -0,0 +1,34 @@ +# ADR 0006: Statistical Evaluation and Decoupled Verifier Evals + +## Status + +Accepted. Source: [Subagent & Loop Engineering Review, 2026-07-15](../SUBAGENT_LOOP_REVIEW.md) (F9). Refines [ADR 0004](./0004-orchestration-value-evidence.md). Implementation is tracked separately in `ROADMAP.md`. + +## Context + +ADR 0004's machinery landed: suites are split by question, the 3-way provider comparison is a data dimension, `benchmark-summarize` reads committed fixtures, and `trace-check` no longer passes on empty traces. The remaining weaknesses are in measurement validity, not machinery: + +- **Every cell is n=1.** The 2026-07-04 headline numbers (+85.1%, −2.6%, +145.6%) are single stochastic runs. LLM run-to-run variance on tasks this size can exceed the deltas being reported; direction cannot be claimed from one sample. +- **Pass labels default to pass.** `scripts/extract-benchmark-metrics.ts` emits every row `passed=true` and relies on a human hand-editing the JSONL afterwards (`extract-benchmark-metrics.ts:12-13,92,123`). A forgotten edit silently inflates pass rate; the pipeline has a built-in default-pass bias. +- **Rubric scores are unauditable.** `completion_score` values in `ORCHESTRATOR_BENCHMARK.md` (0.90, 0.95) have no committed rubric, judge prompt, or scoring protocol; they cannot be reproduced or challenged. +- **The verifier's value is structurally unmeasurable end to end.** In the 2026-07-04 run the maker self-verified green on every task, so no `verifier` delegation ever fired — the end-to-end suite cannot observe the effect it exists to detect. The ROADMAP item "design deceptively-green tasks" is open with no generation method. +- **Routing quality is measured at full price.** Routing correctness and envelope validity currently require complete `opencode run` executions, so the sample stays tiny while the measured failure mode (malformed `Active domains`) is precisely a high-frequency, cheap-to-check text-contract behavior. +- **Token sums conflate unlike tokens.** `total_tokens` weighs a cache-read token equal to an output token; the 2026-07-04 notes themselves attribute the mixed-provider gap "mostly to cache-read volume and reasoning tokens". The session DB already records `cost`. + +## Decision + +Adopt the following direction. Each item is a decision record; implementation is tracked separately (see `ROADMAP.md`). + +- **D1 - Repetition and paired statistics.** A publishable comparison requires k ≥ 5 runs per task×variant cell, reported as median and spread, with paired per-task deltas and a sign test or bootstrap confidence interval on the direction. Single runs remain useful as smoke evidence but must be labeled anecdotal in `ORCHESTRATOR_BENCHMARK.md`, not summarized as results. +- **D2 - Machine-checkable acceptance per task.** Each benchmark task ships a committed acceptance check (a script or assertion set) that decides `passed` deterministically from the run's artifacts. The extractor must not default `passed=true`; every emitted row must carry an explicit `true`/`false` result from the acceptance check. `benchmark-summarize` rejects unlabeled rows, and `--strict` remains the acceptance-gate posture. +- **D3 - Offline routing-contract eval.** Add a cheap high-n harness that runs *only* the orchestrator turn against a committed labeled prompt set (target agent, expected loop decision, expected domain fields; on the order of 50 prompts covering the domain scenarios, loop suggest-vs-set cases, and known malformation traps). Judge the output with the existing `parseTaskContextEnvelope` + `validateDomainUsageContract` plus the expected-agent label — the validator is already the contract, so it is the judge. This makes envelope reliability measurable per prompt-change at a fraction of end-to-end cost; the fixed `domain-scenarios` set becomes one subset of it. +- **D4 - Checker-recall eval.** Measure the verifier in isolation: a committed fixture set of maker outputs — diffs, completion claims, and verification transcripts — with known planted defects and known clean cases. Run `verifier` alone over the set and score recall and precision. This decouples "does the verifier catch defects" from "does the verifier get triggered", which end-to-end runs conflate. +- **D5 - Mutation-derived deceptively-green tasks.** Generate the harder suite ADR 0004 D1 calls for with mutation testing: run a mutation tool over a small fixture project, and keep surviving mutants — defects the existing test suite does not catch — as planted defects whose self-check is green by construction. This yields reproducible, difficulty-tunable "plausibly green but wrong" tasks in quantity, and directly answers the open ROADMAP item; the 2026-07-04 hand-planted defects failed because capable natives caught them. +- **D6 - Cost and time as the primary economic metric.** Headline comparisons use the session `cost` column and elapsed time; token components (input, output, reasoning, cache read/write) are reported separately and never summed into a single headline number. `tokens_per_pass` remains as a secondary, same-provider-only metric. + +## Consequences + +- Benchmark claims become defensible: direction backed by paired statistics, pass rates backed by committed acceptance checks, scores backed by versioned rubrics or dropped in favor of binary acceptance. +- Run cost rises with k; D3 offsets this by moving the highest-frequency question (routing/envelope quality) to an offline harness that can run in CI on every prompt change. +- D4 + D5 make the verifier-value question answerable in both directions — including the negative result that an independent checker adds no recall over maker self-checks, which would be a valid, publishable outcome consistent with ADR 0001. +- D2 makes unlabeled benchmark rows invalid. Acceptance summaries cannot proceed until every task has a deterministic pass or fail result.