refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -0,0 +1,123 @@
/**
* Parse Claude Code's event-to-matcher-group hook format into shared {@link MatcherGroup}s.
* Only command hooks run; other hook types are returned as skipped so the
* bridge can warn. Plugin-root and project-directory substitutions are applied
* to commands at parse time.
* @module @deepseek-ai/dsh-hooks-claude-code/config
*/
import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
const CLAUDE_EVENTS = [
'SessionStart',
'UserPromptSubmit',
'PreToolUse',
'PostToolUse',
'Stop',
'SubagentStart',
'SubagentStop',
] as const
/** A parsed CC config: event name → its matcher groups (command hooks only). */
export type ClaudeCodeHookConfig = Record<string, MatcherGroup[]>
/** A skipped non-command hook, surfaced so the bridge can warn about it. */
export interface SkippedHook {
event: string
type: string
}
/** The outcome of parsing one config file: the runnable groups + what was skipped. */
export interface ParsedClaudeConfig {
config: ClaudeCodeHookConfig
skipped: SkippedHook[]
}
/** Substitution variables applied to each `command` string at parse time. */
export interface SubstitutionVars {
/** Replaces `${CLAUDE_PLUGIN_ROOT}` — the plugin's root dir. */
pluginRoot?: string
/** Replaces `${CLAUDE_PROJECT_DIR}` — the project root. */
projectDir?: string
}
/** A plain (non-null, non-array) object, else undefined. */
function asObject(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: undefined
}
/**
* Apply `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution to a command string.
* @param command - the raw command from config.
* @param vars - the substitution values; a token whose variable is unset stays verbatim.
* @returns the command with every occurrence of each set token replaced.
*/
export function substituteCommand(command: string, vars: SubstitutionVars): string {
let out = command
if (vars.pluginRoot !== undefined) out = out.split('${CLAUDE_PLUGIN_ROOT}').join(vars.pluginRoot)
if (vars.projectDir !== undefined) out = out.split('${CLAUDE_PROJECT_DIR}').join(vars.projectDir)
return out
}
/**
* Parse either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are
* ignored rather than failing boot; unsupported events are ignored before their groups are parsed,
* non-command hooks are returned in `skipped`, and substitutions are applied to every surviving
* command. Matcher fields on UserPromptSubmit and Stop are discarded because those events have no
* matcher subject. A matcher-bearing supported runnable group with an invalid regex throws a
* `SyntaxError`, allowing the bridge to reject the complete config before listener registration.
*
* @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare
* event map.
* @param vars - substitution values applied to every surviving `command` (defaults to
* none).
* @returns the runnable per-event groups plus the skipped non-command hooks.
*/
export function parseClaudeCodeConfig(raw: unknown, vars: SubstitutionVars = {}): ParsedClaudeConfig {
const config: ClaudeCodeHookConfig = {}
const skipped: SkippedHook[] = []
// Accept either `{ hooks: { … } }` (a settings file) or the bare event map.
const root = asObject(raw)
const hooksMap = root ? asObject(root.hooks) ?? root : undefined
if (!hooksMap) return { config, skipped }
for (const event of CLAUDE_EVENTS) {
const rawGroups = hooksMap[event]
if (!Array.isArray(rawGroups)) continue
const groups: MatcherGroup[] = []
for (const rawGroup of rawGroups) {
const group = asObject(rawGroup)
if (!group || !Array.isArray(group.hooks)) continue
const commands: MatcherGroup['hooks'] = []
for (const rawHook of group.hooks) {
const hook = asObject(rawHook)
if (!hook) continue
const type = typeof hook.type === 'string' ? hook.type : 'command'
if (type !== 'command') {
skipped.push({ event, type })
continue
}
if (typeof hook.command !== 'string') continue
commands.push({
command: substituteCommand(hook.command, vars),
...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {},
})
}
if (commands.length === 0) continue
const matcher = event === 'UserPromptSubmit' || event === 'Stop'
? undefined
: typeof group.matcher === 'string' ? group.matcher : undefined
const diagnostic = matcherDiagnostic(matcher, 'claude-code')
if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
groups.push({
...matcher !== undefined ? { matcher } : {},
hooks: commands,
})
}
if (groups.length > 0) config[event] = groups
}
return { config, skipped }
}

View File

