| 1 | /** |
| 2 | * Merge matched hooks into one most-restrictive outcome. Permission precedence |
| 3 | * is `deny > ask > allow`; the first `continue:false` stop is sticky; reasons |
| 4 | * for the winning rank are joined; and context and system messages accumulate |
| 5 | * in hook order. |
| 6 | * @module @deepseek-ai/dsh-hook-protocol/merge |
| 7 | */ |
| 8 | |
| 9 | import type { HookOutput } from './types.ts' |
| 10 | |
| 11 | /** The single decision a hook point resolves to after merging all matched hooks. */ |
| 12 | export type MergedDecision = 'allow' | 'ask' | 'deny' | 'none' |
| 13 | |
| 14 | /** The folded outcome of every hook that matched one point. */ |
| 15 | export interface MergedHookOutcome { |
| 16 | /** |
| 17 | * The most-restrictive permission decision across all hooks (`deny` > `ask` > |
| 18 | * `allow`), or `none` when no hook expressed one. `block`/`deny` both fold to |
| 19 | * `deny`; `approve`/`allow` both fold to `allow`. |
| 20 | */ |
| 21 | decision: MergedDecision |
| 22 | /** Joined (`\n\n`) reasons from every blocking/denying hook, or `undefined`. */ |
| 23 | reason?: string |
| 24 | /** `true` when any hook asked to halt (`continue:false`). */ |
| 25 | stop: boolean |
| 26 | /** The first halting hook's `stopReason`, when one halted. */ |
| 27 | stopReason?: string |
| 28 | /** Every hook's `additionalContext`, in hook order (no joining — the bridge decides). */ |
| 29 | additionalContext: string[] |
| 30 | /** Every hook's `systemMessage`, in hook order. */ |
| 31 | systemMessages: string[] |
| 32 | } |
| 33 | |
| 34 | /** Rank a single hook's decision for the deny>ask>allow precedence (higher = stricter). */ |
| 35 | function rank(decision: HookOutput['decision']): number { |
| 36 | switch (decision) { |
| 37 | case 'deny': case 'block': return 3 |
| 38 | case 'ask': return 2 |
| 39 | case 'approve': case 'allow': return 1 |
| 40 | default: return 0 // no decision |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | /** Collapse a ranked decision back to the merged enum. */ |
| 45 | function decisionForRank(maxRank: number): MergedDecision { |
| 46 | switch (maxRank) { |
| 47 | case 3: return 'deny' |
| 48 | case 2: return 'ask' |
| 49 | case 1: return 'allow' |
| 50 | default: return 'none' |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Fold `outputs` (the results of every hook that matched a point, in hook order) |
| 56 | * into one {@link MergedHookOutcome} by the precedence rules above. An empty list |
| 57 | * yields a neutral outcome (`decision: 'none'`, no stop, empty context) — the |
| 58 | * caller treats that as "no hook had anything to say". |
| 59 | * @param outputs - every matched hook's decoded output, in hook order. |
| 60 | * @returns the single folded outcome the bridge maps onto its extension point. |
| 61 | */ |
| 62 | export function mergeHookOutputs(outputs: HookOutput[]): MergedHookOutcome { |
| 63 | let maxRank = 0 |
| 64 | // Keep reasons per rank so only objections explaining the winning decision surface. |
| 65 | const reasonsByRank = new Map<number, string[]>() |
| 66 | let stop = false |
| 67 | let stopReason: string | undefined |
| 68 | const additionalContext: string[] = [] |
| 69 | const systemMessages: string[] = [] |
| 70 | |
| 71 | for (const out of outputs) { |
| 72 | const r = rank(out.decision) |
| 73 | if (r > maxRank) maxRank = r |
| 74 | if ((r === 3 || r === 2) && out.reason !== undefined && out.reason.length > 0) { |
| 75 | const list = reasonsByRank.get(r) ?? [] |
| 76 | list.push(out.reason) |
| 77 | reasonsByRank.set(r, list) |
| 78 | } |
| 79 | if (out.continue === false && !stop) { |
| 80 | stop = true |
| 81 | if (out.stopReason !== undefined) stopReason = out.stopReason |
| 82 | } |
| 83 | if (out.additionalContext !== undefined && out.additionalContext.length > 0) { |
| 84 | additionalContext.push(out.additionalContext) |
| 85 | } |
| 86 | if (out.systemMessage !== undefined && out.systemMessage.length > 0) { |
| 87 | systemMessages.push(out.systemMessage) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | const reasons = reasonsByRank.get(maxRank) ?? [] |
| 92 | return { |
| 93 | decision: decisionForRank(maxRank), |
| 94 | ...reasons.length > 0 ? { reason: reasons.join('\n\n') } : {}, |
| 95 | stop, |
| 96 | ...stopReason !== undefined ? { stopReason } : {}, |
| 97 | additionalContext, |
| 98 | systemMessages, |
| 99 | } |
| 100 | } |
| 101 |