# `secret` Interactive Console Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
> Design (approved): `docs/plans/2026-07-19-secrets-interactive-cli-design.md`
> Execute in worktree branch `worktree-secrets-interactive-cli` (root: `/Users/dohertj2/Desktop/scadaproj/.claude/worktrees/secrets-interactive-cli`). All paths below are relative to `ZB.MOM.WW.Secrets/` inside that worktree. Run all commands from that `ZB.MOM.WW.Secrets/` directory.

**Goal:** Menu-driven Spectre.Console TUI inside the existing `secret` CLI for deployment secret-seeding and lockout recovery, launched by no-args-in-a-TTY; all headless verbs unchanged.

**Architecture:** New `Interactive/` layer in `src/ZB.MOM.WW.Secrets.Cli`: UI-free services (`TargetConfigReader`, `SecretsSessionFactory`, `ReferenceAuditor`, `KekDoctor`, `BundleService`, `RecentTargetsStore`) + an `InteractiveShell` that owns `IAnsiConsole` and dispatches to `IInteractiveFlow` implementations (one file per flow, so flows parallelize). Sessions are built per target by constructing the store stack directly (SQLite or SQL-Server) — no per-target DI host. Degraded mode when the KEK is unavailable in the CLI's shell.

**Tech Stack:** .NET 10, Spectre.Console (+ Spectre.Console.Testing), xunit 2.9.3, existing core seams (`ISecretStore`, `ISecretCipher`, `IMasterKeyProvider`, `KekRotationService`, `SecretLastWriterWins`).

**Global rules for every task:**
- TDD: write the failing test first, see it fail, implement, see it pass, commit.
- Test command: `dotnet test ZB.MOM.WW.Secrets.slnx` (fully offline; must end 0-warning — the repo treats warnings as errors in CI posture).
- Secret values must NEVER appear in recents JSON, bundles, logs, or non-reveal screen output. XML-doc every public member (family CommentChecker convention).
- Match surrounding code style: file-scoped namespaces, `sealed` classes, records for data, `ConfigureAwait(false)` in library-ish code.

---

### Task 1: Package + project wiring

**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none (everything depends on it)

**Files:**
- Modify: `Directory.Packages.props`
- Modify: `src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj`
- Modify: `tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj`

**Step 1:** Add to `Directory.Packages.props` (new `<!-- Interactive console -->` group next to the Test group):

```xml
<PackageVersion Include="Spectre.Console" Version="0.50.0" />
<PackageVersion Include="Spectre.Console.Testing" Version="0.50.0" />
```

