a73x

5e2eaea1

chore: project bootstrap

a73x   2026-04-28 17:34


diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6e2723e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
bin/
*.test
*.out
.DS_Store
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..cd7a0cb
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,23 @@
BIN := bin/filewatch-mcp
PKG := ./cmd/filewatch-mcp

.PHONY: build test lint run install clean

build:
	mkdir -p bin
	go build -o $(BIN) $(PKG)

test:
	go test ./...

lint:
	golangci-lint run

run:
	go run $(PKG) --root .

install:
	go install $(PKG)

clean:
	rm -rf bin/
diff --git a/cmd/filewatch-mcp/main.go b/cmd/filewatch-mcp/main.go
new file mode 100644
index 0000000..ba241f0
--- /dev/null
+++ b/cmd/filewatch-mcp/main.go
@@ -0,0 +1,7 @@
package main

import "fmt"

func main() {
	fmt.Println("filewatch-mcp")
}
diff --git a/docs/superpowers/plans/2026-04-28-claudealong-filewatch-mcp.md b/docs/superpowers/plans/2026-04-28-claudealong-filewatch-mcp.md
new file mode 100644
index 0000000..e85ed06
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-28-claudealong-filewatch-mcp.md
@@ -0,0 +1,2153 @@
# `@claude` File-Watcher MCP — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a Go MCP server that watches a project directory, detects new `@claude <ask>` markers in code comments, pushes a `notifications/claude/channel` JSON-RPC notification into the running Claude Code session for each, and stamps the marker with a UUID inline so the file itself becomes the dedupe state.

**Architecture:** Single Go binary spawned by Claude Code as an MCP child over stdio. Five internal packages: `scanner` (parses comments), `rewriter` (atomic UUID stamping), `ignore` (`.gitignore` + hard-excludes), `watcher` (fsnotify with `WRITE|CREATE|RENAME` op mask), `mcp` (custom outbound notification using the official Go SDK). The `cmd/filewatch-mcp/main.go` entry point wires them.

**Tech Stack:** Go 1.22+, `github.com/modelcontextprotocol/go-sdk` (official), `github.com/fsnotify/fsnotify`, `github.com/sabhiram/go-gitignore`, `github.com/google/uuid`. Make for dev. TDD with `go test`.

**Spec:** `docs/superpowers/specs/2026-04-28-claude-channel-filewatch-design.md`

---

## Module API contract (used by all tasks)

Tasks below introduce these incrementally. Listed here for cross-reference — tasks restate signatures where relevant for self-contained reading.

```go
// internal/scanner
type Marker struct {
    Line    int    // 1-indexed
    Text    string // payload starting at "@claude", comment delimiters stripped
    Tagged  bool   // true if marker has [fw-XXXXXXXX]
    UUID    string // populated when Tagged=true, e.g. "fw-a1b2c3d4"
}
func Scan(path string) ([]Marker, error)

// internal/rewriter
var ErrConcurrentModification = errors.New("file modified concurrently")
func StampUUIDs(path string, stamps map[int]string) error

// internal/ignore
type Matcher struct{ /* unexported */ }
func New(root string) (*Matcher, error)
func (m *Matcher) ShouldIgnore(path string) bool
func (m *Matcher) Reload() error

// internal/watcher
type Event struct{ Path string }
type Watcher struct{ /* unexported */ }
func New(root string, ig *ignore.Matcher, debounce time.Duration) (*Watcher, error)
func (w *Watcher) Events() <-chan Event
func (w *Watcher) Start(ctx context.Context) error
func (w *Watcher) Close() error

// internal/mcp
type ChannelMeta struct {
    Source  string // always "filewatch"
    File    string
    Line    int
    ReplyTo string // "fw-XXXXXXXX"
}
type Server struct{ /* unexported */ }
func New() *Server
func (s *Server) SendChannel(ctx context.Context, meta ChannelMeta, content string) error
func (s *Server) Run(ctx context.Context) error
```

---

### Task 1: Project bootstrap

**Files:**
- Create: `/home/xanderle/code/rad/claudealong/go.mod`
- Create: `/home/xanderle/code/rad/claudealong/.gitignore`
- Create: `/home/xanderle/code/rad/claudealong/Makefile`
- Create: `/home/xanderle/code/rad/claudealong/cmd/filewatch-mcp/main.go`

- [ ] **Step 1: Initialize git and Go module**

```bash
cd /home/xanderle/code/rad/claudealong
git init
go mod init github.com/xanderle/claudealong
```

Expected: `go.mod` created with module path.

- [ ] **Step 2: Write `.gitignore`**

```gitignore
bin/
*.test
*.out
.DS_Store
```

- [ ] **Step 3: Write minimal `cmd/filewatch-mcp/main.go`**

```go
package main

import "fmt"

func main() {
    fmt.Println("filewatch-mcp")
}
```

- [ ] **Step 4: Write `Makefile`**

```makefile
BIN := bin/filewatch-mcp
PKG := ./cmd/filewatch-mcp

.PHONY: build test lint run install clean

build:
	mkdir -p bin
	go build -o $(BIN) $(PKG)

test:
	go test ./...

lint:
	golangci-lint run

run:
	go run $(PKG) --root .

install:
	go install $(PKG)

clean:
	rm -rf bin/
```

- [ ] **Step 5: Verify build**

Run: `make build`
Expected: `bin/filewatch-mcp` created, no errors.

- [ ] **Step 6: Commit**

```bash
git add go.mod go.sum .gitignore Makefile cmd/
git commit -m "chore: project bootstrap"
```

---

### Task 2: SDK spike — verify custom notification path

**Goal:** Confirm `github.com/modelcontextprotocol/go-sdk` lets us send `notifications/claude/channel` end-to-end. If this fails, the plan needs revision before continuing.

**Files:**
- Modify: `/home/xanderle/code/rad/claudealong/cmd/filewatch-mcp/main.go`
- Modify: `/home/xanderle/code/rad/claudealong/go.mod` (via `go get`)

- [ ] **Step 1: Add SDK dependency**

```bash
go get github.com/modelcontextprotocol/go-sdk@latest
```

Expected: `go.mod` and `go.sum` updated.

- [ ] **Step 2: Write spike main.go**

Replace `cmd/filewatch-mcp/main.go` with code that boots an MCP stdio server and sends one `notifications/claude/channel` notification with hardcoded content. Exact code depends on the SDK's notification API — read the SDK README and the `pkg.go.dev` reference for `Server.Notify` or equivalent. Pseudocode:

```go
package main

import (
    "context"
    "log"
    "os"

    "github.com/modelcontextprotocol/go-sdk/mcp"
)

func main() {
    srv := mcp.NewServer(&mcp.Implementation{
        Name:    "filewatch-mcp",
        Version: "0.0.1-spike",
    })

    // After init handshake, fire one channel notification.
    srv.OnInitialized(func(ctx context.Context) {
        params := map[string]any{
            "content": "@claude SDK spike test",
            "meta": map[string]any{
                "source":  "filewatch",
                "file":    "spike.go",
                "line":    1,
                "replyTo": "fw-spike001",
            },
        }
        if err := srv.Notify(ctx, "notifications/claude/channel", params); err != nil {
            log.Printf("notify error: %v", err)
        }
    })

    if err := srv.Run(context.Background(), mcp.NewStdioTransport(os.Stdin, os.Stdout)); err != nil {
        log.Fatal(err)
    }
}
```

If the SDK does not expose `Notify` for arbitrary methods, find the equivalent (likely on `ServerSession`). If the SDK rejects custom method names with a validation error, **stop and report**: the plan needs to switch to direct JSON-RPC framing without the SDK.

- [ ] **Step 3: Build**

Run: `make build`
Expected: builds clean.

- [ ] **Step 4: Manual end-to-end smoke**

Add to a Claude Code project's `.mcp.json` (use absolute path to your built binary):

```json
{
  "mcpServers": {
    "filewatch-spike": {
      "command": "/home/xanderle/code/rad/claudealong/bin/filewatch-mcp"
    }
  }
}
```

Start Claude Code with `--channels` (`claude --channels`) in that project. Expected: a `<channel source="filewatch" file="spike.go" line="1" replyTo="fw-spike001">@claude SDK spike test</channel>` block appears in the session shortly after start.

- [ ] **Step 5: Decision gate**

If the channel block appeared: continue to Task 3.
If not: read the SDK source for the actual notification API, fix, retry. If after one focused day the SDK still won't emit a custom-named notification, **stop**, document findings in `docs/superpowers/specs/2026-04-28-sdk-spike-notes.md`, and revisit the spec.

- [ ] **Step 6: Commit**

```bash
git add go.mod go.sum cmd/
git commit -m "spike: confirm notifications/claude/channel via official Go SDK"
```

---

### Task 3: Scanner — happy path single Go marker

**Files:**
- Create: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner.go`
- Create: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner_test.go`

- [ ] **Step 1: Write the failing test**

`internal/scanner/scanner_test.go`:

```go
package scanner

import (
    "os"
    "path/filepath"
    "testing"
)

func writeFile(t *testing.T, dir, name, content string) string {
    t.Helper()
    path := filepath.Join(dir, name)
    if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
        t.Fatal(err)
    }
    return path
}

func TestScanGoFileSingleUntaggedMarker(t *testing.T) {
    dir := t.TempDir()
    path := writeFile(t, dir, "foo.go", "package foo\n\n// @claude write a test for foo\nfunc Foo() {}\n")

    markers, err := Scan(path)
    if err != nil {
        t.Fatalf("Scan: %v", err)
    }
    if len(markers) != 1 {
        t.Fatalf("got %d markers, want 1: %#v", len(markers), markers)
    }
    m := markers[0]
    if m.Line != 3 {
        t.Errorf("Line = %d, want 3", m.Line)
    }
    if m.Text != "@claude write a test for foo" {
        t.Errorf("Text = %q, want %q", m.Text, "@claude write a test for foo")
    }
    if m.Tagged {
        t.Errorf("Tagged = true, want false")
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `go test ./internal/scanner/ -run TestScanGoFileSingleUntaggedMarker -v`
Expected: FAIL with build error (`Scan` undefined).

- [ ] **Step 3: Write minimal implementation**

`internal/scanner/scanner.go`:

```go
package scanner

import (
    "bufio"
    "os"
    "path/filepath"
    "regexp"
    "strings"
)

type Marker struct {
    Line   int
    Text   string
    Tagged bool
    UUID   string
}

// commentPrefixes maps file extension (lowercase, with dot) to the line-comment prefix.
var commentPrefixes = map[string]string{
    ".go": "//",
}

var (
    untaggedRE = regexp.MustCompile(`^@claude\s+\S`)
    taggedRE   = regexp.MustCompile(`^@claude\[(fw-[0-9a-f]{8})\]\s+\S`)
)

func Scan(path string) ([]Marker, error) {
    ext := strings.ToLower(filepath.Ext(path))
    prefix, ok := commentPrefixes[ext]
    if !ok {
        return nil, nil
    }
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close()

    var markers []Marker
    sc := bufio.NewScanner(f)
    sc.Buffer(make([]byte, 1024*1024), 1024*1024)
    line := 0
    for sc.Scan() {
        line++
        raw := strings.TrimSpace(sc.Text())
        if !strings.HasPrefix(raw, prefix) {
            continue
        }
        body := strings.TrimSpace(strings.TrimPrefix(raw, prefix))
        if m := taggedRE.FindStringSubmatch(body); m != nil {
            markers = append(markers, Marker{Line: line, Text: body, Tagged: true, UUID: m[1]})
            continue
        }
        if untaggedRE.MatchString(body) {
            markers = append(markers, Marker{Line: line, Text: body})
        }
    }
    return markers, sc.Err()
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `go test ./internal/scanner/ -run TestScanGoFileSingleUntaggedMarker -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add internal/scanner/
git commit -m "feat(scanner): detect untagged @claude markers in Go comments"
```

---

### Task 4: Scanner — multi-language comment table

**Files:**
- Modify: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner.go`
- Modify: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner_test.go`

- [ ] **Step 1: Write failing tests for additional languages**

Append to `scanner_test.go`:

```go
func TestScanPythonFile(t *testing.T) {
    dir := t.TempDir()
    path := writeFile(t, dir, "foo.py", "# @claude rename this\ndef foo(): pass\n")
    markers, err := Scan(path)
    if err != nil {
        t.Fatal(err)
    }
    if len(markers) != 1 || markers[0].Line != 1 || markers[0].Text != "@claude rename this" {
        t.Fatalf("got %#v", markers)
    }
}

func TestScanSQLFile(t *testing.T) {
    dir := t.TempDir()
    path := writeFile(t, dir, "q.sql", "-- @claude add an index\nSELECT 1;\n")
    markers, err := Scan(path)
    if err != nil {
        t.Fatal(err)
    }
    if len(markers) != 1 || markers[0].Text != "@claude add an index" {
        t.Fatalf("got %#v", markers)
    }
}

func TestScanCSSBlockComment(t *testing.T) {
    dir := t.TempDir()
    path := writeFile(t, dir, "s.css", "/* @claude make this responsive */\nbody {}\n")
    markers, err := Scan(path)
    if err != nil {
        t.Fatal(err)
    }
    if len(markers) != 1 || markers[0].Text != "@claude make this responsive" {
        t.Fatalf("got %#v", markers)
    }
}

func TestScanHTMLBlockComment(t *testing.T) {
    dir := t.TempDir()
    path := writeFile(t, dir, "x.html", "<!-- @claude add aria labels -->\n<div></div>\n")
    markers, err := Scan(path)
    if err != nil {
        t.Fatal(err)
    }
    if len(markers) != 1 || markers[0].Text != "@claude add aria labels" {
        t.Fatalf("got %#v", markers)
    }
}

func TestScanUnrecognizedExtensionSkipped(t *testing.T) {
    dir := t.TempDir()
    path := writeFile(t, dir, "weird.xyz", "// @claude do thing\n")
    markers, err := Scan(path)
    if err != nil {
        t.Fatal(err)
    }
    if len(markers) != 0 {
        t.Fatalf("expected skip, got %#v", markers)
    }
}

func TestScanMakefileByName(t *testing.T) {
    dir := t.TempDir()
    path := writeFile(t, dir, "Makefile", "# @claude add a lint target\nbuild:\n\techo hi\n")
    markers, err := Scan(path)
    if err != nil {
        t.Fatal(err)
    }
    if len(markers) != 1 || markers[0].Text != "@claude add a lint target" {
        t.Fatalf("got %#v", markers)
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `go test ./internal/scanner/ -v`
Expected: 5 new tests FAIL (only `.go` recognized).

- [ ] **Step 3: Extend implementation**

Replace the `commentPrefixes` map and the loop body in `scanner.go`. Add a separate handling for block-style single-line markers (`/* ... */`, `<!-- ... -->`).

```go
// Replace commentPrefixes with two tables.

// lineCommentPrefixes: extensions whose markers must be a line-comment.
var lineCommentPrefixes = map[string]string{
    ".go":  "//", ".js": "//", ".jsx": "//", ".ts": "//", ".tsx": "//",
    ".c":   "//", ".cpp": "//", ".h": "//", ".hpp": "//",
    ".rs":  "//", ".java": "//", ".kt": "//", ".swift": "//",
    ".scala": "//", ".cs": "//",
    ".py": "#", ".rb": "#", ".sh": "#", ".bash": "#", ".zsh": "#",
    ".yaml": "#", ".yml": "#", ".toml": "#",
    ".sql": "--", ".lua": "--", ".hs": "--", ".elm": "--",
}

// blockCommentDelims: extensions whose single-line markers may use a block comment.
type blockDelim struct{ open, close string }
var blockCommentDelims = map[string]blockDelim{
    ".css":  {"/*", "*/"},
    ".html": {"<!--", "-->"},
    ".xml":  {"<!--", "-->"},
    ".md":   {"<!--", "-->"},
}

// nameOverrides: filename → line-comment prefix when no useful extension exists.
var nameOverrides = map[string]string{
    "Makefile": "#", "makefile": "#", "Dockerfile": "#", "dockerfile": "#",
}
```

Replace the body of `Scan` to:
1. Resolve a prefix using extension first, then `nameOverrides[filepath.Base(path)]`.
2. If neither a line-comment prefix nor a block delim is registered, return `nil`.
3. For each line: trim, then either strip a line-comment prefix, or match a single-line block comment (`open ... close` on the same line) and strip both delimiters from the body. Then run the existing tagged/untagged regex against the body.

Concrete loop body:

```go
for sc.Scan() {
    line++
    raw := strings.TrimSpace(sc.Text())
    var body string
    switch {
    case linePrefix != "" && strings.HasPrefix(raw, linePrefix):
        body = strings.TrimSpace(strings.TrimPrefix(raw, linePrefix))
    case blockDelim.open != "" && strings.HasPrefix(raw, blockDelim.open) && strings.HasSuffix(raw, blockDelim.close):
        body = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, blockDelim.open), blockDelim.close))
    default:
        continue
    }
    if m := taggedRE.FindStringSubmatch(body); m != nil {
        markers = append(markers, Marker{Line: line, Text: body, Tagged: true, UUID: m[1]})
        continue
    }
    if untaggedRE.MatchString(body) {
        markers = append(markers, Marker{Line: line, Text: body})
    }
}
```

(Where `linePrefix` and `blockDelim` are resolved at the top of `Scan` from the tables above.)

- [ ] **Step 4: Run tests to verify they pass**

Run: `go test ./internal/scanner/ -v`
Expected: all PASS.

- [ ] **Step 5: Commit**

```bash
git add internal/scanner/
git commit -m "feat(scanner): support multi-language comment syntax"
```

---

### Task 5: Scanner — tagged marker skip + size cap + binary sniff

**Files:**
- Modify: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner.go`
- Modify: `/home/xanderle/code/rad/claudealong/internal/scanner/scanner_test.go`

- [ ] **Step 1: Write failing tests**

Append to `scanner_test.go`:

```go
func TestScanTaggedMarkerNotFiredButReported(t *testing.T) {
    dir := t.TempDir()
    path := writeFile(t, dir, "foo.go", "// @claude[fw-a1b2c3d4] write a test\n")
    markers, err := Scan(path)
    if err != nil {
        t.Fatal(err)
    }
    if len(markers) != 1 {
        t.Fatalf("want 1 marker, got %d", len(markers))
    }
    if !markers[0].Tagged || markers[0].UUID != "fw-a1b2c3d4" {
        t.Errorf("got %#v, want Tagged=true UUID=fw-a1b2c3d4", markers[0])
    }
}

func TestScanLargeFileSkipped(t *testing.T) {
    dir := t.TempDir()
    big := strings.Repeat("// filler line\n", 80_000) // > 1 MB
    path := writeFile(t, dir, "big.go", big+"// @claude do it\n")
    markers, err := Scan(path)
    if err != nil {
        t.Fatal(err)
    }
    if markers != nil {
        t.Fatalf("expected nil for oversized file, got %#v", markers)
    }
}

func TestScanBinaryFileSkipped(t *testing.T) {
    dir := t.TempDir()
    path := filepath.Join(dir, "blob.go")
    if err := os.WriteFile(path, []byte{0x00, 0x01, 0x02, '/', '/', ' ', '@', 'c', 'l', 'a', 'u', 'd', 'e', ' ', 'x'}, 0o644); err != nil {
        t.Fatal(err)
    }
    markers, err := Scan(path)
    if err != nil {
        t.Fatal(err)
    }
    if markers != nil {
        t.Fatalf("expected nil for binary file, got %#v", markers)
    }
}
```

(`strings` already imported in test file from prior tasks. If not, add it.)

- [ ] **Step 2: Run tests to verify they fail**

Run: `go test ./internal/scanner/ -v`
Expected: tagged-marker test passes already (regex matches both); size-cap and binary tests FAIL (no such checks).

- [ ] **Step 3: Add size cap and binary sniff to `Scan`**

At the top of `Scan`, after resolving the prefix:

```go
const maxBytes = 1 << 20 // 1 MB

st, err := os.Stat(path)
if err != nil {
    return nil, err
}
if st.Size() > maxBytes {
    return nil, nil
}

// Binary sniff: read first 8 KB, look for null byte.
sniffN := int64(8192)
if st.Size() < sniffN {
    sniffN = st.Size()
}
sniff := make([]byte, sniffN)
sf, err := os.Open(path)
if err != nil {
    return nil, err
}
n, _ := sf.Read(sniff)
sf.Close()
if bytes.IndexByte(sniff[:n], 0) >= 0 {
    return nil, nil
}
```

Add `"bytes"` import.

- [ ] **Step 4: Run tests to verify they pass**

Run: `go test ./internal/scanner/ -v`
Expected: all PASS.

- [ ] **Step 5: Commit**

```bash
git add internal/scanner/
git commit -m "feat(scanner): tagged marker reporting, size cap, binary sniff"
```

---

### Task 6: Rewriter — atomic UUID stamping

**Files:**
- Create: `/home/xanderle/code/rad/claudealong/internal/rewriter/rewriter.go`
- Create: `/home/xanderle/code/rad/claudealong/internal/rewriter/rewriter_test.go`

- [ ] **Step 1: Write failing test**

`internal/rewriter/rewriter_test.go`:

```go
package rewriter

import (
    "os"
    "path/filepath"
    "strings"
    "testing"
)

func TestStampUUIDsInsertsTagAfterClaude(t *testing.T) {
    dir := t.TempDir()
    path := filepath.Join(dir, "foo.go")
    original := "package foo\n\n// @claude write a test\nfunc Foo() {}\n"
    if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
        t.Fatal(err)
    }

    err := StampUUIDs(path, map[int]string{3: "fw-a1b2c3d4"})
    if err != nil {
        t.Fatalf("StampUUIDs: %v", err)
    }

    got, err := os.ReadFile(path)
    if err != nil {
        t.Fatal(err)
    }
    want := "package foo\n\n// @claude[fw-a1b2c3d4] write a test\nfunc Foo() {}\n"
    if string(got) != want {
        t.Errorf("got:\n%s\nwant:\n%s", got, want)
    }
}

func TestStampUUIDsPreservesIndentation(t *testing.T) {
    dir := t.TempDir()
    path := filepath.Join(dir, "foo.go")
    original := "func F() {\n    // @claude refactor\n}\n"
    if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
        t.Fatal(err)
    }

    if err := StampUUIDs(path, map[int]string{2: "fw-deadbeef"}); err != nil {
        t.Fatal(err)
    }
    got, _ := os.ReadFile(path)
    if !strings.Contains(string(got), "    // @claude[fw-deadbeef] refactor") {
        t.Errorf("indentation not preserved:\n%s", got)
    }
}

func TestStampUUIDsBlockComment(t *testing.T) {
    dir := t.TempDir()
    path := filepath.Join(dir, "x.html")
    original := "<!-- @claude add aria -->\n<div></div>\n"
    if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
        t.Fatal(err)
    }
    if err := StampUUIDs(path, map[int]string{1: "fw-12345678"}); err != nil {
        t.Fatal(err)
    }
    got, _ := os.ReadFile(path)
    want := "<!-- @claude[fw-12345678] add aria -->\n<div></div>\n"
    if string(got) != want {
        t.Errorf("got:\n%s\nwant:\n%s", got, want)
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `go test ./internal/rewriter/ -v`
Expected: build error (`StampUUIDs` undefined).

- [ ] **Step 3: Write implementation**

`internal/rewriter/rewriter.go`:

```go
package rewriter

import (
    "bufio"
    "errors"
    "fmt"
    "os"
    "path/filepath"
    "regexp"
    "strings"
)

var ErrConcurrentModification = errors.New("file modified concurrently")

// stampRE matches "@claude" not already followed by a "[fw-...]" tag.
var stampRE = regexp.MustCompile(`@claude(?:\[fw-[0-9a-f]{8}\])?`)

// StampUUIDs rewrites the file at path, splicing "[fw-XXXXXXXX]" immediately
// after "@claude" on each given line. stamps maps line number (1-indexed) → UUID
// (e.g. "fw-a1b2c3d4"). Aborts with ErrConcurrentModification if the file's
// mtime changed between read and rename. File mode is preserved.
func StampUUIDs(path string, stamps map[int]string) error {
    info, err := os.Stat(path)
    if err != nil {
        return err
    }
    mtimeBefore := info.ModTime()

    in, err := os.Open(path)
    if err != nil {
        return err
    }
    defer in.Close()

    dir := filepath.Dir(path)
    tmp, err := os.CreateTemp(dir, ".filewatch-*.tmp")
    if err != nil {
        return err
    }
    tmpPath := tmp.Name()
    cleanup := func() {
        tmp.Close()
        os.Remove(tmpPath)
    }

    sc := bufio.NewScanner(in)
    sc.Buffer(make([]byte, 1024*1024), 1024*1024)
    bw := bufio.NewWriter(tmp)
    line := 0
    for sc.Scan() {
        line++
        text := sc.Text()
        if uuid, ok := stamps[line]; ok {
            text = stampLine(text, uuid)
        }
        if _, err := bw.WriteString(text); err != nil {
            cleanup()
            return err
        }
        if _, err := bw.WriteString("\n"); err != nil {
            cleanup()
            return err
        }
    }
    if err := sc.Err(); err != nil {
        cleanup()
        return err
    }
    if err := bw.Flush(); err != nil {
        cleanup()
        return err
    }
    if err := tmp.Chmod(info.Mode().Perm()); err != nil {
        cleanup()
        return err
    }
    if err := tmp.Close(); err != nil {
        os.Remove(tmpPath)
        return err
    }

    // mtime check before atomic rename
    info2, err := os.Stat(path)
    if err != nil {
        os.Remove(tmpPath)
        return err
    }
    if !info2.ModTime().Equal(mtimeBefore) {
        os.Remove(tmpPath)
        return ErrConcurrentModification
    }

    if err := os.Rename(tmpPath, path); err != nil {
        os.Remove(tmpPath)
        return fmt.Errorf("rename: %w", err)
    }
    return nil
}

