panopticon init

This commit is contained in:
2026-04-06 15:09:41 +02:00
commit 8391eb0f70
27 changed files with 6632 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
state/
runs/
*.log

45
CLAUDE.md Normal file
View File

@@ -0,0 +1,45 @@
# Panopticon — Automated Project Documentation Registry
## Overview
Panopticon is a nightly batch system that maintains up-to-date, LLM-optimized project documentation as pi skill files. It analyzes git diffs since the last run, dispatches parallel LLM workers to update documentation sections, synthesizes the results, and writes pi-compatible skill directories into each project's `.pi/skills/panopticon/` folder.
## Architecture
- **CLI entry point:** `src/index.ts` — handles `--full-analysis <name>`, `--project <name>`, `--dry-run`
- **Config:** `src/config.ts` — loads `config.json` with project registry, model config, limits
- **Git:** `src/git.ts` — git operations (pull, diff, log, file tree, churn)
- **Structural:** `src/structural.ts` — regex-based AST extraction, import graph, file hashing
- **Session:** `src/session.ts` — pi SDK session factory for orchestrator/worker/synthesizer roles
- **Orchestrator:** `src/orchestrator.ts` — analyzes diffs, plans work units
- **Worker:** `src/worker.ts` — generates/updates doc sections with read tool access
- **Synthesizer:** `src/synthesizer.ts` — reconciles worker outputs, generates SKILL.md
- **Writer:** `src/writer.ts` — writes skill files to `<project>/.pi/skills/panopticon/`
- **Reporter:** `src/reporter.ts` — generates run reports, detects anomalies
- **Metrics:** `src/metrics.ts` — pushes Prometheus metrics to Victoria Metrics
- **State:** `src/state.ts` — per-project state persistence (last SHA, file hashes)
- **Types:** `src/types.ts` — shared type definitions
## Key Conventions
- All pi SDK sessions are created via `createSession()` in `session.ts`
- Workers get read-only tools; orchestrator and synthesizer get no tools
- Prompts live in `prompts/` directory as standalone markdown files
- Models default to Anthropic (claude-sonnet-4-5 for smart, claude-haiku-4-5 for cheap)
- Config is in `config.json` at project root
- State persisted in `state/` directory, run reports in `runs/`
## Build & Run
```bash
npm run build # Compile TypeScript
npm start # Run incremental (all projects)
node dist/index.js --full-analysis snow_trail_sdl # Full analysis
node dist/index.js --dry-run # Test without writing
```
## Dependencies
- `@mariozechner/pi-coding-agent` — pi SDK for LLM sessions
- `@mariozechner/pi-ai` — model resolution and streaming
- Node.js 20+, TypeScript 5.7+

36
config.json Normal file
View File

@@ -0,0 +1,36 @@
{
"projects": [
{
"name": "snow_trail",
"path": "/home/jonas/projects/snow_trail",
"language": "rust",
"sourceGlobs": ["src/**/*.rs", "shaders/**/*.wgsl"],
"excludeGlobs": ["target/**", "*.lock"],
"branch": "main"
}
],
"models": {
"orchestrator": "anthropic/claude-sonnet-4-5",
"worker": "anthropic/claude-haiku-4-5",
"synthesizer": "anthropic/claude-sonnet-4-5"
},
"thinkingLevels": {
"orchestrator": "medium",
"worker": "off",
"synthesizer": "low"
},
"metrics": {
"enabled": true,
"victoriaMetricsUrl": "http://localhost:8428",
"jobLabel": "panopticon"
},
"limits": {
"maxWorkerConcurrency": 4,
"maxDiffSizeBytes": 200000,
"maxFilesPerWorkUnit": 15,
"workerTimeoutSeconds": 120,
"synthesizerTimeoutSeconds": 180
},
"stateDir": "./state",
"runsDir": "./runs"
}

122
grafana/dashboard.json Normal file
View File

@@ -0,0 +1,122 @@
{
"annotations": { "list": [] },
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"panels": [
{
"title": "Run Status Timeline",
"type": "state-timeline",
"gridPos": { "h": 6, "w": 12, "x": 0, "y": 0 },
"targets": [
{
"expr": "panopticon_run_status",
"legendFormat": "{{project}}"
}
]
},
{
"title": "Run Duration",
"type": "timeseries",
"gridPos": { "h": 6, "w": 12, "x": 12, "y": 0 },
"targets": [
{
"expr": "panopticon_run_duration_seconds",
"legendFormat": "{{project}}"
}
]
},
{
"title": "Token Usage",
"type": "barchart",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 },
"targets": [
{
"expr": "sum by (model, direction) (panopticon_tokens_total)",
"legendFormat": "{{model}} {{direction}}"
}
]
},
{
"title": "Estimated Cost (30d)",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 6 },
"targets": [
{
"expr": "sum(increase(panopticon_estimated_cost_usd[30d]))",
"legendFormat": "Cost"
}
],
"fieldConfig": {
"defaults": {
"unit": "currencyUSD"
}
}
},
{
"title": "Error Rate (7d)",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 6 },
"targets": [
{
"expr": "sum(increase(panopticon_errors_total[7d]))",
"legendFormat": "Errors"
}
],
"fieldConfig": {
"defaults": {
"thresholds": {
"steps": [
{ "color": "green", "value": 0 },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 5 }
]
}
}
}
},
{
"title": "Files Changed",
"type": "timeseries",
"gridPos": { "h": 6, "w": 12, "x": 12, "y": 10 },
"targets": [
{
"expr": "panopticon_files_changed",
"legendFormat": "{{project}}"
}
]
},
{
"title": "Doc Churn",
"type": "table",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 14 },
"targets": [
{
"expr": "panopticon_doc_lines_changed",
"legendFormat": "{{project}} {{file}}",
"format": "table",
"instant": true
}
]
},
{
"title": "Phase Breakdown",
"type": "barchart",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 },
"targets": [
{
"expr": "panopticon_phase_duration_seconds",
"legendFormat": "{{project}} {{phase}}"
}
]
}
],
"schemaVersion": 39,
"tags": ["panopticon"],
"templating": { "list": [] },
"time": { "from": "now-30d", "to": "now" },
"title": "Panopticon",
"uid": "panopticon-main"
}

3553
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "panopticon",
"version": "0.1.0",
"description": "Automated project documentation registry using pi SDK",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsc --watch"
},
"dependencies": {
"@mariozechner/pi-coding-agent": "^0.57.0",
"@mariozechner/pi-ai": "^0.57.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"@types/node": "^22.0.0"
}
}

View File

@@ -0,0 +1,56 @@
You are the Panopticon orchestrator performing a full analysis of a codebase.
Your job is to plan the documentation generation from scratch.
## Input
You will receive:
1. The complete file tree
2. AST summaries (key types, functions, impls per file)
3. An import/dependency graph
4. The project's CLAUDE.md if it exists
5. Git log and file churn data
## Output
Return a JSON object with this structure:
```json
{
"skipReason": null,
"updates": [
{
"target": "structure.md",
"reason": "Full analysis — generating from scratch",
"relevantFiles": ["src/main.rs", "src/lib.rs", "src/rendering/mod.rs"],
"diffContext": "Focus on module boundaries, data flow, and key types"
},
{
"target": "guide.md",
"reason": "Full analysis — discovering patterns from code",
"relevantFiles": ["CLAUDE.md", "src/main.rs"],
"diffContext": "Focus on coding conventions, architectural patterns, testing patterns"
},
{
"target": "changelog.md",
"reason": "Full analysis — summarizing recent development",
"relevantFiles": [],
"diffContext": "Use git log and churn data to identify active areas"
}
]
}
```
## Document Scopes (Workers Must Stay in Lane)
- **structure.md**: Modules, types, data flow, entry points, dependencies. A factual MAP of the codebase. No conventions, no patterns, no "do this / don't do that".
- **guide.md**: Coding conventions, patterns, anti-patterns, testing, build workflow. A GUIDE for writing code. No module catalogs, no type listings, no architecture descriptions.
- **changelog.md**: Recent changes, active areas, stability, open threads. A LOG of what changed. No architecture descriptions, no conventions.
## Rules
- For full analysis, ALL three files should always be generated.
- For structure.md: identify the most important 15-20 files that define the module structure. Include entry points, mod.rs files, and key type definitions.
- For guide.md: include CLAUDE.md/AGENTS.md and representative source files that show coding patterns.
- For changelog.md: the worker will use git log data, no specific files needed.
- List relevant files in order of importance for each target.
- Return ONLY the JSON object. No other text.

View File