(If restore says 0.50.0 doesn't exist on the feed, use the latest stable `dotnet package search Spectre.Console` reports and keep both packages on the same version.)

**Step 2:** In the Cli csproj add `<PackageReference Include="Spectre.Console" />` to its ItemGroup and a ProjectReference to `..\ZB.MOM.WW.Secrets.Replicator.SqlServer\ZB.MOM.WW.Secrets.Replicator.SqlServer.csproj` (SQL-hub targets like ScadaBridge central).

**Step 3:** In the Tests csproj add `<PackageReference Include="Spectre.Console.Testing" />`.

**Step 4:** Run: `dotnet build ZB.MOM.WW.Secrets.slnx` → Expected: Build succeeded, 0 warnings.

**Step 5:** Commit: `git add -A && git commit -m "feat(secrets-cli): wire Spectre.Console + SqlServer store refs for interactive console"`

---

### Task 2: RecentTargetsStore

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 3

**Files:**
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/RecentTargetsStore.cs`
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/RecentTargetsStoreTests.cs`

**Step 1 (failing tests):** `RecentTargetsStoreTests` against a temp file path (constructor takes the JSON file path; production default `Path.Combine(Environment.GetFolderPath(SpecialFolder.UserProfile), ".zb-secrets", "recent-targets.json")` supplied by the caller, tests pass a temp path):
- `Load_returns_empty_when_file_missing`
- `Touch_then_load_roundtrips_name_and_path`
- `Touch_existing_path_moves_it_first_and_updates_stamp` (inject a `TimeProvider`)
- `Load_tolerates_corrupt_json_by_returning_empty`
- `Touch_caps_list_at_ten_entries`
- `Saved_json_contains_no_key_material_fields` (serialize, assert raw text has only `name`/`appSettingsPath`/`lastUsedUtc` properties)

**Step 2:** Run `dotnet test ZB.MOM.WW.Secrets.slnx --filter "FullyQualifiedName~RecentTargetsStoreTests"` → FAIL (type missing).

**Step 3 (implement):**

```csharp
/// <summary>Persists the operator's recently-used store targets (names + appsettings paths ONLY — never key material).</summary>
public sealed class RecentTargetsStore
{
    public sealed record RecentTarget(string Name, string AppSettingsPath, DateTimeOffset LastUsedUtc);

    public RecentTargetsStore(string filePath, TimeProvider? timeProvider = null) { ... }
    public IReadOnlyList<RecentTarget> Load() { ... }   // missing/corrupt file => []
    public void Touch(string name, string appSettingsPath) { ... } // upsert by path, newest-first, cap 10, CreateDirectory, atomic write (temp+move)
}
```

**Step 4:** Re-run the filter → PASS. **Step 5:** Commit `feat(secrets-cli): recent-targets store for the interactive console`.

---

### Task 3: TargetConfigReader + StoreTarget

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2

**Files:**
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/StoreTarget.cs`
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/TargetConfigReader.cs`
- Read for reference: `src/ZB.MOM.WW.Secrets/SecretsOptions.cs`, `src/ZB.MOM.WW.Secrets.Replicator.SqlServer/SqlServerSecretsOptions.cs`
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/TargetConfigReaderTests.cs`

**Step 1 (failing tests):** temp dir fixtures writing real `appsettings.json` files:
- `Reads_secrets_section_and_binds_options` (SqlitePath, MasterKey source/env-var)
- `Environment_overlay_wins_over_base` (`appsettings.Production.json` overrides SqlitePath when `environment: "Production"` passed)
- `Binds_sqlserver_options_when_section_present` (`Secrets:SqlServer:ConnectionString`)
- `SqlitePath_is_resolved_relative_to_the_app_directory` (relative `secrets.db` → absolute under the appsettings dir — the app's CWD at runtime; this is the honest-store guarantee)
- `Missing_file_throws_FileNotFoundException_with_path`
- `Custom_section_path_is_honored` (e.g. `MySecrets`)

**Step 2:** Run filter `TargetConfigReaderTests` → FAIL.

**Step 3 (implement):**

```csharp
/// <summary>A resolved deployment store target: the app's composed Secrets options + where they came from.</summary>
public sealed record StoreTarget(
    string Name,                       // display name (defaults to appsettings parent dir name)
    string? AppSettingsPath,           // null for manual-entry targets
    SecretsOptions Secrets,            // SqlitePath already made absolute
    SqlServerSecretsOptions? SqlServer,
    IConfigurationRoot Configuration); // full composed config, consumed by ReferenceAuditor

public sealed class TargetConfigReader
{
    /// <summary>Loads the target app's config exactly the way the app would (base json + optional env overlay + env vars).</summary>
    public StoreTarget Read(string appSettingsPath, string? environment = null, string sectionPath = "Secrets") { ... }
    /// <summary>Builds a manual target from explicit db path + master-key options (no appsettings).</summary>
    public StoreTarget Manual(string sqlitePath, MasterKeyOptions masterKey) { ... }
}
```

`Read` uses `new ConfigurationBuilder().SetBasePath(dir).AddJsonFile(fileName).AddJsonFile($"appsettings.{environment}.json", optional: true).AddEnvironmentVariables().Build()`, binds `SecretsOptions` from `sectionPath`, binds `SqlServerSecretsOptions` from `sectionPath + ":SqlServer"` only when that section has children, and absolutizes `SqlitePath` against `dir`.

**Step 4:** filter → PASS. **Step 5:** Commit `feat(secrets-cli): target config reader — operate on the app's own composed Secrets config`.

---

### Task 4: SecretsSession + SecretsSessionFactory + LiteralMasterKeyProvider

**Classification:** high-risk (crypto/KEK wiring; degraded-mode correctness is the lockout path)
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Tasks 5-12 depend on it)

