Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install ruff
run: pip install ruff
- name: Ruff check
run: ruff check afterburn/ tests/
- name: Ruff format check
run: ruff format --check afterburn/ tests/

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install
run: |
pip install -e .
pip install pytest
- name: Test
run: pytest tests/ -v

leak-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Scan for accidental leaks
shell: bash
run: |
set -e
# Patterns: editable-install pseudo-pins, host paths, API keys, private keys.
# Excludes node_modules, .git, .venv, the workflows dir itself.
PATTERN='UNKNOWN @ file://|/home/[a-z][a-z0-9_-]+/(orchestrator|models)/|sk-ant-api03-[A-Za-z0-9_-]{20,}|sk-proj-[A-Za-z0-9_-]{20,}|-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----'
if git ls-files | grep -vE '^\.github/workflows/' | xargs -r grep -EHn "$PATTERN" 2>/dev/null; then
echo "::error::Leak guard found suspicious patterns above"
exit 1
fi
echo "Leak guard clean"
18 changes: 14 additions & 4 deletions afterburn/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ def run_archive(args) -> None:
"""Archive old sessions and clean history."""
cwd = args.cwd if hasattr(args, "cwd") and args.cwd else os.getcwd()
slug = _current_project_slug(cwd)
sessions_base = Path(args.sessions_dir) if hasattr(args, "sessions_dir") and args.sessions_dir else default_sessions_dir()
sessions_base = (
Path(args.sessions_dir)
if hasattr(args, "sessions_dir") and args.sessions_dir
else default_sessions_dir()
)
project_dir = sessions_base / slug

if not project_dir.exists():
Expand All @@ -126,20 +130,26 @@ def run_archive(args) -> None:
sys.exit(0)

total_size = sum(f.stat().st_size for f in stale)
print(f"Found {len(stale)} sessions older than {max_age} days ({total_size / 1024 / 1024:.1f}MB)")
print(
f"Found {len(stale)} sessions older than {max_age} days ({total_size / 1024 / 1024:.1f}MB)"
)

if args.dry_run:
print("\n[dry-run] Would archive:")
for f in stale:
age_days = (time.time() - f.stat().st_mtime) / 86400
print(f" {f.name} ({f.stat().st_size / 1024:.0f}KB, {age_days:.0f} days old)")
print(
f" {f.name} ({f.stat().st_size / 1024:.0f}KB, {age_days:.0f} days old)"
)
return

# Archive
archive_path = _archive_sessions(stale, project_dir)
archive_size = archive_path.stat().st_size
print(f"\nArchived to: {archive_path}")
print(f" {total_size / 1024 / 1024:.1f}MB → {archive_size / 1024 / 1024:.1f}MB ({archive_size / total_size * 100:.0f}%)")
print(
f" {total_size / 1024 / 1024:.1f}MB → {archive_size / 1024 / 1024:.1f}MB ({archive_size / total_size * 100:.0f}%)"
)

# Collect session IDs before deleting
session_ids = {f.stem for f in stale}
Expand Down
36 changes: 27 additions & 9 deletions afterburn/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ def main():
choices=["friction", "patterns", "gaps", "releases"],
help="Run a single analysis pass (default: all three)",
)
discover_parser.add_argument("--since", help="Only analyze sessions after this date (YYYY-MM-DD)")
discover_parser.add_argument(
"--since", help="Only analyze sessions after this date (YYYY-MM-DD)"
)
discover_parser.add_argument("--project", help="Filter to a specific project slug")
discover_parser.add_argument(
"--sessions-dir",
Expand Down Expand Up @@ -57,21 +59,27 @@ def main():
)

# evolve
evolve_parser = subparsers.add_parser("evolve", help="Evolve a skill via experiment loop")
evolve_parser = subparsers.add_parser(
"evolve", help="Evolve a skill via experiment loop"
)
evolve_parser.add_argument("--skill", required=True, help="Skill name to evolve")
evolve_parser.add_argument(
"--max-iterations",
type=int,
default=10,
help="Maximum experiment iterations (default: 10)",
)
evolve_parser.add_argument("--dry-run", action="store_true", help="Show plan without executing")
evolve_parser.add_argument(
"--dry-run", action="store_true", help="Show plan without executing"
)

# status
subparsers.add_parser("status", help="Show last run summary")

# archive
archive_parser = subparsers.add_parser("archive", help="Archive old sessions and clean history")
archive_parser = subparsers.add_parser(
"archive", help="Archive old sessions and clean history"
)
archive_parser.add_argument(
"--days",
type=int,
Expand All @@ -86,11 +94,17 @@ def main():
"--cwd",
help="Working directory to derive project slug from (default: current directory)",
)
archive_parser.add_argument("--dry-run", action="store_true", help="Show what would be archived")
archive_parser.add_argument(
"--dry-run", action="store_true", help="Show what would be archived"
)