@@ -0,0 +1,47 @@
You are the Panopticon orchestrator. Your job is to analyze what changed in a
project and decide which documentation sections need updating.
## Input
You will receive:
1. A diff summary (files changed, insertions, deletions)
2. A git log of commit messages since the last run
3. The current table of contents of each documentation file
4. The file tree of the project
## Output
Return a JSON object with this structure:
```json
{
"skipReason": null,
"updates": [
{
"target": "structure.md",
"reason": "Why this file needs updating",
"relevantFiles": ["src/rendering/pipeline.rs", "src/rendering/dither.rs"],
"diffContext": "Key changes to communicate to the worker"
}
]
}
```
`skipReason` should be null if updates are needed, or a string like "no-meaningful-changes" if the diff is trivial.
`target` must be one of: "structure.md", "guide.md", "changelog.md"
## Document Scopes
- **structure.md**: Modules, types, data flow, entry points, dependencies. Update when module boundaries, key types, or data flow changed.
- **guide.md**: Coding conventions, patterns, anti-patterns, testing. Update when new patterns emerged or existing conventions changed.
- **changelog.md**: Recent changes, active areas, stability. ALWAYS update if there are any code changes.
## Rules
- If the diff is purely non-code (README, docs, CI config), set skipReason to "no-meaningful-changes".
- changelog.md should ALWAYS be updated if there are any code changes.
- structure.md only needs updating if module boundaries, key types, or data flow changed.
- guide.md only needs updating if new patterns emerged or existing conventions were violated/changed.
- Be conservative. Unnecessary updates waste tokens and risk doc drift.
- Return ONLY the JSON object. No other text.

79
prompts/synthesizer.md Normal file
View File

@@ -0,0 +1,79 @@
You are the Panopticon synthesizer. You combine documentation sections produced
by specialist workers into a coherent skill package.
## Input
You will receive updated documentation sections from workers and the current
SKILL.md entry point (if it exists).
## Tasks
1. Generate or update SKILL.md as a **pure table of contents** — it must NOT
repeat content from the sub-documents. It has a 2-3 sentence project summary,
quick reference block, and links. Nothing else.
2. Verify cross-references between documents are consistent:
- Types mentioned in structure.md should use the same names everywhere
- Patterns described in guide.md should reference real types from structure.md
- Active areas in changelog.md should reference real modules from structure.md
3. Check that documents stay in their lane:
- structure.md should NOT contain conventions, patterns, or "do this" advice
- guide.md should NOT catalog modules/types or describe data flow
- changelog.md should NOT describe architecture or conventions
4. Flag any contradictions, overlap, or scope violations you find.
5. Keep SKILL.md concise: description under 200 characters, body under 40 lines.
## SKILL.md Format
The SKILL.md must follow this exact structure:
```markdown
---
name: panopticon
description: >-
Auto-generated project overview for <project-name>. Structure, conventions,
and recent activity. Updated nightly by Panopticon.
---
# <project-name> — Project Overview
<2-3 sentence summary of the project. What it is and what technologies it uses.>
## Quick Reference
- **Language:** <language>
- **Key dependencies:** <deps>
- **Build:** `<build command>`
- **Test:** `<test command>`
- **Entry point:** `<entry point>`
## Documentation
- [Structure](structure.md) — modules, types, data flow, dependencies
- [Guide](guide.md) — conventions, patterns, anti-patterns, testing
- [Changelog](changelog.md) — recent changes, active areas, stability
```
**IMPORTANT:** SKILL.md is ONLY a table of contents. Do NOT add "Architecture
Highlights", "Key Conventions", or any other sections that summarize the
sub-documents. The links are sufficient.
## Output
Return a JSON object:
```json
{
"skill_md": "the complete SKILL.md content",
"fixes": [
{
"file": "structure.md",
"description": "Fixed reference to renamed type",
"before": "old text",
"after": "new text"
}
],
"inconsistencies": ["description of any unresolvable issues"]
}
```
Return ONLY the JSON object. No other text.

View File