**Files:**
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSession.cs`
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSessionFactory.cs`
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/LiteralMasterKeyProvider.cs`
- Read for reference: `src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderBase.cs` (KekId derivation to mirror), `src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderFactory.cs`, `src/ZB.MOM.WW.Secrets.Replicator.SqlServer/DependencyInjection/` (how SqlServer store + migrator are constructed), `src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs`
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/SecretsSessionFactoryTests.cs`, `.../LiteralMasterKeyProviderTests.cs`

**Step 1 (failing tests):**
- `LiteralMasterKeyProvider`: `Accepts_base64_32_byte_key`, `Rejects_wrong_length`, `Derives_same_kekid_as_environment_provider_for_same_material` (set a temp env var, compare `KekId` — pins the sha256 derivation match so rewraps interoperate), `Explicit_kekid_wins`.
- `SecretsSessionFactory` (temp SQLite targets):
  - `Opens_full_session_when_env_kek_present` (`KekAvailable == true`, cipher + store usable, schema migrated — assert a `set`-style upsert roundtrips)
  - `Opens_degraded_session_when_env_var_unset` (`KekAvailable == false`, `Store` still lists, `Cipher` is null)
  - `Override_kek_upgrades_degraded_session` (create with `LiteralMasterKeyProvider` override → full)
  - `SqlServer_target_with_unresolved_secret_ref_connstr_falls_back_to_sqlite_with_warning` (connstr containing `${secret:` → `session.Warnings` non-empty, store is the SQLite one)

**Step 2:** filter `SecretsSessionFactoryTests|LiteralMasterKeyProviderTests` → FAIL.

**Step 3 (implement):**

```csharp
/// <summary>An open store session for one target. Degraded when no KEK is available (metadata ops only).</summary>
public sealed class SecretsSession
{
    public required StoreTarget Target { get; init; }
    public required ISecretStore Store { get; init; }
    public required ISecretsStoreMigrator Migrator { get; init; }
    public IMasterKeyProvider? MasterKey { get; init; }      // null => degraded
    public ISecretCipher? Cipher { get; init; }              // null => degraded
    public required string StoreKind { get; init; }          // "sqlite" | "sqlserver"
    public IReadOnlyList<string> Warnings { get; init; } = [];
    public bool KekAvailable => Cipher is not null;
}

public sealed class SecretsSessionFactory
{
    /// <summary>Builds the store stack for a target. KEK resolution failure yields a DEGRADED session, never a throw.</summary>
    public async Task<SecretsSession> OpenAsync(StoreTarget target, IMasterKeyProvider? overrideKek, CancellationToken ct) { ... }
}
```

Factory logic: pick SqlServer store when `target.SqlServer` is set AND its `ConnectionString` doesn't contain `${secret:` (else warn + SQLite fallback); construct exactly what the DI extensions construct (`SecretsSqliteConnectionFactory`/`SqliteSecretStore`/`SqliteSecretsStoreMigrator`, or the SqlServer equivalents); KEK = `overrideKek ?? try MasterKeyProviderFactory.Create(...)` — wrap creation AND a probe call (compute `KekId`) in try/catch, catching only the provider's documented failure exceptions; on failure record the reason in `Warnings` and open degraded. Always `await migrator.MigrateAsync(ct)` before returning. `LiteralMasterKeyProvider` holds the 32 raw bytes pasted by the operator, mirrors the base class's derived-KekId scheme.

**Step 4:** filter → PASS. **Step 5:** Commit `feat(secrets-cli): per-target session factory with degraded-KEK mode + literal KEK provider`.

---

### Task 5: ReferenceAuditor

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 6, Task 7

**Files:**
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/ReferenceAuditor.cs`
- Read for reference: `src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs` (token regex — reuse the exact pattern `\$\{secret:([^}]+)\}`)
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/ReferenceAuditorTests.cs`

**Step 1 (failing tests):** in-memory `IConfigurationRoot` + temp SQLite session:
- `Finds_all_distinct_secret_references_with_their_config_paths` (two keys referencing the same name → one finding, two paths)
- `Classifies_present_decryptable_secret_as_Ok`
- `Classifies_absent_secret_as_Missing`
- `Classifies_tombstoned_secret_as_Tombstoned`
- `Classifies_wrong_kek_row_as_Undecryptable` (seed row via a cipher on KEK-A, audit with session on KEK-B)
- `Degraded_session_reports_PresenceOnly_status` (KEK unavailable → Ok becomes `PresentUnverified`)

**Step 2:** filter → FAIL. **Step 3 (implement):**

```csharp
public enum ReferenceStatus { Ok, PresentUnverified, Missing, Tombstoned, Undecryptable }
public sealed record ReferenceFinding(string SecretName, ReferenceStatus Status, IReadOnlyList<string> ConfigPaths);

/// <summary>Scans a target app's composed configuration for ${secret:NAME} tokens and checks each against the store.</summary>
public sealed class ReferenceAuditor
{
    public async Task<IReadOnlyList<ReferenceFinding>> AuditAsync(SecretsSession session, CancellationToken ct) { ... }
}
```

Walk `session.Target.Configuration.AsEnumerable()` values with the regex; for each distinct name: `store.GetAsync` → null ⇒ Missing; `IsDeleted` ⇒ Tombstoned; degraded ⇒ PresentUnverified; else `cipher.Decrypt` in try/catch(`SecretDecryptionException`) ⇒ Ok/Undecryptable (discard plaintext immediately; never store it in the finding).

**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): reference auditor — classify every ${secret:} the target app needs`.

---

### Task 6: KekDoctor

**Classification:** high-risk (drives rewrap remediation)
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5, Task 7

**Files:**
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/KekDoctor.cs`
- Read for reference: `src/ZB.MOM.WW.Secrets/Rotation/KekRotationService.cs`, `src/ZB.MOM.WW.Secrets/Rotation/RewrapReport.cs`
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/KekDoctorTests.cs`

**Step 1 (failing tests):** temp store seeded under KEK-A and KEK-B:
- `Diagnose_reports_ok_rows_under_session_kek`
- `Diagnose_reports_wrong_kek_rows_with_their_kekid` (session on B, rows on A → `WrongKek`, foreign KekId surfaced so the operator knows which key to hunt for)
- `Diagnose_reports_corrupt_row_when_kekid_matches_but_unwrap_fails` (tamper `WrapTag` byte)
- `Diagnose_throws_on_degraded_session` (`InvalidOperationException` — doctor needs a KEK)
- `RewrapAll_moves_wrong_kek_rows_onto_session_kek` (delegate to `KekRotationService`; assert report counts + rows now decrypt)

**Step 2:** FAIL. **Step 3 (implement):**

```csharp
public enum RowKekStatus { Ok, WrongKek, Corrupt }
public sealed record KekDiagnosis(string SecretName, RowKekStatus Status, string RowKekId);
public sealed record KekDoctorReport(string SessionKekId, IReadOnlyList<KekDiagnosis> Rows)
{ public bool Healthy => Rows.All(r => r.Status == RowKekStatus.Ok); }

/// <summary>Lockout triage: verifies the session KEK actually opens every stored row and drives the rewrap remedy.</summary>
public sealed class KekDoctor
{
    public async Task<KekDoctorReport> DiagnoseAsync(SecretsSession session, CancellationToken ct) { ... }
    public Task<RewrapReport> RewrapAllAsync(SecretsSession session, IMasterKeyProvider oldKek, CancellationToken ct)
        => new KekRotationService(session.Store, session.Cipher!).RewrapAllAsync(oldKek, session.MasterKey!, ct);
}
```

Diagnosis probe: KekId mismatch ⇒ WrongKek (no decrypt attempt); match ⇒ `cipher.Decrypt` try/catch ⇒ Ok/Corrupt. Include tombstoned rows (they still block rewrap-all).

**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): KEK doctor — per-row diagnosis + guided rewrap remedy`.

---

### Task 7: SecretBundle export/import

**Classification:** high-risk (data movement between stores, LWW + cross-KEK rewrap)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5, Task 6

