Base directory for this skill: /Users/steve.briggs/.claude/plugins/cache/claude-plugins-official/superpowers/5.1.0/skills/finishing-a-development-branch

# Finishing a Development Branch

## Overview

Guide completion of development work by presenting clear options and handling chosen workflow.

**Core principle:** Verify tests → Detect environment → Present options → Execute choice → Clean up.

**Announce at start:** "I'm using the finishing-a-development-branch skill to complete this work."

## The Process

### Step 1: Verify Tests

**Before presenting options, verify tests pass:**

```bash
# Run project's test suite
npm test / cargo test / pytest / go test ./...
```

**If tests fail:**
```
Tests failing (<N> failures). Must fix before completing:

[Show failures]

Cannot proceed with merge/PR until tests pass.
```

Stop. Don't proceed to Step 2.

**If tests pass:** Continue to Step 2.

### Step 2: Detect Environment

**Determine workspace state before presenting options:**

```bash
GIT_DIR=$(cd "$(git rev-parse --git-dir)" 2>/dev/null && pwd -P)
GIT_COMMON=$(cd "$(git rev-parse --git-common-dir)" 2>/dev/null && pwd -P)
```

This determines which menu to show and how cleanup works:

| State | Menu | Cleanup |
|-------|------|---------|
| `GIT_DIR == GIT_COMMON` (normal repo) | Standard 4 options | No worktree to clean up |
| `GIT_DIR != GIT_COMMON`, named branch | Standard 4 options | Provenance-based (see Step 6) |
| `GIT_DIR != GIT_COMMON`, detached HEAD | Reduced 3 options (no merge) | No cleanup (externally managed) |

### Step 3: Determine Base Branch

```bash
# Try common base branches
git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null
```

Or ask: "This branch split from main - is that correct?"

### Step 4: Present Options

**Normal repo and named-branch worktree — present exactly these 4 options:**

```
Implementation complete. What would you like to do?

1. Merge back to <base-branch> locally
2. Push and create a Pull Request
3. Keep the branch as-is (I'll handle it later)
4. Discard this work

Which option?
```

**Detached HEAD — present exactly these 3 options:**

```
Implementation complete. You're on a detached HEAD (externally managed workspace).

1. Push as new branch and create a Pull Request
2. Keep as-is (I'll handle it later)
3. Discard this work

Which option?
```

**Don't add explanation** - keep options concise.

### Step 5: Execute Choice

#### Option 1: Merge Locally

```bash
# Get main repo root for CWD safety
MAIN_ROOT=$(git -C "$(git rev-parse --git-common-dir)/.." rev-parse --show-toplevel)
cd "$MAIN_ROOT"

# Merge first — verify success before removing anything
git checkout <base-branch>
git pull
git merge <feature-branch>

# Verify tests on merged result
<test command>

# Only after merge succeeds: cleanup worktree (Step 6), then delete branch
```

Then: Cleanup worktree (Step 6), then delete branch:

```bash
git branch -d <feature-branch>
```

#### Option 2: Push and Create PR

```bash
# Push branch
git push -u origin <feature-branch>

# Create PR
gh pr create --title "<title>" --body "$(cat <<'EOF'
## Summary
<2-3 bullets of what changed>

## Test Plan
- [ ] <verification steps>
EOF
)"
```

**Do NOT clean up worktree** — user needs it alive to iterate on PR feedback.

#### Option 3: Keep As-Is

Report: "Keeping branch <name>. Worktree preserved at <path>."

**Don't cleanup worktree.**

#### Option 4: Discard

**Confirm first:**
```
This will permanently delete:
- Branch <name>
- All commits: <commit-list>
- Worktree at <path>

Type 'discard' to confirm.
```

Wait for exact confirmation.

If confirmed:
```bash
MAIN_ROOT=$(git -C "$(git rev-parse --git-common-dir)/.." rev-parse --show-toplevel)
cd "$MAIN_ROOT"
```

Then: Cleanup worktree (Step 6), then force-delete branch:
```bash
git branch -D <feature-branch>
```

### Step 6: Cleanup Workspace

**Only runs for Options 1 and 4.** Options 2 and 3 always preserve the worktree.

```bash
GIT_DIR=$(cd "$(git rev-parse --git-dir)" 2>/dev/null && pwd -P)
GIT_COMMON=$(cd "$(git rev-parse --git-common-dir)" 2>/dev/null && pwd -P)
WORKTREE_PATH=$(git rev-parse --show-toplevel)
```

**If `GIT_DIR == GIT_COMMON`:** Normal repo, no worktree to clean up. Done.

**If worktree path is under `.worktrees/`, `worktrees/`, or `~/.config/superpowers/worktrees/`:** Superpowers created this worktree — we own cleanup.

```bash
MAIN_ROOT=$(git -C "$(git rev-parse --git-common-dir)/.." rev-parse --show-toplevel)
cd "$MAIN_ROOT"
git worktree remove "$WORKTREE_PATH"
git worktree prune  # Self-healing: clean up any stale registrations
```

**Otherwise:** The host environment (harness) owns this workspace. Do NOT remove it. If your platform provides a workspace-exit tool, use it. Otherwise, leave the workspace in place.

## Quick Reference

| Option | Merge | Push | Keep Worktree | Cleanup Branch |
|--------|-------|------|---------------|----------------|
| 1. Merge locally | yes | - | - | yes |
| 2. Create PR | - | yes | yes | - |
| 3. Keep as-is | - | - | yes | - |
| 4. Discard | - | - | - | yes (force) |

## Common Mistakes

**Skipping test verification**
- **Problem:** Merge broken code, create failing PR
- **Fix:** Always verify tests before offering options

**Open-ended questions**
- **Problem:** "What should I do next?" is ambiguous
- **Fix:** Present exactly 4 structured options (or 3 for detached HEAD)

**Cleaning up worktree for Option 2**
- **Problem:** Remove worktree user needs for PR iteration
- **Fix:** Only cleanup for Options 1 and 4

**Deleting branch before removing worktree**
- **Problem:** `git branch -d` fails because worktree still references the branch
- **Fix:** Merge first, remove worktree, then delete branch

**Running git worktree remove from inside the worktree**
- **Problem:** Command fails silently when CWD is inside the worktree being removed
- **Fix:** Always `cd` to main repo root before `git worktree remove`

**Cleaning up harness-owned worktrees**
- **Problem:** Removing a worktree the harness created causes phantom state
- **Fix:** Only clean up worktrees under `.worktrees/`, `worktrees/`, or `~/.config/superpowers/worktrees/`

**No confirmation for discard**
- **Problem:** Accidentally delete work
- **Fix:** Require typed "discard" confirmation

## Red Flags

**Never:**
- Proceed with failing tests
- Merge without verifying tests on result
- Delete work without confirmation
- Force-push without explicit request
- Remove a worktree before confirming merge success
- Clean up worktrees you didn't create (provenance check)
- Run `git worktree remove` from inside the worktree

