DSH port v2: доставка superpowers вариантом C (sync-по-пину, самопроверка бандла) (#69) #72

Merged
aep merged 19 commits from feat/69-dsh-port-v2 into main 2026-10-25 00:12:57 +03:00
6 changed files with 215 additions and 10 deletions
Showing only changes of commit f1bbbe0412 - Show all commits

View File

@ -350,6 +350,16 @@ profile was removed; the real `desktop` profile was not touched.
- **DSH is 0.2.1-rc.3.** The plugin/event API is not frozen; pin the version and
re-run the tests after upgrades.
- **Contract self-check.** The plugin/event API drift is now caught from inside
DSH: at activation the bundle checks the contract it depends on
(`ctx.tools.guard`, `ctx.on` for `agent/pre-step`, `ctx.inject` for commands,
`workingDirectory.get`), logs
`[dsh-harness] self-check: <feature> not-ok: <detail>` for each missing
service, and skips only the guard wiring when `tools.guard` is missing
(bootstrap and `/role` keep working when their own services exist).
`/dsh-harness-check` re-runs the same check on demand. This is the in-DSH
compat check that closes RV-11 (D-6) — the terminal `--check` cannot see the
SDK inside `app.asar`.
- **Local profile imports are not resolved from the DSH installation.** The
first install attempt failed at activation with
`ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/schemastery'`. The

View File

@ -185,3 +185,11 @@ repository layout:
skill and `openspec/changes/dsh-sdd/`.
- OpenSpec-скиллы проект получает сам (`openspec init --tools agents` или
`--openspec`); обвязка ставит их только в проект, не в `~/.dsh`.
- The bundle self-checks the DSH contract it depends on at activation:
`ctx.tools.guard`, `ctx.on` for `agent/pre-step`, `ctx.inject` for commands,
`workingDirectory.get`. Each missing service is logged as
`[dsh-harness] self-check: <feature> not-ok: …`; the guard wiring is the only
feature skipped when its service is missing — bootstrap and `/role` keep
working when their own services exist. The `/dsh-harness-check` command
re-runs the same check on demand (one `ok` / `not-ok` line per feature).
Disable with `selfCheck: false` / `checkCommand: false`.

View File

@ -0,0 +1,33 @@
/**
* Human-facing `dsh-harness-check` command for DeepSeek Harness.
*
* Re-runs the bundle contract self-check on demand and prints one line per
* feature. Mirrors `command-role.js`: the same object-form
* `commands.register`, the same `{ kind, text }` result shape.
*/
export function registerCheckCommand({ commands, collect, logger }) {
commands.register({
name: 'dsh-harness-check',
description: 'Проверить контракт DSH, на который опирается dsh-harness',
handler: async () => {
let report
try {
report = collect()
} catch (error) {
logger?.warn?.('[dsh-harness] dsh-harness-check failed: %s', error?.message ?? error)
return { kind: 'error', text: String(error?.message ?? error) }
}
const problems = report.filter((entry) => !entry.ok)
const lines = report.map((entry) =>
entry.ok ? `${entry.feature}: ok` : `${entry.feature}: not-ok — ${entry.detail}`,
)
const headline =
problems.length === 0
? 'dsh-harness self-check: all ok'
: `dsh-harness self-check: ${problems.length} problem(s)`
return { kind: 'success', text: [headline, ...lines].join('\n') }
},
})
}

View File

@ -22,11 +22,13 @@ import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { registerRoleCommand } from './command-role.js'
import { registerCheckCommand } from './command-check.js'
import { createGuard } from './lib/guards.js'
import { isBootstrapVisible, prepareBootstrap } from './lib/bootstrap.js'
import { isLegacyClaudePath, warnLegacyOnce } from './lib/legacy.js'
import { createUserMessage } from './lib/message.js'
import { DEFAULT_ROLE_NAMES } from './lib/role.js'
import { collectSelfCheck } from './lib/selfcheck.js'
export const name = 'dsh-harness'
export const inject = ['tools', 'workingDirectory']
@ -77,6 +79,8 @@ const DEFAULTS = {
bootstrap: true,
bootstrapSubagents: false,
roleCommand: true,
selfCheck: true,
checkCommand: true,
hooksDir: '',
rolesDir: '',
projectRolesDir: '',
@ -99,7 +103,13 @@ export function apply(ctx, rawConfig = {}) {
}
const guard = createGuard({ hooksDir, logger: ctx.logger })
if (config.enabled && config.guards) {
const selfCheckReport =
config.enabled && config.selfCheck ? collectSelfCheck(ctx) : null
const guardsOk = selfCheckReport
? selfCheckReport.find((entry) => entry.feature === 'guards')?.ok === true
: true
if (config.enabled && config.guards && guardsOk) {
// A synchronous, monotonic guard: it runs after the extensible
// `tools/pre-execute` waterfall, so no other listener can turn a denial
// back into permission.
@ -140,19 +150,36 @@ export function apply(ctx, rawConfig = {}) {
})
}
if (config.enabled && config.roleCommand) {
if (config.enabled && (config.roleCommand || config.checkCommand)) {
ctx.inject(['commands'], (commandCtx) => {
registerRoleCommand({
commands: commandCtx.commands,
workingDirectory: ctx.workingDirectory,
rolesDir,
projectRolesDir: config.projectRolesDir,
roleNames: config.roleNames,
logger: ctx.logger,
})
if (config.roleCommand) {
registerRoleCommand({
commands: commandCtx.commands,
workingDirectory: ctx.workingDirectory,
rolesDir,
projectRolesDir: config.projectRolesDir,
roleNames: config.roleNames,
logger: ctx.logger,
})
}
if (config.checkCommand) {
registerCheckCommand({
commands: commandCtx.commands,
collect: () => collectSelfCheck(ctx),
logger: ctx.logger,
})
}
})
}
if (selfCheckReport) {
for (const entry of selfCheckReport) {
if (!entry.ok) {
ctx.logger?.error?.('[dsh-harness] self-check: %s not-ok: %s', entry.feature, entry.detail)
}
}
}
ctx.logger?.info?.(
'[dsh-harness] ready: hooks=%s, bootstrap=%s, /role=%s',
config.guards ? hooksDir : 'off',

View File

@ -0,0 +1,47 @@
/**
* Activation-time self-check of the DSH contract the bundle depends on.
*
* Pure shape inspection: no ctx service is invoked, only checked for presence.
* A DSH upgrade may rename or remove a service; the check turns that into a
* visible `not-ok` line (activation log plus the `dsh-harness-check` command)
* instead of a TypeError at first use.
*/
const CONTRACT = [
{
feature: 'guards',
label: 'tools.guard',
available: (ctx) => typeof ctx?.tools?.guard === 'function',
},
{
feature: 'bootstrap',
label: 'ctx.on (agent/pre-step)',
available: (ctx) => typeof ctx?.on === 'function',
},
{
feature: 'commands',
label: 'ctx.inject',
available: (ctx) => typeof ctx?.inject === 'function',
},
{
feature: 'workingDirectory',
label: 'workingDirectory.get',
available: (ctx) => typeof ctx?.workingDirectory?.get === 'function',
},
]
/**
* Inspect a ctx-like object without calling anything on it.
* @returns {{feature: 'guards'|'bootstrap'|'commands'|'workingDirectory',
* ok: boolean, detail: string}[]}
*/
export function collectSelfCheck(ctxLike) {
return CONTRACT.map(({ feature, label, available }) => {
const ok = available(ctxLike)
return {
feature,
ok,
detail: ok ? `${label}: available` : `${label}: missing or not a function`,
}
})
}

View File

@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { collectSelfCheck } from '../bundle/lib/selfcheck.js'
import { registerCheckCommand } from '../bundle/command-check.js'
const FEATURES = ['guards', 'bootstrap', 'commands', 'workingDirectory']
/** A ctx-like with every service the bundle depends on. */
function fullCtx() {
return {
tools: { guard() {} },
on() {},
inject() {},
workingDirectory: { get() {} },
}
}
test('collectSelfCheck ok on full ctx-like', () => {
const report = collectSelfCheck(fullCtx())
assert.deepEqual(
report.map((entry) => entry.feature),
FEATURES,
)
for (const entry of report) {
assert.equal(entry.ok, true, `${entry.feature} ok`)
assert.equal(typeof entry.detail, 'string', `${entry.feature} detail is a string`)
assert.ok(entry.detail.length > 0, `${entry.feature} detail is non-empty`)
}
})
test('collectSelfCheck flags missing tools.guard', () => {
const ctx = fullCtx()
ctx.tools = {}
const report = collectSelfCheck(ctx)
const guards = report.find((entry) => entry.feature === 'guards')
assert.equal(guards.ok, false)
assert.match(guards.detail, /tools\.guard/)
for (const entry of report.filter((entry) => entry.feature !== 'guards')) {
assert.equal(entry.ok, true, `${entry.feature} stays ok`)
}
})
test('collectSelfCheck flags missing on()', () => {
const ctx = fullCtx()
ctx.on = undefined
const report = collectSelfCheck(ctx)
const bootstrap = report.find((entry) => entry.feature === 'bootstrap')
assert.equal(bootstrap.ok, false)
for (const entry of report.filter((entry) => entry.feature !== 'bootstrap')) {
assert.equal(entry.ok, true, `${entry.feature} stays ok`)
}
})
test('registerCheckCommand builds report text', async () => {
let captured
const commands = {
register(def) {
captured = def
},
}
registerCheckCommand({ commands, collect: () => collectSelfCheck(fullCtx()) })
assert.equal(captured.name, 'dsh-harness-check')
const okResult = await captured.handler({})
assert.match(okResult.text, /guards: ok/)
assert.match(okResult.text, /all ok/)
const brokenCtx = fullCtx()
brokenCtx.tools = {}
registerCheckCommand({ commands, collect: () => collectSelfCheck(brokenCtx) })
const badResult = await captured.handler({})
assert.match(badResult.text, /guards: not-ok/)
assert.match(badResult.text, /problem/)
})