返回 CodeWhale
codec.ts
1 /**
2 * Decode hook process outcomes for both dialects. Exit 0 may carry structured
3 * JSON or plain stdout; exit 2 blocks with stderr as the reason; every other
4 * exit is a non-blocking error. Bridges decide which recognized fields apply.
5 * @module @deepseek-ai/dsh-hook-protocol/codec
6 */
7
8 import type { HookOutput } from './types.ts'
9
10 /** The exit code a hook uses to signal a blocking error (stderr → model). */
11 const BLOCKING_EXIT_CODE = 2
12
13 /** Read a string field from a parsed object, or `undefined` if absent/wrong type. */
14 function str(obj: Record<string, unknown>, key: string): string | undefined {
15 const v = obj[key]
16 return typeof v === 'string' ? v : undefined
17 }
18
19 /** Read a boolean field, or `undefined` if absent/wrong type. */
20 function bool(obj: Record<string, unknown>, key: string): boolean | undefined {
21 const v = obj[key]
22 return typeof v === 'boolean' ? v : undefined
23 }
24
25 /** A plain (non-null, non-array) object, or `undefined`. */
26 function obj(value: unknown): Record<string, unknown> | undefined {
27 return typeof value === 'object' && value !== null && !Array.isArray(value)
28 ? value as Record<string, unknown>
29 : undefined
30 }
31
32 /**
33 * The legacy TOP-LEVEL `decision` is only `approve`/`block` in both reference
34 * schemas — `allow`/`deny`/`ask` are reserved for `hookSpecificOutput.
35 * permissionDecision`. So an out-of-band `{"decision":"deny"}` is invalid and
36 * ignored here (it must not become a real blocking decision).
37 */
38 function topLevelDecisionOf(value: string | undefined): HookOutput['decision'] {
39 return value === 'approve' || value === 'block' ? value : undefined
40 }
41
42 /** A `hookSpecificOutput.permissionDecision` is `allow`/`deny`/`ask` only. */
43 function permissionDecisionOf(value: string | undefined): HookOutput['decision'] {
44 return value === 'allow' || value === 'deny' || value === 'ask' ? value : undefined
45 }
46
47 /**
48 * Decode process output into a dialect-neutral hook outcome. This function is
49 * total: malformed JSON remains plain stdout. When `expectedEventName` is set,
50 * a missing or different `hookSpecificOutput.hookEventName` discards only its
51 * event-scoped fields; top-level fields and the claimed discriminator remain.
52 * Omitting the guard applies the block as-is.
53 * @param exitCode - process exit, or `undefined` when spawn failed.
54 * @param stdout - output parsed as structured JSON only on exit 0.
55 * @param stderr - the captured stderr stream; becomes the blocking `reason` on exit 2.
56 * @param expectedEventName - firing event used to guard hook-specific fields; omit to disable the guard.
57 * @returns the dialect-neutral decoded outcome.
58 */
59 export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string, expectedEventName?: string): HookOutput {
60 const trimmedErr = stderr.trim()
61 const trimmedOut = stdout.trim()
62 // Plain stdout remains available even when it is not JSON.
63 const output: HookOutput = { exitCode, stderr: trimmedErr, stdout: trimmedOut }
64
65 // Both dialects treat exit 2 as a block with stderr as its reason.
66 if (exitCode === BLOCKING_EXIT_CODE) {
67 output.decision = 'block'
68 if (trimmedErr.length > 0) output.reason = trimmedErr
69 }
70
71 // Structured stdout is valid only for a clean exit.
72 if (exitCode === 0) {
73 // Only attempt JSON when stdout looks like a JSON object — matches the
74 // reference engines, which treat other stdout as plain text, not an error.
75 if (trimmedOut.startsWith('{')) {
76 let parsed: Record<string, unknown> | undefined
77 try {
78 parsed = obj(JSON.parse(trimmedOut))
79 } catch {
80 // Malformed JSON on a clean exit = no structured output (lenient, as the
81 // reference engines are). The plain stdout remains the bridge's to use.
82 parsed = undefined
83 }
84 if (parsed) applyStructured(output, parsed, expectedEventName)
85 }
86 }
87
88 return output
89 }
90
91 /**
92 * Fold a parsed structured-stdout object into `output` (mutates in place).
93 * `expectedEventName` (the firing event) gates the per-event `hookSpecificOutput`
94 * block: a block whose `hookEventName` names a different event — OR omits it — has
95 * its event-scoped fields discarded (any present `hookEventName` is still recorded).
96 */
97 function applyStructured(output: HookOutput, parsed: Record<string, unknown>, expectedEventName?: string): void {
98 const cont = bool(parsed, 'continue')
99 if (cont !== undefined) output.continue = cont
100 const stopReason = str(parsed, 'stopReason')
101 if (stopReason !== undefined) output.stopReason = stopReason
102 const sysMsg = str(parsed, 'systemMessage')
103 if (sysMsg !== undefined) output.systemMessage = sysMsg
104
105 // Top-level legacy `decision` (approve/block ONLY — allow/deny/ask there are
106 // invalid per both schemas) + its `reason`.
107 const topDecision = topLevelDecisionOf(str(parsed, 'decision'))
108 if (topDecision !== undefined) output.decision = topDecision
109 const topReason = str(parsed, 'reason')
110 if (topReason !== undefined) output.reason = topReason
111
112 // hookSpecificOutput: the per-event channel, keyed by `hookEventName`. The
113 // permissionDecision (allow/deny/ask) OVERRIDES the legacy top-level decision;
114 // additionalContext and updatedInput live here too.
115 const hso = obj(parsed.hookSpecificOutput)
116 if (hso) {
117 const eventName = str(hso, 'hookEventName')
118 // Always surface the discriminator (for the log/diagnostics), even on a
119 // mismatch — the record should show what the malformed block claimed.
120 if (eventName !== undefined) output.hookEventName = eventName
121 // A missing or mismatched discriminator cannot affect the firing event.
122 if (expectedEventName !== undefined && eventName !== expectedEventName) {
123 return
124 }
125 const permission = permissionDecisionOf(str(hso, 'permissionDecision'))
126 if (permission !== undefined) output.decision = permission
127 const permissionReason = str(hso, 'permissionDecisionReason')
128 if (permissionReason !== undefined) output.reason = permissionReason
129 const addCtx = str(hso, 'additionalContext')
130 if (addCtx !== undefined) output.additionalContext = addCtx
131 const updated = obj(hso.updatedInput)
132 if (updated !== undefined) output.updatedInput = updated
133 }
134 }
135
135 lines TYPESCRIPT