From 22b7079b937a7edd8f72a3cf79f0e9dcd5b8cb75 Mon Sep 17 00:00:00 2001 From: George Payne Date: Wed, 26 Aug 2026 22:31:56 +0200 Subject: [PATCH 1/7] Apply the visibility answer the Agents screen already recorded The field was absent from the wire models, so the flow asked who may read future sessions and step 3 overwrote the answer seconds later. It rides the Agents decision, so it is read off the same answer and validated against the CLI closed set rather than forwarded: the value lands in profile config and is stamped on every session after. --- README.md | 4 +- docs/CHANGES.md | 18 +++++ ...2282-default-visibility-cli-half-design.md | 67 +++++++++++++++++++ .../FirstRun/FirstRunAgentsAnswer.cs | 14 +++- .../FirstRun/FirstRunFlowModels.cs | 14 ++++ .../FirstRun/FirstRunFlowOutcomes.cs | 15 ++++- src/Capacitor.Cli/Commands/SetupCommand.cs | 25 +++++-- .../FirstRun/FirstRunFlowOutcomesTests.cs | 54 +++++++++++++++ .../Commands/SetupCommandTests.cs | 18 +++++ 9 files changed, 217 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-26-ai2282-default-visibility-cli-half-design.md diff --git a/README.md b/README.md index 82e3a00bb..25df409f2 100644 --- a/README.md +++ b/README.md @@ -114,12 +114,14 @@ The setup wizard walks you through: kcap reports the coding agents it found on this machine — which are on your `PATH`, which have config on disk, which kcap is already wired into, and whether your login shell can find `kcap` — since only the machine can know that, and on a device-code sign-in the browser is a different box entirely. **Whatever you choose there is what step 4 installs**, per agent and per choice, instead of the terminal prompt; a `--skip-` flag still wins, and "Not now" installs nothing. + That screen also asks who may read the sessions this machine records from now on, and **step 3 then reports that answer instead of asking again**. Declining every agent and still choosing an audience is a coherent answer, so the two are read separately; if the screen was never answered, step 3 prompts as before. + **The Import screen's figures come from this machine, and its answer runs here.** Once you have answered the Agents screen, kcap scans for past sessions and reports what it found — per repository and per history window (last 30 days / last 90 days / everything), plus how many sessions could not be attributed to a repository at all. It scans only the agents you kept: an agent you were offered and left off is skipped, while one with history on disk but nothing installed now was never offered, so its history still counts. The report is capped at 200 repositories, newest activity first, and the screen says how many it is not showing. When you answer that screen, **the import runs in the terminal while you are still on the browser's last screen** — one pass for repositories you kept to yourself (`--private`) and one for those you shared, each scoped to the window you chose. Waiting resumes when it finishes, and step 6 below then reports what ran instead of asking again. A shared repository gets an explicit per-session visibility write, because leaning on the profile default would deliver owner-only on any server that is not using the shared GitHub App. **The browser can ask this machine to fix a broken `PATH` while you wait.** If your login shell cannot find `kcap`, the Agents screen offers to link it, and pressing that button runs the same `kcap daemon shim ensure` you could run yourself — so on macOS **you will be asked for your admin password** while you are still looking at the browser. kcap prints a line naming what asked, before the dialog appears. The screen reports what happened either way; nothing is attempted unless you press the button, and only the named PATH fix can be requested — the browser cannot ask this machine to run anything else. -3. **Default visibility** — choose how your sessions are visible to others +3. **Default visibility** — choose how your sessions are visible to others. Answered on the browser's Agents screen, this step reports that choice rather than prompting 4. **Coding-agent hooks** — detects Claude Code and Codex CLI on `PATH`, Cursor by user-dir presence (`~/.cursor/`), GitHub Copilot CLI by `~/.copilot/` or `copilot` on `PATH`, Google Gemini CLI by `~/.gemini/` or `gemini` on `PATH`, AWS Kiro CLI by `~/.kiro/` or `kiro`/`kiro-cli` on `PATH`, Pi by `~/.pi/` or `pi` on `PATH`, SST OpenCode by `~/.config/opencode/` (or `~/.local/share/opencode/`) or `opencode` on `PATH`, and Google Antigravity by `~/.gemini/antigravity/` (GUI) or `~/.gemini/antigravity-cli/` (the `agy` CLI) or `antigravity`/`agy` on `PATH`, lists what it found, then asks **one** yes/no prompt to install kcap for every detected agent (hooks — or, for Pi/OpenCode/Antigravity, the live-ingest plugin — plus skills, instructions, and MCP) — plus a single shared set of agent skills under `~/.agents/skills/`, installed once when any of Codex, Cursor, Copilot, Gemini, Pi, or OpenCode is detected (Claude gets its skills from the bundled plugin; AWS Kiro and Google Antigravity read their own skills dirs — `~/.kiro/skills` and `~/.gemini/skills` respectively — so each gets its own copy there instead of the shared tree) — all user-wide. For Codex it also offers to enable **sandbox network access** for kcap (see below) — Codex blocks sandbox network by default, so the kcap skills can't reach the server without it. Each agent's own config-relocation environment variable is honored when set: `CLAUDE_CONFIG_DIR` (Claude), `CODEX_HOME` (Codex), `GEMINI_CLI_HOME` (Gemini — names the parent of `.gemini`), `KIRO_HOME` (Kiro), `COPILOT_HOME` (Copilot), `OPENCODE_CONFIG_DIR` (OpenCode), and `PI_CODING_AGENT_DIR` (Pi). Cursor's hooks path is fixed at `~/.cursor/hooks.json` and is not relocated. 5. **Daemon** — configure the daemon name for remote agent execution (the daemon verb is `kcap daemon`; `kcap agent` is a separate group that runs coding agents — see [Local agents](#local-agents-kcap-agent)) 6. **Import past sessions** — offers (default yes) to import this repository's past sessions across every detected agent, equivalent to `kcap import --repo .`. Only shown when the current directory is a git repo with a resolvable origin remote and your authentication requirements are satisfied — which includes no-auth servers (auth provider `None`, no token needed); otherwise it's skipped with the usual `kcap import` hint. Opt out with `--skip-import`. **Answered in the browser, this step reports rather than prompts** — the screen there chooses repositories and a history window this prompt cannot express, so re-asking would offer to redo part of what already ran. diff --git a/docs/CHANGES.md b/docs/CHANGES.md index 433d01274..f55e87293 100644 --- a/docs/CHANGES.md +++ b/docs/CHANGES.md @@ -7,6 +7,24 @@ Not release notes. Each entry is written as of the change that produced it and i code moves on; where an entry disagrees with the code, the code wins. +## The Agents screen's visibility answer reaches the profile + +The flow asked who may read future sessions, recorded it on `FirstRunAgentsDecidedEvent`, served it on +the poll as `default_visibility` — and no CLI read it. The field was absent from the wire models +entirely, so it was dropped at deserialisation and `kcap setup`'s step 3 prompted unconditionally and +wrote its own answer over it. The one place in the flow that asked a question and discarded the answer. + +It rides the Agents decision, so it is read off the same answer and gated the same way, and it is +validated against `AppConfig.ValidVisibilities` rather than forwarded: the value lands in profile +config and is stamped on every session afterwards, so a stop a newer server invented would be written +to a file this build owns and read back by something that may not mean the same by it. A dropped value +degrades to null, which leaves the profile as it was — the same outcome as never having asked. + +Null is not a value here, and covers both "unanswered" and "declined everything". Declining every +harness while still choosing an audience is coherent, so `IsDecline` says nothing about the visibility. +No precedence question against `--default-visibility` arises: that flag is read only under +`--no-prompt`, where the browser leg never runs. + ## `--private` stamps a value An omitted `default_visibility` is not "no default": the server's generated column reads diff --git a/docs/superpowers/specs/2026-08-26-ai2282-default-visibility-cli-half-design.md b/docs/superpowers/specs/2026-08-26-ai2282-default-visibility-cli-half-design.md new file mode 100644 index 000000000..7c1b4c75a --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-ai2282-default-visibility-cli-half-design.md @@ -0,0 +1,67 @@ +# AI-2282 — Apply the Agents step's default-visibility answer + +## Problem + +The Agents step asks who may read the sessions this machine records from now on, records the answer on +`FirstRunAgentsDecidedEvent`, and serves it on the poll as `default_visibility`. **No CLI read it.** The +field was absent from `Capacitor.Cli.Core/FirstRun`'s wire models, so it was dropped at +deserialisation, and `SetupCommand`'s step 3 then prompted unconditionally and wrote its own answer to +the profile. + +That made it the one place in the flow that asked a question and discarded the answer. The Agents +decision itself is applied; this rides the same event and was not. + +AI-2215's spec named the CLI half as AI-2216, and AI-2216 merged without it — a gap between two +tickets rather than a decision. + +## Where it lives + +**On `FirstRunAgentsAnswer`, not beside it.** It rides the same decision and the same timestamp, so a +separate reader would need the same gate (the Agents step settled), the same null handling, and could +disagree with the choices about which decision it came from. One answer, two fields. + +**Validated, never forwarded.** The value lands in profile config and is stamped on every session this +machine records afterwards. A stop a newer server invented would therefore be written to a file this +build owns and read back later by a server that may no longer mean the same thing by it — which is the +same argument the lane already makes for vendor keys and enum members, applied to the one field that +persists locally. `AppConfig.ValidVisibilities` is the closed set; AI-2215's own spec argued against a +parallel enum, and this is why it was right: the CLI already has the set, and a second spelling of it +would be a mapping table with no meaning. + +A dropped value degrades to null, which leaves the profile exactly as it was — the same outcome as +never having asked, and the only degradation that cannot make a session more visible than the user's +existing configuration already allows. + +## Null is not a value + +Null covers two situations the CLI must not tell apart: the step is unanswered, and the user declined +everything. Neither asks for a default, so both fall through to the prompt. + +**`IsDecline` says nothing about it.** Declining every harness and still choosing who may read future +sessions is a coherent answer — the screen asks two questions — so the two are read separately. + +## The precedence question that turned out not to exist + +The ticket expected a rule for `--default-visibility` against the browser's answer, on the precedent +that `--skip-` still wins over the Agents decision. There is no case: that flag is read **only** +under `--no-prompt`, and the browser leg is skipped under `--no-prompt` entirely (it waits on a human). +So the two can never both be present, and inventing a precedence rule would have been dead code with a +test that could only assert its own scaffolding. + +Interactively the flag is ignored today, before and after this change. Making it live is a separate +behaviour change and not this ticket's. + +## Copy + +Step 3 reports rather than prompts, as steps 4 and 6 already do when the browser answered them. The +stop labels moved into `SetupCommand.VisibilityLabel` so the prompt's converter and the report share +one list — two lists that have to correspond are one list, and a stop described differently in two +places is how a user learns not to trust either. + +## Tests + +The boundary's own: every stop the wire can name is carried, null leaves the profile alone, an unknown +stop and an empty string both degrade to null, and declining every harness still carries the answer. +Two mutations confirm they bind — ignoring the validation, and never reading the field — and both had +to be reshaped to compile, because dropping the reference outright trips IDE0005/IDE0051 as errors. +Analyzer protection is worth noting but is not test coverage. diff --git a/src/Capacitor.Cli.Core/FirstRun/FirstRunAgentsAnswer.cs b/src/Capacitor.Cli.Core/FirstRun/FirstRunAgentsAnswer.cs index 82bd89629..3a2e0e372 100644 --- a/src/Capacitor.Cli.Core/FirstRun/FirstRunAgentsAnswer.cs +++ b/src/Capacitor.Cli.Core/FirstRun/FirstRunAgentsAnswer.cs @@ -25,12 +25,20 @@ public sealed record FirstRunAgentsChoice(string VendorId, bool Record, bool Too /// How many entries named a vendor this build has never heard of. Dropped /// rather than forwarded, and counted so the user can be told their CLI is behind their server rather /// than left with a harness that silently did not get set up. +/// Who may read the sessions this machine records from now on, or null +/// to leave the profile's own value alone. Part of this answer rather than beside it, because it rides +/// the same decision and the same timestamp — see the wire model. public sealed record FirstRunAgentsAnswer( IReadOnlyList Choices, DateTimeOffset DecidedAt, - int Unrecognised) { - /// The user asked for nothing, and we understood all of it. Distinct from an answer whose - /// every entry was dropped, which asks for nothing only because this build could not read it. + int Unrecognised, + string? DefaultVisibility = null) { + /// The user asked for nothing to be installed, and we understood all of it. Distinct from + /// an answer that asks for nothing only because none of its entries are readable here. + /// + /// Says nothing about : declining every harness and still + /// choosing who may read future sessions is a coherent answer, so the two are read separately. + /// public bool IsDecline => Choices.Count == 0 && Unrecognised == 0; /// Install capture for this harness. False for a vendor the answer never mentions — a diff --git a/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs b/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs index 5ceb7311a..ed8318698 100644 --- a/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs +++ b/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs @@ -209,6 +209,20 @@ public sealed record FirstRunFlowResponse { /// [JsonPropertyName("agents_decided_at")] public DateTimeOffset? AgentsDecidedAt { get; init; } + /// + /// The default session visibility the same decision chose, as a canonical + /// default_visibility value. + /// + /// Null means leave the profile alone, and it is null in two situations this build must + /// not tell apart: the step is unanswered, and the user declined everything. Neither asks for a + /// default. + /// + /// It can name a stop this tenant would no longer offer, because the server's fold keeps + /// a durable answer as given. Such a value is still the one to write: every stop it can name resolves + /// no wider than the profile's own fallback. + /// + [JsonPropertyName("default_visibility")] public string? DefaultVisibility { get; init; } + /// /// What the browser is asking this machine to do, and the one field on this response the CLI acts on /// rather than records. Absent or empty means nothing is outstanding. diff --git a/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowOutcomes.cs b/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowOutcomes.cs index 53c645665..eae24280e 100644 --- a/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowOutcomes.cs +++ b/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowOutcomes.cs @@ -1,3 +1,4 @@ +using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.Setup; namespace Capacitor.Cli.Core.FirstRun; @@ -149,9 +150,21 @@ public static bool IsFinished(FirstRunFlowResponse view) => choices.Add(new FirstRunAgentsChoice(entry.Vendor, entry.Record, entry.Tools)); } - return new FirstRunAgentsAnswer(choices, decidedAt, unrecognised); + return new FirstRunAgentsAnswer(choices, decidedAt, unrecognised, Visibility(view.DefaultVisibility)); } + /// + /// The default visibility to write, or null to leave the profile alone. + /// + /// Validated against this build's own closed set, not forwarded. The value lands in + /// profile config and is stamped on every session this machine records afterwards, so one a newer + /// server invented would be written to disk and then read back by a server that may no longer mean + /// the same thing by it. Null is what a dropped value degrades to, which leaves the profile as it + /// was — the same outcome as never having asked. + /// + static string? Visibility(string? value) => + value is { Length: > 0 } && AppConfig.ValidVisibilities.Contains(value) ? value : null; + /// /// The actions the browser is asking this machine to perform, filtered to ones this build can name. /// diff --git a/src/Capacitor.Cli/Commands/SetupCommand.cs b/src/Capacitor.Cli/Commands/SetupCommand.cs index f9de1aeec..9d7fa510f 100644 --- a/src/Capacitor.Cli/Commands/SetupCommand.cs +++ b/src/Capacitor.Cli/Commands/SetupCommand.cs @@ -450,17 +450,18 @@ await Console.Error.WriteLineAsync( } await Console.Out.WriteLineAsync($" Default visibility: {defaultVisibility}"); + } else if (browserAgents?.DefaultVisibility is { } chosenInBrowser) { + // Answered on the Agents screen, which asks this question in the same words. Prompting again + // would take a second answer and silently keep it, which is what this step used to do. + defaultVisibility = chosenInBrowser; + + AnsiConsole.MarkupLine( + $" [dim]· Chosen in the browser: {Markup.Escape(VisibilityLabel(defaultVisibility))}[/]"); } else { var visibilityPrompt = new SelectionPrompt() .Title("Which of your sessions should be readable by other users in the same Kurrent Capacitor account by default?") .AddChoices(AppConfig.ValidVisibilities) - .UseConverter(v => v switch { - "private" => "All private — only you can see your sessions", - "project" => "Project repos public to fellow project members, others private", - "org_public" => "Org repos public, others private (default)", - "public" => "All public — others can see all your sessions", - _ => v - }); + .UseConverter(VisibilityLabel); // Start the cursor on the option we label "(default)" rather than the first choice. visibilityPrompt.DefaultValue = "org_public"; @@ -1216,6 +1217,16 @@ internal async Task RunLoginStepAsync( return 0; } + /// What each default_visibility stop is called. Shared by the prompt and by the line + /// that reports the browser's answer, because two lists that have to correspond are one list. + internal static string VisibilityLabel(string visibility) => visibility switch { + "private" => "All private — only you can see your sessions", + "project" => "Project repos public to fellow project members, others private", + "org_public" => "Org repos public, others private (default)", + "public" => "All public — others can see all your sessions", + _ => visibility + }; + /// Per request, not per leg: the poll below runs for as long as a human takes. static readonly TimeSpan BrowserFlowHttpTimeout = TimeSpan.FromSeconds(15); diff --git a/test/Capacitor.Cli.Core.Tests.Unit/FirstRun/FirstRunFlowOutcomesTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/FirstRun/FirstRunFlowOutcomesTests.cs index 95ebbaec4..4e9e035cc 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/FirstRun/FirstRunFlowOutcomesTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/FirstRun/FirstRunFlowOutcomesTests.cs @@ -1,3 +1,4 @@ +using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.FirstRun; namespace Capacitor.Cli.Core.Tests.Unit.FirstRun; @@ -335,4 +336,57 @@ public async Task A_dismissed_leg_still_carries_the_decision_it_was_given() { await Assert.That(FirstRunFlowOutcomes.Import(result)).IsNotNull(); } + + // ===================================================================== + // The default visibility the same decision carries. + // ===================================================================== + + static FirstRunFlowResponse WithVisibility(string? visibility) => + AllSettled() with { + Agents = [new FirstRunAgentChoiceResponse { Vendor = "claude", Record = true, Tools = true }], + AgentsDecidedAt = new DateTimeOffset(2026, 8, 21, 12, 0, 0, TimeSpan.Zero), + DefaultVisibility = visibility + }; + + [Test] + public async Task Each_stop_the_wire_can_name_is_carried_through() { + foreach (var stop in AppConfig.ValidVisibilities) { + await Assert.That(FirstRunFlowOutcomes.Agents(WithVisibility(stop))!.DefaultVisibility) + .IsEqualTo(stop); + } + } + + [Test] + public async Task No_visibility_answer_leaves_the_profile_alone() { + // Null covers both "unanswered" and "declined everything", and neither asks for a default. + await Assert.That(FirstRunFlowOutcomes.Agents(WithVisibility(null))!.DefaultVisibility).IsNull(); + } + + [Test] + public async Task A_stop_this_build_does_not_know_is_dropped_rather_than_written_to_disk() { + // It would land in profile config and be stamped on every session afterwards, so forwarding one + // a newer server invented writes a value this build cannot reason about to a file it owns. + await Assert.That(FirstRunFlowOutcomes.Agents(WithVisibility("telepathy"))!.DefaultVisibility).IsNull(); + } + + [Test] + public async Task An_empty_visibility_string_is_not_a_stop() { + await Assert.That(FirstRunFlowOutcomes.Agents(WithVisibility(""))!.DefaultVisibility).IsNull(); + } + + [Test] + public async Task Declining_every_harness_still_carries_the_visibility_answer() { + // Two separate questions on one screen: installing nothing and choosing who may read future + // sessions are both coherent together. + var view = AllSettled() with { + Agents = [], + AgentsDecidedAt = new DateTimeOffset(2026, 8, 21, 12, 0, 0, TimeSpan.Zero), + DefaultVisibility = "private" + }; + + var answer = FirstRunFlowOutcomes.Agents(view)!; + + await Assert.That(answer.IsDecline).IsTrue(); + await Assert.That(answer.DefaultVisibility).IsEqualTo("private"); + } } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs index 07f66d8ea..2637e0321 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs @@ -163,6 +163,24 @@ public async Task BrowserImportSummary_an_answer_it_read_whole_gets_one_line() { await Assert.That(SetupCommand.BrowserImportSummary(ImportAnswer(repos: "kcap")).Count).IsEqualTo(1); } + [Test] + public async Task VisibilityLabel_names_every_stop_the_wire_can_carry() { + // One list behind the prompt and behind the browser-answer line, so the two cannot describe the + // same stop differently. + foreach (var stop in AppConfig.ValidVisibilities) { + await Assert.That(SetupCommand.VisibilityLabel(stop)) + .IsNotEqualTo(stop) + .Because($"'{stop}' has no human label"); + } + } + + [Test] + public async Task VisibilityLabel_falls_back_to_the_value_for_a_stop_it_does_not_know() { + // Reachable only if the closed set grows without this switch; showing the raw value beats + // showing nothing. + await Assert.That(SetupCommand.VisibilityLabel("telepathy")).IsEqualTo("telepathy"); + } + // --- Step 6 import auth-eligibility probe (IsAuthSatisfiedAsync) --- [Test] From 29c35dea9a159a2b0171cfc429a0b70e44356e44 Mon Sep 17 00:00:00 2001 From: George Payne Date: Wed, 26 Aug 2026 22:35:48 +0200 Subject: [PATCH 2/7] Keep the code history out of the comments --- src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs | 6 +++--- src/Capacitor.Cli.Core/FirstRun/FirstRunFlowOutcomes.cs | 6 +++--- src/Capacitor.Cli/Commands/SetupCommand.cs | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs b/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs index ed8318698..68b76e528 100644 --- a/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs +++ b/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs @@ -217,9 +217,9 @@ public sealed record FirstRunFlowResponse { /// not tell apart: the step is unanswered, and the user declined everything. Neither asks for a /// default. /// - /// It can name a stop this tenant would no longer offer, because the server's fold keeps - /// a durable answer as given. Such a value is still the one to write: every stop it can name resolves - /// no wider than the profile's own fallback. + /// It can name a stop this tenant does not currently offer, because the server's fold + /// keeps a durable answer as given. Such a value is still the one to write: every stop it can name + /// resolves no wider than the profile's own fallback. /// [JsonPropertyName("default_visibility")] public string? DefaultVisibility { get; init; } diff --git a/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowOutcomes.cs b/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowOutcomes.cs index eae24280e..f61e13c3e 100644 --- a/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowOutcomes.cs +++ b/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowOutcomes.cs @@ -158,9 +158,9 @@ public static bool IsFinished(FirstRunFlowResponse view) => /// /// Validated against this build's own closed set, not forwarded. The value lands in /// profile config and is stamped on every session this machine records afterwards, so one a newer - /// server invented would be written to disk and then read back by a server that may no longer mean - /// the same thing by it. Null is what a dropped value degrades to, which leaves the profile as it - /// was — the same outcome as never having asked. + /// server invented would be written to disk and then read back by a server whose meaning for it this + /// build cannot check. Null is what a dropped value degrades to, which leaves the profile untouched — + /// the same outcome as never having asked. /// static string? Visibility(string? value) => value is { Length: > 0 } && AppConfig.ValidVisibilities.Contains(value) ? value : null; diff --git a/src/Capacitor.Cli/Commands/SetupCommand.cs b/src/Capacitor.Cli/Commands/SetupCommand.cs index 9d7fa510f..00502fd5f 100644 --- a/src/Capacitor.Cli/Commands/SetupCommand.cs +++ b/src/Capacitor.Cli/Commands/SetupCommand.cs @@ -452,7 +452,7 @@ await Console.Error.WriteLineAsync( await Console.Out.WriteLineAsync($" Default visibility: {defaultVisibility}"); } else if (browserAgents?.DefaultVisibility is { } chosenInBrowser) { // Answered on the Agents screen, which asks this question in the same words. Prompting again - // would take a second answer and silently keep it, which is what this step used to do. + // would take a second answer and silently keep it. defaultVisibility = chosenInBrowser; AnsiConsole.MarkupLine( From 2455e5e8b2911a61edfcaec5a63b467ac2584654 Mon Sep 17 00:00:00 2001 From: George Payne Date: Wed, 26 Aug 2026 23:07:17 +0200 Subject: [PATCH 3/7] Keep the profile when the screen was answered and left unset Falling through to the prompt does not leave the profile alone: its cursor starts on org_public, so a Return on a re-run widens an existing private on a question already answered. Whether the step settled separates the two nulls, and DecideVisibility is extracted because step 3 interactive branches had no coverage at all. --- README.md | 2 +- docs/CHANGES.md | 14 +++-- ...2282-default-visibility-cli-half-design.md | 52 +++++++++++++++---- src/Capacitor.Cli/Commands/SetupCommand.cs | 49 ++++++++++++++--- .../FirstRun/FirstRunFlowClientTests.cs | 46 ++++++++++++++++ .../Commands/SetupCommandTests.cs | 45 ++++++++++++++++ 6 files changed, 185 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 25df409f2..91cdfd408 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ The setup wizard walks you through: kcap reports the coding agents it found on this machine — which are on your `PATH`, which have config on disk, which kcap is already wired into, and whether your login shell can find `kcap` — since only the machine can know that, and on a device-code sign-in the browser is a different box entirely. **Whatever you choose there is what step 4 installs**, per agent and per choice, instead of the terminal prompt; a `--skip-` flag still wins, and "Not now" installs nothing. - That screen also asks who may read the sessions this machine records from now on, and **step 3 then reports that answer instead of asking again**. Declining every agent and still choosing an audience is a coherent answer, so the two are read separately; if the screen was never answered, step 3 prompts as before. + That screen also asks who may read the sessions this machine records from now on, and **step 3 then reports that answer instead of asking again**. Declining every agent and still choosing an audience is a coherent answer, so the two are read separately. Answer that screen without setting an audience and step 3 keeps whatever your profile already has, rather than re-asking — its prompt defaults to org-public, so re-asking could widen a narrower setting you never changed. If the screen was never answered at all, step 3 prompts as before. **The Import screen's figures come from this machine, and its answer runs here.** Once you have answered the Agents screen, kcap scans for past sessions and reports what it found — per repository and per history window (last 30 days / last 90 days / everything), plus how many sessions could not be attributed to a repository at all. It scans only the agents you kept: an agent you were offered and left off is skipped, while one with history on disk but nothing installed now was never offered, so its history still counts. The report is capped at 200 repositories, newest activity first, and the screen says how many it is not showing. diff --git a/docs/CHANGES.md b/docs/CHANGES.md index f55e87293..53bedd9be 100644 --- a/docs/CHANGES.md +++ b/docs/CHANGES.md @@ -20,10 +20,16 @@ config and is stamped on every session afterwards, so a stop a newer server inve to a file this build owns and read back by something that may not mean the same by it. A dropped value degrades to null, which leaves the profile as it was — the same outcome as never having asked. -Null is not a value here, and covers both "unanswered" and "declined everything". Declining every -harness while still choosing an audience is coherent, so `IsDecline` says nothing about the visibility. -No precedence question against `--default-visibility` arises: that flag is read only under -`--no-prompt`, where the browser leg never runs. +**The two nulls are not the same.** The field is null both when the step is unanswered and when it was +answered and left unset, and only the first should reach the prompt: the prompt's cursor starts on +`org_public`, so a Return on a re-run would widen an existing `private` on a question the user had +already answered. An answered-but-unset screen therefore re-writes what the profile already holds, +which is the lane's contract for a null answer and a no-op for everything downstream. Whether the step +settled is what separates them, and `SetupCommand.DecideVisibility` is the one place that decides. + +Declining every harness while still choosing an audience is coherent, so `IsDecline` says nothing about +the visibility. No precedence question against `--default-visibility` arises: that flag is read only +under `--no-prompt`, where the browser leg never runs. ## `--private` stamps a value diff --git a/docs/superpowers/specs/2026-08-26-ai2282-default-visibility-cli-half-design.md b/docs/superpowers/specs/2026-08-26-ai2282-default-visibility-cli-half-design.md index 7c1b4c75a..3177e531c 100644 --- a/docs/superpowers/specs/2026-08-26-ai2282-default-visibility-cli-half-design.md +++ b/docs/superpowers/specs/2026-08-26-ai2282-default-visibility-cli-half-design.md @@ -28,14 +28,36 @@ persists locally. `AppConfig.ValidVisibilities` is the closed set; AI-2215's own parallel enum, and this is why it was right: the CLI already has the set, and a second spelling of it would be a mapping table with no meaning. -A dropped value degrades to null, which leaves the profile exactly as it was — the same outcome as -never having asked, and the only degradation that cannot make a session more visible than the user's -existing configuration already allows. +A dropped value degrades to null, and what null then means depends on whether the step settled — see +below. Either way the degradation cannot widen anything: on a settled step the profile is carried +unchanged, and on an unsettled one the user is asked. -## Null is not a value +## Null is not a value, and the two nulls are not the same -Null covers two situations the CLI must not tell apart: the step is unanswered, and the user declined -everything. Neither asks for a default, so both fall through to the prompt. +The wire field is null both when the step is unanswered and when the user answered it and set nothing. +An earlier draft of this design said both fall through to the prompt. **That is wrong, and it was the +one defect an external review found here**: the prompt cannot leave the profile alone, because its +cursor starts on `org_public`, so a single Return on a re-run silently widens an existing `private` — +on a screen the user had already answered. The lane's own contract says a null answer leaves the +profile alone; falling through to the prompt violates it. + +So the two nulls are separated by whether the **step settled**, which `FirstRunAgentsAnswer` already +encodes by existing at all: + +| Agents step | `default_visibility` | Step 3 | +| --- | --- | --- | +| settled | a stop | applies it | +| settled | null | re-writes what the profile already holds, and says so | +| never settled | — | prompts, exactly as before | + +Re-writing the profile's own value rather than skipping the write keeps `defaultVisibility` a single +non-nullable string for everything downstream — the profile write, the `saved` context, step 6's +import stamp — instead of threading a nullable through paths that have no meaning for one. + +The rule is `SetupCommand.DecideVisibility`, extracted because `HandleSetupAsync`'s interactive +branches have no test coverage at all: every `HandleAsync_*` test drives `--no-prompt`, which never +reaches the browser leg. A rule a reviewer just found a defect in should not be the part that is +untestable. **`IsDecline` says nothing about it.** Declining every harness and still choosing who may read future sessions is a coherent answer — the screen asks two questions — so the two are read separately. @@ -60,8 +82,16 @@ places is how a user learns not to trust either. ## Tests -The boundary's own: every stop the wire can name is carried, null leaves the profile alone, an unknown -stop and an empty string both degrade to null, and declining every harness still carries the answer. -Two mutations confirm they bind — ignoring the validation, and never reading the field — and both had -to be reshaped to compile, because dropping the reference outright trips IDE0005/IDE0051 as errors. -Analyzer protection is worth noting but is not test coverage. +The boundary's own: every stop the wire can name is carried, null degrades to null, an unknown stop and +an empty string both degrade to null, and declining every harness still carries the answer. Two +mutations confirm they bind — ignoring the validation, and never reading the field — and both had to be +reshaped to compile, because dropping the reference outright trips IDE0005/IDE0051 as errors. Analyzer +protection is worth noting but is not test coverage. + +`DecideVisibility`'s three arms are pinned separately, with three more mutations: an answered-but-unset +screen falling through to the prompt, an unsettled screen treated as answered, and the kept branch +inventing a fallback of its own instead of carrying the profile's value. + +The field is also round-tripped through the source-generated JSON context against WireMock, present and +absent. Nothing else covered that: every other test builds `FirstRunFlowResponse` directly, so a naming +or AOT-binding slip would have left the profile untouched for ever with the whole suite green. diff --git a/src/Capacitor.Cli/Commands/SetupCommand.cs b/src/Capacitor.Cli/Commands/SetupCommand.cs index 00502fd5f..649da0b4a 100644 --- a/src/Capacitor.Cli/Commands/SetupCommand.cs +++ b/src/Capacitor.Cli/Commands/SetupCommand.cs @@ -440,6 +440,15 @@ await Console.Error.WriteLineAsync( string defaultVisibility; + // The flow's answer, resolved once above the branches so the rule sits in one testable place. + // The profile is read only where the answer might defer to it — a run with no flow needs none. + var flowVisibility = noPrompt || browserAgents is null + ? new VisibilityDecision(null, Kept: false) + : DecideVisibility( + browserAgents, + (await AppConfig.LoadProfileConfig(config)) + .Profiles.GetValueOrDefault(activeProfile)?.DefaultVisibility ?? "org_public"); + if (noPrompt) { defaultVisibility = (GetArg(args, "--default-visibility") ?? "org_public").ToLowerInvariant(); @@ -450,13 +459,14 @@ await Console.Error.WriteLineAsync( } await Console.Out.WriteLineAsync($" Default visibility: {defaultVisibility}"); - } else if (browserAgents?.DefaultVisibility is { } chosenInBrowser) { - // Answered on the Agents screen, which asks this question in the same words. Prompting again - // would take a second answer and silently keep it. - defaultVisibility = chosenInBrowser; - - AnsiConsole.MarkupLine( - $" [dim]· Chosen in the browser: {Markup.Escape(VisibilityLabel(defaultVisibility))}[/]"); + } else if (flowVisibility.Apply is { } fromFlow) { + // Re-writing the profile's own value is the no-op that keeps the rest of the run — the + // import stamp, the summary — reading one field rather than two. + defaultVisibility = fromFlow; + + AnsiConsole.MarkupLine(flowVisibility.Kept + ? $" [dim]· Not chosen in the browser - keeping {Markup.Escape(VisibilityLabel(fromFlow))}[/]" + : $" [dim]· Chosen in the browser: {Markup.Escape(VisibilityLabel(fromFlow))}[/]"); } else { var visibilityPrompt = new SelectionPrompt() .Title("Which of your sessions should be readable by other users in the same Kurrent Capacitor account by default?") @@ -1217,6 +1227,31 @@ internal async Task RunLoginStepAsync( return 0; } + /// + /// What step 3 does about the default visibility: apply a value, or prompt. + /// + /// The value to apply, or null to prompt. + /// The value is the profile's own, carried because the screen was answered and + /// left unset. Distinguished from a browser choice only so the line can say which happened. + internal readonly record struct VisibilityDecision(string? Apply, bool Kept); + + /// + /// Which default visibility step 3 applies. + /// + /// An answered screen that set nothing leaves the profile alone, which is the lane's + /// contract for a null answer — and the reason this cannot simply fall through to the prompt: the + /// prompt's cursor starts on org_public, so one Return would widen an existing + /// private. A screen that was never answered has told us nothing and still needs asking. + /// + /// The Agents answer, or null where that step never settled. + /// What the profile holds now. + internal static VisibilityDecision DecideVisibility(FirstRunAgentsAnswer? browser, string current) => + browser switch { + { DefaultVisibility: { } chosen } => new(chosen, Kept: false), + not null => new(current, Kept: true), + _ => new(null, Kept: false) + }; + /// What each default_visibility stop is called. Shared by the prompt and by the line /// that reports the browser's answer, because two lists that have to correspond are one list. internal static string VisibilityLabel(string visibility) => visibility switch { diff --git a/test/Capacitor.Cli.Core.Tests.Unit/FirstRun/FirstRunFlowClientTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/FirstRun/FirstRunFlowClientTests.cs index f01f9f26a..707bc8132 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/FirstRun/FirstRunFlowClientTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/FirstRun/FirstRunFlowClientTests.cs @@ -513,4 +513,50 @@ public async Task PollAsync_reads_the_import_decision_the_server_returns() { await Assert.That(answer.Vendors).IsEquivalentTo(["claude"]); await Assert.That(answer.Choices.Single().Level).IsEqualTo(FirstRunImportLevel.Shared); } + + [Test] + public async Task PollAsync_reads_the_default_visibility_the_decision_carries() { + // Through the source-generated context, not a hand-built response: the field lands in profile + // config, so a naming or AOT-binding slip would silently leave the profile untouched forever + // while every unit test above still passed. + using var server = WireMockServer.Start(); + server.Given(Request.Create().WithPath(PollPath).UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBody($$$""" + {"flow_id":"{{{FlowId}}}","step":"Import","can_finish":true, + "steps":{"SignIn":"Completed","Agents":"Completed","Import":"Active","Done":"Pending"}, + "agents":[{"vendor":"claude","record":true,"tools":true}], + "agents_decided_at":"2026-08-26T10:00:00Z", + "default_visibility":"private"} + """) + .WithHeader("Content-Type", "application/json")); + + using var http = new HttpClient(); + + var outcome = await new FirstRunFlowClient(http).PollAsync(server.Urls[0], FlowId, CancellationToken.None); + + await Assert.That(outcome.Body!.DefaultVisibility).IsEqualTo("private"); + await Assert.That(FirstRunFlowOutcomes.Agents(outcome.Body)!.DefaultVisibility).IsEqualTo("private"); + } + + [Test] + public async Task PollAsync_leaves_the_visibility_null_when_the_decision_carries_none() { + using var server = WireMockServer.Start(); + server.Given(Request.Create().WithPath(PollPath).UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBody($$$""" + {"flow_id":"{{{FlowId}}}","step":"Import","can_finish":true, + "steps":{"SignIn":"Completed","Agents":"Completed","Import":"Active","Done":"Pending"}, + "agents":[{"vendor":"claude","record":true,"tools":true}], + "agents_decided_at":"2026-08-26T10:00:00Z"} + """) + .WithHeader("Content-Type", "application/json")); + + using var http = new HttpClient(); + + var outcome = await new FirstRunFlowClient(http).PollAsync(server.Urls[0], FlowId, CancellationToken.None); + + await Assert.That(outcome.Body!.DefaultVisibility).IsNull(); + await Assert.That(FirstRunFlowOutcomes.Agents(outcome.Body)!.DefaultVisibility).IsNull(); + } } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs index 2637e0321..c52af934f 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs @@ -163,6 +163,51 @@ public async Task BrowserImportSummary_an_answer_it_read_whole_gets_one_line() { await Assert.That(SetupCommand.BrowserImportSummary(ImportAnswer(repos: "kcap")).Count).IsEqualTo(1); } + static FirstRunAgentsAnswer VisibilityAnswer(string? visibility) => + new([new FirstRunAgentsChoice("claude", true, true)], + new DateTimeOffset(2026, 8, 26, 9, 0, 0, TimeSpan.Zero), + 0, + visibility); + + [Test] + public async Task DecideVisibility_applies_what_the_browser_chose() { + var decided = SetupCommand.DecideVisibility(VisibilityAnswer("public"), current: "private"); + + await Assert.That(decided.Apply).IsEqualTo("public"); + await Assert.That(decided.Kept).IsFalse(); + } + + // The finding this branch exists for: falling through to the prompt does NOT leave the profile + // alone, because the prompt's cursor starts on org_public — so one Return widens an existing + // private, on a screen the user already answered. + [Test] + public async Task DecideVisibility_keeps_the_profile_when_the_screen_was_answered_and_left_unset() { + var decided = SetupCommand.DecideVisibility(VisibilityAnswer(null), current: "private"); + + await Assert.That(decided.Apply).IsEqualTo("private"); + await Assert.That(decided.Kept).IsTrue(); + } + + [Test] + public async Task DecideVisibility_prompts_when_that_screen_never_settled() { + // Never asked is not the same as asked and declined: the terminal still has to put the + // question, which is what a null Apply means. + var decided = SetupCommand.DecideVisibility(null, current: "private"); + + await Assert.That(decided.Apply).IsNull(); + await Assert.That(decided.Kept).IsFalse(); + } + + [Test] + public async Task DecideVisibility_never_narrows_or_widens_a_kept_profile() { + // Whatever the profile holds is what comes back, for every stop - the branch must not have a + // fallback of its own. + foreach (var stop in AppConfig.ValidVisibilities) { + await Assert.That(SetupCommand.DecideVisibility(VisibilityAnswer(null), stop).Apply) + .IsEqualTo(stop); + } + } + [Test] public async Task VisibilityLabel_names_every_stop_the_wire_can_carry() { // One list behind the prompt and behind the browser-answer line, so the two cannot describe the From feeb81ea5c931a737655cea74e2f37312bd96e24 Mon Sep 17 00:00:00 2001 From: George Payne Date: Wed, 26 Aug 2026 23:09:14 +0200 Subject: [PATCH 4/7] Drop a section banner that restated its tests --- .../FirstRun/FirstRunFlowOutcomesTests.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/Capacitor.Cli.Core.Tests.Unit/FirstRun/FirstRunFlowOutcomesTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/FirstRun/FirstRunFlowOutcomesTests.cs index 4e9e035cc..d8dcce5d9 100644 --- a/test/Capacitor.Cli.Core.Tests.Unit/FirstRun/FirstRunFlowOutcomesTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/FirstRun/FirstRunFlowOutcomesTests.cs @@ -337,10 +337,6 @@ public async Task A_dismissed_leg_still_carries_the_decision_it_was_given() { await Assert.That(FirstRunFlowOutcomes.Import(result)).IsNotNull(); } - // ===================================================================== - // The default visibility the same decision carries. - // ===================================================================== - static FirstRunFlowResponse WithVisibility(string? visibility) => AllSettled() with { Agents = [new FirstRunAgentChoiceResponse { Vendor = "claude", Record = true, Tools = true }], From 5acfa798acb443f4c442a1314ea73858a8f1dc36 Mon Sep 17 00:00:00 2001 From: George Payne Date: Wed, 26 Aug 2026 23:29:53 +0200 Subject: [PATCH 5/7] Say which of the two nulls the wire field means --- src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs | 7 ++++--- .../Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs | 5 ++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs b/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs index 68b76e528..14a68dea5 100644 --- a/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs +++ b/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs @@ -213,9 +213,10 @@ public sealed record FirstRunFlowResponse { /// The default session visibility the same decision chose, as a canonical /// default_visibility value. /// - /// Null means leave the profile alone, and it is null in two situations this build must - /// not tell apart: the step is unanswered, and the user declined everything. Neither asks for a - /// default. + /// Null is not a value, and it is null in two situations that are not the same: the step + /// was answered and no audience set, and the step was never answered at all. Only the first says + /// anything about the profile — see SetupCommand.DecideVisibility, which separates them by + /// whether the step settled, because the second has told the machine nothing. /// /// It can name a stop this tenant does not currently offer, because the server's fold /// keeps a durable answer as given. Such a value is still the one to write: every stop it can name diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs index c52af934f..fd150ff06 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/SetupCommandTests.cs @@ -177,9 +177,8 @@ public async Task DecideVisibility_applies_what_the_browser_chose() { await Assert.That(decided.Kept).IsFalse(); } - // The finding this branch exists for: falling through to the prompt does NOT leave the profile - // alone, because the prompt's cursor starts on org_public — so one Return widens an existing - // private, on a screen the user already answered. + // Falling through to the prompt would not leave the profile alone: its cursor starts on org_public, + // so one Return widens an existing private on a screen the user already answered. [Test] public async Task DecideVisibility_keeps_the_profile_when_the_screen_was_answered_and_left_unset() { var decided = SetupCommand.DecideVisibility(VisibilityAnswer(null), current: "private"); From 0867c0f8c0e2c6f1c7043ff601753ccfa59edd16 Mon Sep 17 00:00:00 2001 From: George Payne Date: Thu, 27 Aug 2026 09:52:04 +0200 Subject: [PATCH 6/7] Give the hung-server budget test headroom over the budget it measures Its claim is that the attempt gave up rather than waiting the 30s hold, but its bound was the hook 5s budget itself, so a loaded runner overshot it by 240ms with the spool correct. The two sibling tests for the same claim use 15s and 10s; this brings the outlier into line, still well under the hold. --- .../Commands/Harness/ClaudeHookCommandTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs index e75ef2d90..4e20758a9 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/Harness/ClaudeHookCommandTests.cs @@ -657,7 +657,10 @@ public async Task subagent_stop_against_hung_server_is_spooled_within_budget() { var exit = await fx.HandleAsync($$"""{"hook_event_name":"SubagentStop","session_id":"{{Sid}}","agent_id":"{{AgentId}}","transcript_path":"/none","cwd":"/tmp"}"""); sw.Stop(); await Assert.That(exit).IsEqualTo(0); - await Assert.That(sw.Elapsed).IsLessThan(TimeSpan.FromSeconds(5)); // did not wait the full 30s + // Bounded well clear of the hook's own 5s budget rather than at it: the claim is that the + // attempt gave up instead of waiting the server's 30s hold, and a bound equal to the budget + // it is measuring has no headroom for a loaded runner (observed 5.24s on a Windows leg). + await Assert.That(sw.Elapsed).IsLessThan(TimeSpan.FromSeconds(10)); await Assert.That(fx.SpoolFiles.Any()).IsTrue(); } From 2c38a35f51d5c80a068f20401e9ebf6eda2ff123 Mon Sep 17 00:00:00 2001 From: George Payne Date: Thu, 27 Aug 2026 13:58:03 +0200 Subject: [PATCH 7/7] Correct the default_visibility wire doc The paragraph said an unrecognised stop is still written, which is the opposite of what the boundary does: it is mapped through the closed set and degrades to null. A caller reading the doc could reasonably have bypassed the validation. --- src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs b/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs index 14a68dea5..7052be8fd 100644 --- a/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs +++ b/src/Capacitor.Cli.Core/FirstRun/FirstRunFlowModels.cs @@ -218,9 +218,9 @@ public sealed record FirstRunFlowResponse { /// anything about the profile — see SetupCommand.DecideVisibility, which separates them by /// whether the step settled, because the second has told the machine nothing. /// - /// It can name a stop this tenant does not currently offer, because the server's fold - /// keeps a durable answer as given. Such a value is still the one to write: every stop it can name - /// resolves no wider than the profile's own fallback. + /// A stop this build cannot name is dropped, not written. The value persists in profile + /// config and is stamped on every session afterwards, so it is mapped through + /// AppConfig.ValidVisibilities and degrades to null, which leaves the profile as it was. /// [JsonPropertyName("default_visibility")] public string? DefaultVisibility { get; init; }