a73x

326fddae

spike: validate channel mechanism, fix meta schema and capability

a73x   2026-04-29 05:41

Two corrections from end-to-end smoke against Claude Code 2.1.121:

1. MCP server must declare experimental.claude/channel capability in
   the initialize response, or Claude Code silently skips channel
   registration ("server did not declare claude/channel capability").

2. notifications/claude/channel meta is Record<string, string>; non-
   string values (e.g. line as number) are silently rejected by Zod
   validation. Line is now stringified.

The original spec and Task 10/11 plan code had line as number, citing
"don't use string-typed integers." Reverted: schema requires string.

diff --git a/docs/superpowers/plans/2026-04-28-claudealong-filewatch-mcp.md b/docs/superpowers/plans/2026-04-28-claudealong-filewatch-mcp.md
index e85ed06..7cf4641 100644
--- a/docs/superpowers/plans/2026-04-28-claudealong-filewatch-mcp.md
+++ b/docs/superpowers/plans/2026-04-28-claudealong-filewatch-mcp.md
@@ -45,10 +45,11 @@ func (w *Watcher) Start(ctx context.Context) error
func (w *Watcher) Close() error

// internal/mcp
// All fields are strings: schema is Record<string, string>.
type ChannelMeta struct {
    Source  string // always "filewatch"
    File    string
    Line    int
    Line    string // stringified line number
    ReplyTo string // "fw-XXXXXXXX"
}
type Server struct{ /* unexported */ }
@@ -1662,8 +1663,8 @@ func TestSendChannelEmitsCorrectFrame(t *testing.T) {
        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"])
    if s, ok := meta["line"].(string); !ok || s != "42" {
        t.Errorf("line = %v (%T), want \"42\" (string)", meta["line"], meta["line"])
    }
}
```
@@ -1688,10 +1689,12 @@ import (
    "sync"
)

// Note: all meta fields are strings — Claude Code's schema is
// Record<string, string> and silently drops non-string values.
type ChannelMeta struct {
    Source  string `json:"source"`
    File    string `json:"file"`
    Line    int    `json:"line"`
    Line    string `json:"line"`
    ReplyTo string `json:"replyTo"`
}

@@ -1761,9 +1764,21 @@ func (s *Server) Run(ctx context.Context) error {
        var result any
        switch method {
        case "initialize":
            // Echo the client's protocolVersion so we accept whatever they speak.
            params, _ := req["params"].(map[string]any)
            protoVer, _ := params["protocolVersion"].(string)
            if protoVer == "" {
                protoVer = "2024-11-05"
            }
            // experimental.claude/channel capability is REQUIRED — Claude Code
            // silently skips channel registration without it.
            result = map[string]any{
                "protocolVersion": "2024-11-05",
                "capabilities":    map[string]any{},
                "protocolVersion": protoVer,
                "capabilities": map[string]any{
                    "experimental": map[string]any{
                        "claude/channel": map[string]any{},
                    },
                },
                "serverInfo": map[string]any{
                    "name":    "filewatch-mcp",
                    "version": "0.1.0",
@@ -1827,6 +1842,7 @@ import (
    "log"
    "os"
    "os/signal"
    "strconv"
    "syscall"
    "time"

@@ -1903,7 +1919,7 @@ func handle(ctx context.Context, path string, srv *mcp.Server) {
        if err := srv.SendChannel(ctx, mcp.ChannelMeta{
            Source:  "filewatch",
            File:    path,
            Line:    m.Line,
            Line:    strconv.Itoa(m.Line),
            ReplyTo: id,
        }, m.Text); err != nil {
            log.Printf("send: %v", err)
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
index 48cda21..a499997 100644
--- a/docs/superpowers/specs/2026-04-28-claude-channel-filewatch-design.md
+++ b/docs/superpowers/specs/2026-04-28-claude-channel-filewatch-design.md
@@ -82,7 +82,24 @@ Consequences:

### `internal/mcp`
- MCP stdio server using `github.com/modelcontextprotocol/go-sdk`.
- For each fired (untagged) marker, emits one outbound notification:
- **Init handshake must declare the channel capability**, or Claude Code silently skips registration:

  ```json
  {
    "jsonrpc": "2.0", "id": 0,
    "result": {
      "protocolVersion": "<echo client's>",
      "capabilities": {
        "experimental": {
          "claude/channel": {}
        }
      },
      "serverInfo": { "name": "filewatch-mcp", "version": "..." }
    }
  }
  ```

- For each fired (untagged) marker, emits one outbound notification. **All `meta` values must be strings** — Claude Code's schema is `Record<string, string>`; non-string values are silently rejected by validation.

  ```json
  {
@@ -93,7 +110,7 @@ Consequences:
      "meta": {
        "source": "filewatch",
        "file": "src/foo.ts",
        "line": 42,
        "line": "42",
        "replyTo": "fw-a1b2c3d4"
      }
    }
@@ -102,6 +119,7 @@ Consequences:

  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.
- Confirmed via spike on Claude Code v2.1.121 with `--dangerously-load-development-channels server:<name>`.

### `internal/ignore`
- `.gitignore` parsing via `github.com/sabhiram/go-gitignore`.