diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8738f7f18..68a4f124b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,8 +119,22 @@ jobs: working-directory: editors/vscode run: npm run lint + - name: Set up Go (command-parity oracle) + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build ctx (command-parity oracle) + run: go build -o "${{ github.workspace }}/ctxbin" ./cmd/ctx + - name: Test (vitest) working-directory: editors/vscode + env: + # Ground-truth binary for commandParity.test.ts — built from this + # same commit so dispatched commands are checked against the real + # ctx tree, not a mock. + CTX_BIN: ${{ github.workspace }}/ctxbin run: npm test - name: Package dry-run (vsce) diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md index f3129a835..fdcc4223d 100644 --- a/editors/vscode/CHANGELOG.md +++ b/editors/vscode/CHANGELOG.md @@ -5,6 +5,74 @@ will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [0.10.0] - 2026-07-05 + +### Fixed + +- **Commands now target the real `ctx` CLI.** The participant dispatched + a dozen-plus commands to subcommands that don't exist on the shipped + binary (`ctx tasks`, `ctx complete`, `ctx decisions`, `ctx recall`, + `ctx add`, `ctx notify`, …), so a large part of the surface was dead on + arrival. Every invocation is reconciled to the actual command tree: + `/task`, `/change`, `/decision`, `/learning`, `/permission` (the CLI + registry is singular); `complete`→`task complete`; `recall`→ + `journal source`; `notify`→`hook notify`; `/add `→` add`; + tool-config `hook`→`setup`; `system stats`→`usage`, `system resources`→ + `sysinfo`, `system message`→`hook message`, `check-reminders`→ + `check-reminder`. +- **`runCtx` no longer reports failures as success.** Cancelled or + timed-out runs reject instead of rendering partial output as a clean + result, and the process exit code is surfaced to callers rather than + assuming any output means success. +- **`/pause` and `/resume` pause and resume context hooks** (`ctx hook + pause` / `ctx hook resume`) instead of writing a session-snapshot JSON + while the hooks — which the command name promises to pause — kept firing. +- **The onboarding gate no longer blocks `/guide`, `/why`, `/config`, and + `/hook`** in an uninitialized project; these mirror the CLI's init-exempt + set (the "run /init first" gate previously hid exactly the commands a new + user reaches for). +- **Reminder `$(bell)` clears when the queue is empty.** The status bar now + reads `ctx remind list` ("No reminders.") instead of the provenance-only + `check-reminder` output, which never matched the hide condition and pinned + the bell on permanently. +- **`/worktree` and `/changelog` git calls are cancellable** and time out + after 30s, so a git op blocked on an index lock can't hang the request. +- **`saveWatcher` no longer runs against the wrong workspace root** — it + skips paths starting with `..`, matching its siblings. +- Stopped regenerating the tracked `.github/copilot-instructions.md` on + every `.context/**` change (git churn / write amplification). + +### Changed + +- `/verify` and `/wrapup` descriptions corrected to match actual behavior + (both are read-only: `/verify` runs `ctx doctor` + `drift`; `/wrapup` + summarizes status, drift, and journal and *suggests* — does not persist — + decision/learning entries). + +### Added + +- **Command-parity test** (`commandParity.test.ts`): asserts + `package.json` ↔ dispatcher ↔ the real `ctx` command tree (built from + the same commit), so a future CLI rename can never silently strand a + command again. + +### Removed + +- `/prompt` and `/deps` slash commands — their `ctx prompt` / `ctx dep` + backing commands were removed from the CLI with no replacement. The + command surface is now 43 commands, each mapping to a real `ctx` + command. +- `/system backup` subcommand (no CLI replacement). +- **Unreachable Command-Palette registrations** — `activate()` registered + `ctx.*` commands with no matching `contributes.commands`, so none were + reachable from the palette. Removed pending a deliberate palette design. +- **Violation guardrails** (the terminal-command watcher, the + sensitive-file watcher, and `.context/state/violations.json` + recording). Capturing the user's terminal text — credentials + included — into a file that an MCP server relays into model context + is a design decision that warrants its own review. It will be + re-proposed separately with a design note on the capture surface. + ## [0.9.0] - 2026-03-19 ### Added diff --git a/editors/vscode/README.md b/editors/vscode/README.md index f23883738..cbe9c5e40 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -11,7 +11,7 @@ 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 +Type `@ctx` in the Chat view to access 43 slash commands, automatic context hooks, a reminder status bar, and natural language routing, all powered by the ctx CLI. @@ -57,8 +57,8 @@ The extension auto-downloads the ctx CLI binary if it isn't on your PATH. | `/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 | +| `/pause` | Pause context hooks for this session | +| `/resume` | Resume context hooks | ### Discovery & Planning @@ -68,7 +68,6 @@ The extension auto-downloads the ctx CLI binary if it isn't on your PATH. | `/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 | @@ -92,7 +91,6 @@ The extension auto-downloads the ctx CLI binary if it isn't on your PATH. | `/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 | @@ -204,7 +202,7 @@ The extension is a single-file implementation 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 +- All 43 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 diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 19a160c30..db8a5976c 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", @@ -92,13 +92,17 @@ "description": "Reconcile context with codebase" }, { - "name": "task", - "description": "Task operations: complete, archive, snapshot" + "name": "complete", + "description": "Mark a task complete by number or description" }, { "name": "remind", "description": "Manage session-scoped reminders" }, + { + "name": "tasks", + "description": "Task operations: complete, archive, snapshot" + }, { "name": "pad", "description": "Encrypted scratchpad for sensitive notes" @@ -112,52 +116,68 @@ "description": "System diagnostics and bootstrap" }, { - "name": "memory", - "description": "Memory bridge operations (sync, status, diff, import, publish)" + "name": "wrapup", + "description": "Summarize the session for end-of-session wrap-up (read-only)" }, { - "name": "journal", - "description": "Journal management (site, obsidian)" + "name": "remember", + "description": "Recall project context and present a structured readback" }, { - "name": "doctor", - "description": "Context health diagnostics" + "name": "next", + "description": "Suggest what to work on next" }, { - "name": "config", - "description": "Runtime configuration (switch, status, schema)" + "name": "brainstorm", + "description": "Design before implementation: turn a vague idea into a validated design" }, { - "name": "prompt", - "description": "Prompt templates (list, add, show, rm)" + "name": "reflect", + "description": "Reflect on session progress at a natural breakpoint" }, { - "name": "why", - "description": "Show rationale for context design decisions" + "name": "spec", + "description": "Scaffold a feature spec from the project template" }, { - "name": "change", - "description": "Show recent codebase changes" + "name": "implement", + "description": "Execute a plan step-by-step with verification" }, { - "name": "dep", - "description": "Show project dependencies" + "name": "verify", + "description": "Run verification checks (ctx doctor + drift)" }, { - "name": "guide", - "description": "Quick start guide" + "name": "map", + "description": "Build and maintain architecture maps (ARCHITECTURE.md)" }, { - "name": "permission", - "description": "Permission snapshot and restore" + "name": "blog", + "description": "Generate a blog post draft from project progress" + }, + { + "name": "changelog", + "description": "Generate a themed blog post from commits between releases" + }, + { + "name": "check-links", + "description": "Audit docs for dead links" + }, + { + "name": "journal", + "description": "Journal management (site, obsidian)" + }, + { + "name": "consolidate", + "description": "Consolidate redundant entries in LEARNINGS.md or DECISIONS.md" }, { - "name": "site", - "description": "Documentation site (feed)" + "name": "audit", + "description": "Run a code-level convention audit across the codebase" }, { - "name": "loop", - "description": "Generate autonomous iteration scripts" + "name": "worktree", + "description": "Manage git worktrees for parallel agent development" }, { "name": "pause", @@ -167,13 +187,41 @@ "name": "resume", "description": "Resume context hooks" }, + { + "name": "memory", + "description": "Memory bridge operations (sync, status, diff, import, publish)" + }, + { + "name": "decisions", + "description": "Manage DECISIONS.md architectural decisions" + }, + { + "name": "learnings", + "description": "Manage LEARNINGS.md gotchas and lessons" + }, + { + "name": "config", + "description": "Runtime configuration (switch, status, schema)" + }, + { + "name": "permissions", + "description": "Permission snapshot and restore" + }, + { + "name": "changes", + "description": "Show recent codebase changes" + }, + { + "name": "guide", + "description": "Quick start guide" + }, { "name": "reindex", "description": "Rebuild context file indices" }, { - "name": "diag", - "description": "Diagnose extension issues — times each step to find hangs" + "name": "why", + "description": "Show rationale for context design decisions" } ], "disambiguation": [ diff --git a/editors/vscode/src/commandParity.test.ts b/editors/vscode/src/commandParity.test.ts new file mode 100644 index 000000000..1237b9869 --- /dev/null +++ b/editors/vscode/src/commandParity.test.ts @@ -0,0 +1,165 @@ +/** + * Command-parity guard. + * + * The original chat-participant rewrite shipped ~12 commands that dispatched + * to `ctx` subcommands which do not exist on the real binary (e.g. `ctx tasks` + * instead of `ctx task`). Nothing caught it because the unit tests mock + * `execFile` — any argv "passes". This test closes that gap by checking the + * dispatcher against three sources of truth at once: + * + * 1. package.json `contributes.chatParticipants[].commands` (the manifest) + * 2. the `switch (request.command)` cases (the dispatcher) + * 3. the real `ctx` command tree (the binary) + * + * (1) ↔ (2) is pure text and always runs. (2)/(3): every literal `ctx …` + * invocation in the source must resolve to a real command path in a `ctx` + * binary built from THIS commit. Provide the binary via the `CTX_BIN` + * environment variable (CI builds it: `go build -o ctxbin ./cmd/ctx`); the + * test also tries `ctx` on PATH. If neither resolves, the ground-truth block + * is skipped LOUDLY (never silently passed) so CI can be wired to guarantee it. + */ +import { describe, it, expect } from "vitest"; +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +// vitest runs with the extension package as the working directory. +const SRC = path.resolve("src/extension.ts"); +const PKG = path.resolve("package.json"); + +const source = fs.readFileSync(SRC, "utf8"); + +// ── (1) manifest ↔ (2) dispatcher ──────────────────────────────────────────── + +function manifestCommands(): string[] { + const pkg = JSON.parse(fs.readFileSync(PKG, "utf8")); + return pkg.contributes.chatParticipants[0].commands.map( + (c: { name: string }) => c.name + ); +} + +/** The `case "x":` labels inside `switch (request.command)`, up to `default:`. */ +function dispatcherCommands(): string[] { + const start = source.indexOf("switch (request.command)"); + const end = source.indexOf("default:", start); + if (start < 0 || end < 0) throw new Error("dispatcher switch not found"); + const body = source.slice(start, end); + return [...body.matchAll(/case "([^"]+)":/g)].map((m) => m[1]); +} + +// ── (2)/(3) invocation command paths ↔ the real binary ─────────────────────── + +/** + * Every `ctx` invocation's static command-path prefix. Invocations are the + * literal `runCtx([...])` arrays and the `args = [...]` arrays that feed them. + * We take the leading string-literal tokens (a command path is always literal + * and precedes any flag/variable), stopping at the first flag (`--x`), spread, + * variable, or template — so `["recall", "show", rest]` → `["recall","show"]`. + */ +function invocationPaths(): string[][] { + const bodies: string[] = []; + for (const m of source.matchAll(/runCtx\(\s*\[([\s\S]*?)\]/g)) bodies.push(m[1]); + for (const m of source.matchAll(/\bargs\s*=\s*\[([\s\S]*?)\]/g)) bodies.push(m[1]); + + const paths: string[][] = []; + for (const body of bodies) { + const path: string[] = []; + for (const raw of body.split(",")) { + const t = raw.trim(); + const lit = t.match(/^["']([A-Za-z][A-Za-z0-9-]*)["']$/); // word literal, not a -flag + if (!lit) break; + path.push(lit[1]); + } + if (path.length) paths.push(path); + } + // Dedupe by joined path. + const seen = new Set(); + return paths.filter((p) => { + const k = p.join(" "); + if (seen.has(k)) return false; + seen.add(k); + return true; + }); +} + +function resolveCtx(): string | null { + const candidates = [process.env.CTX_BIN, "ctx"].filter(Boolean) as string[]; + for (const bin of candidates) { + try { + execFileSync(bin, ["--version"], { stdio: "pipe" }); + return bin; + } catch { + /* try next */ + } + } + return null; +} + +/** + * Whether `ctx ` is a real command. Probes the binary directly (so + * HIDDEN commands like `ctx system` are recognized) and requires the command's + * own `Usage:` line to name the full path — a parent with a `RunE` that accepts + * free args would otherwise echo its own help for a bogus subcommand and read + * as a false positive. An unknown command exits non-zero → false. (Memoized.) + */ +const existsCache = new Map(); +function pathExists(ctxBin: string, path: string[]): boolean { + const key = path.join(" "); + const cached = existsCache.get(key); + if (cached !== undefined) return cached; + let ok = false; + try { + const out = execFileSync(ctxBin, [...path, "--help"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + ok = out.includes(`ctx ${key}`); + } catch { + ok = false; // non-zero exit = unknown command + } + existsCache.set(key, ok); + return ok; +} + +/** Returns the shallowest sub-path that isn't a real command, else null. */ +function firstDeadSegment(ctxBin: string, path: string[]): string | null { + for (let i = 1; i <= path.length; i++) { + const sub = path.slice(0, i); + if (!pathExists(ctxBin, sub)) return sub.join(" "); + } + return null; +} + +// ── tests ──────────────────────────────────────────────────────────────────── + +describe("manifest ↔ dispatcher parity", () => { + it("declares exactly the commands it dispatches", () => { + const manifest = manifestCommands().sort(); + const dispatched = dispatcherCommands().sort(); + expect(manifest).toEqual(dispatched); + }); +}); + +describe("dispatcher ↔ real ctx command tree", () => { + const ctxBin = resolveCtx(); + + if (!ctxBin) { + it.skip("SKIPPED: no ctx binary (set CTX_BIN) — ground-truth check did NOT run", () => { + /* Loud skip: CI must provide CTX_BIN so this cannot silently pass. */ + }); + console.warn( + "[commandParity] No ctx binary found (CTX_BIN unset, `ctx` not on PATH). " + + "CLI ground-truth parity was SKIPPED — wire CTX_BIN in CI to enforce it." + ); + return; + } + + it("every dispatched ctx invocation is a real command path", () => { + const dead: string[] = []; + for (const path of invocationPaths()) { + const missing = firstDeadSegment(ctxBin, path); + if (missing) dead.push(`ctx ${path.join(" ")} → no such command: "${missing}"`); + } + expect(dead).toEqual([]); + }); +}); diff --git a/editors/vscode/src/extension.test.ts b/editors/vscode/src/extension.test.ts index 4bd0cd766..ae40da721 100644 --- a/editors/vscode/src/extension.test.ts +++ b/editors/vscode/src/extension.test.ts @@ -25,16 +25,15 @@ import { getCtxPath, getWorkspaceRoot, getPlatformInfo, - handleTask, + handleComplete, handleRemind, + handleTasks, handlePad, handleNotify, handleSystem, } 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. +// Helper: create a fake CancellationToken function fakeToken(cancelled = false) { type Listener = (e: unknown) => unknown; const listeners: Listener[] = []; @@ -237,14 +236,14 @@ function mockRunCtxError(message: string) { ); } -describe("handleTask complete", () => { +describe("handleComplete", () => { 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"); + const result = await handleComplete(stream as never, "", "/test", token); + expect(result.metadata.command).toBe("complete"); expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Usage")); }); @@ -252,8 +251,8 @@ describe("handleTask complete", () => { 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"); + const result = await handleComplete(stream as never, "3", "/test", token); + expect(result.metadata.command).toBe("complete"); expect(stream.progress).toHaveBeenCalledWith("Marking task as completed..."); expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Task 3 marked as done")); }); @@ -262,10 +261,10 @@ describe("handleTask complete", () => { mockRunCtxSuccess("Completed: Fix login bug"); const stream = fakeStream(); const token = fakeToken(); - await handleTask(stream as never, "complete Fix login bug", "/test", token); + await handleComplete(stream as never, "Fix login bug", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["task", "complete", "Fix login bug", "--no-color"], + ["task", "complete", "Fix login bug"], expect.anything(), expect.any(Function) ); @@ -275,7 +274,7 @@ describe("handleTask complete", () => { mockRunCtxError("task not found"); const stream = fakeStream(); const token = fakeToken(); - await handleTask(stream as never, "complete 99", "/test", token); + await handleComplete(stream as never, "99", "/test", token); expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); }); }); @@ -290,7 +289,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "list", "--no-color"], + ["remind", "list"], expect.anything(), expect.any(Function) ); @@ -303,7 +302,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "add Check CI status", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "add", "Check CI status", "--no-color"], + ["remind", "add", "Check CI status"], expect.anything(), expect.any(Function) ); @@ -316,7 +315,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "Check CI status", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "add", "Check CI status", "--no-color"], + ["remind", "add", "Check CI status"], expect.anything(), expect.any(Function) ); @@ -329,7 +328,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "list", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "list", "--no-color"], + ["remind", "list"], expect.anything(), expect.any(Function) ); @@ -342,7 +341,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "dismiss 2", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "dismiss", "2", "--no-color"], + ["remind", "dismiss", "2"], expect.anything(), expect.any(Function) ); @@ -355,7 +354,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "dismiss", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "dismiss", "--all", "--no-color"], + ["remind", "dismiss", "--all"], expect.anything(), expect.any(Function) ); @@ -378,14 +377,14 @@ describe("handleRemind", () => { }); }); -describe("handleTask archive/snapshot", () => { +describe("handleTasks", () => { 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"); + const result = await handleTasks(stream as never, "", "/test", token); + expect(result.metadata.command).toBe("tasks"); expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Usage")); }); @@ -393,10 +392,10 @@ describe("handleTask archive/snapshot", () => { mockRunCtxSuccess("Archived 3 tasks"); const stream = fakeStream(); const token = fakeToken(); - await handleTask(stream as never, "archive", "/test", token); + await handleTasks(stream as never, "archive", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["task", "archive", "--no-color"], + ["task", "archive"], expect.anything(), expect.any(Function) ); @@ -407,10 +406,10 @@ describe("handleTask archive/snapshot", () => { mockRunCtxSuccess("Snapshot created"); const stream = fakeStream(); const token = fakeToken(); - await handleTask(stream as never, "snapshot pre-refactor", "/test", token); + await handleTasks(stream as never, "snapshot pre-refactor", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["task", "snapshot", "pre-refactor", "--no-color"], + ["task", "snapshot", "pre-refactor"], expect.anything(), expect.any(Function) ); @@ -420,10 +419,10 @@ describe("handleTask archive/snapshot", () => { mockRunCtxSuccess("Snapshot created"); const stream = fakeStream(); const token = fakeToken(); - await handleTask(stream as never, "snapshot", "/test", token); + await handleTasks(stream as never, "snapshot", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["task", "snapshot", "--no-color"], + ["task", "snapshot"], expect.anything(), expect.any(Function) ); @@ -433,7 +432,7 @@ describe("handleTask archive/snapshot", () => { mockRunCtxSuccess(""); const stream = fakeStream(); const token = fakeToken(); - await handleTask(stream as never, "archive", "/test", token); + await handleTasks(stream as never, "archive", "/test", token); expect(stream.markdown).toHaveBeenCalledWith("Completed tasks archived."); }); @@ -441,7 +440,7 @@ describe("handleTask archive/snapshot", () => { mockRunCtxError("no tasks file"); const stream = fakeStream(); const token = fakeToken(); - await handleTask(stream as never, "archive", "/test", token); + await handleTasks(stream as never, "archive", "/test", token); expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Error")); }); }); @@ -456,7 +455,7 @@ describe("handlePad", () => { await handlePad(stream as never, "", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "--no-color"], + ["pad"], expect.anything(), expect.any(Function) ); @@ -469,7 +468,7 @@ describe("handlePad", () => { await handlePad(stream as never, "add my secret note", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "add", "my secret note", "--no-color"], + ["pad", "add", "my secret note"], expect.anything(), expect.any(Function) ); @@ -489,7 +488,7 @@ describe("handlePad", () => { await handlePad(stream as never, "show 1", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "show", "1", "--no-color"], + ["pad", "show", "1"], expect.anything(), expect.any(Function) ); @@ -502,7 +501,7 @@ describe("handlePad", () => { await handlePad(stream as never, "rm 2", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "rm", "2", "--no-color"], + ["pad", "rm", "2"], expect.anything(), expect.any(Function) ); @@ -522,7 +521,7 @@ describe("handlePad", () => { await handlePad(stream as never, "edit 1 new text", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "edit", "1", "new", "text", "--no-color"], + ["pad", "edit", "1", "new", "text"], expect.anything(), expect.any(Function) ); @@ -535,7 +534,7 @@ describe("handlePad", () => { await handlePad(stream as never, "mv 1 3", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "mv", "1", "3", "--no-color"], + ["pad", "mv", "1", "3"], expect.anything(), expect.any(Function) ); @@ -576,7 +575,7 @@ describe("handleNotify", () => { await handleNotify(stream as never, "setup", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["notify", "setup", "--no-color"], + ["hook", "notify", "setup"], expect.anything(), expect.any(Function) ); @@ -590,7 +589,7 @@ describe("handleNotify", () => { await handleNotify(stream as never, "test", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["notify", "test", "--no-color"], + ["hook", "notify", "test"], expect.anything(), expect.any(Function) ); @@ -603,7 +602,7 @@ describe("handleNotify", () => { await handleNotify(stream as never, "build done --event build", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["notify", "build", "done", "--event", "build", "--no-color"], + ["hook", "notify", "build", "done", "--event", "build"], expect.anything(), expect.any(Function) ); @@ -652,7 +651,7 @@ describe("handleSystem", () => { await handleSystem(stream as never, "resources", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["system", "resources", "--no-color"], + ["sysinfo"], expect.anything(), expect.any(Function) ); @@ -666,7 +665,7 @@ describe("handleSystem", () => { await handleSystem(stream as never, "bootstrap", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["system", "bootstrap", "--no-color"], + ["system", "bootstrap"], expect.anything(), expect.any(Function) ); @@ -680,7 +679,7 @@ describe("handleSystem", () => { await handleSystem(stream as never, "message list", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["system", "message", "list", "--no-color"], + ["hook", "message", "list"], expect.anything(), expect.any(Function) ); diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index f95508284..e95ab910e 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -20,6 +20,9 @@ let resolvedCtxPath: string | undefined; // Extension context — set during activation let extensionCtx: vscode.ExtensionContext | undefined; +// Status bar item for context reminders +let reminderStatusBar: vscode.StatusBarItem | undefined; + function getCtxPath(): string { if (resolvedCtxPath) { return resolvedCtxPath; @@ -263,11 +266,39 @@ 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 `ctx` invocation. `code` is the process exit code (0 on + * success, non-zero on failure); `null` when the process was terminated + * by a signal before exiting. Callers that must distinguish success from + * failure inspect `code` — a non-empty `stdout`/`stderr` is NOT proof of + * success (an unknown subcommand prints usage to stderr and exits 1). + */ +interface CtxRun { + stdout: string; + stderr: string; + code: number | null; +} + 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,12 +306,10 @@ 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 @@ -296,16 +325,41 @@ function runCtx( 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 }); + 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. + // (execFile sets `killed` on timeout too, alongside SIGTERM.) + if (err.killed || err.signal || token?.isCancellationRequested) { + reject( + new Error( + `\`ctx ${args.join(" ")}\` was cancelled or timed out` + + (err.signal ? ` (${err.signal})` : "") + ) + ); return; } - reject(error); + // Failure to spawn (e.g. ENOENT) surfaces as a string `code` + // and no output — there is no result to show. + if (typeof err.code === "string" || (!stdout && !stderr)) { + reject(error); + return; + } + // Non-zero exit WITH output. Surface the output (some commands, + // e.g. `ctx drift`, use exit 1 as a meaningful signal), but + // report the real exit code so callers can tell failure from + // success instead of assuming any output means it worked. + resolve({ + stdout, + stderr, + code: typeof err.code === "number" ? err.code : 1, + }); return; } - resolve({ stdout, stderr }); + resolve({ stdout, stderr, code: 0 }); } ); disposable = token?.onCancellationRequested(() => { @@ -314,6 +368,13 @@ function runCtx( }); } +/** + * Check if .context/ directory exists in the workspace root. + */ +function hasContextDir(cwd: string): boolean { + return fs.existsSync(path.join(cwd, ".context")); +} + async function handleInit( stream: vscode.ChatResponseStream, cwd: string, @@ -321,8 +382,8 @@ async function handleInit( ): Promise { stream.progress("Initializing .context/ directory..."); try { - const { stdout, stderr } = await runCtx(["init", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["init", "--caller", "vscode"], cwd, token); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } @@ -331,15 +392,15 @@ async function handleInit( // project context automatically. stream.progress("Generating Copilot instructions..."); try { - const setupResult = await runCtx( - ["setup", "copilot", "--write", "--no-color"], + const hookResult = await runCtx( + ["setup", "copilot", "--write"], cwd, token ); - const setupOutput = (setupResult.stdout + setupResult.stderr).trim(); - if (setupOutput) { + const hookOutput = mergeOutput(hookResult.stdout, hookResult.stderr); + if (hookOutput) { stream.markdown( - "\n**Copilot integration:**\n```\n" + setupOutput + "\n```" + "\n**Copilot integration:**\n```\n" + hookOutput + "\n```" ); } else { stream.markdown( @@ -347,10 +408,10 @@ async function handleInit( ); } } catch { - // Non-fatal — init succeeded, setup is a bonus + // Non-fatal — init succeeded, hook is a bonus stream.markdown( "\n> **Note:** Could not generate `.github/copilot-instructions.md`. " + - "Run `@ctx /setup copilot` manually." + "Run `@ctx /hook copilot` manually." ); } @@ -359,6 +420,9 @@ async function handleInit( "`.context/` directory initialized. Run `@ctx /status` to see your project context." ); } + + // Fire session-start since activate() missed it (no .context/ at activation time) + runCtx(["system", "session-event", "--type", "start", "--caller", "vscode"], cwd).catch(() => {}); } catch (err: unknown) { stream.markdown( `**Error:** Failed to initialize context.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` @@ -374,8 +438,8 @@ async function handleStatus( ): Promise { stream.progress("Checking context status..."); try { - const { stdout, stderr } = await runCtx(["status", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["status"], cwd, token); + const output = mergeOutput(stdout, stderr); stream.markdown("```\n" + output + "\n```"); } catch (err: unknown) { stream.markdown( @@ -387,13 +451,19 @@ async function handleStatus( async function handleAgent( stream: vscode.ChatResponseStream, + prompt: string, cwd: string, token: vscode.CancellationToken ): Promise { stream.progress("Generating AI-ready context packet..."); try { - const { stdout, stderr } = await runCtx(["agent", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const args = ["agent"]; + const budgetMatch = prompt.match(/(?:--budget\s+|budget\s+)(\d+)/); + if (budgetMatch) { + args.splice(1, 0, "--budget", budgetMatch[1]); + } + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); stream.markdown(output); } catch (err: unknown) { stream.markdown( @@ -410,8 +480,8 @@ async function handleDrift( ): Promise { stream.progress("Detecting context drift..."); try { - const { stdout, stderr } = await runCtx(["drift", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["drift"], cwd, token); + const output = mergeOutput(stdout, stderr); stream.markdown("```\n" + output + "\n```"); } catch (err: unknown) { stream.markdown( @@ -427,18 +497,45 @@ async function handleRecall( cwd: string, token: vscode.CancellationToken ): Promise { - stream.progress("Searching session history..."); - try { - const args = ["recall", "list", "--no-color"]; - if (prompt.trim()) { - args.push("--query", prompt.trim()); + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + const rest = parts.slice(1).join(" "); + + let args: string[]; + let progressMsg: string; + + // Session history moved from the standalone `ctx recall` command to + // `ctx journal source` (list + inspect raw session files). Lock/unlock/ + // sync now operate on journal entries and are reached via `@ctx /journal`. + switch (subcmd) { + case "show": + if (!rest) { + stream.markdown("**Usage:** `@ctx /recall show `"); + return { metadata: { command: "recall" } }; + } + args = ["journal", "source", "--show", rest]; + progressMsg = "Loading session details..."; + break; + case "list": + default: { + args = ["journal", "source"]; + progressMsg = "Listing sessions..."; + const limitMatch = prompt.match(/(?:--limit\s+|limit\s+)(\d+)/); + if (limitMatch) { + args.push("--limit", limitMatch[1]); + } + break; } + } + + stream.progress(progressMsg); + try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { - stream.markdown("No session history found."); + stream.markdown(subcmd === "list" || !subcmd ? "No session history found." : "No output."); } } catch (err: unknown) { stream.markdown( @@ -448,7 +545,7 @@ async function handleRecall( return { metadata: { command: "recall" } }; } -async function handleSetup( +async function handleHook( stream: vscode.ChatResponseStream, prompt: string, cwd: string, @@ -462,7 +559,6 @@ async function handleSetup( if (!preview) { args.push("--write"); } - args.push("--no-color"); stream.progress( preview @@ -471,7 +567,7 @@ async function handleSetup( ); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -486,7 +582,7 @@ async function handleSetup( `**Error:** Failed to generate hook.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); } - return { metadata: { command: "setup" } }; + return { metadata: { command: "hook" } }; } async function handleAdd( @@ -502,7 +598,7 @@ async function handleAdd( if (!type) { stream.markdown( "**Usage:** `@ctx /add `\n\n" + - "Types: `task`, `decision`, `learning`\n\n" + + "Types: `task`, `decision`, `learning`, `convention`\n\n" + "Example: `@ctx /add task Implement user authentication`" ); return { metadata: { command: "add" } }; @@ -510,12 +606,14 @@ async function handleAdd( stream.progress(`Adding ${type}...`); try { - const args = ["add", type]; + // The generic `ctx add ` was split into per-artifact + // subcommands: `ctx task add`, `ctx decision add`, etc. + const args = [type, "add"]; if (content) { args.push(content); } const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -536,8 +634,8 @@ async function handleLoad( ): Promise { stream.progress("Loading assembled context..."); try { - const { stdout, stderr } = await runCtx(["load", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["load"], cwd, token); + const output = mergeOutput(stdout, stderr); stream.markdown(output); } catch (err: unknown) { stream.markdown( @@ -554,8 +652,8 @@ async function handleCompact( ): Promise { stream.progress("Compacting context..."); try { - const { stdout, stderr } = await runCtx(["compact", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["compact"], cwd, token); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -576,8 +674,8 @@ async function handleSync( ): Promise { stream.progress("Syncing context with codebase..."); try { - const { stdout, stderr } = await runCtx(["sync", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["sync"], cwd, token); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -591,82 +689,40 @@ async function handleSync( return { metadata: { command: "sync" } }; } -async function handleTask( +async function handleComplete( 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 "complete": { - const taskRef = rest.trim(); - if (!taskRef) { - stream.markdown( - "**Usage:** `@ctx /task complete `\n\n" + - "Example: `@ctx /task complete 3` or " + - "`@ctx /task complete Fix login bug`" - ); - return { metadata: { command: "task" } }; - } - args = ["task", "complete", taskRef]; - progressMsg = "Marking task as completed..."; - 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( - "**Usage:** `@ctx /task `\n\n" + - "| Subcommand | Description |\n" + - "|------------|-------------|\n" + - "| `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`" - ); - return { metadata: { command: "task" } }; + const taskRef = prompt.trim(); + if (!taskRef) { + stream.markdown( + "**Usage:** `@ctx /complete `\n\n" + + "Example: `@ctx /complete 3` or `@ctx /complete Fix login bug`" + ); + return { metadata: { command: "complete" } }; } - args.push("--no-color"); - stream.progress(progressMsg); + stream.progress("Marking task as completed..."); try { - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx( + ["task", "complete", taskRef], + cwd, + token + ); + const output = mergeOutput(stdout, stderr); 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; - } + stream.markdown(`Task **${taskRef}** marked as completed.`); } } catch (err: unknown) { stream.markdown( - `**Error:** Failed to ${subcmd} task.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + `**Error:** Failed to complete task.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); } - return { metadata: { command: "task" } }; + return { metadata: { command: "complete" } }; } async function handleRemind( @@ -708,12 +764,11 @@ async function handleRemind( } break; } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -727,6 +782,61 @@ async function handleRemind( return { metadata: { command: "remind" } }; } +async function handleTasks( + 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 "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( + "**Usage:** `@ctx /tasks `\n\n" + + "| Subcommand | Description |\n" + + "|------------|-------------|\n" + + "| `archive` | Move completed tasks to archive |\n" + + "| `snapshot [name]` | Create point-in-time snapshot |\n\n" + + "Example: `@ctx /tasks archive` or `@ctx /tasks snapshot pre-refactor`" + ); + return { metadata: { command: "tasks" } }; + } + + stream.progress(progressMsg); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown( + subcmd === "archive" + ? "Completed tasks archived." + : "Task snapshot created." + ); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to ${subcmd} tasks.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "tasks" } }; +} + async function handlePad( stream: vscode.ChatResponseStream, prompt: string, @@ -773,18 +883,37 @@ async function handlePad( args = ["pad", "mv", ...parts.slice(1)]; progressMsg = "Moving scratchpad entry..."; break; + case "resolve": + args = ["pad", "resolve"]; + progressMsg = "Resolving scratchpad conflicts..."; + break; + case "import": + if (!rest) { + stream.markdown("**Usage:** `@ctx /pad import `"); + return { metadata: { command: "pad" } }; + } + args = ["pad", "import", rest]; + progressMsg = "Importing scratchpad archive..."; + break; + case "export": + args = rest ? ["pad", "export", rest] : ["pad", "export"]; + progressMsg = "Exporting scratchpad..."; + break; + case "merge": + args = rest ? ["pad", "merge", rest] : ["pad", "merge"]; + progressMsg = "Merging scratchpads..."; + break; default: // No subcommand or unknown — list all entries args = ["pad"]; progressMsg = "Listing scratchpad..."; break; } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -812,11 +941,11 @@ async function handleNotify( switch (subcmd) { case "setup": - args = ["notify", "setup"]; + args = ["hook", "notify", "setup"]; progressMsg = "Setting up webhook..."; break; case "test": - args = ["notify", "test"]; + args = ["hook", "notify", "test"]; progressMsg = "Sending test notification..."; break; default: { @@ -833,17 +962,16 @@ async function handleNotify( ); return { metadata: { command: "notify" } }; } - args = ["notify", ...parts]; + args = ["hook", "notify", ...parts]; progressMsg = "Sending notification..."; break; } } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -877,35 +1005,50 @@ async function handleSystem( switch (subcmd) { case "resources": - args = ["system", "resources"]; + args = ["sysinfo"]; progressMsg = "Checking system resources..."; break; case "bootstrap": args = ["system", "bootstrap"]; progressMsg = "Running bootstrap..."; break; + case "doctor": + args = ["doctor"]; + progressMsg = "Running diagnostics..."; + break; case "message": - args = ["system", "message", ...parts.slice(1)]; + args = ["hook", "message", ...parts.slice(1)]; + if (parts.length < 2 || !["list", "show", "edit", "reset"].includes(parts[1]?.toLowerCase())) { + args = ["hook", "message", "list"]; + } progressMsg = "Managing hook messages..."; break; + case "stats": + args = ["usage"]; + progressMsg = "Loading session usage..."; + break; default: stream.markdown( "**Usage:** `@ctx /system `\n\n" + "| Subcommand | Description |\n" + "|------------|-------------|\n" + "| `resources` | Show system resource usage |\n" + + "| `doctor` | Diagnose context health |\n" + "| `bootstrap` | Print context location for AI agents |\n" + - "| `message list|show|edit|reset` | Manage hook messages |\n\n" + - "Example: `@ctx /system resources` or `@ctx /system bootstrap`" + "| `stats` | Show session token usage |\n" + + "| `message list` | List hook message templates |\n" + + "| `message show ` | Show a hook message |\n" + + "| `message edit ` | Edit a hook message |\n" + + "| `message reset ` | Reset a hook message |\n\n" + + "Example: `@ctx /system resources` or `@ctx /system message list`" ); return { metadata: { command: "system" } }; } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -919,364 +1062,898 @@ async function handleSystem( return { metadata: { command: "system" } }; } -async function handleMemory( +async function handleWrapup( stream: vscode.ChatResponseStream, - prompt: string, cwd: string, token: vscode.CancellationToken ): Promise { - const parts = prompt.trim().split(/\s+/); - const subcmd = parts[0]?.toLowerCase(); + stream.progress("Generating session wrap-up..."); + try { + // Gather status + drift in parallel for a comprehensive wrap-up + const [statusResult, driftResult] = await Promise.all([ + runCtx(["status"], cwd, token), + runCtx(["drift"], cwd, token), + ]); + const statusOutput = mergeOutput(statusResult.stdout, statusResult.stderr); + const driftOutput = mergeOutput(driftResult.stdout, driftResult.stderr); + + stream.markdown("## Session Wrap-up\n\n"); + stream.markdown("### Context Status\n```\n" + statusOutput + "\n```\n\n"); + stream.markdown("### Drift Check\n```\n" + driftOutput + "\n```\n\n"); + stream.markdown( + "**Before closing:** Review any open tasks in `.context/TASKS.md`. " + + "Record decisions or learnings with `@ctx /add decision ...` or `@ctx /add learning ...`.\n" + ); - let args: string[]; - let progressMsg: string; + // 2.15: Journal audit + try { + const stateDir = path.join(cwd, ".context", "state"); + if (fs.existsSync(stateDir)) { + const journalFiles = fs.readdirSync(stateDir).filter((f) => f.includes("journal") || f.includes("event")); + if (journalFiles.length > 0) { + const latest = journalFiles.sort().slice(-1)[0]; + const content = fs.readFileSync(path.join(stateDir, latest), "utf-8"); + const lines = content.split("\n").filter((l) => l.trim()); + stream.markdown(`\n### Journal\n${lines.length} entries in \`${latest}\`. `); + const today = new Date().toISOString().split("T")[0]; + if (!content.includes(today)) { + stream.markdown("**No entries today.** Consider logging your work.\n"); + } else { + stream.markdown("Today's entries present.\n"); + } + } + } + } catch { /* non-fatal */ } - 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"); + // 2.18: Memory drift + try { + const memDir = path.join(cwd, ".context", "memory"); + if (fs.existsSync(memDir)) { + const memFiles = fs.readdirSync(memDir).filter((f) => f.endsWith(".md")); + if (memFiles.length > 0) { + const contextFiles = ["DECISIONS.md", "LEARNINGS.md", "CONVENTIONS.md", "TASKS.md"]; + const drifts: string[] = []; + for (const memFile of memFiles) { + const memStat = fs.statSync(path.join(memDir, memFile)); + for (const ctxFile of contextFiles) { + const ctxPath = path.join(cwd, ".context", ctxFile); + if (fs.existsSync(ctxPath)) { + const ctxStat = fs.statSync(ctxPath); + if (memStat.mtimeMs < ctxStat.mtimeMs - 86400000) { + drifts.push(`\`memory/${memFile}\` older than \`${ctxFile}\``); + } + } + } + } + if (drifts.length > 0) { + stream.markdown("\n### Memory Drift\n" + drifts.map((d) => `- ${d}`).join("\n") + "\n"); + } + } + } + } catch { /* non-fatal */ } - 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.`); - } + // Record session end + runCtx(["system", "session-event", "--type", "end", "--caller", "vscode"], cwd).catch(() => {}); } catch (err: unknown) { stream.markdown( - `**Error:** Failed to run memory ${subcmd}.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + `**Error:** Wrap-up failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); } - return { metadata: { command: "memory" } }; + return { metadata: { command: "wrapup" } }; } -async function handleJournal( +async function handleRemember( 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); + stream.progress("Loading recent sessions..."); try { + const args = ["journal", "source"]; + const limitMatch = prompt.match(/(?:--limit\s+|limit\s+)(\d+)/); + args.push("--limit", limitMatch ? limitMatch[1] : "3"); const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { - stream.markdown("```\n" + output + "\n```"); + stream.markdown("## Recent Sessions\n\n```\n" + output + "\n```"); } else { - stream.markdown(`Journal ${subcmd} completed.`); + stream.markdown("No recent sessions found. Start working and sessions will be recorded."); } } catch (err: unknown) { stream.markdown( - `**Error:** Failed to run journal ${subcmd}.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + `**Error:** Failed to load sessions.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); } - return { metadata: { command: "journal" } }; + return { metadata: { command: "remember" } }; } -async function handleDoctor( +async function handleNext( stream: vscode.ChatResponseStream, cwd: string, - token: vscode.CancellationToken + _token: vscode.CancellationToken ): Promise { - stream.progress("Running context health diagnostics..."); + stream.progress("Finding next task..."); try { - const { stdout, stderr } = await runCtx(["doctor", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); + const tasksPath = path.join(cwd, ".context", "TASKS.md"); + if (!fs.existsSync(tasksPath)) { + stream.markdown("No `.context/TASKS.md` found. Add tasks with `@ctx /add task ...`."); + return { metadata: { command: "next" } }; + } + const content = fs.readFileSync(tasksPath, "utf-8"); + const lines = content.split("\n"); + const openTasks = lines.filter((l) => /^\s*-\s*\[ \]/.test(l)); + if (openTasks.length === 0) { + stream.markdown("All tasks completed! Add new tasks with `@ctx /add task ...`."); } else { - stream.markdown("Context health check passed."); + stream.markdown("## Next Task\n\n" + openTasks[0].trim() + "\n"); + if (openTasks.length > 1) { + stream.markdown( + `\n*${openTasks.length - 1} more open task(s) remaining.*` + ); + } } } catch (err: unknown) { stream.markdown( - `**Error:** Failed to run doctor.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + `**Error:** Failed to read tasks.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); } - return { metadata: { command: "doctor" } }; + return { metadata: { command: "next" } }; } -async function handleConfig( +async function handleBrainstorm( stream: vscode.ChatResponseStream, prompt: string, cwd: string, - token: vscode.CancellationToken + _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.progress("Loading ideas..."); + try { + const ideasDir = path.join(cwd, "ideas"); + if (!fs.existsSync(ideasDir)) { 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`" + "No `ideas/` directory found. Run `@ctx /init` first, then add ideas to `ideas/README.md`." ); - return { metadata: { command: "config" } }; - } - 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```"); + return { metadata: { command: "brainstorm" } }; + } + const readmePath = path.join(ideasDir, "README.md"); + if (fs.existsSync(readmePath)) { + const content = fs.readFileSync(readmePath, "utf-8").trim(); + stream.markdown("## Current Ideas\n\n" + content + "\n"); } else { - stream.markdown(`Config ${subcmd} completed.`); + stream.markdown("Ideas directory exists but `ideas/README.md` is empty.\n"); } - } catch (err: unknown) { - stream.markdown( - `**Error:** Failed to run config ${subcmd}.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` - ); + // List any other files in ideas/ + const files = fs.readdirSync(ideasDir).filter((f) => f !== "README.md" && f.endsWith(".md")); + if (files.length > 0) { + stream.markdown( + "\n### Idea Files\n" + files.map((f) => `- \`ideas/${f}\``).join("\n") + "\n" + ); + } + if (prompt.trim()) { + stream.markdown( + "\n---\n\nTo develop **" + prompt.trim() + "** into a spec, create `specs/" + + prompt.trim().toLowerCase().replace(/\s+/g, "-") + ".md` with your design." + ); + } else { + stream.markdown( + "\n---\nTo develop an idea into a spec, run `@ctx /brainstorm `." + ); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to load ideas.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); } - return { metadata: { command: "config" } }; + return { metadata: { command: "brainstorm" } }; } -async function handlePrompt( +async function handleReflect( 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(" "); + stream.progress("Reflecting on session..."); + try { + const [statusResult, driftResult] = await Promise.all([ + runCtx(["status"], cwd, token), + runCtx(["drift"], cwd, token), + ]); + const statusOutput = mergeOutput(statusResult.stdout, statusResult.stderr); + const driftOutput = mergeOutput(driftResult.stdout, driftResult.stderr); + + stream.markdown("## Session Reflection\n\n"); + stream.markdown("### Current State\n```\n" + statusOutput + "\n```\n\n"); + if (driftOutput) { + stream.markdown("### Drift Detected\n```\n" + driftOutput + "\n```\n\n"); + } + stream.markdown( + "### Worth Persisting?\n\n" + + "Consider what happened this session:\n" + + "- **Decision?** Did you make a design choice? → `@ctx /add decision ...`\n" + + "- **Learning?** Hit a gotcha or discovered something? → `@ctx /add learning ...`\n" + + "- **Convention?** Established a pattern? → `@ctx /add convention ...`\n" + + "- **Task?** Identified work for later? → `@ctx /add task ...`\n" + ); - let args: string[]; - let progressMsg: string; + // 2.15: Journal audit — check journal completeness + try { + const stateDir = path.join(cwd, ".context", "state"); + if (fs.existsSync(stateDir)) { + const journalFiles = fs.readdirSync(stateDir).filter((f) => f.includes("journal") || f.includes("event")); + if (journalFiles.length > 0) { + const latest = journalFiles.sort().slice(-1)[0]; + const content = fs.readFileSync(path.join(stateDir, latest), "utf-8"); + const lines = content.split("\n").filter((l) => l.trim()); + stream.markdown(`\n### Journal\n${lines.length} entries in \`${latest}\`. `); + const today = new Date().toISOString().split("T")[0]; + if (!content.includes(today)) { + stream.markdown("**No entries today.** Consider logging your work.\n"); + } else { + stream.markdown("Today's entries present.\n"); + } + } + } + } catch { /* non-fatal */ } - 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" } }; + // 2.18: Memory drift — compare memory with context files + try { + const memDir = path.join(cwd, ".context", "memory"); + if (fs.existsSync(memDir)) { + const memFiles = fs.readdirSync(memDir).filter((f) => f.endsWith(".md")); + if (memFiles.length > 0) { + const contextFiles = ["DECISIONS.md", "LEARNINGS.md", "CONVENTIONS.md", "TASKS.md"]; + const drifts: string[] = []; + for (const memFile of memFiles) { + const memStat = fs.statSync(path.join(memDir, memFile)); + for (const ctxFile of contextFiles) { + const ctxPath = path.join(cwd, ".context", ctxFile); + if (fs.existsSync(ctxPath)) { + const ctxStat = fs.statSync(ctxPath); + // Memory older than context by 24+ hours = potentially stale + if (memStat.mtimeMs < ctxStat.mtimeMs - 86400000) { + drifts.push(`\`memory/${memFile}\` older than \`${ctxFile}\``); + } + } + } + } + if (drifts.length > 0) { + stream.markdown("\n### Memory Drift\n" + drifts.map((d) => `- ${d}`).join("\n") + "\n"); + } + } + } + } catch { /* non-fatal */ } + } catch (err: unknown) { + stream.markdown( + `**Error:** Reflection failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); } - args.push("--no-color"); + return { metadata: { command: "reflect" } }; +} - stream.progress(progressMsg); +async function handleSpec( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Loading specs..."); try { - const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); + const specsDir = path.join(cwd, "specs"); + const tplDir = path.join(specsDir, "tpl"); + if (!prompt.trim()) { + const specs = fs.existsSync(specsDir) ? fs.readdirSync(specsDir).filter((f) => f.endsWith(".md")) : []; + const templates = fs.existsSync(tplDir) ? fs.readdirSync(tplDir).filter((f) => f.endsWith(".md")) : []; + stream.markdown("## Specs\n\n"); + if (specs.length) { + stream.markdown("### Existing\n" + specs.map((f) => `- \`specs/${f}\``).join("\n") + "\n\n"); + } + if (templates.length) { + stream.markdown("### Templates\n" + templates.map((f) => `- \`specs/tpl/${f}\``).join("\n") + "\n\nScaffold: `@ctx /spec `\n"); + } else { + stream.markdown("No templates in `specs/tpl/`. Create one to enable scaffolding.\n"); + } + } else { + const name = prompt.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""); + const target = path.join(specsDir, `${name}.md`); + if (fs.existsSync(target)) { + const content = fs.readFileSync(target, "utf-8"); + stream.markdown(`\`specs/${name}.md\` exists:\n\n\`\`\`markdown\n${content}\n\`\`\``); + } else { + const templates = fs.existsSync(tplDir) ? fs.readdirSync(tplDir).filter((f) => f.endsWith(".md")) : []; + let content: string; + if (templates.length > 0) { + content = fs.readFileSync(path.join(tplDir, templates[0]), "utf-8") + .replace(/\{\{name\}\}/gi, name) + .replace(/\{\{title\}\}/gi, prompt.trim()); + } else { + content = `# ${prompt.trim()}\n\n## Problem\n\n## Proposal\n\n## Implementation\n\n## Verification\n`; + } + if (!fs.existsSync(specsDir)) { fs.mkdirSync(specsDir, { recursive: true }); } + fs.writeFileSync(target, content, "utf-8"); + stream.markdown(`Created \`specs/${name}.md\`.\n\n\`\`\`markdown\n${content}\n\`\`\``); + } + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "spec" } }; +} + +async function handleImplement( + stream: vscode.ChatResponseStream, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Loading implementation plan..."); + try { + const planPath = path.join(cwd, "IMPLEMENTATION_PLAN.md"); + if (!fs.existsSync(planPath)) { + stream.markdown("No `IMPLEMENTATION_PLAN.md` found in project root."); + return { metadata: { command: "implement" } }; + } + const content = fs.readFileSync(planPath, "utf-8"); + const lines = content.split("\n"); + const done = lines.filter((l) => /^\s*-\s*\[x\]/i.test(l)).length; + const open = lines.filter((l) => /^\s*-\s*\[ \]/.test(l)).length; + const total = done + open; + if (total > 0) { + stream.markdown(`## Implementation Plan (${done}/${total} steps done)\n\n`); + const nextStep = lines.find((l) => /^\s*-\s*\[ \]/.test(l)); + if (nextStep) { + stream.markdown("**Next step:** " + nextStep.replace(/^\s*-\s*\[ \]\s*/, "").trim() + "\n\n---\n\n"); + } + } else { + stream.markdown("## Implementation Plan\n\n"); + } + stream.markdown(content); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "implement" } }; +} + +async function handleVerify( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Running verification checks..."); + try { + const results: string[] = []; + try { + const { stdout, stderr } = await runCtx(["doctor"], cwd, token); + results.push("### Context Health\n```\n" + mergeOutput(stdout, stderr) + "\n```"); + } catch (err: unknown) { + results.push("### Context Health\n```\nFailed: " + (err instanceof Error ? err.message : String(err)) + "\n```"); + } + try { + const { stdout, stderr } = await runCtx(["drift"], cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { results.push("### Drift\n```\n" + output + "\n```"); } + } catch { /* non-fatal */ } + stream.markdown("## Verification Report\n\n" + results.join("\n\n")); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "verify" } }; +} + +async function handleMap( + stream: vscode.ChatResponseStream, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Mapping dependencies..."); + try { + const results: string[] = []; + const gomodPath = path.join(cwd, "go.mod"); + if (fs.existsSync(gomodPath)) { + const content = fs.readFileSync(gomodPath, "utf-8"); + const moduleLine = content.match(/^module\s+(.+)$/m); + const requires = content.match(/require\s*\(([\s\S]*?)\)/); + results.push("### Go Module: " + (moduleLine ? moduleLine[1] : "unknown")); + if (requires) { + const deps = requires[1].trim().split("\n").filter((l) => l.trim() && !l.trim().startsWith("//")); + results.push(`${deps.length} dependencies:\n\`\`\`\n${deps.map((d) => d.trim()).join("\n")}\n\`\`\``); + } + } + const pkgPath = path.join(cwd, "package.json"); + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); + const deps = Object.keys(pkg.dependencies || {}); + const devDeps = Object.keys(pkg.devDependencies || {}); + results.push("### Node Package: " + (pkg.name || "unknown")); + if (deps.length) { results.push(`Dependencies: ${deps.join(", ")}`); } + if (devDeps.length) { results.push(`Dev: ${devDeps.join(", ")}`); } + } + if (results.length === 0) { + stream.markdown("No `go.mod` or `package.json` found."); } else { - stream.markdown(`Prompt ${subcmd} completed.`); + stream.markdown("## Dependency Map\n\n" + results.join("\n\n")); } } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "map" } }; +} + +async function handleBlog( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Drafting blog post..."); + try { + const sections: string[] = []; + const decisionsPath = path.join(cwd, ".context", "DECISIONS.md"); + const learningsPath = path.join(cwd, ".context", "LEARNINGS.md"); + if (fs.existsSync(decisionsPath)) { + const entries = fs.readFileSync(decisionsPath, "utf-8").split("\n").filter((l) => l.startsWith("- ")); + if (entries.length) { sections.push("## Key Decisions\n\n" + entries.slice(-5).join("\n")); } + } + if (fs.existsSync(learningsPath)) { + const entries = fs.readFileSync(learningsPath, "utf-8").split("\n").filter((l) => l.startsWith("- ")); + if (entries.length) { sections.push("## Lessons Learned\n\n" + entries.slice(-5).join("\n")); } + } + const title = prompt.trim() || "Untitled Post"; + const date = new Date().toISOString().split("T")[0]; stream.markdown( - `**Error:** Failed to run prompt ${subcmd}.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + `# Blog Draft: ${title}\n\n*Date: ${date}*\n\n` + + (sections.length ? sections.join("\n\n") : "No decisions or learnings to draw from.") + + "\n\n---\n*Edit and refine this draft, then save to `docs/blog/`.*" ); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); } - return { metadata: { command: "prompt" } }; + return { metadata: { command: "blog" } }; } -async function handleWhy( +/** + * Run `git` with a timeout and cancellation, mirroring runCtx. Rejects on + * non-zero exit, timeout, or cancel — so a git op blocked on an index lock + * can't hang the request forever and Cancel actually kills the child. + */ +function execGit( + args: string[], + cwd: string, + token?: vscode.CancellationToken +): Promise { + return new Promise((resolve, reject) => { + if (token?.isCancellationRequested) { + reject(new Error("Cancelled")); + return; + } + 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, stderr) => { + if (!disposed) { + disposed = true; + disposable?.dispose(); + } + if (error) { + const err = error as NodeJS.ErrnoException & { + killed?: boolean; + signal?: NodeJS.Signals | null; + }; + if (err.killed || err.signal || token?.isCancellationRequested) { + reject( + new Error(`\`git ${args.join(" ")}\` was cancelled or timed out`) + ); + return; + } + reject(error); + return; + } + resolve(stdout + stderr); + } + ); + disposable = token?.onCancellationRequested(() => child.kill()); + }); +} + +async function handleChangelog( 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"); + stream.progress("Generating changelog..."); + try { + const result = await execGit( + ["log", "--oneline", "--no-decorate", "-20"], + cwd, + token + ); + if (result.trim()) { + stream.markdown("## Recent Commits\n\n```\n" + result.trim() + "\n```\n\n" + + "Use these to draft release notes or a changelog blog post."); + } else { + stream.markdown("No commits found."); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "changelog" } }; +} + +async function handleCheckLinks( + stream: vscode.ChatResponseStream, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Checking links in context files..."); + try { + const contextDir = path.join(cwd, ".context"); + if (!fs.existsSync(contextDir)) { + stream.markdown("No `.context/` directory found."); + return { metadata: { command: "check-links" } }; + } + const files = fs.readdirSync(contextDir).filter((f) => f.endsWith(".md")); + const broken: string[] = []; + let total = 0; + for (const file of files) { + const content = fs.readFileSync(path.join(contextDir, file), "utf-8"); + const linkRegex = /\[([^\]]*)\]\(([^)]+)\)/g; + let m; + while ((m = linkRegex.exec(content)) !== null) { + const target = m[2]; + if (target.startsWith("http://") || target.startsWith("https://")) { continue; } + total++; + const resolved = path.resolve(contextDir, target); + if (!fs.existsSync(resolved)) { + broken.push(`- \`${file}\`: [${m[1]}](${target}) \u2192 not found`); + } + } + } + stream.markdown(`## Link Check\n\nChecked ${total} local links in ${files.length} context files.\n\n`); + if (broken.length) { + stream.markdown("### Broken Links\n" + broken.join("\n") + "\n"); + } else { + stream.markdown("All local links are valid.\n"); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "check-links" } }; +} - stream.progress("Looking up design rationale..."); +async function handleJournal( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Checking journal..."); try { + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + const args = ["journal"]; + let progressOverride: string | undefined; + if (subcmd === "site") { + args.push("site", ...parts.slice(1)); + progressOverride = "Exporting journal to static site..."; + } else if (subcmd === "obsidian") { + args.push("obsidian", ...parts.slice(1)); + progressOverride = "Exporting journal to Obsidian..."; + } else if (prompt.trim()) { + args.push(...parts); + } + if (progressOverride) { stream.progress(progressOverride); } const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); - if (output) { - stream.markdown("```\n" + output + "\n```"); + const output = mergeOutput(stdout, stderr); + if (output) { stream.markdown("```\n" + output + "\n```"); } + else { stream.markdown("No journal output."); } + } catch { + try { + const stateDir = path.join(cwd, ".context", "state"); + if (fs.existsSync(stateDir)) { + const files = fs.readdirSync(stateDir).filter((f) => f.includes("journal") || f.includes("event")); + if (files.length) { + stream.markdown("## Journal Entries\n\n"); + for (const f of files.slice(-3)) { + try { + const content = fs.readFileSync(path.join(stateDir, f), "utf-8").trim(); + const preview = content.split("\n").slice(0, 10).join("\n"); + stream.markdown(`### ${f}\n\`\`\`\n${preview}\n\`\`\`\n\n`); + } catch { /* skip unreadable */ } + } + } else { + stream.markdown("No journal or event log files found in `.context/state/`."); + } + } else { + stream.markdown("No `.context/state/` directory found."); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + } + return { metadata: { command: "journal" } }; +} + +async function handleConsolidate( + stream: vscode.ChatResponseStream, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Scanning for overlapping entries..."); + try { + const contextDir = path.join(cwd, ".context"); + const targetFiles = ["DECISIONS.md", "LEARNINGS.md", "CONVENTIONS.md", "TASKS.md"]; + const findings: string[] = []; + for (const file of targetFiles) { + const filePath = path.join(contextDir, file); + if (!fs.existsSync(filePath)) { continue; } + const entries = fs.readFileSync(filePath, "utf-8").split("\n") + .filter((l) => /^\s*-\s/.test(l)) + .map((l) => l.replace(/^\s*-\s*(\[.\]\s*)?/, "").trim().toLowerCase()); + const seen = new Map(); + for (const entry of entries) { + const key = entry.replace(/[^a-z0-9\s]/g, "").replace(/\s+/g, " "); + seen.set(key, (seen.get(key) || 0) + 1); + } + const dupes = [...seen.entries()].filter(([, count]) => count > 1); + if (dupes.length) { + findings.push(`### ${file}\n` + dupes.map(([text, count]) => `- "${text}" (\u00d7${count})`).join("\n")); + } + } + stream.markdown("## Consolidation Report\n\n"); + if (findings.length) { + stream.markdown(findings.join("\n\n") + "\n\nReview and merge manually."); } else { - stream.markdown("No rationale found."); + stream.markdown("No duplicate entries found across context files."); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "consolidate" } }; +} + +async function handleAudit( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Running alignment audit..."); + try { + const { stdout: driftOut, stderr: driftErr } = await runCtx(["drift"], cwd, token); + stream.markdown("## Alignment Audit\n\n### Drift\n```\n" + mergeOutput(driftOut, driftErr) + "\n```\n\n"); + const convPath = path.join(cwd, ".context", "CONVENTIONS.md"); + if (fs.existsSync(convPath)) { + const entries = fs.readFileSync(convPath, "utf-8").split("\n").filter((l) => /^\s*-\s/.test(l)); + stream.markdown(`### Conventions: ${entries.length} documented\n\n`); + if (entries.length === 0) { + stream.markdown("**Warning:** No conventions documented. Run `@ctx /add convention ...`\n"); + } } + const archPath = path.join(cwd, ".context", "ARCHITECTURE.md"); + if (fs.existsSync(archPath)) { + const lines = fs.readFileSync(archPath, "utf-8").split("\n").filter((l) => l.trim() && !l.startsWith("#")); + if (lines.length < 3) { + stream.markdown("**Warning:** `ARCHITECTURE.md` appears sparse. Consider documenting system structure.\n"); + } + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "audit" } }; +} + +async function handleWorktree( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Managing worktrees..."); + try { + const subcmd = prompt.trim().split(/\s+/)[0]?.toLowerCase() || "list"; + if (subcmd === "list" || !prompt.trim()) { + const result = await execGit(["worktree", "list"], cwd, token); + stream.markdown("## Git Worktrees\n\n```\n" + result.trim() + "\n```\n\nCreate: `@ctx /worktree add `"); + } else if (subcmd === "add") { + const branch = prompt.trim().split(/\s+/).slice(1).join("-").replace(/[^a-zA-Z0-9_/-]/g, ""); + if (!branch) { + stream.markdown("Usage: `@ctx /worktree add `"); + } else { + const worktreePath = path.join(path.dirname(cwd), path.basename(cwd) + "-" + branch); + const result = await execGit( + ["worktree", "add", worktreePath, "-b", branch], + cwd, + token + ); + stream.markdown(`Worktree created at \`${worktreePath}\`.\n\n\`\`\`\n${result.trim()}\n\`\`\``); + } + } else { + stream.markdown("**Usage:** `@ctx /worktree [list|add ]`"); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "worktree" } }; +} + +async function handlePause( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Pausing context hooks..."); + try { + const { stdout, stderr } = await runCtx(["hook", "pause"], cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown( + output + ? "```\n" + output + "\n```" + : "Context hooks paused for this session. Resume with `@ctx /resume`." + ); } catch (err: unknown) { stream.markdown( - `**Error:** Failed to look up rationale.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + `**Error:** Failed to pause hooks.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); } - return { metadata: { command: "why" } }; + return { metadata: { command: "pause" } }; } -async function handleChange( +async function handleResume( stream: vscode.ChatResponseStream, cwd: string, token: vscode.CancellationToken ): Promise { - stream.progress("Checking recent codebase changes..."); + stream.progress("Resuming context hooks..."); try { - const { stdout, stderr } = await runCtx(["change", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["hook", "resume"], cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown( + output + ? "```\n" + output + "\n```" + : "Context hooks resumed for this session." + ); + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to resume hooks.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "resume" } }; +} + +async function handleMemory( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + const rest = parts.slice(1).join(" "); + + let args: string[]; + let progressMsg: string; + + switch (subcmd) { + case "sync": + args = ["memory", "sync"]; + progressMsg = "Syncing memory bridge..."; + break; + case "status": + args = ["memory", "status"]; + progressMsg = "Checking memory bridge status..."; + break; + case "diff": + args = ["memory", "diff"]; + progressMsg = "Comparing memory with context..."; + break; + case "import": + args = rest ? ["memory", "import", rest] : ["memory", "import"]; + progressMsg = "Importing memory..."; + break; + case "publish": + args = rest ? ["memory", "publish", rest] : ["memory", "publish"]; + progressMsg = "Publishing memory..."; + break; + case "unpublish": + args = rest ? ["memory", "unpublish", rest] : ["memory", "unpublish"]; + progressMsg = "Unpublishing memory..."; + break; + default: + stream.markdown( + "**Usage:** `@ctx /memory `\n\n" + + "| Subcommand | Description |\n" + + "|------------|-------------|\n" + + "| `sync` | Sync Claude Code memory bridge |\n" + + "| `status` | Show memory bridge status |\n" + + "| `diff` | Compare memory with context files |\n" + + "| `import` | Import from Claude Code memory |\n" + + "| `publish` | Publish context to Claude Code memory |\n" + + "| `unpublish` | Remove published memory |\n\n" + + "Example: `@ctx /memory sync` or `@ctx /memory diff`" + ); + return { metadata: { command: "memory" } }; + } + + stream.progress(progressMsg); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { - stream.markdown("No recent changes found."); + stream.markdown("No output."); } } catch (err: unknown) { stream.markdown( - `**Error:** Failed to check changes.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + `**Error:** Memory command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); } - return { metadata: { command: "change" } }; + return { metadata: { command: "memory" } }; } -async function handleDep( +async function handleDecisions( stream: vscode.ChatResponseStream, + prompt: string, 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."); + const subcmd = prompt.trim().split(/\s+/)[0]?.toLowerCase(); + + if (subcmd === "reindex") { + stream.progress("Reindexing decisions..."); + try { + const { stdout, stderr } = await runCtx(["decision", "reindex"], cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : "Decision index rebuilt."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + } else { + stream.progress("Loading decisions..."); + try { + const { stdout, stderr } = await runCtx(["decision"], cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : "No decisions found."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); } - } 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" } }; + return { metadata: { command: "decisions" } }; } -async function handleGuide( +async function handleLearnings( stream: vscode.ChatResponseStream, + prompt: string, 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."); + const subcmd = prompt.trim().split(/\s+/)[0]?.toLowerCase(); + + if (subcmd === "reindex") { + stream.progress("Reindexing learnings..."); + try { + const { stdout, stderr } = await runCtx(["learning", "reindex"], cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : "Learning index rebuilt."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + } else { + stream.progress("Loading learnings..."); + try { + const { stdout, stderr } = await runCtx(["learning"], cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : "No learnings found."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); } - } 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" } }; + return { metadata: { command: "learnings" } }; } -async function handlePermission( +async function handleConfig( stream: vscode.ChatResponseStream, prompt: string, cwd: string, @@ -1284,186 +1961,211 @@ async function handlePermission( ): 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 "snapshot": - args = ["permission", "snapshot"]; - progressMsg = "Taking permission snapshot..."; + case "switch": + if (!rest) { + stream.markdown("**Usage:** `@ctx /config switch `\n\nExample: `@ctx /config switch dev`"); + return { metadata: { command: "config" } }; + } + args = ["config", "switch", rest]; + progressMsg = `Switching to profile "${rest}"...`; break; - case "restore": - args = ["permission", "restore"]; - progressMsg = "Restoring permissions..."; + case "status": + args = ["config", "status"]; + progressMsg = "Checking config status..."; + break; + case "schema": + args = ["config", "schema"]; + progressMsg = "Loading config schema..."; break; default: stream.markdown( - "**Usage:** `@ctx /permission `\n\n" + + "**Usage:** `@ctx /config `\n\n" + "| Subcommand | Description |\n" + "|------------|-------------|\n" + - "| `snapshot` | Capture current file permissions |\n" + - "| `restore` | Restore saved permissions |\n\n" + - "Example: `@ctx /permission snapshot`" + "| `switch ` | Switch to a config profile |\n" + + "| `status` | Show current config profile |\n" + + "| `schema` | Show config schema |\n\n" + + "Example: `@ctx /config switch dev`" ); - return { metadata: { command: "permission" } }; + return { metadata: { command: "config" } }; } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { - stream.markdown(`Permission ${subcmd} completed.`); + stream.markdown("No output."); } } catch (err: unknown) { stream.markdown( - `**Error:** Failed to ${subcmd} permissions.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + `**Error:** Config command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); } - return { metadata: { command: "permission" } }; + return { metadata: { command: "config" } }; } -async function handleSite( +async function handlePermissions( stream: vscode.ChatResponseStream, prompt: string, cwd: string, token: vscode.CancellationToken ): Promise { - const parts = prompt.trim().split(/\s+/); - const subcmd = parts[0]?.toLowerCase(); + const subcmd = prompt.trim().split(/\s+/)[0]?.toLowerCase(); let args: string[]; let progressMsg: string; switch (subcmd) { - case "feed": - args = ["site", "feed"]; - progressMsg = "Generating site feed..."; + case "snapshot": + args = ["permission", "snapshot"]; + progressMsg = "Saving permissions snapshot..."; + break; + case "restore": + args = ["permission", "restore"]; + progressMsg = "Restoring permissions..."; break; default: stream.markdown( - "**Usage:** `@ctx /site `\n\n" + + "**Usage:** `@ctx /permissions `\n\n" + "| Subcommand | Description |\n" + "|------------|-------------|\n" + - "| `feed` | Generate documentation site feed |\n\n" + - "Example: `@ctx /site feed`" + "| `snapshot` | Backup current Claude settings |\n" + + "| `restore` | Restore settings from backup |\n\n" + + "Example: `@ctx /permissions snapshot`" ); - return { metadata: { command: "site" } }; + return { metadata: { command: "permissions" } }; } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { - stream.markdown(`Site ${subcmd} completed.`); + stream.markdown(subcmd === "snapshot" ? "Permissions snapshot saved." : "Permissions restored."); } } catch (err: unknown) { stream.markdown( - `**Error:** Failed to run site ${subcmd}.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + `**Error:** Permissions command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); } - return { metadata: { command: "site" } }; + return { metadata: { command: "permissions" } }; } -async function handleLoop( +async function handleChanges( 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..."); + stream.progress("Checking what changed..."); try { + const args = ["change"]; + const sinceMatch = prompt.match(/--since\s+(\S+)/); + if (sinceMatch) { + args.push("--since", sinceMatch[1]); + } const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { - stream.markdown("No loop script generated."); + stream.markdown("No changes detected since last session."); } } catch (err: unknown) { stream.markdown( - `**Error:** Failed to generate loop script.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + `**Error:** Failed to check changes.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); } - return { metadata: { command: "loop" } }; + return { metadata: { command: "changes" } }; } -async function handlePause( +async function handleGuide( stream: vscode.ChatResponseStream, + prompt: string, cwd: string, token: vscode.CancellationToken ): Promise { - stream.progress("Pausing context hooks..."); + stream.progress("Loading guide..."); try { - const { stdout, stderr } = await runCtx(["pause", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const args = ["guide"]; + if (prompt.includes("--skills")) { + args.push("--skills"); + } else if (prompt.includes("--commands")) { + args.push("--commands"); + } + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); if (output) { - stream.markdown("```\n" + output + "\n```"); + stream.markdown(output); } else { - stream.markdown("Context hooks paused for this session."); + stream.markdown("No guide output."); } } catch (err: unknown) { stream.markdown( - `**Error:** Failed to pause hooks.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + `**Error:** Failed to load guide.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); } - return { metadata: { command: "pause" } }; + return { metadata: { command: "guide" } }; } -async function handleResume( +async function handleReindex( stream: vscode.ChatResponseStream, cwd: string, token: vscode.CancellationToken ): Promise { - stream.progress("Resuming context hooks..."); + stream.progress("Regenerating indices..."); try { - const { stdout, stderr } = await runCtx(["resume", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["reindex"], cwd, token); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { - stream.markdown("Context hooks resumed."); + stream.markdown("Indices regenerated for DECISIONS.md and LEARNINGS.md."); } } catch (err: unknown) { stream.markdown( - `**Error:** Failed to resume hooks.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + `**Error:** Failed to reindex.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); } - return { metadata: { command: "resume" } }; + return { metadata: { command: "reindex" } }; } -async function handleReindex( +async function handleWhy( stream: vscode.ChatResponseStream, + prompt: string, cwd: string, token: vscode.CancellationToken ): Promise { - stream.progress("Rebuilding context file indices..."); + stream.progress("Loading philosophy..."); try { - const { stdout, stderr } = await runCtx(["reindex", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const args = ["why"]; + if (prompt.trim()) { + args.push(prompt.trim()); + } + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); if (output) { - stream.markdown("```\n" + output + "\n```"); + stream.markdown(output); } else { - stream.markdown("Context indices rebuilt."); + stream.markdown("No philosophy content available."); } } catch (err: unknown) { stream.markdown( - `**Error:** Failed to reindex.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + `**Error:** Failed to load philosophy.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); } - return { metadata: { command: "reindex" } }; + return { metadata: { command: "why" } }; } async function handleFreeform( @@ -1488,13 +2190,13 @@ async function handleFreeform( 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); + return handleComplete(stream, 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); + return handleTasks(stream, request.prompt, cwd, token); } if (prompt.includes("pad") || prompt.includes("scratchpad") || prompt.includes("scratch")) { return handlePad(stream, request.prompt, cwd, token); @@ -1505,88 +2207,151 @@ async function handleFreeform( 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("wrap") || prompt.includes("end session") || prompt.includes("closing")) { + return handleWrapup(stream, cwd, token); + } + if (prompt.includes("remember") || prompt.includes("last session") || prompt.includes("what were we")) { + return handleRemember(stream, request.prompt, cwd, token); + } + if (prompt.includes("next") || prompt.includes("what should") || prompt.includes("pick task")) { + return handleNext(stream, cwd, token); + } + if (prompt.includes("brainstorm") || prompt.includes("idea")) { + return handleBrainstorm(stream, request.prompt, cwd, token); + } + if (prompt.includes("reflect") || prompt.includes("persist") || prompt.includes("worth saving")) { + return handleReflect(stream, cwd, token); + } + if (prompt.includes("spec") || prompt.includes("scaffold")) { + return handleSpec(stream, request.prompt, cwd, token); + } + if (prompt.includes("implement") || prompt.includes("execution plan")) { + return handleImplement(stream, cwd, token); + } + if (prompt.includes("verify") || prompt.includes("qa") || prompt.includes("lint")) { + return handleVerify(stream, cwd, token); } - if (prompt.includes("journal")) { + if (prompt.includes("map") || prompt.includes("dependencies") || prompt.includes("deps")) { + return handleMap(stream, cwd, token); + } + if (prompt.includes("blog") && prompt.includes("changelog")) { + return handleChangelog(stream, cwd, token); + } + if (prompt.includes("blog") || prompt.includes("post")) { + return handleBlog(stream, request.prompt, cwd, token); + } + if (prompt.includes("changelog") || prompt.includes("release notes")) { + return handleChangelog(stream, cwd, token); + } + if (prompt.includes("link") || prompt.includes("broken") || prompt.includes("dead link")) { + return handleCheckLinks(stream, cwd, token); + } + if (prompt.includes("journal") || prompt.includes("log entries")) { return handleJournal(stream, request.prompt, cwd, token); } - if (prompt.includes("doctor") || prompt.includes("health")) { - return handleDoctor(stream, cwd, token); + if (prompt.includes("consolidate") || prompt.includes("merge entries") || prompt.includes("duplicate")) { + return handleConsolidate(stream, cwd, token); } - if (prompt.includes("config") || prompt.includes("configuration")) { - return handleConfig(stream, request.prompt, cwd, token); + if (prompt.includes("memory bridge") || prompt.includes("memory sync") || prompt.includes("memory status") || prompt.includes("memory diff") || prompt.includes("memory import") || prompt.includes("memory publish")) { + return handleMemory(stream, request.prompt, cwd, token); } - if (prompt.includes("prompt") || prompt.includes("template")) { - return handlePrompt(stream, request.prompt, cwd, token); + if (prompt.includes("decisions") || prompt.includes("decision list") || prompt.includes("decision reindex")) { + return handleDecisions(stream, request.prompt, cwd, token); } - if (prompt.includes("why") || prompt.includes("rationale")) { - return handleWhy(stream, request.prompt, cwd, token); + if (prompt.includes("learnings") || prompt.includes("learning list") || prompt.includes("learning reindex")) { + return handleLearnings(stream, request.prompt, cwd, token); } - if (prompt.includes("change") || prompt.includes("recent")) { - return handleChange(stream, cwd, token); + if (prompt.includes("config") || prompt.includes("profile") || prompt.includes("switch profile")) { + return handleConfig(stream, request.prompt, cwd, token); + } + if (prompt.includes("permissions") || prompt.includes("permission snapshot") || prompt.includes("permission restore")) { + return handlePermissions(stream, request.prompt, cwd, token); } - if (prompt.includes("dep") || prompt.includes("dependenc")) { - return handleDep(stream, cwd, token); + if (prompt.includes("changes") || prompt.includes("what changed") || prompt.includes("since last session")) { + return handleChanges(stream, request.prompt, cwd, token); } - if (prompt.includes("guide") || prompt.includes("quickstart") || prompt.includes("getting started")) { - return handleGuide(stream, cwd, token); + if (prompt.includes("guide") || prompt.includes("cheat sheet") || prompt.includes("quick reference")) { + return handleGuide(stream, request.prompt, cwd, token); + } + if (prompt.includes("reindex") || prompt.includes("rebuild index") || prompt.includes("regenerate index")) { + return handleReindex(stream, cwd, token); } - if (prompt.includes("permission")) { - return handlePermission(stream, request.prompt, cwd, token); + if (prompt.includes("why") || prompt.includes("philosophy") || prompt.includes("manifesto")) { + return handleWhy(stream, request.prompt, cwd, token); } - if (prompt.includes("site") || prompt.includes("feed")) { - return handleSite(stream, request.prompt, cwd, token); + if (prompt.includes("audit") || prompt.includes("alignment")) { + return handleAudit(stream, cwd, token); } - if (prompt.includes("loop") || prompt.includes("iterate")) { - return handleLoop(stream, request.prompt, cwd, token); + if (prompt.includes("worktree")) { + return handleWorktree(stream, request.prompt, cwd, token); } - if (prompt.includes("pause")) { + if (prompt.includes("pause") || prompt.includes("save state")) { return handlePause(stream, cwd, token); } - if (prompt.includes("resume")) { + if (prompt.includes("resume") || prompt.includes("restore state") || prompt.includes("continue session")) { return handleResume(stream, cwd, token); } - if (prompt.includes("reindex") || prompt.includes("rebuild")) { - return handleReindex(stream, cwd, token); + + // 2.5: Specs nudge — remind about specs when planning + if (prompt.includes("plan") || prompt.includes("design") || prompt.includes("architect")) { + const specsDir = path.join(cwd, "specs"); + if (fs.existsSync(specsDir)) { + const specs = fs.readdirSync(specsDir).filter((f) => f.endsWith(".md")); + if (specs.length > 0) { + stream.markdown("> **specs/** has " + specs.length + " spec(s). Review with `@ctx /spec` before designing.\n\n"); + } + } } // Default: show help with available commands stream.markdown( - "## ctx -- Persistent Context for AI\n\n" + + "## 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" + + "| `/agent [--budget N]` | 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" + + "| `/recall [show\\|export\\|lock\\|unlock\\|sync]` | Browse session history |\n" + + "| `/hook` | Generate tool integration configs |\n" + + "| `/add` | Add task, decision, learning, or convention |\n" + "| `/load` | Output assembled context |\n" + "| `/compact` | Archive completed tasks |\n" + "| `/sync` | Reconcile context with codebase |\n" + - "| `/task` | Task operations (complete, archive, snapshot) |\n" + + "| `/complete` | Mark a task as completed |\n" + "| `/remind` | Manage session reminders |\n" + - "| `/pad` | Encrypted scratchpad |\n" + + "| `/tasks` | Archive or snapshot tasks |\n" + + "| `/decisions [reindex]` | List or reindex decisions |\n" + + "| `/learnings [reindex]` | List or reindex learnings |\n" + + "| `/pad [resolve\\|import\\|export\\|merge]` | Encrypted scratchpad |\n" + "| `/notify` | Webhook notifications |\n" + - "| `/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" + + "| `/memory [sync\\|status\\|diff\\|import\\|publish]` | Claude Code memory bridge |\n" + + "| `/system [stats\\|backup\\|message]` | System diagnostics |\n" + + "| `/config [switch\\|status\\|schema]` | Config profile management |\n" + + "| `/permissions [snapshot\\|restore]` | Claude settings backup |\n" + + "| `/wrapup` | End-of-session wrap-up |\n" + + "| `/remember [--limit N]` | Recall recent sessions |\n" + + "| `/next` | Show next open task |\n" + + "| `/brainstorm` | Browse and develop ideas |\n" + + "| `/reflect` | Surface items worth persisting |\n" + + "| `/spec` | List or scaffold feature specs |\n" + + "| `/implement` | Show implementation plan |\n" + + "| `/verify` | Run verification checks |\n" + + "| `/map` | Show dependency map |\n" + + "| `/blog` | Draft blog post from context |\n" + + "| `/changelog` | Recent commits for changelog |\n" + + "| `/check-links` | Audit local links in context |\n" + + "| `/journal [site\\|obsidian]` | View or export journal |\n" + + "| `/consolidate` | Find duplicate entries |\n" + + "| `/audit` | Alignment audit (drift + conventions) |\n" + + "| `/worktree` | Git worktree management |\n" + + "| `/pause` | Pause context hooks for this session |\n" + "| `/resume` | Resume context hooks |\n" + - "| `/reindex` | Rebuild context indices |\n\n" + + "| `/changes [--since duration]` | Show what changed since last session |\n" + + "| `/guide [--skills\\|--commands]` | Quick-reference cheat sheet |\n" + + "| `/reindex` | Regenerate decision/learning indices |\n" + + "| `/why [topic]` | Read ctx philosophy |\n\n" + "Example: `@ctx /status` or `@ctx /add task Fix login bug`" ); return { metadata: { command: "help" } }; @@ -1620,19 +2385,31 @@ const handler: vscode.ChatRequestHandler = async ( return { metadata: { command: request.command || "none" } }; } + // Verify .context/ exists — except for commands that deliberately work + // without an initialized project (mirrors the CLI's AnnotationSkipInit): + // init plus the informational/config commands a new user reaches for. + const skipsInitGate = new Set(["init", "guide", "why", "config", "hook"]); + if (!skipsInitGate.has(request.command ?? "") && !hasContextDir(cwd)) { + stream.markdown( + "**Not initialized.** This project doesn't have a `.context/` directory yet.\n\n" + + "Run `@ctx /init` to set up persistent context for this project." + ); + return { metadata: { command: request.command || "none" } }; + } + switch (request.command) { case "init": return handleInit(stream, cwd, token); case "status": return handleStatus(stream, cwd, token); case "agent": - return handleAgent(stream, cwd, token); + return handleAgent(stream, request.prompt, 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 "hook": + return handleHook(stream, request.prompt, cwd, token); case "add": return handleAdd(stream, request.prompt, cwd, token); case "load": @@ -1641,46 +2418,72 @@ const handler: vscode.ChatRequestHandler = async ( return handleCompact(stream, cwd, token); case "sync": return handleSync(stream, cwd, token); - case "task": - return handleTask(stream, request.prompt, cwd, token); + case "complete": + return handleComplete(stream, request.prompt, cwd, token); case "remind": return handleRemind(stream, request.prompt, cwd, token); + case "tasks": + return handleTasks(stream, request.prompt, cwd, token); case "pad": return handlePad(stream, request.prompt, cwd, token); case "notify": return handleNotify(stream, request.prompt, cwd, token); case "system": return handleSystem(stream, request.prompt, cwd, token); - case "memory": - return handleMemory(stream, request.prompt, cwd, token); + case "wrapup": + return handleWrapup(stream, cwd, token); + case "remember": + return handleRemember(stream, request.prompt, cwd, token); + case "next": + return handleNext(stream, cwd, token); + case "brainstorm": + return handleBrainstorm(stream, request.prompt, cwd, token); + case "reflect": + return handleReflect(stream, cwd, token); + case "spec": + return handleSpec(stream, request.prompt, cwd, token); + case "implement": + return handleImplement(stream, cwd, token); + case "verify": + return handleVerify(stream, cwd, token); + case "map": + return handleMap(stream, cwd, token); + case "blog": + return handleBlog(stream, request.prompt, cwd, token); + case "changelog": + return handleChangelog(stream, cwd, token); + case "check-links": + return handleCheckLinks(stream, cwd, token); case "journal": return handleJournal(stream, request.prompt, cwd, token); - case "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 "consolidate": + return handleConsolidate(stream, cwd, token); + case "audit": + return handleAudit(stream, cwd, token); + case "worktree": + return handleWorktree(stream, request.prompt, cwd, token); case "pause": return handlePause(stream, cwd, token); case "resume": return handleResume(stream, cwd, token); + case "memory": + return handleMemory(stream, request.prompt, cwd, token); + case "decisions": + return handleDecisions(stream, request.prompt, cwd, token); + case "learnings": + return handleLearnings(stream, request.prompt, cwd, token); + case "config": + return handleConfig(stream, request.prompt, cwd, token); + case "permissions": + return handlePermissions(stream, request.prompt, cwd, token); + case "changes": + return handleChanges(stream, request.prompt, cwd, token); + case "guide": + return handleGuide(stream, request.prompt, cwd, token); case "reindex": return handleReindex(stream, cwd, token); + case "why": + return handleWhy(stream, request.prompt, cwd, token); default: return handleFreeform(request, stream, cwd, token); } @@ -1718,15 +2521,14 @@ export function activate(extensionContext: vscode.ExtensionContext) { { prompt: "Show my context status", command: "status" }, { prompt: "Generate copilot integration", - command: "setup", + command: "hook", } ); break; case "status": followups.push( { prompt: "Detect context drift", command: "drift" }, - { prompt: "Load full context", command: "load" }, - { prompt: "Run health check", command: "doctor" } + { prompt: "Load full context", command: "load" } ); break; case "drift": @@ -1735,10 +2537,10 @@ export function activate(extensionContext: vscode.ExtensionContext) { { prompt: "Show context status", command: "status" } ); break; - case "task": + case "complete": followups.push( { prompt: "Show context status", command: "status" }, - { prompt: "Compact context", command: "compact" } + { prompt: "Archive completed tasks", command: "tasks" } ); break; case "remind": @@ -1746,48 +2548,157 @@ export function activate(extensionContext: vscode.ExtensionContext) { { prompt: "Show context status", command: "status" } ); break; + case "tasks": + followups.push( + { prompt: "Show context status", command: "status" }, + { prompt: "Compact context", command: "compact" } + ); + break; case "pad": followups.push( { prompt: "List scratchpad", command: "pad" } ); break; - case "memory": + case "help": followups.push( - { prompt: "Check memory status", command: "memory" }, + { prompt: "Initialize project context", command: "init" }, { prompt: "Show context status", command: "status" } ); break; - case "doctor": + case "wrapup": + followups.push( + { prompt: "Add a decision", command: "add" }, + { prompt: "Add a learning", command: "add" } + ); + break; + case "remember": followups.push( { prompt: "Show context status", command: "status" }, - { prompt: "Detect drift", command: "drift" } + { prompt: "Load full context", command: "load" } ); break; - case "config": + case "next": + followups.push( + { prompt: "Mark task completed", command: "complete" }, + { prompt: "Show all tasks", command: "status" } + ); + break; + case "brainstorm": + followups.push( + { prompt: "Show context status", command: "status" }, + { prompt: "Add a task", command: "add" } + ); + break; + case "reflect": + followups.push( + { prompt: "Add a decision", command: "add" }, + { prompt: "Add a learning", command: "add" }, + { prompt: "Wrap up session", command: "wrapup" } + ); + break; + case "spec": + followups.push( + { prompt: "Show implementation plan", command: "implement" }, + { prompt: "Run verification", command: "verify" } + ); + break; + case "implement": + followups.push( + { prompt: "Show next task", command: "next" }, + { prompt: "Run verification", command: "verify" } + ); + break; + case "verify": + followups.push( + { prompt: "Show context status", command: "status" }, + { prompt: "Run alignment audit", command: "audit" } + ); + break; + case "map": followups.push( - { prompt: "Show config status", command: "config" } + { prompt: "Show context status", command: "status" } + ); + break; + case "blog": + case "changelog": + followups.push( + { prompt: "Show context status", command: "status" } ); break; - case "change": + case "consolidate": followups.push( + { prompt: "Run alignment audit", command: "audit" }, { prompt: "Show context status", command: "status" } ); break; + case "audit": + followups.push( + { prompt: "Fix drift", command: "sync" }, + { prompt: "Add a convention", command: "add" } + ); + break; case "pause": followups.push( - { prompt: "Resume hooks", command: "resume" } + { prompt: "Resume session", command: "resume" } ); break; case "resume": followups.push( + { prompt: "Show next task", command: "next" }, { prompt: "Show context status", command: "status" } ); break; - case "help": + case "memory": + followups.push( + { prompt: "Show memory diff", command: "memory" }, + { prompt: "Show context status", command: "status" } + ); + break; + case "decisions": + followups.push( + { prompt: "Add a decision", command: "add" }, + { prompt: "Reindex decisions", command: "decisions" } + ); + break; + case "learnings": + followups.push( + { prompt: "Add a learning", command: "add" }, + { prompt: "Reindex learnings", command: "learnings" } + ); + break; + case "config": + followups.push( + { prompt: "Show config status", command: "config" }, + { prompt: "Show context status", command: "status" } + ); + break; + case "permissions": + followups.push( + { prompt: "Show context status", command: "status" } + ); + break; + case "changes": + followups.push( + { prompt: "Show context status", command: "status" }, + { prompt: "Load full context", command: "load" } + ); + break; + case "guide": followups.push( - { prompt: "Initialize project context", command: "init" }, { prompt: "Show context status", command: "status" }, - { prompt: "Quick start guide", command: "guide" } + { prompt: "Read philosophy", command: "why" } + ); + break; + case "reindex": + followups.push( + { prompt: "List decisions", command: "decisions" }, + { prompt: "List learnings", command: "learnings" } + ); + break; + case "why": + followups.push( + { prompt: "Show guide", command: "guide" }, + { prompt: "Show context status", command: "status" } ); break; } @@ -1797,6 +2708,182 @@ export function activate(extensionContext: vscode.ExtensionContext) { }; extensionContext.subscriptions.push(participant); + + // --- VS Code native hooks: background nudges on save, commit, and context change --- + const cwd = getWorkspaceRoot(); + + // 2.6: onDidSave → task completion check (PostToolUse Edit/Write equivalent) + const saveWatcher = vscode.workspace.onDidSaveTextDocument((doc) => { + if (!cwd || !bootstrapDone || !hasContextDir(cwd)) { + return; + } + // Only trigger for files inside this workspace root — not for .context/ + // files themselves, and not for other roots (rel starting with ".."). + const rel = path.relative(cwd, doc.uri.fsPath); + if (rel.startsWith(".context") || rel.startsWith("..")) { + return; + } + // Fire and forget — non-blocking background check + runCtx(["system", "check-task-completion"], cwd).catch( + () => {} + ); + }); + extensionContext.subscriptions.push(saveWatcher); + + // 2.7: Git post-commit — detect commits and nudge for context capture + try { + const gitExtension = vscode.extensions.getExtension("vscode.git"); + if (gitExtension) { + const setupGitHook = (git: any) => { + try { + const api = git.getAPI(1); + if (api && api.repositories.length > 0) { + const repo = api.repositories[0]; + let lastCommit = repo.state.HEAD?.commit; + const commitListener = repo.state.onDidChange(() => { + const currentCommit = repo.state.HEAD?.commit; + if (currentCommit && currentCommit !== lastCommit) { + lastCommit = currentCommit; + if (!cwd || !bootstrapDone || !hasContextDir(cwd)) { + return; + } + vscode.window + .showInformationMessage( + "Commit succeeded. Record context or run QA?", + "Add Decision", + "Add Learning", + "Verify", + "Skip" + ) + .then((choice) => { + if (choice === "Add Decision") { + vscode.commands.executeCommand( + "workbench.action.chat.open", + { query: "@ctx /add decision " } + ); + } else if (choice === "Add Learning") { + vscode.commands.executeCommand( + "workbench.action.chat.open", + { query: "@ctx /add learning " } + ); + } else if (choice === "Verify") { + vscode.commands.executeCommand( + "workbench.action.chat.open", + { query: "@ctx /verify" } + ); + } + }); + } + }); + extensionContext.subscriptions.push(commitListener); + } + } catch { + // Git API not available + } + }; + + if (gitExtension.isActive) { + setupGitHook(gitExtension.exports); + } else { + gitExtension.activate().then(setupGitHook, () => {}); + } + } + } catch { + // Git extension not available + } + + // 2.9: Watch .context/ for external changes — refresh reminders + if (cwd) { + const contextWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(cwd, ".context/**") + ); + const onContextChange = () => { + if (hasContextDir(cwd)) { + updateReminderStatus(cwd); + } + }; + contextWatcher.onDidChange(onContextChange); + contextWatcher.onDidCreate(onContextChange); + contextWatcher.onDidDelete(onContextChange); + extensionContext.subscriptions.push(contextWatcher); + } + + // 2.17: Watch dependency files for staleness + if (cwd) { + const depWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(cwd, "{go.mod,go.sum,package.json,package-lock.json}") + ); + depWatcher.onDidChange(() => { + vscode.window.showInformationMessage( + "Dependencies changed. Review with @ctx /map?", + "View Map" + ).then((choice) => { + if (choice === "View Map") { + vscode.commands.executeCommand("workbench.action.chat.open", { query: "@ctx /map" }); + } + }); + }); + extensionContext.subscriptions.push(depWatcher); + } + + // 2.10: Status bar reminder indicator + reminderStatusBar = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 50 + ); + reminderStatusBar.name = "ctx Reminders"; + reminderStatusBar.command = undefined; // informational only + extensionContext.subscriptions.push(reminderStatusBar); + + // Check reminders periodically (every 5 minutes) + if (cwd && hasContextDir(cwd)) { + updateReminderStatus(cwd); + const reminderInterval = setInterval(() => { + updateReminderStatus(cwd); + // 2.14: Heartbeat — record session-alive timestamp + try { + const stateDir = path.join(cwd, ".context", "state"); + if (!fs.existsSync(stateDir)) { fs.mkdirSync(stateDir, { recursive: true }); } + fs.writeFileSync(path.join(stateDir, "heartbeat"), new Date().toISOString(), "utf-8"); + } catch { /* non-fatal */ } + }, 5 * 60 * 1000); + extensionContext.subscriptions.push({ + dispose: () => clearInterval(reminderInterval), + }); + + // 2.12: Session start ceremony + runCtx( + ["system", "session-event", "--type", "start", "--caller", "vscode"], + cwd + ).catch(() => {}); + } +} + +/** + * Update the status bar reminder indicator by checking due reminders. + */ +function updateReminderStatus(cwd: string): void { + if (!bootstrapDone || !reminderStatusBar) { + return; + } + // `ctx remind list` prints "No reminders." when the queue is empty. The + // hook relay (`system check-reminder`) instead always emits a provenance + // line that never contains that string, which pinned the bell on forever. + runCtx(["remind", "list"], cwd) + .then(({ stdout }) => { + const trimmed = stdout.trim(); + const hasReminders = trimmed.length > 0 && !/no reminders/i.test(trimmed); + if (hasReminders) { + reminderStatusBar!.text = "$(bell) ctx"; + reminderStatusBar!.tooltip = trimmed; + reminderStatusBar!.show(); + } else { + reminderStatusBar!.hide(); + } + }) + .catch(() => { + reminderStatusBar!.hide(); + }); } export { @@ -1806,26 +2893,30 @@ export { ensureCtxAvailable, bootstrap, getPlatformInfo, - handleTask, + handleComplete, handleRemind, + handleTasks, handlePad, handleNotify, handleSystem, handleMemory, - handleJournal, - handleDoctor, + handleDecisions, + handleLearnings, handleConfig, - handlePrompt, - handleWhy, - handleChange, - handleDep, + handlePermissions, + handleChanges, handleGuide, - handlePermission, - handleSite, - handleLoop, - handlePause, - handleResume, handleReindex, + handleWhy, }; -export function deactivate() {} +export function deactivate() { + // 2.12: Session end ceremony + const cwd = getWorkspaceRoot(); + if (cwd && hasContextDir(cwd)) { + runCtx( + ["system", "session-event", "--type", "end", "--caller", "vscode"], + cwd + ).catch(() => {}); + } +}