feat(dsh): каркас порта claude-harness на DeepSeek Harness

This commit is contained in:
Andrey Epifancev 2026-10-24 14:59:03 +04:00
parent 613f843d73
commit 16a9739759
24 changed files with 1841 additions and 0 deletions

View File

@ -1,2 +1,3 @@
hooks/
skills/
dsh/

View File

@ -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-маркер `<!-- DSH-HARNESS-BOOTSTRAP:<hash> -->` виден один раз;
после compaction появляется снова.
- `/role dev` читает `~/.claude/roles/dev.md` и `<cwd>/.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 и установленного бандла; текущий
прогон ограничен статическими тестами.

249
dsh/PORT.md Normal file
View File

@ -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 `<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 the global
`~/.claude/roles/<role>.md` and the project `.claude/roles/<role>.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 -> <cwd>/.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
`<!-- DSH-HARNESS-BOOTSTRAP:<hash> -->`; 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
`<SUBAGENT-STOP>` 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.

127
dsh/README.md Normal file
View File

@ -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
<!-- DSH-HARNESS-BOOTSTRAP:<16-hex-hash> -->
```
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 -> <cwd>/.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).

View File

@ -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),
}
}
},
})
}

View File

@ -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'

46
dsh/bundle/dsh-tools.md Normal file
View File

@ -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 <id>` 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.

1
dsh/bundle/hooks Symbolic link
View File

@ -0,0 +1 @@
../../hooks

12
dsh/bundle/icon.svg Normal file
View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="DSH Harness">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1f6feb"/>
<stop offset="1" stop-color="#0b3d91"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="48" fill="url(#g)"/>
<path d="M128 34l72 26v54c0 48-30 88-72 108-42-20-72-60-72-108V60l72-26z" fill="none" stroke="#fff" stroke-width="12" stroke-linejoin="round"/>
<path d="M96 104l22 24-22 24" fill="none" stroke="#fff" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M132 152h32" fill="none" stroke="#fff" stroke-width="12" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 717 B

168
dsh/bundle/index.js Normal file
View File

@ -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',
)
}

212
dsh/bundle/lib/bootstrap.js vendored Normal file
View File

@ -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,
'<EXTREMELY_IMPORTANT>',
'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,
'</EXTREMELY_IMPORTANT>',
'',
].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 = `<!-- DSH-HARNESS-BOOTSTRAP:${hash} -->`
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
}

108
dsh/bundle/lib/guards.js Normal file
View File

@ -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 })
},
}
}

113
dsh/bundle/lib/role.js Normal file
View File

@ -0,0 +1,113 @@
/**
* Role loading for the `/role` command.
*
* The file format stays the claude-harness format: a global layer
* (`~/.claude/roles/<role>.md`) and an optional project layer
* (`<workspace>/.claude/roles/<role>.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 не поддерживаются —',
'пропусти их и скажи об этом одной строкой.',
'',
'## Ответ',
'Ответь ровно пятью строками:',
'- Роль: <role> — <описание из первой строки файла роли>.',
'- Процесс: <одна строка из раздела «Процесс» — что действует по умолчанию>.',
'- Прочитано: <перечень файлов обоих слоёв и файлов памяти, через запятую>.',
'- Не делать: <раздел «Не делать» одной строкой>.',
'- Ждёт решения владельца: <из памяти роли; если нет — «ничего»>.',
)
return {
text: sections.join('\n'),
globalPath,
projectPath,
projectMissing,
}
}

View File

@ -0,0 +1,6 @@
{
"meta": {
"title": "DSH Harness",
"description": "Claude-harness port for DeepSeek Harness: main-branch guards, superpowers bootstrap, and the /role command."
}
}

View File

@ -0,0 +1,6 @@
{
"meta": {
"title": "DSH Harness",
"description": "Порт claude-harness для DeepSeek Harness: запрет записи на main, bootstrap superpowers и команда /role."
}
}

27
dsh/bundle/package.json Normal file
View File

@ -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"
}
}
}

184
dsh/install.sh Executable file
View File

@ -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 <<EOF
Usage: bash dsh/install.sh [--check] [--global] [--skills] [--all]
--check verify python3, git, node, hooks, bundle, and superpowers
--global back up and write \$DSH_HOME/AGENTS.md from global/CLAUDE.md
--skills symlink installed superpowers skills into \$DSH_HOME/skills
--all --check + --global + --skills, then print the bundle step
Bundle installation itself is done in DSH:
Plugins -> 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 <<EOF
Bundle: $BUNDLE
Install it in the current DSH profile (the Desktop profile is Electron-managed):
- GUI: Plugins -> 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"

18
dsh/mcp.codegraph.yml Normal file
View File

@ -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

View File

@ -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(`<!-- marker -->\n${'x'.repeat(5000)}`, 2000)
assert.ok(Buffer.byteLength(out, 'utf8') <= 2000)
assert.match(out, /bootstrap truncated/)
assert.match(out, /^<!-- marker -->/)
})
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, /^<!-- DSH-HARNESS-BOOTSTRAP:[0-9a-f]{16} -->$/)
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: '<!-- DSH-HARNESS-BOOTSTRAP:0000000000000000 -->',
}
assert.equal(buildBootstrapText(args), buildBootstrapText(args))
})
test('isBootstrapVisible sees the marker in messages and in the visible surface', () => {
const marker = '<!-- DSH-HARNESS-BOOTSTRAP:abc -->'
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)
}
})

117
dsh/tests/guard.test.mjs Normal file
View File

@ -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)
}
})

37
dsh/tests/helpers.mjs Normal file
View File

@ -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 })
}

View File

@ -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/)
})

96
dsh/tests/role.test.mjs Normal file
View File

@ -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)
}
})

11
dsh/tests/run.sh Executable file
View File

@ -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