@@ -0,0 +1,69 @@
You are generating a changelog / recent activity document for a codebase. You
have read access to the source code via the `read` and `bash` tools.
## Your Scope — ONLY Recent Changes and Development Activity
This document covers ONLY what has changed recently:
- Which areas of the codebase are actively being modified
- Semantic descriptions of recent changes
- Stability assessment (what hasn't changed)
- Partially complete work / open threads
## What Does NOT Belong Here
- **Module descriptions, type catalogs, dependency graphs** → structure.md
- **Coding conventions, patterns, anti-patterns** → guide.md
- **How the architecture works** → structure.md
- **How to write code** → guide.md
If you find yourself describing what a module does or how the architecture works,
STOP — that belongs in structure.md. You may name modules/areas as locations of
changes, but do not describe their architecture.
## Instructions
1. Analyze the git log and file churn data provided.
2. Group changes by area/module, not chronologically.
3. Identify:
- **Active areas:** directories/modules with the most churn
- **Recent changes:** what changed semantically ("Added dithering pass" not "Modified pipeline.rs")
- **Stability assessment:** which parts haven't changed in 30+ days
- **Open threads:** partially complete work based on recent commits
4. Target {min_lines}-{max_lines} lines. Updated every run — older entries age out.
## Format
```
# Changelog
*Last updated: <date>*
## Active Areas
| Area | Changes (30d) | Description |
...
## Recent Changes
### <area-name>
- <semantic description of change>
...
## Stability
| Area | Last Changed | Status |
...
## Open Threads
- <description of partially complete work>
```
## Writing Rules
- Use semantic descriptions, not commit messages
- Group by area, not by date
- Be specific about what changed and why it matters
- Mark areas as "active", "stable", or "in flux"
- You may use `bash` to run `git log` commands for more detail
## Output
Return ONLY the markdown document. No preamble, no commentary, no "here is the
document" or "let me create" — start directly with `# Changelog`.

75
prompts/worker-guide.md Normal file
View File

@@ -0,0 +1,75 @@
You are generating a development guide for a codebase. You have read access
to the source code via the `read` and `bash` tools.
## Your Scope — ONLY Conventions, Patterns, and How-To
This document covers ONLY how to write code in this project:
- Naming conventions
- Formatting rules
- Architectural patterns (with code examples)
- Anti-patterns to avoid (with code examples)
- Testing conventions
- Build and development workflow
## What Does NOT Belong Here
- **Module listings, type catalogs, dependency graphs** → these go in structure.md
- **Data flow descriptions** → structure.md
- **Entry points, initialization order** → structure.md
- **What changed recently** → changelog.md
- **Describing what each module does** → structure.md
If you find yourself listing all the modules or types or describing what each
system does, STOP — that belongs in structure.md, not here.
You may REFERENCE specific types or modules as examples of a pattern, but do not
catalog them.
## Instructions
1. If a CLAUDE.md or AGENTS.md exists, read it first — these contain authoritative
project rules. Summarize and reference them, don't duplicate verbatim.
2. Read representative source files to discover recurring patterns.
3. For each pattern, show a concrete code example from the actual codebase.
4. For each anti-pattern, show what to avoid and why.
5. Target {min_lines}-{max_lines} lines. Dense, prescriptive, no fluff.
## Format
```
# Guide
## Conventions
### Naming
### Formatting
### Imports
## Patterns
### <pattern-name>
<why it exists, code example>
## Anti-Patterns
### <anti-pattern-name>
<what to avoid, why, code example>
## Testing
### Structure
### What to Test
### Running Tests
## References
- See CLAUDE.md for authoritative project rules
```
## Writing Rules
- Be prescriptive: "Do X" not "X is sometimes done"
- Give concrete code examples for each pattern
- Explain WHY a pattern exists, not just WHAT it is
- Reference specific types/modules as examples, don't catalog them
- Write for an LLM reader that will be writing code in this project
## Output
Return ONLY the markdown document. No preamble, no commentary, no "here is the
document" or "let me create" — start directly with `# Guide`.

View File

@@ -0,0 +1,72 @@
You are generating a structure document for a codebase. You have read access
to the source code via the `read` and `bash` tools.
## Your Scope — ONLY Factual Structure
This document covers ONLY the physical and logical layout of the code:
- Modules and what they contain
- Types and their fields
- Data flow through the system
- Entry points and initialization order
- Dependency relationships between modules
## What Does NOT Belong Here
- **Coding conventions, style rules, naming rules** → these go in guide.md
- **Patterns, anti-patterns, best practices** → these go in guide.md
- **How to write code in this project** → guide.md
- **What changed recently** → changelog.md
- **Code examples showing "do this / don't do this"** → guide.md
If you find yourself writing "Do X" or "Avoid Y" or showing good/bad examples,
STOP — that belongs in guide.md, not here.
## Instructions
1. Read key files to understand the actual structure. Start with entry points
and work outward.
2. Identify the natural module boundaries from the code structure.
3. For each module/area, describe:
- What it does (1-2 sentences of factual description)
- Key types it defines
- What it depends on and what depends on it
4. Describe the main data flow through the system.
5. List the 10-20 most important types with one-sentence descriptions.
6. Identify entry points and initialization order.
7. Target {min_lines}-{max_lines} lines. Dense, precise, no filler.
## Format
```
# Structure
## Modules
### <module-name>
...
## Data Flow
...
## Key Types
| Type | Location | Description |
...
## Entry Points
...
## Dependencies
...
```
## Writing Rules
- Be precise about names: exact function names, type names, file paths
- State relationships explicitly: "X calls Y", "A depends on B"
- Avoid vague language: "various", "several", "etc."
- This is a **map**, not a **guide** — describe what IS, not what SHOULD BE
- Write for an LLM reader, not a human
## Output
Return ONLY the markdown document. No preamble, no commentary, no "here is the
document" or "let me create" — start directly with `# Structure`.

20
prompts/worker-update.md Normal file
View File

@@ -0,0 +1,20 @@
You are updating a section of project documentation. You have read access to the
project's source code via the `read` and `bash` tools.
## Instructions
1. Read the relevant source files to understand the changes in context.
2. Follow imports if needed to understand how changes connect to the broader codebase.
3. Update ONLY the sections affected by the changes. Do not rewrite unchanged sections.
4. Preserve the existing structure and heading hierarchy unless it no longer fits.
5. Keep the document between {min_lines} and {max_lines} lines.
6. Write for an LLM reader, not a human:
- Be precise about names (exact function names, type names, file paths)
- State relationships explicitly ("X calls Y", "A depends on B")
- Avoid vague language ("various", "several", "etc.")
- Include concrete examples over abstract descriptions
## Output
Return the complete updated markdown file. Do not wrap in code fences. Return
only the document content.

67
src/config.ts Normal file
View File

@@ -0,0 +1,67 @@
import { readFileSync, existsSync } from "fs";
import { resolve } from "path";
import type { Config, ProjectConfig, ModelsConfig, ThinkingConfig, MetricsConfig, LimitsConfig } from "./types.js";
const DEFAULT_LIMITS: LimitsConfig = {
maxWorkerConcurrency: 4,
maxDiffSizeBytes: 200000,
maxFilesPerWorkUnit: 15,
workerTimeoutSeconds: 120,
synthesizerTimeoutSeconds: 180,
};
const DEFAULT_METRICS: MetricsConfig = {
enabled: true,
victoriaMetricsUrl: "http://localhost:8428",
jobLabel: "panopticon",
};
const DEFAULT_MODELS: ModelsConfig = {
orchestrator: "anthropic/claude-sonnet-4-5",
worker: "anthropic/claude-haiku-4-5",
synthesizer: "anthropic/claude-sonnet-4-5",
};
const DEFAULT_THINKING: ThinkingConfig = {
orchestrator: "medium",
worker: "off",
synthesizer: "low",
};
export function loadConfig(configPath?: string): Config {
const resolvedPath = configPath ?? resolve(process.cwd(), "config.json");
if (!existsSync(resolvedPath)) {
throw new Error(`Config file not found: ${resolvedPath}`);
}
const raw = JSON.parse(readFileSync(resolvedPath, "utf-8"));
if (!raw.projects || !Array.isArray(raw.projects) || raw.projects.length === 0) {
throw new Error("Config must have at least one project in 'projects' array");
}
for (const p of raw.projects) {
validateProject(p);
}
return {
projects: raw.projects as ProjectConfig[],
models: { ...DEFAULT_MODELS, ...raw.models },
thinkingLevels: { ...DEFAULT_THINKING, ...raw.thinkingLevels },
metrics: { ...DEFAULT_METRICS, ...raw.metrics },
limits: { ...DEFAULT_LIMITS, ...raw.limits },
stateDir: raw.stateDir ?? "./state",
runsDir: raw.runsDir ?? "./runs",
};
}
function validateProject(p: any): asserts p is ProjectConfig {
if (!p.name || typeof p.name !== "string") throw new Error("Project must have a 'name' string");
if (!p.path || typeof p.path !== "string") throw new Error(`Project '${p.name}' must have a 'path' string`);
if (!p.language || typeof p.language !== "string") throw new Error(`Project '${p.name}' must have a 'language' string`);
if (!existsSync(p.path)) throw new Error(`Project '${p.name}' path does not exist: ${p.path}`);
p.sourceGlobs = p.sourceGlobs ?? ["src/**/*"];
p.excludeGlobs = p.excludeGlobs ?? [];
p.branch = p.branch ?? "main";
}

149
src/git.ts Normal file
View File

@@ -0,0 +1,149 @@
import { execSync } from "child_process";
import type { ProjectConfig, GitDiffResult, GitChurnEntry } from "./types.js";
function git(projectPath: string, args: string): string {
try {
return execSync(`git ${args}`, {
cwd: projectPath,
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
timeout: 30000,
}).trim();
} catch (err: any) {
throw new Error(`Git command failed in ${projectPath}: git ${args}\n${err.stderr || err.message}`);
}
}
export function getCurrentSha(projectPath: string): string {
return git(projectPath, "rev-parse HEAD");
}
export function pull(projectPath: string, branch: string): void {
git(projectPath, `pull origin ${branch} --ff-only`);
}
export function getFileTree(projectPath: string, config: ProjectConfig): string[] {
const allFiles = git(projectPath, "ls-files").split("\n").filter(Boolean);
// Filter by source globs (simple glob matching)
const filtered = allFiles.filter((file) => {
// Check exclusions first
for (const exc of config.excludeGlobs) {
if (matchGlob(file, exc)) return false;
}
// Check inclusions
for (const inc of config.sourceGlobs) {
if (matchGlob(file, inc)) return true;
}
return false;
});
return filtered.sort();
}
export function getDiffSince(projectPath: string, lastSha: string, config: ProjectConfig): GitDiffResult {
const currentSha = getCurrentSha(projectPath);
// Diff stat
const diffStat = git(projectPath, `diff ${lastSha}..${currentSha} --stat`);
// Full diff (for source files only), truncated
const sourceExtArgs = getSourceExtArgs(config);
let diffContent: string;
try {
diffContent = git(projectPath, `diff ${lastSha}..${currentSha} ${sourceExtArgs}`);
// Truncate if too large
if (Buffer.byteLength(diffContent) > config.excludeGlobs.length) {
// Use maxDiffSizeBytes from caller if needed; for now truncate at 200KB
const maxBytes = 200000;
if (Buffer.byteLength(diffContent) > maxBytes) {
diffContent = diffContent.slice(0, maxBytes) + "\n... [truncated]";
}
}
} catch {
diffContent = "[diff too large or unavailable]";
}
// Commit log
const commitLog = git(projectPath, `log ${lastSha}..${currentSha} --oneline`);
// Files changed
const filesChanged = git(projectPath, `diff ${lastSha}..${currentSha} --name-only`)
.split("\n")
.filter(Boolean);
// Stats
let insertions = 0;
let deletions = 0;
try {
const numstat = git(projectPath, `diff ${lastSha}..${currentSha} --numstat`);
for (const line of numstat.split("\n")) {
const parts = line.split("\t");
if (parts.length >= 2) {
const ins = parseInt(parts[0], 10);
const del = parseInt(parts[1], 10);
if (!isNaN(ins)) insertions += ins;
if (!isNaN(del)) deletions += del;
}
}
} catch { /* ignore */ }
const commitCount = commitLog ? commitLog.split("\n").length : 0;
return { diffStat, diffContent, commitLog, filesChanged, insertions, deletions, commitCount };
}
export function getGitLog(projectPath: string, days: number): string {
try {
return git(projectPath, `log --oneline --since="${days} days ago"`);
} catch {
return "";
}
}
export function getFileChurn(projectPath: string, days: number): GitChurnEntry[] {
try {
const raw = git(projectPath, `log --since="${days} days ago" --pretty=format: --name-only`);
const counts = new Map<string, number>();
for (const line of raw.split("\n")) {
const file = line.trim();
if (file) {
counts.set(file, (counts.get(file) ?? 0) + 1);
}
}
return Array.from(counts.entries())
.map(([file, count]) => ({ file, count }))
.sort((a, b) => b.count - a.count);
} catch {
return [];
}
}
export function getDirstat(projectPath: string, lastSha: string): string {
try {
return git(projectPath, `diff ${lastSha}..HEAD --dirstat`);
} catch {
return "";
}
}
// Simple glob matching (supports ** and *)
function matchGlob(path: string, glob: string): boolean {
const regex = glob
.replace(/\./g, "\\.")
.replace(/\*\*/g, "{{DOUBLESTAR}}")
.replace(/\*/g, "[^/]*")
.replace(/\{\{DOUBLESTAR\}\}/g, ".*");
return new RegExp(`^${regex}$`).test(path);
}
function getSourceExtArgs(config: ProjectConfig): string {
// Build -- '*.ext' args from source globs for git diff filtering
const exts = new Set<string>();
for (const glob of config.sourceGlobs) {
const match = glob.match(/\*\.(\w+)$/);
if (match) exts.add(match[1]);
}
if (exts.size === 0) return "";
return "-- " + Array.from(exts).map((e) => `'*.${e}'`).join(" ");
}

584
src/index.ts Normal file
View File

@@ -0,0 +1,584 @@
#!/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);
});

