169 lines
5.7 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);
* - `/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.
*/
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { z } from '@deepseek-ai/schemastery'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { registerRoleCommand } from './command-role.js'
import { createGuard } from './lib/guards.js'
import { isBootstrapVisible, prepareBootstrap } from './lib/bootstrap.js'
import { DEFAULT_ROLE_NAMES } from './lib/role.js'
export const name = 'dsh-harness'
export const inject = ['tools', 'workingDirectory']
export const Config = z.object({
enabled: z.boolean().default(true),
guards: z.boolean().default(true),
bootstrap: z.boolean().default(true),
bootstrapSubagents: z.boolean().default(false),
roleCommand: z.boolean().default(true),
hooksDir: z.string().default(''),
rolesDir: z.string().default(''),
projectRolesDir: z.string().default(''),
roleNames: z.array(z.string()).default(DEFAULT_ROLE_NAMES),
bootstrapSkillPath: z.string().default(''),
toolMappingPath: z.string().default(''),
bootstrapMaxBytes: z.number().default(65_536),
})
/** 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 the installed global roles, then the repository's source roles. */
function resolveRolesDir(bundleDir, configured) {
if (configured) return configured
const installed = join(homedir(), '.claude', 'roles')
if (existsSync(join(installed, 'dev.md'))) return installed
const source = join(bundleDir, '..', '..', 'roles')
if (existsSync(join(source, 'dev.md'))) return source
return installed
}
function isSubagent(agent) {
if (!agent) return false
if (agent.parent || agent.parentAgent || agent.parentSessionId) return true
if (typeof agent.depth === 'number' && agent.depth > 0) return true
return false
}
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()
}
const DEFAULTS = {
enabled: true,
guards: true,
bootstrap: true,
bootstrapSubagents: false,
roleCommand: 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)
const guard = createGuard({ hooksDir, logger: ctx.logger })
if (config.enabled && config.guards) {
// 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 reason = guard.check(exec, sessionCwd(ctx.workingDirectory, exec.agent))
if (reason !== undefined) {
ctx.logger?.info?.('[dsh-harness] blocked %s: %s', exec.name, reason.split('\n')[0])
}
return reason
})
}
if (config.enabled && config.bootstrap) {
ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
const decision = await next()
if (decision.kind === 'reject' || signal?.aborted) return decision
if (!config.bootstrapSubagents && isSubagent(agent)) return decision
const prepared = prepareBootstrap({
skillPath: config.bootstrapSkillPath,
mappingPath: config.toolMappingPath || join(bundleDir, 'dsh-tools.md'),
maxBytes: config.bootstrapMaxBytes,
logger: ctx.logger,
})
if (!prepared) return decision
if (isBootstrapVisible(agent, decision.messages, prepared.marker)) return decision
return {
...decision,
messages: [
...(decision.messages ?? []),
createUserMessage({
content: [{ type: 'text', text: prepared.text }],
source: { kind: 'user' },
}),
],
}
})
}
if (config.enabled && config.roleCommand) {
ctx.inject(['commands'], (commandCtx) => {
registerRoleCommand({
commands: commandCtx.commands,
workingDirectory: ctx.workingDirectory,
rolesDir,
projectRolesDir: config.projectRolesDir,
roleNames: config.roleNames,
logger: ctx.logger,
})
})
}
ctx.logger?.info?.(
'[dsh-harness] ready: hooks=%s, bootstrap=%s, /role=%s',
config.guards ? hooksDir : 'off',
config.bootstrap ? 'on' : 'off',
config.roleCommand ? 'on' : 'off',
)
}