Files
panopticon/src/index.ts
2026-04-06 15:09:41 +02:00

585 lines
18 KiB
JavaScript

#!/usr/bin/env node
/**
* Panopticon — Automated Project Documentation Registry
*
* Usage:
* node dist/index.js # Incremental update (all projects)
* node dist/index.js --full-analysis <name> # Full analysis for one project
* node dist/index.js --project <name> # 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<ProjectRunReport> {
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<string, string> = {};
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<string, string> = {};
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<string, number> = {};
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<ProjectRunReport> {
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<string, string> = {};
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<string, number> = {};
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);
});