diff --git a/docs/README.instructions.md b/docs/README.instructions.md index a2d45c00..7b953ff1 100644 --- a/docs/README.instructions.md +++ b/docs/README.instructions.md @@ -110,6 +110,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-instructions) for guidelines on | [Go Development Instructions](../instructions/go.instructions.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fgo.instructions.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode-insiders%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fgo.instructions.md) | Instructions for writing Go code following idiomatic Go practices and community standards | | [Go MCP Server Development Guidelines](../instructions/go-mcp-server.instructions.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fgo-mcp-server.instructions.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode-insiders%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fgo-mcp-server.instructions.md) | Best practices and patterns for building Model Context Protocol (MCP) servers in Go using the official github.com/modelcontextprotocol/go-sdk package. | | [Guidance for Localization](../instructions/localization.instructions.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Flocalization.instructions.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode-insiders%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Flocalization.instructions.md) | Guidelines for localizing markdown documents | +| [Hook Authoring Guidelines](../instructions/hooks.instructions.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fhooks.instructions.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode-insiders%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fhooks.instructions.md) | Portable guidance for authoring safe, fast, and clear hooks and reusable hook examples | | [How to Use the Sample Components](../instructions/pcf-sample-components.instructions.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fpcf-sample-components.instructions.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode-insiders%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fpcf-sample-components.instructions.md) | How to use and run PCF sample components from the PowerApps-Samples repository | | [HTML CSS Style Color Guide](../instructions/html-css-style-color-guide.instructions.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fhtml-css-style-color-guide.instructions.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode-insiders%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fhtml-css-style-color-guide.instructions.md) | Color usage guidelines and styling rules for HTML elements to ensure accessible, professional designs. | | [Java 11 to Java 17 Upgrade Guide](../instructions/java-11-to-java-17-upgrade.instructions.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fjava-11-to-java-17-upgrade.instructions.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode-insiders%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fjava-11-to-java-17-upgrade.instructions.md) | Comprehensive best practices for adopting new Java 17 features since the release of Java 11. | diff --git a/instructions/hooks.instructions.md b/instructions/hooks.instructions.md new file mode 100644 index 00000000..fea06098 --- /dev/null +++ b/instructions/hooks.instructions.md @@ -0,0 +1,597 @@ +--- +description: 'Portable guidance for authoring safe, fast, and clear hooks and reusable hook examples' +applyTo: '.github/hooks/**, hooks/**' +--- + +# Hook Authoring Guidelines + +Hooks are **small, deterministic commands or scripts** that run at specific lifecycle events. +An awesome hook does one clear job, runs quickly, and makes its side effects explicit. + +## Folder Structure + +A GitHub Copilot hook lives in `.github/hooks/` inside your repository: + +```text +.github/ +└── hooks/ + ├── block-dangerous-commands.json ← hook config (which event, which script, options) + └── scripts/ + ├── block-dangerous-commands.sh ← Bash implementation + └── block-dangerous-commands.ps1 ← PowerShell implementation (optional if Bash-only) +``` + +You can have multiple `.json` files — each one registers hooks for one or more events. The host loads all of them. + +## The Config File + +Each `.json` file maps events to an array of hook entries. + +- **Command hooks** (`type: "command"`): run a local script. The host passes event JSON on stdin, your script responds through exit code and stdout. + +### Config example + +```json +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "matcher": "bash", + "type": "command", + "bash": "./.github/hooks/scripts/block-dangerous-commands.sh", + "powershell": "./.github/hooks/scripts/block-dangerous-commands.ps1", + "cwd": ".", + "timeoutSec": 5, + "env": { + "BLOCK_MODE": "deny" + } + } + ] + } +} +``` + +### Config fields + +| Field | Required | What it does | +| ---- | ---- | ---- | +| `type` | yes | `"command"` for scripts | +| `matcher` | no | Host-level filter — hook only fires when the tool name matches this value (e.g. `"bash"`, `"powershell"`, `"edit"`, `"create"`). Locally verified working in Copilot CLI v1.0.36; not yet used in repo hook samples. | +| `bash` | one or both | Command line invoked on Unix / Bash-capable hosts | +| `powershell` | one or both | Command line invoked on Windows / PowerShell-capable hosts | +| `cwd` | no | Working directory, relative to repo root | +| `timeoutSec` | no | Max seconds before the host kills the process (default 30) | +| `env` | no | Extra process environment variables passed to the script | + +### Why matchers matter + +Without a matcher, every `preToolUse` hook fires on **every** tool call. Your script starts with boilerplate like: + +```bash +tool_name="$(printf '%s' "$payload" | jq -r '.toolName')" +[[ "$tool_name" != "bash" ]] && exit 0 +``` + +With a matcher, the host does this filtering for you — no boilerplate, no process spawn for irrelevant tools. This will likely become the standard pattern once the feature stabilizes. + +If your hooks must work on both the CLI and the cloud agent (or on older CLI versions), keep the in-script filtering as a fallback even when using matchers. + +### `env` — static configuration for your script + +`env` is a **standard host field**. The keys inside it are **author-defined variables** — you choose the names and values. + +They arrive as **process environment variables**, not inside the stdin JSON payload. Use them for static configuration that should not be hardcoded: + +| Pattern | Example | +| ---- | ---- | +| Mode flag | `"BLOCK_MODE": "deny"` — same script logs in one repo, blocks in another | +| Threshold | `"MAX_CHANGED_FILES": "20"` | +| Path | `"AUDIT_LOG_PATH": ".github/logs/hooks.log"` | +| Feature toggle | `"ENABLE_NOTIFICATIONS": "false"` | + +### `bash` and `powershell` — when to provide one or both + +The host picks whichever entry matches the current environment. It does not run both, and does not fall back from one to the other. + +| Situation | Provide | +| ---- | ---- | +| Private hook, one known platform | Only that platform's entry | +| Published hook claiming cross-platform support | Both entries | +| Single cross-platform runtime (Python, Node, pwsh) | Expose the same script through both entries | +| Bash-only dependency | `bash` only | +| Windows-only dependency | `powershell` only | + +Cross-platform example using Python through both entries: + +```json +{ + "type": "command", + "bash": "python3 ./.github/hooks/scripts/check.py", + "powershell": "python .\\.github\\hooks\\scripts\\check.py" +} +``` + +## The Script Contract + +Every hook script follows the same basic contract: read JSON from stdin, do work, and respond through exit code, stdout, and stderr. + +**Important**: `toolArgs` is a **JSON string**, not a nested object. You must parse it a second time to access its fields. + +### Reading stdin and responding — Bash and PowerShell + +**Bash**: + +```bash +#!/usr/bin/env bash +set -euo pipefail +payload="$(cat)" +tool_name="$(printf '%s' "$payload" | jq -r '.toolName')" +tool_args="$(printf '%s' "$payload" | jq -r '.toolArgs')" +command="$(printf '%s' "$tool_args" | jq -r '.command // ""')" +``` + +**PowerShell**: + +```powershell +Set-StrictMode -Version Latest +$payload = [Console]::In.ReadToEnd() | ConvertFrom-Json +$toolArgs = $payload.toolArgs | ConvertFrom-Json +$command = $toolArgs.command +``` + +To deny in `preToolUse` (PowerShell): + +```powershell +@{ permissionDecision = 'deny'; permissionDecisionReason = 'Blocked by policy' } | + ConvertTo-Json -Compress +exit 0 +``` + +### What the script receives + +| Input | What it carries | +| ---- | ---- | +| `stdin` | One JSON payload describing the current event | +| process environment | Normal env vars plus any you defined under `env` in the config | +| working directory | `cwd` from the config, or the host default | + +### How the script responds + +| Channel | Purpose | +| ---- | ---- | +| exit `0` | Script succeeded — host continues unless stdout carried a structured deny | +| non-zero exit | **Blocks the triggering action** and signals hook failure | +| `stdout` | Structured machine-readable output — only for events that document a stdout schema (like `preToolUse`) | +| `stderr` | Human-readable diagnostics for logs | + +### Exit codes and deny: the full picture + +The deny mechanism **depends on the event**: + +| Event type | How to allow | How to deny / block | +| ---- | ---- | ---- | +| `preToolUse` | exit `0`, empty or `{"permissionDecision":"allow"}` on stdout | **Preferred**: exit `0` + `{"permissionDecision":"deny","permissionDecisionReason":"..."}` on stdout — gives the host a reason to show. **Also works**: non-zero exit blocks the tool call, but without a structured reason. | +| `userPromptSubmitted` | exit `0` | Non-zero exit blocks the prompt (stdout is ignored for this event) | +| `agentStop` | exit `0` | Non-zero exit blocks the action | +| Other events (`sessionStart`, `sessionEnd`, `postToolUse`, `errorOccurred`) | exit `0` | Non-zero exit signals failure; the host may skip subsequent hooks for that event | + +**Rule of thumb**: if the event has a structured stdout schema (like `preToolUse`), use it — it gives a clean reason and is the officially documented deny path. For events without structured stdout, non-zero exit is the practical block mechanism — this is confirmed by repo samples and learning hub docs, though the official GitHub reference does not explicitly document "non-zero = block" as a contract guarantee. + +### Example 1: Commit gate — block commits until lint, types, and tests pass + +**Why this pattern matters**: the deny reason includes the actual errors, so the agent sees what's broken and fixes it before trying again. This creates a self-correcting feedback loop — the most powerful thing hooks can do. + +**Event**: `preToolUse` — fires before the agent runs `git commit` + +**Config** — `.github/hooks/commit-gate.json`: + +```json +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "bash": "./.github/hooks/scripts/commit-gate.sh", + "cwd": ".", + "timeoutSec": 120 + } + ] + } +} +``` + +**Script** — `.github/hooks/scripts/commit-gate.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +payload="$(cat)" +tool_name="$(printf '%s' "$payload" | jq -r '.toolName')" + +# Only gate bash commands that are git commits +if [[ "$tool_name" != "bash" ]]; then exit 0; fi +command="$(printf '%s' "$payload" | jq -r '.toolArgs' | jq -r '.command // ""')" +if ! printf '%s' "$command" | grep -q "git commit"; then exit 0; fi + +CWD="$(printf '%s' "$payload" | jq -r '.cwd')" +ERRORS="" + +# 1. TypeScript type check +if [[ -f "$CWD/tsconfig.json" ]]; then + TSC_OUT=$(cd "$CWD" && npx tsc --noEmit 2>&1) || ERRORS="${ERRORS} +=== TypeScript Errors === +$(echo "$TSC_OUT" | head -30)" +fi + +# 2. Lint +if [[ -f "$CWD/package.json" ]]; then + HAS_LINT=$(jq -r '.scripts.lint // empty' "$CWD/package.json" 2>/dev/null) + if [[ -n "$HAS_LINT" ]]; then + LINT_OUT=$(cd "$CWD" && npm run lint --silent 2>&1) || ERRORS="${ERRORS} +=== Lint Errors === +$(echo "$LINT_OUT" | tail -30)" + fi + + # 3. Tests + HAS_TEST=$(jq -r '.scripts.test // empty' "$CWD/package.json" 2>/dev/null) + if [[ -n "$HAS_TEST" ]]; then + TEST_OUT=$(cd "$CWD" && CI=true npm test -- --watchAll=false 2>&1) || ERRORS="${ERRORS} +=== Test Failures === +$(echo "$TEST_OUT" | tail -30)" + fi +fi + +if [[ -n "$ERRORS" ]]; then + jq -nc --arg reason "Cannot commit — fix these issues first: +$ERRORS" \ + '{permissionDecision:"deny",permissionDecisionReason:$reason}' +fi +exit 0 +``` + +**What happens at runtime:** + +| Scenario | stdout | exit | Host action | +| ---- | ---- | ---- | ---- | +| All checks pass | empty | `0` | Commit proceeds | +| Lint fails | `{"permissionDecision":"deny","permissionDecisionReason":"Cannot commit — fix these issues first:\n=== Lint Errors ===\n..."}` | `0` | Blocks commit; agent sees the errors and fixes them | +| jq missing | empty | non-zero | Hook failure | + +### Example 2: Auto-format after file edits + +**Why this pattern matters**: the agent writes code, and your formatter runs immediately after — no manual step needed. The agent's next read of that file sees the formatted version. + +**Event**: `postToolUse` — fires after `edit` or `create` tool calls + +**Config** — `.github/hooks/format-on-save.json`: + +```json +{ + "version": 1, + "hooks": { + "postToolUse": [ + { + "type": "command", + "bash": "./.github/hooks/scripts/format-on-save.sh", + "cwd": ".", + "timeoutSec": 15 + } + ] + } +} +``` + +**Script** — `.github/hooks/scripts/format-on-save.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +payload="$(cat)" +tool_name="$(printf '%s' "$payload" | jq -r '.toolName')" +result_type="$(printf '%s' "$payload" | jq -r '.toolResult.resultType // ""')" + +# Only format after successful file writes +case "$tool_name" in + edit|create) ;; + *) exit 0 ;; +esac +[[ "$result_type" != "success" ]] && exit 0 + +file_path="$(printf '%s' "$payload" | jq -r '.toolArgs' | jq -r '.path // ""')" +[[ -z "$file_path" || ! -f "$file_path" ]] && exit 0 + +# Run the project's formatter — adapt to your stack +if command -v npx >/dev/null 2>&1 && [[ -f "package.json" ]]; then + npx prettier --write "$file_path" 2>/dev/null || true +elif command -v dotnet >/dev/null 2>&1 && [[ "$file_path" == *.cs ]]; then + dotnet format --include "$file_path" 2>/dev/null || true +fi +exit 0 +``` + +**What happens at runtime:** + +| Scenario | What the hook does | exit | +| ---- | ---- | ---- | +| Agent edits `src/app.ts` successfully | Runs `prettier --write src/app.ts` | `0` | +| Agent runs `bash ls` | Skips (not a file-writing tool) | `0` | +| Prettier not installed | Silently skips formatting | `0` | + +### Example 3: Block dangerous commands with structured deny + +**Why this pattern matters**: the simplest guardrail — prevent destructive shell commands before they execute, with a clear reason the agent can read. + +**Event**: `preToolUse` — fires before any tool call + +**Config** — `.github/hooks/block-dangerous.json`: + +```json +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "bash": "./.github/hooks/scripts/block-dangerous.sh", + "cwd": ".", + "timeoutSec": 5, + "env": { + "BLOCK_MODE": "deny" + } + } + ] + } +} +``` + +**Script** — `.github/hooks/scripts/block-dangerous.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +payload="$(cat)" +block_mode="${BLOCK_MODE:-log}" +tool_name="$(printf '%s' "$payload" | jq -r '.toolName')" + +[[ "$tool_name" != "bash" ]] && exit 0 + +command="$(printf '%s' "$payload" | jq -r '.toolArgs' | jq -r '.command // ""')" + +if printf '%s' "$command" | grep -qE 'rm -rf /|git reset --hard|git clean -fd|git push.*--force'; then + # Truncate command to avoid leaking secrets in deny reason or logs + short_cmd="$(printf '%.80s' "$command")" + if [[ "$block_mode" == "deny" ]]; then + jq -cn --arg reason "Destructive command blocked: ${short_cmd}..." \ + '{permissionDecision:"deny",permissionDecisionReason:$reason}' + else + echo "Would block: ${short_cmd}..." >&2 + fi +fi +exit 0 +``` + +**What happens at runtime:** + +| Scenario | BLOCK_MODE | stdout | exit | Host action | +| ---- | ---- | ---- | ---- | ---- | +| Safe command | any | empty | `0` | Proceeds | +| `git push --force` | `deny` | `{"permissionDecision":"deny",...}` | `0` | Blocks with reason | +| `git push --force` | `log` | empty | `0` | Proceeds (log only) | + +## Event Types + +The full hooks reference is authoritative. **Always check it for the latest payload shapes** before writing a hook: + +- [Hooks configuration reference](https://docs.github.com/en/copilot/reference/hooks-configuration) +- [About hooks](https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-hooks) + +| Event | stdout | Typical use | +| ---- | ---- | ---- | +| `sessionStart` | **parsed** — `additionalContext` in stdout is injected into the session | Setup, validation, context injection, logging | +| `sessionEnd` | ignored | Cleanup, summaries | +| `userPromptSubmitted` | ignored | Auditing, prompt blocking | +| `preToolUse` | **parsed** — `permissionDecision`, `modifiedArgs`/`updatedInput`, `additionalContext` | Guardrails, deny/block, argument modification | +| `postToolUse` | ignored | Logging, formatting | +| `postToolUseFailure` | — | Recovery after a failed tool run | +| `agentStop` | — | Final validation | +| `subagentStart` | — | Subagent audit | +| `subagentStop` | — | Subagent output validation | +| `errorOccurred` | ignored | Diagnostics, alerts | +| `preCompact` | — | Pre-compaction work | +| `permissionRequest` | — | Approval workflow | + +### Payload schemas for common events + +These are the payload shapes from the hooks reference. Always verify against the [official reference](https://docs.github.com/en/copilot/reference/hooks-configuration) for the latest fields. + +**`sessionStart`** + +```json +{ + "timestamp": 1704614400000, + "cwd": "/path/to/project", + "source": "new", + "initialPrompt": "Create a new feature" +} +``` + +`source` is `"new"`, `"resume"`, or `"startup"`. `initialPrompt` is the user's first prompt if provided. + +**`sessionStart` stdout output** — the host parses stdout for: + +```json +{ + "additionalContext": "Current branch: main. Deploy target: staging." +} +``` + +`additionalContext` is injected directly into the session conversation, letting hooks provide environment-specific context dynamically. + +**`sessionEnd`** + +```json +{ + "timestamp": 1704618000000, + "cwd": "/path/to/project", + "reason": "complete" +} +``` + +`reason` is `"complete"`, `"error"`, `"abort"`, `"timeout"`, or `"user_exit"`. + +**`userPromptSubmitted`** + +```json +{ + "timestamp": 1704614500000, + "cwd": "/path/to/project", + "prompt": "Fix the authentication bug" +} +``` + +The field is `prompt` — the exact text the user submitted. + +**`preToolUse`** + +```json +{ + "timestamp": 1704614600000, + "cwd": "/path/to/project", + "toolName": "bash", + "toolArgs": "{\"command\":\"rm -rf dist\",\"description\":\"Clean build directory\"}" +} +``` + +`toolArgs` is a **JSON string** — parse it a second time to access its fields. + +**`preToolUse` stdout output** — the host parses stdout for: + +| Field | What it does | +| ---- | ---- | +| `permissionDecision` | `"deny"` blocks the tool call. `"allow"` and `"ask"` also accepted; only `"deny"` is currently processed. | +| `permissionDecisionReason` | Human-readable reason shown to the user | +| `modifiedArgs` or `updatedInput` | Replacement tool arguments — used instead of the originals | +| `additionalContext` | Text injected into the agent's context for this turn | + +**`postToolUse`** + +```json +{ + "timestamp": 1704614700000, + "cwd": "/path/to/project", + "toolName": "bash", + "toolArgs": "{\"command\":\"npm test\"}", + "toolResult": { + "resultType": "success", + "textResultForLlm": "All tests passed (15/15)" + } +} +``` + +`resultType` is `"success"`, `"failure"`, or `"denied"`. + +**`errorOccurred`** + +```json +{ + "timestamp": 1704614800000, + "cwd": "/path/to/project", + "error": { + "message": "Network timeout", + "name": "TimeoutError", + "stack": "TimeoutError: Network timeout\n at ..." + } +} +``` + +**`agentStop`** + +```json +{ + "timestamp": 1704618000000, + "cwd": "/path/to/project" +} +``` + +Minimal payload — use it to trigger end-of-session actions like running `git diff --stat` or final validation. + +## When Hooks Are the Wrong Tool + +| Avoid hooks for | Better fit | +| ---- | ---- | +| Open-ended reasoning or style guidance | Instructions, prompts, or agents | +| Long multi-step workflows with memory, retries, or branching | Agents, scripts, or workflow engines | +| Background daemons, watchers, debounce loops, or async jobs | Dedicated automation, services, or CI | +| Heavy repository-wide validation | CI, scheduled jobs, or dedicated automation | + +## Universal Design Rules + +| Rule | Why it matters | +| ---- | ---- | +| One hook, one responsibility | Small hooks are easier to trust and debug | +| Default to **observe first** | Blocking or mutation should be an explicit choice | +| Keep hooks synchronous, bounded, and non-interactive | Hooks run in the critical path | +| Make hooks deterministic and idempotent | Re-runs should not create drift | +| Do not mutate branch, index, or worktree state by default | Git-destructive behavior is high risk | +| Treat prompts, tool arguments, and tool output as untrusted and sensitive | Input may be hostile or private | +| Redact secrets, credentials, tokens, and private content from logs | Logs often outlive the hook run | + +## Script Authoring Rules + +- Validate the JSON fields you actually use +- Quote shell variables and never build commands from raw input +- Keep stdout clean unless the host requires structured output +- Use strict modes: Bash `set -euo pipefail`, PowerShell `Set-StrictMode -Version Latest` +- Check dependencies early and fail clearly if they are missing +- Avoid prompts, hidden installs, or environment mutation during execution +- Test scripts by piping representative JSON payloads into them manually + +## Choose the Smallest Viable Implementation + +1. **PowerShell 7**, **Node.js**, or **Python** for broadly portable hooks +2. **Bash** where Bash is an explicit requirement or safe assumption +3. **An existing project CLI** when the repository already depends on it + +Do **not** introduce a new compiled runtime just to implement an ordinary hook. + +## Packaging a Reusable Hook + +- Package config, scripts, and docs together +- Document the trigger event, purpose, side effects, dependencies, and disable path +- Explain what the hook reads, what it writes, and what it blocks + +## Anti-Patterns + +- Long-running hooks, watchers, background daemons, or fire-and-forget async work +- Heavy scans on every event when a narrower trigger would do +- Hidden network calls or uploads in the critical path +- Silent mutation of Git state (checkout, reset, clean, stash, stage, commit, push, or history rewriting) by default +- Interactive prompts or implicit approval steps +- Noisy stdout, ad-hoc output formats, or mixed machine/human output +- Logging raw prompts, secrets, credentials, or large tool outputs +- Monolithic hooks that mix unrelated responsibilities + +## Portability + +### GitHub Copilot: CLI, VS Code, and Cloud Agent + +The same `.github/hooks/*.json` config, the same payload schema, and the same script contract work across CLI, VS Code, and the cloud agent. Event names accept both camelCase (`preToolUse`) and PascalCase (`PreToolUse`). The documented payload field for tool arguments is `toolArgs` (a JSON string). + +One thing to know: the cloud agent only loads hooks from the repository's **default branch**. If your hooks.json is only on a feature branch, the cloud agent won't see it. + +### Claude Code + +Claude Code uses a different hook system: + +- Settings in `~/.claude/settings.json` and `.claude/settings.json` +- Different event names and matcher syntax (regex, `if` conditions) +- Exit 2 = block, exit 1 = non-blocking error (not the same as GitHub Copilot) +- 5 hook types (command, http, mcp_tool, prompt, agent) +- 29+ events including `FileChanged`, `CwdChanged`, `ConfigChange` + +The shared best practice is the same: keep hooks small, deterministic, explicit about I/O, and strict about side effects.