diff --git a/dsh/PORT.md b/dsh/PORT.md index 26f4531..a34d04b 100644 --- a/dsh/PORT.md +++ b/dsh/PORT.md @@ -16,7 +16,7 @@ This is a port, not a copy. Three layers map differently: | Skills | `~/.claude/skills`, `.claude/skills` | `~/.dsh/skills`, `.agents/skills`, `customSkillDirs` | 1:1 format | | Roles | `roles/`, `/role` skill | same files + `/role` command + model-invocable `role` skill | close | | Hard rules | `PreToolUse` shell hooks | `ctx.tools.guard` Cordis guard | logic reused, wrapper new | -| Superpowers | plugin + `SessionStart` hook | skills + `agent/pre-step` bootstrap | content 1:1, bootstrap new | +| Superpowers | plugin + `SessionStart` hook | pinned sync of tag `v7.0.1` (`DSH_SUPERPOWERS_REF`) into `~/.dsh/skills` + `agent/pre-step` bootstrap | content adapted by sync, bootstrap new | | SDD | `Task` (synchronous) | `subagent` (asynchronous) / `workflow` | semantics differ | | Gitea | CLI + skills | `bash` + skills | 1:1 | | OpenSpec | CLI + `.claude/skills` + `/opsx:*` | CLI + проектные `.agents/skills` | close: нет слэш-команд | @@ -81,16 +81,20 @@ file has to be installed as `$DSH_HOME/AGENTS.md` because DSH does not read - **Full installer**: `install.sh` prepares global instructions and skills; the bundle itself is installed by `plugin_manager` because the Desktop profile is Electron-managed. -- **Upstream pin (change `dsh-port-v2`, D-2)**: the default install source is - the clone at `~/.dsh/superpowers` pinned to `$DSH_SUPERPOWERS_REF` (default - `v7.0.1`). `install.sh --skills` clones or updates it, runs the vendored - sync + verify, and copies the adapted skills; a broken or empty synced tree - is never installed. Explicit sources (`--superpowers-from`, +- **Pinned sync of superpowers (change `dsh-port-v2`, D-2)**: "vendored + superpowers" is gone — no upstream skill text is copied as-is. The default + install source is the clone at `~/.dsh/superpowers` pinned to + `$DSH_SUPERPOWERS_REF` (default `v7.0.1`). `install.sh --skills` clones or + updates it, runs the vendored `dsh/sync/sync-from-upstream.mjs` (asserted + transforms, namespace strip, `dsh-tools.md` override) and + `dsh/sync/verify-skills.mjs`, and copies only the adapted tree; a broken or + empty synced tree is never installed. Explicit sources (`--superpowers-from`, `$DSH_SUPERPOWERS_SOURCE`) are synced directly with no network. Upstream moves independently, so each release is a deliberate pin bump (an issue per - release, D-7). Removing the legacy `~/.claude` fallbacks is deferred until - every machine has rerun `--skills --roles`; until then both runtime and - installer warn when they use one. + release, D-7; the procedure is in `dsh/README.md`). Removing the legacy + `~/.claude` fallbacks is deferred until every machine has rerun + `--skills --roles`; until then both runtime and installer warn when they use + one. ## 4. Architecture @@ -170,6 +174,8 @@ for the repository layout: guards: true bootstrap: true roleCommand: true + selfCheck: true # activation check of the DSH contract; not-ok is logged + checkCommand: true # register the dsh-harness-check command bootstrapSubagents: false hooksDir: '' # empty -> bundle/hooks -> ../../hooks rolesDir: '' # empty -> ~/.dsh/roles, legacy ~/.claude/roles, repo roles/ diff --git a/dsh/README.md b/dsh/README.md index e54378a..87e8ac7 100644 --- a/dsh/README.md +++ b/dsh/README.md @@ -23,9 +23,13 @@ separate Cordis bundle plus a thin install helper. (ставит `openspec init --tools agents`); глобальной копии нет, слэш-команд нет. Установка в проект: `bash dsh/install.sh --openspec`. -**Not yet:** `briefing`, `harness-check`, `role-hint`, `harness-doctor`, -auto-memory, caveman. See [PORT.md](PORT.md) for the design, mapping table, and -risks. +- self-check — at activation the bundle verifies the DSH contract it depends on + and logs every missing service; the `dsh-harness-check` command re-runs the + same check on demand. + +**Not yet:** `briefing`, the `harness-check` SessionStart hook, `role-hint`, +`harness-doctor`, auto-memory, caveman. See [PORT.md](PORT.md) for the design, +mapping table, and risks. ## Quick start @@ -74,6 +78,29 @@ Do not hand-edit `$DSH_HOME/profiles/desktop/package.json` or `cordis.patch.yml`: the Desktop profile is Electron-managed and the plugin manager owns those writes. +## Update + +Upstream superpowers moves on its own schedule, so a pin bump is a deliberate +step, never a background pull (design D-2, cadence D-7): + +1. One issue per `obra/superpowers` release (sync → tests → PR). Compare the + release tags with the pin in `dsh/install.sh`. +2. Bump `DSH_SUPERPOWERS_REF` in `dsh/install.sh` and the pin quoted in this + file and in `PORT.md`; then run the sync and the verifier by hand against a + checkout of the new tag: + `node dsh/sync/sync-from-upstream.mjs --out --ref `, + then `node dsh/sync/verify-skills.mjs `. A changed transform anchor or a + changed skill set is a hard `FAIL`: adapt the transform or the expected set, + do not bypass it. +3. Reinstall and check: `bash dsh/install.sh --skills`, then + `bash dsh/install.sh --check`. The source line must name the new ref and the + synced version (`ref v7.0.2 (synced, version 7.0.2)`); `--check` prints the + configured pin next to the ref recorded by the last sync, so a pin that was + bumped without resyncing (or a sync that never ran) is visible instead of + silently old text. +4. `bash dsh/tests/run.sh` must stay green. The fixtures are a reduced upstream + tree, so a transform that stopped matching fails there first. + ## Verify 1. In a new session, ask `what skills do you have?` — the superpowers catalog @@ -148,6 +175,8 @@ repository layout: guards: true bootstrap: true roleCommand: true + selfCheck: true # activation check of the DSH contract; not-ok is logged + checkCommand: true # register the dsh-harness-check command bootstrapSubagents: false hooksDir: '' # empty -> bundle/hooks -> ../../hooks rolesDir: '' # empty -> ~/.dsh/roles, legacy ~/.claude/roles, repo roles/ diff --git a/dsh/sync/sync-from-upstream.mjs b/dsh/sync/sync-from-upstream.mjs index 517a87e..e57d1ad 100644 --- a/dsh/sync/sync-from-upstream.mjs +++ b/dsh/sync/sync-from-upstream.mjs @@ -322,10 +322,18 @@ function parseArgs(argv) { } } -/** True when `parent` is `child` or one of its ancestors (`/a` → `/a/b`). */ +/** + * True when `parent` is `child` or one of its ancestors (`/a` → `/a/b`). + * + * A parent hop is exactly `..` or `..…`; a sibling named `..foo` also makes + * `relative()` start with `..` but is a descendant, so the collision guard must + * still fire for it (T1.9). + */ function isAncestorOrSame(parent, child) { const rel = relative(parent, child) - return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) + if (rel === '') return true + if (rel === '..' || rel.startsWith('..' + sep)) return false + return !isAbsolute(rel) } /** @returns {Promise} repo-relative file paths under `root`. */ diff --git a/dsh/sync/verify-skills.mjs b/dsh/sync/verify-skills.mjs index bb20bc8..25343e4 100644 --- a/dsh/sync/verify-skills.mjs +++ b/dsh/sync/verify-skills.mjs @@ -28,8 +28,9 @@ * regression this check exists for (T2.1/T2.2). Names that are * illustrative examples rather than real files (`scripts/tool.sh`, …) * are skipped, as in the vendored reference implementation; that - * whitelist is a deliberate compromise, not a general escape hatch - * (T2.3 in the SDD ledger). + * whitelist is a deliberate compromise, not a general escape hatch — + * it covers exactly four literals and hides nothing real in v7.0.1 + * (T2.3/T2.8 in the SDD ledger). * * The prefix check is scoped to SKILL.md bodies on purpose: reference files * such as `using-superpowers/references/dsh-tools.md` quote `superpowers:` in @@ -58,10 +59,11 @@ const TRAILING_PUNCTUATION = /[.,;:)\]]+$/ * Names that appear only as illustrative examples in prose, never as real * files — the same list the vendored reference implementation keeps. * - * Deliberate compromise: upstream `writing-skills` shows `bash scripts/tool.sh` - * as an example, and no such file ships (see T2.3 in the SDD ledger). The list - * is closed and exact-path matched, so it cannot hide a real dangling link in - * another skill. + * The protection is deliberately narrow (T2.3/T2.8): it skips exactly these + * four literals, matched as whole inline-code tokens — not a prefix, not a + * glob, not a path segment. Checked on obra/superpowers v7.0.1: no real + * upstream link or path uses any of these names, so nothing that exists is + * skipped today. A new name here needs the same evidence. */ const ILLUSTRATIVE = new Set(['scripts/tool.sh', 'scripts/tool.js', 'scripts/helper.py', 'scripts/helper.sh']) diff --git a/dsh/tests/sync.test.mjs b/dsh/tests/sync.test.mjs index 60a593b..df269d5 100644 --- a/dsh/tests/sync.test.mjs +++ b/dsh/tests/sync.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' -import { cpSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' @@ -135,6 +135,34 @@ test('sync refuses --out that collides with the upstream checkout', () => { } }) +test('sync refuses --out that is an ancestor named like a parent hop (T1.9)', () => { + const work = tempDir('dsh-sync-collision-dotdot-') + try { + // `relative(out, upstream)` is `..foo` here: a sibling directory whose name + // starts with `..`, not a parent hop. Treating every `..`-prefixed relative + // path as unrelated would let the swap delete the checkout it reads. + const out = join(work, 'out') + const upstream = join(out, '..foo') + mkdirSync(upstream, { recursive: true }) + cpSync(fixture, upstream, { recursive: true }) + write(join(upstream, 'sentinel.txt'), 'source stays\n') + + const result = runSync([upstream, '--out', out, '--ref', 'v7.0.1']) + + assert.equal(result.status, 1, `--out ${out} must be refused`) + assert.match(result.stderr, /--out collides with the upstream checkout/) + assert.equal(readFileSync(join(upstream, 'sentinel.txt'), 'utf8'), 'source stays\n') + assert.equal(existsSync(join(upstream, 'skills', 'using-superpowers', 'SKILL.md')), true) + assert.deepEqual( + readdirSync(work).filter((name) => name.includes('.tmp-')), + [], + 'a refused sync must not create its staging tree', + ) + } finally { + cleanup(work) + } +}) + test('sync fails on a transform whose source file upstream dropped', () => { const work = tempDir('dsh-sync-missing-transform-file-') try { diff --git a/dsh/tests/verify.test.mjs b/dsh/tests/verify.test.mjs index 9ab9b00..8428bd0 100644 --- a/dsh/tests/verify.test.mjs +++ b/dsh/tests/verify.test.mjs @@ -250,6 +250,23 @@ test('verify ignores prefixes and paths inside fenced examples', () => { } }) +test('verify fails on a dangling inline reference (T2.7)', () => { + const work = tempDir('dsh-verify-dangling-inline-') + try { + const dir = syncedCopy(work, syncedTree(work)) + const file = join(dir, 'using-superpowers', 'SKILL.md') + write(file, `${readFileSync(file, 'utf8')}\nSee \`references/nope.md\` for the full mapping.\n`) + + const result = verify(dir) + + assert.equal(result.status, 1, `stdout: ${result.stdout}`) + assert.match(result.stderr, /using-superpowers\/SKILL\.md/) + assert.match(result.stderr, /references\/nope\.md/) + } finally { + cleanup(work) + } +}) + test('verify does not flag a resolving inline reference', () => { const work = tempDir('dsh-verify-inline-reference-') try { diff --git a/openspec/changes/dsh-port-v2/design.md b/openspec/changes/dsh-port-v2/design.md index dd2bc77..ddf379b 100644 --- a/openspec/changes/dsh-port-v2/design.md +++ b/openspec/changes/dsh-port-v2/design.md @@ -51,6 +51,21 @@ RV-6 (legacy-фолбэки как техдолг), RV-11 (нет compat-чек | D-9 | Модели исполнителей (указание владельца 24.10.2026 «простые задачи — дешёвой модели»): механика и простые правки — `deepseek-official/deepseek-flash`, интеграция и ревью — `deepseek-official/deepseek-v4-pro`; фиксируется в брифах плана | Экономия на рутине без потери качества ревью | Сложность недооценена → flash не справился, fix-loop дороже; страхуется ревьюером на v4-pro | | D-10 | #63 (ложный блок worktree-цепочки, O-4) — bounded-задача **до старта плана** (решение владельца 24.10.2026); CC-сторона хуков входит в скоуп только этим исключением | Блок бьёт по каждому чеклисту закрытия; фикс маленький и независим от dsh/ | Два параллельных потока правок в claude-harness (#63 и дизайн #69) — не пересекаются по файлам: #63 — `hooks/` + тесты, #69 — `dsh/` | +## Статус решений (25.10.2026, ветка `feat/69-dsh-port-v2`) + +- **D-5 — выполнено в этой ветке.** `.claude/code-paths` содержит строку + `dsh/` (проверено 24.10.2026), вендоренный `dsh/sync/` попадает под + require-plan; строку держит assert в `dsh/tests/install.test.mjs` + (`code-paths lists dsh/`), чтобы каталог не выпал из хука молча. +- **D-6 — выполнено в этой ветке.** Самопроверка живёт в + `dsh/bundle/lib/selfcheck.js` (чистый `collectSelfCheck(ctxLike)`) и + `dsh/bundle/command-check.js` (команда `dsh-harness-check`), подключается + аддитивно в `dsh/bundle/index.js`; ключи конфига `selfCheck`/`checkCommand` + (default `true`); тесты — `dsh/tests/selfcheck.test.mjs`; риск и чем закрыт + — PORT.md §7. Терминальный `--check` остался офлайн-проверкой файлов и + окружения: SDK внутри `app.asar` ему недоступен, поэтому контракт + проверяется только изнутри DSH (требование `selfcheck` в спеке-дельте). + ## Открытые вопросы (к владельцу) — решены 24.10.2026 1. ~~Момент в DSH-сессии, когда обвязка споткнула~~ — ответ «пока трудно diff --git a/openspec/changes/dsh-port-v2/proposal.md b/openspec/changes/dsh-port-v2/proposal.md index f270dc4..6d8b9eb 100644 --- a/openspec/changes/dsh-port-v2/proposal.md +++ b/openspec/changes/dsh-port-v2/proposal.md @@ -31,10 +31,11 @@ Issue: #69. Источники: ревью обвязки 24.10.2026 (наход **Вариант C** — взять машинерию готового порта как форк/вендор, но sync гонять самим по своему каденсу: -- В `dsh/sync/` вендорятся `scripts/sync-from-upstream.mjs`, - `scripts/verify-skills.mjs`, `scripts/check-dsh-compat.mjs` из - LayneChai/superpowers-dsh (MIT) с адаптацией путей и transform-ов под - наш `dsh-tools.md`. +- В `dsh/sync/` вендорятся два скрипта LayneChai/superpowers-dsh (MIT): + `scripts/sync-from-upstream.mjs` и `scripts/verify-skills.mjs` — с + адаптацией путей и transform-ов под наш `dsh-tools.md`. Их + `scripts/check-dsh-compat.mjs` не вендорим: его идея переезжает в + самопроверку бандла (см. ниже и design D-6). - Пин на тег obra/superpowers (v7.0.1 на момент плана) живёт в репозитории, а не в голове: `DSH_SUPERPOWERS_REF`, переменная в `dsh/install.sh` и в sync-скрипте. #67 (авто-pull unpinned HEAD) отменяется — rejected. diff --git a/openspec/changes/dsh-port-v2/specs/dsh-install/spec.md b/openspec/changes/dsh-port-v2/specs/dsh-install/spec.md new file mode 100644 index 0000000..77532fc --- /dev/null +++ b/openspec/changes/dsh-port-v2/specs/dsh-install/spec.md @@ -0,0 +1,241 @@ +# Spec Delta: dsh-install + +Дельта change `dsh-port-v2` (#69). Доставка superpowers переходит с «сырой +копии клона» на sync по пину `v7.0.1`, бандл получает самопроверку контракта. + +**BREAKING:** `--skills` без явного источника теперь требует сеть (клон пина); +офлайн-прогон без клона — `FAIL`, а не тихий legacy и не «WARN + код 0». +Отмена #67 (авто-pull unpinned HEAD) — решение design.md D-2. + +## ADDED Requirements + +### Requirement: Sync по пину +`dsh/install.sh --skills` без явного источника MUST получить superpowers из +клона `$DSH_HOME/superpowers` на пине `$DSH_SUPERPOWERS_REF` (default +`v7.0.1`) из `$DSH_SUPERPOWERS_REPO` (default +`https://github.com/obra/superpowers`) и MUST прогнать sync +(`dsh/sync/sync-from-upstream.mjs`) и verify (`dsh/sync/verify-skills.mjs`) до +копирования в `$DSH_HOME/skills`. + +- Копирование MUST брать только synced-дерево; сырой текст апстрима при пине + MUST NOT попадать в `$DSH_HOME/skills`. Адаптации: `superpowers:`-префиксы + сняты, ссылки ведут на `using-superpowers/references/dsh-tools.md` из + `dsh/bundle/`, чужие harness-референсы не копируются. +- Sync MUST завершаться `FAIL` (код 1) на несовпадении якоря transform-а + (ноль или больше одного совпадения), на изменении набора скиллов и на + отсутствии `skills/` в checkout. При любом падении назначение (`--out`) + MUST остаться прежним, а staging-каталог MUST быть удалён. +- Verify MUST проверять YAML frontmatter (`name` и `description` непусты, без + дублей ключей), отсутствие остаточных `superpowers:`-префиксов в телах + `SKILL.md` и резолв всех относительных ссылок внутри synced-дерева. Дерево, + прошедшее verify с нулём скиллов (`verified 0 skills`), MUST трактоваться + как `FAIL`: пустое дерево не устанавливается. +- В `/.sync-meta.json` MUST записываться `ref` (значение `--ref`) и + `version` (версия из `.claude-plugin/plugin.json` апстрима); `--check` MUST + печатать ref и version из этого файла, не обращаясь в сеть. +- Повторный `--skills` MUST быть идемпотентным: каталоги `*.tmp-*` не + остаются, установленные копии заменяются целиком (`rm -rf` + `cp -R`), а не + дополняются. + +#### Scenario: Свежий HOME, установка по пину +- **WHEN** `HOME` пуст, флаг и `DSH_SUPERPOWERS_SOURCE` не заданы, а + `DSH_SUPERPOWERS_REPO` указывает на репозиторий с тегом `v7.0.1` +- **THEN** `--skills` клонирует пин, печатает `synced N files` и + `verified 15 skills`, копирует адаптированные скиллы; в + `using-superpowers/SKILL.md` есть `references/dsh-tools.md` и нет + `superpowers:`, а `references/dsh-tools.md` совпадает с + `dsh/bundle/dsh-tools.md` + +#### Scenario: Сломанный якорь апстрима +- **WHEN** checkout на пине не содержит якоря одного из transform-ов +- **THEN** sync печатает `FAIL` с именем transform-а и завершается кодом 1, + прежнее `--out` и `$DSH_HOME/skills` не изменены + +#### Scenario: Изменился набор скиллов +- **WHEN** в checkout на пине появился или исчез каталог скилла относительно + ожидаемого списка +- **THEN** sync печатает `added upstream:` / `removed upstream:` и завершается + кодом 1; установка ничего не копирует + +#### Scenario: Verify нашёл висячую ссылку +- **WHEN** synced-дерево содержит ссылку на несуществующий файл (markdown или + inline-код) +- **THEN** verify печатает `/SKILL.md: dangling relative reference: …` и + завершается кодом 1; в `$DSH_HOME/skills` ничего не копируется + +#### Scenario: Нулевое дерево +- **WHEN** verify завершился кодом 0, но сообщил `verified 0 skills` +- **THEN** установка завершается `FAIL`, ни один скилл не скопирован + +#### Scenario: Повторный прогон +- **WHEN** `--skills` запущен второй раз на том же пине +- **THEN** установленные скиллы заменены, в `$DSH_HOME/superpowers` нет + каталогов `*.tmp-*`, а `.sync-meta.json.ref` равен пину + +### Requirement: Самопроверка контракта бандла +Бандл `dsh-harness` MUST при активации проверять контракт DSH, на который +опирается, и MUST предоставлять команду `dsh-harness-check` для повторного +прогона той же проверки. + +- Проверка MUST покрывать `ctx.tools.guard`, `ctx.on` для `agent/pre-step`, + `ctx.inject` (регистрация команд) и `workingDirectory.get`. Каждая + отсутствующая служба MUST логироваться строкой + `[dsh-harness] self-check: not-ok: `. +- Отсутствие службы MUST отключать только свою фичу: без `ctx.tools.guard` + guard-обвязка пропускается, bootstrap и `/role` продолжают работать при + своих службах. Активация бандла MUST NOT падать целиком из-за несовпадения + контракта. +- Команда `dsh-harness-check` MUST возвращать текстовый отчёт с одной строкой + `ok`/`not-ok` на каждую проверяемую фичу. +- Ключи конфига `selfCheck` и `checkCommand` (default `true`) MUST позволять + выключить активационную проверку и команду соответственно. +- Терминальный `--check` MUST оставаться офлайн-проверкой файлов и окружения: + SDK внутри `app.asar` ему недоступен, контракт проверяется только изнутри + DSH. + +#### Scenario: Сломанный контракт при активации +- **WHEN** бандл активируется в `ctx` без `tools.guard` +- **THEN** в лог попадает `self-check: guards not-ok: tools.guard`, guard-wiring + пропускается, bootstrap и команды продолжают работать, бандл не падает + +#### Scenario: Проверка по требованию +- **WHEN** пользователь вызывает `/dsh-harness-check` +- **THEN** отчёт перечисляет каждую фичу со статусом `ok` или `not-ok` + +## MODIFIED Requirements + +### Requirement: Источник superpowers задаётся при установке +`dsh/install.sh --skills` SHALL разрешать источник superpowers-скиллов в +порядке приоритета. Сеть нужна только ветке пина; явный источник +синхронизируется локально. + +- Порядок: `--superpowers-from ` → `$DSH_SUPERPOWERS_SOURCE` → + клон `$DSH_HOME/superpowers` на `$DSH_SUPERPOWERS_REF` → + legacy-кэш Claude Code → legacy `~/.claude/skills`. +- Явный источник (`--superpowers-from`, `$DSH_SUPERPOWERS_SOURCE`) MUST NOT + требовать сети и MUST NOT проверять пин. Допускаются две формы: корень + checkout (есть `skills/using-superpowers/SKILL.md`) или каталог `skills` + такого корня. Синк явного источника MUST идти в staging вне каталога клона + (`$DSH_HOME/.skills.synced`), потому что `$DSH_HOME/superpowers` зарезервирован + под клон пина. +- Каталог скиллов, который не является ни checkout-корнем, ни его `skills/`, + MUST быть `FAIL` (код 1) и в `--skills`, и в `--check`: вердикты двух команд + MUST совпадать. Тихий откат на legacy запрещён. +- Клон-пин: отсутствующий или пустой каталог MUST клонироваться + `git clone --depth 1 --branch `; неудачный клон MUST + удалять недоклонированный каталог (полусостояние не остаётся). +- Существующий клон MUST обновляться fetch'ем от `$DSH_SUPERPOWERS_REPO` (не от + origin клона) с force-рефспеком тега + (`+refs/tags/:refs/tags/`) и checkout `FETCH_HEAD`: смена + репозитория и переехавший тег MUST подхватываться следующим прогоном. + Офлайн клон MAY переиспользоваться, только если HEAD уже ровно на пине, и + тогда MUST печататься `WARN`; иначе — `FAIL` с командой клона. +- Непустой каталог на месте клона без рабочего `.git` MUST быть `FAIL` (код 1) + без изменения каталога. +- Копирование MUST быть заменой каталога назначения (`rm -rf` + `cp -R`), а не + симлинком: `~/.dsh/skills` не должен зависеть от каталога-источника. + +#### Scenario: Явный источник без ~/.claude +- **WHEN** `HOME` — пустой temp-каталог без `~/.claude`, а `--superpowers-from` + указывает на каталог superpowers +- **THEN** `--skills` синхронизирует и копирует скиллы в `$DSH_HOME/skills`, + печатает источник и завершается кодом 0, не обращаясь в сеть + +#### Scenario: Переменная окружения вместо флага +- **WHEN** задан `DSH_SUPERPOWERS_SOURCE=` и флаг `--superpowers-from` не передан +- **THEN** источник берётся из переменной; поведение совпадает с флагом + +#### Scenario: Флаг важнее переменной +- **WHEN** заданы и `--superpowers-from`, и `DSH_SUPERPOWERS_SOURCE` +- **THEN** используется каталог из флага + +#### Scenario: Невалидный явный источник +- **WHEN** `--superpowers-from` указывает на каталог без + `using-superpowers/SKILL.md` +- **THEN** `--skills` печатает `FAIL` с ожидаемой структурой и завершается + кодом 1, ничего не копируя из legacy-источников + +#### Scenario: Голый каталог скиллов +- **WHEN** явный источник — каталог `my-skills` с + `using-superpowers/SKILL.md`, но без checkout-корня +- **THEN** и `--skills`, и `--check` печатают `FAIL: explicit superpowers source + must be a checkout root or its skills/ directory` и завершаются кодом 1 + +#### Scenario: Пиннинг в ~/.dsh +- **WHEN** существует клон `$DSH_HOME/superpowers` с + `skills/using-superpowers/SKILL.md` и флаг/переменная не заданы +- **THEN** источником служит этот клон, а `--check` офлайн печатает + `ok: superpowers source ()` и ref из `.sync-meta.json` + +#### Scenario: Обновление клона от DSH_SUPERPOWERS_REPO +- **WHEN** клон уже есть, а `DSH_SUPERPOWERS_REPO` сменился или тег переехал +- **THEN** прогон фетчит от `DSH_SUPERPOWERS_REPO` с force-рефспеком, делает + checkout `FETCH_HEAD` и устанавливает текст новой версии тега + +#### Scenario: Офлайн-переиспользование клона +- **WHEN** клон существует, HEAD ровно на пине, сеть недоступна +- **THEN** `--skills` печатает `WARN: offline; reusing the existing clone` и + завершается кодом 0 + +#### Scenario: Каталог без .git на месте клона +- **WHEN** `$DSH_HOME/superpowers` непустой и не является git-клоном +- **THEN** `--skills` завершается `FAIL` (код 1), каталог не изменяется + +### Requirement: Отсутствие источника не бывает тихим +Если источник superpowers недоступен, `--skills` MUST завершаться `FAIL`, кроме +случая, когда существует legacy-источник. + +- Офлайн, без явного источника, без клона на пине и без legacy MUST печататься + `FAIL: no superpowers source` с готовой командой клона + (`git clone --depth 1 --branch /superpowers`) и код + возврата 1. Тихий успех запрещён даже при уже установленных копиях. +- Уже установленные копии в `$DSH_HOME/skills` MUST NOT удаляться или + портиться при таком `FAIL`. +- Если legacy-источник существует — скиллы копируются сырым текстом с + `WARN: legacy superpowers source` и кодом 0 (путь живёт до #66). +- Локальные скиллы обвязки (`dsh/skills/*`: `role`, `sdd`) MUST ставиться в + любом исходе: они не зависят от superpowers. + +#### Scenario: Первая установка без источника +- **WHEN** `HOME` пуст, `$DSH_HOME/skills` не существует, клон получить не + удалось, legacy нет +- **THEN** `--skills` печатает `FAIL: no superpowers source` и команду клона, + код 1, но `$DSH_HOME/skills/role/SKILL.md` и `$DSH_HOME/skills/sdd/SKILL.md` + на месте + +#### Scenario: Повторная установка без источника +- **WHEN** `$DSH_HOME/skills/using-superpowers/SKILL.md` уже есть, а клон + получить не удалось +- **THEN** `--skills` печатает `FAIL: no superpowers source`, код 1, + установленный файл не изменён (раньше здесь был `WARN` и код 0) + +### Requirement: ~/.claude — только legacy-fallback с предупреждением +superpowers-скиллы и роли MUST иметь рабочий путь `~/.dsh`; пути `~/.claude` +SHALL оставаться fallback'ом для машин, где `--skills --roles` ещё не +перезапускали, и MUST сопровождаться предупреждением. + +- Установка: legacy-кэш Claude Code и `~/.claude/skills` MAY быть источником + только после того, как клон пина получить не удалось; копирование в этом + случае — сырое (без sync и verify), с + `WARN: legacy superpowers source` и подсказкой перейти на пин/явный источник. +- `--check`: legacy-источник — `WARN`, явный/пин — `ok`, отсутствие — `WARN` с + подсказкой клона, невалидный явный источник — `FAIL` и код 1 + (пользователь задал его сам, подсказка «нет источника» здесь неверна). +- Runtime: bootstrap и резолвер ролей сохраняют порядок `~/.dsh` → `~/.claude`, + но при выборе legacy-пути один раз за процесс пишут в лог предупреждение. +- Удаление fallback'ов — отдельное изменение (#66); в этом change они не + удаляются. + +#### Scenario: Legacy-кэш как источник установки +- **WHEN** в `HOME` есть только кэш Claude Code с superpowers, `$DSH_HOME` пуст +- **THEN** `--skills` копирует скиллы и печатает `WARN: legacy superpowers + source` + +#### Scenario: Runtime-предупреждение +- **WHEN** bootstrap резолвит `using-superpowers` из `~/.claude/...` +- **THEN** в лог один раз за процесс попадает предупреждение с предложением + выполнить `dsh/install.sh --skills` + +#### Scenario: ~/.dsh приоритетнее +- **WHEN** существуют и `~/.dsh/skills/using-superpowers/SKILL.md`, и legacy-копия +- **THEN** используется `~/.dsh`, предупреждения нет