Done.

---

**Status:** DONE

**Files modified:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/diff.go`

**Changes made:**
1. Replaced the `"interactive mode not yet implemented"` error branch in `runDiff` with a delegation to `runInteractive`.
2. Merged `tea "github.com/charmbracelet/bubbletea"` into the existing import block.
3. Appended `runInteractive` which: checks TTY, builds `client`/`registry`, runs the picker program (Stage 1), reads `Bytes()`, returns nil if user quit early, then extracts both archives + computes diff + runs the Browser (Stage 2).

**Tests:** 9 packages, all PASS, race-clean (34 individual test cases).

**Build:** clean.

**Commit SHA:** `894eb9c` — `feat(birdseye): wire picker -> browser for no-args diff`

**Nothing unexpected.** The existing imports already included `tui`, `archive`, `diff`, `registry`, and `client`, so no new import groups were needed — only the `bubbletea` alias had to be added.

agentId: af83c4e6af83bdf4f (use SendMessage with to: 'af83c4e6af83bdf4f' to continue this agent)
<usage>total_tokens: 19220
tool_uses: 8
duration_ms: 76383</usage>

Prompt:
You are implementing Task 15 of the birdseye CLI implementation plan — the last task in Phase D.

## Working Environment

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

## Context

Task 14 created the interactive Picker (`*tui.Picker`) which is a `tea.Model` running a state machine that ends by calling `tea.Quit` (without entering `StateBrowser`) once both module versions have been fetched. The picker exposes `Bytes() ([]byte, []byte)` so the caller can extract + diff + build a Browser, then run a SECOND `tea.NewProgram` with the Browser as root model.

Your job: replace the "interactive mode not yet implemented" branch in `runDiff` with a two-stage runner that:
1. Builds a `client.Client` + `registry.Registry`
2. Runs the picker until it quits
3. Reads the fetched bytes via `Bytes()`
4. If both bytes are non-nil, extracts both archives, computes diff, runs the Browser

This is a small wiring task.

## Task Description (Verbatim — from the plan)

### Task 15: Wire picker into `cmd diff` for no-args invocation

**Files:**
- Modify: `apps/birdseye/cmd/diff.go`

- [ ] **Step 1: Replace the "interactive mode not yet implemented" branch** in `runDiff`:

```go
	if len(args) == 0 {
		return runInteractive(ctx, cmd, g, o)
	}
```

(Drop the previous `return fmt.Errorf("%w: interactive mode not yet implemented..."` line.)

- [ ] **Step 2: Add the two-stage runner** to `apps/birdseye/cmd/diff.go`. Append below `runDiff` (and the existing helpers):

```go
import (
	tea "github.com/charmbracelet/bubbletea"

	"github.com/immuta/operations/apps/birdseye/internal/tui"
)
```

(Merge into the existing import block. Don't add a second `import` clause.)

```go
func runInteractive(ctx context.Context, cmd *cobra.Command, g *GlobalFlags, o *diffOpts) error {
	if !isTTY(cmd.OutOrStdout()) {
		return fmt.Errorf("%w: interactive mode requires a TTY", ErrUsage)
	}
	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)

	// Stage 1: picker → fetched bytes
	p := tui.NewPicker(&tui.Deps{Client: cli, Reg: reg})
	prog := tea.NewProgram(p, tea.WithAltScreen(), tea.WithContext(ctx))
	if _, err := prog.Run(); err != nil {
		return err
	}
	aBytes, bBytes := p.Bytes()
	if aBytes == nil || bBytes == nil {
		// User quit before completing picker; not an error.
		return nil
	}

	// Stage 2: extract + diff + browser
	modA, err := archive.Extract(aBytes, maxArchiveBytes)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrArchive, err)
	}
	modB, err := archive.Extract(bBytes, maxArchiveBytes)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrArchive, err)
	}
	result := diff.Result{Files: diff.Textual(modA, modB)}
	return tui.RunBrowser(ctx, result)
}
```

- [ ] **Step 3: Run all tests, expect PASS**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... -v`
Expected: All PASS — the no-args path is not covered by automated tests because it requires a TTY, so the existing cmd tests still verify the positional-args path.

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

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

- [ ] **Step 5: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/cmd/diff.go && git commit -m "feat(birdseye): wire picker -> browser for no-args diff"
```

## Notes & Style

- The two-stage approach is intentional: `tui` cannot import `archive` or `diff` (those packages would create cycles or duplicate code). The cmd layer composes them.
- The "user quit before completing picker" case (Bytes returns nil, nil) returns nil success — Ctrl+C or `q` during picker should NOT produce a non-zero exit code.
- `runInteractive` runs the picker program FIRST, blocks until it quits, THEN runs the Browser program. Two separate `tea.NewProgram` invocations.
- Don't add new tests in this task — the existing test surface still covers the positional-args path which is what test environments use anyway.

## Self-Review Checklist

- [ ] `runDiff` correctly delegates 0-args to `runInteractive`
- [ ] `runInteractive` checks `isTTY` first and returns ErrUsage if not a TTY
- [ ] `prog.Run()` is called BEFORE `Bytes()` is read
- [ ] If both `aBytes` and `bBytes` are non-nil → extract + diff + RunBrowser
- [ ] If either is nil (user quit) → return nil
- [ ] Archive extract errors wrap with `%w: %v` against `ErrArchive`
- [ ] `go vet ./...` clean
- [ ] All existing tests pass
- [ ] Commit message exactly: `feat(birdseye): wire picker -> browser for no-args diff`
- [ ] Only `apps/birdseye/cmd/diff.go` staged

## Report Format

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


Agent

Combined review Task 15

Prompt:
Combined spec + code-quality review of Task 15 (wire picker into cmd diff) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Diff: `git diff b9ac21b..894eb9c -- apps/birdseye/cmd/diff.go`

## Spec — What Was Requested

In `apps/birdseye/cmd/diff.go`:

1. Replace the 0-args branch in `runDiff` from `return fmt.Errorf("%w: interactive mode not yet implemented...")` to:
   ```go
   if len(args) == 0 {
       return runInteractive(ctx, cmd, g, o)
   }
   ```

2. Add `runInteractive(ctx, cmd, g, o) error` that:
   - Returns `ErrUsage` if `!isTTY(cmd.OutOrStdout())`
   - Builds client, disk cache, registry
   - Creates picker via `tui.NewPicker(&tui.Deps{Client: cli, Reg: reg})`
   - Runs `tea.NewProgram(p, tea.WithAltScreen(), tea.WithContext(ctx))` and waits
   - Reads `p.Bytes()` — if either nil, return nil (user quit)
   - Extracts both archives (errors wrap `ErrArchive`)
   - Calls `diff.Textual` + `tui.RunBrowser`

3. Import `tea "github.com/charmbracelet/bubbletea"` merged into existing block.

Commit message exact: `feat(birdseye): wire picker -> browser for no-args diff`.

## Quality Focus

- Two-stage runner correctness:
  - Stage 1 runs to completion before Stage 2
  - Stage 2 only runs if Bytes returned non-nil pair
- Error handling: archive errors wrap ErrArchive; ctx-derived errors flow through naturally
- Test coverage: no new tests required since the no-args path needs a TTY
- Import grouping: `tea` placed correctly with other third-party imports
- Resource cleanup: any leaked file descriptors, goroutines from the picker?
- Style consistency with the rest of `diff.go`

## Report Format

**Spec:** ✅ / ❌
**Quality:** Strengths / Issues / Assessment

Be terse.


**Spec: ✅**

All five spec requirements are met exactly as described.

**Quality:**

**Strengths:**
- Two-stage runner is correctly sequenced: `prog.Run()` blocks until picker exits, `p.Bytes()` is only called after, Stage 2 only runs when both byte slices are non-nil.
- Archive errors correctly wrap `ErrArchive` with `%w` (consistent with the non-interactive path).
- `tea` import is placed correctly — in the third-party group between `bubbletea` and `cobra`, maintaining the stdlib / third-party / internal block structure.
- `isTTY` guard placed first, matching the spirit of fail-fast.
- Nil-pair early return is comment-explained and clean.
- Variable naming (`modA`/`modB` for extracted maps) matches the non-interactive path naming.

**Issues:**

1. **`o` (diffOpts) is accepted but never used.** `runInteractive` ignores `o.Plain`, `o.Semantic`, and potentially other future opts. If the user passed `--plain` with no args, they'd get the interactive TTY check instead of a usage error. That's arguably intentional (plain is for the 3-arg path), but the parameter should either be dropped or have a comment acknowledging it's reserved for future use (semantic diff, Task 17).

2. **Client constructed twice risk.** `runInteractive` duplicates the `client.New(...)`, `registry.NewDiskCache(...)`, `registry.New(...)` setup verbatim from `runDiff`. Not a correctness issue now, but a latent maintenance hazard — if the non-interactive path changes (e.g., auth, retry policy), this fork won't automatically follow. A small private helper `buildRegistry(g, o)` would eliminate the duplication.

3. **Picker resource leak on `prog.Run()` error.** If `prog.Run()` returns an error, the picker's internal goroutines/channels are torn down by BubbleTea's own cleanup, so this is fine. No leak.

4. **`prog.Run()` return value discarded.** The first return value of `prog.Run()` is `(tea.Model, error)`. The model is discarded with `_`, which is correct here since state is read directly from `p.Bytes()` — no issue.

5. **Commit message matches spec exactly.** ✅

**Assessment:** Functionally correct and spec-complete. The only real quality concern worth flagging is the duplicated client/registry construction — not a bug, but it will need consolidation as the codebase grows. The unused `o` parameter is minor but worth a comment.

agentId: a14b33f72211a177c (use SendMessage with to: 'a14b33f72211a177c' to continue this agent)
<usage>total_tokens: 16063
tool_uses: 2
duration_ms: 20993</usage>

Prompt:
Combined spec + code-quality review of Task 15 (wire picker into cmd diff) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Diff: `git diff b9ac21b..894eb9c -- apps/birdseye/cmd/diff.go`

## Spec — What Was Requested

In `apps/birdseye/cmd/diff.go`:

1. Replace the 0-args branch in `runDiff` from `return fmt.Errorf("%w: interactive mode not yet implemented...")` to:
   ```go
   if len(args) == 0 {
       return runInteractive(ctx, cmd, g, o)
   }
   ```

2. Add `runInteractive(ctx, cmd, g, o) error` that:
   - Returns `ErrUsage` if `!isTTY(cmd.OutOrStdout())`
   - Builds client, disk cache, registry
   - Creates picker via `tui.NewPicker(&tui.Deps{Client: cli, Reg: reg})`
   - Runs `tea.NewProgram(p, tea.WithAltScreen(), tea.WithContext(ctx))` and waits
   - Reads `p.Bytes()` — if either nil, return nil (user quit)
   - Extracts both archives (errors wrap `ErrArchive`)
   - Calls `diff.Textual` + `tui.RunBrowser`

3. Import `tea "github.com/charmbracelet/bubbletea"` merged into existing block.

Commit message exact: `feat(birdseye): wire picker -> browser for no-args diff`.

## Quality Focus

- Two-stage runner correctness:
  - Stage 1 runs to completion before Stage 2
  - Stage 2 only runs if Bytes returned non-nil pair
- Error handling: archive errors wrap ErrArchive; ctx-derived errors flow through naturally
- Test coverage: no new tests required since the no-args path needs a TTY
- Import grouping: `tea` placed correctly with other third-party imports
- Resource cleanup: any leaked file descriptors, goroutines from the picker?
- Style consistency with the rest of `diff.go`

## Report Format

**Spec:** ✅ / ❌
**Quality:** Strengths / Issues / Assessment

Be terse.


Build birdseye CLI for Terraform module diffing


Phase D complete. Moving to Phase E with Task 16 (HCL semantic diff).


Updated task #11 status


Updated task #12 status


Implement Task 16: semantic diff

Prompt:
You are implementing Task 16 of the birdseye CLI implementation plan — HCL-aware semantic diff.

## Working Environment

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

## Context

The existing textual diff covers any file, including .tf. This task adds an HCL-aware semantic diff that parses `.tf` files via `github.com/hashicorp/hcl/v2/hclparse`, builds a `ModuleSummary` (resources, variables, outputs, providers, locals, data, modules), and reports per-kind added/removed/modified blocks. Used by the `--semantic` flag (wired in Task 17).

The `SemanticChange` type already exists in `apps/birdseye/internal/diff/types.go` (Task 9). You add the `Semantic` function and a private `ModuleSummary` helper.

The block-equality check uses a hash of {labels, attribute keys, attribute byte-range offsets, nested block types+labels}. This is approximate but stable: any change to body bytes within a block shifts the offsets, so the hash changes. Renames of resources/variables/etc. surface as Removed+Added.

Strict TDD.

## Task Description (Verbatim)

### Task 16: `internal/diff` — semantic diff (ModuleSummary)

**Files:**
- Create: `apps/birdseye/internal/diff/semantic.go`
- Create: `apps/birdseye/internal/diff/semantic_test.go`

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

```go
package diff

