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
+107 -1
View File
@@ -959,13 +959,114 @@ export function resolveTargetFiles(event, projectCwd) {
export function resolveHarness(env = {}, event = null) {
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
if (explicit === 'cursor') return 'cursor';
if (explicit === 'github') return 'github';
if (explicit === 'claude' || explicit === 'codex') return 'claude';
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
// has no `tool_name`/`tool_input`. That shape is the discriminator.
if (event && typeof event === 'object'
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
&& event.tool_name === undefined && event.tool_input === undefined) {
return 'github';
}
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
return 'claude';
}
// GitHub Copilot's postToolUse payload is
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
// normalizeGitHubEvent). The detector reads the file from disk after the tool
// ran, so only the path (not the proposed content) is needed here.
export function parseGitHubToolArgs(toolArgs) {
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
if (typeof toolArgs === 'string' && toolArgs.trim()) {
try {
const parsed = JSON.parse(toolArgs);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
return {};
}
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
// *** Begin Patch
// *** Add File: /abs/app.css
// +body { ... }
// *** End Patch
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
// JSON string with the path under `path`. Both must map onto the internal shape.
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
function looksLikeApplyPatch(rawArgs) {
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
// Guard against an edit/create payload whose edited *content* happens to
// contain patch markers: that payload is a JSON object string, whereas a real
// apply_patch payload is a raw patch string that does not parse as JSON. Only
// treat non-JSON-object strings as apply_patch so edit events still get their
// `path` extracted.
try {
const parsed = JSON.parse(rawArgs);
if (parsed && typeof parsed === 'object') return false;
} catch { /* not JSON → genuine raw patch */ }
return true;
}
function applyPatchText(rawArgs) {
if (typeof rawArgs === 'string') {
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
// Defensive: a future Copilot build might JSON-wrap the patch.
const parsed = parseGitHubToolArgs(rawArgs);
return parsed.patch || parsed.input || parsed.command || '';
}
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
}
return '';
}
function normalizeGitHubEvent(event, projectCwd) {
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
const sessionId = event.sessionId || event.session_id || 'unknown';
const toolName = event.toolName || event.tool_name || null;
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
const rawArgs = event.toolArgs;
let normalizedToolName = toolName;
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
// resolveTargetFiles() reads the touched paths from tool_input.command when
// tool_name is 'apply_patch', so normalize the name even if a future build
// sends the patch under a different tool label.
const patch = applyPatchText(rawArgs);
if (patch) {
toolInput.command = patch;
normalizedToolName = 'apply_patch';
}
} else {
const args = parseGitHubToolArgs(rawArgs);
const filePath = args.path || args.file_path || args.filePath || args.target_file;
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
}
return {
...event,
cwd,
session_id: sessionId,
tool_name: normalizedToolName,
tool_input: toolInput,
};
}
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
if (!event || typeof event !== 'object') return event;
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
if (harness !== 'cursor') return event;
const cwd = event.cwd
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
@@ -1520,6 +1621,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
if (harness === 'cursor') {
return JSON.stringify({ additional_context: text });
}
// GitHub Copilot's postToolUse hook injects context via a top-level
// `additionalContext` string (alongside an optional `modifiedResult`).
if (harness === 'github') {
return JSON.stringify({ additionalContext: text });
}
return JSON.stringify({
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
});