Reference implementation for the Phoenix Architecture. Work in progress. aicoding.leaflet.pub/
ai coding crazy
at main 1649 lines 60 kB view raw
1#!/usr/bin/env node 2/** 3 * Phoenix VCS — Command Line Interface 4 * 5 * The primary UX surface for Phoenix. `phoenix status` is the most 6 * important command — it must always be explainable, conservative, 7 * and correct-enough to rely on. 8 */ 9 10import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'; 11import { execSync } from 'node:child_process'; 12import { join, resolve, relative, basename, dirname } from 'node:path'; 13 14// Stores 15import { SpecStore } from './store/spec-store.js'; 16import { CanonicalStore } from './store/canonical-store.js'; 17import { EvidenceStore } from './store/evidence-store.js'; 18import { ManifestManager } from './manifest.js'; 19 20// Phase A 21import { parseSpec } from './spec-parser.js'; 22import { diffClauses } from './diff.js'; 23 24// Phase B 25import { extractCanonicalNodes, extractCandidates } from './canonicalizer.js'; 26import { extractCanonicalNodesLLM } from './canonicalizer-llm.js'; 27import { computeWarmHashes } from './warm-hasher.js'; 28import { classifyChanges } from './classifier.js'; 29import { classifyChangesWithLLM } from './classifier-llm.js'; 30import { DRateTracker } from './d-rate.js'; 31import { BootstrapStateMachine } from './bootstrap.js'; 32 33// Phase C 34import { planIUs } from './iu-planner.js'; 35import { generateIU, generateAll } from './regen.js'; 36import type { RegenContext } from './regen.js'; 37import { detectDrift } from './drift.js'; 38import { extractDependencies } from './dep-extractor.js'; 39import { validateBoundary } from './boundary-validator.js'; 40 41// Phase D 42import { evaluatePolicy, evaluateAllPolicies } from './policy-engine.js'; 43import { computeCascade } from './cascade.js'; 44 45// Phase E 46import { runShadowPipeline } from './shadow-pipeline.js'; 47 48// Phase F 49import { parseCommand, routeCommand, getAllCommands } from './bot-router.js'; 50 51// Scaffold 52import { deriveServices, generateScaffold } from './scaffold.js'; 53 54// Inspect 55import { collectInspectData, renderInspectHTML, serveInspect } from './inspect.js'; 56 57// LLM 58import { resolveProvider, describeAvailability } from './llm/resolve.js'; 59 60// Architectures 61import { resolveTarget, listArchitectures } from './architectures/index.js'; 62import type { ResolvedTarget } from './models/architecture.js'; 63 64// Audit & Fowler gaps 65import { auditIU, auditAll } from './audit.js'; 66import type { AuditResult, ReadinessLevel } from './audit.js'; 67import { EvaluationStore } from './store/evaluation-store.js'; 68import { NegativeKnowledgeStore } from './store/negative-knowledge-store.js'; 69import type { PaceLayerMetadata } from './models/pace-layer.js'; 70 71// Models 72import type { Clause } from './models/clause.js'; 73import { DiffType } from './models/clause.js'; 74import type { CanonicalNode } from './models/canonical.js'; 75import type { ImplementationUnit } from './models/iu.js'; 76import type { Diagnostic } from './models/diagnostic.js'; 77import type { DriftReport } from './models/manifest.js'; 78import { DriftStatus } from './models/manifest.js'; 79import { BootstrapState, DRateLevel } from './models/classification.js'; 80import type { PolicyEvaluation, CascadeEvent } from './models/evidence.js'; 81 82// ─── ANSI Colors ───────────────────────────────────────────────────────────── 83 84const BOLD = '\x1b[1m'; 85const DIM = '\x1b[2m'; 86const RESET = '\x1b[0m'; 87const RED = '\x1b[31m'; 88const GREEN = '\x1b[32m'; 89const YELLOW = '\x1b[33m'; 90const BLUE = '\x1b[34m'; 91const MAGENTA = '\x1b[35m'; 92const CYAN = '\x1b[36m'; 93const WHITE = '\x1b[37m'; 94const BG_RED = '\x1b[41m'; 95const BG_GREEN = '\x1b[42m'; 96const BG_YELLOW = '\x1b[43m'; 97 98function red(s: string): string { return `${RED}${s}${RESET}`; } 99function green(s: string): string { return `${GREEN}${s}${RESET}`; } 100function yellow(s: string): string { return `${YELLOW}${s}${RESET}`; } 101function blue(s: string): string { return `${BLUE}${s}${RESET}`; } 102function magenta(s: string): string { return `${MAGENTA}${s}${RESET}`; } 103function cyan(s: string): string { return `${CYAN}${s}${RESET}`; } 104function dim(s: string): string { return `${DIM}${s}${RESET}`; } 105function bold(s: string): string { return `${BOLD}${s}${RESET}`; } 106 107function severityColor(severity: string): string { 108 switch (severity) { 109 case 'error': return `${BG_RED}${WHITE}${BOLD} ERROR ${RESET}`; 110 case 'warning': return `${BG_YELLOW}${WHITE}${BOLD} WARN ${RESET}`; 111 case 'info': return `${BG_GREEN}${WHITE}${BOLD} INFO ${RESET}`; 112 default: return severity; 113 } 114} 115 116function severityIcon(severity: string): string { 117 switch (severity) { 118 case 'error': return red('✖'); 119 case 'warning': return yellow('⚠'); 120 case 'info': return blue('ℹ'); 121 default: return ' '; 122 } 123} 124 125// ─── Helpers ───────────────────────────────────────────────────────────────── 126 127const VERSION = '0.1.0'; 128 129function findPhoenixRoot(from: string = process.cwd()): string | null { 130 let dir = resolve(from); 131 while (true) { 132 if (existsSync(join(dir, '.phoenix'))) return dir; 133 const parent = resolve(dir, '..'); 134 if (parent === dir) return null; 135 dir = parent; 136 } 137} 138 139function requirePhoenixRoot(): { projectRoot: string; phoenixDir: string } { 140 const projectRoot = findPhoenixRoot(); 141 if (!projectRoot) { 142 console.error(red('✖ Not a Phoenix project. Run `phoenix init` first.')); 143 process.exit(1); 144 } 145 return { projectRoot, phoenixDir: join(projectRoot, '.phoenix') }; 146} 147 148function loadBootstrapState(phoenixDir: string): BootstrapStateMachine { 149 const statePath = join(phoenixDir, 'state.json'); 150 if (existsSync(statePath)) { 151 const data = JSON.parse(readFileSync(statePath, 'utf8')); 152 return BootstrapStateMachine.fromJSON(data); 153 } 154 return new BootstrapStateMachine(); 155} 156 157function saveBootstrapState(phoenixDir: string, machine: BootstrapStateMachine): void { 158 writeFileSync(join(phoenixDir, 'state.json'), JSON.stringify(machine.toJSON(), null, 2), 'utf8'); 159} 160 161function loadIUs(phoenixDir: string): ImplementationUnit[] { 162 const iuPath = join(phoenixDir, 'graphs', 'ius.json'); 163 if (!existsSync(iuPath)) return []; 164 return JSON.parse(readFileSync(iuPath, 'utf8')); 165} 166 167function saveIUs(phoenixDir: string, ius: ImplementationUnit[]): void { 168 const dir = join(phoenixDir, 'graphs'); 169 mkdirSync(dir, { recursive: true }); 170 writeFileSync(join(dir, 'ius.json'), JSON.stringify(ius, null, 2), 'utf8'); 171} 172 173function loadDRateTracker(phoenixDir: string): DRateTracker { 174 const path = join(phoenixDir, 'drate.json'); 175 if (existsSync(path)) { 176 const data = JSON.parse(readFileSync(path, 'utf8')); 177 const tracker = new DRateTracker(data.window_size || 100); 178 // Re-record stored window 179 if (data.window) { 180 for (const cls of data.window) { 181 tracker.recordOne(cls); 182 } 183 } 184 return tracker; 185 } 186 return new DRateTracker(); 187} 188 189function saveDRateTracker(phoenixDir: string, tracker: DRateTracker): void { 190 const status = tracker.getStatus(); 191 writeFileSync(join(phoenixDir, 'drate.json'), JSON.stringify({ 192 window_size: status.window_size, 193 rate: status.rate, 194 level: status.level, 195 d_count: status.d_count, 196 total_count: status.total_count, 197 }, null, 2), 'utf8'); 198} 199 200function findSpecFiles(projectRoot: string): string[] { 201 const specDir = join(projectRoot, 'spec'); 202 if (!existsSync(specDir)) return []; 203 return readdirSync(specDir, { recursive: true }) 204 .map(f => f.toString()) 205 .filter(f => f.endsWith('.md')) 206 .map(f => join(specDir, f)); 207} 208 209function printDiagnosticTable(diagnostics: Diagnostic[]): void { 210 if (diagnostics.length === 0) { 211 console.log(green(' No issues found.')); 212 return; 213 } 214 215 const errors = diagnostics.filter(d => d.severity === 'error'); 216 const warnings = diagnostics.filter(d => d.severity === 'warning'); 217 const infos = diagnostics.filter(d => d.severity === 'info'); 218 219 for (const group of [ 220 { items: errors, label: 'Errors' }, 221 { items: warnings, label: 'Warnings' }, 222 { items: infos, label: 'Info' }, 223 ]) { 224 if (group.items.length === 0) continue; 225 console.log(); 226 console.log(` ${bold(group.label)} (${group.items.length}):`); 227 for (const d of group.items) { 228 console.log(` ${severityIcon(d.severity)} ${bold(d.category)} ${dim('·')} ${d.subject}`); 229 console.log(` ${d.message}`); 230 if (d.recommended_actions.length > 0) { 231 console.log(` ${dim('→')} ${dim(d.recommended_actions[0])}`); 232 } 233 } 234 } 235} 236 237// ─── Commands ──────────────────────────────────────────────────────────────── 238 239function cmdInit(args?: string[]): void { 240 const projectRoot = process.cwd(); 241 const phoenixDir = join(projectRoot, '.phoenix'); 242 243 if (existsSync(phoenixDir)) { 244 console.log(yellow('⚠ Phoenix already initialized in this directory.')); 245 return; 246 } 247 248 mkdirSync(join(phoenixDir, 'store', 'objects'), { recursive: true }); 249 mkdirSync(join(phoenixDir, 'graphs'), { recursive: true }); 250 mkdirSync(join(phoenixDir, 'manifests'), { recursive: true }); 251 252 const machine = new BootstrapStateMachine(); 253 saveBootstrapState(phoenixDir, machine); 254 255 // Save architecture choice if specified 256 const archArg = args?.find(a => a.startsWith('--arch='))?.split('=')[1]; 257 if (archArg) { 258 const arch = resolveTarget(archArg); 259 if (!arch) { 260 console.log(red(`✖ Unknown architecture: ${archArg}`)); 261 console.log(` Available: ${listArchitectures().join(', ')}`); 262 return; 263 } 264 const configPath = join(phoenixDir, 'config.json'); 265 const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf8')) : {}; 266 config.architecture = archArg; 267 writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); 268 } 269 270 // Ensure spec/ directory exists 271 const specDir = join(projectRoot, 'spec'); 272 if (!existsSync(specDir)) { 273 mkdirSync(specDir, { recursive: true }); 274 } 275 276 // Create .gitignore 277 const gitignorePath = join(phoenixDir, '.gitignore'); 278 if (!existsSync(gitignorePath)) { 279 writeFileSync(gitignorePath, 'store/objects/\n', 'utf8'); 280 } 281 282 console.log(green('✔ Phoenix initialized.')); 283 console.log(); 284 console.log(` ${dim('Project root:')} ${projectRoot}`); 285 console.log(` ${dim('Phoenix dir:')} ${phoenixDir}`); 286 console.log(` ${dim('State:')} ${BootstrapState.BOOTSTRAP_COLD}`); 287 if (archArg) { 288 console.log(` ${dim('Architecture:')} ${cyan(archArg)}`); 289 } 290 console.log(); 291 console.log(` ${dim('Next steps:')}`); 292 console.log(` 1. Add spec documents to ${cyan('spec/')}`); 293 console.log(` 2. Run ${cyan('phoenix bootstrap')} to ingest & canonicalize`); 294} 295 296async function cmdBootstrap(): Promise<void> { 297 const { projectRoot, phoenixDir } = requirePhoenixRoot(); 298 299 console.log(bold('🔥 Phoenix Bootstrap')); 300 console.log(); 301 302 const specStore = new SpecStore(phoenixDir); 303 const canonStore = new CanonicalStore(phoenixDir); 304 const machine = loadBootstrapState(phoenixDir); 305 306 // Step 1: Find and ingest spec files 307 const specFiles = findSpecFiles(projectRoot); 308 if (specFiles.length === 0) { 309 console.log(yellow(' ⚠ No spec files found in spec/ directory.')); 310 console.log(dim(` Add .md files to ${join(projectRoot, 'spec')} and re-run.`)); 311 return; 312 } 313 314 console.log(` ${dim('Phase A:')} Clause extraction + cold hashing`); 315 let totalClauses = 0; 316 for (const specFile of specFiles) { 317 const result = specStore.ingestDocument(specFile, projectRoot); 318 totalClauses += result.clauses.length; 319 console.log(` ${green('✔')} ${relative(projectRoot, specFile)}${result.clauses.length} clauses`); 320 } 321 console.log(` ${dim(`Total: ${totalClauses} clauses extracted`)}`); 322 console.log(); 323 324 // Step 2: Canonicalization 325 const llmEarly = resolveProvider(phoenixDir); 326 if (llmEarly) { 327 console.log(` ${dim('Phase B:')} Canonicalization + warm context hashing ${dim(`(LLM: ${llmEarly.name}/${llmEarly.model})`)}`); 328 } else { 329 console.log(` ${dim('Phase B:')} Canonicalization + warm context hashing ${dim('(rule-based)')}`); 330 } 331 332 // Collect all clauses 333 const allClauses: Clause[] = []; 334 for (const specFile of specFiles) { 335 const docId = relative(projectRoot, specFile); 336 allClauses.push(...specStore.getClauses(docId)); 337 } 338 339 // Extract canonical nodes (LLM-enhanced when available) 340 const canonNodes = await extractCanonicalNodesLLM(allClauses, llmEarly); 341 canonStore.saveNodes(canonNodes); 342 console.log(` ${green('✔')} ${canonNodes.length} canonical nodes extracted`); 343 344 // Compute warm hashes 345 const warmHashes = computeWarmHashes(allClauses, canonNodes); 346 console.log(` ${green('✔')} ${warmHashes.size} warm context hashes computed`); 347 348 // Save warm hashes 349 const warmPath = join(phoenixDir, 'graphs', 'warm-hashes.json'); 350 const warmObj: Record<string, string> = {}; 351 for (const [k, v] of warmHashes) warmObj[k] = v; 352 writeFileSync(warmPath, JSON.stringify(warmObj, null, 2), 'utf8'); 353 354 // Mark warm pass complete 355 machine.markWarmPassComplete(); 356 console.log(` ${green('✔')} System state: ${cyan(machine.getState())}`); 357 console.log(); 358 359 // Step 3: Plan IUs 360 console.log(` ${dim('Phase C:')} IU planning`); 361 const ius = planIUs(canonNodes, allClauses); 362 saveIUs(phoenixDir, ius); 363 console.log(` ${green('✔')} ${ius.length} Implementation Units planned`); 364 for (const iu of ius) { 365 console.log(` ${dim('·')} ${iu.name} ${dim(`(${iu.risk_tier})`)}${iu.output_files.join(', ')}`); 366 } 367 console.log(); 368 369 // Step 4: Generate code 370 const llm = resolveProvider(phoenixDir); 371 const { hint } = describeAvailability(); 372 if (llm) { 373 console.log(` ${dim('Phase C:')} Code generation ${dim(`(${llm.name}/${llm.model})`)}`); 374 } else { 375 console.log(` ${dim('Phase C:')} Code generation ${dim('(stubs — no LLM)')}`); 376 console.log(` ${dim(hint)}`); 377 } 378 379 // Load architecture from config 380 const configPath = join(phoenixDir, 'config.json'); 381 let arch: ResolvedTarget | null = null; 382 if (existsSync(configPath)) { 383 try { 384 const config = JSON.parse(readFileSync(configPath, 'utf8')); 385 if (config.architecture) { 386 arch = resolveTarget(config.architecture); 387 if (arch) console.log(` ${dim('Architecture:')} ${cyan(arch.architecture.name)} / ${cyan(arch.runtime.name)}`); 388 } 389 } catch { /* ignore */ } 390 } 391 392 // Write shared architecture files BEFORE code generation 393 // so the typecheck-retry loop can resolve imports like ../../db.js 394 if (arch) { 395 for (const [filePath, content] of Object.entries(arch.runtime.sharedFiles)) { 396 const fullPath = join(projectRoot, filePath); 397 mkdirSync(dirname(fullPath), { recursive: true }); 398 writeFileSync(fullPath, content, 'utf8'); 399 } 400 // Write package.json with arch deps so tsc can resolve types during generation 401 const earlyPkg = { 402 name: basename(projectRoot), 403 version: '0.1.0', 404 type: 'module', 405 dependencies: arch.runtime.packages, 406 devDependencies: arch.runtime.devPackages, 407 }; 408 const pkgPath = join(projectRoot, 'package.json'); 409 writeFileSync(pkgPath, JSON.stringify(earlyPkg, null, 2) + '\n', 'utf8'); 410 // Install so type declarations are available for typecheck-retry 411 try { 412 execSync('npm install --silent 2>/dev/null', { cwd: projectRoot, stdio: 'pipe', timeout: 60000 }); 413 } catch { /* best effort */ } 414 } 415 416 const regenCtx: RegenContext = { 417 llm: llm ?? undefined, 418 canonNodes, 419 allIUs: ius, 420 projectRoot, 421 target: arch, 422 onProgress: (iu, status, msg) => { 423 if (status === 'start') process.stdout.write(`${iu.name}`); 424 else if (status === 'done') process.stdout.write(` ${green('✔')}\n`); 425 else if (status === 'error') process.stdout.write(` ${red('✖')} ${dim(msg || 'failed, using stub')}\n`); 426 }, 427 }; 428 429 const manifestManager = new ManifestManager(phoenixDir); 430 const regenResults = await generateAll(ius, regenCtx); 431 for (const result of regenResults) { 432 for (const [filePath, content] of result.files) { 433 const fullPath = join(projectRoot, filePath); 434 mkdirSync(join(fullPath, '..'), { recursive: true }); 435 writeFileSync(fullPath, content, 'utf8'); 436 } 437 manifestManager.recordIU(result.manifest); 438 if (!llm) { 439 console.log(` ${green('✔')} ${result.iu_id.slice(0, 8)}… → ${result.files.size} file(s)`); 440 } 441 } 442 console.log(); 443 444 // Step 5: Service scaffold 445 console.log(` ${dim('Scaffold:')} Service wiring + project config`); 446 const services = deriveServices(ius); 447 const projectName = basename(projectRoot); 448 const scaffold = generateScaffold(services, projectName, arch); 449 for (const [filePath, content] of scaffold.files) { 450 const fullPath = join(projectRoot, filePath); 451 mkdirSync(join(fullPath, '..'), { recursive: true }); 452 writeFileSync(fullPath, content, 'utf8'); 453 } 454 for (const svc of services) { 455 console.log(` ${green('✔')} ${svc.name} → :${svc.port} (${svc.modules.length} modules)`); 456 } 457 console.log(` ${green('✔')} package.json, tsconfig.json`); 458 console.log(); 459 460 // Save state 461 saveBootstrapState(phoenixDir, machine); 462 463 // Step 6: First trust dashboard 464 console.log(` ${dim('Phase D:')} Trust Dashboard`); 465 console.log(); 466 printTrustDashboard(phoenixDir, projectRoot, machine, ius, canonNodes, allClauses); 467 468 console.log(); 469 console.log(green(' ✔ Bootstrap complete.')); 470 console.log(` State: ${cyan(machine.getState())}`); 471 console.log(` Run ${cyan('phoenix status')} to see the trust dashboard.`); 472} 473 474function cmdStatus(): void { 475 const { projectRoot, phoenixDir } = requirePhoenixRoot(); 476 const machine = loadBootstrapState(phoenixDir); 477 const ius = loadIUs(phoenixDir); 478 const canonStore = new CanonicalStore(phoenixDir); 479 const canonNodes = canonStore.getAllNodes(); 480 const specStore = new SpecStore(phoenixDir); 481 482 // Collect all clauses 483 const allClauses: Clause[] = []; 484 const specFiles = findSpecFiles(projectRoot); 485 for (const specFile of specFiles) { 486 const docId = relative(projectRoot, specFile); 487 allClauses.push(...specStore.getClauses(docId)); 488 } 489 490 console.log(); 491 console.log(bold('🔥 Phoenix Status')); 492 console.log(); 493 494 printTrustDashboard(phoenixDir, projectRoot, machine, ius, canonNodes, allClauses); 495} 496 497function printTrustDashboard( 498 phoenixDir: string, 499 projectRoot: string, 500 machine: BootstrapStateMachine, 501 ius: ImplementationUnit[], 502 canonNodes: CanonicalNode[], 503 allClauses: Clause[], 504): void { 505 const diagnostics: Diagnostic[] = []; 506 507 // System state 508 const state = machine.getState(); 509 const stateLabel = state === BootstrapState.STEADY_STATE 510 ? green(state) 511 : state === BootstrapState.BOOTSTRAP_WARMING 512 ? yellow(state) 513 : cyan(state); 514 console.log(` ${dim('System State:')} ${stateLabel}`); 515 console.log(` ${dim('Canonical Nodes:')} ${canonNodes.length}`); 516 console.log(` ${dim('Implementation Units:')} ${ius.length}`); 517 console.log(` ${dim('Spec Clauses:')} ${allClauses.length}`); 518 519 // Canon type breakdown 520 const typeBreakdown: Record<string, number> = {}; 521 for (const n of canonNodes) typeBreakdown[n.type] = (typeBreakdown[n.type] ?? 0) + 1; 522 const typeParts = Object.entries(typeBreakdown).map(([t, c]) => `${c} ${t}`); 523 if (typeParts.length > 0) { 524 console.log(` ${dim('Canon Types:')} ${dim(typeParts.join(', '))}`); 525 } 526 527 // Resolution metrics 528 let totalEdges = 0; 529 let relatesToEdges = 0; 530 let orphanCount = 0; 531 let maxDegree = 0; 532 let withParent = 0; 533 const nonContextNodes = canonNodes.filter(n => n.type !== 'CONTEXT'); 534 for (const n of canonNodes) { 535 const deg = n.linked_canon_ids.length; 536 if (deg === 0) orphanCount++; 537 if (deg > maxDegree) maxDegree = deg; 538 if (n.parent_canon_id) withParent++; 539 for (const [, edgeType] of Object.entries(n.link_types ?? {})) { 540 totalEdges++; 541 if (edgeType === 'relates_to') relatesToEdges++; 542 } 543 } 544 if (canonNodes.length > 0) { 545 const resDRate = totalEdges > 0 ? ((relatesToEdges / totalEdges) * 100).toFixed(0) : '0'; 546 const orphanPct = ((orphanCount / canonNodes.length) * 100).toFixed(0); 547 const hierPct = nonContextNodes.length > 0 ? ((withParent / nonContextNodes.length) * 100).toFixed(0) : '0'; 548 console.log(` ${dim('Resolution:')} ${totalEdges} edges ${dim(`(${resDRate}% relates_to)`)}${dim(',')} max degree ${maxDegree}${dim(',')} ${hierPct}% hierarchy`); 549 } 550 551 // Extraction coverage (recompute from current specs) 552 if (allClauses.length > 0) { 553 const { coverage } = extractCandidates(allClauses); 554 const avgCov = coverage.reduce((s, c) => s + c.coverage_pct, 0) / coverage.length; 555 const lowCov = coverage.filter(c => c.coverage_pct < 80); 556 const covLabel = avgCov >= 95 ? green(`${avgCov.toFixed(0)}%`) : avgCov >= 80 ? yellow(`${avgCov.toFixed(0)}%`) : red(`${avgCov.toFixed(0)}%`); 557 console.log(` ${dim('Coverage:')} ${covLabel} extraction${lowCov.length > 0 ? dim(` (${lowCov.length} clause${lowCov.length !== 1 ? 's' : ''} below 80%)`) : ''}`); 558 for (const cov of lowCov) { 559 diagnostics.push({ 560 severity: 'info', 561 category: 'canon', 562 subject: cov.clause_id.slice(0, 12), 563 message: `Extraction coverage ${cov.coverage_pct.toFixed(0)}% (${cov.extracted_sentences + cov.context_sentences}/${cov.total_sentences} sentences)`, 564 recommended_actions: cov.uncovered.map(u => `[${u.reason}] ${u.text.slice(0, 60)}`), 565 }); 566 } 567 } 568 569 console.log(); 570 571 // D-rate 572 const dRateTracker = loadDRateTracker(phoenixDir); 573 const dRate = dRateTracker.getStatus(); 574 if (dRate.total_count > 0) { 575 const pct = (dRate.rate * 100).toFixed(1); 576 let dRateColor: (s: string) => string; 577 switch (dRate.level) { 578 case DRateLevel.TARGET: dRateColor = green; break; 579 case DRateLevel.ACCEPTABLE: dRateColor = green; break; 580 case DRateLevel.WARNING: dRateColor = yellow; break; 581 case DRateLevel.ALARM: dRateColor = red; break; 582 } 583 console.log(` ${dim('D-Rate:')} ${dRateColor(`${pct}%`)} ${dim(`(${dRate.level}, ${dRate.d_count}/${dRate.total_count})`)}`); 584 585 if (dRate.level === DRateLevel.WARNING || dRate.level === DRateLevel.ALARM) { 586 if (!machine.shouldSuppressAlarms()) { 587 diagnostics.push({ 588 severity: machine.shouldDowngradeSeverity() ? 'warning' : 'error', 589 category: 'd-rate', 590 subject: 'Global', 591 message: `D-rate ${pct}% (${dRate.level})`, 592 recommended_actions: ['Tune classifier or resolve uncertain changes'], 593 }); 594 } 595 } 596 } else { 597 console.log(` ${dim('D-Rate:')} ${dim('no data')}`); 598 } 599 600 // Drift detection 601 const manifestManager = new ManifestManager(phoenixDir); 602 const manifest = manifestManager.load(); 603 if (manifest.generated_at) { 604 const driftReport = detectDrift(manifest, projectRoot); 605 const driftLabel = driftReport.drifted_count === 0 && driftReport.missing_count === 0 606 ? green('clean') 607 : red(`${driftReport.drifted_count} drifted, ${driftReport.missing_count} missing`); 608 console.log(` ${dim('Drift:')} ${driftLabel} ${dim(`(${driftReport.clean_count} clean)`)}`); 609 610 for (const entry of driftReport.entries) { 611 if (entry.status === DriftStatus.DRIFTED) { 612 diagnostics.push({ 613 severity: 'error', 614 category: 'drift', 615 subject: entry.file_path, 616 iu_id: entry.iu_id, 617 message: `Working tree differs from generated manifest`, 618 recommended_actions: ['Label edit (promote_to_requirement, waiver, or temporary_patch)', 'Or run `phoenix regen` to regenerate'], 619 }); 620 } 621 if (entry.status === DriftStatus.MISSING) { 622 diagnostics.push({ 623 severity: 'error', 624 category: 'drift', 625 subject: entry.file_path, 626 iu_id: entry.iu_id, 627 message: `Generated file is missing from working tree`, 628 recommended_actions: ['Run `phoenix regen` to regenerate'], 629 }); 630 } 631 } 632 } else { 633 console.log(` ${dim('Drift:')} ${dim('no manifest')}`); 634 } 635 636 // Boundary validation 637 for (const iu of ius) { 638 for (const outputFile of iu.output_files) { 639 const fullPath = join(projectRoot, outputFile); 640 if (!existsSync(fullPath)) continue; 641 const source = readFileSync(fullPath, 'utf8'); 642 const depGraph = extractDependencies(source, outputFile); 643 const boundaryDiags = validateBoundary(depGraph, iu); 644 diagnostics.push(...boundaryDiags); 645 } 646 } 647 648 // Policy evaluation 649 const evidenceStore = new EvidenceStore(phoenixDir); 650 const allEvidence = evidenceStore.getAll(); 651 const policyEvals = evaluateAllPolicies(ius, allEvidence); 652 653 let passCount = 0; 654 let failCount = 0; 655 let incompleteCount = 0; 656 657 for (const eval_ of policyEvals) { 658 switch (eval_.verdict) { 659 case 'PASS': passCount++; break; 660 case 'FAIL': failCount++; break; 661 case 'INCOMPLETE': incompleteCount++; break; 662 } 663 664 if (eval_.verdict === 'FAIL') { 665 diagnostics.push({ 666 severity: 'error', 667 category: 'evidence', 668 subject: eval_.iu_name, 669 iu_id: eval_.iu_id, 670 message: `Evidence failed: ${eval_.failed.join(', ')}`, 671 recommended_actions: ['Re-run failing evidence checks', `Risk tier: ${eval_.risk_tier}`], 672 }); 673 } else if (eval_.verdict === 'INCOMPLETE') { 674 diagnostics.push({ 675 severity: 'warning', 676 category: 'evidence', 677 subject: eval_.iu_name, 678 iu_id: eval_.iu_id, 679 message: `Missing evidence: ${eval_.missing.join(', ')}`, 680 recommended_actions: [`Collect required evidence for ${eval_.risk_tier} tier`], 681 }); 682 } 683 } 684 685 console.log(` ${dim('Evidence:')} ${green(`${passCount} pass`)}, ${failCount > 0 ? red(`${failCount} fail`) : dim(`${failCount} fail`)}, ${incompleteCount > 0 ? yellow(`${incompleteCount} incomplete`) : dim(`${incompleteCount} incomplete`)}`); 686 687 // Cascade effects 688 const cascadeEvents = computeCascade(policyEvals, ius); 689 if (cascadeEvents.length > 0) { 690 console.log(` ${dim('Cascades:')} ${yellow(`${cascadeEvents.length} active`)}`); 691 for (const event of cascadeEvents) { 692 for (const action of event.actions) { 693 if (action.action === 'BLOCK') { 694 diagnostics.push({ 695 severity: 'error', 696 category: 'evidence', 697 subject: action.iu_name, 698 iu_id: action.iu_id, 699 message: `BLOCKED: ${action.reason}`, 700 recommended_actions: ['Fix failing evidence before proceeding'], 701 }); 702 } else if (action.action === 'RE_VALIDATE') { 703 diagnostics.push({ 704 severity: 'warning', 705 category: 'evidence', 706 subject: action.iu_name, 707 iu_id: action.iu_id, 708 message: `Re-validation needed: ${action.reason}`, 709 recommended_actions: ['Re-run typecheck + boundary + tagged tests'], 710 }); 711 } 712 } 713 } 714 } else { 715 console.log(` ${dim('Cascades:')} ${dim('none')}`); 716 } 717 718 console.log(); 719 720 // Diagnostics table 721 console.log(bold(' ─── Diagnostics ───')); 722 printDiagnosticTable(diagnostics); 723 console.log(); 724 725 // Summary line 726 const errors = diagnostics.filter(d => d.severity === 'error').length; 727 const warnings = diagnostics.filter(d => d.severity === 'warning').length; 728 const infos = diagnostics.filter(d => d.severity === 'info').length; 729 730 if (errors === 0 && warnings === 0) { 731 console.log(green(' ✔ All clear.')); 732 } else { 733 const parts: string[] = []; 734 if (errors > 0) parts.push(red(`${errors} error${errors !== 1 ? 's' : ''}`)); 735 if (warnings > 0) parts.push(yellow(`${warnings} warning${warnings !== 1 ? 's' : ''}`)); 736 if (infos > 0) parts.push(blue(`${infos} info`)); 737 console.log(` ${parts.join(', ')}`); 738 } 739} 740 741function cmdIngest(args: string[]): void { 742 const { projectRoot, phoenixDir } = requirePhoenixRoot(); 743 const specStore = new SpecStore(phoenixDir); 744 const verbose = args.includes('-v') || args.includes('--verbose'); 745 const filteredArgs = args.filter(a => a !== '-v' && a !== '--verbose'); 746 747 let files: string[]; 748 if (filteredArgs.length === 0) { 749 files = findSpecFiles(projectRoot); 750 if (files.length === 0) { 751 console.log(yellow('⚠ No spec files found. Provide a path or add files to spec/.')); 752 return; 753 } 754 } else { 755 files = args.map(f => resolve(f)); 756 for (const f of files) { 757 if (!existsSync(f)) { 758 console.error(red(`✖ File not found: ${f}`)); 759 process.exit(1); 760 } 761 } 762 } 763 764 console.log(bold('📥 Spec Ingestion')); 765 console.log(); 766 767 let totalClauses = 0; 768 let totalChanges = 0; 769 770 for (const file of files) { 771 const docId = relative(projectRoot, file); 772 773 // Show diff BEFORE ingesting 774 const diffs = specStore.diffDocument(file, projectRoot); 775 const added = diffs.filter(d => d.diff_type === DiffType.ADDED).length; 776 const removed = diffs.filter(d => d.diff_type === DiffType.REMOVED).length; 777 const modified = diffs.filter(d => d.diff_type === DiffType.MODIFIED).length; 778 const hasChanges = added > 0 || removed > 0 || modified > 0; 779 780 // Now ingest (overwrites stored clauses) 781 const result = specStore.ingestDocument(file, projectRoot); 782 totalClauses += result.clauses.length; 783 784 if (hasChanges) { 785 totalChanges += added + removed + modified; 786 console.log(` ${green('✔')} ${docId}${result.clauses.length} clauses`); 787 if (added > 0) console.log(` ${green(`+${added} added`)}`); 788 if (removed > 0) console.log(` ${red(`-${removed} removed`)}`); 789 if (modified > 0) console.log(` ${yellow(`~${modified} modified`)}`); 790 791 // Show which clauses changed 792 for (const d of diffs) { 793 if (d.diff_type === DiffType.UNCHANGED) continue; 794 const pathLabel = d.section_path_after?.join(' > ') || d.section_path_before?.join(' > ') || ''; 795 const icon = d.diff_type === DiffType.ADDED ? green('+') : d.diff_type === DiffType.REMOVED ? red('-') : yellow('~'); 796 console.log(` ${icon} ${pathLabel}`); 797 798 if (verbose && d.diff_type === DiffType.MODIFIED && d.clause_before && d.clause_after) { 799 // Show line-level diff of the raw text 800 const beforeLines = d.clause_before.raw_text.split('\n'); 801 const afterLines = d.clause_after.raw_text.split('\n'); 802 const beforeSet = new Set(beforeLines.map(l => l.trim())); 803 const afterSet = new Set(afterLines.map(l => l.trim())); 804 for (const line of afterLines) { 805 if (!beforeSet.has(line.trim()) && line.trim()) { 806 console.log(` ${green('+ ' + line.trim())}`); 807 } 808 } 809 for (const line of beforeLines) { 810 if (!afterSet.has(line.trim()) && line.trim()) { 811 console.log(` ${red('- ' + line.trim())}`); 812 } 813 } 814 } else if (verbose && d.diff_type === DiffType.ADDED && d.clause_after) { 815 const lines = d.clause_after.raw_text.split('\n').filter(l => l.trim()); 816 for (const line of lines.slice(0, 5)) { 817 console.log(` ${green('+ ' + line.trim())}`); 818 } 819 if (lines.length > 5) console.log(` ${dim(`... and ${lines.length - 5} more lines`)}`); 820 } else if (verbose && d.diff_type === DiffType.REMOVED && d.clause_before) { 821 const lines = d.clause_before.raw_text.split('\n').filter(l => l.trim()); 822 for (const line of lines.slice(0, 5)) { 823 console.log(` ${red('- ' + line.trim())}`); 824 } 825 if (lines.length > 5) console.log(` ${dim(`... and ${lines.length - 5} more lines`)}`); 826 } 827 } 828 } else { 829 console.log(` ${green('✔')} ${docId}${result.clauses.length} clauses ${dim('(no changes)')}`); 830 } 831 } 832 833 console.log(); 834 console.log(` ${dim(`Total: ${totalClauses} clauses ingested`)}`); 835 if (totalChanges > 0) { 836 console.log(` ${dim(`Changes: ${totalChanges} clauses affected`)}`); 837 console.log(); 838 console.log(` ${dim('Next: run')} ${cyan('phoenix canonicalize')} ${dim('then')} ${cyan('phoenix regen')} ${dim('to update generated code')}`); 839 } 840} 841 842function cmdDiff(args: string[]): void { 843 const { projectRoot, phoenixDir } = requirePhoenixRoot(); 844 const specStore = new SpecStore(phoenixDir); 845 846 let files: string[]; 847 if (args.length === 0) { 848 files = findSpecFiles(projectRoot); 849 } else { 850 files = args.map(f => resolve(f)); 851 } 852 853 console.log(bold('📊 Clause Diff')); 854 console.log(); 855 856 for (const file of files) { 857 if (!existsSync(file)) { 858 console.log(red(`${file}: not found`)); 859 continue; 860 } 861 862 const docId = relative(projectRoot, file); 863 const diffs = specStore.diffDocument(file, projectRoot); 864 865 const added = diffs.filter(d => d.diff_type === DiffType.ADDED).length; 866 const removed = diffs.filter(d => d.diff_type === DiffType.REMOVED).length; 867 const modified = diffs.filter(d => d.diff_type === DiffType.MODIFIED).length; 868 const moved = diffs.filter(d => d.diff_type === DiffType.MOVED).length; 869 const unchanged = diffs.filter(d => d.diff_type === DiffType.UNCHANGED).length; 870 871 console.log(` ${bold(docId)}`); 872 873 if (diffs.length === 0) { 874 console.log(` ${dim('(no stored clauses to compare against)')}`); 875 continue; 876 } 877 878 if (added === 0 && removed === 0 && modified === 0 && moved === 0) { 879 console.log(` ${green('✔')} No changes (${unchanged} clauses)`); 880 continue; 881 } 882 883 if (added > 0) console.log(` ${green(`+${added} added`)}`); 884 if (removed > 0) console.log(` ${red(`-${removed} removed`)}`); 885 if (modified > 0) console.log(` ${yellow(`~${modified} modified`)}`); 886 if (moved > 0) console.log(` ${blue(`${moved} moved`)}`); 887 console.log(` ${dim(`${unchanged} unchanged`)}`); 888 889 // Show details for non-trivial changes 890 for (const d of diffs) { 891 if (d.diff_type === DiffType.UNCHANGED) continue; 892 const pathLabel = d.section_path_after?.join(' > ') || d.section_path_before?.join(' > ') || ''; 893 switch (d.diff_type) { 894 case DiffType.ADDED: 895 console.log(` ${green('+')} ${pathLabel}`); 896 break; 897 case DiffType.REMOVED: 898 console.log(` ${red('-')} ${pathLabel}`); 899 break; 900 case DiffType.MODIFIED: 901 console.log(` ${yellow('~')} ${pathLabel}`); 902 break; 903 case DiffType.MOVED: 904 console.log(` ${blue('↗')} ${d.section_path_before?.join(' > ')}${d.section_path_after?.join(' > ')}`); 905 break; 906 } 907 } 908 console.log(); 909 } 910} 911 912function cmdClauses(args: string[]): void { 913 const { projectRoot, phoenixDir } = requirePhoenixRoot(); 914 const specStore = new SpecStore(phoenixDir); 915 916 let files: string[]; 917 if (args.length === 0) { 918 files = findSpecFiles(projectRoot); 919 } else { 920 files = args.map(f => resolve(f)); 921 } 922 923 console.log(bold('📋 Stored Clauses')); 924 console.log(); 925 926 for (const file of files) { 927 const docId = relative(projectRoot, file); 928 const clauses = specStore.getClauses(docId); 929 930 console.log(` ${bold(docId)} ${dim(`(${clauses.length} clauses)`)}`); 931 for (const c of clauses) { 932 const path = c.section_path.join(' > ') || '(root)'; 933 const lines = `L${c.source_line_range[0]}${c.source_line_range[1]}`; 934 const preview = c.normalized_text.slice(0, 80).replace(/\n/g, ' '); 935 console.log(` ${dim(c.clause_id.slice(0, 8))} ${cyan(path)} ${dim(lines)}`); 936 console.log(` ${dim(preview)}${c.normalized_text.length > 80 ? '…' : ''}`); 937 } 938 console.log(); 939 } 940} 941 942function cmdCanon(): void { 943 const { phoenixDir } = requirePhoenixRoot(); 944 const canonStore = new CanonicalStore(phoenixDir); 945 const nodes = canonStore.getAllNodes(); 946 947 console.log(bold('📐 Canonical Graph')); 948 console.log(); 949 console.log(` ${dim(`${nodes.length} nodes`)}`); 950 console.log(); 951 952 const byType = new Map<string, CanonicalNode[]>(); 953 for (const node of nodes) { 954 const list = byType.get(node.type) || []; 955 list.push(node); 956 byType.set(node.type, list); 957 } 958 959 for (const [type, typeNodes] of byType) { 960 const color = type === 'REQUIREMENT' ? green 961 : type === 'CONSTRAINT' ? red 962 : type === 'INVARIANT' ? magenta 963 : blue; 964 console.log(` ${color(bold(type))} (${typeNodes.length})`); 965 for (const node of typeNodes) { 966 const preview = node.statement.slice(0, 80).replace(/\n/g, ' '); 967 const links = node.linked_canon_ids.length > 0 968 ? dim(`${node.linked_canon_ids.length} links`) 969 : ''; 970 console.log(` ${dim(node.canon_id.slice(0, 8))} ${preview}${node.statement.length > 80 ? '…' : ''}${links}`); 971 } 972 console.log(); 973 } 974} 975 976function cmdPlan(): void { 977 const { projectRoot, phoenixDir } = requirePhoenixRoot(); 978 const canonStore = new CanonicalStore(phoenixDir); 979 const specStore = new SpecStore(phoenixDir); 980 981 const canonNodes = canonStore.getAllNodes(); 982 if (canonNodes.length === 0) { 983 console.log(yellow('⚠ No canonical nodes. Run `phoenix bootstrap` or `phoenix ingest` + `phoenix canonicalize` first.')); 984 return; 985 } 986 987 // Collect clauses 988 const allClauses: Clause[] = []; 989 const specFiles = findSpecFiles(projectRoot); 990 for (const specFile of specFiles) { 991 const docId = relative(projectRoot, specFile); 992 allClauses.push(...specStore.getClauses(docId)); 993 } 994 995 const ius = planIUs(canonNodes, allClauses); 996 saveIUs(phoenixDir, ius); 997 998 console.log(bold('📦 IU Plan')); 999 console.log(); 1000 console.log(` ${green(`${ius.length} Implementation Units planned`)}`); 1001 console.log(); 1002 1003 for (const iu of ius) { 1004 const riskColor = iu.risk_tier === 'critical' ? red 1005 : iu.risk_tier === 'high' ? yellow 1006 : iu.risk_tier === 'medium' ? cyan 1007 : green; 1008 console.log(` ${bold(iu.name)}`); 1009 console.log(` ${dim('ID:')} ${iu.iu_id.slice(0, 12)}`); 1010 console.log(` ${dim('Risk:')} ${riskColor(iu.risk_tier)}`); 1011 console.log(` ${dim('Kind:')} ${iu.kind}`); 1012 console.log(` ${dim('Sources:')} ${iu.source_canon_ids.length} canonical nodes`); 1013 console.log(` ${dim('Output:')} ${iu.output_files.join(', ')}`); 1014 console.log(` ${dim('Evidence:')} ${iu.evidence_policy.required.join(', ')}`); 1015 if (iu.contract.invariants.length > 0) { 1016 console.log(` ${dim('Invariants:')}`); 1017 for (const inv of iu.contract.invariants) { 1018 console.log(` ${dim('·')} ${inv.slice(0, 80)}`); 1019 } 1020 } 1021 console.log(); 1022 } 1023} 1024 1025async function cmdRegen(args: string[]): Promise<void> { 1026 const { projectRoot, phoenixDir } = requirePhoenixRoot(); 1027 const ius = loadIUs(phoenixDir); 1028 1029 if (ius.length === 0) { 1030 console.log(yellow('⚠ No IUs planned. Run `phoenix plan` first.')); 1031 return; 1032 } 1033 1034 // Parse --iu=<id> flag and --stubs flag 1035 const iuFilter = args.find(a => a.startsWith('--iu='))?.split('=')[1]; 1036 const forceStubs = args.includes('--stubs'); 1037 const targetIUs = iuFilter 1038 ? ius.filter(iu => iu.iu_id.startsWith(iuFilter) || iu.name === iuFilter) 1039 : ius; 1040 1041 if (targetIUs.length === 0) { 1042 console.log(red(`✖ No IU matching: ${iuFilter}`)); 1043 return; 1044 } 1045 1046 const llm = forceStubs ? null : resolveProvider(phoenixDir); 1047 const canonStore = new CanonicalStore(phoenixDir); 1048 const canonNodes = canonStore.getAllNodes(); 1049 1050 console.log(bold('⚡ Code Regeneration')); 1051 if (llm) { 1052 console.log(` ${dim(`Provider: ${llm.name}/${llm.model}`)}`); 1053 } else { 1054 const { hint } = describeAvailability(); 1055 console.log(` ${dim('Mode: stubs')}${forceStubs ? '' : ` ${dim('—')} ${dim(hint)}`}`); 1056 } 1057 console.log(); 1058 1059 // Load architecture 1060 const configPath = join(phoenixDir, 'config.json'); 1061 let regenArch: ResolvedTarget | null = null; 1062 if (existsSync(configPath)) { 1063 try { 1064 const cfg = JSON.parse(readFileSync(configPath, 'utf8')); 1065 if (cfg.architecture) regenArch = resolveTarget(cfg.architecture); 1066 } catch { /* ignore */ } 1067 } 1068 1069 const regenCtx: RegenContext = { 1070 llm: llm ?? undefined, 1071 canonNodes, 1072 allIUs: ius, 1073 projectRoot, 1074 target: regenArch, 1075 onProgress: (iu, status, msg) => { 1076 if (status === 'start') process.stdout.write(`${iu.name}`); 1077 else if (status === 'done') process.stdout.write(` ${green('✔')}\n`); 1078 else if (status === 'error') process.stdout.write(` ${red('✖')} ${dim(msg || 'failed, using stub')}\n`); 1079 }, 1080 }; 1081 1082 const manifestManager = new ManifestManager(phoenixDir); 1083 const results = await generateAll(targetIUs, regenCtx); 1084 1085 for (const result of results) { 1086 for (const [filePath, content] of result.files) { 1087 const fullPath = join(projectRoot, filePath); 1088 mkdirSync(join(fullPath, '..'), { recursive: true }); 1089 writeFileSync(fullPath, content, 'utf8'); 1090 } 1091 manifestManager.recordIU(result.manifest); 1092 1093 if (!llm) { 1094 const iu = targetIUs.find(i => i.iu_id === result.iu_id); 1095 console.log(` ${green('✔')} ${iu?.name || result.iu_id.slice(0, 12)}`); 1096 for (const [filePath] of result.files) { 1097 console.log(`${cyan(filePath)}`); 1098 } 1099 } 1100 } 1101 1102 // Re-generate scaffold wiring 1103 const allIUs = loadIUs(phoenixDir); 1104 const services = deriveServices(allIUs); 1105 const scaffold = generateScaffold(services, basename(projectRoot)); 1106 for (const [filePath, content] of scaffold.files) { 1107 const fullPath = join(projectRoot, filePath); 1108 mkdirSync(join(fullPath, '..'), { recursive: true }); 1109 writeFileSync(fullPath, content, 'utf8'); 1110 } 1111 1112 console.log(); 1113 console.log(` ${dim(`${results.length} IU(s) regenerated. Scaffold updated.`)}`); 1114} 1115 1116function cmdDrift(): void { 1117 const { projectRoot, phoenixDir } = requirePhoenixRoot(); 1118 const manifestManager = new ManifestManager(phoenixDir); 1119 const manifest = manifestManager.load(); 1120 1121 if (!manifest.generated_at) { 1122 console.log(yellow('⚠ No generated manifest. Run `phoenix regen` first.')); 1123 return; 1124 } 1125 1126 const report = detectDrift(manifest, projectRoot); 1127 1128 console.log(bold('🔍 Drift Detection')); 1129 console.log(); 1130 1131 if (report.drifted_count === 0 && report.missing_count === 0) { 1132 console.log(` ${green('✔')} ${report.summary}`); 1133 } else { 1134 console.log(` ${red('✖')} ${report.summary}`); 1135 } 1136 console.log(); 1137 1138 for (const entry of report.entries) { 1139 switch (entry.status) { 1140 case DriftStatus.CLEAN: 1141 console.log(` ${green('✔')} ${entry.file_path}`); 1142 break; 1143 case DriftStatus.DRIFTED: 1144 console.log(` ${red('✖')} ${entry.file_path} ${red('DRIFTED')}`); 1145 console.log(` ${dim('expected:')} ${entry.expected_hash?.slice(0, 12)}`); 1146 console.log(` ${dim('actual:')} ${entry.actual_hash?.slice(0, 12)}`); 1147 console.log(` ${dim('→ Label this edit: promote_to_requirement | waiver | temporary_patch')}`); 1148 break; 1149 case DriftStatus.MISSING: 1150 console.log(` ${red('✖')} ${entry.file_path} ${red('MISSING')}`); 1151 console.log(` ${dim('→ Run `phoenix regen` to regenerate')}`); 1152 break; 1153 case DriftStatus.WAIVED: 1154 console.log(` ${yellow('⚠')} ${entry.file_path} ${yellow('WAIVED')}`); 1155 if (entry.waiver) { 1156 console.log(` ${dim('kind:')} ${entry.waiver.kind}`); 1157 console.log(` ${dim('reason:')} ${entry.waiver.reason}`); 1158 } 1159 break; 1160 } 1161 } 1162} 1163 1164async function cmdCanonicalize(): Promise<void> { 1165 const { projectRoot, phoenixDir } = requirePhoenixRoot(); 1166 const specStore = new SpecStore(phoenixDir); 1167 const canonStore = new CanonicalStore(phoenixDir); 1168 1169 const allClauses: Clause[] = []; 1170 const specFiles = findSpecFiles(projectRoot); 1171 for (const specFile of specFiles) { 1172 const docId = relative(projectRoot, specFile); 1173 allClauses.push(...specStore.getClauses(docId)); 1174 } 1175 1176 if (allClauses.length === 0) { 1177 console.log(yellow('⚠ No ingested clauses. Run `phoenix ingest` first.')); 1178 return; 1179 } 1180 1181 const llm = resolveProvider(phoenixDir); 1182 console.log(bold('📐 Canonicalization')); 1183 if (llm) { 1184 console.log(` ${dim(`LLM: ${llm.name}/${llm.model}`)}`); 1185 } 1186 console.log(); 1187 1188 const canonNodes = await extractCanonicalNodesLLM(allClauses, llm); 1189 canonStore.saveNodes(canonNodes); 1190 1191 console.log(` ${green('✔')} ${canonNodes.length} canonical nodes extracted from ${allClauses.length} clauses`); 1192 1193 const byType = new Map<string, number>(); 1194 for (const node of canonNodes) { 1195 byType.set(node.type, (byType.get(node.type) || 0) + 1); 1196 } 1197 for (const [type, count] of byType) { 1198 console.log(` ${dim('·')} ${type}: ${count}`); 1199 } 1200 1201 // Compute warm hashes 1202 const warmHashes = computeWarmHashes(allClauses, canonNodes); 1203 const warmPath = join(phoenixDir, 'graphs', 'warm-hashes.json'); 1204 const warmObj: Record<string, string> = {}; 1205 for (const [k, v] of warmHashes) warmObj[k] = v; 1206 writeFileSync(warmPath, JSON.stringify(warmObj, null, 2), 'utf8'); 1207 1208 console.log(` ${green('✔')} ${warmHashes.size} warm context hashes computed`); 1209} 1210 1211function cmdEvaluate(args: string[]): void { 1212 const { phoenixDir } = requirePhoenixRoot(); 1213 const ius = loadIUs(phoenixDir); 1214 const evidenceStore = new EvidenceStore(phoenixDir); 1215 const allEvidence = evidenceStore.getAll(); 1216 1217 const iuFilter = args.find(a => a.startsWith('--iu='))?.split('=')[1]; 1218 const targetIUs = iuFilter 1219 ? ius.filter(iu => iu.iu_id.startsWith(iuFilter) || iu.name === iuFilter) 1220 : ius; 1221 1222 const evals = evaluateAllPolicies(targetIUs, allEvidence); 1223 1224 console.log(bold('📋 Policy Evaluation')); 1225 console.log(); 1226 1227 for (const eval_ of evals) { 1228 const verdictColor = eval_.verdict === 'PASS' ? green 1229 : eval_.verdict === 'FAIL' ? red 1230 : yellow; 1231 1232 console.log(` ${verdictColor(eval_.verdict)} ${bold(eval_.iu_name)} ${dim(`(${eval_.risk_tier})`)}`); 1233 if (eval_.satisfied.length > 0) { 1234 console.log(` ${green('✔')} ${eval_.satisfied.join(', ')}`); 1235 } 1236 if (eval_.missing.length > 0) { 1237 console.log(` ${yellow('○')} Missing: ${eval_.missing.join(', ')}`); 1238 } 1239 if (eval_.failed.length > 0) { 1240 console.log(` ${red('✖')} Failed: ${eval_.failed.join(', ')}`); 1241 } 1242 console.log(); 1243 } 1244} 1245 1246function cmdCascade(): void { 1247 const { phoenixDir } = requirePhoenixRoot(); 1248 const ius = loadIUs(phoenixDir); 1249 const evidenceStore = new EvidenceStore(phoenixDir); 1250 const allEvidence = evidenceStore.getAll(); 1251 const evals = evaluateAllPolicies(ius, allEvidence); 1252 const cascadeEvents = computeCascade(evals, ius); 1253 1254 console.log(bold('🌊 Cascade Effects')); 1255 console.log(); 1256 1257 if (cascadeEvents.length === 0) { 1258 console.log(` ${green('✔')} No cascading failures.`); 1259 return; 1260 } 1261 1262 for (const event of cascadeEvents) { 1263 console.log(` ${red('✖')} ${bold(event.source_iu_name)} (${event.failure_kind})`); 1264 for (const action of event.actions) { 1265 const icon = action.action === 'BLOCK' ? red('⊘') : yellow('↻'); 1266 console.log(` ${icon} ${action.iu_name}: ${action.action}`); 1267 console.log(` ${dim(action.reason)}`); 1268 } 1269 console.log(); 1270 } 1271} 1272 1273function cmdBot(args: string[]): void { 1274 if (args.length === 0) { 1275 // Show all bot commands 1276 const commands = getAllCommands(); 1277 console.log(bold('🤖 Phoenix Bots')); 1278 console.log(); 1279 for (const [bot, cmds] of Object.entries(commands)) { 1280 console.log(` ${bold(bot)}: ${cmds.join(', ')}`); 1281 } 1282 console.log(); 1283 console.log(dim(' Usage: phoenix bot "BotName: action arg=value"')); 1284 return; 1285 } 1286 1287 const raw = args.join(' '); 1288 const parsed = parseCommand(raw); 1289 1290 if ('error' in parsed) { 1291 console.error(red(`${parsed.error}`)); 1292 process.exit(1); 1293 } 1294 1295 const response = routeCommand(parsed); 1296 1297 console.log(bold(`🤖 ${response.bot}`)); 1298 console.log(); 1299 console.log(` ${response.message}`); 1300 1301 if (response.mutating && response.confirm_id) { 1302 console.log(); 1303 console.log(dim(` Confirmation ID: ${response.confirm_id}`)); 1304 } 1305} 1306 1307function cmdGraph(): void { 1308 const { phoenixDir } = requirePhoenixRoot(); 1309 const canonStore = new CanonicalStore(phoenixDir); 1310 const graph = canonStore.getGraph(); 1311 const ius = loadIUs(phoenixDir); 1312 1313 console.log(bold('🕸️ Provenance Graph')); 1314 console.log(); 1315 1316 // Clause → Canon 1317 const provenanceCount = Object.values(graph.provenance).reduce((sum, arr) => sum + arr.length, 0); 1318 console.log(` ${dim('Provenance edges:')} ${provenanceCount}`); 1319 console.log(` ${dim('Canon → Canon links:')} ${Object.values(graph.nodes).reduce((sum, n) => sum + n.linked_canon_ids.length, 0)}`); 1320 console.log(` ${dim('Canon → IU mappings:')} ${ius.reduce((sum, iu) => sum + iu.source_canon_ids.length, 0)}`); 1321 console.log(); 1322 1323 // Show IU dependency graph 1324 if (ius.length > 0) { 1325 console.log(` ${bold('IU Dependency Graph:')}`); 1326 for (const iu of ius) { 1327 const deps = iu.dependencies.length > 0 1328 ? iu.dependencies.map(d => { 1329 const dep = ius.find(i => i.iu_id === d); 1330 return dep?.name || d.slice(0, 8); 1331 }).join(', ') 1332 : dim('(none)'); 1333 console.log(` ${iu.name}${deps}`); 1334 } 1335 } 1336} 1337 1338async function cmdInspect(args: string[]): Promise<void> { 1339 const { projectRoot, phoenixDir } = requirePhoenixRoot(); 1340 const machine = loadBootstrapState(phoenixDir); 1341 const ius = loadIUs(phoenixDir); 1342 const canonStore = new CanonicalStore(phoenixDir); 1343 const canonNodes = canonStore.getAllNodes(); 1344 const specStore = new SpecStore(phoenixDir); 1345 const manifestManager = new ManifestManager(phoenixDir); 1346 const manifest = manifestManager.load(); 1347 1348 // Collect all clauses 1349 const allClauses: Clause[] = []; 1350 const specFiles = findSpecFiles(projectRoot); 1351 for (const specFile of specFiles) { 1352 const docId = relative(projectRoot, specFile); 1353 allClauses.push(...specStore.getClauses(docId)); 1354 } 1355 1356 // Drift 1357 let driftReport = null; 1358 if (manifest.generated_at) { 1359 driftReport = detectDrift(manifest, projectRoot); 1360 } 1361 1362 const projectName = basename(projectRoot); 1363 const data = collectInspectData( 1364 projectName, 1365 machine.getState(), 1366 allClauses, 1367 canonNodes, 1368 ius, 1369 manifest, 1370 driftReport, 1371 projectRoot, 1372 ); 1373 1374 const html = renderInspectHTML(data); 1375 const dataJson = JSON.stringify(data); 1376 1377 // Parse --port flag 1378 const portArg = args.find(a => a.startsWith('--port='))?.split('=')[1]; 1379 const port = portArg ? parseInt(portArg, 10) : 0; // 0 = random 1380 1381 const instance = serveInspect(html, port, dataJson); 1382 await instance.ready; 1383 1384 console.log(); 1385 console.log(bold('🔥 Phoenix Inspect')); 1386 console.log(); 1387 console.log(` ${cyan(`http://localhost:${instance.port}`)}`); 1388 console.log(); 1389 console.log(` ${dim(`${data.stats.specFiles} specs → ${data.stats.clauses} clauses → ${data.stats.canonNodes} canon → ${data.stats.ius} IUs → ${data.stats.generatedFiles} files`)}`); 1390 console.log(` ${dim(`${data.stats.edgeCount} provenance edges`)}`); 1391 console.log(); 1392 console.log(dim(' Press Ctrl+C to stop.')); 1393 1394 // Keep process alive 1395 await new Promise(() => {}); 1396} 1397 1398// ─── Replacement Audit (Fowler Ch. 4) ──────────────────────────────────────── 1399 1400function cmdAudit(args: string[]): void { 1401 const { phoenixDir } = requirePhoenixRoot(); 1402 const ius = loadIUs(phoenixDir); 1403 const evalStore = new EvaluationStore(phoenixDir); 1404 const nkStore = new NegativeKnowledgeStore(phoenixDir); 1405 1406 if (ius.length === 0) { 1407 console.log(yellow('⚠ No Implementation Units found. Run `phoenix plan` first.')); 1408 return; 1409 } 1410 1411 // Build coverage map 1412 const evalCoverages = new Map<string, any>(); 1413 for (const iu of ius) { 1414 evalCoverages.set(iu.iu_id, evalStore.coverage(iu)); 1415 } 1416 1417 // Load pace layers (from iu metadata or defaults) 1418 const paceLayers = new Map<string, PaceLayerMetadata>(); 1419 // TODO: load from .phoenix/pace-layers.json when populated 1420 1421 const nk = nkStore.getActive(); 1422 const previousMasses = new Map<string, number>(); 1423 // TODO: load from previous manifest cycle 1424 1425 // Filter by --iu if specified 1426 const iuArg = args.find(a => a.startsWith('--iu=')); 1427 const targetIUs = iuArg 1428 ? ius.filter(iu => iu.iu_id === iuArg.slice(5) || iu.name === iuArg.slice(5)) 1429 : ius; 1430 1431 const results = auditAll(targetIUs, evalCoverages, paceLayers, nk, previousMasses); 1432 1433 console.log(); 1434 console.log(bold('🔥 Phoenix Replacement Audit')); 1435 console.log(dim(' "Could I replace this implementation entirely and have its dependents not notice?"')); 1436 console.log(); 1437 1438 // Summary counts 1439 const readinessCounts: Record<ReadinessLevel, number> = { 1440 regenerable: 0, evaluable: 0, observable: 0, opaque: 0, 1441 }; 1442 for (const r of results) readinessCounts[r.readiness]++; 1443 1444 console.log( 1445 ` ${green(`${readinessCounts.regenerable} regenerable`)} ` + 1446 `${blue(`${readinessCounts.evaluable} evaluable`)} ` + 1447 `${yellow(`${readinessCounts.observable} observable`)} ` + 1448 `${red(`${readinessCounts.opaque} opaque`)}` 1449 ); 1450 console.log(); 1451 1452 // Per-IU details 1453 for (const result of results) { 1454 const readinessIcon = readinessToIcon(result.readiness); 1455 const scoreColor = result.score >= 75 ? green : result.score >= 50 ? yellow : red; 1456 1457 console.log(` ${readinessIcon} ${bold(result.iu_name)} ${dim(`(${result.iu_id})`)}${scoreColor(`${result.score}/100`)} ${dim(result.readiness)}`); 1458 1459 // Dimension summary 1460 const dims = [ 1461 result.boundary_clarity, 1462 result.evaluation_coverage, 1463 result.blast_radius, 1464 result.deletion_safety, 1465 result.pace_layer, 1466 result.conceptual_mass, 1467 result.negative_knowledge, 1468 ]; 1469 for (const d of dims) { 1470 const icon = d.status === 'good' ? green('✓') : d.status === 'warning' ? yellow('⚠') : red('✖'); 1471 console.log(` ${icon} ${dim(d.name + ':')} ${d.detail}`); 1472 } 1473 1474 // Blockers 1475 if (result.blockers.length > 0) { 1476 console.log(` ${red('Blockers:')}`); 1477 for (const b of result.blockers) { 1478 const sev = b.severity === 'error' ? red('✖') : yellow('⚠'); 1479 console.log(` ${sev} ${b.message}`); 1480 console.log(` ${dim('→ ' + b.recommended_action)}`); 1481 } 1482 } 1483 1484 // Recommendations 1485 if (result.recommendations.length > 0) { 1486 console.log(` ${cyan('Recommendations:')}`); 1487 for (const r of result.recommendations) { 1488 console.log(` ${dim('→')} ${r}`); 1489 } 1490 } 1491 1492 console.log(); 1493 } 1494 1495 // Overall verdict 1496 const totalScore = results.length > 0 1497 ? Math.round(results.reduce((sum, r) => sum + r.score, 0) / results.length) 1498 : 0; 1499 const totalBlockers = results.reduce((sum, r) => sum + r.blockers.length, 0); 1500 1501 console.log(dim(' ─────────────────────────────────────────')); 1502 console.log(` ${bold('Overall:')} ${totalScore}/100 avg score, ${totalBlockers} blocker(s)`); 1503 console.log(` ${dim('Trust > cleverness.')}`); 1504 console.log(); 1505} 1506 1507function readinessToIcon(readiness: ReadinessLevel): string { 1508 switch (readiness) { 1509 case 'regenerable': return green('●'); 1510 case 'evaluable': return blue('◐'); 1511 case 'observable': return yellow('○'); 1512 case 'opaque': return red('◌'); 1513 } 1514} 1515 1516function cmdVersion(): void { 1517 console.log(`Phoenix VCS v${VERSION}`); 1518} 1519 1520function cmdHelp(): void { 1521 console.log(` 1522${bold('🔥 Phoenix VCS')} — Regenerative Version Control 1523${dim(`v${VERSION}`)} 1524 1525${bold('Usage:')} phoenix <command> [options] 1526 1527${bold('Getting Started:')} 1528 ${cyan('init')} Initialize a new Phoenix project 1529 ${cyan('bootstrap')} Full bootstrap: ingest → canonicalize → plan → generate 1530 1531${bold('Spec Management:')} 1532 ${cyan('ingest')} [files...] Ingest spec documents (default: all in spec/) 1533 ${cyan('diff')} [files...] Show clause diffs vs stored state 1534 ${cyan('clauses')} [files...] List stored clauses 1535 1536${bold('Canonical Graph:')} 1537 ${cyan('canonicalize')} Extract canonical nodes from ingested clauses 1538 ${cyan('canon')} Show the canonical graph 1539 1540${bold('Implementation:')} 1541 ${cyan('plan')} Plan Implementation Units from canonical graph 1542 ${cyan('regen')} [--iu=<id>] Regenerate code (all or specific IU) 1543 ${dim('Uses LLM if ANTHROPIC_API_KEY or OPENAI_API_KEY is set')} 1544 ${dim('--stubs Force stub generation (skip LLM)')} 1545 1546${bold('Verification:')} 1547 ${cyan('status')} Trust dashboard — the primary UX 1548 ${cyan('drift')} Check generated files for drift 1549 ${cyan('evaluate')} [--iu=<id>] Evaluate evidence against policy 1550 ${cyan('cascade')} Show cascade failure effects 1551 ${cyan('audit')} [--iu=<id>] Replacement audit — readiness per IU 1552 1553${bold('Inspection:')} 1554 ${cyan('inspect')} [--port=N] Interactive pipeline visualisation (opens browser) 1555 ${cyan('graph')} Show provenance graph summary 1556 ${cyan('bot')} "<command>" Route a bot command (e.g., "SpecBot: help") 1557 1558${bold('Meta:')} 1559 ${cyan('version')} Show version 1560 ${cyan('help')} Show this help 1561 1562${dim('Trust > cleverness.')} 1563`); 1564} 1565 1566// ─── Main ──────────────────────────────────────────────────────────────────── 1567 1568async function main(): Promise<void> { 1569 const args = process.argv.slice(2); 1570 const command = args[0]; 1571 const commandArgs = args.slice(1); 1572 1573 switch (command) { 1574 case 'init': 1575 cmdInit(commandArgs); 1576 break; 1577 case 'bootstrap': 1578 await cmdBootstrap(); 1579 break; 1580 case 'status': 1581 cmdStatus(); 1582 break; 1583 case 'ingest': 1584 cmdIngest(commandArgs); 1585 break; 1586 case 'diff': 1587 cmdDiff(commandArgs); 1588 break; 1589 case 'clauses': 1590 cmdClauses(commandArgs); 1591 break; 1592 case 'canonicalize': 1593 case 'canon-extract': 1594 await cmdCanonicalize(); 1595 break; 1596 case 'canon': 1597 cmdCanon(); 1598 break; 1599 case 'plan': 1600 cmdPlan(); 1601 break; 1602 case 'regen': 1603 case 'regenerate': 1604 await cmdRegen(commandArgs); 1605 break; 1606 case 'drift': 1607 cmdDrift(); 1608 break; 1609 case 'evaluate': 1610 case 'eval': 1611 cmdEvaluate(commandArgs); 1612 break; 1613 case 'cascade': 1614 cmdCascade(); 1615 break; 1616 case 'audit': 1617 cmdAudit(commandArgs); 1618 break; 1619 case 'inspect': 1620 await cmdInspect(commandArgs); 1621 break; 1622 case 'graph': 1623 cmdGraph(); 1624 break; 1625 case 'bot': 1626 cmdBot(commandArgs); 1627 break; 1628 case 'version': 1629 case '--version': 1630 case '-v': 1631 cmdVersion(); 1632 break; 1633 case 'help': 1634 case '--help': 1635 case '-h': 1636 case undefined: 1637 cmdHelp(); 1638 break; 1639 default: 1640 console.error(red(`✖ Unknown command: ${command}`)); 1641 console.error(dim(' Run `phoenix help` for available commands.')); 1642 process.exit(1); 1643 } 1644} 1645 1646main().catch(err => { 1647 console.error(red(`${err.message || err}`)); 1648 process.exit(1); 1649});