70
src/metrics.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { MetricLine, MetricsConfig, ProjectRunReport } from "./types.js";
function formatLabels(labels: Record<string, string>): string {
return Object.entries(labels)
.map(([k, v]) => `${k}="${v}"`)
.join(",");
}
export async function pushMetrics(config: MetricsConfig, metrics: MetricLine[]): Promise<void> {
if (!config.enabled) return;
const body = metrics
.map((m) => {
const ts = m.timestamp ?? Date.now();
return `${m.name}{${formatLabels(m.labels)}} ${m.value} ${ts}`;
})
.join("\n");
try {
const response = await fetch(`${config.victoriaMetricsUrl}/api/v1/import/prometheus`, {
method: "POST",
headers: { "Content-Type": "text/plain" },
body,
});
if (!response.ok) {
console.error(`Metrics push failed: ${response.status} ${response.statusText}`);
}
} catch (err: any) {
console.error(`Metrics push error: ${err.message}`);
}
}
export function buildProjectMetrics(report: ProjectRunReport, jobLabel: string): MetricLine[] {
const labels = { project: report.project, job: jobLabel };
const ts = Date.now();
const metrics: MetricLine[] = [];
const add = (name: string, value: number, extraLabels: Record<string, string> = {}) => {
metrics.push({ name, labels: { ...labels, ...extraLabels }, value, timestamp: ts });
};
add("panopticon_run_status", report.status === "success" ? 1 : 0);
add("panopticon_run_duration_seconds", report.duration / 1000);
add("panopticon_run_skipped", report.status === "skipped" ? 1 : 0);
add("panopticon_files_changed", report.filesChanged);
add("panopticon_commits_since_last", report.commitCount);
// Phase timings
for (const [phase, duration] of Object.entries(report.phaseTimings)) {
add("panopticon_phase_duration_seconds", duration / 1000, { phase });
}
// Token usage
add("panopticon_tokens_total", report.totalTokensIn, { direction: "input" });
add("panopticon_tokens_total", report.totalTokensOut, { direction: "output" });
// Cost estimate
add("panopticon_estimated_cost_usd", report.estimatedCost);
// Doc changes
for (const [file, lines] of Object.entries(report.docLinesChanged)) {
add("panopticon_doc_lines_changed", lines, { file });
}
// Errors
add("panopticon_errors_total", report.errors.length);
add("panopticon_worker_failures", report.workerResults.filter((w) => w.status === "failure").length);
return metrics;
}

212
src/orchestrator.ts Normal file
View File

@@ -0,0 +1,212 @@
import { readFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import { createSession, promptWithTimeout } from "./session.js";
import type { Config, OrchestratorResult, GitDiffResult, StructuralContext, SessionMetrics } from "./types.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROMPTS_DIR = join(__dirname, "..", "prompts");
function loadPrompt(name: string): string {
return readFileSync(join(PROMPTS_DIR, name), "utf-8");
}
/**
* Run the orchestrator for an incremental update.
* Analyzes the diff and decides which doc files need updating.
*/
export async function runIncrementalOrchestrator(
projectPath: string,
config: Config,
diff: GitDiffResult,
existingDocs: Record<string, string>,
fileTree: string[]
): Promise<{ result: OrchestratorResult; metrics: SessionMetrics }> {
const systemPrompt = loadPrompt("orchestrator-incremental.md");
const { session, metrics } = await createSession({
role: "orchestrator",
projectPath,
config,
systemPrompt,
});
const startTime = Date.now();
// Build context for the orchestrator
const context = buildIncrementalContext(diff, existingDocs, fileTree);
try {
const response = await promptWithTimeout(
session,
context,
config.limits.workerTimeoutSeconds * 1000
);
metrics.durationMs = Date.now() - startTime;
// Parse JSON from response
const result = parseOrchestratorResponse(response);
return { result, metrics };
} catch (err: any) {
metrics.durationMs = Date.now() - startTime;
metrics.errors.push(`orchestrator: ${err.message}`);
// Fallback: update all files
console.error(`Orchestrator failed, falling back to full update: ${err.message}`);
return {
result: {
skipReason: null,
updates: [
{ target: "structure.md", reason: "Orchestrator fallback", relevantFiles: [], diffContext: diff.diffStat },
{ target: "guide.md", reason: "Orchestrator fallback", relevantFiles: [], diffContext: diff.diffStat },
{ target: "changelog.md", reason: "Orchestrator fallback", relevantFiles: [], diffContext: diff.commitLog },
],
},
metrics,
};
}
}
/**
* Run the orchestrator for a full analysis.
* Plans documentation structure from scratch.
*/
export async function runFullOrchestrator(
projectPath: string,
config: Config,
structural: StructuralContext
): Promise<{ result: OrchestratorResult; metrics: SessionMetrics }> {
const systemPrompt = loadPrompt("orchestrator-full.md");
const { session, metrics } = await createSession({
role: "orchestrator",
projectPath,
config,
systemPrompt,
});
const startTime = Date.now();
const context = buildFullContext(structural);
try {
const response = await promptWithTimeout(
session,
context,
config.limits.workerTimeoutSeconds * 1000
);
metrics.durationMs = Date.now() - startTime;
const result = parseOrchestratorResponse(response);
return { result, metrics };
} catch (err: any) {
metrics.durationMs = Date.now() - startTime;
metrics.errors.push(`orchestrator-full: ${err.message}`);
// Fallback: generate all docs
return {
result: {
skipReason: null,
updates: [
{ target: "structure.md", reason: "Full analysis", relevantFiles: structural.fileTree, diffContext: "" },
{ target: "guide.md", reason: "Full analysis", relevantFiles: structural.fileTree, diffContext: "" },
{ target: "changelog.md", reason: "Full analysis", relevantFiles: structural.fileTree, diffContext: structural.gitLog },
],
},
metrics,
};
}
}
function buildIncrementalContext(
diff: GitDiffResult,
existingDocs: Record<string, string>,
fileTree: string[]
): string {
const parts: string[] = [];
parts.push("## Diff Summary");
parts.push(diff.diffStat);
parts.push("");
parts.push("## Commit Log");
parts.push(diff.commitLog);
parts.push("");
parts.push("## Files Changed");
parts.push(diff.filesChanged.join("\n"));
parts.push("");
parts.push("## Current Documentation TOCs");
for (const [file, content] of Object.entries(existingDocs)) {
parts.push(`### ${file}`);
// Extract headings as TOC
const headings = content.split("\n").filter((l) => l.startsWith("#"));
parts.push(headings.join("\n"));
parts.push("");
}
parts.push("## File Tree (first 200 files)");
parts.push(fileTree.slice(0, 200).join("\n"));
return parts.join("\n");
}
function buildFullContext(structural: StructuralContext): string {
const parts: string[] = [];
parts.push("## File Tree");
parts.push(structural.fileTree.slice(0, 300).join("\n"));
parts.push("");
parts.push("## AST Summaries");
for (const entry of structural.astSummaries.slice(0, 50)) {
parts.push(`### ${entry.file}`);
parts.push(entry.summary);
parts.push("");
}
parts.push("## Import Graph");
for (const [mod, deps] of structural.importGraph.modules) {
parts.push(`${mod}${deps.join(", ")}`);
}
parts.push("");
if (structural.claudeMd) {
parts.push("## CLAUDE.md");
parts.push(structural.claudeMd.slice(0, 5000));
parts.push("");
}
parts.push("## Git Log (last 90 days)");
parts.push(structural.gitLog.slice(0, 3000));
parts.push("");
parts.push("## File Churn (last 30 days, top 30)");
for (const entry of structural.gitChurn.slice(0, 30)) {
parts.push(` ${entry.count}\t${entry.file}`);
}
return parts.join("\n");
}
function parseOrchestratorResponse(response: string): OrchestratorResult {
// Try to find JSON in the response
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error("No JSON found in orchestrator response");
}
try {
const parsed = JSON.parse(jsonMatch[0]);
return {
skipReason: parsed.skipReason ?? null,
updates: (parsed.updates ?? []).map((u: any) => ({
target: u.target,
reason: u.reason ?? "",
relevantFiles: u.relevantFiles ?? [],
diffContext: u.diffContext ?? "",
})),
};
} catch (err: any) {
throw new Error(`Failed to parse orchestrator JSON: ${err.message}`);
}
}

157
src/reporter.ts Normal file
View File

