From d6fc0d32362f757e05297996868215777ab26917 Mon Sep 17 00:00:00 2001 From: Dominic Nguyen Date: Mon, 28 Sep 2026 22:14:20 -0700 Subject: [PATCH 1/6] Add git-commit-story init and Copilot prompt hook --- ts/packages/git-commit-story/README.md | 6 +++ ts/packages/git-commit-story/src/cli.ts | 57 +++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/ts/packages/git-commit-story/README.md b/ts/packages/git-commit-story/README.md index 550b2f7805..e3b162c263 100644 --- a/ts/packages/git-commit-story/README.md +++ b/ts/packages/git-commit-story/README.md @@ -12,8 +12,14 @@ $ pnpm build $ npm link # puts git-commit-story on PATH $ git commit-story hello Hello from git-commit-story +$ git commit-story init # registers Copilot CLI hooks for this repo +$ git commit-story hooks copilot user-prompt-submitted +Hello World ``` +`init` writes the hooks to `.github/copilot/settings.local.json` and adds that +file to `.git/info/exclude`, so it stays local to the clone. + ## Trademarks This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft diff --git a/ts/packages/git-commit-story/src/cli.ts b/ts/packages/git-commit-story/src/cli.ts index 611bd41f15..41208ce37d 100644 --- a/ts/packages/git-commit-story/src/cli.ts +++ b/ts/packages/git-commit-story/src/cli.ts @@ -5,16 +5,67 @@ // CLI entry point. On PATH as `git-commit-story`, so git also runs it as // `git commit-story `. import { Command } from "commander"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +// Copilot CLI reads repo-level hooks from this file. The `.local` variant is +// per-clone, so init also adds it to `.git/info/exclude`. +const COPILOT_SETTINGS = ".github/copilot/settings.local.json"; +const PROMPT_HOOK = "git commit-story hooks copilot user-prompt-submitted"; const program = new Command("git-commit-story") .description("Attach agent session stories to git commits") .version("0.0.1"); +// Registers agent hooks for the current repository. +program + .command("init") + .description("Register agent hooks for the current repository") + .action(() => { + const git = (...args: string[]) => + execFileSync("git", args, { encoding: "utf8" }).trim(); + const root = git("rev-parse", "--show-toplevel"); + const settingsPath = path.join(root, COPILOT_SETTINGS); + const settings = fs.existsSync(settingsPath) + ? JSON.parse(fs.readFileSync(settingsPath, "utf8")) + : {}; + settings.hooks = { + ...settings.hooks, + userPromptSubmitted: [ + { type: "command", bash: PROMPT_HOOK, powershell: PROMPT_HOOK }, + ], + }; + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify(settings, null, 4) + "\n", + ); + + const exclude = path.resolve( + root, + git("rev-parse", "--git-path", "info/exclude"), + ); + const lines = fs.existsSync(exclude) + ? fs.readFileSync(exclude, "utf8").split("\n") + : []; + if (!lines.includes(COPILOT_SETTINGS)) { + fs.mkdirSync(path.dirname(exclude), { recursive: true }); + fs.appendFileSync(exclude, `${COPILOT_SETTINGS}\n`); + } + process.stdout.write(`Registered Copilot hooks in ${settingsPath}\n`); + }); + +// Agent hook handlers. Placeholder output until story capture exists. program - .command("hello") - .description("Print a greeting") + .command("hooks") + .description("Agent hook handlers") + .command("copilot") + .description("Copilot CLI hook handlers") + .command("user-prompt-submitted") + .description("Handle the Copilot userPromptSubmitted hook") .action(() => { - process.stdout.write("Hello from git-commit-story\n"); + process.stdout.write("Hello World\n"); }); program.parse(); From ed30258fea064f7d8c6c9b1ec74ecb355c397e6b Mon Sep 17 00:00:00 2001 From: Dominic Nguyen Date: Mon, 28 Sep 2026 22:58:40 -0700 Subject: [PATCH 2/6] Keep existing settings keys in init and sort output --- ts/packages/git-commit-story/src/cli.ts | 31 ++++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/ts/packages/git-commit-story/src/cli.ts b/ts/packages/git-commit-story/src/cli.ts index 41208ce37d..c9e89ab90e 100644 --- a/ts/packages/git-commit-story/src/cli.ts +++ b/ts/packages/git-commit-story/src/cli.ts @@ -14,6 +14,18 @@ import path from "node:path"; const COPILOT_SETTINGS = ".github/copilot/settings.local.json"; const PROMPT_HOOK = "git commit-story hooks copilot user-prompt-submitted"; +// Recursively sorts object keys so the settings file has a stable order. +// Array order is kept. Example: {b:1,a:{d:2,c:3}} -> {a:{c:3,d:2},b:1}. +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeys); + if (value === null || typeof value !== "object") return value; + return Object.fromEntries( + Object.keys(value) + .sort() + .map((k) => [k, sortKeys((value as Record)[k])]), + ); +} + const program = new Command("git-commit-story") .description("Attach agent session stories to git commits") .version("0.0.1"); @@ -30,16 +42,23 @@ program const settings = fs.existsSync(settingsPath) ? JSON.parse(fs.readFileSync(settingsPath, "utf8")) : {}; - settings.hooks = { - ...settings.hooks, - userPromptSubmitted: [ - { type: "command", bash: PROMPT_HOOK, powershell: PROMPT_HOOK }, - ], + // Update only our hook entry; keep other keys and hooks as they are. + const hook = { + type: "command", + bash: PROMPT_HOOK, + powershell: PROMPT_HOOK, }; + settings.hooks ??= {}; + settings.hooks.userPromptSubmitted = [ + ...(settings.hooks.userPromptSubmitted ?? []).filter( + (h: { bash?: string }) => h.bash !== PROMPT_HOOK, + ), + hook, + ]; fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); fs.writeFileSync( settingsPath, - JSON.stringify(settings, null, 4) + "\n", + JSON.stringify(sortKeys(settings), null, 4) + "\n", ); const exclude = path.resolve( From 031e5113b09df01a0f6392d858d93badc14699c7 Mon Sep 17 00:00:00 2001 From: Dominic Nguyen Date: Mon, 28 Sep 2026 22:59:13 -0700 Subject: [PATCH 3/6] Move git-commit-story commands into commands/ --- ts/packages/git-commit-story/src/cli.ts | 83 ++----------------- .../git-commit-story/src/commands/hooks.ts | 18 ++++ .../git-commit-story/src/commands/init.ts | 68 +++++++++++++++ 3 files changed, 91 insertions(+), 78 deletions(-) create mode 100644 ts/packages/git-commit-story/src/commands/hooks.ts create mode 100644 ts/packages/git-commit-story/src/commands/init.ts diff --git a/ts/packages/git-commit-story/src/cli.ts b/ts/packages/git-commit-story/src/cli.ts index c9e89ab90e..b925035c73 100644 --- a/ts/packages/git-commit-story/src/cli.ts +++ b/ts/packages/git-commit-story/src/cli.ts @@ -5,86 +5,13 @@ // CLI entry point. On PATH as `git-commit-story`, so git also runs it as // `git commit-story `. import { Command } from "commander"; -import { execFileSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; - -// Copilot CLI reads repo-level hooks from this file. The `.local` variant is -// per-clone, so init also adds it to `.git/info/exclude`. -const COPILOT_SETTINGS = ".github/copilot/settings.local.json"; -const PROMPT_HOOK = "git commit-story hooks copilot user-prompt-submitted"; - -// Recursively sorts object keys so the settings file has a stable order. -// Array order is kept. Example: {b:1,a:{d:2,c:3}} -> {a:{c:3,d:2},b:1}. -function sortKeys(value: unknown): unknown { - if (Array.isArray(value)) return value.map(sortKeys); - if (value === null || typeof value !== "object") return value; - return Object.fromEntries( - Object.keys(value) - .sort() - .map((k) => [k, sortKeys((value as Record)[k])]), - ); -} +import { hooksCommand } from "./commands/hooks.js"; +import { initCommand } from "./commands/init.js"; const program = new Command("git-commit-story") .description("Attach agent session stories to git commits") - .version("0.0.1"); - -// Registers agent hooks for the current repository. -program - .command("init") - .description("Register agent hooks for the current repository") - .action(() => { - const git = (...args: string[]) => - execFileSync("git", args, { encoding: "utf8" }).trim(); - const root = git("rev-parse", "--show-toplevel"); - const settingsPath = path.join(root, COPILOT_SETTINGS); - const settings = fs.existsSync(settingsPath) - ? JSON.parse(fs.readFileSync(settingsPath, "utf8")) - : {}; - // Update only our hook entry; keep other keys and hooks as they are. - const hook = { - type: "command", - bash: PROMPT_HOOK, - powershell: PROMPT_HOOK, - }; - settings.hooks ??= {}; - settings.hooks.userPromptSubmitted = [ - ...(settings.hooks.userPromptSubmitted ?? []).filter( - (h: { bash?: string }) => h.bash !== PROMPT_HOOK, - ), - hook, - ]; - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - fs.writeFileSync( - settingsPath, - JSON.stringify(sortKeys(settings), null, 4) + "\n", - ); - - const exclude = path.resolve( - root, - git("rev-parse", "--git-path", "info/exclude"), - ); - const lines = fs.existsSync(exclude) - ? fs.readFileSync(exclude, "utf8").split("\n") - : []; - if (!lines.includes(COPILOT_SETTINGS)) { - fs.mkdirSync(path.dirname(exclude), { recursive: true }); - fs.appendFileSync(exclude, `${COPILOT_SETTINGS}\n`); - } - process.stdout.write(`Registered Copilot hooks in ${settingsPath}\n`); - }); - -// Agent hook handlers. Placeholder output until story capture exists. -program - .command("hooks") - .description("Agent hook handlers") - .command("copilot") - .description("Copilot CLI hook handlers") - .command("user-prompt-submitted") - .description("Handle the Copilot userPromptSubmitted hook") - .action(() => { - process.stdout.write("Hello World\n"); - }); + .version("0.0.1") + .addCommand(initCommand) + .addCommand(hooksCommand); program.parse(); diff --git a/ts/packages/git-commit-story/src/commands/hooks.ts b/ts/packages/git-commit-story/src/commands/hooks.ts new file mode 100644 index 0000000000..29e769b7e6 --- /dev/null +++ b/ts/packages/git-commit-story/src/commands/hooks.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Command } from "commander"; + +// `hooks`: agent hook handlers. Placeholder output until story capture exists. +export const hooksCommand = new Command("hooks").description( + "Agent hook handlers", +); + +hooksCommand + .command("copilot") + .description("Copilot CLI hook handlers") + .command("user-prompt-submitted") + .description("Handle the Copilot userPromptSubmitted hook") + .action(() => { + process.stdout.write("Hello World\n"); + }); diff --git a/ts/packages/git-commit-story/src/commands/init.ts b/ts/packages/git-commit-story/src/commands/init.ts new file mode 100644 index 0000000000..c3047f1b11 --- /dev/null +++ b/ts/packages/git-commit-story/src/commands/init.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Command } from "commander"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +// Copilot CLI reads repo-level hooks from this file. The `.local` variant is +// per-clone, so init also adds it to `.git/info/exclude`. +const COPILOT_SETTINGS = ".github/copilot/settings.local.json"; +const PROMPT_HOOK = "git commit-story hooks copilot user-prompt-submitted"; + +// Recursively sorts object keys so the settings file has a stable order. +// Array order is kept. Example: {b:1,a:{d:2,c:3}} -> {a:{c:3,d:2},b:1}. +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeys); + if (value === null || typeof value !== "object") return value; + return Object.fromEntries( + Object.keys(value) + .sort() + .map((k) => [k, sortKeys((value as Record)[k])]), + ); +} + +// `init`: registers agent hooks for the current repository. +export const initCommand = new Command("init") + .description("Register agent hooks for the current repository") + .action(() => { + const git = (...args: string[]) => + execFileSync("git", args, { encoding: "utf8" }).trim(); + const root = git("rev-parse", "--show-toplevel"); + const settingsPath = path.join(root, COPILOT_SETTINGS); + const settings = fs.existsSync(settingsPath) + ? JSON.parse(fs.readFileSync(settingsPath, "utf8")) + : {}; + // Update only our hook entry; keep other keys and hooks as they are. + const hook = { + type: "command", + bash: PROMPT_HOOK, + powershell: PROMPT_HOOK, + }; + settings.hooks ??= {}; + settings.hooks.userPromptSubmitted = [ + ...(settings.hooks.userPromptSubmitted ?? []).filter( + (h: { bash?: string }) => h.bash !== PROMPT_HOOK, + ), + hook, + ]; + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify(sortKeys(settings), null, 4) + "\n", + ); + + const exclude = path.resolve( + root, + git("rev-parse", "--git-path", "info/exclude"), + ); + const lines = fs.existsSync(exclude) + ? fs.readFileSync(exclude, "utf8").split("\n") + : []; + if (!lines.includes(COPILOT_SETTINGS)) { + fs.mkdirSync(path.dirname(exclude), { recursive: true }); + fs.appendFileSync(exclude, `${COPILOT_SETTINGS}\n`); + } + process.stdout.write(`Registered Copilot hooks in ${settingsPath}\n`); + }); From 127b3a3e8d34f80c1a490649f5eb501601053d9e Mon Sep 17 00:00:00 2001 From: typeagent-bot Date: Tue, 29 Sep 2026 06:08:20 +0000 Subject: [PATCH 4/6] docs: regenerate README.AUTOGEN.md, command reference, and action browser --- ts/packages/git-commit-story/README.AUTOGEN.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ts/packages/git-commit-story/README.AUTOGEN.md b/ts/packages/git-commit-story/README.AUTOGEN.md index 7cdd26900e..1a21d48c08 100644 --- a/ts/packages/git-commit-story/README.AUTOGEN.md +++ b/ts/packages/git-commit-story/README.AUTOGEN.md @@ -3,7 +3,7 @@ - + # git-commit-story — AI-generated documentation @@ -30,10 +30,10 @@ External: `commander` ### Files of interest -`./src/cli.ts`, `./src/tsconfig.json`. +`./src/cli.ts`, `./src/commands/hooks.ts`, `./src/commands/init.ts`, …and 1 more under `./src/`. --- -_Auto-generated against commit `464df9d8299adf53a6c3e3afd53688fbcde439f2` on `2026-09-28T20:24:26.282Z` by `docs-generate.yml`. Links validated at that commit; the working tree may have drifted by up to 24h. Re-run `pnpm --filter git-commit-story docs:verify-links` to spot-check._ +_Auto-generated against commit `031e5113b09df01a0f6392d858d93badc14699c7` on `2026-09-29T06:06:07.744Z` by `docs-generate.yml`. Links validated at that commit; the working tree may have drifted by up to 24h. Re-run `pnpm --filter git-commit-story docs:verify-links` to spot-check._ From f85ded571a1ba17aea4923a199bd14c6f78cae52 Mon Sep 17 00:00:00 2001 From: Dominic Nguyen Date: Tue, 29 Sep 2026 10:21:21 -0700 Subject: [PATCH 5/6] git-commit-story: handle invalid settings JSON, fix exclude path from subdirs, trim README --- ts/packages/git-commit-story/README.md | 2 -- .../git-commit-story/src/commands/init.ts | 18 ++++++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/ts/packages/git-commit-story/README.md b/ts/packages/git-commit-story/README.md index e3b162c263..1902c012e4 100644 --- a/ts/packages/git-commit-story/README.md +++ b/ts/packages/git-commit-story/README.md @@ -10,8 +10,6 @@ Git runs any `git-` binary on `PATH` as `git `, so the $ cd ts/packages/git-commit-story $ pnpm build $ npm link # puts git-commit-story on PATH -$ git commit-story hello -Hello from git-commit-story $ git commit-story init # registers Copilot CLI hooks for this repo $ git commit-story hooks copilot user-prompt-submitted Hello World diff --git a/ts/packages/git-commit-story/src/commands/init.ts b/ts/packages/git-commit-story/src/commands/init.ts index c3047f1b11..06ae2da6d7 100644 --- a/ts/packages/git-commit-story/src/commands/init.ts +++ b/ts/packages/git-commit-story/src/commands/init.ts @@ -31,9 +31,18 @@ export const initCommand = new Command("init") execFileSync("git", args, { encoding: "utf8" }).trim(); const root = git("rev-parse", "--show-toplevel"); const settingsPath = path.join(root, COPILOT_SETTINGS); - const settings = fs.existsSync(settingsPath) - ? JSON.parse(fs.readFileSync(settingsPath, "utf8")) - : {}; + let settings: any = {}; + if (fs.existsSync(settingsPath)) { + try { + settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + } catch (e) { + process.stderr.write( + `Failed to parse ${settingsPath}: ${(e as Error).message}\n`, + ); + process.exitCode = 1; + return; + } + } // Update only our hook entry; keep other keys and hooks as they are. const hook = { type: "command", @@ -53,8 +62,9 @@ export const initCommand = new Command("init") JSON.stringify(sortKeys(settings), null, 4) + "\n", ); + // `--git-path` is relative to the cwd (not the repo root), so resolve + // it against the cwd to work from subdirectories. const exclude = path.resolve( - root, git("rev-parse", "--git-path", "info/exclude"), ); const lines = fs.existsSync(exclude) From 8962cd4f6c77eb476a42ce884a1f74dba4afa98c Mon Sep 17 00:00:00 2001 From: Dominic Nguyen Date: Tue, 29 Sep 2026 10:33:38 -0700 Subject: [PATCH 6/6] git-commit-story: replace any with typed settings shape (lint ratchet) --- ts/packages/git-commit-story/src/commands/init.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ts/packages/git-commit-story/src/commands/init.ts b/ts/packages/git-commit-story/src/commands/init.ts index 06ae2da6d7..789cf1e21e 100644 --- a/ts/packages/git-commit-story/src/commands/init.ts +++ b/ts/packages/git-commit-story/src/commands/init.ts @@ -31,7 +31,10 @@ export const initCommand = new Command("init") execFileSync("git", args, { encoding: "utf8" }).trim(); const root = git("rev-parse", "--show-toplevel"); const settingsPath = path.join(root, COPILOT_SETTINGS); - let settings: any = {}; + let settings: { + hooks?: { userPromptSubmitted?: { bash?: string }[] }; + [key: string]: unknown; + } = {}; if (fs.existsSync(settingsPath)) { try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); @@ -52,7 +55,7 @@ export const initCommand = new Command("init") settings.hooks ??= {}; settings.hooks.userPromptSubmitted = [ ...(settings.hooks.userPromptSubmitted ?? []).filter( - (h: { bash?: string }) => h.bash !== PROMPT_HOOK, + (h) => h.bash !== PROMPT_HOOK, ), hook, ];