#!/usr/bin/env node /** * Panopticon — Automated Project Documentation Registry * * Usage: * node dist/index.js # Incremental update (all projects) * node dist/index.js --full-analysis # Full analysis for one project * node dist/index.js --project # Incremental update for one project */ import { resolve } from "path"; import { loadConfig } from "./config.js"; import { getCurrentSha, pull, getFileTree, getDiffSince } from "./git.js"; import { gatherStructuralContext, hashFileAtPath } from "./structural.js"; import { loadState, saveState } from "./state.js"; import { readAllSkillFiles, readSkillFile, writeSkillFiles, countLinesChanged, cleanSkillDir } from "./writer.js"; import { runIncrementalOrchestrator, runFullOrchestrator } from "./orchestrator.js"; import { runIncrementalWorker, runFullAnalysisWorker, runWorkersConcurrently } from "./worker.js"; import { runSynthesizer } from "./synthesizer.js"; import { pushMetrics, buildProjectMetrics } from "./metrics.js"; import { writeProjectReport, writeNightlyReport, detectAnomalies } from "./reporter.js"; import type { Config, ProjectConfig, ProjectRunReport, NightlyReport, OrchestratorResult, WorkerResult, PhaseTimings, SessionMetrics, } from "./types.js"; import { join } from "path"; // ── CLI Parsing ── interface CliArgs { fullAnalysis: string | null; project: string | null; configPath: string | null; dryRun: boolean; } function parseArgs(): CliArgs { const args = process.argv.slice(2); const result: CliArgs = { fullAnalysis: null, project: null, configPath: null, dryRun: false, }; for (let i = 0; i < args.length; i++) { switch (args[i]) { case "--full-analysis": result.fullAnalysis = args[++i] ?? null; break; case "--project": result.project = args[++i] ?? null; break; case "--config": result.configPath = args[++i] ?? null; break; case "--dry-run": result.dryRun = true; break; } } return result; } // ── Main Pipeline ── async function main() { const cliArgs = parseArgs(); const config = loadConfig(cliArgs.configPath ?? undefined); const date = new Date().toISOString().split("T")[0]; console.log(`Panopticon run: ${date}`); console.log(`Projects configured: ${config.projects.map((p) => p.name).join(", ")}`); if (cliArgs.fullAnalysis) { // Full analysis mode const project = config.projects.find((p) => p.name === cliArgs.fullAnalysis); if (!project) { console.error(`Project not found: ${cliArgs.fullAnalysis}`); console.error(`Available: ${config.projects.map((p) => p.name).join(", ")}`); process.exit(1); } console.log(`\nRunning full analysis for: ${project.name}`); const report = await runFullAnalysis(project, config, cliArgs.dryRun); writeProjectReport(resolve(config.runsDir), date, report); if (config.metrics.enabled) { const metrics = buildProjectMetrics(report, config.metrics.jobLabel); await pushMetrics(config.metrics, metrics); } console.log(`\nFull analysis complete. Status: ${report.status}`); return; } // Incremental mode (nightly) const projectsToRun = cliArgs.project ? config.projects.filter((p) => p.name === cliArgs.project) : config.projects; if (projectsToRun.length === 0) { console.error(`No matching projects found`); process.exit(1); } const nightlyStart = Date.now(); const projectReports: ProjectRunReport[] = []; for (const project of projectsToRun) { console.log(`\nProcessing: ${project.name}`); const report = await runIncremental(project, config, cliArgs.dryRun); projectReports.push(report); console.log(` Status: ${report.status} (${(report.duration / 1000).toFixed(1)}s)`); } // Nightly report const anomalies = detectAnomalies(projectReports); const nightlyReport: NightlyReport = { date, projectReports, totalDuration: Date.now() - nightlyStart, totalCost: projectReports.reduce((sum, r) => sum + r.estimatedCost, 0), anomalies, }; writeNightlyReport(resolve(config.runsDir), date, nightlyReport); // Push metrics for all projects if (config.metrics.enabled) { for (const report of projectReports) { const metrics = buildProjectMetrics(report, config.metrics.jobLabel); await pushMetrics(config.metrics, metrics); } } console.log(`\nNightly run complete.`); console.log(` Duration: ${(nightlyReport.totalDuration / 1000).toFixed(1)}s`); console.log(` Cost: ~$${nightlyReport.totalCost.toFixed(3)}`); if (anomalies.length > 0) { console.log(` Anomalies: ${anomalies.length}`); for (const a of anomalies) console.log(` - ${a}`); } } // ── Full Analysis Pipeline ── async function runFullAnalysis( project: ProjectConfig, config: Config, dryRun: boolean ): Promise { const runStart = Date.now(); const timings: PhaseTimings = { git: 0, structural: 0, orchestrator: 0, workers: 0, synthesizer: 0 }; const errors: string[] = []; const stateDir = resolve(config.stateDir); // Phase: Git let gitStart = Date.now(); let currentSha: string; try { currentSha = getCurrentSha(project.path); } catch (err: any) { errors.push(`Git error: ${err.message}`); return makeFailedReport(project.name, 0, errors, timings, Date.now() - runStart); } timings.git = Date.now() - gitStart; // Phase: Structural extraction let structStart = Date.now(); const structural = gatherStructuralContext(project.path, project); timings.structural = Date.now() - structStart; console.log(` Files: ${structural.fileTree.length}, AST entries: ${structural.astSummaries.length}`); // Phase: Orchestrator let orchStart = Date.now(); const { result: orchResult, metrics: orchMetrics } = await runFullOrchestrator( project.path, config, structural ); timings.orchestrator = Date.now() - orchStart; errors.push(...orchMetrics.errors); console.log(` Orchestrator: ${orchResult.updates.length} work units planned`); // Phase: Workers let workerStart = Date.now(); const workerTasks = orchResult.updates.map((workUnit) => { return () => runFullAnalysisWorker( project.path, config, workUnit.target as "structure.md" | "guide.md" | "changelog.md", structural, workUnit ); }); const workerResults = await runWorkersConcurrently(workerTasks, config.limits.maxWorkerConcurrency); timings.workers = Date.now() - workerStart; for (const w of workerResults) { console.log(` Worker ${w.target}: ${w.status}${w.error ? ` (${w.error})` : ""}`); if (w.metrics.errors.length > 0) errors.push(...w.metrics.errors); } // Phase: Synthesizer let synthStart = Date.now(); const currentSkillMd = readSkillFile(project.path, "SKILL.md"); const { result: synthResult, metrics: synthMetrics } = await runSynthesizer( project.path, config, project.name, workerResults, currentSkillMd ); timings.synthesizer = Date.now() - synthStart; errors.push(...synthMetrics.errors); // Write outputs if (!dryRun) { const existingDocs = readAllSkillFiles(project.path); // Wipe all generated .md files (except SKILL.md) before writing fresh ones. // This removes stale documents from previous runs (e.g. after file renames). const removed = cleanSkillDir(project.path); if (removed.length > 0) { console.log(` Cleaned stale docs: ${removed.join(", ")}`); } const filesToWrite: Record = {}; filesToWrite["SKILL.md"] = synthResult.skillMd; for (const w of workerResults) { if (w.status === "success") { filesToWrite[w.target] = w.content; } } // Apply synthesizer fixes for (const fix of synthResult.fixes) { if (filesToWrite[fix.file] && fix.before && fix.after) { filesToWrite[fix.file] = filesToWrite[fix.file].replace(fix.before, fix.after); } } writeSkillFiles(project.path, filesToWrite); // Update state const fileHashes: Record = {}; for (const file of structural.fileTree) { try { fileHashes[file] = hashFileAtPath(join(project.path, file)); } catch { /* skip */ } } saveState(stateDir, project.name, { lastSha: currentSha, lastRunTimestamp: new Date().toISOString(), lastRunStatus: errors.length === 0 ? "success" : "partial", fileHashes, docVersions: Object.fromEntries( Object.entries(filesToWrite).map(([k, v]) => [k, String(v.length)]) ), }); // Doc line changes const docLinesChanged: Record = {}; for (const [file, content] of Object.entries(filesToWrite)) { docLinesChanged[file] = countLinesChanged(existingDocs[file] ?? null, content); } const totalTokensIn = orchMetrics.tokensIn + synthMetrics.tokensIn + workerResults.reduce((s, w) => s + w.metrics.tokensIn, 0); const totalTokensOut = orchMetrics.tokensOut + synthMetrics.tokensOut + workerResults.reduce((s, w) => s + w.metrics.tokensOut, 0); return { project: project.name, date: new Date().toISOString(), status: errors.length === 0 ? "success" : "partial", duration: Date.now() - runStart, commitCount: 0, filesChanged: structural.fileTree.length, insertions: 0, deletions: 0, orchestratorDecision: orchResult, workerResults, synthesizerStatus: synthMetrics.errors.length === 0 ? "success" : "failure", docLinesChanged, phaseTimings: timings, totalTokensIn, totalTokensOut, estimatedCost: estimateCost(totalTokensIn, totalTokensOut), errors, }; } // Dry run return makeFailedReport(project.name, 0, ["dry-run"], timings, Date.now() - runStart); } // ── Incremental Pipeline ── async function runIncremental( project: ProjectConfig, config: Config, dryRun: boolean ): Promise { const runStart = Date.now(); const timings: PhaseTimings = { git: 0, structural: 0, orchestrator: 0, workers: 0, synthesizer: 0 }; const errors: string[] = []; const stateDir = resolve(config.stateDir); // Load state const state = loadState(stateDir, project.name); // Phase: Git let gitStart = Date.now(); let currentSha: string; try { // Try to pull (non-fatal if fails for local repos) try { pull(project.path, project.branch); } catch { /* local repo, no remote */ } currentSha = getCurrentSha(project.path); } catch (err: any) { errors.push(`Git error: ${err.message}`); timings.git = Date.now() - gitStart; return makeFailedReport(project.name, 0, errors, timings, Date.now() - runStart); } timings.git = Date.now() - gitStart; // Check if there are changes if (state.lastSha && state.lastSha === currentSha) { console.log(` No changes since last run (${currentSha.slice(0, 8)})`); return makeSkippedReport(project.name, timings, Date.now() - runStart); } // If no previous state, recommend full analysis if (!state.lastSha) { console.log(` No previous state — running full analysis instead`); return runFullAnalysis(project, config, dryRun); } // Get diff let diff; try { diff = getDiffSince(project.path, state.lastSha, project); } catch (err: any) { errors.push(`Diff error: ${err.message}`); // Fall back to full analysis console.log(` Diff failed, falling back to full analysis`); return runFullAnalysis(project, config, dryRun); } if (diff.filesChanged.length === 0) { console.log(` No file changes detected`); return makeSkippedReport(project.name, timings, Date.now() - runStart); } console.log(` Changes: ${diff.commitCount} commits, ${diff.filesChanged.length} files (+${diff.insertions}/-${diff.deletions})`); // Phase: Structural (lightweight for incremental) let structStart = Date.now(); const fileTree = getFileTree(project.path, project); timings.structural = Date.now() - structStart; // Phase: Orchestrator let orchStart = Date.now(); const existingDocs = readAllSkillFiles(project.path); const { result: orchResult, metrics: orchMetrics } = await runIncrementalOrchestrator( project.path, config, diff, existingDocs, fileTree ); timings.orchestrator = Date.now() - orchStart; errors.push(...orchMetrics.errors); if (orchResult.skipReason) { console.log(` Orchestrator: skip (${orchResult.skipReason})`); // Still update state if (!dryRun) { saveState(stateDir, project.name, { ...state, lastSha: currentSha, lastRunTimestamp: new Date().toISOString(), lastRunStatus: "success", }); } return makeSkippedReport(project.name, timings, Date.now() - runStart); } console.log(` Orchestrator: ${orchResult.updates.map((u) => u.target).join(", ")}`); // Phase: Workers let workerStart = Date.now(); const workerTasks = orchResult.updates.map((workUnit) => { return () => runIncrementalWorker( project.path, config, workUnit, existingDocs[workUnit.target] ?? null, diff.diffContent ); }); const workerResults = await runWorkersConcurrently(workerTasks, config.limits.maxWorkerConcurrency); timings.workers = Date.now() - workerStart; for (const w of workerResults) { console.log(` Worker ${w.target}: ${w.status}${w.error ? ` (${w.error})` : ""}`); if (w.metrics.errors.length > 0) errors.push(...w.metrics.errors); } // Phase: Synthesizer let synthStart = Date.now(); const currentSkillMd = readSkillFile(project.path, "SKILL.md"); const successfulWorkers = workerResults.filter((w) => w.status === "success"); let synthResult; let synthMetrics: SessionMetrics = { tokensIn: 0, tokensOut: 0, toolCalls: 0, errors: [], durationMs: 0 }; if (successfulWorkers.length > 0) { const synthResponse = await runSynthesizer( project.path, config, project.name, workerResults, currentSkillMd ); synthResult = synthResponse.result; synthMetrics = synthResponse.metrics; errors.push(...synthMetrics.errors); } timings.synthesizer = Date.now() - synthStart; // Write outputs if (!dryRun && successfulWorkers.length > 0) { const filesToWrite: Record = {}; if (synthResult) { filesToWrite["SKILL.md"] = synthResult.skillMd; // Apply fixes for (const w of workerResults) { if (w.status === "success") { filesToWrite[w.target] = w.content; } } for (const fix of synthResult.fixes) { if (filesToWrite[fix.file] && fix.before && fix.after) { filesToWrite[fix.file] = filesToWrite[fix.file].replace(fix.before, fix.after); } } } else { // No synthesizer — write worker outputs directly for (const w of workerResults) { if (w.status === "success") { filesToWrite[w.target] = w.content; } } } writeSkillFiles(project.path, filesToWrite); // Update state saveState(stateDir, project.name, { ...state, lastSha: currentSha, lastRunTimestamp: new Date().toISOString(), lastRunStatus: errors.length === 0 ? "success" : "partial", }); // Doc line changes const docLinesChanged: Record = {}; for (const [file, content] of Object.entries(filesToWrite)) { docLinesChanged[file] = countLinesChanged(existingDocs[file] ?? null, content); } const totalTokensIn = orchMetrics.tokensIn + synthMetrics.tokensIn + workerResults.reduce((s, w) => s + w.metrics.tokensIn, 0); const totalTokensOut = orchMetrics.tokensOut + synthMetrics.tokensOut + workerResults.reduce((s, w) => s + w.metrics.tokensOut, 0); return { project: project.name, date: new Date().toISOString(), status: workerResults.some((w) => w.status === "failure") ? "partial" : "success", duration: Date.now() - runStart, commitCount: diff.commitCount, filesChanged: diff.filesChanged.length, insertions: diff.insertions, deletions: diff.deletions, orchestratorDecision: orchResult, workerResults, synthesizerStatus: synthMetrics.errors.length === 0 ? "success" : "failure", docLinesChanged, phaseTimings: timings, totalTokensIn, totalTokensOut, estimatedCost: estimateCost(totalTokensIn, totalTokensOut), errors, }; } return makeFailedReport(project.name, diff.commitCount, errors, timings, Date.now() - runStart); } // ── Helpers ── function estimateCost(tokensIn: number, tokensOut: number): number { // Rough estimate based on Anthropic pricing // Sonnet: $3/Minput, $15/Moutput; Haiku: $0.80/Minput, $4/Moutput // Average it out roughly return (tokensIn * 2.0 + tokensOut * 10.0) / 1_000_000; } function makeSkippedReport(project: string, timings: PhaseTimings, duration: number): ProjectRunReport { return { project, date: new Date().toISOString(), status: "skipped", duration, commitCount: 0, filesChanged: 0, insertions: 0, deletions: 0, orchestratorDecision: null, workerResults: [], synthesizerStatus: "skipped", docLinesChanged: {}, phaseTimings: timings, totalTokensIn: 0, totalTokensOut: 0, estimatedCost: 0, errors: [], }; } function makeFailedReport( project: string, commitCount: number, errors: string[], timings: PhaseTimings, duration: number ): ProjectRunReport { return { project, date: new Date().toISOString(), status: "failure", duration, commitCount, filesChanged: 0, insertions: 0, deletions: 0, orchestratorDecision: null, workerResults: [], synthesizerStatus: "skipped", docLinesChanged: {}, phaseTimings: timings, totalTokensIn: 0, totalTokensOut: 0, estimatedCost: 0, errors, }; } // ── Entry Point ── main().catch((err) => { console.error("Fatal error:", err); process.exit(1); });