**Always:**
- Verify tests before offering options
- Detect environment before presenting menu
- Present exactly 4 options (or 3 for detached HEAD)
- Get typed confirmation for Option 4
- Clean up worktree for Options 1 & 4 only
- `cd` to main repo root before worktree removal
- Run `git worktree prune` after removal


Build birdseye CLI for Terraform module diffing


I'm using the finishing-a-development-branch skill to complete this work.

**Step 1: Verify tests.**


Bash

cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... 2>&1 | tail -10 && echo "---" && cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/services/crow && go test -race ./pkg/client/... 2>&1 | tail -3

Run all tests race-clean


?   	github.com/immuta/operations/apps/birdseye	[no test files]
ok  	github.com/immuta/operations/apps/birdseye/cmd	(cached)
ok  	github.com/immuta/operations/apps/birdseye/internal/archive	(cached)
ok  	github.com/immuta/operations/apps/birdseye/internal/diff	(cached)
ok  	github.com/immuta/operations/apps/birdseye/internal/known	(cached)
ok  	github.com/immuta/operations/apps/birdseye/internal/registry	(cached)
ok  	github.com/immuta/operations/apps/birdseye/internal/tui	(cached)
---
ok  	github.com/immuta/operations/services/crow/pkg/client	(cached)


All green, including the new `internal/known` package. **Step 2: detect environment.**


GIT_DIR=$(cd "$(git rev-parse --git-dir)" 2>/dev/null && pwd -P)
GIT_COMMON=$(cd "$(git rev-parse --git-common-dir)" 2>/dev/null && pwd -P)
BRANCH=$(git branch --show-current)
WORKTREE=$(git rev-parse --show-toplevel)
echo "GIT_DIR=$GIT_DIR"
echo "GIT_COMMON=$GIT_COMMON"
echo "BRANCH=$BRANCH"
echo "WORKTREE=$WORKTREE"
echo "BASE=$(git merge-base HEAD master)"
echo "FEATURE_COMMITS_ABOVE_PREV=$(git log --oneline 474d56d..HEAD | wc -l | tr -d ' ')"
git log --oneline 474d56d..HEAD

Detect env + feature scope


GIT_DIR=/Users/steve.briggs/projects/operations/.git/worktrees/birdseye
GIT_COMMON=/Users/steve.briggs/projects/operations/.git
BRANCH=worktree-birdseye
WORKTREE=/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye
BASE=c040bf383d3080ffd21fc32c38a9a793039acca2
FEATURE_COMMITS_ABOVE_PREV=6
9e71395 test(birdseye): isolate cmd/diff tests from real known-modules path
4f53099 feat(birdseye): TUI textinput suggestions and best-effort cache from interactive picker
4d747fc feat(birdseye): shell completion for diff and versions from known-modules cache
51a2c0e feat(birdseye): cache module address after successful CLI diff
171c32a test(birdseye): harden known-modules cache (dedup, sort, atomic, validation)
32e305b feat(birdseye): known-modules autocomplete cache with Path/Load/Add


6 commits in this feature on top of the 25 from the prior birdseye build. All tests passing.


AskUserQuestion

Question: What would you like to do with this work?

Choices:
Push `worktree-birdseye` to origin (6 new commits since last push) and open a PR against master. Worktree preserved for PR iteration.
Push the 6 new feature commits to the same branch. No PR opened. Worktree preserved.
Don't push. Local branch + worktree stay where they are; you'll handle git ops yourself like last time.
Permanently delete the known-modules feature commits and the worktree. Requires typed confirmation.


User has answered your questions: "What would you like to do with this work?"="Push to existing branch (no PR)". You can now continue with the user's answers in mind.

Question: What would you like to do with this work?

Choices:
Push `worktree-birdseye` to origin (6 new commits since last push) and open a PR against master. Worktree preserved for PR iteration.
Push the 6 new feature commits to the same branch. No PR opened. Worktree preserved.
Don't push. Local branch + worktree stay where they are; you'll handle git ops yourself like last time.
Permanently delete the known-modules feature commits and the worktree. Requires typed confirmation.


Bash

Push 6 new commits to origin/worktree-birdseye


remote:
remote: GitHub found 18 vulnerabilities on immuta/operations's default branch (2 high, 13 moderate, 3 low). To find out more, visit:
remote:      https://github.com/immuta/operations/security/dependabot
remote:
To github.com:immuta/operations.git
   474d56d..9e71395  worktree-birdseye -> worktree-birdseye


Pushed. `worktree-birdseye` now at `9e71395` on origin (6 new commits: `32e305b` → `9e71395`). Worktree preserved at `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`. No PR opened, per your call.

---

## Feature summary

**Branch:** `worktree-birdseye` (31 commits ahead of master)
**Pushed:** yes, all the way through `9e71395`

**What shipped this round (6 commits, 564 LOC, 8 files):**

- New `internal/known` package — `Path()`, `Load()`, `Add()` with atomic temp+rename writes, dedup, alphabetical sort, malformed-line skipping, last-writer-wins concurrency documented
- Hidden `--known-modules-file` flag on `diff` and `versions` (test override)
- Post-success `known.Add` from CLI `diff` positional path
- Cobra `ValidArgsFunction` on both `diff` and `versions` (versions reads but never writes — intentional asymmetry)
- Picker `NewPicker(ctx, deps, suggestions, knownPath)` with `bubbles/textinput.SetSuggestions` and `ShowSuggestions=true` when suggestions present
- Fire-and-forget `addKnownCmd` dispatched from picker's `versionsLoadedMsg` success path
- `runInteractive` preloads suggestions via `known.Load` before launching the picker
- Critical fix from final Opus review: 4 pre-existing tests were polluting the host's real `~/Library/Application Support/birdseye/known-modules`; all now inject `--known-modules-file` pointing at `t.TempDir()`

**Spec + plan in-tree:** `docs/superpowers/specs/2026-05-13-birdseye-known-modules-design.md`, `docs/superpowers/plans/2026-05-13-birdseye-known-modules.md` — both ride along on this branch.

That's a wrap, Sir Charles of St Cheese. Bussin' feature, no cap. The completion menu hits different now that it remembers what you've already looked at — kind of like how dinosaur nuggies remember to taste good every time, unlike those amorphous blob nuggies that surprise you with sadness on every bite. Type shit.


afplay ~/.claude/sounds/mario/stop/stomp.wav 2>/dev/null || true


<command-message>obsidian-save</command-message>
<command-name>/obsidian-save</command-name>


Save current conversation context to an Obsidian note under the `Claude/` directory.

## What to save

Capture all of the following that are relevant to the current conversation:

- **Conversation summary**: What the user is trying to accomplish and why
- **Project context**: Relevant architecture, design decisions, or constraints discovered
- **Task state**: What has been completed, what is in progress, what is blocked or pending
- **Key decisions**: Any choices made and the reasoning behind them
- **Open questions**: Unresolved questions or things to revisit
- **Next steps**: Concrete actions that should follow from this session

## File location

Derive the project name from the current working directory (use the repo or app folder name). Save to:

```
Claude/<project-name>/<YYYY-MM-DD>-<short-slug>.md
```

