Implement Task 9: textual diff

Prompt:
You are implementing Task 9 of the birdseye CLI implementation plan.

## Working Environment

- **Worktree path:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`
- **Git branch:** `worktree-birdseye`
- Recent commits include Phase A + Phase B (latest: `b7ab5b6`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

This task creates a NEW package `internal/diff` in `apps/birdseye/`. It contains the pure diff functions that take two `archive.Module` values and produce typed diff results. Subsequent tasks add the plain CLI rendering, TUI rendering, and semantic HCL diff.

The textual diff uses `github.com/hexops/gotextdiff` (same library `gopls` uses). The plan provides the full implementation; your job is mostly mechanical transcription with TDD.

Strict TDD: write failing tests, run, then implement.

## Task Description (Verbatim)

### Task 9: `internal/diff` — types and textual diff

**Files:**
- Create: `apps/birdseye/internal/diff/types.go`
- Create: `apps/birdseye/internal/diff/textual.go`
- Create: `apps/birdseye/internal/diff/textual_test.go`

- [ ] **Step 1: Write failing test** at `apps/birdseye/internal/diff/textual_test.go`:

```go
package diff

import (
	"strings"
	"testing"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
)

func mkArchive(files map[string]string) archive.Module {
	out := archive.Module{Files: make(map[string][]byte, len(files))}
	for k, v := range files {
		out.Files[k] = []byte(v)
	}
	return out
}

func findFile(t *testing.T, ds []FileDiff, path string) FileDiff {
	t.Helper()
	for _, d := range ds {
		if d.Path == path {
			return d
		}
	}
	t.Fatalf("no FileDiff for %s; got %d entries", path, len(ds))
	return FileDiff{}
}

func TestTextualAdded(t *testing.T) {
	a := mkArchive(nil)
	b := mkArchive(map[string]string{"new.tf": "x\n"})
	ds := Textual(a, b)
	d := findFile(t, ds, "new.tf")
	if d.Status != StatusAdded {
		t.Errorf("status = %v, want Added", d.Status)
	}
	if d.Adds != 1 || d.Dels != 0 {
		t.Errorf("adds/dels = %d/%d", d.Adds, d.Dels)
	}
}

func TestTextualRemoved(t *testing.T) {
	a := mkArchive(map[string]string{"old.tf": "x\n"})
	b := mkArchive(nil)
	ds := Textual(a, b)
	d := findFile(t, ds, "old.tf")
	if d.Status != StatusRemoved {
		t.Errorf("status = %v, want Removed", d.Status)
	}
	if d.Adds != 0 || d.Dels != 1 {
		t.Errorf("adds/dels = %d/%d", d.Adds, d.Dels)
	}
}

func TestTextualModified(t *testing.T) {
	a := mkArchive(map[string]string{"m.tf": "alpha\nbeta\n"})
	b := mkArchive(map[string]string{"m.tf": "alpha\nGAMMA\n"})
	ds := Textual(a, b)
	d := findFile(t, ds, "m.tf")
	if d.Status != StatusModified {
		t.Errorf("status = %v, want Modified", d.Status)
	}
	if !strings.Contains(d.Unified, "-beta") || !strings.Contains(d.Unified, "+GAMMA") {
		t.Errorf("unified missing expected lines:\n%s", d.Unified)
	}
}

func TestTextualIdenticalReturnsEmpty(t *testing.T) {
	a := mkArchive(map[string]string{"m.tf": "x\n"})
	b := mkArchive(map[string]string{"m.tf": "x\n"})
	if got := Textual(a, b); len(got) != 0 {
		t.Errorf("expected 0 diffs, got %d", len(got))
	}
}

func TestTextualSortedByPath(t *testing.T) {
	a := mkArchive(map[string]string{"z.tf": "a\n", "a.tf": "a\n"})
	b := mkArchive(map[string]string{"z.tf": "b\n", "a.tf": "b\n"})
	ds := Textual(a, b)
	if len(ds) != 2 {
		t.Fatalf("got %d diffs, want 2", len(ds))
	}
	if ds[0].Path != "a.tf" || ds[1].Path != "z.tf" {
		t.Errorf("expected sorted by path; got %s, %s", ds[0].Path, ds[1].Path)
	}
}
```

- [ ] **Step 2: Run, expect FAIL** (compile errors)

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/diff/...`

- [ ] **Step 3: Implement `apps/birdseye/internal/diff/types.go`**

```go
// Package diff produces FileDiff entries and optional SemanticChange
// entries from a pair of extracted Modules. All functions are pure: no
// I/O, no globals.
package diff

// FileStatus describes what happened to a file between the two versions.
type FileStatus int

const (
	StatusModified FileStatus = iota
	StatusAdded
	StatusRemoved
)

func (s FileStatus) String() string {
	switch s {
	case StatusAdded:
		return "added"
	case StatusRemoved:
		return "removed"
	default:
		return "modified"
	}
}

// FileDiff is a per-file diff result. Unified holds the rendered diff
// hunks; Adds/Dels are the count of added/removed lines for summary
// rendering.
type FileDiff struct {
	Path    string
	Status  FileStatus
	Unified string
	Adds    int
	Dels    int
}

// SemanticChange is a single HCL-aware change. Kind is one of "resource",
// "variable", "output", "provider", "locals", "data", or "module".
type SemanticChange struct {
	Kind   string
	Name   string
	Status FileStatus
	Detail string
}

// Result is what cmd/diff renders. Semantic is nil unless --semantic was
// requested.
type Result struct {
	Files    []FileDiff
	Semantic []SemanticChange
}
```

- [ ] **Step 4: Implement `apps/birdseye/internal/diff/textual.go`**

```go
package diff

import (
	"sort"
	"strings"

	"github.com/hexops/gotextdiff"
	"github.com/hexops/gotextdiff/myers"
	"github.com/hexops/gotextdiff/span"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
)

// Textual returns a FileDiff per file that differs between a and b,
// sorted by path. Files identical in both archives are not included.
func Textual(a, b archive.Module) []FileDiff {
	all := make(map[string]struct{})
	for k := range a.Files {
		all[k] = struct{}{}
	}
	for k := range b.Files {
		all[k] = struct{}{}
	}
	paths := make([]string, 0, len(all))
	for k := range all {
		paths = append(paths, k)
	}
	sort.Strings(paths)

	out := make([]FileDiff, 0, len(paths))
	for _, p := range paths {
		ab, aok := a.Files[p]
		bb, bok := b.Files[p]
		switch {
		case aok && bok:
			if string(ab) == string(bb) {
				continue
			}
			out = append(out, modifiedDiff(p, string(ab), string(bb)))
		case !aok && bok:
			out = append(out, addedDiff(p, string(bb)))
		case aok && !bok:
			out = append(out, removedDiff(p, string(ab)))
		}
	}
	return out
}

func modifiedDiff(path, oldText, newText string) FileDiff {
	edits := myers.ComputeEdits(span.URIFromPath(path), oldText, newText)
	unified := gotextdiff.ToUnified(path, path, oldText, edits)
	u := unified.String()
	adds, dels := countLines(u)
	return FileDiff{Path: path, Status: StatusModified, Unified: u, Adds: adds, Dels: dels}
}

func addedDiff(path, newText string) FileDiff {
	return FileDiff{
		Path:    path,
		Status:  StatusAdded,
		Unified: "+++ " + path + "\n" + prefixLines(newText, "+"),
		Adds:    countNonEmptyLines(newText),
	}
}

func removedDiff(path, oldText string) FileDiff {
	return FileDiff{
		Path:    path,
		Status:  StatusRemoved,
		Unified: "--- " + path + "\n" + prefixLines(oldText, "-"),
		Dels:    countNonEmptyLines(oldText),
	}
}

func prefixLines(s, prefix string) string {
	if s == "" {
		return ""
	}
	lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
	var sb strings.Builder
	sb.Grow(len(s) + len(lines)*len(prefix))
	for _, l := range lines {
		sb.WriteString(prefix)
		sb.WriteString(l)
		sb.WriteByte('\n')
	}
	return sb.String()
}

func countNonEmptyLines(s string) int {
	if s == "" {
		return 0
	}
	n := strings.Count(s, "\n")
	if !strings.HasSuffix(s, "\n") {
		n++
	}
	return n
}

func countLines(unified string) (adds, dels int) {
	for _, line := range strings.Split(unified, "\n") {
		if len(line) == 0 {
			continue
		}
		switch line[0] {
		case '+':
			if !strings.HasPrefix(line, "+++") {
				adds++
			}
		case '-':
			if !strings.HasPrefix(line, "---") {
				dels++
			}
		}
	}
	return adds, dels
}
```

- [ ] **Step 5: Run, expect PASS**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/diff/... -v`
Expected: All 5 tests PASS.

- [ ] **Step 6: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/diff/ apps/birdseye/go.mod apps/birdseye/go.sum && git commit -m "feat(birdseye): textual unified diff between extracted modules"
```

Drop go.mod/go.sum from `git add` if neither was modified.

## Notes & Style

- Strict TDD.
- `github.com/hexops/gotextdiff` should already be in go.mod from Task 1's initial dependency list. `go mod tidy` is your friend if not.
- The plan deliberately splits added/removed/modified into helper functions for clarity. Keep them.
- The `prefixLines` and `countLines` helpers are private to this file. Don't export.
- Tests do not import gotextdiff — they only verify behavior via the public `FileDiff` shape.

## Code Organization

Two production files: `types.go` (data) + `textual.go` (logic). Test file: `textual_test.go`. Three files total.

## Self-Review Checklist

