diff --git a/.context/PROMPT.md b/.context/PROMPT.md deleted file mode 100644 index 8ed4f0ff4..000000000 --- a/.context/PROMPT.md +++ /dev/null @@ -1,43 +0,0 @@ -# Project Session Prompt - - - - -## On Session Start - -1. Read this file (you're doing it now) -2. Run `ctx status` to see current context summary -3. Check `.context/TASKS.md` for active work items - -## Context Files - -| File | Purpose | -|------------------------------|------------------------------------------| -| `.context/CONSTITUTION.md` | Hard rules — NEVER violate | -| `.context/TASKS.md` | Current work items | -| `.context/DECISIONS.md` | Architectural decisions with rationale | -| `.context/LEARNINGS.md` | Gotchas and lessons learned | -| `.context/CONVENTIONS.md` | Code patterns and standards | -| `.context/AGENT_PLAYBOOK.md` | How to persist context, session patterns | - -## Working Style - -- **Ask questions** when requirements are unclear -- **Persist context** as you work (don't wait for session end) -- **Use `ctx add`** for learnings, decisions, tasks -- **Check existing patterns** before writing new code - -## Persist as You Go - -After completing meaningful work, capture what matters: - -| Trigger | Action | -|--------------------------|---------------------------------------------| -| Completed a task | Mark done in TASKS.md, add learnings if any | -| Made a decision | `ctx add decision "..."` | -| Discovered a gotcha | `ctx add learning "..."` | -| Significant code changes | Consider what's worth capturing | - -Don't wait for the session to end — it may never come cleanly. - - diff --git a/.context/architecture-dia-build.md b/.context/architecture-dia-build.md index a8eeff35d..732ed3b4f 100644 --- a/.context/architecture-dia-build.md +++ b/.context/architecture-dia-build.md @@ -72,7 +72,7 @@ ctx/ │ │ ├── doctor/ # ctx doctor │ │ ├── drift/ # ctx drift │ │ ├── guide/ # ctx guide -│ │ ├── hook/ # ctx hook +│ │ ├── setup/ # ctx setup │ │ ├── initialize/ # ctx init │ │ ├── journal/ # ctx journal │ │ ├── learning/ # ctx learning diff --git a/.context/templates/decision.md b/.context/templates/decision.md index 7f06e047b..87f805f97 100644 --- a/.context/templates/decision.md +++ b/.context/templates/decision.md @@ -5,8 +5,8 @@ **Context**: [What situation prompted this decision? What constraints exist?] **Alternatives Considered**: -1. **[Option A]**: [Description] — Pros: [...] / Cons: [...] -2. **[Option B]**: [Description] — Pros: [...] / Cons: [...] +1. **[Option A]**: [Description]: Pros: [...] / Cons: [...] +2. **[Option B]**: [Description]: Pros: [...] / Cons: [...] **Decision**: [What was decided?] diff --git a/internal/assets/hooks/copilot-instructions.md b/.github/copilot-instructions.md similarity index 91% rename from internal/assets/hooks/copilot-instructions.md rename to .github/copilot-instructions.md index 32e899dde..96172842d 100644 --- a/internal/assets/hooks/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -6,7 +6,7 @@ ## Context System This project uses Context (`ctx`) for persistent AI context -management. Your memory is NOT ephemeral: it lives in `.context/` files. +management. Your memory is NOT ephemeral — it lives in `.context/` files. ## On Session Start @@ -50,7 +50,7 @@ After completing meaningful work, save a session summary to Create a file named `YYYY-MM-DD-topic.md`: ```markdown -# Session: YYYY-MM-DD - Brief Topic Description +# Session: YYYY-MM-DD — Brief Topic Description ## What Was Done - Describe completed work items @@ -77,12 +77,12 @@ Create a file named `YYYY-MM-DD-topic.md`: Proactively update context files as you work: -| Event | Action | -|-----------------------------|----------------------------------| -| Made architectural decision | Add to `.context/DECISIONS.md` | -| Discovered gotcha/bug | Add to `.context/LEARNINGS.md` | +| Event | Action | +|-----------------------------|-------------------------------------| +| Made architectural decision | Add to `.context/DECISIONS.md` | +| Discovered gotcha/bug | Add to `.context/LEARNINGS.md` | | Established new pattern | Add to `.context/CONVENTIONS.md` | -| Completed task | Mark [x] in `.context/TASKS.md` | +| Completed task | Mark [x] in `.context/TASKS.md` | ## Self-Check @@ -90,7 +90,7 @@ Periodically ask yourself: > "If this session ended right now, would the next session know what happened?" -If no: save a session file or update context files before continuing. +If no — save a session file or update context files before continuing. ## CLI Commands @@ -100,7 +100,7 @@ If `ctx` is installed, use these commands: ctx status # Context summary and health check ctx agent # AI-ready context packet ctx drift # Check for stale context -ctx journal source # Recent session history +ctx recall list # Recent session history ``` diff --git a/CLAUDE.md b/CLAUDE.md index 6aeb382b2..ed28657bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,62 @@ # Context - Claude Code Context + +# Project Context + + + + +## IMPORTANT: You Have Persistent Memory + +This project uses Context (`ctx`) for context persistence across sessions. +**Your memory is NOT ephemeral** — it lives in the `.context/` directory. + +## On Session Start + +1. **Read `.context/AGENT_PLAYBOOK.md`** — it explains how to use this system +2. **Run `ctx agent --budget 4000`** in a terminal for an AI-optimized context summary +3. **Check `.context/TASKS.md`** for active work items + +## When Asked "Do You Remember?" + +**Do this FIRST (silently):** +- Read TASKS.md, DECISIONS.md, and LEARNINGS.md from `.context/` +- Run `ctx recall list --limit 5` for recent session history + +**Then respond with a structured readback:** + +1. **Last session**: cite the most recent session topic and date +2. **Active work**: list pending or in-progress tasks +3. **Recent context**: mention 1-2 recent decisions or learnings +4. **Next step**: offer to continue or ask what to focus on + +**Never** lead with "I don't have memory." The `.context/` files are +your memory. Read them silently, then present what you found as recall. + +## Quick Context Load + +```bash +# Get AI-optimized context packet (what you should know) +ctx agent --budget 4000 + +# Or see full status +ctx status +``` + +## Context Files + +| File | Purpose | +|------|---------| +| CONSTITUTION.md | Hard rules - NEVER violate | +| TASKS.md | Current work items | +| DECISIONS.md | Architectural decisions with rationale | +| LEARNINGS.md | Gotchas, tips, lessons learned | +| CONVENTIONS.md | Code patterns and standards | + +All files live in the `.context/` directory. + + + ## IMPORTANT: You Have Persistent Memory This project uses Context (ctx) for context persistence across sessions. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 9b2ae2ff0..d4d682dbe 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -13,9 +13,9 @@ This file provides high-level direction. Detailed tasks live in `.context/TASKS. What does "done" look like for this project? -1. **Goal** — Define your end state -2. **Validation** — How will you know it works? -3. **Handoff** — Can someone else pick this up? +1. **Goal**: Define your end state +2. **Validation**: How will you know it works? +3. **Handoff**: Can someone else pick this up? ## Notes diff --git a/Makefile b/Makefile index 01f3d6e96..13c177a64 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ .PHONY: build test vet fmt lint lint-style lint-drift clean all release build-all help \ test-coverage smoke site site-feed site-serve site-serve-lan site-setup audit check plugin-reload \ journal journal-serve journal-serve-lan gpg-fix gpg-test register-mcp reinstall \ -sync-version check-version-sync sync-why check-why gemini-search +sync-version check-version-sync sync-why check-why sync-copilot-skills check-copilot-skills gemini-search # Default binary name and output BINARY := ctx @@ -21,8 +21,8 @@ sync-version: mv internal/assets/claude/.claude-plugin/plugin.json.tmp internal/assets/claude/.claude-plugin/plugin.json; \ echo "Plugin version synced to $$V" -## build: Build for current platform (syncs version + embedded docs first) -build: sync-version sync-why +## build: Build for current platform (syncs version + embedded docs + copilot skills first) +build: sync-version sync-why sync-copilot-skills CGO_ENABLED=0 go build -ldflags="-X github.com/ActiveMemory/ctx/internal/bootstrap.version=$$(cat VERSION | tr -d '[:space:]')" -o $(OUTPUT) ./cmd/ctx ## test: Run tests with coverage summary @@ -128,6 +128,8 @@ audit: @$(MAKE) --no-print-directory check-version-sync @echo "==> Checking why docs freshness..." @$(MAKE) --no-print-directory check-why + @echo "==> Checking Copilot skills freshness..." + @$(MAKE) --no-print-directory check-copilot-skills @echo "==> Running tests..." @CGO_ENABLED=0 CTX_SKIP_PATH_CHECK=1 go test ./... @echo "" @@ -251,6 +253,25 @@ check-version-sync: fi; \ echo "Version sync OK ($$V)." +## sync-copilot-skills: Sync Copilot CLI skills from canonical ctx skills +sync-copilot-skills: + @./hack/sync-copilot-skills.sh + +## check-copilot-skills: Verify Copilot CLI skills match ctx source skills +check-copilot-skills: + @TMPDIR=$$(mktemp -d) && \ + cp -r internal/assets/integrations/copilot-cli/skills/ "$$TMPDIR/before" && \ + ./hack/sync-copilot-skills.sh > /dev/null && \ + if ! diff -rq "$$TMPDIR/before" internal/assets/integrations/copilot-cli/skills/ > /dev/null 2>&1; then \ + echo "FAIL: Copilot CLI skills are stale — run 'make sync-copilot-skills'"; \ + diff -rq "$$TMPDIR/before" internal/assets/integrations/copilot-cli/skills/ || true; \ + cp -r "$$TMPDIR/before/"* internal/assets/integrations/copilot-cli/skills/; \ + rm -rf "$$TMPDIR"; \ + exit 1; \ + fi; \ + rm -rf "$$TMPDIR"; \ + echo "Copilot CLI skills are in sync." + ## check-why: Verify embedded why docs match source docs check-why: @diff -q docs/index.md internal/assets/why/manifesto.md || (echo "FAIL: manifesto.md is stale — run 'make sync-why'" && exit 1) diff --git a/docs/cli/index.md b/docs/cli/index.md index 429253773..db77a6092 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -33,7 +33,7 @@ created by `ctx init`. Running a command without one produces: ctx: not initialized - run "ctx init" first ``` -Commands that work before initialization: `ctx init`, `ctx hook`, +Commands that work before initialization: `ctx init`, `ctx setup`, `ctx doctor`, and grouping commands that only show help (e.g. `ctx` with no subcommand, `ctx system`). Hidden hook commands have their own guards and no-op gracefully. @@ -60,7 +60,7 @@ own guards and no-op gracefully. | [`ctx journal`](recall.md#ctx-journal) | Generate static site from journal entries | | [`ctx serve`](recall.md#ctx-serve) | Serve any zensical directory (default: journal site) | | [`ctx watch`](tools.md#ctx-watch) | Auto-apply context updates from AI output | -| [`ctx hook`](tools.md#ctx-hook) | Generate AI tool integration configs | +| [`ctx setup`](tools.md#ctx-hook) | Generate AI tool integration configs | | [`ctx loop`](tools.md#ctx-loop) | Generate autonomous loop script | | [`ctx memory`](tools.md#ctx-memory) | Bridge Claude Code auto memory into .context/ | | [`ctx notify`](tools.md#ctx-notify) | Send webhook notifications | diff --git a/docs/cli/tools.md b/docs/cli/tools.md index a6922690b..09cdfb3e3 100644 --- a/docs/cli/tools.md +++ b/docs/cli/tools.md @@ -42,12 +42,12 @@ ctx watch --dry-run --- -### `ctx hook` +### `ctx setup` Generate AI tool integration configuration. ```bash -ctx hook [flags] +ctx setup [flags] ``` **Flags**: @@ -68,17 +68,17 @@ ctx hook [flags] !!! note "Claude Code Uses the Plugin system" Claude Code integration is now provided via the `ctx` plugin. - Running `ctx hook claude-code` prints plugin install instructions. + Running `ctx setup claude-code` prints plugin install instructions. **Example**: ```bash # Print hook instructions to stdout -ctx hook cursor -ctx hook aider +ctx setup cursor +ctx setup aider # Generate and write .github/copilot-instructions.md -ctx hook copilot --write +ctx setup copilot --write ``` --- diff --git a/docs/home/common-workflows.md b/docs/home/common-workflows.md index a45eecd55..982a82916 100644 --- a/docs/home/common-workflows.md +++ b/docs/home/common-workflows.md @@ -331,7 +331,7 @@ These are infrastructure: used in scripts, CI, or one-time setup. | `ctx task complete` | Mark a task done by substring match | | `ctx sync` | Reconcile context with codebase state | | `ctx compact` | Consolidate and clean up context files | -| `ctx hook` | Generate AI tool integration config | +| `ctx setup` | Generate AI tool integration config | | `ctx watch` | Watch AI output and auto-apply context updates | | `ctx serve` | Serve any zensical directory (default: journal) | | `ctx permission snapshot` | Save settings as a golden image | diff --git a/docs/home/first-session.md b/docs/home/first-session.md index ad4a3043d..1721062c3 100644 --- a/docs/home/first-session.md +++ b/docs/home/first-session.md @@ -127,7 +127,7 @@ via hooks, but the explicit ceremony gives you a **readback** to verify. With **VS Code Copilot Chat** (*and the [ctx extension](../operations/integrations.md#vs-code-chat-extension-ctx)*), type `@ctx /agent` in chat to load your context packet, or `@ctx /status` - to check your project context. Run `ctx hook copilot --write` once + to check your project context. Run `ctx setup copilot --write` once to generate `.github/copilot-instructions.md` for automatic context loading. If you are not using Claude Code, generate a diff --git a/docs/home/getting-started.md b/docs/home/getting-started.md index 053e888a3..47908be86 100644 --- a/docs/home/getting-started.md +++ b/docs/home/getting-started.md @@ -217,7 +217,7 @@ via hooks. With **VS Code Copilot Chat**, install the [ctx extension](../operations/integrations.md#vs-code-chat-extension-ctx) and use `@ctx /status`, `@ctx /agent`, and other slash commands directly in chat. -Run `ctx hook copilot --write` to generate `.github/copilot-instructions.md` +Run `ctx setup copilot --write` to generate `.github/copilot-instructions.md` for automatic context loading. For other tools, paste the output of: diff --git a/docs/operations/integrations.md b/docs/operations/integrations.md index 72c1fd3d7..3ec24aa47 100644 --- a/docs/operations/integrations.md +++ b/docs/operations/integrations.md @@ -342,7 +342,7 @@ files directly. ```bash # Generate Cursor configuration -ctx hook cursor +ctx setup cursor # Initialize context ctx init --minimal @@ -390,7 +390,7 @@ Aider works well with context files through its `--read` flag. ```bash # Generate Aider configuration -ctx hook aider +ctx setup aider # Initialize context ctx init @@ -444,7 +444,7 @@ instructions file, a VS Code Chat extension, and manual patterns. ctx init # Generate .github/copilot-instructions.md -ctx hook copilot --write +ctx setup copilot --write ``` The `--write` flag creates `.github/copilot-instructions.md`, which @@ -453,7 +453,7 @@ contains your project's constitution rules, current tasks, conventions, and architecture: giving Copilot persistent context without manual copy-paste. -Re-run `ctx hook copilot --write` after updating your `.context/` files +Re-run `ctx setup copilot --write` after updating your `.context/` files to regenerate the instructions. ### VS Code Chat Extension (`@ctx`) @@ -568,7 +568,7 @@ Windsurf supports custom instructions and file-based context. ```bash # Generate Windsurf configuration -ctx hook windsurf +ctx setup windsurf # Initialize context ctx init diff --git a/docs/operations/migration.md b/docs/operations/migration.md index 5bf54f530..e7077956a 100644 --- a/docs/operations/migration.md +++ b/docs/operations/migration.md @@ -198,10 +198,10 @@ to it: You can generate a tool-specific configuration with: ```bash -ctx hook cursor # Generate Cursor config snippet -ctx hook aider # Generate .aider.conf.yml -ctx hook copilot # Generate Copilot tips -ctx hook windsurf # Generate Windsurf config +ctx setup cursor # Generate Cursor config snippet +ctx setup aider # Generate .aider.conf.yml +ctx setup copilot # Generate Copilot tips +ctx setup windsurf # Generate Windsurf config ``` ### Migrating Content Into `.context/` @@ -296,7 +296,7 @@ You don't need the whole team to switch at once: 1. One person runs `ctx init --merge` and commits; 2. `CLAUDE.md` instructions work immediately for Claude Code users; -3. Other tool users can adopt at their own pace using `ctx hook `; +3. Other tool users can adopt at their own pace using `ctx setup `; 4. Context files benefit everyone who reads them, even without tool integration. --- diff --git a/docs/recipes/index.md b/docs/recipes/index.md index 9625f84c0..e09368c5a 100644 --- a/docs/recipes/index.md +++ b/docs/recipes/index.md @@ -25,7 +25,7 @@ Initialize `ctx` and configure hooks for Claude Code, Cursor, Aider, Copilot, or Windsurf. Includes **shell completion**, **watch mode** for non-native tools, and **verification**. -**Uses**: `ctx init`, `ctx hook`, `ctx agent`, `ctx completion`, +**Uses**: `ctx init`, `ctx setup`, `ctx agent`, `ctx completion`, `ctx watch` --- diff --git a/docs/recipes/multi-tool-setup.md b/docs/recipes/multi-tool-setup.md index 2df126933..3e064955a 100644 --- a/docs/recipes/multi-tool-setup.md +++ b/docs/recipes/multi-tool-setup.md @@ -31,7 +31,7 @@ claude /plugin marketplace add ActiveMemory/ctx claude /plugin install ctx@activememory-ctx # ## Cursor / Aider / Copilot / Windsurf ## -ctx hook cursor # or: aider, copilot, windsurf +ctx setup cursor # or: aider, copilot, windsurf # ## Companion tools (highly recommended) ## npx gitnexus analyze # code knowledge graph @@ -48,7 +48,7 @@ Then start your AI tool and ask: "**Do you remember?**" | Command/Skill | Role in this workflow | |---------------------|--------------------------------------------------------------| | `ctx init` | Create `.context/` directory, templates, and permissions | -| `ctx hook` | Generate integration configuration for a specific AI tool | +| `ctx setup` | Generate integration configuration for a specific AI tool | | `ctx agent` | Print a token-budgeted context packet for AI consumption | | `ctx load` | Output assembled context in read order (for manual pasting) | | `ctx watch` | Auto-apply context updates from AI output (non-native tools) | @@ -114,16 +114,16 @@ automatically by `ctx init`*), generate its integration configuration: ```bash # For Cursor -ctx hook cursor +ctx setup cursor # For Aider -ctx hook aider +ctx setup aider # For GitHub Copilot -ctx hook copilot +ctx setup copilot # For Windsurf -ctx hook windsurf +ctx setup windsurf ``` Each command prints the configuration you need. How you apply it depends on the @@ -314,12 +314,12 @@ source <(ctx completion zsh) # or bash/fish # Start Claude Code, then ask: "Do you remember?" # ## Cursor ## -ctx hook cursor +ctx setup cursor # Add the system prompt to .cursor/settings.json # Paste context: ctx agent --budget 4000 | pbcopy # ## Aider ## -ctx hook aider +ctx setup aider # Create .aider.conf.yml with read: paths # Run watch mode alongside: ctx watch --log /tmp/aider.log diff --git a/docs/recipes/troubleshooting.md b/docs/recipes/troubleshooting.md index 1eb24bf52..9de9275af 100644 --- a/docs/recipes/troubleshooting.md +++ b/docs/recipes/troubleshooting.md @@ -153,7 +153,7 @@ ctx init # create .context/ with template files ctx init --minimal # or just the essentials (CONSTITUTION, TASKS, DECISIONS) ``` -**Commands that work without initialization**: `ctx init`, `ctx hook`, +**Commands that work without initialization**: `ctx init`, `ctx setup`, `ctx doctor`, and help-only grouping commands (`ctx`, `ctx system`). ### "My hook isn't firing" diff --git a/editors/vscode/.vscodeignore b/editors/vscode/.vscodeignore index d81632969..07f67a409 100644 --- a/editors/vscode/.vscodeignore +++ b/editors/vscode/.vscodeignore @@ -3,7 +3,11 @@ node_modules/ src/ tsconfig.json vitest.config.ts +vitest.workspace.ts package-lock.json +test-insiders-sim.js +*.vsix +.github/ **/*.map **/*.ts !dist/** diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md new file mode 100644 index 000000000..cd4586a30 --- /dev/null +++ b/editors/vscode/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to the **ctx — Persistent Context for AI** extension +will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/). + +## [0.9.0] — 2026-03-19 + +### Added + +- **@ctx chat participant** with 45 slash commands covering context + lifecycle, task management, session recall, and discovery +- **Natural language routing** — type plain English after `@ctx` and + the extension maps it to the correct handler +- **Auto-bootstrap** — downloads the ctx CLI binary if not found on PATH +- **Detection ring** — terminal command watcher and file edit watcher + record governance violations for the MCP engine +- **Status bar reminders** — `$(bell) ctx` indicator for pending reminders +- **Automatic hooks** — file save, git commit, dependency change, and + context file change handlers +- **Follow-up suggestions** — context-aware buttons after each command +- **`/diag` command** — diagnose extension issues with step-by-step timing + +### Configuration + +- `ctx.executablePath` — path to the ctx CLI binary (default: `ctx`) + +## [Unreleased] + +- Marketplace publication diff --git a/editors/vscode/LICENSE b/editors/vscode/LICENSE new file mode 100644 index 000000000..be659d906 --- /dev/null +++ b/editors/vscode/LICENSE @@ -0,0 +1,207 @@ + / ctx: https://ctx.ist + ,'`./ do you remember? + `.,'\ + \ Copyright 2026-present Context contributors. + SPDX-License-Identifier: Apache-2.0 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 4bfe70f3f..5c97c7114 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -8,37 +8,174 @@ ## `ctx`: VS Code Chat Extension -A VS Code Chat Participant that brings [ctx](https://ctx.ist): -(*persistent project context for AI coding sessions*) -directly into GitHub Copilot Chat. - -## Usage - -Type `@ctx` in the VS Code Chat view, then use slash commands: - -| Command | Description | -|-----------------|--------------------------------------------------------| -| `@ctx /init` | Initialize a `.context/` directory with template files | -| `@ctx /status` | Show context summary with token estimate | -| `@ctx /agent` | Print AI-ready context packet | -| `@ctx /drift` | Detect stale or invalid context | -| `@ctx /recall` | Browse and search AI session history | -| `@ctx /hook` | Generate AI tool integration configs | -| `@ctx /add` | Add a task, decision, or learning | -| `@ctx /load` | Output assembled context Markdown | -| `@ctx /compact` | Archive completed tasks and clean up | -| `@ctx /sync` | Reconcile context with codebase | +A VS Code Chat Participant that brings [ctx](https://ctx.ist) — persistent +project context for AI coding sessions — directly into GitHub Copilot Chat. + +Type `@ctx` in the Chat view to access 45 slash commands, automatic context +hooks, a reminder status bar, and natural language routing — all powered by +the ctx CLI. + +## Quick Start + +1. Install the extension (or build from source — see [Development](#development)) +2. Open a project in VS Code +3. Open Copilot Chat and type `@ctx /init` + +The extension auto-downloads the ctx CLI binary if it isn't on your PATH. + +## Slash Commands + +### Core Context + +| Command | Description | +|---------|-------------| +| `/init` | Initialize a `.context/` directory with template files | +| `/status` | Show context summary with token estimate | +| `/agent` | Print AI-ready context packet | +| `/drift` | Detect stale or invalid context | +| `/recall` | Browse and search AI session history | +| `/hook` | Generate AI tool integration configs (copilot, claude) | +| `/add` | Add a task, decision, learning, or convention | +| `/load` | Output assembled context Markdown | +| `/compact` | Archive completed tasks and clean up context | +| `/sync` | Reconcile context with codebase | + +### Tasks & Reminders + +| Command | Description | +|---------|-------------| +| `/complete` | Mark a task as completed | +| `/remind` | Manage session-scoped reminders (add, list, dismiss) | +| `/tasks` | Archive or snapshot tasks | +| `/next` | Show the next open task from TASKS.md | +| `/implement` | Show the implementation plan with progress | + +### Session Lifecycle + +| Command | Description | +|---------|-------------| +| `/wrapup` | End-of-session wrap-up with status, drift, and journal audit | +| `/remember` | Recall recent AI sessions for this project | +| `/reflect` | Surface items worth persisting as decisions or learnings | +| `/pause` | Save session state for later | +| `/resume` | Restore a paused session | + +### Discovery & Planning + +| Command | Description | +|---------|-------------| +| `/brainstorm` | Browse and develop ideas from `ideas/` | +| `/spec` | List or scaffold feature specs from templates | +| `/verify` | Run verification checks (doctor + drift) | +| `/map` | Show dependency map (go.mod, package.json) | +| `/prompt` | Browse and view prompt templates | +| `/blog` | Draft a blog post from recent context | +| `/changelog` | Show recent commits for changelog | + +### Maintenance & Audit + +| Command | Description | +|---------|-------------| +| `/check-links` | Audit local links in context files | +| `/journal` | View or export journal entries | +| `/consolidate` | Find duplicate entries across context files | +| `/audit` | Alignment audit — drift + convention check | +| `/worktree` | Git worktree management (list, add) | + +### Context Metadata + +| Command | Description | +|---------|-------------| +| `/memory` | Claude Code memory bridge (sync, status, diff, import, publish) | +| `/decisions` | List or reindex project decisions | +| `/learnings` | List or reindex project learnings | +| `/config` | Manage config profiles (switch, status, schema) | +| `/permissions` | Backup or restore Claude settings | +| `/changes` | Show what changed since last session | +| `/deps` | Show package dependency graph | +| `/guide` | Quick-reference cheat sheet for ctx | +| `/reindex` | Regenerate indices for DECISIONS.md and LEARNINGS.md | +| `/why` | Read the philosophy behind ctx | + +### System & Diagnostics + +| Command | Description | +|---------|-------------| +| `/system` | System diagnostics and bootstrap | +| `/pad` | Encrypted scratchpad for sensitive notes | +| `/notify` | Send webhook notifications | + +Sub-routes for `/system`: `resources`, `doctor`, `bootstrap`, `stats`, +`backup`, `message`. + +## Automatic Hooks + +The extension registers several VS Code event handlers that mirror +Claude Code's hook system. These run in the background — no user action +needed. + +| Trigger | What Happens | +|---------|--------------| +| **File save** | Runs task-completion check on non-`.context/` files | +| **Git commit** | Notification prompting to add a Decision, Learning, run Verify, or Skip | +| **`.context/` file change** | Refreshes reminders and regenerates `.github/copilot-instructions.md` | +| **Dependency file change** | Notification when `go.mod`, `package.json`, etc. change — offers `/map` | +| **Every 5 minutes** | Updates reminder status bar and writes heartbeat timestamp | +| **Extension activate** | Fires `session-event --type start` to ctx CLI | +| **Extension deactivate** | Fires `session-event --type end` to ctx CLI | + +## Status Bar + +A `$(bell) ctx` indicator appears in the status bar when you have pending +reminders. It updates every 5 minutes. When no reminders are due, it hides +automatically. + +## Natural Language + +You can also type plain English after `@ctx` — the extension routes +common phrases to the correct handler: + +- "What should I work on next?" → `/next` +- "Time to wrap up" → `/wrapup` +- "Show me the status" → `/status` +- "Add a decision" → `/add` +- "Check for drift" → `/drift` + +## Auto-Bootstrap + +If the ctx CLI isn't found on PATH or at the configured path, the +extension automatically downloads the correct platform binary from +[GitHub Releases](https://github.com/ActiveMemory/ctx/releases): + +1. Detects OS and architecture (darwin/linux/windows, amd64/arm64) +2. Fetches the latest release from the GitHub API +3. Downloads and verifies the matching binary +4. Caches it in VS Code's global storage directory + +Subsequent sessions reuse the cached binary. To force a specific version, +set `ctx.executablePath` in your settings. + +## Follow-Up Suggestions + +After each command, Copilot Chat shows context-aware follow-up buttons. +For example: + +- After `/init` → "Show status" or "Generate copilot integration" +- After `/drift` → "Sync context" or "Show status" +- After `/reflect` → "Add decision", "Add learning", or "Wrap up" +- After `/spec` → "Show implementation plan" or "Run verification" ## Prerequisites -- [ctx](https://ctx.ist) CLI installed and available on PATH (or configure `ctx.executablePath`) -- VS Code 1.93+ with GitHub Copilot Chat +- VS Code 1.93+ +- [GitHub Copilot Chat](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot-chat) extension +- [ctx](https://ctx.ist) CLI on PATH — or let the extension auto-download it ## Configuration -| Setting | Default | Description | -|----------------------|---------|----------------------------| -| `ctx.executablePath` | `ctx` | Path to the ctx executable | +| Setting | Default | Description | +|---------|---------|-------------| +| `ctx.executablePath` | `ctx` | Path to the ctx CLI binary. Set this if ctx isn't on PATH and you don't want auto-download. | ## Development @@ -47,8 +184,32 @@ cd editors/vscode npm install npm run watch # Watch mode npm run build # Production build +npm test # Run tests (53 test cases via vitest) ``` +### Architecture + +The extension is a single-file implementation +(`src/extension.ts`, ~3 000 lines) that: + +- Registers a `ChatParticipant` with `@ctx` as the handle +- Routes slash commands to dedicated `handleXxx()` functions +- Each handler calls the ctx CLI via `execFile` and streams the output +- On Windows, uses `shell: true` so PATH resolution works without `.exe` +- Merges stdout/stderr with deduplication (Cobra prints errors to both) +- A `handleFreeform()` function maps natural language to handlers + +### Testing + +Tests live in `src/extension.test.ts` and use vitest with a VS Code API +mock. They verify: + +- All 45 command handlers exist and are callable +- `runCtx` invokes the correct binary with correct arguments +- Platform detection returns valid GOOS/GOARCH values +- Follow-up suggestions are returned after commands +- Edge cases: missing workspace, cancellation, empty output + ## License Apache-2.0 diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 14b819373..3d01f2881 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -34,7 +34,13 @@ "copilot", "chat" ], - "activationEvents": [], + "pricing": "Free", + "extensionDependencies": [ + "github.copilot-chat" + ], + "activationEvents": [ + "onStartupFinished" + ], "main": "./dist/extension.js", "contributes": { "chatParticipants": [ @@ -164,6 +170,10 @@ { "name": "reindex", "description": "Rebuild context file indices" + }, + { + "name": "diag", + "description": "Diagnose extension issues — times each step to find hangs" } ], "disambiguation": [ @@ -183,6 +193,7 @@ ] } ], + ], "configuration": { "title": "ctx", "properties": { diff --git a/editors/vscode/src/extension.test.ts b/editors/vscode/src/extension.test.ts index 4fb12792c..739ffafa0 100644 --- a/editors/vscode/src/extension.test.ts +++ b/editors/vscode/src/extension.test.ts @@ -263,7 +263,7 @@ describe("handleComplete", () => { await handleComplete(stream as never, "Fix login bug", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["complete", "Fix login bug", "--no-color"], + ["complete", "Fix login bug"], expect.anything(), expect.any(Function) ); @@ -288,7 +288,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "list", "--no-color"], + ["remind", "list"], expect.anything(), expect.any(Function) ); @@ -301,7 +301,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "add Check CI status", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "add", "Check CI status", "--no-color"], + ["remind", "add", "Check CI status"], expect.anything(), expect.any(Function) ); @@ -314,7 +314,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "Check CI status", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "add", "Check CI status", "--no-color"], + ["remind", "add", "Check CI status"], expect.anything(), expect.any(Function) ); @@ -327,7 +327,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "list", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "list", "--no-color"], + ["remind", "list"], expect.anything(), expect.any(Function) ); @@ -340,7 +340,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "dismiss 2", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "dismiss", "2", "--no-color"], + ["remind", "dismiss", "2"], expect.anything(), expect.any(Function) ); @@ -353,7 +353,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "dismiss", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "dismiss", "--all", "--no-color"], + ["remind", "dismiss", "--all"], expect.anything(), expect.any(Function) ); @@ -394,7 +394,7 @@ describe("handleTasks", () => { await handleTasks(stream as never, "archive", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["tasks", "archive", "--no-color"], + ["tasks", "archive"], expect.anything(), expect.any(Function) ); @@ -408,7 +408,7 @@ describe("handleTasks", () => { await handleTasks(stream as never, "snapshot pre-refactor", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["tasks", "snapshot", "pre-refactor", "--no-color"], + ["tasks", "snapshot", "pre-refactor"], expect.anything(), expect.any(Function) ); @@ -421,7 +421,7 @@ describe("handleTasks", () => { await handleTasks(stream as never, "snapshot", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["tasks", "snapshot", "--no-color"], + ["tasks", "snapshot"], expect.anything(), expect.any(Function) ); @@ -454,7 +454,7 @@ describe("handlePad", () => { await handlePad(stream as never, "", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "--no-color"], + ["pad"], expect.anything(), expect.any(Function) ); @@ -467,7 +467,7 @@ describe("handlePad", () => { await handlePad(stream as never, "add my secret note", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "add", "my secret note", "--no-color"], + ["pad", "add", "my secret note"], expect.anything(), expect.any(Function) ); @@ -487,7 +487,7 @@ describe("handlePad", () => { await handlePad(stream as never, "show 1", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "show", "1", "--no-color"], + ["pad", "show", "1"], expect.anything(), expect.any(Function) ); @@ -500,7 +500,7 @@ describe("handlePad", () => { await handlePad(stream as never, "rm 2", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "rm", "2", "--no-color"], + ["pad", "rm", "2"], expect.anything(), expect.any(Function) ); @@ -520,7 +520,7 @@ describe("handlePad", () => { await handlePad(stream as never, "edit 1 new text", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "edit", "1", "new", "text", "--no-color"], + ["pad", "edit", "1", "new", "text"], expect.anything(), expect.any(Function) ); @@ -533,7 +533,7 @@ describe("handlePad", () => { await handlePad(stream as never, "mv 1 3", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "mv", "1", "3", "--no-color"], + ["pad", "mv", "1", "3"], expect.anything(), expect.any(Function) ); @@ -574,7 +574,7 @@ describe("handleNotify", () => { await handleNotify(stream as never, "setup", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["notify", "setup", "--no-color"], + ["notify", "setup"], expect.anything(), expect.any(Function) ); @@ -588,7 +588,7 @@ describe("handleNotify", () => { await handleNotify(stream as never, "test", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["notify", "test", "--no-color"], + ["notify", "test"], expect.anything(), expect.any(Function) ); @@ -601,7 +601,7 @@ describe("handleNotify", () => { await handleNotify(stream as never, "build done --event build", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["notify", "build", "done", "--event", "build", "--no-color"], + ["notify", "build", "done", "--event", "build"], expect.anything(), expect.any(Function) ); @@ -650,7 +650,7 @@ describe("handleSystem", () => { await handleSystem(stream as never, "resources", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["system", "resources", "--no-color"], + ["system", "resources"], expect.anything(), expect.any(Function) ); @@ -664,7 +664,7 @@ describe("handleSystem", () => { await handleSystem(stream as never, "bootstrap", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["system", "bootstrap", "--no-color"], + ["system", "bootstrap"], expect.anything(), expect.any(Function) ); @@ -678,7 +678,7 @@ describe("handleSystem", () => { await handleSystem(stream as never, "message list", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["system", "message", "list", "--no-color"], + ["system", "message", "list"], expect.anything(), expect.any(Function) ); diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 87feb6f6e..0b618bb04 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -324,15 +324,15 @@ async function handleInit( // project context automatically. stream.progress("Generating Copilot instructions..."); try { - const hookResult = await runCtx( - ["hook", "copilot", "--write", "--no-color"], + const setupResult = await runCtx( + ["setup", "copilot", "--write", "--no-color"], cwd, token ); - const hookOutput = (hookResult.stdout + hookResult.stderr).trim(); - if (hookOutput) { + const setupOutput = (setupResult.stdout + setupResult.stderr).trim(); + if (setupOutput) { stream.markdown( - "\n**Copilot integration:**\n```\n" + hookOutput + "\n```" + "\n**Copilot integration:**\n```\n" + setupOutput + "\n```" ); } else { stream.markdown( @@ -340,10 +340,10 @@ async function handleInit( ); } } catch { - // Non-fatal — init succeeded, hook is a bonus + // Non-fatal — init succeeded, setup is a bonus stream.markdown( "\n> **Note:** Could not generate `.github/copilot-instructions.md`. " + - "Run `@ctx /hook copilot` manually." + "Run `@ctx /setup copilot` manually." ); } @@ -441,7 +441,7 @@ async function handleRecall( return { metadata: { command: "recall" } }; } -async function handleHook( +async function handleSetup( stream: vscode.ChatResponseStream, prompt: string, cwd: string, @@ -451,7 +451,7 @@ async function handleHook( const tool = parts[0] || "copilot"; const preview = parts.includes("preview") || parts.includes("--preview"); - const args = ["hook", tool]; + const args = ["setup", tool]; if (!preview) { args.push("--write"); } @@ -479,7 +479,7 @@ async function handleHook( `**Error:** Failed to generate hook.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); } - return { metadata: { command: "hook" } }; + return { metadata: { command: "setup" } }; } async function handleAdd( @@ -1555,7 +1555,7 @@ async function handleFreeform( "| `/agent` | Print AI-ready context packet |\n" + "| `/drift` | Detect stale or invalid context |\n" + "| `/recall` | Browse session history |\n" + - "| `/hook` | Generate tool integration configs |\n" + + "| `/setup` | Generate tool integration configs |\n" + "| `/add` | Add task, decision, or learning |\n" + "| `/load` | Output assembled context |\n" + "| `/compact` | Archive completed tasks |\n" + @@ -1624,8 +1624,8 @@ const handler: vscode.ChatRequestHandler = async ( return handleDrift(stream, cwd, token); case "recall": return handleRecall(stream, request.prompt, cwd, token); - case "hook": - return handleHook(stream, request.prompt, cwd, token); + case "setup": + return handleSetup(stream, request.prompt, cwd, token); case "add": return handleAdd(stream, request.prompt, cwd, token); case "load": @@ -1711,7 +1711,7 @@ export function activate(extensionContext: vscode.ExtensionContext) { { prompt: "Show my context status", command: "status" }, { prompt: "Generate copilot integration", - command: "hook", + command: "setup", } ); break; diff --git a/hack/sync-copilot-skills.sh b/hack/sync-copilot-skills.sh new file mode 100755 index 000000000..f103a664c --- /dev/null +++ b/hack/sync-copilot-skills.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +# / ctx: https://ctx.ist +# ,'`./ do you remember? +# `.,'\ +# \ Copyright 2026-present Context contributors. +# SPDX-License-Identifier: Apache-2.0 + +# sync-copilot-skills.sh — sync Copilot CLI skills from canonical ctx skills. +# +# ctx skills (internal/assets/claude/skills/) are the source of truth. +# Copilot CLI skills (internal/assets/integrations/copilot-cli/skills/) are +# generated from them with the `allowed-tools` frontmatter key stripped +# (Claude Code-specific, not applicable to Copilot). +# +# Skills that exist only in the Copilot directory (no ctx counterpart) +# are left untouched. + +set -euo pipefail + +CTX_SKILLS="internal/assets/claude/skills" +COPILOT_SKILLS="internal/assets/integrations/copilot-cli/skills" + +synced=0 +skipped=0 + +for copilot_dir in "$COPILOT_SKILLS"/*/; do + skill_name=$(basename "$copilot_dir") + ctx_skill="$CTX_SKILLS/$skill_name/SKILL.md" + copilot_skill="$copilot_dir/SKILL.md" + + if [ ! -f "$ctx_skill" ]; then + # No ctx counterpart — Copilot-only skill, leave untouched. + skipped=$((skipped + 1)) + continue + fi + + # Strip `allowed-tools:` line from frontmatter (Claude Code-specific). + sed '/^allowed-tools:/d' "$ctx_skill" > "$copilot_skill" + synced=$((synced + 1)) +done + +echo "Copilot skills synced: $synced updated, $skipped Copilot-only (unchanged)." diff --git a/internal/assets/commands/commands.yaml b/internal/assets/commands/commands.yaml index 15d75302f..a2a6ba2e5 100644 --- a/internal/assets/commands/commands.yaml +++ b/internal/assets/commands/commands.yaml @@ -243,7 +243,7 @@ guide: Use --skills to list all available slash-command skills. Use --commands to list all CLI commands. short: Quick-reference cheat sheet for ctx -hook: +setup: long: |- Generate configuration and instructions for integrating Context with AI tools. @@ -256,10 +256,10 @@ hook: windsurf - Windsurf IDE Use --write to generate the configuration file directly: - ctx hook copilot --write # Creates .github/copilot-instructions.md + ctx setup copilot --write # Creates .github/copilot-instructions.md Example: - ctx hook cursor + ctx setup cursor short: Generate AI tool integration configs initialize: long: |- @@ -1236,6 +1236,15 @@ system.resume: The session ID is read from stdin JSON (same as hooks) or --session-id flag. short: Resume context hooks for this session +system.sessionevent: + long: |- + Records a session lifecycle event (start or end) to the event log. + Called by editor integrations when a workspace is opened or closed. + + Examples: + ctx system session-event --type start --caller vscode + ctx system session-event --type end --caller vscode + short: Record session start or end system.specsnudge: long: |- Emits a directive reminding the agent to save plans to specs/ diff --git a/internal/assets/commands/flags.yaml b/internal/assets/commands/flags.yaml index ecdd756f1..62633dae7 100644 --- a/internal/assets/commands/flags.yaml +++ b/internal/assets/commands/flags.yaml @@ -34,6 +34,8 @@ compact.archive: short: Create .context/archive/ for old content context-dir: short: 'Override context directory path (default: .context)' +initialize.caller: + short: Identify the calling tool (e.g. vscode) to tailor output deps.external: short: Include external module dependencies deps.format: @@ -50,7 +52,7 @@ guide.commands: short: List all CLI commands guide.skills: short: List all available skills -hook.write: +setup.write: short: Write the configuration file instead of printing initialize.force: short: Overwrite existing context files @@ -208,6 +210,10 @@ system.resources.json: short: Output in JSON format system.resume.session-id: short: Session ID (overrides stdin) +system.sessionevent.caller: + short: Calling editor (e.g., vscode) +system.sessionevent.type: + short: 'Event type: start or end' system.stats.follow: short: Stream new entries as they arrive system.stats.json: diff --git a/internal/assets/commands/text/errors.yaml b/internal/assets/commands/text/errors.yaml index 4f3a0d59b..8b55ea78a 100644 --- a/internal/assets/commands/text/errors.yaml +++ b/internal/assets/commands/text/errors.yaml @@ -418,6 +418,8 @@ err.session.session-id-required: short: please provide a session ID or use --latest err.session.session-not-found: short: 'session not found: %s' +err.session.event-invalid-type: + short: "--type must be '%s' or '%s', got %q" err.site.marshal-feed: short: 'cannot marshal feed: %w' err.site.no-site-config: diff --git a/internal/assets/commands/text/hooks.yaml b/internal/assets/commands/text/hooks.yaml index bef28689b..a9d3dc19b 100644 --- a/internal/assets/commands/text/hooks.yaml +++ b/internal/assets/commands/text/hooks.yaml @@ -378,6 +378,20 @@ heartbeat.notify-plain: short: 'heartbeat: prompt #%d (context_modified=%t)' heartbeat.notify-tokens: short: 'heartbeat: prompt #%d (context_modified=%t tokens=%s pct=%d%%)' +hook.agents: + short: | + AGENTS.md (Universal Agent Instructions) + ========================================= + + Generate AGENTS.md in the project root with universal + agent instructions that work across all AI coding tools. + + AGENTS.md is read natively by: Codex, Gemini CLI, OpenCode, + Claude Code, and GitHub Copilot CLI. + + Run with --write to generate: + + ctx setup agents --write hook.aider: short: | Aider Integration @@ -420,7 +434,23 @@ hook.copilot: Add the following to .github/copilot-instructions.md, or run with --write to generate the file directly: - ctx hook copilot --write + ctx setup copilot --write +hook.copilot-cli: + short: | + GitHub Copilot CLI Integration + ============================== + + Generate .github/hooks/ with ctx lifecycle hooks + for the GitHub Copilot CLI agent (cross-platform). + + This creates: + .github/hooks/ctx-hooks.json Hook configuration + .github/hooks/scripts/*.sh Bash scripts (Linux/macOS/WSL) + .github/hooks/scripts/*.ps1 PowerShell scripts (Windows) + + Run with --write to generate all files: + + ctx setup copilot-cli --write hook.cursor: short: | Cursor IDE Integration @@ -443,10 +473,12 @@ hook.cursor: hook.supported-tools: short: | Supported tools: + agents - AGENTS.md (universal agent instructions) claude-code - Anthropic's Claude Code CLI (use plugin instead) cursor - Cursor IDE aider - Aider AI coding assistant - copilot - GitHub Copilot + copilot - GitHub Copilot (VS Code extension) + copilot-cli - GitHub Copilot CLI (terminal agent) windsurf - Windsurf IDE hook.windsurf: short: | diff --git a/internal/assets/commands/text/mcp.yaml b/internal/assets/commands/text/mcp.yaml index 980d3e9af..fcb649a3f 100644 --- a/internal/assets/commands/text/mcp.yaml +++ b/internal/assets/commands/text/mcp.yaml @@ -350,3 +350,16 @@ mcp.format-watch-completed: short: 'Completed: %s' mcp.format-wrote: short: Wrote %s to .context/%s. + +mcp.gov-session-not-started: + short: '⚠ Session not started. Call ctx_session_event(type="start") to enable tracking.' +mcp.gov-context-not-loaded: + short: '⚠ Context not loaded. Call ctx_status() to load context before proceeding.' +mcp.gov-drift-not-checked: + short: '⚠ Drift not checked in %d minutes. Consider calling ctx_drift().' +mcp.gov-drift-never-checked: + short: '⚠ Drift has not been checked this session. Consider calling ctx_drift().' +mcp.gov-persist-nudge: + short: '⚠ %d tool calls since last context write. Persist decisions, learnings, or completed tasks with ctx_add() or ctx_complete().' +mcp.gov-violation-critical: + short: '🚨 CRITICAL: %s — %s (at %s). Review this action immediately. If unintended, revert it.' diff --git a/internal/assets/commands/text/write.yaml b/internal/assets/commands/text/write.yaml index 63b859159..4420a3cd4 100644 --- a/internal/assets/commands/text/write.yaml +++ b/internal/assets/commands/text/write.yaml @@ -103,6 +103,31 @@ write.format-si-mega-upper: short: "%.1fM" write.format-thousands: short: "%d,%03d" +write.hook-agents-created: + short: ' ✓ %s' +write.hook-agents-merged: + short: ' ✓ %s (merged ctx section)' +write.hook-agents-skipped: + short: ' ○ %s (ctx markers exist, skipped)' +write.hook-agents-summary: + short: |- + AGENTS.md is now available for all AI coding tools. + Tools that read AGENTS.md natively: Codex, Gemini CLI, OpenCode, + Claude Code, GitHub Copilot CLI. +write.hook-copilot-cli-created: + short: ' ✓ %s' +write.hook-copilot-cli-skipped: + short: ' ○ %s (ctx hooks exist, skipped)' +write.hook-copilot-cli-summary: + short: |- + GitHub Copilot CLI will now: + 1. Record session start/end via ctx + 2. Block dangerous commands (preToolUse) + 3. Audit tool invocations to .context/state/ + + Hooks work on all platforms: + Linux/macOS/WSL → .sh scripts + Windows → .ps1 scripts write.hook-copilot-created: short: ' ✓ %s' write.hook-copilot-force-hint: @@ -512,6 +537,8 @@ write.restore-restored-header: short: 'Restored %d allow permission(s):' write.resumed: short: Context hooks resumed for session %s +write.session-event: + short: 'session-%s: %s' write.setup-done: short: |- Webhook configured: %s @@ -676,3 +703,14 @@ write.knowledge-unit-lines: write.version-drift-fallback: short: "VERSION (%s), plugin.json (%s), marketplace.json (%s) are out of sync. Update all three before releasing." + +write.vscode-created: + short: " ✓ %s" +write.vscode-exists-skipped: + short: " ○ %s (exists, skipped)" +write.vscode-recommendation-exists: + short: " ○ %s (recommendation exists)" +write.vscode-add-manually: + short: " ○ %s (exists, add %s manually)" +write.vscode-warn-non-fatal: + short: " ⚠ %s: %v" diff --git a/internal/assets/embed.go b/internal/assets/embed.go index 47ee4d28d..93104d84e 100644 --- a/internal/assets/embed.go +++ b/internal/assets/embed.go @@ -12,7 +12,9 @@ import ( //go:embed claude/.claude-plugin/plugin.json claude/CLAUDE.md //go:embed claude/skills/*/references/*.md claude/skills/*/SKILL.md -//go:embed context/*.md project/* entry-templates/*.md hooks/*.md +//go:embed context/*.md project/* entry-templates/*.md integrations/agents.md integrations/copilot/*.md +//go:embed integrations/copilot-cli/*.json integrations/copilot-cli/*.md integrations/copilot-cli/scripts/*.sh integrations/copilot-cli/scripts/*.ps1 +//go:embed integrations/copilot-cli/skills/*/SKILL.md //go:embed hooks/messages/*/*.txt hooks/messages/registry.yaml //go:embed schema/*.json why/*.md //go:embed permissions/*.txt commands/*.yaml commands/text/*.yaml journal/*.css diff --git a/internal/assets/embed_test.go b/internal/assets/embed_test.go index c2120d395..135193019 100644 --- a/internal/assets/embed_test.go +++ b/internal/assets/embed_test.go @@ -391,7 +391,7 @@ func TestSchemaCoversCtxRC(t *testing.T) { } func TestHookMessageRegistry(t *testing.T) { - data, readErr := FS.ReadFile(asset.PathHookRegistry) + data, readErr := FS.ReadFile(asset.PathMessageRegistry) if readErr != nil { t.Fatalf("unexpected error: %v", readErr) } diff --git a/internal/assets/integrations/agents.md b/internal/assets/integrations/agents.md new file mode 100644 index 000000000..69013e9dc --- /dev/null +++ b/internal/assets/integrations/agents.md @@ -0,0 +1,124 @@ +# Project Context + + + + +## You Have Persistent Memory + +This project uses Context (`ctx`) for context persistence across +sessions. Your memory is NOT ephemeral: it lives in `.context/`. + +## On Session Start + +Read these files **in order** before starting any work: + +1. `.context/CONSTITUTION.md`: Hard rules, NEVER violate +2. `.context/TASKS.md`: Current work items +3. `.context/CONVENTIONS.md`: Code patterns and standards +4. `.context/ARCHITECTURE.md`: System structure +5. `.context/DECISIONS.md`: Architectural decisions with rationale +6. `.context/LEARNINGS.md`: Gotchas, tips, lessons learned +7. `.context/AGENT_PLAYBOOK.md`: How to use this context system + +If `ctx` is installed (check with `which ctx` or `Get-Command ctx`): +- Run `ctx agent --budget 4000` for a token-budgeted context summary +- Run `ctx status` for a health check + +After reading, confirm: "I have read the required context files and I'm +following project conventions." + +## When Asked "Do You Remember?" + +**Do this FIRST (silently):** +- Read `.context/TASKS.md` +- Read `.context/DECISIONS.md` and `.context/LEARNINGS.md` +- Check `.context/sessions/` for recent session files + +**Then respond with a structured readback:** +1. **Last session**: cite the most recent session topic and date +2. **Active work**: list pending or in-progress tasks +3. **Recent context**: mention 1-2 recent decisions or learnings +4. **Next step**: offer to continue or ask what to focus on + +**Never** lead with "I don't have memory." The `.context/` files are +your memory. Read them silently, then present what you found as recall. + +## Context Files + +| File | Purpose | +|-------------------|----------------------------------------| +| CONSTITUTION.md | Hard rules, NEVER violate | +| TASKS.md | Current work items | +| CONVENTIONS.md | Code patterns and standards | +| ARCHITECTURE.md | System structure | +| DECISIONS.md | Architectural decisions with rationale | +| LEARNINGS.md | Gotchas, tips, lessons learned | +| AGENT_PLAYBOOK.md | How to use this context system | + +All files live in `.context/`. + +## Session Persistence + +After completing meaningful work, save a session summary to +`.context/sessions/`. + +### Session File Format + +Create a file named `YYYY-MM-DD-topic.md`: + +```markdown +# Session: YYYY-MM-DD - Brief Topic Description + +## What Was Done +- Describe completed work items + +## Decisions +- Key decisions made and their rationale + +## Learnings +- Gotchas, tips, or insights discovered + +## Next Steps +- Follow-up work or remaining items +``` + +### When to Save + +- After completing a task or feature +- After making architectural decisions +- After a debugging session +- Before ending the session +- At natural breakpoints in long sessions + +## Context Updates During Work + +Proactively update context files as you work: + +| Event | Action | +|-----------------------------|----------------------------------| +| Made architectural decision | Add to `.context/DECISIONS.md` | +| Discovered gotcha/bug | Add to `.context/LEARNINGS.md` | +| Established new pattern | Add to `.context/CONVENTIONS.md` | +| Completed task | Mark [x] in `.context/TASKS.md` | + +## Self-Check + +Periodically ask yourself: + +> "If this session ended right now, would the next session know +> what happened?" + +If no: save a session file or update context files before continuing. + +## CLI Commands + +If `ctx` is installed, use these commands: + +```bash +ctx status # Context summary and health check +ctx agent # AI-ready context packet +ctx drift # Check for stale context +ctx recall list # Recent session history +``` + + diff --git a/internal/assets/integrations/copilot-cli/agents-ctx.md b/internal/assets/integrations/copilot-cli/agents-ctx.md new file mode 100644 index 000000000..2efc2b942 --- /dev/null +++ b/internal/assets/integrations/copilot-cli/agents-ctx.md @@ -0,0 +1,34 @@ +# ctx: Context Management Agent + +You are a context management specialist. Your role is to help maintain +project context using the `ctx` system. + +## Capabilities + +- Read and update `.context/` files (TASKS, DECISIONS, LEARNINGS, etc.) +- Run `ctx` CLI commands for status, drift, and recall +- Save session summaries to `.context/sessions/` +- Check context health and suggest updates + +## When to Delegate to This Agent + +Use this agent when: +- The user asks to update context files +- Session context needs to be saved +- Context health needs checking +- Tasks need to be marked complete or added + +## Workflow + +1. Run `ctx status` to assess current context health +2. Read the relevant `.context/` files +3. Make the requested changes +4. Run `ctx drift` to verify no stale context remains +5. Save a session summary if meaningful work was done + +## Rules + +- NEVER modify `.context/CONSTITUTION.md` without explicit user approval +- Always use marker-based sections when editing generated files +- Prefer `ctx` CLI commands over manual file editing when available +- Save session summaries in `YYYY-MM-DD-topic.md` format diff --git a/internal/assets/integrations/copilot-cli/ctx-hooks.json b/internal/assets/integrations/copilot-cli/ctx-hooks.json new file mode 100644 index 000000000..2aa48f448 --- /dev/null +++ b/internal/assets/integrations/copilot-cli/ctx-hooks.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": ".github/hooks/scripts/ctx-sessionStart.sh", + "powershell": ".github/hooks/scripts/ctx-sessionStart.ps1", + "cwd": ".", + "timeoutSec": 10 + } + ], + "preToolUse": [ + { + "type": "command", + "bash": ".github/hooks/scripts/ctx-preToolUse.sh", + "powershell": ".github/hooks/scripts/ctx-preToolUse.ps1", + "cwd": ".", + "timeoutSec": 5 + } + ], + "postToolUse": [ + { + "type": "command", + "bash": ".github/hooks/scripts/ctx-postToolUse.sh", + "powershell": ".github/hooks/scripts/ctx-postToolUse.ps1", + "cwd": ".", + "timeoutSec": 5 + } + ], + "sessionEnd": [ + { + "type": "command", + "bash": ".github/hooks/scripts/ctx-sessionEnd.sh", + "powershell": ".github/hooks/scripts/ctx-sessionEnd.ps1", + "cwd": ".", + "timeoutSec": 15 + } + ] + } +} diff --git a/internal/assets/integrations/copilot-cli/instructions-context.md b/internal/assets/integrations/copilot-cli/instructions-context.md new file mode 100644 index 000000000..f894e424a --- /dev/null +++ b/internal/assets/integrations/copilot-cli/instructions-context.md @@ -0,0 +1,27 @@ +# Context Files Instructions + +Files in `.context/` are the project's persistent memory. Follow these +rules when reading or modifying them. + +## Reading Context + +- Read `.context/CONSTITUTION.md` first: it contains hard rules +- Read `.context/TASKS.md` to understand current work items +- Use `ctx agent` for a token-budgeted summary instead of reading all files + +## Modifying Context + +- NEVER delete content from context files without explicit user approval +- Use append-only patterns: add new entries, mark old ones complete +- Task format: `- [ ] description #added:YYYY-MM-DD-HHMMSS` +- Decision format: date, decision, rationale as a section entry +- Mark completed tasks with `[x]`, never delete them + +## File Permissions + +- `CONSTITUTION.md`: Read-only unless user explicitly approves changes +- `TASKS.md`: Append new tasks, mark existing ones complete +- `DECISIONS.md`: Append only +- `LEARNINGS.md`: Append only +- `CONVENTIONS.md`: Append only, propose changes to user first +- `sessions/`: Create new files freely, never modify existing ones diff --git a/internal/assets/integrations/copilot-cli/scripts/ctx-postToolUse.ps1 b/internal/assets/integrations/copilot-cli/scripts/ctx-postToolUse.ps1 new file mode 100644 index 000000000..1122e853f --- /dev/null +++ b/internal/assets/integrations/copilot-cli/scripts/ctx-postToolUse.ps1 @@ -0,0 +1,18 @@ +# ctx postToolUse hook for GitHub Copilot CLI +# Reads tool result JSON from stdin and appends to audit log. +$ErrorActionPreference = 'SilentlyContinue' + +if (Get-Command ctx -ErrorAction SilentlyContinue) { + $RawInput = $input | Out-String + $LogDir = Join-Path '.context' 'state' + $LogFile = Join-Path $LogDir 'copilot-cli-audit.jsonl' + + if (Test-Path '.context') { + if (-not (Test-Path $LogDir)) { + New-Item -ItemType Directory -Path $LogDir -Force | Out-Null + } + $Timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $Entry = "{`"timestamp`":`"$Timestamp`",`"event`":`"postToolUse`",`"data`":$RawInput}" + Add-Content -Path $LogFile -Value $Entry -ErrorAction SilentlyContinue + } +} diff --git a/internal/assets/integrations/copilot-cli/scripts/ctx-postToolUse.sh b/internal/assets/integrations/copilot-cli/scripts/ctx-postToolUse.sh new file mode 100644 index 000000000..b8fbdb9ee --- /dev/null +++ b/internal/assets/integrations/copilot-cli/scripts/ctx-postToolUse.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# ctx postToolUse hook for GitHub Copilot CLI +# Reads tool result JSON from stdin and appends to audit log. +set -euo pipefail + +# Append tool invocation to audit log if ctx is available. +if command -v ctx >/dev/null 2>&1; then + INPUT=$(cat) + LOGDIR=".context/state" + LOGFILE="$LOGDIR/copilot-cli-audit.jsonl" + + if [ -d ".context" ]; then + mkdir -p "$LOGDIR" + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date +"%Y-%m-%dT%H:%M:%S") + echo "{\"timestamp\":\"$TIMESTAMP\",\"event\":\"postToolUse\",\"data\":$INPUT}" >> "$LOGFILE" 2>/dev/null || true + fi +fi diff --git a/internal/assets/integrations/copilot-cli/scripts/ctx-preToolUse.ps1 b/internal/assets/integrations/copilot-cli/scripts/ctx-preToolUse.ps1 new file mode 100644 index 000000000..a3c7f0678 --- /dev/null +++ b/internal/assets/integrations/copilot-cli/scripts/ctx-preToolUse.ps1 @@ -0,0 +1,46 @@ +# ctx preToolUse hook for GitHub Copilot CLI +# Reads tool invocation JSON from stdin and blocks dangerous commands. +$ErrorActionPreference = 'SilentlyContinue' + +$RawInput = $input | Out-String +if (-not $RawInput) { exit 0 } + +try { + $Data = $RawInput | ConvertFrom-Json +} catch { + exit 0 +} + +$ToolName = if ($Data.tool_name) { $Data.tool_name } elseif ($Data.tool) { $Data.tool } else { '' } + +# Block dangerous shell commands matching known patterns. +if ($ToolName -eq 'shell' -or $ToolName -eq 'powershell') { + $Command = '' + if ($Data.input -and $Data.input.command) { + $Command = $Data.input.command + } + + $DangerousPatterns = @( + 'sudo ', + 'rm -rf /', + 'rm -rf ~', + 'Remove-Item -Recurse -Force C:\', + 'Remove-Item -Recurse -Force $env:USERPROFILE', + 'chmod 777', + 'Format-Volume' + ) + foreach ($Pattern in $DangerousPatterns) { + if ($Command -like "*$Pattern*") { + Write-Error 'ctx: blocked dangerous command' + exit 1 + } + } + + $IrreversiblePatterns = @('git push', 'git reset --hard') + foreach ($Pattern in $IrreversiblePatterns) { + if ($Command -like "*$Pattern*") { + Write-Error 'ctx: blocked irreversible git operation — review first' + exit 1 + } + } +} diff --git a/internal/assets/integrations/copilot-cli/scripts/ctx-preToolUse.sh b/internal/assets/integrations/copilot-cli/scripts/ctx-preToolUse.sh new file mode 100644 index 000000000..34507b11f --- /dev/null +++ b/internal/assets/integrations/copilot-cli/scripts/ctx-preToolUse.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# ctx preToolUse hook for GitHub Copilot CLI +# Reads tool invocation JSON from stdin and blocks dangerous commands. +set -euo pipefail + +INPUT=$(cat) + +# Extract the tool name from the JSON input. +TOOL="" +if command -v jq >/dev/null 2>&1; then + TOOL=$(echo "$INPUT" | jq -r '.tool_name // .tool // empty' 2>/dev/null) +fi + +# Block dangerous shell commands matching known patterns. +if [ "$TOOL" = "shell" ] || [ "$TOOL" = "bash" ]; then + COMMAND="" + if command -v jq >/dev/null 2>&1; then + COMMAND=$(echo "$INPUT" | jq -r '.input.command // empty' 2>/dev/null) + fi + + case "$COMMAND" in + *"sudo "* | *"rm -rf /"* | *"rm -rf ~"* | *"chmod 777"*) + echo '{"decision":"deny","reason":"ctx: blocked dangerous command"}' >&2 + exit 1 + ;; + *"git push"* | *"git reset --hard"*) + echo '{"decision":"deny","reason":"ctx: blocked irreversible git operation — review first"}' >&2 + exit 1 + ;; + esac +fi diff --git a/internal/assets/integrations/copilot-cli/scripts/ctx-sessionEnd.ps1 b/internal/assets/integrations/copilot-cli/scripts/ctx-sessionEnd.ps1 new file mode 100644 index 000000000..fdac60d8c --- /dev/null +++ b/internal/assets/integrations/copilot-cli/scripts/ctx-sessionEnd.ps1 @@ -0,0 +1,7 @@ +# ctx sessionEnd hook for GitHub Copilot CLI +# Records session end event for recall and context persistence. +$ErrorActionPreference = 'SilentlyContinue' + +if (Get-Command ctx -ErrorAction SilentlyContinue) { + ctx system session-event --type end --caller copilot-cli 2>$null +} diff --git a/internal/assets/integrations/copilot-cli/scripts/ctx-sessionEnd.sh b/internal/assets/integrations/copilot-cli/scripts/ctx-sessionEnd.sh new file mode 100644 index 000000000..1c518ae4b --- /dev/null +++ b/internal/assets/integrations/copilot-cli/scripts/ctx-sessionEnd.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# ctx sessionEnd hook for GitHub Copilot CLI +# Records session end event for recall and context persistence. +set -euo pipefail + +if command -v ctx >/dev/null 2>&1; then + ctx system session-event --type end --caller copilot-cli 2>/dev/null || true +fi diff --git a/internal/assets/integrations/copilot-cli/scripts/ctx-sessionStart.ps1 b/internal/assets/integrations/copilot-cli/scripts/ctx-sessionStart.ps1 new file mode 100644 index 000000000..83ffb8d81 --- /dev/null +++ b/internal/assets/integrations/copilot-cli/scripts/ctx-sessionStart.ps1 @@ -0,0 +1,7 @@ +# ctx sessionStart hook for GitHub Copilot CLI +# Records session start and loads context status. +$ErrorActionPreference = 'SilentlyContinue' + +if (Get-Command ctx -ErrorAction SilentlyContinue) { + ctx system session-event --type start --caller copilot-cli 2>$null +} diff --git a/internal/assets/integrations/copilot-cli/scripts/ctx-sessionStart.sh b/internal/assets/integrations/copilot-cli/scripts/ctx-sessionStart.sh new file mode 100644 index 000000000..159b30797 --- /dev/null +++ b/internal/assets/integrations/copilot-cli/scripts/ctx-sessionStart.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# ctx sessionStart hook for GitHub Copilot CLI +# Records session start and loads context status. +set -euo pipefail + +if command -v ctx >/dev/null 2>&1; then + ctx system session-event --type start --caller copilot-cli 2>/dev/null || true +fi diff --git a/internal/assets/integrations/copilot-cli/skills/ctx-compact/SKILL.md b/internal/assets/integrations/copilot-cli/skills/ctx-compact/SKILL.md new file mode 100644 index 000000000..05145b060 --- /dev/null +++ b/internal/assets/integrations/copilot-cli/skills/ctx-compact/SKILL.md @@ -0,0 +1,35 @@ +--- +name: ctx-compact +description: "Archive completed tasks and trim context. Use when context files are growing large." +--- + +Archive completed tasks and trim stale context entries to keep +the context directory lean and within token budgets. + +## When to Use + +- When TASKS.md has many completed items +- When context token count is growing large +- When asked to "clean up" or "compact" context +- Before starting a new phase of work + +## When NOT to Use + +- When all tasks are still active +- When context is already compact + +## Execution + +Run the compact operation: + +```bash +ctx compact +``` + +This archives completed tasks from TASKS.md into the session +history and trims stale entries from other context files. + +After running, confirm: +- How many tasks were archived +- Current token budget usage +- Whether any manual cleanup is recommended diff --git a/internal/assets/integrations/copilot-cli/skills/ctx-drift/SKILL.md b/internal/assets/integrations/copilot-cli/skills/ctx-drift/SKILL.md new file mode 100644 index 000000000..59cd6f907 --- /dev/null +++ b/internal/assets/integrations/copilot-cli/skills/ctx-drift/SKILL.md @@ -0,0 +1,250 @@ +--- +name: ctx-drift +description: "Detect and fix context drift. Use to find stale paths, broken references, and constitution violations in context files." +--- + +Detect context drift at two layers: **structural** (stale paths, +missing files, constitution violations) via `ctx drift`, and +**semantic** (outdated conventions, superseded decisions, +irrelevant learnings) via agent analysis. The semantic layer is +where the real value is: the CLI cannot do it. + +## When to Use + +- At session start to verify context health before working +- After refactors, renames, or major structural changes +- When the user asks "is our context clean?", "anything + stale?", or "check for drift" +- Proactively when you notice a path in ARCHITECTURE.md or + CONVENTIONS.md that does not match the actual file tree +- Before a release or milestone to ensure context is accurate + +## When NOT to Use + +- When you just ran `/ctx-status` and everything looked fine + (status already shows drift warnings) +- Repeatedly in the same session without changes in between +- When the user is mid-flow on a task; do not interrupt with + unsolicited maintenance + +## Usage Examples + +```text +/ctx-drift +/ctx-drift (after the refactor) +``` + +## Execution + +Drift detection has two layers: **structural** (programmatic) and +**semantic** (agent-driven). Always do both. + +### Layer 1: Structural Checks + +Run the CLI tool for fast, programmatic checks: + +```bash +ctx drift +``` + +This catches dead paths, missing files, staleness indicators, +and constitution violations. These are necessary but insufficient: +they only detect structural problems. + +### Layer 2: Semantic Analysis + +After the structural check, read the context files yourself and +compare them to what you know about the codebase. This is where +you add real value: the CLI tool cannot do this. + +Check for: + +- **Outdated conventions**: Does CONVENTIONS.md describe patterns + the code no longer follows? Read a few source files in the + relevant area to verify. +- **Superseded decisions**: Does DECISIONS.md contain entries that + were implicitly overridden by later work? Look for decisions + whose rationale no longer applies. +- **Stale architecture descriptions**: Does ARCHITECTURE.md + describe module purposes that have changed? A path can still + exist while its description is wrong. +- **Irrelevant learnings**: Does LEARNINGS.md contain entries + about bugs that were since fixed or patterns that no longer + apply? +- **Contradictions**: Do any context files contradict each other + or contradict the actual code? + +### Reporting + +After both layers, do **not** dump raw output. Instead: + +1. **Summarize findings** by severity (structural warnings, + semantic issues) in plain language +2. **Explain each finding**: what file, what line, why it + matters +3. **Distinguish structural from semantic**: structural issues + can be auto-fixed; semantic issues need the user's judgment +4. **Offer to auto-fix** structural issues: + "I can run `ctx drift --fix` to clean up the dead path + references. Want me to?" +5. **Propose specific edits** for semantic issues: + "CONVENTIONS.md still says 'use fmt.Printf for output' but + we switched to cmd.Printf three weeks ago. Want me to + update it?" +6. **Suggest follow-up commands** when appropriate: + - Many stale paths after a refactor → suggest `ctx sync` + - Heavy task clutter → suggest `ctx compact --archive` + - Old files untouched for weeks → suggest reviewing content + +## Interpreting Results + +| Finding | What It Means | Suggested Action | +|-------------------------------|--------------------------------------------------|------------------------------------------------------| +| Path does not exist | Context references a deleted file/dir | Remove reference or update path | +| Directory is empty | Referenced dir exists but has no files | Remove reference or populate directory | +| Many completed tasks | TASKS.md is cluttered | Run `ctx compact --archive` | +| File not modified in 30+ days | Content may be outdated | Review and update or confirm current | +| Constitution violation | A hard rule may be broken | Fix immediately | +| Missing packages | An `internal/` package is not in ARCHITECTURE.md | Add it with `/ctx-architecture` or document manually | +| Required file missing | A core context file does not exist | Create it with `ctx init` or manually | + +## Auto-Fix + +When the user agrees to auto-fix: + +```bash +ctx drift --fix +``` + +After fixing, run `ctx drift` again to confirm remaining +issues need manual attention. Report what was fixed and what +still needs the user's judgment. + +## Skill Template Drift + +After running `ctx drift`, check whether the project's +installed skills (`.claude/skills/`) match the canonical +templates shipped with `ctx`. + +### Procedure + +1. Create a temp directory and run `ctx init --force` inside + it to get the latest templates: + + ```bash + CTX_TPL_DIR=$(mktemp -d) + cd "$CTX_TPL_DIR" && ctx init --force 2>/dev/null + ``` + +2. Compare each skill in the project against the template: + + ```bash + diff -ru "$CTX_TPL_DIR/.claude/skills/" .claude/skills/ 2>/dev/null + ``` + +3. Clean up the temp directory: + + ```bash + rm -rf "$CTX_TPL_DIR" + ``` + +### Interpreting Skill Drift + +| Finding | Action | +|--------------------------------------|---------------------------------------------------| +| Skill missing from project | Offer to install: copy from template | +| Skill differs from template | Show the diff; offer to update to latest template | +| Project has extra skills (no match) | These are custom: leave them alone | +| No differences | Skills are up to date; report clean | + +When reporting skill drift, distinguish between: + +- **ctx-managed skills** (present in the template): these + should generally match; differences mean the user's copy + is outdated or was customized intentionally +- **Custom skills** (only in the project): these are user + additions and should not be flagged as drift + +If a skill was intentionally customized, note it and move on. +Offer to update only ctx-managed skills, and always show the +diff before overwriting. + +## Permission Drift + +After checking skills, verify that `.claude/settings.local.json` +has the expected ctx permissions. This file is gitignored, so it +drifts independently from the codebase. + +### Procedure + +1. Read `.claude/settings.local.json` and extract the allow list. + +2. Check for **missing ctx defaults**. Every entry in + `DefaultAllowPermissions()` (defined in + `internal/assets/permissions/allow.txt`) should be present. The current + expected set is: + + - `Bash(ctx:*)`: covers all ctx subcommands + - `Skill(ctx-*)`: one entry per ctx-shipped skill + + To get the authoritative list: + + ```bash + ctx init --force 2>/dev/null # in a temp dir + ``` + + Then compare permissions from the generated + `settings.local.json` against the project's copy. + +3. Check for **stale skill permissions**. If a `Skill(ctx-*)` + entry references a skill that no longer exists in + `.claude/skills/`, flag it. + +4. Check for **missing skill permissions**. If a `ctx-*` skill + exists in `.claude/skills/` but has no corresponding + `Skill(ctx-*)` in the allow list, flag it. + +### Interpreting Permission Drift + +| Finding | Action | +|----------------------------------|---------------------------------------------------------------------| +| Missing `Bash(ctx:*)` | Suggest adding: required for ctx to work | +| Missing `Skill(ctx-*)` entry | Suggest adding: skill will prompt every time | +| Stale `Skill(ctx-*)` entry | Suggest removing: dead reference | +| Granular `Bash(ctx :*)` | Suggest consolidating to `Bash(ctx:*)` | +| One-off / session debris entries | Note as hygiene issue (see `hack/runbooks/sanitize-permissions.md`) | + +### Important + +Do **not** edit `settings.local.json` directly. Report findings +and let the user make changes. This file controls agent +permissions: self-modification is a security concern. Refer +users to `hack/runbooks/sanitize-permissions.md` for the manual cleanup +procedure. + +## Proactive Use + +Run drift detection without being asked when: + +- You load context at session start and notice a path + reference that does not match the file tree +- The user just completed a refactor that renamed or moved + files +- TASKS.md has obviously heavy clutter (20+ completed items + visible when you read it) + +When running proactively, keep the report brief: + +> I ran a quick drift check after the refactor. Two stale +> path references in ARCHITECTURE.md. Want me to clean +> them up? + +## Quality Checklist + +After running drift detection, verify: +- [ ] Summarized findings in plain language (did not just + paste raw CLI output) +- [ ] Explained why each finding matters +- [ ] Offered auto-fix for fixable issues before running it +- [ ] Suggested appropriate follow-up commands +- [ ] Did not run `--fix` without user confirmation diff --git a/internal/assets/integrations/copilot-cli/skills/ctx-next/SKILL.md b/internal/assets/integrations/copilot-cli/skills/ctx-next/SKILL.md new file mode 100644 index 000000000..58181d247 --- /dev/null +++ b/internal/assets/integrations/copilot-cli/skills/ctx-next/SKILL.md @@ -0,0 +1,149 @@ +--- +name: ctx-next +description: "Suggest what to work on next. Use when starting a session, finishing a task, or when unsure what to prioritize." +--- + +Analyze current tasks and recent session activity, then suggest +1-3 concrete next actions with rationale. + +## When to Use + +- At session start after loading context ("what should I do?") +- After completing a task ("what's next?") +- When the user asks for priorities or direction +- When multiple tasks exist and it's unclear which to pick + +## When NOT to Use + +- When the user has already stated what they want to work on +- When actively mid-task (don't interrupt flow with suggestions) +- When no context directory exists (nothing to analyze) + +## Usage Examples + +```text +/ctx-next +/ctx-next (just finished the auth refactor) +``` + +## Process + +Do all of this **silently**: do not narrate the steps: + +1. **Read TASKS.md** to get the full task list with statuses, + priorities, and phases +2. **Check recent sessions** to understand what was just worked + on and avoid suggesting already-completed work: + ```bash + ctx journal source --limit 3 + ``` +3. **Read the most recent session file** (if any) to understand + what was accomplished and what follow-up items were noted +4. **Analyze and rank** tasks using the priority logic below +5. **Present 1-3 recommendations** in the output format below + +## Priority Logic + +Rank candidate tasks using these criteria (in order): + +1. **Explicit priority**: `#priority:high` > `#priority:medium` + > `#priority:low` > untagged +2. **Unblocked**: tasks not tagged `#blocked` or listed under a + "Blocked" section +3. **In-progress first**: `#in-progress` tasks should be resumed + before starting new ones (finishing > starting) +4. **Momentum**: prefer tasks related to recent session work + (continuing a thread is cheaper than context-switching) +5. **Phase order**: earlier phases before later phases (Phase 0 + before Phase 1, etc.) unless priority overrides +6. **Quick wins**: if two tasks have equal priority, prefer the + one that seems smaller/faster (builds momentum) + +### Skip these tasks: + +- `[x]` completed tasks +- `[-]` skipped tasks +- Tasks explicitly tagged `#blocked` with no resolution path +- Tasks that were the main focus of the most recent session + (user likely wants variety or the session ended because it + was done) + +## Output Format + +Present your recommendations like this: + +### Recommended Next + +**1. [Task title or summary]** `#priority:X` +> [1-2 sentence rationale: why this, why now] + +**2. [Task title or summary]** `#priority:X` +> [1-2 sentence rationale] + +**3. [Task title or summary]** *(optional: only if genuinely +useful)* +> [1-2 sentence rationale] + +--- + +*Based on N pending tasks across M phases. Last session: +[topic] ([date]).* + +### Rules for recommendations: + +- **1-3 items only**: more than 3 defeats the purpose +- **Be specific**: "Fix `block-non-path-ctx` hook" not + "work on hooks" +- **Include the priority tag** so the user sees the weight +- **Rationale must reference context**: why *this* task, not + just what it is. Connect to recent work, priority, or + dependencies +- If an in-progress task exists, it should almost always be + recommendation #1 (don't abandon unfinished work) + +## Examples + +### Good Output + +> ### Recommended Next +> +> **1. Fix `block-non-path-ctx` hook** `#priority:high` +> > Still open from yesterday's session. The hook is too +> > aggressive: it blocks `git -C path` commands that don't +> > invoke ctx. Quick fix, clears a blocker. +> +> **2. Add `Context.File(name)` method** `#priority:high` +> > Eliminates 10+ linear scan boilerplate instances across +> > 5 packages. High impact, low effort: good consolidation +> > target. +> +> **3. Topics system (T1.1)** `#priority:medium` +> > Journal site's most impactful remaining feature. Metadata +> > is already in place from the enrichment work. +> +> --- +> +> *Based on 24 pending tasks across 3 phases. Last session: +> doc-drift-cleanup (2026-02-11).* + +### Bad Output + +> "You have many tasks. Here are some options: +> - Do some stuff with hooks +> - Maybe work on tests +> - There's also some docs to write" + +(Too vague, no priorities, no rationale, no connection to +context.) + +## Quality Checklist + +Before presenting recommendations, verify: +- [ ] TASKS.md was read (not guessed from memory) +- [ ] Recent sessions were checked to avoid re-suggesting + completed work +- [ ] Each recommendation has a specific task reference +- [ ] Each recommendation has a rationale grounded in context +- [ ] In-progress tasks are prioritized over new starts +- [ ] No more than 3 recommendations +- [ ] Footer shows task count and last session reference diff --git a/internal/assets/integrations/copilot-cli/skills/ctx-recall/SKILL.md b/internal/assets/integrations/copilot-cli/skills/ctx-recall/SKILL.md new file mode 100644 index 000000000..5db979773 --- /dev/null +++ b/internal/assets/integrations/copilot-cli/skills/ctx-recall/SKILL.md @@ -0,0 +1,42 @@ +--- +name: ctx-recall +description: "Browse session history. Use when referencing past discussions or finding context from previous work." +--- + +Browse, inspect, and export AI session history. + +## When to Use + +- When the user asks "what did we do last time?" +- When looking for context from previous work sessions +- When exporting sessions to the journal +- When searching for a specific session by topic or date + +## When NOT to Use + +- When the user just wants current context (use ctx-status instead) +- For modifying session content (recall is read-only) + +## Execution + +List recent sessions: + +```bash +ctx recall list --limit 5 +``` + +Show details of a specific session: + +```bash +ctx recall show --latest +ctx recall show +``` + +Export sessions to journal markdown: + +```bash +ctx recall export --all +``` + +After listing sessions, summarize relevant findings rather than +dumping raw output. diff --git a/internal/assets/integrations/copilot-cli/skills/ctx-status/SKILL.md b/internal/assets/integrations/copilot-cli/skills/ctx-status/SKILL.md new file mode 100644 index 000000000..345e97f6d --- /dev/null +++ b/internal/assets/integrations/copilot-cli/skills/ctx-status/SKILL.md @@ -0,0 +1,99 @@ +--- +name: ctx-status +description: "Show context summary. Use at session start or when unclear about current project state." +--- + +Show the current context status: files, token budget, tasks, +and recent activity. + +## When to Use + +- At session start to orient before doing work +- When confused about what is being worked on or what context + exists +- To check token usage and context health +- When the user asks "what's the state of the project?" + +## When NOT to Use + +- When you already loaded context via `/ctx-agent` in this + session (status is a subset of what agent provides) +- Repeatedly within the same session without changes in between + +## Usage Examples + +```text +/ctx-status +/ctx-status --verbose +/ctx-status --json +``` + +## Flags + +| Flag | Short | Default | Purpose | +|-------------|-------|---------|----------------------------------| +| `--json` | | false | Output as JSON (for scripting) | +| `--verbose` | `-v` | false | Include file content previews | + +## What It Shows + +The output has three sections: + +### 1. Overview + +- Context directory path +- Total file count +- Token estimate (sum across all `.md` files in the context directory) + +### 2. Files + +Each `.md` file in the context directory with: + +| Indicator | Meaning | +|-----------|-----------------------------------------| +| check | File has content (loaded) | +| circle | File exists but is empty | + +File-specific summaries: +- `CONSTITUTION.md`: number of invariants +- `TASKS.md`: active and completed task counts +- `DECISIONS.md`: number of decisions +- `GLOSSARY.md`: number of terms +- Others: "loaded" or "empty" + +With `--verbose`: adds token count, byte size, and a 3-line +content preview per file. + +### 3. Recent Activity + +The 3 most recently modified files with relative timestamps +(e.g., "5 minutes ago", "2 hours ago"). + +## Execution + +```bash +ctx status +``` + +After running, summarize the key points for the user: +- How many active tasks remain +- Whether any context files are empty (might need populating) +- Token budget usage (is context lean or bloated?) +- What was recently modified (gives a sense of momentum) + +## Interpreting Results + +| Observation | Suggestion | +|-------------------------|-------------------------------------------------------------| +| Many empty files | Context is sparse; populate core files (TASKS, CONVENTIONS) | +| High token count (>30k) | Consider `ctx compact` or archiving completed tasks | +| No recent activity | Context may be stale; check if files need updating | +| TASKS.md has 0 active | All work done, or tasks need to be added | + +## Quality Checklist + +After running status, verify: +- [ ] Summarized the output for the user (do not just dump + raw output without commentary) +- [ ] Flagged any empty core files that should be populated +- [ ] Noted token budget if it seems high or low diff --git a/internal/assets/integrations/copilot/copilot-instructions.md b/internal/assets/integrations/copilot/copilot-instructions.md new file mode 100644 index 000000000..0caf71efb --- /dev/null +++ b/internal/assets/integrations/copilot/copilot-instructions.md @@ -0,0 +1,180 @@ +# Project Context + + + + +## Context System + +This project uses Context (`ctx`) for persistent AI context +management. Your memory is NOT ephemeral: it lives in `.context/` files. + +## On Session Start + +Read these files **in order** before starting any work: + +1. `.context/CONSTITUTION.md`: Hard rules, NEVER violate +2. `.context/TASKS.md`: Current work items +3. `.context/CONVENTIONS.md`: Code patterns and standards +4. `.context/ARCHITECTURE.md`: System structure +5. `.context/DECISIONS.md`: Architectural decisions with rationale +6. `.context/LEARNINGS.md`: Gotchas, tips, lessons learned +7. `.context/GLOSSARY.md`: Domain terms and abbreviations +8. `.context/AGENT_PLAYBOOK.md`: How to use this context system + +After reading, confirm: "I have read the required context files and I'm +following project conventions." + +## When Asked "Do You Remember?" + +**Do this FIRST (silently):** +- Read `.context/TASKS.md` +- Read `.context/DECISIONS.md` and `.context/LEARNINGS.md` +- Check `.context/sessions/` for recent session files + +**Then respond with a structured readback:** +1. **Last session**: cite the most recent session topic and date +2. **Active work**: list pending or in-progress tasks +3. **Recent context**: mention 1-2 recent decisions or learnings +4. **Next step**: offer to continue or ask what to focus on + +**Never** lead with "I don't have memory." The `.context/` files are +your memory. Read them silently, then present what you found as recall. + +## Session Persistence + +After completing meaningful work, save a session summary to +`.context/sessions/`. + +### Session File Format + +Create a file named `YYYY-MM-DD-topic.md`: + +```markdown +# Session: YYYY-MM-DD - Brief Topic Description + +## What Was Done +- Describe completed work items + +## Decisions +- Key decisions made and their rationale + +## Learnings +- Gotchas, tips, or insights discovered + +## Next Steps +- Follow-up work or remaining items +``` + +### When to Save + +- After completing a task or feature +- After making architectural decisions +- After a debugging session +- Before ending the session +- At natural breakpoints in long sessions + +## Context Updates During Work + +Proactively update context files as you work: + +| Event | Action | +|-----------------------------|----------------------------------| +| Made architectural decision | Add to `.context/DECISIONS.md` | +| Discovered gotcha/bug | Add to `.context/LEARNINGS.md` | +| Established new pattern | Add to `.context/CONVENTIONS.md` | +| Completed task | Mark [x] in `.context/TASKS.md` | + +## Self-Check + +Periodically ask yourself: + +> "If this session ended right now, would the next session know what happened?" + +If no: save a session file or update context files before continuing. + +## CLI Commands + +If `ctx` is installed, use these commands: + +```bash +ctx status # Context summary and health check +ctx agent # AI-ready context packet +ctx drift # Check for stale context +ctx journal source # Recent session history +``` + +## MCP Tools (Preferred) + +When an MCP server named `ctx` is available, **always prefer MCP tools +over terminal commands** for context operations. MCP tools provide +validation, session tracking, and boundary checks automatically. + +| MCP Tool | Purpose | +|-----------------------------|--------------------------------------| +| `ctx_status` | Context summary and health check | +| `ctx_add` | Add task, decision, learning, or convention | +| `ctx_complete` | Mark a task as done | +| `ctx_drift` | Check for stale or drifted context | +| `ctx_recall` | Query session history | +| `ctx_next` | Get the next task to work on | +| `ctx_compact` | Archive completed tasks | +| `ctx_watch_update` | Write entry and queue for review | +| `ctx_check_task_completion` | Match recent work to open tasks | +| `ctx_session_event` | Signal session start or end | +| `ctx_remind` | List pending reminders | + +**Rule**: Do NOT run `ctx` in the terminal when the equivalent MCP tool +exists. MCP tools enforce boundary validation and track session state. +Terminal fallback is only for commands without an MCP equivalent (e.g., +`ctx agent`, `ctx recall list`). + +## Governance: When to Call Tools + +The MCP server tracks session state and appends warnings to tool +responses when governance actions are overdue. Follow this protocol: + +### Session Lifecycle + +1. **BEFORE any work**: call `ctx_session_event(type="start")`, then + `ctx_status()` to load context. +2. **Before ending**: call `ctx_session_event(type="end")` to flush + pending state. + +### During Work + +- **After making a decision or discovering a gotcha**: call `ctx_add()` + to persist it immediately — not at session end. +- **After completing a task**: call `ctx_complete()` or + `ctx_check_task_completion()`. +- **Every 10–15 tool calls or 15 minutes**: call `ctx_drift()` to + check for stale context. +- **Before git commit**: call `ctx_status()` to verify context health. + +### Responding to Warnings + +When a tool response contains a `⚠` warning, act on it in your next +action. Do not ignore governance warnings — they indicate context +hygiene actions that are overdue. + +When a tool response contains a `🚨 CRITICAL` warning, **stop current +work immediately** and address the violation. These indicate dangerous +commands, sensitive file access, or policy violations detected by the +VS Code extension. Review the action, revert if unintended, and explain +what happened before continuing. + +### Detection Ring + +The VS Code extension monitors terminal commands and file access in +real time. The following actions are flagged as violations: + +- **Dangerous commands**: `sudo`, `rm -rf /`, `git push`, `git reset + --hard`, `curl`, `wget`, `chmod 777` +- **hack/ scripts**: Direct execution of `hack/*.sh` — use `make` + targets instead +- **Sensitive files**: Editing `.env`, `.pem`, `.key`, or files + matching `credentials` or `secret` + +Violations are recorded and surfaced as CRITICAL warnings in your next +MCP tool response. The user also sees a VS Code notification. + + diff --git a/internal/assets/read/agent/agent.go b/internal/assets/read/agent/agent.go index 14348bddb..6103ca543 100644 --- a/internal/assets/read/agent/agent.go +++ b/internal/assets/read/agent/agent.go @@ -9,15 +9,114 @@ package agent import ( + "io/fs" + "path" + "strings" + "github.com/ActiveMemory/ctx/internal/assets" "github.com/ActiveMemory/ctx/internal/config/asset" + "github.com/ActiveMemory/ctx/internal/config/file" ) // CopilotInstructions reads the embedded Copilot instructions template. // // Returns: -// - []byte: Template content from hooks/copilot-instructions.md +// - []byte: Template content from integrations/copilot-instructions.md // - error: Non-nil if the file is not found or read fails func CopilotInstructions() ([]byte, error) { return assets.FS.ReadFile(asset.PathCopilotInstructions) } + +// CopilotCLIHooksJSON reads the embedded Copilot CLI hooks config. +// +// Returns: +// - []byte: JSON content from integrations/copilot-cli/ctx-hooks.json +// - error: Non-nil if the file is not found or read fails +func CopilotCLIHooksJSON() ([]byte, error) { + return assets.FS.ReadFile(asset.PathCopilotCLIHooksJSON) +} + +// AgentsMd reads the embedded AGENTS.md template. +// +// Returns: +// - []byte: Template content from integrations/agents.md +// - error: Non-nil if the file is not found or read fails +func AgentsMd() ([]byte, error) { + return assets.FS.ReadFile(asset.PathAgentsMd) +} + +// AgentsCtxMd reads the embedded .github/agents/ctx.md template. +// +// Returns: +// - []byte: Template content from integrations/copilot-cli/agents-ctx.md +// - error: Non-nil if the file is not found or read fails +func AgentsCtxMd() ([]byte, error) { + return assets.FS.ReadFile(asset.PathAgentsCtxMd) +} + +// InstructionsCtxMd reads the embedded path-specific instructions. +// +// Returns: +// - []byte: Template content from integrations/copilot-cli/instructions-context.md +// - error: Non-nil if the file is not found or read fails +func InstructionsCtxMd() ([]byte, error) { + return assets.FS.ReadFile(asset.PathInstructionsCtxMd) +} + +// CopilotCLIScripts reads all embedded Copilot CLI hook scripts. +// Returns a map of filename to content for scripts in +// integrations/copilot-cli/scripts/. +// +// Returns: +// - map[string][]byte: Filename -> content for each script +// - error: Non-nil if the directory read fails +func CopilotCLIScripts() (map[string][]byte, error) { + scripts := make(map[string][]byte) + entries, dirErr := fs.ReadDir(assets.FS, asset.DirIntegrationsCopilotScrp) + if dirErr != nil { + return nil, dirErr + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, file.ExtSh) && !strings.HasSuffix(name, file.ExtPs1) { + continue + } + content, readErr := assets.FS.ReadFile(path.Join(asset.DirIntegrationsCopilotScrp, name)) + if readErr != nil { + return nil, readErr + } + scripts[name] = content + } + return scripts, nil +} + +// CopilotCLISkills reads all embedded Copilot CLI skill templates. +// Returns a map of skill directory name to SKILL.md content for skills +// in integrations/copilot-cli/skills/. +// +// Returns: +// - map[string][]byte: Skill name -> SKILL.md content +// - error: Non-nil if the directory read fails +func CopilotCLISkills() (map[string][]byte, error) { + skills := make(map[string][]byte) + entries, dirErr := fs.ReadDir(assets.FS, asset.DirIntegrationsCopilotSkill) + if dirErr != nil { + return nil, dirErr + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + skillPath := path.Join(asset.DirIntegrationsCopilotSkill, name, asset.FileSKILLMd) + content, readErr := assets.FS.ReadFile(skillPath) + if readErr != nil { + return nil, readErr + } + skills[name] = content + } + return skills, nil +} diff --git a/internal/assets/read/hook/hook.go b/internal/assets/read/hook/hook.go index 856c24d4d..b867c96cb 100644 --- a/internal/assets/read/hook/hook.go +++ b/internal/assets/read/hook/hook.go @@ -33,7 +33,7 @@ func Message(hook, filename string) ([]byte, error) { // - []byte: Raw YAML content // - error: Non-nil if the file is not found or read fails func MessageRegistry() ([]byte, error) { - return assets.FS.ReadFile(asset.PathHookRegistry) + return assets.FS.ReadFile(asset.PathMessageRegistry) } // MessageList returns available hook message directory names. diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index d04979241..deba99b33 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -71,7 +71,7 @@ func TestInitialize(t *testing.T) { "compact", "decision", "watch", - "hook", + "setup", "learning", "task", "loop", diff --git a/internal/bootstrap/group.go b/internal/bootstrap/group.go index 7ad8b35f9..f1f78eba3 100644 --- a/internal/bootstrap/group.go +++ b/internal/bootstrap/group.go @@ -17,7 +17,6 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/doctor" "github.com/ActiveMemory/ctx/internal/cli/drift" "github.com/ActiveMemory/ctx/internal/cli/guide" - "github.com/ActiveMemory/ctx/internal/cli/hook" "github.com/ActiveMemory/ctx/internal/cli/initialize" "github.com/ActiveMemory/ctx/internal/cli/journal" "github.com/ActiveMemory/ctx/internal/cli/learning" @@ -33,6 +32,7 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/remind" "github.com/ActiveMemory/ctx/internal/cli/resume" "github.com/ActiveMemory/ctx/internal/cli/serve" + "github.com/ActiveMemory/ctx/internal/cli/setup" "github.com/ActiveMemory/ctx/internal/cli/site" "github.com/ActiveMemory/ctx/internal/cli/status" "github.com/ActiveMemory/ctx/internal/cli/sync" @@ -115,7 +115,7 @@ func runtimeCmds() []registration { // - []registration: Hook, mcp, watch, notify, and loop commands func integrations() []registration { return []registration{ - {hook.Cmd, embedCmd.GroupIntegration}, + {setup.Cmd, embedCmd.GroupIntegration}, {mcp.Cmd, embedCmd.GroupIntegration}, {watch.Cmd, embedCmd.GroupIntegration}, {notify.Cmd, embedCmd.GroupIntegration}, diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 67fca6051..f06250d40 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -190,8 +190,8 @@ func TestBinaryIntegration(t *testing.T) { {[]string{"status"}, "Context"}, {[]string{"agent"}, "Context Packet"}, {[]string{"drift"}, "Drift"}, - {[]string{"load"}, ""}, // load: varies - {[]string{"hook", "cursor"}, "Cursor"}, // hook: integration + {[]string{"load"}, ""}, // load: varies + {[]string{"setup", "cursor"}, "Cursor"}, // setup: integration } for _, tc := range subcommands { diff --git a/internal/cli/initialize/cmd/root/cmd.go b/internal/cli/initialize/cmd/root/cmd.go index 90779fb42..81a190641 100644 --- a/internal/cli/initialize/cmd/root/cmd.go +++ b/internal/cli/initialize/cmd/root/cmd.go @@ -38,6 +38,7 @@ func Cmd() *cobra.Command { minimal bool merge bool noPluginEnable bool + caller string ) short, long := desc.Command(cmd.DescKeyInitialize) @@ -47,7 +48,7 @@ func Cmd() *cobra.Command { Annotations: map[string]string{cli.AnnotationSkipInit: cli.AnnotationTrue}, Long: long, RunE: func(cmd *cobra.Command, args []string) error { - return Run(cmd, force, minimal, merge, noPluginEnable) + return Run(cmd, force, minimal, merge, noPluginEnable, caller) }, } @@ -69,6 +70,10 @@ func Cmd() *cobra.Command { &noPluginEnable, cFlag.NoPluginEnable, false, desc.Flag(flag.DescKeyInitializeNoPluginEnable), ) + c.Flags().StringVar( + &caller, cFlag.Caller, "", + desc.Flag(flag.DescKeyInitializeCaller), + ) return c } diff --git a/internal/cli/initialize/cmd/root/run.go b/internal/cli/initialize/cmd/root/run.go index 66fb4d52d..e5da8cb4f 100644 --- a/internal/cli/initialize/cmd/root/run.go +++ b/internal/cli/initialize/cmd/root/run.go @@ -52,15 +52,19 @@ import ( // - minimal: If true, only create essential files // - merge: If true, auto-merge ctx content into existing files // - noPluginEnable: If true, skip auto-enabling the plugin globally +// - caller: Identifies the calling tool (e.g. "vscode") for template overrides // // Returns: // - error: Non-nil if directory creation or file operations fail func Run( - cmd *cobra.Command, force, minimal, merge, noPluginEnable bool, + cmd *cobra.Command, force, minimal, merge, noPluginEnable bool, caller string, ) error { - // Check if ctx is in PATH (required for hooks to work) - if pathErr := validate.CheckCtxInPath(cmd); pathErr != nil { - return pathErr + // Check if ctx is in PATH (required for hooks to work). + // Skip when a caller is set — the caller manages its own binary path. + if caller == "" { + if pathErr := validate.CheckCtxInPath(cmd); pathErr != nil { + return pathErr + } } contextDir := rc.ContextDir() @@ -70,6 +74,12 @@ func Run( // treated as uninitialized - no overwrite prompt needed. if _, statErr := os.Stat(contextDir); statErr == nil { if !force && hasEssentialFiles(contextDir) { + // When called from an editor (--caller), stdin is unavailable. + // Skip the interactive prompt to prevent hanging. + if caller != "" { + initialize.InfoAborted(cmd) + return nil + } // Prompt for confirmation initialize.InfoOverwritePrompt(cmd, contextDir) reader := bufio.NewReader(os.Stdin) diff --git a/internal/cli/initialize/core/vscode/doc.go b/internal/cli/initialize/core/vscode/doc.go new file mode 100644 index 000000000..a44c983cb --- /dev/null +++ b/internal/cli/initialize/core/vscode/doc.go @@ -0,0 +1,22 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package vscode generates VS Code workspace configuration files +// during ctx init. +// +// [WriteAll] is the entry point, invoked from the init pipeline. +// It delegates to per-file generators that create: +// +// - extensions.json — recommended extensions including the ctx +// VS Code extension +// - tasks.json — shell tasks for common ctx commands (status, +// drift, agent) +// - mcp.json — MCP server registration pointing at ctx mcp serve +// +// Each generator skips its file if it already exists, printing a +// diagnostic via [writeVscode.InfoExistsSkipped]. Types used for +// JSON serialisation live in types.go. +package vscode diff --git a/internal/cli/initialize/core/vscode/extension.go b/internal/cli/initialize/core/vscode/extension.go new file mode 100644 index 000000000..eeb758fd7 --- /dev/null +++ b/internal/cli/initialize/core/vscode/extension.go @@ -0,0 +1,67 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package vscode + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config/fs" + "github.com/ActiveMemory/ctx/internal/config/token" + cfgVscode "github.com/ActiveMemory/ctx/internal/config/vscode" + "github.com/ActiveMemory/ctx/internal/io" + writeVscode "github.com/ActiveMemory/ctx/internal/write/vscode" +) + +// createExtensionsJSON creates .vscode/extensions.json with the ctx +// extension recommendation. +// +// If the file exists and already contains the recommendation, it is +// skipped. If the file exists without the recommendation, the user +// is prompted to add it manually. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if reading or writing the file fails +func createExtensionsJSON(cmd *cobra.Command) error { + target := filepath.Join(cfgVscode.Dir, cfgVscode.FileExtensionsJSON) + + if _, statErr := os.Stat(target); statErr == nil { + data, readErr := io.SafeReadUserFile(target) + if readErr != nil { + return readErr + } + var existing map[string][]string + if json.Unmarshal(data, &existing) == nil { + for _, r := range existing[cfgVscode.KeyRecommendations] { + if r == cfgVscode.ExtensionID { + writeVscode.InfoRecommendationExists(cmd, target) + return nil + } + } + } + writeVscode.InfoAddManually(cmd, target, cfgVscode.ExtensionID) + return nil + } + + content := map[string][]string{ + cfgVscode.KeyRecommendations: {cfgVscode.ExtensionID}, + } + data, _ := json.MarshalIndent(content, "", " ") + data = append(data, token.NewlineLF...) + + if writeErr := os.WriteFile(target, data, fs.PermFile); writeErr != nil { + return writeErr + } + writeVscode.InfoCreated(cmd, target) + return nil +} diff --git a/internal/cli/initialize/core/vscode/mcp.go b/internal/cli/initialize/core/vscode/mcp.go new file mode 100644 index 000000000..53c502d4e --- /dev/null +++ b/internal/cli/initialize/core/vscode/mcp.go @@ -0,0 +1,57 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package vscode + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config/fs" + mcpServer "github.com/ActiveMemory/ctx/internal/config/mcp/server" + "github.com/ActiveMemory/ctx/internal/config/token" + cfgVscode "github.com/ActiveMemory/ctx/internal/config/vscode" + writeVscode "github.com/ActiveMemory/ctx/internal/write/vscode" +) + +// createMCPJSON creates .vscode/mcp.json with the ctx MCP server +// registration. +// +// Skips if the file already exists to preserve user customizations. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if writing the file fails +func createMCPJSON(cmd *cobra.Command) error { + target := filepath.Join(cfgVscode.Dir, cfgVscode.FileMCPJSON) + + if _, statErr := os.Stat(target); statErr == nil { + writeVscode.InfoExistsSkipped(cmd, target) + return nil + } + + file := vsMCPFile{ + Servers: map[string]vsMCPServer{ + mcpServer.Name: { + Command: mcpServer.Command, + Args: mcpServer.Args(), + }, + }, + } + data, _ := json.MarshalIndent(file, "", " ") + data = append(data, token.NewlineLF...) + + if writeErr := os.WriteFile(target, data, fs.PermFile); writeErr != nil { + return writeErr + } + writeVscode.InfoCreated(cmd, target) + return nil +} diff --git a/internal/cli/initialize/core/vscode/tasks.go b/internal/cli/initialize/core/vscode/tasks.go new file mode 100644 index 000000000..8b58a4c64 --- /dev/null +++ b/internal/cli/initialize/core/vscode/tasks.go @@ -0,0 +1,66 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package vscode + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config/fs" + "github.com/ActiveMemory/ctx/internal/config/token" + cfgVscode "github.com/ActiveMemory/ctx/internal/config/vscode" + writeVscode "github.com/ActiveMemory/ctx/internal/write/vscode" +) + +// createTasksJSON creates .vscode/tasks.json with ctx command tasks. +// +// Skips if the file already exists to preserve user customizations. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if writing the file fails +func createTasksJSON(cmd *cobra.Command) error { + target := filepath.Join(cfgVscode.Dir, cfgVscode.FileTasksJSON) + + if _, statErr := os.Stat(target); statErr == nil { + writeVscode.InfoExistsSkipped(cmd, target) + return nil + } + + tasks := make([]vsTask, len(cfgVscode.Tasks)) + for i, t := range cfgVscode.Tasks { + tasks[i] = vsTask{ + Label: t.Label, + Type: cfgVscode.TypeShell, + Command: t.Command, + Group: cfgVscode.GroupNone, + Presentation: vsPresentation{ + Reveal: cfgVscode.RevealAlways, + Panel: cfgVscode.PanelShared, + }, + ProblemMatcher: []string{}, + } + } + + file := vsTasksFile{ + Version: cfgVscode.TasksVersion, + Tasks: tasks, + } + data, _ := json.MarshalIndent(file, "", " ") + data = append(data, token.NewlineLF...) + + if writeErr := os.WriteFile(target, data, fs.PermFile); writeErr != nil { + return writeErr + } + writeVscode.InfoCreated(cmd, target) + return nil +} diff --git a/internal/cli/initialize/core/vscode/testmain_test.go b/internal/cli/initialize/core/vscode/testmain_test.go new file mode 100644 index 000000000..838ab6c51 --- /dev/null +++ b/internal/cli/initialize/core/vscode/testmain_test.go @@ -0,0 +1,19 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package vscode + +import ( + "os" + "testing" + + "github.com/ActiveMemory/ctx/internal/assets/read/lookup" +) + +func TestMain(m *testing.M) { + lookup.Init() + os.Exit(m.Run()) +} diff --git a/internal/cli/initialize/core/vscode/types.go b/internal/cli/initialize/core/vscode/types.go new file mode 100644 index 000000000..a00d8fe45 --- /dev/null +++ b/internal/cli/initialize/core/vscode/types.go @@ -0,0 +1,63 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package vscode + +// vsTask represents a single VS Code task definition for tasks.json. +// +// Fields: +// - Label: Human-readable name shown in the task picker +// - Type: Execution type (e.g. "shell") +// - Command: Shell command to execute +// - Group: Task group classification (e.g. "none") +// - Presentation: Terminal display settings +// - ProblemMatcher: Patterns for parsing task output (empty for ctx) +type vsTask struct { + Label string `json:"label"` + Type string `json:"type"` + Command string `json:"command"` + Group string `json:"group"` + Presentation vsPresentation `json:"presentation"` + ProblemMatcher []string `json:"problemMatcher"` +} + +// vsPresentation controls how the task terminal panel is displayed. +// +// Fields: +// - Reveal: When to reveal the terminal (e.g. "always") +// - Panel: Terminal reuse strategy (e.g. "shared") +type vsPresentation struct { + Reveal string `json:"reveal"` + Panel string `json:"panel"` +} + +// vsTasksFile is the top-level structure for .vscode/tasks.json. +// +// Fields: +// - Version: Tasks schema version (e.g. "2.0.0") +// - Tasks: List of task definitions +type vsTasksFile struct { + Version string `json:"version"` + Tasks []vsTask `json:"tasks"` +} + +// vsMCPServer represents a single MCP server entry in mcp.json. +// +// Fields: +// - Command: Executable to launch the server +// - Args: Command-line arguments passed to the server +type vsMCPServer struct { + Command string `json:"command"` + Args []string `json:"args"` +} + +// vsMCPFile is the top-level structure for .vscode/mcp.json. +// +// Fields: +// - Servers: Map of server name to server configuration +type vsMCPFile struct { + Servers map[string]vsMCPServer `json:"servers"` +} diff --git a/internal/cli/initialize/core/vscode/vscode.go b/internal/cli/initialize/core/vscode/vscode.go new file mode 100644 index 000000000..b9e1773ea --- /dev/null +++ b/internal/cli/initialize/core/vscode/vscode.go @@ -0,0 +1,49 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package vscode + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config/fs" + cfgVscode "github.com/ActiveMemory/ctx/internal/config/vscode" + writeVscode "github.com/ActiveMemory/ctx/internal/write/vscode" +) + +// CreateVSCodeArtifacts generates VS Code workspace configuration files +// (.vscode/) during ctx init. +// +// Creates extensions.json, tasks.json, and mcp.json as the +// editor-specific counterpart to Claude Code's settings and hooks. +// Individual file errors are non-fatal and reported inline. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if directory creation fails +func CreateVSCodeArtifacts(cmd *cobra.Command) error { + if mkdirErr := os.MkdirAll(cfgVscode.Dir, fs.PermExec); mkdirErr != nil { + return mkdirErr + } + + if extErr := createExtensionsJSON(cmd); extErr != nil { + writeVscode.InfoWarnNonFatal(cmd, cfgVscode.FileExtensionsJSON, extErr) + } + + if taskErr := createTasksJSON(cmd); taskErr != nil { + writeVscode.InfoWarnNonFatal(cmd, cfgVscode.FileTasksJSON, taskErr) + } + + if mcpErr := createMCPJSON(cmd); mcpErr != nil { + writeVscode.InfoWarnNonFatal(cmd, cfgVscode.FileMCPJSON, mcpErr) + } + + return nil +} diff --git a/internal/cli/initialize/core/vscode/vscode_test.go b/internal/cli/initialize/core/vscode/vscode_test.go new file mode 100644 index 000000000..f8739708a --- /dev/null +++ b/internal/cli/initialize/core/vscode/vscode_test.go @@ -0,0 +1,149 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package vscode + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + + cfgVscode "github.com/ActiveMemory/ctx/internal/config/vscode" +) + +// testCmd returns a cobra.Command that captures output. +func testCmd(buf *bytes.Buffer) *cobra.Command { + cmd := &cobra.Command{} + cmd.SetOut(buf) + return cmd +} + +func TestWriteMCPJSON_CreatesFile(t *testing.T) { + tmp := t.TempDir() + + origDir, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + if err := os.MkdirAll(cfgVscode.Dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + var buf bytes.Buffer + cmd := testCmd(&buf) + + if err := createMCPJSON(cmd); err != nil { + t.Fatalf("createMCPJSON() error = %v", err) + } + + target := filepath.Join(cfgVscode.Dir, "mcp.json") + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("failed to read mcp.json: %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("mcp.json is not valid JSON: %v", err) + } + + servers, ok := parsed["servers"].(map[string]interface{}) + if !ok { + t.Fatal("mcp.json missing 'servers' key") + } + + ctxServer, ok := servers["ctx"].(map[string]interface{}) + if !ok { + t.Fatal("mcp.json missing 'servers.ctx' key") + } + + if ctxServer["command"] != "ctx" { + t.Errorf("expected command 'ctx', got %q", ctxServer["command"]) + } + + output := buf.String() + if len(output) == 0 { + t.Error("expected output message for created file") + } +} + +func TestWriteMCPJSON_SkipsExisting(t *testing.T) { + tmp := t.TempDir() + + origDir, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + target := filepath.Join(cfgVscode.Dir, "mcp.json") + if err := os.MkdirAll(cfgVscode.Dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + existing := []byte(`{"servers":{"other":{}}}`) + if err := os.WriteFile(target, existing, 0o644); err != nil { + t.Fatalf("write existing: %v", err) + } + + var buf bytes.Buffer + cmd := testCmd(&buf) + + if err := createMCPJSON(cmd); err != nil { + t.Fatalf("createMCPJSON() error = %v", err) + } + + // File should not be overwritten + data, _ := os.ReadFile(target) + if string(data) != string(existing) { + t.Error("createMCPJSON overwrote existing file") + } + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("exists")) { + t.Errorf("expected 'exists' in output, got %q", output) + } +} + +func TestCreateVSCodeArtifacts_CreatesMCPJSON(t *testing.T) { + tmp := t.TempDir() + + origDir, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + var buf bytes.Buffer + cmd := testCmd(&buf) + + if err := CreateVSCodeArtifacts(cmd); err != nil { + t.Fatalf("CreateVSCodeArtifacts() error = %v", err) + } + + // Verify mcp.json was created as part of the artifacts + target := filepath.Join(cfgVscode.Dir, "mcp.json") + if _, err := os.Stat(target); os.IsNotExist(err) { + t.Error("CreateVSCodeArtifacts did not create mcp.json") + } + + // Verify extensions.json was also created + extTarget := filepath.Join(cfgVscode.Dir, "extensions.json") + if _, err := os.Stat(extTarget); os.IsNotExist(err) { + t.Error("CreateVSCodeArtifacts did not create extensions.json") + } + + // Verify tasks.json was also created + taskTarget := filepath.Join(cfgVscode.Dir, "tasks.json") + if _, err := os.Stat(taskTarget); os.IsNotExist(err) { + t.Error("CreateVSCodeArtifacts did not create tasks.json") + } +} diff --git a/internal/cli/hook/cmd/root/cmd.go b/internal/cli/setup/cmd/root/cmd.go similarity index 81% rename from internal/cli/hook/cmd/root/cmd.go rename to internal/cli/setup/cmd/root/cmd.go index 268f61643..8facf2691 100644 --- a/internal/cli/hook/cmd/root/cmd.go +++ b/internal/cli/setup/cmd/root/cmd.go @@ -16,7 +16,7 @@ import ( cFlag "github.com/ActiveMemory/ctx/internal/config/flag" ) -// Cmd returns the "ctx hook" command for generating AI tool integrations. +// Cmd returns the "ctx setup" command for generating AI tool integrations. // // The command outputs configuration snippets and instructions for integrating // Context with various AI coding tools like Claude Code, Cursor, Aider, etc. @@ -25,13 +25,13 @@ import ( // - --write, -w: Write the configuration file instead of printing // // Returns: -// - *cobra.Command: Configured hook command that accepts a tool name argument +// - *cobra.Command: Configured setup command that accepts a tool name argument func Cmd() *cobra.Command { var write bool - short, long := desc.Command(cmd.DescKeyHook) + short, long := desc.Command(cmd.DescKeySetup) c := &cobra.Command{ - Use: cmd.UseHook, + Use: cmd.UseSetup, Short: short, Annotations: map[string]string{cli.AnnotationSkipInit: cli.AnnotationTrue}, Long: long, @@ -43,7 +43,7 @@ func Cmd() *cobra.Command { c.Flags().BoolVarP( &write, cFlag.Write, cFlag.ShortWrite, false, - desc.Flag(flag.DescKeyHookWrite), + desc.Flag(flag.DescKeySetupWrite), ) return c diff --git a/internal/cli/hook/cmd/root/doc.go b/internal/cli/setup/cmd/root/doc.go similarity index 61% rename from internal/cli/hook/cmd/root/doc.go rename to internal/cli/setup/cmd/root/doc.go index 92c29cece..26ae63d0a 100644 --- a/internal/cli/hook/cmd/root/doc.go +++ b/internal/cli/setup/cmd/root/doc.go @@ -4,10 +4,10 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -// Package root implements the ctx hook command for generating +// Package root implements the ctx setup command for generating // AI tool integration configs. // // [Cmd] builds the cobra.Command with --write flag. [Run] generates -// hook configurations for Claude Code, Cursor, Copilot, and others. -// [WriteCopilotInstructions] deploys the embedded copilot template. +// integration configurations for Claude Code, Cursor, Copilot, and others. +// Deployment logic lives in the core/ sub-packages. package root diff --git a/internal/cli/setup/cmd/root/run.go b/internal/cli/setup/cmd/root/run.go new file mode 100644 index 000000000..9e389a632 --- /dev/null +++ b/internal/cli/setup/cmd/root/run.go @@ -0,0 +1,91 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package root + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/agent" + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + coreAgents "github.com/ActiveMemory/ctx/internal/cli/setup/core/agents" + coreCopilot "github.com/ActiveMemory/ctx/internal/cli/setup/core/copilot" + coreCopilotCLI "github.com/ActiveMemory/ctx/internal/cli/setup/core/copilot_cli" + "github.com/ActiveMemory/ctx/internal/config/embed/text" + cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" + "github.com/ActiveMemory/ctx/internal/err/config" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" +) + +// Run executes the setup command logic. +// +// Outputs integration instructions and configuration snippets for the +// specified AI tool. With --write, generates the configuration file +// directly. +// +// Parameters: +// - cmd: Cobra command for output stream +// - args: Command arguments; args[0] is the tool name +// - writeFile: If true, write the configuration file instead of printing +// +// Returns: +// - error: Non-nil if the tool is not supported or file write fails +func Run(cmd *cobra.Command, args []string, writeFile bool) error { + tool := strings.ToLower(args[0]) + + switch tool { + case cfgHook.ToolAgents: + if writeFile { + return coreAgents.Deploy(cmd) + } + writeSetup.InfoTool(cmd, desc.Text(text.DescKeyHookAgents)) + writeSetup.Separator(cmd) + content, readErr := agent.AgentsMd() + if readErr != nil { + return readErr + } + writeSetup.Content(cmd, string(content)) + + case cfgHook.ToolClaudeCode, cfgHook.ToolClaude: + writeSetup.InfoTool(cmd, desc.Text(text.DescKeyHookClaude)) + + case cfgHook.ToolCursor: + writeSetup.InfoTool(cmd, desc.Text(text.DescKeyHookCursor)) + + case cfgHook.ToolAider: + writeSetup.InfoTool(cmd, desc.Text(text.DescKeyHookAider)) + + case cfgHook.ToolCopilot: + if writeFile { + return coreCopilot.DeployInstructions(cmd) + } + writeSetup.InfoTool(cmd, desc.Text(text.DescKeyHookCopilot)) + writeSetup.Separator(cmd) + content, readErr := agent.CopilotInstructions() + if readErr != nil { + return readErr + } + writeSetup.Content(cmd, string(content)) + + case cfgHook.ToolCopilotCLI: + if writeFile { + return coreCopilotCLI.Deploy(cmd) + } + writeSetup.InfoTool(cmd, desc.Text(text.DescKeyHookCopilotCLI)) + + case cfgHook.ToolWindsurf: + writeSetup.InfoTool(cmd, desc.Text(text.DescKeyHookWindsurf)) + + default: + writeSetup.InfoUnknownTool(cmd, tool) + writeSetup.InfoTool(cmd, desc.Text(text.DescKeyHookSupportedTools)) + return config.UnsupportedTool(tool) + } + + return nil +} diff --git a/internal/cli/setup/cmd/root/testmain_test.go b/internal/cli/setup/cmd/root/testmain_test.go new file mode 100644 index 000000000..adc4e3eef --- /dev/null +++ b/internal/cli/setup/cmd/root/testmain_test.go @@ -0,0 +1,19 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package root + +import ( + "os" + "testing" + + "github.com/ActiveMemory/ctx/internal/assets/read/lookup" +) + +func TestMain(m *testing.M) { + lookup.Init() + os.Exit(m.Run()) +} diff --git a/internal/cli/setup/core/agents/agents.go b/internal/cli/setup/core/agents/agents.go new file mode 100644 index 000000000..3cf08c30e --- /dev/null +++ b/internal/cli/setup/core/agents/agents.go @@ -0,0 +1,73 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package agents + +import ( + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/agent" + "github.com/ActiveMemory/ctx/internal/config/fs" + cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" + "github.com/ActiveMemory/ctx/internal/config/marker" + "github.com/ActiveMemory/ctx/internal/config/token" + errFs "github.com/ActiveMemory/ctx/internal/err/fs" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" +) + +// Deploy generates AGENTS.md in the project root. +// +// Creates AGENTS.md with universal agent instructions. Preserves existing +// non-ctx content by checking for ctx:agents markers. If the file exists +// with markers, skips. If it exists without markers, merges. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if file write fails +func Deploy(cmd *cobra.Command) error { + targetFile := cfgHook.FileAgentsMd + + // Load the AGENTS.md template + agentsContent, readErr := agent.AgentsMd() + if readErr != nil { + return readErr + } + + // Check if the file exists + existingContent, err := os.ReadFile(filepath.Clean(targetFile)) + fileExists := err == nil + + if fileExists { + existingStr := string(existingContent) + if strings.Contains(existingStr, marker.AgentsStart) { + writeSetup.InfoAgentsSkipped(cmd, targetFile) + return nil + } + + // File exists without ctx markers: append ctx content + merged := existingStr + token.NewlineLF + string(agentsContent) + if wErr := os.WriteFile(targetFile, []byte(merged), fs.PermFile); wErr != nil { + return errFs.FileWrite(targetFile, wErr) + } + writeSetup.InfoAgentsMerged(cmd, targetFile) + return nil + } + + // File doesn't exist: create it + if wErr := os.WriteFile(targetFile, agentsContent, fs.PermFile); wErr != nil { + return errFs.FileWrite(targetFile, wErr) + } + writeSetup.InfoAgentsCreated(cmd, targetFile) + + writeSetup.InfoAgentsSummary(cmd) + return nil +} diff --git a/internal/cli/setup/core/agents/doc.go b/internal/cli/setup/core/agents/doc.go new file mode 100644 index 000000000..0d709f345 --- /dev/null +++ b/internal/cli/setup/core/agents/doc.go @@ -0,0 +1,11 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package agents deploys AGENTS.md for universal agent instructions. +// +// [Deploy] generates or merges AGENTS.md in the project root, +// preserving existing non-ctx content via marker detection. +package agents diff --git a/internal/cli/hook/cmd/root/run.go b/internal/cli/setup/core/copilot/copilot.go similarity index 54% rename from internal/cli/hook/cmd/root/run.go rename to internal/cli/setup/core/copilot/copilot.go index 94b97f948..9d7187418 100644 --- a/internal/cli/hook/cmd/root/run.go +++ b/internal/cli/setup/core/copilot/copilot.go @@ -4,7 +4,7 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -package root +package copilot import ( "os" @@ -14,70 +14,18 @@ import ( "github.com/spf13/cobra" "github.com/ActiveMemory/ctx/internal/assets/read/agent" - "github.com/ActiveMemory/ctx/internal/assets/read/desc" "github.com/ActiveMemory/ctx/internal/config/dir" - "github.com/ActiveMemory/ctx/internal/config/embed/text" "github.com/ActiveMemory/ctx/internal/config/fs" cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" "github.com/ActiveMemory/ctx/internal/config/marker" "github.com/ActiveMemory/ctx/internal/config/token" - "github.com/ActiveMemory/ctx/internal/err/config" + cfgVscode "github.com/ActiveMemory/ctx/internal/config/vscode" errFs "github.com/ActiveMemory/ctx/internal/err/fs" writeErr "github.com/ActiveMemory/ctx/internal/write/err" - "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) -// Run executes the hook command logic. -// -// Outputs integration instructions and configuration snippets for the -// specified AI tool. With --write, generates the configuration file -// directly. -// -// Parameters: -// - cmd: Cobra command for output stream -// - args: Command arguments; args[0] is the tool name -// - writeFile: If true, write the configuration file instead of printing -// -// Returns: -// - error: Non-nil if the tool is not supported or file write fails -func Run(cmd *cobra.Command, args []string, writeFile bool) error { - tool := strings.ToLower(args[0]) - - switch tool { - case cfgHook.ToolClaudeCode, cfgHook.ToolClaude: - hook.InfoTool(cmd, desc.Text(text.DescKeyHookClaude)) - - case cfgHook.ToolCursor: - hook.InfoTool(cmd, desc.Text(text.DescKeyHookCursor)) - - case cfgHook.ToolAider: - hook.InfoTool(cmd, desc.Text(text.DescKeyHookAider)) - - case cfgHook.ToolCopilot: - if writeFile { - return WriteCopilotInstructions(cmd) - } - hook.InfoTool(cmd, desc.Text(text.DescKeyHookCopilot)) - hook.Separator(cmd) - content, readErr := agent.CopilotInstructions() - if readErr != nil { - return readErr - } - hook.Content(cmd, string(content)) - - case cfgHook.ToolWindsurf: - hook.InfoTool(cmd, desc.Text(text.DescKeyHookWindsurf)) - - default: - hook.InfoUnknownTool(cmd, tool) - hook.InfoTool(cmd, desc.Text(text.DescKeyHookSupportedTools)) - return config.UnsupportedTool(tool) - } - - return nil -} - -// WriteCopilotInstructions generates .github/copilot-instructions.md. +// DeployInstructions generates .github/copilot-instructions.md. // // Creates the .github/ directory if needed and writes the comprehensive // Copilot instructions file. Preserves existing non-ctx content by @@ -88,7 +36,7 @@ func Run(cmd *cobra.Command, args []string, writeFile bool) error { // // Returns: // - error: Non-nil if directory creation or file write fails -func WriteCopilotInstructions(cmd *cobra.Command) error { +func DeployInstructions(cmd *cobra.Command) error { targetFile := filepath.Join(cfgHook.DirGitHub, cfgHook.FileCopilotInstructions) // Create .github/ directory if needed @@ -109,7 +57,7 @@ func WriteCopilotInstructions(cmd *cobra.Command) error { if fileExists { existingStr := string(existingContent) if strings.Contains(existingStr, marker.CopilotStart) { - hook.InfoCopilotSkipped(cmd, targetFile) + writeSetup.InfoCopilotSkipped(cmd, targetFile) return nil } @@ -120,7 +68,7 @@ func WriteCopilotInstructions(cmd *cobra.Command) error { ); wErr != nil { return errFs.FileWrite(targetFile, wErr) } - hook.InfoCopilotMerged(cmd, targetFile) + writeSetup.InfoCopilotMerged(cmd, targetFile) return nil } @@ -130,17 +78,22 @@ func WriteCopilotInstructions(cmd *cobra.Command) error { ); wErr != nil { return errFs.FileWrite(targetFile, wErr) } - hook.InfoCopilotCreated(cmd, targetFile) + writeSetup.InfoCopilotCreated(cmd, targetFile) // Also create .context/sessions/ if it doesn't exist sessionsDir := filepath.Join(dir.Context, dir.Sessions) if mkErr := os.MkdirAll(sessionsDir, fs.PermExec); mkErr != nil { writeErr.WarnFile(cmd, sessionsDir, mkErr) } else { - hook.InfoCopilotSessionsDir(cmd, sessionsDir) + writeSetup.InfoCopilotSessionsDir(cmd, sessionsDir) } - hook.InfoCopilotSummary(cmd) + writeSetup.InfoCopilotSummary(cmd) + + // Also create .vscode/mcp.json if it doesn't exist + if err := ensureVSCodeMCP(cmd); err != nil { + writeErr.WarnFile(cmd, cfgVscode.FileMCPJSON, err) + } return nil } diff --git a/internal/cli/setup/core/copilot/copilot_test.go b/internal/cli/setup/core/copilot/copilot_test.go new file mode 100644 index 000000000..d9dad4d49 --- /dev/null +++ b/internal/cli/setup/core/copilot/copilot_test.go @@ -0,0 +1,146 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package copilot + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" +) + +// testCmd returns a cobra.Command that captures output. +func testCmd(buf *bytes.Buffer) *cobra.Command { + cmd := &cobra.Command{} + cmd.SetOut(buf) + return cmd +} + +func TestEnsureVSCodeMCP_CreatesFile(t *testing.T) { + tmp := t.TempDir() + + origDir, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + var buf bytes.Buffer + cmd := testCmd(&buf) + + if err := ensureVSCodeMCP(cmd); err != nil { + t.Fatalf("ensureVSCodeMCP() error = %v", err) + } + + target := filepath.Join(".vscode", "mcp.json") + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("failed to read mcp.json: %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("mcp.json is not valid JSON: %v", err) + } + + servers, ok := parsed["servers"].(map[string]interface{}) + if !ok { + t.Fatal("mcp.json missing 'servers' key") + } + + ctxServer, ok := servers["ctx"].(map[string]interface{}) + if !ok { + t.Fatal("mcp.json missing 'servers.ctx' key") + } + + if ctxServer["command"] != "ctx" { + t.Errorf("expected command 'ctx', got %q", ctxServer["command"]) + } + + args, ok := ctxServer["args"].([]interface{}) + if !ok || len(args) != 2 || args[0] != "mcp" || args[1] != "serve" { + t.Errorf("expected args [mcp, serve], got %v", ctxServer["args"]) + } + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("\u2713")) { + t.Errorf("expected success marker in output, got %q", output) + } +} + +func TestEnsureVSCodeMCP_SkipsExisting(t *testing.T) { + tmp := t.TempDir() + + origDir, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + vsDir := ".vscode" + target := filepath.Join(vsDir, "mcp.json") + if err := os.MkdirAll(vsDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + existing := []byte(`{"servers":{"custom":{"command":"other"}}}`) + if err := os.WriteFile(target, existing, 0o644); err != nil { + t.Fatalf("write existing: %v", err) + } + + var buf bytes.Buffer + cmd := testCmd(&buf) + + if err := ensureVSCodeMCP(cmd); err != nil { + t.Fatalf("ensureVSCodeMCP() error = %v", err) + } + + // File should not be overwritten + data, _ := os.ReadFile(target) + if string(data) != string(existing) { + t.Error("ensureVSCodeMCP overwrote existing file") + } + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("skipped")) { + t.Errorf("expected 'skipped' in output, got %q", output) + } +} + +func TestEnsureVSCodeMCP_CreatesVSCodeDir(t *testing.T) { + tmp := t.TempDir() + + origDir, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Ensure .vscode/ does NOT exist beforehand + vsDir := filepath.Join(tmp, ".vscode") + if _, err := os.Stat(vsDir); err == nil { + t.Fatal(".vscode should not exist yet") + } + + var buf bytes.Buffer + cmd := testCmd(&buf) + + if err := ensureVSCodeMCP(cmd); err != nil { + t.Fatalf("ensureVSCodeMCP() error = %v", err) + } + + // .vscode/ should now exist + info, err := os.Stat(".vscode") + if err != nil { + t.Fatalf(".vscode dir was not created: %v", err) + } + if !info.IsDir() { + t.Error(".vscode should be a directory") + } +} diff --git a/internal/cli/setup/core/copilot/doc.go b/internal/cli/setup/core/copilot/doc.go new file mode 100644 index 000000000..ac54b63d4 --- /dev/null +++ b/internal/cli/setup/core/copilot/doc.go @@ -0,0 +1,11 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package copilot deploys GitHub Copilot integration files. +// +// [DeployInstructions] generates .github/copilot-instructions.md and +// the accompanying .vscode/mcp.json for VS Code Copilot MCP support. +package copilot diff --git a/internal/cli/setup/core/copilot/testmain_test.go b/internal/cli/setup/core/copilot/testmain_test.go new file mode 100644 index 000000000..59dd50749 --- /dev/null +++ b/internal/cli/setup/core/copilot/testmain_test.go @@ -0,0 +1,19 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package copilot + +import ( + "os" + "testing" + + "github.com/ActiveMemory/ctx/internal/assets/read/lookup" +) + +func TestMain(m *testing.M) { + lookup.Init() + os.Exit(m.Run()) +} diff --git a/internal/cli/setup/core/copilot/vscode.go b/internal/cli/setup/core/copilot/vscode.go new file mode 100644 index 000000000..bf9cb38e3 --- /dev/null +++ b/internal/cli/setup/core/copilot/vscode.go @@ -0,0 +1,61 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package copilot + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config/fs" + mcpServer "github.com/ActiveMemory/ctx/internal/config/mcp/server" + "github.com/ActiveMemory/ctx/internal/config/token" + cfgVscode "github.com/ActiveMemory/ctx/internal/config/vscode" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" +) + +// ensureVSCodeMCP creates .vscode/mcp.json to register the ctx MCP +// server for VS Code Copilot. +// +// Skips if the file already exists to preserve user customizations. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if directory creation or file write fails +func ensureVSCodeMCP(cmd *cobra.Command) error { + target := filepath.Join(cfgVscode.Dir, cfgVscode.FileMCPJSON) + + if _, statErr := os.Stat(target); statErr == nil { + writeSetup.InfoCopilotCLISkipped(cmd, target) + return nil + } + + if mkdirErr := os.MkdirAll(cfgVscode.Dir, fs.PermExec); mkdirErr != nil { + return mkdirErr + } + + mcpCfg := map[string]interface{}{ + cfgVscode.KeyServers: map[string]interface{}{ + mcpServer.Name: map[string]interface{}{ + cfgVscode.KeyCommand: mcpServer.Command, + cfgVscode.KeyArgs: mcpServer.Args(), + }, + }, + } + data, _ := json.MarshalIndent(mcpCfg, "", " ") + data = append(data, token.NewlineLF...) + + if writeFileErr := os.WriteFile(target, data, fs.PermFile); writeFileErr != nil { + return writeFileErr + } + writeSetup.InfoCopilotCLICreated(cmd, target) + return nil +} diff --git a/internal/cli/setup/core/copilot_cli/agent.go b/internal/cli/setup/core/copilot_cli/agent.go new file mode 100644 index 000000000..07f8ca6c3 --- /dev/null +++ b/internal/cli/setup/core/copilot_cli/agent.go @@ -0,0 +1,51 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package copilotcli + +import ( + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/agent" + "github.com/ActiveMemory/ctx/internal/config/fs" + cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" +) + +// deployAgent creates .github/agents/ctx.md for Copilot CLI custom +// agent delegation. Skips if the file already exists. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if directory creation or file write fails +func deployAgent(cmd *cobra.Command) error { + agentsDir := filepath.Join(cfgHook.DirGitHub, cfgHook.DirGitHubAgents) + target := filepath.Join(agentsDir, cfgHook.FileAgentsCtxMd) + + if _, err := os.Stat(target); err == nil { + writeSetup.InfoCopilotCLISkipped(cmd, target) + return nil + } + + if err := os.MkdirAll(agentsDir, fs.PermExec); err != nil { + return err + } + + content, readErr := agent.AgentsCtxMd() + if readErr != nil { + return readErr + } + if wErr := os.WriteFile(target, content, fs.PermFile); wErr != nil { + return wErr + } + writeSetup.InfoCopilotCLICreated(cmd, target) + return nil +} diff --git a/internal/cli/setup/core/copilot_cli/copilot_cli.go b/internal/cli/setup/core/copilot_cli/copilot_cli.go new file mode 100644 index 000000000..01ad533a7 --- /dev/null +++ b/internal/cli/setup/core/copilot_cli/copilot_cli.go @@ -0,0 +1,98 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package copilotcli + +import ( + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/agent" + "github.com/ActiveMemory/ctx/internal/config/fs" + cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" + errFs "github.com/ActiveMemory/ctx/internal/err/fs" + writeErr "github.com/ActiveMemory/ctx/internal/write/err" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" +) + +// Deploy generates .github/hooks/ctx-hooks.json and the +// accompanying hook scripts for GitHub Copilot CLI integration. +// +// Creates the .github/hooks/ and .github/hooks/scripts/ directories if +// needed and writes the JSON config plus bash and PowerShell scripts +// from embedded assets. Also writes .github/agents/ctx.md and +// .github/instructions/context.instructions.md for Copilot CLI. +// Skips if ctx-hooks.json already exists. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if directory creation or file write fails +func Deploy(cmd *cobra.Command) error { + hooksDir := filepath.Join(cfgHook.DirGitHub, cfgHook.DirGitHubHooks) + scriptsDir := filepath.Join(hooksDir, cfgHook.DirGitHubHooksScripts) + targetJSON := filepath.Join(hooksDir, cfgHook.FileCopilotCLIHooksJSON) + + // Check if ctx-hooks.json already exists + if _, err := os.Stat(targetJSON); err == nil { + writeSetup.InfoCopilotCLISkipped(cmd, targetJSON) + return nil + } + + // Create directories + if err := os.MkdirAll(scriptsDir, fs.PermExec); err != nil { + return errFs.Mkdir(scriptsDir, err) + } + + // Write ctx-hooks.json + jsonContent, readErr := agent.CopilotCLIHooksJSON() + if readErr != nil { + return readErr + } + if wErr := os.WriteFile(targetJSON, jsonContent, fs.PermFile); wErr != nil { + return errFs.FileWrite(targetJSON, wErr) + } + writeSetup.InfoCopilotCLICreated(cmd, targetJSON) + + // Write all hook scripts + scripts, scrErr := agent.CopilotCLIScripts() + if scrErr != nil { + return scrErr + } + for name, content := range scripts { + target := filepath.Join(scriptsDir, name) + if wErr := os.WriteFile(target, content, fs.PermExec); wErr != nil { + return errFs.FileWrite(target, wErr) + } + writeSetup.InfoCopilotCLICreated(cmd, target) + } + + // Write .github/agents/ctx.md + if err := deployAgent(cmd); err != nil { + writeErr.WarnFile(cmd, cfgHook.DirGitHubAgents, err) + } + + // Write .github/instructions/context.instructions.md + if err := deployInstructions(cmd); err != nil { + writeErr.WarnFile(cmd, cfgHook.DirGitHubInstructions, err) + } + + // Register ctx MCP server in ~/.copilot/mcp-config.json + if err := ensureMCPConfig(cmd); err != nil { + writeErr.WarnFile(cmd, cfgHook.FileMCPConfigJSON, err) + } + + // Write .github/skills//SKILL.md for Copilot CLI skills + if err := deploySkills(cmd); err != nil { + writeErr.WarnFile(cmd, cfgHook.DirGitHubSkills, err) + } + + writeSetup.InfoCopilotCLISummary(cmd) + return nil +} diff --git a/internal/cli/setup/core/copilot_cli/doc.go b/internal/cli/setup/core/copilot_cli/doc.go new file mode 100644 index 000000000..621e3feb9 --- /dev/null +++ b/internal/cli/setup/core/copilot_cli/doc.go @@ -0,0 +1,12 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package copilotcli deploys GitHub Copilot CLI hook scripts, +// agent definitions, instructions, skills, and MCP configuration. +// +// [DeployHooks] is the public entry point called from the setup command. +// Helper functions handle individual artifact types. +package copilotcli diff --git a/internal/cli/setup/core/copilot_cli/instructions.go b/internal/cli/setup/core/copilot_cli/instructions.go new file mode 100644 index 000000000..439368aa6 --- /dev/null +++ b/internal/cli/setup/core/copilot_cli/instructions.go @@ -0,0 +1,52 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package copilotcli + +import ( + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/agent" + "github.com/ActiveMemory/ctx/internal/config/fs" + cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" +) + +// deployInstructions creates +// .github/instructions/context.instructions.md for path-specific +// context file instructions. Skips if the file already exists. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if directory creation or file write fails +func deployInstructions(cmd *cobra.Command) error { + instrDir := filepath.Join(cfgHook.DirGitHub, cfgHook.DirGitHubInstructions) + target := filepath.Join(instrDir, cfgHook.FileInstructionsCtxMd) + + if _, err := os.Stat(target); err == nil { + writeSetup.InfoCopilotCLISkipped(cmd, target) + return nil + } + + if err := os.MkdirAll(instrDir, fs.PermExec); err != nil { + return err + } + + content, readErr := agent.InstructionsCtxMd() + if readErr != nil { + return readErr + } + if wErr := os.WriteFile(target, content, fs.PermFile); wErr != nil { + return wErr + } + writeSetup.InfoCopilotCLICreated(cmd, target) + return nil +} diff --git a/internal/cli/setup/core/copilot_cli/mcp.go b/internal/cli/setup/core/copilot_cli/mcp.go new file mode 100644 index 000000000..b3c5fe71d --- /dev/null +++ b/internal/cli/setup/core/copilot_cli/mcp.go @@ -0,0 +1,91 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package copilotcli + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config/fs" + cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" + mcpServer "github.com/ActiveMemory/ctx/internal/config/mcp/server" + "github.com/ActiveMemory/ctx/internal/config/token" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" +) + +// ensureMCPConfig registers the ctx MCP server in +// ~/.copilot/mcp-config.json (or $COPILOT_HOME/mcp-config.json). +// +// Merge-safe: reads existing config, adds ctx server, writes back. +// Skips if ctx server is already registered. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if file read/write fails +func ensureMCPConfig(cmd *cobra.Command) error { + copilotHome := os.Getenv(cfgHook.EnvCopilotHome) + if copilotHome == "" { + home, homeErr := os.UserHomeDir() + if homeErr != nil { + return homeErr + } + copilotHome = filepath.Join(home, cfgHook.DirCopilotHome) + } + + target := filepath.Join(copilotHome, cfgHook.FileMCPConfigJSON) + + // Read existing config if it exists + existing := make(map[string]interface{}) + if data, readErr := os.ReadFile(filepath.Clean(target)); readErr == nil { + if jErr := json.Unmarshal(data, &existing); jErr != nil { + return jErr + } + } + + // Get or create mcpServers map + servers, _ := existing[cfgHook.KeyMCPServers].(map[string]interface{}) + if servers == nil { + servers = make(map[string]interface{}) + } + + // Check if ctx is already registered + if _, ok := servers[mcpServer.Name]; ok { + writeSetup.InfoCopilotCLISkipped(cmd, target) + return nil + } + + // Add ctx MCP server + servers[mcpServer.Name] = map[string]interface{}{ + cfgHook.KeyType: cfgHook.MCPServerType, + cfgHook.KeyCommand: mcpServer.Command, + cfgHook.KeyArgs: mcpServer.Args(), + cfgHook.KeyTools: []string{cfgHook.ToolsWildcard}, + } + existing[cfgHook.KeyMCPServers] = servers + + // Create directory if needed + if mkdirErr := os.MkdirAll(copilotHome, fs.PermExec); mkdirErr != nil { + return mkdirErr + } + + data, marshalErr := json.MarshalIndent(existing, "", " ") + if marshalErr != nil { + return marshalErr + } + data = append(data, token.NewlineLF...) + + if writeFileErr := os.WriteFile(target, data, fs.PermFile); writeFileErr != nil { + return writeFileErr + } + writeSetup.InfoCopilotCLICreated(cmd, target) + return nil +} diff --git a/internal/cli/setup/core/copilot_cli/skills.go b/internal/cli/setup/core/copilot_cli/skills.go new file mode 100644 index 000000000..1738ee9c4 --- /dev/null +++ b/internal/cli/setup/core/copilot_cli/skills.go @@ -0,0 +1,54 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package copilotcli + +import ( + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/agent" + "github.com/ActiveMemory/ctx/internal/config/fs" + cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" +) + +// deploySkills creates .github/skills//SKILL.md for each +// embedded Copilot CLI skill template. Skips skills that already exist. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if skill reading or file write fails +func deploySkills(cmd *cobra.Command) error { + skills, readErr := agent.CopilotCLISkills() + if readErr != nil { + return readErr + } + + skillsBase := filepath.Join(cfgHook.DirGitHub, cfgHook.DirGitHubSkills) + for name, content := range skills { + skillDir := filepath.Join(skillsBase, name) + target := filepath.Join(skillDir, cfgHook.FileSKILLMd) + + if _, err := os.Stat(target); err == nil { + writeSetup.InfoCopilotCLISkipped(cmd, target) + continue + } + + if err := os.MkdirAll(skillDir, fs.PermExec); err != nil { + return err + } + if wErr := os.WriteFile(target, content, fs.PermFile); wErr != nil { + return wErr + } + writeSetup.InfoCopilotCLICreated(cmd, target) + } + return nil +} diff --git a/internal/cli/hook/doc.go b/internal/cli/setup/doc.go similarity index 69% rename from internal/cli/hook/doc.go rename to internal/cli/setup/doc.go index 92c290a8b..b07873cfe 100644 --- a/internal/cli/hook/doc.go +++ b/internal/cli/setup/doc.go @@ -4,10 +4,10 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -// Package hook implements the "ctx hook" command for generating AI tool +// Package setup implements the "ctx setup" command for generating AI tool // integration configurations. // -// The hook command outputs configuration snippets and instructions for +// The setup command outputs configuration snippets and instructions for // integrating Context with various AI coding tools including Claude Code, // Cursor, Aider, GitHub Copilot, and Windsurf. -package hook +package setup diff --git a/internal/cli/hook/hook.go b/internal/cli/setup/setup.go similarity index 53% rename from internal/cli/hook/hook.go rename to internal/cli/setup/setup.go index cfe0bbdee..9757e24f5 100644 --- a/internal/cli/hook/hook.go +++ b/internal/cli/setup/setup.go @@ -4,18 +4,18 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -package hook +package setup import ( "github.com/spf13/cobra" - hookRoot "github.com/ActiveMemory/ctx/internal/cli/hook/cmd/root" + setupRoot "github.com/ActiveMemory/ctx/internal/cli/setup/cmd/root" ) -// Cmd returns the "ctx hook" command for generating AI tool integrations. +// Cmd returns the "ctx setup" command for generating AI tool integrations. // // Returns: -// - *cobra.Command: The hook command with subcommands registered +// - *cobra.Command: The setup command with subcommands registered func Cmd() *cobra.Command { - return hookRoot.Cmd() + return setupRoot.Cmd() } diff --git a/internal/cli/hook/hook_test.go b/internal/cli/setup/setup_test.go similarity index 70% rename from internal/cli/hook/hook_test.go rename to internal/cli/setup/setup_test.go index 02ed74a1e..069f95765 100644 --- a/internal/cli/hook/hook_test.go +++ b/internal/cli/setup/setup_test.go @@ -4,7 +4,7 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -package hook +package setup import ( "bytes" @@ -13,12 +13,12 @@ import ( "strings" "testing" - hookRoot "github.com/ActiveMemory/ctx/internal/cli/hook/cmd/root" + coreCopilot "github.com/ActiveMemory/ctx/internal/cli/setup/core/copilot" "github.com/spf13/cobra" ) -// TestHookCommand tests the hook command. -func TestHookCommand(t *testing.T) { +// TestSetupCommand tests the setup command. +func TestSetupCommand(t *testing.T) { tests := []struct { tool string contains string @@ -32,24 +32,24 @@ func TestHookCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.tool, func(t *testing.T) { - hookCmd := Cmd() - hookCmd.SetArgs([]string{tt.tool}) + setupCmd := Cmd() + setupCmd.SetArgs([]string{tt.tool}) - if err := hookCmd.Execute(); err != nil { - t.Fatalf("hook %s command failed: %v", tt.tool, err) + if err := setupCmd.Execute(); err != nil { + t.Fatalf("setup %s command failed: %v", tt.tool, err) } }) } } -// TestHookCommandUnknownTool tests hook command with unknown tool. -func TestHookCommandUnknownTool(t *testing.T) { - hookCmd := Cmd() - hookCmd.SetArgs([]string{"unknown-tool"}) +// TestSetupCommandUnknownTool tests setup command with unknown tool. +func TestSetupCommandUnknownTool(t *testing.T) { + setupCmd := Cmd() + setupCmd.SetArgs([]string{"unknown-tool"}) - err := hookCmd.Execute() + err := setupCmd.Execute() if err == nil { - t.Error("hook command should fail for unknown tool") + t.Error("setup command should fail for unknown tool") } } @@ -61,16 +61,16 @@ func newHookTestCmd() *cobra.Command { return cmd } -// hookCmdOutput returns the captured output from a test command. -func hookCmdOutput(cmd *cobra.Command) string { +// setupCmdOutput returns the captured output from a test command. +func setupCmdOutput(cmd *cobra.Command) string { return cmd.OutOrStdout().(*bytes.Buffer).String() } -// TestWriteCopilotInstructions_NewFile creates the file from scratch. -func TestWriteCopilotInstructions_NewFile(t *testing.T) { +// TestDeployInstructions_NewFile creates the file from scratch. +func TestDeployInstructions_NewFile(t *testing.T) { tmpDir := t.TempDir() - // hookRoot.WriteCopilotInstructions uses relative paths, so chdir. + // coreCopilot.DeployInstructions uses relative paths, so chdir. origDir, wdErr := os.Getwd() if wdErr != nil { t.Fatal(wdErr) @@ -83,8 +83,8 @@ func TestWriteCopilotInstructions_NewFile(t *testing.T) { }) cmd := newHookTestCmd() - if runErr := hookRoot.WriteCopilotInstructions(cmd); runErr != nil { - t.Fatalf("hookRoot.WriteCopilotInstructions failed: %v", runErr) + if runErr := coreCopilot.DeployInstructions(cmd); runErr != nil { + t.Fatalf("coreCopilot.DeployInstructions failed: %v", runErr) } targetFile := filepath.Join(tmpDir, ".github", "copilot-instructions.md") @@ -102,8 +102,8 @@ func TestWriteCopilotInstructions_NewFile(t *testing.T) { } } -// TestWriteCopilotInstructions_ExistingWithMarker skips when marker exists. -func TestWriteCopilotInstructions_ExistingWithMarker(t *testing.T) { +// TestDeployInstructions_ExistingWithMarker skips when marker exists. +func TestDeployInstructions_ExistingWithMarker(t *testing.T) { tmpDir := t.TempDir() origDir, wdErr := os.Getwd() @@ -131,8 +131,8 @@ func TestWriteCopilotInstructions_ExistingWithMarker(t *testing.T) { } cmd := newHookTestCmd() - if runErr := hookRoot.WriteCopilotInstructions(cmd); runErr != nil { - t.Fatalf("hookRoot.WriteCopilotInstructions failed: %v", runErr) + if runErr := coreCopilot.DeployInstructions(cmd); runErr != nil { + t.Fatalf("coreCopilot.DeployInstructions failed: %v", runErr) } // File should be unchanged (skipped). @@ -144,14 +144,14 @@ func TestWriteCopilotInstructions_ExistingWithMarker(t *testing.T) { t.Error("file with existing ctx marker should not be modified") } - out := hookCmdOutput(cmd) + out := setupCmdOutput(cmd) if !strings.Contains(out, "skipped") { t.Errorf("output should mention skipped, got: %s", out) } } -// TestWriteCopilotInstructions_ExistingWithoutMarker merges content. -func TestWriteCopilotInstructions_ExistingWithoutMarker(t *testing.T) { +// TestDeployInstructions_ExistingWithoutMarker merges content. +func TestDeployInstructions_ExistingWithoutMarker(t *testing.T) { tmpDir := t.TempDir() origDir, wdErr := os.Getwd() @@ -179,8 +179,8 @@ func TestWriteCopilotInstructions_ExistingWithoutMarker(t *testing.T) { } cmd := newHookTestCmd() - if runErr := hookRoot.WriteCopilotInstructions(cmd); runErr != nil { - t.Fatalf("hookRoot.WriteCopilotInstructions failed: %v", runErr) + if runErr := coreCopilot.DeployInstructions(cmd); runErr != nil { + t.Fatalf("coreCopilot.DeployInstructions failed: %v", runErr) } data, readErr := os.ReadFile(targetFile) @@ -197,7 +197,7 @@ func TestWriteCopilotInstructions_ExistingWithoutMarker(t *testing.T) { t.Error("merged file should contain the ctx marker") } - out := hookCmdOutput(cmd) + out := setupCmdOutput(cmd) if !strings.Contains(out, "merged") { t.Errorf("output should mention merged, got: %s", out) } diff --git a/internal/cli/hook/testmain_test.go b/internal/cli/setup/testmain_test.go similarity index 96% rename from internal/cli/hook/testmain_test.go rename to internal/cli/setup/testmain_test.go index bec38758c..8f97e7e43 100644 --- a/internal/cli/hook/testmain_test.go +++ b/internal/cli/setup/testmain_test.go @@ -4,7 +4,7 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -package hook +package setup import ( "os" diff --git a/internal/cli/system/cmd/block_dangerous_command/run.go b/internal/cli/system/cmd/block_dangerous_command/run.go index ea866669e..8e82ec997 100644 --- a/internal/cli/system/cmd/block_dangerous_command/run.go +++ b/internal/cli/system/cmd/block_dangerous_command/run.go @@ -22,7 +22,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/regex" "github.com/ActiveMemory/ctx/internal/entity" "github.com/ActiveMemory/ctx/internal/notify" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the block-dangerous-commands hook logic. @@ -80,7 +80,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { Reason: reason, } data, _ := json.Marshal(resp) - writeHook.BlockResponse(cmd, string(data)) + writeSetup.BlockResponse(cmd, string(data)) ref := notify.NewTemplateRef(hook.BlockDangerousCommands, variant, nil) nudge.Relay(fmt.Sprintf( desc.Text(text.DescKeyRelayPrefixFormat), diff --git a/internal/cli/system/cmd/block_non_path_ctx/run.go b/internal/cli/system/cmd/block_non_path_ctx/run.go index e4e6c2d76..eb7666390 100644 --- a/internal/cli/system/cmd/block_non_path_ctx/run.go +++ b/internal/cli/system/cmd/block_non_path_ctx/run.go @@ -23,7 +23,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/token" "github.com/ActiveMemory/ctx/internal/entity" "github.com/ActiveMemory/ctx/internal/notify" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the block-non-path-ctx hook logic. @@ -80,7 +80,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { desc.Text(text.DescKeyBlockConstitutionSuffix), } data, _ := json.Marshal(resp) - writeHook.BlockResponse(cmd, string(data)) + writeSetup.BlockResponse(cmd, string(data)) blockRef := notify.NewTemplateRef(hook.BlockNonPathCtx, variant, nil) nudge.Relay(fmt.Sprintf(desc.Text(text.DescKeyRelayPrefixFormat), hook.BlockNonPathCtx, desc.Text(text.DescKeyBlockNonPathRelayMessage)), diff --git a/internal/cli/system/cmd/check_ceremony/run.go b/internal/cli/system/cmd/check_ceremony/run.go index b3a14a025..da2090da4 100644 --- a/internal/cli/system/cmd/check_ceremony/run.go +++ b/internal/cli/system/cmd/check_ceremony/run.go @@ -23,7 +23,7 @@ import ( ctxResolve "github.com/ActiveMemory/ctx/internal/context/resolve" internalIo "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/notify" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the check-ceremonies hook logic. @@ -69,7 +69,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { } msg, variant := coreCeremony.Emit(remember, wrapUp) - writeHook.Nudge(cmd, msg) + writeSetup.Nudge(cmd, msg) if msg == "" { return nil } diff --git a/internal/cli/system/cmd/check_context_size/run.go b/internal/cli/system/cmd/check_context_size/run.go index 2b81889ca..160ed4525 100644 --- a/internal/cli/system/cmd/check_context_size/run.go +++ b/internal/cli/system/cmd/check_context_size/run.go @@ -29,7 +29,7 @@ import ( "github.com/ActiveMemory/ctx/internal/entity" "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/rc" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the check-context-size hook logic. @@ -57,7 +57,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { // Pause check: this hook is the designated single emitter if turns := nudge.Paused(sessionID); turns > 0 { - writeHook.Nudge(cmd, nudge.PausedMessage(turns)) + writeSetup.Nudge(cmd, nudge.PausedMessage(turns)) return nil } @@ -86,7 +86,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { billingHit := billingThreshold > 0 && tokens >= billingThreshold if billingHit { - writeHook.NudgeBlock(cmd, + writeSetup.NudgeBlock(cmd, nudge.EmitBillingWarning( logFile, sessionID, count, tokens, billingThreshold, @@ -132,14 +132,14 @@ func Run(cmd *cobra.Command, stdin *os.File) error { evt := trigger.Event switch { case trigger.Window: - writeHook.NudgeBlock(cmd, + writeSetup.NudgeBlock(cmd, nudge.EmitWindowWarning( logFile, sessionID, count, tokens, pct, ), ) case trigger.Checkpoint: - writeHook.NudgeBlock(cmd, + writeSetup.NudgeBlock(cmd, nudge.EmitCheckpoint( logFile, sessionID, count, tokens, pct, windowSize, diff --git a/internal/cli/system/cmd/check_journal/run.go b/internal/cli/system/cmd/check_journal/run.go index 19b653a5d..8bae8982b 100644 --- a/internal/cli/system/cmd/check_journal/run.go +++ b/internal/cli/system/cmd/check_journal/run.go @@ -27,7 +27,7 @@ import ( ctxResolve "github.com/ActiveMemory/ctx/internal/context/resolve" internalIo "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/notify" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the check-journal hook logic. @@ -116,7 +116,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { boxTitle := desc.Text(text.DescKeyCheckJournalBoxTitle) relayPrefix := desc.Text(text.DescKeyCheckJournalRelayPrefix) - writeHook.Nudge(cmd, message.NudgeBox(relayPrefix, boxTitle, content)) + writeSetup.Nudge(cmd, message.NudgeBox(relayPrefix, boxTitle, content)) ref := notify.NewTemplateRef(hook.CheckJournal, variant, vars) journalMsg := hook.CheckJournal + ": " + fmt.Sprintf( diff --git a/internal/cli/system/cmd/check_knowledge/run.go b/internal/cli/system/cmd/check_knowledge/run.go index 225ef8a87..3d555fcc3 100644 --- a/internal/cli/system/cmd/check_knowledge/run.go +++ b/internal/cli/system/cmd/check_knowledge/run.go @@ -17,7 +17,7 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/system/core/state" "github.com/ActiveMemory/ctx/internal/config/knowledge" internalIo "github.com/ActiveMemory/ctx/internal/io" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the check-knowledge hook logic. @@ -49,7 +49,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { } if box, warned := coreKnowledge.CheckHealth(sessionID); warned { - writeHook.Nudge(cmd, box) + writeSetup.Nudge(cmd, box) internalIo.TouchFile(markerPath) } diff --git a/internal/cli/system/cmd/check_map_staleness/run.go b/internal/cli/system/cmd/check_map_staleness/run.go index eeaf61d4c..2808ef772 100644 --- a/internal/cli/system/cmd/check_map_staleness/run.go +++ b/internal/cli/system/cmd/check_map_staleness/run.go @@ -19,7 +19,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/architecture" cfgTime "github.com/ActiveMemory/ctx/internal/config/time" internalIo "github.com/ActiveMemory/ctx/internal/io" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the check-map-staleness hook logic. @@ -74,7 +74,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { } dateStr := lastRun.Format(cfgTime.DateFormat) - writeHook.Nudge(cmd, health.EmitMapStalenessWarning( + writeSetup.Nudge(cmd, health.EmitMapStalenessWarning( input.SessionID, dateStr, moduleCommits, )) diff --git a/internal/cli/system/cmd/check_persistence/run.go b/internal/cli/system/cmd/check_persistence/run.go index 3141493af..372d71483 100644 --- a/internal/cli/system/cmd/check_persistence/run.go +++ b/internal/cli/system/cmd/check_persistence/run.go @@ -29,7 +29,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/stats" "github.com/ActiveMemory/ctx/internal/notify" "github.com/ActiveMemory/ctx/internal/rc" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the check-persistence hook logic. @@ -121,7 +121,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { boxTitle := desc.Text(text.DescKeyCheckPersistenceBoxTitle) relayPrefix := desc.Text(text.DescKeyCheckPersistenceRelayPrefix) - writeHook.NudgeBlock(cmd, + writeSetup.NudgeBlock(cmd, message.NudgeBox( relayPrefix, fmt.Sprintf( desc.Text(text.DescKeyCheckPersistenceBoxTitleFormat), diff --git a/internal/cli/system/cmd/check_reminder/run.go b/internal/cli/system/cmd/check_reminder/run.go index 37d66e13e..136c682c1 100644 --- a/internal/cli/system/cmd/check_reminder/run.go +++ b/internal/cli/system/cmd/check_reminder/run.go @@ -25,7 +25,7 @@ import ( cfgTime "github.com/ActiveMemory/ctx/internal/config/time" "github.com/ActiveMemory/ctx/internal/config/token" "github.com/ActiveMemory/ctx/internal/notify" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the check-reminders hook logic. @@ -88,7 +88,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { return nil } - writeHook.Nudge(cmd, message.NudgeBox( + writeSetup.Nudge(cmd, message.NudgeBox( desc.Text(text.DescKeyCheckRemindersRelayPrefix), desc.Text(text.DescKeyCheckRemindersBoxTitle), content)) diff --git a/internal/cli/system/cmd/check_resources/run.go b/internal/cli/system/cmd/check_resources/run.go index 76ae90703..bc8d92874 100644 --- a/internal/cli/system/cmd/check_resources/run.go +++ b/internal/cli/system/cmd/check_resources/run.go @@ -22,7 +22,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/token" "github.com/ActiveMemory/ctx/internal/notify" "github.com/ActiveMemory/ctx/internal/sysinfo" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the check-resources hook logic. @@ -74,7 +74,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { return nil } - writeHook.Nudge(cmd, message.NudgeBox( + writeSetup.Nudge(cmd, message.NudgeBox( desc.Text(text.DescKeyCheckResourcesRelayPrefix), desc.Text(text.DescKeyCheckResourcesBoxTitle), content)) diff --git a/internal/cli/system/cmd/check_skill_discovery/run.go b/internal/cli/system/cmd/check_skill_discovery/run.go index a8ee8e49c..76fc855b3 100644 --- a/internal/cli/system/cmd/check_skill_discovery/run.go +++ b/internal/cli/system/cmd/check_skill_discovery/run.go @@ -21,7 +21,7 @@ import ( cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" "github.com/ActiveMemory/ctx/internal/config/stats" internalIo "github.com/ActiveMemory/ctx/internal/io" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the skill discovery nudge hook logic. @@ -84,7 +84,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { desc.Text(text.DescKeySkillDiscoveryBoxTitle), content, ) - writeHook.NudgeBlock(cmd, box) + writeSetup.NudgeBlock(cmd, box) internalIo.TouchFile(guardFile) return nil diff --git a/internal/cli/system/cmd/check_task_completion/run.go b/internal/cli/system/cmd/check_task_completion/run.go index 5476c51fb..dc8255588 100644 --- a/internal/cli/system/cmd/check_task_completion/run.go +++ b/internal/cli/system/cmd/check_task_completion/run.go @@ -25,7 +25,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/nudge" "github.com/ActiveMemory/ctx/internal/notify" "github.com/ActiveMemory/ctx/internal/rc" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the check-task-completion hook logic. @@ -73,7 +73,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { if msg == "" { return nil } - writeHook.Context( + writeSetup.Context( cmd, coreSession.FormatContext(hook.EventPostToolUse, msg), ) diff --git a/internal/cli/system/cmd/check_version/run.go b/internal/cli/system/cmd/check_version/run.go index 6fad163b1..1bef49f25 100644 --- a/internal/cli/system/cmd/check_version/run.go +++ b/internal/cli/system/cmd/check_version/run.go @@ -25,7 +25,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/version" internalIo "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/notify" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the check-version hook logic. @@ -71,7 +71,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { msg := fmt.Sprintf( desc.Text(text.DescKeyCheckVersionPluginReadError), pluginErr, ) - writeHook.Nudge(cmd, msg) + writeSetup.Nudge(cmd, msg) return nil } @@ -105,7 +105,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { boxTitle := desc.Text(text.DescKeyCheckVersionBoxTitle) relayPrefix := desc.Text(text.DescKeyCheckVersionRelayPrefix) - writeHook.Nudge(cmd, message.NudgeBox(relayPrefix, boxTitle, content)) + writeSetup.Nudge(cmd, message.NudgeBox(relayPrefix, boxTitle, content)) ref := notify.NewTemplateRef(hook.CheckVersion, hook.VariantMismatch, map[string]any{ @@ -121,7 +121,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { internalIo.TouchFile(markerFile) // Key age check: piggyback on the daily version check - writeHook.Nudge(cmd, coreVersion.CheckKeyAge(input.SessionID)) + writeSetup.Nudge(cmd, coreVersion.CheckKeyAge(input.SessionID)) return nil } diff --git a/internal/cli/system/cmd/context_load_gate/run.go b/internal/cli/system/cmd/context_load_gate/run.go index bb345fa23..e0bed2eee 100644 --- a/internal/cli/system/cmd/context_load_gate/run.go +++ b/internal/cli/system/cmd/context_load_gate/run.go @@ -32,7 +32,7 @@ import ( "github.com/ActiveMemory/ctx/internal/entity" internalIo "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/rc" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the context-load-gate hook logic. @@ -131,7 +131,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { desc.Text(text.DescKeyContextLoadGateFooter), filesLoaded, totalTokens)) - writeHook.Context( + writeSetup.Context( cmd, coreSession.FormatContext(hook.EventPreToolUse, content.String()), ) diff --git a/internal/cli/system/cmd/post_commit/run.go b/internal/cli/system/cmd/post_commit/run.go index 7d39ea394..fb640ee5c 100644 --- a/internal/cli/system/cmd/post_commit/run.go +++ b/internal/cli/system/cmd/post_commit/run.go @@ -24,7 +24,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/regex" ctxContext "github.com/ActiveMemory/ctx/internal/context/resolve" "github.com/ActiveMemory/ctx/internal/notify" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the post-commit hook logic. @@ -66,7 +66,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { return nil } msg = ctxContext.AppendDir(msg) - writeHook.Context(cmd, coreSession.FormatContext(hook.EventPostToolUse, msg)) + writeSetup.Context(cmd, coreSession.FormatContext(hook.EventPostToolUse, msg)) ref := notify.NewTemplateRef(hookName, variant, nil) nudge.Relay( @@ -79,11 +79,11 @@ func Run(cmd *cobra.Command, stdin *os.File) error { ) if driftResponse := drift.CheckVersion(sessionID); driftResponse != "" { - writeHook.Context(cmd, driftResponse) + writeSetup.Context(cmd, driftResponse) } if violations := scoreCommitViolations(); violations != "" { - writeHook.NudgeBlock(cmd, violations) + writeSetup.NudgeBlock(cmd, violations) } return nil diff --git a/internal/cli/system/cmd/qa_reminder/run.go b/internal/cli/system/cmd/qa_reminder/run.go index dad890a34..ce31345ae 100644 --- a/internal/cli/system/cmd/qa_reminder/run.go +++ b/internal/cli/system/cmd/qa_reminder/run.go @@ -24,7 +24,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/hook" ctxContext "github.com/ActiveMemory/ctx/internal/context/resolve" "github.com/ActiveMemory/ctx/internal/notify" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the qa-reminder hook logic. @@ -58,7 +58,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { } msg = ctxContext.AppendDir(msg) - writeHook.Context(cmd, coreSession.FormatContext(hook.EventPreToolUse, msg)) + writeSetup.Context(cmd, coreSession.FormatContext(hook.EventPreToolUse, msg)) ref := notify.NewTemplateRef(hook.QAReminder, hook.VariantGate, nil) nudge.Relay(fmt.Sprintf(desc.Text(text.DescKeyRelayPrefixFormat), diff --git a/internal/cli/system/cmd/session_event/cmd.go b/internal/cli/system/cmd/session_event/cmd.go new file mode 100644 index 000000000..af010e4b2 --- /dev/null +++ b/internal/cli/system/cmd/session_event/cmd.go @@ -0,0 +1,46 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package sessionevent + +import ( + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/cmd" + embFlag "github.com/ActiveMemory/ctx/internal/config/embed/flag" + cFlag "github.com/ActiveMemory/ctx/internal/config/flag" +) + +// Cmd returns the "ctx system session-event" subcommand. +// +// Returns: +// - *cobra.Command: Configured session-event subcommand +func Cmd() *cobra.Command { + var eventType string + var caller string + + short, long := desc.Command(cmd.DescKeySystemSessionEvent) + + c := &cobra.Command{ + Use: cmd.UseSystemSessionEvent, + Short: short, + Long: long, + Hidden: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return Run(cmd, eventType, caller) + }, + } + + c.Flags().StringVar(&eventType, cFlag.Type, "", + desc.Flag(embFlag.DescKeySystemSessionEventType)) + c.Flags().StringVar(&caller, cFlag.Caller, "", + desc.Flag(embFlag.DescKeySystemSessionEventCaller)) + _ = c.MarkFlagRequired(cFlag.Type) + _ = c.MarkFlagRequired(cFlag.Caller) + + return c +} diff --git a/internal/cli/system/cmd/session_event/run.go b/internal/cli/system/cmd/session_event/run.go new file mode 100644 index 000000000..fe27a864d --- /dev/null +++ b/internal/cli/system/cmd/session_event/run.go @@ -0,0 +1,58 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package sessionevent + +import ( + "fmt" + + "github.com/spf13/cobra" + + coreState "github.com/ActiveMemory/ctx/internal/cli/system/core/state" + cfgEvent "github.com/ActiveMemory/ctx/internal/config/event" + cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" + errSession "github.com/ActiveMemory/ctx/internal/err/session" + "github.com/ActiveMemory/ctx/internal/log/event" + "github.com/ActiveMemory/ctx/internal/notify" + wSession "github.com/ActiveMemory/ctx/internal/write/session" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/text" +) + +// Run executes the session-event command logic. +// +// Records a session lifecycle event (start or end) to the event log +// and sends a notification. No-op if the context directory is not +// initialized. +// +// Parameters: +// - cmd: Cobra command for output +// - eventType: "start" or "end" +// - caller: identifier of the calling editor (e.g. "vscode") +// +// Returns: +// - error: Non-nil if eventType is invalid +func Run(cmd *cobra.Command, eventType, caller string) error { + if !coreState.Initialized() { + return nil + } + + if eventType != cfgEvent.TypeStart && eventType != cfgEvent.TypeEnd { + return errSession.EventInvalidType( + cfgEvent.TypeStart, cfgEvent.TypeEnd, eventType) + } + + msg := fmt.Sprintf(desc.Text(text.DescKeyWriteSessionEvent), eventType, caller) + ref := notify.NewTemplateRef(cfgHook.SessionEvent, eventType, + map[string]any{"Caller": caller}) + + event.Append(cfgEvent.CategorySession, msg, "", ref) + _ = notify.Send(cfgEvent.CategorySession, msg, "", ref) + + wSession.Event(cmd, eventType, caller) + return nil +} diff --git a/internal/cli/system/cmd/specs_nudge/run.go b/internal/cli/system/cmd/specs_nudge/run.go index 5341462b8..2f876d714 100644 --- a/internal/cli/system/cmd/specs_nudge/run.go +++ b/internal/cli/system/cmd/specs_nudge/run.go @@ -22,7 +22,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/hook" ctxContext "github.com/ActiveMemory/ctx/internal/context/resolve" "github.com/ActiveMemory/ctx/internal/notify" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Run executes the specs-nudge hook logic. @@ -53,7 +53,7 @@ func Run(cmd *cobra.Command, stdin *os.File) error { return nil } msg = ctxContext.AppendDir(msg) - writeHook.Context(cmd, coreSession.FormatContext(hook.EventPreToolUse, msg)) + writeSetup.Context(cmd, coreSession.FormatContext(hook.EventPreToolUse, msg)) nudgeMsg := desc.Text(text.DescKeySpecsNudgeNudgeMessage) ref := notify.NewTemplateRef(hook.SpecsNudge, hook.VariantNudge, nil) nudge.Relay( diff --git a/internal/cli/system/core/nudge/relay.go b/internal/cli/system/core/nudge/relay.go index 99e006317..fb19b94fd 100644 --- a/internal/cli/system/core/nudge/relay.go +++ b/internal/cli/system/core/nudge/relay.go @@ -14,7 +14,7 @@ import ( internalIo "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/log/event" "github.com/ActiveMemory/ctx/internal/notify" - writeHook "github.com/ActiveMemory/ctx/internal/write/hook" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) // Relay sends a relay notification and appends the same event to the @@ -95,7 +95,7 @@ func Emit( vars map[string]any, markerPath string, ) { - writeHook.Nudge(cmd, message.NudgeBox(relayPrefix, boxTitle, content)) + writeSetup.Nudge(cmd, message.NudgeBox(relayPrefix, boxTitle, content)) ref := notify.NewTemplateRef(hookName, variant, vars) Relay(hookName+": "+relayMessage, sessionID, ref) if markerPath != "" { diff --git a/internal/cli/system/doc.go b/internal/cli/system/doc.go index 9ce0fb68c..a0aa7b6cd 100644 --- a/internal/cli/system/doc.go +++ b/internal/cli/system/doc.go @@ -20,6 +20,7 @@ // Plumbing subcommands (hidden, used by skills and automation): // - mark-journal: Update journal processing state (.state.json) // - mark-wrapped-up: Record wrap-up ceremony timestamp +// - session-event: Record session lifecycle events (start, end) // // Hook subcommands read JSON from stdin (Claude Code hook contract), perform // their logic, and exit 0. Block commands output JSON with a "decision" field. diff --git a/internal/cli/system/system.go b/internal/cli/system/system.go index da9b32f5d..8051655e8 100644 --- a/internal/cli/system/system.go +++ b/internal/cli/system/system.go @@ -40,6 +40,7 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/system/cmd/qa_reminder" "github.com/ActiveMemory/ctx/internal/cli/system/cmd/resources" "github.com/ActiveMemory/ctx/internal/cli/system/cmd/resume" + sessionevent "github.com/ActiveMemory/ctx/internal/cli/system/cmd/session_event" "github.com/ActiveMemory/ctx/internal/cli/system/cmd/specs_nudge" "github.com/ActiveMemory/ctx/internal/cli/system/cmd/stats" "github.com/ActiveMemory/ctx/internal/config/embed/cmd" @@ -93,6 +94,7 @@ func Cmd() *cobra.Command { qa_reminder.Cmd(), resources.Cmd(), resume.Cmd(), + sessionevent.Cmd(), specs_nudge.Cmd(), stats.Cmd(), ) diff --git a/internal/compliance/compliance_test.go b/internal/compliance/compliance_test.go index 4fc17eea8..442cf33c3 100644 --- a/internal/compliance/compliance_test.go +++ b/internal/compliance/compliance_test.go @@ -1050,7 +1050,6 @@ func TestCmdDirPurity(t *testing.T) { "guide/cmd/root/command.go": {"listCommands": true}, "guide/cmd/root/skill.go": {"parseSkillFrontmatter": true, "truncateDescription": true, "listSkills": true}, "guide/cmd/root/types.go": {"skillMeta": true}, - "hook/cmd/root/run.go": {"WriteCopilotInstructions": true}, "initialize/cmd/root/run.go": {"initScratchpad": true, "hasEssentialFiles": true, "ensureGitignoreEntries": true, "writeGettingStarted": true}, "journal/cmd/obsidian/run.go": {"BuildVault": true}, "journal/cmd/source/list.go": {"runList": true}, @@ -1139,3 +1138,99 @@ func TestCmdDirPurity(t *testing.T) { t.Fatalf("walk: %v", err) } } + +// allSourceFiles returns all source files (.go, .ts, .js) under the project +// root, excluding vendor/, node_modules/, dist/, site/, and .git/. +func allSourceFiles(t *testing.T, root string) []string { + t.Helper() + sourceExts := map[string]bool{ + ".go": true, + ".ts": true, + ".js": true, + } + var files []string + err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() && (info.Name() == "vendor" || info.Name() == ".git" || + info.Name() == "dist" || info.Name() == "site" || info.Name() == "node_modules") { + return filepath.SkipDir + } + if !info.IsDir() && sourceExts[filepath.Ext(path)] { + files = append(files, path) + } + return nil + }) + if err != nil { + t.Fatalf("failed to walk project: %v", err) + } + return files +} + +// --------------------------------------------------------------------------- +// 23. No UTF-8 BOM — source files must not start with a byte-order mark +// --------------------------------------------------------------------------- + +// TestNoUTF8BOM detects the UTF-8 BOM (0xEF 0xBB 0xBF) that Windows editors +// sometimes insert. BOM causes subtle issues with Go tooling and TypeScript +// compilers and should never appear in source files. +func TestNoUTF8BOM(t *testing.T) { + root := projectRoot(t) + bom := []byte{0xEF, 0xBB, 0xBF} + + for _, p := range allSourceFiles(t, root) { + rel, _ := filepath.Rel(root, p) + t.Run(rel, func(t *testing.T) { + data, readErr := os.ReadFile(filepath.Clean(p)) + if readErr != nil { + t.Fatalf("read: %v", readErr) + } + if bytes.HasPrefix(data, bom) { + t.Errorf("file starts with UTF-8 BOM (0xEF 0xBB 0xBF); remove it") + } + }) + } +} + +// --------------------------------------------------------------------------- +// 24. No mojibake — detect double-encoded UTF-8 (encoding corruption) +// --------------------------------------------------------------------------- + +// TestNoMojibake catches the classic Windows encoding corruption where UTF-8 +// bytes are misread as Windows-1252/Latin-1 and re-encoded as UTF-8. +// Example: em dash U+2014 becomes a 6-byte garbled sequence starting with +// 0xC3 0xA2. We detect that signature to catch double-encoded files. +func TestNoMojibake(t *testing.T) { + root := projectRoot(t) + // 0xC3 0xA2 is UTF-8 for U+00E2 (Latin small letter a with circumflex). + // In mojibake, it always appears followed by 0xE2 as part of a garbled + // multi-byte sequence (e.g., em dash becomes 0xC3 0xA2 0xE2 0x82 ...). + // We match that three-byte signature: 0xC3 0xA2 0xE2. + mojibakePattern := []byte{0xC3, 0xA2, 0xE2} + + for _, p := range allSourceFiles(t, root) { + rel, _ := filepath.Rel(root, p) + t.Run(rel, func(t *testing.T) { + data, readErr := os.ReadFile(filepath.Clean(p)) + if readErr != nil { + t.Fatalf("read: %v", readErr) + } + if idx := bytes.Index(data, mojibakePattern); idx >= 0 { + // Show context around the corruption + start := idx + if start > 20 { + start = idx - 20 + } + end := idx + 30 + if end > len(data) { + end = len(data) + } + t.Errorf("double-encoded UTF-8 (mojibake) detected at byte %d: %q\n"+ + "This usually means a Windows editor re-encoded the file.\n"+ + "Fix: restore from git (git checkout HEAD -- %s) and re-apply changes with a UTF-8-aware editor.", + idx, data[start:end], rel) + } + }) + } +} diff --git a/internal/config/asset/asset.go b/internal/config/asset/asset.go index 20aa02c34..8e5679c3c 100644 --- a/internal/config/asset/asset.go +++ b/internal/config/asset/asset.go @@ -10,20 +10,24 @@ import "path" // Embedded asset directory names. const ( - DirClaude = "claude" - DirClaudePlugin = "claude/.claude-plugin" - DirClaudeSkills = "claude/skills" - DirCommands = "commands" - DirCommandsText = "commands/text" - DirContext = "context" - DirEntryTemplates = "entry-templates" - DirHooks = "hooks" - DirHooksMessages = "hooks/messages" - DirJournal = "journal" - DirPermissions = "permissions" - DirProject = "project" - DirSchema = "schema" - DirWhy = "why" + DirClaude = "claude" + DirClaudePlugin = "claude/.claude-plugin" + DirClaudeSkills = "claude/skills" + DirCommands = "commands" + DirCommandsText = "commands/text" + DirContext = "context" + DirEntryTemplates = "entry-templates" + DirIntegrations = "integrations" + DirIntegrationsCopilot = "integrations/copilot" + DirIntegrationsCopilotCLI = "integrations/copilot-cli" + DirIntegrationsCopilotScrp = "integrations/copilot-cli/scripts" + DirIntegrationsCopilotSkill = "integrations/copilot-cli/skills" + DirHooksMessages = "hooks/messages" + DirJournal = "journal" + DirPermissions = "permissions" + DirProject = "project" + DirSchema = "schema" + DirWhy = "why" ) // JSON field keys used when parsing embedded asset files. @@ -43,7 +47,11 @@ const ( FileAllowTxt = "allow.txt" FileCLAUDEMd = "CLAUDE.md" FileCommandsYAML = "commands.yaml" + FileAgentsMd = "agents.md" + FileAgentsCtxMd = "agents-ctx.md" + FileCopilotCLIHooksJSON = "ctx-hooks.json" FileCopilotInstructionsMd = "copilot-instructions.md" + FileInstructionsCtxMd = "instructions-context.md" FileCtxrcSchemaJSON = "ctxrc.schema.json" FileDenyTxt = "deny.txt" FileExamplesYAML = "examples.yaml" @@ -67,8 +75,12 @@ var ( PathCommandsYAML = path.Join(DirCommands, FileCommandsYAML) PathFlagsYAML = path.Join(DirCommands, FileFlagsYAML) PathExamplesYAML = path.Join(DirCommands, FileExamplesYAML) - PathCopilotInstructions = path.Join(DirHooks, FileCopilotInstructionsMd) - PathHookRegistry = path.Join(DirHooksMessages, FileRegistryYAML) + PathAgentsMd = path.Join(DirIntegrations, FileAgentsMd) + PathAgentsCtxMd = path.Join(DirIntegrationsCopilotCLI, FileAgentsCtxMd) + PathCopilotCLIHooksJSON = path.Join(DirIntegrationsCopilotCLI, FileCopilotCLIHooksJSON) + PathCopilotInstructions = path.Join(DirIntegrationsCopilot, FileCopilotInstructionsMd) + PathInstructionsCtxMd = path.Join(DirIntegrationsCopilotCLI, FileInstructionsCtxMd) + PathMessageRegistry = path.Join(DirHooksMessages, FileRegistryYAML) PathExtraCSS = path.Join(DirJournal, FileExtraCSS) PathMakefileCtx = path.Join(DirProject, FileMakefileCtx) PathAllowTxt = path.Join(DirPermissions, FileAllowTxt) diff --git a/internal/config/copilot/copilot.go b/internal/config/copilot/copilot.go new file mode 100644 index 000000000..b5b4f8168 --- /dev/null +++ b/internal/config/copilot/copilot.go @@ -0,0 +1,78 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package copilot defines constants for Copilot Chat and Copilot CLI +// session parsing and integration. +// +// Provides JSON key paths, response item kinds, scanner buffer sizes, +// VS Code storage paths, and Copilot CLI directory names used by the +// journal parser and setup command. +package copilot + +// JSON key paths in Copilot Chat JSONL session files. +const ( + KeyRequests = "requests" + KeyResult = "result" + KeyResponse = "response" +) + +// Response item kind values in Copilot Chat sessions. +const ( + RespKindThinking = "thinking" + RespKindToolInvoke = "toolInvocationSerialized" +) + +// Copilot Chat session storage directory and file names. +const ( + DirChatSessions = "chatSessions" + FileWorkspace = "workspace.json" + ResponseSuffix = "-response" +) + +// Scanner buffer sizes for JSONL parsing. +const ( + // ScanBufInit is the initial scanner buffer size (64KB). + ScanBufInit = 64 * 1024 + // ScanBufMax is the maximum scanner buffer size (4MB). + // Copilot lines can be very large due to embedded code content. + ScanBufMax = 4 * 1024 * 1024 + // ScanBufMatchMax is the maximum scanner buffer for Matches + // checks (1MB). Smaller than full parse because only the first + // line is inspected. + ScanBufMatchMax = 1024 * 1024 +) + +// Tool ID parsing. +const ( + // ToolIDSeparator separates the namespace prefix from the tool + // name in Copilot tool IDs (e.g., "copilot_readFile"). + ToolIDSeparator = "_" +) + +// Copilot CLI application and session directory names. +const ( + // CLIAppName is the application directory name used on Windows + // under LOCALAPPDATA for Copilot CLI sessions. + CLIAppName = "GitHub Copilot CLI" + // DirSessions is a candidate session subdirectory. + DirSessions = "sessions" + // DirHistory is a candidate session subdirectory. + DirHistory = "history" +) + +// VS Code platform and storage path constants. +const ( + EnvAppData = "APPDATA" + OSDarwin = "darwin" + SchemeFile = "file" + AppCode = "Code" + AppCodeInsiders = "Code - Insiders" + DirUser = "User" + DirWorkspace = "workspaceStorage" + DirLibrary = "Library" + DirAppSupport = "Application Support" + DirDotConfig = ".config" +) diff --git a/internal/config/copilot/doc.go b/internal/config/copilot/doc.go new file mode 100644 index 000000000..fff2094d3 --- /dev/null +++ b/internal/config/copilot/doc.go @@ -0,0 +1,13 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package copilot defines constants for Copilot Chat and Copilot CLI +// session parsing and integration. +// +// Provides JSON key paths, response item kinds, scanner buffer sizes, +// VS Code storage paths, and Copilot CLI directory names used by the +// journal parser and setup command. +package copilot diff --git a/internal/config/embed/cmd/base.go b/internal/config/embed/cmd/base.go index 7454573e0..89e4f55df 100644 --- a/internal/config/embed/cmd/base.go +++ b/internal/config/embed/cmd/base.go @@ -17,7 +17,7 @@ const ( UseDrift = "drift" UseComplete = "complete " UseGuide = "guide" - UseHook = "hook " + UseSetup = "setup " UseInit = "init" UseLoad = "load" UseLoop = "loop" @@ -49,7 +49,7 @@ const ( DescKeyDep = "dep" DescKeyDoctor = "doctor" DescKeyDrift = "drift" - DescKeyHook = "hook" + DescKeySetup = "setup" DescKeyInitialize = "initialize" DescKeyLoad = "load" DescKeyLoop = "loop" diff --git a/internal/config/embed/cmd/system.go b/internal/config/embed/cmd/system.go index cd686d4cc..d21412e1e 100644 --- a/internal/config/embed/cmd/system.go +++ b/internal/config/embed/cmd/system.go @@ -41,6 +41,7 @@ const ( UseSystemQaReminder = "qa-reminder" UseSystemResources = "resources" UseSystemResume = "resume" + UseSystemSessionEvent = "session-event" UseSystemSpecsNudge = "specs-nudge" UseSystemStats = "stats" ) @@ -81,6 +82,7 @@ const ( DescKeySystemQaReminder = "system.qareminder" DescKeySystemResources = "system.resources" DescKeySystemResume = "system.resume" + DescKeySystemSessionEvent = "system.sessionevent" DescKeySystemSpecsNudge = "system.specsnudge" DescKeySystemStats = "system.stats" ) diff --git a/internal/config/embed/flag/flag.go b/internal/config/embed/flag/flag.go index c8bceec2a..a2e86d800 100644 --- a/internal/config/embed/flag/flag.go +++ b/internal/config/embed/flag/flag.go @@ -7,10 +7,11 @@ package flag const ( - DescKeyAllowOutsideCwd = "allow-outside-cwd" - DescKeyChangesSince = "changes.since" - DescKeyCompactArchive = "compact.archive" - DescKeyContextDir = "context-dir" - DescKeyDoctorJson = "doctor.json" - DescKeyHookWrite = "hook.write" + DescKeyAllowOutsideCwd = "allow-outside-cwd" + DescKeyChangesSince = "changes.since" + DescKeyCompactArchive = "compact.archive" + DescKeyContextDir = "context-dir" + DescKeyDoctorJson = "doctor.json" + DescKeyInitializeCaller = "initialize.caller" + DescKeySetupWrite = "setup.write" ) diff --git a/internal/config/embed/flag/system.go b/internal/config/embed/flag/system.go index 708c92ef6..a1a86b360 100644 --- a/internal/config/embed/flag/system.go +++ b/internal/config/embed/flag/system.go @@ -7,25 +7,27 @@ package flag const ( - DescKeySystemBackupJson = "system.backup.json" - DescKeySystemBackupScope = "system.backup.scope" - DescKeySystemBootstrapJson = "system.bootstrap.json" - DescKeySystemBootstrapQuiet = "system.bootstrap.quiet" - DescKeySystemEventsAll = "system.events.all" - DescKeySystemEventsEvent = "system.events.event" - DescKeySystemEventsHook = "system.events.hook" - DescKeySystemEventsJson = "system.events.json" - DescKeySystemEventsLast = "system.events.last" - DescKeySystemEventsSession = "system.events.session" - DescKeySystemMarkJournalCheck = "system.markjournal.check" - DescKeySystemMessageJson = "system.message.json" - DescKeySystemPauseSessionId = "system.pause.session-id" - DescKeySystemPruneDays = "system.prune.days" - DescKeySystemPruneDryRun = "system.prune.dry-run" - DescKeySystemResourcesJson = "system.resources.json" - DescKeySystemResumeSessionId = "system.resume.session-id" - DescKeySystemStatsFollow = "system.stats.follow" - DescKeySystemStatsJson = "system.stats.json" - DescKeySystemStatsLast = "system.stats.last" - DescKeySystemStatsSession = "system.stats.session" + DescKeySystemBackupJson = "system.backup.json" + DescKeySystemBackupScope = "system.backup.scope" + DescKeySystemBootstrapJson = "system.bootstrap.json" + DescKeySystemBootstrapQuiet = "system.bootstrap.quiet" + DescKeySystemEventsAll = "system.events.all" + DescKeySystemEventsEvent = "system.events.event" + DescKeySystemEventsHook = "system.events.hook" + DescKeySystemEventsJson = "system.events.json" + DescKeySystemEventsLast = "system.events.last" + DescKeySystemEventsSession = "system.events.session" + DescKeySystemMarkJournalCheck = "system.markjournal.check" + DescKeySystemMessageJson = "system.message.json" + DescKeySystemPauseSessionId = "system.pause.session-id" + DescKeySystemPruneDays = "system.prune.days" + DescKeySystemPruneDryRun = "system.prune.dry-run" + DescKeySystemResourcesJson = "system.resources.json" + DescKeySystemResumeSessionId = "system.resume.session-id" + DescKeySystemSessionEventCaller = "system.sessionevent.caller" + DescKeySystemSessionEventType = "system.sessionevent.type" + DescKeySystemStatsFollow = "system.stats.follow" + DescKeySystemStatsJson = "system.stats.json" + DescKeySystemStatsLast = "system.stats.last" + DescKeySystemStatsSession = "system.stats.session" ) diff --git a/internal/config/embed/text/err_session.go b/internal/config/embed/text/err_session.go index b792b34ea..c8041af51 100644 --- a/internal/config/embed/text/err_session.go +++ b/internal/config/embed/text/err_session.go @@ -15,6 +15,7 @@ const ( DescKeyErrSessionNoSessionsFoundHint = "err.session.no-sessions-found-hint" DescKeyErrSessionIDRequired = "err.session.session-id-required" DescKeyErrSessionNotFound = "err.session.session-not-found" + DescKeyErrSessionEventInvalidType = "err.session.event-invalid-type" DescKeyErrSiteMarshalFeed = "err.site.marshal-feed" DescKeyErrSiteNoSiteConfig = "err.site.no-site-config" ) diff --git a/internal/config/embed/text/governance.go b/internal/config/embed/text/governance.go new file mode 100644 index 000000000..1f6bf4817 --- /dev/null +++ b/internal/config/embed/text/governance.go @@ -0,0 +1,16 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package text + +const ( + DescKeyGovSessionNotStarted = "mcp.gov-session-not-started" + DescKeyGovContextNotLoaded = "mcp.gov-context-not-loaded" + DescKeyGovDriftNotChecked = "mcp.gov-drift-not-checked" + DescKeyGovDriftNeverChecked = "mcp.gov-drift-never-checked" + DescKeyGovPersistNudge = "mcp.gov-persist-nudge" + DescKeyGovViolationCritical = "mcp.gov-violation-critical" +) diff --git a/internal/config/embed/text/hook.go b/internal/config/embed/text/hook.go index f23c4c028..342e3b5a2 100644 --- a/internal/config/embed/text/hook.go +++ b/internal/config/embed/text/hook.go @@ -8,14 +8,24 @@ package text const ( DescKeyHookAider = "hook.aider" + DescKeyHookAgents = "hook.agents" DescKeyHookClaude = "hook.claude" DescKeyHookCopilot = "hook.copilot" + DescKeyHookCopilotCLI = "hook.copilot-cli" DescKeyHookCursor = "hook.cursor" DescKeyHookSupportedTools = "hook.supported-tools" DescKeyHookWindsurf = "hook.windsurf" ) const ( + DescKeyWriteHookAgentsCreated = "write.hook-agents-created" + DescKeyWriteHookAgentsMerged = "write.hook-agents-merged" + DescKeyWriteHookAgentsSkipped = "write.hook-agents-skipped" + DescKeyWriteHookAgentsSummary = "write.hook-agents-summary" + DescKeyWriteHookCopilotCLICreated = "write.hook-copilot-cli-created" + DescKeyWriteHookCopilotCLISkipped = "write.hook-copilot-cli-skipped" + DescKeyWriteHookCopilotCLISkills = "write.hook-copilot-cli-skills" + DescKeyWriteHookCopilotCLISummary = "write.hook-copilot-cli-summary" DescKeyWriteHookCopilotCreated = "write.hook-copilot-created" DescKeyWriteHookCopilotForceHint = "write.hook-copilot-force-hint" DescKeyWriteHookCopilotMerged = "write.hook-copilot-merged" diff --git a/internal/config/embed/text/pause.go b/internal/config/embed/text/pause.go index 1f486dfa6..720b34ebe 100644 --- a/internal/config/embed/text/pause.go +++ b/internal/config/embed/text/pause.go @@ -10,5 +10,6 @@ const ( DescKeyWritePaused = "write.paused" DescKeyWritePausedMessage = "write.paused-message" DescKeyWriteResumed = "write.resumed" + DescKeyWriteSessionEvent = "write.session-event" DescKeyPauseConfirmed = "pause.confirmed" ) diff --git a/internal/config/embed/text/vscode.go b/internal/config/embed/text/vscode.go new file mode 100644 index 000000000..ee6e7369d --- /dev/null +++ b/internal/config/embed/text/vscode.go @@ -0,0 +1,24 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package text + +// VS Code artifact output formatting keys. +const ( + // DescKeyWriteVscodeCreated reports a VS Code config file was created. + DescKeyWriteVscodeCreated = "write.vscode-created" + // DescKeyWriteVscodeExistsSkipped reports a file was skipped (exists). + DescKeyWriteVscodeExistsSkipped = "write.vscode-exists-skipped" + // DescKeyWriteVscodeRecommendationExists reports the extension + // recommendation already exists. + DescKeyWriteVscodeRecommendationExists = "write.vscode-recommendation-exists" + // DescKeyWriteVscodeAddManually reports the file exists but lacks + // the ctx recommendation. + DescKeyWriteVscodeAddManually = "write.vscode-add-manually" + // DescKeyWriteVscodeWarnNonFatal reports a non-fatal error during + // artifact creation. + DescKeyWriteVscodeWarnNonFatal = "write.vscode-warn-non-fatal" +) diff --git a/internal/config/env/os.go b/internal/config/env/os.go new file mode 100644 index 000000000..d3fb9acd7 --- /dev/null +++ b/internal/config/env/os.go @@ -0,0 +1,13 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package env + +// OS identifier constants for runtime.GOOS comparison. +const ( + // OSWindows is the runtime.GOOS value for Windows. + OSWindows = "windows" +) diff --git a/internal/config/event/event.go b/internal/config/event/event.go index 44cbde091..0f0a5a1a6 100644 --- a/internal/config/event/event.go +++ b/internal/config/event/event.go @@ -6,6 +6,20 @@ package event +// Session lifecycle event types. +const ( + // TypeStart is the session start event. + TypeStart = "start" + // TypeEnd is the session end event. + TypeEnd = "end" +) + +// Event categories for log grouping. +const ( + // CategorySession groups session lifecycle events. + CategorySession = "session" +) + // Events display configuration. const ( // MessageMaxLen is the maximum character length for event messages diff --git a/internal/config/file/ext.go b/internal/config/file/ext.go index e70667d3f..d92f4c6d2 100644 --- a/internal/config/file/ext.go +++ b/internal/config/file/ext.go @@ -22,6 +22,10 @@ const ( ExtJSON = ".json" // ExtEnc is the encrypted file extension. ExtEnc = ".enc" + // ExtSh is the shell script file extension. + ExtSh = ".sh" + // ExtPs1 is the PowerShell script file extension. + ExtPs1 = ".ps1" // ExtTmp is the temporary file suffix for atomic writes. ExtTmp = ".tmp" // ExtExample is the suffix for example/template files that are safe diff --git a/internal/config/file/name.go b/internal/config/file/name.go index 9451c748a..cd11c8c36 100644 --- a/internal/config/file/name.go +++ b/internal/config/file/name.go @@ -12,4 +12,6 @@ const ( Readme = "README.md" // Index is the standard index filename for generated sites. Index = "index.md" + // Violations is the governance violations file in .context/state/. + Violations = "violations.json" ) diff --git a/internal/config/flag/flag.go b/internal/config/flag/flag.go index 0460aceaa..871ce6802 100644 --- a/internal/config/flag/flag.go +++ b/internal/config/flag/flag.go @@ -57,6 +57,7 @@ const ( BaseURL = "base-url" Blob = "blob" Build = "build" + Caller = "caller" Check = "check" Commands = "commands" Completion = "completion" diff --git a/internal/config/hook/hook.go b/internal/config/hook/hook.go index 42b052612..b09a6a8a7 100644 --- a/internal/config/hook/hook.go +++ b/internal/config/hook/hook.go @@ -47,18 +47,22 @@ const ( PostCommit = "post-commit" // QAReminder is the hook name for QA reminder gates. QAReminder = "qa-reminder" + // SessionEvent is the hook name for session lifecycle events. + SessionEvent = "session-event" // SpecsNudge is the hook name for specs directory nudges. SpecsNudge = "specs-nudge" // VersionDrift is the hook name for version drift nudges. VersionDrift = "version-drift" ) -// Supported integration tool names for ctx hook command. +// Supported integration tool names for ctx setup command. const ( + ToolAgents = "agents" ToolAider = "aider" ToolClaude = "claude" ToolClaudeCode = "claude-code" ToolCopilot = "copilot" + ToolCopilotCLI = "copilot-cli" ToolCursor = "cursor" ToolWindsurf = "windsurf" ) @@ -66,7 +70,41 @@ const ( // Copilot integration paths. const ( DirGitHub = ".github" + DirGitHubAgents = "agents" + DirGitHubHooks = "hooks" + DirGitHubHooksScripts = "scripts" + DirGitHubInstructions = "instructions" + DirGitHubSkills = "skills" + FileAgentsMd = "AGENTS.md" + FileAgentsCtxMd = "ctx.md" FileCopilotInstructions = "copilot-instructions.md" + FileCopilotCLIHooksJSON = "ctx-hooks.json" + FileInstructionsCtxMd = "context.instructions.md" + FileSKILLMd = "SKILL.md" +) + +// Copilot CLI home directory and MCP config. +const ( + // DirCopilotHome is the default Copilot CLI config directory name. + DirCopilotHome = ".copilot" + // EnvCopilotHome is the environment variable to override the config dir. + EnvCopilotHome = "COPILOT_HOME" + // FileMCPConfigJSON is the MCP server configuration file name. + FileMCPConfigJSON = "mcp-config.json" + // KeyMCPServers is the top-level JSON key in mcp-config.json. + KeyMCPServers = "mcpServers" + // MCPServerType is the server type value for local MCP servers. + MCPServerType = "local" + // KeyType is the JSON key for MCP server type. + KeyType = "type" + // KeyCommand is the JSON key for MCP server command. + KeyCommand = "command" + // KeyArgs is the JSON key for MCP server args. + KeyArgs = "args" + // KeyTools is the JSON key for MCP server tools filter. + KeyTools = "tools" + // ToolsWildcard is the wildcard value for MCP tools access. + ToolsWildcard = "*" ) // Prefixes @@ -97,3 +135,11 @@ const ( // EventPostToolUse is the hook event for post-tool-use hooks. EventPostToolUse = "PostToolUse" ) + +// Copilot CLI hook event names (GitHub Copilot CLI lifecycle stages). +const ( + CLIEventSessionStart = "sessionStart" + CLIEventSessionEnd = "sessionEnd" + CLIEventPreToolUse = "preToolUse" + CLIEventPostToolUse = "postToolUse" +) diff --git a/internal/config/hook/notify.go b/internal/config/hook/notify.go index 16c1d9970..3bb6b0493 100644 --- a/internal/config/hook/notify.go +++ b/internal/config/hook/notify.go @@ -14,4 +14,6 @@ const ( NotifyChannelNudge = "nudge" // NotifyChannelRelay is the notification channel for relay messages. NotifyChannelRelay = "relay" + // NotifyChannelSession is the notification channel for session events. + NotifyChannelSession = "session" ) diff --git a/internal/config/marker/marker.go b/internal/config/marker/marker.go index a87cff0f2..926e24678 100644 --- a/internal/config/marker/marker.go +++ b/internal/config/marker/marker.go @@ -68,6 +68,14 @@ const ( CopilotEnd = "" ) +// Agents block markers for AGENTS.md. +const ( + // AgentsStart marks the beginning of ctx-managed AGENTS.md content. + AgentsStart = "" + // AgentsEnd marks the end of ctx-managed AGENTS.md content. + AgentsEnd = "" +) + // Index markers for auto-generated table of contents sections. const ( // IndexStart marks the beginning of an auto-generated index. diff --git a/internal/config/mcp/governance/governance.go b/internal/config/mcp/governance/governance.go new file mode 100644 index 000000000..deb0e0a4e --- /dev/null +++ b/internal/config/mcp/governance/governance.go @@ -0,0 +1,23 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package governance + +import "time" + +// Governance thresholds — tuned to match Claude Code hook intervals. +const ( + // DriftCheckInterval is the minimum time between drift reminders. + DriftCheckInterval = 15 * time.Minute + + // PersistNudgeAfter is the tool call count after which a persist + // reminder fires if no context writes have occurred. + PersistNudgeAfter = 10 + + // PersistNudgeRepeat is how often the persist nudge repeats after + // the initial threshold. + PersistNudgeRepeat = 8 +) diff --git a/internal/config/mcp/server/server.go b/internal/config/mcp/server/server.go index 2bd5adae1..6404bc008 100644 --- a/internal/config/mcp/server/server.go +++ b/internal/config/mcp/server/server.go @@ -13,4 +13,13 @@ const ( JSONRPCVersion = "2.0" // Name is the server name reported during initialization. Name = "ctx" + // Command is the binary name used to launch the MCP server. + Command = "ctx" + // SubcommandServe is the serve subcommand under mcp. + SubcommandServe = "serve" ) + +// Args returns the CLI arguments to launch the ctx MCP server. +func Args() []string { + return []string{"mcp", SubcommandServe} +} diff --git a/internal/config/session/session.go b/internal/config/session/session.go index e300d48ff..6a1697e20 100644 --- a/internal/config/session/session.go +++ b/internal/config/session/session.go @@ -6,6 +6,14 @@ package session +// Event type constants for session lifecycle events. +const ( + // EventStart marks the beginning of a workspace session. + EventStart = "start" + // EventEnd marks the end of a workspace session. + EventEnd = "end" +) + // Session and template constants. const ( // IDUnknown is the fallback session ID when input lacks one. diff --git a/internal/config/session/tool.go b/internal/config/session/tool.go index 6d6ef8baf..261b34531 100644 --- a/internal/config/session/tool.go +++ b/internal/config/session/tool.go @@ -10,6 +10,10 @@ package session const ( // ToolClaudeCode is the tool identifier for Claude Code sessions. ToolClaudeCode = "claude-code" + // ToolCopilot is the tool identifier for VS Code Copilot Chat sessions. + ToolCopilot = "copilot" + // ToolCopilotCLI is the tool identifier for GitHub Copilot CLI sessions. + ToolCopilotCLI = "copilot-cli" // ToolMarkdown is the tool identifier for Markdown session files. ToolMarkdown = "markdown" ) diff --git a/internal/config/vscode/doc.go b/internal/config/vscode/doc.go new file mode 100644 index 000000000..1549bd74f --- /dev/null +++ b/internal/config/vscode/doc.go @@ -0,0 +1,25 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package vscode defines constants for VS Code workspace configuration +// artifacts generated by ctx init. +// +// Provides directory paths, file names, JSON keys, and task configuration +// values used when creating .vscode/ workspace files. +// +// Constant groups: +// - Dir, file names: workspace directory and configuration file paths +// - ExtensionID: VS Code Marketplace identifier for the ctx extension +// - KeyRecommendations: JSON key for extensions.json +// - KeyVersion, KeyTasks, ...: JSON keys for tasks.json structure +// - KeyServers, KeyArgs: JSON keys for mcp.json structure +// - TasksVersion, TypeShell, ...: task configuration values +// +// The Tasks variable holds the label/command pairs for ctx tasks that +// are written into .vscode/tasks.json during initialization. +// +// Constants are referenced by domain packages via config/vscode.*. +package vscode diff --git a/internal/config/vscode/vscode.go b/internal/config/vscode/vscode.go new file mode 100644 index 000000000..42a4c1def --- /dev/null +++ b/internal/config/vscode/vscode.go @@ -0,0 +1,68 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package vscode defines constants for VS Code workspace configuration +// artifacts generated by ctx init. +package vscode + +// Dir is the VS Code workspace configuration directory. +const Dir = ".vscode" + +// Configuration file names within .vscode/. +const ( + FileExtensionsJSON = "extensions.json" + FileTasksJSON = "tasks.json" + FileMCPJSON = "mcp.json" +) + +// ExtensionID is the VS Code Marketplace identifier for the ctx extension. +const ExtensionID = "activememory.ctx-context" + +// JSON keys for extensions.json. +const ( + KeyRecommendations = "recommendations" +) + +// JSON keys for tasks.json. +const ( + KeyVersion = "version" + KeyTasks = "tasks" + KeyLabel = "label" + KeyType = "type" + KeyCommand = "command" + KeyGroup = "group" + KeyPresentation = "presentation" + KeyReveal = "reveal" + KeyPanel = "panel" + KeyProblemMatcher = "problemMatcher" +) + +// Task configuration values. +const ( + TasksVersion = "2.0.0" + TypeShell = "shell" + GroupNone = "none" + RevealAlways = "always" + PanelShared = "shared" +) + +// JSON keys for mcp.json. +const ( + KeyServers = "servers" + KeyArgs = "args" +) + +// Task definitions: label and command pairs for ctx tasks. +var Tasks = []struct { + Label string + Command string +}{ + {"ctx: status", "ctx status"}, + {"ctx: drift", "ctx drift"}, + {"ctx: agent", "ctx agent --budget 4000"}, + {"ctx: journal", "ctx recall export --all && ctx journal site --build"}, + {"ctx: journal-serve", "ctx journal site --serve"}, +} diff --git a/internal/err/session/session.go b/internal/err/session/session.go index d7d2ae039..79fd7c1dc 100644 --- a/internal/err/session/session.go +++ b/internal/err/session/session.go @@ -86,6 +86,23 @@ func AllWithID() error { ) } +// EventInvalidType returns a validation error when --type receives an +// unrecognized session event type. +// +// Parameters: +// - start: the accepted start value (e.g. "start"). +// - end: the accepted end value (e.g. "end"). +// - got: the value that was actually provided. +// +// Returns: +// - error: "--type must be '' or '', got \"\"" +func EventInvalidType(start, end, got string) error { + return fmt.Errorf( + desc.Text(text.DescKeyErrSessionEventInvalidType), + start, end, got, + ) +} + // AllWithPattern returns a validation error when --all is used with a pattern. // // Returns: diff --git a/internal/journal/parser/copilot.go b/internal/journal/parser/copilot.go new file mode 100644 index 000000000..f95aed4b4 --- /dev/null +++ b/internal/journal/parser/copilot.go @@ -0,0 +1,244 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package parser + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + + cfgCopilot "github.com/ActiveMemory/ctx/internal/config/copilot" + "github.com/ActiveMemory/ctx/internal/config/env" + "github.com/ActiveMemory/ctx/internal/config/file" + "github.com/ActiveMemory/ctx/internal/config/session" + "github.com/ActiveMemory/ctx/internal/entity" + errParser "github.com/ActiveMemory/ctx/internal/err/parser" +) + +// Ensure Copilot implements Session. +var _ Session = (*Copilot)(nil) + +// Copilot parses VS Code Copilot Chat JSONL session files. +// +// Copilot Chat stores sessions as JSONL files in VS Code's workspaceStorage +// directory. Each file contains one session. The first line is a full session +// snapshot (kind=0), subsequent lines are incremental patches (kind=1, kind=2). +type Copilot struct{} + +// NewCopilot creates a new Copilot Chat session parser. +// +// Returns: +// - *Copilot: a new parser instance +func NewCopilot() *Copilot { + return &Copilot{} +} + +// Tool returns the tool identifier for this parser. +// +// Returns: +// - string: the Copilot tool identifier +func (p *Copilot) Tool() string { + return session.ToolCopilot +} + +// Matches returns true if the file appears to be a Copilot Chat session file. +// +// Checks if the file has a .jsonl extension and lives in a chatSessions +// directory, and the first line contains a Copilot session snapshot. +// +// Parameters: +// - path: file path to check +// +// Returns: +// - bool: true if the file is a Copilot Chat session +func (p *Copilot) Matches(path string) bool { + if !strings.HasSuffix(path, file.ExtJSONL) { + return false + } + + // Copilot sessions live in chatSessions/ directories + if !strings.Contains(filepath.Dir(path), cfgCopilot.DirChatSessions) { + return false + } + + file, openErr := os.Open(filepath.Clean(path)) + if openErr != nil { + return false + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + buf := make([]byte, 0, cfgCopilot.ScanBufInit) + scanner.Buffer(buf, cfgCopilot.ScanBufMatchMax) + + if !scanner.Scan() { + return false + } + + var line copilotRawLine + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + return false + } + + // kind=0 is the full session snapshot + if line.Kind != copilotKindSnapshot { + return false + } + + var session copilotRawSession + if err := json.Unmarshal(line.V, &session); err != nil { + return false + } + + return session.SessionID != "" && session.Version > 0 +} + +// ParseFile reads a Copilot Chat JSONL file and returns the session. +// +// Reconstructs the session by reading the initial snapshot (kind=0) and +// applying incremental patches (kind=1 for scalar, kind=2 for array/object). +// +// Parameters: +// - path: path to the JSONL session file +// +// Returns: +// - []*entity.Session: the parsed sessions (at most one for Copilot) +// - error: any error encountered during parsing +func (p *Copilot) ParseFile(path string) ([]*entity.Session, error) { + file, openErr := os.Open(filepath.Clean(path)) + if openErr != nil { + return nil, errParser.OpenFile(openErr) + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + buf := make([]byte, 0, cfgCopilot.ScanBufInit) + scanner.Buffer(buf, cfgCopilot.ScanBufMax) + + var session *copilotRawSession + + for scanner.Scan() { + lineBytes := scanner.Bytes() + if len(lineBytes) == 0 { + continue + } + + var line copilotRawLine + if err := json.Unmarshal(lineBytes, &line); err != nil { + continue + } + + switch line.Kind { + case copilotKindSnapshot: + // Full session snapshot + var s copilotRawSession + if err := json.Unmarshal(line.V, &s); err != nil { + return nil, errParser.Unmarshal(err) + } + session = &s + + case copilotKindScalarPatch: + // Scalar property patch — apply to session + if session != nil { + p.applyScalarPatch(session, line.K, line.V) + } + + case copilotKindObjectPatch: + // Array/object patch — apply to session + if session != nil { + p.applyPatch(session, line.K, line.V) + } + } + } + + if scanErr := scanner.Err(); scanErr != nil { + return nil, errParser.ScanFile(scanErr) + } + + if session == nil { + return nil, nil + } + + // Resolve workspace folder from workspace.json next to chatSessions/ + cwd := p.resolveWorkspaceCWD(path) + + result := p.buildSession(session, path, cwd) + if result == nil { + return nil, nil + } + + return []*entity.Session{result}, nil +} + +// ParseLine is not meaningful for Copilot sessions since they use patches. +// Returns nil for all lines. +// +// Parameters: +// - line: the raw line bytes (unused) +// +// Returns: +// - *entity.Message: always nil +// - string: always empty +// - error: always nil +func (p *Copilot) ParseLine(_ []byte) (*entity.Message, string, error) { + return nil, "", nil +} + +// CopilotSessionDirs returns the directories where Copilot Chat sessions +// are stored. Checks both VS Code stable and Insiders paths. +// +// Returns: +// - []string: paths to chatSessions directories found on the system +func CopilotSessionDirs() []string { + var dirs []string + + appData := os.Getenv(cfgCopilot.EnvAppData) + if runtime.GOOS != env.OSWindows { + // On macOS/Linux, VS Code stores data in different locations + home, err := os.UserHomeDir() + if err != nil { + return nil + } + switch runtime.GOOS { + case cfgCopilot.OSDarwin: + appData = filepath.Join(home, cfgCopilot.DirLibrary, cfgCopilot.DirAppSupport) + default: // Linux + appData = filepath.Join(home, cfgCopilot.DirDotConfig) + } + } + + if appData == "" { + return nil + } + + // Check both Code stable and Code Insiders + variants := []string{cfgCopilot.AppCode, cfgCopilot.AppCodeInsiders} + for _, variant := range variants { + wsDir := filepath.Join(appData, variant, cfgCopilot.DirUser, cfgCopilot.DirWorkspace) + if info, err := os.Stat(wsDir); err == nil && info.IsDir() { + // Scan each workspace for chatSessions/ subdirectory + entries, err := os.ReadDir(wsDir) + if err != nil { + continue + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + chatDir := filepath.Join(wsDir, entry.Name(), cfgCopilot.DirChatSessions) + if info, err := os.Stat(chatDir); err == nil && info.IsDir() { + dirs = append(dirs, chatDir) + } + } + } + } + + return dirs +} diff --git a/internal/journal/parser/copilot_build.go b/internal/journal/parser/copilot_build.go new file mode 100644 index 000000000..030069353 --- /dev/null +++ b/internal/journal/parser/copilot_build.go @@ -0,0 +1,219 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package parser + +import ( + "encoding/json" + "path/filepath" + "strings" + "time" + + "github.com/ActiveMemory/ctx/internal/config/claude" + cfgCopilot "github.com/ActiveMemory/ctx/internal/config/copilot" + "github.com/ActiveMemory/ctx/internal/config/session" + "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/entity" +) + +// buildSession converts a reconstructed copilotRawSession into a Session. +// +// Parameters: +// - raw: the reconstructed raw session data +// - sourcePath: path to the JSONL source file +// - cwd: resolved workspace directory +// +// Returns: +// - *entity.Session: the built session, or nil if the session has no requests +func (p *Copilot) buildSession( + raw *copilotRawSession, sourcePath string, cwd string, +) *entity.Session { + if len(raw.Requests) == 0 { + return nil + } + + sess := &entity.Session{ + ID: raw.SessionID, + Tool: session.ToolCopilot, + SourceFile: sourcePath, + CWD: cwd, + Project: filepath.Base(cwd), + StartTime: time.UnixMilli(raw.CreationDate), + } + + if raw.CustomTitle != "" { + sess.Slug = raw.CustomTitle + } + + for _, req := range raw.Requests { + // User message + userMsg := entity.Message{ + ID: req.RequestID, + Timestamp: time.UnixMilli(req.Timestamp), + Role: claude.RoleUser, + Text: req.Message.Text, + } + + if req.Result != nil { + userMsg.TokensIn = req.Result.Metadata.PromptTokens + } + + sess.Messages = append(sess.Messages, userMsg) + sess.TurnCount++ + + if sess.FirstUserMsg == "" && userMsg.Text != "" { + preview := userMsg.Text + if len(preview) > session.PreviewMaxLen { + preview = preview[:session.PreviewMaxLen] + token.Ellipsis + } + sess.FirstUserMsg = preview + } + + // Assistant response + assistantMsg := p.buildAssistantMessage(req) + if assistantMsg != nil { + sess.Messages = append(sess.Messages, *assistantMsg) + + if sess.Model == "" && req.ModelID != "" { + sess.Model = req.ModelID + } + } + + // Accumulate tokens + if req.Result != nil { + sess.TotalTokensIn += req.Result.Metadata.PromptTokens + sess.TotalTokensOut += req.Result.Metadata.OutputTokens + } + } + + sess.TotalTokens = sess.TotalTokensIn + sess.TotalTokensOut + + // Set end time from last request + if last := raw.Requests[len(raw.Requests)-1]; last.Result != nil { + sess.EndTime = time.UnixMilli(last.Timestamp).Add( + time.Duration(last.Result.Timings.TotalElapsed) * time.Millisecond, + ) + } else { + sess.EndTime = time.UnixMilli( + raw.Requests[len(raw.Requests)-1].Timestamp, + ) + } + sess.Duration = sess.EndTime.Sub(sess.StartTime) + + return sess +} + +// buildAssistantMessage extracts the assistant response from a request. +// +// Parameters: +// - req: the raw request containing response items +// +// Returns: +// - *entity.Message: the assistant message, or nil if the request has no response +func (p *Copilot) buildAssistantMessage( + req copilotRawRequest, +) *entity.Message { + if len(req.Response) == 0 { + return nil + } + + msg := &entity.Message{ + ID: req.RequestID + cfgCopilot.ResponseSuffix, + Timestamp: time.UnixMilli(req.Timestamp), + Role: claude.RoleAssistant, + } + + if req.Result != nil { + msg.TokensOut = req.Result.Metadata.OutputTokens + } + + for _, item := range req.Response { + switch item.Kind { + case cfgCopilot.RespKindThinking: + var text string + if err := json.Unmarshal(item.Value, &text); err == nil { + if msg.Thinking != "" { + msg.Thinking += token.NewlineLF + } + msg.Thinking += text + } + + case cfgCopilot.RespKindToolInvoke: + tu := p.parseToolInvocation(item) + if tu != nil { + msg.ToolUses = append(msg.ToolUses, *tu) + } + + case "": + // Plain markdown text (no kind field) + var text string + if err := json.Unmarshal(item.Value, &text); err == nil { + text = strings.TrimSpace(text) + if text != "" { + if msg.Text != "" { + msg.Text += token.NewlineLF + } + msg.Text += text + } + } + + // Skip: codeblockUri, inlineReference, progressTaskSerialized, + // textEditGroup, undoStop, mcpServersStarting + } + } + + // Check for tool errors + for _, tr := range msg.ToolResults { + if tr.IsError { + return msg // HasErrors is set at session level + } + } + + return msg +} + +// parseToolInvocation extracts a ToolUse from a toolInvocationSerialized item. +// +// Parameters: +// - item: the raw response item containing tool invocation data +// +// Returns: +// - *entity.ToolUse: the parsed tool use, or nil if the item has no tool ID +func (p *Copilot) parseToolInvocation(item copilotRawRespItem) *entity.ToolUse { + toolID := item.ToolID + if toolID == "" { + return nil + } + + // Extract the tool name from toolId (e.g., "copilot_readFile" -> "readFile") + name := toolID + if idx := strings.LastIndex(toolID, cfgCopilot.ToolIDSeparator); idx >= 0 { + name = toolID[idx+1:] + } + + // Use invocationMessage as the input description + inputStr := "" + if item.InvocationMessage != nil { + // InvocationMessage can be a string or object with value field + var simple string + if err := json.Unmarshal(item.InvocationMessage, &simple); err == nil { + inputStr = simple + } else { + var obj struct { + Value string `json:"value"` + } + if err := json.Unmarshal(item.InvocationMessage, &obj); err == nil { + inputStr = obj.Value + } + } + } + + return &entity.ToolUse{ + ID: item.ToolCallID, + Name: name, + Input: inputStr, + } +} diff --git a/internal/journal/parser/copilot_cli.go b/internal/journal/parser/copilot_cli.go new file mode 100644 index 000000000..658769a1f --- /dev/null +++ b/internal/journal/parser/copilot_cli.go @@ -0,0 +1,296 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package parser + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/ActiveMemory/ctx/internal/config/claude" + cfgCopilot "github.com/ActiveMemory/ctx/internal/config/copilot" + "github.com/ActiveMemory/ctx/internal/config/env" + "github.com/ActiveMemory/ctx/internal/config/file" + cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" + "github.com/ActiveMemory/ctx/internal/config/session" + "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/entity" + errParser "github.com/ActiveMemory/ctx/internal/err/parser" + "github.com/ActiveMemory/ctx/internal/log/warn" +) + +// CopilotCLI parses GitHub Copilot CLI session files. +// +// Copilot CLI stores sessions as JSONL files in ~/.copilot/sessions/ +// (or $COPILOT_HOME/sessions/). Each file contains one session with +// JSONL-formatted messages similar to Claude Code's format. +type CopilotCLI struct{} + +// NewCopilotCLI creates a new Copilot CLI session parser. +// +// Returns: +// - *CopilotCLI: a new parser instance +func NewCopilotCLI() *CopilotCLI { + return &CopilotCLI{} +} + +// Tool returns the tool identifier for this parser. +// +// Returns: +// - string: the Copilot CLI tool identifier +func (p *CopilotCLI) Tool() string { + return session.ToolCopilotCLI +} + +// Matches returns true if the file appears to be a Copilot CLI session file. +// +// Checks if the file has a .jsonl extension and lives in a Copilot CLI +// session directory (under ~/.copilot/ or $COPILOT_HOME), and the first +// line contains a valid Copilot CLI message with a role or type field. +// +// Parameters: +// - path: file path to check +// +// Returns: +// - bool: true if the file is a Copilot CLI session +func (p *CopilotCLI) Matches(path string) bool { + if !strings.HasSuffix(path, file.ExtJSONL) { + return false + } + + // Must be under a .copilot directory (not chatSessions, which is VS Code) + dir := filepath.Dir(path) + if strings.Contains(dir, cfgCopilot.DirChatSessions) { + return false + } + + // Check if this is under a .copilot directory + if !strings.Contains(path, cfgHook.DirCopilotHome) { + return false + } + + // Verify the first line looks like a Copilot CLI session + f, err := os.Open(filepath.Clean(path)) + if err != nil { + return false + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, cfgCopilot.ScanBufInit) + scanner.Buffer(buf, cfgCopilot.ScanBufMatchMax) + + if !scanner.Scan() { + return false + } + + var msg copilotCLIRawMessage + if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { + return false + } + + // A Copilot CLI session line must have a role and type + return msg.Role != "" || msg.Type != "" +} + +// ParseFile reads a Copilot CLI JSONL session file and returns sessions. +// +// Each file represents one session. Messages are parsed line by line from +// the JSONL file and assembled into a single Session entity. +// +// Parameters: +// - path: path to the JSONL session file +// +// Returns: +// - []*entity.Session: the parsed sessions (at most one for Copilot CLI) +// - error: any error encountered during parsing +func (p *CopilotCLI) ParseFile(path string) ([]*entity.Session, error) { + f, err := os.Open(filepath.Clean(path)) + if err != nil { + return nil, errParser.OpenFile(err) + } + defer func() { + if closeErr := f.Close(); closeErr != nil { + warn.Warn("copilot-cli: close %s: %v", path, closeErr) + } + }() + + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, cfgCopilot.ScanBufInit) + scanner.Buffer(buf, cfgCopilot.ScanBufMax) + + var messages []copilotCLIRawMessage + + for scanner.Scan() { + lineBytes := scanner.Bytes() + if len(lineBytes) == 0 { + continue + } + + var msg copilotCLIRawMessage + if err := json.Unmarshal(lineBytes, &msg); err != nil { + continue + } + messages = append(messages, msg) + } + + if scanErr := scanner.Err(); scanErr != nil { + return nil, errParser.ScanFile(scanErr) + } + + if len(messages) == 0 { + return nil, nil + } + + result := p.buildSession(messages, path) + if result == nil { + return nil, nil + } + + return []*entity.Session{result}, nil +} + +// ParseLine is not meaningful for Copilot CLI sessions since each file +// represents a complete session. Returns nil for all lines. +// +// Parameters: +// - line: the raw line bytes (unused) +// +// Returns: +// - *entity.Message: always nil +// - string: always empty +// - error: always nil +func (p *CopilotCLI) ParseLine(_ []byte) (*entity.Message, string, error) { + return nil, "", nil +} + +// buildSession converts raw Copilot CLI messages into a Session entity. +// +// Iterates through all messages to extract metadata (CWD, model, timestamps) +// and assemble a complete session with turn counts and preview text. +// +// Parameters: +// - msgs: raw messages parsed from the JSONL file +// - sourcePath: path to the JSONL source file +// +// Returns: +// - *entity.Session: the built session, or nil if msgs is empty +func (p *CopilotCLI) buildSession( + msgs []copilotCLIRawMessage, sourcePath string, +) *entity.Session { + if len(msgs) == 0 { + return nil + } + + sess := &entity.Session{ + ID: filepath.Base(strings.TrimSuffix(sourcePath, file.ExtJSONL)), + Tool: session.ToolCopilotCLI, + SourceFile: sourcePath, + } + + for _, msg := range msgs { + // Extract CWD from first message that has it + if sess.CWD == "" && msg.CWD != "" { + sess.CWD = msg.CWD + sess.Project = filepath.Base(msg.CWD) + } + + // Extract session ID if present + if msg.SessionID != "" { + sess.ID = msg.SessionID + } + + // Extract model + if sess.Model == "" && msg.Model != "" { + sess.Model = msg.Model + } + + // Set timestamps + if !msg.Timestamp.IsZero() { + if sess.StartTime.IsZero() { + sess.StartTime = msg.Timestamp + } + sess.EndTime = msg.Timestamp + } + + // Build entity message + entityMsg := entity.Message{ + ID: msg.ID, + Timestamp: msg.Timestamp, + Role: msg.Role, + Text: msg.Text, + } + + if msg.Role == claude.RoleUser { + sess.TurnCount++ + if sess.FirstUserMsg == "" && msg.Text != "" { + preview := msg.Text + if len(preview) > session.PreviewMaxLen { + preview = preview[:session.PreviewMaxLen] + token.Ellipsis + } + sess.FirstUserMsg = preview + } + } + + sess.Messages = append(sess.Messages, entityMsg) + } + + if !sess.StartTime.IsZero() && !sess.EndTime.IsZero() { + sess.Duration = sess.EndTime.Sub(sess.StartTime) + } + + return sess +} + +// CopilotCLISessionDirs returns the directories where Copilot CLI sessions +// may be stored. Checks ~/.copilot/sessions and ~/.copilot/history, and +// on Windows also checks LOCALAPPDATA. Respects $COPILOT_HOME env var. +// +// Returns: +// - []string: paths to session directories found on the system +func CopilotCLISessionDirs() []string { + var dirs []string + + copilotHome := os.Getenv(cfgHook.EnvCopilotHome) + if copilotHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + copilotHome = filepath.Join(home, cfgHook.DirCopilotHome) + } + + // Check common session subdirectories + candidates := []string{cfgCopilot.DirSessions, cfgCopilot.DirHistory} + for _, sub := range candidates { + dir := filepath.Join(copilotHome, sub) + if info, err := os.Stat(dir); err == nil && info.IsDir() { + dirs = append(dirs, dir) + } + } + + // On Windows, also check under LOCALAPPDATA + if runtime.GOOS == env.OSWindows { + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData != "" { + for _, sub := range candidates { + dir := filepath.Join(localAppData, cfgCopilot.CLIAppName, sub) + if info, err := os.Stat(dir); err == nil && info.IsDir() { + dirs = append(dirs, dir) + } + } + } + } + + return dirs +} + +// Ensure CopilotCLI implements Session. +var _ Session = (*CopilotCLI)(nil) diff --git a/internal/journal/parser/copilot_cli_raw.go b/internal/journal/parser/copilot_cli_raw.go new file mode 100644 index 000000000..27d9e4e6e --- /dev/null +++ b/internal/journal/parser/copilot_cli_raw.go @@ -0,0 +1,22 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package parser + +import "time" + +// copilotCLIRawMessage represents a single JSONL line from a Copilot CLI +// session file. The exact format may evolve as Copilot CLI matures. +type copilotCLIRawMessage struct { + ID string `json:"id,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Role string `json:"role,omitempty"` + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + Model string `json:"model,omitempty"` + CWD string `json:"cwd,omitempty"` + Timestamp time.Time `json:"timestamp"` +} diff --git a/internal/journal/parser/copilot_const.go b/internal/journal/parser/copilot_const.go new file mode 100644 index 000000000..64a10b22d --- /dev/null +++ b/internal/journal/parser/copilot_const.go @@ -0,0 +1,18 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package parser + +// Copilot JSONL line Kind values. Parser-internal: these are wire +// format discriminators, not configurable. +const ( + // copilotKindSnapshot is a full session snapshot (kind=0). + copilotKindSnapshot = 0 + // copilotKindScalarPatch is a scalar field replacement (kind=1). + copilotKindScalarPatch = 1 + // copilotKindObjectPatch is an array/object replacement (kind=2). + copilotKindObjectPatch = 2 +) diff --git a/internal/journal/parser/copilot_patch.go b/internal/journal/parser/copilot_patch.go new file mode 100644 index 000000000..436bec89b --- /dev/null +++ b/internal/journal/parser/copilot_patch.go @@ -0,0 +1,101 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package parser + +import ( + "encoding/json" + "strconv" + + cfgCopilot "github.com/ActiveMemory/ctx/internal/config/copilot" +) + +// applyScalarPatch applies a kind=1 scalar patch to the session. +// These update individual properties like result, modelState, followups. +// +// Parameters: +// - session: the session to patch +// - keys: raw JSON key path from the JSONL line +// - value: raw JSON value to apply +func (p *Copilot) applyScalarPatch( + session *copilotRawSession, keys []json.RawMessage, value json.RawMessage, +) { + path := p.parseKeyPath(keys) + if len(path) < 2 { + return + } + + // Handle requests..result patches — these contain token counts + if path[0] == cfgCopilot.KeyRequests && len(path) == 3 && path[2] == cfgCopilot.KeyResult { + idx, err := strconv.Atoi(path[1]) + if err != nil || idx < 0 || idx >= len(session.Requests) { + return + } + var result copilotRawResult + if err := json.Unmarshal(value, &result); err == nil { + session.Requests[idx].Result = &result + } + } +} + +// applyPatch applies a kind=2 array/object patch to the session. +// +// Parameters: +// - session: the session to patch +// - keys: raw JSON key path from the JSONL line +// - value: raw JSON value to apply +func (p *Copilot) applyPatch( + session *copilotRawSession, keys []json.RawMessage, value json.RawMessage, +) { + path := p.parseKeyPath(keys) + if len(path) == 0 { + return + } + + switch { + case len(path) == 1 && path[0] == cfgCopilot.KeyRequests: + // New request(s) appended + var requests []copilotRawRequest + if err := json.Unmarshal(value, &requests); err == nil { + session.Requests = append(session.Requests, requests...) + } + + case len(path) == 3 && path[0] == cfgCopilot.KeyRequests && path[2] == cfgCopilot.KeyResponse: + // Response update for a specific request + idx, err := strconv.Atoi(path[1]) + if err != nil || idx < 0 || idx >= len(session.Requests) { + return + } + var items []copilotRawRespItem + if err := json.Unmarshal(value, &items); err == nil { + session.Requests[idx].Response = items + } + } +} + +// parseKeyPath converts the K array from JSONL into string path segments. +// +// Parameters: +// - keys: raw JSON key elements to decode +// +// Returns: +// - []string: decoded path segments as strings +func (p *Copilot) parseKeyPath(keys []json.RawMessage) []string { + path := make([]string, 0, len(keys)) + for _, k := range keys { + var s string + if err := json.Unmarshal(k, &s); err == nil { + path = append(path, s) + continue + } + var n int + if err := json.Unmarshal(k, &n); err == nil { + path = append(path, strconv.Itoa(n)) + continue + } + } + return path +} diff --git a/internal/journal/parser/copilot_path.go b/internal/journal/parser/copilot_path.go new file mode 100644 index 000000000..a2517a10d --- /dev/null +++ b/internal/journal/parser/copilot_path.go @@ -0,0 +1,83 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package parser + +import ( + "encoding/json" + "net/url" + "os" + "path/filepath" + "runtime" + + cfgCopilot "github.com/ActiveMemory/ctx/internal/config/copilot" + "github.com/ActiveMemory/ctx/internal/config/env" +) + +// resolveWorkspaceCWD reads workspace.json from the workspaceStorage +// directory to determine the workspace folder path. +// +// Parameters: +// - sessionPath: path to the JSONL session file +// +// Returns: +// - string: the resolved workspace folder path, or empty string on failure +func (p *Copilot) resolveWorkspaceCWD(sessionPath string) string { + // sessionPath is like: .../workspaceStorage//chatSessions/.jsonl + // workspace.json is at: .../workspaceStorage//workspace.json + chatDir := filepath.Dir(sessionPath) // chatSessions/ + storageDir := filepath.Dir(chatDir) // / + wsFile := filepath.Join(storageDir, cfgCopilot.FileWorkspace) + + data, err := os.ReadFile(filepath.Clean(wsFile)) + if err != nil { + return "" + } + + var ws copilotRawWorkspace + if err := json.Unmarshal(data, &ws); err != nil { + return "" + } + + return fileURIToPath(ws.Folder) +} + +// fileURIToPath converts a file:// URI to a local file path. +// +// Parameters: +// - uri: the file URI to convert (e.g., "file:///home/user/project") +// +// Returns: +// - string: the local file path, or empty string if the URI is invalid +func fileURIToPath(uri string) string { + if uri == "" { + return "" + } + + parsed, err := url.Parse(uri) + if err != nil { + return "" + } + + if parsed.Scheme != cfgCopilot.SchemeFile { + return "" + } + + path := parsed.Path + + // URL-decode the path (e.g., %3A -> :) + decoded, err := url.PathUnescape(path) + if err != nil { + decoded = path + } + + // On Windows, file URIs have /G:/... — strip the leading slash + if runtime.GOOS == env.OSWindows && len(decoded) > 2 && decoded[0] == '/' { + decoded = decoded[1:] + } + + return filepath.FromSlash(decoded) +} diff --git a/internal/journal/parser/copilot_raw.go b/internal/journal/parser/copilot_raw.go new file mode 100644 index 000000000..94ba97d23 --- /dev/null +++ b/internal/journal/parser/copilot_raw.go @@ -0,0 +1,102 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package parser + +import "encoding/json" + +// Copilot Chat JSONL raw types. +// +// Copilot Chat stores sessions as JSONL files in VS Code's workspaceStorage. +// Each file contains one session. The first line (kind=0) is the full session +// snapshot, subsequent lines are incremental patches (kind=1 for scalar +// replacements, kind=2 for array/object replacements). + +// copilotRawLine represents a single JSONL line from a Copilot Chat session. +// +// Kind discriminates the line type: +// - 0: Full session snapshot (V contains copilotRawSession) +// - 1: Scalar property patch (K is the JSON path, V is the new value) +// - 2: Array/object patch (K is the JSON path, V is the new value) +type copilotRawLine struct { + Kind int `json:"kind"` + K []json.RawMessage `json:"k,omitempty"` + V json.RawMessage `json:"v"` +} + +// copilotRawSession is the full session snapshot from a copilotKindSnapshot line. +type copilotRawSession struct { + // Version is the schema version of the session format. + Version int `json:"version"` + // CreationDate is the session creation time as Unix milliseconds. + CreationDate int64 `json:"creationDate"` + // CustomTitle is the user-set session title, if any. + CustomTitle string `json:"customTitle,omitempty"` + // SessionID is the unique identifier for this session. + SessionID string `json:"sessionId"` + // ResponderUsername is the Copilot responder identity. + ResponderUsername string `json:"responderUsername,omitempty"` + // InitialLocation is the VS Code view that started the session. + InitialLocation string `json:"initialLocation,omitempty"` + // Requests holds the ordered request-response pairs. + Requests []copilotRawRequest `json:"requests"` +} + +// copilotRawRequest represents a single request-response pair. +type copilotRawRequest struct { + RequestID string `json:"requestId"` + Timestamp int64 `json:"timestamp"` + ModelID string `json:"modelId,omitempty"` + Message copilotRawMessage `json:"message"` + Response []copilotRawRespItem `json:"response,omitempty"` + Result *copilotRawResult `json:"result,omitempty"` + ContentReferences []json.RawMessage `json:"contentReferences,omitempty"` +} + +// copilotRawMessage is the user's input message. +type copilotRawMessage struct { + Text string `json:"text"` +} + +// copilotRawRespItem is a single item in the response array. +// +// The Kind field discriminates the type: +// - "thinking": Extended thinking (Value contains the text) +// - "toolInvocationSerialized": Tool call +// - "textEditGroup": File edit +// - "": Plain markdown text (Value field only) +type copilotRawRespItem struct { + Kind string `json:"kind,omitempty"` + Value json.RawMessage `json:"value,omitempty"` + ID string `json:"id,omitempty"` + InvocationMessage json.RawMessage `json:"invocationMessage,omitempty"` + ToolID string `json:"toolId,omitempty"` + ToolCallID string `json:"toolCallId,omitempty"` + IsComplete json.RawMessage `json:"isComplete,omitempty"` +} + +// copilotRawResult contains completion metadata for a request. +type copilotRawResult struct { + Timings copilotRawTimings `json:"timings"` + Metadata copilotRawMetadata `json:"metadata,omitempty"` +} + +// copilotRawTimings contains timing information. +type copilotRawTimings struct { + FirstProgress int64 `json:"firstProgress"` + TotalElapsed int64 `json:"totalElapsed"` +} + +// copilotRawMetadata contains token usage and other metadata. +type copilotRawMetadata struct { + PromptTokens int `json:"promptTokens,omitempty"` + OutputTokens int `json:"outputTokens,omitempty"` +} + +// copilotRawWorkspace is the workspace.json file in workspaceStorage. +type copilotRawWorkspace struct { + Folder string `json:"folder,omitempty"` +} diff --git a/internal/journal/parser/parser.go b/internal/journal/parser/parser.go index 6399e95a8..6d5f81625 100644 --- a/internal/journal/parser/parser.go +++ b/internal/journal/parser/parser.go @@ -21,6 +21,8 @@ import ( // Add new parsers here when supporting additional tools. var registeredParsers = []Session{ NewClaudeCode(), + NewCopilot(), + NewCopilotCLI(), NewMarkdownSession(), } diff --git a/internal/journal/parser/query.go b/internal/journal/parser/query.go index 0934fd2fd..862cd2ba4 100644 --- a/internal/journal/parser/query.go +++ b/internal/journal/parser/query.go @@ -57,6 +57,16 @@ func findSessionsWithFilter( scanOnce(filepath.Join(home, dir.Claude, dir.Projects)) } + // Check Copilot Chat session directories (Code + Code Insiders) + for _, dir := range CopilotSessionDirs() { + scanOnce(dir) + } + + // Check Copilot CLI session directories (~/.copilot/ or $COPILOT_HOME) + for _, dir := range CopilotCLISessionDirs() { + scanOnce(dir) + } + // Check .context/sessions/ in the current working directory if cwd, cwdErr := os.Getwd(); cwdErr == nil { scanOnce(filepath.Join(cwd, dir.Context, dir.Sessions)) diff --git a/internal/mcp/handler/tool.go b/internal/mcp/handler/tool.go index 16224c985..a8a8b5af6 100644 --- a/internal/mcp/handler/tool.go +++ b/internal/mcp/handler/tool.go @@ -521,6 +521,7 @@ func (h *Handler) SessionEvent( switch eventType { case event.Start: h.Session = session.NewState(h.ContextDir) + h.Session.RecordSessionStart() if caller != "" { return fmt.Sprintf( desc.Text( diff --git a/internal/mcp/server/route/tool/dispatch.go b/internal/mcp/server/route/tool/dispatch.go index f9495d7ec..7a8c724e9 100644 --- a/internal/mcp/server/route/tool/dispatch.go +++ b/internal/mcp/server/route/tool/dispatch.go @@ -31,14 +31,16 @@ func DispatchList(req proto.Request) *proto.Response { } // DispatchCall unmarshals tool call params and dispatches to the -// appropriate handler function. +// appropriate handler function. After dispatch, per-tool governance +// state is recorded and advisory warnings are appended to the +// response text. // // Parameters: // - h: handler for domain logic and session tracking // - req: the MCP request containing tool name and arguments // // Returns: -// - *proto.Response: tool result or error +// - *proto.Response: tool result or error (with governance warnings) func DispatchCall( h *handler.Handler, req proto.Request, ) *proto.Response { @@ -51,32 +53,41 @@ func DispatchCall( } h.Session.RecordToolCall() + h.Session.IncrementCallsSinceWrite() + + var resp *proto.Response switch params.Name { case tool.Status: - return out.Call(req.ID, h.Status) + resp = out.Call(req.ID, h.Status) + h.Session.RecordContextLoaded() case tool.Add: - return add(h, req.ID, params.Arguments) + resp = add(h, req.ID, params.Arguments) + h.Session.RecordContextWrite() case tool.Complete: - return complete(h, req.ID, params.Arguments) + resp = complete(h, req.ID, params.Arguments) + h.Session.RecordContextWrite() case tool.Drift: - return out.Call(req.ID, h.Drift) + resp = out.Call(req.ID, h.Drift) + h.Session.RecordDriftCheck() case tool.JournalSource: - return journalSource(req.ID, params.Arguments, h.Recall) + resp = journalSource(req.ID, params.Arguments, h.Recall) case tool.WatchUpdate: - return watchUpdate(h, req.ID, params.Arguments) + resp = watchUpdate(h, req.ID, params.Arguments) + h.Session.RecordContextWrite() case tool.Compact: - return compact(req.ID, params.Arguments, h.Compact) + resp = compact(req.ID, params.Arguments, h.Compact) + h.Session.RecordContextWrite() case tool.Next: - return out.Call(req.ID, h.Next) + resp = out.Call(req.ID, h.Next) case tool.CheckTaskCompletion: - return checkTaskCompletion( + resp = checkTaskCompletion( req.ID, params.Arguments, h.CheckTaskCompletion, ) case tool.SessionEvent: - return sessionEvent(req.ID, params.Arguments, h.SessionEvent) + resp = sessionEvent(req.ID, params.Arguments, h.SessionEvent) case tool.Remind: - return out.Call(req.ID, h.Remind) + resp = out.Call(req.ID, h.Remind) default: return out.ErrResponse( req.ID, proto.ErrCodeNotFound, @@ -86,4 +97,8 @@ func DispatchCall( ), ) } + + appendGovernance(resp, params.Name, h) + + return resp } diff --git a/internal/mcp/server/route/tool/governance.go b/internal/mcp/server/route/tool/governance.go new file mode 100644 index 000000000..328757a38 --- /dev/null +++ b/internal/mcp/server/route/tool/governance.go @@ -0,0 +1,35 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package tool + +import ( + "github.com/ActiveMemory/ctx/internal/mcp/handler" + "github.com/ActiveMemory/ctx/internal/mcp/proto" +) + +// appendGovernance appends governance advisory warnings to a tool +// response. It modifies the response in-place by appending warning +// text to the first content item. +// +// Parameters: +// - resp: the MCP response to augment +// - toolName: name of the tool that was called +// - h: handler providing session governance state +func appendGovernance( + resp *proto.Response, toolName string, h *handler.Handler, +) { + warning := h.Session.CheckGovernance(toolName) + if warning == "" { + return + } + result, ok := resp.Result.(proto.CallToolResult) + if !ok || len(result.Content) == 0 { + return + } + result.Content[0].Text += warning + resp.Result = result +} diff --git a/internal/mcp/server/server_test.go b/internal/mcp/server/server_test.go index 8324f5e61..6a572030a 100644 --- a/internal/mcp/server/server_test.go +++ b/internal/mcp/server/server_test.go @@ -795,6 +795,16 @@ func TestToolCheckTaskCompletion(t *testing.T) { func TestToolCheckTaskCompletionNoMatch(t *testing.T) { srv, _ := newTestServer(t) + + // Prime session state to avoid governance warnings in response. + request(t, srv, "tools/call", proto.CallToolParams{ + Name: "ctx_session_event", + Arguments: map[string]interface{}{"type": "start"}, + }) + request(t, srv, "tools/call", proto.CallToolParams{ + Name: "ctx_status", + }) + resp := request(t, srv, "tools/call", proto.CallToolParams{ Name: "ctx_check_task_completion", Arguments: map[string]interface{}{ diff --git a/internal/mcp/session/governance.go b/internal/mcp/session/governance.go new file mode 100644 index 000000000..877ac0162 --- /dev/null +++ b/internal/mcp/session/governance.go @@ -0,0 +1,148 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package session + +import ( + "fmt" + "strings" + "time" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/text" + "github.com/ActiveMemory/ctx/internal/config/mcp/governance" + "github.com/ActiveMemory/ctx/internal/config/mcp/tool" + "github.com/ActiveMemory/ctx/internal/config/token" +) + +// RecordSessionStart marks the session as explicitly started and +// resets the session start timestamp. +// +// Called by the session_event tool when the agent reports a "start" +// event. Sets sessionStarted to true and captures the current wall +// time so governance checks can measure elapsed time. +func (ss *State) RecordSessionStart() { + ss.sessionStarted = true + ss.sessionStartedAt = time.Now() +} + +// RecordContextLoaded marks context as loaded for this session. +// +// Called after the agent successfully loads context files (TASKS.md, +// DECISIONS.md, etc.). Suppresses the "context not loaded" governance +// warning that would otherwise appear on every tool response. +func (ss *State) RecordContextLoaded() { + ss.contextLoaded = true +} + +// RecordDriftCheck records that a drift check was performed. +// +// Called after the agent runs ctx_drift. Updates the last-drift-check +// timestamp so CheckGovernance can determine whether a follow-up drift +// check is overdue based on governance.DriftCheckInterval. +func (ss *State) RecordDriftCheck() { + ss.lastDriftCheck = time.Now() +} + +// RecordContextWrite records that a .context/ write occurred. +// +// Called after successful ctx_add, ctx_complete, ctx_watch_update, or +// ctx_compact invocations. Captures the current wall time and resets +// the calls-since-write counter to zero, which suppresses persist +// nudges until governance.PersistNudgeAfter more tool calls elapse. +func (ss *State) RecordContextWrite() { + ss.lastContextWrite = time.Now() + ss.callsSinceWrite = 0 +} + +// IncrementCallsSinceWrite bumps the counter used for persist nudges. +// +// Called by the MCP server after every tool dispatch regardless of tool +// type. When the counter reaches governance.PersistNudgeAfter, +// CheckGovernance begins emitting persist nudge warnings. +func (ss *State) IncrementCallsSinceWrite() { + ss.callsSinceWrite++ +} + +// CheckGovernance returns governance warnings that should be +// appended to the current tool response. Returns an empty string +// when no action is warranted. +// +// Parameters: +// - toolName: the MCP tool that was just called, used to +// suppress redundant warnings (e.g. drift warning is not +// appended to a ctx_drift response) +// +// Returns: +// - string: newline-separated warnings preceded by a separator, +// or empty string when no warnings apply +func (ss *State) CheckGovernance(toolName string) string { + var warnings []string + + // 1. Session not started + if !ss.sessionStarted && toolName != tool.SessionEvent { + warnings = append(warnings, + desc.Text(text.DescKeyGovSessionNotStarted)) + } + + // 2. Context not loaded + if !ss.contextLoaded && toolName != tool.Status && + toolName != tool.SessionEvent { + warnings = append(warnings, + desc.Text(text.DescKeyGovContextNotLoaded)) + } + + // 3. Drift not checked recently + if ss.sessionStarted && toolName != tool.Drift && + toolName != tool.SessionEvent { + if !ss.lastDriftCheck.IsZero() { + if time.Since(ss.lastDriftCheck) > governance.DriftCheckInterval { + warnings = append(warnings, fmt.Sprintf( + desc.Text(text.DescKeyGovDriftNotChecked), + int(time.Since(ss.lastDriftCheck).Minutes()))) + } + } else if ss.ToolCalls > 5 { + // Never checked drift and already 5+ calls in + warnings = append(warnings, + desc.Text(text.DescKeyGovDriftNeverChecked)) + } + } + + // 4. Persist nudge — no context writes in a while + if ss.sessionStarted && ss.callsSinceWrite >= governance.PersistNudgeAfter && + toolName != tool.Add && toolName != tool.WatchUpdate && + toolName != tool.Complete && toolName != tool.Compact && + toolName != tool.SessionEvent { + // Fire at threshold, then every governance.PersistNudgeRepeat + // calls after. + if ss.callsSinceWrite == governance.PersistNudgeAfter || + (ss.callsSinceWrite-governance.PersistNudgeAfter)%governance.PersistNudgeRepeat == 0 { + warnings = append(warnings, fmt.Sprintf( + desc.Text(text.DescKeyGovPersistNudge), + ss.callsSinceWrite)) + } + } + + // 5. Violations from extension detection ring + if violations := readAndClearViolations(ss.contextDir); len(violations) > 0 { + for _, v := range violations { + detail := v.Detail + if len(detail) > 120 { + detail = detail[:120] + token.Ellipsis + } + warnings = append(warnings, fmt.Sprintf( + desc.Text(text.DescKeyGovViolationCritical), + v.Kind, detail, v.Timestamp)) + } + } + + if len(warnings) == 0 { + return "" + } + + nl := token.NewlineLF + return nl + nl + token.Separator + nl + strings.Join(warnings, nl) +} diff --git a/internal/mcp/session/governance_test.go b/internal/mcp/session/governance_test.go new file mode 100644 index 000000000..3d77b0277 --- /dev/null +++ b/internal/mcp/session/governance_test.go @@ -0,0 +1,341 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package session + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ActiveMemory/ctx/internal/config/dir" + "github.com/ActiveMemory/ctx/internal/config/file" + cfgGov "github.com/ActiveMemory/ctx/internal/config/mcp/governance" +) + +func newTestState() *State { + return NewState("/tmp/test/.context") +} + +func TestCheckGovernance_SessionNotStarted(t *testing.T) { + ss := newTestState() + got := ss.CheckGovernance("ctx_status") + if !strings.Contains(got, "Session not started") { + t.Errorf("expected session-not-started warning, got: %q", got) + } +} + +func TestCheckGovernance_SessionNotStarted_SuppressedForSessionEvent(t *testing.T) { + ss := newTestState() + got := ss.CheckGovernance("ctx_session_event") + if strings.Contains(got, "Session not started") { + t.Errorf("session-not-started should be suppressed for ctx_session_event, got: %q", got) + } +} + +func TestCheckGovernance_ContextNotLoaded(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + got := ss.CheckGovernance("ctx_add") + if !strings.Contains(got, "Context not loaded") { + t.Errorf("expected context-not-loaded warning, got: %q", got) + } +} + +func TestCheckGovernance_ContextNotLoaded_SuppressedForStatus(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + got := ss.CheckGovernance("ctx_status") + if strings.Contains(got, "Context not loaded") { + t.Errorf("context-not-loaded should be suppressed for ctx_status, got: %q", got) + } +} + +func TestCheckGovernance_DriftNeverChecked(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.ToolCalls = 6 // above the 5-call threshold + + got := ss.CheckGovernance("ctx_add") + if !strings.Contains(got, "Drift has not been checked") { + t.Errorf("expected drift-never-checked warning, got: %q", got) + } +} + +func TestCheckGovernance_DriftNeverChecked_BelowThreshold(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.ToolCalls = 3 // below 5 + + got := ss.CheckGovernance("ctx_add") + if strings.Contains(got, "Drift") { + t.Errorf("drift warning should not fire below 5 calls, got: %q", got) + } +} + +func TestCheckGovernance_DriftStale(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.lastDriftCheck = time.Now().Add(-20 * time.Minute) // 20 min ago + + got := ss.CheckGovernance("ctx_add") + if !strings.Contains(got, "Drift not checked in") { + t.Errorf("expected stale-drift warning, got: %q", got) + } +} + +func TestCheckGovernance_DriftStale_SuppressedForDrift(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.lastDriftCheck = time.Now().Add(-20 * time.Minute) + + got := ss.CheckGovernance("ctx_drift") + if strings.Contains(got, "Drift") { + t.Errorf("drift warning should be suppressed for ctx_drift, got: %q", got) + } +} + +func TestCheckGovernance_PersistNudge_AtThreshold(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.callsSinceWrite = cfgGov.PersistNudgeAfter // exactly at threshold + + got := ss.CheckGovernance("ctx_status") + if !strings.Contains(got, "tool calls since last context write") { + t.Errorf("expected persist-nudge at threshold, got: %q", got) + } +} + +func TestCheckGovernance_PersistNudge_BelowThreshold(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.callsSinceWrite = cfgGov.PersistNudgeAfter - 1 + + got := ss.CheckGovernance("ctx_status") + if strings.Contains(got, "tool calls since last context write") { + t.Errorf("persist-nudge should not fire below threshold, got: %q", got) + } +} + +func TestCheckGovernance_PersistNudge_Repeat(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.callsSinceWrite = cfgGov.PersistNudgeAfter + cfgGov.PersistNudgeRepeat + + got := ss.CheckGovernance("ctx_status") + if !strings.Contains(got, "tool calls since last context write") { + t.Errorf("expected persist-nudge at repeat interval, got: %q", got) + } +} + +func TestCheckGovernance_PersistNudge_SuppressedForWriteTools(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.callsSinceWrite = cfgGov.PersistNudgeAfter + + for _, tool := range []string{"ctx_add", "ctx_complete", "ctx_watch_update", "ctx_compact"} { + got := ss.CheckGovernance(tool) + if strings.Contains(got, "tool calls since last context write") { + t.Errorf("persist-nudge should be suppressed for %s, got: %q", tool, got) + } + } +} + +func TestCheckGovernance_NoWarnings(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.RecordContextWrite() + + got := ss.CheckGovernance("ctx_status") + if got != "" { + t.Errorf("expected no warnings, got: %q", got) + } +} + +func TestRecordSessionStart(t *testing.T) { + ss := newTestState() + if ss.sessionStarted { + t.Fatal("sessionStarted should be false initially") + } + ss.RecordSessionStart() + if !ss.sessionStarted { + t.Fatal("sessionStarted should be true after RecordSessionStart") + } +} + +func TestRecordContextWrite_ResetsCounter(t *testing.T) { + ss := newTestState() + ss.callsSinceWrite = 15 + ss.RecordContextWrite() + if ss.callsSinceWrite != 0 { + t.Errorf("callsSinceWrite should be 0 after RecordContextWrite, got %d", ss.callsSinceWrite) + } +} + +func TestIncrementCallsSinceWrite(t *testing.T) { + ss := newTestState() + ss.IncrementCallsSinceWrite() + ss.IncrementCallsSinceWrite() + ss.IncrementCallsSinceWrite() + if ss.callsSinceWrite != 3 { + t.Errorf("expected 3, got %d", ss.callsSinceWrite) + } +} + +func TestCheckGovernance_WarningFormat(t *testing.T) { + ss := newTestState() + got := ss.CheckGovernance("ctx_add") + if got != "" && !strings.HasPrefix(got, "\n\n---\n") { + t.Errorf("warnings should start with separator, got: %q", got) + } +} + +func newTestStateWithDir(t *testing.T) *State { + t.Helper() + contextDir := filepath.Join(t.TempDir(), ".context") + if err := os.MkdirAll(filepath.Join(contextDir, dir.State), 0o755); err != nil { + t.Fatal(err) + } + return NewState(contextDir) +} + +func writeViolations(t *testing.T, contextDir string, entries []violation) { + t.Helper() + data, err := json.Marshal(violationsData{Entries: entries}) + if err != nil { + t.Fatal(err) + } + p := filepath.Join(contextDir, dir.State, file.Violations) + if err := os.WriteFile(p, data, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestCheckGovernance_ViolationsDetected(t *testing.T) { + ss := newTestStateWithDir(t) + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.RecordContextWrite() + + writeViolations(t, ss.contextDir, []violation{ + {Kind: "dangerous_command", Detail: "sudo rm -rf /tmp", Timestamp: "2026-03-17T10:00:00Z"}, + }) + + got := ss.CheckGovernance("ctx_status") + if !strings.Contains(got, "CRITICAL") { + t.Errorf("expected CRITICAL warning, got: %q", got) + } + if !strings.Contains(got, "dangerous_command") { + t.Errorf("expected violation kind in warning, got: %q", got) + } +} + +func TestCheckGovernance_ViolationsFileRemovedAfterRead(t *testing.T) { + ss := newTestStateWithDir(t) + writeViolations(t, ss.contextDir, []violation{ + {Kind: "sensitive_file_read", Detail: ".env", Timestamp: "2026-03-17T10:00:00Z"}, + }) + + p := filepath.Join(ss.contextDir, dir.State, file.Violations) + if _, err := os.Stat(p); err != nil { + t.Fatal("violations file should exist before read") + } + + ss.CheckGovernance("ctx_status") + + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Error("violations file should be removed after read") + } +} + +func TestCheckGovernance_NoViolationsFile(t *testing.T) { + ss := newTestStateWithDir(t) + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.RecordContextWrite() + + got := ss.CheckGovernance("ctx_status") + if strings.Contains(got, "CRITICAL") { + t.Errorf("no violations should mean no CRITICAL warning, got: %q", got) + } +} + +func TestCheckGovernance_ViolationDetailTruncated(t *testing.T) { + ss := newTestStateWithDir(t) + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.RecordContextWrite() + + longDetail := strings.Repeat("x", 200) + writeViolations(t, ss.contextDir, []violation{ + {Kind: "hack_script", Detail: longDetail, Timestamp: "2026-03-17T10:00:00Z"}, + }) + + got := ss.CheckGovernance("ctx_status") + if strings.Contains(got, longDetail) { + t.Error("full 200-char detail should be truncated") + } + if !strings.Contains(got, "...") { + t.Errorf("truncated detail should contain ellipsis, got: %q", got) + } +} + +func TestCheckGovernance_MultipleViolations(t *testing.T) { + ss := newTestStateWithDir(t) + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.RecordContextWrite() + + writeViolations(t, ss.contextDir, []violation{ + {Kind: "dangerous_command", Detail: "git push --force", Timestamp: "2026-03-17T10:00:00Z"}, + {Kind: "sensitive_file_read", Detail: ".env.local", Timestamp: "2026-03-17T10:00:01Z"}, + }) + + got := ss.CheckGovernance("ctx_status") + count := strings.Count(got, "CRITICAL") + if count != 2 { + t.Errorf("expected 2 CRITICAL warnings, got %d in: %q", count, got) + } +} + +func TestReadAndClearViolations_EmptyContextDir(t *testing.T) { + ss := &State{contextDir: ""} + violations := readAndClearViolations(ss.contextDir) + if violations != nil { + t.Errorf("expected nil for empty contextDir, got: %v", violations) + } +} + +func TestReadAndClearViolations_CorruptFile(t *testing.T) { + ss := newTestStateWithDir(t) + p := filepath.Join(ss.contextDir, dir.State, file.Violations) + if err := os.WriteFile(p, []byte("not json"), 0o644); err != nil { + t.Fatal(err) + } + violations := readAndClearViolations(ss.contextDir) + if violations != nil { + t.Errorf("expected nil for corrupt file, got: %v", violations) + } +} diff --git a/internal/mcp/session/testmain_test.go b/internal/mcp/session/testmain_test.go new file mode 100644 index 000000000..d3c0fcdba --- /dev/null +++ b/internal/mcp/session/testmain_test.go @@ -0,0 +1,19 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package session + +import ( + "os" + "testing" + + "github.com/ActiveMemory/ctx/internal/assets/read/lookup" +) + +func TestMain(m *testing.M) { + lookup.Init() + os.Exit(m.Run()) +} diff --git a/internal/mcp/session/types.go b/internal/mcp/session/types.go index cd88d3b10..24214fd74 100644 --- a/internal/mcp/session/types.go +++ b/internal/mcp/session/types.go @@ -29,6 +29,14 @@ type State struct { AddsPerformed map[string]int sessionStartedAt time.Time PendingFlush []PendingUpdate + + // Governance tracking — used by CheckGovernance() to emit + // contextual warnings in MCP tool responses. + sessionStarted bool + contextLoaded bool + lastDriftCheck time.Time + lastContextWrite time.Time + callsSinceWrite int } // PendingUpdate represents a context update awaiting human confirmation. @@ -44,3 +52,24 @@ type PendingUpdate struct { Attrs map[string]string QueuedAt time.Time } + +// violation represents a single governance violation recorded by the +// VS Code extension's detection ring. +// +// Fields: +// - Kind: violation category identifier +// - Detail: human-readable description of what was violated +// - Timestamp: ISO-8601 timestamp of when the violation occurred +type violation struct { + Kind string `json:"kind"` + Detail string `json:"detail"` + Timestamp string `json:"timestamp"` +} + +// violationsData is the JSON structure of the violations file. +// +// Fields: +// - Entries: list of recorded violations +type violationsData struct { + Entries []violation `json:"entries"` +} diff --git a/internal/mcp/session/violations.go b/internal/mcp/session/violations.go new file mode 100644 index 000000000..004e252ac --- /dev/null +++ b/internal/mcp/session/violations.go @@ -0,0 +1,46 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package session + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/ActiveMemory/ctx/internal/config/dir" + "github.com/ActiveMemory/ctx/internal/config/file" + ctxio "github.com/ActiveMemory/ctx/internal/io" +) + +// readAndClearViolations reads violations from +// .context/state/violations.json and removes the file to prevent +// repeated escalation. +// +// Parameters: +// - contextDir: path to the project context directory +// +// Returns: +// - []violation: parsed violations, or nil if contextDir is empty, +// no file exists, or on read/parse error +func readAndClearViolations(contextDir string) []violation { + if contextDir == "" { + return nil + } + stateDir := filepath.Join(contextDir, dir.State) + data, readErr := ctxio.SafeReadFile(stateDir, file.Violations) + if readErr != nil { + return nil + } + // Remove the file immediately to prevent duplicate alerts. + _ = os.Remove(filepath.Join(stateDir, file.Violations)) + + var vd violationsData + if unmarshalErr := json.Unmarshal(data, &vd); unmarshalErr != nil { + return nil + } + return vd.Entries +} diff --git a/internal/validate/path.go b/internal/validate/path.go index 8f8d1c1f8..250035ca7 100644 --- a/internal/validate/path.go +++ b/internal/validate/path.go @@ -9,8 +9,10 @@ package validate import ( "os" "path/filepath" + "runtime" "strings" + "github.com/ActiveMemory/ctx/internal/config/env" errCtx "github.com/ActiveMemory/ctx/internal/err/context" errFs "github.com/ActiveMemory/ctx/internal/err/fs" ) @@ -49,10 +51,26 @@ func Boundary(dir string) error { resolvedDir = filepath.Clean(absDir) } + // On Windows, path comparisons must be case-insensitive because + // filepath.EvalSymlinks resolves to actual disk casing while + // os.Getwd preserves the casing from the caller (e.g. VS Code + // passes a lowercase drive letter via fsPath). + equal := func(a, b string) bool { return a == b } + hasPrefix := strings.HasPrefix + if runtime.GOOS == env.OSWindows { + equal = strings.EqualFold + hasPrefix = func(s, prefix string) bool { + return len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) + } + } + // Ensure the resolved dir is equal to or nested under the project root. // Append os.PathSeparator to avoid "/foo/bar" matching "/foo/b". + // On Windows, use case-insensitive comparison since NTFS paths are + // case-insensitive but EvalSymlinks normalizes casing only for the + // existing cwd, not the non-existent target — creating a mismatch. root := resolvedCwd + string(os.PathSeparator) - if resolvedDir != resolvedCwd && !strings.HasPrefix(resolvedDir, root) { + if !equal(resolvedDir, resolvedCwd) && !hasPrefix(resolvedDir, root) { return errCtx.OutsideRoot(dir, resolvedCwd) } diff --git a/internal/validate/path_test.go b/internal/validate/path_test.go index 1c9ac7a99..a5a341230 100644 --- a/internal/validate/path_test.go +++ b/internal/validate/path_test.go @@ -9,10 +9,14 @@ package validate import ( "os" "path/filepath" + "runtime" + "strings" "testing" + + "github.com/ActiveMemory/ctx/internal/config/env" ) -func TestValidateBoundary(t *testing.T) { +func TestBoundary(t *testing.T) { cwd, err := os.Getwd() if err != nil { t.Fatal(err) @@ -36,13 +40,48 @@ func TestValidateBoundary(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := Boundary(tt.dir) if (err != nil) != tt.wantErr { - t.Errorf("ValidateBoundary(%q) error = %v, wantErr %v", + t.Errorf("Boundary(%q) error = %v, wantErr %v", tt.dir, err, tt.wantErr) } }) } } +func TestBoundaryCaseInsensitive(t *testing.T) { + if runtime.GOOS != env.OSWindows { + t.Skip("case-insensitive path test only applies to Windows") + } + + // On Windows, EvalSymlinks normalizes casing to the filesystem's + // canonical form. When .context/ doesn't exist yet the fallback + // preserves the original cwd casing. The prefix check must be + // case-insensitive to avoid false "outside cwd" errors. + tmp := t.TempDir() + + // Change cwd to a case-mangled version of the temp dir. + // TempDir returns canonical casing; flip it. + mangled := strings.ToUpper(tmp) + if mangled == tmp { + mangled = strings.ToLower(tmp) + } + + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(orig) }() + + if err := os.Chdir(mangled); err != nil { + t.Skipf("cannot chdir to case-mangled path %q: %v", mangled, err) + } + + // .context doesn't exist — this is the exact scenario that caused the + // false positive on Windows. + if err := Boundary(".context"); err != nil { + t.Errorf("Boundary(.context) with case-mangled cwd: %v", err) + } +} + func TestCheckSymlinks(t *testing.T) { t.Run("regular directory passes", func(t *testing.T) { dir := t.TempDir() @@ -97,3 +136,45 @@ func TestCheckSymlinks(t *testing.T) { } }) } + +func TestBoundary_WindowsCaseInsensitive(t *testing.T) { + if runtime.GOOS != env.OSWindows { + t.Skip("Windows-only test") + } + + // Simulate the VS Code plugin scenario: CWD has a lowercase drive letter + // but EvalSymlinks resolves to the actual (uppercase) casing. + // When .context doesn't exist yet (first init), the fallback path + // preserves the lowercase letter, causing a case mismatch. + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Swap the drive letter case to simulate VS Code's fsPath + if len(cwd) >= 2 && cwd[1] == ':' { + var swapped string + if cwd[0] >= 'A' && cwd[0] <= 'Z' { + swapped = strings.ToLower(cwd[:1]) + cwd[1:] + } else { + swapped = strings.ToUpper(cwd[:1]) + cwd[1:] + } + + origDir, _ := os.Getwd() + if chErr := os.Chdir(swapped); chErr != nil { + t.Fatalf("cannot chdir to %s: %v", swapped, chErr) + } + defer func() { _ = os.Chdir(origDir) }() + + // Non-existent subdir simulates .context before init + nonExistent := filepath.Join(swapped, ".nonexistent-ctx-dir") + if err := Boundary(nonExistent); err != nil { + t.Errorf("Boundary(%q) with swapped drive case should pass, got: %v", nonExistent, err) + } + + // Also test the default relative path that ctx init uses + if err := Boundary(".context"); err != nil { + t.Errorf("Boundary(.context) with swapped drive case should pass, got: %v", err) + } + } +} diff --git a/internal/write/session/doc.go b/internal/write/session/doc.go index d3f8b44c6..ae3680da9 100644 --- a/internal/write/session/doc.go +++ b/internal/write/session/doc.go @@ -5,8 +5,9 @@ // SPDX-License-Identifier: Apache-2.0 // Package session provides terminal output for session lifecycle -// commands (ctx pause, ctx resume, ctx wrap-up). +// commands (ctx pause, ctx resume, ctx wrap-up, ctx system session-event). // +// [Event] confirms a session start or end event was recorded. // [Paused] confirms hooks were suspended for the session. // [Resumed] confirms hooks were re-enabled. [WrappedUp] confirms // the end-of-session persistence ceremony completed. diff --git a/internal/write/session/session.go b/internal/write/session/session.go index 9ceacccfd..6ca65427e 100644 --- a/internal/write/session/session.go +++ b/internal/write/session/session.go @@ -15,6 +15,19 @@ import ( "github.com/ActiveMemory/ctx/internal/config/embed/text" ) +// Event prints a session lifecycle event confirmation. +// +// Parameters: +// - cmd: Cobra command for output. Nil is a no-op. +// - eventType: the event type (e.g. "start" or "end"). +// - caller: the calling editor identifier (e.g. "vscode"). +func Event(cmd *cobra.Command, eventType, caller string) { + if cmd == nil { + return + } + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteSessionEvent), eventType, caller)) +} + // Paused prints confirmation that hooks were paused. // // Parameters: diff --git a/internal/write/hook/doc.go b/internal/write/setup/doc.go similarity index 76% rename from internal/write/hook/doc.go rename to internal/write/setup/doc.go index 72c2f0541..e0ff35e16 100644 --- a/internal/write/hook/doc.go +++ b/internal/write/setup/doc.go @@ -4,10 +4,10 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -// Package hook provides terminal output for the hook generation -// command (ctx hook) and hook lifecycle output. +// Package setup provides terminal output for the setup generation +// command (ctx setup) and hook lifecycle output. // -// Functions cover hook deployment output ([InfoCopilotCreated], +// Functions cover setup deployment output ([InfoCopilotCreated], // [InfoCopilotMerged], [InfoCopilotSkipped], [InfoCopilotSummary]), // hook runtime output ([Nudge], [NudgeBlock], [BlockResponse], // [Context]), and general-purpose hook helpers ([Content], @@ -16,4 +16,4 @@ // Nudge vs NudgeBlock: [Nudge] emits a single-line relay, // [NudgeBlock] emits a multi-line boxed message. Both are // consumed by the agent as VERBATIM relay directives. -package hook +package setup diff --git a/internal/write/hook/hook.go b/internal/write/setup/hook.go similarity index 67% rename from internal/write/hook/hook.go rename to internal/write/setup/hook.go index c93a8a63f..3fc1999ab 100644 --- a/internal/write/hook/hook.go +++ b/internal/write/setup/hook.go @@ -4,7 +4,7 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -package hook +package setup import ( "fmt" @@ -137,6 +137,71 @@ func InfoCopilotSummary(cmd *cobra.Command) { cmd.Println(desc.Text(text.DescKeyWriteHookCopilotSummary)) } +// InfoCopilotCLICreated reports that copilot-cli hook files were created. +// +// Parameters: +// - cmd: Cobra command for output +// - targetFile: Path to the created file +func InfoCopilotCLICreated(cmd *cobra.Command, targetFile string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteHookCopilotCLICreated), targetFile)) +} + +// InfoAgentsCreated reports that AGENTS.md was created. +// +// Parameters: +// - cmd: Cobra command for output +// - targetFile: Path to the created file +func InfoAgentsCreated(cmd *cobra.Command, targetFile string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteHookAgentsCreated), targetFile)) +} + +// InfoAgentsMerged reports that ctx content was merged into AGENTS.md. +// +// Parameters: +// - cmd: Cobra command for output +// - targetFile: Path to the merged file +func InfoAgentsMerged(cmd *cobra.Command, targetFile string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteHookAgentsMerged), targetFile)) +} + +// InfoAgentsSkipped reports that AGENTS.md was skipped because +// ctx markers already exist. +// +// Parameters: +// - cmd: Cobra command for output +// - targetFile: Path to the existing file +func InfoAgentsSkipped(cmd *cobra.Command, targetFile string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteHookAgentsSkipped), targetFile)) +} + +// InfoAgentsSummary prints the post-write summary for AGENTS.md. +// +// Parameters: +// - cmd: Cobra command for output +func InfoAgentsSummary(cmd *cobra.Command) { + cmd.Println() + cmd.Println(desc.Text(text.DescKeyWriteHookAgentsSummary)) +} + +// InfoCopilotCLISkipped reports that copilot-cli hooks were skipped +// because they already exist. +// +// Parameters: +// - cmd: Cobra command for output +// - targetFile: Path to the existing file +func InfoCopilotCLISkipped(cmd *cobra.Command, targetFile string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteHookCopilotCLISkipped), targetFile)) +} + +// InfoCopilotCLISummary prints the post-write summary for copilot-cli. +// +// Parameters: +// - cmd: Cobra command for output +func InfoCopilotCLISummary(cmd *cobra.Command) { + cmd.Println() + cmd.Println(desc.Text(text.DescKeyWriteHookCopilotCLISummary)) +} + // InfoUnknownTool prints the unknown tool message. // // Parameters: diff --git a/internal/write/vscode/doc.go b/internal/write/vscode/doc.go new file mode 100644 index 000000000..7dfc552a9 --- /dev/null +++ b/internal/write/vscode/doc.go @@ -0,0 +1,9 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package vscode provides terminal output for VS Code artifact generation +// during ctx init. +package vscode diff --git a/internal/write/vscode/info.go b/internal/write/vscode/info.go new file mode 100644 index 000000000..7a28d7964 --- /dev/null +++ b/internal/write/vscode/info.go @@ -0,0 +1,64 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package vscode provides terminal output for VS Code artifact generation. +package vscode + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/text" +) + +// InfoCreated reports a VS Code configuration file was created. +// +// Parameters: +// - cmd: Cobra command for output +// - target: path to the created file +func InfoCreated(cmd *cobra.Command, target string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteVscodeCreated), target)) +} + +// InfoExistsSkipped reports a VS Code file was skipped because it exists. +// +// Parameters: +// - cmd: Cobra command for output +// - target: path to the existing file +func InfoExistsSkipped(cmd *cobra.Command, target string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteVscodeExistsSkipped), target)) +} + +// InfoRecommendationExists reports the extension recommendation already exists. +// +// Parameters: +// - cmd: Cobra command for output +// - target: path to the extensions.json file +func InfoRecommendationExists(cmd *cobra.Command, target string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteVscodeRecommendationExists), target)) +} + +// InfoAddManually reports the file exists but lacks the ctx recommendation. +// +// Parameters: +// - cmd: Cobra command for output +// - target: path to the extensions.json file +// - extensionID: the extension identifier to add +func InfoAddManually(cmd *cobra.Command, target, extensionID string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteVscodeAddManually), target, extensionID)) +} + +// InfoWarnNonFatal reports a non-fatal error during artifact creation. +// +// Parameters: +// - cmd: Cobra command for output +// - name: short description of what failed +// - err: the non-fatal error +func InfoWarnNonFatal(cmd *cobra.Command, name string, err error) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteVscodeWarnNonFatal), name, err)) +} diff --git a/specs/2026-03-25-pr45-rebase-v080.md b/specs/2026-03-25-pr45-rebase-v080.md new file mode 100644 index 000000000..bab5bc577 --- /dev/null +++ b/specs/2026-03-25-pr45-rebase-v080.md @@ -0,0 +1,33 @@ +# Session: 2026-03-25 — PR #45 Rebase onto v0.8.0 + +## What Was Done +- Fetched upstream/main: jumped from 898e3cd7 to a3fdab88 (106 new commits, v0.8.0 release) +- Rebased 6 PR commits (feat/copilot-governance) onto new upstream/main +- Resolved conflicts in 14 files across 4 rebase steps: + - **Commit 1** (Windows compat): path.go — kept case-insensitive closures + errCtx alias + - **Commit 3** (VS Code extension): 6 conflicts — package.json, extension.ts, embed.go, cmd.go, run.go, claude.go + - **Commit 5** (Insiders fix): 4 conflicts — CONVENTIONS.md, package.json, extension.ts, run.go + - **Commit 6** (CI fixes): 3 conflicts — .golangci.yml, embed_test.go, embeds.go +- Fixed 3 post-rebase build errors: + - `cmd.Flags()` → `c.Flags()` (upstream variable rename in bootstrap) + - `core.Initialized()` → `coreState.Initialized()` (core split to core/state) + - `Session`/`Message`/`ToolUse` → `entity.Session`/`entity.Message`/`entity.ToolUse` (types moved to entity pkg) +- All verification passed: `go build ./...` clean, compliance tests OK, lint 0 issues +- Force-pushed to PR #45 + +## Decisions +- Accepted upstream extension.ts entirely (1711 lines) — governance features (detection ring, watchers, diag, session lifecycle) will be re-applied in a follow-up +- Accepted upstream's G101 exclusion path change to `text/text.go` (constants restructured) +- Accepted upstream's removal of TestTextDescKeysResolve from embed_test.go (moved to read/desc/) +- Deleted `internal/cli/initialize/core/claude.go` and `internal/config/embed/embeds.go` (upstream split into subpackages) + +## Learnings +- v0.8.0 was a massive refactoring: standardized import aliases (Yoda-style camelCase), entity package for cross-cutting types, config/embed split into cmd/flag/text subdirectories, core/ split into validate/prompt/plan/plugin/merge/project/claude/entry subpackages +- When upstream has 106 commits with structural changes, accept upstream for complex files (extension.ts) and re-apply features later +- The `caller` parameter and `autoMerge` pattern survived the rebase — these are the key PR additions to run.go + +## Next Steps +- Re-apply governance features to extension.ts (detection ring, watchers, diag command, session lifecycle) — reference `editors/vscode/src/extension_pr.ts` +- Re-apply marketplace fields (extensionDependencies, pricing) to package.json +- Clean up temp files: extension_pr.ts, test-insiders-sim.js, .bak files +- Monitor CI on PR #45 diff --git a/specs/agents-md.md b/specs/agents-md.md index 1fc05ef32..d13e83587 100644 --- a/specs/agents-md.md +++ b/specs/agents-md.md @@ -13,7 +13,7 @@ Aider) can't see it. This creates two problems: 2. **"Agent-agnostic" is aspirational, not real.** ctx's docs and architecture claim tool independence, but the primary integration - point is a Claude Code-specific file. The `ctx hook` command + point is a Claude Code-specific file. The `ctx setup` command generates snippets for other tools, but they're shallow copies that duplicate content and drift immediately. @@ -32,7 +32,7 @@ Three changes, each independently shippable: generated by `ctx init`, read by all tools 2. **CLAUDE.md as thin wrapper** — Claude Code-specific addendum that references AGENTS.md -3. **`ctx hook --write` for all tools** — generate thin +3. **`ctx setup --write` for all tools** — generate thin wrappers for Cursor, Windsurf, Copilot (not just Copilot) Plus a skill visibility improvement that rides on top. @@ -151,9 +151,9 @@ This is ~15 lines instead of ~65. The real content lives in AGENTS.md. Claude Code reads both files (AGENTS.md from the project tree + CLAUDE.md as its native config), so no content is lost. -### 2. `ctx hook --write` for All Tools +### 2. `ctx setup --write` for All Tools -Currently only `ctx hook copilot --write` generates a file. Extend +Currently only `ctx setup copilot --write` generates a file. Extend to all tools that have a config file: | Tool | File | Status | @@ -192,14 +192,14 @@ Run `ctx drift` to check for stale context. Same structure, `ctx:windsurf` markers. -#### `ctx hook copilot --write` (updated) +#### `ctx setup copilot --write` (updated) The existing Copilot template is comprehensive (~130 lines with session persistence instructions). Slim it down to reference AGENTS.md and add only Copilot-specific behavior (session file format for `.context/sessions/`). -#### `ctx hook agents --write` +#### `ctx setup agents --write` Generates AGENTS.md from the embedded template. Same merge logic as CLAUDE.md: check for `ctx:agents` markers, merge or skip. @@ -231,7 +231,7 @@ when the user requests that behavior. ``` This is generated, not hand-maintained — `ctx init` or -`ctx hook agents --write` reads `.claude/skills/*/SKILL.md` +`ctx setup agents --write` reads `.claude/skills/*/SKILL.md` frontmatter to build the table. #### Option B: Symlink deployment (future, if demand warrants) @@ -284,11 +284,11 @@ value. ``` ctx init # Now generates AGENTS.md + thin CLAUDE.md ctx init --no-agents # Skip AGENTS.md (opt out) -ctx hook agents # Print AGENTS.md content -ctx hook agents -w # Write AGENTS.md (with merge logic) -ctx hook cursor -w # Write .cursorrules (NEW) -ctx hook windsurf -w # Write .windsurfrules (NEW) -ctx hook copilot -w # Write .github/copilot-instructions.md (exists) +ctx setup agents # Print AGENTS.md content +ctx setup agents -w # Write AGENTS.md (with merge logic) +ctx setup cursor -w # Write .cursorrules (NEW) +ctx setup windsurf -w # Write .windsurfrules (NEW) +ctx setup copilot -w # Write .github/copilot-instructions.md (exists) ``` No new flags beyond `--no-agents` on init. The `--write` / `-w` @@ -353,10 +353,10 @@ key if users request persistent opt-out. - `handleAgentsMd()`: new file, merge with existing, marker detection, skip without force — mirror `handleClaudeMd` tests -- `ctx hook agents --write`: write new, merge existing, marker +- `ctx setup agents --write`: write new, merge existing, marker idempotency -- `ctx hook cursor --write`: write new file, merge existing -- `ctx hook windsurf --write`: write new file, merge existing +- `ctx setup cursor --write`: write new file, merge existing +- `ctx setup windsurf --write`: write new file, merge existing - Skill table generation: parse frontmatter, build table, handle empty skills directory @@ -366,7 +366,7 @@ key if users request persistent opt-out. created - `ctx init --no-agents` → only CLAUDE.md created - `ctx init` with existing AGENTS.md (no markers) → merge offered -- `ctx hook agents -w && ctx hook agents -w` → idempotent +- `ctx setup agents -w && ctx setup agents -w` → idempotent - CLAUDE.md references AGENTS.md, not duplicates content ## Non-Goals @@ -384,9 +384,9 @@ key if users request persistent opt-out. skill table in AGENTS.md covers discoverability without the fragility of cross-platform symlinks. - **Auto-syncing AGENTS.md on context changes**: AGENTS.md is - generated once by `ctx init` or `ctx hook agents --write`. It + generated once by `ctx init` or `ctx setup agents --write`. It doesn't auto-update when context files change. Run - `ctx hook agents --write --force` to regenerate. + `ctx setup agents --write --force` to regenerate. ## Open Questions @@ -406,6 +406,6 @@ key if users request persistent opt-out. 3. **Skill table staleness**: The skill table in AGENTS.md is generated at init time. If skills are added/removed later, the table drifts. Options: (a) drift check catches it, (b) a - `ctx hook agents --write --force` regenerates it, (c) a hook + `ctx setup agents --write --force` regenerates it, (c) a hook auto-updates it. Leaning toward (a) + (b) — consistent with how ctx handles other drift. diff --git a/specs/copilot-cli-integration.md b/specs/copilot-cli-integration.md new file mode 100644 index 000000000..a9e98f1e5 --- /dev/null +++ b/specs/copilot-cli-integration.md @@ -0,0 +1,240 @@ +# Spec: Copilot CLI Integration — Feature Matrix + +## Feature Matrix: Claude Code vs VS Code Extension vs GitHub Copilot CLI + +### Legend + +- **✅** — Implemented and shipping +- **🔧** — Partially implemented / needs work +- **📋** — Planned / specced +- **—** — Not applicable to this surface + +--- + +### 1. Context Injection (How the agent learns about ctx) + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| Project instructions file | ✅ `CLAUDE.md` | ✅ `.github/copilot-instructions.md` | 📋 `.github/copilot-instructions.md` + `AGENTS.md` | +| Auto-generated on `ctx init` | ✅ Merged into project root | ✅ Via `@ctx /init` (also runs `hook copilot --write`) | 📋 `ctx init` should also generate `AGENTS.md` | +| Marker-based idempotency | ✅ `` / `` | ✅ `` / `` | 📋 Same copilot markers | +| Path-specific instructions | — | — | 📋 `.github/instructions/*.instructions.md` | +| Custom agents | — | — | 📋 `.github/agents/ctx.md` | +| Home-dir instructions | — | — | 📋 `~/.copilot/copilot-instructions.md` | +| Reads `CLAUDE.md` natively | ✅ Core feature | — | ✅ Built-in (Copilot CLI reads CLAUDE.md) | +| Reads `AGENTS.md` natively | — | — | ✅ Built-in (primary instructions) | + +--- + +### 2. Hook System (Pre/post tool execution, session lifecycle) + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| Config location | `.claude/settings.local.json` | — (extension handles internally) | 📋 `.github/hooks/ctx-hooks.json` | +| PreToolUse / preToolUse | ✅ Regex matcher + command | — | 📋 `bash` + `powershell` fields | +| PostToolUse / postToolUse | ✅ Command hook | — | 📋 `bash` + `powershell` fields | +| UserPromptSubmit / userPromptSubmitted | ✅ Command hook | — | 📋 `bash` + `powershell` fields | +| SessionEnd / sessionEnd | ✅ Command hook | — | 📋 `bash` + `powershell` fields | +| SessionStart / sessionStart | — | — | 📋 `bash` + `powershell` fields | +| agentStop | — | — | 📋 Available in Copilot CLI | +| subagentStop | — | — | 📋 Available in Copilot CLI | +| errorOccurred | — | — | 📋 Available in Copilot CLI | +| Hook script format | Bash only | N/A | 📋 Dual: bash + PowerShell | +| Block dangerous commands | ✅ `block-hack-scripts.sh` | ✅ Detection ring (deny patterns) | 📋 `ctx-block-commands.sh` + `.ps1` | +| Platform support | Linux/macOS (bash) | All (TypeScript) | 📋 All (bash + powershell) | +| Timeout control | — (Claude manages) | — | 📋 `timeoutSec` per hook | +| Working directory | Implicit (project root) | Implicit | 📋 `cwd` field per hook | +| Environment variables | — | — | 📋 `env` field per hook | + +--- + +### 3. MCP Server (Model Context Protocol) + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| MCP server registration | ✅ Plugin system (`ctx@activememory-ctx`) | ✅ `.vscode/mcp.json` auto-generated | 📋 `~/.copilot/mcp-config.json` | +| Transport | Plugin (in-process) | stdio (`ctx mcp serve`) | 📋 stdio (`ctx mcp serve`) | +| `ctx_status` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_add` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_complete` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_drift` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_recall` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_watch_update` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_compact` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_next` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_check_task_completion` | ✅ | ✅ | 📋 (same server) | +| `ctx_session_event` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_remind` tool | ✅ | ✅ | 📋 (same server) | +| 8 context resources | ✅ | ✅ | 📋 (same server) | +| Resource change notifications | ✅ Poller-based | ✅ Poller-based | 📋 (same server) | +| Prompt templates | ✅ | ✅ | 📋 (same server) | +| Session governance tracking | ✅ | ✅ | 📋 (same server) | + +--- + +### 4. Session Recall (Parsing AI session history) + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| Session parser | ✅ ClaudeCodeParser (JSONL) | ✅ CopilotParser (JSONL) | 📋 CopilotCLIParser (TBD format) | +| Auto-detect session dir | ✅ `~/.claude/projects/` | ✅ Platform-specific `workspaceStorage/` | 📋 `~/.copilot/sessions/` (TBD) | +| Windows path handling | ✅ Drive letter fix | ✅ APPDATA detection | 📋 USERPROFILE / COPILOT_HOME | +| macOS path handling | ✅ ~/Library/... | ✅ ~/Library/Application Support/Code/... | 📋 ~/.copilot/ | +| Linux path handling | ✅ ~/.claude/ | ✅ ~/.config/Code/... | 📋 ~/.copilot/ | +| WSL path handling | — | — | 📋 Must handle WSL ↔ Windows boundary | +| Markdown session export | ✅ | ✅ | 📋 | + +--- + +### 5. Governance & Safety + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| Permission allow-list | ✅ `permissions.allow[]` | — | 📋 `--allow-tool` / `--deny-tool` flags | +| Permission deny-list | ✅ `permissions.deny[]` | — | 📋 `--deny-tool` flags | +| Dangerous command blocking | ✅ PreToolUse hook | ✅ Detection ring (regex) | 📋 preToolUse hook script | +| Sensitive file detection | — | ✅ SENSITIVE_FILE_PATTERNS | 📋 preToolUse hook script | +| Violation recording | — | ✅ `.context/state/violations.json` | 📋 Hook script writes violations | +| Hack script interception | ✅ `block-hack-scripts.sh` | ✅ DENY_COMMAND_SCRIPT_PATTERNS | 📋 preToolUse hook script | +| Tool approval model | Per-session allow | N/A (Copilot manages) | Per-session or `--allow-tool` | +| Trusted directories | Implicit (project root) | Implicit (workspace) | ✅ Built-in trust prompt | + +--- + +### 6. Binary Management + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| Auto-install ctx binary | ✅ Plugin installation | ✅ GitHub releases download | 📋 ctx already on PATH or manual | +| Platform detection | — (Go binary) | ✅ darwin/windows/linux + amd64/arm64 | 📋 Same Go binary | +| Binary verification | — | ✅ Executes `--version` check | — | +| Update mechanism | Plugin update | ✅ GitHub releases (latest) | — (user manages) | + +--- + +### 7. UI & User Experience + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| Chat participant | — (terminal-based) | ✅ `@ctx` with 34 slash commands | — (terminal-based) | +| Status bar | — | 🔧 Reminder status bar (PR pending) | — | +| Diagnostics command | — | ✅ `/diag` with timing | — | +| Progress indicators | — | ✅ `stream.progress()` | — | +| Markdown rendering | Terminal output | ✅ VS Code markdown | Terminal output | +| Interactive mode | ✅ Terminal REPL | ✅ Chat panel | ✅ Terminal REPL | +| Programmatic mode | — | — | ✅ `copilot -p "prompt"` | +| Plan mode | — | — | ✅ Shift+Tab | +| Custom agents | — | — | ✅ `/agent` + `--agent=` flag | +| Skills | ✅ `.claude/skills/` | — | ✅ `.github/skills/` | +| Autopilot mode | — | — | ✅ `--experimental` | + +--- + +### 8. Cross-Platform Support + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| Windows (native) | ✅ | ✅ | ✅ (PowerShell v6+) | +| macOS | ✅ | ✅ | ✅ | +| Linux | ✅ | ✅ | ✅ | +| WSL | — | ✅ (Remote WSL) | ✅ (bash) | +| Hook script: bash | ✅ | N/A | 📋 Required | +| Hook script: PowerShell | — | N/A | 📋 Required | +| Path separator handling | ✅ filepath.Join | ✅ path.join + filepath | 📋 filepath.Join (Go binary) | +| Case-insensitive paths | ✅ (validation pkg) | ✅ (VS Code handles) | 📋 Inherit from ctx binary | +| Home dir detection | `~/.claude/` | Extension globalStorage | 📋 `~/.copilot/` or `$COPILOT_HOME` | + +--- + +### 9. Context System (Shared across all surfaces) + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| `ctx init` | ✅ CLI | ✅ `@ctx /init` | 📋 CLI (same binary) | +| `ctx status` | ✅ CLI | ✅ `@ctx /status` | 📋 CLI (same binary) | +| `ctx agent` | ✅ CLI | ✅ `@ctx /agent` | 📋 CLI (same binary) | +| `ctx drift` | ✅ CLI | ✅ `@ctx /drift` | 📋 CLI (same binary) | +| `ctx recall` | ✅ CLI | ✅ `@ctx /recall` | 📋 CLI (same binary) | +| `ctx add` | ✅ CLI | ✅ `@ctx /add` | 📋 CLI (same binary) | +| `ctx compact` | ✅ CLI | ✅ `@ctx /compact` | 📋 CLI (same binary) | +| `ctx setup ` | ✅ `ctx setup claude` | ✅ `ctx setup copilot` | 📋 `ctx setup copilot-cli` | +| Session persistence | ✅ `.context/sessions/` | ✅ `.context/sessions/` | 📋 `.context/sessions/` | + +--- + +## Implementation Plan: Copilot CLI Integration + +### Phase 1 — Hook Generation (cross-platform) + +**Goal:** `ctx setup copilot-cli --write` generates: + +1. `.github/hooks/ctx-hooks.json` — Hook configuration with dual bash/powershell +2. `.github/hooks/scripts/ctx-preToolUse.sh` — Bash pre-tool gate +3. `.github/hooks/scripts/ctx-preToolUse.ps1` — PowerShell pre-tool gate +4. `.github/hooks/scripts/ctx-sessionStart.sh` — Bash session init +5. `.github/hooks/scripts/ctx-sessionStart.ps1` — PowerShell session init +6. `.github/hooks/scripts/ctx-postToolUse.sh` — Bash post-tool audit +7. `.github/hooks/scripts/ctx-postToolUse.ps1` — PowerShell post-tool audit +8. `.github/hooks/scripts/ctx-sessionEnd.sh` — Bash session teardown +9. `.github/hooks/scripts/ctx-sessionEnd.ps1` — PowerShell session teardown + +**Hook JSON structure:** +```json +{ + "version": 1, + "hooks": { + "sessionStart": [{ + "type": "command", + "bash": ".github/hooks/scripts/ctx-sessionStart.sh", + "powershell": ".github/hooks/scripts/ctx-sessionStart.ps1", + "cwd": ".", + "timeoutSec": 10 + }], + "preToolUse": [{ + "type": "command", + "bash": ".github/hooks/scripts/ctx-preToolUse.sh", + "powershell": ".github/hooks/scripts/ctx-preToolUse.ps1", + "cwd": ".", + "timeoutSec": 5 + }], + "postToolUse": [{ + "type": "command", + "bash": ".github/hooks/scripts/ctx-postToolUse.sh", + "powershell": ".github/hooks/scripts/ctx-postToolUse.ps1", + "cwd": ".", + "timeoutSec": 5 + }], + "sessionEnd": [{ + "type": "command", + "bash": ".github/hooks/scripts/ctx-sessionEnd.sh", + "powershell": ".github/hooks/scripts/ctx-sessionEnd.ps1", + "cwd": ".", + "timeoutSec": 15 + }] + } +} +``` + +**Script behavior:** All scripts are thin shims that call the ctx binary: +- `ctx-sessionStart` → `ctx system session-event --type start --caller copilot-cli` +- `ctx-preToolUse` → reads JSON stdin, calls `ctx` for dangerous command check +- `ctx-postToolUse` → reads JSON stdin, appends to audit log +- `ctx-sessionEnd` → `ctx system session-event --type end --caller copilot-cli` + +### Phase 2 — Agent & Instructions + +1. `AGENTS.md` generation in project root (read by Copilot CLI as primary instructions) +2. `.github/agents/ctx.md` — custom agent for context management delegation +3. `.github/instructions/context.instructions.md` — path-specific instructions for `.context/` files + +### Phase 3 — MCP & Recall + +1. Register ctx MCP server in `~/.copilot/mcp-config.json` (respects `$COPILOT_HOME`) +2. Copilot CLI session parser for `ctx recall` +3. Cross-session continuity (Copilot CLI `--resume` ↔ ctx session files) + +### Phase 4 — Deep Integration + +1. ACP (Agent Client Protocol) server mode — Copilot CLI can use ctx as an ACP agent +2. Copilot Memory ↔ ctx memory bridge bidirectional sync +3. Skills in `.github/skills/` that wrap ctx operations diff --git a/specs/vscode-feature-parity.md b/specs/vscode-feature-parity.md new file mode 100644 index 000000000..6d66ebe8e --- /dev/null +++ b/specs/vscode-feature-parity.md @@ -0,0 +1,128 @@ +# VS Code Extension Feature Parity Spec + +> Goal: Native port of every Claude Code integration feature to VS Code equivalents. +> Each item maps a Claude Code mechanism to the correct VS Code platform primitive. + +## Layer 0 — Shared Core (editor-agnostic) + +These are identical across all editors. Created by `ctx init` regardless of `--caller`. + +| # | Feature | Files Created | Status | +|---|---------|--------------|--------| +| 0.1 | `.context/*.md` templates (9 files) | TASKS, DECISIONS, LEARNINGS, CONVENTIONS, CONSTITUTION, ARCHITECTURE, GLOSSARY, AGENT_PLAYBOOK, PROMPT | Done | +| 0.2 | Entry templates | `.context/templates/*.md` | Done | +| 0.3 | Prompt templates | `.context/prompts/*.md` | Done | +| 0.4 | Project directories | `specs/`, `ideas/` with README.md | Done | +| 0.5 | PROMPT.md | Project root prompt template | Done | +| 0.6 | IMPLEMENTATION_PLAN.md | Project root plan template | Done | +| 0.7 | .gitignore entries | `.context/state/`, `.context/memory/`, etc. | Done | +| 0.8 | Scratchpad | `.context/scratch.md` or encrypted `.enc` | Done | + +## Layer 1 — Init Artifacts (editor-specific) + +Files created by `ctx init --caller vscode` that are VS Code platform native. + +| # | Claude Code | Claude Mechanism | VS Code Equivalent | VS Code Mechanism | Status | +|---|-------------|-----------------|---------------------|-------------------|--------| +| 1.1 | `CLAUDE.md` (agent instructions) | `HandleClaudeMd()` — Claude reads this on session start | `.github/copilot-instructions.md` | Copilot reads this automatically on every chat session. Already generated by `ctx setup copilot --write`. Init should call this for vscode caller. | **Partial** — generated by `/hook` but not wired into init | +| 1.2 | `.claude/settings.local.json` (permissions: allow/deny lists) | `MergeSettingsPermissions()` — controls what tools Claude can use | `.vscode/settings.json` (ctx extension settings) | VS Code extensions don't have a tool permission model. Instead, write `ctx.*` configuration keys: `ctx.executablePath`, `ctx.autoContextLoad`, `ctx.sessionTracking`. | **Not started** | +| 1.3 | Plugin enablement (`~/.claude/settings.json`) | `EnablePluginGlobally()` — adds to global enabledPlugins | `.vscode/extensions.json` (recommended extensions) | VS Code workspace recommendations. Write `{"recommendations": ["activememory.ctx-context"]}` so collaborators get prompted to install. | **Not started** | +| 1.4 | `Makefile.ctx` (build targets) | `HandleMakefileCtx()` — ctx-managed make targets | `.vscode/tasks.json` (build tasks) | Register `ctx status`, `ctx drift`, `ctx agent` as VS Code tasks so they appear in Ctrl+Shift+B / Task Runner. | **Not started** | + +## Layer 2 — Hooks (event-driven automation) + +Claude Code hooks fire on tool use events. VS Code equivalents use extension API event handlers. + +| # | Claude Hook | Claude Trigger | What It Does | VS Code Equivalent | VS Code API | Status | +|---|-------------|---------------|--------------|---------------------|-------------|--------| +| 2.1 | PreToolUse `ctx agent --budget 4000` | Every tool invocation | Loads full context packet with cooldown | Chat participant handler preamble | Already implicit: each `@ctx` invocation can load context. Could add explicit `ctx agent` call as preamble to non-init commands. | **Implicit** | +| 2.2 | PreToolUse `context-load-gate` | Every tool invocation | Validates `.context/` exists | Chat participant handler check | Already done: handler checks `getWorkspaceRoot()`. Could add `.context/` existence check with init prompt. | **Partial** | +| 2.3 | PreToolUse `block-non-path-ctx` | Bash tool | Prevents shell from directly accessing context files | N/A | VS Code doesn't execute arbitrary bash on user's behalf. The extension is the sole interface. | **N/A** | +| 2.4 | PreToolUse `qa-reminder` | Bash tool | Reminds about QA checks | N/A | No equivalent — VS Code Copilot doesn't have pre-tool hooks. Could surface via status bar or notification. | **Deferred** | +| 2.5 | PreToolUse `specs-nudge` | EnterPlanMode | Nudges to review specs/ before planning | N/A | No plan mode concept in VS Code. Could trigger when `/agent` or freeform mentions "plan"/"design". | **Deferred** | +| 2.6 | PostToolUse `check-task-completion` | Edit/Write tool | Detects completed tasks after file edits | `onDidSaveTextDocument` | `vscode.workspace.onDidSaveTextDocument` — when a `.context/TASKS.md` is saved, run `ctx system check-task-completion`. | **Not started** | +| 2.7 | PostToolUse `post-commit` | Bash tool (git commit) | Captures context after commits | Git extension API | `vscode.extensions.getExtension('vscode.git')` → `git.onDidCommit` or use `postCommitCommand` setting to run `ctx system post-commit`. | **Not started** | +| 2.8 | UserPromptSubmit `check-context-size` | Every user message | Monitors token usage at 80% | N/A | VS Code Copilot doesn't expose token counts. | **N/A** | +| 2.9 | UserPromptSubmit `check-persistence` | Every user message | Ensures context changes are persisted | `onDidSaveTextDocument` | Watch `.context/` files. If modified externally, refresh cached state. | **Not started** | +| 2.10 | UserPromptSubmit `check-reminders` | Every user message | Surfaces due reminders | Status bar + periodic timer | `vscode.window.createStatusBarItem()` — show reminder count. Check on activation and periodically. | **Not started** | +| 2.11 | UserPromptSubmit `check-version` | Every user message | Warns on version mismatch | Bootstrap version check | `ensureCtxAvailable()` already checks version. Could compare against expected. | **Done** | +| 2.12 | UserPromptSubmit `check-ceremonies` | Every user message | Validates session checkpoints | Window close handler | `vscode.workspace.onWillSaveNotebookDocument` or `vscode.window.onDidChangeWindowState` — prompt for session wrap-up. | **Not started** | +| 2.13 | UserPromptSubmit `check-resources` | Every user message | Reports system resources | N/A | Not relevant for VS Code — no token budget concerns. | **N/A** | +| 2.14 | UserPromptSubmit `heartbeat` | Every user message | Telemetry ping | Extension telemetry | `vscode.env.telemetryLevel` — respect user preference, send via VS Code telemetry API. | **Deferred** | +| 2.15 | UserPromptSubmit `check-journal` | Every user message | Audits journal completeness | Periodic check | Could run on session end or as a follow-up suggestion. | **Deferred** | +| 2.16 | UserPromptSubmit `check-knowledge` | Every user message | Validates knowledge graph | N/A | Knowledge graph is Claude Code specific. | **N/A** | +| 2.17 | UserPromptSubmit `check-map-staleness` | Every user message | Detects stale dependency maps | `FileSystemWatcher` | `vscode.workspace.createFileSystemWatcher('**/go.mod')` etc. — watch dependency files, mark maps stale. | **Deferred** | +| 2.18 | UserPromptSubmit `check-memory-drift` | Every user message | Compares memory with context files | Periodic check | Could run on `/status` or `/drift` rather than every message. | **Deferred** | + +## Layer 3 — Skills → Slash Commands + +Claude Code skills become VS Code chat participant slash commands. + +| # | Claude Skill | What It Does | VS Code Command | Status | +|---|-------------|-------------|-----------------|--------| +| 3.1 | `ctx-agent` | Load full context packet | `/agent` | **Done** | +| 3.2 | `ctx-status` | Show context summary | `/status` | **Done** | +| 3.3 | `ctx-drift` | Detect stale context | `/drift` | **Done** | +| 3.4 | `ctx-add-decision` | Record decisions | `/add decision ...` | **Done** | +| 3.5 | `ctx-add-learning` | Record learnings | `/add learning ...` | **Done** | +| 3.6 | `ctx-add-convention` | Record conventions | `/add convention ...` | **Done** | +| 3.7 | `ctx-add-task` | Add tasks | `/add task ...` | **Done** | +| 3.8 | `ctx-recall` | Browse session history | `/recall` | **Done** | +| 3.9 | `ctx-pad` | Transient working document | `/pad` | **Done** | +| 3.10 | `ctx-archive` | Archive completed tasks | `/tasks archive` | **Done** | +| 3.11 | `ctx-commit` | Commit with context capture | `/sync` | **Done** (via sync) | +| 3.12 | `ctx-doctor` | Diagnose context health | `/system doctor` | **Done** (via system) | +| 3.13 | `ctx-remind` | Session reminders | `/remind` | **Done** | +| 3.14 | `ctx-complete` | Mark task completed | `/complete` | **Done** | +| 3.15 | `ctx-compact` | Compact/archive tasks | `/compact` | **Done** | +| 3.16 | `ctx-notify` | Webhook notifications | `/notify` | **Done** | +| 3.17 | `ctx-brainstorm` | Ideas → validated designs | Not mapped | **Not started** | +| 3.18 | `ctx-spec` | Scaffold feature specs | Not mapped | **Not started** | +| 3.19 | `ctx-implement` | Execute plans step-by-step | Not mapped | **Not started** | +| 3.20 | `ctx-next` | Choose next work item | Not mapped | **Not started** | +| 3.21 | `ctx-verify` | Run verification | Not mapped | **Not started** | +| 3.22 | `ctx-blog` | Generate blog post | Not mapped | **Deferred** (niche) | +| 3.23 | `ctx-blog-changelog` | Blog from commits | Not mapped | **Deferred** (niche) | +| 3.24 | `ctx-check-links` | Audit dead links | Not mapped | **Deferred** (niche) | +| 3.25 | `ctx-journal-*` | Journal enrichment | Not mapped | **Deferred** | +| 3.26 | `ctx-consolidate` | Merge overlapping entries | Not mapped | **Deferred** | +| 3.27 | `ctx-alignment-audit` | Audit doc alignment | Not mapped | **Deferred** | +| 3.28 | `ctx-map` | Dependency visualization | Not mapped | **Not started** | +| 3.29 | `ctx-import-plans` | Import plan files | Not mapped | **Deferred** | +| 3.30 | `ctx-prompt` | Work with prompt templates | Not mapped | **Deferred** | +| 3.31 | `ctx-context-monitor` | Real-time context monitoring | Not mapped | **Deferred** | +| 3.32 | `ctx-loop` | Interactive REPL | N/A | **N/A** (no concept in chat UI) | +| 3.33 | `ctx-worktree` | Git worktree management | Not mapped | **Deferred** | +| 3.34 | `ctx-reflect` | Surface persist-worthy items | Not mapped | **Not started** | +| 3.35 | `ctx-wrap-up` | End-of-session ceremony | Not mapped | **Not started** | +| 3.36 | `ctx-remember` | Session recall at startup | Not mapped | **Not started** | +| 3.37 | `ctx-pause` / `ctx-resume` | Pause/resume state | Not mapped | **Deferred** | + +## Priority Matrix + +### P0 — Must have for init to work correctly +- [ ] 1.1 — Generate `copilot-instructions.md` during init (wire hook copilot into init flow) +- [ ] 1.3 — Generate `.vscode/extensions.json` recommending ctx extension +- [ ] 2.2 — Check `.context/` exists, prompt to init if missing + +### P1 — Core event hooks (native port of Claude hooks) +- [ ] 2.6 — `onDidSaveTextDocument` → task completion check +- [ ] 2.7 — Git post-commit → context capture +- [ ] 2.10 — Status bar reminder indicator +- [ ] 2.12 — Session end ceremony prompt + +### P2 — Init artifacts for team workflow +- [ ] 1.2 — `.vscode/settings.json` with ctx configuration +- [ ] 1.4 — `.vscode/tasks.json` with ctx tasks +- [ ] 2.9 — Watch `.context/` for external changes + +### P3 — Missing slash commands (high value) +- [ ] 3.17 — `/brainstorm` +- [ ] 3.20 — `/next` +- [ ] 3.34 — `/reflect` +- [ ] 3.35 — `/wrapup` +- [ ] 3.36 — `/remember` + +### Deferred — Lower priority or niche +- 2.4, 2.5, 2.14, 2.15, 2.17, 2.18 +- 3.18, 3.19, 3.21–3.33, 3.37