#!/usr/bin/env bash # claude-harness doctor: снимок состояния обвязки на машине и в проекте + самотест хуков. # Запуск: bash doctor.sh [папка проекта] — ничего не меняет, только читает. set -uo pipefail CC="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" DIR="${1:-.}" ok(){ printf ' ok %s\n' "$*"; }; miss(){ printf ' MISS %s\n' "$*"; }; warn(){ printf ' WARN %s\n' "$*"; }; info(){ printf ' - %s\n' "$*"; } # allow-правило для п. 4 закрытия (решение владельца 2): строку добавляет владелец, doctor показывает, где она есть allow_delete(){ # $1 — корень проекта или workspace local r line r=$(python3 - "$1/.claude/settings.json" "$1/.claude/settings.local.json" "$CC/settings.json" <<'PY' 2>/dev/null import json, sys cmd = "git push origin --delete " # правило должно покрывать любую ветку: feat/…, chore/archive-… for f in sys.argv[1:]: try: d = json.load(open(f, encoding="utf-8")) except FileNotFoundError: continue except Exception: print("bad " + f) continue allow = (d.get("permissions") or {}).get("allow") if isinstance(d, dict) else None for a in allow if isinstance(allow, list) else []: if not isinstance(a, str): continue p = a[5:-1] if a.startswith("Bash(") and a.endswith(")") else ("" if a == "Bash" else None) if p is None: continue p = p[:-2] if p.endswith(":*") else p[:-1] if p.endswith("*") else p if a == "Bash" else None if p is not None and cmd.startswith(p): print("ok " + f) sys.exit() PY ) while IFS= read -r line; do case "$line" in "bad "*) info "${line#bad } — не JSON, allow-правила не прочитаны" ;; esac; done <<< "$r" case "$r" in *"ok "*) ok "allow git push origin --delete (${r##*ok })" ;; *) warn "нет allow-правила для git push origin --delete (закрытие, п. 4) — строка в .claude/settings.json → permissions.allow: \"Bash(git push origin --delete:*)\"" ;; esac } echo "## harness-doctor $(date +%F' '%T)" echo "## Машина" for b in claude git python3 node npm tea openspec codegraph; do command -v "$b" >/dev/null 2>&1 && ok "$b $( { $b --version 2>/dev/null || true; } | head -1 | cut -c1-40)" || miss "$b" done if [ -f "$CC/plugins/installed_plugins.json" ]; then for p in superpowers caveman; do grep -q "\"$p@" "$CC/plugins/installed_plugins.json" && ok "plugin $p" || miss "plugin $p"; done for p in beads template-bridge; do grep -q "\"$p@" "$CC/plugins/installed_plugins.json" && warn "plugin $p установлен (конфликт)"; done else warn "нет $CC/plugins/installed_plugins.json"; fi [ -f "$CC/CLAUDE.md" ] && ok "глобальный CLAUDE.md ($(grep -c . "$CC/CLAUDE.md") строк; маркировка: $(grep -q 'R-n' "$CC/CLAUDE.md" && echo да || echo нет); codegraph projectPath: $(grep -q projectPath "$CC/CLAUDE.md" && echo да || echo нет))" || miss "глобальный CLAUDE.md" for h in guard-main require-plan harness-check role-hint; do f="$CC/hooks/$h.sh" if [ -x "$f" ]; then grep -q "$h.sh" "$CC/settings.json" 2>/dev/null && ok "hook $h.sh установлен и зарегистрирован" || warn "hook $h.sh есть, но не зарегистрирован в settings.json" else miss "hook $h.sh"; fi done [ -f "$CC/hooks/harness_target.py" ] && ok "hooks/harness_target.py (ядро guard-main и require-plan)" || miss "hooks/harness_target.py (bash install.sh)" [ -x "$CC/harness/adopt-project.sh" ] && ok "~/.claude/harness/ (adopt/new-project)" || miss "~/.claude/harness/adopt-project.sh" [ -x "$CC/harness/link-roles.sh" ] && ok "~/.claude/harness/link-roles.sh" || miss "~/.claude/harness/link-roles.sh" rmiss=(); for r in dev admin analyst qa; do [ -f "$CC/roles/$r.md" ] || rmiss+=("$r"); done if [ ${#rmiss[@]} -eq 0 ]; then ok "roles/ (dev admin analyst qa)"; else for r in "${rmiss[@]}"; do miss "roles/$r.md"; done; fi [ -f "$CC/skills/role/SKILL.md" ] && ok "skill role" || miss "skill role (~/.claude/skills/role/SKILL.md)" [ -f "$CC/agents/briefing.md" ] && ok "agent briefing" || miss "agent briefing (~/.claude/agents/briefing.md)" if [ -f "$CC/CLAUDE.md" ]; then n=$(wc -c < "$CC/CLAUDE.md" | tr -d ' '); [ "$n" -le 3072 ] && ok "глобальный CLAUDE.md $n Б (≤ 3 КБ)" || warn "глобальный CLAUDE.md $n Б > 3 КБ"; fi [ -f "$HOME/.config/caveman/config.json" ] && ok "caveman config: $(tr -d ' \n' < "$HOME/.config/caveman/config.json" | cut -c1-40)" || warn "нет ~/.config/caveman/config.json" grep -q 'codegraph prompt-hook' "$CC/settings.json" 2>/dev/null && warn "codegraph prompt-hook в settings.json" env | grep -E '^(AITUNNEL|OPENAI|GROQ|ANTHROPIC)_API_KEY=' | cut -d= -f1 | while read -r k; do warn "в окружении есть $k — виден всем агентам, тесты с ключом платные"; done echo "## Самотест хуков (фиктивные вызовы, ничего не пишется)" T=$(mktemp -d); git -C "$T" init -q -b main 2>/dev/null; mkdir -p "$T/src" hk(){ # name json expected local r; printf '%s' "$2" | bash "$CC/hooks/$1.sh" >/dev/null 2>&1; r=$? [ "$r" = "$3" ] && ok "$1: $4 → exit $r" || warn "$1: $4 → exit $r (ожидалось $3)" } if [ -x "$CC/hooks/guard-main.sh" ]; then hk guard-main "{\"cwd\":\"$T\",\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"$T/x\"}}" 2 "Write на main" hk guard-main "{\"cwd\":\"$T\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"echo hi > x\"}}" 2 "Bash echo > на main" hk guard-main "{\"cwd\":\"$T\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"git worktree add ../w -b f\"}}" 0 "git worktree на main" mkdir -p "$T/openspec"; echo a > "$T/openspec/x.md"; git -C "$T" add openspec 2>/dev/null hk guard-main "{\"cwd\":\"$T\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"git commit -m archive\"}}" 2 "git commit openspec-only на main" git -C "$T" reset -q 2>/dev/null git -C "$T" switch -q -c feat/1-x 2>/dev/null hk guard-main "{\"cwd\":\"$T\",\"tool_name\":\"Write\",\"tool_input\":{\"file_path\":\"$T/x\"}}" 0 "Write на ветке" fi if [ -x "$CC/hooks/require-plan.sh" ]; then hk require-plan "{\"cwd\":\"$T\",\"tool_input\":{\"file_path\":\"$T/src/a.ts\"}}" 2 "src/ без плана" hk require-plan "{\"cwd\":\"$T\",\"tool_input\":{\"file_path\":\"$T/api/src/a.py\"}}" 2 "api/src/ без плана" touch "$T/.bounded"; hk require-plan "{\"cwd\":\"$T\",\"tool_input\":{\"file_path\":\"$T/src/a.ts\"}}" 0 "src/ с .bounded" fi if [ -x "$CC/hooks/role-hint.sh" ]; then o=$(printf '{"session_title":"admin: спринт"}' | bash "$CC/hooks/role-hint.sh" 2>/dev/null) case "$o" in *"Роль сессии: admin"*) ok "role-hint: заголовок admin → подсказка" ;; *) warn "role-hint: заголовок admin → '$o' (ожидалась подсказка)" ;; esac o=$(printf '{"cwd":"/tmp","source":"resume"}' | bash "$CC/hooks/role-hint.sh" 2>/dev/null) [ -z "$o" ] && ok "role-hint: без заголовка → тишина" || warn "role-hint: без заголовка → '$o' (ожидалась тишина)" fi rm -rf "$T" HC="$CC/skills/harness-doctor/scripts/hook_corpus.py" if [ -f "$HC" ] && [ -f "$CC/hooks/harness_target.py" ]; then out=$(python3 "$HC" "$CC/hooks" --summary 2>&1); rc=$? printf '%s\n' "$out" | grep '^MISMATCH' | sed 's/^MISMATCH / /' [ "$rc" = 0 ] && ok "$(printf '%s\n' "$out" | tail -1)" || warn "$(printf '%s\n' "$out" | tail -1) — расхождения выше" else miss "корпус хуков (skills/harness-doctor/scripts/hook_corpus.py, hooks/harness_target.py)"; fi echo "## Проект: $(cd "$DIR" 2>/dev/null && pwd)" if root=$(git -C "$DIR" rev-parse --show-toplevel 2>/dev/null); then main=$(git -C "$root" rev-parse --git-common-dir 2>/dev/null); main=$(cd "$root" && cd "$(dirname "$main")" 2>/dev/null && pwd) info "git root: $root$( [ "$main" != "$root" ] && echo " (worktree; основной: $main)")" info "ветка: $(git -C "$root" branch --show-current 2>/dev/null || echo 'detached') чистое дерево: $([ -z "$(git -C "$root" status --porcelain)" ] && echo да || echo НЕТ)" git -C "$root" worktree list 2>/dev/null | sed 's/^/ /' [ -f "$root/CLAUDE.md" ] && { grep -q '^## OpenSpec и superpowers' "$root/CLAUDE.md" && ok "CLAUDE.md с секцией харнеса" || warn "CLAUDE.md без секции «OpenSpec и superpowers»"; } || miss "CLAUDE.md" [ -f "$root/ARCHITECTURE.md" ] && ok "ARCHITECTURE.md" || info "ARCHITECTURE.md нет" if [ -d "$root/openspec" ]; then ok "openspec: specs $(ls "$root/openspec/specs" 2>/dev/null | wc -l | tr -d ' '), changes $(ls -d "$root"/openspec/changes/*/ 2>/dev/null | grep -v archive | wc -l | tr -d ' '), archive $(ls "$root/openspec/changes/archive" 2>/dev/null | wc -l | tr -d ' ')" for c in "$root"/openspec/changes/*/; do [ "$(basename "$c")" = archive ] && continue; [ -d "$c" ] || continue info "change $(basename "$c"): decisions $(grep -c '^[0-9]\+\. ' "$c/design.md" 2>/dev/null || echo 0)"; done else miss "openspec/"; fi if [ -d "$root/.codegraph" ]; then [ -f "$root/.codegraph/codegraph.db" ] && ok "codegraph: индекс есть$( [ -f "$root/.codegraph/daemon.pid" ] && echo ', демон pid '$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["pid"])' "$root/.codegraph/daemon.pid" 2>/dev/null))" || warn "codegraph: папка есть, индекса нет — codegraph init" [ "$main" != "$root" ] && [ -f "$main/.codegraph/codegraph.db" ] && info "MCP, скорее всего, смотрит на индекс основного checkout: в вызовах нужен projectPath=$root" else miss ".codegraph/ (codegraph init)"; fi info "планы: $(ls "$root"/docs/superpowers/plans/*.md 2>/dev/null | wc -l | tr -d ' ')$( [ -f "$root/.bounded" ] && echo ' .bounded: есть')" [ -d "$root/.superpowers/sdd" ] && info "SDD workspace: $(ls "$root/.superpowers/sdd" | tr '\n' ' ')" [ -f "$root/.claude/code-paths" ] && info "code-paths: $(grep -v '^#' "$root/.claude/code-paths" | tr '\n' ' ')" || info "code-paths: умолчание (любой src/)" [ -f "$root/.claude/readonly-paths" ] && info "readonly-paths: $(grep -v '^#' "$root/.claude/readonly-paths" | tr '\n' ' ')" git -C "$root" check-ignore -q .bounded 2>/dev/null || warn ".bounded не в .gitignore" allow_delete "$root" [ -d "$root/.git/hooks" ] && [ -x "$root/.git/hooks/pre-commit" ] && ok "git pre-commit есть" || info "git pre-commit нет" if command -v tea >/dev/null 2>&1 && git -C "$root" remote get-url origin >/dev/null 2>&1; then info "remote: $(git -C "$root" remote get-url origin)" (cd "$root" && tea issues ls --limit 5 2>/dev/null | sed 's/^/ /') || warn "tea не отвечает (логин/токен?)" fi elif [ -f "$DIR/CLAUDE.md" ] && grep -Eq '^\|[[:space:]]*Каталог[[:space:]]*\|[[:space:]]*Репозиторий[[:space:]]*\|[[:space:]]*Роль[[:space:]]*\|' "$DIR/CLAUDE.md"; then ws=$(cd "$DIR" && pwd) info "workspace (не git): $ws" n=$(wc -c < "$ws/CLAUDE.md" | tr -d ' '); [ "$n" -le 5120 ] && ok "CLAUDE.md workspace $n Б (≤ 5 КБ)" || warn "CLAUDE.md workspace $n Б > 5 КБ" for kind in roles agents; do for l in "$ws/.claude/$kind"/*; do [ -e "$l" ] || [ -L "$l" ] || continue name="$kind/$(basename "$l")" if [ -L "$l" ]; then [ -e "$l" ] && ok "$name → $(readlink "$l")" || warn "$name — битая ссылка на $(readlink "$l")" else info "$name — обычный файл (не ссылка)"; fi done done grep -E '^\|' "$ws/CLAUDE.md" | awk -F'|' '{gsub(/ /,"",$4); gsub(/^ +| +$/,"",$2); if (($4=="admin"||$4=="analyst"||$4=="qa") && $2!="" && $2 !~ /^-/) print $2, $4}' | while read -r d r; do [ -f "$ws/${d%/}/.claude/roles/$r.md" ] || warn "нет ${d%/}/.claude/roles/$r.md — запусти link-roles.sh после создания" done # slug каталога памяти Claude Code: все не-алфавитно-цифровые символы пути → «-» (например, /Users/a.b/x → -Users-a-b-x) slug=$(printf '%s' "$ws" | sed 's#[^A-Za-z0-9]#-#g'); mem="$CC/projects/$slug/memory/MEMORY.md" if [ -f "$mem" ]; then n=$(wc -c < "$mem" | tr -d ' '); [ "$n" -le 3072 ] && ok "индекс auto-memory $n Б (≤ 3 КБ)" || warn "индекс auto-memory $n Б > 3 КБ ($mem)"; else info "индекс auto-memory: нет ($mem)"; fi allow_delete "$ws" [ -x "$CC/harness/link-roles.sh" ] && info "обновить ссылки: bash $CC/harness/link-roles.sh $ws" else info "не git-репозиторий и не workspace (нет таблицы репозиториев в CLAUDE.md)"; fi