- [ ] All 5 textual tests pass (-race)
- [ ] `go vet ./...` clean
- [ ] go.mod/go.sum reflect any new direct dep on `gotextdiff` (it should already be direct from Task 1's plan, but verify)
- [ ] `StatusModified` is the iota=0 value (the plan puts it first)
- [ ] `FileStatus.String()` returns "added", "removed", "modified"
- [ ] Identical archives → empty result (verified by `TestTextualIdenticalReturnsEmpty`)
- [ ] Output is path-sorted (verified by `TestTextualSortedByPath`)
- [ ] Commit message exactly: `feat(birdseye): textual unified diff between extracted modules`

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files created
- Test count + pass status
- go.mod/go.sum changes (if any)
- Commit SHA
- Anything unexpected


**Status:** DONE

**Files created:**
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/types.go`
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual.go`
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/diff/textual_test.go`

**Test count + pass status:** 5/5 PASS with `-race`

**go.mod/go.sum changes:** `github.com/hexops/gotextdiff v1.0.3` added as a direct dependency (was not in go.mod prior to this task). go.sum updated accordingly.

**Commit SHA:** `16193cc`

**One deviation from the plan:** The plan used `unified.String()` but `gotextdiff.Unified` implements `fmt.Formatter` (not `fmt.Stringer`). Used `fmt.Sprintf("%v", unified)` instead — same output, all tests pass.

agentId: a820e4ae845f49330 (use SendMessage with to: 'a820e4ae845f49330' to continue this agent)
<usage>total_tokens: 31751
tool_uses: 24
duration_ms: 406396</usage>

Prompt:
You are implementing Task 9 of the birdseye CLI implementation plan.

## Working Environment

- **Worktree path:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`
- **Git branch:** `worktree-birdseye`
- Recent commits include Phase A + Phase B (latest: `b7ab5b6`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

This task creates a NEW package `internal/diff` in `apps/birdseye/`. It contains the pure diff functions that take two `archive.Module` values and produce typed diff results. Subsequent tasks add the plain CLI rendering, TUI rendering, and semantic HCL diff.

The textual diff uses `github.com/hexops/gotextdiff` (same library `gopls` uses). The plan provides the full implementation; your job is mostly mechanical transcription with TDD.

Strict TDD: write failing tests, run, then implement.

## Task Description (Verbatim)

### Task 9: `internal/diff` — types and textual diff

**Files:**
- Create: `apps/birdseye/internal/diff/types.go`
- Create: `apps/birdseye/internal/diff/textual.go`
- Create: `apps/birdseye/internal/diff/textual_test.go`

- [ ] **Step 1: Write failing test** at `apps/birdseye/internal/diff/textual_test.go`:

```go
package diff

import (
	"strings"
	"testing"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
)

func mkArchive(files map[string]string) archive.Module {
	out := archive.Module{Files: make(map[string][]byte, len(files))}
	for k, v := range files {
		out.Files[k] = []byte(v)
	}
	return out
}

func findFile(t *testing.T, ds []FileDiff, path string) FileDiff {
	t.Helper()
	for _, d := range ds {
		if d.Path == path {
			return d
		}
	}
	t.Fatalf("no FileDiff for %s; got %d entries", path, len(ds))
	return FileDiff{}
}

func TestTextualAdded(t *testing.T) {
	a := mkArchive(nil)
	b := mkArchive(map[string]string{"new.tf": "x\n"})
	ds := Textual(a, b)
	d := findFile(t, ds, "new.tf")
	if d.Status != StatusAdded {
		t.Errorf("status = %v, want Added", d.Status)
	}
	if d.Adds != 1 || d.Dels != 0 {
		t.Errorf("adds/dels = %d/%d", d.Adds, d.Dels)
	}
}

func TestTextualRemoved(t *testing.T) {
	a := mkArchive(map[string]string{"old.tf": "x\n"})
	b := mkArchive(nil)
	ds := Textual(a, b)
	d := findFile(t, ds, "old.tf")
	if d.Status != StatusRemoved {
		t.Errorf("status = %v, want Removed", d.Status)
	}
	if d.Adds != 0 || d.Dels != 1 {
		t.Errorf("adds/dels = %d/%d", d.Adds, d.Dels)
	}
}

func TestTextualModified(t *testing.T) {
	a := mkArchive(map[string]string{"m.tf": "alpha\nbeta\n"})
	b := mkArchive(map[string]string{"m.tf": "alpha\nGAMMA\n"})
	ds := Textual(a, b)
	d := findFile(t, ds, "m.tf")
	if d.Status != StatusModified {
		t.Errorf("status = %v, want Modified", d.Status)
	}
	if !strings.Contains(d.Unified, "-beta") || !strings.Contains(d.Unified, "+GAMMA") {
		t.Errorf("unified missing expected lines:\n%s", d.Unified)
	}
}

func TestTextualIdenticalReturnsEmpty(t *testing.T) {
	a := mkArchive(map[string]string{"m.tf": "x\n"})
	b := mkArchive(map[string]string{"m.tf": "x\n"})
	if got := Textual(a, b); len(got) != 0 {
		t.Errorf("expected 0 diffs, got %d", len(got))
	}
}

func TestTextualSortedByPath(t *testing.T) {
	a := mkArchive(map[string]string{"z.tf": "a\n", "a.tf": "a\n"})
	b := mkArchive(map[string]string{"z.tf": "b\n", "a.tf": "b\n"})
	ds := Textual(a, b)
	if len(ds) != 2 {
		t.Fatalf("got %d diffs, want 2", len(ds))
	}
	if ds[0].Path != "a.tf" || ds[1].Path != "z.tf" {
		t.Errorf("expected sorted by path; got %s, %s", ds[0].Path, ds[1].Path)
	}
}
```

- [ ] **Step 2: Run, expect FAIL** (compile errors)

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/diff/...`

- [ ] **Step 3: Implement `apps/birdseye/internal/diff/types.go`**

```go
// Package diff produces FileDiff entries and optional SemanticChange
// entries from a pair of extracted Modules. All functions are pure: no
// I/O, no globals.
package diff

// FileStatus describes what happened to a file between the two versions.
type FileStatus int

const (
	StatusModified FileStatus = iota
	StatusAdded
	StatusRemoved
)

func (s FileStatus) String() string {
	switch s {
	case StatusAdded:
		return "added"
	case StatusRemoved:
		return "removed"
	default:
		return "modified"
	}
}

// FileDiff is a per-file diff result. Unified holds the rendered diff
// hunks; Adds/Dels are the count of added/removed lines for summary
// rendering.
type FileDiff struct {
	Path    string
	Status  FileStatus
	Unified string
	Adds    int
	Dels    int
}

// SemanticChange is a single HCL-aware change. Kind is one of "resource",
// "variable", "output", "provider", "locals", "data", or "module".
type SemanticChange struct {
	Kind   string
	Name   string
	Status FileStatus
	Detail string
}

// Result is what cmd/diff renders. Semantic is nil unless --semantic was
// requested.
type Result struct {
	Files    []FileDiff
	Semantic []SemanticChange
}
```

- [ ] **Step 4: Implement `apps/birdseye/internal/diff/textual.go`**

```go
package diff

import (
	"sort"
	"strings"

	"github.com/hexops/gotextdiff"
	"github.com/hexops/gotextdiff/myers"
	"github.com/hexops/gotextdiff/span"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
)

// Textual returns a FileDiff per file that differs between a and b,
// sorted by path. Files identical in both archives are not included.
func Textual(a, b archive.Module) []FileDiff {
	all := make(map[string]struct{})
	for k := range a.Files {
		all[k] = struct{}{}
	}
	for k := range b.Files {
		all[k] = struct{}{}
	}
	paths := make([]string, 0, len(all))
	for k := range all {
		paths = append(paths, k)
	}
	sort.Strings(paths)

	out := make([]FileDiff, 0, len(paths))
	for _, p := range paths {
		ab, aok := a.Files[p]
		bb, bok := b.Files[p]
		switch {
		case aok && bok:
			if string(ab) == string(bb) {
				continue
			}
			out = append(out, modifiedDiff(p, string(ab), string(bb)))
		case !aok && bok:
			out = append(out, addedDiff(p, string(bb)))
		case aok && !bok:
			out = append(out, removedDiff(p, string(ab)))
		}
	}
	return out
}

func modifiedDiff(path, oldText, newText string) FileDiff {
	edits := myers.ComputeEdits(span.URIFromPath(path), oldText, newText)
	unified := gotextdiff.ToUnified(path, path, oldText, edits)
	u := unified.String()
	adds, dels := countLines(u)
	return FileDiff{Path: path, Status: StatusModified, Unified: u, Adds: adds, Dels: dels}
}

func addedDiff(path, newText string) FileDiff {
	return FileDiff{
		Path:    path,
		Status:  StatusAdded,
		Unified: "+++ " + path + "\n" + prefixLines(newText, "+"),
		Adds:    countNonEmptyLines(newText),
	}
}

func removedDiff(path, oldText string) FileDiff {
	return FileDiff{
		Path:    path,
		Status:  StatusRemoved,
		Unified: "--- " + path + "\n" + prefixLines(oldText, "-"),
		Dels:    countNonEmptyLines(oldText),
	}
}

func prefixLines(s, prefix string) string {
	if s == "" {
		return ""
	}
	lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
	var sb strings.Builder
	sb.Grow(len(s) + len(lines)*len(prefix))
	for _, l := range lines {
		sb.WriteString(prefix)
		sb.WriteString(l)
		sb.WriteByte('\n')
	}
	return sb.String()
}

func countNonEmptyLines(s string) int {
	if s == "" {
		return 0
	}
	n := strings.Count(s, "\n")
	if !strings.HasSuffix(s, "\n") {
		n++
	}
	return n
}

func countLines(unified string) (adds, dels int) {
	for _, line := range strings.Split(unified, "\n") {
		if len(line) == 0 {
			continue
		}
		switch line[0] {
		case '+':
			if !strings.HasPrefix(line, "+++") {
				adds++
			}
		case '-':
			if !strings.HasPrefix(line, "---") {
				dels++
			}
		}
	}
	return adds, dels
}
```

- [ ] **Step 5: Run, expect PASS**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/diff/... -v`
Expected: All 5 tests PASS.

- [ ] **Step 6: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/diff/ apps/birdseye/go.mod apps/birdseye/go.sum && git commit -m "feat(birdseye): textual unified diff between extracted modules"
```

Drop go.mod/go.sum from `git add` if neither was modified.

## Notes & Style

- Strict TDD.
- `github.com/hexops/gotextdiff` should already be in go.mod from Task 1's initial dependency list. `go mod tidy` is your friend if not.
- The plan deliberately splits added/removed/modified into helper functions for clarity. Keep them.
- The `prefixLines` and `countLines` helpers are private to this file. Don't export.
- Tests do not import gotextdiff — they only verify behavior via the public `FileDiff` shape.

## Code Organization

Two production files: `types.go` (data) + `textual.go` (logic). Test file: `textual_test.go`. Three files total.

## Self-Review Checklist

- [ ] All 5 textual tests pass (-race)
- [ ] `go vet ./...` clean
- [ ] go.mod/go.sum reflect any new direct dep on `gotextdiff` (it should already be direct from Task 1's plan, but verify)
- [ ] `StatusModified` is the iota=0 value (the plan puts it first)
- [ ] `FileStatus.String()` returns "added", "removed", "modified"
- [ ] Identical archives → empty result (verified by `TestTextualIdenticalReturnsEmpty`)
- [ ] Output is path-sorted (verified by `TestTextualSortedByPath`)
- [ ] Commit message exactly: `feat(birdseye): textual unified diff between extracted modules`

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files created
- Test count + pass status
- go.mod/go.sum changes (if any)
- Commit SHA
- Anything unexpected


Build birdseye CLI for Terraform module diffing


Agent

Combined review Task 9

Prompt:
Combined spec + code-quality review of Task 9 (textual diff types + Textual()) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`. Do spec first, then quality.

Diff: `git diff b7ab5b6..16193cc -- apps/birdseye/`

## Spec — What Was Requested

Create:
- `apps/birdseye/internal/diff/types.go` with `FileStatus` enum (`StatusModified=0, StatusAdded, StatusRemoved`), `(s FileStatus) String() string`, `FileDiff{Path, Status, Unified, Adds, Dels}`, `SemanticChange{Kind, Name, Status, Detail}`, `Result{Files, Semantic}`.
- `apps/birdseye/internal/diff/textual.go` with `Textual(a, b archive.Module) []FileDiff`. Behavior:
  - Union of file keys, sorted lexicographically by path
  - Identical bodies skipped
  - Added/Removed/Modified each map to a different `FileDiff` produced by helper functions (`addedDiff`, `removedDiff`, `modifiedDiff`)
  - Modified uses `gotextdiff` myers ComputeEdits + ToUnified, then `.String()` (or equivalent stringification)
  - Adds/Dels populated by `countLines` for modifications and `countNonEmptyLines` for added/removed
- `textual_test.go` with 5 tests covering Added, Removed, Modified, Identical, Sorted

The implementer reports one deviation: `gotextdiff.Unified` implements `fmt.Formatter` not `fmt.Stringer`, so they used `fmt.Sprintf("%v", unified)` instead of `unified.String()`. **This is acceptable** — flag it but do not fail. (Actually verify by reading the actual `gotextdiff` package source if necessary.)

Commit message exact: `feat(birdseye): textual unified diff between extracted modules`.

## Quality Focus

- API correctness: does `fmt.Sprintf("%v", unified)` produce a sensible unified diff format? (Look at the test output via the file body or just rely on tests passing.)
- Style: helpers `addedDiff`/`removedDiff`/`modifiedDiff` cleanly factored?
- Allocation: `prefixLines` uses `strings.Builder` with `Grow`? `Textual` preallocates slices where size is known?
- Correctness: the `+++`/`---` prefix exclusion in `countLines` handles the file header lines properly (so they're not counted as add/del lines)?
- Test independence: helpers `mkArchive` and `findFile` are self-contained?

## Report Format

**Spec:** ✅ compliant OR ❌ issues with file:line
**Quality:**
- Strengths: 1-2 bullets
- Issues: Critical / Important / Minor
- Assessment: APPROVED | NEEDS_CHANGES

Be terse.


Good. Commit message matches exactly. Now I have all the information needed for the full review.

---

**Spec:** COMPLIANT

All required files are present with correct structure. Every spec item is satisfied:

- `types.go`: `FileStatus` enum with correct iota ordering (`StatusModified=0, StatusAdded, StatusRemoved`), `String()`, `FileDiff`, `SemanticChange`, `Result` — all present and correct.
- `textual.go`: `Textual()` unions file keys, sorts lexicographically, skips identical bodies, routes to `addedDiff`/`removedDiff`/`modifiedDiff` as specified. `modifiedDiff` uses `myers.ComputeEdits` + `gotextdiff.ToUnified`. `Adds`/`Dels` populated via `countLines` for modified and `countNonEmptyLines` for added/removed.
- `textual_test.go`: 5 tests covering Added, Removed, Modified, Identical, Sorted — all present.
- Commit message matches exactly: `feat(birdseye): textual unified diff between extracted modules`.

**Deviation confirmed acceptable:** `gotextdiff.Unified` implements `fmt.Formatter` (via `Format(f fmt.State, r rune)`) and does NOT implement `fmt.Stringer`. `fmt.Sprintf("%v", unified)` correctly invokes `Format`, producing proper unified diff output. The deviation report is accurate.

---

**Quality:**

Strengths:
- `prefixLines` uses `strings.Builder` with `Grow(len(s) + len(lines)*len(prefix))` — correct preallocation accounting for both original content and prefix overhead.
- `Textual` preallocates both `paths` (`make([]string, 0, len(all))`) and `out` (`make([]FileDiff, 0, len(paths))`), holding to the spec and project style guide.

Issues:

**Minor — `countNonEmptyLines` name vs behavior mismatch (`textual.go:94-100`):** The function counts all lines with content, but it actually counts non-empty lines by excluding the trailing newline via `HasSuffix` logic. However it does NOT skip blank lines in the middle of a file (e.g., `"a\n\nb\n"` → 3, not 2). The name implies empty lines are excluded but only the trailing newline is handled. This is unlikely to matter for Terraform files but the name is misleading. `countLines` (without "NonEmpty") would be more accurate.

**Minor — `addedDiff`/`removedDiff` use different header format from `modifiedDiff` (`textual.go:56-70`):** Modified files get `--- path` / `+++ path` from `gotextdiff.Format`. Added files get only `+++ path`, removed only `--- path`. This is reasonable and intentional, but there's no hunk header (`@@ ... @@`) for added/removed diffs — they're synthetic, not real unified diff format. This is a conscious simplification but worth noting if downstream consumers parse the `Unified` field as standard diff.

**Minor — `countLines` `@@` hunk header lines are not filtered (`textual.go:103-116`):** Lines starting with `@@` are context lines in unified diff; they begin with `@` not `+`/`-`, so they are correctly unaffected. However `\ No newline at end of file` lines (starting with `\`) are also silently ignored — correct by accident since `\` is not `+` or `-`. No bug, but fragile if the format ever changes.

**Assessment: APPROVED**

The implementation is clean, allocation-conscious, and fully spec-compliant. The minor naming imprecision in `countNonEmptyLines` is the only substantive note; it does not affect correctness for the expected input domain.

agentId: abcb6b8a1f5446fd7 (use SendMessage with to: 'abcb6b8a1f5446fd7' to continue this agent)
<usage>total_tokens: 21619
tool_uses: 6
duration_ms: 51428</usage>

Prompt:
Combined spec + code-quality review of Task 9 (textual diff types + Textual()) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`. Do spec first, then quality.

Diff: `git diff b7ab5b6..16193cc -- apps/birdseye/`

## Spec — What Was Requested

Create:
- `apps/birdseye/internal/diff/types.go` with `FileStatus` enum (`StatusModified=0, StatusAdded, StatusRemoved`), `(s FileStatus) String() string`, `FileDiff{Path, Status, Unified, Adds, Dels}`, `SemanticChange{Kind, Name, Status, Detail}`, `Result{Files, Semantic}`.
- `apps/birdseye/internal/diff/textual.go` with `Textual(a, b archive.Module) []FileDiff`. Behavior:
  - Union of file keys, sorted lexicographically by path
  - Identical bodies skipped
  - Added/Removed/Modified each map to a different `FileDiff` produced by helper functions (`addedDiff`, `removedDiff`, `modifiedDiff`)
  - Modified uses `gotextdiff` myers ComputeEdits + ToUnified, then `.String()` (or equivalent stringification)
  - Adds/Dels populated by `countLines` for modifications and `countNonEmptyLines` for added/removed
- `textual_test.go` with 5 tests covering Added, Removed, Modified, Identical, Sorted

The implementer reports one deviation: `gotextdiff.Unified` implements `fmt.Formatter` not `fmt.Stringer`, so they used `fmt.Sprintf("%v", unified)` instead of `unified.String()`. **This is acceptable** — flag it but do not fail. (Actually verify by reading the actual `gotextdiff` package source if necessary.)

Commit message exact: `feat(birdseye): textual unified diff between extracted modules`.

## Quality Focus

- API correctness: does `fmt.Sprintf("%v", unified)` produce a sensible unified diff format? (Look at the test output via the file body or just rely on tests passing.)
- Style: helpers `addedDiff`/`removedDiff`/`modifiedDiff` cleanly factored?
- Allocation: `prefixLines` uses `strings.Builder` with `Grow`? `Textual` preallocates slices where size is known?
- Correctness: the `+++`/`---` prefix exclusion in `countLines` handles the file header lines properly (so they're not counted as add/del lines)?
- Test independence: helpers `mkArchive` and `findFile` are self-contained?

## Report Format

**Spec:** ✅ compliant OR ❌ issues with file:line
**Quality:**
- Strengths: 1-2 bullets
- Issues: Critical / Important / Minor
- Assessment: APPROVED | NEEDS_CHANGES

Be terse.


Task 9 APPROVED. Moving to Task 10 (cmd diff --plain end-to-end).


Implement Task 10: cmd diff --plain

Prompt:
You are implementing Task 10 of the birdseye CLI implementation plan. This is the biggest integration step so far — wiring archive + registry + diff together behind the `birdseye diff` subcommand.

## Working Environment

- **Worktree path:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`
- **Git branch:** `worktree-birdseye`
- Recent commits: Phase A + B + Task 9 textual diff (`16193cc`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

The pieces exist:
- `cmd.NewRootCmd()` (Task 2) — root cobra command, persistent flags
- `client.Client.Download` (Task 3) — fetch tarball
- `archive.Extract` (Task 4) — tar.gz → Module
- `registry.Registry.FetchPair` (Task 8) — cache + concurrent fetch
- `diff.Textual` (Task 9) — produces []FileDiff

Your job: a `diff` cobra subcommand that takes 3 positional args (`<module>` `<vA>` `<vB>`), fetches+extracts+diffs, and writes unified diff to stdout when `--plain` is passed. TUI integration comes later (Task 13). The integration tests in this task all use `--plain` so they're hermetic.

The test code is large (a custom `fakeCrowTwoVersions` httptest server). Follow the spec carefully — TDD means writing all tests first, even though they require a lot of setup.

## Task Description (Verbatim)

### Task 10: `cmd diff --plain` — plain-text output integration

**Files:**
- Create: `apps/birdseye/cmd/diff.go`
- Create: `apps/birdseye/cmd/diff_test.go`
- Modify: `apps/birdseye/cmd/root.go` (wire subcommand)

- [ ] **Step 1: Write failing integration test** at `apps/birdseye/cmd/diff_test.go`:

```go
package cmd

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// fakeCrow with two versions of moduleA. v1 has main.tf; v2 has main.tf
// (modified) and a new variables.tf.
func fakeCrowTwoVersions(t *testing.T) *httptest.Server {
	t.Helper()
	mkTgz := func(files map[string]string) []byte {
		var buf bytes.Buffer
		gz := gzip.NewWriter(&buf)
		tw := tar.NewWriter(gz)
		for n, b := range files {
			_ = tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Size: int64(len(b)), Typeflag: tar.TypeReg})
			_, _ = tw.Write([]byte(b))
		}
		_ = tw.Close()
		_ = gz.Close()
		return buf.Bytes()
	}
	v1 := mkTgz(map[string]string{"main.tf": "resource \"aws_s3_bucket\" \"alpha\" {}\n"})
	v2 := mkTgz(map[string]string{
		"main.tf":      "resource \"aws_s3_bucket\" \"beta\" {}\n",
		"variables.tf": "variable \"name\" { type = string }\n",
	})

	mux := http.NewServeMux()
	blob := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/blob/1.0.0":
			_, _ = w.Write(v1)
		case "/blob/1.1.0":
			_, _ = w.Write(v2)
		default:
			http.NotFound(w, r)
		}
	}))
	t.Cleanup(blob.Close)
	mux.HandleFunc("/v1/modules/iac/comply/aws/", func(w http.ResponseWriter, r *http.Request) {
		parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/modules/iac/comply/aws/"), "/")
		if len(parts) != 2 || parts[1] != "download" {
			http.NotFound(w, r)
			return
		}
		w.Header().Set("X-Terraform-Get", blob.URL+"/blob/"+parts[0])
		w.WriteHeader(http.StatusNoContent)
	})
	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)
	return srv
}