import (
	"errors"
	"testing"
)

func TestSemanticAddedResource(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": ""})
	b := mkArchive(map[string]string{
		"main.tf": `resource "aws_s3_bucket" "alpha" {}`,
	})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "resource", "aws_s3_bucket.alpha", StatusAdded) {
		t.Errorf("missing added resource:\n%v", changes)
	}
}

func TestSemanticRemovedVariable(t *testing.T) {
	a := mkArchive(map[string]string{"v.tf": `variable "name" {}`})
	b := mkArchive(map[string]string{"v.tf": ""})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "variable", "name", StatusRemoved) {
		t.Errorf("missing removed variable:\n%v", changes)
	}
}

func TestSemanticModifiedResource(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" { bucket = "old" }`})
	b := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" { bucket = "new" }`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "resource", "aws_s3_bucket.alpha", StatusModified) {
		t.Errorf("missing modified resource:\n%v", changes)
	}
}

func TestSemanticParseError(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": "this is not valid hcl{{{"})
	_, err := Semantic(a, mkArchive(nil))
	if err == nil {
		t.Fatal("expected parse error")
	}
	if !errors.Is(err, ErrParseFailed) {
		t.Errorf("expected ErrParseFailed, got %v", err)
	}
}

func hasChange(changes []SemanticChange, kind, name string, status FileStatus) bool {
	for _, c := range changes {
		if c.Kind == kind && c.Name == name && c.Status == status {
			return true
		}
	}
	return false
}
```

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/diff/... -run TestSemantic -v`
Expected: compile error (`undefined: Semantic`).

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

```go
package diff

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"sort"
	"strings"

	"github.com/hashicorp/hcl/v2"
	"github.com/hashicorp/hcl/v2/hclparse"
	"github.com/hashicorp/hcl/v2/hclsyntax"

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

// ErrParseFailed is returned by Semantic when any .tf file fails to
// parse. Callers map this to exit code 5.
var ErrParseFailed = errors.New("hcl parse failed")

// ModuleSummary aggregates the named HCL blocks of interest from every
// .tf file in a module. Maps are keyed by a stable identifier
// (resource: "type.name", variable: name, etc.).
type ModuleSummary struct {
	Resources map[string]*hclsyntax.Block
	Variables map[string]*hclsyntax.Block
	Outputs   map[string]*hclsyntax.Block
	Providers map[string]*hclsyntax.Block
	Locals    map[string]*hclsyntax.Block
	Data      map[string]*hclsyntax.Block
	Modules   map[string]*hclsyntax.Block
}

// Semantic produces SemanticChange entries by parsing every .tf file in
// each archive into a ModuleSummary and diffing summaries.
func Semantic(a, b archive.Module) ([]SemanticChange, error) {
	sa, err := summarize(a)
	if err != nil {
		return nil, err
	}
	sb, err := summarize(b)
	if err != nil {
		return nil, err
	}
	var out []SemanticChange
	out = append(out, diffMap("resource", sa.Resources, sb.Resources)...)
	out = append(out, diffMap("variable", sa.Variables, sb.Variables)...)
	out = append(out, diffMap("output", sa.Outputs, sb.Outputs)...)
	out = append(out, diffMap("provider", sa.Providers, sb.Providers)...)
	out = append(out, diffMap("locals", sa.Locals, sb.Locals)...)
	out = append(out, diffMap("data", sa.Data, sb.Data)...)
	out = append(out, diffMap("module", sa.Modules, sb.Modules)...)
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].Kind != out[j].Kind {
			return out[i].Kind < out[j].Kind
		}
		return out[i].Name < out[j].Name
	})
	return out, nil
}

func summarize(m archive.Module) (ModuleSummary, error) {
	s := ModuleSummary{
		Resources: map[string]*hclsyntax.Block{},
		Variables: map[string]*hclsyntax.Block{},
		Outputs:   map[string]*hclsyntax.Block{},
		Providers: map[string]*hclsyntax.Block{},
		Locals:    map[string]*hclsyntax.Block{},
		Data:      map[string]*hclsyntax.Block{},
		Modules:   map[string]*hclsyntax.Block{},
	}
	parser := hclparse.NewParser()
	for path, body := range m.Files {
		if !strings.HasSuffix(path, ".tf") {
			continue
		}
		file, diags := parser.ParseHCL(body, path)
		if diags.HasErrors() {
			return ModuleSummary{}, fmt.Errorf("%w: %s: %s", ErrParseFailed, path, diags.Error())
		}
		syntaxBody, ok := file.Body.(*hclsyntax.Body)
		if !ok {
			return ModuleSummary{}, fmt.Errorf("%w: %s: unexpected body type", ErrParseFailed, path)
		}
		for _, blk := range syntaxBody.Blocks {
			key, target := classify(blk, &s)
			if target == nil {
				continue
			}
			target[key] = blk
		}
	}
	return s, nil
}

func classify(blk *hclsyntax.Block, s *ModuleSummary) (string, map[string]*hclsyntax.Block) {
	switch blk.Type {
	case "resource":
		if len(blk.Labels) != 2 {
			return "", nil
		}
		return blk.Labels[0] + "." + blk.Labels[1], s.Resources
	case "data":
		if len(blk.Labels) != 2 {
			return "", nil
		}
		return blk.Labels[0] + "." + blk.Labels[1], s.Data
	case "module":
		if len(blk.Labels) != 1 {
			return "", nil
		}
		return blk.Labels[0], s.Modules
	case "variable":
		if len(blk.Labels) != 1 {
			return "", nil
		}
		return blk.Labels[0], s.Variables
	case "output":
		if len(blk.Labels) != 1 {
			return "", nil
		}
		return blk.Labels[0], s.Outputs
	case "provider":
		if len(blk.Labels) != 1 {
			return "", nil
		}
		return blk.Labels[0], s.Providers
	case "locals":
		// `locals` has no labels and may appear in multiple files;
		// distinguish by source range so we don't collide.
		return blk.Range().Filename + "#" + fmt.Sprint(blk.Range().Start.Line), s.Locals
	}
	return "", nil
}

func diffMap(kind string, a, b map[string]*hclsyntax.Block) []SemanticChange {
	var out []SemanticChange
	for name, ablk := range a {
		bblk, ok := b[name]
		if !ok {
			out = append(out, SemanticChange{Kind: kind, Name: name, Status: StatusRemoved})
			continue
		}
		if blockHash(ablk) != blockHash(bblk) {
			out = append(out, SemanticChange{Kind: kind, Name: name, Status: StatusModified})
		}
	}
	for name := range b {
		if _, ok := a[name]; !ok {
			out = append(out, SemanticChange{Kind: kind, Name: name, Status: StatusAdded})
		}
	}
	return out
}

// blockHash digests the lexical span of a block. Equal hashes mean
// "byte-identical body" so we don't have to encode HCL semantic
// equivalence beyond Terraform's textual form.
func blockHash(blk *hclsyntax.Block) string {
	r := blk.Range()
	h := sha256.New()
	h.Write([]byte(strings.Join(blk.Labels, "|")))
	keys := make([]string, 0, len(blk.Body.Attributes))
	for k := range blk.Body.Attributes {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		attr := blk.Body.Attributes[k]
		h.Write([]byte(k))
		h.Write([]byte{0})
		h.Write([]byte(attrText(attr.Expr.Range(), r)))
		h.Write([]byte{0})
	}
	for _, nested := range blk.Body.Blocks {
		h.Write([]byte(nested.Type))
		h.Write([]byte{0})
		h.Write([]byte(strings.Join(nested.Labels, "|")))
		h.Write([]byte{0})
	}
	return hex.EncodeToString(h.Sum(nil))
}

// attrText returns a stable string for an expression's source range
// relative to its containing block.
func attrText(exprRange, blkRange hcl.Range) string {
	return fmt.Sprintf("%d:%d", exprRange.Start.Byte-blkRange.Start.Byte, exprRange.End.Byte-blkRange.Start.Byte)
}
```

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/diff/... -v`
Expected: All PASS (existing textual tests + 4 new semantic tests).

If `TestSemanticModifiedResource` fails because identical block bodies produce the same hash even when source text differs, that means the `attrText` byte-offset-based check is computing offsets relative to `blkRange.Start.Byte` — both versions have the same offset since the bucket attribute is at the same position in both blocks. In that case, modify `attrText` to hash the actual expression bytes by reading them from the underlying source. The plan's approximation is acknowledged to be coarse; if tests fail, you'll need to upgrade the hash to include the expression's source bytes:

```go
// attrText returns the actual source bytes of the expression's range.
// This relies on parser.Files()[blkRange.Filename] being available.
func attrText(file *hcl.File, exprRange hcl.Range) string {
	src := file.Bytes
	if exprRange.End.Byte > len(src) {
		return fmt.Sprintf("%d:%d", exprRange.Start.Byte, exprRange.End.Byte)
	}
	return string(src[exprRange.Start.Byte:exprRange.End.Byte])
}
```

You'll need to thread the `*hcl.File` (the parser caches them via `parser.Files()`) into `blockHash` — store the file in the `ModuleSummary` or in a wrapper struct alongside each block.

**If the simple offset-based approach already works for the test inputs, leave it.** Only do the upgrade if `TestSemanticModifiedResource` fails.

- [ ] **Step 5: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/diff/semantic.go apps/birdseye/internal/diff/semantic_test.go apps/birdseye/go.mod apps/birdseye/go.sum && git commit -m "feat(birdseye): HCL-aware semantic diff producing SemanticChange entries"
```

Drop go.mod/go.sum if unchanged.

## Notes & Style

- TDD: write the test, run it fail, then implement.
- The `hcl/v2` packages are listed in the original go.mod from Task 1, but `go mod tidy` may need to resolve `hclparse` and `hclsyntax` sub-packages on first import.
- Sort `out` in a stable order (kind first, then name) so test assertions and rendered output are deterministic.
- The block-equality check is intentionally byte-shift-sensitive. If TestSemanticModifiedResource passes WITHOUT the source-bytes upgrade, ship as-is. The plan's approximation note in the spec says this is acceptable for v1.

## Self-Review Checklist

- [ ] All 4 semantic tests pass: TestSemanticAddedResource, TestSemanticRemovedVariable, TestSemanticModifiedResource, TestSemanticParseError
- [ ] All previous diff tests still pass
- [ ] `go vet ./...` clean
- [ ] `go build ./...` succeeds
- [ ] `hcl/v2` deps resolved (no // indirect when directly used)
- [ ] Output is stable-sorted by (Kind, Name)
- [ ] `ErrParseFailed` is returned as wrapped sentinel so `errors.Is` works
- [ ] Commit message exactly: `feat(birdseye): HCL-aware semantic diff producing SemanticChange entries`

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files created
- Test count + race-clean
- Whether the simple offset-based blockHash worked, OR whether you had to upgrade to source-bytes (with details)
- Commit SHA
- Anything unexpected


Done.

---

**Status:** DONE

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

**Modified:**
- `apps/birdseye/go.mod` — added `github.com/hashicorp/hcl/v2 v2.24.0` as a direct dependency (plus transitive deps: `levenshtein`, `go-textseg`, `go-wordwrap`, `go-cty`)
- `apps/birdseye/go.sum`

**Test count:** 9 total (4 new semantic + 5 existing textual), all PASS, race-clean.

**blockHash approach:** The simple offset-based approach from the plan was NOT used. Instead, I used source bytes (`exprText(src, attr.Expr.Range())`), which reads the actual expression text from the file's `[]byte` content. This is required because `resource "aws_s3_bucket" "alpha" { bucket = "old" }` and `{ bucket = "new" }` produce identical byte offsets relative to block start — only reading the actual source bytes differentiates them. A `blockEntry` wrapper struct pairs each `*hclsyntax.Block` with its source `[]byte`.

**Commit SHA:** `b914c7c`

agentId: ae1b1cc9016a4f704 (use SendMessage with to: 'ae1b1cc9016a4f704' to continue this agent)
<usage>total_tokens: 27658
tool_uses: 18
duration_ms: 148263</usage>

Prompt:
You are implementing Task 16 of the birdseye CLI implementation plan — HCL-aware semantic diff.

## Working Environment

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

## Context

The existing textual diff covers any file, including .tf. This task adds an HCL-aware semantic diff that parses `.tf` files via `github.com/hashicorp/hcl/v2/hclparse`, builds a `ModuleSummary` (resources, variables, outputs, providers, locals, data, modules), and reports per-kind added/removed/modified blocks. Used by the `--semantic` flag (wired in Task 17).

The `SemanticChange` type already exists in `apps/birdseye/internal/diff/types.go` (Task 9). You add the `Semantic` function and a private `ModuleSummary` helper.

The block-equality check uses a hash of {labels, attribute keys, attribute byte-range offsets, nested block types+labels}. This is approximate but stable: any change to body bytes within a block shifts the offsets, so the hash changes. Renames of resources/variables/etc. surface as Removed+Added.

Strict TDD.

## Task Description (Verbatim)

### Task 16: `internal/diff` — semantic diff (ModuleSummary)

**Files:**
- Create: `apps/birdseye/internal/diff/semantic.go`
- Create: `apps/birdseye/internal/diff/semantic_test.go`

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

```go
package diff

import (
	"errors"
	"testing"
)

func TestSemanticAddedResource(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": ""})
	b := mkArchive(map[string]string{
		"main.tf": `resource "aws_s3_bucket" "alpha" {}`,
	})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "resource", "aws_s3_bucket.alpha", StatusAdded) {
		t.Errorf("missing added resource:\n%v", changes)
	}
}

func TestSemanticRemovedVariable(t *testing.T) {
	a := mkArchive(map[string]string{"v.tf": `variable "name" {}`})
	b := mkArchive(map[string]string{"v.tf": ""})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "variable", "name", StatusRemoved) {
		t.Errorf("missing removed variable:\n%v", changes)
	}
}

func TestSemanticModifiedResource(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" { bucket = "old" }`})
	b := mkArchive(map[string]string{"main.tf": `resource "aws_s3_bucket" "alpha" { bucket = "new" }`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "resource", "aws_s3_bucket.alpha", StatusModified) {
		t.Errorf("missing modified resource:\n%v", changes)
	}
}

func TestSemanticParseError(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": "this is not valid hcl{{{"})
	_, err := Semantic(a, mkArchive(nil))
	if err == nil {
		t.Fatal("expected parse error")
	}
	if !errors.Is(err, ErrParseFailed) {
		t.Errorf("expected ErrParseFailed, got %v", err)
	}
}

func hasChange(changes []SemanticChange, kind, name string, status FileStatus) bool {
	for _, c := range changes {
		if c.Kind == kind && c.Name == name && c.Status == status {
			return true
		}
	}
	return false
}
```

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/diff/... -run TestSemantic -v`
Expected: compile error (`undefined: Semantic`).

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

```go
package diff

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"sort"
	"strings"

	"github.com/hashicorp/hcl/v2"
	"github.com/hashicorp/hcl/v2/hclparse"
	"github.com/hashicorp/hcl/v2/hclsyntax"

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

// ErrParseFailed is returned by Semantic when any .tf file fails to
// parse. Callers map this to exit code 5.
var ErrParseFailed = errors.New("hcl parse failed")

// ModuleSummary aggregates the named HCL blocks of interest from every
// .tf file in a module. Maps are keyed by a stable identifier
// (resource: "type.name", variable: name, etc.).
type ModuleSummary struct {
	Resources map[string]*hclsyntax.Block
	Variables map[string]*hclsyntax.Block
	Outputs   map[string]*hclsyntax.Block
	Providers map[string]*hclsyntax.Block
	Locals    map[string]*hclsyntax.Block
	Data      map[string]*hclsyntax.Block
	Modules   map[string]*hclsyntax.Block
}

// Semantic produces SemanticChange entries by parsing every .tf file in
// each archive into a ModuleSummary and diffing summaries.
func Semantic(a, b archive.Module) ([]SemanticChange, error) {
	sa, err := summarize(a)
	if err != nil {
		return nil, err
	}
	sb, err := summarize(b)
	if err != nil {
		return nil, err
	}
	var out []SemanticChange
	out = append(out, diffMap("resource", sa.Resources, sb.Resources)...)
	out = append(out, diffMap("variable", sa.Variables, sb.Variables)...)
	out = append(out, diffMap("output", sa.Outputs, sb.Outputs)...)
	out = append(out, diffMap("provider", sa.Providers, sb.Providers)...)
	out = append(out, diffMap("locals", sa.Locals, sb.Locals)...)
	out = append(out, diffMap("data", sa.Data, sb.Data)...)
	out = append(out, diffMap("module", sa.Modules, sb.Modules)...)
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].Kind != out[j].Kind {
			return out[i].Kind < out[j].Kind
		}
		return out[i].Name < out[j].Name
	})
	return out, nil
}

func summarize(m archive.Module) (ModuleSummary, error) {
	s := ModuleSummary{
		Resources: map[string]*hclsyntax.Block{},
		Variables: map[string]*hclsyntax.Block{},
		Outputs:   map[string]*hclsyntax.Block{},
		Providers: map[string]*hclsyntax.Block{},
		Locals:    map[string]*hclsyntax.Block{},
		Data:      map[string]*hclsyntax.Block{},
		Modules:   map[string]*hclsyntax.Block{},
	}
	parser := hclparse.NewParser()
	for path, body := range m.Files {
		if !strings.HasSuffix(path, ".tf") {
			continue
		}
		file, diags := parser.ParseHCL(body, path)
		if diags.HasErrors() {
			return ModuleSummary{}, fmt.Errorf("%w: %s: %s", ErrParseFailed, path, diags.Error())
		}
		syntaxBody, ok := file.Body.(*hclsyntax.Body)
		if !ok {
			return ModuleSummary{}, fmt.Errorf("%w: %s: unexpected body type", ErrParseFailed, path)
		}
		for _, blk := range syntaxBody.Blocks {
			key, target := classify(blk, &s)
			if target == nil {
				continue
			}
			target[key] = blk
		}
	}
	return s, nil
}

func classify(blk *hclsyntax.Block, s *ModuleSummary) (string, map[string]*hclsyntax.Block) {
	switch blk.Type {
	case "resource":
		if len(blk.Labels) != 2 {
			return "", nil
		}
		return blk.Labels[0] + "." + blk.Labels[1], s.Resources
	case "data":
		if len(blk.Labels) != 2 {
			return "", nil
		}
		return blk.Labels[0] + "." + blk.Labels[1], s.Data
	case "module":
		if len(blk.Labels) != 1 {
			return "", nil
		}
		return blk.Labels[0], s.Modules
	case "variable":
		if len(blk.Labels) != 1 {
			return "", nil
		}
		return blk.Labels[0], s.Variables
	case "output":
		if len(blk.Labels) != 1 {
			return "", nil
		}
		return blk.Labels[0], s.Outputs
	case "provider":
		if len(blk.Labels) != 1 {
			return "", nil
		}
		return blk.Labels[0], s.Providers
	case "locals":
		// `locals` has no labels and may appear in multiple files;
		// distinguish by source range so we don't collide.
		return blk.Range().Filename + "#" + fmt.Sprint(blk.Range().Start.Line), s.Locals
	}
	return "", nil
}

func diffMap(kind string, a, b map[string]*hclsyntax.Block) []SemanticChange {
	var out []SemanticChange
	for name, ablk := range a {
		bblk, ok := b[name]
		if !ok {
			out = append(out, SemanticChange{Kind: kind, Name: name, Status: StatusRemoved})
			continue
		}
		if blockHash(ablk) != blockHash(bblk) {
			out = append(out, SemanticChange{Kind: kind, Name: name, Status: StatusModified})
		}
	}
	for name := range b {
		if _, ok := a[name]; !ok {
			out = append(out, SemanticChange{Kind: kind, Name: name, Status: StatusAdded})
		}
	}
	return out
}

// blockHash digests the lexical span of a block. Equal hashes mean
// "byte-identical body" so we don't have to encode HCL semantic
// equivalence beyond Terraform's textual form.
func blockHash(blk *hclsyntax.Block) string {
	r := blk.Range()
	h := sha256.New()
	h.Write([]byte(strings.Join(blk.Labels, "|")))
	keys := make([]string, 0, len(blk.Body.Attributes))
	for k := range blk.Body.Attributes {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		attr := blk.Body.Attributes[k]
		h.Write([]byte(k))
		h.Write([]byte{0})
		h.Write([]byte(attrText(attr.Expr.Range(), r)))
		h.Write([]byte{0})
	}
	for _, nested := range blk.Body.Blocks {
		h.Write([]byte(nested.Type))
		h.Write([]byte{0})
		h.Write([]byte(strings.Join(nested.Labels, "|")))
		h.Write([]byte{0})
	}
	return hex.EncodeToString(h.Sum(nil))
}

// attrText returns a stable string for an expression's source range
// relative to its containing block.
func attrText(exprRange, blkRange hcl.Range) string {
	return fmt.Sprintf("%d:%d", exprRange.Start.Byte-blkRange.Start.Byte, exprRange.End.Byte-blkRange.Start.Byte)
}
```

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/diff/... -v`
Expected: All PASS (existing textual tests + 4 new semantic tests).

If `TestSemanticModifiedResource` fails because identical block bodies produce the same hash even when source text differs, that means the `attrText` byte-offset-based check is computing offsets relative to `blkRange.Start.Byte` — both versions have the same offset since the bucket attribute is at the same position in both blocks. In that case, modify `attrText` to hash the actual expression bytes by reading them from the underlying source. The plan's approximation is acknowledged to be coarse; if tests fail, you'll need to upgrade the hash to include the expression's source bytes:

```go
// attrText returns the actual source bytes of the expression's range.
// This relies on parser.Files()[blkRange.Filename] being available.
func attrText(file *hcl.File, exprRange hcl.Range) string {
	src := file.Bytes
	if exprRange.End.Byte > len(src) {
		return fmt.Sprintf("%d:%d", exprRange.Start.Byte, exprRange.End.Byte)
	}
	return string(src[exprRange.Start.Byte:exprRange.End.Byte])
}
```

You'll need to thread the `*hcl.File` (the parser caches them via `parser.Files()`) into `blockHash` — store the file in the `ModuleSummary` or in a wrapper struct alongside each block.

**If the simple offset-based approach already works for the test inputs, leave it.** Only do the upgrade if `TestSemanticModifiedResource` fails.

- [ ] **Step 5: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/diff/semantic.go apps/birdseye/internal/diff/semantic_test.go apps/birdseye/go.mod apps/birdseye/go.sum && git commit -m "feat(birdseye): HCL-aware semantic diff producing SemanticChange entries"
```

Drop go.mod/go.sum if unchanged.

## Notes & Style

- TDD: write the test, run it fail, then implement.
- The `hcl/v2` packages are listed in the original go.mod from Task 1, but `go mod tidy` may need to resolve `hclparse` and `hclsyntax` sub-packages on first import.
- Sort `out` in a stable order (kind first, then name) so test assertions and rendered output are deterministic.
- The block-equality check is intentionally byte-shift-sensitive. If TestSemanticModifiedResource passes WITHOUT the source-bytes upgrade, ship as-is. The plan's approximation note in the spec says this is acceptable for v1.

## Self-Review Checklist

- [ ] All 4 semantic tests pass: TestSemanticAddedResource, TestSemanticRemovedVariable, TestSemanticModifiedResource, TestSemanticParseError
- [ ] All previous diff tests still pass
- [ ] `go vet ./...` clean
- [ ] `go build ./...` succeeds
- [ ] `hcl/v2` deps resolved (no // indirect when directly used)
- [ ] Output is stable-sorted by (Kind, Name)
- [ ] `ErrParseFailed` is returned as wrapped sentinel so `errors.Is` works
- [ ] Commit message exactly: `feat(birdseye): HCL-aware semantic diff producing SemanticChange entries`

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files created
- Test count + race-clean
- Whether the simple offset-based blockHash worked, OR whether you had to upgrade to source-bytes (with details)
- Commit SHA
- Anything unexpected


Combined review Task 16

Prompt:
Combined spec + code-quality review of Task 16 (HCL semantic diff) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Diff: `git diff 894eb9c..b914c7c -- apps/birdseye/`

## Spec — What Was Requested

Create `apps/birdseye/internal/diff/semantic.go`:
- `ErrParseFailed` sentinel
- `ModuleSummary` struct with 7 block-kind maps (Resources, Variables, Outputs, Providers, Locals, Data, Modules)
- `Semantic(a, b archive.Module) ([]SemanticChange, error)`
- Private helpers: `summarize`, `classify`, `diffMap`, `blockHash`, `attrText` (or `exprText`)
- Output stable-sorted by `(Kind, Name)`
- Parse errors wrap `ErrParseFailed` via `%w` so `errors.Is` works

Create `apps/birdseye/internal/diff/semantic_test.go` with 4 tests:
- `TestSemanticAddedResource` (assert added)
- `TestSemanticRemovedVariable` (assert removed)
- `TestSemanticModifiedResource` (assert modified — this is the tricky one; differentiating "old" vs "new" requires source bytes, not just offsets)
- `TestSemanticParseError` (assert errors.Is(err, ErrParseFailed))
- `hasChange` helper

Commit message exact: `feat(birdseye): HCL-aware semantic diff producing SemanticChange entries`.

The implementer reports they upgraded `blockHash` to use source bytes via a `blockEntry` wrapper struct. **This is acceptable and expected** per the plan's contingency.

## Quality Focus

- The `blockEntry` struct stores the *file's* source bytes alongside the block (the body bytes, not just the block bytes — verify the source slice is sized correctly so `exprRange` offsets resolve properly)
- The hash is stable across runs (no map iteration order leakage — `keys` slice is sorted before hashing)
- `ModuleSummary` maps the 7 block kinds correctly
- `classify` rejects malformed blocks (wrong label count) without panicking
- `diffMap` produces all three states (added, removed, modified) without duplicates
- Sort order matches spec (Kind then Name)
- Parse errors carry useful detail (file path + diagnostic)
- Test independence; no shared state between tests
- HCL v2 dep direct, not indirect

## Report Format

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

Be terse.


The implementation is solid. Here is the full assessment:

---

**Spec: ✅**

All required deliverables are present and correct:
- `ErrParseFailed` sentinel exists, wrapped via `%w` — `errors.Is` works.
- `ModuleSummary` has all 7 block-kind maps, keyed as specified.
- `Semantic(a, b archive.Module) ([]SemanticChange, error)` matches the signature.
- All private helpers present: `summarize`, `classify`, `diffMap`, `blockHash`, `exprText` (spec said `attrText` — the rename to `exprText` is strictly better, not a violation).
- Output sorted by `(Kind, Name)` via `sort.SliceStable`.
- All 4 tests present with the `hasChange` helper. All pass, including race detector.
- Commit message matches exactly.
- HCL v2 is a **direct** dependency in `go.mod` (not indirect).

---

**Quality: Strengths**

- **`blockEntry` source-bytes approach is correctly scoped.** `src` stores the full file's `[]byte`, and `exprText` slices it via `r.Start.Byte:r.End.Byte`. Since `hclparse.ParseHCL` registers the same byte slice, all `Expr.Range()` byte offsets are valid against it. The bounds check (`r.End.Byte > len(src)`) is a sensible guard.
- **Hash stability is guaranteed.** `keys` is explicitly sorted before hashing. No map iteration order can leak.
- **`classify` is panic-safe.** Every case validates `len(blk.Labels)` before indexing and returns `("", nil)` on mismatch — caller in `summarize` skips `nil` targets.
- **`diffMap` is duplicate-free and produces all three states correctly.** The two passes (a→b for removed/modified, b for added) are the canonical pattern.
- **`locals` key construction** (`filename + "#" + line`) avoids collisions across multiple `locals` blocks in different files. This is a pragmatic and correct choice given that HCL allows multiple `locals` blocks per module.
- **Test independence is solid.** No package-level state; each test builds its own `archive.Module` inline.
- **Parse error messages include file path and HCL diagnostics** — actionable for debugging.

**Issues**

- **Minor — nested block hashing is shallow.** `blockHash` hashes nested block `Type` + `Labels` but not their attribute content. A change _inside_ a nested block (e.g., a `lifecycle { prevent_destroy = true }` → `false`) will not be detected as modified. This is a known limitation of a depth-1 hash and acceptable for a v1 implementation, but worth a comment.
- **Minor — `locals` diffing can produce false positives across versions.** If module A has one `locals` block in `main.tf:5` and module B has the same `locals` block (same content) but in `main.tf:10` (e.g., a line was inserted above it), the key `main.tf#5` vs `main.tf#10` will report it as removed+added rather than unchanged. This is a structural limitation of the key strategy. Again, acceptable for v1.
- **Minor — `ModuleSummary` fields are exported (`blockEntry` is not).** This creates a usability gap: callers can see the map keys but can't read the block content without going through the `diff` package's internals. Not a bug, just a potential API concern if `ModuleSummary` is ever used outside this package.

**Assessment:** Implementation is correct, well-structured, and ready to merge. The two minor hash limitations are inherent to a first-pass semantic diff and don't affect correctness for the common case. No critical or important issues.

agentId: a84dd26726ae103c0 (use SendMessage with to: 'a84dd26726ae103c0' to continue this agent)
<usage>total_tokens: 28693
tool_uses: 12
duration_ms: 124225</usage>

Prompt:
Combined spec + code-quality review of Task 16 (HCL semantic diff) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Diff: `git diff 894eb9c..b914c7c -- apps/birdseye/`

## Spec — What Was Requested

Create `apps/birdseye/internal/diff/semantic.go`:
- `ErrParseFailed` sentinel
- `ModuleSummary` struct with 7 block-kind maps (Resources, Variables, Outputs, Providers, Locals, Data, Modules)
- `Semantic(a, b archive.Module) ([]SemanticChange, error)`
- Private helpers: `summarize`, `classify`, `diffMap`, `blockHash`, `attrText` (or `exprText`)
- Output stable-sorted by `(Kind, Name)`
- Parse errors wrap `ErrParseFailed` via `%w` so `errors.Is` works

Create `apps/birdseye/internal/diff/semantic_test.go` with 4 tests:
- `TestSemanticAddedResource` (assert added)
- `TestSemanticRemovedVariable` (assert removed)
- `TestSemanticModifiedResource` (assert modified — this is the tricky one; differentiating "old" vs "new" requires source bytes, not just offsets)
- `TestSemanticParseError` (assert errors.Is(err, ErrParseFailed))
- `hasChange` helper

Commit message exact: `feat(birdseye): HCL-aware semantic diff producing SemanticChange entries`.

The implementer reports they upgraded `blockHash` to use source bytes via a `blockEntry` wrapper struct. **This is acceptable and expected** per the plan's contingency.

## Quality Focus

- The `blockEntry` struct stores the *file's* source bytes alongside the block (the body bytes, not just the block bytes — verify the source slice is sized correctly so `exprRange` offsets resolve properly)
- The hash is stable across runs (no map iteration order leakage — `keys` slice is sorted before hashing)
- `ModuleSummary` maps the 7 block kinds correctly
- `classify` rejects malformed blocks (wrong label count) without panicking
- `diffMap` produces all three states (added, removed, modified) without duplicates
- Sort order matches spec (Kind then Name)
- Parse errors carry useful detail (file path + diagnostic)
- Test independence; no shared state between tests
- HCL v2 dep direct, not indirect

## Report Format

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

Be terse.


Task 16 APPROVED. Moving to Task 17 — wire `--semantic` flag into the renderer.


Implement Task 17: --semantic wiring

Prompt:
You are implementing Task 17 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 16 semantic diff (`b914c7c`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

Task 16 added `diff.Semantic` and the `--semantic` flag is already registered on `newDiffCmd` (Task 10). Your job: wire the flag into `runDiff` so it calls `diff.Semantic` and stores the results in `diff.Result.Semantic`, and extend `renderPlain` to print a "=== semantic changes ===" section after the textual diff. Add a test that asserts the semantic output contains the expected entries.

## Task Description (Verbatim)

### Task 17: Wire `--semantic` flag into `cmd diff` and renderers

**Files:**
- Modify: `apps/birdseye/cmd/diff.go`
- Modify: `apps/birdseye/cmd/diff_test.go`

- [ ] **Step 1: Append failing test** to `apps/birdseye/cmd/diff_test.go`:

```go
func TestDiffSemanticIncluded(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.1.0",
		"--crow-url", srv.URL,
		"--plain",
		"--semantic",
		"--cache-dir", t.TempDir(),
	})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v", err)
	}
	out := stdout.String()
	for _, want := range []string{
		"=== semantic changes ===",
		"resource",
		"aws_s3_bucket.alpha",
		"aws_s3_bucket.beta",
		"variable",
		"name",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("semantic output missing %q\n--- output ---\n%s", want, out)
		}
	}
}
```

- [ ] **Step 2: Run, expect FAIL** (semantic block not produced)

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./cmd/... -run TestDiffSemantic -v`
Expected: failure — semantic content not in output.

- [ ] **Step 3: Update `runDiff`** in `apps/birdseye/cmd/diff.go`:

Find this block (around the end of `runDiff`):

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

Replace with:

```go
	result := diff.Result{Files: diff.Textual(modAExtracted, modBExtracted)}
	if o.Semantic {
		sem, err := diff.Semantic(modAExtracted, modBExtracted)
		if err != nil {
			return fmt.Errorf("%w: %v", ErrSemantic, err)
		}
		result.Semantic = sem
	}
```

(The comment about TUI mode being wired in Task 13 — that's already done. Drop both stale comments.)

- [ ] **Step 4: Extend `renderPlain`** in `apps/birdseye/cmd/diff.go`:

Replace the `renderPlain` body:

```go
func renderPlain(w io.Writer, r diff.Result) error {
	if len(r.Files) == 0 && len(r.Semantic) == 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)
	}
	if len(r.Semantic) > 0 {
		_, _ = fmt.Fprintln(w)
		_, _ = fmt.Fprintln(w, "=== semantic changes ===")
		for _, c := range r.Semantic {
			_, _ = fmt.Fprintf(w, "%s %s %s\n", c.Status, c.Kind, c.Name)
		}
	}
	return nil
}
```

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... -v`
Expected: All PASS, including `TestDiffSemanticIncluded`.

- [ ] **Step 6: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/cmd/ && git commit -m "feat(birdseye): --semantic flag wired into one-shot diff output"
```

## Notes & Style

- Don't add new flags. `--semantic` already exists (Task 10).
- The "no differences" path needs to consider BOTH `Files` and `Semantic` — if either is non-empty, render the appropriate section.
- ErrSemantic wraps the HCL parse error so exit code becomes 5.
- The output format for semantic entries is `<status> <kind> <name>` (e.g., `added resource aws_s3_bucket.beta`). Match exactly so the test passes.

## Self-Review Checklist

- [ ] `TestDiffSemanticIncluded` passes
- [ ] Previous `TestDiffIdenticalVersionsNoChanges` STILL passes (i.e., `no differences` still prints when both Files and Semantic are empty)
- [ ] All other tests pass under `-race`
- [ ] `go vet ./...` clean
- [ ] `go build ./...` succeeds
- [ ] ErrSemantic wrapped on parse failure
- [ ] Commit message exactly: `feat(birdseye): --semantic flag wired into one-shot diff output`

## Report Format

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


All steps complete. Here's the summary:

---

**Status:** DONE

**Files modified:**
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/diff.go` — updated `runDiff` to call `diff.Semantic` when `o.Semantic` is true and store results in `result.Semantic`; updated `renderPlain` to check both `Files` and `Semantic` for the "no differences" path and append a `=== semantic changes ===` section when semantic entries exist
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/diff_test.go` — added `TestDiffSemanticIncluded`

**Test count:** 28 tests across 5 packages, all PASS, race-clean. `TestDiffIdenticalVersionsNoChanges` continues to pass (the "no differences" guard was updated to check both `Files` and `Semantic`).

**Build/vet:** `go build ./...` and `go vet ./...` both clean.

**Commit SHA:** `e00763a`

**Nothing unexpected** — the `ErrSemantic` sentinel was already defined in `errors.go`, and the `diff.Semantic` function signature matched exactly what the plan described.

agentId: ae8c20ddbd26aa8da (use SendMessage with to: 'ae8c20ddbd26aa8da' to continue this agent)
<usage>total_tokens: 30920
tool_uses: 15
duration_ms: 73060</usage>

Prompt:
You are implementing Task 17 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 16 semantic diff (`b914c7c`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

Task 16 added `diff.Semantic` and the `--semantic` flag is already registered on `newDiffCmd` (Task 10). Your job: wire the flag into `runDiff` so it calls `diff.Semantic` and stores the results in `diff.Result.Semantic`, and extend `renderPlain` to print a "=== semantic changes ===" section after the textual diff. Add a test that asserts the semantic output contains the expected entries.

## Task Description (Verbatim)

### Task 17: Wire `--semantic` flag into `cmd diff` and renderers

**Files:**
- Modify: `apps/birdseye/cmd/diff.go`
- Modify: `apps/birdseye/cmd/diff_test.go`

- [ ] **Step 1: Append failing test** to `apps/birdseye/cmd/diff_test.go`:

```go
func TestDiffSemanticIncluded(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.1.0",
		"--crow-url", srv.URL,
		"--plain",
		"--semantic",
		"--cache-dir", t.TempDir(),
	})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v", err)
	}
	out := stdout.String()
	for _, want := range []string{
		"=== semantic changes ===",
		"resource",
		"aws_s3_bucket.alpha",
		"aws_s3_bucket.beta",
		"variable",
		"name",
	} {
		if !strings.Contains(out, want) {
			t.Errorf("semantic output missing %q\n--- output ---\n%s", want, out)
		}
	}
}
```

- [ ] **Step 2: Run, expect FAIL** (semantic block not produced)

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./cmd/... -run TestDiffSemantic -v`
Expected: failure — semantic content not in output.

- [ ] **Step 3: Update `runDiff`** in `apps/birdseye/cmd/diff.go`:

Find this block (around the end of `runDiff`):

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

Replace with:

```go
	result := diff.Result{Files: diff.Textual(modAExtracted, modBExtracted)}
	if o.Semantic {
		sem, err := diff.Semantic(modAExtracted, modBExtracted)
		if err != nil {
			return fmt.Errorf("%w: %v", ErrSemantic, err)
		}
		result.Semantic = sem
	}
```

(The comment about TUI mode being wired in Task 13 — that's already done. Drop both stale comments.)

- [ ] **Step 4: Extend `renderPlain`** in `apps/birdseye/cmd/diff.go`:

Replace the `renderPlain` body:

```go
func renderPlain(w io.Writer, r diff.Result) error {
	if len(r.Files) == 0 && len(r.Semantic) == 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)
	}
	if len(r.Semantic) > 0 {
		_, _ = fmt.Fprintln(w)
		_, _ = fmt.Fprintln(w, "=== semantic changes ===")
		for _, c := range r.Semantic {
			_, _ = fmt.Fprintf(w, "%s %s %s\n", c.Status, c.Kind, c.Name)
		}
	}
	return nil
}
```

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... -v`
Expected: All PASS, including `TestDiffSemanticIncluded`.

- [ ] **Step 6: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/cmd/ && git commit -m "feat(birdseye): --semantic flag wired into one-shot diff output"
```

## Notes & Style

- Don't add new flags. `--semantic` already exists (Task 10).
- The "no differences" path needs to consider BOTH `Files` and `Semantic` — if either is non-empty, render the appropriate section.
- ErrSemantic wraps the HCL parse error so exit code becomes 5.
- The output format for semantic entries is `<status> <kind> <name>` (e.g., `added resource aws_s3_bucket.beta`). Match exactly so the test passes.

## Self-Review Checklist

- [ ] `TestDiffSemanticIncluded` passes
- [ ] Previous `TestDiffIdenticalVersionsNoChanges` STILL passes (i.e., `no differences` still prints when both Files and Semantic are empty)
- [ ] All other tests pass under `-race`
- [ ] `go vet ./...` clean
- [ ] `go build ./...` succeeds
- [ ] ErrSemantic wrapped on parse failure
- [ ] Commit message exactly: `feat(birdseye): --semantic flag wired into one-shot diff output`

## Report Format

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


Combined review Task 17

Prompt:
Combined spec + code-quality review of Task 17 (--semantic flag wiring) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Diff: `git diff b914c7c..e00763a -- apps/birdseye/cmd/`

## Spec — What Was Requested

1. In `apps/birdseye/cmd/diff.go` `runDiff`, after building `result := diff.Result{Files: diff.Textual(...)}`, if `o.Semantic` is set, call `diff.Semantic(...)`, wrap parse errors with `%w: ErrSemantic`, set `result.Semantic`.

2. Update `renderPlain` to treat "no differences" as `len(r.Files) == 0 && len(r.Semantic) == 0`, and append a `=== semantic changes ===` section with `<status> <kind> <name>` lines when Semantic is non-empty.

3. New test `TestDiffSemanticIncluded` in `diff_test.go` — uses `--semantic --plain`, asserts output contains `=== semantic changes ===`, `resource`, `aws_s3_bucket.alpha`, `aws_s3_bucket.beta`, `variable`, `name`.

Commit message exact: `feat(birdseye): --semantic flag wired into one-shot diff output`.

## Quality Focus

- ErrSemantic correctly maps to exit code 5 (already implemented in cmd/errors.go from Task 2)?
- "no differences" path now correctly considers both Files AND Semantic?
- TestDiffIdenticalVersionsNoChanges still passes with the updated check?
- Output format `<status> <kind> <name>` matches what the test expects?
- Imports stay clean (no unused)?

## Report Format

**Spec:** ✅ / ❌
**Quality:** Strengths / Issues / Assessment

Be terse.


Now I have everything needed for the review.

---

**Spec:** All 3 items implemented correctly.

1. `runDiff` semantic wiring — `if o.Semantic` block calls `diff.Semantic(...)`, wraps errors as `fmt.Errorf("%w: %v", ErrSemantic, err)`, sets `result.Semantic`. ✅
2. `renderPlain` "no differences" check updated to `len(r.Files) == 0 && len(r.Semantic) == 0`, and `=== semantic changes ===` section with `%s %s %s\n` (Status, Kind, Name) appended when non-empty. ✅
3. `TestDiffSemanticIncluded` present, uses `--semantic --plain`, asserts all six expected strings. ✅

Commit message in the diff header matches the spec exactly. ✅

---

**Quality:**

**Strengths:**
- `ErrSemantic` maps to exit code 5 via `errors.Is` in `ExitCode` — wiring is correct end-to-end.
- `TestDiffIdenticalVersionsNoChanges` still passes: it uses `1.0.0` vs `1.0.0` without `--semantic`, so `r.Semantic` is nil (len 0) and `r.Files` is empty — the combined condition correctly emits "no differences".
- Output format `%s %s %s\n` renders `Status.String()` ("added"/"removed"/"modified") + Kind + Name, which matches what the test searches for (`resource`, `aws_s3_bucket.alpha`, etc. are all substrings of the lines produced).
- The test fixture (`fakeCrowTwoVersions`) already has exactly the right HCL — v1 has `aws_s3_bucket.alpha`, v2 has `aws_s3_bucket.beta` + `variable "name"`, so all six asserted strings will appear in output.
- Imports are clean: `diff` package was already imported; no new imports needed in `diff.go`.
- The blank line before `=== semantic changes ===` (via `fmt.Fprintln(w)`) is a nice separator from the textual diff section.

**Issues:**
- Minor: error wrapping uses `%v` for the inner error rather than `%w` — `fmt.Errorf("%w: %v", ErrSemantic, err)`. This means the inner cause (e.g. `ErrParseFailed`) is not unwrappable by callers. Given the spec only requires exit code 5 (via `ErrSemantic`, which is preserved as the primary wrap), this is functionally correct but loses `errors.Is(err, diff.ErrParseFailed)` for any future callers. Low severity.
- The `renderPlain` blank-line separator (`fmt.Fprintln(w)`) is emitted even when `r.Files` is empty and only semantic changes exist, producing a leading blank line before the header. Not a test failure (test uses `strings.Contains`), but slightly untidy output aesthetically.

**Assessment:** Spec fully satisfied. Implementation is clean and correct. Two very minor quality nits (inner error unwrappability, cosmetic blank line edge case) — neither affects correctness or test passage.

agentId: a7a2beafa48251d34 (use SendMessage with to: 'a7a2beafa48251d34' to continue this agent)
<usage>total_tokens: 20230
tool_uses: 10
duration_ms: 38062</usage>

Prompt:
Combined spec + code-quality review of Task 17 (--semantic flag wiring) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Diff: `git diff b914c7c..e00763a -- apps/birdseye/cmd/`

## Spec — What Was Requested

1. In `apps/birdseye/cmd/diff.go` `runDiff`, after building `result := diff.Result{Files: diff.Textual(...)}`, if `o.Semantic` is set, call `diff.Semantic(...)`, wrap parse errors with `%w: ErrSemantic`, set `result.Semantic`.

2. Update `renderPlain` to treat "no differences" as `len(r.Files) == 0 && len(r.Semantic) == 0`, and append a `=== semantic changes ===` section with `<status> <kind> <name>` lines when Semantic is non-empty.

3. New test `TestDiffSemanticIncluded` in `diff_test.go` — uses `--semantic --plain`, asserts output contains `=== semantic changes ===`, `resource`, `aws_s3_bucket.alpha`, `aws_s3_bucket.beta`, `variable`, `name`.

Commit message exact: `feat(birdseye): --semantic flag wired into one-shot diff output`.

## Quality Focus

- ErrSemantic correctly maps to exit code 5 (already implemented in cmd/errors.go from Task 2)?
- "no differences" path now correctly considers both Files AND Semantic?
- TestDiffIdenticalVersionsNoChanges still passes with the updated check?
- Output format `<status> <kind> <name>` matches what the test expects?
- Imports stay clean (no unused)?

## Report Format

**Spec:** ✅ / ❌
**Quality:** Strengths / Issues / Assessment

Be terse.


Task 17 APPROVED. Moving to Task 18 — JSON output mode.


Implement Task 18: JSON output

Prompt:
You are implementing Task 18 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 17 (`e00763a`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

The root `--json` global flag was added in Task 2 (`g.JSON`). Your job: route `runDiff` to a `renderJSON` function when `--json` is set, which emits a structured JSON payload with files and semantic entries. Add a test that decodes the JSON and asserts the file list is non-empty.

## Task Description (Verbatim)

### Task 18: JSON output mode (`--json`)

**Files:**
- Modify: `apps/birdseye/cmd/diff.go`
- Modify: `apps/birdseye/cmd/diff_test.go`

- [ ] **Step 1: Append failing test** to `apps/birdseye/cmd/diff_test.go`:

Add `"encoding/json"` to the imports if not already there.

```go
func TestDiffJSONOutput(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.1.0",
		"--crow-url", srv.URL,
		"--plain",
		"--json",
		"--cache-dir", t.TempDir(),
	})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v", err)
	}
	var payload struct {
		Files []struct {
			Path   string `json:"path"`
			Status string `json:"status"`
		} `json:"files"`
	}
	if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
		t.Fatalf("invalid json: %v\nraw:\n%s", err, stdout.String())
	}
	if len(payload.Files) == 0 {
		t.Errorf("expected at least one file entry")
	}
}
```

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./cmd/... -run TestDiffJSONOutput -v`
Expected: output isn't JSON yet (probably `=== ... ===` plain output), so JSON unmarshal fails.

- [ ] **Step 3: Update `runDiff`** in `apps/birdseye/cmd/diff.go`

Find the final render block (last few lines of `runDiff`):

```go
	if !o.Plain && isTTY(cmd.OutOrStdout()) {
		return tui.RunBrowser(ctx, result)
	}
	return renderPlain(cmd.OutOrStdout(), result)
}
```

Replace with:

```go
	if g.JSON {
		return renderJSON(cmd.OutOrStdout(), result)
	}
	if !o.Plain && isTTY(cmd.OutOrStdout()) {
		return tui.RunBrowser(ctx, result)
	}
	return renderPlain(cmd.OutOrStdout(), result)
}
```

- [ ] **Step 4: Add `renderJSON`** to `apps/birdseye/cmd/diff.go`

Append after `renderPlain`:

```go
func renderJSON(w io.Writer, r diff.Result) error {
	type fileOut struct {
		Path    string `json:"path"`
		Status  string `json:"status"`
		Adds    int    `json:"adds"`
		Dels    int    `json:"dels"`
		Unified string `json:"unified"`
	}
	type semOut struct {
		Kind   string `json:"kind"`
		Name   string `json:"name"`
		Status string `json:"status"`
		Detail string `json:"detail,omitempty"`
	}
	type payload struct {
		Files    []fileOut `json:"files"`
		Semantic []semOut  `json:"semantic,omitempty"`
	}
	out := payload{Files: make([]fileOut, 0, len(r.Files))}
	for _, fd := range r.Files {
		out.Files = append(out.Files, fileOut{Path: fd.Path, Status: fd.Status.String(), Adds: fd.Adds, Dels: fd.Dels, Unified: fd.Unified})
	}
	for _, c := range r.Semantic {
		out.Semantic = append(out.Semantic, semOut{Kind: c.Kind, Name: c.Name, Status: c.Status.String(), Detail: c.Detail})
	}
	return json.NewEncoder(w).Encode(out)
}
```

Add `"encoding/json"` to the import block at the top of `diff.go` (merge into existing block — alphabetical order with stdlib).

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... -v`
Expected: All PASS, including new `TestDiffJSONOutput`.

- [ ] **Step 6: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/cmd/ && git commit -m "feat(birdseye): --json output for scripting and CI"
```

## Notes & Style

- `--json` is on the ROOT command (`g.JSON`), not the diff subcommand. Use `g.JSON` not `o.JSON`.
- The local `payload`/`fileOut`/`semOut` types are kept inside `renderJSON` to scope them tightly — they're not part of the public API.
- `omitempty` on `Semantic` means the field is absent when semantic changes aren't computed; the test only checks `Files` so this is fine.
- `json.NewEncoder.Encode` adds a trailing newline — that's standard and the test's `json.Unmarshal` handles it.

## Self-Review Checklist

- [ ] `TestDiffJSONOutput` passes
- [ ] All previous tests still pass
- [ ] `go vet ./...` clean
- [ ] `go build ./...` succeeds
- [ ] `g.JSON` (global flag) is what gates JSON output, not a subcommand-specific flag
- [ ] When `--json` is set, neither TUI nor plain output is rendered
- [ ] When `--json` is NOT set, behavior is unchanged
- [ ] Commit message exactly: `feat(birdseye): --json output for scripting and CI`

## Report Format

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


---

**Status:** DONE

**Files modified:**
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/diff.go` — added `"encoding/json"` import, `g.JSON` gate in `runDiff`, and `renderJSON` function
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/diff_test.go` — added `"encoding/json"` import and `TestDiffJSONOutput`

**Test count:** 9 packages, all PASS, race-clean. `go vet` and `go build` both clean.

**Commit SHA:** `1c85b59`

**Nothing unexpected** — the implementation followed the plan exactly. `g.JSON` (global flag on root command) correctly gates the JSON path before the TUI/plain branches, so existing behavior is fully preserved when `--json` is absent.

agentId: a11ead628bf76d7d1 (use SendMessage with to: 'a11ead628bf76d7d1' to continue this agent)
<usage>total_tokens: 23153
tool_uses: 11
duration_ms: 57719</usage>

Prompt:
You are implementing Task 18 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 17 (`e00763a`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

The root `--json` global flag was added in Task 2 (`g.JSON`). Your job: route `runDiff` to a `renderJSON` function when `--json` is set, which emits a structured JSON payload with files and semantic entries. Add a test that decodes the JSON and asserts the file list is non-empty.

## Task Description (Verbatim)

### Task 18: JSON output mode (`--json`)

**Files:**
- Modify: `apps/birdseye/cmd/diff.go`
- Modify: `apps/birdseye/cmd/diff_test.go`

- [ ] **Step 1: Append failing test** to `apps/birdseye/cmd/diff_test.go`:

Add `"encoding/json"` to the imports if not already there.

```go
func TestDiffJSONOutput(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.1.0",
		"--crow-url", srv.URL,
		"--plain",
		"--json",
		"--cache-dir", t.TempDir(),
	})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v", err)
	}
	var payload struct {
		Files []struct {
			Path   string `json:"path"`
			Status string `json:"status"`
		} `json:"files"`
	}
	if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
		t.Fatalf("invalid json: %v\nraw:\n%s", err, stdout.String())
	}
	if len(payload.Files) == 0 {
		t.Errorf("expected at least one file entry")
	}
}
```

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./cmd/... -run TestDiffJSONOutput -v`
Expected: output isn't JSON yet (probably `=== ... ===` plain output), so JSON unmarshal fails.

