From 31975e308858236fbbe72ef988a7086b77c37f74 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Mon, 4 May 2026 10:56:41 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20Update=20various=20docu?= =?UTF-8?q?mentation,=20scripts,=20and=20configuration=20templates=20to=20?= =?UTF-8?q?enhance=20clarity,=20functionality,=20and=20maintainability=20a?= =?UTF-8?q?cross=20the=20project?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 15 + config/mcp/copilot-cli.mcp.template.jsonc | 66 + config/mcp/local-overrides.example.jsonc | 17 + config/mcp/vscode.mcp.template.jsonc | 42 + config/vscode/settings.template.jsonc | 3 +- docs/architecture.md | 19 + docs/audit-workflow.md | 127 ++ docs/authoring.md | 12 + docs/git-bootstrap-hygiene.md | 59 + docs/operations.md | 22 + docs/review-standards.md | 12 + docs/setup.md | 31 + docs/troubleshooting.md | 16 + install/bootstrap.ps1 | 33 + install/bootstrap.sh | 56 + .../mcp/copilot-cli-filesystem-wrapper.mjs | 63 + install/merge-managed-mcp-config.mjs | 823 +++++++++ install/merge-managed-mcp-config.test.mjs | 282 ++++ install/merge-vscode-settings.mjs | 192 ++- install/merge-vscode-settings.test.mjs | 133 +- install/publish.sh | 0 install/update.ps1 | 1 + install/update.sh | 1 + install/verify.ps1 | 37 + install/verify.sh | 40 + resources/hooks/session-audit.json | 4 +- .../token-efficient-authoring.instructions.md | 15 + resources/mcp/README.md | 39 +- .../prompts/audit-copilot-usage.prompt.md | 17 + .../prepare-audit-promotions.prompt.md | 16 + .../prompts/review-audit-candidates.prompt.md | 20 + resources/scripts/audit-copilot-usage.mjs | 1486 +++++++++++++++++ resources/scripts/audit-copilot-usage.sh | 17 + .../scripts/prepare-audit-promotions.mjs | 481 ++++++ resources/scripts/prepare-audit-promotions.sh | 17 + resources/scripts/report-hook-event.sh | 0 resources/skills/copilot-cost-review/SKILL.md | 40 + resources/skills/copilot-reuse-audit/SKILL.md | 52 + .../references/publishing-checklist.md | 6 + resources/templates/new-resource-checklist.md | 4 + 41 files changed, 4184 insertions(+), 133 deletions(-) create mode 100644 config/mcp/copilot-cli.mcp.template.jsonc create mode 100644 config/mcp/local-overrides.example.jsonc create mode 100644 config/mcp/vscode.mcp.template.jsonc create mode 100644 docs/audit-workflow.md create mode 100644 docs/git-bootstrap-hygiene.md mode change 100644 => 100755 install/bootstrap.sh create mode 100644 install/mcp/copilot-cli-filesystem-wrapper.mjs create mode 100644 install/merge-managed-mcp-config.mjs create mode 100644 install/merge-managed-mcp-config.test.mjs mode change 100644 => 100755 install/publish.sh mode change 100644 => 100755 install/update.sh mode change 100644 => 100755 install/verify.sh create mode 100644 resources/instructions/token-efficient-authoring.instructions.md create mode 100644 resources/prompts/audit-copilot-usage.prompt.md create mode 100644 resources/prompts/prepare-audit-promotions.prompt.md create mode 100644 resources/prompts/review-audit-candidates.prompt.md create mode 100644 resources/scripts/audit-copilot-usage.mjs create mode 100755 resources/scripts/audit-copilot-usage.sh create mode 100644 resources/scripts/prepare-audit-promotions.mjs create mode 100755 resources/scripts/prepare-audit-promotions.sh mode change 100644 => 100755 resources/scripts/report-hook-event.sh create mode 100644 resources/skills/copilot-cost-review/SKILL.md create mode 100644 resources/skills/copilot-reuse-audit/SKILL.md diff --git a/.gitignore b/.gitignore index fafff2e..e94a35c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store Thumbs.db +.local/ diff --git a/README.md b/README.md index 7bd2538..7fda451 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ This initial implementation provides: - Publish, update, and verify scripts - Scripted VS Code settings merge for managed Copilot-related keys - Scripted Copilot CLI environment wiring through a managed sourced fragment +- Scripted managed MCP config generation for VS Code and Copilot CLI ## Operating Model @@ -51,6 +52,20 @@ per-machine copies. They also merge the managed VS Code settings keys and add a small managed shell or PowerShell profile block for Copilot CLI environment variables without replacing the rest of the user config. +Bootstrap also generates managed user-level MCP configuration for VS Code and +Copilot CLI from the tracked templates in `config/mcp/`. Machine-local MCP +values live in `.local/mcp.local.jsonc`, which bootstrap creates from the +tracked example file on first run. + +Today the managed MCP set is: + +- Playwright for VS Code +- Filesystem for VS Code and Copilot CLI +- Gitea/Forgejo for VS Code and Copilot CLI when enabled locally + +`install/update.*` now reruns bootstrap after pulling so those managed config +files propagate when this repository changes. + ## Next Docs The rest of the handbook will live in `docs/` and will cover architecture, diff --git a/config/mcp/copilot-cli.mcp.template.jsonc b/config/mcp/copilot-cli.mcp.template.jsonc new file mode 100644 index 0000000..98efb42 --- /dev/null +++ b/config/mcp/copilot-cli.mcp.template.jsonc @@ -0,0 +1,66 @@ +{ + // Managed MCP servers for the GitHub Copilot CLI user profile. + "mcpServers": { + "filesystem": { + "type": "stdio", + "command": "node", + "args": [ + "{{COPILOT_RESOURCES_HOME}}/install/mcp/copilot-cli-filesystem-wrapper.mjs" + ], + "timeout": 30000 + }, + "gitea": { + "type": "stdio", + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "FORGEJOMCP_SERVER", + "-e", + "FORGEJOMCP_TOKEN", + "ronmi/forgejo-mcp", + "stdio" + ], + "env": { + "FORGEJOMCP_SERVER": "{{GITEA_SERVER_URL}}", + "FORGEJOMCP_TOKEN": "{{GITEA_TOKEN}}" + }, + "tools": [ + "search_repositories", + "list_my_repositories", + "list_org_repositories", + "get_repository", + "list_repo_issues", + "get_issue", + "create_issue", + "edit_issue", + "list_issue_comments", + "add_issue_labels", + "remove_issue_label", + "replace_issue_labels", + "list_repo_labels", + "create_label", + "edit_label", + "delete_label", + "list_repo_milestones", + "create_milestone", + "edit_milestone", + "delete_milestone", + "list_releases", + "create_release", + "edit_release", + "delete_release", + "list_release_attachments", + "list_pull_requests", + "get_pull_request", + "create_pull_request", + "list_wiki_pages", + "get_wiki_page", + "list_action_tasks" + ], + "timeout": 30000 + } + } +} \ No newline at end of file diff --git a/config/mcp/local-overrides.example.jsonc b/config/mcp/local-overrides.example.jsonc new file mode 100644 index 0000000..457341b --- /dev/null +++ b/config/mcp/local-overrides.example.jsonc @@ -0,0 +1,17 @@ +{ + // Copy this file to .local/mcp.local.jsonc and replace the placeholder values. + // The generated Gitea server is omitted unless enabled is true and both values are set. + "servers": { + "playwright": { + "enabled": true + }, + "filesystem": { + "enabled": true + }, + "gitea": { + "enabled": false, + "serverUrl": "https://git.example.com", + "token": "replace-with-a-local-access-token" + } + } +} \ No newline at end of file diff --git a/config/mcp/vscode.mcp.template.jsonc b/config/mcp/vscode.mcp.template.jsonc new file mode 100644 index 0000000..f5f8183 --- /dev/null +++ b/config/mcp/vscode.mcp.template.jsonc @@ -0,0 +1,42 @@ +{ + // Managed MCP servers for the VS Code user profile. + "servers": { + "playwright": { + "type": "stdio", + "command": "npx", + "args": ["@playwright/mcp@latest"] + }, + "filesystem": { + "type": "stdio", + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "--mount", + "type=bind,src=${workspaceFolder},dst=/projects/workspace", + "mcp/filesystem", + "/projects/workspace" + ] + }, + "gitea": { + "type": "stdio", + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "-e", + "FORGEJOMCP_SERVER", + "-e", + "FORGEJOMCP_TOKEN", + "ronmi/forgejo-mcp", + "stdio" + ], + "env": { + "FORGEJOMCP_SERVER": "{{GITEA_SERVER_URL}}", + "FORGEJOMCP_TOKEN": "{{GITEA_TOKEN}}" + } + } + } +} \ No newline at end of file diff --git a/config/vscode/settings.template.jsonc b/config/vscode/settings.template.jsonc index cb0abc6..6ef5c23 100644 --- a/config/vscode/settings.template.jsonc +++ b/config/vscode/settings.template.jsonc @@ -6,7 +6,6 @@ "chat.useAgentsMdFile": true, "chat.useClaudeMdFile": true, "chat.useCustomizationsInParentRepositories": true, - // Direct-path fallback configuration in case symlinked defaults are not preferred. "chat.instructionsFilesLocations": { "{{COPILOT_RESOURCES_HOME}}/resources/instructions": true, @@ -26,4 +25,4 @@ "{{COPILOT_RESOURCES_HOME}}/resources/hooks": true, "~/.claude/settings.json": true } -} +} \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index b977fee..f4ce1e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,6 +13,9 @@ Git-based after a resource is published. - `resources/instructions/`: shared instruction packs - `resources/agents/`: shared custom agents for local chat and overlays - `resources/hooks/`: shared hook definitions +- `config/mcp/`: tracked MCP templates and example machine-local overrides +- `install/merge-managed-mcp-config.mjs`: managed MCP merge and prune logic for user config files +- `install/mcp/`: wrapper scripts for MCP servers that need runtime adaptation - `templates/repo-overlay/`: files that can be copied into another repository for repository-scoped behavior @@ -29,6 +32,14 @@ Bootstrap prefers linking default discovery paths back to this repository: This keeps the repository authoritative while still using default discovery locations whenever possible. +For MCP, bootstrap uses generated user-level config instead of links: + +- VS Code user `mcp.json` +- Copilot CLI user `~/.copilot/mcp-config.json` + +Those generated files come from tracked templates plus machine-local data in +`.local/mcp.local.jsonc`. + ## Propagation Model There are only two supported creation paths: @@ -38,3 +49,11 @@ There are only two supported creation paths: Once a resource lands in the repository, commit and push it. Other systems pick it up through `install/update.*` or future scheduled sync. + +Managed MCP propagation follows the same rule, but with a split between shared +and local inputs: + +- Shared defaults and server definitions are tracked in the repo +- Machine-local secrets and enablement stay in `.local/` +- `install/update.*` pulls the repo and reruns bootstrap so the generated MCP + files refresh from the latest templates on every machine diff --git a/docs/audit-workflow.md b/docs/audit-workflow.md new file mode 100644 index 0000000..3fcf765 --- /dev/null +++ b/docs/audit-workflow.md @@ -0,0 +1,127 @@ +# Audit Workflow + +## Purpose + +The audit workflow reviews the last 30 days of persisted local Copilot +artifacts and produces a shortlist of reusable patterns worth promoting into the +shared Copilot resource repository. + +## Current Scope + +- macOS first +- read-only audit execution +- file-based review interface +- local per-machine audit history stored inside the repo checkout but excluded + from git + +## Command + +```bash +resources/scripts/audit-copilot-usage.sh --days 30 +``` + +The runner excludes the current `copilot-resources` repo root from workspace +indexing by default so you do not review patterns that already live in the +shared repository. + +Optional filters: + +```bash +resources/scripts/audit-copilot-usage.sh --days 14 --workspace copilot-resources +``` + +```bash +resources/scripts/audit-copilot-usage.sh --days 30 --include-sources publish-log,repo-memories +``` + +```bash +resources/scripts/audit-copilot-usage.sh --days 30 --exclude-workspace /path/to/another/repo +``` + +## Storage Model + +Audit runs are written under: + +```text +.local/audits/// +``` + +The runner creates the machine directory automatically on first use. The entire +`.local/` tree is git-ignored so each machine can keep its own audit history +without creating commit noise. + +## Inputs + +The first implementation reads from these local sources when they exist: + +- VS Code workspace transcripts under `~/Library/Application Support/Code/User/workspaceStorage/*/GitHub.copilot-chat/transcripts/` +- Repo memories under `.../GitHub.copilot-chat/memory-tool/memories/repo/` +- Shared resource publish history under `~/.copilot-resources-state/publish-log.tsv` + +## Outputs + +- `audit-summary.md` — high-level inventory and top candidates +- `workspace-index.tsv` — workspaceStorage hash to workspace path mapping for the run +- `candidates-report.tsv` — scored candidate list with source references plus transcript prompt-cost proxy columns such as `token_cost_signal`, `avg_prompt_chars`, and `repeated_prompt_chars` +- `selection-manifest.tsv` — editable approval surface with `decision`, `review_note`, `detail_file`, and the same transcript prompt-cost proxy columns used during triage +- `pattern-details/*.md` — per-candidate evidence bundles used during review, including purpose, expected benefit, audit context, score rationale, and caveats + +Transcript prompt-cost fields are character-count proxies derived from persisted +user message text. They help surface repeated long prompts that are likely to be +more expensive, but they are not exact billing-token measurements. + +## Selection Interface + +Use `selection-manifest.tsv` as the review surface and `pattern-details/*.md` as +the one-by-one evidence bundle for each candidate. + +Inside Copilot, use the shared `review-audit-candidates` prompt to work through +pending rows one at a time. That prompt should ask for a decision with buttons, +capture optional freeform review notes, explain why the candidate exists and +why it scored as it did, and update `selection-manifest.tsv` after each answer. + +Recommended decision states: + +- `promote-skill` +- `promote-instruction` +- `promote-agent` +- `promote-hook` +- `promote-script` +- `promote-prompt` +- `template-only` +- `docs-only` +- `discard` +- `needs-sanitization` + +## Promotion Rule + +If a candidate cannot be explained as a portable skill, instruction, prompt +adapter, agent behavior, hook, script, or template, it should stay in audit +notes and not be promoted into the shared repo. + +## Follow-on Draft Generation + +After the manifest has approved rows, generate next-step artifacts with: + +```bash +resources/scripts/prepare-audit-promotions.sh +``` + +Or target a specific run: + +```bash +resources/scripts/prepare-audit-promotions.sh --audit-dir .local/audits// +``` + +The command reads approved rows from `selection-manifest.tsv` and writes: + +- `draft-resources/` for straightforward prompt, instruction, agent, and skill drafts +- `staging-notes/` for approved rows that still need manual design work +- `promotion-summary.md` to summarize what was generated + +## Limits + +- This audits persisted local artifacts only. +- It does not capture every inline completion or transient UI suggestion. +- Windows parity is deferred until the macOS workflow and report format are + proven. \ No newline at end of file diff --git a/docs/authoring.md b/docs/authoring.md index df9d9f0..322ef66 100644 --- a/docs/authoring.md +++ b/docs/authoring.md @@ -10,6 +10,18 @@ - Use an agent for a reusable persona with tool restrictions or handoffs. - Use a hook only when the behavior must be deterministic and enforced in code. +## Keep Shared Resources Cheap + +- Reuse an existing shared resource before creating a new one. +- Choose the cheapest sufficient primitive for the job instead of defaulting to + a long prompt. +- Keep frontmatter, descriptions, and embedded examples concise. +- Prefer targeted inputs such as a file, symbol, or command over broad repo + scans. +- State the default output budget when the resource is meant to stay brief. +- Say when the resource should not be used so it does not become a generic, + expensive fallback. + ## Supported Creation Paths ### Repo-first diff --git a/docs/git-bootstrap-hygiene.md b/docs/git-bootstrap-hygiene.md new file mode 100644 index 0000000..8f9128d --- /dev/null +++ b/docs/git-bootstrap-hygiene.md @@ -0,0 +1,59 @@ +# Git Bootstrap Hygiene + +## Purpose + +This guidance helps when a repository is being created, normalized, or cleaned +up early in its life so temporary local material does not leak into shared Git +history and the first committed shape of the repo is intentional. + +## Why This Matters + +Early repository mistakes tend to persist. Temporary import trees, scratch +directories, local caches, and vendor copies are easy to commit accidentally +when ignore rules and branch decisions are still unsettled. A small amount of +bootstrap hygiene keeps those mistakes out of long-term history. + +## Core Guidance + +- Identify scratch, cache, vendor-copy, and other temporary paths before the + first shared commit and keep them untracked unless there is an explicit + decision to promote them. +- Confirm the intended default branch before the first shared commit. Use the + project's branch policy when one exists; otherwise use a consistent default + such as `main`. +- Make the first tracked commit only after the repository's ignore rules and + basic bootstrap layout reflect the intended long-term structure. +- If temporary content later becomes real project content, move or regenerate it + into the correct tracked location instead of committing the original scratch + tree as-is. +- Keep repo-specific exceptions in repo-local setup notes or docs instead of + putting one-off paths, hashes, or names into shared guidance. + +## Good Outcomes + +- The initial history starts from an intentional branch and file layout. +- Local-only material stays local until it is deliberately promoted. +- Repositories keep fewer one-off cleanup commits related to accidental early + tracking. +- Shared guidance stays reusable because repo-specific details remain local. + +## What This Is Not + +- It is not a replacement for project-specific setup documentation. +- It does not tell a team which branch policy to adopt; it only says to choose + that policy intentionally before the first shared commit. +- It does not forbid generated or vendor content from being tracked when a + project intentionally relies on that model. + +## Typical Signals That This Guidance Applies + +- A repository is brand new and the first commit has not happened yet. +- A local import or migration dropped temporary files into the repo root. +- Scratch or cache directories are present and the ignore rules are not settled. +- The team is still deciding how bootstrap files and initial layout should look. + +## Local Exceptions + +When a repository intentionally tracks generated assets, mirrored vendor code, +or unusual bootstrap files, document that exception in the repo's own setup or +contributing docs instead of weakening the shared rule for every other project. \ No newline at end of file diff --git a/docs/operations.md b/docs/operations.md index 1ac3d96..d5964fa 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -21,3 +21,25 @@ install/verify.ps1 ``` Scheduled sync will be added on top of the same update and verify entrypoints. + +## Audit + +```bash +resources/scripts/audit-copilot-usage.sh --days 30 +``` + +The audit workflow is macOS-first in this iteration. It writes per-machine audit +history under `.local/audits//` in the repo checkout and keeps that +runtime state out of git. + +After a run is generated, use the `review-audit-candidates` prompt in Copilot +to work through `selection-manifest.tsv` one candidate at a time. + +When rows are approved, generate draft resources or staging notes with: + +```bash +resources/scripts/prepare-audit-promotions.sh +``` + +See `docs/audit-workflow.md` for the report format, selection manifest, and +promotion rules. diff --git a/docs/review-standards.md b/docs/review-standards.md index 83578e0..d619a2b 100644 --- a/docs/review-standards.md +++ b/docs/review-standards.md @@ -8,3 +8,15 @@ A new shared resource should be reviewed for: - Security implications, especially for hooks and scripts - Clear purpose and expected usage - Duplication with existing resources +- Cheapest sufficient primitive for the intended workflow +- Justified context size, including whether long examples or repeated guidance + can be moved into docs or scripts instead +- An intentional default output budget instead of open-ended verbosity +- Clear limits on when the resource should not be used + +Audit candidates should also be reviewed for: + +- Clear provenance back to an audit summary or selection manifest +- Portable fit as a skill, instruction, prompt adapter, agent, hook, script, or template +- Absence of secrets, machine-specific paths, or repo-specific assumptions +- A justified decision when the right outcome is `template-only`, `docs-only`, or `discard` diff --git a/docs/setup.md b/docs/setup.md index 800d651..8106db7 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -17,7 +17,9 @@ install/bootstrap.ps1 - Creates a canonical path at `~/.copilot-resources` - Links default discovery locations back to this repository - Merges only the managed Copilot-related VS Code settings into the user settings file +- Generates managed VS Code and Copilot CLI MCP config files from the tracked templates in `config/mcp/` - Writes a managed Copilot CLI environment fragment and sources it from the shell or PowerShell profile +- Creates `.local/mcp.local.jsonc` from the tracked example if the machine-local MCP override file does not exist yet - Writes a local install-state file outside the repository ## Optional Settings @@ -31,3 +33,32 @@ possible. Bootstrap also writes a managed Copilot CLI environment file into the local state directory and adds a small managed source block to the active shell or PowerShell profile instead of replacing the whole profile. + +Bootstrap generates user-level MCP config as well: + +- VS Code user `mcp.json` +- Copilot CLI user `~/.copilot/mcp-config.json` + +Those files are rendered from the tracked templates in `config/mcp/` and merged +in place so unmanaged MCP server entries are preserved. Managed MCP servers that +are no longer desired are removed. + +Machine-local MCP inputs live in `.local/mcp.local.jsonc`. The tracked example +file starts with Gitea disabled. Enable it per machine by editing that local +file and providing: + +- `servers.gitea.enabled: true` +- `servers.gitea.serverUrl` +- `servers.gitea.token` + +Current managed MCP behavior: + +- VS Code gets Playwright, Filesystem, and optional Gitea +- Copilot CLI gets Filesystem and optional Gitea +- Copilot CLI Playwright is intentionally omitted because it already ships as a built-in MCP server + +`install/update.sh` and `install/update.ps1` rerun bootstrap after `git pull`, +so managed MCP config changes propagate on update. + +For generic Git repository bootstrap hygiene outside the installation flow of +this shared resources repo, see `docs/git-bootstrap-hygiene.md`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8845c54..1e6748f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -15,6 +15,22 @@ Treat hot reload as likely but not guaranteed. If needed: - restart the Copilot CLI session - reopen VS Code +## Session Start Hook Permission Denied + +If you see `/bin/sh: .../report-hook-event.sh: Permission denied` when a Copilot +session starts: + +- Pull the latest repo changes and rerun `install/update.sh`. +- If you need to refresh links and generated config without pulling, rerun + `install/bootstrap.sh`. +- Run `install/verify.sh --quick` and resolve any missing managed files it + reports. +- Start a new Copilot session and confirm + `~/.copilot-resources-state/hook-events.log` receives a new JSON line. + +The shared hook now invokes the shell script through `bash`, so the session +start hook no longer depends on the script file itself being executable. + ## Publish Refused To Overwrite The publish scripts stop on collisions by default. Use a new name or rerun with diff --git a/install/bootstrap.ps1 b/install/bootstrap.ps1 index f4917a5..f58097e 100644 --- a/install/bootstrap.ps1 +++ b/install/bootstrap.ps1 @@ -9,6 +9,9 @@ $VscodeUserDir = if ($env:VSCODE_USER_DIR) { $env:VSCODE_USER_DIR } else { Join- $ManagedShellEnv = $null $ProfilePath = $PROFILE.CurrentUserAllHosts $VscodeSettingsFile = Join-Path $VscodeUserDir 'settings.json' +$VscodeMcpFile = Join-Path $VscodeUserDir 'mcp.json' +$CopilotCliMcpFile = Join-Path $CopilotHome 'mcp-config.json' +$LocalMcpOverridesFile = Join-Path $CanonicalHome '.local\mcp.local.jsonc' function Resolve-Directory { param([string]$Path) @@ -127,6 +130,14 @@ function Write-ManagedPowerShellEnv { -Body "if (Test-Path -LiteralPath $QuotedEnvPath) {`n . $QuotedEnvPath`n}" } +function Ensure-LocalMcpOverrides { + $ExampleFile = Join-Path $CanonicalHome 'config\mcp\local-overrides.example.jsonc' + Ensure-Directory (Split-Path -Parent $LocalMcpOverridesFile) + if (-not (Test-Path -LiteralPath $LocalMcpOverridesFile)) { + Copy-Item -LiteralPath $ExampleFile -Destination $LocalMcpOverridesFile + } +} + function Merge-VscodeSettings { $NodeExecutable = Find-NodeExecutable if (-not $NodeExecutable) { @@ -138,6 +149,20 @@ function Merge-VscodeSettings { & $NodeExecutable (Join-Path $ScriptDir 'merge-vscode-settings.mjs') --target $VscodeSettingsFile --template (Join-Path $CanonicalHome 'config\vscode\settings.template.jsonc') --set "COPILOT_RESOURCES_HOME=$CanonicalHome" } +function Merge-ManagedMcpConfig { + $NodeExecutable = Find-NodeExecutable + if (-not $NodeExecutable) { + Write-Warning 'Skipping managed MCP config merge because Node.js is not available.' + return + } + + Ensure-Directory (Split-Path -Parent $VscodeMcpFile) + Ensure-Directory (Split-Path -Parent $CopilotCliMcpFile) + + & $NodeExecutable (Join-Path $ScriptDir 'merge-managed-mcp-config.mjs') --target $VscodeMcpFile --template (Join-Path $CanonicalHome 'config\mcp\vscode.mcp.template.jsonc') --server-key servers --overrides $LocalMcpOverridesFile --set "COPILOT_RESOURCES_HOME=$CanonicalHome" + & $NodeExecutable (Join-Path $ScriptDir 'merge-managed-mcp-config.mjs') --target $CopilotCliMcpFile --template (Join-Path $CanonicalHome 'config\mcp\copilot-cli.mcp.template.jsonc') --server-key mcpServers --overrides $LocalMcpOverridesFile --set "COPILOT_RESOURCES_HOME=$CanonicalHome" +} + Ensure-Directory (Split-Path -Parent $CanonicalHome) if (Test-Path -LiteralPath $CanonicalHome) { if ((Resolve-Directory $CanonicalHome) -ne (Resolve-Directory $RepoRoot)) { @@ -157,7 +182,9 @@ Ensure-Junction -Target (Join-Path $CanonicalHome 'resources\hooks') -Path (Join Ensure-Junction -Target (Join-Path $CanonicalHome 'resources\prompts') -Path (Join-Path $VscodeUserDir 'prompts') Write-ManagedPowerShellEnv +Ensure-LocalMcpOverrides Merge-VscodeSettings +Merge-ManagedMcpConfig Ensure-Directory $StateDir @{ @@ -166,6 +193,9 @@ Ensure-Directory $StateDir copilotHome = $CopilotHome vscodeUserDir = $VscodeUserDir vscodeSettingsFile = $VscodeSettingsFile + vscodeMcpFile = $VscodeMcpFile + copilotCliMcpFile = $CopilotCliMcpFile + mcpLocalOverridesFile = $LocalMcpOverridesFile shellRcFile = $ProfilePath managedShellEnv = $ManagedShellEnv bootstrapScript = (Join-Path $ScriptDir 'bootstrap.ps1') @@ -177,6 +207,9 @@ Write-Host "Repository root: $RepoRoot" Write-Host "Copilot home: $CopilotHome" Write-Host "VS Code user dir: $VscodeUserDir" Write-Host "Merged managed VS Code settings into: $VscodeSettingsFile" +Write-Host "Merged managed MCP configuration into: $VscodeMcpFile" +Write-Host "Merged managed MCP configuration into: $CopilotCliMcpFile" Write-Host "Installed managed Copilot CLI PowerShell environment into: $ManagedShellEnv" Write-Host "Linked PowerShell profile: $ProfilePath" Write-Host "Optional VS Code template: $(Join-Path $CanonicalHome 'config\vscode\settings.template.jsonc')" +Write-Host "Machine-local MCP overrides live in: $LocalMcpOverridesFile" diff --git a/install/bootstrap.sh b/install/bootstrap.sh old mode 100644 new mode 100755 index 53e3d02..246603f --- a/install/bootstrap.sh +++ b/install/bootstrap.sh @@ -10,6 +10,9 @@ copilot_home="${COPILOT_HOME:-$HOME/.copilot}" managed_shell_env="" shell_rc_file="" vscode_settings_file="" +vscode_mcp_file="" +copilot_cli_mcp_file="" +local_mcp_overrides_file="" usage() { cat <<'EOF' @@ -163,6 +166,18 @@ EOF "$shell_block" } +ensure_local_mcp_overrides() { + local example_file + + local_mcp_overrides_file="$canonical_home/.local/mcp.local.jsonc" + example_file="$canonical_home/config/mcp/local-overrides.example.jsonc" + + ensure_parent_dir "$local_mcp_overrides_file" + if [[ ! -f "$local_mcp_overrides_file" ]]; then + cp -- "$example_file" "$local_mcp_overrides_file" + fi +} + merge_vscode_settings() { local node_bin vscode_settings_file="$vscode_user_dir/settings.json" @@ -180,6 +195,35 @@ merge_vscode_settings() { --set "COPILOT_RESOURCES_HOME=$canonical_home" } +merge_managed_mcp_config() { + local node_bin + + if ! node_bin="$(find_node_bin)"; then + printf 'Skipping managed MCP config merge because Node.js is not available.\n' >&2 + return 0 + fi + + vscode_mcp_file="$vscode_user_dir/mcp.json" + copilot_cli_mcp_file="$copilot_home/mcp-config.json" + + ensure_parent_dir "$vscode_mcp_file" + ensure_parent_dir "$copilot_cli_mcp_file" + + "$node_bin" "$script_dir/merge-managed-mcp-config.mjs" \ + --target "$vscode_mcp_file" \ + --template "$canonical_home/config/mcp/vscode.mcp.template.jsonc" \ + --server-key "servers" \ + --overrides "$local_mcp_overrides_file" \ + --set "COPILOT_RESOURCES_HOME=$canonical_home" + + "$node_bin" "$script_dir/merge-managed-mcp-config.mjs" \ + --target "$copilot_cli_mcp_file" \ + --template "$canonical_home/config/mcp/copilot-cli.mcp.template.jsonc" \ + --server-key "mcpServers" \ + --overrides "$local_mcp_overrides_file" \ + --set "COPILOT_RESOURCES_HOME=$canonical_home" +} + write_state() { mkdir -p -- "$state_dir" cat > "$state_dir/install-state.json" < { + console.error(`Failed to start Docker for the filesystem MCP server: ${error.message}`); + process.exit(1); + }); + + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(signal, () => { + if (!child.killed) { + child.kill(signal); + } + }); + } + + child.on("exit", (code, signal) => { + if (signal) { + console.error(`Filesystem MCP server exited with signal ${signal}`); + process.exit(1); + } + + process.exit(code ?? 0); + }); +} + +main(); \ No newline at end of file diff --git a/install/merge-managed-mcp-config.mjs b/install/merge-managed-mcp-config.mjs new file mode 100644 index 0000000..bf3f876 --- /dev/null +++ b/install/merge-managed-mcp-config.mjs @@ -0,0 +1,823 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; + +function usage() { + console.error( + "Usage: node install/merge-managed-mcp-config.mjs --target --template --server-key [--overrides ] [--set NAME=value]", + ); +} + +function parseArgs(argv) { + const options = { + replacements: {}, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--target") { + options.target = argv[index + 1]; + index += 1; + continue; + } + + if (arg === "--template") { + options.template = argv[index + 1]; + index += 1; + continue; + } + + if (arg === "--server-key") { + options.serverKey = argv[index + 1]; + index += 1; + continue; + } + + if (arg === "--overrides") { + options.overrides = argv[index + 1]; + index += 1; + continue; + } + + if (arg === "--set") { + const assignment = argv[index + 1] ?? ""; + const equalsIndex = assignment.indexOf("="); + if (equalsIndex <= 0) { + throw new Error(`Invalid --set assignment: ${assignment}`); + } + const key = assignment.slice(0, equalsIndex); + const value = assignment.slice(equalsIndex + 1); + options.replacements[key] = value; + index += 1; + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + if (!options.target || !options.template || !options.serverKey) { + usage(); + throw new Error( + "--target, --template, and --server-key are all required.", + ); + } + + return options; +} + +function stripJsonComments(input) { + let output = ""; + let inString = false; + let escaping = false; + let inLineComment = false; + let inBlockComment = false; + + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + const next = input[index + 1]; + + if (inLineComment) { + if (char === "\n") { + inLineComment = false; + output += char; + } + continue; + } + + if (inBlockComment) { + if (char === "*" && next === "/") { + inBlockComment = false; + index += 1; + } else if (char === "\n" || char === "\r") { + output += char; + } + continue; + } + + if (inString) { + output += char; + if (escaping) { + escaping = false; + } else if (char === "\\") { + escaping = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + output += char; + continue; + } + + if (char === "/" && next === "/") { + inLineComment = true; + index += 1; + continue; + } + + if (char === "/" && next === "*") { + inBlockComment = true; + index += 1; + continue; + } + + output += char; + } + + return output; +} + +function stripTrailingCommas(input) { + let output = ""; + let inString = false; + let escaping = false; + + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + + if (inString) { + output += char; + if (escaping) { + escaping = false; + } else if (char === "\\") { + escaping = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + output += char; + continue; + } + + if (char === ",") { + let lookahead = index + 1; + while (lookahead < input.length && /\s/.test(input[lookahead])) { + lookahead += 1; + } + if (input[lookahead] === "}" || input[lookahead] === "]") { + continue; + } + } + + output += char; + } + + return output; +} + +function renderTemplate(input, replacements) { + return input.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key) => { + if (!(key in replacements)) { + return match; + } + return replacements[key].replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + }); +} + +function parseJsonc(input, label) { + const sanitized = stripTrailingCommas(stripJsonComments(input)).trim(); + if (!sanitized) { + return {}; + } + + let parsed; + try { + parsed = JSON.parse(sanitized); + } catch (error) { + throw new Error(`Failed to parse ${label}: ${error.message}`); + } + + if (!isPlainObject(parsed)) { + throw new Error(`${label} must contain a JSON object at the root.`); + } + + return parsed; +} + +function parseJsoncAst(input, label) { + let index = 0; + + function fail(message) { + throw new Error(`Failed to parse ${label}: ${message}`); + } + + function skipTrivia() { + while (index < input.length) { + const char = input[index]; + const next = input[index + 1]; + + if (/\s/.test(char)) { + index += 1; + continue; + } + + if (char === "/" && next === "/") { + index += 2; + while (index < input.length && input[index] !== "\n") { + index += 1; + } + continue; + } + + if (char === "/" && next === "*") { + index += 2; + while ( + index < input.length && + !(input[index] === "*" && input[index + 1] === "/") + ) { + index += 1; + } + if (index >= input.length) { + fail("unterminated block comment"); + } + index += 2; + continue; + } + + break; + } + } + + function parseStringNode() { + const start = index; + index += 1; + + while (index < input.length) { + const char = input[index]; + + if (char === "\\") { + index += 2; + continue; + } + + if (char === '"') { + index += 1; + const raw = input.slice(start, index); + return { + type: "string", + start, + end: index, + value: JSON.parse(raw), + }; + } + + index += 1; + } + + fail("unterminated string literal"); + } + + function parseLiteralNode(expectedText, value) { + const start = index; + if (!input.startsWith(expectedText, index)) { + fail(`expected ${expectedText}`); + } + index += expectedText.length; + return { + type: typeof value, + start, + end: index, + value, + }; + } + + function parseNumberNode() { + const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec( + input.slice(index), + ); + if (!match) { + fail(`invalid number at offset ${index}`); + } + + const start = index; + index += match[0].length; + return { + type: "number", + start, + end: index, + value: Number(match[0]), + }; + } + + function parseArrayNode() { + const start = index; + index += 1; + const elements = []; + + skipTrivia(); + while (index < input.length && input[index] !== "]") { + const valueNode = parseValueNode(); + elements.push(valueNode); + skipTrivia(); + + if (input[index] === ",") { + index += 1; + skipTrivia(); + continue; + } + + if (input[index] !== "]") { + fail(`expected ',' or ']' at offset ${index}`); + } + } + + if (input[index] !== "]") { + fail("unterminated array"); + } + + index += 1; + return { + type: "array", + start, + end: index, + elements, + value: elements.map((element) => element.value), + }; + } + + function parseObjectNode() { + const start = index; + index += 1; + const properties = []; + const value = {}; + + skipTrivia(); + while (index < input.length && input[index] !== "}") { + if (input[index] !== '"') { + fail(`expected string property name at offset ${index}`); + } + + const keyNode = parseStringNode(); + const key = keyNode.value; + skipTrivia(); + + if (input[index] !== ":") { + fail(`expected ':' after property name at offset ${index}`); + } + + index += 1; + skipTrivia(); + + const valueNode = parseValueNode(); + const property = { + key, + keyNode, + value: valueNode, + hasTrailingComma: false, + }; + + properties.push(property); + value[key] = valueNode.value; + + skipTrivia(); + if (input[index] === ",") { + property.hasTrailingComma = true; + index += 1; + skipTrivia(); + continue; + } + + if (input[index] !== "}") { + fail(`expected ',' or '}' at offset ${index}`); + } + } + + if (input[index] !== "}") { + fail("unterminated object"); + } + + index += 1; + return { + type: "object", + start, + end: index, + properties, + value, + }; + } + + function parseValueNode() { + skipTrivia(); + + const char = input[index]; + if (char === "{") { + return parseObjectNode(); + } + if (char === "[") { + return parseArrayNode(); + } + if (char === '"') { + return parseStringNode(); + } + if (char === "t") { + return parseLiteralNode("true", true); + } + if (char === "f") { + return parseLiteralNode("false", false); + } + if (char === "n") { + return parseLiteralNode("null", null); + } + if (char === "-" || /\d/.test(char ?? "")) { + return parseNumberNode(); + } + + fail(`unexpected token at offset ${index}`); + } + + skipTrivia(); + const root = parseValueNode(); + skipTrivia(); + + if (index !== input.length) { + fail(`unexpected trailing content at offset ${index}`); + } + + if (root.type !== "object") { + throw new Error(`${label} must contain a JSON object at the root.`); + } + + return root; +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function detectIndentUnit(input) { + const matches = input.match(/^( +|\t+)\S/m); + if (!matches) { + return " "; + } + + return matches[1]; +} + +function detectEol(input) { + return input.includes("\r\n") ? "\r\n" : "\n"; +} + +function lineStartIndex(input, index) { + const newlineIndex = input.lastIndexOf("\n", index - 1); + return newlineIndex === -1 ? 0 : newlineIndex + 1; +} + +function lineIndentAt(input, index) { + const start = lineStartIndex(input, index); + let end = start; + while (end < input.length && (input[end] === " " || input[end] === "\t")) { + end += 1; + } + return input.slice(start, end); +} + +function renderValue(value, propertyIndent, indentUnit, eol) { + const raw = JSON.stringify(value, null, indentUnit); + if (!raw.includes("\n")) { + return raw; + } + + return raw + .split("\n") + .map((line, index) => (index === 0 ? line : `${propertyIndent}${line}`)) + .join(eol); +} + +function renderProperty(key, value, propertyIndent, indentUnit, eol) { + return `${JSON.stringify(key)}: ${renderValue(value, propertyIndent, indentUnit, eol)}`; +} + +function getObjectChildIndent(input, objectNode, indentUnit) { + if (objectNode.properties.length > 0) { + return lineIndentAt(input, objectNode.properties[0].keyNode.start); + } + + return `${lineIndentAt(input, objectNode.start)}${indentUnit}`; +} + +function buildMergeEdits( + input, + objectNode, + managedSettings, + indentUnit, + eol, + edits, +) { + const missingEntries = []; + + for (const [key, managedValue] of Object.entries(managedSettings)) { + const property = objectNode.properties.find( + (candidate) => candidate.key === key, + ); + + if (!property) { + missingEntries.push([key, managedValue]); + continue; + } + + if (isPlainObject(managedValue) && property.value.type === "object") { + buildMergeEdits( + input, + property.value, + managedValue, + indentUnit, + eol, + edits, + ); + continue; + } + + if (!isDeepStrictEqual(property.value.value, managedValue)) { + const propertyIndent = lineIndentAt(input, property.keyNode.start); + edits.push({ + start: property.value.start, + end: property.value.end, + text: renderValue(managedValue, propertyIndent, indentUnit, eol), + }); + } + } + + if (missingEntries.length === 0) { + return; + } + + const propertyIndent = getObjectChildIndent(input, objectNode, indentUnit); + const closingIndent = lineIndentAt(input, objectNode.end); + const closingBraceIndex = objectNode.end - 1; + const renderedProperties = missingEntries + .map( + ([key, value]) => + `${propertyIndent}${renderProperty(key, value, propertyIndent, indentUnit, eol)}`, + ) + .join(`,${eol}`); + + if (objectNode.properties.length === 0) { + edits.push({ + start: objectNode.start + 1, + end: closingBraceIndex, + text: `${eol}${renderedProperties}${eol}${closingIndent}`, + }); + return; + } + + const lastProperty = objectNode.properties[objectNode.properties.length - 1]; + if (!lastProperty.hasTrailingComma) { + edits.push({ + start: lastProperty.value.end, + end: lastProperty.value.end, + text: ",", + }); + } + + const closingLineStart = lineStartIndex(input, objectNode.end); + const insertBeforeClosingLine = closingLineStart > lastProperty.value.end; + + edits.push({ + start: insertBeforeClosingLine ? closingLineStart : objectNode.end, + end: insertBeforeClosingLine ? closingLineStart : objectNode.end, + text: insertBeforeClosingLine + ? `${renderedProperties}${eol}` + : `${eol}${renderedProperties}${eol}${closingIndent}`, + }); +} + +function buildRemovalEdits(input, objectNode, keysToRemove, edits) { + const removeSet = new Set(keysToRemove); + if (removeSet.size === 0) { + return; + } + + const properties = objectNode.properties; + const closingBraceIndex = objectNode.end - 1; + + for (let index = 0; index < properties.length; index += 1) { + if (!removeSet.has(properties[index].key)) { + continue; + } + + let endIndex = index; + while ( + endIndex + 1 < properties.length && + removeSet.has(properties[endIndex + 1].key) + ) { + endIndex += 1; + } + + const firstProperty = properties[index]; + const lastProperty = properties[endIndex]; + const previousProperty = index > 0 ? properties[index - 1] : null; + const nextProperty = + endIndex + 1 < properties.length ? properties[endIndex + 1] : null; + + if (!previousProperty && !nextProperty) { + edits.push({ + start: objectNode.start + 1, + end: closingBraceIndex, + text: + closingBraceIndex > lastProperty.value.end + ? input.slice(lastProperty.value.end, closingBraceIndex) + : "", + }); + continue; + } + + if (nextProperty) { + edits.push({ + start: firstProperty.keyNode.start, + end: nextProperty.keyNode.start, + text: "", + }); + index = endIndex; + continue; + } + + edits.push({ + start: previousProperty.value.end, + end: closingBraceIndex, + text: + closingBraceIndex > lastProperty.value.end + ? input.slice(lastProperty.value.end, closingBraceIndex) + : "", + }); + index = endIndex; + } +} + +function applyEdits(input, edits) { + return edits + .sort((left, right) => right.start - left.start || right.end - left.end) + .reduce( + (text, edit) => + `${text.slice(0, edit.start)}${edit.text}${text.slice(edit.end)}`, + input, + ); +} + +function readLocalOverrides(filePath) { + if (!filePath || !fs.existsSync(filePath)) { + return {}; + } + + return parseJsonc(fs.readFileSync(filePath, "utf8"), filePath); +} + +function getServerOverride(overrides, name) { + const serverMap = isPlainObject(overrides.servers) ? overrides.servers : {}; + const value = serverMap[name]; + return isPlainObject(value) ? value : {}; +} + +function normalizeString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function buildTemplateReplacements(replacements, overrides) { + const giteaOverrides = getServerOverride(overrides, "gitea"); + + return { + ...replacements, + GITEA_SERVER_URL: normalizeString(giteaOverrides.serverUrl), + GITEA_TOKEN: normalizeString(giteaOverrides.token), + }; +} + +function isServerEnabled(name, overrides) { + const serverOverride = getServerOverride(overrides, name); + if (serverOverride.enabled === false) { + return false; + } + + if (name === "gitea") { + return ( + serverOverride.enabled === true && + normalizeString(serverOverride.serverUrl) !== "" && + normalizeString(serverOverride.token) !== "" + ); + } + + return true; +} + +function selectManagedConfig(managedConfig, serverKey, overrides) { + const managedServers = managedConfig[serverKey]; + if (!isPlainObject(managedServers)) { + throw new Error( + `${serverKey} in ${serverKey} template must be an object at the root.`, + ); + } + + const selectedServers = {}; + for (const [name, serverConfig] of Object.entries(managedServers)) { + if (isServerEnabled(name, overrides)) { + selectedServers[name] = serverConfig; + } + } + + return { + ...managedConfig, + [serverKey]: selectedServers, + }; +} + +function removeStaleManagedServers( + input, + rootNode, + serverKey, + managedServerNames, + desiredServerNames, +) { + const serverProperty = rootNode.properties.find( + (candidate) => candidate.key === serverKey, + ); + + if (!serverProperty || serverProperty.value.type !== "object") { + return input; + } + + const removableKeys = serverProperty.value.properties + .map((property) => property.key) + .filter( + (key) => managedServerNames.has(key) && !desiredServerNames.has(key), + ); + + if (removableKeys.length === 0) { + return input; + } + + const edits = []; + buildRemovalEdits(input, serverProperty.value, removableKeys, edits); + return applyEdits(input, edits); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const localOverrides = readLocalOverrides(options.overrides); + const replacements = buildTemplateReplacements( + options.replacements, + localOverrides, + ); + + const templateText = fs.readFileSync(options.template, "utf8"); + const renderedTemplate = renderTemplate(templateText, replacements); + const managedConfig = parseJsonc(renderedTemplate, options.template); + const desiredConfig = selectManagedConfig( + managedConfig, + options.serverKey, + localOverrides, + ); + + const managedServers = desiredConfig[options.serverKey]; + const allManagedServerNames = new Set(Object.keys(managedConfig[options.serverKey])); + const desiredServerNames = new Set(Object.keys(managedServers)); + + const targetText = fs.existsSync(options.target) + ? fs.readFileSync(options.target, "utf8") + : "{}\n"; + const targetAst = parseJsoncAst(targetText, options.target); + const cleanedTargetText = removeStaleManagedServers( + targetText, + targetAst, + options.serverKey, + allManagedServerNames, + desiredServerNames, + ); + const cleanedTargetAst = parseJsoncAst(cleanedTargetText, options.target); + const indentUnit = detectIndentUnit(targetText); + const eol = detectEol(targetText); + const edits = []; + + buildMergeEdits( + cleanedTargetText, + cleanedTargetAst, + desiredConfig, + indentUnit, + eol, + edits, + ); + + const output = + edits.length === 0 ? cleanedTargetText : applyEdits(cleanedTargetText, edits); + + parseJsonc(output, options.target); + fs.mkdirSync(path.dirname(options.target), { recursive: true }); + + if (output === targetText) { + console.log(`Managed MCP config already up to date: ${options.target}`); + return; + } + + fs.writeFileSync(options.target, output, "utf8"); + console.log(`Merged managed MCP config into: ${options.target}`); +} + +main(); \ No newline at end of file diff --git a/install/merge-managed-mcp-config.test.mjs b/install/merge-managed-mcp-config.test.mjs new file mode 100644 index 0000000..64810ee --- /dev/null +++ b/install/merge-managed-mcp-config.test.mjs @@ -0,0 +1,282 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, ".."); +const mergeScript = path.join(scriptDir, "merge-managed-mcp-config.mjs"); +const vscodeTemplateFile = path.join( + repoRoot, + "config", + "mcp", + "vscode.mcp.template.jsonc", +); +const copilotCliTemplateFile = path.join( + repoRoot, + "config", + "mcp", + "copilot-cli.mcp.template.jsonc", +); + +function stripJsonComments(input) { + let output = ""; + let inString = false; + let escaping = false; + let inLineComment = false; + let inBlockComment = false; + + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + const next = input[index + 1]; + + if (inLineComment) { + if (char === "\n") { + inLineComment = false; + output += char; + } + continue; + } + + if (inBlockComment) { + if (char === "*" && next === "/") { + inBlockComment = false; + index += 1; + } else if (char === "\n" || char === "\r") { + output += char; + } + continue; + } + + if (inString) { + output += char; + if (escaping) { + escaping = false; + } else if (char === "\\") { + escaping = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + output += char; + continue; + } + + if (char === "/" && next === "/") { + inLineComment = true; + index += 1; + continue; + } + + if (char === "/" && next === "*") { + inBlockComment = true; + index += 1; + continue; + } + + output += char; + } + + return output; +} + +function stripTrailingCommas(input) { + let output = ""; + let inString = false; + let escaping = false; + + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + + if (inString) { + output += char; + if (escaping) { + escaping = false; + } else if (char === "\\") { + escaping = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + output += char; + continue; + } + + if (char === ",") { + let lookahead = index + 1; + while (lookahead < input.length && /\s/.test(input[lookahead])) { + lookahead += 1; + } + if (input[lookahead] === "}" || input[lookahead] === "]") { + continue; + } + } + + output += char; + } + + return output; +} + +function parseJsonc(input) { + return JSON.parse(stripTrailingCommas(stripJsonComments(input))); +} + +function runMerge({ targetFile, templateFile, serverKey, overridesFile }) { + const args = [ + mergeScript, + "--target", + targetFile, + "--template", + templateFile, + "--server-key", + serverKey, + "--set", + "COPILOT_RESOURCES_HOME=/repo/home", + ]; + + if (overridesFile) { + args.push("--overrides", overridesFile); + } + + return spawnSync(process.execPath, args, { + cwd: repoRoot, + encoding: "utf8", + }); +} + +test("preserves comments and custom servers while pruning stale managed MCP entries", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-mcp-")); + const targetFile = path.join(tempDir, "mcp.json"); + + fs.writeFileSync( + targetFile, + `{ + // keep this comment + "servers": { + // custom server stays + "customServer": { + "type": "http", + "url": "https://example.com/mcp" + }, + "gitea": { + "type": "stdio", + "command": "docker", + "args": ["stale"] + } + } +} +`, + "utf8", + ); + + const result = runMerge({ + targetFile, + templateFile: vscodeTemplateFile, + serverKey: "servers", + }); + assert.equal(result.status, 0, result.stderr); + + const output = fs.readFileSync(targetFile, "utf8"); + assert.match(output, /\/\/ keep this comment/); + assert.match(output, /\/\/ custom server stays/); + assert.match(output, /"customServer"/); + assert.match(output, /"playwright"/); + assert.match(output, /"filesystem"/); + assert.doesNotMatch(output, /"gitea"/); + + const parsed = parseJsonc(output); + assert.equal(parsed.servers.customServer.type, "http"); + assert.equal(parsed.servers.playwright.command, "npx"); + assert.equal(parsed.servers.filesystem.command, "docker"); + assert.equal(parsed.servers.gitea, undefined); +}); + +test("renders optional Gitea config when local overrides are complete", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-mcp-")); + const targetFile = path.join(tempDir, "mcp-config.json"); + const overridesFile = path.join(tempDir, "mcp.local.jsonc"); + + fs.writeFileSync( + overridesFile, + `{ + "servers": { + "gitea": { + "enabled": true, + "serverUrl": "https://git.example.com", + "token": "secret-token" + } + } +} +`, + "utf8", + ); + + const result = runMerge({ + targetFile, + templateFile: copilotCliTemplateFile, + serverKey: "mcpServers", + overridesFile, + }); + assert.equal(result.status, 0, result.stderr); + + const parsed = parseJsonc(fs.readFileSync(targetFile, "utf8")); + assert.equal( + parsed.mcpServers.filesystem.args[0], + "/repo/home/install/mcp/copilot-cli-filesystem-wrapper.mjs", + ); + assert.equal(parsed.mcpServers.gitea.env.FORGEJOMCP_SERVER, "https://git.example.com"); + assert.equal(parsed.mcpServers.gitea.env.FORGEJOMCP_TOKEN, "secret-token"); + assert.equal(parsed.mcpServers.playwright, undefined); +}); + +test("second run is idempotent and keeps the file text unchanged", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-mcp-")); + const targetFile = path.join(tempDir, "mcp.json"); + + fs.writeFileSync( + targetFile, + `{ + // user comment + "servers": { + "customServer": { + "type": "http", + "url": "https://example.com/mcp" + } + } +} +`, + "utf8", + ); + + const firstRun = runMerge({ + targetFile, + templateFile: vscodeTemplateFile, + serverKey: "servers", + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + const firstOutput = fs.readFileSync(targetFile, "utf8"); + + const secondRun = runMerge({ + targetFile, + templateFile: vscodeTemplateFile, + serverKey: "servers", + }); + assert.equal(secondRun.status, 0, secondRun.stderr); + assert.match(secondRun.stdout, /already up to date/); + + const secondOutput = fs.readFileSync(targetFile, "utf8"); + assert.equal(secondOutput, firstOutput); + assert.match(secondOutput, /\/\/ user comment/); +}); \ No newline at end of file diff --git a/install/merge-vscode-settings.mjs b/install/merge-vscode-settings.mjs index a750529..b44517d 100644 --- a/install/merge-vscode-settings.mjs +++ b/install/merge-vscode-settings.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; -import { isDeepStrictEqual } from 'node:util'; +import fs from "node:fs"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; function usage() { console.error( - 'Usage: node install/merge-vscode-settings.mjs --target --template [--set NAME=value]' + "Usage: node install/merge-vscode-settings.mjs --target --template [--set NAME=value]", ); } @@ -18,21 +18,21 @@ function parseArgs(argv) { for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; - if (arg === '--target') { + if (arg === "--target") { options.target = argv[index + 1]; index += 1; continue; } - if (arg === '--template') { + if (arg === "--template") { options.template = argv[index + 1]; index += 1; continue; } - if (arg === '--set') { - const assignment = argv[index + 1] ?? ''; - const equalsIndex = assignment.indexOf('='); + if (arg === "--set") { + const assignment = argv[index + 1] ?? ""; + const equalsIndex = assignment.indexOf("="); if (equalsIndex <= 0) { throw new Error(`Invalid --set assignment: ${assignment}`); } @@ -48,14 +48,14 @@ function parseArgs(argv) { if (!options.target || !options.template) { usage(); - throw new Error('Both --target and --template are required.'); + throw new Error("Both --target and --template are required."); } return options; } function stripJsonComments(input) { - let output = ''; + let output = ""; let inString = false; let escaping = false; let inLineComment = false; @@ -66,7 +66,7 @@ function stripJsonComments(input) { const next = input[index + 1]; if (inLineComment) { - if (char === '\n') { + if (char === "\n") { inLineComment = false; output += char; } @@ -74,10 +74,10 @@ function stripJsonComments(input) { } if (inBlockComment) { - if (char === '*' && next === '/') { + if (char === "*" && next === "/") { inBlockComment = false; index += 1; - } else if (char === '\n' || char === '\r') { + } else if (char === "\n" || char === "\r") { output += char; } continue; @@ -87,7 +87,7 @@ function stripJsonComments(input) { output += char; if (escaping) { escaping = false; - } else if (char === '\\') { + } else if (char === "\\") { escaping = true; } else if (char === '"') { inString = false; @@ -101,13 +101,13 @@ function stripJsonComments(input) { continue; } - if (char === '/' && next === '/') { + if (char === "/" && next === "/") { inLineComment = true; index += 1; continue; } - if (char === '/' && next === '*') { + if (char === "/" && next === "*") { inBlockComment = true; index += 1; continue; @@ -120,7 +120,7 @@ function stripJsonComments(input) { } function stripTrailingCommas(input) { - let output = ''; + let output = ""; let inString = false; let escaping = false; @@ -131,7 +131,7 @@ function stripTrailingCommas(input) { output += char; if (escaping) { escaping = false; - } else if (char === '\\') { + } else if (char === "\\") { escaping = true; } else if (char === '"') { inString = false; @@ -145,12 +145,12 @@ function stripTrailingCommas(input) { continue; } - if (char === ',') { + if (char === ",") { let lookahead = index + 1; while (lookahead < input.length && /\s/.test(input[lookahead])) { lookahead += 1; } - if (input[lookahead] === '}' || input[lookahead] === ']') { + if (input[lookahead] === "}" || input[lookahead] === "]") { continue; } } @@ -166,9 +166,7 @@ function renderTemplate(input, replacements) { if (!(key in replacements)) { return match; } - return replacements[key] - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"'); + return replacements[key].replace(/\\/g, "\\\\").replace(/"/g, '\\"'); }); } @@ -209,21 +207,24 @@ function parseJsoncAst(input, label) { continue; } - if (char === '/' && next === '/') { + if (char === "/" && next === "/") { index += 2; - while (index < input.length && input[index] !== '\n') { + while (index < input.length && input[index] !== "\n") { index += 1; } continue; } - if (char === '/' && next === '*') { + if (char === "/" && next === "*") { index += 2; - while (index < input.length && !(input[index] === '*' && input[index + 1] === '/')) { + while ( + index < input.length && + !(input[index] === "*" && input[index + 1] === "/") + ) { index += 1; } if (index >= input.length) { - fail('unterminated block comment'); + fail("unterminated block comment"); } index += 2; continue; @@ -240,7 +241,7 @@ function parseJsoncAst(input, label) { while (index < input.length) { const char = input[index]; - if (char === '\\') { + if (char === "\\") { index += 2; continue; } @@ -249,7 +250,7 @@ function parseJsoncAst(input, label) { index += 1; const raw = input.slice(start, index); return { - type: 'string', + type: "string", start, end: index, value: JSON.parse(raw), @@ -259,7 +260,7 @@ function parseJsoncAst(input, label) { index += 1; } - fail('unterminated string literal'); + fail("unterminated string literal"); } function parseLiteralNode(expectedText, value) { @@ -277,7 +278,9 @@ function parseJsoncAst(input, label) { } function parseNumberNode() { - const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(input.slice(index)); + const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec( + input.slice(index), + ); if (!match) { fail(`invalid number at offset ${index}`); } @@ -285,7 +288,7 @@ function parseJsoncAst(input, label) { const start = index; index += match[0].length; return { - type: 'number', + type: "number", start, end: index, value: Number(match[0]), @@ -298,29 +301,29 @@ function parseJsoncAst(input, label) { const elements = []; skipTrivia(); - while (index < input.length && input[index] !== ']') { + while (index < input.length && input[index] !== "]") { const valueNode = parseValueNode(); elements.push(valueNode); skipTrivia(); - if (input[index] === ',') { + if (input[index] === ",") { index += 1; skipTrivia(); continue; } - if (input[index] !== ']') { + if (input[index] !== "]") { fail(`expected ',' or ']' at offset ${index}`); } } - if (input[index] !== ']') { - fail('unterminated array'); + if (input[index] !== "]") { + fail("unterminated array"); } index += 1; return { - type: 'array', + type: "array", start, end: index, elements, @@ -335,7 +338,7 @@ function parseJsoncAst(input, label) { const value = {}; skipTrivia(); - while (index < input.length && input[index] !== '}') { + while (index < input.length && input[index] !== "}") { if (input[index] !== '"') { fail(`expected string property name at offset ${index}`); } @@ -344,7 +347,7 @@ function parseJsoncAst(input, label) { const key = keyNode.value; skipTrivia(); - if (input[index] !== ':') { + if (input[index] !== ":") { fail(`expected ':' after property name at offset ${index}`); } @@ -363,25 +366,25 @@ function parseJsoncAst(input, label) { value[key] = valueNode.value; skipTrivia(); - if (input[index] === ',') { + if (input[index] === ",") { property.hasTrailingComma = true; index += 1; skipTrivia(); continue; } - if (input[index] !== '}') { + if (input[index] !== "}") { fail(`expected ',' or '}' at offset ${index}`); } } - if (input[index] !== '}') { - fail('unterminated object'); + if (input[index] !== "}") { + fail("unterminated object"); } index += 1; return { - type: 'object', + type: "object", start, end: index, properties, @@ -393,25 +396,25 @@ function parseJsoncAst(input, label) { skipTrivia(); const char = input[index]; - if (char === '{') { + if (char === "{") { return parseObjectNode(); } - if (char === '[') { + if (char === "[") { return parseArrayNode(); } if (char === '"') { return parseStringNode(); } - if (char === 't') { - return parseLiteralNode('true', true); + if (char === "t") { + return parseLiteralNode("true", true); } - if (char === 'f') { - return parseLiteralNode('false', false); + if (char === "f") { + return parseLiteralNode("false", false); } - if (char === 'n') { - return parseLiteralNode('null', null); + if (char === "n") { + return parseLiteralNode("null", null); } - if (char === '-' || /\d/.test(char ?? '')) { + if (char === "-" || /\d/.test(char ?? "")) { return parseNumberNode(); } @@ -426,7 +429,7 @@ function parseJsoncAst(input, label) { fail(`unexpected trailing content at offset ${index}`); } - if (root.type !== 'object') { + if (root.type !== "object") { throw new Error(`${label} must contain a JSON object at the root.`); } @@ -434,31 +437,31 @@ function parseJsoncAst(input, label) { } function isPlainObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value); + return value !== null && typeof value === "object" && !Array.isArray(value); } function detectIndentUnit(input) { const matches = input.match(/^( +|\t+)\S/m); if (!matches) { - return ' '; + return " "; } return matches[1]; } function detectEol(input) { - return input.includes('\r\n') ? '\r\n' : '\n'; + return input.includes("\r\n") ? "\r\n" : "\n"; } function lineStartIndex(input, index) { - const newlineIndex = input.lastIndexOf('\n', index - 1); + const newlineIndex = input.lastIndexOf("\n", index - 1); return newlineIndex === -1 ? 0 : newlineIndex + 1; } function lineIndentAt(input, index) { const start = lineStartIndex(input, index); let end = start; - while (end < input.length && (input[end] === ' ' || input[end] === '\t')) { + while (end < input.length && (input[end] === " " || input[end] === "\t")) { end += 1; } return input.slice(start, end); @@ -466,12 +469,12 @@ function lineIndentAt(input, index) { function renderValue(value, propertyIndent, indentUnit, eol) { const raw = JSON.stringify(value, null, indentUnit); - if (!raw.includes('\n')) { + if (!raw.includes("\n")) { return raw; } return raw - .split('\n') + .split("\n") .map((line, index) => (index === 0 ? line : `${propertyIndent}${line}`)) .join(eol); } @@ -488,19 +491,35 @@ function getObjectChildIndent(input, objectNode, indentUnit) { return `${lineIndentAt(input, objectNode.start)}${indentUnit}`; } -function buildMergeEdits(input, objectNode, managedSettings, indentUnit, eol, edits) { +function buildMergeEdits( + input, + objectNode, + managedSettings, + indentUnit, + eol, + edits, +) { const missingEntries = []; for (const [key, managedValue] of Object.entries(managedSettings)) { - const property = objectNode.properties.find((candidate) => candidate.key === key); + const property = objectNode.properties.find( + (candidate) => candidate.key === key, + ); if (!property) { missingEntries.push([key, managedValue]); continue; } - if (isPlainObject(managedValue) && property.value.type === 'object') { - buildMergeEdits(input, property.value, managedValue, indentUnit, eol, edits); + if (isPlainObject(managedValue) && property.value.type === "object") { + buildMergeEdits( + input, + property.value, + managedValue, + indentUnit, + eol, + edits, + ); continue; } @@ -520,14 +539,18 @@ function buildMergeEdits(input, objectNode, managedSettings, indentUnit, eol, ed const propertyIndent = getObjectChildIndent(input, objectNode, indentUnit); const closingIndent = lineIndentAt(input, objectNode.end); + const closingBraceIndex = objectNode.end - 1; const renderedProperties = missingEntries - .map(([key, value]) => `${propertyIndent}${renderProperty(key, value, propertyIndent, indentUnit, eol)}`) + .map( + ([key, value]) => + `${propertyIndent}${renderProperty(key, value, propertyIndent, indentUnit, eol)}`, + ) .join(`,${eol}`); if (objectNode.properties.length === 0) { edits.push({ start: objectNode.start + 1, - end: objectNode.end, + end: closingBraceIndex, text: `${eol}${renderedProperties}${eol}${closingIndent}`, }); return; @@ -538,7 +561,7 @@ function buildMergeEdits(input, objectNode, managedSettings, indentUnit, eol, ed edits.push({ start: lastProperty.value.end, end: lastProperty.value.end, - text: ',', + text: ",", }); } @@ -558,27 +581,36 @@ function applyEdits(input, edits) { return edits .sort((left, right) => right.start - left.start || right.end - left.end) .reduce( - (text, edit) => `${text.slice(0, edit.start)}${edit.text}${text.slice(edit.end)}`, - input + (text, edit) => + `${text.slice(0, edit.start)}${edit.text}${text.slice(edit.end)}`, + input, ); } function main() { const options = parseArgs(process.argv.slice(2)); - const templateText = fs.readFileSync(options.template, 'utf8'); + const templateText = fs.readFileSync(options.template, "utf8"); const renderedTemplate = renderTemplate(templateText, options.replacements); const managedSettings = parseJsonc(renderedTemplate, options.template); const targetText = fs.existsSync(options.target) - ? fs.readFileSync(options.target, 'utf8') - : '{}\n'; + ? fs.readFileSync(options.target, "utf8") + : "{}\n"; const targetAst = parseJsoncAst(targetText, options.target); const indentUnit = detectIndentUnit(targetText); const eol = detectEol(targetText); const edits = []; - buildMergeEdits(targetText, targetAst, managedSettings, indentUnit, eol, edits); - const output = edits.length === 0 ? targetText : applyEdits(targetText, edits); + buildMergeEdits( + targetText, + targetAst, + managedSettings, + indentUnit, + eol, + edits, + ); + const output = + edits.length === 0 ? targetText : applyEdits(targetText, edits); parseJsonc(output, options.target); @@ -589,8 +621,8 @@ function main() { return; } - fs.writeFileSync(options.target, output, 'utf8'); + fs.writeFileSync(options.target, output, "utf8"); console.log(`Merged managed VS Code settings into: ${options.target}`); } -main(); \ No newline at end of file +main(); diff --git a/install/merge-vscode-settings.test.mjs b/install/merge-vscode-settings.test.mjs index d4ef147..72410c0 100644 --- a/install/merge-vscode-settings.test.mjs +++ b/install/merge-vscode-settings.test.mjs @@ -1,18 +1,23 @@ -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import test from 'node:test'; -import { fileURLToPath } from 'node:url'; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(scriptDir, '..'); -const mergeScript = path.join(scriptDir, 'merge-vscode-settings.mjs'); -const templateFile = path.join(repoRoot, 'config', 'vscode', 'settings.template.jsonc'); +const repoRoot = path.resolve(scriptDir, ".."); +const mergeScript = path.join(scriptDir, "merge-vscode-settings.mjs"); +const templateFile = path.join( + repoRoot, + "config", + "vscode", + "settings.template.jsonc", +); function stripJsonComments(input) { - let output = ''; + let output = ""; let inString = false; let escaping = false; let inLineComment = false; @@ -23,7 +28,7 @@ function stripJsonComments(input) { const next = input[index + 1]; if (inLineComment) { - if (char === '\n') { + if (char === "\n") { inLineComment = false; output += char; } @@ -31,10 +36,10 @@ function stripJsonComments(input) { } if (inBlockComment) { - if (char === '*' && next === '/') { + if (char === "*" && next === "/") { inBlockComment = false; index += 1; - } else if (char === '\n' || char === '\r') { + } else if (char === "\n" || char === "\r") { output += char; } continue; @@ -44,7 +49,7 @@ function stripJsonComments(input) { output += char; if (escaping) { escaping = false; - } else if (char === '\\') { + } else if (char === "\\") { escaping = true; } else if (char === '"') { inString = false; @@ -58,13 +63,13 @@ function stripJsonComments(input) { continue; } - if (char === '/' && next === '/') { + if (char === "/" && next === "/") { inLineComment = true; index += 1; continue; } - if (char === '/' && next === '*') { + if (char === "/" && next === "*") { inBlockComment = true; index += 1; continue; @@ -77,7 +82,7 @@ function stripJsonComments(input) { } function stripTrailingCommas(input) { - let output = ''; + let output = ""; let inString = false; let escaping = false; @@ -88,7 +93,7 @@ function stripTrailingCommas(input) { output += char; if (escaping) { escaping = false; - } else if (char === '\\') { + } else if (char === "\\") { escaping = true; } else if (char === '"') { inString = false; @@ -102,12 +107,12 @@ function stripTrailingCommas(input) { continue; } - if (char === ',') { + if (char === ",") { let lookahead = index + 1; while (lookahead < input.length && /\s/.test(input[lookahead])) { lookahead += 1; } - if (input[lookahead] === '}' || input[lookahead] === ']') { + if (input[lookahead] === "}" || input[lookahead] === "]") { continue; } } @@ -127,23 +132,23 @@ function runMerge(targetFile) { process.execPath, [ mergeScript, - '--target', + "--target", targetFile, - '--template', + "--template", templateFile, - '--set', - 'COPILOT_RESOURCES_HOME=/repo/home', + "--set", + "COPILOT_RESOURCES_HOME=/repo/home", ], { cwd: repoRoot, - encoding: 'utf8', - } + encoding: "utf8", + }, ); } -test('preserves comments and custom nested entries while inserting managed settings', () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-settings-')); - const targetFile = path.join(tempDir, 'settings.json'); +test("preserves comments and custom nested entries while inserting managed settings", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-settings-")); + const targetFile = path.join(tempDir, "settings.json"); fs.writeFileSync( targetFile, @@ -163,13 +168,13 @@ test('preserves comments and custom nested entries while inserting managed setti } } `, - 'utf8' + "utf8", ); const result = runMerge(targetFile); assert.equal(result.status, 0, result.stderr); - const output = fs.readFileSync(targetFile, 'utf8'); + const output = fs.readFileSync(targetFile, "utf8"); assert.match(output, /\/\/ keep this comment/); assert.match(output, /\/\/ preserve nested comments/); assert.match(output, /\/\/ custom agents stay/); @@ -177,18 +182,32 @@ test('preserves comments and custom nested entries while inserting managed setti assert.match(output, /"\/repo\/home\/resources\/agents": true/); const parsed = parseJsonc(output); - assert.equal(parsed['workbench.colorTheme'], 'GitHub Dark Mode'); - assert.equal(parsed['chat.agentFilesLocations']['/custom/agents'], true); - assert.equal(parsed['chat.agentFilesLocations']['/repo/home/resources/agents'], true); - assert.equal(parsed['chat.agentFilesLocations']['~/.copilot/agents'], true); - assert.equal(parsed['chat.instructionsFilesLocations']['/old/instructions'], true); - assert.equal(parsed['chat.instructionsFilesLocations']['/repo/home/resources/instructions'], true); - assert.equal(parsed['chat.instructionsFilesLocations']['~/.claude/rules'], true); + assert.equal(parsed["workbench.colorTheme"], "GitHub Dark Mode"); + assert.equal(parsed["chat.agentFilesLocations"]["/custom/agents"], true); + assert.equal( + parsed["chat.agentFilesLocations"]["/repo/home/resources/agents"], + true, + ); + assert.equal(parsed["chat.agentFilesLocations"]["~/.copilot/agents"], true); + assert.equal( + parsed["chat.instructionsFilesLocations"]["/old/instructions"], + true, + ); + assert.equal( + parsed["chat.instructionsFilesLocations"][ + "/repo/home/resources/instructions" + ], + true, + ); + assert.equal( + parsed["chat.instructionsFilesLocations"]["~/.claude/rules"], + true, + ); }); -test('second run is idempotent and keeps the file text unchanged', () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-settings-')); - const targetFile = path.join(tempDir, 'settings.json'); +test("second run is idempotent and keeps the file text unchanged", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-settings-")); + const targetFile = path.join(tempDir, "settings.json"); fs.writeFileSync( targetFile, @@ -199,18 +218,40 @@ test('second run is idempotent and keeps the file text unchanged', () => { } } `, - 'utf8' + "utf8", ); const firstRun = runMerge(targetFile); assert.equal(firstRun.status, 0, firstRun.stderr); - const firstOutput = fs.readFileSync(targetFile, 'utf8'); + const firstOutput = fs.readFileSync(targetFile, "utf8"); const secondRun = runMerge(targetFile); assert.equal(secondRun.status, 0, secondRun.stderr); assert.match(secondRun.stdout, /already up to date/); - const secondOutput = fs.readFileSync(targetFile, 'utf8'); + const secondOutput = fs.readFileSync(targetFile, "utf8"); assert.equal(secondOutput, firstOutput); assert.match(secondOutput, /\/\/ user comment/); -}); \ No newline at end of file +}); + +test("creates a valid settings file from an empty target", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-settings-")); + const targetFile = path.join(tempDir, "settings.json"); + + fs.writeFileSync(targetFile, "{}\n", "utf8"); + + const result = runMerge(targetFile); + assert.equal(result.status, 0, result.stderr); + + const parsed = parseJsonc(fs.readFileSync(targetFile, "utf8")); + assert.equal( + parsed["chat.agentFilesLocations"]["/repo/home/resources/agents"], + true, + ); + assert.equal( + parsed["chat.instructionsFilesLocations"][ + "/repo/home/resources/instructions" + ], + true, + ); +}); diff --git a/install/publish.sh b/install/publish.sh old mode 100644 new mode 100755 diff --git a/install/update.ps1 b/install/update.ps1 index a1735ec..ca7b51f 100644 --- a/install/update.ps1 +++ b/install/update.ps1 @@ -9,4 +9,5 @@ if (Test-Path -LiteralPath (Join-Path $RepoRoot '.git')) { Write-Host 'Skipping git pull because this repository is not initialized as a git repository yet.' } +& (Join-Path $ScriptDir 'bootstrap.ps1') & (Join-Path $ScriptDir 'verify.ps1') -Quick diff --git a/install/update.sh b/install/update.sh old mode 100644 new mode 100755 index 1dbc0ff..7b2e787 --- a/install/update.sh +++ b/install/update.sh @@ -12,6 +12,7 @@ main() { printf 'Skipping git pull because this repository is not initialized as a git repository yet.\n' fi + "$script_dir/bootstrap.sh" "$script_dir/verify.sh" --quick } diff --git a/install/verify.ps1 b/install/verify.ps1 index 65776f9..96e96ee 100644 --- a/install/verify.ps1 +++ b/install/verify.ps1 @@ -23,15 +23,52 @@ function Assert-Path { } } +function Assert-Command { + param( + [string]$Label, + [string]$CommandName + ) + + if (Get-Command $CommandName -ErrorAction SilentlyContinue) { + Write-Host "[ok] $Label: $CommandName" + } else { + throw "Missing $Label: $CommandName" + } +} + +function Assert-ReadableFile { + param( + [string]$Label, + [string]$Path + ) + + if ((Test-Path -LiteralPath $Path -PathType Leaf) -and (Get-Item -LiteralPath $Path).PSIsContainer -eq $false) { + Write-Host "[ok] $Label: $Path" + } else { + throw "Unreadable $Label: $Path" + } +} + Assert-Path -Label 'repo root' -Path $RepoRoot Assert-Path -Label 'canonical home' -Path $CanonicalHome Assert-Path -Label 'skills link' -Path (Join-Path $CopilotHome 'skills') Assert-Path -Label 'agents link' -Path (Join-Path $CopilotHome 'agents') Assert-Path -Label 'instructions link' -Path (Join-Path $CopilotHome 'instructions') Assert-Path -Label 'hooks link' -Path (Join-Path $CopilotHome 'hooks') +Assert-Path -Label 'session start hook' -Path (Join-Path $CanonicalHome 'resources\hooks\session-audit.json') +Assert-Path -Label 'session start hook script' -Path (Join-Path $CanonicalHome 'resources\scripts\report-hook-event.sh') +Assert-ReadableFile -Label 'session start hook script' -Path (Join-Path $CanonicalHome 'resources\scripts\report-hook-event.sh') +Assert-Command -Label 'session start hook shell' -CommandName 'bash' Assert-Path -Label 'prompts link' -Path (Join-Path $VscodeUserDir 'prompts') +Assert-Path -Label 'VS Code MCP config' -Path (Join-Path $VscodeUserDir 'mcp.json') +Assert-Path -Label 'Copilot CLI MCP config' -Path (Join-Path $CopilotHome 'mcp-config.json') +Assert-Path -Label 'local MCP overrides' -Path (Join-Path $CanonicalHome '.local\mcp.local.jsonc') if (-not $Quick) { Assert-Path -Label 'VS Code settings template' -Path (Join-Path $RepoRoot 'config\vscode\settings.template.jsonc') Assert-Path -Label 'CLI env template' -Path (Join-Path $RepoRoot 'config\copilot-cli\env.example.ps1') + Assert-Path -Label 'VS Code MCP template' -Path (Join-Path $RepoRoot 'config\mcp\vscode.mcp.template.jsonc') + Assert-Path -Label 'Copilot CLI MCP template' -Path (Join-Path $RepoRoot 'config\mcp\copilot-cli.mcp.template.jsonc') + Assert-Path -Label 'MCP local override example' -Path (Join-Path $RepoRoot 'config\mcp\local-overrides.example.jsonc') + Assert-Path -Label 'Copilot CLI filesystem wrapper' -Path (Join-Path $RepoRoot 'install\mcp\copilot-cli-filesystem-wrapper.mjs') } diff --git a/install/verify.sh b/install/verify.sh old mode 100644 new mode 100755 index d3e318f..8a07dd0 --- a/install/verify.sh +++ b/install/verify.sh @@ -39,6 +39,30 @@ check_path() { fi } +check_command() { + local label="$1" + local command_name="$2" + + if command -v "$command_name" >/dev/null 2>&1; then + printf '[ok] %s: %s\n' "$label" "$command_name" + else + printf '[missing] %s: %s\n' "$label" "$command_name" >&2 + return 1 + fi +} + +check_readable_file() { + local label="$1" + local path="$2" + + if [[ -r "$path" ]]; then + printf '[ok] %s: %s\n' "$label" "$path" + else + printf '[unreadable] %s: %s\n' "$label" "$path" >&2 + return 1 + fi +} + main() { if [[ "${1:-}" == "--quick" ]]; then quick="true" @@ -46,6 +70,11 @@ main() { local vscode_user_dir vscode_user_dir="$(detect_vscode_user_dir)" + local vscode_mcp_file="$vscode_user_dir/mcp.json" + local copilot_cli_mcp_file="$copilot_home/mcp-config.json" + local local_mcp_overrides_file="$canonical_home/.local/mcp.local.jsonc" + local session_start_hook_file="$canonical_home/resources/hooks/session-audit.json" + local session_start_hook_script="$canonical_home/resources/scripts/report-hook-event.sh" check_path "repo root" "$repo_root" check_path "canonical home" "$canonical_home" @@ -53,11 +82,22 @@ main() { check_path "agents link" "$copilot_home/agents" check_path "instructions link" "$copilot_home/instructions" check_path "hooks link" "$copilot_home/hooks" + check_path "session start hook" "$session_start_hook_file" + check_path "session start hook script" "$session_start_hook_script" + check_readable_file "session start hook script" "$session_start_hook_script" + check_command "session start hook shell" "bash" check_path "prompts link" "$vscode_user_dir/prompts" + check_path "VS Code MCP config" "$vscode_mcp_file" + check_path "Copilot CLI MCP config" "$copilot_cli_mcp_file" + check_path "local MCP overrides" "$local_mcp_overrides_file" if [[ "$quick" != "true" ]]; then check_path "VS Code settings template" "$repo_root/config/vscode/settings.template.jsonc" check_path "CLI env template" "$repo_root/config/copilot-cli/env.example.sh" + check_path "VS Code MCP template" "$repo_root/config/mcp/vscode.mcp.template.jsonc" + check_path "Copilot CLI MCP template" "$repo_root/config/mcp/copilot-cli.mcp.template.jsonc" + check_path "MCP local override example" "$repo_root/config/mcp/local-overrides.example.jsonc" + check_path "Copilot CLI filesystem wrapper" "$repo_root/install/mcp/copilot-cli-filesystem-wrapper.mjs" fi } diff --git a/resources/hooks/session-audit.json b/resources/hooks/session-audit.json index 6c2193d..0d45796 100644 --- a/resources/hooks/session-audit.json +++ b/resources/hooks/session-audit.json @@ -3,8 +3,8 @@ "SessionStart": [ { "type": "command", - "osx": "~/.copilot-resources/resources/scripts/report-hook-event.sh", - "linux": "~/.copilot-resources/resources/scripts/report-hook-event.sh", + "osx": "bash ~/.copilot-resources/resources/scripts/report-hook-event.sh", + "linux": "bash ~/.copilot-resources/resources/scripts/report-hook-event.sh", "windows": "powershell -NoProfile -File \"$HOME/.copilot-resources/resources/scripts/report-hook-event.ps1\"" } ] diff --git a/resources/instructions/token-efficient-authoring.instructions.md b/resources/instructions/token-efficient-authoring.instructions.md new file mode 100644 index 0000000..60eb316 --- /dev/null +++ b/resources/instructions/token-efficient-authoring.instructions.md @@ -0,0 +1,15 @@ +--- +description: "Use when authoring or editing shared Copilot resources that should stay lightweight and inexpensive to run." +applyTo: "resources/prompts/**/*.prompt.md,resources/instructions/**/*.instructions.md,resources/agents/**/*.agent.md,resources/skills/**/SKILL.md" +--- + +- Reuse an existing shared resource before creating a new one. +- Prefer the cheapest sufficient primitive: instruction for durable rules, skill + for portable workflows, prompt for thin VS Code entrypoints, hook only for + deterministic enforced behavior. +- Keep frontmatter, examples, and repeated policy text short. +- Ask for narrow inputs such as a file, symbol, or command instead of broad + workspace scans when possible. +- Bias the resource toward concise outputs unless the task clearly needs depth. +- Say when the resource should not be used if that prevents broad, expensive + misuse. \ No newline at end of file diff --git a/resources/mcp/README.md b/resources/mcp/README.md index 663bcff..4bd5ded 100644 --- a/resources/mcp/README.md +++ b/resources/mcp/README.md @@ -1,5 +1,38 @@ # MCP Notes -This folder is reserved for reusable MCP references and safe shared -configuration snippets. Machine-specific secrets and authenticated local server -definitions should stay out of the repository. +This repository manages shared MCP configuration through tracked templates plus +machine-local overrides. + +## What Is Tracked + +- Shared templates live in `config/mcp/`. +- The merge logic lives in `install/merge-managed-mcp-config.mjs`. +- The Copilot CLI filesystem wrapper lives in `install/mcp/`. + +## What Stays Local + +- Machine-local values live in `.local/mcp.local.jsonc`. +- Secrets stay in that local file and are never committed. +- Bootstrap creates `.local/mcp.local.jsonc` from + `config/mcp/local-overrides.example.jsonc` if it does not exist yet. + +## Generated Outputs + +- VS Code user MCP config: user-profile `mcp.json` +- Copilot CLI user MCP config: `~/.copilot/mcp-config.json` + +Bootstrap and update regenerate those managed files while preserving unmanaged +entries already present in the user config. + +## Managed Servers + +- Playwright: generated for VS Code with `npx @playwright/mcp@latest` +- Filesystem: generated for VS Code with Docker and `${workspaceFolder}` binding +- Filesystem: generated for Copilot CLI with a repo-owned Node wrapper that + binds the current working directory into Docker +- Gitea/Forgejo: generated for VS Code and Copilot CLI with + `ronmi/forgejo-mcp`, but only when `.local/mcp.local.jsonc` enables it and + provides `serverUrl` plus `token` + +Copilot CLI Playwright is not generated here because Copilot CLI already ships a +built-in Playwright MCP server. diff --git a/resources/prompts/audit-copilot-usage.prompt.md b/resources/prompts/audit-copilot-usage.prompt.md new file mode 100644 index 0000000..25e59e1 --- /dev/null +++ b/resources/prompts/audit-copilot-usage.prompt.md @@ -0,0 +1,17 @@ +--- +name: "audit-copilot-usage" +description: "Run the local Copilot reuse audit and review candidates for promotion into the shared resource repo." +agent: "agent" +tools: [read, search, execute] +argument-hint: "days= workspace= sources=" +--- + +Run the repository audit workflow by using the shared audit script. + +Requirements: + +- Prefer `resources/scripts/audit-copilot-usage.sh` over ad hoc searching. +- Keep the audit itself read-only. +- Review `audit-summary.md`, `candidates-report.tsv`, `selection-manifest.tsv`, and `pattern-details/`. +- Recommend promotion only for portable resources; keep machine-specific or + repo-specific patterns in audit notes. \ No newline at end of file diff --git a/resources/prompts/prepare-audit-promotions.prompt.md b/resources/prompts/prepare-audit-promotions.prompt.md new file mode 100644 index 0000000..3ce7b0c --- /dev/null +++ b/resources/prompts/prepare-audit-promotions.prompt.md @@ -0,0 +1,16 @@ +--- +name: "prepare-audit-promotions" +description: "Generate draft resources or staging notes from approved audit manifest rows." +agent: "agent" +tools: [read, search, execute] +argument-hint: "audit=" +--- + +Generate promotion drafts from the audit approval surface by using the shared repository script. + +Requirements: + +- Prefer `resources/scripts/prepare-audit-promotions.sh` over manual translation. +- Treat `promote-*`, `template-only`, and `docs-only` rows as approved when their `decision` column is non-empty. +- Treat `discard`, `needs-sanitization`, and empty decisions as non-approved. +- Review `promotion-summary.md` after the script runs and summarize the generated draft resources and staging notes. \ No newline at end of file diff --git a/resources/prompts/review-audit-candidates.prompt.md b/resources/prompts/review-audit-candidates.prompt.md new file mode 100644 index 0000000..a9e0f3c --- /dev/null +++ b/resources/prompts/review-audit-candidates.prompt.md @@ -0,0 +1,20 @@ +--- +name: "review-audit-candidates" +description: "Review an audit shortlist one candidate at a time and update the selection manifest with decisions and notes." +agent: "agent" +tools: [read, search, edit] +argument-hint: "audit=" +--- + +Review the audit shortlist one candidate at a time. + +Requirements: + +- Resolve the target audit directory from `audit=` when provided, otherwise use the latest run under `.local/audits/`. +- Read `selection-manifest.tsv` first, then open each pending candidate's `detail_file` before asking for a decision. +- Summarize the candidate's purpose, expected benefit, audit context, and why it scored as it did before asking for a decision. +- Mention transcript prompt-cost proxy fields when they are present, but label them as cost signals rather than exact token counts. +- If the detail bundle has weak or missing evidence, say that explicitly and explain what the user would be deciding on anyway. +- Use `vscode_askQuestions` to present decision choices as buttons and allow freeform text for `review_note`. +- Update `selection-manifest.tsv` after each answer so review progress survives interruptions. +- Stop only when every row has a non-empty `decision` or the user tells you to stop. \ No newline at end of file diff --git a/resources/scripts/audit-copilot-usage.mjs b/resources/scripts/audit-copilot-usage.mjs new file mode 100644 index 0000000..4879f8d --- /dev/null +++ b/resources/scripts/audit-copilot-usage.mjs @@ -0,0 +1,1486 @@ +#!/usr/bin/env node +// @ts-nocheck + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DEFAULT_DAYS = 30; +const DEFAULT_LIMIT = 50; +const DEFAULT_SOURCES = ['publish-log', 'repo-memories', 'transcripts']; +const TRANSCRIPT_KEYWORDS = [ + 'agent', + 'audit', + 'bootstrap', + 'checklist', + 'hook', + 'instruction', + 'prompt', + 'publish', + 'review', + 'script', + 'setup', + 'skill', + 'template', + 'workflow', +]; + +function usage() { + console.error(`Usage: resources/scripts/audit-copilot-usage.sh [options] + +Options: + --days Audit the last days. Default: ${DEFAULT_DAYS} + --since Audit from an explicit ISO 8601 timestamp. + --workspace Only include workspaces whose path contains . + --exclude-workspace