Use today's date and a short 2-4 word slug describing the session topic (e.g. `2026-04-29-ruth-plugin-setup.md`).

If `` is provided, use it as the slug instead of generating one.

## Note format

Use this structure:

```markdown
---
date: <YYYY-MM-DD>
project: <project-name>
tags: [claude-context]
---

# <Session Title>

## Summary
<1-3 sentence overview of what this session was about>

## Context
<Relevant project background, architecture notes, constraints>

## Decisions
<Decisions made and rationale>

## Task State
<What was completed, in progress, or blocked>

## Open Questions
<Anything unresolved>

## Next Steps
<Concrete follow-up actions>
```

## Steps

1. Determine the project name from the working directory.
2. Determine today's date.
3. Draft the note content based on the current conversation.
4. Use `mcp__obsidian__create_vault_file` to write the note to the vault.
5. Confirm to the user where the note was saved.


---
date: 2026-05-13
project: operations
tags: [claude-context, birdseye, autocomplete, cobra-completion, bubbletea, superpowers]
---

# Birdseye — Known-Modules Autocomplete Cache + Bugbot Fixes

## Summary

Continuation of the birdseye CLI work on branch `worktree-birdseye`. This session: (1) addressed two bugbot-flagged issues from the prior session — `--semantic` was silently ignored in interactive mode + `blockHash` ignored nested HCL block content — and (2) shipped a new feature: persisting successful module addresses in an XDG-friendly config file for cobra shell completion AND TUI textinput ghost-text suggestions. 31 total commits ahead of master at push time (`9e71395`).

## Context

**Carry-over from prior session:** birdseye implementation completed on `worktree-birdseye`, pushed to origin. Spec + plan landed in `docs/superpowers/specs/` and `docs/superpowers/plans/`. User chose `Keep as-is` finishing then pushed manually. This session is a continuation on the same worktree.

**Existing layout (post-prior-session):**
- `apps/birdseye/cmd/` — cobra root + diff + versions
- `apps/birdseye/internal/{archive,registry,diff,tui}` — domain packages
- `services/crow/pkg/client/Download` — added in prior session

**User communication style update mid-session:** CLAUDE.md was edited to add a Gen Z slang dictionary alongside the existing chicken-nuggie persona. Both should be woven into responses going forward.

## Decisions

### Bugbot fix #1 — `--semantic` in interactive mode
- `runInteractive` was building `diff.Result{Files: diff.Textual(...)}` and dropping `o.Semantic`. Fix mirrors the `runDiff` block — call `diff.Semantic`, wrap parse errors with `ErrSemantic`, populate `result.Semantic`.
- Adjacent bug discovered: `Browser.View()` only renders `result.Files`, never `result.Semantic`. So even with the cmd-side fix, the user wouldn't *see* semantic info in the TUI.
- Fixed both: added `Browser.renderSemantic()` that returns a `─── semantic changes ───` block above the footer when `Semantic` is non-empty, reusing existing `StatusAdd`/`StatusDel`/`StatusMod` theme styles.
- Commit: `021e400`.

### Bugbot fix #2 — `blockHash` recursion
- `blockHash` only digested `Type + Labels` of nested HCL blocks; their attributes and recursive content were invisible to the hash.
- Fix: split `blockHash` into entrypoint + recursive `hashBlock(h hash.Hash, blk, src)` helper that writes labels, sorted attribute keys + source-byte expression text, then for each nested block writes its type and recursively hashes its content.
- Catches the canonical `lifecycle { prevent_destroy = true → false }` case and any depth-N nested change.
- The original task 16 spec acknowledged this as a "depth-1 hash" v1 limitation; bugbot was right to push back on shipping that.
- Commit: `474d56d`.

### Known-modules autocomplete feature
- Cache "module addresses the user has successfully used" in a plain-text file at `os.UserConfigDir()/birdseye/known-modules` (Linux: `~/.config/birdseye/known-modules`, macOS: `~/Library/Application Support/birdseye/known-modules`).
- Two consumption surfaces share the cache:
  1. Cobra `ValidArgsFunction` on both `diff` and `versions` (shell tab-completion).
  2. `bubbles/textinput.SetSuggestions(...)` + `ShowSuggestions = true` in the TUI picker (ghost-text suggestion; Tab/→ accepts).
- Two write triggers (intentionally narrow):
  1. After successful positional CLI `diff` (in `runDiff` after the diff is built, before render).
  2. Inside the picker on `versionsLoadedMsg` success (fire-and-forget `addKnownCmd` returns `nil` tea.Msg).
- `versions` subcommand intentionally reads but never writes.
- File format: plain text, one `ns/name/provider` per line, alphabetically sorted, atomic temp+rename writes. Last writer wins on concurrent writes (documented).
- New package `internal/known/` exposes only `Path() / Load() / Add()`. Pure data layer; no logging, no HTTP, no CLI awareness.
- Hidden `--known-modules-file` flag on both `diff` and `versions` so tests inject `t.TempDir()` and never pollute the host.
- Picker signature: `NewPicker(ctx, deps, suggestions, knownPath)` — 4 params. Existing 3 tests updated; 3 new tests added.

### Final-review catch: test hermeticity
- Opus final reviewer caught that 4 pre-existing `cmd/diff` tests (`TestDiffPlainOutput`, `TestDiffIdenticalVersionsNoChanges`, `TestDiffJSONOutput`, `TestDiffSemanticIncluded`) executed a successful `birdseye diff` without injecting `--known-modules-file`. With the new feature, that means `known.Add(resolveKnownPath(""), ...)` resolves to the real user config dir on the host. Reviewer reproduced it (file appeared in `~/Library/Application Support/birdseye/`).
- On my machine the host didn't appear polluted (likely subagent sandbox isolation), but the spec invariant *"hermetic in tests"* was violated and any CI machine would have been hit.
- Fix: each of the 4 tests gets `"--known-modules-file", filepath.Join(t.TempDir(), "known-modules")` added to its `SetArgs`. Commit: `9e71395`.

## Task State

**All complete and pushed to origin `worktree-birdseye` at `9e71395`.**

Feature commits this session (top → bottom):
- `9e71395` test(birdseye): isolate cmd/diff tests from real known-modules path
- `4f53099` feat(birdseye): TUI textinput suggestions and best-effort cache from interactive picker
- `4d747fc` feat(birdseye): shell completion for diff and versions from known-modules cache
- `51a2c0e` feat(birdseye): cache module address after successful CLI diff
- `171c32a` test(birdseye): harden known-modules cache (dedup, sort, atomic, validation)
- `32e305b` feat(birdseye): known-modules autocomplete cache with Path/Load/Add
- `474d56d` fix(birdseye): recursively hash nested HCL blocks in semantic diff
- `021e400` fix(birdseye): honor --semantic in interactive mode and render in TUI