@@ -0,0 +1,157 @@
import { writeFileSync, mkdirSync } from "fs";
import { join } from "path";
import type { ProjectRunReport, NightlyReport } from "./types.js";
export function writeProjectReport(runsDir: string, date: string, report: ProjectRunReport): void {
const dir = join(runsDir, date, report.project);
mkdirSync(dir, { recursive: true });
mkdirSync(join(dir, "sessions"), { recursive: true });
const md = renderProjectReport(report);
writeFileSync(join(dir, "report.md"), md);
}
function renderProjectReport(r: ProjectRunReport): string {
const lines: string[] = [];
lines.push(`# Panopticon Run Report: ${r.project}`);
lines.push(`Date: ${r.date}`);
lines.push(`Status: ${r.status}`);
lines.push(`Duration: ${(r.duration / 1000).toFixed(1)}s`);
lines.push("");
lines.push("## Changes Since Last Run");
lines.push(`- Commits: ${r.commitCount}`);
lines.push(`- Files changed: ${r.filesChanged}`);
lines.push(`- Insertions: +${r.insertions}, Deletions: -${r.deletions}`);
lines.push("");
if (r.orchestratorDecision) {
lines.push("## Orchestrator Decision");
if (r.orchestratorDecision.skipReason) {
lines.push(`Skip: ${r.orchestratorDecision.skipReason}`);
} else {
for (const u of r.orchestratorDecision.updates) {
lines.push(`- ${u.target}: UPDATE (${u.reason})`);
}
}
lines.push("");
}
if (r.workerResults.length > 0) {
lines.push("## Worker Results");
lines.push("| Worker | Target | Status | Tokens In | Tokens Out | Duration |");
lines.push("|--------|--------|--------|-----------|------------|----------|");
for (let i = 0; i < r.workerResults.length; i++) {
const w = r.workerResults[i];
lines.push(
`| worker-${i} | ${w.target} | ${w.status} | ${w.metrics.tokensIn.toLocaleString()} | ${w.metrics.tokensOut.toLocaleString()} | ${(w.metrics.durationMs / 1000).toFixed(1)}s |`
);
}
lines.push("");
}
lines.push("## Synthesizer Result");
lines.push(`- Status: ${r.synthesizerStatus}`);
lines.push("");
if (Object.keys(r.docLinesChanged).length > 0) {
lines.push("## Doc Changes");
for (const [file, linesChanged] of Object.entries(r.docLinesChanged)) {
lines.push(`### ${file}`);
lines.push(`- Lines changed: ${linesChanged}`);
lines.push("");
}
}
if (r.errors.length > 0) {
lines.push("## Errors");
for (const err of r.errors) {
lines.push(`- ${err}`);
}
lines.push("");
}
return lines.join("\n");
}
export function writeNightlyReport(runsDir: string, date: string, report: NightlyReport): void {
mkdirSync(join(runsDir, date), { recursive: true });
const md = renderNightlyReport(report);
writeFileSync(join(runsDir, date, "report.md"), md);
}
function renderNightlyReport(r: NightlyReport): string {
const lines: string[] = [];
lines.push(`# Panopticon Nightly Report — ${r.date}`);
lines.push("");
const succeeded = r.projectReports.filter((p) => p.status === "success").length;
const skipped = r.projectReports.filter((p) => p.status === "skipped").length;
const failed = r.projectReports.filter((p) => p.status === "failure").length;
lines.push("## Summary");
lines.push(`- Projects processed: ${r.projectReports.length}`);
lines.push(`- Succeeded: ${succeeded}`);
lines.push(`- Skipped (no changes): ${skipped}`);
lines.push(`- Failures: ${failed}`);
lines.push(`- Total duration: ${(r.totalDuration / 1000).toFixed(1)}s`);
lines.push(`- Total cost: ~$${r.totalCost.toFixed(3)}`);
lines.push("");
lines.push("## Per Project");
lines.push("| Project | Status | Changes | Duration | Cost |");
lines.push("|---------|--------|---------|----------|------|");
for (const p of r.projectReports) {
const statusIcon = p.status === "success" ? "✅" : p.status === "skipped" ? "⏭" : "❌";
lines.push(
`| ${p.project} | ${statusIcon} ${p.status} | ${p.filesChanged} files | ${(p.duration / 1000).toFixed(1)}s | $${p.estimatedCost.toFixed(3)} |`
);
}
lines.push("");
if (r.anomalies.length > 0) {
lines.push("## Anomalies");
for (const a of r.anomalies) {
lines.push(`- ${a}`);
}
} else {
lines.push("## Anomalies");
lines.push("None detected.");
}
lines.push("");
return lines.join("\n");
}
/**
* Detect anomalies in the nightly run.
*/
export function detectAnomalies(reports: ProjectRunReport[]): string[] {
const anomalies: string[] = [];
for (const r of reports) {
// Empty worker output
for (const w of r.workerResults) {
if (w.status === "success" && (!w.content || w.content.trim().length < 50)) {
anomalies.push(`${r.project}: worker for ${w.target} returned near-empty output`);
}
}
// Large doc size changes (would need previous sizes — flag if >500 lines changed)
for (const [file, lines] of Object.entries(r.docLinesChanged)) {
if (lines > 200) {
anomalies.push(`${r.project}: ${file} had ${lines} lines changed (drastic change)`);
}
}
// Worker failures
const failedWorkers = r.workerResults.filter((w) => w.status === "failure");
if (failedWorkers.length > 0) {
anomalies.push(`${r.project}: ${failedWorkers.length} worker(s) failed`);
}
}
return anomalies;
}

153
src/session.ts Normal file
View File

@@ -0,0 +1,153 @@
/**
* Shared session creation utilities for orchestrator, workers, and synthesizer.
*/
import {
createAgentSession,
DefaultResourceLoader,
SessionManager,
SettingsManager,
AuthStorage,
ModelRegistry,
readOnlyTools,
type AgentSession,
type AgentSessionEvent,
} from "@mariozechner/pi-coding-agent";
import { getModel } from "@mariozechner/pi-ai";
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Config, SessionMetrics } from "./types.js";
export interface SessionOptions {
role: "orchestrator" | "worker" | "synthesizer";
projectPath: string;
config: Config;
systemPrompt: string;
}
/**
* Resolve a model spec like "anthropic/claude-sonnet-4-5" into a Model object.
*/
function resolveModel(spec: string, modelRegistry: ModelRegistry) {
const [provider, modelId] = spec.split("/");
// Try ModelRegistry first, fall back to getModel
try {
return getModel(provider as any, modelId as any);
} catch {
throw new Error(`Cannot resolve model: ${spec}`);
}
}
/**
* Create a pi AgentSession for a given role.
*/
export async function createSession(options: SessionOptions): Promise<{
session: AgentSession;
metrics: SessionMetrics;
}> {
const { role, projectPath, config, systemPrompt } = options;
const authStorage = AuthStorage.create();
const modelRegistry = new ModelRegistry(authStorage);
const modelSpec = config.models[role];
const model = resolveModel(modelSpec, modelRegistry);
const thinkingLevel: ThinkingLevel = config.thinkingLevels[role];
const loader = new DefaultResourceLoader({
cwd: projectPath,
systemPrompt,
noSkills: true,
noPromptTemplates: true,
noExtensions: true,
noThemes: true,
});
await loader.reload();
const settingsManager = SettingsManager.inMemory({
retry: { enabled: true, maxRetries: 2 },
});
// Workers get read-only tools; orchestrator and synthesizer get no tools
const tools = role === "worker" ? readOnlyTools : [];
const { session } = await createAgentSession({
cwd: projectPath,
model,
thinkingLevel,
tools,
resourceLoader: loader,
sessionManager: SessionManager.inMemory(),
settingsManager,
authStorage,
modelRegistry,
});
// Track metrics
const metrics = trackSession(session);
return { session, metrics };
}
/**
* Subscribe to session events and collect metrics.
*/
function trackSession(session: AgentSession): SessionMetrics {
const metrics: SessionMetrics = {
tokensIn: 0,
tokensOut: 0,
toolCalls: 0,
errors: [],
durationMs: 0,
};
session.subscribe((event: AgentSessionEvent) => {
if (event.type === "message_end") {
const msg = event.message as any;
if (msg.role === "assistant" && msg.usage) {
metrics.tokensIn += msg.usage.input ?? 0;
metrics.tokensOut += msg.usage.output ?? 0;
}
}
if (event.type === "tool_execution_end") {
metrics.toolCalls++;
if (event.isError) {
metrics.errors.push(`${event.toolName}: ${JSON.stringify(event.result).slice(0, 200)}`);
}
}
});
return metrics;
}
/**
* Extract the final text response from a session after prompt() completes.
*/
export function extractFinalResponse(session: AgentSession): string {
const messages = session.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i] as any;
if (msg.role === "assistant" && msg.content) {
const textParts = msg.content.filter((c: any) => c.type === "text");
return textParts.map((c: any) => c.text).join("\n");
}
}
return "";
}
/**
* Run a prompt with a timeout via AbortController.
*/
export async function promptWithTimeout(
session: AgentSession,
prompt: string,
timeoutMs: number
): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
await session.prompt(prompt);
return extractFinalResponse(session);
} finally {
clearTimeout(timeout);
}
}