func stampLine(text, uuid string) string {
    // Replace only the first "@claude" that is not already tagged.
    return stampRE.ReplaceAllStringFunc(text, func(m string) string {
        if strings.Contains(m, "[fw-") {
            return m // already tagged, leave it
        }
        return "@claude[" + uuid + "]"
    })
}
```

Note: `stampLine` replaces every untagged `@claude` on the line. There should only be one per line in practice, and stamping all of them is harmless.

But wait — `ReplaceAllStringFunc` runs the func for every match. We need stampLine to only stamp the first untagged one. Refine:

```go
func stampLine(text, uuid string) string {
    done := false
    return stampRE.ReplaceAllStringFunc(text, func(m string) string {
        if done || strings.Contains(m, "[fw-") {
            return m
        }
        done = true
        return "@claude[" + uuid + "]"
    })
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `go test ./internal/rewriter/ -v`
Expected: all PASS.

- [ ] **Step 5: Commit**

```bash
git add internal/rewriter/
git commit -m "feat(rewriter): atomic UUID stamping with mode preservation"
```

---

### Task 7: Rewriter — mtime-conflict abort

**Files:**
- Modify: `/home/xanderle/code/rad/claudealong/internal/rewriter/rewriter_test.go`

- [ ] **Step 1: Write failing test**

Append to `rewriter_test.go`:

```go
import "time" // add to imports if not present

func TestStampUUIDsAbortsOnConcurrentModification(t *testing.T) {
    dir := t.TempDir()
    path := filepath.Join(dir, "foo.go")
    if err := os.WriteFile(path, []byte("// @claude do it\n"), 0o644); err != nil {
        t.Fatal(err)
    }

    // Pre-set mtime to a known past value, then mutate the file just before
    // StampUUIDs would rename, by hooking via a goroutine isn't reliable.
    // Instead: change mtime AFTER initial Stat by editing the file in-test.
    // We do this by wrapping: write again to bump mtime to "now", then call
    // StampUUIDs which read an older Stat.
    //
    // Easiest deterministic approach: set the file's mtime artificially old,
    // call StampUUIDs (which records that mtime), then bump mtime, then —
    // we can't because StampUUIDs runs synchronously.
    //
    // Instead, test the helper boundary: temporarily expose an internal
    // function that takes a "frozen mtimeBefore" and verify it aborts when
    // the on-disk mtime differs.
    //
    // Simpler alternative used here: write a helper that sets the file's
    // mtime to an old value, then concurrently writes during StampUUIDs by
    // using a small file and racing — flaky. Skip the goroutine race; test
    // the explicit error path via direct manipulation.

    // Touch the file with a future mtime, then artificially "go back" by
    // calling StampUUIDs after manually rewinding our captured mtime. We
    // do this by bumping mtime AFTER os.Stat inside StampUUIDs runs.
    //
    // The cleanest deterministic test: factor StampUUIDs to take a hook
    // for "between-stat-and-rename" — but that bloats the API. Instead:
    // pre-set mtime to T-1s, call StampUUIDs, then immediately also call
    // os.Chtimes from this test goroutine in a tight loop. Race window is
    // wide enough for a small file. If flaky, revisit.

    past := time.Now().Add(-1 * time.Hour)
    if err := os.Chtimes(path, past, past); err != nil {
        t.Fatal(err)
    }

    done := make(chan error, 1)
    go func() {
        done <- StampUUIDs(path, map[int]string{1: "fw-aaaaaaaa"})
    }()

    // Hammer Chtimes to ensure it runs between StampUUIDs's Stat and Rename.
    deadline := time.Now().Add(500 * time.Millisecond)
    for time.Now().Before(deadline) {
        os.Chtimes(path, time.Now(), time.Now())
    }

    err := <-done
    if err != nil && !errors.Is(err, ErrConcurrentModification) {
        t.Logf("StampUUIDs returned %v (acceptable: nil or ErrConcurrentModification)", err)
    }
    // We don't assert which: race timing decides. The test mainly verifies
    // ErrConcurrentModification is a real, exported error and the code path
    // can return it without panicking.
    _ = err
}
```

(Add `"errors"` import.)

This test is intentionally non-strict because the race is timing-dependent. The deterministic version of this assertion comes from a more invasive refactor; we accept the looser test for now.

- [ ] **Step 2: Run test**

Run: `go test ./internal/rewriter/ -run TestStampUUIDsAbortsOnConcurrentModification -v`
Expected: PASS (no panic; either error is acceptable per test logic).

- [ ] **Step 3: If the test never exercises the error path, add a deterministic test**

Refactor `StampUUIDs` to extract the post-write rename step into an unexported helper that takes the captured `mtimeBefore`, and write a unit test that calls the helper directly with a stale mtime to assert `ErrConcurrentModification`.

```go
// In rewriter.go, factor out:
func atomicReplace(path, tmpPath string, mode os.FileMode, mtimeBefore time.Time) error {
    info, err := os.Stat(path)
    if err != nil {
        os.Remove(tmpPath)
        return err
    }
    if !info.ModTime().Equal(mtimeBefore) {
        os.Remove(tmpPath)
        return ErrConcurrentModification
    }
    if err := os.Rename(tmpPath, path); err != nil {
        os.Remove(tmpPath)
        return fmt.Errorf("rename: %w", err)
    }
    return nil
}
```

And update `StampUUIDs` to call `atomicReplace` instead of inlining those steps.

Then in test (replace the flaky test above):

```go
func TestAtomicReplaceAbortsOnStaleMtime(t *testing.T) {
    dir := t.TempDir()
    path := filepath.Join(dir, "foo.go")
    if err := os.WriteFile(path, []byte("hi\n"), 0o644); err != nil {
        t.Fatal(err)
    }
    tmpPath := filepath.Join(dir, "tmp")
    if err := os.WriteFile(tmpPath, []byte("bye\n"), 0o644); err != nil {
        t.Fatal(err)
    }
    stale := time.Now().Add(-time.Hour)
    err := atomicReplace(path, tmpPath, 0o644, stale)
    if !errors.Is(err, ErrConcurrentModification) {
        t.Fatalf("got %v, want ErrConcurrentModification", err)
    }
}
```

- [ ] **Step 4: Run tests**

Run: `go test ./internal/rewriter/ -v`
Expected: all PASS.

- [ ] **Step 5: Commit**

```bash
git add internal/rewriter/
git commit -m "test(rewriter): cover concurrent-modification abort path"
```

---

### Task 8: Ignore — gitignore + hard-excludes + reload

**Files:**
- Create: `/home/xanderle/code/rad/claudealong/internal/ignore/ignore.go`
- Create: `/home/xanderle/code/rad/claudealong/internal/ignore/ignore_test.go`

- [ ] **Step 1: Add dependency**

```bash
go get github.com/sabhiram/go-gitignore
```

- [ ] **Step 2: Write failing tests**

`internal/ignore/ignore_test.go`:

```go
package ignore

import (
    "os"
    "path/filepath"
    "testing"
)

func writeGitignore(t *testing.T, dir, content string) {
    t.Helper()
    if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(content), 0o644); err != nil {
        t.Fatal(err)
    }
}

func TestHardExcludes(t *testing.T) {
    dir := t.TempDir()
    m, err := New(dir)
    if err != nil {
        t.Fatal(err)
    }
    cases := []struct {
        path string
        want bool
    }{
        {filepath.Join(dir, ".git", "HEAD"), true},
        {filepath.Join(dir, "node_modules", "x", "y.js"), true},
        {filepath.Join(dir, "dist", "out.js"), true},
        {filepath.Join(dir, "build", "out.o"), true},
        {filepath.Join(dir, "target", "x"), true},
        {filepath.Join(dir, ".venv", "bin", "python"), true},
        {filepath.Join(dir, "src", "foo.go"), false},
        {filepath.Join(dir, "foo.swp"), true},
        {filepath.Join(dir, "lock.lock"), true},
        {filepath.Join(dir, "a.tmp"), true},
    }
    for _, c := range cases {
        if got := m.ShouldIgnore(c.path); got != c.want {
            t.Errorf("ShouldIgnore(%q) = %v, want %v", c.path, got, c.want)
        }
    }
}

func TestGitignorePatterns(t *testing.T) {
    dir := t.TempDir()
    writeGitignore(t, dir, "secret.txt\n*.bak\n")
    m, err := New(dir)
    if err != nil {
        t.Fatal(err)
    }
    if !m.ShouldIgnore(filepath.Join(dir, "secret.txt")) {
        t.Error("secret.txt should be ignored")
    }
    if !m.ShouldIgnore(filepath.Join(dir, "old.bak")) {
        t.Error("old.bak should be ignored")
    }
    if m.ShouldIgnore(filepath.Join(dir, "ok.txt")) {
        t.Error("ok.txt should NOT be ignored")
    }
}

func TestReloadPicksUpNewPatterns(t *testing.T) {
    dir := t.TempDir()
    writeGitignore(t, dir, "")
    m, err := New(dir)
    if err != nil {
        t.Fatal(err)
    }
    p := filepath.Join(dir, "fresh.txt")
    if m.ShouldIgnore(p) {
        t.Fatal("fresh.txt should not be ignored before reload")
    }
    writeGitignore(t, dir, "fresh.txt\n")
    if err := m.Reload(); err != nil {
        t.Fatal(err)
    }
    if !m.ShouldIgnore(p) {
        t.Error("fresh.txt should be ignored after reload")
    }
}
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `go test ./internal/ignore/ -v`
Expected: build error.

- [ ] **Step 4: Implement**

`internal/ignore/ignore.go`:

```go
package ignore

import (
    "os"
    "path/filepath"
    "strings"
    "sync"

    gitignore "github.com/sabhiram/go-gitignore"
)

var hardExcludeDirs = []string{
    ".git", "node_modules", "dist", "build", "target", ".venv",
}
var hardExcludeSuffixes = []string{
    ".swp", ".tmp", ".lock",
}

type Matcher struct {
    root string
    mu   sync.RWMutex
    gi   *gitignore.GitIgnore
}

func New(root string) (*Matcher, error) {
    m := &Matcher{root: root}
    if err := m.Reload(); err != nil {
        return nil, err
    }
    return m, nil
}

func (m *Matcher) Reload() error {
    path := filepath.Join(m.root, ".gitignore")
    var gi *gitignore.GitIgnore
    if _, err := os.Stat(path); err == nil {
        loaded, err := gitignore.CompileIgnoreFile(path)
        if err != nil {
            return err
        }
        gi = loaded
    }
    m.mu.Lock()
    m.gi = gi
    m.mu.Unlock()
    return nil
}

func (m *Matcher) ShouldIgnore(path string) bool {
    rel, err := filepath.Rel(m.root, path)
    if err != nil || strings.HasPrefix(rel, "..") {
        return true
    }
    parts := strings.Split(rel, string(filepath.Separator))
    for _, p := range parts {
        for _, ex := range hardExcludeDirs {
            if p == ex {
                return true
            }
        }
    }
    base := filepath.Base(rel)
    for _, sfx := range hardExcludeSuffixes {
        if strings.HasSuffix(base, sfx) {
            return true
        }
    }
    m.mu.RLock()
    gi := m.gi
    m.mu.RUnlock()
    if gi != nil && gi.MatchesPath(rel) {
        return true
    }
    return false
}
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `go test ./internal/ignore/ -v`
Expected: all PASS.

- [ ] **Step 6: Commit**

```bash
git add go.mod go.sum internal/ignore/
git commit -m "feat(ignore): gitignore + hard-excludes with live reload"
```

---

### Task 9: Watcher — fsnotify with full op mask + inode re-watch

**Files:**
- Create: `/home/xanderle/code/rad/claudealong/internal/watcher/watcher.go`
- Create: `/home/xanderle/code/rad/claudealong/internal/watcher/watcher_test.go`

- [ ] **Step 1: Add dependency**

```bash
go get github.com/fsnotify/fsnotify
```

- [ ] **Step 2: Write failing tests**

`internal/watcher/watcher_test.go`:

```go
package watcher

import (
    "context"
    "os"
    "path/filepath"
    "testing"
    "time"

    "github.com/xanderle/claudealong/internal/ignore"
)

func newTestMatcher(t *testing.T, dir string) *ignore.Matcher {
    t.Helper()
    m, err := ignore.New(dir)
    if err != nil {
        t.Fatal(err)
    }
    return m
}

func TestWatcherFiresOnInPlaceWrite(t *testing.T) {
    dir := t.TempDir()
    p := filepath.Join(dir, "a.go")
    if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
        t.Fatal(err)
    }

    w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond)
    if err != nil {
        t.Fatal(err)
    }
    defer w.Close()

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    if err := w.Start(ctx); err != nil {
        t.Fatal(err)
    }

    // Trigger WRITE
    time.Sleep(20 * time.Millisecond)
    if err := os.WriteFile(p, []byte("y"), 0o644); err != nil {
        t.Fatal(err)
    }

    select {
    case ev := <-w.Events():
        if ev.Path != p {
            t.Errorf("got path %q, want %q", ev.Path, p)
        }
    case <-time.After(2 * time.Second):
        t.Fatal("no event received")
    }
}

func TestWatcherFiresOnAtomicSave(t *testing.T) {
    dir := t.TempDir()
    p := filepath.Join(dir, "a.go")
    if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
        t.Fatal(err)
    }

    w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond)
    if err != nil {
        t.Fatal(err)
    }
    defer w.Close()
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    if err := w.Start(ctx); err != nil {
        t.Fatal(err)
    }
    time.Sleep(20 * time.Millisecond)

    // Atomic-save: write tmpfile + rename over original
    tmp := filepath.Join(dir, ".a.go.tmp")
    if err := os.WriteFile(tmp, []byte("z"), 0o644); err != nil {
        t.Fatal(err)
    }
    if err := os.Rename(tmp, p); err != nil {
        t.Fatal(err)
    }

    select {
    case ev := <-w.Events():
        if ev.Path != p {
            t.Errorf("got path %q, want %q", ev.Path, p)
        }
    case <-time.After(2 * time.Second):
        t.Fatal("no event after atomic save (inode re-watch broken?)")
    }
}

func TestWatcherDebouncesBurst(t *testing.T) {
    dir := t.TempDir()
    p := filepath.Join(dir, "a.go")
    if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
        t.Fatal(err)
    }
    w, err := New(dir, newTestMatcher(t, dir), 200*time.Millisecond)
    if err != nil {
        t.Fatal(err)
    }
    defer w.Close()
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    if err := w.Start(ctx); err != nil {
        t.Fatal(err)
    }
    time.Sleep(50 * time.Millisecond)

    // Burst of writes within debounce window
    for i := 0; i < 5; i++ {
        os.WriteFile(p, []byte{'a' + byte(i)}, 0o644)
        time.Sleep(20 * time.Millisecond)
    }

    // Drain for 500ms; expect exactly 1 event
    count := 0
    deadline := time.After(500 * time.Millisecond)
loop:
    for {
        select {
        case <-w.Events():
            count++
        case <-deadline:
            break loop
        }
    }
    if count != 1 {
        t.Errorf("got %d events, want 1 (debounced)", count)
    }
}

func TestWatcherSkipsIgnoredPaths(t *testing.T) {
    dir := t.TempDir()
    if err := os.MkdirAll(filepath.Join(dir, "node_modules"), 0o755); err != nil {
        t.Fatal(err)
    }
    p := filepath.Join(dir, "node_modules", "a.go")
    if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
        t.Fatal(err)
    }
    w, err := New(dir, newTestMatcher(t, dir), 50*time.Millisecond)
    if err != nil {
        t.Fatal(err)
    }
    defer w.Close()
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    if err := w.Start(ctx); err != nil {
        t.Fatal(err)
    }
    time.Sleep(50 * time.Millisecond)
    os.WriteFile(p, []byte("y"), 0o644)

    select {
    case ev := <-w.Events():
        t.Fatalf("unexpected event for ignored path: %q", ev.Path)
    case <-time.After(300 * time.Millisecond):
        // pass
    }
}
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `go test ./internal/watcher/ -v`
Expected: build error.

- [ ] **Step 4: Implement**

`internal/watcher/watcher.go`:

```go
package watcher

import (
    "context"
    "errors"
    "os"
    "path/filepath"
    "sync"
    "time"

    "github.com/fsnotify/fsnotify"
    "github.com/xanderle/claudealong/internal/ignore"
)

type Event struct {
    Path string
}

type Watcher struct {
    root     string
    ig       *ignore.Matcher
    debounce time.Duration

    fs     *fsnotify.Watcher
    events chan Event

    mu        sync.Mutex
    pending   map[string]*time.Timer
    closed    bool
}

func New(root string, ig *ignore.Matcher, debounce time.Duration) (*Watcher, error) {
    fw, err := fsnotify.NewWatcher()
    if err != nil {
        return nil, err
    }
    return &Watcher{
        root:     root,
        ig:       ig,
        debounce: debounce,
        fs:       fw,
        events:   make(chan Event, 16),
        pending:  make(map[string]*time.Timer),
    }, nil
}

func (w *Watcher) Events() <-chan Event { return w.events }

func (w *Watcher) Start(ctx context.Context) error {
    if err := w.addRecursive(w.root); err != nil {
        return err
    }
    go w.run(ctx)
    return nil
}

func (w *Watcher) addRecursive(root string) error {
    return filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
        if err != nil {
            return nil // best-effort
        }
        if !info.IsDir() {
            return nil
        }
        if w.ig.ShouldIgnore(p) {
            return filepath.SkipDir
        }
        // Skip symlinked dirs
        if info.Mode()&os.ModeSymlink != 0 {
            return filepath.SkipDir
        }
        return w.fs.Add(p)
    })
}

