Decide, modify, inject
# grade this stage (from your course directory)
npx theinference test --stage decide-and-modify --watchThe matcher decided whether a hook fires; the exit code decided whether it crashed. Now comes the payload: a PreToolUse hook that exits 0 prints a JSON decision on stdout, and your job is to apply it to the pending tool call. This is where hooks earn their keep — a hook can block the call with a reason, rewrite its arguments before it runs, or inject context the model sees next turn. One small function folds all three into a clean effect the loop can act on.
Three fields, three powers. decision: "block" (with a reason) stops the tool before any side effect — the model gets the reason back and adapts. updatedInput replaces the tool's arguments: the hook saw { command: "rm" }, decided it was fine but should be { command: "ls" }, and the rewritten input is what actually runs. additionalContext injects a string into the next turn without touching the call itself — how a PostToolUse lint hook says "lint passed" or feeds errors back. Your function normalizes whatever the hook returned into one predictable HookEffect.
What to build
Implement applyPreToolUse
In src/hooks.ts, implement applyPreToolUse(out, toolInput). out is the hook's parsed JSON ({ decision?, reason?, updatedInput?, additionalContext? }); toolInput is the tool call's current arguments. Return a HookEffect:
- Block — if
out.decision === "block": return{ allowed: false, input: toolInput, injectedContext: null, reason: out.reason ?? "blocked by hook" }. The input is left unchanged (a blocked call never runs, so there's nothing to rewrite) and no context is injected. - Allow (the default) — otherwise: return
allowed: true, withinput = out.updatedInput ?? toolInput(rewrite if the hook supplied one, else pass the original through untouched),injectedContext = out.additionalContext ?? null, andreason: null.
Then make program.ts hook-apply <fixture> map applyPreToolUse(x.output, x.input) over case.cases and print { effects }.
export interface HookOutput {
decision?: "block" | "allow";
reason?: string;
updatedInput?: Record<string, unknown>;
additionalContext?: string;
}
export interface HookEffect {
allowed: boolean;
input: Record<string, unknown>;
injectedContext: string | null;
reason: string | null;
}
/** Apply a PreToolUse hook's output to a pending tool call. */
export function applyPreToolUse(
out: HookOutput,
toolInput: Record<string, unknown>,
): HookEffect {
// block: allowed=false, input=toolInput (unchanged), reason=out.reason ?? "blocked by hook"
// else: allowed=true, input=out.updatedInput ?? toolInput, injectedContext=out.additionalContext ?? null
}
Two failure modes the grader catches. Block too late: if allowed:false but the loop already executed the tool, the brake did nothing — block has to gate execute, so the side effect never happens. Rewrite that doesn't land: if the hook returns updatedInput: { command: "ls" } but the loop runs the original command: "rm", the rewrite was cosmetic. The contract is strict: input is the bytes the tool actually receives — the rewritten ones when present, the originals otherwise.
A blocked call leaves input as the original toolInput, even if out.updatedInput is also present — there's nothing to rewrite for a tool that won't run, and surfacing a phantom rewrite would mislead the loop. Only the allow branch consults updatedInput. Same for additionalContext: it's injected only when the call is allowed; a block carries its reason instead. One decision per hook, no mixed signals.
adapter mode, stdout_json_match. program.ts hook-apply runs applyPreToolUse(x.output, x.input) over each case and prints { effects }. The fixture covers all three powers: a block ({decision:'block',reason:'no rm'} over {command:'rm -rf /'}), a rewrite ({updatedInput:{command:'ls'}} over {command:'rm'}), and an inject ({additionalContext:'lint passed'} over {path:'a.ts'}). Expected: the blocked call is allowed:false with its original input and reason, the rewrite shows input:{command:'ls'}, and the inject shows injectedContext:'lint passed' — each field compared exactly.
./your_program.sh hook-apply fixtures/case.json
# cases: block {command:"rm -rf /"} | rewrite -> {command:"ls"} | inject "lint passed"
# -> {"effects":[
# {"allowed":false,"input":{"command":"rm -rf /"},"injectedContext":null,"reason":"no rm"},
# {"allowed":true,"input":{"command":"ls"},"injectedContext":null,"reason":null},
# {"allowed":true,"input":{"path":"a.ts"},"injectedContext":"lint passed","reason":null}]}