Files
deepseek-harness/packages/hooks/hook-protocol/src/merge.ts
T
Tianyi Cui 65165b5d54 feat(hooks): dsh-hook-protocol — shared Claude Code / Codex hook wire-protocol core
The two hook bridges (dsh-hooks-claude, dsh-hooks-codex) would otherwise duplicate
the bulk of the protocol — Codex deliberately reimplements a SUBSET of the Claude
Code protocol (same hooks.json shape, exit-code/stdout contract, command-hook
model). This library holds the genuinely-identical primitives; each bridge owns
only what differs (per-event stdin payload, env/substitution, decision mapping).

New packages/hooks/ group; hook-protocol is a LIBRARY (no plugin, registers/injects
nothing):
- matcher: matchesMatcher(pattern, query, mode) — the one dialect axis collapsed to
  a mode param (claude = literal-or-regex with pipe alternation; codex = always
  unanchored regex). Match-all on absent/''/'*'; invalid regex matches nothing.
- codec: parseHookOutput(exit, stdout, stderr) → dialect-neutral HookOutput. Exit 0
  → lenient JSON; exit 2 → blocking error (stderr = reason, surfaced as
  decision:'block'); other → non-blocking. Parses the CC superset
  (continue/stopReason/decision/hookSpecificOutput.{permissionDecision,
  additionalContext,updatedInput}/systemMessage); permissionDecision overrides the
  legacy top-level decision.
- runner: runHook(bash, hook, opts, now) — runs a command hook via ctx.bash (stdin
  payload + trusted-plugin env), honors timeoutSec, never throws (executor reject →
  non-blocking-error HookOutput). Injected clock for testable durations.
- merge: mergeHookOutputs — most-restrictive fold (deny>ask>allow, sticky stop,
  block reasons joined, context/system-messages accumulated).
- hook/* session events (declaration-merged into SessionEventMap, log-only like
  compact/*) + appendHookInvoked/appendHookResult helpers.

updatedInput is parsed but NOT honored (deferred pre-tool-input-rewrite RFC); a
bridge logs+warns. 47 unit tests at per-file 100% (matcher per-mode, codec per
exit-code/field, runner plumbing w/ stub executor, merge precedence, hook/*
helpers). RFC: implemented/feature/2026-06-30-hook-protocol-lib.md.
2026-07-01 00:41:53 +08:00

110 lines
4.3 KiB
TypeScript

/**
* Merge the outcomes of MULTIPLE hooks that matched one hook point into a single
* most-restrictive {@link MergedHookOutcome}. Both reference engines run matched
* hooks concurrently and fold their results; the precedence rules here are the
* intersection both dialects agree on (and the strictest interpretation where
* they differ), so a bridge gets one decision to map onto its seam:
*
* - **permission precedence `deny > ask > allow`**: any `deny`/`block` wins; an
* `ask` overrides `allow`; `allow`/`approve` only stands if nothing stricter
* appeared. (Claude Code's explicit precedence; Codex only ever blocks, so the
* rule degenerates correctly for it.)
* - **halt is sticky**: the first hook with `continue:false` sets `stop` and its
* `stopReason`.
* - **reasons accumulate**: block/deny reasons are joined with `\n\n` (Codex's
* `join_text_chunks`), so the model sees every objection, not just the first.
* - **context accumulates**: `additionalContext` from every hook is collected in
* order (CC concatenates; Codex keeps them as separate developer messages —
* either way the bridge gets the ordered list).
* - **systemMessages accumulate** likewise.
*
* @module @deepseek-ai/dsh-hook-protocol/merge
*/
import type { HookOutput } from './types.ts'
/** The single decision a hook point resolves to after merging all matched hooks. */
export type MergedDecision = 'allow' | 'ask' | 'deny' | 'none'
/** The folded outcome of every hook that matched one point. */
export interface MergedHookOutcome {
/**
* The most-restrictive permission decision across all hooks (`deny` > `ask` >
* `allow`), or `none` when no hook expressed one. `block`/`deny` both fold to
* `deny`; `approve`/`allow` both fold to `allow`.
*/
decision: MergedDecision
/** Joined (`\n\n`) reasons from every blocking/denying hook, or `undefined`. */
reason?: string
/** `true` when any hook asked to halt (`continue:false`). */
stop: boolean
/** The first halting hook's `stopReason`, when one halted. */
stopReason?: string
/** Every hook's `additionalContext`, in hook order (no joining — the bridge decides). */
additionalContext: string[]
/** Every hook's `systemMessage`, in hook order. */
systemMessages: string[]
}
/** Rank a single hook's decision for the deny>ask>allow precedence (higher = stricter). */
function rank(decision: HookOutput['decision']): number {
switch (decision) {
case 'deny': case 'block': return 3
case 'ask': return 2
case 'approve': case 'allow': return 1
default: return 0 // no decision
}
}
/** Collapse a ranked decision back to the merged enum. */
function decisionForRank(maxRank: number): MergedDecision {
switch (maxRank) {
case 3: return 'deny'
case 2: return 'ask'
case 1: return 'allow'
default: return 'none'
}
}
/**
* Fold `outputs` (the results of every hook that matched a point, in hook order)
* into one {@link MergedHookOutcome} by the precedence rules above. An empty list
* yields a neutral outcome (`decision: 'none'`, no stop, empty context) — the
* caller treats that as "no hook had anything to say".
*/
export function mergeHookOutputs(outputs: HookOutput[]): MergedHookOutcome {
let maxRank = 0
const reasons: string[] = []
let stop = false
let stopReason: string | undefined
const additionalContext: string[] = []
const systemMessages: string[] = []
for (const out of outputs) {
const r = rank(out.decision)
if (r > maxRank) maxRank = r
// Collect a reason only from a blocking/denying hook (rank 3) — an allow's
// "reason" is not an objection the model needs to see.
if (r === 3 && out.reason !== undefined && out.reason.length > 0) reasons.push(out.reason)
if (out.continue === false && !stop) {
stop = true
if (out.stopReason !== undefined) stopReason = out.stopReason
}
if (out.additionalContext !== undefined && out.additionalContext.length > 0) {
additionalContext.push(out.additionalContext)
}
if (out.systemMessage !== undefined && out.systemMessage.length > 0) {
systemMessages.push(out.systemMessage)
}
}
return {
decision: decisionForRank(maxRank),
...reasons.length > 0 ? { reason: reasons.join('\n\n') } : {},
stop,
...stopReason !== undefined ? { stopReason } : {},
additionalContext,
systemMessages,
}
}