dsh-hooks 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generic webhook notification for dsh-hooks — posts the hook context as
4
+ * one JSON document to any HTTP endpoint. Works with Slack incoming
5
+ * webhooks, Discord, Lark/DingTalk custom bots, ntfy, Bark, n8n, or any
6
+ * automation service that accepts a JSON POST.
7
+ *
8
+ * Reads the hook context from DSH_HOOK_* environment variables and POSTs
9
+ * `application/json`. Only present context fields are included, so the
10
+ * payload shape is stable and small.
11
+ *
12
+ * Required environment (set in the dsh process environment, NOT in config):
13
+ * DSH_HOOKS_WEBHOOK_URL target webhook URL
14
+ * …or pass it as the first flag: --url <url>
15
+ *
16
+ * Usage (from a dsh-hooks config):
17
+ * - on: 'turn/end'
18
+ * when: 'completed'
19
+ * run: 'node examples/notify-webhook.mjs --url https://hooks.slack.com/services/T/B/…'
20
+ * - on: 'tool/call'
21
+ * run: 'node examples/notify-webhook.mjs'
22
+ *
23
+ * Optional flags:
24
+ * --url <url> webhook URL (overrides the environment variable)
25
+ * --slack post Slack-style `{ text }` instead of the full
26
+ * context document
27
+ * --timeout <ms> fetch timeout (default 10000)
28
+ * -q quiet: suppress success output (hooks parse stdout)
29
+ *
30
+ * Zero npm dependencies: fetch is global in Node 18+.
31
+ * The module exports its helpers for testing; it only executes when invoked
32
+ * directly (node notify-webhook.mjs), not when imported.
33
+ */
34
+ import { pathToFileURL } from 'node:url'
35
+
36
+ /** Every DSH_HOOK_* variable this script understands, in payload order. */
37
+ const CONTEXT_VARS = [
38
+ 'DSH_HOOK_EVENT',
39
+ 'DSH_HOOK_TIMESTAMP',
40
+ 'DSH_HOOK_SESSION_ID',
41
+ 'DSH_HOOK_SESSION_NAME',
42
+ 'DSH_HOOK_CWD',
43
+ 'DSH_HOOK_TURN',
44
+ 'DSH_HOOK_STEP',
45
+ 'DSH_HOOK_REASON',
46
+ 'DSH_HOOK_TOOL',
47
+ 'DSH_HOOK_CALL_ID',
48
+ 'DSH_HOOK_TOOL_ARGS',
49
+ 'DSH_HOOK_TOOL_ERROR',
50
+ 'DSH_HOOK_SOURCE',
51
+ 'DSH_HOOK_DURATION_MS',
52
+ 'DSH_HOOK_STATUS',
53
+ 'DSH_HOOK_ERROR',
54
+ 'DSH_HOOK_CONTENT',
55
+ 'DSH_HOOK_USAGE_INPUT_TOKENS',
56
+ 'DSH_HOOK_USAGE_OUTPUT_TOKENS',
57
+ 'DSH_HOOK_USAGE_CACHE_READ_TOKENS',
58
+ 'DSH_HOOK_USAGE_CACHE_WRITE_TOKENS',
59
+ 'DSH_HOOK_USAGE_REASONING_TOKENS',
60
+ ]
61
+
62
+ /** Parse one context env var into a number, or undefined when absent/garbage. */
63
+ function num(value) {
64
+ if (value === undefined || value === '') return undefined
65
+ const n = Number(value)
66
+ return Number.isFinite(n) ? n : undefined
67
+ }
68
+
69
+ /** Read the raw context: only the variables that are present and non-empty. */
70
+ export function readEnv(env = process.env) {
71
+ const raw = {}
72
+ for (const name of CONTEXT_VARS) {
73
+ const value = env[name]
74
+ if (value !== undefined && value !== '') raw[name] = value
75
+ }
76
+ return raw
77
+ }
78
+
79
+ /** Group one raw env snapshot into a nested JSON payload. */
80
+ export function buildPayload(raw) {
81
+ const payload = {}
82
+ const session = {}
83
+ if (raw.DSH_HOOK_SESSION_ID) session.id = raw.DSH_HOOK_SESSION_ID
84
+ if (raw.DSH_HOOK_SESSION_NAME) session.name = raw.DSH_HOOK_SESSION_NAME
85
+ if (raw.DSH_HOOK_CWD) session.cwd = raw.DSH_HOOK_CWD
86
+ if (Object.keys(session).length > 0) payload.session = session
87
+ if (raw.DSH_HOOK_EVENT) payload.event = raw.DSH_HOOK_EVENT
88
+ if (raw.DSH_HOOK_TIMESTAMP) payload.timestamp = raw.DSH_HOOK_TIMESTAMP
89
+ const turn = num(raw.DSH_HOOK_TURN)
90
+ const step = num(raw.DSH_HOOK_STEP)
91
+ const durationMs = num(raw.DSH_HOOK_DURATION_MS)
92
+ if (turn !== undefined) payload.turn = turn
93
+ if (step !== undefined) payload.step = step
94
+ if (durationMs !== undefined) payload.duration_ms = durationMs
95
+ if (raw.DSH_HOOK_REASON) payload.reason = raw.DSH_HOOK_REASON
96
+ if (raw.DSH_HOOK_TOOL) payload.tool = raw.DSH_HOOK_TOOL
97
+ if (raw.DSH_HOOK_CALL_ID) payload.call_id = raw.DSH_HOOK_CALL_ID
98
+ if (raw.DSH_HOOK_TOOL_ARGS) payload.tool_args = raw.DSH_HOOK_TOOL_ARGS
99
+ if (raw.DSH_HOOK_TOOL_ERROR) payload.tool_error = raw.DSH_HOOK_TOOL_ERROR
100
+ if (raw.DSH_HOOK_SOURCE) payload.source = raw.DSH_HOOK_SOURCE
101
+ if (raw.DSH_HOOK_STATUS) payload.status = raw.DSH_HOOK_STATUS
102
+ if (raw.DSH_HOOK_ERROR) payload.error = raw.DSH_HOOK_ERROR
103
+ if (raw.DSH_HOOK_CONTENT) payload.content = raw.DSH_HOOK_CONTENT
104
+ const usage = {}
105
+ const usageInput = num(raw.DSH_HOOK_USAGE_INPUT_TOKENS)
106
+ const usageOutput = num(raw.DSH_HOOK_USAGE_OUTPUT_TOKENS)
107
+ const usageCacheRead = num(raw.DSH_HOOK_USAGE_CACHE_READ_TOKENS)
108
+ const usageCacheWrite = num(raw.DSH_HOOK_USAGE_CACHE_WRITE_TOKENS)
109
+ const usageReasoning = num(raw.DSH_HOOK_USAGE_REASONING_TOKENS)
110
+ if (usageInput !== undefined) usage.input_tokens = usageInput
111
+ if (usageOutput !== undefined) usage.output_tokens = usageOutput
112
+ if (usageCacheRead !== undefined) usage.cache_read_tokens = usageCacheRead
113
+ if (usageCacheWrite !== undefined) usage.cache_write_tokens = usageCacheWrite
114
+ if (usageReasoning !== undefined) usage.reasoning_tokens = usageReasoning
115
+ if (Object.keys(usage).length > 0) payload.usage = usage
116
+ return payload
117
+ }
118
+
119
+ /** One-line summary for Slack-style `{ text }` payloads. */
120
+ export function summarize(payload) {
121
+ const label = payload.session?.name || payload.session?.id || ''
122
+ const where = label ? ` · ${label}` : ''
123
+ switch (payload.event) {
124
+ case 'turn/end':
125
+ if (payload.reason === 'completed') return `✅ 任务已完成${where}(回合 #${payload.turn ?? '?'})`
126
+ if (payload.error) return `❌ 任务失败${where}: ${payload.error.slice(0, 200)}`
127
+ return `⏸ 任务${payload.reason ? ` ${payload.reason}` : '结束'}${where}(回合 #${payload.turn ?? '?'})`
128
+ case 'tool/call':
129
+ return `🔧 调用工具 ${payload.tool ?? ''}${where}`
130
+ case 'tool/result':
131
+ if (payload.tool_error) return `⚠️ 工具 ${payload.tool ?? ''} 失败${where}: ${payload.tool_error}`
132
+ return `✅ 工具 ${payload.tool ?? ''} 完成${where}`
133
+ case 'approval/asked':
134
+ return `⏳ 需要审批:工具 ${payload.tool ?? ''}${where}`
135
+ case 'user/message':
136
+ return `💬 新消息${where}${payload.content ? `:${payload.content.slice(0, 120)}` : ''}`
137
+ case 'session/title':
138
+ return `🏷 会话改名${where}: ${payload.session?.name ?? ''}`
139
+ case 'session/created':
140
+ return `✨ 会话开始${where}`
141
+ case 'session/disposed':
142
+ return `🏁 会话结束${where}`
143
+ default:
144
+ return `🔔 DSH ${payload.event ?? '事件'}${where}`
145
+ }
146
+ }
147
+
148
+ /** Parse the optional CLI flags. */
149
+ export function parseArgs(args) {
150
+ const opts = { url: '', slack: false, timeoutMs: 10000, quiet: false }
151
+ for (let i = 0; i < args.length; i++) {
152
+ const a = args[i]
153
+ if (a === '--url') opts.url = args[++i] ?? ''
154
+ else if (a === '--slack') opts.slack = true
155
+ else if (a === '--timeout') {
156
+ const n = Number(args[++i])
157
+ if (Number.isFinite(n) && n > 0) opts.timeoutMs = n
158
+ } else if (a === '-q') opts.quiet = true
159
+ }
160
+ return opts
161
+ }
162
+
163
+ /** POST one JSON body to the webhook with a timeout; one retry on transport failure. */
164
+ export async function postJson(url, body, timeoutMs = 10000) {
165
+ const attempt = async () => {
166
+ const controller = new AbortController()
167
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
168
+ try {
169
+ return await fetch(url, {
170
+ method: 'POST',
171
+ headers: { 'content-type': 'application/json' },
172
+ body: JSON.stringify(body),
173
+ signal: controller.signal,
174
+ })
175
+ } finally {
176
+ clearTimeout(timer)
177
+ }
178
+ }
179
+ let response
180
+ try {
181
+ response = await attempt()
182
+ } catch (error) {
183
+ // One retry for transient transport failures (webhook endpoints often
184
+ // drop the first request when cold).
185
+ try {
186
+ response = await attempt()
187
+ } catch (retryError) {
188
+ const cause = retryError instanceof Error ? retryError.message : String(retryError)
189
+ throw new Error(`webhook 请求失败(重试后仍失败): ${cause}`)
190
+ }
191
+ }
192
+ if (!response.ok) throw new Error(`webhook 响应 HTTP ${response.status}`)
193
+ }
194
+
195
+ /** Full pipeline for one hook event. Exported for tests and CLI use. */
196
+ export async function run(env = process.env, args = []) {
197
+ const opts = parseArgs(args)
198
+ const url = opts.url || env.DSH_HOOKS_WEBHOOK_URL
199
+ if (!url) throw new Error('缺少 webhook URL:请设置 DSH_HOOKS_WEBHOOK_URL 或传 --url <url>')
200
+ const raw = readEnv(env)
201
+ if (!raw.DSH_HOOK_EVENT) throw new Error('缺少 DSH_HOOK_EVENT(请通过 dsh-hooks 触发,不要直接运行)')
202
+ const payload = buildPayload(raw)
203
+ const body = opts.slack ? { text: summarize(payload) } : payload
204
+ await postJson(url, body, opts.timeoutMs)
205
+ return body
206
+ }
207
+
208
+ function isDirectRun() {
209
+ try {
210
+ return process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href
211
+ } catch {
212
+ return false
213
+ }
214
+ }
215
+
216
+ if (isDirectRun()) {
217
+ run(process.env, process.argv.slice(2))
218
+ .then((body) => {
219
+ if (!parseArgs(process.argv.slice(2)).quiet) {
220
+ const text = JSON.stringify(body)
221
+ console.log(`已发送 webhook: ${text.length > 80 ? text.slice(0, 80) + '…' : text}`)
222
+ }
223
+ process.exit(0)
224
+ })
225
+ .catch((error) => {
226
+ console.warn(`[dsh-hooks/notify-webhook] ${error instanceof Error ? error.message : String(error)}`)
227
+ process.exit(1)
228
+ })
229
+ }
package/lib/config.d.ts CHANGED
@@ -1,10 +1,23 @@
1
1
  /** Hookable event kinds. v1 is emit-only: no waterfall/interception events. */
