From b51b834af77734e9dc9193fac528dd46e994b490 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Tue, 9 Jun 2026 12:59:17 -0400 Subject: [PATCH 1/3] Repair Summon Ghost fingerprint input --- apps/server/package.json | 2 +- apps/server/src/ghost-adapter.test.ts | 108 ++++++++++++++++++++++---- apps/server/src/ghost-adapter.ts | 52 ++++++------- pnpm-lock.yaml | 4 +- 4 files changed, 122 insertions(+), 44 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index c598fb4..6001f70 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -10,7 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@anarchitecture/ghost": "^0.2.0", + "@anarchitecture/ghost": "^0.7.1", "@anthropic-ai/sdk": "^0.88.0", "cors": "^2.8.5", "express": "^4.21.2", diff --git a/apps/server/src/ghost-adapter.test.ts b/apps/server/src/ghost-adapter.test.ts index bff03e0..7ff5533 100644 --- a/apps/server/src/ghost-adapter.test.ts +++ b/apps/server/src/ghost-adapter.test.ts @@ -83,10 +83,25 @@ describe('Ghost adapter', () => { assert.equal(ctx.tokenSource.kind, 'ghost-config'); assert.equal(ctx.tokenSource.source, 'tokens.css'); assert.equal(ctx.tokenSource.css, await readDefaultTokensCss()); - assert.equal(ctx.stack.merged.fingerprint.summary.product, 'Test Product'); + assert.equal(ctx.stack.merged.fingerprint.prose.summary.product, 'Test Product'); 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\/manifest\.yml found/, + ); }); it('falls back to Summon default tokens when Ghost token CSS is missing or invalid', async () => { @@ -167,6 +182,83 @@ describe('Ghost adapter', () => { async function makeGhostFixture(options: { tokenCss?: string } = {}): Promise { const root = await mkdtemp(join(tmpdir(), 'summon-ghost-adapter-')); fixtureRoots.push(root); + await mkdir(join(root, '.ghost', 'fingerprint', 'enforcement'), { recursive: true }); + await mkdir(join(root, '.ghost', 'fingerprint', 'memory'), { recursive: true }); + await writeFile( + join(root, '.ghost', 'fingerprint', 'manifest.yml'), + `schema: ghost.fingerprint-package/v1 +id: test-product +`, + ); + await writeFile( + join(root, '.ghost', 'fingerprint', 'prose.yml'), + `summary: + product: Test Product + audience: [operators] + goals: [keep work legible] + tone: [quiet, exacting workflows] +situations: [] +principles: + - id: calm-density + principle: Preserve quiet density and clear hierarchy. +experience_contracts: [] +`, + ); + await writeFile( + join(root, '.ghost', 'fingerprint', 'inventory.yml'), + `topology: + scopes: + - id: app + paths: [.] + surface_types: [dashboard] + surface_types: [dashboard] +building_blocks: + tokens: [--color-bg, --color-text] + components: [] +`, + ); + await writeFile( + join(root, '.ghost', 'fingerprint', 'composition.yml'), + `patterns: + - id: measured-surfaces + kind: visual + pattern: Surfaces are compact, rectangular, and information-first. +`, + ); + await writeFile( + join(root, '.ghost', 'fingerprint', 'enforcement', 'checks.yml'), + `schema: ghost.checks/v1 +id: test-product +checks: [] +`, + ); + await writeFile( + join(root, '.ghost', 'fingerprint', 'memory', 'intent.md'), + `# Intent + +Human-approved test intent keeps generated surfaces grounded. +`, + ); + await writeFile( + join(root, '.ghost', 'config.yml'), + `schema: ghost.config/v1 +targets: + - id: web + platform: web + roots: [.] + tokens: [tokens.css] +libraries: [] +`, + ); + if (options.tokenCss !== undefined) { + await writeFile(join(root, 'tokens.css'), options.tokenCss); + } + 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.yml'), @@ -201,20 +293,6 @@ review_policy: - Agents propose memory changes; humans promote durable truth. `, ); - await writeFile( - join(root, '.ghost', 'config.yml'), - `schema: ghost.config/v1 -targets: - - id: web - platform: web - roots: [.] - tokens: [tokens.css] -libraries: [] -`, - ); - if (options.tokenCss !== undefined) { - await writeFile(join(root, 'tokens.css'), options.tokenCss); - } return root; } diff --git a/apps/server/src/ghost-adapter.ts b/apps/server/src/ghost-adapter.ts index d4eefc9..9f0210f 100644 --- a/apps/server/src/ghost-adapter.ts +++ b/apps/server/src/ghost-adapter.ts @@ -1,12 +1,12 @@ import { - loadMemoryStackForPath, - memoryStackToPackageMemory, + fingerprintStackToPackageContext, + loadFingerprintStackForPath, normalizeMemoryDir, readOptionalPackageConfig, - writePackageContextBundleFromMemory, - type GhostMemoryStack, - type GhostMemoryStackLayer, - type PackageMemory, + writePackageContextBundleFromContext, + type GhostFingerprintStack, + type GhostFingerprintStackLayer, + type PackageContext, } from '@anarchitecture/ghost/scan'; import { compileTokenContract, type ProtocolLine } from '@anarchitecture/summon/engine'; import { existsSync, readFileSync, statSync } from 'node:fs'; @@ -67,8 +67,8 @@ export interface GhostBaseDirection { export interface ResolvedGhostSteer { request: GhostRequest; root: string; - stack: GhostMemoryStack; - memory: PackageMemory; + stack: GhostFingerprintStack; + context: PackageContext; prompt: string; tokenSource: GhostTokenSource; baseDirectionId: string | null; @@ -85,7 +85,7 @@ export interface GhostReviewPacket { product: string; layers: string[]; memoryProvenance: { - merge: GhostMemoryStack['provenance']['merge']; + merge: GhostFingerprintStack['provenance']['merge']; layers: Array<{ relativeRoot: string; memoryDir: string; @@ -212,22 +212,22 @@ export async function resolveGhostSteer( throw new Error('ghost.targetPath must stay within the configured root'); } - const stack = await loadMemoryStackForPath(request.targetPath, root, { + const stack = await loadFingerprintStackForPath(request.targetPath, root, { 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 = fingerprintStackToPackageContext(stack); const [prompt, tokenSource] = await Promise.all([ - buildPromptFromMemory(memory), + buildPromptFromContext(context), resolveGhostTokenSource(stack, baseDirection), ]); return { request, root, stack, - memory, + context, prompt, tokenSource, baseDirectionId: baseDirection?.id ?? request.baseDirectionId ?? null, @@ -238,9 +238,9 @@ export function ghostContextMeta(ctx: ResolvedGhostContext) { return { rootId: ctx.request.rootId, targetPath: ctx.stack.target_path, - memoryDir: ctx.stack.memory_dir, + memoryDir: ctx.stack.fingerprint_dir, layers: ctx.stack.layers.map((layer) => layer.relative_root), - product: ctx.stack.merged.fingerprint.summary.product ?? ctx.memory.name, + product: ctx.stack.merged.fingerprint.prose.summary.product ?? ctx.context.name, baseDirectionId: ctx.baseDirectionId, styleSource: ctx.tokenSource.kind, }; @@ -275,16 +275,16 @@ export function buildGhostReviewPacket(input: { prompt: input.prompt, rootId: input.context.request.rootId, targetPath: input.context.stack.target_path, - memoryDir: input.context.stack.memory_dir, + memoryDir: input.context.stack.fingerprint_dir, product: - input.context.stack.merged.fingerprint.summary.product ?? - input.context.memory.name, + input.context.stack.merged.fingerprint.prose.summary.product ?? + input.context.context.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, + memoryDir: layer.fingerprint_dir, })), }, tokenSource: { @@ -303,10 +303,10 @@ export function buildGhostReviewPacket(input: { }; } -async function buildPromptFromMemory(memory: PackageMemory): Promise { +async function buildPromptFromContext(context: PackageContext): Promise { const dir = await mkdtemp(join(tmpdir(), 'summon-ghost-context-')); try { - await writePackageContextBundleFromMemory(memory, { + await writePackageContextBundleFromContext(context, { outDir: dir, promptOnly: true, }); @@ -317,12 +317,12 @@ async function buildPromptFromMemory(memory: PackageMemory): Promise { } async function resolveGhostTokenSource( - stack: GhostMemoryStack, + stack: GhostFingerprintStack, baseDirection: GhostBaseDirection | null, ): Promise { const warnings: string[] = []; for (const layer of [...stack.layers].reverse()) { - const configPath = resolve(layer.dir, 'config.yml'); + const configPath = resolve(layer.root, layer.fingerprint_dir, 'config.yml'); let config; try { config = await readOptionalPackageConfig(configPath); @@ -405,7 +405,7 @@ async function resolveGhostTokenSource( } function resolveTokenPath( - layer: GhostMemoryStackLayer, + layer: GhostFingerprintStackLayer, rawRef: string, ): string | null { const raw = rawRef.trim(); @@ -478,7 +478,7 @@ function isWithinOrEqual(root: string, child: string): boolean { return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); } -function displayPath(stack: GhostMemoryStack, absPath: string): string { +function displayPath(stack: GhostFingerprintStack, absPath: string): string { const rel = relative(stack.repo_root, absPath); return rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : absPath; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cfd3c3..93d6d98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,8 +46,8 @@ importers: apps/server: dependencies: '@anarchitecture/ghost': - specifier: ^0.2.0 - version: 0.2.0 + specifier: ^0.7.1 + version: 0.7.1 '@anarchitecture/summon': specifier: workspace:* version: link:../../packages/summon From 0e70a12859e03e6768f3d37add7332074ad35aa6 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Tue, 9 Jun 2026 16:54:47 -0400 Subject: [PATCH 2/3] Add first-class Ghost context input --- apps/demo/src/generate-main.ts | 12 +- apps/server/src/generate-route.test.ts | 59 ++++ apps/server/src/ghost-adapter.test.ts | 149 +++++++-- apps/server/src/ghost-adapter.ts | 284 +++++++++++++++--- apps/server/src/main.ts | 14 +- packages/engine/src/contracts.ts | 29 +- packages/engine/src/index.ts | 3 + packages/engine/test/contracts.test.ts | 21 +- packages/server/src/index.ts | 1 + packages/server/src/session.ts | 1 + packages/server/src/types.ts | 5 + .../test/generate-surface-stream.test.ts | 22 ++ packages/summon-server/src/index.ts | 1 + scripts/build-public-packages.mjs | 4 + scripts/public-api-manifest.json | 4 + 15 files changed, 513 insertions(+), 96 deletions(-) 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 7ff5533..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,10 +85,12 @@ 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()); - assert.equal(ctx.stack.merged.fingerprint.prose.summary.product, 'Test Product'); + assert.equal(ctx.stack.merged.fingerprint.summary.product, 'Test Product'); assert.match(ctx.prompt, /Test Product/); assert.match(ctx.prompt, /quiet/); assert.match(ctx.prompt, /exacting workflows/); @@ -100,7 +107,7 @@ describe('Ghost adapter', () => { await assert.rejects( () => resolveGhostContext(request, roots), - /No \.ghost\/fingerprint\/manifest\.yml found/, + /No \.ghost\/fingerprint\.yml found/, ); }); @@ -113,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:/); @@ -132,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'); @@ -146,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, @@ -161,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); @@ -177,63 +191,132 @@ 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 { const root = await mkdtemp(join(tmpdir(), 'summon-ghost-adapter-')); fixtureRoots.push(root); - await mkdir(join(root, '.ghost', 'fingerprint', 'enforcement'), { recursive: true }); - await mkdir(join(root, '.ghost', 'fingerprint', 'memory'), { recursive: true }); - await writeFile( - join(root, '.ghost', 'fingerprint', 'manifest.yml'), - `schema: ghost.fingerprint-package/v1 -id: test-product -`, - ); + await mkdir(join(root, '.ghost'), { recursive: true }); await writeFile( - join(root, '.ghost', 'fingerprint', 'prose.yml'), - `summary: + join(root, '.ghost', 'fingerprint.yml'), + `schema: ghost.fingerprint/v1 +summary: product: Test Product audience: [operators] goals: [keep work legible] tone: [quiet, exacting workflows] -situations: [] -principles: - - id: calm-density - principle: Preserve quiet density and clear hierarchy. -experience_contracts: [] -`, - ); - await writeFile( - join(root, '.ghost', 'fingerprint', 'inventory.yml'), - `topology: +topology: scopes: - id: app paths: [.] surface_types: [dashboard] surface_types: [dashboard] -building_blocks: - tokens: [--color-bg, --color-text] - components: [] -`, - ); - await writeFile( - join(root, '.ghost', 'fingerprint', 'composition.yml'), - `patterns: +situations: [] +principles: + - id: calm-density + status: accepted + principle: Preserve quiet density and clear hierarchy. +experience_contracts: [] +patterns: - id: measured-surfaces + status: accepted kind: visual pattern: Surfaces are compact, rectangular, and information-first. +implementation_vocabulary: + tokens: [--color-bg, --color-text] + components: [] +review_policy: {} `, ); await writeFile( - join(root, '.ghost', 'fingerprint', 'enforcement', 'checks.yml'), + join(root, '.ghost', 'checks.yml'), `schema: ghost.checks/v1 id: test-product checks: [] `, ); await writeFile( - join(root, '.ghost', 'fingerprint', 'memory', 'intent.md'), + join(root, '.ghost', 'intent.md'), `# Intent Human-approved test intent keeps generated surfaces grounded. @@ -261,7 +344,7 @@ async function makeLegacyGhostFixture(): Promise { fixtureRoots.push(root); await mkdir(join(root, '.ghost'), { recursive: true }); await writeFile( - join(root, '.ghost', 'fingerprint.yml'), + join(root, '.ghost', 'fingerprint.md'), `schema: ghost.fingerprint/v1 summary: product: Test Product diff --git a/apps/server/src/ghost-adapter.ts b/apps/server/src/ghost-adapter.ts index 9f0210f..67e00dc 100644 --- a/apps/server/src/ghost-adapter.ts +++ b/apps/server/src/ghost-adapter.ts @@ -1,14 +1,15 @@ import { - fingerprintStackToPackageContext, - loadFingerprintStackForPath, + loadMemoryStackForPath, + memoryStackToPackageMemory, normalizeMemoryDir, readOptionalPackageConfig, - writePackageContextBundleFromContext, - type GhostFingerprintStack, - type GhostFingerprintStackLayer, - type PackageContext, + writePackageContextBundleFromMemory, + type GhostMemoryStack, + type GhostMemoryStackLayer, + 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: GhostFingerprintStack; - context: PackageContext; + stack: GhostMemoryStack; + 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: GhostFingerprintStack['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); @@ -212,37 +312,60 @@ export async function resolveGhostSteer( throw new Error('ghost.targetPath must stay within the configured root'); } - const stack = await loadFingerprintStackForPath(request.targetPath, root, { + const stack = await loadMemoryStackForPath(request.targetPath, root, { memoryDir: request.memoryDir, }); if (resolve(stack.repo_root) !== resolve(root)) { throw new Error('configured Ghost root must resolve to the fingerprint stack repo root'); } - const context = fingerprintStackToPackageContext(stack); + const context = memoryStackToPackageMemory(stack); const [prompt, tokenSource] = await Promise.all([ buildPromptFromContext(context), resolveGhostTokenSource(stack, baseDirection), ]); return { + source: 'root', request, root, stack, 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.fingerprint_dir, + memoryDir: ctx.stack.memory_dir, layers: ctx.stack.layers.map((layer) => layer.relative_root), - product: ctx.stack.merged.fingerprint.prose.summary.product ?? ctx.context.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.fingerprint_dir, - product: - input.context.stack.merged.fingerprint.prose.summary.product ?? - input.context.context.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.fingerprint_dir, - })), - }, + ...rootFields, tokenSource: { kind: input.context.tokenSource.kind, source: input.context.tokenSource.source, @@ -303,10 +441,10 @@ export function buildGhostReviewPacket(input: { }; } -async function buildPromptFromContext(context: PackageContext): Promise { +async function buildPromptFromContext(context: PackageMemory): Promise { const dir = await mkdtemp(join(tmpdir(), 'summon-ghost-context-')); try { - await writePackageContextBundleFromContext(context, { + await writePackageContextBundleFromMemory(context, { outDir: dir, promptOnly: true, }); @@ -317,12 +455,12 @@ async function buildPromptFromContext(context: PackageContext): Promise } async function resolveGhostTokenSource( - stack: GhostFingerprintStack, + stack: GhostMemoryStack, baseDirection: GhostBaseDirection | null, ): Promise { const warnings: string[] = []; for (const layer of [...stack.layers].reverse()) { - const configPath = resolve(layer.root, layer.fingerprint_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'); @@ -405,7 +577,7 @@ async function resolveGhostTokenSource( } function resolveTokenPath( - layer: GhostFingerprintStackLayer, + layer: GhostMemoryStackLayer, rawRef: string, ): string | null { const raw = rawRef.trim(); @@ -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 } { @@ -478,7 +662,7 @@ function isWithinOrEqual(root: string, child: string): boolean { return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); } -function displayPath(stack: GhostFingerprintStack, absPath: string): string { +function displayPath(stack: GhostMemoryStack, absPath: string): string { const rel = relative(stack.repo_root, absPath); return rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : absPath; } 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", From 9616830aed49aef0e6e066a8c9f6808b8358f381 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Tue, 9 Jun 2026 17:04:54 -0400 Subject: [PATCH 3/3] Fix Ghost PR CI failures --- apps/server/package.json | 2 +- pnpm-lock.yaml | 4 ++-- tests/safety-smoke.spec.ts | 18 +++++++++++------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 6001f70..c598fb4 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -10,7 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@anarchitecture/ghost": "^0.7.1", + "@anarchitecture/ghost": "^0.2.0", "@anthropic-ai/sdk": "^0.88.0", "cors": "^2.8.5", "express": "^4.21.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93d6d98..4cfd3c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,8 +46,8 @@ importers: apps/server: dependencies: '@anarchitecture/ghost': - specifier: ^0.7.1 - version: 0.7.1 + specifier: ^0.2.0 + version: 0.2.0 '@anarchitecture/summon': specifier: workspace:* version: link:../../packages/summon 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');