DSH: порт claude-harness — спайк (guard, require-plan, bootstrap, /role) #36

Merged
aep merged 7 commits from feat/35-dsh-port-spike into main 2026-10-24 15:20:05 +03:00
4 changed files with 22 additions and 4 deletions
Showing only changes of commit 9412bd18e2 - Show all commits

View File

@ -34,7 +34,7 @@
- [x] Bootstrap `using-superpowers`: резолв версии, marker, dedupe, compaction.
- [x] `/role`: парсинг, два слоя, инъекция, followup.
- [x] `dsh/install.sh`, `mcp.codegraph.yml`, README/PORT.
- [x] `bash dsh/tests/run.sh` — 25 тестов зелёные.
- [x] `bash dsh/tests/run.sh` — 26 тестов зелёные.
- [x] `bash tests/run.sh` — 630 тестов зелёные (регрессий нет).
- [x] Живой `guard-main`: `write` и `bash` на main → `BLOCKED`.
- [x] Живой `require-plan`: `src/probe.ts` без `.bounded` → `BLOCKED`;

View File

@ -98,7 +98,7 @@ from the profile, not from the DSH installation, so a DSH-internal import fails
at activation with `ERR_MODULE_NOT_FOUND`. `lib/message.js` is a local copy of
the tiny `createUserMessage` constructor, and `Config` was replaced by defaults
normalized in `apply()`. The pure modules are testable with plain Node; this is
why the port's 25 tests run without a DSH process.
why the port's 26 tests run without a DSH process.
### Guard flow

View File

@ -28,6 +28,13 @@ const PLAN_TOOLS = new Set(['write', 'edit', 'multi_edit', 'str_replace_editor']
const DEFAULT_TIMEOUT_MS = 15_000
/** Claude skill namespaces do not exist in DSH; rewrite the hook messages. */
export function forDshReason(reason) {
return String(reason)
.replaceAll('superpowers:using-git-worktrees', 'using-git-worktrees')
.replaceAll('superpowers:brainstorming', 'brainstorming')
}
/**
* Run one hook script with the synthesized payload.
* @returns the denial text for exit 2, otherwise undefined.
@ -49,7 +56,7 @@ export function runHook({ script, hooksDir, payload, logger, timeoutMs = DEFAULT
} catch (error) {
if (error?.status === 2) {
const reason = String(error.stderr ?? '').trim()
return reason || `BLOCKED by ${script}`
return forDshReason(reason || `BLOCKED by ${script}`)
}
// Fail open: a broken hook must never stop the agent.
logger?.warn?.('[dsh-harness] %s failed open: %s', script, error?.message ?? String(error))

View File

@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import test from 'node:test'
import { checkTool } from '../bundle/lib/guards.js'
import { checkTool, forDshReason } from '../bundle/lib/guards.js'
import { cleanup, git, makeRepo, write } from './helpers.mjs'
const hooksDir = fileURLToPath(new URL('../bundle/hooks/', import.meta.url))
@ -20,11 +20,20 @@ test('guard-main blocks a write on main', () => {
hooksDir,
})
assert.match(reason ?? '', /BLOCKED \[guard-main\]/)
assert.doesNotMatch(reason ?? '', /superpowers:/)
assert.match(reason ?? '', /using-git-worktrees/)
} finally {
cleanup(repo)
}
})
test('forDshReason removes the Claude skill namespace from hook messages', () => {
assert.equal(
forDshReason('Run superpowers:brainstorming; use superpowers:using-git-worktrees.'),
'Run brainstorming; use using-git-worktrees.',
)
})
test('guard-main allows a write on a feature branch', () => {
const repo = makeRepo()
try {
@ -72,6 +81,8 @@ test('require-plan blocks src edits without a plan or .bounded', () => {
hooksDir,
})
assert.match(blocked ?? '', /BLOCKED \[require-plan\]/)
assert.doesNotMatch(blocked ?? '', /superpowers:/)
assert.match(blocked ?? '', /brainstorming/)
write(join(repo, '.bounded'), '')
const bounded = checkTool({