Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,13 @@ Dispatch `article-analyzer` subagents to extract implicit knowledge:

5. Clean up intermediate files:
```
rm -rf <TARGET_DIR>/.understand-anything/intermediate
python3 - <<'PY'
from pathlib import Path
import shutil
root = Path("<TARGET_DIR>/.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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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"
Expand Down
56 changes: 56 additions & 0 deletions understand-anything-plugin/skills/understand-sandbox/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <SKILL_DIR>/sandbox-pilot.py --source <PROJECT_ROOT> --sandbox /tmp/understand-anything-sandbox-<slug>
```

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.
143 changes: 143 additions & 0 deletions understand-anything-plugin/skills/understand-sandbox/sandbox-pilot.py
Original file line number Diff line number Diff line change
@@ -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())
6 changes: 3 additions & 3 deletions understand-anything-plugin/skills/understand/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<timestamp>/` 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-<timestamp>/` 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 <SKILL_DIR>/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`
Expand Down Expand Up @@ -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=…)
Expand Down
42 changes: 42 additions & 0 deletions understand-anything-plugin/skills/understand/purge-old-trash.py
Original file line number Diff line number Diff line change
@@ -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())