# kg-setup Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a Claude Code skill `kg-setup` that bootstraps a 4-layer project memory system (CodeGraph + GitNexus + CLAUDE.md + Obsidian + auto-memory) in any local project on macOS. **Architecture:** A markdown orchestrator (`SKILL.md`) delegates mechanical work to bundled bash + python scripts in `scripts/`. Each script has a single responsibility, emits JSON where state matters, is idempotent, and is tested in isolation. A top-level state file `.kg-setup-state.json` tracks per-project setup progress for safe reruns. Skill developed in its own repo at `~/Projects/kg-setup/`, symlinked into `~/.claude/skills/` at the end so Claude Code picks it up. **Tech Stack:** - Bash (macOS/zsh) for system-level scripts (install, MCP register, health check) - Python 3.12 for logic-heavy scripts (detect_project, merge_claude_md, state, build_obsidian_index, update_memory_index) - pytest for Python unit tests - bats-core (or plain bash + `set -e` + `[[ ]]` asserts) for shell integration tests — default to plain bash to avoid extra deps - External CLIs (not dependencies of the plan, but dependencies of the skill at runtime): `gitnexus` (npm), `@colbymchenry/codegraph` (npm), Node.js 18+, `claude` CLI --- ## File Structure Will be created at the repo root: ``` (repo root)/ ├── SKILL.md ├── README.md ├── scripts/ │ ├── check_prereqs.sh │ ├── install_tools.sh │ ├── detect_project.py │ ├── register_mcp.sh │ ├── init_codegraph.sh │ ├── init_gitnexus.sh │ ├── merge_claude_md.py │ ├── build_obsidian_index.py │ ├── update_memory_index.py │ ├── health_check.sh │ └── state.py ├── templates/ │ ├── claude_md_section.md │ ├── obsidian_index_minimal.md │ └── obsidian_index_rich.md └── tests/ ├── conftest.py ├── test_state.py ├── test_detect_project.py ├── test_merge_claude_md.py ├── test_build_obsidian_index.py ├── test_update_memory_index.py ├── test_check_prereqs.sh └── integration_test.sh ``` **Responsibility boundaries:** - `state.py` — single source of truth for `.kg-setup-state.json` I/O; used by every script that reads/writes state - `detect_project.py` — read-only; pure inspection, emits JSON, no side effects - `check_prereqs.sh` — read-only inspection of system env - `install_tools.sh`, `register_mcp.sh`, `init_*.sh` — system-state mutators; must be idempotent - `merge_claude_md.py`, `build_obsidian_index.py`, `update_memory_index.py` — file writers; must be atomic (tmp + rename) - `health_check.sh` — read-only verification - `SKILL.md` — orchestration logic only; no business rules embedded --- ## Phase A — Foundation: state + detection (Tasks 1-4) Pure-Python, no external dependencies, fully testable. Lands first so every downstream script has a solid I/O layer. ### Task 1: Repo scaffolding **Files:** - Create: `scripts/` (directory) - Create: `templates/` (directory) - Create: `tests/` (directory) - Create: `README.md` - Create: `tests/conftest.py` - Create: `.gitignore` (top-level — repo is fresh) - [ ] **Step 1: Create directories and README** ```bash mkdir -p scripts templates tests ``` Write `README.md`: ```markdown # kg-setup Claude Code skill that bootstraps a 4-layer project memory system: CodeGraph + GitNexus MCP servers, project CLAUDE.md section, Obsidian vault folder with `_index.md`, and an auto-memory pointer. ## Install Symlink into `~/.claude/skills/`: ```bash ln -s "$(pwd)" ~/.claude/skills/kg-setup ``` ## Activate Say to Claude Code: "настрой граф знаний" or "setup knowledge graph". ## Design See `docs/superpowers/specs/2026-04-22-kg-setup-design.md` in the repo root. ``` - [ ] **Step 2: Add pytest config to conftest** Write `tests/conftest.py`: ```python import sys from pathlib import Path SCRIPTS_DIR = Path(__file__).parent.parent / "scripts" sys.path.insert(0, str(SCRIPTS_DIR)) ``` - [ ] **Step 3: Update .gitignore** Append to `.gitignore`: ``` # kg-setup dev __pycache__/ **/__pycache__/ **/*.pyc .pytest_cache/ ``` - [ ] **Step 4: Verify pytest runs (empty)** ```bash cd ~/Projects/kg-setup && python3 -m pytest tests/ -v ``` Expected: `no tests ran in ...` (exit code 5 is fine — no tests yet). - [ ] **Step 5: Commit** ```bash git add .gitignore git commit -m "kg-setup: scaffold skill directory structure" ``` --- ### Task 2: `state.py` — state file I/O **Files:** - Create: `scripts/state.py` - Create: `tests/test_state.py` - [ ] **Step 1: Write the failing tests** Write `tests/test_state.py`: ```python import json from pathlib import Path from state import State, STATE_FILENAME, SCHEMA_VERSION def test_state_defaults(tmp_path): s = State(tmp_path) assert s.schema_version == SCHEMA_VERSION assert s.status == "unknown" assert s.layers == {} assert s.warnings == [] assert s.errors == [] def test_state_write_creates_file(tmp_path): s = State(tmp_path) s.status = "healthy" s.layers["code_graph"] = {"configured": True, "tool": "codegraph"} s.save() f = tmp_path / STATE_FILENAME assert f.exists() data = json.loads(f.read_text()) assert data["schema_version"] == SCHEMA_VERSION assert data["last_run_status"] == "healthy" assert data["layers"]["code_graph"]["tool"] == "codegraph" def test_state_load_roundtrip(tmp_path): s1 = State(tmp_path) s1.status = "degraded" s1.warnings.append({"phase": "obsidian", "message": "vault missing"}) s1.save() s2 = State(tmp_path) s2.load() assert s2.status == "degraded" assert s2.warnings[0]["message"] == "vault missing" def test_state_load_missing_file_noop(tmp_path): s = State(tmp_path) s.load() assert s.status == "unknown" def test_state_atomic_write(tmp_path, monkeypatch): s = State(tmp_path) s.status = "healthy" s.save() tmp_file = tmp_path / (STATE_FILENAME + ".tmp") assert not tmp_file.exists(), "tmp file must be renamed, not left behind" ``` - [ ] **Step 2: Run tests to verify they fail** ```bash cd ~/Projects/kg-setup && python3 -m pytest tests/test_state.py -v ``` Expected: FAIL with `ModuleNotFoundError: No module named 'state'`. - [ ] **Step 3: Implement `state.py`** Write `scripts/state.py`: ```python """Read/write .kg-setup-state.json at project root.""" from __future__ import annotations import json import os from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any SCHEMA_VERSION = 1 SKILL_VERSION = "0.1.0" STATE_FILENAME = ".kg-setup-state.json" @dataclass class State: project_path: Path schema_version: int = SCHEMA_VERSION skill_version: str = SKILL_VERSION last_run: str = "" status: str = "unknown" layers: dict[str, Any] = field(default_factory=dict) warnings: list[dict] = field(default_factory=list) errors: list[dict] = field(default_factory=list) @property def path(self) -> Path: return self.project_path / STATE_FILENAME def load(self) -> None: if not self.path.exists(): return data = json.loads(self.path.read_text()) self.schema_version = data.get("schema_version", SCHEMA_VERSION) self.skill_version = data.get("skill_version", SKILL_VERSION) self.last_run = data.get("last_run", "") self.status = data.get("last_run_status", "unknown") self.layers = data.get("layers", {}) self.warnings = data.get("warnings", []) self.errors = data.get("errors", []) def save(self) -> None: self.last_run = datetime.now(timezone.utc).isoformat(timespec="seconds") payload = { "schema_version": self.schema_version, "skill_version": self.skill_version, "last_run": self.last_run, "last_run_status": self.status, "layers": self.layers, "warnings": self.warnings, "errors": self.errors, } tmp = self.path.with_suffix(self.path.suffix + ".tmp") tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n") os.replace(tmp, self.path) ``` - [ ] **Step 4: Run tests to verify PASS** ```bash cd ~/Projects/kg-setup && python3 -m pytest tests/test_state.py -v ``` Expected: 5 passed. - [ ] **Step 5: Commit** ```bash git add scripts/state.py tests/test_state.py git commit -m "kg-setup: add state.py with tests" ``` --- ### Task 3: `detect_project.py` — read-only project inspection **Files:** - Create: `scripts/detect_project.py` - Create: `tests/test_detect_project.py` - [ ] **Step 1: Write the failing tests** Write `tests/test_detect_project.py`: ```python import json import subprocess from pathlib import Path from detect_project import ( detect_git_remote_name, detect_primary_language, count_loc, build_report, ) def _git_init(path: Path, remote_url: str | None = None) -> None: subprocess.run(["git", "init", "-q", "-b", "main"], cwd=path, check=True) if remote_url: subprocess.run( ["git", "remote", "add", "origin", remote_url], cwd=path, check=True ) def test_detect_git_remote_name_with_origin(tmp_path): _git_init(tmp_path, "https://example.com/team/my-repo.git") assert detect_git_remote_name(tmp_path) == "my-repo" def test_detect_git_remote_name_no_dotgit(tmp_path): _git_init(tmp_path, "git@example.com:team/other") assert detect_git_remote_name(tmp_path) == "other" def test_detect_git_remote_name_no_remote(tmp_path): _git_init(tmp_path) assert detect_git_remote_name(tmp_path) is None def test_detect_git_remote_name_no_git(tmp_path): assert detect_git_remote_name(tmp_path) is None def test_detect_primary_language_python(tmp_path): (tmp_path / "requirements.txt").write_text("flask\n") (tmp_path / "app.py").write_text("print(1)\n") assert detect_primary_language(tmp_path) == "python" def test_detect_primary_language_js(tmp_path): (tmp_path / "package.json").write_text('{"name": "x"}\n') (tmp_path / "index.js").write_text("console.log(1)\n") assert detect_primary_language(tmp_path) == "javascript" def test_detect_primary_language_unknown(tmp_path): (tmp_path / "README.md").write_text("# hi\n") assert detect_primary_language(tmp_path) == "unknown" def test_count_loc(tmp_path): (tmp_path / "a.py").write_text("x = 1\n\n# comment\ny = 2\n") (tmp_path / "b.py").write_text("z = 3\n") n = count_loc(tmp_path, [".py"]) # non-blank, non-comment-only lines: x=1, y=2, z=3 → 3 assert n == 3 def test_build_report_full(tmp_path): _git_init(tmp_path, "git@github.com:foo/bar.git") (tmp_path / "main.py").write_text("print(1)\n") (tmp_path / "requirements.txt").write_text("") report = build_report(tmp_path) data = json.loads(report) assert data["path"] == str(tmp_path) assert data["git_remote_name"] == "bar" assert data["primary_lang"] == "python" assert data["loc"] >= 1 assert data["has_claude_md"] is False ``` - [ ] **Step 2: Run tests to verify they fail** ```bash cd ~/Projects/kg-setup && python3 -m pytest tests/test_detect_project.py -v ``` Expected: FAIL with `ModuleNotFoundError: No module named 'detect_project'`. - [ ] **Step 3: Implement `detect_project.py`** Write `scripts/detect_project.py`: ```python """Read-only project inspection. Emits JSON to stdout when run as CLI.""" from __future__ import annotations import json import subprocess import sys from pathlib import Path SOURCE_EXTENSIONS = { "python": [".py"], "javascript": [".js", ".jsx", ".mjs"], "typescript": [".ts", ".tsx"], "go": [".go"], "rust": [".rs"], "java": [".java"], } LANGUAGE_MARKERS = { "python": ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile"], "javascript": ["package.json"], "typescript": ["tsconfig.json"], "go": ["go.mod"], "rust": ["Cargo.toml"], "java": ["pom.xml", "build.gradle"], } def detect_git_remote_name(path: Path) -> str | None: try: result = subprocess.run( ["git", "remote", "get-url", "origin"], cwd=path, capture_output=True, text=True, timeout=5, ) if result.returncode != 0: return None url = result.stdout.strip() if not url: return None name = url.rsplit("/", 1)[-1] if name.endswith(".git"): name = name[:-4] return name or None except (FileNotFoundError, subprocess.TimeoutExpired): return None def detect_primary_language(path: Path) -> str: for lang, markers in LANGUAGE_MARKERS.items(): if any((path / m).exists() for m in markers): return lang return "unknown" def count_loc(path: Path, extensions: list[str], cap: int = 100_000) -> int: total = 0 for ext in extensions: for f in path.rglob(f"*{ext}"): if any(part.startswith(".") for part in f.relative_to(path).parts): continue try: for line in f.read_text(errors="ignore").splitlines(): s = line.strip() if s and not s.startswith("#") and not s.startswith("//"): total += 1 if total >= cap: return total except OSError: continue return total def build_report(path: Path) -> str: lang = detect_primary_language(path) exts = SOURCE_EXTENSIONS.get(lang, []) report = { "path": str(path), "git_remote_name": detect_git_remote_name(path), "primary_lang": lang, "loc": count_loc(path, exts) if exts else 0, "has_claude_md": (path / "CLAUDE.md").exists(), "has_codegraph_dir": (path / ".codegraph").is_dir(), "has_gitnexus_dir": (path / ".gitnexus").is_dir(), "has_state_file": (path / ".kg-setup-state.json").exists(), } return json.dumps(report, indent=2, ensure_ascii=False) if __name__ == "__main__": target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd() print(build_report(target)) ``` - [ ] **Step 4: Run tests to verify PASS** ```bash cd ~/Projects/kg-setup && python3 -m pytest tests/test_detect_project.py -v ``` Expected: 9 passed. - [ ] **Step 5: Smoke test CLI mode on arb-scanner itself** ```bash python3 scripts/detect_project.py . ``` Expected: JSON with `git_remote_name: "arb-scanner"`, `primary_lang: "python"`, `loc > 0`, `has_claude_md: true`. - [ ] **Step 6: Commit** ```bash git add scripts/detect_project.py tests/test_detect_project.py git commit -m "kg-setup: add detect_project.py with tests" ``` --- ### Task 4: `check_prereqs.sh` — env inspection **Files:** - Create: `scripts/check_prereqs.sh` - Create: `tests/test_check_prereqs.sh` - [ ] **Step 1: Write the shell test** Write `tests/test_check_prereqs.sh`: ```bash #!/usr/bin/env bash set -euo pipefail SCRIPT="$(dirname "$0")/../scripts/check_prereqs.sh" output=$("$SCRIPT") # Must be valid JSON echo "$output" | python3 -c "import json, sys; json.loads(sys.stdin.read())" \ || { echo "FAIL: output not valid JSON"; exit 1; } # Must contain the required top-level keys for key in schema_version env tools obsidian errors warnings; do echo "$output" | python3 -c " import json, sys data = json.loads(sys.stdin.read()) assert '$key' in data, 'missing key: $key' " || { echo "FAIL: missing key $key"; exit 1; } done # Must report node presence or blocking error has_node=$(echo "$output" | python3 -c " import json, sys data = json.loads(sys.stdin.read()) print(bool(data['env'].get('node'))) ") if [[ "$has_node" != "True" && "$has_node" != "False" ]]; then echo "FAIL: node field malformed"; exit 1 fi echo "PASS: check_prereqs.sh" ``` Make executable: ```bash chmod +x tests/test_check_prereqs.sh ``` - [ ] **Step 2: Run test to verify failure** ```bash tests/test_check_prereqs.sh ``` Expected: error that script file does not exist. - [ ] **Step 3: Implement `check_prereqs.sh`** Write `scripts/check_prereqs.sh`: ```bash #!/usr/bin/env bash # check_prereqs.sh — inspect local env, emit JSON to stdout. # Read-only. Never mutates state. Never writes files. set -euo pipefail json_str() { # escape string for JSON python3 -c "import json,sys; print(json.dumps(sys.stdin.read().strip()))" } get_version() { local cmd="$1" flag="${2:---version}" if command -v "$cmd" >/dev/null 2>&1; then local out out=$("$cmd" "$flag" 2>&1 | head -n1 || true) json_str <<<"$out" else echo "null" fi } node_major() { if command -v node >/dev/null 2>&1; then node -v 2>/dev/null | sed -E 's/^v([0-9]+).*/\1/' else echo "null" fi } tool_entry() { local name="$1" cli="$2" local installed=false version="null" path="null" if command -v "$cli" >/dev/null 2>&1; then installed=true local p v p=$(command -v "$cli") path=$(json_str <<<"$p") v=$("$cli" --version 2>/dev/null | head -n1 || true) if [[ -n "$v" ]]; then version=$(json_str <<<"$v") fi fi printf ' "%s": {"installed": %s, "version": %s, "path": %s}' \ "$name" "$installed" "$version" "$path" } mcp_registered() { local server_name="$1" if command -v claude >/dev/null 2>&1; then if claude mcp list 2>/dev/null | grep -q "^${server_name}\b"; then echo "true" else echo "false" fi else echo "false" fi } NODE_VERSION=$(get_version node -v) PYTHON_VERSION=$(get_version python3 --version) GIT_VERSION=$(get_version git --version) NPM_VERSION=$(get_version npm --version) NODE_MAJOR=$(node_major) GITNEXUS_ENTRY=$(tool_entry "gitnexus_cli" "gitnexus") CODEGRAPH_ENTRY=$(tool_entry "codegraph_cli" "codegraph") GITNEXUS_MCP=$(mcp_registered "gitnexus") CODEGRAPH_MCP=$(mcp_registered "codegraph") ERRORS="[]" WARNINGS="[]" if [[ -z "$NODE_MAJOR" || "$NODE_MAJOR" == "null" ]]; then ERRORS='[{"code":"no_node","message":"Node.js not found; install Node 18+ via `brew install node`"}]' elif [[ "$NODE_MAJOR" -lt 18 ]]; then ERRORS="[{\"code\":\"node_too_old\",\"message\":\"Node $NODE_MAJOR < 18; upgrade via \`brew upgrade node\`\"}]" fi if [[ -n "${KG_SETUP_VAULT_PATH:-}" ]]; then VAULT_HINT="\"$KG_SETUP_VAULT_PATH\"" else VAULT_HINT="null" fi cat </dev/null 2>&1 || { echo "ERROR: npm not found. Install Node 18+ first (brew install node)" >&2 exit 2 } } install_if_missing() { local cli_name="$1" pkg="$2" if command -v "$cli_name" >/dev/null 2>&1; then echo "[skip] $cli_name already installed: $(command -v "$cli_name")" return 0 fi echo "[install] npm install -g $pkg" if ! npm install -g "$pkg"; then echo "[retry] npm install -g $pkg (attempt 2)" npm install -g "$pkg" fi command -v "$cli_name" >/dev/null 2>&1 || { echo "ERROR: $cli_name still not on PATH after install" >&2 return 1 } echo "[done] $cli_name installed" } require_npm install_if_missing gitnexus "$GITNEXUS_PKG" install_if_missing codegraph "$CODEGRAPH_PKG" ``` Make executable: ```bash chmod +x scripts/install_tools.sh ``` - [ ] **Step 2: Dry-run verify script syntax** ```bash bash -n scripts/install_tools.sh && echo "syntax ok" ``` Expected: `syntax ok`. - [ ] **Step 3: Manual execution (user approval gate)** Before running: confirm with user that installing `gitnexus` and `@colbymchenry/codegraph` globally is OK. Then: ```bash scripts/install_tools.sh ``` Expected: `[install]` or `[skip]` lines for each tool, final state has both on PATH. Verify: ```bash which gitnexus && which codegraph ``` - [ ] **Step 4: Idempotency check** ```bash scripts/install_tools.sh ``` Expected: both report `[skip]`. - [ ] **Step 5: Commit** ```bash git add scripts/install_tools.sh git commit -m "kg-setup: add install_tools.sh for gitnexus + codegraph" ``` --- ### Task 6: `register_mcp.sh` — add MCP servers to Claude Code **Files:** - Create: `scripts/register_mcp.sh` - [ ] **Step 1: Implement `register_mcp.sh`** Write `scripts/register_mcp.sh`: ```bash #!/usr/bin/env bash # register_mcp.sh — register gitnexus + codegraph MCP servers with Claude Code. # Idempotent: uses `claude mcp list` to detect existing registration. set -euo pipefail require_claude() { command -v claude >/dev/null 2>&1 || { echo "ERROR: claude CLI not found. Install Claude Code first." >&2 exit 2 } } is_registered() { local name="$1" claude mcp list 2>/dev/null | grep -Eq "^${name}[[:space:]]" } register_if_missing() { local name="$1"; shift if is_registered "$name"; then echo "[skip] mcp server '$name' already registered" return 0 fi echo "[register] claude mcp add $name -- $*" claude mcp add "$name" -- "$@" is_registered "$name" || { echo "ERROR: registration of '$name' did not take effect" >&2 return 1 } echo "[done] '$name' registered" } require_claude register_if_missing gitnexus npx -y gitnexus@latest mcp register_if_missing codegraph codegraph serve --mcp ``` Make executable: ```bash chmod +x scripts/register_mcp.sh ``` - [ ] **Step 2: Syntax check** ```bash bash -n scripts/register_mcp.sh && echo "syntax ok" ``` Expected: `syntax ok`. - [ ] **Step 3: Execute and verify (user approval gate)** ```bash scripts/register_mcp.sh claude mcp list ``` Expected: list contains `gitnexus` and `codegraph` rows. - [ ] **Step 4: Idempotency check** ```bash scripts/register_mcp.sh ``` Expected: both `[skip]`. - [ ] **Step 5: Commit** ```bash git add scripts/register_mcp.sh git commit -m "kg-setup: add register_mcp.sh for gitnexus + codegraph servers" ``` --- ### Task 7: `health_check.sh` — post-setup verification **Files:** - Create: `scripts/health_check.sh` - [ ] **Step 1: Implement `health_check.sh`** Write `scripts/health_check.sh`: ```bash #!/usr/bin/env bash # health_check.sh — verify kg-setup artifacts in current project. # Emits JSON summary to stdout. Exit code 0 = healthy, 1 = degraded. set -uo pipefail PROJECT="${1:-$PWD}" cd "$PROJECT" check_mcp_listed() { local name="$1" if command -v claude >/dev/null 2>&1 \ && claude mcp list 2>/dev/null | grep -Eq "^${name}[[:space:]]"; then echo "true" else echo "false" fi } check_codegraph_dir() { [[ -d ".codegraph" ]] && echo "true" || echo "false" } check_gitnexus_dir() { [[ -d ".gitnexus" ]] && echo "true" || echo "false" } check_claude_md_section() { [[ -f "CLAUDE.md" ]] && grep -q "" CLAUDE.md \ && echo "true" || echo "false" } check_state_file() { [[ -f ".kg-setup-state.json" ]] && echo "true" || echo "false" } RESULTS=$(cat </dev/null 2>&1; then echo "ERROR: codegraph not on PATH. Run install_tools.sh first." >&2 exit 2 fi REFRESH="${KG_SETUP_REFRESH:-0}" if [[ -d ".codegraph" && "$REFRESH" != "1" ]]; then echo "[skip] .codegraph/ already exists (set KG_SETUP_REFRESH=1 to force reindex)" exit 0 fi if [[ -d ".codegraph" && "$REFRESH" == "1" ]]; then echo "[refresh] removing .codegraph/ before reindex" rm -rf .codegraph fi echo "[init] codegraph init -i" codegraph init -i [[ -d ".codegraph" ]] || { echo "ERROR: codegraph init did not create .codegraph/" >&2 exit 1 } echo "[done] .codegraph/ initialized" ``` - [ ] **Step 2: Implement `init_gitnexus.sh`** Write `scripts/init_gitnexus.sh`: ```bash #!/usr/bin/env bash # init_gitnexus.sh — run `gitnexus analyze .` in current project. set -euo pipefail if ! command -v gitnexus >/dev/null 2>&1; then echo "ERROR: gitnexus not on PATH. Run install_tools.sh first." >&2 exit 2 fi REFRESH="${KG_SETUP_REFRESH:-0}" if [[ -d ".gitnexus" && "$REFRESH" != "1" ]]; then echo "[skip] .gitnexus/ already exists (set KG_SETUP_REFRESH=1 to force reindex)" exit 0 fi if [[ "$REFRESH" == "1" ]]; then FORCE_FLAG="--force" else FORCE_FLAG="" fi echo "[init] gitnexus analyze . $FORCE_FLAG" gitnexus analyze . $FORCE_FLAG [[ -d ".gitnexus" ]] || { echo "ERROR: gitnexus analyze did not create .gitnexus/" >&2 exit 1 } echo "[done] .gitnexus/ initialized" ``` Make both executable: ```bash chmod +x scripts/init_codegraph.sh chmod +x scripts/init_gitnexus.sh ``` - [ ] **Step 3: Syntax checks** ```bash bash -n scripts/init_codegraph.sh bash -n scripts/init_gitnexus.sh echo "syntax ok" ``` - [ ] **Step 4: Smoke test — skip path** ```bash (cd /tmp && mkdir -p kg-test-init && cd kg-test-init && mkdir .codegraph && \ /Users/pavelmalkin/Projects/kg-setup/scripts/init_codegraph.sh) ``` Expected: `[skip] .codegraph/ already exists`. - [ ] **Step 5: Commit** ```bash git add scripts/init_codegraph.sh scripts/init_gitnexus.sh git commit -m "kg-setup: add init_codegraph.sh + init_gitnexus.sh" ``` --- ### Task 9: `merge_claude_md.py` — critical, fully TDD **Files:** - Create: `templates/claude_md_section.md` - Create: `scripts/merge_claude_md.py` - Create: `tests/test_merge_claude_md.py` - [ ] **Step 1: Write the template** Write `templates/claude_md_section.md`: ```markdown ## Knowledge Graph Local graph indices: - CodeGraph: `.codegraph/codegraph.db` (query via MCP server `codegraph`) - GitNexus: `.gitnexus/` (query via MCP server `gitnexus`) Obsidian notes: `{vault_root}/{repo_name}/_index.md` auto-memory: `~/.claude/projects/{slug}/memory/MEMORY.md` Refresh indices after major code changes: - `KG_SETUP_REFRESH=1 bash ~/.claude/scripts/init_codegraph.sh` - `KG_SETUP_REFRESH=1 bash ~/.claude/scripts/init_gitnexus.sh` ``` - [ ] **Step 2: Write failing tests** Write `tests/test_merge_claude_md.py`: ```python from pathlib import Path from merge_claude_md import merge, MARKER_START, MARKER_END, USER_CONTENT_MARKER TEMPLATE_VARS = { "vault_root": "/Users/u/Obsidian/Vault", "repo_name": "myrepo", "slug": "-Users-u-myrepo", } def test_merge_creates_file_when_missing(tmp_path): f = tmp_path / "CLAUDE.md" result = merge(f, TEMPLATE_VARS) assert result.action == "created" text = f.read_text() assert "## Knowledge Graph" in text assert MARKER_START in text assert "/Users/u/Obsidian/Vault/myrepo/_index.md" in text def test_merge_appends_to_existing_no_section(tmp_path): f = tmp_path / "CLAUDE.md" f.write_text("# My Project\n\nSome content.\n") result = merge(f, TEMPLATE_VARS) assert result.action == "appended" text = f.read_text() assert text.startswith("# My Project") assert "## Knowledge Graph" in text assert MARKER_START in text def test_merge_updates_generated_block_preserves_user_content(tmp_path): f = tmp_path / "CLAUDE.md" initial = f"""# Proj ## Knowledge Graph {MARKER_START} old stuff {MARKER_END} {USER_CONTENT_MARKER} My own notes here. Keep me. """ f.write_text(initial) result = merge(f, TEMPLATE_VARS) assert result.action == "updated" text = f.read_text() assert "old stuff" not in text assert "My own notes here." in text assert "Keep me." in text assert "/Users/u/Obsidian/Vault/myrepo/_index.md" in text def test_merge_skips_section_without_our_marker(tmp_path): f = tmp_path / "CLAUDE.md" initial = "# Proj\n\n## Knowledge Graph\n\nCustom user section.\n" f.write_text(initial) result = merge(f, TEMPLATE_VARS) assert result.action == "skipped_foreign_section" assert result.warning assert f.read_text() == initial # unchanged def test_merge_idempotent(tmp_path): f = tmp_path / "CLAUDE.md" merge(f, TEMPLATE_VARS) first = f.read_text() for _ in range(10): merge(f, TEMPLATE_VARS) assert f.read_text() == first def test_merge_atomic_write_no_tmp_leftover(tmp_path): f = tmp_path / "CLAUDE.md" merge(f, TEMPLATE_VARS) assert not (tmp_path / "CLAUDE.md.tmp").exists() ``` - [ ] **Step 3: Run tests — verify fail** ```bash cd ~/Projects/kg-setup && python3 -m pytest tests/test_merge_claude_md.py -v ``` Expected: FAIL `ModuleNotFoundError`. - [ ] **Step 4: Implement `merge_claude_md.py`** Write `scripts/merge_claude_md.py`: ```python """Idempotently merge the kg-setup 'Knowledge Graph' section into CLAUDE.md.""" from __future__ import annotations import os import re import sys from dataclasses import dataclass from pathlib import Path MARKER_START = "" MARKER_END = "" USER_CONTENT_MARKER = "" SECTION_HEADING = "## Knowledge Graph" TEMPLATE_PATH = Path(__file__).parent.parent / "templates" / "claude_md_section.md" @dataclass class MergeResult: action: str # created | appended | updated | skipped_foreign_section warning: str | None = None def _render_template(vars: dict) -> str: tpl = TEMPLATE_PATH.read_text() return tpl.format(**vars) def _atomic_write(path: Path, content: str) -> None: tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(content) os.replace(tmp, path) def _has_our_section(text: str) -> bool: return MARKER_START in text and MARKER_END in text def _has_foreign_section(text: str) -> bool: return SECTION_HEADING in text and MARKER_START not in text def _extract_user_content(text: str) -> str: idx = text.find(USER_CONTENT_MARKER) if idx == -1: return "" return text[idx + len(USER_CONTENT_MARKER):] def merge(claude_md: Path, template_vars: dict) -> MergeResult: rendered = _render_template(template_vars) if not claude_md.exists(): _atomic_write(claude_md, rendered) return MergeResult(action="created") existing = claude_md.read_text() if _has_our_section(existing): user_tail = _extract_user_content(existing) # Rebuild: everything before our section_heading + fresh rendered + # optionally carry forward the user-content tail (without its marker; template has its own) before_heading = existing.split(SECTION_HEADING, 1)[0].rstrip() + "\n\n" # rendered already ends with the user-content marker; we append the extracted tail after it new_text = before_heading + rendered.rstrip() + user_tail # Normalize to end with one newline if not new_text.endswith("\n"): new_text += "\n" _atomic_write(claude_md, new_text) return MergeResult(action="updated") if _has_foreign_section(existing): return MergeResult( action="skipped_foreign_section", warning=( "CLAUDE.md already has a '## Knowledge Graph' section without " "the kg-setup marker. Not overwriting. Remove manually or add " f"the marker '{MARKER_START}' to allow kg-setup to manage it." ), ) # Plain append sep = "" if existing.endswith("\n") else "\n" new_text = existing + sep + "\n" + rendered _atomic_write(claude_md, new_text) return MergeResult(action="appended") if __name__ == "__main__": if len(sys.argv) < 4: print("usage: merge_claude_md.py ", file=sys.stderr) sys.exit(2) result = merge( Path(sys.argv[1]), {"vault_root": sys.argv[2], "repo_name": sys.argv[3], "slug": sys.argv[4]}, ) print(f"action={result.action}") if result.warning: print(f"warning={result.warning}", file=sys.stderr) ``` - [ ] **Step 5: Run tests — verify pass** ```bash cd ~/Projects/kg-setup && python3 -m pytest tests/test_merge_claude_md.py -v ``` Expected: 6 passed. - [ ] **Step 6: Commit** ```bash git add templates/claude_md_section.md \ scripts/merge_claude_md.py \ tests/test_merge_claude_md.py git commit -m "kg-setup: add merge_claude_md.py (idempotent section merge) with tests" ``` --- ### Task 10: `build_obsidian_index.py` — generate `_index.md` content **Files:** - Create: `templates/obsidian_index_minimal.md` - Create: `templates/obsidian_index_rich.md` - Create: `scripts/build_obsidian_index.py` - Create: `tests/test_build_obsidian_index.py` - [ ] **Step 1: Write templates** Write `templates/obsidian_index_minimal.md`: ```markdown --- tags: [project, kg-index] project: {repo_name} setup_date: {today} --- # {repo_name} **Repo:** `{project_path}` **Languages:** {primary_lang} **LOC:** ~{loc} **Setup date:** {today} ## Quick links - Code graph local index: `{project_path}/.codegraph/` - CLAUDE.md: `{project_path}/CLAUDE.md` - State: `{project_path}/.kg-setup-state.json` ## What lives here Add notes on decisions, research, session logs below or in nested folders. ``` Write `templates/obsidian_index_rich.md`: ```markdown --- tags: [project, kg-index, rich] project: {repo_name} setup_date: {today} --- # {repo_name} **Repo:** `{project_path}` **Languages:** {primary_lang} **LOC:** ~{loc} **Setup date:** {today} ## Quick links - Code graph local index: `{project_path}/.codegraph/` - CLAUDE.md: `{project_path}/CLAUDE.md` - State: `{project_path}/.kg-setup-state.json` ## Sections - [[{repo_name}/sessions/_index|Sessions]] — chronological work logs - [[{repo_name}/knowledge/decisions/_index|Decisions]] — recorded design calls - [[{repo_name}/knowledge/patterns/_index|Patterns]] — reusable patterns and idioms ## What lives here Top-level: high-signal notes you want always visible. Nested folders: dated session logs, decisions, and patterns. Use backlinks liberally. ``` - [ ] **Step 2: Write failing tests** Write `tests/test_build_obsidian_index.py`: ```python from build_obsidian_index import render, choose_mode VARS = { "repo_name": "foo", "project_path": "/tmp/foo", "primary_lang": "python", "loc": 1234, "today": "2026-04-22", } def test_choose_mode_minimal_small_project(): assert choose_mode(loc=100, has_tests=False, has_docs=False, explicit=None) == "minimal" def test_choose_mode_rich_large_project(): assert choose_mode(loc=6000, has_tests=False, has_docs=False, explicit=None) == "rich" def test_choose_mode_rich_by_structure(): assert choose_mode(loc=100, has_tests=True, has_docs=True, explicit=None) == "rich" def test_choose_mode_explicit_overrides(): assert choose_mode(loc=100000, has_tests=True, has_docs=True, explicit="minimal") == "minimal" assert choose_mode(loc=1, has_tests=False, has_docs=False, explicit="rich") == "rich" def test_render_minimal_contains_required_fields(): text = render("minimal", VARS) assert "# foo" in text assert "/tmp/foo" in text assert "python" in text assert "1234" in text assert "2026-04-22" in text def test_render_rich_contains_section_links(): text = render("rich", VARS) assert "Sessions" in text assert "Decisions" in text assert "Patterns" in text def test_render_unknown_mode_raises(): try: render("weird", VARS) except ValueError: return raise AssertionError("expected ValueError") ``` - [ ] **Step 3: Run tests — verify fail** ```bash cd ~/Projects/kg-setup && python3 -m pytest tests/test_build_obsidian_index.py -v ``` Expected: FAIL `ModuleNotFoundError`. - [ ] **Step 4: Implement `build_obsidian_index.py`** Write `scripts/build_obsidian_index.py`: ```python """Generate Obsidian _index.md content for a project. Does not write to vault directly — returns rendered text so the caller (Claude, via Obsidian MCP write_note) performs the actual write. """ from __future__ import annotations import json import sys from datetime import date from pathlib import Path TEMPLATES = Path(__file__).parent.parent / "templates" LOC_RICH_THRESHOLD = 5000 def choose_mode( loc: int, has_tests: bool, has_docs: bool, explicit: str | None ) -> str: if explicit in ("minimal", "rich"): return explicit if loc > LOC_RICH_THRESHOLD: return "rich" if has_tests and has_docs: return "rich" return "minimal" def render(mode: str, vars: dict) -> str: if mode not in ("minimal", "rich"): raise ValueError(f"unknown mode: {mode}") tpl = (TEMPLATES / f"obsidian_index_{mode}.md").read_text() v = dict(vars) v.setdefault("today", date.today().isoformat()) return tpl.format(**v) if __name__ == "__main__": # Input: JSON on stdin with keys matching template vars + optional "mode" # plus "has_tests" and "has_docs" for mode selection. inp = json.loads(sys.stdin.read()) mode = choose_mode( loc=inp.get("loc", 0), has_tests=bool(inp.get("has_tests", False)), has_docs=bool(inp.get("has_docs", False)), explicit=inp.get("mode"), ) print(render(mode, inp)) ``` - [ ] **Step 5: Run tests — verify pass** ```bash cd ~/Projects/kg-setup && python3 -m pytest tests/test_build_obsidian_index.py -v ``` Expected: 7 passed. - [ ] **Step 6: Commit** ```bash git add templates/obsidian_index_*.md \ scripts/build_obsidian_index.py \ tests/test_build_obsidian_index.py git commit -m "kg-setup: add build_obsidian_index.py + templates with tests" ``` --- ### Task 11: `update_memory_index.py` — auto-memory pointer **Files:** - Create: `scripts/update_memory_index.py` - Create: `tests/test_update_memory_index.py` - [ ] **Step 1: Write failing tests** Write `tests/test_update_memory_index.py`: ```python from pathlib import Path from update_memory_index import ( path_to_slug, memory_dir_for, append_pointer, write_memory_file, ) def test_path_to_slug(): assert path_to_slug("/Users/u/Documents/foo") == "-Users-u-Documents-foo" assert path_to_slug("/a/b") == "-a-b" def test_memory_dir_for(tmp_path): # simulate ~/.claude/projects/ layout claude_home = tmp_path / ".claude" project_path = "/Users/u/x" expected = claude_home / "projects" / "-Users-u-x" / "memory" assert memory_dir_for(project_path, claude_home=claude_home) == expected def test_append_pointer_creates_memory_md(tmp_path): mem_dir = tmp_path / "memory" mem_dir.mkdir() (mem_dir / "MEMORY.md").write_text("- [existing](file.md) — hook\n") append_pointer( memory_dir=mem_dir, repo_name="myrepo", date_str="2026-04-22", ) text = (mem_dir / "MEMORY.md").read_text() assert "myrepo" in text assert "2026-04-22" in text assert "- [existing]" in text # previous line preserved def test_append_pointer_idempotent(tmp_path): mem_dir = tmp_path / "memory" mem_dir.mkdir() (mem_dir / "MEMORY.md").write_text("") for _ in range(3): append_pointer(memory_dir=mem_dir, repo_name="r", date_str="2026-04-22") text = (mem_dir / "MEMORY.md").read_text() assert text.count("[Knowledge graph bootstrap for r]") == 1 def test_append_pointer_skips_when_no_memory_dir(tmp_path): # memory dir does not exist → skipped, no error append_pointer(memory_dir=tmp_path / "nope", repo_name="r", date_str="2026-04-22") assert not (tmp_path / "nope").exists() def test_write_memory_file(tmp_path): mem_dir = tmp_path / "memory" mem_dir.mkdir() write_memory_file( memory_dir=mem_dir, repo_name="zz", project_path="/tmp/zz", date_str="2026-04-22", ) f = mem_dir / "project_kg_zz.md" assert f.exists() text = f.read_text() assert "name: Knowledge graph for zz" in text assert "type: project" in text assert "/tmp/zz" in text ``` - [ ] **Step 2: Run tests — verify fail** ```bash cd ~/Projects/kg-setup && python3 -m pytest tests/test_update_memory_index.py -v ``` Expected: FAIL `ModuleNotFoundError`. - [ ] **Step 3: Implement `update_memory_index.py`** Write `scripts/update_memory_index.py`: ```python """Append kg-setup pointer to user's auto-memory index.""" from __future__ import annotations import os import sys from pathlib import Path def path_to_slug(project_path: str) -> str: return project_path.replace("/", "-") def memory_dir_for( project_path: str, claude_home: Path | None = None ) -> Path: home = claude_home or Path.home() / ".claude" return home / "projects" / path_to_slug(project_path) / "memory" def append_pointer(memory_dir: Path, repo_name: str, date_str: str) -> str: """Append a pointer line to MEMORY.md. Idempotent. Returns action taken.""" if not memory_dir.exists(): return "skipped_no_memory_dir" memory_md = memory_dir / "MEMORY.md" line = ( f"- [Knowledge graph bootstrap for {repo_name}]" f"(project_kg_{repo_name}.md) — set up {date_str}, " f"graph in .codegraph, vault: {repo_name}/" ) existing = memory_md.read_text() if memory_md.exists() else "" if f"[Knowledge graph bootstrap for {repo_name}]" in existing: return "already_present" sep = "" if (not existing or existing.endswith("\n")) else "\n" new_text = existing + sep + line + "\n" _atomic_write(memory_md, new_text) return "appended" def write_memory_file( memory_dir: Path, repo_name: str, project_path: str, date_str: str ) -> str: if not memory_dir.exists(): return "skipped_no_memory_dir" target = memory_dir / f"project_kg_{repo_name}.md" content = f"""--- name: Knowledge graph for {repo_name} description: Pointer to graph indices, Obsidian vault folder, and CLAUDE.md section for {repo_name} type: project --- Project `{repo_name}` ({project_path}) has kg-setup applied on {date_str}. - CodeGraph MCP: server name `codegraph`, query with `mcp__codegraph__*` tools - GitNexus MCP: server name `gitnexus` - Obsidian vault folder: `{repo_name}/` - State file: `{project_path}/.kg-setup-state.json` - Reindex: set `KG_SETUP_REFRESH=1` and re-run init scripts """ _atomic_write(target, content) return "written" def _atomic_write(path: Path, content: str) -> None: tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(content) os.replace(tmp, path) if __name__ == "__main__": if len(sys.argv) < 4: print("usage: update_memory_index.py ", file=sys.stderr) sys.exit(2) project_path, repo_name, date_str = sys.argv[1], sys.argv[2], sys.argv[3] mem = memory_dir_for(project_path) print("pointer:", append_pointer(mem, repo_name, date_str)) print("file:", write_memory_file(mem, repo_name, project_path, date_str)) ``` - [ ] **Step 4: Run tests — verify pass** ```bash cd ~/Projects/kg-setup && python3 -m pytest tests/test_update_memory_index.py -v ``` Expected: 6 passed. - [ ] **Step 5: Commit** ```bash git add scripts/update_memory_index.py \ tests/test_update_memory_index.py git commit -m "kg-setup: add update_memory_index.py with tests" ``` --- ### Task 12: Run full test suite **Files:** - None to modify; verification only. - [ ] **Step 1: Run all Python tests** ```bash cd ~/Projects/kg-setup && python3 -m pytest tests/ -v ``` Expected: all tests from Tasks 2, 3, 9, 10, 11 pass (28 total). - [ ] **Step 2: Run all shell tests** ```bash tests/test_check_prereqs.sh ``` Expected: `PASS: check_prereqs.sh`. - [ ] **Step 3: Commit nothing (verification-only task, no code changes)** If coverage is green, move on. If any test fails, fix it before proceeding to Phase D. --- ## Phase D — Orchestrator + integration (Tasks 13-14) ### Task 13: Write `SKILL.md` orchestrator **Files:** - Create: `SKILL.md` - [ ] **Step 1: Write `SKILL.md`** Write `SKILL.md`: ````markdown --- name: kg-setup description: Bootstrap a 4-layer project memory system (CodeGraph + GitNexus MCP servers, project CLAUDE.md section, Obsidian vault folder with _index, auto-memory pointer). Use when user asks to "настрой граф знаний", "подключи базу знаний", "запомни проект", "setup knowledge graph", "bootstrap project memory", "initialize code graph", or "пусть ты помнишь детали проекта". --- # kg-setup You bootstrap a 4-layer project memory system in the user's current project directory: a local code knowledge graph (CodeGraph + GitNexus), a `## Knowledge Graph` section in the project's `CLAUDE.md`, a folder in the user's Obsidian vault with a seed `_index.md`, and a pointer entry in the user's auto-memory. All mechanical work is delegated to scripts in `~/.claude/scripts/` (installation path via symlink — see Task 15 of the plan). Do not re-implement any logic — invoke the scripts. ## Phases Run these phases in order. Each phase is a single logical unit. Stop and report to the user if a phase produces a blocking error. ### Phase 1 — DETECT Execute in parallel and collect JSON outputs: - `bash ~/.claude/scripts/check_prereqs.sh` → `prereqs` JSON - `python3 ~/.claude/scripts/detect_project.py "$PWD"` → `project` JSON Then read (best-effort): - `./CLAUDE.md` (if exists) — first 40 lines only - `./.kg-setup-state.json` (if exists) — full contents Form an in-memory `state` dict with these four sections. ### Phase 2 — PLAN Inspect `state` and construct the action list: 1. If `prereqs.errors` is non-empty (e.g. no Node, Node < 18) → STOP. Report errors to user, do not proceed. 2. If `.kg-setup-state.json` exists and reports `last_run_status=healthy`: - Ask the user: "Проект уже настроен (последний запуск: {last_run}). Что делать? (r) только health-check / (f) full refresh / (s) только Obsidian layer / (q) отмена". Default to (r) if no response. - If (q) → exit gracefully - If (r) → skip to Phase 4 - If (f) → set env `KG_SETUP_REFRESH=1` for this run; proceed through all steps - If (s) → only run Obsidian + memory steps 3. Otherwise, the full per-project pipeline. ### Phase 3 — EXECUTE Run in order. After each step, update the state dict; save state after the pipeline finishes (even on partial failure — mark `status=incomplete`). #### 3a. One-time (only if missing per prereqs): ``` bash ~/.claude/scripts/install_tools.sh bash ~/.claude/scripts/register_mcp.sh ``` Ask for user confirmation before running `install_tools.sh` on first run. Phrase: "Я установлю globally две npm-пакета: `gitnexus` и `@colbymchenry/codegraph`. OK?" #### 3b. Per-project: ``` KG_SETUP_REFRESH=${refresh:-0} bash ~/.claude/scripts/init_codegraph.sh KG_SETUP_REFRESH=${refresh:-0} bash ~/.claude/scripts/init_gitnexus.sh ``` If CLAUDE.md has uncommitted changes (check via `git status --porcelain CLAUDE.md` — non-empty = dirty) → ASK user to commit/stash before proceeding. Do not modify a dirty CLAUDE.md. ``` python3 ~/.claude/scripts/merge_claude_md.py \ ./CLAUDE.md \ "$VAULT_ROOT" \ "$REPO_NAME" \ "$SLUG" ``` #### 3c. Obsidian layer (via Obsidian MCP): Build `_index.md` content: ``` echo "$OBSIDIAN_INPUT_JSON" | python3 ~/.claude/scripts/build_obsidian_index.py ``` Where `OBSIDIAN_INPUT_JSON` contains: `repo_name`, `project_path`, `primary_lang`, `loc`, `has_tests` (bool: does `./tests/` exist), `has_docs` (bool: does `./docs/` exist), and optionally `mode` ("minimal" or "rich" if user asked explicitly). Then: - Use Obsidian MCP `list_directory` to check if `{repo_name}/` exists in vault. If not, MCP's `write_note` to `{repo_name}/_index.md` will create it. - Use `write_note` to place the rendered content at `{repo_name}/_index.md`. If the note already exists, do NOT overwrite unless user explicitly said "refresh obsidian" — instead, warn and skip. #### 3d. Auto-memory layer: ``` python3 ~/.claude/scripts/update_memory_index.py \ "$PROJECT_PATH" \ "$REPO_NAME" \ "$(date -u +%Y-%m-%d)" ``` #### 3e. Write state + gitignore: - Save state via `state.py` (use python inline: `python3 -c "from state import State; s = State(Path('.')); s.load(); s.status='healthy'; s.layers={...}; s.save()"`). Prefer writing a small helper call rather than inline if the string grows. - Ensure `.kg-setup-state.json` is in `.gitignore` (append if missing). ### Phase 4 — VERIFY ``` bash ~/.claude/scripts/health_check.sh "$PWD" ``` If exit code is non-zero, mark `state.status=degraded` and include which checks failed in the report. ### Phase 5 — REPORT Print a user-facing bulleted summary: ``` kg-setup complete for ✓ GitNexus + CodeGraph installed ✓ MCP servers registered (gitnexus, codegraph) ✓ .codegraph/ initialized (python, 4200 LOC) ✓ .gitnexus/ initialized ✓ CLAUDE.md — added "## Knowledge Graph" section ✓ Obsidian: {repo_name}/_index.md created (minimal mode) ✓ auto-memory: pointer added to MEMORY.md ✓ Health check: 6/6 passed Next: ask me any architectural question about the project — I'll query the graph via mcp__codegraph__* instead of reading files. ``` For any step that was skipped or failed, use the emoji `⊘` (skipped) or `✗` (failed) with the reason. ## Idempotency contract - Re-running on a healthy project with default answer "r" must produce zero file writes. - Re-running with "f" must re-run init scripts (fresh indices) and refresh generated blocks, preserving user-content tails. - Scripts in `scripts/` must each be individually safe to re-run. ## Error handling - **Blocking** (no Node, Node < 18, no git) → stop in Phase 2, print command the user should run (`brew install node`, etc.), do not touch disk. - **Recoverable** (npm flake) → scripts retry once internally; if still failing → surface to user with the stderr and next command. - **Degraded** (no Obsidian vault, unsupported language for codegraph) → skip that step, continue, mark in state.warnings. - **User conflict** (CLAUDE.md dirty, foreign `## Knowledge Graph` section, existing Obsidian `_index.md`) → stop the specific step, ask user, proceed with their choice. ```` - [ ] **Step 2: Commit** ```bash git add SKILL.md git commit -m "kg-setup: add SKILL.md orchestrator" ``` --- ### Task 14: Integration smoke test **Files:** - Create: `tests/integration_test.sh` - [ ] **Step 1: Write integration test** Write `tests/integration_test.sh`: ```bash #!/usr/bin/env bash # integration_test.sh — end-to-end smoke test of per-project scripts # on a synthetic temp project. Does NOT test install/register/Obsidian # (those require real external state). Covers: detect + init_codegraph # (skipped branch) + merge_claude_md + state. set -euo pipefail SKILL_ROOT="$(cd "$(dirname "$0")/.." && pwd)" WORK=$(mktemp -d -t kg-setup-integ-XXXX) trap 'rm -rf "$WORK"' EXIT cd "$WORK" git init -q -b main git remote add origin git@example.com:test/smoke.git echo "print('hi')" > app.py echo "flask" > requirements.txt git add . && git -c user.email=t@t -c user.name=t commit -q -m "init" echo "--- detect_project ---" python3 "$SKILL_ROOT/scripts/detect_project.py" "$WORK" echo "--- merge_claude_md (create) ---" python3 "$SKILL_ROOT/scripts/merge_claude_md.py" \ "$WORK/CLAUDE.md" "/vault" "smoke" "-tmp-smoke" grep -q "## Knowledge Graph" "$WORK/CLAUDE.md" grep -q "kg-setup-v1" "$WORK/CLAUDE.md" echo "--- merge_claude_md (idempotent) ---" cp "$WORK/CLAUDE.md" "$WORK/CLAUDE.md.before" python3 "$SKILL_ROOT/scripts/merge_claude_md.py" \ "$WORK/CLAUDE.md" "/vault" "smoke" "-tmp-smoke" diff -q "$WORK/CLAUDE.md.before" "$WORK/CLAUDE.md" echo "--- state write + read ---" python3 -c " import sys sys.path.insert(0, '$SKILL_ROOT/scripts') from pathlib import Path from state import State s = State(Path('$WORK')) s.status = 'healthy' s.layers = {'code_graph': {'configured': True}} s.save() s2 = State(Path('$WORK')) s2.load() assert s2.status == 'healthy', s2.status assert s2.layers['code_graph']['configured'] is True print('state roundtrip OK') " echo "--- health_check (expect exit 1, no setup) ---" if "$SKILL_ROOT/scripts/health_check.sh" "$WORK" >/dev/null; then echo "FAIL: expected non-zero exit from health_check on unsetup project" exit 1 fi echo "ALL INTEGRATION CHECKS PASSED" ``` Make executable: ```bash chmod +x tests/integration_test.sh ``` - [ ] **Step 2: Run it** ```bash tests/integration_test.sh ``` Expected: `ALL INTEGRATION CHECKS PASSED`. - [ ] **Step 3: Commit** ```bash git add tests/integration_test.sh git commit -m "kg-setup: add integration smoke test for per-project pipeline" ``` --- ## Phase E — Install + real-world smoke (Tasks 15-17) ### Task 15: Symlink into `~/.claude/skills/` **Files:** - External: `~/.claude/skills/kg-setup` symlink - [ ] **Step 1: Create the symlink** ```bash ln -sfn "$(pwd)" "$HOME/.claude/skills/kg-setup" ls -la "$HOME/.claude/skills/kg-setup" ``` Expected: symlink pointing to `~/Projects/kg-setup`. - [ ] **Step 2: Verify Claude Code picks it up** In a fresh Claude Code session (or the current one — the skill list reloads dynamically), check that `kg-setup` appears in the available skills list. The user verifies manually. - [ ] **Step 3: Commit nothing (external action, not repo)** --- ### Task 16: End-to-end smoke on a scratch project **Files:** - None in repo; external verification only. - [ ] **Step 1: Create a scratch project** ```bash mkdir -p /tmp/kg-e2e && cd /tmp/kg-e2e git init -q -b main git remote add origin git@example.com:test/kg-e2e.git echo "def add(a, b): return a + b" > calc.py echo "flask" > requirements.txt git add . && git commit -qm "init" ``` - [ ] **Step 2: Trigger the skill via Claude Code** In Claude Code (with cwd = `/tmp/kg-e2e`): say "настрой граф знаний для этого проекта". Claude should pick up `kg-setup`, run through phases, ask install confirmation, then complete. - [ ] **Step 3: Verify artifacts** ```bash ls -la /tmp/kg-e2e/ # Expect: .codegraph/, .gitnexus/, CLAUDE.md, .kg-setup-state.json cat /tmp/kg-e2e/.kg-setup-state.json # Expect: last_run_status: "healthy" grep "kg-setup-v1" /tmp/kg-e2e/CLAUDE.md # Expect: marker present claude mcp list | grep -E 'codegraph|gitnexus' # Expect: both rows ``` - [ ] **Step 4: Re-run and verify idempotency** Say: "настрой граф знаний" again. Claude should detect existing state, ask (r/f/s/q), default (r), run health check, print report. No file diffs. - [ ] **Step 5: Cleanup** ```bash rm -rf /tmp/kg-e2e ``` --- ### Task 17: Smoke on arb-scanner itself + final commit **Files:** - Potentially modifies `./CLAUDE.md`, `.kg-setup-state.json` (gitignored), `.gitignore`. Verification uses pre-existing project. - [ ] **Step 1: Ensure arb-scanner CLAUDE.md is committed** ```bash git status --porcelain CLAUDE.md ``` Expected: empty output (no uncommitted changes). If there are, commit or stash them first — the skill will refuse to write a dirty CLAUDE.md. - [ ] **Step 2: Trigger the skill on arb-scanner** Say to Claude: "настрой граф знаний для этого проекта". - [ ] **Step 3: Verify artifacts** ```bash ls -la .codegraph/ .gitnexus/ .kg-setup-state.json grep "kg-setup-v1" CLAUDE.md ``` - [ ] **Step 4: Verify Obsidian side** Via Obsidian MCP `list_directory arb-scanner/` — expect `_index.md` now in the folder alongside the existing 15 notes. - [ ] **Step 5: Verify auto-memory pointer** ```bash cat ~/.claude/projects/-Users-pavelmalkin-Documents-Scaner/memory/MEMORY.md | tail -5 ls ~/.claude/projects/-Users-pavelmalkin-Documents-Scaner/memory/project_kg_arb-scanner.md ``` Expected: new pointer line, new memory file. - [ ] **Step 6: Manual token-usage smoke** Ask me 3 architectural questions about arb-scanner ("describe matcher.py's arb detection", "which BKs are configured for proxy routing", "how is the scan loop started"). Confirm I use `mcp__codegraph__*` tool calls instead of reading source files. Note: this is a qualitative check, not a hard SLA. - [ ] **Step 7: Commit the resulting CLAUDE.md change** ```bash git add CLAUDE.md git commit -m "CLAUDE.md: add kg-setup Knowledge Graph section" ``` Note: `.codegraph/`, `.gitnexus/`, and `.kg-setup-state.json` are all gitignored (the skill appends them to `.gitignore` in Phase 3e if missing). --- ## Self-review checklist (performed before handoff) - [x] Every spec section maps to ≥1 task: Phase A covers §3.1 layers + §4.11 state; Phase B covers §4.3, §4.4, §4.10, §8; Phase C covers §4.5, §4.6, §4.7, §4.8, §4.9; Phase D covers §4.1 orchestrator + §7.2 integration; Phase E covers §9 install/deploy + §7.3 manual smoke. - [x] No "TBD", "add error handling", "similar to Task N" placeholders. - [x] Type/name consistency: `MARKER_START`, `MARKER_END`, `USER_CONTENT_MARKER` used consistently across `merge_claude_md.py` and its tests. `State` class, `STATE_FILENAME`, `SCHEMA_VERSION` consistent across `state.py`/tests. `KG_SETUP_REFRESH` env var naming consistent across scripts and SKILL.md. - [x] Every code step includes the actual code, not a reference. - [x] Every test step includes a specific run command + expected output. - [x] Commit messages match user's existing style (short, imperative, prefixed by component). - [x] Out-of-scope items (remote/SSH, cron re-index, chat→Obsidian) explicitly skipped in §10 of spec, not snuck back in. ## Execution handoff Plan complete and saved to `docs/superpowers/plans/2026-04-22-kg-setup.md`. Two execution options: 1. **Subagent-Driven (recommended)** — a fresh subagent is dispatched per task, the main session reviews between tasks. Fast iteration, clean context per task. 2. **Inline Execution** — tasks run in this session with checkpoints for review. More control, heavier on main-session context. Which approach?