@@ -0,0 +1,361 @@
/**
* Bridge for unmodified Claude Code command hooks on harness interception
* extension points. It supports SessionStart, prompt/tool pre/post, Stop, and subagent
* start/stop. It owns Claude payloads, environment, substitution, and decision
* mapping; shared execution and parsing live in `dsh-hook-protocol`.
* `updatedInput` is logged and warned but not honored. Bespoke behavior should
* use typed native plugins on the same extension points; see the
* [hook-bridges Agent Note](../../../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md).
* @module @deepseek-ai/dsh-hooks-claude-code
*/
import { readFileSync } from 'node:fs'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import {
appendHookInvoked,
appendHookResult,
createDetachedRuns,
DEFAULT_HOOK_TIMEOUT_MS,
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
matchesMatcher,
mergeHookOutputs,
runHook,
type HookOutput,
type MatcherGroup,
type MergedHookOutcome,
} from '@deepseek-ai/dsh-hook-protocol'
// Pulls in the declaration-merged subagent events and the identity pairing their
// start/end edges.
import type { SubagentRunId } from '@deepseek-ai/dsh-subagent'
import { parseClaudeCodeConfig, type ClaudeCodeHookConfig } from './config.ts'
export const name = 'hooks-claude-code'
// `bash` is required to run hooks; the rest are read opportunistically via
// ctx.get so a deployment can load this bridge without every extension point present.
export const inject = ['shell']
/** Plugin config: where the CC hook config lives + substitution roots. */
export interface Config {
/**
* Path to a `hooks.json` or a settings file whose `hooks` key holds the config.
* Process-level: read once at load, a relative path resolves against the process
* launch cwd, so one config applies to the whole process.
* TODO(per-session-hook-config): per-session discovery of a project-local
* `hooks.json` from each `session/new.cwd`.
*/
configPath: string
/**
* Replaces `${CLAUDE_PLUGIN_ROOT}` in command strings (the plugin's root dir).
*/
pluginRoot?: string
/**
* Replaces `${CLAUDE_PROJECT_DIR}` in command strings AND is exported as the
* `CLAUDE_PROJECT_DIR` env var for hook processes. When omitted, the env var
* defaults per-run to the agent's session workspace (`session.header.cwd`, the
* same dir the hook runs in) — Claude Code always exports this var, and common
* unmodified hooks reference `$CLAUDE_PROJECT_DIR` for project-relative paths.
*/
projectDir?: string
/** Default per-hook timeout in ms when a hook sets none (CC default: 600000). */
defaultTimeoutMs?: number
/** Character cap for the `hook/result` event's persisted stderr summary. */
stderrSummaryMaxChars?: number
}
export const Config: z<Config> = z.object({
configPath: z.string().required(),
pluginRoot: z.string(),
projectDir: z.string(),
defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),
stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),
})
/** A stable per-handler id so an invoked/result pair correlates in the log. */
let handlerCounter = 0
function nextHandlerId(point: string): string {
return `claude-code:${point}:${++handlerCounter}`
}
/** The `{kind:'plugin'}` source stamped on every context this bridge injects. */
const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-claude-code' }
/** The summary cap bounds a persisted event field — a positive integer or the slice misbehaves silently. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`hooks-claude-code: ${name} must be a positive integer`)
}
}
export function apply(ctx: Context, config: Config): void {
// Validate before config parsing so a bad value cannot be hidden by its early return.
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
// Parse once at load. A read or parse failure logs and registers nothing.
let parsed: ClaudeCodeHookConfig = {}
try {
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
const result = parseClaudeCodeConfig(raw, {
...config.pluginRoot !== undefined ? { pluginRoot: config.pluginRoot } : {},
...config.projectDir !== undefined ? { projectDir: config.projectDir } : {},
})
parsed = result.config
for (const s of result.skipped) {
ctx.logger.warn(`hooks-claude-code: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`)
}
} catch (error: unknown) {
ctx.logger.warn(`hooks-claude-code: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`)
return
}
// Emit-shaped points run detached, so track their chains; disposal aborts
// active hooks and drains continuations before resolving.
const detached = createDetachedRuns()
// Only the start edge guarantees registry access. Retain each local child
// through its paired end so stop hooks keep the session workspace after the
// handle unregisters the agent. Every retained entry relies on that paired
// end; a producer that can omit it must provide another release edge.
const subagentChildren = new Map<SubagentRunId, Agent>()
ctx.effect(() => () => detached.drain(), 'hooks-claude-code: drain detached hook runs')
/**
* Run every command hook configured for `point` whose matcher selects
* `matchQuery`, with the per-event `payload` on stdin, and fold the results.
* Writes a `hook/invoked`/`hook/result` pair per hook when `opts.turn` names
* an open turn. Detached lifecycle points omit the pair. Returns the merged outcome (a neutral,
* already-most-restrictive view) for the caller to map onto its extension point
* decision. `matchQuery` is the event's matcher subject (tool name, session
* source, …); `''` for events that ignore matchers.
*/
async function runPoint(
point: string,
matchQuery: string,
payload: unknown,
opts: { agent?: Agent; turn?: number; readonly signal: AbortSignal },
): Promise<MergedHookOutcome> {
const groups: MatcherGroup[] = parsed[point] ?? []
const outputs: HookOutput[] = []
// Run the hook in the agent's session workspace (the `session/new` cwd on the session
// header), not the executor or entry-point process's launch dir.
const workdir = opts.agent?.session.header.cwd
// CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session
// workspace (the same dir the hook runs in).
const projectDir = config.projectDir ?? workdir
const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined
for (const group of groups) {
if (!matchesMatcher(group.matcher, matchQuery, 'claude-code')) continue
for (const hook of group.hooks) {
const handlerId = nextHandlerId(point)
const session = opts.agent?.session
if (session && opts.turn !== undefined) {
appendHookInvoked(session, {
turn: opts.turn, point, dialect: 'claude-code', handlerId,
...group.matcher !== undefined ? { matcher: group.matcher } : {},
})
}
const { output, durationMs } = await runHook(ctx.shell, hook, {
payload,
defaultTimeoutMs,
...hookEnv ? { env: hookEnv } : {},
...workdir !== undefined ? { cwd: workdir } : {},
signal: opts.signal,
trailingNewline: true,
// Discard a `hookSpecificOutput` block whose `hookEventName` names a
// different event than the one firing (the schemas key it by event).
expectedEventName: point,
}, () => performance.now())
outputs.push(output)
if (output.updatedInput !== undefined) {
ctx.logger.warn(`hooks-claude-code: ${point} hook requested updatedInput, which is not yet honored (ignored)`)
}
if (output.systemMessage !== undefined) {
ctx.logger.warn(`hooks-claude-code: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
}
if (session && opts.turn !== undefined) {
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })
}
}
}
return mergeHookOutputs(outputs)
}
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt mechanism.
/** Build additional model context from hook output, or return undefined when empty. */
function contextFrom(merged: MergedHookOutcome): UserMessage | undefined {
if (merged.additionalContext.length === 0) return undefined
const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text }))
return createUserMessage({ content, source: PLUGIN_SOURCE })
}
/** Prepend one context without flattening source fields or other downstream metadata. */
function prependContext(ours: UserMessage, theirs: UserMessage[] | undefined): UserMessage[] {
return [ours, ...theirs ?? []]
}
// SessionStart injects context when its detached hook resolves; a slow hook
// may miss the first request.
// TODO(session-start-gating): add a startup gate before promising first-turn delivery.
ctx.on('agent/session-start', ({ agent, source }) => {
detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal })
.then((merged) => {
const context = contextFrom(merged)
if (context) agent.inject(context)
})
.catch((error: unknown) => {
ctx.logger.warn(`hooks-claude-code: SessionStart hook failed: ${String(error)}`)
}))
})
// --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no
// matcher subject (CC ignores matchers for this event). ---
ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise<PreStepDecision> => {
if (messages.length === 0) return next()
const content = messages.flatMap(message => message.content)
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn, signal })
if (merged.decision === 'deny') {
return { kind: 'reject' }
}
// Delegate so later listeners may still rewrite or reject, then prepend our
// context only to a downstream enter decision.
const downstream = await next()
const ours = contextFrom(merged)
if (!ours || downstream.kind !== 'enter') return downstream
return {
kind: 'enter',
messages: [...downstream.messages, ours],
}
})
// --- PreToolUse → PreToolDecision. Matcher subject is the tool name. ---
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
const turn = lastTurn(exec.agent)
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} }
return next()
})
// --- PostToolUse → PostToolDecision. Matcher subject is the tool name. ---
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
const turn = lastTurn(exec.agent)
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
const context = contextFrom(merged)
if (merged.decision === 'deny') {
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} }
}
// Our hooks did not block. DELEGATE so a later listener can still block/replace,
// then fold our context onto its decision (a downstream block carries it too).
const downstream = await next()
if (!context) return downstream
if (downstream.kind === 'block') {
return { ...downstream, additionalContexts: prependContext(context, downstream.additionalContexts) }
}
return {
...downstream,
additionalContexts: prependContext(context, downstream.additionalContexts),
}
})
// A blocking Stop hook steers at the stopping boundary, which makes the
// machine observe pending input and run another step.
// TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile.
ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise<void> => {
const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn, signal })
if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation.
const text = merged.reason ?? 'continue: blocked by Stop hook'
agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: PLUGIN_SOURCE }))
}
})
// SubagentStart may inject child context; SubagentStop only observes. Both
// use the live child's workspace and the generic agent-type matcher subject.
ctx.on('subagent/start', (info) => {
const child = ctx.get('agents')?.get(info.id)
if (child !== undefined) subagentChildren.set(info.runId, child)
detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
.then((merged) => {
const context = contextFrom(merged)
if (context && child) child.inject(context)
})
.catch((error: unknown) => { ctx.logger.warn(`hooks-claude-code: SubagentStart hook failed: ${String(error)}`) }))
})
ctx.on('subagent/end', (info) => {
const child = subagentChildren.get(info.runId) ?? ctx.get('agents')?.get(info.id)
subagentChildren.delete(info.runId)
detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload(ctx, 'SubagentStop', info, child), { ...child ? { agent: child } : {}, signal: detached.signal }))
})
}
/**
* The `agent_type` value the bridge reports for SubagentStart/Stop. The harness
* subagent seam carries no per-kind label, so the bridge uses Claude Code's own
* Task-tool default — a hooks.json with a default/`*`/empty `agent_type` matcher
* fires; a config matching a specific kind (e.g. `code-reviewer`) does not.
*/
const SUBAGENT_TYPE = 'general-purpose'
// --- Per-event stdin payloads (the CC DIALECT shape). Field names match CC's
// hook input schema; this is the part a bridge owns. ---
/** The last open turn number in the agent's log, or 0 without an agent. */
function lastTurn(agent: Agent | undefined): number {
if (!agent) return 0
const last = [...agent.session.events].findLast(e => e.type === 'turn/start')
/* v8 ignore next -- agent-present callers are tool/stop extension points inside an open turn. */
return last?.type === 'turn/start' ? last.data.turn : 0
}
/** Flatten content blocks to the text a hook payload carries (the common case). */
function blocksToText(content: ContentBlock[]): string {
return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')
}
function base(ctx: Context, agent: Agent | undefined, event: string): Record<string, unknown> {
return {
session_id: agent?.session.header.id ?? '',
transcript_path: agent === undefined
? ''
: ctx.get('sessionPersistence')?.locate(agent.session.header)?.path ?? '',
cwd: agent?.session.header.cwd ?? process.cwd(),
hook_event_name: event,
}
}
function sessionStartPayload(ctx: Context, agent: Agent, source: string): Record<string, unknown> {
return { ...base(ctx, agent, 'SessionStart'), source }
}
function promptPayload(ctx: Context, agent: Agent, content: ContentBlock[]): Record<string, unknown> {
return { ...base(ctx, agent, 'UserPromptSubmit'), prompt: blocksToText(content) }
}
function preToolPayload(ctx: Context, exec: ToolExecution): Record<string, unknown> {
return { ...base(ctx, exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId }
}
function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): Record<string, unknown> {
return { ...base(ctx, exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
}
function stopPayload(ctx: Context, agent: Agent): Record<string, unknown> {
return { ...base(ctx, agent, 'Stop'), stop_hook_active: false }
}
/**
* Build a SubagentStart/SubagentStop payload from the CC base (the child's
* `session_id`/`cwd` when the child agent is available) plus the subagent-hook
* fields. `agent_type` is the CC-default {@link SUBAGENT_TYPE}; `stop_hook_active`
* is present on SubagentStop only (the loop-guard flag, always false).
*/
function subagentPayload(ctx: Context, event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record<string, unknown> {
return {
...base(ctx, child, event),
agent_id: info.id,
agent_type: SUBAGENT_TYPE,
...event === 'SubagentStop' ? { stop_hook_active: false } : {},
}
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-hooks-claude-code`.
* @module @deepseek-ai/dsh-hooks-claude-code/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-claude-code'
/** Cordis companion plugin name. */
export const name = 'hooks-claude-code-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this bridge publishes hook-protocol session events, whose companion owns
* which invocation event each result cites.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */