diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..f64268d8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,106 @@ +# 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 recall list # Recent session history +``` + + diff --git a/editors/vscode/LICENSE b/editors/vscode/LICENSE new file mode 100644 index 00000000..be659d90 --- /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 402af09c..825b9858 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -1,34 +1,173 @@ # 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. +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 Chat view to access 45 slash commands, automatic context +hooks, a reminder status bar, and natural language routing — all powered by +the ctx CLI. -Type `@ctx` in the VS Code Chat view, then use slash commands: +## 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 | |---------|-------------| -| `@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 | +| `/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 | +| `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 @@ -37,8 +176,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 d8e3656a..bd284ba2 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "ctx-context", "displayName": "ctx — Persistent Context for AI", "description": "Chat participant (@ctx) for persistent project context across AI coding sessions", - "version": "0.7.0", + "version": "0.9.0", "publisher": "activememory", "license": "Apache-2.0", "homepage": "https://github.com/ActiveMemory/ctx", @@ -108,6 +108,122 @@ { "name": "system", "description": "System diagnostics and bootstrap" + }, + { + "name": "wrapup", + "description": "End-of-session wrap-up with status and drift summary" + }, + { + "name": "remember", + "description": "Recall recent AI sessions for this project" + }, + { + "name": "next", + "description": "Show the next open task from TASKS.md" + }, + { + "name": "brainstorm", + "description": "Browse and develop ideas from ideas/" + }, + { + "name": "reflect", + "description": "Reflect on session — surface items worth persisting" + }, + { + "name": "spec", + "description": "List or scaffold feature specs from templates" + }, + { + "name": "implement", + "description": "Show the implementation plan" + }, + { + "name": "verify", + "description": "Run verification checks (doctor + drift)" + }, + { + "name": "map", + "description": "Show dependency map (go.mod, package.json)" + }, + { + "name": "prompt", + "description": "Browse and view prompt templates" + }, + { + "name": "blog", + "description": "Draft a blog post from recent context" + }, + { + "name": "changelog", + "description": "Show recent commits for changelog" + }, + { + "name": "check-links", + "description": "Audit local links in context files" + }, + { + "name": "journal", + "description": "View journal entries" + }, + { + "name": "consolidate", + "description": "Find duplicate entries in context files" + }, + { + "name": "audit", + "description": "Alignment audit — drift + convention check" + }, + { + "name": "worktree", + "description": "Git worktree management (list, add)" + }, + { + "name": "pause", + "description": "Save session state for later" + }, + { + "name": "resume", + "description": "Restore a paused session" + }, + { + "name": "memory", + "description": "Claude Code memory bridge (sync, status, diff, import, publish)" + }, + { + "name": "decisions", + "description": "List or reindex project decisions" + }, + { + "name": "learnings", + "description": "List or reindex project learnings" + }, + { + "name": "config", + "description": "Manage config profiles (switch, status, schema)" + }, + { + "name": "permissions", + "description": "Backup or restore Claude settings (snapshot, restore)" + }, + { + "name": "changes", + "description": "Show what changed since last session (--since duration)" + }, + { + "name": "deps", + "description": "Show package dependency graph (--format mermaid|table|json)" + }, + { + "name": "guide", + "description": "Quick-reference cheat sheet for ctx (--skills, --commands)" + }, + { + "name": "reindex", + "description": "Regenerate indices for DECISIONS.md and LEARNINGS.md" + }, + { + "name": "why", + "description": "Read the philosophy behind ctx (manifesto, about, invariants)" } ], "disambiguation": [ @@ -127,6 +243,53 @@ ] } ], + "commands": [ + { "command": "ctx.init", "title": "Init", "category": "ctx" }, + { "command": "ctx.status", "title": "Status", "category": "ctx" }, + { "command": "ctx.agent", "title": "Agent", "category": "ctx" }, + { "command": "ctx.drift", "title": "Drift", "category": "ctx" }, + { "command": "ctx.recall", "title": "Recall", "category": "ctx" }, + { "command": "ctx.hook", "title": "Hook", "category": "ctx" }, + { "command": "ctx.add", "title": "Add", "category": "ctx" }, + { "command": "ctx.load", "title": "Load", "category": "ctx" }, + { "command": "ctx.compact", "title": "Compact", "category": "ctx" }, + { "command": "ctx.sync", "title": "Sync", "category": "ctx" }, + { "command": "ctx.complete", "title": "Complete Task", "category": "ctx" }, + { "command": "ctx.remind", "title": "Remind", "category": "ctx" }, + { "command": "ctx.tasks", "title": "Tasks", "category": "ctx" }, + { "command": "ctx.pad", "title": "Pad", "category": "ctx" }, + { "command": "ctx.notify", "title": "Notify", "category": "ctx" }, + { "command": "ctx.system", "title": "System", "category": "ctx" }, + { "command": "ctx.wrapup", "title": "Wrapup", "category": "ctx" }, + { "command": "ctx.remember", "title": "Remember", "category": "ctx" }, + { "command": "ctx.next", "title": "Next Task", "category": "ctx" }, + { "command": "ctx.brainstorm", "title": "Brainstorm", "category": "ctx" }, + { "command": "ctx.reflect", "title": "Reflect", "category": "ctx" }, + { "command": "ctx.spec", "title": "Spec", "category": "ctx" }, + { "command": "ctx.implement", "title": "Implement", "category": "ctx" }, + { "command": "ctx.verify", "title": "Verify", "category": "ctx" }, + { "command": "ctx.map", "title": "Map", "category": "ctx" }, + { "command": "ctx.prompt", "title": "Prompt", "category": "ctx" }, + { "command": "ctx.blog", "title": "Blog", "category": "ctx" }, + { "command": "ctx.changelog", "title": "Changelog", "category": "ctx" }, + { "command": "ctx.checkLinks", "title": "Check Links", "category": "ctx" }, + { "command": "ctx.journal", "title": "Journal", "category": "ctx" }, + { "command": "ctx.consolidate", "title": "Consolidate", "category": "ctx" }, + { "command": "ctx.audit", "title": "Audit", "category": "ctx" }, + { "command": "ctx.worktree", "title": "Worktree", "category": "ctx" }, + { "command": "ctx.pause", "title": "Pause", "category": "ctx" }, + { "command": "ctx.resume", "title": "Resume", "category": "ctx" }, + { "command": "ctx.memory", "title": "Memory", "category": "ctx" }, + { "command": "ctx.decisions", "title": "Decisions", "category": "ctx" }, + { "command": "ctx.learnings", "title": "Learnings", "category": "ctx" }, + { "command": "ctx.config", "title": "Config", "category": "ctx" }, + { "command": "ctx.permissions", "title": "Permissions", "category": "ctx" }, + { "command": "ctx.changes", "title": "Changes", "category": "ctx" }, + { "command": "ctx.deps", "title": "Deps", "category": "ctx" }, + { "command": "ctx.guide", "title": "Guide", "category": "ctx" }, + { "command": "ctx.reindex", "title": "Reindex", "category": "ctx" }, + { "command": "ctx.why", "title": "Why", "category": "ctx" } + ], "configuration": { "title": "ctx", "properties": { diff --git a/editors/vscode/src/extension.test.ts b/editors/vscode/src/extension.test.ts index 4fb12792..739ffafa 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 99391619..8b185a37 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -20,6 +20,9 @@ let resolvedCtxPath: string | undefined; // Extension context — set during activation let extensionCtx: vscode.ExtensionContext | undefined; +// Status bar item for context reminders +let reminderStatusBar: vscode.StatusBarItem | undefined; + function getCtxPath(): string { if (resolvedCtxPath) { return resolvedCtxPath; @@ -263,6 +266,21 @@ async function bootstrap(): Promise { return bootstrapPromise; } +/** + * Merge stdout and stderr without duplicating lines that appear in both. + * Cobra prints errors to both streams — naive concatenation doubles them. + */ +function mergeOutput(stdout: string, stderr: string): string { + const out = stdout.trim(); + const err = stderr.trim(); + if (!out) return err; + if (!err) return out; + // If stderr content already appears in stdout, skip it + if (out.includes(err)) return out; + if (err.includes(out)) return err; + return out + "\n" + err; +} + function runCtx( args: string[], cwd?: string, @@ -307,6 +325,13 @@ function runCtx( }); } +/** + * Check if .context/ directory exists in the workspace root. + */ +function hasContextDir(cwd: string): boolean { + return fs.existsSync(path.join(cwd, ".context")); +} + async function handleInit( stream: vscode.ChatResponseStream, cwd: string, @@ -314,8 +339,8 @@ async function handleInit( ): Promise { stream.progress("Initializing .context/ directory..."); try { - const { stdout, stderr } = await runCtx(["init", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["init", "--caller", "vscode"], cwd, token); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } @@ -325,11 +350,11 @@ async function handleInit( stream.progress("Generating Copilot instructions..."); try { const hookResult = await runCtx( - ["hook", "copilot", "--write", "--no-color"], + ["hook", "copilot", "--write"], cwd, token ); - const hookOutput = (hookResult.stdout + hookResult.stderr).trim(); + const hookOutput = mergeOutput(hookResult.stdout, hookResult.stderr); if (hookOutput) { stream.markdown( "\n**Copilot integration:**\n```\n" + hookOutput + "\n```" @@ -352,6 +377,9 @@ async function handleInit( "`.context/` directory initialized. Run `@ctx /status` to see your project context." ); } + + // Fire session-start since activate() missed it (no .context/ at activation time) + runCtx(["system", "session-event", "--type", "start", "--caller", "vscode"], cwd).catch(() => {}); } catch (err: unknown) { stream.markdown( `**Error:** Failed to initialize context.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` @@ -367,8 +395,8 @@ async function handleStatus( ): Promise { stream.progress("Checking context status..."); try { - const { stdout, stderr } = await runCtx(["status", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["status"], cwd, token); + const output = mergeOutput(stdout, stderr); stream.markdown("```\n" + output + "\n```"); } catch (err: unknown) { stream.markdown( @@ -380,13 +408,19 @@ async function handleStatus( async function handleAgent( stream: vscode.ChatResponseStream, + prompt: string, cwd: string, token: vscode.CancellationToken ): Promise { stream.progress("Generating AI-ready context packet..."); try { - const { stdout, stderr } = await runCtx(["agent", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const args = ["agent"]; + const budgetMatch = prompt.match(/(?:--budget\s+|budget\s+)(\d+)/); + if (budgetMatch) { + args.splice(1, 0, "--budget", budgetMatch[1]); + } + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); stream.markdown(output); } catch (err: unknown) { stream.markdown( @@ -403,8 +437,8 @@ async function handleDrift( ): Promise { stream.progress("Detecting context drift..."); try { - const { stdout, stderr } = await runCtx(["drift", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["drift"], cwd, token); + const output = mergeOutput(stdout, stderr); stream.markdown("```\n" + output + "\n```"); } catch (err: unknown) { stream.markdown( @@ -420,18 +454,74 @@ async function handleRecall( cwd: string, token: vscode.CancellationToken ): Promise { - stream.progress("Searching session history..."); - try { - const args = ["recall", "list", "--no-color"]; - if (prompt.trim()) { - args.push("--query", prompt.trim()); + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + const rest = parts.slice(1).join(" "); + + let args: string[]; + let progressMsg: string; + + switch (subcmd) { + case "show": + if (!rest) { + stream.markdown("**Usage:** `@ctx /recall show `"); + return { metadata: { command: "recall" } }; + } + args = ["recall", "show", rest]; + progressMsg = "Loading session details..."; + break; + case "export": + if (!rest) { + stream.markdown("**Usage:** `@ctx /recall export `"); + return { metadata: { command: "recall" } }; + } + args = ["recall", "export", rest]; + progressMsg = "Exporting session..."; + break; + case "lock": + if (!rest) { + stream.markdown("**Usage:** `@ctx /recall lock `"); + return { metadata: { command: "recall" } }; + } + args = ["recall", "lock", rest]; + progressMsg = "Locking session..."; + break; + case "unlock": + if (!rest) { + stream.markdown("**Usage:** `@ctx /recall unlock `"); + return { metadata: { command: "recall" } }; + } + args = ["recall", "unlock", rest]; + progressMsg = "Unlocking session..."; + break; + case "sync": + args = ["recall", "sync"]; + progressMsg = "Syncing recall database..."; + break; + case "list": + default: { + args = ["recall", "list"]; + progressMsg = "Searching session history..."; + const limitMatch = prompt.match(/(?:--limit\s+|limit\s+)(\d+)/); + if (limitMatch) { + args.push("--limit", limitMatch[1]); + } + const query = (subcmd === "list" ? rest : prompt.trim()).replace(/--limit\s+\d+/, "").replace(/limit\s+\d+/, "").trim(); + if (query) { + args.push("--query", query); + } + break; } + } + + stream.progress(progressMsg); + try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { - stream.markdown("No session history found."); + stream.markdown(subcmd === "list" || !subcmd ? "No session history found." : "No output."); } } catch (err: unknown) { stream.markdown( @@ -455,7 +545,6 @@ async function handleHook( if (!preview) { args.push("--write"); } - args.push("--no-color"); stream.progress( preview @@ -464,7 +553,7 @@ async function handleHook( ); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -495,7 +584,7 @@ async function handleAdd( if (!type) { stream.markdown( "**Usage:** `@ctx /add `\n\n" + - "Types: `task`, `decision`, `learning`\n\n" + + "Types: `task`, `decision`, `learning`, `convention`\n\n" + "Example: `@ctx /add task Implement user authentication`" ); return { metadata: { command: "add" } }; @@ -508,7 +597,7 @@ async function handleAdd( args.push(content); } const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -529,8 +618,8 @@ async function handleLoad( ): Promise { stream.progress("Loading assembled context..."); try { - const { stdout, stderr } = await runCtx(["load", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["load"], cwd, token); + const output = mergeOutput(stdout, stderr); stream.markdown(output); } catch (err: unknown) { stream.markdown( @@ -547,8 +636,8 @@ async function handleCompact( ): Promise { stream.progress("Compacting context..."); try { - const { stdout, stderr } = await runCtx(["compact", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["compact"], cwd, token); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -569,8 +658,8 @@ async function handleSync( ): Promise { stream.progress("Syncing context with codebase..."); try { - const { stdout, stderr } = await runCtx(["sync", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["sync"], cwd, token); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -602,11 +691,11 @@ async function handleComplete( stream.progress("Marking task as completed..."); try { const { stdout, stderr } = await runCtx( - ["complete", taskRef, "--no-color"], + ["complete", taskRef], cwd, token ); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -659,12 +748,11 @@ async function handleRemind( } break; } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -711,12 +799,11 @@ async function handleTasks( ); return { metadata: { command: "tasks" } }; } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -780,18 +867,37 @@ async function handlePad( args = ["pad", "mv", ...parts.slice(1)]; progressMsg = "Moving scratchpad entry..."; break; + case "resolve": + args = ["pad", "resolve"]; + progressMsg = "Resolving scratchpad conflicts..."; + break; + case "import": + if (!rest) { + stream.markdown("**Usage:** `@ctx /pad import `"); + return { metadata: { command: "pad" } }; + } + args = ["pad", "import", rest]; + progressMsg = "Importing scratchpad archive..."; + break; + case "export": + args = rest ? ["pad", "export", rest] : ["pad", "export"]; + progressMsg = "Exporting scratchpad..."; + break; + case "merge": + args = rest ? ["pad", "merge", rest] : ["pad", "merge"]; + progressMsg = "Merging scratchpads..."; + break; default: // No subcommand or unknown — list all entries args = ["pad"]; progressMsg = "Listing scratchpad..."; break; } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -846,12 +952,11 @@ async function handleNotify( break; } } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -892,28 +997,48 @@ async function handleSystem( args = ["system", "bootstrap"]; progressMsg = "Running bootstrap..."; break; + case "doctor": + args = ["doctor"]; + progressMsg = "Running diagnostics..."; + break; case "message": args = ["system", "message", ...parts.slice(1)]; + if (parts.length < 2 || !["list", "show", "edit", "reset"].includes(parts[1]?.toLowerCase())) { + args = ["system", "message", "list"]; + } progressMsg = "Managing hook messages..."; break; + case "stats": + args = ["system", "stats"]; + progressMsg = "Loading system stats..."; + break; + case "backup": + args = ["system", "backup"]; + progressMsg = "Running backup..."; + break; default: stream.markdown( "**Usage:** `@ctx /system `\n\n" + "| Subcommand | Description |\n" + "|------------|-------------|\n" + "| `resources` | Show system resource usage |\n" + + "| `doctor` | Diagnose context health |\n" + "| `bootstrap` | Print context location for AI agents |\n" + - "| `message list\|show\|edit\|reset` | Manage hook messages |\n\n" + - "Example: `@ctx /system resources` or `@ctx /system bootstrap`" + "| `stats` | Show session and context stats |\n" + + "| `backup` | Backup context data |\n" + + "| `message list` | List hook message templates |\n" + + "| `message show ` | Show a hook message |\n" + + "| `message edit ` | Edit a hook message |\n" + + "| `message reset ` | Reset a hook message |\n\n" + + "Example: `@ctx /system resources` or `@ctx /system message list`" ); return { metadata: { command: "system" } }; } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -927,159 +1052,1516 @@ async function handleSystem( return { metadata: { command: "system" } }; } -async function handleFreeform( - request: vscode.ChatRequest, +async function handleWrapup( stream: vscode.ChatResponseStream, cwd: string, token: vscode.CancellationToken ): Promise { - const prompt = request.prompt.trim().toLowerCase(); + stream.progress("Generating session wrap-up..."); + try { + // Gather status + drift in parallel for a comprehensive wrap-up + const [statusResult, driftResult] = await Promise.all([ + runCtx(["status"], cwd, token), + runCtx(["drift"], cwd, token), + ]); + const statusOutput = mergeOutput(statusResult.stdout, statusResult.stderr); + const driftOutput = mergeOutput(driftResult.stdout, driftResult.stderr); + + stream.markdown("## Session Wrap-up\n\n"); + stream.markdown("### Context Status\n```\n" + statusOutput + "\n```\n\n"); + stream.markdown("### Drift Check\n```\n" + driftOutput + "\n```\n\n"); + stream.markdown( + "**Before closing:** Review any open tasks in `.context/TASKS.md`. " + + "Record decisions or learnings with `@ctx /add decision ...` or `@ctx /add learning ...`.\n" + ); - // Try to infer intent from natural language - if (prompt.includes("init")) { - return handleInit(stream, cwd, token); - } - if (prompt.includes("status")) { - return handleStatus(stream, cwd, token); - } - if (prompt.includes("drift")) { - return handleDrift(stream, cwd, token); - } - if (prompt.includes("recall") || prompt.includes("session") || prompt.includes("history")) { - return handleRecall(stream, request.prompt, cwd, token); - } - if (prompt.includes("complete") || prompt.includes("done") || prompt.includes("finish")) { - return handleComplete(stream, request.prompt, cwd, token); - } - if (prompt.includes("remind")) { - return handleRemind(stream, request.prompt, cwd, token); - } - if (prompt.includes("task")) { - return handleTasks(stream, request.prompt, cwd, token); - } - if (prompt.includes("pad") || prompt.includes("scratchpad") || prompt.includes("scratch")) { - return handlePad(stream, request.prompt, cwd, token); - } - if (prompt.includes("notify") || prompt.includes("webhook")) { - return handleNotify(stream, request.prompt, cwd, token); - } - if (prompt.includes("system") || prompt.includes("resource") || prompt.includes("bootstrap")) { - return handleSystem(stream, request.prompt, cwd, token); - } + // 2.15: Journal audit + try { + const stateDir = path.join(cwd, ".context", "state"); + if (fs.existsSync(stateDir)) { + const journalFiles = fs.readdirSync(stateDir).filter((f) => f.includes("journal") || f.includes("event")); + if (journalFiles.length > 0) { + const latest = journalFiles.sort().slice(-1)[0]; + const content = fs.readFileSync(path.join(stateDir, latest), "utf-8"); + const lines = content.split("\n").filter((l) => l.trim()); + stream.markdown(`\n### Journal\n${lines.length} entries in \`${latest}\`. `); + const today = new Date().toISOString().split("T")[0]; + if (!content.includes(today)) { + stream.markdown("**No entries today.** Consider logging your work.\n"); + } else { + stream.markdown("Today's entries present.\n"); + } + } + } + } catch { /* non-fatal */ } - // Default: show help with available commands - stream.markdown( - "## ctx — Persistent Context for AI\n\n" + - "Available commands:\n\n" + - "| Command | Description |\n" + - "|---------|-------------|\n" + - "| `/init` | Initialize `.context/` directory |\n" + - "| `/status` | Show context summary |\n" + - "| `/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" + - "| `/add` | Add task, decision, or learning |\n" + - "| `/load` | Output assembled context |\n" + - "| `/compact` | Archive completed tasks |\n" + - "| `/sync` | Reconcile context with codebase |\n" + - "| `/complete` | Mark a task as completed |\n" + - "| `/remind` | Manage session reminders |\n" + - "| `/tasks` | Archive or snapshot tasks |\n" + - "| `/pad` | Encrypted scratchpad |\n" + - "| `/notify` | Webhook notifications |\n" + - "| `/system` | System diagnostics |\n\n" + - "Example: `@ctx /status` or `@ctx /add task Fix login bug`" - ); - return { metadata: { command: "help" } }; + // 2.18: Memory drift + try { + const memDir = path.join(cwd, ".context", "memory"); + if (fs.existsSync(memDir)) { + const memFiles = fs.readdirSync(memDir).filter((f) => f.endsWith(".md")); + if (memFiles.length > 0) { + const contextFiles = ["DECISIONS.md", "LEARNINGS.md", "CONVENTIONS.md", "TASKS.md"]; + const drifts: string[] = []; + for (const memFile of memFiles) { + const memStat = fs.statSync(path.join(memDir, memFile)); + for (const ctxFile of contextFiles) { + const ctxPath = path.join(cwd, ".context", ctxFile); + if (fs.existsSync(ctxPath)) { + const ctxStat = fs.statSync(ctxPath); + if (memStat.mtimeMs < ctxStat.mtimeMs - 86400000) { + drifts.push(`\`memory/${memFile}\` older than \`${ctxFile}\``); + } + } + } + } + if (drifts.length > 0) { + stream.markdown("\n### Memory Drift\n" + drifts.map((d) => `- ${d}`).join("\n") + "\n"); + } + } + } + } catch { /* non-fatal */ } + + // Record session end + runCtx(["system", "session-event", "--type", "end", "--caller", "vscode"], cwd).catch(() => {}); + } catch (err: unknown) { + stream.markdown( + `**Error:** Wrap-up failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "wrapup" } }; } -const handler: vscode.ChatRequestHandler = async ( - request: vscode.ChatRequest, - _context: vscode.ChatContext, +async function handleRemember( stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, token: vscode.CancellationToken -): Promise => { - const cwd = getWorkspaceRoot(); - if (!cwd) { +): Promise { + stream.progress("Loading recent sessions..."); + try { + const args = ["recall", "list"]; + const limitMatch = prompt.match(/(?:--limit\s+|limit\s+)(\d+)/); + args.push("--limit", limitMatch ? limitMatch[1] : "3"); + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown("## Recent Sessions\n\n```\n" + output + "\n```"); + } else { + stream.markdown("No recent sessions found. Start working and sessions will be recorded."); + } + } catch (err: unknown) { stream.markdown( - "**Error:** No workspace folder is open. Open a project folder first." + `**Error:** Failed to load sessions.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); - return { metadata: { command: request.command || "none" } }; } + return { metadata: { command: "remember" } }; +} - // Auto-bootstrap: ensure ctx binary is available before any command +async function handleNext( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Finding next task..."); try { - stream.progress("Checking ctx installation..."); - await bootstrap(); + const tasksPath = path.join(cwd, ".context", "TASKS.md"); + if (!fs.existsSync(tasksPath)) { + stream.markdown("No `.context/TASKS.md` found. Add tasks with `@ctx /add task ...`."); + return { metadata: { command: "next" } }; + } + const content = fs.readFileSync(tasksPath, "utf-8"); + const lines = content.split("\n"); + const openTasks = lines.filter((l) => /^\s*-\s*\[ \]/.test(l)); + if (openTasks.length === 0) { + stream.markdown("All tasks completed! Add new tasks with `@ctx /add task ...`."); + } else { + stream.markdown("## Next Task\n\n" + openTasks[0].trim() + "\n"); + if (openTasks.length > 1) { + stream.markdown( + `\n*${openTasks.length - 1} more open task(s) remaining.*` + ); + } + } } catch (err: unknown) { stream.markdown( - `**Error:** ctx CLI not found and auto-install failed.\n\n` + - `\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`\n\n` + - `Install manually: \`go install github.com/ActiveMemory/ctx/cmd/ctx@latest\` ` + - `or download from [GitHub Releases](https://github.com/${GITHUB_REPO}/releases).` + `**Error:** Failed to read tasks.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); - return { metadata: { command: request.command || "none" } }; } + return { metadata: { command: "next" } }; +} - switch (request.command) { - case "init": - return handleInit(stream, cwd, token); - case "status": - return handleStatus(stream, cwd, token); - case "agent": - return handleAgent(stream, cwd, token); - case "drift": - return handleDrift(stream, cwd, token); - case "recall": - return handleRecall(stream, request.prompt, cwd, token); - case "hook": - return handleHook(stream, request.prompt, cwd, token); - case "add": - return handleAdd(stream, request.prompt, cwd, token); - case "load": - return handleLoad(stream, cwd, token); - case "compact": - return handleCompact(stream, cwd, token); - case "sync": - return handleSync(stream, cwd, token); - case "complete": - return handleComplete(stream, request.prompt, cwd, token); - case "remind": - return handleRemind(stream, request.prompt, cwd, token); - case "tasks": - return handleTasks(stream, request.prompt, cwd, token); - case "pad": - return handlePad(stream, request.prompt, cwd, token); - case "notify": - return handleNotify(stream, request.prompt, cwd, token); - case "system": - return handleSystem(stream, request.prompt, cwd, token); - default: - return handleFreeform(request, stream, cwd, token); +async function handleBrainstorm( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Loading ideas..."); + try { + const ideasDir = path.join(cwd, "ideas"); + if (!fs.existsSync(ideasDir)) { + stream.markdown( + "No `ideas/` directory found. Run `@ctx /init` first, then add ideas to `ideas/README.md`." + ); + return { metadata: { command: "brainstorm" } }; + } + const readmePath = path.join(ideasDir, "README.md"); + if (fs.existsSync(readmePath)) { + const content = fs.readFileSync(readmePath, "utf-8").trim(); + stream.markdown("## Current Ideas\n\n" + content + "\n"); + } else { + stream.markdown("Ideas directory exists but `ideas/README.md` is empty.\n"); + } + // List any other files in ideas/ + const files = fs.readdirSync(ideasDir).filter((f) => f !== "README.md" && f.endsWith(".md")); + if (files.length > 0) { + stream.markdown( + "\n### Idea Files\n" + files.map((f) => `- \`ideas/${f}\``).join("\n") + "\n" + ); + } + if (prompt.trim()) { + stream.markdown( + "\n---\n\nTo develop **" + prompt.trim() + "** into a spec, create `specs/" + + prompt.trim().toLowerCase().replace(/\s+/g, "-") + ".md` with your design." + ); + } else { + stream.markdown( + "\n---\nTo develop an idea into a spec, run `@ctx /brainstorm `." + ); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to load ideas.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); } -}; - -export function activate(extensionContext: vscode.ExtensionContext) { - // Store extension context for auto-bootstrap binary downloads - extensionCtx = extensionContext; + return { metadata: { command: "brainstorm" } }; +} - // Kick off background bootstrap — don't block activation - bootstrap().catch(() => { - // Errors will surface when user invokes a command - }); +async function handleReflect( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Reflecting on session..."); + try { + const [statusResult, driftResult] = await Promise.all([ + runCtx(["status"], cwd, token), + runCtx(["drift"], cwd, token), + ]); + const statusOutput = mergeOutput(statusResult.stdout, statusResult.stderr); + const driftOutput = mergeOutput(driftResult.stdout, driftResult.stderr); + + stream.markdown("## Session Reflection\n\n"); + stream.markdown("### Current State\n```\n" + statusOutput + "\n```\n\n"); + if (driftOutput) { + stream.markdown("### Drift Detected\n```\n" + driftOutput + "\n```\n\n"); + } + stream.markdown( + "### Worth Persisting?\n\n" + + "Consider what happened this session:\n" + + "- **Decision?** Did you make a design choice? → `@ctx /add decision ...`\n" + + "- **Learning?** Hit a gotcha or discovered something? → `@ctx /add learning ...`\n" + + "- **Convention?** Established a pattern? → `@ctx /add convention ...`\n" + + "- **Task?** Identified work for later? → `@ctx /add task ...`\n" + ); - const participant = vscode.chat.createChatParticipant( - PARTICIPANT_ID, - handler - ); - participant.iconPath = vscode.Uri.joinPath( - extensionContext.extensionUri, - "icon.png" - ); + // 2.15: Journal audit — check journal completeness + try { + const stateDir = path.join(cwd, ".context", "state"); + if (fs.existsSync(stateDir)) { + const journalFiles = fs.readdirSync(stateDir).filter((f) => f.includes("journal") || f.includes("event")); + if (journalFiles.length > 0) { + const latest = journalFiles.sort().slice(-1)[0]; + const content = fs.readFileSync(path.join(stateDir, latest), "utf-8"); + const lines = content.split("\n").filter((l) => l.trim()); + stream.markdown(`\n### Journal\n${lines.length} entries in \`${latest}\`. `); + const today = new Date().toISOString().split("T")[0]; + if (!content.includes(today)) { + stream.markdown("**No entries today.** Consider logging your work.\n"); + } else { + stream.markdown("Today's entries present.\n"); + } + } + } + } catch { /* non-fatal */ } - participant.followupProvider = { - provideFollowups( + // 2.18: Memory drift — compare memory with context files + try { + const memDir = path.join(cwd, ".context", "memory"); + if (fs.existsSync(memDir)) { + const memFiles = fs.readdirSync(memDir).filter((f) => f.endsWith(".md")); + if (memFiles.length > 0) { + const contextFiles = ["DECISIONS.md", "LEARNINGS.md", "CONVENTIONS.md", "TASKS.md"]; + const drifts: string[] = []; + for (const memFile of memFiles) { + const memStat = fs.statSync(path.join(memDir, memFile)); + for (const ctxFile of contextFiles) { + const ctxPath = path.join(cwd, ".context", ctxFile); + if (fs.existsSync(ctxPath)) { + const ctxStat = fs.statSync(ctxPath); + // Memory older than context by 24+ hours = potentially stale + if (memStat.mtimeMs < ctxStat.mtimeMs - 86400000) { + drifts.push(`\`memory/${memFile}\` older than \`${ctxFile}\``); + } + } + } + } + if (drifts.length > 0) { + stream.markdown("\n### Memory Drift\n" + drifts.map((d) => `- ${d}`).join("\n") + "\n"); + } + } + } + } catch { /* non-fatal */ } + } catch (err: unknown) { + stream.markdown( + `**Error:** Reflection failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "reflect" } }; +} + +async function handleSpec( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Loading specs..."); + try { + const specsDir = path.join(cwd, "specs"); + const tplDir = path.join(specsDir, "tpl"); + if (!prompt.trim()) { + const specs = fs.existsSync(specsDir) ? fs.readdirSync(specsDir).filter((f) => f.endsWith(".md")) : []; + const templates = fs.existsSync(tplDir) ? fs.readdirSync(tplDir).filter((f) => f.endsWith(".md")) : []; + stream.markdown("## Specs\n\n"); + if (specs.length) { + stream.markdown("### Existing\n" + specs.map((f) => `- \`specs/${f}\``).join("\n") + "\n\n"); + } + if (templates.length) { + stream.markdown("### Templates\n" + templates.map((f) => `- \`specs/tpl/${f}\``).join("\n") + "\n\nScaffold: `@ctx /spec `\n"); + } else { + stream.markdown("No templates in `specs/tpl/`. Create one to enable scaffolding.\n"); + } + } else { + const name = prompt.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9\-]/g, ""); + const target = path.join(specsDir, `${name}.md`); + if (fs.existsSync(target)) { + const content = fs.readFileSync(target, "utf-8"); + stream.markdown(`\`specs/${name}.md\` exists:\n\n\`\`\`markdown\n${content}\n\`\`\``); + } else { + const templates = fs.existsSync(tplDir) ? fs.readdirSync(tplDir).filter((f) => f.endsWith(".md")) : []; + let content: string; + if (templates.length > 0) { + content = fs.readFileSync(path.join(tplDir, templates[0]), "utf-8") + .replace(/\{\{name\}\}/gi, name) + .replace(/\{\{title\}\}/gi, prompt.trim()); + } else { + content = `# ${prompt.trim()}\n\n## Problem\n\n## Proposal\n\n## Implementation\n\n## Verification\n`; + } + if (!fs.existsSync(specsDir)) { fs.mkdirSync(specsDir, { recursive: true }); } + fs.writeFileSync(target, content, "utf-8"); + stream.markdown(`Created \`specs/${name}.md\`.\n\n\`\`\`markdown\n${content}\n\`\`\``); + } + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "spec" } }; +} + +async function handleImplement( + stream: vscode.ChatResponseStream, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Loading implementation plan..."); + try { + const planPath = path.join(cwd, "IMPLEMENTATION_PLAN.md"); + if (!fs.existsSync(planPath)) { + stream.markdown("No `IMPLEMENTATION_PLAN.md` found in project root."); + return { metadata: { command: "implement" } }; + } + const content = fs.readFileSync(planPath, "utf-8"); + const lines = content.split("\n"); + const done = lines.filter((l) => /^\s*-\s*\[x\]/i.test(l)).length; + const open = lines.filter((l) => /^\s*-\s*\[ \]/.test(l)).length; + const total = done + open; + if (total > 0) { + stream.markdown(`## Implementation Plan (${done}/${total} steps done)\n\n`); + const nextStep = lines.find((l) => /^\s*-\s*\[ \]/.test(l)); + if (nextStep) { + stream.markdown("**Next step:** " + nextStep.replace(/^\s*-\s*\[ \]\s*/, "").trim() + "\n\n---\n\n"); + } + } else { + stream.markdown("## Implementation Plan\n\n"); + } + stream.markdown(content); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "implement" } }; +} + +async function handleVerify( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Running verification checks..."); + try { + const results: string[] = []; + try { + const { stdout, stderr } = await runCtx(["doctor"], cwd, token); + results.push("### Context Health\n```\n" + mergeOutput(stdout, stderr) + "\n```"); + } catch (err: unknown) { + results.push("### Context Health\n```\nFailed: " + (err instanceof Error ? err.message : String(err)) + "\n```"); + } + try { + const { stdout, stderr } = await runCtx(["drift"], cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { results.push("### Drift\n```\n" + output + "\n```"); } + } catch { /* non-fatal */ } + stream.markdown("## Verification Report\n\n" + results.join("\n\n")); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "verify" } }; +} + +async function handleMap( + stream: vscode.ChatResponseStream, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Mapping dependencies..."); + try { + const results: string[] = []; + const gomodPath = path.join(cwd, "go.mod"); + if (fs.existsSync(gomodPath)) { + const content = fs.readFileSync(gomodPath, "utf-8"); + const moduleLine = content.match(/^module\s+(.+)$/m); + const requires = content.match(/require\s*\(([\s\S]*?)\)/); + results.push("### Go Module: " + (moduleLine ? moduleLine[1] : "unknown")); + if (requires) { + const deps = requires[1].trim().split("\n").filter((l) => l.trim() && !l.trim().startsWith("//")); + results.push(`${deps.length} dependencies:\n\`\`\`\n${deps.map((d) => d.trim()).join("\n")}\n\`\`\``); + } + } + const pkgPath = path.join(cwd, "package.json"); + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); + const deps = Object.keys(pkg.dependencies || {}); + const devDeps = Object.keys(pkg.devDependencies || {}); + results.push("### Node Package: " + (pkg.name || "unknown")); + if (deps.length) { results.push(`Dependencies: ${deps.join(", ")}`); } + if (devDeps.length) { results.push(`Dev: ${devDeps.join(", ")}`); } + } + if (results.length === 0) { + stream.markdown("No `go.mod` or `package.json` found."); + } else { + stream.markdown("## Dependency Map\n\n" + results.join("\n\n")); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "map" } }; +} + +async function handlePromptTpl( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Loading prompt templates..."); + try { + const promptsDir = path.join(cwd, ".context", "prompts"); + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + const rest = parts.slice(1).join(" "); + + if (subcmd === "add") { + if (!rest) { + stream.markdown("**Usage:** `@ctx /prompt add `\n\nAdds a file as a prompt template."); + return { metadata: { command: "prompt" } }; + } + const args = ["prompt", "add", rest]; + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : `Template **${rest}** added.`); + return { metadata: { command: "prompt" } }; + } + + if (subcmd === "rm" || subcmd === "remove" || subcmd === "delete") { + if (!rest) { + stream.markdown("**Usage:** `@ctx /prompt rm `"); + return { metadata: { command: "prompt" } }; + } + const args = ["prompt", "rm", rest]; + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : `Template **${rest}** removed.`); + return { metadata: { command: "prompt" } }; + } + + if (!fs.existsSync(promptsDir)) { + stream.markdown("No `.context/prompts/` directory found."); + return { metadata: { command: "prompt" } }; + } + const files = fs.readdirSync(promptsDir).filter((f) => f.endsWith(".md")); + if (!prompt.trim()) { + if (files.length === 0) { + stream.markdown("`.context/prompts/` is empty. Add prompt templates with `@ctx /prompt add `."); + } else { + stream.markdown("## Prompt Templates\n\n" + files.map((f) => `- \`${f}\``).join("\n") + + "\n\nView: `@ctx /prompt `\nAdd: `@ctx /prompt add `\nRemove: `@ctx /prompt rm `"); + } + } else { + const name = prompt.trim(); + const match = files.find((f) => f.toLowerCase().includes(name.toLowerCase())); + if (match) { + const content = fs.readFileSync(path.join(promptsDir, match), "utf-8"); + stream.markdown(`## ${match}\n\n${content}`); + } else { + stream.markdown(`No template matching "${name}". Available: ${files.join(", ")}`); + } + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "prompt" } }; +} + +async function handleBlog( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Drafting blog post..."); + try { + const sections: string[] = []; + const decisionsPath = path.join(cwd, ".context", "DECISIONS.md"); + const learningsPath = path.join(cwd, ".context", "LEARNINGS.md"); + if (fs.existsSync(decisionsPath)) { + const entries = fs.readFileSync(decisionsPath, "utf-8").split("\n").filter((l) => l.startsWith("- ")); + if (entries.length) { sections.push("## Key Decisions\n\n" + entries.slice(-5).join("\n")); } + } + if (fs.existsSync(learningsPath)) { + const entries = fs.readFileSync(learningsPath, "utf-8").split("\n").filter((l) => l.startsWith("- ")); + if (entries.length) { sections.push("## Lessons Learned\n\n" + entries.slice(-5).join("\n")); } + } + const title = prompt.trim() || "Untitled Post"; + const date = new Date().toISOString().split("T")[0]; + stream.markdown( + `# Blog Draft: ${title}\n\n*Date: ${date}*\n\n` + + (sections.length ? sections.join("\n\n") : "No decisions or learnings to draw from.") + + "\n\n---\n*Edit and refine this draft, then save to `docs/blog/`.*" + ); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "blog" } }; +} + +async function handleChangelog( + stream: vscode.ChatResponseStream, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Generating changelog..."); + try { + const result = await new Promise((resolve, reject) => { + execFile("git", ["log", "--oneline", "--no-decorate", "-20"], { cwd }, (err, stdout) => { + if (err) { reject(err); } else { resolve(stdout); } + }); + }); + if (result.trim()) { + stream.markdown("## Recent Commits\n\n```\n" + result.trim() + "\n```\n\n" + + "Use these to draft release notes or a changelog blog post."); + } else { + stream.markdown("No commits found."); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "changelog" } }; +} + +async function handleCheckLinks( + stream: vscode.ChatResponseStream, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Checking links in context files..."); + try { + const contextDir = path.join(cwd, ".context"); + if (!fs.existsSync(contextDir)) { + stream.markdown("No `.context/` directory found."); + return { metadata: { command: "check-links" } }; + } + const files = fs.readdirSync(contextDir).filter((f) => f.endsWith(".md")); + const broken: string[] = []; + let total = 0; + for (const file of files) { + const content = fs.readFileSync(path.join(contextDir, file), "utf-8"); + const linkRegex = /\[([^\]]*)\]\(([^)]+)\)/g; + let m; + while ((m = linkRegex.exec(content)) !== null) { + const target = m[2]; + if (target.startsWith("http://") || target.startsWith("https://")) { continue; } + total++; + const resolved = path.resolve(contextDir, target); + if (!fs.existsSync(resolved)) { + broken.push(`- \`${file}\`: [${m[1]}](${target}) \u2192 not found`); + } + } + } + stream.markdown(`## Link Check\n\nChecked ${total} local links in ${files.length} context files.\n\n`); + if (broken.length) { + stream.markdown("### Broken Links\n" + broken.join("\n") + "\n"); + } else { + stream.markdown("All local links are valid.\n"); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "check-links" } }; +} + +async function handleJournal( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Checking journal..."); + try { + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + const args = ["journal"]; + let progressOverride: string | undefined; + if (subcmd === "site") { + args.push("site", ...parts.slice(1)); + progressOverride = "Exporting journal to static site..."; + } else if (subcmd === "obsidian") { + args.push("obsidian", ...parts.slice(1)); + progressOverride = "Exporting journal to Obsidian..."; + } else if (prompt.trim()) { + args.push(...parts); + } + if (progressOverride) { stream.progress(progressOverride); } + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { stream.markdown("```\n" + output + "\n```"); } + else { stream.markdown("No journal output."); } + } catch { + try { + const stateDir = path.join(cwd, ".context", "state"); + if (fs.existsSync(stateDir)) { + const files = fs.readdirSync(stateDir).filter((f) => f.includes("journal") || f.includes("event")); + if (files.length) { + stream.markdown("## Journal Entries\n\n"); + for (const f of files.slice(-3)) { + try { + const content = fs.readFileSync(path.join(stateDir, f), "utf-8").trim(); + const preview = content.split("\n").slice(0, 10).join("\n"); + stream.markdown(`### ${f}\n\`\`\`\n${preview}\n\`\`\`\n\n`); + } catch { /* skip unreadable */ } + } + } else { + stream.markdown("No journal or event log files found in `.context/state/`."); + } + } else { + stream.markdown("No `.context/state/` directory found."); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + } + return { metadata: { command: "journal" } }; +} + +async function handleConsolidate( + stream: vscode.ChatResponseStream, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Scanning for overlapping entries..."); + try { + const contextDir = path.join(cwd, ".context"); + const targetFiles = ["DECISIONS.md", "LEARNINGS.md", "CONVENTIONS.md", "TASKS.md"]; + const findings: string[] = []; + for (const file of targetFiles) { + const filePath = path.join(contextDir, file); + if (!fs.existsSync(filePath)) { continue; } + const entries = fs.readFileSync(filePath, "utf-8").split("\n") + .filter((l) => /^\s*-\s/.test(l)) + .map((l) => l.replace(/^\s*-\s*(\[.\]\s*)?/, "").trim().toLowerCase()); + const seen = new Map(); + for (const entry of entries) { + const key = entry.replace(/[^a-z0-9\s]/g, "").replace(/\s+/g, " "); + seen.set(key, (seen.get(key) || 0) + 1); + } + const dupes = [...seen.entries()].filter(([, count]) => count > 1); + if (dupes.length) { + findings.push(`### ${file}\n` + dupes.map(([text, count]) => `- "${text}" (\u00d7${count})`).join("\n")); + } + } + stream.markdown("## Consolidation Report\n\n"); + if (findings.length) { + stream.markdown(findings.join("\n\n") + "\n\nReview and merge manually."); + } else { + stream.markdown("No duplicate entries found across context files."); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "consolidate" } }; +} + +async function handleAudit( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Running alignment audit..."); + try { + const { stdout: driftOut, stderr: driftErr } = await runCtx(["drift"], cwd, token); + stream.markdown("## Alignment Audit\n\n### Drift\n```\n" + mergeOutput(driftOut, driftErr) + "\n```\n\n"); + const convPath = path.join(cwd, ".context", "CONVENTIONS.md"); + if (fs.existsSync(convPath)) { + const entries = fs.readFileSync(convPath, "utf-8").split("\n").filter((l) => /^\s*-\s/.test(l)); + stream.markdown(`### Conventions: ${entries.length} documented\n\n`); + if (entries.length === 0) { + stream.markdown("**Warning:** No conventions documented. Run `@ctx /add convention ...`\n"); + } + } + const archPath = path.join(cwd, ".context", "ARCHITECTURE.md"); + if (fs.existsSync(archPath)) { + const lines = fs.readFileSync(archPath, "utf-8").split("\n").filter((l) => l.trim() && !l.startsWith("#")); + if (lines.length < 3) { + stream.markdown("**Warning:** `ARCHITECTURE.md` appears sparse. Consider documenting system structure.\n"); + } + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "audit" } }; +} + +async function handleWorktree( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Managing worktrees..."); + try { + const subcmd = prompt.trim().split(/\s+/)[0]?.toLowerCase() || "list"; + if (subcmd === "list" || !prompt.trim()) { + const result = await new Promise((resolve, reject) => { + execFile("git", ["worktree", "list"], { cwd }, (err, stdout) => { + if (err) { reject(err); } else { resolve(stdout); } + }); + }); + stream.markdown("## Git Worktrees\n\n```\n" + result.trim() + "\n```\n\nCreate: `@ctx /worktree add `"); + } else if (subcmd === "add") { + const branch = prompt.trim().split(/\s+/).slice(1).join("-").replace(/[^a-zA-Z0-9_\-\/]/g, ""); + if (!branch) { + stream.markdown("Usage: `@ctx /worktree add `"); + } else { + const worktreePath = path.join(path.dirname(cwd), path.basename(cwd) + "-" + branch); + const result = await new Promise((resolve, reject) => { + execFile("git", ["worktree", "add", worktreePath, "-b", branch], { cwd }, (err, stdout, stderr) => { + if (err) { reject(err); } else { resolve(stdout + stderr); } + }); + }); + stream.markdown(`Worktree created at \`${worktreePath}\`.\n\n\`\`\`\n${result.trim()}\n\`\`\``); + } + } else { + stream.markdown("**Usage:** `@ctx /worktree [list|add ]`"); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "worktree" } }; +} + +async function handlePause( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Pausing session..."); + try { + const stateDir = path.join(cwd, ".context", "state"); + if (!fs.existsSync(stateDir)) { fs.mkdirSync(stateDir, { recursive: true }); } + const { stdout } = await runCtx(["status"], cwd, token); + const state = { paused_at: new Date().toISOString(), status: stdout.trim(), cwd }; + fs.writeFileSync(path.join(stateDir, "paused-session.json"), JSON.stringify(state, null, 2), "utf-8"); + stream.markdown("Session paused. State saved to `.context/state/paused-session.json`.\n\nResume with `@ctx /resume`."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "pause" } }; +} + +async function handleResume( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Resuming session..."); + try { + const statePath = path.join(cwd, ".context", "state", "paused-session.json"); + if (!fs.existsSync(statePath)) { + stream.markdown("No paused session found. Start fresh with `@ctx /status`."); + return { metadata: { command: "resume" } }; + } + const state = JSON.parse(fs.readFileSync(statePath, "utf-8")); + stream.markdown("## Resuming Session\n\n" + `Paused at: ${state.paused_at}\n\n` + + "### Status at pause\n```\n" + state.status + "\n```\n\n"); + try { + const { stdout } = await runCtx(["status"], cwd, token); + stream.markdown("### Current Status\n```\n" + stdout.trim() + "\n```\n"); + } catch { /* non-fatal */ } + fs.unlinkSync(statePath); + stream.markdown("\nSession resumed. Pause file removed."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "resume" } }; +} + +async function handleMemory( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + const rest = parts.slice(1).join(" "); + + let args: string[]; + let progressMsg: string; + + switch (subcmd) { + case "sync": + args = ["memory", "sync"]; + progressMsg = "Syncing memory bridge..."; + break; + case "status": + args = ["memory", "status"]; + progressMsg = "Checking memory bridge status..."; + break; + case "diff": + args = ["memory", "diff"]; + progressMsg = "Comparing memory with context..."; + break; + case "import": + args = rest ? ["memory", "import", rest] : ["memory", "import"]; + progressMsg = "Importing memory..."; + break; + case "publish": + args = rest ? ["memory", "publish", rest] : ["memory", "publish"]; + progressMsg = "Publishing memory..."; + break; + case "unpublish": + args = rest ? ["memory", "unpublish", rest] : ["memory", "unpublish"]; + progressMsg = "Unpublishing memory..."; + break; + default: + stream.markdown( + "**Usage:** `@ctx /memory `\n\n" + + "| Subcommand | Description |\n" + + "|------------|-------------|\n" + + "| `sync` | Sync Claude Code memory bridge |\n" + + "| `status` | Show memory bridge status |\n" + + "| `diff` | Compare memory with context files |\n" + + "| `import` | Import from Claude Code memory |\n" + + "| `publish` | Publish context to Claude Code memory |\n" + + "| `unpublish` | Remove published memory |\n\n" + + "Example: `@ctx /memory sync` or `@ctx /memory diff`" + ); + return { metadata: { command: "memory" } }; + } + + stream.progress(progressMsg); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("No output."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Memory command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "memory" } }; +} + +async function handleDecisions( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const subcmd = prompt.trim().split(/\s+/)[0]?.toLowerCase(); + + if (subcmd === "reindex") { + stream.progress("Reindexing decisions..."); + try { + const { stdout, stderr } = await runCtx(["decisions", "reindex"], cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : "Decision index rebuilt."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + } else { + stream.progress("Loading decisions..."); + try { + const { stdout, stderr } = await runCtx(["decisions"], cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : "No decisions found."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + } + return { metadata: { command: "decisions" } }; +} + +async function handleLearnings( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const subcmd = prompt.trim().split(/\s+/)[0]?.toLowerCase(); + + if (subcmd === "reindex") { + stream.progress("Reindexing learnings..."); + try { + const { stdout, stderr } = await runCtx(["learnings", "reindex"], cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : "Learning index rebuilt."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + } else { + stream.progress("Loading learnings..."); + try { + const { stdout, stderr } = await runCtx(["learnings"], cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : "No learnings found."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + } + return { metadata: { command: "learnings" } }; +} + +async function handleConfig( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + const rest = parts.slice(1).join(" "); + + let args: string[]; + let progressMsg: string; + + switch (subcmd) { + case "switch": + if (!rest) { + stream.markdown("**Usage:** `@ctx /config switch `\n\nExample: `@ctx /config switch dev`"); + return { metadata: { command: "config" } }; + } + args = ["config", "switch", rest]; + progressMsg = `Switching to profile "${rest}"...`; + break; + case "status": + args = ["config", "status"]; + progressMsg = "Checking config status..."; + break; + case "schema": + args = ["config", "schema"]; + progressMsg = "Loading config schema..."; + break; + default: + stream.markdown( + "**Usage:** `@ctx /config `\n\n" + + "| Subcommand | Description |\n" + + "|------------|-------------|\n" + + "| `switch ` | Switch to a config profile |\n" + + "| `status` | Show current config profile |\n" + + "| `schema` | Show config schema |\n\n" + + "Example: `@ctx /config switch dev`" + ); + return { metadata: { command: "config" } }; + } + + stream.progress(progressMsg); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("No output."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Config command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "config" } }; +} + +async function handlePermissions( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const subcmd = prompt.trim().split(/\s+/)[0]?.toLowerCase(); + + let args: string[]; + let progressMsg: string; + + switch (subcmd) { + case "snapshot": + args = ["permissions", "snapshot"]; + progressMsg = "Saving permissions snapshot..."; + break; + case "restore": + args = ["permissions", "restore"]; + progressMsg = "Restoring permissions..."; + break; + default: + stream.markdown( + "**Usage:** `@ctx /permissions `\n\n" + + "| Subcommand | Description |\n" + + "|------------|-------------|\n" + + "| `snapshot` | Backup current Claude settings |\n" + + "| `restore` | Restore settings from backup |\n\n" + + "Example: `@ctx /permissions snapshot`" + ); + return { metadata: { command: "permissions" } }; + } + + stream.progress(progressMsg); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown(subcmd === "snapshot" ? "Permissions snapshot saved." : "Permissions restored."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Permissions command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "permissions" } }; +} + +async function handleChanges( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Checking what changed..."); + try { + const args = ["changes"]; + const sinceMatch = prompt.match(/--since\s+(\S+)/); + if (sinceMatch) { + args.push("--since", sinceMatch[1]); + } + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("No changes detected since last session."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to check changes.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "changes" } }; +} + +async function handleDeps( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Analyzing dependencies..."); + try { + const args = ["deps"]; + const formatMatch = prompt.match(/--format\s+(\S+)/); + if (formatMatch) { + args.push("--format", formatMatch[1]); + } + if (prompt.includes("--external")) { + args.push("--external"); + } + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown(output); + } else { + stream.markdown("No dependency information available."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to analyze dependencies.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "deps" } }; +} + +async function handleGuide( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Loading guide..."); + try { + const args = ["guide"]; + if (prompt.includes("--skills")) { + args.push("--skills"); + } else if (prompt.includes("--commands")) { + args.push("--commands"); + } + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown(output); + } else { + stream.markdown("No guide output."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to load guide.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "guide" } }; +} + +async function handleReindex( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Regenerating indices..."); + try { + const { stdout, stderr } = await runCtx(["reindex"], cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("Indices regenerated for DECISIONS.md and LEARNINGS.md."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to reindex.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "reindex" } }; +} + +async function handleWhy( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Loading philosophy..."); + try { + const args = ["why"]; + if (prompt.trim()) { + args.push(prompt.trim()); + } + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown(output); + } else { + stream.markdown("No philosophy content available."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to load philosophy.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "why" } }; +} + +async function handleFreeform( + request: vscode.ChatRequest, + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + const prompt = request.prompt.trim().toLowerCase(); + + // Try to infer intent from natural language + if (prompt.includes("init")) { + return handleInit(stream, cwd, token); + } + if (prompt.includes("status")) { + return handleStatus(stream, cwd, token); + } + if (prompt.includes("drift")) { + return handleDrift(stream, cwd, token); + } + if (prompt.includes("recall") || prompt.includes("session") || prompt.includes("history")) { + return handleRecall(stream, request.prompt, cwd, token); + } + if (prompt.includes("complete") || prompt.includes("done") || prompt.includes("finish")) { + return handleComplete(stream, request.prompt, cwd, token); + } + if (prompt.includes("remind")) { + return handleRemind(stream, request.prompt, cwd, token); + } + if (prompt.includes("task")) { + return handleTasks(stream, request.prompt, cwd, token); + } + if (prompt.includes("pad") || prompt.includes("scratchpad") || prompt.includes("scratch")) { + return handlePad(stream, request.prompt, cwd, token); + } + if (prompt.includes("notify") || prompt.includes("webhook")) { + return handleNotify(stream, request.prompt, cwd, token); + } + if (prompt.includes("system") || prompt.includes("resource") || prompt.includes("bootstrap")) { + return handleSystem(stream, request.prompt, cwd, token); + } + if (prompt.includes("wrap") || prompt.includes("end session") || prompt.includes("closing")) { + return handleWrapup(stream, cwd, token); + } + if (prompt.includes("remember") || prompt.includes("last session") || prompt.includes("what were we")) { + return handleRemember(stream, request.prompt, cwd, token); + } + if (prompt.includes("next") || prompt.includes("what should") || prompt.includes("pick task")) { + return handleNext(stream, cwd, token); + } + if (prompt.includes("brainstorm") || prompt.includes("idea")) { + return handleBrainstorm(stream, request.prompt, cwd, token); + } + if (prompt.includes("reflect") || prompt.includes("persist") || prompt.includes("worth saving")) { + return handleReflect(stream, cwd, token); + } + if (prompt.includes("spec") || prompt.includes("scaffold")) { + return handleSpec(stream, request.prompt, cwd, token); + } + if (prompt.includes("implement") || prompt.includes("execution plan")) { + return handleImplement(stream, cwd, token); + } + if (prompt.includes("verify") || prompt.includes("qa") || prompt.includes("lint")) { + return handleVerify(stream, cwd, token); + } + if (prompt.includes("map") || prompt.includes("dependencies") || prompt.includes("deps")) { + return handleMap(stream, cwd, token); + } + if (prompt.includes("prompt template") || prompt.includes("prompts")) { + return handlePromptTpl(stream, request.prompt, cwd, token); + } + if (prompt.includes("blog") && prompt.includes("changelog")) { + return handleChangelog(stream, cwd, token); + } + if (prompt.includes("blog") || prompt.includes("post")) { + return handleBlog(stream, request.prompt, cwd, token); + } + if (prompt.includes("changelog") || prompt.includes("release notes")) { + return handleChangelog(stream, cwd, token); + } + if (prompt.includes("link") || prompt.includes("broken") || prompt.includes("dead link")) { + return handleCheckLinks(stream, cwd, token); + } + if (prompt.includes("journal") || prompt.includes("log entries")) { + return handleJournal(stream, request.prompt, cwd, token); + } + if (prompt.includes("consolidate") || prompt.includes("merge entries") || prompt.includes("duplicate")) { + return handleConsolidate(stream, cwd, token); + } + if (prompt.includes("memory bridge") || prompt.includes("memory sync") || prompt.includes("memory status") || prompt.includes("memory diff") || prompt.includes("memory import") || prompt.includes("memory publish")) { + return handleMemory(stream, request.prompt, cwd, token); + } + if (prompt.includes("decisions") || prompt.includes("decision list") || prompt.includes("decision reindex")) { + return handleDecisions(stream, request.prompt, cwd, token); + } + if (prompt.includes("learnings") || prompt.includes("learning list") || prompt.includes("learning reindex")) { + return handleLearnings(stream, request.prompt, cwd, token); + } + if (prompt.includes("config") || prompt.includes("profile") || prompt.includes("switch profile")) { + return handleConfig(stream, request.prompt, cwd, token); + } + if (prompt.includes("permissions") || prompt.includes("permission snapshot") || prompt.includes("permission restore")) { + return handlePermissions(stream, request.prompt, cwd, token); + } + if (prompt.includes("changes") || prompt.includes("what changed") || prompt.includes("since last session")) { + return handleChanges(stream, request.prompt, cwd, token); + } + if (prompt.includes("deps") || prompt.includes("dependency graph") || prompt.includes("package graph")) { + return handleDeps(stream, request.prompt, cwd, token); + } + if (prompt.includes("guide") || prompt.includes("cheat sheet") || prompt.includes("quick reference")) { + return handleGuide(stream, request.prompt, cwd, token); + } + if (prompt.includes("reindex") || prompt.includes("rebuild index") || prompt.includes("regenerate index")) { + return handleReindex(stream, cwd, token); + } + if (prompt.includes("why") || prompt.includes("philosophy") || prompt.includes("manifesto")) { + return handleWhy(stream, request.prompt, cwd, token); + } + if (prompt.includes("audit") || prompt.includes("alignment")) { + return handleAudit(stream, cwd, token); + } + if (prompt.includes("worktree")) { + return handleWorktree(stream, request.prompt, cwd, token); + } + if (prompt.includes("pause") || prompt.includes("save state")) { + return handlePause(stream, cwd, token); + } + if (prompt.includes("resume") || prompt.includes("restore state") || prompt.includes("continue session")) { + return handleResume(stream, cwd, token); + } + + // 2.5: Specs nudge — remind about specs when planning + if (prompt.includes("plan") || prompt.includes("design") || prompt.includes("architect")) { + const specsDir = path.join(cwd, "specs"); + if (fs.existsSync(specsDir)) { + const specs = fs.readdirSync(specsDir).filter((f) => f.endsWith(".md")); + if (specs.length > 0) { + stream.markdown("> **specs/** has " + specs.length + " spec(s). Review with `@ctx /spec` before designing.\n\n"); + } + } + } + + // Default: show help with available commands + stream.markdown( + "## ctx — Persistent Context for AI\n\n" + + "Available commands:\n\n" + + "| Command | Description |\n" + + "|---------|-------------|\n" + + "| `/init` | Initialize `.context/` directory |\n" + + "| `/status` | Show context summary |\n" + + "| `/agent [--budget N]` | Print AI-ready context packet |\n" + + "| `/drift` | Detect stale or invalid context |\n" + + "| `/recall [show\\|export\\|lock\\|unlock\\|sync]` | Browse session history |\n" + + "| `/hook` | Generate tool integration configs |\n" + + "| `/add` | Add task, decision, learning, or convention |\n" + + "| `/load` | Output assembled context |\n" + + "| `/compact` | Archive completed tasks |\n" + + "| `/sync` | Reconcile context with codebase |\n" + + "| `/complete` | Mark a task as completed |\n" + + "| `/remind` | Manage session reminders |\n" + + "| `/tasks` | Archive or snapshot tasks |\n" + + "| `/decisions [reindex]` | List or reindex decisions |\n" + + "| `/learnings [reindex]` | List or reindex learnings |\n" + + "| `/pad [resolve\\|import\\|export\\|merge]` | Encrypted scratchpad |\n" + + "| `/notify` | Webhook notifications |\n" + + "| `/memory [sync\\|status\\|diff\\|import\\|publish]` | Claude Code memory bridge |\n" + + "| `/system [stats\\|backup\\|message]` | System diagnostics |\n" + + "| `/config [switch\\|status\\|schema]` | Config profile management |\n" + + "| `/permissions [snapshot\\|restore]` | Claude settings backup |\n" + + "| `/wrapup` | End-of-session wrap-up |\n" + + "| `/remember [--limit N]` | Recall recent sessions |\n" + + "| `/next` | Show next open task |\n" + + "| `/brainstorm` | Browse and develop ideas |\n" + + "| `/reflect` | Surface items worth persisting |\n" + + "| `/spec` | List or scaffold feature specs |\n" + + "| `/implement` | Show implementation plan |\n" + + "| `/verify` | Run verification checks |\n" + + "| `/map` | Show dependency map |\n" + + "| `/prompt [add\\|rm]` | Manage prompt templates |\n" + + "| `/blog` | Draft blog post from context |\n" + + "| `/changelog` | Recent commits for changelog |\n" + + "| `/check-links` | Audit local links in context |\n" + + "| `/journal [site\\|obsidian]` | View or export journal |\n" + + "| `/consolidate` | Find duplicate entries |\n" + + "| `/audit` | Alignment audit (drift + conventions) |\n" + + "| `/worktree` | Git worktree management |\n" + + "| `/pause` | Save session state |\n" + + "| `/resume` | Restore paused session |\n" + + "| `/changes [--since duration]` | Show what changed since last session |\n" + + "| `/deps [--format mermaid\\|table\\|json]` | Package dependency graph |\n" + + "| `/guide [--skills\\|--commands]` | Quick-reference cheat sheet |\n" + + "| `/reindex` | Regenerate decision/learning indices |\n" + + "| `/why [topic]` | Read ctx philosophy |\n\n" + + "Example: `@ctx /status` or `@ctx /add task Fix login bug`" + ); + return { metadata: { command: "help" } }; +} + +const handler: vscode.ChatRequestHandler = async ( + request: vscode.ChatRequest, + _context: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken +): Promise => { + const cwd = getWorkspaceRoot(); + if (!cwd) { + stream.markdown( + "**Error:** No workspace folder is open. Open a project folder first." + ); + return { metadata: { command: request.command || "none" } }; + } + + // Auto-bootstrap: ensure ctx binary is available before any command + try { + stream.progress("Checking ctx installation..."); + await bootstrap(); + } catch (err: unknown) { + stream.markdown( + `**Error:** ctx CLI not found and auto-install failed.\n\n` + + `\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`\n\n` + + `Install manually: \`go install github.com/ActiveMemory/ctx/cmd/ctx@latest\` ` + + `or download from [GitHub Releases](https://github.com/${GITHUB_REPO}/releases).` + ); + return { metadata: { command: request.command || "none" } }; + } + + // For non-init commands, verify .context/ exists + if (request.command !== "init" && !hasContextDir(cwd)) { + stream.markdown( + "**Not initialized.** This project doesn't have a `.context/` directory yet.\n\n" + + "Run `@ctx /init` to set up persistent context for this project." + ); + return { metadata: { command: request.command || "none" } }; + } + + switch (request.command) { + case "init": + return handleInit(stream, cwd, token); + case "status": + return handleStatus(stream, cwd, token); + case "agent": + return handleAgent(stream, request.prompt, cwd, token); + case "drift": + return handleDrift(stream, cwd, token); + case "recall": + return handleRecall(stream, request.prompt, cwd, token); + case "hook": + return handleHook(stream, request.prompt, cwd, token); + case "add": + return handleAdd(stream, request.prompt, cwd, token); + case "load": + return handleLoad(stream, cwd, token); + case "compact": + return handleCompact(stream, cwd, token); + case "sync": + return handleSync(stream, cwd, token); + case "complete": + return handleComplete(stream, request.prompt, cwd, token); + case "remind": + return handleRemind(stream, request.prompt, cwd, token); + case "tasks": + return handleTasks(stream, request.prompt, cwd, token); + case "pad": + return handlePad(stream, request.prompt, cwd, token); + case "notify": + return handleNotify(stream, request.prompt, cwd, token); + case "system": + return handleSystem(stream, request.prompt, cwd, token); + case "wrapup": + return handleWrapup(stream, cwd, token); + case "remember": + return handleRemember(stream, request.prompt, cwd, token); + case "next": + return handleNext(stream, cwd, token); + case "brainstorm": + return handleBrainstorm(stream, request.prompt, cwd, token); + case "reflect": + return handleReflect(stream, cwd, token); + case "spec": + return handleSpec(stream, request.prompt, cwd, token); + case "implement": + return handleImplement(stream, cwd, token); + case "verify": + return handleVerify(stream, cwd, token); + case "map": + return handleMap(stream, cwd, token); + case "prompt": + return handlePromptTpl(stream, request.prompt, cwd, token); + case "blog": + return handleBlog(stream, request.prompt, cwd, token); + case "changelog": + return handleChangelog(stream, cwd, token); + case "check-links": + return handleCheckLinks(stream, cwd, token); + case "journal": + return handleJournal(stream, request.prompt, cwd, token); + case "consolidate": + return handleConsolidate(stream, cwd, token); + case "audit": + return handleAudit(stream, cwd, token); + case "worktree": + return handleWorktree(stream, request.prompt, cwd, token); + case "pause": + return handlePause(stream, cwd, token); + case "resume": + return handleResume(stream, cwd, token); + case "memory": + return handleMemory(stream, request.prompt, cwd, token); + case "decisions": + return handleDecisions(stream, request.prompt, cwd, token); + case "learnings": + return handleLearnings(stream, request.prompt, cwd, token); + case "config": + return handleConfig(stream, request.prompt, cwd, token); + case "permissions": + return handlePermissions(stream, request.prompt, cwd, token); + case "changes": + return handleChanges(stream, request.prompt, cwd, token); + case "deps": + return handleDeps(stream, request.prompt, cwd, token); + case "guide": + return handleGuide(stream, request.prompt, cwd, token); + case "reindex": + return handleReindex(stream, cwd, token); + case "why": + return handleWhy(stream, request.prompt, cwd, token); + default: + return handleFreeform(request, stream, cwd, token); + } +}; + +export function activate(extensionContext: vscode.ExtensionContext) { + // Store extension context for auto-bootstrap binary downloads + extensionCtx = extensionContext; + + // Kick off background bootstrap — don't block activation + bootstrap().catch(() => { + // Errors will surface when user invokes a command + }); + + const participant = vscode.chat.createChatParticipant( + PARTICIPANT_ID, + handler + ); + participant.iconPath = vscode.Uri.joinPath( + extensionContext.extensionUri, + "icon.png" + ); + + participant.followupProvider = { + provideFollowups( result: CtxResult, _context: vscode.ChatContext, _token: vscode.CancellationToken @@ -1136,6 +2618,148 @@ export function activate(extensionContext: vscode.ExtensionContext) { { prompt: "Show context status", command: "status" } ); break; + case "wrapup": + followups.push( + { prompt: "Add a decision", command: "add" }, + { prompt: "Add a learning", command: "add" } + ); + break; + case "remember": + followups.push( + { prompt: "Show context status", command: "status" }, + { prompt: "Load full context", command: "load" } + ); + break; + case "next": + followups.push( + { prompt: "Mark task completed", command: "complete" }, + { prompt: "Show all tasks", command: "status" } + ); + break; + case "brainstorm": + followups.push( + { prompt: "Show context status", command: "status" }, + { prompt: "Add a task", command: "add" } + ); + break; + case "reflect": + followups.push( + { prompt: "Add a decision", command: "add" }, + { prompt: "Add a learning", command: "add" }, + { prompt: "Wrap up session", command: "wrapup" } + ); + break; + case "spec": + followups.push( + { prompt: "Show implementation plan", command: "implement" }, + { prompt: "Run verification", command: "verify" } + ); + break; + case "implement": + followups.push( + { prompt: "Show next task", command: "next" }, + { prompt: "Run verification", command: "verify" } + ); + break; + case "verify": + followups.push( + { prompt: "Show context status", command: "status" }, + { prompt: "Run alignment audit", command: "audit" } + ); + break; + case "map": + followups.push( + { prompt: "Show context status", command: "status" } + ); + break; + case "blog": + case "changelog": + followups.push( + { prompt: "Show context status", command: "status" } + ); + break; + case "consolidate": + followups.push( + { prompt: "Run alignment audit", command: "audit" }, + { prompt: "Show context status", command: "status" } + ); + break; + case "audit": + followups.push( + { prompt: "Fix drift", command: "sync" }, + { prompt: "Add a convention", command: "add" } + ); + break; + case "pause": + followups.push( + { prompt: "Resume session", command: "resume" } + ); + break; + case "resume": + followups.push( + { prompt: "Show next task", command: "next" }, + { prompt: "Show context status", command: "status" } + ); + break; + case "memory": + followups.push( + { prompt: "Show memory diff", command: "memory" }, + { prompt: "Show context status", command: "status" } + ); + break; + case "decisions": + followups.push( + { prompt: "Add a decision", command: "add" }, + { prompt: "Reindex decisions", command: "decisions" } + ); + break; + case "learnings": + followups.push( + { prompt: "Add a learning", command: "add" }, + { prompt: "Reindex learnings", command: "learnings" } + ); + break; + case "config": + followups.push( + { prompt: "Show config status", command: "config" }, + { prompt: "Show context status", command: "status" } + ); + break; + case "permissions": + followups.push( + { prompt: "Show context status", command: "status" } + ); + break; + case "changes": + followups.push( + { prompt: "Show context status", command: "status" }, + { prompt: "Load full context", command: "load" } + ); + break; + case "deps": + followups.push( + { prompt: "Show dependency map", command: "map" }, + { prompt: "Show context status", command: "status" } + ); + break; + case "guide": + followups.push( + { prompt: "Show context status", command: "status" }, + { prompt: "Read philosophy", command: "why" } + ); + break; + case "reindex": + followups.push( + { prompt: "List decisions", command: "decisions" }, + { prompt: "List learnings", command: "learnings" } + ); + break; + case "why": + followups.push( + { prompt: "Show guide", command: "guide" }, + { prompt: "Show context status", command: "status" } + ); + break; } return followups; @@ -1143,6 +2767,237 @@ export function activate(extensionContext: vscode.ExtensionContext) { }; extensionContext.subscriptions.push(participant); + + // --- Command palette entries — open chat with the right slash command --- + const paletteCommands: Array<[string, string]> = [ + ["ctx.init", "/init"], + ["ctx.status", "/status"], + ["ctx.agent", "/agent"], + ["ctx.drift", "/drift"], + ["ctx.recall", "/recall"], + ["ctx.hook", "/hook"], + ["ctx.add", "/add"], + ["ctx.load", "/load"], + ["ctx.compact", "/compact"], + ["ctx.sync", "/sync"], + ["ctx.complete", "/complete"], + ["ctx.remind", "/remind"], + ["ctx.tasks", "/tasks"], + ["ctx.pad", "/pad"], + ["ctx.notify", "/notify"], + ["ctx.system", "/system"], + ["ctx.wrapup", "/wrapup"], + ["ctx.remember", "/remember"], + ["ctx.next", "/next"], + ["ctx.brainstorm", "/brainstorm"], + ["ctx.reflect", "/reflect"], + ["ctx.spec", "/spec"], + ["ctx.implement", "/implement"], + ["ctx.verify", "/verify"], + ["ctx.map", "/map"], + ["ctx.prompt", "/prompt"], + ["ctx.blog", "/blog"], + ["ctx.changelog", "/changelog"], + ["ctx.checkLinks", "/check-links"], + ["ctx.journal", "/journal"], + ["ctx.consolidate", "/consolidate"], + ["ctx.audit", "/audit"], + ["ctx.worktree", "/worktree"], + ["ctx.pause", "/pause"], + ["ctx.resume", "/resume"], + ["ctx.memory", "/memory"], + ["ctx.decisions", "/decisions"], + ["ctx.learnings", "/learnings"], + ["ctx.config", "/config"], + ["ctx.permissions", "/permissions"], + ["ctx.changes", "/changes"], + ["ctx.deps", "/deps"], + ["ctx.guide", "/guide"], + ["ctx.reindex", "/reindex"], + ["ctx.why", "/why"], + ]; + for (const [cmdId, slash] of paletteCommands) { + extensionContext.subscriptions.push( + vscode.commands.registerCommand(cmdId, () => { + vscode.commands.executeCommand("workbench.action.chat.open", { + query: `@ctx ${slash}`, + }); + }) + ); + } + + // --- VS Code native hooks (equivalent to Claude Code hooks.json) --- + const cwd = getWorkspaceRoot(); + + // 2.6: onDidSave → task completion check (PostToolUse Edit/Write equivalent) + const saveWatcher = vscode.workspace.onDidSaveTextDocument((doc) => { + if (!cwd || !bootstrapDone || !hasContextDir(cwd)) { + return; + } + // Only trigger for files inside the workspace, not for .context/ files themselves + const rel = path.relative(cwd, doc.uri.fsPath); + if (rel.startsWith(".context")) { + return; + } + // Fire and forget — non-blocking background check + runCtx(["system", "check-task-completion"], cwd).catch( + () => {} + ); + }); + extensionContext.subscriptions.push(saveWatcher); + + // 2.7: Git post-commit — detect commits and nudge for context capture + try { + const gitExtension = vscode.extensions.getExtension("vscode.git"); + if (gitExtension) { + const setupGitHook = (git: any) => { + try { + const api = git.getAPI(1); + if (api && api.repositories.length > 0) { + const repo = api.repositories[0]; + let lastCommit = repo.state.HEAD?.commit; + const commitListener = repo.state.onDidChange(() => { + const currentCommit = repo.state.HEAD?.commit; + if (currentCommit && currentCommit !== lastCommit) { + lastCommit = currentCommit; + if (!cwd || !bootstrapDone || !hasContextDir(cwd)) { + return; + } + vscode.window + .showInformationMessage( + "Commit succeeded. Record context or run QA?", + "Add Decision", + "Add Learning", + "Verify", + "Skip" + ) + .then((choice) => { + if (choice === "Add Decision") { + vscode.commands.executeCommand( + "workbench.action.chat.open", + { query: "@ctx /add decision " } + ); + } else if (choice === "Add Learning") { + vscode.commands.executeCommand( + "workbench.action.chat.open", + { query: "@ctx /add learning " } + ); + } else if (choice === "Verify") { + vscode.commands.executeCommand( + "workbench.action.chat.open", + { query: "@ctx /verify" } + ); + } + }); + } + }); + extensionContext.subscriptions.push(commitListener); + } + } catch { + // Git API not available + } + }; + + if (gitExtension.isActive) { + setupGitHook(gitExtension.exports); + } else { + gitExtension.activate().then(setupGitHook, () => {}); + } + } + } catch { + // Git extension not available + } + + // 2.9: Watch .context/ for external changes — refresh reminders + if (cwd) { + const contextWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(cwd, ".context/**") + ); + const onContextChange = () => { + if (hasContextDir(cwd)) { + updateReminderStatus(cwd); + // Re-generate copilot-instructions.md when context files change + runCtx(["hook", "copilot", "--write"], cwd).catch(() => {}); + } + }; + contextWatcher.onDidChange(onContextChange); + contextWatcher.onDidCreate(onContextChange); + contextWatcher.onDidDelete(onContextChange); + extensionContext.subscriptions.push(contextWatcher); + } + + // 2.17: Watch dependency files for staleness + if (cwd) { + const depWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(cwd, "{go.mod,go.sum,package.json,package-lock.json}") + ); + depWatcher.onDidChange(() => { + vscode.window.showInformationMessage( + "Dependencies changed. Review with @ctx /map?", + "View Map" + ).then((choice) => { + if (choice === "View Map") { + vscode.commands.executeCommand("workbench.action.chat.open", { query: "@ctx /map" }); + } + }); + }); + extensionContext.subscriptions.push(depWatcher); + } + + // 2.10: Status bar reminder indicator + reminderStatusBar = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 50 + ); + reminderStatusBar.name = "ctx Reminders"; + reminderStatusBar.command = undefined; // informational only + extensionContext.subscriptions.push(reminderStatusBar); + + // Check reminders periodically (every 5 minutes) + if (cwd && hasContextDir(cwd)) { + updateReminderStatus(cwd); + const reminderInterval = setInterval(() => { + updateReminderStatus(cwd); + // 2.14: Heartbeat — record session-alive timestamp + try { + const stateDir = path.join(cwd, ".context", "state"); + if (!fs.existsSync(stateDir)) { fs.mkdirSync(stateDir, { recursive: true }); } + fs.writeFileSync(path.join(stateDir, "heartbeat"), new Date().toISOString(), "utf-8"); + } catch { /* non-fatal */ } + }, 5 * 60 * 1000); + extensionContext.subscriptions.push({ + dispose: () => clearInterval(reminderInterval), + }); + + // 2.12: Session start ceremony + runCtx( + ["system", "session-event", "--type", "start", "--caller", "vscode"], + cwd + ).catch(() => {}); + } +} + +/** + * Update the status bar reminder indicator by checking due reminders. + */ +function updateReminderStatus(cwd: string): void { + if (!bootstrapDone || !reminderStatusBar) { + return; + } + runCtx(["system", "check-reminders"], cwd) + .then(({ stdout }) => { + const trimmed = stdout.trim(); + if (trimmed && !trimmed.includes("no reminders")) { + reminderStatusBar!.text = "$(bell) ctx"; + reminderStatusBar!.tooltip = trimmed; + reminderStatusBar!.show(); + } else { + reminderStatusBar!.hide(); + } + }) + .catch(() => { + reminderStatusBar!.hide(); + }); } export { @@ -1158,6 +3013,25 @@ export { handlePad, handleNotify, handleSystem, + handleMemory, + handleDecisions, + handleLearnings, + handleConfig, + handlePermissions, + handleChanges, + handleDeps, + handleGuide, + handleReindex, + handleWhy, }; -export function deactivate() {} +export function deactivate() { + // 2.12: Session end ceremony + const cwd = getWorkspaceRoot(); + if (cwd && hasContextDir(cwd)) { + runCtx( + ["system", "session-event", "--type", "end", "--caller", "vscode"], + cwd + ).catch(() => {}); + } +} diff --git a/internal/assets/embed.go b/internal/assets/embed.go index e1c1825c..0f3b33a7 100644 --- a/internal/assets/embed.go +++ b/internal/assets/embed.go @@ -19,7 +19,7 @@ import ( "gopkg.in/yaml.v3" ) -//go:embed claude/.claude-plugin/plugin.json claude/CLAUDE.md claude/skills/*/references/*.md claude/skills/*/SKILL.md context/*.md project/* entry-templates/*.md hooks/messages/*/*.txt hooks/messages/registry.yaml prompt-templates/*.md ralph/*.md schema/*.json why/*.md permissions/*.txt commands/*.yaml +//go:embed claude/.claude-plugin/plugin.json claude/CLAUDE.md claude/skills/*/references/*.md claude/skills/*/SKILL.md context/*.md project/* entry-templates/*.md hooks/messages/*/*.txt hooks/messages/registry.yaml prompt-templates/*.md ralph/*.md schema/*.json why/*.md permissions/*.txt commands/*.yaml overrides/*/*.md var FS embed.FS // Template reads a template file by name from the embedded filesystem. @@ -34,6 +34,18 @@ func Template(name string) ([]byte, error) { return FS.ReadFile("context/" + name) } +// TemplateForCaller reads a template, using a caller-specific override if available. +// Falls back to the default template when no override exists for the caller. +func TemplateForCaller(name, caller string) ([]byte, error) { + if caller != "" { + override, err := FS.ReadFile("overrides/" + caller + "/" + name) + if err == nil { + return override, nil + } + } + return Template(name) +} + // List returns all available template file names. // // Returns: @@ -239,6 +251,18 @@ func ClaudeMd() ([]byte, error) { return FS.ReadFile("claude/CLAUDE.md") } +// ClaudeMdForCaller reads the CLAUDE.md template, using a caller-specific override if available. +// Falls back to the default CLAUDE.md when no override exists for the caller. +func ClaudeMdForCaller(caller string) ([]byte, error) { + if caller != "" { + override, err := FS.ReadFile("overrides/" + caller + "/CLAUDE.md") + if err == nil { + return override, nil + } + } + return ClaudeMd() +} + // RalphTemplate reads a Ralph-mode template file by name. // // Ralph mode templates are designed for autonomous loop operation, diff --git a/internal/assets/overrides/vscode/AGENT_PLAYBOOK.md b/internal/assets/overrides/vscode/AGENT_PLAYBOOK.md new file mode 100644 index 00000000..e0d7d101 --- /dev/null +++ b/internal/assets/overrides/vscode/AGENT_PLAYBOOK.md @@ -0,0 +1,268 @@ +# Agent Playbook + +## Mental Model + +Each session is a fresh execution in a shared workshop. Work +continuity comes from artifacts left on the bench. Follow the +cycle: **Work → Reflect → Persist**. After completing a task, +making a decision, learning something, or hitting a milestone — +persist before continuing. Don't wait for session end; it may +never come cleanly. + +## Invoking ctx + +Always use `ctx` from PATH: +``` +ctx status # ✔ correct +ctx agent # ✔ correct +./dist/ctx # ✗ avoid hardcoded paths +go run ./cmd/ctx # ✗ avoid unless developing ctx itself +``` + +If unsure whether it's installed, run `ctx --version` in a terminal. + +## Context Readback + +Before starting any work, read the required context files and confirm to the +user: "I have read the required context files and I'm following project +conventions." Do not begin implementation until you have done so. + +## Reason Before Acting + +Before implementing any non-trivial change, think through it step-by-step: + +1. **Decompose**: break the problem into smaller parts +2. **Identify impact**: what files, tests, and behaviors does this touch? +3. **Anticipate failure**: what could go wrong? What are the edge cases? +4. **Sequence**: what order minimizes risk and maximizes checkpoints? + +This applies to debugging too — reason through the cause before reaching +for a fix. Rushing to code before reasoning is the most common source of +wasted work. + +## Session Lifecycle + +A session follows this arc: + +**Load → Orient → Pick → Work → Commit → Reflect** + +Not every session uses every step — a quick bugfix skips reflection, a +research session skips committing — but the full flow is: + +| Step | What Happens | Command | +|-------------|----------------------------------------------------|----------------------| +| **Load** | Recall context, present structured readback | `ctx recall list` | +| **Orient** | Check context health, surface issues | `ctx status` | +| **Pick** | Choose what to work on | Read TASKS.md | +| **Work** | Write code, fix bugs, research | — | +| **Commit** | Commit with context capture | `git commit` | +| **Reflect** | Surface persist-worthy items from this session | Update context files | + +### Context Health at Session Start + +During **Load** and **Orient**, run `ctx status` and read the output. +Surface problems worth mentioning: + +- **High completion ratio in TASKS.md**: offer to archive +- **Stale context files** (not modified recently): mention before + stale context influences work +- **Bloated token count** (over 30k): offer `ctx compact` +- **Drift between files and code**: spot-check paths from + ARCHITECTURE.md against the actual file tree + +One sentence is enough — don't turn startup into a maintenance session. + +### Conversational Triggers + +Users rarely invoke skills explicitly. Recognize natural language: + +| User Says | Action | +|-----------|--------| +| "Do you remember?" / "What were we working on?" | Read TASKS.md, DECISIONS.md, LEARNINGS.md; run `ctx recall list` | +| "How's our context looking?" | Run `ctx status` | +| "What should we work on?" | Read TASKS.md, pick highest priority | +| "Commit this" / "Ship it" | `git commit`, update TASKS.md | +| "The rate limiter is done" / "We finished that" | Mark done in TASKS.md | +| "What did we learn?" | Review session work, offer to update LEARNINGS.md | +| "Save that as a decision" | Add entry to DECISIONS.md | +| "That's worth remembering" / "Any gotchas?" | Add entry to LEARNINGS.md | +| "Record that convention" | Add entry to CONVENTIONS.md | +| "Add a task for that" | Add entry to TASKS.md | +| "Let's wrap up" | Reflect → persist outstanding items → present together | + +## Proactive Persistence + +**Don't wait to be asked.** Identify persist-worthy moments in real time: + +| Event | Action | +|-------|--------| +| Completed a task | Mark done in TASKS.md, offer to add learnings | +| Chose between design alternatives | Offer: *"Worth recording as a decision?"* | +| Hit a subtle bug or gotcha | Offer: *"Want me to add this as a learning?"* | +| Finished a feature or fix | Identify follow-up work, offer to add as tasks | +| Resolved a tricky debugging session | Capture root cause before moving on | +| Multi-step task or feature complete | Suggest reflection: *"Want me to capture what we learned?"* | +| Session winding down | Offer: *"Want me to capture outstanding learnings or decisions?"* | +| Shipped a feature or closed batch of tasks | Offer blog post or journal site rebuild | + +**Self-check**: periodically ask yourself — *"If this session ended +right now, would the next session know what happened?"* If no, persist +something before continuing. + +Offer once and respect "no." Default to surfacing the opportunity +rather than letting it pass silently. + +### Task Lifecycle Timestamps + +Track task progress with timestamps for session correlation: + +```markdown +- [ ] Implement feature X #added:2026-01-25-220332 +- [ ] Fix bug Y #added:2026-01-25-220332 #started:2026-01-25-221500 +- [x] Refactor Z #added:2026-01-25-200000 #started:2026-01-25-210000 #done:2026-01-25-223045 +``` + +| Tag | When to Add | Format | +|------------|------------------------------------------|----------------------| +| `#added` | Auto-added by `ctx add task` | `YYYY-MM-DD-HHMMSS` | +| `#started` | When you begin working on the task | `YYYY-MM-DD-HHMMSS` | +| `#done` | When you mark the task `[x]` complete | `YYYY-MM-DD-HHMMSS` | + +## Collaboration Defaults + +Standing behavioral defaults for how the agent collaborates with the +user. These apply unless the user overrides them for the session +(e.g., "skip the alternatives, just build it"). + +- **At design decisions**: always present 2+ approaches with + trade-offs before committing — don't silently pick one +- **At completion claims**: run self-audit questions (What did I + assume? What didn't I check? Where am I least confident? What + would a reviewer question?) before reporting done +- **At ambiguous moments**: ask the user rather than inferring + intent — a quick question is cheaper than rework +- **When producing artifacts**: flag assumptions and uncertainty + areas inline, not buried in a footnote + +These follow the same pattern as proactive persistence: offer once +and respect "no." + +## Own the Whole Branch + +When working on a branch, you own every issue on it — lint failures, test +failures, build errors — regardless of who introduced them. Never dismiss +a problem as "pre-existing" or "not related to my changes." + +- **If `make lint` fails, fix it.** The branch must be green when you're done. +- **If tests break, investigate.** Even if the failing test is in a file you + didn't touch, something you changed may have caused it — or it may have been + broken before and it's still your job to fix it on this branch. +- **Run the full validation suite** (`make lint`, `go test ./...`, `go build`) + before declaring any phase complete. + +## How to Avoid Hallucinating Memory + +Never assume. If you don't see it in files, you don't know it. + +- Don't claim "we discussed X" without file evidence +- Don't invent history — check context files and `ctx recall` +- If uncertain, say "I don't see this documented" +- Trust files over intuition + +## Planning Non-Trivial Work + +Before implementing a feature or multi-task effort, follow this sequence: + +**1. Spec first** — Write a design document in `specs/` covering: problem, +solution, storage, CLI surface, error cases, and non-goals. Keep it concise +but complete enough that another session could implement from it alone. + +**2. Task it out** — Break the work into individual tasks in TASKS.md under +a dedicated Phase section. Each task should be independently completable and +verifiable. + +**3. Cross-reference** — The Phase header in TASKS.md must reference the +spec: `Spec: \`specs/feature-name.md\``. The first task in the phase should +include: "Read `specs/feature-name.md` before starting any PX task." + +**4. Read before building** — When picking up a task that references a spec, +read the spec first. Don't rely on the task description alone — it's a +summary, not the full design. + +## When to Consolidate vs Add Features + +**Signs you should consolidate first:** +- Same string literal appears in 3+ files +- Hardcoded paths use string concatenation +- Test file is growing into a monolith (>500 lines) +- Package name doesn't match folder name + +When in doubt, ask: "Would a new contributor understand where this belongs?" + +## Pre-Flight Checklist: CLI Code + +Before writing or modifying CLI code (`internal/cli/**/*.go`): + +1. **Read CONVENTIONS.md** — load established patterns into context +2. **Check similar commands** — how do existing commands handle output? +3. **Use cmd methods for output** — `cmd.Printf`, `cmd.Println`, + not `fmt.Printf`, `fmt.Println` +4. **Follow docstring format** — see CONVENTIONS.md, Documentation section + +--- + +## Context Anti-Patterns + +Avoid these common context management mistakes: + +### Stale Context + +Context files become outdated and misleading when ARCHITECTURE.md +describes components that no longer exist, or CONVENTIONS.md patterns +contradict actual code. **Solution**: Update context as part of +completing work, not as a separate task. Run `ctx drift` periodically. + +### Context Sprawl + +Information scattered across multiple locations — same decision in +DECISIONS.md and a session file, conventions split between +CONVENTIONS.md and code comments. **Solution**: Single source of +truth for each type of information. Use the defined file structure. + +### Implicit Context + +Relying on knowledge not captured in artifacts — "everyone knows we +don't do X" but it's not in CONSTITUTION.md, patterns followed but +not in CONVENTIONS.md. **Solution**: If you reference something +repeatedly, add it to the appropriate file. + +### Over-Specification + +Context becomes so detailed it's impossible to maintain — 50+ rules +in CONVENTIONS.md, every minor choice gets a DECISIONS.md entry. +**Solution**: Keep artifacts focused on decisions that affect behavior +and alignment. Not everything needs documenting. + +### Context Avoidance + +Not using context because "it's faster to just code." Same mistakes +repeated across sessions, decisions re-debated because prior decisions +weren't found. **Solution**: Reading context is faster than +re-discovering it. 5 minutes reading saves 50 minutes of wasted work. + +--- + +## Context Validation Checklist + +### Quick Check (Every Session) +- [ ] TASKS.md reflects current priorities +- [ ] No obvious staleness in files you'll reference +- [ ] Recent history reviewed via `ctx recall list` + +### Deep Check (Weekly or Before Major Work) +- [ ] CONSTITUTION.md rules still apply +- [ ] ARCHITECTURE.md matches actual structure +- [ ] CONVENTIONS.md patterns match code +- [ ] DECISIONS.md has no superseded entries unmarked +- [ ] LEARNINGS.md gotchas still relevant +- [ ] Run `ctx drift` and address warnings diff --git a/internal/assets/overrides/vscode/CLAUDE.md b/internal/assets/overrides/vscode/CLAUDE.md new file mode 100644 index 00000000..6668efc6 --- /dev/null +++ b/internal/assets/overrides/vscode/CLAUDE.md @@ -0,0 +1,55 @@ +# 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. + + diff --git a/internal/assets/overrides/vscode/CONSTITUTION.md b/internal/assets/overrides/vscode/CONSTITUTION.md new file mode 100644 index 00000000..a650b4a9 --- /dev/null +++ b/internal/assets/overrides/vscode/CONSTITUTION.md @@ -0,0 +1,47 @@ +# Constitution + + + +These rules are INVIOLABLE. If a task requires violating these, the task is wrong. + +## Security Invariants + +- [ ] Never commit secrets, tokens, API keys, or credentials +- [ ] Never store customer/user data in context files + +## Quality Invariants + +- [ ] All code must pass tests before commit +- [ ] No TODO comments in main branch (move to TASKS.md) +- [ ] Path construction uses language-standard path joining — no string concatenation (security: prevents path traversal) + +## Process Invariants + +- [ ] All architectural changes require a decision record + +## TASKS.md Structure Invariants + +TASKS.md must remain a replayable checklist. Uncheck all items and re-run = verify/redo all tasks in order. + +- [ ] **Never move tasks** — tasks stay in their Phase section permanently +- [ ] **Never remove Phase headers** — Phase labels provide structure and order +- [ ] **Never merge or collapse Phase sections** — each phase is a logical unit +- [ ] **Never delete tasks** — mark as `[x]` completed, or `[-]` skipped with reason +- [ ] **Use inline labels for status** — add `#in-progress` to task text, don't move it +- [ ] **No "In Progress" / "Next Up" sections** — these encourage moving tasks +- [ ] **Ask before restructuring** — if structure changes seem needed, ask the user first + +## Context Preservation Invariants + +- [ ] **Archival is allowed, deletion is not** — use `ctx tasks archive` to move completed tasks to `.context/archive/`, never delete context history +- [ ] **Archive preserves structure** — archived tasks keep their Phase headers for traceability diff --git a/internal/assets/overrides/vscode/CONVENTIONS.md b/internal/assets/overrides/vscode/CONVENTIONS.md new file mode 100644 index 00000000..cbe272ab --- /dev/null +++ b/internal/assets/overrides/vscode/CONVENTIONS.md @@ -0,0 +1,55 @@ +# Conventions + + + +## Naming + +- **Use semantic prefixes for constants**: Group related constants with prefixes + - `DIR_*` / `Dir*` for directories + - `FILE_*` / `File*` for file paths + - `*_TYPE` / `*Type` for enum-like values +- **Module/package name = folder name**: Keep names consistent with the filesystem +- **Avoid magic strings**: Use named constants instead of string literals for comparison + +## Patterns + +- **Centralize repeated literals**: All repeated literals belong in a constants/config module + - If a string appears in 3+ files, it needs a constant + - If a string is used for comparison, it needs a constant +- **Path construction**: Always use your language's standard path joining + - Python: `os.path.join(dir, file)` or `pathlib.Path(dir) / file` + - Node/TS: `path.join(dir, file)` + - Go: `filepath.Join(dir, file)` + - Rust: `PathBuf::from(dir).join(file)` + - Never: `dir + "/" + file` (string concatenation) +- **Colocate related code**: Group by feature, not by type + - `session/run.ext`, `session/types.ext`, `session/parse.ext` + - Not: `runners/session.ext`, `types/session.ext`, `parsers/session.ext` + +## Testing + +- **Colocate tests**: Test files live next to source files + - Not in a separate `tests/` folder (unless the language convention requires it) +- **Test the unit, not the file**: One test file can test multiple related functions +- **Integration tests are separate**: Clearly distinguish unit tests from end-to-end tests + +## Documentation + +- **Follow language conventions**: Use the standard doc format for your language + - Python: docstrings (Google/NumPy/Sphinx style) + - TypeScript/JavaScript: JSDoc or TSDoc + - Go: Godoc comments + - Rust: `///` doc comments with Markdown +- **Document public APIs**: Every exported function/class/type gets a doc comment +- **Copyright headers**: All source files get the project copyright header diff --git a/internal/assets/why/about.md b/internal/assets/why/about.md index 121fe7ae..059bc7f6 100644 --- a/internal/assets/why/about.md +++ b/internal/assets/why/about.md @@ -35,7 +35,7 @@ It lives in a `.context/` directory in your repo. * A session is *interactive*. * `ctx` enables **cognitive continuity**. -* **Cognitive continuity** enables durable, *symbiotic-like* human–AI workflows. +* **Cognitive continuity** enables durable, *symbiotic-like* human-AI workflows. !!! tip "Context Files" Context files let AI tools **remember** [*decisions*, *conventions*, and *learnings*](context-files.md): @@ -95,7 +95,7 @@ Without persistent memory, every session starts at zero. AI: "Per DECISIONS.md (2026-02-12): JWT, not sessions: The project uses PostgreSQL with JWT authentication. The API follows snake_case conventions. - Last session you completed the login endpoint — the + Last session you completed the login endpoint: the registration endpoint is next on TASKS.md. Want me to start there?" @@ -141,5 +141,5 @@ makes the *next* session **smarter**. **Ready to Get Started?** -* [Getting Started →](getting-started.md) — full installation and setup -* [Your First Session →](first-session.md) — step-by-step walkthrough from `ctx init` to verified recall +* [Getting Started →](getting-started.md): full installation and setup +* [Your First Session →](first-session.md): step-by-step walkthrough from `ctx init` to verified recall diff --git a/internal/assets/why/design-invariants.md b/internal/assets/why/design-invariants.md index 62a8bf84..c8a2d4cd 100644 --- a/internal/assets/why/design-invariants.md +++ b/internal/assets/why/design-invariants.md @@ -9,6 +9,8 @@ title: Invariants icon: lucide/anchor --- +![ctx](../images/ctx-banner.png) + # The System Explains Itself These are the properties that **must hold** for any valid `ctx` implementation. diff --git a/internal/bootstrap/cmd.go b/internal/bootstrap/cmd.go index eba0d2d3..a89cb208 100644 --- a/internal/bootstrap/cmd.go +++ b/internal/bootstrap/cmd.go @@ -45,10 +45,12 @@ func RootCmd() *cobra.Command { short, long := assets.CommandDesc("ctx") cmd := &cobra.Command{ - Use: "ctx", - Short: short, - Long: long, - Version: version, + Use: "ctx", + Short: short, + Long: long, + Version: version, + SilenceErrors: true, + SilenceUsage: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // Apply global flag values if contextDir != "" { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 26864b38..0a84b022 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -13,6 +13,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" ) @@ -34,7 +35,11 @@ func TestBinaryIntegration(t *testing.T) { defer func() { _ = os.RemoveAll(tmpDir) }() // Build the binary - binaryPath := filepath.Join(tmpDir, "ctx-test-binary") + binaryName := "ctx-test-binary" + if runtime.GOOS == "windows" { + binaryName += ".exe" + } + binaryPath := filepath.Join(tmpDir, binaryName) buildCmd := exec.Command("go", "build", "-o", binaryPath, "./cmd/ctx") //nolint:gosec // G204: test builds local binary buildCmd.Env = append(os.Environ(), "CGO_ENABLED=0") diff --git a/internal/cli/initialize/cmd/root/run.go b/internal/cli/initialize/cmd/root/run.go index b8eb3bb3..4cb1839d 100644 --- a/internal/cli/initialize/cmd/root/run.go +++ b/internal/cli/initialize/cmd/root/run.go @@ -34,13 +34,17 @@ import ( // - merge: If true, auto-merge ctx content into existing files // - ralph: If true, use autonomous loop templates (no questions, signals) // - 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, ralph, noPluginEnable bool) error { - // Check if ctx is in PATH (required for hooks to work) - if err := core.CheckCtxInPath(cmd); err != nil { - return err +func Run(cmd *cobra.Command, force, minimal, merge, ralph, noPluginEnable bool, caller string) error { + // 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 err := core.CheckCtxInPath(cmd); err != nil { + return err + } } contextDir := rc.ContextDir() @@ -94,7 +98,7 @@ func Run(cmd *cobra.Command, force, minimal, merge, ralph, noPluginEnable bool) continue } - content, err := assets.Template(name) + content, err := assets.TemplateForCaller(name, caller) if err != nil { return fmt.Errorf("failed to read template %s: %w", name, err) } @@ -151,51 +155,68 @@ func Run(cmd *cobra.Command, force, minimal, merge, ralph, noPluginEnable bool) )) } - // Merge permissions into settings.local.json (no hook scaffolding) - cmd.Println("\nSetting up Claude Code permissions...") - if err := core.MergeSettingsPermissions(cmd); err != nil { - // Non-fatal: warn but continue - cmd.Println(fmt.Sprintf(" ⚠ Permissions: %v", err)) - } + // Claude Code specific artifacts — skip when called from another editor. + // These create .claude/settings.local.json, enable the plugin, + // and deploy Makefile.ctx — none of which are used by VS Code. + if caller == "" { + cmd.Println("\nSetting up Claude Code permissions...") + if err := core.MergeSettingsPermissions(cmd); err != nil { + // Non-fatal: warn but continue + cmd.Println(fmt.Sprintf(" ⚠ Permissions: %v", err)) + } - // Auto-enable plugin globally unless suppressed - if !noPluginEnable { - if pluginErr := core.EnablePluginGlobally(cmd); pluginErr != nil { + // Auto-enable plugin globally unless suppressed + if !noPluginEnable { + if pluginErr := core.EnablePluginGlobally(cmd); pluginErr != nil { + // Non-fatal: warn but continue + cmd.Println(fmt.Sprintf(" ⚠ Plugin enablement: %v", pluginErr)) + } + } + + // Deploy Makefile.ctx and amend user Makefile + if err := core.HandleMakefileCtx(cmd); err != nil { // Non-fatal: warn but continue - cmd.Println(fmt.Sprintf(" ⚠ Plugin enablement: %v", pluginErr)) + cmd.Println(fmt.Sprintf(" ⚠ Makefile: %v", err)) } } - // Handle CLAUDE.md creation/merge - if err := core.HandleClaudeMd(cmd, force, merge); err != nil { + // Handle CLAUDE.md creation/merge (uses caller-specific override when available) + if err := core.HandleClaudeMd(cmd, force, merge, caller); err != nil { // Non-fatal: warn but continue cmd.Println(fmt.Sprintf(" ⚠ CLAUDE.md: %v", err)) } - // Deploy Makefile.ctx and amend user Makefile - if err := core.HandleMakefileCtx(cmd); err != nil { - // Non-fatal: warn but continue - cmd.Println(fmt.Sprintf(" ⚠ Makefile: %v", err)) - } - // Update .gitignore with recommended entries - if err := ensureGitignoreEntries(cmd); err != nil { + if err := ensureGitignoreEntries(cmd, caller); err != nil { cmd.Println(fmt.Sprintf(" ⚠ .gitignore: %v", err)) } - cmd.Println("\nNext steps:") - cmd.Println(" 1. Edit .context/TASKS.md to add your current tasks") - cmd.Println(" 2. Run 'ctx status' to see context summary") - cmd.Println(" 3. Run 'ctx agent' to get AI-ready context packet") - cmd.Println() - cmd.Println("Claude Code users: install the ctx plugin for hooks & skills:") - cmd.Println(" /plugin marketplace add ActiveMemory/ctx") - cmd.Println(" /plugin install ctx@activememory-ctx") - cmd.Println() - cmd.Println("Note: local plugin installs are not auto-enabled globally.") - cmd.Println("Run 'ctx init' again after installing the plugin to enable it,") - cmd.Println("or manually add to ~/.claude/settings.json:") - cmd.Println(" {\"enabledPlugins\": {\"ctx@activememory-ctx\": true}}") + // Claude Code specific setup — skip when called from another editor + if caller == "" { + cmd.Println("\nNext steps:") + cmd.Println(" 1. Edit .context/TASKS.md to add your current tasks") + cmd.Println(" 2. Run 'ctx status' to see context summary") + cmd.Println(" 3. Run 'ctx agent' to get AI-ready context packet") + cmd.Println() + cmd.Println("Claude Code users: install the ctx plugin for hooks & skills:") + cmd.Println(" /plugin marketplace add ActiveMemory/ctx") + cmd.Println(" /plugin install ctx@activememory-ctx") + cmd.Println() + cmd.Println("Note: local plugin installs are not auto-enabled globally.") + cmd.Println("Run 'ctx init' again after installing the plugin to enable it,") + cmd.Println("or manually add to ~/.claude/settings.json:") + cmd.Println(" {\"enabledPlugins\": {\"ctx@activememory-ctx\": true}}") + } else { + // VS Code / other editor specific setup + cmd.Println("\nSetting up editor integration...") + if err := core.CreateVSCodeArtifacts(cmd); err != nil { + cmd.Println(fmt.Sprintf(" ⚠ VS Code artifacts: %v", err)) + } + + cmd.Println("\nNext steps:") + cmd.Println(" 1. Edit .context/TASKS.md to add your current tasks") + cmd.Println(" 2. Run '@ctx /status' to see context summary") + } return nil } @@ -282,7 +303,10 @@ func hasEssentialFiles(contextDir string) bool { // ensureGitignoreEntries appends recommended .gitignore entries that are not // already present. Creates .gitignore if it does not exist. -func ensureGitignoreEntries(cmd *cobra.Command) error { +// +// When caller is non-empty (editor integration), Claude Code-specific +// entries like .claude/settings.local.json are skipped. +func ensureGitignoreEntries(cmd *cobra.Command, caller string) error { gitignorePath := ".gitignore" content, err := os.ReadFile(gitignorePath) @@ -296,9 +320,13 @@ func ensureGitignoreEntries(cmd *cobra.Command) error { existing[strings.TrimSpace(line)] = true } - // Collect missing entries. + // Collect missing entries, skipping Claude Code-specific ones for editors. var missing []string for _, entry := range config.GitignoreEntries { + // .claude/ entries are only relevant when running without a caller + if caller != "" && strings.HasPrefix(entry, ".claude/") { + continue + } if !existing[entry] { missing = append(missing, entry) } diff --git a/internal/cli/initialize/core/claude.go b/internal/cli/initialize/core/claude.go index dc2f9e6b..b3983fd1 100644 --- a/internal/cli/initialize/core/claude.go +++ b/internal/cli/initialize/core/claude.go @@ -25,11 +25,12 @@ import ( // - cmd: Cobra command for output // - force: If true, overwrite existing ctx section // - autoMerge: If true, skip interactive confirmation +// - caller: Editor caller ID (e.g. "vscode"); empty for CLI/Claude Code // // Returns: // - error: Non-nil if file operations fail -func HandleClaudeMd(cmd *cobra.Command, force, autoMerge bool) error { - templateContent, err := assets.ClaudeMd() +func HandleClaudeMd(cmd *cobra.Command, force, autoMerge bool, caller string) error { + templateContent, err := assets.ClaudeMdForCaller(caller) if err != nil { return fmt.Errorf("failed to read CLAUDE.md template: %w", err) } diff --git a/internal/cli/initialize/core/vscode.go b/internal/cli/initialize/core/vscode.go new file mode 100644 index 00000000..9fdb8d68 --- /dev/null +++ b/internal/cli/initialize/core/vscode.go @@ -0,0 +1,163 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config" +) + +// vscodeDirName is the VS Code workspace configuration directory. +const vscodeDirName = ".vscode" + +// CreateVSCodeArtifacts generates VS Code-native configuration files +// as the editor-specific counterpart to Claude Code's settings and hooks. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if file creation fails +func CreateVSCodeArtifacts(cmd *cobra.Command) error { + if err := os.MkdirAll(vscodeDirName, config.PermExec); err != nil { + return fmt.Errorf("failed to create %s/: %w", vscodeDirName, err) + } + + // .vscode/extensions.json — recommend the ctx extension to collaborators + if err := writeExtensionsJSON(cmd); err != nil { + cmd.Println(fmt.Sprintf(" ⚠ extensions.json: %v", err)) + } + + // .vscode/tasks.json — register ctx commands as VS Code tasks + if err := writeTasksJSON(cmd); err != nil { + cmd.Println(fmt.Sprintf(" ⚠ tasks.json: %v", err)) + } + + return nil +} + +func writeExtensionsJSON(cmd *cobra.Command) error { + target := filepath.Join(vscodeDirName, "extensions.json") + + if _, err := os.Stat(target); err == nil { + // Exists — check if recommendation is already present + data, readErr := os.ReadFile(filepath.Clean(target)) //nolint:gosec // path built from constants + if readErr != nil { + return readErr + } + var existing map[string]interface{} + if json.Unmarshal(data, &existing) == nil { + if recs, ok := existing["recommendations"].([]interface{}); ok { + for _, r := range recs { + if r == "activememory.ctx-context" { + cmd.Println(fmt.Sprintf(" ○ %s (recommendation exists)", target)) + return nil + } + } + } + } + // File exists but doesn't have our recommendation — leave it alone + cmd.Println(fmt.Sprintf(" ○ %s (exists, add activememory.ctx-context manually)", target)) + return nil + } + + content := map[string][]string{ + "recommendations": {"activememory.ctx-context"}, + } + data, _ := json.MarshalIndent(content, "", " ") + data = append(data, '\n') + + if err := os.WriteFile(target, data, config.PermFile); err != nil { + return err + } + cmd.Println(fmt.Sprintf(" ✓ %s", target)) + return nil +} + +func writeTasksJSON(cmd *cobra.Command) error { + target := filepath.Join(vscodeDirName, "tasks.json") + + if _, err := os.Stat(target); err == nil { + cmd.Println(fmt.Sprintf(" ○ %s (exists, skipped)", target)) + return nil + } + + tasks := map[string]interface{}{ + "version": "2.0.0", + "tasks": []map[string]interface{}{ + { + "label": "ctx: status", + "type": "shell", + "command": "ctx status", + "group": "none", + "presentation": map[string]interface{}{ + "reveal": "always", + "panel": "shared", + }, + "problemMatcher": []interface{}{}, + }, + { + "label": "ctx: drift", + "type": "shell", + "command": "ctx drift", + "group": "none", + "presentation": map[string]interface{}{ + "reveal": "always", + "panel": "shared", + }, + "problemMatcher": []interface{}{}, + }, + { + "label": "ctx: agent", + "type": "shell", + "command": "ctx agent --budget 4000", + "group": "none", + "presentation": map[string]interface{}{ + "reveal": "always", + "panel": "shared", + }, + "problemMatcher": []interface{}{}, + }, + { + "label": "ctx: journal", + "type": "shell", + "command": "ctx recall export --all && ctx journal site --build", + "group": "none", + "presentation": map[string]interface{}{ + "reveal": "always", + "panel": "shared", + }, + "problemMatcher": []interface{}{}, + }, + { + "label": "ctx: journal-serve", + "type": "shell", + "command": "ctx journal site --serve", + "group": "none", + "presentation": map[string]interface{}{ + "reveal": "always", + "panel": "shared", + }, + "problemMatcher": []interface{}{}, + }, + }, + } + data, _ := json.MarshalIndent(tasks, "", " ") + data = append(data, '\n') + + if err := os.WriteFile(target, data, config.PermFile); err != nil { + return err + } + cmd.Println(fmt.Sprintf(" ✓ %s", target)) + return nil +} diff --git a/internal/cli/initialize/init.go b/internal/cli/initialize/init.go index 947c458d..1a162af6 100644 --- a/internal/cli/initialize/init.go +++ b/internal/cli/initialize/init.go @@ -55,6 +55,7 @@ func Cmd() *cobra.Command { merge bool ralph bool noPluginEnable bool + caller string ) short, long := assets.CommandDesc("initialize") @@ -64,7 +65,7 @@ func Cmd() *cobra.Command { Annotations: map[string]string{config.AnnotationSkipInit: "true"}, Long: long, RunE: func(cmd *cobra.Command, args []string) error { - return initroot.Run(cmd, force, minimal, merge, ralph, noPluginEnable) + return initroot.Run(cmd, force, minimal, merge, ralph, noPluginEnable, caller) }, } @@ -89,6 +90,10 @@ func Cmd() *cobra.Command { &noPluginEnable, "no-plugin-enable", false, assets.FlagDesc("initialize.no-plugin-enable"), ) + cmd.Flags().StringVar( + &caller, "caller", "", + "Identify the calling tool (e.g. vscode) to tailor output", + ) return cmd } diff --git a/internal/cli/system/cmd/sessionevent/cmd.go b/internal/cli/system/cmd/sessionevent/cmd.go new file mode 100644 index 00000000..58b7f5d3 --- /dev/null +++ b/internal/cli/system/cmd/sessionevent/cmd.go @@ -0,0 +1,68 @@ +// / 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" + + "github.com/ActiveMemory/ctx/internal/cli/system/core" + "github.com/ActiveMemory/ctx/internal/eventlog" + "github.com/ActiveMemory/ctx/internal/notify" +) + +// 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 + + cmd := &cobra.Command{ + Use: "session-event", + Short: "Record session start or end", + 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`, + Hidden: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return runSessionEvent(cmd, eventType, caller) + }, + } + + cmd.Flags().StringVar(&eventType, "type", "", "Event type: start or end") + cmd.Flags().StringVar(&caller, "caller", "", "Calling editor (e.g., vscode)") + _ = cmd.MarkFlagRequired("type") + _ = cmd.MarkFlagRequired("caller") + + return cmd +} + +func runSessionEvent(cmd *cobra.Command, eventType, caller string) error { + if !core.IsInitialized() { + return nil + } + + if eventType != "start" && eventType != "end" { + return fmt.Errorf("--type must be 'start' or 'end', got %q", eventType) + } + + msg := fmt.Sprintf("session-%s: %s", eventType, caller) + ref := notify.NewTemplateRef("session-event", eventType, + map[string]any{"Caller": caller}) + + eventlog.Append("session", msg, "", ref) + _ = notify.Send("session", msg, "", ref) + + cmd.Println(msg) + return nil +} diff --git a/internal/cli/system/system.go b/internal/cli/system/system.go index 6bc058ef..b756c1e5 100644 --- a/internal/cli/system/system.go +++ b/internal/cli/system/system.go @@ -37,6 +37,7 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/system/cmd/qareminder" "github.com/ActiveMemory/ctx/internal/cli/system/cmd/resources" "github.com/ActiveMemory/ctx/internal/cli/system/cmd/resume" + "github.com/ActiveMemory/ctx/internal/cli/system/cmd/sessionevent" "github.com/ActiveMemory/ctx/internal/cli/system/cmd/specsnudge" "github.com/ActiveMemory/ctx/internal/cli/system/cmd/stats" ) @@ -131,6 +132,7 @@ Hook subcommands (Claude Code plugin — safe to run manually): specsnudge.Cmd(), checkmemorydrift.Cmd(), heartbeat.Cmd(), + sessionevent.Cmd(), ) return cmd diff --git a/internal/compliance/compliance_test.go b/internal/compliance/compliance_test.go index 2a4d2099..07a90e64 100644 --- a/internal/compliance/compliance_test.go +++ b/internal/compliance/compliance_test.go @@ -863,3 +863,99 @@ func TestPermissionConstants(t *testing.T) { } }) } + +// 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, err error) error { + if err != nil { + return err + } + 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 +} + +// --------------------------------------------------------------------------- +// 21. 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, err := os.ReadFile(filepath.Clean(p)) + if err != nil { + t.Fatalf("read: %v", err) + } + if bytes.HasPrefix(data, bom) { + t.Errorf("file starts with UTF-8 BOM (0xEF 0xBB 0xBF); remove it") + } + }) + } +} + +// --------------------------------------------------------------------------- +// 22. 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, err := os.ReadFile(filepath.Clean(p)) + if err != nil { + t.Fatalf("read: %v", err) + } + 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/validation/path.go b/internal/validation/path.go index e54ebc2c..c4312d58 100644 --- a/internal/validation/path.go +++ b/internal/validation/path.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" ) @@ -43,9 +44,18 @@ func ValidateBoundary(dir string) error { // 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) { - return fmt.Errorf("context directory %q resolves outside project root %q", dir, resolvedCwd) + if runtime.GOOS == "windows" { + if !strings.EqualFold(resolvedDir, resolvedCwd) && !strings.HasPrefix(strings.ToLower(resolvedDir), strings.ToLower(root)) { + return fmt.Errorf("context directory %q resolves outside project root %q", dir, resolvedCwd) + } + } else { + if resolvedDir != resolvedCwd && !strings.HasPrefix(resolvedDir, root) { + return fmt.Errorf("context directory %q resolves outside project root %q", dir, resolvedCwd) + } } return nil diff --git a/internal/validation/path_test.go b/internal/validation/path_test.go index 63d6add9..ae243f1b 100644 --- a/internal/validation/path_test.go +++ b/internal/validation/path_test.go @@ -9,6 +9,8 @@ package validation import ( "os" "path/filepath" + "runtime" + "strings" "testing" ) @@ -42,6 +44,41 @@ func TestValidateBoundary(t *testing.T) { } } +func TestValidateBoundaryCaseInsensitive(t *testing.T) { + if runtime.GOOS != "windows" { + 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 := ValidateBoundary(".context"); err != nil { + t.Errorf("ValidateBoundary(.context) with case-mangled cwd: %v", err) + } +} + func TestCheckSymlinks(t *testing.T) { t.Run("regular directory passes", func(t *testing.T) { dir := t.TempDir() diff --git a/specs/vscode-feature-parity.md b/specs/vscode-feature-parity.md new file mode 100644 index 00000000..5b4ea562 --- /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 hook 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