User chose `Push to existing branch (no PR)` finishing. No PR opened; worktree preserved at `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

## Open Questions

- None blocking. Spec's `locals`-block keyed-by-`filename#line` still has the known limitation that a line shift above an unchanged `locals` block reports as add+remove — accepted v1 limitation, unchanged from prior session.
- The `Path()` failure edge case (`os.UserConfigDir()` returning an error → `Add("")` could leak a temp in CWD before the rename fails) was flagged but accepted as extremely unlikely on darwin/linux. If we ever see it in practice, early-return in `Add`/`Load` when path is empty.

## Next Steps

When ready to ship:

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye
# already pushed; just open the PR when you're ready:
gh pr create --title "feat(birdseye): module diff CLI with semantic, TUI, and autocomplete" --body "..."
```

Possible enhancements (NOT in scope):
- LRU ordering of suggestions (currently alphabetical). Would require JSON or richer format.
- A `birdseye known clear` / `known list` management subcommand. YAGNI for v1 — `rm ~/.config/birdseye/known-modules` works.
- Real shell-completion install path (`birdseye completion bash > /etc/bash_completion.d/birdseye`). Cobra generates the scripts; only user docs are missing.

## Artifacts

- Spec: `docs/superpowers/specs/2026-05-13-birdseye-known-modules-design.md` (in worktree)
- Plan: `docs/superpowers/plans/2026-05-13-birdseye-known-modules.md` (in worktree)
- Prior-session save: `Claude/operations/2026-05-12-birdseye-cli-implementation.md`
- Branch: `worktree-birdseye` @ `9e71395`
- Worktree: `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`

## Workflow Lessons

- `bubbles/textinput.SetSuggestions([]string)` + `ShowSuggestions = true` is the right way to surface autocomplete in a bubbletea textinput — no third-party fuzzy lib needed.
- Cobra `ValidArgsFunction` with the `__complete` pseudo-command is testable hermetically via `root.SetArgs([]string{"__complete", "diff", ""})` + `SetOut(&buf)` — no need to simulate a real shell.
- When adding a hidden flag for test injection, ALWAYS check existing tests that exercise the same success path — they likely need the flag too. The Opus final review caught this for me; the per-task spec/quality reviewers missed it because they only saw the diff in isolation.
- Subagent sandbox isolation can mask real host-side bugs (the `known-modules` pollution didn't surface on this machine). Spec-stated invariants like "tests are hermetic" should be the source of truth, not "did this break my host?".
- Per-feature spec + plan in `docs/superpowers/` (committed in-tree with the worktree) gives the PR reviewer the design context alongside the code change. Cleaner than asking them to ferret through a separate docs branch.


File created successfully

File created successfully


Saved to `Claude/operations/2026-05-13-birdseye-known-modules-cache.md`. Task list is already fully closed out — all 22 entries completed across the brainstorm/plan/execute/finish cycles. No tracking work pending.

Bet, that's the session wrapped, Sir Charles of St Cheese. Birdseye now remembers your modules like a good pookie remembers your nuggie preferences (dinosaurs only, naturally).


Built a known-modules autocomplete cache for birdseye (cobra completion + TUI suggestions) on branch worktree-birdseye, pushed at 9e71395 with the worktree preserved. Next action: open the PR with gh pr create when you're ready. (disable recaps in /config)


can you create a readme.md and add @~/Downloads/birdseye_logo.png in it at the top?


ls -la ~/Downloads/birdseye_logo.png 2>&1; echo "---"; file ~/Downloads/birdseye_logo.png 2>&1

Verify image exists


.rw-r--r-- steve.briggs staff 1.6 MB Wed May 13 11:47:35 2026 /Users/steve.briggs/Downloads/birdseye_logo.png
---
/Users/steve.briggs/Downloads/birdseye_logo.png: PNG image data, 2816 x 1536, 8-bit/color RGBA, non-interlaced


1	# birdseye
2
3	A Go CLI for diffing two versions of a Terraform module hosted in
4	[Crow](../../services/crow/README.md), Immuta's private module registry.
5
6	## Build
7
8	```bash
9	make build
10	./bin/birdseye --help
11	```
12
13	## Usage
14
15	One-shot diff (multi-pane TUI on a TTY, unified diff to stdout otherwise):
16
17	```
18	birdseye diff <namespace>/<name>/<provider> <versionA> <versionB>
19	```
20
21	Interactive flow (TUI walks you through module → versions → diff):
22
23	```
24	birdseye diff
25	```
26
27	Common flags:
28
29	- `--semantic` — include HCL-aware resource/variable/output diff
30	- `--plain` / `--no-tui` — force plain unified output even on a TTY
31	- `--json` — emit structured JSON for scripts
32	- `--crow-url` — override Crow base URL (env `CROW_URL`)
33	- `--timeout` — per-HTTP-call timeout (default `30s`)
34	- `--cache-dir` — override the local tarball cache (default `~/.cache/birdseye`)
35
36	List versions for a module:
37
38	```
39	birdseye versions <namespace>/<name>/<provider>
40	```
41
42	## Exit codes
43
44	| Code | Meaning |
45	|---|---|
46	| 0 | success (including "no differences") |
47	| 1 | usage error |
48	| 2 | module version not found in registry |
49	| 3 | network or registry communication failure |
50	| 4 | archive corrupt or unsafe |
51	| 5 | HCL parse error (`--semantic` only) |
52	| 130 | user cancelled |
53
54	## Development
55
56	```
57	make test       # go test ./...
58	make test-race  # go test -race ./...
59	make lint       # go vet ./...
60	```
61

# 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 ./...
```


The README already exists at `apps/birdseye/README.md` from Task 19 — I'll update it (not create a fresh one) and copy the logo into a conventional `assets/` directory in the worktree.


mkdir -p /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/assets && cp /Users/steve.briggs/Downloads/birdseye_logo.png /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/assets/birdseye_logo.png && ls -la /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/assets/

Copy logo into worktree assets dir


drwxr-xr-x steve.briggs staff 384 B  Wed May 13 12:10:59 2026 ..
drwxr-xr-x steve.briggs staff  96 B  Wed May 13 12:10:59 2026 .
.rw-r--r-- steve.briggs staff 1.6 MB Wed May 13 12:10:59 2026 birdseye_logo.png


Edit

# birdseye

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

# birdseye

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


The file /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/README.md has been updated successfully. (file state is current in your context — no need to Read it back)

# 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 ./...
```

+  <img src="assets/birdseye_logo.png" alt="birdseye" width="400">


Bash

cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git status -s && echo "---" && git diff --stat apps/birdseye/README.md

Confirm pending changes


 M apps/birdseye/README.md
?? .claude/mind.mv2.lock
?? apps/birdseye/assets/
?? services/crow/.claude/
---
 apps/birdseye/README.md | 4 ++++
 1 file changed, 4 insertions(+)


git add apps/birdseye/README.md apps/birdseye/assets/birdseye_logo.png && git commit -m "docs(birdseye): add logo to README"

Commit logo + README update


[worktree-birdseye 2f12a15] docs(birdseye): add logo to README
 2 files changed, 4 insertions(+)
 create mode 100644 apps/birdseye/assets/birdseye_logo.png


Logo dropped in at `2f12a15`. The crow holding up its wings with that little `x x +` face is straight-up bussin' — it's giving "I just diffed your terraform and I'm not sorry about it" energy.

**What changed:**
- New: `apps/birdseye/assets/birdseye_logo.png` (1.6 MB, 2816×1536 PNG)
- Modified: `apps/birdseye/README.md` — `<p align="center">` block with the logo at 400px wide above the `# birdseye` heading

Want me to push this commit to origin, or are you handling that yourself, Sir Charles of St Cheese?


Browser footer advertises nonexistent "esc back" functionality

Low Severity

The footer renders "esc back" but the Browser.Update method has no handler for the Escape key. The application architecture also doesn't support "going back" — the browser runs as a standalone tea.NewProgram after the picker quits. Pressing Escape does nothing, which is confusing for users reading the footer hint.


Browser footer advertises nonexistent "esc back" functionality  Low Severity  The footer renders "esc back" but the Browser.Update method has no handler for the Escape key. The application architectur…


Real catch — "esc back" was always advertising a feature that never existed. Cleanest fix: make Escape actually do something (quit, same as `q`) and update the footer to reflect reality.


Agent

Fix browser Esc handling

Prompt:
Fix a bug in `apps/birdseye/internal/tui/browser.go` inside worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

## The Bug (from bugbot)

The Browser footer renders `j/k or ↑/↓ move · q quit · esc back`, but `Browser.Update` has no Escape-key handler. There's also no "back" state — the Browser is launched as a standalone `tea.NewProgram` AFTER the picker quits (two-stage architecture). Esc does nothing; the hint misleads users.

## The Fix

Make Escape do what users actually expect (quit the browser, same as `q`) and rewrite the footer to advertise only what works.

### Edit 1: Add Escape handler

In `apps/birdseye/internal/tui/browser.go`, `Browser.Update`, find the `tea.KeyMsg` switch. The current shape:

```go
case tea.KeyMsg:
    switch msg.Type {
    case tea.KeyDown:
        b.Move(1)
        return b, nil
    case tea.KeyUp:
        b.Move(-1)
        return b, nil
    case tea.KeyRunes:
        switch string(msg.Runes) {
        case "q":
            return b, tea.Quit
        case "j":
            b.Move(1)
            return b, nil
        case "k":
            b.Move(-1)
            return b, nil
        }
    }
```

Add a `tea.KeyEsc` case alongside `tea.KeyDown` / `tea.KeyUp`:

```go
case tea.KeyEsc:
    return b, tea.Quit
```

Place it directly after `case tea.KeyUp:` and its `return b, nil`.

### Edit 2: Fix footer text

In `Browser.View()`, the footer line currently reads:

```go
footer := b.theme.Footer.Render("j/k or ↑/↓ move · q quit · esc back")
```

Replace with:

```go
footer := b.theme.Footer.Render("j/k or ↑/↓ move · q/esc quit")
```

### Edit 3: Add a regression test

Append to `apps/birdseye/internal/tui/browser_test.go`:

```go
func TestBrowserEscQuits(t *testing.T) {
	b := NewBrowser(sampleResult())
	b.SetSize(120, 30)
	_, cmd := b.Update(tea.KeyMsg{Type: tea.KeyEsc})
	if cmd == nil {
		t.Fatal("expected tea.Quit cmd from Esc")
	}
	if got, want := cmd(), tea.Quit(); got != want {
		t.Errorf("cmd msg = %v, want %v", got, want)
	}
}

func TestBrowserFooterReflectsActualKeybinds(t *testing.T) {
	b := NewBrowser(sampleResult())
	b.SetSize(120, 30)
	view := b.View()
	if !contains(view, "q/esc quit") {
		t.Errorf("footer should advertise q/esc quit:\n%s", view)
	}
	if contains(view, "esc back") {
		t.Errorf("footer should not advertise the removed \"esc back\":\n%s", view)
	}
}
```

The `contains` helper already exists in `browser_test.go`.

## Verify

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/tui/... -v
```

All TUI tests must pass, including the two new ones.

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... && go vet ./... && go build ./...
```

All clean.

## Commit

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/tui/browser.go apps/birdseye/internal/tui/browser_test.go && git commit -m "fix(birdseye): make Esc quit the browser and fix misleading footer"
```

## Report

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Summary of fixes
- Test count + race-clean
- Commit SHA
- Anything unexpected


**Status: DONE**

### Summary

Three changes made to `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/tui/`:

1. **`browser.go` — Esc handler** (`Browser.Update`): Added `case tea.KeyEsc: return b, tea.Quit` alongside the existing `KeyDown`/`KeyUp` cases. Esc now quits the program identically to `q`.

2. **`browser.go` — footer text** (`Browser.View`): Changed `"j/k or ↑/↓ move · q quit · esc back"` to `"j/k or ↑/↓ move · q/esc quit"`. No more phantom "esc back" hint.

3. **`browser_test.go`** — Two new regression tests: `TestBrowserEscQuits` (verifies the returned cmd is `tea.Quit`) and `TestBrowserFooterReflectsActualKeybinds` (verifies presence of `"q/esc quit"` and absence of `"esc back"`).

**Tests:** 9 browser tests + 6 picker tests — all pass, race-clean.
**Commit:** `91e827d`

agentId: a64c844b5718fb151 (use SendMessage with to: 'a64c844b5718fb151' to continue this agent)
<usage>total_tokens: 19450
tool_uses: 8
duration_ms: 57200</usage>

Prompt:
Fix a bug in `apps/birdseye/internal/tui/browser.go` inside worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

## The Bug (from bugbot)

The Browser footer renders `j/k or ↑/↓ move · q quit · esc back`, but `Browser.Update` has no Escape-key handler. There's also no "back" state — the Browser is launched as a standalone `tea.NewProgram` AFTER the picker quits (two-stage architecture). Esc does nothing; the hint misleads users.

## The Fix

Make Escape do what users actually expect (quit the browser, same as `q`) and rewrite the footer to advertise only what works.

### Edit 1: Add Escape handler

In `apps/birdseye/internal/tui/browser.go`, `Browser.Update`, find the `tea.KeyMsg` switch. The current shape:

```go
case tea.KeyMsg:
    switch msg.Type {
    case tea.KeyDown:
        b.Move(1)
        return b, nil
    case tea.KeyUp:
        b.Move(-1)
        return b, nil
    case tea.KeyRunes:
        switch string(msg.Runes) {
        case "q":
            return b, tea.Quit
        case "j":
            b.Move(1)
            return b, nil
        case "k":
            b.Move(-1)
            return b, nil
        }
    }
```

Add a `tea.KeyEsc` case alongside `tea.KeyDown` / `tea.KeyUp`:

```go
case tea.KeyEsc:
    return b, tea.Quit
```

Place it directly after `case tea.KeyUp:` and its `return b, nil`.

### Edit 2: Fix footer text

In `Browser.View()`, the footer line currently reads:

```go
footer := b.theme.Footer.Render("j/k or ↑/↓ move · q quit · esc back")
```

Replace with:

```go
footer := b.theme.Footer.Render("j/k or ↑/↓ move · q/esc quit")
```

### Edit 3: Add a regression test

Append to `apps/birdseye/internal/tui/browser_test.go`:

```go
func TestBrowserEscQuits(t *testing.T) {
	b := NewBrowser(sampleResult())
	b.SetSize(120, 30)
	_, cmd := b.Update(tea.KeyMsg{Type: tea.KeyEsc})
	if cmd == nil {
		t.Fatal("expected tea.Quit cmd from Esc")
	}
	if got, want := cmd(), tea.Quit(); got != want {
		t.Errorf("cmd msg = %v, want %v", got, want)
	}
}

func TestBrowserFooterReflectsActualKeybinds(t *testing.T) {
	b := NewBrowser(sampleResult())
	b.SetSize(120, 30)
	view := b.View()
	if !contains(view, "q/esc quit") {
		t.Errorf("footer should advertise q/esc quit:\n%s", view)
	}
	if contains(view, "esc back") {
		t.Errorf("footer should not advertise the removed \"esc back\":\n%s", view)
	}
}
```

The `contains` helper already exists in `browser_test.go`.

## Verify

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/tui/... -v
```

All TUI tests must pass, including the two new ones.

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... && go vet ./... && go build ./...
```

All clean.

## Commit

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/tui/browser.go apps/birdseye/internal/tui/browser_test.go && git commit -m "fix(birdseye): make Esc quit the browser and fix misleading footer"
```

## Report

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Summary of fixes
- Test count + race-clean
- Commit SHA
- Anything unexpected


Fixed at `91e827d`. Browser now actually quits on Esc and the footer stops gaslighting users about a "back" feature.

Heads up: there are now 2 unpushed commits on `worktree-birdseye` — the README+logo (`2f12a15`) and this fix (`91e827d`). Want me to push both, or are you handling that yourself?


Semantic diff locals keyed by line number

Medium Severity

The locals block key includes the source file start line number (blk.Range().Filename + "#" + fmt.Sprint(blk.Range().Start.Line)). Since locals blocks have no labels, line number is used as a disambiguator — but when other changes in the file shift a locals block to a different line, diffMap will report it as removed+added (two false entries) even if the block content is identical. This is a common scenario when diffing two module versions.


This one's a known v1 limitation the prior reviews already flagged. The "minimal" fix (content-hash key) trades line-shift false positives for "modified locals look like remove+add". The right fix is to treat `locals` as a flat namespace of named attributes — which is how Terraform actually models them — and diff individual local definitions by name across the whole module.

Bigger change than the prior bugbot fixes, but it's the correct shape. Dispatching.


Fix locals diff by attribute name

Prompt:
Fix the semantic diff for `locals` blocks in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

## The Bug

Currently in `apps/birdseye/internal/diff/semantic.go`, `locals` blocks are stored in `ModuleSummary.Locals` keyed by `filename#startLine`. When a `locals` block shifts position (a line above it changes) without its content changing, `diffMap` reports it as removed + added — two false-positive entries.

## Root Cause

Terraform's `locals` is a flat namespace. The blocks are just syntactic containers; the semantically meaningful unit is each individual local definition (an attribute inside a `locals` block). The current code treats the WHOLE BLOCK as the unit and synthesizes a position-based key to distinguish multiple blocks — which is brittle by design.

## The Fix

Change the semantic diff to track individual `local` definitions by their attribute name, not the enclosing block by line number.

### Changes to `apps/birdseye/internal/diff/semantic.go`

1. **New type `localEntry`** alongside `blockEntry`:

```go
// localEntry is a single attribute inside a locals block. Locals are a
// flat namespace in Terraform — name uniquely identifies the value
// regardless of which file or block it lives in.
type localEntry struct {
	expr *hclsyntax.Expression
	src  []byte
}
```

Wait, `*hclsyntax.Expression` is an interface pointer which is awkward. Use `hcl.Range` directly to get the source range for hashing:

```go
type localEntry struct {
	srcRange hcl.Range
	src      []byte
}
```

2. **Change `ModuleSummary.Locals` type:**

From:
```go
Locals map[string]blockEntry
```
To:
```go
Locals map[string]localEntry
```

3. **Update `summarize`** — locate where `classify` is called on each block; the "locals" case currently returns a synthetic filename#line key into `s.Locals`. Replace that handling so that for `locals` blocks, we iterate their attributes and add one entry per attribute name. The classify switch's `case "locals":` should be removed and replaced with inline handling in `summarize`.

Concretely, in `summarize`'s main loop:

```go
for _, blk := range syntaxBody.Blocks {
    if blk.Type == "locals" {
        for name, attr := range blk.Body.Attributes {
            s.Locals[name] = localEntry{
                srcRange: attr.Expr.Range(),
                src:      body,
            }
        }
        continue
    }
    key, target := classify(blk, &s)
    if target == nil {
        continue
    }
    target[key] = blockEntry{block: blk, src: body}
}
```

Remove the `case "locals":` arm from the `classify` function entirely.

4. **Initialize `Locals` map with the new type** in `summarize`:

```go
s := ModuleSummary{
    Resources: map[string]blockEntry{},
    Variables: map[string]blockEntry{},
    Outputs:   map[string]blockEntry{},
    Providers: map[string]blockEntry{},
    Locals:    map[string]localEntry{},  // CHANGED
    Data:      map[string]blockEntry{},
    Modules:   map[string]blockEntry{},
}
```

5. **Add a `diffLocals` function** alongside `diffMap`:

```go
func diffLocals(a, b map[string]localEntry) []SemanticChange {
    out := make([]SemanticChange, 0)
    for name, ae := range a {
        be, ok := b[name]
        if !ok {
            out = append(out, SemanticChange{Kind: "locals", Name: name, Status: StatusRemoved})
            continue
        }
        if localText(ae) != localText(be) {
            out = append(out, SemanticChange{Kind: "locals", Name: name, Status: StatusModified})
        }
    }
    for name := range b {
        if _, ok := a[name]; !ok {
            out = append(out, SemanticChange{Kind: "locals", Name: name, Status: StatusAdded})
        }
    }
    return out
}

// localText returns the source bytes for a local definition's
// expression. Two locals with identical text are equal.
func localText(e localEntry) string {
    return exprText(e.src, e.srcRange)
}
```

6. **Update `Semantic` to call `diffLocals`** instead of `diffMap` for locals. Find the existing line in `Semantic`:

```go
out = append(out, diffMap("locals", sa.Locals, sb.Locals)...)
```

Replace with:

```go
out = append(out, diffLocals(sa.Locals, sb.Locals)...)
```

### Tests to add in `apps/birdseye/internal/diff/semantic_test.go`

```go
func TestSemanticLocalsLineShiftIsNotAChange(t *testing.T) {
	// Same local definition, different surrounding whitespace pushes
	// the locals block to a different line. Must NOT report changes.
	a := mkArchive(map[string]string{"main.tf": `
locals {
  region = "us-east-1"
}
`})
	b := mkArchive(map[string]string{"main.tf": `
# new comment

variable "x" {}

locals {
  region = "us-east-1"
}
`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	for _, c := range changes {
		if c.Kind == "locals" {
			t.Errorf("unexpected locals change after line shift: %+v", c)
		}
	}
}

func TestSemanticLocalsValueChange(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `locals { region = "us-east-1" }`})
	b := mkArchive(map[string]string{"main.tf": `locals { region = "us-west-2" }`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "locals", "region", StatusModified) {
		t.Errorf("expected locals.region modified, got: %v", changes)
	}
}

func TestSemanticLocalsAdded(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `locals { region = "us-east-1" }`})
	b := mkArchive(map[string]string{"main.tf": `locals { region = "us-east-1"
  bucket = "logs"
}`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "locals", "bucket", StatusAdded) {
		t.Errorf("expected locals.bucket added, got: %v", changes)
	}
}

func TestSemanticLocalsRemoved(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `locals {
  region = "us-east-1"
  bucket = "logs"
}`})
	b := mkArchive(map[string]string{"main.tf": `locals {
  region = "us-east-1"
}`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "locals", "bucket", StatusRemoved) {
		t.Errorf("expected locals.bucket removed, got: %v", changes)
	}
}

func TestSemanticLocalsAcrossFiles(t *testing.T) {
	// A local moved from main.tf to locals.tf without value change must
	// not produce any diff entries — locals is a flat namespace.
	a := mkArchive(map[string]string{
		"main.tf": `locals { region = "us-east-1" }`,
	})
	b := mkArchive(map[string]string{
		"main.tf":   ``,
		"locals.tf": `locals { region = "us-east-1" }`,
	})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	for _, c := range changes {
		if c.Kind == "locals" {
			t.Errorf("unexpected locals change after cross-file move: %+v", c)
		}
	}
}
```

## Verify

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/diff/... -v
```

All previous diff tests still pass (especially `TestSemanticAddedResource`, `TestSemanticRemovedVariable`, `TestSemanticModifiedResource`, `TestSemanticDetectsNestedBlockAttributeChange`, `TestSemanticDetectsDeepNestedChange`, `TestSemanticIdenticalNestedBlocksUnchanged`, `TestSemanticParseError`) — these don't involve locals, so they should remain unaffected.

Then:

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... && go vet ./... && go build ./...
```

All clean.

## 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 && git commit -m "fix(birdseye): track locals by name across the module's flat namespace"
```

## Notes

- The `localEntry` shares the `exprText(src, range)` helper that already exists in `semantic.go` from prior tasks.
- `classify` no longer handles `locals`; only resource/data/module/variable/output/provider remain in the switch.
- `ModuleSummary.Locals` type changes from `map[string]blockEntry` to `map[string]localEntry`. The `Locals` field stays exported; consumers were not relying on its inner type (the only consumer is `diffMap`/`diffLocals` in this same file).
- If `classify` is called with `blk.Type == "locals"` somewhere else, search for it and remove. The only caller is in `summarize` which you'll have already updated.
- `hcl.Range` is in the existing `"github.com/hashicorp/hcl/v2"` import — no new imports needed.

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Summary of structural changes
- Test count + race-clean
- Confirmation that previous non-locals tests still pass
- Commit SHA
- Anything unexpected


Semantic diff locals keyed by line number  Medium Severity  The locals block key includes the source file start line number (blk.Range().Filename + "#" + fmt.Sprint(blk.Range().Start.Line)). Since loc…


**Status: DONE**

**Structural changes:**

- `semantic.go`: Added `localEntry` struct (`srcRange hcl.Range`, `src []byte`). Changed `ModuleSummary.Locals` from `map[string]blockEntry` to `map[string]localEntry`. Updated `summarize` to handle `locals` blocks inline by iterating their attributes and keying by attribute name. Removed the `case "locals":` arm from `classify`. Added `diffLocals` function (compares by `localText`/`exprText`) and `localText` helper. Updated `Semantic` to call `diffLocals` instead of `diffMap` for locals.

- `semantic_test.go`: Added 5 new tests: `TestSemanticLocalsLineShiftIsNotAChange`, `TestSemanticLocalsValueChange`, `TestSemanticLocalsAdded`, `TestSemanticLocalsRemoved`, `TestSemanticLocalsAcrossFiles`. One test from the spec had a malformed HCL literal (multi-line content in a single-line block) — fixed it to use a properly formatted multi-line block.

**Test count:** 17 tests total, all pass, race-clean.

**Previous non-locals tests:** All 7 original tests still pass unaffected.

**Commit SHA:** `6ea0909`

**Unexpected:** `TestSemanticLocalsAdded` as written in the spec contained invalid HCL (a newline inside a single-line block definition). Fixed by converting the `b` fixture to a proper multi-line block — semantically identical to the intent.

agentId: ab859239e7ba2dcaa (use SendMessage with to: 'ab859239e7ba2dcaa' to continue this agent)
<usage>total_tokens: 28082
tool_uses: 19
duration_ms: 111237</usage>

Prompt:
Fix the semantic diff for `locals` blocks in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

## The Bug

Currently in `apps/birdseye/internal/diff/semantic.go`, `locals` blocks are stored in `ModuleSummary.Locals` keyed by `filename#startLine`. When a `locals` block shifts position (a line above it changes) without its content changing, `diffMap` reports it as removed + added — two false-positive entries.

## Root Cause

Terraform's `locals` is a flat namespace. The blocks are just syntactic containers; the semantically meaningful unit is each individual local definition (an attribute inside a `locals` block). The current code treats the WHOLE BLOCK as the unit and synthesizes a position-based key to distinguish multiple blocks — which is brittle by design.

## The Fix

Change the semantic diff to track individual `local` definitions by their attribute name, not the enclosing block by line number.

### Changes to `apps/birdseye/internal/diff/semantic.go`

1. **New type `localEntry`** alongside `blockEntry`:

```go
// localEntry is a single attribute inside a locals block. Locals are a
// flat namespace in Terraform — name uniquely identifies the value
// regardless of which file or block it lives in.
type localEntry struct {
	expr *hclsyntax.Expression
	src  []byte
}
```

Wait, `*hclsyntax.Expression` is an interface pointer which is awkward. Use `hcl.Range` directly to get the source range for hashing:

```go
type localEntry struct {
	srcRange hcl.Range
	src      []byte
}
```

2. **Change `ModuleSummary.Locals` type:**

From:
```go
Locals map[string]blockEntry
```
To:
```go
Locals map[string]localEntry
```

3. **Update `summarize`** — locate where `classify` is called on each block; the "locals" case currently returns a synthetic filename#line key into `s.Locals`. Replace that handling so that for `locals` blocks, we iterate their attributes and add one entry per attribute name. The classify switch's `case "locals":` should be removed and replaced with inline handling in `summarize`.

Concretely, in `summarize`'s main loop:

```go
for _, blk := range syntaxBody.Blocks {
    if blk.Type == "locals" {
        for name, attr := range blk.Body.Attributes {
            s.Locals[name] = localEntry{
                srcRange: attr.Expr.Range(),
                src:      body,
            }
        }
        continue
    }
    key, target := classify(blk, &s)
    if target == nil {
        continue
    }
    target[key] = blockEntry{block: blk, src: body}
}
```

