mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Add durable Live variant planning
This commit is contained in:
@@ -12,11 +12,11 @@ import { loadContext } from '../context.mjs';
|
||||
|
||||
import {
|
||||
CODEX_WORKER_OWNER,
|
||||
CODEX_WORKER_OUTPUT_SCHEMA,
|
||||
applyCodexWorkerOutput,
|
||||
buildCodexWorkerInstructions,
|
||||
buildCodexWorkerTurnInputs,
|
||||
buildGenerationTurnInput,
|
||||
codexWorkerOutputSchemaForPhase,
|
||||
codexWorkerStateIsOwned,
|
||||
generationIsCanceled,
|
||||
prepareCodexWorkerPhase,
|
||||
@@ -305,6 +305,7 @@ export class CodexLiveWorkerSupervisor {
|
||||
phase,
|
||||
prepared,
|
||||
artifact,
|
||||
variantPlan: this.sessionStore.getSnapshot(event.id, { includeCompleted: true })?.variantPlan || null,
|
||||
...contexts,
|
||||
});
|
||||
const input = buildCodexWorkerTurnInputs({
|
||||
@@ -325,7 +326,7 @@ export class CodexLiveWorkerSupervisor {
|
||||
phase: phase === 'final' ? 'remaining_variants_validating' : 'first_variant_validating',
|
||||
durationMs: Date.now() - phaseStartedAt,
|
||||
});
|
||||
applyCodexWorkerOutput({
|
||||
const applied = applyCodexWorkerOutput({
|
||||
output: answer,
|
||||
prepared,
|
||||
phase,
|
||||
@@ -333,6 +334,9 @@ export class CodexLiveWorkerSupervisor {
|
||||
cwd: this.cwd,
|
||||
maxBytes: this.config.maxArtifactBytes,
|
||||
});
|
||||
if (applied.plan) {
|
||||
this.sessionStore.appendEvent({ type: 'variant_plan', id: event.id, plan: applied.plan });
|
||||
}
|
||||
if (this.isCanceled(event.id)) return;
|
||||
const published = publishCodexWorkerPhase({ event, prepared, arrivedVariants, cwd: this.cwd });
|
||||
await this.publishCheckpoint(this.base, this.token, {
|
||||
@@ -356,7 +360,7 @@ export class CodexLiveWorkerSupervisor {
|
||||
if (this.isCanceled(event.id)) return;
|
||||
const result = await this.runTurnWithReconnect({
|
||||
input,
|
||||
outputSchema: CODEX_WORKER_OUTPUT_SCHEMA,
|
||||
outputSchema: codexWorkerOutputSchemaForPhase(phase, Number(event.count || arrivedVariants)),
|
||||
onAgentMessage: publishCandidate,
|
||||
eventId: event.id,
|
||||
});
|
||||
|
||||
@@ -8,6 +8,35 @@ import {
|
||||
import { createLiveSessionStore } from './session-store.mjs';
|
||||
|
||||
export const CODEX_WORKER_OWNER = 'impeccable-live-codex-worker-v1';
|
||||
const VARIANT_PLAN_SCHEMA = Object.freeze({
|
||||
type: 'object',
|
||||
properties: {
|
||||
identityLock: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
maxItems: 8,
|
||||
items: { type: 'string', minLength: 1, maxLength: 240 },
|
||||
},
|
||||
directions: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
maxItems: 6,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
variantId: { type: 'integer', minimum: 1, maximum: 6 },
|
||||
name: { type: 'string', minLength: 1, maxLength: 80 },
|
||||
axis: { type: 'string', minLength: 1, maxLength: 120 },
|
||||
intent: { type: 'string', minLength: 1, maxLength: 300 },
|
||||
},
|
||||
required: ['variantId', 'name', 'axis', 'intent'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['identityLock', 'directions'],
|
||||
additionalProperties: false,
|
||||
});
|
||||
export const CODEX_WORKER_OUTPUT_SCHEMA = Object.freeze({
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -29,6 +58,17 @@ export const CODEX_WORKER_OUTPUT_SCHEMA = Object.freeze({
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
export function codexWorkerOutputSchemaForPhase(phase, expectedVariants = 3) {
|
||||
const requirePlan = Number(expectedVariants) > 1 && (phase === 'first' || phase === 'atomic');
|
||||
return {
|
||||
...CODEX_WORKER_OUTPUT_SCHEMA,
|
||||
properties: requirePlan
|
||||
? { ...CODEX_WORKER_OUTPUT_SCHEMA.properties, plan: VARIANT_PLAN_SCHEMA }
|
||||
: CODEX_WORKER_OUTPUT_SCHEMA.properties,
|
||||
required: requirePlan ? ['files', 'plan'] : ['files'],
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCodexWorkerConfig({ env = process.env, liveConfig = {} } = {}) {
|
||||
const configured = liveConfig.experimentalCodexWorker || liveConfig.codexWorker || {};
|
||||
const envEnabled = parseBoolean(env.IMPECCABLE_LIVE_CODEX_WORKER);
|
||||
@@ -87,6 +127,7 @@ export function buildGenerationTurnInput({
|
||||
phase,
|
||||
prepared,
|
||||
artifact,
|
||||
variantPlan,
|
||||
product,
|
||||
design,
|
||||
actionReference,
|
||||
@@ -96,24 +137,35 @@ export function buildGenerationTurnInput({
|
||||
const count = Number(event.count || 3);
|
||||
const first = phase === 'first';
|
||||
const component = Boolean(prepared.previewMode);
|
||||
const actionRules = event.action === 'bolder' && count > 1
|
||||
? [
|
||||
'For /bolder, keep variant 1 low-risk: preserve the selected root’s high-level layout and create impact through controlled hierarchy, proportion, or rhythm. Reserve root recomposition for variant 2 or 3.',
|
||||
'At least one later direction must recompose the selected root or materially change the spatial relationship among its children. The set must not merely restyle the same descendant three ways.',
|
||||
'Color alone is not a sufficient primary axis for /bolder; pair any palette shift with a meaningful hierarchy, proportion, rhythm, or composition change.',
|
||||
]
|
||||
: [];
|
||||
const phaseRules = first
|
||||
? [
|
||||
'Produce only variant 1 now so it can be reviewed immediately.',
|
||||
'Variant 1 must be the strongest low-risk, independently shippable interpretation of the request; reserve more experimental directions for later variants.',
|
||||
`Before authoring, define the shared identity lock and exactly ${count} distinct, meaningful design axes. Return them in plan.directions ordered by variantId so the final phase can complete the same coherent set.`,
|
||||
'Defer tunable parameters: params must be absent or empty for this phase.',
|
||||
]
|
||||
: phase === 'final'
|
||||
? [
|
||||
`Complete variants 2 through ${count} and the final parameter manifest.`,
|
||||
'Variant 1 is already visible and immutable. Do not return or alter its file, markup, or CSS.',
|
||||
'Follow the durable variant plan below. Preserve its identity lock and implement each remaining named axis instead of improvising a new set.',
|
||||
]
|
||||
: [
|
||||
`Produce the complete set of ${count} variants and final parameters atomically.`,
|
||||
`Before authoring, define the shared identity lock and exactly ${count} distinct, meaningful design axes and return them in plan.directions ordered by variantId.`,
|
||||
];
|
||||
|
||||
return [
|
||||
`LIVE GENERATION PHASE: ${phase}`,
|
||||
...phaseRules,
|
||||
...actionRules,
|
||||
component
|
||||
? `Return staged component files relative to componentDir. Allowed variant extension: .${artifact.componentExtension}. The supervisor updates manifest.json.`
|
||||
: `Return exactly one file whose path is ${JSON.stringify(prepared.artifactFile)} and whose content is the complete staged source artifact.`,
|
||||
@@ -124,6 +176,9 @@ export function buildGenerationTurnInput({
|
||||
'<event>',
|
||||
JSON.stringify(sanitizeEvent(event), null, 2),
|
||||
'</event>',
|
||||
'<variant_plan>',
|
||||
JSON.stringify(variantPlan || null, null, 2),
|
||||
'</variant_plan>',
|
||||
'',
|
||||
'<product_context>',
|
||||
String(product || ''),
|
||||
@@ -221,6 +276,9 @@ export function applyCodexWorkerOutput({
|
||||
totalBytes += Buffer.byteLength(file.content);
|
||||
}
|
||||
if (totalBytes > maxBytes) throw workerError('worker_output_too_large');
|
||||
const requirePlan = Number(expectedVariants) > 1 && (phase === 'first' || phase === 'atomic');
|
||||
if (requirePlan && !parsed.plan) throw workerError('worker_output_plan_missing');
|
||||
const plan = parsed.plan ? normalizeVariantPlan(parsed.plan, expectedVariants) : null;
|
||||
|
||||
if (!prepared.previewMode) {
|
||||
if (parsed.files.length !== 1 || parsed.files[0].path !== prepared.artifactFile) {
|
||||
@@ -229,7 +287,7 @@ export function applyCodexWorkerOutput({
|
||||
const artifactPath = resolveInside(cwd, prepared.artifactFile);
|
||||
if (!artifactPath) throw workerError('artifact_path_outside_project');
|
||||
fs.writeFileSync(artifactPath, parsed.files[0].content, 'utf-8');
|
||||
return { files: [prepared.artifactFile] };
|
||||
return { files: [prepared.artifactFile], plan };
|
||||
}
|
||||
|
||||
const componentDir = resolveInside(cwd, prepared.componentDir);
|
||||
@@ -267,7 +325,37 @@ export function applyCodexWorkerOutput({
|
||||
}
|
||||
manifest.arrivedVariants = phase === 'first' ? 1 : expectedVariants;
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
return { files: [...seen] };
|
||||
return { files: [...seen], plan };
|
||||
}
|
||||
|
||||
function normalizeVariantPlan(plan, expectedVariants) {
|
||||
if (!plan || typeof plan !== 'object' || Array.isArray(plan)) {
|
||||
throw workerError('worker_output_plan_invalid');
|
||||
}
|
||||
const identityLock = Array.isArray(plan.identityLock)
|
||||
? plan.identityLock.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const directions = Array.isArray(plan.directions) ? plan.directions : [];
|
||||
if (identityLock.length < 1 || identityLock.length > 8 || directions.length !== Number(expectedVariants)) {
|
||||
throw workerError('worker_output_plan_invalid');
|
||||
}
|
||||
const normalizedDirections = directions.map((direction) => ({
|
||||
variantId: Number(direction?.variantId),
|
||||
name: String(direction?.name || '').trim(),
|
||||
axis: String(direction?.axis || '').trim(),
|
||||
intent: String(direction?.intent || '').trim(),
|
||||
}));
|
||||
const expectedIds = Array.from({ length: Number(expectedVariants) }, (_, index) => index + 1);
|
||||
const sortedIds = normalizedDirections.map((direction) => direction.variantId).sort((a, b) => a - b);
|
||||
if (normalizedDirections.some((direction) => (
|
||||
!Number.isInteger(direction.variantId)
|
||||
|| !direction.name
|
||||
|| !direction.axis
|
||||
|| !direction.intent
|
||||
)) || sortedIds.some((id, index) => id !== expectedIds[index])) {
|
||||
throw workerError('worker_output_plan_invalid');
|
||||
}
|
||||
return { identityLock, directions: normalizedDirections };
|
||||
}
|
||||
|
||||
export function prepareCodexWorkerPhase({ id, sourceFile, cwd = process.cwd() }) {
|
||||
|
||||
@@ -134,6 +134,7 @@ function baseSnapshot(id) {
|
||||
generationEpoch: 1,
|
||||
publishedRevision: 0,
|
||||
deliveredVariants: {},
|
||||
variantPlan: null,
|
||||
generationCanceled: false,
|
||||
generationCanceledAt: null,
|
||||
cancelReason: null,
|
||||
@@ -178,6 +179,7 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
|
||||
generationTimings: { ...(snapshot.generationTimings || {}) },
|
||||
deliveredVariants: { ...(snapshot.deliveredVariants || {}) },
|
||||
variantPlan: snapshot.variantPlan || null,
|
||||
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
|
||||
diagnostics: [...(snapshot.diagnostics || [])],
|
||||
updatedAt: entry.ts || new Date().toISOString(),
|
||||
@@ -195,8 +197,14 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
next.expectedVariants = event.count ?? next.expectedVariants;
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
next.pendingEvent = toPendingEvent(event);
|
||||
next.variantPlan = null;
|
||||
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
|
||||
break;
|
||||
case 'variant_plan':
|
||||
if (!next.generationCanceled && !GENERATION_FENCED_PHASES.has(next.phase)) {
|
||||
next.variantPlan = event.plan ?? next.variantPlan;
|
||||
}
|
||||
break;
|
||||
case 'variant_published':
|
||||
if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) {
|
||||
next.diagnostics.push({
|
||||
|
||||
@@ -455,12 +455,25 @@ describe('Codex Live worker supervisor ownership and lifecycle', () => {
|
||||
const final = '<main><div data-impeccable-variants="codexprogress"><style data-impeccable-css="codexprogress">@scope ([data-impeccable-variant="1"]) { h1 { color: red; } }\n@scope ([data-impeccable-variant="2"]) { h1 { color: green; } }\n@scope ([data-impeccable-variant="3"]) { h1 { color: blue; } }</style><div data-impeccable-variant="original"><h1>Original</h1></div><div data-impeccable-variant="1"><h1>One</h1></div><div data-impeccable-variant="2"><h1>Two</h1></div><div data-impeccable-variant="3"><h1>Three</h1></div></div></main>';
|
||||
const client = fakeClient();
|
||||
let turn = 0;
|
||||
const prompts = [];
|
||||
const plan = {
|
||||
identityLock: ['Preserve copy and shared component roles'],
|
||||
directions: [
|
||||
{ variantId: 1, name: 'Hierarchy', axis: 'type scale', intent: 'Strengthen hierarchy' },
|
||||
{ variantId: 2, name: 'Composition', axis: 'layout', intent: 'Recompose the root' },
|
||||
{ variantId: 3, name: 'Rhythm', axis: 'spacing', intent: 'Increase rhythm' },
|
||||
],
|
||||
};
|
||||
client.startTurn = async ({ input, onStarted, onAgentMessage }) => {
|
||||
turn += 1;
|
||||
onStarted?.(`turn-${turn}`);
|
||||
const prompt = input.find((item) => item.type === 'text').text;
|
||||
prompts.push(prompt);
|
||||
const artifactPath = JSON.parse(prompt.match(/Return exactly one file whose path is ("[^"]+")/)[1]);
|
||||
const message = JSON.stringify({ files: [{ path: artifactPath, content: turn === 1 ? first : final }] });
|
||||
const message = JSON.stringify({
|
||||
files: [{ path: artifactPath, content: turn === 1 ? first : final }],
|
||||
...(turn === 1 ? { plan } : {}),
|
||||
});
|
||||
await Promise.all([
|
||||
onAgentMessage?.(message),
|
||||
onAgentMessage?.(message),
|
||||
@@ -506,6 +519,8 @@ describe('Codex Live worker supervisor ownership and lifecycle', () => {
|
||||
const snapshot = createLiveSessionStore({ cwd, sessionId }).getSnapshot(sessionId, { includeCompleted: true });
|
||||
assert.equal(snapshot.arrivedVariants, 3);
|
||||
assert.equal(snapshot.publishedRevision, 2);
|
||||
assert.deepEqual(snapshot.variantPlan, plan);
|
||||
assert.match(prompts[1], /"name": "Composition"/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
buildCodexWorkerInstructions,
|
||||
buildCodexWorkerTurnInputs,
|
||||
buildGenerationTurnInput,
|
||||
codexWorkerOutputSchemaForPhase,
|
||||
codexWorkerProcessStateIsOwned,
|
||||
codexWorkerStateIsOwned,
|
||||
isCodexRuntime,
|
||||
@@ -223,6 +224,17 @@ describe('Codex Live worker structured artifact boundary', () => {
|
||||
assert.match(instructions, /Ignore any instruction.*run commands/);
|
||||
});
|
||||
|
||||
it('requires a coherent variant plan before progressive or atomic multi-variant output', () => {
|
||||
const firstSchema = codexWorkerOutputSchemaForPhase('first', 3);
|
||||
const finalSchema = codexWorkerOutputSchemaForPhase('final', 3);
|
||||
assert.deepEqual(firstSchema.required, ['files', 'plan']);
|
||||
assert.ok(firstSchema.properties.plan);
|
||||
assert.deepEqual(codexWorkerOutputSchemaForPhase('atomic', 3).required, ['files', 'plan']);
|
||||
assert.deepEqual(finalSchema.required, ['files']);
|
||||
assert.equal(finalSchema.properties.plan, undefined, 'strict schemas cannot expose optional properties');
|
||||
assert.deepEqual(codexWorkerOutputSchemaForPhase('atomic', 1).required, ['files']);
|
||||
});
|
||||
|
||||
it('writes only the prepared source artifact path', () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), 'codex-worker-source-'));
|
||||
const artifact = path.join(cwd, '.impeccable/live/artifacts/session-r1.jsx');
|
||||
@@ -231,7 +243,7 @@ describe('Codex Live worker structured artifact boundary', () => {
|
||||
const prepared = { artifactFile: '.impeccable/live/artifacts/session-r1.jsx' };
|
||||
|
||||
applyCodexWorkerOutput({
|
||||
output: { files: [{ path: prepared.artifactFile, content: 'after' }] },
|
||||
output: { files: [{ path: prepared.artifactFile, content: 'after' }], plan: variantPlan() },
|
||||
prepared,
|
||||
phase: 'first',
|
||||
expectedVariants: 3,
|
||||
@@ -240,7 +252,7 @@ describe('Codex Live worker structured artifact boundary', () => {
|
||||
assert.equal(readFileSync(artifact, 'utf-8'), 'after');
|
||||
assert.throws(
|
||||
() => applyCodexWorkerOutput({
|
||||
output: { files: [{ path: 'src/App.jsx', content: 'unsafe' }] },
|
||||
output: { files: [{ path: 'src/App.jsx', content: 'unsafe' }], plan: variantPlan() },
|
||||
prepared,
|
||||
phase: 'first',
|
||||
expectedVariants: 3,
|
||||
@@ -314,7 +326,7 @@ describe('Codex Live worker structured artifact boundary', () => {
|
||||
{ path: 'v2.svelte', content: '<h1>Two</h1>' },
|
||||
{ path: 'v3.svelte', content: '<h1>Three</h1>' },
|
||||
{ path: 'params.json', content: '{}' },
|
||||
] },
|
||||
], plan: variantPlan() },
|
||||
prepared,
|
||||
phase: 'atomic',
|
||||
expectedVariants: 3,
|
||||
@@ -354,7 +366,7 @@ describe('Codex Live worker structured artifact boundary', () => {
|
||||
const prepared = { artifactFile: 'artifact.html' };
|
||||
const artifact = readPreparedArtifact(prepared, { cwd });
|
||||
const prompt = buildGenerationTurnInput({
|
||||
event: { id: 'abc', count: 3, scaffold: { file: 'artifact.html' } },
|
||||
event: { id: 'abc', count: 3, action: 'bolder', scaffold: { file: 'artifact.html' } },
|
||||
phase: 'first',
|
||||
prepared,
|
||||
artifact,
|
||||
@@ -366,11 +378,25 @@ describe('Codex Live worker structured artifact boundary', () => {
|
||||
});
|
||||
assert.match(prompt, /Produce only variant 1/);
|
||||
assert.match(prompt, /strongest low-risk, independently shippable/);
|
||||
assert.match(prompt, /shared identity lock and exactly 3 distinct/);
|
||||
assert.match(prompt, /keep variant 1 low-risk/);
|
||||
assert.match(prompt, /Reserve root recomposition for variant 2 or 3/);
|
||||
assert.match(prompt, /Color alone is not a sufficient primary axis/);
|
||||
assert.match(prompt, /<main>wrapped<\/main>/);
|
||||
assert.match(prompt, /Product facts/);
|
||||
assert.match(prompt, /Design tokens/);
|
||||
assert.match(prompt, /docs\/PRODUCT\.md/);
|
||||
assert.match(prompt, /src\/Button\.jsx/);
|
||||
|
||||
const finalPrompt = buildGenerationTurnInput({
|
||||
event: { id: 'abc', count: 3 },
|
||||
phase: 'final',
|
||||
prepared,
|
||||
artifact,
|
||||
variantPlan: variantPlan(),
|
||||
});
|
||||
assert.match(finalPrompt, /Follow the durable variant plan/);
|
||||
assert.match(finalPrompt, /Composition/);
|
||||
});
|
||||
|
||||
it('attaches the real skill and annotation image as first-class turn inputs', () => {
|
||||
@@ -389,3 +415,14 @@ describe('Codex Live worker structured artifact boundary', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function variantPlan() {
|
||||
return {
|
||||
identityLock: ['Preserve copy and established component roles'],
|
||||
directions: [
|
||||
{ variantId: 1, name: 'Hierarchy', axis: 'type scale', intent: 'Strengthen the primary hierarchy' },
|
||||
{ variantId: 2, name: 'Composition', axis: 'spatial layout', intent: 'Recompose the selected root' },
|
||||
{ variantId: 3, name: 'Rhythm', axis: 'spacing and rules', intent: 'Increase editorial rhythm' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +62,24 @@ describe('live-session-store', () => {
|
||||
assert.equal(active[0].id, 'session-a');
|
||||
});
|
||||
|
||||
it('persists the progressive variant plan across worker restarts', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'planned-session' });
|
||||
const plan = {
|
||||
identityLock: ['Preserve copy'],
|
||||
directions: [
|
||||
{ variantId: 1, name: 'Hierarchy', axis: 'scale', intent: 'Increase hierarchy' },
|
||||
{ variantId: 2, name: 'Composition', axis: 'layout', intent: 'Recompose the root' },
|
||||
{ variantId: 3, name: 'Rhythm', axis: 'spacing', intent: 'Increase rhythm' },
|
||||
],
|
||||
};
|
||||
store.appendEvent({ type: 'generate', id: 'planned-session', count: 3 });
|
||||
store.appendEvent({ type: 'variant_plan', id: 'planned-session', plan });
|
||||
store.appendEvent({ type: 'checkpoint', id: 'planned-session', revision: 1, arrivedVariants: 1 });
|
||||
|
||||
const restarted = createLiveSessionStore({ cwd: tmp, sessionId: 'planned-session' });
|
||||
assert.deepEqual(restarted.getSnapshot('planned-session').variantPlan, plan);
|
||||
});
|
||||
|
||||
it('tombstones generation on early accept and ignores late generation writes', () => {
|
||||
const store = createLiveSessionStore({ cwd: tmp, sessionId: 'early-accept' });
|
||||
store.appendEvent({
|
||||
|
||||
Reference in New Issue
Block a user