mirror of
https://github.com/github/awesome-copilot.git
synced 2026-04-30 12:15:56 +00:00
* feat: add hooks authoring instruction Add hooks.instructions.md with portable guidance for writing GitHub Copilot hooks: - Folder structure, config schema, and all field documentation - Script contract: stdin JSON, exit codes, stdout/stderr channels - Payload schemas for all common events (sessionStart, sessionEnd, userPromptSubmitted, preToolUse, postToolUse, errorOccurred, agentStop) - Per-event deny mechanisms (structured JSON for preToolUse, non-zero exit for others) - Matcher support for host-level tool filtering - Three impactful examples: commit gate, auto-format, dangerous command blocker - Bash and PowerShell templates - Cross-platform guidance (Python through both entries) - Anti-patterns, design rules, and portability notes (GitHub Copilot vs Claude Code) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address PR review comments - Document both toolArgs (JSON string) and tool_input/toolInput (object) variants with defensive parsing - Update sessionStart stdout to reflect additionalContext injection support - Document preToolUse stdout output fields: modifiedArgs/updatedInput, additionalContext - Add matcher field note about local verification - Remove undocumented notification event from event table Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address round 2 review comments - Defensive toolArgs parsing in all 3 example scripts (handle toolArgs string, tool_input, toolInput) - PowerShell type check: test -is [string] before ConvertFrom-Json, init to null - commit-gate.sh: add package.json existence guard before jq - block-dangerous.sh: truncate command in deny reason and stderr to avoid leaking secrets - Consistent defensive parsing helper across all Bash examples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: handle toolArgs as string or object in jq, fix short_cmd scoping - All 3 Bash example jq blocks now check toolArgs type before fromjson - short_cmd defined before the deny/log branch to avoid set -u error - Consistent defensive pattern across commit-gate, format-on-save, block-dangerous Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove accidentally committed agency.toml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: expand Portability Note into CLI vs VS Code vs Cloud Agent section Address Aaron's review: replace the thin portability bullet points with a concrete comparison table covering what is the same (config schema, event name casing, fields) and what differs (hook loading, shell environment, tool argument field names). Add a 'How to write portable hooks' checklist. Separate Claude Code into its own subsection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: simplify to toolArgs-only parsing, clean portability section - toolArgs is the documented contract — remove tool_input/toolInput defensive fallbacks from all examples and the Script Contract template - Simplify portability section: same config everywhere, one-line note about cloud agent requiring default branch - All 3 example scripts now use simple toolArgs pipe parsing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
598 lines
20 KiB
Markdown
598 lines
20 KiB
Markdown
---
|
|
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.
|