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