DSH: model-invocable role loader (#39) #40

Merged
aep merged 1 commits from feat/39-dsh-role-loader into main 2026-10-24 15:30:45 +03:00
7 changed files with 179 additions and 19 deletions

View File

@ -0,0 +1,60 @@
# DSH role loader — план
Ветка: `feat/39-dsh-role-loader` (issue #39).
Спека/дизайн: `dsh/PORT.md`.
## Цель
Дать модели DSH штатный способ загрузить роль: skill `role`, который читает
`~/.dsh/roles/<r>.md`. `/role` остаётся пользовательской командой; глобальные
инструкции больше не требуют от модели вызвать slash-команду.
## Scope
1. `dsh/skills/role/SKILL.md` — model-invocable инструкция: определить роль,
прочитать файл, объявить роль, следовать процессу.
2. `dsh/install.sh --skills`: копировать локальные `dsh/skills/*` в
`$DSH_HOME/skills` вместе с superpowers.
3. `install_global`: adapter-строка «модель использует skill `role`; `/role` —
пользовательская команда».
4. Тесты: наличие/содержимое skill, install-копирование, `--check`.
5. README/PORT.
## Non-goals
- Кастомный `ctx.tools.register('role')` — отдельный вариант, если skill
окажется недостаточно.
- Автоматическая инъекция роли в `agent/pre-step` — возможный следующий шаг.
- Изменение `global/CLAUDE.md` (он общий с Claude Code и ограничен 3 КБ).
## Задачи
- [x] **Task 1: skill `role`**
- Create: `dsh/skills/role/SKILL.md`
- Frontmatter: `name: role`, description для каталога.
- Body: роль из первого сообщения; пути `~/.dsh/roles`, `~/.claude/roles`,
repo `roles/`; прочитать файл; объявить «Роль: <r>; если не так — скажи».
- Не вызывать `/role`; это команда пользователя.
- [x] **Task 2: install.sh**
- Modify: `dsh/install.sh`
- `install_local_skills()` копирует `$ROOT/dsh/skills/*` в `$DSH_HOME/skills`.
- `--skills` вызывает superpowers + local; `--all` уже включает.
- `check` проверяет `$DSH_HOME/skills/role/SKILL.md`.
- `install_global` добавляет adapter-строку про skill `role`.
- [x] **Task 3: тесты**
- Modify: `dsh/tests/install.test.mjs` — role skill скопирован.
- Add: `dsh/tests/role-skill.test.mjs` — frontmatter, пути, запрет `/role`.
- [x] **Task 4: docs**
- Modify: `dsh/README.md`, `dsh/PORT.md` — skill `role` vs `/role`.
- [x] **Task 5: проверки и коммит**
- `bash dsh/tests/run.sh`
- `bash tests/run.sh`
- Commit, push, PR #40 (base main).
## Acceptance
Свежая сессия DSH, «Let's make a react todo list»:
- модель вызывает `skill({name:"role"})`;
- читает `~/.dsh/roles/dev.md`;
- объявляет роль и следует процессу, без ручного `/role`.

View File

@ -1,9 +1,10 @@
# DSH port — design and spike notes
Status: **spike 0.1.0, live guard/require-plan/bootstrap verified; `/role` and
fresh-session acceptance pending**. This directory is the
DeepSeek Harness adapter for `claude-harness`; the Claude Code installation and
the repository's source of truth are unchanged.
Status: **spike 0.2.0, live guard/require-plan/bootstrap verified; `/role`
command works, the model-invocable `role` skill is added but its live
acceptance is pending**. This directory is the DeepSeek Harness adapter for
`claude-harness`; the Claude Code installation and the repository's source of
truth are unchanged.
## 1. Verdict
@ -13,7 +14,7 @@ This is a port, not a copy. Three layers map differently:
|---|---|---|---|
| Instructions | `~/.claude/CLAUDE.md`, project `CLAUDE.md` | `$DSH_HOME/AGENTS.md`, project `CLAUDE.md`/`AGENTS.md`, nested | almost 1:1 |
| Skills | `~/.claude/skills`, `.claude/skills` | `~/.dsh/skills`, `.agents/skills`, `customSkillDirs` | 1:1 format |
| Roles | `roles/`, `/role` skill | same files + a real `/role` command | close |
| 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 |
| SDD | `Task` (synchronous) | `subagent` (asynchronous) / `workflow` | semantics differ |
@ -41,12 +42,14 @@ file has to be installed as `$DSH_HOME/AGENTS.md` because DSH does not read
`bundle/dsh-tools.md`, wraps the result in `<EXTREMELY_IMPORTANT>`, and
injects it once per session. A content-hash marker handles dedupe; scanning
`session.surface.nodes` re-injects after compaction.
3. **`/role`** as a real command (`ctx.commands.register`). It reads
3. **Roles** — `/role` is a real command (`ctx.commands.register`); it reads
`~/.dsh/roles/<role>.md`, falls back to `~/.claude/roles/<role>.md`, then to
the repository's `roles/`, and reads the project `.claude/roles/<role>.md`.
It injects the role text as a user message and starts a turn for the
five-line summary. Auto-memory is explicitly dropped; the role's "Читать на
старте" section is left to the model.
five-line summary. Because a model cannot call a slash command,
`skills/role/SKILL.md` is the model-invocable loader: it infers the role,
reads the file, and tells the model to follow it. Auto-memory is explicitly
dropped; the role's "Читать на старте" section is left to the model.
4. **Display metadata and an optional CodeGraph MCP overlay** so the bundle can
be installed through the Plugins page and codegraph stays available.
@ -88,7 +91,8 @@ dsh/
locale/{en,ru}.json
icon.svg
tests/ # Node tests, no DSH runtime needed
install.sh # --check / --global / --skills / --all
skills/role/SKILL.md # model-invocable role loader (installed to ~/.dsh)
install.sh # --check / --global / --skills / --roles / --all
mcp.codegraph.yml # optional CodeGraph MCP row
```
@ -282,8 +286,8 @@ profile was removed; the real `desktop` profile was not touched.
## 8. Next phases
1. Finish the live smoke test: `/role dev`, the fresh-session superpowers
acceptance, and bootstrap re-injection after `/compact`.
1. Finish the live smoke test: the fresh-session `role` skill acceptance, plus
the bootstrap and `/compact` checks already verified.
2. Add `harness-check` and optional `role-hint` as non-blocking pre-step
injections.
3. Build `briefing` as an agent preset (read-only tools + model route).

View File

@ -11,8 +11,10 @@ separate Cordis bundle plus a thin install helper.
Claude Code;
- superpowers bootstrap — `using-superpowers` is injected once per session from
`agent/pre-step` and re-injected after compaction;
- `/role dev|admin|analyst|qa` — reads both role layers and injects the role
text, without Claude's auto-memory step.
- roles — the model loads them with the `role` skill (`~/.dsh/roles`, then
`~/.claude/roles`, then repo `roles/`); `/role dev|admin|analyst|qa` remains
the user-facing command and injects the same text, without Claude's
auto-memory step.
**Not yet:** SDD (DSH subagents are asynchronous), `briefing`,
`harness-check`, `role-hint`, `harness-doctor`, auto-memory, caveman. See
@ -24,7 +26,7 @@ separate Cordis bundle plus a thin install helper.
# Static check
bash dsh/install.sh --check
# Global instructions, superpowers skills, and roles
# Global instructions, superpowers skills, 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
@ -47,7 +49,8 @@ manager owns those writes.
`BLOCKED [guard-main]`.
3. Create a worktree on a feature branch, ask for an edit under `src/` — expect
`BLOCKED [require-plan]` until `.bounded` or a plan for the branch exists.
4. `/role dev` — expect the five-line role summary.
4. The model should load a role with `skill({name:"role"})`; `/role dev` is the
user-facing command with the same file. Expect the five-line role summary.
5. Prompt `Let's make a react todo list` — `brainstorming` must trigger before
any code.
@ -124,6 +127,9 @@ repository layout:
plugin cache is only a fallback, so a partial Claude Code rollback does not
break the bootstrap. Set `bootstrapSkillPath` or `DSH_SUPERPOWERS_SKILL` for
another layout.
- Role loading is model-driven through the `role` skill; there is no automatic
`agent/pre-step` injection. If the model skips the skill, use the `/role`
command or ask it to load the role explicitly.
- Model names in roles and superpowers (`sonnet`, `haiku`, `opus`) are not DSH
models. Map them to live provider/model routes before porting SDD.
- SDD is intentionally out of scope for this spike; see

View File

@ -32,7 +32,7 @@ Usage: bash dsh/install.sh [--check] [--global] [--skills] [--roles] [--all]
--check verify python3, git, node, hooks, bundle, skills, and roles
--global back up and write \$DSH_HOME/AGENTS.md from global/CLAUDE.md
--skills copy installed superpowers skills into \$DSH_HOME/skills
--skills copy superpowers skills 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
@ -109,6 +109,11 @@ check() {
else
echo "WARN: superpowers plugin not found; the bootstrap will be skipped" >&2
fi
if [ -f "$DSH_HOME/skills/role/SKILL.md" ]; then
echo "ok: role skill"
else
echo "WARN: role skill not installed; run --skills" >&2
fi
if [ -f "$DSH_HOME/roles/dev.md" ]; then
echo "ok: roles ($DSH_HOME/roles)"
elif [ -f "$HOME/.claude/roles/dev.md" ]; then
@ -134,9 +139,10 @@ install_global() {
echo "# DeepSeek Harness global instructions"
echo
echo "Installed from $ROOT/global/CLAUDE.md by dsh/install.sh."
echo "The dsh-harness bundle enforces guard-main/require-plan and provides /role."
echo "DSH adapter: role hints come from the first message or /role; there is no SessionStart hint."
echo "DSH adapter: /role reads ~/.dsh/roles, then ~/.claude/roles, then $ROOT/roles."
echo "The dsh-harness bundle enforces guard-main/require-plan and provides the /role command."
echo "DSH adapter: the model loads roles with skill role; /role is the user-facing command."
echo "DSH adapter: there is no SessionStart role hint; infer the role from the first message."
echo "DSH adapter: role files are ~/.dsh/roles, then ~/.claude/roles, then $ROOT/roles."
echo "DSH adapter: bootstrap prefers ~/.dsh/skills; the ~/.claude cache is a fallback."
echo "DSH adapter: codegraph MCP tools are named mcp__codegraph__codegraph_explore."
echo
@ -165,6 +171,23 @@ install_skills() {
echo "copied $count superpowers skills into $DSH_HOME/skills"
}
install_local_skills() {
local src="$ROOT/dsh/skills"
[ -d "$src" ] || return 0
mkdir -p "$DSH_HOME/skills"
local count=0
local dir name dest
for dir in "$src"/*; do
[ -f "$dir/SKILL.md" ] || continue
name="$(basename "$dir")"
dest="$DSH_HOME/skills/$name"
rm -rf "$dest"
cp -R "$dir" "$dest"
count=$((count + 1))
done
echo "copied $count local skills into $DSH_HOME/skills"
}
install_roles() {
mkdir -p "$DSH_HOME/roles"
local src="$ROOT/roles"
@ -206,6 +229,7 @@ if [ "$DO_GLOBAL" = 1 ]; then
fi
if [ "$DO_SKILLS" = 1 ]; then
install_skills
install_local_skills
fi
if [ "$DO_ROLES" = 1 ]; then
install_roles

43
dsh/skills/role/SKILL.md Normal file
View File

@ -0,0 +1,43 @@
---
name: role
description: Загрузить роль сессии (dev, admin, analyst, qa) из ~/.dsh/roles и следовать её процессу. Вызывать в начале сессии, когда роль очевидна из первого сообщения, или по просьбе владельца.
---
# Роль сессии
`/role` — пользовательская команда; модель её не вызывает. Этот skill — штатный
способ загрузить роль.
## 1. Определи роль
По первому сообщению:
- Jira, Гант, спринт → `admin`;
- стенд, прогон, скриншот, макет → `qa`;
- ТЗ, OQ, спека, методика → `analyst`;
- срез, issue, PR, тест, миграция, «сделай фичу» → `dev`.
Если роль не очевидна — задай один вопрос с четырьмя вариантами и остановись.
## 2. Прочитай файл роли
Порядок:
1. `~/.dsh/roles/<r>.md` — основной слой DSH;
2. `~/.claude/roles/<r>.md` — fallback, если DSH-копии нет;
3. `./.claude/roles/<r>.md` — проектный слой, если есть; его правила важнее общего.
Если ни один файл не найден — скажи одной строкой «файла роли <r> нет» и
продолжай без роли. Строки `brief:` пропускай: агент `briefing` в DSH не
портирован.
## 3. Объяви и следуй
Ответь коротко:
- `Роль: <r>; если не так — скажи.`
- процесс: одна строка из раздела «Процесс»;
- прочитано: какие файлы роли загружены;
- не делать: одна строка из раздела «Не делать».
Дальше следуй файлу роли. Новая роль заменяет предыдущую целиком.

View File

@ -32,6 +32,7 @@ test('install.sh copies skills and roles instead of linking into ~/.claude', ()
const first = run()
assert.equal(first.status, 0, first.stderr)
assert.match(first.stdout, /copied 2 superpowers skills/)
assert.match(first.stdout, /copied 1 local skills/)
assert.match(first.stdout, /installed 4 roles/)
const skill = join(dshHome, 'skills', 'using-superpowers')
@ -39,6 +40,10 @@ test('install.sh copies skills and roles instead of linking into ~/.claude', ()
assert.equal(lstatSync(skill).isDirectory(), true)
assert.match(readFileSync(join(skill, 'SKILL.md'), 'utf8'), /body/)
const roleSkill = join(dshHome, 'skills', 'role', 'SKILL.md')
assert.ok(existsSync(roleSkill), 'role skill was not installed')
assert.match(readFileSync(roleSkill, 'utf8'), /name: role/)
for (const role of ['dev', 'admin', 'analyst', 'qa']) {
assert.ok(existsSync(join(dshHome, 'roles', `${role}.md`)), `missing ${role}.md`)
}
@ -46,6 +51,7 @@ test('install.sh copies skills and roles instead of linking into ~/.claude', ()
const second = run()
assert.equal(second.status, 0, second.stderr)
assert.equal(lstatSync(skill).isSymbolicLink(), false)
assert.ok(existsSync(roleSkill))
} finally {
cleanup(home)
}

View File

@ -0,0 +1,17 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const skillPath = join(here, '..', 'skills', 'role', 'SKILL.md')
test('role skill is model-invocable and names the DSH role paths', () => {
const text = readFileSync(skillPath, 'utf8')
assert.match(text, /^---\nname: role\n/)
assert.match(text, /\ndescription: .+/)
assert.match(text, /~\/\.dsh\/roles/)
assert.match(text, /\.claude\/roles/)
assert.match(text, /модель её не вызывает/)
})