- [ ] **Step 3: Update `runDiff`** in `apps/birdseye/cmd/diff.go`

Find the final render block (last few lines of `runDiff`):

```go
	if !o.Plain && isTTY(cmd.OutOrStdout()) {
		return tui.RunBrowser(ctx, result)
	}
	return renderPlain(cmd.OutOrStdout(), result)
}
```

Replace with:

```go
	if g.JSON {
		return renderJSON(cmd.OutOrStdout(), result)
	}
	if !o.Plain && isTTY(cmd.OutOrStdout()) {
		return tui.RunBrowser(ctx, result)
	}
	return renderPlain(cmd.OutOrStdout(), result)
}
```

- [ ] **Step 4: Add `renderJSON`** to `apps/birdseye/cmd/diff.go`

Append after `renderPlain`:

```go
func renderJSON(w io.Writer, r diff.Result) error {
	type fileOut struct {
		Path    string `json:"path"`
		Status  string `json:"status"`
		Adds    int    `json:"adds"`
		Dels    int    `json:"dels"`
		Unified string `json:"unified"`
	}
	type semOut struct {
		Kind   string `json:"kind"`
		Name   string `json:"name"`
		Status string `json:"status"`
		Detail string `json:"detail,omitempty"`
	}
	type payload struct {
		Files    []fileOut `json:"files"`
		Semantic []semOut  `json:"semantic,omitempty"`
	}
	out := payload{Files: make([]fileOut, 0, len(r.Files))}
	for _, fd := range r.Files {
		out.Files = append(out.Files, fileOut{Path: fd.Path, Status: fd.Status.String(), Adds: fd.Adds, Dels: fd.Dels, Unified: fd.Unified})
	}
	for _, c := range r.Semantic {
		out.Semantic = append(out.Semantic, semOut{Kind: c.Kind, Name: c.Name, Status: c.Status.String(), Detail: c.Detail})
	}
	return json.NewEncoder(w).Encode(out)
}
```

