mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -21,7 +21,7 @@
|
||||
import type { HookOutput } from './types.ts'
|
||||
|
||||
/** The exit code a hook uses to signal a blocking error (stderr → model). */
|
||||
export const BLOCKING_EXIT_CODE = 2
|
||||
const BLOCKING_EXIT_CODE = 2
|
||||
|
||||
/** Read a string field from a parsed object, or `undefined` if absent/wrong type. */
|
||||
function str(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
@@ -72,7 +72,7 @@ function permissionDecisionOf(value: string | undefined): HookOutput['decision']
|
||||
* `additionalContext`/`updatedInput`) are DISCARDED — a `PreToolUse` block on a
|
||||
* `Stop` hook must not deny the `Stop`. The block's `hookEventName` is still
|
||||
* surfaced (for the log/diagnostics), and the event-agnostic top-level fields
|
||||
* (`decision`/`reason`/`continue`/`stopReason`/`suppressOutput`/`systemMessage`)
|
||||
* (`decision`/`reason`/`continue`/`stopReason`/`systemMessage`)
|
||||
* are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the
|
||||
* block as-is — a caller that doesn't key by event opts out of the check.
|
||||
*/
|
||||
@@ -125,8 +125,6 @@ function applyStructured(output: HookOutput, parsed: Record<string, unknown>, ex
|
||||
if (cont !== undefined) output.continue = cont
|
||||
const stopReason = str(parsed, 'stopReason')
|
||||
if (stopReason !== undefined) output.stopReason = stopReason
|
||||
const suppress = bool(parsed, 'suppressOutput')
|
||||
if (suppress !== undefined) output.suppressOutput = suppress
|
||||
const sysMsg = str(parsed, 'systemMessage')
|
||||
if (sysMsg !== undefined) output.systemMessage = sysMsg
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { HookDialect } from './types.ts'
|
||||
import type { HookDialect, HookOutput } from './types.ts'
|
||||
|
||||
/** What identifies a hook invocation across its invoked/result pair. */
|
||||
export interface HookInvocation {
|
||||
@@ -37,14 +37,22 @@ export interface HookResultRecord {
|
||||
turn: number
|
||||
point: string
|
||||
handlerId: string
|
||||
/** The dialect-neutral decision the bridge resolved (`deny`/`allow`/`block`/…). */
|
||||
decision: string
|
||||
/** The process exit code (absent when the hook could not run). */
|
||||
exitCode?: number
|
||||
/** A truncated stderr summary (the block-reason source on exit 2). */
|
||||
stderrSummary?: string
|
||||
/** Wall-clock duration of the run. */
|
||||
durationMs: number
|
||||
/**
|
||||
* The decoded outcome the run produced. {@link appendHookResult} derives the
|
||||
* durable `decision`/`exitCode`/`stderrSummary` fields from it, so the shared
|
||||
* event's semantics live here, in the lib that declares it, not per-bridge.
|
||||
*/
|
||||
output: HookOutput
|
||||
}
|
||||
|
||||
/** How many characters of stderr the `hook/result.stderrSummary` field keeps. */
|
||||
const STDERR_SUMMARY_MAX = 500
|
||||
|
||||
/** Truncate a stderr blob for the `hook/result.stderrSummary` field (`undefined` when empty). */
|
||||
function summarizeStderr(stderr: string): string | undefined {
|
||||
const t = stderr.trim()
|
||||
if (t.length === 0) return undefined
|
||||
return t.length > STDERR_SUMMARY_MAX ? t.slice(0, STDERR_SUMMARY_MAX) + '…' : t
|
||||
}
|
||||
|
||||
/** Append a `hook/invoked` provenance event to `session`. */
|
||||
@@ -58,15 +66,22 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation):
|
||||
})
|
||||
}
|
||||
|
||||
/** Append a `hook/result` outcome event to `session` (pairs with a prior `hook/invoked`). */
|
||||
/**
|
||||
* Append a `hook/result` outcome event to `session` (pairs with a prior
|
||||
* `hook/invoked`). Owns the durable event's semantics: `decision` is the hook's
|
||||
* parsed decision, else `'stop'` when it asked to halt (`continue: false`),
|
||||
* else `'pass'`; `stderrSummary` is the trimmed stderr truncated to 500
|
||||
* characters (omitted when empty); `exitCode` is omitted when the hook never ran.
|
||||
*/
|
||||
export function appendHookResult(session: Session, record: HookResultRecord): void {
|
||||
const { output } = record
|
||||
const stderrSummary = summarizeStderr(output.stderr)
|
||||
session.append('hook/result', {
|
||||
turn: record.turn,
|
||||
point: record.point,
|
||||
handlerId: record.handlerId,
|
||||
decision: record.decision,
|
||||
...record.exitCode !== undefined ? { exitCode: record.exitCode } : {},
|
||||
...record.stderrSummary !== undefined ? { stderrSummary: record.stderrSummary } : {},
|
||||
durationMs: record.durationMs,
|
||||
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
|
||||
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
|
||||
...stderrSummary !== undefined ? { stderrSummary } : {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
* - {@link mergeHookOutputs} — fold multiple matched hooks into one
|
||||
* most-restrictive {@link MergedHookOutcome} (deny > ask > allow).
|
||||
* - {@link appendHookInvoked} / {@link appendHookResult} — the log-only `hook/*`
|
||||
* session-event helpers (declaration-merged into `SessionEventMap`).
|
||||
* session-event helpers (declaration-merged into `SessionEventMap`);
|
||||
* `appendHookResult` derives the durable `decision`/`stderrSummary` from the
|
||||
* {@link HookOutput} so the shared event's semantics live in one place.
|
||||
*
|
||||
* Each bridge owns what genuinely DIFFERS: building the per-event stdin payload
|
||||
* (CC vs Codex field sets), the dialect's env/substitution, and mapping the
|
||||
@@ -29,9 +31,9 @@ export type {
|
||||
MatcherMode,
|
||||
} from './types.ts'
|
||||
export { matchesMatcher } from './matcher.ts'
|
||||
export { BLOCKING_EXIT_CODE, parseHookOutput } from './codec.ts'
|
||||
export { runHook } from './runner.ts'
|
||||
export type { RunHookOptions, RunHookResult } from './runner.ts'
|
||||
export { parseHookOutput } from './codec.ts'
|
||||
export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts'
|
||||
export type { RunHookOptions } from './runner.ts'
|
||||
export { mergeHookOutputs } from './merge.ts'
|
||||
export type { MergedDecision, MergedHookOutcome } from './merge.ts'
|
||||
export { appendHookInvoked, appendHookResult } from './events.ts'
|
||||
|
||||
@@ -17,6 +17,14 @@ import type { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import { parseHookOutput } from './codec.ts'
|
||||
import type { CommandHook, HookOutput } from './types.ts'
|
||||
|
||||
/**
|
||||
* The reference default per-hook timeout, in ms (10 minutes) — the value both
|
||||
* Claude Code and Codex apply to a hook whose config sets no `timeout`. It
|
||||
* lives here, once, as the protocol's default; a per-hook {@link CommandHook.timeoutSec}
|
||||
* is the override surface.
|
||||
*/
|
||||
export const DEFAULT_HOOK_TIMEOUT_MS = 600_000
|
||||
|
||||
/** Everything a single hook invocation needs beyond its command line. */
|
||||
export interface RunHookOptions {
|
||||
/** The JSON payload object written to the hook's stdin (the bridge builds it). */
|
||||
@@ -27,8 +35,6 @@ export interface RunHookOptions {
|
||||
cwd?: string
|
||||
/** Abort signal — cancels the hook run when fired (the parent step aborts). */
|
||||
signal?: AbortSignal
|
||||
/** Default timeout (ms) when the hook config sets none. */
|
||||
defaultTimeoutMs: number
|
||||
/** Whether to append a trailing newline to the stdin payload (CC yes, Codex no). */
|
||||
trailingNewline: boolean
|
||||
/**
|
||||
@@ -40,30 +46,22 @@ export interface RunHookOptions {
|
||||
expectedEventName?: string
|
||||
}
|
||||
|
||||
/** The {@link HookOutput} plus the wall-clock duration of the run (for `hook/result`). */
|
||||
export interface RunHookResult {
|
||||
output: HookOutput
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `hook` via `bash` with `options.payload` serialized to its stdin, then
|
||||
* decode the result. `now` is injected (a monotonic-ms source) so the duration
|
||||
* is testable without a real clock. The hook's configured `timeoutSec` (wire
|
||||
* unit: seconds) overrides `defaultTimeoutMs`. The command runs with the
|
||||
* dialect's `env` merged after the executor's credential scrub (the trusted-
|
||||
* plugin path). NEVER throws: an infrastructure failure (the executor rejecting)
|
||||
* is surfaced as a {@link HookOutput} with `exitCode: undefined`, so the caller's
|
||||
* merge logic treats it as a non-blocking error rather than crashing the turn.
|
||||
* decode the result into a {@link HookOutput}. The hook's configured
|
||||
* `timeoutSec` (wire unit: seconds) overrides {@link DEFAULT_HOOK_TIMEOUT_MS}.
|
||||
* The command runs with the dialect's `env` merged after the executor's
|
||||
* credential scrub (the trusted-plugin path). NEVER throws: an infrastructure
|
||||
* failure (the executor rejecting) is surfaced as a {@link HookOutput} with
|
||||
* `exitCode: undefined`, so the caller's merge logic treats it as a
|
||||
* non-blocking error rather than crashing the turn.
|
||||
*/
|
||||
export async function runHook(
|
||||
bash: BashExecutor,
|
||||
hook: CommandHook,
|
||||
options: RunHookOptions,
|
||||
now: () => number,
|
||||
): Promise<RunHookResult> {
|
||||
const started = now()
|
||||
const timeoutMs = hook.timeoutSec !== undefined ? hook.timeoutSec * 1000 : options.defaultTimeoutMs
|
||||
): Promise<HookOutput> {
|
||||
const timeoutMs = hook.timeoutSec !== undefined ? hook.timeoutSec * 1000 : DEFAULT_HOOK_TIMEOUT_MS
|
||||
const stdin = JSON.stringify(options.payload) + (options.trailingNewline ? '\n' : '')
|
||||
|
||||
const request = {
|
||||
@@ -81,18 +79,12 @@ export async function runHook(
|
||||
// protocol's exit-code contract is numeric, so a signal death maps to
|
||||
// `undefined` (a non-blocking error — no clean exit code to act on).
|
||||
const exitCode = result.exitCode ?? undefined
|
||||
return {
|
||||
output: parseHookOutput(exitCode, result.stdout.text, result.stderr.text, options.expectedEventName),
|
||||
durationMs: now() - started,
|
||||
}
|
||||
return parseHookOutput(exitCode, result.stdout.text, result.stderr.text, options.expectedEventName)
|
||||
} catch (error: unknown) {
|
||||
// The executor rejects only on infrastructure faults (unusable workdir,
|
||||
// missing shell). A hook that cannot run is a non-blocking error: no exit
|
||||
// code, the failure on stderr for the record. The turn proceeds.
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return {
|
||||
output: parseHookOutput(undefined, '', message),
|
||||
durationMs: now() - started,
|
||||
}
|
||||
return parseHookOutput(undefined, '', message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
/**
|
||||
* A hook command was invoked at a hook point — log-only provenance (like
|
||||
* `compact/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`).
|
||||
* `dialect` is the bridge that ran it (`claude`/`codex`/`native`), `point`
|
||||
* `dialect` is the bridge that ran it (`claude`/`codex`), `point`
|
||||
* the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group
|
||||
* pattern that selected it (absent for match-all), `handlerId` a stable id
|
||||
* for the command (so an invoked/result pair correlates). `turn` is the open
|
||||
@@ -34,11 +34,13 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
}
|
||||
/**
|
||||
* A hook command's outcome — log-only, paired with a prior `hook/invoked`
|
||||
* (same `handlerId`). `decision` is the resolved dialect-neutral outcome the
|
||||
* bridge mapped it to (`allow`/`deny`/`ask`/`block`/`continue`/`stop`/`pass`),
|
||||
* `exitCode` the process exit (absent if it never ran), `stderrSummary` a
|
||||
* truncated stderr (the block reason source on exit 2), `durationMs` the wall
|
||||
* time. `turn` matches the `hook/invoked`.
|
||||
* (same `handlerId`). `decision` is the dialect-neutral outcome derived by
|
||||
* `appendHookResult` (which owns the rule): the hook's parsed decision
|
||||
* (`approve`/`allow`/`block`/`deny`/`ask`), else `'stop'` when it asked to
|
||||
* halt via `continue:false`, else `'pass'`. `exitCode` is the process exit
|
||||
* (absent if it never ran), `stderrSummary` the trimmed stderr truncated to
|
||||
* 500 characters (the block reason source on exit 2). `turn` matches the
|
||||
* `hook/invoked`.
|
||||
* @mode emit
|
||||
*/
|
||||
'hook/result': {
|
||||
@@ -48,13 +50,16 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
decision: string
|
||||
exitCode?: number
|
||||
stderrSummary?: string
|
||||
durationMs: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Which protocol dialect a hook config / invocation belongs to. */
|
||||
export type HookDialect = 'claude' | 'codex' | 'native'
|
||||
/**
|
||||
* The bridge that ran a hook — the CC bridge stamps `'claude'`, the Codex
|
||||
* bridge `'codex'`. A native plugin on the interception seams is not a bridge
|
||||
* and writes no `hook/*` provenance (see the interception-seams RFC).
|
||||
*/
|
||||
export type HookDialect = 'claude' | 'codex'
|
||||
|
||||
/**
|
||||
* One configured command hook (the `{ type: 'command', command, timeout? }`
|
||||
@@ -115,8 +120,6 @@ export interface HookOutput {
|
||||
continue?: boolean
|
||||
/** Human-readable reason shown when {@link continue} is `false`. */
|
||||
stopReason?: string
|
||||
/** Hide the hook's stdout from the transcript (CC `suppressOutput`). */
|
||||
suppressOutput?: boolean
|
||||
/**
|
||||
* The neutral blocking decision a hook expressed, folded from the two channels
|
||||
* the reference protocols keep DISTINCT: the legacy top-level `decision`
|
||||
|
||||
Reference in New Issue
Block a user