diff --git a/.changeset/786-copilot-hooks-agents.md b/.changeset/786-copilot-hooks-agents.md new file mode 100644 index 000000000..24271c046 --- /dev/null +++ b/.changeset/786-copilot-hooks-agents.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 804 +--- +The GitHub Copilot installer now reaches lifecycle-hook and instruction parity with other first-class runtimes. It emits a self-contained `sessionStart` hook config (`.github/hooks/gsd-session.json` for local installs, `~/.copilot/hooks/gsd-session.json` for global) and writes `AGENTS.md` at the repository root (which Copilot CLI reads as primary instructions) alongside `copilot-instructions.md`. The hook is an inline `command` hook with no separate script file, so it cannot dangle. Both artifacts are removed — with user-authored content preserved — on `--uninstall`. (#786) diff --git a/bin/install.js b/bin/install.js index d3e660915..1439003aa 100755 --- a/bin/install.js +++ b/bin/install.js @@ -101,6 +101,32 @@ function isCodexHooksFeatureKey(key) { const GSD_COPILOT_INSTRUCTIONS_MARKER = ''; const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = ''; +// #786 \u2014 GitHub Copilot CLI lifecycle hook constants. +// Copilot reads hook configs from /hooks/*.json (repo scope: .github/hooks/, +// user scope: ~/.copilot/hooks/) with the shape { version, hooks: { : [...] } }. +// Events use camelCase (sessionStart, preToolUse, postToolUse, ...). A `command` +// hook runs an INLINE shell command (bash / powershell), so the GSD hook is fully +// self-contained \u2014 there is no separate hook script to install, and therefore +// nothing that can dangle if a script copy is skipped. See +// https://docs.github.com/en/copilot/reference/hooks-configuration +const GSD_COPILOT_HOOK_FILE = 'gsd-session.json'; +// Copilot parses a command hook's stdout as the hook-output JSON. For sessionStart +// the schema is `{ additionalContext?: string }` (the text is prepended to the +// session as context). So the hook must emit that JSON envelope — not bare text. +// The two messages contain no JSON-special characters, so they embed verbatim. +const GSD_COPILOT_SESSION_MSG_PRESENT = + 'GSD: .planning/STATE.md present - review the current phase and any blockers before acting.'; +const GSD_COPILOT_SESSION_MSG_ABSENT = + 'GSD: no .planning/ workflow found - run /gsd-new-project to start a tracked workflow.'; +const GSD_COPILOT_SESSION_HOOK_BASH = + 'if [ -f .planning/STATE.md ]; then ' + + `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}'; else ` + + `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}'; fi`; +const GSD_COPILOT_SESSION_HOOK_PWSH = + 'if (Test-Path .planning/STATE.md) ' + + `{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` + + `else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`; + // GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks). // git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does. const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh']; @@ -5015,6 +5041,57 @@ function stripGsdFromCopilotInstructions(content) { return content; } +/** + * #786 — Build the GSD-managed GitHub Copilot lifecycle hook config object. + * + * Returns the verbatim JSON shape Copilot CLI expects: + * { version: 1, hooks: { sessionStart: [ ] } } + * + * The sessionStart entry is a `command` hook whose `bash`/`powershell` bodies + * run inline (no external script file), so the config can never reference a + * hook script that the installer did not also install — it is self-contained + * by construction. The command is advisory-only (always exits 0) and orients + * the agent toward the project's GSD planning state at session start. + * + * @returns {object} Copilot hooks-configuration object + */ +function buildCopilotHookConfig() { + return { + version: 1, + hooks: { + sessionStart: [ + { + type: 'command', + bash: GSD_COPILOT_SESSION_HOOK_BASH, + powershell: GSD_COPILOT_SESSION_HOOK_PWSH, + timeoutSec: 10, + }, + ], + }, + }; +} + +/** + * #786 — Write the GSD-managed Copilot lifecycle hook config under the runtime + * config dir (`/hooks/gsd-session.json`). For local installs + * targetDir is `.github` (→ `.github/hooks/`); for global installs it is + * `~/.copilot` (→ `~/.copilot/hooks/`) — both are valid Copilot hook locations. + * + * The managed file is fully owned by GSD, so it is overwritten wholesale on + * every install (idempotent). User-authored sibling `*.json` hook files in the + * same directory are untouched. + * + * @param {string} targetDir - The Copilot config dir + * @returns {string} The path the hook config was written to + */ +function writeCopilotHookConfig(targetDir) { + const hooksDir = path.join(targetDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + const hookPath = path.join(hooksDir, GSD_COPILOT_HOOK_FILE); + fs.writeFileSync(hookPath, JSON.stringify(buildCopilotHookConfig(), null, 2) + '\n'); + return hookPath; +} + /** * Generate config.toml and per-agent .toml files for Codex. * Reads agent .md files from source, extracts metadata, writes .toml configs. @@ -6822,6 +6899,24 @@ function uninstall(isGlobal, runtime = 'claude') { console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`); + // #786: AGENTS.md lives at the repo root (outside targetDir) for local Copilot + // installs, so its cleanup must run even when .github (targetDir) was already + // removed — i.e. BEFORE the "target directory missing" early-return below. + if (isCopilot && !isGlobal) { + const agentsMdPath = path.join(process.cwd(), 'AGENTS.md'); + if (fs.existsSync(agentsMdPath)) { + const content = fs.readFileSync(agentsMdPath, 'utf8'); + const cleaned = stripGsdFromCopilotInstructions(content); + if (cleaned === null) { + fs.unlinkSync(agentsMdPath); + console.log(` ${green}✓${reset} Removed AGENTS.md (was GSD-only)`); + } else if (cleaned !== content) { + fs.writeFileSync(agentsMdPath, cleaned); + console.log(` ${green}✓${reset} Cleaned GSD section from AGENTS.md`); + } + } + } + // Check if target directory exists if (!fs.existsSync(targetDir)) { console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`); @@ -6899,6 +6994,23 @@ function uninstall(isGlobal, runtime = 'claude') { console.log(` ${green}✓${reset} Cleaned GSD section from copilot-instructions.md`); } } + + // #786: remove the GSD-managed Copilot lifecycle hook config and prune the + // hooks dir if we left it empty. + const hookPath = path.join(targetDir, 'hooks', GSD_COPILOT_HOOK_FILE); + if (fs.existsSync(hookPath)) { + fs.unlinkSync(hookPath); + removedCount++; + console.log(` ${green}✓${reset} Removed Copilot lifecycle hook (${GSD_COPILOT_HOOK_FILE})`); + try { + const hooksDir = path.join(targetDir, 'hooks'); + if (fs.existsSync(hooksDir) && fs.readdirSync(hooksDir).length === 0) { + fs.rmdirSync(hooksDir); + } + } catch { /* non-fatal: leave a non-empty/locked hooks dir in place */ } + } + // Note: AGENTS.md (repo root) is cleaned earlier, before the targetDir + // existence early-return, since it lives outside targetDir (#786). } // 1c. Claude local: remove commands/gsd/ (primary local install location). @@ -9349,8 +9461,24 @@ function install(isGlobal, runtime = 'claude', options = {}) { const template = fs.readFileSync(templatePath, 'utf8'); mergeCopilotInstructions(instructionsPath, template); console.log(` ${green}✓${reset} Generated copilot-instructions.md`); + // #786: also emit AGENTS.md, which Copilot CLI reads as primary + // instructions from the repository root. AGENTS.md is a repo-root concept + // (no documented user-scope home), so emit it only for local installs; + // global scope is already covered by ~/.copilot/copilot-instructions.md. + if (!isGlobal) { + const agentsMdPath = path.join(process.cwd(), 'AGENTS.md'); + mergeCopilotInstructions(agentsMdPath, template); + console.log(` ${green}✓${reset} Generated AGENTS.md`); + } } - // Copilot: no settings.json, no hooks, no statusline (like Codex) + // #786: emit a self-contained Copilot lifecycle hook (sessionStart). Copilot + // command hooks run inline bash/powershell, so this needs no separate hook + // script and cannot dangle. Repo scope → .github/hooks/, user → ~/.copilot/hooks/. + // The hook is a required install artifact, so a write failure is fatal (it + // propagates) rather than silently producing a "successful" install missing + // the feature. + writeCopilotHookConfig(targetDir); + console.log(` ${green}✓${reset} Configured Copilot lifecycle hook (sessionStart)`); persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } @@ -10814,6 +10942,9 @@ module.exports = { GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER, mergeCopilotInstructions, stripGsdFromCopilotInstructions, + GSD_COPILOT_HOOK_FILE, + buildCopilotHookConfig, + writeCopilotHookConfig, convertClaudeToAntigravityContent, convertClaudeCommandToAntigravitySkill, convertClaudeAgentToAntigravityAgent, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 48380d3b2..46400e0be 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -800,7 +800,7 @@ The migration-specific ownership and source snapshots live in | Kilo | `~/.config/kilo` | `./.kilo` | `command/gsd-*.md` | `agents/gsd-*.md` | `kilo.json` or `kilo.jsonc`; no GSD hooks | | Gemini CLI | `~/.gemini` | `./.gemini` | `commands/gsd/*.toml` | `agents/gsd-*.md` | `settings.json` feature flag, hooks, and statusline | | Codex | `~/.codex` | `./.codex` | `skills/gsd-*/SKILL.md` | `agents/` source markdown plus per-agent TOML | `config.toml` `[agents.gsd-*]`, `[features].hooks` (canonical; legacy alias `codex_hooks` is recognized and migrated forward on reinstall, #3566), and hook tables | -| GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md` and `copilot-instructions.md` | `.agent.md` files | No GSD hooks or statusline | +| GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md`, `copilot-instructions.md`, and `AGENTS.md` (repo root, local) | `.agent.md` files | Self-contained `sessionStart` hook (`hooks/gsd-session.json`, inline `command` type); no statusline | | Antigravity | auto-detected: `~/.gemini/antigravity`, `~/.gemini/antigravity-ide`, or `~/.gemini/antigravity-cli` | `./.agent` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Gemini-style `settings.json` hook entries when installed by GSD | | Cursor | `~/.cursor` | `./.cursor` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | | Windsurf | `~/.codeium/windsurf` | `./.windsurf` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index e7f3ccb1b..d5de73f47 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -156,6 +156,13 @@ npx @opengsd/gsd-core@latest --copilot --global Skills land in `~/.copilot/`. GSD installs as agent `.md` files and repository instruction files. +GSD also wires Copilot's lifecycle hooks and instruction files: + +- **`AGENTS.md`** (local installs) — written at the repository root, which GitHub Copilot CLI reads as primary instructions, alongside `copilot-instructions.md`. +- **Lifecycle hook** — a `sessionStart` hook config is written to `.github/hooks/gsd-session.json` (local) or `~/.copilot/hooks/gsd-session.json` (global). It is a self-contained inline `command` hook (no separate hook script to install), so it can never reference a missing script. The hook is advisory-only: at session start it surfaces whether the project has a `.planning/` workflow. + +Both are removed (and any user-authored content preserved) on `--uninstall`. + **Override the install directory:** ```bash diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index 404a6baee..a5f55499d 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -38,6 +38,9 @@ const { GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER, mergeCopilotInstructions, stripGsdFromCopilotInstructions, + GSD_COPILOT_HOOK_FILE, + buildCopilotHookConfig, + writeCopilotHookConfig, writeManifest, reportLocalPatches, installRuntimeArtifacts, @@ -1040,6 +1043,112 @@ describe('Copilot instructions merge/strip', () => { }); }); +// ─── Copilot lifecycle hooks (#786) ──────────────────────────────────────────── + +describe('Copilot lifecycle hook config (#786)', () => { + describe('buildCopilotHookConfig', () => { + test('emits the documented Copilot hooks-config shape', () => { + const cfg = buildCopilotHookConfig(); + assert.strictEqual(cfg.version, 1, 'version must be 1 per Copilot hooks schema'); + assert.ok(cfg.hooks && typeof cfg.hooks === 'object', 'has hooks object'); + assert.ok(Array.isArray(cfg.hooks.sessionStart), 'sessionStart is an array (camelCase event name)'); + assert.strictEqual(cfg.hooks.sessionStart.length, 1, 'one sessionStart entry'); + }); + + test('sessionStart entry is a self-contained inline command hook', () => { + const [entry] = buildCopilotHookConfig().hooks.sessionStart; + assert.strictEqual(entry.type, 'command', 'type is command'); + assert.ok(typeof entry.bash === 'string' && entry.bash.length > 0, 'has inline bash body'); + assert.ok(typeof entry.powershell === 'string' && entry.powershell.length > 0, 'has inline powershell body'); + assert.strictEqual(entry.timeoutSec, 10, 'uses timeoutSec (Copilot field), not timeout'); + }); + + test('command bodies emit the Copilot sessionStart JSON envelope (additionalContext)', () => { + // Copilot parses command-hook stdout as JSON; sessionStart schema is + // { additionalContext?: string }. Bare text would be invalid hook output. + const [entry] = buildCopilotHookConfig().hooks.sessionStart; + assert.ok(entry.bash.includes('"additionalContext"'), 'bash body emits additionalContext JSON'); + assert.ok(entry.powershell.includes('"additionalContext"'), 'powershell body emits additionalContext JSON'); + }); + + test('executing the bash hook body produces valid sessionStart JSON', { skip: process.platform === 'win32' }, () => { + const { execFileSync } = require('child_process'); + const [entry] = buildCopilotHookConfig().hooks.sessionStart; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-exec-')); + try { + // No .planning/STATE.md → absent branch + const outAbsent = execFileSync('bash', ['-c', entry.bash], { cwd: tmp, encoding: 'utf8' }); + const parsedAbsent = JSON.parse(outAbsent); + assert.ok(typeof parsedAbsent.additionalContext === 'string', 'absent branch yields additionalContext string'); + assert.ok(/gsd-new-project/.test(parsedAbsent.additionalContext), 'absent branch suggests gsd-new-project'); + + // With .planning/STATE.md → present branch + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(tmp, '.planning', 'STATE.md'), '# state\n'); + const outPresent = execFileSync('bash', ['-c', entry.bash], { cwd: tmp, encoding: 'utf8' }); + const parsedPresent = JSON.parse(outPresent); + assert.ok(/STATE\.md present/.test(parsedPresent.additionalContext), 'present branch references STATE.md'); + } finally { + cleanup(tmp); + } + }); + + test('hook command references no external script path (cannot dangle)', () => { + const [entry] = buildCopilotHookConfig().hooks.sessionStart; + // A dangling hook points at a hook SCRIPT file the installer never wrote. + // The GSD Copilot hook is inline, so it must not reference hooks/gsd-*.js|sh. + assert.ok(!/hooks\/gsd-[\w-]+\.(js|cjs|sh)/.test(entry.bash), 'bash body references no gsd hook script file'); + assert.ok(!/hooks\/gsd-[\w-]+\.(js|cjs|sh)/.test(entry.powershell), 'powershell body references no gsd hook script file'); + }); + + test('produces valid JSON', () => { + const json = JSON.stringify(buildCopilotHookConfig()); + assert.doesNotThrow(() => JSON.parse(json), 'config round-trips through JSON'); + }); + }); + + describe('writeCopilotHookConfig', () => { + let tmpHookDir; + + beforeEach(() => { + tmpHookDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-copilot-hook-')); + }); + + afterEach(() => { + cleanup(tmpHookDir); + }); + + test('writes hooks/gsd-session.json under the config dir', () => { + const written = writeCopilotHookConfig(tmpHookDir); + const expected = path.join(tmpHookDir, 'hooks', GSD_COPILOT_HOOK_FILE); + assert.strictEqual(written, expected, 'returns the written path'); + assert.ok(fs.existsSync(expected), 'hook config file exists'); + const parsed = JSON.parse(fs.readFileSync(expected, 'utf8')); + assert.strictEqual(parsed.version, 1, 'written file has version 1'); + assert.ok(Array.isArray(parsed.hooks.sessionStart), 'written file has sessionStart array'); + }); + + test('is idempotent and overwrites the managed file in place', () => { + writeCopilotHookConfig(tmpHookDir); + const hookPath = path.join(tmpHookDir, 'hooks', GSD_COPILOT_HOOK_FILE); + fs.writeFileSync(hookPath, '{"stale":true}\n'); + writeCopilotHookConfig(tmpHookDir); + const parsed = JSON.parse(fs.readFileSync(hookPath, 'utf8')); + assert.strictEqual(parsed.stale, undefined, 'stale content replaced'); + assert.strictEqual(parsed.version, 1, 'managed content restored'); + }); + + test('preserves sibling user-authored hook files', () => { + const hooksDir = path.join(tmpHookDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + const userHook = path.join(hooksDir, 'my-hook.json'); + fs.writeFileSync(userHook, '{"version":1,"hooks":{}}\n'); + writeCopilotHookConfig(tmpHookDir); + assert.ok(fs.existsSync(userHook), 'user hook file untouched'); + }); + }); +}); + // ─── Copilot uninstall skill removal ─────────────────────────────────────────── describe('Copilot uninstall skill removal', () => { @@ -1321,6 +1430,23 @@ describe('E2E: Copilot full install verification', () => { 'Should contain GSD Configuration close marker'); }); + test('emits AGENTS.md at the repo root with GSD markers (#786)', () => { + const agentsMdPath = path.join(tmpDir, 'AGENTS.md'); + assert.ok(fs.existsSync(agentsMdPath), 'AGENTS.md should exist at repo root for local install'); + const content = fs.readFileSync(agentsMdPath, 'utf-8'); + assert.ok(content.includes(''), 'AGENTS.md has GSD close marker'); + }); + + test('emits a Copilot lifecycle hook config (#786)', () => { + const hookPath = path.join(tmpDir, '.github', 'hooks', 'gsd-session.json'); + assert.ok(fs.existsSync(hookPath), '.github/hooks/gsd-session.json should exist'); + const cfg = JSON.parse(fs.readFileSync(hookPath, 'utf-8')); + assert.strictEqual(cfg.version, 1, 'hook config has version 1'); + assert.ok(Array.isArray(cfg.hooks.sessionStart), 'hook config has sessionStart array'); + assert.strictEqual(cfg.hooks.sessionStart[0].type, 'command', 'sessionStart is a command hook'); + }); + test('creates manifest with correct structure', () => { const manifestPath = path.join(tmpDir, '.github', 'gsd-file-manifest.json'); assert.ok(fs.existsSync(manifestPath), 'gsd-file-manifest.json should exist'); @@ -1429,6 +1555,16 @@ describe('E2E: Copilot uninstall verification', () => { } }); + test('removes the Copilot lifecycle hook config (#786)', () => { + const hookPath = path.join(tmpDir, '.github', 'hooks', 'gsd-session.json'); + assert.ok(!fs.existsSync(hookPath), 'gsd-session.json should not exist after uninstall'); + }); + + test('removes GSD-only AGENTS.md (#786)', () => { + const agentsMdPath = path.join(tmpDir, 'AGENTS.md'); + assert.ok(!fs.existsSync(agentsMdPath), 'GSD-only AGENTS.md should be removed after uninstall'); + }); + describe('preserves non-GSD content', () => { let td; @@ -1463,6 +1599,90 @@ describe('E2E: Copilot uninstall verification', () => { assert.ok(fs.existsSync(customAgentPath), 'Non-GSD agent file should be preserved after uninstall'); }); + + test('preserves user-authored content in AGENTS.md on uninstall (#786)', () => { + // After install, AGENTS.md exists with the GSD block. Prepend user content. + const agentsMdPath = path.join(td, 'AGENTS.md'); + assert.ok(fs.existsSync(agentsMdPath), 'AGENTS.md created by install'); + const gsdBlock = fs.readFileSync(agentsMdPath, 'utf-8'); + fs.writeFileSync(agentsMdPath, '# My Project Notes\n\nKeep these.\n\n' + gsdBlock); + // Uninstall strips only the GSD section + runCopilotUninstall(td); + assert.ok(fs.existsSync(agentsMdPath), 'AGENTS.md preserved (had user content)'); + const after = fs.readFileSync(agentsMdPath, 'utf-8'); + assert.ok(after.includes('# My Project Notes'), 'user content preserved'); + assert.ok(!after.includes('