diff --git a/apps/demo/src/generate-main.ts b/apps/demo/src/generate-main.ts index 4db3fee..e730b41 100644 --- a/apps/demo/src/generate-main.ts +++ b/apps/demo/src/generate-main.ts @@ -1088,14 +1088,16 @@ function applyLineTo(target: SandboxTarget, line: ProtocolLine, context: Surface if (line.op === 'meta' && line.path === '/ghost-context') { const value = line.value as | { - product?: unknown; - targetPath?: unknown; - layers?: unknown; - baseDirectionId?: unknown; + product?: unknown; + source?: unknown; + targetPath?: unknown; + layers?: unknown; + baseDirectionId?: unknown; styleSource?: unknown; } | undefined; const product = typeof value?.product === 'string' ? value.product : 'Ghost'; + const source = typeof value?.source === 'string' ? value.source : 'root'; const targetPath = typeof value?.targetPath === 'string' ? value.targetPath : '.'; const layers = Array.isArray(value?.layers) ? value.layers.filter((layer): layer is string => typeof layer === 'string') @@ -1104,7 +1106,7 @@ function applyLineTo(target: SandboxTarget, line: ProtocolLine, context: Surface const style = typeof value?.styleSource === 'string' ? value.styleSource : 'unknown'; target.onLog( 'op-meta', - `ghost context → ${product}; target=${targetPath}; layers=${layers.join(' › ') || '.'}; base=${base}; style=${style}`, + `ghost context → ${product}; source=${source}; target=${targetPath}; layers=${layers.join(' › ') || '.'}; base=${base}; style=${style}`, ); return; } diff --git a/apps/server/src/generate-route.test.ts b/apps/server/src/generate-route.test.ts index 045e7e8..c5330de 100644 --- a/apps/server/src/generate-route.test.ts +++ b/apps/server/src/generate-route.test.ts @@ -224,6 +224,65 @@ test('api generate sends narrowed contract and stream meta shape through package persistence: 'replayable', }); assert.deepEqual((policyLines[1] as Extract).value, surfacePlan); + + const ghostResponse = await fetch(`http://127.0.0.1:${appPort}/api/generate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + prompt: 'build checkout status', + mode: 'static', + ghost: { + source: 'resolved-context', + id: 'checkout', + product: 'Checkout', + prompt: 'You are working inside the Checkout product experience.', + provenance: { layers: ['portable-bundle'] }, + }, + }), + }); + const ghostBody = await ghostResponse.text(); + assert.equal(ghostResponse.status, 200, ghostBody); + + assert.equal(anthropicRequests.length, 3); + const ghostRequest = anthropicRequests[2] as { system?: Array<{ text?: string }>; stream?: boolean }; + const ghostSystemText = ghostRequest.system?.map((block) => block.text ?? '').join('\n') ?? ''; + assert.match(ghostSystemText, /Checkout product experience/); + + const ghostLines = ghostBody + .trim() + .split(/\n/) + .filter(Boolean) + .map((raw) => JSON.parse(raw) as ProtocolLine); + assert.deepEqual(ghostLines.slice(0, 4).map((line) => `${line.op} ${line.path}`), [ + 'meta /ghost-context', + 'meta /ghost-token-source', + 'meta /surface-plan', + 'meta /status', + ]); + const ghostContext = ghostLines.find((line) => line.path === '/ghost-context') as Extract; + assert.equal((ghostContext.value as { source?: unknown }).source, 'resolved-context'); + assert.equal((ghostContext.value as { product?: unknown }).product, 'Checkout'); + const ghostTokenSource = ghostLines.find((line) => line.path === '/ghost-token-source') as Extract; + assert.equal((ghostTokenSource.value as { kind?: unknown }).kind, 'base-direction'); + const ghostReviewPacket = ghostLines.find((line) => line.path === '/ghost-review-packet') as Extract; + assert.equal((ghostReviewPacket.value as { source?: unknown }).source, 'resolved-context'); + + const ghostOverrideResponse = await fetch(`http://127.0.0.1:${appPort}/api/generate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + prompt: 'build checkout status', + ghost: { + source: 'resolved-context', + prompt: 'You are working inside the Checkout product experience.', + }, + tokenOverrides: { 'color-accent': 'red' }, + }), + }); + const ghostOverrideBody = await ghostOverrideResponse.text(); + assert.equal(ghostOverrideResponse.status, 400); + assert.match(ghostOverrideBody, /tokenOverrides are not supported with Ghost product memory/); + assert.equal(anthropicRequests.length, 3); }); function sse(event: string, data: unknown): string { diff --git a/apps/server/src/ghost-adapter.test.ts b/apps/server/src/ghost-adapter.test.ts index bff03e0..ba01aa1 100644 --- a/apps/server/src/ghost-adapter.test.ts +++ b/apps/server/src/ghost-adapter.test.ts @@ -46,6 +46,7 @@ describe('Ghost adapter', () => { const parsed = parseGhostRequest({ rootId: 'checkout' }, roots); assert.equal(parsed.ok, true); assert.deepEqual(parsed.ok ? parsed.request : null, { + source: 'root', rootId: 'checkout', targetPath: '.', memoryDir: '.ghost', @@ -60,11 +61,15 @@ describe('Ghost adapter', () => { }, roots); assert.equal(withBase.ok, true); assert.deepEqual(withBase.ok ? withBase.request : null, { + source: 'root', rootId: 'checkout', targetPath: 'app', memoryDir: '.ghost', baseDirectionId: 'ghost', }); + const explicitRoot = parseGhostRequest({ source: 'root', rootId: 'checkout' }, roots); + assert.equal(explicitRoot.ok, true); + assert.equal(explicitRoot.ok ? explicitRoot.request?.source : null, 'root'); assert.deepEqual(parseGhostRequest({ rootId: 'checkout', baseDirectionId: '../ghost' }, roots), { ok: false, error: 'ghost.baseDirectionId must be a valid direction id', @@ -80,6 +85,8 @@ describe('Ghost adapter', () => { const ctx = await resolveGhostContext(parsed.request, roots); + assert.equal(ctx.source, 'root'); + if (ctx.source !== 'root') assert.fail('expected root context'); assert.equal(ctx.tokenSource.kind, 'ghost-config'); assert.equal(ctx.tokenSource.source, 'tokens.css'); assert.equal(ctx.tokenSource.css, await readDefaultTokensCss()); @@ -87,6 +94,21 @@ describe('Ghost adapter', () => { assert.match(ctx.prompt, /Test Product/); assert.match(ctx.prompt, /quiet/); assert.match(ctx.prompt, /exacting workflows/); + assert.match(ctx.prompt, /Human-approved test intent/); + }); + + it('rejects legacy single-file fingerprints', async () => { + const root = await makeLegacyGhostFixture(); + const roots = parseGhostRoots(`checkout=${root}`); + const parsed = parseGhostRequest({ rootId: 'checkout' }, roots); + assert.equal(parsed.ok, true); + if (!parsed.ok || !parsed.request) assert.fail('expected valid Ghost request'); + const request = parsed.request; + + await assert.rejects( + () => resolveGhostContext(request, roots), + /No \.ghost\/fingerprint\.yml found/, + ); }); it('falls back to Summon default tokens when Ghost token CSS is missing or invalid', async () => { @@ -98,6 +120,8 @@ describe('Ghost adapter', () => { const ctx = await resolveGhostContext(parsed.request, roots); + assert.equal(ctx.source, 'root'); + if (ctx.source !== 'root') assert.fail('expected root context'); assert.equal(ctx.tokenSource.kind, 'summon-default'); assert.equal(ctx.tokenSource.source, '@anarchitecture/summon/tokens.css'); assert.match(ctx.tokenSource.css, /--color-bg:/); @@ -117,6 +141,8 @@ describe('Ghost adapter', () => { tokensCss: baseTokens, }); + assert.equal(ctx.source, 'root'); + if (ctx.source !== 'root') assert.fail('expected root context'); assert.equal(ctx.baseDirectionId, 'ghost'); assert.equal(ctx.tokenSource.kind, 'base-direction'); assert.equal(ctx.tokenSource.source, 'direction:ghost/tokens.css'); @@ -131,6 +157,8 @@ describe('Ghost adapter', () => { assert.equal(parsed.ok, true); if (!parsed.ok || !parsed.request) assert.fail('expected valid Ghost request'); const ctx = await resolveGhostContext(parsed.request, roots); + assert.equal(ctx.source, 'root'); + if (ctx.source !== 'root') assert.fail('expected root context'); const packet = buildGhostReviewPacket({ context: ctx, @@ -146,6 +174,7 @@ describe('Ghost adapter', () => { }); assert.equal(packet.schema, 'summon.ghost-generation/v1'); + assert.equal(packet.source, 'root'); assert.equal(packet.rootId, 'checkout'); assert.equal(packet.product, 'Test Product'); assert.equal(packet.baseDirectionId, null); @@ -162,6 +191,86 @@ describe('Ghost adapter', () => { assert.equal(packet.tokenSource.kind, 'summon-default'); assert.equal('css' in packet.tokenSource, false); }); + + it('resolves caller-provided Ghost context without repo access', async () => { + const roots = parseGhostRoots(''); + const parsed = parseGhostRequest({ + source: 'resolved-context', + id: 'checkout', + product: 'Checkout', + prompt: 'You are working inside the Checkout product experience.', + provenance: { layers: ['portable'] }, + }, roots); + assert.equal(parsed.ok, true); + if (!parsed.ok || !parsed.request) assert.fail('expected valid resolved context request'); + + const ctx = await resolveGhostSteer(parsed.request, roots); + + assert.equal(ctx.source, 'resolved-context'); + assert.equal(ctx.prompt, 'You are working inside the Checkout product experience.'); + assert.equal(ctx.product, 'Checkout'); + assert.equal(ctx.root, null); + assert.equal(ctx.stack, null); + assert.equal(ctx.tokenSource.kind, 'summon-default'); + assert.deepEqual(ctx.provenance, { layers: ['portable'] }); + }); + + it('uses valid resolved-context tokens', async () => { + const tokens = await readDefaultTokensCss(); + const roots = parseGhostRoots(''); + const parsed = parseGhostRequest({ + source: 'resolved-context', + prompt: 'Use portable Ghost memory.', + tokensCss: tokens, + tokenSource: 'bundle/tokens.css', + }, roots); + assert.equal(parsed.ok, true); + if (!parsed.ok || !parsed.request) assert.fail('expected valid resolved context request'); + + const ctx = await resolveGhostSteer(parsed.request, roots); + + assert.equal(ctx.source, 'resolved-context'); + assert.equal(ctx.tokenSource.kind, 'resolved-context'); + assert.equal(ctx.tokenSource.source, 'bundle/tokens.css'); + assert.equal(ctx.tokenSource.css, tokens); + }); + + it('falls back from invalid resolved-context tokens to base direction tokens', async () => { + const baseTokens = await readDefaultTokensCss(); + const roots = parseGhostRoots(''); + const parsed = parseGhostRequest({ + source: 'resolved-context', + prompt: 'Use portable Ghost memory.', + tokensCss: ':root { --color-bg: red; }', + tokenSource: 'bundle/tokens.css', + baseDirectionId: 'ghost', + }, roots); + assert.equal(parsed.ok, true); + if (!parsed.ok || !parsed.request) assert.fail('expected valid resolved context request'); + + const ctx = await resolveGhostSteer(parsed.request, roots, { + id: 'ghost', + tokensCss: baseTokens, + }); + + assert.equal(ctx.source, 'resolved-context'); + assert.equal(ctx.tokenSource.kind, 'base-direction'); + assert.equal(ctx.tokenSource.source, 'direction:ghost/tokens.css'); + assert.ok(ctx.tokenSource.warnings.some((warning) => warning.includes('bundle/tokens.css failed token contract'))); + }); + + it('rejects resolved-context requests without prompt', () => { + const roots = parseGhostRoots(''); + + assert.deepEqual(parseGhostRequest({ source: 'resolved-context' }, roots), { + ok: false, + error: 'ghost.prompt is required for resolved-context', + }); + assert.deepEqual(parseGhostRequest({ source: 'resolved-context', prompt: ' ' }, roots), { + ok: false, + error: 'ghost.prompt is required for resolved-context', + }); + }); }); async function makeGhostFixture(options: { tokenCss?: string } = {}): Promise { @@ -190,15 +299,27 @@ principles: experience_contracts: [] patterns: - id: measured-surfaces - kind: visual status: accepted + kind: visual pattern: Surfaces are compact, rectangular, and information-first. implementation_vocabulary: tokens: [--color-bg, --color-text] components: [] -review_policy: - proposal_policy: - - Agents propose memory changes; humans promote durable truth. +review_policy: {} +`, + ); + await writeFile( + join(root, '.ghost', 'checks.yml'), + `schema: ghost.checks/v1 +id: test-product +checks: [] +`, + ); + await writeFile( + join(root, '.ghost', 'intent.md'), + `# Intent + +Human-approved test intent keeps generated surfaces grounded. `, ); await writeFile( @@ -218,6 +339,46 @@ libraries: [] return root; } +async function makeLegacyGhostFixture(): Promise { + const root = await mkdtemp(join(tmpdir(), 'summon-ghost-adapter-legacy-')); + fixtureRoots.push(root); + await mkdir(join(root, '.ghost'), { recursive: true }); + await writeFile( + join(root, '.ghost', 'fingerprint.md'), + `schema: ghost.fingerprint/v1 +summary: + product: Test Product + audience: [operators] + goals: [keep work legible] + tone: [quiet, exacting workflows] +topology: + scopes: + - id: app + paths: [.] + surface_types: [dashboard] + surface_types: [dashboard] +situations: [] +principles: + - id: calm-density + status: accepted + principle: Preserve quiet density and clear hierarchy. +experience_contracts: [] +patterns: + - id: measured-surfaces + kind: visual + status: accepted + pattern: Surfaces are compact, rectangular, and information-first. +implementation_vocabulary: + tokens: [--color-bg, --color-text] + components: [] +review_policy: + proposal_policy: + - Agents propose memory changes; humans promote durable truth. +`, + ); + return root; +} + async function readDefaultTokensCss(): Promise { const here = dirname(fileURLToPath(import.meta.url)); return readFile( diff --git a/apps/server/src/ghost-adapter.ts b/apps/server/src/ghost-adapter.ts index d4eefc9..67e00dc 100644 --- a/apps/server/src/ghost-adapter.ts +++ b/apps/server/src/ghost-adapter.ts @@ -9,6 +9,7 @@ import { type PackageMemory, } from '@anarchitecture/ghost/scan'; import { compileTokenContract, type ProtocolLine } from '@anarchitecture/summon/engine'; +import type { GhostGenerationContext } from '@anarchitecture/summon-server'; import { existsSync, readFileSync, statSync } from 'node:fs'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -37,13 +38,27 @@ const DEFAULT_TOKENS_CSS = readFileSync( 'utf-8', ); -export interface GhostRequest { +export interface GhostRootRequest { + source: 'root'; rootId: string; targetPath: string; memoryDir: string; baseDirectionId: string | null; } +export interface GhostResolvedContextRequest { + source: 'resolved-context'; + id?: string; + product?: string; + prompt: string; + tokensCss?: string; + tokenSource?: string; + provenance?: unknown; + baseDirectionId: string | null; +} + +export type GhostRequest = GhostRootRequest | GhostResolvedContextRequest; + export interface GhostRoot { id: string; root: string; @@ -52,7 +67,7 @@ export interface GhostRoot { export type GhostRoots = Map; export interface GhostTokenSource { - kind: 'ghost-config' | 'base-direction' | 'summon-default'; + kind: 'ghost-config' | 'resolved-context' | 'base-direction' | 'summon-default'; source: string; css: string; warnings: string[]; @@ -64,32 +79,51 @@ export interface GhostBaseDirection { tokensCss: string; } -export interface ResolvedGhostSteer { - request: GhostRequest; +export interface ResolvedRootGhostSteer extends GhostGenerationContext { + source: 'root'; + request: GhostRootRequest; root: string; stack: GhostMemoryStack; - memory: PackageMemory; + context: PackageMemory; + prompt: string; + tokenSource: GhostTokenSource; + baseDirectionId: string | null; + product: string; +} + +export interface ResolvedContextGhostSteer extends GhostGenerationContext { + source: 'resolved-context'; + request: GhostResolvedContextRequest; + root: null; + stack: null; + context: null; prompt: string; tokenSource: GhostTokenSource; baseDirectionId: string | null; + product?: string; + provenance?: unknown; } +export type ResolvedGhostSteer = ResolvedRootGhostSteer | ResolvedContextGhostSteer; + export type ResolvedGhostContext = ResolvedGhostSteer; export interface GhostReviewPacket { schema: 'summon.ghost-generation/v1'; + source: ResolvedGhostSteer['source']; prompt: string; - rootId: string; - targetPath: string; - memoryDir: string; + rootId: string | null; + targetPath: string | null; + memoryDir: string | null; product: string; layers: string[]; memoryProvenance: { - merge: GhostMemoryStack['provenance']['merge']; + merge: GhostMemoryStack['provenance']['merge'] | 'external'; layers: Array<{ relativeRoot: string; memoryDir: string; }>; + provenance?: unknown; }; tokenSource: Omit; baseDirectionId: string | null; @@ -149,6 +183,48 @@ export function parseGhostRequest( } const obj = raw as Record; + const source = obj.source === undefined || obj.source === null || obj.source === '' + ? 'root' + : obj.source; + if (source !== 'root' && source !== 'resolved-context') { + return { ok: false, error: 'ghost.source must be "root" or "resolved-context"' }; + } + + const baseDirectionId = parseBaseDirectionId(obj.baseDirectionId); + if (!baseDirectionId.ok) return { ok: false, error: baseDirectionId.error }; + + if (source === 'resolved-context') { + const prompt = typeof obj.prompt === 'string' ? obj.prompt.trim() : ''; + if (!prompt) { + return { ok: false, error: 'ghost.prompt is required for resolved-context' }; + } + const request: GhostResolvedContextRequest = { + source: 'resolved-context', + prompt, + baseDirectionId: baseDirectionId.value, + }; + if (obj.id !== undefined && (typeof obj.id !== 'string' || !obj.id.trim())) { + return { ok: false, error: 'ghost.id must be a non-empty string when provided' }; + } + if (typeof obj.id === 'string') request.id = obj.id.trim(); + if (obj.product !== undefined && (typeof obj.product !== 'string' || !obj.product.trim())) { + return { ok: false, error: 'ghost.product must be a non-empty string when provided' }; + } + if (typeof obj.product === 'string') request.product = obj.product.trim(); + if (obj.tokensCss !== undefined && typeof obj.tokensCss !== 'string') { + return { ok: false, error: 'ghost.tokensCss must be a string when provided' }; + } + if (typeof obj.tokensCss === 'string' && obj.tokensCss.trim()) request.tokensCss = obj.tokensCss; + if (obj.tokenSource !== undefined && (typeof obj.tokenSource !== 'string' || !obj.tokenSource.trim())) { + return { ok: false, error: 'ghost.tokenSource must be a non-empty string when provided' }; + } + if (typeof obj.tokenSource === 'string' && obj.tokenSource.trim()) { + request.tokenSource = obj.tokenSource.trim(); + } + if (obj.provenance !== undefined) request.provenance = obj.provenance; + return { ok: true, request }; + } + if (typeof obj.rootId !== 'string' || !ROOT_ID_RE.test(obj.rootId)) { return { ok: false, error: 'ghost.rootId must be a configured root id' }; } @@ -174,21 +250,14 @@ export function parseGhostRequest( } } - let baseDirectionId: string | null = null; - if (obj.baseDirectionId !== undefined && obj.baseDirectionId !== null && obj.baseDirectionId !== '') { - if (typeof obj.baseDirectionId !== 'string' || !ROOT_ID_RE.test(obj.baseDirectionId)) { - return { ok: false, error: 'ghost.baseDirectionId must be a valid direction id' }; - } - baseDirectionId = obj.baseDirectionId; - } - return { ok: true, request: { + source: 'root', rootId: obj.rootId, targetPath: target.path, memoryDir, - baseDirectionId, + baseDirectionId: baseDirectionId.value, }, }; } @@ -197,7 +266,7 @@ export async function resolveGhostContext( request: GhostRequest, roots: GhostRoots, ): Promise { - return resolveGhostSteer(request, roots); + return resolveGhostGenerationContext(request, roots); } export async function resolveGhostSteer( @@ -205,6 +274,37 @@ export async function resolveGhostSteer( roots: GhostRoots, baseDirection: GhostBaseDirection | null = null, ): Promise { + return resolveGhostGenerationContext(request, roots, baseDirection); +} + +export async function resolveGhostGenerationContext( + request: GhostRequest, + roots: GhostRoots, + baseDirection: GhostBaseDirection | null = null, +): Promise { + if (request.source === 'resolved-context') { + const tokenSource = resolveResolvedContextTokenSource(request, baseDirection); + return { + source: 'resolved-context', + request, + root: null, + stack: null, + context: null, + prompt: request.prompt, + product: request.product, + tokenSource, + provenance: request.provenance, + baseDirectionId: baseDirection?.id ?? request.baseDirectionId ?? null, + }; + } + return resolveRootGhostGenerationContext(request, roots, baseDirection); +} + +async function resolveRootGhostGenerationContext( + request: GhostRootRequest, + roots: GhostRoots, + baseDirection: GhostBaseDirection | null, +): Promise { const root = roots.get(request.rootId); if (!root) throw new Error(`unknown Ghost root "${request.rootId}"`); const targetAbs = resolve(root, request.targetPath); @@ -216,33 +316,56 @@ export async function resolveGhostSteer( memoryDir: request.memoryDir, }); if (resolve(stack.repo_root) !== resolve(root)) { - throw new Error('configured Ghost root must resolve to the memory stack repo root'); + throw new Error('configured Ghost root must resolve to the fingerprint stack repo root'); } - const memory = memoryStackToPackageMemory(stack); + const context = memoryStackToPackageMemory(stack); const [prompt, tokenSource] = await Promise.all([ - buildPromptFromMemory(memory), + buildPromptFromContext(context), resolveGhostTokenSource(stack, baseDirection), ]); return { + source: 'root', request, root, stack, - memory, + context, prompt, + product: stack.merged.fingerprint.summary.product ?? context.name, tokenSource, baseDirectionId: baseDirection?.id ?? request.baseDirectionId ?? null, }; } export function ghostContextMeta(ctx: ResolvedGhostContext) { + if (ctx.source === 'resolved-context') { + return { + source: ctx.source, + rootId: ctx.request.id ?? null, + targetPath: null, + memoryDir: null, + layers: [], + product: ctx.product ?? ctx.request.id ?? 'Ghost', + baseDirectionId: ctx.baseDirectionId, + styleSource: ctx.tokenSource.kind, + provenance: ctx.provenance ?? null, + }; + } return { + source: ctx.source, rootId: ctx.request.rootId, targetPath: ctx.stack.target_path, - memoryDir: ctx.stack.memory_dir, + memoryDir: ctx.stack.memory_dir, layers: ctx.stack.layers.map((layer) => layer.relative_root), - product: ctx.stack.merged.fingerprint.summary.product ?? ctx.memory.name, + product: ctx.product, baseDirectionId: ctx.baseDirectionId, styleSource: ctx.tokenSource.kind, + provenance: { + merge: ctx.stack.provenance.merge, + layers: ctx.stack.provenance.layers.map((layer) => ({ + relativeRoot: layer.relative_root, + memoryDir: layer.memory_dir, + })), + }, }; } @@ -270,23 +393,38 @@ export function buildGhostReviewPacket(input: { if (line.op !== 'add' || !line.path.startsWith('/section/')) continue; sectionsById.set(line.path.slice('/section/'.length), line.html ?? ''); } + const rootFields = input.context.source === 'root' + ? { + rootId: input.context.request.rootId, + targetPath: input.context.stack.target_path, + memoryDir: input.context.stack.memory_dir, + product: input.context.product, + layers: input.context.stack.layers.map((layer) => layer.relative_root), + memoryProvenance: { + merge: input.context.stack.provenance.merge, + layers: input.context.stack.provenance.layers.map((layer) => ({ + relativeRoot: layer.relative_root, + memoryDir: layer.memory_dir, + })), + }, + } + : { + rootId: input.context.request.id ?? null, + targetPath: null, + memoryDir: null, + product: input.context.product ?? input.context.request.id ?? 'Ghost', + layers: [], + memoryProvenance: { + merge: 'external' as const, + layers: [], + provenance: input.context.provenance ?? null, + }, + }; return { schema: 'summon.ghost-generation/v1', + source: input.context.source, prompt: input.prompt, - rootId: input.context.request.rootId, - targetPath: input.context.stack.target_path, - memoryDir: input.context.stack.memory_dir, - product: - input.context.stack.merged.fingerprint.summary.product ?? - input.context.memory.name, - layers: input.context.stack.layers.map((layer) => layer.relative_root), - memoryProvenance: { - merge: input.context.stack.provenance.merge, - layers: input.context.stack.provenance.layers.map((layer) => ({ - relativeRoot: layer.relative_root, - memoryDir: layer.memory_dir, - })), - }, + ...rootFields, tokenSource: { kind: input.context.tokenSource.kind, source: input.context.tokenSource.source, @@ -303,10 +441,10 @@ export function buildGhostReviewPacket(input: { }; } -async function buildPromptFromMemory(memory: PackageMemory): Promise { +async function buildPromptFromContext(context: PackageMemory): Promise { const dir = await mkdtemp(join(tmpdir(), 'summon-ghost-context-')); try { - await writePackageContextBundleFromMemory(memory, { + await writePackageContextBundleFromMemory(context, { outDir: dir, promptOnly: true, }); @@ -322,7 +460,7 @@ async function resolveGhostTokenSource( ): Promise { const warnings: string[] = []; for (const layer of [...stack.layers].reverse()) { - const configPath = resolve(layer.dir, 'config.yml'); + const configPath = resolve(layer.root, layer.memory_dir, 'config.yml'); let config; try { config = await readOptionalPackageConfig(configPath); @@ -371,6 +509,40 @@ async function resolveGhostTokenSource( } } } + return resolveFallbackTokenSource(warnings, baseDirection); +} + +function resolveResolvedContextTokenSource( + request: GhostResolvedContextRequest, + baseDirection: GhostBaseDirection | null, +): GhostTokenSource { + const warnings: string[] = []; + if (request.tokensCss) { + const validation = compileTokenContract({ css: request.tokensCss }); + const blocking = validation.issues.filter((issue) => issue.severity === 'block'); + if (blocking.length === 0) { + return { + kind: 'resolved-context', + source: request.tokenSource ?? 'resolved-context:tokensCss', + css: request.tokensCss, + warnings: [ + ...validation.issues + .filter((issue) => issue.severity === 'warn') + .map((issue) => issue.message), + ], + }; + } + warnings.push( + `${request.tokenSource ?? 'resolved-context:tokensCss'} failed token contract: ${blocking.map((issue) => issue.message).join('; ')}`, + ); + } + return resolveFallbackTokenSource(warnings, baseDirection); +} + +function resolveFallbackTokenSource( + warnings: string[], + baseDirection: GhostBaseDirection | null, +): GhostTokenSource { if (baseDirection) { const validation = compileTokenContract({ css: baseDirection.tokensCss }); const blocking = validation.issues.filter((issue) => issue.severity === 'block'); @@ -434,6 +606,18 @@ function resolveTokenPath( return isWithinOrEqual(layer.root, resolved) ? resolved : null; } +function parseBaseDirectionId(raw: unknown): + | { ok: true; value: string | null } + | { ok: false; error: string } { + if (raw === undefined || raw === null || raw === '') { + return { ok: true, value: null }; + } + if (typeof raw !== 'string' || !ROOT_ID_RE.test(raw)) { + return { ok: false, error: 'ghost.baseDirectionId must be a valid direction id' }; + } + return { ok: true, value: raw }; +} + function normalizeTargetPath(raw: unknown): | { ok: true; path: string } | { ok: false; error: string } { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 4e13c07..a63f383 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -38,7 +38,7 @@ import { parseGhostRequest, parseGhostRoots, publicGhostRoots, - resolveGhostSteer, + resolveGhostGenerationContext, type ResolvedGhostSteer, } from './ghost-adapter.js'; import { inferPack } from './infer-capabilities.js'; @@ -476,7 +476,7 @@ app.post('/api/generate', async (req, res) => { let ghostContext: ResolvedGhostSteer | null = null; try { ghostContext = ghostRequest - ? await resolveGhostSteer( + ? await resolveGhostGenerationContext( { ...ghostRequest, baseDirectionId: requestedGhostBaseDirectionId }, ghostRoots, ghostBaseDirection ?? null, @@ -677,7 +677,7 @@ app.post('/api/generate', async (req, res) => { layout, } : null, - ghostPrompt: ghostContext?.prompt ?? null, + ghost: ghostContext ?? null, layout, edit, capabilities: hasSurfacePolicy ? capabilityCeiling : pack, @@ -720,7 +720,7 @@ app.post('/api/generate', async (req, res) => { const stats = summary.repairStats ?? { queued: 0, cancelled: 0, repaired: 0, failed: 0 }; const upgradeTag = modeUpgraded ? ` (upgraded ${inferenceUsed ? 'via inference' : 'via regex'})` : ''; console.log( - `[generate] dir=${directionId ?? 'none'} ghost=${ghostContext?.request.rootId ?? 'none'} mode=${mode}${upgradeTag}` + + `[generate] dir=${directionId ?? 'none'} ghost=${ghostContext ? ghostLogId(ghostContext) : 'none'} mode=${mode}${upgradeTag}` + ` shape=${shape ?? 'all'}` + ` layout=${layout?.id ?? 'none'}` + ` edit=${edit ? 'yes' : 'no'}` + @@ -746,6 +746,12 @@ app.post('/api/generate', async (req, res) => { }); }); +function ghostLogId(context: ResolvedGhostSteer): string { + return context.source === 'root' + ? context.request.rootId + : (context.request.id ?? 'resolved-context'); +} + app.listen(PORT, () => { console.log(`[summon-server] listening on http://localhost:${PORT}`); console.log(`[summon-server] CORS origin: ${ALLOWED_ORIGIN}`); diff --git a/packages/engine/src/contracts.ts b/packages/engine/src/contracts.ts index 1d29b75..f3e656c 100644 --- a/packages/engine/src/contracts.ts +++ b/packages/engine/src/contracts.ts @@ -64,6 +64,28 @@ export interface ContractPromptBlock { cache: 'ephemeral' | 'none'; } +export type GhostGenerationSource = 'root' | 'resolved-context'; + +export type GhostTokenSourceKind = + | 'ghost-config' + | 'resolved-context' + | 'base-direction' + | 'summon-default'; + +export interface GhostGenerationContext { + source?: GhostGenerationSource; + prompt: string; + product?: string; + baseDirectionId?: string | null; + tokenSource?: { + kind: GhostTokenSourceKind; + source: string; + css: string; + warnings: string[]; + }; + provenance?: unknown; +} + export interface CompiledTokenContract { promptVocabulary: string; definedTokens: Set; @@ -110,6 +132,8 @@ export interface CompiledComponentContract { export interface SystemContractInput { mode: ValidationContext['mode']; direction?: DirectionContractInput | null; + ghost?: GhostGenerationContext | null; + /** @deprecated Use `ghost` with a first-class GhostGenerationContext. */ ghostPrompt?: string | null; layout?: SummonLayout | null; editBlock?: string | null; @@ -331,10 +355,11 @@ export function compileSystemContracts( issues.push(...direction.issues); } - if (input.ghostPrompt) { + const ghostPrompt = input.ghost?.prompt ?? input.ghostPrompt; + if (ghostPrompt) { promptBlocks.push({ id: 'ghost', - text: input.ghostPrompt, + text: ghostPrompt, cache: 'ephemeral', }); } diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index af94462..da88e27 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -83,6 +83,9 @@ export type { ContractPromptBlock, CapabilityContractOptions, DirectionContractInput, + GhostGenerationContext, + GhostGenerationSource, + GhostTokenSourceKind, SystemContractInput, TokenContractInput, } from './contracts.js'; diff --git a/packages/engine/test/contracts.test.ts b/packages/engine/test/contracts.test.ts index 6c13bb3..118bb1a 100644 --- a/packages/engine/test/contracts.test.ts +++ b/packages/engine/test/contracts.test.ts @@ -138,7 +138,11 @@ test('system compiler returns deterministic prompt block order and validation co opts: {}, layout, }, - ghostPrompt: 'Ghost context block.', + ghost: { + source: 'resolved-context', + prompt: 'Ghost context block.', + product: 'Ghost Product', + }, layout, editBlock: 'Edit block.', capabilities, @@ -342,13 +346,26 @@ test('system compiler validates against explicit active tokens when direction is exemplars: [], opts: {}, }, - ghostPrompt: 'Ghost context block.', + ghost: { + source: 'resolved-context', + prompt: 'Ghost context block.', + }, activeTokensCss: activeTokens, }); assert.equal(compiled.validationContext.definedTokens?.has('ghost-config-only'), true); }); +test('system compiler keeps deprecated ghostPrompt compatibility', () => { + const compiled = compileSystemContracts({ + mode: 'static', + ghostPrompt: 'Legacy Ghost context block.', + }); + + const ghostBlock = compiled.promptBlocks.find((block) => block.id === 'ghost'); + assert.equal(ghostBlock?.text, 'Legacy Ghost context block.'); +}); + test('system compiler can produce declarative-only interactive contracts', () => { const compiled = compileSystemContracts({ mode: 'interactive', diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 5eb5d8d..519a6d5 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,6 +8,7 @@ export type { GenerateEditInput, GenerateSurfaceInput, GenerationSummary, + GhostGenerationContext, RepairOptions, RepairStats, ResolvedSurfaceGenerationPlan, diff --git a/packages/server/src/session.ts b/packages/server/src/session.ts index ddf1ac3..c01733b 100644 --- a/packages/server/src/session.ts +++ b/packages/server/src/session.ts @@ -57,6 +57,7 @@ export class SurfaceGenerationSession { this.systemContracts = compileSystemContracts({ mode: this.surfacePolicy?.mode ?? input.mode ?? 'static', direction: input.direction ?? null, + ghost: input.ghost ?? null, ghostPrompt: input.ghostPrompt ?? null, layout: input.layout ?? null, editBlock, diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index a726cd9..a735256 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -4,6 +4,7 @@ import type { ContractIssue, ContractPromptBlock, DirectionContractInput, + GhostGenerationContext, ProtocolLine, RepairFeedbackMetaValue, ScriptPolicy, @@ -16,6 +17,8 @@ import type { TokenOverride, } from '@summon-internal/engine'; +export type { GhostGenerationContext } from '@summon-internal/engine'; + export interface SummonModelRequest { prompt: string; promptBlocks: ContractPromptBlock[]; @@ -90,6 +93,8 @@ export interface SurfaceGenerationInput { modelProvider: SummonModelProvider; mode?: 'static' | 'interactive'; direction?: DirectionContractInput | null; + ghost?: GhostGenerationContext | null; + /** @deprecated Use `ghost` with a first-class GhostGenerationContext. */ ghostPrompt?: string | null; layout?: SummonLayout | null; edit?: GenerateEditInput | null; diff --git a/packages/server/test/generate-surface-stream.test.ts b/packages/server/test/generate-surface-stream.test.ts index 689c256..70b970f 100644 --- a/packages/server/test/generate-surface-stream.test.ts +++ b/packages/server/test/generate-surface-stream.test.ts @@ -119,6 +119,28 @@ test('runSurfaceGeneration emits prelude, surface plan, and layout startup befor assert.equal(summary.blocked, false); }); +test('runSurfaceGeneration forwards first-class Ghost context to the model contract', async () => { + let ghostBlockText: string | undefined; + const provider: SummonModelProvider = async function* (request) { + ghostBlockText = request.promptBlocks.find((block) => block.id === 'ghost')?.text; + yield '{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n'; + yield '{"op":"add","path":"/section/hero","html":"

Hello

"}\n'; + }; + + const summary = await runSurfaceGeneration({ + prompt: 'hello', + modelProvider: provider, + mode: 'static', + ghost: { + source: 'resolved-context', + prompt: 'Portable Ghost context.', + }, + }, () => {}); + + assert.equal(summary.blocked, false); + assert.equal(ghostBlockText, 'Portable Ghost context.'); +}); + test('runSurfaceGeneration compiles surface policy, emits metadata, and narrows contracts', async () => { const lines: ProtocolLine[] = []; let systemText = ''; diff --git a/packages/summon-server/src/index.ts b/packages/summon-server/src/index.ts index 2eed851..7a7c520 100644 --- a/packages/summon-server/src/index.ts +++ b/packages/summon-server/src/index.ts @@ -10,6 +10,7 @@ export type { GenerateEditInput, GenerateSurfaceInput, GenerationSummary, + GhostGenerationContext, ProtocolLine, ProtocolSkipMetaValue, RepairFeedbackMetaValue, diff --git a/scripts/build-public-packages.mjs b/scripts/build-public-packages.mjs index eb55a74..aa15a51 100644 --- a/scripts/build-public-packages.mjs +++ b/scripts/build-public-packages.mjs @@ -191,6 +191,9 @@ const coreExports = { 'DirectionInput', 'DirectionOpts', 'Exemplar', + 'GhostGenerationContext', + 'GhostGenerationSource', + 'GhostTokenSourceKind', 'IntentSpec', 'MetaLine', 'NormalizedSurfacePolicy', @@ -466,6 +469,7 @@ const serverExports = { 'GenerateEditInput', 'GenerateSurfaceInput', 'GenerationSummary', + 'GhostGenerationContext', 'ProtocolLine', 'ProtocolSkipMetaValue', 'RepairFeedbackMetaValue', diff --git a/scripts/public-api-manifest.json b/scripts/public-api-manifest.json index c497795..6428d1a 100644 --- a/scripts/public-api-manifest.json +++ b/scripts/public-api-manifest.json @@ -235,6 +235,9 @@ "DirectionInput", "DirectionOpts", "Exemplar", + "GhostGenerationContext", + "GhostGenerationSource", + "GhostTokenSourceKind", "IntentSpec", "MetaLine", "NormalizedSurfacePolicy", @@ -421,6 +424,7 @@ "GenerateEditInput", "GenerateSurfaceInput", "GenerationSummary", + "GhostGenerationContext", "ProtocolLine", "ProtocolSkipMetaValue", "RepairFeedbackMetaValue", diff --git a/tests/safety-smoke.spec.ts b/tests/safety-smoke.spec.ts index 692dee7..f1e1d51 100644 --- a/tests/safety-smoke.spec.ts +++ b/tests/safety-smoke.spec.ts @@ -58,11 +58,13 @@ test('generate page boots without server credentials', async ({ page }) => { await expect(page.locator('#sandbox')).toHaveAttribute('sandbox', 'allow-scripts'); await expect(page.locator('#go')).toBeEnabled(); await expect(page.locator('#welcome')).toBeVisible(); - await expect(page.locator('#welcome')).toContainText('Host-resource search'); - await expect(page.locator('#scenario')).toContainText('Host-resource search'); + await expect(page.locator('#welcome')).toContainText('Host Data Search'); + await expect(page.locator('#scenario')).toContainText('Host Data Search'); await expect(page.locator('.generate-shell')).toBeVisible(); - await expect(page.locator('.scenario-card.active')).toContainText('Host-resource search'); - await expect(page.locator('#contract-summary [data-contract-row="requested"]')).toContainText('Requested Plan'); + await expect(page.locator('.scenario-card.active')).toContainText('Host Data Search'); + await expect(page.locator('#contract-summary [data-contract-row="requested"]')).toContainText( + 'Requested surface config', + ); await expect(page.locator('#custom-contract-panel')).toBeHidden(); await page.locator('#custom-contract-enabled').check(); await expect(page.locator('#custom-contract-panel')).toBeVisible(); @@ -116,12 +118,12 @@ test('generate showcase sends narrowed scenario contract', async ({ page }) => { }); await page.goto('/generate.html'); - await expect(page.locator('#scenario')).toContainText('Repair diagnostics'); + await expect(page.locator('#scenario')).toContainText('Validation Retry Diagnostics'); await page.locator('#scenario').selectOption('repair-diagnostics'); await page.locator('#token-preset').selectOption('accent-blue'); await expect(page.locator('#prompt')).toHaveValue(/onboarding checklist/); - await expect(page.locator('.scenario-card.active')).toContainText('Repair diagnostics'); + await expect(page.locator('.scenario-card.active')).toContainText('Validation Retry Diagnostics'); await expect(page.locator('#repair-enabled')).toBeChecked(); await expect(page.locator('#custom-contract-panel')).toBeHidden(); await page.locator('#custom-contract-enabled').check(); @@ -133,7 +135,9 @@ test('generate showcase sends narrowed scenario contract', async ({ page }) => { await expect(page.locator('#iframe-status')).toContainText('done'); await expect(page.locator('#result-toolbar')).toBeVisible(); await expect(page.locator('#edit-card')).toBeVisible(); - await expect(page.locator('#contract-summary [data-contract-row="effective"]')).toContainText('collect/declarative'); + await expect(page.locator('#contract-summary [data-contract-row="effective"]')).toContainText( + 'collect · declarative', + ); expect(captured).toBeTruthy(); expect(captured.mode).toBe('interactive');