33
src/state.ts Normal file
View File

@@ -0,0 +1,33 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { join } from "path";
import type { ProjectState } from "./types.js";
const EMPTY_STATE: ProjectState = {
lastSha: null,
lastRunTimestamp: null,
lastRunStatus: null,
fileHashes: {},
docVersions: {},
};
export function loadState(stateDir: string, projectName: string): ProjectState {
const statePath = join(stateDir, `${projectName}.json`);
if (!existsSync(statePath)) {
return { ...EMPTY_STATE };
}
try {
const raw = JSON.parse(readFileSync(statePath, "utf-8"));
return {
...EMPTY_STATE,
...raw,
};
} catch {
return { ...EMPTY_STATE };
}
}
export function saveState(stateDir: string, projectName: string, state: ProjectState): void {
mkdirSync(stateDir, { recursive: true });
const statePath = join(stateDir, `${projectName}.json`);
writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
}

240
src/structural.ts Normal file
View File

@@ -0,0 +1,240 @@
import { readFileSync, existsSync } from "fs";
import { join } from "path";
import { createHash } from "crypto";
import type { ProjectConfig, AstEntry, ImportGraph, StructuralContext, GitChurnEntry } from "./types.js";
import { getFileTree, getGitLog, getFileChurn } from "./git.js";
export function hashFile(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
export function hashFileAtPath(filePath: string): string {
const content = readFileSync(filePath, "utf-8");
return hashFile(content);
}
/**
* Extract AST summaries using regex-based parsing.
* Works for Rust, TypeScript, Python. Not perfect but functional.
*/
export function extractAstSummaries(projectPath: string, files: string[], language: string): AstEntry[] {
const entries: AstEntry[] = [];
for (const file of files) {
const fullPath = join(projectPath, file);
if (!existsSync(fullPath)) continue;
let content: string;
try {
content = readFileSync(fullPath, "utf-8");
} catch {
continue;
}
const summary = extractSummary(content, language);
if (summary) {
entries.push({ file, summary });
}
}
return entries;
}
function extractSummary(content: string, language: string): string {
switch (language) {
case "rust":
return extractRustSummary(content);
case "typescript":
case "javascript":
return extractTsSummary(content);
default:
return extractGenericSummary(content);
}
}
function extractRustSummary(content: string): string {
const lines: string[] = [];
// Structs
const structs = content.matchAll(/pub\s+struct\s+(\w+)(?:<[^>]*>)?\s*\{([^}]*)\}/gs);
for (const m of structs) {
const fields = m[2]
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("//") && !l.startsWith("#"))
.map((l) => l.replace(/pub\s+/, "").replace(/,\s*$/, "").split(":")[0]?.trim())
.filter(Boolean);
lines.push(` struct ${m[1]} { ${fields.join(", ")} }`);
}
// Enums
const enums = content.matchAll(/pub\s+enum\s+(\w+)(?:<[^>]*>)?\s*\{([^}]*)\}/gs);
for (const m of enums) {
const variants = m[2]
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("//") && !l.startsWith("#"))
.map((l) => l.replace(/,\s*$/, "").split("(")[0]?.split("{")[0]?.trim())
.filter(Boolean);
lines.push(` enum ${m[1]} { ${variants.join(", ")} }`);
}
// Impl blocks
const impls = content.matchAll(/impl(?:<[^>]*>)?\s+(\w+)(?:<[^>]*>)?(?:\s+for\s+(\w+)(?:<[^>]*>)?)?\s*\{/g);
for (const m of impls) {
const implName = m[2] ? `${m[1]} for ${m[2]}` : m[1];
// Find functions within the impl block (rough)
const startIdx = m.index! + m[0].length;
const block = extractBlock(content, startIdx);
const fns = block.matchAll(/(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*(?:<[^>]*>)?\s*\(([^)]*)\)(?:\s*->\s*([^\n{]+))?/g);
const fnNames: string[] = [];
for (const f of fns) {
fnNames.push(f[1]);
}
if (fnNames.length > 0) {
lines.push(` impl ${implName}: ${fnNames.join(", ")}`);
}
}
// Top-level functions
const topFns = content.matchAll(/^pub\s+(?:async\s+)?fn\s+(\w+)\s*(?:<[^>]*>)?\s*\(([^)]*)\)(?:\s*->\s*([^\n{]+))?/gm);
for (const m of topFns) {
const ret = m[3]?.trim() ?? "()";
lines.push(` fn ${m[1]}(...) -> ${ret}`);
}
// Traits
const traits = content.matchAll(/pub\s+trait\s+(\w+)(?:<[^>]*>)?\s*(?::\s*[^{]*)?\{/g);
for (const m of traits) {
lines.push(` trait ${m[1]}`);
}
return lines.join("\n");
}
function extractTsSummary(content: string): string {
const lines: string[] = [];
// Interfaces and types
const interfaces = content.matchAll(/export\s+(?:interface|type)\s+(\w+)/g);
for (const m of interfaces) {
lines.push(` type ${m[1]}`);
}
// Classes
const classes = content.matchAll(/export\s+class\s+(\w+)/g);
for (const m of classes) {
lines.push(` class ${m[1]}`);
}
// Functions
const fns = content.matchAll(/export\s+(?:async\s+)?function\s+(\w+)/g);
for (const m of fns) {
lines.push(` fn ${m[1]}`);
}
return lines.join("\n");
}
function extractGenericSummary(content: string): string {
// Fallback: count lines, extract function-like patterns
const fns = content.matchAll(/(?:def|fn|func|function|sub)\s+(\w+)/g);
const names: string[] = [];
for (const m of fns) {
names.push(m[1]);
}
if (names.length === 0) return "";
return ` functions: ${names.join(", ")}`;
}
/**
* Extract a block of code starting from an opening brace position.
* Returns content between the first { and matching }.
*/
function extractBlock(content: string, startIdx: number): string {
let depth = 1;
let i = startIdx;
while (i < content.length && depth > 0) {
if (content[i] === "{") depth++;
else if (content[i] === "}") depth--;
i++;
}
return content.slice(startIdx, i - 1);
}
/**
* Build an import/dependency graph (Rust-specific, with fallbacks).
*/
export function buildImportGraph(projectPath: string, files: string[], language: string): ImportGraph {
const modules = new Map<string, string[]>();
if (language === "rust") {
for (const file of files) {
const fullPath = join(projectPath, file);
if (!existsSync(fullPath)) continue;
let content: string;
try {
content = readFileSync(fullPath, "utf-8");
} catch {
continue;
}
const deps: string[] = [];
// use crate:: imports
const uses = content.matchAll(/use\s+crate::(\w+)/g);
for (const m of uses) {
deps.push(m[1]);
}
// mod declarations
const mods = content.matchAll(/(?:pub\s+)?mod\s+(\w+)\s*;/g);
for (const m of mods) {
deps.push(m[1]);
}
if (deps.length > 0) {
modules.set(file, [...new Set(deps)]);
}
}
}
return { modules };
}
/**
* Gather all structural context for a project.
*/
export function gatherStructuralContext(
projectPath: string,
config: ProjectConfig
): StructuralContext {
const files = getFileTree(projectPath, config);
const astSummaries = extractAstSummaries(projectPath, files, config.language);
const importGraph = buildImportGraph(projectPath, files, config.language);
const gitLog = getGitLog(projectPath, 90);
const gitChurn = getFileChurn(projectPath, 30);
// Read CLAUDE.md / AGENTS.md if they exist
let claudeMd: string | null = null;
let agentsMd: string | null = null;
const claudeMdPath = join(projectPath, "CLAUDE.md");
if (existsSync(claudeMdPath)) {
claudeMd = readFileSync(claudeMdPath, "utf-8");
}
const agentsMdPath = join(projectPath, "AGENTS.md");
if (existsSync(agentsMdPath)) {
agentsMd = readFileSync(agentsMdPath, "utf-8");
}
return {
fileTree: files,
astSummaries,
importGraph,
gitLog,
gitChurn,
claudeMd,
agentsMd,
};
}

151
src/synthesizer.ts Normal file
View File

@@ -0,0 +1,151 @@
import { readFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import { createSession, promptWithTimeout } from "./session.js";
import type { Config, WorkerResult, SynthesizerResult, SessionMetrics } from "./types.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROMPTS_DIR = join(__dirname, "..", "prompts");
function loadPrompt(name: string): string {
return readFileSync(join(PROMPTS_DIR, name), "utf-8");
}
/**
* Run the synthesizer to reconcile worker outputs and generate SKILL.md.
*/
export async function runSynthesizer(
projectPath: string,
config: Config,
projectName: string,
workerResults: WorkerResult[],
currentSkillMd: string | null
): Promise<{ result: SynthesizerResult; metrics: SessionMetrics }> {
const systemPrompt = loadPrompt("synthesizer.md");
const { session, metrics } = await createSession({
role: "synthesizer",
projectPath,
config,
systemPrompt,
});
const startTime = Date.now();
const userPrompt = buildSynthesizerPrompt(projectName, workerResults, currentSkillMd);
try {
const response = await promptWithTimeout(
session,
userPrompt,
config.limits.synthesizerTimeoutSeconds * 1000
);
metrics.durationMs = Date.now() - startTime;
const result = parseSynthesizerResponse(response, projectName);
return { result, metrics };
} catch (err: any) {
metrics.durationMs = Date.now() - startTime;
metrics.errors.push(`synthesizer: ${err.message}`);
// Fallback: generate a basic SKILL.md
console.error(`Synthesizer failed, generating basic SKILL.md: ${err.message}`);
return {
result: {
skillMd: generateFallbackSkillMd(projectName, workerResults),
fixes: [],
inconsistencies: [`Synthesizer failed: ${err.message}`],
},
metrics,
};
}
}
function buildSynthesizerPrompt(
projectName: string,
workerResults: WorkerResult[],
currentSkillMd: string | null
): string {
const parts: string[] = [];
parts.push(`## Project: ${projectName}`);
parts.push("");
for (const w of workerResults) {
if (w.status === "success") {
parts.push(`## Updated: ${w.target}`);
parts.push(w.content);
parts.push("");
} else {
parts.push(`## Failed: ${w.target} (keeping existing)`);
parts.push(`Error: ${w.error}`);
parts.push("");
}
}
if (currentSkillMd) {
parts.push("## Current SKILL.md");
parts.push(currentSkillMd);
} else {
parts.push("## No existing SKILL.md — generate from scratch");
}
return parts.join("\n");
}
function parseSynthesizerResponse(response: string, projectName: string): SynthesizerResult {
// Try to parse JSON response
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
try {
const parsed = JSON.parse(jsonMatch[0]);
return {
skillMd: parsed.skill_md ?? parsed.skillMd ?? generateFallbackSkillMd(projectName, []),
fixes: (parsed.fixes ?? []).map((f: any) => ({
file: f.file,
description: f.description ?? "",
before: f.before ?? "",
after: f.after ?? "",
})),
inconsistencies: parsed.inconsistencies ?? [],
};
} catch {
// JSON parsing failed, treat entire response as SKILL.md
}
}
// If no valid JSON, treat the response as the SKILL.md content
return {
skillMd: response.trim(),
fixes: [],
inconsistencies: [],
};
}
function generateFallbackSkillMd(projectName: string, workerResults: WorkerResult[]): string {
const hasStructure = workerResults.some((w) => w.target === "structure.md" && w.status === "success");
const hasGuide = workerResults.some((w) => w.target === "guide.md" && w.status === "success");
const hasChangelog = workerResults.some((w) => w.target === "changelog.md" && w.status === "success");
const lines: string[] = [];
lines.push("---");
lines.push(`name: panopticon`);
lines.push(`description: >-`);
lines.push(` Auto-generated project overview for ${projectName}. Architecture, conventions,`);
lines.push(` and recent activity. Updated nightly by Panopticon.`);
lines.push("---");
lines.push("");
lines.push(`# ${projectName} — Project Overview`);
lines.push("");
lines.push("*Auto-generated by Panopticon*");
lines.push("");
lines.push("## Detailed Documentation");
lines.push("");
if (hasStructure) lines.push("- [Structure](structure.md) — modules, types, data flow, dependencies");
if (hasGuide) lines.push("- [Guide](guide.md) — conventions, patterns, anti-patterns, testing");
if (hasChangelog) lines.push("- [Changelog](changelog.md) — recent changes, active areas, stability");
lines.push("");
return lines.join("\n");
}

192
src/types.ts Normal file
View File

@@ -0,0 +1,192 @@
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
// ── Config types ──
export interface ProjectConfig {
name: string;
path: string;
language: string;
sourceGlobs: string[];
excludeGlobs: string[];
branch: string;
}
export interface ModelsConfig {
orchestrator: string; // "anthropic/claude-sonnet-4-5"
worker: string;
synthesizer: string;
}
export interface ThinkingConfig {
orchestrator: ThinkingLevel;
worker: ThinkingLevel;
synthesizer: ThinkingLevel;
}
export interface MetricsConfig {
enabled: boolean;
victoriaMetricsUrl: string;
jobLabel: string;
}
export interface LimitsConfig {
maxWorkerConcurrency: number;
maxDiffSizeBytes: number;
maxFilesPerWorkUnit: number;
workerTimeoutSeconds: number;
synthesizerTimeoutSeconds: number;
}
export interface Config {
projects: ProjectConfig[];
models: ModelsConfig;
thinkingLevels: ThinkingConfig;
metrics: MetricsConfig;
limits: LimitsConfig;
stateDir: string;
runsDir: string;
}
// ── State types ──
export interface ProjectState {
lastSha: string | null;
lastRunTimestamp: string | null;
lastRunStatus: "success" | "failure" | "partial" | null;
fileHashes: Record<string, string>;
docVersions: Record<string, string>;
}
// ── Orchestrator types ──
export interface WorkUnit {
target: "structure.md" | "guide.md" | "changelog.md";
reason: string;
relevantFiles: string[];
diffContext: string;
}
export interface OrchestratorResult {
skipReason: string | null;
updates: WorkUnit[];
}
// ── Worker types ──
export interface WorkerResult {
target: string;
content: string;
status: "success" | "failure";
error?: string;
metrics: SessionMetrics;
}
// ── Synthesizer types ──
export interface SynthesizerFix {
file: string;
description: string;
before: string;
after: string;
}
export interface SynthesizerResult {
skillMd: string;
fixes: SynthesizerFix[];
inconsistencies: string[];
}
// ── Metrics types ──
export interface SessionMetrics {
tokensIn: number;
tokensOut: number;
toolCalls: number;
errors: string[];
durationMs: number;
}
export interface MetricLine {
name: string;
labels: Record<string, string>;
value: number;
timestamp?: number;
}
// ── Structural types ──
export interface FileTreeResult {
files: string[];
}
export interface AstEntry {
file: string;
summary: string;
}
export interface ImportGraph {
modules: Map<string, string[]>;
}
export interface GitDiffResult {
diffStat: string;
diffContent: string;
commitLog: string;
filesChanged: string[];
insertions: number;
deletions: number;
commitCount: number;
}
export interface GitChurnEntry {
count: number;
file: string;
}
export interface StructuralContext {
fileTree: string[];
astSummaries: AstEntry[];
importGraph: ImportGraph;
gitLog: string;
gitChurn: GitChurnEntry[];
claudeMd: string | null;
agentsMd: string | null;
}
// ── Report types ──
export interface PhaseTimings {
git: number;
structural: number;
orchestrator: number;
workers: number;
synthesizer: number;
}
export interface ProjectRunReport {
project: string;
date: string;
status: "success" | "failure" | "partial" | "skipped";
duration: number;
commitCount: number;
filesChanged: number;
insertions: number;
deletions: number;
orchestratorDecision: OrchestratorResult | null;
workerResults: WorkerResult[];
synthesizerStatus: "success" | "failure" | "skipped";
docLinesChanged: Record<string, number>;
phaseTimings: PhaseTimings;
totalTokensIn: number;
totalTokensOut: number;
estimatedCost: number;
errors: string[];
}
export interface NightlyReport {
date: string;
projectReports: ProjectRunReport[];
totalDuration: number;
totalCost: number;
anomalies: string[];
}

327
src/worker.ts Normal file
View File

@@ -0,0 +1,327 @@
import { readFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import { createSession, promptWithTimeout } from "./session.js";
import type { Config, WorkUnit, WorkerResult, StructuralContext, SessionMetrics } from "./types.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROMPTS_DIR = join(__dirname, "..", "prompts");
function loadPrompt(name: string): string {
return readFileSync(join(PROMPTS_DIR, name), "utf-8");
}
const LINE_LIMITS: Record<string, { min: number; max: number }> = {
"structure.md": { min: 200, max: 500 },
"guide.md": { min: 100, max: 300 },
"changelog.md": { min: 100, max: 200 },
};
/**
* Run a worker for an incremental doc update.
*/
export async function runIncrementalWorker(
projectPath: string,
config: Config,
workUnit: WorkUnit,
currentDoc: string | null,
diffContent: string
): Promise<WorkerResult> {
const promptTemplate = loadPrompt("worker-update.md");
const limits = LINE_LIMITS[workUnit.target] ?? { min: 100, max: 300 };
const systemPrompt = promptTemplate
.replace("{min_lines}", String(limits.min))
.replace("{max_lines}", String(limits.max));
const { session, metrics } = await createSession({
role: "worker",
projectPath,
config,
systemPrompt,
});
const startTime = Date.now();
const userPrompt = buildIncrementalWorkerPrompt(workUnit, currentDoc, diffContent);
try {
const content = await promptWithTimeout(
session,
userPrompt,
config.limits.workerTimeoutSeconds * 1000
);
metrics.durationMs = Date.now() - startTime;
if (!content || content.trim().length < 20) {
return {
target: workUnit.target,
content: currentDoc ?? "",
status: "failure",
error: "Worker returned empty/near-empty output",
metrics,
};
}
return {
target: workUnit.target,
content: cleanMarkdownOutput(content),
status: "success",
metrics,
};
} catch (err: any) {
metrics.durationMs = Date.now() - startTime;
return {
target: workUnit.target,
content: currentDoc ?? "",
status: "failure",
error: err.message,
metrics,
};
}
}
/**
* Run a worker for full analysis of a specific doc section.
*/
export async function runFullAnalysisWorker(
projectPath: string,
config: Config,
target: "structure.md" | "guide.md" | "changelog.md",
structural: StructuralContext,
workUnit: WorkUnit
): Promise<WorkerResult> {
const promptName = `worker-${target.replace(".md", "")}.md`;
let systemPrompt: string;
try {
systemPrompt = loadPrompt(promptName);
} catch {
// Fall back to generic worker prompt
systemPrompt = loadPrompt("worker-structure.md");
}
const limits = LINE_LIMITS[target] ?? { min: 100, max: 300 };
systemPrompt = systemPrompt
.replace("{min_lines}", String(limits.min))
.replace("{max_lines}", String(limits.max));
const { session, metrics } = await createSession({
role: "worker",
projectPath,
config,
systemPrompt,
});
const startTime = Date.now();
const userPrompt = buildFullWorkerPrompt(target, structural, workUnit);
try {
const content = await promptWithTimeout(
session,
userPrompt,
config.limits.workerTimeoutSeconds * 1000
);
metrics.durationMs = Date.now() - startTime;
if (!content || content.trim().length < 20) {
return {
target,
content: "",
status: "failure",
error: "Worker returned empty/near-empty output",
metrics,
};
}
return {
target,
content: cleanMarkdownOutput(content),
status: "success",
metrics,
};
} catch (err: any) {
metrics.durationMs = Date.now() - startTime;
return {
target,
content: "",
status: "failure",
error: err.message,
metrics,
};
}
}
/**
* Run multiple workers concurrently, respecting concurrency limits.
*/
export async function runWorkersConcurrently<T>(
tasks: Array<() => Promise<T>>,
maxConcurrency: number
): Promise<T[]> {
const results: T[] = [];
const running: Promise<void>[] = [];
for (const task of tasks) {
const promise = task().then((result) => {
results.push(result);
});
running.push(promise);
if (running.length >= maxConcurrency) {
await Promise.race(running);
// Remove completed promises
for (let i = running.length - 1; i >= 0; i--) {
// Check if promise is settled by racing with an immediate resolve
const settled = await Promise.race([
running[i].then(() => true),
Promise.resolve(false),
]);
if (settled) running.splice(i, 1);
}
}
}
await Promise.all(running);
return results;
}
function buildIncrementalWorkerPrompt(
workUnit: WorkUnit,
currentDoc: string | null,
diffContent: string
): string {
const parts: string[] = [];
parts.push("## Current Documentation");
if (currentDoc) {
parts.push(currentDoc);
} else {
parts.push("*No existing documentation for this section.*");
}
parts.push("");
parts.push("## What Changed");
parts.push(workUnit.reason);
parts.push("");
parts.push("## Diff Context");
parts.push(workUnit.diffContext || diffContent.slice(0, 50000));
parts.push("");
parts.push("## Relevant Files to Examine");
parts.push("Use the read tool to examine these files for context:");
for (const f of workUnit.relevantFiles) {
parts.push(`- ${f}`);
}
return parts.join("\n");
}
function buildFullWorkerPrompt(
target: string,
structural: StructuralContext,
workUnit: WorkUnit
): string {
const parts: string[] = [];
parts.push("## File Tree");
parts.push(structural.fileTree.slice(0, 300).join("\n"));
parts.push("");
if (target === "structure.md") {
parts.push("## AST Summaries");
for (const entry of structural.astSummaries) {
parts.push(`### ${entry.file}`);
parts.push(entry.summary);
parts.push("");
}
parts.push("## Import Graph");
for (const [mod, deps] of structural.importGraph.modules) {
parts.push(`${mod}${deps.join(", ")}`);
}
parts.push("");
}
if (target === "guide.md") {
if (structural.claudeMd) {
parts.push("## Existing CLAUDE.md");
parts.push(structural.claudeMd);
parts.push("");
}
if (structural.agentsMd) {
parts.push("## Existing AGENTS.md");
parts.push(structural.agentsMd);
parts.push("");
}
parts.push("## AST Summaries (for pattern detection)");
for (const entry of structural.astSummaries.slice(0, 30)) {
parts.push(`### ${entry.file}`);
parts.push(entry.summary);
parts.push("");
}
}
if (target === "changelog.md") {
parts.push("## Git Log (last 90 days)");
parts.push(structural.gitLog);
parts.push("");
parts.push("## File Churn (last 30 days)");
for (const entry of structural.gitChurn) {
parts.push(` ${entry.count}\t${entry.file}`);
}
parts.push("");
}
if (workUnit.relevantFiles.length > 0) {
parts.push("## Key Files to Examine");
parts.push("Use the read tool to examine these files:");
for (const f of workUnit.relevantFiles.slice(0, 20)) {
parts.push(`- ${f}`);
}
}
return parts.join("\n");
}
/**
* Clean markdown output: remove wrapping code fences and LLM preamble.
*/
function cleanMarkdownOutput(content: string): string {
let cleaned = content.trim();
// Remove ```markdown ... ``` wrapper
if (cleaned.startsWith("```markdown")) {
cleaned = cleaned.slice("```markdown".length);
if (cleaned.endsWith("```")) {
cleaned = cleaned.slice(0, -3);
}
cleaned = cleaned.trim();
} else if (cleaned.startsWith("```md")) {
cleaned = cleaned.slice("```md".length);
if (cleaned.endsWith("```")) {
cleaned = cleaned.slice(0, -3);
}
cleaned = cleaned.trim();
} else if (cleaned.startsWith("```")) {
cleaned = cleaned.slice(3);
if (cleaned.endsWith("```")) {
cleaned = cleaned.slice(0, -3);
}
cleaned = cleaned.trim();
}
// Strip LLM preamble: any text before the first markdown heading.
// Workers are instructed to start directly with a heading, so anything
// before the first "# " line is conversational fluff.
const firstHeading = cleaned.search(/^# /m);
if (firstHeading > 0) {
cleaned = cleaned.slice(firstHeading).trim();
}
return cleaned;
}

80
src/writer.ts Normal file
View File

@@ -0,0 +1,80 @@
import { writeFileSync, mkdirSync, existsSync, readFileSync, readdirSync, unlinkSync } from "fs";
import { join } from "path";
const SKILL_DIR = ".pi/skills/panopticon";
/**
* Write the complete skill directory for a project.
*/
export function writeSkillFiles(
projectPath: string,
files: Record<string, string>
): void {
const skillDir = join(projectPath, SKILL_DIR);
mkdirSync(skillDir, { recursive: true });
for (const [filename, content] of Object.entries(files)) {
const filePath = join(skillDir, filename);
writeFileSync(filePath, content);
}
}
/**
* Read an existing skill file if it exists.
*/
export function readSkillFile(projectPath: string, filename: string): string | null {
const filePath = join(projectPath, SKILL_DIR, filename);
if (!existsSync(filePath)) return null;
try {
return readFileSync(filePath, "utf-8");
} catch {
return null;
}
}
/**
* Read all existing skill files.
*/
export function readAllSkillFiles(projectPath: string): Record<string, string> {
const files: Record<string, string> = {};
for (const name of ["SKILL.md", "structure.md", "guide.md", "changelog.md"]) {
const content = readSkillFile(projectPath, name);
if (content) {
files[name] = content;
}
}
return files;
}
/**
* Remove all generated .md files in the skill directory except SKILL.md.
* Used before full analysis to wipe stale documents (e.g. after renames).
*/
export function cleanSkillDir(projectPath: string): string[] {
const skillDir = join(projectPath, SKILL_DIR);
if (!existsSync(skillDir)) return [];
const removed: string[] = [];
for (const entry of readdirSync(skillDir)) {
if (entry === "SKILL.md") continue;
if (entry.endsWith(".md")) {
unlinkSync(join(skillDir, entry));
removed.push(entry);
}
}
return removed;
}
/**
* Count lines changed between old and new content.
*/
export function countLinesChanged(oldContent: string | null, newContent: string): number {
if (!oldContent) return newContent.split("\n").length;
const oldLines = new Set(oldContent.split("\n"));
const newLines = newContent.split("\n");
let changed = 0;
for (const line of newLines) {
if (!oldLines.has(line)) changed++;
}
return changed;
}

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}