421 lines
15 KiB
JavaScript
421 lines
15 KiB
JavaScript
/**
|
|
* 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',
|
|
)
|
|
}
|