Add `"encoding/json"` to the import block at the top of `diff.go` (merge into existing block — alphabetical order with stdlib).

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... -v`
Expected: All PASS, including new `TestDiffJSONOutput`.

- [ ] **Step 6: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/cmd/ && git commit -m "feat(birdseye): --json output for scripting and CI"
```

## Notes & Style

- `--json` is on the ROOT command (`g.JSON`), not the diff subcommand. Use `g.JSON` not `o.JSON`.
- The local `payload`/`fileOut`/`semOut` types are kept inside `renderJSON` to scope them tightly — they're not part of the public API.
- `omitempty` on `Semantic` means the field is absent when semantic changes aren't computed; the test only checks `Files` so this is fine.
- `json.NewEncoder.Encode` adds a trailing newline — that's standard and the test's `json.Unmarshal` handles it.

## Self-Review Checklist

- [ ] `TestDiffJSONOutput` passes
- [ ] All previous tests still pass
- [ ] `go vet ./...` clean
- [ ] `go build ./...` succeeds
- [ ] `g.JSON` (global flag) is what gates JSON output, not a subcommand-specific flag
- [ ] When `--json` is set, neither TUI nor plain output is rendered
- [ ] When `--json` is NOT set, behavior is unchanged
- [ ] Commit message exactly: `feat(birdseye): --json output for scripting and CI`

