# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## CRITICAL INSTRUCTIONS

**DO NOT RUN `git commit`** - This is explicitly forbidden. Use `git add`, `git status`, `git diff`, and `git log` only. The user will commit manually.

**ALWAYS run `make lint` and `make test`** before claiming success.

**DO NOT modify `.golangci.yml`** without explicit approval.

## Project Overview

FinFocus Core is a CLI tool and plugin host system for calculating cloud infrastructure costs from Pulumi infrastructure definitions. It provides both projected cost estimates and actual historical cost analysis through a plugin-based architecture.

## Specs

Feature specs, plans, and tasks live in `specs/NNN-*/` and are produced by the
**Spec Kit** pipeline (`.specify/` templates, `.specify/memory/constitution.md`,
and the `/speckit-*` skills). Spec Kit is the ONLY spec/plan/task pipeline:
design or plan output from other tools (e.g. superpowers brainstorming or
writing-plans) MUST be converted into Spec Kit feature folders under `specs/`
before implementation, not committed elsewhere in the repo.

## Build Commands

```bash
make tools         # Install toolchain pinned in mise.toml (first-time setup)
make build         # Build binary to bin/finfocus
make test          # Run unit tests (default, fast)
make test-race     # Run with race detector
make test-integration  # Integration tests (slower)
make test-e2e      # E2E tests (requires AWS credentials)
make lint          # Run golangci-lint + markdownlint
make validate      # go mod tidy, go vet
make clean         # Remove build artifacts
make run           # Build and run with --help
make dev           # Build and run without args
make docs-lint     # Lint markdown docs
make docs-build    # Build Jekyll site
make docs-serve    # Serve docs at http://localhost:4000/finfocus/
make build-recorder    # Build recorder plugin to bin/finfocus-plugin-recorder
make install-recorder  # Build and install recorder to ~/.finfocus/plugins/recorder/0.1.0/
make test-jev          # Test the jev scorer plugin module (plugins/jev, separate go.mod)
make install-jev       # Build and install the jev scorer plugin
```

### Single Package/Test Commands

```bash
go test -v ./internal/cli/...           # Test specific package
go test -v ./internal/engine/...        # Test engine package
go test -run TestSpecificFunction ./... # Run specific test

# Coverage
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out        # View in browser

# Plugin management
./bin/finfocus plugin list
./bin/finfocus plugin list --output json  # JSON array for machine consumption
./bin/finfocus plugin validate
```

### Troubleshooting

```bash
pkill golangci-lint || true             # Fix parallel linting conflicts
GOOS=linux GOARCH=amd64 make build      # Test release build locally
gh workflow validate .github/workflows/ci.yml  # Validate workflow syntax
```

### Test Requirements

- **Unit tests**: Must achieve 80% coverage minimum
- **Critical paths**: Must achieve 95% coverage
- **All error paths**: Must be tested
- **Performance regressions**: Must be detected via benchmarks
- **Integration scenarios**: Must include plugin communication flows
- **End-to-end workflows**: Must test complete CLI usage

**Never complete a project without running:**

```bash
make test    # Run all tests
make lint    # Run linting
```

## Go Version

**Project Go Version**: 1.27.1 (see `go.mod`)

**CRITICAL**: Before claiming any Go version "doesn't exist" or suggesting version
changes, verify on <https://go.dev/dl/> first.

### Constitution Precedence Rule

**CRITICAL**: The constitution (`.specify/memory/constitution.md`) takes **absolute
precedence** over all runtime mode instructions (learning mode, explanatory mode, etc.).

If any runtime instruction conflicts with a constitution principle:

1. **Constitution wins** - Follow the constitution rule
2. **Use `/speckit.revisit`** - Document the conflict for prevention
3. **Never compromise** - Principle VI forbids TODOs/stubs regardless of mode

## Architecture

Core components and their directories:

1. **CLI** (`internal/cli/`) - Cobra commands: cost projected/actual/recommendations, plugin management, analyzer
2. **Engine** (`internal/engine/`) - Cost calculation orchestration, output rendering (table/JSON/NDJSON)
3. **Plugin Host** (`internal/pluginhost/`) - gRPC plugin lifecycle (launch, connect, cleanup)
4. **Registry** (`internal/registry/`) - Plugin discovery in `~/.finfocus/plugins/<name>/<version>/`
5. **Ingestion** (`internal/ingest/`) - Pulumi plan JSON parsing
6. **Analyzer** (`internal/analyzer/`) - Pulumi Analyzer gRPC protocol for zero-click cost estimation
7. **TUI** (`internal/tui/`) - Bubble Tea terminal UI components
8. **Router** (`internal/router/`) - Intelligent plugin routing with priority and fallback
9. **Config** (`internal/config/`) - Two-tier configuration (project-local overrides global)

### Configuration Resolution

**Project-specific settings** (config, dismissals) precedence:

1. `--project-dir` flag (explicit override)
2. `FINFOCUS_PROJECT_DIR` env var
3. Walk up from CWD to find `Pulumi.yaml`, use `$PROJECT/.finfocus/`
4. Fall back to `~/.finfocus/` (backward compatible)

**Global resources** (plugins, cache, logs) precedence:

1. `FINFOCUS_HOME` env var
2. `PULUMI_HOME/finfocus`
3. `~/.finfocus/`

**Config Merge**: Project `config.hujson` overrides global `config.hujson` at the **top-level key** level
(shallow merge). Keys absent in project config inherit from global defaults. Legacy `config.yaml` files are auto-migrated to Hujson format on first read.

## Key Patterns

### CLI Conventions

- Use `RunE` not `Run` for error handling
- Use `cmd.Printf()` for output (not `fmt.Printf()`)
- Defer cleanup functions immediately after obtaining resources
- Support multiple date formats: "2006-01-02", RFC3339

### Pre-Flight Request Validation

The adapter layer (`internal/proto/`) validates requests using `pluginsdk` validation
before gRPC calls. Key points:

- Validation happens in `GetProjectedCostWithErrors()` and `GetActualCostWithErrors()`
- Uses "VALIDATION:" prefix to distinguish from plugin errors ("ERROR:")
- Logs at WARN level with resource context for debugging
- Returns placeholder CostResult with $0 cost and descriptive Notes
- Invalid resources are skipped; valid resources still call the plugin
- A projected resource with `ref.*` tags and an empty SKU uses lenient validation
  (`pluginsdk.ValidateProjectedCostRequestLenient`) instead of failing closed on SKU

### Logging

```bash
# Enable debug output
finfocus cost projected --debug --pulumi-json plan.json
export FINFOCUS_LOG_LEVEL=debug
export FINFOCUS_LOG_FORMAT=json    # json or console
export FINFOCUS_TRACE_ID=external-trace-123  # inject external trace ID
```

Precedence: CLI flags (`--debug`) > env vars > config file > default (info, console).

### Global Agentic Flags

The following flags are automatically mounted by `ax.Execute` on all commands:

- **`--format json|human`**: Output format for machine vs human consumption (JSON for agent/tooling, human for terminal). When set to `json`, some commands automatically output machine-readable JSON. Defaults to auto-detection via TTY.
- **`--dry-run`**: Preview changes without making them (skip real side effects). Affects mutating commands: `plugin install/update/remove`, `analyzer install/uninstall`, `config init/set`, and recommendation operations (dismiss/snooze/undismiss), and `plugin upgrade`.
- **`--yes`**: Skip confirmation prompts (equivalent to `-y` or `--force` on individual commands). Automatically confirms operations that normally require user approval.
- **`--idempotency-key string`**: Opaque retry-deduplication key for preventing duplicate-create operations in distributed systems.
- **`--debug`**: Enable debug logging (also available as persistent flag for CLI-specific control).

Additionally, the following utility commands are available:

