AskUserQuestion

Question: Where should autocompletion of cached module addresses show up?

Choices:
`birdseye diff <TAB>` and `birdseye versions <TAB>` suggest cached addresses. Cobra supports this natively via ValidArgsFunction. Works in bash/zsh/fish after `birdseye completion <shell>` is sourced.
In the no-args interactive picker, the module textinput shows ghost-text suggestions from the cache; press → or Tab to accept. bubbles/textinput supports this via SetSuggestions.
Cobra completion AND TUI suggestions. Same on-disk cache feeds both surfaces.


User has answered your questions: "Where should autocompletion of cached module addresses show up?"="Both". You can now continue with the user's answers in mind.

Question: Where should autocompletion of cached module addresses show up?

Choices:
`birdseye diff <TAB>` and `birdseye versions <TAB>` suggest cached addresses. Cobra supports this natively via ValidArgsFunction. Works in bash/zsh/fish after `birdseye completion <shell>` is sourced.
In the no-args interactive picker, the module textinput shows ghost-text suggestions from the cache; press → or Tab to accept. bubbles/textinput supports this via SetSuggestions.
Cobra completion AND TUI suggestions. Same on-disk cache feeds both surfaces.


Good. Next: when does an address get added to the cache?


Question: Which successful operations should add the module address to the autocomplete cache?

Choices:
After a successful `birdseye diff ns/n/p vA vB` (i.e., both versions fetched, archives extracted, diff produced — regardless of whether changes exist), cache `ns/n/p`.
After a successful `birdseye versions ns/n/p` (versions endpoint returned 200 with at least one version), cache `ns/n/p`.
After the TUI picker user types an address and Crow returns a non-empty version list, cache that address. Same idea as `versions`, but only via the interactive flow.


User has answered your questions: "Which successful operations should add the module address to the autocomplete cache?"="lets store the address in the cache on both, a diff from the CLI and in the interactive picker versions success". You can now continue with the user's answers in mind.

Question: Which successful operations should add the module address to the autocomplete cache?

Choices:
After a successful `birdseye diff ns/n/p vA vB` (i.e., both versions fetched, archives extracted, diff produced — regardless of whether changes exist), cache `ns/n/p`.
After a successful `birdseye versions ns/n/p` (versions endpoint returned 200 with at least one version), cache `ns/n/p`.
After the TUI picker user types an address and Crow returns a non-empty version list, cache that address. Same idea as `versions`, but only via the interactive flow.

lets store the address in the cache on both, a diff from the CLI and in the interactive picker versions success


Got it: cache on (1) successful CLI `diff`, and (2) interactive picker's `ListVersions` success. The `versions` subcommand is intentionally NOT a trigger.


Question: Where on disk should the cache file live?

Choices:
User-config-like data. On macOS: `~/Library/Application Support/birdseye/known-modules`. On Linux: `~/.config/birdseye/known-modules`. Sits NEXT to where future config (e.g., aliases) would go.
Alongside existing tarball cache at `~/.cache/birdseye/`. Pro: one cache dir. Con: cache dirs are conventionally safe to wipe — known-modules is user preference data and shouldn't be "cache" per XDG semantics.
Reuse the existing `--cache-dir` flag's directory. Simple, but conflates tarball cache with autocomplete data — users wiping their tarball cache would also lose their autocomplete history.


User has answered your questions: "Where on disk should the cache file live?"="$XDG_CONFIG_HOME/birdseye/known-modules (Recommended)". You can now continue with the user's answers in mind.

Question: Where on disk should the cache file live?

Choices:
User-config-like data. On macOS: `~/Library/Application Support/birdseye/known-modules`. On Linux: `~/.config/birdseye/known-modules`. Sits NEXT to where future config (e.g., aliases) would go.
Alongside existing tarball cache at `~/.cache/birdseye/`. Pro: one cache dir. Con: cache dirs are conventionally safe to wipe — known-modules is user preference data and shouldn't be "cache" per XDG semantics.
Reuse the existing `--cache-dir` flag's directory. Simple, but conflates tarball cache with autocomplete data — users wiping their tarball cache would also lose their autocomplete history.


Sufficient context. Moving to approaches.


Updated task #14 status


Updated task #15 status


I would like to add another feature, I would like to store a cache with the application (perhaps in the users home directory?) the cache should only store the address of a module after a successful lo…


Build birdseye CLI for Terraform module diffing


## Three approaches for storage shape

**Recommendation: Approach A — plain-text, one address per line.** A file at `os.UserConfigDir()/birdseye/known-modules` containing one `ns/n/p` per line, alphabetically sorted. Read-all on every load; on add: read, dedup-insert, sort, atomic temp+rename write. The tarball cache (`registry.DiskCache`) already does atomic writes — we mirror that pattern.

**Approach B — JSON with metadata.** `{ "modules": [{"address": "ns/n/p", "lastUsed": "...", "hitCount": 3}] }`. Enables LRU-sorted suggestions ("most-recent-first" autocomplete) but adds:
- Full file rewrite per update (vs. plain-text we'd also rewrite, but it's smaller)
- Schema migration burden later
- Harder to `cat ~/.config/birdseye/known-modules` for debugging

LRU is nicer UX but YAGNI for v1 — users typically have <20 modules.

**Approach C — SQLite at `~/.config/birdseye/known.db`.** Robust, supports concurrent writes natively, future-proof for richer metadata. But: pulls in CGo or pure-Go sqlite driver (~MB of binary bloat), massive overkill for a string list.

### Surface layering

- **New package `internal/known/`** exposes:
  ```go
  func Path() (string, error)                     // resolves UserConfigDir + "birdseye/known-modules"
  func Load(path string) ([]string, error)        // returns sorted addresses, empty slice if file missing
  func Add(path, address string) error            // load → dedup-insert → sort → atomic write
  ```
- **CLI integration**:
  - `newDiffCmd` gets a `ValidArgsFunction` that returns `known.Load(...)` for the first positional arg
  - `newVersionsCmd` gets the same (note: completing `versions` from cache is fine even though success of `versions` does NOT add — the cache is read-only here)
  - After successful `runDiff` (positional path), call `known.Add(modID.String())`
- **TUI integration**:
  - `Picker.NewPicker(ctx, deps, suggestions []string)` — third param accepts pre-loaded suggestions
  - Picker passes them to `textinput.SetSuggestions(...)` and sets `ShowSuggestions=true`
  - On successful `ListVersions` inside picker's loading transition, call `known.Add(p.moduleID.String())` (best-effort; failure does not block diff flow)

### Boundary notes