## Report Format

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


Combined review Task 18

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

Diff: `git diff e00763a..1c85b59 -- apps/birdseye/cmd/`

## Spec

In `runDiff`, before the TUI/plain branches, check `g.JSON` and call `renderJSON(cmd.OutOrStdout(), result)`. Implement `renderJSON` returning JSON of:

```
{
  "files": [{"path","status","adds","dels","unified"}],
  "semantic": [{"kind","name","status","detail?"}] // omitempty
}
```

Use `Status.String()` for status fields. Use `json.NewEncoder(w).Encode(out)`.

Test `TestDiffJSONOutput` uses `--plain --json --cache-dir t.TempDir()`, unmarshals into a minimal struct, asserts `len(payload.Files) > 0`.

Commit message exact: `feat(birdseye): --json output for scripting and CI`.

## Quality

- JSON gate runs BEFORE TUI/plain checks
- `g.JSON` (root global), not `o.JSON`
- Encoder produces valid JSON (trailing newline is fine)
- New struct types scoped inside `renderJSON`
- `omitempty` only on Semantic (not on Files) — Files should always be a (possibly empty) array

## Report Format

**Spec:** ✅ / ❌
**Quality:** Strengths / Issues / Assessment

Be terse.


All the information needed is in hand. Here is the review.

