From ceec425760705b719497172d86edc1926b9956bc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 12 Oct 2026 00:32:08 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20link-roles.sh=20=E2=80=94=20=D1=81?= =?UTF-8?q?=D0=B8=D0=BC=D0=BB=D0=B8=D0=BD=D0=BA=D0=B8=20=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9,=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B0=D0=BC=D1=8F=D1=82=D0=B8=20=D0=B8=D0=B7=20?= =?UTF-8?q?workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #2 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MzkPQWjfA7RpqYWkjnqJwt --- link-roles.sh | 61 ++++++++++++++++++++++++++++++++++++++++ tests/test-link-roles.sh | 58 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100755 link-roles.sh create mode 100644 tests/test-link-roles.sh diff --git a/link-roles.sh b/link-roles.sh new file mode 100755 index 0000000..e4b9c31 --- /dev/null +++ b/link-roles.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# claude-harness: симлинки из /.claude/ в репозитории направлений (роли, агенты, память агентов). +# Запуск: bash link-roles.sh [workspace] [--map = ...] +# Источник пар «каталог → роль»: таблица | Каталог | Репозиторий | Роль | в /CLAUDE.md (роли admin|analyst|qa) +# и/или пары --map. Идемпотентно. Обычные файлы на месте ссылок не трогает (ПРОПУЩЕН). +set -euo pipefail +WS="."; MAPS=() +while [ $# -gt 0 ]; do + case "$1" in + --map) MAPS+=("$2"); shift 2 ;; + *) WS="$1"; shift ;; + esac +done +cd "$WS"; WS="$(pwd)" +pairs=() +if [ -f CLAUDE.md ]; then + while IFS= read -r line; do pairs+=("$line"); done < <( + python3 - CLAUDE.md <<'PY' +import sys +for line in open(sys.argv[1], encoding="utf-8"): + if not line.startswith("|"): continue + cells = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cells) < 3: continue + d, role = cells[0].rstrip("/"), cells[2] + if role in ("admin", "analyst", "qa") and d and not d.startswith("-"): + print(f"{d}={role}") +PY + ) +fi +pairs+=("${MAPS[@]+"${MAPS[@]}"}") +[ ${#pairs[@]} -gt 0 ] || { echo "link-roles: нет пар каталог=роль (таблица в CLAUDE.md или --map)"; exit 0; } +mkdir -p .claude/roles .claude/agents .claude/agent-memory + +link() { # $1 = путь ссылки (относительно WS), $2 = цель (относительно каталога ссылки) + local l="$1" t="$2" + if [ -L "$l" ]; then + if [ "$(readlink "$l")" = "$t" ]; then echo " есть $l"; return 0; fi + rm "$l" + elif [ -e "$l" ]; then + echo " ПРОПУЩЕН $l — обычный файл/каталог, не симлинк; убери его вручную, если нужна ссылка"; return 0 + fi + ln -s "$t" "$l"; echo " создан $l → $t" +} + +for p in "${pairs[@]}"; do + d=${p%%=*}; r=${p#*=}; d=${d%/} + echo "$d ($r)" + [ -d "$d" ] || { echo " WARN каталог $d не найден"; continue; } + if [ -f "$d/.claude/roles/$r.md" ]; then + link ".claude/roles/$r.md" "../../$d/.claude/roles/$r.md" + else + echo " WARN нет $d/.claude/roles/$r.md — проектный файл роли не слинкован" + fi + for a in "$d"/.claude/agents/*.md; do + [ -f "$a" ] || continue + n=$(basename "$a" .md) + link ".claude/agents/$n.md" "../../$d/.claude/agents/$n.md" + mkdir -p "$d/.claude/agent-memory/$n" + link ".claude/agent-memory/$n" "../../$d/.claude/agent-memory/$n" + done +done diff --git a/tests/test-link-roles.sh b/tests/test-link-roles.sh new file mode 100644 index 0000000..a510b1d --- /dev/null +++ b/tests/test-link-roles.sh @@ -0,0 +1,58 @@ +# link-roles.sh: симлинки ролей, агентов и памяти из workspace в репозитории направлений. +L="$ROOT/link-roles.sh" +assert_file "link-roles.sh существует" "$L" +W="$TMP/ws"; mkdir -p "$W/admin/.claude/roles" "$W/admin/.claude/agents" "$W/qa/.claude/roles" "$W/docs" "$W/arm-core" +echo '# Роль: admin — проектный слой' > "$W/admin/.claude/roles/admin.md" +printf -- '---\nname: jira-admin\nmodel: sonnet\n---\n' > "$W/admin/.claude/agents/jira-admin.md" +echo '# Роль: qa — проектный слой' > "$W/qa/.claude/roles/qa.md" +cat > "$W/CLAUDE.md" <<'EOT' +# Проект + +| Каталог | Репозиторий | Роль | +|---|---|---| +| arm-core/ | RSHB/arm-core | dev | +| admin/ | RSHB/arm-ka-admin | admin | +| qa | RSHB/arm-ka-qa | qa | +| docs/ | RSHB/arm-ka-docs | — | +| source/ | — | | +EOT +out=$(bash "$L" "$W" 2>&1); rc=$? +assert_eq "первый запуск: exit 0" "$rc" "0" +assert_symlink "roles/admin.md → admin/" "$W/.claude/roles/admin.md" +assert_symlink "roles/qa.md → qa/ (без завершающего /)" "$W/.claude/roles/qa.md" +assert_eq "roles/admin.md относительный" "$(readlink "$W/.claude/roles/admin.md")" "../../admin/.claude/roles/admin.md" +assert_symlink "agents/jira-admin.md" "$W/.claude/agents/jira-admin.md" +assert_symlink "agent-memory/jira-admin" "$W/.claude/agent-memory/jira-admin" +assert_file "целевой каталог памяти создан" "$W/admin/.claude/agent-memory/jira-admin" +assert_eq "dev не линкуется" "$(ls "$W/.claude/roles" | grep -c dev)" "0" +assert_eq "docs/source пропущены" "$(ls "$W/.claude/roles" | wc -l | tr -d ' ')" "2" +assert_grep "печатает 'создан'" 'создан' <(printf '%s' "$out") +out2=$(bash "$L" "$W" 2>&1); rc=$? +assert_eq "второй запуск: exit 0" "$rc" "0" +assert_eq "второй запуск: ничего не создано" "$(printf '%s' "$out2" | grep -c 'создан')" "0" +assert_grep "второй запуск: 'есть'" 'есть' <(printf '%s' "$out2") +assert_eq "второй запуск: ссылка прежняя" "$(readlink "$W/.claude/agent-memory/jira-admin")" "../../admin/.claude/agent-memory/jira-admin" +# чужой обычный файл на месте ссылки не затирается +rm "$W/.claude/roles/qa.md"; echo 'мой файл' > "$W/.claude/roles/qa.md" +out3=$(bash "$L" "$W" 2>&1) +assert_eq "обычный файл сохранён" "$(cat "$W/.claude/roles/qa.md")" "мой файл" +assert_grep "обычный файл → ПРОПУЩЕН" 'ПРОПУЩЕН' <(printf '%s' "$out3") +# обычный каталог на месте ссылки памяти тоже не затирается +rm "$W/.claude/agent-memory/jira-admin"; mkdir "$W/.claude/agent-memory/jira-admin" +out5=$(bash "$L" "$W" 2>&1); rc=$? +assert_eq "каталог на месте ссылки: exit 0" "$rc" "0" +assert_eq "каталог на месте ссылки: не симлинк" "$([ -L "$W/.claude/agent-memory/jira-admin" ] && echo link || echo dir)" "dir" +assert_grep "каталог на месте ссылки → ПРОПУЩЕН" 'ПРОПУЩЕН' <(printf '%s' "$out5") +# --map без CLAUDE.md +W2="$TMP/ws2"; mkdir -p "$W2/an/.claude/roles"; echo x > "$W2/an/.claude/roles/analyst.md" +bash "$L" "$W2" --map an=analyst >/dev/null 2>&1 +assert_symlink "--map an=analyst" "$W2/.claude/roles/analyst.md" +# источник роли отсутствует → предупреждение, не ошибка +W3="$TMP/ws3"; mkdir -p "$W3/qa" +printf '| Каталог | Репозиторий | Роль |\n|---|---|---|\n| qa/ | x | qa |\n' > "$W3/CLAUDE.md" +out4=$(bash "$L" "$W3" 2>&1); rc=$? +assert_eq "нет файла роли в репозитории: exit 0" "$rc" "0" +assert_grep "нет файла роли: WARN" 'WARN' <(printf '%s' "$out4") +# CLAUDE.md без таблицы и без --map: не падает +W4="$TMP/ws4"; mkdir -p "$W4"; echo '# пусто' > "$W4/CLAUDE.md" +bash "$L" "$W4" >/dev/null 2>&1; assert_eq "нет пар: exit 0" "$?" "0"