diff --git a/dsh/PORT.md b/dsh/PORT.md index c111a13..26f4531 100644 --- a/dsh/PORT.md +++ b/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: not-ok: ` 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 diff --git a/dsh/README.md b/dsh/README.md index 5654b9e..e54378a 100644 --- a/dsh/README.md +++ b/dsh/README.md @@ -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: 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`. diff --git a/dsh/bundle/command-check.js b/dsh/bundle/command-check.js new file mode 100644 index 0000000..e2f8e90 --- /dev/null +++ b/dsh/bundle/command-check.js @@ -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') } + }, + }) +} diff --git a/dsh/bundle/index.js b/dsh/bundle/index.js index 4d91a6b..a3a13ac 100644 --- a/dsh/bundle/index.js +++ b/dsh/bundle/index.js @@ -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', diff --git a/dsh/bundle/lib/selfcheck.js b/dsh/bundle/lib/selfcheck.js new file mode 100644 index 0000000..d61d978 --- /dev/null +++ b/dsh/bundle/lib/selfcheck.js @@ -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`, + } + }) +} diff --git a/dsh/tests/selfcheck.test.mjs b/dsh/tests/selfcheck.test.mjs new file mode 100644 index 0000000..32098f2 --- /dev/null +++ b/dsh/tests/selfcheck.test.mjs @@ -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/) +})