func (w *Watcher) run(ctx context.Context) {
    defer close(w.events)
    for {
        select {
        case <-ctx.Done():
            return
        case ev, ok := <-w.fs.Events:
            if !ok {
                return
            }
            w.handle(ev)
        case _, ok := <-w.fs.Errors:
            if !ok {
                return
            }
        }
    }
}

func (w *Watcher) handle(ev fsnotify.Event) {
    if w.ig.ShouldIgnore(ev.Name) {
        return
    }
    info, err := os.Lstat(ev.Name)
    if err == nil && info.Mode()&os.ModeSymlink != 0 {
        return // skip symlinks
    }
    // Re-watch on rename/create of a directory
    if ev.Op&(fsnotify.Create) != 0 && err == nil && info.IsDir() {
        _ = w.addRecursive(ev.Name)
        return
    }
    if ev.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 {
        return
    }
    if err == nil && info.IsDir() {
        return
    }
    w.scheduleEvent(ev.Name)
}

func (w *Watcher) scheduleEvent(path string) {
    w.mu.Lock()
    defer w.mu.Unlock()
    if w.closed {
        return
    }
    if t, ok := w.pending[path]; ok {
        t.Stop()
    }
    w.pending[path] = time.AfterFunc(w.debounce, func() {
        w.mu.Lock()
        delete(w.pending, path)
        closed := w.closed
        w.mu.Unlock()
        if closed {
            return
        }
        select {
        case w.events <- Event{Path: path}:
        default:
            // drop if buffer full
        }
    })
}

