From 16a9739759f7eaabd54853bbff24ca92b3d06912 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 14:59:03 +0400 Subject: [PATCH 1/7] =?UTF-8?q?feat(dsh):=20=D0=BA=D0=B0=D1=80=D0=BA=D0=B0?= =?UTF-8?q?=D1=81=20=D0=BF=D0=BE=D1=80=D1=82=D0=B0=20claude-harness=20?= =?UTF-8?q?=D0=BD=D0=B0=20DeepSeek=20Harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/code-paths | 1 + .../plans/2026-10-24-dsh-port-spike.md | 60 +++++ dsh/PORT.md | 249 ++++++++++++++++++ dsh/README.md | 127 +++++++++ dsh/bundle/command-role.js | 92 +++++++ dsh/bundle/cordis.patch.yml | 8 + dsh/bundle/dsh-tools.md | 46 ++++ dsh/bundle/hooks | 1 + dsh/bundle/icon.svg | 12 + dsh/bundle/index.js | 168 ++++++++++++ dsh/bundle/lib/bootstrap.js | 212 +++++++++++++++ dsh/bundle/lib/guards.js | 108 ++++++++ dsh/bundle/lib/role.js | 113 ++++++++ dsh/bundle/locale/en.json | 6 + dsh/bundle/locale/ru.json | 6 + dsh/bundle/package.json | 27 ++ dsh/install.sh | 184 +++++++++++++ dsh/mcp.codegraph.yml | 18 ++ dsh/tests/bootstrap.test.mjs | 90 +++++++ dsh/tests/guard.test.mjs | 117 ++++++++ dsh/tests/helpers.mjs | 37 +++ dsh/tests/package.test.mjs | 52 ++++ dsh/tests/role.test.mjs | 96 +++++++ dsh/tests/run.sh | 11 + 24 files changed, 1841 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-24-dsh-port-spike.md create mode 100644 dsh/PORT.md create mode 100644 dsh/README.md create mode 100644 dsh/bundle/command-role.js create mode 100644 dsh/bundle/cordis.patch.yml create mode 100644 dsh/bundle/dsh-tools.md create mode 120000 dsh/bundle/hooks create mode 100644 dsh/bundle/icon.svg create mode 100644 dsh/bundle/index.js create mode 100644 dsh/bundle/lib/bootstrap.js create mode 100644 dsh/bundle/lib/guards.js create mode 100644 dsh/bundle/lib/role.js create mode 100644 dsh/bundle/locale/en.json create mode 100644 dsh/bundle/locale/ru.json create mode 100644 dsh/bundle/package.json create mode 100755 dsh/install.sh create mode 100644 dsh/mcp.codegraph.yml create mode 100644 dsh/tests/bootstrap.test.mjs create mode 100644 dsh/tests/guard.test.mjs create mode 100644 dsh/tests/helpers.mjs create mode 100644 dsh/tests/package.test.mjs create mode 100644 dsh/tests/role.test.mjs create mode 100755 dsh/tests/run.sh diff --git a/.claude/code-paths b/.claude/code-paths index 3c11b1a..c52c8ae 100644 --- a/.claude/code-paths +++ b/.claude/code-paths @@ -1,2 +1,3 @@ hooks/ skills/ +dsh/ diff --git a/docs/superpowers/plans/2026-10-24-dsh-port-spike.md b/docs/superpowers/plans/2026-10-24-dsh-port-spike.md new file mode 100644 index 0000000..4e019ec --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-dsh-port-spike.md @@ -0,0 +1,60 @@ +# DSH port spike — план + +Ветка: `feat/dsh-port-spike`. +Спека/дизайн: `dsh/PORT.md`. + +## Цель + +Проверить, что `claude-harness` переносится на DeepSeek Harness без потери +жёстких правил, и получить работающий каркас адаптера в `dsh/`. + +## Scope + +1. Cordis-бандл `dsh/bundle`: + - `ctx.tools.guard` → существующие `hooks/guard-main.sh` и + `hooks/require-plan.sh` (переиспользовать Python-ядро, не переписывать); + - `agent/pre-step` → bootstrap `using-superpowers` + `dsh-tools.md`; + - `ctx.commands.register('role')` → оба слоя ролей, без auto-memory. +2. Установка: `dsh/install.sh` (`--check`, `--global`, `--skills`), бандл — + через `plugin_manager`. +3. Опциональный CodeGraph MCP overlay. +4. Тесты на чистом Node + документация (`dsh/README.md`, `dsh/PORT.md`). + +## Non-goals + +- SDD: DSH `subagent` асинхронный; нужна отдельная семантика. +- `briefing`, `harness-check`, `role-hint`, `harness-doctor`, auto-memory, + caveman. +- Публикация бандла и отдельный репозиторий. + +## Задачи + +- [x] Каркас `dsh/bundle` (package.json, cordis.patch.yml, locale, icon). +- [x] Адаптер guard-main/require-plan + тесты на временном git-репозитории. +- [x] Bootstrap `using-superpowers`: резолв версии, marker, dedupe, compaction. +- [x] `/role`: парсинг, два слоя, инъекция, followup. +- [x] `dsh/install.sh`, `mcp.codegraph.yml`, README/PORT. +- [x] `bash dsh/tests/run.sh` — 22 теста зелёные. +- [x] `bash tests/run.sh` — 630 тестов зелёные (регрессий нет). +- [ ] Живая проверка в DSH: маркер, guard на main, `/role`, acceptance + superpowers. +- [ ] Решение по SDD (async controller vs workflow). + +## Acceptance + +- На `main` `write`/`edit`/`bash`-запись → `BLOCKED [guard-main]`. +- На ветке `src/...` без плана → `BLOCKED [require-plan]`; `.bounded` или план + для ветки → разрешено. +- Bootstrap-маркер `` виден один раз; + после compaction появляется снова. +- `/role dev` читает `~/.claude/roles/dev.md` и `/.claude/roles/dev.md`. +- «Let's make a react todo list» → сначала `brainstorming`, до кода. + +## Замечания + +- `dsh/bundle/hooks` — симлинк на `../../hooks` для локальной установки; для + публикации бандла нужна реальная копия (уже в `package.json.files`). +- `install.sh` не устанавливает бандл в профиль: Desktop-профиль управляется + Electron-приложением, установка идёт через Plugins/`plugin_manager`. +- Живая проверка требует новой сессии DSH и установленного бандла; текущий + прогон ограничен статическими тестами. diff --git a/dsh/PORT.md b/dsh/PORT.md new file mode 100644 index 0000000..69bc7e9 --- /dev/null +++ b/dsh/PORT.md @@ -0,0 +1,249 @@ +# DSH port — design and spike notes + +Status: **spike 0.1.0, live verification 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 + +This is a port, not a copy. Three layers map differently: + +| Layer | Claude Code | DeepSeek Harness | Status | +|---|---|---|---| +| 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 | +| 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 | +| Gitea, OpenSpec | CLI + skills | `bash` + skills | 1:1 | +| codegraph | MCP | MCP (`mcp__codegraph__*`) | 1:1 with names | +| auto-memory | Claude Code feature | none | dropped/replaced with files | +| install | `install.sh` + `settings.json` | bundle + profile manager | new | + +The important discovery: **DSH loads `CLAUDE.md` as a project instruction file**, +so the project templates and nested instructions need no rewrite. The global +file has to be installed as `$DSH_HOME/AGENTS.md` because DSH does not read +`~/.claude/CLAUDE.md`. + +## 2. What this spike implements + +1. **`guard-main` and `require-plan`** through `ctx.tools.guard`. The adapter + synthesizes the Claude hook payload (`{cwd, tool_name, tool_input}`), runs + the existing `hooks/guard-main.sh` / `hooks/require-plan.sh`, turns `exit 2` + into a denial reason, and fails open on every other error. The Python core + (`hooks/harness_target.py`) and its tests remain the source of truth. A guard + is synchronous and monotonic: it runs after `tools/pre-execute`, and no later + listener can turn the denial back into permission. +2. **Superpowers bootstrap** through `agent/pre-step`. It reads the installed + `using-superpowers/SKILL.md`, strips frontmatter, appends + `bundle/dsh-tools.md`, wraps the result in ``, 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 the global + `~/.claude/roles/.md` and the project `.claude/roles/.md`, 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. +4. **Display metadata and an optional CodeGraph MCP overlay** so the bundle can + be installed through the Plugins page and codegraph stays available. + +## 3. What is deliberately deferred + +- **`harness-check` and `role-hint`**: non-blocking SessionStart hooks. They can + become `agent/pre-step` injections later; the first message role rule already + covers most of `role-hint`. +- **`briefing`**: needs an agent preset with read-only tools and a model route; + not part of the guard/bootstrap spike. +- **SDD**: the Claude `Task` is synchronous, while DSH `subagent` returns a + child id and reports later. This needs a semantic rewrite (async controller + with `send_message` + `maxDepth: 2`, or a `workflow` script). Do not claim SDD + parity from this spike. +- **Codegraph by default**: `dsh/mcp.codegraph.yml` is an opt-in overlay; the + bundle does not assume the binary is installed. +- **`harness-doctor`**: its scripts inspect `~/.claude`; a DSH variant is a + separate task. +- **Auto-memory, caveman**: no direct DSH equivalents; caveman is off by default + anyway. +- **Full installer**: `install.sh` prepares global instructions and skills; the + bundle itself is installed by `plugin_manager` because the Desktop profile is + Electron-managed. + +## 4. Architecture + +``` +dsh/ + bundle/ + package.json # dsh.bundle.patch, exports, display resources + cordis.patch.yml # inserts the dsh-harness row + index.js # ctx.tools.guard + agent/pre-step + /role wiring + command-role.js # /role command glue (DSH imports live only here) + lib/guards.js # Claude-hook JSON adapter, fail-open + lib/bootstrap.js # skill resolution, marker, dedupe, compaction + lib/role.js # role parsing and text building (pure) + dsh-tools.md # superpowers tool mapping + hooks -> ../../hooks # local-install symlink to the tested bash/Python hooks + locale/{en,ru}.json + icon.svg + tests/ # Node tests, no DSH runtime needed + install.sh # --check / --global / --skills / --all + mcp.codegraph.yml # optional CodeGraph MCP row +``` + +`index.js` is the only file importing DSH packages +(`@deepseek-ai/dsh-llm`, `@deepseek-ai/schemastery`). The pure modules are +testable with plain Node; this is why the port's 22 tests run without a +DSH process. + +### Guard flow + +``` +model calls write/edit/bash + -> tools/pre-execute (approval, extensible policy) + -> ctx.tools.guard (dsh-harness, synchronous, monotonic) + -> lib/guards.js maps tool + args + -> bash hooks/guard-main.sh (cwd, tool_name, tool_input) + -> python3 hooks/harness_target.py guard-main + -> exit 2 => deny reason (final) + -> exit 0 or any failure => allow + -> require-plan.sh for write/edit + -> execution +``` + +The guard returns a denial; the tool pipeline materializes it as the model-facing +error. Other listeners cannot turn it back into permission. A script failure, +missing Python, or malformed JSON fails open, matching the harness invariant. + +### Bootstrap flow + +``` +agent/pre-step + -> next() (other injections happen first) + -> skip if reject/aborted, or if subagent and bootstrapSubagents=false + -> prepareBootstrap(): resolve skill, read mapping, hash content, build text + -> isBootstrapVisible(agent, decision.messages, marker)? + yes -> no-op + no -> append createUserMessage({ source: {kind:"user"} }) +``` + +The marker is an HTML comment at the top of the message. The visible-surface +scan uses `agent.session.surface.nodes` + `eventAt(seq)`, the same mechanism the +skill catalog uses, so a compacted message is re-injected on the next step. + +## 5. Configuration + +The bundle row can be configured from the profile patch. Defaults are suitable +for the repository layout: + +```yaml +- id: dsh-harness + config: + enabled: true + guards: true + bootstrap: true + roleCommand: true + bootstrapSubagents: false + hooksDir: '' # empty -> bundle/hooks -> ../../hooks + rolesDir: '' # empty -> installed ~/.claude/roles, else repo roles/ + projectRolesDir: '' # empty -> /.claude/roles + roleNames: [dev, admin, analyst, qa] + bootstrapSkillPath: '' # empty -> newest superpowers plugin cache + toolMappingPath: '' # empty -> bundle/dsh-tools.md + bootstrapMaxBytes: 65536 +``` + +`bootstrapSkillPath` resolution order: explicit path, `DSH_SUPERPOWERS_SKILL`, +the newest `~/.claude/plugins/cache/claude-plugins-official/superpowers/*/`, +then `~/.claude/skills`, `~/.dsh/skills`, and `~/.agents/skills`. + +## 6. Install and verify + +Prerequisites: `claude-harness` installed in `~/.claude` (hooks, roles, +skills), superpowers 6.4.x, DSH 0.2.1-rc.3, `python3`, `git`, Node. + +```bash +# 1. Static checks +bash dsh/install.sh --check + +# 2. Global instructions and skills (run from a terminal; writes outside the +# session workspace) +bash dsh/install.sh --global +bash dsh/install.sh --skills + +# 3. Install the bundle in DSH: +# Plugins -> install bundle from dsh/bundle +# or ask the agent: +# plugin_manager install_bundle target=/absolute/path/to/claude-harness/dsh/bundle + +# 4. Optional: CodeGraph MCP +# apply dsh/mcp.codegraph.yml as a --patch overlay; the tool is +# mcp__codegraph__codegraph_explore. +``` + +Live checks: + +1. New session: ask "what skills do you have?" — superpowers skills and the + DSH catalog should be visible. +2. On `main`, ask the agent to create a file — expect + `BLOCKED [guard-main]`. +3. In a worktree on a feature branch, edit `src/...` without a plan — expect + `BLOCKED [require-plan]`; add `.bounded` and retry. +4. `/role dev` — expect the five-line summary and the injected role rules. +5. The acceptance prompt "Let's make a react todo list" must trigger + `brainstorming` before code. If it does not, the bootstrap is not visible; + check the plugin row and the skill path. +6. Guard marker: the injected message starts with + ``; after compaction a new marker is + appended. + +Automated tests: + +```bash +bash dsh/tests/run.sh +``` + +They cover main/detached guards, branch allowance, Bash writes, `require-plan` +with `.bounded` and a branch plan, bootstrap frontmatter/marker/dedupe, +role-layer composition, and package metadata. + +## 7. Risks and open questions + +- **DSH is 0.2.1-rc.3.** The plugin/event API is not frozen; pin the version and + re-run the tests after upgrades. +- **`ctx.tools.guard` is synchronous.** The guard resolves cwd through + `workingDirectory.get(session)` (sync) and runs the hooks with + `execFileSync`. If a future DSH version changes the guard contract or makes + cwd resolution async, the adapter needs a `tools/pre-execute` fallback. The + guard is global and monotonic; it runs after `tools/pre-execute`, so an earlier + user approval cannot turn a `guard-main` denial back into permission. +- **`agent/pre-step` injection.** The code follows the shipped skill-catalog + pattern (`next()` then append `messages`), but the live behavior of + `createUserMessage({source:{kind:"user"}})` in a host bundle still needs a + smoke test. The source kind is intentionally `user` to avoid an unknown + session source variant. +- **Subagent bootstrap.** `isSubagent` probes common fields; if none are present + on this version, the bootstrap reaches children too. The skill's + `` makes that harmless but token-expensive. +- **Model tiers.** `sonnet`/`haiku`/`opus` are not DSH models. Map them to live + provider/model routes before porting the SDD text. +- **Superpowers porting rules.** The upstream guide requires a session-start + bootstrap with no per-session opt-in; `agent/pre-step` satisfies that shape, + but a real acceptance transcript is still required before claiming a port. +- **Hooks symlink.** Local-directory installation links the checkout, so + `bundle/hooks -> ../../hooks` works. A packed/published bundle must include a + real copy of `hooks/`; the `files` field already lists it. + +## 8. Next phases + +1. Live smoke test in DSH: guard, bootstrap marker, `/role`. +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). +4. SDD spike: async controller (`subagent` + `send_message`, `maxDepth: 2`) for + one plan, or a `workflow`-based executor; measure which fits the harness. +5. Enable CodeGraph MCP by default when the binary is present; update role and + project instructions to `mcp__codegraph__codegraph_explore`. +6. Port `harness-doctor` checks to DSH paths. +7. Decide the long-term home: keep `dsh/` in this repository while it shares + hooks/roles/skills, then extract a `dsh-harness` repository when the bundle + gets its own release cadence. diff --git a/dsh/README.md b/dsh/README.md new file mode 100644 index 0000000..98d219b --- /dev/null +++ b/dsh/README.md @@ -0,0 +1,127 @@ +# dsh — DeepSeek Harness adapter + +Spike of the `claude-harness` port for DeepSeek Harness (DSH 0.2.1-rc.3). +The Claude Code installation in `~/.claude` stays untouched; this directory is a +separate Cordis bundle plus a thin install helper. + +**What works in the spike:** + +- `guard-main` / `require-plan` — the tested bash/Python hooks run through + `ctx.tools.guard`; the agent gets `BLOCKED` with the same messages as in + 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. + +**Not yet:** SDD (DSH subagents are asynchronous), `briefing`, +`harness-check`, `role-hint`, `harness-doctor`, auto-memory, caveman. See +[PORT.md](PORT.md) for the design, mapping table, and risks. + +## Quick start + +```bash +# Static check +bash dsh/install.sh --check + +# Global instructions and superpowers skills +# (run from a terminal; these write outside the session workspace) +bash dsh/install.sh --global +bash dsh/install.sh --skills + +# Install the bundle in DSH: +# Plugins -> install a bundle from dsh/bundle +# or ask the agent to call plugin_manager install_bundle with that target. +``` + +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. + +## Verify + +1. In a new session, ask `what skills do you have?` — the superpowers catalog + should be visible. +2. In a repository on `main`, ask the agent to create a file — expect + `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. +5. Prompt `Let's make a react todo list` — `brainstorming` must trigger before + any code. + +The injected bootstrap starts with a marker: + +```text + +``` + +After compaction the marker disappears from `session.surface.nodes` and the next +step injects a fresh copy. + +## Tests + +```bash +bash dsh/tests/run.sh +``` + +Pure Node; no DSH process is required. The guard tests create a throwaway git +repository and exercise the real `hooks/guard-main.sh` and +`hooks/require-plan.sh` through the adapter. + +## Layout + +```text +dsh/ + bundle/ + package.json DSH bundle manifest (dsh.bundle.patch) + cordis.patch.yml inserts the dsh-harness row + index.js ctx.tools.guard + agent/pre-step + /role + command-role.js /role 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) + dsh-tools.md superpowers tool mapping + hooks -> ../../hooks local install: tested hooks are reused, not copied + locale/{en,ru}.json + icon.svg + tests/ + install.sh + mcp.codegraph.yml optional CodeGraph MCP overlay + PORT.md design, mapping, risks, next phases +``` + +## Configuration + +The row is configurable from the profile patch; defaults target this +repository layout: + +```yaml +- id: dsh-harness + config: + guards: true + bootstrap: true + roleCommand: true + bootstrapSubagents: false + hooksDir: '' # empty -> bundle/hooks -> ../../hooks + rolesDir: '' # empty -> installed ~/.claude/roles, else repo roles/ + projectRolesDir: '' # empty -> /.claude/roles + roleNames: [dev, admin, analyst, qa] + bootstrapSkillPath: '' # empty -> newest superpowers plugin cache + toolMappingPath: '' # empty -> bundle/dsh-tools.md + bootstrapMaxBytes: 65536 +``` + +## Known limits + +- The guard adapter uses the repository's bash/Python hooks; if `python3` is + missing, the hooks fail open by design. +- `bundle/hooks` is a symlink for local-directory installation. A published + bundle needs a real copy of `hooks/` (already listed in `package.json` + `files`). +- `using-superpowers` content is read from the installed Claude plugin cache; + set `bootstrapSkillPath` or `DSH_SUPERPOWERS_SKILL` for another layout. +- 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 + [PORT.md](PORT.md#3-what-is-deliberately-deferred). diff --git a/dsh/bundle/command-role.js b/dsh/bundle/command-role.js new file mode 100644 index 0000000..ac17d21 --- /dev/null +++ b/dsh/bundle/command-role.js @@ -0,0 +1,92 @@ +/** + * Human-facing `/role` command for DeepSeek Harness. + * + * The command reads the same role files as the Claude skill, then injects the + * role instructions as a user message and starts a turn so the model can answer + * the five-line role summary. The command's own result is UI-only. + */ + +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { buildRoleText, listRoles, parseRole } from './lib/role.js' + +function currentDirectory(workingDirectory, agent) { + try { + const cwd = workingDirectory.get(agent.session) + if (typeof cwd === 'string' && cwd !== '') return cwd + } catch { + // Fall through to the process directory. + } + return process.cwd() +} + +export function registerRoleCommand({ + commands, + workingDirectory, + rolesDir, + projectRolesDir, + roleNames, + logger, +}) { + commands.register({ + name: 'role', + description: 'Переключить сессию в роль dev | admin | analyst | qa', + input: { hint: 'dev|admin|analyst|qa' }, + handler: async ({ agent, rawInput }) => { + const cwd = currentDirectory(workingDirectory, agent) + const parsed = parseRole(rawInput, roleNames) + + if (parsed.kind === 'list') { + return { + kind: 'success', + text: listRoles({ cwd, rolesDir, projectRolesDir, roleNames }), + } + } + + if (parsed.kind === 'invalid') { + return { + kind: 'error', + text: `Неизвестная роль «${parsed.role}». Доступны: ${roleNames.join(', ')}.`, + } + } + + try { + const built = buildRoleText({ + role: parsed.role, + cwd, + rolesDir, + projectRolesDir, + roleNames, + }) + const message = createUserMessage({ + content: [{ type: 'text', text: built.text }], + source: { kind: 'user' }, + }) + + if (typeof agent.followup === 'function') { + agent.followup(message) + } else if (typeof agent.inject === 'function') { + agent.inject(message) + } else { + return { + kind: 'error', + text: 'Роль прочитана, но у агента нет followup/inject — передать правила в контекст не удалось.', + } + } + + const projectNote = built.projectMissing + ? ` Проектного файла роли ${parsed.role} нет, работаю по общему слою.` + : '' + return { + kind: 'success', + text: `Роль ${parsed.role} загружена (${built.globalPath}${built.projectMissing ? '' : ` + ${built.projectPath}`}).${projectNote}`, + } + } catch (error) { + logger?.warn?.('[dsh-harness] /role failed: %s', error?.message ?? error) + return { + kind: 'error', + text: String(error?.message ?? error), + } + } + }, + }) +} diff --git a/dsh/bundle/cordis.patch.yml b/dsh/bundle/cordis.patch.yml new file mode 100644 index 0000000..8e211b6 --- /dev/null +++ b/dsh/bundle/cordis.patch.yml @@ -0,0 +1,8 @@ +# dsh-harness: claude-harness port for DeepSeek Harness. +# +# The row is inserted by the bundle patch when this package is selected in the +# profile. All paths default relative to this package; override them in the +# profile patch only if the layout differs. +- insert: + - id: dsh-harness + name: '@local/dsh-harness' diff --git a/dsh/bundle/dsh-tools.md b/dsh/bundle/dsh-tools.md new file mode 100644 index 0000000..e786051 --- /dev/null +++ b/dsh/bundle/dsh-tools.md @@ -0,0 +1,46 @@ +# DSH tool mapping (claude-harness port) + +Skills speak in actions ("read a file", "dispatch a subagent", "create a todo"). +On DeepSeek Harness these actions resolve to the tools below. + +| Action | DSH tool | +|---|---| +| Read a file | `read` | +| Read several files | `read` once per file, or `grep`/`glob` first | +| Write a file | `write` | +| Edit a file | `edit` | +| Run a shell command | `bash` | +| Search file contents | `grep` | +| Find files by name | `glob` | +| Fetch a URL / web search | `web_fetch` / `web_search` | +| Dispatch a subagent | `subagent` (asynchronous) or `workflow` (synchronous fan-out) | +| Continue a child | `send_message` | +| List children | `list_agents` | +| Create / update todos | `todo_write` | +| Ask the user | `ask_user_question` | +| Load a skill | `skill` | +| Present a deliverable | `present` | +| Approve a plan | `exit_plan_mode` | +| Change directory | `working_directory` | +| Create a worktree | `create_worktree` when the bundle is enabled, otherwise `bash` with `git worktree` | + +## Important differences + +- **No `superpowers:` namespace.** A skill is named in kebab-case and loaded as + `skill({ name: "brainstorming" })`, not `superpowers:brainstorming`. +- **`subagent` does not wait.** It returns `started subagent ` and the + completion notice arrives later. Do not expect the child's answer in the same + tool result. For a synchronous fan-out where the parent needs every result in + one call, use `workflow`; for one child with follow-up messages, use + `subagent` + `send_message`. +- **No implicit model tiers.** There is no built-in `sonnet`/`haiku`/`opus`. + Use the provider/model routes that are available in this session. When a + skill asks for a model tier, map it to an explicit `provider`/`model` pair + from the live catalog. +- **Instructions files are `AGENTS.md` / `CLAUDE.md`.** DSH loads them from the + harness home, the shared agents home, and the project root down to the + working directory. The `CLAUDE.md` files of this harness need no rewrite. +- **`todo_write` is the task-tracking action.** Older `TodoWrite` references + mean this tool. +- **Plan mode is a real mode.** Use `exit_plan_mode` to submit a plan; prose + agreement does not leave plan mode. diff --git a/dsh/bundle/hooks b/dsh/bundle/hooks new file mode 120000 index 0000000..2777e15 --- /dev/null +++ b/dsh/bundle/hooks @@ -0,0 +1 @@ +../../hooks \ No newline at end of file diff --git a/dsh/bundle/icon.svg b/dsh/bundle/icon.svg new file mode 100644 index 0000000..5582bae --- /dev/null +++ b/dsh/bundle/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dsh/bundle/index.js b/dsh/bundle/index.js new file mode 100644 index 0000000..e3ed3d1 --- /dev/null +++ b/dsh/bundle/index.js @@ -0,0 +1,168 @@ +/** + * dsh-harness — the DeepSeek Harness adapter for claude-harness. + * + * Three Claude Code integration points are replaced by Cordis hooks: + * - PreToolUse Edit/Write/Bash -> `ctx.tools.guard` calling the existing + * `hooks/guard-main.sh` and `hooks/require-plan.sh` through a thin adapter; + * - SessionStart superpowers -> `agent/pre-step` injecting + * `using-superpowers` once per session (and again after compaction); + * - `/role` skill -> a real `/role` command registering on + * `ctx.commands`. + * + * Everything else (CLAUDE.md, roles, OpenSpec, Gitea, codegraph, project + * templates) stays in the repository and is consumed as ordinary files. + */ + +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { z } from '@deepseek-ai/schemastery' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { registerRoleCommand } from './command-role.js' +import { createGuard } from './lib/guards.js' +import { isBootstrapVisible, prepareBootstrap } from './lib/bootstrap.js' +import { DEFAULT_ROLE_NAMES } from './lib/role.js' + +export const name = 'dsh-harness' +export const inject = ['tools', 'workingDirectory'] + +export const Config = z.object({ + enabled: z.boolean().default(true), + guards: z.boolean().default(true), + bootstrap: z.boolean().default(true), + bootstrapSubagents: z.boolean().default(false), + roleCommand: z.boolean().default(true), + hooksDir: z.string().default(''), + rolesDir: z.string().default(''), + projectRolesDir: z.string().default(''), + roleNames: z.array(z.string()).default(DEFAULT_ROLE_NAMES), + bootstrapSkillPath: z.string().default(''), + toolMappingPath: z.string().default(''), + bootstrapMaxBytes: z.number().default(65_536), +}) + +/** Resolve the hook directory for both a linked checkout and a packed package. */ +function resolveHooksDir(bundleDir, configured) { + if (configured) return configured + const local = join(bundleDir, 'hooks') + if (existsSync(join(local, 'guard-main.sh'))) return local + return join(bundleDir, '..', '..', 'hooks') +} + +/** Prefer the installed global roles, then the repository's source roles. */ +function resolveRolesDir(bundleDir, configured) { + if (configured) return configured + const installed = join(homedir(), '.claude', 'roles') + if (existsSync(join(installed, 'dev.md'))) return installed + const source = join(bundleDir, '..', '..', 'roles') + if (existsSync(join(source, 'dev.md'))) return source + return installed +} + + +function isSubagent(agent) { + if (!agent) return false + if (agent.parent || agent.parentAgent || agent.parentSessionId) return true + if (typeof agent.depth === 'number' && agent.depth > 0) return true + return false +} + +function sessionCwd(workingDirectory, agent) { + if (agent?.session) { + try { + const cwd = workingDirectory.get(agent.session) + if (typeof cwd === 'string' && cwd !== '') return cwd + } catch { + // Fall through to the process directory. + } + } + return process.cwd() +} + +const DEFAULTS = { + enabled: true, + guards: true, + bootstrap: true, + bootstrapSubagents: false, + roleCommand: true, + hooksDir: '', + rolesDir: '', + projectRolesDir: '', + roleNames: DEFAULT_ROLE_NAMES, + bootstrapSkillPath: '', + toolMappingPath: '', + bootstrapMaxBytes: 65_536, +} + +export function apply(ctx, rawConfig = {}) { + const config = { ...DEFAULTS, ...(rawConfig ?? {}) } + if (!Array.isArray(config.roleNames) || config.roleNames.length === 0) { + config.roleNames = DEFAULT_ROLE_NAMES + } + const bundleDir = dirname(fileURLToPath(import.meta.url)) + const hooksDir = resolveHooksDir(bundleDir, config.hooksDir) + const rolesDir = resolveRolesDir(bundleDir, config.rolesDir) + const guard = createGuard({ hooksDir, logger: ctx.logger }) + + if (config.enabled && config.guards) { + // A synchronous, monotonic guard: it runs after the extensible + // `tools/pre-execute` waterfall, so no other listener can turn a denial + // back into permission. + ctx.tools.guard((exec) => { + const reason = guard.check(exec, sessionCwd(ctx.workingDirectory, exec.agent)) + if (reason !== undefined) { + ctx.logger?.info?.('[dsh-harness] blocked %s: %s', exec.name, reason.split('\n')[0]) + } + return reason + }) + } + + if (config.enabled && config.bootstrap) { + ctx.on('agent/pre-step', async ({ agent, signal }, next) => { + const decision = await next() + if (decision.kind === 'reject' || signal?.aborted) return decision + if (!config.bootstrapSubagents && isSubagent(agent)) return decision + + const prepared = prepareBootstrap({ + skillPath: config.bootstrapSkillPath, + mappingPath: config.toolMappingPath || join(bundleDir, 'dsh-tools.md'), + maxBytes: config.bootstrapMaxBytes, + logger: ctx.logger, + }) + if (!prepared) return decision + if (isBootstrapVisible(agent, decision.messages, prepared.marker)) return decision + + return { + ...decision, + messages: [ + ...(decision.messages ?? []), + createUserMessage({ + content: [{ type: 'text', text: prepared.text }], + source: { kind: 'user' }, + }), + ], + } + }) + } + + if (config.enabled && config.roleCommand) { + ctx.inject(['commands'], (commandCtx) => { + registerRoleCommand({ + commands: commandCtx.commands, + workingDirectory: ctx.workingDirectory, + rolesDir, + projectRolesDir: config.projectRolesDir, + roleNames: config.roleNames, + logger: ctx.logger, + }) + }) + } + + ctx.logger?.info?.( + '[dsh-harness] ready: hooks=%s, bootstrap=%s, /role=%s', + config.guards ? hooksDir : 'off', + config.bootstrap ? 'on' : 'off', + config.roleCommand ? 'on' : 'off', + ) +} diff --git a/dsh/bundle/lib/bootstrap.js b/dsh/bundle/lib/bootstrap.js new file mode 100644 index 0000000..d4ace29 --- /dev/null +++ b/dsh/bundle/lib/bootstrap.js @@ -0,0 +1,212 @@ +/** + * Superpowers bootstrap for DeepSeek Harness. + * + * Claude Code delivers `using-superpowers` through a SessionStart hook. DSH has + * no shell hook layer, so this module builds the same bootstrap text and the + * plugin injects it once per session from `agent/pre-step`. The marker at the + * top makes the injection idempotent and lets a later step re-inject after + * compaction (the old message is no longer in `session.surface.nodes`). + */ + +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const DEFAULT_MAPPING = [ + 'Use the tools reported by the harness. In particular:', + '- read/write/edit/bash for files and commands;', + '- skill({ name }) to load a skill (no `superpowers:` namespace);', + '- todo_write for task tracking;', + '- subagent for asynchronous delegation, or workflow for synchronous fan-out.', +].join('\n') + +const cache = new Map() + +/** Remove a leading YAML frontmatter block, if present. */ +export function stripFrontmatter(text) { + if (typeof text !== 'string') return '' + return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim() +} + +/** Truncate to a UTF-8 byte budget, keeping a visible notice. */ +export function truncateUtf8(text, maxBytes) { + const limit = Math.max(1024, Number(maxBytes) || 65_536) + if (Buffer.byteLength(text, 'utf8') <= limit) return text + const suffix = '\n\n[bootstrap truncated by dsh-harness maxBytes]\n' + const head = Buffer.from(text, 'utf8') + .subarray(0, Math.max(0, limit - Buffer.byteLength(suffix, 'utf8'))) + .toString('utf8') + .replace(/\uFFFD+$/u, '') + return head + suffix +} + +/** Sort dotted version directory names without a semver dependency. */ +export function compareVersions(a, b) { + const pa = String(a).split(/[.-]/) + const pb = String(b).split(/[.-]/) + const n = Math.max(pa.length, pb.length) + for (let i = 0; i < n; i += 1) { + const x = pa[i] ?? '' + const y = pb[i] ?? '' + const nx = Number(x) + const ny = Number(y) + if (Number.isFinite(nx) && Number.isFinite(ny) && nx !== ny) return nx - ny + const c = String(x).localeCompare(String(y), undefined, { numeric: true }) + if (c !== 0) return c + } + return 0 +} + +/** Candidate `using-superpowers/SKILL.md` paths, most specific first. */ +export function superpowersCandidates({ home = homedir(), env = process.env } = {}) { + const out = [] + if (env.DSH_SUPERPOWERS_SKILL) out.push(env.DSH_SUPERPOWERS_SKILL) + + const root = join(home, '.claude', 'plugins', 'cache', 'claude-plugins-official', 'superpowers') + if (existsSync(root)) { + let entries = [] + try { + entries = readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(compareVersions) + .reverse() + } catch { + entries = [] + } + for (const version of entries) { + out.push(join(root, version, 'skills', 'using-superpowers', 'SKILL.md')) + } + } + + out.push(join(home, '.claude', 'skills', 'using-superpowers', 'SKILL.md')) + out.push(join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md')) + out.push(join(home, '.agents', 'skills', 'using-superpowers', 'SKILL.md')) + return out +} + +/** Resolve the skill file: explicit config, env override, installed plugin cache, then skill roots. */ +export function resolveBootstrapSkill({ configuredPath = '', env = process.env, home = homedir() } = {}) { + if (configuredPath && existsSync(configuredPath)) return configuredPath + return superpowersCandidates({ home, env }).find((path) => existsSync(path)) +} + +/** Build the exact text injected before the first model step. */ +export function buildBootstrapText({ skillBody, mapping, marker }) { + return [ + marker, + '', + 'You have superpowers.', + '', + "**Below is the full content of your 'using-superpowers' skill — your introduction to using skills. On this harness, load other skills with the `skill` tool. There is no `superpowers:` namespace; use the skill name, for example `brainstorming`.**", + '', + skillBody, + '', + '## DSH tool mapping', + '', + mapping, + '', + '', + ].join('\n') +} + +function statKey(path) { + try { + const stat = statSync(path) + return `${stat.mtimeMs}:${stat.size}` + } catch { + return '' + } +} + +/** + * Build (or reuse) the bootstrap payload. + * @returns {{path: string, marker: string, text: string}|undefined} + */ +export function prepareBootstrap({ + skillPath = '', + mappingPath = '', + maxBytes = 65_536, + logger, + env = process.env, + home = homedir(), +} = {}) { + const resolved = resolveBootstrapSkill({ configuredPath: skillPath, env, home }) + if (!resolved) { + logger?.warn?.('[dsh-harness] superpowers bootstrap skill not found; skipping injection') + return undefined + } + + const mappingFile = mappingPath && existsSync(mappingPath) ? mappingPath : '' + const key = `${resolved}|${mappingFile}|${maxBytes}` + const fingerprint = `${statKey(resolved)}|${mappingFile ? statKey(mappingFile) : 'default'}` + const hit = cache.get(key) + if (hit && hit.fingerprint === fingerprint) return hit.value + + let skillText + try { + skillText = readFileSync(resolved, 'utf8') + } catch (error) { + logger?.warn?.('[dsh-harness] cannot read superpowers bootstrap %s: %s', resolved, error?.message ?? error) + return undefined + } + + const skillBody = stripFrontmatter(skillText) + if (!skillBody) { + logger?.warn?.('[dsh-harness] superpowers bootstrap is empty: %s', resolved) + return undefined + } + + let mapping = DEFAULT_MAPPING + if (mappingFile) { + try { + mapping = readFileSync(mappingFile, 'utf8').trim() || DEFAULT_MAPPING + } catch { + mapping = DEFAULT_MAPPING + } + } + + const hash = createHash('sha256').update(skillBody).update('\0').update(mapping).digest('hex').slice(0, 16) + const marker = `` + const text = truncateUtf8(buildBootstrapText({ skillBody, mapping, marker }), maxBytes) + const value = { path: resolved, marker, text } + cache.set(key, { fingerprint, value }) + return value +} + +/** Read model-visible text from a session message or a session event payload. */ +export function textOf(value) { + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map(textOf).join('\n') + if (!value || typeof value !== 'object') return '' + if (Array.isArray(value.content)) return value.content.map(textOf).join('\n') + if (typeof value.content === 'string') return value.content + if (typeof value.text === 'string') return value.text + return '' +} + +/** + * Whether this exact bootstrap marker is still visible in the session. + * Messages under consideration by the current pre-step are checked first; + * then the durable visible surface, which drops a message after compaction. + */ +export function isBootstrapVisible(agent, messages, marker) { + if (!marker) return false + if (Array.isArray(messages) && messages.some((message) => textOf(message).includes(marker))) return true + + const session = agent?.session + if (!session || typeof session.eventAt !== 'function') return false + const nodes = session.surface?.nodes + if (!nodes) return false + + for (const seq of new Set(nodes)) { + try { + const event = session.eventAt(seq) + if (event?.type === 'user/message' && textOf(event.data).includes(marker)) return true + } catch { + // A seq outside the current surface is not evidence of visibility. + } + } + return false +} diff --git a/dsh/bundle/lib/guards.js b/dsh/bundle/lib/guards.js new file mode 100644 index 0000000..60561d5 --- /dev/null +++ b/dsh/bundle/lib/guards.js @@ -0,0 +1,108 @@ +/** + * Thin adapter from DeepSeek Harness tool calls to the claude-harness bash hooks. + * + * The guard logic stays in `hooks/harness_target.py` (the source of truth, also + * covered by the repository's own tests). This module only: + * - maps DSH tool names to the Claude hook vocabulary; + * - synthesizes the `{ cwd, tool_name, tool_input }` payload; + * - runs `guard-main.sh` / `require-plan.sh`; + * - turns `exit 2` into a denial reason and every other failure into a pass, + * preserving the harness invariant "a parsing failure must not block". + */ + +import { execFileSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +/** DSH tool name -> Claude Code tool name used by the hooks. */ +const TOOL_NAMES = { + write: 'Write', + edit: 'Edit', + multi_edit: 'MultiEdit', + str_replace_editor: 'Edit', + bash: 'Bash', +} + +/** Tools that `require-plan.sh` must inspect in addition to `guard-main.sh`. */ +const PLAN_TOOLS = new Set(['write', 'edit', 'multi_edit', 'str_replace_editor']) + +const DEFAULT_TIMEOUT_MS = 15_000 + +/** + * Run one hook script with the synthesized payload. + * @returns the denial text for exit 2, otherwise undefined. + */ +export function runHook({ script, hooksDir, payload, logger, timeoutMs = DEFAULT_TIMEOUT_MS }) { + const path = join(hooksDir, script) + if (!existsSync(path)) { + logger?.warn?.('[dsh-harness] hook not found: %s', path) + return undefined + } + try { + execFileSync('bash', [path], { + input: JSON.stringify(payload), + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: timeoutMs, + }) + return undefined + } catch (error) { + if (error?.status === 2) { + const reason = String(error.stderr ?? '').trim() + return reason || `BLOCKED by ${script}` + } + // Fail open: a broken hook must never stop the agent. + logger?.warn?.('[dsh-harness] %s failed open: %s', script, error?.message ?? String(error)) + return undefined + } +} + +/** Normalize the DSH arguments into the shape `harness_target.py` reads. */ +export function normalizeToolInput(toolName, args) { + const input = { ...(args ?? {}) } + if (toolName !== 'Bash') { + const path = input.file_path ?? input.path + if (typeof path === 'string' && path !== '') input.file_path = path + } + return input +} + +/** + * Decide one tool call. + * @param {object} options + * @param {{name: string, arguments?: object}} options.exec - DSH tool execution. + * @param {string} options.cwd - Session working directory. + * @param {string} options.hooksDir - Directory holding guard-main.sh. + * @param {object} [options.logger] - Cordis logger. + * @returns {string|undefined} denial reason. + */ +export function checkTool({ exec, cwd, hooksDir, logger }) { + if (!cwd || !exec || typeof exec.name !== 'string') return undefined + const toolName = TOOL_NAMES[exec.name] + if (!toolName) return undefined + + const payload = { + cwd, + tool_name: toolName, + tool_input: normalizeToolInput(toolName, exec.arguments), + } + + const guardReason = runHook({ script: 'guard-main.sh', hooksDir, payload, logger }) + if (guardReason !== undefined) return guardReason + + if (PLAN_TOOLS.has(exec.name)) { + const planReason = runHook({ script: 'require-plan.sh', hooksDir, payload, logger }) + if (planReason !== undefined) return planReason + } + + return undefined +} + +/** Bind the module to one bundle configuration. */ +export function createGuard({ hooksDir, logger }) { + return { + check(exec, cwd) { + return checkTool({ exec, cwd, hooksDir, logger }) + }, + } +} diff --git a/dsh/bundle/lib/role.js b/dsh/bundle/lib/role.js new file mode 100644 index 0000000..2417a1d --- /dev/null +++ b/dsh/bundle/lib/role.js @@ -0,0 +1,113 @@ +/** + * Role loading for the `/role` command. + * + * The file format stays the claude-harness format: a global layer + * (`~/.claude/roles/.md`) and an optional project layer + * (`/.claude/roles/.md`). The project layer wins on conflict. + * Auto-memory is Claude Code-only; this port explicitly drops that step and + * leaves the role's "Читать на старте" section to the model. + */ + +import { existsSync, readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +export const DEFAULT_ROLE_NAMES = ['dev', 'admin', 'analyst', 'qa'] + +/** Parse `/role [name]` without owning any grammar beyond the first word. */ +export function parseRole(rawInput, roleNames = DEFAULT_ROLE_NAMES) { + const input = String(rawInput ?? '').trim() + if (input === '') return { kind: 'list' } + const role = input.split(/\s+/u)[0] + if (!roleNames.includes(role)) return { kind: 'invalid', role } + return { kind: 'role', role } +} + +/** Absolute paths of both layers for one role. */ +export function rolePaths({ role, cwd, rolesDir = join(homedir(), '.claude', 'roles'), projectRolesDir = '' }) { + const globalPath = join(rolesDir, `${role}.md`) + const projectPath = join(projectRolesDir || join(cwd, '.claude', 'roles'), `${role}.md`) + return { globalPath, projectPath } +} + +function readIfPresent(path) { + try { + return existsSync(path) ? readFileSync(path, 'utf8') : undefined + } catch { + return undefined + } +} + +/** Render the list shown by a bare `/role`. */ +export function listRoles({ cwd, rolesDir, projectRolesDir, roleNames = DEFAULT_ROLE_NAMES }) { + const lines = [ + `Роли claude-harness; общий слой: ${rolesDir || join(homedir(), '.claude', 'roles')}`, + `Workspace: ${cwd}`, + '', + ] + for (const role of roleNames) { + const { globalPath, projectPath } = rolePaths({ role, cwd, rolesDir, projectRolesDir }) + const global = existsSync(globalPath) ? 'есть' : 'нет' + const project = existsSync(projectPath) ? 'есть' : 'нет' + lines.push(`- ${role}: общий — ${global}; проектный — ${project}`) + } + lines.push('', 'Вызов: /role dev | /role admin | /role analyst | /role qa') + return lines.join('\n') +} + +/** + * Build the model-visible role instructions. + * @throws when the global role file is missing. + */ +export function buildRoleText({ role, cwd, rolesDir, projectRolesDir, roleNames = DEFAULT_ROLE_NAMES }) { + if (!roleNames.includes(role)) throw new Error(`Неизвестная роль: ${role}`) + + const { globalPath, projectPath } = rolePaths({ role, cwd, rolesDir, projectRolesDir }) + const globalText = readIfPresent(globalPath) + if (globalText === undefined) { + throw new Error(`Нет общего файла роли: ${globalPath}`) + } + const projectText = readIfPresent(projectPath) + const projectMissing = projectText === undefined + + const sections = [ + `[dsh-harness] Роль сессии: ${role}. Workspace: ${cwd}`, + 'Новая роль заменяет предыдущую целиком: правила прежней роли больше не действуют.', + '', + `## Общий слой: ${globalPath}`, + globalText.trim(), + '', + ] + + if (projectMissing) { + sections.push( + `## Проектный слой: ${projectPath}`, + `Проектного файла роли ${role} нет — работаю по общему слою.`, + '', + ) + } else { + sections.push(`## Проектный слой: ${projectPath}`, projectText.trim(), '') + } + + sections.push( + '## Память', + 'Auto-memory в DeepSeek Harness нет. Раздел «Читать на старте» выполни сам: пути читай через read,', + 'команды запускай через bash и только на чтение. Строки с префиксом `brief:` в этом spike не поддерживаются —', + 'пропусти их и скажи об этом одной строкой.', + '', + '## Ответ', + 'Ответь ровно пятью строками:', + '- Роль: — <описание из первой строки файла роли>.', + '- Процесс: <одна строка из раздела «Процесс» — что действует по умолчанию>.', + '- Прочитано: <перечень файлов обоих слоёв и файлов памяти, через запятую>.', + '- Не делать: <раздел «Не делать» одной строкой>.', + '- Ждёт решения владельца: <из памяти роли; если нет — «ничего»>.', + ) + + return { + text: sections.join('\n'), + globalPath, + projectPath, + projectMissing, + } +} diff --git a/dsh/bundle/locale/en.json b/dsh/bundle/locale/en.json new file mode 100644 index 0000000..3c25005 --- /dev/null +++ b/dsh/bundle/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DSH Harness", + "description": "Claude-harness port for DeepSeek Harness: main-branch guards, superpowers bootstrap, and the /role command." + } +} diff --git a/dsh/bundle/locale/ru.json b/dsh/bundle/locale/ru.json new file mode 100644 index 0000000..028593a --- /dev/null +++ b/dsh/bundle/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DSH Harness", + "description": "Порт claude-harness для DeepSeek Harness: запрет записи на main, bootstrap superpowers и команда /role." + } +} diff --git a/dsh/bundle/package.json b/dsh/bundle/package.json new file mode 100644 index 0000000..7542b74 --- /dev/null +++ b/dsh/bundle/package.json @@ -0,0 +1,27 @@ +{ + "name": "@local/dsh-harness", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "DeepSeek Harness port of claude-harness: main-branch guards, superpowers bootstrap, /role", + "exports": { + ".": "./index.js", + "./locale/*.json": "./locale/*.json", + "./icon": "./icon.svg" + }, + "files": [ + "index.js", + "command-role.js", + "lib", + "dsh-tools.md", + "cordis.patch.yml", + "hooks", + "locale/*.json", + "icon.svg" + ], + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + } +} diff --git a/dsh/install.sh b/dsh/install.sh new file mode 100755 index 0000000..0e88cd5 --- /dev/null +++ b/dsh/install.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# Install the DeepSeek Harness side of claude-harness. +# +# This script never installs the Cordis bundle into a profile: the Desktop +# profile is Electron-managed, so the bundle goes through the GUI Plugins page +# or the model-facing `plugin_manager` tool. The script prepares the global +# instructions and the user skill root, then prints the bundle path. +# +# Usage: +# bash dsh/install.sh --check +# bash dsh/install.sh --global +# bash dsh/install.sh --skills +# bash dsh/install.sh --all +# +# Run it from a terminal, not from inside a DSH session: --global and --skills +# write outside the session workspace and would require approval. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUNDLE="$ROOT/dsh/bundle" +DSH_HOME="${DSH_HOME:-$HOME/.dsh}" +DO_CHECK=0 +DO_GLOBAL=0 +DO_SKILLS=0 + +usage() { + cat < install a bundle from $BUNDLE +or ask the agent to call plugin_manager install_bundle with that target. +EOF +} + +if [ "$#" -eq 0 ]; then + DO_CHECK=1 +fi + +for arg in "$@"; do + case "$arg" in + --check) DO_CHECK=1 ;; + --global) DO_GLOBAL=1 ;; + --skills) DO_SKILLS=1 ;; + --all) DO_CHECK=1; DO_GLOBAL=1; DO_SKILLS=1 ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;; + esac +done + +superpowers_skills() { + local dir + dir="$(ls -d "$HOME"/.claude/plugins/cache/claude-plugins-official/superpowers/*/skills 2>/dev/null | sort -V | tail -1 || true)" + if [ -n "$dir" ] && [ -d "$dir" ]; then + printf '%s\n' "$dir" + return 0 + fi + return 1 +} + +check() { + local failed=0 + for cmd in python3 git node; do + if command -v "$cmd" >/dev/null 2>&1; then + echo "ok: $cmd ($("$cmd" --version 2>&1 | head -1))" + else + echo "WARN: $cmd not found" >&2 + failed=1 + fi + done + if [ -f "$ROOT/hooks/harness_target.py" ]; then + echo "ok: hooks/harness_target.py" + else + echo "FAIL: $ROOT/hooks/harness_target.py is missing" >&2 + failed=1 + fi + if [ -f "$ROOT/global/CLAUDE.md" ]; then + echo "ok: global/CLAUDE.md" + else + echo "FAIL: $ROOT/global/CLAUDE.md is missing" >&2 + failed=1 + fi + if [ -f "$BUNDLE/index.js" ] && [ -f "$BUNDLE/cordis.patch.yml" ]; then + echo "ok: bundle $BUNDLE" + else + echo "FAIL: bundle is incomplete: $BUNDLE" >&2 + failed=1 + fi + if [ -f "$BUNDLE/hooks/guard-main.sh" ] && [ -f "$BUNDLE/hooks/require-plan.sh" ]; then + echo "ok: hook bridge" + else + echo "FAIL: hook bridge is missing (is dsh/bundle/hooks a symlink to ../../hooks?)" >&2 + failed=1 + fi + if superpowers_skills >/dev/null 2>&1; then + echo "ok: superpowers skills ($(superpowers_skills))" + else + echo "WARN: superpowers plugin not found; the bootstrap will be skipped" >&2 + fi + if [ -f "$HOME/.claude/roles/dev.md" ]; then + echo "ok: roles (~/.claude/roles)" + elif [ -f "$ROOT/roles/dev.md" ]; then + echo "ok: roles ($ROOT/roles; ~/.claude/roles is not installed)" + else + echo "WARN: roles not found; /role will fail until ~/.claude/roles or $ROOT/roles exists" >&2 + fi + echo "DSH_HOME=$DSH_HOME" + return "$failed" +} + +install_global() { + mkdir -p "$DSH_HOME" + local target="$DSH_HOME/AGENTS.md" + if [ -f "$target" ]; then + local backup="$target.bak.$(date +%s)" + cp "$target" "$backup" + echo "backup: $backup" + fi + { + 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 ~/.claude/roles when installed, otherwise $ROOT/roles." + echo "DSH adapter: codegraph MCP tools are named mcp__codegraph__codegraph_explore." + echo + cat "$ROOT/global/CLAUDE.md" + } > "$target" + echo "written: $target" +} + +install_skills() { + mkdir -p "$DSH_HOME/skills" + local skills + if ! skills="$(superpowers_skills)"; then + echo "WARN: superpowers skills not found; nothing linked" >&2 + return 0 + fi + local count=0 + local dir name + for dir in "$skills"/*; do + [ -f "$dir/SKILL.md" ] || continue + name="$(basename "$dir")" + ln -sfn "$dir" "$DSH_HOME/skills/$name" + count=$((count + 1)) + done + echo "linked $count superpowers skills into $DSH_HOME/skills" +} + +print_bundle_step() { + cat < install a bundle from the directory above; + - or ask the agent to call plugin_manager install_bundle with target "$BUNDLE". +Do not edit \$DSH_HOME/profiles/desktop/package.json or cordis.patch.yml by hand. + +Optional: add CodeGraph as an MCP server from dsh/mcp.codegraph.yml. +EOF +} + +status=0 +if [ "$DO_CHECK" = 1 ]; then + check || status=1 +fi +if [ "$DO_GLOBAL" = 1 ]; then + install_global +fi +if [ "$DO_SKILLS" = 1 ]; then + install_skills +fi +if [ "$DO_GLOBAL" = 1 ] || [ "$DO_SKILLS" = 1 ]; then + print_bundle_step +fi + +exit "$status" diff --git a/dsh/mcp.codegraph.yml b/dsh/mcp.codegraph.yml new file mode 100644 index 0000000..a61a5fc --- /dev/null +++ b/dsh/mcp.codegraph.yml @@ -0,0 +1,18 @@ +# Optional: CodeGraph MCP for DeepSeek Harness. +# +# Apply as an extra patch layer to the profile, for example: +# dsh --profile web --patch /absolute/path/to/claude-harness/dsh/mcp.codegraph.yml +# or select the same row through the profile's plugin configuration. +# +# `codegraph` must be on PATH (`npm i -g @colbymchenry/codegraph`, or an +# absolute command below). The MCP tool appears as +# `mcp__codegraph__codegraph_explore`, not `codegraph_explore`. +- insert: + - id: mcp-codegraph + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: codegraph + transport: stdio + command: codegraph + args: ['serve', '--mcp'] + failOnStartupError: false diff --git a/dsh/tests/bootstrap.test.mjs b/dsh/tests/bootstrap.test.mjs new file mode 100644 index 0000000..bd2ddd6 --- /dev/null +++ b/dsh/tests/bootstrap.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict' +import { join } from 'node:path' +import test from 'node:test' +import { + buildBootstrapText, + isBootstrapVisible, + prepareBootstrap, + resolveBootstrapSkill, + stripFrontmatter, + truncateUtf8, +} from '../bundle/lib/bootstrap.js' +import { cleanup, tempDir, write } from './helpers.mjs' + +test('stripFrontmatter removes a YAML header', () => { + assert.equal(stripFrontmatter('---\nname: using-superpowers\n---\nbody\n'), 'body') + assert.equal(stripFrontmatter('body\n'), 'body') +}) + +test('truncateUtf8 respects the byte budget', () => { + const out = truncateUtf8(`\n${'x'.repeat(5000)}`, 2000) + assert.ok(Buffer.byteLength(out, 'utf8') <= 2000) + assert.match(out, /bootstrap truncated/) + assert.match(out, /^/) +}) + +test('prepareBootstrap builds a marker-first message with the mapping', () => { + const dir = tempDir() + try { + const skillPath = join(dir, 'SKILL.md') + const mappingPath = join(dir, 'dsh-tools.md') + write(skillPath, '---\nname: using-superpowers\ndescription: test\n---\n# Use skills\nNever skip the skill check.\n') + write(mappingPath, '| Read a file | read |\n') + + const prepared = prepareBootstrap({ skillPath, mappingPath, maxBytes: 65_536 }) + assert.ok(prepared) + assert.match(prepared.marker, /^$/) + assert.ok(prepared.text.startsWith(prepared.marker)) + assert.match(prepared.text, /Never skip the skill check/) + assert.match(prepared.text, /\| Read a file \| read \|/) + assert.doesNotMatch(prepared.text, /^---/) + assert.match(prepared.text, /EXTREMELY_IMPORTANT/) + } finally { + cleanup(dir) + } +}) + +test('buildBootstrapText is deterministic for the same content', () => { + const args = { + skillBody: '# Skill', + mapping: '| Read | read |', + marker: '', + } + assert.equal(buildBootstrapText(args), buildBootstrapText(args)) +}) + +test('isBootstrapVisible sees the marker in messages and in the visible surface', () => { + const marker = '' + assert.equal(isBootstrapVisible({}, [{ content: [{ type: 'text', text: marker }] }], marker), true) + assert.equal(isBootstrapVisible({}, [{ content: [{ type: 'text', text: 'hello' }] }], marker), false) + + const event = { type: 'user/message', data: { content: [{ type: 'text', text: `prefix ${marker}` }] } } + const visibleAgent = { + session: { + seq: 2, + surface: { nodes: new Set([1]) }, + eventAt: (seq) => (seq === 1 ? event : undefined), + }, + } + assert.equal(isBootstrapVisible(visibleAgent, [], marker), true) + + const compactedAgent = { + session: { seq: 3, surface: { nodes: new Set() }, eventAt: () => undefined }, + } + assert.equal(isBootstrapVisible(compactedAgent, [], marker), false) +}) + +test('resolveBootstrapSkill prefers an explicit path and finds the user root', () => { + const home = tempDir() + try { + const installed = join(home, '.dsh', 'skills', 'using-superpowers', 'SKILL.md') + write(installed, '---\nname: using-superpowers\n---\nbody\n') + assert.equal(resolveBootstrapSkill({ home, env: {} }), installed) + + const explicit = join(home, 'explicit.md') + write(explicit, 'explicit\n') + assert.equal(resolveBootstrapSkill({ configuredPath: explicit, home, env: {} }), explicit) + } finally { + cleanup(home) + } +}) diff --git a/dsh/tests/guard.test.mjs b/dsh/tests/guard.test.mjs new file mode 100644 index 0000000..4c210c3 --- /dev/null +++ b/dsh/tests/guard.test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' +import { checkTool } from '../bundle/lib/guards.js' +import { cleanup, git, makeRepo, write } from './helpers.mjs' + +const hooksDir = fileURLToPath(new URL('../bundle/hooks/', import.meta.url)) + +function call(name, args) { + return { name, arguments: args } +} + +test('guard-main blocks a write on main', () => { + const repo = makeRepo() + try { + const reason = checkTool({ + exec: call('write', { path: join(repo, 'a.txt'), content: 'x' }), + cwd: repo, + hooksDir, + }) + assert.match(reason ?? '', /BLOCKED \[guard-main\]/) + } finally { + cleanup(repo) + } +}) + +test('guard-main allows a write on a feature branch', () => { + const repo = makeRepo() + try { + git(repo, ['checkout', '-b', 'feat/x']) + const reason = checkTool({ + exec: call('write', { path: join(repo, 'a.txt'), content: 'x' }), + cwd: repo, + hooksDir, + }) + assert.equal(reason, undefined) + } finally { + cleanup(repo) + } +}) + +test('guard-main inspects bash commands on main', () => { + const repo = makeRepo() + try { + const blocked = checkTool({ + exec: call('bash', { command: 'echo hi > a.txt' }), + cwd: repo, + hooksDir, + }) + assert.match(blocked ?? '', /BLOCKED \[guard-main\]/) + + const allowed = checkTool({ + exec: call('bash', { command: 'git status --short' }), + cwd: repo, + hooksDir, + }) + assert.equal(allowed, undefined) + } finally { + cleanup(repo) + } +}) + +test('require-plan blocks src edits without a plan or .bounded', () => { + const repo = makeRepo() + try { + git(repo, ['checkout', '-b', 'feat/x']) + write(join(repo, 'src', '.keep'), '') + const blocked = checkTool({ + exec: call('write', { path: join(repo, 'src', 'a.ts'), content: 'export {}\n' }), + cwd: repo, + hooksDir, + }) + assert.match(blocked ?? '', /BLOCKED \[require-plan\]/) + + write(join(repo, '.bounded'), '') + const bounded = checkTool({ + exec: call('write', { path: join(repo, 'src', 'a.ts'), content: 'export {}\n' }), + cwd: repo, + hooksDir, + }) + assert.equal(bounded, undefined) + } finally { + cleanup(repo) + } +}) + +test('require-plan accepts a plan that names the branch', () => { + const repo = makeRepo() + try { + git(repo, ['checkout', '-b', 'feat/x']) + write(join(repo, 'src', '.keep'), '') + write(join(repo, 'docs', 'superpowers', 'plans', 'test.md'), '# plan for feat/x\n') + const reason = checkTool({ + exec: call('edit', { path: join(repo, 'src', 'a.ts'), old_string: 'a', new_string: 'b' }), + cwd: repo, + hooksDir, + }) + assert.equal(reason, undefined) + } finally { + cleanup(repo) + } +}) + +test('guard-main blocks an edit on main before require-plan runs', () => { + const repo = makeRepo() + try { + const reason = checkTool({ + exec: call('edit', { path: join(repo, 'README.md'), old_string: 'test', new_string: 'changed' }), + cwd: repo, + hooksDir, + }) + assert.match(reason ?? '', /BLOCKED \[guard-main\]/) + } finally { + cleanup(repo) + } +}) diff --git a/dsh/tests/helpers.mjs b/dsh/tests/helpers.mjs new file mode 100644 index 0000000..ce2e22f --- /dev/null +++ b/dsh/tests/helpers.mjs @@ -0,0 +1,37 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +export function tempDir(prefix = 'dsh-harness-') { + return mkdtempSync(join(tmpdir(), prefix)) +} + +export function write(path, content) { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content, 'utf8') +} + +export function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() +} + +export function makeRepo() { + const repo = tempDir('dsh-harness-repo-') + 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']) + write(join(repo, 'README.md'), '# test\n') + git(repo, ['add', 'README.md']) + git(repo, ['commit', '-m', 'init']) + return repo +} + +export function cleanup(path) { + rmSync(path, { recursive: true, force: true }) +} diff --git a/dsh/tests/package.test.mjs b/dsh/tests/package.test.mjs new file mode 100644 index 0000000..9dba3f3 --- /dev/null +++ b/dsh/tests/package.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict' +import { existsSync, readFileSync, readlinkSync, lstatSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const dshDir = fileURLToPath(new URL('..', import.meta.url)) +const bundleDir = join(dshDir, 'bundle') +const pkg = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) + +test('package declares a DSH bundle patch and the runtime files', () => { + assert.equal(pkg.name, '@local/dsh-harness') + assert.equal(pkg.type, 'module') + assert.equal(pkg.dsh?.bundle?.patch, './cordis.patch.yml') + assert.equal(pkg.exports?.['.'], './index.js') + assert.ok(pkg.files.includes('index.js')) + assert.ok(pkg.files.includes('lib')) + assert.ok(pkg.files.includes('hooks')) + assert.ok(existsSync(join(bundleDir, 'cordis.patch.yml'))) +}) + +test('bundle patch inserts the harness row', () => { + const patch = readFileSync(join(bundleDir, 'cordis.patch.yml'), 'utf8') + assert.match(patch, /id: dsh-harness/) + assert.match(patch, /name: '@local\/dsh-harness'/) +}) + +test('display resources and hook bridge exist', () => { + for (const file of ['icon.svg', 'locale/en.json', 'locale/ru.json', 'dsh-tools.md']) { + assert.ok(existsSync(join(bundleDir, file)), `${file} exists`) + } + assert.ok(existsSync(join(bundleDir, 'hooks', 'guard-main.sh'))) + assert.ok(existsSync(join(bundleDir, 'hooks', 'require-plan.sh'))) + assert.match(readFileSync(join(bundleDir, 'dsh-tools.md'), 'utf8'), /subagent/) +}) + +test('hook directory is a link to the repository source of truth', () => { + const hooks = join(bundleDir, 'hooks') + const stat = lstatSync(hooks) + if (stat.isSymbolicLink()) { + assert.equal(readlinkSync(hooks), '../../hooks') + } +}) + +test('index.js registers all three integration points', () => { + const source = readFileSync(join(bundleDir, 'index.js'), 'utf8') + assert.match(source, /tools\.guard/) + assert.match(source, /agent\/pre-step/) + assert.match(source, /registerRoleCommand/) + assert.match(source, /createGuard/) + assert.match(source, /prepareBootstrap/) +}) diff --git a/dsh/tests/role.test.mjs b/dsh/tests/role.test.mjs new file mode 100644 index 0000000..c635b0d --- /dev/null +++ b/dsh/tests/role.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict' +import { join } from 'node:path' +import test from 'node:test' +import { buildRoleText, listRoles, parseRole } from '../bundle/lib/role.js' +import { cleanup, tempDir, write } from './helpers.mjs' + +const names = ['dev', 'admin', 'analyst', 'qa'] + +test('parseRole distinguishes list, role, and invalid input', () => { + assert.deepEqual(parseRole('', names), { kind: 'list' }) + assert.deepEqual(parseRole('dev', names), { kind: 'role', role: 'dev' }) + assert.deepEqual(parseRole(' admin ', names), { kind: 'role', role: 'admin' }) + assert.deepEqual(parseRole('nope', names), { kind: 'invalid', role: 'nope' }) +}) + +test('buildRoleText reads the global layer and notes the missing project layer', () => { + const root = tempDir() + try { + const rolesDir = join(root, 'roles') + const project = join(root, 'project') + write(join(rolesDir, 'dev.md'), '# Роль: dev\n\n## Процесс\n- TDD\n\n## Не делать\n- не писать в main\n') + + const built = buildRoleText({ + role: 'dev', + cwd: project, + rolesDir, + projectRolesDir: join(project, '.claude', 'roles'), + roleNames: names, + }) + + assert.match(built.text, /Роль сессии: dev/) + assert.match(built.text, /# Роль: dev/) + assert.match(built.text, /TDD/) + assert.match(built.text, /Проектного файла роли dev нет/) + assert.equal(built.projectMissing, true) + } finally { + cleanup(root) + } +}) + +test('buildRoleText includes the project layer when it exists', () => { + const root = tempDir() + try { + const rolesDir = join(root, 'roles') + const project = join(root, 'project') + write(join(rolesDir, 'dev.md'), '# Роль: dev\n- общий\n') + write(join(project, '.claude', 'roles', 'dev.md'), '# Роль: dev (проект)\n- проектный\n') + + const built = buildRoleText({ + role: 'dev', + cwd: project, + rolesDir, + projectRolesDir: join(project, '.claude', 'roles'), + roleNames: names, + }) + + assert.match(built.text, /- общий/) + assert.match(built.text, /- проектный/) + assert.equal(built.projectMissing, false) + } finally { + cleanup(root) + } +}) + +test('buildRoleText fails loudly without the global layer', () => { + const root = tempDir() + try { + assert.throws( + () => buildRoleText({ role: 'dev', cwd: root, rolesDir: join(root, 'roles'), roleNames: names }), + /Нет общего файла роли/, + ) + } finally { + cleanup(root) + } +}) + +test('listRoles reports both layers', () => { + const root = tempDir() + try { + const rolesDir = join(root, 'roles') + const project = join(root, 'project') + write(join(rolesDir, 'dev.md'), '# dev\n') + write(join(project, '.claude', 'roles', 'qa.md'), '# qa\n') + + const text = listRoles({ + cwd: project, + rolesDir, + projectRolesDir: join(project, '.claude', 'roles'), + roleNames: names, + }) + assert.match(text, /dev: общий — есть; проектный — нет/) + assert.match(text, /qa: общий — нет; проектный — есть/) + } finally { + cleanup(root) + } +}) diff --git a/dsh/tests/run.sh b/dsh/tests/run.sh new file mode 100755 index 0000000..9254d17 --- /dev/null +++ b/dsh/tests/run.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Tests for the DSH port. Pure Node, no DSH runtime required. +set -euo pipefail +root="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$root" + +for f in dsh/bundle/index.js dsh/bundle/command-role.js dsh/bundle/lib/*.js dsh/tests/*.mjs; do + node --check "$f" +done + +node --test dsh/tests/*.test.mjs -- 2.47.2 From 7e143be61a30f691d7133bb091b395e244d41425 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 14:59:50 +0400 Subject: [PATCH 2/7] =?UTF-8?q?docs(dsh):=20composition=20smoke=20test=20?= =?UTF-8?q?=D0=B2=20temp-=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh/PORT.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dsh/PORT.md b/dsh/PORT.md index 69bc7e9..3f9eb39 100644 --- a/dsh/PORT.md +++ b/dsh/PORT.md @@ -206,6 +206,13 @@ They cover main/detached guards, branch allowance, Bash writes, `require-plan` with `.bounded` and a branch plan, bootstrap frontmatter/marker/dedupe, role-layer composition, and package metadata. +Composition smoke test (no model call): the bundle was installed with +`dsh plugin --profile test add dsh/bundle` into a temporary `DSH_HOME` based on +the shipped `headless` profile. `--dump-config` shows the `dsh-harness` row, the +profile lists `@local/dsh-harness` in `dsh.profile.bundles`, and the package's +internal `hooks` symlink resolves to the repository `hooks/`. The temporary +profile was removed; the real `desktop` profile was not touched. + ## 7. Risks and open questions - **DSH is 0.2.1-rc.3.** The plugin/event API is not frozen; pin the version and -- 2.47.2 From 7665218247d7e37a994568677914f7accb7acbaf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 15:31:39 +0400 Subject: [PATCH 3/7] =?UTF-8?q?fix(dsh):=20=D1=83=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D1=8B=20DSH-?= =?UTF-8?q?=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=BE=D0=B2=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=BD=D0=B4=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-10-24-dsh-port-spike.md | 2 +- dsh/PORT.md | 20 ++++++++++--- dsh/bundle/command-role.js | 2 +- dsh/bundle/index.js | 22 ++++----------- dsh/bundle/lib/message.js | 28 +++++++++++++++++++ dsh/tests/message.test.mjs | 24 ++++++++++++++++ dsh/tests/package.test.mjs | 14 ++++++++++ 7 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 dsh/bundle/lib/message.js create mode 100644 dsh/tests/message.test.mjs diff --git a/docs/superpowers/plans/2026-10-24-dsh-port-spike.md b/docs/superpowers/plans/2026-10-24-dsh-port-spike.md index 4e019ec..f79d81a 100644 --- a/docs/superpowers/plans/2026-10-24-dsh-port-spike.md +++ b/docs/superpowers/plans/2026-10-24-dsh-port-spike.md @@ -34,7 +34,7 @@ - [x] Bootstrap `using-superpowers`: резолв версии, marker, dedupe, compaction. - [x] `/role`: парсинг, два слоя, инъекция, followup. - [x] `dsh/install.sh`, `mcp.codegraph.yml`, README/PORT. -- [x] `bash dsh/tests/run.sh` — 22 теста зелёные. +- [x] `bash dsh/tests/run.sh` — 25 тестов зелёные. - [x] `bash tests/run.sh` — 630 тестов зелёные (регрессий нет). - [ ] Живая проверка в DSH: маркер, guard на main, `/role`, acceptance superpowers. diff --git a/dsh/PORT.md b/dsh/PORT.md index 3f9eb39..1ddc38f 100644 --- a/dsh/PORT.md +++ b/dsh/PORT.md @@ -90,10 +90,14 @@ dsh/ mcp.codegraph.yml # optional CodeGraph MCP row ``` -`index.js` is the only file importing DSH packages -(`@deepseek-ai/dsh-llm`, `@deepseek-ai/schemastery`). The pure modules are -testable with plain Node; this is why the port's 22 tests run without a -DSH process. +The bundle imports only Node builtins and files inside this package. It +deliberately does not import `@deepseek-ai/dsh-llm` or +`@deepseek-ai/schemastery`: a local-directory profile install resolves imports +from the profile, not from the DSH installation, so a DSH-internal import fails +at activation with `ERR_MODULE_NOT_FOUND`. `lib/message.js` is a local copy of +the tiny `createUserMessage` constructor, and `Config` was replaced by defaults +normalized in `apply()`. The pure modules are testable with plain Node; this is +why the port's 25 tests run without a DSH process. ### Guard flow @@ -217,6 +221,14 @@ 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. +- **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 + bundle now imports only Node builtins and local files: `lib/message.js` + replaces `createUserMessage` from `@deepseek-ai/dsh-llm`, and `Config` was + dropped in favor of defaults normalized in `apply()`. Any future DSH service + must be reached through `ctx` (injected service), never through an + `@deepseek-ai/*` import; a package test enforces this. - **`ctx.tools.guard` is synchronous.** The guard resolves cwd through `workingDirectory.get(session)` (sync) and runs the hooks with `execFileSync`. If a future DSH version changes the guard contract or makes diff --git a/dsh/bundle/command-role.js b/dsh/bundle/command-role.js index ac17d21..07c90a9 100644 --- a/dsh/bundle/command-role.js +++ b/dsh/bundle/command-role.js @@ -6,7 +6,7 @@ * the five-line role summary. The command's own result is UI-only. */ -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage } from './lib/message.js' import { buildRoleText, listRoles, parseRole } from './lib/role.js' function currentDirectory(workingDirectory, agent) { diff --git a/dsh/bundle/index.js b/dsh/bundle/index.js index e3ed3d1..edbc028 100644 --- a/dsh/bundle/index.js +++ b/dsh/bundle/index.js @@ -11,37 +11,25 @@ * * Everything else (CLAUDE.md, roles, OpenSpec, Gitea, codegraph, project * templates) stays in the repository and is consumed as ordinary files. + * + * The bundle intentionally has no `@deepseek-ai/*` imports: a local-directory + * profile install resolves imports from the profile, not from the DSH + * installation, so only Node builtins and files in this package are safe. */ import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { z } from '@deepseek-ai/schemastery' -import { createUserMessage } from '@deepseek-ai/dsh-llm' import { registerRoleCommand } from './command-role.js' import { createGuard } from './lib/guards.js' import { isBootstrapVisible, prepareBootstrap } from './lib/bootstrap.js' +import { createUserMessage } from './lib/message.js' import { DEFAULT_ROLE_NAMES } from './lib/role.js' export const name = 'dsh-harness' export const inject = ['tools', 'workingDirectory'] -export const Config = z.object({ - enabled: z.boolean().default(true), - guards: z.boolean().default(true), - bootstrap: z.boolean().default(true), - bootstrapSubagents: z.boolean().default(false), - roleCommand: z.boolean().default(true), - hooksDir: z.string().default(''), - rolesDir: z.string().default(''), - projectRolesDir: z.string().default(''), - roleNames: z.array(z.string()).default(DEFAULT_ROLE_NAMES), - bootstrapSkillPath: z.string().default(''), - toolMappingPath: z.string().default(''), - bootstrapMaxBytes: z.number().default(65_536), -}) - /** Resolve the hook directory for both a linked checkout and a packed package. */ function resolveHooksDir(bundleDir, configured) { if (configured) return configured diff --git a/dsh/bundle/lib/message.js b/dsh/bundle/lib/message.js new file mode 100644 index 0000000..db9f569 --- /dev/null +++ b/dsh/bundle/lib/message.js @@ -0,0 +1,28 @@ +/** + * Minimal local copy of the DSH user-message constructor. + * + * The bundle must load in a profile whose module resolution does not include + * the DSH installation's internal packages. `createUserMessage` in + * `@deepseek-ai/dsh-llm` is only `{ ...input, role: "user", id: randomUUID() }` + * plus a deep freeze, so keeping a local copy removes the only runtime import + * outside this package. + */ + +import { randomUUID } from 'node:crypto' + +function deepFreeze(value) { + if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value + Object.freeze(value) + for (const child of Object.values(value)) deepFreeze(child) + return value +} + +/** Create an immutable user-role message with a fresh identity. */ +export function createUserMessage(input) { + const message = { + ...structuredClone(input), + role: 'user', + id: randomUUID(), + } + return deepFreeze(message) +} diff --git a/dsh/tests/message.test.mjs b/dsh/tests/message.test.mjs new file mode 100644 index 0000000..4bf1ce5 --- /dev/null +++ b/dsh/tests/message.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createUserMessage } from '../bundle/lib/message.js' + +test('createUserMessage returns a frozen user message with a fresh id', () => { + const message = createUserMessage({ + content: [{ type: 'text', text: 'hello' }], + source: { kind: 'user' }, + }) + + assert.equal(message.role, 'user') + assert.match(message.id, /^[0-9a-f-]{36}$/) + assert.deepEqual(message.content, [{ type: 'text', text: 'hello' }]) + assert.deepEqual(message.source, { kind: 'user' }) + assert.ok(Object.isFrozen(message)) + assert.ok(Object.isFrozen(message.content)) + assert.ok(Object.isFrozen(message.content[0])) +}) + +test('createUserMessage does not reuse an id', () => { + const a = createUserMessage({ content: [], source: { kind: 'user' } }) + const b = createUserMessage({ content: [], source: { kind: 'user' } }) + assert.notEqual(a.id, b.id) +}) diff --git a/dsh/tests/package.test.mjs b/dsh/tests/package.test.mjs index 9dba3f3..dc63957 100644 --- a/dsh/tests/package.test.mjs +++ b/dsh/tests/package.test.mjs @@ -50,3 +50,17 @@ test('index.js registers all three integration points', () => { assert.match(source, /createGuard/) assert.match(source, /prepareBootstrap/) }) + +test('runtime files have no external @deepseek-ai imports', () => { + for (const file of [ + 'index.js', + 'command-role.js', + 'lib/message.js', + 'lib/guards.js', + 'lib/bootstrap.js', + 'lib/role.js', + ]) { + const source = readFileSync(join(bundleDir, file), 'utf8') + assert.doesNotMatch(source, /from ['"]@deepseek-ai\//, `${file} must not import DSH internals`) + } +}) -- 2.47.2 From a6bddfc19762da8624c12f31cd30e1f76fb96b2c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 15:42:00 +0400 Subject: [PATCH 4/7] =?UTF-8?q?docs(dsh):=20=D0=B6=D0=B8=D0=B2=D0=B0=D1=8F?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20guard,=20?= =?UTF-8?q?require-plan=20=D0=B8=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-10-24-dsh-port-spike.md | 9 ++++-- dsh/PORT.md | 30 ++++++++++++++----- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-10-24-dsh-port-spike.md b/docs/superpowers/plans/2026-10-24-dsh-port-spike.md index f79d81a..2c8f5f7 100644 --- a/docs/superpowers/plans/2026-10-24-dsh-port-spike.md +++ b/docs/superpowers/plans/2026-10-24-dsh-port-spike.md @@ -36,8 +36,13 @@ - [x] `dsh/install.sh`, `mcp.codegraph.yml`, README/PORT. - [x] `bash dsh/tests/run.sh` — 25 тестов зелёные. - [x] `bash tests/run.sh` — 630 тестов зелёные (регрессий нет). -- [ ] Живая проверка в DSH: маркер, guard на main, `/role`, acceptance - superpowers. +- [x] Живой `guard-main`: `write` и `bash` на main → `BLOCKED`. +- [x] Живой `require-plan`: `src/probe.ts` без `.bounded` → `BLOCKED`; + с `.bounded` → разрешено. +- [x] Bootstrap: marker `6b676dd9bb8a0234` пришёл в контекст сессии. +- [ ] `/role dev` — проверить в UI. +- [ ] Acceptance superpowers в чистой сессии. +- [ ] `/compact` → повторный bootstrap. - [ ] Решение по SDD (async controller vs workflow). ## Acceptance diff --git a/dsh/PORT.md b/dsh/PORT.md index 1ddc38f..fd3493b 100644 --- a/dsh/PORT.md +++ b/dsh/PORT.md @@ -1,6 +1,7 @@ # DSH port — design and spike notes -Status: **spike 0.1.0, live verification pending**. This directory is the +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. @@ -200,6 +201,20 @@ Live checks: ``; after compaction a new marker is appended. +Verified live on 2026-10-24 in the desktop profile: + +- the bootstrap marker `` and the + full `using-superpowers` body arrived in a fresh session, and the skill + catalog showed the superpowers skills; +- `write` to a file on `main` returned `BLOCKED [guard-main]`; +- a Bash redirect write on `main` returned `BLOCKED [guard-main]`; +- a `write` on a feature branch was allowed; +- `write` to `src/probe.ts` on the branch returned `BLOCKED [require-plan]`, + and the same write succeeded after creating `.bounded`. + +Still pending: `/role dev`, the fresh-session "react todo list" acceptance, and +re-injection after `/compact`. + Automated tests: ```bash @@ -235,11 +250,11 @@ profile was removed; the real `desktop` profile was not touched. cwd resolution async, the adapter needs a `tools/pre-execute` fallback. The guard is global and monotonic; it runs after `tools/pre-execute`, so an earlier user approval cannot turn a `guard-main` denial back into permission. -- **`agent/pre-step` injection.** The code follows the shipped skill-catalog - pattern (`next()` then append `messages`), but the live behavior of - `createUserMessage({source:{kind:"user"}})` in a host bundle still needs a - smoke test. The source kind is intentionally `user` to avoid an unknown - session source variant. +- **`agent/pre-step` injection.** Verified live: the bootstrap marker and body + arrived in a fresh session, and the skill catalog showed the superpowers + skills. The local `createUserMessage` copy works with `source.kind: "user"`, + which avoids an unknown session source variant. Re-injection after compaction + is still pending. - **Subagent bootstrap.** `isSubagent` probes common fields; if none are present on this version, the bootstrap reaches children too. The skill's `` makes that harmless but token-expensive. @@ -254,7 +269,8 @@ profile was removed; the real `desktop` profile was not touched. ## 8. Next phases -1. Live smoke test in DSH: guard, bootstrap marker, `/role`. +1. Finish the live smoke test: `/role dev`, the fresh-session superpowers + acceptance, and bootstrap re-injection after `/compact`. 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). -- 2.47.2 From 9412bd18e2eafd5e476474c8ba62babc27124c91 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 15:42:49 +0400 Subject: [PATCH 5/7] =?UTF-8?q?fix(dsh):=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=85=D1=83=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20namespace=20superpowers:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/plans/2026-10-24-dsh-port-spike.md | 2 +- dsh/PORT.md | 2 +- dsh/bundle/lib/guards.js | 9 ++++++++- dsh/tests/guard.test.mjs | 13 ++++++++++++- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-10-24-dsh-port-spike.md b/docs/superpowers/plans/2026-10-24-dsh-port-spike.md index 2c8f5f7..fd6d423 100644 --- a/docs/superpowers/plans/2026-10-24-dsh-port-spike.md +++ b/docs/superpowers/plans/2026-10-24-dsh-port-spike.md @@ -34,7 +34,7 @@ - [x] Bootstrap `using-superpowers`: резолв версии, marker, dedupe, compaction. - [x] `/role`: парсинг, два слоя, инъекция, followup. - [x] `dsh/install.sh`, `mcp.codegraph.yml`, README/PORT. -- [x] `bash dsh/tests/run.sh` — 25 тестов зелёные. +- [x] `bash dsh/tests/run.sh` — 26 тестов зелёные. - [x] `bash tests/run.sh` — 630 тестов зелёные (регрессий нет). - [x] Живой `guard-main`: `write` и `bash` на main → `BLOCKED`. - [x] Живой `require-plan`: `src/probe.ts` без `.bounded` → `BLOCKED`; diff --git a/dsh/PORT.md b/dsh/PORT.md index fd3493b..ec2dab5 100644 --- a/dsh/PORT.md +++ b/dsh/PORT.md @@ -98,7 +98,7 @@ from the profile, not from the DSH installation, so a DSH-internal import fails at activation with `ERR_MODULE_NOT_FOUND`. `lib/message.js` is a local copy of the tiny `createUserMessage` constructor, and `Config` was replaced by defaults normalized in `apply()`. The pure modules are testable with plain Node; this is -why the port's 25 tests run without a DSH process. +why the port's 26 tests run without a DSH process. ### Guard flow diff --git a/dsh/bundle/lib/guards.js b/dsh/bundle/lib/guards.js index 60561d5..096e6ff 100644 --- a/dsh/bundle/lib/guards.js +++ b/dsh/bundle/lib/guards.js @@ -28,6 +28,13 @@ const PLAN_TOOLS = new Set(['write', 'edit', 'multi_edit', 'str_replace_editor'] const DEFAULT_TIMEOUT_MS = 15_000 +/** Claude skill namespaces do not exist in DSH; rewrite the hook messages. */ +export function forDshReason(reason) { + return String(reason) + .replaceAll('superpowers:using-git-worktrees', 'using-git-worktrees') + .replaceAll('superpowers:brainstorming', 'brainstorming') +} + /** * Run one hook script with the synthesized payload. * @returns the denial text for exit 2, otherwise undefined. @@ -49,7 +56,7 @@ export function runHook({ script, hooksDir, payload, logger, timeoutMs = DEFAULT } catch (error) { if (error?.status === 2) { const reason = String(error.stderr ?? '').trim() - return reason || `BLOCKED by ${script}` + return forDshReason(reason || `BLOCKED by ${script}`) } // Fail open: a broken hook must never stop the agent. logger?.warn?.('[dsh-harness] %s failed open: %s', script, error?.message ?? String(error)) diff --git a/dsh/tests/guard.test.mjs b/dsh/tests/guard.test.mjs index 4c210c3..1c367fc 100644 --- a/dsh/tests/guard.test.mjs +++ b/dsh/tests/guard.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import test from 'node:test' -import { checkTool } from '../bundle/lib/guards.js' +import { checkTool, forDshReason } from '../bundle/lib/guards.js' import { cleanup, git, makeRepo, write } from './helpers.mjs' const hooksDir = fileURLToPath(new URL('../bundle/hooks/', import.meta.url)) @@ -20,11 +20,20 @@ test('guard-main blocks a write on main', () => { hooksDir, }) assert.match(reason ?? '', /BLOCKED \[guard-main\]/) + assert.doesNotMatch(reason ?? '', /superpowers:/) + assert.match(reason ?? '', /using-git-worktrees/) } finally { cleanup(repo) } }) +test('forDshReason removes the Claude skill namespace from hook messages', () => { + assert.equal( + forDshReason('Run superpowers:brainstorming; use superpowers:using-git-worktrees.'), + 'Run brainstorming; use using-git-worktrees.', + ) +}) + test('guard-main allows a write on a feature branch', () => { const repo = makeRepo() try { @@ -72,6 +81,8 @@ test('require-plan blocks src edits without a plan or .bounded', () => { hooksDir, }) assert.match(blocked ?? '', /BLOCKED \[require-plan\]/) + assert.doesNotMatch(blocked ?? '', /superpowers:/) + assert.match(blocked ?? '', /brainstorming/) write(join(repo, '.bounded'), '') const bounded = checkTool({ -- 2.47.2 From f422119090e62c9d735ab445973db6267496d4a8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 15:49:45 +0400 Subject: [PATCH 6/7] =?UTF-8?q?docs(dsh):=20=D0=B2=D0=B5=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=20issue=20#35,=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=20=D0=B6=D0=B8=D0=B2=D1=8B=D1=85=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/plans/2026-10-24-dsh-port-spike.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-10-24-dsh-port-spike.md b/docs/superpowers/plans/2026-10-24-dsh-port-spike.md index fd6d423..1e9e5bb 100644 --- a/docs/superpowers/plans/2026-10-24-dsh-port-spike.md +++ b/docs/superpowers/plans/2026-10-24-dsh-port-spike.md @@ -1,6 +1,6 @@ # DSH port spike — план -Ветка: `feat/dsh-port-spike`. +Ветка: `feat/35-dsh-port-spike` (issue #35). Спека/дизайн: `dsh/PORT.md`. ## Цель @@ -40,9 +40,9 @@ - [x] Живой `require-plan`: `src/probe.ts` без `.bounded` → `BLOCKED`; с `.bounded` → разрешено. - [x] Bootstrap: marker `6b676dd9bb8a0234` пришёл в контекст сессии. -- [ ] `/role dev` — проверить в UI. +- [x] `/role dev` — роль применилась в новой сессии DSH. - [ ] Acceptance superpowers в чистой сессии. -- [ ] `/compact` → повторный bootstrap. +- [x] `/compact` → повторный bootstrap. - [ ] Решение по SDD (async controller vs workflow). ## Acceptance -- 2.47.2 From 95dab08109b41b1dc71f1f6cf7b4c31d68e71dfd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 16:18:09 +0400 Subject: [PATCH 7/7] =?UTF-8?q?docs(dsh):=20acceptance=20bootstrap=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B9=D0=B4=D0=B5=D0=BD,=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=B8=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/plans/2026-10-24-dsh-port-spike.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-24-dsh-port-spike.md b/docs/superpowers/plans/2026-10-24-dsh-port-spike.md index 1e9e5bb..28cf178 100644 --- a/docs/superpowers/plans/2026-10-24-dsh-port-spike.md +++ b/docs/superpowers/plans/2026-10-24-dsh-port-spike.md @@ -40,8 +40,8 @@ - [x] Живой `require-plan`: `src/probe.ts` без `.bounded` → `BLOCKED`; с `.bounded` → разрешено. - [x] Bootstrap: marker `6b676dd9bb8a0234` пришёл в контекст сессии. -- [x] `/role dev` — роль применилась в новой сессии DSH. -- [ ] Acceptance superpowers в чистой сессии. +- [x] `/role dev` — команда работает (пользовательский вызов). +- [x] Acceptance superpowers в чистой сессии: bootstrap + `brainstorming` до кода. - [x] `/compact` → повторный bootstrap. - [ ] Решение по SDD (async controller vs workflow). @@ -63,3 +63,5 @@ Electron-приложением, установка идёт через Plugins/`plugin_manager`. - Живая проверка требует новой сессии DSH и установленного бандла; текущий прогон ограничен статическими тестами. +- `/role` — пользовательская команда; модель её не вызывает. Авто-загрузка + роли — #39. -- 2.47.2