func TestDiffPlainOutput(t *testing.T) {
	srv := fakeCrowTwoVersions(t)
	root := NewRootCmd()
	var stdout, stderr bytes.Buffer
	root.SetOut(&stdout)
	root.SetErr(&stderr)
	root.SetArgs([]string{
		"diff",
		"iac/comply/aws", "1.0.0", "1.1.0",
		"--crow-url", srv.URL,
		"--plain",
		"--cache-dir", t.TempDir(),
	})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v\nstderr: %s", err, stderr.String())
	}
	out := stdout.String()
	for _, want := range []string{"main.tf", "variables.tf", "alpha", "beta"} {
		if !strings.Contains(out, want) {
			t.Errorf("plain output missing %q\n--- output ---\n%s", want, out)
		}
	}
}

func TestDiffIdenticalVersionsNoChanges(t *testing.T) {
	srv := fakeCrowTwoVersions(t)
	root := NewRootCmd()
	var stdout bytes.Buffer
	root.SetOut(&stdout)
	root.SetArgs([]string{
		"diff",
		"iac/comply/aws", "1.0.0", "1.0.0",
		"--crow-url", srv.URL,
		"--plain",
		"--cache-dir", t.TempDir(),
	})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v", err)
	}
	if got := strings.TrimSpace(stdout.String()); got != "no differences" {
		t.Errorf("output = %q, want \"no differences\"", got)
	}
}

func TestDiffBadModuleAddress(t *testing.T) {
	root := NewRootCmd()
	var stderr bytes.Buffer
	root.SetErr(&stderr)
	root.SetArgs([]string{"diff", "bad-address", "1.0.0", "1.1.0"})
	err := root.ExecuteContext(context.Background())
	if err == nil {
		t.Fatal("expected error for bad address")
	}
	if ExitCode(err) != 1 {
		t.Errorf("exit code = %d, want 1", ExitCode(err))
	}
}
```

- [ ] **Step 2: Run, expect FAIL**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./cmd/... -run TestDiff -v`
Expected: "unknown command diff" failures.

