Reference implementation for the Phoenix Architecture. Work in progress. aicoding.leaflet.pub/
ai coding crazy
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Pluggable LLM code generation with typecheck-and-retry

- LLM provider interface (src/llm/provider.ts)
- Anthropic (Claude) and OpenAI (GPT) providers
- Auto-detection: ANTHROPIC_API_KEY > OPENAI_API_KEY
- Preference saved in .phoenix/config.json
- Override with PHOENIX_LLM_PROVIDER env var

Regen engine now has two modes:
- Stub mode (no LLM): typed skeletons with throw stubs
- LLM mode: sends IU contract + canonical requirements to LLM,
gets back real implementations

Typecheck-and-retry loop:
- After generating code, runs tsc --noEmit on the file
- If errors, feeds them back to the LLM for fix (up to 2 retries)
- Falls back to stubs if LLM fails entirely

CLI changes:
- bootstrap/regen show provider info
- phoenix regen --stubs forces stub mode
- Progress indicators for LLM generation

Tests: 201 passing (updated for async generateIU/generateAll)

+724 -47
+1 -1
examples/tictactoe/spec/multiplayer.md
··· 11 11 12 12 - A player must be able to create a new game and wait for an opponent 13 13 - A player must be able to join an existing game that is in "waiting" status 14 - - The creator of a game always plays as X 14 + - The creator of a game always plays as Y 15 15 - The joiner always plays as O 16 16 - When a second player joins, the game status must change to "in-progress" 17 17
+71 -16
src/cli.ts
··· 30 30 // Phase C 31 31 import { planIUs } from './iu-planner.js'; 32 32 import { generateIU, generateAll } from './regen.js'; 33 + import type { RegenContext } from './regen.js'; 33 34 import { detectDrift } from './drift.js'; 34 35 import { extractDependencies } from './dep-extractor.js'; 35 36 import { validateBoundary } from './boundary-validator.js'; ··· 46 47 47 48 // Scaffold 48 49 import { deriveServices, generateScaffold } from './scaffold.js'; 50 + 51 + // LLM 52 + import { resolveProvider, describeAvailability } from './llm/resolve.js'; 49 53 50 54 // Models 51 55 import type { Clause } from './models/clause.js'; ··· 254 258 console.log(` 2. Run ${cyan('phoenix bootstrap')} to ingest & canonicalize`); 255 259 } 256 260 257 - function cmdBootstrap(): void { 261 + async function cmdBootstrap(): Promise<void> { 258 262 const { projectRoot, phoenixDir } = requirePhoenixRoot(); 259 263 260 264 console.log(bold('🔥 Phoenix Bootstrap')); ··· 323 327 console.log(); 324 328 325 329 // Step 4: Generate code 326 - console.log(` ${dim('Phase C:')} Code generation`); 330 + const llm = resolveProvider(phoenixDir); 331 + const { hint } = describeAvailability(); 332 + if (llm) { 333 + console.log(` ${dim('Phase C:')} Code generation ${dim(`(${llm.name}/${llm.model})`)}`); 334 + } else { 335 + console.log(` ${dim('Phase C:')} Code generation ${dim('(stubs — no LLM)')}`); 336 + console.log(` ${dim(hint)}`); 337 + } 338 + 339 + const regenCtx: RegenContext = { 340 + llm: llm ?? undefined, 341 + canonNodes, 342 + allIUs: ius, 343 + projectRoot, 344 + onProgress: (iu, status, msg) => { 345 + if (status === 'start') process.stdout.write(` ⏳ ${iu.name}…`); 346 + else if (status === 'done') process.stdout.write(` ${green('✔')}\n`); 347 + else if (status === 'error') process.stdout.write(` ${red('✖')} ${dim(msg || 'failed, using stub')}\n`); 348 + }, 349 + }; 350 + 327 351 const manifestManager = new ManifestManager(phoenixDir); 328 - const regenResults = generateAll(ius); 352 + const regenResults = await generateAll(ius, regenCtx); 329 353 for (const result of regenResults) { 330 354 for (const [filePath, content] of result.files) { 331 355 const fullPath = join(projectRoot, filePath); ··· 333 357 writeFileSync(fullPath, content, 'utf8'); 334 358 } 335 359 manifestManager.recordIU(result.manifest); 336 - const fileCount = result.files.size; 337 - console.log(` ${green('✔')} ${result.iu_id.slice(0, 8)}… → ${fileCount} file(s) generated`); 360 + if (!llm) { 361 + console.log(` ${green('✔')} ${result.iu_id.slice(0, 8)}… → ${result.files.size} file(s)`); 362 + } 338 363 } 339 364 console.log(); 340 365 ··· 802 827 } 803 828 } 804 829 805 - function cmdRegen(args: string[]): void { 830 + async function cmdRegen(args: string[]): Promise<void> { 806 831 const { projectRoot, phoenixDir } = requirePhoenixRoot(); 807 832 const ius = loadIUs(phoenixDir); 808 833 ··· 811 836 return; 812 837 } 813 838 814 - // Parse --iu=<id> flag 839 + // Parse --iu=<id> flag and --stubs flag 815 840 const iuFilter = args.find(a => a.startsWith('--iu='))?.split('=')[1]; 841 + const forceStubs = args.includes('--stubs'); 816 842 const targetIUs = iuFilter 817 843 ? ius.filter(iu => iu.iu_id.startsWith(iuFilter) || iu.name === iuFilter) 818 844 : ius; ··· 821 847 console.log(red(`✖ No IU matching: ${iuFilter}`)); 822 848 return; 823 849 } 850 + 851 + const llm = forceStubs ? null : resolveProvider(phoenixDir); 852 + const canonStore = new CanonicalStore(phoenixDir); 853 + const canonNodes = canonStore.getAllNodes(); 824 854 825 855 console.log(bold('⚡ Code Regeneration')); 856 + if (llm) { 857 + console.log(` ${dim(`Provider: ${llm.name}/${llm.model}`)}`); 858 + } else { 859 + const { hint } = describeAvailability(); 860 + console.log(` ${dim('Mode: stubs')}${forceStubs ? '' : ` ${dim('—')} ${dim(hint)}`}`); 861 + } 826 862 console.log(); 827 863 864 + const regenCtx: RegenContext = { 865 + llm: llm ?? undefined, 866 + canonNodes, 867 + allIUs: ius, 868 + projectRoot, 869 + onProgress: (iu, status, msg) => { 870 + if (status === 'start') process.stdout.write(` ⏳ ${iu.name}…`); 871 + else if (status === 'done') process.stdout.write(` ${green('✔')}\n`); 872 + else if (status === 'error') process.stdout.write(` ${red('✖')} ${dim(msg || 'failed, using stub')}\n`); 873 + }, 874 + }; 875 + 828 876 const manifestManager = new ManifestManager(phoenixDir); 829 - const results = generateAll(targetIUs); 877 + const results = await generateAll(targetIUs, regenCtx); 830 878 831 879 for (const result of results) { 832 880 for (const [filePath, content] of result.files) { ··· 836 884 } 837 885 manifestManager.recordIU(result.manifest); 838 886 839 - const iu = targetIUs.find(i => i.iu_id === result.iu_id); 840 - console.log(` ${green('✔')} ${iu?.name || result.iu_id.slice(0, 12)}`); 841 - for (const [filePath] of result.files) { 842 - console.log(` → ${cyan(filePath)}`); 887 + if (!llm) { 888 + const iu = targetIUs.find(i => i.iu_id === result.iu_id); 889 + console.log(` ${green('✔')} ${iu?.name || result.iu_id.slice(0, 12)}`); 890 + for (const [filePath] of result.files) { 891 + console.log(` → ${cyan(filePath)}`); 892 + } 843 893 } 844 894 } 845 895 ··· 1102 1152 ${bold('Implementation:')} 1103 1153 ${cyan('plan')} Plan Implementation Units from canonical graph 1104 1154 ${cyan('regen')} [--iu=<id>] Regenerate code (all or specific IU) 1155 + ${dim('Uses LLM if ANTHROPIC_API_KEY or OPENAI_API_KEY is set')} 1156 + ${dim('--stubs Force stub generation (skip LLM)')} 1105 1157 1106 1158 ${bold('Verification:')} 1107 1159 ${cyan('status')} Trust dashboard — the primary UX ··· 1123 1175 1124 1176 // ─── Main ──────────────────────────────────────────────────────────────────── 1125 1177 1126 - function main(): void { 1178 + async function main(): Promise<void> { 1127 1179 const args = process.argv.slice(2); 1128 1180 const command = args[0]; 1129 1181 const commandArgs = args.slice(1); ··· 1133 1185 cmdInit(); 1134 1186 break; 1135 1187 case 'bootstrap': 1136 - cmdBootstrap(); 1188 + await cmdBootstrap(); 1137 1189 break; 1138 1190 case 'status': 1139 1191 cmdStatus(); ··· 1159 1211 break; 1160 1212 case 'regen': 1161 1213 case 'regenerate': 1162 - cmdRegen(commandArgs); 1214 + await cmdRegen(commandArgs); 1163 1215 break; 1164 1216 case 'drift': 1165 1217 cmdDrift(); ··· 1195 1247 } 1196 1248 } 1197 1249 1198 - main(); 1250 + main().catch(err => { 1251 + console.error(red(`✖ ${err.message || err}`)); 1252 + process.exit(1); 1253 + });
+9
src/index.ts
··· 36 36 // Phase C1 37 37 export { planIUs } from './iu-planner.js'; 38 38 export { generateIU, generateAll } from './regen.js'; 39 + export type { RegenContext } from './regen.js'; 39 40 export { ManifestManager } from './manifest.js'; 40 41 export { detectDrift } from './drift.js'; 41 42 ··· 60 61 // Scaffold 61 62 export { deriveServices, generateScaffold } from './scaffold.js'; 62 63 export type { ServiceDescriptor, ScaffoldResult } from './scaffold.js'; 64 + 65 + // LLM 66 + export type { LLMProvider, GenerateOptions, LLMConfig } from './llm/provider.js'; 67 + export { DEFAULT_MODELS } from './llm/provider.js'; 68 + export { AnthropicProvider } from './llm/anthropic.js'; 69 + export { OpenAIProvider } from './llm/openai.js'; 70 + export { resolveProvider, describeAvailability } from './llm/resolve.js'; 71 + export { buildPrompt, SYSTEM_PROMPT } from './llm/prompt.js'; 63 72 64 73 // Stores 65 74 export { ContentStore } from './store/content-store.js';
+63
src/llm/anthropic.ts
··· 1 + /** 2 + * Anthropic (Claude) LLM Provider. 3 + * 4 + * Uses the Messages API via native fetch. 5 + * Requires ANTHROPIC_API_KEY env var. 6 + */ 7 + 8 + import type { LLMProvider, GenerateOptions } from './provider.js'; 9 + 10 + const API_URL = 'https://api.anthropic.com/v1/messages'; 11 + const API_VERSION = '2023-06-01'; 12 + 13 + export class AnthropicProvider implements LLMProvider { 14 + readonly name = 'anthropic'; 15 + readonly model: string; 16 + private apiKey: string; 17 + 18 + constructor(apiKey: string, model: string) { 19 + this.apiKey = apiKey; 20 + this.model = model; 21 + } 22 + 23 + async generate(prompt: string, options?: GenerateOptions): Promise<string> { 24 + const body: Record<string, unknown> = { 25 + model: this.model, 26 + max_tokens: options?.maxTokens ?? 8192, 27 + messages: [{ role: 'user', content: prompt }], 28 + }; 29 + 30 + if (options?.system) { 31 + body.system = options.system; 32 + } 33 + if (options?.temperature !== undefined) { 34 + body.temperature = options.temperature; 35 + } 36 + 37 + const res = await fetch(API_URL, { 38 + method: 'POST', 39 + headers: { 40 + 'Content-Type': 'application/json', 41 + 'x-api-key': this.apiKey, 42 + 'anthropic-version': API_VERSION, 43 + }, 44 + body: JSON.stringify(body), 45 + }); 46 + 47 + if (!res.ok) { 48 + const text = await res.text(); 49 + throw new Error(`Anthropic API error ${res.status}: ${text}`); 50 + } 51 + 52 + const data = await res.json() as { 53 + content: Array<{ type: string; text: string }>; 54 + }; 55 + 56 + const textBlocks = data.content.filter(b => b.type === 'text'); 57 + if (textBlocks.length === 0) { 58 + throw new Error('Anthropic returned no text content'); 59 + } 60 + 61 + return textBlocks.map(b => b.text).join(''); 62 + } 63 + }
+10
src/llm/index.ts
··· 1 + /** 2 + * LLM integration — pluggable provider system for code generation. 3 + */ 4 + 5 + export type { LLMProvider, GenerateOptions, LLMConfig } from './provider.js'; 6 + export { DEFAULT_MODELS } from './provider.js'; 7 + export { AnthropicProvider } from './anthropic.js'; 8 + export { OpenAIProvider } from './openai.js'; 9 + export { resolveProvider, describeAvailability } from './resolve.js'; 10 + export { buildPrompt, SYSTEM_PROMPT } from './prompt.js';
+64
src/llm/openai.ts
··· 1 + /** 2 + * OpenAI (GPT) LLM Provider. 3 + * 4 + * Uses the Chat Completions API via native fetch. 5 + * Requires OPENAI_API_KEY env var. 6 + */ 7 + 8 + import type { LLMProvider, GenerateOptions } from './provider.js'; 9 + 10 + const API_URL = 'https://api.openai.com/v1/chat/completions'; 11 + 12 + export class OpenAIProvider implements LLMProvider { 13 + readonly name = 'openai'; 14 + readonly model: string; 15 + private apiKey: string; 16 + 17 + constructor(apiKey: string, model: string) { 18 + this.apiKey = apiKey; 19 + this.model = model; 20 + } 21 + 22 + async generate(prompt: string, options?: GenerateOptions): Promise<string> { 23 + const messages: Array<{ role: string; content: string }> = []; 24 + 25 + if (options?.system) { 26 + messages.push({ role: 'system', content: options.system }); 27 + } 28 + messages.push({ role: 'user', content: prompt }); 29 + 30 + const body: Record<string, unknown> = { 31 + model: this.model, 32 + messages, 33 + max_tokens: options?.maxTokens ?? 8192, 34 + }; 35 + 36 + if (options?.temperature !== undefined) { 37 + body.temperature = options.temperature; 38 + } 39 + 40 + const res = await fetch(API_URL, { 41 + method: 'POST', 42 + headers: { 43 + 'Content-Type': 'application/json', 44 + 'Authorization': `Bearer ${this.apiKey}`, 45 + }, 46 + body: JSON.stringify(body), 47 + }); 48 + 49 + if (!res.ok) { 50 + const text = await res.text(); 51 + throw new Error(`OpenAI API error ${res.status}: ${text}`); 52 + } 53 + 54 + const data = await res.json() as { 55 + choices: Array<{ message: { content: string } }>; 56 + }; 57 + 58 + if (!data.choices?.length) { 59 + throw new Error('OpenAI returned no choices'); 60 + } 61 + 62 + return data.choices[0].message.content; 63 + } 64 + }
+119
src/llm/prompt.ts
··· 1 + /** 2 + * Prompt Builder — constructs LLM prompts from IU contracts. 3 + * 4 + * Turns the structured IU (requirements, constraints, invariants, 5 + * inputs, outputs) into a prompt that produces working TypeScript. 6 + */ 7 + 8 + import type { ImplementationUnit } from '../models/iu.js'; 9 + import type { CanonicalNode } from '../models/canonical.js'; 10 + 11 + export const SYSTEM_PROMPT = `You are a senior TypeScript engineer generating production-quality module implementations for Phoenix VCS. 12 + 13 + Rules: 14 + - Output ONLY the TypeScript module code. No markdown fences, no explanation. 15 + - The module must be a valid ES module (.ts) that compiles under strict mode. 16 + - Export all public functions and types. 17 + - Use descriptive types (not \`any\` or \`unknown\` where a real type is appropriate). 18 + - Implement the actual logic described in the requirements — not stubs or TODOs. 19 + - Keep the code clean, readable, and minimal. No over-engineering. 20 + - Include the _phoenix metadata constant exactly as specified. 21 + - Do NOT import from external packages. ZERO runtime dependencies. 22 + - Use only Node.js built-in modules (node:crypto, node:events, node:http, etc.) when needed. 23 + - For WebSocket-like features, use raw node:http or define the interface — do NOT import 'ws'. 24 + - For DOM/browser code, do NOT use DOM APIs. Generate string HTML templates instead. 25 + - For EventEmitter, use node:events and cast as needed. Prefer simple callbacks or Maps. 26 + - The code must compile under TypeScript strict mode (strict: true, no implicit any). 27 + - If the requirements describe a data structure, define and export the types. 28 + - If the requirements describe validation rules, implement them with clear error messages. 29 + - If the requirements describe state management, use a class or closure — your choice.`; 30 + 31 + /** 32 + * Build the user prompt for generating an IU implementation. 33 + */ 34 + export function buildPrompt( 35 + iu: ImplementationUnit, 36 + canonNodes: CanonicalNode[], 37 + siblingModules?: string[], 38 + ): string { 39 + const lines: string[] = []; 40 + 41 + lines.push(`Generate a TypeScript module implementing "${iu.name}".`); 42 + lines.push(''); 43 + 44 + // Requirements 45 + const iuNodes = canonNodes.filter(n => iu.source_canon_ids.includes(n.canon_id)); 46 + const requirements = iuNodes.filter(n => n.type === 'REQUIREMENT'); 47 + const constraints = iuNodes.filter(n => n.type === 'CONSTRAINT'); 48 + const invariants = iuNodes.filter(n => n.type === 'INVARIANT'); 49 + const definitions = iuNodes.filter(n => n.type === 'DEFINITION'); 50 + 51 + if (requirements.length > 0) { 52 + lines.push('## Requirements'); 53 + for (const r of requirements) { 54 + lines.push(`- ${r.statement}`); 55 + } 56 + lines.push(''); 57 + } 58 + 59 + if (constraints.length > 0) { 60 + lines.push('## Constraints'); 61 + for (const c of constraints) { 62 + lines.push(`- ${c.statement}`); 63 + } 64 + lines.push(''); 65 + } 66 + 67 + if (invariants.length > 0) { 68 + lines.push('## Invariants'); 69 + for (const inv of invariants) { 70 + lines.push(`- ${inv.statement}`); 71 + } 72 + lines.push(''); 73 + } 74 + 75 + if (definitions.length > 0) { 76 + lines.push('## Definitions'); 77 + for (const d of definitions) { 78 + lines.push(`- ${d.statement}`); 79 + } 80 + lines.push(''); 81 + } 82 + 83 + // Contract 84 + if (iu.contract.inputs.length > 0) { 85 + lines.push(`## Inputs: ${iu.contract.inputs.join(', ')}`); 86 + } 87 + if (iu.contract.outputs.length > 0) { 88 + lines.push(`## Outputs: ${iu.contract.outputs.join(', ')}`); 89 + } 90 + lines.push(`## Risk Tier: ${iu.risk_tier}`); 91 + lines.push(''); 92 + 93 + // Context: sibling modules 94 + if (siblingModules && siblingModules.length > 0) { 95 + lines.push(`## Other modules in this service (for context, do NOT import them):`); 96 + for (const m of siblingModules) { 97 + lines.push(`- ${m}`); 98 + } 99 + lines.push(''); 100 + } 101 + 102 + // Phoenix metadata 103 + lines.push('## Required metadata export'); 104 + lines.push('Include this exact constant at the end of the module:'); 105 + lines.push('```'); 106 + lines.push(`/** @internal Phoenix VCS traceability — do not remove. */`); 107 + lines.push(`export const _phoenix = {`); 108 + lines.push(` iu_id: '${iu.iu_id}',`); 109 + lines.push(` name: '${iu.name}',`); 110 + lines.push(` risk_tier: '${iu.risk_tier}',`); 111 + lines.push(` canon_ids: [${iu.source_canon_ids.length} as const],`); 112 + lines.push(`} as const;`); 113 + lines.push('```'); 114 + lines.push(''); 115 + 116 + lines.push('Output the complete TypeScript module now.'); 117 + 118 + return lines.join('\n'); 119 + }
+41
src/llm/provider.ts
··· 1 + /** 2 + * LLM Provider — pluggable interface for code generation. 3 + * 4 + * Providers implement a single method: generate code from a prompt. 5 + * Phoenix auto-detects available providers from env vars and saves 6 + * a preference in .phoenix/config.json. 7 + */ 8 + 9 + export interface LLMProvider { 10 + /** Provider name for display/config. */ 11 + readonly name: string; 12 + 13 + /** Model identifier being used. */ 14 + readonly model: string; 15 + 16 + /** 17 + * Generate a completion from a prompt. 18 + * Returns the raw text response. 19 + */ 20 + generate(prompt: string, options?: GenerateOptions): Promise<string>; 21 + } 22 + 23 + export interface GenerateOptions { 24 + /** Max tokens to generate. */ 25 + maxTokens?: number; 26 + /** Temperature (0 = deterministic, 1 = creative). */ 27 + temperature?: number; 28 + /** System prompt / role. */ 29 + system?: string; 30 + } 31 + 32 + export interface LLMConfig { 33 + provider: string; 34 + model: string; 35 + } 36 + 37 + /** Default models per provider. */ 38 + export const DEFAULT_MODELS: Record<string, string> = { 39 + anthropic: 'claude-sonnet-4-20250514', 40 + openai: 'gpt-4o', 41 + };
+131
src/llm/resolve.ts
··· 1 + /** 2 + * LLM Provider Resolution — auto-detect, preference, config. 3 + * 4 + * Priority order: 5 + * 1. PHOENIX_LLM_PROVIDER env var (explicit override) 6 + * 2. Saved preference in .phoenix/config.json 7 + * 3. Auto-detect from available API keys: 8 + * - ANTHROPIC_API_KEY → anthropic 9 + * - OPENAI_API_KEY → openai 10 + * If both present, prefer anthropic. 11 + * 4. null (no provider available — fall back to stubs) 12 + */ 13 + 14 + import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; 15 + import { join } from 'node:path'; 16 + import type { LLMProvider, LLMConfig } from './provider.js'; 17 + import { DEFAULT_MODELS } from './provider.js'; 18 + import { AnthropicProvider } from './anthropic.js'; 19 + import { OpenAIProvider } from './openai.js'; 20 + 21 + interface PhoenixConfig { 22 + llm?: LLMConfig; 23 + } 24 + 25 + /** 26 + * Resolve the LLM provider. Returns null if no provider is available. 27 + */ 28 + export function resolveProvider(phoenixDir?: string): LLMProvider | null { 29 + const config = phoenixDir ? loadConfig(phoenixDir) : {}; 30 + 31 + // 1. Explicit env var override 32 + const envProvider = process.env.PHOENIX_LLM_PROVIDER; 33 + const envModel = process.env.PHOENIX_LLM_MODEL; 34 + 35 + // 2. Determine provider name 36 + let providerName = envProvider || config.llm?.provider || detectProvider(); 37 + if (!providerName) return null; 38 + 39 + // 3. Determine model 40 + const model = envModel || config.llm?.model || DEFAULT_MODELS[providerName] || DEFAULT_MODELS.anthropic; 41 + 42 + // 4. Build provider 43 + const provider = buildProvider(providerName, model); 44 + if (!provider) return null; 45 + 46 + // 5. Save preference if we detected it (and have a phoenix dir) 47 + if (phoenixDir && !config.llm) { 48 + saveConfig(phoenixDir, { 49 + ...config, 50 + llm: { provider: providerName, model }, 51 + }); 52 + } 53 + 54 + return provider; 55 + } 56 + 57 + /** 58 + * Auto-detect which provider is available from env vars. 59 + */ 60 + function detectProvider(): string | null { 61 + if (process.env.ANTHROPIC_API_KEY) return 'anthropic'; 62 + if (process.env.OPENAI_API_KEY) return 'openai'; 63 + return null; 64 + } 65 + 66 + /** 67 + * Build a provider instance. 68 + */ 69 + function buildProvider(name: string, model: string): LLMProvider | null { 70 + switch (name) { 71 + case 'anthropic': { 72 + const key = process.env.ANTHROPIC_API_KEY; 73 + if (!key) return null; 74 + return new AnthropicProvider(key, model); 75 + } 76 + case 'openai': { 77 + const key = process.env.OPENAI_API_KEY; 78 + if (!key) return null; 79 + return new OpenAIProvider(key, model); 80 + } 81 + default: 82 + return null; 83 + } 84 + } 85 + 86 + /** 87 + * Load Phoenix config from .phoenix/config.json. 88 + */ 89 + function loadConfig(phoenixDir: string): PhoenixConfig { 90 + const configPath = join(phoenixDir, 'config.json'); 91 + if (!existsSync(configPath)) return {}; 92 + try { 93 + return JSON.parse(readFileSync(configPath, 'utf8')); 94 + } catch { 95 + return {}; 96 + } 97 + } 98 + 99 + /** 100 + * Save Phoenix config to .phoenix/config.json. 101 + */ 102 + function saveConfig(phoenixDir: string, config: PhoenixConfig): void { 103 + mkdirSync(phoenixDir, { recursive: true }); 104 + writeFileSync( 105 + join(phoenixDir, 'config.json'), 106 + JSON.stringify(config, null, 2) + '\n', 107 + 'utf8', 108 + ); 109 + } 110 + 111 + /** 112 + * Describe which providers are available (for CLI help). 113 + */ 114 + export function describeAvailability(): { available: string[]; configured: string | null; hint: string } { 115 + const available: string[] = []; 116 + if (process.env.ANTHROPIC_API_KEY) available.push('anthropic'); 117 + if (process.env.OPENAI_API_KEY) available.push('openai'); 118 + 119 + const configured = process.env.PHOENIX_LLM_PROVIDER || null; 120 + 121 + let hint: string; 122 + if (available.length === 0) { 123 + hint = 'No LLM API keys found. Set ANTHROPIC_API_KEY or OPENAI_API_KEY to enable code generation. Falling back to stubs.'; 124 + } else if (available.length === 1) { 125 + hint = `Using ${available[0]} (detected from env).`; 126 + } else { 127 + hint = `Multiple providers available: ${available.join(', ')}. Using ${configured || available[0]}. Set PHOENIX_LLM_PROVIDER to override.`; 128 + } 129 + 130 + return { available, configured, hint }; 131 + }
+199 -14
src/regen.ts
··· 1 1 /** 2 - * Regeneration Engine — generates code stubs for each IU. 2 + * Regeneration Engine — generates code for each IU. 3 3 * 4 - * Produces natural-looking TypeScript modules with: 5 - * - Typed interfaces for inputs/outputs 6 - * - Function stubs derived from requirements 7 - * - Config types from constraints 8 - * - Clean, readable code a developer would recognize 4 + * Two modes: 5 + * - Stub mode (no LLM): produces typed skeletons with throw stubs. 6 + * - LLM mode: sends IU contract + canonical requirements to an LLM 7 + * and produces real, working implementations. 9 8 * 10 - * In production this would invoke an LLM with a promptpack. 9 + * The LLM provider is pluggable (Anthropic, OpenAI, etc.) 10 + * and auto-detected from env vars. 11 11 */ 12 12 13 + import { execSync } from 'node:child_process'; 14 + import { writeFileSync, mkdirSync, unlinkSync, existsSync } from 'node:fs'; 15 + import { join, dirname } from 'node:path'; 13 16 import type { ImplementationUnit } from './models/iu.js'; 17 + import type { CanonicalNode } from './models/canonical.js'; 14 18 import type { IUManifest, RegenMetadata, FileManifestEntry } from './models/manifest.js'; 19 + import type { LLMProvider } from './llm/provider.js'; 20 + import { buildPrompt, SYSTEM_PROMPT } from './llm/prompt.js'; 15 21 import { sha256 } from './semhash.js'; 16 22 17 23 const TOOLCHAIN_VERSION = 'phoenix-regen/0.1.0'; 18 - const MODEL_ID = 'stub-generator/1.0'; 19 24 20 25 export interface RegenResult { 21 26 iu_id: string; ··· 23 28 manifest: IUManifest; 24 29 } 25 30 31 + export interface RegenContext { 32 + /** LLM provider for real code generation. Omit for stub mode. */ 33 + llm?: LLMProvider; 34 + /** All canonical nodes (needed for LLM prompt context). */ 35 + canonNodes?: CanonicalNode[]; 36 + /** All IUs (for sibling module context). */ 37 + allIUs?: ImplementationUnit[]; 38 + /** Project root directory (for typecheck-and-retry). */ 39 + projectRoot?: string; 40 + /** Callback for progress reporting. */ 41 + onProgress?: (iu: ImplementationUnit, status: 'start' | 'done' | 'error', message?: string) => void; 42 + } 43 + 26 44 /** 27 45 * Generate code for a single IU. 46 + * Uses LLM if provided in context, otherwise falls back to stubs. 28 47 */ 29 - export function generateIU(iu: ImplementationUnit): RegenResult { 48 + export async function generateIU(iu: ImplementationUnit, ctx?: RegenContext): Promise<RegenResult> { 30 49 const files = new Map<string, string>(); 50 + const modelId = ctx?.llm ? `${ctx.llm.name}/${ctx.llm.model}` : 'stub-generator/1.0'; 31 51 32 52 for (const outputPath of iu.output_files) { 33 - const content = generateModule(iu); 53 + let content: string; 54 + 55 + if (ctx?.llm && ctx.canonNodes) { 56 + ctx.onProgress?.(iu, 'start', `Generating ${iu.name} via ${ctx.llm.name}…`); 57 + try { 58 + content = await generateWithLLM(iu, ctx.llm, ctx.canonNodes, ctx.allIUs, ctx.projectRoot); 59 + ctx.onProgress?.(iu, 'done'); 60 + } catch (err) { 61 + const msg = err instanceof Error ? err.message : String(err); 62 + ctx.onProgress?.(iu, 'error', msg); 63 + // Fall back to stub on LLM failure 64 + content = generateModule(iu); 65 + } 66 + } else { 67 + content = generateModule(iu); 68 + } 69 + 34 70 files.set(outputPath, content); 35 71 } 36 72 ··· 48 84 const promptpackHash = sha256(JSON.stringify(iu.contract)); 49 85 50 86 const metadata: RegenMetadata = { 51 - model_id: MODEL_ID, 87 + model_id: modelId, 52 88 promptpack_hash: promptpackHash, 53 89 toolchain_version: TOOLCHAIN_VERSION, 54 90 generated_at: now, ··· 67 103 } 68 104 69 105 /** 70 - * Generate code for all IUs. 106 + * Generate code for all IUs. Runs sequentially to respect LLM rate limits. 107 + */ 108 + export async function generateAll(ius: ImplementationUnit[], ctx?: RegenContext): Promise<RegenResult[]> { 109 + const results: RegenResult[] = []; 110 + for (const iu of ius) { 111 + results.push(await generateIU(iu, ctx)); 112 + } 113 + return results; 114 + } 115 + 116 + // ─── LLM Generation ───────────────────────────────────────────────────────── 117 + 118 + const MAX_RETRIES = 2; 119 + 120 + /** 121 + * Generate code for an IU using an LLM provider. 122 + * Includes typecheck-and-retry: if the generated code has TS errors, 123 + * feed them back to the LLM for a fix attempt. 124 + */ 125 + async function generateWithLLM( 126 + iu: ImplementationUnit, 127 + llm: LLMProvider, 128 + canonNodes: CanonicalNode[], 129 + allIUs?: ImplementationUnit[], 130 + projectRoot?: string, 131 + ): Promise<string> { 132 + // Find sibling modules in the same service 133 + const iuDir = iu.output_files[0]?.split('/').slice(0, -1).join('/'); 134 + const siblings = allIUs 135 + ?.filter(other => other.iu_id !== iu.iu_id && other.output_files[0]?.startsWith(iuDir || '')) 136 + .map(other => other.name) ?? []; 137 + 138 + const prompt = buildPrompt(iu, canonNodes, siblings); 139 + 140 + let code = cleanCodeResponse(await llm.generate(prompt, { 141 + system: SYSTEM_PROMPT, 142 + temperature: 0.2, 143 + maxTokens: 8192, 144 + })); 145 + 146 + // Typecheck-and-retry loop 147 + if (projectRoot && iu.output_files[0]) { 148 + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { 149 + const errors = typecheckFile(projectRoot, iu.output_files[0], code); 150 + if (!errors) break; // clean! 151 + 152 + // Feed errors back to LLM 153 + const fixPrompt = buildFixPrompt(code, errors); 154 + code = cleanCodeResponse(await llm.generate(fixPrompt, { 155 + system: SYSTEM_PROMPT, 156 + temperature: 0.1, 157 + maxTokens: 8192, 158 + })); 159 + } 160 + } 161 + 162 + return code; 163 + } 164 + 165 + const MINIMAL_TSCONFIG = JSON.stringify({ 166 + compilerOptions: { 167 + target: 'ES2022', 168 + module: 'Node16', 169 + moduleResolution: 'Node16', 170 + strict: true, 171 + esModuleInterop: true, 172 + skipLibCheck: true, 173 + outDir: 'dist', 174 + rootDir: 'src', 175 + }, 176 + include: ['src'], 177 + }, null, 2); 178 + 179 + /** 180 + * Typecheck a single file by writing it to disk and running tsc. 181 + * Returns error output or null if clean. 71 182 */ 72 - export function generateAll(ius: ImplementationUnit[]): RegenResult[] { 73 - return ius.map(iu => generateIU(iu)); 183 + function typecheckFile(projectRoot: string, filePath: string, content: string): string | null { 184 + const fullPath = join(projectRoot, filePath); 185 + mkdirSync(dirname(fullPath), { recursive: true }); 186 + writeFileSync(fullPath, content, 'utf8'); 187 + 188 + // Ensure tsconfig.json exists for tsc 189 + const tsconfigPath = join(projectRoot, 'tsconfig.json'); 190 + const hadTsconfig = existsSync(tsconfigPath); 191 + if (!hadTsconfig) { 192 + writeFileSync(tsconfigPath, MINIMAL_TSCONFIG, 'utf8'); 193 + } 194 + 195 + try { 196 + execSync('npx tsc --noEmit 2>&1', { 197 + cwd: projectRoot, 198 + timeout: 30000, 199 + stdio: 'pipe', 200 + }); 201 + return null; // clean 202 + } catch (err: unknown) { 203 + const execErr = err as { stdout?: Buffer; stderr?: Buffer }; 204 + const output = (execErr.stdout?.toString() || '') + (execErr.stderr?.toString() || ''); 205 + // Filter to only errors from this file 206 + const fileErrors = output 207 + .split('\n') 208 + .filter(line => line.includes(filePath)) 209 + .join('\n') 210 + .trim(); 211 + return fileErrors || output.trim(); 212 + } 213 + } 214 + 215 + /** 216 + * Build a prompt asking the LLM to fix typecheck errors. 217 + */ 218 + function buildFixPrompt(code: string, errors: string): string { 219 + return `The following TypeScript module has compilation errors. Fix them. 220 + 221 + ## Current code: 222 + \`\`\`typescript 223 + ${code} 224 + \`\`\` 225 + 226 + ## TypeScript errors: 227 + ${errors} 228 + 229 + ## Rules: 230 + - Output ONLY the fixed TypeScript module. No markdown fences, no explanation. 231 + - Do NOT import external packages. Use only Node.js built-in modules. 232 + - For WebSocket features, use node:http — do NOT import 'ws'. 233 + - For DOM/browser code, use string HTML templates — no DOM APIs. 234 + - The code must compile under strict mode. 235 + - Keep all existing exports and the _phoenix metadata constant. 236 + 237 + Output the complete fixed TypeScript module now.`; 238 + } 239 + 240 + /** 241 + * Strip markdown code fences from LLM response. 242 + */ 243 + function cleanCodeResponse(raw: string): string { 244 + let code = raw.trim(); 245 + 246 + // Remove ```typescript ... ``` or ```ts ... ``` or ``` ... ``` 247 + const fenceMatch = code.match(/^```(?:typescript|ts)?\s*\n([\s\S]*?)\n```\s*$/); 248 + if (fenceMatch) { 249 + code = fenceMatch[1]; 250 + } 251 + 252 + // Also handle case where there's text before/after the fence 253 + const innerMatch = code.match(/```(?:typescript|ts)?\s*\n([\s\S]*?)\n```/); 254 + if (innerMatch && innerMatch[1].includes('export')) { 255 + code = innerMatch[1]; 256 + } 257 + 258 + return code; 74 259 } 75 260 76 261 // ─── Module Generation ───────────────────────────────────────────────────────
+4 -4
tests/functional/iu-pipeline.test.ts
··· 39 39 mkdirSync(phoenixRoot, { recursive: true }); 40 40 }); 41 41 42 - it('end-to-end: spec → clauses → canon → IUs → generated code → manifest → drift check', () => { 42 + it('end-to-end: spec → clauses → canon → IUs → generated code → manifest → drift check', async () => { 43 43 // Phase A: Parse 44 44 const clauses = parseSpec(SPEC, 'spec/auth.md'); 45 45 expect(clauses.length).toBeGreaterThan(0); ··· 53 53 expect(ius.length).toBeGreaterThan(0); 54 54 55 55 // Phase C1: Generate code 56 - const results = generateAll(ius); 56 + const results = await generateAll(ius); 57 57 expect(results.length).toBe(ius.length); 58 58 59 59 // Write generated files to disk ··· 77 77 expect(report.clean_count).toBe(results.reduce((sum, r) => sum + r.files.size, 0)); 78 78 }); 79 79 80 - it('detects drift after manual edit', () => { 80 + it('detects drift after manual edit', async () => { 81 81 const clauses = parseSpec(SPEC, 'spec/auth.md'); 82 82 const canon = extractCanonicalNodes(clauses); 83 83 const ius = planIUs(canon, clauses); 84 - const results = generateAll(ius); 84 + const results = await generateAll(ius); 85 85 86 86 // Write and record 87 87 for (const result of results) {
+12 -12
tests/unit/regen.test.ts
··· 11 11 return planIUs(canon, clauses)[0]; 12 12 } 13 13 14 - it('generates files for an IU', () => { 14 + it('generates files for an IU', async () => { 15 15 const iu = makeIU(); 16 - const result = generateIU(iu); 16 + const result = await generateIU(iu); 17 17 expect(result.files.size).toBeGreaterThan(0); 18 18 }); 19 19 20 - it('generated code contains IU metadata', () => { 20 + it('generated code contains IU metadata', async () => { 21 21 const iu = makeIU(); 22 - const result = generateIU(iu); 22 + const result = await generateIU(iu); 23 23 const content = [...result.files.values()][0]; 24 24 expect(content).toContain('AUTO-GENERATED by Phoenix VCS'); 25 25 expect(content).toContain(iu.iu_id); 26 26 expect(content).toContain(iu.risk_tier); 27 27 }); 28 28 29 - it('generated code contains a function stub', () => { 29 + it('generated code contains a function stub', async () => { 30 30 const iu = makeIU(); 31 - const result = generateIU(iu); 31 + const result = await generateIU(iu); 32 32 const content = [...result.files.values()][0]; 33 33 expect(content).toContain('export function'); 34 34 expect(content).toContain('throw new Error'); 35 35 }); 36 36 37 - it('produces a manifest with correct file hashes', () => { 37 + it('produces a manifest with correct file hashes', async () => { 38 38 const iu = makeIU(); 39 - const result = generateIU(iu); 39 + const result = await generateIU(iu); 40 40 expect(result.manifest.iu_id).toBe(iu.iu_id); 41 41 42 42 for (const [path, entry] of Object.entries(result.manifest.files)) { ··· 46 46 } 47 47 }); 48 48 49 - it('records regen metadata', () => { 49 + it('records regen metadata', async () => { 50 50 const iu = makeIU(); 51 - const result = generateIU(iu); 51 + const result = await generateIU(iu); 52 52 const meta = result.manifest.regen_metadata; 53 53 expect(meta.model_id).toBeTruthy(); 54 54 expect(meta.toolchain_version).toBeTruthy(); ··· 58 58 }); 59 59 60 60 describe('generateAll', () => { 61 - it('generates for multiple IUs', () => { 61 + it('generates for multiple IUs', async () => { 62 62 const spec = `# Auth\n\nUsers must authenticate.\n\n# Billing\n\nPayments must be processed.`; 63 63 const clauses = parseSpec(spec, 'test.md'); 64 64 const canon = extractCanonicalNodes(clauses); 65 65 const ius = planIUs(canon, clauses); 66 - const results = generateAll(ius); 66 + const results = await generateAll(ius); 67 67 expect(results.length).toBe(ius.length); 68 68 }); 69 69 });