func (w *Watcher) Close() error {
    w.mu.Lock()
    w.closed = true
    for _, t := range w.pending {
        t.Stop()
    }
    w.mu.Unlock()
    if err := w.fs.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
        return err
    }
    return nil
}
```

Note on inode re-watch: fsnotify on Linux internally re-resolves on `Create` events for paths that match an existing watch under their parent directory. By watching directories (not files) recursively, atomic saves (which create a new file at the old name) deliver `Create` for the path under its parent's watch. The `addRecursive` on directory-create handles the case where a new directory appears.

- [ ] **Step 5: Run tests to verify they pass**

Run: `go test ./internal/watcher/ -v`
Expected: all PASS.

- [ ] **Step 6: Commit**

```bash
git add go.mod go.sum internal/watcher/
git commit -m "feat(watcher): fsnotify with WRITE+CREATE+RENAME, debounce, ignore"
```

---

### Task 10: MCP wiring — emit notification on fired marker

**Files:**
- Create: `/home/xanderle/code/rad/claudealong/internal/mcp/mcp.go`
- Create: `/home/xanderle/code/rad/claudealong/internal/mcp/mcp_test.go`

- [ ] **Step 1: Write failing test**

`internal/mcp/mcp_test.go`:

```go
package mcp

import (
    "bytes"
    "context"
    "encoding/json"
    "io"
    "strings"
    "testing"
)

// readFrames reads newline-delimited JSON-RPC frames from r until it has count
// non-empty frames or EOF.
func readFrames(t *testing.T, r io.Reader, count int) []map[string]any {
    t.Helper()
    var out []map[string]any
    dec := json.NewDecoder(r)
    for len(out) < count {
        var f map[string]any
        if err := dec.Decode(&f); err != nil {
            if err == io.EOF {
                break
            }
            t.Fatalf("decode: %v", err)
        }
        out = append(out, f)
    }
    return out
}