- [ ] **Step 3: Implement `apps/birdseye/cmd/diff.go`**

```go
package cmd

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/spf13/cobra"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
	"github.com/immuta/operations/apps/birdseye/internal/diff"
	"github.com/immuta/operations/apps/birdseye/internal/registry"
	"github.com/immuta/operations/services/crow/pkg/api"
	"github.com/immuta/operations/services/crow/pkg/client"
)

// diffOpts holds the resolved options for `birdseye diff`.
type diffOpts struct {
	Semantic bool
	Plain    bool
	CacheDir string
}

const maxArchiveBytes = 50 << 20 // 50 MiB safety cap on uncompressed size

func newDiffCmd(g *GlobalFlags) *cobra.Command {
	o := &diffOpts{}
	c := &cobra.Command{
		Use:   "diff [module] [versionA] [versionB]",
		Short: "Diff two versions of a Crow module",
		Long: "Diff two versions of a module hosted in Crow.\n\n" +
			"If module/versions are omitted, the interactive TUI launches\n" +
			"(see picker task). With --plain, prints unified diff to stdout.",
		Args:          cobra.MaximumNArgs(3),
		SilenceUsage:  true,
		SilenceErrors: true,
		RunE: func(cmd *cobra.Command, args []string) error {
			return runDiff(cmd.Context(), cmd, args, g, o)
		},
	}
	c.Flags().BoolVar(&o.Semantic, "semantic", false, "include HCL-aware semantic diff alongside textual")
	c.Flags().BoolVar(&o.Plain, "plain", false, "force plain (non-TUI) output")
	c.Flags().BoolVar(&o.Plain, "no-tui", false, "alias for --plain")
	c.Flags().StringVar(&o.CacheDir, "cache-dir", defaultCacheDir(), "module tarball cache directory")
	return c
}

func defaultCacheDir() string {
	if home, err := os.UserCacheDir(); err == nil {
		return filepath.Join(home, "birdseye")
	}
	return ".birdseye-cache"
}

func newHTTPClient(d time.Duration) *http.Client {
	return &http.Client{Timeout: d}
}

func parseModuleAddress(s string) (api.ModuleIdentifier, error) {
	parts := strings.Split(s, "/")
	if len(parts) != 3 {
		return api.ModuleIdentifier{}, fmt.Errorf("%w: module address must be namespace/name/provider, got %q", ErrUsage, s)
	}
	id := api.ModuleIdentifier{Namespace: parts[0], Name: parts[1], Provider: parts[2]}
	if err := id.Validate(); err != nil {
		return api.ModuleIdentifier{}, fmt.Errorf("%w: %v", ErrUsage, err)
	}
	return id, nil
}

func runDiff(ctx context.Context, cmd *cobra.Command, args []string, g *GlobalFlags, o *diffOpts) error {
	if len(args) == 0 {
		// Interactive flow lives in Task 15; for now fall through to error.
		return fmt.Errorf("%w: interactive mode not yet implemented; provide <module> <versionA> <versionB>", ErrUsage)
	}
	if len(args) != 3 {
		return fmt.Errorf("%w: diff requires <module> <versionA> <versionB>", ErrUsage)
	}
	id, err := parseModuleAddress(args[0])
	if err != nil {
		return err
	}
	if err := api.ValidateVersion(args[1]); err != nil {
		return fmt.Errorf("%w: %v", ErrUsage, err)
	}
	if err := api.ValidateVersion(args[2]); err != nil {
		return fmt.Errorf("%w: %v", ErrUsage, err)
	}
	modA := api.Module{ModuleIdentifier: id, Version: args[1]}
	modB := api.Module{ModuleIdentifier: id, Version: args[2]}

	cli := client.New(g.CrowURL, client.WithHTTPClient(newHTTPClient(g.Timeout)))
	cache, err := registry.NewDiskCache(o.CacheDir)
	if err != nil {
		return fmt.Errorf("cache init: %w", err)
	}
	reg := registry.New(cli, cache)

	aBytes, bBytes, err := reg.FetchPair(ctx, modA, modB)
	if err != nil {
		return err
	}

	modAExtracted, err := archive.Extract(aBytes, maxArchiveBytes)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrArchive, err)
	}
	modBExtracted, err := archive.Extract(bBytes, maxArchiveBytes)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrArchive, err)
	}

	result := diff.Result{Files: diff.Textual(modAExtracted, modBExtracted)}
	// Semantic mode wired in Task 17.
	// TUI mode wired in Task 13.

	return renderPlain(cmd.OutOrStdout(), result)
}

// renderPlain writes the diff result to w in unified-diff format.
// "no differences" is emitted when there are zero files in the result.
func renderPlain(w io.Writer, r diff.Result) error {
	if len(r.Files) == 0 {
		_, err := fmt.Fprintln(w, "no differences")
		return err
	}
	for i, fd := range r.Files {
		if i > 0 {
			_, _ = fmt.Fprintln(w)
		}
		_, _ = fmt.Fprintf(w, "=== %s (%s) +%d -%d\n", fd.Path, fd.Status, fd.Adds, fd.Dels)
		_, _ = fmt.Fprint(w, fd.Unified)
	}
	return nil
}
```

- [ ] **Step 4: Wire subcommand** in `apps/birdseye/cmd/root.go` — in `NewRootCmd()`, replace the placeholder comment block:

```go
	// Subcommands will be added in later tasks:
	//   root.AddCommand(newDiffCmd(g))
	//   root.AddCommand(newVersionsCmd(g))
```

with:

```go
	root.AddCommand(newDiffCmd(g))
	// versions subcommand added in Task 11
```

- [ ] **Step 5: Run, expect PASS**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./cmd/... -v`
Expected: All PASS (7 existing tests + 3 new = 10 total).

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go build ./...`
Expected: success.

- [ ] **Step 7: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/cmd/ && git commit -m "feat(birdseye): plain unified diff output for one-shot CLI usage"
```

If go.mod / go.sum changed (unlikely — all deps already added), include them.

## Important Implementation Notes

- The `RunE: cmd.Help()` workaround from Task 2 lives on the ROOT command. Now that a subcommand exists, cobra resolves `birdseye diff ...` to `newDiffCmd` and runs ITS RunE, never the root's RunE. So the `cmd.Help()` workaround is dormant — no removal needed.
- `--no-tui` is a flag alias for `--plain` via two `BoolVar` calls on the same `o.Plain` pointer. Both flags share state.
- `cobra.MaximumNArgs(3)` allows 0 to 3 args. 0 is reserved for interactive mode (future); 3 is normal. 1-2 returns `ErrUsage`.
- `parseModuleAddress` uses `fmt.Errorf("%w: ...", ErrUsage, ...)` so `errors.Is(err, ErrUsage)` works in tests; `ExitCode` returns 1.

## Code Organization

`diff.go` holds the subcommand factory, `runDiff`, helpers (`parseModuleAddress`, `defaultCacheDir`, `newHTTPClient`, `renderPlain`). One file. Don't split.

## Self-Review Checklist

- [ ] All 3 new tests pass under `-race`: `TestDiffPlainOutput`, `TestDiffIdenticalVersionsNoChanges`, `TestDiffBadModuleAddress`
- [ ] All previous `cmd` tests still pass (root + errors)
- [ ] `go build ./...` succeeds
- [ ] `go vet ./...` succeeds
- [ ] `newDiffCmd(g)` is wired into root via `root.AddCommand(...)`
- [ ] Both `--plain` and `--no-tui` work (they share `o.Plain`)
- [ ] `defaultCacheDir()` returns `$XDG_CACHE_HOME/birdseye` (or platform equivalent via `os.UserCacheDir`)
- [ ] `TestDiffIdenticalVersionsNoChanges` outputs literally `no differences\n`
- [ ] Bad module address → `ExitCode == 1`
- [ ] Commit message exactly: `feat(birdseye): plain unified diff output for one-shot CLI usage`

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files created/modified
- Test count + race-clean
- `go build` / `go vet` output
- Commit SHA
- Anything unexpected


---

**Status:** DONE

**Files created/modified:**
- Created: `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/diff.go` — `newDiffCmd`, `runDiff`, `renderPlain`, `parseModuleAddress`, `defaultCacheDir`, `newHTTPClient`
- Created: `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/diff_test.go` — 3 integration tests with `fakeCrowTwoVersions` httptest server
- Modified: `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/root.go` — wired `newDiffCmd(g)` via `root.AddCommand`

**Test count:** 9 total (3 new + 6 existing), all PASS, race-clean (`go test -race ./cmd/... -v`)

**`go build ./...`:** OK
**`go vet ./...`:** OK

**Commit SHA:** `835333a`

**Nothing unexpected.** The `--no-tui` alias shares `o.Plain` state correctly via two `BoolVar` calls on the same pointer. The `fakeCrowTwoVersions` server accurately simulates Crow's 204 + `X-Terraform-Get` redirect protocol that `client.Download` expects, so the integration test is hermetic with no real network calls.

agentId: a563370c22f86a06c (use SendMessage with to: 'a563370c22f86a06c' to continue this agent)
<usage>total_tokens: 37277
tool_uses: 22
duration_ms: 152790</usage>

Prompt:
You are implementing Task 10 of the birdseye CLI implementation plan. This is the biggest integration step so far — wiring archive + registry + diff together behind the `birdseye diff` subcommand.

## Working Environment

- **Worktree path:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`
- **Git branch:** `worktree-birdseye`
- Recent commits: Phase A + B + Task 9 textual diff (`16193cc`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

The pieces exist:
- `cmd.NewRootCmd()` (Task 2) — root cobra command, persistent flags
- `client.Client.Download` (Task 3) — fetch tarball
- `archive.Extract` (Task 4) — tar.gz → Module
- `registry.Registry.FetchPair` (Task 8) — cache + concurrent fetch
- `diff.Textual` (Task 9) — produces []FileDiff

Your job: a `diff` cobra subcommand that takes 3 positional args (`<module>` `<vA>` `<vB>`), fetches+extracts+diffs, and writes unified diff to stdout when `--plain` is passed. TUI integration comes later (Task 13). The integration tests in this task all use `--plain` so they're hermetic.

The test code is large (a custom `fakeCrowTwoVersions` httptest server). Follow the spec carefully — TDD means writing all tests first, even though they require a lot of setup.

## Task Description (Verbatim)

### Task 10: `cmd diff --plain` — plain-text output integration

**Files:**
- Create: `apps/birdseye/cmd/diff.go`
- Create: `apps/birdseye/cmd/diff_test.go`
- Modify: `apps/birdseye/cmd/root.go` (wire subcommand)

- [ ] **Step 1: Write failing integration test** at `apps/birdseye/cmd/diff_test.go`:

```go
package cmd

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

// fakeCrow with two versions of moduleA. v1 has main.tf; v2 has main.tf
// (modified) and a new variables.tf.
func fakeCrowTwoVersions(t *testing.T) *httptest.Server {
	t.Helper()
	mkTgz := func(files map[string]string) []byte {
		var buf bytes.Buffer
		gz := gzip.NewWriter(&buf)
		tw := tar.NewWriter(gz)
		for n, b := range files {
			_ = tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Size: int64(len(b)), Typeflag: tar.TypeReg})
			_, _ = tw.Write([]byte(b))
		}
		_ = tw.Close()
		_ = gz.Close()
		return buf.Bytes()
	}
	v1 := mkTgz(map[string]string{"main.tf": "resource \"aws_s3_bucket\" \"alpha\" {}\n"})
	v2 := mkTgz(map[string]string{
		"main.tf":      "resource \"aws_s3_bucket\" \"beta\" {}\n",
		"variables.tf": "variable \"name\" { type = string }\n",
	})

	mux := http.NewServeMux()
	blob := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.URL.Path {
		case "/blob/1.0.0":
			_, _ = w.Write(v1)
		case "/blob/1.1.0":
			_, _ = w.Write(v2)
		default:
			http.NotFound(w, r)
		}
	}))
	t.Cleanup(blob.Close)
	mux.HandleFunc("/v1/modules/iac/comply/aws/", func(w http.ResponseWriter, r *http.Request) {
		parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/modules/iac/comply/aws/"), "/")
		if len(parts) != 2 || parts[1] != "download" {
			http.NotFound(w, r)
			return
		}
		w.Header().Set("X-Terraform-Get", blob.URL+"/blob/"+parts[0])
		w.WriteHeader(http.StatusNoContent)
	})
	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)
	return srv
}

func TestDiffPlainOutput(t *testing.T) {
	srv := fakeCrowTwoVersions(t)
	root := NewRootCmd()
	var stdout, stderr bytes.Buffer
	root.SetOut(&stdout)
	root.SetErr(&stderr)
	root.SetArgs([]string{
		"diff",
		"iac/comply/aws", "1.0.0", "1.1.0",
		"--crow-url", srv.URL,
		"--plain",
		"--cache-dir", t.TempDir(),
	})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v\nstderr: %s", err, stderr.String())
	}
	out := stdout.String()
	for _, want := range []string{"main.tf", "variables.tf", "alpha", "beta"} {
		if !strings.Contains(out, want) {
			t.Errorf("plain output missing %q\n--- output ---\n%s", want, out)
		}
	}
}

func TestDiffIdenticalVersionsNoChanges(t *testing.T) {
	srv := fakeCrowTwoVersions(t)
	root := NewRootCmd()
	var stdout bytes.Buffer
	root.SetOut(&stdout)
	root.SetArgs([]string{
		"diff",
		"iac/comply/aws", "1.0.0", "1.0.0",
		"--crow-url", srv.URL,
		"--plain",
		"--cache-dir", t.TempDir(),
	})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v", err)
	}
	if got := strings.TrimSpace(stdout.String()); got != "no differences" {
		t.Errorf("output = %q, want \"no differences\"", got)
	}
}

func TestDiffBadModuleAddress(t *testing.T) {
	root := NewRootCmd()
	var stderr bytes.Buffer
	root.SetErr(&stderr)
	root.SetArgs([]string{"diff", "bad-address", "1.0.0", "1.1.0"})
	err := root.ExecuteContext(context.Background())
	if err == nil {
		t.Fatal("expected error for bad address")
	}
	if ExitCode(err) != 1 {
		t.Errorf("exit code = %d, want 1", ExitCode(err))
	}
}
```

- [ ] **Step 2: Run, expect FAIL**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./cmd/... -run TestDiff -v`
Expected: "unknown command diff" failures.

- [ ] **Step 3: Implement `apps/birdseye/cmd/diff.go`**

```go
package cmd

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/spf13/cobra"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
	"github.com/immuta/operations/apps/birdseye/internal/diff"
	"github.com/immuta/operations/apps/birdseye/internal/registry"
	"github.com/immuta/operations/services/crow/pkg/api"
	"github.com/immuta/operations/services/crow/pkg/client"
)