- `known` is a pure data layer — no logging, no CLI knowledge, no HTTP
- `cmd` is the only place that picks the file path (so tests can inject a tempdir)
- `Add` failures are non-fatal — the diff succeeded, the auto-complete refinement is a nice-to-have; we'd log to stderr if we had a logger, but for now just swallow per the same pattern as `registry.Fetch`'s cache-write
- Path resolution honors a new optional `--known-modules-file` flag (mostly for tests; users won't usually set it)

Does Approach A + this layering look right, or want to push back on any piece?


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


lets use approach A


## Section 1 — Architecture & layout

New internal package, plus small touchpoints in `cmd/` and `tui/`. No other files changed.

```
apps/birdseye/
  internal/
    known/
      known.go        # Path() / Load() / Add() — all pure besides file I/O
      known_test.go   # tempdir-based tests, no live cache touch
  cmd/
    diff.go           # +Suggestion plumbing to picker, +known.Add after runDiff success
                      # +ValidArgsFunction on diff/versions for shell completion
    versions.go       # +ValidArgsFunction
    root.go           # +--known-modules-file flag (hidden), defaulted to known.Path()
  internal/tui/
    picker.go         # NewPicker signature gains `suggestions []string`
                      # textinput.SetSuggestions + ShowSuggestions=true
                      # After versionsLoadedMsg success → known.Add (best-effort, not blocking)
```

**File on disk:**
- Path: `os.UserConfigDir()/birdseye/known-modules`
  - Linux: `~/.config/birdseye/known-modules`
  - macOS: `~/Library/Application Support/birdseye/known-modules`
- Format: plain text, one `namespace/name/provider` per line, alphabetically sorted, trailing newline. Empty file or missing file → no suggestions, not an error.

**Test override:** `--known-modules-file` hidden flag. Tests pass `t.TempDir() + "/known-modules"` so the host's real `~/.config/birdseye/known-modules` is never touched.

Look right or want adjustments before I get into component interfaces?


looks right


## Section 2 — Component interfaces

### `internal/known/known.go`

```go
// Package known persists a sorted, deduplicated list of Crow module
// addresses the user has successfully diff'd or browsed, so future
// invocations can offer shell-completion and TUI suggestions.
package known

// Path returns the default known-modules file path, honoring
// os.UserConfigDir() (XDG_CONFIG_HOME on Linux, Application Support on
// macOS). The directory is NOT created here — Add creates it lazily on
// first write.
func Path() (string, error)

// Load reads the file at path. Returns an empty slice if the file does
// not exist (first run is normal). Each non-empty trimmed line is
// returned. The result is sorted ascending. Malformed lines are skipped
// silently.
func Load(path string) ([]string, error)

// Add inserts address into the file at path. Existing entries are
// preserved; duplicates are deduplicated; the final list is sorted.
// Writes are atomic (temp file in the same dir + rename).
//
// The directory is created with 0o755 if missing. If address is not a
// well-formed ns/name/provider triple, Add returns an error without
// touching the file.
func Add(path, address string) error
```

That's it. Three functions, no types exposed. The internal `validateAddress` helper uses the same rule as `cmd.parseModuleAddress` (3 slash-separated segments, each passing `api.ValidateSegments`).

### `cmd/diff.go` changes

```go
type diffOpts struct {
    Semantic         bool
    Plain            bool
    CacheDir         string
    KnownModulesFile string  // NEW: defaulted from known.Path(), overridable
}

// newDiffCmd gains:
c.Flags().StringVar(&o.KnownModulesFile, "known-modules-file", "", "override known-modules autocomplete file (mostly for tests)")
_ = c.Flags().MarkHidden("known-modules-file")
c.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
    if len(args) > 0 {
        return nil, cobra.ShellCompDirectiveNoFileComp
    }
    return known.Load(resolveKnownPath(o.KnownModulesFile)), cobra.ShellCompDirectiveNoFileComp
}
```

`resolveKnownPath(override string) string` is a tiny helper: returns `override` if non-empty, else `known.Path()` (errors fall back to `""` so Load gracefully returns nothing).

After successful `runDiff` (positional path, NOT interactive — interactive triggers from inside the picker), call:

```go
_ = known.Add(resolveKnownPath(o.KnownModulesFile), id.Namespace+"/"+id.Name+"/"+id.Provider)
```

Best-effort. Errors swallowed because the diff already succeeded.

### `cmd/versions.go` changes

Same `ValidArgsFunction` wiring as diff. NO `Add` call — the user explicitly excluded `versions` from triggers.

### `cmd/root.go` changes

None. The `--known-modules-file` flag is on the diff and versions subcommands, not global (different flag scope = clearer cobra completion semantics).

### `internal/tui/picker.go` changes

```go
// NewPicker signature gains suggestions.
func NewPicker(ctx context.Context, deps *Deps, suggestions []string) *Picker
```

In `NewPicker`, after constructing `ti`:

```go
if len(suggestions) > 0 {
    ti.SetSuggestions(suggestions)
    ti.ShowSuggestions = true
}
```

In `updateLoading`, after `versionsLoadedMsg` arrives with no error and a non-empty version list, dispatch a fire-and-forget add via a tea.Cmd:

```go
return p, addKnownCmd(p.knownPath, p.moduleID)
```

Where `addKnownCmd` is:

```go
func addKnownCmd(path string, id api.ModuleIdentifier) tea.Cmd {
    return func() tea.Msg {
        _ = known.Add(path, id.Namespace+"/"+id.Name+"/"+id.Provider)
        return nil
    }
}
```

The `nil` returned msg is a no-op — bubbletea handles `nil` cleanly. We don't care about the result.

Picker gets a new field `knownPath string`, set from a fourth arg added to `NewPicker(ctx, deps, suggestions, knownPath)`. (So actually `NewPicker(ctx, deps, suggestions, knownPath)` — 4 params. Worth flagging now.)

Acceptable, or do you want the picker to receive a smaller "config" struct instead of 4 positional args?


this is acceptable


## Section 3 — Data flow

### CLI one-shot (positional args)

```
$ birdseye diff iac/comply/aws 1.0.0 1.1.0

cobra ValidArgsFunction (on TAB)
  -> known.Load(resolveKnownPath(o.KnownModulesFile))    # called every TAB press
  -> returns []string for completion

runDiff
  -> [existing flow: parse, fetch, extract, diff, render]
  -> on success: known.Add(path, "iac/comply/aws")       # best-effort, errors swallowed
```

`Add` runs synchronously at the tail of `runDiff` but after the diff has been written to stdout/TUI. If the user already saw their diff, a slow disk write doesn't matter. The whole operation is sub-millisecond for typical file sizes anyway.

### Interactive picker

```
$ birdseye diff

runInteractive
  -> known.Load(resolveKnownPath(...))                    # at startup, sync
  -> tui.NewPicker(ctx, deps, suggestions, knownPath)    # injects into textinput
  -> tea.NewProgram(picker).Run()

picker.Update (in StateAskModule, on Enter with valid address)
  -> state = StateLoading
  -> tea.Cmd: client.ListVersions

picker.updateLoading (on versionsLoadedMsg success)
  -> populate versionsA list, state = StateAskVersionA
  -> tea.Cmd: addKnownCmd(p.knownPath, p.moduleID)        # fire-and-forget
```

The `addKnownCmd` fires concurrently with the user's transition into version selection — they never wait on it. Errors disappear silently.

### What ISN'T written

- `birdseye versions ns/n/p` success → NOT cached (per your earlier "diff CLI + interactive picker only")
- A failed `diff` (404, network, archive corrupt) → NOT cached
- Picker quit before completing version selection → cached (because `ListVersions` already succeeded — we know the address resolves)

The "picker quit before completing" case is a deliberate choice: if Crow returned versions, the address IS valid and worth remembering even if the user changed their mind.

### Concurrency

Two birdseye processes running at once:
- Both call `Add` against the same file
- Each: read existing file, dedup-insert new entry, sort, write to `<dir>/.known-modules-*.tmp`, `os.Rename` over the final
- Last writer wins for the file content. If process A had entries {x, y} and process B had {x, z}, the final file contains whichever set the late renamer wrote — the other's contribution may be lost.

For a low-frequency, append-only-ish list this is acceptable. Worth a one-line code comment acknowledging it. Not worth a lockfile or atomic-merge for v1.

Look right, or want to push back on the "last writer wins" or any other piece?


looks good to me


## Section 4 — Error handling & Section 5 — Testing

Compact since this feature is small.

### Error handling

| Failure | Behavior |
|---|---|
| `os.UserConfigDir()` fails (unusual — sandboxed env, no `$HOME`) | `Path()` returns the error. Callers fall back to `""`; `Load("")` returns empty slice, `Add("")` returns error. Cobra completion shows nothing; CLI behavior otherwise unchanged. |
| File doesn't exist (first run) | `Load` returns `(nil, nil)`. Normal. |
| File exists but malformed UTF-8 / binary blob | `Load` returns the lines that survive `bufio.Scanner` line splitting; invalid trimmed lines (failing the slash-segment shape check) are silently skipped. |
| `Add` rejects bad address | Returns error, file unchanged. Caller (cmd) swallows. |
| `Add` fails to write (read-only fs, no permission) | Returns wrapped error. Caller swallows. |
| Two concurrent writers | Last writer wins. Documented in code comment. |

**Never blocks the diff.** The `Add` calls are always best-effort. The user's actual diff already succeeded by the time `Add` runs.

### Testing strategy

**`internal/known/known_test.go` (unit tests, all hermetic via `t.TempDir()`):**

| Test | What it verifies |
|---|---|
| `TestLoadMissingFileReturnsEmpty` | First-run case: no file, no error, empty slice |
| `TestAddCreatesDirectoryAndFile` | Cold start: nested dir gets created with 0o755 |
| `TestAddDeduplicates` | Adding the same address twice → file has one line |
| `TestAddSortsAlphabetically` | Adding `z/y/x` then `a/b/c` → file lists `a/b/c` first |
| `TestAddAtomicWrite` | After successful `Add`, no `.known-modules-*.tmp` lingering |
| `TestAddRejectsBadAddress` | `"not-a-valid-address"` → error, file unchanged |
| `TestLoadSkipsMalformedLines` | File with a mix of valid/garbage lines → garbage ignored |
| `TestLoadStripsBlankAndComments` | Trailing/blank lines → empty fields not returned |

No `Path()` test against the real filesystem — it just wraps `os.UserConfigDir()`. We verify it via integration tests below.

**`cmd/diff_test.go` additions:**

| Test | What it verifies |
|---|---|
| `TestDiffAddsToKnownAfterSuccess` | Run `diff --known-modules-file=<tempfile>`, then read file and assert it contains the module address |
| `TestDiffDoesNotAddOnFailure` | Force a 404 via fake crow returning 404 for one version → file remains untouched |
| `TestDiffValidArgsFunctionLoadsCache` | Pre-seed a known-modules file, invoke completion for the diff command's first arg, assert suggestions match |

**`cmd/versions_test.go` additions:**

| Test | What it verifies |
|---|---|
| `TestVersionsDoesNotAddToKnown` | Run versions, assert known-modules file is unchanged (the file must not be created) |
| `TestVersionsValidArgsFunctionLoadsCache` | Same completion check as diff |

**`internal/tui/picker_test.go` additions:**

| Test | What it verifies |
|---|---|
| `TestPickerAcceptsSuggestionsArg` | `NewPicker(ctx, nil, []string{"iac/comply/aws"}, "")` — textinput has the suggestion attached and `ShowSuggestions == true` |
| `TestPickerNoSuggestionsLeavesShowSuggestionsFalse` | Empty/nil suggestions → `ShowSuggestions == false` |

We don't attempt to test the `addKnownCmd` fire-and-forget through the picker. It's already unit-tested via `internal/known/known_test.go`; the picker side is just a one-line `tea.Cmd` dispatch.

### What we're NOT testing

- The actual shell-completion script (cobra generates that; we trust cobra)
- Two-process concurrent writes (documented "last writer wins"; not worth a flaky test)
- Real `~/.config/birdseye/` interaction (every test uses tempdir; the host config is never touched)

That wraps it. Want me to write the spec doc and proceed to writing-plans, or push back on anything?


fuck yeah this looks sick, lets continue my daawwggg


Bash

ls /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/docs/superpowers/specs/ 2>&1; echo "---"; ls /Users/steve.briggs/projects/operations/docs/superpowers/specs/ 2>&1

Check spec dirs in worktree vs main


lsd: /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/docs/superpowers/specs/: No such file or directory (os error 2).

---
.rw-r--r-- steve.briggs staff  14 KB Tue May 12 17:45:23 2026 2026-05-12-birdseye-design.md
drwxr-xr-x steve.briggs staff  96 B  Tue May 12 17:45:23 2026 .
drwxr-xr-x steve.briggs staff 128 B  Tue May 12 17:48:54 2026 ..


Create specs dir in worktree


(Bash completed with no output)


Write

# Birdseye — known-modules autocomplete cache

**Status:** Draft for implementation
**Date:** 2026-05-13
**Owner:** steve.briggs@immuta.com
**Builds on:** `2026-05-12-birdseye-design.md`

## Summary

Persist the set of module addresses (`namespace/name/provider`) the user
has successfully diff'd or browsed in a small text file, and offer them
as autocomplete suggestions on subsequent invocations. Two surfaces
share the same on-disk cache: cobra shell-completion for the CLI
positional argument, and `bubbles/textinput` ghost-text suggestions in
the interactive TUI picker.

## Goals

- Zero-friction autocomplete: addresses the user has used before should
  surface automatically on the next invocation, both in shell tab-completion
  and the TUI textinput.
- Best-effort persistence: cache writes never block or fail the diff flow.
- Hermetic in tests: every test path injects a tempdir, so the host's
  real `~/.config/birdseye/known-modules` is never touched.

## Non-goals

- Tracking version history (we only remember the `ns/n/p` triple).
- LRU ordering, hit counts, or any per-entry metadata — alphabetical
  sort is fine for v1.
- A management CLI (`birdseye known clear`, `known list`). Users can
  inspect/delete the file with standard tools.
- Concurrent-write merge semantics. Two parallel birdseye processes
  writing at the same instant: last writer wins (documented).

## File location and format

**Path** resolved via `os.UserConfigDir()`:

- Linux: `~/.config/birdseye/known-modules`
- macOS: `~/Library/Application Support/birdseye/known-modules`

**Format:** plain text, one `namespace/name/provider` per line,
alphabetically sorted, single trailing newline. Empty file or missing
file → no suggestions, not an error.

Example:
```
iac/comply/aws
iac/eks-cluster/aws
platform/networking/aws
```

## Triggers — what adds to the cache

| Operation | Adds to cache? |
|---|---|
| `birdseye diff ns/n/p vA vB` (positional CLI), success | yes |
| Interactive picker (`birdseye diff`), `ListVersions` returns versions | yes |
| `birdseye versions ns/n/p`, success | **no** (intentionally excluded) |
| Any failed operation (404, network, archive corrupt) | no |
| Picker quit before completing version selection | yes (if `ListVersions` already succeeded) |

The picker case fires the moment Crow confirms the address resolves —
we know the address is valid even if the user changes their mind about
which versions to diff.

## High-level architecture

```
apps/birdseye/
  internal/
    known/
      known.go        # Path() / Load() / Add() — pure besides file I/O
      known_test.go
  cmd/
    diff.go           # ValidArgsFunction for first positional arg
                      # +known.Add after successful runDiff
                      # +diffOpts.KnownModulesFile flag (hidden)
    versions.go       # ValidArgsFunction for first positional arg (no Add)
  internal/tui/
    picker.go         # NewPicker(ctx, deps, suggestions, knownPath)
                      # textinput.SetSuggestions + ShowSuggestions
                      # +addKnownCmd fire-and-forget on versionsLoadedMsg
```

`internal/known` is a small, pure data layer. The cmd layer is the only
place that resolves the file path (so tests can inject a tempdir).

## Component interfaces

### `internal/known`

```go
// Path returns the default known-modules file path, honoring
// os.UserConfigDir(). The directory is NOT created here — Add creates
// it lazily on first write.
func Path() (string, error)

// Load reads the file at path. Returns an empty slice if the file
// does not exist (first run is normal). Each non-empty trimmed line
// is returned. The result is sorted ascending. Malformed lines
// (lines that don't pass the ns/name/provider segment validation
// from services/crow/pkg/api) are skipped silently.
func Load(path string) ([]string, error)

// Add inserts address into the file at path. Existing entries are
// preserved; duplicates deduplicated; the final list is sorted.
// Writes are atomic via temp file in the same directory + rename.
//
// The directory is created with 0o755 if missing. If address is not
// a well-formed ns/name/provider triple, Add returns an error
// without touching the file.
func Add(path, address string) error
```

Validation reuses `api.ValidateSegments` from `services/crow/pkg/api`.

### `cmd/diff.go`

```go
type diffOpts struct {
    Semantic         bool
    Plain            bool
    CacheDir         string
    KnownModulesFile string // NEW: defaulted to known.Path(), overridable
}
```

`--known-modules-file` is a hidden flag (test override). The diff
subcommand and versions subcommand each register a
`ValidArgsFunction` that returns:

```go
func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
    if len(args) > 0 {
        return nil, cobra.ShellCompDirectiveNoFileComp
    }
    suggestions, _ := known.Load(resolveKnownPath(o.KnownModulesFile))
    return suggestions, cobra.ShellCompDirectiveNoFileComp
}
```

`resolveKnownPath(override string) string` returns `override` if
non-empty, else `known.Path()`; on path-resolution error, returns
`""` so `Load` and `Add` gracefully no-op.

After `runDiff` succeeds on the positional path (and before returning),
the cmd layer calls:

```go
_ = known.Add(resolveKnownPath(o.KnownModulesFile),
    id.Namespace+"/"+id.Name+"/"+id.Provider)
```

Errors are swallowed.

### `cmd/versions.go`

Same `ValidArgsFunction` wiring as diff. **No** `Add` call.

### `internal/tui/picker.go`

```go
func NewPicker(ctx context.Context, deps *Deps, suggestions []string, knownPath string) *Picker
```

In `NewPicker`, after constructing `ti`:

```go
if len(suggestions) > 0 {
    ti.SetSuggestions(suggestions)
    ti.ShowSuggestions = true
}
```

`Picker` gains a `knownPath string` field.

In `updateLoading`, when `versionsLoadedMsg` arrives with no error and
the version list is non-empty, dispatch:

```go
return p, addKnownCmd(p.knownPath, p.moduleID)
```

Helper:

```go
func addKnownCmd(path string, id api.ModuleIdentifier) tea.Cmd {
    return func() tea.Msg {
        _ = known.Add(path, id.Namespace+"/"+id.Name+"/"+id.Provider)
        return nil
    }
}
```

The returned `nil` msg is a no-op — bubbletea ignores nil cleanly.

### `cmd/diff.go` `runInteractive` changes

```go
suggestions, _ := known.Load(resolveKnownPath(o.KnownModulesFile))
p := tui.NewPicker(ctx, &tui.Deps{Client: cli, Reg: reg},
    suggestions, resolveKnownPath(o.KnownModulesFile))
```

## Data flow

### CLI one-shot

```
$ birdseye diff iac/comply/aws 1.0.0 1.1.0

cobra ValidArgsFunction (when user presses TAB)
  -> known.Load(resolveKnownPath(o.KnownModulesFile))
  -> returns []string for completion

runDiff
  -> [existing: parse, validate, fetch, extract, diff, render]
  -> on success: known.Add(path, "iac/comply/aws")
```

### Interactive picker

```
$ birdseye diff

runInteractive
  -> known.Load(resolveKnownPath(...))    [synchronous, at startup]
  -> tui.NewPicker(ctx, deps, suggestions, knownPath)
  -> textinput.SetSuggestions(suggestions); ShowSuggestions=true
  -> tea.NewProgram(picker).Run()

picker.updateLoading (on versionsLoadedMsg success)
  -> populate versionsA, state = StateAskVersionA
  -> dispatch addKnownCmd(p.knownPath, p.moduleID)   [fire-and-forget]
```

## Error handling

| Failure | Behavior |
|---|---|
| `os.UserConfigDir()` fails | `Path()` returns the error. Caller falls back to `""`; `Load("")` and `Add("")` no-op. CLI behavior otherwise unchanged. |
| File doesn't exist (first run) | `Load` returns `(nil, nil)`. |
| Malformed file content | `Load` skips lines failing `api.ValidateSegments`. |
| `Add` rejects bad address | Returns error; caller swallows. File unchanged. |
| `Add` fails to write | Returns wrapped error; caller swallows. |
| Concurrent writers | Last writer wins. Documented in code. |

`Add` calls never block the diff. They run after the diff result has
been written/rendered.

## Testing strategy

TDD as elsewhere in this codebase. Race detector on every `go test`.
All paths use `t.TempDir()`; the real `~/.config/birdseye/` is never
touched.

### `internal/known/known_test.go`

- `TestLoadMissingFileReturnsEmpty` — first-run: no file, no error
- `TestAddCreatesDirectoryAndFile` — cold start: nested dir + file created
- `TestAddDeduplicates` — same address twice → file has one line
- `TestAddSortsAlphabetically` — adds in reverse alphabetical order, file is sorted
- `TestAddAtomicWrite` — after successful `Add`, no `.known-modules-*.tmp` lingering
- `TestAddRejectsBadAddress` — `"not-a-valid-address"` → error, file unchanged
- `TestLoadSkipsMalformedLines` — mixed valid/garbage → garbage ignored
- `TestLoadStripsBlankLines` — trailing blank lines → empty fields not returned

### `cmd/diff_test.go` additions

- `TestDiffAddsToKnownAfterSuccess` — run diff with `--known-modules-file=<tempfile>`, assert file contains the address
- `TestDiffDoesNotAddOnFailure` — force a 404 → file unchanged
- `TestDiffValidArgsFunctionLoadsCache` — pre-seed cache, invoke completion, assert suggestions match

### `cmd/versions_test.go` additions

- `TestVersionsDoesNotAddToKnown` — run versions, assert file unchanged
- `TestVersionsValidArgsFunctionLoadsCache` — same completion check as diff

### `internal/tui/picker_test.go` additions

- `TestPickerAcceptsSuggestionsArg` — `NewPicker(ctx, nil, []string{"iac/comply/aws"}, "")` → textinput has the suggestion attached, `ShowSuggestions == true`
- `TestPickerNoSuggestionsLeavesShowSuggestionsFalse` — empty/nil → `ShowSuggestions == false`

### Not tested

- Shell-completion script generation (cobra owns it).
- Two-process concurrent writes (documented "last writer wins").
- Real `~/.config/birdseye/` interaction.

## Dependencies

No new third-party Go modules. Reuses:

- stdlib: `os`, `path/filepath`, `bufio`, `sort`, `strings`
- `services/crow/pkg/api` — `ValidateSegments` for address validation
- `github.com/charmbracelet/bubbles/textinput` — already in go.mod
- `github.com/spf13/cobra` — already in go.mod

## Implementation phases

A single plan should cover this end-to-end. Suggested ordering:

1. `internal/known/` package with TDD unit tests
2. `cmd/diff.go` wire-up: `--known-modules-file` flag, `ValidArgsFunction`, post-success `Add`, plus tests
3. `cmd/versions.go` wire-up: `ValidArgsFunction` (no Add), plus test
4. `internal/tui/picker.go` signature extension and `addKnownCmd`, plus tests
5. `runInteractive` wiring in `cmd/diff.go`

## Open questions

None. Cleared during brainstorming:

- Surface: both shell completion AND TUI suggestions.
- Triggers: CLI diff success + picker `ListVersions` success. `versions` subcommand intentionally excluded.
- Location: `os.UserConfigDir()/birdseye/known-modules`.
- Format: plain text, sorted, atomic temp+rename writes.
- Concurrency: last writer wins, documented in code.


File created successfully at: /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/docs/superpowers/specs/2026-05-13-birdseye-known-modules-design.md (file state is current in your context — no need to Read it back)

# Birdseye — known-modules autocomplete cache

**Status:** Draft for implementation
**Date:** 2026-05-13
**Owner:** steve.briggs@immuta.com
**Builds on:** `2026-05-12-birdseye-design.md`

## Summary

Persist the set of module addresses (`namespace/name/provider`) the user
has successfully diff'd or browsed in a small text file, and offer them
as autocomplete suggestions on subsequent invocations. Two surfaces
share the same on-disk cache: cobra shell-completion for the CLI
positional argument, and `bubbles/textinput` ghost-text suggestions in
the interactive TUI picker.

## Goals

- Zero-friction autocomplete: addresses the user has used before should
  surface automatically on the next invocation, both in shell tab-completion
  and the TUI textinput.
- Best-effort persistence: cache writes never block or fail the diff flow.
- Hermetic in tests: every test path injects a tempdir, so the host's
  real `~/.config/birdseye/known-modules` is never touched.

## Non-goals

- Tracking version history (we only remember the `ns/n/p` triple).
- LRU ordering, hit counts, or any per-entry metadata — alphabetical
  sort is fine for v1.
- A management CLI (`birdseye known clear`, `known list`). Users can
  inspect/delete the file with standard tools.
- Concurrent-write merge semantics. Two parallel birdseye processes
  writing at the same instant: last writer wins (documented).

## File location and format

**Path** resolved via `os.UserConfigDir()`:

- Linux: `~/.config/birdseye/known-modules`
- macOS: `~/Library/Application Support/birdseye/known-modules`

**Format:** plain text, one `namespace/name/provider` per line,
alphabetically sorted, single trailing newline. Empty file or missing
file → no suggestions, not an error.

Example:
```
iac/comply/aws
iac/eks-cluster/aws
platform/networking/aws
```

## Triggers — what adds to the cache

| Operation | Adds to cache? |
|---|---|
| `birdseye diff ns/n/p vA vB` (positional CLI), success | yes |
| Interactive picker (`birdseye diff`), `ListVersions` returns versions | yes |
| `birdseye versions ns/n/p`, success | **no** (intentionally excluded) |
| Any failed operation (404, network, archive corrupt) | no |
| Picker quit before completing version selection | yes (if `ListVersions` already succeeded) |

The picker case fires the moment Crow confirms the address resolves —
we know the address is valid even if the user changes their mind about
which versions to diff.

## High-level architecture

```
apps/birdseye/
  internal/
    known/
      known.go        # Path() / Load() / Add() — pure besides file I/O
      known_test.go
  cmd/
    diff.go           # ValidArgsFunction for first positional arg
                      # +known.Add after successful runDiff
                      # +diffOpts.KnownModulesFile flag (hidden)
    versions.go       # ValidArgsFunction for first positional arg (no Add)
  internal/tui/
    picker.go         # NewPicker(ctx, deps, suggestions, knownPath)
                      # textinput.SetSuggestions + ShowSuggestions
                      # +addKnownCmd fire-and-forget on versionsLoadedMsg
```

`internal/known` is a small, pure data layer. The cmd layer is the only
place that resolves the file path (so tests can inject a tempdir).

## Component interfaces

### `internal/known`

```go
// Path returns the default known-modules file path, honoring
// os.UserConfigDir(). The directory is NOT created here — Add creates
// it lazily on first write.
func Path() (string, error)

// Load reads the file at path. Returns an empty slice if the file
// does not exist (first run is normal). Each non-empty trimmed line
// is returned. The result is sorted ascending. Malformed lines
// (lines that don't pass the ns/name/provider segment validation
// from services/crow/pkg/api) are skipped silently.
func Load(path string) ([]string, error)

// Add inserts address into the file at path. Existing entries are
// preserved; duplicates deduplicated; the final list is sorted.
// Writes are atomic via temp file in the same directory + rename.
//
// The directory is created with 0o755 if missing. If address is not
// a well-formed ns/name/provider triple, Add returns an error
// without touching the file.
func Add(path, address string) error
```

Validation reuses `api.ValidateSegments` from `services/crow/pkg/api`.

### `cmd/diff.go`

```go
type diffOpts struct {
    Semantic         bool
    Plain            bool
    CacheDir         string
    KnownModulesFile string // NEW: defaulted to known.Path(), overridable
}
```

`--known-modules-file` is a hidden flag (test override). The diff
subcommand and versions subcommand each register a
`ValidArgsFunction` that returns:

```go
func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
    if len(args) > 0 {
        return nil, cobra.ShellCompDirectiveNoFileComp
    }
    suggestions, _ := known.Load(resolveKnownPath(o.KnownModulesFile))
    return suggestions, cobra.ShellCompDirectiveNoFileComp
}
```

`resolveKnownPath(override string) string` returns `override` if
non-empty, else `known.Path()`; on path-resolution error, returns
`""` so `Load` and `Add` gracefully no-op.

After `runDiff` succeeds on the positional path (and before returning),
the cmd layer calls:

```go
_ = known.Add(resolveKnownPath(o.KnownModulesFile),
    id.Namespace+"/"+id.Name+"/"+id.Provider)
```

Errors are swallowed.

### `cmd/versions.go`

Same `ValidArgsFunction` wiring as diff. **No** `Add` call.

### `internal/tui/picker.go`

```go
func NewPicker(ctx context.Context, deps *Deps, suggestions []string, knownPath string) *Picker
```

In `NewPicker`, after constructing `ti`:

```go
if len(suggestions) > 0 {
    ti.SetSuggestions(suggestions)
    ti.ShowSuggestions = true
}
```

`Picker` gains a `knownPath string` field.

In `updateLoading`, when `versionsLoadedMsg` arrives with no error and
the version list is non-empty, dispatch:

```go
return p, addKnownCmd(p.knownPath, p.moduleID)
```

Helper:

```go
func addKnownCmd(path string, id api.ModuleIdentifier) tea.Cmd {
    return func() tea.Msg {
        _ = known.Add(path, id.Namespace+"/"+id.Name+"/"+id.Provider)
        return nil
    }
}
```

The returned `nil` msg is a no-op — bubbletea ignores nil cleanly.

### `cmd/diff.go` `runInteractive` changes

```go
suggestions, _ := known.Load(resolveKnownPath(o.KnownModulesFile))
p := tui.NewPicker(ctx, &tui.Deps{Client: cli, Reg: reg},
    suggestions, resolveKnownPath(o.KnownModulesFile))
```

## Data flow

### CLI one-shot

```
$ birdseye diff iac/comply/aws 1.0.0 1.1.0

cobra ValidArgsFunction (when user presses TAB)
  -> known.Load(resolveKnownPath(o.KnownModulesFile))
  -> returns []string for completion

runDiff
  -> [existing: parse, validate, fetch, extract, diff, render]
  -> on success: known.Add(path, "iac/comply/aws")
```

### Interactive picker

```
$ birdseye diff

runInteractive
  -> known.Load(resolveKnownPath(...))    [synchronous, at startup]
  -> tui.NewPicker(ctx, deps, suggestions, knownPath)
  -> textinput.SetSuggestions(suggestions); ShowSuggestions=true
  -> tea.NewProgram(picker).Run()

picker.updateLoading (on versionsLoadedMsg success)
  -> populate versionsA, state = StateAskVersionA
  -> dispatch addKnownCmd(p.knownPath, p.moduleID)   [fire-and-forget]
```

## Error handling

| Failure | Behavior |
|---|---|
| `os.UserConfigDir()` fails | `Path()` returns the error. Caller falls back to `""`; `Load("")` and `Add("")` no-op. CLI behavior otherwise unchanged. |
| File doesn't exist (first run) | `Load` returns `(nil, nil)`. |
| Malformed file content | `Load` skips lines failing `api.ValidateSegments`. |
| `Add` rejects bad address | Returns error; caller swallows. File unchanged. |
| `Add` fails to write | Returns wrapped error; caller swallows. |
| Concurrent writers | Last writer wins. Documented in code. |

`Add` calls never block the diff. They run after the diff result has
been written/rendered.

## Testing strategy

TDD as elsewhere in this codebase. Race detector on every `go test`.
All paths use `t.TempDir()`; the real `~/.config/birdseye/` is never
touched.

### `internal/known/known_test.go`

- `TestLoadMissingFileReturnsEmpty` — first-run: no file, no error
- `TestAddCreatesDirectoryAndFile` — cold start: nested dir + file created
- `TestAddDeduplicates` — same address twice → file has one line
- `TestAddSortsAlphabetically` — adds in reverse alphabetical order, file is sorted
- `TestAddAtomicWrite` — after successful `Add`, no `.known-modules-*.tmp` lingering
- `TestAddRejectsBadAddress` — `"not-a-valid-address"` → error, file unchanged
- `TestLoadSkipsMalformedLines` — mixed valid/garbage → garbage ignored
- `TestLoadStripsBlankLines` — trailing blank lines → empty fields not returned

### `cmd/diff_test.go` additions

- `TestDiffAddsToKnownAfterSuccess` — run diff with `--known-modules-file=<tempfile>`, assert file contains the address
- `TestDiffDoesNotAddOnFailure` — force a 404 → file unchanged
- `TestDiffValidArgsFunctionLoadsCache` — pre-seed cache, invoke completion, assert suggestions match

### `cmd/versions_test.go` additions

- `TestVersionsDoesNotAddToKnown` — run versions, assert file unchanged
- `TestVersionsValidArgsFunctionLoadsCache` — same completion check as diff

### `internal/tui/picker_test.go` additions

- `TestPickerAcceptsSuggestionsArg` — `NewPicker(ctx, nil, []string{"iac/comply/aws"}, "")` → textinput has the suggestion attached, `ShowSuggestions == true`
- `TestPickerNoSuggestionsLeavesShowSuggestionsFalse` — empty/nil → `ShowSuggestions == false`

### Not tested

- Shell-completion script generation (cobra owns it).
- Two-process concurrent writes (documented "last writer wins").
- Real `~/.config/birdseye/` interaction.

## Dependencies

No new third-party Go modules. Reuses:

- stdlib: `os`, `path/filepath`, `bufio`, `sort`, `strings`
- `services/crow/pkg/api` — `ValidateSegments` for address validation
- `github.com/charmbracelet/bubbles/textinput` — already in go.mod
- `github.com/spf13/cobra` — already in go.mod

## Implementation phases

A single plan should cover this end-to-end. Suggested ordering:

1. `internal/known/` package with TDD unit tests
2. `cmd/diff.go` wire-up: `--known-modules-file` flag, `ValidArgsFunction`, post-success `Add`, plus tests
3. `cmd/versions.go` wire-up: `ValidArgsFunction` (no Add), plus test
4. `internal/tui/picker.go` signature extension and `addKnownCmd`, plus tests
5. `runInteractive` wiring in `cmd/diff.go`

## Open questions

None. Cleared during brainstorming:

- Surface: both shell completion AND TUI suggestions.
- Triggers: CLI diff success + picker `ListVersions` success. `versions` subcommand intentionally excluded.
- Location: `os.UserConfigDir()/birdseye/known-modules`.
- Format: plain text, sorted, atomic temp+rename writes.
- Concurrency: last writer wins, documented in code.


Spec written. Self-review pass:

- ✅ No TBDs / TODOs
- ✅ File location consistent across sections (UserConfigDir / `~/.config/birdseye/known-modules`)
- ✅ Triggers consistent: CLI diff success + picker `ListVersions` success, `versions` subcommand intentionally excluded
- ✅ `NewPicker(ctx, deps, suggestions, knownPath)` signature appears identically in both Component interfaces and Data flow sections
- ✅ No double-Add risk: the picker handles interactive-flow caching; CLI positional path handles its own; never both for the same invocation
- ✅ Single focused feature, fits one implementation plan

Spec is at `docs/superpowers/specs/2026-05-13-birdseye-known-modules-design.md` (inside the worktree this time, so it'll travel with the feature branch). Please give it a read and let me know if you want changes before I move to writing-plans.


move forward my G this looks good to me, type shit


Updated task #15 status


Updated task #16 status


Launching skill: superpowers:writing-plans


Base directory for this skill: /Users/steve.briggs/.claude/plugins/cache/claude-plugins-official/superpowers/5.1.0/skills/writing-plans

# Writing Plans

## Overview

Write comprehensive implementation plans assuming the engineer has zero context for our codebase and questionable taste. Document everything they need to know: which files to touch for each task, code, testing, docs they might need to check, how to test it. Give them the whole plan as bite-sized tasks. DRY. YAGNI. TDD. Frequent commits.

Assume they are a skilled developer, but know almost nothing about our toolset or problem domain. Assume they don't know good test design very well.

**Announce at start:** "I'm using the writing-plans skill to create the implementation plan."

**Context:** If working in an isolated worktree, it should have been created via the `superpowers:using-git-worktrees` skill at execution time.

**Save plans to:** `docs/superpowers/plans/YYYY-MM-DD-<feature-name>.md`
- (User preferences for plan location override this default)

## Scope Check

If the spec covers multiple independent subsystems, it should have been broken into sub-project specs during brainstorming. If it wasn't, suggest breaking this into separate plans — one per subsystem. Each plan should produce working, testable software on its own.

## File Structure

Before defining tasks, map out which files will be created or modified and what each one is responsible for. This is where decomposition decisions get locked in.

- Design units with clear boundaries and well-defined interfaces. Each file should have one clear responsibility.
- You reason best about code you can hold in context at once, and your edits are more reliable when files are focused. Prefer smaller, focused files over large ones that do too much.
- Files that change together should live together. Split by responsibility, not by technical layer.
- In existing codebases, follow established patterns. If the codebase uses large files, don't unilaterally restructure - but if a file you're modifying has grown unwieldy, including a split in the plan is reasonable.

This structure informs the task decomposition. Each task should produce self-contained changes that make sense independently.

## Bite-Sized Task Granularity

**Each step is one action (2-5 minutes):**
- "Write the failing test" - step
- "Run it to make sure it fails" - step
- "Implement the minimal code to make the test pass" - step
- "Run the tests and make sure they pass" - step
- "Commit" - step

## Plan Document Header

**Every plan MUST start with this header:**

```markdown
# [Feature Name] Implementation Plan

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

**Goal:** [One sentence describing what this builds]

**Architecture:** [2-3 sentences about approach]

**Tech Stack:** [Key technologies/libraries]

---
```

## Task Structure

````markdown
### Task N: [Component Name]

**Files:**
- Create: `exact/path/to/file.py`
- Modify: `exact/path/to/existing.py:123-145`
- Test: `tests/exact/path/to/test.py`

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

```python
def test_specific_behavior():
    result = function(input)
    assert result == expected
```

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

Run: `pytest tests/path/test.py::test_name -v`
Expected: FAIL with "function not defined"

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

```python
def function(input):
    return expected
```

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

Run: `pytest tests/path/test.py::test_name -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add tests/path/test.py src/path/file.py
git commit -m "feat: add specific feature"
```
````

## No Placeholders

Every step must contain the actual content an engineer needs. These are **plan failures** — never write them:
- "TBD", "TODO", "implement later", "fill in details"
- "Add appropriate error handling" / "add validation" / "handle edge cases"
- "Write tests for the above" (without actual test code)
- "Similar to Task N" (repeat the code — the engineer may be reading tasks out of order)
- Steps that describe what to do without showing how (code blocks required for code steps)
- References to types, functions, or methods not defined in any task

## Remember
- Exact file paths always
- Complete code in every step — if a step changes code, show the code
- Exact commands with expected output
- DRY, YAGNI, TDD, frequent commits

## Self-Review

After writing the complete plan, look at the spec with fresh eyes and check the plan against it. This is a checklist you run yourself — not a subagent dispatch.

**1. Spec coverage:** Skim each section/requirement in the spec. Can you point to a task that implements it? List any gaps.

**2. Placeholder scan:** Search your plan for red flags — any of the patterns from the "No Placeholders" section above. Fix them.

**3. Type consistency:** Do the types, method signatures, and property names you used in later tasks match what you defined in earlier tasks? A function called `clearLayers()` in Task 3 but `clearFullLayers()` in Task 7 is a bug.

If you find issues, fix them inline. No need to re-review — just fix and move on. If you find a spec requirement with no task, add the task.

## Execution Handoff

After saving the plan, offer execution choice:

**"Plan complete and saved to `docs/superpowers/plans/<filename>.md`. Two execution options:**

**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration

**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints

**Which approach?"**

**If Subagent-Driven chosen:**
- **REQUIRED SUB-SKILL:** Use superpowers:subagent-driven-development
- Fresh subagent per task + two-stage review

**If Inline Execution chosen:**
- **REQUIRED SUB-SKILL:** Use superpowers:executing-plans
- Batch execution with checkpoints for review


I'm using the writing-plans skill to create the implementation plan.


Bash

Create plans dir in worktree


Write

# Birdseye Known-Modules Autocomplete Cache Implementation Plan

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

**Goal:** Persist user-confirmed module addresses (`namespace/name/provider`) in a plain-text file at `os.UserConfigDir()/birdseye/known-modules`, and offer them as suggestions through cobra shell completion (CLI) and `bubbles/textinput` ghost-text (TUI picker).

**Architecture:** A new `internal/known` package handles file I/O (Path/Load/Add) with atomic temp+rename writes. The `cmd` layer wires shell completion via cobra's `ValidArgsFunction` and calls `Add` after successful diffs. The `tui/picker` accepts pre-loaded suggestions and dispatches a fire-and-forget `Add` after `ListVersions` succeeds.

**Tech Stack:** Go 1.25.5, stdlib (`os`, `path/filepath`, `bufio`, `sort`, `strings`), cobra `ValidArgsFunction`, `bubbles/textinput.SetSuggestions`. Reuses `api.ValidateSegments` from `services/crow/pkg/api`. No new third-party deps.

**Spec:** `docs/superpowers/specs/2026-05-13-birdseye-known-modules-design.md`

**Conventions:**
- TDD: red → green → commit. Run `go test -race ./...` before every commit.
- Absolute paths in shell commands; never `git add .` or `git add -A`.
- Conventional commits: `feat(birdseye): ...`, `test(birdseye): ...`.

---

## Task 1: `internal/known` — Path, Load, Add (happy paths)

**Files:**
- Create: `apps/birdseye/internal/known/known.go`
- Create: `apps/birdseye/internal/known/known_test.go`

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

```go
package known

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

func TestLoadMissingFileReturnsEmpty(t *testing.T) {
	dir := t.TempDir()
	got, err := Load(filepath.Join(dir, "missing"))
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if len(got) != 0 {
		t.Errorf("expected empty slice, got %v", got)
	}
}

func TestAddCreatesDirectoryAndFile(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "subdir", "known-modules")
	if err := Add(path, "iac/comply/aws"); err != nil {
		t.Fatalf("Add: %v", err)
	}
	body, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("ReadFile: %v", err)
	}
	if string(body) != "iac/comply/aws\n" {
		t.Errorf("body = %q", body)
	}
}

func TestAddThenLoadRoundTrip(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "known-modules")
	if err := Add(path, "iac/comply/aws"); err != nil {
		t.Fatalf("Add: %v", err)
	}
	got, err := Load(path)
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if len(got) != 1 || got[0] != "iac/comply/aws" {
		t.Errorf("Load = %v, want [iac/comply/aws]", got)
	}
}

func TestPathHonorsUserConfigDir(t *testing.T) {
	got, err := Path()
	if err != nil {
		t.Fatalf("Path: %v", err)
	}
	if !filepath.IsAbs(got) {
		t.Errorf("expected absolute path, got %q", got)
	}
	base := filepath.Base(got)
	if base != "known-modules" {
		t.Errorf("expected basename %q, got %q", "known-modules", base)
	}
	parent := filepath.Base(filepath.Dir(got))
	if parent != "birdseye" {
		t.Errorf("expected parent dir %q, got %q", "birdseye", parent)
	}
}
```

- [ ] **Step 2: Run, expect FAIL** (`undefined: Load`, etc.)

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/known/...`
Expected: compile error.

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

```go
// Package known persists a sorted, deduplicated list of Crow module
// addresses the user has successfully diff'd or browsed, so future
// invocations can offer shell-completion and TUI suggestions.
//
// The on-disk format is plain text, one ns/name/provider per line,
// alphabetically sorted, with a trailing newline. Empty or missing
// file means "no suggestions yet" — not an error.
//
// Concurrent writers: last writer wins. Two birdseye processes running
// simultaneously may each see the file before the other's Rename
// lands; whichever Rename completes last sets the final contents.
// Acceptable for a low-frequency, append-only-ish list.
package known

import (
	"bufio"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"

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

// Path returns the default known-modules file path, honoring
// os.UserConfigDir() (XDG_CONFIG_HOME on Linux, Application Support on
// macOS). The directory is NOT created here — Add creates it lazily on
// first write.
func Path() (string, error) {
	cfg, err := os.UserConfigDir()
	if err != nil {
		return "", fmt.Errorf("resolve user config dir: %w", err)
	}
	return filepath.Join(cfg, "birdseye", "known-modules"), nil
}

// Load reads the file at path. Returns an empty slice if the file
// does not exist (first run is normal). Lines failing the
// ns/name/provider validation are silently skipped. The result is
// sorted ascending.
func Load(path string) ([]string, error) {
	f, err := os.Open(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, fmt.Errorf("open known-modules: %w", err)
	}
	defer f.Close()

	var out []string
	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if line == "" {
			continue
		}
		if !validAddress(line) {
			continue
		}
		out = append(out, line)
	}
	if err := scanner.Err(); err != nil {
		return nil, fmt.Errorf("scan known-modules: %w", err)
	}
	sort.Strings(out)
	return out, nil
}

// Add inserts address into the file at path. Existing entries are
// preserved; duplicates deduplicated; the final list is sorted.
// Writes are atomic via temp file + rename in the same directory.
// The directory is created with 0o755 if missing. If address is not a
// well-formed ns/name/provider triple, Add returns an error without
// touching the file.
func Add(path, address string) error {
	if !validAddress(address) {
		return fmt.Errorf("invalid module address %q", address)
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return fmt.Errorf("mkdir known-modules dir: %w", err)
	}

	existing, err := Load(path)
	if err != nil {
		return err
	}
	merged := mergeAndSort(existing, address)

	tmp, err := os.CreateTemp(filepath.Dir(path), ".known-modules-*.tmp")
	if err != nil {
		return fmt.Errorf("create temp: %w", err)
	}
	tmpName := tmp.Name()
	w := bufio.NewWriter(tmp)
	for _, addr := range merged {
		if _, err := w.WriteString(addr); err != nil {
			_ = tmp.Close()
			_ = os.Remove(tmpName)
			return fmt.Errorf("write temp: %w", err)
		}
		if err := w.WriteByte('\n'); err != nil {
			_ = tmp.Close()
			_ = os.Remove(tmpName)
			return fmt.Errorf("write temp: %w", err)
		}
	}
	if err := w.Flush(); err != nil {
		_ = tmp.Close()
		_ = os.Remove(tmpName)
		return fmt.Errorf("flush temp: %w", err)
	}
	if err := tmp.Close(); err != nil {
		_ = os.Remove(tmpName)
		return fmt.Errorf("close temp: %w", err)
	}
	if err := os.Rename(tmpName, path); err != nil {
		_ = os.Remove(tmpName)
		return fmt.Errorf("rename temp: %w", err)
	}
	return nil
}

// mergeAndSort returns a deduplicated, sorted slice combining existing
// with the new address.
func mergeAndSort(existing []string, address string) []string {
	seen := make(map[string]struct{}, len(existing)+1)
	out := make([]string, 0, len(existing)+1)
	for _, a := range existing {
		if _, dup := seen[a]; dup {
			continue
		}
		seen[a] = struct{}{}
		out = append(out, a)
	}
	if _, dup := seen[address]; !dup {
		out = append(out, address)
	}
	sort.Strings(out)
	return out
}

// validAddress checks that s is exactly three slash-separated segments
// each passing api.ValidateSegments.
func validAddress(s string) bool {
	parts := strings.Split(s, "/")
	if len(parts) != 3 {
		return false
	}
	return api.ValidateSegments(parts) == nil
}
```

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

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

- [ ] **Step 5: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/known/ && git commit -m "feat(birdseye): known-modules autocomplete cache with Path/Load/Add"
```

---

## Task 2: `internal/known` — hardening tests

**Files:**
- Modify: `apps/birdseye/internal/known/known_test.go`

- [ ] **Step 1: Append failing tests** to `apps/birdseye/internal/known/known_test.go`:

```go
import (
	"strings"
	// existing imports preserved
)

func TestAddDeduplicates(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "known-modules")
	for i := 0; i < 3; i++ {
		if err := Add(path, "iac/comply/aws"); err != nil {
			t.Fatalf("Add iteration %d: %v", i, err)
		}
	}
	body, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	lines := strings.Split(strings.TrimRight(string(body), "\n"), "\n")
	if len(lines) != 1 {
		t.Errorf("expected 1 unique line, got %d: %v", len(lines), lines)
	}
}

func TestAddSortsAlphabetically(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "known-modules")
	for _, addr := range []string{"z/y/x", "a/b/c", "m/n/o"} {
		if err := Add(path, addr); err != nil {
			t.Fatalf("Add %s: %v", addr, err)
		}
	}
	got, err := Load(path)
	if err != nil {
		t.Fatal(err)
	}
	want := []string{"a/b/c", "m/n/o", "z/y/x"}
	if len(got) != len(want) {
		t.Fatalf("got %d lines, want %d", len(got), len(want))
	}
	for i := range got {
		if got[i] != want[i] {
			t.Errorf("got[%d] = %s, want %s", i, got[i], want[i])
		}
	}
}

func TestAddAtomicNoLingeringTemp(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "known-modules")
	if err := Add(path, "iac/comply/aws"); err != nil {
		t.Fatalf("Add: %v", err)
	}
	entries, err := os.ReadDir(dir)
	if err != nil {
		t.Fatal(err)
	}
	for _, e := range entries {
		if strings.HasPrefix(e.Name(), ".known-modules-") && strings.HasSuffix(e.Name(), ".tmp") {
			t.Errorf("found lingering temp file: %s", e.Name())
		}
	}
}

func TestAddRejectsBadAddress(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "known-modules")
	cases := []string{
		"not-three-parts",
		"two/parts",
		"four/parts/here/extra",
		"has spaces/in/it",
		"",
	}
	for _, addr := range cases {
		t.Run(addr, func(t *testing.T) {
			if err := Add(path, addr); err == nil {
				t.Errorf("expected error for %q, got nil", addr)
			}
		})
	}
	// File must not have been created.
	if _, err := os.Stat(path); !os.IsNotExist(err) {
		t.Errorf("expected file to NOT exist, got err=%v", err)
	}
}

func TestLoadSkipsMalformedLines(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "known-modules")
	content := "iac/comply/aws\nnot-valid\ntwo/parts\nplatform/networking/aws\n"
	if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
		t.Fatal(err)
	}
	got, err := Load(path)
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	want := []string{"iac/comply/aws", "platform/networking/aws"}
	if len(got) != len(want) {
		t.Fatalf("got %v, want %v", got, want)
	}
	for i := range got {
		if got[i] != want[i] {
			t.Errorf("got[%d] = %s, want %s", i, got[i], want[i])
		}
	}
}

func TestLoadSkipsBlankLines(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "known-modules")
	content := "\niac/comply/aws\n\n\nplatform/networking/aws\n"
	if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
		t.Fatal(err)
	}
	got, err := Load(path)
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if len(got) != 2 {
		t.Errorf("got %d lines, want 2: %v", len(got), got)
	}
}
```

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/known/... -v`
Expected: All 10 tests PASS (4 from Task 1 + 6 new).

- [ ] **Step 3: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/known/known_test.go && git commit -m "test(birdseye): harden known-modules cache (dedup, sort, atomic, validation)"
```

---

## Task 3: `cmd/diff` — `--known-modules-file` flag + post-success Add + tests

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

- [ ] **Step 1: Write failing tests** — append to `apps/birdseye/cmd/diff_test.go`:

```go
import (
	// existing imports preserved
	"os"
	"path/filepath"
)

func TestDiffAddsToKnownAfterSuccess(t *testing.T) {
	srv := fakeCrowTwoVersions(t)
	knownFile := filepath.Join(t.TempDir(), "known-modules")
	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",
		"--cache-dir", t.TempDir(),
		"--known-modules-file", knownFile,
	})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v", err)
	}
	body, err := os.ReadFile(knownFile)
	if err != nil {
		t.Fatalf("known-modules file not created: %v", err)
	}
	if !strings.Contains(string(body), "iac/comply/aws") {
		t.Errorf("known-modules missing address:\n%s", body)
	}
}

func TestDiffDoesNotAddOnFailure(t *testing.T) {
	// fakeCrow with only one of the two requested versions
	srv := fakeCrow(t, map[string][]byte{"1.0.0": []byte("body-100")})
	knownFile := filepath.Join(t.TempDir(), "known-modules")
	root := NewRootCmd()
	root.SetErr(&bytes.Buffer{})
	root.SetArgs([]string{
		"diff",
		"iac/comply/aws", "1.0.0", "9.9.9",
		"--crow-url", srv.URL,
		"--plain",
		"--cache-dir", t.TempDir(),
		"--known-modules-file", knownFile,
	})
	err := root.ExecuteContext(context.Background())
	if err == nil {
		t.Fatal("expected error for missing version")
	}
	if _, statErr := os.Stat(knownFile); !os.IsNotExist(statErr) {
		t.Errorf("known-modules file should NOT exist after failure, stat err=%v", statErr)
	}
}
```

NOTE: `fakeCrow` exists at `apps/birdseye/internal/registry/registry_test.go` — different package. Since `cmd` does NOT import `registry`'s tests, we need a `fakeCrow` helper in cmd's test file. The existing `fakeCrowTwoVersions` in `diff_test.go` already covers the success path. For the failure test, write a minimal failing-version helper inline:

Replace the body of `TestDiffDoesNotAddOnFailure` with the inline server below (do not reuse the registry-package helper):

```go
func TestDiffDoesNotAddOnFailure(t *testing.T) {
	mux := http.NewServeMux()
	// All download attempts return 404
	mux.HandleFunc("/v1/modules/", func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusNotFound)
	})
	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)

	knownFile := filepath.Join(t.TempDir(), "known-modules")
	root := NewRootCmd()
	root.SetErr(&bytes.Buffer{})
	root.SetArgs([]string{
		"diff",
		"iac/comply/aws", "1.0.0", "1.1.0",
		"--crow-url", srv.URL,
		"--plain",
		"--cache-dir", t.TempDir(),
		"--known-modules-file", knownFile,
	})
	if err := root.ExecuteContext(context.Background()); err == nil {
		t.Fatal("expected error for 404s")
	}
	if _, statErr := os.Stat(knownFile); !os.IsNotExist(statErr) {
		t.Errorf("known-modules file should NOT exist after failure, stat err=%v", statErr)
	}
}
```

- [ ] **Step 2: Run, expect FAIL** (`unknown flag --known-modules-file`)

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

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

Add the field to `diffOpts`:

```go
type diffOpts struct {
	Semantic         bool
	Plain            bool
	CacheDir         string
	KnownModulesFile string // NEW
}
```

Add the flag in `newDiffCmd`:

```go
c.Flags().StringVar(&o.KnownModulesFile, "known-modules-file", "", "override known-modules autocomplete file (mostly for tests)")
_ = c.Flags().MarkHidden("known-modules-file")
```

Add a helper at file scope (below `defaultCacheDir`):

```go
// resolveKnownPath returns override if non-empty, otherwise the default
// known.Path(). Errors fall back to "" so Load and Add become no-ops.
func resolveKnownPath(override string) string {
	if override != "" {
		return override
	}
	p, err := known.Path()
	if err != nil {
		return ""
	}
	return p
}
```

Add `"github.com/immuta/operations/apps/birdseye/internal/known"` to the import block.

At the END of `runDiff`, BEFORE the final TUI/plain/JSON render dispatch, add the best-effort Add:

```go
	if err := known.Add(resolveKnownPath(o.KnownModulesFile),
		id.Namespace+"/"+id.Name+"/"+id.Provider); err != nil {
		_ = err // best-effort: failure does not block the diff
	}
```

(Place this immediately AFTER `result := diff.Result{Files: ...}` and any `if o.Semantic` block, but BEFORE the `if g.JSON { ... }` / `if !o.Plain && isTTY(...) { ... }` / `return renderPlain(...)` block. Adding before the render means a failed render does NOT prevent the cache update, but a failed extract/fetch DOES — exactly what the spec wants.)

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./cmd/... -v`
Expected: All previous + 2 new tests PASS.

- [ ] **Step 5: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/cmd/ && git commit -m "feat(birdseye): cache module address after successful CLI diff"
```

---

## Task 4: `cmd/diff` + `cmd/versions` — ValidArgsFunction for shell completion

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

- [ ] **Step 1: Write failing tests** — append to `apps/birdseye/cmd/diff_test.go`:

```go
func TestDiffValidArgsFunctionLoadsCache(t *testing.T) {
	knownFile := filepath.Join(t.TempDir(), "known-modules")
	if err := os.WriteFile(knownFile, []byte("alpha/aaa/aws\nbravo/bbb/gcp\n"), 0o600); err != nil {
		t.Fatal(err)
	}

	root := NewRootCmd()
	// Drive cobra completion via the __complete pseudo-command.
	root.SetArgs([]string{"__complete", "diff", "--known-modules-file", knownFile, ""})
	var stdout bytes.Buffer
	root.SetOut(&stdout)
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute completion: %v", err)
	}
	out := stdout.String()
	for _, want := range []string{"alpha/aaa/aws", "bravo/bbb/gcp"} {
		if !strings.Contains(out, want) {
			t.Errorf("completion output missing %q\n%s", want, out)
		}
	}
}
```

And to `apps/birdseye/cmd/versions_test.go`:

```go
import (
	// existing imports preserved
	"os"
	"path/filepath"
)

func TestVersionsValidArgsFunctionLoadsCache(t *testing.T) {
	knownFile := filepath.Join(t.TempDir(), "known-modules")
	if err := os.WriteFile(knownFile, []byte("alpha/aaa/aws\n"), 0o600); err != nil {
		t.Fatal(err)
	}

	root := NewRootCmd()
	root.SetArgs([]string{"__complete", "versions", "--known-modules-file", knownFile, ""})
	var stdout bytes.Buffer
	root.SetOut(&stdout)
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute completion: %v", err)
	}
	if !strings.Contains(stdout.String(), "alpha/aaa/aws") {
		t.Errorf("completion missing entry:\n%s", stdout.String())
	}
}

func TestVersionsDoesNotAddToKnown(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode(api.ListVersionsResponse{
			Modules: []api.ModuleVersions{
				{Versions: []api.VersionEntry{{Version: "1.0.0"}}},
			},
		})
	}))
	defer srv.Close()

	knownFile := filepath.Join(t.TempDir(), "known-modules")
	root := NewRootCmd()
	var stdout bytes.Buffer
	root.SetOut(&stdout)
	root.SetArgs([]string{"versions", "iac/comply/aws",
		"--crow-url", srv.URL,
		"--known-modules-file", knownFile,
	})
	if err := root.ExecuteContext(context.Background()); err != nil {
		t.Fatalf("execute: %v", err)
	}
	if _, statErr := os.Stat(knownFile); !os.IsNotExist(statErr) {
		t.Errorf("known-modules file should NOT exist after versions, stat err=%v", statErr)
	}
}
```

- [ ] **Step 2: Run, expect FAIL** (versions has no `--known-modules-file` flag yet; diff has no `ValidArgsFunction`)

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

- [ ] **Step 3: Add `ValidArgsFunction` to `newDiffCmd` in `apps/birdseye/cmd/diff.go`**

Just before the closing `return c` of `newDiffCmd`, add:

```go
	c.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
		if len(args) > 0 {
			return nil, cobra.ShellCompDirectiveNoFileComp
		}
		suggestions, _ := known.Load(resolveKnownPath(o.KnownModulesFile))
		return suggestions, cobra.ShellCompDirectiveNoFileComp
	}
```

- [ ] **Step 4: Update `apps/birdseye/cmd/versions.go`**

Add the matching flag + completion to `newVersionsCmd`. Replace the existing function body:

```go
func newVersionsCmd(g *GlobalFlags) *cobra.Command {
	var knownModulesFile string
	c := &cobra.Command{
		Use:           "versions <module>",
		Short:         "List published versions of a Crow module",
		Args:          cobra.ExactArgs(1),
		SilenceUsage:  true,
		SilenceErrors: true,
		RunE: func(cmd *cobra.Command, args []string) error {
			id, err := parseModuleAddress(args[0])
			if err != nil {
				return err
			}
			cli := client.New(g.CrowURL, client.WithHTTPClient(newHTTPClient(g.Timeout)))
			versions, err := cli.ListVersions(cmd.Context(), id)
			if err != nil {
				return err
			}
			for _, v := range versions {
				fmt.Fprintln(cmd.OutOrStdout(), v)
			}
			return nil
		},
	}
	c.Flags().StringVar(&knownModulesFile, "known-modules-file", "", "override known-modules autocomplete file (mostly for tests)")
	_ = c.Flags().MarkHidden("known-modules-file")
	c.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
		if len(args) > 0 {
			return nil, cobra.ShellCompDirectiveNoFileComp
		}
		suggestions, _ := known.Load(resolveKnownPath(knownModulesFile))
		return suggestions, cobra.ShellCompDirectiveNoFileComp
	}
	return c
}
```

Add `"github.com/immuta/operations/apps/birdseye/internal/known"` to the imports.

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./cmd/... -v`
Expected: All previous + 3 new tests PASS.

- [ ] **Step 6: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/cmd/ && git commit -m "feat(birdseye): shell completion for diff and versions from known-modules cache"
```

---

## Task 5: `internal/tui/picker` — suggestions + fire-and-forget Add + wire `runInteractive`

**Files:**
- Modify: `apps/birdseye/internal/tui/picker.go`
- Modify: `apps/birdseye/internal/tui/picker_test.go`
- Modify: `apps/birdseye/cmd/diff.go`

- [ ] **Step 1: Append failing tests** to `apps/birdseye/internal/tui/picker_test.go`:

```go
import (
	// existing: testing, tea
	"context"
)

func TestPickerAcceptsSuggestionsArg(t *testing.T) {
	p := NewPicker(context.Background(), nil, []string{"iac/comply/aws", "platform/networking/aws"}, "")
	if !p.input.ShowSuggestions {
		t.Error("expected ShowSuggestions == true when suggestions provided")
	}
	// bubbles/textinput stores suggestions internally; we can't easily
	// inspect them directly, but ShowSuggestions=true confirms the
	// setter ran (we set it conditionally on len > 0).
}

func TestPickerNoSuggestionsLeavesShowSuggestionsFalse(t *testing.T) {
	p := NewPicker(context.Background(), nil, nil, "")
	if p.input.ShowSuggestions {
		t.Error("expected ShowSuggestions == false when suggestions are nil/empty")
	}
}

func TestPickerEmptySuggestionsLeavesShowSuggestionsFalse(t *testing.T) {
	p := NewPicker(context.Background(), nil, []string{}, "")
	if p.input.ShowSuggestions {
		t.Error("expected ShowSuggestions == false when suggestions slice is empty")
	}
}
```

- [ ] **Step 2: Update existing picker tests** in `apps/birdseye/internal/tui/picker_test.go` to use the new `NewPicker` signature

Find every existing `NewPicker(context.Background(), nil)` call and replace with `NewPicker(context.Background(), nil, nil, "")`. There are three such calls in the existing tests:

- `TestPickerStartsAtAskModule`
- `TestPickerAdvancesOnEnter`
- `TestPickerRejectsBadAddressInline`

Each becomes `p := NewPicker(context.Background(), nil, nil, "")`.

- [ ] **Step 3: Run, expect FAIL** (`NewPicker` signature mismatch)

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

- [ ] **Step 4: Update `apps/birdseye/internal/tui/picker.go`**

Change the `Picker` struct to add a `knownPath` field. Locate the `type Picker struct` and add `knownPath string` to the field list.

Change `NewPicker` signature and body:

```go
func NewPicker(ctx context.Context, deps *Deps, suggestions []string, knownPath string) *Picker {
	ti := textinput.New()
	ti.Placeholder = "namespace/name/provider"
	ti.Focus()
	if len(suggestions) > 0 {
		ti.SetSuggestions(suggestions)
		ti.ShowSuggestions = true
	}
	sp := spinner.New()
	sp.Spinner = spinner.Dot
	return &Picker{
		ctx:       ctx,
		deps:      deps,
		state:     StateAskModule,
		input:     ti,
		spinner:   sp,
		knownPath: knownPath,
	}
}
```

Add the import for `known`:

```go
"github.com/immuta/operations/apps/birdseye/internal/known"
```

Modify `updateLoading` — in the `versionsLoadedMsg` branch, BEFORE returning, dispatch the fire-and-forget `Add`. Replace the existing branch:

```go
	case versionsLoadedMsg:
		if m.err != nil {
			p.errMessage = m.err.Error()
			p.state = StateError
			return p, nil
		}
		items := make([]list.Item, 0, len(m.versions))
		for _, v := range m.versions {
			items = append(items, versionItem(v))
		}
		p.versionsA = list.New(items, list.NewDefaultDelegate(), p.width, p.height-4)
		p.versionsA.Title = "Select version A"
		p.state = StateAskVersionA
		return p, addKnownCmd(p.knownPath, p.moduleID)
```

Add `addKnownCmd` at file scope (below `loadPairCmd`):

```go
// addKnownCmd dispatches a fire-and-forget known.Add. The returned
// tea.Msg is nil — bubbletea ignores nil messages cleanly. Errors are
// swallowed (best-effort cache update).
func addKnownCmd(path string, id api.ModuleIdentifier) tea.Cmd {
	if path == "" {
		return nil
	}
	addr := id.Namespace + "/" + id.Name + "/" + id.Provider
	return func() tea.Msg {
		_ = known.Add(path, addr)
		return nil
	}
}
```

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

Replace the `tui.NewPicker(...)` call:

Before:
```go
	p := tui.NewPicker(ctx, &tui.Deps{Client: cli, Reg: reg})
```

After:
```go
	knownPath := resolveKnownPath(o.KnownModulesFile)
	suggestions, _ := known.Load(knownPath)
	p := tui.NewPicker(ctx, &tui.Deps{Client: cli, Reg: reg}, suggestions, knownPath)
```

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... -v`
Expected: all PASS — existing + 3 new picker tests + 3 existing picker tests updated to new signature.

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

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

- [ ] **Step 8: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/tui/ apps/birdseye/cmd/diff.go && git commit -m "feat(birdseye): TUI textinput suggestions and best-effort cache from interactive picker"
```

---

## Plan self-review

**Spec coverage:**
- `internal/known` Path/Load/Add ✓ Tasks 1, 2
- Atomic temp+rename writes ✓ Task 1 implementation, Task 2 test
- Dedup + sort ✓ Task 1 implementation, Task 2 tests
- Malformed line skipping ✓ Task 1 `validAddress` filter, Task 2 test
- Bad-address rejection ✓ Task 1 implementation, Task 2 test
- `--known-modules-file` hidden flag on `diff` ✓ Task 3
- `--known-modules-file` hidden flag on `versions` ✓ Task 4
- Post-success `Add` from CLI diff ✓ Task 3
- `Add` NOT called from `versions` ✓ Task 4 `TestVersionsDoesNotAddToKnown`
- `Add` NOT called on failure ✓ Task 3 `TestDiffDoesNotAddOnFailure`
- Cobra `ValidArgsFunction` on diff and versions ✓ Task 4
- TUI suggestions via `SetSuggestions` / `ShowSuggestions` ✓ Task 5
- `addKnownCmd` fire-and-forget on `versionsLoadedMsg` ✓ Task 5
- `runInteractive` loads suggestions before picker ✓ Task 5 Step 5

**Placeholder scan:** No TBDs, no "implement later", every step has concrete code.

**Type consistency:**
- `NewPicker(ctx, deps, suggestions, knownPath)` — same 4-arg signature in Task 5 step 4 and step 5 ✓
- `known.Path() (string, error)`, `Load(path string) ([]string, error)`, `Add(path, address string) error` — same signatures across Tasks 1-5 ✓
- `resolveKnownPath(override string) string` — declared in Task 3, reused in Tasks 4 and 5 ✓
- `diffOpts.KnownModulesFile` — declared in Task 3, referenced in Task 5 ✓

**Open caveats:**
- The picker has an existing `ctx` field added in the Task 13 fix (from the prior session). Verify it exists by reading `picker.go` before Step 4; if absent, add it in this task. The Task 5 implementation here ASSUMES it exists.
- `bubbles/textinput.SetSuggestions` is available in `v0.21.0` per go.mod (Task 1 of prior plan). If the API differs in this exact version, fall back to setting `ti.SetSuggestions(...)` directly with whatever the actual method name is.