fix(skills): redirect PROJECT_ROOT out of git worktrees (#133)

When /understand or /understand-domain runs from a CWD inside an
ephemeral git worktree (the default for parallel-agent / isolation
sessions), every output file goes to the worktree path. Claude Code
deletes the worktree on session end, taking knowledge-graph.json,
domain-graph.json, meta.json, intermediate batches and ~hundreds of K
of analysis tokens with it.

Resolve PROJECT_ROOT through a worktree check before any output:
compare git rev-parse --git-dir against --git-common-dir; in a normal
checkout (and in a submodule) they're the same path, in a worktree they
differ and parent(--git-common-dir) is the main repo root.
UNDERSTAND_NO_WORKTREE_REDIRECT=1 opts out for the rare per-worktree
case.

- skills/understand/SKILL.md Phase 0 step 1: add the redirect after
  PROJECT_ROOT is set from $ARGUMENTS or CWD, so an explicit arg path
  is also rescued from a worktree but can be opted out of.
- skills/understand-domain/SKILL.md: add an explicit Phase 0 (it
  previously inferred "current project" implicitly), then thread
  $PROJECT_ROOT through Phases 2-5 so subsequent steps honor the
  redirect.
- New worktree-redirect.test.mjs: 5 vitest cases covering main repo,
  worktree root, worktree subdir, opt-out env var, and non-git CWD.
  Mirrors the bash snippet inline (no shared lib in this repo).

Submodule false-positive ruled out by probe — submodules see git-dir
== git-common-dir (both point at <super>/.git/modules/<name>).
This commit is contained in:
d 🔹
2026-05-09 12:58:46 +08:00
Unverified
parent 3eb7700a8f
commit b962a3dc33
3 changed files with 144 additions and 8 deletions
@@ -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" / `<project-root>` 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 <project-root>
python ./extract-domain-context.py "$PROJECT_ROOT"
```
This outputs `<project-root>/.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
@@ -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 <path>`). 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.
@@ -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);
});
});