Merge pull request #49 from AIOSAI/feat/session-29-cleanup

fix(system): session 29 cleanup — governance, ruff config, branch work
This commit is contained in:
AIPass
2026-03-14 23:43:10 -07:00
committed by GitHub
13 changed files with 266 additions and 209 deletions
+2
View File
@@ -80,6 +80,8 @@ drone @ai_mail dispatch wake --fresh @target # Fresh session
- `--dispatch` = recipient must ACT (tasks, bugs, investigations)
- No flag = just informing (FYI, status updates)
**Always reply to dispatch emails.** When devpulse or another branch sends you work, they're waiting for a response. Complete the task, then email back with results. No silent completions — if someone dispatched you, they need to know what happened.
## How to Work
**Always plan before executing.** Create an FPLAN before building anything non-trivial. The plan is your continuity — if you get sidetracked, the plan remembers where you were.
+4 -4
View File
@@ -1,8 +1,8 @@
# Pre-Compact Prep
# Session Wrap-Up
Purpose: Button up everything before a manual /compact. Memories, plans, git — all tidy so the compacted summary has clean state to work from.
Purpose: Button up everything at the end of a session — or before a /compact. Memories, plans, git — all tidy. Works for both closing out a chat and preparing for compaction.
**Workflow:** `/prep` → review output → `/compact`
**Workflow:** `/prep` → review output → close chat or `/compact`
## Execution
@@ -49,5 +49,5 @@ Prep complete:
- Inbox: [count, action taken]
- Loose ends: [any flagged]
Ready for /compact.
Ready to close out or /compact.
```
+2
View File
@@ -5,6 +5,8 @@
__pycache__/
*.pyc
*.egg-info/
.pytest_cache/
.ruff_cache/
# ChromaDB
.chroma/
+6
View File
@@ -75,3 +75,9 @@ exclude_lines = [
[tool.ruff]
line-length = 120
[tool.ruff.lint]
ignore = ["E402"]
[tool.ruff.lint.per-file-ignores]
"**/__init__.py" = ["F401"]
@@ -31,7 +31,7 @@ def _get_write_section():
"""Lazy import write_section from devpulse module API."""
global _write_section
if _write_section is None:
from aipass.devpulse.apps.modules.dashboard import write_section
from aipass.prax.apps.modules.dashboard import write_section
_write_section = write_section
return _write_section
@@ -28,7 +28,8 @@ from typing import Dict, Set, List, Optional
from aipass.backup.apps.handlers.config.ignore_patterns import (
GLOBAL_IGNORE_PATTERNS, IGNORE_EXCEPTIONS, should_ignore,
filter_tracked_items, get_ignore_patterns, get_cli_tracking_patterns,
DIFF_IGNORE_PATTERNS, DIFF_INCLUDE_PATTERNS, CLI_TRACKING_PATTERNS
DIFF_IGNORE_PATTERNS, DIFF_INCLUDE_PATTERNS, CLI_TRACKING_PATTERNS,
SOURCE_WHITELIST, MAX_FILE_SIZE_MB
)
# =============================================
@@ -68,6 +68,8 @@ IGNORE_EXCEPTIONS: List[str] = _data["ignore_exceptions"]["patterns"]
CLI_TRACKING_PATTERNS: List[str] = _data["cli_tracking_patterns"]["patterns"]
DIFF_IGNORE_PATTERNS: List[str] = _data["diff_ignore_patterns"]["patterns"]
DIFF_INCLUDE_PATTERNS: List[str] = _data["diff_include_patterns"]["patterns"]
SOURCE_WHITELIST: List[str] = _data.get("source_whitelist", {}).get("directories", [])
MAX_FILE_SIZE_MB: int = _data.get("max_file_size_mb", {}).get("value", 100)
# =============================================
# HELPER FUNCTIONS
@@ -209,4 +211,6 @@ def should_ignore(path: Path, ignore_patterns: Optional[List[str]] = None,
logger.info(f"[ignore_patterns] Module loaded — {len(GLOBAL_IGNORE_PATTERNS)} global patterns, "
f"{len(IGNORE_EXCEPTIONS)} exceptions, {len(DIFF_IGNORE_PATTERNS)} diff-ignore, "
f"{len(DIFF_INCLUDE_PATTERNS)} diff-include")
f"{len(DIFF_INCLUDE_PATTERNS)} diff-include, "
f"whitelist: {SOURCE_WHITELIST if SOURCE_WHITELIST else '(all)'}, "
f"max_file_size: {MAX_FILE_SIZE_MB}MB")
@@ -18,27 +18,57 @@ Scans source directory for files to backup, applying ignore patterns.
import os
from pathlib import Path
from typing import Callable
from typing import Callable, List, Optional
# =============================================
# FILE SCANNING OPERATIONS
# =============================================
def scan_files(source_dir: Path, should_ignore: Callable, show_progress: bool = False) -> tuple[list[Path], dict]:
def scan_files(source_dir: Path, should_ignore: Callable, show_progress: bool = False,
whitelist: Optional[List[str]] = None,
max_file_size_mb: int = 0) -> tuple[list[Path], dict]:
"""Scan source directory for files to backup.
Args:
source_dir: Root directory to scan
should_ignore: Function to check if path should be ignored
show_progress: Whether to show progress spinner (default: False)
whitelist: Top-level directory names to scan. Empty/None = scan all (backwards compatible).
max_file_size_mb: Skip files larger than this (MB). 0 = no limit.
Returns:
Tuple of (file_list, skipped_items_dict)
- file_list: List of Path objects for files to backup
- skipped_items: Dict with 'directories' and 'files' sets
- skipped_items: Dict with 'directories', 'files', and 'too_large' sets
"""
files_to_backup = []
skipped_items = {"directories": set(), "files": set()}
skipped_items = {"directories": set(), "files": set(), "too_large": set()}
max_bytes = max_file_size_mb * 1024 * 1024 if max_file_size_mb > 0 else 0
source_dir_str = str(source_dir)
def _apply_whitelist(dirpath: str, dirnames: list) -> None:
"""At the top level only, filter dirnames to whitelist entries."""
if whitelist and dirpath == source_dir_str:
whitelist_set = set(whitelist)
removed = [d for d in dirnames if d not in whitelist_set]
dirnames[:] = [d for d in dirnames if d in whitelist_set]
for d in removed:
rel_dir = str(Path(dirpath).relative_to(source_dir) / d)
skipped_items["directories"].add(rel_dir)
def _check_file_size(file_path: Path) -> bool:
"""Check if file exceeds size cap. Returns True if too large."""
if max_bytes <= 0:
return False
try:
size = file_path.stat().st_size
if size > max_bytes:
rel_file = str(file_path.relative_to(source_dir))
skipped_items["too_large"].add((rel_file, size))
return True
except OSError:
pass
return False
if show_progress:
from rich.progress import Progress, SpinnerColumn, TextColumn
@@ -56,6 +86,9 @@ def scan_files(source_dir: Path, should_ignore: Callable, show_progress: bool =
# Walk directory tree
for dirpath, dirnames, filenames in os.walk(source_dir):
# Apply whitelist at top level
_apply_whitelist(dirpath, dirnames)
# Filter directories (modify in-place to prune walk)
original_dirs = dirnames.copy()
dirnames[:] = [d for d in dirnames if not should_ignore(Path(dirpath) / d)]
@@ -73,6 +106,8 @@ def scan_files(source_dir: Path, should_ignore: Callable, show_progress: bool =
if should_ignore(file_path):
rel_file = str(file_path.relative_to(source_dir))
skipped_items["files"].add(rel_file)
elif _check_file_size(file_path):
pass # Already tracked in too_large
else:
files_to_backup.append(file_path)
# Update spinner description with count occasionally
@@ -85,6 +120,9 @@ def scan_files(source_dir: Path, should_ignore: Callable, show_progress: bool =
pass # Skip inaccessible paths silently
for dirpath, dirnames, filenames in os.walk(source_dir, onerror=_walk_error):
# Apply whitelist at top level
_apply_whitelist(dirpath, dirnames)
# Filter directories (modify in-place to prune walk)
original_dirs = dirnames.copy()
dirnames[:] = [d for d in dirnames if not should_ignore(Path(dirpath) / d)]
@@ -102,6 +140,8 @@ def scan_files(source_dir: Path, should_ignore: Callable, show_progress: bool =
if should_ignore(file_path):
rel_file = str(file_path.relative_to(source_dir))
skipped_items["files"].add(rel_file)
elif _check_file_size(file_path):
pass # Already tracked in too_large
elif file_path.exists():
files_to_backup.append(file_path)
@@ -62,6 +62,10 @@
".backup",
".antigravity",
".gemini",
".docker",
".expo",
".password-store",
".copilot",
"snap",
".trinity",
@@ -78,16 +82,7 @@
".idea",
".eclipse",
".claude/todos",
".claude/shell-snapshots",
".claude/ide",
".claude/statsig",
".claude/.credentials.json",
".claude/debug",
".claude/file-history",
".claude/history.jsonl",
".claude/.update.lock",
".claude/.projects",
".claude",
".serena/logs",
".code",
@@ -171,7 +166,7 @@
"aipass_internal": "AIPass internal state (runtime, not user data): .trinity (passport, local, observations), .ai_mail.local (mailbox dirs), .archive, backup_data (runtime state), backup_json (metadata tracking), DASHBOARD.local.json, CLOSED_PLANS.local.json, STATUS.local.md",
"version_control": "Version control (huge number of files!): .git repos are version controlled elsewhere",
"ide_dirs": "IDE and editor directories: .idea (IntelliJ), .eclipse",
"claude_session": "Claude Code session files (change every session) — synced from .gitignore: .claude/todos, .claude/shell-snapshots, .claude/ide, .claude/statsig, .claude/.credentials.json, .claude/debug, .claude/file-history, .claude/history.jsonl, .claude/.update.lock, .claude/.projects, .serena/logs, .code",
"claude_code": "Claude Code — ignore entire .claude/ directory (projects, cache, session data). Exceptions in ignore_exceptions for: hooks/, hook_logger.sh, settings.json, settings.local.json, statusline.sh. Also .serena/logs, .code",
"dev_cache": "Development cache/build directories: .npm, .cargo, .rustup, .gem, .gradle, .m2",
"app_data": "Application data we don't need in backups: .thunderbird, .wine, .steam, .zoom",
"user_dirs": "User directories that shouldn't be backed up: Downloads, Videos, Pictures, Dropbox, system_logs, external_repos (version controlled elsewhere), mcp_servers/* (external MCP server repos)",
@@ -190,6 +185,11 @@
"*.local.md",
".vscode/settings.json",
"*/.claude/settings.local.json",
".claude/hooks/**",
".claude/hook_logger.sh",
".claude/settings.json",
".claude/settings.local.json",
".claude/statusline.sh",
"tools/cleanup_configs/*.json",
"*_config.json",
"drone/commands/global/*.json",
@@ -217,7 +217,8 @@
"root_files": ".gitignore — Root .gitignore file should be backed up",
"aipass_session": "*.local.md — AIPass session tracking files (caught by .local pattern but should be backed up)",
"vscode": ".vscode/settings.json — VS Code settings",
"claude_settings": "*/.claude/settings.local.json — Claude local settings in any directory",
"claude_settings": "*/.claude/settings.local.json — Claude local settings in any project directory",
"claude_keep": ".claude/hooks/**, .claude/hook_logger.sh, .claude/settings.json, .claude/settings.local.json, .claude/statusline.sh — essential Claude Code config files to preserve",
"config_json": "*_config.json, tools/cleanup_configs/*.json, drone/commands/global/*.json, .claude.json, .mcp.json, .commands.json — Various config JSON files",
"nerd_dictation": ".config/nerd-dictation/* — Nerd dictation config",
"branch_registry": "BRANCH_REGISTRY.json — Core ecosystem registry — vital file",
@@ -278,5 +279,16 @@
"settings.json",
"settings.local.json"
]
},
"source_whitelist": {
"comments": "Top-level directories under home to scan. Empty = scan all (backwards compatible). Only these directories are entered — everything else is invisible.",
"directories": [
"Projects",
"Desktop"
]
},
"max_file_size_mb": {
"comments": "Files larger than this are skipped and reported. Safety net against backing up huge binaries/images.",
"value": 100
}
}
@@ -46,7 +46,9 @@ from aipass.backup.apps.handlers.config.config_handler import (
GLOBAL_IGNORE_PATTERNS,
IGNORE_EXCEPTIONS,
filter_tracked_items,
should_ignore
should_ignore,
SOURCE_WHITELIST,
MAX_FILE_SIZE_MB
)
from aipass.backup.apps.handlers.models.backup_models import BackupResult
from aipass.backup.apps.handlers.operations.file_operations import copy_file_with_structure, copy_versioned_file
@@ -388,7 +390,11 @@ class BackupEngine:
# HANDLER: Scan files
from aipass.backup.apps.handlers.operations.file_scanner import scan_files
files_to_backup, skipped_items = scan_files(self.source_dir, self.should_ignore)
files_to_backup, skipped_items = scan_files(
self.source_dir, self.should_ignore,
whitelist=SOURCE_WHITELIST,
max_file_size_mb=MAX_FILE_SIZE_MB
)
# HANDLER: Process files
from aipass.backup.apps.handlers.operations.path_builder import build_backup_path
@@ -0,0 +1,168 @@
# =================== AIPass ====================
# Name: test_pattern_scan.py
# Description: Pattern audit — scan home dir, report what passes ignore patterns
# Version: 1.0.0
# Created: 2026-03-14
# Modified: 2026-03-14
# =============================================
"""
Pattern Audit Scan
Diagnostic tool — uses the same ignore patterns and scanner as the real backup
to show what would be backed up. Reports file counts and sizes per top-level
directory, flags large files and long paths.
Usage:
python3 tests/test_pattern_scan.py
"""
from collections import defaultdict
from pathlib import Path
from rich.console import Console
from aipass.backup.apps.handlers.config.config_handler import (
GLOBAL_IGNORE_PATTERNS,
IGNORE_EXCEPTIONS,
should_ignore,
SOURCE_WHITELIST,
MAX_FILE_SIZE_MB,
)
from aipass.backup.apps.handlers.operations.file_scanner import scan_files
console = Console()
def fmt_size(b: int) -> str:
if b >= 1024 * 1024 * 1024:
return f"{b / (1024**3):.1f} GB"
if b >= 1024 * 1024:
return f"{b / (1024**2):.1f} MB"
if b >= 1024:
return f"{b / 1024:.1f} KB"
return f"{b} B"
def run_scan() -> None:
source_dir = Path.home()
large_file_threshold = 1 * 1024 * 1024 # 1MB
console.print()
console.print("[bold cyan]Pattern Audit Scan[/bold cyan]")
console.print(f" Source: {source_dir}")
console.print(f" Whitelist: {SOURCE_WHITELIST if SOURCE_WHITELIST else '(all directories)'}")
console.print(f" Max file size: {MAX_FILE_SIZE_MB} MB")
console.print(f" Large file threshold: {fmt_size(large_file_threshold)}")
console.print()
console.print("[dim]Scanning with current ignore patterns...[/dim]")
ignore_patterns = GLOBAL_IGNORE_PATTERNS
def check_ignore(path: Path) -> bool:
return should_ignore(path, ignore_patterns, IGNORE_EXCEPTIONS)
files, skipped = scan_files(source_dir, check_ignore,
whitelist=SOURCE_WHITELIST,
max_file_size_mb=MAX_FILE_SIZE_MB)
# Aggregate by top-level directory
dir_stats: dict[str, dict] = defaultdict(lambda: {"count": 0, "size": 0})
large_files: list = []
total_size = 0
path_too_long: list = []
for f in files:
try:
rel = f.relative_to(source_dir)
top_dir = rel.parts[0] if len(rel.parts) > 1 else "(root files)"
size = f.stat().st_size
dir_stats[top_dir]["count"] += 1
dir_stats[top_dir]["size"] += size
total_size += size
if size >= large_file_threshold:
large_files.append((f, size))
# Estimate backup path length
estimated_path = len(str(f)) + 80
if estimated_path > 260:
path_too_long.append((f, estimated_path))
except (OSError, ValueError):
pass
sorted_dirs = sorted(dir_stats.items(), key=lambda x: x[1]["size"], reverse=True)
# Report: directories
console.print()
console.print(f"[bold cyan]Files passing ignore patterns: {len(files)}[/bold cyan]")
console.print(f"[bold cyan]Total size: {fmt_size(total_size)}[/bold cyan]")
console.print(
f"[dim]Directories ignored: {len(skipped.get('directories', set()))} "
f"| Files ignored: {len(skipped.get('files', set()))}[/dim]"
)
console.print()
console.print("[yellow]Top directories by size:[/yellow]")
for dir_name, stats in sorted_dirs[:25]:
pct = (stats["size"] / total_size * 100) if total_size > 0 else 0
size_str = fmt_size(stats["size"])
color = "red" if pct > 20 else "yellow" if pct > 5 else "dim"
console.print(
f" [{color}]{dir_name:<40} {stats['count']:>6} files "
f"{size_str:>10} ({pct:.1f}%)[/{color}]"
)
if len(sorted_dirs) > 25:
console.print(f" [dim]... and {len(sorted_dirs) - 25} more directories[/dim]")
# Report: large files
if large_files:
large_files.sort(key=lambda x: x[1], reverse=True)
console.print()
console.print(
f"[yellow]Large files (>{fmt_size(large_file_threshold)}):[/yellow] "
f"{len(large_files)} found"
)
for f, size in large_files[:20]:
rel = f.relative_to(source_dir)
console.print(f" [red]{fmt_size(size):>10}[/red] {rel}")
if len(large_files) > 20:
console.print(f" [dim]... and {len(large_files) - 20} more[/dim]")
# Report: path too long
if path_too_long:
console.print()
console.print(
f"[yellow]Path too long (>260 chars estimated):[/yellow] "
f"{len(path_too_long)} found"
)
for f, length in path_too_long[:10]:
rel = f.relative_to(source_dir)
console.print(f" [red]{length} chars[/red] {rel}")
if len(path_too_long) > 10:
console.print(f" [dim]... and {len(path_too_long) - 10} more[/dim]")
# Report: files skipped by size cap
too_large = skipped.get("too_large", set())
if too_large:
sorted_large = sorted(too_large, key=lambda x: x[1], reverse=True)
console.print()
console.print(
f"[yellow]Skipped by size cap (>{MAX_FILE_SIZE_MB} MB):[/yellow] "
f"{len(sorted_large)} files"
)
for rel_path, size in sorted_large[:20]:
console.print(f" [red]{fmt_size(size):>10}[/red] {rel_path}")
if len(sorted_large) > 20:
console.print(f" [dim]... and {len(sorted_large) - 20} more[/dim]")
if not large_files and not path_too_long and not too_large:
console.print()
console.print("[green]No large files or long paths detected[/green]")
console.print()
if __name__ == "__main__":
run_scan()
-1
View File
@@ -1 +0,0 @@
# Temporary home for aipass init — will port to CLI branch later.
-183
View File
@@ -1,183 +0,0 @@
"""
aipass init — Bootstrap an AIPass project in any directory.
Temporary home in devpulse. Will port to CLI branch once proven.
Usage:
python -m aipass.devpulse.apps.init_project [target_dir]
What it does:
1. Generates a UUID for the project registry
2. Creates *_REGISTRY.json with metadata.id
3. Creates .trinity/ (passport with registry_id)
4. Creates .aipass/ (aipass_local_prompt.md)
5. Creates AIPASS.md (project prompt)
"""
import json
import re
import sys
import uuid
from datetime import date
from pathlib import Path
def _sanitize_name(raw: str) -> str:
"""Sanitize a project name for use in filenames.
Replaces non-alphanumeric characters (except underscore/hyphen) with
underscores and strips leading/trailing underscores.
"""
return re.sub(r"[^A-Z0-9_-]", "_", raw.upper()).strip("_")
def init_project(target: Path, project_name: str | None = None) -> dict:
"""Initialize an AIPass project in the target directory.
Args:
target: Directory to initialize
project_name: Name for the registry (defaults to directory name)
Returns:
dict with created files and registry_id
"""
target = target.resolve()
if not target.exists():
target.mkdir(parents=True)
raw_name = project_name or target.name
name = _sanitize_name(raw_name)
if not name:
raise ValueError(
f"Cannot derive project name from '{raw_name}'. "
"Pass a project name explicitly."
)
registry_id = str(uuid.uuid4())
today = date.today().isoformat()
created = []
# 1. Registry
registry_filename = f"{name}_REGISTRY.json"
registry_path = target / registry_filename
if registry_path.exists():
raise FileExistsError(f"Registry already exists: {registry_path}")
registry_data = {
"metadata": {
"id": registry_id,
"name": name,
"version": "1.0.0",
"created": today,
"last_updated": today,
"total_branches": 0,
},
"branches": [],
}
registry_path.write_text(
json.dumps(registry_data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
created.append(str(registry_path))
# 2. .trinity/
trinity_dir = target / ".trinity"
trinity_dir.mkdir(exist_ok=True)
passport = {
"document_metadata": {
"document_type": "project_identity",
"document_name": f"{name}.PASSPORT",
"version": "1.0.0",
"created": today,
"last_updated": today,
},
"identity": {
"project_name": name,
"role": "project_root",
"purpose": "",
},
"citizenship": {
"registered": True,
"registry_id": registry_id,
"registry_name": name,
},
}
passport_path = trinity_dir / "passport.json"
if passport_path.exists():
raise FileExistsError(
f"Passport already exists: {passport_path}. "
"Remove .trinity/passport.json to re-initialize."
)
passport_path.write_text(
json.dumps(passport, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
created.append(str(passport_path))
# local.json + observations.json
for filename in ("local.json", "observations.json"):
p = trinity_dir / filename
if not p.exists():
p.write_text("{}\n", encoding="utf-8")
created.append(str(p))
# 3. .aipass/
aipass_dir = target / ".aipass"
aipass_dir.mkdir(exist_ok=True)
prompt_path = aipass_dir / "aipass_local_prompt.md"
if not prompt_path.exists():
prompt_path.write_text(
f"# {name} — Local Prompt\n\nInjected every turn. Add project-specific context here.\n",
encoding="utf-8",
)
created.append(str(prompt_path))
# 4. AIPASS.md
aipass_md = target / "AIPASS.md"
if not aipass_md.exists():
aipass_md.write_text(
"# AIPass — Project Prompt\n\n"
"Project-level instructions. Persists in context for the entire conversation.\n\n"
"## Startup\n\n"
"On any greeting, silently read these files from CWD and run the commands "
"— no narration, no announcing steps. Just do it and respond with the status.\n\n"
"**Read:** `.trinity/passport.json`, `.trinity/local.json`, "
"`.trinity/observations.json`, `README.md`\n"
"**Run:** `git status`\n\n"
"## Memories\n\n"
"Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`. "
"If compaction hits before you save, it's gone.\n",
encoding="utf-8",
)
created.append(str(aipass_md))
return {
"registry_id": registry_id,
"registry_file": registry_filename,
"project_name": name,
"target": str(target),
"created_files": created,
}
def main():
"""CLI entry point."""
target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
name = sys.argv[2] if len(sys.argv) > 2 else None
try:
result = init_project(target, name)
except (FileExistsError, ValueError, OSError) as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
print(f"Initialized AIPass project: {result['project_name']}")
print(f"Registry: {result['registry_file']} (id: {result['registry_id'][:8]}...)")
print(f"Created {len(result['created_files'])} files:")
for f in result["created_files"]:
print(f" {f}")
if __name__ == "__main__":
main()