docs: trim generated prose

This commit is contained in:
Tianyi Cui
2026-07-12 03:36:43 +08:00
parent 3dca90261c
commit 75838e10b5
323 changed files with 2857 additions and 11833 deletions

View File

@@ -1,11 +1,5 @@
/**
* Parse a Codex `hooks.json` into the shared {@link MatcherGroup} shape. Codex's
* config format is a SUBSET of Claude Code's: the same event-name → matcher-group
* structure and the same `{ type: 'command', command, timeout?/timeoutSec? }`
* hook shape, but only five events and NO command-string substitution (Codex sets
* no hook env vars and does not expand `${…}`). Non-command hooks (and Codex's
* `async: true` commands) are parsed-and-skipped with a warning.
*
* Parse a Codex `hooks.json` into the shared {@link MatcherGroup} shape.
* @module @deepseek-ai/dsh-hooks-codex/config
*/

View File

@@ -1,17 +1,6 @@
/**
* `dsh-hooks-codex` — a bridge plugin that runs a user's existing Codex
* `hooks.json` on the harness's canonical interception seams. The CODEX DIALECT
* half of the hooks subsystem.
*
* Codex's hook protocol is a deliberate SUBSET of Claude Code's: five hook points
* (`PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no
* subagent/notification/compaction), regex-only matchers, snake_case stdin
* payloads with `turn_id`/`model` extras and NO trailing newline, no env vars and
* no command substitution, and a block-only decision model (allow/ask are not
* honored — a hook can only block, never pre-approve). The dialect-agnostic
* primitives come from `@deepseek-ai/dsh-hook-protocol`; this bridge owns the
* Codex-specific payloads + matcher mode + decision mapping.
*
* `dsh-hooks-codex` — a bridge plugin that runs a user's existing Codex `hooks.json` on the
* harness's canonical interception seams. The CODEX DIALECT half of the hooks subsystem.
* @module @deepseek-ai/dsh-hooks-codex
*/
@@ -112,9 +101,9 @@ export function apply(ctx: Context, config: Config): void {
): Promise<MergedHookOutcome> {
const groups: MatcherGroup[] = parsed[point] ?? []
const outputs: HookOutput[] = []
// Run the hook in the agent's session workspace (the `session/new` cwd), not
// the executor default (the server launch dir) — a hook reading a relative
// file or `pwd` must see the user's project tree. Absent for a no-agent run.
// Run the hook in the agent's session workspace (the `session/new` cwd), not the executor
// default (the server launch dir) — a hook reading a relative file or `pwd` must see the
// user's project tree.
const workdir = opts.agent?.session.header.cwd
for (const group of groups) {
// Codex matches with PURE regex (no literal fast path).
@@ -137,16 +126,8 @@ export function apply(ctx: Context, config: Config): void {
// Discard a `hookSpecificOutput` block naming a different event.
expectedEventName: point,
}, () => performance.now())
// Codex's SessionStart/UserPromptSubmit treat a CLEAN hook's PLAIN
// (non-JSON) stdout as additionalContext. The codec keeps that raw text on
// `output.stdout` but only sets `additionalContext` from a JSON
// `hookSpecificOutput`, so fold plain stdout in here and let the shared
// merge + contextFrom path carry it. Gated exactly like the codec's own
// structured-stdout parse: only on a clean `exitCode === 0` (a non-zero
// exit is an error, not context — an `echo x; exit 2` must not inject
// `x`), only when stdout is non-JSON (`!startsWith('{')` — a structured
// hook's raw JSON is never dumped as prose), and never clobbering an
// explicit additionalContext from a JSON block.
// Codex's SessionStart/UserPromptSubmit treat a CLEAN hook's PLAIN (non-JSON) stdout as
// additionalContext.
if (opts.plainStdoutAsContext === true && output.exitCode === 0
&& output.additionalContext === undefined
&& output.stdout.length > 0 && !output.stdout.startsWith('{')) {
@@ -164,11 +145,7 @@ export function apply(ctx: Context, config: Config): void {
return mergeHookOutputs(outputs)
}
// TODO(hook-continue-false): the merge computes `merged.stop`/`stopReason` from
// a hook's `continue:false`, but no seam below honors it — there is no
// "hard-halt the whole agent" primitive on the interception seams yet. Deferred
// with the loop-guard work; until then a `continue:false` hook keeps its
// per-point effect and the halt request is recorded in `hook/result`, not acted on.
// TODO(hook-continue-false): `merged.stop` is logged but needs a run-level halt seam.
function contextFrom(merged: MergedHookOutcome): HookContext | undefined {
if (merged.additionalContext.length === 0) return undefined
@@ -176,25 +153,14 @@ export function apply(ctx: Context, config: Config): void {
return { content, source: PLUGIN_SOURCE }
}
/**
* Concatenate this bridge's {@link HookContext} (`ours`, always present at the
* call sites) with a downstream listener's optional one, so folding our
* additionalContext onto a delegated decision drops neither. The merged block
* carries a single `source` — this bridge's — because a `HookContext` holds one
* `MessageSource` and the seam cannot represent mixed provenance; the rendered
* `context/message` only distinguishes by `source.kind` ('plugin'), so a
* downstream plugin's text is still correctly framed as plugin context.
*/
/** Merge hook context while retaining this bridge's plugin-level source. */
function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
if (!theirs) return ours
return { content: [...ours.content, ...theirs.content], source: ours.source }
}
// SessionStart: emit. Codex passes a plain-stdout hook's output as additionalContext.
// TODO(session-start-gating): a synchronous emit + detached `.then`, so the
// injected context is BEST-EFFORT — not guaranteed before the first turn reaches
// the model (a slow hook can miss the first request). Gating is a deferred
// loop-level change; the contract is "injected as soon as the hook resolves".
// SessionStart injects plain stdout when its detached hook resolves.
// 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, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal })
.then((merged) => {

View File

@@ -143,10 +143,8 @@ describe('hooks-codex bridge', () => {
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
const dir = configDir()
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose, it
// would veto the prompt (0 model requests) and log a hook/invoked. After a
// clean dispose the turn must proceed untouched — this fails loudly on a leak
// (a no-op `true` hook would pass even with a leaked listener).
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose, it would veto the
// prompt (0 model requests) and log a hook/invoked.
const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n')
writeHooks(dir, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: deny }] }] })
const adapter = new MockAdapter([textResponse('ok')])
@@ -172,10 +170,8 @@ describe('hooks-codex bridge', () => {
const dir = configDir()
const pidFile = join(dir, 'pid')
const marker = join(dir, 'started')
// 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. Dispose must KILL the process (the tracker's abort signal wired
// through this bridge's runPoint), not await its exit.
// 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.
const slow = script(dir, 'slow.sh', `#!/usr/bin/env bash\necho $$ > "${pidFile}"\ntouch "${marker}"\nsleep 30\n`)
writeHooks(dir, { SessionStart: [{ hooks: [{ type: 'command', command: slow }] }] })
const ctx = new Context()
@@ -194,11 +190,9 @@ describe('hooks-codex bridge', () => {
await waitFor(() => existsSync(marker))
const pid = Number(readFileSync(pidFile, 'utf8').trim())
await fiber.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). An untracked fire-and-forget regression would leave the
// process alive (or unreaped) and fail this deterministically.
// 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).
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.

View File

@@ -70,9 +70,8 @@ describe('hooks-codex coverage — decision mapping paths', () => {
})
it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
// Context alone is not a veto: a downstream agent/prompt-submit listener (a
// policy plugin registered after the bridge) must still get to block. The
// bridge delegates via next() and folds its context onto the decision.
// Context alone is not a veto: a downstream agent/prompt-submit listener (a policy plugin
// registered after the bridge) must still get to block.
const d = dir()
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n') }] }] })
const adapter = new MockAdapter([textResponse('should not run')])
@@ -438,11 +437,8 @@ describe('hooks-codex coverage — decision mapping paths', () => {
})
it('a NON-clean SessionStart hook (exit 2) does NOT inject its stdout as context', async () => {
// The plain-stdout→context fold is gated on exitCode === 0, matching the
// codec's structured-stdout rule. SessionStart is an EMIT (cannot block), so
// an `echo stale; exit 2` here is the exact case the gate guards: without it,
// the non-clean hook's stdout would wrongly inject "stale". A marker lets us
// wait for the detached hook to finish before asserting absence.
// The plain-stdout→context fold is gated on exitCode === 0, matching the codec's
// structured-stdout rule.
const d = dir()
const marker = join(d, 'ran')
hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] })