From c2aca4528a842f3debfbfd17be9bdbe7acef7f45 Mon Sep 17 00:00:00 2001 From: Ersan Bilik Date: Sat, 26 Sep 2026 23:40:20 +0300 Subject: [PATCH 1/2] feat(vscode): skill-backed @ctx participant, verified against the CLI Reworks the closed #128 onto current main. The participant on main is dead on arrival: every invocation passes --no-color, which the CLI no longer has, and recall/add/notify/system/pause/resume/reindex/ prompt/dep/loop no longer match the command tree. #128 fixed some of that but left dead commands of its own (decision/learning reindex, bare `ctx decision`, split multi-word arguments) and re-implemented skills in TypeScript against file formats ctx no longer uses. CLI-backed commands (27) now build argv for the current tree: noun- verb ` add` with provenance filled in (VS Code session ID, git branch and commit), `journal source`, `hook notify|message|pause| resume`, `sysinfo`, `usage`, `index .context/DECISIONS.md`, and so on. runCtx runs without a shell (prompt text on Windows split arguments and `&` ran a second command), closes stdin so a prompting command fails fast, and returns the exit code; one runAndRender shows any non-zero exit as a failure with the CLI's own message. Skill-backed commands (9: brainstorm, spec, implement, next, remember, reflect, wrap-up, blog, consolidate) send the canonical ctx- SKILL.md, bundled from internal/assets/claude/skills at build time, to the chat model together with `ctx agent` output, skill-specific read-only ctx output, earlier turns of the same skill, and #file attachments. The model proposes commands; it cannot run them and is told never to claim it did. Only skill exchanges are sent back to the model, so /pad or /notify input never is. Guard: commandParity.test.ts checks package.json against the dispatch tables, drives every command branch through the chat handler, and snapshots each ctx argv to src/ctx-cli-surface.json. internal/bootstrap/vscode_surface_test.go parses every recorded argv against the real cobra tree (unknown command, flag, or subcommand; bad positional args), runs the entry adds in a scratch project, and checks each bundled skill still ships, so a CLI rename fails go test. Also: natural-language routing reaches only read-only commands; the init gate is gone (the CLI decides, and failures point at /init); the save watcher (its hook exits silently without stdin), commit and dependency popups, and heartbeat file are dropped; /notify setup sends the user to a terminal so the webhook URL stays out of chat; multi-root windows use the active editor's folder. Version 0.10.0; the released 0.9.0 changelog section is unchanged. Refs #127, #128. Spec: specs/vscode-skill-backed-participant.md Signed-off-by: Ersan Bilik --- editors/vscode/CHANGELOG.md | 58 + editors/vscode/README.md | 293 ++- editors/vscode/package-lock.json | 4 +- editors/vscode/package.json | 92 +- editors/vscode/src/commandParity.test.ts | 186 ++ editors/vscode/src/ctx-cli-surface.json | 80 + editors/vscode/src/extension.test.ts | 947 +++++----- editors/vscode/src/extension.ts | 2045 +++++++++------------ editors/vscode/src/md.d.ts | 6 + editors/vscode/src/vscodeMock.ts | 53 + editors/vscode/vitest.config.ts | 10 + internal/bootstrap/vscode_surface_test.go | 178 ++ specs/vscode-skill-backed-participant.md | 139 ++ 13 files changed, 2147 insertions(+), 1944 deletions(-) create mode 100644 editors/vscode/src/commandParity.test.ts create mode 100644 editors/vscode/src/ctx-cli-surface.json create mode 100644 editors/vscode/src/md.d.ts create mode 100644 editors/vscode/src/vscodeMock.ts create mode 100644 internal/bootstrap/vscode_surface_test.go create mode 100644 specs/vscode-skill-backed-participant.md diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md index f3129a835..441e85df7 100644 --- a/editors/vscode/CHANGELOG.md +++ b/editors/vscode/CHANGELOG.md @@ -5,6 +5,64 @@ will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [0.10.0] - Unreleased + +### Added + +- **Skill-backed commands**: `/brainstorm`, `/spec`, `/implement`, + `/next`, `/remember`, `/reflect`, `/wrap-up`, `/blog`, and + `/consolidate` each run the canonical `ctx-` skill through the + chat model. The skill text is bundled from + `internal/assets/claude/skills/` at build time; each request is + grounded in `ctx agent` output, the read-only ctx output the skill + relies on, earlier turns of the same skill conversation, and `#file` + attachments. The model proposes commands and edits; it never claims to + have run them. A plain reply after a skill answer continues that skill. +- `/decision` and `/learning` list entries (`ctx index`). +- Reminder status bar: `$(bell) ctx` while `ctx remind list` has entries. +- Session start and end events (`ctx system session-event`) on + activation, after `/init`, and on deactivation. +- **Command-parity guard.** `commandParity.test.ts` checks that + `package.json` declares exactly the dispatched commands, drives every + command branch through the chat handler, and records each `ctx` argv in + `src/ctx-cli-surface.json`. The Go test + `internal/bootstrap/vscode_surface_test.go` parses every recorded argv + against the real command tree, runs the entry `add` invocations, and + checks every bundled skill still ships, so a CLI rename fails CI. + +### Fixed + +- **Every command targets the current CLI.** All invocations passed the + removed `--no-color` flag, so every command failed. Also reconciled: + `recall list` → `journal source`; `add ` → ` add` with the + required provenance (`--session-id`, `--branch`, `--commit`); + `notify` → `hook notify`; `system resources` → `sysinfo`; + `system message` → `hook message`; `pause`/`resume` → + `hook pause`/`hook resume`; `/why` with no argument no longer opens the + CLI's interactive menu. +- **Failures are shown as failures.** A non-zero exit renders the CLI's + message under "exited with code N" instead of as a normal result; + cancellation, the 30s timeout, and spawn or buffer failures are + errors, never partial output. +- **No shell on Windows.** Prompt text reached `cmd.exe` unquoted, so a + multi-word argument split apart and `&` ran a second command. The CLI + now runs without a shell, and multi-word values stay one argument + (`/pad edit`, `/notify `, quoted `/add` flag values). +- stdin is closed, so no command waits on a prompt until the timeout. +- Natural-language routing only reaches read-only commands; a keyword + match can no longer complete a task or add a reminder. +- In a multi-root window, commands run in the folder of the active + editor. + +### Removed + +- `/prompt` and `/dep`: `ctx prompt` and `ctx dep` no longer exist. +- `/reindex`: `ctx reindex` no longer exists; indices are projected on + demand (`/decision`, `/learning`). +- `/loop`: `ctx loop` writes a shell script that drives a terminal agent, + not a chat workflow. +- `/site`: `ctx site` is a hidden maintainer command. + ## [0.9.0] - 2026-03-19 ### Added diff --git a/editors/vscode/README.md b/editors/vscode/README.md index f23883738..e04de7a55 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -11,9 +11,9 @@ A VS Code Chat Participant that brings [ctx](https://ctx.ist) (persistent project context for AI coding sessions) directly into GitHub Copilot Chat. -Type `@ctx` in the Chat view to access 45 slash commands, automatic context -hooks, a reminder status bar, and natural language routing, all powered by -the ctx CLI. +Type `@ctx` in the Chat view for 36 slash commands: 27 run the ctx CLI, +and 9 run a canonical ctx skill (brainstorm, spec, next, wrap-up, ...) +through the chat model, grounded in live ctx output. ## Quick Start @@ -25,122 +25,115 @@ The extension auto-downloads the ctx CLI binary if it isn't on your PATH. ## Slash Commands -### Core Context - -| Command | Description | -|---------|-------------| -| `/init` | Initialize a `.context/` directory with template files | -| `/status` | Show context summary with token estimate | -| `/agent` | Print AI-ready context packet | -| `/drift` | Detect stale or invalid context | -| `/recall` | Browse and search AI session history | -| `/hook` | Generate AI tool integration configs (copilot, claude) | -| `/add` | Add a task, decision, learning, or convention | -| `/load` | Output assembled context Markdown | -| `/compact` | Archive completed tasks and clean up context | -| `/sync` | Reconcile context with codebase | - -### Tasks & Reminders - -| Command | Description | -|---------|-------------| -| `/complete` | Mark a task as completed | -| `/remind` | Manage session-scoped reminders (add, list, dismiss) | -| `/tasks` | Archive or snapshot tasks | -| `/next` | Show the next open task from TASKS.md | -| `/implement` | Show the implementation plan with progress | - -### Session Lifecycle - -| Command | Description | -|---------|-------------| -| `/wrapup` | End-of-session wrap-up with status, drift, and journal audit | -| `/remember` | Recall recent AI sessions for this project | -| `/reflect` | Surface items worth persisting as decisions or learnings | -| `/pause` | Save session state for later | -| `/resume` | Restore a paused session | - -### Discovery & Planning - -| Command | Description | -|---------|-------------| -| `/brainstorm` | Browse and develop ideas from `ideas/` | -| `/spec` | List or scaffold feature specs from templates | -| `/verify` | Run verification checks (doctor + drift) | -| `/map` | Show dependency map (go.mod, package.json) | -| `/prompt` | Browse and view prompt templates | -| `/blog` | Draft a blog post from recent context | -| `/changelog` | Show recent commits for changelog | - -### Maintenance & Audit - -| Command | Description | -|---------|-------------| -| `/check-links` | Audit local links in context files | -| `/journal` | View or export journal entries | -| `/consolidate` | Find duplicate entries across context files | -| `/audit` | Alignment audit: drift + convention check | -| `/worktree` | Git worktree management (list, add) | - -### Context Metadata - -| Command | Description | -|---------|-------------| -| `/memory` | Claude Code memory bridge (sync, status, diff, import, publish) | -| `/decisions` | List or reindex project decisions | -| `/learnings` | List or reindex project learnings | -| `/config` | Manage config profiles (switch, status, schema) | -| `/permissions` | Backup or restore Claude settings | -| `/changes` | Show what changed since last session | -| `/deps` | Show package dependency graph | -| `/guide` | Quick-reference cheat sheet for ctx | -| `/reindex` | Regenerate indices for DECISIONS.md and LEARNINGS.md | -| `/why` | Read the philosophy behind ctx | - -### System & Diagnostics - -| Command | Description | -|---------|-------------| -| `/system` | System diagnostics and bootstrap | -| `/pad` | Encrypted scratchpad for sensitive notes | -| `/notify` | Send webhook notifications | - -Sub-routes for `/system`: `resources`, `doctor`, `bootstrap`, `stats`, -`backup`, `message`. - -## Automatic Hooks - -The extension registers several VS Code event handlers that mirror -Claude Code's hook system. These run in the background; no user action -needed. +### CLI-Backed + +Each runs the `ctx` command shown and renders its output. A command that +exits non-zero is shown as a failure with the CLI's own message, never +as a normal result. + +| Command | Runs | +|---------|------| +| `/init` | `ctx init --caller vscode`, then `ctx setup copilot --write` | +| `/status` | `ctx status` | +| `/agent [--budget N]` | `ctx agent` | +| `/drift` | `ctx drift` | +| `/recall [--limit N]`, `/recall show ` | `ctx journal source` | +| `/setup [tool] [preview]` | `ctx setup --write` (default tool: `copilot`) | +| `/add [flags]` | `ctx task\|decision\|learning\|convention add` | +| `/decision` | `ctx index .context/DECISIONS.md` | +| `/learning` | `ctx index .context/LEARNINGS.md` | +| `/load` | `ctx load` | +| `/compact` | `ctx compact` | +| `/sync` | `ctx sync` | +| `/task complete \|archive\|snapshot [name]` | `ctx task ...` | +| `/remind [add\|list\|dismiss]` | `ctx remind ...` | +| `/pad [add\|show\|rm\|edit\|mv\|resolve\|import\|export\|merge]` | `ctx pad ...` | +| `/notify test`, `/notify --event ` | `ctx hook notify ...` | +| `/system resources\|stats\|bootstrap\|message` | `ctx sysinfo`, `ctx usage`, `ctx system bootstrap`, `ctx hook message ...` | +| `/memory sync\|status\|diff\|import\|publish\|unpublish` | `ctx memory ...` | +| `/journal site\|obsidian` | `ctx journal ...` | +| `/doctor` | `ctx doctor` | +| `/config switch \|status\|schema` | `ctx config ...` | +| `/why [document]` | `ctx why ` (default: `manifesto`) | +| `/change [--since D]` | `ctx change` | +| `/guide [--skills\|--commands]` | `ctx guide` | +| `/permission snapshot\|restore` | `ctx permission ...` | +| `/pause`, `/resume` | `ctx hook pause`, `ctx hook resume` | + +`/add` fills in the provenance the CLI requires for tasks, decisions, +and learnings (`--session-id` from the VS Code session, `--branch` and +`--commit` from git). Everything else is passed through, so the +CLI's own rules apply: tasks and conventions need `--section`, decisions +need `--context`, `--rationale`, `--consequence`, and learnings need +`--context`, `--lesson`, `--application`. Quote multi-word values: + +```text +@ctx /add decision Use PostgreSQL --context "Need a reliable DB" --rationale "ACID and JSON" --consequence "Ops training" +``` + +`/notify setup` points you at `ctx hook notify setup` in a terminal: the +webhook URL is a secret and does not belong in the chat history. + +### Skill-Backed + +`/` runs the canonical `ctx-` skill. The skill text is bundled +from `internal/assets/claude/skills/` at build time, and each request +hands the chat model the skill, the output of `ctx agent` (plus any +read-only ctx output the skill relies on), earlier turns of the same +skill conversation, and files you attach with `#file`. + +| Command | Skill | Also reads | +|---------|-------|------------| +| `/brainstorm` | `ctx-brainstorm` | | +| `/spec` | `ctx-spec` | | +| `/implement` | `ctx-implement` | attach the plan with `#file` | +| `/next` | `ctx-next` | `ctx journal source --limit 3` | +| `/remember` | `ctx-remember` | `ctx journal source --limit 3` | +| `/reflect` | `ctx-reflect` | | +| `/wrap-up` | `ctx-wrap-up` | | +| `/blog` | `ctx-blog` | `ctx journal source --limit 10` | +| `/consolidate` | `ctx-consolidate` | `ctx drift --json` | + +The model cannot run commands or edit files from here. Where a skill +says to persist something, it gives you the exact `ctx` or `@ctx` +command to run instead, and it never claims to have done it. A plain +reply right after a skill answer continues that skill, so multi-turn +workflows like `/brainstorm` keep their thread. Only skill exchanges +are sent back to the model: output of CLI commands such as `/pad` never +is. + +Skills that must explore the repository or run commands on their own +(`ctx-architecture`, `ctx-link-check`, `ctx-worktree`, +`ctx-blog-changelog`) are left to agent integrations where ctx deploys +its skills (Claude Code, `ctx setup copilot-cli`). + +## Background Behavior | 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 | +| **Extension activate** | Fires `ctx system session-event --type start` | +| **`/init` succeeds** | Fires the same session start (activation had no `.context/` yet) | +| **`.context/` file change, and every 5 minutes** | Refreshes the reminder status bar from `ctx remind list` (read-only) | +| **Extension deactivate** | Fires `ctx system session-event --type end` | ## 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. +A `$(bell) ctx` indicator appears in the status bar while `ctx remind +list` has pending reminders, and hides when the list is empty. ## Natural Language -You can also type plain English after `@ctx`: the extension routes -common phrases to the correct handler: +Plain English after `@ctx` routes to a read-only command: +- "Do you remember?" → `/remember` - "What should I work on next?" → `/next` -- "Time to wrap up" → `/wrapup` +- "Time to wrap up" → `/wrap-up` - "Show me the status" → `/status` -- "Add a decision" → `/add` - "Check for drift" → `/drift` +A keyword match never changes context: it cannot add, complete, or +dismiss anything. Unmatched text shows the command list. + ## Auto-Bootstrap If the ctx CLI isn't found on PATH or at the configured path, the @@ -157,13 +150,13 @@ set `ctx.executablePath` in your settings. ## Follow-Up Suggestions -After each command, Copilot Chat shows context-aware follow-up buttons. -For example: +After a command, Copilot Chat offers context-aware follow-ups. 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" +- After `/init` → "Show context status" or "What should I work on next?" +- After `/drift` → "Sync context with codebase" or "Run health check" +- After `/brainstorm` → "Turn this into a spec" +- After `/reflect` → "Wrap up the session" ## Prerequisites @@ -184,38 +177,46 @@ cd editors/vscode npm install npm run watch # Watch mode npm run build # Production build -npm test # Run tests (53 test cases via vitest) +npm test # vitest +npm run lint # eslint ``` ### Architecture -The extension is a single-file implementation -(`src/extension.ts`, ~3 000 lines) that: +The extension is a single-file implementation (`src/extension.ts`) 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 +- Dispatches slash commands through two tables: `CLI_COMMANDS` (handlers + that build a `ctx` argv) and `SKILLS` (command → canonical skill) +- Runs the ctx CLI via `execFile` **without a shell**, so prompt text + reaches the binary as literal arguments, with stdin closed so no + command can wait on a prompt +- Bundles the skill files with esbuild's text loader + (`--loader:.md=text`); `vitest.config.ts` mirrors the loader ### 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 +- `src/extension.test.ts`: handler behavior against a mocked + `execFile` and a VS Code API mock (`src/vscodeMock.ts`). +- `src/commandParity.test.ts`: `package.json` commands == dispatched + commands; each skill-backed command bundles the skill it names; every + follow-up and natural-language route targets a real command. It then + drives a scenario per command branch through the chat handler and + records every `ctx` argv in `src/ctx-cli-surface.json` (a file + snapshot). +- `internal/bootstrap/vscode_surface_test.go` (Go, runs with `go test + ./...`): parses every argv in that snapshot against the real cobra + command tree, runs the `add` invocations in a scratch project, and + checks every listed skill ships. A CLI rename that strands a chat + command fails there. + +After changing what a command runs, refresh the snapshot and review the +diff: -> **Note**: the test file currently has unresolved type errors -> (handler imports that no longer exist on `extension.ts`, and -> a `CancellationToken` mock with an out-of-date signature). The -> tests still run under vitest's loose runtime, but `tsc` against -> them fails. Tracked in TASKS.md; until fixed, the CI gate uses -> `tsconfig.ci.json` which excludes `**/*.test.ts`. +```bash +npx vitest run -u +git diff src/ctx-cli-surface.json +``` ## Release @@ -229,22 +230,16 @@ job in `.github/workflows/ci.yml`) run on every PR and push to `main`: - `npm ci`: clean dependency install from the committed lockfile. -- `npm run build`: esbuild bundles `src/extension.ts` to - `dist/extension.js`. Catches bundler errors and missing imports - at the JavaScript level. -- `npx tsc --noEmit -p tsconfig.ci.json`: type-checks the - production source (`src/**/*.ts` minus test files). Catches type - errors that esbuild silently passes through. - -What CI does **not** gate yet (known gaps): - -- **Tests** (`npm test`, vitest). The suite has type errors - unrelated to the production code; until they're fixed, gating - on vitest would force resolving them before any merge. -- **Lint** (`npm run lint`, eslint). -- **Publish dry-run** (`vsce package` to produce the `.vsix` - artifact without uploading). Worth adding once the test gate - is back. +- `npm run build`: esbuild bundles `src/extension.ts` (and the skill + files it imports) to `dist/extension.js`. +- `npx tsc --noEmit -p tsconfig.ci.json`: type-checks the source and + the tests. +- `npm run lint`: eslint. +- `npm test`: vitest, including the command-parity snapshot. +- `npx vsce package --no-dependencies`: packaging dry-run. + +The Go `test` job runs `vscode_surface_test.go` against the same +snapshot. Release checklist for a maintainer: diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json index 485503d68..f4611f5fa 100644 --- a/editors/vscode/package-lock.json +++ b/editors/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "ctx-context", - "version": "0.8.1", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ctx-context", - "version": "0.8.1", + "version": "0.10.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "^20.0.0", diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 19a160c30..4dfe4f5e5 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.8.1", + "version": "0.10.0", "publisher": "activememory", "license": "Apache-2.0", "homepage": "https://github.com/ActiveMemory/ctx", @@ -53,7 +53,7 @@ "commands": [ { "name": "init", - "description": "Initialize a new .context/ directory with template files" + "description": "Initialize a new .context/ directory and Copilot instructions" }, { "name": "status", @@ -69,15 +69,23 @@ }, { "name": "recall", - "description": "Browse and search AI session history" + "description": "Browse AI session history (list, show)" }, { - "name": "hook", - "description": "Generate AI tool integration configs (e.g. copilot, claude)" + "name": "setup", + "description": "Generate an AI tool integration config (e.g. copilot, claude-code)" }, { "name": "add", - "description": "Add a new item to a context file (task, decision, learning)" + "description": "Add a task, decision, learning, or convention" + }, + { + "name": "decision", + "description": "List DECISIONS.md entries" + }, + { + "name": "learning", + "description": "List LEARNINGS.md entries" }, { "name": "load", @@ -109,71 +117,83 @@ }, { "name": "system", - "description": "System diagnostics and bootstrap" + "description": "System resources, token usage, bootstrap, hook messages" }, { "name": "memory", - "description": "Memory bridge operations (sync, status, diff, import, publish)" + "description": "Bridge Claude Code auto memory into .context/" }, { "name": "journal", - "description": "Journal management (site, obsidian)" + "description": "Export the session journal (site, obsidian)" }, { "name": "doctor", - "description": "Context health diagnostics" + "description": "Structural health check" }, { "name": "config", - "description": "Runtime configuration (switch, status, schema)" - }, - { - "name": "prompt", - "description": "Prompt templates (list, add, show, rm)" + "description": "Runtime configuration profiles (switch, status, schema)" }, { "name": "why", - "description": "Show rationale for context design decisions" + "description": "Read the philosophy behind ctx" }, { "name": "change", - "description": "Show recent codebase changes" - }, - { - "name": "dep", - "description": "Show project dependencies" + "description": "Show what changed since last session" }, { "name": "guide", - "description": "Quick start guide" + "description": "Quick-reference cheat sheet for ctx" }, { "name": "permission", "description": "Permission snapshot and restore" }, { - "name": "site", - "description": "Documentation site (feed)" + "name": "pause", + "description": "Pause ctx context hooks" }, { - "name": "loop", - "description": "Generate autonomous iteration scripts" + "name": "resume", + "description": "Resume ctx context hooks" }, { - "name": "pause", - "description": "Pause context hooks for current session" + "name": "brainstorm", + "description": "Design before implementation (ctx-brainstorm skill)" }, { - "name": "resume", - "description": "Resume context hooks" + "name": "spec", + "description": "Draft a feature spec (ctx-spec skill)" + }, + { + "name": "implement", + "description": "Work through a plan step by step; attach it with #file (ctx-implement skill)" + }, + { + "name": "next", + "description": "Suggest what to work on next (ctx-next skill)" + }, + { + "name": "remember", + "description": "Structured readback of project context (ctx-remember skill)" + }, + { + "name": "reflect", + "description": "Surface what is worth persisting (ctx-reflect skill)" + }, + { + "name": "wrap-up", + "description": "End-of-session review that proposes entries to persist; writes nothing (ctx-wrap-up skill)" }, { - "name": "reindex", - "description": "Rebuild context file indices" + "name": "blog", + "description": "Draft a blog post from project progress (ctx-blog skill)" }, { - "name": "diag", - "description": "Diagnose extension issues — times each step to find hangs" + "name": "consolidate", + "description": "Propose merges for overlapping LEARNINGS.md or DECISIONS.md entries (ctx-consolidate skill)" } ], "disambiguation": [ @@ -206,8 +226,8 @@ }, "scripts": { "vscode:prepublish": "npm run build", - "build": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --minify", - "watch": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --watch", + "build": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --loader:.md=text --minify", + "watch": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --format=cjs --platform=node --loader:.md=text --watch", "test": "vitest run", "lint": "eslint src" }, diff --git a/editors/vscode/src/commandParity.test.ts b/editors/vscode/src/commandParity.test.ts new file mode 100644 index 000000000..34a64c541 --- /dev/null +++ b/editors/vscode/src/commandParity.test.ts @@ -0,0 +1,186 @@ +/** + * Command-parity guard. + * + * The chat participant once shipped commands that dispatched to `ctx` + * subcommands the binary does not have. Unit tests could not see it: they + * mock `execFile`, so any argv "passes". This suite closes the gap in two + * halves: + * + * 1. Here: package.json commands == the dispatcher's commands; every + * skill-backed command bundles the skill it names; follow-ups and + * natural-language routes target real commands. Then every scenario + * below is driven through the real chat handler and each `ctx` argv it + * produces is recorded in `ctx-cli-surface.json` (a file snapshot, so + * an unreviewed change to what the extension runs fails this test). + * + * 2. In Go: internal/bootstrap/vscode_surface_test.go parses every argv in + * that file against the real cobra command tree (unknown commands, + * unknown flags, bad positional arguments, missing required fields) and + * checks every listed skill exists. It runs in the main Go test job, so + * a CLI rename that strands a VS Code command fails CI there. + * + * Refresh the snapshot after changing what a command runs: + * npx vitest run -u (from editors/vscode) + */ +import { describe, it, expect, vi, beforeAll } from "vitest"; +import * as cp from "child_process"; +import * as fs from "fs"; +import * as path from "path"; + +vi.mock("vscode", async () => (await import("./vscodeMock")).createVscodeMock()); +vi.mock("child_process"); + +import { + handler, + CLI_COMMANDS, + SKILLS, + FREEFORM, + FOLLOWUPS, + BACKGROUND_INVOCATIONS, +} from "./extension"; + +// vitest runs with the extension package as the working directory. +const SKILLS_DIR = path.resolve("..", "..", "internal", "assets", "claude", "skills"); + +const manifest: string[] = JSON.parse(fs.readFileSync("package.json", "utf8")) + .contributes.chatParticipants[0].commands.map((c: { name: string }) => c.name); + +/** + * Prompts that exercise every branch that runs ctx. A new command or a new + * branch needs a row here, or its argv is never validated. + */ +const SCENARIOS: Record = { + init: [""], + status: [""], + agent: ["", "--budget 4000"], + drift: [""], + recall: ["", "--limit 5", "show 1a2b3c4d"], + setup: ["", "claude-code", "cursor preview"], + add: [ + 'task Fix login bug --section "Phase 1"', + "task Fix login bug --section Maintenance --priority high", + 'decision Use PostgreSQL --context "Need a reliable DB" --rationale "ACID and JSON" --consequence "Ops training"', + 'learning Go embed is package-local --context "Embedding a parent dir failed" --lesson "No parent paths" --application "Keep assets beside the package"', + "convention Use camelCase for functions --section Naming", + ], + decision: [""], + learning: [""], + load: [""], + compact: [""], + sync: [""], + task: ["complete 3", "archive", "snapshot", "snapshot pre-refactor"], + remind: ["", "add Check CI", "Check CI", "dismiss 2", "dismiss"], + pad: [ + "", + "add a secret note", + "show 1", + "rm 1 2", + "edit 1 new text", + "mv 1 3", + "resolve", + "import notes.txt", + "export", + "export out", + "merge a.enc b.enc", + ], + notify: ["setup", "test", "build done --event build"], + system: ["resources", "bootstrap", "stats", "message", "message show check-freshness stale"], + memory: ["sync", "status", "diff", "import", "publish", "unpublish"], + journal: ["site", "obsidian"], + doctor: [""], + config: ["switch dev", "status", "schema"], + why: ["", "manifesto"], + change: ["", "--since 2h"], + guide: ["", "--skills", "--commands"], + permission: ["snapshot", "restore"], + pause: [""], + resume: [""], + ...Object.fromEntries(Object.keys(SKILLS).map((c) => [c, [""]])), +}; + +describe("manifest ↔ dispatcher", () => { + it("declares exactly the commands it dispatches", () => { + const dispatched = [...Object.keys(CLI_COMMANDS), ...Object.keys(SKILLS)]; + expect([...manifest].sort()).toEqual([...dispatched].sort()); + expect(new Set(dispatched).size).toBe(dispatched.length); + }); + + it("routes follow-ups and natural language only to real commands", () => { + const targets = [ + ...Object.keys(FOLLOWUPS).filter((c) => c !== "help"), + ...Object.values(FOLLOWUPS).flat().map((f) => f.command), + ...FREEFORM.map(([, command]) => command), + ]; + expect(targets.filter((c) => !c || !manifest.includes(c))).toEqual([]); + }); +}); + +describe("skill-backed commands", () => { + it.each(Object.entries(SKILLS))("/%s bundles the skill it names", (command, def) => { + expect(def.skill).toBe(`ctx-${command}`); + const file = path.join(SKILLS_DIR, def.skill, "SKILL.md"); + expect(fs.existsSync(file), `${file} missing`).toBe(true); + expect(def.text).toBe(fs.readFileSync(file, "utf8")); + }); +}); + +describe("ctx CLI surface", () => { + const argvs: string[][] = []; + + beforeAll(async () => { + vi.mocked(cp.execFile).mockImplementation((( + cmd: string, + args: string[], + _opts: unknown, + cb: (e: unknown, out: string, err: string) => void + ) => { + if (cmd === "git") { + cb(null, args.includes("--abbrev-ref") ? "main\n" : "abc1234\n", ""); + } else { + // `--version` is the bootstrap probe, not a command. + if (args[0] !== "--version") { + argvs.push(args); + } + cb(null, "ok", ""); + } + return { kill: () => {} }; + }) as never); + const model = { + sendRequest: async () => ({ text: (async function* () {})() }), + }; + const token = { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) }; + const stream = { markdown: () => {}, progress: () => {} }; + for (const [command, prompts] of Object.entries(SCENARIOS)) { + for (const prompt of prompts) { + await handler( + { command, prompt, model, references: [] } as never, + { history: [] } as never, + stream as never, + token as never + ); + } + } + }); + + it("has a scenario for every command", () => { + expect(manifest.filter((c) => !SCENARIOS[c])).toEqual([]); + }); + + it("matches ctx-cli-surface.json (refresh: npx vitest run -u)", async () => { + const unique = new Map(); + for (const argv of [...argvs, ...BACKGROUND_INVOCATIONS]) { + unique.set(JSON.stringify(argv), argv); + } + const invocations = [...unique.keys()].sort(); + const skills = Object.values(SKILLS).map((s) => s.skill).sort(); + const surface = + "{\n" + + ' "_generated": "By editors/vscode/src/commandParity.test.ts (refresh: npx vitest run -u). ' + + 'Validated against the real ctx command tree by internal/bootstrap/vscode_surface_test.go.",\n' + + ` "skills": ${JSON.stringify(skills)},\n` + + ' "invocations": [\n' + + invocations.map((argv) => ` ${argv}`).join(",\n") + + "\n ]\n}\n"; + await expect(surface).toMatchFileSnapshot("./ctx-cli-surface.json"); + }); +}); diff --git a/editors/vscode/src/ctx-cli-surface.json b/editors/vscode/src/ctx-cli-surface.json new file mode 100644 index 000000000..f96dafc4d --- /dev/null +++ b/editors/vscode/src/ctx-cli-surface.json @@ -0,0 +1,80 @@ +{ + "_generated": "By editors/vscode/src/commandParity.test.ts (refresh: npx vitest run -u). Validated against the real ctx command tree by internal/bootstrap/vscode_surface_test.go.", + "skills": ["ctx-blog","ctx-brainstorm","ctx-consolidate","ctx-implement","ctx-next","ctx-reflect","ctx-remember","ctx-spec","ctx-wrap-up"], + "invocations": [ + ["agent","--budget","4000"], + ["agent"], + ["change","--since","2h"], + ["change"], + ["compact"], + ["config","schema"], + ["config","status"], + ["config","switch","dev"], + ["convention","add","Use camelCase for functions","--section","Naming"], + ["decision","add","Use PostgreSQL","--context","Need a reliable DB","--rationale","ACID and JSON","--consequence","Ops training","--session-id","01234567","--branch","main","--commit","abc1234"], + ["doctor"], + ["drift","--json"], + ["drift"], + ["guide","--commands"], + ["guide","--skills"], + ["guide"], + ["hook","message","list"], + ["hook","message","show","check-freshness","stale"], + ["hook","notify","build done","--event","build"], + ["hook","notify","test"], + ["hook","pause"], + ["hook","resume"], + ["index",".context/DECISIONS.md"], + ["index",".context/LEARNINGS.md"], + ["init","--caller","vscode"], + ["journal","obsidian"], + ["journal","site"], + ["journal","source","--limit","10"], + ["journal","source","--limit","3"], + ["journal","source","--limit","5"], + ["journal","source","--show","1a2b3c4d"], + ["journal","source"], + ["learning","add","Go embed is package-local","--context","Embedding a parent dir failed","--lesson","No parent paths","--application","Keep assets beside the package","--session-id","01234567","--branch","main","--commit","abc1234"], + ["load"], + ["memory","diff"], + ["memory","import"], + ["memory","publish"], + ["memory","status"], + ["memory","sync"], + ["memory","unpublish"], + ["pad","add","a secret note"], + ["pad","edit","1","new text"], + ["pad","export","out"], + ["pad","export"], + ["pad","import","notes.txt"], + ["pad","merge","a.enc","b.enc"], + ["pad","mv","1","3"], + ["pad","resolve"], + ["pad","rm","1","2"], + ["pad","show","1"], + ["pad"], + ["permission","restore"], + ["permission","snapshot"], + ["remind","add","Check CI"], + ["remind","dismiss","--all"], + ["remind","dismiss","2"], + ["remind","list"], + ["setup","claude-code","--write"], + ["setup","copilot","--write"], + ["setup","cursor"], + ["status"], + ["sync"], + ["sysinfo"], + ["system","bootstrap"], + ["system","session-event","--type","end","--caller","vscode"], + ["system","session-event","--type","start","--caller","vscode"], + ["task","add","Fix login bug","--section","Maintenance","--priority","high","--session-id","01234567","--branch","main","--commit","abc1234"], + ["task","add","Fix login bug","--section","Phase 1","--session-id","01234567","--branch","main","--commit","abc1234"], + ["task","archive"], + ["task","complete","3"], + ["task","snapshot","pre-refactor"], + ["task","snapshot"], + ["usage"], + ["why","manifesto"] + ] +} diff --git a/editors/vscode/src/extension.test.ts b/editors/vscode/src/extension.test.ts index 4bd0cd766..2e57a17a5 100644 --- a/editors/vscode/src/extension.test.ts +++ b/editors/vscode/src/extension.test.ts @@ -1,40 +1,28 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import * as cp from "child_process"; +import * as vscode from "vscode"; -// Mock vscode module (external, not bundled) -vi.mock("vscode", () => ({ - workspace: { - getConfiguration: vi.fn(() => ({ - get: vi.fn(() => undefined), - })), - workspaceFolders: [{ uri: { fsPath: "/test/workspace" } }], - }, - chat: { - createChatParticipant: vi.fn(() => ({ - iconPath: null, - followupProvider: null, - })), - }, - Uri: { joinPath: vi.fn() }, -})); - +vi.mock("vscode", async () => (await import("./vscodeMock")).createVscodeMock()); vi.mock("child_process"); +import type { createVscodeMock } from "./vscodeMock"; +// The mocked module's classes, with constructors the real typings hide. +const vs = vscode as unknown as ReturnType; + import { runCtx, getCtxPath, getWorkspaceRoot, getPlatformInfo, - handleTask, - handleRemind, - handlePad, - handleNotify, - handleSystem, + handler, + tokenize, + CLI_COMMANDS, + SKILLS, } from "./extension"; -// Helper: create a fake CancellationToken. The listener signature -// matches VS Code's Event contract `(e: any) => any` — using -// `(cb: () => void)` here trips strict TS in the test surface. +type ExecCallback = (e: unknown, out: string, err: string) => void; + +// Helper: create a fake CancellationToken function fakeToken(cancelled = false) { type Listener = (e: unknown) => unknown; const listeners: Listener[] = []; @@ -48,13 +36,59 @@ function fakeToken(cancelled = false) { }; } +function fakeStream() { + return { + markdown: vi.fn(), + progress: vi.fn(), + }; +} + +/** execFile error for a process that exited with `code`. */ +function exitError(code: number) { + return Object.assign(new Error(`exit ${code}`), { code }); +} + +/** Every ctx call exits with `code` and prints `stdout`; git answers rev-parse. */ +function mockExec(stdout: string, code = 0, stderr = "") { + vi.mocked(cp.execFile).mockImplementation((( + cmd: string, + args: string[], + _opts: unknown, + cb: ExecCallback + ) => { + if (cmd === "git") { + cb(null, args.includes("--abbrev-ref") ? "main\n" : "abc1234\n", ""); + } else { + cb(code === 0 ? null : exitError(code), stdout, stderr); + } + return { kill: vi.fn() }; + }) as never); +} + +/** argv of every ctx (non-git) call so far. */ +function ctxCalls(): string[][] { + return vi + .mocked(cp.execFile) + .mock.calls.filter((c) => c[0] !== "git") + .map((c) => c[1] as unknown as string[]); +} + +function markdownOf(stream: ReturnType): string { + return stream.markdown.mock.calls.map((c) => c[0]).join("\n"); +} + +async function run(command: string, prompt: string) { + const stream = fakeStream(); + const res = await CLI_COMMANDS[command](stream as never, prompt, "/test", fakeToken() as never); + return { stream, res }; +} + describe("getCtxPath", () => { it("returns 'ctx' when no config is set", () => { expect(getCtxPath()).toBe("ctx"); }); - it("returns configured path when set", async () => { - const vscode = await import("vscode"); + it("returns configured path when set", () => { vi.mocked(vscode.workspace.getConfiguration).mockReturnValueOnce({ get: vi.fn(() => "/custom/ctx"), } as never); @@ -67,112 +101,100 @@ describe("getWorkspaceRoot", () => { expect(getWorkspaceRoot()).toBe("/test/workspace"); }); - it("returns undefined when no workspace is open", async () => { - const vscode = await import("vscode"); - const original = vscode.workspace.workspaceFolders; - (vscode.workspace as Record).workspaceFolders = undefined; + it("prefers the folder of the active editor in a multi-root window", () => { + const win = vscode.window as { activeTextEditor: unknown }; + win.activeTextEditor = { document: { uri: { fsPath: "/other/file.ts" } } }; + vi.mocked(vscode.workspace.getWorkspaceFolder).mockReturnValueOnce({ + uri: { fsPath: "/other" }, + } as never); + expect(getWorkspaceRoot()).toBe("/other"); + win.activeTextEditor = undefined; + }); + + it("returns undefined when no workspace is open", () => { + const ws = vscode.workspace as { workspaceFolders: unknown }; + const original = ws.workspaceFolders; + ws.workspaceFolders = undefined; expect(getWorkspaceRoot()).toBeUndefined(); - (vscode.workspace as Record).workspaceFolders = original; + ws.workspaceFolders = original; }); }); describe("runCtx", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("resolves with stdout and stderr on success", async () => { - vi.mocked(cp.execFile).mockImplementation( - (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { - (cb as (e: null, out: string, err: string) => void)( - null, - "output", - "errors" - ); - return { kill: vi.fn() } as never; - } - ); + beforeEach(() => vi.clearAllMocks()); + it("resolves with output and exit code 0 on success", async () => { + mockExec("output", 0, "errors"); const result = await runCtx(["status"]); - expect(result.stdout).toBe("output"); - expect(result.stderr).toBe("errors"); - }); - - it("resolves on non-zero exit when output is present", async () => { - vi.mocked(cp.execFile).mockImplementation( - (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { - const err = new Error("exit 1"); - (cb as (e: Error, out: string, err: string) => void)( - err, - "", - "drift detected" - ); - return { kill: vi.fn() } as never; - } - ); + expect(result).toEqual({ stdout: "output", stderr: "errors", code: 0 }); + }); + it("resolves with the real exit code on a non-zero exit", async () => { + mockExec("drift report", 1); const result = await runCtx(["drift"]); - expect(result.stderr).toBe("drift detected"); + expect(result.code).toBe(1); + expect(result.stdout).toBe("drift report"); }); - it("rejects on non-zero exit with no output", async () => { - vi.mocked(cp.execFile).mockImplementation( - (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { - const err = new Error("not found"); - (cb as (e: Error, out: string, err: string) => void)(err, "", ""); - return { kill: vi.fn() } as never; - } - ); + it("resolves a non-zero exit even without output", async () => { + mockExec("", 2); + expect((await runCtx(["status"])).code).toBe(2); + }); + + it("rejects when the binary cannot start", async () => { + vi.mocked(cp.execFile).mockImplementation(((_c: unknown, _a: unknown, _o: unknown, cb: ExecCallback) => { + cb(Object.assign(new Error("spawn ctx ENOENT"), { code: "ENOENT" }), "", ""); + return { kill: vi.fn() }; + }) as never); + await expect(runCtx(["status"])).rejects.toThrow("ENOENT"); + }); - await expect(runCtx(["missing"])).rejects.toThrow("not found"); + it("rejects on timeout instead of showing partial output", async () => { + vi.mocked(cp.execFile).mockImplementation(((_c: unknown, _a: unknown, _o: unknown, cb: ExecCallback) => { + cb(Object.assign(new Error("timeout"), { killed: true, signal: "SIGTERM" }), "partial", ""); + return { kill: vi.fn() }; + }) as never); + await expect(runCtx(["agent"])).rejects.toThrow("cancelled or timed out"); }); it("rejects immediately when token is already cancelled", async () => { - const token = fakeToken(true); - await expect(runCtx(["status"], "/test", token)).rejects.toThrow( - "Cancelled" - ); + await expect(runCtx(["status"], "/test", fakeToken(true) as never)).rejects.toThrow("Cancelled"); expect(cp.execFile).not.toHaveBeenCalled(); }); - it("kills child process when token fires cancellation", async () => { + it("kills the child and rejects when the token fires", async () => { const killFn = vi.fn(); - let resolveCallback: (e: Error, out: string, err: string) => void; - - vi.mocked(cp.execFile).mockImplementation( - (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { - resolveCallback = cb as typeof resolveCallback; - return { kill: killFn } as never; - } - ); + let finish: ExecCallback = () => {}; + vi.mocked(cp.execFile).mockImplementation(((_c: unknown, _a: unknown, _o: unknown, cb: ExecCallback) => { + finish = cb; + return { kill: killFn }; + }) as never); const token = fakeToken(); - const promise = runCtx(["agent"], "/test", token); - - // Simulate cancellation + const promise = runCtx(["agent"], "/test", token as never); token._fire(); expect(killFn).toHaveBeenCalled(); - // Process exits after kill — no output so it rejects - resolveCallback!(new Error("killed"), "", ""); - await expect(promise).rejects.toThrow("killed"); + finish(Object.assign(new Error("killed"), { killed: true, signal: "SIGTERM" }), "half", ""); + await expect(promise).rejects.toThrow("cancelled or timed out"); }); - it("passes cwd to execFile", async () => { - vi.mocked(cp.execFile).mockImplementation( - (_cmd: unknown, _args: unknown, opts: unknown, cb: unknown) => { - (cb as (e: null, out: string, err: string) => void)(null, "", ""); - return { kill: vi.fn() } as never; - } - ); - + it("passes cwd and never runs through a shell", async () => { + mockExec(""); await runCtx(["status"], "/my/project"); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["status"], - expect.objectContaining({ cwd: "/my/project" }), - expect.any(Function) - ); + const opts = vi.mocked(cp.execFile).mock.calls[0][2] as { cwd: string; shell?: unknown }; + expect(opts.cwd).toBe("/my/project"); + expect(opts.shell).toBeUndefined(); + }); + + it("closes stdin so a prompting command cannot wait for input", async () => { + const end = vi.fn(); + vi.mocked(cp.execFile).mockImplementation(((_c: unknown, _a: unknown, _o: unknown, cb: ExecCallback) => { + process.nextTick(() => cb(null, "", "")); + return { kill: vi.fn(), stdin: { end } }; + }) as never); + await runCtx(["why"]); + expect(end).toHaveBeenCalled(); }); it("disposes cancellation listener when process completes", async () => { @@ -181,18 +203,12 @@ describe("runCtx", () => { isCancellationRequested: false, onCancellationRequested: vi.fn(() => ({ dispose: disposeFn })), }; + vi.mocked(cp.execFile).mockImplementation(((_c: unknown, _a: unknown, _o: unknown, cb: ExecCallback) => { + process.nextTick(() => cb(null, "done", "")); + return { kill: vi.fn() }; + }) as never); - vi.mocked(cp.execFile).mockImplementation( - (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { - // Simulate async callback like real execFile - process.nextTick(() => - (cb as (e: null, out: string, err: string) => void)(null, "done", "") - ); - return { kill: vi.fn() } as never; - } - ); - - await runCtx(["status"], "/test", token); + await runCtx(["status"], "/test", token as never); expect(disposeFn).toHaveBeenCalled(); }); }); @@ -202,503 +218,366 @@ describe("getPlatformInfo", () => { const info = getPlatformInfo(); expect(["darwin", "linux", "windows"]).toContain(info.goos); expect(["amd64", "arm64"]).toContain(info.goarch); - if (info.goos === "windows") { - expect(info.ext).toBe(".exe"); - } else { - expect(info.ext).toBe(""); - } + expect(info.ext).toBe(info.goos === "windows" ? ".exe" : ""); }); }); -// Helpers for handler tests -function fakeStream() { - return { - markdown: vi.fn(), - progress: vi.fn(), - }; -} - -function mockRunCtxSuccess(stdout: string, stderr = "") { - vi.mocked(cp.execFile).mockImplementation( - (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { - (cb as (e: null, out: string, err: string) => void)(null, stdout, stderr); - return { kill: vi.fn() } as never; - } - ); -} - -function mockRunCtxError(message: string) { - vi.mocked(cp.execFile).mockImplementation( - (_cmd: unknown, _args: unknown, _opts: unknown, cb: unknown) => { - const err = new Error(message); - (cb as (e: Error, out: string, err: string) => void)(err, "", ""); - return { kill: vi.fn() } as never; - } - ); -} +describe("tokenize", () => { + it("keeps double-quoted phrases together", () => { + expect(tokenize('decision Use Postgres --context "need a db" --x y')).toEqual([ + "decision", + "Use", + "Postgres", + "--context", + "need a db", + "--x", + "y", + ]); + }); +}); -describe("handleTask complete", () => { +describe("result rendering", () => { beforeEach(() => vi.clearAllMocks()); - it("shows usage when no task reference provided", async () => { - const stream = fakeStream(); - const token = fakeToken(); - const result = await handleTask(stream as never, "complete", "/test", token); - expect(result.metadata.command).toBe("task"); - expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Usage")); + it("fences successful output", async () => { + mockExec("3 tasks archived"); + const { stream } = await run("task", "archive"); + expect(markdownOf(stream)).toBe("```\n3 tasks archived\n```"); }); - it("runs complete command with task reference", async () => { - mockRunCtxSuccess("Task 3 marked as done"); - const stream = fakeStream(); - const token = fakeToken(); - const result = await handleTask(stream as never, "complete 3", "/test", token); - expect(result.metadata.command).toBe("task"); - expect(stream.progress).toHaveBeenCalledWith("Marking task as completed..."); - expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Task 3 marked as done")); + it("reports a non-zero exit as such, never as a result", async () => { + mockExec("Error: unknown flag: --no-color", 1); + const { stream } = await run("status", ""); + const md = markdownOf(stream); + expect(md).toContain("`ctx status` exited with code 1."); + expect(md).toContain("unknown flag"); }); - it("runs complete with text reference", async () => { - mockRunCtxSuccess("Completed: Fix login bug"); - const stream = fakeStream(); - const token = fakeToken(); - await handleTask(stream as never, "complete Fix login bug", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["task", "complete", "Fix login bug", "--no-color"], - expect.anything(), - expect.any(Function) - ); + it("points at /init when the folder has no .context/", async () => { + mockExec("Error: no .context here", 1); + const { stream } = await run("status", ""); + expect(markdownOf(stream)).toContain("@ctx /init"); }); - it("handles errors gracefully", async () => { - mockRunCtxError("task not found"); - const stream = fakeStream(); - const token = fakeToken(); - await handleTask(stream as never, "complete 99", "/test", token); - expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + it("renders spawn failures as errors", async () => { + vi.mocked(cp.execFile).mockImplementation(((_c: unknown, _a: unknown, _o: unknown, cb: ExecCallback) => { + cb(Object.assign(new Error("spawn ctx ENOENT"), { code: "ENOENT" }), "", ""); + return { kill: vi.fn() }; + }) as never); + const { stream } = await run("drift", ""); + expect(markdownOf(stream)).toContain("**Error:**"); }); }); -describe("handleRemind", () => { +describe("/task", () => { beforeEach(() => vi.clearAllMocks()); - it("lists reminders when no subcommand given", async () => { - mockRunCtxSuccess("1. Update docs\n2. Review PR"); - const stream = fakeStream(); - const token = fakeToken(); - await handleRemind(stream as never, "", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["remind", "list", "--no-color"], - expect.anything(), - expect.any(Function) - ); - }); - - it("adds reminder with 'add' subcommand", async () => { - mockRunCtxSuccess("Reminder added"); - const stream = fakeStream(); - const token = fakeToken(); - await handleRemind(stream as never, "add Check CI status", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["remind", "add", "Check CI status", "--no-color"], - expect.anything(), - expect.any(Function) - ); + it("shows usage when no subcommand given", async () => { + mockExec(""); + const { stream, res } = await run("task", ""); + expect(res.metadata.command).toBe("task"); + expect(markdownOf(stream)).toContain("Usage"); + expect(cp.execFile).not.toHaveBeenCalled(); }); - it("adds reminder when text provided without subcommand", async () => { - mockRunCtxSuccess("Reminder added"); - const stream = fakeStream(); - const token = fakeToken(); - await handleRemind(stream as never, "Check CI status", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["remind", "add", "Check CI status", "--no-color"], - expect.anything(), - expect.any(Function) - ); + it("shows usage for complete without a reference", async () => { + const { stream } = await run("task", "complete"); + expect(markdownOf(stream)).toContain("Usage"); }); - it("lists reminders with 'list' subcommand", async () => { - mockRunCtxSuccess("No reminders"); - const stream = fakeStream(); - const token = fakeToken(); - await handleRemind(stream as never, "list", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["remind", "list", "--no-color"], - expect.anything(), - expect.any(Function) - ); + it.each([ + ["complete Fix login bug", ["task", "complete", "Fix login bug"]], + ["archive", ["task", "archive"]], + ["snapshot pre-refactor", ["task", "snapshot", "pre-refactor"]], + ["snapshot", ["task", "snapshot"]], + ])("%s", async (prompt, argv) => { + mockExec("ok"); + await run("task", prompt); + expect(ctxCalls()).toEqual([argv]); }); +}); - it("dismisses reminder by id", async () => { - mockRunCtxSuccess("Dismissed reminder 2"); - const stream = fakeStream(); - const token = fakeToken(); - await handleRemind(stream as never, "dismiss 2", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["remind", "dismiss", "2", "--no-color"], - expect.anything(), - expect.any(Function) - ); - }); +describe("/remind", () => { + beforeEach(() => vi.clearAllMocks()); - it("dismisses all when no id given", async () => { - mockRunCtxSuccess("All dismissed"); - const stream = fakeStream(); - const token = fakeToken(); - await handleRemind(stream as never, "dismiss", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["remind", "dismiss", "--all", "--no-color"], - expect.anything(), - expect.any(Function) - ); + it.each([ + ["", ["remind", "list"]], + ["list", ["remind", "list"]], + ["add Check CI status", ["remind", "add", "Check CI status"]], + ["Check CI status", ["remind", "add", "Check CI status"]], + ["dismiss 2", ["remind", "dismiss", "2"]], + ["dismiss", ["remind", "dismiss", "--all"]], + ])("'%s'", async (prompt, argv) => { + mockExec("ok"); + await run("remind", prompt); + expect(ctxCalls()).toEqual([argv]); }); it("shows 'No reminders.' when output is empty", async () => { - mockRunCtxSuccess(""); - const stream = fakeStream(); - const token = fakeToken(); - await handleRemind(stream as never, "list", "/test", token); - expect(stream.markdown).toHaveBeenCalledWith("No reminders."); - }); - - it("handles errors gracefully", async () => { - mockRunCtxError("failed"); - const stream = fakeStream(); - const token = fakeToken(); - await handleRemind(stream as never, "add test", "/test", token); - expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + mockExec(""); + const { stream } = await run("remind", "list"); + expect(markdownOf(stream)).toBe("No reminders."); }); }); -describe("handleTask archive/snapshot", () => { +describe("/pad", () => { beforeEach(() => vi.clearAllMocks()); - it("shows usage when no subcommand given", async () => { - const stream = fakeStream(); - const token = fakeToken(); - const result = await handleTask(stream as never, "", "/test", token); - expect(result.metadata.command).toBe("task"); - expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Usage")); + it.each([ + ["", ["pad"]], + ["add my secret note", ["pad", "add", "my secret note"]], + ["show 1", ["pad", "show", "1"]], + ["rm 2 3", ["pad", "rm", "2", "3"]], + // `ctx pad edit N [TEXT]`: the text must stay one argument + ["edit 1 new text", ["pad", "edit", "1", "new text"]], + ["mv 1 3", ["pad", "mv", "1", "3"]], + ])("'%s'", async (prompt, argv) => { + mockExec("ok"); + await run("pad", prompt); + expect(ctxCalls()).toEqual([argv]); + }); + + it.each(["add", "rm", "edit", "import"])("shows usage for '%s' without arguments", async (sub) => { + const { stream } = await run("pad", sub); + expect(markdownOf(stream)).toContain("Usage"); + expect(cp.execFile).not.toHaveBeenCalled(); }); - it("runs archive subcommand", async () => { - mockRunCtxSuccess("Archived 3 tasks"); - const stream = fakeStream(); - const token = fakeToken(); - await handleTask(stream as never, "archive", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["task", "archive", "--no-color"], - expect.anything(), - expect.any(Function) - ); - expect(stream.progress).toHaveBeenCalledWith("Archiving completed tasks..."); + it("shows 'Scratchpad is empty.' when output is empty", async () => { + mockExec(""); + const { stream } = await run("pad", ""); + expect(markdownOf(stream)).toBe("Scratchpad is empty."); }); +}); - it("runs snapshot subcommand with name", async () => { - mockRunCtxSuccess("Snapshot created"); - const stream = fakeStream(); - const token = fakeToken(); - await handleTask(stream as never, "snapshot pre-refactor", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["task", "snapshot", "pre-refactor", "--no-color"], - expect.anything(), - expect.any(Function) - ); - }); +describe("/notify", () => { + beforeEach(() => vi.clearAllMocks()); - it("runs snapshot without name", async () => { - mockRunCtxSuccess("Snapshot created"); - const stream = fakeStream(); - const token = fakeToken(); - await handleTask(stream as never, "snapshot", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["task", "snapshot", "--no-color"], - expect.anything(), - expect.any(Function) - ); + it("shows usage when no message given", async () => { + const { stream } = await run("notify", ""); + expect(markdownOf(stream)).toContain("Usage"); }); - it("shows fallback message when archive output is empty", async () => { - mockRunCtxSuccess(""); - const stream = fakeStream(); - const token = fakeToken(); - await handleTask(stream as never, "archive", "/test", token); - expect(stream.markdown).toHaveBeenCalledWith("Completed tasks archived."); + it("sends setup to the terminal, so the webhook URL never enters the chat", async () => { + const { stream } = await run("notify", "setup"); + expect(markdownOf(stream)).toContain("ctx hook notify setup"); + expect(cp.execFile).not.toHaveBeenCalled(); }); - it("handles errors gracefully", async () => { - mockRunCtxError("no tasks file"); - const stream = fakeStream(); - const token = fakeToken(); - await handleTask(stream as never, "archive", "/test", token); - expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + it.each([ + ["test", ["hook", "notify", "test"]], + // `ctx hook notify [message]`: the message must stay one argument + ["build done --event build", ["hook", "notify", "build done", "--event", "build"]], + ])("'%s'", async (prompt, argv) => { + mockExec("ok"); + await run("notify", prompt); + expect(ctxCalls()).toEqual([argv]); }); }); -describe("handlePad", () => { +describe("/system", () => { beforeEach(() => vi.clearAllMocks()); - it("lists all entries when no subcommand given", async () => { - mockRunCtxSuccess("1: secret key\n2: API token"); - const stream = fakeStream(); - const token = fakeToken(); - await handlePad(stream as never, "", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["pad", "--no-color"], - expect.anything(), - expect.any(Function) - ); + it("shows usage when no subcommand given", async () => { + const { stream } = await run("system", ""); + expect(markdownOf(stream)).toContain("Usage"); }); - it("adds entry with 'add' subcommand", async () => { - mockRunCtxSuccess("Entry added"); - const stream = fakeStream(); - const token = fakeToken(); - await handlePad(stream as never, "add my secret note", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["pad", "add", "my secret note", "--no-color"], - expect.anything(), - expect.any(Function) - ); + it.each([ + ["resources", ["sysinfo"]], + ["bootstrap", ["system", "bootstrap"]], + ["stats", ["usage"]], + ["message", ["hook", "message", "list"]], + ["message show check-freshness stale", ["hook", "message", "show", "check-freshness", "stale"]], + ])("'%s'", async (prompt, argv) => { + mockExec("ok"); + await run("system", prompt); + expect(ctxCalls()).toEqual([argv]); }); +}); - it("shows usage when 'add' has no content", async () => { - const stream = fakeStream(); - const token = fakeToken(); - await handlePad(stream as never, "add", "/test", token); - expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Usage")); - }); +describe("/why", () => { + beforeEach(() => vi.clearAllMocks()); - it("shows entry by number", async () => { - mockRunCtxSuccess("secret value"); - const stream = fakeStream(); - const token = fakeToken(); - await handlePad(stream as never, "show 1", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["pad", "show", "1", "--no-color"], - expect.anything(), - expect.any(Function) - ); + it("defaults to the manifesto instead of the interactive menu", async () => { + mockExec("# The ctx Manifesto"); + await run("why", ""); + expect(ctxCalls()).toEqual([["why", "manifesto"]]); }); +}); - it("removes entry by number", async () => { - mockRunCtxSuccess("Entry removed"); - const stream = fakeStream(); - const token = fakeToken(); - await handlePad(stream as never, "rm 2", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["pad", "rm", "2", "--no-color"], - expect.anything(), - expect.any(Function) - ); - }); +describe("/add", () => { + beforeEach(() => vi.clearAllMocks()); - it("shows usage when 'rm' has no number", async () => { - const stream = fakeStream(); - const token = fakeToken(); - await handlePad(stream as never, "rm", "/test", token); - expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Usage")); + it("shows usage for an unknown type", async () => { + const { stream } = await run("add", "idea something"); + expect(markdownOf(stream)).toContain("Usage"); + expect(cp.execFile).not.toHaveBeenCalled(); }); - it("edits entry", async () => { - mockRunCtxSuccess("Entry updated"); - const stream = fakeStream(); - const token = fakeToken(); - await handlePad(stream as never, "edit 1 new text", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["pad", "edit", "1", "new", "text", "--no-color"], - expect.anything(), - expect.any(Function) - ); + it("adds a task with provenance", async () => { + mockExec("✓ Added to TASKS.md"); + await run("add", 'task Fix login bug --section "Phase 1"'); + expect(ctxCalls()).toEqual([ + [ + "task", "add", "Fix login bug", + "--section", "Phase 1", + "--session-id", "01234567", "--branch", "main", "--commit", "abc1234", + ], + ]); }); - it("moves entry", async () => { - mockRunCtxSuccess("Entry moved"); - const stream = fakeStream(); - const token = fakeToken(); - await handlePad(stream as never, "mv 1 3", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["pad", "mv", "1", "3", "--no-color"], - expect.anything(), - expect.any(Function) + it("passes quoted flag values as single arguments and keeps user provenance", async () => { + mockExec("✓ Added to DECISIONS.md"); + await run( + "add", + 'decision Use PostgreSQL --context "Need a reliable DB" --rationale ACID --consequence "Ops training" --branch release' ); + expect(ctxCalls()).toEqual([ + [ + "decision", "add", "Use PostgreSQL", + "--context", "Need a reliable DB", "--rationale", "ACID", + "--consequence", "Ops training", "--branch", "release", + "--session-id", "01234567", "--commit", "abc1234", + ], + ]); }); - it("shows 'Scratchpad is empty.' when output is empty", async () => { - mockRunCtxSuccess(""); - const stream = fakeStream(); - const token = fakeToken(); - await handlePad(stream as never, "", "/test", token); - expect(stream.markdown).toHaveBeenCalledWith("Scratchpad is empty."); + it("adds a convention without provenance", async () => { + mockExec("✓ Added to CONVENTIONS.md"); + await run("add", "convention Use camelCase --section Naming"); + expect(ctxCalls()).toEqual([["convention", "add", "Use camelCase", "--section", "Naming"]]); }); - it("handles errors gracefully", async () => { - mockRunCtxError("no key"); - const stream = fakeStream(); - const token = fakeToken(); - await handlePad(stream as never, "add secret", "/test", token); - expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + it("surfaces the CLI's missing-field error", async () => { + mockExec("Error: decision requires --context, --rationale, --consequence", 1); + const { stream } = await run("add", "decision Use PostgreSQL"); + expect(markdownOf(stream)).toContain("exited with code 1"); }); }); -describe("handleNotify", () => { +describe("skill-backed commands", () => { beforeEach(() => vi.clearAllMocks()); - it("shows usage when no subcommand given", async () => { - const stream = fakeStream(); - const token = fakeToken(); - const result = await handleNotify(stream as never, "", "/test", token); - expect(result.metadata.command).toBe("notify"); - expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Usage")); - }); - - it("runs setup subcommand", async () => { - mockRunCtxSuccess("Webhook configured"); - const stream = fakeStream(); - const token = fakeToken(); - await handleNotify(stream as never, "setup", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["notify", "setup", "--no-color"], - expect.anything(), - expect.any(Function) - ); - expect(stream.progress).toHaveBeenCalledWith("Setting up webhook..."); - }); - - it("runs test subcommand", async () => { - mockRunCtxSuccess("Test OK"); - const stream = fakeStream(); - const token = fakeToken(); - await handleNotify(stream as never, "test", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["notify", "test", "--no-color"], - expect.anything(), - expect.any(Function) - ); - }); - - it("sends notification with message", async () => { - mockRunCtxSuccess("Sent"); - const stream = fakeStream(); - const token = fakeToken(); - await handleNotify(stream as never, "build done --event build", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["notify", "build", "done", "--event", "build", "--no-color"], - expect.anything(), - expect.any(Function) + function fakeModel(fragments: string[]) { + return { + sendRequest: vi.fn(async () => ({ + text: (async function* () { + yield* fragments; + })(), + })), + }; + } + + function request(command: string | undefined, prompt: string, model: unknown, references: unknown[] = []) { + return { command, prompt, model, references } as never; + } + + it("grounds the canonical skill in live ctx output and streams the answer", async () => { + mockExec("# Context Packet"); + const model = fakeModel(["Recommended ", "next"]); + const stream = fakeStream(); + const res = await handler(request("next", "", model), { history: [] } as never, stream as never, fakeToken() as never); + + expect(res).toEqual({ metadata: { command: "next" } }); + expect(ctxCalls()).toContainEqual(["agent"]); + expect(ctxCalls()).toContainEqual(["journal", "source", "--limit", "3"]); + const [messages] = model.sendRequest.mock.calls[0] as unknown as [Array<{ content: string }>]; + expect(messages[0].content).toContain(SKILLS.next.text); + expect(messages[1].content).toContain("# Context Packet"); + expect(stream.markdown.mock.calls.map((c) => c[0])).toEqual(["Recommended ", "next"]); + }); + + it("inlines #file attachments", async () => { + mockExec("packet"); + const model = fakeModel(["ok"]); + const ref = { value: new vs.Uri("/test/workspace/specs/plans/m1.md") }; + await handler(request("implement", "", model, [ref]), { history: [] } as never, fakeStream() as never, fakeToken() as never); + const [messages] = model.sendRequest.mock.calls[0] as unknown as [Array<{ content: string }>]; + expect(messages[1].content).toContain("attached text"); + }); + + it("continues the previous skill when a reply has no slash command", async () => { + mockExec("packet"); + const model = fakeModel(["next question"]); + const history = [ + new vs.ChatRequestTurn("an auth idea", "brainstorm"), + new vs.ChatResponseTurn( + [new vs.ChatResponseMarkdownPart("What problem does it solve?")], + { metadata: { command: "brainstorm" } } + ), + ]; + const res = await handler( + request(undefined, "logins keep expiring, the status page is wrong", model), + { history } as never, + fakeStream() as never, + fakeToken() as never ); - }); - - it("shows fallback on empty setup output", async () => { - mockRunCtxSuccess(""); - const stream = fakeStream(); - const token = fakeToken(); - await handleNotify(stream as never, "setup", "/test", token); - expect(stream.markdown).toHaveBeenCalledWith("Webhook configured."); - }); - - it("shows fallback on empty test output", async () => { - mockRunCtxSuccess(""); - const stream = fakeStream(); - const token = fakeToken(); - await handleNotify(stream as never, "test", "/test", token); - expect(stream.markdown).toHaveBeenCalledWith("Test notification sent."); - }); - - it("handles errors gracefully", async () => { - mockRunCtxError("webhook failed"); - const stream = fakeStream(); - const token = fakeToken(); - await handleNotify(stream as never, "test", "/test", token); - expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + expect(res?.metadata?.command).toBe("brainstorm"); + const [messages] = model.sendRequest.mock.calls[0] as unknown as [Array<{ role: string; content: string }>]; + expect(messages[0].content).toContain(SKILLS.brainstorm.text); + expect(messages).toContainEqual({ role: "user", content: "/brainstorm an auth idea" }); + expect(messages).toContainEqual({ role: "assistant", content: "What problem does it solve?" }); + }); + + it("never sends CLI command turns (e.g. /pad) to the model", async () => { + mockExec("packet"); + const model = fakeModel(["ok"]); + const history = [ + new vs.ChatRequestTurn("add api-key=s3cret", "pad"), + new vs.ChatResponseTurn([new vs.ChatResponseMarkdownPart("Added entry 1.")], { + metadata: { command: "pad" }, + }), + ]; + await handler(request("reflect", "", model), { history } as never, fakeStream() as never, fakeToken() as never); + const [messages] = model.sendRequest.mock.calls[0] as unknown as [Array<{ content: string }>]; + expect(JSON.stringify(messages)).not.toContain("s3cret"); + }); + + it("reports model failures instead of throwing", async () => { + mockExec("packet"); + const model = { sendRequest: vi.fn(async () => Promise.reject(new Error("quota exceeded"))) }; + const stream = fakeStream(); + await handler(request("reflect", "", model), { history: [] } as never, stream as never, fakeToken() as never); + expect(markdownOf(stream)).toContain("quota exceeded"); }); }); -describe("handleSystem", () => { +describe("natural-language routing", () => { beforeEach(() => vi.clearAllMocks()); - it("shows usage when no subcommand given", async () => { - const stream = fakeStream(); - const token = fakeToken(); - const result = await handleSystem(stream as never, "", "/test", token); - expect(result.metadata.command).toBe("system"); - expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Usage")); - }); - - it("runs resources subcommand", async () => { - mockRunCtxSuccess("Memory: 4GB / 16GB\nDisk: 50%"); - const stream = fakeStream(); - const token = fakeToken(); - await handleSystem(stream as never, "resources", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["system", "resources", "--no-color"], - expect.anything(), - expect.any(Function) + it("routes to read-only commands and passes them no arguments", async () => { + mockExec("ok"); + const res = await handler( + { command: undefined, prompt: "show me the status of the login task", references: [] } as never, + { history: [] } as never, + fakeStream() as never, + fakeToken() as never ); - expect(stream.progress).toHaveBeenCalledWith("Checking system resources..."); + expect(res?.metadata?.command).toBe("status"); + expect(ctxCalls()).toContainEqual(["status"]); }); - it("runs bootstrap subcommand", async () => { - mockRunCtxSuccess("context_dir: .context"); - const stream = fakeStream(); - const token = fakeToken(); - await handleSystem(stream as never, "bootstrap", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["system", "bootstrap", "--no-color"], - expect.anything(), - expect.any(Function) + it("never mutates context on a keyword match", async () => { + mockExec("ok"); + await handler( + { command: undefined, prompt: "is the login task done? remind me to archive it", references: [] } as never, + { history: [] } as never, + fakeStream() as never, + fakeToken() as never ); - expect(stream.progress).toHaveBeenCalledWith("Running bootstrap..."); + const mutating = ctxCalls().filter((argv) => ["task", "remind", "pad", "add"].includes(argv[0])); + expect(mutating).toEqual([]); }); - it("runs message subcommand with arguments", async () => { - mockRunCtxSuccess("Hook messages listed"); - const stream = fakeStream(); - const token = fakeToken(); - await handleSystem(stream as never, "message list", "/test", token); - expect(cp.execFile).toHaveBeenCalledWith( - "ctx", - ["system", "message", "list", "--no-color"], - expect.anything(), - expect.any(Function) + it("falls back to help", async () => { + mockExec("ok"); + const res = await handler( + { command: undefined, prompt: "hello", references: [] } as never, + { history: [] } as never, + fakeStream() as never, + fakeToken() as never ); - }); - - it("shows 'No output.' when output is empty", async () => { - mockRunCtxSuccess(""); - const stream = fakeStream(); - const token = fakeToken(); - await handleSystem(stream as never, "resources", "/test", token); - expect(stream.markdown).toHaveBeenCalledWith("No output."); - }); - - it("handles errors gracefully", async () => { - mockRunCtxError("system error"); - const stream = fakeStream(); - const token = fakeToken(); - await handleSystem(stream as never, "resources", "/test", token); - expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); + expect(res?.metadata?.command).toBe("help"); }); }); diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index f95508284..99a8df0e3 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -5,6 +5,20 @@ import * as os from "os"; import * as path from "path"; import * as https from "https"; +// Canonical ctx skills, bundled as text at build time (esbuild's +// `--loader:.md=text`; vitest.config.ts mirrors it). Bundling pins each +// skill body to the ctx commit the extension is built from, and a renamed +// or deleted skill fails the build instead of shipping a dead command. +import blogSkill from "../../../internal/assets/claude/skills/ctx-blog/SKILL.md"; +import brainstormSkill from "../../../internal/assets/claude/skills/ctx-brainstorm/SKILL.md"; +import consolidateSkill from "../../../internal/assets/claude/skills/ctx-consolidate/SKILL.md"; +import implementSkill from "../../../internal/assets/claude/skills/ctx-implement/SKILL.md"; +import nextSkill from "../../../internal/assets/claude/skills/ctx-next/SKILL.md"; +import reflectSkill from "../../../internal/assets/claude/skills/ctx-reflect/SKILL.md"; +import rememberSkill from "../../../internal/assets/claude/skills/ctx-remember/SKILL.md"; +import specSkill from "../../../internal/assets/claude/skills/ctx-spec/SKILL.md"; +import wrapUpSkill from "../../../internal/assets/claude/skills/ctx-wrap-up/SKILL.md"; + const PARTICIPANT_ID = "ctx.participant"; const GITHUB_REPO = "ActiveMemory/ctx"; @@ -14,12 +28,23 @@ interface CtxResult extends vscode.ChatResult { }; } +/** A CLI-backed slash command. `prompt` is the text after the command. */ +type Handler = ( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +) => Promise; + // Resolved path to ctx binary — set during bootstrap 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; @@ -30,8 +55,17 @@ function getCtxPath(): string { ); } +/** + * The project root ctx runs in: the workspace folder of the active editor, + * so a multi-root window targets the project being worked on, falling back + * to the first folder. + */ function getWorkspaceRoot(): string | undefined { - return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const active = vscode.window.activeTextEditor?.document.uri; + const folder = + (active && vscode.workspace.getWorkspaceFolder(active)) || + vscode.workspace.workspaceFolders?.[0]; + return folder?.uri.fsPath; } /** @@ -263,11 +297,49 @@ 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; +} + +/** + * Result of a completed `ctx` process. `code` is the exit code: callers + * must check it, because a failing command still prints output (an unknown + * flag prints usage and exits 1). + */ +interface CtxRun { + stdout: string; + stderr: string; + code: number; +} + +/** + * Run ctx without a shell: arguments reach the binary verbatim, so free + * text from the chat prompt can neither split into extra arguments nor + * inject shell syntax. (Node resolves `ctx` to `ctx.exe` on PATH on + * Windows without a shell.) stdin is closed at once, so a command that + * would prompt (`ctx why` with no document, an add with no content) fails + * fast with its own message instead of waiting for the timeout. + * + * Resolves for any exit code; rejects only when there is no complete + * result to show: the process could not start, its output overflowed the + * buffer, or it was cancelled, timed out, or killed. + */ function runCtx( args: string[], cwd?: string, token?: vscode.CancellationToken -): Promise<{ stdout: string; stderr: string }> { +): Promise { const ctxPath = getCtxPath(); return new Promise((resolve, reject) => { if (token?.isCancellationRequested) { @@ -275,150 +347,249 @@ function runCtx( return; } let disposed = false; - // `disposable` must be declared (not just const-assigned) before - // the execFile callback can reference it. The cancellation - // listener can only register after `child` exists, so a const - // initializer is impossible here; and mocked execFile (vitest) - // fires the callback synchronously, which would TDZ-trap a - // const-declared-later pattern. + // `disposable` is declared, not const-initialized: the cancellation + // listener can only register after `child` exists, and mocked + // execFile (vitest) fires the callback synchronously, so a + // const-declared-later pattern would TDZ-trap. // eslint-disable-next-line prefer-const let disposable: { dispose(): void } | undefined; - // Use shell on Windows so execFile can resolve PATH executables - // without requiring the .exe extension. - const useShell = os.platform() === "win32"; const child = execFile( ctxPath, args, - { cwd, maxBuffer: 1024 * 1024, timeout: 30000, shell: useShell }, + { cwd, maxBuffer: 1024 * 1024, timeout: 30000 }, (error, stdout, stderr) => { if (!disposed) { disposed = true; disposable?.dispose(); } - if (error) { - // Still return output even on non-zero exit — ctx drift uses exit 1 - // for "drift detected" which is a valid result - if (stdout || stderr) { - resolve({ stdout, stderr }); - return; - } + if (!error) { + resolve({ stdout, stderr, code: 0 }); + return; + } + const err = error as NodeJS.ErrnoException & { + killed?: boolean; + signal?: NodeJS.Signals | null; + code?: number | string; + }; + // Killed by cancellation, the 30s timeout, or a signal: the + // output is partial and must not be presented as a result. + if (err.killed || err.signal || token?.isCancellationRequested) { + reject( + new Error( + `\`ctx ${args.join(" ")}\` was cancelled or timed out` + + (err.signal ? ` (${err.signal})` : "") + ) + ); + return; + } + // A string code is a spawn or buffer failure (ENOENT, + // ERR_CHILD_PROCESS_STDIO_MAXBUFFER), not an exit status. + if (typeof err.code !== "number") { reject(error); return; } - resolve({ stdout, stderr }); + resolve({ stdout, stderr, code: err.code }); } ); + child.stdin?.end(); disposable = token?.onCancellationRequested(() => { child.kill(); }); }); } -async function handleInit( - stream: vscode.ChatResponseStream, +/** + * Run `git` with a timeout and cancellation, mirroring runCtx. Resolves + * with stdout; rejects on non-zero exit, timeout, or cancel. + */ +function execGit( + args: string[], cwd: string, - token: vscode.CancellationToken -): Promise { - stream.progress("Initializing .context/ directory..."); - try { - const { stdout, stderr } = await runCtx(["init", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); + token?: vscode.CancellationToken +): Promise { + return new Promise((resolve, reject) => { + if (token?.isCancellationRequested) { + reject(new Error("Cancelled")); + return; } - - // Auto-generate .github/copilot-instructions.md so Copilot gets - // project context automatically. - stream.progress("Generating Copilot instructions..."); - try { - const setupResult = await runCtx( - ["setup", "copilot", "--write", "--no-color"], - cwd, - token - ); - const setupOutput = (setupResult.stdout + setupResult.stderr).trim(); - if (setupOutput) { - stream.markdown( - "\n**Copilot integration:**\n```\n" + setupOutput + "\n```" - ); - } else { - stream.markdown( - "\n`.github/copilot-instructions.md` generated for Copilot context loading." - ); + let disposed = false; + // eslint-disable-next-line prefer-const + let disposable: { dispose(): void } | undefined; + const child = execFile( + "git", + args, + { cwd, timeout: 30000, maxBuffer: 1024 * 1024 }, + (error, stdout) => { + if (!disposed) { + disposed = true; + disposable?.dispose(); + } + if (error) { + reject(error); + return; + } + resolve(stdout); } - } catch { - // Non-fatal — init succeeded, setup is a bonus - stream.markdown( - "\n> **Note:** Could not generate `.github/copilot-instructions.md`. " + - "Run `@ctx /setup copilot` manually." - ); - } - - if (!output) { - stream.markdown( - "`.context/` directory initialized. Run `@ctx /status` to see your project context." - ); - } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to initialize context.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); - } - return { metadata: { command: "init" } }; + disposable = token?.onCancellationRequested(() => child.kill()); + }); +} + +/** + * Check if .context/ directory exists in the workspace root. + */ +function hasContextDir(cwd: string): boolean { + return fs.existsSync(path.join(cwd, ".context")); +} + +function fence(text: string): string { + return "```\n" + text + "\n```"; +} + +function errorMarkdown(title: string, err: unknown): string { + return `**Error:** ${title}.\n\n` + fence(err instanceof Error ? err.message : String(err)); +} + +function result(command: string): CtxResult { + return { metadata: { command } }; } -async function handleStatus( +/** + * Run ctx and render the outcome. A non-zero exit is shown as such, with + * the CLI's own output, never as a normal result; when the folder has no + * .context/ yet, it also points at /init. Returns whether ctx exited 0. + */ +async function runAndRender( stream: vscode.ChatResponseStream, cwd: string, - token: vscode.CancellationToken -): Promise { - stream.progress("Checking context status..."); + token: vscode.CancellationToken, + args: string[], + progress: string, + emptyMessage: string, + fenced = true +): Promise { + stream.progress(progress); + let run: CtxRun; try { - const { stdout, stderr } = await runCtx(["status", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); - stream.markdown("```\n" + output + "\n```"); + run = await runCtx(args, cwd, token); } catch (err: unknown) { + stream.markdown(errorMarkdown(`\`ctx ${args.join(" ")}\` did not complete`, err)); + return false; + } + const output = mergeOutput(run.stdout, run.stderr); + if (run.code !== 0) { stream.markdown( - `**Error:** Failed to get status.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + `**\`ctx ${args.join(" ")}\` exited with code ${run.code}.**\n\n` + + fence(output || "(no output)") + + (hasContextDir(cwd) + ? "" + : "\n\nThis folder has no `.context/` yet. Run `@ctx /init` to set it up.") ); + return false; } - return { metadata: { command: "status" } }; + stream.markdown(!output ? emptyMessage : fenced ? fence(output) : output); + return true; } -async function handleAgent( +/** A command that runs one fixed ctx invocation and ignores its prompt. */ +function simple( + command: string, + args: string[], + progress: string, + emptyMessage: string, + fenced = true +): Handler { + return async (stream, _prompt, cwd, token) => { + await runAndRender(stream, cwd, token, args, progress, emptyMessage, fenced); + return result(command); + }; +} + +/** A command whose first word selects one of `allowed` ctx subcommands. */ +function subcommands( + command: string, + allowed: string[], + usage: string +): Handler { + return async (stream, prompt, cwd, token) => { + const sub = prompt.trim().split(/\s+/)[0]?.toLowerCase(); + if (!sub || !allowed.includes(sub)) { + stream.markdown(usage); + return result(command); + } + const args = [command, sub]; + await runAndRender(stream, cwd, token, args, `Running ctx ${args.join(" ")}...`, `\`ctx ${args.join(" ")}\` completed.`); + return result(command); + }; +} + +/** + * Split a prompt into words, keeping "double-quoted phrases" together so + * flag values like `--context "why we chose it"` survive as one argument. + */ +function tokenize(prompt: string): string[] { + return [...prompt.matchAll(/"([^"]*)"|(\S+)/g)].map((m) => m[1] ?? m[2]); +} + +/** Leading words joined as free text, then everything from the first `--flag` on. */ +function splitFlags(words: string[]): { text: string; flags: string[] } { + const at = words.findIndex((w) => w.startsWith("--")); + return at < 0 + ? { text: words.join(" "), flags: [] } + : { text: words.slice(0, at).join(" "), flags: words.slice(at) }; +} + +const SESSION_START = ["system", "session-event", "--type", "start", "--caller", "vscode"]; +const SESSION_END = ["system", "session-event", "--type", "end", "--caller", "vscode"]; +const REMINDER_CHECK = ["remind", "list"]; + +/** ctx invocations the extension makes outside chat requests. */ +const BACKGROUND_INVOCATIONS = [SESSION_START, SESSION_END, REMINDER_CHECK]; + +async function handleInit( 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(); - stream.markdown(output); - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to generate agent context.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + const ok = await runAndRender( + stream, + cwd, + token, + ["init", "--caller", "vscode"], + "Initializing .context/ directory...", + "`.context/` initialized. Run `@ctx /status` to see your project context." + ); + if (ok) { + // Copilot reads .github/copilot-instructions.md natively, so plain + // Copilot Chat picks up the project context too. + await runAndRender( + stream, + cwd, + token, + ["setup", "copilot", "--write"], + "Generating Copilot instructions...", + "`.github/copilot-instructions.md` generated for Copilot context loading." ); + // activate() skipped session-start: .context/ did not exist yet. + runCtx(SESSION_START, cwd).catch(() => {}); } - return { metadata: { command: "agent" } }; + return result("init"); } -async function handleDrift( +async function handleAgent( stream: vscode.ChatResponseStream, + prompt: string, cwd: string, token: vscode.CancellationToken ): Promise { - stream.progress("Detecting context drift..."); - try { - const { stdout, stderr } = await runCtx(["drift", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); - stream.markdown("```\n" + output + "\n```"); - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to detect drift.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); + const args = ["agent"]; + const budget = prompt.match(/(?:--budget\s+|budget\s+)(\d+)/); + if (budget) { + args.push("--budget", budget[1]); } - return { metadata: { command: "drift" } }; + await runAndRender(stream, cwd, token, args, "Generating AI-ready context packet...", "Empty context packet.", false); + return result("agent"); } async function handleRecall( @@ -427,25 +598,24 @@ 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 words = prompt.trim().split(/\s+/).filter(Boolean); + let args: string[]; + if (words[0]?.toLowerCase() === "show") { + const id = words.slice(1).join(" "); + if (!id) { + stream.markdown("**Usage:** `@ctx /recall show `"); + return result("recall"); } - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown("No session history found."); + args = ["journal", "source", "--show", id]; + } else { + args = ["journal", "source"]; + const limit = prompt.match(/(?:--limit\s+|limit\s+)(\d+)/); + if (limit) { + args.push("--limit", limit[1]); } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to recall sessions.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); } - return { metadata: { command: "recall" } }; + await runAndRender(stream, cwd, token, args, "Loading session history...", "No session history found."); + return result("recall"); } async function handleSetup( @@ -454,141 +624,95 @@ async function handleSetup( cwd: string, token: vscode.CancellationToken ): Promise { - const parts = prompt.trim().split(/\s+/); - const tool = parts[0] || "copilot"; - const preview = parts.includes("preview") || parts.includes("--preview"); - + const words = prompt.trim().split(/\s+/).filter(Boolean); + const preview = words.includes("preview") || words.includes("--preview"); + const tool = words.find((w) => w !== "preview" && w !== "--preview") || "copilot"; const args = ["setup", tool]; if (!preview) { args.push("--write"); } - args.push("--no-color"); - - stream.progress( - preview - ? `Previewing ${tool} integration config...` - : `Generating ${tool} integration config...` + await runAndRender( + stream, + cwd, + token, + args, + preview ? `Previewing ${tool} integration config...` : `Generating ${tool} integration config...`, + preview ? `No output for **${tool}** preview.` : `Integration config for **${tool}** generated.` ); - try { - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown( - preview - ? `No output for **${tool}** preview.` - : `Integration config for **${tool}** generated.` - ); - } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to generate hook.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "setup" } }; + return result("setup"); } -async function handleAdd( - stream: vscode.ChatResponseStream, - prompt: string, - cwd: string, - token: vscode.CancellationToken -): Promise { - const parts = prompt.trim().split(/\s+/); - const type = parts[0]; - const content = parts.slice(1).join(" "); - - if (!type) { - stream.markdown( - "**Usage:** `@ctx /add `\n\n" + - "Types: `task`, `decision`, `learning`\n\n" + - "Example: `@ctx /add task Implement user authentication`" - ); - return { metadata: { command: "add" } }; - } - - stream.progress(`Adding ${type}...`); - try { - const args = ["add", type]; - if (content) { - args.push(content); - } - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown(`Added **${type}**: ${content}`); - } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to add ${type}.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "add" } }; -} +const ENTRY_TYPES = ["task", "decision", "learning", "convention"]; -async function handleLoad( - stream: vscode.ChatResponseStream, +/** + * Provenance flags for `ctx task|decision|learning add`, which the CLI + * requires. VS Code exposes no AI session ID, so the window session ID + * stands in for it. + */ +async function provenanceFlags( cwd: string, token: vscode.CancellationToken -): Promise { - stream.progress("Loading assembled context..."); - try { - const { stdout, stderr } = await runCtx(["load", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); - stream.markdown(output); - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to load context.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` +): Promise { + const git = (...args: string[]) => + execGit(args, cwd, token).then( + (out) => out.trim() || "unknown", + () => "unknown" ); - } - return { metadata: { command: "load" } }; + const [branch, commit] = await Promise.all([ + git("rev-parse", "--abbrev-ref", "HEAD"), + git("rev-parse", "--short", "HEAD"), + ]); + return [ + "--session-id", + vscode.env.sessionId.slice(0, 8), + "--branch", + branch, + "--commit", + commit, + ]; } -async function handleCompact( +async function handleAdd( stream: vscode.ChatResponseStream, + prompt: string, cwd: string, token: vscode.CancellationToken ): Promise { - stream.progress("Compacting context..."); - try { - const { stdout, stderr } = await runCtx(["compact", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown("Context compacted successfully."); - } - } catch (err: unknown) { + const [first, ...words] = tokenize(prompt); + const type = first?.toLowerCase(); + if (!type || !ENTRY_TYPES.includes(type)) { stream.markdown( - `**Error:** Failed to compact context.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + "**Usage:** `@ctx /add [flags]`\n\n" + + "| Type | Required flags |\n" + + "|------|----------------|\n" + + '| `task` | `--section ""` |\n' + + '| `decision` | `--context "..." --rationale "..." --consequence "..."` |\n' + + '| `learning` | `--context "..." --lesson "..." --application "..."` |\n' + + '| `convention` | `--section "
"` |\n\n' + + "Quote multi-word values. Provenance (`--session-id`, `--branch`, " + + "`--commit`) is filled in automatically.\n\n" + + 'Example: `@ctx /add task Fix the login redirect loop --section "Phase 1"`' ); - } - return { metadata: { command: "compact" } }; -} - -async function handleSync( - stream: vscode.ChatResponseStream, - cwd: string, - token: vscode.CancellationToken -): Promise { - stream.progress("Syncing context with codebase..."); - try { - const { stdout, stderr } = await runCtx(["sync", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown("Context synced with codebase."); + return result("add"); + } + const { text, flags } = splitFlags(words); + const args = [type, "add"]; + if (text) { + args.push(text); + } + // No --section default: the CLI makes the caller choose the section, + // so a catch-all never quietly collects every entry. + args.push(...flags); + if (type !== "convention") { + const provenance = await provenanceFlags(cwd, token); + for (let i = 0; i < provenance.length; i += 2) { + if (!flags.includes(provenance[i])) { + args.push(provenance[i], provenance[i + 1]); + } } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to sync context.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); } - return { metadata: { command: "sync" } }; + await runAndRender(stream, cwd, token, args, `Adding ${type}...`, `Added **${type}**.`); + return result("add"); } async function handleTask( @@ -602,30 +726,22 @@ async function handleTask( const rest = parts.slice(1).join(" "); let args: string[]; - let progressMsg: string; - switch (subcmd) { - case "complete": { - const taskRef = rest.trim(); - if (!taskRef) { + case "complete": + if (!rest) { stream.markdown( "**Usage:** `@ctx /task complete `\n\n" + - "Example: `@ctx /task complete 3` or " + - "`@ctx /task complete Fix login bug`" + "Example: `@ctx /task complete 3` or `@ctx /task complete Fix login bug`" ); - return { metadata: { command: "task" } }; + return result("task"); } - args = ["task", "complete", taskRef]; - progressMsg = "Marking task as completed..."; + args = ["task", "complete", rest]; break; - } case "archive": args = ["task", "archive"]; - progressMsg = "Archiving completed tasks..."; break; case "snapshot": args = rest ? ["task", "snapshot", rest] : ["task", "snapshot"]; - progressMsg = "Creating task snapshot..."; break; default: stream.markdown( @@ -635,38 +751,12 @@ async function handleTask( "| `complete ` | Mark a task as completed |\n" + "| `archive` | Move completed tasks to archive |\n" + "| `snapshot [name]` | Create point-in-time snapshot |\n\n" + - "Example: `@ctx /task complete 3` or " + - "`@ctx /task archive`" + "Add tasks with `@ctx /add task ...`." ); - return { metadata: { command: "task" } }; - } - args.push("--no-color"); - - stream.progress(progressMsg); - try { - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - switch (subcmd) { - case "complete": - stream.markdown(`Task **${rest.trim()}** marked as completed.`); - break; - case "archive": - stream.markdown("Completed tasks archived."); - break; - default: - stream.markdown("Task snapshot created."); - break; - } - } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to ${subcmd} task.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); + return result("task"); } - return { metadata: { command: "task" } }; + await runAndRender(stream, cwd, token, args, `Running ctx ${args.join(" ")}...`, `\`ctx ${args.join(" ")}\` completed.`); + return result("task"); } async function handleRemind( @@ -680,51 +770,25 @@ async function handleRemind( const rest = parts.slice(1).join(" "); let args: string[]; - let progressMsg: string; - switch (subcmd) { case "dismiss": case "rm": - args = rest ? ["remind", "dismiss", rest] : ["remind", "dismiss", "--all"]; - progressMsg = "Dismissing reminder(s)..."; + args = rest ? ["remind", "dismiss", ...rest.split(/\s+/)] : ["remind", "dismiss", "--all"]; break; case "list": case "ls": args = ["remind", "list"]; - progressMsg = "Listing reminders..."; break; case "add": args = rest ? ["remind", "add", rest] : ["remind", "list"]; - progressMsg = rest ? "Adding reminder..." : "Listing reminders..."; break; default: - // If text provided without subcommand, treat as "add" - if (subcmd) { - args = ["remind", "add", prompt.trim()]; - progressMsg = "Adding reminder..."; - } else { - args = ["remind", "list"]; - progressMsg = "Listing reminders..."; - } + // Text without a subcommand is a new reminder. + args = subcmd ? ["remind", "add", prompt.trim()] : ["remind", "list"]; break; } - args.push("--no-color"); - - stream.progress(progressMsg); - try { - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown("No reminders."); - } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to manage reminders.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "remind" } }; + await runAndRender(stream, cwd, token, args, "Managing reminders...", "No reminders."); + return result("remind"); } async function handlePad( @@ -736,66 +800,54 @@ async function handlePad( const parts = prompt.trim().split(/\s+/); const subcmd = parts[0]?.toLowerCase(); const rest = parts.slice(1).join(" "); + const usage: Record = { + add: "`@ctx /pad add `", + rm: "`@ctx /pad rm [number...]`", + edit: "`@ctx /pad edit [text]`", + mv: "`@ctx /pad mv `", + import: "`@ctx /pad import `", + merge: "`@ctx /pad merge [file...]`", + }; let args: string[]; - let progressMsg: string; - switch (subcmd) { case "add": - if (!rest) { - stream.markdown("**Usage:** `@ctx /pad add `"); - return { metadata: { command: "pad" } }; - } args = ["pad", "add", rest]; - progressMsg = "Adding scratchpad entry..."; break; case "show": args = rest ? ["pad", "show", rest] : ["pad"]; - progressMsg = "Showing scratchpad entry..."; break; case "rm": - if (!rest) { - stream.markdown("**Usage:** `@ctx /pad rm `"); - return { metadata: { command: "pad" } }; - } - args = ["pad", "rm", rest]; - progressMsg = "Removing scratchpad entry..."; + case "mv": + case "merge": + args = ["pad", subcmd, ...parts.slice(1)]; break; - case "edit": - if (!rest) { - stream.markdown("**Usage:** `@ctx /pad edit [text]`"); - return { metadata: { command: "pad" } }; - } - args = ["pad", "edit", ...parts.slice(1)]; - progressMsg = "Editing scratchpad entry..."; + case "edit": { + // `ctx pad edit N [TEXT]`: the replacement text is one argument. + const text = parts.slice(2).join(" "); + args = text ? ["pad", "edit", parts[1], text] : ["pad", "edit", parts[1]]; break; - case "mv": - args = ["pad", "mv", ...parts.slice(1)]; - progressMsg = "Moving scratchpad entry..."; + } + case "import": + args = ["pad", "import", rest]; + break; + case "export": + args = rest ? ["pad", "export", rest] : ["pad", "export"]; + break; + case "resolve": + args = ["pad", "resolve"]; 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(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown("Scratchpad is empty."); - } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to access scratchpad.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); + if (subcmd && Object.hasOwn(usage, subcmd) && !rest) { + stream.markdown(`**Usage:** ${usage[subcmd]}`); + return result("pad"); } - return { metadata: { command: "pad" } }; + await runAndRender(stream, cwd, token, args, "Accessing scratchpad...", "Scratchpad is empty."); + return result("pad"); } async function handleNotify( @@ -804,63 +856,39 @@ async function handleNotify( cwd: string, token: vscode.CancellationToken ): Promise { - const parts = prompt.trim().split(/\s+/); - const subcmd = parts[0]?.toLowerCase(); - - let args: string[]; - let progressMsg: string; + const words = tokenize(prompt); + const subcmd = words[0]?.toLowerCase(); - switch (subcmd) { - case "setup": - args = ["notify", "setup"]; - progressMsg = "Setting up webhook..."; - break; - case "test": - args = ["notify", "test"]; - progressMsg = "Sending test notification..."; - break; - default: { - // Send a notification — require --event flag - if (!subcmd) { - stream.markdown( - "**Usage:** `@ctx /notify `\n\n" + - "| Subcommand | Description |\n" + - "|------------|-------------|\n" + - "| `setup` | Configure webhook URL |\n" + - "| `test` | Send test notification |\n" + - "| ` --event ` | Send notification |\n\n" + - "Example: `@ctx /notify test` or `@ctx /notify setup`" - ); - return { metadata: { command: "notify" } }; - } - args = ["notify", ...parts]; - progressMsg = "Sending notification..."; - break; - } + if (subcmd === "setup") { + // `ctx hook notify setup` prompts for the webhook URL. The URL is a + // secret, so it is entered in a terminal, never in the chat history. + stream.markdown( + "Run `ctx hook notify setup` in a terminal. It prompts for the webhook " + + "URL and stores it encrypted; then check it with `@ctx /notify test`." + ); + return result("notify"); } - args.push("--no-color"); - - stream.progress(progressMsg); - try { - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { + let args: string[]; + if (subcmd === "test") { + args = ["hook", "notify", "test"]; + } else { + const { text, flags } = splitFlags(words); + if (!text) { stream.markdown( - subcmd === "setup" - ? "Webhook configured." - : subcmd === "test" - ? "Test notification sent." - : "Notification sent." + "**Usage:** `@ctx /notify `\n\n" + + "| Subcommand | Description |\n" + + "|------------|-------------|\n" + + "| `setup` | How to configure the webhook URL |\n" + + "| `test` | Send test notification |\n" + + "| ` --event ` | Send notification |\n\n" + + "Example: `@ctx /notify test` or `@ctx /notify build done --event build`" ); + return result("notify"); } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to send notification.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); + args = ["hook", "notify", text, ...flags]; } - return { metadata: { command: "notify" } }; + await runAndRender(stream, cwd, token, args, "Sending notification...", "Notification sent."); + return result("notify"); } async function handleSystem( @@ -873,53 +901,42 @@ async function handleSystem( const subcmd = parts[0]?.toLowerCase(); let args: string[]; - let progressMsg: string; - switch (subcmd) { case "resources": - args = ["system", "resources"]; - progressMsg = "Checking system resources..."; + args = ["sysinfo"]; break; case "bootstrap": args = ["system", "bootstrap"]; - progressMsg = "Running bootstrap..."; break; - case "message": - args = ["system", "message", ...parts.slice(1)]; - progressMsg = "Managing hook messages..."; + case "stats": + args = ["usage"]; break; + case "message": { + const action = parts[1]?.toLowerCase(); + args = ["show", "edit", "reset"].includes(action ?? "") + ? ["hook", "message", action as string, ...parts.slice(2)] + : ["hook", "message", "list"]; + break; + } default: stream.markdown( "**Usage:** `@ctx /system `\n\n" + - "| Subcommand | Description |\n" + - "|------------|-------------|\n" + - "| `resources` | Show system resource usage |\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`" + "| Subcommand | Runs |\n" + + "|------------|------|\n" + + "| `resources` | `ctx sysinfo`: memory, swap, disk, load |\n" + + "| `bootstrap` | `ctx system bootstrap`: context location for AI agents |\n" + + "| `stats` | `ctx usage`: session token usage |\n" + + "| `message [list]` | `ctx hook message list` |\n" + + "| `message show\\|edit\\|reset ` | `ctx hook message ...` |\n\n" + + "Example: `@ctx /system resources`" ); - 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(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown("No output."); - } - } catch (err: unknown) { - stream.markdown( - `**Error:** System command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); + return result("system"); } - return { metadata: { command: "system" } }; + await runAndRender(stream, cwd, token, args, `Running ctx ${args.join(" ")}...`, "No output."); + return result("system"); } -async function handleMemory( +async function handleConfig( stream: vscode.ChatResponseStream, prompt: string, cwd: string, @@ -927,674 +944,303 @@ async function handleMemory( ): Promise { const parts = prompt.trim().split(/\s+/); const subcmd = parts[0]?.toLowerCase(); + const profile = parts[1]; let args: string[]; - let progressMsg: string; + if (subcmd === "switch" && profile) { + args = ["config", "switch", profile]; + } else if (subcmd === "status" || subcmd === "schema") { + args = ["config", subcmd]; + } else { + stream.markdown( + "**Usage:** `@ctx /config `\n\n" + + "| Subcommand | Description |\n" + + "|------------|-------------|\n" + + "| `switch ` | Switch the runtime config profile |\n" + + "| `status` | Show the active profile |\n" + + "| `schema` | Show the .ctxrc schema |\n\n" + + "Example: `@ctx /config switch dev`" + ); + return result("config"); + } + await runAndRender(stream, cwd, token, args, `Running ctx ${args.join(" ")}...`, "No output."); + return result("config"); +} - switch (subcmd) { - case "sync": - args = ["memory", "sync"]; - progressMsg = "Syncing memory bridge..."; - break; - case "status": - args = ["memory", "status"]; - progressMsg = "Checking memory status..."; - break; - case "diff": - args = ["memory", "diff"]; - progressMsg = "Diffing memory state..."; - break; - case "import": - args = ["memory", "import"]; - progressMsg = "Importing memory..."; - break; - case "publish": - args = ["memory", "publish"]; - progressMsg = "Publishing memory..."; - break; - case "unpublish": - args = ["memory", "unpublish"]; - progressMsg = "Unpublishing memory..."; - break; - default: - stream.markdown( - "**Usage:** `@ctx /memory `\n\n" + - "| Subcommand | Description |\n" + - "|------------|-------------|\n" + - "| `sync` | Synchronize memory bridge |\n" + - "| `status` | Show memory bridge status |\n" + - "| `diff` | Show memory diff |\n" + - "| `import` | Import external memory |\n" + - "| `publish` | Publish curated context |\n" + - "| `unpublish` | Remove published context |\n\n" + - "Example: `@ctx /memory status` or `@ctx /memory sync`" - ); - return { metadata: { command: "memory" } }; - } - args.push("--no-color"); - - stream.progress(progressMsg); - try { - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown(`Memory ${subcmd} completed.`); - } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to run memory ${subcmd}.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "memory" } }; -} - -async function handleJournal( +async function handleWhy( stream: vscode.ChatResponseStream, prompt: string, cwd: string, token: vscode.CancellationToken ): Promise { - const parts = prompt.trim().split(/\s+/); - const subcmd = parts[0]?.toLowerCase(); - - let args: string[]; - let progressMsg: string; - - switch (subcmd) { - case "site": - args = ["journal", "site"]; - progressMsg = "Generating journal site..."; - break; - case "obsidian": - args = ["journal", "obsidian"]; - progressMsg = "Exporting journal to Obsidian..."; - break; - default: - stream.markdown( - "**Usage:** `@ctx /journal `\n\n" + - "| Subcommand | Description |\n" + - "|------------|-------------|\n" + - "| `site` | Generate journal site |\n" + - "| `obsidian` | Export journal to Obsidian |\n\n" + - "Example: `@ctx /journal site`" - ); - return { metadata: { command: "journal" } }; - } - args.push("--no-color"); - - stream.progress(progressMsg); - try { - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown(`Journal ${subcmd} completed.`); - } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to run journal ${subcmd}.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "journal" } }; -} - -async function handleDoctor( - stream: vscode.ChatResponseStream, - cwd: string, - token: vscode.CancellationToken -): Promise { - stream.progress("Running context health diagnostics..."); - try { - const { stdout, stderr } = await runCtx(["doctor", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown("Context health check passed."); - } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to run doctor.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "doctor" } }; + // Bare `ctx why` opens an interactive menu, so default to a document. + const args = ["why", prompt.trim() || "manifesto"]; + await runAndRender(stream, cwd, token, args, "Loading philosophy...", "No philosophy content available.", false); + return result("why"); } -async function handleConfig( +async function handleChange( 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": - args = rest ? ["config", "switch", rest] : ["config", "switch"]; - progressMsg = "Switching configuration..."; - break; - case "status": - args = ["config", "status"]; - progressMsg = "Checking configuration status..."; - break; - case "schema": - args = ["config", "schema"]; - progressMsg = "Showing configuration schema..."; - break; - default: - stream.markdown( - "**Usage:** `@ctx /config `\n\n" + - "| Subcommand | Description |\n" + - "|------------|-------------|\n" + - "| `switch` | Switch active configuration |\n" + - "| `status` | Show current configuration |\n" + - "| `schema` | Show configuration schema |\n\n" + - "Example: `@ctx /config status` or `@ctx /config switch minimal`" - ); - return { metadata: { command: "config" } }; + const args = ["change"]; + const since = prompt.match(/--since\s+(\S+)/); + if (since) { + args.push("--since", since[1]); } - args.push("--no-color"); - - stream.progress(progressMsg); - try { - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown(`Config ${subcmd} completed.`); - } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to run config ${subcmd}.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "config" } }; + await runAndRender(stream, cwd, token, args, "Checking what changed...", "No changes detected since last session.", false); + return result("change"); } -async function handlePrompt( +async function handleGuide( 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 "list": - case "ls": - args = ["prompt", "list"]; - progressMsg = "Listing prompt templates..."; - break; - case "add": - args = rest ? ["prompt", "add", rest] : ["prompt", "add"]; - progressMsg = "Adding prompt template..."; - break; - case "show": - args = rest ? ["prompt", "show", rest] : ["prompt", "show"]; - progressMsg = "Showing prompt template..."; - break; - case "rm": - args = rest ? ["prompt", "rm", rest] : ["prompt", "rm"]; - progressMsg = "Removing prompt template..."; - break; - default: - stream.markdown( - "**Usage:** `@ctx /prompt `\n\n" + - "| Subcommand | Description |\n" + - "|------------|-------------|\n" + - "| `list` | List prompt templates |\n" + - "| `add ` | Add a prompt template |\n" + - "| `show ` | Show a prompt template |\n" + - "| `rm ` | Remove a prompt template |\n\n" + - "Example: `@ctx /prompt list` or `@ctx /prompt show review`" - ); - return { metadata: { command: "prompt" } }; - } - args.push("--no-color"); - - stream.progress(progressMsg); - try { - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown(`Prompt ${subcmd} completed.`); - } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to run prompt ${subcmd}.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "prompt" } }; + const args = ["guide"]; + if (prompt.includes("--skills")) { + args.push("--skills"); + } else if (prompt.includes("--commands")) { + args.push("--commands"); + } + await runAndRender(stream, cwd, token, args, "Loading guide...", "No guide output."); + return result("guide"); } -async function handleWhy( - stream: vscode.ChatResponseStream, - prompt: string, - cwd: string, - token: vscode.CancellationToken -): Promise { - const filename = prompt.trim(); - const args = filename ? ["why", filename] : ["why"]; - args.push("--no-color"); +/** CLI-backed slash commands: each dispatches to real `ctx` subcommands. */ +const CLI_COMMANDS: Record = { + init: handleInit, + status: simple("status", ["status"], "Checking context status...", "No status output."), + agent: handleAgent, + drift: simple("drift", ["drift"], "Detecting context drift...", "No drift output."), + recall: handleRecall, + setup: handleSetup, + add: handleAdd, + // posix join: ctx accepts forward slashes on every OS, and the argv + // stays identical across platforms (see ctx-cli-surface.json). + decision: simple( + "decision", + ["index", path.posix.join(".context", "DECISIONS.md")], + "Loading decisions...", + "No decisions recorded yet. Add one with `@ctx /add decision ...`." + ), + learning: simple( + "learning", + ["index", path.posix.join(".context", "LEARNINGS.md")], + "Loading learnings...", + "No learnings recorded yet. Add one with `@ctx /add learning ...`." + ), + load: simple("load", ["load"], "Loading assembled context...", "No context loaded.", false), + compact: simple("compact", ["compact"], "Compacting context...", "Context compacted."), + sync: simple("sync", ["sync"], "Syncing context with codebase...", "Context synced with codebase."), + task: handleTask, + remind: handleRemind, + pad: handlePad, + notify: handleNotify, + system: handleSystem, + memory: subcommands( + "memory", + ["sync", "status", "diff", "import", "publish", "unpublish"], + "**Usage:** `@ctx /memory `\n\n" + + "Bridges Claude Code auto memory into `.context/`. Example: `@ctx /memory status`" + ), + journal: subcommands( + "journal", + ["site", "obsidian"], + "**Usage:** `@ctx /journal `\n\n" + + "Exports the session journal. Browse sessions with `@ctx /recall`." + ), + doctor: simple("doctor", ["doctor"], "Running context health diagnostics...", "Context health check passed."), + config: handleConfig, + why: handleWhy, + change: handleChange, + guide: handleGuide, + permission: subcommands( + "permission", + ["snapshot", "restore"], + "**Usage:** `@ctx /permission `\n\n" + + "Saves or restores the Claude Code permission golden image." + ), + pause: simple("pause", ["hook", "pause"], "Pausing context hooks...", "Context hooks paused."), + resume: simple("resume", ["hook", "resume"], "Resuming context hooks...", "Context hooks resumed."), +}; - stream.progress("Looking up design rationale..."); - try { - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown("No rationale found."); - } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to look up rationale.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "why" } }; +interface SkillCommand { + /** Skill directory under internal/assets/claude/skills. */ + skill: string; + /** The skill's SKILL.md, bundled at build time. */ + text: string; + /** Read-only ctx invocations the skill's process relies on; `ctx agent` always runs. */ + reads: string[][]; } -async function handleChange( - stream: vscode.ChatResponseStream, - cwd: string, - token: vscode.CancellationToken -): Promise { - stream.progress("Checking recent codebase changes..."); - try { - const { stdout, stderr } = await runCtx(["change", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown("No recent changes found."); - } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to check changes.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "change" } }; -} +/** + * Skill-backed slash commands: `/` runs the canonical `ctx-` + * skill through the chat model, grounded in live ctx output. Only skills + * that can work from that output, the @ctx conversation, and #file + * attachments are exposed: the model here cannot run commands or read the + * workspace on its own. + */ +const SKILLS: Record = { + blog: { skill: "ctx-blog", text: blogSkill, reads: [["journal", "source", "--limit", "10"]] }, + brainstorm: { skill: "ctx-brainstorm", text: brainstormSkill, reads: [] }, + consolidate: { skill: "ctx-consolidate", text: consolidateSkill, reads: [["drift", "--json"]] }, + implement: { skill: "ctx-implement", text: implementSkill, reads: [] }, + next: { skill: "ctx-next", text: nextSkill, reads: [["journal", "source", "--limit", "3"]] }, + reflect: { skill: "ctx-reflect", text: reflectSkill, reads: [] }, + remember: { skill: "ctx-remember", text: rememberSkill, reads: [["journal", "source", "--limit", "3"]] }, + spec: { skill: "ctx-spec", text: specSkill, reads: [] }, + "wrap-up": { skill: "ctx-wrap-up", text: wrapUpSkill, reads: [] }, +}; -async function handleDep( - stream: vscode.ChatResponseStream, - cwd: string, - token: vscode.CancellationToken -): Promise { - stream.progress("Checking project dependencies..."); - try { - const { stdout, stderr } = await runCtx(["dep", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown("No dependencies found."); - } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to check dependencies.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "dep" } }; +function skillPreamble(skill: string): string { + return ( + `You are running the ctx skill \`${skill}\` for the user inside the VS Code \`@ctx\` chat participant. ` + + "Follow the skill below within the limits of this environment:\n" + + "- You cannot run commands or read or edit files. The ctx CLI output and any files the user attached " + + "are included in the next message. If the skill needs something that is missing, ask the user to " + + "attach the file with #file or to paste the command output.\n" + + "- Where the skill says to run a command or change a file, give the exact command or change for the " + + "user to apply (`ctx` commands also exist as `@ctx` slash commands, e.g. `@ctx /add`). Never claim you " + + "ran or changed anything.\n\n" + ); } -async function handleGuide( - stream: vscode.ChatResponseStream, - cwd: string, - token: vscode.CancellationToken -): Promise { - stream.progress("Loading quick start guide..."); - try { - const { stdout, stderr } = await runCtx(["guide", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown(output); - } else { - stream.markdown("No guide content available."); +/** + * Earlier skill exchanges in this @ctx conversation, so multi-turn skills + * keep their thread. CLI command turns are left out: /pad, /notify, and + * /add can carry secrets, and their output must not reach the model. + */ +function historyMessages(context: vscode.ChatContext): vscode.LanguageModelChatMessage[] { + const messages: vscode.LanguageModelChatMessage[] = []; + context.history.forEach((turn, i) => { + if (!(turn instanceof vscode.ChatResponseTurn)) { + return; } - } 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 handlePermission( - stream: vscode.ChatResponseStream, - prompt: string, - cwd: string, - token: vscode.CancellationToken -): Promise { - const parts = prompt.trim().split(/\s+/); - const subcmd = parts[0]?.toLowerCase(); - - let args: string[]; - let progressMsg: string; - - switch (subcmd) { - case "snapshot": - args = ["permission", "snapshot"]; - progressMsg = "Taking permission snapshot..."; - break; - case "restore": - args = ["permission", "restore"]; - progressMsg = "Restoring permissions..."; - break; - default: - stream.markdown( - "**Usage:** `@ctx /permission `\n\n" + - "| Subcommand | Description |\n" + - "|------------|-------------|\n" + - "| `snapshot` | Capture current file permissions |\n" + - "| `restore` | Restore saved permissions |\n\n" + - "Example: `@ctx /permission snapshot`" - ); - return { metadata: { command: "permission" } }; - } - args.push("--no-color"); - - stream.progress(progressMsg); - try { - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown(`Permission ${subcmd} completed.`); + const command = (turn.result as CtxResult).metadata?.command; + if (!command || !Object.hasOwn(SKILLS, command)) { + return; } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to ${subcmd} permissions.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "permission" } }; -} - -async function handleSite( - stream: vscode.ChatResponseStream, - prompt: string, - cwd: string, - token: vscode.CancellationToken -): Promise { - const parts = prompt.trim().split(/\s+/); - const subcmd = parts[0]?.toLowerCase(); - - let args: string[]; - let progressMsg: string; - - switch (subcmd) { - case "feed": - args = ["site", "feed"]; - progressMsg = "Generating site feed..."; - break; - default: - stream.markdown( - "**Usage:** `@ctx /site `\n\n" + - "| Subcommand | Description |\n" + - "|------------|-------------|\n" + - "| `feed` | Generate documentation site feed |\n\n" + - "Example: `@ctx /site feed`" + const asked = context.history[i - 1]; + if (asked instanceof vscode.ChatRequestTurn) { + messages.push( + vscode.LanguageModelChatMessage.User((asked.command ? `/${asked.command} ` : "") + asked.prompt) ); - return { metadata: { command: "site" } }; - } - args.push("--no-color"); - - stream.progress(progressMsg); - try { - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown(`Site ${subcmd} completed.`); } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to run site ${subcmd}.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "site" } }; -} - -async function handleLoop( - stream: vscode.ChatResponseStream, - prompt: string, - cwd: string, - token: vscode.CancellationToken -): Promise { - const toolName = prompt.trim(); - const args = toolName ? ["loop", toolName] : ["loop"]; - args.push("--no-color"); - - stream.progress("Generating iteration script..."); - try { - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown("No loop script generated."); + const answer = turn.response + .map((part) => (part instanceof vscode.ChatResponseMarkdownPart ? part.value.value : "")) + .join(""); + if (answer) { + messages.push(vscode.LanguageModelChatMessage.Assistant(answer)); } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to generate loop script.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "loop" } }; + }); + return messages; } -async function handlePause( +async function handleSkill( + command: string, + request: vscode.ChatRequest, + context: vscode.ChatContext, stream: vscode.ChatResponseStream, cwd: string, token: vscode.CancellationToken ): Promise { - stream.progress("Pausing context hooks..."); + const { skill, text, reads } = SKILLS[command]; + stream.progress(`Loading project context for ${skill}...`); try { - const { stdout, stderr } = await runCtx(["pause", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown("Context hooks paused for this session."); + const sections: string[] = []; + for (const args of [["agent"], ...reads]) { + const run = await runCtx(args, cwd, token); + sections.push( + run.code === 0 + ? `### \`ctx ${args.join(" ")}\`\n\n${run.stdout.trim()}` + : `### \`ctx ${args.join(" ")}\` (exited with code ${run.code})\n\n${mergeOutput(run.stdout, run.stderr)}` + ); + } + // ponytail: attachments are inlined whole; cap them if large files start blowing the model's context. + for (const ref of request.references) { + if (ref.value instanceof vscode.Uri) { + const content = new TextDecoder().decode(await vscode.workspace.fs.readFile(ref.value)); + sections.push(`### Attached: ${vscode.workspace.asRelativePath(ref.value)}\n\n${content}`); + } } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to pause hooks.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "pause" } }; -} -async function handleResume( - stream: vscode.ChatResponseStream, - cwd: string, - token: vscode.CancellationToken -): Promise { - stream.progress("Resuming context hooks..."); - try { - const { stdout, stderr } = await runCtx(["resume", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown("Context hooks resumed."); + const model = + request.model ?? (await vscode.lm.selectChatModels({ vendor: "copilot" }))[0]; + if (!model) { + stream.markdown("**Error:** No chat model is available. Sign in to GitHub Copilot Chat and retry."); + return result(command); + } + const messages = [ + vscode.LanguageModelChatMessage.User(skillPreamble(skill) + text), + vscode.LanguageModelChatMessage.User("## Project context\n\n" + sections.join("\n\n")), + ...historyMessages(context), + vscode.LanguageModelChatMessage.User(request.prompt.trim() || `Run the ${skill} skill.`), + ]; + stream.progress(`Running ${skill}...`); + const response = await model.sendRequest(messages, {}, token); + for await (const fragment of response.text) { + stream.markdown(fragment); } } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to resume hooks.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); + stream.markdown(errorMarkdown(`Failed to run ${skill}`, err)); } - return { metadata: { command: "resume" } }; + return result(command); } -async function handleReindex( - stream: vscode.ChatResponseStream, - cwd: string, - token: vscode.CancellationToken -): Promise { - stream.progress("Rebuilding context file indices..."); - try { - const { stdout, stderr } = await runCtx(["reindex", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); - } else { - stream.markdown("Context indices rebuilt."); - } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to reindex.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); - } - return { metadata: { command: "reindex" } }; +/** + * Natural-language routing for prompts without a slash command. Only + * read-only targets: a keyword match must never mutate context. CLI + * targets run with no arguments; skill targets get the prompt. + */ +const FREEFORM: Array<[string[], string]> = [ + [["remember", "last session", "what were we"], "remember"], + [["what should i", "work on next", "next task"], "next"], + [["wrap up", "wrap-up", "end of session"], "wrap-up"], + [["reflect", "worth saving", "worth persisting"], "reflect"], + [["brainstorm", "idea"], "brainstorm"], + [["status"], "status"], + [["drift"], "drift"], + [["doctor", "health"], "doctor"], + [["what changed", "since last session"], "change"], + [["recall", "session history"], "recall"], + [["guide", "cheat sheet", "getting started"], "guide"], + [["philosophy", "manifesto"], "why"], +]; + +function helpMarkdown(): string { + const commands: Array<{ name: string; description: string }> = + extensionCtx?.extension.packageJSON?.contributes?.chatParticipants?.[0]?.commands ?? []; + return ( + "## ctx: Persistent Context for AI\n\n" + + "| Command | Description |\n" + + "|---------|-------------|\n" + + commands.map((c) => `| \`/${c.name}\` | ${c.description} |`).join("\n") + + "\n\nExample: `@ctx /status` or `@ctx /add task Fix login bug`" + ); } -async function handleFreeform( +async function dispatch( + command: string, + prompt: string, request: vscode.ChatRequest, + context: vscode.ChatContext, 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 handleTask(stream, "complete " + request.prompt, cwd, token); - } - if (prompt.includes("remind")) { - return handleRemind(stream, request.prompt, cwd, token); - } - if (prompt.includes("task")) { - return handleTask(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("memory")) { - return handleMemory(stream, request.prompt, cwd, token); - } - if (prompt.includes("journal")) { - return handleJournal(stream, request.prompt, cwd, token); - } - if (prompt.includes("doctor") || prompt.includes("health")) { - return handleDoctor(stream, cwd, token); - } - if (prompt.includes("config") || prompt.includes("configuration")) { - return handleConfig(stream, request.prompt, cwd, token); - } - if (prompt.includes("prompt") || prompt.includes("template")) { - return handlePrompt(stream, request.prompt, cwd, token); - } - if (prompt.includes("why") || prompt.includes("rationale")) { - return handleWhy(stream, request.prompt, cwd, token); - } - if (prompt.includes("change") || prompt.includes("recent")) { - return handleChange(stream, cwd, token); + if (Object.hasOwn(SKILLS, command)) { + return handleSkill(command, request, context, stream, cwd, token); } - if (prompt.includes("dep") || prompt.includes("dependenc")) { - return handleDep(stream, cwd, token); - } - if (prompt.includes("guide") || prompt.includes("quickstart") || prompt.includes("getting started")) { - return handleGuide(stream, cwd, token); - } - if (prompt.includes("permission")) { - return handlePermission(stream, request.prompt, cwd, token); - } - if (prompt.includes("site") || prompt.includes("feed")) { - return handleSite(stream, request.prompt, cwd, token); - } - if (prompt.includes("loop") || prompt.includes("iterate")) { - return handleLoop(stream, request.prompt, cwd, token); - } - if (prompt.includes("pause")) { - return handlePause(stream, cwd, token); - } - if (prompt.includes("resume")) { - return handleResume(stream, cwd, token); - } - if (prompt.includes("reindex") || prompt.includes("rebuild")) { - return handleReindex(stream, cwd, token); - } - - // 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" + - "| `/setup` | 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" + - "| `/task` | Task operations (complete, archive, snapshot) |\n" + - "| `/remind` | Manage session reminders |\n" + - "| `/pad` | Encrypted scratchpad |\n" + - "| `/notify` | Webhook notifications |\n" + - "| `/system` | System diagnostics |\n" + - "| `/memory` | Memory bridge operations |\n" + - "| `/journal` | Journal management |\n" + - "| `/doctor` | Context health diagnostics |\n" + - "| `/config` | Runtime configuration |\n" + - "| `/prompt` | Prompt templates |\n" + - "| `/why` | Design rationale for context files |\n" + - "| `/change` | Recent codebase changes |\n" + - "| `/dep` | Project dependencies |\n" + - "| `/guide` | Quick start guide |\n" + - "| `/permission` | Permission snapshot/restore |\n" + - "| `/site` | Documentation site |\n" + - "| `/loop` | Generate iteration scripts |\n" + - "| `/pause` | Pause context hooks |\n" + - "| `/resume` | Resume context hooks |\n" + - "| `/reindex` | Rebuild context indices |\n\n" + - "Example: `@ctx /status` or `@ctx /add task Fix login bug`" - ); - return { metadata: { command: "help" } }; + return CLI_COMMANDS[command](stream, prompt, cwd, token); } const handler: vscode.ChatRequestHandler = async ( request: vscode.ChatRequest, - _context: vscode.ChatContext, + context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken ): Promise => { @@ -1603,7 +1249,7 @@ const handler: vscode.ChatRequestHandler = async ( stream.markdown( "**Error:** No workspace folder is open. Open a project folder first." ); - return { metadata: { command: request.command || "none" } }; + return result(request.command || "none"); } // Auto-bootstrap: ensure ctx binary is available before any command @@ -1613,190 +1259,158 @@ const handler: vscode.ChatRequestHandler = async ( } 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\` ` + + fence(err instanceof Error ? err.message : String(err)) + + `\n\nInstall 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" } }; + return result(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, cwd, token); - case "drift": - return handleDrift(stream, cwd, token); - case "recall": - return handleRecall(stream, request.prompt, cwd, token); - case "setup": - return handleSetup(stream, request.prompt, cwd, token); - case "add": - return handleAdd(stream, request.prompt, cwd, token); - case "load": - return handleLoad(stream, cwd, token); - case "compact": - return handleCompact(stream, cwd, token); - case "sync": - return handleSync(stream, cwd, token); - case "task": - return handleTask(stream, request.prompt, cwd, token); - case "remind": - return handleRemind(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 "memory": - return handleMemory(stream, request.prompt, cwd, token); - case "journal": - return handleJournal(stream, request.prompt, cwd, token); - case "doctor": - return handleDoctor(stream, cwd, token); - case "config": - return handleConfig(stream, request.prompt, cwd, token); - case "prompt": - return handlePrompt(stream, request.prompt, cwd, token); - case "why": - return handleWhy(stream, request.prompt, cwd, token); - case "change": - return handleChange(stream, cwd, token); - case "dep": - return handleDep(stream, cwd, token); - case "guide": - return handleGuide(stream, cwd, token); - case "permission": - return handlePermission(stream, request.prompt, cwd, token); - case "site": - return handleSite(stream, request.prompt, cwd, token); - case "loop": - return handleLoop(stream, request.prompt, cwd, token); - case "pause": - return handlePause(stream, cwd, token); - case "resume": - return handleResume(stream, cwd, token); - case "reindex": - return handleReindex(stream, cwd, token); - default: - return handleFreeform(request, stream, cwd, token); + // No init gate here: the CLI decides which commands need .context/ + // (AnnotationSkipInit), and runAndRender points at /init when one fails. + const command = request.command; + if (command && (Object.hasOwn(SKILLS, command) || Object.hasOwn(CLI_COMMANDS, command))) { + return dispatch(command, request.prompt, request, context, stream, cwd, token); + } + + // A plain reply right after a skill turn continues that skill, so + // multi-turn workflows (e.g. /brainstorm) keep their thread. + const last = context.history[context.history.length - 1]; + const previous = + last instanceof vscode.ChatResponseTurn ? (last.result as CtxResult).metadata?.command : undefined; + if (previous && Object.hasOwn(SKILLS, previous)) { + return handleSkill(previous, request, context, stream, cwd, token); + } + + const text = request.prompt.trim().toLowerCase(); + const hit = FREEFORM.find(([keywords]) => keywords.some((k) => text.includes(k))); + if (hit) { + return dispatch(hit[1], "", request, context, stream, cwd, token); } + stream.markdown(helpMarkdown()); + return result("help"); }; +/** + * Follow-up suggestions per command. `prompt` is what the follow-up sends + * as the command's arguments; `label` is what the user sees. + */ +const FOLLOWUPS: Record = { + init: [ + { label: "Show context status", prompt: "", command: "status" }, + { label: "What should I work on next?", prompt: "", command: "next" }, + ], + status: [ + { label: "Detect context drift", prompt: "", command: "drift" }, + { label: "What should I work on next?", prompt: "", command: "next" }, + ], + drift: [ + { label: "Sync context with codebase", prompt: "", command: "sync" }, + { label: "Run health check", prompt: "", command: "doctor" }, + ], + doctor: [ + { label: "Show context status", prompt: "", command: "status" }, + { label: "Detect context drift", prompt: "", command: "drift" }, + ], + task: [ + { label: "Show context status", prompt: "", command: "status" }, + { label: "Compact context", prompt: "", command: "compact" }, + ], + remind: [{ label: "List reminders", prompt: "list", command: "remind" }], + pad: [{ label: "List scratchpad", prompt: "", command: "pad" }], + pause: [{ label: "Resume hooks", prompt: "", command: "resume" }], + resume: [{ label: "Show context status", prompt: "", command: "status" }], + remember: [{ label: "What should I work on next?", prompt: "", command: "next" }], + next: [{ label: "Show context status", prompt: "", command: "status" }], + reflect: [{ label: "Wrap up the session", prompt: "", command: "wrap-up" }], + "wrap-up": [{ label: "Record an entry", prompt: "", command: "add" }], + brainstorm: [{ label: "Turn this into a spec", prompt: "Turn the design above into a spec.", command: "spec" }], + spec: [{ label: "Plan the implementation", prompt: "Plan the implementation of the spec above.", command: "implement" }], + help: [ + { label: "Initialize project context", prompt: "", command: "init" }, + { label: "Show context status", prompt: "", command: "status" }, + { label: "Quick-reference guide", prompt: "", command: "guide" }, + ], +}; + +/** + * Show `$(bell) ctx` while `ctx remind list` has entries. `remind list` + * is read-only, so the .context/** watcher that calls this cannot loop. + */ +function updateReminderStatus(cwd: string): void { + if (!bootstrapDone || !reminderStatusBar) { + return; + } + const bar = reminderStatusBar; + runCtx(REMINDER_CHECK, cwd) + .then(({ stdout, code }) => { + const trimmed = stdout.trim(); + if (code === 0 && trimmed && !/no reminders/i.test(trimmed)) { + bar.text = "$(bell) ctx"; + bar.tooltip = trimmed; + bar.show(); + } else { + bar.hide(); + } + }) + .catch(() => bar.hide()); +} + 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) { + return FOLLOWUPS[result.metadata.command] ?? []; + }, + }; + extensionContext.subscriptions.push(participant); - const participant = vscode.chat.createChatParticipant( - PARTICIPANT_ID, - handler - ); - participant.iconPath = vscode.Uri.joinPath( - extensionContext.extensionUri, - "icon.png" - ); + reminderStatusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 50); + reminderStatusBar.name = "ctx Reminders"; + extensionContext.subscriptions.push(reminderStatusBar); - participant.followupProvider = { - provideFollowups( - result: CtxResult, - _context: vscode.ChatContext, - _token: vscode.CancellationToken - ) { - const followups: vscode.ChatFollowup[] = []; - - switch (result.metadata.command) { - case "init": - followups.push( - { prompt: "Show my context status", command: "status" }, - { - prompt: "Generate copilot integration", - command: "setup", - } - ); - break; - case "status": - followups.push( - { prompt: "Detect context drift", command: "drift" }, - { prompt: "Load full context", command: "load" }, - { prompt: "Run health check", command: "doctor" } - ); - break; - case "drift": - followups.push( - { prompt: "Sync context with codebase", command: "sync" }, - { prompt: "Show context status", command: "status" } - ); - break; - case "task": - followups.push( - { prompt: "Show context status", command: "status" }, - { prompt: "Compact context", command: "compact" } - ); - break; - case "remind": - followups.push( - { prompt: "Show context status", command: "status" } - ); - break; - case "pad": - followups.push( - { prompt: "List scratchpad", command: "pad" } - ); - break; - case "memory": - followups.push( - { prompt: "Check memory status", command: "memory" }, - { prompt: "Show context status", command: "status" } - ); - break; - case "doctor": - followups.push( - { prompt: "Show context status", command: "status" }, - { prompt: "Detect drift", command: "drift" } - ); - break; - case "config": - followups.push( - { prompt: "Show config status", command: "config" } - ); - break; - case "change": - followups.push( - { prompt: "Show context status", command: "status" } - ); - break; - case "pause": - followups.push( - { prompt: "Resume hooks", command: "resume" } - ); - break; - case "resume": - followups.push( - { prompt: "Show context status", command: "status" } - ); - break; - case "help": - followups.push( - { prompt: "Initialize project context", command: "init" }, - { prompt: "Show context status", command: "status" }, - { prompt: "Quick start guide", command: "guide" } - ); - break; + const cwd = getWorkspaceRoot(); + if (!cwd) { + bootstrap().catch(() => {}); + return; + } + const refresh = () => updateReminderStatus(cwd); + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(cwd, ".context/**") + ); + watcher.onDidChange(refresh); + watcher.onDidCreate(refresh); + watcher.onDidDelete(refresh); + const interval = setInterval(refresh, 5 * 60 * 1000); + extensionContext.subscriptions.push(watcher, { dispose: () => clearInterval(interval) }); + + // Background bootstrap; errors surface when the user invokes a command. + // Reminder and session-start calls wait for it, so they use the resolved + // (possibly auto-downloaded) binary. + bootstrap().then( + () => { + if (hasContextDir(cwd)) { + refresh(); + runCtx(SESSION_START, cwd).catch(() => {}); } - - return followups; }, - }; + () => {} + ); +} - extensionContext.subscriptions.push(participant); +export function deactivate(): Thenable | undefined { + const cwd = getWorkspaceRoot(); + if (!cwd || !bootstrapDone || !hasContextDir(cwd)) { + return undefined; + } + return runCtx(SESSION_END, cwd).then( + () => undefined, + () => undefined + ); } export { @@ -1806,26 +1420,11 @@ export { ensureCtxAvailable, bootstrap, getPlatformInfo, - handleTask, - handleRemind, - handlePad, - handleNotify, - handleSystem, - handleMemory, - handleJournal, - handleDoctor, - handleConfig, - handlePrompt, - handleWhy, - handleChange, - handleDep, - handleGuide, - handlePermission, - handleSite, - handleLoop, - handlePause, - handleResume, - handleReindex, + handler, + tokenize, + CLI_COMMANDS, + SKILLS, + FREEFORM, + FOLLOWUPS, + BACKGROUND_INVOCATIONS, }; - -export function deactivate() {} diff --git a/editors/vscode/src/md.d.ts b/editors/vscode/src/md.d.ts new file mode 100644 index 000000000..3c93bc613 --- /dev/null +++ b/editors/vscode/src/md.d.ts @@ -0,0 +1,6 @@ +// SKILL.md files are imported as text: esbuild bundles them with +// `--loader:.md=text` and vitest.config.ts mirrors that loader. +declare module "*.md" { + const text: string; + export default text; +} diff --git a/editors/vscode/src/vscodeMock.ts b/editors/vscode/src/vscodeMock.ts new file mode 100644 index 000000000..458024695 --- /dev/null +++ b/editors/vscode/src/vscodeMock.ts @@ -0,0 +1,53 @@ +// Test double for the `vscode` module (provided by the extension host at +// runtime, so it is not installed). Test files use it via: +// vi.mock("vscode", async () => (await import("./vscodeMock")).createVscodeMock()); +import { vi } from "vitest"; + +export function createVscodeMock() { + class Uri { + constructor(public fsPath: string) {} + static joinPath = vi.fn(); + } + class ChatRequestTurn { + constructor( + public prompt: string, + public command?: string + ) {} + } + class ChatResponseMarkdownPart { + value: { value: string }; + constructor(value: string) { + this.value = { value }; + } + } + class ChatResponseTurn { + constructor( + public response: ChatResponseMarkdownPart[], + public result: { metadata?: { command: string } }, + public command?: string + ) {} + } + return { + workspace: { + getConfiguration: vi.fn(() => ({ get: vi.fn(() => undefined) })), + workspaceFolders: [{ uri: { fsPath: "/test/workspace" } }] as + | Array<{ uri: { fsPath: string } }> + | undefined, + getWorkspaceFolder: vi.fn(() => undefined), + asRelativePath: vi.fn((uri: Uri) => uri.fsPath), + fs: { readFile: vi.fn(async () => new TextEncoder().encode("attached text")) }, + }, + window: { activeTextEditor: undefined as unknown }, + env: { sessionId: "0123456789abcdef" }, + lm: { selectChatModels: vi.fn(async () => []) }, + chat: { createChatParticipant: vi.fn(() => ({})) }, + LanguageModelChatMessage: { + User: (content: string) => ({ role: "user", content }), + Assistant: (content: string) => ({ role: "assistant", content }), + }, + Uri, + ChatRequestTurn, + ChatResponseTurn, + ChatResponseMarkdownPart, + }; +} diff --git a/editors/vscode/vitest.config.ts b/editors/vscode/vitest.config.ts index ae847ff6d..c759e408a 100644 --- a/editors/vscode/vitest.config.ts +++ b/editors/vscode/vitest.config.ts @@ -1,6 +1,16 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ + // Mirror esbuild's `--loader:.md=text` so the bundled SKILL.md imports + // resolve under test exactly as they do in the build. + plugins: [ + { + name: "md-as-text", + transform(code, id) { + return id.endsWith(".md") ? `export default ${JSON.stringify(code)};` : undefined; + }, + }, + ], test: { include: ["src/**/*.test.ts"], }, diff --git a/internal/bootstrap/vscode_surface_test.go b/internal/bootstrap/vscode_surface_test.go new file mode 100644 index 000000000..2536aa3f3 --- /dev/null +++ b/internal/bootstrap/vscode_surface_test.go @@ -0,0 +1,178 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package bootstrap + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/skill" + "github.com/ActiveMemory/ctx/internal/cli/initialize" + "github.com/ActiveMemory/ctx/internal/testutil/testctx" +) + +// vscodeSurface mirrors editors/vscode/src/ctx-cli-surface.json: every +// ctx invocation and skill the VS Code extension dispatches, recorded by +// the extension's own test suite (editors/vscode/src/commandParity.test.ts; +// refresh with `npx vitest run -u` in editors/vscode). +type vscodeSurface struct { + Skills []string `json:"skills"` + Invocations [][]string `json:"invocations"` +} + +// entryNouns are the commands whose `add` validates required fields +// (provenance, --section, decision and learning structure) inside RunE, +// where parsing alone cannot see them. +var entryNouns = []string{"convention", "decision", "learning", "task"} + +func loadVSCodeSurface(t *testing.T) vscodeSurface { + t.Helper() + path := filepath.Join( + "..", "..", "editors", "vscode", "src", "ctx-cli-surface.json", + ) + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("read %s: %v", path, readErr) + } + var surface vscodeSurface + if jsonErr := json.Unmarshal(data, &surface); jsonErr != nil { + t.Fatalf("parse %s: %v", path, jsonErr) + } + if len(surface.Invocations) == 0 || len(surface.Skills) == 0 { + t.Fatalf("%s lists no invocations or no skills", path) + } + return surface +} + +// resolveInvocation finds argv's command in a fresh ctx command tree and +// parses the rest of argv exactly as cobra does before running it. +// +// Parameters: +// - argv: ctx arguments, without the binary name +// +// Returns: +// - *cobra.Command: The command argv runs +// - []string: Its positional arguments +// - error: Why the CLI would reject argv, if it would +func resolveInvocation(argv []string) (*cobra.Command, []string, error) { + root := Initialize(RootCmd()) + cmd, rest, findErr := root.Find(argv) + switch { + case findErr != nil: + return nil, nil, findErr + case cmd == root: + return nil, nil, fmt.Errorf("no ctx command in %q", argv) + case cmd.Deprecated != "": + return nil, nil, fmt.Errorf("%q is deprecated", cmd.CommandPath()) + case !cmd.Runnable(): + return nil, nil, fmt.Errorf("%q only prints help", cmd.CommandPath()) + } + if parseErr := cmd.ParseFlags(rest); parseErr != nil { + return nil, nil, parseErr + } + args := cmd.Flags().Args() + // Without an Args validator cobra accepts any positional arguments. + // A command group then reads a leftover word as a subcommand it does + // not have (`ctx system stats` prints an unknown-subcommand notice + // and still exits 0); a command whose Use line names no arguments + // silently ignores them. + if cmd.Args == nil && len(args) > 0 { + if cmd.HasSubCommands() { + return nil, nil, fmt.Errorf( + "%q has no subcommand %q", cmd.CommandPath(), args[0], + ) + } + if !strings.Contains(cmd.Use, " ") { + return nil, nil, fmt.Errorf( + "%q takes no arguments, got %q", cmd.CommandPath(), args, + ) + } + } + if argsErr := cmd.ValidateArgs(args); argsErr != nil { + return nil, nil, argsErr + } + if reqErr := cmd.ValidateRequiredFlags(); reqErr != nil { + return nil, nil, reqErr + } + if groupErr := cmd.ValidateFlagGroups(); groupErr != nil { + return nil, nil, groupErr + } + return cmd, args, nil +} + +// TestVSCodeExtensionSurface fails when the VS Code extension dispatches +// a ctx invocation this CLI would reject, so renaming or removing a +// command cannot silently strand an @ctx chat command. +func TestVSCodeExtensionSurface(t *testing.T) { + for _, argv := range loadVSCodeSurface(t).Invocations { + t.Run(strings.Join(argv, " "), func(t *testing.T) { + if _, _, err := resolveInvocation(argv); err != nil { + t.Errorf("ctx %s: %v", strings.Join(argv, " "), err) + } + }) + } +} + +// TestVSCodeExtensionEntryAdds runs the extension's `ctx add` +// invocations in a scratch project, because their required fields are +// validated at run time rather than by flag parsing. +func TestVSCodeExtensionEntryAdds(t *testing.T) { + surface := loadVSCodeSurface(t) + testctx.Declare(t, t.TempDir()) + initCmd := initialize.Cmd() + initCmd.SetArgs([]string{}) + initCmd.SetOut(io.Discard) + initCmd.SetErr(io.Discard) + if initErr := initCmd.Execute(); initErr != nil { + t.Fatalf("init: %v", initErr) + } + + ran := 0 + for _, argv := range surface.Invocations { + if len(argv) < 2 || argv[1] != "add" || + !slices.Contains(entryNouns, argv[0]) { + continue + } + ran++ + t.Run(strings.Join(argv, " "), func(t *testing.T) { + cmd, args, resolveErr := resolveInvocation(argv) + if resolveErr != nil { + t.Fatalf("resolve: %v", resolveErr) + } + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + if runErr := cmd.RunE(cmd, args); runErr != nil { + t.Errorf("ctx %s: %v", strings.Join(argv, " "), runErr) + } + }) + } + if ran == 0 { + t.Fatal("surface lists no entry add invocations") + } +} + +// TestVSCodeExtensionSkills fails when a skill-backed @ctx command names +// a skill that no longer ships. +func TestVSCodeExtensionSkills(t *testing.T) { + names, listErr := skill.List() + if listErr != nil { + t.Fatalf("list skills: %v", listErr) + } + for _, name := range loadVSCodeSurface(t).Skills { + if !slices.Contains(names, name) { + t.Errorf("skill %q is not in internal/assets/claude/skills", name) + } + } +} diff --git a/specs/vscode-skill-backed-participant.md b/specs/vscode-skill-backed-participant.md new file mode 100644 index 000000000..c67119d24 --- /dev/null +++ b/specs/vscode-skill-backed-participant.md @@ -0,0 +1,139 @@ +# VS Code `@ctx` Participant: Skill-Backed, Verified Against the Real CLI + +Issue: https://github.com/ActiveMemory/ctx/issues/127 +Supersedes: https://github.com/ActiveMemory/ctx/pull/128 (closed for rework) + +The `@ctx` chat participant on `main` exposes only CLI wrappers, none of +the skill/workflow layer (#127). PR #128 added the workflow commands +but was closed: a dozen of its commands dispatched to `ctx` subcommands +that do not exist, failures rendered as results, and nothing tested the +dispatched argv against the real binary. This spec lands the participant +on current `main`, with every command traceable to a real `ctx` command +or a shipped skill, and a test that fails when that stops being true. + +## Problem + +1. **`main`'s participant is dead on arrival.** Every invocation passes + `--no-color`, a flag the CLI no longer has, so every command exits 1 + with a usage dump. Beyond that: `recall list`, `add `, + `notify ...`, `system resources|message`, `pause`, `resume`, + `reindex`, `prompt`, `dep`, and `loop ` no longer match the + CLI. +2. **Failures read as results.** `runCtx` resolved on any output, so an + error (or a timed-out partial run) rendered like success. +3. **Windows command injection.** `execFile(..., { shell: true })` + joins arguments unquoted: a multi-word prompt splits into extra + arguments and `&` in a prompt runs a second command. +4. **The workflow layer is missing** (#127), and #128's version of it + re-implemented skills in TypeScript against file formats ctx no longer + uses (`IMPLEMENTATION_PLAN.md`, `- ` bullets in DECISIONS.md), so + those commands were dead too, just silently. +5. **Nothing guards the surface.** Unit tests mock `execFile`; any + argv passes. + +## Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| What "skill-backed" means | `/` sends the canonical `ctx-` SKILL.md, `ctx agent` output, skill-specific read-only ctx output, earlier turns of the same skill, and `#file` attachments to the chat model (`request.model`) | Delegates the workflow to the skill ctx ships instead of a TypeScript imitation that drifts from it. | +| Skill source | Bundled at build time from `internal/assets/claude/skills/` (esbuild `--loader:.md=text`) | Pins skill text to the commit the VSIX is built from; a renamed skill breaks the build. No CLI command prints skill bodies, and none is added for this. | +| Which skills | brainstorm, spec, implement, next, remember, reflect, wrap-up, blog, consolidate | They work from the inputs the participant can supply. The model here cannot run commands or browse the repo, so architecture (the old `/map`), link-check, worktree, and blog-changelog stay with agent integrations. `/audit` and `/verify` have no shipped skill. | +| Command names | Skill name without `ctx-` (`/wrap-up`, not `/wrapup`); CLI nouns singular (`/task`, `/change`, `/permission`, `/decision`, `/learning`) | One obvious mapping each way; matches the command names already on `main`. | +| Model limits | The preamble forbids claiming to have run or changed anything; the model gives the exact `ctx`/`@ctx` command instead | Honest about the participant's reach. | +| History sent to the model | Only earlier skill exchanges | `/pad`, `/notify`, `/add` turns can hold secrets; CLI output never reaches the model. | +| Process execution | No shell; stdin closed; resolve with the exit code; reject on spawn/buffer failure, cancel, timeout | Fixes injection and splitting; a prompting command (`ctx why`, content-less `add`) fails fast; failures cannot pose as results. | +| Rendering | One `runAndRender`: non-zero exit shows "exited with code N" and the CLI output, plus an `/init` hint when `.context/` is missing | Every handler routes through it, so none can render a failure as success. | +| Init gate | Removed | The CLI owns which commands need `.context/` (`AnnotationSkipInit`); a copy in the extension drifts (it blocked `/guide`, `/why`). | +| `/add` defaults | Provenance filled in (`vscode.env.sessionId`, git branch and commit); no `--section` default | The CLI requires provenance; it deliberately refuses a catch-all section (`internal/cli/add/core/build/section.go`). | +| `/notify setup` | Tells the user to run `ctx hook notify setup` in a terminal | The command prompts for a secret webhook URL; it must not go through chat. | +| Natural language | Routes only to read-only commands; CLI targets run without arguments | A keyword match must never complete a task or add a reminder. | +| Background hooks from #128 | Kept: session start/end, reminder bar (read-only `remind list`). Dropped: save watcher, commit popup, dependency popup, heartbeat file, violation recording | `system check-task-completion` reads hook JSON from stdin and exits silently without it, so the save watcher never did anything; the commit popup fired on every HEAD move (checkout, pull); the rest had no ctx consumer. Violation capture is out of scope (see #128 review). | +| Version | `0.10.0`; new CHANGELOG section, `0.9.0` untouched | Review blocker 4. | + +## Command Surface + +CLI-backed (27): `/init`, `/status`, `/agent`, `/drift`, `/recall`, +`/setup`, `/add`, `/decision`, `/learning`, `/load`, `/compact`, +`/sync`, `/task`, `/remind`, `/pad`, `/notify`, `/system`, `/memory`, +`/journal`, `/doctor`, `/config`, `/why`, `/change`, `/guide`, +`/permission`, `/pause`, `/resume`. The argv each one runs is recorded +in `editors/vscode/src/ctx-cli-surface.json`. + +Skill-backed (9): `/brainstorm`, `/spec`, `/implement`, `/next`, +`/remember`, `/reflect`, `/wrap-up`, `/blog`, `/consolidate`. + +Removed from `main`'s surface: `/prompt`, `/dep`, `/reindex` (CLI +commands gone), `/loop` (writes a terminal-agent script), `/site` +(hidden maintainer command). + +## Parity Guard + +Two halves, one artifact: + +1. **vitest** (`editors/vscode/src/commandParity.test.ts`): + - `package.json` commands == `CLI_COMMANDS` ∪ `SKILLS`; + - each skill command bundles the skill it names (`ctx-`, + text byte-equal to the SKILL.md); + - every follow-up and natural-language route targets a real command; + - every command has a scenario; each scenario runs through the real + chat handler with `execFile` mocked, and every `ctx` argv produced + (plus background invocations) is written to + `src/ctx-cli-surface.json` via `toMatchFileSnapshot`. An unreviewed + change to what the extension runs fails the test. +2. **Go** (`internal/bootstrap/vscode_surface_test.go`, in the main + `go test ./...` job), for every argv in that file: + - `Find` in a fresh `Initialize(RootCmd())` tree: unknown command, + deprecated command, or group-only command fails; + - `ParseFlags`, `ValidateArgs`, `ValidateRequiredFlags`, + `ValidateFlagGroups`; a group or no-argument command with leftover + positionals fails (cobra accepts those silently, which is how + `ctx system stats` "worked"); + - `task|decision|learning|convention add` invocations run in a + scratch project, because their required fields are checked in + `RunE`; + - every listed skill must exist in `internal/assets/claude/skills`. + +A CLI rename therefore fails the Go job; an extension change that +alters an argv fails vitest until the snapshot is refreshed and the Go +job re-validates it. + +**Refreshing the snapshot**: from `editors/vscode`, run +`npx vitest run -u`, review `git diff src/ctx-cli-surface.json`, then +`go test ./internal/bootstrap -run TestVSCode`. + +## Review Points from #128 + +| Point | Resolution | +|-------|------------| +| B1: commands call non-existent subcommands | Reconciled against the current tree; guarded by the Go test. | +| B2: `runCtx` renders failures as success | Exit code surfaced; `runAndRender` shows failures; cancel/timeout/spawn reject. | +| B3: no test for the new surface | `commandParity.test.ts` + snapshot + Go test; handler tests cover every branch. | +| B4: no version bump | `0.10.0`, new CHANGELOG section. | +| Reminder bell permanent | Reads `ctx remind list`; bar refresh waits for bootstrap (it used to run before it and never updated at activation). | +| `/pause` `/resume` repurposed | `ctx hook pause` / `ctx hook resume`. | +| Pre-init gate blocks `/guide`, `/why` | Gate removed; the CLI decides. | +| Unreachable palette registrations | None registered. | +| `/verify`, `/wrapup` oversell | `/verify` dropped (no shipped skill, `/doctor` + `/drift` cover it); `/wrap-up` runs the real skill and says it writes nothing. | +| Unkillable git handlers | Git runs only for provenance, with timeout and cancellation. | +| `saveWatcher` cross-root misfire | Watcher removed (it never produced a nudge). | +| Guardrails (terminal capture, violations.json) | Not included; separate proposal if at all. | +| Windows `shell: true` injection | Fixed: no shell. | +| Multi-root `folder[0]` | Active editor's folder, else the first. | + +## Verification + +- `editors/vscode`: `npm ci`, `npm run build`, + `npx tsc --noEmit -p tsconfig.ci.json`, `npm run lint`, `npm test`, + `npx vsce package --no-dependencies`. +- `go test ./internal/bootstrap -run TestVSCode`; full `go test ./...` + shows no new failures; `golangci-lint run` clean. +- The recorded argv were also executed against a freshly built `ctx` in + a scratch project, and the bundled `dist/extension.js` was driven end + to end against it with a stub `vscode` module. + +## Out of Scope + +- Letting the model call tools (`vscode.lm.invokeTool`) so skills can + explore the workspace; that would bring the excluded skills in. +- A Command Palette surface. +- Terminal-command and sensitive-file capture (#128 guardrails). From af6013e6e450ca2cb99473bfbb7f227b3dbe6b5a Mon Sep 17 00:00:00 2001 From: Ersan Bilik Date: Sat, 26 Sep 2026 23:40:20 +0300 Subject: [PATCH 2/2] docs(vscode): describe the reconciled @ctx participant The VS Code page, the Copilot integration section, and the multi-tool recipe still described the unlanded 45-command participant: file-save and git-commit hooks, a heartbeat, Copilot instructions regenerated on every .context/ change, /verify, /map, and /wrapup. Describe what ships: 27 CLI-backed and 9 skill-backed commands, what the skill commands can and cannot do, the read-only natural-language routing, and the reminder bar and session events that remain. Spec: specs/vscode-skill-backed-participant.md Signed-off-by: Ersan Bilik --- docs/home/vscode.md | 90 ++++++++++++++++---------------- docs/operations/integrations.md | 18 +++---- docs/recipes/multi-tool-setup.md | 10 ++-- 3 files changed, 58 insertions(+), 60 deletions(-) diff --git a/docs/home/vscode.md b/docs/home/vscode.md index 5621014bc..87ac1cb9b 100644 --- a/docs/home/vscode.md +++ b/docs/home/vscode.md @@ -69,7 +69,7 @@ Install the extension and the `ctx` binary, then `ctx init` your project: | File | Purpose | |------|---------| | `.context/` | Project-local context directory (created by `ctx init`) | -| `.github/copilot-instructions.md` | Repository instructions Copilot reads natively; regenerated automatically whenever `.context/` files change | +| `.github/copilot-instructions.md` | Repository instructions Copilot reads natively; written by `@ctx /init` (`ctx setup copilot --write`) | The extension itself lives in VS Code's extension storage. No project files are added beyond `.context/` and the Copilot instructions. @@ -79,88 +79,86 @@ files are added beyond `.context/` and the Copilot instructions. Type `@ctx` in the Copilot Chat view to invoke the chat participant. Then either: -- **Use a slash command:** `@ctx /status`, `@ctx /wrapup`, etc. There - are 45 commands; the most common ones live in the [Slash Commands](#slash-commands) +- **Use a slash command:** `@ctx /status`, `@ctx /wrap-up`, etc. There + are 36 commands; the most common ones live in the [Slash Commands](#slash-commands) table below. - **Use natural language:** `@ctx what should I work on?` routes to - `/next`; `@ctx time to wrap up` routes to `/wrapup`. See + `/next`; `@ctx time to wrap up` routes to `/wrap-up`. See [Natural Language](#natural-language). The extension shows context-aware follow-up suggestions after each -command. For example, after `/init` you'll see buttons for "Show -status" or "Generate copilot integration." +command. For example, after `/init` you'll see "Show context status" +or "What should I work on next?" ## What Happens Automatically -The extension registers several VS Code event handlers that mirror -Claude Code's hook system. These run in the background; no user action +The extension does a few things in the background; no user action needed. | Trigger | What fires | |---------|------------| -| **File save** | Task-completion check on non-`.context/` files | -| **Git commit** | Notification prompting to add a Decision, Learning, run `/verify`, or Skip | -| **`.context/` file change** | Refreshes pending reminders and regenerates `.github/copilot-instructions.md` | -| **Dependency file change** | When `go.mod`, `package.json`, etc. change, prompts to refresh the dependency map (`/map`) | -| **Every 5 minutes** | Updates the reminder status-bar item and writes a heartbeat timestamp | -| **Extension activate** | Fires `ctx system session-event --type start` | -| **Extension deactivate** | Fires `ctx system session-event --type end` | +| **Extension activate** | `ctx system session-event --type start` | +| **`/init` succeeds** | The same session start (activation found no `.context/` yet) | +| **`.context/` file change, and every 5 minutes** | Refreshes the reminder status bar from `ctx remind list` (read-only) | +| **Extension deactivate** | `ctx system session-event --type end` | ### Status Bar -A `$(bell) ctx` indicator appears in the status bar when you have -pending reminders. It refreshes every 5 minutes and hides itself when -nothing is due. +A `$(bell) ctx` indicator appears in the status bar while `ctx remind +list` has pending reminders, and hides itself when the list is empty. ## Slash Commands -The extension surfaces 45 commands across six categories. The most -commonly used: +The extension surfaces 36 commands. 27 run the `ctx` CLI and render its +output; a command that exits non-zero is shown as a failure with the +CLI's own message. 9 run a canonical `ctx` skill through the chat model. +The most commonly used: ### Core Context | Command | When to use | |---------|-------------| -| `/init` | Initialize a `.context/` directory with template files | +| `/init` | Initialize a `.context/` directory and the Copilot instructions | | `/status` | Token estimate, file count, what's recent | | `/agent` | Print AI-ready context packet | | `/drift` | Detect stale paths, missing files, dead references | -| `/recall` | Browse and search prior AI session history | -| `/add` | Add a task, decision, learning, or convention | +| `/recall` | Browse prior AI session history (`ctx journal source`) | +| `/add` | Add a task, decision, learning, or convention (provenance is filled in) | +| `/decision`, `/learning` | List recorded decisions or learnings | -### Session Lifecycle +### Skill-Backed Workflows -| Command | When to use | -|---------|-------------| -| `/wrapup` | End-of-session ceremony: status, drift, journal audit | -| `/remember` | Structured readback (trigger: "Do you remember?") from tasks, decisions, learnings, recent journal | -| `/reflect` | Surface items worth persisting as decisions or learnings | -| `/pause` / `/resume` | Save and restore session state for later | - -### Discovery & Planning +Each runs the `ctx-` skill, grounded in `ctx agent` output and any +file you attach with `#file`. The model proposes commands and edits and +gives you the exact `@ctx` or `ctx` command to run; it never claims to +have run anything. | Command | When to use | |---------|-------------| -| `/brainstorm` | Browse and develop ideas from `ideas/` | -| `/spec` | List or scaffold feature specs from templates | -| `/verify` | Run verification (doctor + drift) | -| `/map` | Show dependency map (go.mod, package.json) | - -Full list (with maintenance, audit, metadata, and system commands) is -in [editors/vscode/README.md](https://github.com/ActiveMemory/ctx/blob/main/editors/vscode/README.md#slash-commands). +| `/remember` | Structured readback (trigger: "Do you remember?") | +| `/next` | Pick what to work on next | +| `/brainstorm` | Turn a vague idea into a validated design (multi-turn) | +| `/spec` | Draft a feature spec | +| `/implement` | Work through a plan step by step (attach it with `#file`) | +| `/reflect` | Surface what is worth persisting | +| `/wrap-up` | End-of-session review; proposes entries to persist | +| `/blog`, `/consolidate` | Draft a post; propose merges of overlapping entries | + +Full list is in +[editors/vscode/README.md](https://github.com/ActiveMemory/ctx/blob/main/editors/vscode/README.md#slash-commands). ## Natural Language -Plain English after `@ctx` is routed to the right command: +Plain English after `@ctx` is routed to a read-only command: +- "Do you remember?" → `/remember` - "What should I work on next?" → `/next` -- "Time to wrap up" → `/wrapup` +- "Time to wrap up" → `/wrap-up` - "Show me the status" → `/status` -- "Add a decision" → `/add` - "Check for drift" → `/drift` -If the phrase doesn't match a known pattern, the extension surfaces a -short menu of likely matches. +A keyword match never changes your context. If the phrase doesn't match, +the extension lists its commands. ## Auto-Bootstrap @@ -205,7 +203,7 @@ provide the CLI it shells out to. |---------|-------|-----| | `@ctx` participant doesn't appear in Copilot Chat | Copilot Chat not installed or not signed in | Install [GitHub Copilot Chat](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot-chat) and ensure you're signed in to a Copilot-eligible account | | `@ctx /status` says `ctx` not found | CLI not on PATH and auto-download disabled | Either add `ctx` to PATH (`brew install activememory/tap/ctx` or download from [Releases](https://github.com/ActiveMemory/ctx/releases)), or unset `ctx.executablePath` to let the extension auto-download | -| Status-bar reminder never updates | Heartbeat suppressed or `.context/` doesn't exist | Run `ctx init` from your project root; reload VS Code if the indicator still doesn't appear within 5 minutes | +| Status-bar reminder never appears | `.context/` doesn't exist, or no reminders are pending | Run `ctx init` from your project root, then add one with `@ctx /remind ` | | Commands run but nothing is captured to `.context/` | Workspace folder missing or `.context/` outside the open folder | Make sure your project root (the one with `.context/`) is the workspace root, not a subdirectory of it | ## Verify It Works @@ -219,7 +217,7 @@ Open Copilot Chat and ask: You should see a structured readback citing specific tasks, decisions, and recent session topics. If you instead see "I don't have memory" or "Let me check," something went wrong: confirm the CLI is reachable -(`@ctx /system doctor`) and `.context/` has files in it. +(`@ctx /doctor`) and `.context/` has files in it. ## What's Next diff --git a/docs/operations/integrations.md b/docs/operations/integrations.md index 1928423c8..b94ffad32 100644 --- a/docs/operations/integrations.md +++ b/docs/operations/integrations.md @@ -647,10 +647,9 @@ to regenerate the instructions. ### VS Code Chat Extension (`@ctx`) The **`ctx` VS Code extension** adds a `@ctx` chat participant to -GitHub Copilot Chat, giving you direct access to 45 context commands -from within the editor, plus automatic hooks on file save / git commit / -`.context/` changes / dependency-file edits, and a reminder status-bar -indicator. +GitHub Copilot Chat: 27 commands that run the `ctx` CLI, 9 that run a +canonical `ctx` skill through the chat model (brainstorm, spec, next, +wrap-up, ...), and a reminder status-bar indicator. !!! tip "Full guide: [`ctx` for VS Code](../home/vscode.md)" The home-page guide covers daily workflows, the full command list, @@ -681,17 +680,18 @@ Reload VS Code. Type `@ctx` in Copilot Chat to verify. | File | Purpose | |------|---------| | `.context/` | Project-local context directory (created by `ctx init`, not by the extension) | -| `.github/copilot-instructions.md` | Repository instructions Copilot reads natively; regenerated automatically when `.context/` files change | +| `.github/copilot-instructions.md` | Repository instructions Copilot reads natively; written by `@ctx /init` (`ctx setup copilot --write`) | The extension itself lives in VS Code's extension storage; no project files beyond `.context/` and the Copilot instructions are added. #### How It Works -- **Chat participant:** `@ctx` is registered with VS Code's Chat API; 45 slash commands route to dedicated handlers that shell out to the `ctx` CLI. -- **Automatic hooks:** file save → task-completion check; git commit → decision/learning prompt; `.context/` change → regenerate Copilot instructions; dependency-file change → `/map` prompt. -- **Status-bar reminder:** a `$(bell) ctx` indicator surfaces pending session reminders, refreshing every 5 minutes. -- **Natural language:** plain English after `@ctx` is routed to the nearest matching command. +- **CLI-backed commands** run the `ctx` CLI (no shell) and render its output; a non-zero exit is shown as a failure with the CLI's message. +- **Skill-backed commands** hand the chat model the canonical `ctx-` skill (bundled from `internal/assets/claude/skills/`), `ctx agent` output, and any `#file` attachments. The model proposes commands and edits; it cannot run them. +- **Session events:** activation and deactivation fire `ctx system session-event`. +- **Status-bar reminder:** a `$(bell) ctx` indicator shows while `ctx remind list` has entries, refreshed on `.context/` changes and every 5 minutes. +- **Natural language:** plain English after `@ctx` is routed to a read-only command. - **Auto-bootstrap:** if the `ctx` CLI isn't on PATH, the extension downloads the correct platform binary from GitHub Releases and caches it. #### Configuration diff --git a/docs/recipes/multi-tool-setup.md b/docs/recipes/multi-tool-setup.md index 814229bb8..f17a51c08 100644 --- a/docs/recipes/multi-tool-setup.md +++ b/docs/recipes/multi-tool-setup.md @@ -233,11 +233,11 @@ auto-downloads the `ctx` CLI if it isn't on PATH. See !!! tip "VS Code Is a First-Class Citizen" The extension carries its own runtime. No `ctx setup` step is - needed. It registers a `@ctx` chat participant with 45 slash - commands, automatic hooks (file save, git commit, `.context/` - change, dependency-file edit), and a reminder status-bar - indicator. Unlike embedded harnesses, the extension ships - through its own pipeline to the VS Code Marketplace. + needed. It registers a `@ctx` chat participant with 36 slash + commands (27 CLI-backed, 9 running a canonical `ctx` skill + through the chat model) and a reminder status-bar indicator. + Unlike embedded harnesses, the extension ships through its own + pipeline to the VS Code Marketplace. #### Cursor