Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/spikes/claude-code-hook-mutation.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,26 @@ required for our hook to fire there.
```

**Pass-through (no preference, or one-way safety override):**

Emit **no** `permissionDecision` — absence is "no opinion". Either write nothing to
stdout, or emit only `additionalContext`:
```json
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "defer"
"additionalContext": "…optional…"
}
}
```

> ⚠️ **Do not use `permissionDecision: "defer"` here.** (This spike originally
> recommended it; that was wrong and shipped a real bug — see the AskUserQuestion
> note in `~/.claude/CLAUDE.md`.) The documented set is `allow | deny | ask`.
> `defer` is an undocumented print-mode-only value: interactive Claude Code logs
> "defer is print-mode only" and ignores it, but non-interactive sessions (Cowork,
> Agent SDK, `claude -p`) honor it and leave the tool_use **unresolved with no
> result**, which the agent sees as `[Tool result missing due to internal error]`.

**PostToolUse capture (always):**
```json
{
Expand Down
28 changes: 22 additions & 6 deletions hosts/claude/hooks/question-preference-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,29 @@ function readStdin(): Promise<string> {
});
}

/**
* Pass the tool through untouched. Emits NO `permissionDecision` — absence is
* the correct "no opinion" signal, and the only one that is safe in both modes.
*
* Do NOT emit `permissionDecision: 'defer'` here. It is an undocumented
* print-mode-only value (the documented set is allow | deny | ask): interactive
* Claude Code logs "defer is print-mode only" and ignores it, but a
* non-interactive / print session — Cowork, the Agent SDK, `claude -p` — honors
* it and leaves the tool_use unresolved with no result, which surfaces to the
* agent as "[Tool result missing due to internal error]". That made
* AskUserQuestion permanently dead in Cowork while looking fine in the terminal.
*/
function defer(additionalContext?: string): void {
const out: Record<string, unknown> = {
hookEventName: 'PreToolUse',
permissionDecision: 'defer',
};
if (additionalContext) out.additionalContext = additionalContext;
process.stdout.write(JSON.stringify({ hookSpecificOutput: out }));
if (additionalContext) {
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext,
},
}),
);
}
process.exit(0);
}

Expand Down
6 changes: 3 additions & 3 deletions test/memory-cache-injection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe('memory injection', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
expect(r.parsed?.hookSpecificOutput?.additionalContext).toContain('verbose explanations');
});

Expand All @@ -115,7 +115,7 @@ describe('memory injection', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
expect(r.parsed?.hookSpecificOutput?.additionalContext).toBeUndefined();
});

Expand Down Expand Up @@ -219,7 +219,7 @@ describe('per-session memory cache', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
expect(r.parsed?.hookSpecificOutput?.additionalContext).toBeUndefined();
});
});
20 changes: 10 additions & 10 deletions test/question-preference-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ describe('defers (no enforcement)', () => {
},
});
expect(r.status).toBe(0);
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
});

test('marker missing → defer (D18)', () => {
Expand All @@ -141,7 +141,7 @@ describe('defers (no enforcement)', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
});

test('always-ask preference → defer', () => {
Expand All @@ -156,7 +156,7 @@ describe('defers (no enforcement)', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
});

test('empty stdin → defer (crash safety)', () => {
Expand All @@ -168,13 +168,13 @@ describe('defers (no enforcement)', () => {
const res = spawnSync(HOOK, [], { env, input: '', encoding: 'utf-8' });
expect(res.status).toBe(0);
const parsed = JSON.parse(res.stdout || '{}');
expect(parsed.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(parsed.hookSpecificOutput?.permissionDecision).toBeUndefined();
});

test('non-AUQ tool_name → defer (defensive)', () => {
writeProjectPref('test-q', 'never-ask');
const r = runHook({ session_id: 's4', tool_name: 'Bash', tool_use_id: 'tu-4', tool_input: {} });
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
});

Expand Down Expand Up @@ -219,7 +219,7 @@ describe('enforces never-ask preferences', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
});

test('ambiguous recommendation (two labels) → defer (D2 refuse-on-ambiguous)', () => {
Expand All @@ -237,7 +237,7 @@ describe('enforces never-ask preferences', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
});

test('no recommendation marker AND no prose match → defer', () => {
Expand All @@ -255,7 +255,7 @@ describe('enforces never-ask preferences', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
});

Expand Down Expand Up @@ -317,7 +317,7 @@ describe('precedence: project wins over global (D8)', () => {
],
},
});
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
});

Expand Down Expand Up @@ -443,7 +443,7 @@ describe('Conductor prose redirect', () => {
undefined,
CONDUCTOR,
);
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(r.parsed?.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
});

Expand Down
2 changes: 1 addition & 1 deletion test/skill-e2e-plan-tune-cathedral.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ describeIfSelected('PlanTune cathedral E2E: annotation', ['plan-tune-annotation'
});
expect(res.status).toBe(0);
const parsed = JSON.parse(res.stdout || '{}');
expect(parsed.hookSpecificOutput?.permissionDecision).toBe('defer');
expect(parsed.hookSpecificOutput?.permissionDecision).toBeUndefined();
expect(parsed.hookSpecificOutput?.additionalContext).toContain('verbose explanations');
});
});
Expand Down