2
- export declare const HOOK_EVENTS: readonly ["turn/start", "turn/end", "approval/asked", "agent/created", "agent/disposed", "agent/error", "agent/status"];
2
+ export declare const HOOK_EVENTS: readonly ['turn/start', 'turn/end', 'step/end', 'tool/call', 'tool/result', 'user/message', 'approval/asked', 'session/title', 'session/created', 'session/disposed', 'agent/created', 'agent/disposed', 'agent/error', 'agent/status'];
3
3
  export type HookEvent = (typeof HOOK_EVENTS)[number];
4
4
  /** `turn/end` reason kinds (from @deepseek-ai/dsh-session TurnEndReasonMap). */
5
- export declare const TURN_END_REASONS: readonly ["completed", "error", "aborted", "blocked", "max-tokens", "interrupted"];
5
+ export declare const TURN_END_REASONS: readonly ['completed', 'error', 'aborted', 'blocked', 'max-tokens', 'interrupted'];
6
6
  export type TurnEndReasonKind = (typeof TURN_END_REASONS)[number];
7
- /** One declared hook: a matching event runs `run` through the platform shell. */
7
+ /**
8
+ * Built-in notification: send the hook context through a channel declared
9
+ * in config, no external script required. Mutually exclusive with `run` —
10
+ * a hook declares exactly one of the two.
11
+ */
12
+ export interface NotifySpec {
13
+ /** Channel to send through. */
14
+ channel: 'webhook' | 'desktop';
15
+ /** webhook: the target URL (falls back to the `DSH_HOOKS_WEBHOOK_URL` env var). */
16
+ url?: string;
17
+ /** webhook: post a Slack-style `{ text }` one-line summary instead of the full context document. */
18
+ slack?: boolean;
19
+ }
20
+ /** One declared hook: a matching event runs `run` (or sends `notify`). */
8
21
  export interface HookSpec {
9
22
  /** Event that triggers the hook. */
10
23
  on: HookEvent;
@@ -13,13 +26,48 @@ export interface HookSpec {
13
26
  * (`completed`, `error`, …). Ignored for other events.
14
27
  */
15
28
  when?: TurnEndReasonKind;
16
- /** Command to spawn through the platform shell. */
17
- run: string;
29
+ /**
30
+ * Optional field → regex filters: every declared regex must match the
31
+ * context's field value for the hook to run. Fields are `HookContext`
32
+ * keys (`tool`, `sessionName`, `sessionId`, `error`, `source`, `cwd`,
33
+ * `content`, …); a field absent from the context never matches.
34
+ */
35
+ match?: Record<string, RegExp>;
36
+ /**
37
+ * Command to spawn through the platform shell. Exactly one of `run` and
38
+ * `notify` must be declared.
39
+ */
40
+ run?: string;
41
+ /** Built-in notification channel. Exactly one of `run` and `notify` must be declared. */
42
+ notify?: NotifySpec | null;
43
+ /**
44
+ * How the context reaches the command. `env` (default) passes the
45
+ * `DSH_HOOK_*` variables only; `stdin` additionally writes the full
46
+ * context as one JSON document to the command's stdin.
47
+ */
48
+ input?: 'env' | 'stdin';
18
49
  /** Per-hook timeout in milliseconds. Defaults to 10000. */
19
50
  timeoutMs?: number;
51
+ /**
52
+ * Retry count for non-zero exit codes (default 0: fire-and-forget,
53
+ * never retried). Spawn failures and timeouts are never retried.
54
+ */
55
+ retries?: number;
56
+ /** Base delay between retries in milliseconds; doubles per attempt. Defaults to 500. */
57
+ retryDelayMs?: number;
58
+ }
59
+ /** Execution-history settings: in-memory ring buffer + optional JSONL log. */
60
+ export interface HistoryConfig {
61
+ /** Persist records to disk. Defaults to true. */
62
+ enabled?: boolean;
63
+ /** JSONL file path. Defaults to ~/.dsh/dsh-hooks/history.jsonl (0600). */
64
+ path?: string;
65
+ /** In-memory ring buffer size. Defaults to 500. */
66
+ max?: number;
20
67
  }
21
68
  export interface Config {
22
69
  hooks?: HookSpec[];
70
+ history?: HistoryConfig | null;
23
71
  }
24
72
  export declare const Config: {
25
73
  (data?: Config | null): Config;
package/lib/config.js CHANGED
@@ -3,7 +3,14 @@ import Schema from '@deepseek-ai/schemastery';
3
3
  export const HOOK_EVENTS = [
4
4
  'turn/start',
5
5
  'turn/end',
6
+ 'step/end',
7
+ 'tool/call',
8
+ 'tool/result',
9
+ 'user/message',
6
10
  'approval/asked',
11
+ 'session/title',
12
+ 'session/created',
13
+ 'session/disposed',
7
14
  'agent/created',
8
15
  'agent/disposed',
9
16
  'agent/error',
@@ -24,11 +31,39 @@ export const TURN_END_REASONS = [
24
31
  // declaration self-contained.
25
32
  export const Config = Schema.object({
26
33
  hooks: Schema.array(Schema.object({
27
- on: Schema.union([...HOOK_EVENTS]).description('触发事件:turn/start | turn/end | approval/asked | agent/created | agent/disposed | agent/error | agent/status'),
34
+ on: Schema.union([...HOOK_EVENTS]).description('触发事件:turn/start | turn/end | step/end | tool/call | tool/result | user/message | approval/asked | session/title | session/created | session/disposed | agent/created | agent/disposed | agent/error | agent/status'),
28
35
  when: Schema.union([...TURN_END_REASONS]).description('可选过滤:对 turn/end 匹配结束原因(completed/error/aborted/blocked/max-tokens/interrupted);其他事件忽略该字段'),
29
- run: Schema.string().required().description('触发时通过系统 shell 执行的命令'),
36
+ match: Schema.dict(Schema.regExp()).description('可选通用过滤:字段 正则,全部匹配才触发。字段为上下文键(tool/sessionName/sessionId/error/source/cwd/content/reason/…),上下文中不存在的字段视为不匹配'),
37
+ run: Schema.string().description('触发时通过系统 shell 执行的命令(与 notify 二选一)'),
38
+ notify: Schema.union([
39
+ Schema.object({
40
+ channel: Schema.union(['webhook', 'desktop'])
41
+ .required()
42
+ .description('通知渠道:webhook 发 HTTP JSON;desktop 发系统桌面通知'),
43
+ url: Schema.string().description('webhook 渠道的目标 URL(缺省时用环境变量 DSH_HOOKS_WEBHOOK_URL)'),
44
+ slack: Schema.boolean().default(false).description('webhook 渠道:改为发送 Slack 风格 { text } 单行摘要'),
45
+ }),
46
+ Schema.const(null),
47
+ ])
48
+ .default(null)
49
+ .description('内置通知(与 run 二选一):配置驱动发送,无需外部脚本'),
50
+ input: Schema.union(['env', 'stdin'])
51
+ .default('env')
52
+ .description('上下文传递方式:env 只传 DSH_HOOK_* 环境变量(默认);stdin 额外把完整上下文 JSON 写入命令标准输入'),
30
53
  timeoutMs: Schema.number().default(10000).description('单次执行超时(毫秒)'),
31
- }).description('一个事件 命令的 hook 声明'))
54
+ retries: Schema.natural().default(0).description('非零退出码的重试次数(默认 0 不重试;spawn 失败与超时不重试)'),
55
+ retryDelayMs: Schema.natural().default(500).description('重试基础间隔(毫秒),每次翻倍'),
56
+ }).description('一个事件 → 命令/通知的 hook 声明'))
32
57
  .default([])
33
58
  .description('事件触发时执行的外部命令列表;按声明顺序触发'),
59
+ history: Schema.union([
60
+ Schema.object({
61
+ enabled: Schema.boolean().default(true).description('是否把执行历史持久化到磁盘(默认 true)'),
62
+ path: Schema.string().description('JSONL 文件路径(默认 ~/.dsh/dsh-hooks/history.jsonl,权限 0600)'),
63
+ max: Schema.natural().default(500).description('内存环形缓冲条数(默认 500)'),
64
+ }),
65
+ Schema.const(null),
66
+ ])
67
+ .default(null)
68
+ .description('hook 执行历史:内存环形缓冲 + 可选 JSONL 持久化日志(供 UI/调试使用,严格 best-effort)'),
34
69
  }).description('dsh-hooks 配置:声明式生命周期 hooks');
package/lib/context.d.ts CHANGED
@@ -14,14 +14,28 @@ export interface HookContext {
14
14
  /** Absolute working directory of the session, when known. */
15
15
  cwd?: string;
16
16
  turn?: number;
17
+ /** Step number of the turn (step and tool events). */
18
+ step?: number;
17
19
  reason?: string;
18
20
  tool?: string;
19
21
  callId?: string;
22
+ /** Raw tool-call arguments JSON as the model produced it (tool/call). */
23
+ toolArgs?: string;
24
+ /** Tool failure identity (`name`/`code`) when a tool result errored. */
25
+ toolError?: string;
26
+ /** Producer source kind: user message source, title source, etc. */
27
+ source?: string;
20
28
  durationMs?: number;
21
29
  status?: string;
22
30
  error?: string;
23
- /** Turn content snapshot, e.g. the turn's final assistant text. */
31
+ /** Event content snapshot: turn assistant text, tool result text, */
24
32
  content?: string;
33
+ /** Aggregated token usage of the turn (turn/end), when reported. */
34
+ usageInputTokens?: number;
35
+ usageOutputTokens?: number;
36
+ usageCacheReadTokens?: number;
37
+ usageCacheWriteTokens?: number;
38
+ usageReasoningTokens?: number;
25
39
  timestamp: string;
26
40
  }
27
41
  export declare function toEnv(ctx: HookContext): Record<string, string>;
package/lib/context.js CHANGED
@@ -13,12 +13,20 @@ export function toEnv(ctx) {
13
13
  env.DSH_HOOK_CWD = ctx.cwd;
14
14
  if (ctx.turn !== undefined)
15
15
  env.DSH_HOOK_TURN = String(ctx.turn);
16
+ if (ctx.step !== undefined)
17
+ env.DSH_HOOK_STEP = String(ctx.step);
16
18
  if (ctx.reason !== undefined)
17
19
  env.DSH_HOOK_REASON = ctx.reason;
18
20
  if (ctx.tool !== undefined)
19
21
  env.DSH_HOOK_TOOL = ctx.tool;
20
22
  if (ctx.callId !== undefined)
21
23
  env.DSH_HOOK_CALL_ID = ctx.callId;
24
+ if (ctx.toolArgs !== undefined)
25
+ env.DSH_HOOK_TOOL_ARGS = ctx.toolArgs;
26
+ if (ctx.toolError !== undefined)
27
+ env.DSH_HOOK_TOOL_ERROR = ctx.toolError;
28
+ if (ctx.source !== undefined)
29
+ env.DSH_HOOK_SOURCE = ctx.source;
22
30
  if (ctx.durationMs !== undefined)
23
31
  env.DSH_HOOK_DURATION_MS = String(ctx.durationMs);
24
32
  if (ctx.status !== undefined)
@@ -27,6 +35,16 @@ export function toEnv(ctx) {
27
35
  env.DSH_HOOK_ERROR = ctx.error;
28
36
  if (ctx.content !== undefined)
29
37
  env.DSH_HOOK_CONTENT = ctx.content;
38
+ if (ctx.usageInputTokens !== undefined)
39
+ env.DSH_HOOK_USAGE_INPUT_TOKENS = String(ctx.usageInputTokens);
40
+ if (ctx.usageOutputTokens !== undefined)
41
+ env.DSH_HOOK_USAGE_OUTPUT_TOKENS = String(ctx.usageOutputTokens);
42
+ if (ctx.usageCacheReadTokens !== undefined)
43
+ env.DSH_HOOK_USAGE_CACHE_READ_TOKENS = String(ctx.usageCacheReadTokens);
44
+ if (ctx.usageCacheWriteTokens !== undefined)
45
+ env.DSH_HOOK_USAGE_CACHE_WRITE_TOKENS = String(ctx.usageCacheWriteTokens);
46
+ if (ctx.usageReasoningTokens !== undefined)
47
+ env.DSH_HOOK_USAGE_REASONING_TOKENS = String(ctx.usageReasoningTokens);
30
48
  return env;
31
49
  }
32
50
  /** Render `{{DSH_HOOK_*}}` placeholders from the context map. */
@@ -0,0 +1,48 @@
1
+ import { type HookSpec, type TurnEndReasonKind } from './config.js';
2
+ import type { HookContext } from './context.js';
3
+ /** Profile patch file for a profile name. */
4
+ export declare function patchFilePath(profile: string): string;
5
+ /**
6
+ * Load and normalize the dsh-hooks config block from a profile's
7
+ * cordis.patch.yml. Runs the block through the Config schema so match
8
+ * regexes compile and invalid entries fail loudly.
9
+ */
10
+ export declare function loadHooks(profile: string, paths?: {
11
+ patchFile?: string;
12
+ }): {
13
+ hooks: HookSpec[];
14
+ source: string;
15
+ };
16
+ /** A synthetic context for the simulated event, overridable per field. */
17
+ export declare function mockContext(event: string, overrides?: Partial<HookContext>): HookContext;
18
+ export interface DryRunLine {
19
+ /** 1-based hook index in the config. */
20
+ index: number;
21
+ matched: boolean;
22
+ /** Short reason the hook was skipped (empty when matched). */
23
+ why: string;
24
+ /** One-line hook description. */
25
+ summary: string;
26
+ }
27
+ /** One-line hook description for report rows. */
28
+ export declare function describeHook(hook: HookSpec): string;
29
+ /** Evaluate every hook against the simulated event/context. */
30
+ export declare function evaluateHooks(hooks: readonly HookSpec[], event: string, ctx: HookContext, reasonKind?: TurnEndReasonKind): DryRunLine[];
31
+ export interface DryRunOptions {
32
+ profile?: string;
33
+ event: string;
34
+ reason?: TurnEndReasonKind;
35
+ tool?: string;
36
+ sessionName?: string;
37
+ /** Actually run the matching hooks (real side effects!). */
38
+ execute?: boolean;
39
+ print?: (line: string) => void;
40
+ paths?: {
41
+ patchFile?: string;
42
+ };
43
+ }
44
+ /** Full dry-run report; optionally executes the matching hooks. */
45
+ export declare function runDryRun(options: DryRunOptions): Promise<{
46
+ matched: number;
47
+ total: number;
48
+ }>;
package/lib/dry-run.js ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * dry-run: simulate a hook event against a profile's dsh-hooks config and
3
+ * report which hooks would fire (and why the others would not). `--execute`
4
+ * actually runs the matching hooks, for end-to-end verification.
5
+ */
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import YAML from 'yaml';
10
+ import { Config } from './config.js';
11
+ import { matchFilters } from './events.js';
12
+ import { createHookRunner } from './runner.js';
13
+ import { fireNotify } from './notify.js';
14
+ /** Profile patch file for a profile name. */
15
+ export function patchFilePath(profile) {
16
+ return join(homedir(), '.dsh', 'profiles', profile, 'cordis.patch.yml');
17
+ }
18
+ /**
19
+ * Load and normalize the dsh-hooks config block from a profile's
20
+ * cordis.patch.yml. Runs the block through the Config schema so match
21
+ * regexes compile and invalid entries fail loudly.
22
+ */
23
+ export function loadHooks(profile, paths = {}) {
24
+ const file = paths.patchFile ?? patchFilePath(profile);
25
+ if (!existsSync(file))
26
+ throw new Error(`未找到 ${file}(profile 不存在或没有 cordis.patch.yml)`);
27
+ let entries;
28
+ try {
29
+ entries = YAML.parse(readFileSync(file, 'utf8'));
30
+ }
31
+ catch {
32
+ throw new Error(`cordis.patch.yml 解析失败:${file}`);
33
+ }
34
+ if (!Array.isArray(entries))
35
+ throw new Error('cordis.patch.yml 顶层必须是 YAML 数组');
36
+ let block;
37
+ for (const entry of entries) {
38
+ if (entry !== null && typeof entry === 'object' && entry.id === 'dsh-hooks') {
39
+ block = entry;
40
+ break;
41
+ }
42
+ }
43
+ if (block === undefined)
44
+ throw new Error('cordis.patch.yml 中没有 id: dsh-hooks 的配置块');
45
+ const rawHooks = block.config?.hooks;
46
+ const config = Config({ hooks: (Array.isArray(rawHooks) ? rawHooks : []) });
47
+ return { hooks: config.hooks ?? [], source: file };
48
+ }
49
+ /** A synthetic context for the simulated event, overridable per field. */
50
+ export function mockContext(event, overrides = {}) {
51
+ return {
52
+ event,
53
+ sessionId: 'dry-run',
54
+ sessionName: 'dry-run 会话',
55
+ cwd: process.cwd(),
56
+ turn: 1,
57
+ step: 1,
58
+ tool: 'pwsh',
59
+ callId: 'dry-run-call',
60
+ content: 'dry-run 模拟内容',
61
+ timestamp: new Date().toISOString(),
62
+ ...overrides,
63
+ };
64
+ }
65
+ /** One-line hook description for report rows. */
66
+ export function describeHook(hook) {
67
+ const when = hook.when ? ` when=${hook.when}` : '';
68
+ const match = hook.match && Object.keys(hook.match).length > 0
69
+ ? ` match=${JSON.stringify(Object.fromEntries(Object.entries(hook.match).map(([key, re]) => [key, re.source])))},`
70
+ : '';
71
+ if (hook.run)
72
+ return `[${hook.on}${when}]${match} run: ${hook.run}`;
73
+ if (hook.notify)
74
+ return `[${hook.on}${when}]${match} notify: ${hook.notify.channel}${hook.notify.url ? ` ${hook.notify.url}` : ''}`;
75
+ return `[${hook.on}${when}]${match} (既无 run 也无 notify)`;
76
+ }
77
+ /** Evaluate every hook against the simulated event/context. */
78
+ export function evaluateHooks(hooks, event, ctx, reasonKind) {
79
+ return hooks.map((hook, index) => {
80
+ const summary = describeHook(hook);
81
+ if (hook.on !== event) {
82
+ return { index: index + 1, matched: false, why: `事件不匹配(${hook.on} ≠ ${event})`, summary };
83
+ }
84
+ if (event === 'turn/end' && hook.when !== undefined && hook.when !== reasonKind) {
85
+ return { index: index + 1, matched: false, why: `when 不匹配(期望 ${hook.when},实际 ${reasonKind ?? '无'})`, summary };
86
+ }
87
+ if (!matchFilters(hook.match, ctx)) {
88
+ return { index: index + 1, matched: false, why: 'match 过滤未通过', summary };
89
+ }
90
+ return { index: index + 1, matched: true, why: '', summary };
91
+ });
92
+ }
93
+ /** Full dry-run report; optionally executes the matching hooks. */
94
+ export async function runDryRun(options) {
95
+ const profile = options.profile ?? 'web';
96
+ const print = options.print ?? console.log;
97
+ const { hooks, source } = loadHooks(profile, options.paths);
98
+ const reasonKind = options.reason;
99
+ const ctx = mockContext(options.event, {
100
+ reason: reasonKind,
101
+ tool: options.tool,
102
+ sessionName: options.sessionName,
103
+ });
104
+ print('dsh-hooks dry-run');
105
+ print(`配置来源:${source}(${hooks.length} 个 hook)`);
106
+ print(`模拟事件:${options.event}${reasonKind ? `(reason=${reasonKind})` : ''}`);
107
+ const lines = evaluateHooks(hooks, options.event, ctx, reasonKind);
108
+ for (const line of lines) {
109
+ print(line.matched ? `✅ [${line.index}] ${line.summary}` : `⏭ [${line.index}] ${line.summary} —— ${line.why}`);
110
+ }
111
+ const matched = lines.filter((line) => line.matched);
112
+ if (options.execute) {
113
+ if (matched.length === 0) {
114
+ print('没有匹配的 hook 可执行');
115
+ }
116
+ const runner = createHookRunner((line) => print(` ${line}`));
117
+ for (const line of matched) {
118
+ const hook = hooks[line.index - 1];
119
+ if (hook.run) {
120
+ print(`▶ 执行 [${line.index}] ${describeHook(hook)}`);
121
+ const outcome = runner.run(hook, ctx);
122
+ if (!outcome.ok)
123
+ print(` ✗ ${outcome.reason}${outcome.detail ? `: ${outcome.detail}` : ''}`);
124
+ }
125
+ else if (hook.notify) {
126
+ print(`▶ 发送 [${line.index}] notify:${hook.notify.channel}`);
127
+ await fireNotify(hook.notify, ctx);
128
+ }
129
+ }
130
+ print('(run 命令 fire-and-forget:执行结果见 dsh 日志)');
131
+ }
132
+ else if (matched.length > 0) {
133
+ print(`共 ${matched.length} 个 hook 会触发。加 --execute 实际执行(真实副作用!)`);
134
+ }
135
+ return { matched: matched.length, total: hooks.length };
136
+ }