- **`__schema [--as=ax|mcp]`**: Emit machine-discoverability schema in AX (default) or MCP format for tool discovery.
- **`mcp-server [--transport=stdio|http] [--addr] [--allow-non-loopback]`**: Expose the entire CLI as a live Model Context Protocol server.
- **`--mcp`** (root-local flag): stdio alias for `mcp-server`; same tool list. User docs: `docs/src/content/docs/guides/mcp.md`.

## Testing

### TUI Visual Verification

After modifying `internal/tui/` code, ALWAYS:

1. Render the affected view and read the full output (not just test pass/fail)
2. Verify column alignment, section ordering, and data population visually
3. Run golden file tests: `go test -run TestGolden ./internal/tui/...`
4. Regenerate golden files if layout intentionally changed:
   `UPDATE_GOLDEN=1 go test -run TestGolden ./internal/tui/...`

String-only assertions (`assert.Contains`) are NOT sufficient for TUI testing.

### Parallel Tests

Tests call `t.Parallel()` (top level and each `t.Run` subtest) unless they touch
process-wide state. Parallel tests only run after every sequential test in the
package has finished, so the hazard is parallel tests interfering with each other.

- Not parallel: `t.Setenv`, `t.Chdir`, `os.Setenv`, swapping a package-level
  variable, `config.ResetGlobalConfigForTest`/`SetGlobalConfig`, or anything that
  builds a root command (`NewRootCmd*` sets the process-wide resolved project
  directory through `config.SetResolvedProjectDir`). This includes helpers that call
  those, such as `stubHome` or `WithEnv`.
