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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它不是 Cordis 插件:不注册也不注入任何内容。它是一个**库**,提供两个桥接插件(`@deepseek-ai/dsh-hooks-claude`、`@deepseek-ai/dsh-hooks-codex`)导入的方言无关原语,使两者都无需重复实现协议中相同的部分。
|
||||
Claude Code/Codex 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 影响
|
||||
|
||||
|
||||
@@ -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:^"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 "["')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
"path": "../../shell/shell"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user