**Files:**
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretBundle.cs` (DTOs + codec)
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/BundleService.cs`
- Read for reference: `src/ZB.MOM.WW.Secrets.Abstractions/SecretLastWriterWins.cs`, `src/ZB.MOM.WW.Secrets.Abstractions/StoredSecret.cs`
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/BundleServiceTests.cs`

**Step 1 (failing tests):**
- `Export_then_import_into_empty_store_roundtrips_rows_byte_identical`
- `Bundle_json_contains_no_plaintext` (export a known value; assert raw file text doesn't contain it)
- `Import_conflict_resolved_by_last_writer_wins` (newer local row survives; newer bundle row replaces)
- `Import_conflict_callback_can_force_bundle_row` (per-row override delegate)
- `Cross_kek_import_rewraps_when_source_kek_supplied` (bundle from KEK-A into session on KEK-B + `oldKek` → rows land on B and decrypt)
- `Cross_kek_import_without_source_kek_skips_rows_and_reports_them`

**Step 2:** FAIL. **Step 3 (implement):** DTO `SecretBundle { int FormatVersion = 1; DateTimeOffset ExportedUtc; string SourceKekId; List<BundleEntry> Entries; }` where `BundleEntry` mirrors every `StoredSecret` field (byte[] as base64, `SecretName` as string). `BundleService`:

```csharp
public sealed class BundleService
{
    public async Task<int> ExportAsync(SecretsSession session, string path, bool includeDeleted, CancellationToken ct) { ... }
    public async Task<BundleImportReport> ImportAsync(
        SecretsSession session, string path, IMasterKeyProvider? sourceKek,
        Func<StoredSecret /*existing*/, StoredSecret /*incoming*/, bool /*takeIncoming*/>? conflictOverride,
        CancellationToken ct) { ... }
}
public sealed record BundleImportReport(int Imported, int SkippedOlder, int SkippedForeignKek, int Conflicts);
```

Import per entry: rewrap via `session.Cipher.Rewrap(row, sourceKek, session.MasterKey)` when `row.KekId != session.MasterKey.KekId` and `sourceKek` matches, else count `SkippedForeignKek`; conflict when the store already has the name → default `SecretLastWriterWins`, `conflictOverride` (when supplied) decides instead; upsert winners. `ExportedUtc` from an injected `TimeProvider`.

**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): ciphertext-only bundle export/import with LWW + cross-KEK rewrap`.

---

### Task 8: InteractiveShell skeleton + IInteractiveFlow + target picker

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Tasks 9-12 depend on the flow seam)

**Files:**
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/IInteractiveFlow.cs`
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveShell.cs`
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveShellTests.cs`

**Step 1 (failing tests):** with `Spectre.Console.Testing.TestConsole` (`Interactive()` profile, `PushKey`/`PushTextWithEnter` scripted input) and a fake flow:
- `Shell_shows_target_picker_then_menu_and_quits` (recents + manual entry path; select Quit → returns 0)
- `Menu_lists_registered_flows_by_title_plus_builtins` (Switch target, Quit)
- `Degraded_session_flow_requiring_kek_prompts_for_key_first` (fake flow `RequiresKek = true`, degraded session → shell asks masked key/file prompt, upgrades session via factory override, then runs the flow)
- `Flow_exception_renders_error_panel_and_returns_to_menu` (fake flow throws `SecretDecryptionException` → output contains remedy hint text `KEK doctor`, shell loops, next Quit exits cleanly)
- `Ctrl_c_at_menu_exits_zero`

**Step 2:** FAIL. **Step 3 (implement):**

```csharp
/// <summary>One menu action of the interactive console. Implementations are UI-owning but store-logic-free.</summary>
public interface IInteractiveFlow
{
    string Title { get; }
    bool RequiresKek { get; }
    Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct);
}

/// <summary>The menu loop: target selection, session lifecycle, flow dispatch, error containment.</summary>
public sealed class InteractiveShell
{
    public InteractiveShell(IAnsiConsole console, RecentTargetsStore recents, TargetConfigReader reader,
        SecretsSessionFactory sessions, IReadOnlyList<IInteractiveFlow> flows) { ... }
    public async Task<int> RunAsync(CancellationToken ct) { ... }
}
```

Header panel per loop: target name, store kind, `KEK: OK` / `KEK: UNAVAILABLE (degraded)`. Menu = flows' titles + `Switch store target` + `Quit`. Degraded + `RequiresKek` ⇒ prompt (choice: base64 paste via `TextPrompt<string>().Secret()`, or key-file path) → `LiteralMasterKeyProvider`/file provider → reopen session with override. Every flow call wrapped in try/catch rendering `new Panel(...)` in red with the remedy hint; loop continues.

**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): interactive shell skeleton — target picker, degraded-KEK upgrade, flow dispatch`.

---

### Task 9: CRUD flows (list / set-rotate / reveal / delete)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 11, Task 12

**Files:**
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ListSecretsFlow.cs`
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/SetSecretFlow.cs`
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/RevealSecretFlow.cs`
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/DeleteSecretFlow.cs`
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/CrudFlowTests.cs`

**Step 1 (failing tests):** each flow run against a TestConsole + real temp-SQLite session:
- List: `Renders_metadata_table_never_values` (name/contentType/kekId/revision/updated columns; `RequiresKek == false`)
- Set: `Masked_prompt_seals_and_upserts` (typed value lands decryptable; typed value NOT in console output), `Existing_name_confirms_overwrite`
- Reveal: `Confirm_then_prints_value_once`, `Decline_prints_nothing`
- Delete: `Confirm_tombstones_row`, `Decline_leaves_row`

**Step 2:** FAIL. **Step 3:** Implement the four flows (each ~40 lines): name via `TextPrompt<string>` (validated through `SecretName`), value via `.Secret()`, content type via `SelectionPrompt<SecretContentType>`, description optional; seal with `session.Cipher.Encrypt` + actor `Environment.UserName` (same stamps as `SecretCommands.UpsertSealedAsync`). Reveal decrypts via `session.Cipher.Decrypt(store.GetAsync(...))` — no resolver, no cache.

**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): CRUD flows for the interactive console`.

---

### Task 10: Reference-audit + seeding flow

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 9, Task 11, Task 12

**Files:**
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/ReferenceAuditFlow.cs`
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/ReferenceAuditFlowTests.cs`

**Step 1 (failing tests):** TestConsole + session whose target config references three secrets (one Ok, one Missing, one Tombstoned):
- `Renders_status_table_for_all_references`
- `Offers_to_seed_each_non_ok_reference_and_seeds_accepted_ones` (masked prompts; afterwards re-audit reports all Ok)
- `Skipped_references_are_left_untouched`
- `Manual_target_without_appsettings_reports_nothing_to_audit`

**Step 2:** FAIL. **Step 3:** Implement: run `ReferenceAuditor.AuditAsync`, render table (status color-coded: green Ok / yellow PresentUnverified / red Missing/Tombstoned/Undecryptable + the config paths), then for each non-Ok finding `ConfirmationPrompt("Seed '<name>' now?")` → masked value + content-type prompts → seal + upsert (tombstoned: upsert revives), finish with a re-audit summary line. `RequiresKek == true`.

**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): deployment seeding flow — audit ${secret:} refs and fill the gaps`.

---

### Task 11: KEK-doctor flow

**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 9, Task 10, Task 12

**Files:**
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/KekDoctorFlow.cs`
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/KekDoctorFlowTests.cs`

**Step 1 (failing tests):**
- `Healthy_store_renders_all_ok_summary`
- `Wrong_kek_rows_offer_rewrap_and_rewrap_succeeds` (script: choose "Rewrap from old KEK", choose env-var source pointing at KEK-A, confirm → rows decrypt under session KEK; output shows report counts)
- `Rewrap_requires_explicit_confirmation` (decline → store untouched)
- `Lost_kek_path_offers_reset_of_affected_secrets` (choose "Old KEK is lost" → per-row confirm+masked re-set; declined rows stay wrong-KEK)

**Step 2:** FAIL. **Step 3:** Implement: `DiagnoseAsync` → summary table (session KekId, per-status counts, the foreign KekIds seen); if unhealthy, `SelectionPrompt`: *Rewrap from old KEK* (old key via env-var name / file path / masked base64 → provider; `ConfirmationPrompt` showing exactly what will happen; then `KekDoctor.RewrapAllAsync`, render `RewrapReport` counts) / *Old KEK is lost — re-set affected secrets* (per wrong-KEK row: confirm + masked new value → seal under session KEK, overwriting) / *Back*. `RequiresKek == true`.

**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): lockout-recovery flow — KEK diagnosis, guided rewrap, lost-KEK reset`.

---

### Task 12: Bundle flows (export / import)

**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 9, Task 10, Task 11

