refactor(hooks): tighten the hook-protocol contract surface

Implement the tighten-hook-protocol-contract RFC (moved to implemented/):

- HookDialect narrows to 'claude' | 'codex': the 'native' variant had zero
  producers (native plugins on the seams write no hook/* provenance), and the
  dialect is defined as the bridge that ran the hook.
- HookOutput.suppressOutput is gone: the codec parsed it and every path
  discarded it with no warn and no deferral — hook stdout never enters a
  transcript, so there is nothing to suppress.
- hook/result.durationMs is gone: durable timing telemetry with no reader
  that the snapshot normalizer had to scrub as replay noise. With no duration
  to measure, runHook loses its injected now clock and the single-field
  RunHookResult wrapper — it returns the HookOutput directly. The committed
  hook fixtures had the field stripped mechanically (field-only diff); the
  stdout goldens never carried it.
- The bridges' double-defaulted defaultTimeoutMs config knob is replaced by
  one reference-default constant, DEFAULT_HOOK_TIMEOUT_MS, exported from the
  lib's runner and applied inside runHook; per-hook timeoutSec stays the
  override surface.
- The hook/result semantics move into the lib that declares the event:
  HookResultRecord now carries the decoded HookOutput and appendHookResult
  derives the decision string (decision ?? stop-on-continue:false ?? pass)
  and the 500-char stderrSummary truncation; both bridges delete their
  byte-identical private copies. The snapshot suite passes against the
  existing goldens, proving the derived values are unchanged.
- Rider: BLOCKING_EXIT_CODE is codec-internal again (zero importers).

Amend the hook-protocol-lib and hook-snapshot-matrix RFCs to the new facts,
update the lib/bridge READMEs and the session.md event tables, and retarget
the affected unit tests (including new lib-level coverage of the derivation
rules).
This commit is contained in:
Tianyi Cui
2026-07-04 15:44:26 +08:00
parent 226a8b5e4c
commit cd49670f4e
36 changed files with 233 additions and 235 deletions

View File

@@ -12,7 +12,6 @@ const config: Config = {
configPath: '/path/to/hooks.json', // required: a hooks.json or a settings file with a `hooks` key
pluginRoot: '/path/to/plugin', // optional: replaces ${CLAUDE_PLUGIN_ROOT} in command strings
projectDir: '/path/to/project', // optional: replaces ${CLAUDE_PROJECT_DIR} AND sets the hook env var; defaults to the session cwd when omitted
defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none (CC default)
}
```
@@ -25,7 +24,7 @@ In a `cordis.yml`:
projectDir: .
```
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning.
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir.

View File

@@ -71,15 +71,12 @@ export interface Config {
* 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
}
export const Config: z<Config> = z.object({
configPath: z.string().required(),
pluginRoot: z.string(),
projectDir: z.string(),
defaultTimeoutMs: z.number().default(600_000),
})
/** A stable per-handler id so an invoked/result pair correlates in the log. */
@@ -91,13 +88,6 @@ function nextHandlerId(point: string): string {
/** The `{kind:'plugin'}` source stamped on every context this bridge injects. */
const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-claude' }
/** Truncate a stderr blob for the `hook/result` summary field. */
function summarize(stderr: string): string | undefined {
const t = stderr.trim()
if (t.length === 0) return undefined
return t.length > 500 ? t.slice(0, 500) + '…' : t
}
export function apply(ctx: Context, config: Config): void {
// --- Parse the config ONCE at load. A read/parse failure is contained: the
// bridge logs and registers nothing rather than crashing boot (a typo'd path
@@ -118,8 +108,6 @@ export function apply(ctx: Context, config: Config): void {
return
}
const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000
/**
* Run every command hook configured for `point` whose matcher selects
* `matchQuery`, with the per-event `payload` on stdin, and fold the results.
@@ -163,17 +151,16 @@ export function apply(ctx: Context, config: Config): void {
...group.matcher !== undefined ? { matcher: group.matcher } : {},
})
}
const { output, durationMs } = await runHook(ctx.bash, hook, {
const output = await runHook(ctx.bash, hook, {
payload,
...hookEnv ? { env: hookEnv } : {},
...workdir !== undefined ? { cwd: workdir } : {},
...opts.signal ? { signal: opts.signal } : {},
defaultTimeoutMs,
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: ${point} hook requested updatedInput, which is not yet honored (ignored)`)
@@ -182,14 +169,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.logger.warn(`hooks-claude: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
}
if (session && opts.turn !== undefined) {
const stderrSummary = summarize(output.stderr)
appendHookResult(session, {
turn: opts.turn, point, handlerId,
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
...stderrSummary !== undefined ? { stderrSummary } : {},
durationMs,
})
appendHookResult(session, { turn: opts.turn, point, handlerId, output })
}
}
}

View File

@@ -297,8 +297,8 @@ describe('hooks-claude coverage — more default/sparse arms', () => {
})
})
describe('hooks-claude coverage — schema-bypass default + unspawnable hook', () => {
it('a direct apply() (schema bypass) defaults the timeout and runs', async () => {
describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => {
it('a direct apply() (schema bypass) with only configPath runs', async () => {
const d = dir()
const marker = join(d, 'ran')
const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
@@ -312,8 +312,9 @@ describe('hooks-claude coverage — schema-bypass default + unspawnable hook', (
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
// Direct apply with only configPath — bypasses schemastery's defaults, so the
// runtime `defaultTimeoutMs ?? 600_000` fallback is exercised.
// Direct apply with only configPath — bypasses schemastery's defaults, so
// the bridge must run on the raw minimal config (the per-hook timeout is
// the protocol lib's reference default, not a config knob).
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })