refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/hooks/hook-protocol/README.md
README.md: cf5d6d4c106a8fbd569f3c64b80dc8c787aadb3e
README.zh.md: 49b0496d5b7a7bce39d15ef2c502cbb2b85a649e
README.md: cebf93a04caef67a82549663018c10aac4cd535d
README.zh.md: ecdad2396d7f185f02404d55fc3edb86635ab1f5

View File

@@ -2,16 +2,16 @@
English | [中文](README.zh.md)
The **shared core** of the Claude Code / Codex hook wire protocol. NOT a cordis plugin — it registers nothing and injects nothing. It is a **library** of dialect-neutral primitives the two bridge plugins (`@deepseek-ai/dsh-hooks-claude`, `@deepseek-ai/dsh-hooks-codex`) import so neither re-implements the identical halves of the protocol.
The **shared core** of the Claude Code / Codex hook wire protocol. NOT a cordis plugin — it registers nothing and injects nothing. It is a **library** of dialect-neutral primitives the two bridge plugins (`@deepseek-ai/dsh-hooks-claude-code`, `@deepseek-ai/dsh-hooks-codex`) import so neither re-implements the identical halves of the protocol.
Codex deliberately reimplements a *subset* of the Claude Code hook protocol — the same `hooks.json` matcher-group shape, the same exit-code/stdout output contract, the same command-hook execution model. The genuinely-shared parts live here; each bridge owns only what differs.
## What's shared (here) vs. per-dialect (the bridges)
| Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) |
| Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude-code` / `-codex`) |
|---|---|---|
| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `matchesMatcher(pattern, query, mode)` for contained runtime matching | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) and rejects a config group carrying a diagnostic |
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** |
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.shell`, decode | builds the per-event stdin **payload** + the dialect's **env** |
| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto an extension-point-specific typed Decision |
| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events; the result's `decision`/`stderrSummary` derive from the `HookOutput` here) | calls them around each invocation |
@@ -20,20 +20,20 @@ Codex deliberately reimplements a *subset* of the Claude Code hook protocol —
## Primitives
- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. Bridge parsers discard matcher fields for events without matcher subjects, then use `matcherDiagnostic` to reject an invalid consumed regex with a stable diagnostic before registering any hooks. The runtime predicate still contains an invalid pattern as a non-match, so a direct library caller cannot throw into the agent loop.
- **`runHook(bash, hook, options, now)`** — require and forward the caller-owned `options.signal`, serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin API), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Cancellation therefore reaches the executor's process-group kill and join boundary. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
- **`runHook(bash, hook, options, now)`** — require and forward the caller-owned `options.signal`, serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-shell` trusted-plugin API), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Cancellation therefore reaches the executor's process-group kill and join boundary. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge.
- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.
- **`createDetachedRuns()`** — quiescence tracking for the emit-shaped points, which run detached (no extension point awaits them). The bridge tracks each run chain — the hook run PLUS its continuation — and registers `drain()` as its effect disposer: drain fires the tracker's abort `signal` (so a still-running hook process is killed via `runHook`, not awaited out to its timeout), then resolves once every tracked chain has settled. `fiber.dispose()` resolving therefore means no detached hook work is left to fire into a disposed context ([defensive patterns](../../../docs/defensive-patterns.md): dispose must reach quiescence).
## `hook/*` session events
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
Declaration-merged into `SessionEventMap` (log-only, like `compaction/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
Hook invocation/result records must sit inside an open turn. `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` satisfy that owner-defined relation by construction. `SessionStart` runs before turn 1 and gets no `hook/*` record; its allowed context remains pending in the inbox until a waking delivery opens a turn — see the hooks Agent Note.
## Model Experience
Indirectly, through `dsh-hooks-claude` and `dsh-hooks-codex`, which can turn parsed hook output into prompt context, blocked outcomes, or continuation feedback.
Indirectly, through `dsh-hooks-claude-code` and `dsh-hooks-codex`, which can turn parsed hook output into prompt context, blocked outcomes, or continuation feedback.
#### KV Cache effect

View File

@@ -2,16 +2,16 @@
[English](README.md) | 中文
Claude CodeCodex hook 协议格式wire format的**共享核心**。它不是 Cordis 插件:不注册也不注入任何内容。它是一个**库**,提供两个桥接插件(`@deepseek-ai/dsh-hooks-claude``@deepseek-ai/dsh-hooks-codex`)导入的方言无关原语,使两者都无需重复实现协议中相同的部分。
Claude CodeCodex hook 协议格式wire format的**共享核心**。它不是 Cordis 插件:不注册也不注入任何内容。它是一个**库**,提供两个桥接插件(`@deepseek-ai/dsh-hooks-claude-code``@deepseek-ai/dsh-hooks-codex`)导入的方言无关原语,使两者都无需重复实现协议中相同的部分。
Codex 有意重新实现了 Claude Code hook 协议的一个*子集*,包括相同的 `hooks.json` matcher group 结构、相同的退出码stdout 输出约定以及相同的 command hook 执行模式。真正共享的部分位于此处;每个桥接只负责不同的部分。
## 共享内容(此处)与各方言内容(桥接)
| 关注点 | 此处(`dsh-hook-protocol` | 桥接(`dsh-hooks-claude` / `-codex` |
| 关注点 | 此处(`dsh-hook-protocol` | 桥接(`dsh-hooks-claude-code` / `-codex` |
|---|---|---|
| Matcher 校验与匹配判断 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于隔离的运行时匹配 | 选择自身的 `mode``claude` = 字面量或正则,`codex` = 始终使用正则),并拒绝带有诊断的配置组 |
| 运行 hook | `runHook(bash, hook, opts, now)`:通过 `ctx.bash` 提供 stdin payload + env再解码 | 构造每个事件的 stdin **payload** + 该方言的 **env** |
| 运行 hook | `runHook(bash, hook, opts, now)`:通过 `ctx.shell` 提供 stdin payload + env再解码 | 构造每个事件的 stdin **payload** + 该方言的 **env** |
| 解码输出 | `parseHookOutput(exit, stdout, stderr)` → 中性 `HookOutput` | 将中性 `HookOutput` 映射到扩展点特定的类型化 Decision |
| 合并 N 个 hook | `mergeHookOutputs(outputs)` → 最严格的 `MergedHookOutcome` | (无) |
| 持久记录 | `appendHookInvoked` / `appendHookResult``hook/*` 会话事件;结果的 `decision``stderrSummary` 从此处的 `HookOutput` 派生) | 在每次调用前后调用它们 |
@@ -20,20 +20,20 @@ Codex 有意重新实现了 Claude Code hook 协议的一个*子集*,包括相
## 原语
- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''``'*'` 时匹配全部;`claude` mode 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配交替),其他 pattern 视为正则;`codex` mode 始终使用未锚定正则。桥接解析器会丢弃没有 matcher 匹配对象的事件所带的 matcher 字段,再用 `matcherDiagnostic` 拒绝事件实际使用的无效正则,并在注册任何钩子之前给出稳定诊断。运行时谓词仍会将无效 pattern 隔离为不匹配,因此直接调用本库不会向 agent loop智能体循环抛异常。
- **`runHook(bash, hook, options, now)`**:要求并转发调用方拥有的 `options.signal`,将 `options.payload` 序列化到 hook stdin当且仅当 `options.trailingNewline` 时添加尾随换行符),在执行器凭证清理后合并 `options.env``dsh-bash` 受信任插件接口),遵循 hook 的 `timeoutSec`(否则使用 `options.defaultTimeoutMs`;默认值属于桥接,其配置默认为 lib 的 `DEFAULT_HOOK_TIMEOUT_MS` 10 分钟参考值),再解码结果(将 `options.expectedEventName` 传递给 codec。因此取消会到达执行器的进程组终止与 join 边界。它绝不抛出异常:执行器拒绝(基础设施故障)会变为 `HookOutput`,其 `exitCode: undefined`(非阻塞错误)。`now` 会被注入,以便测试持续时间。
- **`runHook(bash, hook, options, now)`**:要求并转发调用方拥有的 `options.signal`,将 `options.payload` 序列化到 hook stdin当且仅当 `options.trailingNewline` 时添加尾随换行符),在执行器凭证清理后合并 `options.env``dsh-shell` 受信任插件接口),遵循 hook 的 `timeoutSec`(否则使用 `options.defaultTimeoutMs`;默认值属于桥接,其配置默认为 lib 的 `DEFAULT_HOOK_TIMEOUT_MS` 10 分钟参考值),再解码结果(将 `options.expectedEventName` 传递给 codec。因此取消会到达执行器的进程组终止与 join 边界。它绝不抛出异常:执行器拒绝(基础设施故障)会变为 `HookOutput`,其 `exitCode: undefined`(非阻塞错误)。`now` 会被注入,以便测试持续时间。
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** 解码退出状态与结构化 stdout。退出码为 2 时,会以 stderr 内容阻止执行;其他失败不阻塞。匹配的 hook 特定权限决策会覆盖遗留顶层决策;事件判别字段不匹配或缺失只会抑制事件特定字段。顶层字段仍与事件无关,成功但非 JSON 的输出会留给桥接处理。
- **`mergeHookOutputs(outputs)`**:折叠在一个点上匹配的每个 hook 结果:权限优先级为 **deny > ask > allow**,从首个 `continue:false`halt 状态保持不变,阻塞原因用 `\n\n` 连接,`additionalContext``systemMessages` 按顺序累积。
- **`createDetachedRuns()`**:跟踪以 emit 形式脱离运行的点是否完全停稳(没有扩展点等待它们)。桥接会跟踪每条运行链,包括 hook 运行及其 continuation并将 `drain()` 注册为 effect disposer。drain 会触发 tracker 的 abort `signal`(因此仍在运行的 hook 进程会通过 `runHook` 终止,而不是等待到超时),随后在所有已跟踪链结算后 resolve。因此 `fiber.dispose()` resolve 时,不会遗留任何可能作用于已 dispose资源释放的上下文的脱离 hook 工作(见 [防御模式](../../../docs/defensive-patterns.md)dispose 必须达到完全停稳)。
## `hook/*` 会话事件
通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compact/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp``hook/invoked`hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,决策规则由 `appendHookResult` 负责。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md)`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500为空时省略
通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compaction/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp``hook/invoked`hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,决策规则由 `appendHookResult` 负责。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md)`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500为空时省略
Hook 调用/结果记录必须位于一个尚未结束的轮次内。`UserPromptSubmit``PreToolUse``PostToolUse``Stop` 按构造满足这条由所有者定义的关系。`SessionStart` 在轮次 1 之前运行,因此没有 `hook/*` 记录;其获准的上下文会在 inbox 中保持待处理,直到唤醒交付打开一个轮次,详见 hooks Agent Note。
## 模型体验
通过 `dsh-hooks-claude``dsh-hooks-codex` 间接影响;它们可以将解析后 hook 输出转为提示词上下文、已阻塞结果或 continuation 反馈。
通过 `dsh-hooks-claude-code``dsh-hooks-codex` 间接影响;它们可以将解析后 hook 输出转为提示词上下文、已阻塞结果或 continuation 反馈。
#### KV Cache 影响

View File

@@ -32,13 +32,13 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"

View File

@@ -43,7 +43,7 @@ function validateHookEvent(
fail('hook/invoked point and handlerId must be non-empty')
}
const dialect: string = event.data.dialect
if (dialect !== 'claude' && dialect !== 'codex') {
if (dialect !== 'claude-code' && dialect !== 'codex') {
fail(`hook/invoked carries unknown dialect ${JSON.stringify(dialect)}`)
}
return { key: hookKey(event.data), delta: 1 }

View File

@@ -37,7 +37,7 @@ function compileRegex(pattern: string): RegExp | undefined {
export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined {
if (isMatchAll(matcher)) return undefined
const pattern = matcher as string
if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) return undefined
if (mode === 'claude-code' && CLAUDE_LITERAL.test(pattern)) return undefined
return compileRegex(pattern) === undefined
? `invalid ${mode} regex matcher ${JSON.stringify(pattern)}`
: undefined
@@ -58,7 +58,7 @@ export function matchesMatcher(matcher: string | undefined, query: string, mode:
if (isMatchAll(matcher)) return true
// matcher is a non-empty string past the match-all guard.
const pattern = matcher as string
if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) {
if (mode === 'claude-code' && CLAUDE_LITERAL.test(pattern)) {
return pattern.split('|').includes(query)
}
return compileRegex(pattern)?.test(query) ?? false

View File

@@ -1,12 +1,12 @@
/**
* Execute command hooks through `ctx.bash`, using its credential scrub,
* Execute command hooks through `ctx.shell`, using its credential scrub,
* process-group cancellation, and timeout machinery. The bridge supplies the
* trusted stdin payload and dialect environment, then this module decodes the
* captured outcome.
* @module @deepseek-ai/dsh-hook-protocol/runner
*/
import type { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { ShellExecutor } from '@deepseek-ai/dsh-shell'
import { parseHookOutput } from './codec.ts'
import type { CommandHook, HookOutput } from './types.ts'
@@ -65,7 +65,7 @@ export interface RunHookResult {
* @returns the decoded output plus the run's wall-clock duration.
*/
export async function runHook(
bash: BashExecutor,
bash: ShellExecutor,
hook: CommandHook,
options: RunHookOptions,
now: () => number,
@@ -85,7 +85,7 @@ export async function runHook(
try {
const result = await bash.run(bash.resolve(request))
// BashRunResult.exitCode is `number | null` (null = died by signal); the
// ShellRunResult.exitCode is `number | null` (null = died by signal); the
// protocol's exit-code contract is numeric, so a signal death maps to
// `undefined` (a non-blocking error — no clean exit code to act on).
const exitCode = result.exitCode ?? undefined

View File

@@ -9,7 +9,7 @@ declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
* A hook command was invoked at a hook point — a log-only record (like
* `compact/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`).
* `compaction/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`).
* `dialect` is the bridge that ran it (`claude`/`codex`), `point`
* the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group
* pattern that selected it (absent for match-all), `handlerId` a stable id
@@ -41,11 +41,11 @@ declare module '@deepseek-ai/dsh-session/types' {
}
/**
* The bridge that ran a hook — the CC bridge stamps `'claude'`, the Codex
* The bridge that ran a hook — the CC bridge stamps `'claude-code'`, the Codex
* bridge `'codex'`. A native plugin at the interception points is not a bridge
* and writes no `hook/*` invocation/result records (see the interception extension-points Agent Note).
*/
export type HookDialect = 'claude' | 'codex'
export type HookDialect = 'claude-code' | 'codex'
/**
* One configured command hook (the `{ type: 'command', command, timeout? }`
@@ -76,7 +76,7 @@ export interface MatcherGroup {
* {@link regex} otherwise; Codex is always {@link regex}. The bridge picks the
* mode for its dialect.
*/
export type MatcherMode = 'claude' | 'codex'
export type MatcherMode = 'claude-code' | 'codex'
/**
* The dialect-neutral OUTCOME a hook produced, parsed from its exit code +

View File

@@ -10,12 +10,12 @@ function output(over: Partial<HookOutput> = {}): HookOutput {
describe('hook/* session events', () => {
it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => {
const session = Session.create(SessionId('s'))
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'h1', matcher: 'Bash' })
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'h1', matcher: 'Bash' })
const ev = [...session.events].find(e => e.type === 'hook/invoked')
expect(ev?.type).toBe('hook/invoked')
if (ev?.type === 'hook/invoked') {
expect(ev.data).toMatchObject({ turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'h1', matcher: 'Bash' })
expect(ev.data).toMatchObject({ turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'h1', matcher: 'Bash' })
}
// Log-only: no surfaceOp on the event.
expect((ev as unknown as { surfaceOp?: unknown }).surfaceOp).toBeUndefined()
@@ -95,7 +95,7 @@ describe('hook/* session events', () => {
it('an invoked/result pair correlates by handlerId', () => {
const session = Session.create(SessionId('s'))
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'pair-1' })
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'pair-1' })
appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) })
const invoked = [...session.events].find(e => e.type === 'hook/invoked')

View File

@@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import * as HookInvariant from '@deepseek-ai/dsh-hook-protocol/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
await ctx.plugin(InvariantRegistry)
await ctx.plugin(HookInvariant)
return ctx
}
@@ -15,7 +15,7 @@ async function setup(): Promise<Context> {
const invoked = (overrides: Record<string, unknown> = {}) => ({
turn: 1,
point: 'PreToolUse',
dialect: 'claude' as const,
dialect: 'claude-code' as const,
handlerId: 'hook-1',
...overrides,
})
@@ -51,7 +51,7 @@ describe('hook-protocol invariants', () => {
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1 })
session.append('hook/invoked', invoked())
await ctx.plugin(InvariantService)
await ctx.plugin(InvariantRegistry)
await ctx.plugin(HookInvariant)
expect(() => session.append('hook/result', result())).not.toThrow()
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -89,7 +89,7 @@ describe('hook-protocol invariants', () => {
startTurn(session)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('hook/invoked', invoked())
await ctx.plugin(InvariantService)
await ctx.plugin(InvariantRegistry)
await expect(ctx.plugin(HookInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
})

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
describe('matchesMatcher — match-all sentinels (both dialects)', () => {
for (const mode of ['claude', 'codex'] as const) {
for (const mode of ['claude-code', 'codex'] as const) {
it(`${mode}: absent / empty / '*' match everything`, () => {
expect(matchesMatcher(undefined, 'Bash', mode)).toBe(true)
expect(matchesMatcher('', 'anything', mode)).toBe(true)
@@ -13,24 +13,24 @@ describe('matchesMatcher — match-all sentinels (both dialects)', () => {
describe('matchesMatcher — claude dialect (literal-or-regex)', () => {
it('a pure word-char pattern is a LITERAL exact match (not substring)', () => {
expect(matchesMatcher('Bash', 'Bash', 'claude')).toBe(true)
expect(matchesMatcher('Bash', 'Bash', 'claude-code')).toBe(true)
// literal exact: "Bash" must NOT match "BashOutput" (a regex would, substring)
expect(matchesMatcher('Bash', 'BashOutput', 'claude')).toBe(false)
expect(matchesMatcher('Bash', 'BashOutput', 'claude-code')).toBe(false)
})
it('a pipe pattern is literal ALTERNATION (exact match any alternative)', () => {
expect(matchesMatcher('Edit|Write', 'Edit', 'claude')).toBe(true)
expect(matchesMatcher('Edit|Write', 'Write', 'claude')).toBe(true)
expect(matchesMatcher('Edit|Write', 'Read', 'claude')).toBe(false)
expect(matchesMatcher('Edit|Write', 'Edit', 'claude-code')).toBe(true)
expect(matchesMatcher('Edit|Write', 'Write', 'claude-code')).toBe(true)
expect(matchesMatcher('Edit|Write', 'Read', 'claude-code')).toBe(false)
// still exact per-alternative, not substring
expect(matchesMatcher('Edit|Write', 'EditFile', 'claude')).toBe(false)
expect(matchesMatcher('Edit|Write', 'EditFile', 'claude-code')).toBe(false)
})
it('a non-word pattern falls through to regex (unanchored)', () => {
expect(matchesMatcher('^Bash$', 'Bash', 'claude')).toBe(true)
expect(matchesMatcher('Bash.*', 'BashOutput', 'claude')).toBe(true)
expect(matchesMatcher('.*\\.ts$', 'foo.ts', 'claude')).toBe(true)
expect(matchesMatcher('.*\\.ts$', 'foo.js', 'claude')).toBe(false)
expect(matchesMatcher('^Bash$', 'Bash', 'claude-code')).toBe(true)
expect(matchesMatcher('Bash.*', 'BashOutput', 'claude-code')).toBe(true)
expect(matchesMatcher('.*\\.ts$', 'foo.ts', 'claude-code')).toBe(true)
expect(matchesMatcher('.*\\.ts$', 'foo.js', 'claude-code')).toBe(false)
})
})
@@ -51,24 +51,24 @@ describe('matchesMatcher — codex dialect (always regex)', () => {
describe('matchesMatcher — invalid regex is a non-match (never throws)', () => {
it('an unbalanced pattern matches nothing rather than throwing', () => {
// '(' is not the claude-literal charset, so it goes to the regex path and is invalid.
expect(() => matchesMatcher('(', 'x', 'claude')).not.toThrow()
expect(matchesMatcher('(', 'x', 'claude')).toBe(false)
expect(() => matchesMatcher('(', 'x', 'claude-code')).not.toThrow()
expect(matchesMatcher('(', 'x', 'claude-code')).toBe(false)
expect(matchesMatcher('[', 'x', 'codex')).toBe(false)
})
})
describe('matcherDiagnostic — parse-time diagnostics', () => {
it('accepts match-all sentinels, Claude literals, and valid regexes', () => {
expect(matcherDiagnostic(undefined, 'claude')).toBeUndefined()
expect(matcherDiagnostic(undefined, 'claude-code')).toBeUndefined()
expect(matcherDiagnostic('', 'codex')).toBeUndefined()
expect(matcherDiagnostic('*', 'codex')).toBeUndefined()
expect(matcherDiagnostic('Edit|Write', 'claude')).toBeUndefined()
expect(matcherDiagnostic('^Bash$', 'claude')).toBeUndefined()
expect(matcherDiagnostic('Edit|Write', 'claude-code')).toBeUndefined()
expect(matcherDiagnostic('^Bash$', 'claude-code')).toBeUndefined()
expect(matcherDiagnostic('Edit|Write', 'codex')).toBeUndefined()
})
it('returns a stable diagnostic for invalid regexes in either dialect', () => {
expect(matcherDiagnostic('(', 'claude')).toBe('invalid claude regex matcher "("')
expect(matcherDiagnostic('(', 'claude-code')).toBe('invalid claude-code regex matcher "("')
expect(matcherDiagnostic('[', 'codex')).toBe('invalid codex regex matcher "["')
})
})

View File

@@ -1,22 +1,22 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import type { BashExecRequest, BashExecSpec, BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash'
import type { ShellExecRequest, ShellExecSpec, ShellExecutor, ShellRunResult } from '@deepseek-ai/dsh-shell'
import { DEFAULT_HOOK_TIMEOUT_MS, runHook } from '@deepseek-ai/dsh-hook-protocol'
import type { RunHookOptions } from '@deepseek-ai/dsh-hook-protocol'
/**
* A minimal stand-in for the bits of {@link BashExecutor} that {@link runHook}
* A minimal stand-in for the bits of {@link ShellExecutor} that {@link runHook}
* actually calls (`resolve` then `run`). `runHook` is pure plumbing over those
* two methods, so a duck-typed recorder is the right test hook — the REAL
* executor (dsh-bash-local) is exercised end-to-end by the hook-bridge plugins
* that consume this library, not here.
*/
function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
bash: BashExecutor
specs: BashExecSpec[]
function recordingBash(run: (spec: ShellExecSpec) => Promise<ShellRunResult>): {
bash: ShellExecutor
specs: ShellExecSpec[]
} {
const specs: BashExecSpec[] = []
const specs: ShellExecSpec[] = []
const bash = {
resolve(request: BashExecRequest): BashExecSpec {
resolve(request: ShellExecRequest): ShellExecSpec {
// Carry the request through verbatim, defaulting the required spec fields —
// exactly what dsh-bash-local's resolve does for the fields runHook sets.
return {
@@ -30,15 +30,15 @@ function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
sandboxPolicy: request.sandboxPolicy,
}
},
async run(spec: BashExecSpec): Promise<BashRunResult> {
async run(spec: ShellExecSpec): Promise<ShellRunResult> {
specs.push(spec)
return run(spec)
},
} as unknown as BashExecutor
} as unknown as ShellExecutor
return { bash, specs }
}
function result(over: Partial<BashRunResult> = {}): BashRunResult {
function result(over: Partial<ShellRunResult> = {}): ShellRunResult {
return {
exitCode: 0,
signal: null,

View File

@@ -15,13 +15,13 @@
"path": "../../../vendor/cordis"
},
{
"path": "../../bash/bash"
"path": "../../shell/shell"
},
{
"path": "../../core/session"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}