/** * dsh-harness — the DeepSeek Harness adapter for claude-harness. * * Three Claude Code integration points are replaced by Cordis hooks: * - PreToolUse Edit/Write/Bash -> `ctx.tools.guard` calling the existing * `hooks/guard-main.sh` and `hooks/require-plan.sh` through a thin adapter; * - SessionStart superpowers -> `agent/pre-step` injecting * `using-superpowers` once per session (and again after compaction), * plus the harness-check and role-hint messages; * - `/role` skill -> a real `/role` command registering on * `ctx.commands`. * * Everything else (CLAUDE.md, roles, OpenSpec, Gitea, codegraph, project * templates) stays in the repository and is consumed as ordinary files. * * The bundle intentionally has no `@deepseek-ai/*` imports: a local-directory * profile install resolves imports from the profile, not from the DSH * installation, so only Node builtins and files in this package are safe. */ import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, isAbsolute, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { registerRoleCommand } from './command-role.js' import { registerCheckCommand } from './command-check.js' import { registerApproveCommand } from './command-approve.js' import { createApproveTool } from './tool-approve.js' import { createGuard } from './lib/guards.js' import { checkApprovalWrite, checkBashTargets, checkDispatch, checkPlanWrite, checkSddWrite, } from './lib/gates.js' import { isInjectionVisible, prepareBootstrap, textOf } from './lib/bootstrap.js' import { isLegacyClaudePath, warnLegacyOnce } from './lib/legacy.js' import { createUserMessage } from './lib/message.js' import { buildHarnessCheckText, buildRoleHintText, detectAdoption, detectRole, isHarnessMessage, makePrestepMarker, } from './lib/prestep.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'] /** Resolve the hook directory for both a linked checkout and a packed package. */ function resolveHooksDir(bundleDir, configured) { if (configured) return configured const local = join(bundleDir, 'hooks') if (existsSync(join(local, 'guard-main.sh'))) return local return join(bundleDir, '..', '..', 'hooks') } /** Prefer DSH-installed roles, then Claude Code, then the repository source. */ export function resolveRolesDir(bundleDir, configured, home = homedir()) { if (configured) return configured const dshInstalled = join(home, '.dsh', 'roles') if (existsSync(join(dshInstalled, 'dev.md'))) return dshInstalled const claudeInstalled = join(home, '.claude', 'roles') if (existsSync(join(claudeInstalled, 'dev.md'))) return claudeInstalled const source = join(bundleDir, '..', '..', 'roles') if (existsSync(join(source, 'dev.md'))) return source return dshInstalled } function isSubagent(agent) { return Boolean( agent?.session?.header?.delegationDepth > 0 || agent?.session?.header?.origin === 'subagent' || agent?.options?.subagentDepth > 0, ) } function sessionCwd(workingDirectory, agent) { if (agent?.session) { try { const cwd = workingDirectory.get(agent.session) if (typeof cwd === 'string' && cwd !== '') return cwd } catch { // Fall through to the process directory. } } return process.cwd() } /** DSH tools that write file content (the Edit/Write/MultiEdit family). */ const WRITE_TOOLS = new Set(['write', 'edit', 'multi_edit', 'str_replace_editor']) /** Coarse substrings that make a Bash command worth target-parsing (D-8). */ const BASH_GATE_HINTS = ['docs/superpowers/plans', 'approval.md', '.superpowers/sdd'] /** * Write targets of one Bash command, parsed by the same parser the hooks use * (`harness_target.py targets`). `null` on any spawn/parse failure — the gate * fails open exactly like the hooks do. */ function bashWriteTargets(hooksDir, command, cwd, logger) { const parser = join(hooksDir, 'harness_target.py') let result try { result = spawnSync('python3', [parser, 'targets', command, cwd], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15_000, }) } catch (error) { logger?.warn?.('[dsh-harness] approval gates: bash target parse failed open: %s', error?.message ?? String(error)) return null } if (result.error || result.status !== 0) { logger?.warn?.( '[dsh-harness] approval gates: harness_target.py failed open (%s)', result.error?.message ?? `exit ${result.status}`, ) return null } const targets = [] for (const line of String(result.stdout ?? '').split(/\r?\n/u)) { if (!line.startsWith('file ')) continue const path = line.slice('file '.length).trim().split(/\s+/u)[0] if (path !== '') targets.push(path) } return targets } /** * Approval gates A/B/C for one tool call, run after the hook guards. * Returns the first deny reason or undefined. All failures fail open: a call * that names no plan/path is never blocked, and a broken bash parser pass is * allowed, mirroring the hook invariant. */ function approvalGateDeny({ exec, repoRoot, hooksDir, logger }) { const name = exec?.name const args = exec?.arguments ?? {} if (WRITE_TOOLS.has(name)) { const rawPath = args.file_path ?? args.path if (typeof rawPath !== 'string' || rawPath === '') return undefined const filePath = isAbsolute(rawPath) ? resolve(rawPath) : resolve(repoRoot, rawPath) const content = typeof args.content === 'string' ? args.content : typeof args.new_string === 'string' ? args.new_string : null for (const verdict of [ checkApprovalWrite({ filePath, repoRoot }), checkPlanWrite({ filePath, content, repoRoot }), checkSddWrite({ filePath, repoRoot }), ]) { if (verdict.deny !== undefined) return verdict.deny } return undefined } if (name === 'subagent' || name === 'subagent_fork' || name === 'workflow') { return checkDispatch({ prompt: typeof args.prompt === 'string' ? args.prompt : undefined, script: typeof args.script === 'string' ? args.script : undefined, repoRoot, }).deny } if (name === 'bash') { const command = args.command if (typeof command !== 'string' || command === '') return undefined if (!BASH_GATE_HINTS.some((hint) => command.includes(hint))) return undefined const targets = bashWriteTargets(hooksDir, command, repoRoot, logger) if (targets === null) return undefined // Plans/approval.md through the shared target check (T3.7), then the // additional sdd pass for `.superpowers/sdd/` targets. const bashVerdict = checkBashTargets(targets, repoRoot) if (bashVerdict.deny !== undefined) return bashVerdict.deny for (const target of targets) { const sdd = checkSddWrite({ filePath: target, repoRoot }) if (sdd.deny !== undefined) return sdd.deny } return undefined } return undefined } const DEFAULTS = { enabled: true, guards: true, approvalGates: true, bootstrap: true, bootstrapSubagents: false, harnessCheck: true, roleHint: true, roleCommand: true, selfCheck: true, checkCommand: true, hooksDir: '', rolesDir: '', projectRolesDir: '', roleNames: DEFAULT_ROLE_NAMES, bootstrapSkillPath: '', toolMappingPath: '', bootstrapMaxBytes: 65_536, } export function apply(ctx, rawConfig = {}) { const config = { ...DEFAULTS, ...(rawConfig ?? {}) } if (!Array.isArray(config.roleNames) || config.roleNames.length === 0) { config.roleNames = DEFAULT_ROLE_NAMES } const bundleDir = dirname(fileURLToPath(import.meta.url)) const hooksDir = resolveHooksDir(bundleDir, config.hooksDir) const rolesDir = resolveRolesDir(bundleDir, config.rolesDir) if (isLegacyClaudePath(rolesDir)) { warnLegacyOnce(ctx.logger, 'roles', rolesDir, 'roles') } const guard = createGuard({ hooksDir, logger: ctx.logger }) const selfCheckReport = config.enabled && config.selfCheck ? collectSelfCheck(ctx, config) : null // A feature is wired only when its own service passed the self-check (or // the self-check itself is off): a missing service must disable its feature // instead of crashing the whole bundle. const serviceOk = (feature) => !selfCheckReport || selfCheckReport.find((entry) => entry.feature === feature)?.ok === true const guardsOk = serviceOk('guards') const bootstrapOk = serviceOk('bootstrap') const commandsOk = serviceOk('commands') // Log every not-ok feature before any wiring runs, so a missing service // always leaves a diagnostic line for the feature being skipped. 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) } } } 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. ctx.tools.guard((exec) => { const repoRoot = sessionCwd(ctx.workingDirectory, exec.agent) const reason = guard.check(exec, repoRoot) if (reason !== undefined) { ctx.logger?.info?.('[dsh-harness] blocked %s: %s', exec.name, reason.split('\n')[0]) return reason } if (config.enabled && config.approvalGates) { const gateDeny = approvalGateDeny({ exec, repoRoot, hooksDir, logger: ctx.logger }) if (gateDeny !== undefined) { const gateReason = `BLOCKED [approval-gates]: ${gateDeny}` ctx.logger?.info?.('[dsh-harness] blocked %s: %s', exec.name, gateReason.split('\n')[0]) return gateReason } } return undefined }) } // The pre-step listener is registered when any of the three injections is // wanted: the bootstrap needs its service from the self-check, while // harness-check and role-hint gate directly on ctx.on (D-5) so they stay // alive with `bootstrap:false`. const prestepsWanted = (config.bootstrap && bootstrapOk) || config.harnessCheck || config.roleHint if (config.enabled && prestepsWanted && typeof ctx.on === 'function') { ctx.on('agent/pre-step', async ({ agent, signal }, next) => { const decision = await next() if (decision.kind === 'reject' || signal?.aborted) return decision const onOk = typeof ctx.on === 'function' let result = decision if (config.bootstrap && bootstrapOk) { if (config.bootstrapSubagents || !isSubagent(agent)) { const prepared = prepareBootstrap({ skillPath: config.bootstrapSkillPath, mappingPath: config.toolMappingPath || join(bundleDir, 'dsh-tools.md'), maxBytes: config.bootstrapMaxBytes, logger: ctx.logger, }) if (prepared && !isInjectionVisible(agent, result.messages, prepared.marker)) { result = { ...result, messages: [ ...(result.messages ?? []), createUserMessage({ content: [{ type: 'text', text: prepared.text }], source: { kind: 'user' }, }), ], } } } } if (config.enabled && config.harnessCheck && onOk && !isSubagent(agent)) { const cwd = sessionCwd(ctx.workingDirectory, agent) const res = detectAdoption(cwd) if (!res.adopted) { const { marker, body } = makePrestepMarker( buildHarnessCheckText(res.root ?? cwd, res.missing), ) if (!isInjectionVisible(agent, result.messages, marker)) { result = { ...result, messages: [ ...(result.messages ?? []), createUserMessage({ content: [{ type: 'text', text: body }], source: { kind: 'user' }, }), ], } } } } if (config.enabled && config.roleHint && onOk && !isSubagent(agent)) { const firstUser = (result.messages ?? []).find((message) => { if (message?.source?.kind !== 'user') return false const text = textOf(message) return text !== '' && !isHarnessMessage(text) }) if (firstUser) { const { marker, body } = makePrestepMarker( buildRoleHintText(detectRole(textOf(firstUser))), ) if (!isInjectionVisible(agent, result.messages, marker)) { result = { ...result, messages: [ ...(result.messages ?? []), createUserMessage({ content: [{ type: 'text', text: body }], source: { kind: 'user' }, }), ], } } } } return result }) } if (config.enabled && (config.roleCommand || config.checkCommand) && commandsOk) { ctx.inject(['commands'], (commandCtx) => { 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, config), logger: ctx.logger, }) } }) } // /approve has its own activation switch (T3.2), independent of /role and // /dsh-harness-check: the command resolves its repoRoot per invocation from // the receiving agent, so the wiring passes the service, not a root. The // commands availability is checked directly — the self-check skips the // `commands` feature when both roleCommand and checkCommand are off. if (config.enabled && config.approvalGates && typeof ctx.inject === 'function') { ctx.inject(['commands'], (commandCtx) => { registerApproveCommand({ commands: commandCtx.commands, userQuestions: typeof ctx.get === 'function' ? ctx.get('userQuestions') : undefined, workingDirectory: ctx.workingDirectory, logger: ctx.logger, }) }) } // `request_approval` is the model-driven front-end of the same gate: the // model initiates the approval, the owner only confirms in the dialog, and // the marker is still written by lib/approval.js. `userQuestions` is // resolved lazily, never injected — a missing service must refuse this tool // instead of parking the whole bundle. if (config.enabled && config.approvalGates && typeof ctx.tools?.register === 'function') { ctx.tools.register( createApproveTool({ getWorkingDirectory: () => ctx.workingDirectory, getUserQuestions: () => (typeof ctx.get === 'function' ? (ctx.get('userQuestions') ?? null) : null), logger: ctx.logger, now: () => new Date(), }), ) ctx.logger?.info?.('[dsh-harness] request_approval tool registered') } ctx.logger?.info?.( '[dsh-harness] ready: hooks=%s, bootstrap=%s, /role=%s', config.guards ? hooksDir : 'off', config.bootstrap ? 'on' : 'off', config.roleCommand ? 'on' : 'off', ) }