mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
Merge origin/main v1.91.18.0 (#3023) into the v1.91.19.0 wave
Resolutions: - design-doc discovery: main's bin/gstack-design-doc-find replaces the inline block; it keeps #2839 (a root DESIGN.md is the design system, never the design doc; only docs/designs/ competes on freshness). Its unit test and the F2 harness (now installs the helper) follow. - design-shotgun: main's $D variants batch, staged under ${TMPDIR:-/tmp}. - memory ingest: main's scoped consent (scope counts, purge of staged pages outside the scope, zero-sessions notice) on the wave's locked state flow; the purge runs before the state loads, under the lock. - setup / render library: --model overlay plus --disabled-skills on every Claude render, including main's plain overlay render. - codex template and constants: the wave's sandbox preflight and unverified mode; main's issue-number removals. - docsync interface: ls-tree (main) and check-ignore (wave) are both reads. - census pins re-measured on the merged tree: 114 paid files; gate behavior census 44 files / 76 shards (periodic-only files skip the gate, now including two safety-rule evals); periodic floor 35,742 and release sum 58,097 (gate-only files no longer count toward periodic). - parity caps at measured merged sizes: ship 1.4271, review 1.1981, qa 1.1099. - generated output regenerated (gen:skill-docs --host all, llms.txt, context budget, goldens).
This commit is contained in:
commit
8e2beec2de
412 files changed
+24774
-1012
No files matched your search
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* W2 calibration request configurations, keyed by distinct request (prompt
|
||||
* builder, model, max_tokens, stream, effort, thresholds), not by test case.
|
||||
* Each config builds prompts through the same builder the eval sends; "old" is
|
||||
* today's request and "new" adds only the JSON schema (comparison 1).
|
||||
*/
|
||||
import {
|
||||
ARM_JUDGE_MODEL, ARM_JUDGE_SCHEMA, buildArmJudgePrompt, parseArmJudgeResponse,
|
||||
buildQaWorkflowJudgePrompt, buildQaHealthRubricJudgePrompt, buildQaAntiRefusalJudgePrompt,
|
||||
buildCrossSkillConsistencyJudgePrompt, buildVoiceDirectiveJudgePrompt,
|
||||
JUDGE_SCORE_SCHEMA, QA_ANTI_REFUSAL_JUDGE_SCHEMA, CROSS_SKILL_CONSISTENCY_JUDGE_SCHEMA,
|
||||
VOICE_DIRECTIVE_JUDGE_SCHEMA, VOICE_DIRECTIVE_DIMENSIONS, JUDGE_SCORE_DIMENSIONS, DEFAULT_JUDGE_MAX_TOKENS,
|
||||
judgePanelMean, judgePanelMajority,
|
||||
} from '../test/helpers/llm-judge';
|
||||
import { buildWorkflowJudgePrompt, WORKFLOW_JUDGE_RESPONSE_SCHEMA } from '../test/helpers/workflow-judge-input';
|
||||
import { CLAUDE_FRONTIER_EVAL_MODEL } from '../lib/eval-model';
|
||||
import type { CalibrationConfig, Verdict } from './judge-calibration';
|
||||
|
||||
type Sample = Record<string, unknown>;
|
||||
|
||||
/** What production rejects: judgePanelMean needs finite numbers, judgePanelMajority needs booleans. */
|
||||
function requireFields(value: unknown, numbers: readonly string[], booleans: readonly string[] = []): void {
|
||||
const sample = (value ?? {}) as Sample;
|
||||
for (const key of numbers) {
|
||||
if (typeof sample[key] !== 'number' || !Number.isFinite(sample[key])) throw new Error(`non-numeric ${key}: ${JSON.stringify(sample[key])}`);
|
||||
}
|
||||
for (const key of booleans) {
|
||||
if (typeof sample[key] !== 'boolean') throw new Error(`non-boolean ${key}: ${JSON.stringify(sample[key])}`);
|
||||
}
|
||||
}
|
||||
|
||||
const meets = (mean: Record<string, number>, floors: Record<string, number>): Verdict =>
|
||||
Object.entries(floors).every(([key, floor]) => mean[key]! >= floor) ? 'pass' : 'fail';
|
||||
|
||||
const QA_FLOORS = { clarity: 3, completeness: 3, actionability: 4 };
|
||||
const scoreJudge = (floors: Record<string, number>) => ({
|
||||
validate: (value: unknown) => requireFields(value, JUDGE_SCORE_DIMENSIONS),
|
||||
verdict: (samples: unknown[]) => meets(judgePanelMean<string>(samples as Sample[], JUDGE_SCORE_DIMENSIONS), floors),
|
||||
});
|
||||
|
||||
const workflow = (id: string, priority: number, cases: string[], extra: { agentCapability?: 'frontier'; model?: string; floors?: Record<string, number> } = {}): CalibrationConfig => ({
|
||||
id, priority, cases,
|
||||
model: extra.model ?? CLAUDE_FRONTIER_EVAL_MODEL,
|
||||
build: inputs => buildWorkflowJudgePrompt({ judgeContext: inputs.judgeContext!, judgeGoal: inputs.judgeGoal!, agentCapability: extra.agentCapability },
|
||||
{ files: [], text: inputs.bundle! }),
|
||||
oldOptions: { max_tokens: DEFAULT_JUDGE_MAX_TOKENS },
|
||||
newOptions: { max_tokens: DEFAULT_JUDGE_MAX_TOKENS, jsonSchema: WORKFLOW_JUDGE_RESPONSE_SCHEMA },
|
||||
...scoreJudge(extra.floors ?? QA_FLOORS),
|
||||
estimatedOutputTokens: 1200,
|
||||
});
|
||||
|
||||
export const CALIBRATION_CONFIGS: CalibrationConfig[] = [
|
||||
{
|
||||
id: 'arm', priority: 1, cases: ['arm-benchmark (skill-e2e-arm-benchmark.test.ts)'], model: ARM_JUDGE_MODEL,
|
||||
build: inputs => buildArmJudgePrompt(inputs.task!.trimEnd(), inputs.diff!),
|
||||
oldOptions: {},
|
||||
newOptions: { jsonSchema: ARM_JUDGE_SCHEMA },
|
||||
validate: value => { parseArmJudgeResponse(value); },
|
||||
verdict: samples => {
|
||||
const scores = samples.map(sample => parseArmJudgeResponse(sample).over_engineering).sort((a, b) => a - b);
|
||||
return scores[Math.floor(scores.length / 2)]! <= 1 ? 'pass' : 'fail';
|
||||
},
|
||||
estimatedOutputTokens: 300,
|
||||
},
|
||||
{
|
||||
id: 'qa-workflow', priority: 2, cases: ['qa/SKILL.md workflow'], model: CLAUDE_FRONTIER_EVAL_MODEL,
|
||||
build: inputs => buildQaWorkflowJudgePrompt(inputs.section!),
|
||||
oldOptions: {}, newOptions: { jsonSchema: JUDGE_SCORE_SCHEMA },
|
||||
...scoreJudge(QA_FLOORS), estimatedOutputTokens: 1200,
|
||||
},
|
||||
{
|
||||
id: 'qa-health-rubric', priority: 3, cases: ['qa/SKILL.md health rubric'], model: CLAUDE_FRONTIER_EVAL_MODEL,
|
||||
build: inputs => buildQaHealthRubricJudgePrompt(inputs.section!),
|
||||
oldOptions: {}, newOptions: { jsonSchema: JUDGE_SCORE_SCHEMA },
|
||||
...scoreJudge(QA_FLOORS), estimatedOutputTokens: 1200,
|
||||
},
|
||||
{
|
||||
id: 'qa-anti-refusal', priority: 4, cases: ['qa/SKILL.md anti-refusal'], model: CLAUDE_FRONTIER_EVAL_MODEL,
|
||||
build: inputs => buildQaAntiRefusalJudgePrompt(inputs.diffAwareSection!, inputs.rulesSection!),
|
||||
oldOptions: {}, newOptions: { jsonSchema: QA_ANTI_REFUSAL_JUDGE_SCHEMA },
|
||||
validate: value => requireFields(value, ['confidence'], ['would_browse']),
|
||||
verdict: samples => judgePanelMajority<string>(samples as Sample[], 'would_browse')
|
||||
&& judgePanelMean<string>(samples as Sample[], ['confidence']).confidence >= 4 ? 'pass' : 'fail',
|
||||
estimatedOutputTokens: 1200,
|
||||
},
|
||||
{
|
||||
id: 'cross-skill', priority: 5, cases: ['cross-skill greptile consistency'], model: CLAUDE_FRONTIER_EVAL_MODEL,
|
||||
build: inputs => buildCrossSkillConsistencyJudgePrompt(inputs.collected!),
|
||||
oldOptions: {}, newOptions: { jsonSchema: CROSS_SKILL_CONSISTENCY_JUDGE_SCHEMA },
|
||||
validate: value => requireFields(value, ['score'], ['consistent']),
|
||||
verdict: samples => judgePanelMajority<string>(samples as Sample[], 'consistent')
|
||||
&& judgePanelMean<string>(samples as Sample[], ['score']).score >= 4 ? 'pass' : 'fail',
|
||||
estimatedOutputTokens: 1200,
|
||||
},
|
||||
{
|
||||
id: 'voice', priority: 6, cases: ['voice directive tone'], model: CLAUDE_FRONTIER_EVAL_MODEL,
|
||||
build: inputs => buildVoiceDirectiveJudgePrompt(inputs.voiceSection!),
|
||||
oldOptions: {}, newOptions: { jsonSchema: VOICE_DIRECTIVE_JUDGE_SCHEMA },
|
||||
validate: value => requireFields(value, VOICE_DIRECTIVE_DIMENSIONS),
|
||||
verdict: samples => meets(judgePanelMean<string>(samples as Sample[], VOICE_DIRECTIVE_DIMENSIONS),
|
||||
Object.fromEntries(VOICE_DIRECTIVE_DIMENSIONS.map(key => [key, 4]))),
|
||||
estimatedOutputTokens: 1200,
|
||||
},
|
||||
workflow('workflow-default', 7, ['document-release/SKILL.md workflow', 'plan-ceo-review/SKILL.md modes', 'plan-eng-review/SKILL.md sections',
|
||||
'plan-design-review/SKILL.md passes', 'design-review/SKILL.md fix loop', 'design-consultation/SKILL.md research',
|
||||
'land-and-deploy/SKILL.md workflow', 'canary/SKILL.md monitoring loop', 'benchmark/SKILL.md perf collection',
|
||||
'setup-deploy/SKILL.md platform setup', 'sync-gbrain/SKILL.md read-only readiness', 'retro/SKILL.md instructions',
|
||||
'qa-only/SKILL.md workflow', 'gstack-upgrade/SKILL.md upgrade flow']),
|
||||
];
|
||||
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* W2 judge calibration harness (comparison 1: schema transport, prose unchanged).
|
||||
*
|
||||
* For every corpus item of a request configuration it draws three independent
|
||||
* 3-sample panels: two with the current request ("old-a", "old-b") and one with
|
||||
* the schema-transport request ("new"). Each sample is one direct callJudge
|
||||
* call. Calibration never goes through judgePanel or the workflow passing-panel
|
||||
* cache: a replayed panel would make old-vs-old flips read zero.
|
||||
*
|
||||
* Each sample records a verdict-bearing value or an error kind (refusal,
|
||||
* truncation, parse, schema, transport), its latency and its priced usage.
|
||||
* Errors never count as verdicts; flip rates use items where both panels
|
||||
* completed (all three samples valid).
|
||||
*
|
||||
* Usage:
|
||||
* bun run scripts/judge-calibration.ts manifest [--budget 90]
|
||||
* bun run scripts/judge-calibration.ts run --config <id> --budget <usd> [--concurrency 6]
|
||||
* bun run scripts/judge-calibration.ts resume --config <id> --budget <usd> (only never-dispatched planned calls)
|
||||
* bun run scripts/judge-calibration.ts analyze --config <id>
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { callJudge, JudgeRefusalError, type CallJudgeOptions, type JudgeResponseMeta } from '../test/helpers/llm-judge';
|
||||
import { JUDGE_MS } from '../test/helpers/eval-budgets';
|
||||
import { estimateCostUsd } from '../test/helpers/pricing';
|
||||
|
||||
export const PANEL_SAMPLES = 3;
|
||||
export const PHASES = ['old-a', 'old-b', 'new'] as const;
|
||||
export type Phase = typeof PHASES[number];
|
||||
export type Verdict = 'pass' | 'fail';
|
||||
export type ErrorKind = 'refusal' | 'truncation' | 'parse' | 'schema' | 'transport';
|
||||
export const LATENCY_CEILING_MS = 0.8 * JUDGE_MS;
|
||||
export const CORPUS_ROOT = path.join(import.meta.dir, '..', 'test', 'fixtures', 'judge-calibration');
|
||||
|
||||
export interface CorpusItem {
|
||||
id: string;
|
||||
category: 'pass' | 'fail' | 'adjacent';
|
||||
expected: Verdict;
|
||||
split: 'dev' | 'heldout';
|
||||
rationale: string;
|
||||
inputs: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CalibrationConfig {
|
||||
id: string;
|
||||
/** Dispatch order when the priced manifest exceeds the budget (armJudge, inline judges, workflow by case count). */
|
||||
priority: number;
|
||||
/** Eval cases that send this request configuration. */
|
||||
cases: string[];
|
||||
model: string;
|
||||
build(inputs: Record<string, string>): string;
|
||||
/** Today's request options. */
|
||||
oldOptions: CallJudgeOptions;
|
||||
/** Comparison (1): today's options plus the JSON schema; prose unchanged. */
|
||||
newOptions: CallJudgeOptions;
|
||||
/** Throws when production would reject the sample's shape. */
|
||||
validate(value: unknown): void;
|
||||
verdict(samples: unknown[]): Verdict;
|
||||
/** Manifest pricing estimate per call (output includes adaptive thinking). */
|
||||
estimatedOutputTokens: number;
|
||||
}
|
||||
|
||||
export interface SampleRecord {
|
||||
config: string;
|
||||
item: string;
|
||||
split: CorpusItem['split'];
|
||||
phase: Phase;
|
||||
sample: number;
|
||||
status: 'ok' | 'error';
|
||||
error_kind?: ErrorKind;
|
||||
error?: string;
|
||||
value?: unknown;
|
||||
latency_ms: number;
|
||||
stop_reason: string | null;
|
||||
usage: { input_tokens: number; output_tokens: number } | null;
|
||||
cost_usd: number;
|
||||
}
|
||||
|
||||
export type JudgeCall = (prompt: string, model: string, opts: CallJudgeOptions) => Promise<unknown>;
|
||||
|
||||
export function loadCorpus(configId: string, root = CORPUS_ROOT): CorpusItem[] {
|
||||
const dir = path.join(root, configId);
|
||||
const corpus = JSON.parse(fs.readFileSync(path.join(dir, 'corpus.json'), 'utf8'));
|
||||
return corpus.items.map((item: any) => ({
|
||||
...item,
|
||||
inputs: Object.fromEntries(Object.entries(item.inputs as Record<string, string | { file: string }>).map(([key, value]) =>
|
||||
[key, typeof value === 'string' ? value : fs.readFileSync(path.join(dir, value.file), 'utf8')])),
|
||||
}));
|
||||
}
|
||||
|
||||
export function classifyError(error: unknown): ErrorKind {
|
||||
if (error instanceof JudgeRefusalError) return 'refusal';
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/truncated at max_tokens|stop_reason=max_tokens/.test(message)) return 'truncation';
|
||||
if (error instanceof SyntaxError || /non-JSON|did not complete/.test(message)) return 'parse';
|
||||
return 'transport';
|
||||
}
|
||||
|
||||
/** The fixed request identity: what must not move between dispatch and analysis. */
|
||||
export function requestHash(config: CalibrationConfig, phase: Phase): string {
|
||||
const { onResponse: _ignored, signal: _signal, ...options } = phase === 'new' ? config.newOptions : config.oldOptions;
|
||||
return createHash('sha256').update(JSON.stringify({ model: config.model, options })).digest('hex');
|
||||
}
|
||||
|
||||
export interface PlannedCall { item: CorpusItem; phase: Phase; sample: number }
|
||||
|
||||
/** Every paid call for one configuration, interleaved by a fixed seed so phases share time-of-day drift. */
|
||||
export function planCalls(items: CorpusItem[], split: CorpusItem['split']): PlannedCall[] {
|
||||
const calls = items.filter(item => item.split === split)
|
||||
.flatMap(item => PHASES.flatMap(phase => Array.from({ length: PANEL_SAMPLES }, (_, sample) => ({ item, phase, sample }))));
|
||||
let seed = 0x5eed;
|
||||
const random = () => (seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31;
|
||||
for (let i = calls.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(random() * (i + 1));
|
||||
[calls[i], calls[j]] = [calls[j]!, calls[i]!];
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
/** Planned calls never dispatched (a budget or infrastructure stop); completing them is not a resample. */
|
||||
export function undispatched(calls: PlannedCall[], records: SampleRecord[]): PlannedCall[] {
|
||||
const done = new Set(records.map(record => `${record.item}/${record.phase}/${record.sample}`));
|
||||
return calls.filter(call => !done.has(`${call.item.id}/${call.phase}/${call.sample}`));
|
||||
}
|
||||
|
||||
export async function runSample(config: CalibrationConfig, planned: PlannedCall, call: JudgeCall = callJudge as JudgeCall,
|
||||
clock: () => number = () => performance.now()): Promise<SampleRecord> {
|
||||
let meta: JudgeResponseMeta | null = null;
|
||||
const options = { ...(planned.phase === 'new' ? config.newOptions : config.oldOptions), onResponse: (m: JudgeResponseMeta) => { meta = m; } };
|
||||
const base = { config: config.id, item: planned.item.id, split: planned.item.split, phase: planned.phase, sample: planned.sample };
|
||||
const started = clock();
|
||||
let value: unknown;
|
||||
let failure: { kind: ErrorKind; message: string } | null = null;
|
||||
try {
|
||||
value = await call(config.build(planned.item.inputs), config.model, options);
|
||||
try { config.validate(value); }
|
||||
catch (error) { failure = { kind: 'schema', message: error instanceof Error ? error.message : String(error) }; }
|
||||
} catch (error) {
|
||||
failure = { kind: classifyError(error), message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
const latency_ms = Math.round(clock() - started);
|
||||
const response = meta as JudgeResponseMeta | null;
|
||||
const usage = response?.usage ?? null;
|
||||
const cost_usd = usage ? estimateCostUsd({ input: usage.input_tokens, output: usage.output_tokens }, config.model) : 0;
|
||||
const shared = { latency_ms, stop_reason: response?.stop_reason ?? null, usage, cost_usd };
|
||||
return failure
|
||||
? { ...base, status: 'error', error_kind: failure.kind, error: failure.message.slice(0, 500), ...shared }
|
||||
: { ...base, status: 'ok', value, ...shared };
|
||||
}
|
||||
|
||||
export interface RunOptions {
|
||||
budgetUsd: number;
|
||||
concurrency?: number;
|
||||
call?: JudgeCall;
|
||||
clock?: () => number;
|
||||
onRecord?: (record: SampleRecord) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch planned calls with bounded concurrency. Stops dispatching when the
|
||||
* actual spend plus one in-flight estimate per open slot would pass the budget,
|
||||
* or after two consecutive transport failures (infrastructure, not a verdict).
|
||||
*/
|
||||
export async function runCalls(config: CalibrationConfig, calls: PlannedCall[], opts: RunOptions): Promise<{ records: SampleRecord[]; stopped: string | null }> {
|
||||
const records: SampleRecord[] = [];
|
||||
const perCall = estimateCostUsd({ input: 0, output: config.estimatedOutputTokens }, config.model);
|
||||
let spent = 0, next = 0, consecutiveTransport = 0, inFlight = 0;
|
||||
let stopped: string | null = null;
|
||||
const worker = async () => {
|
||||
while (!stopped && next < calls.length) {
|
||||
if (spent + (inFlight + 1) * perCall * 2 > opts.budgetUsd) { stopped = `budget: spent $${spent.toFixed(2)} of $${opts.budgetUsd.toFixed(2)}`; break; }
|
||||
const planned = calls[next++]!;
|
||||
inFlight++;
|
||||
const record = await runSample(config, planned, opts.call, opts.clock);
|
||||
inFlight--;
|
||||
spent += record.cost_usd;
|
||||
consecutiveTransport = record.error_kind === 'transport' ? consecutiveTransport + 1 : 0;
|
||||
records.push(record);
|
||||
opts.onRecord?.(record);
|
||||
if (consecutiveTransport >= 2) stopped = `infrastructure: two consecutive transport failures (${record.error})`;
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.max(1, opts.concurrency ?? 6) }, worker));
|
||||
return { records, stopped };
|
||||
}
|
||||
|
||||
export interface PanelResult { verdict: Verdict | null; errors: number }
|
||||
|
||||
export function panels(config: CalibrationConfig, records: SampleRecord[]): Map<string, Record<Phase, PanelResult>> {
|
||||
const byItem = new Map<string, Record<Phase, PanelResult>>();
|
||||
for (const item of new Set(records.map(record => record.item))) {
|
||||
const entry = {} as Record<Phase, PanelResult>;
|
||||
for (const phase of PHASES) {
|
||||
const samples = records.filter(record => record.item === item && record.phase === phase);
|
||||
const ok = samples.filter(record => record.status === 'ok');
|
||||
entry[phase] = { verdict: ok.length === PANEL_SAMPLES && samples.length === PANEL_SAMPLES ? config.verdict(ok.map(record => record.value)) : null,
|
||||
errors: samples.length - ok.length };
|
||||
}
|
||||
byItem.set(item, entry);
|
||||
}
|
||||
return byItem;
|
||||
}
|
||||
|
||||
function p95(values: number[]): number | null {
|
||||
if (values.length === 0) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(0.95 * sorted.length) - 1)]!;
|
||||
}
|
||||
|
||||
export interface ComparisonSummary {
|
||||
items: number;
|
||||
old_flips: { flips: number; compared: number };
|
||||
new_flips: { flips: number; compared: number };
|
||||
agreement: Record<Phase, number>;
|
||||
false_passes: Record<Phase, string[]>;
|
||||
new_false_passes: string[];
|
||||
}
|
||||
|
||||
export function compare(config: CalibrationConfig, items: CorpusItem[], records: SampleRecord[]): ComparisonSummary {
|
||||
const byItem = panels(config, records);
|
||||
const flips = (a: Phase, b: Phase) => {
|
||||
const both = items.map(item => byItem.get(item.id)).filter(entry => entry?.[a].verdict && entry[b].verdict);
|
||||
return { flips: both.filter(entry => entry![a].verdict !== entry![b].verdict).length, compared: both.length };
|
||||
};
|
||||
const agreement = Object.fromEntries(PHASES.map(phase =>
|
||||
[phase, items.filter(item => byItem.get(item.id)?.[phase].verdict === item.expected).length])) as Record<Phase, number>;
|
||||
const falsePasses = Object.fromEntries(PHASES.map(phase =>
|
||||
[phase, items.filter(item => item.expected === 'fail' && byItem.get(item.id)?.[phase].verdict === 'pass').map(item => item.id)])) as Record<Phase, string[]>;
|
||||
return { items: items.length, old_flips: flips('old-a', 'old-b'), new_flips: flips('new', 'old-a'), agreement,
|
||||
false_passes: falsePasses, new_false_passes: falsePasses.new.filter(id => !falsePasses['old-a'].includes(id)) };
|
||||
}
|
||||
|
||||
export interface CalibrationResult {
|
||||
config: string;
|
||||
model: string;
|
||||
request_hash: Record<'old' | 'new', string>;
|
||||
complete: boolean;
|
||||
stopped: string | null;
|
||||
calls: number;
|
||||
cost_usd: number;
|
||||
errors: Record<'old' | 'new', Record<ErrorKind, number> & { samples: number; rate: number }>;
|
||||
latency_p95_ms: Record<'old' | 'new', number | null>;
|
||||
all: ComparisonSummary;
|
||||
heldout: ComparisonSummary;
|
||||
landing: { lands: boolean; reasons: string[]; strict_no_flip_increase: boolean };
|
||||
prose_step_triggered: { triggered: boolean; reasons: string[] };
|
||||
}
|
||||
|
||||
export function analyze(config: CalibrationConfig, items: CorpusItem[], records: SampleRecord[], stopped: string | null = null): CalibrationResult {
|
||||
const prompt = (phase: Phase) => phase === 'new' ? 'new' : 'old';
|
||||
const errors = Object.fromEntries((['old', 'new'] as const).map(which => {
|
||||
const samples = records.filter(record => prompt(record.phase) === which);
|
||||
const counts = Object.fromEntries((['refusal', 'truncation', 'parse', 'schema', 'transport'] as const)
|
||||
.map(kind => [kind, samples.filter(record => record.error_kind === kind).length])) as Record<ErrorKind, number>;
|
||||
const errored = samples.filter(record => record.status === 'error').length;
|
||||
return [which, { ...counts, samples: samples.length, rate: samples.length ? errored / samples.length : 0 }];
|
||||
})) as CalibrationResult['errors'];
|
||||
const latency = Object.fromEntries((['old', 'new'] as const).map(which =>
|
||||
[which, p95(records.filter(record => prompt(record.phase) === which && record.status === 'ok').map(record => record.latency_ms))])) as CalibrationResult['latency_p95_ms'];
|
||||
const expectedCalls = items.length * PHASES.length * PANEL_SAMPLES;
|
||||
const complete = records.length === expectedCalls && !stopped;
|
||||
const all = compare(config, items, records);
|
||||
const heldout = compare(config, items.filter(item => item.split === 'heldout'), records);
|
||||
|
||||
const reasons: string[] = [];
|
||||
if (!complete) reasons.push(`incomplete: ${records.length}/${expectedCalls} calls${stopped ? ` (${stopped})` : ''}`);
|
||||
if (all.new_flips.flips > all.old_flips.flips + 1) reasons.push(`new flips ${all.new_flips.flips}/${all.new_flips.compared} > old flips ${all.old_flips.flips}/${all.old_flips.compared} + 1`);
|
||||
if (all.agreement.new < all.agreement['old-a']) reasons.push(`expected-verdict agreement dropped: new ${all.agreement.new} < old ${all.agreement['old-a']}`);
|
||||
if (all.new_false_passes.length > 0) reasons.push(`new false passes: ${all.new_false_passes.join(', ')}`);
|
||||
if (latency.new !== null && latency.new > LATENCY_CEILING_MS) reasons.push(`new p95 latency ${latency.new} ms > ${LATENCY_CEILING_MS} ms (80% of JUDGE_MS)`);
|
||||
|
||||
const trigger: string[] = [];
|
||||
if (records.some(record => record.phase === 'new' && record.status === 'error')) trigger.push('new prompt has errored samples');
|
||||
if (reasons.length > 0) trigger.push('a landing condition failed');
|
||||
if (all.old_flips.flips >= 3) trigger.push(`old-vs-old flips ${all.old_flips.flips} >= 3`);
|
||||
|
||||
return {
|
||||
config: config.id, model: config.model,
|
||||
request_hash: { old: requestHash(config, 'old-a'), new: requestHash(config, 'new') },
|
||||
complete, stopped, calls: records.length,
|
||||
cost_usd: +records.reduce((sum, record) => sum + record.cost_usd, 0).toFixed(4),
|
||||
errors, latency_p95_ms: latency, all, heldout,
|
||||
landing: { lands: reasons.length === 0, reasons, strict_no_flip_increase: all.new_flips.flips <= all.old_flips.flips },
|
||||
prose_step_triggered: { triggered: trigger.length > 0, reasons: trigger },
|
||||
};
|
||||
}
|
||||
|
||||
export interface ManifestEntry {
|
||||
config: string;
|
||||
priority: number;
|
||||
cases: number;
|
||||
model: string;
|
||||
items: number;
|
||||
calls: number;
|
||||
input_tokens: number;
|
||||
estimated_output_tokens_per_call: number;
|
||||
estimated_cost_usd: number;
|
||||
}
|
||||
|
||||
/** Price every planned call before dispatch; count_tokens is free. */
|
||||
export async function priceManifest(configs: CalibrationConfig[], countTokens: (model: string, prompt: string) => Promise<number>,
|
||||
root = CORPUS_ROOT): Promise<ManifestEntry[]> {
|
||||
const entries: ManifestEntry[] = [];
|
||||
for (const config of [...configs].sort((a, b) => a.priority - b.priority)) {
|
||||
if (!fs.existsSync(path.join(root, config.id, 'corpus.json'))) continue;
|
||||
const items = loadCorpus(config.id, root);
|
||||
let input = 0;
|
||||
for (const item of items) input += await countTokens(config.model, config.build(item.inputs)) * PHASES.length * PANEL_SAMPLES;
|
||||
const calls = items.length * PHASES.length * PANEL_SAMPLES;
|
||||
entries.push({ config: config.id, priority: config.priority, cases: config.cases.length, model: config.model, items: items.length, calls,
|
||||
input_tokens: input, estimated_output_tokens_per_call: config.estimatedOutputTokens,
|
||||
estimated_cost_usd: estimateCostUsd({ input, output: calls * config.estimatedOutputTokens }, config.model) });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function flag(args: string[], name: string): string | undefined {
|
||||
const index = args.indexOf(`--${name}`);
|
||||
return index >= 0 ? args[index + 1] : undefined;
|
||||
}
|
||||
|
||||
async function main(args: string[]): Promise<void> {
|
||||
// The per-judge table (builders, schemas, verdict rules) is committed separately from this harness.
|
||||
const { CALIBRATION_CONFIGS } = await import(path.join(import.meta.dir, 'judge-calibration-configs.ts')) as { CALIBRATION_CONFIGS: CalibrationConfig[] };
|
||||
const command = args[0];
|
||||
if (command === 'manifest') {
|
||||
const client = new Anthropic();
|
||||
const entries = await priceManifest(CALIBRATION_CONFIGS, async (model, prompt) =>
|
||||
(await client.messages.countTokens({ model, messages: [{ role: 'user', content: prompt }] })).input_tokens);
|
||||
const budget = Number(flag(args, 'budget') ?? 90);
|
||||
let running = 0;
|
||||
const manifest = { generated_at: new Date().toISOString(), budget_usd: budget,
|
||||
entries: entries.map(entry => ({ ...entry, cumulative_usd: +(running += entry.estimated_cost_usd).toFixed(4), within_budget: running <= budget })) };
|
||||
fs.writeFileSync(path.join(CORPUS_ROOT, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
console.table(manifest.entries);
|
||||
return;
|
||||
}
|
||||
const config = CALIBRATION_CONFIGS.find(candidate => candidate.id === flag(args, 'config'));
|
||||
if (!config) throw new Error(`unknown --config; expected one of ${CALIBRATION_CONFIGS.map(candidate => candidate.id).join(', ')}`);
|
||||
const items = loadCorpus(config.id);
|
||||
const runsDir = path.join(CORPUS_ROOT, config.id, 'runs');
|
||||
const samplesPath = path.join(runsDir, 'comparison-1.samples.jsonl');
|
||||
const resultPath = path.join(runsDir, 'comparison-1.result.json');
|
||||
if (command === 'run' || command === 'resume') {
|
||||
// resume dispatches only planned calls a stop left undispatched; recorded samples are never redrawn.
|
||||
if (command === 'run' && fs.existsSync(samplesPath)) throw new Error(`${samplesPath} exists; calibration runs are never repeated`);
|
||||
if (command === 'resume' && !fs.existsSync(samplesPath)) throw new Error(`${samplesPath} is missing; nothing to resume`);
|
||||
const prior: SampleRecord[] = command === 'resume' ? fs.readFileSync(samplesPath, 'utf8').trim().split('\n').map(line => JSON.parse(line)) : [];
|
||||
fs.mkdirSync(runsDir, { recursive: true });
|
||||
const budgetUsd = Number(flag(args, 'budget'));
|
||||
if (!Number.isFinite(budgetUsd) || budgetUsd <= 0) throw new Error('--budget <usd> is required');
|
||||
const concurrency = Number(flag(args, 'concurrency') ?? 6);
|
||||
const onRecord = (record: SampleRecord) => fs.appendFileSync(samplesPath, JSON.stringify(record) + '\n');
|
||||
let spent = 0, stopped: string | null = null;
|
||||
// Development items first; the held-out third is dispatched only after, with the same frozen requests.
|
||||
for (const split of ['dev', 'heldout'] as const) {
|
||||
const outcome = await runCalls(config, undispatched(planCalls(items, split), prior), { budgetUsd: budgetUsd - spent, concurrency, onRecord });
|
||||
spent += outcome.records.reduce((sum, record) => sum + record.cost_usd, 0);
|
||||
console.log(`[calibration] ${config.id} ${split}: ${outcome.records.length} calls, $${spent.toFixed(2)} cumulative${outcome.stopped ? `, stopped: ${outcome.stopped}` : ''}`);
|
||||
if ((stopped = outcome.stopped)) break;
|
||||
}
|
||||
fs.writeFileSync(path.join(runsDir, 'comparison-1.stop.json'), JSON.stringify({ stopped }) + '\n');
|
||||
} else if (command !== 'analyze') {
|
||||
throw new Error(`unknown command ${command}; expected manifest, run, resume or analyze`);
|
||||
}
|
||||
const records: SampleRecord[] = fs.readFileSync(samplesPath, 'utf8').trim().split('\n').map(line => JSON.parse(line));
|
||||
const stopPath = path.join(runsDir, 'comparison-1.stop.json');
|
||||
const stopped = fs.existsSync(stopPath) ? JSON.parse(fs.readFileSync(stopPath, 'utf8')).stopped : null;
|
||||
const result = analyze(config, items, records, stopped);
|
||||
fs.writeFileSync(resultPath, JSON.stringify(result, null, 2) + '\n');
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main(process.argv.slice(2)).catch(error => { console.error(error); process.exit(1); });
|
||||
}
|
||||
@@ -85,6 +85,7 @@ export const CASE_TEST_NAMES: Record<string, string> = {
|
||||
'cso-full-audit': '/cso persists supported tenant-boundary findings with redacted evidence',
|
||||
'cso-diff-mode': '/cso --diff records its base and investigates changed security paths',
|
||||
'cso-infra-scope': '/cso --infra finds an attacker-to-credential execution path',
|
||||
'plan-ceo-review-plan-mode': 'first terminal outcome is asked (Step 0 fires before any plan write)',
|
||||
};
|
||||
|
||||
export const CASE_KEY_SEPARATOR = '#';
|
||||
@@ -259,6 +260,7 @@ export const CODEX_CI_FILES: readonly string[] = [
|
||||
'test/codex-e2e-recommendation-substance.test.ts',
|
||||
'test/skill-e2e-outside-voice.test.ts',
|
||||
'test/skill-e2e-outside-plan-disabled.test.ts',
|
||||
'test/skill-e2e-safety-codex-boundary.test.ts',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -101,3 +101,79 @@ export function validateModel(input: string): string | null {
|
||||
if ((ALL_MODEL_NAMES as readonly string[]).includes(input)) return null;
|
||||
return `'${input}' is not a known model. Use ${ALL_MODEL_NAMES.join(', ')}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlays `./setup --claude-model <id>` may select for the Claude host. The
|
||||
* list is the Claude subset of ALL_MODEL_NAMES; a new Claude overlay joins
|
||||
* both lists.
|
||||
*/
|
||||
export const CLAUDE_OVERLAY_MODELS = ['claude', 'opus-4-7', 'opus-4-8', 'sonnet-5', 'fable-5'] as const;
|
||||
|
||||
export type ClaudeOverlay = (typeof CLAUDE_OVERLAY_MODELS)[number];
|
||||
|
||||
export interface ClaudeOverlayError {
|
||||
kind: 'alias' | 'unknown' | 'non-claude';
|
||||
problem: string;
|
||||
cause: string;
|
||||
fix: string;
|
||||
}
|
||||
|
||||
export type ClaudeOverlayResult =
|
||||
| { overlay: ClaudeOverlay; generic: boolean }
|
||||
| { error: ClaudeOverlayError };
|
||||
|
||||
const CLAUDE_CODE_ALIASES = ['opus', 'sonnet', 'haiku', 'default'];
|
||||
|
||||
/**
|
||||
* Map a `--claude-model` value to the overlay setup renders. `resolveModel`
|
||||
* does the family matching (bracketed context suffixes, dated and `-latest`
|
||||
* snapshots); this adds the Claude-only allowlist and the errors setup prints.
|
||||
* `generic` is true when a specific model ID has no overlay of its own and
|
||||
* uses the generic `claude` overlay (for example `claude-opus-5-5`).
|
||||
*/
|
||||
export function resolveClaudeOverlay(id: string): ClaudeOverlayResult {
|
||||
const s = id.trim().toLowerCase().replace(/\[[^\]]*\]$/, '').trim();
|
||||
const problem = `${JSON.stringify(id)} is not a Claude model ID that gstack can map to a skill overlay.`;
|
||||
if (CLAUDE_CODE_ALIASES.includes(s)) {
|
||||
return { error: {
|
||||
kind: 'alias',
|
||||
problem,
|
||||
cause: `"${s}" is a Claude Code alias whose model changes over time, so it names no fixed overlay.`,
|
||||
fix: 'Pass a full model ID, for example ./setup --claude-model claude-opus-4-8, or ./setup --claude-model claude for the generic overlay.',
|
||||
} };
|
||||
}
|
||||
const family = resolveModel(s);
|
||||
if (!family) {
|
||||
return { error: {
|
||||
kind: 'unknown',
|
||||
problem,
|
||||
cause: 'It does not match any model family gstack knows.',
|
||||
fix: 'Pass a Claude model ID such as claude-opus-4-8 or claude-sonnet-5, or claude for the generic overlay.',
|
||||
} };
|
||||
}
|
||||
if (!(CLAUDE_OVERLAY_MODELS as readonly string[]).includes(family)) {
|
||||
return { error: {
|
||||
kind: 'non-claude',
|
||||
problem,
|
||||
cause: `It belongs to the ${family} family, and --claude-model selects Claude overlays only.`,
|
||||
fix: `For Codex skills use ./setup --host codex --model ${id}. For Claude, pass a Claude model ID such as claude-opus-4-8.`,
|
||||
} };
|
||||
}
|
||||
return { overlay: family as ClaudeOverlay, generic: family === 'claude' && s !== 'claude' };
|
||||
}
|
||||
|
||||
// `bun scripts/models.ts claude-overlay <id>` — setup's bridge to the table
|
||||
// above. Prints "<overlay>\t<generic 0|1>" or the error lines on stderr (exit 1).
|
||||
if (import.meta.main) {
|
||||
const [command, id] = process.argv.slice(2);
|
||||
if (command !== 'claude-overlay' || id === undefined) {
|
||||
console.error('Usage: bun scripts/models.ts claude-overlay <claude-model-id>');
|
||||
process.exit(2);
|
||||
}
|
||||
const result = resolveClaudeOverlay(id);
|
||||
if ('error' in result) {
|
||||
console.error(`${result.error.problem}\n${result.error.cause}\n${result.error.fix}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`${result.overlay}\t${result.generic ? 1 : 0}`);
|
||||
}
|
||||
@@ -66,6 +66,12 @@
|
||||
"test/skill-e2e-review-army.test.ts#review-army-quality-score": 86836,
|
||||
"test/skill-e2e-review-attribution.test.ts": 77592,
|
||||
"test/skill-e2e-review.test.ts": 136132,
|
||||
"test/skill-e2e-safety-codex-boundary.test.ts": 1000,
|
||||
"test/skill-e2e-safety-codex-consult.test.ts": 82940,
|
||||
"test/skill-e2e-safety-design-risk.test.ts": 1000,
|
||||
"test/skill-e2e-safety-ios-demo.test.ts": 1000,
|
||||
"test/skill-e2e-safety-pair-agent.test.ts": 39860,
|
||||
"test/skill-e2e-safety-ship-evidence.test.ts": 106130,
|
||||
"test/skill-e2e-session-intelligence.test.ts": 55332,
|
||||
"test/skill-e2e-shared-libs-paths.test.ts": 680000,
|
||||
"test/skill-e2e-shared-libs-paths.test.ts#shared-libs-review-index-flags": 190202,
|
||||
@@ -154,6 +160,7 @@
|
||||
"test/skill-e2e-office-hours.test.ts": 136970,
|
||||
"test/skill-e2e-outside-plan-disabled.test.ts": 54255,
|
||||
"test/skill-e2e-outside-voice.test.ts": 186881,
|
||||
"test/skill-e2e-overlay-harness-claude-dedicated-tools-vs-bash-opus-5-5.test.ts": 56080,
|
||||
"test/skill-e2e-overlay-harness-claude-dedicated-tools-vs-bash-sonnet.test.ts": 85282,
|
||||
"test/skill-e2e-overlay-harness-claude-dedicated-tools-vs-bash.test.ts": 80465,
|
||||
"test/skill-e2e-overlay-harness-opus-4-7-effort-match-trivial.test.ts": 49282,
|
||||
@@ -187,6 +194,12 @@
|
||||
"test/skill-e2e-review-army.test.ts#review-army-simplification-precision": 23884,
|
||||
"test/skill-e2e-review-attribution.test.ts": 1000,
|
||||
"test/skill-e2e-review.test.ts": 179362,
|
||||
"test/skill-e2e-safety-codex-boundary.test.ts": 100310,
|
||||
"test/skill-e2e-safety-codex-consult.test.ts": 1000,
|
||||
"test/skill-e2e-safety-design-risk.test.ts": 52700,
|
||||
"test/skill-e2e-safety-ios-demo.test.ts": 53270,
|
||||
"test/skill-e2e-safety-pair-agent.test.ts": 1000,
|
||||
"test/skill-e2e-safety-ship-evidence.test.ts": 1000,
|
||||
"test/skill-e2e-session-intelligence.test.ts": 1000,
|
||||
"test/skill-e2e-setup-gbrain-bad-token.test.ts": 42912,
|
||||
"test/skill-e2e-setup-gbrain-path4-local-pglite.test.ts": 165743,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Prompt-audit manifest: the files a contributor feeds to Anthropic's
|
||||
* `/claude-api prompt-audit` (a Claude Code skill, run in your own Claude Code
|
||||
* session) at each frontier-model release, cut into slices small enough for
|
||||
* one audit pass each. Procedure: CONTRIBUTING.md "Prompt audit at each
|
||||
* frontier-model release".
|
||||
*
|
||||
* Slices, in order: shared instructions every skill reads (CLAUDE.md, model
|
||||
* overlays, preamble resolvers), the other resolvers, the skill templates
|
||||
* (a skill's SKILL.md.tmpl and sections stay in one slice), the code that
|
||||
* builds model requests, and the tests that pin skill wording (they read a
|
||||
* template, skill or resolver and assert a literal of 40+ characters).
|
||||
*
|
||||
* Usage: bun run audit:manifest [--json] [--budget-kb <n>]
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const DEFAULT_BUDGET_KB = 200;
|
||||
|
||||
export interface AuditSlice { id: string; group: string; files: string[]; bytes: number }
|
||||
|
||||
function walk(dir: string, keep: (rel: string) => boolean): string[] {
|
||||
const out: string[] = [];
|
||||
const visit = (d: string) => {
|
||||
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
||||
const p = join(d, entry.name);
|
||||
const rel = relative(ROOT, p).replaceAll('\\', '/');
|
||||
if (entry.isDirectory()) {
|
||||
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') visit(p);
|
||||
} else if (keep(rel)) {
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(dir);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
const size = (rel: string) => statSync(join(ROOT, rel)).size;
|
||||
const read = (rel: string) => readFileSync(join(ROOT, rel), 'utf8');
|
||||
|
||||
/** Pack units (each a list of files that must stay together) into slices under the budget. */
|
||||
function pack(group: string, units: string[][], budget: number): Omit<AuditSlice, 'id'>[] {
|
||||
const slices: Omit<AuditSlice, 'id'>[] = [];
|
||||
let current: Omit<AuditSlice, 'id'> | null = null;
|
||||
for (const unit of units) {
|
||||
const bytes = unit.reduce((sum, f) => sum + size(f), 0);
|
||||
if (!current || (current.bytes > 0 && current.bytes + bytes > budget)) {
|
||||
current = { group, files: [], bytes: 0 };
|
||||
slices.push(current);
|
||||
}
|
||||
current.files.push(...unit);
|
||||
current.bytes += bytes;
|
||||
}
|
||||
return slices;
|
||||
}
|
||||
|
||||
export function buildManifest(budgetKb = DEFAULT_BUDGET_KB): AuditSlice[] {
|
||||
const budget = budgetKb * 1024;
|
||||
const preamble = walk(join(ROOT, 'scripts/resolvers/preamble'), rel => rel.endsWith('.ts'));
|
||||
const shared = ['CLAUDE.md', ...walk(join(ROOT, 'model-overlays'), rel => rel.endsWith('.md')), ...preamble];
|
||||
const resolvers = walk(join(ROOT, 'scripts/resolvers'), rel => rel.endsWith('.ts') && !preamble.includes(rel));
|
||||
|
||||
const templates = walk(ROOT, rel => rel.endsWith('.tmpl') && !rel.startsWith('test/'));
|
||||
const bySkill = new Map<string, string[]>();
|
||||
for (const rel of templates) {
|
||||
const skill = rel.includes('/') ? rel.split('/')[0] : '(root)';
|
||||
bySkill.set(skill, [...(bySkill.get(skill) ?? []), rel]);
|
||||
}
|
||||
|
||||
const requestCode = [...walk(join(ROOT, 'lib'), rel => rel.endsWith('.ts')),
|
||||
...walk(join(ROOT, 'design/src'), rel => rel.endsWith('.ts')),
|
||||
...walk(join(ROOT, 'test/helpers'), rel => rel.endsWith('.ts'))]
|
||||
.filter(rel => /api\.anthropic\.com|api\.openai\.com|\/v1\/messages|messages\.create|responses\.create/.test(read(rel)));
|
||||
|
||||
const pinningTests = walk(join(ROOT, 'test'), rel => /^test\/[^/]+\.test\.ts$/.test(rel))
|
||||
.filter(rel => {
|
||||
const src = read(rel);
|
||||
return /SKILL\.md|\.md\.tmpl|scripts\/resolvers|model-overlays/.test(src) && /\.(toContain|toMatch)\((['"`])[^'"`]{40,}/.test(src);
|
||||
});
|
||||
|
||||
const slices = [
|
||||
...pack('shared instructions', [shared], Infinity),
|
||||
...pack('resolvers', resolvers.map(f => [f]), budget),
|
||||
...pack('skill templates', [...bySkill.values()], budget),
|
||||
...pack('model-request code', requestCode.map(f => [f]), budget),
|
||||
...pack('tests that pin skill wording', pinningTests.map(f => [f]), budget),
|
||||
];
|
||||
return slices.map((slice, i) => ({ id: `s${String(i + 1).padStart(2, '0')}`, ...slice }));
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const args = process.argv.slice(2);
|
||||
const budgetArg = args.indexOf('--budget-kb');
|
||||
const budgetKb = budgetArg >= 0 ? Number(args[budgetArg + 1]) : DEFAULT_BUDGET_KB;
|
||||
if (!Number.isFinite(budgetKb) || budgetKb <= 0) {
|
||||
console.error('--budget-kb needs a positive number of kilobytes');
|
||||
process.exit(2);
|
||||
}
|
||||
const manifest = buildManifest(budgetKb);
|
||||
if (args.includes('--json')) {
|
||||
console.log(JSON.stringify(manifest, null, 2));
|
||||
} else {
|
||||
const files = manifest.reduce((n, s) => n + s.files.length, 0);
|
||||
console.log(`Prompt-audit manifest: ${manifest.length} slices, ${files} files. Run /claude-api prompt-audit in Claude Code on one slice at a time (CONTRIBUTING.md).`);
|
||||
for (const slice of manifest) {
|
||||
console.log(`\n## ${slice.id} ${slice.group} (${slice.files.length} files, ${Math.round(slice.bytes / 1024)} KB)`);
|
||||
for (const file of slice.files) console.log(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,7 @@ Branch on the echoed \`CODEX_MODE\`:
|
||||
- **\`under_codex\`** — stale artifact selected its own harness. Print: "Codex outside review unavailable: harness mismatch; no outside process started. Missing coverage. Repair: setup --host codex." Skip the outside invocation and follow the workflow's native-review instructions below. Conflicting inherited harness markers are not grounds to guess another provider.
|
||||
- **\`not_authed\`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run \`codex login\` or set \`$CODEX_API_KEY\`." ${nativeRoute}
|
||||
- **\`broken_install\`** — the CLI is on PATH but cannot execute (spawn ENOENT, non-executable binary, missing vendor payload). Print: "Codex is installed but its binary cannot run — Codex passes skipped. Reinstall: \`npm install -g @openai/codex\`." Relay the probe's HINT lines. ${nativeRoute}
|
||||
- **\`model_unusable\`** — the selected model (see \`CODEX_MODEL:\`) is invalid or unavailable to the account (#2477: HTTP 400 on every call). Relay the probe's HINT lines and the fix (\`GSTACK_CODEX_MODEL=<supported-model>\` or config.toml \`model\`); never substitute a model. ${nativeRoute} The ~10s round trip is cached for 1h.
|
||||
- **\`model_unusable\`** — the selected model (see \`CODEX_MODEL:\`) is invalid or unavailable to the account (HTTP 400 on every call). Relay the probe's HINT lines and the fix (\`GSTACK_CODEX_MODEL=<supported-model>\` or config.toml \`model\`); never substitute a model. ${nativeRoute} The ~10s round trip is cached for 1h.
|
||||
- **\`sandbox_unavailable\`** — Codex's sandbox cannot start here (containers without user namespaces); the probe printed the reason and fix. No paid call ran; outside coverage is unavailable. ${nativeRoute}
|
||||
- **\`ready\`** or **\`unverified\`** — run the Codex pass below. \`unverified\` means the model check timed out; say so, and let the pass's own verdict decide.`;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,26 @@
|
||||
/**
|
||||
* {{DESIGN_DOC_DISCOVERY}} — the canonical design-doc discovery block (#703).
|
||||
* {{DESIGN_DOC_DISCOVERY}} — the canonical design-doc discovery block.
|
||||
*
|
||||
* Finds the design doc a plan review should read: newest branch-scoped doc
|
||||
* under ~/.gstack/projects/<slug>/, falling back to newest project-scoped
|
||||
* doc, then lets a repo-local feature doc (docs/designs/*.md) win when
|
||||
* it is at least as fresh. A root DESIGN.md is never a design doc: it is the
|
||||
* design system /design-consultation writes, and preferring it reviewed
|
||||
* plans against a token file while printing "Design doc found" (#2839). office-hours dual-writes docs/designs/ alongside
|
||||
* ~/.gstack, and the committed copy is what teammates see — but a stale old
|
||||
* repo doc must never shadow a newer private session.
|
||||
*
|
||||
* Single source of truth for the freshness-preference logic that previously
|
||||
* lived verbatim in plan-ceo-review, plan-eng-review, plan-devex-review, and
|
||||
* the prerequisite-skill re-check in review.ts (GStack 2 fork-port wave,
|
||||
* time-attack/gstack). Drift between copies meant plan reviews could
|
||||
* disagree about which design doc wins.
|
||||
* One invocation of bin/gstack-design-doc-find, which owns the precedence
|
||||
* (newest branch-scoped doc in the state root's project directory, then the
|
||||
* newest project-scoped doc, then the newest repo-local docs/designs/*.md when
|
||||
* it is at least as fresh; a root DESIGN.md is the design system, never the doc) and resolves the state root through
|
||||
* bin/gstack-paths. Plan reviews, autoplan and the prerequisite-skill re-check
|
||||
* in spec-review.ts all render this one block, so they agree on which doc wins.
|
||||
*
|
||||
* The fragment carries no code fences — the {{DESIGN_DOC_DISCOVERY}} token
|
||||
* sits inside each caller's ```bash block. Callers must set $SLUG and
|
||||
* $BRANCH first (and `setopt +o nomatch` for zsh); the block sets $DESIGN
|
||||
* and prints "Design doc found: ..." or "No design doc found".
|
||||
* $BRANCH first; the block sets $DESIGN and prints "Design doc found: ..." or
|
||||
* "No design doc found".
|
||||
*/
|
||||
|
||||
import type { TemplateContext } from './types';
|
||||
|
||||
/**
|
||||
* Raw canonical fragment, exported so TS resolvers (review.ts's prerequisite
|
||||
* re-check) can interpolate it into their own template strings instead of
|
||||
* embedding a drifting copy.
|
||||
* Raw canonical fragment, exported so TS resolvers (spec-review.ts's
|
||||
* prerequisite re-check) can interpolate it into their own template strings.
|
||||
*/
|
||||
export const DESIGN_DOC_DISCOVERY_BLOCK = `_LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1)
|
||||
[ -z "$_LOCALDOC" ] && _LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1)
|
||||
# Repo-local docs win when at least as fresh (#703): office-hours dual-writes
|
||||
# docs/designs/ alongside ~/.gstack, and the committed copy is what teammates
|
||||
# see. A stale old repo doc never shadows a newer private session.
|
||||
_REPOTOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
|
||||
_REPODOC=""
|
||||
# Only docs/designs/ holds feature design docs; a root DESIGN.md is the design system (#2839).
|
||||
[ -n "$_REPOTOP" ] && _REPODOC=$(ls -t "$_REPOTOP"/docs/designs/*.md 2>/dev/null | head -1)
|
||||
DESIGN="$_LOCALDOC"
|
||||
if [ -n "$_REPODOC" ] && { [ -z "$_LOCALDOC" ] || [ "$_REPODOC" -nt "$_LOCALDOC" ]; }; then
|
||||
DESIGN="$_REPODOC"
|
||||
fi
|
||||
export const DESIGN_DOC_DISCOVERY_BLOCK = `DESIGN=$(~/.claude/skills/gstack/bin/gstack-design-doc-find "$SLUG" "$BRANCH")
|
||||
[ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found"`;
|
||||
|
||||
export function generateDesignDocDiscovery(_ctx: TemplateContext): string {
|
||||
|
||||
@@ -35,6 +35,7 @@ Check if the diff touches frontend files using \`gstack-diff-scope\`:
|
||||
|
||||
\`\`\`bash
|
||||
source <(${ctx.paths.binDir}/gstack-diff-scope <base> 2>/dev/null)
|
||||
echo "SCOPE_FRONTEND=$SCOPE_FRONTEND"
|
||||
\`\`\`
|
||||
|
||||
**If \`SCOPE_FRONTEND=false\`:** Skip design review silently. No output.
|
||||
|
||||
@@ -17,7 +17,7 @@ import { CC_BACKGROUND_DEFAULT_SINCE } from './constants';
|
||||
import { outsideVoiceFailurePolicy, outsideVoiceFor, outsideVoiceInvocation, outsideVoicePreflight, outsideVoiceProvenance } from './outside-voice';
|
||||
import { runtimeRootPrelude } from './runtime-root';
|
||||
|
||||
const CODEX_BOUNDARY = 'IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are skill definitions, not repository review data. Do not invoke any installed skill (Codex home skills/, .agents/), hook, or tool instruction; answer directly. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\\n\\n';
|
||||
const CODEX_BOUNDARY = 'Filesystem boundary: do not read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. They hold skill definitions, not repository code to review. Do not invoke any installed skill (Codex home skills/, .agents/), hook, or tool instruction; answer directly. Do not modify agents/openai.yaml. Review only the repository code.\\n\\n';
|
||||
|
||||
export function generateCodexSecondOpinion(ctx: TemplateContext): string {
|
||||
|
||||
|
||||
+18
-15
@@ -76,30 +76,33 @@ For /qa and /qa-only:
|
||||
- Browser Quick: SECONDS=30. Browser Full/Regression: SECONDS=900.
|
||||
- Functional Full, Quick and Regression have no default total timer.
|
||||
Set SECONDS to the shorter mode/caller limit; an unlimited mode uses the caller's bound.
|
||||
Without a total time limit, do not start D; announce finite command timeouts.
|
||||
Without a total time limit, do not create DEADLINE_FILE; announce finite command timeouts.
|
||||
Stop when scoped contracts are tested or blocked.
|
||||
Clocks/checkpoints use REPORT_DIR; mixed standalone runs use REPORT_DIR/browser and REPORT_DIR/functional, with one final report at REPORT_DIR. Caller paths win.
|
||||
Names used below (quote every path):
|
||||
- R: this surface's owned probe directory, chosen above. D: \`R/deadline.json\`, which exists only for a bounded run.
|
||||
- PROBE_DIR: this surface's owned probe directory, chosen above. DEADLINE_FILE: \`PROBE_DIR/deadline.json\`, which exists only for a bounded run.
|
||||
- SECONDS: the total probe budget set above. NNN: a fresh three-digit ID (001, 002, ...) for each capture or checkpoint; never reuse one.
|
||||
- G = \`${quoteSafePath(ctx.paths.binDir)}/gstack-qa-deadline\`, the deadline guard: \`start\` creates D, \`status\` prints \`remainingMs\` and \`expired\`, and \`run\` executes one command, stopping it at the deadline.
|
||||
- Q = \`${quoteSafePath(ctx.paths.binDir)}/gstack-qa-evidence\`, the functional evidence recorder: \`capture\` runs one command, stores its exit code, stdout and stderr under \`R/.qa-evidence/NNN/\`, and prints status \`complete\`, \`incomplete\` or \`sensitive\`.
|
||||
- DEADLINE_TOOL = \`${quoteSafePath(ctx.paths.binDir)}/gstack-qa-deadline\`, the deadline guard: \`start\` creates DEADLINE_FILE, \`status\` prints \`remainingMs\` and \`expired\`, and \`run\` executes one command, stopping it at the deadline.
|
||||
- EVIDENCE_TOOL = \`${quoteSafePath(ctx.paths.binDir)}/gstack-qa-evidence\`, the functional evidence recorder: \`capture\` runs one command, stores its exit code, stdout and stderr under \`PROBE_DIR/.qa-evidence/NNN/\`, and prints status \`complete\`, \`incomplete\` or \`sensitive\`.
|
||||
|
||||
Start once before baseline: \`bun G start D SECONDS [EARLIER_UTC]\` if bounded.
|
||||
Start once before baseline: \`bun DEADLINE_TOOL start DEADLINE_FILE SECONDS [EARLIER_UTC]\` if bounded.
|
||||
EARLIER_UTC = caller's absolute deadline, if set.
|
||||
Functional: \`bun Q capture R NNN [--public] --deadline D -- COMMAND ARGS\`.
|
||||
Unbounded: use \`--timeout-ms MS\` instead of \`--deadline D\`.
|
||||
--public requires approved public/synthetic output; Q screens credentials. For complete private captures, await a safe Read of \`R/.qa-evidence/NNN/observation.json\` (the decoded stdout). Sensitive/incomplete captures cannot anchor checkpoints.
|
||||
Bounded browsers: \`bun G run D -- COMMAND ARGS\`. No detached probes.
|
||||
Never reset D/bypass G. Expiry or invalid/missing D stops probes; report unfinished coverage. QA_DEADLINE receipts are not observations.
|
||||
Functional: \`bun EVIDENCE_TOOL capture PROBE_DIR NNN [--public] --deadline DEADLINE_FILE -- COMMAND ARGS\`.
|
||||
Unbounded: use \`--timeout-ms MS\` instead of \`--deadline DEADLINE_FILE\`.
|
||||
--public requires approved public/synthetic output; EVIDENCE_TOOL screens credentials. For complete private captures, await a safe Read of \`PROBE_DIR/.qa-evidence/NNN/observation.json\` (the decoded stdout). Sensitive/incomplete captures cannot anchor checkpoints.
|
||||
Bounded browsers: \`bun DEADLINE_TOOL run DEADLINE_FILE -- COMMAND ARGS\`. No detached probes.
|
||||
Never reset DEADLINE_FILE/bypass DEADLINE_TOOL. Expiry or an invalid/missing DEADLINE_FILE stops probes; report unfinished coverage. QA_DEADLINE receipts are not observations.
|
||||
|
||||
## 2. Probe loop
|
||||
|
||||
Each probe is one native command/interaction plus checks, excluding bookkeeping.
|
||||
Never batch probes.
|
||||
How one probe works: (1) if bounded, check the clock and choose the next command;
|
||||
(2) publish a checkpoint naming it (functional: \`capture --after\` publishes it);
|
||||
(3) run exactly that command via DEADLINE_TOOL or EVIDENCE_TOOL, read its result, repeat.
|
||||
|
||||
1. First demonstrate success: output AND durable effects. Guard if bounded; await completion.
|
||||
2. **Decide whether another probe is needed.** If bounded, run \`bun G status D\`.
|
||||
2. **Decide whether another probe is needed.** If bounded, run \`bun DEADLINE_TOOL status DEADLINE_FILE\`.
|
||||
If expired or no safe next probe remains, STOP exploration; write the report, not a checkpoint.
|
||||
${reportOnly ? ` **Classify the last result before copying it.** For public or synthetic observations,
|
||||
retain the entire result unchanged, including owned fixture paths, IDs, hashes and
|
||||
@@ -119,11 +122,11 @@ ${reportOnly ? ` For guarded text, copy the complete span between the guard's
|
||||
Preserve every safe program-JSON key/value and identity hash unchanged.
|
||||
Withhold unsafe values, disclose limits and stop that chain.
|
||||
Check fields before publication. No drafts/placeholders or invented safe-path redactions; corrections cannot repair published notes.
|
||||
Functional: do not write this file; the next capture publishes it: \`bun Q capture R NNN --deadline D --after PREV --hypothesis 'why' -- CMD\` (PREV: the last complete capture's ID). Q supplies observed; never transcribe it.
|
||||
Functional: do not write this file; the next capture publishes it: \`bun EVIDENCE_TOOL capture PROBE_DIR NNN --deadline DEADLINE_FILE --after PREV --hypothesis 'why' -- CMD\` (PREV: the last complete capture's ID). EVIDENCE_TOOL supplies observed; never transcribe it.
|
||||
Browser checkpoints use Write: create the four-field file yourself.
|
||||
Wait for successful checkpoint publication before dispatch.
|
||||
Never backfill or overwrite notes.
|
||||
3. Run that exact probe; G enforces the deadline when bounded.
|
||||
3. Run that exact probe; DEADLINE_TOOL enforces the deadline when bounded.
|
||||
Report refusals as not-run; retain initial state/inputs/results. Repeat from step 2.
|
||||
4. Replay the exact failing command/request from the same initial fixture state via steps 2–3 (same native command, fresh capture ID)
|
||||
${reportOnly ? 'to confirm it' : 'before repair'}, then minimize via those gates. Expiry leaves confirmation/minimization incomplete.
|
||||
@@ -147,7 +150,7 @@ Never freeze buggy output, weaken tests or delete valid red tests.`}
|
||||
## 4. Final report
|
||||
|
||||
Use the surface report template; link each checkpoint. Separate browser scores, functional outcomes and proposed/executed tests.
|
||||
Write R/annotations.json {evidence: [{capture, command, contract, expected, classification}], limits} (browser-only: evidence [], checkpoints in limits); before Markdown \`bun Q materialize R annotations.json\` (fills observed/metadata; prints reportLinks); you classify. Retain all safe probes, including failures/replays; disclose withheld/incomplete evidence.
|
||||
Write PROBE_DIR/annotations.json {evidence: [{capture, command, contract, expected, classification}], limits} (browser-only: evidence [], checkpoints in limits); before Markdown \`bun EVIDENCE_TOOL materialize PROBE_DIR annotations.json\` (fills observed/metadata; prints reportLinks; runs once per report root); you classify. Annotate every safe capture, including failures/replays: an omitted capture is withheld and keeps the verdict inconclusive. Classify a capture taken before an input change \`superseded\`; it closes when the same command reran on current inputs. Disclose withheld/incomplete evidence.
|
||||
Evidence is invocation-local${reportOnly ? '.' : '; /ship reruns once per invocation.'}
|
||||
Missing prerequisites/expectations/observations, timeouts and refusal never pass.
|
||||
Pass requires all required current-input contracts to pass with no required remainder.
|
||||
@@ -260,7 +263,7 @@ Never install, import cookies or bootstrap tests. Functional-only skips browser
|
||||
|
||||
**3. Run smoke and plan checks.**
|
||||
Follow the shared Probe loop for smoke checks and replays until the smoke limit.
|
||||
Then run required plan checks and revalidation, even after smoke expires, using the same procedure but no smoke guard; never reset the clock. Their checkpoints sit beside D; they skip \`G status D\` and use \`--timeout-ms\`, not \`--deadline D\`. Post-expiry smoke rechecks are not-run.
|
||||
Then run required plan checks and revalidation, even after smoke expires, using the same procedure but no smoke guard; never reset the clock. Their checkpoints sit beside DEADLINE_FILE; they skip \`DEADLINE_TOOL status DEADLINE_FILE\` and use \`--timeout-ms\`, not \`--deadline DEADLINE_FILE\`. Post-expiry smoke rechecks are not-run.
|
||||
Use finite command timeouts, capped at the caller's remaining time if it has a deadline.${ship ? '' : ' /review sets none; only an invoker-supplied EARLIER_UTC counts.'}
|
||||
Await clock/guard results before acting. When the caller's deadline expires, mark unfinished checks not-run.
|
||||
|
||||
|
||||
@@ -199,7 +199,8 @@ Core findings keep the core Confidence Calibration gates.
|
||||
#### 5. Score and present specialists
|
||||
|
||||
Only specialist findings enter this header and \`quality_score\`; core findings do not.
|
||||
Use the merged NON-advisory specialist findings for both counts and score:
|
||||
Use the merged NON-advisory specialist findings for both counts and score;
|
||||
the header's N is X + Y, so advisory findings never add to it:
|
||||
\`quality_score = max(0, 10 - (critical_count * 2 + informational_count * 0.5))\`
|
||||
Cap at 10 and retain for ${persistRef}. These are not final unresolved-defect totals.
|
||||
Print only this block: the stage 6 activity object and \`test_stub\` bodies are log and Fix-First data.
|
||||
|
||||
@@ -133,7 +133,7 @@ if command -v jq >/dev/null 2>&1; then
|
||||
# .commit must be bound BEFORE piping to the split commit array: a
|
||||
# pipe rebinds jq's context, so a bare .commit after it indexes the
|
||||
# ARRAY with a string, every line errors into 2>/dev/null, and the
|
||||
# aggregate is empty forever — the #2018 zero-tasks bug.
|
||||
# aggregate is empty forever.
|
||||
jq -c --arg branch "$BRANCH" --arg commits "$COMMITS_RECENT" \\
|
||||
'.commit as $c | select(.branch == $branch and ($commits | split("|") | index($c) != null))' \\
|
||||
"$f" 2>/dev/null >> "$ALL_JSONL" || true
|
||||
|
||||
@@ -1432,7 +1432,7 @@ function trackShardBrowser(stateDir: string, env: NodeJS.ProcessEnv) {
|
||||
let alive = true;
|
||||
|
||||
const check = (capture: Capture) => {
|
||||
if (closed || capture.abort.signal.aborted || active !== capture) throw new CaptureStopped();
|
||||
if (closed || capture.abort.signal.aborted || active !== capture || (!forced && performance.now() >= forceAt)) throw new CaptureStopped();
|
||||
if (performance.now() >= Math.min(capture.deadline, deadline)) throw new BrowserCleanupError('browser ownership deadline exceeded');
|
||||
};
|
||||
const probe = async (capture: Capture, command: string, args: string[], timeout: number) => {
|
||||
@@ -1462,7 +1462,7 @@ function trackShardBrowser(stateDir: string, env: NodeJS.ProcessEnv) {
|
||||
killProcessGroup(child, 'SIGKILL');
|
||||
reaper = setTimeout(() => finish(null), 100);
|
||||
};
|
||||
const timer = setTimeout(stop, Math.max(1, remaining));
|
||||
const timer = setTimeout(() => { if (!closed) errors.add('browser identity probe did not complete'); stop(); }, Math.max(1, Math.min(remaining, forced ? Infinity : forceAt - performance.now())));
|
||||
capture.abort.signal.addEventListener('abort', stop, { once: true });
|
||||
child.once('error', () => { failed = true; finish(null); });
|
||||
child.once('close', finish);
|
||||
@@ -1693,7 +1693,7 @@ function trackShardBrowser(stateDir: string, env: NodeJS.ProcessEnv) {
|
||||
};
|
||||
const enqueue = () => {
|
||||
if (closed || pending || process.platform === 'win32') return;
|
||||
const operation: Capture = { abort: new AbortController(), deadline: Math.min(performance.now() + 10000, deadline, forced ? Infinity : forceAt), probes: new Set() };
|
||||
const operation: Capture = { abort: new AbortController(), deadline: Math.min(performance.now() + 10000, deadline), probes: new Set() };
|
||||
active = operation;
|
||||
pending = (async () => {
|
||||
try {
|
||||
|
||||
@@ -26,12 +26,13 @@ export const PR_PROFILE_CASE_IDS = [
|
||||
'gstack-upgrade-happy-path',
|
||||
'investigate-owned-completion', 'investigate-owned-abort', 'investigate-owned-ending-error',
|
||||
'ship-coverage-value', 'review-test-value', 'test-audit-report-only',
|
||||
'office-hours-auto-mode',
|
||||
'office-hours-auto-mode', 'plan-ceo-review-plan-mode',
|
||||
] as const;
|
||||
|
||||
/** Audited ownership: unknown/direct-describe files remain broad coverage. */
|
||||
export const PR_PROFILE_FILES: Record<string, readonly string[]> = {
|
||||
'test/skill-e2e-office-hours-auto-mode.test.ts': ['office-hours-auto-mode'],
|
||||
'test/skill-e2e-plan-ceo-plan-mode.test.ts': ['plan-ceo-review-plan-mode'],
|
||||
'test/skill-e2e-investigate-owned-completion.test.ts': ['investigate-owned-completion'],
|
||||
'test/skill-e2e-investigate-owned-termination.test.ts': ['investigate-owned-abort', 'investigate-owned-ending-error'],
|
||||
'test/skill-e2e-hermetic-canary.test.ts': ['hermetic-canary', 'hermetic-sentinel'],
|
||||
|
||||
Reference in new issue
Block a user