From 9507269774be0f25c9ed2d9d345966fe7828864d Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Tue, 17 Mar 2026 19:40:50 +0300 Subject: [PATCH 01/12] 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 a507b047..5d5f49d1 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" errFs "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 19e9ab7601854fe545da8d36cb74633fd041ca11 Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Tue, 17 Mar 2026 19:43:36 +0300 Subject: [PATCH 02/12] 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 6d6ef8ba..c823c54a 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 e9db5867..0297c95d 100644 --- a/internal/recall/parser/parser.go +++ b/internal/recall/parser/parser.go @@ -21,6 +21,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 41eb2d94..50c46b59 100644 --- a/internal/recall/parser/query.go +++ b/internal/recall/parser/query.go @@ -57,6 +57,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 7dcbdf626bf695f20cd4d99bdc0a7e0fb6688fb1 Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Tue, 17 Mar 2026 19:45:22 +0300 Subject: [PATCH 03/12] 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 | 4 + editors/vscode/src/extension.test.ts | 44 +-- .../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/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 | 47 +-- 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 +++++++++ 19 files changed, 1804 insertions(+), 67 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 c967dc38..129d8f2c 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -164,6 +164,10 @@ { "name": "reindex", "description": "Rebuild context file indices" + }, + { + "name": "diag", + "description": "Diagnose extension issues — times each step to find hangs" } ], "disambiguation": [ 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/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/cli/hook/cmd/root/run.go b/internal/cli/hook/cmd/root/run.go index 7204f6dd..28162abf 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" @@ -140,5 +141,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 55f0c988..f39d1a32 100644 --- a/internal/cli/initialize/cmd/root/cmd.go +++ b/internal/cli/initialize/cmd/root/cmd.go @@ -41,6 +41,7 @@ func Cmd() *cobra.Command { merge bool ralph bool noPluginEnable bool + caller string ) short, long := desc.Command(cmd.DescKeyInitialize) @@ -50,7 +51,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) }, } @@ -76,6 +77,10 @@ func Cmd() *cobra.Command { &noPluginEnable, cFlag.NoPluginEnable, false, desc.Flag(flag.DescKeyInitializeNoPluginEnable), ) + cmd.Flags().StringVar( + &caller, "caller", "", + "Identify the calling tool (e.g. vscode) to tailor output", + ) return c } diff --git a/internal/cli/initialize/cmd/root/run.go b/internal/cli/initialize/cmd/root/run.go index b32e811a..97f14cc7 100644 --- a/internal/cli/initialize/cmd/root/run.go +++ b/internal/cli/initialize/cmd/root/run.go @@ -56,15 +56,19 @@ import ( // - merge: If true, auto-merge ctx content into existing files // - ralph: If true, use autonomous loop templates (no questions, signals) // - noPluginEnable: If true, skip auto-enabling the plugin globally +// - caller: Identifies the calling tool (e.g. "vscode") for template overrides // // Returns: // - error: Non-nil if directory creation or file operations fail func Run( - cmd *cobra.Command, force, minimal, merge, ralph, noPluginEnable bool, + cmd *cobra.Command, force, minimal, merge, ralph, noPluginEnable bool, caller string, ) error { - // Check if ctx is in PATH (required for hooks to work) - if err := validate.CheckCtxInPath(cmd); err != nil { - return err + // 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 := validate.CheckCtxInPath(cmd); err != nil { + return err + } } contextDir := rc.ContextDir() @@ -168,18 +172,21 @@ func Run( initialize.InfoWarnNonFatal(cmd, project.ImplementationPlan, err) } - // Merge permissions into settings.local.json (no hook scaffolding) - initialize.InfoSettingUpPermissions(cmd) - if err := coreMerge.SettingsPermissions(cmd); err != nil { - // Non-fatal: warn but continue - initialize.InfoWarnNonFatal(cmd, desc.Text(text.DescKeyInitLabelPermissions), err) - } - - // Auto-enable plugin globally unless suppressed - if !noPluginEnable { - if pluginErr := plugin.EnablePluginGlobally(cmd); pluginErr != nil { + // Claude Code specific artifacts — skip when called from another editor. + if caller == "" { + // Merge permissions into settings.local.json (no hook scaffolding) + initialize.InfoSettingUpPermissions(cmd) + if err := coreMerge.SettingsPermissions(cmd); err != nil { // Non-fatal: warn but continue - initialize.InfoWarnNonFatal(cmd, desc.Text(text.DescKeyInitLabelPluginEnable), pluginErr) + initialize.InfoWarnNonFatal(cmd, desc.Text(text.DescKeyInitLabelPermissions), err) + } + + // Auto-enable plugin globally unless suppressed + if !noPluginEnable { + if pluginErr := plugin.EnablePluginGlobally(cmd); pluginErr != nil { + // Non-fatal: warn but continue + initialize.InfoWarnNonFatal(cmd, desc.Text(text.DescKeyInitLabelPluginEnable), pluginErr) + } } } @@ -189,10 +196,12 @@ func Run( initialize.InfoWarnNonFatal(cmd, claude.Md, err) } - // Deploy Makefile.ctx and amend user Makefile - if err := coreProject.HandleMakefileCtx(cmd); err != nil { - // Non-fatal: warn but continue - initialize.InfoWarnNonFatal(cmd, sync.PatternMakefile, err) + // Deploy Makefile.ctx and amend user Makefile (Claude Code only) + if caller == "" { + if err := coreProject.HandleMakefileCtx(cmd); err != nil { + // Non-fatal: warn but continue + initialize.InfoWarnNonFatal(cmd, sync.PatternMakefile, err) + } } // Update .gitignore with recommended entries 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 fcd77f68..385009f8 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 f83ba415e37c667266be8f4a4a1e4fa0011c06ae Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Tue, 17 Mar 2026 19:49:52 +0300 Subject: [PATCH 04/12] 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 774581f9..798fd910 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( desc.Text( diff --git a/internal/mcp/server/route/tool/dispatch.go b/internal/mcp/server/route/tool/dispatch.go index 46bd0f77..8c6ccc7f 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 11a4d332..2015376b 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 6dbcf666fb5e17355d1242268be92ca9bed32bae Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Wed, 18 Mar 2026 01:33:09 +0300 Subject: [PATCH 05/12] 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/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 | 5 +- internal/cli/initialize/cmd/root/run.go | 14 +- 15 files changed, 312 insertions(+), 433 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 1a8ca27a..3a326a16 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 @@ -252,3 +215,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/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 129d8f2c..99ca73f7 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": [ @@ -187,6 +189,7 @@ ] } ], + ], "configuration": { "title": "ctx", "properties": { diff --git a/internal/cli/initialize/cmd/root/run.go b/internal/cli/initialize/cmd/root/run.go index 97f14cc7..645210f7 100644 --- a/internal/cli/initialize/cmd/root/run.go +++ b/internal/cli/initialize/cmd/root/run.go @@ -78,6 +78,12 @@ func Run( // 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) @@ -161,13 +167,15 @@ func Run( } // Create PROMPT.md (uses ralph template if --ralph flag set) - if err := prompt.HandlePromptMd(cmd, force, merge, ralph); err != nil { + // When called from an editor (--caller), auto-merge to avoid stdin prompt. + autoMerge := merge || caller != "" + if err := prompt.HandlePromptMd(cmd, force, autoMerge, ralph); err != nil { // Non-fatal: warn but continue initialize.InfoWarnNonFatal(cmd, loop.PromptMd, err) } // Create IMPLEMENTATION_PLAN.md - if err := plan.HandleImplementationPlan(cmd, force, merge); err != nil { + if err := plan.HandleImplementationPlan(cmd, force, autoMerge); err != nil { // Non-fatal: warn but continue initialize.InfoWarnNonFatal(cmd, project.ImplementationPlan, err) } @@ -191,7 +199,7 @@ func Run( } // Handle CLAUDE.md creation/merge - if err := coreClaude.HandleClaudeMd(cmd, force, merge); err != nil { + if err := coreClaude.HandleClaudeMd(cmd, force, autoMerge); err != nil { // Non-fatal: warn but continue initialize.InfoWarnNonFatal(cmd, claude.Md, err) } From e4601cab795592963a7d5d6651e82bb0f35f56b1 Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Thu, 19 Mar 2026 01:01:00 +0300 Subject: [PATCH 06/12] 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 --- editors/vscode/.vscodeignore | 3 +++ editors/vscode/CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ editors/vscode/package.json | 4 ++++ 3 files changed, 38 insertions(+) create mode 100644 editors/vscode/CHANGELOG.md 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 99ca73f7..1231be5a 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" ], From bf18bb0d47558d7c2688a627e87d9b73c7f73eb0 Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Wed, 25 Mar 2026 23:09:55 +0300 Subject: [PATCH 07/12] fix: resolve build errors after rebase onto v0.8.0 - cmd.go: rename cmd.Flags() to c.Flags() (upstream variable rename) - sessionevent/cmd.go: update import core -> core/state (package split) - copilot.go: prefix Session/Message/ToolUse with entity. (types moved) DCO-1.1-Signed-off-by: ersan bilik Signed-off-by: ersan bilik --- internal/cli/initialize/cmd/root/cmd.go | 2 +- internal/cli/system/cmd/sessionevent/cmd.go | 4 ++-- internal/recall/parser/copilot.go | 21 +++++++++++---------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/internal/cli/initialize/cmd/root/cmd.go b/internal/cli/initialize/cmd/root/cmd.go index f39d1a32..9b385a81 100644 --- a/internal/cli/initialize/cmd/root/cmd.go +++ b/internal/cli/initialize/cmd/root/cmd.go @@ -77,7 +77,7 @@ func Cmd() *cobra.Command { &noPluginEnable, cFlag.NoPluginEnable, false, desc.Flag(flag.DescKeyInitializeNoPluginEnable), ) - cmd.Flags().StringVar( + c.Flags().StringVar( &caller, "caller", "", "Identify the calling tool (e.g. vscode) to tailor output", ) diff --git a/internal/cli/system/cmd/sessionevent/cmd.go b/internal/cli/system/cmd/sessionevent/cmd.go index bfabda38..1ec70f1b 100644 --- a/internal/cli/system/cmd/sessionevent/cmd.go +++ b/internal/cli/system/cmd/sessionevent/cmd.go @@ -11,7 +11,7 @@ import ( "github.com/spf13/cobra" - "github.com/ActiveMemory/ctx/internal/cli/system/core" + coreState "github.com/ActiveMemory/ctx/internal/cli/system/core/state" "github.com/ActiveMemory/ctx/internal/log" "github.com/ActiveMemory/ctx/internal/notify" ) @@ -48,7 +48,7 @@ Examples: } func runSessionEvent(cmd *cobra.Command, eventType, caller string) error { - if !core.Initialized() { + if !coreState.Initialized() { return nil } diff --git a/internal/recall/parser/copilot.go b/internal/recall/parser/copilot.go index ab3a5794..7ee88ef2 100644 --- a/internal/recall/parser/copilot.go +++ b/internal/recall/parser/copilot.go @@ -22,6 +22,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/file" "github.com/ActiveMemory/ctx/internal/config/session" "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/entity" ) // copilotKeyRequests is the key path segment for request arrays. @@ -94,7 +95,7 @@ func (p *CopilotParser) Matches(path string) bool { // // 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) { +func (p *CopilotParser) ParseFile(path string) ([]*entity.Session, error) { file, openErr := os.Open(filepath.Clean(path)) if openErr != nil { return nil, fmt.Errorf("open file: %w", openErr) @@ -157,12 +158,12 @@ func (p *CopilotParser) ParseFile(path string) ([]*Session, error) { return nil, nil } - return []*Session{result}, nil + return []*entity.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) { +func (p *CopilotParser) ParseLine(_ []byte) (*entity.Message, string, error) { return nil, "", nil } @@ -240,12 +241,12 @@ func (p *CopilotParser) parseKeyPath(keys []json.RawMessage) []string { // buildSession converts a reconstructed copilotRawSession into a Session. func (p *CopilotParser) buildSession( raw *copilotRawSession, sourcePath string, cwd string, -) *Session { +) *entity.Session { if len(raw.Requests) == 0 { return nil } - session := &Session{ + session := &entity.Session{ ID: raw.SessionID, Tool: session.ToolCopilot, SourceFile: sourcePath, @@ -260,7 +261,7 @@ func (p *CopilotParser) buildSession( for _, req := range raw.Requests { // User message - userMsg := Message{ + userMsg := entity.Message{ ID: req.RequestID, Timestamp: time.UnixMilli(req.Timestamp), Role: claude.RoleUser, @@ -319,12 +320,12 @@ func (p *CopilotParser) buildSession( // buildAssistantMessage extracts the assistant response from a request. func (p *CopilotParser) buildAssistantMessage( req copilotRawRequest, -) *Message { +) *entity.Message { if len(req.Response) == 0 { return nil } - msg := &Message{ + msg := &entity.Message{ ID: req.RequestID + "-response", Timestamp: time.UnixMilli(req.Timestamp), Role: claude.RoleAssistant, @@ -380,7 +381,7 @@ func (p *CopilotParser) buildAssistantMessage( } // parseToolInvocation extracts a ToolUse from a toolInvocationSerialized item. -func (p *CopilotParser) parseToolInvocation(item copilotRawRespItem) *ToolUse { +func (p *CopilotParser) parseToolInvocation(item copilotRawRespItem) *entity.ToolUse { toolID := item.ToolID if toolID == "" { return nil @@ -409,7 +410,7 @@ func (p *CopilotParser) parseToolInvocation(item copilotRawRespItem) *ToolUse { } } - return &ToolUse{ + return &entity.ToolUse{ ID: item.ToolCallID, Name: name, Input: inputStr, From 28c6bacd6cb47d6e0403aa9e48d06c7cc60745af Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Wed, 25 Mar 2026 23:14:28 +0300 Subject: [PATCH 08/12] docs: persist session context for PR #45 v0.8.0 rebase DCO-1.1-Signed-off-by: ersan bilik Signed-off-by: ersan bilik --- .context/LEARNINGS.md | 5 +++ .../sessions/2026-03-25-pr45-rebase-v080.md | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 .context/sessions/2026-03-25-pr45-rebase-v080.md diff --git a/.context/LEARNINGS.md b/.context/LEARNINGS.md index 5f38a490..6dd1bc0b 100644 --- a/.context/LEARNINGS.md +++ b/.context/LEARNINGS.md @@ -3,6 +3,11 @@ | Date | Learning | |------|--------| +| 2026-03-25 | v0.8.0 rebase: entity package absorbed Session/Message/ToolUse types — all parsers must use entity. prefix | +| 2026-03-25 | v0.8.0 rebase: core packages split into subpackages — core.Initialized() moved to core/state | +| 2026-03-25 | v0.8.0 rebase: cmd variable renamed to c in bootstrap/cmd.go — caller flag must use c.Flags() | +| 2026-03-25 | v0.8.0 rebase: config/embed/embeds.go deleted, constants split into cmd/, flag/, text/ subdirectories | +| 2026-03-25 | v0.8.0 rebase: 106 upstream commits in one cycle — rebase more frequently to avoid compound conflicts | | 2026-03-24 | lint-drift false positives from conflating constant namespaces | | 2026-03-24 | git describe --tags follows ancestry, not global tag list | | 2026-03-23 | Typography detection script needs exclusion lists for intentional uses | diff --git a/.context/sessions/2026-03-25-pr45-rebase-v080.md b/.context/sessions/2026-03-25-pr45-rebase-v080.md new file mode 100644 index 00000000..bab5bc57 --- /dev/null +++ b/.context/sessions/2026-03-25-pr45-rebase-v080.md @@ -0,0 +1,33 @@ +# Session: 2026-03-25 — PR #45 Rebase onto v0.8.0 + +## What Was Done +- Fetched upstream/main: jumped from 898e3cd7 to a3fdab88 (106 new commits, v0.8.0 release) +- Rebased 6 PR commits (feat/copilot-governance) onto new upstream/main +- Resolved conflicts in 14 files across 4 rebase steps: + - **Commit 1** (Windows compat): path.go — kept case-insensitive closures + errCtx alias + - **Commit 3** (VS Code extension): 6 conflicts — package.json, extension.ts, embed.go, cmd.go, run.go, claude.go + - **Commit 5** (Insiders fix): 4 conflicts — CONVENTIONS.md, package.json, extension.ts, run.go + - **Commit 6** (CI fixes): 3 conflicts — .golangci.yml, embed_test.go, embeds.go +- Fixed 3 post-rebase build errors: + - `cmd.Flags()` → `c.Flags()` (upstream variable rename in bootstrap) + - `core.Initialized()` → `coreState.Initialized()` (core split to core/state) + - `Session`/`Message`/`ToolUse` → `entity.Session`/`entity.Message`/`entity.ToolUse` (types moved to entity pkg) +- All verification passed: `go build ./...` clean, compliance tests OK, lint 0 issues +- Force-pushed to PR #45 + +## Decisions +- Accepted upstream extension.ts entirely (1711 lines) — governance features (detection ring, watchers, diag, session lifecycle) will be re-applied in a follow-up +- Accepted upstream's G101 exclusion path change to `text/text.go` (constants restructured) +- Accepted upstream's removal of TestTextDescKeysResolve from embed_test.go (moved to read/desc/) +- Deleted `internal/cli/initialize/core/claude.go` and `internal/config/embed/embeds.go` (upstream split into subpackages) + +## Learnings +- v0.8.0 was a massive refactoring: standardized import aliases (Yoda-style camelCase), entity package for cross-cutting types, config/embed split into cmd/flag/text subdirectories, core/ split into validate/prompt/plan/plugin/merge/project/claude/entry subpackages +- When upstream has 106 commits with structural changes, accept upstream for complex files (extension.ts) and re-apply features later +- The `caller` parameter and `autoMerge` pattern survived the rebase — these are the key PR additions to run.go + +## Next Steps +- Re-apply governance features to extension.ts (detection ring, watchers, diag command, session lifecycle) — reference `editors/vscode/src/extension_pr.ts` +- Re-apply marketplace fields (extensionDependencies, pricing) to package.json +- Clean up temp files: extension_pr.ts, test-insiders-sim.js, .bak files +- Monitor CI on PR #45 From 43fd02929ad9a47c365270e0913a7a7961665767 Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Thu, 26 Mar 2026 00:00:36 +0300 Subject: [PATCH 09/12] feat: Copilot CLI hook generation (Phase 1) Add cross-platform hook generation for GitHub Copilot CLI integration. ctx hook copilot-cli --write generates .github/hooks/ctx-hooks.json with dual bash/PowerShell scripts for sessionStart, preToolUse, postToolUse, and sessionEnd lifecycle events. New files: - Embedded hook templates: ctx-hooks.json + 8 scripts (.sh/.ps1) - Asset readers: CopilotCLIHooksJSON(), CopilotCLIScripts() - WriteCopilotCLIHooks() in hook command run.go - Feature matrix spec: specs/copilot-cli-integration.md Constants added: ToolCopilotCLI, CLI event names, asset paths, text description keys, write output functions. DCO-1.1-Signed-off-by: ersan bilik Signed-off-by: ersan bilik --- internal/assets/commands/text/hooks.yaml | 19 +- internal/assets/commands/text/write.yaml | 14 + internal/assets/embed.go | 1 + .../assets/hooks/copilot-cli/ctx-hooks.json | 41 +++ .../copilot-cli/scripts/ctx-postToolUse.ps1 | 18 ++ .../copilot-cli/scripts/ctx-postToolUse.sh | 17 ++ .../copilot-cli/scripts/ctx-preToolUse.ps1 | 46 ++++ .../copilot-cli/scripts/ctx-preToolUse.sh | 31 +++ .../copilot-cli/scripts/ctx-sessionEnd.ps1 | 7 + .../copilot-cli/scripts/ctx-sessionEnd.sh | 8 + .../copilot-cli/scripts/ctx-sessionStart.ps1 | 7 + .../copilot-cli/scripts/ctx-sessionStart.sh | 8 + internal/assets/read/agent/agent.go | 42 +++ internal/cli/hook/cmd/root/run.go | 61 +++++ internal/config/asset/asset.go | 36 +-- internal/config/embed/text/hook.go | 4 + internal/config/hook/hook.go | 12 + internal/write/hook/hook.go | 28 ++ specs/copilot-cli-integration.md | 240 ++++++++++++++++++ 19 files changed, 623 insertions(+), 17 deletions(-) create mode 100644 internal/assets/hooks/copilot-cli/ctx-hooks.json create mode 100644 internal/assets/hooks/copilot-cli/scripts/ctx-postToolUse.ps1 create mode 100644 internal/assets/hooks/copilot-cli/scripts/ctx-postToolUse.sh create mode 100644 internal/assets/hooks/copilot-cli/scripts/ctx-preToolUse.ps1 create mode 100644 internal/assets/hooks/copilot-cli/scripts/ctx-preToolUse.sh create mode 100644 internal/assets/hooks/copilot-cli/scripts/ctx-sessionEnd.ps1 create mode 100644 internal/assets/hooks/copilot-cli/scripts/ctx-sessionEnd.sh create mode 100644 internal/assets/hooks/copilot-cli/scripts/ctx-sessionStart.ps1 create mode 100644 internal/assets/hooks/copilot-cli/scripts/ctx-sessionStart.sh create mode 100644 specs/copilot-cli-integration.md diff --git a/internal/assets/commands/text/hooks.yaml b/internal/assets/commands/text/hooks.yaml index f086cfb6..064f7f3f 100644 --- a/internal/assets/commands/text/hooks.yaml +++ b/internal/assets/commands/text/hooks.yaml @@ -400,6 +400,22 @@ hook.copilot: or run with --write to generate the file directly: ctx hook copilot --write +hook.copilot-cli: + short: | + GitHub Copilot CLI Integration + ============================== + + Generate .github/hooks/ with ctx lifecycle hooks + for the GitHub Copilot CLI agent (cross-platform). + + This creates: + .github/hooks/ctx-hooks.json Hook configuration + .github/hooks/scripts/*.sh Bash scripts (Linux/macOS/WSL) + .github/hooks/scripts/*.ps1 PowerShell scripts (Windows) + + Run with --write to generate all files: + + ctx hook copilot-cli --write hook.cursor: short: | Cursor IDE Integration @@ -425,7 +441,8 @@ hook.supported-tools: claude-code - Anthropic's Claude Code CLI (use plugin instead) cursor - Cursor IDE aider - Aider AI coding assistant - copilot - GitHub Copilot + copilot - GitHub Copilot (VS Code extension) + copilot-cli - GitHub Copilot CLI (terminal agent) windsurf - Windsurf IDE hook.windsurf: short: | diff --git a/internal/assets/commands/text/write.yaml b/internal/assets/commands/text/write.yaml index 587a38be..a6e0d705 100644 --- a/internal/assets/commands/text/write.yaml +++ b/internal/assets/commands/text/write.yaml @@ -103,6 +103,20 @@ write.format-si-mega-upper: short: "%.1fM" write.format-thousands: short: "%d,%03d" +write.hook-copilot-cli-created: + short: ' ✓ %s' +write.hook-copilot-cli-skipped: + short: ' ○ %s (ctx hooks exist, skipped)' +write.hook-copilot-cli-summary: + short: |- + GitHub Copilot CLI will now: + 1. Record session start/end via ctx + 2. Block dangerous commands (preToolUse) + 3. Audit tool invocations to .context/state/ + + Hooks work on all platforms: + Linux/macOS/WSL → .sh scripts + Windows → .ps1 scripts write.hook-copilot-created: short: ' ✓ %s' write.hook-copilot-force-hint: diff --git a/internal/assets/embed.go b/internal/assets/embed.go index 19bba841..0ae171ed 100644 --- a/internal/assets/embed.go +++ b/internal/assets/embed.go @@ -13,6 +13,7 @@ import ( //go:embed claude/.claude-plugin/plugin.json claude/CLAUDE.md //go:embed claude/skills/*/references/*.md claude/skills/*/SKILL.md //go:embed context/*.md project/* entry-templates/*.md hooks/*.md +//go:embed hooks/copilot-cli/*.json hooks/copilot-cli/scripts/*.sh hooks/copilot-cli/scripts/*.ps1 //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 diff --git a/internal/assets/hooks/copilot-cli/ctx-hooks.json b/internal/assets/hooks/copilot-cli/ctx-hooks.json new file mode 100644 index 00000000..2aa48f44 --- /dev/null +++ b/internal/assets/hooks/copilot-cli/ctx-hooks.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": ".github/hooks/scripts/ctx-sessionStart.sh", + "powershell": ".github/hooks/scripts/ctx-sessionStart.ps1", + "cwd": ".", + "timeoutSec": 10 + } + ], + "preToolUse": [ + { + "type": "command", + "bash": ".github/hooks/scripts/ctx-preToolUse.sh", + "powershell": ".github/hooks/scripts/ctx-preToolUse.ps1", + "cwd": ".", + "timeoutSec": 5 + } + ], + "postToolUse": [ + { + "type": "command", + "bash": ".github/hooks/scripts/ctx-postToolUse.sh", + "powershell": ".github/hooks/scripts/ctx-postToolUse.ps1", + "cwd": ".", + "timeoutSec": 5 + } + ], + "sessionEnd": [ + { + "type": "command", + "bash": ".github/hooks/scripts/ctx-sessionEnd.sh", + "powershell": ".github/hooks/scripts/ctx-sessionEnd.ps1", + "cwd": ".", + "timeoutSec": 15 + } + ] + } +} diff --git a/internal/assets/hooks/copilot-cli/scripts/ctx-postToolUse.ps1 b/internal/assets/hooks/copilot-cli/scripts/ctx-postToolUse.ps1 new file mode 100644 index 00000000..1122e853 --- /dev/null +++ b/internal/assets/hooks/copilot-cli/scripts/ctx-postToolUse.ps1 @@ -0,0 +1,18 @@ +# ctx postToolUse hook for GitHub Copilot CLI +# Reads tool result JSON from stdin and appends to audit log. +$ErrorActionPreference = 'SilentlyContinue' + +if (Get-Command ctx -ErrorAction SilentlyContinue) { + $RawInput = $input | Out-String + $LogDir = Join-Path '.context' 'state' + $LogFile = Join-Path $LogDir 'copilot-cli-audit.jsonl' + + if (Test-Path '.context') { + if (-not (Test-Path $LogDir)) { + New-Item -ItemType Directory -Path $LogDir -Force | Out-Null + } + $Timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $Entry = "{`"timestamp`":`"$Timestamp`",`"event`":`"postToolUse`",`"data`":$RawInput}" + Add-Content -Path $LogFile -Value $Entry -ErrorAction SilentlyContinue + } +} diff --git a/internal/assets/hooks/copilot-cli/scripts/ctx-postToolUse.sh b/internal/assets/hooks/copilot-cli/scripts/ctx-postToolUse.sh new file mode 100644 index 00000000..b8fbdb9e --- /dev/null +++ b/internal/assets/hooks/copilot-cli/scripts/ctx-postToolUse.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# ctx postToolUse hook for GitHub Copilot CLI +# Reads tool result JSON from stdin and appends to audit log. +set -euo pipefail + +# Append tool invocation to audit log if ctx is available. +if command -v ctx >/dev/null 2>&1; then + INPUT=$(cat) + LOGDIR=".context/state" + LOGFILE="$LOGDIR/copilot-cli-audit.jsonl" + + if [ -d ".context" ]; then + mkdir -p "$LOGDIR" + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date +"%Y-%m-%dT%H:%M:%S") + echo "{\"timestamp\":\"$TIMESTAMP\",\"event\":\"postToolUse\",\"data\":$INPUT}" >> "$LOGFILE" 2>/dev/null || true + fi +fi diff --git a/internal/assets/hooks/copilot-cli/scripts/ctx-preToolUse.ps1 b/internal/assets/hooks/copilot-cli/scripts/ctx-preToolUse.ps1 new file mode 100644 index 00000000..a3c7f067 --- /dev/null +++ b/internal/assets/hooks/copilot-cli/scripts/ctx-preToolUse.ps1 @@ -0,0 +1,46 @@ +# ctx preToolUse hook for GitHub Copilot CLI +# Reads tool invocation JSON from stdin and blocks dangerous commands. +$ErrorActionPreference = 'SilentlyContinue' + +$RawInput = $input | Out-String +if (-not $RawInput) { exit 0 } + +try { + $Data = $RawInput | ConvertFrom-Json +} catch { + exit 0 +} + +$ToolName = if ($Data.tool_name) { $Data.tool_name } elseif ($Data.tool) { $Data.tool } else { '' } + +# Block dangerous shell commands matching known patterns. +if ($ToolName -eq 'shell' -or $ToolName -eq 'powershell') { + $Command = '' + if ($Data.input -and $Data.input.command) { + $Command = $Data.input.command + } + + $DangerousPatterns = @( + 'sudo ', + 'rm -rf /', + 'rm -rf ~', + 'Remove-Item -Recurse -Force C:\', + 'Remove-Item -Recurse -Force $env:USERPROFILE', + 'chmod 777', + 'Format-Volume' + ) + foreach ($Pattern in $DangerousPatterns) { + if ($Command -like "*$Pattern*") { + Write-Error 'ctx: blocked dangerous command' + exit 1 + } + } + + $IrreversiblePatterns = @('git push', 'git reset --hard') + foreach ($Pattern in $IrreversiblePatterns) { + if ($Command -like "*$Pattern*") { + Write-Error 'ctx: blocked irreversible git operation — review first' + exit 1 + } + } +} diff --git a/internal/assets/hooks/copilot-cli/scripts/ctx-preToolUse.sh b/internal/assets/hooks/copilot-cli/scripts/ctx-preToolUse.sh new file mode 100644 index 00000000..34507b11 --- /dev/null +++ b/internal/assets/hooks/copilot-cli/scripts/ctx-preToolUse.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# ctx preToolUse hook for GitHub Copilot CLI +# Reads tool invocation JSON from stdin and blocks dangerous commands. +set -euo pipefail + +INPUT=$(cat) + +# Extract the tool name from the JSON input. +TOOL="" +if command -v jq >/dev/null 2>&1; then + TOOL=$(echo "$INPUT" | jq -r '.tool_name // .tool // empty' 2>/dev/null) +fi + +# Block dangerous shell commands matching known patterns. +if [ "$TOOL" = "shell" ] || [ "$TOOL" = "bash" ]; then + COMMAND="" + if command -v jq >/dev/null 2>&1; then + COMMAND=$(echo "$INPUT" | jq -r '.input.command // empty' 2>/dev/null) + fi + + case "$COMMAND" in + *"sudo "* | *"rm -rf /"* | *"rm -rf ~"* | *"chmod 777"*) + echo '{"decision":"deny","reason":"ctx: blocked dangerous command"}' >&2 + exit 1 + ;; + *"git push"* | *"git reset --hard"*) + echo '{"decision":"deny","reason":"ctx: blocked irreversible git operation — review first"}' >&2 + exit 1 + ;; + esac +fi diff --git a/internal/assets/hooks/copilot-cli/scripts/ctx-sessionEnd.ps1 b/internal/assets/hooks/copilot-cli/scripts/ctx-sessionEnd.ps1 new file mode 100644 index 00000000..fdac60d8 --- /dev/null +++ b/internal/assets/hooks/copilot-cli/scripts/ctx-sessionEnd.ps1 @@ -0,0 +1,7 @@ +# ctx sessionEnd hook for GitHub Copilot CLI +# Records session end event for recall and context persistence. +$ErrorActionPreference = 'SilentlyContinue' + +if (Get-Command ctx -ErrorAction SilentlyContinue) { + ctx system session-event --type end --caller copilot-cli 2>$null +} diff --git a/internal/assets/hooks/copilot-cli/scripts/ctx-sessionEnd.sh b/internal/assets/hooks/copilot-cli/scripts/ctx-sessionEnd.sh new file mode 100644 index 00000000..1c518ae4 --- /dev/null +++ b/internal/assets/hooks/copilot-cli/scripts/ctx-sessionEnd.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# ctx sessionEnd hook for GitHub Copilot CLI +# Records session end event for recall and context persistence. +set -euo pipefail + +if command -v ctx >/dev/null 2>&1; then + ctx system session-event --type end --caller copilot-cli 2>/dev/null || true +fi diff --git a/internal/assets/hooks/copilot-cli/scripts/ctx-sessionStart.ps1 b/internal/assets/hooks/copilot-cli/scripts/ctx-sessionStart.ps1 new file mode 100644 index 00000000..83ffb8d8 --- /dev/null +++ b/internal/assets/hooks/copilot-cli/scripts/ctx-sessionStart.ps1 @@ -0,0 +1,7 @@ +# ctx sessionStart hook for GitHub Copilot CLI +# Records session start and loads context status. +$ErrorActionPreference = 'SilentlyContinue' + +if (Get-Command ctx -ErrorAction SilentlyContinue) { + ctx system session-event --type start --caller copilot-cli 2>$null +} diff --git a/internal/assets/hooks/copilot-cli/scripts/ctx-sessionStart.sh b/internal/assets/hooks/copilot-cli/scripts/ctx-sessionStart.sh new file mode 100644 index 00000000..159b3079 --- /dev/null +++ b/internal/assets/hooks/copilot-cli/scripts/ctx-sessionStart.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# ctx sessionStart hook for GitHub Copilot CLI +# Records session start and loads context status. +set -euo pipefail + +if command -v ctx >/dev/null 2>&1; then + ctx system session-event --type start --caller copilot-cli 2>/dev/null || true +fi diff --git a/internal/assets/read/agent/agent.go b/internal/assets/read/agent/agent.go index 14348bdd..2d1168f9 100644 --- a/internal/assets/read/agent/agent.go +++ b/internal/assets/read/agent/agent.go @@ -9,6 +9,9 @@ package agent import ( + "io/fs" + "strings" + "github.com/ActiveMemory/ctx/internal/assets" "github.com/ActiveMemory/ctx/internal/config/asset" ) @@ -21,3 +24,42 @@ import ( func CopilotInstructions() ([]byte, error) { return assets.FS.ReadFile(asset.PathCopilotInstructions) } + +// CopilotCLIHooksJSON reads the embedded Copilot CLI hooks config. +// +// Returns: +// - []byte: JSON content from hooks/copilot-cli/ctx-hooks.json +// - error: Non-nil if the file is not found or read fails +func CopilotCLIHooksJSON() ([]byte, error) { + return assets.FS.ReadFile(asset.PathCopilotCLIHooksJSON) +} + +// CopilotCLIScripts reads all embedded Copilot CLI hook scripts. +// Returns a map of filename to content for scripts in +// hooks/copilot-cli/scripts/. +// +// Returns: +// - map[string][]byte: Filename -> content for each script +// - error: Non-nil if the directory read fails +func CopilotCLIScripts() (map[string][]byte, error) { + scripts := make(map[string][]byte) + entries, dirErr := fs.ReadDir(assets.FS, asset.DirHooksCopilotCLIScrp) + if dirErr != nil { + return nil, dirErr + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, ".sh") && !strings.HasSuffix(name, ".ps1") { + continue + } + content, readErr := assets.FS.ReadFile(asset.DirHooksCopilotCLIScrp + "/" + name) + if readErr != nil { + return nil, readErr + } + scripts[name] = content + } + return scripts, nil +} diff --git a/internal/cli/hook/cmd/root/run.go b/internal/cli/hook/cmd/root/run.go index 28162abf..fe6491de 100644 --- a/internal/cli/hook/cmd/root/run.go +++ b/internal/cli/hook/cmd/root/run.go @@ -66,6 +66,12 @@ func Run(cmd *cobra.Command, args []string, writeFile bool) error { } hook.Content(cmd, string(content)) + case cfgHook.ToolCopilotCLI: + if writeFile { + return WriteCopilotCLIHooks(cmd) + } + hook.InfoTool(cmd, desc.Text(text.DescKeyHookCopilotCLI)) + case cfgHook.ToolWindsurf: hook.InfoTool(cmd, desc.Text(text.DescKeyHookWindsurf)) @@ -149,6 +155,61 @@ func WriteCopilotInstructions(cmd *cobra.Command) error { return nil } +// WriteCopilotCLIHooks generates .github/hooks/ctx-hooks.json and the +// accompanying hook scripts for GitHub Copilot CLI integration. +// +// Creates the .github/hooks/ and .github/hooks/scripts/ directories if +// needed and writes the JSON config plus bash and PowerShell scripts +// from embedded assets. Skips if ctx-hooks.json already exists. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if directory creation or file write fails +func WriteCopilotCLIHooks(cmd *cobra.Command) error { + hooksDir := filepath.Join(cfgHook.DirGitHub, cfgHook.DirGitHubHooks) + scriptsDir := filepath.Join(hooksDir, cfgHook.DirGitHubHooksScripts) + targetJSON := filepath.Join(hooksDir, cfgHook.FileCopilotCLIHooksJSON) + + // Check if ctx-hooks.json already exists + if _, err := os.Stat(targetJSON); err == nil { + hook.InfoCopilotCLISkipped(cmd, targetJSON) + return nil + } + + // Create directories + if err := os.MkdirAll(scriptsDir, fs.PermExec); err != nil { + return errFs.Mkdir(scriptsDir, err) + } + + // Write ctx-hooks.json + jsonContent, readErr := agent.CopilotCLIHooksJSON() + if readErr != nil { + return readErr + } + if wErr := os.WriteFile(targetJSON, jsonContent, fs.PermFile); wErr != nil { + return errFs.FileWrite(targetJSON, wErr) + } + hook.InfoCopilotCLICreated(cmd, targetJSON) + + // Write all hook scripts + scripts, scrErr := agent.CopilotCLIScripts() + if scrErr != nil { + return scrErr + } + for name, content := range scripts { + target := filepath.Join(scriptsDir, name) + if wErr := os.WriteFile(target, content, fs.PermExec); wErr != nil { + return errFs.FileWrite(target, wErr) + } + hook.InfoCopilotCLICreated(cmd, target) + } + + hook.InfoCopilotCLISummary(cmd) + 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 { diff --git a/internal/config/asset/asset.go b/internal/config/asset/asset.go index 2b0593e1..b990c64f 100644 --- a/internal/config/asset/asset.go +++ b/internal/config/asset/asset.go @@ -10,22 +10,24 @@ import "path" // Embedded asset directory names. const ( - DirClaude = "claude" - DirClaudePlugin = "claude/.claude-plugin" - DirClaudeSkills = "claude/skills" - DirCommands = "commands" - DirCommandsText = "commands/text" - DirContext = "context" - DirEntryTemplates = "entry-templates" - DirHooks = "hooks" - DirHooksMessages = "hooks/messages" - DirJournal = "journal" - DirPermissions = "permissions" - DirProject = "project" - DirPromptTemplates = "prompt-templates" - DirRalph = "ralph" - DirSchema = "schema" - DirWhy = "why" + DirClaude = "claude" + DirClaudePlugin = "claude/.claude-plugin" + DirClaudeSkills = "claude/skills" + DirCommands = "commands" + DirCommandsText = "commands/text" + DirContext = "context" + DirEntryTemplates = "entry-templates" + DirHooks = "hooks" + DirHooksCopilotCLI = "hooks/copilot-cli" + DirHooksCopilotCLIScrp = "hooks/copilot-cli/scripts" + DirHooksMessages = "hooks/messages" + DirJournal = "journal" + DirPermissions = "permissions" + DirProject = "project" + DirPromptTemplates = "prompt-templates" + DirRalph = "ralph" + DirSchema = "schema" + DirWhy = "why" ) // JSON field keys used when parsing embedded asset files. @@ -45,6 +47,7 @@ const ( FileAllowTxt = "allow.txt" FileCLAUDEMd = "CLAUDE.md" FileCommandsYAML = "commands.yaml" + FileCopilotCLIHooksJSON = "ctx-hooks.json" FileCopilotInstructionsMd = "copilot-instructions.md" FileCtxrcSchemaJSON = "ctxrc.schema.json" FileDenyTxt = "deny.txt" @@ -69,6 +72,7 @@ var ( PathCommandsYAML = path.Join(DirCommands, FileCommandsYAML) PathFlagsYAML = path.Join(DirCommands, FileFlagsYAML) PathExamplesYAML = path.Join(DirCommands, FileExamplesYAML) + PathCopilotCLIHooksJSON = path.Join(DirHooksCopilotCLI, FileCopilotCLIHooksJSON) PathCopilotInstructions = path.Join(DirHooks, FileCopilotInstructionsMd) PathHookRegistry = path.Join(DirHooksMessages, FileRegistryYAML) PathExtraCSS = path.Join(DirJournal, FileExtraCSS) diff --git a/internal/config/embed/text/hook.go b/internal/config/embed/text/hook.go index f23c4c02..226786cf 100644 --- a/internal/config/embed/text/hook.go +++ b/internal/config/embed/text/hook.go @@ -10,12 +10,16 @@ const ( DescKeyHookAider = "hook.aider" DescKeyHookClaude = "hook.claude" DescKeyHookCopilot = "hook.copilot" + DescKeyHookCopilotCLI = "hook.copilot-cli" DescKeyHookCursor = "hook.cursor" DescKeyHookSupportedTools = "hook.supported-tools" DescKeyHookWindsurf = "hook.windsurf" ) const ( + DescKeyWriteHookCopilotCLICreated = "write.hook-copilot-cli-created" + DescKeyWriteHookCopilotCLISkipped = "write.hook-copilot-cli-skipped" + DescKeyWriteHookCopilotCLISummary = "write.hook-copilot-cli-summary" DescKeyWriteHookCopilotCreated = "write.hook-copilot-created" DescKeyWriteHookCopilotForceHint = "write.hook-copilot-force-hint" DescKeyWriteHookCopilotMerged = "write.hook-copilot-merged" diff --git a/internal/config/hook/hook.go b/internal/config/hook/hook.go index 997b2307..5bbeba15 100644 --- a/internal/config/hook/hook.go +++ b/internal/config/hook/hook.go @@ -57,6 +57,7 @@ const ( ToolClaude = "claude" ToolClaudeCode = "claude-code" ToolCopilot = "copilot" + ToolCopilotCLI = "copilot-cli" ToolCursor = "cursor" ToolWindsurf = "windsurf" ) @@ -64,7 +65,10 @@ const ( // Copilot integration paths. const ( DirGitHub = ".github" + DirGitHubHooks = "hooks" + DirGitHubHooksScripts = "scripts" FileCopilotInstructions = "copilot-instructions.md" + FileCopilotCLIHooksJSON = "ctx-hooks.json" ) // Prefixes @@ -89,3 +93,11 @@ const ( // EventPostToolUse is the hook event for post-tool-use hooks. EventPostToolUse = "PostToolUse" ) + +// Copilot CLI hook event names (GitHub Copilot CLI lifecycle stages). +const ( + CLIEventSessionStart = "sessionStart" + CLIEventSessionEnd = "sessionEnd" + CLIEventPreToolUse = "preToolUse" + CLIEventPostToolUse = "postToolUse" +) diff --git a/internal/write/hook/hook.go b/internal/write/hook/hook.go index 1b09bf75..fbfad3b3 100644 --- a/internal/write/hook/hook.go +++ b/internal/write/hook/hook.go @@ -128,6 +128,34 @@ func InfoCopilotSummary(cmd *cobra.Command) { cmd.Println(desc.Text(text.DescKeyWriteHookCopilotSummary)) } +// InfoCopilotCLICreated reports that copilot-cli hook files were created. +// +// Parameters: +// - cmd: Cobra command for output +// - targetFile: Path to the created file +func InfoCopilotCLICreated(cmd *cobra.Command, targetFile string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteHookCopilotCLICreated), targetFile)) +} + +// InfoCopilotCLISkipped reports that copilot-cli hooks were skipped +// because they already exist. +// +// Parameters: +// - cmd: Cobra command for output +// - targetFile: Path to the existing file +func InfoCopilotCLISkipped(cmd *cobra.Command, targetFile string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteHookCopilotCLISkipped), targetFile)) +} + +// InfoCopilotCLISummary prints the post-write summary for copilot-cli. +// +// Parameters: +// - cmd: Cobra command for output +func InfoCopilotCLISummary(cmd *cobra.Command) { + cmd.Println() + cmd.Println(desc.Text(text.DescKeyWriteHookCopilotCLISummary)) +} + // InfoUnknownTool prints the unknown tool message. // // Parameters: diff --git a/specs/copilot-cli-integration.md b/specs/copilot-cli-integration.md new file mode 100644 index 00000000..9586ce1e --- /dev/null +++ b/specs/copilot-cli-integration.md @@ -0,0 +1,240 @@ +# Spec: Copilot CLI Integration — Feature Matrix + +## Feature Matrix: Claude Code vs VS Code Extension vs GitHub Copilot CLI + +### Legend + +- **✅** — Implemented and shipping +- **🔧** — Partially implemented / needs work +- **📋** — Planned / specced +- **—** — Not applicable to this surface + +--- + +### 1. Context Injection (How the agent learns about ctx) + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| Project instructions file | ✅ `CLAUDE.md` | ✅ `.github/copilot-instructions.md` | 📋 `.github/copilot-instructions.md` + `AGENTS.md` | +| Auto-generated on `ctx init` | ✅ Merged into project root | ✅ Via `@ctx /init` (also runs `hook copilot --write`) | 📋 `ctx init` should also generate `AGENTS.md` | +| Marker-based idempotency | ✅ `` / `` | ✅ `` / `` | 📋 Same copilot markers | +| Path-specific instructions | — | — | 📋 `.github/instructions/*.instructions.md` | +| Custom agents | — | — | 📋 `.github/agents/ctx.md` | +| Home-dir instructions | — | — | 📋 `~/.copilot/copilot-instructions.md` | +| Reads `CLAUDE.md` natively | ✅ Core feature | — | ✅ Built-in (Copilot CLI reads CLAUDE.md) | +| Reads `AGENTS.md` natively | — | — | ✅ Built-in (primary instructions) | + +--- + +### 2. Hook System (Pre/post tool execution, session lifecycle) + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| Config location | `.claude/settings.local.json` | — (extension handles internally) | 📋 `.github/hooks/ctx-hooks.json` | +| PreToolUse / preToolUse | ✅ Regex matcher + command | — | 📋 `bash` + `powershell` fields | +| PostToolUse / postToolUse | ✅ Command hook | — | 📋 `bash` + `powershell` fields | +| UserPromptSubmit / userPromptSubmitted | ✅ Command hook | — | 📋 `bash` + `powershell` fields | +| SessionEnd / sessionEnd | ✅ Command hook | — | 📋 `bash` + `powershell` fields | +| SessionStart / sessionStart | — | — | 📋 `bash` + `powershell` fields | +| agentStop | — | — | 📋 Available in Copilot CLI | +| subagentStop | — | — | 📋 Available in Copilot CLI | +| errorOccurred | — | — | 📋 Available in Copilot CLI | +| Hook script format | Bash only | N/A | 📋 Dual: bash + PowerShell | +| Block dangerous commands | ✅ `block-hack-scripts.sh` | ✅ Detection ring (deny patterns) | 📋 `ctx-block-commands.sh` + `.ps1` | +| Platform support | Linux/macOS (bash) | All (TypeScript) | 📋 All (bash + powershell) | +| Timeout control | — (Claude manages) | — | 📋 `timeoutSec` per hook | +| Working directory | Implicit (project root) | Implicit | 📋 `cwd` field per hook | +| Environment variables | — | — | 📋 `env` field per hook | + +--- + +### 3. MCP Server (Model Context Protocol) + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| MCP server registration | ✅ Plugin system (`ctx@activememory-ctx`) | ✅ `.vscode/mcp.json` auto-generated | 📋 `~/.copilot/mcp-config.json` | +| Transport | Plugin (in-process) | stdio (`ctx mcp serve`) | 📋 stdio (`ctx mcp serve`) | +| `ctx_status` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_add` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_complete` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_drift` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_recall` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_watch_update` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_compact` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_next` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_check_task_completion` | ✅ | ✅ | 📋 (same server) | +| `ctx_session_event` tool | ✅ | ✅ | 📋 (same server) | +| `ctx_remind` tool | ✅ | ✅ | 📋 (same server) | +| 8 context resources | ✅ | ✅ | 📋 (same server) | +| Resource change notifications | ✅ Poller-based | ✅ Poller-based | 📋 (same server) | +| Prompt templates | ✅ | ✅ | 📋 (same server) | +| Session governance tracking | ✅ | ✅ | 📋 (same server) | + +--- + +### 4. Session Recall (Parsing AI session history) + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| Session parser | ✅ ClaudeCodeParser (JSONL) | ✅ CopilotParser (JSONL) | 📋 CopilotCLIParser (TBD format) | +| Auto-detect session dir | ✅ `~/.claude/projects/` | ✅ Platform-specific `workspaceStorage/` | 📋 `~/.copilot/sessions/` (TBD) | +| Windows path handling | ✅ Drive letter fix | ✅ APPDATA detection | 📋 USERPROFILE / COPILOT_HOME | +| macOS path handling | ✅ ~/Library/... | ✅ ~/Library/Application Support/Code/... | 📋 ~/.copilot/ | +| Linux path handling | ✅ ~/.claude/ | ✅ ~/.config/Code/... | 📋 ~/.copilot/ | +| WSL path handling | — | — | 📋 Must handle WSL ↔ Windows boundary | +| Markdown session export | ✅ | ✅ | 📋 | + +--- + +### 5. Governance & Safety + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| Permission allow-list | ✅ `permissions.allow[]` | — | 📋 `--allow-tool` / `--deny-tool` flags | +| Permission deny-list | ✅ `permissions.deny[]` | — | 📋 `--deny-tool` flags | +| Dangerous command blocking | ✅ PreToolUse hook | ✅ Detection ring (regex) | 📋 preToolUse hook script | +| Sensitive file detection | — | ✅ SENSITIVE_FILE_PATTERNS | 📋 preToolUse hook script | +| Violation recording | — | ✅ `.context/state/violations.json` | 📋 Hook script writes violations | +| Hack script interception | ✅ `block-hack-scripts.sh` | ✅ DENY_COMMAND_SCRIPT_PATTERNS | 📋 preToolUse hook script | +| Tool approval model | Per-session allow | N/A (Copilot manages) | Per-session or `--allow-tool` | +| Trusted directories | Implicit (project root) | Implicit (workspace) | ✅ Built-in trust prompt | + +--- + +### 6. Binary Management + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| Auto-install ctx binary | ✅ Plugin installation | ✅ GitHub releases download | 📋 ctx already on PATH or manual | +| Platform detection | — (Go binary) | ✅ darwin/windows/linux + amd64/arm64 | 📋 Same Go binary | +| Binary verification | — | ✅ Executes `--version` check | — | +| Update mechanism | Plugin update | ✅ GitHub releases (latest) | — (user manages) | + +--- + +### 7. UI & User Experience + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| Chat participant | — (terminal-based) | ✅ `@ctx` with 34 slash commands | — (terminal-based) | +| Status bar | — | 🔧 Reminder status bar (PR pending) | — | +| Diagnostics command | — | ✅ `/diag` with timing | — | +| Progress indicators | — | ✅ `stream.progress()` | — | +| Markdown rendering | Terminal output | ✅ VS Code markdown | Terminal output | +| Interactive mode | ✅ Terminal REPL | ✅ Chat panel | ✅ Terminal REPL | +| Programmatic mode | — | — | ✅ `copilot -p "prompt"` | +| Plan mode | — | — | ✅ Shift+Tab | +| Custom agents | — | — | ✅ `/agent` + `--agent=` flag | +| Skills | ✅ `.claude/skills/` | — | ✅ `.github/skills/` | +| Autopilot mode | — | — | ✅ `--experimental` | + +--- + +### 8. Cross-Platform Support + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| Windows (native) | ✅ | ✅ | ✅ (PowerShell v6+) | +| macOS | ✅ | ✅ | ✅ | +| Linux | ✅ | ✅ | ✅ | +| WSL | — | ✅ (Remote WSL) | ✅ (bash) | +| Hook script: bash | ✅ | N/A | 📋 Required | +| Hook script: PowerShell | — | N/A | 📋 Required | +| Path separator handling | ✅ filepath.Join | ✅ path.join + filepath | 📋 filepath.Join (Go binary) | +| Case-insensitive paths | ✅ (validation pkg) | ✅ (VS Code handles) | 📋 Inherit from ctx binary | +| Home dir detection | `~/.claude/` | Extension globalStorage | 📋 `~/.copilot/` or `$COPILOT_HOME` | + +--- + +### 9. Context System (Shared across all surfaces) + +| Feature | Claude Code | VS Code Extension | Copilot CLI | +|---------|-------------|-------------------|-------------| +| `ctx init` | ✅ CLI | ✅ `@ctx /init` | 📋 CLI (same binary) | +| `ctx status` | ✅ CLI | ✅ `@ctx /status` | 📋 CLI (same binary) | +| `ctx agent` | ✅ CLI | ✅ `@ctx /agent` | 📋 CLI (same binary) | +| `ctx drift` | ✅ CLI | ✅ `@ctx /drift` | 📋 CLI (same binary) | +| `ctx recall` | ✅ CLI | ✅ `@ctx /recall` | 📋 CLI (same binary) | +| `ctx add` | ✅ CLI | ✅ `@ctx /add` | 📋 CLI (same binary) | +| `ctx compact` | ✅ CLI | ✅ `@ctx /compact` | 📋 CLI (same binary) | +| `ctx hook ` | ✅ `ctx hook claude` | ✅ `ctx hook copilot` | 📋 `ctx hook copilot-cli` | +| Session persistence | ✅ `.context/sessions/` | ✅ `.context/sessions/` | 📋 `.context/sessions/` | + +--- + +## Implementation Plan: Copilot CLI Integration + +### Phase 1 — Hook Generation (cross-platform) + +**Goal:** `ctx hook copilot-cli --write` generates: + +1. `.github/hooks/ctx-hooks.json` — Hook configuration with dual bash/powershell +2. `.github/hooks/scripts/ctx-preToolUse.sh` — Bash pre-tool gate +3. `.github/hooks/scripts/ctx-preToolUse.ps1` — PowerShell pre-tool gate +4. `.github/hooks/scripts/ctx-sessionStart.sh` — Bash session init +5. `.github/hooks/scripts/ctx-sessionStart.ps1` — PowerShell session init +6. `.github/hooks/scripts/ctx-postToolUse.sh` — Bash post-tool audit +7. `.github/hooks/scripts/ctx-postToolUse.ps1` — PowerShell post-tool audit +8. `.github/hooks/scripts/ctx-sessionEnd.sh` — Bash session teardown +9. `.github/hooks/scripts/ctx-sessionEnd.ps1` — PowerShell session teardown + +**Hook JSON structure:** +```json +{ + "version": 1, + "hooks": { + "sessionStart": [{ + "type": "command", + "bash": ".github/hooks/scripts/ctx-sessionStart.sh", + "powershell": ".github/hooks/scripts/ctx-sessionStart.ps1", + "cwd": ".", + "timeoutSec": 10 + }], + "preToolUse": [{ + "type": "command", + "bash": ".github/hooks/scripts/ctx-preToolUse.sh", + "powershell": ".github/hooks/scripts/ctx-preToolUse.ps1", + "cwd": ".", + "timeoutSec": 5 + }], + "postToolUse": [{ + "type": "command", + "bash": ".github/hooks/scripts/ctx-postToolUse.sh", + "powershell": ".github/hooks/scripts/ctx-postToolUse.ps1", + "cwd": ".", + "timeoutSec": 5 + }], + "sessionEnd": [{ + "type": "command", + "bash": ".github/hooks/scripts/ctx-sessionEnd.sh", + "powershell": ".github/hooks/scripts/ctx-sessionEnd.ps1", + "cwd": ".", + "timeoutSec": 15 + }] + } +} +``` + +**Script behavior:** All scripts are thin shims that call the ctx binary: +- `ctx-sessionStart` → `ctx system session-event --type start --caller copilot-cli` +- `ctx-preToolUse` → reads JSON stdin, calls `ctx` for dangerous command check +- `ctx-postToolUse` → reads JSON stdin, appends to audit log +- `ctx-sessionEnd` → `ctx system session-event --type end --caller copilot-cli` + +### Phase 2 — Agent & Instructions + +1. `AGENTS.md` generation in project root (read by Copilot CLI as primary instructions) +2. `.github/agents/ctx.md` — custom agent for context management delegation +3. `.github/instructions/context.instructions.md` — path-specific instructions for `.context/` files + +### Phase 3 — MCP & Recall + +1. Register ctx MCP server in `~/.copilot/mcp-config.json` (respects `$COPILOT_HOME`) +2. Copilot CLI session parser for `ctx recall` +3. Cross-session continuity (Copilot CLI `--resume` ↔ ctx session files) + +### Phase 4 — Deep Integration + +1. ACP (Agent Client Protocol) server mode — Copilot CLI can use ctx as an ACP agent +2. Copilot Memory ↔ ctx memory bridge bidirectional sync +3. Skills in `.github/skills/` that wrap ctx operations From f8857d05243e2aff0e75233936558d5b4c4d634d Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Thu, 26 Mar 2026 00:08:46 +0300 Subject: [PATCH 10/12] feat: Phase 2 agent instructions and Copilot CLI extras Add AGENTS.md generation (ctx hook agents --write) with universal agent instructions readable by all AI coding tools. Add Copilot CLI extras: .github/agents/ctx.md custom agent and .github/instructions/context.instructions.md path-specific guide. WriteCopilotCLIHooks now also generates agents/ctx.md and instructions/context.instructions.md alongside the hook scripts. WriteAgentsMd supports marker-based merge with existing AGENTS.md. New templates: agents.md, agents-ctx.md, instructions-context.md New constants: ToolAgents, AgentsMarker*, text keys, write functions DCO-1.1-Signed-off-by: ersan bilik Signed-off-by: ersan bilik --- internal/assets/commands/text/hooks.yaml | 15 ++ internal/assets/commands/text/write.yaml | 11 ++ internal/assets/embed.go | 2 +- internal/assets/hooks/agents.md | 124 +++++++++++++++++ .../assets/hooks/copilot-cli/agents-ctx.md | 34 +++++ .../hooks/copilot-cli/instructions-context.md | 27 ++++ internal/assets/read/agent/agent.go | 27 ++++ internal/cli/hook/cmd/root/run.go | 129 +++++++++++++++++- internal/config/asset/asset.go | 6 + internal/config/embed/text/hook.go | 5 + internal/config/hook/hook.go | 6 + internal/config/marker/marker.go | 8 ++ internal/write/hook/hook.go | 37 +++++ 13 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 internal/assets/hooks/agents.md create mode 100644 internal/assets/hooks/copilot-cli/agents-ctx.md create mode 100644 internal/assets/hooks/copilot-cli/instructions-context.md diff --git a/internal/assets/commands/text/hooks.yaml b/internal/assets/commands/text/hooks.yaml index 064f7f3f..3b09a9de 100644 --- a/internal/assets/commands/text/hooks.yaml +++ b/internal/assets/commands/text/hooks.yaml @@ -357,6 +357,20 @@ heartbeat.notify-plain: short: 'heartbeat: prompt #%d (context_modified=%t)' heartbeat.notify-tokens: short: 'heartbeat: prompt #%d (context_modified=%t tokens=%s pct=%d%%)' +hook.agents: + short: | + AGENTS.md (Universal Agent Instructions) + ========================================= + + Generate AGENTS.md in the project root with universal + agent instructions that work across all AI coding tools. + + AGENTS.md is read natively by: Codex, Gemini CLI, OpenCode, + Claude Code, and GitHub Copilot CLI. + + Run with --write to generate: + + ctx hook agents --write hook.aider: short: | Aider Integration @@ -438,6 +452,7 @@ hook.cursor: hook.supported-tools: short: | Supported tools: + agents - AGENTS.md (universal agent instructions) claude-code - Anthropic's Claude Code CLI (use plugin instead) cursor - Cursor IDE aider - Aider AI coding assistant diff --git a/internal/assets/commands/text/write.yaml b/internal/assets/commands/text/write.yaml index a6e0d705..c07bc1c4 100644 --- a/internal/assets/commands/text/write.yaml +++ b/internal/assets/commands/text/write.yaml @@ -103,6 +103,17 @@ write.format-si-mega-upper: short: "%.1fM" write.format-thousands: short: "%d,%03d" +write.hook-agents-created: + short: ' ✓ %s' +write.hook-agents-merged: + short: ' ✓ %s (merged ctx section)' +write.hook-agents-skipped: + short: ' ○ %s (ctx markers exist, skipped)' +write.hook-agents-summary: + short: |- + AGENTS.md is now available for all AI coding tools. + Tools that read AGENTS.md natively: Codex, Gemini CLI, OpenCode, + Claude Code, GitHub Copilot CLI. write.hook-copilot-cli-created: short: ' ✓ %s' write.hook-copilot-cli-skipped: diff --git a/internal/assets/embed.go b/internal/assets/embed.go index 0ae171ed..eec0e1cd 100644 --- a/internal/assets/embed.go +++ b/internal/assets/embed.go @@ -13,7 +13,7 @@ import ( //go:embed claude/.claude-plugin/plugin.json claude/CLAUDE.md //go:embed claude/skills/*/references/*.md claude/skills/*/SKILL.md //go:embed context/*.md project/* entry-templates/*.md hooks/*.md -//go:embed hooks/copilot-cli/*.json hooks/copilot-cli/scripts/*.sh hooks/copilot-cli/scripts/*.ps1 +//go:embed hooks/copilot-cli/*.json hooks/copilot-cli/*.md hooks/copilot-cli/scripts/*.sh hooks/copilot-cli/scripts/*.ps1 //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 diff --git a/internal/assets/hooks/agents.md b/internal/assets/hooks/agents.md new file mode 100644 index 00000000..69013e9d --- /dev/null +++ b/internal/assets/hooks/agents.md @@ -0,0 +1,124 @@ +# Project Context + + + + +## You Have Persistent Memory + +This project uses Context (`ctx`) for context persistence across +sessions. Your memory is NOT ephemeral: it lives in `.context/`. + +## On Session Start + +Read these files **in order** before starting any work: + +1. `.context/CONSTITUTION.md`: Hard rules, NEVER violate +2. `.context/TASKS.md`: Current work items +3. `.context/CONVENTIONS.md`: Code patterns and standards +4. `.context/ARCHITECTURE.md`: System structure +5. `.context/DECISIONS.md`: Architectural decisions with rationale +6. `.context/LEARNINGS.md`: Gotchas, tips, lessons learned +7. `.context/AGENT_PLAYBOOK.md`: How to use this context system + +If `ctx` is installed (check with `which ctx` or `Get-Command ctx`): +- Run `ctx agent --budget 4000` for a token-budgeted context summary +- Run `ctx status` for a health check + +After reading, confirm: "I have read the required context files and I'm +following project conventions." + +## When Asked "Do You Remember?" + +**Do this FIRST (silently):** +- Read `.context/TASKS.md` +- Read `.context/DECISIONS.md` and `.context/LEARNINGS.md` +- Check `.context/sessions/` for recent session files + +**Then respond with a structured readback:** +1. **Last session**: cite the most recent session topic and date +2. **Active work**: list pending or in-progress tasks +3. **Recent context**: mention 1-2 recent decisions or learnings +4. **Next step**: offer to continue or ask what to focus on + +**Never** lead with "I don't have memory." The `.context/` files are +your memory. Read them silently, then present what you found as recall. + +## Context Files + +| File | Purpose | +|-------------------|----------------------------------------| +| CONSTITUTION.md | Hard rules, NEVER violate | +| TASKS.md | Current work items | +| CONVENTIONS.md | Code patterns and standards | +| ARCHITECTURE.md | System structure | +| DECISIONS.md | Architectural decisions with rationale | +| LEARNINGS.md | Gotchas, tips, lessons learned | +| AGENT_PLAYBOOK.md | How to use this context system | + +All files live in `.context/`. + +## Session Persistence + +After completing meaningful work, save a session summary to +`.context/sessions/`. + +### Session File Format + +Create a file named `YYYY-MM-DD-topic.md`: + +```markdown +# Session: YYYY-MM-DD - Brief Topic Description + +## What Was Done +- Describe completed work items + +## Decisions +- Key decisions made and their rationale + +## Learnings +- Gotchas, tips, or insights discovered + +## Next Steps +- Follow-up work or remaining items +``` + +### When to Save + +- After completing a task or feature +- After making architectural decisions +- After a debugging session +- Before ending the session +- At natural breakpoints in long sessions + +## Context Updates During Work + +Proactively update context files as you work: + +| Event | Action | +|-----------------------------|----------------------------------| +| Made architectural decision | Add to `.context/DECISIONS.md` | +| Discovered gotcha/bug | Add to `.context/LEARNINGS.md` | +| Established new pattern | Add to `.context/CONVENTIONS.md` | +| Completed task | Mark [x] in `.context/TASKS.md` | + +## Self-Check + +Periodically ask yourself: + +> "If this session ended right now, would the next session know +> what happened?" + +If no: save a session file or update context files before continuing. + +## CLI Commands + +If `ctx` is installed, use these commands: + +```bash +ctx status # Context summary and health check +ctx agent # AI-ready context packet +ctx drift # Check for stale context +ctx recall list # Recent session history +``` + + diff --git a/internal/assets/hooks/copilot-cli/agents-ctx.md b/internal/assets/hooks/copilot-cli/agents-ctx.md new file mode 100644 index 00000000..2efc2b94 --- /dev/null +++ b/internal/assets/hooks/copilot-cli/agents-ctx.md @@ -0,0 +1,34 @@ +# ctx: Context Management Agent + +You are a context management specialist. Your role is to help maintain +project context using the `ctx` system. + +## Capabilities + +- Read and update `.context/` files (TASKS, DECISIONS, LEARNINGS, etc.) +- Run `ctx` CLI commands for status, drift, and recall +- Save session summaries to `.context/sessions/` +- Check context health and suggest updates + +## When to Delegate to This Agent + +Use this agent when: +- The user asks to update context files +- Session context needs to be saved +- Context health needs checking +- Tasks need to be marked complete or added + +## Workflow + +1. Run `ctx status` to assess current context health +2. Read the relevant `.context/` files +3. Make the requested changes +4. Run `ctx drift` to verify no stale context remains +5. Save a session summary if meaningful work was done + +## Rules + +- NEVER modify `.context/CONSTITUTION.md` without explicit user approval +- Always use marker-based sections when editing generated files +- Prefer `ctx` CLI commands over manual file editing when available +- Save session summaries in `YYYY-MM-DD-topic.md` format diff --git a/internal/assets/hooks/copilot-cli/instructions-context.md b/internal/assets/hooks/copilot-cli/instructions-context.md new file mode 100644 index 00000000..f894e424 --- /dev/null +++ b/internal/assets/hooks/copilot-cli/instructions-context.md @@ -0,0 +1,27 @@ +# Context Files Instructions + +Files in `.context/` are the project's persistent memory. Follow these +rules when reading or modifying them. + +## Reading Context + +- Read `.context/CONSTITUTION.md` first: it contains hard rules +- Read `.context/TASKS.md` to understand current work items +- Use `ctx agent` for a token-budgeted summary instead of reading all files + +## Modifying Context + +- NEVER delete content from context files without explicit user approval +- Use append-only patterns: add new entries, mark old ones complete +- Task format: `- [ ] description #added:YYYY-MM-DD-HHMMSS` +- Decision format: date, decision, rationale as a section entry +- Mark completed tasks with `[x]`, never delete them + +## File Permissions + +- `CONSTITUTION.md`: Read-only unless user explicitly approves changes +- `TASKS.md`: Append new tasks, mark existing ones complete +- `DECISIONS.md`: Append only +- `LEARNINGS.md`: Append only +- `CONVENTIONS.md`: Append only, propose changes to user first +- `sessions/`: Create new files freely, never modify existing ones diff --git a/internal/assets/read/agent/agent.go b/internal/assets/read/agent/agent.go index 2d1168f9..fdf44180 100644 --- a/internal/assets/read/agent/agent.go +++ b/internal/assets/read/agent/agent.go @@ -34,6 +34,33 @@ func CopilotCLIHooksJSON() ([]byte, error) { return assets.FS.ReadFile(asset.PathCopilotCLIHooksJSON) } +// AgentsMd reads the embedded AGENTS.md template. +// +// Returns: +// - []byte: Template content from hooks/agents.md +// - error: Non-nil if the file is not found or read fails +func AgentsMd() ([]byte, error) { + return assets.FS.ReadFile(asset.PathAgentsMd) +} + +// AgentsCtxMd reads the embedded .github/agents/ctx.md template. +// +// Returns: +// - []byte: Template content from hooks/copilot-cli/agents-ctx.md +// - error: Non-nil if the file is not found or read fails +func AgentsCtxMd() ([]byte, error) { + return assets.FS.ReadFile(asset.PathAgentsCtxMd) +} + +// InstructionsCtxMd reads the embedded path-specific instructions. +// +// Returns: +// - []byte: Template content from hooks/copilot-cli/instructions-context.md +// - error: Non-nil if the file is not found or read fails +func InstructionsCtxMd() ([]byte, error) { + return assets.FS.ReadFile(asset.PathInstructionsCtxMd) +} + // CopilotCLIScripts reads all embedded Copilot CLI hook scripts. // Returns a map of filename to content for scripts in // hooks/copilot-cli/scripts/. diff --git a/internal/cli/hook/cmd/root/run.go b/internal/cli/hook/cmd/root/run.go index fe6491de..0bd466cb 100644 --- a/internal/cli/hook/cmd/root/run.go +++ b/internal/cli/hook/cmd/root/run.go @@ -45,6 +45,18 @@ func Run(cmd *cobra.Command, args []string, writeFile bool) error { tool := strings.ToLower(args[0]) switch tool { + case cfgHook.ToolAgents: + if writeFile { + return WriteAgentsMd(cmd) + } + hook.InfoTool(cmd, desc.Text(text.DescKeyHookAgents)) + hook.Separator(cmd) + content, readErr := agent.AgentsMd() + if readErr != nil { + return readErr + } + hook.Content(cmd, string(content)) + case cfgHook.ToolClaudeCode, cfgHook.ToolClaude: hook.InfoTool(cmd, desc.Text(text.DescKeyHookClaude)) @@ -160,7 +172,9 @@ func WriteCopilotInstructions(cmd *cobra.Command) error { // // Creates the .github/hooks/ and .github/hooks/scripts/ directories if // needed and writes the JSON config plus bash and PowerShell scripts -// from embedded assets. Skips if ctx-hooks.json already exists. +// from embedded assets. Also writes .github/agents/ctx.md and +// .github/instructions/context.instructions.md for Copilot CLI. +// Skips if ctx-hooks.json already exists. // // Parameters: // - cmd: Cobra command for output messages @@ -206,10 +220,123 @@ func WriteCopilotCLIHooks(cmd *cobra.Command) error { hook.InfoCopilotCLICreated(cmd, target) } + // Write .github/agents/ctx.md + if err := writeCopilotCLIAgent(cmd); err != nil { + writeErr.WarnFile(cmd, cfgHook.DirGitHubAgents, err) + } + + // Write .github/instructions/context.instructions.md + if err := writeCopilotCLIInstructions(cmd); err != nil { + writeErr.WarnFile(cmd, cfgHook.DirGitHubInstructions, err) + } + hook.InfoCopilotCLISummary(cmd) return nil } +// writeCopilotCLIAgent creates .github/agents/ctx.md for Copilot CLI +// custom agent delegation. Skips if the file already exists. +func writeCopilotCLIAgent(cmd *cobra.Command) error { + agentsDir := filepath.Join(cfgHook.DirGitHub, cfgHook.DirGitHubAgents) + target := filepath.Join(agentsDir, cfgHook.FileAgentsCtxMd) + + if _, err := os.Stat(target); err == nil { + hook.InfoCopilotCLICreated(cmd, target+" (exists, skipped)") + return nil + } + + if err := os.MkdirAll(agentsDir, fs.PermExec); err != nil { + return err + } + + content, readErr := agent.AgentsCtxMd() + if readErr != nil { + return readErr + } + if wErr := os.WriteFile(target, content, fs.PermFile); wErr != nil { + return wErr + } + hook.InfoCopilotCLICreated(cmd, target) + return nil +} + +// writeCopilotCLIInstructions creates +// .github/instructions/context.instructions.md for path-specific +// context file instructions. Skips if the file already exists. +func writeCopilotCLIInstructions(cmd *cobra.Command) error { + instrDir := filepath.Join(cfgHook.DirGitHub, cfgHook.DirGitHubInstructions) + target := filepath.Join(instrDir, cfgHook.FileInstructionsCtxMd) + + if _, err := os.Stat(target); err == nil { + hook.InfoCopilotCLICreated(cmd, target+" (exists, skipped)") + return nil + } + + if err := os.MkdirAll(instrDir, fs.PermExec); err != nil { + return err + } + + content, readErr := agent.InstructionsCtxMd() + if readErr != nil { + return readErr + } + if wErr := os.WriteFile(target, content, fs.PermFile); wErr != nil { + return wErr + } + hook.InfoCopilotCLICreated(cmd, target) + return nil +} + +// WriteAgentsMd generates AGENTS.md in the project root. +// +// Creates AGENTS.md with universal agent instructions. Preserves existing +// non-ctx content by checking for ctx:agents markers. If the file exists +// with markers, skips. If it exists without markers, merges. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if file write fails +func WriteAgentsMd(cmd *cobra.Command) error { + targetFile := cfgHook.FileAgentsMd + + // Load the AGENTS.md template + agentsContent, readErr := agent.AgentsMd() + if readErr != nil { + return readErr + } + + // Check if the file exists + existingContent, err := os.ReadFile(filepath.Clean(targetFile)) + fileExists := err == nil + + if fileExists { + existingStr := string(existingContent) + if strings.Contains(existingStr, marker.AgentsMarkerStart) { + hook.InfoAgentsSkipped(cmd, targetFile) + return nil + } + + // File exists without ctx markers: append ctx content + merged := existingStr + token.NewlineLF + string(agentsContent) + if wErr := os.WriteFile(targetFile, []byte(merged), fs.PermFile); wErr != nil { + return errFs.FileWrite(targetFile, wErr) + } + hook.InfoAgentsMerged(cmd, targetFile) + return nil + } + + // File doesn't exist: create it + if wErr := os.WriteFile(targetFile, agentsContent, fs.PermFile); wErr != nil { + return errFs.FileWrite(targetFile, wErr) + } + hook.InfoAgentsCreated(cmd, targetFile) + + hook.InfoAgentsSummary(cmd) + 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 { diff --git a/internal/config/asset/asset.go b/internal/config/asset/asset.go index b990c64f..6ff94e02 100644 --- a/internal/config/asset/asset.go +++ b/internal/config/asset/asset.go @@ -47,8 +47,11 @@ const ( FileAllowTxt = "allow.txt" FileCLAUDEMd = "CLAUDE.md" FileCommandsYAML = "commands.yaml" + FileAgentsMd = "agents.md" + FileAgentsCtxMd = "agents-ctx.md" FileCopilotCLIHooksJSON = "ctx-hooks.json" FileCopilotInstructionsMd = "copilot-instructions.md" + FileInstructionsCtxMd = "instructions-context.md" FileCtxrcSchemaJSON = "ctxrc.schema.json" FileDenyTxt = "deny.txt" FileExamplesYAML = "examples.yaml" @@ -72,8 +75,11 @@ var ( PathCommandsYAML = path.Join(DirCommands, FileCommandsYAML) PathFlagsYAML = path.Join(DirCommands, FileFlagsYAML) PathExamplesYAML = path.Join(DirCommands, FileExamplesYAML) + PathAgentsMd = path.Join(DirHooks, FileAgentsMd) + PathAgentsCtxMd = path.Join(DirHooksCopilotCLI, FileAgentsCtxMd) PathCopilotCLIHooksJSON = path.Join(DirHooksCopilotCLI, FileCopilotCLIHooksJSON) PathCopilotInstructions = path.Join(DirHooks, FileCopilotInstructionsMd) + PathInstructionsCtxMd = path.Join(DirHooksCopilotCLI, FileInstructionsCtxMd) PathHookRegistry = path.Join(DirHooksMessages, FileRegistryYAML) PathExtraCSS = path.Join(DirJournal, FileExtraCSS) PathMakefileCtx = path.Join(DirProject, FileMakefileCtx) diff --git a/internal/config/embed/text/hook.go b/internal/config/embed/text/hook.go index 226786cf..bf9c9e9f 100644 --- a/internal/config/embed/text/hook.go +++ b/internal/config/embed/text/hook.go @@ -8,6 +8,7 @@ package text const ( DescKeyHookAider = "hook.aider" + DescKeyHookAgents = "hook.agents" DescKeyHookClaude = "hook.claude" DescKeyHookCopilot = "hook.copilot" DescKeyHookCopilotCLI = "hook.copilot-cli" @@ -17,6 +18,10 @@ const ( ) const ( + DescKeyWriteHookAgentsCreated = "write.hook-agents-created" + DescKeyWriteHookAgentsMerged = "write.hook-agents-merged" + DescKeyWriteHookAgentsSkipped = "write.hook-agents-skipped" + DescKeyWriteHookAgentsSummary = "write.hook-agents-summary" DescKeyWriteHookCopilotCLICreated = "write.hook-copilot-cli-created" DescKeyWriteHookCopilotCLISkipped = "write.hook-copilot-cli-skipped" DescKeyWriteHookCopilotCLISummary = "write.hook-copilot-cli-summary" diff --git a/internal/config/hook/hook.go b/internal/config/hook/hook.go index 5bbeba15..ca7f2174 100644 --- a/internal/config/hook/hook.go +++ b/internal/config/hook/hook.go @@ -53,6 +53,7 @@ const ( // Supported integration tool names for ctx hook command. const ( + ToolAgents = "agents" ToolAider = "aider" ToolClaude = "claude" ToolClaudeCode = "claude-code" @@ -65,10 +66,15 @@ const ( // Copilot integration paths. const ( DirGitHub = ".github" + DirGitHubAgents = "agents" DirGitHubHooks = "hooks" DirGitHubHooksScripts = "scripts" + DirGitHubInstructions = "instructions" + FileAgentsMd = "AGENTS.md" + FileAgentsCtxMd = "ctx.md" FileCopilotInstructions = "copilot-instructions.md" FileCopilotCLIHooksJSON = "ctx-hooks.json" + FileInstructionsCtxMd = "context.instructions.md" ) // Prefixes diff --git a/internal/config/marker/marker.go b/internal/config/marker/marker.go index 99ef370f..b1ffa868 100644 --- a/internal/config/marker/marker.go +++ b/internal/config/marker/marker.go @@ -46,6 +46,14 @@ const ( CopilotMarkerEnd = "" ) +// Agents block markers for AGENTS.md. +const ( + // AgentsMarkerStart marks the beginning of ctx-managed AGENTS.md content. + AgentsMarkerStart = "" + // AgentsMarkerEnd marks the end of ctx-managed AGENTS.md content. + AgentsMarkerEnd = "" +) + // Plan block markers for IMPLEMENTATION_PLAN.md. const ( // PlanMarkerStart marks the beginning of the plan block. diff --git a/internal/write/hook/hook.go b/internal/write/hook/hook.go index fbfad3b3..48c427cb 100644 --- a/internal/write/hook/hook.go +++ b/internal/write/hook/hook.go @@ -137,6 +137,43 @@ func InfoCopilotCLICreated(cmd *cobra.Command, targetFile string) { cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteHookCopilotCLICreated), targetFile)) } +// InfoAgentsCreated reports that AGENTS.md was created. +// +// Parameters: +// - cmd: Cobra command for output +// - targetFile: Path to the created file +func InfoAgentsCreated(cmd *cobra.Command, targetFile string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteHookAgentsCreated), targetFile)) +} + +// InfoAgentsMerged reports that ctx content was merged into AGENTS.md. +// +// Parameters: +// - cmd: Cobra command for output +// - targetFile: Path to the merged file +func InfoAgentsMerged(cmd *cobra.Command, targetFile string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteHookAgentsMerged), targetFile)) +} + +// InfoAgentsSkipped reports that AGENTS.md was skipped because +// ctx markers already exist. +// +// Parameters: +// - cmd: Cobra command for output +// - targetFile: Path to the existing file +func InfoAgentsSkipped(cmd *cobra.Command, targetFile string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteHookAgentsSkipped), targetFile)) +} + +// InfoAgentsSummary prints the post-write summary for AGENTS.md. +// +// Parameters: +// - cmd: Cobra command for output +func InfoAgentsSummary(cmd *cobra.Command) { + cmd.Println() + cmd.Println(desc.Text(text.DescKeyWriteHookAgentsSummary)) +} + // InfoCopilotCLISkipped reports that copilot-cli hooks were skipped // because they already exist. // From c0cbca4d674c6f16d26c8e1a82d99e879110305c Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Thu, 26 Mar 2026 00:25:30 +0300 Subject: [PATCH 11/12] =?UTF-8?q?feat(copilot-cli):=20Phase=203=20?= =?UTF-8?q?=E2=80=94=20MCP=20registration=20+=20session=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3A: MCP Registration - Add ensureCopilotCLIMCPConfig() to register ctx MCP server in ~/.copilot/mcp-config.json (respects COPILOT_HOME env var) - Merge-safe: reads existing config, adds ctx server, writes back - Uses official Copilot CLI mcpServers format with type=local - Called automatically from WriteCopilotCLIHooks() - Add DirCopilotHome, EnvCopilotHome, FileMCPConfigJSON constants Phase 3B: Copilot CLI Session Parser - Create CopilotCLIParser implementing SessionParser interface - Add ToolCopilotCLI constant to config/session/tool.go - Register parser in registeredParsers (parser.go) - Add CopilotCLISessionDirs() scanning ~/.copilot/sessions/, ~/.copilot/history/, and Windows LOCALAPPDATA paths - Wire into findSessionsWithFilter() query scanning (query.go) - Extract osWindows constant to satisfy goconst linter - Parser skeleton ready for format discovery as Copilot CLI session storage format stabilizes Ref: specs/copilot-cli-integration.md Phase 3 Signed-off-by: ersan bilik --- internal/cli/hook/cmd/root/run.go | 68 ++++++ internal/config/hook/hook.go | 10 + internal/config/session/tool.go | 2 + internal/recall/parser/copilot.go | 7 +- internal/recall/parser/copilot_cli.go | 245 ++++++++++++++++++++++ internal/recall/parser/copilot_cli_raw.go | 22 ++ internal/recall/parser/parser.go | 1 + internal/recall/parser/query.go | 5 + 8 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 internal/recall/parser/copilot_cli.go create mode 100644 internal/recall/parser/copilot_cli_raw.go diff --git a/internal/cli/hook/cmd/root/run.go b/internal/cli/hook/cmd/root/run.go index 0bd466cb..3ecf5f72 100644 --- a/internal/cli/hook/cmd/root/run.go +++ b/internal/cli/hook/cmd/root/run.go @@ -230,6 +230,11 @@ func WriteCopilotCLIHooks(cmd *cobra.Command) error { writeErr.WarnFile(cmd, cfgHook.DirGitHubInstructions, err) } + // Register ctx MCP server in ~/.copilot/mcp-config.json + if err := ensureCopilotCLIMCPConfig(cmd); err != nil { + cmd.Println(" ⚠ mcp-config.json: " + err.Error()) + } + hook.InfoCopilotCLISummary(cmd) return nil } @@ -369,3 +374,66 @@ func ensureVSCodeMCPJSON(cmd *cobra.Command) error { cmd.Println(" ✓ " + target) return nil } + +// ensureCopilotCLIMCPConfig registers the ctx MCP server in +// ~/.copilot/mcp-config.json (or $COPILOT_HOME/mcp-config.json). +// Merge-safe: reads existing config, adds ctx server, writes back. +// Skips if ctx server is already registered. +func ensureCopilotCLIMCPConfig(cmd *cobra.Command) error { + copilotHome := os.Getenv(cfgHook.EnvCopilotHome) + if copilotHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return err + } + copilotHome = filepath.Join(home, cfgHook.DirCopilotHome) + } + + target := filepath.Join(copilotHome, cfgHook.FileMCPConfigJSON) + + // Read existing config if it exists + existing := make(map[string]interface{}) + if data, err := os.ReadFile(filepath.Clean(target)); err == nil { + if jErr := json.Unmarshal(data, &existing); jErr != nil { + return jErr + } + } + + // Get or create mcpServers map + servers, _ := existing["mcpServers"].(map[string]interface{}) + if servers == nil { + servers = make(map[string]interface{}) + } + + // Check if ctx is already registered + if _, ok := servers["ctx"]; ok { + cmd.Println(" ○ " + target + " (ctx server exists, skipped)") + return nil + } + + // Add ctx MCP server + servers["ctx"] = map[string]interface{}{ + "type": "local", + "command": "ctx", + "args": []string{"mcp", "serve"}, + "tools": []string{"*"}, + } + existing["mcpServers"] = servers + + // Create directory if needed + if err := os.MkdirAll(copilotHome, fs.PermExec); err != nil { + return err + } + + data, err := json.MarshalIndent(existing, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + + if wErr := os.WriteFile(target, data, fs.PermFile); wErr != nil { + return wErr + } + cmd.Println(" ✓ " + target) + return nil +} diff --git a/internal/config/hook/hook.go b/internal/config/hook/hook.go index ca7f2174..4104df49 100644 --- a/internal/config/hook/hook.go +++ b/internal/config/hook/hook.go @@ -77,6 +77,16 @@ const ( FileInstructionsCtxMd = "context.instructions.md" ) +// Copilot CLI home directory and MCP config. +const ( + // DirCopilotHome is the default Copilot CLI config directory name. + DirCopilotHome = ".copilot" + // EnvCopilotHome is the environment variable to override the config dir. + EnvCopilotHome = "COPILOT_HOME" + // FileMCPConfigJSON is the MCP server configuration file name. + FileMCPConfigJSON = "mcp-config.json" +) + // Prefixes const ( // StdinReadTimeout is the maximum time to wait for hook JSON on stdin diff --git a/internal/config/session/tool.go b/internal/config/session/tool.go index c823c54a..261b3453 100644 --- a/internal/config/session/tool.go +++ b/internal/config/session/tool.go @@ -12,6 +12,8 @@ const ( ToolClaudeCode = "claude-code" // ToolCopilot is the tool identifier for VS Code Copilot Chat sessions. ToolCopilot = "copilot" + // ToolCopilotCLI is the tool identifier for GitHub Copilot CLI sessions. + ToolCopilotCLI = "copilot-cli" // ToolMarkdown is the tool identifier for Markdown session files. ToolMarkdown = "markdown" ) diff --git a/internal/recall/parser/copilot.go b/internal/recall/parser/copilot.go index 7ee88ef2..032df657 100644 --- a/internal/recall/parser/copilot.go +++ b/internal/recall/parser/copilot.go @@ -25,6 +25,9 @@ import ( "github.com/ActiveMemory/ctx/internal/entity" ) +// osWindows is the runtime.GOOS value for Windows. +const osWindows = "windows" + // copilotKeyRequests is the key path segment for request arrays. const copilotKeyRequests = "requests" @@ -466,7 +469,7 @@ func fileURIToPath(uri string) string { } // On Windows, file URIs have /G:/... — strip the leading slash - if runtime.GOOS == "windows" && len(decoded) > 2 && decoded[0] == '/' { + if runtime.GOOS == osWindows && len(decoded) > 2 && decoded[0] == '/' { decoded = decoded[1:] } @@ -479,7 +482,7 @@ func CopilotSessionDirs() []string { var dirs []string appData := os.Getenv("APPDATA") - if runtime.GOOS != "windows" { + if runtime.GOOS != osWindows { // On macOS/Linux, VS Code stores data in different locations home, err := os.UserHomeDir() if err != nil { diff --git a/internal/recall/parser/copilot_cli.go b/internal/recall/parser/copilot_cli.go new file mode 100644 index 00000000..e87e2fcc --- /dev/null +++ b/internal/recall/parser/copilot_cli.go @@ -0,0 +1,245 @@ +// / 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" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/ActiveMemory/ctx/internal/config/claude" + "github.com/ActiveMemory/ctx/internal/config/file" + cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" + "github.com/ActiveMemory/ctx/internal/config/session" + "github.com/ActiveMemory/ctx/internal/entity" +) + +// CopilotCLIParser parses GitHub Copilot CLI session files. +// +// Copilot CLI stores sessions as JSONL files in ~/.copilot/sessions/ +// (or $COPILOT_HOME/sessions/). Each file contains one session with +// JSONL-formatted messages similar to Claude Code's format. +type CopilotCLIParser struct{} + +// NewCopilotCLIParser creates a new Copilot CLI session parser. +func NewCopilotCLIParser() *CopilotCLIParser { + return &CopilotCLIParser{} +} + +// Tool returns the tool identifier for this parser. +func (p *CopilotCLIParser) Tool() string { + return session.ToolCopilotCLI +} + +// Matches returns true if the file appears to be a Copilot CLI session file. +// +// Checks if the file has a .jsonl extension and lives in a Copilot CLI +// session directory (under ~/.copilot/ or $COPILOT_HOME). +func (p *CopilotCLIParser) Matches(path string) bool { + if !strings.HasSuffix(path, file.ExtJSONL) { + return false + } + + // Must be under a .copilot directory (not chatSessions, which is VS Code) + dir := filepath.Dir(path) + if strings.Contains(dir, "chatSessions") { + return false + } + + // Check if this is under a .copilot directory + if !strings.Contains(path, cfgHook.DirCopilotHome) { + return false + } + + // Verify the first line looks like a Copilot CLI session + f, err := os.Open(filepath.Clean(path)) + if err != nil { + return false + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + if !scanner.Scan() { + return false + } + + var msg copilotCLIRawMessage + if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil { + return false + } + + // A Copilot CLI session line must have a role and type + return msg.Role != "" || msg.Type != "" +} + +// ParseFile reads a Copilot CLI JSONL session file and returns sessions. +// +// Each file represents one session. Messages are parsed line by line. +func (p *CopilotCLIParser) ParseFile(path string) ([]*entity.Session, error) { + f, err := os.Open(filepath.Clean(path)) + if err != nil { + return nil, fmt.Errorf("open file: %w", err) + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 4*1024*1024) // 4MB per line + + var messages []copilotCLIRawMessage + + for scanner.Scan() { + lineBytes := scanner.Bytes() + if len(lineBytes) == 0 { + continue + } + + var msg copilotCLIRawMessage + if err := json.Unmarshal(lineBytes, &msg); err != nil { + continue + } + messages = append(messages, msg) + } + + if scanErr := scanner.Err(); scanErr != nil { + return nil, fmt.Errorf("scan file: %w", scanErr) + } + + if len(messages) == 0 { + return nil, nil + } + + result := p.buildSession(messages, path) + if result == nil { + return nil, nil + } + + return []*entity.Session{result}, nil +} + +// ParseLine is not used for Copilot CLI sessions since each file +// represents a complete session. Returns nil. +func (p *CopilotCLIParser) ParseLine(_ []byte) (*entity.Message, string, error) { + return nil, "", nil +} + +// buildSession converts raw messages into a Session entity. +func (p *CopilotCLIParser) buildSession( + msgs []copilotCLIRawMessage, sourcePath string, +) *entity.Session { + if len(msgs) == 0 { + return nil + } + + sess := &entity.Session{ + ID: filepath.Base(strings.TrimSuffix(sourcePath, file.ExtJSONL)), + Tool: session.ToolCopilotCLI, + SourceFile: sourcePath, + } + + for _, msg := range msgs { + // Extract CWD from first message that has it + if sess.CWD == "" && msg.CWD != "" { + sess.CWD = msg.CWD + sess.Project = filepath.Base(msg.CWD) + } + + // Extract session ID if present + if msg.SessionID != "" { + sess.ID = msg.SessionID + } + + // Extract model + if sess.Model == "" && msg.Model != "" { + sess.Model = msg.Model + } + + // Set timestamps + if !msg.Timestamp.IsZero() { + if sess.StartTime.IsZero() { + sess.StartTime = msg.Timestamp + } + sess.EndTime = msg.Timestamp + } + + // Build entity message + entityMsg := entity.Message{ + ID: msg.ID, + Timestamp: msg.Timestamp, + Role: msg.Role, + Text: msg.Text, + } + + if msg.Role == claude.RoleUser { + sess.TurnCount++ + if sess.FirstUserMsg == "" && msg.Text != "" { + preview := msg.Text + if len(preview) > 100 { + preview = preview[:100] + "..." + } + sess.FirstUserMsg = preview + } + } + + sess.Messages = append(sess.Messages, entityMsg) + } + + if !sess.StartTime.IsZero() && !sess.EndTime.IsZero() { + sess.Duration = sess.EndTime.Sub(sess.StartTime) + } + + return sess +} + +// CopilotCLISessionDirs returns the directories where Copilot CLI sessions +// may be stored. Respects $COPILOT_HOME env var. +func CopilotCLISessionDirs() []string { + var dirs []string + + copilotHome := os.Getenv(cfgHook.EnvCopilotHome) + if copilotHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + copilotHome = filepath.Join(home, cfgHook.DirCopilotHome) + } + + // Check common session subdirectories + candidates := []string{"sessions", "history"} + for _, sub := range candidates { + dir := filepath.Join(copilotHome, sub) + if info, err := os.Stat(dir); err == nil && info.IsDir() { + dirs = append(dirs, dir) + } + } + + // On Windows, also check under LOCALAPPDATA + if runtime.GOOS == osWindows { + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData != "" { + for _, sub := range candidates { + dir := filepath.Join(localAppData, "GitHub Copilot CLI", sub) + if info, err := os.Stat(dir); err == nil && info.IsDir() { + dirs = append(dirs, dir) + } + } + } + } + + return dirs +} + +// Ensure CopilotCLIParser implements SessionParser. +var _ SessionParser = (*CopilotCLIParser)(nil) diff --git a/internal/recall/parser/copilot_cli_raw.go b/internal/recall/parser/copilot_cli_raw.go new file mode 100644 index 00000000..27d9e4e6 --- /dev/null +++ b/internal/recall/parser/copilot_cli_raw.go @@ -0,0 +1,22 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package parser + +import "time" + +// copilotCLIRawMessage represents a single JSONL line from a Copilot CLI +// session file. The exact format may evolve as Copilot CLI matures. +type copilotCLIRawMessage struct { + ID string `json:"id,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Role string `json:"role,omitempty"` + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + Model string `json:"model,omitempty"` + CWD string `json:"cwd,omitempty"` + Timestamp time.Time `json:"timestamp"` +} diff --git a/internal/recall/parser/parser.go b/internal/recall/parser/parser.go index 0297c95d..eb1914d4 100644 --- a/internal/recall/parser/parser.go +++ b/internal/recall/parser/parser.go @@ -22,6 +22,7 @@ import ( var registeredParsers = []SessionParser{ NewClaudeCodeParser(), NewCopilotParser(), + NewCopilotCLIParser(), NewMarkdownSessionParser(), } diff --git a/internal/recall/parser/query.go b/internal/recall/parser/query.go index 50c46b59..54f056f4 100644 --- a/internal/recall/parser/query.go +++ b/internal/recall/parser/query.go @@ -62,6 +62,11 @@ func findSessionsWithFilter( scanOnce(dir) } + // Check Copilot CLI session directories (~/.copilot/ or $COPILOT_HOME) + for _, dir := range CopilotCLISessionDirs() { + scanOnce(dir) + } + // Check .context/sessions/ in the current working directory if cwd, cwdErr := os.Getwd(); cwdErr == nil { scanOnce(filepath.Join(cwd, dir.Context, dir.Sessions)) From 31fdab2ec1c7a2132af4a983eee8df358e8fa09a Mon Sep 17 00:00:00 2001 From: ersan bilik Date: Thu, 26 Mar 2026 00:48:12 +0300 Subject: [PATCH 12/12] =?UTF-8?q?feat(copilot-cli):=20Phase=204=20?= =?UTF-8?q?=E2=80=94=20Copilot=20CLI=20skill=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 5 embedded skill templates for GitHub Copilot CLI integration: - ctx-status: context summary and health check - ctx-recall: session history browsing - ctx-drift: context drift detection - ctx-compact: archive completed tasks - ctx-next: advance to next task Each skill is written to .github/skills//SKILL.md when 'ctx hook copilot-cli --write' is invoked. Skills use YAML frontmatter (name, description) with Markdown instructions, following the Copilot CLI skill format. Wiring: - embed.go: glob for hooks/copilot-cli/skills/*/SKILL.md - agent.go: CopilotCLISkills() reader function - run.go: writeCopilotCLISkills() called from WriteCopilotCLIHooks() - asset.go: DirHooksCopilotCLISkills constant - hook.go: DirGitHubSkills, FileSKILLMd constants - text/hook.go: DescKeyWriteHookCopilotCLISkills key Signed-off-by: ersan bilik --- internal/assets/embed.go | 1 + .../copilot-cli/skills/ctx-compact/SKILL.md | 35 ++++++++++++++++ .../copilot-cli/skills/ctx-drift/SKILL.md | 41 ++++++++++++++++++ .../copilot-cli/skills/ctx-next/SKILL.md | 34 +++++++++++++++ .../copilot-cli/skills/ctx-recall/SKILL.md | 42 +++++++++++++++++++ .../copilot-cli/skills/ctx-status/SKILL.md | 42 +++++++++++++++++++ internal/assets/read/agent/agent.go | 28 +++++++++++++ internal/cli/hook/cmd/root/run.go | 34 +++++++++++++++ internal/config/asset/asset.go | 37 ++++++++-------- internal/config/embed/text/hook.go | 1 + internal/config/hook/hook.go | 2 + 11 files changed, 279 insertions(+), 18 deletions(-) create mode 100644 internal/assets/hooks/copilot-cli/skills/ctx-compact/SKILL.md create mode 100644 internal/assets/hooks/copilot-cli/skills/ctx-drift/SKILL.md create mode 100644 internal/assets/hooks/copilot-cli/skills/ctx-next/SKILL.md create mode 100644 internal/assets/hooks/copilot-cli/skills/ctx-recall/SKILL.md create mode 100644 internal/assets/hooks/copilot-cli/skills/ctx-status/SKILL.md diff --git a/internal/assets/embed.go b/internal/assets/embed.go index eec0e1cd..9bd50bc2 100644 --- a/internal/assets/embed.go +++ b/internal/assets/embed.go @@ -14,6 +14,7 @@ import ( //go:embed claude/skills/*/references/*.md claude/skills/*/SKILL.md //go:embed context/*.md project/* entry-templates/*.md hooks/*.md //go:embed hooks/copilot-cli/*.json hooks/copilot-cli/*.md hooks/copilot-cli/scripts/*.sh hooks/copilot-cli/scripts/*.ps1 +//go:embed hooks/copilot-cli/skills/*/SKILL.md //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 diff --git a/internal/assets/hooks/copilot-cli/skills/ctx-compact/SKILL.md b/internal/assets/hooks/copilot-cli/skills/ctx-compact/SKILL.md new file mode 100644 index 00000000..05145b06 --- /dev/null +++ b/internal/assets/hooks/copilot-cli/skills/ctx-compact/SKILL.md @@ -0,0 +1,35 @@ +--- +name: ctx-compact +description: "Archive completed tasks and trim context. Use when context files are growing large." +--- + +Archive completed tasks and trim stale context entries to keep +the context directory lean and within token budgets. + +## When to Use + +- When TASKS.md has many completed items +- When context token count is growing large +- When asked to "clean up" or "compact" context +- Before starting a new phase of work + +## When NOT to Use + +- When all tasks are still active +- When context is already compact + +## Execution + +Run the compact operation: + +```bash +ctx compact +``` + +This archives completed tasks from TASKS.md into the session +history and trims stale entries from other context files. + +After running, confirm: +- How many tasks were archived +- Current token budget usage +- Whether any manual cleanup is recommended diff --git a/internal/assets/hooks/copilot-cli/skills/ctx-drift/SKILL.md b/internal/assets/hooks/copilot-cli/skills/ctx-drift/SKILL.md new file mode 100644 index 00000000..37931ef8 --- /dev/null +++ b/internal/assets/hooks/copilot-cli/skills/ctx-drift/SKILL.md @@ -0,0 +1,41 @@ +--- +name: ctx-drift +description: "Detect context drift. Use to find stale paths, broken references, and outdated context." +--- + +Detect context drift: stale paths, missing files, constitution +violations, and semantic staleness in context files. + +## When to Use + +- At session start to verify context health +- After refactors, renames, or major structural changes +- When asked "is our context clean?" or "anything stale?" +- Before a release or milestone + +## When NOT to Use + +- When you just ran ctx status and everything looked fine +- Repeatedly without changes in between + +## Execution + +Run the structural drift check: + +```bash +ctx drift +``` + +This catches dead paths, missing files, staleness indicators, +and constitution violations. + +After running, also do a semantic check: read the context files +and compare them to what you know about the codebase. Look for: + +- Outdated conventions that the code no longer follows +- Decisions whose rationale no longer applies +- Architecture descriptions that have changed +- Learnings about bugs that were since fixed + +Report findings as actionable items, not raw output. Propose +specific fixes for each issue found. diff --git a/internal/assets/hooks/copilot-cli/skills/ctx-next/SKILL.md b/internal/assets/hooks/copilot-cli/skills/ctx-next/SKILL.md new file mode 100644 index 00000000..f5fdc8a1 --- /dev/null +++ b/internal/assets/hooks/copilot-cli/skills/ctx-next/SKILL.md @@ -0,0 +1,34 @@ +--- +name: ctx-next +description: "Advance to the next task. Use when finishing a task or when asked what to work on next." +--- + +Mark the current task complete and advance to the next one in +the context task list. + +## When to Use + +- After completing a task +- When the user asks "what's next?" +- When picking up work after a break + +## When NOT to Use + +- When the user has a specific task in mind already +- When no tasks exist in the context + +## Execution + +Show the current task and mark it complete: + +```bash +ctx next +``` + +This reads `.context/TASKS.md`, identifies the current in-progress +task, marks it done, and shows the next pending task. + +After running, summarize: +- What was completed +- What the next task is +- Any blockers or dependencies noted in the task list diff --git a/internal/assets/hooks/copilot-cli/skills/ctx-recall/SKILL.md b/internal/assets/hooks/copilot-cli/skills/ctx-recall/SKILL.md new file mode 100644 index 00000000..5db97977 --- /dev/null +++ b/internal/assets/hooks/copilot-cli/skills/ctx-recall/SKILL.md @@ -0,0 +1,42 @@ +--- +name: ctx-recall +description: "Browse session history. Use when referencing past discussions or finding context from previous work." +--- + +Browse, inspect, and export AI session history. + +## When to Use + +- When the user asks "what did we do last time?" +- When looking for context from previous work sessions +- When exporting sessions to the journal +- When searching for a specific session by topic or date + +## When NOT to Use + +- When the user just wants current context (use ctx-status instead) +- For modifying session content (recall is read-only) + +## Execution + +List recent sessions: + +```bash +ctx recall list --limit 5 +``` + +Show details of a specific session: + +```bash +ctx recall show --latest +ctx recall show +``` + +Export sessions to journal markdown: + +```bash +ctx recall export --all +``` + +After listing sessions, summarize relevant findings rather than +dumping raw output. diff --git a/internal/assets/hooks/copilot-cli/skills/ctx-status/SKILL.md b/internal/assets/hooks/copilot-cli/skills/ctx-status/SKILL.md new file mode 100644 index 00000000..2de17c3c --- /dev/null +++ b/internal/assets/hooks/copilot-cli/skills/ctx-status/SKILL.md @@ -0,0 +1,42 @@ +--- +name: ctx-status +description: "Show context summary and health. Use at session start or when unclear about project state." +--- + +Show the current context status: files, token budget, tasks, and +recent activity. + +## When to Use + +- At session start to orient before doing work +- When confused about what is being worked on +- To check token usage and context health +- When the user asks "what's the state of the project?" + +## When NOT to Use + +- When you already loaded context via `ctx agent` in this session +- Repeatedly within the same session without changes in between + +## Execution + +```bash +ctx status +``` + +For verbose output with file previews: + +```bash +ctx status --verbose +``` + +For machine-readable output: + +```bash +ctx status --json +``` + +After running, summarize the key points: +- How many active tasks remain +- Whether any context files are empty or stale +- What was most recently modified diff --git a/internal/assets/read/agent/agent.go b/internal/assets/read/agent/agent.go index fdf44180..11b23b3d 100644 --- a/internal/assets/read/agent/agent.go +++ b/internal/assets/read/agent/agent.go @@ -90,3 +90,31 @@ func CopilotCLIScripts() (map[string][]byte, error) { } return scripts, nil } + +// CopilotCLISkills reads all embedded Copilot CLI skill templates. +// Returns a map of skill directory name to SKILL.md content for skills +// in hooks/copilot-cli/skills/. +// +// Returns: +// - map[string][]byte: Skill name -> SKILL.md content +// - error: Non-nil if the directory read fails +func CopilotCLISkills() (map[string][]byte, error) { + skills := make(map[string][]byte) + entries, dirErr := fs.ReadDir(assets.FS, asset.DirHooksCopilotCLISkills) + if dirErr != nil { + return nil, dirErr + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + skillPath := asset.DirHooksCopilotCLISkills + "/" + name + "/" + asset.FileSKILLMd + content, readErr := assets.FS.ReadFile(skillPath) + if readErr != nil { + return nil, readErr + } + skills[name] = content + } + return skills, nil +} diff --git a/internal/cli/hook/cmd/root/run.go b/internal/cli/hook/cmd/root/run.go index 3ecf5f72..a4bb0652 100644 --- a/internal/cli/hook/cmd/root/run.go +++ b/internal/cli/hook/cmd/root/run.go @@ -235,6 +235,11 @@ func WriteCopilotCLIHooks(cmd *cobra.Command) error { cmd.Println(" ⚠ mcp-config.json: " + err.Error()) } + // Write .github/skills//SKILL.md for Copilot CLI skills + if err := writeCopilotCLISkills(cmd); err != nil { + writeErr.WarnFile(cmd, cfgHook.DirGitHubSkills, err) + } + hook.InfoCopilotCLISummary(cmd) return nil } @@ -292,6 +297,35 @@ func writeCopilotCLIInstructions(cmd *cobra.Command) error { return nil } +// writeCopilotCLISkills creates .github/skills//SKILL.md for each +// embedded Copilot CLI skill template. Skips skills that already exist. +func writeCopilotCLISkills(cmd *cobra.Command) error { + skills, readErr := agent.CopilotCLISkills() + if readErr != nil { + return readErr + } + + skillsBase := filepath.Join(cfgHook.DirGitHub, cfgHook.DirGitHubSkills) + for name, content := range skills { + skillDir := filepath.Join(skillsBase, name) + target := filepath.Join(skillDir, cfgHook.FileSKILLMd) + + if _, err := os.Stat(target); err == nil { + hook.InfoCopilotCLICreated(cmd, target+" (exists, skipped)") + continue + } + + if err := os.MkdirAll(skillDir, fs.PermExec); err != nil { + return err + } + if wErr := os.WriteFile(target, content, fs.PermFile); wErr != nil { + return wErr + } + hook.InfoCopilotCLICreated(cmd, target) + } + return nil +} + // WriteAgentsMd generates AGENTS.md in the project root. // // Creates AGENTS.md with universal agent instructions. Preserves existing diff --git a/internal/config/asset/asset.go b/internal/config/asset/asset.go index 6ff94e02..9ea52211 100644 --- a/internal/config/asset/asset.go +++ b/internal/config/asset/asset.go @@ -10,24 +10,25 @@ import "path" // Embedded asset directory names. const ( - DirClaude = "claude" - DirClaudePlugin = "claude/.claude-plugin" - DirClaudeSkills = "claude/skills" - DirCommands = "commands" - DirCommandsText = "commands/text" - DirContext = "context" - DirEntryTemplates = "entry-templates" - DirHooks = "hooks" - DirHooksCopilotCLI = "hooks/copilot-cli" - DirHooksCopilotCLIScrp = "hooks/copilot-cli/scripts" - DirHooksMessages = "hooks/messages" - DirJournal = "journal" - DirPermissions = "permissions" - DirProject = "project" - DirPromptTemplates = "prompt-templates" - DirRalph = "ralph" - DirSchema = "schema" - DirWhy = "why" + DirClaude = "claude" + DirClaudePlugin = "claude/.claude-plugin" + DirClaudeSkills = "claude/skills" + DirCommands = "commands" + DirCommandsText = "commands/text" + DirContext = "context" + DirEntryTemplates = "entry-templates" + DirHooks = "hooks" + DirHooksCopilotCLI = "hooks/copilot-cli" + DirHooksCopilotCLIScrp = "hooks/copilot-cli/scripts" + DirHooksCopilotCLISkills = "hooks/copilot-cli/skills" + DirHooksMessages = "hooks/messages" + DirJournal = "journal" + DirPermissions = "permissions" + DirProject = "project" + DirPromptTemplates = "prompt-templates" + DirRalph = "ralph" + DirSchema = "schema" + DirWhy = "why" ) // JSON field keys used when parsing embedded asset files. diff --git a/internal/config/embed/text/hook.go b/internal/config/embed/text/hook.go index bf9c9e9f..342e3b5a 100644 --- a/internal/config/embed/text/hook.go +++ b/internal/config/embed/text/hook.go @@ -24,6 +24,7 @@ const ( DescKeyWriteHookAgentsSummary = "write.hook-agents-summary" DescKeyWriteHookCopilotCLICreated = "write.hook-copilot-cli-created" DescKeyWriteHookCopilotCLISkipped = "write.hook-copilot-cli-skipped" + DescKeyWriteHookCopilotCLISkills = "write.hook-copilot-cli-skills" DescKeyWriteHookCopilotCLISummary = "write.hook-copilot-cli-summary" DescKeyWriteHookCopilotCreated = "write.hook-copilot-created" DescKeyWriteHookCopilotForceHint = "write.hook-copilot-force-hint" diff --git a/internal/config/hook/hook.go b/internal/config/hook/hook.go index 4104df49..e12d423b 100644 --- a/internal/config/hook/hook.go +++ b/internal/config/hook/hook.go @@ -70,11 +70,13 @@ const ( DirGitHubHooks = "hooks" DirGitHubHooksScripts = "scripts" DirGitHubInstructions = "instructions" + DirGitHubSkills = "skills" FileAgentsMd = "AGENTS.md" FileAgentsCtxMd = "ctx.md" FileCopilotInstructions = "copilot-instructions.md" FileCopilotCLIHooksJSON = "ctx-hooks.json" FileInstructionsCtxMd = "context.instructions.md" + FileSKILLMd = "SKILL.md" ) // Copilot CLI home directory and MCP config.