Kaynağa Gözat

docs(hooks): correct fold description + drop history-narrating test comments

Codex convergence findings on the delegate-and-fold fix (code path verified
correct, prose only):

- The hook-bridges RFC claimed a downstream `block` "carries the bridge context
  too" for BOTH seams. True for `tools/post-execute` (PostToolDecision.block has
  an additionalContext field) but false for `agent/prompt-submit`
  (PromptDecision.block is `{kind,reason}` with no context field). The code is
  already correct — a blocked prompt drops the context, which is right since the
  prompt never reaches the model. Reworded the RFC to state the per-seam
  difference accurately.
- Two test comments narrated "Before the fix…", which the current-state-only
  doc rule forbids. Reworded to describe the behavior, not its history.
- Documented on concatContext (both bridges) why the merged block carries a
  single source: a HookContext holds one MessageSource and the seam cannot
  represent mixed provenance; rendering distinguishes only by source.kind, so a
  downstream plugin's text stays framed as plugin context.
Tianyi Cui 3 ay önce
ebeveyn
işleme
2a66b7c4e0

+ 1 - 1
docs/rfc/implemented/feature/2026-06-30-hook-bridges.md

@@ -37,7 +37,7 @@ Each bridge maps the neutral `MergedHookOutcome` from the shared lib onto the se
 
 ### Adding context is not a veto — delegate, then fold
 
-A hook that only attaches `additionalContext` (no block/deny) is NOT a decision the bridge should return on its own: returning `allow`/`accept` from a waterfall listener WITHOUT calling `next()` short-circuits every later `agent/prompt-submit` / `tools/post-execute` listener, so a policy/sandbox plugin registered after the bridge would never see the prompt. So on the context-only path each bridge **delegates via `next()`** and then **folds** its `additionalContext` onto the downstream decision (`concatContext`): a downstream `block`/`deny` still wins (and carries the bridge context too), a downstream `allow`/`accept` keeps its own content rewrite and gains the bridge context. Only a real `deny`/`block` from the hook short-circuits. Tests assert a later listener can still block a prompt a context-only hook allowed, and that both contexts survive when the downstream also adds one.
+A hook that only attaches `additionalContext` (no block/deny) is NOT a decision the bridge should return on its own: returning `allow`/`accept` from a waterfall listener WITHOUT calling `next()` short-circuits every later `agent/prompt-submit` / `tools/post-execute` listener, so a policy/sandbox plugin registered after the bridge would never see the prompt. So on the context-only path each bridge **delegates via `next()`** and then **folds** its `additionalContext` onto the downstream decision (`concatContext`). The fold differs by seam because the two Decision unions differ: `tools/post-execute` — a downstream `block`/`accept` both carry an `additionalContext` field, so the bridge context rides along either way (a downstream block wins AND keeps the context; a downstream accept keeps its content rewrite and gains the context). `agent/prompt-submit` — a downstream `allow` gains the bridge context (and keeps its own content rewrite / additionalContext), but `PromptDecision.block` carries no context field, so a downstream block drops the bridge context — which is correct: a blocked prompt never reaches the model, so context attached to it is moot. Only a real `deny`/`block` from the hook itself short-circuits. Tests assert a later listener can still block a prompt a context-only hook allowed, and that both contexts survive when the downstream also adds one.
 
 ### CLAUDE_PROJECT_DIR defaults to the session workspace
 

+ 6 - 1
packages/hooks/hooks-claude/src/index.ts

@@ -214,7 +214,12 @@ export function apply(ctx: Context, config: Config): void {
   /**
    * 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.
+   * 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, not a
+   * user prompt.
    */
   function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
     if (!theirs) return ours

+ 1 - 1
packages/hooks/hooks-claude/tests/coverage.spec.ts

@@ -433,7 +433,7 @@ 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. Before the fix the bridge returned `allow` without next().
+    // block the prompt. The bridge delegates via next() and folds its context.
     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 }] }] })

+ 5 - 1
packages/hooks/hooks-codex/src/index.ts

@@ -169,7 +169,11 @@ export function apply(ctx: Context, config: Config): void {
   /**
    * 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.
+   * 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.
    */
   function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
     if (!theirs) return ours

+ 2 - 2
packages/hooks/hooks-codex/tests/coverage.spec.ts

@@ -71,8 +71,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. Before
-    // the fix the bridge returned `allow` without calling next().
+    // policy plugin registered after the bridge) must still get to block. The
+    // bridge delegates via next() and folds its context onto the decision.
     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')])