panopticon init
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
state/
|
||||
runs/
|
||||
*.log
|
||||
45
CLAUDE.md
Normal file
45
CLAUDE.md
Normal 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
36
config.json
Normal 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
122
grafana/dashboard.json
Normal 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
3553
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
56
prompts/orchestrator-full.md
Normal file
56
prompts/orchestrator-full.md
Normal 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.
|
||||
47
prompts/orchestrator-incremental.md
Normal file
47
prompts/orchestrator-incremental.md
Normal 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
79
prompts/synthesizer.md
Normal 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.
|
||||
69
prompts/worker-changelog.md
Normal file
69
prompts/worker-changelog.md
Normal 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
75
prompts/worker-guide.md
Normal 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`.
|
||||
72
prompts/worker-structure.md
Normal file
72
prompts/worker-structure.md
Normal 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
20
prompts/worker-update.md
Normal 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
67
src/config.ts
Normal 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
149
src/git.ts
Normal 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
584
src/index.ts
Normal 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
70
src/metrics.ts
Normal 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
212
src/orchestrator.ts
Normal 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
157
src/reporter.ts
Normal 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
153
src/session.ts
Normal 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
33
src/state.ts
Normal 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
240
src/structural.ts
Normal 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
151
src/synthesizer.ts
Normal 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
192
src/types.ts
Normal 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
327
src/worker.ts
Normal 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
80
src/writer.ts
Normal 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
18
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user