- Not parallel: a test that writes a script or binary (`#!/bin/sh`, `os.WriteFile`
  or `os.Chmod` with exec bits) and then executes it, directly or through a helper
  or launcher. A concurrent fork in the same process lets the child inherit the open
  write fd, so the exec fails with `text file busy` (ETXTBSY, golang/go#22315).
  Only the executing test needs to be sequential, because sequential tests never
  overlap parallel ones.
- Mark such a test with `//nolint:paralleltest // <specific reason>` on the line
  above `func`. Put the directive on the `t.Run` or `for` line instead when only
  some subtests must stay sequential. `nolintlint` requires the reason.
- Table-driven subtests must not share a mutable fixture. Production code mutates
  proto messages and slices in place (for example `Engine.GetBudgets` on a shared
  `*pbc.Budget`), so build the fixture inside the subtest.
- Do not use `defer` in a parent test that starts parallel subtests; use
  `t.Cleanup`. The defer runs before the paused subtests resume.
- Verify with `go test -race -shuffle=on -count=3 ./<pkg>/...`. A flake or race
  means the test is not safe to parallelise.

### E2E Testing

**Location**: `test/e2e/` (separate Go module)

**Prerequisites**: AWS session or profile configured, Pulumi CLI, `make build`

```bash
export PATH="$HOME/.pulumi/bin:$PATH"
export PULUMI_CONFIG_PASSPHRASE="e2e-test-passphrase"
make test-e2e
```

**CRITICAL**: E2E tests MUST call actual finfocus CLI binary.
Never simulate cost values or stub CLI execution.

### Expected Failure Test Patterns

**IMPORTANT**: Tests that intentionally create failing plugin scenarios must follow
these patterns to avoid false CI failures:

- **Expected errors**: Use `t.Logf()` (informational, test passes), NOT `t.Errorf()`
- **Required errors**: Use `t.Fatalf("expected error")` only if error is absent
- **Common expected errors**: `context deadline exceeded`, `connection refused`,
  `broken pipe`/`EOF`, `no such file or directory`

If CI shows these errors in logs but tests are marked PASS, the behavior is correct.
Only investigate if tests actually FAIL (exit code 1).

### Error Path Testing

When writing new code, always include tests for error conditions:

1. Test every error return path
2. Validate error messages with `assert.Contains(t, err.Error(), "expected text")`
3. Test boundary conditions: empty inputs, nil pointers, invalid ranges
4. Test partial failures in batch operations
5. Test resource cleanup runs even when errors occur (defer patterns)

### Testify Assertion Standards

**CRITICAL**: All Go tests MUST use testify's `require` and `assert` packages.
NEVER use manual `if x != y { t.Errorf(...) }` patterns.

**`require.*`** (stops test on failure): Setup operations, error checks where
continuing would panic, non-nil checks before use.

**`assert.*`** (continues on failure): Value comparisons, multiple property
checks, non-critical validations.

| Manual Pattern | Testify Replacement |
| --- | --- |
| `if err != nil { t.Fatal(err) }` | `require.NoError(t, err)` |
| `if err == nil { t.Error("expected error") }` | `require.Error(t, err)` |
| `if x != y { t.Errorf("got %v, want %v", x, y) }` | `assert.Equal(t, y, x)` |
| `if len(x) != n { t.Errorf(...) }` | `assert.Len(t, x, n)` |
| `if !strings.Contains(s, sub) { t.Errorf(...) }` | `assert.Contains(t, s, sub)` |
| `if x == nil { t.Fatal("nil") }` | `require.NotNil(t, x)` |

### Local Plugin Development

1. Clone the plugin repository (e.g., `finfocus-plugin-aws-public`)
2. Modify the plugin code (add logging, fix type mapping)
3. Build: `make build-region REGION=us-east-1`
4. Install: Copy binary to `~/.finfocus/plugins/<plugin>/<version>/`
5. Run Core E2E tests to verify

## Important Files

- `cmd/finfocus/main.go` - CLI entry point, routed through `ax.Execute` (ax-go). Exit codes:
  0=success, 1=generic/internal error (ax `ExitInternal`), and a user-configurable code (default 1,
  0-255 via `cost --exit-code`/`--exit-on-threshold`) for budget-exceeded, preserved through
  `ax.Execute` via `internal/cli.toAxExitError`. `ax.ExitValidation`(2)/`ExitNetwork`(3)/`ExitAuth`(4)
  are ax-go's own reserved exit codes for new error classifications, not yet used anywhere in
  finfocus, and not retrofitted onto existing exit-1 paths.
- `internal/engine/engine.go` - Core orchestration
- `internal/pluginhost/host.go` - Plugin client management
- `internal/ingest/pulumi_plan.go` - Pulumi plan parsing
- `.specify/memory/constitution.md` - Project principles and quality gates
- `examples/plans/aws-simple-plan.json` - Sample plan for testing

## Pulumi Integration Notes

### Plan JSON Parsing

The `pulumi preview --json` output nests resource details under `newState`.
Ingestion MUST inspect `newState` to extract `inputs` and `type`. Without this,
property extraction fails and plugins return `InvalidArgument` errors.

`newState` and stack-export resources also carry `parent`, `dependencies`, and
`propertyDependencies`. `parent` is the Pulumi stack, not a pricing reference.
`ApplyCrossResourceRefs` follows `propertyDependencies` only: a property that
points at exactly one in-plan resource becomes `ref.<property>.{urn,type,region,sku}`
tags. The Pulumi unknown sentinel `04da6b54-80e4-46f7-96ec-b56ff0331ba9` is never
sent as a tag, SKU, or region. A child's own region is filled from the referenced
resource only when the child has none and exactly one referenced resource has a
region. The referenced SKU is never copied into the child's `Sku`. A child with
`ref.*` tags and an empty SKU uses `ValidateProjectedCostRequestLenient` so the
plugin still sees the tags. Classic Azure `skuName` is copied only into
`ref.<property>.sku` (the provider resolver does not treat `skuName` as the
resource's own SKU). Projected cache keys append `/refs-<hash>` when those tags
exist. Spec: `specs/618-cross-resource-refs/`.

`ConvertToProto` also emits dotted keys for nested maps and arrays
(`sku.capacity`, `rootBlockDevice.0.volumeType`) beside each collapsed key.
It skips `__` segments, credential-like segments (`password`, `secret`,
`token`, `credential`, `ciphertext`, `privatekey`, `apikey`, `accesskey`,
`connectionstring`, with the snake_case forms), a top-level input named `ref`
(so it cannot flatten into the `ref.*` reference namespace), and the containers
`tags`, `tagsAll`, `labels`, and `annotations`. Depth is capped at 6
segments, new keys at 128 characters, new values at 256 characters, and the
whole tag map at 50 entries. Existing collapsed keys are kept first.
No credential or Pulumi secret reaches a plugin through tags: `ConvertToProto`
drops credential-like top-level keys (`isCredentialKey`, the one rule shared
with dotted keys and attributes) and secret values, the dotted walk skips
secret maps (a decrypted secret's `value` leaf), `ConvertValueToString`
returns `""` for a secret and leaves credential keys and secrets out of
collapsed map and slice text, actual-cost tags drop secret values, and
`EstimateCost` attributes go through `redactedProperties`.
Projected cache keys always append `/tags-<digest>` of that map, then
`/refs-<hash>` when reference tags exist. `EstimateCost` attributes stay
nested. Spec: `specs/619-dotted-tag-keys/`.

Core also sends the raw properties as `ResourceDescriptor.attributes`
(finfocus-spec v0.7.3), built by `engine.BuildAttributes`. They go on projected
`GetProjectedCost` and `BatchCost`, `Supports`, and `GetPricingSpec`, not on
actual cost. At any depth they drop keys `skipDottedSegment` rejects (`__`
and credential-like names) and Pulumi secrets (`history.IsPulumiSecret`, the one
shared rule). At the top level they drop `ref` and `ref.*`. Pulumi unknowns and
the `tags`/`labels` containers are kept. Over `pluginsdk.MaxAttributesBytes`
(65536), they are omitted with a warning. Projected cache keys add
`/attrs-<digest>` after `/refs-`, and the in-memory `Supports` key adds
`:attrs-<digest>`, so `Supports` runs about once per distinct resource. A
projected `BatchCost` chunk is also split so its descriptors stay under
`maxBatchRequestBytes` (3 MiB; grpc-go's default limit is 4 MiB). Tags are
unchanged. Spec: `specs/621-k8s-workload-projected-cost/`.

### Property Extraction

The adapter (`internal/proto/adapter.go`) relies on the `Inputs` map to extract:

- **SKU**: from `instanceType`, `type`, etc.
- **Region**: from `availabilityZone`, `region`

If ingestion fails to populate `Inputs`, these fields are empty.

### Resource Type Compatibility

Pulumi provides types like `aws:ec2/instance:Instance` (Type Token). Plugins may
expect `aws:ec2:Instance` or just `ec2`. Plugins should handle the standard
Pulumi format or normalize internally.

### Pulumi SDK Import Path

For Analyzer development, use the correct import:

```go
pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go"
// NOT: github.com/pulumi/pulumi/sdk/v3/proto/go/pulumirpc
```

## Multi-Repository Ecosystem

FinFocus operates across three repositories:

- **finfocus** (this repo) - CLI tool, plugin host, orchestration
- **finfocus-spec** - Protocol buffer definitions, SDK generation
- **finfocus-plugin** - Plugin implementations (Kubecost, Vantage, etc.)

Cross-repo changes follow the protocol in `.specify/memory/constitution.md`.

### Agent Skills Placement

- **Tool-specific skills** (`finfocus-install`, `finfocus-budget`, `finfocus-diagnose`, etc.) → live in
  `rshade/finfocus` under `agent-skills/` — these are product skills tightly coupled to
  finfocus CLI commands, file paths, and architecture
- **Generic cost workflow skills** (`cost-check`, `cost-drift`, `cost-optimize`,
  `budget-setup`) → live in `rshade/agent-skills` as multi-tool skills not tied to any
  specific cost tool

## Common Error Types

- `ErrNoCostData`: No cost data available for a resource
- `ErrMixedCurrencies`: Multiple currencies detected in cross-provider aggregation
- `ErrInvalidGroupBy`: Invalid grouping type used for time-based aggregation
- `ErrEmptyResults`: Attempted aggregation on empty results
- `ErrInvalidDateRange`: Invalid date range (end date before start date)
- `ErrResourceValidation`: Internal resource validation failed
- `ErrConfigCorrupted`: Configuration file is malformed

Structured error codes for JSON/NDJSON output: `PLUGIN_ERROR`, `VALIDATION_ERROR`,
`TIMEOUT_ERROR`, `NO_COST_DATA` (see `internal/engine/types.go`).

## Recorder Plugin

Reference plugin for inspecting Core-to-plugin data shapes and contract testing.

| Variable | Default | Description |
| --- | --- | --- |
| `FINFOCUS_RECORDER_OUTPUT_DIR` | `./recorded_data` | Directory for recorded JSON files |
| `FINFOCUS_RECORDER_MOCK_RESPONSE` | `false` | Enable randomized mock responses |

## Jev Scorer Plugin

`plugins/jev/` is a separate module (like `plugins/kubernetes`) implementing
`RecommendationScorerService` with TypeSafe AI's Jev API. It must not import
finfocus core packages (`make check-plugin-boundaries`). Design notes:

- Off unless `TYPESAFE_API_KEY` is set; without it every scoring call returns
  `UNAUTHENTICATED`. The key is read only from the environment and must never
  appear in logs, errors, tests or committed files.
- Thin `net/http` client in `plugins/jev/internal/jevapi`; no third-party Jev SDK.
- Question names are `<signal>:#<position>` over an ordered state list; the
  recommendation id never leaves the plugin (answers map back by position via
  `batch.indexes`). Question text never contains recommendation content. Free
  text is stripped and capped in `internal/scoring/record.go`. Tests find a
  record's key with `questionKey()` in `helpers_test.go`, because every
  one-record `priority` request uses `priority:#0`.
- `jevapi.Client` does not start a retry whose wait outlasts the caller's
  deadline (core's `scoring.timeout_seconds`); it returns the `*APIError`.
- Live tests (`TestLive*`, `TestEvaluation` with `JEV_EVAL=1`) are skipped without
  a key. Batching lowers `priority` rank quality (Spearman about 0.55 at batch
  25 against 0.77 at batch 1), so priority is always one record per request.
- Scores are ranking-only. Never gate an irreversible action on one.

## Package-Specific Gotchas

Non-obvious behaviors that can cause subtle bugs if you don't know about them.

### Plugin Host (`internal/pluginhost/`)

- **Port allocation**: Uses allocate→hold→release→bind pattern. Race window between
  release and plugin bind is mitigated by `StartWithRetry`
- **Zombie prevention**: Always call `cmd.Wait()` after `cmd.Process.Kill()`
- **`PORT` env var removed**: Core does NOT set `PORT` (avoids Cloud Run conflicts).
  Plugins must use `--port` flag or `FINFOCUS_PLUGIN_PORT`

### CLI (`internal/cli/`)

- **`analyzer serve` stdout**: Prints ONLY the port number to stdout (Pulumi handshake
  protocol). ALL logging must go to stderr exclusively
- **DismissalStore**: Uses `GetResolvedProjectDir()` → project `dismissed.json`;
  falls back to `~/.finfocus/dismissed.json`
- **`config init`**: Without `--global`, inside a Pulumi project creates
  `$PROJECT/.finfocus/config.hujson` + `.gitignore`. Outside Pulumi project → global init
- **`config routes`**: `config routes list` shows effective routing source/path;
  `config routes test <type> [region]` simulates per-feature plugin selection without loading plugin binaries
- **`config validate` and cost pre-run**: both call `ValidateConfigSource`.
  `--file` selects a document. `--output json` prints the report. An unknown
  `--output` is rejected before the file is read. A missing default file stays
  valid. `cost projected`, `cost actual`, `cost recommendations`,
  `cost estimate`, and `cost cluster` fail in pre-run when a present file is
  invalid, and they ignore a missing file. Flat `cost.budgets.amount` is warned
  and not applied; the on-disk field is `cost.budgets.global.amount`. Period
  stays monthly. Threshold stays 0–1000. Amount 0 disables a scope. Unknown
  keys warn, with a suggestion when the name is close. There is no
  notifications section
- **Unit tests leak into the real `~/.finfocus`**: any test that executes a
  mutating command (`dismiss`, `snooze`, `config set`) writes to the developer's
  and the CI runner's actual home unless it sets
  `t.Setenv("FINFOCUS_HOME", t.TempDir())`. `NewDismissalStore("")` falls back to
  `ResolveConfigDir()`, which honors `FINFOCUS_HOME` first. This has produced
  tests that pass *only* because a sibling test seeded the shared store — green
  as a package, red when run alone. Detect with
  `HOME=$(mktemp -d) go test -run TestName ./internal/cli/`; a test that fails
  in isolation but passes in the package is order-dependent, not flaky
- **Validate `--output` before loading state**: commands that early-return on an
  empty result (e.g. history's `len(events) == 0`) must reject an unknown format
  *first*, or an invalid `--output` silently exits 0. See `config_routes.go`,
  `analyzer_check.go`, `plugin_list.go` for the up-front pattern
- **Overview docs path**: the published page is
  `docs/src/content/docs/commands/overview.md`. Root `README.md` links there.
  `docs/commands/overview.md` is not a file. Plain headers are
  `ACTUAL(MTD)` and `PROJECTED` (`PROJECTED*` in state-only mode). Delta comes
  from `CalculateRowDelta`, not projected minus month-to-date. The Warn column
  repeats a shown drift as `drift` and also lists `error` and `new`. `error`
  covers a plugin error reported on the result (`PLUGIN_ERROR`, `TIMEOUT_ERROR`,
  `VALIDATION_ERROR`, an `ERROR:`/`VALIDATION:` note) as well as a returned error; `NO_COST_DATA`
  is not an error. `ApplyChangesToRows` re-derives warnings when a status changes. The TUI
  cell uses `name+N` when that list does not fit in 7 columns. `estimate` and
  `stale` exist on `OverviewWarning` and are not derived
- **Cost table flags**: `--show-breakdown` on `cost projected` and `cost actual`
  adds alphabetical component sub-rows in the plain table (`├─`, last row `└─`).
  Empty maps, nil maps, and empty keys add no sub-rows. `--show-confidence`
  adds the actual-cost Confidence column. Neither flag changes JSON or NDJSON.
  `--estimate-confidence` still sets the plugin request and decides whether
  JSON/NDJSON keep `confidence`. Either new actual flag skips the interactive
  TUI so the table is what the user sees. Time-based `--group-by` stays on the
  cross-provider aggregation table, which has no per-resource sub-rows
- **Accessibility flags are local to output commands**: `overview`, `cost projected`, `cost actual`, and `cost recommendations` accept `--no-color`, `--plain`, `--color`, and `--high-contrast`. `overview` also keeps `--force-color` as an alias of `--color`. Do not put `--plain` on the `cost` parent: `cost history view` already defines a local `--plain`, and Cobra rejects the redefined flag. `FINFOCUS_PLAIN`, a non-empty `NO_COLOR`, `FORCE_COLOR`, and `FINFOCUS_HIGH_CONTRAST` fill unset flags (`strconv.ParseBool`; invalid values are ignored). Explicit `--plain` or `--no-color` wins over color flags. Those env vars win over `FORCE_COLOR` and `FINFOCUS_HIGH_CONTRAST` when the color mode was not set by a flag. `--plain` and `--no-color` stay `OutputModePlain`. High contrast does not change `ColorOK` and the other package color variables; `tui.Palette(true)` is ANSI 46, 226, 196, and 231, used by the styled budget box. Plain budget status text is `[OK] Within budget` or `[WARNING] Exceeds N% threshold`. Call `tui.DetectResolvedOutputMode` with the resolved struct, never `DetectOutputModeFor`,
  which re-reads `NO_COLOR` and would undo an explicit `--color`. `RenderScopedBudgetStatus` takes the
  resolved `tui.Accessibility` like the global box. `cost projected` prints its diff table as plain text in
  every mode, so its four flags only change the budget box (the help text says so). `FINFOCUS_PLAIN` fills
  in `cost history view`'s default format only: an explicit `--output`, `--format json`, or `AGENT_MODE`
  keeps JSON, and an explicit `--plain` still wins. `FORCE_COLOR` forces color for any non-empty value except `0` and `false` (force-color.org; supports-color treats those two as off)
- **Budget CLI flags stay off the global config**: `--exit-on-threshold` and
  `--exit-code` are `BudgetFlagOverrides` on the command context, passed into
  `evaluateBudgetStatus` and `legacyBudgetConfig`. They are not written onto
  `config.GetGlobalConfig()`. Precedence is the CLI pointer, then the scoped
  budget field, then parent `BudgetsConfig`, then the zero value. Only a copy
  of the global scope receives the overlay. Provider, tag, and type scopes do not.

### Registry (`internal/registry/`)

- **Region-specific binaries**: When `plugin.metadata.json` has a `region` key, registry
  looks for `finfocus-plugin-<name>-<region>` first, then falls back to standard names
- **Checksum verification**: Only a confirmed hash mismatch is fatal. Missing
  `checksums.txt`, download failures, or unlisted assets produce warnings and continue
- **`--skip-checksum`** flag available on `plugin install` and `plugin update`
- **Monorepo plugins (`tag_prefix`)**: an entry with `tag_prefix: "kubernetes-"` installs
  from `rshade/finfocus` tags like `kubernetes-v0.1.0`. "Latest" is the highest semver among
  prefixed stable releases (scans 100), never `/releases/latest`; install dir, asset names, and
  recorded version use the *canonical* version (`v0.1.0`, via `CanonicalVersion`). The entry
  also needs `asset_hints.asset_prefix`, and prefixes must not start with `v` (CLI workflows
  treat `v*` tags as CLI releases)

### Router (`internal/router/`)

- **Fallback chain**: `$0.00` cost is a VALID result (does NOT trigger fallback).
  Only nil/empty results trigger fallback to the next plugin
- **Priority**: Higher number = higher priority (sorted descending by `sortByPriority`)
- **Provider is the billing cloud, not the package**: `resourcetype.NormalizeProvider`
  holds the only alias table (`aws-native`→`aws`; `azure-native`, `azurerm`→`azure`;
  `google-native`, `google`→`gcp`; anything else passes through lowercased).
  `resourcetype.ExtractProvider` returns the cloud and `ExtractPackage` the raw
  prefix. Never split a type token for a provider anywhere else. `resource_type`
  keeps the package, so routing `patterns` such as `aws-native:*` still match.
  `ProviderMatches` normalizes both sides, so a plugin that still lists
  `azure-native` keeps matching. Provider budget keys, `ByProvider`,
  `GroupByProvider`, the `provider=` filter and history provider lookups compare
  normalized names, which also lets history entries stored under a package name
  match a cloud filter
- **No config = no routing**: `createRouterForEngine()` returns nil if no routing config;
  engine falls back to querying all plugins

### Engine (`internal/engine/`)

- **`hoursPerMonth = 730`** for monthly cost calculations
- **Projected cost diff**: `cost projected` calls `GetProjectedCostDiff`.
  An empty operation is a create (`$0` before, current price after); Terraform
  state maps to `same` (existing infrastructure, no delta). Internal `pulumi:`
  types that no plugin priced are left out of the entries, and a projected cache
  hit is re-keyed to the requesting resource's ID (identical resources share one
  cache entry). `update`,
  `replace`, and `create-replacement` price `OldProperties` then `Properties`.
  `delete` and `delete-replaced` price old properties and a `$0` after.
  `same` is priced once. Both sides use the same basis: `OldProperties` is outputs
  merged under the old inputs (like `Properties`), and `ref.*` tags are resolved
  once over the whole plan (`resolveDiffRefTags`) and given to both descriptors,
  because the before and after slices hold different resources. A replacement's
  `create-replacement`, `replace`, and `delete-replaced` steps share a URN and
  are collapsed into one `update` entry first (`collapseReplacements`), or the
  old resource would be counted twice. `summary.totalMonthly` and budget evaluation use the
  after total. Table output is the diff table (TTY included). JSON adds
  `finfocus.diff`. NDJSON is one diff entry per line. `--show-breakdown`
  adds component sub-rows on that table only. Recommendations are merged
  onto the after cost and skipped for deletes
- **Cost history** (`cost history collect|view|list`): snapshots live in
  `~/.finfocus/history/<project>@<stack>.history.db` (`@` cannot appear in a
  Pulumi name, so names never collide). The project is the `Pulumi.yaml` in the
  working directory or the middle part of `--stack org/project/stack`
  (`resolveHistoryTarget`). A database records its project in `meta` and
  `OpenCostDBFor` errors on a different one. A legacy `<stack>.history.db`
  (slashes become dashes) is kept by the project its snapshot URNs name
  (`CostDB.InferProject`), or adopted when they name none; any other project
  gets its own file. With no project, a bare `--stack` uses the legacy file,
  else a single `@` match, else errors. That file is not the
  resource-observation `history.db`. `collect`
  prices each successful `update` through `GetProjectedCostWithErrors` and
  stores the result; a successful `destroy` is `$0` with annotation
  `Stack destroyed` and does not call the pricer. `view` and `list` are
  read-only. A real plugin `$0` is stored. Adapter `none`, the unpriced note,
  or a result error fails that checkpoint (fail fast, per the #549 design: no
  inaccurate totals), and that includes a type an installed plugin declines.
  `pulumi:` internal types (provider resources are `custom: true`) are skipped
  before pricing, because the engine never prices them. `internal/history` must not import
  `engine` or `ingest` (`engine` already imports `history`). The parent
  `cost --stack` flag is reused; history subcommands do not redeclare it
- **Cost history collect details**: `pulumiExporter.History` reads
  `pulumi stack history` page by page (page size 100), stops on a
  page with no new versions (a short page is not an end marker), and asks once without paging flags
  if the CLI rejects them, because the default page is 10 updates. `import` and
  `refresh` updates are not collected (a checkpoint is a deployment; their cost
  shows at the next update). `history list` skips an unreadable or locked database
  with a stderr warning (`ListCostDBsLenient`), and `Stats` reads only the key
  count and the two end snapshots. `view --provider` errors on an unknown provider.
- **Cost history currencies**: `cost history view` warns and keeps the
  dominant currency (most snapshots; a tie keeps the newer timestamp, then
  the earlier code). `--currency` filters and does not warn. `--strict`
  returns `history.ErrMixedCurrencies`. An empty currency is `(unset)`.
  One currency produces no warning. `cost history export` uses the same
  default and prints the warning on stderr
- **Cost history prune**: `cost history prune` deletes per-stack snapshots
  (`--keep`, `--older-than`, `--force`). `--dry-run` is the global ax flag.
  Compaction uses `bolt.Compact` after the database is closed. This is not
  `BoltStore.cleanupExpiredEntries`. `cost.history.retention.auto_prune`
  runs that policy after `collect` when the command loaded config
- **Cost history export**: `cost history export` writes JSON, CSV, or NDJSON
  from the per-stack database. `--format` is the root ax flag. The command
  does not declare a second `--format`, because Cobra rejects the redefined
  flag. `csv` and `ndjson` are not agent modes (`ParseMode` allows `json` and
  `human` only). The export command's `Args` saves those two values and sets
  the flag to `json` before `ax.Execute` resolves the mode. Other commands
  still reject them. `--from`
  and `--to` are inclusive dates. `--provider` keeps every snapshot and sets
  `total_monthly` to that provider, or 0 when the snapshot has none.
  Sparklines are `history.Sparkline` (U+2581 through U+2588, width 7). A flat
  series is the low block. Empty input is an empty string. `cost projected`
  and `cost actual` add a Trend column only when that stack's history file
  exists. A missing file does not fail the cost command. There is no ntcharts
  dependency. The interactive `d` key from #550 is not part of this command
- **Cost history diff**: `cost history diff` compares two stored snapshots
  by URN (`internal/history/diff.go`). It reads bbolt only. A cost move of
  $0.01 or less is unchanged. `--threshold` hides smaller impacts.
  `--from` and `--to` take `vN` or `YYYY-MM-DD` (nearest snapshot). Empty
  `--to` is the newest snapshot. The interactive `d` key from #550 is not
  part of this command
- **Cost history in CI**: `cost history collect --versions` defaults to 0
  (every successful checkpoint not already stored). `--versions 1` keeps
  the newest. Recipes are
  `docs/src/content/docs/guides/ci-cd-cost-tracking.md`. There is no
  `docs/guides/ci-cd-cost-tracking.md` page. Release assets are versioned
  archives (`finfocus-v0.4.0-linux-amd64.tar.gz`); the install script is
  `scripts/install.sh`
- **`cost projected --explain`** calls `GetPricingSpec` after
  `GetProjectedCostDiff` and stores the view on `DiffEntry.PricingSpec`
  (`pricing_spec` in JSON and NDJSON). It does not change `Monthly`. The
  default is off, and a disabled run does not call the RPC. A delete is
  explained from the old properties; every other operation uses the new
  ones. An RPC error or an empty spec omits the field and keeps the cost.
  The next selected plugin is tried when the first has no spec. Unpriced
  `pulumi:` rows are not explained because they are not diff entries
- **Plugin pricing spec fallback** is off by default. `newEngineWithCache`
  copies `cost.pricing_spec_fallback`. `cost projected --pricing-spec-fallback`
  overrides that for the command when the flag is set. When on, a resource
  whose plugins returned no projected price is priced from `GetPricingSpec`
  before local YAML. Disabled runs do not call the RPC. Notes start with
  `Calculated from plugin pricing spec` and the adapter is `plugin-spec`.
  A `$0` rate is a priced result. Unknown billing modes and RPC errors fall
  through to YAML. Each `GetPricingSpec` call (fallback and `DiscoverPricingSpec`)
  has a 5s deadline (`perResourceTimeout`). A chain that stopped at a plugin
  whose router `Fallback` is false asks no plugin for a spec. With the flag on,
  projected cache keys end in `/pricing-spec`, so a run with it off never reads a
  `plugin-spec` result (the exported `ProjectedResourceCacheKey` stays the
  default key). `per_hour` and `per_cpu_hour` use 730 hours (the AWS Pricing Calculator
  basis, 365 × 24 / 12), `per_day` uses `daysPerMonth` (30, the month AWS's billing
  examples use: the ELB page's `$/hour * 24 hours * 30 days` and CloudWatch's
  `30 days * 24 hours = 720 hours`). Do not "fix" the 720 against 730 by arithmetic:
  both are AWS's own conventions for different purposes. `per_gb_month`
  multiplies by storage size or 1 GB. A billing mode the engine does not know that
  still has a usable unit, such as `per_hour_plus_data`, is priced from the unit
  alone and the note says `<mode>: hourly rate only, other charges not included`.
  A tiered spec is graduated, as finfocus-spec defines it
  (`docs/ADVANCED_PATTERNS.md`: usage in each tier is `min(usage, max) - min`; the S3
  example is "first 50 TB at $0.023, next 400 TB at $0.022"): each tier bills the part of
  the quantity inside it (`graduatedTierCost`). Tiers whose ranges do not cover the
  whole quantity (a gap, a first tier above zero, a bounded last tier below the
  quantity) are unusable and fall through, since the uncovered part has no rate. A `$0` rate stays a price, so a plugin must answer `zero_cost` or an error
  for "no price", not a zero rate (#1639 reports aws-public returning a zero
  `per_hour` rate for a type it does not find, which core prices as `$0`; unverified
  here, since it is a plugin-side issue)
- **Estimate TUI pricing discovery** calls `GetPricingSpec` when
  `cost estimate --interactive` starts. `DiscoverPricingSpec` caches by
  resource type, SKU, and region on the engine for that session, so an edited SKU
  asks the plugin again. The view lists each plugin
  billing mode, its tiers, assumptions, and usage hints. Left and right move
  between modes. `not_implemented`, an empty mode, and RPC errors hide that
  section and leave property editing in place. The lookup does not replace
  `EstimateCost`
- **Budget health thresholds**: OK (<80%), WARNING (80-89%), CRITICAL (90-100%),
  EXCEEDED (>100%). Aggregation uses worst-case status
- **Cache hits**: Append `(cached)`, including the leading space, to the Adapter field for visual feedback
- **Cache corruption**: Auto-detected and auto-recovered (delete + recreate)
- **Plugin TTL hints**: Plugins can set `expires_at` on responses to control per-entry
  cache TTL. The engine extracts `ExpiresAt` from `CostResult`, calls
  `cache.CalculatePluginTTL()`, and uses `SetWithTTL()` when a plugin hint is present.
  Past timestamps skip caching entirely. TTLs exceeding `MaxTTLSeconds` (604800 = 7 days)
  are capped. Debug logs record TTL overrides; warn logs record caps and skips
- **Optional LRU tier**: `cost.cache.lru_enabled` (default false) wraps the
  BoltDB cache with `cache.TieredStore` inside `initCacheFromConfig`, so every
  `newEngineWithCache` caller gets it. Reads hit memory first and promote a
  disk hit. Writes update disk, then refresh memory. Expired memory entries
  fall through. `lru_max_items` of 0 uses 256. `FINFOCUS_CACHE_LRU_ENABLED`
  and `FINFOCUS_CACHE_LRU_MAX_ITEMS` override the file. A wrap failure keeps
  the Bolt store. The engine field stays `cache.Cache`
- `checkPluginSupports` sends the descriptor `proto.PrepareProjectedDescriptor` builds
  (the same one `GetProjectedCost` sends, including a region inherited from a
  referenced resource, and its attributes), and caches per
  client+provider+type+region+sku+feature, plus an attributes digest when the resource
  has attributes (SKU is part of the key: a first SKU-less
  resource in a region must not poison the cached answer for every other SKU there);
  plugins on finfocus-spec ≥ v0.6.2 answer `Supports` for real, so a region-bound plugin
  (aws-public) declines other regions instead of being called and failing. A plugin that
  never implements `SupportsProvider` gets the SDK's generic fallback response
  (`Supported:false`, `Reason: pluginsdk.DefaultSupportsNotImplementedReason`); the engine
  treats that reason as fail-open (cached `true`), same as an RPC error, so such a plugin
  isn't silently dropped from routing
- **Recommendations**: `convertProtoRecommendation` keeps the full plugin record
  (`ID`, category, priority, confidence, source, metadata, `ImpactDetail`, `ResourceInfo`,
  `ActionDetail`, primary/secondary reasons);
  `Recommendation.ID` is the dismissal ID. The cache key is
  `recommendations/multi/{types}/{hash}` where the hash covers resource id/provider/type/
  properties plus dismissed IDs (`cache.HashRecommendationInputs`). Resources are routed per
  plugin via `routeRecommendationTargets` (router feature `Recommendations`)
- **Recommendation scoring** (`internal/scoring`, opt-in via `scoring.*` config, off by default):
  `scoring.Service.Score` runs after the fetch in `cost recommendations`, sends recommendations
  to a scorer plugin (`PLUGIN_CAPABILITY_RECOMMENDATION_SCORING`, normalized
  `recommendation_scoring`) through `pbc.RecommendationScorerServiceClient`. Core applies
  `identifier_mode` (default pseudonymized: per-request HMAC key, opaque `rec-N` ids, raw ids
  scrubbed from text) and `field_allowlist` before sending; identifier handling also covers
  identifiers inside `action_detail` (Kubernetes cluster/namespace/controller/container names) and
  free text there; the first call probes batch size
  (20, halving on INVALID_ARGUMENT), later batches run up to 8 concurrent. Scorer failure only
  warns. Scores are cached in the `scores` bucket (extracted values only). Scores never dismiss
  or hide anything. Scorer-only plugins are skipped by `routeRecommendationTargets`

### Overview Field Semantics (`internal/engine/overview_*.go`)

Understanding what each field *means* prevents the most common overview bugs.
Every field on `OverviewRow` has a specific temporal basis, population rule,
and set of valid comparisons. Violating these invariants produces subtle bugs
(misleading deltas, nonsensical drift, UI garbage).

#### Cost Fields — Temporal Basis

| Field | Struct | Temporal Basis | Unit | Source |
| --- | --- | --- | --- | --- |
| `MTDCost` | `ActualCostData` | Partial month (day 1 → today) | Dollars spent so far | Actual cost plugin |
| `MonthlyCost` | `ProjectedCostData` | Full canonical month (730h) | Dollars if run all month | Projected cost plugin |
| `ExtrapolatedMonthly` | `CostDriftData` | Full calendar month (28-31d) | Projected from MTD trend | Calculated by `CalculateCostDrift` |
| `Delta` | `CostDriftData` | Full calendar month | ExtrapolatedMonthly - Projected | Calculated by `CalculateCostDrift` |

**Key rule**: `MTDCost` and `MonthlyCost` are **different units**. You cannot
subtract one from the other. To compare them, you must first extrapolate
`MTDCost` to a full month using `getExtrapolatedActual()` (30-day standard)
or `CalculateCostDrift()` (calendar-accurate).

#### When Fields Are Nil vs Populated

Fields start nil after merge and get populated during enrichment. What gets
populated depends on the resource's `Status`:

| Status | `ActualCost` | `ProjectedCost` | `CostDrift` | `PropertyDiffs` |
| --- | --- | --- | --- | --- |
| Active | Yes (has billing history) | Yes (current config) | Maybe (nil if < 10% or day < 3) | No (no changes) |
| Updating | Yes (still running) | Yes (new config pricing) | Maybe | Yes (what changed) |
| Replacing | Yes (old resource billing) | Yes (new resource pricing) | Maybe | Yes (what changed) |
| Creating | No (doesn't exist yet) | Yes (new resource pricing) | No (no history) | No |
| Deleting | Yes (still running) | No (will be removed) | No (no projection) | No |

This table is the **source of truth** for which cost computations are valid
per status. If a formula assumes a field is non-nil, check this table first.

#### Delta Column — What It Means Per Status

The "Delta" TUI column answers: "how will this change affect my monthly bill?"
Use `CalculateRowDelta()` — it encodes status-aware logic:

| Status | Delta Formula | Meaning |
| --- | --- | --- |
| Updating/Replacing | `projected - extrapolatedActual` | Cost impact of the config change |
| Creating | `+projected` | New cost being added |
| Deleting | `-extrapolatedActual` | Cost being removed |
| Active (with drift) | `CostDrift.Delta` | How much actual spend deviates from projection |
| Active (no drift) | `-` (no delta shown) | Spend is tracking projection (< 10% off) |

#### Extrapolation — Two Methods, Intentionally Different

| Function | Month Basis | Used For |
| --- | --- | --- |
| `getExtrapolatedActual()` | 30-day standard | Delta calculations (consistent cross-month) |
| `CalculateCostDrift()` | Calendar days (28-31) | Drift % (calendar-accurate precision) |

Do not unify these. Delta uses 30-day for stable comparisons across months.
Drift uses calendar days because a February drift % must account for 28 days.

#### Drift Nil Cases

`CostDrift` is nil (not populated) in these cases — all intentional:

- **Day 1-2 of month** (`driftMinDay = 3`): insufficient data
- **Drift < 10%** (`driftWarningThreshold`): not significant enough to show
- **New resource** (has projected, no actual): nothing to extrapolate from
- **Deleted resource** (has actual, no projected): nothing to compare against
- **Recently created** (`CreatedAt` within billing window, < 3 days old)

Code must always handle `row.CostDrift == nil` as a normal case, not an error.

#### Pulumi Plan Data — What to Filter

`PulumiStep.OldState.Inputs` and `NewState.Inputs` contain both user-specified
properties and Pulumi internal metadata. When displaying to users:

- **Filter keys prefixed with `__`** (e.g., `__defaults`, `__provider`) —
  these are Pulumi SDK internals, not user properties
- **Truncate values** in TUI to prevent wrapping (max 40 chars via
  `truncateDiffValue()`) — Pulumi inputs can contain large arrays/objects
- **PropertyDiff data flow**: Plan JSON → `diffInputs()` (CLI) →
  `PlanStep.PropertyDiffs` → merge → `OverviewRow.PropertyDiffs` → TUI view

#### State-Only Mode

When no preview is provided, overview shows state resources with `*` footnote
on projected costs. The `p` key triggers on-demand preview; when it completes,
`ApplyChangesToRows()` and `ApplyPropertyDiffsToRows()` update rows in-place.

### Cluster allocation (`internal/engine/cluster*.go`, `plugins/kubernetes/`)

- **Core never interprets Kubernetes**: nodes arrive from `GetStats` as ordinary
  `ResourceDescriptor`s (`sku`/`region` passed as properties) and are priced by
  `GetProjectedCostWithErrors`; grouping is string-map aggregation
- **Node identity** (#1588): the allocator keys a node by cluster and node
  name joined with NUL, same as the workload key. The priceable `id` stays
  the Kubernetes node name so stats validation and the idle row still match
  it. `NodeDescriptor` copies the kubeconfig context onto tags `cluster` and
  `node`. A priced node with no `cluster` tag still joins on the node name.
  Two priced entries that share `kind=node` and `id` are rejected by the SDK
  before allocation; a multi-cluster request gives each one its own id and
  keeps the Kubernetes name on the `node` tag
- **`$0` price = unpriced**: aws-public returns `$0` (not an error) for unknown
  instance types, so `priceResources` treats `Monthly <= 0` as unpriced
- **Conservation is enforced in core** (`VerifyConservation`, rel 1e-6, delegating to
  `pluginsdk.CheckConservation`) even though the conformance suite also checks it —
  third-party allocators exist, and one shared SDK rule keeps core and plugins in agreement
- **`plugins/kubernetes` is a nested module** and must not import core packages
  (`make check-plugin-boundaries`); it declines `Supports` so `cost projected`
  skips it (finfocus-spec ≥ v0.6.2 delivers plugin `Supports` answers to hosts;
  earlier SDKs errored and the engine failed open)
- **Allocator policy v2** (`specs/614-allocator-policy-v2/`): `idle` and
  `system_workloads` accept `share` as well as the default `separate`. Idle
  CPU and memory are shared in proportion to each workload's already-computed
  CPU cost and memory cost. System rows (`namespace=kube-system` or
  `controller_kind=DaemonSet`) are then folded into the other workloads on
  the same node. The idle row stays even at `$0`, because
  `ValidateAllocateResponse` requires exactly one idle row per priced node.
  When both fields are `share`, idle is distributed first (including onto
  system rows) and system rows are folded after that
- **EKS Fargate pods** (`specs/615-eks-fargate-pods/`): a node labeled
  `eks.amazonaws.com/compute-type=fargate` still has no capacity rows and no
  node descriptor. Each running pod on it is a priceable
  `aws:eks/fargate:Pod` (sku `fargate`, tags `kind=fargate`, `cpu`,
  `memory_gib`). The allocator assigns that priced cost to the pod and never
  folds it into `__idle__`. `Monthly <= 0` stays unpriced (`Fargate pod has
  no price`, or the pricer's note). A `fargate-` node name with no fargate
  priced entry still notes `Fargate pricing not supported yet`. Rates are
  aws-public's job
  ([finfocus-plugin-aws-public#409](https://github.com/rshade/finfocus-plugin-aws-public/issues/409));
  kind cannot simulate Fargate, so `make test-e2e-kind` does not cover it
- **Projected workload pricing** (`specs/621-k8s-workload-projected-cost/`):
  `plugins/kubernetes/workload` prices Deployment, StatefulSet, DaemonSet,
  Job, and CronJob from `attributes` at rates from
  `FINFOCUS_KUBERNETES_CPU_HOURLY_RATE` and
  `FINFOCUS_KUBERNETES_MEMORY_GIB_HOURLY_RATE`, with
  `FINFOCUS_KUBERNETES_DAEMONSET_NODE_COUNT` and
  `FINFOCUS_KUBERNETES_JOB_HOURS_PER_MONTH` as hints. `Supports` and
  `GetProjectedCost` share `workload.Estimate`. A `Supports` decline reason
  (at most 160 characters) is the only plugin text core keeps in a
  `NO_COST_DATA` note; a `GetProjectedCost` error's text is dropped. The
  per-pod request reuses `usage.EffectiveRequests`, with limits standing in
  for absent requests first. Prices carry `expires_at` = now because the
  core cache key cannot see the plugin's environment; without it a rate change
  kept the old cached price
- **Pulumi URN linking** (`specs/616-k8s-pulumi-urn-linking/`): the provider
  does not write a URN onto objects. Users set annotation
  `finfocus.dev/pulumi-urn` (not a label: URN values contain `:` and exceed
  63 characters). The collector copies it to subject
  `label.finfocus.dev/pulumi-urn`, which stats validation allows. `cost
  cluster --group-by pulumi-stack` groups by `<stack>/<project>`; a missing
  or malformed value is `<none>`. Group JSON includes `pulumi_urns`

### MCP Server (`internal/cli/mcp.go`, `internal/cli/output_mode.go`)

- **Per-call lifecycle**: ax-go's dispatcher re-executes the *shared* root once
  per `tools/call` (nested `ExecuteContext`), so root `PersistentPreRunE`/
  `PersistentPostRunE` run per call. `commandLifecycle.serving` marks dispatched
  calls: they reuse the server's `loggingSession` via `attach` and never close
  it. Opening/closing logging per call previously leaked handles and ended in
  `close .../finfocus.log: file already closed`. `loggingSession.Close` is
  idempotent. Config resolution still runs per call
- **Serialized dispatch**: one mutex covers every call, so a blocking command
  stalls the whole server. That is why `analyzer serve` is excluded
- **Output resolver**: `resolveOutputFormat` gives an explicitly set `--output`
  first priority, then `--format` (the dispatcher injects `json` on every call),
  then `AGENT_MODE`, resolved via `ax.ResolveMode(..., stdoutIsTTY=true)`, else the
  flag default. It deliberately ignores `ax.ModeFromContext` and TTY detection:
  those report JSON whenever stdout is piped, which would flip redirected human
  output. New commands with an output flag must call it,
  and commands without one should emit JSON when `machineOutputRequested(cmd)`
- **Flag stickiness**: the dispatcher resets flag values *and* `Changed` per
  call, but only on a real server. Nested `ExecuteContext` in unit tests does
  not reset, so assert cross-call `--output` behavior in the integration test
- **Exclusion**: `applyMCPExclusions` marks `mcpExcludedCommands` (root,
  `analyzer serve`, `cost history collect` (minutes-long, would stall the serialized
  dispatcher), `setup`, `plugin init`) with ax-go's node-only `mcp.Exclude`
  at tree build time; they stay in `--help`. ax-go (v0.7.0+) skips `help` and
  non-runnable groups itself. Never use `Hidden` to exclude a root/group: ax-go
  prunes the whole subtree. `withPositionalCommandsHidden` still hides
  positional-arg leaves while an MCP tool list is built so the static
  `__schema --as=mcp` matches the live server, which cannot pass positional args
- **Tool allow-list golden**: `internal/cli/testdata/mcp/tools.golden` pins the
  tool list for both the static schema (unit) and live `tools/list` of both
  entry points (`test/integration/mcp_server_test.go`). A new command must make
  an expose-or-exclude decision; regenerate with `UPDATE_GOLDEN=1`
- **Version**: the MCP handshake rejects `dev`/`unknown` versions, so binaries
  built for MCP tests need `-ldflags -X .../pkg/version.version=...`

### Plugin Upgrade (`internal/pluginupgrade/`, `agent-skills/finfocus-plugin-upgrade/`)

- **Bumping finfocus-spec in core needs a hop**: `TestHopsReachCoreSpecVersion`
  fails until `hops.go` has an entry whose `To` is at least
  `pluginsdk.SpecVersion`, and `TestHopsMatchGuides` requires a matching
  `references/to-vX.Y.Z.md`. Add both in the bump PR, even for an additive
  release (a guide that says "no changes required" is fine)
- **Hops are releases that need author action, not minor lines**: v0.5.7 (a
  patch) changed `HandleDryRun`; v0.6.1 raised Go to 1.27.1
- **`SpecVersion` must be `v`-prefixed**: `pluginsdk.ValidateSpecVersion`
  rejects `"0.6.1"`, so `GetPluginInfo` returns `Internal` and `plugin list`
  shows `N/A`. `plugin init` now writes `SpecVersion = pluginsdk.SpecVersion`;
  scaffolds from v0.4.0 and earlier have a bare literal, which the upgrade
  rewrites
- **Minimum supported version is v0.5.0**; there are no pre-rename plugins
- **The scaffold's finfocus-spec pin is `pluginsdk.SpecVersion`** (#248), so
  bumping finfocus-spec in core also moves new plugins.
  `TestPluginInitTracksCoreSpecVersion` keeps it at v0.7.1 or newer and checks
  that a fresh scaffold needs no `plugin upgrade`; the scaffold integration
  test proves the templates still compile against it
- **`plugin.manifest.json` is not the pluginsdk manifest**: `plugin validate`
  parses it as the flat `registry.Manifest` (`name`, `version`, ...) and
  requires both to match the install directory. The scaffold's `manifest.json`
  (`pluginsdk.SaveManifest`, nested `metadata`/`specification`) fails to parse
  there, so the generated `make install` writes the flat file with `printf`.
  `test/integration/plugin/scaffold_build_test.go` builds, installs, and
  validates a generated project
- Rebuild `agent-skills/finfocus-plugin-upgrade/finfocus-plugin-upgrade.skill`
  (a zip of `SKILL.md` + `references/`) after editing either
- **Skill install (`internal/pluginskill/`)**: `plugin init` and `plugin upgrade`
  run `npx -y skills@1.7.0 add <finfocus tree URL> --skill finfocus-plugin-dev
  --skill finfocus-plugin-upgrade --agent codex --agent claude-code --copy -y`
  in an empty temp directory, then copies the two skill directories into the
  plugin and merges their `skills-lock.json` entries. Never run npx in the
  plugin directory: npx honors its `.npmrc` (registry) and `node_modules`
  (local `skills` package), so an untrusted checkout could run its own code.
  The copy and the lockfile merge write through an `os.Root` on the plugin
  directory, so a symlinked `.agents`, `.claude`, or `skills-lock.json` cannot
  redirect a write outside the checkout.
  Without `--agent` the skills CLI writes a copy for
  every agent it detects (`.bob`, `.grok`, ...). The source is the release tag
  only for a clean semver newer than v0.4.0 (the last tag without the skill),
  else `main`. Upgrade re-runs `add`, not `skills update`, because `update`
  moves to `main`. Every failure is a warning, never an error. Tests must never
  run npx: `internal/cli` swaps the installer in `TestMain` (`main_test.go`,
  `export_test.go`), and `test/integration/plugin` passes `--no-skill`. Spec:
  `specs/620-plugin-dev-skill/`

### Integration Tests (`test/integration/`)

- **The CLI helper must go through `ax.Execute`**: `helpers.CLIHelper.Execute`
  runs commands via `ax.Execute`, the same entry point as `cmd/finfocus`. Calling
  `cli.NewRootCmd().Execute()` directly does NOT mount the persistent agentic flags
  (`--format`, `--dry-run`, `--yes`, `--idempotency-key`), so those flags fail with
  "unknown flag" in integration tests while working in production and in unit tests
  (which use `axtest.Run`). `ax.Execute` returns an exit code rather than an error,
  so the helper converts non-zero codes back into an error from stderr
- **Many integration tests do not isolate `HOME`**: tests that call
  `helpers.NewCLIHelper(t)` without `WithEnv` read the developer's real
  `~/.finfocus/config.hujson`, so local config can change their results. (A local
  `cost.budgets` entry used to inject a "BUDGET STATUS" banner into JSON/NDJSON
  output and fail ~57 tests locally; the banner now renders only for table output.)
  Always diff the integration failure *set* against a pre-change baseline rather
  than comparing failure counts
- **Overriding `HOME` for a `go`/`make` child moves Go's caches**: when `GOPATH`
  is not exported (CI), `HOME=<t.TempDir()>` puts the module cache under the
  temp dir. Its files are read-only, so `t.TempDir` cleanup fails with
  `permission denied` (green locally where `GOPATH` is set). Pass the real
  `GOMODCACHE`/`GOCACHE` from `go env`, as `scaffold_build_test.go` does.
  Reproduce with `env -u GOPATH go test ...`
- **`config.yaml` fixtures are intentional**: most `config.yaml` references in
  `test/integration/` write legacy YAML as *input* to exercise auto-migration. Only
  assertions that `config init` *creates* a file should expect `config.hujson`

## Recent Changes

- 621-k8s-workload-projected-cost: finfocus-spec v0.7.3 (`ResourceDescriptor.attributes`); core sends redacted attributes; the kubernetes plugin prices declared workloads
- 608-batch-cost-consumer: Added Go 1.27.1 (see `go.mod`) + finfocus-spec v0.6.0 (proto definitions with `BatchCost` RPC), Cobra (CLI), gRPC, zerolog (logging)
- 608-resource-history-store: Added Go 1.27.1 (see `go.mod`) + BoltDB (`go.etcd.io/bbolt` — already in `go.mod`).
- 608-estimate-cost-rpc: Added Go 1.27.1 (see `go.mod`) + finfocus-spec v0.6.0 (proto definitions), gRPC, Cobra, zerolog
- 607-state-only-flag: Added Go 1.27.1 (see `go.mod`) + Cobra (CLI framework), Bubble Tea (TUI), zerolog (logging)
- 606-cache-expires-at: Added Go 1.27.1 (see `go.mod`) + finfocus-spec v0.6.0 (provides `expires_at` proto fields), BoltDB (cache storage), zerolog (logging)

## Active Technologies

- Go 1.27.1 (see `go.mod`) + finfocus-spec v0.6.0 (proto definitions with `BatchCost` RPC), Cobra (CLI), gRPC, zerolog (logging) (608-batch-cost-consumer)
- BoltDB cost cache (`~/.finfocus/cache/cache.db`) — `Engine.GetProjectedCost` caches batch projected results per-resource via `storeProjectedCostCache`; `Engine.GetActualCostWithOptions` caches actual-cost results by full request key via `storeActualCostCacheIfClean` (608-batch-cost-consumer)
- Go 1.27.1 (see `go.mod`) + BoltDB (`go.etcd.io/bbolt` — already in `go.mod`) (608-resource-history-store)
- BoltDB at `~/.finfocus/history/history.db` (separate from cache) (608-resource-history-store)
- Go 1.27.1 (see `go.mod`) + finfocus-spec v0.6.0 (proto definitions), gRPC, Cobra, zerolog (608-estimate-cost-rpc)
- N/A (no new persistent state) (608-estimate-cost-rpc)
