feat(dsh): каркас порта claude-harness на DeepSeek Harness
This commit is contained in:
parent
613f843d73
commit
16a9739759
@ -1,2 +1,3 @@
|
||||
hooks/
|
||||
skills/
|
||||
dsh/
|
||||
|
||||
60
docs/superpowers/plans/2026-10-24-dsh-port-spike.md
Normal file
60
docs/superpowers/plans/2026-10-24-dsh-port-spike.md
Normal 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
249
dsh/PORT.md
Normal 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
127
dsh/README.md
Normal 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).
|
||||
92
dsh/bundle/command-role.js
Normal file
92
dsh/bundle/command-role.js
Normal 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),
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
8
dsh/bundle/cordis.patch.yml
Normal file
8
dsh/bundle/cordis.patch.yml
Normal 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
46
dsh/bundle/dsh-tools.md
Normal 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
1
dsh/bundle/hooks
Symbolic link
@ -0,0 +1 @@
|
||||
../../hooks
|
||||
12
dsh/bundle/icon.svg
Normal file
12
dsh/bundle/icon.svg
Normal 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
168
dsh/bundle/index.js
Normal 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
212
dsh/bundle/lib/bootstrap.js
vendored
Normal 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
108
dsh/bundle/lib/guards.js
Normal 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
113
dsh/bundle/lib/role.js
Normal 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,
|
||||
}
|
||||
}
|
||||
6
dsh/bundle/locale/en.json
Normal file
6
dsh/bundle/locale/en.json
Normal 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."
|
||||
}
|
||||
}
|
||||
6
dsh/bundle/locale/ru.json
Normal file
6
dsh/bundle/locale/ru.json
Normal 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
27
dsh/bundle/package.json
Normal 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
184
dsh/install.sh
Executable 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
18
dsh/mcp.codegraph.yml
Normal 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
|
||||
90
dsh/tests/bootstrap.test.mjs
Normal file
90
dsh/tests/bootstrap.test.mjs
Normal 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
117
dsh/tests/guard.test.mjs
Normal 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
37
dsh/tests/helpers.mjs
Normal 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 })
|
||||
}
|
||||
52
dsh/tests/package.test.mjs
Normal file
52
dsh/tests/package.test.mjs
Normal 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
96
dsh/tests/role.test.mjs
Normal 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
11
dsh/tests/run.sh
Executable 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
|
||||
Loading…
x
Reference in New Issue
Block a user