Files
Socrates/phase-0/src/cli.ts
dtoro f1c4566576 Initial commit — design docs + Phase 0 validation harness
Sets up the Socrata project repo with:

docs/ — strategy and design documents
  - idea.md: full product vision
  - implementation-plan.md: Phase 0 + Phase 1 MVP plan
  - phase-0-validation.md: 2-week validation experiment strategy
  - phase-0-plan.md: concrete Phase 0 build plan
  - phase-0-results.md: Phase 0 gate outcome — GO for MVP
  - sysml-modeling.md: metamodel + SE discipline + validation rules
  - socrates.md: agent character, surfaces, modes, prompts, lifecycle
  - sync.md: bidirectional text↔diagram sync engineering
  - design-source/: HTML/CSS/JS handoff bundle from Claude Design

phase-0/ — validated harness (CLI, no UI, no DB)
  - LM Studio (local OpenAI-compatible) generation + detection + judge
  - PlantUML rendering for SysML model visualization
  - 10-seed corpus (8 working + 2 holdouts)
  - 5 corpus runs with iteration history in reports/
  - Final gate: 10/10 pass, mean 4.32/5, holdouts validated

Phase 1 MVP scope and milestones documented in implementation-plan.md.
2026-04-28 22:07:38 +02:00

342 lines
14 KiB
JavaScript

