diff --git a/understand-anything-plugin/skills/understand-domain/SKILL.md b/understand-anything-plugin/skills/understand-domain/SKILL.md index 5c455e8..1bb1108 100644 --- a/understand-anything-plugin/skills/understand-domain/SKILL.md +++ b/understand-anything-plugin/skills/understand-domain/SKILL.md @@ -16,9 +16,35 @@ Extracts business domain knowledge — domains, business flows, and process step ## Instructions +### Phase 0: Resolve `PROJECT_ROOT` + +Set `PROJECT_ROOT` to the current working directory. + +**Worktree redirect.** If `PROJECT_ROOT` is inside a git worktree (not the main checkout), redirect output to the main repository root. Worktrees managed by Claude Code are ephemeral — `.understand-anything/` written there is destroyed when the session ends, taking the domain graph with it (issue #133). Detect a worktree by comparing `git rev-parse --git-dir` against `git rev-parse --git-common-dir`; in a normal checkout or submodule they resolve to the same path, in a worktree they differ and the parent of `--git-common-dir` is the main repo root. + +```bash +COMMON_DIR=$(git -C "$PROJECT_ROOT" rev-parse --git-common-dir 2>/dev/null) +GIT_DIR=$(git -C "$PROJECT_ROOT" rev-parse --git-dir 2>/dev/null) +if [ -n "$COMMON_DIR" ] && [ -n "$GIT_DIR" ]; then + COMMON_ABS=$(cd "$PROJECT_ROOT" && cd "$COMMON_DIR" 2>/dev/null && pwd -P) + GIT_ABS=$(cd "$PROJECT_ROOT" && cd "$GIT_DIR" 2>/dev/null && pwd -P) + if [ -n "$COMMON_ABS" ] && [ "$COMMON_ABS" != "$GIT_ABS" ]; then + MAIN_ROOT=$(dirname "$COMMON_ABS") + if [ -d "$MAIN_ROOT" ] && [ "${UNDERSTAND_NO_WORKTREE_REDIRECT:-0}" != "1" ]; then + echo "[understand-domain] Detected git worktree at $PROJECT_ROOT" + echo "[understand-domain] Redirecting output to main repo root: $MAIN_ROOT" + echo "[understand-domain] (Set UNDERSTAND_NO_WORKTREE_REDIRECT=1 to keep PROJECT_ROOT as the worktree.)" + PROJECT_ROOT="$MAIN_ROOT" + fi + fi +fi +``` + +Use `$PROJECT_ROOT` (not the bare CWD) for every reference to "the current project" / `` in subsequent phases. + ### Phase 1: Detect Existing Graph -1. Check if `.understand-anything/knowledge-graph.json` exists in the current project +1. Check if `$PROJECT_ROOT/.understand-anything/knowledge-graph.json` exists 2. If it exists AND `--full` was NOT passed → proceed to Phase 3 (derive from graph) 3. Otherwise → proceed to Phase 2 (lightweight scan) @@ -26,11 +52,11 @@ Extracts business domain knowledge — domains, business flows, and process step The preprocessing script does NOT produce a domain graph — it produces **raw material** (file tree, entry points, exports/imports) so the domain-analyzer agent can focus on the actual domain analysis instead of spending dozens of tool calls exploring the codebase. Think of it as a cheat sheet: cheap Python preprocessing → expensive LLM gets a clean, small input → better results for less cost. -1. Run the preprocessing script bundled with this skill: +1. Run the preprocessing script bundled with this skill, passing `$PROJECT_ROOT` from Phase 0: ``` - python ./extract-domain-context.py + python ./extract-domain-context.py "$PROJECT_ROOT" ``` - This outputs `/.understand-anything/intermediate/domain-context.json` containing: + This outputs `$PROJECT_ROOT/.understand-anything/intermediate/domain-context.json` containing: - File tree (respecting `.gitignore`) - Detected entry points (HTTP routes, CLI commands, event handlers, cron jobs, exported handlers) - File signatures (exports, imports per file) @@ -41,7 +67,7 @@ The preprocessing script does NOT produce a domain graph — it produces **raw m ### Phase 3: Derive from Existing Graph (Path 2) -1. Read `.understand-anything/knowledge-graph.json` +1. Read `$PROJECT_ROOT/.understand-anything/knowledge-graph.json` 2. Format the graph data as structured context: - All nodes with their types, names, summaries, and tags - All edges with their types (especially `calls`, `imports`, `contains`) @@ -54,15 +80,15 @@ The preprocessing script does NOT produce a domain graph — it produces **raw m 1. Read the domain-analyzer agent prompt from `agents/domain-analyzer.md` 2. Dispatch a subagent with the domain-analyzer prompt + the context from Phase 2 or 3 -3. The agent writes its output to `.understand-anything/intermediate/domain-analysis.json` +3. The agent writes its output to `$PROJECT_ROOT/.understand-anything/intermediate/domain-analysis.json` ### Phase 5: Validate and Save 1. Read the domain analysis output 2. Validate using the standard graph validation pipeline (the schema now supports domain/flow/step types) 3. If validation fails, log warnings but save what's valid (error tolerance) -4. Save to `.understand-anything/domain-graph.json` -5. Clean up `.understand-anything/intermediate/domain-analysis.json` and `.understand-anything/intermediate/domain-context.json` +4. Save to `$PROJECT_ROOT/.understand-anything/domain-graph.json` +5. Clean up `$PROJECT_ROOT/.understand-anything/intermediate/domain-analysis.json` and `$PROJECT_ROOT/.understand-anything/intermediate/domain-context.json` ### Phase 6: Launch Dashboard diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index 3c22cfd..aa47a6c 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -29,6 +29,27 @@ Determine whether to run a full analysis or incremental update. - Verify the resolved path exists and is a directory (run `test -d `). If it does not exist or is not a directory, report an error to the user and **STOP**. - Set `PROJECT_ROOT` to the resolved absolute path. - If no directory path argument is found, set `PROJECT_ROOT` to the current working directory. + - **Worktree redirect.** If `PROJECT_ROOT` is inside a git worktree (not the main checkout), redirect output to the main repository root. Worktrees managed by Claude Code are ephemeral — `.understand-anything/` written there is destroyed when the session ends, taking the knowledge graph with it (issue #133). Detect a worktree by comparing `git rev-parse --git-dir` against `git rev-parse --git-common-dir`; in a normal checkout or submodule they resolve to the same path, in a worktree they differ and the parent of `--git-common-dir` is the main repo root. + + ```bash + COMMON_DIR=$(git -C "$PROJECT_ROOT" rev-parse --git-common-dir 2>/dev/null) + GIT_DIR=$(git -C "$PROJECT_ROOT" rev-parse --git-dir 2>/dev/null) + if [ -n "$COMMON_DIR" ] && [ -n "$GIT_DIR" ]; then + COMMON_ABS=$(cd "$PROJECT_ROOT" && cd "$COMMON_DIR" 2>/dev/null && pwd -P) + GIT_ABS=$(cd "$PROJECT_ROOT" && cd "$GIT_DIR" 2>/dev/null && pwd -P) + if [ -n "$COMMON_ABS" ] && [ "$COMMON_ABS" != "$GIT_ABS" ]; then + MAIN_ROOT=$(dirname "$COMMON_ABS") + if [ -d "$MAIN_ROOT" ] && [ "${UNDERSTAND_NO_WORKTREE_REDIRECT:-0}" != "1" ]; then + echo "[understand] Detected git worktree at $PROJECT_ROOT" + echo "[understand] Redirecting output to main repo root: $MAIN_ROOT" + echo "[understand] (Set UNDERSTAND_NO_WORKTREE_REDIRECT=1 to keep PROJECT_ROOT as the worktree.)" + PROJECT_ROOT="$MAIN_ROOT" + fi + fi + fi + ``` + + Set `UNDERSTAND_NO_WORKTREE_REDIRECT=1` if you intentionally want a per-worktree graph (rare — most users want the redirect). 1.5. **Ensure the plugin is built.** Later phases invoke Node scripts that import `@understand-anything/core`. On a fresh install `packages/core/dist/` does not exist yet — build once. **Important:** do **not** assume the plugin root is simply two directories above the skill path string. In many installations `~/.agents/skills/understand` is a symlink into the real plugin checkout. Prefer runtime-provided plugin roots first (for Claude), then fall back to universal symlinks, skill symlink resolution, and common clone-based install paths. diff --git a/understand-anything-plugin/src/__tests__/worktree-redirect.test.mjs b/understand-anything-plugin/src/__tests__/worktree-redirect.test.mjs new file mode 100644 index 0000000..f0ffcf3 --- /dev/null +++ b/understand-anything-plugin/src/__tests__/worktree-redirect.test.mjs @@ -0,0 +1,89 @@ +// Validates the worktree-redirect bash snippet embedded in +// `skills/understand/SKILL.md` Phase 0 step 1 and +// `skills/understand-domain/SKILL.md` Phase 0. +// +// If you edit the snippet in either SKILL.md, mirror the change to RESOLVE_SNIPPET +// below — there is no shared script to source (per-skill convention in this repo). + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const RESOLVE_SNIPPET = ` +COMMON_DIR=$(git -C "$PROJECT_ROOT" rev-parse --git-common-dir 2>/dev/null) +GIT_DIR=$(git -C "$PROJECT_ROOT" rev-parse --git-dir 2>/dev/null) +if [ -n "$COMMON_DIR" ] && [ -n "$GIT_DIR" ]; then + COMMON_ABS=$(cd "$PROJECT_ROOT" && cd "$COMMON_DIR" 2>/dev/null && pwd -P) + GIT_ABS=$(cd "$PROJECT_ROOT" && cd "$GIT_DIR" 2>/dev/null && pwd -P) + if [ -n "$COMMON_ABS" ] && [ "$COMMON_ABS" != "$GIT_ABS" ]; then + MAIN_ROOT=$(dirname "$COMMON_ABS") + if [ -d "$MAIN_ROOT" ] && [ "\${UNDERSTAND_NO_WORKTREE_REDIRECT:-0}" != "1" ]; then + PROJECT_ROOT="$MAIN_ROOT" + fi + fi +fi +echo "$PROJECT_ROOT" +`; + +function runResolve(projectRoot, env = {}) { + // No `set -e` — the snippet relies on `git ... 2>/dev/null` returning empty + // strings when not in a git repo; `set -e` would short-circuit instead. + const script = `PROJECT_ROOT=${JSON.stringify(projectRoot)}\n${RESOLVE_SNIPPET}`; + return execFileSync("bash", ["-c", script], { + env: { ...process.env, ...env }, + encoding: "utf8", + }).trim(); +} + +let tmpRoot; +let mainRepo; +let worktree; +let subdir; + +beforeAll(() => { + tmpRoot = realpathSync(mkdtempSync(join(tmpdir(), "ua-wt-"))); + mainRepo = join(tmpRoot, "main"); + worktree = join(tmpRoot, "wt"); + subdir = join(worktree, "src", "deep"); + + execFileSync("git", ["init", "-q", "-b", "main", mainRepo]); + execFileSync("git", ["-C", mainRepo, "config", "user.email", "t@t"]); + execFileSync("git", ["-C", mainRepo, "config", "user.name", "t"]); + writeFileSync(join(mainRepo, "README.md"), "main\n"); + execFileSync("git", ["-C", mainRepo, "add", "."]); + execFileSync("git", ["-C", mainRepo, "commit", "-q", "-m", "init"]); + execFileSync("git", ["-C", mainRepo, "worktree", "add", "-q", worktree]); + mkdirSync(subdir, { recursive: true }); +}); + +afterAll(() => { + if (tmpRoot) rmSync(tmpRoot, { recursive: true, force: true }); +}); + +describe("worktree-redirect snippet (issue #133)", () => { + it("leaves PROJECT_ROOT alone in a normal checkout", () => { + expect(runResolve(mainRepo)).toBe(mainRepo); + }); + + it("redirects PROJECT_ROOT to the main repo when started in a worktree", () => { + expect(runResolve(worktree)).toBe(mainRepo); + }); + + it("redirects from a subdirectory inside a worktree", () => { + expect(runResolve(subdir)).toBe(mainRepo); + }); + + it("respects UNDERSTAND_NO_WORKTREE_REDIRECT=1", () => { + expect(runResolve(worktree, { UNDERSTAND_NO_WORKTREE_REDIRECT: "1" })).toBe(worktree); + }); + + it("leaves PROJECT_ROOT alone when not inside a git repo", () => { + // Use a path under the resolved tmp root so we never accidentally land + // inside a parent git repo (e.g. when /tmp is symlinked into one). + const nonGit = join(tmpRoot, "no-git"); + mkdirSync(nonGit, { recursive: true }); + expect(runResolve(nonGit)).toBe(nonGit); + }); +});