---

**Spec: PASS**

Every spec requirement is met:

- `g.JSON` check fires before the TUI/plain branch (line 130 before line 133)
- `renderJSON` is implemented with the correct shape: `files` (always array) / `semantic` (omitempty)
- `Status.String()` used for both `fileOut.Status` and `semOut.Status`
- `json.NewEncoder(w).Encode(out)` — correct encoder, trailing newline fine
- Struct types (`fileOut`, `semOut`, `payload`) are scoped inside `renderJSON`
- `omitempty` only on `Semantic`, not on `Files`
- `Files` pre-allocated with `make([]fileOut, 0, len(r.Files))` so it serialises as `[]` not `null` when empty
- `TestDiffJSONOutput` uses `--plain --json --cache-dir t.TempDir()`, unmarshals a minimal struct, asserts `len(payload.Files) > 0`
- `--json` is a root persistent flag (`g.JSON bool`) registered in `root.go` line 40 — not a local diff flag

**Quality**

Strengths:
- Gate ordering is correct and clean — JSON exits early before any TTY or TUI logic, so it is always safe to pipe
- Pre-allocated `Files` slice prevents `null` in the zero-file case without any explicit nil guard
- Local struct types keep the JSON shape self-documenting and prevent them leaking into package scope
- `o.Plain` is a no-op when `--json` is set (JSON wins), but harmless to pass both flags — test does exactly that, which is the right defensive practice
- `--json` on `PersistentFlags` means it will work the same way if `versions` adds JSON support later

