feat(dsh): самопроверка контракта бандла при активации + команда dsh-harness-check (#69)
This commit is contained in:
parent
1a0c78cbc3
commit
f1bbbe0412
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
|
- **DSH is 0.2.1-rc.3.** The plugin/event API is not frozen; pin the version and
|
||||||
re-run the tests after upgrades.
|
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
|
- **Local profile imports are not resolved from the DSH installation.** The
|
||||||
first install attempt failed at activation with
|
first install attempt failed at activation with
|
||||||
`ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/schemastery'`. The
|
`ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/schemastery'`. The
|
||||||
|
|||||||
@ -185,3 +185,11 @@ repository layout:
|
|||||||
skill and `openspec/changes/dsh-sdd/`.
|
skill and `openspec/changes/dsh-sdd/`.
|
||||||
- OpenSpec-скиллы проект получает сам (`openspec init --tools agents` или
|
- OpenSpec-скиллы проект получает сам (`openspec init --tools agents` или
|
||||||
`--openspec`); обвязка ставит их только в проект, не в `~/.dsh`.
|
`--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 { dirname, join } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { registerRoleCommand } from './command-role.js'
|
import { registerRoleCommand } from './command-role.js'
|
||||||
|
import { registerCheckCommand } from './command-check.js'
|
||||||
import { createGuard } from './lib/guards.js'
|
import { createGuard } from './lib/guards.js'
|
||||||
import { isBootstrapVisible, prepareBootstrap } from './lib/bootstrap.js'
|
import { isBootstrapVisible, prepareBootstrap } from './lib/bootstrap.js'
|
||||||
import { isLegacyClaudePath, warnLegacyOnce } from './lib/legacy.js'
|
import { isLegacyClaudePath, warnLegacyOnce } from './lib/legacy.js'
|
||||||
import { createUserMessage } from './lib/message.js'
|
import { createUserMessage } from './lib/message.js'
|
||||||
import { DEFAULT_ROLE_NAMES } from './lib/role.js'
|
import { DEFAULT_ROLE_NAMES } from './lib/role.js'
|
||||||
|
import { collectSelfCheck } from './lib/selfcheck.js'
|
||||||
|
|
||||||
export const name = 'dsh-harness'
|
export const name = 'dsh-harness'
|
||||||
export const inject = ['tools', 'workingDirectory']
|
export const inject = ['tools', 'workingDirectory']
|
||||||
@ -77,6 +79,8 @@ const DEFAULTS = {
|
|||||||
bootstrap: true,
|
bootstrap: true,
|
||||||
bootstrapSubagents: false,
|
bootstrapSubagents: false,
|
||||||
roleCommand: true,
|
roleCommand: true,
|
||||||
|
selfCheck: true,
|
||||||
|
checkCommand: true,
|
||||||
hooksDir: '',
|
hooksDir: '',
|
||||||
rolesDir: '',
|
rolesDir: '',
|
||||||
projectRolesDir: '',
|
projectRolesDir: '',
|
||||||
@ -99,7 +103,13 @@ export function apply(ctx, rawConfig = {}) {
|
|||||||
}
|
}
|
||||||
const guard = createGuard({ hooksDir, logger: ctx.logger })
|
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
|
// A synchronous, monotonic guard: it runs after the extensible
|
||||||
// `tools/pre-execute` waterfall, so no other listener can turn a denial
|
// `tools/pre-execute` waterfall, so no other listener can turn a denial
|
||||||
// back into permission.
|
// 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) => {
|
ctx.inject(['commands'], (commandCtx) => {
|
||||||
registerRoleCommand({
|
if (config.roleCommand) {
|
||||||
commands: commandCtx.commands,
|
registerRoleCommand({
|
||||||
workingDirectory: ctx.workingDirectory,
|
commands: commandCtx.commands,
|
||||||
rolesDir,
|
workingDirectory: ctx.workingDirectory,
|
||||||
projectRolesDir: config.projectRolesDir,
|
rolesDir,
|
||||||
roleNames: config.roleNames,
|
projectRolesDir: config.projectRolesDir,
|
||||||
logger: ctx.logger,
|
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?.(
|
ctx.logger?.info?.(
|
||||||
'[dsh-harness] ready: hooks=%s, bootstrap=%s, /role=%s',
|
'[dsh-harness] ready: hooks=%s, bootstrap=%s, /role=%s',
|
||||||
config.guards ? hooksDir : 'off',
|
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