Port the design spec and 17-task implementation plan from arb-scanner (where the idea was born) to this dedicated repo. Paths in the plan adjusted to treat this repo root as the skill root (no skills/kg-setup/ subdirectory). Implementation follows via subagent-driven-development. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
60 KiB
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+,claudeCLI
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.jsonI/O; used by every script that reads/writes statedetect_project.py— read-only; pure inspection, emits JSON, no side effectscheck_prereqs.sh— read-only inspection of system envinstall_tools.sh,register_mcp.sh,init_*.sh— system-state mutators; must be idempotentmerge_claude_md.py,build_obsidian_index.py,update_memory_index.py— file writers; must be atomic (tmp + rename)health_check.sh— read-only verificationSKILL.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
mkdir -p scripts templates tests
Write README.md:
# 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)
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
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:
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
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:
"""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
cd ~/Projects/kg-setup && python3 -m pytest tests/test_state.py -v
Expected: 5 passed.
- Step 5: Commit
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:
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
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:
"""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
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
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
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:
#!/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:
chmod +x tests/test_check_prereqs.sh
- Step 2: Run test to verify failure
tests/test_check_prereqs.sh
Expected: error that script file does not exist.
- Step 3: Implement
check_prereqs.sh
Write scripts/check_prereqs.sh:
#!/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 <<JSON
{
"schema_version": 1,
"env": {
"node": ${NODE_VERSION},
"node_major": ${NODE_MAJOR:-null},
"python": ${PYTHON_VERSION},
"git": ${GIT_VERSION},
"npm": ${NPM_VERSION}
},
"tools": {
${GITNEXUS_ENTRY},
${CODEGRAPH_ENTRY},
"gitnexus_mcp_registered": ${GITNEXUS_MCP},
"codegraph_mcp_registered": ${CODEGRAPH_MCP}
},
"obsidian": {
"mcp_available": null,
"vault_path_hint": ${VAULT_HINT}
},
"errors": ${ERRORS},
"warnings": ${WARNINGS}
}
JSON
Make executable:
chmod +x scripts/check_prereqs.sh
- Step 4: Run test to verify PASS
tests/test_check_prereqs.sh
Expected: PASS: check_prereqs.sh.
- Step 5: Smoke-run on this system
scripts/check_prereqs.sh | python3 -m json.tool
Expected: valid JSON with actual versions of node/python/git and false for gitnexus/codegraph if you haven't installed them yet.
- Step 6: Commit
git add scripts/check_prereqs.sh tests/test_check_prereqs.sh
git commit -m "kg-setup: add check_prereqs.sh with smoke test"
Phase B — System plumbing: install + MCP register + health (Tasks 5-7)
These mutate system state. Tested via actual smoke runs + idempotency checks, not unit tests.
Task 5: install_tools.sh — install GitNexus + CodeGraph
Files:
-
Create:
scripts/install_tools.sh -
Step 1: Implement
install_tools.sh
Write scripts/install_tools.sh:
#!/usr/bin/env bash
# install_tools.sh — install GitNexus + CodeGraph globally via npm.
# Idempotent: skips if already present. Retries once on npm failure.
set -euo pipefail
GITNEXUS_PKG="gitnexus"
CODEGRAPH_PKG="@colbymchenry/codegraph"
require_npm() {
command -v npm >/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:
chmod +x scripts/install_tools.sh
- Step 2: Dry-run verify script syntax
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:
scripts/install_tools.sh
Expected: [install] or [skip] lines for each tool, final state has both on PATH.
Verify:
which gitnexus && which codegraph
- Step 4: Idempotency check
scripts/install_tools.sh
Expected: both report [skip].
- Step 5: Commit
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:
#!/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:
chmod +x scripts/register_mcp.sh
- Step 2: Syntax check
bash -n scripts/register_mcp.sh && echo "syntax ok"
Expected: syntax ok.
- Step 3: Execute and verify (user approval gate)
scripts/register_mcp.sh
claude mcp list
Expected: list contains gitnexus and codegraph rows.
- Step 4: Idempotency check
scripts/register_mcp.sh
Expected: both [skip].
- Step 5: Commit
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:
#!/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 "<!-- generated:kg-setup-v1 -->" CLAUDE.md \
&& echo "true" || echo "false"
}
check_state_file() {
[[ -f ".kg-setup-state.json" ]] && echo "true" || echo "false"
}
RESULTS=$(cat <<JSON
{
"project": "$PROJECT",
"checks": {
"codegraph_mcp_listed": $(check_mcp_listed codegraph),
"gitnexus_mcp_listed": $(check_mcp_listed gitnexus),
"codegraph_index": $(check_codegraph_dir),
"gitnexus_index": $(check_gitnexus_dir),
"claude_md_section": $(check_claude_md_section),
"state_file": $(check_state_file)
}
}
JSON
)
echo "$RESULTS"
# Exit code: 0 if all core (codegraph_mcp + codegraph_index + state_file) pass
core_pass=$(echo "$RESULTS" | python3 -c "
import json, sys
d = json.load(sys.stdin)
c = d['checks']
print(c['codegraph_mcp_listed'] and c['codegraph_index'] and c['state_file'])
")
if [[ "$core_pass" == "True" ]]; then
exit 0
else
exit 1
fi
Make executable:
chmod +x scripts/health_check.sh
- Step 2: Syntax check
bash -n scripts/health_check.sh && echo "syntax ok"
Expected: syntax ok.
- Step 3: Smoke run in a clean directory
(cd /tmp && mkdir -p kg-test && cd kg-test && /Users/pavelmalkin/Projects/kg-setup/scripts/health_check.sh) || echo "exit=1 (expected, no setup)"
Expected: JSON with all false, exit 1.
- Step 4: Commit
git add scripts/health_check.sh
git commit -m "kg-setup: add health_check.sh for post-setup verification"
Phase C — Per-project logic (Tasks 8-12)
Task 8: init_codegraph.sh and init_gitnexus.sh
Files:
-
Create:
scripts/init_codegraph.sh -
Create:
scripts/init_gitnexus.sh -
Step 1: Implement
init_codegraph.sh
Write scripts/init_codegraph.sh:
#!/usr/bin/env bash
# init_codegraph.sh — run `codegraph init -i` in current project.
# Respects KG_SETUP_REFRESH env var to force re-index.
set -euo pipefail
if ! command -v codegraph >/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:
#!/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:
chmod +x scripts/init_codegraph.sh
chmod +x scripts/init_gitnexus.sh
- Step 3: Syntax checks
bash -n scripts/init_codegraph.sh
bash -n scripts/init_gitnexus.sh
echo "syntax ok"
- Step 4: Smoke test — skip path
(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
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:
## Knowledge Graph
<!-- generated:kg-setup-v1 -->
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`
<!-- /generated -->
<!-- user-content -->
<!-- Anything below is preserved between kg-setup runs. -->
- Step 2: Write failing tests
Write tests/test_merge_claude_md.py:
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
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:
"""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 = "<!-- generated:kg-setup-v1 -->"
MARKER_END = "<!-- /generated -->"
USER_CONTENT_MARKER = "<!-- user-content -->"
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 <claude_md_path> <vault_root> <repo_name> <slug>",
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
cd ~/Projects/kg-setup && python3 -m pytest tests/test_merge_claude_md.py -v
Expected: 6 passed.
- Step 6: Commit
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:
---
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:
---
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:
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
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:
"""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
cd ~/Projects/kg-setup && python3 -m pytest tests/test_build_obsidian_index.py -v
Expected: 7 passed.
- Step 6: Commit
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:
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
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:
"""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 <project_path> <repo_name> <date_str>",
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
cd ~/Projects/kg-setup && python3 -m pytest tests/test_update_memory_index.py -v
Expected: 6 passed.
- Step 5: Commit
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
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
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:
---
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 <repo_name>
✓ 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
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:
#!/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:
chmod +x tests/integration_test.sh
- Step 2: Run it
tests/integration_test.sh
Expected: ALL INTEGRATION CHECKS PASSED.
- Step 3: Commit
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-setupsymlink -
Step 1: Create the symlink
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
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
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
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
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
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
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
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)
- 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.
- No "TBD", "add error handling", "similar to Task N" placeholders.
- Type/name consistency:
MARKER_START,MARKER_END,USER_CONTENT_MARKERused consistently acrossmerge_claude_md.pyand its tests.Stateclass,STATE_FILENAME,SCHEMA_VERSIONconsistent acrossstate.py/tests.KG_SETUP_REFRESHenv var naming consistent across scripts and SKILL.md. - Every code step includes the actual code, not a reference.
- Every test step includes a specific run command + expected output.
- Commit messages match user's existing style (short, imperative, prefixed by component).
- 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:
- Subagent-Driven (recommended) — a fresh subagent is dispatched per task, the main session reviews between tasks. Fast iteration, clean context per task.
- Inline Execution — tasks run in this session with checkpoints for review. More control, heavier on main-session context.
Which approach?