5e2eaea1
chore: project bootstrap
a73x 2026-04-28 17:34
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e2723e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ bin/ *.test *.out .DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cd7a0cb --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ BIN := bin/filewatch-mcp PKG := ./cmd/filewatch-mcp .PHONY: build test lint run install clean build: mkdir -p bin go build -o $(BIN) $(PKG) test: go test ./... lint: golangci-lint run run: go run $(PKG) --root . install: go install $(PKG) clean: rm -rf bin/ diff --git a/cmd/filewatch-mcp/main.go b/cmd/filewatch-mcp/main.go new file mode 100644 index 0000000..ba241f0 --- /dev/null +++ b/cmd/filewatch-mcp/main.go @@ -0,0 +1,7 @@ package main import "fmt" func main() { fmt.Println("filewatch-mcp") } diff --git a/docs/superpowers/plans/2026-04-28-claudealong-filewatch-mcp.md b/docs/superpowers/plans/2026-04-28-claudealong-filewatch-mcp.md new file mode 100644 index 0000000..e85ed06 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-claudealong-filewatch-mcp.md @@ -0,0 +1,2153 @@ # `@claude` File-Watcher MCP — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a Go MCP server that watches a project directory, detects new `@claude <ask>` markers in code comments, pushes a `notifications/claude/channel` JSON-RPC notification into the running Claude Code session for each, and stamps the marker with a UUID inline so the file itself becomes the dedupe state. **Architecture:** Single Go binary spawned by Claude Code as an MCP child over stdio. Five internal packages: `scanner` (parses comments), `rewriter` (atomic UUID stamping), `ignore` (`.gitignore` + hard-excludes), `watcher` (fsnotify with `WRITE|CREATE|RENAME` op mask), `mcp` (custom outbound notification using the official Go SDK). The `cmd/filewatch-mcp/main.go` entry point wires them. **Tech Stack:** Go 1.22+, `github.com/modelcontextprotocol/go-sdk` (official), `github.com/fsnotify/fsnotify`, `github.com/sabhiram/go-gitignore`, `github.com/google/uuid`. Make for dev. TDD with `go test`. **Spec:** `docs/superpowers/specs/2026-04-28-claude-channel-filewatch-design.md` --- ## Module API contract (used by all tasks) Tasks below introduce these incrementally. Listed here for cross-reference — tasks restate signatures where relevant for self-contained reading. ```go // internal/scanner type Marker struct { Line int // 1-indexed Text string // payload starting at "@claude", comment delimiters stripped Tagged bool // true if marker has [fw-XXXXXXXX] UUID string // populated when Tagged=true, e.g. "fw-a1b2c3d4" } func Scan(path string) ([]Marker, error) // internal/rewriter var ErrConcurrentModification = errors.New("file modified concurrently") func StampUUIDs(path string, stamps map[int]string) error // internal/ignore type Matcher struct{ /* unexported */ } func New(root string) (*Matcher, error) func (m *Matcher) ShouldIgnore(path string) bool func (m *Matcher) Reload() error // internal/watcher type Event struct{ Path string } type Watcher struct{ /* unexported */ } func New(root string, ig *ignore.Matcher, debounce time.Duration) (*Watcher, error) func (w *Watcher) Events() <-chan Event func (w *Watcher) Start(ctx context.Context) error func (w *Watcher) Close() error // internal/mcp type ChannelMeta struct { Source string // always "filewatch" File string Line int ReplyTo string // "fw-XXXXXXXX" } type Server struct{ /* unexported */ } func New() *Server func (s *Server) SendChannel(ctx context.Context, meta ChannelMeta, content string) error func (s *Server) Run(ctx context.Context) error ``` --- ### Task 1: Project bootstrap **Files:** - Create: `/home/xanderle/code/rad/claudealong/go.mod` - Create: `/home/xanderle/code/rad/claudealong/.gitignore` - Create: `/home/xanderle/code/rad/claudealong/Makefile` - Create: `/home/xanderle/code/rad/claudealong/cmd/filewatch-mcp/main.go` - [ ] **Step 1: Initialize git and Go module** ```bash cd /home/xanderle/code/rad/claudealong git init go mod init github.com/xanderle/claudealong ``` Expected: `go.mod` created with module path. - [ ] **Step 2: Write `.gitignore`** ```gitignore bin/ *.test *.out .DS_Store ``` - [ ] **Step 3: Write minimal `cmd/filewatch-mcp/main.go`** ```go package main import "fmt" func main() { fmt.Println("filewatch-mcp") } ``` - [ ] **Step 4: Write `Makefile`** ```makefile BIN := bin/filewatch-mcp PKG := ./cmd/filewatch-mcp .PHONY: build test lint run install clean build: mkdir -p bin go build -o $(BIN) $(PKG) test: go test ./... lint: golangci-lint run run: go run $(PKG) --root . install: go install $(PKG) clean: rm -rf bin/ ``` - [ ] **Step 5: Verify build** Run: `make build` Expected: `bin/filewatch-mcp` created, no errors. - [ ] **Step 6: Commit** ```bash git add go.mod go.sum .gitignore Makefile cmd/ git commit -m "chore: project bootstrap" ``` --- ### Task 2: SDK spike — verify custom notification path **Goal:** Confirm `github.com/modelcontextprotocol/go-sdk` lets us send `notifications/claude/channel` end-to-end. If this fails, the plan needs revision before continuing. **Files:** - Modify: `/home/xanderle/code/rad/claudealong/cmd/filewatch-mcp/main.go` - Modify: `/home/xanderle/code/rad/claudealong/go.mod` (via `go get`) - [ ] **Step 1: Add SDK dependency** ```bash go get github.com/modelcontextprotocol/go-sdk@latest ``` Expected: `go.mod` and `go.sum` updated. - [ ] **Step 2: Write spike main.go** Replace `cmd/filewatch-mcp/main.go` with code that boots an MCP stdio server and sends one `notifications/claude/channel` notification with hardcoded content. Exact code depends on the SDK's notification API — read the SDK README and the `pkg.go.dev` reference for `Server.Notify` or equivalent. Pseudocode: ```go package main import ( "context" "log" "os" "github.com/modelcontextprotocol/go-sdk/mcp" ) func main() { srv := mcp.NewServer(&mcp.Implementation{ Name: "filewatch-mcp", Version: "0.0.1-spike", }) // After init handshake, fire one channel notification. srv.OnInitialized(func(ctx context.Context) { params := map[string]any{ "content": "@claude SDK spike test", "meta": map[string]any{ "source": "filewatch", "file": "spike.go", "line": 1, "replyTo": "fw-spike001", }, } if err := srv.Notify(ctx, "notifications/claude/channel", params); err != nil { log.Printf("notify error: %v", err) } }) if err := srv.Run(context.Background(), mcp.NewStdioTransport(os.Stdin, os.Stdout)); err != nil { log.Fatal(err) } } ``` If the SDK does not expose `Notify` for arbitrary methods, find the equivalent (likely on `ServerSession`). If the SDK rejects custom method names with a validation error, **stop and report**: the plan needs to switch to direct JSON-RPC framing without the SDK. - [ ] **Step 3: Build** Run: `make build` Expected: builds clean. - [ ] **Step 4: Manual end-to-end smoke** Add to a Claude Code project's `.mcp.json` (use absolute path to your built binary): ```json { "mcpServers": { "filewatch-spike": { "command": "/home/xanderle/code/rad/claudealong/bin/filewatch-mcp" } } } ``` Start Claude Code with `--channels` (`claude --channels`) in that project. Expected: a `<channel source="filewatch" file="spike.go" line="1" replyTo="fw-spike001">@claude SDK spike test</channel>` block appears in the session shortly after start. - [ ] **Step 5: Decision gate** If the channel block appeared: continue to Task 3. If not: read the SDK source for the actual notification API, fix, retry. If after one focused day the SDK still won't emit a custom-named notification, **stop**, document findings in `docs/superpowers/specs/2026-04-28-sdk-spike-notes.md`, and revisit the spec. - [ ] **Step 6: Commit** ```bash git add go.mod go.sum cmd/ git commit -m "spike: confirm notifications/claude/channel via official Go SDK" ``` --- ### Task 3: Scanner — happy path single Go marker **Files:** - Create: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner.go` - Create: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner_test.go` - [ ] **Step 1: Write the failing test** `internal/scanner/scanner_test.go`: ```go package scanner import ( "os" "path/filepath" "testing" ) func writeFile(t *testing.T, dir, name, content string) string { t.Helper() path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } return path } func TestScanGoFileSingleUntaggedMarker(t *testing.T) { dir := t.TempDir() path := writeFile(t, dir, "foo.go", "package foo\n\n// @claude write a test for foo\nfunc Foo() {}\n") markers, err := Scan(path) if err != nil { t.Fatalf("Scan: %v", err) } if len(markers) != 1 { t.Fatalf("got %d markers, want 1: %#v", len(markers), markers) } m := markers[0] if m.Line != 3 { t.Errorf("Line = %d, want 3", m.Line) } if m.Text != "@claude write a test for foo" { t.Errorf("Text = %q, want %q", m.Text, "@claude write a test for foo") } if m.Tagged { t.Errorf("Tagged = true, want false") } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/scanner/ -run TestScanGoFileSingleUntaggedMarker -v` Expected: FAIL with build error (`Scan` undefined). - [ ] **Step 3: Write minimal implementation** `internal/scanner/scanner.go`: ```go package scanner import ( "bufio" "os" "path/filepath" "regexp" "strings" ) type Marker struct { Line int Text string Tagged bool UUID string } // commentPrefixes maps file extension (lowercase, with dot) to the line-comment prefix. var commentPrefixes = map[string]string{ ".go": "//", } var ( untaggedRE = regexp.MustCompile(`^@claude\s+\S`) taggedRE = regexp.MustCompile(`^@claude\[(fw-[0-9a-f]{8})\]\s+\S`) ) func Scan(path string) ([]Marker, error) { ext := strings.ToLower(filepath.Ext(path)) prefix, ok := commentPrefixes[ext] if !ok { return nil, nil } f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() var markers []Marker sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 1024*1024), 1024*1024) line := 0 for sc.Scan() { line++ raw := strings.TrimSpace(sc.Text()) if !strings.HasPrefix(raw, prefix) { continue } body := strings.TrimSpace(strings.TrimPrefix(raw, prefix)) if m := taggedRE.FindStringSubmatch(body); m != nil { markers = append(markers, Marker{Line: line, Text: body, Tagged: true, UUID: m[1]}) continue } if untaggedRE.MatchString(body) { markers = append(markers, Marker{Line: line, Text: body}) } } return markers, sc.Err() } ``` - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/scanner/ -run TestScanGoFileSingleUntaggedMarker -v` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add internal/scanner/ git commit -m "feat(scanner): detect untagged @claude markers in Go comments" ``` --- ### Task 4: Scanner — multi-language comment table **Files:** - Modify: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner.go` - Modify: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner_test.go` - [ ] **Step 1: Write failing tests for additional languages** Append to `scanner_test.go`: ```go func TestScanPythonFile(t *testing.T) { dir := t.TempDir() path := writeFile(t, dir, "foo.py", "# @claude rename this\ndef foo(): pass\n") markers, err := Scan(path) if err != nil { t.Fatal(err) } if len(markers) != 1 || markers[0].Line != 1 || markers[0].Text != "@claude rename this" { t.Fatalf("got %#v", markers) } } func TestScanSQLFile(t *testing.T) { dir := t.TempDir() path := writeFile(t, dir, "q.sql", "-- @claude add an index\nSELECT 1;\n") markers, err := Scan(path) if err != nil { t.Fatal(err) } if len(markers) != 1 || markers[0].Text != "@claude add an index" { t.Fatalf("got %#v", markers) } } func TestScanCSSBlockComment(t *testing.T) { dir := t.TempDir() path := writeFile(t, dir, "s.css", "/* @claude make this responsive */\nbody {}\n") markers, err := Scan(path) if err != nil { t.Fatal(err) } if len(markers) != 1 || markers[0].Text != "@claude make this responsive" { t.Fatalf("got %#v", markers) } } func TestScanHTMLBlockComment(t *testing.T) { dir := t.TempDir() path := writeFile(t, dir, "x.html", "<!-- @claude add aria labels -->\n<div></div>\n") markers, err := Scan(path) if err != nil { t.Fatal(err) } if len(markers) != 1 || markers[0].Text != "@claude add aria labels" { t.Fatalf("got %#v", markers) } } func TestScanUnrecognizedExtensionSkipped(t *testing.T) { dir := t.TempDir() path := writeFile(t, dir, "weird.xyz", "// @claude do thing\n") markers, err := Scan(path) if err != nil { t.Fatal(err) } if len(markers) != 0 { t.Fatalf("expected skip, got %#v", markers) } } func TestScanMakefileByName(t *testing.T) { dir := t.TempDir() path := writeFile(t, dir, "Makefile", "# @claude add a lint target\nbuild:\n\techo hi\n") markers, err := Scan(path) if err != nil { t.Fatal(err) } if len(markers) != 1 || markers[0].Text != "@claude add a lint target" { t.Fatalf("got %#v", markers) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `go test ./internal/scanner/ -v` Expected: 5 new tests FAIL (only `.go` recognized). - [ ] **Step 3: Extend implementation** Replace the `commentPrefixes` map and the loop body in `scanner.go`. Add a separate handling for block-style single-line markers (`/* ... */`, `<!-- ... -->`). ```go // Replace commentPrefixes with two tables. // lineCommentPrefixes: extensions whose markers must be a line-comment. var lineCommentPrefixes = map[string]string{ ".go": "//", ".js": "//", ".jsx": "//", ".ts": "//", ".tsx": "//", ".c": "//", ".cpp": "//", ".h": "//", ".hpp": "//", ".rs": "//", ".java": "//", ".kt": "//", ".swift": "//", ".scala": "//", ".cs": "//", ".py": "#", ".rb": "#", ".sh": "#", ".bash": "#", ".zsh": "#", ".yaml": "#", ".yml": "#", ".toml": "#", ".sql": "--", ".lua": "--", ".hs": "--", ".elm": "--", } // blockCommentDelims: extensions whose single-line markers may use a block comment. type blockDelim struct{ open, close string } var blockCommentDelims = map[string]blockDelim{ ".css": {"/*", "*/"}, ".html": {"<!--", "-->"}, ".xml": {"<!--", "-->"}, ".md": {"<!--", "-->"}, } // nameOverrides: filename → line-comment prefix when no useful extension exists. var nameOverrides = map[string]string{ "Makefile": "#", "makefile": "#", "Dockerfile": "#", "dockerfile": "#", } ``` Replace the body of `Scan` to: 1. Resolve a prefix using extension first, then `nameOverrides[filepath.Base(path)]`. 2. If neither a line-comment prefix nor a block delim is registered, return `nil`. 3. For each line: trim, then either strip a line-comment prefix, or match a single-line block comment (`open ... close` on the same line) and strip both delimiters from the body. Then run the existing tagged/untagged regex against the body. Concrete loop body: ```go for sc.Scan() { line++ raw := strings.TrimSpace(sc.Text()) var body string switch { case linePrefix != "" && strings.HasPrefix(raw, linePrefix): body = strings.TrimSpace(strings.TrimPrefix(raw, linePrefix)) case blockDelim.open != "" && strings.HasPrefix(raw, blockDelim.open) && strings.HasSuffix(raw, blockDelim.close): body = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, blockDelim.open), blockDelim.close)) default: continue } if m := taggedRE.FindStringSubmatch(body); m != nil { markers = append(markers, Marker{Line: line, Text: body, Tagged: true, UUID: m[1]}) continue } if untaggedRE.MatchString(body) { markers = append(markers, Marker{Line: line, Text: body}) } } ``` (Where `linePrefix` and `blockDelim` are resolved at the top of `Scan` from the tables above.) - [ ] **Step 4: Run tests to verify they pass** Run: `go test ./internal/scanner/ -v` Expected: all PASS. - [ ] **Step 5: Commit** ```bash git add internal/scanner/ git commit -m "feat(scanner): support multi-language comment syntax" ``` --- ### Task 5: Scanner — tagged marker skip + size cap + binary sniff **Files:** - Modify: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner.go` - Modify: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner_test.go` - [ ] **Step 1: Write failing tests** Append to `scanner_test.go`: ```go func TestScanTaggedMarkerNotFiredButReported(t *testing.T) { dir := t.TempDir() path := writeFile(t, dir, "foo.go", "// @claude[fw-a1b2c3d4] write a test\n") markers, err := Scan(path) if err != nil { t.Fatal(err) } if len(markers) != 1 { t.Fatalf("want 1 marker, got %d", len(markers)) } if !markers[0].Tagged || markers[0].UUID != "fw-a1b2c3d4" { t.Errorf("got %#v, want Tagged=true UUID=fw-a1b2c3d4", markers[0]) } } func TestScanLargeFileSkipped(t *testing.T) { dir := t.TempDir() big := strings.Repeat("// filler line\n", 80_000) // > 1 MB path := writeFile(t, dir, "big.go", big+"// @claude do it\n") markers, err := Scan(path) if err != nil { t.Fatal(err) } if markers != nil { t.Fatalf("expected nil for oversized file, got %#v", markers) } } func TestScanBinaryFileSkipped(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "blob.go") if err := os.WriteFile(path, []byte{0x00, 0x01, 0x02, '/', '/', ' ', '@', 'c', 'l', 'a', 'u', 'd', 'e', ' ', 'x'}, 0o644); err != nil { t.Fatal(err) } markers, err := Scan(path) if err != nil { t.Fatal(err) } if markers != nil { t.Fatalf("expected nil for binary file, got %#v", markers) } } ``` (`strings` already imported in test file from prior tasks. If not, add it.) - [ ] **Step 2: Run tests to verify they fail** Run: `go test ./internal/scanner/ -v` Expected: tagged-marker test passes already (regex matches both); size-cap and binary tests FAIL (no such checks). - [ ] **Step 3: Add size cap and binary sniff to `Scan`** At the top of `Scan`, after resolving the prefix: ```go const maxBytes = 1 << 20 // 1 MB st, err := os.Stat(path) if err != nil { return nil, err } if st.Size() > maxBytes { return nil, nil } // Binary sniff: read first 8 KB, look for null byte. sniffN := int64(8192) if st.Size() < sniffN { sniffN = st.Size() } sniff := make([]byte, sniffN) sf, err := os.Open(path) if err != nil { return nil, err } n, _ := sf.Read(sniff) sf.Close() if bytes.IndexByte(sniff[:n], 0) >= 0 { return nil, nil } ``` Add `"bytes"` import. - [ ] **Step 4: Run tests to verify they pass** Run: `go test ./internal/scanner/ -v` Expected: all PASS. - [ ] **Step 5: Commit** ```bash git add internal/scanner/ git commit -m "feat(scanner): tagged marker reporting, size cap, binary sniff" ``` --- ### Task 6: Rewriter — atomic UUID stamping **Files:** - Create: `/home/xanderle/code/rad/claudealong/internal/rewriter/rewriter.go` - Create: `/home/xanderle/code/rad/claudealong/internal/rewriter/rewriter_test.go` - [ ] **Step 1: Write failing test** `internal/rewriter/rewriter_test.go`: ```go package rewriter import ( "os" "path/filepath" "strings" "testing" ) func TestStampUUIDsInsertsTagAfterClaude(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "foo.go") original := "package foo\n\n// @claude write a test\nfunc Foo() {}\n" if err := os.WriteFile(path, []byte(original), 0o644); err != nil { t.Fatal(err) } err := StampUUIDs(path, map[int]string{3: "fw-a1b2c3d4"}) if err != nil { t.Fatalf("StampUUIDs: %v", err) } got, err := os.ReadFile(path) if err != nil { t.Fatal(err) } want := "package foo\n\n// @claude[fw-a1b2c3d4] write a test\nfunc Foo() {}\n" if string(got) != want { t.Errorf("got:\n%s\nwant:\n%s", got, want) } } func TestStampUUIDsPreservesIndentation(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "foo.go") original := "func F() {\n // @claude refactor\n}\n" if err := os.WriteFile(path, []byte(original), 0o644); err != nil { t.Fatal(err) } if err := StampUUIDs(path, map[int]string{2: "fw-deadbeef"}); err != nil { t.Fatal(err) } got, _ := os.ReadFile(path) if !strings.Contains(string(got), " // @claude[fw-deadbeef] refactor") { t.Errorf("indentation not preserved:\n%s", got) } } func TestStampUUIDsBlockComment(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "x.html") original := "<!-- @claude add aria -->\n<div></div>\n" if err := os.WriteFile(path, []byte(original), 0o644); err != nil { t.Fatal(err) } if err := StampUUIDs(path, map[int]string{1: "fw-12345678"}); err != nil { t.Fatal(err) } got, _ := os.ReadFile(path) want := "<!-- @claude[fw-12345678] add aria -->\n<div></div>\n" if string(got) != want { t.Errorf("got:\n%s\nwant:\n%s", got, want) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/rewriter/ -v` Expected: build error (`StampUUIDs` undefined). - [ ] **Step 3: Write implementation** `internal/rewriter/rewriter.go`: ```go package rewriter import ( "bufio" "errors" "fmt" "os" "path/filepath" "regexp" "strings" ) var ErrConcurrentModification = errors.New("file modified concurrently") // stampRE matches "@claude" not already followed by a "[fw-...]" tag. var stampRE = regexp.MustCompile(`@claude(?:\[fw-[0-9a-f]{8}\])?`) // StampUUIDs rewrites the file at path, splicing "[fw-XXXXXXXX]" immediately // after "@claude" on each given line. stamps maps line number (1-indexed) → UUID // (e.g. "fw-a1b2c3d4"). Aborts with ErrConcurrentModification if the file's // mtime changed between read and rename. File mode is preserved. func StampUUIDs(path string, stamps map[int]string) error { info, err := os.Stat(path) if err != nil { return err } mtimeBefore := info.ModTime() in, err := os.Open(path) if err != nil { return err } defer in.Close() dir := filepath.Dir(path) tmp, err := os.CreateTemp(dir, ".filewatch-*.tmp") if err != nil { return err } tmpPath := tmp.Name() cleanup := func() { tmp.Close() os.Remove(tmpPath) } sc := bufio.NewScanner(in) sc.Buffer(make([]byte, 1024*1024), 1024*1024) bw := bufio.NewWriter(tmp) line := 0 for sc.Scan() { line++ text := sc.Text() if uuid, ok := stamps[line]; ok { text = stampLine(text, uuid) } if _, err := bw.WriteString(text); err != nil { cleanup() return err } if _, err := bw.WriteString("\n"); err != nil { cleanup() return err } } if err := sc.Err(); err != nil { cleanup() return err } if err := bw.Flush(); err != nil { cleanup() return err } if err := tmp.Chmod(info.Mode().Perm()); err != nil { cleanup() return err } if err := tmp.Close(); err != nil { os.Remove(tmpPath) return err } // mtime check before atomic rename info2, err := os.Stat(path) if err != nil { os.Remove(tmpPath) return err } if !info2.ModTime().Equal(mtimeBefore) { os.Remove(tmpPath) return ErrConcurrentModification } if err := os.Rename(tmpPath, path); err != nil { os.Remove(tmpPath) return fmt.Errorf("rename: %w", err) } return nil } func stampLine(text, uuid string) string { // Replace only the first "@claude" that is not already tagged. return stampRE.ReplaceAllStringFunc(text, func(m string) string { if strings.Contains(m, "[fw-") { return m // already tagged, leave it } return "@claude[" + uuid + "]" }) } ``` Note: `stampLine` replaces every untagged `@claude` on the line. There should only be one per line in practice, and stamping all of them is harmless. But wait — `ReplaceAllStringFunc` runs the func for every match. We need stampLine to only stamp the first untagged one. Refine: ```go func stampLine(text, uuid string) string { done := false return stampRE.ReplaceAllStringFunc(text, func(m string) string { if done || strings.Contains(m, "[fw-") { return m } done = true return "@claude[" + uuid + "]" }) } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `go test ./internal/rewriter/ -v` Expected: all PASS. - [ ] **Step 5: Commit** ```bash git add internal/rewriter/ git commit -m "feat(rewriter): atomic UUID stamping with mode preservation" ``` --- ### Task 7: Rewriter — mtime-conflict abort **Files:** - Modify: `/home/xanderle/code/rad/claudealong/internal/rewriter/rewriter_test.go` - [ ] **Step 1: Write failing test** Append to `rewriter_test.go`: ```go import "time" // add to imports if not present func TestStampUUIDsAbortsOnConcurrentModification(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "foo.go") if err := os.WriteFile(path, []byte("// @claude do it\n"), 0o644); err != nil { t.Fatal(err) } // Pre-set mtime to a known past value, then mutate the file just before // StampUUIDs would rename, by hooking via a goroutine isn't reliable. // Instead: change mtime AFTER initial Stat by editing the file in-test. // We do this by wrapping: write again to bump mtime to "now", then call // StampUUIDs which read an older Stat. // // Easiest deterministic approach: set the file's mtime artificially old, // call StampUUIDs (which records that mtime), then bump mtime, then — // we can't because StampUUIDs runs synchronously. // // Instead, test the helper boundary: temporarily expose an internal // function that takes a "frozen mtimeBefore" and verify it aborts when // the on-disk mtime differs. // // Simpler alternative used here: write a helper that sets the file's // mtime to an old value, then concurrently writes during StampUUIDs by // using a small file and racing — flaky. Skip the goroutine race; test // the explicit error path via direct manipulation. // Touch the file with a future mtime, then artificially "go back" by // calling StampUUIDs after manually rewinding our captured mtime. We // do this by bumping mtime AFTER os.Stat inside StampUUIDs runs. // // The cleanest deterministic test: factor StampUUIDs to take a hook // for "between-stat-and-rename" — but that bloats the API. Instead: // pre-set mtime to T-1s, call StampUUIDs, then immediately also call // os.Chtimes from this test goroutine in a tight loop. Race window is // wide enough for a small file. If flaky, revisit. past := time.Now().Add(-1 * time.Hour) if err := os.Chtimes(path, past, past); err != nil { t.Fatal(err) } done := make(chan error, 1) go func() { done <- StampUUIDs(path, map[int]string{1: "fw-aaaaaaaa"}) }() // Hammer Chtimes to ensure it runs between StampUUIDs's Stat and Rename. deadline := time.Now().Add(500 * time.Millisecond) for time.Now().Before(deadline) { os.Chtimes(path, time.Now(), time.Now()) } err := <-done if err != nil && !errors.Is(err, ErrConcurrentModification) { t.Logf("StampUUIDs returned %v (acceptable: nil or ErrConcurrentModification)", err) } // We don't assert which: race timing decides. The test mainly verifies // ErrConcurrentModification is a real, exported error and the code path // can return it without panicking. _ = err } ``` (Add `"errors"` import.) This test is intentionally non-strict because the race is timing-dependent. The deterministic version of this assertion comes from a more invasive refactor; we accept the looser test for now. - [ ] **Step 2: Run test** Run: `go test ./internal/rewriter/ -run TestStampUUIDsAbortsOnConcurrentModification -v` Expected: PASS (no panic; either error is acceptable per test logic). - [ ] **Step 3: If the test never exercises the error path, add a deterministic test** Refactor `StampUUIDs` to extract the post-write rename step into an unexported helper that takes the captured `mtimeBefore`, and write a unit test that calls the helper directly with a stale mtime to assert `ErrConcurrentModification`. ```go // In rewriter.go, factor out: func atomicReplace(path, tmpPath string, mode os.FileMode, mtimeBefore time.Time) error { info, err := os.Stat(path) if err != nil { os.Remove(tmpPath) return err } if !info.ModTime().Equal(mtimeBefore) { os.Remove(tmpPath) return ErrConcurrentModification } if err := os.Rename(tmpPath, path); err != nil { os.Remove(tmpPath) return fmt.Errorf("rename: %w", err) } return nil } ``` And update `StampUUIDs` to call `atomicReplace` instead of inlining those steps. Then in test (replace the flaky test above): ```go func TestAtomicReplaceAbortsOnStaleMtime(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "foo.go") if err := os.WriteFile(path, []byte("hi\n"), 0o644); err != nil { t.Fatal(err) } tmpPath := filepath.Join(dir, "tmp") if err := os.WriteFile(tmpPath, []byte("bye\n"), 0o644); err != nil { t.Fatal(err) } stale := time.Now().Add(-time.Hour) err := atomicReplace(path, tmpPath, 0o644, stale) if !errors.Is(err, ErrConcurrentModification) { t.Fatalf("got %v, want ErrConcurrentModification", err) } } ``` - [ ] **Step 4: Run tests** Run: `go test ./internal/rewriter/ -v` Expected: all PASS. - [ ] **Step 5: Commit** ```bash git add internal/rewriter/ git commit -m "test(rewriter): cover concurrent-modification abort path" ``` --- ### Task 8: Ignore — gitignore + hard-excludes + reload **Files:** - Create: `/home/xanderle/code/rad/claudealong/internal/ignore/ignore.go` - Create: `/home/xanderle/code/rad/claudealong/internal/ignore/ignore_test.go` - [ ] **Step 1: Add dependency** ```bash go get github.com/sabhiram/go-gitignore ``` - [ ] **Step 2: Write failing tests** `internal/ignore/ignore_test.go`: ```go package ignore import ( "os" "path/filepath" "testing" ) func writeGitignore(t *testing.T, dir, content string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(content), 0o644); err != nil { t.Fatal(err) } } func TestHardExcludes(t *testing.T) { dir := t.TempDir() m, err := New(dir) if err != nil { t.Fatal(err) } cases := []struct { path string want bool }{ {filepath.Join(dir, ".git", "HEAD"), true}, {filepath.Join(dir, "node_modules", "x", "y.js"), true}, {filepath.Join(dir, "dist", "out.js"), true}, {filepath.Join(dir, "build", "out.o"), true}, {filepath.Join(dir, "target", "x"), true}, {filepath.Join(dir, ".venv", "bin", "python"), true}, {filepath.Join(dir, "src", "foo.go"), false}, {filepath.Join(dir, "foo.swp"), true}, {filepath.Join(dir, "lock.lock"), true}, {filepath.Join(dir, "a.tmp"), true}, } for _, c := range cases { if got := m.ShouldIgnore(c.path); got != c.want { t.Errorf("ShouldIgnore(%q) = %v, want %v", c.path, got, c.want) } } } func TestGitignorePatterns(t *testing.T) { dir := t.TempDir() writeGitignore(t, dir, "secret.txt\n*.bak\n") m, err := New(dir) if err != nil { t.Fatal(err) } if !m.ShouldIgnore(filepath.Join(dir, "secret.txt")) { t.Error("secret.txt should be ignored") } if !m.ShouldIgnore(filepath.Join(dir, "old.bak")) { t.Error("old.bak should be ignored") } if m.ShouldIgnore(filepath.Join(dir, "ok.txt")) { t.Error("ok.txt should NOT be ignored") } } func TestReloadPicksUpNewPatterns(t *testing.T) { dir := t.TempDir() writeGitignore(t, dir, "") m, err := New(dir) if err != nil { t.Fatal(err) } p := filepath.Join(dir, "fresh.txt") if m.ShouldIgnore(p) { t.Fatal("fresh.txt should not be ignored before reload") } writeGitignore(t, dir, "fresh.txt\n") if err := m.Reload(); err != nil { t.Fatal(err) } if !m.ShouldIgnore(p) { t.Error("fresh.txt should be ignored after reload") } } ``` - [ ] **Step 3: Run tests to verify they fail** Run: `go test ./internal/ignore/ -v` Expected: build error. - [ ] **Step 4: Implement** `internal/ignore/ignore.go`: ```go package ignore import ( "os" "path/filepath" "strings" "sync" gitignore "github.com/sabhiram/go-gitignore" ) var hardExcludeDirs = []string{ ".git", "node_modules", "dist", "build", "target", ".venv", } var hardExcludeSuffixes = []string{ ".swp", ".tmp", ".lock", } type Matcher struct { root string mu sync.RWMutex gi *gitignore.GitIgnore } func New(root string) (*Matcher, error) { m := &Matcher{root: root} if err := m.Reload(); err != nil { return nil, err } return m, nil } func (m *Matcher) Reload() error { path := filepath.Join(m.root, ".gitignore") var gi *gitignore.GitIgnore if _, err := os.Stat(path); err == nil { loaded, err := gitignore.CompileIgnoreFile(path) if err != nil { return err } gi = loaded } m.mu.Lock() m.gi = gi m.mu.Unlock() return nil } func (m *Matcher) ShouldIgnore(path string) bool { rel, err := filepath.Rel(m.root, path) if err != nil || strings.HasPrefix(rel, "..") { return true } parts := strings.Split(rel, string(filepath.Separator)) for _, p := range parts { for _, ex := range hardExcludeDirs { if p == ex { return true } } } base := filepath.Base(rel) for _, sfx := range hardExcludeSuffixes { if strings.HasSuffix(base, sfx) { return true } } m.mu.RLock() gi := m.gi m.mu.RUnlock() if gi != nil && gi.MatchesPath(rel) { return true } return false } ``` - [ ] **Step 5: Run tests to verify they pass** Run: `go test ./internal/ignore/ -v` Expected: all PASS. - [ ] **Step 6: Commit** ```bash git add go.mod go.sum internal/ignore/ git commit -m "feat(ignore): gitignore + hard-excludes with live reload" ``` --- ### Task 9: Watcher — fsnotify with full op mask + inode re-watch **Files:** - Create: `/home/xanderle/code/rad/claudealong/internal/watcher/watcher.go` - Create: `/home/xanderle/code/rad/claudealong/internal/watcher/watcher_test.go` - [ ] **Step 1: Add dependency** ```bash go get github.com/fsnotify/fsnotify ``` - [ ] **Step 2: Write failing tests** `internal/watcher/watcher_test.go`: ```go package watcher import ( "context" "os" "path/filepath" "testing" "time" "github.com/xanderle/claudealong/internal/ignore" ) func newTestMatcher(t *testing.T, dir string) *ignore.Matcher { t.Helper() m, err := ignore.New(dir) if err != nil { t.Fatal(err) } return m } func TestWatcherFiresOnInPlaceWrite(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "a.go") if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { t.Fatal(err) } w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond) if err != nil { t.Fatal(err) } defer w.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := w.Start(ctx); err != nil { t.Fatal(err) } // Trigger WRITE time.Sleep(20 * time.Millisecond) if err := os.WriteFile(p, []byte("y"), 0o644); err != nil { t.Fatal(err) } select { case ev := <-w.Events(): if ev.Path != p { t.Errorf("got path %q, want %q", ev.Path, p) } case <-time.After(2 * time.Second): t.Fatal("no event received") } } func TestWatcherFiresOnAtomicSave(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "a.go") if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { t.Fatal(err) } w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond) if err != nil { t.Fatal(err) } defer w.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := w.Start(ctx); err != nil { t.Fatal(err) } time.Sleep(20 * time.Millisecond) // Atomic-save: write tmpfile + rename over original tmp := filepath.Join(dir, ".a.go.tmp") if err := os.WriteFile(tmp, []byte("z"), 0o644); err != nil { t.Fatal(err) } if err := os.Rename(tmp, p); err != nil { t.Fatal(err) } select { case ev := <-w.Events(): if ev.Path != p { t.Errorf("got path %q, want %q", ev.Path, p) } case <-time.After(2 * time.Second): t.Fatal("no event after atomic save (inode re-watch broken?)") } } func TestWatcherDebouncesBurst(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "a.go") if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { t.Fatal(err) } w, err := New(dir, newTestMatcher(t, dir), 200*time.Millisecond) if err != nil { t.Fatal(err) } defer w.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := w.Start(ctx); err != nil { t.Fatal(err) } time.Sleep(50 * time.Millisecond) // Burst of writes within debounce window for i := 0; i < 5; i++ { os.WriteFile(p, []byte{'a' + byte(i)}, 0o644) time.Sleep(20 * time.Millisecond) } // Drain for 500ms; expect exactly 1 event count := 0 deadline := time.After(500 * time.Millisecond) loop: for { select { case <-w.Events(): count++ case <-deadline: break loop } } if count != 1 { t.Errorf("got %d events, want 1 (debounced)", count) } } func TestWatcherSkipsIgnoredPaths(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "node_modules"), 0o755); err != nil { t.Fatal(err) } p := filepath.Join(dir, "node_modules", "a.go") if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { t.Fatal(err) } w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond) if err != nil { t.Fatal(err) } defer w.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() if err := w.Start(ctx); err != nil { t.Fatal(err) } time.Sleep(50 * time.Millisecond) os.WriteFile(p, []byte("y"), 0o644) select { case ev := <-w.Events(): t.Fatalf("unexpected event for ignored path: %q", ev.Path) case <-time.After(300 * time.Millisecond): // pass } } ``` - [ ] **Step 3: Run tests to verify they fail** Run: `go test ./internal/watcher/ -v` Expected: build error. - [ ] **Step 4: Implement** `internal/watcher/watcher.go`: ```go package watcher import ( "context" "errors" "os" "path/filepath" "sync" "time" "github.com/fsnotify/fsnotify" "github.com/xanderle/claudealong/internal/ignore" ) type Event struct { Path string } type Watcher struct { root string ig *ignore.Matcher debounce time.Duration fs *fsnotify.Watcher events chan Event mu sync.Mutex pending map[string]*time.Timer closed bool } func New(root string, ig *ignore.Matcher, debounce time.Duration) (*Watcher, error) { fw, err := fsnotify.NewWatcher() if err != nil { return nil, err } return &Watcher{ root: root, ig: ig, debounce: debounce, fs: fw, events: make(chan Event, 16), pending: make(map[string]*time.Timer), }, nil } func (w *Watcher) Events() <-chan Event { return w.events } func (w *Watcher) Start(ctx context.Context) error { if err := w.addRecursive(w.root); err != nil { return err } go w.run(ctx) return nil } func (w *Watcher) addRecursive(root string) error { return filepath.Walk(root, func(p string, info os.FileInfo, err error) error { if err != nil { return nil // best-effort } if !info.IsDir() { return nil } if w.ig.ShouldIgnore(p) { return filepath.SkipDir } // Skip symlinked dirs if info.Mode()&os.ModeSymlink != 0 { return filepath.SkipDir } return w.fs.Add(p) }) } func (w *Watcher) run(ctx context.Context) { defer close(w.events) for { select { case <-ctx.Done(): return case ev, ok := <-w.fs.Events: if !ok { return } w.handle(ev) case _, ok := <-w.fs.Errors: if !ok { return } } } } func (w *Watcher) handle(ev fsnotify.Event) { if w.ig.ShouldIgnore(ev.Name) { return } info, err := os.Lstat(ev.Name) if err == nil && info.Mode()&os.ModeSymlink != 0 { return // skip symlinks } // Re-watch on rename/create of a directory if ev.Op&(fsnotify.Create) != 0 && err == nil && info.IsDir() { _ = w.addRecursive(ev.Name) return } if ev.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 { return } if err == nil && info.IsDir() { return } w.scheduleEvent(ev.Name) } func (w *Watcher) scheduleEvent(path string) { w.mu.Lock() defer w.mu.Unlock() if w.closed { return } if t, ok := w.pending[path]; ok { t.Stop() } w.pending[path] = time.AfterFunc(w.debounce, func() { w.mu.Lock() delete(w.pending, path) closed := w.closed w.mu.Unlock() if closed { return } select { case w.events <- Event{Path: path}: default: // drop if buffer full } }) } func (w *Watcher) Close() error { w.mu.Lock() w.closed = true for _, t := range w.pending { t.Stop() } w.mu.Unlock() if err := w.fs.Close(); err != nil && !errors.Is(err, os.ErrClosed) { return err } return nil } ``` Note on inode re-watch: fsnotify on Linux internally re-resolves on `Create` events for paths that match an existing watch under their parent directory. By watching directories (not files) recursively, atomic saves (which create a new file at the old name) deliver `Create` for the path under its parent's watch. The `addRecursive` on directory-create handles the case where a new directory appears. - [ ] **Step 5: Run tests to verify they pass** Run: `go test ./internal/watcher/ -v` Expected: all PASS. - [ ] **Step 6: Commit** ```bash git add go.mod go.sum internal/watcher/ git commit -m "feat(watcher): fsnotify with WRITE+CREATE+RENAME, debounce, ignore" ``` --- ### Task 10: MCP wiring — emit notification on fired marker **Files:** - Create: `/home/xanderle/code/rad/claudealong/internal/mcp/mcp.go` - Create: `/home/xanderle/code/rad/claudealong/internal/mcp/mcp_test.go` - [ ] **Step 1: Write failing test** `internal/mcp/mcp_test.go`: ```go package mcp import ( "bytes" "context" "encoding/json" "io" "strings" "testing" ) // readFrames reads newline-delimited JSON-RPC frames from r until it has count // non-empty frames or EOF. func readFrames(t *testing.T, r io.Reader, count int) []map[string]any { t.Helper() var out []map[string]any dec := json.NewDecoder(r) for len(out) < count { var f map[string]any if err := dec.Decode(&f); err != nil { if err == io.EOF { break } t.Fatalf("decode: %v", err) } out = append(out, f) } return out } func TestSendChannelEmitsCorrectFrame(t *testing.T) { var buf bytes.Buffer s := NewWithIO(strings.NewReader(""), &buf) // test-only constructor err := s.SendChannel(context.Background(), ChannelMeta{ Source: "filewatch", File: "src/foo.ts", Line: 42, ReplyTo: "fw-a1b2c3d4", }, "@claude write a test for foo") if err != nil { t.Fatal(err) } var frame map[string]any if err := json.NewDecoder(&buf).Decode(&frame); err != nil { t.Fatal(err) } if frame["jsonrpc"] != "2.0" { t.Errorf("jsonrpc = %v, want 2.0", frame["jsonrpc"]) } if frame["method"] != "notifications/claude/channel" { t.Errorf("method = %v, want notifications/claude/channel", frame["method"]) } params := frame["params"].(map[string]any) if params["content"] != "@claude write a test for foo" { t.Errorf("content = %v", params["content"]) } meta := params["meta"].(map[string]any) if meta["source"] != "filewatch" || meta["file"] != "src/foo.ts" || meta["replyTo"] != "fw-a1b2c3d4" { t.Errorf("meta wrong: %#v", meta) } // line should be a number, not a string if l, ok := meta["line"].(float64); !ok || l != 42 { t.Errorf("line = %v (%T), want 42 (number)", meta["line"], meta["line"]) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `go test ./internal/mcp/ -v` Expected: build error. - [ ] **Step 3: Implement** `internal/mcp/mcp.go`: ```go package mcp import ( "context" "encoding/json" "io" "os" "sync" ) type ChannelMeta struct { Source string `json:"source"` File string `json:"file"` Line int `json:"line"` ReplyTo string `json:"replyTo"` } type Server struct { in io.Reader out io.Writer mu sync.Mutex // serializes writes to stdout } func New() *Server { return &Server{in: os.Stdin, out: os.Stdout} } // NewWithIO is for tests. func NewWithIO(in io.Reader, out io.Writer) *Server { return &Server{in: in, out: out} } type notification struct { Jsonrpc string `json:"jsonrpc"` Method string `json:"method"` Params map[string]any `json:"params"` } func (s *Server) SendChannel(ctx context.Context, meta ChannelMeta, content string) error { n := notification{ Jsonrpc: "2.0", Method: "notifications/claude/channel", Params: map[string]any{ "content": content, "meta": map[string]any{ "source": meta.Source, "file": meta.File, "line": meta.Line, "replyTo": meta.ReplyTo, }, }, } s.mu.Lock() defer s.mu.Unlock() enc := json.NewEncoder(s.out) return enc.Encode(n) } // Run drives the MCP request loop: reads JSON-RPC requests on s.in, // responds with minimal init/initialized handshake, then reads-and-discards // any further requests (we have no tools in v1). Notifications are written // concurrently via SendChannel. func (s *Server) Run(ctx context.Context) error { dec := json.NewDecoder(s.in) for { if ctx.Err() != nil { return nil } var req map[string]any if err := dec.Decode(&req); err != nil { if err == io.EOF { return nil } return err } method, _ := req["method"].(string) id := req["id"] if id == nil { continue // notification, ignore } var result any switch method { case "initialize": result = map[string]any{ "protocolVersion": "2024-11-05", "capabilities": map[string]any{}, "serverInfo": map[string]any{ "name": "filewatch-mcp", "version": "0.1.0", }, } default: // Method not found s.writeError(id, -32601, "method not found") continue } s.writeResult(id, result) } } func (s *Server) writeResult(id, result any) { s.mu.Lock() defer s.mu.Unlock() json.NewEncoder(s.out).Encode(map[string]any{ "jsonrpc": "2.0", "id": id, "result": result, }) } func (s *Server) writeError(id any, code int, msg string) { s.mu.Lock() defer s.mu.Unlock() json.NewEncoder(s.out).Encode(map[string]any{ "jsonrpc": "2.0", "id": id, "error": map[string]any{"code": code, "message": msg}, }) } ``` **Note:** This implementation deliberately does NOT use the SDK from Task 2 — Task 2 confirmed feasibility, but for the actual production server we own the JSON-RPC framing because the only outbound message we need is a single custom notification, and direct JSON-RPC is simpler than working through SDK escape hatches. If Task 2's SDK approach ended up working cleanly, you may instead replace this file with an SDK-based version; the test's expectations on the wire format are the contract. - [ ] **Step 4: Run test to verify it passes** Run: `go test ./internal/mcp/ -v` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add internal/mcp/ git commit -m "feat(mcp): emit notifications/claude/channel via direct JSON-RPC" ``` --- ### Task 11: Wire it all together in `cmd/filewatch-mcp/main.go` **Files:** - Modify: `/home/xanderle/code/rad/claudealong/cmd/filewatch-mcp/main.go` - [ ] **Step 1: Replace main.go with wired version** ```go package main import ( "context" "flag" "log" "os" "os/signal" "syscall" "time" "github.com/google/uuid" "github.com/xanderle/claudealong/internal/ignore" "github.com/xanderle/claudealong/internal/mcp" "github.com/xanderle/claudealong/internal/rewriter" "github.com/xanderle/claudealong/internal/scanner" "github.com/xanderle/claudealong/internal/watcher" ) func main() { root := flag.String("root", ".", "project root to watch") debounce := flag.Duration("debounce", 500*time.Millisecond, "per-file debounce window") flag.Parse() ig, err := ignore.New(*root) if err != nil { log.Fatalf("ignore: %v", err) } w, err := watcher.New(*root, ig, *debounce) if err != nil { log.Fatalf("watcher: %v", err) } defer w.Close() srv := mcp.New() ctx, cancel := context.WithCancel(context.Background()) defer cancel() sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) go func() { <-sigCh; cancel() }() if err := w.Start(ctx); err != nil { log.Fatalf("watcher start: %v", err) } go consume(ctx, w, srv) if err := srv.Run(ctx); err != nil { log.Fatalf("mcp run: %v", err) } } func consume(ctx context.Context, w *watcher.Watcher, srv *mcp.Server) { for { select { case <-ctx.Done(): return case ev, ok := <-w.Events(): if !ok { return } handle(ctx, ev.Path, srv) } } } func handle(ctx context.Context, path string, srv *mcp.Server) { markers, err := scanner.Scan(path) if err != nil { log.Printf("scan %s: %v", path, err) return } stamps := make(map[int]string, len(markers)) for _, m := range markers { if m.Tagged { continue } id := "fw-" + uuid.NewString()[:8] stamps[m.Line] = id if err := srv.SendChannel(ctx, mcp.ChannelMeta{ Source: "filewatch", File: path, Line: m.Line, ReplyTo: id, }, m.Text); err != nil { log.Printf("send: %v", err) } } if len(stamps) == 0 { return } if err := rewriter.StampUUIDs(path, stamps); err != nil { log.Printf("stamp %s: %v", path, err) } } ``` - [ ] **Step 2: Add UUID dependency** ```bash go get github.com/google/uuid ``` - [ ] **Step 3: Build** Run: `make build` Expected: clean build. - [ ] **Step 4: Commit** ```bash git add go.mod go.sum cmd/ git commit -m "feat: wire watcher → scanner → mcp → rewriter in main" ``` --- ### Task 12: End-to-end integration test **Files:** - Create: `/home/xanderle/code/rad/claudealong/cmd/filewatch-mcp/integration_test.go` - [ ] **Step 1: Write integration test** ```go package main import ( "bufio" "encoding/json" "io" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) // TestEndToEnd boots the binary as a subprocess, simulates a save, and // verifies a notifications/claude/channel frame appears on stdout AND the // file gets rewritten with [fw-XXXXXXXX]. func TestEndToEnd(t *testing.T) { if testing.Short() { t.Skip("integration") } if _, err := exec.LookPath("go"); err != nil { t.Skip("go toolchain not on PATH") } dir := t.TempDir() target := filepath.Join(dir, "foo.go") if err := os.WriteFile(target, []byte("package foo\n"), 0o644); err != nil { t.Fatal(err) } cmd := exec.Command("go", "run", ".", "--root", dir, "--debounce", "100ms") stdin, err := cmd.StdinPipe() if err != nil { t.Fatal(err) } stdout, err := cmd.StdoutPipe() if err != nil { t.Fatal(err) } cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { t.Fatal(err) } defer func() { stdin.Close() cmd.Process.Kill() cmd.Wait() }() // Send initialize so srv.Run completes the handshake. fmt.Fprint(stdin, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`+"\n") // Wait for init result then trigger a save. sc := bufio.NewScanner(stdout) sc.Buffer(make([]byte, 1<<16), 1<<16) if !sc.Scan() { t.Fatal("no init response") } // Now trigger the save (atomic rename). tmp := target + ".tmp" if err := os.WriteFile(tmp, []byte("// @claude please review\npackage foo\n"), 0o644); err != nil { t.Fatal(err) } if err := os.Rename(tmp, target); err != nil { t.Fatal(err) } // Look for the channel notification in subsequent frames. deadline := time.Now().Add(5 * time.Second) var sawChannel bool for time.Now().Before(deadline) { if !sc.Scan() { break } var f map[string]any if err := json.Unmarshal(sc.Bytes(), &f); err != nil { continue } if f["method"] == "notifications/claude/channel" { sawChannel = true break } } if !sawChannel { t.Fatal("no notifications/claude/channel frame on stdout") } // Verify the file got stamped. got, _ := os.ReadFile(target) if !strings.Contains(string(got), "@claude[fw-") { t.Errorf("file not stamped:\n%s", got) } } ``` (Add `"fmt"` to imports.) - [ ] **Step 2: Run integration test** Run: `go test ./cmd/filewatch-mcp/ -run TestEndToEnd -v` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add cmd/filewatch-mcp/ git commit -m "test: end-to-end save-to-stamp integration" ``` --- ### Task 13: README + .mcp.json template **Files:** - Create: `/home/xanderle/code/rad/claudealong/README.md` - Create: `/home/xanderle/code/rad/claudealong/.mcp.json` - [ ] **Step 1: Write `.mcp.json` template** ```json { "mcpServers": { "filewatch": { "command": "filewatch-mcp", "args": ["--root", "."] } } } ``` - [ ] **Step 2: Write README.md** Sections to cover: - What it does (one paragraph: write `@claude X` in a comment, save, Claude in your running session sees it). - Install: `make install`. - Configure: copy `.mcp.json` into your project, run Claude Code with `--channels`. - How dedupe works (UUID stamping in-place — explain editor-reload caveat for JetBrains). - Supported languages (list from scanner table). - Limitations (single-line comments only, no multi-session). - [ ] **Step 3: Commit** ```bash git add README.md .mcp.json git commit -m "docs: README and .mcp.json template" ``` --- ### Task 14: Manual smoke test - [ ] **Step 1: Install binary** ```bash make install ``` - [ ] **Step 2: Set up a test project** Create or pick a small Go/Python project. Add `.mcp.json` from this repo. Start Claude Code with `--channels` in that project. - [ ] **Step 3: Add a marker and save** In some source file, type `// @claude what does this function do` above a function. Save. - [ ] **Step 4: Verify** - A `<channel source="filewatch" ...>` block appears in the Claude Code session. - Claude reads the file and answers in the session. - Claude removes the marker line via `Edit`. - The file no longer contains `@claude` (it was either removed or stamped+removed). - [ ] **Step 5: Repeat with each editor** VS Code, Vim, JetBrains. Confirm: - VS Code auto-reloads cleanly. - Vim with `set autoread` reloads cleanly. - JetBrains prompts on external change — accept the reload. - [ ] **Step 6: If smoke fails** File issues per editor in `docs/superpowers/specs/`. The most likely failure modes: - JetBrains atomic save not delivering Create event in time → bump debounce. - VS Code's "files.autoSave: afterDelay" causing burst → bump debounce. - Claude Code session doesn't show the channel block → confirm `--channels` is enabled and the SDK spike (Task 2) is still working. --- ## Self-review notes (post-write) - **Spec coverage:** - Architecture diagram → Tasks 1, 9–11 ✓ - Inline UUID stamping → Tasks 6–7, 11 ✓ - fsnotify op mask + inode re-watch → Task 9 ✓ - SDK spike → Task 2 ✓ - Editor reload risk → covered in Task 14 manual smoke ✓ - Single-line comments only → enforced by scanner regex (Task 4); documented in Task 13 README ✓ - Size cap + binary sniff → Task 5 ✓ - Symlinks not followed → Task 9 watcher ✓ - `.gitignore` + hard-excludes + reload → Task 8 ✓ - **Type consistency:** `Marker.UUID` introduced in Task 5 used unchanged in Task 11; `ChannelMeta` field types match across mcp_test.go and main.go; `StampUUIDs(path, map[int]string)` signature consistent across tasks. - **Notes:** - Task 10 deliberately bypasses the official Go SDK in favor of direct JSON-RPC framing. The Task 2 spike validates the channel mechanism end-to-end; the production server then owns its framing because it only needs one custom notification and an init handshake. This trades SDK ergonomics for transparency. If you prefer SDK-mediated framing, the test contract in Task 10 (the wire-format assertions) is what must hold. - Task 7's flaky timing test is replaced inline by a deterministic helper-level test; the original is left in the plan for context, but you should land only the deterministic version. diff --git a/docs/superpowers/specs/2026-04-28-claude-channel-filewatch-design.md b/docs/superpowers/specs/2026-04-28-claude-channel-filewatch-design.md new file mode 100644 index 0000000..48cda21 --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-claude-channel-filewatch-design.md @@ -0,0 +1,209 @@ # `@claude` File-Watcher MCP — Design **Date:** 2026-04-28 **Status:** Draft, ready for review ## Goal Let a developer write `@claude <ask>` inside a code comment, save the file, and have a running Claude Code session pick up the ask automatically — with file path, line number, and the comment text — then handle it (answer in-session for questions, edit the file for work asks). Claude removes the marker as part of handling. ## Scope Solo, local. Single developer machine. One Claude Code session running. Single project directory under watch. Out of scope for v1: multi-user / team / shared bot, remote triggering (`RemoteTrigger`), GitHub PR integration, response-back-into-the-file workflows, multi-line block-comment markers (single-line comments only). ## Architecture ``` ┌──────────────────────────────────────┐ │ Claude Code session │ │ │ │ receives <channel> notifications │ │ reads file, edits, removes marker │ └──────────────┬───────────────────────┘ │ stdio (MCP) ┌──────────────▼───────────────────────┐ │ filewatch-mcp (Go binary) │ │ │ │ • fsnotify watcher (WRITE+CREATE+ │ │ RENAME, re-watch on rename) │ │ • scanner: comment-syntax table, │ │ distinguishes tagged vs. untagged │ │ • rewriter: atomic UUID stamping │ │ • mcp: notifications/claude/channel │ └──────────────────────────────────────┘ ``` Single Go binary spawned by Claude Code as an MCP child process over stdio. No state file, no daemons, no IRC. ## Key design decision: file is the state Untagged marker `// @claude write a test` is the source of "this needs handling." On first detection, the watcher rewrites the line in place to `// @claude[fw-a1b2c3d4] write a test`. The bracketed UUID **is** the dedupe state. Tagged markers are ignored on subsequent scans. Claude removes the whole tagged line when handling the ask, returning the file to a clean state. Consequences: - No `state.json`, no `flock`, no multi-session race. - No feedback-loop suppression: Claude's `Edit` triggers fsnotify, scanner sees a tagged (or absent) marker, no fire. - The watcher modifies the user's file. Editors will detect the on-disk change and either auto-reload (VS Code, Vim with `autoread`) or prompt (JetBrains). Documented in Open Risks. ## Components ### `internal/watcher` - Wraps `fsnotify` with op mask `WRITE | CREATE | RENAME`. - Atomic saves (tmpfile + rename, used by Vim, JetBrains, VS Code with `files.atomicSave`) deliver `RENAME`/`CREATE`, not `WRITE` — all three must trigger a scan. - On Linux, fsnotify watches inodes; after a rename-replace the watch points at the deleted inode. Watcher must re-establish the watch on the new path when a `RENAME` arrives. - 500 ms per-file trailing-edge debounce (JetBrains autosave bursts >200 ms apart). - Symlinks: not followed. Watcher uses `Lstat` and skips them. - Respects `.gitignore` plus hard-excludes: `.git/`, `node_modules/`, `dist/`, `build/`, `target/`, `.venv/`, `*.swp`, `*.tmp`, `*.lock`. ### `internal/scanner` - Per-extension comment-syntax table: - `//` — js, ts, jsx, tsx, go, c, cpp, h, hpp, rs, java, kt, swift, scala, cs - `#` — py, rb, sh, bash, zsh, yaml, yml, toml, dockerfile, makefile (matched by name) - `--` — sql, lua, hs, elm - `/* */` block — c-family, css (single-line `/* ... */` only; multi-line out of scope) - `<!-- -->` — html, xml, md (single-line only) - Unrecognized extensions are skipped. - Size cap: files > 1 MB are skipped. - Binary sniff: if the first 8 KB contains a null byte, skip. - A line is a marker iff: it is a single-line comment in the file's language **and** matches one of: - **Untagged:** `@claude` followed by whitespace + non-empty payload. Fires. - **Tagged:** `@claude[fw-XXXXXXXX]` (8 hex chars) followed by whitespace + payload. Skipped. - Returns `[]Marker{ Line int, Text string, Tagged bool }`. `Text` is the marker payload starting at `@claude`, with comment delimiters and trailing block-comment terminators (`*/`, `-->`) stripped. ### `internal/rewriter` - Given a file path and a list of untagged markers (line numbers + UUIDs to stamp), rewrites the file: 1. Read whole file. 2. For each marker line, splice `[fw-XXXXXXXX]` immediately after `@claude` (preserving leading whitespace, comment delimiter, and the rest of the line). 3. Write to a sibling tmpfile in the same directory. 4. `os.Rename` over the original. Atomic on POSIX. - Preserves file mode (`Stat` → `Chmod`). - Aborts and skips the rewrite if the file's mtime changed between read and rename (someone — likely the user's editor — wrote to it concurrently). The marker stays untagged and will be re-attempted on the next save. ### `internal/mcp` - MCP stdio server using `github.com/modelcontextprotocol/go-sdk`. - For each fired (untagged) marker, emits one outbound notification: ```json { "jsonrpc": "2.0", "method": "notifications/claude/channel", "params": { "content": "@claude can you write a test for foo", "meta": { "source": "filewatch", "file": "src/foo.ts", "line": 42, "replyTo": "fw-a1b2c3d4" } } } ``` Claude renders this as `<channel source="filewatch" file="src/foo.ts" line="42" replyTo="fw-a1b2c3d4">@claude can you write a test for foo</channel>`. The `replyTo` UUID matches the bracketed tag stamped into the file. - No reply tool exposed in v1. Claude's `Edit` to remove the marker is the implicit ack. ### `internal/ignore` - `.gitignore` parsing via `github.com/sabhiram/go-gitignore`. - Hard-exclude list takes precedence. - `.gitignore` re-read on its own change events (so updates are picked up live). ## Data Flow 1. User edits `src/foo.ts`, adds `// @claude write a test for foo`, saves. 2. Editor writes via tmpfile + rename → fsnotify delivers `CREATE` (and possibly `RENAME`); watcher re-establishes inode watch and triggers scan. 3. Watcher debounces 500 ms; scanner reads the file. 4. Scanner returns one marker: `{ Line: 42, Text: "@claude write a test for foo", Tagged: false }`. 5. MCP generates UUID `fw-a1b2c3d4`, emits `notifications/claude/channel`. 6. Rewriter atomically rewrites line 42 to `// @claude[fw-a1b2c3d4] write a test for foo`. 7. Claude Code session displays the `<channel>` block; Claude reads `src/foo.ts`, writes the test, removes line 42 with `Edit`. 8. Both the rewrite (step 6) and Claude's edit (step 7) trigger fresh fsnotify events. Scanner sees only tagged (or no) markers. Nothing fires. ## Lifecycle - **Startup:** no full-tree scan. Watch saves going forward only. Pre-existing untagged markers stay dormant until the file is touched. - **Shutdown:** clean fsnotify close on SIGINT/SIGTERM. No state to persist. ## Configuration `.mcp.json` registration (this is what users add to their project): ```json { "mcpServers": { "filewatch": { "command": "filewatch-mcp", "args": ["--root", "."] } } } ``` Flags: - `--root` (default `.`) — project root to watch. - `--debounce` (default `500ms`) — trailing-edge per-file debounce. No JSON config file in v1. Add when a real need surfaces. Requires Claude Code with `--channels` enabled. ## Project Layout ``` claudealong/ ├── go.mod ├── cmd/filewatch-mcp/main.go ├── internal/ │ ├── watcher/ │ ├── scanner/ │ ├── rewriter/ │ ├── mcp/ │ └── ignore/ ├── .mcp.json # template for users ├── Makefile ├── docs/superpowers/specs/ └── README.md ``` ## Makefile Composable targets: - `make build` — `go build -o bin/filewatch-mcp ./cmd/filewatch-mcp` - `make test` — `go test ./...` - `make lint` — `golangci-lint run` - `make run` — `go run ./cmd/filewatch-mcp --root .` - `make install` — `go install ./cmd/filewatch-mcp` - `make clean` — `rm -rf bin/` ## Implementation order 1. **SDK spike (1 day max).** Confirm `github.com/modelcontextprotocol/go-sdk` lets a server send a custom `notifications/claude/channel` outbound notification, end-to-end, into a real Claude Code session with `--channels`. If the SDK rejects custom method names, this design is dead and we revisit (likely: build directly on JSON-RPC 2.0 stdio without the SDK). 2. Scanner + comment-syntax table + tagged/untagged distinction. 3. Rewriter with atomic-rename and mtime-conflict abort. 4. Watcher with full op mask + inode re-watch on rename. 5. MCP wiring + notification emission. 6. Integration test: temp dir, simulated saves via different editors (atomic vs. in-place), assert the right JSON-RPC frames on stdout. ## Testing - **Unit:** - `scanner`: comment-syntax detection per extension, tagged-vs-untagged, payload extraction, size cap, binary sniff. - `rewriter`: atomic write semantics, mtime-conflict abort, mode preservation, idempotency on already-tagged lines. - `ignore`: `.gitignore` matching, hard-exclude precedence, live re-read on `.gitignore` change. - **Integration:** - Boot the MCP server with a temp project dir. - Simulate atomic save (tmpfile + rename); assert WRITE/CREATE/RENAME all trigger scan. - Write a file with `// @claude X`, assert the JSON-RPC frame on stdout matches the expected `notifications/claude/channel` shape AND the file is rewritten with `[fw-XXXXXXXX]`. - Edit the file to remove the marker, assert no refire. - Edit the file to add a new untagged marker beside the tagged one, assert only the new one fires. - **Manual smoke:** - Run against a real Claude Code session with `--channels`. Add `// @claude what does this do` above a function in VS Code, Vim, and a JetBrains IDE in turn; eyeball the channel block in-session and confirm Claude responds and removes the line. ## Open Risks - **`notifications/claude/channel` is Claude-Code-specific** and the official Go SDK may validate method names against known MCP methods. Mitigated by the day-1 SDK spike (Implementation step 1). Fallback if the SDK rejects: write directly to the stdio JSON-RPC layer without the SDK. - **Inline UUID stamping modifies the user's file mid-session.** Editors with auto-reload (VS Code, Vim+`autoread`) handle this transparently. JetBrains and others may prompt. Document in README so users are not surprised. Mitigated by atomic-rename + mtime-conflict abort so we never clobber concurrent user writes. - **fsnotify on Linux watches inodes**, not paths. Atomic-save editors break naive watches. Mitigated by re-establishing the watch on every `RENAME`/`CREATE` event. - **`.gitignore` semantics are subtle.** Use a vetted parser, don't roll our own. - **Single-line comments only.** Multi-line block-comment markers (e.g., `/* @claude\n foo */`) silently won't fire. Document in README; revisit if real users hit this. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0ac0f68 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ module github.com/xanderle/claudealong go 1.26.2