#!/usr/bin/env node
// Phase 0 CLI entrypoint.
import { Command } from 'commander';
import kleur from 'kleur';
import { describeClient, modelName } from './llm/client.js';
import { chat } from './llm/messages.js';
import { toPlantUML } from './render/to-plantuml.js';
import { renderPNG, writePlantUMLSource, plantUMLViewerURL } from './render/render.js';
import { aristotleReferenceModel } from './fixtures/aristotle.js';
import { loadSeed } from './seed/load.js';
import { generateModel } from './generate/generate.js';
import { detectFindings } from './socrates/detect.js';
import { runConversation } from './socrates/converse.js';
import { proposeChange, applyProposal, summarizeOp } from './socrates/propose.js';
import { runCorpus } from './eval/corpus.js';
import { judgeSeed, averageScore, minDimension } from './eval/judge.js';
import * as readline from 'node:readline/promises';
import { stdin as stdInput, stdout as stdOutput } from 'node:process';
import { resolve } from 'node:path';
import { writeFile, mkdir } from 'node:fs/promises';
const program = new Command();
program
.name('phase0')
.description('Socrata Phase 0 validation harness')
.version('0.0.1');
program
.command('hello')
.description('Smoke test: round-trip a chat to LM Studio')
.action(async () => {
console.log(kleur.dim(`> ${describeClient()}`));
const start = Date.now();
try {
const result = await chat([
{ role: 'system', content: 'You are a terse assistant. Reply in one sentence.' },
{ role: 'user', content: 'Are you there? Identify yourself briefly.' },
], { temperature: 0.2, maxTokens: 256 });
const elapsed = Date.now() - start;
console.log(kleur.green('✓ round-trip OK'));
console.log(kleur.dim(` ${elapsed}ms · ${result.inputTokens} in / ${result.outputTokens} out`));
console.log();
console.log(kleur.bold('response:'));
console.log(result.text);
} catch (err) {
console.error(kleur.red('✗ round-trip FAILED'));
console.error(kleur.red(` ${(err as Error).message}`));
console.error(kleur.dim('\nIs LM Studio running? Is a model loaded?'));
process.exit(1);
}
});
program
.command('render-test')
.description('Render the hand-written Aristotle reference model to PlantUML PNG')
.option('-o, --out <dir>', 'output directory', './outputs/_render-test')
.action(async (opts: { out: string }) => {
const outDir = resolve(opts.out);
const pumlPath = `${outDir}/aristotle.puml`;
const pngPath = `${outDir}/aristotle.png`;
const source = toPlantUML(aristotleReferenceModel);
await writePlantUMLSource(source, pumlPath);
console.log(kleur.green('✓ wrote PlantUML source'));
console.log(kleur.dim(` ${pumlPath}`));
try {
await renderPNG(source, pngPath);
console.log(kleur.green('✓ rendered PNG'));
console.log(kleur.dim(` ${pngPath}`));
console.log();
console.log(kleur.bold('view online:'));
console.log(plantUMLViewerURL(source));
} catch (err) {
console.error(kleur.yellow('⚠ PNG render failed (PlantUML source still saved):'));
console.error(kleur.dim(` ${(err as Error).message}`));
console.log();
console.log(kleur.bold('view online:'));
console.log(plantUMLViewerURL(source));
}
});
program
.command('run <seedId>')
.description('Run a seed through generation + render')
.option('-o, --out <dir>', 'output directory (default: outputs/<seedId>)')
.option('--converse', 'after detection, open an interactive Socrates conversation')
.option('--propose', 'after detection, ask Socrates to propose a model change (accept/reject)')
.action(async (seedId: string, opts: { out?: string; converse?: boolean; propose?: boolean }) => {
const outDir = resolve(opts.out ?? `./outputs/${seedId}`);
console.log(kleur.dim(`> seed: ${seedId}`));
console.log(kleur.dim(`> ${describeClient()}`));
console.log();
let seed;
try {
seed = await loadSeed(seedId);
} catch (err) {
console.error(kleur.red(`✗ couldn't load seed: ${(err as Error).message}`));
process.exit(1);
}
console.log(kleur.bold(`seed: ${seed.title}`));
console.log(kleur.dim(` problem: ${truncate(seed.problem, 120)}`));
console.log();
// Generate
console.log(kleur.cyan('generating model…'));
const gen = await generateModel(seed);
console.log(kleur.green('✓ model generated'));
console.log(
kleur.dim(
` ${gen.durationMs}ms · ${gen.inputTokens} in / ${gen.outputTokens} out · ` +
`${gen.model.blocks.length} blocks · ${gen.model.associations.length} associations · ` +
`${gen.model.constraints.length} constraints · ${gen.model.requirements.length} requirements · ` +
`confidence ${gen.model.overallConfidence.toFixed(2)}`
)
);
if (gen.warnings.length > 0) {
console.log(kleur.yellow(' lint warnings:'));
for (const w of gen.warnings) console.log(kleur.yellow(` · ${w}`));
}
console.log();
// Persist model + render in parallel with detection
await mkdir(outDir, { recursive: true });
await writeFile(`${outDir}/model.json`, JSON.stringify(gen.model, null, 2));
const source = toPlantUML(gen.model);
await writePlantUMLSource(source, `${outDir}/diagram.puml`);
// Detection (3 prompts in parallel)
console.log(kleur.cyan('detecting findings…'));
const det = await detectFindings(gen.model, seed);
const counts = countFindings(det.findings);
console.log(kleur.green('✓ findings detected'));
console.log(
kleur.dim(
` ${det.durationMs}ms · ${det.inputTokens} in / ${det.outputTokens} out · ` +
`${counts.assumption} assumptions · ${counts.risk} risks · ${counts.inconsistency} inconsistencies`
)
);
if (det.strippedRefs > 0 || det.droppedFindings > 0) {
console.log(
kleur.yellow(
` · post-validate: ${det.strippedRefs} hallucinated refs stripped, ${det.droppedFindings} findings dropped`
)
);
}
await writeFile(`${outDir}/findings.json`, JSON.stringify(det.findings, null, 2));
if (det.findings.length > 0) {
console.log();
printTopFindings(det.findings);
}
console.log();
try {
await renderPNG(source, `${outDir}/diagram.png`);
console.log(kleur.green('✓ rendered'));
} catch (err) {
console.log(kleur.yellow(`⚠ PNG render failed: ${(err as Error).message}`));
}
console.log(kleur.dim(` ${outDir}/`));
console.log();
console.log(kleur.bold('view diagram online:'));
console.log(plantUMLViewerURL(source));
// Optional: Socrates proposes a model change (accept/reject)
if (opts.propose) {
console.log();
console.log(kleur.dim('─'.repeat(60)));
console.log(kleur.bold('Proposal'));
console.log(kleur.dim('─'.repeat(60)));
const proposal = await proposeChange(seed, gen.model, det.findings);
console.log(kleur.bold('Σ Socrates proposes:'));
console.log(` ${proposal.reasoning}`);
console.log();
if (proposal.ops.length === 0) {
console.log(kleur.dim(' (no ops — nothing to apply)'));
} else {
console.log(kleur.bold('Ops:'));
for (const op of proposal.ops) console.log(` ${summarizeOp(op)}`);
console.log();
const rl = readline.createInterface({ input: stdInput, output: stdOutput });
let answer = '';
try {
answer = ((await rl.question(kleur.cyan('apply? [y/N] '))) ?? '').trim().toLowerCase();
} finally {
rl.close();
}
if (answer === 'y' || answer === 'yes') {
const applied = applyProposal(gen.model, proposal.ops);
console.log(kleur.green(`✓ applied ${applied.applied.length} op(s)`));
if (applied.skipped.length > 0) {
console.log(kleur.yellow(` ⚠ skipped ${applied.skipped.length}:`));
for (const s of applied.skipped) console.log(kleur.yellow(` - ${summarizeOp(s.op)}${s.reason}`));
}
await writeFile(`${outDir}/model.json`, JSON.stringify(applied.model, null, 2));
const newSource = toPlantUML(applied.model);
await writePlantUMLSource(newSource, `${outDir}/diagram.puml`);
try {
await renderPNG(newSource, `${outDir}/diagram.png`);
console.log(kleur.green('✓ re-rendered'));
console.log(kleur.dim(` ${outDir}/diagram.png`));
console.log(kleur.bold('view diagram online:'));
console.log(plantUMLViewerURL(newSource));
} catch (err) {
console.log(kleur.yellow(`⚠ PNG render failed: ${(err as Error).message}`));
}
} else {
console.log(kleur.dim(' (proposal rejected)'));
}
}
}
// Optional: open an interactive Socrates conversation
if (opts.converse) {
console.log();
console.log(kleur.dim('─'.repeat(60)));
console.log(kleur.bold('Conversation with Socrates'));
console.log(kleur.dim('Type :done or Ctrl+D to end · max 5 turns'));
console.log(kleur.dim('─'.repeat(60)));
const conv = await runConversation(seed, gen.model, det.findings, {
outPath: `${outDir}/conversation.md`,
});
console.log();
console.log(kleur.green('✓ conversation saved'));
console.log(
kleur.dim(
` ${conv.turns.length} turns · ${conv.durationMs}ms · ${conv.inputTokens} in / ${conv.outputTokens} out`
)
);
console.log(kleur.dim(` ${outDir}/conversation.md`));
}
});
function countFindings(findings: { kind: string }[]): { assumption: number; risk: number; inconsistency: number } {
const c = { assumption: 0, risk: 0, inconsistency: 0 };
for (const f of findings) {
if (f.kind in c) (c as Record<string, number>)[f.kind]++;
}
return c;
}
function printTopFindings(findings: import('./types.js').Finding[]): void {
const byKind = (k: string) => findings.filter(f => f.kind === k).slice(0, 3);
const sections: Array<[string, string, ReturnType<typeof byKind>]> = [
['assumptions', kleur.cyan('●'), byKind('assumption')],
['risks', kleur.red('▲'), byKind('risk')],
['inconsistencies', kleur.yellow('!'), byKind('inconsistency')],
];
for (const [label, glyph, items] of sections) {
if (items.length === 0) continue;
console.log(kleur.bold(` ${label}:`));
for (const f of items) {
const links = f.linkedElementIds.length > 0 ? kleur.dim(` [${f.linkedElementIds.join(', ')}]`) : '';
const conf = kleur.dim(` (${f.confidence.toFixed(2)})`);
console.log(` ${glyph} ${f.text}${links}${conf}`);
}
}
}
function truncate(s: string, n: number): string {
return s.length > n ? s.slice(0, n - 1) + '…' : s;
}
program
.command('corpus')
.description('Run all non-holdout seeds through generation + detection; write a report')
.option('--final', 'include holdout seeds (final-round only; requires PHASE0_FINAL_ROUND=1)')
.option('--skip-png', 'skip PNG rendering (faster — just .puml + JSON)')
.option('--score', 'after corpus runs, score every seed with the LLM judge')
.option('-o, --out <dir>', 'output root (default: ./outputs)')
.option('-r, --report <file>', 'report path (default: ./reports/<timestamp>-run.md)')
.action(async (opts: { final?: boolean; skipPng?: boolean; score?: boolean; out?: string; report?: string }) => {
await runCorpus({
includeHoldouts: !!opts.final,
outRoot: opts.out ? resolve(opts.out) : undefined,
reportPath: opts.report ? resolve(opts.report) : undefined,
skipPNG: !!opts.skipPng,
score: !!opts.score,
});
});
program
.command('score <seedId>')
.description('Score one seed\'s outputs with the LLM judge (writes outputs/<seedId>/score.json)')
.action(async (seedId: string) => {
console.log(kleur.dim(`> scoring ${seedId} with ${describeClient()}`));
const judgeResult = await judgeSeed(seedId);
const s = judgeResult.score;
const avg = averageScore(s);
const min = minDimension(s);
console.log(kleur.green('✓ scored'));
console.log(
kleur.dim(
` ${judgeResult.durationMs}ms · ${judgeResult.inputTokens} in / ${judgeResult.outputTokens} out · ` +
`avg ${avg.toFixed(2)} · min ${min}`
)
);
console.log();
console.log(kleur.bold('Scores:'));
const dims: Array<[keyof typeof judgeResult.reasons, string]> = [
['modelCoverage', 'model.coverage '],
['modelAccuracy', 'model.accuracy '],
['modelParsimony', 'model.parsimony '],
['constraintCapture', 'constraint.capture'],
['assumptionDetectionQuality', 'asm.detection '],
['riskDetectionQuality', 'risk.detection '],
['voiceAndCharacter', 'voice.character '],
];
for (const [key, label] of dims) {
const v = (s as unknown as Record<string, number>)[key]!;
const bar = '█'.repeat(v) + '░'.repeat(5 - v);
const color = v >= 4 ? kleur.green : v >= 3 ? kleur.yellow : kleur.red;
console.log(` ${label} ${color(bar)} ${v} ${kleur.dim('— ' + judgeResult.reasons[key])}`);
}
if (judgeResult.overallComment) {
console.log();
console.log(kleur.bold('Comment:'), judgeResult.overallComment);
}
});
program
.command('config')
.description('Print resolved configuration')
.action(() => {
console.log(kleur.bold('LM Studio'));
console.log(` baseURL: ${process.env.LMSTUDIO_BASE_URL ?? 'http://localhost:1234/v1'}`);
console.log(` model: ${modelName}`);
console.log(` apiKey: ${process.env.LMSTUDIO_API_KEY ? kleur.green('set') : kleur.yellow('default (lm-studio)')}`);
console.log();
console.log(kleur.bold('PlantUML'));
console.log(` server: ${process.env.PLANTUML_SERVER ?? 'https://www.plantuml.com/plantuml'}`);
});
program.parseAsync(process.argv).catch(err => {
console.error(kleur.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
});