From a42f698f17e9760405fb8a99f7f8325606caff12 Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Tue, 17 Mar 2026 19:40:50 +0300 Subject: [PATCH 1/6] fix: Windows cross-platform compatibility - Case-insensitive path comparison on Windows for boundary validation - Extract osWindows constant for goconst compliance Closes #30 Supersedes #31 Signed-off-by: ersan bilik --- internal/validation/path.go | 22 ++++++++- internal/validation/path_test.go | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/internal/validation/path.go b/internal/validation/path.go index bb9f12e9..a1453a78 100644 --- a/internal/validation/path.go +++ b/internal/validation/path.go @@ -9,12 +9,16 @@ package validation import ( "os" "path/filepath" + "runtime" "strings" errctx "github.com/ActiveMemory/ctx/internal/err/context" fserr "github.com/ActiveMemory/ctx/internal/err/fs" ) +// osWindows is the GOOS value for Windows, extracted to satisfy goconst. +const osWindows = "windows" + // ValidateBoundary checks that dir resolves to a path within the current // working directory. Returns an error if the resolved path escapes the // project root. @@ -43,10 +47,26 @@ func ValidateBoundary(dir string) error { resolvedDir = filepath.Clean(absDir) } + // On Windows, path comparisons must be case-insensitive because + // filepath.EvalSymlinks resolves to actual disk casing while + // os.Getwd preserves the casing from the caller (e.g. VS Code + // passes a lowercase drive letter via fsPath). + equal := func(a, b string) bool { return a == b } + hasPrefix := strings.HasPrefix + if runtime.GOOS == osWindows { + equal = strings.EqualFold + hasPrefix = func(s, prefix string) bool { + return len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) + } + } + // Ensure the resolved dir is equal to or nested under the project root. // Append os.PathSeparator to avoid "/foo/bar" matching "/foo/b". + // On Windows, use case-insensitive comparison since NTFS paths are + // case-insensitive but EvalSymlinks normalizes casing only for the + // existing cwd, not the non-existent target — creating a mismatch. root := resolvedCwd + string(os.PathSeparator) - if resolvedDir != resolvedCwd && !strings.HasPrefix(resolvedDir, root) { + if !equal(resolvedDir, resolvedCwd) && !hasPrefix(resolvedDir, root) { return errctx.OutsideRoot(dir, resolvedCwd) } diff --git a/internal/validation/path_test.go b/internal/validation/path_test.go index 63d6add9..f9cf3bb3 100644 --- a/internal/validation/path_test.go +++ b/internal/validation/path_test.go @@ -9,6 +9,8 @@ package validation import ( "os" "path/filepath" + "runtime" + "strings" "testing" ) @@ -42,6 +44,41 @@ func TestValidateBoundary(t *testing.T) { } } +func TestValidateBoundaryCaseInsensitive(t *testing.T) { + if runtime.GOOS != osWindows { + t.Skip("case-insensitive path test only applies to Windows") + } + + // On Windows, EvalSymlinks normalizes casing to the filesystem's + // canonical form. When .context/ doesn't exist yet the fallback + // preserves the original cwd casing. The prefix check must be + // case-insensitive to avoid false "outside cwd" errors. + tmp := t.TempDir() + + // Change cwd to a case-mangled version of the temp dir. + // TempDir returns canonical casing; flip it. + mangled := strings.ToUpper(tmp) + if mangled == tmp { + mangled = strings.ToLower(tmp) + } + + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(orig) }() + + if err := os.Chdir(mangled); err != nil { + t.Skipf("cannot chdir to case-mangled path %q: %v", mangled, err) + } + + // .context doesn't exist — this is the exact scenario that caused the + // false positive on Windows. + if err := ValidateBoundary(".context"); err != nil { + t.Errorf("ValidateBoundary(.context) with case-mangled cwd: %v", err) + } +} + func TestCheckSymlinks(t *testing.T) { t.Run("regular directory passes", func(t *testing.T) { dir := t.TempDir() @@ -95,3 +132,45 @@ func TestCheckSymlinks(t *testing.T) { } }) } + +func TestValidateBoundary_WindowsCaseInsensitive(t *testing.T) { + if runtime.GOOS != osWindows { + t.Skip("Windows-only test") + } + + // Simulate the VS Code plugin scenario: CWD has a lowercase drive letter + // but EvalSymlinks resolves to the actual (uppercase) casing. + // When .context doesn't exist yet (first init), the fallback path + // preserves the lowercase letter, causing a case mismatch. + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + + // Swap the drive letter case to simulate VS Code's fsPath + if len(cwd) >= 2 && cwd[1] == ':' { + var swapped string + if cwd[0] >= 'A' && cwd[0] <= 'Z' { + swapped = strings.ToLower(cwd[:1]) + cwd[1:] + } else { + swapped = strings.ToUpper(cwd[:1]) + cwd[1:] + } + + origDir, _ := os.Getwd() + if chErr := os.Chdir(swapped); chErr != nil { + t.Fatalf("cannot chdir to %s: %v", swapped, chErr) + } + defer func() { _ = os.Chdir(origDir) }() + + // Non-existent subdir simulates .context before init + nonExistent := filepath.Join(swapped, ".nonexistent-ctx-dir") + if err := ValidateBoundary(nonExistent); err != nil { + t.Errorf("ValidateBoundary(%q) with swapped drive case should pass, got: %v", nonExistent, err) + } + + // Also test the default relative path that ctx init uses + if err := ValidateBoundary(".context"); err != nil { + t.Errorf("ValidateBoundary(.context) with swapped drive case should pass, got: %v", err) + } + } +} From 8cd24bef1df390f7bbc04433a0b4f64fda257335 Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Tue, 17 Mar 2026 19:43:36 +0300 Subject: [PATCH 2/6] feat: add Copilot Chat session parser for recall - CopilotParser implementing SessionParser interface with Matches() and ParseFile() - Platform-aware directory discovery for Code and Code Insiders - Typed Go structs for Copilot Chat JSONL format (snapshot+patch model) - Registration in parser.go and query scanning in query.go - ToolCopilot constant in config/session Closes #28 Supersedes #29 Signed-off-by: ersan bilik --- internal/config/session/tool.go | 2 + internal/recall/parser/copilot.go | 525 ++++++++++++++++++++++++++ internal/recall/parser/copilot_raw.go | 95 +++++ internal/recall/parser/parser.go | 1 + internal/recall/parser/query.go | 5 + 5 files changed, 628 insertions(+) create mode 100644 internal/recall/parser/copilot.go create mode 100644 internal/recall/parser/copilot_raw.go diff --git a/internal/config/session/tool.go b/internal/config/session/tool.go index ff676970..9295b2d8 100644 --- a/internal/config/session/tool.go +++ b/internal/config/session/tool.go @@ -10,6 +10,8 @@ package session const ( // ToolClaudeCode is the tool identifier for Claude Code sessions. ToolClaudeCode = "claude-code" + // ToolCopilot is the tool identifier for VS Code Copilot Chat sessions. + ToolCopilot = "copilot" // ToolMarkdown is the tool identifier for Markdown session files. ToolMarkdown = "markdown" ) diff --git a/internal/recall/parser/copilot.go b/internal/recall/parser/copilot.go new file mode 100644 index 00000000..ab3a5794 --- /dev/null +++ b/internal/recall/parser/copilot.go @@ -0,0 +1,525 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package parser + +import ( + "bufio" + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/ActiveMemory/ctx/internal/config/claude" + "github.com/ActiveMemory/ctx/internal/config/file" + "github.com/ActiveMemory/ctx/internal/config/session" + "github.com/ActiveMemory/ctx/internal/config/token" +) + +// copilotKeyRequests is the key path segment for request arrays. +const copilotKeyRequests = "requests" + +// CopilotParser parses VS Code Copilot Chat JSONL session files. +// +// Copilot Chat stores sessions as JSONL files in VS Code's workspaceStorage +// directory. Each file contains one session. The first line is a full session +// snapshot (kind=0), subsequent lines are incremental patches (kind=1, kind=2). +type CopilotParser struct{} + +// NewCopilotParser creates a new Copilot Chat session parser. +func NewCopilotParser() *CopilotParser { + return &CopilotParser{} +} + +// Tool returns the tool identifier for this parser. +func (p *CopilotParser) Tool() string { + return session.ToolCopilot +} + +// Matches returns true if the file appears to be a Copilot Chat session file. +// +// Checks if the file has a .jsonl extension and lives in a chatSessions +// directory, and the first line contains a Copilot session snapshot. +func (p *CopilotParser) Matches(path string) bool { + if !strings.HasSuffix(path, file.ExtJSONL) { + return false + } + + // Copilot sessions live in chatSessions/ directories + if !strings.Contains(filepath.Dir(path), "chatSessions") { + return false + } + + file, openErr := os.Open(filepath.Clean(path)) + if openErr != nil { + return false + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + if !scanner.Scan() { + return false + } + + var line copilotRawLine + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + return false + } + + // kind=0 is the full session snapshot + if line.Kind != 0 { + return false + } + + var session copilotRawSession + if err := json.Unmarshal(line.V, &session); err != nil { + return false + } + + return session.SessionID != "" && session.Version > 0 +} + +// ParseFile reads a Copilot Chat JSONL file and returns the session. +// +// Reconstructs the session by reading the initial snapshot (kind=0) and +// applying incremental patches (kind=1 for scalar, kind=2 for array/object). +func (p *CopilotParser) ParseFile(path string) ([]*Session, error) { + file, openErr := os.Open(filepath.Clean(path)) + if openErr != nil { + return nil, fmt.Errorf("open file: %w", openErr) + } + defer func() { _ = file.Close() }() + + scanner := bufio.NewScanner(file) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 4*1024*1024) // 4MB — Copilot lines can be very large + + var session *copilotRawSession + + for scanner.Scan() { + lineBytes := scanner.Bytes() + if len(lineBytes) == 0 { + continue + } + + var line copilotRawLine + if err := json.Unmarshal(lineBytes, &line); err != nil { + continue + } + + switch line.Kind { + case 0: + // Full session snapshot + var s copilotRawSession + if err := json.Unmarshal(line.V, &s); err != nil { + return nil, fmt.Errorf("parse session snapshot: %w", err) + } + session = &s + + case 1: + // Scalar property patch — apply to session + if session != nil { + p.applyScalarPatch(session, line.K, line.V) + } + + case 2: + // Array/object patch — apply to session + if session != nil { + p.applyPatch(session, line.K, line.V) + } + } + } + + if scanErr := scanner.Err(); scanErr != nil { + return nil, fmt.Errorf("scan file: %w", scanErr) + } + + if session == nil { + return nil, nil + } + + // Resolve workspace folder from workspace.json next to chatSessions/ + cwd := p.resolveWorkspaceCWD(path) + + result := p.buildSession(session, path, cwd) + if result == nil { + return nil, nil + } + + return []*Session{result}, nil +} + +// ParseLine is not meaningful for Copilot sessions since they use patches. +// Returns nil for all lines. +func (p *CopilotParser) ParseLine(_ []byte) (*Message, string, error) { + return nil, "", nil +} + +// applyScalarPatch applies a kind=1 scalar patch to the session. +// These update individual properties like result, modelState, followups. +func (p *CopilotParser) applyScalarPatch( + session *copilotRawSession, keys []json.RawMessage, value json.RawMessage, +) { + path := p.parseKeyPath(keys) + if len(path) < 2 { + return + } + + // Handle requests..result patches — these contain token counts + if path[0] == copilotKeyRequests && len(path) == 3 && path[2] == "result" { + idx, err := strconv.Atoi(path[1]) + if err != nil || idx < 0 || idx >= len(session.Requests) { + return + } + var result copilotRawResult + if err := json.Unmarshal(value, &result); err == nil { + session.Requests[idx].Result = &result + } + } +} + +// applyPatch applies a kind=2 array/object patch to the session. +func (p *CopilotParser) applyPatch( + session *copilotRawSession, keys []json.RawMessage, value json.RawMessage, +) { + path := p.parseKeyPath(keys) + if len(path) == 0 { + return + } + + switch { + case len(path) == 1 && path[0] == copilotKeyRequests: + // New request(s) appended + var requests []copilotRawRequest + if err := json.Unmarshal(value, &requests); err == nil { + session.Requests = append(session.Requests, requests...) + } + + case len(path) == 3 && path[0] == copilotKeyRequests && path[2] == "response": + // Response update for a specific request + idx, err := strconv.Atoi(path[1]) + if err != nil || idx < 0 || idx >= len(session.Requests) { + return + } + var items []copilotRawRespItem + if err := json.Unmarshal(value, &items); err == nil { + session.Requests[idx].Response = items + } + } +} + +// parseKeyPath converts the K array from JSONL into string path segments. +func (p *CopilotParser) parseKeyPath(keys []json.RawMessage) []string { + path := make([]string, 0, len(keys)) + for _, k := range keys { + var s string + if err := json.Unmarshal(k, &s); err == nil { + path = append(path, s) + continue + } + var n int + if err := json.Unmarshal(k, &n); err == nil { + path = append(path, strconv.Itoa(n)) + continue + } + } + return path +} + +// buildSession converts a reconstructed copilotRawSession into a Session. +func (p *CopilotParser) buildSession( + raw *copilotRawSession, sourcePath string, cwd string, +) *Session { + if len(raw.Requests) == 0 { + return nil + } + + session := &Session{ + ID: raw.SessionID, + Tool: session.ToolCopilot, + SourceFile: sourcePath, + CWD: cwd, + Project: filepath.Base(cwd), + StartTime: time.UnixMilli(raw.CreationDate), + } + + if raw.CustomTitle != "" { + session.Slug = raw.CustomTitle + } + + for _, req := range raw.Requests { + // User message + userMsg := Message{ + ID: req.RequestID, + Timestamp: time.UnixMilli(req.Timestamp), + Role: claude.RoleUser, + Text: req.Message.Text, + } + + if req.Result != nil { + userMsg.TokensIn = req.Result.Metadata.PromptTokens + } + + session.Messages = append(session.Messages, userMsg) + session.TurnCount++ + + if session.FirstUserMsg == "" && userMsg.Text != "" { + preview := userMsg.Text + if len(preview) > 100 { + preview = preview[:100] + "..." + } + session.FirstUserMsg = preview + } + + // Assistant response + assistantMsg := p.buildAssistantMessage(req) + if assistantMsg != nil { + session.Messages = append(session.Messages, *assistantMsg) + + if session.Model == "" && req.ModelID != "" { + session.Model = req.ModelID + } + } + + // Accumulate tokens + if req.Result != nil { + session.TotalTokensIn += req.Result.Metadata.PromptTokens + session.TotalTokensOut += req.Result.Metadata.OutputTokens + } + } + + session.TotalTokens = session.TotalTokensIn + session.TotalTokensOut + + // Set end time from last request + if last := raw.Requests[len(raw.Requests)-1]; last.Result != nil { + session.EndTime = time.UnixMilli(last.Timestamp).Add( + time.Duration(last.Result.Timings.TotalElapsed) * time.Millisecond, + ) + } else { + session.EndTime = time.UnixMilli( + raw.Requests[len(raw.Requests)-1].Timestamp, + ) + } + session.Duration = session.EndTime.Sub(session.StartTime) + + return session +} + +// buildAssistantMessage extracts the assistant response from a request. +func (p *CopilotParser) buildAssistantMessage( + req copilotRawRequest, +) *Message { + if len(req.Response) == 0 { + return nil + } + + msg := &Message{ + ID: req.RequestID + "-response", + Timestamp: time.UnixMilli(req.Timestamp), + Role: claude.RoleAssistant, + } + + if req.Result != nil { + msg.TokensOut = req.Result.Metadata.OutputTokens + } + + for _, item := range req.Response { + switch item.Kind { + case "thinking": + var text string + if err := json.Unmarshal(item.Value, &text); err == nil { + if msg.Thinking != "" { + msg.Thinking += token.NewlineLF + } + msg.Thinking += text + } + + case "toolInvocationSerialized": + tu := p.parseToolInvocation(item) + if tu != nil { + msg.ToolUses = append(msg.ToolUses, *tu) + } + + case "": + // Plain markdown text (no kind field) + var text string + if err := json.Unmarshal(item.Value, &text); err == nil { + text = strings.TrimSpace(text) + if text != "" { + if msg.Text != "" { + msg.Text += token.NewlineLF + } + msg.Text += text + } + } + + // Skip: codeblockUri, inlineReference, progressTaskSerialized, + // textEditGroup, undoStop, mcpServersStarting + } + } + + // Check for tool errors + for _, tr := range msg.ToolResults { + if tr.IsError { + return msg // HasErrors is set at session level + } + } + + return msg +} + +// parseToolInvocation extracts a ToolUse from a toolInvocationSerialized item. +func (p *CopilotParser) parseToolInvocation(item copilotRawRespItem) *ToolUse { + toolID := item.ToolID + if toolID == "" { + return nil + } + + // Extract the tool name from toolId (e.g., "copilot_readFile" -> "readFile") + name := toolID + if idx := strings.LastIndex(toolID, "_"); idx >= 0 { + name = toolID[idx+1:] + } + + // Use invocationMessage as the input description + inputStr := "" + if item.InvocationMessage != nil { + // InvocationMessage can be a string or object with value field + var simple string + if err := json.Unmarshal(item.InvocationMessage, &simple); err == nil { + inputStr = simple + } else { + var obj struct { + Value string `json:"value"` + } + if err := json.Unmarshal(item.InvocationMessage, &obj); err == nil { + inputStr = obj.Value + } + } + } + + return &ToolUse{ + ID: item.ToolCallID, + Name: name, + Input: inputStr, + } +} + +// resolveWorkspaceCWD reads workspace.json from the workspaceStorage +// directory to determine the workspace folder path. +func (p *CopilotParser) resolveWorkspaceCWD(sessionPath string) string { + // sessionPath is like: .../workspaceStorage//chatSessions/.jsonl + // workspace.json is at: .../workspaceStorage//workspace.json + chatDir := filepath.Dir(sessionPath) // chatSessions/ + storageDir := filepath.Dir(chatDir) // / + wsFile := filepath.Join(storageDir, "workspace.json") + + data, err := os.ReadFile(filepath.Clean(wsFile)) + if err != nil { + return "" + } + + var ws copilotRawWorkspace + if err := json.Unmarshal(data, &ws); err != nil { + return "" + } + + return fileURIToPath(ws.Folder) +} + +// fileURIToPath converts a file:// URI to a local file path. +// Example: "file:///g%3A/GitProjects/ctx" -> "G:\GitProjects\ctx" (Windows) +// +// "file:///home/user/project" -> "/home/user/project" (Unix) +func fileURIToPath(uri string) string { + if uri == "" { + return "" + } + + parsed, err := url.Parse(uri) + if err != nil { + return "" + } + + if parsed.Scheme != "file" { + return "" + } + + path := parsed.Path + + // URL-decode the path (e.g., %3A -> :) + decoded, err := url.PathUnescape(path) + if err != nil { + decoded = path + } + + // On Windows, file URIs have /G:/... — strip the leading slash + if runtime.GOOS == "windows" && len(decoded) > 2 && decoded[0] == '/' { + decoded = decoded[1:] + } + + return filepath.FromSlash(decoded) +} + +// CopilotSessionDirs returns the directories where Copilot Chat sessions +// are stored. Checks both VS Code stable and Insiders paths. +func CopilotSessionDirs() []string { + var dirs []string + + appData := os.Getenv("APPDATA") + if runtime.GOOS != "windows" { + // On macOS/Linux, VS Code stores data in different locations + home, err := os.UserHomeDir() + if err != nil { + return nil + } + switch runtime.GOOS { + case "darwin": + appData = filepath.Join(home, "Library", "Application Support") + default: // Linux + appData = filepath.Join(home, ".config") + } + } + + if appData == "" { + return nil + } + + // Check both Code stable and Code Insiders + variants := []string{"Code", "Code - Insiders"} + for _, variant := range variants { + wsDir := filepath.Join(appData, variant, "User", "workspaceStorage") + if info, err := os.Stat(wsDir); err == nil && info.IsDir() { + // Scan each workspace for chatSessions/ subdirectory + entries, err := os.ReadDir(wsDir) + if err != nil { + continue + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + chatDir := filepath.Join(wsDir, entry.Name(), "chatSessions") + if info, err := os.Stat(chatDir); err == nil && info.IsDir() { + dirs = append(dirs, chatDir) + } + } + } + } + + return dirs +} + +// Ensure CopilotParser implements SessionParser. +var _ SessionParser = (*CopilotParser)(nil) diff --git a/internal/recall/parser/copilot_raw.go b/internal/recall/parser/copilot_raw.go new file mode 100644 index 00000000..c6839bf2 --- /dev/null +++ b/internal/recall/parser/copilot_raw.go @@ -0,0 +1,95 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package parser + +import "encoding/json" + +// Copilot Chat JSONL raw types. +// +// Copilot Chat stores sessions as JSONL files in VS Code's workspaceStorage. +// Each file contains one session. The first line (kind=0) is the full session +// snapshot, subsequent lines are incremental patches (kind=1 for scalar +// replacements, kind=2 for array/object replacements). + +// copilotRawLine represents a single JSONL line from a Copilot Chat session. +// +// Kind discriminates the line type: +// - 0: Full session snapshot (V contains copilotRawSession) +// - 1: Scalar property patch (K is the JSON path, V is the new value) +// - 2: Array/object patch (K is the JSON path, V is the new value) +type copilotRawLine struct { + Kind int `json:"kind"` + K []json.RawMessage `json:"k,omitempty"` + V json.RawMessage `json:"v"` +} + +// copilotRawSession is the full session snapshot from a kind=0 line. +type copilotRawSession struct { + Version int `json:"version"` + CreationDate int64 `json:"creationDate"` + CustomTitle string `json:"customTitle,omitempty"` + SessionID string `json:"sessionId"` + ResponderUsername string `json:"responderUsername,omitempty"` + InitialLocation string `json:"initialLocation,omitempty"` + Requests []copilotRawRequest `json:"requests"` +} + +// copilotRawRequest represents a single request-response pair. +type copilotRawRequest struct { + RequestID string `json:"requestId"` + Timestamp int64 `json:"timestamp"` + ModelID string `json:"modelId,omitempty"` + Message copilotRawMessage `json:"message"` + Response []copilotRawRespItem `json:"response,omitempty"` + Result *copilotRawResult `json:"result,omitempty"` + ContentReferences []json.RawMessage `json:"contentReferences,omitempty"` +} + +// copilotRawMessage is the user's input message. +type copilotRawMessage struct { + Text string `json:"text"` +} + +// copilotRawRespItem is a single item in the response array. +// +// The Kind field discriminates the type: +// - "thinking": Extended thinking (Value contains the text) +// - "toolInvocationSerialized": Tool call +// - "textEditGroup": File edit +// - "": Plain markdown text (Value field only) +type copilotRawRespItem struct { + Kind string `json:"kind,omitempty"` + Value json.RawMessage `json:"value,omitempty"` + ID string `json:"id,omitempty"` + InvocationMessage json.RawMessage `json:"invocationMessage,omitempty"` + ToolID string `json:"toolId,omitempty"` + ToolCallID string `json:"toolCallId,omitempty"` + IsComplete json.RawMessage `json:"isComplete,omitempty"` +} + +// copilotRawResult contains completion metadata for a request. +type copilotRawResult struct { + Timings copilotRawTimings `json:"timings"` + Metadata copilotRawMetadata `json:"metadata,omitempty"` +} + +// copilotRawTimings contains timing information. +type copilotRawTimings struct { + FirstProgress int64 `json:"firstProgress"` + TotalElapsed int64 `json:"totalElapsed"` +} + +// copilotRawMetadata contains token usage and other metadata. +type copilotRawMetadata struct { + PromptTokens int `json:"promptTokens,omitempty"` + OutputTokens int `json:"outputTokens,omitempty"` +} + +// copilotRawWorkspace is the workspace.json file in workspaceStorage. +type copilotRawWorkspace struct { + Folder string `json:"folder,omitempty"` +} diff --git a/internal/recall/parser/parser.go b/internal/recall/parser/parser.go index 21a57d55..1c66f164 100644 --- a/internal/recall/parser/parser.go +++ b/internal/recall/parser/parser.go @@ -20,6 +20,7 @@ import ( // Add new parsers here when supporting additional tools. var registeredParsers = []SessionParser{ NewClaudeCodeParser(), + NewCopilotParser(), NewMarkdownSessionParser(), } diff --git a/internal/recall/parser/query.go b/internal/recall/parser/query.go index 14b31785..c757b4b6 100644 --- a/internal/recall/parser/query.go +++ b/internal/recall/parser/query.go @@ -56,6 +56,11 @@ func findSessionsWithFilter( scanOnce(filepath.Join(home, dir.Claude, dir.Projects)) } + // Check Copilot Chat session directories (Code + Code Insiders) + for _, dir := range CopilotSessionDirs() { + scanOnce(dir) + } + // Check .context/sessions/ in the current working directory if cwd, cwdErr := os.Getwd(); cwdErr == nil { scanOnce(filepath.Join(cwd, dir.Context, dir.Sessions)) From 5fd63c518fcc34f11f0ac59d7175877f1c3bcc38 Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Tue, 17 Mar 2026 19:45:22 +0300 Subject: [PATCH 3/6] feat: VS Code extension with MCP integration and detection ring - VS Code extension with @ctx chat participant and slash commands - .vscode/mcp.json generation in init and hook paths - VS Code-specific context overrides (CONSTITUTION, CONVENTIONS, etc.) - Session event CLI command for VS Code lifecycle integration - Detection ring: terminal watcher for dangerous commands - Detection ring: file edit watcher for sensitive file patterns - Violation recording to .context/state/violations.json - Extension tests and init/hook integration tests Supersedes #36 Signed-off-by: ersan bilik --- .github/copilot-instructions.md | 106 + editors/vscode/LICENSE | 207 ++ editors/vscode/README.md | 211 +- editors/vscode/package.json | 165 +- editors/vscode/src/extension.test.ts | 44 +- editors/vscode/src/extension.ts | 2322 +++++++++++++++-- internal/assets/embed.go | 25 + .../assets/overrides/vscode/AGENT_PLAYBOOK.md | 268 ++ internal/assets/overrides/vscode/CLAUDE.md | 55 + .../assets/overrides/vscode/CONSTITUTION.md | 47 + .../assets/overrides/vscode/CONVENTIONS.md | 55 + internal/bootstrap/cmd.go | 10 +- internal/cli/hook/cmd/root/run.go | 39 + internal/cli/hook/cmd/root/run_test.go | 146 ++ internal/cli/initialize/cmd/root/cmd.go | 7 +- internal/cli/initialize/cmd/root/run.go | 84 +- internal/cli/initialize/core/claude.go | 5 +- internal/cli/initialize/core/vscode.go | 194 ++ internal/cli/initialize/core/vscode_test.go | 147 ++ internal/cli/system/cmd/sessionevent/cmd.go | 68 + internal/cli/system/system.go | 2 + internal/compliance/compliance_test.go | 96 + specs/vscode-feature-parity.md | 128 + 23 files changed, 4188 insertions(+), 243 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 editors/vscode/LICENSE create mode 100644 internal/assets/overrides/vscode/AGENT_PLAYBOOK.md create mode 100644 internal/assets/overrides/vscode/CLAUDE.md create mode 100644 internal/assets/overrides/vscode/CONSTITUTION.md create mode 100644 internal/assets/overrides/vscode/CONVENTIONS.md create mode 100644 internal/cli/hook/cmd/root/run_test.go create mode 100644 internal/cli/initialize/core/vscode.go create mode 100644 internal/cli/initialize/core/vscode_test.go create mode 100644 internal/cli/system/cmd/sessionevent/cmd.go create mode 100644 specs/vscode-feature-parity.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..f64268d8 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,106 @@ +# Project Context + + + + +## Context System + +This project uses Context (`ctx`) for persistent AI context +management. Your memory is NOT ephemeral — it lives in `.context/` files. + +## On Session Start + +Read these files **in order** before starting any work: + +1. `.context/CONSTITUTION.md` — Hard rules, NEVER violate +2. `.context/TASKS.md` — Current work items +3. `.context/CONVENTIONS.md` — Code patterns and standards +4. `.context/ARCHITECTURE.md` — System structure +5. `.context/DECISIONS.md` — Architectural decisions with rationale +6. `.context/LEARNINGS.md` — Gotchas, tips, lessons learned +7. `.context/GLOSSARY.md` — Domain terms and abbreviations +8. `.context/AGENT_PLAYBOOK.md` — How to use this context system + +After reading, confirm: "I have read the required context files and I'm +following project conventions." + +## When Asked "Do You Remember?" + +**Do this FIRST (silently):** +- Read `.context/TASKS.md` +- Read `.context/DECISIONS.md` and `.context/LEARNINGS.md` +- Check `.context/sessions/` for recent session files + +**Then respond with a structured readback:** +1. **Last session**: cite the most recent session topic and date +2. **Active work**: list pending or in-progress tasks +3. **Recent context**: mention 1-2 recent decisions or learnings +4. **Next step**: offer to continue or ask what to focus on + +**Never** lead with "I don't have memory." The `.context/` files are +your memory. Read them silently, then present what you found as recall. + +## Session Persistence + +After completing meaningful work, save a session summary to +`.context/sessions/`. + +### Session File Format + +Create a file named `YYYY-MM-DD-topic.md`: + +```markdown +# Session: YYYY-MM-DD — Brief Topic Description + +## What Was Done +- Describe completed work items + +## Decisions +- Key decisions made and their rationale + +## Learnings +- Gotchas, tips, or insights discovered + +## Next Steps +- Follow-up work or remaining items +``` + +### When to Save + +- After completing a task or feature +- After making architectural decisions +- After a debugging session +- Before ending the session +- At natural breakpoints in long sessions + +## Context Updates During Work + +Proactively update context files as you work: + +| Event | Action | +|-----------------------------|-------------------------------------| +| Made architectural decision | Add to `.context/DECISIONS.md` | +| Discovered gotcha/bug | Add to `.context/LEARNINGS.md` | +| Established new pattern | Add to `.context/CONVENTIONS.md` | +| Completed task | Mark [x] in `.context/TASKS.md` | + +## Self-Check + +Periodically ask yourself: + +> "If this session ended right now, would the next session know what happened?" + +If no — save a session file or update context files before continuing. + +## CLI Commands + +If `ctx` is installed, use these commands: + +```bash +ctx status # Context summary and health check +ctx agent # AI-ready context packet +ctx drift # Check for stale context +ctx recall list # Recent session history +``` + + diff --git a/editors/vscode/LICENSE b/editors/vscode/LICENSE new file mode 100644 index 00000000..be659d90 --- /dev/null +++ b/editors/vscode/LICENSE @@ -0,0 +1,207 @@ + / ctx: https://ctx.ist + ,'`./ do you remember? + `.,'\ + \ Copyright 2026-present Context contributors. + SPDX-License-Identifier: Apache-2.0 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 4bfe70f3..5c97c711 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -8,37 +8,174 @@ ## `ctx`: VS Code Chat Extension -A VS Code Chat Participant that brings [ctx](https://ctx.ist): -(*persistent project context for AI coding sessions*) -directly into GitHub Copilot Chat. - -## Usage - -Type `@ctx` in the VS Code Chat view, then use slash commands: - -| Command | Description | -|-----------------|--------------------------------------------------------| -| `@ctx /init` | Initialize a `.context/` directory with template files | -| `@ctx /status` | Show context summary with token estimate | -| `@ctx /agent` | Print AI-ready context packet | -| `@ctx /drift` | Detect stale or invalid context | -| `@ctx /recall` | Browse and search AI session history | -| `@ctx /hook` | Generate AI tool integration configs | -| `@ctx /add` | Add a task, decision, or learning | -| `@ctx /load` | Output assembled context Markdown | -| `@ctx /compact` | Archive completed tasks and clean up | -| `@ctx /sync` | Reconcile context with codebase | +A VS Code Chat Participant that brings [ctx](https://ctx.ist) — persistent +project context for AI coding sessions — directly into GitHub Copilot Chat. + +Type `@ctx` in the Chat view to access 45 slash commands, automatic context +hooks, a reminder status bar, and natural language routing — all powered by +the ctx CLI. + +## Quick Start + +1. Install the extension (or build from source — see [Development](#development)) +2. Open a project in VS Code +3. Open Copilot Chat and type `@ctx /init` + +The extension auto-downloads the ctx CLI binary if it isn't on your PATH. + +## Slash Commands + +### Core Context + +| Command | Description | +|---------|-------------| +| `/init` | Initialize a `.context/` directory with template files | +| `/status` | Show context summary with token estimate | +| `/agent` | Print AI-ready context packet | +| `/drift` | Detect stale or invalid context | +| `/recall` | Browse and search AI session history | +| `/hook` | Generate AI tool integration configs (copilot, claude) | +| `/add` | Add a task, decision, learning, or convention | +| `/load` | Output assembled context Markdown | +| `/compact` | Archive completed tasks and clean up context | +| `/sync` | Reconcile context with codebase | + +### Tasks & Reminders + +| Command | Description | +|---------|-------------| +| `/complete` | Mark a task as completed | +| `/remind` | Manage session-scoped reminders (add, list, dismiss) | +| `/tasks` | Archive or snapshot tasks | +| `/next` | Show the next open task from TASKS.md | +| `/implement` | Show the implementation plan with progress | + +### Session Lifecycle + +| Command | Description | +|---------|-------------| +| `/wrapup` | End-of-session wrap-up with status, drift, and journal audit | +| `/remember` | Recall recent AI sessions for this project | +| `/reflect` | Surface items worth persisting as decisions or learnings | +| `/pause` | Save session state for later | +| `/resume` | Restore a paused session | + +### Discovery & Planning + +| Command | Description | +|---------|-------------| +| `/brainstorm` | Browse and develop ideas from `ideas/` | +| `/spec` | List or scaffold feature specs from templates | +| `/verify` | Run verification checks (doctor + drift) | +| `/map` | Show dependency map (go.mod, package.json) | +| `/prompt` | Browse and view prompt templates | +| `/blog` | Draft a blog post from recent context | +| `/changelog` | Show recent commits for changelog | + +### Maintenance & Audit + +| Command | Description | +|---------|-------------| +| `/check-links` | Audit local links in context files | +| `/journal` | View or export journal entries | +| `/consolidate` | Find duplicate entries across context files | +| `/audit` | Alignment audit — drift + convention check | +| `/worktree` | Git worktree management (list, add) | + +### Context Metadata + +| Command | Description | +|---------|-------------| +| `/memory` | Claude Code memory bridge (sync, status, diff, import, publish) | +| `/decisions` | List or reindex project decisions | +| `/learnings` | List or reindex project learnings | +| `/config` | Manage config profiles (switch, status, schema) | +| `/permissions` | Backup or restore Claude settings | +| `/changes` | Show what changed since last session | +| `/deps` | Show package dependency graph | +| `/guide` | Quick-reference cheat sheet for ctx | +| `/reindex` | Regenerate indices for DECISIONS.md and LEARNINGS.md | +| `/why` | Read the philosophy behind ctx | + +### System & Diagnostics + +| Command | Description | +|---------|-------------| +| `/system` | System diagnostics and bootstrap | +| `/pad` | Encrypted scratchpad for sensitive notes | +| `/notify` | Send webhook notifications | + +Sub-routes for `/system`: `resources`, `doctor`, `bootstrap`, `stats`, +`backup`, `message`. + +## Automatic Hooks + +The extension registers several VS Code event handlers that mirror +Claude Code's hook system. These run in the background — no user action +needed. + +| Trigger | What Happens | +|---------|--------------| +| **File save** | Runs task-completion check on non-`.context/` files | +| **Git commit** | Notification prompting to add a Decision, Learning, run Verify, or Skip | +| **`.context/` file change** | Refreshes reminders and regenerates `.github/copilot-instructions.md` | +| **Dependency file change** | Notification when `go.mod`, `package.json`, etc. change — offers `/map` | +| **Every 5 minutes** | Updates reminder status bar and writes heartbeat timestamp | +| **Extension activate** | Fires `session-event --type start` to ctx CLI | +| **Extension deactivate** | Fires `session-event --type end` to ctx CLI | + +## Status Bar + +A `$(bell) ctx` indicator appears in the status bar when you have pending +reminders. It updates every 5 minutes. When no reminders are due, it hides +automatically. + +## Natural Language + +You can also type plain English after `@ctx` — the extension routes +common phrases to the correct handler: + +- "What should I work on next?" → `/next` +- "Time to wrap up" → `/wrapup` +- "Show me the status" → `/status` +- "Add a decision" → `/add` +- "Check for drift" → `/drift` + +## Auto-Bootstrap + +If the ctx CLI isn't found on PATH or at the configured path, the +extension automatically downloads the correct platform binary from +[GitHub Releases](https://github.com/ActiveMemory/ctx/releases): + +1. Detects OS and architecture (darwin/linux/windows, amd64/arm64) +2. Fetches the latest release from the GitHub API +3. Downloads and verifies the matching binary +4. Caches it in VS Code's global storage directory + +Subsequent sessions reuse the cached binary. To force a specific version, +set `ctx.executablePath` in your settings. + +## Follow-Up Suggestions + +After each command, Copilot Chat shows context-aware follow-up buttons. +For example: + +- After `/init` → "Show status" or "Generate copilot integration" +- After `/drift` → "Sync context" or "Show status" +- After `/reflect` → "Add decision", "Add learning", or "Wrap up" +- After `/spec` → "Show implementation plan" or "Run verification" ## Prerequisites -- [ctx](https://ctx.ist) CLI installed and available on PATH (or configure `ctx.executablePath`) -- VS Code 1.93+ with GitHub Copilot Chat +- VS Code 1.93+ +- [GitHub Copilot Chat](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot-chat) extension +- [ctx](https://ctx.ist) CLI on PATH — or let the extension auto-download it ## Configuration -| Setting | Default | Description | -|----------------------|---------|----------------------------| -| `ctx.executablePath` | `ctx` | Path to the ctx executable | +| Setting | Default | Description | +|---------|---------|-------------| +| `ctx.executablePath` | `ctx` | Path to the ctx CLI binary. Set this if ctx isn't on PATH and you don't want auto-download. | ## Development @@ -47,8 +184,32 @@ cd editors/vscode npm install npm run watch # Watch mode npm run build # Production build +npm test # Run tests (53 test cases via vitest) ``` +### Architecture + +The extension is a single-file implementation +(`src/extension.ts`, ~3 000 lines) that: + +- Registers a `ChatParticipant` with `@ctx` as the handle +- Routes slash commands to dedicated `handleXxx()` functions +- Each handler calls the ctx CLI via `execFile` and streams the output +- On Windows, uses `shell: true` so PATH resolution works without `.exe` +- Merges stdout/stderr with deduplication (Cobra prints errors to both) +- A `handleFreeform()` function maps natural language to handlers + +### Testing + +Tests live in `src/extension.test.ts` and use vitest with a VS Code API +mock. They verify: + +- All 45 command handlers exist and are callable +- `runCtx` invokes the correct binary with correct arguments +- Platform detection returns valid GOOS/GOARCH values +- Follow-up suggestions are returned after commands +- Edge cases: missing workspace, cancellation, empty output + ## License Apache-2.0 diff --git a/editors/vscode/package.json b/editors/vscode/package.json index d8e3656a..bd284ba2 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "ctx-context", "displayName": "ctx — Persistent Context for AI", "description": "Chat participant (@ctx) for persistent project context across AI coding sessions", - "version": "0.7.0", + "version": "0.9.0", "publisher": "activememory", "license": "Apache-2.0", "homepage": "https://github.com/ActiveMemory/ctx", @@ -108,6 +108,122 @@ { "name": "system", "description": "System diagnostics and bootstrap" + }, + { + "name": "wrapup", + "description": "End-of-session wrap-up with status and drift summary" + }, + { + "name": "remember", + "description": "Recall recent AI sessions for this project" + }, + { + "name": "next", + "description": "Show the next open task from TASKS.md" + }, + { + "name": "brainstorm", + "description": "Browse and develop ideas from ideas/" + }, + { + "name": "reflect", + "description": "Reflect on session — surface items worth persisting" + }, + { + "name": "spec", + "description": "List or scaffold feature specs from templates" + }, + { + "name": "implement", + "description": "Show the implementation plan" + }, + { + "name": "verify", + "description": "Run verification checks (doctor + drift)" + }, + { + "name": "map", + "description": "Show dependency map (go.mod, package.json)" + }, + { + "name": "prompt", + "description": "Browse and view prompt templates" + }, + { + "name": "blog", + "description": "Draft a blog post from recent context" + }, + { + "name": "changelog", + "description": "Show recent commits for changelog" + }, + { + "name": "check-links", + "description": "Audit local links in context files" + }, + { + "name": "journal", + "description": "View journal entries" + }, + { + "name": "consolidate", + "description": "Find duplicate entries in context files" + }, + { + "name": "audit", + "description": "Alignment audit — drift + convention check" + }, + { + "name": "worktree", + "description": "Git worktree management (list, add)" + }, + { + "name": "pause", + "description": "Save session state for later" + }, + { + "name": "resume", + "description": "Restore a paused session" + }, + { + "name": "memory", + "description": "Claude Code memory bridge (sync, status, diff, import, publish)" + }, + { + "name": "decisions", + "description": "List or reindex project decisions" + }, + { + "name": "learnings", + "description": "List or reindex project learnings" + }, + { + "name": "config", + "description": "Manage config profiles (switch, status, schema)" + }, + { + "name": "permissions", + "description": "Backup or restore Claude settings (snapshot, restore)" + }, + { + "name": "changes", + "description": "Show what changed since last session (--since duration)" + }, + { + "name": "deps", + "description": "Show package dependency graph (--format mermaid|table|json)" + }, + { + "name": "guide", + "description": "Quick-reference cheat sheet for ctx (--skills, --commands)" + }, + { + "name": "reindex", + "description": "Regenerate indices for DECISIONS.md and LEARNINGS.md" + }, + { + "name": "why", + "description": "Read the philosophy behind ctx (manifesto, about, invariants)" } ], "disambiguation": [ @@ -127,6 +243,53 @@ ] } ], + "commands": [ + { "command": "ctx.init", "title": "Init", "category": "ctx" }, + { "command": "ctx.status", "title": "Status", "category": "ctx" }, + { "command": "ctx.agent", "title": "Agent", "category": "ctx" }, + { "command": "ctx.drift", "title": "Drift", "category": "ctx" }, + { "command": "ctx.recall", "title": "Recall", "category": "ctx" }, + { "command": "ctx.hook", "title": "Hook", "category": "ctx" }, + { "command": "ctx.add", "title": "Add", "category": "ctx" }, + { "command": "ctx.load", "title": "Load", "category": "ctx" }, + { "command": "ctx.compact", "title": "Compact", "category": "ctx" }, + { "command": "ctx.sync", "title": "Sync", "category": "ctx" }, + { "command": "ctx.complete", "title": "Complete Task", "category": "ctx" }, + { "command": "ctx.remind", "title": "Remind", "category": "ctx" }, + { "command": "ctx.tasks", "title": "Tasks", "category": "ctx" }, + { "command": "ctx.pad", "title": "Pad", "category": "ctx" }, + { "command": "ctx.notify", "title": "Notify", "category": "ctx" }, + { "command": "ctx.system", "title": "System", "category": "ctx" }, + { "command": "ctx.wrapup", "title": "Wrapup", "category": "ctx" }, + { "command": "ctx.remember", "title": "Remember", "category": "ctx" }, + { "command": "ctx.next", "title": "Next Task", "category": "ctx" }, + { "command": "ctx.brainstorm", "title": "Brainstorm", "category": "ctx" }, + { "command": "ctx.reflect", "title": "Reflect", "category": "ctx" }, + { "command": "ctx.spec", "title": "Spec", "category": "ctx" }, + { "command": "ctx.implement", "title": "Implement", "category": "ctx" }, + { "command": "ctx.verify", "title": "Verify", "category": "ctx" }, + { "command": "ctx.map", "title": "Map", "category": "ctx" }, + { "command": "ctx.prompt", "title": "Prompt", "category": "ctx" }, + { "command": "ctx.blog", "title": "Blog", "category": "ctx" }, + { "command": "ctx.changelog", "title": "Changelog", "category": "ctx" }, + { "command": "ctx.checkLinks", "title": "Check Links", "category": "ctx" }, + { "command": "ctx.journal", "title": "Journal", "category": "ctx" }, + { "command": "ctx.consolidate", "title": "Consolidate", "category": "ctx" }, + { "command": "ctx.audit", "title": "Audit", "category": "ctx" }, + { "command": "ctx.worktree", "title": "Worktree", "category": "ctx" }, + { "command": "ctx.pause", "title": "Pause", "category": "ctx" }, + { "command": "ctx.resume", "title": "Resume", "category": "ctx" }, + { "command": "ctx.memory", "title": "Memory", "category": "ctx" }, + { "command": "ctx.decisions", "title": "Decisions", "category": "ctx" }, + { "command": "ctx.learnings", "title": "Learnings", "category": "ctx" }, + { "command": "ctx.config", "title": "Config", "category": "ctx" }, + { "command": "ctx.permissions", "title": "Permissions", "category": "ctx" }, + { "command": "ctx.changes", "title": "Changes", "category": "ctx" }, + { "command": "ctx.deps", "title": "Deps", "category": "ctx" }, + { "command": "ctx.guide", "title": "Guide", "category": "ctx" }, + { "command": "ctx.reindex", "title": "Reindex", "category": "ctx" }, + { "command": "ctx.why", "title": "Why", "category": "ctx" } + ], "configuration": { "title": "ctx", "properties": { diff --git a/editors/vscode/src/extension.test.ts b/editors/vscode/src/extension.test.ts index 4fb12792..739ffafa 100644 --- a/editors/vscode/src/extension.test.ts +++ b/editors/vscode/src/extension.test.ts @@ -263,7 +263,7 @@ describe("handleComplete", () => { await handleComplete(stream as never, "Fix login bug", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["complete", "Fix login bug", "--no-color"], + ["complete", "Fix login bug"], expect.anything(), expect.any(Function) ); @@ -288,7 +288,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "list", "--no-color"], + ["remind", "list"], expect.anything(), expect.any(Function) ); @@ -301,7 +301,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "add Check CI status", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "add", "Check CI status", "--no-color"], + ["remind", "add", "Check CI status"], expect.anything(), expect.any(Function) ); @@ -314,7 +314,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "Check CI status", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "add", "Check CI status", "--no-color"], + ["remind", "add", "Check CI status"], expect.anything(), expect.any(Function) ); @@ -327,7 +327,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "list", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "list", "--no-color"], + ["remind", "list"], expect.anything(), expect.any(Function) ); @@ -340,7 +340,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "dismiss 2", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "dismiss", "2", "--no-color"], + ["remind", "dismiss", "2"], expect.anything(), expect.any(Function) ); @@ -353,7 +353,7 @@ describe("handleRemind", () => { await handleRemind(stream as never, "dismiss", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["remind", "dismiss", "--all", "--no-color"], + ["remind", "dismiss", "--all"], expect.anything(), expect.any(Function) ); @@ -394,7 +394,7 @@ describe("handleTasks", () => { await handleTasks(stream as never, "archive", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["tasks", "archive", "--no-color"], + ["tasks", "archive"], expect.anything(), expect.any(Function) ); @@ -408,7 +408,7 @@ describe("handleTasks", () => { await handleTasks(stream as never, "snapshot pre-refactor", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["tasks", "snapshot", "pre-refactor", "--no-color"], + ["tasks", "snapshot", "pre-refactor"], expect.anything(), expect.any(Function) ); @@ -421,7 +421,7 @@ describe("handleTasks", () => { await handleTasks(stream as never, "snapshot", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["tasks", "snapshot", "--no-color"], + ["tasks", "snapshot"], expect.anything(), expect.any(Function) ); @@ -454,7 +454,7 @@ describe("handlePad", () => { await handlePad(stream as never, "", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "--no-color"], + ["pad"], expect.anything(), expect.any(Function) ); @@ -467,7 +467,7 @@ describe("handlePad", () => { await handlePad(stream as never, "add my secret note", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "add", "my secret note", "--no-color"], + ["pad", "add", "my secret note"], expect.anything(), expect.any(Function) ); @@ -487,7 +487,7 @@ describe("handlePad", () => { await handlePad(stream as never, "show 1", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "show", "1", "--no-color"], + ["pad", "show", "1"], expect.anything(), expect.any(Function) ); @@ -500,7 +500,7 @@ describe("handlePad", () => { await handlePad(stream as never, "rm 2", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "rm", "2", "--no-color"], + ["pad", "rm", "2"], expect.anything(), expect.any(Function) ); @@ -520,7 +520,7 @@ describe("handlePad", () => { await handlePad(stream as never, "edit 1 new text", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "edit", "1", "new", "text", "--no-color"], + ["pad", "edit", "1", "new", "text"], expect.anything(), expect.any(Function) ); @@ -533,7 +533,7 @@ describe("handlePad", () => { await handlePad(stream as never, "mv 1 3", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["pad", "mv", "1", "3", "--no-color"], + ["pad", "mv", "1", "3"], expect.anything(), expect.any(Function) ); @@ -574,7 +574,7 @@ describe("handleNotify", () => { await handleNotify(stream as never, "setup", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["notify", "setup", "--no-color"], + ["notify", "setup"], expect.anything(), expect.any(Function) ); @@ -588,7 +588,7 @@ describe("handleNotify", () => { await handleNotify(stream as never, "test", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["notify", "test", "--no-color"], + ["notify", "test"], expect.anything(), expect.any(Function) ); @@ -601,7 +601,7 @@ describe("handleNotify", () => { await handleNotify(stream as never, "build done --event build", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["notify", "build", "done", "--event", "build", "--no-color"], + ["notify", "build", "done", "--event", "build"], expect.anything(), expect.any(Function) ); @@ -650,7 +650,7 @@ describe("handleSystem", () => { await handleSystem(stream as never, "resources", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["system", "resources", "--no-color"], + ["system", "resources"], expect.anything(), expect.any(Function) ); @@ -664,7 +664,7 @@ describe("handleSystem", () => { await handleSystem(stream as never, "bootstrap", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["system", "bootstrap", "--no-color"], + ["system", "bootstrap"], expect.anything(), expect.any(Function) ); @@ -678,7 +678,7 @@ describe("handleSystem", () => { await handleSystem(stream as never, "message list", "/test", token); expect(cp.execFile).toHaveBeenCalledWith( "ctx", - ["system", "message", "list", "--no-color"], + ["system", "message", "list"], expect.anything(), expect.any(Function) ); diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 99391619..19757845 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -20,6 +20,73 @@ 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; + +// --- Detection ring: deny patterns for governance --- +const DENY_COMMAND_PATTERNS: RegExp[] = [ + /\bsudo\s/, + /\brm\s+-rf\s+[/~]/, + /\bgit\s+push\b/, + /\bgit\s+reset\s+--hard\b/, + /\bcurl\s/, + /\bwget\s/, + /\bchmod\s+777\b/, +]; +const DENY_COMMAND_SCRIPT_PATTERNS: RegExp[] = [ + /hack\/[^/]+\.sh\b/, + /hack\\[^\\]+\.sh\b/, +]; +const SENSITIVE_FILE_PATTERNS: RegExp[] = [ + /\.env$/, + /\.env\./, + /\.pem$/, + /\.key$/, + /credentials/i, + /secret/i, +]; + +/** + * Record a governance violation to .context/state/violations.json. + * The MCP governance engine reads this file and escalates to CRITICAL + * warnings in tool responses. + */ +function recordViolation( + cwd: string, + kind: string, + detail: string +): void { + try { + const stateDir = path.join(cwd, ".context", "state"); + if (!fs.existsSync(stateDir)) { + fs.mkdirSync(stateDir, { recursive: true }); + } + const violationsPath = path.join(stateDir, "violations.json"); + let violations: { entries: Array<{ kind: string; detail: string; timestamp: string }> } = { + entries: [], + }; + if (fs.existsSync(violationsPath)) { + try { + violations = JSON.parse(fs.readFileSync(violationsPath, "utf-8")); + } catch { + // corrupt file — reset + } + } + violations.entries.push({ + kind, + detail, + timestamp: new Date().toISOString(), + }); + // Keep only the last 50 violations + if (violations.entries.length > 50) { + violations.entries = violations.entries.slice(-50); + } + fs.writeFileSync(violationsPath, JSON.stringify(violations, null, 2), "utf-8"); + } catch { + // Non-fatal — governance is advisory + } +} + function getCtxPath(): string { if (resolvedCtxPath) { return resolvedCtxPath; @@ -263,6 +330,21 @@ async function bootstrap(): Promise { return bootstrapPromise; } +/** + * Merge stdout and stderr without duplicating lines that appear in both. + * Cobra prints errors to both streams — naive concatenation doubles them. + */ +function mergeOutput(stdout: string, stderr: string): string { + const out = stdout.trim(); + const err = stderr.trim(); + if (!out) return err; + if (!err) return out; + // If stderr content already appears in stdout, skip it + if (out.includes(err)) return out; + if (err.includes(out)) return err; + return out + "\n" + err; +} + function runCtx( args: string[], cwd?: string, @@ -307,6 +389,13 @@ function runCtx( }); } +/** + * Check if .context/ directory exists in the workspace root. + */ +function hasContextDir(cwd: string): boolean { + return fs.existsSync(path.join(cwd, ".context")); +} + async function handleInit( stream: vscode.ChatResponseStream, cwd: string, @@ -314,8 +403,8 @@ async function handleInit( ): Promise { stream.progress("Initializing .context/ directory..."); try { - const { stdout, stderr } = await runCtx(["init", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["init", "--caller", "vscode"], cwd, token); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } @@ -325,11 +414,11 @@ async function handleInit( stream.progress("Generating Copilot instructions..."); try { const hookResult = await runCtx( - ["hook", "copilot", "--write", "--no-color"], + ["hook", "copilot", "--write"], cwd, token ); - const hookOutput = (hookResult.stdout + hookResult.stderr).trim(); + const hookOutput = mergeOutput(hookResult.stdout, hookResult.stderr); if (hookOutput) { stream.markdown( "\n**Copilot integration:**\n```\n" + hookOutput + "\n```" @@ -352,6 +441,9 @@ async function handleInit( "`.context/` directory initialized. Run `@ctx /status` to see your project context." ); } + + // Fire session-start since activate() missed it (no .context/ at activation time) + runCtx(["system", "session-event", "--type", "start", "--caller", "vscode"], cwd).catch(() => {}); } catch (err: unknown) { stream.markdown( `**Error:** Failed to initialize context.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` @@ -367,8 +459,8 @@ async function handleStatus( ): Promise { stream.progress("Checking context status..."); try { - const { stdout, stderr } = await runCtx(["status", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["status"], cwd, token); + const output = mergeOutput(stdout, stderr); stream.markdown("```\n" + output + "\n```"); } catch (err: unknown) { stream.markdown( @@ -380,13 +472,19 @@ async function handleStatus( async function handleAgent( stream: vscode.ChatResponseStream, + prompt: string, cwd: string, token: vscode.CancellationToken ): Promise { stream.progress("Generating AI-ready context packet..."); try { - const { stdout, stderr } = await runCtx(["agent", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const args = ["agent"]; + const budgetMatch = prompt.match(/(?:--budget\s+|budget\s+)(\d+)/); + if (budgetMatch) { + args.splice(1, 0, "--budget", budgetMatch[1]); + } + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); stream.markdown(output); } catch (err: unknown) { stream.markdown( @@ -403,8 +501,8 @@ async function handleDrift( ): Promise { stream.progress("Detecting context drift..."); try { - const { stdout, stderr } = await runCtx(["drift", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["drift"], cwd, token); + const output = mergeOutput(stdout, stderr); stream.markdown("```\n" + output + "\n```"); } catch (err: unknown) { stream.markdown( @@ -420,18 +518,74 @@ async function handleRecall( cwd: string, token: vscode.CancellationToken ): Promise { - stream.progress("Searching session history..."); - try { - const args = ["recall", "list", "--no-color"]; - if (prompt.trim()) { - args.push("--query", prompt.trim()); + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + const rest = parts.slice(1).join(" "); + + let args: string[]; + let progressMsg: string; + + switch (subcmd) { + case "show": + if (!rest) { + stream.markdown("**Usage:** `@ctx /recall show `"); + return { metadata: { command: "recall" } }; + } + args = ["recall", "show", rest]; + progressMsg = "Loading session details..."; + break; + case "export": + if (!rest) { + stream.markdown("**Usage:** `@ctx /recall export `"); + return { metadata: { command: "recall" } }; + } + args = ["recall", "export", rest]; + progressMsg = "Exporting session..."; + break; + case "lock": + if (!rest) { + stream.markdown("**Usage:** `@ctx /recall lock `"); + return { metadata: { command: "recall" } }; + } + args = ["recall", "lock", rest]; + progressMsg = "Locking session..."; + break; + case "unlock": + if (!rest) { + stream.markdown("**Usage:** `@ctx /recall unlock `"); + return { metadata: { command: "recall" } }; + } + args = ["recall", "unlock", rest]; + progressMsg = "Unlocking session..."; + break; + case "sync": + args = ["recall", "sync"]; + progressMsg = "Syncing recall database..."; + break; + case "list": + default: { + args = ["recall", "list"]; + progressMsg = "Searching session history..."; + const limitMatch = prompt.match(/(?:--limit\s+|limit\s+)(\d+)/); + if (limitMatch) { + args.push("--limit", limitMatch[1]); + } + const query = (subcmd === "list" ? rest : prompt.trim()).replace(/--limit\s+\d+/, "").replace(/limit\s+\d+/, "").trim(); + if (query) { + args.push("--query", query); + } + break; } + } + + stream.progress(progressMsg); + try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { - stream.markdown("No session history found."); + stream.markdown(subcmd === "list" || !subcmd ? "No session history found." : "No output."); } } catch (err: unknown) { stream.markdown( @@ -455,7 +609,6 @@ async function handleHook( if (!preview) { args.push("--write"); } - args.push("--no-color"); stream.progress( preview @@ -464,7 +617,7 @@ async function handleHook( ); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -495,7 +648,7 @@ async function handleAdd( if (!type) { stream.markdown( "**Usage:** `@ctx /add `\n\n" + - "Types: `task`, `decision`, `learning`\n\n" + + "Types: `task`, `decision`, `learning`, `convention`\n\n" + "Example: `@ctx /add task Implement user authentication`" ); return { metadata: { command: "add" } }; @@ -508,7 +661,7 @@ async function handleAdd( args.push(content); } const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -529,8 +682,8 @@ async function handleLoad( ): Promise { stream.progress("Loading assembled context..."); try { - const { stdout, stderr } = await runCtx(["load", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["load"], cwd, token); + const output = mergeOutput(stdout, stderr); stream.markdown(output); } catch (err: unknown) { stream.markdown( @@ -547,8 +700,8 @@ async function handleCompact( ): Promise { stream.progress("Compacting context..."); try { - const { stdout, stderr } = await runCtx(["compact", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["compact"], cwd, token); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -569,8 +722,8 @@ async function handleSync( ): Promise { stream.progress("Syncing context with codebase..."); try { - const { stdout, stderr } = await runCtx(["sync", "--no-color"], cwd, token); - const output = (stdout + stderr).trim(); + const { stdout, stderr } = await runCtx(["sync"], cwd, token); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -602,11 +755,11 @@ async function handleComplete( stream.progress("Marking task as completed..."); try { const { stdout, stderr } = await runCtx( - ["complete", taskRef, "--no-color"], + ["complete", taskRef], cwd, token ); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -659,12 +812,11 @@ async function handleRemind( } break; } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -711,12 +863,11 @@ async function handleTasks( ); return { metadata: { command: "tasks" } }; } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -780,18 +931,37 @@ async function handlePad( args = ["pad", "mv", ...parts.slice(1)]; progressMsg = "Moving scratchpad entry..."; break; + case "resolve": + args = ["pad", "resolve"]; + progressMsg = "Resolving scratchpad conflicts..."; + break; + case "import": + if (!rest) { + stream.markdown("**Usage:** `@ctx /pad import `"); + return { metadata: { command: "pad" } }; + } + args = ["pad", "import", rest]; + progressMsg = "Importing scratchpad archive..."; + break; + case "export": + args = rest ? ["pad", "export", rest] : ["pad", "export"]; + progressMsg = "Exporting scratchpad..."; + break; + case "merge": + args = rest ? ["pad", "merge", rest] : ["pad", "merge"]; + progressMsg = "Merging scratchpads..."; + break; default: // No subcommand or unknown — list all entries args = ["pad"]; progressMsg = "Listing scratchpad..."; break; } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -846,12 +1016,11 @@ async function handleNotify( break; } } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -892,28 +1061,48 @@ async function handleSystem( args = ["system", "bootstrap"]; progressMsg = "Running bootstrap..."; break; + case "doctor": + args = ["doctor"]; + progressMsg = "Running diagnostics..."; + break; case "message": args = ["system", "message", ...parts.slice(1)]; + if (parts.length < 2 || !["list", "show", "edit", "reset"].includes(parts[1]?.toLowerCase())) { + args = ["system", "message", "list"]; + } progressMsg = "Managing hook messages..."; break; + case "stats": + args = ["system", "stats"]; + progressMsg = "Loading system stats..."; + break; + case "backup": + args = ["system", "backup"]; + progressMsg = "Running backup..."; + break; default: stream.markdown( "**Usage:** `@ctx /system `\n\n" + "| Subcommand | Description |\n" + "|------------|-------------|\n" + "| `resources` | Show system resource usage |\n" + + "| `doctor` | Diagnose context health |\n" + "| `bootstrap` | Print context location for AI agents |\n" + - "| `message list\|show\|edit\|reset` | Manage hook messages |\n\n" + - "Example: `@ctx /system resources` or `@ctx /system bootstrap`" + "| `stats` | Show session and context stats |\n" + + "| `backup` | Backup context data |\n" + + "| `message list` | List hook message templates |\n" + + "| `message show ` | Show a hook message |\n" + + "| `message edit ` | Edit a hook message |\n" + + "| `message reset ` | Reset a hook message |\n\n" + + "Example: `@ctx /system resources` or `@ctx /system message list`" ); return { metadata: { command: "system" } }; } - args.push("--no-color"); stream.progress(progressMsg); try { const { stdout, stderr } = await runCtx(args, cwd, token); - const output = (stdout + stderr).trim(); + const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); } else { @@ -927,149 +1116,1506 @@ async function handleSystem( return { metadata: { command: "system" } }; } -async function handleFreeform( - request: vscode.ChatRequest, +async function handleWrapup( stream: vscode.ChatResponseStream, cwd: string, token: vscode.CancellationToken ): Promise { - const prompt = request.prompt.trim().toLowerCase(); + stream.progress("Generating session wrap-up..."); + try { + // Gather status + drift in parallel for a comprehensive wrap-up + const [statusResult, driftResult] = await Promise.all([ + runCtx(["status"], cwd, token), + runCtx(["drift"], cwd, token), + ]); + const statusOutput = mergeOutput(statusResult.stdout, statusResult.stderr); + const driftOutput = mergeOutput(driftResult.stdout, driftResult.stderr); + + stream.markdown("## Session Wrap-up\n\n"); + stream.markdown("### Context Status\n```\n" + statusOutput + "\n```\n\n"); + stream.markdown("### Drift Check\n```\n" + driftOutput + "\n```\n\n"); + stream.markdown( + "**Before closing:** Review any open tasks in `.context/TASKS.md`. " + + "Record decisions or learnings with `@ctx /add decision ...` or `@ctx /add learning ...`.\n" + ); - // Try to infer intent from natural language - if (prompt.includes("init")) { - return handleInit(stream, cwd, token); - } - if (prompt.includes("status")) { - return handleStatus(stream, cwd, token); - } - if (prompt.includes("drift")) { - return handleDrift(stream, cwd, token); - } - if (prompt.includes("recall") || prompt.includes("session") || prompt.includes("history")) { - return handleRecall(stream, request.prompt, cwd, token); - } - if (prompt.includes("complete") || prompt.includes("done") || prompt.includes("finish")) { - return handleComplete(stream, request.prompt, cwd, token); - } - if (prompt.includes("remind")) { - return handleRemind(stream, request.prompt, cwd, token); - } - if (prompt.includes("task")) { - return handleTasks(stream, request.prompt, cwd, token); - } - if (prompt.includes("pad") || prompt.includes("scratchpad") || prompt.includes("scratch")) { - return handlePad(stream, request.prompt, cwd, token); - } - if (prompt.includes("notify") || prompt.includes("webhook")) { - return handleNotify(stream, request.prompt, cwd, token); - } - if (prompt.includes("system") || prompt.includes("resource") || prompt.includes("bootstrap")) { - return handleSystem(stream, request.prompt, cwd, token); - } + // 2.15: Journal audit + try { + const stateDir = path.join(cwd, ".context", "state"); + if (fs.existsSync(stateDir)) { + const journalFiles = fs.readdirSync(stateDir).filter((f) => f.includes("journal") || f.includes("event")); + if (journalFiles.length > 0) { + const latest = journalFiles.sort().slice(-1)[0]; + const content = fs.readFileSync(path.join(stateDir, latest), "utf-8"); + const lines = content.split("\n").filter((l) => l.trim()); + stream.markdown(`\n### Journal\n${lines.length} entries in \`${latest}\`. `); + const today = new Date().toISOString().split("T")[0]; + if (!content.includes(today)) { + stream.markdown("**No entries today.** Consider logging your work.\n"); + } else { + stream.markdown("Today's entries present.\n"); + } + } + } + } catch { /* non-fatal */ } - // Default: show help with available commands - stream.markdown( - "## ctx — Persistent Context for AI\n\n" + - "Available commands:\n\n" + - "| Command | Description |\n" + - "|---------|-------------|\n" + - "| `/init` | Initialize `.context/` directory |\n" + - "| `/status` | Show context summary |\n" + - "| `/agent` | Print AI-ready context packet |\n" + - "| `/drift` | Detect stale or invalid context |\n" + - "| `/recall` | Browse session history |\n" + - "| `/hook` | Generate tool integration configs |\n" + - "| `/add` | Add task, decision, or learning |\n" + - "| `/load` | Output assembled context |\n" + - "| `/compact` | Archive completed tasks |\n" + - "| `/sync` | Reconcile context with codebase |\n" + - "| `/complete` | Mark a task as completed |\n" + - "| `/remind` | Manage session reminders |\n" + - "| `/tasks` | Archive or snapshot tasks |\n" + - "| `/pad` | Encrypted scratchpad |\n" + - "| `/notify` | Webhook notifications |\n" + - "| `/system` | System diagnostics |\n\n" + - "Example: `@ctx /status` or `@ctx /add task Fix login bug`" - ); - return { metadata: { command: "help" } }; + // 2.18: Memory drift + try { + const memDir = path.join(cwd, ".context", "memory"); + if (fs.existsSync(memDir)) { + const memFiles = fs.readdirSync(memDir).filter((f) => f.endsWith(".md")); + if (memFiles.length > 0) { + const contextFiles = ["DECISIONS.md", "LEARNINGS.md", "CONVENTIONS.md", "TASKS.md"]; + const drifts: string[] = []; + for (const memFile of memFiles) { + const memStat = fs.statSync(path.join(memDir, memFile)); + for (const ctxFile of contextFiles) { + const ctxPath = path.join(cwd, ".context", ctxFile); + if (fs.existsSync(ctxPath)) { + const ctxStat = fs.statSync(ctxPath); + if (memStat.mtimeMs < ctxStat.mtimeMs - 86400000) { + drifts.push(`\`memory/${memFile}\` older than \`${ctxFile}\``); + } + } + } + } + if (drifts.length > 0) { + stream.markdown("\n### Memory Drift\n" + drifts.map((d) => `- ${d}`).join("\n") + "\n"); + } + } + } + } catch { /* non-fatal */ } + + // Record session end + runCtx(["system", "session-event", "--type", "end", "--caller", "vscode"], cwd).catch(() => {}); + } catch (err: unknown) { + stream.markdown( + `**Error:** Wrap-up failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "wrapup" } }; } -const handler: vscode.ChatRequestHandler = async ( - request: vscode.ChatRequest, - _context: vscode.ChatContext, +async function handleRemember( stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, token: vscode.CancellationToken -): Promise => { - const cwd = getWorkspaceRoot(); - if (!cwd) { +): Promise { + stream.progress("Loading recent sessions..."); + try { + const args = ["recall", "list"]; + const limitMatch = prompt.match(/(?:--limit\s+|limit\s+)(\d+)/); + args.push("--limit", limitMatch ? limitMatch[1] : "3"); + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown("## Recent Sessions\n\n```\n" + output + "\n```"); + } else { + stream.markdown("No recent sessions found. Start working and sessions will be recorded."); + } + } catch (err: unknown) { stream.markdown( - "**Error:** No workspace folder is open. Open a project folder first." + `**Error:** Failed to load sessions.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); - return { metadata: { command: request.command || "none" } }; } + return { metadata: { command: "remember" } }; +} - // Auto-bootstrap: ensure ctx binary is available before any command +async function handleNext( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Finding next task..."); try { - stream.progress("Checking ctx installation..."); - await bootstrap(); + const tasksPath = path.join(cwd, ".context", "TASKS.md"); + if (!fs.existsSync(tasksPath)) { + stream.markdown("No `.context/TASKS.md` found. Add tasks with `@ctx /add task ...`."); + return { metadata: { command: "next" } }; + } + const content = fs.readFileSync(tasksPath, "utf-8"); + const lines = content.split("\n"); + const openTasks = lines.filter((l) => /^\s*-\s*\[ \]/.test(l)); + if (openTasks.length === 0) { + stream.markdown("All tasks completed! Add new tasks with `@ctx /add task ...`."); + } else { + stream.markdown("## Next Task\n\n" + openTasks[0].trim() + "\n"); + if (openTasks.length > 1) { + stream.markdown( + `\n*${openTasks.length - 1} more open task(s) remaining.*` + ); + } + } } catch (err: unknown) { stream.markdown( - `**Error:** ctx CLI not found and auto-install failed.\n\n` + - `\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`\n\n` + - `Install manually: \`go install github.com/ActiveMemory/ctx/cmd/ctx@latest\` ` + - `or download from [GitHub Releases](https://github.com/${GITHUB_REPO}/releases).` + `**Error:** Failed to read tasks.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` ); - return { metadata: { command: request.command || "none" } }; } + return { metadata: { command: "next" } }; +} - switch (request.command) { - case "init": - return handleInit(stream, cwd, token); - case "status": - return handleStatus(stream, cwd, token); - case "agent": - return handleAgent(stream, cwd, token); - case "drift": - return handleDrift(stream, cwd, token); - case "recall": - return handleRecall(stream, request.prompt, cwd, token); - case "hook": - return handleHook(stream, request.prompt, cwd, token); - case "add": - return handleAdd(stream, request.prompt, cwd, token); - case "load": - return handleLoad(stream, cwd, token); - case "compact": - return handleCompact(stream, cwd, token); - case "sync": - return handleSync(stream, cwd, token); - case "complete": - return handleComplete(stream, request.prompt, cwd, token); - case "remind": - return handleRemind(stream, request.prompt, cwd, token); - case "tasks": - return handleTasks(stream, request.prompt, cwd, token); - case "pad": - return handlePad(stream, request.prompt, cwd, token); - case "notify": - return handleNotify(stream, request.prompt, cwd, token); - case "system": - return handleSystem(stream, request.prompt, cwd, token); - default: - return handleFreeform(request, stream, cwd, token); +async function handleBrainstorm( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Loading ideas..."); + try { + const ideasDir = path.join(cwd, "ideas"); + if (!fs.existsSync(ideasDir)) { + stream.markdown( + "No `ideas/` directory found. Run `@ctx /init` first, then add ideas to `ideas/README.md`." + ); + return { metadata: { command: "brainstorm" } }; + } + const readmePath = path.join(ideasDir, "README.md"); + if (fs.existsSync(readmePath)) { + const content = fs.readFileSync(readmePath, "utf-8").trim(); + stream.markdown("## Current Ideas\n\n" + content + "\n"); + } else { + stream.markdown("Ideas directory exists but `ideas/README.md` is empty.\n"); + } + // List any other files in ideas/ + const files = fs.readdirSync(ideasDir).filter((f) => f !== "README.md" && f.endsWith(".md")); + if (files.length > 0) { + stream.markdown( + "\n### Idea Files\n" + files.map((f) => `- \`ideas/${f}\``).join("\n") + "\n" + ); + } + if (prompt.trim()) { + stream.markdown( + "\n---\n\nTo develop **" + prompt.trim() + "** into a spec, create `specs/" + + prompt.trim().toLowerCase().replace(/\s+/g, "-") + ".md` with your design." + ); + } else { + stream.markdown( + "\n---\nTo develop an idea into a spec, run `@ctx /brainstorm `." + ); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to load ideas.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); } -}; - -export function activate(extensionContext: vscode.ExtensionContext) { - // Store extension context for auto-bootstrap binary downloads - extensionCtx = extensionContext; + return { metadata: { command: "brainstorm" } }; +} - // Kick off background bootstrap — don't block activation - bootstrap().catch(() => { - // Errors will surface when user invokes a command - }); +async function handleReflect( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Reflecting on session..."); + try { + const [statusResult, driftResult] = await Promise.all([ + runCtx(["status"], cwd, token), + runCtx(["drift"], cwd, token), + ]); + const statusOutput = mergeOutput(statusResult.stdout, statusResult.stderr); + const driftOutput = mergeOutput(driftResult.stdout, driftResult.stderr); + + stream.markdown("## Session Reflection\n\n"); + stream.markdown("### Current State\n```\n" + statusOutput + "\n```\n\n"); + if (driftOutput) { + stream.markdown("### Drift Detected\n```\n" + driftOutput + "\n```\n\n"); + } + stream.markdown( + "### Worth Persisting?\n\n" + + "Consider what happened this session:\n" + + "- **Decision?** Did you make a design choice? → `@ctx /add decision ...`\n" + + "- **Learning?** Hit a gotcha or discovered something? → `@ctx /add learning ...`\n" + + "- **Convention?** Established a pattern? → `@ctx /add convention ...`\n" + + "- **Task?** Identified work for later? → `@ctx /add task ...`\n" + ); - const participant = vscode.chat.createChatParticipant( + // 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 */ } + + // 2.18: Memory drift — compare memory with context files + try { + const memDir = path.join(cwd, ".context", "memory"); + if (fs.existsSync(memDir)) { + const memFiles = fs.readdirSync(memDir).filter((f) => f.endsWith(".md")); + if (memFiles.length > 0) { + const contextFiles = ["DECISIONS.md", "LEARNINGS.md", "CONVENTIONS.md", "TASKS.md"]; + const drifts: string[] = []; + for (const memFile of memFiles) { + const memStat = fs.statSync(path.join(memDir, memFile)); + for (const ctxFile of contextFiles) { + const ctxPath = path.join(cwd, ".context", ctxFile); + if (fs.existsSync(ctxPath)) { + const ctxStat = fs.statSync(ctxPath); + // Memory older than context by 24+ hours = potentially stale + if (memStat.mtimeMs < ctxStat.mtimeMs - 86400000) { + drifts.push(`\`memory/${memFile}\` older than \`${ctxFile}\``); + } + } + } + } + if (drifts.length > 0) { + stream.markdown("\n### Memory Drift\n" + drifts.map((d) => `- ${d}`).join("\n") + "\n"); + } + } + } + } catch { /* non-fatal */ } + } catch (err: unknown) { + stream.markdown( + `**Error:** Reflection failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "reflect" } }; +} + +async function handleSpec( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Loading specs..."); + try { + const specsDir = path.join(cwd, "specs"); + const tplDir = path.join(specsDir, "tpl"); + if (!prompt.trim()) { + const specs = fs.existsSync(specsDir) ? fs.readdirSync(specsDir).filter((f) => f.endsWith(".md")) : []; + const templates = fs.existsSync(tplDir) ? fs.readdirSync(tplDir).filter((f) => f.endsWith(".md")) : []; + stream.markdown("## Specs\n\n"); + if (specs.length) { + stream.markdown("### Existing\n" + specs.map((f) => `- \`specs/${f}\``).join("\n") + "\n\n"); + } + if (templates.length) { + stream.markdown("### Templates\n" + templates.map((f) => `- \`specs/tpl/${f}\``).join("\n") + "\n\nScaffold: `@ctx /spec `\n"); + } else { + stream.markdown("No templates in `specs/tpl/`. Create one to enable scaffolding.\n"); + } + } else { + const name = prompt.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9\-]/g, ""); + const target = path.join(specsDir, `${name}.md`); + if (fs.existsSync(target)) { + const content = fs.readFileSync(target, "utf-8"); + stream.markdown(`\`specs/${name}.md\` exists:\n\n\`\`\`markdown\n${content}\n\`\`\``); + } else { + const templates = fs.existsSync(tplDir) ? fs.readdirSync(tplDir).filter((f) => f.endsWith(".md")) : []; + let content: string; + if (templates.length > 0) { + content = fs.readFileSync(path.join(tplDir, templates[0]), "utf-8") + .replace(/\{\{name\}\}/gi, name) + .replace(/\{\{title\}\}/gi, prompt.trim()); + } else { + content = `# ${prompt.trim()}\n\n## Problem\n\n## Proposal\n\n## Implementation\n\n## Verification\n`; + } + if (!fs.existsSync(specsDir)) { fs.mkdirSync(specsDir, { recursive: true }); } + fs.writeFileSync(target, content, "utf-8"); + stream.markdown(`Created \`specs/${name}.md\`.\n\n\`\`\`markdown\n${content}\n\`\`\``); + } + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "spec" } }; +} + +async function handleImplement( + stream: vscode.ChatResponseStream, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Loading implementation plan..."); + try { + const planPath = path.join(cwd, "IMPLEMENTATION_PLAN.md"); + if (!fs.existsSync(planPath)) { + stream.markdown("No `IMPLEMENTATION_PLAN.md` found in project root."); + return { metadata: { command: "implement" } }; + } + const content = fs.readFileSync(planPath, "utf-8"); + const lines = content.split("\n"); + const done = lines.filter((l) => /^\s*-\s*\[x\]/i.test(l)).length; + const open = lines.filter((l) => /^\s*-\s*\[ \]/.test(l)).length; + const total = done + open; + if (total > 0) { + stream.markdown(`## Implementation Plan (${done}/${total} steps done)\n\n`); + const nextStep = lines.find((l) => /^\s*-\s*\[ \]/.test(l)); + if (nextStep) { + stream.markdown("**Next step:** " + nextStep.replace(/^\s*-\s*\[ \]\s*/, "").trim() + "\n\n---\n\n"); + } + } else { + stream.markdown("## Implementation Plan\n\n"); + } + stream.markdown(content); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "implement" } }; +} + +async function handleVerify( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Running verification checks..."); + try { + const results: string[] = []; + try { + const { stdout, stderr } = await runCtx(["doctor"], cwd, token); + results.push("### Context Health\n```\n" + mergeOutput(stdout, stderr) + "\n```"); + } catch (err: unknown) { + results.push("### Context Health\n```\nFailed: " + (err instanceof Error ? err.message : String(err)) + "\n```"); + } + try { + const { stdout, stderr } = await runCtx(["drift"], cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { results.push("### Drift\n```\n" + output + "\n```"); } + } catch { /* non-fatal */ } + stream.markdown("## Verification Report\n\n" + results.join("\n\n")); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "verify" } }; +} + +async function handleMap( + stream: vscode.ChatResponseStream, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Mapping dependencies..."); + try { + const results: string[] = []; + const gomodPath = path.join(cwd, "go.mod"); + if (fs.existsSync(gomodPath)) { + const content = fs.readFileSync(gomodPath, "utf-8"); + const moduleLine = content.match(/^module\s+(.+)$/m); + const requires = content.match(/require\s*\(([\s\S]*?)\)/); + results.push("### Go Module: " + (moduleLine ? moduleLine[1] : "unknown")); + if (requires) { + const deps = requires[1].trim().split("\n").filter((l) => l.trim() && !l.trim().startsWith("//")); + results.push(`${deps.length} dependencies:\n\`\`\`\n${deps.map((d) => d.trim()).join("\n")}\n\`\`\``); + } + } + const pkgPath = path.join(cwd, "package.json"); + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); + const deps = Object.keys(pkg.dependencies || {}); + const devDeps = Object.keys(pkg.devDependencies || {}); + results.push("### Node Package: " + (pkg.name || "unknown")); + if (deps.length) { results.push(`Dependencies: ${deps.join(", ")}`); } + if (devDeps.length) { results.push(`Dev: ${devDeps.join(", ")}`); } + } + if (results.length === 0) { + stream.markdown("No `go.mod` or `package.json` found."); + } else { + stream.markdown("## Dependency Map\n\n" + results.join("\n\n")); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "map" } }; +} + +async function handlePromptTpl( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Loading prompt templates..."); + try { + const promptsDir = path.join(cwd, ".context", "prompts"); + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + const rest = parts.slice(1).join(" "); + + if (subcmd === "add") { + if (!rest) { + stream.markdown("**Usage:** `@ctx /prompt add `\n\nAdds a file as a prompt template."); + return { metadata: { command: "prompt" } }; + } + const args = ["prompt", "add", rest]; + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : `Template **${rest}** added.`); + return { metadata: { command: "prompt" } }; + } + + if (subcmd === "rm" || subcmd === "remove" || subcmd === "delete") { + if (!rest) { + stream.markdown("**Usage:** `@ctx /prompt rm `"); + return { metadata: { command: "prompt" } }; + } + const args = ["prompt", "rm", rest]; + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : `Template **${rest}** removed.`); + return { metadata: { command: "prompt" } }; + } + + if (!fs.existsSync(promptsDir)) { + stream.markdown("No `.context/prompts/` directory found."); + return { metadata: { command: "prompt" } }; + } + const files = fs.readdirSync(promptsDir).filter((f) => f.endsWith(".md")); + if (!prompt.trim()) { + if (files.length === 0) { + stream.markdown("`.context/prompts/` is empty. Add prompt templates with `@ctx /prompt add `."); + } else { + stream.markdown("## Prompt Templates\n\n" + files.map((f) => `- \`${f}\``).join("\n") + + "\n\nView: `@ctx /prompt `\nAdd: `@ctx /prompt add `\nRemove: `@ctx /prompt rm `"); + } + } else { + const name = prompt.trim(); + const match = files.find((f) => f.toLowerCase().includes(name.toLowerCase())); + if (match) { + const content = fs.readFileSync(path.join(promptsDir, match), "utf-8"); + stream.markdown(`## ${match}\n\n${content}`); + } else { + stream.markdown(`No template matching "${name}". Available: ${files.join(", ")}`); + } + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "prompt" } }; +} + +async function handleBlog( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Drafting blog post..."); + try { + const sections: string[] = []; + const decisionsPath = path.join(cwd, ".context", "DECISIONS.md"); + const learningsPath = path.join(cwd, ".context", "LEARNINGS.md"); + if (fs.existsSync(decisionsPath)) { + const entries = fs.readFileSync(decisionsPath, "utf-8").split("\n").filter((l) => l.startsWith("- ")); + if (entries.length) { sections.push("## Key Decisions\n\n" + entries.slice(-5).join("\n")); } + } + if (fs.existsSync(learningsPath)) { + const entries = fs.readFileSync(learningsPath, "utf-8").split("\n").filter((l) => l.startsWith("- ")); + if (entries.length) { sections.push("## Lessons Learned\n\n" + entries.slice(-5).join("\n")); } + } + const title = prompt.trim() || "Untitled Post"; + const date = new Date().toISOString().split("T")[0]; + stream.markdown( + `# Blog Draft: ${title}\n\n*Date: ${date}*\n\n` + + (sections.length ? sections.join("\n\n") : "No decisions or learnings to draw from.") + + "\n\n---\n*Edit and refine this draft, then save to `docs/blog/`.*" + ); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "blog" } }; +} + +async function handleChangelog( + stream: vscode.ChatResponseStream, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Generating changelog..."); + try { + const result = await new Promise((resolve, reject) => { + execFile("git", ["log", "--oneline", "--no-decorate", "-20"], { cwd }, (err, stdout) => { + if (err) { reject(err); } else { resolve(stdout); } + }); + }); + if (result.trim()) { + stream.markdown("## Recent Commits\n\n```\n" + result.trim() + "\n```\n\n" + + "Use these to draft release notes or a changelog blog post."); + } else { + stream.markdown("No commits found."); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "changelog" } }; +} + +async function handleCheckLinks( + stream: vscode.ChatResponseStream, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Checking links in context files..."); + try { + const contextDir = path.join(cwd, ".context"); + if (!fs.existsSync(contextDir)) { + stream.markdown("No `.context/` directory found."); + return { metadata: { command: "check-links" } }; + } + const files = fs.readdirSync(contextDir).filter((f) => f.endsWith(".md")); + const broken: string[] = []; + let total = 0; + for (const file of files) { + const content = fs.readFileSync(path.join(contextDir, file), "utf-8"); + const linkRegex = /\[([^\]]*)\]\(([^)]+)\)/g; + let m; + while ((m = linkRegex.exec(content)) !== null) { + const target = m[2]; + if (target.startsWith("http://") || target.startsWith("https://")) { continue; } + total++; + const resolved = path.resolve(contextDir, target); + if (!fs.existsSync(resolved)) { + broken.push(`- \`${file}\`: [${m[1]}](${target}) \u2192 not found`); + } + } + } + stream.markdown(`## Link Check\n\nChecked ${total} local links in ${files.length} context files.\n\n`); + if (broken.length) { + stream.markdown("### Broken Links\n" + broken.join("\n") + "\n"); + } else { + stream.markdown("All local links are valid.\n"); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "check-links" } }; +} + +async function handleJournal( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Checking journal..."); + try { + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + const args = ["journal"]; + let progressOverride: string | undefined; + if (subcmd === "site") { + args.push("site", ...parts.slice(1)); + progressOverride = "Exporting journal to static site..."; + } else if (subcmd === "obsidian") { + args.push("obsidian", ...parts.slice(1)); + progressOverride = "Exporting journal to Obsidian..."; + } else if (prompt.trim()) { + args.push(...parts); + } + if (progressOverride) { stream.progress(progressOverride); } + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { stream.markdown("```\n" + output + "\n```"); } + else { stream.markdown("No journal output."); } + } catch { + try { + const stateDir = path.join(cwd, ".context", "state"); + if (fs.existsSync(stateDir)) { + const files = fs.readdirSync(stateDir).filter((f) => f.includes("journal") || f.includes("event")); + if (files.length) { + stream.markdown("## Journal Entries\n\n"); + for (const f of files.slice(-3)) { + try { + const content = fs.readFileSync(path.join(stateDir, f), "utf-8").trim(); + const preview = content.split("\n").slice(0, 10).join("\n"); + stream.markdown(`### ${f}\n\`\`\`\n${preview}\n\`\`\`\n\n`); + } catch { /* skip unreadable */ } + } + } else { + stream.markdown("No journal or event log files found in `.context/state/`."); + } + } else { + stream.markdown("No `.context/state/` directory found."); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + } + return { metadata: { command: "journal" } }; +} + +async function handleConsolidate( + stream: vscode.ChatResponseStream, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Scanning for overlapping entries..."); + try { + const contextDir = path.join(cwd, ".context"); + const targetFiles = ["DECISIONS.md", "LEARNINGS.md", "CONVENTIONS.md", "TASKS.md"]; + const findings: string[] = []; + for (const file of targetFiles) { + const filePath = path.join(contextDir, file); + if (!fs.existsSync(filePath)) { continue; } + const entries = fs.readFileSync(filePath, "utf-8").split("\n") + .filter((l) => /^\s*-\s/.test(l)) + .map((l) => l.replace(/^\s*-\s*(\[.\]\s*)?/, "").trim().toLowerCase()); + const seen = new Map(); + for (const entry of entries) { + const key = entry.replace(/[^a-z0-9\s]/g, "").replace(/\s+/g, " "); + seen.set(key, (seen.get(key) || 0) + 1); + } + const dupes = [...seen.entries()].filter(([, count]) => count > 1); + if (dupes.length) { + findings.push(`### ${file}\n` + dupes.map(([text, count]) => `- "${text}" (\u00d7${count})`).join("\n")); + } + } + stream.markdown("## Consolidation Report\n\n"); + if (findings.length) { + stream.markdown(findings.join("\n\n") + "\n\nReview and merge manually."); + } else { + stream.markdown("No duplicate entries found across context files."); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "consolidate" } }; +} + +async function handleAudit( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Running alignment audit..."); + try { + const { stdout: driftOut, stderr: driftErr } = await runCtx(["drift"], cwd, token); + stream.markdown("## Alignment Audit\n\n### Drift\n```\n" + mergeOutput(driftOut, driftErr) + "\n```\n\n"); + const convPath = path.join(cwd, ".context", "CONVENTIONS.md"); + if (fs.existsSync(convPath)) { + const entries = fs.readFileSync(convPath, "utf-8").split("\n").filter((l) => /^\s*-\s/.test(l)); + stream.markdown(`### Conventions: ${entries.length} documented\n\n`); + if (entries.length === 0) { + stream.markdown("**Warning:** No conventions documented. Run `@ctx /add convention ...`\n"); + } + } + const archPath = path.join(cwd, ".context", "ARCHITECTURE.md"); + if (fs.existsSync(archPath)) { + const lines = fs.readFileSync(archPath, "utf-8").split("\n").filter((l) => l.trim() && !l.startsWith("#")); + if (lines.length < 3) { + stream.markdown("**Warning:** `ARCHITECTURE.md` appears sparse. Consider documenting system structure.\n"); + } + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "audit" } }; +} + +async function handleWorktree( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + _token: vscode.CancellationToken +): Promise { + stream.progress("Managing worktrees..."); + try { + const subcmd = prompt.trim().split(/\s+/)[0]?.toLowerCase() || "list"; + if (subcmd === "list" || !prompt.trim()) { + const result = await new Promise((resolve, reject) => { + execFile("git", ["worktree", "list"], { cwd }, (err, stdout) => { + if (err) { reject(err); } else { resolve(stdout); } + }); + }); + stream.markdown("## Git Worktrees\n\n```\n" + result.trim() + "\n```\n\nCreate: `@ctx /worktree add `"); + } else if (subcmd === "add") { + const branch = prompt.trim().split(/\s+/).slice(1).join("-").replace(/[^a-zA-Z0-9_\-\/]/g, ""); + if (!branch) { + stream.markdown("Usage: `@ctx /worktree add `"); + } else { + const worktreePath = path.join(path.dirname(cwd), path.basename(cwd) + "-" + branch); + const result = await new Promise((resolve, reject) => { + execFile("git", ["worktree", "add", worktreePath, "-b", branch], { cwd }, (err, stdout, stderr) => { + if (err) { reject(err); } else { resolve(stdout + stderr); } + }); + }); + stream.markdown(`Worktree created at \`${worktreePath}\`.\n\n\`\`\`\n${result.trim()}\n\`\`\``); + } + } else { + stream.markdown("**Usage:** `@ctx /worktree [list|add ]`"); + } + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "worktree" } }; +} + +async function handlePause( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Pausing session..."); + try { + const stateDir = path.join(cwd, ".context", "state"); + if (!fs.existsSync(stateDir)) { fs.mkdirSync(stateDir, { recursive: true }); } + const { stdout } = await runCtx(["status"], cwd, token); + const state = { paused_at: new Date().toISOString(), status: stdout.trim(), cwd }; + fs.writeFileSync(path.join(stateDir, "paused-session.json"), JSON.stringify(state, null, 2), "utf-8"); + stream.markdown("Session paused. State saved to `.context/state/paused-session.json`.\n\nResume with `@ctx /resume`."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "pause" } }; +} + +async function handleResume( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Resuming session..."); + try { + const statePath = path.join(cwd, ".context", "state", "paused-session.json"); + if (!fs.existsSync(statePath)) { + stream.markdown("No paused session found. Start fresh with `@ctx /status`."); + return { metadata: { command: "resume" } }; + } + const state = JSON.parse(fs.readFileSync(statePath, "utf-8")); + stream.markdown("## Resuming Session\n\n" + `Paused at: ${state.paused_at}\n\n` + + "### Status at pause\n```\n" + state.status + "\n```\n\n"); + try { + const { stdout } = await runCtx(["status"], cwd, token); + stream.markdown("### Current Status\n```\n" + stdout.trim() + "\n```\n"); + } catch { /* non-fatal */ } + fs.unlinkSync(statePath); + stream.markdown("\nSession resumed. Pause file removed."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + return { metadata: { command: "resume" } }; +} + +async function handleMemory( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + const rest = parts.slice(1).join(" "); + + let args: string[]; + let progressMsg: string; + + switch (subcmd) { + case "sync": + args = ["memory", "sync"]; + progressMsg = "Syncing memory bridge..."; + break; + case "status": + args = ["memory", "status"]; + progressMsg = "Checking memory bridge status..."; + break; + case "diff": + args = ["memory", "diff"]; + progressMsg = "Comparing memory with context..."; + break; + case "import": + args = rest ? ["memory", "import", rest] : ["memory", "import"]; + progressMsg = "Importing memory..."; + break; + case "publish": + args = rest ? ["memory", "publish", rest] : ["memory", "publish"]; + progressMsg = "Publishing memory..."; + break; + case "unpublish": + args = rest ? ["memory", "unpublish", rest] : ["memory", "unpublish"]; + progressMsg = "Unpublishing memory..."; + break; + default: + stream.markdown( + "**Usage:** `@ctx /memory `\n\n" + + "| Subcommand | Description |\n" + + "|------------|-------------|\n" + + "| `sync` | Sync Claude Code memory bridge |\n" + + "| `status` | Show memory bridge status |\n" + + "| `diff` | Compare memory with context files |\n" + + "| `import` | Import from Claude Code memory |\n" + + "| `publish` | Publish context to Claude Code memory |\n" + + "| `unpublish` | Remove published memory |\n\n" + + "Example: `@ctx /memory sync` or `@ctx /memory diff`" + ); + return { metadata: { command: "memory" } }; + } + + stream.progress(progressMsg); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("No output."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Memory command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "memory" } }; +} + +async function handleDecisions( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const subcmd = prompt.trim().split(/\s+/)[0]?.toLowerCase(); + + if (subcmd === "reindex") { + stream.progress("Reindexing decisions..."); + try { + const { stdout, stderr } = await runCtx(["decisions", "reindex"], cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : "Decision index rebuilt."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + } else { + stream.progress("Loading decisions..."); + try { + const { stdout, stderr } = await runCtx(["decisions"], cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : "No decisions found."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + } + return { metadata: { command: "decisions" } }; +} + +async function handleLearnings( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const subcmd = prompt.trim().split(/\s+/)[0]?.toLowerCase(); + + if (subcmd === "reindex") { + stream.progress("Reindexing learnings..."); + try { + const { stdout, stderr } = await runCtx(["learnings", "reindex"], cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : "Learning index rebuilt."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + } else { + stream.progress("Loading learnings..."); + try { + const { stdout, stderr } = await runCtx(["learnings"], cwd, token); + const output = mergeOutput(stdout, stderr); + stream.markdown(output ? "```\n" + output + "\n```" : "No learnings found."); + } catch (err: unknown) { + stream.markdown(`**Error:** ${err instanceof Error ? err.message : String(err)}`); + } + } + return { metadata: { command: "learnings" } }; +} + +async function handleConfig( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const parts = prompt.trim().split(/\s+/); + const subcmd = parts[0]?.toLowerCase(); + const rest = parts.slice(1).join(" "); + + let args: string[]; + let progressMsg: string; + + switch (subcmd) { + case "switch": + if (!rest) { + stream.markdown("**Usage:** `@ctx /config switch `\n\nExample: `@ctx /config switch dev`"); + return { metadata: { command: "config" } }; + } + args = ["config", "switch", rest]; + progressMsg = `Switching to profile "${rest}"...`; + break; + case "status": + args = ["config", "status"]; + progressMsg = "Checking config status..."; + break; + case "schema": + args = ["config", "schema"]; + progressMsg = "Loading config schema..."; + break; + default: + stream.markdown( + "**Usage:** `@ctx /config `\n\n" + + "| Subcommand | Description |\n" + + "|------------|-------------|\n" + + "| `switch ` | Switch to a config profile |\n" + + "| `status` | Show current config profile |\n" + + "| `schema` | Show config schema |\n\n" + + "Example: `@ctx /config switch dev`" + ); + return { metadata: { command: "config" } }; + } + + stream.progress(progressMsg); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("No output."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Config command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "config" } }; +} + +async function handlePermissions( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + const subcmd = prompt.trim().split(/\s+/)[0]?.toLowerCase(); + + let args: string[]; + let progressMsg: string; + + switch (subcmd) { + case "snapshot": + args = ["permissions", "snapshot"]; + progressMsg = "Saving permissions snapshot..."; + break; + case "restore": + args = ["permissions", "restore"]; + progressMsg = "Restoring permissions..."; + break; + default: + stream.markdown( + "**Usage:** `@ctx /permissions `\n\n" + + "| Subcommand | Description |\n" + + "|------------|-------------|\n" + + "| `snapshot` | Backup current Claude settings |\n" + + "| `restore` | Restore settings from backup |\n\n" + + "Example: `@ctx /permissions snapshot`" + ); + return { metadata: { command: "permissions" } }; + } + + stream.progress(progressMsg); + try { + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown(subcmd === "snapshot" ? "Permissions snapshot saved." : "Permissions restored."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Permissions command failed.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "permissions" } }; +} + +async function handleChanges( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Checking what changed..."); + try { + const args = ["changes"]; + const sinceMatch = prompt.match(/--since\s+(\S+)/); + if (sinceMatch) { + args.push("--since", sinceMatch[1]); + } + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("No changes detected since last session."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to check changes.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "changes" } }; +} + +async function handleDeps( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Analyzing dependencies..."); + try { + const args = ["deps"]; + const formatMatch = prompt.match(/--format\s+(\S+)/); + if (formatMatch) { + args.push("--format", formatMatch[1]); + } + if (prompt.includes("--external")) { + args.push("--external"); + } + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown(output); + } else { + stream.markdown("No dependency information available."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to analyze dependencies.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "deps" } }; +} + +async function handleGuide( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Loading guide..."); + try { + const args = ["guide"]; + if (prompt.includes("--skills")) { + args.push("--skills"); + } else if (prompt.includes("--commands")) { + args.push("--commands"); + } + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown(output); + } else { + stream.markdown("No guide output."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to load guide.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "guide" } }; +} + +async function handleReindex( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Regenerating indices..."); + try { + const { stdout, stderr } = await runCtx(["reindex"], cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown("```\n" + output + "\n```"); + } else { + stream.markdown("Indices regenerated for DECISIONS.md and LEARNINGS.md."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to reindex.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "reindex" } }; +} + +async function handleWhy( + stream: vscode.ChatResponseStream, + prompt: string, + cwd: string, + token: vscode.CancellationToken +): Promise { + stream.progress("Loading philosophy..."); + try { + const args = ["why"]; + if (prompt.trim()) { + args.push(prompt.trim()); + } + const { stdout, stderr } = await runCtx(args, cwd, token); + const output = mergeOutput(stdout, stderr); + if (output) { + stream.markdown(output); + } else { + stream.markdown("No philosophy content available."); + } + } catch (err: unknown) { + stream.markdown( + `**Error:** Failed to load philosophy.\n\n\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`` + ); + } + return { metadata: { command: "why" } }; +} + +async function handleFreeform( + request: vscode.ChatRequest, + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + const prompt = request.prompt.trim().toLowerCase(); + + // Try to infer intent from natural language + if (prompt.includes("init")) { + return handleInit(stream, cwd, token); + } + if (prompt.includes("status")) { + return handleStatus(stream, cwd, token); + } + if (prompt.includes("drift")) { + return handleDrift(stream, cwd, token); + } + if (prompt.includes("recall") || prompt.includes("session") || prompt.includes("history")) { + return handleRecall(stream, request.prompt, cwd, token); + } + if (prompt.includes("complete") || prompt.includes("done") || prompt.includes("finish")) { + return handleComplete(stream, request.prompt, cwd, token); + } + if (prompt.includes("remind")) { + return handleRemind(stream, request.prompt, cwd, token); + } + if (prompt.includes("task")) { + return handleTasks(stream, request.prompt, cwd, token); + } + if (prompt.includes("pad") || prompt.includes("scratchpad") || prompt.includes("scratch")) { + return handlePad(stream, request.prompt, cwd, token); + } + if (prompt.includes("notify") || prompt.includes("webhook")) { + return handleNotify(stream, request.prompt, cwd, token); + } + if (prompt.includes("system") || prompt.includes("resource") || prompt.includes("bootstrap")) { + return handleSystem(stream, request.prompt, cwd, token); + } + if (prompt.includes("wrap") || prompt.includes("end session") || prompt.includes("closing")) { + return handleWrapup(stream, cwd, token); + } + if (prompt.includes("remember") || prompt.includes("last session") || prompt.includes("what were we")) { + return handleRemember(stream, request.prompt, cwd, token); + } + if (prompt.includes("next") || prompt.includes("what should") || prompt.includes("pick task")) { + return handleNext(stream, cwd, token); + } + if (prompt.includes("brainstorm") || prompt.includes("idea")) { + return handleBrainstorm(stream, request.prompt, cwd, token); + } + if (prompt.includes("reflect") || prompt.includes("persist") || prompt.includes("worth saving")) { + return handleReflect(stream, cwd, token); + } + if (prompt.includes("spec") || prompt.includes("scaffold")) { + return handleSpec(stream, request.prompt, cwd, token); + } + if (prompt.includes("implement") || prompt.includes("execution plan")) { + return handleImplement(stream, cwd, token); + } + if (prompt.includes("verify") || prompt.includes("qa") || prompt.includes("lint")) { + return handleVerify(stream, cwd, token); + } + if (prompt.includes("map") || prompt.includes("dependencies") || prompt.includes("deps")) { + return handleMap(stream, cwd, token); + } + if (prompt.includes("prompt template") || prompt.includes("prompts")) { + return handlePromptTpl(stream, request.prompt, cwd, token); + } + if (prompt.includes("blog") && prompt.includes("changelog")) { + return handleChangelog(stream, cwd, token); + } + if (prompt.includes("blog") || prompt.includes("post")) { + return handleBlog(stream, request.prompt, cwd, token); + } + if (prompt.includes("changelog") || prompt.includes("release notes")) { + return handleChangelog(stream, cwd, token); + } + if (prompt.includes("link") || prompt.includes("broken") || prompt.includes("dead link")) { + return handleCheckLinks(stream, cwd, token); + } + if (prompt.includes("journal") || prompt.includes("log entries")) { + return handleJournal(stream, request.prompt, cwd, token); + } + if (prompt.includes("consolidate") || prompt.includes("merge entries") || prompt.includes("duplicate")) { + return handleConsolidate(stream, cwd, token); + } + if (prompt.includes("memory bridge") || prompt.includes("memory sync") || prompt.includes("memory status") || prompt.includes("memory diff") || prompt.includes("memory import") || prompt.includes("memory publish")) { + return handleMemory(stream, request.prompt, cwd, token); + } + if (prompt.includes("decisions") || prompt.includes("decision list") || prompt.includes("decision reindex")) { + return handleDecisions(stream, request.prompt, cwd, token); + } + if (prompt.includes("learnings") || prompt.includes("learning list") || prompt.includes("learning reindex")) { + return handleLearnings(stream, request.prompt, cwd, token); + } + if (prompt.includes("config") || prompt.includes("profile") || prompt.includes("switch profile")) { + return handleConfig(stream, request.prompt, cwd, token); + } + if (prompt.includes("permissions") || prompt.includes("permission snapshot") || prompt.includes("permission restore")) { + return handlePermissions(stream, request.prompt, cwd, token); + } + if (prompt.includes("changes") || prompt.includes("what changed") || prompt.includes("since last session")) { + return handleChanges(stream, request.prompt, cwd, token); + } + if (prompt.includes("deps") || prompt.includes("dependency graph") || prompt.includes("package graph")) { + return handleDeps(stream, request.prompt, cwd, token); + } + if (prompt.includes("guide") || prompt.includes("cheat sheet") || prompt.includes("quick reference")) { + return handleGuide(stream, request.prompt, cwd, token); + } + if (prompt.includes("reindex") || prompt.includes("rebuild index") || prompt.includes("regenerate index")) { + return handleReindex(stream, cwd, token); + } + if (prompt.includes("why") || prompt.includes("philosophy") || prompt.includes("manifesto")) { + return handleWhy(stream, request.prompt, cwd, token); + } + if (prompt.includes("audit") || prompt.includes("alignment")) { + return handleAudit(stream, cwd, token); + } + if (prompt.includes("worktree")) { + return handleWorktree(stream, request.prompt, cwd, token); + } + if (prompt.includes("pause") || prompt.includes("save state")) { + return handlePause(stream, cwd, token); + } + if (prompt.includes("resume") || prompt.includes("restore state") || prompt.includes("continue session")) { + return handleResume(stream, cwd, token); + } + + // 2.5: Specs nudge — remind about specs when planning + if (prompt.includes("plan") || prompt.includes("design") || prompt.includes("architect")) { + const specsDir = path.join(cwd, "specs"); + if (fs.existsSync(specsDir)) { + const specs = fs.readdirSync(specsDir).filter((f) => f.endsWith(".md")); + if (specs.length > 0) { + stream.markdown("> **specs/** has " + specs.length + " spec(s). Review with `@ctx /spec` before designing.\n\n"); + } + } + } + + // Default: show help with available commands + stream.markdown( + "## ctx — Persistent Context for AI\n\n" + + "Available commands:\n\n" + + "| Command | Description |\n" + + "|---------|-------------|\n" + + "| `/init` | Initialize `.context/` directory |\n" + + "| `/status` | Show context summary |\n" + + "| `/agent [--budget N]` | Print AI-ready context packet |\n" + + "| `/drift` | Detect stale or invalid context |\n" + + "| `/recall [show\\|export\\|lock\\|unlock\\|sync]` | Browse session history |\n" + + "| `/hook` | Generate tool integration configs |\n" + + "| `/add` | Add task, decision, learning, or convention |\n" + + "| `/load` | Output assembled context |\n" + + "| `/compact` | Archive completed tasks |\n" + + "| `/sync` | Reconcile context with codebase |\n" + + "| `/complete` | Mark a task as completed |\n" + + "| `/remind` | Manage session reminders |\n" + + "| `/tasks` | Archive or snapshot tasks |\n" + + "| `/decisions [reindex]` | List or reindex decisions |\n" + + "| `/learnings [reindex]` | List or reindex learnings |\n" + + "| `/pad [resolve\\|import\\|export\\|merge]` | Encrypted scratchpad |\n" + + "| `/notify` | Webhook notifications |\n" + + "| `/memory [sync\\|status\\|diff\\|import\\|publish]` | Claude Code memory bridge |\n" + + "| `/system [stats\\|backup\\|message]` | System diagnostics |\n" + + "| `/config [switch\\|status\\|schema]` | Config profile management |\n" + + "| `/permissions [snapshot\\|restore]` | Claude settings backup |\n" + + "| `/wrapup` | End-of-session wrap-up |\n" + + "| `/remember [--limit N]` | Recall recent sessions |\n" + + "| `/next` | Show next open task |\n" + + "| `/brainstorm` | Browse and develop ideas |\n" + + "| `/reflect` | Surface items worth persisting |\n" + + "| `/spec` | List or scaffold feature specs |\n" + + "| `/implement` | Show implementation plan |\n" + + "| `/verify` | Run verification checks |\n" + + "| `/map` | Show dependency map |\n" + + "| `/prompt [add\\|rm]` | Manage prompt templates |\n" + + "| `/blog` | Draft blog post from context |\n" + + "| `/changelog` | Recent commits for changelog |\n" + + "| `/check-links` | Audit local links in context |\n" + + "| `/journal [site\\|obsidian]` | View or export journal |\n" + + "| `/consolidate` | Find duplicate entries |\n" + + "| `/audit` | Alignment audit (drift + conventions) |\n" + + "| `/worktree` | Git worktree management |\n" + + "| `/pause` | Save session state |\n" + + "| `/resume` | Restore paused session |\n" + + "| `/changes [--since duration]` | Show what changed since last session |\n" + + "| `/deps [--format mermaid\\|table\\|json]` | Package dependency graph |\n" + + "| `/guide [--skills\\|--commands]` | Quick-reference cheat sheet |\n" + + "| `/reindex` | Regenerate decision/learning indices |\n" + + "| `/why [topic]` | Read ctx philosophy |\n\n" + + "Example: `@ctx /status` or `@ctx /add task Fix login bug`" + ); + return { metadata: { command: "help" } }; +} + +const handler: vscode.ChatRequestHandler = async ( + request: vscode.ChatRequest, + _context: vscode.ChatContext, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken +): Promise => { + const cwd = getWorkspaceRoot(); + if (!cwd) { + stream.markdown( + "**Error:** No workspace folder is open. Open a project folder first." + ); + return { metadata: { command: request.command || "none" } }; + } + + // Auto-bootstrap: ensure ctx binary is available before any command + try { + stream.progress("Checking ctx installation..."); + await bootstrap(); + } catch (err: unknown) { + stream.markdown( + `**Error:** ctx CLI not found and auto-install failed.\n\n` + + `\`\`\`\n${err instanceof Error ? err.message : String(err)}\n\`\`\`\n\n` + + `Install manually: \`go install github.com/ActiveMemory/ctx/cmd/ctx@latest\` ` + + `or download from [GitHub Releases](https://github.com/${GITHUB_REPO}/releases).` + ); + return { metadata: { command: request.command || "none" } }; + } + + // For non-init commands, verify .context/ exists + if (request.command !== "init" && !hasContextDir(cwd)) { + stream.markdown( + "**Not initialized.** This project doesn't have a `.context/` directory yet.\n\n" + + "Run `@ctx /init` to set up persistent context for this project." + ); + return { metadata: { command: request.command || "none" } }; + } + + switch (request.command) { + case "init": + return handleInit(stream, cwd, token); + case "status": + return handleStatus(stream, cwd, token); + case "agent": + return handleAgent(stream, request.prompt, cwd, token); + case "drift": + return handleDrift(stream, cwd, token); + case "recall": + return handleRecall(stream, request.prompt, cwd, token); + case "hook": + return handleHook(stream, request.prompt, cwd, token); + case "add": + return handleAdd(stream, request.prompt, cwd, token); + case "load": + return handleLoad(stream, cwd, token); + case "compact": + return handleCompact(stream, cwd, token); + case "sync": + return handleSync(stream, cwd, token); + case "complete": + return handleComplete(stream, request.prompt, cwd, token); + case "remind": + return handleRemind(stream, request.prompt, cwd, token); + case "tasks": + return handleTasks(stream, request.prompt, cwd, token); + case "pad": + return handlePad(stream, request.prompt, cwd, token); + case "notify": + return handleNotify(stream, request.prompt, cwd, token); + case "system": + return handleSystem(stream, request.prompt, cwd, token); + case "wrapup": + return handleWrapup(stream, cwd, token); + case "remember": + return handleRemember(stream, request.prompt, cwd, token); + case "next": + return handleNext(stream, cwd, token); + case "brainstorm": + return handleBrainstorm(stream, request.prompt, cwd, token); + case "reflect": + return handleReflect(stream, cwd, token); + case "spec": + return handleSpec(stream, request.prompt, cwd, token); + case "implement": + return handleImplement(stream, cwd, token); + case "verify": + return handleVerify(stream, cwd, token); + case "map": + return handleMap(stream, cwd, token); + case "prompt": + return handlePromptTpl(stream, request.prompt, cwd, token); + case "blog": + return handleBlog(stream, request.prompt, cwd, token); + case "changelog": + return handleChangelog(stream, cwd, token); + case "check-links": + return handleCheckLinks(stream, cwd, token); + case "journal": + return handleJournal(stream, request.prompt, cwd, token); + case "consolidate": + return handleConsolidate(stream, cwd, token); + case "audit": + return handleAudit(stream, cwd, token); + case "worktree": + return handleWorktree(stream, request.prompt, cwd, token); + case "pause": + return handlePause(stream, cwd, token); + case "resume": + return handleResume(stream, cwd, token); + case "memory": + return handleMemory(stream, request.prompt, cwd, token); + case "decisions": + return handleDecisions(stream, request.prompt, cwd, token); + case "learnings": + return handleLearnings(stream, request.prompt, cwd, token); + case "config": + return handleConfig(stream, request.prompt, cwd, token); + case "permissions": + return handlePermissions(stream, request.prompt, cwd, token); + case "changes": + return handleChanges(stream, request.prompt, cwd, token); + case "deps": + return handleDeps(stream, request.prompt, cwd, token); + case "guide": + return handleGuide(stream, request.prompt, cwd, token); + case "reindex": + return handleReindex(stream, cwd, token); + case "why": + return handleWhy(stream, request.prompt, cwd, token); + default: + return handleFreeform(request, stream, cwd, token); + } +}; + +export function activate(extensionContext: vscode.ExtensionContext) { + // Store extension context for auto-bootstrap binary downloads + extensionCtx = extensionContext; + + // Kick off background bootstrap — don't block activation + bootstrap().catch(() => { + // Errors will surface when user invokes a command + }); + + const participant = vscode.chat.createChatParticipant( PARTICIPANT_ID, handler ); @@ -1136,6 +2682,148 @@ export function activate(extensionContext: vscode.ExtensionContext) { { prompt: "Show context status", command: "status" } ); break; + case "wrapup": + followups.push( + { prompt: "Add a decision", command: "add" }, + { prompt: "Add a learning", command: "add" } + ); + break; + case "remember": + followups.push( + { prompt: "Show context status", command: "status" }, + { prompt: "Load full context", command: "load" } + ); + break; + case "next": + followups.push( + { prompt: "Mark task completed", command: "complete" }, + { prompt: "Show all tasks", command: "status" } + ); + break; + case "brainstorm": + followups.push( + { prompt: "Show context status", command: "status" }, + { prompt: "Add a task", command: "add" } + ); + break; + case "reflect": + followups.push( + { prompt: "Add a decision", command: "add" }, + { prompt: "Add a learning", command: "add" }, + { prompt: "Wrap up session", command: "wrapup" } + ); + break; + case "spec": + followups.push( + { prompt: "Show implementation plan", command: "implement" }, + { prompt: "Run verification", command: "verify" } + ); + break; + case "implement": + followups.push( + { prompt: "Show next task", command: "next" }, + { prompt: "Run verification", command: "verify" } + ); + break; + case "verify": + followups.push( + { prompt: "Show context status", command: "status" }, + { prompt: "Run alignment audit", command: "audit" } + ); + break; + case "map": + followups.push( + { prompt: "Show context status", command: "status" } + ); + break; + case "blog": + case "changelog": + followups.push( + { prompt: "Show context status", command: "status" } + ); + break; + case "consolidate": + followups.push( + { prompt: "Run alignment audit", command: "audit" }, + { prompt: "Show context status", command: "status" } + ); + break; + case "audit": + followups.push( + { prompt: "Fix drift", command: "sync" }, + { prompt: "Add a convention", command: "add" } + ); + break; + case "pause": + followups.push( + { prompt: "Resume session", command: "resume" } + ); + break; + case "resume": + followups.push( + { prompt: "Show next task", command: "next" }, + { prompt: "Show context status", command: "status" } + ); + break; + case "memory": + followups.push( + { prompt: "Show memory diff", command: "memory" }, + { prompt: "Show context status", command: "status" } + ); + break; + case "decisions": + followups.push( + { prompt: "Add a decision", command: "add" }, + { prompt: "Reindex decisions", command: "decisions" } + ); + break; + case "learnings": + followups.push( + { prompt: "Add a learning", command: "add" }, + { prompt: "Reindex learnings", command: "learnings" } + ); + break; + case "config": + followups.push( + { prompt: "Show config status", command: "config" }, + { prompt: "Show context status", command: "status" } + ); + break; + case "permissions": + followups.push( + { prompt: "Show context status", command: "status" } + ); + break; + case "changes": + followups.push( + { prompt: "Show context status", command: "status" }, + { prompt: "Load full context", command: "load" } + ); + break; + case "deps": + followups.push( + { prompt: "Show dependency map", command: "map" }, + { prompt: "Show context status", command: "status" } + ); + break; + case "guide": + followups.push( + { prompt: "Show context status", command: "status" }, + { prompt: "Read philosophy", command: "why" } + ); + break; + case "reindex": + followups.push( + { prompt: "List decisions", command: "decisions" }, + { prompt: "List learnings", command: "learnings" } + ); + break; + case "why": + followups.push( + { prompt: "Show guide", command: "guide" }, + { prompt: "Show context status", command: "status" } + ); + break; } return followups; @@ -1143,6 +2831,297 @@ export function activate(extensionContext: vscode.ExtensionContext) { }; extensionContext.subscriptions.push(participant); + + // --- Command palette entries — open chat with the right slash command --- + const paletteCommands: Array<[string, string]> = [ + ["ctx.init", "/init"], + ["ctx.status", "/status"], + ["ctx.agent", "/agent"], + ["ctx.drift", "/drift"], + ["ctx.recall", "/recall"], + ["ctx.hook", "/hook"], + ["ctx.add", "/add"], + ["ctx.load", "/load"], + ["ctx.compact", "/compact"], + ["ctx.sync", "/sync"], + ["ctx.complete", "/complete"], + ["ctx.remind", "/remind"], + ["ctx.tasks", "/tasks"], + ["ctx.pad", "/pad"], + ["ctx.notify", "/notify"], + ["ctx.system", "/system"], + ["ctx.wrapup", "/wrapup"], + ["ctx.remember", "/remember"], + ["ctx.next", "/next"], + ["ctx.brainstorm", "/brainstorm"], + ["ctx.reflect", "/reflect"], + ["ctx.spec", "/spec"], + ["ctx.implement", "/implement"], + ["ctx.verify", "/verify"], + ["ctx.map", "/map"], + ["ctx.prompt", "/prompt"], + ["ctx.blog", "/blog"], + ["ctx.changelog", "/changelog"], + ["ctx.checkLinks", "/check-links"], + ["ctx.journal", "/journal"], + ["ctx.consolidate", "/consolidate"], + ["ctx.audit", "/audit"], + ["ctx.worktree", "/worktree"], + ["ctx.pause", "/pause"], + ["ctx.resume", "/resume"], + ["ctx.memory", "/memory"], + ["ctx.decisions", "/decisions"], + ["ctx.learnings", "/learnings"], + ["ctx.config", "/config"], + ["ctx.permissions", "/permissions"], + ["ctx.changes", "/changes"], + ["ctx.deps", "/deps"], + ["ctx.guide", "/guide"], + ["ctx.reindex", "/reindex"], + ["ctx.why", "/why"], + ]; + for (const [cmdId, slash] of paletteCommands) { + extensionContext.subscriptions.push( + vscode.commands.registerCommand(cmdId, () => { + vscode.commands.executeCommand("workbench.action.chat.open", { + query: `@ctx ${slash}`, + }); + }) + ); + } + + // --- VS Code native hooks (equivalent to Claude Code hooks.json) --- + const cwd = getWorkspaceRoot(); + + // 2.18: Terminal command watcher — detect dangerous commands + if (cwd) { + const terminalWatcher = vscode.window.onDidStartTerminalShellExecution((e) => { + if (!bootstrapDone || !hasContextDir(cwd)) { + return; + } + const cmd = e.execution.commandLine?.value; + if (!cmd) { + return; + } + for (const pattern of DENY_COMMAND_PATTERNS) { + if (pattern.test(cmd)) { + recordViolation(cwd, "dangerous_command", cmd); + vscode.window.showWarningMessage( + `ctx: Dangerous command detected: ${cmd.slice(0, 80)}` + ); + return; + } + } + for (const pattern of DENY_COMMAND_SCRIPT_PATTERNS) { + if (pattern.test(cmd)) { + recordViolation(cwd, "hack_script", cmd); + vscode.window.showWarningMessage( + `ctx: Direct hack/ script execution detected. Use make targets instead.` + ); + return; + } + } + }); + extensionContext.subscriptions.push(terminalWatcher); + } + + // 2.19: Sensitive file edit watcher — detect modifications to .env, .pem, .key, credentials + // Note: only watches edits, not opens. onDidOpenTextDocument is too noisy + // (fires on search results, peek definition, git diffs, hover previews). + if (cwd) { + const fileEditWatcher = vscode.workspace.onDidChangeTextDocument((e) => { + if (!bootstrapDone || !hasContextDir(cwd)) { + return; + } + if (e.contentChanges.length === 0) { + return; + } + const rel = path.relative(cwd, e.document.uri.fsPath); + if (rel.startsWith("..")) { + return; + } + for (const pattern of SENSITIVE_FILE_PATTERNS) { + if (pattern.test(e.document.uri.fsPath)) { + recordViolation(cwd, "sensitive_file_edit", rel); + vscode.window.showWarningMessage( + `ctx: Sensitive file modified: ${rel}` + ); + return; + } + } + }); + extensionContext.subscriptions.push(fileEditWatcher); + } + + // 2.6: onDidSave → task completion check (PostToolUse Edit/Write equivalent) + const saveWatcher = vscode.workspace.onDidSaveTextDocument((doc) => { + if (!cwd || !bootstrapDone || !hasContextDir(cwd)) { + return; + } + // Only trigger for files inside the workspace, not for .context/ files themselves + const rel = path.relative(cwd, doc.uri.fsPath); + if (rel.startsWith(".context")) { + return; + } + // Fire and forget — non-blocking background check + runCtx(["system", "check-task-completion"], cwd).catch( + () => {} + ); + }); + extensionContext.subscriptions.push(saveWatcher); + + // 2.7: Git post-commit — detect commits and nudge for context capture + try { + const gitExtension = vscode.extensions.getExtension("vscode.git"); + if (gitExtension) { + const setupGitHook = (git: any) => { + try { + const api = git.getAPI(1); + if (api && api.repositories.length > 0) { + const repo = api.repositories[0]; + let lastCommit = repo.state.HEAD?.commit; + const commitListener = repo.state.onDidChange(() => { + const currentCommit = repo.state.HEAD?.commit; + if (currentCommit && currentCommit !== lastCommit) { + lastCommit = currentCommit; + if (!cwd || !bootstrapDone || !hasContextDir(cwd)) { + return; + } + vscode.window + .showInformationMessage( + "Commit succeeded. Record context or run QA?", + "Add Decision", + "Add Learning", + "Verify", + "Skip" + ) + .then((choice) => { + if (choice === "Add Decision") { + vscode.commands.executeCommand( + "workbench.action.chat.open", + { query: "@ctx /add decision " } + ); + } else if (choice === "Add Learning") { + vscode.commands.executeCommand( + "workbench.action.chat.open", + { query: "@ctx /add learning " } + ); + } else if (choice === "Verify") { + vscode.commands.executeCommand( + "workbench.action.chat.open", + { query: "@ctx /verify" } + ); + } + }); + } + }); + extensionContext.subscriptions.push(commitListener); + } + } catch { + // Git API not available + } + }; + + if (gitExtension.isActive) { + setupGitHook(gitExtension.exports); + } else { + gitExtension.activate().then(setupGitHook, () => {}); + } + } + } catch { + // Git extension not available + } + + // 2.9: Watch .context/ for external changes — refresh reminders + if (cwd) { + const contextWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(cwd, ".context/**") + ); + const onContextChange = () => { + if (hasContextDir(cwd)) { + updateReminderStatus(cwd); + // Re-generate copilot-instructions.md when context files change + runCtx(["hook", "copilot", "--write"], cwd).catch(() => {}); + } + }; + contextWatcher.onDidChange(onContextChange); + contextWatcher.onDidCreate(onContextChange); + contextWatcher.onDidDelete(onContextChange); + extensionContext.subscriptions.push(contextWatcher); + } + + // 2.17: Watch dependency files for staleness + if (cwd) { + const depWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(cwd, "{go.mod,go.sum,package.json,package-lock.json}") + ); + depWatcher.onDidChange(() => { + vscode.window.showInformationMessage( + "Dependencies changed. Review with @ctx /map?", + "View Map" + ).then((choice) => { + if (choice === "View Map") { + vscode.commands.executeCommand("workbench.action.chat.open", { query: "@ctx /map" }); + } + }); + }); + extensionContext.subscriptions.push(depWatcher); + } + + // 2.10: Status bar reminder indicator + reminderStatusBar = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right, + 50 + ); + reminderStatusBar.name = "ctx Reminders"; + reminderStatusBar.command = undefined; // informational only + extensionContext.subscriptions.push(reminderStatusBar); + + // Check reminders periodically (every 5 minutes) + if (cwd && hasContextDir(cwd)) { + updateReminderStatus(cwd); + const reminderInterval = setInterval(() => { + updateReminderStatus(cwd); + // 2.14: Heartbeat — record session-alive timestamp + try { + const stateDir = path.join(cwd, ".context", "state"); + if (!fs.existsSync(stateDir)) { fs.mkdirSync(stateDir, { recursive: true }); } + fs.writeFileSync(path.join(stateDir, "heartbeat"), new Date().toISOString(), "utf-8"); + } catch { /* non-fatal */ } + }, 5 * 60 * 1000); + extensionContext.subscriptions.push({ + dispose: () => clearInterval(reminderInterval), + }); + + // 2.12: Session start ceremony + runCtx( + ["system", "session-event", "--type", "start", "--caller", "vscode"], + cwd + ).catch(() => {}); + } +} + +/** + * Update the status bar reminder indicator by checking due reminders. + */ +function updateReminderStatus(cwd: string): void { + if (!bootstrapDone || !reminderStatusBar) { + return; + } + runCtx(["system", "check-reminders"], cwd) + .then(({ stdout }) => { + const trimmed = stdout.trim(); + if (trimmed && !trimmed.includes("no reminders")) { + reminderStatusBar!.text = "$(bell) ctx"; + reminderStatusBar!.tooltip = trimmed; + reminderStatusBar!.show(); + } else { + reminderStatusBar!.hide(); + } + }) + .catch(() => { + reminderStatusBar!.hide(); + }); } export { @@ -1158,6 +3137,25 @@ export { handlePad, handleNotify, handleSystem, + handleMemory, + handleDecisions, + handleLearnings, + handleConfig, + handlePermissions, + handleChanges, + handleDeps, + handleGuide, + handleReindex, + handleWhy, }; -export function deactivate() {} +export function deactivate() { + // 2.12: Session end ceremony + const cwd = getWorkspaceRoot(); + if (cwd && hasContextDir(cwd)) { + runCtx( + ["system", "session-event", "--type", "end", "--caller", "vscode"], + cwd + ).catch(() => {}); + } +} diff --git a/internal/assets/embed.go b/internal/assets/embed.go index 03c5709e..a8287aaf 100644 --- a/internal/assets/embed.go +++ b/internal/assets/embed.go @@ -27,6 +27,7 @@ import ( //go:embed hooks/messages/*/*.txt hooks/messages/registry.yaml //go:embed prompt-templates/*.md ralph/*.md schema/*.json why/*.md //go:embed permissions/*.txt commands/*.yaml commands/text/*.yaml journal/*.css +//go:embed overrides/*/*.md var FS embed.FS // Template reads a template file by name from the embedded filesystem. @@ -41,6 +42,18 @@ func Template(name string) ([]byte, error) { return FS.ReadFile("context/" + name) } +// TemplateForCaller reads a template, using a caller-specific override if available. +// Falls back to the default template when no override exists for the caller. +func TemplateForCaller(name, caller string) ([]byte, error) { + if caller != "" { + override, err := FS.ReadFile("overrides/" + caller + "/" + name) + if err == nil { + return override, nil + } + } + return Template(name) +} + // List returns all available template file names. // // Returns: @@ -246,6 +259,18 @@ func ClaudeMd() ([]byte, error) { return FS.ReadFile("claude/CLAUDE.md") } +// ClaudeMdForCaller reads the CLAUDE.md template, using a caller-specific override if available. +// Falls back to the default CLAUDE.md when no override exists for the caller. +func ClaudeMdForCaller(caller string) ([]byte, error) { + if caller != "" { + override, err := FS.ReadFile("overrides/" + caller + "/CLAUDE.md") + if err == nil { + return override, nil + } + } + return ClaudeMd() +} + // RalphTemplate reads a Ralph-mode template file by name. // // Ralph mode templates are designed for autonomous loop operation, diff --git a/internal/assets/overrides/vscode/AGENT_PLAYBOOK.md b/internal/assets/overrides/vscode/AGENT_PLAYBOOK.md new file mode 100644 index 00000000..e0d7d101 --- /dev/null +++ b/internal/assets/overrides/vscode/AGENT_PLAYBOOK.md @@ -0,0 +1,268 @@ +# Agent Playbook + +## Mental Model + +Each session is a fresh execution in a shared workshop. Work +continuity comes from artifacts left on the bench. Follow the +cycle: **Work → Reflect → Persist**. After completing a task, +making a decision, learning something, or hitting a milestone — +persist before continuing. Don't wait for session end; it may +never come cleanly. + +## Invoking ctx + +Always use `ctx` from PATH: +``` +ctx status # ✔ correct +ctx agent # ✔ correct +./dist/ctx # ✗ avoid hardcoded paths +go run ./cmd/ctx # ✗ avoid unless developing ctx itself +``` + +If unsure whether it's installed, run `ctx --version` in a terminal. + +## Context Readback + +Before starting any work, read the required context files and confirm to the +user: "I have read the required context files and I'm following project +conventions." Do not begin implementation until you have done so. + +## Reason Before Acting + +Before implementing any non-trivial change, think through it step-by-step: + +1. **Decompose**: break the problem into smaller parts +2. **Identify impact**: what files, tests, and behaviors does this touch? +3. **Anticipate failure**: what could go wrong? What are the edge cases? +4. **Sequence**: what order minimizes risk and maximizes checkpoints? + +This applies to debugging too — reason through the cause before reaching +for a fix. Rushing to code before reasoning is the most common source of +wasted work. + +## Session Lifecycle + +A session follows this arc: + +**Load → Orient → Pick → Work → Commit → Reflect** + +Not every session uses every step — a quick bugfix skips reflection, a +research session skips committing — but the full flow is: + +| Step | What Happens | Command | +|-------------|----------------------------------------------------|----------------------| +| **Load** | Recall context, present structured readback | `ctx recall list` | +| **Orient** | Check context health, surface issues | `ctx status` | +| **Pick** | Choose what to work on | Read TASKS.md | +| **Work** | Write code, fix bugs, research | — | +| **Commit** | Commit with context capture | `git commit` | +| **Reflect** | Surface persist-worthy items from this session | Update context files | + +### Context Health at Session Start + +During **Load** and **Orient**, run `ctx status` and read the output. +Surface problems worth mentioning: + +- **High completion ratio in TASKS.md**: offer to archive +- **Stale context files** (not modified recently): mention before + stale context influences work +- **Bloated token count** (over 30k): offer `ctx compact` +- **Drift between files and code**: spot-check paths from + ARCHITECTURE.md against the actual file tree + +One sentence is enough — don't turn startup into a maintenance session. + +### Conversational Triggers + +Users rarely invoke skills explicitly. Recognize natural language: + +| User Says | Action | +|-----------|--------| +| "Do you remember?" / "What were we working on?" | Read TASKS.md, DECISIONS.md, LEARNINGS.md; run `ctx recall list` | +| "How's our context looking?" | Run `ctx status` | +| "What should we work on?" | Read TASKS.md, pick highest priority | +| "Commit this" / "Ship it" | `git commit`, update TASKS.md | +| "The rate limiter is done" / "We finished that" | Mark done in TASKS.md | +| "What did we learn?" | Review session work, offer to update LEARNINGS.md | +| "Save that as a decision" | Add entry to DECISIONS.md | +| "That's worth remembering" / "Any gotchas?" | Add entry to LEARNINGS.md | +| "Record that convention" | Add entry to CONVENTIONS.md | +| "Add a task for that" | Add entry to TASKS.md | +| "Let's wrap up" | Reflect → persist outstanding items → present together | + +## Proactive Persistence + +**Don't wait to be asked.** Identify persist-worthy moments in real time: + +| Event | Action | +|-------|--------| +| Completed a task | Mark done in TASKS.md, offer to add learnings | +| Chose between design alternatives | Offer: *"Worth recording as a decision?"* | +| Hit a subtle bug or gotcha | Offer: *"Want me to add this as a learning?"* | +| Finished a feature or fix | Identify follow-up work, offer to add as tasks | +| Resolved a tricky debugging session | Capture root cause before moving on | +| Multi-step task or feature complete | Suggest reflection: *"Want me to capture what we learned?"* | +| Session winding down | Offer: *"Want me to capture outstanding learnings or decisions?"* | +| Shipped a feature or closed batch of tasks | Offer blog post or journal site rebuild | + +**Self-check**: periodically ask yourself — *"If this session ended +right now, would the next session know what happened?"* If no, persist +something before continuing. + +Offer once and respect "no." Default to surfacing the opportunity +rather than letting it pass silently. + +### Task Lifecycle Timestamps + +Track task progress with timestamps for session correlation: + +```markdown +- [ ] Implement feature X #added:2026-01-25-220332 +- [ ] Fix bug Y #added:2026-01-25-220332 #started:2026-01-25-221500 +- [x] Refactor Z #added:2026-01-25-200000 #started:2026-01-25-210000 #done:2026-01-25-223045 +``` + +| Tag | When to Add | Format | +|------------|------------------------------------------|----------------------| +| `#added` | Auto-added by `ctx add task` | `YYYY-MM-DD-HHMMSS` | +| `#started` | When you begin working on the task | `YYYY-MM-DD-HHMMSS` | +| `#done` | When you mark the task `[x]` complete | `YYYY-MM-DD-HHMMSS` | + +## Collaboration Defaults + +Standing behavioral defaults for how the agent collaborates with the +user. These apply unless the user overrides them for the session +(e.g., "skip the alternatives, just build it"). + +- **At design decisions**: always present 2+ approaches with + trade-offs before committing — don't silently pick one +- **At completion claims**: run self-audit questions (What did I + assume? What didn't I check? Where am I least confident? What + would a reviewer question?) before reporting done +- **At ambiguous moments**: ask the user rather than inferring + intent — a quick question is cheaper than rework +- **When producing artifacts**: flag assumptions and uncertainty + areas inline, not buried in a footnote + +These follow the same pattern as proactive persistence: offer once +and respect "no." + +## Own the Whole Branch + +When working on a branch, you own every issue on it — lint failures, test +failures, build errors — regardless of who introduced them. Never dismiss +a problem as "pre-existing" or "not related to my changes." + +- **If `make lint` fails, fix it.** The branch must be green when you're done. +- **If tests break, investigate.** Even if the failing test is in a file you + didn't touch, something you changed may have caused it — or it may have been + broken before and it's still your job to fix it on this branch. +- **Run the full validation suite** (`make lint`, `go test ./...`, `go build`) + before declaring any phase complete. + +## How to Avoid Hallucinating Memory + +Never assume. If you don't see it in files, you don't know it. + +- Don't claim "we discussed X" without file evidence +- Don't invent history — check context files and `ctx recall` +- If uncertain, say "I don't see this documented" +- Trust files over intuition + +## Planning Non-Trivial Work + +Before implementing a feature or multi-task effort, follow this sequence: + +**1. Spec first** — Write a design document in `specs/` covering: problem, +solution, storage, CLI surface, error cases, and non-goals. Keep it concise +but complete enough that another session could implement from it alone. + +**2. Task it out** — Break the work into individual tasks in TASKS.md under +a dedicated Phase section. Each task should be independently completable and +verifiable. + +**3. Cross-reference** — The Phase header in TASKS.md must reference the +spec: `Spec: \`specs/feature-name.md\``. The first task in the phase should +include: "Read `specs/feature-name.md` before starting any PX task." + +**4. Read before building** — When picking up a task that references a spec, +read the spec first. Don't rely on the task description alone — it's a +summary, not the full design. + +## When to Consolidate vs Add Features + +**Signs you should consolidate first:** +- Same string literal appears in 3+ files +- Hardcoded paths use string concatenation +- Test file is growing into a monolith (>500 lines) +- Package name doesn't match folder name + +When in doubt, ask: "Would a new contributor understand where this belongs?" + +## Pre-Flight Checklist: CLI Code + +Before writing or modifying CLI code (`internal/cli/**/*.go`): + +1. **Read CONVENTIONS.md** — load established patterns into context +2. **Check similar commands** — how do existing commands handle output? +3. **Use cmd methods for output** — `cmd.Printf`, `cmd.Println`, + not `fmt.Printf`, `fmt.Println` +4. **Follow docstring format** — see CONVENTIONS.md, Documentation section + +--- + +## Context Anti-Patterns + +Avoid these common context management mistakes: + +### Stale Context + +Context files become outdated and misleading when ARCHITECTURE.md +describes components that no longer exist, or CONVENTIONS.md patterns +contradict actual code. **Solution**: Update context as part of +completing work, not as a separate task. Run `ctx drift` periodically. + +### Context Sprawl + +Information scattered across multiple locations — same decision in +DECISIONS.md and a session file, conventions split between +CONVENTIONS.md and code comments. **Solution**: Single source of +truth for each type of information. Use the defined file structure. + +### Implicit Context + +Relying on knowledge not captured in artifacts — "everyone knows we +don't do X" but it's not in CONSTITUTION.md, patterns followed but +not in CONVENTIONS.md. **Solution**: If you reference something +repeatedly, add it to the appropriate file. + +### Over-Specification + +Context becomes so detailed it's impossible to maintain — 50+ rules +in CONVENTIONS.md, every minor choice gets a DECISIONS.md entry. +**Solution**: Keep artifacts focused on decisions that affect behavior +and alignment. Not everything needs documenting. + +### Context Avoidance + +Not using context because "it's faster to just code." Same mistakes +repeated across sessions, decisions re-debated because prior decisions +weren't found. **Solution**: Reading context is faster than +re-discovering it. 5 minutes reading saves 50 minutes of wasted work. + +--- + +## Context Validation Checklist + +### Quick Check (Every Session) +- [ ] TASKS.md reflects current priorities +- [ ] No obvious staleness in files you'll reference +- [ ] Recent history reviewed via `ctx recall list` + +### Deep Check (Weekly or Before Major Work) +- [ ] CONSTITUTION.md rules still apply +- [ ] ARCHITECTURE.md matches actual structure +- [ ] CONVENTIONS.md patterns match code +- [ ] DECISIONS.md has no superseded entries unmarked +- [ ] LEARNINGS.md gotchas still relevant +- [ ] Run `ctx drift` and address warnings diff --git a/internal/assets/overrides/vscode/CLAUDE.md b/internal/assets/overrides/vscode/CLAUDE.md new file mode 100644 index 00000000..6668efc6 --- /dev/null +++ b/internal/assets/overrides/vscode/CLAUDE.md @@ -0,0 +1,55 @@ +# Project Context + + + + +## IMPORTANT: You Have Persistent Memory + +This project uses Context (`ctx`) for context persistence across sessions. +**Your memory is NOT ephemeral** — it lives in the `.context/` directory. + +## On Session Start + +1. **Read `.context/AGENT_PLAYBOOK.md`** — it explains how to use this system +2. **Run `ctx agent --budget 4000`** in a terminal for an AI-optimized context summary +3. **Check `.context/TASKS.md`** for active work items + +## When Asked "Do You Remember?" + +**Do this FIRST (silently):** +- Read TASKS.md, DECISIONS.md, and LEARNINGS.md from `.context/` +- Run `ctx recall list --limit 5` for recent session history + +**Then respond with a structured readback:** + +1. **Last session**: cite the most recent session topic and date +2. **Active work**: list pending or in-progress tasks +3. **Recent context**: mention 1-2 recent decisions or learnings +4. **Next step**: offer to continue or ask what to focus on + +**Never** lead with "I don't have memory." The `.context/` files are +your memory. Read them silently, then present what you found as recall. + +## Quick Context Load + +```bash +# Get AI-optimized context packet (what you should know) +ctx agent --budget 4000 + +# Or see full status +ctx status +``` + +## Context Files + +| File | Purpose | +|------|---------| +| CONSTITUTION.md | Hard rules - NEVER violate | +| TASKS.md | Current work items | +| DECISIONS.md | Architectural decisions with rationale | +| LEARNINGS.md | Gotchas, tips, lessons learned | +| CONVENTIONS.md | Code patterns and standards | + +All files live in the `.context/` directory. + + diff --git a/internal/assets/overrides/vscode/CONSTITUTION.md b/internal/assets/overrides/vscode/CONSTITUTION.md new file mode 100644 index 00000000..a650b4a9 --- /dev/null +++ b/internal/assets/overrides/vscode/CONSTITUTION.md @@ -0,0 +1,47 @@ +# Constitution + + + +These rules are INVIOLABLE. If a task requires violating these, the task is wrong. + +## Security Invariants + +- [ ] Never commit secrets, tokens, API keys, or credentials +- [ ] Never store customer/user data in context files + +## Quality Invariants + +- [ ] All code must pass tests before commit +- [ ] No TODO comments in main branch (move to TASKS.md) +- [ ] Path construction uses language-standard path joining — no string concatenation (security: prevents path traversal) + +## Process Invariants + +- [ ] All architectural changes require a decision record + +## TASKS.md Structure Invariants + +TASKS.md must remain a replayable checklist. Uncheck all items and re-run = verify/redo all tasks in order. + +- [ ] **Never move tasks** — tasks stay in their Phase section permanently +- [ ] **Never remove Phase headers** — Phase labels provide structure and order +- [ ] **Never merge or collapse Phase sections** — each phase is a logical unit +- [ ] **Never delete tasks** — mark as `[x]` completed, or `[-]` skipped with reason +- [ ] **Use inline labels for status** — add `#in-progress` to task text, don't move it +- [ ] **No "In Progress" / "Next Up" sections** — these encourage moving tasks +- [ ] **Ask before restructuring** — if structure changes seem needed, ask the user first + +## Context Preservation Invariants + +- [ ] **Archival is allowed, deletion is not** — use `ctx tasks archive` to move completed tasks to `.context/archive/`, never delete context history +- [ ] **Archive preserves structure** — archived tasks keep their Phase headers for traceability diff --git a/internal/assets/overrides/vscode/CONVENTIONS.md b/internal/assets/overrides/vscode/CONVENTIONS.md new file mode 100644 index 00000000..cbe272ab --- /dev/null +++ b/internal/assets/overrides/vscode/CONVENTIONS.md @@ -0,0 +1,55 @@ +# Conventions + + + +## Naming + +- **Use semantic prefixes for constants**: Group related constants with prefixes + - `DIR_*` / `Dir*` for directories + - `FILE_*` / `File*` for file paths + - `*_TYPE` / `*Type` for enum-like values +- **Module/package name = folder name**: Keep names consistent with the filesystem +- **Avoid magic strings**: Use named constants instead of string literals for comparison + +## Patterns + +- **Centralize repeated literals**: All repeated literals belong in a constants/config module + - If a string appears in 3+ files, it needs a constant + - If a string is used for comparison, it needs a constant +- **Path construction**: Always use your language's standard path joining + - Python: `os.path.join(dir, file)` or `pathlib.Path(dir) / file` + - Node/TS: `path.join(dir, file)` + - Go: `filepath.Join(dir, file)` + - Rust: `PathBuf::from(dir).join(file)` + - Never: `dir + "/" + file` (string concatenation) +- **Colocate related code**: Group by feature, not by type + - `session/run.ext`, `session/types.ext`, `session/parse.ext` + - Not: `runners/session.ext`, `types/session.ext`, `parsers/session.ext` + +## Testing + +- **Colocate tests**: Test files live next to source files + - Not in a separate `tests/` folder (unless the language convention requires it) +- **Test the unit, not the file**: One test file can test multiple related functions +- **Integration tests are separate**: Clearly distinguish unit tests from end-to-end tests + +## Documentation + +- **Follow language conventions**: Use the standard doc format for your language + - Python: docstrings (Google/NumPy/Sphinx style) + - TypeScript/JavaScript: JSDoc or TSDoc + - Go: Godoc comments + - Rust: `///` doc comments with Markdown +- **Document public APIs**: Every exported function/class/type gets a doc comment +- **Copyright headers**: All source files get the project copyright header diff --git a/internal/bootstrap/cmd.go b/internal/bootstrap/cmd.go index 8b9f83fe..dfc90100 100644 --- a/internal/bootstrap/cmd.go +++ b/internal/bootstrap/cmd.go @@ -49,10 +49,12 @@ func RootCmd() *cobra.Command { short, long := assets.CommandDesc(embed.CmdDescKeyCtx) cmd := &cobra.Command{ - Use: "ctx", - Short: short, - Long: long, - Version: version, + Use: "ctx", + Short: short, + Long: long, + Version: version, + SilenceErrors: true, + SilenceUsage: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // Apply global flag values if contextDir != "" { diff --git a/internal/cli/hook/cmd/root/run.go b/internal/cli/hook/cmd/root/run.go index c5dc6055..ca56b5d0 100644 --- a/internal/cli/hook/cmd/root/run.go +++ b/internal/cli/hook/cmd/root/run.go @@ -7,6 +7,7 @@ package root import ( + "encoding/json" "os" "path/filepath" "strings" @@ -139,5 +140,43 @@ func WriteCopilotInstructions(cmd *cobra.Command) error { hook.InfoCopilotSummary(cmd) + // Also create .vscode/mcp.json if it doesn't exist + if err := ensureVSCodeMCPJSON(cmd); err != nil { + cmd.Println(" ⚠ .vscode/mcp.json: " + err.Error()) + } + + return nil +} + +// ensureVSCodeMCPJSON creates .vscode/mcp.json to register the ctx MCP +// server for VS Code Copilot. Skips if the file already exists. +func ensureVSCodeMCPJSON(cmd *cobra.Command) error { + vsDir := ".vscode" + target := filepath.Join(vsDir, "mcp.json") + + if _, err := os.Stat(target); err == nil { + cmd.Println(" ○ " + target + " (exists, skipped)") + return nil + } + + if err := os.MkdirAll(vsDir, fs.PermExec); err != nil { + return err + } + + mcpCfg := map[string]interface{}{ + "servers": map[string]interface{}{ + "ctx": map[string]interface{}{ + "command": "ctx", + "args": []string{"mcp", "serve"}, + }, + }, + } + data, _ := json.MarshalIndent(mcpCfg, "", " ") + data = append(data, '\n') + + if err := os.WriteFile(target, data, fs.PermFile); err != nil { + return err + } + cmd.Println(" ✓ " + target) return nil } diff --git a/internal/cli/hook/cmd/root/run_test.go b/internal/cli/hook/cmd/root/run_test.go new file mode 100644 index 00000000..b8e58153 --- /dev/null +++ b/internal/cli/hook/cmd/root/run_test.go @@ -0,0 +1,146 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package root + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" +) + +// testCmd returns a cobra.Command that captures output. +func testCmd(buf *bytes.Buffer) *cobra.Command { + cmd := &cobra.Command{} + cmd.SetOut(buf) + return cmd +} + +func TestEnsureVSCodeMCPJSON_CreatesFile(t *testing.T) { + tmp := t.TempDir() + + origDir, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + var buf bytes.Buffer + cmd := testCmd(&buf) + + if err := ensureVSCodeMCPJSON(cmd); err != nil { + t.Fatalf("ensureVSCodeMCPJSON() error = %v", err) + } + + target := filepath.Join(".vscode", "mcp.json") + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("failed to read mcp.json: %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("mcp.json is not valid JSON: %v", err) + } + + servers, ok := parsed["servers"].(map[string]interface{}) + if !ok { + t.Fatal("mcp.json missing 'servers' key") + } + + ctxServer, ok := servers["ctx"].(map[string]interface{}) + if !ok { + t.Fatal("mcp.json missing 'servers.ctx' key") + } + + if ctxServer["command"] != "ctx" { + t.Errorf("expected command 'ctx', got %q", ctxServer["command"]) + } + + args, ok := ctxServer["args"].([]interface{}) + if !ok || len(args) != 2 || args[0] != "mcp" || args[1] != "serve" { + t.Errorf("expected args [mcp, serve], got %v", ctxServer["args"]) + } + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("✓")) { + t.Errorf("expected success marker in output, got %q", output) + } +} + +func TestEnsureVSCodeMCPJSON_SkipsExisting(t *testing.T) { + tmp := t.TempDir() + + origDir, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + vsDir := ".vscode" + target := filepath.Join(vsDir, "mcp.json") + if err := os.MkdirAll(vsDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + existing := []byte(`{"servers":{"custom":{"command":"other"}}}`) + if err := os.WriteFile(target, existing, 0o644); err != nil { + t.Fatalf("write existing: %v", err) + } + + var buf bytes.Buffer + cmd := testCmd(&buf) + + if err := ensureVSCodeMCPJSON(cmd); err != nil { + t.Fatalf("ensureVSCodeMCPJSON() error = %v", err) + } + + // File should not be overwritten + data, _ := os.ReadFile(target) + if string(data) != string(existing) { + t.Error("ensureVSCodeMCPJSON overwrote existing file") + } + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("exists")) { + t.Errorf("expected 'exists' in output, got %q", output) + } +} + +func TestEnsureVSCodeMCPJSON_CreatesVSCodeDir(t *testing.T) { + tmp := t.TempDir() + + origDir, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Ensure .vscode/ does NOT exist beforehand + vsDir := filepath.Join(tmp, ".vscode") + if _, err := os.Stat(vsDir); err == nil { + t.Fatal(".vscode should not exist yet") + } + + var buf bytes.Buffer + cmd := testCmd(&buf) + + if err := ensureVSCodeMCPJSON(cmd); err != nil { + t.Fatalf("ensureVSCodeMCPJSON() error = %v", err) + } + + // .vscode/ should now exist + info, err := os.Stat(".vscode") + if err != nil { + t.Fatalf(".vscode dir was not created: %v", err) + } + if !info.IsDir() { + t.Error(".vscode should be a directory") + } +} diff --git a/internal/cli/initialize/cmd/root/cmd.go b/internal/cli/initialize/cmd/root/cmd.go index 0a55426e..710929ab 100644 --- a/internal/cli/initialize/cmd/root/cmd.go +++ b/internal/cli/initialize/cmd/root/cmd.go @@ -39,6 +39,7 @@ func Cmd() *cobra.Command { merge bool ralph bool noPluginEnable bool + caller string ) short, long := assets.CommandDesc(embed.CmdDescKeyInitialize) @@ -48,7 +49,7 @@ func Cmd() *cobra.Command { Annotations: map[string]string{cli.AnnotationSkipInit: cli.AnnotationTrue}, Long: long, RunE: func(cmd *cobra.Command, args []string) error { - return Run(cmd, force, minimal, merge, ralph, noPluginEnable) + return Run(cmd, force, minimal, merge, ralph, noPluginEnable, caller) }, } @@ -73,6 +74,10 @@ func Cmd() *cobra.Command { &noPluginEnable, "no-plugin-enable", false, assets.FlagDesc(embed.FlagDescKeyInitializeNoPluginEnable), ) + cmd.Flags().StringVar( + &caller, "caller", "", + "Identify the calling tool (e.g. vscode) to tailor output", + ) return cmd } diff --git a/internal/cli/initialize/cmd/root/run.go b/internal/cli/initialize/cmd/root/run.go index 73794f83..30ce0d2d 100644 --- a/internal/cli/initialize/cmd/root/run.go +++ b/internal/cli/initialize/cmd/root/run.go @@ -45,13 +45,17 @@ const gitignoreHeader = "# ctx managed entries" // - merge: If true, auto-merge ctx content into existing files // - ralph: If true, use autonomous loop templates (no questions, signals) // - noPluginEnable: If true, skip auto-enabling the plugin globally +// - caller: Identifies the calling tool (e.g. "vscode") for template overrides // // Returns: // - error: Non-nil if directory creation or file operations fail -func Run(cmd *cobra.Command, force, minimal, merge, ralph, noPluginEnable bool) error { - // Check if ctx is in PATH (required for hooks to work) - if err := core.CheckCtxInPath(cmd); err != nil { - return err +func Run(cmd *cobra.Command, force, minimal, merge, ralph, noPluginEnable bool, caller string) error { + // Check if ctx is in PATH (required for hooks to work). + // Skip when a caller is set — the caller manages its own binary path. + if caller == "" { + if err := core.CheckCtxInPath(cmd); err != nil { + return err + } } contextDir := rc.ContextDir() @@ -103,7 +107,7 @@ func Run(cmd *cobra.Command, force, minimal, merge, ralph, noPluginEnable bool) continue } - content, err := assets.Template(name) + content, err := assets.TemplateForCaller(name, caller) if err != nil { return ctxerr.ReadTemplate(name, err) } @@ -155,39 +159,60 @@ func Run(cmd *cobra.Command, force, minimal, merge, ralph, noPluginEnable bool) initialize.InfoWarnNonFatal(cmd, "IMPLEMENTATION_PLAN.md", err) } - // Merge permissions into settings.local.json (no hook scaffolding) - initialize.InfoSettingUpPermissions(cmd) - if err := core.MergeSettingsPermissions(cmd); err != nil { - // Non-fatal: warn but continue - initialize.InfoWarnNonFatal(cmd, "Permissions", err) - } - - // Auto-enable plugin globally unless suppressed - if !noPluginEnable { - if pluginErr := core.EnablePluginGlobally(cmd); pluginErr != nil { + // Claude Code specific artifacts — skip when called from another editor. + // These create .claude/settings.local.json, enable the plugin, + // and deploy Makefile.ctx — none of which are used by VS Code. + if caller == "" { + // Merge permissions into settings.local.json (no hook scaffolding) + initialize.InfoSettingUpPermissions(cmd) + if err := core.MergeSettingsPermissions(cmd); err != nil { // Non-fatal: warn but continue - initialize.InfoWarnNonFatal(cmd, "Plugin enablement", pluginErr) + initialize.InfoWarnNonFatal(cmd, "Permissions", err) + } + + // Auto-enable plugin globally unless suppressed + if !noPluginEnable { + if pluginErr := core.EnablePluginGlobally(cmd); pluginErr != nil { + // Non-fatal: warn but continue + initialize.InfoWarnNonFatal(cmd, "Plugin enablement", pluginErr) + } } } - // Handle CLAUDE.md creation/merge - if err := core.HandleClaudeMd(cmd, force, merge); err != nil { + // Handle CLAUDE.md creation/merge (uses caller-specific override when available) + if err := core.HandleClaudeMd(cmd, force, merge, caller); err != nil { // Non-fatal: warn but continue initialize.InfoWarnNonFatal(cmd, "CLAUDE.md", err) } - // Deploy Makefile.ctx and amend user Makefile - if err := core.HandleMakefileCtx(cmd); err != nil { - // Non-fatal: warn but continue - initialize.InfoWarnNonFatal(cmd, "Makefile", err) + // Deploy Makefile.ctx and amend user Makefile (Claude Code only) + if caller == "" { + if err := core.HandleMakefileCtx(cmd); err != nil { + // Non-fatal: warn but continue + initialize.InfoWarnNonFatal(cmd, "Makefile", err) + } } // Update .gitignore with recommended entries - if err := ensureGitignoreEntries(cmd); err != nil { + if err := ensureGitignoreEntries(cmd, caller); err != nil { initialize.InfoWarnNonFatal(cmd, ".gitignore", err) } - initialize.InfoNextSteps(cmd) + // Caller-specific setup — skip Claude Code instructions when called + // from another editor. + if caller == "" { + initialize.InfoNextSteps(cmd) + } else { + // VS Code / other editor specific setup + cmd.Println("\nSetting up editor integration...") + if err := core.CreateVSCodeArtifacts(cmd); err != nil { + initialize.InfoWarnNonFatal(cmd, "VS Code artifacts", err) + } + + cmd.Println("\nNext steps:") + cmd.Println(" 1. Edit .context/TASKS.md to add your current tasks") + cmd.Println(" 2. Run '@ctx /status' to see context summary") + } return nil } @@ -273,7 +298,10 @@ func hasEssentialFiles(contextDir string) bool { // ensureGitignoreEntries appends recommended .gitignore entries that are not // already present. Creates .gitignore if it does not exist. -func ensureGitignoreEntries(cmd *cobra.Command) error { +// +// When caller is non-empty (editor integration), Claude Code-specific +// entries like .claude/settings.local.json are skipped. +func ensureGitignoreEntries(cmd *cobra.Command, caller string) error { gitignorePath := ".gitignore" content, err := os.ReadFile(gitignorePath) @@ -287,9 +315,13 @@ func ensureGitignoreEntries(cmd *cobra.Command) error { existing[strings.TrimSpace(line)] = true } - // Collect missing entries. + // Collect missing entries, skipping Claude Code-specific ones for editors. var missing []string for _, entry := range file.Gitignore { + // .claude/ entries are only relevant when running without a caller + if caller != "" && strings.HasPrefix(entry, ".claude/") { + continue + } if !existing[entry] { missing = append(missing, entry) } diff --git a/internal/cli/initialize/core/claude.go b/internal/cli/initialize/core/claude.go index 21a2bdf7..6ae6cbc6 100644 --- a/internal/cli/initialize/core/claude.go +++ b/internal/cli/initialize/core/claude.go @@ -33,11 +33,12 @@ import ( // - cmd: Cobra command for output // - force: If true, overwrite existing ctx section // - autoMerge: If true, skip interactive confirmation +// - caller: Editor caller ID (e.g. "vscode"); empty for CLI/Claude Code // // Returns: // - error: Non-nil if file operations fail -func HandleClaudeMd(cmd *cobra.Command, force, autoMerge bool) error { - templateContent, err := assets.ClaudeMd() +func HandleClaudeMd(cmd *cobra.Command, force, autoMerge bool, caller string) error { + templateContent, err := assets.ClaudeMdForCaller(caller) if err != nil { return ctxerr.ReadTemplate("CLAUDE.md", err) } diff --git a/internal/cli/initialize/core/vscode.go b/internal/cli/initialize/core/vscode.go new file mode 100644 index 00000000..ddc831fa --- /dev/null +++ b/internal/cli/initialize/core/vscode.go @@ -0,0 +1,194 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config/fs" +) + +// vscodeDirName is the VS Code workspace configuration directory. +const vscodeDirName = ".vscode" + +// CreateVSCodeArtifacts generates VS Code-native configuration files +// as the editor-specific counterpart to Claude Code's settings and hooks. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if file creation fails +func CreateVSCodeArtifacts(cmd *cobra.Command) error { + if err := os.MkdirAll(vscodeDirName, fs.PermExec); err != nil { + return fmt.Errorf("failed to create %s/: %w", vscodeDirName, err) + } + + // .vscode/extensions.json — recommend the ctx extension to collaborators + if err := writeExtensionsJSON(cmd); err != nil { + cmd.Println(fmt.Sprintf(" ⚠ extensions.json: %v", err)) + } + + // .vscode/tasks.json — register ctx commands as VS Code tasks + if err := writeTasksJSON(cmd); err != nil { + cmd.Println(fmt.Sprintf(" ⚠ tasks.json: %v", err)) + } + + // .vscode/mcp.json — register ctx MCP server for Copilot + if err := writeMCPJSON(cmd); err != nil { + cmd.Println(fmt.Sprintf(" ⚠ mcp.json: %v", err)) + } + + return nil +} + +func writeExtensionsJSON(cmd *cobra.Command) error { + target := filepath.Join(vscodeDirName, "extensions.json") + + if _, err := os.Stat(target); err == nil { + // Exists — check if recommendation is already present + data, readErr := os.ReadFile(filepath.Clean(target)) //nolint:gosec // path built from constants + if readErr != nil { + return readErr + } + var existing map[string]interface{} + if json.Unmarshal(data, &existing) == nil { + if recs, ok := existing["recommendations"].([]interface{}); ok { + for _, r := range recs { + if r == "activememory.ctx-context" { + cmd.Println(fmt.Sprintf(" ○ %s (recommendation exists)", target)) + return nil + } + } + } + } + // File exists but doesn't have our recommendation — leave it alone + cmd.Println(fmt.Sprintf(" ○ %s (exists, add activememory.ctx-context manually)", target)) + return nil + } + + content := map[string][]string{ + "recommendations": {"activememory.ctx-context"}, + } + data, _ := json.MarshalIndent(content, "", " ") + data = append(data, '\n') + + if err := os.WriteFile(target, data, fs.PermFile); err != nil { + return err + } + cmd.Println(fmt.Sprintf(" ✓ %s", target)) + return nil +} + +func writeTasksJSON(cmd *cobra.Command) error { + target := filepath.Join(vscodeDirName, "tasks.json") + + if _, err := os.Stat(target); err == nil { + cmd.Println(fmt.Sprintf(" ○ %s (exists, skipped)", target)) + return nil + } + + tasks := map[string]interface{}{ + "version": "2.0.0", + "tasks": []map[string]interface{}{ + { + "label": "ctx: status", + "type": "shell", + "command": "ctx status", + "group": "none", + "presentation": map[string]interface{}{ + "reveal": "always", + "panel": "shared", + }, + "problemMatcher": []interface{}{}, + }, + { + "label": "ctx: drift", + "type": "shell", + "command": "ctx drift", + "group": "none", + "presentation": map[string]interface{}{ + "reveal": "always", + "panel": "shared", + }, + "problemMatcher": []interface{}{}, + }, + { + "label": "ctx: agent", + "type": "shell", + "command": "ctx agent --budget 4000", + "group": "none", + "presentation": map[string]interface{}{ + "reveal": "always", + "panel": "shared", + }, + "problemMatcher": []interface{}{}, + }, + { + "label": "ctx: journal", + "type": "shell", + "command": "ctx recall export --all && ctx journal site --build", + "group": "none", + "presentation": map[string]interface{}{ + "reveal": "always", + "panel": "shared", + }, + "problemMatcher": []interface{}{}, + }, + { + "label": "ctx: journal-serve", + "type": "shell", + "command": "ctx journal site --serve", + "group": "none", + "presentation": map[string]interface{}{ + "reveal": "always", + "panel": "shared", + }, + "problemMatcher": []interface{}{}, + }, + }, + } + data, _ := json.MarshalIndent(tasks, "", " ") + data = append(data, '\n') + + if err := os.WriteFile(target, data, fs.PermFile); err != nil { + return err + } + cmd.Println(fmt.Sprintf(" ✓ %s", target)) + return nil +} + +func writeMCPJSON(cmd *cobra.Command) error { + target := filepath.Join(vscodeDirName, "mcp.json") + + if _, err := os.Stat(target); err == nil { + cmd.Println(fmt.Sprintf(" ○ %s (exists, skipped)", target)) + return nil + } + + mcp := map[string]interface{}{ + "servers": map[string]interface{}{ + "ctx": map[string]interface{}{ + "command": "ctx", + "args": []string{"mcp", "serve"}, + }, + }, + } + data, _ := json.MarshalIndent(mcp, "", " ") + data = append(data, '\n') + + if err := os.WriteFile(target, data, fs.PermFile); err != nil { + return err + } + cmd.Println(fmt.Sprintf(" ✓ %s", target)) + return nil +} diff --git a/internal/cli/initialize/core/vscode_test.go b/internal/cli/initialize/core/vscode_test.go new file mode 100644 index 00000000..662455b8 --- /dev/null +++ b/internal/cli/initialize/core/vscode_test.go @@ -0,0 +1,147 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" +) + +// testCmd returns a cobra.Command that captures output. +func testCmd(buf *bytes.Buffer) *cobra.Command { + cmd := &cobra.Command{} + cmd.SetOut(buf) + return cmd +} + +func TestWriteMCPJSON_CreatesFile(t *testing.T) { + tmp := t.TempDir() + + origDir, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + if err := os.MkdirAll(vscodeDirName, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + var buf bytes.Buffer + cmd := testCmd(&buf) + + if err := writeMCPJSON(cmd); err != nil { + t.Fatalf("writeMCPJSON() error = %v", err) + } + + target := filepath.Join(vscodeDirName, "mcp.json") + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("failed to read mcp.json: %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("mcp.json is not valid JSON: %v", err) + } + + servers, ok := parsed["servers"].(map[string]interface{}) + if !ok { + t.Fatal("mcp.json missing 'servers' key") + } + + ctxServer, ok := servers["ctx"].(map[string]interface{}) + if !ok { + t.Fatal("mcp.json missing 'servers.ctx' key") + } + + if ctxServer["command"] != "ctx" { + t.Errorf("expected command 'ctx', got %q", ctxServer["command"]) + } + + output := buf.String() + if len(output) == 0 { + t.Error("expected output message for created file") + } +} + +func TestWriteMCPJSON_SkipsExisting(t *testing.T) { + tmp := t.TempDir() + + origDir, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + target := filepath.Join(vscodeDirName, "mcp.json") + if err := os.MkdirAll(vscodeDirName, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + existing := []byte(`{"servers":{"other":{}}}`) + if err := os.WriteFile(target, existing, 0o644); err != nil { + t.Fatalf("write existing: %v", err) + } + + var buf bytes.Buffer + cmd := testCmd(&buf) + + if err := writeMCPJSON(cmd); err != nil { + t.Fatalf("writeMCPJSON() error = %v", err) + } + + // File should not be overwritten + data, _ := os.ReadFile(target) + if string(data) != string(existing) { + t.Error("writeMCPJSON overwrote existing file") + } + + output := buf.String() + if !bytes.Contains([]byte(output), []byte("exists")) { + t.Errorf("expected 'exists' in output, got %q", output) + } +} + +func TestCreateVSCodeArtifacts_CreatesMCPJSON(t *testing.T) { + tmp := t.TempDir() + + origDir, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + var buf bytes.Buffer + cmd := testCmd(&buf) + + if err := CreateVSCodeArtifacts(cmd); err != nil { + t.Fatalf("CreateVSCodeArtifacts() error = %v", err) + } + + // Verify mcp.json was created as part of the artifacts + target := filepath.Join(vscodeDirName, "mcp.json") + if _, err := os.Stat(target); os.IsNotExist(err) { + t.Error("CreateVSCodeArtifacts did not create mcp.json") + } + + // Verify extensions.json was also created + extTarget := filepath.Join(vscodeDirName, "extensions.json") + if _, err := os.Stat(extTarget); os.IsNotExist(err) { + t.Error("CreateVSCodeArtifacts did not create extensions.json") + } + + // Verify tasks.json was also created + taskTarget := filepath.Join(vscodeDirName, "tasks.json") + if _, err := os.Stat(taskTarget); os.IsNotExist(err) { + t.Error("CreateVSCodeArtifacts did not create tasks.json") + } +} diff --git a/internal/cli/system/cmd/sessionevent/cmd.go b/internal/cli/system/cmd/sessionevent/cmd.go new file mode 100644 index 00000000..bfabda38 --- /dev/null +++ b/internal/cli/system/cmd/sessionevent/cmd.go @@ -0,0 +1,68 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package sessionevent + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/cli/system/core" + "github.com/ActiveMemory/ctx/internal/log" + "github.com/ActiveMemory/ctx/internal/notify" +) + +// Cmd returns the "ctx system session-event" subcommand. +// +// Returns: +// - *cobra.Command: Configured session-event subcommand +func Cmd() *cobra.Command { + var eventType string + var caller string + + cmd := &cobra.Command{ + Use: "session-event", + Short: "Record session start or end", + Long: `Records a session lifecycle event (start or end) to the event log. +Called by editor integrations when a workspace is opened or closed. + +Examples: + ctx system session-event --type start --caller vscode + ctx system session-event --type end --caller vscode`, + Hidden: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return runSessionEvent(cmd, eventType, caller) + }, + } + + cmd.Flags().StringVar(&eventType, "type", "", "Event type: start or end") + cmd.Flags().StringVar(&caller, "caller", "", "Calling editor (e.g., vscode)") + _ = cmd.MarkFlagRequired("type") + _ = cmd.MarkFlagRequired("caller") + + return cmd +} + +func runSessionEvent(cmd *cobra.Command, eventType, caller string) error { + if !core.Initialized() { + return nil + } + + if eventType != "start" && eventType != "end" { + return fmt.Errorf("--type must be 'start' or 'end', got %q", eventType) + } + + msg := fmt.Sprintf("session-%s: %s", eventType, caller) + ref := notify.NewTemplateRef("session-event", eventType, + map[string]any{"Caller": caller}) + + log.AppendEvent("session", msg, "", ref) + _ = notify.Send("session", msg, "", ref) + + cmd.Println(msg) + return nil +} diff --git a/internal/cli/system/system.go b/internal/cli/system/system.go index ce72dfe8..9df9b5c2 100644 --- a/internal/cli/system/system.go +++ b/internal/cli/system/system.go @@ -40,6 +40,7 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/system/cmd/qa_reminder" "github.com/ActiveMemory/ctx/internal/cli/system/cmd/resources" "github.com/ActiveMemory/ctx/internal/cli/system/cmd/resume" + "github.com/ActiveMemory/ctx/internal/cli/system/cmd/sessionevent" "github.com/ActiveMemory/ctx/internal/cli/system/cmd/specs_nudge" "github.com/ActiveMemory/ctx/internal/cli/system/cmd/stats" ) @@ -98,6 +99,7 @@ func Cmd() *cobra.Command { qa_reminder.Cmd(), resources.Cmd(), resume.Cmd(), + sessionevent.Cmd(), specs_nudge.Cmd(), stats.Cmd(), ) diff --git a/internal/compliance/compliance_test.go b/internal/compliance/compliance_test.go index dd18e7dc..0b3a592d 100644 --- a/internal/compliance/compliance_test.go +++ b/internal/compliance/compliance_test.go @@ -864,3 +864,99 @@ func TestPermissionConstants(t *testing.T) { } }) } + +// allSourceFiles returns all source files (.go, .ts, .js) under the project +// root, excluding vendor/, node_modules/, dist/, site/, and .git/. +func allSourceFiles(t *testing.T, root string) []string { + t.Helper() + sourceExts := map[string]bool{ + ".go": true, + ".ts": true, + ".js": true, + } + var files []string + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() && (info.Name() == "vendor" || info.Name() == ".git" || + info.Name() == "dist" || info.Name() == "site" || info.Name() == "node_modules") { + return filepath.SkipDir + } + if !info.IsDir() && sourceExts[filepath.Ext(path)] { + files = append(files, path) + } + return nil + }) + if err != nil { + t.Fatalf("failed to walk project: %v", err) + } + return files +} + +// --------------------------------------------------------------------------- +// 21. No UTF-8 BOM — source files must not start with a byte-order mark +// --------------------------------------------------------------------------- + +// TestNoUTF8BOM detects the UTF-8 BOM (0xEF 0xBB 0xBF) that Windows editors +// sometimes insert. BOM causes subtle issues with Go tooling and TypeScript +// compilers and should never appear in source files. +func TestNoUTF8BOM(t *testing.T) { + root := projectRoot(t) + bom := []byte{0xEF, 0xBB, 0xBF} + + for _, p := range allSourceFiles(t, root) { + rel, _ := filepath.Rel(root, p) + t.Run(rel, func(t *testing.T) { + data, err := os.ReadFile(filepath.Clean(p)) + if err != nil { + t.Fatalf("read: %v", err) + } + if bytes.HasPrefix(data, bom) { + t.Errorf("file starts with UTF-8 BOM (0xEF 0xBB 0xBF); remove it") + } + }) + } +} + +// --------------------------------------------------------------------------- +// 22. No mojibake — detect double-encoded UTF-8 (encoding corruption) +// --------------------------------------------------------------------------- + +// TestNoMojibake catches the classic Windows encoding corruption where UTF-8 +// bytes are misread as Windows-1252/Latin-1 and re-encoded as UTF-8. +// Example: em dash U+2014 becomes a 6-byte garbled sequence starting with +// 0xC3 0xA2. We detect that signature to catch double-encoded files. +func TestNoMojibake(t *testing.T) { + root := projectRoot(t) + // 0xC3 0xA2 is UTF-8 for U+00E2 (Latin small letter a with circumflex). + // In mojibake, it always appears followed by 0xE2 as part of a garbled + // multi-byte sequence (e.g., em dash becomes 0xC3 0xA2 0xE2 0x82 ...). + // We match that three-byte signature: 0xC3 0xA2 0xE2. + mojibakePattern := []byte{0xC3, 0xA2, 0xE2} + + for _, p := range allSourceFiles(t, root) { + rel, _ := filepath.Rel(root, p) + t.Run(rel, func(t *testing.T) { + data, err := os.ReadFile(filepath.Clean(p)) + if err != nil { + t.Fatalf("read: %v", err) + } + if idx := bytes.Index(data, mojibakePattern); idx >= 0 { + // Show context around the corruption + start := idx + if start > 20 { + start = idx - 20 + } + end := idx + 30 + if end > len(data) { + end = len(data) + } + t.Errorf("double-encoded UTF-8 (mojibake) detected at byte %d: %q\n"+ + "This usually means a Windows editor re-encoded the file.\n"+ + "Fix: restore from git (git checkout HEAD -- %s) and re-apply changes with a UTF-8-aware editor.", + idx, data[start:end], rel) + } + }) + } +} diff --git a/specs/vscode-feature-parity.md b/specs/vscode-feature-parity.md new file mode 100644 index 00000000..5b4ea562 --- /dev/null +++ b/specs/vscode-feature-parity.md @@ -0,0 +1,128 @@ +# VS Code Extension Feature Parity Spec + +> Goal: Native port of every Claude Code integration feature to VS Code equivalents. +> Each item maps a Claude Code mechanism to the correct VS Code platform primitive. + +## Layer 0 — Shared Core (editor-agnostic) + +These are identical across all editors. Created by `ctx init` regardless of `--caller`. + +| # | Feature | Files Created | Status | +|---|---------|--------------|--------| +| 0.1 | `.context/*.md` templates (9 files) | TASKS, DECISIONS, LEARNINGS, CONVENTIONS, CONSTITUTION, ARCHITECTURE, GLOSSARY, AGENT_PLAYBOOK, PROMPT | Done | +| 0.2 | Entry templates | `.context/templates/*.md` | Done | +| 0.3 | Prompt templates | `.context/prompts/*.md` | Done | +| 0.4 | Project directories | `specs/`, `ideas/` with README.md | Done | +| 0.5 | PROMPT.md | Project root prompt template | Done | +| 0.6 | IMPLEMENTATION_PLAN.md | Project root plan template | Done | +| 0.7 | .gitignore entries | `.context/state/`, `.context/memory/`, etc. | Done | +| 0.8 | Scratchpad | `.context/scratch.md` or encrypted `.enc` | Done | + +## Layer 1 — Init Artifacts (editor-specific) + +Files created by `ctx init --caller vscode` that are VS Code platform native. + +| # | Claude Code | Claude Mechanism | VS Code Equivalent | VS Code Mechanism | Status | +|---|-------------|-----------------|---------------------|-------------------|--------| +| 1.1 | `CLAUDE.md` (agent instructions) | `HandleClaudeMd()` — Claude reads this on session start | `.github/copilot-instructions.md` | Copilot reads this automatically on every chat session. Already generated by `ctx hook copilot --write`. Init should call this for vscode caller. | **Partial** — generated by `/hook` but not wired into init | +| 1.2 | `.claude/settings.local.json` (permissions: allow/deny lists) | `MergeSettingsPermissions()` — controls what tools Claude can use | `.vscode/settings.json` (ctx extension settings) | VS Code extensions don't have a tool permission model. Instead, write `ctx.*` configuration keys: `ctx.executablePath`, `ctx.autoContextLoad`, `ctx.sessionTracking`. | **Not started** | +| 1.3 | Plugin enablement (`~/.claude/settings.json`) | `EnablePluginGlobally()` — adds to global enabledPlugins | `.vscode/extensions.json` (recommended extensions) | VS Code workspace recommendations. Write `{"recommendations": ["activememory.ctx-context"]}` so collaborators get prompted to install. | **Not started** | +| 1.4 | `Makefile.ctx` (build targets) | `HandleMakefileCtx()` — ctx-managed make targets | `.vscode/tasks.json` (build tasks) | Register `ctx status`, `ctx drift`, `ctx agent` as VS Code tasks so they appear in Ctrl+Shift+B / Task Runner. | **Not started** | + +## Layer 2 — Hooks (event-driven automation) + +Claude Code hooks fire on tool use events. VS Code equivalents use extension API event handlers. + +| # | Claude Hook | Claude Trigger | What It Does | VS Code Equivalent | VS Code API | Status | +|---|-------------|---------------|--------------|---------------------|-------------|--------| +| 2.1 | PreToolUse `ctx agent --budget 4000` | Every tool invocation | Loads full context packet with cooldown | Chat participant handler preamble | Already implicit: each `@ctx` invocation can load context. Could add explicit `ctx agent` call as preamble to non-init commands. | **Implicit** | +| 2.2 | PreToolUse `context-load-gate` | Every tool invocation | Validates `.context/` exists | Chat participant handler check | Already done: handler checks `getWorkspaceRoot()`. Could add `.context/` existence check with init prompt. | **Partial** | +| 2.3 | PreToolUse `block-non-path-ctx` | Bash tool | Prevents shell from directly accessing context files | N/A | VS Code doesn't execute arbitrary bash on user's behalf. The extension is the sole interface. | **N/A** | +| 2.4 | PreToolUse `qa-reminder` | Bash tool | Reminds about QA checks | N/A | No equivalent — VS Code Copilot doesn't have pre-tool hooks. Could surface via status bar or notification. | **Deferred** | +| 2.5 | PreToolUse `specs-nudge` | EnterPlanMode | Nudges to review specs/ before planning | N/A | No plan mode concept in VS Code. Could trigger when `/agent` or freeform mentions "plan"/"design". | **Deferred** | +| 2.6 | PostToolUse `check-task-completion` | Edit/Write tool | Detects completed tasks after file edits | `onDidSaveTextDocument` | `vscode.workspace.onDidSaveTextDocument` — when a `.context/TASKS.md` is saved, run `ctx system check-task-completion`. | **Not started** | +| 2.7 | PostToolUse `post-commit` | Bash tool (git commit) | Captures context after commits | Git extension API | `vscode.extensions.getExtension('vscode.git')` → `git.onDidCommit` or use `postCommitCommand` setting to run `ctx system post-commit`. | **Not started** | +| 2.8 | UserPromptSubmit `check-context-size` | Every user message | Monitors token usage at 80% | N/A | VS Code Copilot doesn't expose token counts. | **N/A** | +| 2.9 | UserPromptSubmit `check-persistence` | Every user message | Ensures context changes are persisted | `onDidSaveTextDocument` | Watch `.context/` files. If modified externally, refresh cached state. | **Not started** | +| 2.10 | UserPromptSubmit `check-reminders` | Every user message | Surfaces due reminders | Status bar + periodic timer | `vscode.window.createStatusBarItem()` — show reminder count. Check on activation and periodically. | **Not started** | +| 2.11 | UserPromptSubmit `check-version` | Every user message | Warns on version mismatch | Bootstrap version check | `ensureCtxAvailable()` already checks version. Could compare against expected. | **Done** | +| 2.12 | UserPromptSubmit `check-ceremonies` | Every user message | Validates session checkpoints | Window close handler | `vscode.workspace.onWillSaveNotebookDocument` or `vscode.window.onDidChangeWindowState` — prompt for session wrap-up. | **Not started** | +| 2.13 | UserPromptSubmit `check-resources` | Every user message | Reports system resources | N/A | Not relevant for VS Code — no token budget concerns. | **N/A** | +| 2.14 | UserPromptSubmit `heartbeat` | Every user message | Telemetry ping | Extension telemetry | `vscode.env.telemetryLevel` — respect user preference, send via VS Code telemetry API. | **Deferred** | +| 2.15 | UserPromptSubmit `check-journal` | Every user message | Audits journal completeness | Periodic check | Could run on session end or as a follow-up suggestion. | **Deferred** | +| 2.16 | UserPromptSubmit `check-knowledge` | Every user message | Validates knowledge graph | N/A | Knowledge graph is Claude Code specific. | **N/A** | +| 2.17 | UserPromptSubmit `check-map-staleness` | Every user message | Detects stale dependency maps | `FileSystemWatcher` | `vscode.workspace.createFileSystemWatcher('**/go.mod')` etc. — watch dependency files, mark maps stale. | **Deferred** | +| 2.18 | UserPromptSubmit `check-memory-drift` | Every user message | Compares memory with context files | Periodic check | Could run on `/status` or `/drift` rather than every message. | **Deferred** | + +## Layer 3 — Skills → Slash Commands + +Claude Code skills become VS Code chat participant slash commands. + +| # | Claude Skill | What It Does | VS Code Command | Status | +|---|-------------|-------------|-----------------|--------| +| 3.1 | `ctx-agent` | Load full context packet | `/agent` | **Done** | +| 3.2 | `ctx-status` | Show context summary | `/status` | **Done** | +| 3.3 | `ctx-drift` | Detect stale context | `/drift` | **Done** | +| 3.4 | `ctx-add-decision` | Record decisions | `/add decision ...` | **Done** | +| 3.5 | `ctx-add-learning` | Record learnings | `/add learning ...` | **Done** | +| 3.6 | `ctx-add-convention` | Record conventions | `/add convention ...` | **Done** | +| 3.7 | `ctx-add-task` | Add tasks | `/add task ...` | **Done** | +| 3.8 | `ctx-recall` | Browse session history | `/recall` | **Done** | +| 3.9 | `ctx-pad` | Transient working document | `/pad` | **Done** | +| 3.10 | `ctx-archive` | Archive completed tasks | `/tasks archive` | **Done** | +| 3.11 | `ctx-commit` | Commit with context capture | `/sync` | **Done** (via sync) | +| 3.12 | `ctx-doctor` | Diagnose context health | `/system doctor` | **Done** (via system) | +| 3.13 | `ctx-remind` | Session reminders | `/remind` | **Done** | +| 3.14 | `ctx-complete` | Mark task completed | `/complete` | **Done** | +| 3.15 | `ctx-compact` | Compact/archive tasks | `/compact` | **Done** | +| 3.16 | `ctx-notify` | Webhook notifications | `/notify` | **Done** | +| 3.17 | `ctx-brainstorm` | Ideas → validated designs | Not mapped | **Not started** | +| 3.18 | `ctx-spec` | Scaffold feature specs | Not mapped | **Not started** | +| 3.19 | `ctx-implement` | Execute plans step-by-step | Not mapped | **Not started** | +| 3.20 | `ctx-next` | Choose next work item | Not mapped | **Not started** | +| 3.21 | `ctx-verify` | Run verification | Not mapped | **Not started** | +| 3.22 | `ctx-blog` | Generate blog post | Not mapped | **Deferred** (niche) | +| 3.23 | `ctx-blog-changelog` | Blog from commits | Not mapped | **Deferred** (niche) | +| 3.24 | `ctx-check-links` | Audit dead links | Not mapped | **Deferred** (niche) | +| 3.25 | `ctx-journal-*` | Journal enrichment | Not mapped | **Deferred** | +| 3.26 | `ctx-consolidate` | Merge overlapping entries | Not mapped | **Deferred** | +| 3.27 | `ctx-alignment-audit` | Audit doc alignment | Not mapped | **Deferred** | +| 3.28 | `ctx-map` | Dependency visualization | Not mapped | **Not started** | +| 3.29 | `ctx-import-plans` | Import plan files | Not mapped | **Deferred** | +| 3.30 | `ctx-prompt` | Work with prompt templates | Not mapped | **Deferred** | +| 3.31 | `ctx-context-monitor` | Real-time context monitoring | Not mapped | **Deferred** | +| 3.32 | `ctx-loop` | Interactive REPL | N/A | **N/A** (no concept in chat UI) | +| 3.33 | `ctx-worktree` | Git worktree management | Not mapped | **Deferred** | +| 3.34 | `ctx-reflect` | Surface persist-worthy items | Not mapped | **Not started** | +| 3.35 | `ctx-wrap-up` | End-of-session ceremony | Not mapped | **Not started** | +| 3.36 | `ctx-remember` | Session recall at startup | Not mapped | **Not started** | +| 3.37 | `ctx-pause` / `ctx-resume` | Pause/resume state | Not mapped | **Deferred** | + +## Priority Matrix + +### P0 — Must have for init to work correctly +- [ ] 1.1 — Generate `copilot-instructions.md` during init (wire hook copilot into init flow) +- [ ] 1.3 — Generate `.vscode/extensions.json` recommending ctx extension +- [ ] 2.2 — Check `.context/` exists, prompt to init if missing + +### P1 — Core event hooks (native port of Claude hooks) +- [ ] 2.6 — `onDidSaveTextDocument` → task completion check +- [ ] 2.7 — Git post-commit → context capture +- [ ] 2.10 — Status bar reminder indicator +- [ ] 2.12 — Session end ceremony prompt + +### P2 — Init artifacts for team workflow +- [ ] 1.2 — `.vscode/settings.json` with ctx configuration +- [ ] 1.4 — `.vscode/tasks.json` with ctx tasks +- [ ] 2.9 — Watch `.context/` for external changes + +### P3 — Missing slash commands (high value) +- [ ] 3.17 — `/brainstorm` +- [ ] 3.20 — `/next` +- [ ] 3.34 — `/reflect` +- [ ] 3.35 — `/wrapup` +- [ ] 3.36 — `/remember` + +### Deferred — Lower priority or niche +- 2.4, 2.5, 2.14, 2.15, 2.17, 2.18 +- 3.18, 3.19, 3.21–3.33, 3.37 From 0e17a206048a50027a95ea90c9a3e40897752d27 Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Tue, 17 Mar 2026 19:49:52 +0300 Subject: [PATCH 4/6] feat: MCP governance engine with detection ring integration - Governance checker (CheckGovernance) appends contextual warnings to every MCP tool response: session-not-started, context-not-loaded, drift-stale, persist-nudge, and violation escalation - Per-tool state recording: RecordSessionStart, RecordContextLoaded, RecordDriftCheck, RecordContextWrite, IncrementCallsSinceWrite - Violation reading from .context/state/violations.json with automatic cleanup after escalation (read-and-clear pattern) - Governance wired into route/tool dispatch with appendGovernance - RecordSessionStart called on session event handler - copilot-instructions.md template with governance rules and detection ring documentation - 24 governance tests covering all check paths and edge cases Signed-off-by: ersan bilik --- internal/assets/hooks/copilot-instructions.md | 74 ++++ internal/mcp/handler/tool.go | 1 + internal/mcp/server/route/tool/dispatch.go | 59 ++- internal/mcp/server/server_test.go | 10 + internal/mcp/session/governance.go | 180 ++++++++++ internal/mcp/session/governance_test.go | 339 ++++++++++++++++++ internal/mcp/session/state.go | 8 + 7 files changed, 658 insertions(+), 13 deletions(-) create mode 100644 internal/mcp/session/governance.go create mode 100644 internal/mcp/session/governance_test.go diff --git a/internal/assets/hooks/copilot-instructions.md b/internal/assets/hooks/copilot-instructions.md index bb62c209..b231607f 100644 --- a/internal/assets/hooks/copilot-instructions.md +++ b/internal/assets/hooks/copilot-instructions.md @@ -103,4 +103,78 @@ ctx drift # Check for stale context ctx recall list # Recent session history ``` +## MCP Tools (Preferred) + +When an MCP server named `ctx` is available, **always prefer MCP tools +over terminal commands** for context operations. MCP tools provide +validation, session tracking, and boundary checks automatically. + +| MCP Tool | Purpose | +|-----------------------------|--------------------------------------| +| `ctx_status` | Context summary and health check | +| `ctx_add` | Add task, decision, learning, or convention | +| `ctx_complete` | Mark a task as done | +| `ctx_drift` | Check for stale or drifted context | +| `ctx_recall` | Query session history | +| `ctx_next` | Get the next task to work on | +| `ctx_compact` | Archive completed tasks | +| `ctx_watch_update` | Write entry and queue for review | +| `ctx_check_task_completion` | Match recent work to open tasks | +| `ctx_session_event` | Signal session start or end | +| `ctx_remind` | List pending reminders | + +**Rule**: Do NOT run `ctx` in the terminal when the equivalent MCP tool +exists. MCP tools enforce boundary validation and track session state. +Terminal fallback is only for commands without an MCP equivalent (e.g., +`ctx agent`, `ctx recall list`). + +## Governance: When to Call Tools + +The MCP server tracks session state and appends warnings to tool +responses when governance actions are overdue. Follow this protocol: + +### Session Lifecycle + +1. **BEFORE any work**: call `ctx_session_event(type="start")`, then + `ctx_status()` to load context. +2. **Before ending**: call `ctx_session_event(type="end")` to flush + pending state. + +### During Work + +- **After making a decision or discovering a gotcha**: call `ctx_add()` + to persist it immediately — not at session end. +- **After completing a task**: call `ctx_complete()` or + `ctx_check_task_completion()`. +- **Every 10–15 tool calls or 15 minutes**: call `ctx_drift()` to + check for stale context. +- **Before git commit**: call `ctx_status()` to verify context health. + +### Responding to Warnings + +When a tool response contains a `⚠` warning, act on it in your next +action. Do not ignore governance warnings — they indicate context +hygiene actions that are overdue. + +When a tool response contains a `🚨 CRITICAL` warning, **stop current +work immediately** and address the violation. These indicate dangerous +commands, sensitive file access, or policy violations detected by the +VS Code extension. Review the action, revert if unintended, and explain +what happened before continuing. + +### Detection Ring + +The VS Code extension monitors terminal commands and file access in +real time. The following actions are flagged as violations: + +- **Dangerous commands**: `sudo`, `rm -rf /`, `git push`, `git reset + --hard`, `curl`, `wget`, `chmod 777` +- **hack/ scripts**: Direct execution of `hack/*.sh` — use `make` + targets instead +- **Sensitive files**: Editing `.env`, `.pem`, `.key`, or files + matching `credentials` or `secret` + +Violations are recorded and surfaced as CRITICAL warnings in your next +MCP tool response. The user also sees a VS Code notification. + diff --git a/internal/mcp/handler/tool.go b/internal/mcp/handler/tool.go index f0c7ec5d..e6e3c3b2 100644 --- a/internal/mcp/handler/tool.go +++ b/internal/mcp/handler/tool.go @@ -522,6 +522,7 @@ func (h *Handler) SessionEvent( switch eventType { case event.Start: h.Session = session.NewState(h.ContextDir) + h.Session.RecordSessionStart() if caller != "" { return fmt.Sprintf( assets.TextDesc( diff --git a/internal/mcp/server/route/tool/dispatch.go b/internal/mcp/server/route/tool/dispatch.go index 03f9bc4e..4d087ba5 100644 --- a/internal/mcp/server/route/tool/dispatch.go +++ b/internal/mcp/server/route/tool/dispatch.go @@ -31,14 +31,16 @@ func DispatchList(req proto.Request) *proto.Response { } // DispatchCall unmarshals tool call params and dispatches to the -// appropriate handler function. +// appropriate handler function. After dispatch, per-tool governance +// state is recorded and advisory warnings are appended to the +// response text. // // Parameters: // - h: handler for domain logic and session tracking // - req: the MCP request containing tool name and arguments // // Returns: -// - *proto.Response: tool result or error +// - *proto.Response: tool result or error (with governance warnings) func DispatchCall( h *handler.Handler, req proto.Request, ) *proto.Response { @@ -51,32 +53,41 @@ func DispatchCall( } h.Session.RecordToolCall() + h.Session.IncrementCallsSinceWrite() + + var resp *proto.Response switch params.Name { case tool.Status: - return out.Call(req.ID, h.Status) + resp = out.Call(req.ID, h.Status) + h.Session.RecordContextLoaded() case tool.Add: - return add(h, req.ID, params.Arguments) + resp = add(h, req.ID, params.Arguments) + h.Session.RecordContextWrite() case tool.Complete: - return complete(h, req.ID, params.Arguments) + resp = complete(h, req.ID, params.Arguments) + h.Session.RecordContextWrite() case tool.Drift: - return out.Call(req.ID, h.Drift) + resp = out.Call(req.ID, h.Drift) + h.Session.RecordDriftCheck() case tool.Recall: - return recall(req.ID, params.Arguments, h.Recall) + resp = recall(req.ID, params.Arguments, h.Recall) case tool.WatchUpdate: - return watchUpdate(h, req.ID, params.Arguments) + resp = watchUpdate(h, req.ID, params.Arguments) + h.Session.RecordContextWrite() case tool.Compact: - return compact(req.ID, params.Arguments, h.Compact) + resp = compact(req.ID, params.Arguments, h.Compact) + h.Session.RecordContextWrite() case tool.Next: - return out.Call(req.ID, h.Next) + resp = out.Call(req.ID, h.Next) case tool.CheckTaskCompletion: - return checkTaskCompletion( + resp = checkTaskCompletion( req.ID, params.Arguments, h.CheckTaskCompletion, ) case tool.SessionEvent: - return sessionEvent(req.ID, params.Arguments, h.SessionEvent) + resp = sessionEvent(req.ID, params.Arguments, h.SessionEvent) case tool.Remind: - return out.Call(req.ID, h.Remind) + resp = out.Call(req.ID, h.Remind) default: return out.ErrResponse( req.ID, proto.ErrCodeNotFound, @@ -86,4 +97,26 @@ func DispatchCall( ), ) } + + appendGovernance(resp, params.Name, h) + + return resp +} + +// appendGovernance appends governance advisory warnings to a tool +// response. It modifies the response in-place by appending warning +// text to the first content item. +func appendGovernance( + resp *proto.Response, toolName string, h *handler.Handler, +) { + warning := h.Session.CheckGovernance(toolName) + if warning == "" { + return + } + result, ok := resp.Result.(proto.CallToolResult) + if !ok || len(result.Content) == 0 { + return + } + result.Content[0].Text += warning + resp.Result = result } diff --git a/internal/mcp/server/server_test.go b/internal/mcp/server/server_test.go index 1bf116fd..9a6b2875 100644 --- a/internal/mcp/server/server_test.go +++ b/internal/mcp/server/server_test.go @@ -775,6 +775,16 @@ func TestToolCheckTaskCompletion(t *testing.T) { func TestToolCheckTaskCompletionNoMatch(t *testing.T) { srv, _ := newTestServer(t) + + // Prime session state to avoid governance warnings in response. + request(t, srv, "tools/call", proto.CallToolParams{ + Name: "ctx_session_event", + Arguments: map[string]interface{}{"type": "start"}, + }) + request(t, srv, "tools/call", proto.CallToolParams{ + Name: "ctx_status", + }) + resp := request(t, srv, "tools/call", proto.CallToolParams{ Name: "ctx_check_task_completion", Arguments: map[string]interface{}{ diff --git a/internal/mcp/session/governance.go b/internal/mcp/session/governance.go new file mode 100644 index 00000000..c90b32a7 --- /dev/null +++ b/internal/mcp/session/governance.go @@ -0,0 +1,180 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package session + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/ActiveMemory/ctx/internal/config/dir" + "github.com/ActiveMemory/ctx/internal/config/token" + ctxio "github.com/ActiveMemory/ctx/internal/io" +) + +// Governance thresholds — tuned to match Claude Code hook intervals. +const ( + // DriftCheckInterval is the minimum time between drift reminders. + DriftCheckInterval = 15 * time.Minute + + // PersistNudgeAfter is the tool call count after which a persist + // reminder fires if no context writes have occurred. + PersistNudgeAfter = 10 + + // PersistNudgeRepeat is how often the persist nudge repeats after + // the initial threshold. + PersistNudgeRepeat = 8 + + // toolSessionEvent is the MCP tool name for session lifecycle events. + toolSessionEvent = "ctx_session_event" + + // violationsFile is the name of the violations file within .context/state/. + violationsFile = "violations.json" +) + +// violation represents a single governance violation recorded by the +// VS Code extension's detection ring. +type violation struct { + Kind string `json:"kind"` + Detail string `json:"detail"` + Timestamp string `json:"timestamp"` +} + +// violationsData is the JSON structure of the violations file. +type violationsData struct { + Entries []violation `json:"entries"` +} + +// readAndClearViolations reads violations from .context/state/violations.json +// and removes the file to prevent repeated escalation. Returns nil if +// no file exists or on read error. +func (ss *State) readAndClearViolations() []violation { + if ss.contextDir == "" { + return nil + } + stateDir := filepath.Join(ss.contextDir, dir.State) + data, err := ctxio.SafeReadFile(stateDir, violationsFile) + if err != nil { + return nil + } + // Remove the file immediately to prevent duplicate alerts. + _ = os.Remove(filepath.Join(stateDir, violationsFile)) + + var vd violationsData + if err := json.Unmarshal(data, &vd); err != nil { + return nil + } + return vd.Entries +} + +// RecordSessionStart marks the session as explicitly started. +func (ss *State) RecordSessionStart() { + ss.sessionStarted = true + ss.sessionStartedAt = time.Now() +} + +// RecordContextLoaded marks context as loaded for this session. +func (ss *State) RecordContextLoaded() { + ss.contextLoaded = true +} + +// RecordDriftCheck records that a drift check was performed. +func (ss *State) RecordDriftCheck() { + ss.lastDriftCheck = time.Now() +} + +// RecordContextWrite records that a .context/ write occurred (add, +// complete, watch_update, compact). +func (ss *State) RecordContextWrite() { + ss.lastContextWrite = time.Now() + ss.callsSinceWrite = 0 +} + +// IncrementCallsSinceWrite bumps the counter used for persist nudges. +func (ss *State) IncrementCallsSinceWrite() { + ss.callsSinceWrite++ +} + +// CheckGovernance returns governance warnings that should be appended +// to the current tool response. Returns an empty string when no action +// is warranted. +// +// The caller (toolName) is used to suppress redundant warnings — for +// example, a drift warning is not appended to a ctx_drift response. +func (ss *State) CheckGovernance(toolName string) string { + var warnings []string + + // 1. Session not started + if !ss.sessionStarted && toolName != toolSessionEvent { + warnings = append(warnings, + "⚠ Session not started. "+ + "Call ctx_session_event(type=\"start\") to enable tracking.") + } + + // 2. Context not loaded + if !ss.contextLoaded && toolName != "ctx_status" && + toolName != toolSessionEvent { + warnings = append(warnings, + "⚠ Context not loaded. "+ + "Call ctx_status() to load context before proceeding.") + } + + // 3. Drift not checked recently + if ss.sessionStarted && toolName != "ctx_drift" && + toolName != toolSessionEvent { + if !ss.lastDriftCheck.IsZero() { + if time.Since(ss.lastDriftCheck) > DriftCheckInterval { + warnings = append(warnings, fmt.Sprintf( + "⚠ Drift not checked in %d minutes. Consider calling ctx_drift().", + int(time.Since(ss.lastDriftCheck).Minutes()))) + } + } else if ss.ToolCalls > 5 { + // Never checked drift and already 5+ calls in + warnings = append(warnings, + "⚠ Drift has not been checked this session. Consider calling ctx_drift().") + } + } + + // 4. Persist nudge — no context writes in a while + if ss.sessionStarted && ss.callsSinceWrite >= PersistNudgeAfter && + toolName != "ctx_add" && toolName != "ctx_watch_update" && + toolName != "ctx_complete" && toolName != "ctx_compact" && + toolName != toolSessionEvent { + // Fire at threshold, then every PersistNudgeRepeat calls after + if ss.callsSinceWrite == PersistNudgeAfter || + (ss.callsSinceWrite-PersistNudgeAfter)%PersistNudgeRepeat == 0 { + warnings = append(warnings, fmt.Sprintf( + "⚠ %d tool calls since last context write. "+ + "Persist decisions, learnings, or completed tasks with ctx_add() or ctx_complete().", + ss.callsSinceWrite)) + } + } + + // 5. Violations from extension detection ring + if violations := ss.readAndClearViolations(); len(violations) > 0 { + for _, v := range violations { + detail := v.Detail + if len(detail) > 120 { + detail = detail[:120] + "..." + } + warnings = append(warnings, fmt.Sprintf( + "🚨 CRITICAL: %s — %s (at %s). "+ + "Review this action immediately. If unintended, revert it.", + v.Kind, detail, v.Timestamp)) + } + } + + if len(warnings) == 0 { + return "" + } + + nl := token.NewlineLF + return nl + nl + "---" + nl + strings.Join(warnings, nl) +} diff --git a/internal/mcp/session/governance_test.go b/internal/mcp/session/governance_test.go new file mode 100644 index 00000000..c31744ea --- /dev/null +++ b/internal/mcp/session/governance_test.go @@ -0,0 +1,339 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package session + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ActiveMemory/ctx/internal/config/dir" +) + +func newTestState() *State { + return NewState("/tmp/test/.context") +} + +func TestCheckGovernance_SessionNotStarted(t *testing.T) { + ss := newTestState() + got := ss.CheckGovernance("ctx_status") + if !strings.Contains(got, "Session not started") { + t.Errorf("expected session-not-started warning, got: %q", got) + } +} + +func TestCheckGovernance_SessionNotStarted_SuppressedForSessionEvent(t *testing.T) { + ss := newTestState() + got := ss.CheckGovernance("ctx_session_event") + if strings.Contains(got, "Session not started") { + t.Errorf("session-not-started should be suppressed for ctx_session_event, got: %q", got) + } +} + +func TestCheckGovernance_ContextNotLoaded(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + got := ss.CheckGovernance("ctx_add") + if !strings.Contains(got, "Context not loaded") { + t.Errorf("expected context-not-loaded warning, got: %q", got) + } +} + +func TestCheckGovernance_ContextNotLoaded_SuppressedForStatus(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + got := ss.CheckGovernance("ctx_status") + if strings.Contains(got, "Context not loaded") { + t.Errorf("context-not-loaded should be suppressed for ctx_status, got: %q", got) + } +} + +func TestCheckGovernance_DriftNeverChecked(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.ToolCalls = 6 // above the 5-call threshold + + got := ss.CheckGovernance("ctx_add") + if !strings.Contains(got, "Drift has not been checked") { + t.Errorf("expected drift-never-checked warning, got: %q", got) + } +} + +func TestCheckGovernance_DriftNeverChecked_BelowThreshold(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.ToolCalls = 3 // below 5 + + got := ss.CheckGovernance("ctx_add") + if strings.Contains(got, "Drift") { + t.Errorf("drift warning should not fire below 5 calls, got: %q", got) + } +} + +func TestCheckGovernance_DriftStale(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.lastDriftCheck = time.Now().Add(-20 * time.Minute) // 20 min ago + + got := ss.CheckGovernance("ctx_add") + if !strings.Contains(got, "Drift not checked in") { + t.Errorf("expected stale-drift warning, got: %q", got) + } +} + +func TestCheckGovernance_DriftStale_SuppressedForDrift(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.lastDriftCheck = time.Now().Add(-20 * time.Minute) + + got := ss.CheckGovernance("ctx_drift") + if strings.Contains(got, "Drift") { + t.Errorf("drift warning should be suppressed for ctx_drift, got: %q", got) + } +} + +func TestCheckGovernance_PersistNudge_AtThreshold(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.callsSinceWrite = PersistNudgeAfter // exactly at threshold + + got := ss.CheckGovernance("ctx_status") + if !strings.Contains(got, "tool calls since last context write") { + t.Errorf("expected persist-nudge at threshold, got: %q", got) + } +} + +func TestCheckGovernance_PersistNudge_BelowThreshold(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.callsSinceWrite = PersistNudgeAfter - 1 + + got := ss.CheckGovernance("ctx_status") + if strings.Contains(got, "tool calls since last context write") { + t.Errorf("persist-nudge should not fire below threshold, got: %q", got) + } +} + +func TestCheckGovernance_PersistNudge_Repeat(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.callsSinceWrite = PersistNudgeAfter + PersistNudgeRepeat + + got := ss.CheckGovernance("ctx_status") + if !strings.Contains(got, "tool calls since last context write") { + t.Errorf("expected persist-nudge at repeat interval, got: %q", got) + } +} + +func TestCheckGovernance_PersistNudge_SuppressedForWriteTools(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.callsSinceWrite = PersistNudgeAfter + + for _, tool := range []string{"ctx_add", "ctx_complete", "ctx_watch_update", "ctx_compact"} { + got := ss.CheckGovernance(tool) + if strings.Contains(got, "tool calls since last context write") { + t.Errorf("persist-nudge should be suppressed for %s, got: %q", tool, got) + } + } +} + +func TestCheckGovernance_NoWarnings(t *testing.T) { + ss := newTestState() + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.RecordContextWrite() + + got := ss.CheckGovernance("ctx_status") + if got != "" { + t.Errorf("expected no warnings, got: %q", got) + } +} + +func TestRecordSessionStart(t *testing.T) { + ss := newTestState() + if ss.sessionStarted { + t.Fatal("sessionStarted should be false initially") + } + ss.RecordSessionStart() + if !ss.sessionStarted { + t.Fatal("sessionStarted should be true after RecordSessionStart") + } +} + +func TestRecordContextWrite_ResetsCounter(t *testing.T) { + ss := newTestState() + ss.callsSinceWrite = 15 + ss.RecordContextWrite() + if ss.callsSinceWrite != 0 { + t.Errorf("callsSinceWrite should be 0 after RecordContextWrite, got %d", ss.callsSinceWrite) + } +} + +func TestIncrementCallsSinceWrite(t *testing.T) { + ss := newTestState() + ss.IncrementCallsSinceWrite() + ss.IncrementCallsSinceWrite() + ss.IncrementCallsSinceWrite() + if ss.callsSinceWrite != 3 { + t.Errorf("expected 3, got %d", ss.callsSinceWrite) + } +} + +func TestCheckGovernance_WarningFormat(t *testing.T) { + ss := newTestState() + got := ss.CheckGovernance("ctx_add") + if got != "" && !strings.HasPrefix(got, "\n\n---\n") { + t.Errorf("warnings should start with separator, got: %q", got) + } +} + +func newTestStateWithDir(t *testing.T) *State { + t.Helper() + contextDir := filepath.Join(t.TempDir(), ".context") + if err := os.MkdirAll(filepath.Join(contextDir, dir.State), 0o755); err != nil { + t.Fatal(err) + } + return NewState(contextDir) +} + +func writeViolations(t *testing.T, contextDir string, entries []violation) { + t.Helper() + data, err := json.Marshal(violationsData{Entries: entries}) + if err != nil { + t.Fatal(err) + } + p := filepath.Join(contextDir, dir.State, violationsFile) + if err := os.WriteFile(p, data, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestCheckGovernance_ViolationsDetected(t *testing.T) { + ss := newTestStateWithDir(t) + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.RecordContextWrite() + + writeViolations(t, ss.contextDir, []violation{ + {Kind: "dangerous_command", Detail: "sudo rm -rf /tmp", Timestamp: "2026-03-17T10:00:00Z"}, + }) + + got := ss.CheckGovernance("ctx_status") + if !strings.Contains(got, "CRITICAL") { + t.Errorf("expected CRITICAL warning, got: %q", got) + } + if !strings.Contains(got, "dangerous_command") { + t.Errorf("expected violation kind in warning, got: %q", got) + } +} + +func TestCheckGovernance_ViolationsFileRemovedAfterRead(t *testing.T) { + ss := newTestStateWithDir(t) + writeViolations(t, ss.contextDir, []violation{ + {Kind: "sensitive_file_read", Detail: ".env", Timestamp: "2026-03-17T10:00:00Z"}, + }) + + p := filepath.Join(ss.contextDir, dir.State, violationsFile) + if _, err := os.Stat(p); err != nil { + t.Fatal("violations file should exist before read") + } + + ss.CheckGovernance("ctx_status") + + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Error("violations file should be removed after read") + } +} + +func TestCheckGovernance_NoViolationsFile(t *testing.T) { + ss := newTestStateWithDir(t) + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.RecordContextWrite() + + got := ss.CheckGovernance("ctx_status") + if strings.Contains(got, "CRITICAL") { + t.Errorf("no violations should mean no CRITICAL warning, got: %q", got) + } +} + +func TestCheckGovernance_ViolationDetailTruncated(t *testing.T) { + ss := newTestStateWithDir(t) + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.RecordContextWrite() + + longDetail := strings.Repeat("x", 200) + writeViolations(t, ss.contextDir, []violation{ + {Kind: "hack_script", Detail: longDetail, Timestamp: "2026-03-17T10:00:00Z"}, + }) + + got := ss.CheckGovernance("ctx_status") + if strings.Contains(got, longDetail) { + t.Error("full 200-char detail should be truncated") + } + if !strings.Contains(got, "...") { + t.Errorf("truncated detail should contain ellipsis, got: %q", got) + } +} + +func TestCheckGovernance_MultipleViolations(t *testing.T) { + ss := newTestStateWithDir(t) + ss.RecordSessionStart() + ss.RecordContextLoaded() + ss.RecordDriftCheck() + ss.RecordContextWrite() + + writeViolations(t, ss.contextDir, []violation{ + {Kind: "dangerous_command", Detail: "git push --force", Timestamp: "2026-03-17T10:00:00Z"}, + {Kind: "sensitive_file_read", Detail: ".env.local", Timestamp: "2026-03-17T10:00:01Z"}, + }) + + got := ss.CheckGovernance("ctx_status") + count := strings.Count(got, "CRITICAL") + if count != 2 { + t.Errorf("expected 2 CRITICAL warnings, got %d in: %q", count, got) + } +} + +func TestReadAndClearViolations_EmptyContextDir(t *testing.T) { + ss := &State{contextDir: ""} + violations := ss.readAndClearViolations() + if violations != nil { + t.Errorf("expected nil for empty contextDir, got: %v", violations) + } +} + +func TestReadAndClearViolations_CorruptFile(t *testing.T) { + ss := newTestStateWithDir(t) + p := filepath.Join(ss.contextDir, dir.State, violationsFile) + if err := os.WriteFile(p, []byte("not json"), 0o644); err != nil { + t.Fatal(err) + } + violations := ss.readAndClearViolations() + if violations != nil { + t.Errorf("expected nil for corrupt file, got: %v", violations) + } +} diff --git a/internal/mcp/session/state.go b/internal/mcp/session/state.go index e1569aa0..b2ad708f 100644 --- a/internal/mcp/session/state.go +++ b/internal/mcp/session/state.go @@ -23,6 +23,14 @@ type State struct { AddsPerformed map[string]int sessionStartedAt time.Time PendingFlush []PendingUpdate + + // Governance tracking — used by CheckGovernance() to emit + // contextual warnings in MCP tool responses. + sessionStarted bool + contextLoaded bool + lastDriftCheck time.Time + lastContextWrite time.Time + callsSinceWrite int } // PendingUpdate represents a context update awaiting human confirmation. From eb956956ca61a8fa387324f8d03739e4f96794f0 Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Wed, 18 Mar 2026 01:33:09 +0300 Subject: [PATCH 5/6] fix(vscode): resolve Insiders dual extension host hang - Set activationEvents to onStartupFinished for reliable activation - Add /diag slash command for extension diagnostics - Add OutputChannel logging for activation tracing - Pass --force --caller vscode to init to prevent stdin hang - Skip stdin overwrite prompt when caller is set (run.go) - Add test-insiders-sim.js to .vscodeignore Root cause: VS Code Insiders 1.112 uses extensions.experimental.affinity to run GitHub.copilot-chat in a separate extension host process. Chat requests only route to that host. The fix requires adding activememory.ctx-context to the same affinity group in user settings. Signed-off-by: ersan bilik --- .context/AGENT_PLAYBOOK.md | 171 ++++++----- .context/ARCHITECTURE.md | 380 +++++------------------- .context/CONSTITUTION.md | 15 +- .context/CONVENTIONS.md | 180 +++-------- .context/GLOSSARY.md | 49 +-- .context/PROMPT.md | 4 +- .context/prompts/code-review.md | 11 + .context/prompts/explain.md | 11 + .context/prompts/refactor.md | 11 + .context/templates/decision.md | 4 +- .gitignore | 6 + CLAUDE.md | 57 ++++ IMPLEMENTATION_PLAN.md | 6 +- editors/vscode/.vscodeignore | 1 + editors/vscode/package.json | 11 +- editors/vscode/src/extension.ts | 144 ++++++++- internal/cli/initialize/cmd/root/run.go | 16 +- 17 files changed, 491 insertions(+), 586 deletions(-) create mode 100644 .context/prompts/code-review.md create mode 100644 .context/prompts/explain.md create mode 100644 .context/prompts/refactor.md diff --git a/.context/AGENT_PLAYBOOK.md b/.context/AGENT_PLAYBOOK.md index 6f517f6b..e0d7d101 100644 --- a/.context/AGENT_PLAYBOOK.md +++ b/.context/AGENT_PLAYBOOK.md @@ -12,14 +12,14 @@ never come cleanly. ## Invoking ctx Always use `ctx` from PATH: -```bash -ctx status # ✓ correct -ctx agent # ✓ correct +``` +ctx status # ✔ correct +ctx agent # ✔ correct ./dist/ctx # ✗ avoid hardcoded paths go run ./cmd/ctx # ✗ avoid unless developing ctx itself ``` -Check with `which ctx` if unsure whether it's installed. +If unsure whether it's installed, run `ctx --version` in a terminal. ## Context Readback @@ -40,21 +40,6 @@ This applies to debugging too — reason through the cause before reaching for a fix. Rushing to code before reasoning is the most common source of wasted work. -### Chunk and Checkpoint Large Tasks - -For work spanning many files or steps, break it into independently -verifiable chunks. After each chunk: - -1. **Commit** — save progress to git so nothing is lost -2. **Persist** — record learnings or decisions discovered during the chunk -3. **Verify** — run tests or `make lint` before moving on - -Track progress via TASKS.md checkboxes. If context runs low mid-task, -persist a progress note (what's done, what's next, what assumptions -remain) before continuing in a new window. The `check-context-size` -hook warns at 80% usage — treat that as a signal to checkpoint, not -to rush. - ## Session Lifecycle A session follows this arc: @@ -64,15 +49,14 @@ A session follows this arc: Not every session uses every step — a quick bugfix skips reflection, a research session skips committing — but the full flow is: - -| Step | What Happens | Skill / Command | -|-------------|----------------------------------------------------|------------------| -| **Load** | Recall context, present structured readback | `/ctx-remember` | -| **Orient** | Check context health, surface issues | `/ctx-status` | -| **Pick** | Choose what to work on | `/ctx-next` | -| **Work** | Write code, fix bugs, research | `/ctx-implement` | -| **Commit** | Commit with context capture | `/ctx-commit` | -| **Reflect** | Surface persist-worthy items from this session | `/ctx-reflect` | +| Step | What Happens | Command | +|-------------|----------------------------------------------------|----------------------| +| **Load** | Recall context, present structured readback | `ctx recall list` | +| **Orient** | Check context health, surface issues | `ctx status` | +| **Pick** | Choose what to work on | Read TASKS.md | +| **Work** | Write code, fix bugs, research | — | +| **Commit** | Commit with context capture | `git commit` | +| **Reflect** | Surface persist-worthy items from this session | Update context files | ### Context Health at Session Start @@ -88,57 +72,38 @@ Surface problems worth mentioning: One sentence is enough — don't turn startup into a maintenance session. -### Context Window Limits - -The `check-context-size` hook (`ctx system check-context-size`) monitors -context window usage and warns when it exceeds 80%. When you see this -warning or sense context is running long: - -- **Persist progress**: write what's done and what's left to TASKS.md - or a progress note -- **Checkpoint state**: commit work-in-progress so a fresh session can - pick up cleanly -- **Summarize**: leave a breadcrumb for the next window — the current - task, open questions, and next step - -Context compaction happens automatically, but the next window loses -nuance. Explicit persistence is cheaper than re-discovery. - ### Conversational Triggers Users rarely invoke skills explicitly. Recognize natural language: - -| User Says | Action | -|-------------------------------------------------|--------------------------------------------------------| -| "Do you remember?" / "What were we working on?" | `/ctx-remember` | -| "How's our context looking?" | `/ctx-status` | -| "What should we work on?" | `/ctx-next` | -| "Commit this" / "Ship it" | `/ctx-commit` | -| "The rate limiter is done" / "We finished that" | `ctx tasks complete` (match to TASKS.md) | -| "What did we learn?" | `/ctx-reflect` | -| "Save that as a decision" | `/ctx-add-decision` | -| "That's worth remembering" / "Any gotchas?" | `/ctx-add-learning` | -| "Record that convention" | `/ctx-add-convention` | -| "Add a task for that" | `/ctx-add-task` | -| "Sync memory" / "What's in auto memory?" | `ctx memory sync` / `ctx memory status` | -| "Import from memory" | `ctx memory import --dry-run` then `ctx memory import` | -| "Let's wrap up" | Reflect → persist outstanding items → present together | +| User Says | Action | +|-----------|--------| +| "Do you remember?" / "What were we working on?" | Read TASKS.md, DECISIONS.md, LEARNINGS.md; run `ctx recall list` | +| "How's our context looking?" | Run `ctx status` | +| "What should we work on?" | Read TASKS.md, pick highest priority | +| "Commit this" / "Ship it" | `git commit`, update TASKS.md | +| "The rate limiter is done" / "We finished that" | Mark done in TASKS.md | +| "What did we learn?" | Review session work, offer to update LEARNINGS.md | +| "Save that as a decision" | Add entry to DECISIONS.md | +| "That's worth remembering" / "Any gotchas?" | Add entry to LEARNINGS.md | +| "Record that convention" | Add entry to CONVENTIONS.md | +| "Add a task for that" | Add entry to TASKS.md | +| "Let's wrap up" | Reflect → persist outstanding items → present together | ## Proactive Persistence **Don't wait to be asked.** Identify persist-worthy moments in real time: -| Event | Action | -|--------------------------------------------|-------------------------------------------------------------------| -| Completed a task | Mark done in TASKS.md, offer to add learnings | -| Chose between design alternatives | Offer: *"Worth recording as a decision?"* | -| Hit a subtle bug or gotcha | Offer: *"Want me to add this as a learning?"* | -| Finished a feature or fix | Identify follow-up work, offer to add as tasks | -| Resolved a tricky debugging session | Capture root cause before moving on | -| Multi-step task or feature complete | Suggest reflection: *"Want me to capture what we learned?"* | -| Session winding down | Offer: *"Want me to capture outstanding learnings or decisions?"* | -| Shipped a feature or closed batch of tasks | Offer blog post or journal site rebuild | +| Event | Action | +|-------|--------| +| Completed a task | Mark done in TASKS.md, offer to add learnings | +| Chose between design alternatives | Offer: *"Worth recording as a decision?"* | +| Hit a subtle bug or gotcha | Offer: *"Want me to add this as a learning?"* | +| Finished a feature or fix | Identify follow-up work, offer to add as tasks | +| Resolved a tricky debugging session | Capture root cause before moving on | +| Multi-step task or feature complete | Suggest reflection: *"Want me to capture what we learned?"* | +| Session winding down | Offer: *"Want me to capture outstanding learnings or decisions?"* | +| Shipped a feature or closed batch of tasks | Offer blog post or journal site rebuild | **Self-check**: periodically ask yourself — *"If this session ended right now, would the next session know what happened?"* If no, persist @@ -171,11 +136,9 @@ user. These apply unless the user overrides them for the session - **At design decisions**: always present 2+ approaches with trade-offs before committing — don't silently pick one -- **At completion claims**: run `/ctx-verify` — it maps claims to - evidence (e.g., "tests pass" requires 0-failure output, "build - succeeds" requires exit 0). At minimum, answer the self-audit - questions: What did I assume? What didn't I check? Where am I - least confident? What would a reviewer question? +- **At completion claims**: run self-audit questions (What did I + assume? What didn't I check? Where am I least confident? What + would a reviewer question?) before reporting done - **At ambiguous moments**: ask the user rather than inferring intent — a quick question is cheaper than rework - **When producing artifacts**: flag assumptions and uncertainty @@ -245,3 +208,61 @@ Before writing or modifying CLI code (`internal/cli/**/*.go`): 3. **Use cmd methods for output** — `cmd.Printf`, `cmd.Println`, not `fmt.Printf`, `fmt.Println` 4. **Follow docstring format** — see CONVENTIONS.md, Documentation section + +--- + +## Context Anti-Patterns + +Avoid these common context management mistakes: + +### Stale Context + +Context files become outdated and misleading when ARCHITECTURE.md +describes components that no longer exist, or CONVENTIONS.md patterns +contradict actual code. **Solution**: Update context as part of +completing work, not as a separate task. Run `ctx drift` periodically. + +### Context Sprawl + +Information scattered across multiple locations — same decision in +DECISIONS.md and a session file, conventions split between +CONVENTIONS.md and code comments. **Solution**: Single source of +truth for each type of information. Use the defined file structure. + +### Implicit Context + +Relying on knowledge not captured in artifacts — "everyone knows we +don't do X" but it's not in CONSTITUTION.md, patterns followed but +not in CONVENTIONS.md. **Solution**: If you reference something +repeatedly, add it to the appropriate file. + +### Over-Specification + +Context becomes so detailed it's impossible to maintain — 50+ rules +in CONVENTIONS.md, every minor choice gets a DECISIONS.md entry. +**Solution**: Keep artifacts focused on decisions that affect behavior +and alignment. Not everything needs documenting. + +### Context Avoidance + +Not using context because "it's faster to just code." Same mistakes +repeated across sessions, decisions re-debated because prior decisions +weren't found. **Solution**: Reading context is faster than +re-discovering it. 5 minutes reading saves 50 minutes of wasted work. + +--- + +## Context Validation Checklist + +### Quick Check (Every Session) +- [ ] TASKS.md reflects current priorities +- [ ] No obvious staleness in files you'll reference +- [ ] Recent history reviewed via `ctx recall list` + +### Deep Check (Weekly or Before Major Work) +- [ ] CONSTITUTION.md rules still apply +- [ ] ARCHITECTURE.md matches actual structure +- [ ] CONVENTIONS.md patterns match code +- [ ] DECISIONS.md has no superseded entries unmarked +- [ ] LEARNINGS.md gotchas still relevant +- [ ] Run `ctx drift` and address warnings diff --git a/.context/ARCHITECTURE.md b/.context/ARCHITECTURE.md index 07822bdc..5357482a 100644 --- a/.context/ARCHITECTURE.md +++ b/.context/ARCHITECTURE.md @@ -1,326 +1,88 @@ # Architecture -## Overview - -ctx is a CLI tool that creates and manages a `.context/` directory -containing structured markdown files. These files provide persistent, -token-budgeted, priority-ordered context for AI coding assistants -across sessions. + -Design philosophy: - -- **Markdown-centric**: all context is plain markdown; no databases, - no proprietary formats. Files are human-readable and version- - controlled alongside the code they describe. -- **Token-budgeteed**: context assembly respects configurable token - limits so AI agents receive the most important information first - without exceeding their context window. -- **Priority-ordered**: files are loaded in a deliberate sequence - (rules before tasks, conventions before architecture) so agents - internalize constraints before acting. -- **Convention over configuration**: sensible defaults with optional - `.ctxrc` overrides. No config file required to get started. +## Overview -For per-module deep dives (types, exported API, data flow, edge cases), -see [DETAILED_DESIGN.md](DETAILED_DESIGN.md). + -## Package Dependency Graph +## Package/Module Dependency Graph -Entry point `cmd/ctx` → `bootstrap` (root Cobra command) → 24 CLI -command packages under `internal/cli/*`. Commands select from shared -packages: `context`, `drift`, `index`, `task`, `validation`, -`recall/parser`, `claude`, `notify`, `journal/state`, `memory`, -`crypto`, `sysinfo`. Foundation packages (`config`, `assets`, `crypto`, -`sysinfo`) have zero internal dependencies — everything else builds -upward from them. The `rc` package mediates config resolution; -`context` depends on `rc` and `config`; `drift` depends on `context`, -`index`, and `rc`. + core + cli["cli"] --> api +``` +--> ## Component Map - -### Foundation Packages (zero internal dependencies) - -| Package | Purpose | Key Exports | -|--------------------|-------------------------------------------------|---------------------------------------------------| -| `internal/config` | Constants, regex, file names, read order, perms | `FileReadOrder`, `RegExEntryHeader`, `FileType` | -| `internal/assets` | Embedded templates via `go:embed` | `Template()`, `SkillContent()`, `PluginVersion()` | -| `internal/crypto` | AES-256-GCM encryption (stdlib only) | `Encrypt()`, `Decrypt()`, `GenerateKey()` | -| `internal/sysinfo` | OS metrics with platform build tags | `Collect()`, `Evaluate()`, `MaxSeverity()` | - - -### Core Packages - -| Package | Purpose | Key Exports | -|--------------------------|--------------------------------------------------------|--------------------------------------------| -| `internal/rc` | Runtime config from `.ctxrc` + env + CLI flags | `RC()`, `ContextDir()`, `TokenBudget()` | -| `internal/context` | Load `.context/` directory with token estimation | `Load()`, `EstimateTokens()`, `Exists()` | -| `internal/drift` | Context quality validation (7 checks) | `Detect()`, `Report.Status()` | -| `internal/index` | Markdown index tables for DECISIONS/LEARNINGS | `Update()`, `ParseEntryBlocks()` | -| `internal/task` | Task checkbox parsing | `Completed()`, `Pending()`, `SubTask()` | -| `internal/validation` | Input sanitization and path boundary checks | `SanitizeFilename()`, `ValidateBoundary()` | -| `internal/recall/parser` | Session transcript parsing (JSONL + Markdown) | `ParseFile()`, `FindSessionsForCWD()` | -| `internal/claude` | Claude Code integration types and skill access | `Skills()`, `SkillContent()` | -| `internal/notify` | Webhook notifications with encrypted URL storage | `Send()`, `LoadWebhook()`, `SaveWebhook()` | -| `internal/journal/state` | Journal processing pipeline state (JSON) | `Load()`, `Save()`, `Mark*()` | -| `internal/mcp` | MCP server (JSON-RPC 2.0 over stdin/stdout) | `NewServer()`, `Serve()` | -| `internal/memory` | Memory bridge: discover, mirror, diff MEMORY.md | `DiscoverMemoryPath()`, `Sync()`, `Diff()` | - - -### Entry Point - -| Package | Purpose | -|----------------------|------------------------------------------------------------| -| `internal/bootstrap` | Create root Cobra command, register 24 subcommands | - - -### CLI Commands (`internal/cli/*`) - -| Command | Purpose | -|---------------|---------------------------------------------------------------------------------| -| `add` | Append entries to context files (decisions, tasks, learnings, conventions) | -| `agent` | Generate AI-ready context packets with token budgeting | -| `compact` | Archive completed tasks, clean up context files | -| `complete` | Mark tasks as done in TASKS.md | -| `decision` | Manage DECISIONS.md (reindex) | -| `drift` | Detect stale/invalid context and report issues | -| `hook` | Generate AI tool integration configs (Claude, Cursor, Aider, Copilot, Windsurf) | -| `initialize` | Create `.context/` directory, deploy templates, merge settings | -| `journal` | Export sessions; generate static site or Obsidian vault | -| `learnings` | Manage LEARNINGS.md (reindex) | -| `load` | Output assembled context in priority order | -| `loop` | Generate Ralph loop scripts for autonomous iteration | -| `memory` | Bridge Claude Code auto memory into .context/ (sync, status, diff) | -| `notify` | Send fire-and-forget webhook notifications | -| `pad` | Encrypted scratchpad CRUD with blob support and merge | -| `permissions` | Permission snapshot/restore (golden images) for Claude Code | -| `recall` | Browse, export, lock/unlock AI session history | -| `reindex` | Regenerate indices for DECISIONS.md and LEARNINGS.md | -| `remind` | Session-scoped reminders surfaced at start | -| `serve` | Serve static journal site locally via zensical | -| `status` | Display context health summary | -| `sync` | Reconcile codebase changes with context documentation | -| `system` | System diagnostics, resource monitoring, hook plumbing | -| `task` | Task archival and snapshots | -| `watch` | Monitor stdin for context-update tags and apply them | -| `mcp` | MCP server for AI tool integration (stdin/stdout JSON-RPC) | - -## Data Flow Diagrams - -Five core flows define how data moves through the system: - -1. **`ctx init`**: User invokes → `cli/initialize` reads embedded - templates from `assets` → creates `.context/` directory → writes - all template files → generates AES-256 key → deploys hooks and - skills → merges `settings.local.json` → writes/merges `CLAUDE.md`. - -2. **`ctx agent`**: Agent invokes with `--budget N` → `cli/agent` - queries `rc.TokenBudget()` → `context.Load()` reads all `.md` - files → entries scored by recency and relevance → sorted and - fitted to token budget → overflow entries listed as "Also Noted" - → returns Markdown packet. - -3. **`ctx drift`**: User invokes → `cli/drift` loads context → - `drift.Detect()` runs 7 checks (path refs, staleness, - constitution compliance, required files, file age, entry count, - missing packages) → returns report with warnings and violations. - -4. **`ctx recall export`**: User invokes with `--all` → `cli/recall` - calls `parser.FindSessionsForCWD()` which scans - `~/.claude/projects/` → parses JSONL transcripts → loads journal - state → plans each session (new/regen/skip/locked) → formats as - Markdown → writes to `.context/journal/` → marks exported in state. - - -5. **Hook lifecycle**: Claude Code plugin fires hooks at 3 lifecycle - points — `UserPromptSubmit` (12 checks: context size, ceremonies, - persistence, journal, reminders, version, resources, knowledge, - map staleness, memory drift, freshness, heartbeat), `PreToolUse` (block-non-path-ctx for - Bash, qa-reminder for Bash, context-load-gate for all tools, - specs-nudge for EnterPlanMode, agent context for all tools), - `PostToolUse` (post-commit for Bash). All hooks execute - synchronously; failures softened with `|| true` where appropriate. - -*Full sequence diagrams: -[architecture-dia-data-flows.md](architecture-dia-data-flows.md)* - -## State Diagrams - -Five state machines govern lifecycle transitions: - -1. **Context files**: Created → Populated (via `ctx init` templates) - → Active (entries growing via `ctx add` / edits) → Stale (drift - detected) → back to Active (via fixes) or Archived (via - `ctx compact` / `ctx consolidate` to `.context/archive/`). - -2. **Tasks**: Pending `- [ ]` → In-Progress (`#in-progress` label) / - Done `- [x]` / Skipped `- [-]` with reason → Archivable (when no - pending children remain) → Archived (via `ctx task archive` to - `.context/archive/`). - -3. **Journal pipeline**: Exported (JSONL→MD via `recall export`) → - Enriched (YAML frontmatter, tags) → Normalized (soft-wrap, clean - JSON) → Fences Verified (fence balance check) → Locked (prevent - overwrite). Each stage tracked in `.context/journal/.state.json`; - stages are idempotent; locked entries skip regeneration. - -4. **Scratchpad encryption**: User input → `LoadKey()` reads 32-byte - AES key → decrypt existing `scratchpad.enc` → append new entry → - re-encrypt all with AES-256-GCM (12-byte random nonce, ciphertext, - 16-byte auth tag) → write `.enc` file. - -5. **Config resolution**: CLI flags (highest) > environment variables - (`CTX_DIR`, `CTX_TOKEN_BUDGET`) > `.ctxrc` (YAML) > hardcoded - defaults in `internal/rc` → resolved once via `rc.RC()` with - `sync.Once` singleton caching. - -*Full state machine diagrams: -[architecture-dia-state-machines.md](architecture-dia-state-machines.md)* - -## Security Architecture - -Six defense layers protect the system (innermost to outermost): - -- **Layer 0 — Encryption**: AES-256-GCM for scratchpad and webhook - URLs; 12-byte random nonce + 16-byte authentication tag. -- **Layer 1 — File permissions**: Keys 0600, executables 0755, - regular files 0644. -- **Layer 2 — Symlink rejection**: `.context/` directory and children - must not be symlinks (defense against symlink attacks). -- **Layer 3 — Boundary validation**: `ValidateBoundary()` ensures - resolved `.context/` path stays under project root (prevents path - traversal). -- **Layer 4 — Permission deny list**: Blocks `sudo`, `rm -rf`, - `curl`, `wget`, `go install`, force push via Claude Code settings. -- **Layer 5 — Plugin hooks**: `block-non-path-ctx` rejects bare - `./ctx` or absolute-path invocations; `qa-reminder` gates commits. - -**Secret detection** (drift check): scans for `.env`, `credentials*`, -`*secret*`, `*api_key*`, `*password*` — excludes `*.example`, -`*.sample`, and template markers. - -*Full defense layer diagram: -[architecture-dia-security.md](architecture-dia-security.md)* - -## Key Architectural Patterns - - -### Priority-Based File Ordering - -Files load in a deliberate sequence defined by `config.FileReadOrder`: - -1. CONSTITUTION (rules the agent must not violate) -2. TASKS (what to work on now) -3. CONVENTIONS (how to write code) -4. ARCHITECTURE (system structure) -5. DECISIONS (why things are this way) -6. LEARNINGS (gotchas and tips) -7. GLOSSARY (domain terms) -8. AGENT_PLAYBOOK (how to use this system) - -Overridable via `priority_order` in `.ctxrc`. - -### Token Budgeting - -Token estimation uses a 4-characters-per-token heuristic -(see the context package). When the total context exceeds the -budget (default 8000, configurable via `CTX_TOKEN_BUDGET` or -`.ctxrc`), lower-priority files are truncated or omitted. -Higher-priority files always get included first. - -### Structured Entry Headers - -Decisions and learnings use timestamped headers for chronological -ordering and index generation: - + + +## Data Flow + +>API: OK + API-->>User: 201 Created ``` -## [2026-01-28-143022] Use PostgreSQL for primary database -``` - -The regex `config.RegExEntryHeader` parses these across the codebase. - -### Runtime Config Hierarchy - -Configuration resolution (highest priority wins): +--> -1. CLI flags (`--context-dir`) -2. Environment variables (`CTX_DIR`, `CTX_TOKEN_BUDGET`) -3. `.ctxrc` file (YAML) -4. Hardcoded defaults in `internal/rc` +## Key Patterns -Managed by `internal/rc` with sync.Once singleton caching. + -### Extensible Session Parsing - -`internal/recall/parser` defines a `SessionParser` interface. Each -AI tool (Claude Code, potentially Aider, Cursor) registers its own -parser. Currently Claude Code JSONL and Markdown are implemented. -Session matching uses git remote URLs, relative paths, and exact -CWD matching. The Markdown parser recognizes session headers by -configurable prefixes (`session_prefixes` in `.ctxrc`, defaults to -`["Session:"]`). Users extend this list to parse sessions written in -other languages without code changes. - - -### Template and Live Skill Dual-Deployment - -Skills exist in two locations: - -- **Templates** (`internal/assets/claude/skills/`): embedded in the - binary, deployed on `ctx init` -- **Live** (`.claude/skills/`): project-local copies that the user - and agent can edit - -`ctx init` deploys templates to live. The `/update-docs` skill -checks for drift between them. - - -### Hook Architecture - -The Claude Code plugin uses three lifecycle hooks defined in -`internal/assets/claude/hooks/hooks.json`: `UserPromptSubmit` (11 -checks), `PreToolUse` (5 matchers), `PostToolUse` (3 matchers). -Hooks execute synchronously; failures softened with `|| true` -where appropriate. - - -## External Dependencies - -Three direct Go dependencies: `fatih/color` (terminal coloring), -`spf13/cobra` (CLI framework), `gopkg.in/yaml.v3` (YAML parsing). -Optional external tools: `zensical` (static site generation for -journal and docs) and `gpg` (commit signing). - -## Build & Release Pipeline - -Local: `make build` (CGO_ENABLED=0, ldflags version), `make audit` -(gofmt, go vet, golangci-lint, lint scripts, tests), `make smoke` -(integration tests). Release: `hack/release.sh` bumps VERSION, syncs -plugin version, generates release notes, builds all targets, creates -signed git tag. CI: GitHub Actions runs test + lint on push; release -workflow triggers on `v*` tags producing 6 platform binaries -(darwin/linux/windows x amd64/arm64). - -*Full build pipeline diagram: -[architecture-dia-build.md](architecture-dia-build.md)* - - ## File Layout -Top-level: `cmd/ctx/` (entry point), `internal/` (all packages), -`docs/` (site source), `site/` (generated static site), `hack/` -(build scripts), `editors/vscode/` (VS Code extension), `specs/` -(feature specs). Under `internal/`: `bootstrap/`, `claude/`, -`cli/` (24 command packages), `config/`, `context/`, `crypto/`, -`drift/`, `index/`, `journal/state/`, `memory/`, `notify/`, `rc/`, -`recall/parser/`, `sysinfo/`, `task/`, `assets/` (embedded -templates, hooks, skills), `validation/`. Project context lives -in `.context/` with its own journal, sessions, and archive -subdirectories. Claude Code integration in `.claude/` with -settings and 30 live skills. + diff --git a/.context/CONSTITUTION.md b/.context/CONSTITUTION.md index 44f4eb16..a650b4a9 100644 --- a/.context/CONSTITUTION.md +++ b/.context/CONSTITUTION.md @@ -1,5 +1,17 @@ # Constitution + + These rules are INVIOLABLE. If a task requires violating these, the task is wrong. ## Security Invariants @@ -11,12 +23,11 @@ These rules are INVIOLABLE. If a task requires violating these, the task is wron - [ ] All code must pass tests before commit - [ ] No TODO comments in main branch (move to TASKS.md) -- [ ] Path construction uses stdlib — no string concatenation (security: prevents path traversal) +- [ ] Path construction uses language-standard path joining — no string concatenation (security: prevents path traversal) ## Process Invariants - [ ] All architectural changes require a decision record -- [ ] Context loading is not a detour from your task. It IS the first step of every session. A 30-second read delay is always cheaper than a decision made without context. ## TASKS.md Structure Invariants diff --git a/.context/CONVENTIONS.md b/.context/CONVENTIONS.md index df029ce5..cbe272ab 100644 --- a/.context/CONVENTIONS.md +++ b/.context/CONVENTIONS.md @@ -1,165 +1,55 @@ # Conventions -## Naming - -- **Constants use semantic prefixes**: Group related constants with prefixes - - `Dir*` for directories (`DirContext`, `DirArchive`) - - `File*` for file paths (`FileSettings`, `FileClaudeMd`) - - `Filename*` for file names only (`FilenameTask`, `FilenameDecision`) - - `*Type*` for enum-like values (`UpdateTypeTask`, `UpdateTypeDecision`) -- **Package name = folder name**: Go canonical pattern - - `package initialize` in `initialize/` folder - - Never `package initcmd` in `init/` folder -- **Maps reference constants**: Use constants as keys, not literals - - `map[string]X{ConstKey: value}` not `map[string]X{"literal": value}` - -## Casing - -- **Proper nouns keep their casing** in comments, strings, and docs - - `Markdown` not `markdown` (it's a language name) - - `YAML`, `JSON`, `TOML` — always uppercase - - `GitHub`, `JavaScript`, `PostgreSQL` — match official casing - - Exception: code fence language identifiers are lowercase (`` ```markdown ``) + -## Predicates - -- **No Is/Has/Can prefixes**: `Completed()` not `IsCompleted()`, `Empty()` not `IsEmpty()` -- Applies to exported methods that return bool -- Private helpers may use prefixes when it reads more naturally - -## File Organization +## Naming -- **Public API in main file, private helpers in separate logical files** - - `loader.go` (exports `Load()`) + `process.go` (unexported helpers) - - NOT: one file with unexported functions stacked at the bottom -- Reasoning: agent loads only the public API file unless it needs implementation detail -- **Name files after what they contain, not their role** - - `format.go`, `sort.go`, `parse.go` — named by responsibility - - NOT: `util.go`, `utils.go`, `helper.go`, `common.go` — junk drawer names - - If a file can't be named without a generic label, its contents don't belong together - - Existing junk drawers should be split as their contents grow +- **Use semantic prefixes for constants**: Group related constants with prefixes + - `DIR_*` / `Dir*` for directories + - `FILE_*` / `File*` for file paths + - `*_TYPE` / `*Type` for enum-like values +- **Module/package name = folder name**: Keep names consistent with the filesystem +- **Avoid magic strings**: Use named constants instead of string literals for comparison ## Patterns -- **Centralize magic strings**: All repeated literals belong in a `config` or `constants` package +- **Centralize repeated literals**: All repeated literals belong in a constants/config module - If a string appears in 3+ files, it needs a constant - If a string is used for comparison, it needs a constant -- **Path construction**: Always use stdlib path joining +- **Path construction**: Always use your language's standard path joining + - Python: `os.path.join(dir, file)` or `pathlib.Path(dir) / file` + - Node/TS: `path.join(dir, file)` - Go: `filepath.Join(dir, file)` - - Python: `os.path.join(dir, file)` - - Node: `path.join(dir, file)` - - Never: `dir + "/" + file` -- **Constants reference constants**: Self-referential definitions - - `FileType[UpdateTypeTask] = FilenameTask` not `FileType["task"] = "TASKS.md"` -- **No error variable shadowing**: Use descriptive names when multiple errors exist in a function - - `readErr`, `writeErr`, `indexErr` — not repeated `err` / `err :=` - - Shadowed `err` silently disconnects from the outer variable, causing subtle bugs + - Rust: `PathBuf::from(dir).join(file)` + - Never: `dir + "/" + file` (string concatenation) - **Colocate related code**: Group by feature, not by type - - `session/run.go`, `session/types.go`, `session/parse.go` - - Not: `runners/session.go`, `types/session.go`, `parsers/session.go` - -## Line Width - -- **Target ~80 characters**: Highly encouraged, not a hard limit - - Some lines will naturally exceed it (long strings, struct tags, URLs) — that's fine - - Drift accumulates silently, especially in test code - - Break at natural points: function arguments, struct fields, chained calls - -## Duplication - -- **Non-test code**: Apply the rule of three — extract when a block appears 3+ times - - Watch for copy-paste during task-focused sessions where the agent prioritizes completion over shape -- **Test code**: Some duplication is acceptable for readability - - When the same setup/assertion block appears 3+ times, extract a test helper - - Use `t.Helper()` so failure messages point to the caller, not the helper + - `session/run.ext`, `session/types.ext`, `session/parse.ext` + - Not: `runners/session.ext`, `types/session.ext`, `parsers/session.ext` ## Testing - **Colocate tests**: Test files live next to source files - - `foo.go` → `foo_test.go` in same package - - Not a separate `tests/` folder + - Not in a separate `tests/` folder (unless the language convention requires it) - **Test the unit, not the file**: One test file can test multiple related functions -- **Integration tests are separate**: `cli_test.go` for end-to-end binary tests - -## Code Change Heuristics - -- **Present interpretations, don't pick silently**: If a request has multiple - valid readings, lay them out rather than guessing -- **Push back when warranted**: If a simpler approach exists, say so -- **"Would a senior engineer call this overcomplicated?"**: If yes, simplify -- **Match existing style**: Even if you'd write it differently in a greenfield -- **Every changed line traces to the request**: If it doesn't, revert it - -## Decision Heuristics - -- **"Would I start this today?"**: If not, continuing is the sunk cost — evaluate only future value -- **"Reversible or one-way door?"**: Reversible decisions don't need deep analysis -- **"Does the analysis cost more than the decision?"**: Stop deliberating when the options are within an order of magnitude -- **"Order of magnitude, not precision"**: 10x better matters; 10% better usually doesn't - -## Refactoring - -- **Measure the end state, not the effort**: When refactoring, ask what the - codebase looks like *after*, not how much work the change is -- **Three questions before restructuring**: - 1. What's the smallest codebase that solves this? - 2. Does the proposed change result in less total code? - 3. What can we delete now that this change makes obsolete? -- **Deletion is a feature**: Writing 50 lines that delete 200 is a net win +- **Integration tests are separate**: Clearly distinguish unit tests from end-to-end tests ## Documentation -- **Godoc format**: Use canonical sections - ```go - // FunctionName does X. - // - // Longer description if needed. - // - // Parameters: - // - param1: Description - // - param2: Description - // - // Returns: - // - Type: Description of return value - func FunctionName(param1, param2 string) error - ``` -- **Package doc in doc.go**: Each package gets a `doc.go` with package-level - documentation describing behavior, not structure. Do NOT include - `# File Organization` sections listing files — they drift when files are - added, renamed, or removed, and the filesystem is self-documenting +- **Follow language conventions**: Use the standard doc format for your language + - Python: docstrings (Google/NumPy/Sphinx style) + - TypeScript/JavaScript: JSDoc or TSDoc + - Go: Godoc comments + - Rust: `///` doc comments with Markdown +- **Document public APIs**: Every exported function/class/type gets a doc comment - **Copyright headers**: All source files get the project copyright header - -## Blog Publishing - -- **Checklist for ideas/ → docs/blog/ promotion**: - 1. Update date in frontmatter to publish date - 2. Fix relative paths (from `../docs/blog/` to peer references) - 3. Add cross-links to/from companion posts ("See also" sections) - 4. Add "The Arc" section connecting to the series narrative - 5. Update `docs/blog/index.md` with entry (newest first) - 6. Verify all link targets exist - 7. Build and test before commit -- **Arc section**: Every post includes "The Arc" near the end, framing - where the post sits in the broader blog narrative -- **See also links**: Use italic `*See also: [Title](file) -- one-line - description connecting the two posts.*` format at the end of posts -- **Frontmatter**: Include copyright header, title, date, author, topics list -- **Blog index order**: Newest post first, with topic tags and 3-4 line summary - -- Update admonitions for historical blog content: Use MkDocs admonitions (\!\!\! note "Update") at the top of blog post sections where features have been superseded or installation has changed. Link to current documentation. Keep original content intact below for historical context. - -- New CLI subcommand documentation checklist: When adding a new CLI subcommand, update docs in at least three places: (1) Feature page (e.g., docs/scratchpad.md) — commands table, usage section, skill/NL table. (2) CLI reference (docs/cli-reference.md) — full reference entry with args, flags, examples. (3) Relevant recipes — any recipe that covers the feature area. (4) zensical.toml — only if adding a new page. - -- Always stage site/ when committing docs/ changes — the generated HTML is tracked in git with no CI build step. - -- Zero //nolint:errcheck policy — handle errors, don't suppress them. In test code: use t.Fatal(err) for setup errors, _ = os.Chdir(orig) for cleanup. In production code: use defer func() { _ = f.Close() }() for best-effort close. For gosec false positives: prefer config-level exclusions in .golangci.yml. - -- Error constructors belong in internal/err, never in per-package err.go files — eliminates the broken-window pattern where agents add local errors when they see a local err.go exists. - -- CLI package taxonomy: every package under internal/cli/ follows the same structure — parent.go (Cmd wiring), doc.go, cmd/root/ or cmd// (implementation), core/ (shared helpers). cmd/ directories contain only cmd.go + run.go; all other helpers belong in core/ - -- All structs in a core/ package are consolidated into a single types.go file - -- All user-facing text is routed through internal/assets with YAML-backed TextDescKeys — no inline strings in core/ or cmd/ packages - -- Every package under internal/config/ must have a doc.go with the project header and a one-line package comment diff --git a/.context/GLOSSARY.md b/.context/GLOSSARY.md index 93e37633..c8575b82 100644 --- a/.context/GLOSSARY.md +++ b/.context/GLOSSARY.md @@ -1,41 +1,18 @@ # Glossary -## Domain Terms +` and `` markers). Updated by `ctx add` and `ctx decision/learnings reindex`. | -| Readback | A structured summary where the agent plays back what it knows (last session, active tasks, recent decisions) so the user can confirm correct context was loaded. From aviation: pilots repeat ATC instructions back to confirm they heard correctly. In ctx, triggered by "do you remember?" or `/ctx-remember`. | -| Ralph Loop | An iterative autonomous AI development workflow that uses PROMPT.md as a directive. Separate from ctx but complementary: Ralph drives the loop, ctx provides the memory. | -| IMPLEMENTATION_PLAN.md | The orchestrator's directive file. Contains the meta-task ("check your tasks"), not the tasks themselves. Lives in project root, not `.context/`. | -| Skill | A Claude Code Agent Skill: a markdown file in `.claude/skills/` that teaches the agent a specialized workflow. Invoked via `/skill-name`. | -| Live skill | The project-local copy of a skill in `.claude/skills/`. Can be edited by the user or agent. Contrast with template skill. | -| Template skill | The embedded copy of a skill in `internal/assets/claude/skills/`. Deployed on `ctx init`. Source of truth for the default version. | -| Hook | A Claude Code lifecycle script in `.claude/hooks/`. Fires on events: PreToolUse, UserPromptSubmit, SessionEnd. Generated by `ctx init`. | -| Consolidation | A code-quality sweep checking for convention drift: magic strings, predicate naming, file size, dead exports, etc. Run via `/consolidate` skill. Distinct from compaction (which is context-level). | -| 3:1 ratio | Heuristic for consolidation timing: consolidate after every 3 feature/bugfix sessions. Prevents convention drift from compounding. | -| E/A/R classification | Expert/Activation/Redundant taxonomy for evaluating skill quality. Good skill = >70% Expert knowledge, <10% Redundant with what the model already knows. | +DO NOT UPDATE FOR: +- Industry-standard terms with obvious meanings +- Temporary or experimental terminology +--> -## Abbreviations +## Domain Terms -| Abbreviation | Expansion | -|--------------|-------------------------------------------------------------------------------------------------------------| -| ctx | Context (the CLI tool and the system it manages) | -| rc | Runtime configuration (from Unix `.xxxrc` convention); refers to `.ctxrc` and the `internal/rc` package | -| assets | Embedded assets; the `internal/assets` package containing go:embed templates and plugin files | -| CWD | Current working directory; used in session matching to correlate sessions with projects | -| JSONL | JSON Lines; the format Claude Code uses for session transcripts (one JSON object per line) | +## Abbreviations diff --git a/.context/PROMPT.md b/.context/PROMPT.md index 8ed4f0ff..1bf7d063 100644 --- a/.context/PROMPT.md +++ b/.context/PROMPT.md @@ -13,7 +13,7 @@ | File | Purpose | |------------------------------|------------------------------------------| -| `.context/CONSTITUTION.md` | Hard rules — NEVER violate | +| `.context/CONSTITUTION.md` | Hard rules: NEVER violate | | `.context/TASKS.md` | Current work items | | `.context/DECISIONS.md` | Architectural decisions with rationale | | `.context/LEARNINGS.md` | Gotchas and lessons learned | @@ -38,6 +38,6 @@ After completing meaningful work, capture what matters: | Discovered a gotcha | `ctx add learning "..."` | | Significant code changes | Consider what's worth capturing | -Don't wait for the session to end — it may never come cleanly. +Don't wait for the session to end: it may never come cleanly. diff --git a/.context/prompts/code-review.md b/.context/prompts/code-review.md new file mode 100644 index 00000000..4b573e06 --- /dev/null +++ b/.context/prompts/code-review.md @@ -0,0 +1,11 @@ +# Code Review + +Review this code change focusing on: + +- **Correctness**: Does the logic do what it claims? Are there off-by-one errors, nil dereferences, or race conditions? +- **Edge cases**: What happens with empty input, max values, concurrent access, or partial failures? +- **Naming clarity**: Do function, variable, and type names communicate intent without needing comments? +- **Test coverage gaps**: What behavior is untested? What inputs would exercise uncovered paths? +- **Convention adherence**: Does this follow the project patterns documented in `.context/CONVENTIONS.md`? + +Flag but don't fix style issues. Focus your review on substance over formatting. diff --git a/.context/prompts/explain.md b/.context/prompts/explain.md new file mode 100644 index 00000000..318acea9 --- /dev/null +++ b/.context/prompts/explain.md @@ -0,0 +1,11 @@ +# Explain + +Explain this code for someone new to the project. Cover: + +- **What it does**: Describe the purpose and behavior in plain language. +- **Why it exists**: What problem does it solve? What would break without it? +- **How it connects**: Which modules call it, and which modules does it depend on? +- **Key design decisions**: Why was this approach chosen over alternatives? +- **Non-obvious details**: Anything surprising, subtle, or easy to misunderstand. + +Reference `.context/ARCHITECTURE.md` for system-level context where relevant. diff --git a/.context/prompts/refactor.md b/.context/prompts/refactor.md new file mode 100644 index 00000000..e4528418 --- /dev/null +++ b/.context/prompts/refactor.md @@ -0,0 +1,11 @@ +# Refactor + +Refactor the specified code following these rules: + +1. **Write or verify tests first**: confirm existing behavior is captured before changing structure. +2. **Preserve all existing behavior**: refactoring changes structure, not outcomes. +3. **Make one structural change at a time**: keep each step reviewable and revertible. +4. **Run tests after each step**: catch regressions immediately, not at the end. +5. **Check project conventions**: consult `.context/CONVENTIONS.md` to ensure the refactored code follows established patterns. + +If a refactoring step would change observable behavior, stop and flag it as a separate task. diff --git a/.context/templates/decision.md b/.context/templates/decision.md index 7f06e047..87f805f9 100644 --- a/.context/templates/decision.md +++ b/.context/templates/decision.md @@ -5,8 +5,8 @@ **Context**: [What situation prompted this decision? What constraints exist?] **Alternatives Considered**: -1. **[Option A]**: [Description] — Pros: [...] / Cons: [...] -2. **[Option B]**: [Description] — Pros: [...] / Cons: [...] +1. **[Option A]**: [Description]: Pros: [...] / Cons: [...] +2. **[Option B]**: [Description]: Pros: [...] / Cons: [...] **Decision**: [What was decided?] diff --git a/.gitignore b/.gitignore index d59a8592..19e09f96 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,9 @@ ideas .context/.ctx.key .context/.scratchpad.key .DS_Store + +# ctx managed entries +.context/journal +.context/journal-site +.context/journal-obsidian +.context/logs diff --git a/CLAUDE.md b/CLAUDE.md index 1a1eb28d..5c9d6361 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,62 @@ # Context - Claude Code Context + +# Project Context + + + + +## IMPORTANT: You Have Persistent Memory + +This project uses Context (`ctx`) for context persistence across sessions. +**Your memory is NOT ephemeral** — it lives in the `.context/` directory. + +## On Session Start + +1. **Read `.context/AGENT_PLAYBOOK.md`** — it explains how to use this system +2. **Run `ctx agent --budget 4000`** in a terminal for an AI-optimized context summary +3. **Check `.context/TASKS.md`** for active work items + +## When Asked "Do You Remember?" + +**Do this FIRST (silently):** +- Read TASKS.md, DECISIONS.md, and LEARNINGS.md from `.context/` +- Run `ctx recall list --limit 5` for recent session history + +**Then respond with a structured readback:** + +1. **Last session**: cite the most recent session topic and date +2. **Active work**: list pending or in-progress tasks +3. **Recent context**: mention 1-2 recent decisions or learnings +4. **Next step**: offer to continue or ask what to focus on + +**Never** lead with "I don't have memory." The `.context/` files are +your memory. Read them silently, then present what you found as recall. + +## Quick Context Load + +```bash +# Get AI-optimized context packet (what you should know) +ctx agent --budget 4000 + +# Or see full status +ctx status +``` + +## Context Files + +| File | Purpose | +|------|---------| +| CONSTITUTION.md | Hard rules - NEVER violate | +| TASKS.md | Current work items | +| DECISIONS.md | Architectural decisions with rationale | +| LEARNINGS.md | Gotchas, tips, lessons learned | +| CONVENTIONS.md | Code patterns and standards | + +All files live in the `.context/` directory. + + + ## IMPORTANT: You Have Persistent Memory This project uses Context (ctx) for context persistence across sessions. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 9b2ae2ff..d4d682db 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -13,9 +13,9 @@ This file provides high-level direction. Detailed tasks live in `.context/TASKS. What does "done" look like for this project? -1. **Goal** — Define your end state -2. **Validation** — How will you know it works? -3. **Handoff** — Can someone else pick this up? +1. **Goal**: Define your end state +2. **Validation**: How will you know it works? +3. **Handoff**: Can someone else pick this up? ## Notes diff --git a/editors/vscode/.vscodeignore b/editors/vscode/.vscodeignore index d8163296..dd630747 100644 --- a/editors/vscode/.vscodeignore +++ b/editors/vscode/.vscodeignore @@ -4,6 +4,7 @@ src/ tsconfig.json vitest.config.ts package-lock.json +test-insiders-sim.js **/*.map **/*.ts !dist/** diff --git a/editors/vscode/package.json b/editors/vscode/package.json index bd284ba2..4350d4ee 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -34,7 +34,9 @@ "copilot", "chat" ], - "activationEvents": [], + "activationEvents": [ + "onStartupFinished" + ], "main": "./dist/extension.js", "contributes": { "chatParticipants": [ @@ -224,6 +226,10 @@ { "name": "why", "description": "Read the philosophy behind ctx (manifesto, about, invariants)" + }, + { + "name": "diag", + "description": "Diagnose extension issues — times each step to find hangs" } ], "disambiguation": [ @@ -288,7 +294,8 @@ { "command": "ctx.deps", "title": "Deps", "category": "ctx" }, { "command": "ctx.guide", "title": "Guide", "category": "ctx" }, { "command": "ctx.reindex", "title": "Reindex", "category": "ctx" }, - { "command": "ctx.why", "title": "Why", "category": "ctx" } + { "command": "ctx.why", "title": "Why", "category": "ctx" }, + { "command": "ctx.diag", "title": "Diagnose", "category": "ctx" } ], "configuration": { "title": "ctx", diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 19757845..478b90e2 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -23,6 +23,9 @@ let extensionCtx: vscode.ExtensionContext | undefined; // Status bar item for context reminders let reminderStatusBar: vscode.StatusBarItem | undefined; +// Output channel for diagnostic logging (visible in Output panel) +let outputLog: vscode.OutputChannel; + // --- Detection ring: deny patterns for governance --- const DENY_COMMAND_PATTERNS: RegExp[] = [ /\bsudo\s/, @@ -141,13 +144,15 @@ function getPlatformInfo(): { goos: string; goarch: string; ext: string } { */ function fetchJSON(url: string): Promise { return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`fetchJSON timeout after 10s: ${url}`)), 10000); const get = (reqUrl: string, redirectCount: number) => { if (redirectCount > 5) { + clearTimeout(timer); reject(new Error("Too many redirects")); return; } https - .get(reqUrl, { headers: { "User-Agent": "ctx-vscode" } }, (res) => { + .get(reqUrl, { headers: { "User-Agent": "ctx-vscode" }, timeout: 10000 }, (res) => { if ( res.statusCode && res.statusCode >= 300 && @@ -164,15 +169,16 @@ function fetchJSON(url: string): Promise { const chunks: Buffer[] = []; res.on("data", (chunk: Buffer) => chunks.push(chunk)); res.on("end", () => { + clearTimeout(timer); try { resolve(JSON.parse(Buffer.concat(chunks).toString())); } catch (e) { reject(e); } }); - res.on("error", reject); + res.on("error", (err) => { clearTimeout(timer); reject(err); }); }) - .on("error", reject); + .on("error", (err) => { clearTimeout(timer); reject(err); }); }; get(url, 0); }); @@ -183,13 +189,15 @@ function fetchJSON(url: string): Promise { */ function downloadFile(url: string, destPath: string): Promise { return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`downloadFile timeout after 30s: ${url}`)), 30000); const get = (reqUrl: string, redirectCount: number) => { if (redirectCount > 5) { + clearTimeout(timer); reject(new Error("Too many redirects")); return; } https - .get(reqUrl, { headers: { "User-Agent": "ctx-vscode" } }, (res) => { + .get(reqUrl, { headers: { "User-Agent": "ctx-vscode" }, timeout: 30000 }, (res) => { if ( res.statusCode && res.statusCode >= 300 && @@ -206,15 +214,18 @@ function downloadFile(url: string, destPath: string): Promise { const file = fs.createWriteStream(destPath); res.pipe(file); file.on("finish", () => { + clearTimeout(timer); file.close(); resolve(); }); file.on("error", (err) => { + clearTimeout(timer); fs.unlink(destPath, () => {}); reject(err); }); }) .on("error", (err) => { + clearTimeout(timer); fs.unlink(destPath, () => {}); reject(err); }); @@ -403,7 +414,9 @@ async function handleInit( ): Promise { stream.progress("Initializing .context/ directory..."); try { - const { stdout, stderr } = await runCtx(["init", "--caller", "vscode"], cwd, token); + // Always pass --force: without it, ctx init prompts for y/N confirmation + // when .context/ already exists, which hangs in non-interactive execFile. + const { stdout, stderr } = await runCtx(["init", "--force", "--caller", "vscode"], cwd, token); const output = mergeOutput(stdout, stderr); if (output) { stream.markdown("```\n" + output + "\n```"); @@ -2276,6 +2289,101 @@ async function handleWhy( return { metadata: { command: "why" } }; } +async function handleDiag( + stream: vscode.ChatResponseStream, + cwd: string, + token: vscode.CancellationToken +): Promise { + const say = (msg: string) => { + stream.markdown(msg + "\n"); + }; + + const timed = async (label: string, fn: () => Promise, timeoutMs = 8000): Promise => { + say(`\n**${label}**`); + const start = Date.now(); + try { + const result = await Promise.race([ + fn(), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`TIMEOUT after ${timeoutMs}ms`)), timeoutMs) + ), + ]); + say(` ✓ ${Date.now() - start}ms`); + return result; + } catch (err: unknown) { + say(` ✗ ${Date.now() - start}ms — ${err instanceof Error ? err.message : String(err)}`); + return undefined; + } + }; + + say("## ctx Extension Diagnostics\n"); + say(`- **VS Code version:** ${vscode.version}`); + say(`- **Platform:** ${os.platform()} ${os.arch()}`); + say(`- **Workspace:** ${cwd}`); + say(`- **resolvedCtxPath:** ${resolvedCtxPath || "(not set)"}`); + say(`- **bootstrapDone:** ${bootstrapDone}`); + say(`- **getCtxPath():** ${getCtxPath()}`); + say(`- **.context/ exists:** ${fs.existsSync(path.join(cwd, ".context"))}`); + + // Step 1: Can we find ctx at all? (no shell) + const ctxPath = getCtxPath(); + await timed("Step 1: execFile ctx --version (no shell)", () => + new Promise((resolve, reject) => { + execFile(ctxPath, ["--version"], { timeout: 5000 }, (err, stdout) => { + if (err) reject(err); else resolve(stdout.trim()); + }); + }).then((v) => { say(` version: ${v}`); return v; }) + ); + + // Step 2: with shell (Windows PATH resolution) + await timed("Step 2: execFile ctx --version (shell: true)", () => + new Promise((resolve, reject) => { + execFile(ctxPath, ["--version"], { timeout: 5000, shell: true }, (err, stdout) => { + if (err) reject(err); else resolve(stdout.trim()); + }); + }).then((v) => { say(` version: ${v}`); return v; }) + ); + + // Step 3: Bootstrap (includes GitHub API call if binary not found) + await timed("Step 3: bootstrap()", async () => { + bootstrapDone = false; + bootstrapPromise = undefined; + await bootstrap(); + say(` resolvedCtxPath after: ${resolvedCtxPath}`); + }, 15000); + + // Step 4: runCtx status + await timed("Step 4: runCtx(['status'])", async () => { + const { stdout } = await runCtx(["status"], cwd, token); + say(` output: ${stdout.substring(0, 100)}...`); + }); + + // Step 5: runCtx init --force + await timed("Step 5: runCtx(['init', '--force', '--caller', 'vscode'])", async () => { + const { stdout, stderr } = await runCtx( + ["init", "--force", "--caller", "vscode"], + cwd, + token + ); + const output = mergeOutput(stdout, stderr); + say(` output: ${output.substring(0, 200)}...`); + }); + + // Step 6: hook copilot + await timed("Step 6: runCtx(['hook', 'copilot', '--write'])", async () => { + const { stdout, stderr } = await runCtx( + ["hook", "copilot", "--write"], + cwd, + token + ); + const output = mergeOutput(stdout, stderr); + say(` output: ${output.substring(0, 200)}...`); + }); + + say("\n**✅ Diagnostics complete.**"); + return { metadata: { command: "diag" } }; +} + async function handleFreeform( request: vscode.ChatRequest, stream: vscode.ChatResponseStream, @@ -2479,6 +2587,8 @@ const handler: vscode.ChatRequestHandler = async ( stream: vscode.ChatResponseStream, token: vscode.CancellationToken ): Promise => { + try { + outputLog.appendLine(`[${new Date().toISOString()}] Handler: command=${request.command || '(none)'}`); const cwd = getWorkspaceRoot(); if (!cwd) { stream.markdown( @@ -2487,6 +2597,11 @@ const handler: vscode.ChatRequestHandler = async ( return { metadata: { command: request.command || "none" } }; } + // /diag bypasses all guards — it IS the diagnostic tool + if (request.command === "diag") { + return handleDiag(stream, cwd, token); + } + // Auto-bootstrap: ensure ctx binary is available before any command try { stream.progress("Checking ctx installation..."); @@ -2601,18 +2716,32 @@ const handler: vscode.ChatRequestHandler = async ( return handleReindex(stream, cwd, token); case "why": return handleWhy(stream, request.prompt, cwd, token); + case "diag": + return handleDiag(stream, cwd, token); default: return handleFreeform(request, stream, cwd, token); } + } catch (err: unknown) { + const msg = err instanceof Error ? err.stack || err.message : String(err); + console.error(`[ctx] Handler CRASHED: ${msg}`); + stream.markdown(`**Error:** ${msg}`); + return { metadata: { command: request.command || "error" } }; + } }; export function activate(extensionContext: vscode.ExtensionContext) { + // Output channel for diagnostics (visible in Output panel > "ctx") + outputLog = vscode.window.createOutputChannel("ctx"); + outputLog.appendLine(`[${new Date().toISOString()}] ctx activating — VS Code ${vscode.version}, ${os.platform()} ${os.arch()}`); + // Store extension context for auto-bootstrap binary downloads extensionCtx = extensionContext; // Kick off background bootstrap — don't block activation - bootstrap().catch(() => { - // Errors will surface when user invokes a command + bootstrap().then(() => { + outputLog.appendLine(`[${new Date().toISOString()}] Bootstrap complete: ${resolvedCtxPath}`); + }).catch((err) => { + outputLog.appendLine(`[${new Date().toISOString()}] Bootstrap FAILED: ${err instanceof Error ? err.message : String(err)}`); }); const participant = vscode.chat.createChatParticipant( @@ -2879,6 +3008,7 @@ export function activate(extensionContext: vscode.ExtensionContext) { ["ctx.guide", "/guide"], ["ctx.reindex", "/reindex"], ["ctx.why", "/why"], + ["ctx.diag", "/diag"], ]; for (const [cmdId, slash] of paletteCommands) { extensionContext.subscriptions.push( diff --git a/internal/cli/initialize/cmd/root/run.go b/internal/cli/initialize/cmd/root/run.go index 30ce0d2d..731963c2 100644 --- a/internal/cli/initialize/cmd/root/run.go +++ b/internal/cli/initialize/cmd/root/run.go @@ -65,6 +65,12 @@ func Run(cmd *cobra.Command, force, minimal, merge, ralph, noPluginEnable bool, // treated as uninitialized — no overwrite prompt needed. if _, err := os.Stat(contextDir); err == nil { if !force && hasEssentialFiles(contextDir) { + // When called from an editor (--caller), stdin is unavailable. + // Skip the interactive prompt to prevent hanging. + if caller != "" { + initialize.InfoAborted(cmd) + return nil + } // Prompt for confirmation initialize.InfoOverwritePrompt(cmd, contextDir) reader := bufio.NewReader(os.Stdin) @@ -148,13 +154,16 @@ func Run(cmd *cobra.Command, force, minimal, merge, ralph, noPluginEnable bool, } // Create PROMPT.md (uses ralph template if --ralph flag set) - if err := core.HandlePromptMd(cmd, force, merge, ralph); err != nil { + // When called from an editor (--caller), auto-merge to avoid stdin prompt. + autoMerge := merge || caller != "" + if err := core.HandlePromptMd(cmd, force, autoMerge, ralph); err != nil { // Non-fatal: warn but continue initialize.InfoWarnNonFatal(cmd, "PROMPT.md", err) } // Create IMPLEMENTATION_PLAN.md - if err := core.HandleImplementationPlan(cmd, force, merge); err != nil { + // When called from an editor (--caller), auto-merge to avoid stdin prompt. + if err := core.HandleImplementationPlan(cmd, force, autoMerge); err != nil { // Non-fatal: warn but continue initialize.InfoWarnNonFatal(cmd, "IMPLEMENTATION_PLAN.md", err) } @@ -180,7 +189,8 @@ func Run(cmd *cobra.Command, force, minimal, merge, ralph, noPluginEnable bool, } // Handle CLAUDE.md creation/merge (uses caller-specific override when available) - if err := core.HandleClaudeMd(cmd, force, merge, caller); err != nil { + // When called from an editor (--caller), auto-merge to avoid stdin prompt. + if err := core.HandleClaudeMd(cmd, force, autoMerge, caller); err != nil { // Non-fatal: warn but continue initialize.InfoWarnNonFatal(cmd, "CLAUDE.md", err) } From ce018c9fe976262498f4c57cd81e97076670a3ad Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Thu, 19 Mar 2026 01:01:00 +0300 Subject: [PATCH 6/6] fix: CI failures and marketplace readiness - Fix gosec G101 lint path in .golangci.yml (constants moved to config/embed) - Fix TestTextDescKeysResolve path in embed_test.go - Add license header and doc.go for internal/config/embed - Add VS Code marketplace fields (extensionDependencies, pricing) - Add CHANGELOG.md and update .vscodeignore Signed-off-by: ersan bilik --- .golangci.yml | 2 +- editors/vscode/.vscodeignore | 3 +++ editors/vscode/CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ editors/vscode/package.json | 4 ++++ internal/assets/embed_test.go | 10 ++++++---- internal/config/embed/doc.go | 9 +++++++++ internal/config/embed/embeds.go | 6 ++++++ 7 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 editors/vscode/CHANGELOG.md create mode 100644 internal/config/embed/doc.go diff --git a/.golangci.yml b/.golangci.yml index 09228fe9..d5c5b11f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -37,7 +37,7 @@ linters: # TextDescKey constants are i18n keys, not credentials - linters: [gosec] text: "G101" - path: "internal/assets/embed\\.go" + path: "internal/config/embed/embeds\\.go" run: timeout: 5m diff --git a/editors/vscode/.vscodeignore b/editors/vscode/.vscodeignore index dd630747..07f67a40 100644 --- a/editors/vscode/.vscodeignore +++ b/editors/vscode/.vscodeignore @@ -3,8 +3,11 @@ node_modules/ src/ tsconfig.json vitest.config.ts +vitest.workspace.ts package-lock.json test-insiders-sim.js +*.vsix +.github/ **/*.map **/*.ts !dist/** diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md new file mode 100644 index 00000000..cd4586a3 --- /dev/null +++ b/editors/vscode/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to the **ctx — Persistent Context for AI** extension +will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/). + +## [0.9.0] — 2026-03-19 + +### Added + +- **@ctx chat participant** with 45 slash commands covering context + lifecycle, task management, session recall, and discovery +- **Natural language routing** — type plain English after `@ctx` and + the extension maps it to the correct handler +- **Auto-bootstrap** — downloads the ctx CLI binary if not found on PATH +- **Detection ring** — terminal command watcher and file edit watcher + record governance violations for the MCP engine +- **Status bar reminders** — `$(bell) ctx` indicator for pending reminders +- **Automatic hooks** — file save, git commit, dependency change, and + context file change handlers +- **Follow-up suggestions** — context-aware buttons after each command +- **`/diag` command** — diagnose extension issues with step-by-step timing + +### Configuration + +- `ctx.executablePath` — path to the ctx CLI binary (default: `ctx`) + +## [Unreleased] + +- Marketplace publication diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 4350d4ee..0412622c 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -34,6 +34,10 @@ "copilot", "chat" ], + "pricing": "Free", + "extensionDependencies": [ + "github.copilot-chat" + ], "activationEvents": [ "onStartupFinished" ], diff --git a/internal/assets/embed_test.go b/internal/assets/embed_test.go index fc9ccbd0..c3e5e1c5 100644 --- a/internal/assets/embed_test.go +++ b/internal/assets/embed_test.go @@ -9,6 +9,7 @@ package assets import ( "fmt" "os" + "path/filepath" "strings" "testing" @@ -114,13 +115,14 @@ func TestTextDescKeysResolve(t *testing.T) { t.Logf("verified %d TextDescKey constants", len(keys)) } -// collectTextDescKeys extracts all TextDescKey constant values from embed.go -// by parsing lines matching the pattern: TextDescKey... = "..." +// collectTextDescKeys extracts all TextDescKey constant values from +// config/embed/embeds.go by parsing lines matching the pattern: +// TextDescKey... = "..." func collectTextDescKeys(t *testing.T) []string { t.Helper() - data, err := os.ReadFile("embed.go") + data, err := os.ReadFile(filepath.Join("..", "config", "embed", "embeds.go")) if err != nil { - t.Fatalf("read embed.go: %v", err) + t.Fatalf("read config/embed/embeds.go: %v", err) } var keys []string diff --git a/internal/config/embed/doc.go b/internal/config/embed/doc.go new file mode 100644 index 00000000..075f6585 --- /dev/null +++ b/internal/config/embed/doc.go @@ -0,0 +1,9 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package embed provides text description key constants and flag +// description key constants used for i18n-ready CLI output strings. +package embed diff --git a/internal/config/embed/embeds.go b/internal/config/embed/embeds.go index cb8d57b0..cb5c042c 100644 --- a/internal/config/embed/embeds.go +++ b/internal/config/embed/embeds.go @@ -1,3 +1,9 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + package embed const (