// diffOpts holds the resolved options for `birdseye diff`.
type diffOpts struct {
	Semantic bool
	Plain    bool
	CacheDir string
}

const maxArchiveBytes = 50 << 20 // 50 MiB safety cap on uncompressed size

func newDiffCmd(g *GlobalFlags) *cobra.Command {
	o := &diffOpts{}
	c := &cobra.Command{
		Use:   "diff [module] [versionA] [versionB]",
		Short: "Diff two versions of a Crow module",
		Long: "Diff two versions of a module hosted in Crow.\n\n" +
			"If module/versions are omitted, the interactive TUI launches\n" +
			"(see picker task). With --plain, prints unified diff to stdout.",
		Args:          cobra.MaximumNArgs(3),
		SilenceUsage:  true,
		SilenceErrors: true,
		RunE: func(cmd *cobra.Command, args []string) error {
			return runDiff(cmd.Context(), cmd, args, g, o)
		},
	}
	c.Flags().BoolVar(&o.Semantic, "semantic", false, "include HCL-aware semantic diff alongside textual")
	c.Flags().BoolVar(&o.Plain, "plain", false, "force plain (non-TUI) output")
	c.Flags().BoolVar(&o.Plain, "no-tui", false, "alias for --plain")
	c.Flags().StringVar(&o.CacheDir, "cache-dir", defaultCacheDir(), "module tarball cache directory")
	return c
}

func defaultCacheDir() string {
	if home, err := os.UserCacheDir(); err == nil {
		return filepath.Join(home, "birdseye")
	}
	return ".birdseye-cache"
}

func newHTTPClient(d time.Duration) *http.Client {
	return &http.Client{Timeout: d}
}

func parseModuleAddress(s string) (api.ModuleIdentifier, error) {
	parts := strings.Split(s, "/")
	if len(parts) != 3 {
		return api.ModuleIdentifier{}, fmt.Errorf("%w: module address must be namespace/name/provider, got %q", ErrUsage, s)
	}
	id := api.ModuleIdentifier{Namespace: parts[0], Name: parts[1], Provider: parts[2]}
	if err := id.Validate(); err != nil {
		return api.ModuleIdentifier{}, fmt.Errorf("%w: %v", ErrUsage, err)
	}
	return id, nil
}

func runDiff(ctx context.Context, cmd *cobra.Command, args []string, g *GlobalFlags, o *diffOpts) error {
	if len(args) == 0 {
		// Interactive flow lives in Task 15; for now fall through to error.
		return fmt.Errorf("%w: interactive mode not yet implemented; provide <module> <versionA> <versionB>", ErrUsage)
	}
	if len(args) != 3 {
		return fmt.Errorf("%w: diff requires <module> <versionA> <versionB>", ErrUsage)
	}
	id, err := parseModuleAddress(args[0])
	if err != nil {
		return err
	}
	if err := api.ValidateVersion(args[1]); err != nil {
		return fmt.Errorf("%w: %v", ErrUsage, err)
	}
	if err := api.ValidateVersion(args[2]); err != nil {
		return fmt.Errorf("%w: %v", ErrUsage, err)
	}
	modA := api.Module{ModuleIdentifier: id, Version: args[1]}
	modB := api.Module{ModuleIdentifier: id, Version: args[2]}

	cli := client.New(g.CrowURL, client.WithHTTPClient(newHTTPClient(g.Timeout)))
	cache, err := registry.NewDiskCache(o.CacheDir)
	if err != nil {
		return fmt.Errorf("cache init: %w", err)
	}
	reg := registry.New(cli, cache)

	aBytes, bBytes, err := reg.FetchPair(ctx, modA, modB)
	if err != nil {
		return err
	}

	modAExtracted, err := archive.Extract(aBytes, maxArchiveBytes)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrArchive, err)
	}
	modBExtracted, err := archive.Extract(bBytes, maxArchiveBytes)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrArchive, err)
	}

	result := diff.Result{Files: diff.Textual(modAExtracted, modBExtracted)}
	// Semantic mode wired in Task 17.
	// TUI mode wired in Task 13.

	return renderPlain(cmd.OutOrStdout(), result)
}

// renderPlain writes the diff result to w in unified-diff format.
// "no differences" is emitted when there are zero files in the result.
func renderPlain(w io.Writer, r diff.Result) error {
	if len(r.Files) == 0 {
		_, err := fmt.Fprintln(w, "no differences")
		return err
	}
	for i, fd := range r.Files {
		if i > 0 {
			_, _ = fmt.Fprintln(w)
		}
		_, _ = fmt.Fprintf(w, "=== %s (%s) +%d -%d\n", fd.Path, fd.Status, fd.Adds, fd.Dels)
		_, _ = fmt.Fprint(w, fd.Unified)
	}
	return nil
}
```

- [ ] **Step 4: Wire subcommand** in `apps/birdseye/cmd/root.go` — in `NewRootCmd()`, replace the placeholder comment block:

```go
	// Subcommands will be added in later tasks:
	//   root.AddCommand(newDiffCmd(g))
	//   root.AddCommand(newVersionsCmd(g))
```

with:

```go
	root.AddCommand(newDiffCmd(g))
	// versions subcommand added in Task 11
```

- [ ] **Step 5: Run, expect PASS**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./cmd/... -v`
Expected: All PASS (7 existing tests + 3 new = 10 total).

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go build ./...`
Expected: success.

- [ ] **Step 7: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/cmd/ && git commit -m "feat(birdseye): plain unified diff output for one-shot CLI usage"
```

If go.mod / go.sum changed (unlikely — all deps already added), include them.

## Important Implementation Notes

- The `RunE: cmd.Help()` workaround from Task 2 lives on the ROOT command. Now that a subcommand exists, cobra resolves `birdseye diff ...` to `newDiffCmd` and runs ITS RunE, never the root's RunE. So the `cmd.Help()` workaround is dormant — no removal needed.
- `--no-tui` is a flag alias for `--plain` via two `BoolVar` calls on the same `o.Plain` pointer. Both flags share state.
- `cobra.MaximumNArgs(3)` allows 0 to 3 args. 0 is reserved for interactive mode (future); 3 is normal. 1-2 returns `ErrUsage`.
- `parseModuleAddress` uses `fmt.Errorf("%w: ...", ErrUsage, ...)` so `errors.Is(err, ErrUsage)` works in tests; `ExitCode` returns 1.

## Code Organization

`diff.go` holds the subcommand factory, `runDiff`, helpers (`parseModuleAddress`, `defaultCacheDir`, `newHTTPClient`, `renderPlain`). One file. Don't split.

## Self-Review Checklist

- [ ] All 3 new tests pass under `-race`: `TestDiffPlainOutput`, `TestDiffIdenticalVersionsNoChanges`, `TestDiffBadModuleAddress`
- [ ] All previous `cmd` tests still pass (root + errors)
- [ ] `go build ./...` succeeds
- [ ] `go vet ./...` succeeds
- [ ] `newDiffCmd(g)` is wired into root via `root.AddCommand(...)`
- [ ] Both `--plain` and `--no-tui` work (they share `o.Plain`)
- [ ] `defaultCacheDir()` returns `$XDG_CACHE_HOME/birdseye` (or platform equivalent via `os.UserCacheDir`)
- [ ] `TestDiffIdenticalVersionsNoChanges` outputs literally `no differences\n`
- [ ] Bad module address → `ExitCode == 1`
- [ ] Commit message exactly: `feat(birdseye): plain unified diff output for one-shot CLI usage`

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files created/modified
- Test count + race-clean
- `go build` / `go vet` output
- Commit SHA
- Anything unexpected


Combined review Task 10

