diff --git a/.aipass/aipass_global_prompt.md b/.aipass/aipass_global_prompt.md index 51dfca47..23afccae 100644 --- a/.aipass/aipass_global_prompt.md +++ b/.aipass/aipass_global_prompt.md @@ -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. diff --git a/.claude/commands/prep.md b/.claude/commands/prep.md index d2a8085d..46fb64bc 100644 --- a/.claude/commands/prep.md +++ b/.claude/commands/prep.md @@ -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. ``` diff --git a/.gitignore b/.gitignore index c58b971c..cec6fbea 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ __pycache__/ *.pyc *.egg-info/ +.pytest_cache/ +.ruff_cache/ # ChromaDB .chroma/ diff --git a/pyproject.toml b/pyproject.toml index d03a8d12..a1c3620d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/aipass/ai_mail/apps/handlers/email/dashboard_sync.py b/src/aipass/ai_mail/apps/handlers/email/dashboard_sync.py index c505d373..e3da7e13 100644 --- a/src/aipass/ai_mail/apps/handlers/email/dashboard_sync.py +++ b/src/aipass/ai_mail/apps/handlers/email/dashboard_sync.py @@ -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 diff --git a/src/aipass/backup/apps/handlers/config/config_handler.py b/src/aipass/backup/apps/handlers/config/config_handler.py index f7d6c952..655c85a0 100755 --- a/src/aipass/backup/apps/handlers/config/config_handler.py +++ b/src/aipass/backup/apps/handlers/config/config_handler.py @@ -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 ) # ============================================= diff --git a/src/aipass/backup/apps/handlers/config/ignore_patterns.py b/src/aipass/backup/apps/handlers/config/ignore_patterns.py index da0941ed..492abdda 100644 --- a/src/aipass/backup/apps/handlers/config/ignore_patterns.py +++ b/src/aipass/backup/apps/handlers/config/ignore_patterns.py @@ -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") diff --git a/src/aipass/backup/apps/handlers/operations/file_scanner.py b/src/aipass/backup/apps/handlers/operations/file_scanner.py index 6216fd5f..be7bb34f 100644 --- a/src/aipass/backup/apps/handlers/operations/file_scanner.py +++ b/src/aipass/backup/apps/handlers/operations/file_scanner.py @@ -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) diff --git a/src/aipass/backup/apps/json_templates/ignore_patterns.json b/src/aipass/backup/apps/json_templates/ignore_patterns.json index a2d92798..7262292f 100644 --- a/src/aipass/backup/apps/json_templates/ignore_patterns.json +++ b/src/aipass/backup/apps/json_templates/ignore_patterns.json @@ -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 } } diff --git a/src/aipass/backup/apps/modules/backup_core.py b/src/aipass/backup/apps/modules/backup_core.py index fecce03b..a89f1d77 100755 --- a/src/aipass/backup/apps/modules/backup_core.py +++ b/src/aipass/backup/apps/modules/backup_core.py @@ -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 diff --git a/src/aipass/backup/tests/test_pattern_scan.py b/src/aipass/backup/tests/test_pattern_scan.py new file mode 100644 index 00000000..2804fd44 --- /dev/null +++ b/src/aipass/backup/tests/test_pattern_scan.py @@ -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() diff --git a/src/aipass/devpulse/apps/__init__.py b/src/aipass/devpulse/apps/__init__.py deleted file mode 100644 index 418d47bc..00000000 --- a/src/aipass/devpulse/apps/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Temporary home for aipass init — will port to CLI branch later. diff --git a/src/aipass/devpulse/apps/init_project.py b/src/aipass/devpulse/apps/init_project.py deleted file mode 100644 index 3313ffa0..00000000 --- a/src/aipass/devpulse/apps/init_project.py +++ /dev/null @@ -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()