docs: rebalance prose cleanup and add trimming skill
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Parse a Claude Code hook config file into the shared {@link MatcherGroup} shape, faithfully
|
||||
* to CC's `hooks.json` / settings `hooks` key format.
|
||||
* Parse Claude Code's event-to-matcher-group hook format into shared {@link MatcherGroup}s.
|
||||
* Command hooks run after `${CLAUDE_PLUGIN_ROOT}` substitution. Other supported hook types are
|
||||
* parsed but skipped with a warning, matching the bridge's faithful-but-degraded policy.
|
||||
* @module @deepseek-ai/dsh-hooks-claude/config
|
||||
*/
|
||||
|
||||
@@ -50,8 +51,9 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw Claude Code config object (the value under the `hooks` key, or a `hooks.json`
|
||||
* whose top level IS that map) into runnable {@link MatcherGroup}s.
|
||||
* Parse either a settings `hooks` value or a bare `hooks.json` event map. Malformed entries are
|
||||
* ignored rather than failing boot; non-command hooks are returned in `skipped`, and substitutions
|
||||
* are applied to every surviving command.
|
||||
*
|
||||
* @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare
|
||||
* event map.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* `dsh-hooks-claude` — a bridge plugin that runs a user's existing Claude Code hook config
|
||||
* (`hooks.json` / a settings file's `hooks` key) on the harness's canonical interception
|
||||
* seams.
|
||||
* Bridge for unmodified Claude Code command hooks on harness interception
|
||||
* seams. It supports SessionStart, prompt/tool pre/post, Stop, and subagent
|
||||
* start/stop; owns Claude payloads, environment and plugin-root substitution;
|
||||
* and logs but does not honor `updatedInput`. Bespoke behavior should use typed
|
||||
* native plugins on the same seams.
|
||||
* @module @deepseek-ai/dsh-hooks-claude
|
||||
*/
|
||||
|
||||
@@ -195,7 +197,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return { content: [...ours.content, ...theirs.content], source: ours.source }
|
||||
}
|
||||
|
||||
// SessionStart injects context when its detached hook resolves.
|
||||
// SessionStart injects context when its detached hook resolves; a slow hook
|
||||
// may miss the first request.
|
||||
// TODO(session-start-gating): add a startup gate before promising first-turn delivery.
|
||||
ctx.on('agent/session-start', (agent, source) => {
|
||||
detached.track(runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent, signal: detached.signal })
|
||||
@@ -216,7 +219,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (merged.decision === 'deny') {
|
||||
return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
|
||||
}
|
||||
// Our hooks did not block.
|
||||
// Delegate so later listeners may still rewrite or block, then prepend our
|
||||
// context only to a downstream allow decision.
|
||||
const downstream = await next()
|
||||
const ours = contextFrom(merged)
|
||||
if (!ours || downstream.kind !== 'allow') return downstream
|
||||
@@ -259,7 +263,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
})
|
||||
|
||||
// A blocking Stop hook forces continuation with its reason.
|
||||
// TODO(stop-loop-guard): cap consecutive forced continuations.
|
||||
// TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile.
|
||||
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
|
||||
const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn })
|
||||
if (merged.decision === 'deny') {
|
||||
@@ -270,8 +274,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return next()
|
||||
})
|
||||
|
||||
// --- SubagentStart / SubagentStop: observe-only emits (the subagent seam is observe-only
|
||||
// this cut).
|
||||
// SubagentStart may inject child context; SubagentStop only observes. Both
|
||||
// use the live child's workspace and the generic agent-type matcher subject.
|
||||
ctx.on('subagent/start', (info) => {
|
||||
const child = ctx.get('agents')?.get(info.id)
|
||||
detached.track(runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {}, signal: detached.signal })
|
||||
|
||||
@@ -293,7 +293,7 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
|
||||
const { ctx, hooks } = await harnessWithFiber(dir, adapter)
|
||||
// Drive the observe-only lifecycle events directly (no real child needed — the
|
||||
// bridge just listens). No child agent is registered, so SubagentStart's
|
||||
// child lookup yields undefined and it simply runs the hook.
|
||||
// child lookup yields undefined and it runs the hook.
|
||||
ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') })
|
||||
ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
|
||||
|
||||
@@ -302,8 +302,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
|
||||
await waitFor(() => existsSync(startMarker) && existsSync(stopMarker))
|
||||
expect(existsSync(startMarker)).toBe(true)
|
||||
expect(existsSync(stopMarker)).toBe(true)
|
||||
// The markers prove the hook PROCESSES ran, not that the detached `.then` continuations did
|
||||
// (`touch` lands before the process exits).
|
||||
// A marker proves only that the process ran. Disposal drains its detached continuation so the
|
||||
// no-context branch completes before the per-file coverage snapshot instead of racing CI.
|
||||
await hooks.dispose()
|
||||
})
|
||||
|
||||
@@ -313,8 +313,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
|
||||
const pidFile = join(dir, 'pid')
|
||||
const marker = join(dir, 'started')
|
||||
const slowHook = join(dir, 'slow.sh')
|
||||
// Record the hook shell's PID and touch the marker FIRST so the test can tell "the hook is
|
||||
// genuinely mid-run", then sleep far past the suite timeout.
|
||||
// Record the PID and marker before sleeping past the suite timeout. Disposal must abort and
|
||||
// kill the process rather than await its exit or the default ten-minute hook timeout.
|
||||
writeFileSync(slowHook, `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`)
|
||||
chmodSync(slowHook, 0o755)
|
||||
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: {
|
||||
@@ -328,9 +328,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
|
||||
await waitFor(() => existsSync(marker))
|
||||
const pid = Number(readFileSync(pidFile, 'utf8').trim())
|
||||
await hooks.dispose()
|
||||
// Quiescence, not just promptness: the drain resolves only after the run settled, and the
|
||||
// run settles only after the killed process was reaped — so by the time dispose returns,
|
||||
// the PID must be GONE (kill(pid, 0) throws ESRCH).
|
||||
// Disposal reaches quiescence: it returns only after the aborted run settles and the process
|
||||
// is reaped, so `kill(pid, 0)` must report ESRCH. Untracked fire-and-forget work would remain.
|
||||
expect(() => process.kill(pid, 0)).toThrow()
|
||||
// The aborted run resolves as a non-blocking error (runHook never rejects),
|
||||
// so the drained continuation must NOT have logged a failure.
|
||||
@@ -359,8 +358,8 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
})
|
||||
|
||||
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
|
||||
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it would veto the
|
||||
// prompt (0 model requests) and log a hook/invoked.
|
||||
// This is the only bridge mount, and its blocking hook would veto the prompt and log an event
|
||||
// if its listener leaked after disposal. A no-op hook would not expose that leak.
|
||||
const dir = writeConfig({ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = new Context()
|
||||
@@ -382,7 +381,8 @@ describe('hooks-claude bridge — load resilience', () => {
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => {
|
||||
// Loader must retain this namespace's injection metadata.
|
||||
// A default export would make `unwrapExports` collapse the namespace and drop `inject`, causing
|
||||
// load to fail. Guard the shape from postmortem 0001 directly.
|
||||
expect('default' in HooksClaude).toBe(false)
|
||||
expect(HooksClaude.name).toBe('hooks-claude')
|
||||
expect(HooksClaude.inject).toEqual(['bash'])
|
||||
|
||||
@@ -183,9 +183,8 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch',
|
||||
})
|
||||
|
||||
it('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => {
|
||||
// Regression: a blocking Stop hook (exit 2) with no stderr yields decision 'deny' + reason
|
||||
// undefined; the turn must STILL force-continue (the block is what matters), not silently
|
||||
// stop.
|
||||
// A blocking Stop hook with no stderr yields `deny` without a reason. The block still forces
|
||||
// continuation; the script self-limits to one block to avoid a loop.
|
||||
const d = dir()
|
||||
const marker = join(d, 'fired')
|
||||
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`)
|
||||
@@ -382,8 +381,8 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', ()
|
||||
|
||||
describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => {
|
||||
it('a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => {
|
||||
// Honoring `continue:false` (hard-halt the whole run) is deferred — there is no such
|
||||
// primitive on the interception seams yet.
|
||||
// The seams cannot yet honor `continue:false` as a hard halt. The log must still record the
|
||||
// stop decision while execution and the turn continue normally.
|
||||
const d = dir()
|
||||
const s = sh(d, 'stop.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
@@ -455,8 +454,8 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () =>
|
||||
})
|
||||
|
||||
it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
|
||||
// A hook that only adds context must not short-circuit the waterfall: a downstream
|
||||
// agent/prompt-submit listener (a policy plugin) must still get to block the prompt.
|
||||
// A context-only hook delegates with `next()` and folds its context, so a downstream policy
|
||||
// listener can still veto the prompt.
|
||||
const d = dir()
|
||||
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n')
|
||||
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
@@ -586,8 +585,8 @@ describe('hooks-claude coverage — detached-listener catch handlers', () => {
|
||||
|
||||
describe('hooks-claude coverage — hook runs in the session cwd, not the server cwd', () => {
|
||||
it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => {
|
||||
// The bug: the bridge passed no workdir, so hooks ran in the executor default (the server
|
||||
// launch dir), not session/new.cwd.
|
||||
// The server launch directory and session cwd deliberately differ. The marker proves the
|
||||
// bridge passes `session/new.cwd` instead of falling back to the executor default.
|
||||
const serverDir = dir()
|
||||
const sessionDir = dir()
|
||||
const marker = join(sessionDir, 'where')
|
||||
@@ -621,8 +620,8 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server
|
||||
})
|
||||
|
||||
it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => {
|
||||
// SubagentStop looks the child up (recoverable at subagent/end) and runs the hook in the
|
||||
// CHILD's session cwd, not the executor default.
|
||||
// `SubagentStop` recovers the child at `subagent/end`; a relative marker proves `runPoint`
|
||||
// receives that agent and runs in the child's cwd rather than the executor default.
|
||||
const serverDir = dir()
|
||||
const childDir = dir()
|
||||
const marker = join(childDir, 'stopwhere')
|
||||
@@ -673,8 +672,8 @@ describe('hooks-claude coverage — systemMessage is warned, not surfaced', () =
|
||||
|
||||
describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)', () => {
|
||||
it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => {
|
||||
// Regression for the documented downgrade: session-start injection is detached, so a prompt
|
||||
// sent immediately need not observe it.
|
||||
// Session-start injection is detached, so an immediate prompt need not observe it. Assert only
|
||||
// the guaranteed behavior—no crash and a completed turn—without pre-waiting away the race.
|
||||
const d = dir()
|
||||
const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n')
|
||||
const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
|
||||
Reference in New Issue
Block a user