Remove the `case "locals":` arm from the `classify` function entirely.

4. **Initialize `Locals` map with the new type** in `summarize`:

```go
s := ModuleSummary{
    Resources: map[string]blockEntry{},
    Variables: map[string]blockEntry{},
    Outputs:   map[string]blockEntry{},
    Providers: map[string]blockEntry{},
    Locals:    map[string]localEntry{},  // CHANGED
    Data:      map[string]blockEntry{},
    Modules:   map[string]blockEntry{},
}
```

5. **Add a `diffLocals` function** alongside `diffMap`:

```go
func diffLocals(a, b map[string]localEntry) []SemanticChange {
    out := make([]SemanticChange, 0)
    for name, ae := range a {
        be, ok := b[name]
        if !ok {
            out = append(out, SemanticChange{Kind: "locals", Name: name, Status: StatusRemoved})
            continue
        }
        if localText(ae) != localText(be) {
            out = append(out, SemanticChange{Kind: "locals", Name: name, Status: StatusModified})
        }
    }
    for name := range b {
        if _, ok := a[name]; !ok {
            out = append(out, SemanticChange{Kind: "locals", Name: name, Status: StatusAdded})
        }
    }
    return out
}

// localText returns the source bytes for a local definition's
// expression. Two locals with identical text are equal.
func localText(e localEntry) string {
    return exprText(e.src, e.srcRange)
}
```

6. **Update `Semantic` to call `diffLocals`** instead of `diffMap` for locals. Find the existing line in `Semantic`:

```go
out = append(out, diffMap("locals", sa.Locals, sb.Locals)...)
```

Replace with:

```go
out = append(out, diffLocals(sa.Locals, sb.Locals)...)
```

### Tests to add in `apps/birdseye/internal/diff/semantic_test.go`

```go
func TestSemanticLocalsLineShiftIsNotAChange(t *testing.T) {
	// Same local definition, different surrounding whitespace pushes
	// the locals block to a different line. Must NOT report changes.
	a := mkArchive(map[string]string{"main.tf": `
locals {
  region = "us-east-1"
}
`})
	b := mkArchive(map[string]string{"main.tf": `
# new comment

variable "x" {}

locals {
  region = "us-east-1"
}
`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	for _, c := range changes {
		if c.Kind == "locals" {
			t.Errorf("unexpected locals change after line shift: %+v", c)
		}
	}
}

func TestSemanticLocalsValueChange(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `locals { region = "us-east-1" }`})
	b := mkArchive(map[string]string{"main.tf": `locals { region = "us-west-2" }`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "locals", "region", StatusModified) {
		t.Errorf("expected locals.region modified, got: %v", changes)
	}
}

func TestSemanticLocalsAdded(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `locals { region = "us-east-1" }`})
	b := mkArchive(map[string]string{"main.tf": `locals { region = "us-east-1"
  bucket = "logs"
}`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "locals", "bucket", StatusAdded) {
		t.Errorf("expected locals.bucket added, got: %v", changes)
	}
}

func TestSemanticLocalsRemoved(t *testing.T) {
	a := mkArchive(map[string]string{"main.tf": `locals {
  region = "us-east-1"
  bucket = "logs"
}`})
	b := mkArchive(map[string]string{"main.tf": `locals {
  region = "us-east-1"
}`})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	if !hasChange(changes, "locals", "bucket", StatusRemoved) {
		t.Errorf("expected locals.bucket removed, got: %v", changes)
	}
}

func TestSemanticLocalsAcrossFiles(t *testing.T) {
	// A local moved from main.tf to locals.tf without value change must
	// not produce any diff entries — locals is a flat namespace.
	a := mkArchive(map[string]string{
		"main.tf": `locals { region = "us-east-1" }`,
	})
	b := mkArchive(map[string]string{
		"main.tf":   ``,
		"locals.tf": `locals { region = "us-east-1" }`,
	})
	changes, err := Semantic(a, b)
	if err != nil {
		t.Fatalf("Semantic: %v", err)
	}
	for _, c := range changes {
		if c.Kind == "locals" {
			t.Errorf("unexpected locals change after cross-file move: %+v", c)
		}
	}
}
```

## Verify

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/diff/... -v
```

All previous diff tests still pass (especially `TestSemanticAddedResource`, `TestSemanticRemovedVariable`, `TestSemanticModifiedResource`, `TestSemanticDetectsNestedBlockAttributeChange`, `TestSemanticDetectsDeepNestedChange`, `TestSemanticIdenticalNestedBlocksUnchanged`, `TestSemanticParseError`) — these don't involve locals, so they should remain unaffected.

Then:

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... && go vet ./... && go build ./...
```

All clean.

## 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 && git commit -m "fix(birdseye): track locals by name across the module's flat namespace"
```

## Notes

- The `localEntry` shares the `exprText(src, range)` helper that already exists in `semantic.go` from prior tasks.
- `classify` no longer handles `locals`; only resource/data/module/variable/output/provider remain in the switch.
- `ModuleSummary.Locals` type changes from `map[string]blockEntry` to `map[string]localEntry`. The `Locals` field stays exported; consumers were not relying on its inner type (the only consumer is `diffMap`/`diffLocals` in this same file).
- If `classify` is called with `blk.Type == "locals"` somewhere else, search for it and remove. The only caller is in `summarize` which you'll have already updated.
- `hcl.Range` is in the existing `"github.com/hashicorp/hcl/v2"` import — no new imports needed.

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Summary of structural changes
- Test count + race-clean
- Confirmation that previous non-locals tests still pass
- Commit SHA
- Anything unexpected


Done at `6ea0909`. Locals are now diffed as individual named definitions across the whole module — a line-shifted block with identical content reports zero changes, cross-file moves report zero changes, actual value changes report as Modified on the specific local name.

3 unpushed commits sitting on the branch now (README+logo, browser Esc fix, this locals fix). Push 'em?


push em dawg


Bash

Push 3 unpushed commits


remote:
remote: GitHub found 18 vulnerabilities on immuta/operations's default branch (2 high, 13 moderate, 3 low). To find out more, visit:
remote:      https://github.com/immuta/operations/security/dependabot
remote:
To github.com:immuta/operations.git
   9e71395..6ea0909  worktree-birdseye -> worktree-birdseye


Pushed, my G. Origin is now at `6ea0909` — README+logo, browser Esc fix, and the locals-by-name refactor all landed. No cap, that's three bugbot finds caught + cleaned in a row this session. The vibe check on this branch is officially passing.


Building birdseye, a Go CLI that diffs Terraform modules from Crow registry; just pushed three bugbot fixes (README logo, browser Esc handling, locals-by-name refactor) to origin/worktree-birdseye. Next action is yours: open a PR when ready. (disable recaps in /config)


<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>


<command-name>/exit</command-name>
            <command-message>exit</command-message>
            <command-args></command-args>


<local-command-stdout>Worktree kept. Your work is saved at /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye on branch worktree-birdseye</local-command-stdout>