DSH port v2: доставка superpowers вариантом C (sync-по-пину, самопроверка бандла) (#69) #72
10
dsh/PORT.md
10
dsh/PORT.md
@ -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
|
||||
|
||||
@ -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`.
|
||||
|
||||
33
dsh/bundle/command-check.js
Normal file
33
dsh/bundle/command-check.js
Normal 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') }
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -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',
|
||||
|
||||
47
dsh/bundle/lib/selfcheck.js
Normal file
47
dsh/bundle/lib/selfcheck.js
Normal 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`,
|
||||
}
|
||||
})
|
||||
}
|
||||
80
dsh/tests/selfcheck.test.mjs
Normal file
80
dsh/tests/selfcheck.test.mjs
Normal 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/)
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user