**Files:**
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/BundleExportFlow.cs`
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/Flows/BundleImportFlow.cs`
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/Flows/BundleFlowTests.cs`

**Step 1 (failing tests):**
- `Export_prompts_for_path_and_reports_row_count`
- `Import_reports_counts_and_applies_lww`
- `Import_conflict_prompt_lets_operator_pick_per_row` (script both choices)
- `Import_foreign_kek_prompts_for_source_key_then_rewraps`

**Step 2:** FAIL. **Step 3:** Implement over `BundleService`; export path prompt defaults to `secrets-bundle-<target>-<yyyyMMdd>.json`; import wires the flow's per-row `ConfirmationPrompt` into `conflictOverride` and, when `SkippedForeignKek` would be non-zero (bundle `SourceKekId` ≠ session KekId), first offers the source-key prompt (env/file/masked). Render final `BundleImportReport` as a table.

**Step 4:** PASS. **Step 5:** Commit `feat(secrets-cli): bundle export/import flows`.

---

### Task 13: Program.cs entry point + non-TTY pin

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (integrates everything)

**Files:**
- Modify: `src/ZB.MOM.WW.Secrets.Cli/Program.cs` (ONLY the `args.Length == 0` branch + composition)
- Create: `src/ZB.MOM.WW.Secrets.Cli/Interactive/InteractiveEntry.cs`
- Test: `tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/InteractiveEntryTests.cs`

**Step 1 (failing tests):**
- `ShouldEnterInteractive_true_only_for_no_args_and_real_tty` (pure static: `(args, inputRedirected, outputRedirected)` truth table — pins that any redirection keeps usage/exit-2)
- `CreateShell_composes_all_five_flows_plus_builtins` (flow titles present exactly once: List, Set/rotate, Reveal, Delete, Reference audit & seed, KEK doctor, Bundle export, Bundle import)

**Step 2:** FAIL. **Step 3:** `InteractiveEntry` exposes `ShouldEnterInteractive(...)` and `CreateShell(IAnsiConsole console)` (news up recents store at `~/.zb-secrets/recent-targets.json`, reader, factory, all flows). `Program.cs`: replace the bare `return Usage();` with

```csharp
if (args.Length == 0)
{
    if (InteractiveEntry.ShouldEnterInteractive(args, Console.IsInputRedirected, Console.IsOutputRedirected))
        return await InteractiveEntry.CreateShell(AnsiConsole.Console).RunAsync(ct);
    return Usage();
}
```

(The interactive path builds its own sessions per target, so move the host-based migration call so it only runs for the headless verbs — verify with the existing headless tests.)

**Step 4:** `dotnet test ZB.MOM.WW.Secrets.slnx` (FULL suite — protects the headless verbs) → all pass. Manual smoke: `dotnet run --project src/ZB.MOM.WW.Secrets.Cli < /dev/null` → usage, exit 2. **Step 5:** Commit `feat(secrets-cli): launch interactive console on bare secret in a TTY`.

---

### Task 14: Version bump, docs, final green run

**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none

**Files:**
- Modify: `Directory.Build.props` (`<Version>` 0.2.3 → 0.3.0)
- Modify: `README.md` (CLI section: interactive console paragraph + screenshot-style text sample)
- Create: `docs/operations/interactive-console.md` (operator runbook: launching, deployment-seeding walk-through, lockout playbook incl. degraded mode + rewrap, bundle notes; mirror the tone of `docs/operations/kek-rotation.md`)

**Step 1:** Make the edits. **Step 2:** `dotnet build ZB.MOM.WW.Secrets.slnx` → 0 warnings; `dotnet test ZB.MOM.WW.Secrets.slnx` → full suite green. **Step 3:** Commit `docs(secrets): interactive console runbook + 0.3.0 version bump`.

**Step 4 (handoff, not merge):** Do NOT merge to `main` from inside this task — another agent is active on `main`. Leave the worktree branch `worktree-secrets-interactive-cli` fully committed; merging/publishing is a separate user-decision step (family convention: publish the 5 packages + app bumps only on explicit rollout).

---

## Dependency graph

```
1 ─┬─ 2 ─┐
   └─ 3 ─┴─ 4 ─┬─ 5 ─┐
               ├─ 6 ─┤
               ├─ 7 ─┤
               └─ 8 ─┼─ 9 ──┐
                     ├─ 10 ─┤
                     ├─ 11 ─┼─ 13 ── 14
                     └─ 12 ─┘
```
(9-12 also depend on their service tasks: 10→5, 11→6, 12→7.)