Prompt:
Combined spec + code-quality review of Task 10 (cmd diff --plain end-to-end) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`. Do spec first, then quality.

Diff: `git diff 16193cc..835333a -- apps/birdseye/cmd/`

## Spec — What Was Requested

Create `apps/birdseye/cmd/diff.go`:
- `type diffOpts struct { Semantic bool; Plain bool; CacheDir string }`
- Constants: `maxArchiveBytes = 50 << 20`
- `newDiffCmd(g *GlobalFlags) *cobra.Command` — flags `--semantic`, `--plain`, `--no-tui` (alias for Plain), `--cache-dir`; Args: MaximumNArgs(3); RunE invokes `runDiff(cmd.Context(), cmd, args, g, o)`
- `defaultCacheDir() string` — `os.UserCacheDir()` + "birdseye"
- `newHTTPClient(d time.Duration) *http.Client`
- `parseModuleAddress(s string) (api.ModuleIdentifier, error)` — 3 slash-separated parts; calls `id.Validate()`; wraps errors with `%w: ErrUsage`
- `runDiff(ctx, cmd, args, g, o)`:
  - 0 args → return `ErrUsage` (interactive comes later)
  - != 3 args → `ErrUsage`
  - validate module + both versions via api.ValidateVersion
  - build client, disk cache, registry
  - FetchPair both versions
  - Extract both (wrap errors with `%w: ErrArchive`)
  - `diff.Textual` → `diff.Result`
  - `renderPlain(cmd.OutOrStdout(), result)`
- `renderPlain(w io.Writer, r diff.Result)` — prints "no differences" when empty, else per-file `=== path (status) +adds -dels` then `fd.Unified`

Create `apps/birdseye/cmd/diff_test.go`:
- `fakeCrowTwoVersions(t)` test helper with v1+v2 of `iac/comply/aws`
- `TestDiffPlainOutput` — assert output contains `main.tf`, `variables.tf`, `alpha`, `beta`
- `TestDiffIdenticalVersionsNoChanges` — output is literally `no differences`
- `TestDiffBadModuleAddress` — `ExitCode(err) == 1`

Modify `apps/birdseye/cmd/root.go` — replace the placeholder comment with `root.AddCommand(newDiffCmd(g))`.

Commit message exact: `feat(birdseye): plain unified diff output for one-shot CLI usage`.

## Quality Focus

- Layering: does `runDiff` actually flow through registry → archive → diff?
- Error wrapping: `%w: ErrUsage` / `%w: ErrArchive` for the right paths?
- TUI/interactive branch: should be 0-args → ErrUsage placeholder (Task 15 fixes)?
- `defaultCacheDir`: handles `os.UserCacheDir` error path?
- `--no-tui` vs `--plain`: both bind to same `o.Plain`?
- Tests use `t.TempDir()` for cache dir (no pollution)?
- Resource cleanup: any leaked goroutines / file descriptors?

## Report Format

**Spec:** ✅ compliant OR ❌ issues with file:line
**Quality:**
- Strengths: 1-2 bullets
- Issues: Critical / Important / Minor
- Assessment: APPROVED | NEEDS_CHANGES

Be terse.


All checks complete. Here is the review.

---

**Spec:** COMPLIANT

Every item checks out:

- `diffOpts` struct: exact fields (`Semantic`, `Plain`, `CacheDir`)
- `maxArchiveBytes = 50 << 20`: present at package level
- `newDiffCmd`: flags `--semantic`, `--plain`, `--no-tui` (both bound to `o.Plain`), `--cache-dir`; `MaximumNArgs(3)`; `RunE` calls `runDiff`
- `defaultCacheDir`: `os.UserCacheDir()` + "birdseye", fallback to `.birdseye-cache`
- `newHTTPClient`: present, returns `*http.Client`
- `parseModuleAddress`: 3 slash-parts, calls `id.Validate()`, wraps with `%w: ErrUsage`
- `runDiff`: 0-args returns `ErrUsage`, !=3 returns `ErrUsage`, validates both versions, builds client/cache/registry, `FetchPair`, `Extract` (both wrapped `%w: ErrArchive`), `diff.Textual` → `renderPlain`
- `renderPlain`: "no differences" on empty, per-file header + `fd.Unified`
- `root.go`: placeholder comment replaced with `root.AddCommand(newDiffCmd(g))`
- Tests: `fakeCrowTwoVersions` helper, all 3 test cases present; `TestDiffBadModuleAddress` checks `ExitCode(err) == 1`
- Commit message in diff: `feat(birdseye): plain unified diff output for one-shot CLI usage` — matches exactly

---

**Quality:**

Strengths:
- Clean layering: `runDiff` flows strictly through `registry.New` → `FetchPair` → `archive.Extract` → `diff.Textual` → `renderPlain` with no layer-skipping
- Tests use `t.TempDir()` for cache dir, no filesystem pollution; all three tests pass with `-race`

Issues:

**Important — dual `BoolVar` binding is technically correct but fragile.** Both `--plain` and `--no-tui` are registered with separate `BoolVar` calls pointing to the same `o.Plain` pointer (`diff.go:47-48`). Cobra doesn't warn about this and it works today, but if cobra ever validates for duplicate destination pointers it would break silently. The idiomatic Go/cobra approach is to mark `--no-tui` as a hidden alias via `c.Flags().Lookup("plain").Aliases` (cobra doesn't support this natively) or set `--no-tui` with a `MarkHidden` + `PreRunE` to copy its value to `o.Plain`. This is a minor correctness risk now but a maintenance trap later. No bug today; tests pass.

**Minor — `renderPlain` silently discards write errors on non-last iterations.** Lines `_, _ = fmt.Fprintln(w)` and `_, _ = fmt.Fprintf(...)` for the header and body discard errors, then the function returns `nil` if only the final `fmt.Fprintln("no differences")` or lack thereof is reached without error propagation. If the writer is closed mid-stream (piped to a pager that exits) the caller gets a spurious `nil` return. Consistent error returns on every `fmt.Fprintf` call would be safer.

**Minor — `newHTTPClient` is a thin wrapper with no justification for existing as a named function.** The call site uses it exactly once with `g.Timeout`. It adds indirection without adding testability (it returns a concrete `*http.Client`, not an interface). Not a bug, but dead weight.

**Minor — `runDiff` doesn't gate on `o.Plain` before calling `renderPlain`.** The spec says TUI mode lands in Task 13/15, and the 0-args path returns `ErrUsage`, so plain output is effectively the only reachable path. However, there's no check that `o.Plain || !isTTY()` before calling `renderPlain`; a future task wiring in TUI will need to add that guard. The comment `// TUI mode wired in Task 13.` acknowledges this — it's a known stub, not a defect.

**Assessment: APPROVED**

The implementation is correct, all tests pass with `-race`, spec compliance is 100%. The dual-`BoolVar` alias is the most meaningful issue but carries no current bug; the other points are stylistic. No changes required before merge.

agentId: a4155841d5d62c55c (use SendMessage with to: 'a4155841d5d62c55c' to continue this agent)
<usage>total_tokens: 32254
tool_uses: 23
duration_ms: 114759</usage>

