返回 CodeWhale
merge.ts
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
101 lines TYPESCRIPT