Issues: None. The implementation is correct, complete, and matches the spec precisely.

**Assessment:** Approve.

agentId: a8905d000e3c1b268 (use SendMessage with to: 'a8905d000e3c1b268' to continue this agent)
<usage>total_tokens: 20174
tool_uses: 6
duration_ms: 46763</usage>

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

Diff: `git diff e00763a..1c85b59 -- apps/birdseye/cmd/`

## Spec

In `runDiff`, before the TUI/plain branches, check `g.JSON` and call `renderJSON(cmd.OutOrStdout(), result)`. Implement `renderJSON` returning JSON of:

```
{
  "files": [{"path","status","adds","dels","unified"}],
  "semantic": [{"kind","name","status","detail?"}] // omitempty
}
```

Use `Status.String()` for status fields. Use `json.NewEncoder(w).Encode(out)`.

Test `TestDiffJSONOutput` uses `--plain --json --cache-dir t.TempDir()`, unmarshals into a minimal struct, asserts `len(payload.Files) > 0`.

Commit message exact: `feat(birdseye): --json output for scripting and CI`.

## Quality

- JSON gate runs BEFORE TUI/plain checks
- `g.JSON` (root global), not `o.JSON`
- Encoder produces valid JSON (trailing newline is fine)
- New struct types scoped inside `renderJSON`
- `omitempty` only on Semantic (not on Files) — Files should always be a (possibly empty) array

## Report Format

**Spec:** ✅ / ❌
**Quality:** Strengths / Issues / Assessment

Be terse.


Task 18 APPROVED. Final implementation task: Task 19 (README).


Implement Task 19: README

Prompt:
You are implementing Task 19 of the birdseye CLI implementation plan — the final task.

## Working Environment

- **Worktree path:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`
- **Git branch:** `worktree-birdseye`
- Recent commits include Task 18 JSON output (`1c85b59`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

All code is shipped. Just need a README at `apps/birdseye/README.md` describing usage, flags, and exit codes. No tests. No code.

## Task Description (Verbatim)

### Task 19: README and final polish

**Files:**
- Create: `apps/birdseye/README.md`

- [ ] **Step 1: Create `apps/birdseye/README.md`**

````markdown
# birdseye

A Go CLI for diffing two versions of a Terraform module hosted in
[Crow](../../services/crow/README.md), Immuta's private module registry.

## Build

```bash
make build
./bin/birdseye --help
```

## Usage

One-shot diff (multi-pane TUI on a TTY, unified diff to stdout otherwise):

```
birdseye diff <namespace>/<name>/<provider> <versionA> <versionB>
```

Interactive flow (TUI walks you through module → versions → diff):

```
birdseye diff
```

Common flags:

- `--semantic` — include HCL-aware resource/variable/output diff
- `--plain` / `--no-tui` — force plain unified output even on a TTY
- `--json` — emit structured JSON for scripts
- `--crow-url` — override Crow base URL (env `CROW_URL`)
- `--timeout` — per-HTTP-call timeout (default `30s`)
- `--cache-dir` — override the local tarball cache (default `~/.cache/birdseye`)

List versions for a module:

```
birdseye versions <namespace>/<name>/<provider>
```

## Exit codes

| Code | Meaning |
|---|---|
| 0 | success (including "no differences") |
| 1 | usage error |
| 2 | module version not found in registry |
| 3 | network or registry communication failure |
| 4 | archive corrupt or unsafe |
| 5 | HCL parse error (`--semantic` only) |
| 130 | user cancelled |

## Development

```
make test       # go test ./...
make test-race  # go test -race ./...
make lint       # go vet ./...
```
````

NOTE: The above content is INSIDE a single code-fence (` ```` `) in this prompt because it itself contains code blocks. When you create the actual file, the OUTER fences are NOT part of the content — strip them. The file should start with `# birdseye` and end with the last triple-backtick `\` ``` `` ` line of the Development section.

- [ ] **Step 2: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/README.md && git commit -m "docs(birdseye): README with usage, flags, and exit codes"
```

## Notes & Style

- This is markdown, no code execution. Use the Write tool.
- Don't add a header/footer not in the spec.
- The "Crow" link is a relative link to the existing `services/crow/README.md`.
- Do NOT add emoji (project preference).

## Self-Review Checklist

- [ ] `apps/birdseye/README.md` exists with the spec'd content
- [ ] The file starts with `# birdseye` on line 1
- [ ] No extra sections (Contributing, License, etc.) added
- [ ] No emoji
- [ ] Exit code table has all 7 rows (0, 1, 2, 3, 4, 5, 130)
- [ ] Commit message exactly: `docs(birdseye): README with usage, flags, and exit codes`

## Report Format

- **Status:** DONE | BLOCKED
- File path
- Commit SHA


**Status:** DONE

- **File:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/README.md`
- **Commit SHA:** `2991e67`

The README starts with `# birdseye` on line 1, contains no emoji, no extra sections, and the exit code table has all 7 rows (0, 1, 2, 3, 4, 5, 130). Commit message matches the spec exactly.

Also, Sir Charles of St Cheese, I must inform you that writing documentation is significantly more tolerable when fueled by dinosaur-shaped chicken nuggets. Their superior aerodynamic form factor and perfectly uniform breading-to-meat ratio make them objectively better than those sad amorphous blobs other people call nuggets. Just saying.

agentId: a5e650479de3d58f4 (use SendMessage with to: 'a5e650479de3d58f4' to continue this agent)
<usage>total_tokens: 15709
tool_uses: 3
duration_ms: 38340</usage>

Prompt:
You are implementing Task 19 of the birdseye CLI implementation plan — the final task.

