Reference implementation for the Phoenix Architecture. Work in progress.
aicoding.leaflet.pub/
ai
coding
crazy
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});