func TestSendChannelEmitsCorrectFrame(t *testing.T) {
    var buf bytes.Buffer
    s := NewWithIO(strings.NewReader(""), &buf) // test-only constructor
    err := s.SendChannel(context.Background(), ChannelMeta{
        Source:  "filewatch",
        File:    "src/foo.ts",
        Line:    42,
        ReplyTo: "fw-a1b2c3d4",
    }, "@claude write a test for foo")
    if err != nil {
        t.Fatal(err)
    }
    var frame map[string]any
    if err := json.NewDecoder(&buf).Decode(&frame); err != nil {
        t.Fatal(err)
    }
    if frame["jsonrpc"] != "2.0" {
        t.Errorf("jsonrpc = %v, want 2.0", frame["jsonrpc"])
    }
    if frame["method"] != "notifications/claude/channel" {
        t.Errorf("method = %v, want notifications/claude/channel", frame["method"])
    }
    params := frame["params"].(map[string]any)
    if params["content"] != "@claude write a test for foo" {
        t.Errorf("content = %v", params["content"])
    }
    meta := params["meta"].(map[string]any)
    if meta["source"] != "filewatch" || meta["file"] != "src/foo.ts" || meta["replyTo"] != "fw-a1b2c3d4" {
        t.Errorf("meta wrong: %#v", meta)
    }
    // line should be a number, not a string
    if l, ok := meta["line"].(float64); !ok || l != 42 {
        t.Errorf("line = %v (%T), want 42 (number)", meta["line"], meta["line"])
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `go test ./internal/mcp/ -v`
Expected: build error.

- [ ] **Step 3: Implement**

`internal/mcp/mcp.go`:

```go
package mcp

import (
    "context"
    "encoding/json"
    "io"
    "os"
    "sync"
)

type ChannelMeta struct {
    Source  string `json:"source"`
    File    string `json:"file"`
    Line    int    `json:"line"`
    ReplyTo string `json:"replyTo"`
}

type Server struct {
    in  io.Reader
    out io.Writer
    mu  sync.Mutex // serializes writes to stdout
}

func New() *Server {
    return &Server{in: os.Stdin, out: os.Stdout}
}

// NewWithIO is for tests.
func NewWithIO(in io.Reader, out io.Writer) *Server {
    return &Server{in: in, out: out}
}

type notification struct {
    Jsonrpc string         `json:"jsonrpc"`
    Method  string         `json:"method"`
    Params  map[string]any `json:"params"`
}

func (s *Server) SendChannel(ctx context.Context, meta ChannelMeta, content string) error {
    n := notification{
        Jsonrpc: "2.0",
        Method:  "notifications/claude/channel",
        Params: map[string]any{
            "content": content,
            "meta": map[string]any{
                "source":  meta.Source,
                "file":    meta.File,
                "line":    meta.Line,
                "replyTo": meta.ReplyTo,
            },
        },
    }
    s.mu.Lock()
    defer s.mu.Unlock()
    enc := json.NewEncoder(s.out)
    return enc.Encode(n)
}

// Run drives the MCP request loop: reads JSON-RPC requests on s.in,
// responds with minimal init/initialized handshake, then reads-and-discards
// any further requests (we have no tools in v1). Notifications are written
// concurrently via SendChannel.
func (s *Server) Run(ctx context.Context) error {
    dec := json.NewDecoder(s.in)
    for {
        if ctx.Err() != nil {
            return nil
        }
        var req map[string]any
        if err := dec.Decode(&req); err != nil {
            if err == io.EOF {
                return nil
            }
            return err
        }
        method, _ := req["method"].(string)
        id := req["id"]
        if id == nil {
            continue // notification, ignore
        }
        var result any
        switch method {
        case "initialize":
            result = map[string]any{
                "protocolVersion": "2024-11-05",
                "capabilities":    map[string]any{},
                "serverInfo": map[string]any{
                    "name":    "filewatch-mcp",
                    "version": "0.1.0",
                },
            }
        default:
            // Method not found
            s.writeError(id, -32601, "method not found")
            continue
        }
        s.writeResult(id, result)
    }
}

func (s *Server) writeResult(id, result any) {
    s.mu.Lock()
    defer s.mu.Unlock()
    json.NewEncoder(s.out).Encode(map[string]any{
        "jsonrpc": "2.0", "id": id, "result": result,
    })
}

func (s *Server) writeError(id any, code int, msg string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    json.NewEncoder(s.out).Encode(map[string]any{
        "jsonrpc": "2.0", "id": id, "error": map[string]any{"code": code, "message": msg},
    })
}
```

**Note:** This implementation deliberately does NOT use the SDK from Task 2 — Task 2 confirmed feasibility, but for the actual production server we own the JSON-RPC framing because the only outbound message we need is a single custom notification, and direct JSON-RPC is simpler than working through SDK escape hatches. If Task 2's SDK approach ended up working cleanly, you may instead replace this file with an SDK-based version; the test's expectations on the wire format are the contract.

- [ ] **Step 4: Run test to verify it passes**

Run: `go test ./internal/mcp/ -v`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add internal/mcp/
git commit -m "feat(mcp): emit notifications/claude/channel via direct JSON-RPC"
```

---

### Task 11: Wire it all together in `cmd/filewatch-mcp/main.go`

**Files:**
- Modify: `/home/xanderle/code/rad/claudealong/cmd/filewatch-mcp/main.go`

- [ ] **Step 1: Replace main.go with wired version**

```go
package main

import (
    "context"
    "flag"
    "log"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/google/uuid"

    "github.com/xanderle/claudealong/internal/ignore"
    "github.com/xanderle/claudealong/internal/mcp"
    "github.com/xanderle/claudealong/internal/rewriter"
    "github.com/xanderle/claudealong/internal/scanner"
    "github.com/xanderle/claudealong/internal/watcher"
)

func main() {
    root := flag.String("root", ".", "project root to watch")
    debounce := flag.Duration("debounce", 500*time.Millisecond, "per-file debounce window")
    flag.Parse()

    ig, err := ignore.New(*root)
    if err != nil {
        log.Fatalf("ignore: %v", err)
    }
    w, err := watcher.New(*root, ig, *debounce)
    if err != nil {
        log.Fatalf("watcher: %v", err)
    }
    defer w.Close()

    srv := mcp.New()

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
    go func() { <-sigCh; cancel() }()

    if err := w.Start(ctx); err != nil {
        log.Fatalf("watcher start: %v", err)
    }
    go consume(ctx, w, srv)

    if err := srv.Run(ctx); err != nil {
        log.Fatalf("mcp run: %v", err)
    }
}

func consume(ctx context.Context, w *watcher.Watcher, srv *mcp.Server) {
    for {
        select {
        case <-ctx.Done():
            return
        case ev, ok := <-w.Events():
            if !ok {
                return
            }
            handle(ctx, ev.Path, srv)
        }
    }
}

func handle(ctx context.Context, path string, srv *mcp.Server) {
    markers, err := scanner.Scan(path)
    if err != nil {
        log.Printf("scan %s: %v", path, err)
        return
    }
    stamps := make(map[int]string, len(markers))
    for _, m := range markers {
        if m.Tagged {
            continue
        }
        id := "fw-" + uuid.NewString()[:8]
        stamps[m.Line] = id
        if err := srv.SendChannel(ctx, mcp.ChannelMeta{
            Source:  "filewatch",
            File:    path,
            Line:    m.Line,
            ReplyTo: id,
        }, m.Text); err != nil {
            log.Printf("send: %v", err)
        }
    }
    if len(stamps) == 0 {
        return
    }
    if err := rewriter.StampUUIDs(path, stamps); err != nil {
        log.Printf("stamp %s: %v", path, err)
    }
}
```

- [ ] **Step 2: Add UUID dependency**

```bash
go get github.com/google/uuid
```

- [ ] **Step 3: Build**

Run: `make build`
Expected: clean build.

- [ ] **Step 4: Commit**

```bash
git add go.mod go.sum cmd/
git commit -m "feat: wire watcher → scanner → mcp → rewriter in main"
```

---

### Task 12: End-to-end integration test

**Files:**
- Create: `/home/xanderle/code/rad/claudealong/cmd/filewatch-mcp/integration_test.go`

- [ ] **Step 1: Write integration test**

```go
package main

import (
    "bufio"
    "encoding/json"
    "io"
    "os"
    "os/exec"
    "path/filepath"
    "strings"
    "testing"
    "time"
)

// TestEndToEnd boots the binary as a subprocess, simulates a save, and
// verifies a notifications/claude/channel frame appears on stdout AND the
// file gets rewritten with [fw-XXXXXXXX].
func TestEndToEnd(t *testing.T) {
    if testing.Short() {
        t.Skip("integration")
    }
    if _, err := exec.LookPath("go"); err != nil {
        t.Skip("go toolchain not on PATH")
    }

    dir := t.TempDir()
    target := filepath.Join(dir, "foo.go")
    if err := os.WriteFile(target, []byte("package foo\n"), 0o644); err != nil {
        t.Fatal(err)
    }

    cmd := exec.Command("go", "run", ".", "--root", dir, "--debounce", "100ms")
    stdin, err := cmd.StdinPipe()
    if err != nil {
        t.Fatal(err)
    }
    stdout, err := cmd.StdoutPipe()
    if err != nil {
        t.Fatal(err)
    }
    cmd.Stderr = os.Stderr
    if err := cmd.Start(); err != nil {
        t.Fatal(err)
    }
    defer func() {
        stdin.Close()
        cmd.Process.Kill()
        cmd.Wait()
    }()

    // Send initialize so srv.Run completes the handshake.
    fmt.Fprint(stdin, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`+"\n")

    // Wait for init result then trigger a save.
    sc := bufio.NewScanner(stdout)
    sc.Buffer(make([]byte, 1<<16), 1<<16)
    if !sc.Scan() {
        t.Fatal("no init response")
    }
    // Now trigger the save (atomic rename).
    tmp := target + ".tmp"
    if err := os.WriteFile(tmp, []byte("// @claude please review\npackage foo\n"), 0o644); err != nil {
        t.Fatal(err)
    }
    if err := os.Rename(tmp, target); err != nil {
        t.Fatal(err)
    }

    // Look for the channel notification in subsequent frames.
    deadline := time.Now().Add(5 * time.Second)
    var sawChannel bool
    for time.Now().Before(deadline) {
        if !sc.Scan() {
            break
        }
        var f map[string]any
        if err := json.Unmarshal(sc.Bytes(), &f); err != nil {
            continue
        }
        if f["method"] == "notifications/claude/channel" {
            sawChannel = true
            break
        }
    }
    if !sawChannel {
        t.Fatal("no notifications/claude/channel frame on stdout")
    }

    // Verify the file got stamped.
    got, _ := os.ReadFile(target)
    if !strings.Contains(string(got), "@claude[fw-") {
        t.Errorf("file not stamped:\n%s", got)
    }
}
```

(Add `"fmt"` to imports.)

- [ ] **Step 2: Run integration test**

Run: `go test ./cmd/filewatch-mcp/ -run TestEndToEnd -v`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add cmd/filewatch-mcp/
git commit -m "test: end-to-end save-to-stamp integration"
```

---

### Task 13: README + .mcp.json template

**Files:**
- Create: `/home/xanderle/code/rad/claudealong/README.md`
- Create: `/home/xanderle/code/rad/claudealong/.mcp.json`

- [ ] **Step 1: Write `.mcp.json` template**

```json
{
  "mcpServers": {
    "filewatch": {
      "command": "filewatch-mcp",
      "args": ["--root", "."]
    }
  }
}
```

- [ ] **Step 2: Write README.md**

Sections to cover:
- What it does (one paragraph: write `@claude X` in a comment, save, Claude in your running session sees it).
- Install: `make install`.
- Configure: copy `.mcp.json` into your project, run Claude Code with `--channels`.
- How dedupe works (UUID stamping in-place — explain editor-reload caveat for JetBrains).
- Supported languages (list from scanner table).
- Limitations (single-line comments only, no multi-session).

- [ ] **Step 3: Commit**

```bash
git add README.md .mcp.json
git commit -m "docs: README and .mcp.json template"
```

---

### Task 14: Manual smoke test

- [ ] **Step 1: Install binary**

```bash
make install
```

- [ ] **Step 2: Set up a test project**

Create or pick a small Go/Python project. Add `.mcp.json` from this repo. Start Claude Code with `--channels` in that project.

- [ ] **Step 3: Add a marker and save**

In some source file, type `// @claude what does this function do` above a function. Save.

- [ ] **Step 4: Verify**

- A `<channel source="filewatch" ...>` block appears in the Claude Code session.
- Claude reads the file and answers in the session.
- Claude removes the marker line via `Edit`.
- The file no longer contains `@claude` (it was either removed or stamped+removed).

- [ ] **Step 5: Repeat with each editor**

VS Code, Vim, JetBrains. Confirm:
- VS Code auto-reloads cleanly.
- Vim with `set autoread` reloads cleanly.
- JetBrains prompts on external change — accept the reload.

- [ ] **Step 6: If smoke fails**

File issues per editor in `docs/superpowers/specs/`. The most likely failure modes:
- JetBrains atomic save not delivering Create event in time → bump debounce.
- VS Code's "files.autoSave: afterDelay" causing burst → bump debounce.
- Claude Code session doesn't show the channel block → confirm `--channels` is enabled and the SDK spike (Task 2) is still working.

---

## Self-review notes (post-write)

- **Spec coverage:**
  - Architecture diagram → Tasks 1, 9–11 ✓
  - Inline UUID stamping → Tasks 6–7, 11 ✓
  - fsnotify op mask + inode re-watch → Task 9 ✓
  - SDK spike → Task 2 ✓
  - Editor reload risk → covered in Task 14 manual smoke ✓
  - Single-line comments only → enforced by scanner regex (Task 4); documented in Task 13 README ✓
  - Size cap + binary sniff → Task 5 ✓
  - Symlinks not followed → Task 9 watcher ✓
  - `.gitignore` + hard-excludes + reload → Task 8 ✓
- **Type consistency:** `Marker.UUID` introduced in Task 5 used unchanged in Task 11; `ChannelMeta` field types match across mcp_test.go and main.go; `StampUUIDs(path, map[int]string)` signature consistent across tasks.
- **Notes:**
  - Task 10 deliberately bypasses the official Go SDK in favor of direct JSON-RPC framing. The Task 2 spike validates the channel mechanism end-to-end; the production server then owns its framing because it only needs one custom notification and an init handshake. This trades SDK ergonomics for transparency. If you prefer SDK-mediated framing, the test contract in Task 10 (the wire-format assertions) is what must hold.
  - Task 7's flaky timing test is replaced inline by a deterministic helper-level test; the original is left in the plan for context, but you should land only the deterministic version.
diff --git a/docs/superpowers/specs/2026-04-28-claude-channel-filewatch-design.md b/docs/superpowers/specs/2026-04-28-claude-channel-filewatch-design.md
new file mode 100644
index 0000000..48cda21
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-28-claude-channel-filewatch-design.md
@@ -0,0 +1,209 @@
# `@claude` File-Watcher MCP — Design

**Date:** 2026-04-28
**Status:** Draft, ready for review

## Goal

Let a developer write `@claude <ask>` inside a code comment, save the file, and have a running Claude Code session pick up the ask automatically — with file path, line number, and the comment text — then handle it (answer in-session for questions, edit the file for work asks). Claude removes the marker as part of handling.

## Scope

Solo, local. Single developer machine. One Claude Code session running. Single project directory under watch.

Out of scope for v1: multi-user / team / shared bot, remote triggering (`RemoteTrigger`), GitHub PR integration, response-back-into-the-file workflows, multi-line block-comment markers (single-line comments only).

## Architecture

```
┌──────────────────────────────────────┐
│         Claude Code session          │
│                                      │
│  receives <channel> notifications    │
│  reads file, edits, removes marker   │
└──────────────┬───────────────────────┘
               │ stdio (MCP)
┌──────────────▼───────────────────────┐
│   filewatch-mcp (Go binary)          │
│                                      │
│  • fsnotify watcher (WRITE+CREATE+   │
│    RENAME, re-watch on rename)       │
│  • scanner: comment-syntax table,    │
│    distinguishes tagged vs. untagged │
│  • rewriter: atomic UUID stamping    │
│  • mcp: notifications/claude/channel │
└──────────────────────────────────────┘
```

Single Go binary spawned by Claude Code as an MCP child process over stdio. No state file, no daemons, no IRC.

## Key design decision: file is the state

Untagged marker `// @claude write a test` is the source of "this needs handling." On first detection, the watcher rewrites the line in place to `// @claude[fw-a1b2c3d4] write a test`. The bracketed UUID **is** the dedupe state. Tagged markers are ignored on subsequent scans. Claude removes the whole tagged line when handling the ask, returning the file to a clean state.

Consequences:
- No `state.json`, no `flock`, no multi-session race.
- No feedback-loop suppression: Claude's `Edit` triggers fsnotify, scanner sees a tagged (or absent) marker, no fire.
- The watcher modifies the user's file. Editors will detect the on-disk change and either auto-reload (VS Code, Vim with `autoread`) or prompt (JetBrains). Documented in Open Risks.

## Components

### `internal/watcher`
- Wraps `fsnotify` with op mask `WRITE | CREATE | RENAME`.
- Atomic saves (tmpfile + rename, used by Vim, JetBrains, VS Code with `files.atomicSave`) deliver `RENAME`/`CREATE`, not `WRITE` — all three must trigger a scan.
- On Linux, fsnotify watches inodes; after a rename-replace the watch points at the deleted inode. Watcher must re-establish the watch on the new path when a `RENAME` arrives.
- 500 ms per-file trailing-edge debounce (JetBrains autosave bursts >200 ms apart).
- Symlinks: not followed. Watcher uses `Lstat` and skips them.
- Respects `.gitignore` plus hard-excludes: `.git/`, `node_modules/`, `dist/`, `build/`, `target/`, `.venv/`, `*.swp`, `*.tmp`, `*.lock`.

### `internal/scanner`
- Per-extension comment-syntax table:
  - `//` — js, ts, jsx, tsx, go, c, cpp, h, hpp, rs, java, kt, swift, scala, cs
  - `#` — py, rb, sh, bash, zsh, yaml, yml, toml, dockerfile, makefile (matched by name)
  - `--` — sql, lua, hs, elm
  - `/* */` block — c-family, css (single-line `/* ... */` only; multi-line out of scope)
  - `<!-- -->` — html, xml, md (single-line only)
- Unrecognized extensions are skipped.
- Size cap: files > 1 MB are skipped.
- Binary sniff: if the first 8 KB contains a null byte, skip.
- A line is a marker iff: it is a single-line comment in the file's language **and** matches one of:
  - **Untagged:** `@claude` followed by whitespace + non-empty payload. Fires.
  - **Tagged:** `@claude[fw-XXXXXXXX]` (8 hex chars) followed by whitespace + payload. Skipped.
- Returns `[]Marker{ Line int, Text string, Tagged bool }`. `Text` is the marker payload starting at `@claude`, with comment delimiters and trailing block-comment terminators (`*/`, `-->`) stripped.

### `internal/rewriter`
- Given a file path and a list of untagged markers (line numbers + UUIDs to stamp), rewrites the file:
  1. Read whole file.
  2. For each marker line, splice `[fw-XXXXXXXX]` immediately after `@claude` (preserving leading whitespace, comment delimiter, and the rest of the line).
  3. Write to a sibling tmpfile in the same directory.
  4. `os.Rename` over the original. Atomic on POSIX.
- Preserves file mode (`Stat` → `Chmod`).
- Aborts and skips the rewrite if the file's mtime changed between read and rename (someone — likely the user's editor — wrote to it concurrently). The marker stays untagged and will be re-attempted on the next save.

### `internal/mcp`
- MCP stdio server using `github.com/modelcontextprotocol/go-sdk`.
- For each fired (untagged) marker, emits one outbound notification:

  ```json
  {
    "jsonrpc": "2.0",
    "method": "notifications/claude/channel",
    "params": {
      "content": "@claude can you write a test for foo",
      "meta": {
        "source": "filewatch",
        "file": "src/foo.ts",
        "line": 42,
        "replyTo": "fw-a1b2c3d4"
      }
    }
  }
  ```

  Claude renders this as `<channel source="filewatch" file="src/foo.ts" line="42" replyTo="fw-a1b2c3d4">@claude can you write a test for foo</channel>`. The `replyTo` UUID matches the bracketed tag stamped into the file.
- No reply tool exposed in v1. Claude's `Edit` to remove the marker is the implicit ack.

### `internal/ignore`
- `.gitignore` parsing via `github.com/sabhiram/go-gitignore`.
- Hard-exclude list takes precedence.
- `.gitignore` re-read on its own change events (so updates are picked up live).

## Data Flow

1. User edits `src/foo.ts`, adds `// @claude write a test for foo`, saves.
2. Editor writes via tmpfile + rename → fsnotify delivers `CREATE` (and possibly `RENAME`); watcher re-establishes inode watch and triggers scan.
3. Watcher debounces 500 ms; scanner reads the file.
4. Scanner returns one marker: `{ Line: 42, Text: "@claude write a test for foo", Tagged: false }`.
5. MCP generates UUID `fw-a1b2c3d4`, emits `notifications/claude/channel`.
6. Rewriter atomically rewrites line 42 to `// @claude[fw-a1b2c3d4] write a test for foo`.
7. Claude Code session displays the `<channel>` block; Claude reads `src/foo.ts`, writes the test, removes line 42 with `Edit`.
8. Both the rewrite (step 6) and Claude's edit (step 7) trigger fresh fsnotify events. Scanner sees only tagged (or no) markers. Nothing fires.

## Lifecycle

- **Startup:** no full-tree scan. Watch saves going forward only. Pre-existing untagged markers stay dormant until the file is touched.
- **Shutdown:** clean fsnotify close on SIGINT/SIGTERM. No state to persist.

## Configuration

`.mcp.json` registration (this is what users add to their project):

```json
{
  "mcpServers": {
    "filewatch": {
      "command": "filewatch-mcp",
      "args": ["--root", "."]
    }
  }
}
```

Flags:
- `--root` (default `.`) — project root to watch.
- `--debounce` (default `500ms`) — trailing-edge per-file debounce.

No JSON config file in v1. Add when a real need surfaces.

Requires Claude Code with `--channels` enabled.

## Project Layout

```
claudealong/
├── go.mod
├── cmd/filewatch-mcp/main.go
├── internal/
│   ├── watcher/
│   ├── scanner/
│   ├── rewriter/
│   ├── mcp/
│   └── ignore/
├── .mcp.json                     # template for users
├── Makefile
├── docs/superpowers/specs/
└── README.md
```

## Makefile

Composable targets:

- `make build` — `go build -o bin/filewatch-mcp ./cmd/filewatch-mcp`
- `make test` — `go test ./...`
- `make lint` — `golangci-lint run`
- `make run` — `go run ./cmd/filewatch-mcp --root .`
- `make install` — `go install ./cmd/filewatch-mcp`
- `make clean` — `rm -rf bin/`

## Implementation order

1. **SDK spike (1 day max).** Confirm `github.com/modelcontextprotocol/go-sdk` lets a server send a custom `notifications/claude/channel` outbound notification, end-to-end, into a real Claude Code session with `--channels`. If the SDK rejects custom method names, this design is dead and we revisit (likely: build directly on JSON-RPC 2.0 stdio without the SDK).
2. Scanner + comment-syntax table + tagged/untagged distinction.
3. Rewriter with atomic-rename and mtime-conflict abort.
4. Watcher with full op mask + inode re-watch on rename.
5. MCP wiring + notification emission.
6. Integration test: temp dir, simulated saves via different editors (atomic vs. in-place), assert the right JSON-RPC frames on stdout.

## Testing

- **Unit:**
  - `scanner`: comment-syntax detection per extension, tagged-vs-untagged, payload extraction, size cap, binary sniff.
  - `rewriter`: atomic write semantics, mtime-conflict abort, mode preservation, idempotency on already-tagged lines.
  - `ignore`: `.gitignore` matching, hard-exclude precedence, live re-read on `.gitignore` change.
- **Integration:**
  - Boot the MCP server with a temp project dir.
  - Simulate atomic save (tmpfile + rename); assert WRITE/CREATE/RENAME all trigger scan.
  - Write a file with `// @claude X`, assert the JSON-RPC frame on stdout matches the expected `notifications/claude/channel` shape AND the file is rewritten with `[fw-XXXXXXXX]`.
  - Edit the file to remove the marker, assert no refire.
  - Edit the file to add a new untagged marker beside the tagged one, assert only the new one fires.
- **Manual smoke:**
  - Run against a real Claude Code session with `--channels`. Add `// @claude what does this do` above a function in VS Code, Vim, and a JetBrains IDE in turn; eyeball the channel block in-session and confirm Claude responds and removes the line.

## Open Risks

- **`notifications/claude/channel` is Claude-Code-specific** and the official Go SDK may validate method names against known MCP methods. Mitigated by the day-1 SDK spike (Implementation step 1). Fallback if the SDK rejects: write directly to the stdio JSON-RPC layer without the SDK.
- **Inline UUID stamping modifies the user's file mid-session.** Editors with auto-reload (VS Code, Vim+`autoread`) handle this transparently. JetBrains and others may prompt. Document in README so users are not surprised. Mitigated by atomic-rename + mtime-conflict abort so we never clobber concurrent user writes.
- **fsnotify on Linux watches inodes**, not paths. Atomic-save editors break naive watches. Mitigated by re-establishing the watch on every `RENAME`/`CREATE` event.
- **`.gitignore` semantics are subtle.** Use a vetted parser, don't roll our own.
- **Single-line comments only.** Multi-line block-comment markers (e.g., `/* @claude\n   foo */`) silently won't fire. Document in README; revisit if real users hit this.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..0ac0f68
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
module github.com/xanderle/claudealong

go 1.26.2