Exclude a workspace path prefix. Repeatable. Defaults to the repo root. + --include-sources Comma-separated list: publish-log,repo-memories,transcripts. + --limit Max number of candidates to emit. Default: ${DEFAULT_LIMIT} + --machine-id Override the machine id used in the output path. + --output-dir Write audit files into an explicit output directory. + --repo-root Internal override for the repository root. + --help Show this help text. +`); +} + +function parseArgs(argv) { + const options = { + days: DEFAULT_DAYS, + excludeWorkspaces: [], + includeSources: [...DEFAULT_SOURCES], + limit: DEFAULT_LIMIT, + repoRoot: process.cwd(), + workspaceFilter: '', + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--help') { + usage(); + process.exit(0); + } + + if (arg === '--days') { + options.days = Number(argv[index + 1]); + index += 1; + continue; + } + + if (arg === '--since') { + options.since = argv[index + 1]; + index += 1; + continue; + } + + if (arg === '--workspace') { + options.workspaceFilter = argv[index + 1] ?? ''; + index += 1; + continue; + } + + if (arg === '--exclude-workspace') { + options.excludeWorkspaces.push(argv[index + 1] ?? ''); + index += 1; + continue; + } + + if (arg === '--include-sources') { + options.includeSources = (argv[index + 1] ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + index += 1; + continue; + } + + if (arg === '--limit') { + options.limit = Number(argv[index + 1]); + index += 1; + continue; + } + + if (arg === '--machine-id') { + options.machineId = argv[index + 1] ?? ''; + index += 1; + continue; + } + + if (arg === '--output-dir') { + options.outputDir = argv[index + 1] ?? ''; + index += 1; + continue; + } + + if (arg === '--repo-root') { + options.repoRoot = argv[index + 1] ?? options.repoRoot; + index += 1; + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + if (!Number.isFinite(options.days) || options.days <= 0) { + throw new Error('--days must be a positive integer.'); + } + + if (!Number.isFinite(options.limit) || options.limit <= 0) { + throw new Error('--limit must be a positive integer.'); + } + + const includeSources = new Set(); + for (const source of options.includeSources) { + if (source === 'all') { + DEFAULT_SOURCES.forEach((entry) => includeSources.add(entry)); + continue; + } + + if (!DEFAULT_SOURCES.includes(source)) { + throw new Error(`Unsupported source: ${source}`); + } + + includeSources.add(source); + } + + options.includeSources = includeSources.size === 0 ? [...DEFAULT_SOURCES] : [...includeSources]; + options.repoRoot = path.resolve(options.repoRoot); + const normalizedExcludes = [options.repoRoot, ...options.excludeWorkspaces] + .map((entry) => path.resolve(entry)) + .filter((entry, index, entries) => entry && entries.indexOf(entry) === index); + options.excludeWorkspaces = normalizedExcludes; + return options; +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function sanitizeMachineId(input) { + const source = input || os.hostname() || 'local-machine'; + const sanitized = source + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return sanitized || 'local-machine'; +} + +function buildTimestamp(now) { + return now.toISOString().replace(/:/g, '-'); +} + +function getSinceTimestamp(options, now) { + if (options.since) { + const parsed = Date.parse(options.since); + if (Number.isNaN(parsed)) { + throw new Error(`Invalid --since timestamp: ${options.since}`); + } + return parsed; + } + + return now.getTime() - options.days * 24 * 60 * 60 * 1000; +} + +function readJsonFile(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return null; + } +} + +function safeStat(filePath) { + try { + return fs.statSync(filePath); + } catch { + return null; + } +} + +function fileUriToPath(uri) { + try { + return fileURLToPath(uri); + } catch { + return null; + } +} + +function listDirectories(rootPath) { + if (!fs.existsSync(rootPath)) { + return []; + } + + return fs + .readdirSync(rootPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +} + +function isExcludedWorkspace(folderPath, excludedRoots) { + return excludedRoots.some((excludedRoot) => { + if (!excludedRoot) { + return false; + } + + return folderPath === excludedRoot || folderPath.startsWith(`${excludedRoot}${path.sep}`); + }); +} + +function cleanTsvCell(value) { + return String(value ?? '').replace(/[\t\n\r]+/g, ' ').trim(); +} + +function slugify(value) { + return String(value ?? '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) || 'candidate'; +} + +function compactText(value, maxLength = 88) { + const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, maxLength - 3)}...`; +} + +function formatIso(value) { + return new Date(value).toISOString(); +} + +function formatMetric(value) { + return value === null || value === undefined ? 'n/a' : String(value); +} + +function escapeMarkdownTable(value) { + return cleanTsvCell(value).replace(/\|/g, '\\|'); +} + +function wrapCodeFence(value) { + return ['```text', value.trimEnd(), '```'].join('\n'); +} + +function formatOutcomeCounts(historyEntries) { + const counts = new Map(); + for (const entry of historyEntries ?? []) { + counts.set(entry.outcome, (counts.get(entry.outcome) ?? 0) + 1); + } + + return [...counts.entries()].map(([outcome, count]) => `${outcome}=${count}`); +} + +function describeCandidateBenefit(candidate) { + switch (candidate.suggestion) { + case 'promote-skill': + return 'A shared skill preserves a reusable workflow so you do not have to restate the same process in fresh prompts or per-repo notes.'; + case 'promote-instruction': + return 'A shared instruction turns repeated guidance into a durable rule set that applies across future sessions.'; + case 'promote-agent': + return 'A shared agent packages this behavior as a repeatable working mode instead of relying on ad hoc prompt wording.'; + case 'promote-hook': + return 'A shared hook can enforce or observe the workflow automatically instead of relying on manual steps.'; + case 'promote-script': + return 'A shared script moves repeated operational work out of chat and into deterministic automation.'; + case 'promote-prompt': + return 'A shared prompt creates a stable entrypoint for a workflow you already reach for, reducing prompt rewriting.'; + case 'template-only': + return 'A shared template captures the structure of the pattern without forcing it into a runtime resource prematurely.'; + case 'docs-only': + return 'Documentation keeps the insight discoverable even when it is not stable enough to become a shared runtime artifact.'; + default: + return 'This candidate may represent a reusable pattern worth keeping in a more discoverable shared form.'; + } +} + +function describeCandidateContext(candidate, evidence) { + if (candidate.sourceType === 'publish-log') { + const outcomeCounts = formatOutcomeCounts(candidate.historyEntries); + const lines = [ + 'This candidate was selected from actual publish history, which is stronger evidence than a one-off prompt or note.', + `The same artifact fingerprint was processed ${candidate.recurrence} time(s) during the audit window${outcomeCounts.length === 0 ? '.' : ` (${outcomeCounts.join(', ')}).`}`, + `That means you kept returning to the same shared-${candidate.category} target rather than inventing a new one each time.`, + ]; + + if (candidate.sourceRefs[0]) { + lines.push(`The publish target recorded in history was ${candidate.sourceRefs[0]}.`); + } + + if (!evidence.evidencePath) { + lines.push('The current target path is not available locally now, so this bundle is explaining a historical reuse signal rather than showing a live artifact snapshot.'); + } + + return lines; + } + + if (candidate.sourceType === 'repo-memory') { + return [ + 'This candidate came from persisted repo memory, which means it was important enough to save as a reusable note during prior work.', + `It appeared in ${candidate.workspaceCount} workspace(s) during the audit window and remained present as stored working context.`, + ]; + } + + return [ + 'This candidate came from repeated user request patterns in persisted transcripts.', + `It recurred ${candidate.recurrence} time(s) across ${candidate.workspaceCount} workspace(s), which suggests a workflow you may want to standardize.`, + ]; +} + +function buildScoreExplanation(candidate) { + const portabilityLines = [`Base portability starts at ${portabilityBaseForSuggestion(candidate.suggestion)} for ${candidate.suggestion}.`]; + if (candidate.sourceType === 'publish-log') { + portabilityLines.push('+18 because publish history is strong proof that the pattern already became a shared artifact at least once.'); + } + if (candidate.workspaceCount > 1) { + portabilityLines.push(`+8 because it showed up across ${candidate.workspaceCount} workspaces.`); + } + if (candidate.recurrence > 1) { + portabilityLines.push(`+5 because it recurred ${candidate.recurrence} times in the audit window.`); + } + if (candidate.repoSpecific) { + portabilityLines.push('-20 because the content looks repo-specific or machine-specific.'); + } + + const maturityBase = candidate.sourceType === 'publish-log' + ? 90 + : candidate.sourceType === 'repo-memory' + ? 70 + : 55; + const maturityLines = [`Base maturity starts at ${maturityBase} for ${candidate.sourceType}.`]; + if (candidate.recurrence > 1) { + maturityLines.push('+5 recurrence bonus because the same pattern came back more than once.'); + } + + const recurrenceScore = Math.min(100, candidate.recurrence * 20); + const workspaceScore = Math.min(100, candidate.workspaceCount * 25); + const formulaText = `round(${candidate.portabilityScore}*0.45 + ${candidate.maturityScore}*0.20 + ${recurrenceScore}*0.20 + ${workspaceScore}*0.15) = ${candidate.valueRank}`; + + return { + portabilityLines, + maturityLines, + recurrenceScore, + workspaceScore, + formulaText, + }; +} + +function describeTokenCostSignal(candidate) { + if (candidate.sourceType !== 'transcript') { + return [ + 'This candidate does not come from transcript prompt text, so no prompt-length cost proxy was computed.', + ]; + } + + return [ + 'This is a prompt-length proxy derived from persisted user message characters, not exact billed token usage.', + `Average prompt chars: ${formatMetric(candidate.avgPromptChars)}`, + `Repeated prompt chars in the audit window: ${formatMetric(candidate.repeatedPromptChars)}`, + `Signal: ${candidate.tokenCostSignal}`, + ]; +} + +function buildCandidateCaveats(candidate, evidence) { + const caveats = []; + if (!evidence.evidencePath) { + caveats.push('No local artifact preview was available, so the review should rely more on audit context and scoring than on content inspection.'); + } + if (candidate.workspaceCount === 0 && candidate.sourceType === 'publish-log') { + caveats.push('Workspace count is zero because publish-log candidates are repo-level events, not workspace-indexed transcript or memory events.'); + } + if (candidate.repoSpecific) { + caveats.push('The scoring model detected repo-specific or machine-specific signals, so promote carefully.'); + } + if (candidate.sourceType === 'transcript') { + caveats.push('Prompt-size fields are character-count proxies from persisted transcript text, not exact token billing data.'); + } + return caveats; +} + +function buildHistoryLines(candidate) { + if (candidate.sourceType !== 'publish-log' || !candidate.historyEntries?.length) { + return []; + } + + return candidate.historyEntries.map((entry) => { + const sourcePart = entry.source ? ` :: source=${entry.source}` : ''; + return `- ${entry.timestamp} :: ${entry.outcome}${sourcePart}`; + }); +} + +function readFirstExistingEvidencePath(candidate) { + for (const sourceRef of candidate.sourceRefs) { + if (!sourceRef) { + continue; + } + + if (candidate.sourceType === 'publish-log') { + const stat = safeStat(sourceRef); + if (stat?.isDirectory()) { + const skillFile = path.join(sourceRef, 'SKILL.md'); + if (fs.existsSync(skillFile)) { + return skillFile; + } + } + } + + if (fs.existsSync(sourceRef)) { + return sourceRef; + } + } + + return null; +} + +function buildTranscriptEvidence(filePath) { + const userMessages = []; + const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/).filter(Boolean); + + for (const line of lines) { + try { + const event = JSON.parse(line); + if (event.type === 'user.message' && typeof event.data?.content === 'string') { + userMessages.push(compactText(event.data.content, 220)); + } + } catch { + continue; + } + + if (userMessages.length >= 3) { + break; + } + } + + if (userMessages.length === 0) { + return 'No transcript evidence preview was available.'; + } + + return userMessages.map((message, index) => `${index + 1}. ${message}`).join('\n'); +} + +function buildFileEvidence(filePath) { + const lines = fs + .readFileSync(filePath, 'utf8') + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line.trim()) + .slice(0, 20); + + if (lines.length === 0) { + return 'No evidence preview was available.'; + } + + return lines.join('\n'); +} + +function buildEvidenceBundle(candidate) { + const evidencePath = readFirstExistingEvidencePath(candidate); + if (!evidencePath) { + return { + evidencePath: null, + preview: 'No local evidence preview was available for this candidate.', + }; + } + + const preview = candidate.sourceType === 'transcript' + ? buildTranscriptEvidence(evidencePath) + : buildFileEvidence(evidencePath); + + return { evidencePath, preview }; +} + +function writeCandidateDetailFiles(detailsDir, candidates) { + ensureDir(detailsDir); + + for (const candidate of candidates) { + const detailFileName = `${candidate.id}.md`; + const detailFilePath = path.join(detailsDir, detailFileName); + const detailFileRelativePath = path.join('pattern-details', detailFileName); + const evidence = buildEvidenceBundle(candidate); + const scoreExplanation = buildScoreExplanation(candidate); + const caveats = buildCandidateCaveats(candidate, evidence); + const historyLines = buildHistoryLines(candidate); + const lines = [ + `# ${candidate.title}`, + '', + `- Candidate ID: ${candidate.id}`, + `- Suggested action: ${candidate.suggestion}`, + `- Category: ${candidate.category}`, + `- Source type: ${candidate.sourceType}`, + `- Value rank: ${candidate.valueRank}`, + `- Portability score: ${candidate.portabilityScore}`, + `- Maturity score: ${candidate.maturityScore}`, + `- Recurrence: ${candidate.recurrence}`, + `- Workspace count: ${candidate.workspaceCount}`, + `- First seen: ${candidate.firstSeen}`, + `- Last seen: ${candidate.lastSeen}`, + `- Token cost signal: ${candidate.tokenCostSignal}`, + `- Avg prompt chars: ${formatMetric(candidate.avgPromptChars)}`, + `- Repeated prompt chars: ${formatMetric(candidate.repeatedPromptChars)}`, + '', + ]; + + if (evidence.evidencePath) { + lines.push( + '## Evidence Path', + '', + `- ${evidence.evidencePath}`, + '', + ); + } + + lines.push( + '## Recommendation', + '', + `Suggested outcome: ${candidate.suggestion}`, + '', + '## Potential Benefit', + '', + describeCandidateBenefit(candidate), + '', + '## Audit Context', + '', + ...describeCandidateContext(candidate, evidence), + '', + '## Token Cost Signal', + '', + ...describeTokenCostSignal(candidate), + '', + '## Why It Ranked This Highly', + '', + `- Portability score: ${candidate.portabilityScore}`, + ...scoreExplanation.portabilityLines.map((line) => ` - ${line}`), + `- Maturity score: ${candidate.maturityScore}`, + ...scoreExplanation.maturityLines.map((line) => ` - ${line}`), + `- Recurrence score contribution uses ${scoreExplanation.recurrenceScore} from ${candidate.recurrence} occurrence(s).`, + `- Workspace spread contribution uses ${scoreExplanation.workspaceScore} from ${candidate.workspaceCount} workspace(s).`, + `- Value rank formula: ${scoreExplanation.formulaText}`, + '', + '## Notes', + '', + candidate.notes || 'No additional notes were captured.', + '', + '## Review Caveats', + '', + ...(caveats.length === 0 ? ['- No material caveats were detected for this candidate.'] : caveats.map((line) => `- ${line}`)), + '', + '## Source References', + '', + ...candidate.sourceRefs.map((sourceRef) => `- ${sourceRef}`), + '', + '## History', + '', + ...(historyLines.length === 0 ? ['- No additional event history was captured for this candidate type.'] : historyLines), + '', + '## Evidence Preview', + '', + wrapCodeFence(evidence.preview), + ); + + fs.writeFileSync(detailFilePath, `${lines.join('\n')}\n`, 'utf8'); + candidate.detailFile = detailFileRelativePath; + } +} + +function buildWorkspaceIndex(rootPath, workspaceFilter, excludedRoots) { + const workspaces = []; + + for (const storageId of listDirectories(rootPath)) { + const storagePath = path.join(rootPath, storageId); + const workspaceJson = readJsonFile(path.join(storagePath, 'workspace.json')); + if (!workspaceJson?.folder) { + continue; + } + + const folderPath = fileUriToPath(workspaceJson.folder); + if (!folderPath) { + continue; + } + + if (isExcludedWorkspace(folderPath, excludedRoots)) { + continue; + } + + if ( + workspaceFilter && + !folderPath.includes(workspaceFilter) && + !storageId.includes(workspaceFilter) && + !path.basename(folderPath).includes(workspaceFilter) + ) { + continue; + } + + workspaces.push({ + folderPath, + name: path.basename(folderPath), + storageId, + storagePath, + }); + } + + return workspaces.sort((left, right) => left.name.localeCompare(right.name)); +} + +function normalizePromptText(content) { + return content + .toLowerCase() + .replace(/[`*_>#]/g, ' ') + .replace(/[^a-z0-9/ ._-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function hasTranscriptKeyword(content) { + const normalized = normalizePromptText(content); + return TRANSCRIPT_KEYWORDS.some((keyword) => normalized.includes(keyword)); +} + +function extractMarkdownTitle(content, fallback) { + const lines = content.split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('# ')) { + return trimmed.slice(2).trim(); + } + } + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('-') && !trimmed.startsWith('*')) { + return trimmed; + } + } + + return fallback; +} + +function inferSuggestion(text) { + const lower = text.toLowerCase(); + + if (/(prompt)/.test(lower)) { + return 'promote-prompt'; + } + if (/(instruction|rule|standard|policy|checklist)/.test(lower)) { + return 'promote-instruction'; + } + if (/(agent)/.test(lower)) { + return 'promote-agent'; + } + if (/(hook)/.test(lower)) { + return 'promote-hook'; + } + if (/(script|shell|cli)/.test(lower)) { + return 'promote-script'; + } + if (/(template|overlay)/.test(lower)) { + return 'template-only'; + } + if (/(skill|workflow|audit|bootstrap|publish|setup)/.test(lower)) { + return 'promote-skill'; + } + if (/(guide|reference|docs|architecture|notes?)/.test(lower)) { + return 'docs-only'; + } + + return 'docs-only'; +} + +function inferCategory(suggestion) { + if (suggestion.startsWith('promote-')) { + return suggestion.slice('promote-'.length); + } + if (suggestion === 'template-only') { + return 'template'; + } + if (suggestion === 'docs-only') { + return 'documentation'; + } + return 'candidate'; +} + +function likelyRepoSpecific(text, workspaceNames) { + const lower = text.toLowerCase(); + if (/\/users\/|library\/application support|developer\//.test(lower)) { + return true; + } + + return workspaceNames.some((name) => { + const normalized = name.toLowerCase(); + return normalized.length > 4 && lower.includes(normalized); + }); +} + +function portabilityBaseForSuggestion(suggestion) { + switch (suggestion) { + case 'promote-skill': + return 78; + case 'promote-instruction': + return 74; + case 'promote-agent': + return 70; + case 'promote-hook': + return 66; + case 'promote-script': + return 68; + case 'promote-prompt': + return 58; + case 'template-only': + return 60; + case 'docs-only': + return 52; + default: + return 45; + } +} + +function computePortabilityScore({ sourceType, suggestion, recurrence, workspaceCount, repoSpecific }) { + let score = portabilityBaseForSuggestion(suggestion); + + if (sourceType === 'publish-log') { + score += 18; + } + if (workspaceCount > 1) { + score += 8; + } + if (recurrence > 1) { + score += 5; + } + if (repoSpecific) { + score -= 20; + } + + return Math.max(0, Math.min(100, score)); +} + +function computeMaturityScore(sourceType, recurrence) { + let score = 55; + if (sourceType === 'publish-log') { + score = 90; + } else if (sourceType === 'repo-memory') { + score = 70; + } + + if (recurrence > 1) { + score += 5; + } + + return Math.min(100, score); +} + +function computeValueRank({ portabilityScore, maturityScore, recurrence, workspaceCount }) { + const recurrenceScore = Math.min(100, recurrence * 20); + const workspaceScore = Math.min(100, workspaceCount * 25); + return Math.min( + 100, + Math.round( + portabilityScore * 0.45 + + maturityScore * 0.2 + + recurrenceScore * 0.2 + + workspaceScore * 0.15, + ), + ); +} + + function defaultTokenCostMetrics() { + return { + avgPromptChars: null, + repeatedPromptChars: null, + tokenCostSignal: 'n/a', + }; + } + + function computeTranscriptTokenCostMetrics({ recurrence, promptCharsTotal, promptCharsMax }) { + if (!recurrence || !promptCharsTotal) { + return defaultTokenCostMetrics(); + } + + const avgPromptChars = Math.round(promptCharsTotal / recurrence); + let tokenCostSignal = 'low'; + + if (promptCharsTotal >= 2400 || avgPromptChars >= 360 || promptCharsMax >= 500) { + tokenCostSignal = 'very-high'; + } else if (promptCharsTotal >= 1200 || avgPromptChars >= 220 || promptCharsMax >= 320) { + tokenCostSignal = 'high'; + } else if (promptCharsTotal >= 500 || avgPromptChars >= 120 || promptCharsMax >= 180) { + tokenCostSignal = 'medium'; + } + + return { + avgPromptChars, + repeatedPromptChars: promptCharsTotal, + tokenCostSignal, + }; + } + + function tokenCostSignalRank(signal) { + switch (signal) { + case 'very-high': + return 4; + case 'high': + return 3; + case 'medium': + return 2; + case 'low': + return 1; + default: + return 0; + } + } + +function titleFromTarget(kind, targetPath) { + const base = path.basename(targetPath); + if (kind === 'skill') { + return base; + } + + return base + .replace(/\.prompt\.md$/i, '') + .replace(/\.instructions\.md$/i, '') + .replace(/\.agent\.md$/i, '') + .replace(/\.json$/i, ''); +} + +function collectPublishLogCandidates(filePath, sinceTimestamp, excludedRoots) { + const stats = { + candidateCount: 0, + scannedEntries: 0, + }; + if (!fs.existsSync(filePath)) { + return { candidates: [], stats }; + } + + const groups = new Map(); + const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/).filter(Boolean); + + for (const line of lines) { + const [timestamp, kind, source, target, origin, fingerprint, outcome] = line.split('\t'); + const eventTime = Date.parse(timestamp); + if (Number.isNaN(eventTime) || eventTime < sinceTimestamp) { + continue; + } + + if (target && isExcludedWorkspace(path.resolve(target), excludedRoots)) { + continue; + } + + stats.scannedEntries += 1; + const key = `${kind}\t${target}\t${fingerprint}`; + if (!groups.has(key)) { + groups.set(key, { + fingerprint, + firstSeen: eventTime, + historyEntries: [], + kind, + lastSeen: eventTime, + origin, + outcomes: new Map(), + recurrence: 0, + sourceRefs: new Set(), + target, + }); + } + + const group = groups.get(key); + group.recurrence += 1; + group.firstSeen = Math.min(group.firstSeen, eventTime); + group.lastSeen = Math.max(group.lastSeen, eventTime); + group.sourceRefs.add(target); + group.historyEntries.push({ + origin, + outcome, + source, + target, + timestamp: formatIso(eventTime), + }); + group.outcomes.set(outcome, (group.outcomes.get(outcome) ?? 0) + 1); + } + + const candidates = [...groups.values()].map((group) => { + const suggestion = `promote-${group.kind}`; + const workspaceNames = []; + const portabilityScore = computePortabilityScore({ + sourceType: 'publish-log', + suggestion, + recurrence: group.recurrence, + workspaceCount: 0, + repoSpecific: false, + }); + const maturityScore = computeMaturityScore('publish-log', group.recurrence); + const valueRank = computeValueRank({ + portabilityScore, + maturityScore, + recurrence: group.recurrence, + workspaceCount: 0, + }); + const outcomes = [...group.outcomes.entries()] + .map(([outcome, count]) => `${outcome}=${count}`) + .join(', '); + + return { + id: `publish-${slugify(`${group.kind}-${titleFromTarget(group.kind, group.target)}`)}`, + sourceType: 'publish-log', + category: group.kind, + title: titleFromTarget(group.kind, group.target), + suggestion, + recurrence: group.recurrence, + workspaceCount: 0, + ...defaultTokenCostMetrics(), + portabilityScore, + maturityScore, + valueRank, + firstSeen: formatIso(group.firstSeen), + lastSeen: formatIso(group.lastSeen), + sourceRefs: [...group.sourceRefs], + notes: `origin=${group.origin}; outcomes=${outcomes}`, + historyEntries: group.historyEntries.sort((left, right) => left.timestamp.localeCompare(right.timestamp)), + repoSpecific: false, + workspaceNames, + }; + }); + + stats.candidateCount = candidates.length; + return { candidates, stats }; +} + +function collectRepoMemoryCandidates(workspaces, sinceTimestamp) { + const stats = { + candidateCount: 0, + scannedFiles: 0, + }; + const groups = new Map(); + + for (const workspace of workspaces) { + const repoMemoryDir = path.join( + workspace.storagePath, + 'GitHub.copilot-chat', + 'memory-tool', + 'memories', + 'repo', + ); + + if (!fs.existsSync(repoMemoryDir)) { + continue; + } + + for (const entry of fs.readdirSync(repoMemoryDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) { + continue; + } + + const filePath = path.join(repoMemoryDir, entry.name); + const fileStat = safeStat(filePath); + if (!fileStat || fileStat.mtimeMs < sinceTimestamp) { + continue; + } + + stats.scannedFiles += 1; + const content = fs.readFileSync(filePath, 'utf8'); + const fallbackTitle = entry.name.replace(/\.md$/i, '').replace(/-/g, ' '); + const title = extractMarkdownTitle(content, fallbackTitle); + const key = slugify(entry.name.replace(/\.md$/i, '')); + if (!groups.has(key)) { + groups.set(key, { + contentSamples: [], + firstSeen: fileStat.mtimeMs, + lastSeen: fileStat.mtimeMs, + paths: new Set(), + recurrence: 0, + title, + workspaceNames: new Set(), + }); + } + + const group = groups.get(key); + group.recurrence += 1; + group.firstSeen = Math.min(group.firstSeen, fileStat.mtimeMs); + group.lastSeen = Math.max(group.lastSeen, fileStat.mtimeMs); + group.paths.add(filePath); + group.workspaceNames.add(workspace.name); + if (group.contentSamples.length < 2) { + group.contentSamples.push(content); + } + } + } + + const candidates = [...groups.values()].map((group) => { + const sampleText = group.contentSamples.join(' '); + const suggestion = inferSuggestion(`${group.title} ${sampleText}`); + const workspaceNames = [...group.workspaceNames].sort(); + const repoSpecific = likelyRepoSpecific(`${group.title} ${sampleText}`, workspaceNames); + const portabilityScore = computePortabilityScore({ + sourceType: 'repo-memory', + suggestion, + recurrence: group.recurrence, + workspaceCount: workspaceNames.length, + repoSpecific, + }); + const maturityScore = computeMaturityScore('repo-memory', group.recurrence); + const valueRank = computeValueRank({ + portabilityScore, + maturityScore, + recurrence: group.recurrence, + workspaceCount: workspaceNames.length, + }); + + return { + id: `memory-${slugify(group.title)}`, + sourceType: 'repo-memory', + category: inferCategory(suggestion), + title: compactText(group.title), + suggestion, + recurrence: group.recurrence, + workspaceCount: workspaceNames.length, + ...defaultTokenCostMetrics(), + portabilityScore, + maturityScore, + valueRank, + firstSeen: formatIso(group.firstSeen), + lastSeen: formatIso(group.lastSeen), + sourceRefs: [...group.paths].sort(), + notes: `repo memories from ${workspaceNames.join(', ') || 'unknown workspaces'}`, + historyEntries: [], + repoSpecific, + workspaceNames, + }; + }); + + stats.candidateCount = candidates.length; + return { candidates, stats }; +} + +function collectTranscriptCandidates(workspaces, sinceTimestamp) { + const stats = { + candidateCount: 0, + scannedPrompts: 0, + scannedSessions: 0, + }; + const groups = new Map(); + + for (const workspace of workspaces) { + const transcriptDir = path.join(workspace.storagePath, 'GitHub.copilot-chat', 'transcripts'); + if (!fs.existsSync(transcriptDir)) { + continue; + } + + for (const entry of fs.readdirSync(transcriptDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) { + continue; + } + + const transcriptPath = path.join(transcriptDir, entry.name); + const sessionIdsSeen = new Set(); + let sessionStartTimestamp = null; + + const lines = fs.readFileSync(transcriptPath, 'utf8').split(/\r?\n/).filter(Boolean); + const events = []; + for (const line of lines) { + try { + events.push(JSON.parse(line)); + } catch { + continue; + } + } + + for (const event of events) { + if (event.type === 'session.start') { + sessionStartTimestamp = Date.parse(event.data?.startTime ?? event.timestamp); + break; + } + } + + if (sessionStartTimestamp === null || Number.isNaN(sessionStartTimestamp) || sessionStartTimestamp < sinceTimestamp) { + continue; + } + + stats.scannedSessions += 1; + for (const event of events) { + if (event.type !== 'user.message' || typeof event.data?.content !== 'string') { + continue; + } + + const content = event.data.content.trim(); + if (content.length < 24 || content.length > 500 || !hasTranscriptKeyword(content)) { + continue; + } + + const normalized = normalizePromptText(content); + if (!normalized || sessionIdsSeen.has(normalized)) { + continue; + } + + sessionIdsSeen.add(normalized); + stats.scannedPrompts += 1; + if (!groups.has(normalized)) { + groups.set(normalized, { + firstSeen: sessionStartTimestamp, + lastSeen: sessionStartTimestamp, + promptCharsMax: 0, + promptCharsTotal: 0, + recurrence: 0, + sample: compactText(content, 100), + sourceRefs: new Set(), + workspaceNames: new Set(), + }); + } + + const group = groups.get(normalized); + const promptChars = content.length; + group.recurrence += 1; + group.firstSeen = Math.min(group.firstSeen, sessionStartTimestamp); + group.lastSeen = Math.max(group.lastSeen, sessionStartTimestamp); + group.promptCharsMax = Math.max(group.promptCharsMax, promptChars); + group.promptCharsTotal += promptChars; + group.sourceRefs.add(transcriptPath); + group.workspaceNames.add(workspace.name); + } + } + } + + const candidates = [...groups.values()] + .filter((group) => group.recurrence >= 2 || group.workspaceNames.size >= 2) + .map((group) => { + const workspaceNames = [...group.workspaceNames].sort(); + const suggestion = inferSuggestion(group.sample); + const repoSpecific = likelyRepoSpecific(group.sample, workspaceNames); + const tokenCostMetrics = computeTranscriptTokenCostMetrics({ + recurrence: group.recurrence, + promptCharsTotal: group.promptCharsTotal, + promptCharsMax: group.promptCharsMax, + }); + const portabilityScore = computePortabilityScore({ + sourceType: 'transcript', + suggestion, + recurrence: group.recurrence, + workspaceCount: workspaceNames.length, + repoSpecific, + }); + const maturityScore = computeMaturityScore('transcript', group.recurrence); + const valueRank = computeValueRank({ + portabilityScore, + maturityScore, + recurrence: group.recurrence, + workspaceCount: workspaceNames.length, + }); + + return { + id: `transcript-${slugify(group.sample)}`, + sourceType: 'transcript', + category: inferCategory(suggestion), + title: group.sample, + suggestion, + recurrence: group.recurrence, + workspaceCount: workspaceNames.length, + ...tokenCostMetrics, + portabilityScore, + maturityScore, + valueRank, + firstSeen: formatIso(group.firstSeen), + lastSeen: formatIso(group.lastSeen), + sourceRefs: [...group.sourceRefs].sort(), + notes: 'repeated user request pattern from persisted transcripts', + historyEntries: [], + repoSpecific, + workspaceNames, + }; + }); + + stats.candidateCount = candidates.length; + return { candidates, stats }; +} + +function writeWorkspaceIndex(filePath, workspaces) { + const lines = ['storage_id\tworkspace_name\tworkspace_path']; + for (const workspace of workspaces) { + lines.push( + [workspace.storageId, workspace.name, workspace.folderPath] + .map(cleanTsvCell) + .join('\t'), + ); + } + fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); +} + +function writeCandidatesReport(filePath, candidates) { + const header = [ + 'id', + 'source_type', + 'suggested_action', + 'category', + 'title', + 'detail_file', + 'recurrence', + 'workspace_count', + 'token_cost_signal', + 'avg_prompt_chars', + 'repeated_prompt_chars', + 'portability_score', + 'maturity_score', + 'value_rank', + 'first_seen', + 'last_seen', + 'source_refs', + 'notes', + ]; + + const lines = [header.join('\t')]; + for (const candidate of candidates) { + lines.push( + [ + candidate.id, + candidate.sourceType, + candidate.suggestion, + candidate.category, + candidate.title, + candidate.detailFile ?? '', + candidate.recurrence, + candidate.workspaceCount, + candidate.tokenCostSignal, + candidate.avgPromptChars, + candidate.repeatedPromptChars, + candidate.portabilityScore, + candidate.maturityScore, + candidate.valueRank, + candidate.firstSeen, + candidate.lastSeen, + candidate.sourceRefs.join(' | '), + candidate.notes, + ] + .map(cleanTsvCell) + .join('\t'), + ); + } + + fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); +} + +function writeSelectionManifest(filePath, candidates) { + const header = [ + 'decision', + 'review_note', + 'id', + 'suggested_action', + 'category', + 'title', + 'detail_file', + 'value_rank', + 'portability_score', + 'recurrence', + 'workspace_count', + 'token_cost_signal', + 'avg_prompt_chars', + 'repeated_prompt_chars', + 'first_seen', + 'last_seen', + 'source_type', + 'notes', + ]; + + const lines = [header.join('\t')]; + for (const candidate of candidates) { + lines.push( + [ + '', + '', + candidate.id, + candidate.suggestion, + candidate.category, + candidate.title, + candidate.detailFile ?? '', + candidate.valueRank, + candidate.portabilityScore, + candidate.recurrence, + candidate.workspaceCount, + candidate.tokenCostSignal, + candidate.avgPromptChars, + candidate.repeatedPromptChars, + candidate.firstSeen, + candidate.lastSeen, + candidate.sourceType, + candidate.notes, + ] + .map(cleanTsvCell) + .join('\t'), + ); + } + + fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); +} + +function writeSummary(filePath, context) { + const topCandidates = context.candidates.slice(0, 10); + const transcriptCostCandidates = context.candidates + .filter((candidate) => tokenCostSignalRank(candidate.tokenCostSignal) > 0) + .sort( + (left, right) => + tokenCostSignalRank(right.tokenCostSignal) - tokenCostSignalRank(left.tokenCostSignal) || + (right.repeatedPromptChars ?? 0) - (left.repeatedPromptChars ?? 0) || + right.recurrence - left.recurrence, + ) + .slice(0, 5); + const lines = [ + '# Copilot Reuse Audit', + '', + `- Generated: ${context.generatedAt}`, + `- Repo root: ${context.repoRoot}`, + `- Machine id: ${context.machineId}`, + `- Time window start: ${context.since}`, + `- Time window end: ${context.until}`, + `- Workspace filter: ${context.workspaceFilter || '(all indexed workspaces)'}`, + `- Excluded workspace roots: ${context.excludeWorkspaces.length === 0 ? '(none)' : context.excludeWorkspaces.join(', ')}`, + `- Included sources: ${context.includeSources.join(', ')}`, + `- Output directory: ${context.outputDir}`, + '', + '## Inventory', + '', + `- Indexed workspaces: ${context.workspaceCount}`, + `- Publish log entries in window: ${context.sourceStats.publishLog.scannedEntries}`, + `- Repo memory files in window: ${context.sourceStats.repoMemories.scannedFiles}`, + `- Transcript sessions in window: ${context.sourceStats.transcripts.scannedSessions}`, + `- Transcript prompts considered: ${context.sourceStats.transcripts.scannedPrompts}`, + `- Candidates emitted: ${context.candidates.length}`, + `- Transcript candidates with prompt-cost signal: ${context.candidates.filter((candidate) => tokenCostSignalRank(candidate.tokenCostSignal) > 0).length}`, + '', + '## Top Candidates', + '', + '| ID | Title | Suggested action | Source | Value rank |', + '| --- | --- | --- | --- | ---: |', + ]; + + if (topCandidates.length === 0) { + lines.push('| (none) | No candidates were detected in the selected window. | docs-only | audit | 0 |'); + } else { + for (const candidate of topCandidates) { + lines.push( + `| ${escapeMarkdownTable(candidate.id)} | ${escapeMarkdownTable(candidate.title)} | ${escapeMarkdownTable(candidate.suggestion)} | ${escapeMarkdownTable(candidate.sourceType)} | ${candidate.valueRank} |`, + ); + } + } + + lines.push( + '', + '## Prompt Cost Signals', + '', + '| ID | Title | Signal | Avg chars | Repeated chars |', + '| --- | --- | --- | ---: | ---: |', + ); + + if (transcriptCostCandidates.length === 0) { + lines.push('| (none) | No transcript prompt-cost signals were detected in the selected window. | n/a | 0 | 0 |'); + } else { + for (const candidate of transcriptCostCandidates) { + lines.push( + `| ${escapeMarkdownTable(candidate.id)} | ${escapeMarkdownTable(candidate.title)} | ${escapeMarkdownTable(candidate.tokenCostSignal)} | ${formatMetric(candidate.avgPromptChars)} | ${formatMetric(candidate.repeatedPromptChars)} |`, + ); + } + } + + lines.push( + '', + '## Files', + '', + '- pattern-details/', + '- workspace-index.tsv', + '- candidates-report.tsv', + '- selection-manifest.tsv', + '', + '## Review Guidance', + '', + '1. Start with selection-manifest.tsv and fill in the decision column only after reviewing the linked detail file for each candidate.', + '2. Prefer promote-skill, promote-instruction, promote-agent, promote-hook, promote-script, or promote-prompt only when the pattern is portable across repositories.', + '3. Use the transcript prompt-cost fields as triage signals for likely expensive repeated prompts, not as exact billing data.', + '4. Use template-only or docs-only for guidance that is helpful but not suitable as a shared runtime resource.', + '5. Use discard or needs-sanitization when the candidate contains secrets, machine-specific paths, or repo-specific assumptions.', + '', + ); + + fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const now = new Date(); + const sinceTimestamp = getSinceTimestamp(options, now); + const machineId = sanitizeMachineId(options.machineId); + const timestamp = buildTimestamp(now); + const outputDir = options.outputDir + ? path.resolve(options.outputDir) + : path.join(options.repoRoot, '.local', 'audits', machineId, timestamp); + + ensureDir(outputDir); + + const workspaceStorageRoot = + process.env.COPILOT_AUDIT_WORKSPACE_STORAGE_ROOT || + path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage'); + const publishLogPath = path.join( + process.env.COPILOT_RESOURCES_STATE_DIR || path.join(os.homedir(), '.copilot-resources-state'), + 'publish-log.tsv', + ); + + const workspaces = buildWorkspaceIndex( + workspaceStorageRoot, + options.workspaceFilter, + options.excludeWorkspaces, + ); + writeWorkspaceIndex(path.join(outputDir, 'workspace-index.tsv'), workspaces); + + let candidates = []; + const sourceStats = { + publishLog: { candidateCount: 0, scannedEntries: 0 }, + repoMemories: { candidateCount: 0, scannedFiles: 0 }, + transcripts: { candidateCount: 0, scannedPrompts: 0, scannedSessions: 0 }, + }; + + if (options.includeSources.includes('publish-log')) { + const result = collectPublishLogCandidates( + publishLogPath, + sinceTimestamp, + options.excludeWorkspaces, + ); + candidates = candidates.concat(result.candidates); + sourceStats.publishLog = result.stats; + } + + if (options.includeSources.includes('repo-memories')) { + const result = collectRepoMemoryCandidates(workspaces, sinceTimestamp); + candidates = candidates.concat(result.candidates); + sourceStats.repoMemories = result.stats; + } + + if (options.includeSources.includes('transcripts')) { + const result = collectTranscriptCandidates(workspaces, sinceTimestamp); + candidates = candidates.concat(result.candidates); + sourceStats.transcripts = result.stats; + } + + candidates = candidates + .sort((left, right) => right.valueRank - left.valueRank || right.recurrence - left.recurrence || left.title.localeCompare(right.title)) + .slice(0, options.limit); + + writeCandidateDetailFiles(path.join(outputDir, 'pattern-details'), candidates); + writeCandidatesReport(path.join(outputDir, 'candidates-report.tsv'), candidates); + writeSelectionManifest(path.join(outputDir, 'selection-manifest.tsv'), candidates); + writeSummary(path.join(outputDir, 'audit-summary.md'), { + candidates, + generatedAt: now.toISOString(), + includeSources: options.includeSources, + machineId, + excludeWorkspaces: options.excludeWorkspaces, + outputDir, + repoRoot: options.repoRoot, + since: new Date(sinceTimestamp).toISOString(), + sourceStats, + until: now.toISOString(), + workspaceCount: workspaces.length, + workspaceFilter: options.workspaceFilter, + }); + + console.log('Copilot audit complete.'); + console.log(`Output directory: ${outputDir}`); + console.log(`Summary: ${path.join(outputDir, 'audit-summary.md')}`); + console.log(`Candidates: ${path.join(outputDir, 'candidates-report.tsv')}`); + console.log(`Selection manifest: ${path.join(outputDir, 'selection-manifest.tsv')}`); +} + +main(); \ No newline at end of file diff --git a/resources/scripts/audit-copilot-usage.sh b/resources/scripts/audit-copilot-usage.sh new file mode 100755 index 0000000..a3b30df --- /dev/null +++ b/resources/scripts/audit-copilot-usage.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd -- "$script_dir/../.." && pwd -P)" + +if command -v node >/dev/null 2>&1; then + node_bin="$(command -v node)" +elif command -v nodejs >/dev/null 2>&1; then + node_bin="$(command -v nodejs)" +else + printf 'Node.js is required to run the Copilot audit workflow.\n' >&2 + exit 1 +fi + +exec "$node_bin" "$script_dir/audit-copilot-usage.mjs" --repo-root "$repo_root" "$@" \ No newline at end of file diff --git a/resources/scripts/prepare-audit-promotions.mjs b/resources/scripts/prepare-audit-promotions.mjs new file mode 100644 index 0000000..98db621 --- /dev/null +++ b/resources/scripts/prepare-audit-promotions.mjs @@ -0,0 +1,481 @@ +#!/usr/bin/env node +// @ts-nocheck + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const APPROVED_DECISIONS = new Set([ + 'promote-skill', + 'promote-instruction', + 'promote-agent', + 'promote-hook', + 'promote-script', + 'promote-prompt', + 'template-only', + 'docs-only', +]); + +const DRAFTABLE_DECISIONS = new Set([ + 'promote-skill', + 'promote-instruction', + 'promote-agent', + 'promote-prompt', +]); + +function usage() { + console.error( + [ + 'Usage: prepare-audit-promotions.mjs [options]', + '', + 'Options:', + ' --audit-dir Use a specific audit directory.', + ' --machine-id Limit latest-run discovery to one machine id.', + ' --repo-root Override the repository root.', + ' --help Show this help text.', + ].join('\n'), + ); +} + +function parseArgs(argv) { + const options = { + auditDir: '', + machineId: '', + repoRoot: path.resolve(__dirname, '../..'), + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--help' || arg === '-h') { + usage(); + process.exit(0); + } + if (arg === '--audit-dir') { + options.auditDir = argv[index + 1] ?? ''; + index += 1; + continue; + } + if (arg === '--machine-id') { + options.machineId = argv[index + 1] ?? ''; + index += 1; + continue; + } + if (arg === '--repo-root') { + options.repoRoot = path.resolve(argv[index + 1] ?? options.repoRoot); + index += 1; + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + return options; +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function sanitizeMachineId(input) { + return String(input || os.hostname() || 'unknown-machine') + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') || 'unknown-machine'; +} + +function safeStat(filePath) { + try { + return fs.statSync(filePath); + } catch { + return null; + } +} + +function listDirectories(rootPath) { + if (!fs.existsSync(rootPath)) { + return []; + } + + return fs.readdirSync(rootPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(rootPath, entry.name)); +} + +function findLatestAuditDir(repoRoot, machineId) { + const auditRoot = path.join(repoRoot, '.local', 'audits'); + const preferredMachineId = sanitizeMachineId(machineId); + const candidateMachineDirs = []; + + if (machineId) { + const machineDir = path.join(auditRoot, preferredMachineId); + if (fs.existsSync(machineDir)) { + candidateMachineDirs.push(machineDir); + } + } else { + const localMachineDir = path.join(auditRoot, sanitizeMachineId(os.hostname())); + if (fs.existsSync(localMachineDir)) { + candidateMachineDirs.push(localMachineDir); + } + for (const machineDir of listDirectories(auditRoot)) { + if (!candidateMachineDirs.includes(machineDir)) { + candidateMachineDirs.push(machineDir); + } + } + } + + let latestRun = null; + + for (const machineDir of candidateMachineDirs) { + for (const runDir of listDirectories(machineDir)) { + const stat = safeStat(runDir); + if (!stat) { + continue; + } + + if (!latestRun || stat.mtimeMs > latestRun.mtimeMs) { + latestRun = { path: runDir, mtimeMs: stat.mtimeMs }; + } + } + } + + return latestRun?.path ?? null; +} + +function parseTsv(filePath) { + const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/).filter(Boolean); + if (lines.length === 0) { + return []; + } + + const header = lines[0].split('\t'); + return lines.slice(1).map((line) => { + const values = line.split('\t'); + const row = {}; + for (let index = 0; index < header.length; index += 1) { + row[header[index]] = values[index] ?? ''; + } + return row; + }); +} + +function slugify(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-{2,}/g, '-'); +} + +function yamlString(value) { + return JSON.stringify(String(value ?? '')); +} + +function preferredStem(row) { + return slugify(row.title || row.id || 'draft-resource') || 'draft-resource'; +} + +function relativeAuditPath(auditDir, filePath) { + return path.relative(auditDir, filePath) || '.'; +} + +function buildSourceBlock(row, detailRelativePath) { + const lines = [ + `- Candidate ID: ${row.id}`, + `- Decision: ${row.decision}`, + `- Suggested action: ${row.suggested_action}`, + `- Category: ${row.category}`, + `- Value rank: ${row.value_rank}`, + ]; + + if (detailRelativePath) { + lines.push(`- Detail file: ${detailRelativePath}`); + } + + if (row.review_note) { + lines.push(`- Review note: ${row.review_note}`); + } + + if (row.notes) { + lines.push(`- Audit notes: ${row.notes}`); + } + + return lines.join('\n'); +} + +function renderSkillDraft(row, detailRelativePath) { + const name = preferredStem(row); + return [ + '---', + `name: ${yamlString(name)}`, + `description: ${yamlString(`Draft generated from audit candidate ${row.id}.`)}`, + 'argument-hint: ""', + '---', + '', + `# ${row.title}`, + '', + 'Use this draft to turn the audited pattern into a portable shared skill.', + '', + '## Source', + '', + buildSourceBlock(row, detailRelativePath), + '', + '## Draft Workflow', + '', + 'Replace this placeholder with the reusable workflow distilled from the audit evidence bundle.', + '', + '## Validation', + '', + '- Confirm the pattern is portable across repositories.', + '- Add any required docs, prompts, or scripts before publishing.', + '', + ].join('\n'); +} + +function renderInstructionDraft(row, detailRelativePath) { + return [ + '---', + `description: ${yamlString(`Draft generated from audit candidate ${row.id}.`)}`, + 'applyTo: ""', + '---', + '', + '# Draft Instruction', + '', + '- Replace this placeholder with portable instruction text distilled from the audit evidence.', + '', + '## Source', + '', + buildSourceBlock(row, detailRelativePath), + '', + '## Guardrails', + '', + '- Remove repo-specific assumptions before publishing.', + '- Keep the instruction concise and reusable.', + '', + ].join('\n'); +} + +function renderAgentDraft(row, detailRelativePath) { + return [ + '---', + `name: ${yamlString(row.title)}`, + `description: ${yamlString(`Draft generated from audit candidate ${row.id}.`)}`, + 'tools: [read, search, edit, execute]', + '---', + '', + `# ${row.title}`, + '', + 'Use this draft agent to package the audited pattern into a reusable interaction mode.', + '', + '## Source', + '', + buildSourceBlock(row, detailRelativePath), + '', + '## Behavior', + '', + '- Replace this placeholder with the agent workflow distilled from the audit evidence.', + '- Specify when to use the agent and which tradeoffs it should enforce.', + '', + ].join('\n'); +} + +function renderPromptDraft(row, detailRelativePath) { + const name = preferredStem(row); + return [ + '---', + `name: ${yamlString(name)}`, + `description: ${yamlString(`Draft generated from audit candidate ${row.id}.`)}`, + 'agent: "agent"', + 'tools: [read, search, edit, execute]', + 'argument-hint: ""', + '---', + '', + 'Translate the audited pattern into a reusable prompt adapter.', + '', + '## Source', + '', + buildSourceBlock(row, detailRelativePath), + '', + 'Requirements:', + '', + '- Replace this placeholder guidance with the portable workflow.', + '- Recheck the evidence bundle before publishing.', + '- Remove any repo-specific assumptions.', + '', + ].join('\n'); +} + +function suggestedTarget(decision, stem) { + if (decision === 'promote-script') { + return `resources/scripts/${stem}.sh and resources/scripts/${stem}.ps1`; + } + if (decision === 'promote-hook') { + return `resources/hooks/${stem}.json`; + } + if (decision === 'template-only') { + return `templates//${stem}`; + } + if (decision === 'docs-only') { + return `docs/${stem}.md`; + } + + return 'manual follow-up'; +} + +function renderStagingNote(row, detailRelativePath) { + const stem = preferredStem(row); + return [ + `# ${row.title}`, + '', + 'This approved audit row still needs manual design work before it should be published into the shared repository.', + '', + '## Source', + '', + buildSourceBlock(row, detailRelativePath), + '', + '## Suggested Target', + '', + `- ${suggestedTarget(row.decision, stem)}`, + '', + '## Next Steps', + '', + '- Distill the evidence bundle into a portable implementation plan.', + '- Decide whether this should stay a note, become docs, or become a concrete shared artifact.', + '', + ].join('\n'); +} + +function buildDraftSpec(auditDir, row) { + const stem = preferredStem(row); + const detailRelativePath = row.detail_file || ''; + + if (row.decision === 'promote-skill') { + return { + kind: 'draft', + outputPath: path.join(auditDir, 'draft-resources', 'resources', 'skills', stem, 'SKILL.md'), + content: renderSkillDraft(row, detailRelativePath), + }; + } + + if (row.decision === 'promote-instruction') { + return { + kind: 'draft', + outputPath: path.join(auditDir, 'draft-resources', 'resources', 'instructions', `${stem}.instructions.md`), + content: renderInstructionDraft(row, detailRelativePath), + }; + } + + if (row.decision === 'promote-agent') { + return { + kind: 'draft', + outputPath: path.join(auditDir, 'draft-resources', 'resources', 'agents', `${stem}.agent.md`), + content: renderAgentDraft(row, detailRelativePath), + }; + } + + if (row.decision === 'promote-prompt') { + return { + kind: 'draft', + outputPath: path.join(auditDir, 'draft-resources', 'resources', 'prompts', `${stem}.prompt.md`), + content: renderPromptDraft(row, detailRelativePath), + }; + } + + return { + kind: 'note', + outputPath: path.join(auditDir, 'staging-notes', `${stem}.md`), + content: renderStagingNote(row, detailRelativePath), + }; +} + +function writeArtifacts(auditDir, approvedRows) { + const generated = []; + + for (const row of approvedRows) { + const artifact = buildDraftSpec(auditDir, row); + ensureDir(path.dirname(artifact.outputPath)); + fs.writeFileSync(artifact.outputPath, artifact.content, 'utf8'); + generated.push({ + decision: row.decision, + id: row.id, + kind: artifact.kind, + outputPath: artifact.outputPath, + }); + } + + return generated; +} + +function writeSummary(auditDir, manifestPath, approvedRows, generatedArtifacts) { + const summaryPath = path.join(auditDir, 'promotion-summary.md'); + const draftArtifacts = generatedArtifacts.filter((artifact) => artifact.kind === 'draft'); + const noteArtifacts = generatedArtifacts.filter((artifact) => artifact.kind === 'note'); + const lines = [ + '# Audit Promotion Summary', + '', + `- Generated: ${new Date().toISOString()}`, + `- Audit directory: ${auditDir}`, + `- Selection manifest: ${manifestPath}`, + `- Approved rows: ${approvedRows.length}`, + `- Draft resources: ${draftArtifacts.length}`, + `- Staging notes: ${noteArtifacts.length}`, + '', + '## Draft Resources', + '', + ]; + + if (draftArtifacts.length === 0) { + lines.push('- None'); + } else { + for (const artifact of draftArtifacts) { + lines.push(`- ${artifact.decision} :: ${artifact.id} :: ${relativeAuditPath(auditDir, artifact.outputPath)}`); + } + } + + lines.push('', '## Staging Notes', ''); + + if (noteArtifacts.length === 0) { + lines.push('- None'); + } else { + for (const artifact of noteArtifacts) { + lines.push(`- ${artifact.decision} :: ${artifact.id} :: ${relativeAuditPath(auditDir, artifact.outputPath)}`); + } + } + + fs.writeFileSync(summaryPath, `${lines.join('\n')}\n`, 'utf8'); + return summaryPath; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const auditDir = options.auditDir + ? path.resolve(options.auditDir) + : findLatestAuditDir(options.repoRoot, options.machineId); + + if (!auditDir) { + throw new Error('No audit directory was found. Run the audit first or pass --audit-dir.'); + } + + const manifestPath = path.join(auditDir, 'selection-manifest.tsv'); + if (!fs.existsSync(manifestPath)) { + throw new Error(`Selection manifest not found: ${manifestPath}`); + } + + const rows = parseTsv(manifestPath); + const approvedRows = rows.filter((row) => APPROVED_DECISIONS.has(row.decision)); + const generatedArtifacts = writeArtifacts(auditDir, approvedRows); + const summaryPath = writeSummary(auditDir, manifestPath, approvedRows, generatedArtifacts); + + console.log('Audit promotion preparation complete.'); + console.log(`Audit directory: ${auditDir}`); + console.log(`Approved rows: ${approvedRows.length}`); + console.log(`Draft resources: ${generatedArtifacts.filter((artifact) => artifact.kind === 'draft').length}`); + console.log(`Staging notes: ${generatedArtifacts.filter((artifact) => artifact.kind === 'note').length}`); + console.log(`Summary: ${summaryPath}`); +} + +main(); \ No newline at end of file diff --git a/resources/scripts/prepare-audit-promotions.sh b/resources/scripts/prepare-audit-promotions.sh new file mode 100755 index 0000000..e27028b --- /dev/null +++ b/resources/scripts/prepare-audit-promotions.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd -- "$script_dir/../.." && pwd -P)" + +if command -v node >/dev/null 2>&1; then + node_bin="$(command -v node)" +elif command -v nodejs >/dev/null 2>&1; then + node_bin="$(command -v nodejs)" +else + printf 'Node.js is required to prepare audit promotion drafts.\n' >&2 + exit 1 +fi + +exec "$node_bin" "$script_dir/prepare-audit-promotions.mjs" --repo-root "$repo_root" "$@" \ No newline at end of file diff --git a/resources/scripts/report-hook-event.sh b/resources/scripts/report-hook-event.sh old mode 100644 new mode 100755 diff --git a/resources/skills/copilot-cost-review/SKILL.md b/resources/skills/copilot-cost-review/SKILL.md new file mode 100644 index 0000000..3f4a17a --- /dev/null +++ b/resources/skills/copilot-cost-review/SKILL.md @@ -0,0 +1,40 @@ +--- +name: copilot-cost-review +description: "Use when reviewing a prompt, instruction, agent, or skill draft for avoidable token cost and reuse opportunities." +argument-hint: "target=" +--- + +# Copilot Cost Review + +Use this skill when you want to make a Copilot resource cheaper to run without +stripping away the behavior it actually needs. + +## Procedure + +1. Identify the resource's real job, expected inputs, and default output size. +2. Search for an existing shared resource that already covers most of the same + workflow before proposing a new artifact. +3. Re-evaluate the primitive choice: use an instruction for durable rules, a + skill for a portable workflow, a prompt for a thin VS Code entrypoint, and a + hook only for deterministic enforced behavior. +4. Remove repeated policy text, long examples, and broad workspace-reading + requirements unless they materially improve correctness. +5. Replace broad discovery steps with narrow anchors such as a file, symbol, + command, or manifest row whenever possible. +6. Add or tighten an explicit output budget and note when the resource should + not be used. +7. If the resource is still too heavy, split reference material into docs or + scripts and keep the runtime resource concise. + +## Outputs + +- Recommended primitive +- Reuse candidates in the shared repo +- Context and prompt reductions +- Output-budget guidance +- Non-goals or usage limits + +## Notes + +- Reduce prompt size without removing information that changes correctness. +- Treat transcript prompt-cost fields as proxies, not exact billing data. \ No newline at end of file diff --git a/resources/skills/copilot-reuse-audit/SKILL.md b/resources/skills/copilot-reuse-audit/SKILL.md new file mode 100644 index 0000000..512ab2e --- /dev/null +++ b/resources/skills/copilot-reuse-audit/SKILL.md @@ -0,0 +1,52 @@ +--- +name: copilot-reuse-audit +description: "Use when auditing the last 30 days of persisted Copilot artifacts for reusable patterns worth promoting into the shared repo." +argument-hint: "days= workspace= sources=" +--- + +# Copilot Reuse Audit + +Use this skill when you want a repeatable audit of local Copilot usage artifacts +to find patterns that should become shared resources. + +## Procedure + +1. Run `resources/scripts/audit-copilot-usage.sh` instead of manually hunting + through transcripts, memories, and publish logs. +2. By default, the runner excludes the `copilot-resources` repo root from + workspace-backed candidate discovery so the audit does not ask you to review + patterns that already live in the shared repository. +3. Review the generated `audit-summary.md` first to understand coverage and top + candidates. +4. Use `selection-manifest.tsv` and `pattern-details/*.md` as the approval + surface for each candidate. +5. When you want interactive triage inside Copilot, use the + `review-audit-candidates` prompt so each pending row is handled one at a + time and written back to `selection-manifest.tsv`. +6. Use the transcript prompt-cost proxy fields to prioritize repeated long + prompts that are likely worth turning into a shared resource. +7. Treat those prompt-cost fields as triage signals, not as exact billing data. +6. Before asking for a decision, explain the candidate's likely purpose, + concrete benefit, audit context, and score rationale. If evidence is thin, + call that out explicitly instead of asking the user to infer it. +8. Promote only candidates that map cleanly to a portable skill, instruction, + prompt adapter, agent, hook, script, or template. +9. After rows are approved, run `resources/scripts/prepare-audit-promotions.sh` + to create draft resources or staging notes in the audit directory. +10. Keep non-portable or repo-specific findings in audit notes rather than + forcing them into shared resources. + +## Outputs + +- `audit-summary.md` — high-level run summary and top candidates +- `candidates-report.tsv` — scored candidate list with source references +- `selection-manifest.tsv` — editable approval surface for promotion decisions +- `pattern-details/*.md` — per-candidate evidence bundles with benefit, context, score rationale, and caveats +- `draft-resources/` — generated draft resource files for approved portable rows +- `staging-notes/` — generated follow-up notes for approved rows that still need design work + +## Notes + +- The runner is macOS-first in this iteration. +- Audit history is stored per machine under `.local/audits//` in the + repo checkout and is intentionally git-ignored. \ No newline at end of file diff --git a/resources/skills/resource-authoring/references/publishing-checklist.md b/resources/skills/resource-authoring/references/publishing-checklist.md index c66b5c0..d097fe2 100644 --- a/resources/skills/resource-authoring/references/publishing-checklist.md +++ b/resources/skills/resource-authoring/references/publishing-checklist.md @@ -5,3 +5,9 @@ - Confirm frontmatter is valid and descriptive. - Avoid duplicating a workflow that already exists. - If scripts are referenced, validate them before merging. +- Check whether the same outcome can be achieved with a cheaper shared + primitive or by reusing an existing resource. +- Keep examples and embedded guidance short unless the extra context is + essential. +- Say when the resource should not be used if that prevents wasteful generic + usage. diff --git a/resources/templates/new-resource-checklist.md b/resources/templates/new-resource-checklist.md index 3a852ca..4db8733 100644 --- a/resources/templates/new-resource-checklist.md +++ b/resources/templates/new-resource-checklist.md @@ -5,4 +5,8 @@ - Add clear frontmatter. - Check naming and portability rules. - Validate any referenced scripts. +- Reuse an existing shared resource when possible instead of adding a near + duplicate. +- Keep descriptions and examples compact. +- Note the default output budget or brevity expectation when it matters. - Commit and push after publishing.