# narrative
narrative_parser = subparsers.add_parser("narrative", help="Generate a narrative session report")
narrative_parser.add_argument("--today", action="store_true", help="Today's sessions only")
narrative_parser = subparsers.add_parser(
"narrative", help="Generate a narrative session report"
)
narrative_parser.add_argument(
"--today", action="store_true", help="Today's sessions only"
)
narrative_parser.add_argument("--week", action="store_true", help="Last 7 days")
narrative_parser.add_argument("--month", action="store_true", help="Last 30 days")
narrative_parser.add_argument("--since", help="Sessions since date (YYYY-MM-DD)")
Expand All @@ -104,10 +118,14 @@ def main():
help="Directory path — include all projects under this path",
)
narrative_parser.add_argument("--sessions-dir", help="Custom session directory")
narrative_parser.add_argument("--no-llm", action="store_true", help="Stats only, no LLM narrative generation")
narrative_parser.add_argument(
"--no-llm", action="store_true", help="Stats only, no LLM narrative generation"
)

# install
install_parser = subparsers.add_parser("install", help="Install Claude Code slash commands")
install_parser = subparsers.add_parser(
"install", help="Install Claude Code slash commands"
)
install_parser.add_argument(
"--global",
dest="global_install",
Expand Down
57 changes: 38 additions & 19 deletions afterburn/dead_releases.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
import json
import re
import subprocess
import sys
from pathlib import Path

from afterburn.findings import Finding
from afterburn.scanner import SessionInfo
Expand Down Expand Up @@ -111,11 +109,17 @@ def find_new_symbols_in_range(
Returns a list of dicts with keys: name, file, kind (function/class).
"""
if previous_tag:
diff_output = _run_git(repo_path, "diff", f"{previous_tag}..{tag}", "--unified=0")
diff_output = _run_git(
repo_path, "diff", f"{previous_tag}..{tag}", "--unified=0"
)
else:
# First tag — diff against empty tree
diff_output = _run_git(
repo_path, "diff", "4b825dc642cb6eb9a060e54bf899d15f7b422b10", tag, "--unified=0"
repo_path,
"diff",
"4b825dc642cb6eb9a060e54bf899d15f7b422b10",
tag,
"--unified=0",
)

if not diff_output:
Expand Down Expand Up @@ -147,15 +151,30 @@ def find_new_symbols_in_range(
# Skip private/dunder
if name.startswith("_"):
continue
kind = "class" if line.strip().startswith("+class") or line.strip().startswith("+ class") else "function"
kind = (
"class"
if line.strip().startswith("+class")
or line.strip().startswith("+ class")
else "function"
)
symbols.append({"name": name, "file": current_file, "kind": kind})

# TypeScript/JavaScript patterns
elif current_file.endswith((".ts", ".tsx", ".js", ".jsx")):
m = TS_DEF_PATTERN.match(line)
if m:
name = m.group(1)
if name in ("if", "else", "for", "while", "return", "switch", "case", "try", "catch"):
if name in (
"if",
"else",
"for",
"while",
"return",
"switch",
"case",
"try",
"catch",
):
continue
kind = "class" if "class " in line else "function"
symbols.append({"name": name, "file": current_file, "kind": kind})
Expand Down Expand Up @@ -188,9 +207,7 @@ def detect_dead_releases(repo_path: str) -> list[Finding]:
return Findings for any dead code at release time.
"""
# Get all version tags sorted by creation date
tag_output = _run_git(
repo_path, "tag", "--list", "--sort=-version:refname"
)
tag_output = _run_git(repo_path, "tag", "--list", "--sort=-version:refname")
if not tag_output:
return []

Expand Down Expand Up @@ -220,15 +237,17 @@ def detect_dead_releases(repo_path: str) -> list[Finding]:

if dead_symbols:
names = [f"{s['name']} ({s['kind']} in {s['file']})" for s in dead_symbols]
findings.append(Finding(
type="friction",
description=f"Dead code shipped in {tag}: {len(dead_symbols)} unwired symbol(s)",
confidence=min(1.0, len(dead_symbols) / max(len(symbols), 1)),
frequency=len(dead_symbols),
sessions=[],
evidence=f"Symbols with zero callers at release: {', '.join(names[:10])}",
verification=f"git diff {previous_tag + '..' + tag if previous_tag else tag} --stat",
theme="dead_release",
))
findings.append(
Finding(
type="friction",
description=f"Dead code shipped in {tag}: {len(dead_symbols)} unwired symbol(s)",
confidence=min(1.0, len(dead_symbols) / max(len(symbols), 1)),
frequency=len(dead_symbols),
sessions=[],
evidence=f"Symbols with zero callers at release: {', '.join(names[:10])}",
verification=f"git diff {previous_tag + '..' + tag if previous_tag else tag} --stat",
theme="dead_release",
)
)

return findings
27 changes: 21 additions & 6 deletions afterburn/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ def run_discover(args) -> None:
print(f"Found {len(sessions)} sessions ({total_size / 1024 / 1024:.1f}MB)")

output_dir = Path(".afterburn")
passes = [args.analysis_pass] if args.analysis_pass else ["friction", "patterns", "gaps", "releases"]
passes = (
[args.analysis_pass]
if args.analysis_pass
else ["friction", "patterns", "gaps", "releases"]
)
all_findings: list[Finding] = []

for pass_name in passes:
Expand All @@ -50,20 +54,27 @@ def run_discover(args) -> None:
write_findings(all_findings, output_dir, fmt=args.format)

# Generate skill candidates from correction taxonomy
from afterburn.passes import extract_taxonomy_from_findings, generate_skill_candidates
from afterburn.passes import (
extract_taxonomy_from_findings,
generate_skill_candidates,
)

taxonomy_counts = extract_taxonomy_from_findings(all_findings)
skill_candidates = generate_skill_candidates(taxonomy_counts)
if skill_candidates:
write_skill_candidates(skill_candidates, output_dir)
print(f" Generated {len(skill_candidates)} skill candidates from correction taxonomy")
print(
f" Generated {len(skill_candidates)} skill candidates from correction taxonomy"
)

_write_provenance(output_dir, sessions, passes)

print(f"\nResults written to {output_dir}/")


def _run_pass(pass_name: str, sessions: list[SessionInfo], max_calls: int = 1000) -> list[Finding]:
def _run_pass(
pass_name: str, sessions: list[SessionInfo], max_calls: int = 1000
) -> list[Finding]:
"""Run a single analysis pass."""
from afterburn.passes import run_friction_pass, run_gaps_pass, run_patterns_pass

Expand All @@ -86,7 +97,9 @@ def _run_pass(pass_name: str, sessions: list[SessionInfo], max_calls: int = 1000
return []


def _write_provenance(output_dir: Path, sessions: list[SessionInfo], passes: list[str]) -> None:
def _write_provenance(
output_dir: Path, sessions: list[SessionInfo], passes: list[str]
) -> None:
"""Write provenance metadata."""
from datetime import datetime, timezone

Expand All @@ -113,7 +126,9 @@ def show_status() -> None:
prov = json.load(f)

print(f"Last run: {prov['analyzed_at']}")
print(f"Sessions: {prov['sessions_count']} ({prov['total_bytes'] / 1024 / 1024:.1f}MB)")
print(
f"Sessions: {prov['sessions_count']} ({prov['total_bytes'] / 1024 / 1024:.1f}MB)"
)
print(f"Passes: {', '.join(prov['passes'])}")

for name in ["fix-list.md", "pattern-catalog.md", "skill-gaps.md"]:
Expand Down
16 changes: 12 additions & 4 deletions afterburn/findings.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ def to_json(self) -> str:
def to_markdown(self) -> str:
lines = [f"### {self.description}"]
lines.append("")
lines.append(f"**Type**: {self.type} | **Confidence**: {self.confidence:.2f} | **Frequency**: {self.frequency}")
lines.append(
f"**Type**: {self.type} | **Confidence**: {self.confidence:.2f} | **Frequency**: {self.frequency}"
)
lines.append(f"**Theme**: {self.theme}")
lines.append(f"**Sessions**: {len(self.sessions)} sessions")
if self.evidence:
Expand All @@ -69,7 +71,9 @@ def to_dict(self) -> dict:
return asdict(self)


def write_skill_candidates(candidates: list["SkillCandidate"], output_dir: Path) -> None:
def write_skill_candidates(
candidates: list["SkillCandidate"], output_dir: Path
) -> None:
"""Write skill candidates to output directory.

Generates a markdown summary and individual skill drafts.
Expand Down Expand Up @@ -108,7 +112,9 @@ def write_skill_candidates(candidates: list["SkillCandidate"], output_dir: Path)
skill_path.write_text(c.draft_skill_md)


def write_findings(findings: list[Finding], output_dir: Path, fmt: str = "markdown") -> None:
def write_findings(
findings: list[Finding], output_dir: Path, fmt: str = "markdown"
) -> None:
"""Write findings to output files."""
output_dir.mkdir(parents=True, exist_ok=True)

Expand All @@ -133,7 +139,9 @@ def write_findings(findings: list[Finding], output_dir: Path, fmt: str = "markdo
path = output_dir / f"{basename}.md"
with open(path, "w") as fh:
fh.write(f"# {finding_type.title()} Findings\n\n")
fh.write(f"*{len(items)} findings across {len(set(s for i in items for s in i.sessions))} sessions*\n\n")
fh.write(
f"*{len(items)} findings across {len(set(s for i in items for s in i.sessions))} sessions*\n\n"
)
for item in sorted(items, key=lambda x: x.frequency, reverse=True):
fh.write(item.to_markdown())
fh.write("---\n\n")
Loading
Loading