Add GitHub Copilot hook support (CLI + cloud agent) (#279)

* Add GitHub Copilot hook support (CLI + cloud agent)

Wire the Impeccable design detector into GitHub Copilot's hook system so
direct file edits get the same post-edit design feedback the Claude Code,
Codex, and Cursor harnesses already receive.

GitHub Copilot's contract differs from the existing harnesses (verified
against Copilot CLI 1.0.63):
- Repo-level manifest at `.github/hooks/impeccable.json` (read by both the
  CLI, once committed to the default branch, and the cloud/app agent).
- Flat `postToolUse` entries with `bash`/`timeoutSec` and a full-match
  `matcher` regex; the file-editing tools are `edit` and `create`.
- The stdin event uses camelCase `toolName`/`toolArgs`, where `toolArgs` is
  a JSON *string* carrying the touched file under `path`.
- Context is injected via a top-level `additionalContext` string.

Changes:
- hooks.js: buildGitHubHooksManifest() + route `github` in hooksJsonFor().
- providers.js: emitHooks/hooksManifestRel for the github provider.
- hook-lib.mjs: detect the github harness, normalize the camelCase event
  (parse the JSON-string toolArgs -> tool_input.file_path), and emit the
  `additionalContext` payload shape.
- hook-admin.mjs / skills.mjs: install + idempotent-repair the
  `.github/hooks/impeccable.json` manifest (bash-aware marker stripping).
- hooks.md: document GitHub Copilot as a supported harness.
- Tests for the builder, routing, event normalization, and end-to-end run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Cover Copilot apply_patch edits in the hook (live-verified)

The first cut matched only `edit|create`, the tool names `copilot -p` uses.
A live trace against Copilot CLI 1.0.63 in an interactive session showed it
edits files via `apply_patch`, whose toolArgs is a raw OpenAI-format patch
string (`*** Begin Patch` / `*** Add File:`), not JSON. With the narrow
matcher the hook command never ran.

- hooks.js / hook-admin.mjs: matcher -> `edit|create|apply_patch`.
- hook-lib.mjs: normalizeGitHubEvent now routes apply_patch's raw patch
  string into tool_input.command (reusing the existing parseApplyPatchPaths /
  resolveTargetFiles plumbing) and only JSON-parses toolArgs for the
  edit/create/view tools. tool_name is normalized to apply_patch so the patch
  path is extracted even if a future build relabels the tool.
- Tests: apply_patch matcher assertions, event normalization, and an
  end-to-end runHook covering the interactive/cloud path.

Verified live: a trusted interactive `apply_patch` edit fires the hook and
returns the expected `additionalContext` design reminder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address review feedback + add changelog entry

- hook-lib.mjs (Bugbot, low): looksLikeApplyPatch no longer misroutes an
  edit/create event whose edited *content* contains apply_patch markers. A
  real apply_patch payload is a raw string that does not parse as JSON; an
  edit payload is a JSON object, so only non-JSON-object strings are treated
  as apply_patch. Edit events keep extracting `path`. Adds a regression test.
- skills.mjs (Bugbot, medium): document why `.github` is intentionally
  excluded from hookScriptPathForProvider. Its hook manifest is committed and
  shared (read by the Copilot cloud agent and teammates), so the command must
  stay portable via `$(git rev-parse ...)`; rewriting it to a machine-local
  absolute path would break those. GitHub skills are project-scoped, so the
  project-relative path resolves.
- changelog: add an Upcoming (v3.x placeholder) entry for the Copilot hook.
  Version is not bumped yet (batching with other changes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-06-20 02:24:18 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 793feda5a0
commit 41ff946121
10 changed files with 371 additions and 9 deletions
+26 -1
View File
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
},
}),
},
{
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
// manifest is honored by the CLI (once committed to the default branch) and
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
provider: '.github',
skillRel: '.github/skills/impeccable',
destRel: '.github/hooks/impeccable.json',
manifest: () => ({
version: 1,
hooks: {
postToolUse: [
{
type: 'command',
matcher: 'edit|create|apply_patch',
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
timeoutSec: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
// flat entry shape, where the marker lives under the shell-command keys.
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;