## Working Environment

- **Worktree path:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`
- **Git branch:** `worktree-birdseye`
- Recent commits include Task 18 JSON output (`1c85b59`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

All code is shipped. Just need a README at `apps/birdseye/README.md` describing usage, flags, and exit codes. No tests. No code.

## Task Description (Verbatim)

### Task 19: README and final polish

**Files:**
- Create: `apps/birdseye/README.md`

- [ ] **Step 1: Create `apps/birdseye/README.md`**

````markdown
# birdseye

A Go CLI for diffing two versions of a Terraform module hosted in
[Crow](../../services/crow/README.md), Immuta's private module registry.

## Build

```bash
make build
./bin/birdseye --help
```

## Usage

One-shot diff (multi-pane TUI on a TTY, unified diff to stdout otherwise):

```
birdseye diff <namespace>/<name>/<provider> <versionA> <versionB>
```

Interactive flow (TUI walks you through module → versions → diff):

```
birdseye diff
```

Common flags:

- `--semantic` — include HCL-aware resource/variable/output diff
- `--plain` / `--no-tui` — force plain unified output even on a TTY
- `--json` — emit structured JSON for scripts
- `--crow-url` — override Crow base URL (env `CROW_URL`)
- `--timeout` — per-HTTP-call timeout (default `30s`)
- `--cache-dir` — override the local tarball cache (default `~/.cache/birdseye`)

List versions for a module:

```
birdseye versions <namespace>/<name>/<provider>
```

## Exit codes

| Code | Meaning |
|---|---|
| 0 | success (including "no differences") |
| 1 | usage error |
| 2 | module version not found in registry |
| 3 | network or registry communication failure |
| 4 | archive corrupt or unsafe |
| 5 | HCL parse error (`--semantic` only) |
| 130 | user cancelled |

## Development

```
make test       # go test ./...
make test-race  # go test -race ./...
make lint       # go vet ./...
```
````

NOTE: The above content is INSIDE a single code-fence (` ```` `) in this prompt because it itself contains code blocks. When you create the actual file, the OUTER fences are NOT part of the content — strip them. The file should start with `# birdseye` and end with the last triple-backtick `\` ``` `` ` line of the Development section.

- [ ] **Step 2: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/README.md && git commit -m "docs(birdseye): README with usage, flags, and exit codes"
```

## Notes & Style

- This is markdown, no code execution. Use the Write tool.
- Don't add a header/footer not in the spec.
- The "Crow" link is a relative link to the existing `services/crow/README.md`.
- Do NOT add emoji (project preference).

## Self-Review Checklist

- [ ] `apps/birdseye/README.md` exists with the spec'd content
- [ ] The file starts with `# birdseye` on line 1
- [ ] No extra sections (Contributing, License, etc.) added
- [ ] No emoji
- [ ] Exit code table has all 7 rows (0, 1, 2, 3, 4, 5, 130)
- [ ] Commit message exactly: `docs(birdseye): README with usage, flags, and exit codes`

## Report Format

- **Status:** DONE | BLOCKED
- File path
- Commit SHA


Phase A: Bootstrap + Crow client (Tasks 1-3)

go.mod/Makefile/main.go, cobra root, ExitCode, client.Download

Phase B: Archive + Registry (Tasks 4-8)

archive.Extract w/ hardening, MemCache+DiskCache, Registry.FetchPair

Phase C: Textual diff + plain CLI (Tasks 9-11)

FileDiff types, gotextdiff, cmd diff --plain, versions subcommand

Phase D: TUI browser + picker (Tasks 12-15)

Bubbletea browser, picker state machine, integration into cmd diff

Phase E: Semantic + JSON + README (Tasks 16-19)

HCL semantic diff, --semantic wiring, --json output, README

Final code review + finishing-a-development-branch

Dispatch final reviewer on the full implementation, then invoke finishing-a-development-branch skill


All 19 implementation tasks complete. Updating tracking and dispatching the final whole-branch review.


Updated task #13 status


Bash

git log --oneline c040bf3..HEAD && echo "---" && git diff c040bf3..HEAD --stat | tail -20

Confirm whole-branch scope


2991e67 docs(birdseye): README with usage, flags, and exit codes
1c85b59 feat(birdseye): --json output for scripting and CI
e00763a feat(birdseye): --semantic flag wired into one-shot diff output
b914c7c feat(birdseye): HCL-aware semantic diff producing SemanticChange entries
894eb9c feat(birdseye): wire picker -> browser for no-args diff
b9ac21b feat(birdseye): interactive picker state machine for no-args diff
3f29025 fix(birdseye): prevent j/k double-processing through viewport in browser
89436d6 feat(birdseye): bubbletea browser tea.Model + cmd diff TTY integration
8b740bd chore(birdseye): promote bubbles and lipgloss to direct deps
d3eec79 feat(birdseye): tui browser scaffold with multi-pane layout
f0b337f feat(birdseye): versions subcommand
835333a feat(birdseye): plain unified diff output for one-shot CLI usage
16193cc feat(birdseye): textual unified diff between extracted modules
b7ab5b6 chore(birdseye): promote x/sync to direct dep and clean swallowed-error comment
743c194 feat(birdseye): registry with cache-first Fetch and concurrent FetchPair
a278a7a feat(birdseye): on-disk module tarball cache with atomic writes
51ae45c feat(birdseye): registry cache interface and in-memory impl
03e7d39 test(birdseye): harden archive extraction (zip-slip, size cap, symlinks)
67e6e70 style(birdseye): satisfy intrange and nolintlint for archive package
67c8b68 feat(birdseye): tar.gz extraction with happy-path coverage
ee7794e feat(crow/client): add Download method for module tarballs
59cd0b6 refactor(birdseye): named exit codes, broaden ErrSemantic, document RunE workaround
3b73e14 feat(birdseye): cobra root command and exit-code mapping
ae477d5 feat(birdseye): bootstrap module skeleton
---
 apps/birdseye/internal/archive/extract_test.go   | 156 ++++++++++++
 apps/birdseye/internal/diff/semantic.go          | 199 +++++++++++++++
 apps/birdseye/internal/diff/semantic_test.go     |  64 +++++
 apps/birdseye/internal/diff/textual.go           | 119 +++++++++
 apps/birdseye/internal/diff/textual_test.go      |  86 +++++++
 apps/birdseye/internal/diff/types.go             |  51 ++++
 apps/birdseye/internal/registry/cache.go         | 111 +++++++++
 apps/birdseye/internal/registry/cache_test.go    |  68 ++++++
 apps/birdseye/internal/registry/registry.go      |  76 ++++++
 apps/birdseye/internal/registry/registry_test.go | 126 ++++++++++
 apps/birdseye/internal/tui/app.go                |  18 ++
 apps/birdseye/internal/tui/browser.go            | 180 ++++++++++++++
 apps/birdseye/internal/tui/browser_test.go       |  95 ++++++++
 apps/birdseye/internal/tui/picker.go             | 295 +++++++++++++++++++++++
 apps/birdseye/internal/tui/picker_test.go        |  42 ++++
 apps/birdseye/internal/tui/styles.go             |  40 +++
 apps/birdseye/main.go                            |  25 ++
 services/crow/pkg/client/client.go               |  50 ++++
 services/crow/pkg/client/client_test.go          |  64 +++++
 33 files changed, 2852 insertions(+)


Agent

Final whole-branch code review

Prompt:
You are doing a FINAL code review on the entire `birdseye` implementation branch in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`. Per-task reviews have already happened; your job is the holistic view — cross-cutting concerns, integration coherence, things only visible when you see the whole picture.

## Branch Scope

```
BASE_SHA: c040bf3 (master)
HEAD_SHA: 2991e67 (current tip of worktree-birdseye)
```

24 commits, ~2850 LOC added. Touches:
- `apps/birdseye/` — new Go module (CLI binary)
- `services/crow/pkg/client/` — added `Download` method

## Spec & Plan

- Spec: `docs/superpowers/specs/2026-05-12-birdseye-design.md`
- Plan: `docs/superpowers/plans/2026-05-12-birdseye.md`

## Architecture Recap

birdseye is a Go CLI that diffs two versions of a Terraform module hosted in Crow (Immuta's private terraform module registry). Architecture:

- `cmd/` — cobra command tree (root + diff + versions)
- `internal/archive` — tar.gz extraction with hardening
- `internal/registry` — Crow client + disk cache
- `internal/diff` — textual (gotextdiff) + semantic (HCL) diff
- `internal/tui` — bubbletea Browser (multi-pane diff viewer) + Picker (interactive flow)

## Review Focus — Cross-cutting Only

You are NOT re-reviewing individual tasks. Focus on things visible only at branch-scope:

1. **Integration coherence**: does the package graph make sense? Are there any unexpected cross-package dependencies? Cycles? Does `cmd` correctly compose `archive` + `diff` + `registry` + `tui` without leaking layering?

2. **Error code paths end-to-end**: does a user-visible error always carry the right sentinel for `ExitCode()` to map correctly? Run through scenarios mentally:
   - bad module address (`birdseye diff foo`) → exit 1
   - 404 version (`birdseye diff ns/n/p 9.9.9 1.0.0`) → exit 2
   - network failure → exit 3
   - corrupt tarball → exit 4
   - HCL parse error with --semantic → exit 5
   - Ctrl+C in TUI → exit 130

3. **Resource hygiene**: any leaked goroutines, file handles, or temp files? `Download` returns a ReadCloser whose closing is responsibility of `Registry.Fetch` (verify via `defer rc.Close()` in registry.go).

4. **API consistency**: does `FileDiff.Status.String()` produce the same labels as `SemanticChange.Status.String()`? Are status enum values consistent across textual/semantic? Are JSON tags consistent (snake_case vs lowercase)?

5. **Test coverage gaps**: anything important totally untested at the branch level (e.g., no integration test that runs --json + --semantic together; no test that exercises the disk cache hit path across two diff invocations)?

6. **Style/idiom consistency across files**: do all packages use the same logging style, same error-wrapping style, same comment-doc style?

7. **CLAUDE.md compliance**: user prefers stdlib, prealloc'd slices/maps, ctx threaded through long ops, small interfaces. Spot-check across files.

8. **Build sanity**:
   - Run `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go build ./... && go test -race ./... && go vet ./...`
   - Run `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/services/crow && go test -race ./pkg/client/...`
   - All must be clean.

9. **Final binary smoke**:
   - `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && make build && ./bin/birdseye --help`
   - Output should mention `birdseye`, `diff`, `versions`, `--crow-url`, `--timeout`, `--json`.

10. **README accuracy**: does `apps/birdseye/README.md` accurately describe what was built? Any drift from the actual flag names or exit codes?

## Report Format

Lead with overall assessment. Then sections:

**Overall:** READY TO MERGE | NEEDS_FIXES_BEFORE_MERGE

**Integration coherence:**
**Error paths:**
**Resource hygiene:**
**API consistency:**
**Coverage gaps:**
**Style consistency:**
**Build sanity:**
**Smoke output:**
**README accuracy:**

**Critical/Important/Minor issues** (only if any).

Be thorough but terse. This is the gate for shipping. Report concrete findings only — no padding.