5e2eaea1
chore: project bootstrap
a73x 2026-04-28 17:34
Commit message
.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 | ||