a73x

5e2eaea1

chore: project bootstrap

a73x   2026-04-28 17:34

Commit message
chore: project bootstrap

.gitignore
Old New
@@ -0,0 +1,4 @@
1 bin/
2 *.test
3 *.out
4 .DS_Store
Makefile
Old New
@@ -0,0 +1,23 @@
1 BIN := bin/filewatch-mcp
2 PKG := ./cmd/filewatch-mcp
3
4 .PHONY: build test lint run install clean
5
6 build:
7 mkdir -p bin
8 go build -o $(BIN) $(PKG)
9
10 test:
11 go test ./...
12
13 lint:
14 golangci-lint run
15
16 run:
17 go run $(PKG) --root .
18
19 install:
20 go install $(PKG)
21
22 clean:
23 rm -rf bin/
cmd/filewatch-mcp/main.go
Old New
@@ -0,0 +1,7 @@
1 package main
2
3 import "fmt"
4
5 func main() {
6 fmt.Println("filewatch-mcp")
7 }
docs/superpowers/plans/2026-04-28-claudealong-filewatch-mcp.md
Old New
@@ -0,0 +1,2153 @@
1 # `@claude` File-Watcher MCP — Implementation Plan
2
3 > **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.
4
5 **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.
6
7 **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.
8
9 **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`.
10
11 **Spec:** `docs/superpowers/specs/2026-04-28-claude-channel-filewatch-design.md`
12
13 ---
14
15 ## Module API contract (used by all tasks)
16
17 Tasks below introduce these incrementally. Listed here for cross-reference — tasks restate signatures where relevant for self-contained reading.
18
19 ```go
20 // internal/scanner
21 type Marker struct {
22 Line int // 1-indexed
23 Text string // payload starting at "@claude", comment delimiters stripped
24 Tagged bool // true if marker has [fw-XXXXXXXX]
25 UUID string // populated when Tagged=true, e.g. "fw-a1b2c3d4"
26 }
27 func Scan(path string) ([]Marker, error)
28
29 // internal/rewriter
30 var ErrConcurrentModification = errors.New("file modified concurrently")
31 func StampUUIDs(path string, stamps map[int]string) error
32
33 // internal/ignore
34 type Matcher struct{ /* unexported */ }
35 func New(root string) (*Matcher, error)
36 func (m *Matcher) ShouldIgnore(path string) bool
37 func (m *Matcher) Reload() error
38
39 // internal/watcher
40 type Event struct{ Path string }
41 type Watcher struct{ /* unexported */ }
42 func New(root string, ig *ignore.Matcher, debounce time.Duration) (*Watcher, error)
43 func (w *Watcher) Events() <-chan Event
44 func (w *Watcher) Start(ctx context.Context) error
45 func (w *Watcher) Close() error
46
47 // internal/mcp
48 type ChannelMeta struct {
49 Source string // always "filewatch"
50 File string
51 Line int
52 ReplyTo string // "fw-XXXXXXXX"
53 }
54 type Server struct{ /* unexported */ }
55 func New() *Server
56 func (s *Server) SendChannel(ctx context.Context, meta ChannelMeta, content string) error
57 func (s *Server) Run(ctx context.Context) error
58 ```
59
60 ---
61
62 ### Task 1: Project bootstrap
63
64 **Files:**
65 - Create: `/home/xanderle/code/rad/claudealong/go.mod`
66 - Create: `/home/xanderle/code/rad/claudealong/.gitignore`
67 - Create: `/home/xanderle/code/rad/claudealong/Makefile`
68 - Create: `/home/xanderle/code/rad/claudealong/cmd/filewatch-mcp/main.go`
69
70 - [ ] **Step 1: Initialize git and Go module**
71
72 ```bash
73 cd /home/xanderle/code/rad/claudealong
74 git init
75 go mod init github.com/xanderle/claudealong
76 ```
77
78 Expected: `go.mod` created with module path.
79
80 - [ ] **Step 2: Write `.gitignore`**
81
82 ```gitignore
83 bin/
84 *.test
85 *.out
86 .DS_Store
87 ```
88
89 - [ ] **Step 3: Write minimal `cmd/filewatch-mcp/main.go`**
90
91 ```go
92 package main
93
94 import "fmt"
95
96 func main() {
97 fmt.Println("filewatch-mcp")
98 }
99 ```
100
101 - [ ] **Step 4: Write `Makefile`**
102
103 ```makefile
104 BIN := bin/filewatch-mcp
105 PKG := ./cmd/filewatch-mcp
106
107 .PHONY: build test lint run install clean
108
109 build:
110 mkdir -p bin
111 go build -o $(BIN) $(PKG)
112
113 test:
114 go test ./...
115
116 lint:
117 golangci-lint run
118
119 run:
120 go run $(PKG) --root .
121
122 install:
123 go install $(PKG)
124
125 clean:
126 rm -rf bin/
127 ```
128
129 - [ ] **Step 5: Verify build**
130
131 Run: `make build`
132 Expected: `bin/filewatch-mcp` created, no errors.
133
134 - [ ] **Step 6: Commit**
135
136 ```bash
137 git add go.mod go.sum .gitignore Makefile cmd/
138 git commit -m "chore: project bootstrap"
139 ```
140
141 ---
142
143 ### Task 2: SDK spike — verify custom notification path
144
145 **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.
146
147 **Files:**
148 - Modify: `/home/xanderle/code/rad/claudealong/cmd/filewatch-mcp/main.go`
149 - Modify: `/home/xanderle/code/rad/claudealong/go.mod` (via `go get`)
150
151 - [ ] **Step 1: Add SDK dependency**
152
153 ```bash
154 go get github.com/modelcontextprotocol/go-sdk@latest
155 ```
156
157 Expected: `go.mod` and `go.sum` updated.
158
159 - [ ] **Step 2: Write spike main.go**
160
161 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:
162
163 ```go
164 package main
165
166 import (
167 "context"
168 "log"
169 "os"
170
171 "github.com/modelcontextprotocol/go-sdk/mcp"
172 )
173
174 func main() {
175 srv := mcp.NewServer(&mcp.Implementation{
176 Name: "filewatch-mcp",
177 Version: "0.0.1-spike",
178 })
179
180 // After init handshake, fire one channel notification.
181 srv.OnInitialized(func(ctx context.Context) {
182 params := map[string]any{
183 "content": "@claude SDK spike test",
184 "meta": map[string]any{
185 "source": "filewatch",
186 "file": "spike.go",
187 "line": 1,
188 "replyTo": "fw-spike001",
189 },
190 }
191 if err := srv.Notify(ctx, "notifications/claude/channel", params); err != nil {
192 log.Printf("notify error: %v", err)
193 }
194 })
195
196 if err := srv.Run(context.Background(), mcp.NewStdioTransport(os.Stdin, os.Stdout)); err != nil {
197 log.Fatal(err)
198 }
199 }
200 ```
201
202 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.
203
204 - [ ] **Step 3: Build**
205
206 Run: `make build`
207 Expected: builds clean.
208
209 - [ ] **Step 4: Manual end-to-end smoke**
210
211 Add to a Claude Code project's `.mcp.json` (use absolute path to your built binary):
212
213 ```json
214 {
215 "mcpServers": {
216 "filewatch-spike": {
217 "command": "/home/xanderle/code/rad/claudealong/bin/filewatch-mcp"
218 }
219 }
220 }
221 ```
222
223 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.
224
225 - [ ] **Step 5: Decision gate**
226
227 If the channel block appeared: continue to Task 3.
228 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.
229
230 - [ ] **Step 6: Commit**
231
232 ```bash
233 git add go.mod go.sum cmd/
234 git commit -m "spike: confirm notifications/claude/channel via official Go SDK"
235 ```
236
237 ---
238
239 ### Task 3: Scanner — happy path single Go marker
240
241 **Files:**
242 - Create: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner.go`
243 - Create: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner_test.go`
244
245 - [ ] **Step 1: Write the failing test**
246
247 `internal/scanner/scanner_test.go`:
248
249 ```go
250 package scanner
251
252 import (
253 "os"
254 "path/filepath"
255 "testing"
256 )
257
258 func writeFile(t *testing.T, dir, name, content string) string {
259 t.Helper()
260 path := filepath.Join(dir, name)
261 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
262 t.Fatal(err)
263 }
264 return path
265 }
266
267 func TestScanGoFileSingleUntaggedMarker(t *testing.T) {
268 dir := t.TempDir()
269 path := writeFile(t, dir, "foo.go", "package foo\n\n// @claude write a test for foo\nfunc Foo() {}\n")
270
271 markers, err := Scan(path)
272 if err != nil {
273 t.Fatalf("Scan: %v", err)
274 }
275 if len(markers) != 1 {
276 t.Fatalf("got %d markers, want 1: %#v", len(markers), markers)
277 }
278 m := markers[0]
279 if m.Line != 3 {
280 t.Errorf("Line = %d, want 3", m.Line)
281 }
282 if m.Text != "@claude write a test for foo" {
283 t.Errorf("Text = %q, want %q", m.Text, "@claude write a test for foo")
284 }
285 if m.Tagged {
286 t.Errorf("Tagged = true, want false")
287 }
288 }
289 ```
290
291 - [ ] **Step 2: Run test to verify it fails**
292
293 Run: `go test ./internal/scanner/ -run TestScanGoFileSingleUntaggedMarker -v`
294 Expected: FAIL with build error (`Scan` undefined).
295
296 - [ ] **Step 3: Write minimal implementation**
297
298 `internal/scanner/scanner.go`:
299
300 ```go
301 package scanner
302
303 import (
304 "bufio"
305 "os"
306 "path/filepath"
307 "regexp"
308 "strings"
309 )
310
311 type Marker struct {
312 Line int
313 Text string
314 Tagged bool
315 UUID string
316 }
317
318 // commentPrefixes maps file extension (lowercase, with dot) to the line-comment prefix.
319 var commentPrefixes = map[string]string{
320 ".go": "//",
321 }
322
323 var (
324 untaggedRE = regexp.MustCompile(`^@claude\s+\S`)
325 taggedRE = regexp.MustCompile(`^@claude\[(fw-[0-9a-f]{8})\]\s+\S`)
326 )
327
328 func Scan(path string) ([]Marker, error) {
329 ext := strings.ToLower(filepath.Ext(path))
330 prefix, ok := commentPrefixes[ext]
331 if !ok {
332 return nil, nil
333 }
334 f, err := os.Open(path)
335 if err != nil {
336 return nil, err
337 }
338 defer f.Close()
339
340 var markers []Marker
341 sc := bufio.NewScanner(f)
342 sc.Buffer(make([]byte, 1024*1024), 1024*1024)
343 line := 0
344 for sc.Scan() {
345 line++
346 raw := strings.TrimSpace(sc.Text())
347 if !strings.HasPrefix(raw, prefix) {
348 continue
349 }
350 body := strings.TrimSpace(strings.TrimPrefix(raw, prefix))
351 if m := taggedRE.FindStringSubmatch(body); m != nil {
352 markers = append(markers, Marker{Line: line, Text: body, Tagged: true, UUID: m[1]})
353 continue
354 }
355 if untaggedRE.MatchString(body) {
356 markers = append(markers, Marker{Line: line, Text: body})
357 }
358 }
359 return markers, sc.Err()
360 }
361 ```
362
363 - [ ] **Step 4: Run test to verify it passes**
364
365 Run: `go test ./internal/scanner/ -run TestScanGoFileSingleUntaggedMarker -v`
366 Expected: PASS.
367
368 - [ ] **Step 5: Commit**
369
370 ```bash
371 git add internal/scanner/
372 git commit -m "feat(scanner): detect untagged @claude markers in Go comments"
373 ```
374
375 ---
376
377 ### Task 4: Scanner — multi-language comment table
378
379 **Files:**
380 - Modify: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner.go`
381 - Modify: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner_test.go`
382
383 - [ ] **Step 1: Write failing tests for additional languages**
384
385 Append to `scanner_test.go`:
386
387 ```go
388 func TestScanPythonFile(t *testing.T) {
389 dir := t.TempDir()
390 path := writeFile(t, dir, "foo.py", "# @claude rename this\ndef foo(): pass\n")
391 markers, err := Scan(path)
392 if err != nil {
393 t.Fatal(err)
394 }
395 if len(markers) != 1 || markers[0].Line != 1 || markers[0].Text != "@claude rename this" {
396 t.Fatalf("got %#v", markers)
397 }
398 }
399
400 func TestScanSQLFile(t *testing.T) {
401 dir := t.TempDir()
402 path := writeFile(t, dir, "q.sql", "-- @claude add an index\nSELECT 1;\n")
403 markers, err := Scan(path)
404 if err != nil {
405 t.Fatal(err)
406 }
407 if len(markers) != 1 || markers[0].Text != "@claude add an index" {
408 t.Fatalf("got %#v", markers)
409 }
410 }
411
412 func TestScanCSSBlockComment(t *testing.T) {
413 dir := t.TempDir()
414 path := writeFile(t, dir, "s.css", "/* @claude make this responsive */\nbody {}\n")
415 markers, err := Scan(path)
416 if err != nil {
417 t.Fatal(err)
418 }
419 if len(markers) != 1 || markers[0].Text != "@claude make this responsive" {
420 t.Fatalf("got %#v", markers)
421 }
422 }
423
424 func TestScanHTMLBlockComment(t *testing.T) {
425 dir := t.TempDir()
426 path := writeFile(t, dir, "x.html", "<!-- @claude add aria labels -->\n<div></div>\n")
427 markers, err := Scan(path)
428 if err != nil {
429 t.Fatal(err)
430 }
431 if len(markers) != 1 || markers[0].Text != "@claude add aria labels" {
432 t.Fatalf("got %#v", markers)
433 }
434 }
435
436 func TestScanUnrecognizedExtensionSkipped(t *testing.T) {
437 dir := t.TempDir()
438 path := writeFile(t, dir, "weird.xyz", "// @claude do thing\n")
439 markers, err := Scan(path)
440 if err != nil {
441 t.Fatal(err)
442 }
443 if len(markers) != 0 {
444 t.Fatalf("expected skip, got %#v", markers)
445 }
446 }
447
448 func TestScanMakefileByName(t *testing.T) {
449 dir := t.TempDir()
450 path := writeFile(t, dir, "Makefile", "# @claude add a lint target\nbuild:\n\techo hi\n")
451 markers, err := Scan(path)
452 if err != nil {
453 t.Fatal(err)
454 }
455 if len(markers) != 1 || markers[0].Text != "@claude add a lint target" {
456 t.Fatalf("got %#v", markers)
457 }
458 }
459 ```
460
461 - [ ] **Step 2: Run tests to verify they fail**
462
463 Run: `go test ./internal/scanner/ -v`
464 Expected: 5 new tests FAIL (only `.go` recognized).
465
466 - [ ] **Step 3: Extend implementation**
467
468 Replace the `commentPrefixes` map and the loop body in `scanner.go`. Add a separate handling for block-style single-line markers (`/* ... */`, `<!-- ... -->`).
469
470 ```go
471 // Replace commentPrefixes with two tables.
472
473 // lineCommentPrefixes: extensions whose markers must be a line-comment.
474 var lineCommentPrefixes = map[string]string{
475 ".go": "//", ".js": "//", ".jsx": "//", ".ts": "//", ".tsx": "//",
476 ".c": "//", ".cpp": "//", ".h": "//", ".hpp": "//",
477 ".rs": "//", ".java": "//", ".kt": "//", ".swift": "//",
478 ".scala": "//", ".cs": "//",
479 ".py": "#", ".rb": "#", ".sh": "#", ".bash": "#", ".zsh": "#",
480 ".yaml": "#", ".yml": "#", ".toml": "#",
481 ".sql": "--", ".lua": "--", ".hs": "--", ".elm": "--",
482 }
483
484 // blockCommentDelims: extensions whose single-line markers may use a block comment.
485 type blockDelim struct{ open, close string }
486 var blockCommentDelims = map[string]blockDelim{
487 ".css": {"/*", "*/"},
488 ".html": {"<!--", "-->"},
489 ".xml": {"<!--", "-->"},
490 ".md": {"<!--", "-->"},
491 }
492
493 // nameOverrides: filename → line-comment prefix when no useful extension exists.
494 var nameOverrides = map[string]string{
495 "Makefile": "#", "makefile": "#", "Dockerfile": "#", "dockerfile": "#",
496 }
497 ```
498
499 Replace the body of `Scan` to:
500 1. Resolve a prefix using extension first, then `nameOverrides[filepath.Base(path)]`.
501 2. If neither a line-comment prefix nor a block delim is registered, return `nil`.
502 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.
503
504 Concrete loop body:
505
506 ```go
507 for sc.Scan() {
508 line++
509 raw := strings.TrimSpace(sc.Text())
510 var body string
511 switch {
512 case linePrefix != "" && strings.HasPrefix(raw, linePrefix):
513 body = strings.TrimSpace(strings.TrimPrefix(raw, linePrefix))
514 case blockDelim.open != "" && strings.HasPrefix(raw, blockDelim.open) && strings.HasSuffix(raw, blockDelim.close):
515 body = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, blockDelim.open), blockDelim.close))
516 default:
517 continue
518 }
519 if m := taggedRE.FindStringSubmatch(body); m != nil {
520 markers = append(markers, Marker{Line: line, Text: body, Tagged: true, UUID: m[1]})
521 continue
522 }
523 if untaggedRE.MatchString(body) {
524 markers = append(markers, Marker{Line: line, Text: body})
525 }
526 }
527 ```
528
529 (Where `linePrefix` and `blockDelim` are resolved at the top of `Scan` from the tables above.)
530
531 - [ ] **Step 4: Run tests to verify they pass**
532
533 Run: `go test ./internal/scanner/ -v`
534 Expected: all PASS.
535
536 - [ ] **Step 5: Commit**
537
538 ```bash
539 git add internal/scanner/
540 git commit -m "feat(scanner): support multi-language comment syntax"
541 ```
542
543 ---
544
545 ### Task 5: Scanner — tagged marker skip + size cap + binary sniff
546
547 **Files:**
548 - Modify: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner.go`
549 - Modify: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner_test.go`
550
551 - [ ] **Step 1: Write failing tests**
552
553 Append to `scanner_test.go`:
554
555 ```go
556 func TestScanTaggedMarkerNotFiredButReported(t *testing.T) {
557 dir := t.TempDir()
558 path := writeFile(t, dir, "foo.go", "// @claude[fw-a1b2c3d4] write a test\n")
559 markers, err := Scan(path)
560 if err != nil {
561 t.Fatal(err)
562 }
563 if len(markers) != 1 {
564 t.Fatalf("want 1 marker, got %d", len(markers))
565 }
566 if !markers[0].Tagged || markers[0].UUID != "fw-a1b2c3d4" {
567 t.Errorf("got %#v, want Tagged=true UUID=fw-a1b2c3d4", markers[0])
568 }
569 }
570
571 func TestScanLargeFileSkipped(t *testing.T) {
572 dir := t.TempDir()
573 big := strings.Repeat("// filler line\n", 80_000) // > 1 MB
574 path := writeFile(t, dir, "big.go", big+"// @claude do it\n")
575 markers, err := Scan(path)
576 if err != nil {
577 t.Fatal(err)
578 }
579 if markers != nil {
580 t.Fatalf("expected nil for oversized file, got %#v", markers)
581 }
582 }
583
584 func TestScanBinaryFileSkipped(t *testing.T) {
585 dir := t.TempDir()
586 path := filepath.Join(dir, "blob.go")
587 if err := os.WriteFile(path, []byte{0x00, 0x01, 0x02, '/', '/', ' ', '@', 'c', 'l', 'a', 'u', 'd', 'e', ' ', 'x'}, 0o644); err != nil {
588 t.Fatal(err)
589 }
590 markers, err := Scan(path)
591 if err != nil {
592 t.Fatal(err)
593 }
594 if markers != nil {
595 t.Fatalf("expected nil for binary file, got %#v", markers)
596 }
597 }
598 ```
599
600 (`strings` already imported in test file from prior tasks. If not, add it.)
601
602 - [ ] **Step 2: Run tests to verify they fail**
603
604 Run: `go test ./internal/scanner/ -v`
605 Expected: tagged-marker test passes already (regex matches both); size-cap and binary tests FAIL (no such checks).
606
607 - [ ] **Step 3: Add size cap and binary sniff to `Scan`**
608
609 At the top of `Scan`, after resolving the prefix:
610
611 ```go
612 const maxBytes = 1 << 20 // 1 MB
613
614 st, err := os.Stat(path)
615 if err != nil {
616 return nil, err
617 }
618 if st.Size() > maxBytes {
619 return nil, nil
620 }
621
622 // Binary sniff: read first 8 KB, look for null byte.
623 sniffN := int64(8192)
624 if st.Size() < sniffN {
625 sniffN = st.Size()
626 }
627 sniff := make([]byte, sniffN)
628 sf, err := os.Open(path)
629 if err != nil {
630 return nil, err
631 }
632 n, _ := sf.Read(sniff)
633 sf.Close()
634 if bytes.IndexByte(sniff[:n], 0) >= 0 {
635 return nil, nil
636 }
637 ```
638
639 Add `"bytes"` import.
640
641 - [ ] **Step 4: Run tests to verify they pass**
642
643 Run: `go test ./internal/scanner/ -v`
644 Expected: all PASS.
645
646 - [ ] **Step 5: Commit**
647
648 ```bash
649 git add internal/scanner/
650 git commit -m "feat(scanner): tagged marker reporting, size cap, binary sniff"
651 ```
652
653 ---
654
655 ### Task 6: Rewriter — atomic UUID stamping
656
657 **Files:**
658 - Create: `/home/xanderle/code/rad/claudealong/internal/rewriter/rewriter.go`
659 - Create: `/home/xanderle/code/rad/claudealong/internal/rewriter/rewriter_test.go`
660
661 - [ ] **Step 1: Write failing test**
662
663 `internal/rewriter/rewriter_test.go`:
664
665 ```go
666 package rewriter
667
668 import (
669 "os"
670 "path/filepath"
671 "strings"
672 "testing"
673 )
674
675 func TestStampUUIDsInsertsTagAfterClaude(t *testing.T) {
676 dir := t.TempDir()
677 path := filepath.Join(dir, "foo.go")
678 original := "package foo\n\n// @claude write a test\nfunc Foo() {}\n"
679 if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
680 t.Fatal(err)
681 }
682
683 err := StampUUIDs(path, map[int]string{3: "fw-a1b2c3d4"})
684 if err != nil {
685 t.Fatalf("StampUUIDs: %v", err)
686 }
687
688 got, err := os.ReadFile(path)
689 if err != nil {
690 t.Fatal(err)
691 }
692 want := "package foo\n\n// @claude[fw-a1b2c3d4] write a test\nfunc Foo() {}\n"
693 if string(got) != want {
694 t.Errorf("got:\n%s\nwant:\n%s", got, want)
695 }
696 }
697
698 func TestStampUUIDsPreservesIndentation(t *testing.T) {
699 dir := t.TempDir()
700 path := filepath.Join(dir, "foo.go")
701 original := "func F() {\n // @claude refactor\n}\n"
702 if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
703 t.Fatal(err)
704 }
705
706 if err := StampUUIDs(path, map[int]string{2: "fw-deadbeef"}); err != nil {
707 t.Fatal(err)
708 }
709 got, _ := os.ReadFile(path)
710 if !strings.Contains(string(got), " // @claude[fw-deadbeef] refactor") {
711 t.Errorf("indentation not preserved:\n%s", got)
712 }
713 }
714
715 func TestStampUUIDsBlockComment(t *testing.T) {
716 dir := t.TempDir()
717 path := filepath.Join(dir, "x.html")
718 original := "<!-- @claude add aria -->\n<div></div>\n"
719 if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
720 t.Fatal(err)
721 }
722 if err := StampUUIDs(path, map[int]string{1: "fw-12345678"}); err != nil {
723 t.Fatal(err)
724 }
725 got, _ := os.ReadFile(path)
726 want := "<!-- @claude[fw-12345678] add aria -->\n<div></div>\n"
727 if string(got) != want {
728 t.Errorf("got:\n%s\nwant:\n%s", got, want)
729 }
730 }
731 ```
732
733 - [ ] **Step 2: Run test to verify it fails**
734
735 Run: `go test ./internal/rewriter/ -v`
736 Expected: build error (`StampUUIDs` undefined).
737
738 - [ ] **Step 3: Write implementation**
739
740 `internal/rewriter/rewriter.go`:
741
742 ```go
743 package rewriter
744
745 import (
746 "bufio"
747 "errors"
748 "fmt"
749 "os"
750 "path/filepath"
751 "regexp"
752 "strings"
753 )
754
755 var ErrConcurrentModification = errors.New("file modified concurrently")
756
757 // stampRE matches "@claude" not already followed by a "[fw-...]" tag.
758 var stampRE = regexp.MustCompile(`@claude(?:\[fw-[0-9a-f]{8}\])?`)
759
760 // StampUUIDs rewrites the file at path, splicing "[fw-XXXXXXXX]" immediately
761 // after "@claude" on each given line. stamps maps line number (1-indexed) → UUID
762 // (e.g. "fw-a1b2c3d4"). Aborts with ErrConcurrentModification if the file's
763 // mtime changed between read and rename. File mode is preserved.
764 func StampUUIDs(path string, stamps map[int]string) error {
765 info, err := os.Stat(path)
766 if err != nil {
767 return err
768 }
769 mtimeBefore := info.ModTime()
770
771 in, err := os.Open(path)
772 if err != nil {
773 return err
774 }
775 defer in.Close()
776
777 dir := filepath.Dir(path)
778 tmp, err := os.CreateTemp(dir, ".filewatch-*.tmp")
779 if err != nil {
780 return err
781 }
782 tmpPath := tmp.Name()
783 cleanup := func() {
784 tmp.Close()
785 os.Remove(tmpPath)
786 }
787
788 sc := bufio.NewScanner(in)
789 sc.Buffer(make([]byte, 1024*1024), 1024*1024)
790 bw := bufio.NewWriter(tmp)
791 line := 0
792 for sc.Scan() {
793 line++
794 text := sc.Text()
795 if uuid, ok := stamps[line]; ok {
796 text = stampLine(text, uuid)
797 }
798 if _, err := bw.WriteString(text); err != nil {
799 cleanup()
800 return err
801 }
802 if _, err := bw.WriteString("\n"); err != nil {
803 cleanup()
804 return err
805 }
806 }
807 if err := sc.Err(); err != nil {
808 cleanup()
809 return err
810 }
811 if err := bw.Flush(); err != nil {
812 cleanup()
813 return err
814 }
815 if err := tmp.Chmod(info.Mode().Perm()); err != nil {
816 cleanup()
817 return err
818 }
819 if err := tmp.Close(); err != nil {
820 os.Remove(tmpPath)
821 return err
822 }
823
824 // mtime check before atomic rename
825 info2, err := os.Stat(path)
826 if err != nil {
827 os.Remove(tmpPath)
828 return err
829 }
830 if !info2.ModTime().Equal(mtimeBefore) {
831 os.Remove(tmpPath)
832 return ErrConcurrentModification
833 }
834
835 if err := os.Rename(tmpPath, path); err != nil {
836 os.Remove(tmpPath)
837 return fmt.Errorf("rename: %w", err)
838 }
839 return nil
840 }
841
842 func stampLine(text, uuid string) string {
843 // Replace only the first "@claude" that is not already tagged.
844 return stampRE.ReplaceAllStringFunc(text, func(m string) string {
845 if strings.Contains(m, "[fw-") {
846 return m // already tagged, leave it
847 }
848 return "@claude[" + uuid + "]"
849 })
850 }
851 ```
852
853 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.
854
855 But wait — `ReplaceAllStringFunc` runs the func for every match. We need stampLine to only stamp the first untagged one. Refine:
856
857 ```go
858 func stampLine(text, uuid string) string {
859 done := false
860 return stampRE.ReplaceAllStringFunc(text, func(m string) string {
861 if done || strings.Contains(m, "[fw-") {
862 return m
863 }
864 done = true
865 return "@claude[" + uuid + "]"
866 })
867 }
868 ```
869
870 - [ ] **Step 4: Run tests to verify they pass**
871
872 Run: `go test ./internal/rewriter/ -v`
873 Expected: all PASS.
874
875 - [ ] **Step 5: Commit**
876
877 ```bash
878 git add internal/rewriter/
879 git commit -m "feat(rewriter): atomic UUID stamping with mode preservation"
880 ```
881
882 ---
883
884 ### Task 7: Rewriter — mtime-conflict abort
885
886 **Files:**
887 - Modify: `/home/xanderle/code/rad/claudealong/internal/rewriter/rewriter_test.go`
888
889 - [ ] **Step 1: Write failing test**
890
891 Append to `rewriter_test.go`:
892
893 ```go
894 import "time" // add to imports if not present
895
896 func TestStampUUIDsAbortsOnConcurrentModification(t *testing.T) {
897 dir := t.TempDir()
898 path := filepath.Join(dir, "foo.go")
899 if err := os.WriteFile(path, []byte("// @claude do it\n"), 0o644); err != nil {
900 t.Fatal(err)
901 }
902
903 // Pre-set mtime to a known past value, then mutate the file just before
904 // StampUUIDs would rename, by hooking via a goroutine isn't reliable.
905 // Instead: change mtime AFTER initial Stat by editing the file in-test.
906 // We do this by wrapping: write again to bump mtime to "now", then call
907 // StampUUIDs which read an older Stat.
908 //
909 // Easiest deterministic approach: set the file's mtime artificially old,
910 // call StampUUIDs (which records that mtime), then bump mtime, then —
911 // we can't because StampUUIDs runs synchronously.
912 //
913 // Instead, test the helper boundary: temporarily expose an internal
914 // function that takes a "frozen mtimeBefore" and verify it aborts when
915 // the on-disk mtime differs.
916 //
917 // Simpler alternative used here: write a helper that sets the file's
918 // mtime to an old value, then concurrently writes during StampUUIDs by
919 // using a small file and racing — flaky. Skip the goroutine race; test
920 // the explicit error path via direct manipulation.
921
922 // Touch the file with a future mtime, then artificially "go back" by
923 // calling StampUUIDs after manually rewinding our captured mtime. We
924 // do this by bumping mtime AFTER os.Stat inside StampUUIDs runs.
925 //
926 // The cleanest deterministic test: factor StampUUIDs to take a hook
927 // for "between-stat-and-rename" — but that bloats the API. Instead:
928 // pre-set mtime to T-1s, call StampUUIDs, then immediately also call
929 // os.Chtimes from this test goroutine in a tight loop. Race window is
930 // wide enough for a small file. If flaky, revisit.
931
932 past := time.Now().Add(-1 * time.Hour)
933 if err := os.Chtimes(path, past, past); err != nil {
934 t.Fatal(err)
935 }
936
937 done := make(chan error, 1)
938 go func() {
939 done <- StampUUIDs(path, map[int]string{1: "fw-aaaaaaaa"})
940 }()
941
942 // Hammer Chtimes to ensure it runs between StampUUIDs's Stat and Rename.
943 deadline := time.Now().Add(500 * time.Millisecond)
944 for time.Now().Before(deadline) {
945 os.Chtimes(path, time.Now(), time.Now())
946 }
947
948 err := <-done
949 if err != nil && !errors.Is(err, ErrConcurrentModification) {
950 t.Logf("StampUUIDs returned %v (acceptable: nil or ErrConcurrentModification)", err)
951 }
952 // We don't assert which: race timing decides. The test mainly verifies
953 // ErrConcurrentModification is a real, exported error and the code path
954 // can return it without panicking.
955 _ = err
956 }
957 ```
958
959 (Add `"errors"` import.)
960
961 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.
962
963 - [ ] **Step 2: Run test**
964
965 Run: `go test ./internal/rewriter/ -run TestStampUUIDsAbortsOnConcurrentModification -v`
966 Expected: PASS (no panic; either error is acceptable per test logic).
967
968 - [ ] **Step 3: If the test never exercises the error path, add a deterministic test**
969
970 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`.
971
972 ```go
973 // In rewriter.go, factor out:
974 func atomicReplace(path, tmpPath string, mode os.FileMode, mtimeBefore time.Time) error {
975 info, err := os.Stat(path)
976 if err != nil {
977 os.Remove(tmpPath)
978 return err
979 }
980 if !info.ModTime().Equal(mtimeBefore) {
981 os.Remove(tmpPath)
982 return ErrConcurrentModification
983 }
984 if err := os.Rename(tmpPath, path); err != nil {
985 os.Remove(tmpPath)
986 return fmt.Errorf("rename: %w", err)
987 }
988 return nil
989 }
990 ```
991
992 And update `StampUUIDs` to call `atomicReplace` instead of inlining those steps.
993
994 Then in test (replace the flaky test above):
995
996 ```go
997 func TestAtomicReplaceAbortsOnStaleMtime(t *testing.T) {
998 dir := t.TempDir()
999 path := filepath.Join(dir, "foo.go")
1000 if err := os.WriteFile(path, []byte("hi\n"), 0o644); err != nil {
1001 t.Fatal(err)
1002 }
1003 tmpPath := filepath.Join(dir, "tmp")
1004 if err := os.WriteFile(tmpPath, []byte("bye\n"), 0o644); err != nil {
1005 t.Fatal(err)
1006 }
1007 stale := time.Now().Add(-time.Hour)
1008 err := atomicReplace(path, tmpPath, 0o644, stale)
1009 if !errors.Is(err, ErrConcurrentModification) {
1010 t.Fatalf("got %v, want ErrConcurrentModification", err)
1011 }
1012 }
1013 ```
1014
1015 - [ ] **Step 4: Run tests**
1016
1017 Run: `go test ./internal/rewriter/ -v`
1018 Expected: all PASS.
1019
1020 - [ ] **Step 5: Commit**
1021
1022 ```bash
1023 git add internal/rewriter/
1024 git commit -m "test(rewriter): cover concurrent-modification abort path"
1025 ```
1026
1027 ---
1028
1029 ### Task 8: Ignore — gitignore + hard-excludes + reload
1030
1031 **Files:**
1032 - Create: `/home/xanderle/code/rad/claudealong/internal/ignore/ignore.go`
1033 - Create: `/home/xanderle/code/rad/claudealong/internal/ignore/ignore_test.go`
1034
1035 - [ ] **Step 1: Add dependency**
1036
1037 ```bash
1038 go get github.com/sabhiram/go-gitignore
1039 ```
1040
1041 - [ ] **Step 2: Write failing tests**
1042
1043 `internal/ignore/ignore_test.go`:
1044
1045 ```go
1046 package ignore
1047
1048 import (
1049 "os"
1050 "path/filepath"
1051 "testing"
1052 )
1053
1054 func writeGitignore(t *testing.T, dir, content string) {
1055 t.Helper()
1056 if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(content), 0o644); err != nil {
1057 t.Fatal(err)
1058 }
1059 }
1060
1061 func TestHardExcludes(t *testing.T) {
1062 dir := t.TempDir()
1063 m, err := New(dir)
1064 if err != nil {
1065 t.Fatal(err)
1066 }
1067 cases := []struct {
1068 path string
1069 want bool
1070 }{
1071 {filepath.Join(dir, ".git", "HEAD"), true},
1072 {filepath.Join(dir, "node_modules", "x", "y.js"), true},
1073 {filepath.Join(dir, "dist", "out.js"), true},
1074 {filepath.Join(dir, "build", "out.o"), true},
1075 {filepath.Join(dir, "target", "x"), true},
1076 {filepath.Join(dir, ".venv", "bin", "python"), true},
1077 {filepath.Join(dir, "src", "foo.go"), false},
1078 {filepath.Join(dir, "foo.swp"), true},
1079 {filepath.Join(dir, "lock.lock"), true},
1080 {filepath.Join(dir, "a.tmp"), true},
1081 }
1082 for _, c := range cases {
1083 if got := m.ShouldIgnore(c.path); got != c.want {
1084 t.Errorf("ShouldIgnore(%q) = %v, want %v", c.path, got, c.want)
1085 }
1086 }
1087 }
1088
1089 func TestGitignorePatterns(t *testing.T) {
1090 dir := t.TempDir()
1091 writeGitignore(t, dir, "secret.txt\n*.bak\n")
1092 m, err := New(dir)
1093 if err != nil {
1094 t.Fatal(err)
1095 }
1096 if !m.ShouldIgnore(filepath.Join(dir, "secret.txt")) {
1097 t.Error("secret.txt should be ignored")
1098 }
1099 if !m.ShouldIgnore(filepath.Join(dir, "old.bak")) {
1100 t.Error("old.bak should be ignored")
1101 }
1102 if m.ShouldIgnore(filepath.Join(dir, "ok.txt")) {
1103 t.Error("ok.txt should NOT be ignored")
1104 }
1105 }
1106
1107 func TestReloadPicksUpNewPatterns(t *testing.T) {
1108 dir := t.TempDir()
1109 writeGitignore(t, dir, "")
1110 m, err := New(dir)
1111 if err != nil {
1112 t.Fatal(err)
1113 }
1114 p := filepath.Join(dir, "fresh.txt")
1115 if m.ShouldIgnore(p) {
1116 t.Fatal("fresh.txt should not be ignored before reload")
1117 }
1118 writeGitignore(t, dir, "fresh.txt\n")
1119 if err := m.Reload(); err != nil {
1120 t.Fatal(err)
1121 }
1122 if !m.ShouldIgnore(p) {
1123 t.Error("fresh.txt should be ignored after reload")
1124 }
1125 }
1126 ```
1127
1128 - [ ] **Step 3: Run tests to verify they fail**
1129
1130 Run: `go test ./internal/ignore/ -v`
1131 Expected: build error.
1132
1133 - [ ] **Step 4: Implement**
1134
1135 `internal/ignore/ignore.go`:
1136
1137 ```go
1138 package ignore
1139
1140 import (
1141 "os"
1142 "path/filepath"
1143 "strings"
1144 "sync"
1145
1146 gitignore "github.com/sabhiram/go-gitignore"
1147 )
1148
1149 var hardExcludeDirs = []string{
1150 ".git", "node_modules", "dist", "build", "target", ".venv",
1151 }
1152 var hardExcludeSuffixes = []string{
1153 ".swp", ".tmp", ".lock",
1154 }
1155
1156 type Matcher struct {
1157 root string
1158 mu sync.RWMutex
1159 gi *gitignore.GitIgnore
1160 }
1161
1162 func New(root string) (*Matcher, error) {
1163 m := &Matcher{root: root}
1164 if err := m.Reload(); err != nil {
1165 return nil, err
1166 }
1167 return m, nil
1168 }
1169
1170 func (m *Matcher) Reload() error {
1171 path := filepath.Join(m.root, ".gitignore")
1172 var gi *gitignore.GitIgnore
1173 if _, err := os.Stat(path); err == nil {
1174 loaded, err := gitignore.CompileIgnoreFile(path)
1175 if err != nil {
1176 return err
1177 }
1178 gi = loaded
1179 }
1180 m.mu.Lock()
1181 m.gi = gi
1182 m.mu.Unlock()
1183 return nil
1184 }
1185
1186 func (m *Matcher) ShouldIgnore(path string) bool {
1187 rel, err := filepath.Rel(m.root, path)
1188 if err != nil || strings.HasPrefix(rel, "..") {
1189 return true
1190 }
1191 parts := strings.Split(rel, string(filepath.Separator))
1192 for _, p := range parts {
1193 for _, ex := range hardExcludeDirs {
1194 if p == ex {
1195 return true
1196 }
1197 }
1198 }
1199 base := filepath.Base(rel)
1200 for _, sfx := range hardExcludeSuffixes {
1201 if strings.HasSuffix(base, sfx) {
1202 return true
1203 }
1204 }
1205 m.mu.RLock()
1206 gi := m.gi
1207 m.mu.RUnlock()
1208 if gi != nil && gi.MatchesPath(rel) {
1209 return true
1210 }
1211 return false
1212 }
1213 ```
1214
1215 - [ ] **Step 5: Run tests to verify they pass**
1216
1217 Run: `go test ./internal/ignore/ -v`
1218 Expected: all PASS.
1219
1220 - [ ] **Step 6: Commit**
1221
1222 ```bash
1223 git add go.mod go.sum internal/ignore/
1224 git commit -m "feat(ignore): gitignore + hard-excludes with live reload"
1225 ```
1226
1227 ---
1228
1229 ### Task 9: Watcher — fsnotify with full op mask + inode re-watch
1230
1231 **Files:**
1232 - Create: `/home/xanderle/code/rad/claudealong/internal/watcher/watcher.go`
1233 - Create: `/home/xanderle/code/rad/claudealong/internal/watcher/watcher_test.go`
1234
1235 - [ ] **Step 1: Add dependency**
1236
1237 ```bash
1238 go get github.com/fsnotify/fsnotify
1239 ```
1240
1241 - [ ] **Step 2: Write failing tests**
1242
1243 `internal/watcher/watcher_test.go`:
1244
1245 ```go
1246 package watcher
1247
1248 import (
1249 "context"
1250 "os"
1251 "path/filepath"
1252 "testing"
1253 "time"
1254
1255 "github.com/xanderle/claudealong/internal/ignore"
1256 )
1257
1258 func newTestMatcher(t *testing.T, dir string) *ignore.Matcher {
1259 t.Helper()
1260 m, err := ignore.New(dir)
1261 if err != nil {
1262 t.Fatal(err)
1263 }
1264 return m
1265 }
1266
1267 func TestWatcherFiresOnInPlaceWrite(t *testing.T) {
1268 dir := t.TempDir()
1269 p := filepath.Join(dir, "a.go")
1270 if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
1271 t.Fatal(err)
1272 }
1273
1274 w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond)
1275 if err != nil {
1276 t.Fatal(err)
1277 }
1278 defer w.Close()
1279
1280 ctx, cancel := context.WithCancel(context.Background())
1281 defer cancel()
1282 if err := w.Start(ctx); err != nil {
1283 t.Fatal(err)
1284 }
1285
1286 // Trigger WRITE
1287 time.Sleep(20 * time.Millisecond)
1288 if err := os.WriteFile(p, []byte("y"), 0o644); err != nil {
1289 t.Fatal(err)
1290 }
1291
1292 select {
1293 case ev := <-w.Events():
1294 if ev.Path != p {
1295 t.Errorf("got path %q, want %q", ev.Path, p)
1296 }
1297 case <-time.After(2 * time.Second):
1298 t.Fatal("no event received")
1299 }
1300 }
1301
1302 func TestWatcherFiresOnAtomicSave(t *testing.T) {
1303 dir := t.TempDir()
1304 p := filepath.Join(dir, "a.go")
1305 if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
1306 t.Fatal(err)
1307 }
1308
1309 w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond)
1310 if err != nil {
1311 t.Fatal(err)
1312 }
1313 defer w.Close()
1314 ctx, cancel := context.WithCancel(context.Background())
1315 defer cancel()
1316 if err := w.Start(ctx); err != nil {
1317 t.Fatal(err)
1318 }
1319 time.Sleep(20 * time.Millisecond)
1320
1321 // Atomic-save: write tmpfile + rename over original
1322 tmp := filepath.Join(dir, ".a.go.tmp")
1323 if err := os.WriteFile(tmp, []byte("z"), 0o644); err != nil {
1324 t.Fatal(err)
1325 }
1326 if err := os.Rename(tmp, p); err != nil {
1327 t.Fatal(err)
1328 }
1329
1330 select {
1331 case ev := <-w.Events():
1332 if ev.Path != p {
1333 t.Errorf("got path %q, want %q", ev.Path, p)
1334 }
1335 case <-time.After(2 * time.Second):
1336 t.Fatal("no event after atomic save (inode re-watch broken?)")
1337 }
1338 }
1339
1340 func TestWatcherDebouncesBurst(t *testing.T) {
1341 dir := t.TempDir()
1342 p := filepath.Join(dir, "a.go")
1343 if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
1344 t.Fatal(err)
1345 }
1346 w, err := New(dir, newTestMatcher(t, dir), 200*time.Millisecond)
1347 if err != nil {
1348 t.Fatal(err)
1349 }
1350 defer w.Close()
1351 ctx, cancel := context.WithCancel(context.Background())
1352 defer cancel()
1353 if err := w.Start(ctx); err != nil {
1354 t.Fatal(err)
1355 }
1356 time.Sleep(50 * time.Millisecond)
1357
1358 // Burst of writes within debounce window
1359 for i := 0; i < 5; i++ {
1360 os.WriteFile(p, []byte{'a' + byte(i)}, 0o644)
1361 time.Sleep(20 * time.Millisecond)
1362 }
1363
1364 // Drain for 500ms; expect exactly 1 event
1365 count := 0
1366 deadline := time.After(500 * time.Millisecond)
1367 loop:
1368 for {
1369 select {
1370 case <-w.Events():
1371 count++
1372 case <-deadline:
1373 break loop
1374 }
1375 }
1376 if count != 1 {
1377 t.Errorf("got %d events, want 1 (debounced)", count)
1378 }
1379 }
1380
1381 func TestWatcherSkipsIgnoredPaths(t *testing.T) {
1382 dir := t.TempDir()
1383 if err := os.MkdirAll(filepath.Join(dir, "node_modules"), 0o755); err != nil {
1384 t.Fatal(err)
1385 }
1386 p := filepath.Join(dir, "node_modules", "a.go")
1387 if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
1388 t.Fatal(err)
1389 }
1390 w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond)
1391 if err != nil {
1392 t.Fatal(err)
1393 }
1394 defer w.Close()
1395 ctx, cancel := context.WithCancel(context.Background())
1396 defer cancel()
1397 if err := w.Start(ctx); err != nil {
1398 t.Fatal(err)
1399 }
1400 time.Sleep(50 * time.Millisecond)
1401 os.WriteFile(p, []byte("y"), 0o644)
1402
1403 select {
1404 case ev := <-w.Events():
1405 t.Fatalf("unexpected event for ignored path: %q", ev.Path)
1406 case <-time.After(300 * time.Millisecond):
1407 // pass
1408 }
1409 }
1410 ```
1411
1412 - [ ] **Step 3: Run tests to verify they fail**
1413
1414 Run: `go test ./internal/watcher/ -v`
1415 Expected: build error.
1416
1417 - [ ] **Step 4: Implement**
1418
1419 `internal/watcher/watcher.go`:
1420
1421 ```go
1422 package watcher
1423
1424 import (
1425 "context"
1426 "errors"
1427 "os"
1428 "path/filepath"
1429 "sync"
1430 "time"
1431
1432 "github.com/fsnotify/fsnotify"
1433 "github.com/xanderle/claudealong/internal/ignore"
1434 )
1435
1436 type Event struct {
1437 Path string
1438 }
1439
1440 type Watcher struct {
1441 root string
1442 ig *ignore.Matcher
1443 debounce time.Duration
1444
1445 fs *fsnotify.Watcher
1446 events chan Event
1447
1448 mu sync.Mutex
1449 pending map[string]*time.Timer
1450 closed bool
1451 }
1452
1453 func New(root string, ig *ignore.Matcher, debounce time.Duration) (*Watcher, error) {
1454 fw, err := fsnotify.NewWatcher()
1455 if err != nil {
1456 return nil, err
1457 }
1458 return &Watcher{
1459 root: root,
1460 ig: ig,
1461 debounce: debounce,
1462 fs: fw,
1463 events: make(chan Event, 16),
1464 pending: make(map[string]*time.Timer),
1465 }, nil
1466 }
1467
1468 func (w *Watcher) Events() <-chan Event { return w.events }
1469
1470 func (w *Watcher) Start(ctx context.Context) error {
1471 if err := w.addRecursive(w.root); err != nil {
1472 return err
1473 }
1474 go w.run(ctx)
1475 return nil
1476 }
1477
1478 func (w *Watcher) addRecursive(root string) error {
1479 return filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
1480 if err != nil {
1481 return nil // best-effort
1482 }
1483 if !info.IsDir() {
1484 return nil
1485 }
1486 if w.ig.ShouldIgnore(p) {
1487 return filepath.SkipDir
1488 }
1489 // Skip symlinked dirs
1490 if info.Mode()&os.ModeSymlink != 0 {
1491 return filepath.SkipDir
1492 }
1493 return w.fs.Add(p)
1494 })
1495 }
1496
1497 func (w *Watcher) run(ctx context.Context) {
1498 defer close(w.events)
1499 for {
1500 select {
1501 case <-ctx.Done():
1502 return
1503 case ev, ok := <-w.fs.Events:
1504 if !ok {
1505 return
1506 }
1507 w.handle(ev)
1508 case _, ok := <-w.fs.Errors:
1509 if !ok {
1510 return
1511 }
1512 }
1513 }
1514 }
1515
1516 func (w *Watcher) handle(ev fsnotify.Event) {
1517 if w.ig.ShouldIgnore(ev.Name) {
1518 return
1519 }
1520 info, err := os.Lstat(ev.Name)
1521 if err == nil && info.Mode()&os.ModeSymlink != 0 {
1522 return // skip symlinks
1523 }
1524 // Re-watch on rename/create of a directory
1525 if ev.Op&(fsnotify.Create) != 0 && err == nil && info.IsDir() {
1526 _ = w.addRecursive(ev.Name)
1527 return
1528 }
1529 if ev.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 {
1530 return
1531 }
1532 if err == nil && info.IsDir() {
1533 return
1534 }
1535 w.scheduleEvent(ev.Name)
1536 }
1537
1538 func (w *Watcher) scheduleEvent(path string) {
1539 w.mu.Lock()
1540 defer w.mu.Unlock()
1541 if w.closed {
1542 return
1543 }
1544 if t, ok := w.pending[path]; ok {
1545 t.Stop()
1546 }
1547 w.pending[path] = time.AfterFunc(w.debounce, func() {
1548 w.mu.Lock()
1549 delete(w.pending, path)
1550 closed := w.closed
1551 w.mu.Unlock()
1552 if closed {
1553 return
1554 }
1555 select {
1556 case w.events <- Event{Path: path}:
1557 default:
1558 // drop if buffer full
1559 }
1560 })
1561 }
1562
1563 func (w *Watcher) Close() error {
1564 w.mu.Lock()
1565 w.closed = true
1566 for _, t := range w.pending {
1567 t.Stop()
1568 }
1569 w.mu.Unlock()
1570 if err := w.fs.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
1571 return err
1572 }
1573 return nil
1574 }
1575 ```
1576
1577 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.
1578
1579 - [ ] **Step 5: Run tests to verify they pass**
1580
1581 Run: `go test ./internal/watcher/ -v`
1582 Expected: all PASS.
1583
1584 - [ ] **Step 6: Commit**
1585
1586 ```bash
1587 git add go.mod go.sum internal/watcher/
1588 git commit -m "feat(watcher): fsnotify with WRITE+CREATE+RENAME, debounce, ignore"
1589 ```
1590
1591 ---
1592
1593 ### Task 10: MCP wiring — emit notification on fired marker
1594
1595 **Files:**
1596 - Create: `/home/xanderle/code/rad/claudealong/internal/mcp/mcp.go`
1597 - Create: `/home/xanderle/code/rad/claudealong/internal/mcp/mcp_test.go`
1598
1599 - [ ] **Step 1: Write failing test**
1600
1601 `internal/mcp/mcp_test.go`:
1602
1603 ```go
1604 package mcp
1605
1606 import (
1607 "bytes"
1608 "context"
1609 "encoding/json"
1610 "io"
1611 "strings"
1612 "testing"
1613 )
1614
1615 // readFrames reads newline-delimited JSON-RPC frames from r until it has count
1616 // non-empty frames or EOF.
1617 func readFrames(t *testing.T, r io.Reader, count int) []map[string]any {
1618 t.Helper()
1619 var out []map[string]any
1620 dec := json.NewDecoder(r)
1621 for len(out) < count {
1622 var f map[string]any
1623 if err := dec.Decode(&f); err != nil {
1624 if err == io.EOF {
1625 break
1626 }
1627 t.Fatalf("decode: %v", err)
1628 }
1629 out = append(out, f)
1630 }
1631 return out
1632 }
1633
1634 func TestSendChannelEmitsCorrectFrame(t *testing.T) {
1635 var buf bytes.Buffer
1636 s := NewWithIO(strings.NewReader(""), &buf) // test-only constructor
1637 err := s.SendChannel(context.Background(), ChannelMeta{
1638 Source: "filewatch",
1639 File: "src/foo.ts",
1640 Line: 42,
1641 ReplyTo: "fw-a1b2c3d4",
1642 }, "@claude write a test for foo")
1643 if err != nil {
1644 t.Fatal(err)
1645 }
1646 var frame map[string]any
1647 if err := json.NewDecoder(&buf).Decode(&frame); err != nil {
1648 t.Fatal(err)
1649 }
1650 if frame["jsonrpc"] != "2.0" {
1651 t.Errorf("jsonrpc = %v, want 2.0", frame["jsonrpc"])
1652 }
1653 if frame["method"] != "notifications/claude/channel" {
1654 t.Errorf("method = %v, want notifications/claude/channel", frame["method"])
1655 }
1656 params := frame["params"].(map[string]any)
1657 if params["content"] != "@claude write a test for foo" {
1658 t.Errorf("content = %v", params["content"])
1659 }
1660 meta := params["meta"].(map[string]any)
1661 if meta["source"] != "filewatch" || meta["file"] != "src/foo.ts" || meta["replyTo"] != "fw-a1b2c3d4" {
1662 t.Errorf("meta wrong: %#v", meta)
1663 }
1664 // line should be a number, not a string
1665 if l, ok := meta["line"].(float64); !ok || l != 42 {
1666 t.Errorf("line = %v (%T), want 42 (number)", meta["line"], meta["line"])
1667 }
1668 }
1669 ```
1670
1671 - [ ] **Step 2: Run test to verify it fails**
1672
1673 Run: `go test ./internal/mcp/ -v`
1674 Expected: build error.
1675
1676 - [ ] **Step 3: Implement**
1677
1678 `internal/mcp/mcp.go`:
1679
1680 ```go
1681 package mcp
1682
1683 import (
1684 "context"
1685 "encoding/json"
1686 "io"
1687 "os"
1688 "sync"
1689 )
1690
1691 type ChannelMeta struct {
1692 Source string `json:"source"`
1693 File string `json:"file"`
1694 Line int `json:"line"`
1695 ReplyTo string `json:"replyTo"`
1696 }
1697
1698 type Server struct {
1699 in io.Reader
1700 out io.Writer
1701 mu sync.Mutex // serializes writes to stdout
1702 }
1703
1704 func New() *Server {
1705 return &Server{in: os.Stdin, out: os.Stdout}
1706 }
1707
1708 // NewWithIO is for tests.
1709 func NewWithIO(in io.Reader, out io.Writer) *Server {
1710 return &Server{in: in, out: out}
1711 }
1712
1713 type notification struct {
1714 Jsonrpc string `json:"jsonrpc"`
1715 Method string `json:"method"`
1716 Params map[string]any `json:"params"`
1717 }
1718
1719 func (s *Server) SendChannel(ctx context.Context, meta ChannelMeta, content string) error {
1720 n := notification{
1721 Jsonrpc: "2.0",
1722 Method: "notifications/claude/channel",
1723 Params: map[string]any{
1724 "content": content,
1725 "meta": map[string]any{
1726 "source": meta.Source,
1727 "file": meta.File,
1728 "line": meta.Line,
1729 "replyTo": meta.ReplyTo,
1730 },
1731 },
1732 }
1733 s.mu.Lock()
1734 defer s.mu.Unlock()
1735 enc := json.NewEncoder(s.out)
1736 return enc.Encode(n)
1737 }
1738
1739 // Run drives the MCP request loop: reads JSON-RPC requests on s.in,
1740 // responds with minimal init/initialized handshake, then reads-and-discards
1741 // any further requests (we have no tools in v1). Notifications are written
1742 // concurrently via SendChannel.
1743 func (s *Server) Run(ctx context.Context) error {
1744 dec := json.NewDecoder(s.in)
1745 for {
1746 if ctx.Err() != nil {
1747 return nil
1748 }
1749 var req map[string]any
1750 if err := dec.Decode(&req); err != nil {
1751 if err == io.EOF {
1752 return nil
1753 }
1754 return err
1755 }
1756 method, _ := req["method"].(string)
1757 id := req["id"]
1758 if id == nil {
1759 continue // notification, ignore
1760 }
1761 var result any
1762 switch method {
1763 case "initialize":
1764 result = map[string]any{
1765 "protocolVersion": "2024-11-05",
1766 "capabilities": map[string]any{},
1767 "serverInfo": map[string]any{
1768 "name": "filewatch-mcp",
1769 "version": "0.1.0",
1770 },
1771 }
1772 default:
1773 // Method not found
1774 s.writeError(id, -32601, "method not found")
1775 continue
1776 }
1777 s.writeResult(id, result)
1778 }
1779 }
1780
1781 func (s *Server) writeResult(id, result any) {
1782 s.mu.Lock()
1783 defer s.mu.Unlock()
1784 json.NewEncoder(s.out).Encode(map[string]any{
1785 "jsonrpc": "2.0", "id": id, "result": result,
1786 })
1787 }
1788
1789 func (s *Server) writeError(id any, code int, msg string) {
1790 s.mu.Lock()
1791 defer s.mu.Unlock()
1792 json.NewEncoder(s.out).Encode(map[string]any{
1793 "jsonrpc": "2.0", "id": id, "error": map[string]any{"code": code, "message": msg},
1794 })
1795 }
1796 ```
1797
1798 **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.
1799
1800 - [ ] **Step 4: Run test to verify it passes**
1801
1802 Run: `go test ./internal/mcp/ -v`
1803 Expected: PASS.
1804
1805 - [ ] **Step 5: Commit**
1806
1807 ```bash
1808 git add internal/mcp/
1809 git commit -m "feat(mcp): emit notifications/claude/channel via direct JSON-RPC"
1810 ```
1811
1812 ---
1813
1814 ### Task 11: Wire it all together in `cmd/filewatch-mcp/main.go`
1815
1816 **Files:**
1817 - Modify: `/home/xanderle/code/rad/claudealong/cmd/filewatch-mcp/main.go`
1818
1819 - [ ] **Step 1: Replace main.go with wired version**
1820
1821 ```go
1822 package main
1823
1824 import (
1825 "context"
1826 "flag"
1827 "log"
1828 "os"
1829 "os/signal"
1830 "syscall"
1831 "time"
1832
1833 "github.com/google/uuid"
1834
1835 "github.com/xanderle/claudealong/internal/ignore"
1836 "github.com/xanderle/claudealong/internal/mcp"
1837 "github.com/xanderle/claudealong/internal/rewriter"
1838 "github.com/xanderle/claudealong/internal/scanner"
1839 "github.com/xanderle/claudealong/internal/watcher"
1840 )
1841
1842 func main() {
1843 root := flag.String("root", ".", "project root to watch")
1844 debounce := flag.Duration("debounce", 500*time.Millisecond, "per-file debounce window")
1845 flag.Parse()
1846
1847 ig, err := ignore.New(*root)
1848 if err != nil {
1849 log.Fatalf("ignore: %v", err)
1850 }
1851 w, err := watcher.New(*root, ig, *debounce)
1852 if err != nil {
1853 log.Fatalf("watcher: %v", err)
1854 }
1855 defer w.Close()
1856
1857 srv := mcp.New()
1858
1859 ctx, cancel := context.WithCancel(context.Background())
1860 defer cancel()
1861
1862 sigCh := make(chan os.Signal, 1)
1863 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
1864 go func() { <-sigCh; cancel() }()
1865
1866 if err := w.Start(ctx); err != nil {
1867 log.Fatalf("watcher start: %v", err)
1868 }
1869 go consume(ctx, w, srv)
1870
1871 if err := srv.Run(ctx); err != nil {
1872 log.Fatalf("mcp run: %v", err)
1873 }
1874 }
1875
1876 func consume(ctx context.Context, w *watcher.Watcher, srv *mcp.Server) {
1877 for {
1878 select {
1879 case <-ctx.Done():
1880 return
1881 case ev, ok := <-w.Events():
1882 if !ok {
1883 return
1884 }
1885 handle(ctx, ev.Path, srv)
1886 }
1887 }
1888 }
1889
1890 func handle(ctx context.Context, path string, srv *mcp.Server) {
1891 markers, err := scanner.Scan(path)
1892 if err != nil {
1893 log.Printf("scan %s: %v", path, err)
1894 return
1895 }
1896 stamps := make(map[int]string, len(markers))
1897 for _, m := range markers {
1898 if m.Tagged {
1899 continue
1900 }
1901 id := "fw-" + uuid.NewString()[:8]
1902 stamps[m.Line] = id
1903 if err := srv.SendChannel(ctx, mcp.ChannelMeta{
1904 Source: "filewatch",
1905 File: path,
1906 Line: m.Line,
1907 ReplyTo: id,
1908 }, m.Text); err != nil {
1909 log.Printf("send: %v", err)
1910 }
1911 }
1912 if len(stamps) == 0 {
1913 return
1914 }
1915 if err := rewriter.StampUUIDs(path, stamps); err != nil {
1916 log.Printf("stamp %s: %v", path, err)
1917 }
1918 }
1919 ```
1920
1921 - [ ] **Step 2: Add UUID dependency**
1922
1923 ```bash
1924 go get github.com/google/uuid
1925 ```
1926
1927 - [ ] **Step 3: Build**
1928
1929 Run: `make build`
1930 Expected: clean build.
1931
1932 - [ ] **Step 4: Commit**
1933
1934 ```bash
1935 git add go.mod go.sum cmd/
1936 git commit -m "feat: wire watcher → scanner → mcp → rewriter in main"
1937 ```
1938
1939 ---
1940
1941 ### Task 12: End-to-end integration test
1942
1943 **Files:**
1944 - Create: `/home/xanderle/code/rad/claudealong/cmd/filewatch-mcp/integration_test.go`
1945
1946 - [ ] **Step 1: Write integration test**
1947
1948 ```go
1949 package main
1950
1951 import (
1952 "bufio"
1953 "encoding/json"
1954 "io"
1955 "os"
1956 "os/exec"
1957 "path/filepath"
1958 "strings"
1959 "testing"
1960 "time"
1961 )
1962
1963 // TestEndToEnd boots the binary as a subprocess, simulates a save, and
1964 // verifies a notifications/claude/channel frame appears on stdout AND the
1965 // file gets rewritten with [fw-XXXXXXXX].
1966 func TestEndToEnd(t *testing.T) {
1967 if testing.Short() {
1968 t.Skip("integration")
1969 }
1970 if _, err := exec.LookPath("go"); err != nil {
1971 t.Skip("go toolchain not on PATH")
1972 }
1973
1974 dir := t.TempDir()
1975 target := filepath.Join(dir, "foo.go")
1976 if err := os.WriteFile(target, []byte("package foo\n"), 0o644); err != nil {
1977 t.Fatal(err)
1978 }
1979
1980 cmd := exec.Command("go", "run", ".", "--root", dir, "--debounce", "100ms")
1981 stdin, err := cmd.StdinPipe()
1982 if err != nil {
1983 t.Fatal(err)
1984 }
1985 stdout, err := cmd.StdoutPipe()
1986 if err != nil {
1987 t.Fatal(err)
1988 }
1989 cmd.Stderr = os.Stderr
1990 if err := cmd.Start(); err != nil {
1991 t.Fatal(err)
1992 }
1993 defer func() {
1994 stdin.Close()
1995 cmd.Process.Kill()
1996 cmd.Wait()
1997 }()
1998
1999 // Send initialize so srv.Run completes the handshake.
2000 fmt.Fprint(stdin, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`+"\n")
2001
2002 // Wait for init result then trigger a save.
2003 sc := bufio.NewScanner(stdout)
2004 sc.Buffer(make([]byte, 1<<16), 1<<16)
2005 if !sc.Scan() {
2006 t.Fatal("no init response")
2007 }
2008 // Now trigger the save (atomic rename).
2009 tmp := target + ".tmp"
2010 if err := os.WriteFile(tmp, []byte("// @claude please review\npackage foo\n"), 0o644); err != nil {
2011 t.Fatal(err)
2012 }
2013 if err := os.Rename(tmp, target); err != nil {
2014 t.Fatal(err)
2015 }
2016
2017 // Look for the channel notification in subsequent frames.
2018 deadline := time.Now().Add(5 * time.Second)
2019 var sawChannel bool
2020 for time.Now().Before(deadline) {
2021 if !sc.Scan() {
2022 break
2023 }
2024 var f map[string]any
2025 if err := json.Unmarshal(sc.Bytes(), &f); err != nil {
2026 continue
2027 }
2028 if f["method"] == "notifications/claude/channel" {
2029 sawChannel = true
2030 break
2031 }
2032 }
2033 if !sawChannel {
2034 t.Fatal("no notifications/claude/channel frame on stdout")
2035 }
2036
2037 // Verify the file got stamped.
2038 got, _ := os.ReadFile(target)
2039 if !strings.Contains(string(got), "@claude[fw-") {
2040 t.Errorf("file not stamped:\n%s", got)
2041 }
2042 }
2043 ```
2044
2045 (Add `"fmt"` to imports.)
2046
2047 - [ ] **Step 2: Run integration test**
2048
2049 Run: `go test ./cmd/filewatch-mcp/ -run TestEndToEnd -v`
2050 Expected: PASS.
2051
2052 - [ ] **Step 3: Commit**
2053
2054 ```bash
2055 git add cmd/filewatch-mcp/
2056 git commit -m "test: end-to-end save-to-stamp integration"
2057 ```
2058
2059 ---
2060
2061 ### Task 13: README + .mcp.json template
2062
2063 **Files:**
2064 - Create: `/home/xanderle/code/rad/claudealong/README.md`
2065 - Create: `/home/xanderle/code/rad/claudealong/.mcp.json`
2066
2067 - [ ] **Step 1: Write `.mcp.json` template**
2068
2069 ```json
2070 {
2071 "mcpServers": {
2072 "filewatch": {
2073 "command": "filewatch-mcp",
2074 "args": ["--root", "."]
2075 }
2076 }
2077 }
2078 ```
2079
2080 - [ ] **Step 2: Write README.md**
2081
2082 Sections to cover:
2083 - What it does (one paragraph: write `@claude X` in a comment, save, Claude in your running session sees it).
2084 - Install: `make install`.
2085 - Configure: copy `.mcp.json` into your project, run Claude Code with `--channels`.
2086 - How dedupe works (UUID stamping in-place — explain editor-reload caveat for JetBrains).
2087 - Supported languages (list from scanner table).
2088 - Limitations (single-line comments only, no multi-session).
2089
2090 - [ ] **Step 3: Commit**
2091
2092 ```bash
2093 git add README.md .mcp.json
2094 git commit -m "docs: README and .mcp.json template"
2095 ```
2096
2097 ---
2098
2099 ### Task 14: Manual smoke test
2100
2101 - [ ] **Step 1: Install binary**
2102
2103 ```bash
2104 make install
2105 ```
2106
2107 - [ ] **Step 2: Set up a test project**
2108
2109 Create or pick a small Go/Python project. Add `.mcp.json` from this repo. Start Claude Code with `--channels` in that project.
2110
2111 - [ ] **Step 3: Add a marker and save**
2112
2113 In some source file, type `// @claude what does this function do` above a function. Save.
2114
2115 - [ ] **Step 4: Verify**
2116
2117 - A `<channel source="filewatch" ...>` block appears in the Claude Code session.
2118 - Claude reads the file and answers in the session.
2119 - Claude removes the marker line via `Edit`.
2120 - The file no longer contains `@claude` (it was either removed or stamped+removed).
2121
2122 - [ ] **Step 5: Repeat with each editor**
2123
2124 VS Code, Vim, JetBrains. Confirm:
2125 - VS Code auto-reloads cleanly.
2126 - Vim with `set autoread` reloads cleanly.
2127 - JetBrains prompts on external change — accept the reload.
2128
2129 - [ ] **Step 6: If smoke fails**
2130
2131 File issues per editor in `docs/superpowers/specs/`. The most likely failure modes:
2132 - JetBrains atomic save not delivering Create event in time → bump debounce.
2133 - VS Code's "files.autoSave: afterDelay" causing burst → bump debounce.
2134 - Claude Code session doesn't show the channel block → confirm `--channels` is enabled and the SDK spike (Task 2) is still working.
2135
2136 ---
2137
2138 ## Self-review notes (post-write)
2139
2140 - **Spec coverage:**
2141 - Architecture diagram → Tasks 1, 9–11 ✓
2142 - Inline UUID stamping → Tasks 6–7, 11 ✓
2143 - fsnotify op mask + inode re-watch → Task 9 ✓
2144 - SDK spike → Task 2 ✓
2145 - Editor reload risk → covered in Task 14 manual smoke ✓
2146 - Single-line comments only → enforced by scanner regex (Task 4); documented in Task 13 README ✓
2147 - Size cap + binary sniff → Task 5 ✓
2148 - Symlinks not followed → Task 9 watcher ✓
2149 - `.gitignore` + hard-excludes + reload → Task 8 ✓
2150 - **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.
2151 - **Notes:**
2152 - 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.
2153 - 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.
docs/superpowers/specs/2026-04-28-claude-channel-filewatch-design.md
Old New
@@ -0,0 +1,209 @@
1 # `@claude` File-Watcher MCP — Design
2
3 **Date:** 2026-04-28
4 **Status:** Draft, ready for review
5
6 ## Goal
7
8 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.
9
10 ## Scope
11
12 Solo, local. Single developer machine. One Claude Code session running. Single project directory under watch.
13
14 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).
15
16 ## Architecture
17
18 ```
19 ┌──────────────────────────────────────┐
20 │ Claude Code session │
21 │ │
22 │ receives <channel> notifications │
23 │ reads file, edits, removes marker │
24 └──────────────┬───────────────────────┘
25 │ stdio (MCP)
26 ┌──────────────▼───────────────────────┐
27 │ filewatch-mcp (Go binary) │
28 │ │
29 │ • fsnotify watcher (WRITE+CREATE+ │
30 │ RENAME, re-watch on rename) │
31 │ • scanner: comment-syntax table, │
32 │ distinguishes tagged vs. untagged │
33 │ • rewriter: atomic UUID stamping │
34 │ • mcp: notifications/claude/channel │
35 └──────────────────────────────────────┘
36 ```
37
38 Single Go binary spawned by Claude Code as an MCP child process over stdio. No state file, no daemons, no IRC.
39
40 ## Key design decision: file is the state
41
42 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.
43
44 Consequences:
45 - No `state.json`, no `flock`, no multi-session race.
46 - No feedback-loop suppression: Claude's `Edit` triggers fsnotify, scanner sees a tagged (or absent) marker, no fire.
47 - 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.
48
49 ## Components
50
51 ### `internal/watcher`
52 - Wraps `fsnotify` with op mask `WRITE | CREATE | RENAME`.
53 - Atomic saves (tmpfile + rename, used by Vim, JetBrains, VS Code with `files.atomicSave`) deliver `RENAME`/`CREATE`, not `WRITE` — all three must trigger a scan.
54 - 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.
55 - 500 ms per-file trailing-edge debounce (JetBrains autosave bursts >200 ms apart).
56 - Symlinks: not followed. Watcher uses `Lstat` and skips them.
57 - Respects `.gitignore` plus hard-excludes: `.git/`, `node_modules/`, `dist/`, `build/`, `target/`, `.venv/`, `*.swp`, `*.tmp`, `*.lock`.
58
59 ### `internal/scanner`
60 - Per-extension comment-syntax table:
61 - `//` — js, ts, jsx, tsx, go, c, cpp, h, hpp, rs, java, kt, swift, scala, cs
62 - `#` — py, rb, sh, bash, zsh, yaml, yml, toml, dockerfile, makefile (matched by name)
63 - `--` — sql, lua, hs, elm
64 - `/* */` block — c-family, css (single-line `/* ... */` only; multi-line out of scope)
65 - `<!-- -->` — html, xml, md (single-line only)
66 - Unrecognized extensions are skipped.
67 - Size cap: files > 1 MB are skipped.
68 - Binary sniff: if the first 8 KB contains a null byte, skip.
69 - A line is a marker iff: it is a single-line comment in the file's language **and** matches one of:
70 - **Untagged:** `@claude` followed by whitespace + non-empty payload. Fires.
71 - **Tagged:** `@claude[fw-XXXXXXXX]` (8 hex chars) followed by whitespace + payload. Skipped.
72 - 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.
73
74 ### `internal/rewriter`
75 - Given a file path and a list of untagged markers (line numbers + UUIDs to stamp), rewrites the file:
76 1. Read whole file.
77 2. For each marker line, splice `[fw-XXXXXXXX]` immediately after `@claude` (preserving leading whitespace, comment delimiter, and the rest of the line).
78 3. Write to a sibling tmpfile in the same directory.
79 4. `os.Rename` over the original. Atomic on POSIX.
80 - Preserves file mode (`Stat` → `Chmod`).
81 - 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.
82
83 ### `internal/mcp`
84 - MCP stdio server using `github.com/modelcontextprotocol/go-sdk`.
85 - For each fired (untagged) marker, emits one outbound notification:
86
87 ```json
88 {
89 "jsonrpc": "2.0",
90 "method": "notifications/claude/channel",
91 "params": {
92 "content": "@claude can you write a test for foo",
93 "meta": {
94 "source": "filewatch",
95 "file": "src/foo.ts",
96 "line": 42,
97 "replyTo": "fw-a1b2c3d4"
98 }
99 }
100 }
101 ```
102
103 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.
104 - No reply tool exposed in v1. Claude's `Edit` to remove the marker is the implicit ack.
105
106 ### `internal/ignore`
107 - `.gitignore` parsing via `github.com/sabhiram/go-gitignore`.
108 - Hard-exclude list takes precedence.
109 - `.gitignore` re-read on its own change events (so updates are picked up live).
110
111 ## Data Flow
112
113 1. User edits `src/foo.ts`, adds `// @claude write a test for foo`, saves.
114 2. Editor writes via tmpfile + rename → fsnotify delivers `CREATE` (and possibly `RENAME`); watcher re-establishes inode watch and triggers scan.
115 3. Watcher debounces 500 ms; scanner reads the file.
116 4. Scanner returns one marker: `{ Line: 42, Text: "@claude write a test for foo", Tagged: false }`.
117 5. MCP generates UUID `fw-a1b2c3d4`, emits `notifications/claude/channel`.
118 6. Rewriter atomically rewrites line 42 to `// @claude[fw-a1b2c3d4] write a test for foo`.
119 7. Claude Code session displays the `<channel>` block; Claude reads `src/foo.ts`, writes the test, removes line 42 with `Edit`.
120 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.
121
122 ## Lifecycle
123
124 - **Startup:** no full-tree scan. Watch saves going forward only. Pre-existing untagged markers stay dormant until the file is touched.
125 - **Shutdown:** clean fsnotify close on SIGINT/SIGTERM. No state to persist.
126
127 ## Configuration
128
129 `.mcp.json` registration (this is what users add to their project):
130
131 ```json
132 {
133 "mcpServers": {
134 "filewatch": {
135 "command": "filewatch-mcp",
136 "args": ["--root", "."]
137 }
138 }
139 }
140 ```
141
142 Flags:
143 - `--root` (default `.`) — project root to watch.
144 - `--debounce` (default `500ms`) — trailing-edge per-file debounce.
145
146 No JSON config file in v1. Add when a real need surfaces.
147
148 Requires Claude Code with `--channels` enabled.
149
150 ## Project Layout
151
152 ```
153 claudealong/
154 ├── go.mod
155 ├── cmd/filewatch-mcp/main.go
156 ├── internal/
157 │ ├── watcher/
158 │ ├── scanner/
159 │ ├── rewriter/
160 │ ├── mcp/
161 │ └── ignore/
162 ├── .mcp.json # template for users
163 ├── Makefile
164 ├── docs/superpowers/specs/
165 └── README.md
166 ```
167
168 ## Makefile
169
170 Composable targets:
171
172 - `make build` — `go build -o bin/filewatch-mcp ./cmd/filewatch-mcp`
173 - `make test` — `go test ./...`
174 - `make lint` — `golangci-lint run`
175 - `make run` — `go run ./cmd/filewatch-mcp --root .`
176 - `make install` — `go install ./cmd/filewatch-mcp`
177 - `make clean` — `rm -rf bin/`
178
179 ## Implementation order
180
181 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).
182 2. Scanner + comment-syntax table + tagged/untagged distinction.
183 3. Rewriter with atomic-rename and mtime-conflict abort.
184 4. Watcher with full op mask + inode re-watch on rename.
185 5. MCP wiring + notification emission.
186 6. Integration test: temp dir, simulated saves via different editors (atomic vs. in-place), assert the right JSON-RPC frames on stdout.
187
188 ## Testing
189
190 - **Unit:**
191 - `scanner`: comment-syntax detection per extension, tagged-vs-untagged, payload extraction, size cap, binary sniff.
192 - `rewriter`: atomic write semantics, mtime-conflict abort, mode preservation, idempotency on already-tagged lines.
193 - `ignore`: `.gitignore` matching, hard-exclude precedence, live re-read on `.gitignore` change.
194 - **Integration:**
195 - Boot the MCP server with a temp project dir.
196 - Simulate atomic save (tmpfile + rename); assert WRITE/CREATE/RENAME all trigger scan.
197 - 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]`.
198 - Edit the file to remove the marker, assert no refire.
199 - Edit the file to add a new untagged marker beside the tagged one, assert only the new one fires.
200 - **Manual smoke:**
201 - 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.
202
203 ## Open Risks
204
205 - **`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.
206 - **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.
207 - **fsnotify on Linux watches inodes**, not paths. Atomic-save editors break naive watches. Mitigated by re-establishing the watch on every `RENAME`/`CREATE` event.
208 - **`.gitignore` semantics are subtle.** Use a vetted parser, don't roll our own.
209 - **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.
go.mod
Old New
@@ -0,0 +1,3 @@
1 module github.com/xanderle/claudealong
2
3 go 1.26.2