Skip to content
Draft
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
3 changes: 2 additions & 1 deletion packages/cli/src/commands/claudeHookSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ export const ClaudeHookPayloadSchema = z.object({
permission_mode: z.string().trim().nonempty().optional(),
hook_event_name: z.string().trim().nonempty(),
tool_name: z.string().trim().nonempty().optional(),
// Loose: the approve envelope echoes tool_input back verbatim, so unmodeled fields must survive parsing.
tool_input: z
.object({
.looseObject({
plan: z.string().refine((value) => value.trim().length > 0, { message: 'plan content must not be empty' }),
})
.optional(),
Expand Down
74 changes: 15 additions & 59 deletions packages/cli/src/commands/hookClaude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,55 +4,39 @@ import { describe, expect, it } from 'bun:test';
import { CommanderError } from 'commander';
import { type RunAnnotationArgs, runAnnotation } from '#src/annotation/runAnnotation.ts';
import { claudeHookResponse } from '#src/formatters/plan/claudeHookResponse.ts';
import { annotationArgs } from '#src/testFactories.ts';
import { annotationArgs, claudeHookPayload } from '#src/testFactories.ts';
import { createAnnotationDependencies, createStubContext, readErrorLogs } from '#src/testHelpers/index.ts';
import { type HookClaudeDependencies, runHookClaude } from './hookClaude.ts';

describe('hookClaude handler', () => {
it('emits the approved envelope when the review is approved', async () => {
it('emits the approved envelope echoing tool_input when the review is approved', async () => {
const { context, io } = createStubContext();
const submission = annotationSubmission.build({ status: 'approved', threads: [] });
const deps = createHookDependencies({ submission });
io.stdin.write(
JSON.stringify({
session_id: 'sess_123',
transcript_path: '/tmp/transcript.json',
cwd: '/work',
permission_mode: 'plan',
hook_event_name: 'PermissionRequest',
tool_name: 'ExitPlanMode',
tool_input: { plan: '# Plan\n\nStep 1.\n' },
}),
);
const payload = claudeHookPayload.build();
io.stdin.write(JSON.stringify(payload));
io.stdin.end();

await runHookClaude(context, deps);

expect(io.stdout.text()).toBe(`${JSON.stringify(claudeHookResponse(submission, '# Plan\n\nStep 1.\n'))}\n`);
expect(deps.calls).toEqual([annotationArgs.build({ content: '# Plan\n\nStep 1.\n', entrypoint: 'hook_claude' })]);
expect(io.stdout.text()).toBe(`${JSON.stringify(claudeHookResponse(submission, payload.tool_input))}\n`);
const parsed = JSON.parse(io.stdout.text().trim()) as ReturnType<typeof claudeHookResponse>;
if (parsed.hookSpecificOutput.decision.behavior !== 'allow') throw new Error('expected allow');
expect(parsed.hookSpecificOutput.decision.updatedInput).toEqual(payload.tool_input);
expect(deps.calls).toEqual([annotationArgs.build({ content: payload.tool_input.plan, entrypoint: 'hook_claude' })]);
});

it('emits a deny envelope with the markdown feedback when changes are requested', async () => {
const { context, io } = createStubContext();
const submission = annotationSubmission.build();
const deps = createHookDependencies({ submission });
const planContent = '# Plan\n\nStep 1.\n';
io.stdin.write(
JSON.stringify({
session_id: 'sess_123',
transcript_path: '/tmp/transcript.json',
cwd: '/work',
permission_mode: 'plan',
hook_event_name: 'PermissionRequest',
tool_name: 'ExitPlanMode',
tool_input: { plan: planContent },
}),
);
const payload = claudeHookPayload.build();
io.stdin.write(JSON.stringify(payload));
io.stdin.end();

await runHookClaude(context, deps);

const expected = claudeHookResponse(submission, planContent);
const expected = claudeHookResponse(submission, payload.tool_input);
expect(io.stdout.text()).toBe(`${JSON.stringify(expected)}\n`);
const parsed = JSON.parse(io.stdout.text().trim()) as typeof expected;
expect(parsed.hookSpecificOutput.decision.behavior).toBe('deny');
Expand All @@ -66,17 +50,7 @@ describe('hookClaude handler', () => {
const deps: HookClaudeDependencies = {
runReview: (reviewCtx, args) => runAnnotation(reviewCtx, args, createAnnotationDependencies({ submission })),
};
io.stdin.write(
JSON.stringify({
session_id: 'sess_123',
transcript_path: '/tmp/transcript.json',
cwd: '/work',
permission_mode: 'plan',
hook_event_name: 'PermissionRequest',
tool_name: 'ExitPlanMode',
tool_input: { plan: '# Plan\n\nStep 1.\n' },
}),
);
io.stdin.write(JSON.stringify(claudeHookPayload.build()));
io.stdin.end();

await runHookClaude(context, deps);
Expand All @@ -95,16 +69,7 @@ describe('hookClaude handler', () => {
it('aborts when hook_event_name is unsupported', () => {
const { context, io, logs } = createStubContext();
const deps = createHookDependencies();
io.stdin.write(
JSON.stringify({
session_id: 'sess_123',
transcript_path: '/tmp/transcript.json',
cwd: '/work',
hook_event_name: 'PreToolUse',
tool_name: 'ExitPlanMode',
tool_input: { plan: '# x' },
}),
);
io.stdin.write(JSON.stringify(claudeHookPayload.build({ hook_event_name: 'PreToolUse' })));
io.stdin.end();

expect(runHookClaude(context, deps)).rejects.toBeInstanceOf(CommanderError);
Expand All @@ -116,16 +81,7 @@ describe('hookClaude handler', () => {
it('aborts when PermissionRequest arrives for a tool other than ExitPlanMode', () => {
const { context, io, logs } = createStubContext();
const deps = createHookDependencies();
io.stdin.write(
JSON.stringify({
session_id: 'sess_123',
transcript_path: '/tmp/transcript.json',
cwd: '/work',
hook_event_name: 'PermissionRequest',
tool_name: 'Bash',
tool_input: { plan: 'unused' },
}),
);
io.stdin.write(JSON.stringify(claudeHookPayload.build({ tool_name: 'Bash' })));
io.stdin.end();

expect(runHookClaude(context, deps)).rejects.toBeInstanceOf(CommanderError);
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/hookClaude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ async function handleExitPlanMode(
abort(ctx, 'runtime', getErrorMessage(err));
}

return claudeHookResponse(submission, planContent);
return claudeHookResponse(submission, payload.tool_input);
}

function abort(ctx: CliContext, kind: 'input' | 'runtime', message: string): never {
Expand Down
14 changes: 9 additions & 5 deletions packages/cli/src/formatters/plan/claudeHookResponse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,21 @@ import { claudeHookResponse } from './claudeHookResponse.ts';
import { PLAN_TEMPLATES } from './templates.ts';

describe('claudeHookResponse', () => {
it('returns an allow envelope that switches the session to acceptEdits for approved submissions', () => {
it('returns an allow envelope that echoes tool_input and switches the session to acceptEdits for approved submissions', () => {
const submission = annotationSubmission.build({ status: 'approved', threads: [] });
const planContent = '# ignored by approved template\n';
const toolInput = {
plan: '# ignored by approved template\n',
planFilePath: '/home/user/.claude/plans/sample.md',
};

const response = claudeHookResponse(submission, planContent);
const response = claudeHookResponse(submission, toolInput);

expect(response).toEqual({
hookSpecificOutput: {
hookEventName: 'PermissionRequest',
decision: {
behavior: 'allow',
updatedInput: toolInput,
updatedPermissions: [{ type: 'setMode', mode: 'acceptEdits', destination: 'session' }],
},
},
Expand Down Expand Up @@ -61,7 +65,7 @@ describe('claudeHookResponse', () => {
],
});

const response = claudeHookResponse(submission, planContent);
const response = claudeHookResponse(submission, { plan: planContent });
const decision = response.hookSpecificOutput.decision;

expect(response.hookSpecificOutput.hookEventName).toBe('PermissionRequest');
Expand Down Expand Up @@ -108,7 +112,7 @@ describe('claudeHookResponse', () => {
],
});

const response = claudeHookResponse(submission, planContent);
const response = claudeHookResponse(submission, { plan: planContent });
const decision = response.hookSpecificOutput.decision;
if (decision.behavior !== 'deny') throw new Error('expected deny');

Expand Down
16 changes: 14 additions & 2 deletions packages/cli/src/formatters/plan/claudeHookResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,19 @@ export interface ClaudeHookResponse {
hookSpecificOutput: PermissionRequestHookSpecificOutput;
}

export function claudeHookResponse(submission: AnnotationSubmission, planContent: string): ClaudeHookResponse {
export interface ClaudeExitPlanModeInput {
plan: string;
[key: string]: unknown;
}

export function claudeHookResponse(
submission: AnnotationSubmission,
toolInput: ClaudeExitPlanModeInput,
): ClaudeHookResponse {
if (submission.status === 'approved') {
// Claude Code >=2.1.199 discards an ExitPlanMode allow without updatedInput, so echo the
// input verbatim: https://github.com/anthropics/claude-code/issues/74256
//
// setMode → acceptEdits is what actually exits plan mode for the session.
// Without it, the allow only grants this ExitPlanMode call — the session
// stays in `plan` and the agent can't touch the filesystem on its next turn.
Expand All @@ -17,6 +28,7 @@ export function claudeHookResponse(submission: AnnotationSubmission, planContent
hookEventName: 'PermissionRequest',
decision: {
behavior: 'allow',
updatedInput: toolInput,
updatedPermissions: [{ type: 'setMode', mode: 'acceptEdits', destination: 'session' }],
},
},
Expand All @@ -26,7 +38,7 @@ export function claudeHookResponse(submission: AnnotationSubmission, planContent
return {
hookSpecificOutput: {
hookEventName: 'PermissionRequest',
decision: { behavior: 'deny', message: formatAgentResponse(PLAN_TEMPLATES, submission, planContent) },
decision: { behavior: 'deny', message: formatAgentResponse(PLAN_TEMPLATES, submission, toolInput.plan) },
},
};
}
12 changes: 12 additions & 0 deletions packages/cli/src/testFactories.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import type { HarnessDescriptor } from '@contextbridge/harness';
import { Factory } from 'fishery';
import type { RunAnnotationArgs } from '#src/annotation/runAnnotation.ts';
import type { ClaudeHookPayload } from '#src/commands/claudeHookSchema.ts';
import type {
CodexStopHookPayload,
CodexTranscriptHookPromptLine,
CodexTranscriptPlanLine,
} from '#src/commands/codexHookSchema.ts';
import type { Environment } from '#src/environment.ts';
import type { ClaudeExitPlanModeInput } from '#src/formatters/plan/claudeHookResponse.ts';

export const claudeHookPayload = Factory.define<ClaudeHookPayload & { tool_input: ClaudeExitPlanModeInput }>(() => ({
session_id: 'sess_123',
transcript_path: '/tmp/transcript.json',
cwd: '/work',
permission_mode: 'plan',
hook_event_name: 'PermissionRequest',
tool_name: 'ExitPlanMode',
tool_input: { plan: '# Plan\n\nStep 1.\n', planFilePath: '/home/user/.claude/plans/sample.md' },
}));

export const codexStopHookPayload = Factory.define<CodexStopHookPayload>(() => ({
session_id: 'sess_123',
Expand Down
Loading