Prompt:
Combined spec + code-quality review of Task 10 (cmd diff --plain end-to-end) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`. Do spec first, then quality.

Diff: `git diff 16193cc..835333a -- apps/birdseye/cmd/`

## Spec — What Was Requested

Create `apps/birdseye/cmd/diff.go`:
- `type diffOpts struct { Semantic bool; Plain bool; CacheDir string }`
- Constants: `maxArchiveBytes = 50 << 20`
- `newDiffCmd(g *GlobalFlags) *cobra.Command` — flags `--semantic`, `--plain`, `--no-tui` (alias for Plain), `--cache-dir`; Args: MaximumNArgs(3); RunE invokes `runDiff(cmd.Context(), cmd, args, g, o)`
- `defaultCacheDir() string` — `os.UserCacheDir()` + "birdseye"
- `newHTTPClient(d time.Duration) *http.Client`
- `parseModuleAddress(s string) (api.ModuleIdentifier, error)` — 3 slash-separated parts; calls `id.Validate()`; wraps errors with `%w: ErrUsage`
- `runDiff(ctx, cmd, args, g, o)`:
  - 0 args → return `ErrUsage` (interactive comes later)
  - != 3 args → `ErrUsage`
  - validate module + both versions via api.ValidateVersion
  - build client, disk cache, registry
  - FetchPair both versions
  - Extract both (wrap errors with `%w: ErrArchive`)
  - `diff.Textual` → `diff.Result`
  - `renderPlain(cmd.OutOrStdout(), result)`
- `renderPlain(w io.Writer, r diff.Result)` — prints "no differences" when empty, else per-file `=== path (status) +adds -dels` then `fd.Unified`

Create `apps/birdseye/cmd/diff_test.go`:
- `fakeCrowTwoVersions(t)` test helper with v1+v2 of `iac/comply/aws`
- `TestDiffPlainOutput` — assert output contains `main.tf`, `variables.tf`, `alpha`, `beta`
- `TestDiffIdenticalVersionsNoChanges` — output is literally `no differences`
- `TestDiffBadModuleAddress` — `ExitCode(err) == 1`

Modify `apps/birdseye/cmd/root.go` — replace the placeholder comment with `root.AddCommand(newDiffCmd(g))`.

Commit message exact: `feat(birdseye): plain unified diff output for one-shot CLI usage`.

## Quality Focus

- Layering: does `runDiff` actually flow through registry → archive → diff?
- Error wrapping: `%w: ErrUsage` / `%w: ErrArchive` for the right paths?
- TUI/interactive branch: should be 0-args → ErrUsage placeholder (Task 15 fixes)?
- `defaultCacheDir`: handles `os.UserCacheDir` error path?
- `--no-tui` vs `--plain`: both bind to same `o.Plain`?
- Tests use `t.TempDir()` for cache dir (no pollution)?
- Resource cleanup: any leaked goroutines / file descriptors?

## Report Format

**Spec:** ✅ compliant OR ❌ issues with file:line
**Quality:**
- Strengths: 1-2 bullets
- Issues: Critical / Important / Minor
- Assessment: APPROVED | NEEDS_CHANGES

Be terse.


Task 10 APPROVED. Moving to Task 11 (`versions` subcommand).


Implement Task 11: versions subcommand

Prompt:
You are implementing Task 11 of the birdseye CLI implementation plan.

## Working Environment

- **Worktree path:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`
- **Git branch:** `worktree-birdseye`
- Recent commits include Task 10 (cmd diff --plain at `835333a`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

Task 10 added the `diff` subcommand. This task adds a small companion subcommand `birdseye versions <module>` that mirrors `crowctl list-versions` — prints each published version for a module on its own line. Uses the existing `client.Client.ListVersions` (already implemented in `services/crow/pkg/client/`).

This is a simple task: one new file + one test file + a one-line edit to root.go.

## Task Description (Verbatim)

### Task 11: `versions` subcommand

**Files:**
- Create: `apps/birdseye/cmd/versions.go`
- Create: `apps/birdseye/cmd/versions_test.go`
- Modify: `apps/birdseye/cmd/root.go`

- [ ] **Step 1: Write failing test** at `apps/birdseye/cmd/versions_test.go`:

```go
package cmd

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/immuta/operations/services/crow/pkg/api"
)

func TestVersionsCmd(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/v1/modules/iac/comply/aws/versions" {
			http.NotFound(w, r)
			return
		}
		_ = json.NewEncoder(w).Encode(api.ListVersionsResponse{
			Modules: []api.ModuleVersions{
				{Versions: []api.VersionEntry{{Version: "1.0.0"}, {Version: "1.1.0"}}},
			},
		})
	}))
	defer srv.Close()

	root := NewRootCmd()
	var stdout bytes.Buffer
	root.SetOut(&stdout)
	root.SetArgs([]string{"versions", "iac/comply/aws", "--crow-url", srv.URL})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v", err)
	}
	out := stdout.String()
	if !strings.Contains(out, "1.0.0") || !strings.Contains(out, "1.1.0") {
		t.Errorf("versions output missing entries:\n%s", out)
	}
}
```

- [ ] **Step 2: Run, expect FAIL** (`unknown command "versions"`)

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./cmd/... -run TestVersionsCmd -v`

- [ ] **Step 3: Implement `apps/birdseye/cmd/versions.go`**

```go
package cmd

import (
	"fmt"

	"github.com/spf13/cobra"

	"github.com/immuta/operations/services/crow/pkg/client"
)

func newVersionsCmd(g *GlobalFlags) *cobra.Command {
	return &cobra.Command{
		Use:           "versions <module>",
		Short:         "List published versions of a Crow module",
		Args:          cobra.ExactArgs(1),
		SilenceUsage:  true,
		SilenceErrors: true,
		RunE: func(cmd *cobra.Command, args []string) error {
			id, err := parseModuleAddress(args[0])
			if err != nil {
				return err
			}
			cli := client.New(g.CrowURL, client.WithHTTPClient(newHTTPClient(g.Timeout)))
			versions, err := cli.ListVersions(cmd.Context(), id)
			if err != nil {
				return err
			}
			for _, v := range versions {
				fmt.Fprintln(cmd.OutOrStdout(), v)
			}
			return nil
		},
	}
}
```

- [ ] **Step 4: Wire subcommand** in `apps/birdseye/cmd/root.go`

Find the line you added in Task 10:
```go
root.AddCommand(newDiffCmd(g))
// versions subcommand added in Task 11
```

Replace with:
```go
root.AddCommand(newDiffCmd(g))
root.AddCommand(newVersionsCmd(g))
```

- [ ] **Step 5: Run, expect PASS**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./cmd/... -v`
Expected: All PASS (existing + `TestVersionsCmd` = 10+ tests).

- [ ] **Step 6: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/cmd/versions.go apps/birdseye/cmd/versions_test.go apps/birdseye/cmd/root.go && git commit -m "feat(birdseye): versions subcommand"
```

## Notes & Style

- `parseModuleAddress` and `newHTTPClient` already exist in `cmd/diff.go`. Reuse them — don't duplicate.
- `cobra.ExactArgs(1)` rejects 0 or 2+ args at the cobra layer.
- The test asserts via `strings.Contains`. Don't sort or rewrite the version output — the test relies on the order from the fake server.
- Print one version per line via `fmt.Fprintln`.

## Self-Review Checklist

- [ ] `TestVersionsCmd` passes under `-race`
- [ ] All previous `cmd` tests still pass
- [ ] `go build ./...` succeeds
- [ ] `go vet ./...` succeeds
- [ ] `newVersionsCmd(g)` is wired into root
- [ ] Bad module address still surfaces as `ErrUsage` (since we reuse `parseModuleAddress`)
- [ ] Commit message exactly: `feat(birdseye): versions subcommand`
- [ ] Only `apps/birdseye/cmd/versions.go`, `apps/birdseye/cmd/versions_test.go`, `apps/birdseye/cmd/root.go` staged

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files created/modified
- Test count + race-clean
- Commit SHA
- Anything unexpected


**Status:** DONE

**Files created/modified:**
- Created: `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/versions.go`
- Created: `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/versions_test.go`
- Modified: `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/root.go` (replaced the placeholder comment with `root.AddCommand(newVersionsCmd(g))`)

**Test count:** 10 tests in `./cmd/...`, all PASS under `-race` (including the new `TestVersionsCmd`)

**Build/vet:** Clean — no output from `go build ./...` or `go vet ./...`

**Commit SHA:** `f0b337f` — `feat(birdseye): versions subcommand`

**Nothing unexpected.** The implementation reuses `parseModuleAddress` and `newHTTPClient` from `diff.go` as specified, and `client.ListVersions` was already implemented in the crow client package. Straightforward wiring.

agentId: ac33984ab5631023d (use SendMessage with to: 'ac33984ab5631023d' to continue this agent)
<usage>total_tokens: 18425
tool_uses: 9
duration_ms: 59137</usage>

Prompt:
You are implementing Task 11 of the birdseye CLI implementation plan.

## Working Environment

- **Worktree path:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`
- **Git branch:** `worktree-birdseye`
- Recent commits include Task 10 (cmd diff --plain at `835333a`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

Task 10 added the `diff` subcommand. This task adds a small companion subcommand `birdseye versions <module>` that mirrors `crowctl list-versions` — prints each published version for a module on its own line. Uses the existing `client.Client.ListVersions` (already implemented in `services/crow/pkg/client/`).

This is a simple task: one new file + one test file + a one-line edit to root.go.

## Task Description (Verbatim)

### Task 11: `versions` subcommand

**Files:**
- Create: `apps/birdseye/cmd/versions.go`
- Create: `apps/birdseye/cmd/versions_test.go`
- Modify: `apps/birdseye/cmd/root.go`

- [ ] **Step 1: Write failing test** at `apps/birdseye/cmd/versions_test.go`:

```go
package cmd

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/immuta/operations/services/crow/pkg/api"
)

func TestVersionsCmd(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/v1/modules/iac/comply/aws/versions" {
			http.NotFound(w, r)
			return
		}
		_ = json.NewEncoder(w).Encode(api.ListVersionsResponse{
			Modules: []api.ModuleVersions{
				{Versions: []api.VersionEntry{{Version: "1.0.0"}, {Version: "1.1.0"}}},
			},
		})
	}))
	defer srv.Close()

	root := NewRootCmd()
	var stdout bytes.Buffer
	root.SetOut(&stdout)
	root.SetArgs([]string{"versions", "iac/comply/aws", "--crow-url", srv.URL})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v", err)
	}
	out := stdout.String()
	if !strings.Contains(out, "1.0.0") || !strings.Contains(out, "1.1.0") {
		t.Errorf("versions output missing entries:\n%s", out)
	}
}
```

- [ ] **Step 2: Run, expect FAIL** (`unknown command "versions"`)

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./cmd/... -run TestVersionsCmd -v`

- [ ] **Step 3: Implement `apps/birdseye/cmd/versions.go`**

```go
package cmd

import (
	"fmt"

	"github.com/spf13/cobra"

	"github.com/immuta/operations/services/crow/pkg/client"
)

func newVersionsCmd(g *GlobalFlags) *cobra.Command {
	return &cobra.Command{
		Use:           "versions <module>",
		Short:         "List published versions of a Crow module",
		Args:          cobra.ExactArgs(1),
		SilenceUsage:  true,
		SilenceErrors: true,
		RunE: func(cmd *cobra.Command, args []string) error {
			id, err := parseModuleAddress(args[0])
			if err != nil {
				return err
			}
			cli := client.New(g.CrowURL, client.WithHTTPClient(newHTTPClient(g.Timeout)))
			versions, err := cli.ListVersions(cmd.Context(), id)
			if err != nil {
				return err
			}
			for _, v := range versions {
				fmt.Fprintln(cmd.OutOrStdout(), v)
			}
			return nil
		},
	}
}
```

- [ ] **Step 4: Wire subcommand** in `apps/birdseye/cmd/root.go`

Find the line you added in Task 10:
```go
root.AddCommand(newDiffCmd(g))
// versions subcommand added in Task 11
```

Replace with:
```go
root.AddCommand(newDiffCmd(g))
root.AddCommand(newVersionsCmd(g))
```

- [ ] **Step 5: Run, expect PASS**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./cmd/... -v`
Expected: All PASS (existing + `TestVersionsCmd` = 10+ tests).

- [ ] **Step 6: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/cmd/versions.go apps/birdseye/cmd/versions_test.go apps/birdseye/cmd/root.go && git commit -m "feat(birdseye): versions subcommand"
```

## Notes & Style

- `parseModuleAddress` and `newHTTPClient` already exist in `cmd/diff.go`. Reuse them — don't duplicate.
- `cobra.ExactArgs(1)` rejects 0 or 2+ args at the cobra layer.
- The test asserts via `strings.Contains`. Don't sort or rewrite the version output — the test relies on the order from the fake server.
- Print one version per line via `fmt.Fprintln`.

## Self-Review Checklist

- [ ] `TestVersionsCmd` passes under `-race`
- [ ] All previous `cmd` tests still pass
- [ ] `go build ./...` succeeds
- [ ] `go vet ./...` succeeds
- [ ] `newVersionsCmd(g)` is wired into root
- [ ] Bad module address still surfaces as `ErrUsage` (since we reuse `parseModuleAddress`)
- [ ] Commit message exactly: `feat(birdseye): versions subcommand`
- [ ] Only `apps/birdseye/cmd/versions.go`, `apps/birdseye/cmd/versions_test.go`, `apps/birdseye/cmd/root.go` staged

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files created/modified
- Test count + race-clean
- Commit SHA
- Anything unexpected


Combined review Task 11

Prompt:
Combined spec + code-quality review of Task 11 (versions subcommand) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Diff: `git diff 835333a..f0b337f -- apps/birdseye/cmd/`

## Spec — What Was Requested

Create `apps/birdseye/cmd/versions.go`:
- `newVersionsCmd(g *GlobalFlags) *cobra.Command` with Use `"versions <module>"`, `ExactArgs(1)`, `SilenceUsage`/`SilenceErrors` true, RunE parses module address, builds client with `newHTTPClient(g.Timeout)`, calls `client.ListVersions`, prints each version via `fmt.Fprintln(cmd.OutOrStdout(), v)`

Create `apps/birdseye/cmd/versions_test.go` with `TestVersionsCmd` that uses httptest server returning `api.ListVersionsResponse` for `iac/comply/aws` and asserts output contains "1.0.0" and "1.1.0".

Modify `apps/birdseye/cmd/root.go` to `root.AddCommand(newVersionsCmd(g))`.

Commit message exact: `feat(birdseye): versions subcommand`.

## Quality Focus

- Code reuse: does `versions.go` use `parseModuleAddress` and `newHTTPClient` from `diff.go` (not duplicate them)?
- Anything overengineered (no over-abstracted helpers for a 15-line subcommand)?
- Test independence?

## Report Format

**Spec:** ✅ / ❌
**Quality:** Strengths / Issues (Critical/Important/Minor) / Assessment

Be terse.


**Spec:** ✅

Every requirement is met: `newVersionsCmd` with correct `Use`, `ExactArgs(1)`, both Silence flags, `parseModuleAddress` reused, `newHTTPClient` reused, `fmt.Fprintln(cmd.OutOrStdout(), v)` for output, wired into root via `AddCommand`, test uses httptest with `api.ListVersionsResponse` for `iac/comply/aws`, asserts both "1.0.0" and "1.1.0", commit message is exact.

---

**Quality:**

**Strengths**
- Clean reuse: `parseModuleAddress` and `newHTTPClient` called directly from `diff.go` — zero duplication.
- Size is right: 34 lines, no gratuitous helpers or abstractions.
- Test is independent: spins its own httptest server, no shared state, exercises the real cobra wiring via `NewRootCmd()`.
- `cmd.OutOrStdout()` used correctly — makes the output capturable, which the test exploits.
- `--crow-url` passed in the test args — no env var coupling.

**Issues**

*Minor:* The test asserts via `strings.Contains` twice in one `if` with a single error message. If only one version is missing the error message doesn't say which — a small debuggability nit, not a correctness issue.

*Minor:* No `Short` description is required by the spec but one is present (`"List published versions of a Crow module"`) — that's a positive addition, not a problem.

**Assessment:** Ship it. Clean, appropriately sized, good reuse, solid test. The only nit is the combined `strings.Contains` assertion, which is cosmetic.

agentId: a154e8ee984c37943 (use SendMessage with to: 'a154e8ee984c37943' to continue this agent)
<usage>total_tokens: 18056
tool_uses: 4
duration_ms: 27171</usage>

Prompt:
Combined spec + code-quality review of Task 11 (versions subcommand) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Diff: `git diff 835333a..f0b337f -- apps/birdseye/cmd/`

## Spec — What Was Requested

Create `apps/birdseye/cmd/versions.go`:
- `newVersionsCmd(g *GlobalFlags) *cobra.Command` with Use `"versions <module>"`, `ExactArgs(1)`, `SilenceUsage`/`SilenceErrors` true, RunE parses module address, builds client with `newHTTPClient(g.Timeout)`, calls `client.ListVersions`, prints each version via `fmt.Fprintln(cmd.OutOrStdout(), v)`

Create `apps/birdseye/cmd/versions_test.go` with `TestVersionsCmd` that uses httptest server returning `api.ListVersionsResponse` for `iac/comply/aws` and asserts output contains "1.0.0" and "1.1.0".

Modify `apps/birdseye/cmd/root.go` to `root.AddCommand(newVersionsCmd(g))`.

Commit message exact: `feat(birdseye): versions subcommand`.

## Quality Focus

- Code reuse: does `versions.go` use `parseModuleAddress` and `newHTTPClient` from `diff.go` (not duplicate them)?
- Anything overengineered (no over-abstracted helpers for a 15-line subcommand)?
- Test independence?

## Report Format

**Spec:** ✅ / ❌
**Quality:** Strengths / Issues (Critical/Important/Minor) / Assessment

Be terse.


Phase C complete. Moving to Phase D (TUI work). Task 12: TUI Browser scaffold.


Updated task #10 status


Updated task #11 status


Implement Task 12: TUI Browser scaffold

Prompt:
You are implementing Task 12 of the birdseye CLI implementation plan.

## Working Environment

- **Worktree path:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`
- **Git branch:** `worktree-birdseye`
- Recent commits include Task 11 (versions subcommand at `f0b337f`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

This task creates the bubbletea TUI scaffold for browsing a pre-computed `diff.Result`: left pane shows a file tree (status marker + path), right pane shows the colorized unified diff for the selected file, footer shows keybind hints. The Browser is just the rendering layer — it accepts the `diff.Result` as input and never makes network calls.

This task adds:
- `internal/tui/styles.go` — lipgloss theme
- `internal/tui/browser.go` — Browser struct + state helpers (Move, SelectedFile, View, etc.) — but NOT yet a `tea.Model` (no Update/Init)
- `internal/tui/browser_test.go` — non-Model tests

Task 13 (next) wires Browser as a `tea.Model` and adds key handling + the `RunBrowser` entry point.

The plan's code is verbatim. Strict TDD.

## Task Description (Verbatim)

### Task 12: `internal/tui` — styles and Browser model (no key handling yet)

**Files:**
- Create: `apps/birdseye/internal/tui/styles.go`
- Create: `apps/birdseye/internal/tui/browser.go`
- Create: `apps/birdseye/internal/tui/browser_test.go`

- [ ] **Step 1: Write failing test** at `apps/birdseye/internal/tui/browser_test.go`:

```go
package tui

import (
	"testing"

	"github.com/immuta/operations/apps/birdseye/internal/diff"
)

func sampleResult() diff.Result {
	return diff.Result{Files: []diff.FileDiff{
		{Path: "a.tf", Status: diff.StatusModified, Unified: "@@\n-x\n+y\n", Adds: 1, Dels: 1},
		{Path: "b.tf", Status: diff.StatusAdded, Unified: "+++ b.tf\n+new\n", Adds: 1},
	}}
}

func TestNewBrowserDefaults(t *testing.T) {
	b := NewBrowser(sampleResult())
	if b.SelectedIndex() != 0 {
		t.Errorf("default selected index = %d, want 0", b.SelectedIndex())
	}
	if b.SelectedFile().Path != "a.tf" {
		t.Errorf("first selected = %s", b.SelectedFile().Path)
	}
}

func TestBrowserViewContainsBothFiles(t *testing.T) {
	b := NewBrowser(sampleResult())
	b.SetSize(120, 30)
	view := b.View()
	for _, want := range []string{"a.tf", "b.tf"} {
		if !contains(view, want) {
			t.Errorf("view missing %q\n%s", want, view)
		}
	}
}

func contains(s, sub string) bool {
	for i := 0; i+len(sub) <= len(s); i++ {
		if s[i:i+len(sub)] == sub {
			return true
		}
	}
	return false
}
```

- [ ] **Step 2: Run, expect FAIL**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/tui/...`
Expected: undefined symbols.

- [ ] **Step 3: Implement `apps/birdseye/internal/tui/styles.go`**

```go
// Package tui hosts the bubbletea presentation for birdseye. Models are
// pure — they never make network calls or read the filesystem. The
// browser displays a pre-computed diff.Result; the picker (Task 14)
// dispatches network work as tea.Cmds.
package tui

import "github.com/charmbracelet/lipgloss"

// Theme keeps every lipgloss style in one place for easy tweaking.
type Theme struct {
	FileTree     lipgloss.Style
	FileTreeItem lipgloss.Style
	FileSelected lipgloss.Style
	StatusAdd    lipgloss.Style
	StatusDel    lipgloss.Style
	StatusMod    lipgloss.Style
	Viewport     lipgloss.Style
	Footer       lipgloss.Style
	AddedLine    lipgloss.Style
	RemovedLine  lipgloss.Style
	HunkHeader   lipgloss.Style
}

// DefaultTheme returns a high-contrast theme that should look reasonable
// in both light and dark terminals.
func DefaultTheme() Theme {
	return Theme{
		FileTree:     lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).Padding(0, 1),
		FileTreeItem: lipgloss.NewStyle(),
		FileSelected: lipgloss.NewStyle().Bold(true).Reverse(true),
		StatusAdd:    lipgloss.NewStyle().Foreground(lipgloss.Color("10")),
		StatusDel:    lipgloss.NewStyle().Foreground(lipgloss.Color("9")),
		StatusMod:    lipgloss.NewStyle().Foreground(lipgloss.Color("11")),
		Viewport:     lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).Padding(0, 1),
		Footer:       lipgloss.NewStyle().Faint(true),
		AddedLine:    lipgloss.NewStyle().Foreground(lipgloss.Color("10")),
		RemovedLine:  lipgloss.NewStyle().Foreground(lipgloss.Color("9")),
		HunkHeader:   lipgloss.NewStyle().Foreground(lipgloss.Color("14")),
	}
}
```

- [ ] **Step 4: Implement `apps/birdseye/internal/tui/browser.go`**

```go
package tui

import (
	"strings"

	"github.com/charmbracelet/bubbles/viewport"
	"github.com/charmbracelet/lipgloss"

	"github.com/immuta/operations/apps/birdseye/internal/diff"
)

// Browser is the multi-pane diff viewer: file tree on the left,
// rendered unified diff on the right, footer at the bottom.
type Browser struct {
	result   diff.Result
	theme    Theme
	selected int
	width    int
	height   int
	viewport viewport.Model
}

// NewBrowser builds a Browser from a pre-computed Result. SetSize must
// be called once before the first View() so the viewport sizes itself.
func NewBrowser(r diff.Result) *Browser {
	vp := viewport.New(0, 0)
	return &Browser{
		result:   r,
		theme:    DefaultTheme(),
		viewport: vp,
	}
}

// SetSize informs the browser of the terminal dimensions.
func (b *Browser) SetSize(w, h int) {
	b.width, b.height = w, h
	fileTreeW := 32
	if w < 80 {
		fileTreeW = 24
	}
	contentW := w - fileTreeW - 4
	contentH := h - 2
	if contentW < 10 {
		contentW = 10
	}
	if contentH < 5 {
		contentH = 5
	}
	b.viewport.Width = contentW
	b.viewport.Height = contentH
	b.refreshViewport()
}

// SelectedIndex returns the currently focused file diff index.
func (b *Browser) SelectedIndex() int { return b.selected }

// SelectedFile returns the FileDiff at the current selection. Falls
// back to a zero value when the result has no files.
func (b *Browser) SelectedFile() diff.FileDiff {
	if len(b.result.Files) == 0 {
		return diff.FileDiff{}
	}
	return b.result.Files[b.selected]
}

// Move adjusts the selection by delta, clamping to the valid range, and
// refreshes the viewport content.
func (b *Browser) Move(delta int) {
	if len(b.result.Files) == 0 {
		return
	}
	b.selected += delta
	if b.selected < 0 {
		b.selected = 0
	}
	if b.selected >= len(b.result.Files) {
		b.selected = len(b.result.Files) - 1
	}
	b.refreshViewport()
}

func (b *Browser) refreshViewport() {
	if len(b.result.Files) == 0 {
		b.viewport.SetContent("no differences")
		return
	}
	b.viewport.SetContent(b.colorizeUnified(b.SelectedFile().Unified))
	b.viewport.GotoTop()
}

func (b *Browser) colorizeUnified(unified string) string {
	var sb strings.Builder
	for _, line := range strings.Split(unified, "\n") {
		switch {
		case strings.HasPrefix(line, "@@"):
			sb.WriteString(b.theme.HunkHeader.Render(line))
		case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"):
			sb.WriteString(b.theme.AddedLine.Render(line))
		case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"):
			sb.WriteString(b.theme.RemovedLine.Render(line))
		default:
			sb.WriteString(line)
		}
		sb.WriteByte('\n')
	}
	return sb.String()
}

// View renders the full multi-pane layout.
func (b *Browser) View() string {
	tree := b.renderTree()
	right := b.theme.Viewport.Render(b.viewport.View())
	body := lipgloss.JoinHorizontal(lipgloss.Top, tree, right)
	footer := b.theme.Footer.Render("j/k or ↑/↓ move · q quit · esc back")
	return lipgloss.JoinVertical(lipgloss.Left, body, footer)
}

func (b *Browser) renderTree() string {
	var sb strings.Builder
	for i, fd := range b.result.Files {
		marker := " "
		switch fd.Status {
		case diff.StatusAdded:
			marker = b.theme.StatusAdd.Render("+")
		case diff.StatusRemoved:
			marker = b.theme.StatusDel.Render("-")
		case diff.StatusModified:
			marker = b.theme.StatusMod.Render("~")
		}
		line := marker + " " + fd.Path
		if i == b.selected {
			line = b.theme.FileSelected.Render(line)
		} else {
			line = b.theme.FileTreeItem.Render(line)
		}
		sb.WriteString(line)
		sb.WriteByte('\n')
	}
	if len(b.result.Files) == 0 {
		sb.WriteString("(no files)")
	}
	return b.theme.FileTree.Render(sb.String())
}
```

- [ ] **Step 5: Run, expect PASS**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/tui/... -v`
Expected: 2 tests pass.

- [ ] **Step 6: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/tui/ apps/birdseye/go.mod apps/birdseye/go.sum && git commit -m "feat(birdseye): tui browser scaffold with multi-pane layout"
```

Drop go.mod/go.sum from `git add` if unchanged.

## Notes & Style

- This task deliberately omits `Init()` and `Update()` — Task 13 adds them. Don't add them now or you'll have to delete them.
- `bubbles/viewport` and `lipgloss` were already in go.mod from Task 1. If `go mod tidy` doesn't move them between direct/indirect blocks, that's fine.
- The view rendering uses lipgloss's `JoinHorizontal` / `JoinVertical` for layout. Don't hand-roll.
- The `contains` test helper in `browser_test.go` is a tiny utility because `strings.Contains` would otherwise require importing `strings` in the test file. Either works; the plan uses the manual version. Keep it.

## Code Organization

`styles.go` (theme only), `browser.go` (rendering only). No `_test.go` file beyond `browser_test.go`. Three files total.

## Self-Review Checklist

- [ ] Both tests pass under `-race`
- [ ] `go build ./...` succeeds
- [ ] `go vet ./...` clean
- [ ] No `Init()`, `Update()`, or `RunBrowser` defined yet (those are Task 13)
- [ ] Theme struct has all 11 lipgloss styles listed
- [ ] `Browser.SetSize(120, 30)` produces a viewport with non-trivial dimensions
- [ ] `Browser.View()` contains both file paths in the rendered tree
- [ ] Commit message exactly: `feat(birdseye): tui browser scaffold with multi-pane layout`
- [ ] Only `apps/birdseye/internal/tui/` files (plus optional go.mod/go.sum) staged

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files created
- Test count + race-clean
- Commit SHA
- Anything unexpected