Merge pull request 'DSH port v2: доставка superpowers вариантом C (sync-по-пину, самопроверка бандла) (#69)' (#72) from feat/69-dsh-port-v2 into main
This commit is contained in:
commit
653d45598c
287
docs/superpowers/plans/2026-10-24-dsh-port-v2.md
Normal file
287
docs/superpowers/plans/2026-10-24-dsh-port-v2.md
Normal file
@ -0,0 +1,287 @@
|
||||
# DSH port v2 (вариант C) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use the `sdd` skill (DSH) to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Перевести доставку superpowers-скиллов на вариант C: вендор sync-машинерии LayneChai, пин на тег v7.0.1, install.sh гоняет sync→verify→copy, бандл получает аддитивную самопроверку контракта.
|
||||
|
||||
**Architecture:** `dsh/sync/` — два вендорённых Node-скрипта (только stdlib): `sync-from-upstream.mjs` (ассертные transform-ы + staging-swap + override нашего `dsh-tools.md`) и `verify-skills.mjs` (frontmatter/префиксы/ссылки). `dsh/install.sh` оркестрирует: resolve источника (explicit/env → клон по пину → legacy сырой+WARN) → sync → verify → копирование в `$DSH_HOME/skills`. Бандл получает `lib/selfcheck.js` (чистые чекеры контракта ctx) и команду `dsh-harness-check`; существующее поведение бандла не меняется.
|
||||
|
||||
**Tech Stack:** bash (`set -euo pipefail`), Node builtins (без `@deepseek-ai/*`-импортов), git, python3 для существующих хуков. DSH pinned 0.2.1-rc.3.
|
||||
|
||||
**Spec:** `openspec/changes/dsh-port-v2/` — [proposal.md](../openspec/changes/dsh-port-v2/proposal.md) + [design.md](../openspec/changes/dsh-port-v2/design.md) (D-1…D-10; план спорит от них).
|
||||
|
||||
**Models (D-9):** Tasks 1, 2, 5 — `deepseek-official/deepseek-flash` (механика, готовый код); Tasks 3, 4 — `deepseek-official/deepseek-v4-pro` (интеграция); ревью каждой задачи и финальное ревью ветки — `deepseek-official/deepseek-v4-pro`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Runtime-зависимости sync и бандла — только Node builtins; запрещён импорт `@deepseek-ai/*` (ловит `dsh/tests/package.test.mjs`).
|
||||
- `dsh/install.sh` идемпотентен; `--check` никогда не ходит в сеть; невалидный явный источник — FAIL (exit 1), не молчаливый фолбэк.
|
||||
- Пин: `DSH_SUPERPOWERS_REF=v7.0.1`, репозиторий `DSH_SUPERPOWERS_REPO` (default `https://github.com/obra/superpowers`).
|
||||
- Legacy-фолбэки `~/.claude/*` остаются (сырой копией + WARN) до #66 — в этой работе не удаляются.
|
||||
- Тесты — `node --test` через `bash dsh/tests/run.sh`; весь набор зелёный, плюс `bash tests/run.sh` (CC-сторона) не ломается.
|
||||
- Маркировка: находки с маркером и статусом; коммиты со ссылкой на issue (`(#69)`).
|
||||
- Язык субагентов (решение владельца 24.10.2026): брифы и отчёты implementer'ов — на английском; маркеры, статусы, термины обвязки и слэш-команды не переводятся; финальные отчёты контроллера владельцу — по-русски.
|
||||
- Синхронизация апстрима — только по тегу; HEAD не используется нигде.
|
||||
|
||||
## Review Focus
|
||||
|
||||
| # | Вход/условие | Ожидаемое поведение |
|
||||
|---|---|---|
|
||||
| RF1 | Sync против checkout не v7.0.1 (якорь изменился/исчез) | FAIL loudly (exit 1), staging не подменяет назначение |
|
||||
| RF2 | `--skills` без сети и без клона/источника | FAIL с готовой подсказкой, не «пустой успех» |
|
||||
| RF3 | Повторный `--skills` (идемпотентность) | Повторный прогон не ломает установленное; нет мусора staging |
|
||||
| RF4 | Бандл активируется без `ctx.tools.guard` (сломан контракт DSH) | Самопроверка помечает guards not-ok, остальное живо; бандл не падает целиком |
|
||||
| RF5 | Недоклонированный каталог на месте клона | FAIL без оставленного полусостояния |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Вендор sync-from-upstream
|
||||
|
||||
**Files:**
|
||||
- Create: `dsh/sync/sync-from-upstream.mjs`
|
||||
- Create: `dsh/tests/fixtures/superpowers-min/` (минимальное дерево: `using-superpowers/SKILL.md` с якорями transform-ов, `brainstorming/SKILL.md` с `superpowers:`-префиксами и висячими harness-референсами, `references/claude-code-tools.md`, `.claude-plugin/plugin.json` с `"version": "7.0.1"`)
|
||||
- Create: `dsh/tests/sync.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `dsh/bundle/dsh-tools.md` (единственный источник маппинга).
|
||||
- Produces: CLI `node dsh/sync/sync-from-upstream.mjs <upstream-checkout> [--out <dir>] [--ref <tag>]`; exit 0 — печать `synced N files…`, exit 1 — FAIL с именем упавшего transform/якоря; `--out` по умолчанию `<checkout>/.sync-out`; staging-каталог `<out>.tmp-<pid>`; `--out` подменяется только после всех ассертов; в `--out/.sync-meta.json` пишется `{"ref": "<tag|unknown>", "version": "<plugin.json version>"}`.
|
||||
|
||||
- [ ] **Step 1: Напиши падающие тесты** в `dsh/tests/sync.test.mjs` (helpers: `tempDir`, `write`, `spawnSync` из `./helpers.mjs`; фикстура — копия `dsh/tests/fixtures/superpowers-min/`):
|
||||
|
||||
```js
|
||||
test('sync happy path: transforms, namespace strip, override, meta', ...)
|
||||
// assert: exit 0; out/using-superpowers/SKILL.md содержит 'references/dsh-tools.md'
|
||||
// и НЕ содержит 'superpowers:'; out/using-superpowers/references/dsh-tools.md
|
||||
// == dsh/bundle/dsh-tools.md; out/.sync-meta.json.ref === 'v7.0.1'
|
||||
// (запуск с --ref v7.0.1); references/claude-code-tools.md отсутствует (SKIP).
|
||||
|
||||
test('sync fails loud on broken anchor', ...)
|
||||
// испортить якорь в копии фикстуры (replace уникального фрагмента);
|
||||
// assert: exit 1, stderr содержит имя transform-а; существующий --out не тронут.
|
||||
|
||||
test('sync fails on changed skill set', ...)
|
||||
// удалить один skill-каталог из копии; assert: exit 1, stderr содержит added/removed.
|
||||
|
||||
test('sync fails when no skills/ dir', ...) // assert: exit 1.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогони — ожидай FAIL** (скрипта нет)
|
||||
|
||||
Run: `node --test dsh/tests/sync.test.mjs`
|
||||
Expected: FAIL (module not found / exit не 1).
|
||||
|
||||
- [ ] **Step 3: Реализуй `dsh/sync/sync-from-upstream.mjs`** — перенос из LayneChai/superpowers-dsh `scripts/sync-from-upstream.mjs` с адаптациями:
|
||||
- без `package.json`-привязки: `upstreamRoot = resolve(argv[2])`, `--out` из argv;
|
||||
- `SKIP`-список чужих harness-референсов — как у оригинала (7 файлов: copilot-tools.md в v7.0.1 нет; T1.3);
|
||||
- `TRANSFORMS` — как у оригинала, кроме: их `using-superpowers`-rewrite сохраняется как есть (указывает на `references/dsh-tools.md`);
|
||||
- override: после копирования дерева — скопировать `dsh/bundle/dsh-tools.md` → `using-superpowers/references/dsh-tools.md` (путь до бандла — `resolve(import.meta.url, '../../bundle/dsh-tools.md')`);
|
||||
- namespace-strip `superpowers:` → `''` и сверка набора скиллов (15 имён) — как у оригинала;
|
||||
- staging-swap и «unfired transforms» — как у оригинала; `--ref`/`plugin.json`-версия — в `.sync-meta.json`.
|
||||
- Только Node builtins.
|
||||
|
||||
- [ ] **Step 4: Прогони — ожидай PASS**
|
||||
|
||||
Run: `node --test dsh/tests/sync.test.mjs`
|
||||
Expected: все 4 теста PASS.
|
||||
|
||||
- [ ] **Step 5: Весь набор + коммит**
|
||||
|
||||
Run: `bash dsh/tests/run.sh`
|
||||
Expected: весь набор зелёный.
|
||||
```bash
|
||||
git add dsh/sync/sync-from-upstream.mjs dsh/tests/sync.test.mjs dsh/tests/fixtures/superpowers-min
|
||||
git commit -m "feat(dsh): вендор sync-from-upstream — ассертные transform-ы, staging-swap, override dsh-tools.md (#69)"
|
||||
```
|
||||
|
||||
### Task 2: Вендор verify-skills
|
||||
|
||||
**Files:**
|
||||
- Create: `dsh/sync/verify-skills.mjs`
|
||||
- Create: `dsh/tests/verify.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: synced-каталог из Task 1 (формат `<skill>/SKILL.md`).
|
||||
- Produces: CLI `node dsh/sync/verify-skills.mjs <skills-dir>`; exit 0 — `ok: <name>` на каждый скилл; exit 1 — первая ошибка с `<skill>/SKILL.md: <reason>`; проверки: YAML frontmatter с `name`+`description`, без остаточного `superpowers:`-префикса в теле, все относительные ссылки вида `../<skill>/…`/`references/…` из тела резолвятся в `<skills-dir>`.
|
||||
|
||||
- [ ] **Step 1: Напиши падающие тесты** в `dsh/tests/verify.test.mjs` (фикстура — результат sync из Task 1, плюс испорченные копии):
|
||||
|
||||
```js
|
||||
test('verify ok on synced tree', ...) // exit 0, stdout содержит 'ok: using-superpowers'.
|
||||
test('verify fails on missing description', ...) // exit 1, stderr содержит путь и reason.
|
||||
test('verify fails on leftover superpowers: prefix', ...)
|
||||
test('verify fails on dangling relative reference', ...) // ссылка ../brainstorming/x.md без файла.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогони — ожидай FAIL**
|
||||
|
||||
Run: `node --test dsh/tests/verify.test.mjs`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Реализуй `dsh/sync/verify-skills.mjs`** — перенос идеи LayneChai `verify-skills.mjs`, без provider-части: только проверки выше, чистый Node. Парсер frontmatter — как в их `parseFrontmatter` (скалярные поля).
|
||||
|
||||
- [ ] **Step 4: Прогони — ожидай PASS**
|
||||
|
||||
Run: `node --test dsh/tests/verify.test.mjs`
|
||||
Expected: все тесты PASS.
|
||||
|
||||
- [ ] **Step 5: Весь набор + коммит**
|
||||
|
||||
Run: `bash dsh/tests/run.sh`
|
||||
Expected: зелёный.
|
||||
```bash
|
||||
git add dsh/sync/verify-skills.mjs dsh/tests/verify.test.mjs
|
||||
git commit -m "feat(dsh): вендор verify-skills — frontmatter, префиксы, висячие ссылки (#69)"
|
||||
```
|
||||
|
||||
### Task 3: install.sh — пин, fetch, sync-оркестрация
|
||||
|
||||
**Files:**
|
||||
- Modify: `dsh/install.sh`
|
||||
- Modify: `dsh/tests/install.test.mjs`
|
||||
- Modify: `dsh/README.md`, `dsh/PORT.md` (разделы установки/источника)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `dsh/sync/sync-from-upstream.mjs` (Task 1), `dsh/sync/verify-skills.mjs` (Task 2); env `DSH_SUPERPOWERS_REF` (default `v7.0.1`), `DSH_SUPERPOWERS_REPO` (default `https://github.com/obra/superpowers`), `DSH_SUPERPOWERS_SOURCE`, `--superpowers-from`.
|
||||
- Produces: поток `--skills`: resolve (explicit/env → клон `$DSH_HOME/superpowers` на пине → legacy сырой+WARN) → `node dsh/sync/sync-from-upstream.mjs <source> --out "$DSH_HOME/superpowers/skills.synced" --ref "$REF"` → `node dsh/sync/verify-skills.mjs <out>` → копирование `<out>/*` в `$DSH_HOME/skills` (как текущий цикл) → local skills (role, sdd) без изменений. `--check`: строка `ok: superpowers source (<dir>, git <sha>, ref <ref>)`, сети нет.
|
||||
|
||||
- [ ] **Step 1: Напиши падающие тесты** в `dsh/tests/install.test.mjs` (helpers уже есть; локальный git-репозиторий: `git init` + коммит фикстуры `dsh/tests/fixtures/superpowers-min` + `git tag v7.0.1`; env `DSH_SUPERPOWERS_REPO=file://<repo>`):
|
||||
|
||||
```js
|
||||
test('--skills clones pinned ref, syncs, verifies and installs adapted skills', ...)
|
||||
// fresh HOME: exit 0; $DSH_HOME/skills/using-superpowers/SKILL.md содержит 'references/dsh-tools.md'
|
||||
// и НЕ содержит 'superpowers:'; references/dsh-tools.md установлен; stdout содержит 'synced' и 'copied N superpowers skills'.
|
||||
|
||||
test('--skills offline without source fails with hint', ...)
|
||||
// fresh HOME, DSH_SUPERPOWERS_REPO=file:///nonexistent, без explicit: exit 1,
|
||||
// stderr содержит 'git clone' hint; $DSH_HOME/skills не создан с пустыми скиллами.
|
||||
|
||||
test('--skills removes failed empty clone dir (RF5)', ...)
|
||||
// fresh HOME, DSH_SUPERPOWERS_REPO=file:///nonexistent, предварительно mkdir $DSH_HOME/superpowers (пустой):
|
||||
// exit 1; existsSync($DSH_HOME/superpowers) === false (полусостояния не остаётся).
|
||||
|
||||
test('--skills re-run updates from moved tag', ...)
|
||||
// второй прогон после 'git commit --amend' + 'git tag -f v7.0.1': установленный текст обновился (маркер из amend).
|
||||
|
||||
test('--skills leaves no staging garbage', ...)
|
||||
// после прогона: в $DSH_HOME/superpowers нет *.tmp-*; existsSync skills.synced — допустимо, но .sync-meta.json.ref==='v7.0.1'.
|
||||
|
||||
test('--check is offline and reports ref', ...)
|
||||
// env DSH_SUPERPOWERS_REPO=file:///nonexistent: exit 0; stdout содержит 'ref v7.0.1' или 'no superpowers source'.
|
||||
|
||||
test('.claude/code-paths содержит dsh/', ...)
|
||||
// readFileSync('../../.claude/code-paths') содержит строку 'dsh/' (D-5).
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогони — ожидай FAIL**
|
||||
|
||||
Run: `node --test dsh/tests/install.test.mjs`
|
||||
Expected: новые тесты FAIL (clone-логики нет), старые — PASS.
|
||||
|
||||
- [ ] **Step 3: Реализуй в `dsh/install.sh`** — только `superpowers_source()`/`check()`/`install_skills()`:
|
||||
- новая ветка resolve: explicit (`--superpowers-from`/`DSH_SUPERPOWERS_SOURCE`) → **sync от этого каталога без сети**; иначе `ensure_pinned_clone`: если `$DSH_HOME/superpowers` уже клон на `$REF` — использовать (WARN при отсутствии сети); иначе `git clone --depth 1 --branch "$REF" "$REPO" "$DSH_HOME/superpowers"`; при неудаче и существующем каталоге без `.git` — FAIL с подсказкой, каталог не трогать; при неудаче и пустом месте — удалить недоклон и FAIL (RF5);
|
||||
- `install_skills()`: sync → verify → копирование из `--out`; legacy-источник — прежний сырой путь с WARN (без sync);
|
||||
- `--check`: офлайн, печатает источник/ref/sha из `.sync-meta.json` или git;
|
||||
- `usage()` — новые переменные.
|
||||
- Bash с python3-вставками в прежнем стиле.
|
||||
|
||||
- [ ] **Step 4: Прогони — ожидай PASS**
|
||||
|
||||
Run: `node --test dsh/tests/install.test.mjs`
|
||||
Expected: все тесты (старые и новые) PASS.
|
||||
|
||||
- [ ] **Step 5: Доки + весь набор + коммит** — обнови `dsh/README.md` (Quick start: `--skills` сам тянет пин, переменные, офлайн-кейсы) и `dsh/PORT.md` (§3 убрать «Vendored superpowers»-абзац как устаревший, §6 команды установки).
|
||||
|
||||
Run: `bash dsh/tests/run.sh && bash tests/run.sh`
|
||||
Expected: оба набора зелёные.
|
||||
```bash
|
||||
git add dsh/install.sh dsh/tests/install.test.mjs dsh/README.md dsh/PORT.md
|
||||
git commit -m "feat(dsh): --skills гоняет sync/verify по пину v7.0.1, офлайн-кейсы (#69)"
|
||||
```
|
||||
|
||||
### Task 4: Самопроверка бандла + команда dsh-harness-check
|
||||
|
||||
**Files:**
|
||||
- Create: `dsh/bundle/lib/selfcheck.js`
|
||||
- Modify: `dsh/bundle/index.js` (аддитивно)
|
||||
- Create: `dsh/bundle/command-check.js`
|
||||
- Create: `dsh/tests/selfcheck.test.mjs`
|
||||
- Modify: `dsh/README.md` (Known limits → самопроверка), `dsh/PORT.md` (§7 риск → чем закрыт)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ctx`-форма из `dsh/bundle/index.js` (`tools.guard`, `on`, `inject`, `commands`, `workingDirectory.get`).
|
||||
- Produces: `collectSelfCheck(ctxLike) -> [{ feature: 'guards'|'bootstrap'|'commands'|'workingDirectory', ok: boolean, detail: string }]` — чистая функция, без вызовов ctx; `registerCheckCommand({ commands, collect, logger })` — регистрирует команду `dsh-harness-check`, которая собирает чек и возвращает текст-отчёт; в `index.js`: конфиг-ключи `selfCheck: true` (default) и `checkCommand: true` (default); после wiring — прогон `collectSelfCheck`, при not-ok критической службы (guards) — `ctx.logger.error` + пропуск только этой фичи (существующие фичи при их службах на месте работают как раньше).
|
||||
|
||||
- [ ] **Step 1: Напиши падающие тесты** в `dsh/tests/selfcheck.test.mjs`:
|
||||
|
||||
```js
|
||||
test('collectSelfCheck ok on full ctx-like', ...) // все features ok:true.
|
||||
test('collectSelfCheck flags missing tools.guard', ...) // guards ok:false, detail содержит 'tools.guard'; остальные ok:true.
|
||||
test('collectSelfCheck flags missing on()', ...) // bootstrap ok:false.
|
||||
test('registerCheckCommand builds report text', ...)
|
||||
// fake commands.register(name, handler) — вызвать handler с fake collect, вернуть текст:
|
||||
// содержит 'guards: not-ok' и имя фичи; ok-ветка содержит 'ok'.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогони — ожидай FAIL**
|
||||
|
||||
Run: `node --test dsh/tests/selfcheck.test.mjs`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Реализуй** `lib/selfcheck.js` и `command-check.js` по интерфейсам; в `index.js` добавь в `DEFAULTS` ключи и вызовы после существующих блоков (порядок: guards → bootstrap → roleCommand → selfcheck/command), чтобы при отсутствии `ctx.tools.guard` guard-блок пропускался с `ctx.logger.error('[dsh-harness] self-check: guards not-ok: …')`, а bootstrap/command оставались.
|
||||
|
||||
- [ ] **Step 4: Прогони — ожидай PASS**
|
||||
|
||||
Run: `node --test dsh/tests/selfcheck.test.mjs`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Весь набор + доки + коммит** — `dsh/README.md` (Known limits: самопроверка при активации + команда `dsh-harness-check`), `dsh/PORT.md` §7 (риск контракта → закрыт самопроверкой).
|
||||
|
||||
Run: `bash dsh/tests/run.sh`
|
||||
Expected: зелёный (package.test.mjs без `@deepseek-ai/*` тоже).
|
||||
```bash
|
||||
git add dsh/bundle/lib/selfcheck.js dsh/bundle/command-check.js dsh/bundle/index.js dsh/tests/selfcheck.test.mjs dsh/README.md dsh/PORT.md
|
||||
git commit -m "feat(dsh): самопроверка контракта бандла при активации + команда dsh-harness-check (#69)"
|
||||
```
|
||||
|
||||
### Task 5: Спека-дельта dsh-install и проверка code-paths
|
||||
|
||||
**Files:**
|
||||
- Modify: `openspec/changes/dsh-port-v2/specs/dsh-install/spec.md` (delta)
|
||||
- Modify: `dsh/PORT.md` (§1 таблица, §3: строка «Vendored superpowers» → «Pinned sync (v7.0.1)»)
|
||||
- Modify: `openspec/changes/dsh-port-v2/design.md` (D-5 — статус «выполнено в этой ветке» после Task 3; D-6 — ссылки на selfcheck)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: поведение из Tasks 3–4.
|
||||
- Produces: spec-delta: REQUIREMENT `sync-по-пину` (ADDED: `--skills` без явного источника клонирует `DSH_SUPERPOWERS_REPO` на `DSH_SUPERPOWERS_REF`, sync+verify обязательны, FAIL на несовпадении якорей; MODIFIED: сценарии «нет сети» — FAIL с подсказкой вместо тихого legacy; legacy — только WARN-путь), `selfcheck` (ADDED: активационный чек + команда).
|
||||
|
||||
- [ ] **Step 1: Напиши падающую проверку** — в `dsh/tests/install.test.mjs` уже есть тест `.claude/code-paths содержит dsh/` (Task 3); добавь в `dsh/tests/sync.test.mjs`:
|
||||
|
||||
```js
|
||||
test('sync meta records ref and version', ...) // .sync-meta.json: ref==='v7.0.1', version==='7.0.1'.
|
||||
```
|
||||
|
||||
Run: `node --test dsh/tests/sync.test.mjs`
|
||||
Expected: FAIL, если Task 1 не писал `version` (иначе шаг помечается как уже зелёный и пропускается).
|
||||
|
||||
- [ ] **Step 2: Напиши дельту** `openspec/changes/dsh-port-v2/specs/dsh-install/spec.md` — прочитай `openspec/specs/dsh-install/spec.md` и оформи ADDED/MODIFIED по формату; BREAKING отметить в `## MODIFIED Requirements` (сеть для `--skills`).
|
||||
|
||||
Run: `openspec validate dsh-port-v2 --strict`
|
||||
Expected: `Change 'dsh-port-v2' is valid`.
|
||||
|
||||
- [ ] **Step 3: Обнови** `dsh/PORT.md` §1/§3, `dsh/README.md` (раздел «Обновление»: каденс D-7 — issue на каждый релиз obra/superpowers, команда sync→verify→--skills, проверка `--check`) и `design.md` (D-5/D-6 статусы).
|
||||
|
||||
- [ ] **Step 4: Оба набора + коммит**
|
||||
|
||||
Run: `bash dsh/tests/run.sh && bash tests/run.sh && openspec validate dsh-port-v2 --strict`
|
||||
Expected: всё зелёное.
|
||||
```bash
|
||||
git add openspec/changes/dsh-port-v2/specs/dsh-install/spec.md openspec/changes/dsh-port-v2/design.md dsh/PORT.md dsh/tests/sync.test.mjs
|
||||
git commit -m "docs(openspec): дельта dsh-install под sync-по-пину и самопроверку (#69)"
|
||||
```
|
||||
|
||||
## Предусловия окружения (проверяются контроллером один раз до Task 1)
|
||||
|
||||
- `python3`, `git`, `node` в PATH; `bash dsh/tests/run.sh` зелёный на входной точке ветки.
|
||||
- Сеть не нужна для тестов: все прогоны используют `DSH_SUPERPOWERS_REPO=file://…` или temp-HOME; реальный `~/.dsh` не трогается (тесты — temp `DSH_HOME`).
|
||||
- Ключи Gitea доступны только для `tea` (PR), не в переменных окружения сессий.
|
||||
- Ветка: `feat/69-dsh-port-v2`, worktree `.claude/worktrees/dsh-port-v2`; `#63` идёт отдельной веткой `feat/63-guard-worktree-chain` и не пересекается по файлам.
|
||||
67
dsh/PORT.md
67
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,11 +81,18 @@ 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.
|
||||
- **Vendored superpowers**: the install-time source is explicit
|
||||
(`--superpowers-from`, `DSH_SUPERPOWERS_SOURCE`, or a clone in
|
||||
`~/.dsh/superpowers`); a copy inside this repository is rejected because
|
||||
upstream moves independently (change `dsh-install-source`, D-1). Removing the
|
||||
legacy `~/.claude` fallbacks is deferred until every machine has rerun
|
||||
- **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; 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.
|
||||
|
||||
@ -167,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/
|
||||
@ -211,10 +220,20 @@ real files there; the Claude Code locations are a legacy fallback and the
|
||||
bundle logs one warning per process when it resolves one of them.
|
||||
|
||||
Install-time source resolution is separate and stricter (change
|
||||
`dsh-install-source`): `--superpowers-from` → `$DSH_SUPERPOWERS_SOURCE` →
|
||||
`$DSH_HOME/superpowers/skills` → the legacy cache → `~/.claude/skills`. An
|
||||
invalid explicit source is a hard `FAIL`; no source at all is a `FAIL` on a
|
||||
fresh install and a `WARN` when `~/.dsh/skills` already holds the skills.
|
||||
`dsh-install-source`, pin in change `dsh-port-v2`): `--superpowers-from` →
|
||||
`$DSH_SUPERPOWERS_SOURCE` → the pinned clone `~/.dsh/superpowers` at
|
||||
`$DSH_SUPERPOWERS_REF` (default `v7.0.1`, cloned or updated from
|
||||
`$DSH_SUPERPOWERS_REPO`) → the legacy cache → `~/.claude/skills`. Explicit
|
||||
sources are synced in place with no network and must be a checkout root or
|
||||
its `skills/` directory — a bare skills directory outside that layout is
|
||||
invalid, and `--check` and `--skills` fail the same way (narrower than change
|
||||
`dsh-install-source` #49; the spec delta records the MODIFIED). Explicit
|
||||
syncs stage into `~/.dsh/.skills.synced` beside the clone dir, which stays
|
||||
reserved for the pin; an existing clone is updated by fetching
|
||||
`$DSH_SUPERPOWERS_REPO` (not its origin) and reused offline only when already
|
||||
on the ref. An invalid explicit source is a hard `FAIL`; a machine offline
|
||||
without a clone is a `FAIL` with the clone hint, falling back to legacy only
|
||||
when a legacy source exists.
|
||||
|
||||
### OpenSpec skills
|
||||
|
||||
@ -243,20 +262,21 @@ openspec archive <id> -y
|
||||
|
||||
## 6. Install and verify
|
||||
|
||||
Prerequisites: the `claude-harness` checkout, a superpowers source, DSH
|
||||
0.2.1-rc.3, `python3`, `git`, Node. Claude Code is not required at all: get the
|
||||
superpowers source once with
|
||||
`git clone --depth 1 https://github.com/obra/superpowers ~/.dsh/superpowers`,
|
||||
then `--skills --roles` copy everything into `~/.dsh`; the hooks come from this
|
||||
Prerequisites: the `claude-harness` checkout, DSH 0.2.1-rc.3, `python3`,
|
||||
`git`, Node. Claude Code is not required at all: `--skills` clones the pinned
|
||||
superpowers checkout itself (`DSH_SUPERPOWERS_REF`, default `v7.0.1`) and runs
|
||||
the DSH sync + verify before installing, so a manual clone is no longer the
|
||||
first step; `--roles` copies the role files; the hooks come from this
|
||||
checkout.
|
||||
|
||||
```bash
|
||||
# 1. Static checks
|
||||
# 1. Static checks (offline; reports the configured pin and the source ref)
|
||||
bash dsh/install.sh --check
|
||||
|
||||
# 2. Global instructions, skills, and roles (run from a terminal; writes
|
||||
# outside the session workspace). The superpowers source comes from
|
||||
# --superpowers-from, DSH_SUPERPOWERS_SOURCE, or ~/.dsh/superpowers.
|
||||
# outside the session workspace). --skills clones the pinned superpowers
|
||||
# checkout (DSH_SUPERPOWERS_REF, default v7.0.1) and syncs it; or pass
|
||||
# --superpowers-from / DSH_SUPERPOWERS_SOURCE to sync a checkout directly.
|
||||
bash dsh/install.sh --global
|
||||
bash dsh/install.sh --skills # or: --skills --superpowers-from DIR
|
||||
bash dsh/install.sh --roles
|
||||
@ -336,6 +356,17 @@ profile was removed; the real `desktop` profile was not touched.
|
||||
|
||||
- **DSH is 0.2.1-rc.3.** The plugin/event API is not frozen; pin the version and
|
||||
re-run the tests after upgrades.
|
||||
- **Contract self-check.** The plugin/event API drift is now caught from inside
|
||||
DSH: at activation the bundle checks the contract it depends on
|
||||
(`ctx.tools.guard`, `ctx.on` for `agent/pre-step`, `ctx.inject` for commands,
|
||||
`workingDirectory.get`), logs
|
||||
`[dsh-harness] self-check: <feature> not-ok: <detail>` for each missing
|
||||
service, and skips the feature whose service is missing (guards, bootstrap,
|
||||
or commands — each gated by its own check), so a DSH version without one of
|
||||
the services does not crash the bundle.
|
||||
`/dsh-harness-check` re-runs the same check on demand. This is the in-DSH
|
||||
compat check that closes RV-11 (D-6) — the terminal `--check` cannot see the
|
||||
SDK inside `app.asar`.
|
||||
- **Local profile imports are not resolved from the DSH installation.** The
|
||||
first install attempt failed at activation with
|
||||
`ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/schemastery'`. The
|
||||
|
||||
@ -23,27 +23,32 @@ 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
|
||||
|
||||
```bash
|
||||
# Static check
|
||||
# Static check (offline; reports the configured pin and the current source)
|
||||
bash dsh/install.sh --check
|
||||
|
||||
# Superpowers source: get it once per machine. install.sh never downloads
|
||||
# anything and no longer depends on ~/.claude.
|
||||
git clone --depth 1 https://github.com/obra/superpowers ~/.dsh/superpowers
|
||||
# ...or point at any checkout of the superpowers repo / its skills directory:
|
||||
# Superpowers source. The default is a pinned clone: --skills clones or
|
||||
# updates ~/.dsh/superpowers at $DSH_SUPERPOWERS_REF (default v7.0.1, fetched
|
||||
# from $DSH_SUPERPOWERS_REPO), then runs sync + verify and copies the adapted
|
||||
# skills. Offline it reuses the clone only when it is already on the ref.
|
||||
bash dsh/install.sh --skills
|
||||
# ...or sync a checkout directly (no network, no pin enforcement):
|
||||
bash dsh/install.sh --skills --superpowers-from /path/to/superpowers
|
||||
# ...or export DSH_SUPERPOWERS_SOURCE=/path/to/superpowers
|
||||
|
||||
# Global instructions, superpowers skills, local role skill, and roles
|
||||
# Global instructions, local role skill, and roles
|
||||
# (run from a terminal; these write outside the session workspace)
|
||||
bash dsh/install.sh --global
|
||||
bash dsh/install.sh --skills
|
||||
bash dsh/install.sh --roles
|
||||
|
||||
# В корне проекта, где уже есть openspec/: добавить agents-скиллы
|
||||
@ -55,15 +60,47 @@ bash <harness>/dsh/install.sh --openspec
|
||||
```
|
||||
|
||||
`--skills` resolves superpowers in this order: `--superpowers-from` →
|
||||
`$DSH_SUPERPOWERS_SOURCE` → `~/.dsh/superpowers` → the Claude Code plugin cache
|
||||
(legacy, warns). A clean HOME with no source and no installed copy fails with
|
||||
the clone command above instead of pretending success; the local `role` and
|
||||
`sdd` skills are copied either way.
|
||||
`$DSH_SUPERPOWERS_SOURCE` → the pinned clone `~/.dsh/superpowers` at
|
||||
`$DSH_SUPERPOWERS_REF` (default `v7.0.1`, cloned or updated from
|
||||
`$DSH_SUPERPOWERS_REPO`; offline it is reused only when already on the ref) →
|
||||
the Claude Code plugin cache (legacy, warns). Explicit sources are synced in
|
||||
place with no network; they must be a checkout root or its `skills/`
|
||||
directory — a bare skills directory outside that layout is a hard `FAIL` in
|
||||
both `--skills` and `--check`. Explicit syncs stage into
|
||||
`~/.dsh/.skills.synced`, never into `~/.dsh/superpowers` (reserved for the
|
||||
pinned clone). Every sync runs `dsh/sync/sync-from-upstream.mjs` (DSH
|
||||
adaptations) and `dsh/sync/verify-skills.mjs`; a tree that fails verification
|
||||
or holds zero skills is never installed. Legacy sources are copied raw. A
|
||||
clean HOME with no source and no clone fails with the clone command instead of
|
||||
pretending success; the local `role` and `sdd` skills are copied either way.
|
||||
|
||||
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 <checkout> --out <dir> --ref <tag>`,
|
||||
then `node dsh/sync/verify-skills.mjs <dir>`. 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
|
||||
@ -107,13 +144,18 @@ dsh/
|
||||
cordis.patch.yml inserts the dsh-harness row
|
||||
index.js ctx.tools.guard + agent/pre-step + /role
|
||||
command-role.js /role command glue
|
||||
command-check.js dsh-harness-check command glue
|
||||
lib/guards.js DSH tool call -> claude-harness hook JSON
|
||||
lib/bootstrap.js superpowers skill resolution, marker, dedupe
|
||||
lib/role.js role layers and text (pure)
|
||||
lib/selfcheck.js bundle contract self-check (pure)
|
||||
dsh-tools.md superpowers tool mapping
|
||||
hooks -> ../../hooks local install: tested hooks are reused, not copied
|
||||
locale/{en,ru}.json
|
||||
icon.svg
|
||||
sync/ vendored sync machinery (Node builtins only)
|
||||
sync-from-upstream.mjs pinned upstream -> adapted skills (asserted transforms)
|
||||
verify-skills.mjs synced-tree validation (frontmatter, prefixes, links)
|
||||
tests/
|
||||
install.sh
|
||||
skills/{role,sdd}/SKILL.md model-invocable skills installed into ~/.dsh
|
||||
@ -138,6 +180,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/
|
||||
@ -157,7 +201,8 @@ repository layout:
|
||||
`files`).
|
||||
- `using-superpowers` is read from `~/.dsh/skills` after `--skills`. The
|
||||
install-time source is `--superpowers-from`, `DSH_SUPERPOWERS_SOURCE`, or
|
||||
`~/.dsh/superpowers`; Claude Code locations are a **legacy migration
|
||||
the pinned clone `~/.dsh/superpowers` (`DSH_SUPERPOWERS_REF`, default
|
||||
`v7.0.1`); Claude Code locations are a **legacy migration
|
||||
fallback** and log one warning per process when used, so a partial Claude
|
||||
Code rollback does not break the bootstrap. Set `bootstrapSkillPath` or
|
||||
`DSH_SUPERPOWERS_SKILL` for another layout.
|
||||
@ -174,3 +219,13 @@ repository layout:
|
||||
skill and `openspec/changes/dsh-sdd/`.
|
||||
- OpenSpec-скиллы проект получает сам (`openspec init --tools agents` или
|
||||
`--openspec`); обвязка ставит их только в проект, не в `~/.dsh`.
|
||||
- The bundle self-checks the DSH contract it depends on at activation:
|
||||
`ctx.tools.guard`, `ctx.on` for `agent/pre-step`, `ctx.inject` for commands,
|
||||
`workingDirectory.get`. Each missing service is logged as
|
||||
`[dsh-harness] self-check: <feature> not-ok: …`, and the feature whose
|
||||
service is missing is skipped (guards, bootstrap, or commands — each gated
|
||||
by its own check), so a DSH version without one of the services does not
|
||||
crash the bundle; the remaining features keep working. The
|
||||
`/dsh-harness-check` command re-runs the same check on demand (one
|
||||
`ok` / `not-ok` line per feature). Disable with `selfCheck: false` /
|
||||
`checkCommand: false`.
|
||||
|
||||
33
dsh/bundle/command-check.js
Normal file
33
dsh/bundle/command-check.js
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Human-facing `dsh-harness-check` command for DeepSeek Harness.
|
||||
*
|
||||
* Re-runs the bundle contract self-check on demand and prints one line per
|
||||
* feature. Mirrors `command-role.js`: the same object-form
|
||||
* `commands.register`, the same `{ kind, text }` result shape.
|
||||
*/
|
||||
|
||||
export function registerCheckCommand({ commands, collect, logger }) {
|
||||
commands.register({
|
||||
name: 'dsh-harness-check',
|
||||
description: 'Проверить контракт DSH, на который опирается dsh-harness',
|
||||
handler: async () => {
|
||||
let report
|
||||
try {
|
||||
report = collect()
|
||||
} catch (error) {
|
||||
logger?.warn?.('[dsh-harness] dsh-harness-check failed: %s', error?.message ?? error)
|
||||
return { kind: 'error', text: String(error?.message ?? error) }
|
||||
}
|
||||
|
||||
const problems = report.filter((entry) => !entry.ok)
|
||||
const lines = report.map((entry) =>
|
||||
entry.ok ? `${entry.feature}: ok` : `${entry.feature}: not-ok — ${entry.detail}`,
|
||||
)
|
||||
const headline =
|
||||
problems.length === 0
|
||||
? 'dsh-harness self-check: all ok'
|
||||
: `dsh-harness self-check: ${problems.length} problem(s)`
|
||||
return { kind: 'success', text: [headline, ...lines].join('\n') }
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -22,11 +22,13 @@ import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { registerRoleCommand } from './command-role.js'
|
||||
import { registerCheckCommand } from './command-check.js'
|
||||
import { createGuard } from './lib/guards.js'
|
||||
import { isBootstrapVisible, prepareBootstrap } from './lib/bootstrap.js'
|
||||
import { isLegacyClaudePath, warnLegacyOnce } from './lib/legacy.js'
|
||||
import { createUserMessage } from './lib/message.js'
|
||||
import { DEFAULT_ROLE_NAMES } from './lib/role.js'
|
||||
import { collectSelfCheck } from './lib/selfcheck.js'
|
||||
|
||||
export const name = 'dsh-harness'
|
||||
export const inject = ['tools', 'workingDirectory']
|
||||
@ -77,6 +79,8 @@ const DEFAULTS = {
|
||||
bootstrap: true,
|
||||
bootstrapSubagents: false,
|
||||
roleCommand: true,
|
||||
selfCheck: true,
|
||||
checkCommand: true,
|
||||
hooksDir: '',
|
||||
rolesDir: '',
|
||||
projectRolesDir: '',
|
||||
@ -99,7 +103,29 @@ export function apply(ctx, rawConfig = {}) {
|
||||
}
|
||||
const guard = createGuard({ hooksDir, logger: ctx.logger })
|
||||
|
||||
if (config.enabled && config.guards) {
|
||||
const selfCheckReport =
|
||||
config.enabled && config.selfCheck ? collectSelfCheck(ctx, config) : null
|
||||
// A feature is wired only when its own service passed the self-check (or
|
||||
// the self-check itself is off): a missing service must disable its feature
|
||||
// instead of crashing the whole bundle.
|
||||
const serviceOk = (feature) =>
|
||||
!selfCheckReport ||
|
||||
selfCheckReport.find((entry) => entry.feature === feature)?.ok === true
|
||||
const guardsOk = serviceOk('guards')
|
||||
const bootstrapOk = serviceOk('bootstrap')
|
||||
const commandsOk = serviceOk('commands')
|
||||
|
||||
// Log every not-ok feature before any wiring runs, so a missing service
|
||||
// always leaves a diagnostic line for the feature being skipped.
|
||||
if (selfCheckReport) {
|
||||
for (const entry of selfCheckReport) {
|
||||
if (!entry.ok) {
|
||||
ctx.logger?.error?.('[dsh-harness] self-check: %s not-ok: %s', entry.feature, entry.detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.enabled && config.guards && guardsOk) {
|
||||
// A synchronous, monotonic guard: it runs after the extensible
|
||||
// `tools/pre-execute` waterfall, so no other listener can turn a denial
|
||||
// back into permission.
|
||||
@ -112,7 +138,7 @@ export function apply(ctx, rawConfig = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
if (config.enabled && config.bootstrap) {
|
||||
if (config.enabled && config.bootstrap && bootstrapOk) {
|
||||
ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
|
||||
const decision = await next()
|
||||
if (decision.kind === 'reject' || signal?.aborted) return decision
|
||||
@ -140,16 +166,25 @@ export function apply(ctx, rawConfig = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
if (config.enabled && config.roleCommand) {
|
||||
if (config.enabled && (config.roleCommand || config.checkCommand) && commandsOk) {
|
||||
ctx.inject(['commands'], (commandCtx) => {
|
||||
registerRoleCommand({
|
||||
commands: commandCtx.commands,
|
||||
workingDirectory: ctx.workingDirectory,
|
||||
rolesDir,
|
||||
projectRolesDir: config.projectRolesDir,
|
||||
roleNames: config.roleNames,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
if (config.roleCommand) {
|
||||
registerRoleCommand({
|
||||
commands: commandCtx.commands,
|
||||
workingDirectory: ctx.workingDirectory,
|
||||
rolesDir,
|
||||
projectRolesDir: config.projectRolesDir,
|
||||
roleNames: config.roleNames,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
}
|
||||
if (config.checkCommand) {
|
||||
registerCheckCommand({
|
||||
commands: commandCtx.commands,
|
||||
collect: () => collectSelfCheck(ctx, config),
|
||||
logger: ctx.logger,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
56
dsh/bundle/lib/selfcheck.js
Normal file
56
dsh/bundle/lib/selfcheck.js
Normal file
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Activation-time self-check of the DSH contract the bundle depends on.
|
||||
*
|
||||
* Pure shape inspection: no ctx service is invoked, only checked for presence.
|
||||
* A DSH upgrade may rename or remove a service; the check turns that into a
|
||||
* visible `not-ok` line (activation log plus the `dsh-harness-check` command)
|
||||
* instead of a TypeError at first use.
|
||||
*/
|
||||
|
||||
const CONTRACT = [
|
||||
{
|
||||
feature: 'guards',
|
||||
label: 'tools.guard',
|
||||
available: (ctx) => typeof ctx?.tools?.guard === 'function',
|
||||
},
|
||||
{
|
||||
feature: 'bootstrap',
|
||||
label: 'ctx.on (agent/pre-step)',
|
||||
available: (ctx) => typeof ctx?.on === 'function',
|
||||
},
|
||||
{
|
||||
feature: 'commands',
|
||||
label: 'ctx.inject',
|
||||
available: (ctx) => typeof ctx?.inject === 'function',
|
||||
},
|
||||
{
|
||||
feature: 'workingDirectory',
|
||||
label: 'workingDirectory.get',
|
||||
available: (ctx) => typeof ctx?.workingDirectory?.get === 'function',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Inspect a ctx-like object without calling anything on it.
|
||||
* @param {object} [config] - bundle config; a feature whose config switch is
|
||||
* false is skipped entirely (workingDirectory is always checked, and
|
||||
* `commands` is checked while either `roleCommand` or `checkCommand` is on).
|
||||
* @returns {{feature: 'guards'|'bootstrap'|'commands'|'workingDirectory',
|
||||
* ok: boolean, detail: string}[]}
|
||||
*/
|
||||
export function collectSelfCheck(ctxLike, config = {}) {
|
||||
const disabled = {
|
||||
guards: config?.guards === false,
|
||||
bootstrap: config?.bootstrap === false,
|
||||
commands: config?.roleCommand === false && config?.checkCommand === false,
|
||||
}
|
||||
return CONTRACT.filter(({ feature }) => feature === 'workingDirectory' || !disabled[feature])
|
||||
.map(({ feature, label, available }) => {
|
||||
const ok = available(ctxLike)
|
||||
return {
|
||||
feature,
|
||||
ok,
|
||||
detail: ok ? `${label}: available` : `${label}: missing or not a function`,
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -12,6 +12,7 @@
|
||||
"files": [
|
||||
"index.js",
|
||||
"command-role.js",
|
||||
"command-check.js",
|
||||
"lib",
|
||||
"dsh-tools.md",
|
||||
"cordis.patch.yml",
|
||||
|
||||
315
dsh/install.sh
315
dsh/install.sh
@ -22,6 +22,8 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
BUNDLE="$ROOT/dsh/bundle"
|
||||
DSH_HOME="${DSH_HOME:-$HOME/.dsh}"
|
||||
DSH_SUPERPOWERS_REF="${DSH_SUPERPOWERS_REF:-v7.0.1}"
|
||||
DSH_SUPERPOWERS_REPO="${DSH_SUPERPOWERS_REPO:-https://github.com/obra/superpowers}"
|
||||
DO_CHECK=0
|
||||
DO_GLOBAL=0
|
||||
DO_SKILLS=0
|
||||
@ -29,26 +31,38 @@ DO_ROLES=0
|
||||
DO_OPENSPEC=0
|
||||
SUPERPOWERS_FROM=""
|
||||
SUPERPOWERS_FROM_SET=0
|
||||
CLONE_HINT="git clone --depth 1 https://github.com/obra/superpowers $DSH_HOME/superpowers"
|
||||
CLONE_HINT="git clone --depth 1 --branch $DSH_SUPERPOWERS_REF $DSH_SUPERPOWERS_REPO $DSH_HOME/superpowers"
|
||||
# Synced staging trees, per source kind: $DSH_HOME/superpowers is reserved for
|
||||
# the pinned clone (T3.1), so explicit sources stage beside it.
|
||||
PINNED_OUT="$DSH_HOME/superpowers/skills.synced"
|
||||
EXPLICIT_OUT="$DSH_HOME/.skills.synced"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: bash dsh/install.sh [--check] [--global] [--skills] [--roles] [--all] [--openspec]
|
||||
bash dsh/install.sh --skills --superpowers-from <dir>
|
||||
|
||||
--check verify python3, git, node, hooks, bundle, skills, and roles
|
||||
--check verify python3, git, node, hooks, bundle, skills, and roles (offline)
|
||||
--global back up and write \$DSH_HOME/AGENTS.md from global/CLAUDE.md
|
||||
--skills copy superpowers and local dsh/skills into \$DSH_HOME/skills
|
||||
--skills sync + verify + copy superpowers and local dsh/skills into \$DSH_HOME/skills
|
||||
--roles copy roles/ into \$DSH_HOME/roles
|
||||
--all --check + --global + --skills + --roles, then print the bundle step
|
||||
--openspec add the agents skill adapter to the current OpenSpec project
|
||||
--superpowers-from DIR copy superpowers skills from DIR (the repo root or its skills/)
|
||||
--superpowers-from DIR sync superpowers skills from DIR (the repo root or its skills/)
|
||||
|
||||
Superpowers source, first match wins. The script never downloads anything:
|
||||
Superpowers source, first match wins:
|
||||
1. --superpowers-from DIR
|
||||
2. \$DSH_SUPERPOWERS_SOURCE
|
||||
3. \$DSH_HOME/superpowers -- get it once: $CLONE_HINT
|
||||
4. legacy: the Claude Code plugin cache, then ~/.claude/skills (warns)
|
||||
3. the pinned clone \$DSH_HOME/superpowers at \$DSH_SUPERPOWERS_REF
|
||||
(default v7.0.1), cloned or updated from \$DSH_SUPERPOWERS_REPO
|
||||
(default https://github.com/obra/superpowers); offline it is reused
|
||||
only when already on the ref
|
||||
4. legacy: the Claude Code plugin cache, then ~/.claude/skills
|
||||
(raw copy, warns; removal is #66)
|
||||
|
||||
Explicit sources are synced in place (no network, no pin enforcement); the
|
||||
synced tree is verified before anything is copied. Get the default source
|
||||
once: $CLONE_HINT
|
||||
|
||||
Bundle installation itself is done in DSH:
|
||||
Plugins -> install a bundle from $BUNDLE
|
||||
@ -110,39 +124,106 @@ normalize_superpowers_dir() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Print "<skills-dir>\t<explicit|pinned|legacy>". Returns 2 when an explicitly
|
||||
# configured source is invalid (never fall back silently), 1 when none is found.
|
||||
# The checkout root the sync script consumes: <root>/skills must exist.
|
||||
# Returns 1 when the skills directory is not part of a checkout layout.
|
||||
checkout_root() {
|
||||
local skills_dir="${1:-}"
|
||||
[ "$(basename "$skills_dir")" = skills ] || return 1
|
||||
printf '%s\n' "$(dirname "$skills_dir")"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Resolve the superpowers install source without touching the network.
|
||||
# Prints "<kind>\t<checkout-root>\t<skills-dir>". Returns 2 when an explicitly
|
||||
# configured source is invalid or is not a checkout layout (never fall back
|
||||
# silently; --check and --skills must agree on the verdict), 1 when none is
|
||||
# found.
|
||||
superpowers_source() {
|
||||
local dir legacy
|
||||
local skills="" checkout="" legacy=""
|
||||
if [ -n "$SUPERPOWERS_FROM" ]; then
|
||||
dir="$(normalize_superpowers_dir "$SUPERPOWERS_FROM")" || return 2
|
||||
printf '%s\texplicit\n' "$dir"
|
||||
skills="$(normalize_superpowers_dir "$SUPERPOWERS_FROM")" || return 2
|
||||
checkout="$(checkout_root "$skills")" || return 2
|
||||
printf 'explicit\t%s\t%s\n' "$checkout" "$skills"
|
||||
return 0
|
||||
fi
|
||||
if [ -n "${DSH_SUPERPOWERS_SOURCE:-}" ]; then
|
||||
dir="$(normalize_superpowers_dir "$DSH_SUPERPOWERS_SOURCE")" || return 2
|
||||
printf '%s\texplicit\n' "$dir"
|
||||
skills="$(normalize_superpowers_dir "$DSH_SUPERPOWERS_SOURCE")" || return 2
|
||||
checkout="$(checkout_root "$skills")" || return 2
|
||||
printf 'explicit\t%s\t%s\n' "$checkout" "$skills"
|
||||
return 0
|
||||
fi
|
||||
if dir="$(normalize_superpowers_dir "$DSH_HOME/superpowers")"; then
|
||||
printf '%s\tpinned\n' "$dir"
|
||||
if skills="$(normalize_superpowers_dir "$DSH_HOME/superpowers")"; then
|
||||
printf 'pinned\t%s\t%s\n' "$DSH_HOME/superpowers" "$skills"
|
||||
return 0
|
||||
fi
|
||||
legacy="$(ls -d "$HOME"/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills 2>/dev/null | sort -V | tail -1 || true)"
|
||||
# A cache directory without using-superpowers is a broken cache, not a source:
|
||||
# accepting it would copy 0 skills and report success (the silent empty
|
||||
# bootstrap this change removes).
|
||||
if [ -n "$legacy" ] && dir="$(normalize_superpowers_dir "$legacy")"; then
|
||||
printf '%s\tlegacy\n' "$dir"
|
||||
if [ -n "$legacy" ] && skills="$(normalize_superpowers_dir "$legacy")"; then
|
||||
printf 'legacy\t-\t%s\n' "$skills"
|
||||
return 0
|
||||
fi
|
||||
if dir="$(normalize_superpowers_dir "$HOME/.claude/skills")"; then
|
||||
printf '%s\tlegacy\n' "$dir"
|
||||
if skills="$(normalize_superpowers_dir "$HOME/.claude/skills")"; then
|
||||
printf 'legacy\t-\t%s\n' "$skills"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Offline ref report for --check: the synced meta first, then git, then config.
|
||||
# The meta path depends on the source kind (T3.1): pinned reads the clone's
|
||||
# skills.synced, explicit reads the side staging tree.
|
||||
report_checkout_ref() {
|
||||
local checkout="${1:-}" meta="${2:-}" ref="" version="" tag="" sha=""
|
||||
if [ -n "$meta" ] && [ -f "$meta" ]; then
|
||||
ref="$(sed -n 's/^[[:space:]]*"ref":[[:space:]]*"\([^"]*\)".*/\1/p' "$meta" | head -n 1)"
|
||||
version="$(sed -n 's/^[[:space:]]*"version":[[:space:]]*"\([^"]*\)".*/\1/p' "$meta" | head -n 1)"
|
||||
if [ -n "$version" ]; then
|
||||
echo "ref ${ref:-unknown} (synced, version $version)"
|
||||
else
|
||||
echo "ref ${ref:-unknown} (synced)"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$checkout" ] && [ -d "$checkout/.git" ]; then
|
||||
tag="$(git -C "$checkout" describe --tags --exact-match HEAD 2>/dev/null || true)"
|
||||
if [ -n "$tag" ]; then
|
||||
echo "ref $tag (git)"
|
||||
return 0
|
||||
fi
|
||||
sha="$(git -C "$checkout" rev-parse --short HEAD 2>/dev/null || true)"
|
||||
if [ -n "$sha" ]; then
|
||||
echo "git $sha (ref $DSH_SUPERPOWERS_REF configured)"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
echo "ref $DSH_SUPERPOWERS_REF (configured)"
|
||||
}
|
||||
|
||||
# Ref recorded for an explicit source: the exact tag, else the short sha,
|
||||
# else "unknown". An explicit source is not pin-enforced, so any of these is
|
||||
# honest metadata for .sync-meta.json.
|
||||
source_ref() {
|
||||
local root="${1:-}" tag="" sha=""
|
||||
if [ -d "$root/.git" ]; then
|
||||
tag="$(git -C "$root" describe --tags --exact-match HEAD 2>/dev/null || true)"
|
||||
[ -n "$tag" ] && { printf '%s\n' "$tag"; return 0; }
|
||||
sha="$(git -C "$root" rev-parse --short HEAD 2>/dev/null || true)"
|
||||
[ -n "$sha" ] && { printf '%s\n' "$sha"; return 0; }
|
||||
fi
|
||||
printf 'unknown\n'
|
||||
}
|
||||
|
||||
# The effective explicit source directory: the flag first, then the env var.
|
||||
explicit_source_dir() {
|
||||
if [ -n "$SUPERPOWERS_FROM" ]; then
|
||||
printf '%s\n' "$SUPERPOWERS_FROM"
|
||||
else
|
||||
printf '%s\n' "${DSH_SUPERPOWERS_SOURCE:-}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Human-readable name of the source that made superpowers_source() return 2.
|
||||
explicit_source_name() {
|
||||
if [ -n "$SUPERPOWERS_FROM" ]; then
|
||||
@ -152,7 +233,18 @@ explicit_source_name() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Report an invalid explicit source with the accurate reason: either the
|
||||
# source has no using-superpowers/SKILL.md, or it is not a checkout layout
|
||||
# (both are verdict 2, and --check and --skills must fail the same way).
|
||||
report_invalid_explicit_source() {
|
||||
local dir="" skills=""
|
||||
dir="$(explicit_source_dir)"
|
||||
if [ -n "$dir" ] && skills="$(normalize_superpowers_dir "$dir")"; then
|
||||
if ! checkout_root "$skills" >/dev/null 2>&1; then
|
||||
echo "FAIL: explicit superpowers source must be a checkout root or its skills/ directory: $(explicit_source_name)" >&2
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
echo "FAIL: explicit superpowers source is invalid: $(explicit_source_name) has no using-superpowers/SKILL.md (expected <dir>/skills/... or <dir>/...)" >&2
|
||||
}
|
||||
|
||||
@ -218,7 +310,10 @@ check() {
|
||||
else
|
||||
echo "WARN: .agents/skills missing; run --openspec in the project" >&2
|
||||
fi
|
||||
local src="" rc=0 kind
|
||||
# --check is offline: report the pin config and whatever source exists,
|
||||
# never clone or fetch.
|
||||
echo "ok: superpowers ref ${DSH_SUPERPOWERS_REF} (DSH_SUPERPOWERS_REPO=${DSH_SUPERPOWERS_REPO})"
|
||||
local src="" rc=0 kind checkout skills_dir
|
||||
src="$(superpowers_source)" || rc=$?
|
||||
if [ "$rc" -eq 2 ]; then
|
||||
report_invalid_explicit_source
|
||||
@ -230,12 +325,14 @@ check() {
|
||||
echo "WARN: no superpowers source; run: $CLONE_HINT" >&2
|
||||
fi
|
||||
else
|
||||
kind="${src##*$'\t'}"
|
||||
src="${src%%$'\t'*}"
|
||||
kind="${src%%$'\t'*}"; src="${src#*$'\t'}"
|
||||
checkout="${src%%$'\t'*}"; skills_dir="${src#*$'\t'}"
|
||||
if [ "$kind" = legacy ]; then
|
||||
echo "WARN: legacy superpowers source ($src); prefer --superpowers-from or $DSH_HOME/superpowers" >&2
|
||||
echo "WARN: legacy superpowers source ($skills_dir); prefer --superpowers-from or $DSH_HOME/superpowers" >&2
|
||||
elif [ "$kind" = explicit ]; then
|
||||
echo "ok: superpowers source ($skills_dir); $(report_checkout_ref "$checkout" "$EXPLICIT_OUT/.sync-meta.json")"
|
||||
else
|
||||
echo "ok: superpowers source ($src)"
|
||||
echo "ok: superpowers source ($skills_dir); $(report_checkout_ref "$checkout" "$PINNED_OUT/.sync-meta.json")"
|
||||
fi
|
||||
fi
|
||||
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then
|
||||
@ -299,32 +396,98 @@ PY
|
||||
echo "written: $target"
|
||||
}
|
||||
|
||||
install_skills() {
|
||||
mkdir -p "$DSH_HOME/skills"
|
||||
local line="" rc=0 source kind count=0
|
||||
line="$(superpowers_source)" || rc=$?
|
||||
if [ "$rc" -eq 2 ]; then
|
||||
report_invalid_explicit_source
|
||||
return 1
|
||||
# The default source: a clone at $DSH_HOME/superpowers pinned to $REF.
|
||||
# Returns 0 when the clone is usable at the ref, 1 when a fresh clone failed
|
||||
# (the caller may fall back to legacy), 2 when pre-existing content blocks the
|
||||
# pin (hard fail: the directory is never touched).
|
||||
ensure_pinned_clone() {
|
||||
local dir="$1" ref="$2"
|
||||
if [ -e "$dir" ] && [ ! -d "$dir" ]; then
|
||||
echo "FAIL: $dir exists and is not a directory; the pinned clone needs it" >&2
|
||||
return 2
|
||||
fi
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
if [ -f "$DSH_HOME/skills/using-superpowers/SKILL.md" ]; then
|
||||
echo "WARN: no superpowers source found; keeping installed skills in $DSH_HOME/skills" >&2
|
||||
echo "WARN: next time run: $CLONE_HINT" >&2
|
||||
if [ -d "$dir" ] && [ -n "$(ls -A "$dir" 2>/dev/null)" ]; then
|
||||
if [ ! -d "$dir/.git" ] || ! git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "FAIL: $dir has no usable .git: not a pinned superpowers clone" >&2
|
||||
echo "FAIL: move it away and run: $CLONE_HINT" >&2
|
||||
return 2
|
||||
fi
|
||||
# A foreign git repository must never be mutated by fetch/checkout: a real
|
||||
# superpowers clone always carries skills/using-superpowers/SKILL.md (I-1).
|
||||
# The marker also holds for the moved-tag flow — skills are present in
|
||||
# every pinned clone.
|
||||
if [ ! -f "$dir/skills/using-superpowers/SKILL.md" ]; then
|
||||
echo "FAIL: $dir is not a superpowers clone (no skills/using-superpowers/SKILL.md); left untouched" >&2
|
||||
echo "FAIL: move it away or use --superpowers-from <dir>" >&2
|
||||
return 2
|
||||
fi
|
||||
# Online: refresh the pin. Fetching from $DSH_SUPERPOWERS_REPO (not from
|
||||
# the clone's origin) makes a repo change take effect on the next run
|
||||
# (T3.3); the force refspec updates the LOCAL tag as well as FETCH_HEAD, so
|
||||
# a later offline run still sees HEAD on the ref (T3.5), and checking out
|
||||
# FETCH_HEAD handles a moved tag, which `git checkout <ref>` (resolving the
|
||||
# stale local ref) would miss. The plain "$ref" fallback covers non-tag
|
||||
# refs (e.g. a branch pin).
|
||||
if git -C "$dir" fetch -q --depth 1 "$DSH_SUPERPOWERS_REPO" "+refs/tags/$ref:refs/tags/$ref" 2>/dev/null ||
|
||||
git -C "$dir" fetch -q --depth 1 "$DSH_SUPERPOWERS_REPO" "$ref" 2>/dev/null; then
|
||||
if ! git -C "$dir" checkout -q FETCH_HEAD; then
|
||||
echo "FAIL: checkout of the fetched ref failed in $dir" >&2
|
||||
return 2
|
||||
fi
|
||||
echo "ok: superpowers clone at $ref ($dir)"
|
||||
return 0
|
||||
fi
|
||||
echo "FAIL: no superpowers source found and $DSH_HOME/skills has no using-superpowers" >&2
|
||||
echo "FAIL: get the source once: $CLONE_HINT" >&2
|
||||
echo "FAIL: or pass --superpowers-from <dir>; local skills (role, sdd) are installed by this run" >&2
|
||||
# Offline: reuse only when HEAD is exactly at the pinned ref.
|
||||
if git -C "$dir" rev-parse --verify -q "$ref^{commit}" >/dev/null 2>&1 &&
|
||||
[ "$(git -C "$dir" rev-parse HEAD)" = "$(git -C "$dir" rev-parse "$ref^{commit}")" ]; then
|
||||
echo "WARN: offline; reusing the existing clone at $ref ($dir)" >&2
|
||||
return 0
|
||||
fi
|
||||
echo "FAIL: cannot update $dir to $ref (offline?) and it is not on that ref" >&2
|
||||
echo "FAIL: $CLONE_HINT" >&2
|
||||
return 2
|
||||
fi
|
||||
# Fresh (absent or empty): clone pinned; a failed clone leaves nothing (RF5).
|
||||
if ! git clone --depth 1 --branch "$ref" "$DSH_SUPERPOWERS_REPO" "$dir"; then
|
||||
echo "FAIL: $CLONE_HINT failed" >&2
|
||||
rm -rf "$dir"
|
||||
return 1
|
||||
fi
|
||||
kind="${line##*$'\t'}"
|
||||
source="${line%%$'\t'*}"
|
||||
if [ "$kind" = legacy ]; then
|
||||
echo "WARN: legacy superpowers source $source; prefer --superpowers-from or $DSH_HOME/superpowers" >&2
|
||||
echo "ok: cloned superpowers at $ref into $dir"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Sync + verify the checkout into $3 (staging out). Returns 1 (nothing
|
||||
# copied) on any problem; the synced tree is the caller's copy source.
|
||||
sync_skills() {
|
||||
local checkout="$1" ref="$2" out="$3" verified="" verified_n="" verify_rc=0
|
||||
if ! node "$ROOT/dsh/sync/sync-from-upstream.mjs" "$checkout" --out "$out" --ref "$ref"; then
|
||||
echo "FAIL: superpowers sync failed ($checkout)" >&2
|
||||
return 1
|
||||
fi
|
||||
local dir name dest
|
||||
for dir in "$source"/*; do
|
||||
verified="$(node "$ROOT/dsh/sync/verify-skills.mjs" "$out" 2>&1)" || verify_rc=$?
|
||||
printf '%s\n' "$verified"
|
||||
if [ "$verify_rc" -ne 0 ]; then
|
||||
echo "FAIL: superpowers verify failed; nothing copied" >&2
|
||||
return 1
|
||||
fi
|
||||
# T2.5: a verified tree with zero skills (exit 0) must never be installed.
|
||||
verified_n="$(printf '%s\n' "$verified" | sed -n 's/.*verified \([0-9][0-9]*\) skills.*/\1/p' | tail -n 1)"
|
||||
case "$verified_n" in
|
||||
''|*[!0-9]*) echo "FAIL: verify reported no usable skill count; nothing copied" >&2; return 1 ;;
|
||||
esac
|
||||
if [ "$verified_n" -eq 0 ]; then
|
||||
echo "FAIL: verify reported 0 skills; a tree without skills is never installed" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Copy every <dir>/SKILL.md under $1 into $DSH_HOME/skills (rm -rf dest, cp -R).
|
||||
# The optional label names where the skills came from in the report line.
|
||||
copy_skills_from() {
|
||||
local src="$1" label="${2:-$1}" count=0 dir name dest
|
||||
for dir in "$src"/*; do
|
||||
[ -f "$dir/SKILL.md" ] || continue
|
||||
name="$(basename "$dir")"
|
||||
dest="$DSH_HOME/skills/$name"
|
||||
@ -332,7 +495,67 @@ install_skills() {
|
||||
cp -R "$dir" "$dest"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "copied $count superpowers skills into $DSH_HOME/skills (source: $source)"
|
||||
echo "copied $count superpowers skills into $DSH_HOME/skills (source: $label)"
|
||||
}
|
||||
|
||||
install_skills() {
|
||||
mkdir -p "$DSH_HOME/skills"
|
||||
|
||||
# Explicit source: sync it directly, no network, no pin enforcement.
|
||||
local explicit_dir=""
|
||||
if [ -n "$SUPERPOWERS_FROM" ]; then
|
||||
explicit_dir="$SUPERPOWERS_FROM"
|
||||
elif [ -n "${DSH_SUPERPOWERS_SOURCE:-}" ]; then
|
||||
explicit_dir="$DSH_SUPERPOWERS_SOURCE"
|
||||
fi
|
||||
if [ -n "$explicit_dir" ]; then
|
||||
local skills_dir="" checkout=""
|
||||
skills_dir="$(normalize_superpowers_dir "$explicit_dir")" || {
|
||||
report_invalid_explicit_source
|
||||
return 1
|
||||
}
|
||||
checkout="$(checkout_root "$skills_dir")" || {
|
||||
report_invalid_explicit_source
|
||||
return 1
|
||||
}
|
||||
# T3.1: the staging tree lives beside the clone dir, never inside it.
|
||||
if ! sync_skills "$checkout" "$(source_ref "$checkout")" "$EXPLICIT_OUT"; then
|
||||
return 1
|
||||
fi
|
||||
copy_skills_from "$EXPLICIT_OUT" "$checkout"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Default source: the pinned clone at $DSH_HOME/superpowers.
|
||||
local ensure_rc=0
|
||||
ensure_pinned_clone "$DSH_HOME/superpowers" "$DSH_SUPERPOWERS_REF" || ensure_rc=$?
|
||||
if [ "$ensure_rc" -eq 0 ]; then
|
||||
if ! sync_skills "$DSH_HOME/superpowers" "$DSH_SUPERPOWERS_REF" "$PINNED_OUT"; then
|
||||
return 1
|
||||
fi
|
||||
copy_skills_from "$PINNED_OUT" "$DSH_HOME/superpowers"
|
||||
return 0
|
||||
fi
|
||||
if [ "$ensure_rc" -ge 2 ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# A fresh clone failed (offline): legacy sources stay raw with WARN (D-4).
|
||||
local legacy="" legacy_skills=""
|
||||
legacy="$(ls -d "$HOME"/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills 2>/dev/null | sort -V | tail -1 || true)"
|
||||
if [ -n "$legacy" ] && legacy_skills="$(normalize_superpowers_dir "$legacy")"; then
|
||||
:
|
||||
elif legacy_skills="$(normalize_superpowers_dir "$HOME/.claude/skills")"; then
|
||||
:
|
||||
else
|
||||
echo "FAIL: no superpowers source: $CLONE_HINT failed and no legacy source found" >&2
|
||||
echo "FAIL: clone it once: $CLONE_HINT" >&2
|
||||
echo "FAIL: or pass --superpowers-from <dir>; local skills (role, sdd) are installed by this run" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "WARN: legacy superpowers source $legacy_skills; prefer --superpowers-from or $DSH_HOME/superpowers" >&2
|
||||
copy_skills_from "$legacy_skills"
|
||||
return 0
|
||||
}
|
||||
|
||||
install_local_skills() {
|
||||
|
||||
502
dsh/sync/sync-from-upstream.mjs
Normal file
502
dsh/sync/sync-from-upstream.mjs
Normal file
@ -0,0 +1,502 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sync `skills/` from an obra/superpowers checkout, re-applying this port's
|
||||
* DSH adaptations.
|
||||
*
|
||||
* Vendored from LayneChai/superpowers-dsh `scripts/sync-from-upstream.mjs`
|
||||
* (MIT), adapted for claude-harness:
|
||||
*
|
||||
* - no `package.json` binding: the upstream checkout and the destination
|
||||
* come from argv (`<upstream-checkout>`, `--out`), not from the package;
|
||||
* - the DSH-only override is copied from `dsh/bundle/dsh-tools.md` over the
|
||||
* synced tree as `using-superpowers/references/dsh-tools.md` (resolved
|
||||
* from `import.meta.url`, never from cwd);
|
||||
* - the upstream version is recorded in `<out>/.sync-meta.json` together
|
||||
* with the pinned ref, instead of being compared to package.json;
|
||||
* - the destination is replaced by a staging tree only after every
|
||||
* assertion passed, so a broken upstream never leaves a half-synced
|
||||
* `--out` behind.
|
||||
*
|
||||
* The upstream skills are the source of truth for what a skill says. This
|
||||
* port ships a small, explicit, asserted set of deltas:
|
||||
*
|
||||
* 1. `dsh/bundle/dsh-tools.md` replaces the per-harness reference files
|
||||
* upstream ships for Claude Code, Codex, Pi, Antigravity, Hermes and
|
||||
* Muse.
|
||||
* 2. The transforms below rewrite upstream text that names another harness
|
||||
* and drop the Claude-Code-only subagent stop gate.
|
||||
* 3. Every remaining `superpowers:` namespace prefix is stripped: the DSH
|
||||
* skill registry addresses skills by bare name.
|
||||
*
|
||||
* A missing anchor fails the run loudly: upstream rewrote text this port
|
||||
* depends on, so a human must decide what the DSH wording becomes. The one
|
||||
* exception is `optional: true` — a transform whose upstream text v7.0.1 has
|
||||
* already dropped: it is reported as a warning, not a failure, so the port
|
||||
* keeps the transform ready for the release that brings the text back.
|
||||
*
|
||||
* Usage:
|
||||
* node dsh/sync/sync-from-upstream.mjs <upstream-checkout> [--out <dir>] [--ref <tag>]
|
||||
*
|
||||
* Defaults: `--out <upstream-checkout>/.sync-out`, `--ref unknown`.
|
||||
* Staging tree: `<out>.tmp-<pid>`; it is removed on any failure and only
|
||||
* renamed onto `<out>` after all assertions pass.
|
||||
*
|
||||
* @module dsh/sync/sync-from-upstream
|
||||
*/
|
||||
import { rmSync } from 'node:fs'
|
||||
import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
/** The single source of the DSH tool mapping: `dsh/sync/..` = `dsh/`. */
|
||||
const dshToolsOverride = resolve(scriptDir, '..', 'bundle', 'dsh-tools.md')
|
||||
/** Path of the override inside the synced skill tree. */
|
||||
const OVERRIDE_TARGET = 'using-superpowers/references/dsh-tools.md'
|
||||
|
||||
const UPSTREAM_SKILLS = [
|
||||
'brainstorming',
|
||||
'diagnosing-superpowers',
|
||||
'dispatching-parallel-agents',
|
||||
'executing-plans',
|
||||
'finishing-a-development-branch',
|
||||
'receiving-code-review',
|
||||
'requesting-code-review',
|
||||
'subagent-driven-development',
|
||||
'systematic-debugging',
|
||||
'test-driven-development',
|
||||
'using-git-worktrees',
|
||||
'using-superpowers',
|
||||
'verification-before-completion',
|
||||
'writing-plans',
|
||||
'writing-skills'
|
||||
]
|
||||
|
||||
/** Per-harness reference files upstream v7.0.1 ships that dsh-tools.md replaces. */
|
||||
const SKIP = [
|
||||
'using-superpowers/references/antigravity-tools.md',
|
||||
'using-superpowers/references/claude-code-tools.md',
|
||||
'using-superpowers/references/codex-tools.md',
|
||||
'using-superpowers/references/gemini-tools.md',
|
||||
'using-superpowers/references/hermes-tools.md',
|
||||
'using-superpowers/references/muse-tools.md',
|
||||
'using-superpowers/references/pi-tools.md'
|
||||
]
|
||||
|
||||
const DSH_HOME = '$DSH_HOME'
|
||||
|
||||
/**
|
||||
* Explicit rewrites applied before the namespace strip. Each entry must match
|
||||
* exactly `expect` times, or the sync aborts — unless it is `optional: true`,
|
||||
* in which case an absent anchor is reported as a warning. Optional entries
|
||||
* exist for upstream deletions: v7.0.1 dropped the subagent hook example, but
|
||||
* an upstream release may bring it back, and the adaptation must then return
|
||||
* automatically instead of being silently absent from the transform list.
|
||||
*/
|
||||
const TRANSFORMS = [
|
||||
{
|
||||
name: 'using-superpowers: drop the upstream subagent stop gate',
|
||||
file: 'using-superpowers/SKILL.md',
|
||||
find: '<SUBAGENT-STOP>\nIf you were dispatched as a subagent to execute a specific task, ignore this skill.\n</SUBAGENT-STOP>\n\n',
|
||||
replace: ''
|
||||
},
|
||||
{
|
||||
name: 'using-superpowers: DSH platform reference replaces the harness list',
|
||||
file: 'using-superpowers/SKILL.md',
|
||||
find: [
|
||||
'If your harness appears here, read its reference file for special instructions:',
|
||||
'',
|
||||
'- Claude Code: `references/claude-code-tools.md`',
|
||||
'- Codex: `references/codex-tools.md`',
|
||||
'- Pi: `references/pi-tools.md`',
|
||||
'- Antigravity: `references/antigravity-tools.md`',
|
||||
'- Hermes Agent: `references/hermes-tools.md`',
|
||||
'- Muse: `references/muse-tools.md`'
|
||||
].join('\n'),
|
||||
replace: [
|
||||
'This package is the DeepSeek Harness port. Its platform reference is',
|
||||
'`references/dsh-tools.md`: the Claude-Code → DSH tool mapping plus the',
|
||||
'DSH-specific mechanisms these skills rely on (`skill`, `subagent` /',
|
||||
'`subagent_fork`, `workflow`, `goal`, and the Windows `pwsh` notes).',
|
||||
'',
|
||||
'## Invoking skills on DSH',
|
||||
'',
|
||||
'Call the `skill` tool with the exact skill name from the catalog before',
|
||||
'acting on a task that names or clearly matches that skill:',
|
||||
'',
|
||||
'- Load applicable skills FIRST, then follow their full instructions.',
|
||||
'- Skills are addressable by bare name: `brainstorming`, `writing-plans`,',
|
||||
' `systematic-debugging`, `test-driven-development`, `using-git-worktrees`,',
|
||||
' `subagent-driven-development`, `dispatching-parallel-agents`, and the rest.',
|
||||
'- `using-superpowers` itself is discoverable the same way.'
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
name: 'brainstorming: DSH platform note for the visual companion',
|
||||
file: 'brainstorming/visual-companion.md',
|
||||
find: 'Browser-based visual brainstorming companion for showing mockups, diagrams, and options.\n',
|
||||
replace: [
|
||||
'Browser-based visual brainstorming companion for showing mockups, diagrams, and options.',
|
||||
'',
|
||||
'## Platform notes (DeepSeek Harness port)',
|
||||
'',
|
||||
'The companion server is `scripts/server.cjs` (plain Node.js, runs anywhere',
|
||||
'Node runs, including Windows). The `start-server.sh` / `stop-server.sh`',
|
||||
'helpers are bash-only: on Windows, start the server directly with',
|
||||
'`node scripts/server.cjs --project-dir <path> --open` and stop it by killing',
|
||||
'the process. All other guidance below is platform-neutral.',
|
||||
''
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
name: 'executing-plans: DSH availability of subagents',
|
||||
file: 'executing-plans/SKILL.md',
|
||||
find: [
|
||||
'- You have a plan from superpowers:writing-plans and your human partner',
|
||||
' chose inline execution at the handoff.',
|
||||
'- Your harness has no subagent tool (see the per-platform references in',
|
||||
' `../using-superpowers/references/`). Never fabricate a dispatch; run',
|
||||
' the plan here.'
|
||||
].join('\n'),
|
||||
replace: [
|
||||
'- You have a plan from writing-plans and your human partner chose inline',
|
||||
' (Native) execution at the handoff.',
|
||||
'- No subagent tool is available in this session, or your human partner',
|
||||
' chose Native. On the DeepSeek Harness `subagent` / `subagent_fork`',
|
||||
' normally ARE available (see `../using-superpowers/references/dsh-tools.md`),',
|
||||
' so prefer subagent-driven-development; run the plan here when the work',
|
||||
' must stay in one context. Never fabricate a dispatch.'
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
name: 'executing-plans: invoke sdd-workspace through bash',
|
||||
file: 'executing-plans/SKILL.md',
|
||||
find: '`../subagent-driven-development/scripts/sdd-workspace PLAN_FILE`',
|
||||
replace: '`bash ../subagent-driven-development/scripts/sdd-workspace PLAN_FILE`'
|
||||
},
|
||||
{
|
||||
name: 'executing-plans: invoke task-start through bash',
|
||||
file: 'executing-plans/SKILL.md',
|
||||
find: "Run this skill's `scripts/task-start PLAN_FILE N`.",
|
||||
replace: "Run this skill's `bash scripts/task-start PLAN_FILE N`."
|
||||
},
|
||||
{
|
||||
name: 'executing-plans: invoke task-done through bash',
|
||||
file: 'executing-plans/SKILL.md',
|
||||
find: "Run this skill's `scripts/task-done PLAN_FILE N BASE -- <test command>`",
|
||||
replace: "Run this skill's `bash scripts/task-done PLAN_FILE N BASE -- <test command>`"
|
||||
},
|
||||
{
|
||||
name: 'executing-plans: invoke review-package through bash',
|
||||
file: 'executing-plans/SKILL.md',
|
||||
find: 'Run `../subagent-driven-development/scripts/review-package PLAN_FILE MERGE_BASE HEAD`',
|
||||
replace: 'Run `bash ../subagent-driven-development/scripts/review-package PLAN_FILE MERGE_BASE HEAD`'
|
||||
},
|
||||
{
|
||||
name: 'executing-plans: task-start calls task-brief through bash',
|
||||
file: 'executing-plans/scripts/task-start',
|
||||
find: 'out=$("$sdd/task-brief" "$plan" "$n")',
|
||||
replace: 'out=$("${BASH:-bash}" "$sdd/task-brief" "$plan" "$n")'
|
||||
},
|
||||
{
|
||||
name: 'executing-plans: task-done calls sdd-workspace through bash',
|
||||
file: 'executing-plans/scripts/task-done',
|
||||
find: 'dir=$("$sdd/sdd-workspace" "$plan")',
|
||||
replace: 'dir=$("${BASH:-bash}" "$sdd/sdd-workspace" "$plan")'
|
||||
},
|
||||
{
|
||||
name: 'writing-skills: DSH skills directory replaces the cross-harness paths',
|
||||
file: 'writing-skills/SKILL.md',
|
||||
find: "**Personal skills live in your runtime's skills directory** (`~/.claude/skills/` on Claude Code) — see [codex-tools.md](../using-superpowers/references/codex-tools.md) or [gemini-tools.md](../using-superpowers/references/gemini-tools.md) for the path on those runtimes. Codex, Copilot CLI, and Gemini CLI all also recognize `~/.agents/skills/` as a cross-runtime alias.",
|
||||
replace: "**Personal skills live in your runtime's skills directory**: on the DeepSeek Harness, that is `" +
|
||||
DSH_HOME + "/skills/` (user level, discovered automatically by `skill-filesystem`), the project `.dsh/skills/` / " +
|
||||
'`.agents/skills/` roots, or — for packaged skills — the `skills/` directory inside a plugin bundle like this ' +
|
||||
'one. See [dsh-tools.md](../using-superpowers/references/dsh-tools.md) for the full mechanism.'
|
||||
},
|
||||
{
|
||||
// optional: upstream v7.0.1 dropped the hook example this transform rewrites.
|
||||
name: 'subagent-driven-development: hook example uses the DSH home',
|
||||
file: 'subagent-driven-development/SKILL.md',
|
||||
find: 'You: "User level (~/.config/superpowers/hooks/)"',
|
||||
replace: 'You: "Install the hook at the user level (' + DSH_HOME + ')"',
|
||||
optional: true
|
||||
},
|
||||
{
|
||||
name: 'diagnosing-superpowers: case workspace lives under the DSH home',
|
||||
file: 'diagnosing-superpowers/SKILL.md',
|
||||
find: '`~/.superpowers/diagnosing-superpowers/<session-id>/`',
|
||||
replace: '`' + DSH_HOME + '/diagnosing-superpowers/<session-id>/`'
|
||||
},
|
||||
{
|
||||
name: 'diagnosing-superpowers: case template uses the DSH home',
|
||||
file: 'diagnosing-superpowers/templates/case.md',
|
||||
find: 'Workspace: ~/.superpowers/diagnosing-superpowers/<session-id>/',
|
||||
replace: 'Workspace: ' + DSH_HOME + '/diagnosing-superpowers/<session-id>/'
|
||||
},
|
||||
{
|
||||
name: 'diagnosing-superpowers: report template uses the DSH home',
|
||||
file: 'diagnosing-superpowers/templates/report.md',
|
||||
find: 'Report path: ~/.superpowers/diagnosing-superpowers/<session-id>/report.md',
|
||||
replace: 'Report path: ' + DSH_HOME + '/diagnosing-superpowers/<session-id>/report.md'
|
||||
},
|
||||
{
|
||||
name: 'diagnosing-superpowers: DSH session discovery facts',
|
||||
file: 'diagnosing-superpowers/references/session-discovery.md',
|
||||
find: 'limitation and ask for the missing path, export, or identifying detail.',
|
||||
replace: [
|
||||
'limitation and ask for the missing path, export, or identifying detail.',
|
||||
'',
|
||||
'## DeepSeek Harness',
|
||||
'',
|
||||
'Verified in this environment; confirm it against the records you actually read.',
|
||||
'',
|
||||
'- Transcripts: `' + DSH_HOME + '/sessions/<slugified-cwd>/session-<uuid>/session*.jsonl.zstd`',
|
||||
' (`' + DSH_HOME + '` defaults to `~/.dsh`). The slug is the working directory with',
|
||||
' separators and non-alphanumeric characters folded to `-`, wrapped in `--`.',
|
||||
'- Child sessions — work dispatched through `subagent` / `subagent_fork` — are',
|
||||
' peer directories under the same slug whose header record carries',
|
||||
' `delegationDepth: 1`.',
|
||||
'- The schema version is in the filename (`session.v4.jsonl.zstd`; older files',
|
||||
' are `session.v3.jsonl.zstd` or `session.jsonl.zstd`). Read the header',
|
||||
" record's `version` instead of guessing from the name.",
|
||||
'- Each file is a concatenation of independent zstd frames, and the `zstd` CLI',
|
||||
' is usually absent. See `../using-superpowers/references/dsh-tools.md` for the',
|
||||
' frame-by-frame Node decompression command.',
|
||||
'- Records are JSON lines carrying `type`, `seq`, `time`, and `data`. Establish',
|
||||
' what each type means from the records themselves — never import a meaning',
|
||||
' from another harness.'
|
||||
].join('\n')
|
||||
}
|
||||
]
|
||||
|
||||
const TEXT_EXTENSIONS = new Set(['.md', '.markdown', '.txt', '.js', '.cjs', '.mjs', '.ts', '.sh', '.dot', '.html'])
|
||||
|
||||
const USAGE = 'usage: node dsh/sync/sync-from-upstream.mjs <upstream-checkout> [--out <dir>] [--ref <tag>]'
|
||||
|
||||
/** Staging tree, set once the destination is known. */
|
||||
let staging = null
|
||||
|
||||
function fail(message) {
|
||||
if (staging) rmSync(staging, { recursive: true, force: true })
|
||||
console.error(`\n[FAIL] ${message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function usage(message) {
|
||||
if (message) console.error(`[FAIL] ${message}\n`)
|
||||
console.error(USAGE)
|
||||
process.exit(message ? 2 : 0)
|
||||
}
|
||||
|
||||
/** @returns {{upstream: string, out: string, ref: string}} */
|
||||
function parseArgs(argv) {
|
||||
let upstream = null
|
||||
let out = null
|
||||
let ref = null
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
if (arg === '--out' || arg === '--ref') {
|
||||
const value = argv[++i]
|
||||
if (!value) usage(`missing value for ${arg}`)
|
||||
if (arg === '--out') out = value
|
||||
else ref = value
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
usage()
|
||||
} else if (arg.startsWith('--')) {
|
||||
usage(`unknown option ${arg}`)
|
||||
} else if (upstream === null) {
|
||||
upstream = arg
|
||||
} else {
|
||||
usage(`unexpected argument ${arg}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!upstream) usage('missing <upstream-checkout>')
|
||||
|
||||
const upstreamRoot = resolve(upstream)
|
||||
return {
|
||||
upstream: upstreamRoot,
|
||||
out: out === null ? join(upstreamRoot, '.sync-out') : resolve(out),
|
||||
ref: ref === null ? 'unknown' : ref
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `parent` is `child` or one of its ancestors (`/a` → `/a/b`).
|
||||
*
|
||||
* A parent hop is exactly `..` or `..<sep>…`; 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)
|
||||
if (rel === '') return true
|
||||
if (rel === '..' || rel.startsWith('..' + sep)) return false
|
||||
return !isAbsolute(rel)
|
||||
}
|
||||
|
||||
/** @returns {Promise<string[]>} repo-relative file paths under `root`. */
|
||||
async function walk(root, current = root) {
|
||||
const out = []
|
||||
for (const entry of await readdir(current, { withFileTypes: true })) {
|
||||
const full = join(current, entry.name)
|
||||
if (entry.isDirectory()) out.push(...(await walk(root, full)))
|
||||
else out.push(relative(root, full).split(sep).join('/'))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function isTextFile(rel) {
|
||||
const dot = rel.lastIndexOf('.')
|
||||
const slash = rel.lastIndexOf('/')
|
||||
if (dot <= slash) return true
|
||||
return TEXT_EXTENSIONS.has(rel.slice(dot))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply every transform whose `file` matches, asserting the anchor exists.
|
||||
* Optional transforms whose anchor is gone are collected for a warning.
|
||||
*/
|
||||
function applyTransforms(rel, text, applied, optionalMisses) {
|
||||
for (const t of TRANSFORMS) {
|
||||
if (t.file !== rel) continue
|
||||
if (t.find === t.replace) continue
|
||||
const count = text.split(t.find).length - 1
|
||||
if (count === 0) {
|
||||
if (t.optional) {
|
||||
optionalMisses.push(t)
|
||||
continue
|
||||
}
|
||||
fail(`anchor missing in ${rel} for "${t.name}"\n find: ${JSON.stringify(t.find.slice(0, 120))}`)
|
||||
}
|
||||
if (count > 1) fail(`anchor matched ${count} times in ${rel} for "${t.name}"`)
|
||||
text = text.replace(t.find, t.replace)
|
||||
applied.add(t.name)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const upstreamRoot = args.upstream
|
||||
const upstreamSkills = join(upstreamRoot, 'skills')
|
||||
|
||||
// The destination must never overlap the source: swapping it in would
|
||||
// delete the checkout we read from, and `--out <checkout>/skills` would
|
||||
// delete the very tree being walked. Refuse before any write or delete.
|
||||
if (isAncestorOrSame(args.out, upstreamRoot) || args.out === upstreamSkills) {
|
||||
fail(`--out collides with the upstream checkout (${args.out})`)
|
||||
}
|
||||
|
||||
const upstreamStats = await stat(upstreamSkills).catch(() => null)
|
||||
if (!upstreamStats?.isDirectory()) {
|
||||
fail(`no skills/ directory in ${upstreamRoot}`)
|
||||
}
|
||||
|
||||
// A new (or removed) upstream skill must be a deliberate decision: it
|
||||
// changes the skill catalog, the README tables, and possibly the DSH
|
||||
// adaptations.
|
||||
const upstreamSkillDirs = (await readdir(upstreamSkills, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
const expectedSorted = [...UPSTREAM_SKILLS].sort()
|
||||
if (upstreamSkillDirs.join('\n') !== expectedSorted.join('\n')) {
|
||||
const added = upstreamSkillDirs.filter((name) => !expectedSorted.includes(name))
|
||||
const removed = expectedSorted.filter((name) => !upstreamSkillDirs.includes(name))
|
||||
fail(
|
||||
`the upstream skill set changed (expected ${expectedSorted.length} skills from UPSTREAM_SKILLS) — decide before syncing:\n` +
|
||||
(added.length > 0 ? ` added upstream: ${added.join(', ')}\n` : '') +
|
||||
(removed.length > 0 ? ` removed upstream: ${removed.join(', ')}\n` : '') +
|
||||
' Update UPSTREAM_SKILLS in this script and the README skill tables.'
|
||||
)
|
||||
}
|
||||
|
||||
// Report the upstream version so the destination can record it.
|
||||
let upstreamVersion = 'unknown'
|
||||
try {
|
||||
const manifest = JSON.parse(await readFile(join(upstreamRoot, '.claude-plugin', 'plugin.json'), 'utf8'))
|
||||
upstreamVersion = manifest.version ?? 'unknown'
|
||||
} catch {
|
||||
/* version is informational only */
|
||||
}
|
||||
|
||||
const overrideStats = await stat(dshToolsOverride).catch(() => null)
|
||||
if (!overrideStats?.isFile()) {
|
||||
fail(`missing DSH override ${dshToolsOverride} (dsh/bundle/dsh-tools.md)`)
|
||||
}
|
||||
|
||||
staging = `${args.out}.tmp-${process.pid}`
|
||||
await rm(staging, { recursive: true, force: true })
|
||||
await mkdir(staging, { recursive: true })
|
||||
|
||||
const applied = new Set()
|
||||
const optionalMisses = []
|
||||
let copied = 0
|
||||
let stripped = 0
|
||||
|
||||
for (const rel of await walk(upstreamSkills)) {
|
||||
if (SKIP.includes(rel)) continue
|
||||
const sourceFile = join(upstreamSkills, rel)
|
||||
const target = join(staging, rel)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
|
||||
if (!isTextFile(rel)) {
|
||||
await cp(sourceFile, target)
|
||||
copied++
|
||||
continue
|
||||
}
|
||||
|
||||
let text = await readFile(sourceFile, 'utf8')
|
||||
text = applyTransforms(rel, text, applied, optionalMisses)
|
||||
|
||||
const namespaced = text.split('superpowers:').length - 1
|
||||
if (namespaced > 0) {
|
||||
text = text.replaceAll('superpowers:', '')
|
||||
stripped += namespaced
|
||||
}
|
||||
|
||||
await writeFile(target, text, 'utf8')
|
||||
copied++
|
||||
}
|
||||
|
||||
// The DSH-only file replaces upstream text wholesale.
|
||||
const overrideTarget = join(staging, OVERRIDE_TARGET)
|
||||
await mkdir(dirname(overrideTarget), { recursive: true })
|
||||
await cp(dshToolsOverride, overrideTarget)
|
||||
copied++
|
||||
|
||||
// Every required transform must have fired; one that silently stopped
|
||||
// matching would drop a DSH adaptation from the next release's sync.
|
||||
const unfired = TRANSFORMS.filter((t) => t.find !== t.replace && !t.optional && !applied.has(t.name))
|
||||
if (unfired.length > 0) {
|
||||
fail(`transform(s) never matched a copied file: ${unfired.map((t) => `${t.name} (${t.file})`).join('; ')}`)
|
||||
}
|
||||
for (const t of optionalMisses) {
|
||||
console.warn(`[warn] optional transform did not match, anchor gone upstream: "${t.name}" (${t.file})`)
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
join(staging, '.sync-meta.json'),
|
||||
JSON.stringify({ ref: args.ref, version: upstreamVersion }, null, 2) + '\n',
|
||||
'utf8'
|
||||
)
|
||||
|
||||
// Everything applied: swap the staging tree in.
|
||||
await mkdir(dirname(args.out), { recursive: true })
|
||||
await rm(args.out, { recursive: true, force: true })
|
||||
await rename(staging, args.out)
|
||||
staging = null
|
||||
|
||||
console.log(`synced ${copied} files from obra/superpowers ${upstreamVersion} (ref ${args.ref}) into ${args.out}`)
|
||||
console.log(` namespace prefixes stripped: ${stripped}`)
|
||||
console.log(` transforms applied: ${applied.size}`)
|
||||
console.log(` dsh override: ${relative(process.cwd(), dshToolsOverride) || dshToolsOverride} -> ${OVERRIDE_TARGET}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
if (staging) rmSync(staging, { recursive: true, force: true })
|
||||
console.error(`\n[FAIL] unexpected error: ${error?.stack ?? error}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
235
dsh/sync/verify-skills.mjs
Normal file
235
dsh/sync/verify-skills.mjs
Normal file
@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify a synced skills tree the way this port consumes it.
|
||||
*
|
||||
* node dsh/sync/verify-skills.mjs <skills-dir>
|
||||
*
|
||||
* `install.sh` runs this between `sync-from-upstream.mjs` and the copy into
|
||||
* `$DSH_HOME/skills`: a tree that fails here is never installed.
|
||||
*
|
||||
* Checks, per `<skill>/SKILL.md`:
|
||||
*
|
||||
* 1. YAML frontmatter parses and carries non-empty scalar `name` and
|
||||
* `description` — the fields the DSH skill catalog reads. The parser is
|
||||
* the scalar one vendored from LayneChai's `parseFrontmatter` (MIT); no
|
||||
* provider is imported, because on the DeepSeek Harness the registry
|
||||
* belongs to the built-in `skill-filesystem`, not to this repository.
|
||||
* A duplicate scalar key is an error too: the last value silently wins
|
||||
* in a naive parser, and a name/description pair that appears twice is
|
||||
* a broken skill file, not a style choice.
|
||||
* 2. The body carries no leftover upstream `superpowers:` namespace
|
||||
* prefix — the port strips it during sync, and the DSH registry
|
||||
* addresses skills by bare name.
|
||||
* 3. Every relative reference in the body resolves inside `<skills-dir>`:
|
||||
* markdown links (`[x](../<skill>/y.md)`) and path-like inline code
|
||||
* (`references/y.md`, `../<skill>/references/y.md`, `scripts/y`,
|
||||
* `../<skill>/scripts/y`). Both the skill directory and the file must
|
||||
* exist — a renamed or dropped upstream script is exactly the
|
||||
* 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 —
|
||||
* 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
|
||||
* prose, and that is legal — the mapping table names the upstream namespace
|
||||
* on purpose (T1.5). Fenced code blocks are treated as examples, not prose,
|
||||
* exactly as the vendored reference implementation does.
|
||||
*
|
||||
* Output: one `ok: <skill>` line per skill directory and a final
|
||||
* `verified N skills`. The first problem stops the run: `<skill>/SKILL.md:
|
||||
* <reason>` on stderr, exit 1.
|
||||
*
|
||||
* @module dsh/sync/verify-skills
|
||||
*/
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const USAGE = 'usage: node dsh/sync/verify-skills.mjs <skills-dir>'
|
||||
|
||||
const MARKDOWN_LINK = /\[[^\]]*\]\(([^)\s]+)\)/g
|
||||
const INLINE_CODE = /`([^`\n]+)`/g
|
||||
|
||||
/** Trailing punctuation a path picks up from the surrounding sentence. */
|
||||
const TRAILING_PUNCTUATION = /[.,;:)\]]+$/
|
||||
|
||||
/**
|
||||
* Names that appear only as illustrative examples in prose, never as real
|
||||
* files — the same list the vendored reference implementation keeps.
|
||||
*
|
||||
* 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'])
|
||||
|
||||
/**
|
||||
* Parse a scalar-only YAML frontmatter block, same rules as the vendored
|
||||
* `parseFrontmatter`: the file starts with `---`, the block ends at the first
|
||||
* line that starts with `---`, and each `key: value` line becomes a string
|
||||
* (matching quotes stripped). Anything else is left for a real YAML parser.
|
||||
* Keys seen more than once are collected in `duplicates`: the vendored parser
|
||||
* would let the last value win silently, and a duplicated `name` or
|
||||
* `description` is a broken skill file.
|
||||
*
|
||||
* @returns {{metadata: Record<string, string>, body: string, duplicates: string[]} | null}
|
||||
*/
|
||||
function parseFrontmatter(text) {
|
||||
if (!text.startsWith('---')) return null
|
||||
const end = text.indexOf('\n---', 3)
|
||||
if (end === -1) return null
|
||||
const block = text.slice(3, end)
|
||||
const body = text.slice(end + 4).replace(/^\n+/, '')
|
||||
const metadata = {}
|
||||
const duplicates = []
|
||||
for (const line of block.split('\n')) {
|
||||
const match = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line.trim())
|
||||
if (!match) continue
|
||||
let value = match[2].trim()
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
if (Object.hasOwn(metadata, match[1])) duplicates.push(match[1])
|
||||
metadata[match[1]] = value
|
||||
}
|
||||
return { metadata, body, duplicates }
|
||||
}
|
||||
|
||||
/** Map every line inside a ``` / ~~~ fence to an empty line: examples are not prose. */
|
||||
function blankFences(text) {
|
||||
let fenced = false
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
fenced = !fenced
|
||||
return ''
|
||||
}
|
||||
return fenced ? '' : line
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/** True when a markdown link target names no in-tree file to resolve. */
|
||||
function isExternal(target) {
|
||||
return target.startsWith('#') || target.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(target)
|
||||
}
|
||||
|
||||
async function statOrNull(path) {
|
||||
return stat(path).catch(() => null)
|
||||
}
|
||||
|
||||
async function exists(path) {
|
||||
return Boolean(await statOrNull(path))
|
||||
}
|
||||
|
||||
async function isDirectory(path) {
|
||||
return Boolean((await statOrNull(path))?.isDirectory())
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the in-tree reference a whitespace-separated inline-code token names,
|
||||
* or `null` when it is prose (`foo`, `PLAN_FILE`, `skills/…` from the checkout
|
||||
* root) rather than a path relative to the skill directory.
|
||||
*
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function inlineReference(token) {
|
||||
if (/^(?:references|scripts)\//.test(token)) return token
|
||||
if (token.startsWith('../')) return token
|
||||
return null
|
||||
}
|
||||
|
||||
function fail(skill, reason) {
|
||||
console.error(`${skill}/SKILL.md: ${reason}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/** Verify one `<skillsRoot>/<skill>/SKILL.md`; exits the process on the first problem. */
|
||||
async function verifySkill(skillsRoot, skill) {
|
||||
const skillDir = join(skillsRoot, skill)
|
||||
const skillFile = join(skillDir, 'SKILL.md')
|
||||
|
||||
let text
|
||||
try {
|
||||
text = await readFile(skillFile, 'utf8')
|
||||
} catch (error) {
|
||||
fail(skill, `cannot read SKILL.md (${error?.code ?? error})`)
|
||||
}
|
||||
|
||||
const parsed = parseFrontmatter(text)
|
||||
if (parsed === null) fail(skill, 'missing YAML frontmatter')
|
||||
if (parsed.duplicates.length > 0) fail(skill, `duplicate key "${parsed.duplicates[0]}" in frontmatter`)
|
||||
if (!parsed.metadata.name) fail(skill, 'empty name in frontmatter')
|
||||
if (!parsed.metadata.description) fail(skill, 'empty description in frontmatter')
|
||||
|
||||
// The body is a suffix of the file, so the number of newlines before it is
|
||||
// the line offset: report file lines, not body lines, to the operator.
|
||||
const lineOffset = (text.slice(0, text.length - parsed.body.length).match(/\n/g) ?? []).length
|
||||
|
||||
const lines = blankFences(parsed.body).split('\n')
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const lineNumber = lineOffset + index + 1
|
||||
|
||||
if (line.includes('superpowers:')) {
|
||||
fail(skill, `leftover superpowers: namespace prefix in body (line ${lineNumber})`)
|
||||
}
|
||||
|
||||
for (const match of line.matchAll(MARKDOWN_LINK)) {
|
||||
const target = match[1]
|
||||
if (isExternal(target)) continue
|
||||
if (!(await exists(resolve(skillDir, target.replace(/[?#].*$/, ''))))) {
|
||||
fail(skill, `dangling relative reference: ${target} (line ${lineNumber})`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const code of line.matchAll(INLINE_CODE)) {
|
||||
for (const raw of code[1].split(/\s+/)) {
|
||||
const token = raw.replace(TRAILING_PUNCTUATION, '')
|
||||
if (token === '' || ILLUSTRATIVE.has(token)) continue
|
||||
const reference = inlineReference(token)
|
||||
if (reference === null) continue
|
||||
if (!(await exists(resolve(skillDir, reference)))) {
|
||||
fail(skill, `dangling relative reference: ${token} (line ${lineNumber})`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.argv.length > 3) {
|
||||
console.error(USAGE)
|
||||
process.exit(2)
|
||||
}
|
||||
const arg = process.argv[2]
|
||||
if (!arg) {
|
||||
console.error(USAGE)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const skillsRoot = resolve(arg)
|
||||
if (!(await isDirectory(skillsRoot))) {
|
||||
console.error(`${skillsRoot}: not a skills directory`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const skills = (await readdir(skillsRoot, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
|
||||
for (const skill of skills) await verifySkill(skillsRoot, skill)
|
||||
|
||||
for (const skill of skills) console.log(`ok: ${skill}`)
|
||||
console.log(`verified ${skills.length} skills`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`\n[FAIL] unexpected error: ${error?.stack ?? error}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
4
dsh/tests/fixtures/superpowers-min/.claude-plugin/plugin.json
vendored
Normal file
4
dsh/tests/fixtures/superpowers-min/.claude-plugin/plugin.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "superpowers",
|
||||
"version": "7.0.1"
|
||||
}
|
||||
12
dsh/tests/fixtures/superpowers-min/skills/brainstorming/SKILL.md
vendored
Normal file
12
dsh/tests/fixtures/superpowers-min/skills/brainstorming/SKILL.md
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
name: brainstorming
|
||||
description: Use when starting any creative work - explores intent before implementation
|
||||
---
|
||||
|
||||
# Brainstorming
|
||||
|
||||
Write the design into the plan produced by superpowers:writing-plans, then hand
|
||||
the plan to superpowers:executing-plans. For bugfixes, pair it with
|
||||
superpowers:systematic-debugging and superpowers:test-driven-development.
|
||||
|
||||
The visual companion lives in `skills/brainstorming/visual-companion.md`.
|
||||
5
dsh/tests/fixtures/superpowers-min/skills/brainstorming/visual-companion.md
vendored
Normal file
5
dsh/tests/fixtures/superpowers-min/skills/brainstorming/visual-companion.md
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
# Visual companion
|
||||
|
||||
Browser-based visual brainstorming companion for showing mockups, diagrams, and options.
|
||||
|
||||
Minimal upstream stub for sync tests.
|
||||
10
dsh/tests/fixtures/superpowers-min/skills/diagnosing-superpowers/SKILL.md
vendored
Normal file
10
dsh/tests/fixtures/superpowers-min/skills/diagnosing-superpowers/SKILL.md
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
---
|
||||
name: diagnosing-superpowers
|
||||
description: Upstream stub for diagnosing-superpowers used by sync tests.
|
||||
---
|
||||
|
||||
# Diagnosing superpowers
|
||||
|
||||
Case workspace: `~/.superpowers/diagnosing-superpowers/<session-id>/`
|
||||
|
||||
Minimal upstream stub for sync tests.
|
||||
@ -0,0 +1,3 @@
|
||||
# Session discovery
|
||||
|
||||
When a record is missing, state the limitation and ask for the missing path, export, or identifying detail.
|
||||
3
dsh/tests/fixtures/superpowers-min/skills/diagnosing-superpowers/templates/case.md
vendored
Normal file
3
dsh/tests/fixtures/superpowers-min/skills/diagnosing-superpowers/templates/case.md
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# Case
|
||||
|
||||
Workspace: ~/.superpowers/diagnosing-superpowers/<session-id>/
|
||||
3
dsh/tests/fixtures/superpowers-min/skills/diagnosing-superpowers/templates/report.md
vendored
Normal file
3
dsh/tests/fixtures/superpowers-min/skills/diagnosing-superpowers/templates/report.md
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# Report
|
||||
|
||||
Report path: ~/.superpowers/diagnosing-superpowers/<session-id>/report.md
|
||||
8
dsh/tests/fixtures/superpowers-min/skills/dispatching-parallel-agents/SKILL.md
vendored
Normal file
8
dsh/tests/fixtures/superpowers-min/skills/dispatching-parallel-agents/SKILL.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
name: dispatching-parallel-agents
|
||||
description: Upstream stub for dispatching-parallel-agents used by sync tests.
|
||||
---
|
||||
|
||||
# Dispatching parallel agents
|
||||
|
||||
Minimal upstream stub for sync tests. No DSH-specific text is rewritten here.
|
||||
20
dsh/tests/fixtures/superpowers-min/skills/executing-plans/SKILL.md
vendored
Normal file
20
dsh/tests/fixtures/superpowers-min/skills/executing-plans/SKILL.md
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
name: executing-plans
|
||||
description: Upstream stub for executing-plans used by sync tests.
|
||||
---
|
||||
|
||||
# Executing plans
|
||||
|
||||
- You have a plan from superpowers:writing-plans and your human partner
|
||||
chose inline execution at the handoff.
|
||||
- Your harness has no subagent tool (see the per-platform references in
|
||||
`../using-superpowers/references/`). Never fabricate a dispatch; run
|
||||
the plan here.
|
||||
|
||||
Run this skill's `scripts/task-start PLAN_FILE N`.
|
||||
|
||||
Run this skill's `scripts/task-done PLAN_FILE N BASE -- <test command>`.
|
||||
|
||||
Use `../subagent-driven-development/scripts/sdd-workspace PLAN_FILE`.
|
||||
|
||||
Run `../subagent-driven-development/scripts/review-package PLAN_FILE MERGE_BASE HEAD`.
|
||||
6
dsh/tests/fixtures/superpowers-min/skills/executing-plans/scripts/task-done
vendored
Normal file
6
dsh/tests/fixtures/superpowers-min/skills/executing-plans/scripts/task-done
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
sdd=$(dirname "$0")
|
||||
plan=$1
|
||||
dir=$("$sdd/sdd-workspace" "$plan")
|
||||
printf '%s\n' "$dir"
|
||||
7
dsh/tests/fixtures/superpowers-min/skills/executing-plans/scripts/task-start
vendored
Normal file
7
dsh/tests/fixtures/superpowers-min/skills/executing-plans/scripts/task-start
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
sdd=$(dirname "$0")
|
||||
plan=$1
|
||||
n=$2
|
||||
out=$("$sdd/task-brief" "$plan" "$n")
|
||||
printf '%s\n' "$out"
|
||||
8
dsh/tests/fixtures/superpowers-min/skills/finishing-a-development-branch/SKILL.md
vendored
Normal file
8
dsh/tests/fixtures/superpowers-min/skills/finishing-a-development-branch/SKILL.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
name: finishing-a-development-branch
|
||||
description: Upstream stub for finishing-a-development-branch used by sync tests.
|
||||
---
|
||||
|
||||
# Finishing a development branch
|
||||
|
||||
Minimal upstream stub for sync tests. No DSH-specific text is rewritten here.
|
||||
8
dsh/tests/fixtures/superpowers-min/skills/receiving-code-review/SKILL.md
vendored
Normal file
8
dsh/tests/fixtures/superpowers-min/skills/receiving-code-review/SKILL.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
name: receiving-code-review
|
||||
description: Upstream stub for receiving-code-review used by sync tests.
|
||||
---
|
||||
|
||||
# Receiving code review
|
||||
|
||||
Minimal upstream stub for sync tests. No DSH-specific text is rewritten here.
|
||||
8
dsh/tests/fixtures/superpowers-min/skills/requesting-code-review/SKILL.md
vendored
Normal file
8
dsh/tests/fixtures/superpowers-min/skills/requesting-code-review/SKILL.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
name: requesting-code-review
|
||||
description: Upstream stub for requesting-code-review used by sync tests.
|
||||
---
|
||||
|
||||
# Requesting code review
|
||||
|
||||
Minimal upstream stub for sync tests. No DSH-specific text is rewritten here.
|
||||
14
dsh/tests/fixtures/superpowers-min/skills/subagent-driven-development/SKILL.md
vendored
Normal file
14
dsh/tests/fixtures/superpowers-min/skills/subagent-driven-development/SKILL.md
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
---
|
||||
name: subagent-driven-development
|
||||
description: Upstream stub for subagent-driven-development used by sync tests.
|
||||
---
|
||||
|
||||
# Subagent-driven development
|
||||
|
||||
Setup example (upstream v7.0.0 wording, kept so the port exercises its
|
||||
home-dir transform; v7.0.1 dropped the example):
|
||||
|
||||
You: "User level (~/.config/superpowers/hooks/)"
|
||||
Human: "Project level"
|
||||
|
||||
Minimal upstream stub for sync tests.
|
||||
2
dsh/tests/fixtures/superpowers-min/skills/subagent-driven-development/scripts/review-package
vendored
Normal file
2
dsh/tests/fixtures/superpowers-min/skills/subagent-driven-development/scripts/review-package
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
echo "review-package: stub, dsh tests only"
|
||||
2
dsh/tests/fixtures/superpowers-min/skills/subagent-driven-development/scripts/sdd-workspace
vendored
Normal file
2
dsh/tests/fixtures/superpowers-min/skills/subagent-driven-development/scripts/sdd-workspace
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env bash
|
||||
echo "sdd-workspace: stub, dsh tests only"
|
||||
8
dsh/tests/fixtures/superpowers-min/skills/systematic-debugging/SKILL.md
vendored
Normal file
8
dsh/tests/fixtures/superpowers-min/skills/systematic-debugging/SKILL.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
name: systematic-debugging
|
||||
description: Upstream stub for systematic-debugging used by sync tests.
|
||||
---
|
||||
|
||||
# Systematic debugging
|
||||
|
||||
Minimal upstream stub for sync tests. No DSH-specific text is rewritten here.
|
||||
8
dsh/tests/fixtures/superpowers-min/skills/test-driven-development/SKILL.md
vendored
Normal file
8
dsh/tests/fixtures/superpowers-min/skills/test-driven-development/SKILL.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
name: test-driven-development
|
||||
description: Upstream stub for test-driven-development used by sync tests.
|
||||
---
|
||||
|
||||
# Test-driven development
|
||||
|
||||
Minimal upstream stub for sync tests. No DSH-specific text is rewritten here.
|
||||
8
dsh/tests/fixtures/superpowers-min/skills/using-git-worktrees/SKILL.md
vendored
Normal file
8
dsh/tests/fixtures/superpowers-min/skills/using-git-worktrees/SKILL.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
name: using-git-worktrees
|
||||
description: Upstream stub for using-git-worktrees used by sync tests.
|
||||
---
|
||||
|
||||
# Using git worktrees
|
||||
|
||||
Minimal upstream stub for sync tests. No DSH-specific text is rewritten here.
|
||||
46
dsh/tests/fixtures/superpowers-min/skills/using-superpowers/SKILL.md
vendored
Normal file
46
dsh/tests/fixtures/superpowers-min/skills/using-superpowers/SKILL.md
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
---
|
||||
name: using-superpowers
|
||||
description: Use when starting any conversation - establishes how to find and use skills, requiring skill invocation before ANY response including clarifying questions
|
||||
---
|
||||
|
||||
<SUBAGENT-STOP>
|
||||
If you were dispatched as a subagent to execute a specific task, ignore this skill.
|
||||
</SUBAGENT-STOP>
|
||||
|
||||
<EXTREMELY-IMPORTANT>
|
||||
If you think there is even a 1% chance a skill might apply to what you are doing, you ABSOLUTELY MUST invoke the skill.
|
||||
|
||||
IF A SKILL APPLIES TO YOUR TASK, YOU DO NOT HAVE A CHOICE. YOU MUST USE IT.
|
||||
|
||||
This is not negotiable. You cannot rationalize your way out of this.
|
||||
</EXTREMELY-IMPORTANT>
|
||||
|
||||
## The Rule
|
||||
|
||||
**Invoke relevant or requested skills BEFORE any response or action** — including clarifying questions, exploring the codebase, or checking files. If it turns out wrong for the situation, you don't have to use it.
|
||||
|
||||
**Before entering plan mode:** if you haven't already brainstormed, invoke the brainstorming skill first.
|
||||
|
||||
Then announce "Using [skill] to [purpose]" and follow the skill exactly. If it has a checklist, create a todo per item.
|
||||
|
||||
## Skill Priority
|
||||
|
||||
When multiple skills apply, process skills come first — they set the approach, then implementation skills (frontend-design, etc.) carry it out. Brainstorming and systematic-debugging are Superpowers' most common process skills, but the rule holds for any of them.
|
||||
|
||||
- "Let's build X" → superpowers:brainstorming first, then implementation skills.
|
||||
- "Fix this bug" → superpowers:systematic-debugging first, then domain skills.
|
||||
|
||||
## Platform Adaptation
|
||||
|
||||
If your harness appears here, read its reference file for special instructions:
|
||||
|
||||
- Claude Code: `references/claude-code-tools.md`
|
||||
- Codex: `references/codex-tools.md`
|
||||
- Pi: `references/pi-tools.md`
|
||||
- Antigravity: `references/antigravity-tools.md`
|
||||
- Hermes Agent: `references/hermes-tools.md`
|
||||
- Muse: `references/muse-tools.md`
|
||||
|
||||
## User Instructions
|
||||
|
||||
User instructions (CLAUDE.md, AGENTS.md, GEMINI.md, etc, direct requests) take precedence over skills, which in turn override default behavior. Only skip skill workflows or instructions when your human partner has explicitly told you to.
|
||||
3
dsh/tests/fixtures/superpowers-min/skills/using-superpowers/references/claude-code-tools.md
vendored
Normal file
3
dsh/tests/fixtures/superpowers-min/skills/using-superpowers/references/claude-code-tools.md
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# Claude Code tools
|
||||
|
||||
Upstream per-harness reference. The DSH port replaces it with `dsh-tools.md`.
|
||||
8
dsh/tests/fixtures/superpowers-min/skills/verification-before-completion/SKILL.md
vendored
Normal file
8
dsh/tests/fixtures/superpowers-min/skills/verification-before-completion/SKILL.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
name: verification-before-completion
|
||||
description: Upstream stub for verification-before-completion used by sync tests.
|
||||
---
|
||||
|
||||
# Verification before completion
|
||||
|
||||
Minimal upstream stub for sync tests. No DSH-specific text is rewritten here.
|
||||
8
dsh/tests/fixtures/superpowers-min/skills/writing-plans/SKILL.md
vendored
Normal file
8
dsh/tests/fixtures/superpowers-min/skills/writing-plans/SKILL.md
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
name: writing-plans
|
||||
description: Upstream stub for writing-plans used by sync tests.
|
||||
---
|
||||
|
||||
# Writing plans
|
||||
|
||||
Minimal upstream stub for sync tests. No DSH-specific text is rewritten here.
|
||||
10
dsh/tests/fixtures/superpowers-min/skills/writing-skills/SKILL.md
vendored
Normal file
10
dsh/tests/fixtures/superpowers-min/skills/writing-skills/SKILL.md
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
---
|
||||
name: writing-skills
|
||||
description: Upstream stub for writing-skills used by sync tests.
|
||||
---
|
||||
|
||||
# Writing skills
|
||||
|
||||
**Personal skills live in your runtime's skills directory** (`~/.claude/skills/` on Claude Code) — see [codex-tools.md](../using-superpowers/references/codex-tools.md) or [gemini-tools.md](../using-superpowers/references/gemini-tools.md) for the path on those runtimes. Codex, Copilot CLI, and Gemini CLI all also recognize `~/.agents/skills/` as a cross-runtime alias.
|
||||
|
||||
Minimal upstream stub for sync tests.
|
||||
@ -1,13 +1,15 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, lstatSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { cleanup, tempDir, write } from './helpers.mjs'
|
||||
import { cleanup, git, tempDir, write } from './helpers.mjs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const script = join(here, '..', 'install.sh')
|
||||
const testDir = dirname(fileURLToPath(import.meta.url))
|
||||
const fixture = resolve(testDir, 'fixtures', 'superpowers-min')
|
||||
|
||||
test('install.sh copies skills and roles instead of linking into ~/.claude', () => {
|
||||
const home = tempDir()
|
||||
@ -26,7 +28,9 @@ test('install.sh copies skills and roles instead of linking into ~/.claude', ()
|
||||
write(join(cache, 'using-superpowers', 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n')
|
||||
write(join(cache, 'brainstorming', 'SKILL.md'), '---\nname: brainstorming\n---\nbody\n')
|
||||
|
||||
const env = { ...process.env, HOME: home, DSH_HOME: dshHome }
|
||||
const env = { ...process.env, HOME: home, DSH_HOME: dshHome, DSH_SUPERPOWERS_REPO: 'file:///nonexistent' }
|
||||
delete env.DSH_SUPERPOWERS_SOURCE
|
||||
delete env.DSH_SUPERPOWERS_REF
|
||||
const run = () => spawnSync('bash', [script, '--skills', '--roles'], { env, encoding: 'utf8' })
|
||||
|
||||
const first = run()
|
||||
@ -90,9 +94,53 @@ function makeLegacyCache(home) {
|
||||
return join(root, 'skills')
|
||||
}
|
||||
|
||||
/** A local git repo carrying the reduced upstream checkout on tag v7.0.1. */
|
||||
function makePinnedRepo(work, marker = null, name = 'upstream-repo') {
|
||||
const repo = join(work, name)
|
||||
cpSync(fixture, repo, { recursive: true })
|
||||
if (marker) {
|
||||
const skill = join(repo, 'skills', 'using-superpowers', 'SKILL.md')
|
||||
write(skill, `${readFileSync(skill, 'utf8')}\n<!-- pin-marker: ${marker} -->\n`)
|
||||
}
|
||||
try {
|
||||
git(repo, ['init', '-b', 'main'])
|
||||
} catch {
|
||||
git(repo, ['init'])
|
||||
git(repo, ['checkout', '-b', 'main'])
|
||||
}
|
||||
git(repo, ['config', 'user.email', 'dsh-harness@example.invalid'])
|
||||
git(repo, ['config', 'user.name', 'DSH Harness Test'])
|
||||
git(repo, ['add', '.'])
|
||||
git(repo, ['commit', '-m', 'superpowers fixture'])
|
||||
git(repo, ['tag', 'v7.0.1'])
|
||||
return repo
|
||||
}
|
||||
|
||||
/** A full 15-skill checkout copy (explicit sources sync it in place). */
|
||||
function fixtureSuperpowers(work, marker = null, root = join(work, 'src', 'superpowers')) {
|
||||
cpSync(fixture, root, { recursive: true })
|
||||
if (marker) {
|
||||
const skill = join(root, 'skills', 'using-superpowers', 'SKILL.md')
|
||||
write(skill, `${readFileSync(skill, 'utf8')}\n<!-- install-source: ${marker} -->\n`)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
function walk(dir, acc = []) {
|
||||
if (!existsSync(dir)) return acc
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name)
|
||||
acc.push(full)
|
||||
if (entry.isDirectory()) walk(full, acc)
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
function installerEnv(home) {
|
||||
const env = { ...process.env, HOME: home, DSH_HOME: join(home, '.dsh') }
|
||||
delete env.DSH_SUPERPOWERS_SOURCE
|
||||
delete env.DSH_SUPERPOWERS_REPO
|
||||
delete env.DSH_SUPERPOWERS_REF
|
||||
return env
|
||||
}
|
||||
|
||||
@ -103,15 +151,292 @@ function runInstaller(home, args, extraEnv = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
test('--skills takes superpowers from --superpowers-from in a clean HOME', () => {
|
||||
test('--skills clones the pinned ref, syncs, verifies and installs adapted skills', () => {
|
||||
const work = tempDir()
|
||||
try {
|
||||
const home = join(work, 'home')
|
||||
const repo = makePinnedRepo(work)
|
||||
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repo}` })
|
||||
const out = run.stdout + run.stderr
|
||||
assert.equal(run.status, 0, out)
|
||||
assert.match(run.stdout, /synced \d+ files/)
|
||||
assert.match(run.stdout, /verified 15 skills/)
|
||||
assert.match(run.stdout, /copied 15 superpowers skills/)
|
||||
|
||||
const skillFile = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')
|
||||
const text = readFileSync(skillFile, 'utf8')
|
||||
assert.match(text, /references\/dsh-tools\.md/)
|
||||
assert.doesNotMatch(text, /superpowers:/)
|
||||
assert.ok(
|
||||
existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'references', 'dsh-tools.md')),
|
||||
'the DSH tool mapping must be installed',
|
||||
)
|
||||
assert.ok(existsSync(join(home, '.dsh', 'skills', 'role', 'SKILL.md')), 'local skills still installed')
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('--skills offline without a source fails with the clone hint', () => {
|
||||
const home = tempDir()
|
||||
try {
|
||||
const src = makeSuperpowers(join(home, 'src', 'superpowers'))
|
||||
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' })
|
||||
const out = run.stdout + run.stderr
|
||||
assert.equal(run.status, 1, out)
|
||||
assert.match(run.stderr, /git clone --depth 1 --branch v7\.0\.1 file:\/\/\/nonexistent/)
|
||||
assert.match(out, /FAIL: no superpowers source/)
|
||||
assert.equal(existsSync(join(home, '.dsh', 'skills', 'using-superpowers')), false)
|
||||
} finally {
|
||||
cleanup(home)
|
||||
}
|
||||
})
|
||||
|
||||
test('--skills re-run picks up a moved tag', () => {
|
||||
const work = tempDir()
|
||||
try {
|
||||
const home = join(work, 'home')
|
||||
const repo = makePinnedRepo(work)
|
||||
const env = { DSH_SUPERPOWERS_REPO: `file://${repo}` }
|
||||
const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')
|
||||
|
||||
const first = runInstaller(home, ['--skills'], env)
|
||||
assert.equal(first.status, 0, first.stdout + first.stderr)
|
||||
assert.doesNotMatch(readFileSync(installed, 'utf8'), /move-marker: second/)
|
||||
|
||||
const upstreamSkill = join(repo, 'skills', 'using-superpowers', 'SKILL.md')
|
||||
write(upstreamSkill, `${readFileSync(upstreamSkill, 'utf8')}\n<!-- move-marker: second -->\n`)
|
||||
git(repo, ['add', 'skills/using-superpowers/SKILL.md'])
|
||||
git(repo, ['commit', '--amend', '--no-edit'])
|
||||
git(repo, ['tag', '-f', 'v7.0.1'])
|
||||
|
||||
const second = runInstaller(home, ['--skills'], env)
|
||||
assert.equal(second.status, 0, second.stdout + second.stderr)
|
||||
assert.match(readFileSync(installed, 'utf8'), /move-marker: second/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('--skills updates an existing clone from DSH_SUPERPOWERS_REPO, not origin (T3.3)', () => {
|
||||
const work = tempDir()
|
||||
try {
|
||||
const home = join(work, 'home')
|
||||
const repoA = makePinnedRepo(work, 'from-a', 'upstream-a')
|
||||
const repoB = makePinnedRepo(work, 'from-b', 'upstream-b')
|
||||
const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')
|
||||
|
||||
const first = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repoA}` })
|
||||
assert.equal(first.status, 0, first.stdout + first.stderr)
|
||||
assert.match(readFileSync(installed, 'utf8'), /pin-marker: from-a/)
|
||||
|
||||
// Same ref, different repo: the update must come from the env repo, not
|
||||
// from the clone's origin.
|
||||
const second = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repoB}` })
|
||||
assert.equal(second.status, 0, second.stdout + second.stderr)
|
||||
assert.match(readFileSync(installed, 'utf8'), /pin-marker: from-b/)
|
||||
assert.doesNotMatch(readFileSync(installed, 'utf8'), /pin-marker: from-a/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('after a repo switch, an offline run still reuses the clone on the ref (T3.5)', () => {
|
||||
const work = tempDir()
|
||||
try {
|
||||
const home = join(work, 'home')
|
||||
const repoA = makePinnedRepo(work, 'from-a', 'upstream-a')
|
||||
const repoB = makePinnedRepo(work, 'from-b', 'upstream-b')
|
||||
const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')
|
||||
|
||||
const first = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repoA}` })
|
||||
assert.equal(first.status, 0, first.stdout + first.stderr)
|
||||
|
||||
// Online switch: the clone moves to repoB's v7.0.1 (T3.3 behavior).
|
||||
const second = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repoB}` })
|
||||
assert.equal(second.status, 0, second.stdout + second.stderr)
|
||||
assert.match(readFileSync(installed, 'utf8'), /pin-marker: from-b/)
|
||||
|
||||
// Offline afterwards: the local tag must have moved with the fetch, so
|
||||
// the on-ref reuse succeeds with a WARN instead of a hard FAIL.
|
||||
const third = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' })
|
||||
const out = third.stdout + third.stderr
|
||||
assert.equal(third.status, 0, out)
|
||||
assert.match(out, /offline; reusing the existing clone/)
|
||||
assert.match(readFileSync(installed, 'utf8'), /pin-marker: from-b/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('--skills removes a half-cloned dir after a failed fresh clone (RF5)', () => {
|
||||
const home = tempDir()
|
||||
try {
|
||||
const cloneDir = join(home, '.dsh', 'superpowers')
|
||||
mkdirSync(cloneDir, { recursive: true })
|
||||
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' })
|
||||
const out = run.stdout + run.stderr
|
||||
assert.equal(run.status, 1, out)
|
||||
assert.equal(existsSync(cloneDir), false, 'half-cloned dir must be removed')
|
||||
} finally {
|
||||
cleanup(home)
|
||||
}
|
||||
})
|
||||
|
||||
test('a foreign git repo in $DSH_HOME/superpowers is never touched (I-1)', () => {
|
||||
const work = tempDir()
|
||||
try {
|
||||
const home = join(work, 'home')
|
||||
const pinned = makePinnedRepo(work)
|
||||
const foreign = join(home, '.dsh', 'superpowers')
|
||||
mkdirSync(foreign, { recursive: true })
|
||||
git(foreign, ['init', '-b', 'main'])
|
||||
git(foreign, ['config', 'user.email', 'dsh-harness@example.invalid'])
|
||||
git(foreign, ['config', 'user.name', 'DSH Harness Test'])
|
||||
write(join(foreign, 'witness.txt'), 'do not touch\n')
|
||||
git(foreign, ['add', 'witness.txt'])
|
||||
git(foreign, ['commit', '-m', 'foreign repo'])
|
||||
const headBefore = git(foreign, ['rev-parse', 'HEAD'])
|
||||
|
||||
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${pinned}` })
|
||||
const out = run.stdout + run.stderr
|
||||
assert.equal(run.status, 1, out)
|
||||
assert.match(out, /not a superpowers clone/)
|
||||
assert.match(out, /move it away or use --superpowers-from/)
|
||||
assert.equal(readFileSync(join(foreign, 'witness.txt'), 'utf8'), 'do not touch\n')
|
||||
assert.equal(git(foreign, ['rev-parse', 'HEAD']), headBefore, 'HEAD must not move')
|
||||
assert.equal(existsSync(join(foreign, 'skills')), false, 'no skills tree may appear')
|
||||
|
||||
// --check agrees: the foreign repo is not reported as a source.
|
||||
const check = runInstaller(home, ['--check'], { DSH_SUPERPOWERS_REPO: `file://${pinned}` })
|
||||
const checkOut = check.stdout + check.stderr
|
||||
assert.equal(check.status, 0, checkOut)
|
||||
assert.doesNotMatch(checkOut, /ok: superpowers source/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('--skills reuses an on-ref clone when offline (WARN)', () => {
|
||||
const work = tempDir()
|
||||
try {
|
||||
const home = join(work, 'home')
|
||||
const repo = makePinnedRepo(work)
|
||||
|
||||
const first = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repo}` })
|
||||
assert.equal(first.status, 0, first.stdout + first.stderr)
|
||||
|
||||
// Go offline for real: the clone's origin must become unreachable, not
|
||||
// just the DSH_SUPERPOWERS_REPO env (an existing clone fetches origin).
|
||||
rmSync(repo, { recursive: true, force: true })
|
||||
|
||||
const second = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' })
|
||||
const out = second.stdout + second.stderr
|
||||
assert.equal(second.status, 0, out)
|
||||
assert.match(out, /offline; reusing the existing clone/)
|
||||
assert.match(second.stdout, /copied 15 superpowers skills/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('--skills leaves no staging garbage and records the ref in .sync-meta.json', () => {
|
||||
const work = tempDir()
|
||||
try {
|
||||
const home = join(work, 'home')
|
||||
const repo = makePinnedRepo(work)
|
||||
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repo}` })
|
||||
assert.equal(run.status, 0, run.stdout + run.stderr)
|
||||
|
||||
const cloneDir = join(home, '.dsh', 'superpowers')
|
||||
const garbage = walk(cloneDir).filter((p) => p.includes('.tmp-'))
|
||||
assert.deepEqual(garbage, [], `staging garbage left behind: ${garbage.join(', ')}`)
|
||||
|
||||
const meta = JSON.parse(readFileSync(join(cloneDir, 'skills.synced', '.sync-meta.json'), 'utf8'))
|
||||
assert.equal(meta.ref, 'v7.0.1')
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('explicit syncs stage outside the pinned clone; a later default run clones fresh (T3.1)', () => {
|
||||
const work = tempDir()
|
||||
try {
|
||||
const home = join(work, 'home')
|
||||
const repo = makePinnedRepo(work)
|
||||
const src = fixtureSuperpowers(home, null, join(home, 'src', 'superpowers'))
|
||||
|
||||
const first = runInstaller(home, ['--skills', '--superpowers-from', src])
|
||||
assert.equal(first.status, 0, first.stdout + first.stderr)
|
||||
assert.ok(
|
||||
existsSync(join(home, '.dsh', '.skills.synced', 'using-superpowers', 'SKILL.md')),
|
||||
'explicit staging lives outside $DSH_HOME/superpowers',
|
||||
)
|
||||
assert.equal(
|
||||
existsSync(join(home, '.dsh', 'superpowers')),
|
||||
false,
|
||||
'an explicit run must not create the pinned-clone dir',
|
||||
)
|
||||
|
||||
const second = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: `file://${repo}` })
|
||||
const out = second.stdout + second.stderr
|
||||
assert.equal(second.status, 0, out)
|
||||
assert.doesNotMatch(out, /no usable \.git/)
|
||||
assert.match(second.stdout, /copied 15 superpowers skills/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('a bare skills dir without a checkout root is invalid for --skills and --check alike (T3.2)', () => {
|
||||
const home = tempDir()
|
||||
try {
|
||||
const bare = join(home, 'my-skills')
|
||||
write(join(bare, 'using-superpowers', 'SKILL.md'), '---\nname: using-superpowers\n---\nbody\n')
|
||||
|
||||
const skills = runInstaller(home, ['--skills', '--superpowers-from', bare])
|
||||
const check = runInstaller(home, ['--check'], { DSH_SUPERPOWERS_SOURCE: bare })
|
||||
|
||||
for (const run of [skills, check]) {
|
||||
const out = run.stdout + run.stderr
|
||||
assert.equal(run.status, 1, out)
|
||||
assert.match(out, /checkout root or its skills\/ directory/)
|
||||
}
|
||||
assert.equal(existsSync(join(home, '.dsh', 'skills', 'using-superpowers')), false)
|
||||
} finally {
|
||||
cleanup(home)
|
||||
}
|
||||
})
|
||||
|
||||
test('--check is offline and reports the configured ref', () => {
|
||||
const home = tempDir()
|
||||
try {
|
||||
const run = runInstaller(home, ['--check'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' })
|
||||
const out = run.stdout + run.stderr
|
||||
assert.equal(run.status, 0, out)
|
||||
assert.match(run.stdout, /ref v7\.0\.1/)
|
||||
assert.doesNotMatch(out, /fatal:/, '--check must not attempt to clone')
|
||||
} finally {
|
||||
cleanup(home)
|
||||
}
|
||||
})
|
||||
|
||||
test('.claude/code-paths lists dsh/', () => {
|
||||
const codePaths = resolve(testDir, '..', '..', '.claude', 'code-paths')
|
||||
const text = readFileSync(codePaths, 'utf8')
|
||||
assert.ok(text.split('\n').includes('dsh/'), 'code-paths must gate dsh/ under require-plan (D-5)')
|
||||
})
|
||||
|
||||
test('--skills syncs an explicit --superpowers-from source in a clean HOME', () => {
|
||||
const home = tempDir()
|
||||
try {
|
||||
const src = fixtureSuperpowers(home, null, join(home, 'src', 'superpowers'))
|
||||
const run = runInstaller(home, ['--skills', '--superpowers-from', src])
|
||||
const out = run.stdout + run.stderr
|
||||
assert.equal(run.status, 0, out)
|
||||
assert.match(run.stdout, /copied 2 superpowers skills/)
|
||||
assert.ok(run.stdout.includes(`source: ${join(src, 'skills')}`), out)
|
||||
assert.match(run.stdout, /synced \d+ files/)
|
||||
assert.match(run.stdout, /verified 15 skills/)
|
||||
assert.match(run.stdout, /copied 15 superpowers skills/)
|
||||
assert.ok(run.stdout.includes(`source: ${src}`), out)
|
||||
assert.ok(existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')))
|
||||
assert.ok(existsSync(join(home, '.dsh', 'skills', 'role', 'SKILL.md')))
|
||||
assert.ok(existsSync(join(home, '.dsh', 'skills', 'sdd', 'SKILL.md')))
|
||||
@ -125,10 +450,10 @@ test('--skills takes superpowers from --superpowers-from in a clean HOME', () =>
|
||||
test('--superpowers-from accepts the skills directory itself', () => {
|
||||
const home = tempDir()
|
||||
try {
|
||||
const src = makeSuperpowers(join(home, 'src', 'superpowers'))
|
||||
const src = fixtureSuperpowers(home, null, join(home, 'src', 'superpowers'))
|
||||
const run = runInstaller(home, ['--skills', '--superpowers-from', join(src, 'skills')])
|
||||
assert.equal(run.status, 0, run.stdout + run.stderr)
|
||||
assert.match(run.stdout, /copied 2 superpowers skills/)
|
||||
assert.match(run.stdout, /copied 15 superpowers skills/)
|
||||
} finally {
|
||||
cleanup(home)
|
||||
}
|
||||
@ -137,7 +462,7 @@ test('--superpowers-from accepts the skills directory itself', () => {
|
||||
test('--superpowers-from accepts a path with spaces', () => {
|
||||
const home = tempDir()
|
||||
try {
|
||||
const src = makeSuperpowers(join(home, 'super powers'))
|
||||
const src = fixtureSuperpowers(home, null, join(home, 'super powers'))
|
||||
const run = runInstaller(home, ['--skills', '--superpowers-from', src])
|
||||
assert.equal(run.status, 0, run.stdout + run.stderr)
|
||||
assert.ok(existsSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')))
|
||||
@ -149,15 +474,15 @@ test('--superpowers-from accepts a path with spaces', () => {
|
||||
test('--superpowers-from wins over DSH_SUPERPOWERS_SOURCE', () => {
|
||||
const home = tempDir()
|
||||
try {
|
||||
const fromFlag = makeSuperpowers(join(home, 'flag-source'), 'from-flag')
|
||||
const fromEnv = makeSuperpowers(join(home, 'env-source'), 'from-env')
|
||||
const fromFlag = fixtureSuperpowers(home, 'from-flag', join(home, 'flag-source'))
|
||||
const fromEnv = fixtureSuperpowers(home, 'from-env', join(home, 'env-source'))
|
||||
const run = runInstaller(home, ['--skills', '--superpowers-from', fromFlag], {
|
||||
DSH_SUPERPOWERS_SOURCE: fromEnv,
|
||||
})
|
||||
assert.equal(run.status, 0, run.stdout + run.stderr)
|
||||
assert.match(
|
||||
readFileSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), 'utf8'),
|
||||
/from-flag/,
|
||||
/install-source: from-flag/,
|
||||
)
|
||||
} finally {
|
||||
cleanup(home)
|
||||
@ -167,12 +492,12 @@ test('--superpowers-from wins over DSH_SUPERPOWERS_SOURCE', () => {
|
||||
test('DSH_SUPERPOWERS_SOURCE is used when the flag is absent', () => {
|
||||
const home = tempDir()
|
||||
try {
|
||||
const fromEnv = makeSuperpowers(join(home, 'env-source'), 'from-env')
|
||||
const fromEnv = fixtureSuperpowers(home, 'from-env', join(home, 'env-source'))
|
||||
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_SOURCE: fromEnv })
|
||||
assert.equal(run.status, 0, run.stdout + run.stderr)
|
||||
assert.match(
|
||||
readFileSync(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md'), 'utf8'),
|
||||
/from-env/,
|
||||
/install-source: from-env/,
|
||||
)
|
||||
} finally {
|
||||
cleanup(home)
|
||||
@ -182,11 +507,11 @@ test('DSH_SUPERPOWERS_SOURCE is used when the flag is absent', () => {
|
||||
test('--skills fails when there is no source and nothing installed', () => {
|
||||
const home = tempDir()
|
||||
try {
|
||||
const run = runInstaller(home, ['--skills'])
|
||||
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' })
|
||||
const out = run.stdout + run.stderr
|
||||
assert.equal(run.status, 1, out)
|
||||
assert.match(out, /FAIL: no superpowers source/)
|
||||
assert.match(out, /git clone --depth 1 https:\/\/github\.com\/obra\/superpowers/)
|
||||
assert.match(out, /git clone --depth 1 --branch v7\.0\.1 file:\/\/\/nonexistent/)
|
||||
assert.ok(existsSync(join(home, '.dsh', 'skills', 'role', 'SKILL.md')), 'local skills still installed')
|
||||
assert.ok(existsSync(join(home, '.dsh', 'skills', 'sdd', 'SKILL.md')), 'local skills still installed')
|
||||
assert.equal(existsSync(join(home, '.dsh', 'skills', 'using-superpowers')), false)
|
||||
@ -195,15 +520,15 @@ test('--skills fails when there is no source and nothing installed', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('--skills keeps installed copies when the source is gone', () => {
|
||||
test('--skills offline without a source fails but keeps the installed copies', () => {
|
||||
const home = tempDir()
|
||||
try {
|
||||
const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')
|
||||
write(installed, '---\nname: using-superpowers\n---\ninstalled\n')
|
||||
const run = runInstaller(home, ['--skills'])
|
||||
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' })
|
||||
const out = run.stdout + run.stderr
|
||||
assert.equal(run.status, 0, out)
|
||||
assert.match(out, /WARN: no superpowers source/)
|
||||
assert.equal(run.status, 1, out)
|
||||
assert.match(out, /FAIL: no superpowers source/)
|
||||
assert.match(readFileSync(installed, 'utf8'), /installed/)
|
||||
} finally {
|
||||
cleanup(home)
|
||||
@ -213,7 +538,10 @@ test('--skills keeps installed copies when the source is gone', () => {
|
||||
test('empty DSH_SUPERPOWERS_SOURCE counts as unset', () => {
|
||||
const home = tempDir()
|
||||
try {
|
||||
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_SOURCE: '' })
|
||||
const run = runInstaller(home, ['--skills'], {
|
||||
DSH_SUPERPOWERS_SOURCE: '',
|
||||
DSH_SUPERPOWERS_REPO: 'file:///nonexistent',
|
||||
})
|
||||
const out = run.stdout + run.stderr
|
||||
assert.equal(run.status, 1, out)
|
||||
assert.match(out, /FAIL: no superpowers source/)
|
||||
@ -242,7 +570,7 @@ test('the legacy plugin cache still works and warns', () => {
|
||||
const home = tempDir()
|
||||
try {
|
||||
makeLegacyCache(home)
|
||||
const run = runInstaller(home, ['--skills'])
|
||||
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' })
|
||||
const out = run.stdout + run.stderr
|
||||
assert.equal(run.status, 0, out)
|
||||
assert.match(out, /WARN: legacy superpowers source/)
|
||||
@ -318,7 +646,7 @@ test('a legacy cache without using-superpowers is not a source', () => {
|
||||
'skills',
|
||||
)
|
||||
write(join(cache, 'brainstorming', 'SKILL.md'), '---\nname: brainstorming\n---\nbody\n')
|
||||
const run = runInstaller(home, ['--skills'])
|
||||
const run = runInstaller(home, ['--skills'], { DSH_SUPERPOWERS_REPO: 'file:///nonexistent' })
|
||||
const out = run.stdout + run.stderr
|
||||
assert.equal(run.status, 1, out)
|
||||
assert.match(out, /FAIL: no superpowers source/)
|
||||
|
||||
@ -55,10 +55,13 @@ test('runtime files have no external @deepseek-ai imports', () => {
|
||||
for (const file of [
|
||||
'index.js',
|
||||
'command-role.js',
|
||||
'command-check.js',
|
||||
'lib/message.js',
|
||||
'lib/guards.js',
|
||||
'lib/bootstrap.js',
|
||||
'lib/role.js',
|
||||
'lib/legacy.js',
|
||||
'lib/selfcheck.js',
|
||||
]) {
|
||||
const source = readFileSync(join(bundleDir, file), 'utf8')
|
||||
assert.doesNotMatch(source, /from ['"]@deepseek-ai\//, `${file} must not import DSH internals`)
|
||||
|
||||
@ -23,7 +23,7 @@ ensure_bundle_hooks() {
|
||||
|
||||
ensure_bundle_hooks
|
||||
|
||||
for f in dsh/bundle/index.js dsh/bundle/command-role.js dsh/bundle/lib/*.js dsh/tests/*.mjs; do
|
||||
for f in dsh/bundle/index.js dsh/bundle/command-role.js dsh/bundle/command-check.js dsh/bundle/lib/*.js dsh/tests/*.mjs; do
|
||||
node --check "$f"
|
||||
done
|
||||
|
||||
|
||||
169
dsh/tests/selfcheck.test.mjs
Normal file
169
dsh/tests/selfcheck.test.mjs
Normal file
@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { apply } from '../bundle/index.js'
|
||||
import { collectSelfCheck } from '../bundle/lib/selfcheck.js'
|
||||
import { registerCheckCommand } from '../bundle/command-check.js'
|
||||
|
||||
const FEATURES = ['guards', 'bootstrap', 'commands', 'workingDirectory']
|
||||
|
||||
/** A ctx-like with every service the bundle depends on. */
|
||||
function fullCtx() {
|
||||
return {
|
||||
tools: { guard() {} },
|
||||
on() {},
|
||||
inject() {},
|
||||
workingDirectory: { get() {} },
|
||||
}
|
||||
}
|
||||
|
||||
test('collectSelfCheck ok on full ctx-like', () => {
|
||||
const report = collectSelfCheck(fullCtx())
|
||||
assert.deepEqual(
|
||||
report.map((entry) => entry.feature),
|
||||
FEATURES,
|
||||
)
|
||||
for (const entry of report) {
|
||||
assert.equal(entry.ok, true, `${entry.feature} ok`)
|
||||
assert.equal(typeof entry.detail, 'string', `${entry.feature} detail is a string`)
|
||||
assert.ok(entry.detail.length > 0, `${entry.feature} detail is non-empty`)
|
||||
}
|
||||
})
|
||||
|
||||
test('collectSelfCheck flags missing tools.guard', () => {
|
||||
const ctx = fullCtx()
|
||||
ctx.tools = {}
|
||||
const report = collectSelfCheck(ctx)
|
||||
|
||||
const guards = report.find((entry) => entry.feature === 'guards')
|
||||
assert.equal(guards.ok, false)
|
||||
assert.match(guards.detail, /tools\.guard/)
|
||||
|
||||
for (const entry of report.filter((entry) => entry.feature !== 'guards')) {
|
||||
assert.equal(entry.ok, true, `${entry.feature} stays ok`)
|
||||
}
|
||||
})
|
||||
|
||||
test('collectSelfCheck flags missing on()', () => {
|
||||
const ctx = fullCtx()
|
||||
ctx.on = undefined
|
||||
const report = collectSelfCheck(ctx)
|
||||
|
||||
const bootstrap = report.find((entry) => entry.feature === 'bootstrap')
|
||||
assert.equal(bootstrap.ok, false)
|
||||
|
||||
for (const entry of report.filter((entry) => entry.feature !== 'bootstrap')) {
|
||||
assert.equal(entry.ok, true, `${entry.feature} stays ok`)
|
||||
}
|
||||
})
|
||||
|
||||
test('collectSelfCheck skips features disabled by config', () => {
|
||||
const report = collectSelfCheck(fullCtx(), {
|
||||
guards: false,
|
||||
bootstrap: false,
|
||||
roleCommand: false,
|
||||
checkCommand: false,
|
||||
})
|
||||
assert.deepEqual(
|
||||
report.map((entry) => entry.feature),
|
||||
['workingDirectory'],
|
||||
)
|
||||
})
|
||||
|
||||
test('collectSelfCheck does not flag a disabled feature that is also missing', () => {
|
||||
const ctx = fullCtx()
|
||||
ctx.tools = {}
|
||||
ctx.on = undefined
|
||||
const report = collectSelfCheck(ctx, { guards: false, bootstrap: false })
|
||||
assert.deepEqual(
|
||||
report.map((entry) => entry.feature),
|
||||
['commands', 'workingDirectory'],
|
||||
)
|
||||
assert.ok(report.every((entry) => entry.ok), 'nothing not-ok in the report')
|
||||
|
||||
const noInject = fullCtx()
|
||||
noInject.inject = undefined
|
||||
const commandsOff = collectSelfCheck(noInject, { roleCommand: false, checkCommand: false })
|
||||
assert.ok(!commandsOff.some((entry) => entry.feature === 'commands'), 'commands skipped')
|
||||
assert.ok(commandsOff.every((entry) => entry.ok), 'missing inject not flagged')
|
||||
|
||||
const roleOnly = collectSelfCheck(noInject, { roleCommand: false })
|
||||
assert.ok(roleOnly.some((entry) => entry.feature === 'commands'), 'commands still checked')
|
||||
assert.equal(
|
||||
roleOnly.find((entry) => entry.feature === 'commands').ok,
|
||||
false,
|
||||
'checkCommand alone still needs ctx.inject',
|
||||
)
|
||||
})
|
||||
|
||||
test('registerCheckCommand builds report text', async () => {
|
||||
let captured
|
||||
const commands = {
|
||||
register(def) {
|
||||
captured = def
|
||||
},
|
||||
}
|
||||
|
||||
registerCheckCommand({ commands, collect: () => collectSelfCheck(fullCtx()) })
|
||||
assert.equal(captured.name, 'dsh-harness-check')
|
||||
|
||||
const okResult = await captured.handler({})
|
||||
assert.match(okResult.text, /guards: ok/)
|
||||
assert.match(okResult.text, /all ok/)
|
||||
|
||||
const brokenCtx = fullCtx()
|
||||
brokenCtx.tools = {}
|
||||
registerCheckCommand({ commands, collect: () => collectSelfCheck(brokenCtx) })
|
||||
|
||||
const badResult = await captured.handler({})
|
||||
assert.match(badResult.text, /guards: not-ok/)
|
||||
assert.match(badResult.text, /problem/)
|
||||
})
|
||||
|
||||
/** Logger fake that interpolates `%s` and records error lines. */
|
||||
function recordingLogger(errors) {
|
||||
return {
|
||||
info() {},
|
||||
warn() {},
|
||||
error(fmt, ...args) {
|
||||
const line = fmt.replace(/%s/g, () => String(args.shift() ?? ''))
|
||||
errors.push(line)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('apply does not throw when ctx.on and ctx.inject are missing', () => {
|
||||
const calls = { guard: 0 }
|
||||
const errors = []
|
||||
const ctx = {
|
||||
tools: { guard() { calls.guard += 1 } },
|
||||
workingDirectory: { get() {} },
|
||||
logger: recordingLogger(errors),
|
||||
}
|
||||
|
||||
assert.doesNotThrow(() => apply(ctx))
|
||||
assert.equal(calls.guard, 1, 'guards feature stays wired')
|
||||
assert.ok(
|
||||
errors.some((line) => line.includes('self-check: bootstrap not-ok')),
|
||||
'bootstrap not-ok is logged',
|
||||
)
|
||||
assert.ok(
|
||||
errors.some((line) => line.includes('self-check: commands not-ok')),
|
||||
'commands not-ok is logged',
|
||||
)
|
||||
})
|
||||
|
||||
test('apply wires guards, bootstrap, and commands with a full ctx', () => {
|
||||
const calls = { guard: 0, on: 0, inject: 0 }
|
||||
const ctx = {
|
||||
tools: { guard() { calls.guard += 1 } },
|
||||
on() { calls.on += 1 },
|
||||
inject() { calls.inject += 1 },
|
||||
workingDirectory: { get() {} },
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
}
|
||||
|
||||
assert.doesNotThrow(() => apply(ctx))
|
||||
assert.equal(calls.guard, 1, 'guards wired')
|
||||
assert.equal(calls.on, 1, 'bootstrap wired')
|
||||
assert.equal(calls.inject, 1, 'commands wired')
|
||||
})
|
||||
255
dsh/tests/sync.test.mjs
Normal file
255
dsh/tests/sync.test.mjs
Normal file
@ -0,0 +1,255 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
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'
|
||||
import { cleanup, tempDir, write } from './helpers.mjs'
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = resolve(testDir, '..', '..')
|
||||
const script = resolve(repoRoot, 'dsh/sync/sync-from-upstream.mjs')
|
||||
const fixture = resolve(testDir, 'fixtures', 'superpowers-min')
|
||||
const bundleDshTools = resolve(repoRoot, 'dsh/bundle/dsh-tools.md')
|
||||
|
||||
/** A private copy of the reduced upstream checkout: tests mutate it. */
|
||||
function fixtureCopy(work) {
|
||||
const upstream = join(work, 'upstream')
|
||||
cpSync(fixture, upstream, { recursive: true })
|
||||
return upstream
|
||||
}
|
||||
|
||||
function runSync(args) {
|
||||
return spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' })
|
||||
}
|
||||
|
||||
test('sync happy path: transforms, namespace strip, override, meta', () => {
|
||||
const work = tempDir('dsh-sync-happy-')
|
||||
try {
|
||||
const upstream = fixtureCopy(work)
|
||||
const out = join(work, 'out')
|
||||
const result = runSync([upstream, '--out', out, '--ref', 'v7.0.1'])
|
||||
|
||||
assert.equal(result.status, 0, `stderr: ${result.stderr}`)
|
||||
assert.match(result.stdout, /synced \d+ files/)
|
||||
assert.match(result.stdout, /transforms applied: 16/)
|
||||
|
||||
const skillDirs = readdirSync(out).filter((name) => name !== '.sync-meta.json')
|
||||
assert.equal(skillDirs.length, 15, `expected all 15 upstream skills, got ${skillDirs.join(', ')}`)
|
||||
|
||||
const using = readFileSync(join(out, 'using-superpowers', 'SKILL.md'), 'utf8')
|
||||
assert.match(using, /references\/dsh-tools\.md/)
|
||||
assert.doesNotMatch(using, /superpowers:/)
|
||||
assert.doesNotMatch(using, /<SUBAGENT-STOP>/)
|
||||
|
||||
const override = readFileSync(join(out, 'using-superpowers', 'references', 'dsh-tools.md'), 'utf8')
|
||||
assert.equal(override, readFileSync(bundleDshTools, 'utf8'))
|
||||
|
||||
const brainstorming = readFileSync(join(out, 'brainstorming', 'SKILL.md'), 'utf8')
|
||||
assert.doesNotMatch(brainstorming, /superpowers:/)
|
||||
assert.match(brainstorming, /writing-plans/)
|
||||
|
||||
const meta = JSON.parse(readFileSync(join(out, '.sync-meta.json'), 'utf8'))
|
||||
assert.deepEqual(meta, { ref: 'v7.0.1', version: '7.0.1' })
|
||||
|
||||
assert.equal(existsSync(join(out, 'using-superpowers', 'references', 'claude-code-tools.md')), false)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('sync fails loud on a broken transform anchor and leaves --out untouched', () => {
|
||||
const work = tempDir('dsh-sync-anchor-')
|
||||
try {
|
||||
const upstream = fixtureCopy(work)
|
||||
const usingPath = join(upstream, 'skills', 'using-superpowers', 'SKILL.md')
|
||||
const anchor = 'If your harness appears here, read its reference file for special instructions:'
|
||||
const original = readFileSync(usingPath, 'utf8')
|
||||
assert.ok(original.includes(anchor), 'fixture must carry the upstream anchor verbatim')
|
||||
write(usingPath, original.replace(anchor, 'If your harness appears here, read the reference file:'))
|
||||
|
||||
const out = join(work, 'out')
|
||||
write(join(out, 'keep.txt'), 'sentinel\n')
|
||||
|
||||
const result = runSync([upstream, '--out', out, '--ref', 'v7.0.1'])
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /DSH platform reference/)
|
||||
assert.equal(readFileSync(join(out, 'keep.txt'), 'utf8'), 'sentinel\n')
|
||||
assert.deepEqual(readdirSync(out), ['keep.txt'], 'failed sync must not swap a partial tree in')
|
||||
assert.deepEqual(
|
||||
readdirSync(work).filter((name) => name.startsWith('out.tmp-')),
|
||||
[],
|
||||
'failed sync must remove its staging tree',
|
||||
)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('sync fails loud on a broken anchor of another transform', () => {
|
||||
const work = tempDir('dsh-sync-anchor-executing-plans-')
|
||||
try {
|
||||
const upstream = fixtureCopy(work)
|
||||
const skillPath = join(upstream, 'skills', 'executing-plans', 'SKILL.md')
|
||||
const anchor = 'Run `../subagent-driven-development/scripts/review-package PLAN_FILE MERGE_BASE HEAD`'
|
||||
const original = readFileSync(skillPath, 'utf8')
|
||||
assert.ok(original.includes(anchor), 'fixture must carry the review-package anchor verbatim')
|
||||
write(skillPath, original.replace(anchor, 'Run `../subagent-driven-development/scripts/review-package PLAN_FILE HEAD`'))
|
||||
|
||||
const result = runSync([upstream, '--out', join(work, 'out'), '--ref', 'v7.0.1'])
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /invoke review-package through bash/)
|
||||
assert.equal(existsSync(join(work, 'out')), false)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('sync refuses --out that collides with the upstream checkout', () => {
|
||||
const work = tempDir('dsh-sync-collision-')
|
||||
try {
|
||||
const upstream = fixtureCopy(work)
|
||||
write(join(upstream, 'sentinel.txt'), 'source stays\n')
|
||||
|
||||
// `--out` equal to the checkout, an ancestor of it, and the skills/ tree
|
||||
// itself — each would delete the source the sync is reading.
|
||||
for (const out of [upstream, work, join(upstream, 'skills')]) {
|
||||
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, '.claude-plugin', 'plugin.json')), true)
|
||||
assert.equal(readdirSync(join(upstream, 'skills')).length, 15)
|
||||
assert.deepEqual(
|
||||
readdirSync(work).filter((name) => name.includes('.tmp-')),
|
||||
[],
|
||||
'a refused sync must not create its staging tree',
|
||||
)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
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 {
|
||||
const upstream = fixtureCopy(work)
|
||||
rmSync(join(upstream, 'skills', 'brainstorming', 'visual-companion.md'))
|
||||
|
||||
const result = runSync([upstream, '--out', join(work, 'out'), '--ref', 'v7.0.1'])
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /never matched/)
|
||||
assert.match(result.stderr, /DSH platform note for the visual companion/)
|
||||
assert.equal(existsSync(join(work, 'out')), false)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('sync fails when an anchor matches twice', () => {
|
||||
const work = tempDir('dsh-sync-duplicate-anchor-')
|
||||
try {
|
||||
const upstream = fixtureCopy(work)
|
||||
const skillPath = join(upstream, 'skills', 'executing-plans', 'SKILL.md')
|
||||
const anchor = "Run this skill's `scripts/task-start PLAN_FILE N`."
|
||||
const original = readFileSync(skillPath, 'utf8')
|
||||
assert.ok(original.includes(anchor), 'fixture must carry the task-start anchor verbatim')
|
||||
write(skillPath, original.replace(anchor, `${anchor}\n\n${anchor}`))
|
||||
|
||||
const result = runSync([upstream, '--out', join(work, 'out'), '--ref', 'v7.0.1'])
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /matched 2 times/)
|
||||
assert.match(result.stderr, /invoke task-start through bash/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('sync warns, but succeeds, when the optional transform anchor is gone', () => {
|
||||
const work = tempDir('dsh-sync-optional-anchor-')
|
||||
try {
|
||||
const upstream = fixtureCopy(work)
|
||||
const skillPath = join(upstream, 'skills', 'subagent-driven-development', 'SKILL.md')
|
||||
const anchor = 'You: "User level (~/.config/superpowers/hooks/)"'
|
||||
const original = readFileSync(skillPath, 'utf8')
|
||||
assert.ok(original.includes(anchor), 'fixture must carry the optional anchor verbatim')
|
||||
write(skillPath, original.replace(anchor, 'You: "Install the hook at the project level"'))
|
||||
|
||||
const out = join(work, 'out')
|
||||
const result = runSync([upstream, '--out', out, '--ref', 'v7.0.1'])
|
||||
|
||||
assert.equal(result.status, 0, `stderr: ${result.stderr}`)
|
||||
assert.match(result.stderr, /\[warn\] optional transform/)
|
||||
assert.match(result.stdout, /transforms applied: 15/)
|
||||
assert.equal(existsSync(join(out, '.sync-meta.json')), true)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('sync fails on a changed skill set', () => {
|
||||
const work = tempDir('dsh-sync-skill-set-')
|
||||
try {
|
||||
const upstream = fixtureCopy(work)
|
||||
rmSync(join(upstream, 'skills', 'brainstorming'), { recursive: true, force: true })
|
||||
|
||||
const result = runSync([upstream, '--out', join(work, 'out')])
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /removed upstream:\s*brainstorming/)
|
||||
assert.equal(existsSync(join(work, 'out')), false)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('sync fails when the checkout has no skills/ directory', () => {
|
||||
const work = tempDir('dsh-sync-no-skills-')
|
||||
try {
|
||||
const upstream = fixtureCopy(work)
|
||||
rmSync(join(upstream, 'skills'), { recursive: true, force: true })
|
||||
|
||||
const result = runSync([upstream, '--out', join(work, 'out')])
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /no skills\/ directory/)
|
||||
assert.equal(existsSync(join(work, 'out')), false)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
284
dsh/tests/verify.test.mjs
Normal file
284
dsh/tests/verify.test.mjs
Normal file
@ -0,0 +1,284 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { cpSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { cleanup, tempDir, write } from './helpers.mjs'
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = resolve(testDir, '..', '..')
|
||||
const syncScript = resolve(repoRoot, 'dsh/sync/sync-from-upstream.mjs')
|
||||
const verifyScript = resolve(repoRoot, 'dsh/sync/verify-skills.mjs')
|
||||
const fixture = resolve(testDir, 'fixtures', 'superpowers-min')
|
||||
|
||||
function run(script, args) {
|
||||
return spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' })
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree every test starts from: Task 1's sync applied to the reduced
|
||||
* upstream checkout — 15 skill directories plus the DSH `dsh-tools.md`
|
||||
* override. Verify only ever sees a synced tree, never raw upstream text.
|
||||
*/
|
||||
function syncedTree(work) {
|
||||
const out = join(work, 'synced')
|
||||
const result = run(syncScript, [fixture, '--out', out, '--ref', 'v7.0.1'])
|
||||
assert.equal(result.status, 0, `sync fixture failed: ${result.stderr}`)
|
||||
return out
|
||||
}
|
||||
|
||||
/** A private copy of a synced tree: the failing tests mutate it. */
|
||||
function syncedCopy(work, dir) {
|
||||
const copy = join(work, 'copy')
|
||||
cpSync(dir, copy, { recursive: true })
|
||||
return copy
|
||||
}
|
||||
|
||||
function verify(dir) {
|
||||
return run(verifyScript, [dir])
|
||||
}
|
||||
|
||||
test('verify ok on synced tree', () => {
|
||||
const work = tempDir('dsh-verify-ok-')
|
||||
try {
|
||||
const result = verify(syncedTree(work))
|
||||
|
||||
assert.equal(result.status, 0, `stderr: ${result.stderr}`)
|
||||
assert.match(result.stdout, /^ok: using-superpowers$/m)
|
||||
assert.match(result.stdout, /verified 15 skills/)
|
||||
assert.equal((result.stdout.match(/^ok: /gm) ?? []).length, 15, 'one ok line per skill directory')
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('verify fails on missing description', () => {
|
||||
const work = tempDir('dsh-verify-no-description-')
|
||||
try {
|
||||
const dir = syncedCopy(work, syncedTree(work))
|
||||
const file = join(dir, 'brainstorming', 'SKILL.md')
|
||||
write(file, readFileSync(file, 'utf8').replace(/^description:.*\n/m, ''))
|
||||
|
||||
const result = verify(dir)
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /brainstorming\/SKILL\.md/)
|
||||
assert.match(result.stderr, /description/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('verify fails on leftover superpowers: prefix in a body', () => {
|
||||
const work = tempDir('dsh-verify-prefix-')
|
||||
try {
|
||||
const dir = syncedCopy(work, syncedTree(work))
|
||||
const file = join(dir, 'systematic-debugging', 'SKILL.md')
|
||||
write(file, `${readFileSync(file, 'utf8')}\nSee superpowers:brainstorming for the upstream wording.\n`)
|
||||
|
||||
const result = verify(dir)
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /systematic-debugging\/SKILL\.md/)
|
||||
assert.match(result.stderr, /superpowers:/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('verify fails on dangling relative reference', () => {
|
||||
const work = tempDir('dsh-verify-dangling-')
|
||||
try {
|
||||
const dir = syncedCopy(work, syncedTree(work))
|
||||
const file = join(dir, 'writing-plans', 'SKILL.md')
|
||||
write(file, `${readFileSync(file, 'utf8')}\nSee [the missing file](../brainstorming/nope.md).\n`)
|
||||
|
||||
const result = verify(dir)
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /writing-plans\/SKILL\.md/)
|
||||
assert.match(result.stderr, /\.\.\/brainstorming\/nope\.md/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('verify ignores illustrative example script paths', () => {
|
||||
const work = tempDir('dsh-verify-illustrative-')
|
||||
try {
|
||||
const dir = syncedCopy(work, syncedTree(work))
|
||||
const file = join(dir, 'writing-skills', 'SKILL.md')
|
||||
write(
|
||||
file,
|
||||
`${readFileSync(file, 'utf8')}\nInvoke bundled scripts in prose: \`bash scripts/tool.sh\`, \`node scripts/tool.js\`.\n`,
|
||||
)
|
||||
|
||||
const result = verify(dir)
|
||||
|
||||
assert.equal(result.status, 0, `stderr: ${result.stderr}`)
|
||||
assert.match(result.stdout, /verified 15 skills/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('verify ok when superpowers: survives in a references file (T1.5)', () => {
|
||||
const work = tempDir('dsh-verify-references-')
|
||||
try {
|
||||
const dir = syncedTree(work)
|
||||
const reference = readFileSync(join(dir, 'using-superpowers', 'references', 'dsh-tools.md'), 'utf8')
|
||||
assert.match(reference, /superpowers:/, 'the DSH tool mapping quotes the upstream namespace on purpose')
|
||||
|
||||
const result = verify(dir)
|
||||
|
||||
assert.equal(result.status, 0, `stderr: ${result.stderr}`)
|
||||
assert.match(result.stdout, /verified 15 skills/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('verify fails when a sibling skill script is missing', () => {
|
||||
const work = tempDir('dsh-verify-missing-script-')
|
||||
try {
|
||||
const dir = syncedCopy(work, syncedTree(work))
|
||||
rmSync(join(dir, 'subagent-driven-development', 'scripts', 'sdd-workspace'))
|
||||
|
||||
const result = verify(dir)
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /executing-plans\/SKILL\.md/)
|
||||
assert.match(result.stderr, /sdd-workspace/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('verify fails on a mistyped sibling skill name', () => {
|
||||
const work = tempDir('dsh-verify-mistyped-skill-')
|
||||
try {
|
||||
const dir = syncedCopy(work, syncedTree(work))
|
||||
const file = join(dir, 'executing-plans', 'SKILL.md')
|
||||
write(
|
||||
file,
|
||||
`${readFileSync(file, 'utf8')}\nRun \`bash ../subagent-driven-developmen/scripts/sdd-workspace PLAN_FILE\`.\n`,
|
||||
)
|
||||
|
||||
const result = verify(dir)
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /subagent-driven-developmen\/scripts\/sdd-workspace/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('verify fails on a duplicate frontmatter key', () => {
|
||||
const work = tempDir('dsh-verify-duplicate-key-')
|
||||
try {
|
||||
const dir = syncedCopy(work, syncedTree(work))
|
||||
const file = join(dir, 'brainstorming', 'SKILL.md')
|
||||
write(file, readFileSync(file, 'utf8').replace(/^name: brainstorming$/m, 'name: brainstorming\nname: duplicate'))
|
||||
|
||||
const result = verify(dir)
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /brainstorming\/SKILL\.md/)
|
||||
assert.match(result.stderr, /duplicate key/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('verify fails without frontmatter delimiters', () => {
|
||||
const work = tempDir('dsh-verify-no-frontmatter-')
|
||||
try {
|
||||
const dir = syncedCopy(work, syncedTree(work))
|
||||
write(join(dir, 'brainstorming', 'SKILL.md'), '# Brainstorming\n\nNo frontmatter here.\n')
|
||||
|
||||
const result = verify(dir)
|
||||
|
||||
assert.equal(result.status, 1)
|
||||
assert.match(result.stderr, /brainstorming\/SKILL\.md/)
|
||||
assert.match(result.stderr, /missing YAML frontmatter/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('verify CLI without a directory exits 2', () => {
|
||||
const result = verify('')
|
||||
|
||||
assert.equal(result.status, 2)
|
||||
assert.match(result.stderr, /usage:/)
|
||||
})
|
||||
|
||||
test('verify an empty skills directory reports zero skills', () => {
|
||||
const work = tempDir('dsh-verify-empty-')
|
||||
try {
|
||||
const dir = join(work, 'empty')
|
||||
write(join(dir, '.keep'), '')
|
||||
|
||||
const result = verify(dir)
|
||||
|
||||
assert.equal(result.status, 0, `stderr: ${result.stderr}`)
|
||||
assert.match(result.stdout, /verified 0 skills/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
test('verify ignores prefixes and paths inside fenced examples', () => {
|
||||
const work = tempDir('dsh-verify-fenced-')
|
||||
try {
|
||||
const dir = syncedCopy(work, syncedTree(work))
|
||||
const file = join(dir, 'writing-plans', 'SKILL.md')
|
||||
const fence = [
|
||||
'```',
|
||||
'See superpowers:brainstorming, [x](../brainstorming/nope.md) and `references/nope.md`.',
|
||||
'```',
|
||||
].join('\n')
|
||||
write(file, `${readFileSync(file, 'utf8')}\n${fence}\n`)
|
||||
|
||||
const result = verify(dir)
|
||||
|
||||
assert.equal(result.status, 0, `stderr: ${result.stderr}`)
|
||||
assert.match(result.stdout, /verified 15 skills/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
|
||||
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 {
|
||||
const dir = syncedCopy(work, syncedTree(work))
|
||||
const file = join(dir, 'using-superpowers', 'SKILL.md')
|
||||
write(file, `${readFileSync(file, 'utf8')}\nSee \`references/dsh-tools.md\` again.\n`)
|
||||
|
||||
const result = verify(dir)
|
||||
|
||||
assert.equal(result.status, 0, `stderr: ${result.stderr}`)
|
||||
assert.match(result.stdout, /verified 15 skills/)
|
||||
} finally {
|
||||
cleanup(work)
|
||||
}
|
||||
})
|
||||
2
openspec/changes/dsh-port-v2/.openspec.yaml
Normal file
2
openspec/changes/dsh-port-v2/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-24
|
||||
82
openspec/changes/dsh-port-v2/design.md
Normal file
82
openspec/changes/dsh-port-v2/design.md
Normal file
@ -0,0 +1,82 @@
|
||||
# Design: DSH port v2 — вариант C
|
||||
|
||||
Issue: #69. Статус: **утверждён владельцем 24.10.2026**; D-5/D-6 — реализованы в ветке (см. «Статус решений»).
|
||||
|
||||
## Контекст
|
||||
|
||||
Ревью 24.10.2026: RV-1 (своя доставка superpowers без пина и адаптации),
|
||||
RV-6 (legacy-фолбэки как техдолг), RV-11 (нет compat-чека против DSH SDK).
|
||||
Владелец выбрал вариант C: машинерию готового порта
|
||||
[LayneChai/superpowers-dsh](https://github.com/LayneChai/superpowers-dsh)
|
||||
взять как форк/вендор, sync гонять самим. Чистка трекера подтверждена и
|
||||
выполнена 24.10.2026 (см. комментарий в #69; сводная — #70).
|
||||
|
||||
## Что вендорим
|
||||
|
||||
Из LayneChai/superpowers-dsh (MIT):
|
||||
|
||||
- `scripts/sync-from-upstream.mjs` — ассертные transform-ы (якорь не совпал
|
||||
ровно один раз → FAIL), skip-список чужих harness-референсов,
|
||||
staging-swap (назначение меняется только при всех пройденных ассертах),
|
||||
проверка «невзорвавшихся» transform-ов, сверка набора скиллов;
|
||||
- `scripts/verify-skills.mjs` — provider видит все скиллы, frontmatter
|
||||
валиден, нет висячих ссылок;
|
||||
- `scripts/check-dsh-compat.mjs` — **не вендорим как есть**: он написан под
|
||||
SkillProvider LayneChai (у нас его нет — скиллы читает встроенный
|
||||
skill-filesystem) и требует SDK вне app.asar (терминал всегда SKIPPED);
|
||||
его идея — сверка контракта с установленным DSH — переезжает в
|
||||
самопроверку бандла (D-6).
|
||||
|
||||
Адаптация под нас:
|
||||
|
||||
- наши transform-ы, где их текст расходится с нашим (главное: их
|
||||
`using-superpowers`-rewrite остаётся, но reference указывает на наш
|
||||
`dsh-tools.md` из `dsh/bundle/`, а не на их `port/overrides/`);
|
||||
- пути — `dsh/sync/`; скиллы кладутся не в пакет, а в
|
||||
`$DSH_HOME/skills` (источник текста для bootstrap-инъекции бандла
|
||||
остаётся единым — как сейчас).
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
| # | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|
|
||||
| D-1 | Вендор в `dsh/sync/`, не зависимость от npm-пакета LayneChai | MIT разрешает; их каденс не наш (уже на патч позади: v7.0.0 при свежем v7.0.1); форк-стоимость мала (2 скрипта: sync + verify; check-dsh-compat заменён самопроверкой бандла, D-6) | Содержим sync-скрипты сами; escape-люк на их пакет остаётся |
|
||||
| D-2 | Пин на тег (v7.0.1) через `DSH_SUPERPOWERS_REF`; #67 — rejected | Воспроизводимость; unpinned HEAD = случайная семантика | Каждый релиз upstream — свой issue+PR; пропустили релиз = молча старый текст (митигируется `--check`) |
|
||||
| D-3 | Результат sync — в `$DSH_HOME/skills`; bootstrap бандла не меняется | Один источник текста; install.sh остаётся инсталлятором, а не публикуемым пакетом | Нет |
|
||||
| D-4 | Legacy-фолбэки `~/.claude/*` живут до #66, удаляются им | #49 уже ввёл их с WARN; рубить сейчас = сломать машины без переустановки | Техдолг висит дольше; контракт удаления — в #66 |
|
||||
| D-5 | `dsh/` уже в `.claude/code-paths` (проверено 24.10.2026 — файл содержит `dsh/`); require-plan защищает весь `dsh/`, включая `dsh/sync/` | Вендоримый код обязан быть под хук | Без проверки правки sync-скриптов не гейтятся планом — в план добавлен тест-assert на строку `dsh/` в code-paths |
|
||||
| D-6 | Compat-чек (решение владельца 24.10.2026): **самопроверка бандла внутри DSH** — при активации и по команде проверяет контракт, на который бандл опирается (`ctx.tools.guard`, `agent/pre-step`, `session.surface.nodes`, `ctx.commands`, `workingDirectory`); терминальный `--check` гоняет только verify synced-скиллов (frontmatter, префиксы, висячие ссылки) и окружение | SDK DSH лежит в app.asar, терминал до него не дотягивается — проверка контракта возможна только изнутри DSH; это закрывает RV-11 по-настоящему | Бандл тронут аддитивно (активационный assert + команда) — «не трогаем бандл» смягчено до «без изменения существующего поведения» |
|
||||
| D-7 | Каденс: issue на каждый релиз obra/superpowers; compat — после апгрейда DSH | Ритм без напоминаний; влезает в обычный цикл фичи | Пропущенный релиз не виден между ретро — добавить строку в `--check`/doctor (#44) |
|
||||
| D-8 | Болевые точки эксплуатации заранее не фиксируются: ответ владельца 24.10.2026 — «пока трудно сказать, идёт разработка только самого харнеса». План сосредоточен на машинерии порта; первые реальные затыки DSH-сессий копятся в #69 и двигают приоритеты | Не выдумывать боли за владельца, пока нет сигнала | Задачи плана упорядочены без пользовательского приоритета — по зависимостям и P-классам находок |
|
||||
| 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-сессии, когда обвязка споткнула~~ — ответ «пока трудно
|
||||
сказать» → D-8.
|
||||
2. ~~Подтверждение схемы чистки трекера~~ — подтверждено и выполнено
|
||||
(#70, закрытия, парковки).
|
||||
3. ~~#63 bounded-задачей до плана~~ — да → D-10, выполняется отдельным
|
||||
PR до плана.
|
||||
|
||||
## На потом (в план, не в дизайн)
|
||||
|
||||
- Разбивка задач: sync-вендор и пин → install.sh переделка → verify
|
||||
скиллов в `--check` → самопроверка бандла → сводная follow-ups (#70) →
|
||||
#59 → #43–#46 → удаление legacy (#66).
|
||||
85
openspec/changes/dsh-port-v2/proposal.md
Normal file
85
openspec/changes/dsh-port-v2/proposal.md
Normal file
@ -0,0 +1,85 @@
|
||||
# Proposal: DSH port v2 — доставка superpowers вариантом C и чистка трекера
|
||||
|
||||
Issue: #69. Источники: ревью обвязки 24.10.2026 (находки RV-1, RV-6, RV-11),
|
||||
[LayneChai/superpowers-dsh](https://github.com/LayneChai/superpowers-dsh) (MIT),
|
||||
решение владельца 24.10.2026 «вариант C».
|
||||
|
||||
Статус: **утверждён владельцем 24.10.2026**; реализован в ветке `feat/69-dsh-port-v2` (задачи плана 1–5).
|
||||
|
||||
## Why
|
||||
|
||||
Доставка superpowers-скиллов в DSH изобретена заново: `dsh/install.sh --skills`
|
||||
копирует клонированный апстрим **без пина и без адаптации текста** (RV-1).
|
||||
Следствия:
|
||||
|
||||
- установка невоспроизводима: семантика скиллов меняется между релизами
|
||||
obra/superpowers, у нас нет ни пина, ни сверки;
|
||||
- текст скиллов не адаптирован под DSH: внутри остаются
|
||||
`superpowers:`-префиксы и ссылки на чужие harness-ы, которые закрывает
|
||||
только наш runtime-append `dsh-tools.md`;
|
||||
- нет compat-чека против установленного DSH SDK (RV-11): после апгрейда DSH
|
||||
перепроверка ручная;
|
||||
- apстрим уже выпустил v7.0.1, а существующий готовый порт
|
||||
LayneChai/superpowers-dsh пинится на v7.0.0 — зависеть от его каденса
|
||||
значит застрять (замерено 24.10.2026: теги upstream — v7.0.1 свежий);
|
||||
- трекер зарос: 23 открытых issue, 2 открытых PR, 4 worktree, из них
|
||||
2 парковки без PR; roadmap #48 и хвосты #5/#13/#21/#28/#31 дублируют
|
||||
друг друга.
|
||||
|
||||
## What Changes
|
||||
|
||||
**Вариант C** — взять машинерию готового порта как форк/вендор, но sync
|
||||
гонять самим по своему каденсу:
|
||||
|
||||
- В `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.
|
||||
- `dsh/install.sh --skills` перестаёт копировать сырой апстрим: fetch
|
||||
пина → прогон sync с ассертами → копирование адаптированного дерева.
|
||||
`--superpowers-from` остаётся для офлайна и разработки. Legacy-фолбэки
|
||||
`~/.claude/*` остаются до #66 и удаляются по нему.
|
||||
- Bootstrap-инъекция `using-superpowers` остаётся в нашем бандле; источник
|
||||
текста — synced-копия `$DSH_HOME/skills` (единая точка, как сейчас).
|
||||
- Compat-чек (решение владельца): **самопроверка бандла внутри DSH**
|
||||
(активация + команда; контракт guard/pre-step/commands/surface) +
|
||||
терминальный verify synced-скиллов в `--check`. Вендорный
|
||||
check-dsh-compat не берём (SDK в asar, терминал всегда SKIPPED).
|
||||
- Ритм: issue на каждый релиз upstream (sync → тесты → PR), самопроверка
|
||||
после каждого апгрейда DSH.
|
||||
|
||||
**Чистка трекера** (схема на подтверждение, issue #69):
|
||||
|
||||
- закрыть #48 (заменён #69), #67 (rejected), #47 (решение зафиксировано
|
||||
в design.md);
|
||||
- свести follow-ups #5/#13/#21/#28/#31 в одну сводную issue «Сводка
|
||||
отложенных находок обвязки» (это же закрывает B16);
|
||||
- #43–#46, #59, #62, #63, #66 становятся задачами плана; #63 — кандидат
|
||||
в bounded-задачу до старта плана;
|
||||
- судьба PR #18/#8 и парковок `.wt/guard`, `.wt/aura-design`,
|
||||
`.wt/feature-pane`, `dsh-superpowers-git` — за владельцем.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected: `dsh/install.sh`, `dsh/sync/` (новое), `dsh/README.md`,
|
||||
`dsh/PORT.md`, `openspec/specs/dsh-install` (дельта), трекер Gitea,
|
||||
бандл (аддитивно: активационный assert + команда самопроверки,
|
||||
существующее поведение не меняется).
|
||||
- CC-сторона хуков — вне скоупа, кроме #63 (bounded-задача до плана,
|
||||
решение владельца 24.10.2026).
|
||||
- Breaking: `--skills` начинает требовать сеть для fetch пина (кроме
|
||||
`--superpowers-from`); #67 отменён.
|
||||
- Риски: см. design.md.
|
||||
|
||||
## Открытые вопросы (к владельцу) — решены 24.10.2026
|
||||
|
||||
1. ~~Что в DSH-сессии спотыкало сильнее всего~~ — ответ «пока трудно
|
||||
сказать, идёт разработка только самого харнеса» → design.md D-8.
|
||||
2. ~~Подтверждение схемы чистки трекера~~ — подтверждено («чисти»),
|
||||
выполнено 24.10.2026: #70, закрыты #5/#13/#21/#28/#31/#48/#47/#67,
|
||||
парковки удалены.
|
||||
3. ~~#63 bounded-задачей до плана~~ — да → design.md D-10.
|
||||
242
openspec/changes/dsh-port-v2/specs/dsh-install/spec.md
Normal file
242
openspec/changes/dsh-port-v2/specs/dsh-install/spec.md
Normal file
@ -0,0 +1,242 @@
|
||||
# Spec Delta: dsh-install
|
||||
|
||||
Дельта change `dsh-port-v2` (#69). Доставка superpowers переходит с «сырой
|
||||
копии клона» на sync по пину `v7.0.1`, бандл получает самопроверку контракта.
|
||||
|
||||
**BREAKING:** `--skills` без явного источника теперь требует сеть (клон пина);
|
||||
офлайн-прогон без клона и без legacy-источника — `FAIL`, а не тихий пропуск
|
||||
и не «WARN + код 0» (legacy-источник, если он есть, остаётся сырым WARN-путём).
|
||||
Отмена #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`: пустое дерево не устанавливается.
|
||||
- В `<out>/.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>/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: <feature> not-ok: <detail>`.
|
||||
- Отсутствие службы 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 <dir>` → `$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 <ref> <repo> <dir>`; неудачный клон MUST
|
||||
удалять недоклонированный каталог (полусостояние не остаётся).
|
||||
- Существующий клон MUST обновляться fetch'ем от `$DSH_SUPERPOWERS_REPO` (не от
|
||||
origin клона) с force-рефспеком тега
|
||||
(`+refs/tags/<ref>:refs/tags/<ref>`) и 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=<dir>` и флаг `--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 (<path>)` и 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 <ref> <repo> <DSH_HOME>/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`, предупреждения нет
|
||||
Loading…
x
Reference in New Issue
Block a user