mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 17:46:36 +03:00
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:
co-authored by
Claude Opus 4.8
parent
793feda5a0
commit
41ff946121
+107
-1
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user