diff --git a/llp/0185-unpicked-client-stays-attached.decision.md b/llp/0185-unpicked-client-stays-attached.decision.md index d70898bc..7486c705 100644 --- a/llp/0185-unpicked-client-stays-attached.decision.md +++ b/llp/0185-unpicked-client-stays-attached.decision.md @@ -6,6 +6,7 @@ **Author:** Brendan / Claude **Date:** 2026-08-04 **Related:** LLP 0045 (the one disk-driven undo), LLP 0129 (Reconfigure re-enters the picker), LLP 0086 (the existing attach drift diagnostic), LLP 0180 (the finale's attach lane), LLP 0031 (central layer) +**Extended-by:** LLP 0188 (the finale's print stays where §warn-do-not-detach put it; the wizard, whose closing sequence buries it, repeats it short at the end of the run) > Re-running `hyp init` and unchecking a client the previous run attached > regenerates a config without that client's adapter, while the client's own @@ -90,6 +91,12 @@ of the `config_unreadable` finding that is the real repair. The known gap: a The finale still warns about it at the time, which is where the state is created. +> **Extended-by [LLP 0188](./0188-stranded-attach-warning-repeats-at-the-end.decision.md):** +> the finale's print is where this decision put it, but it is not the last +> thing `hyp init` writes. The wizard repeats it, short, after its first look +> and before the privacy narration; `runPickerWalkthrough`, which writes +> nothing substantial after the finale, still prints it once. + **"Not configured" means what `hyp status` already means by it.** Both surfaces read a **local layer** plugin entry with `enabled: false` as inactive, so a switched-off adapter strands its diff --git a/llp/0188-stranded-attach-warning-repeats-at-the-end.decision.md b/llp/0188-stranded-attach-warning-repeats-at-the-end.decision.md new file mode 100644 index 00000000..7e1459e1 --- /dev/null +++ b/llp/0188-stranded-attach-warning-repeats-at-the-end.decision.md @@ -0,0 +1,115 @@ +# LLP 0188: The stranded-attach warning repeats where the run ends + +**Type:** Decision +**Status:** Accepted +**Systems:** Onboarding, CLI, Clients +**Author:** Brendan / Claude +**Date:** 2026-08-05 +**Related:** LLP 0185 (an unpicked client is named, not detached; this doc is its `Extended-by`), LLP 0135 (the wizard's closing sequence), LLP 0100/0101 (the privacy narration), LLP 0086 (the attach drift diagnostic) + +> [LLP 0185](./0185-unpicked-client-stays-attached.decision.md) put the +> stranded-attach warning in the finale, before the daemon restart. It is +> still there. But `hyp init` writes about seventy more lines after the finale +> returns, so on a real terminal the warning is not on screen when the run +> ends, and the user who does not scroll back never reads it. + +## Context {#context} + +`runPickerFinale` prints the warning and returns. What happens next depends on +which entry point called it: + +- `runPickerWalkthrough` writes its short run summary and stops. The warning + is within a handful of lines of the last thing on screen. +- `runInitWizard` writes the run summary, then `runWizardFirstLook` (roughly + sixty lines of real query output, by its own design in + [LLP 0135 §first-look](./0135-install-experience-overhaul.design.md)), then + on the team path the privacy narration. Nothing pauses between them. + +So the same print is prominent on one path and buried on the other. It is +buried worst exactly where it is needed most: on a managed host, +[LLP 0185 §status-backstop](./0185-unpicked-client-stays-attached.decision.md#status-backstop) +gates the `client_attached_not_configured` diagnostic to hosts with no central +layer, so the finale's print is the *only* signal a wizard-attached client the +reconfigure run left unpicked ever gets. A joined host has no backstop to +scroll back to. + +The obvious repair, echoing `finaleSummary.attachedNotConfigured` from +`writeWalkthroughRunSummary`, is wrong twice over. That function is shared by +both entry points, so `runPickerWalkthrough` would print the same warning +twice within a few lines of itself, and the wizard would still bury the echo +under the first look, which is the whole complaint. + +## Decision {#decision} + +**The finale's print stays where it is, and the +caller that buries it repeats it at its own end.** LLP 0185's placement is +unchanged: the finale still names the stranded clients after the config write +and before the daemon restart. `runInitWizard` additionally writes a short +repeat after the first look and before the privacy narration, naming the same +clients and the same `hyp detach --client ` lines. + +The repeat is the *caller's*, not the shared run summary's, because only the +caller knows whether anything came between. `runPickerWalkthrough` prints +nothing substantial after the finale, so it does not repeat, and no path +prints the warning twice on one screen. + +**The repeat is conditional on the closing sequence having +written something.** The wizard emits it only when the first look actually +reached the screen. That is a narrower test than "the first look ran", and +deliberately so: the step is documented to degrade to a silent skip rather +than fail a finished install +([LLP 0135 §first-look](./0135-install-experience-overhaul.design.md)), so an +unregistered dataset, an unreadable cache, or a render that throws leaves an +attended run that attempted the block and printed none of it. Nothing buried +the finale's print on such a run, so nothing is repeated. + +That single condition covers the team pathway as well, because a pathway is +only ever resolved on an interactive run, so a team run that is neither +cancelled nor a dry run has already run the first look. Widening the condition +to "or the pathway is `team`" on the theory that the privacy narration follows +would admit only the runs where the first look did *not* run, and those are +exactly the runs that wrote nothing between the finale and here: the repeat +would land a few lines under the print it repeats, which is the same double +print the shared run summary was rejected for. + +So a scripted `--yes` or `--dry-run` wizard run stays on the single finale +print and its output is unchanged, matching the attended-only rule +([LLP 0131](./0131-configure-phase.decision.md)), and so does an interactive +run cancelled at the backfill consent, whose summary is all that follows the +finale. + +**The repeat is shorter than the original.** The first +print explains the consequence in full; repeating that paragraph verbatim +reads as a bug rather than a reminder. The repeat carries only what a user +acts on: the client names and one `hyp detach --client ` line each. Both +prints share the dry-run tag rule the rest of the finale uses. + +**The privacy narration is still the last +words on the team path.** The repeat goes before it, not after +([LLP 0135 §first-look](./0135-install-experience-overhaul.design.md), +LLP 0100/0101). Seven lines of narration is a reminder that survives on one +screen; sixty lines of query output is not, and that is the difference this +decision turns on. + +## Consequences {#consequences} + +- `writeAttachedNotConfiguredReminder` is exported from + `src/core/cli/walkthrough.js` beside the finale's own + `writeAttachedNotConfiguredWarning`, which stays private to the finale. +- `writeWalkthroughRunSummary` is untouched. It still reports only what the + finale *did*, per + [LLP 0135](./0135-install-experience-overhaul.design.md); a warning is not + an action taken. +- A stranded attach on an attended wizard run is printed twice in one run, in + two places, deliberately. That is the cost of the finale's placement being + correct for the restart and wrong for the end of the run. +- The managed-host gap LLP 0185 recorded is narrowed, not closed: status still + says nothing on a joined host, but the run that creates the state now ends + on the warning instead of burying it. + +## References + +- Issue #614 (deferred review finding from PR #608), PR #608 +- `src/core/cli/walkthrough.js` (`writeAttachedNotConfiguredReminder`), + `src/core/cli/wizard/index.js` (the closing sequence) +- LLP 0185, LLP 0135, LLP 0131, LLP 0100/0101 diff --git a/src/core/cli/walkthrough.js b/src/core/cli/walkthrough.js index 0793c341..a5bcf128 100644 --- a/src/core/cli/walkthrough.js +++ b/src/core/cli/walkthrough.js @@ -1480,6 +1480,38 @@ function writeAttachedNotConfiguredWarning({ clients, stdout, dryRun }) { for (const client of clients) stdout.write(` hyp detach --client ${client}\n`) } +/** + * The closing repeat of the stranded-attach warning, for an entry point whose + * finale is not the last thing it writes. + * + * The finale's own warning stays exactly where LLP 0185 put it, before the + * daemon restart that is the point of no return. This is a second, compact + * print for the caller that keeps writing afterwards: the wizard follows the + * finale with a run summary, a first look that is roughly sixty lines of real + * query output, and on the team path a privacy narration, none of which pause, + * so on a real terminal the original warning is scrolled away by the time the + * run ends. It matters most on a managed host, where the finale's print is the + * only signal there is because `hyp status`'s mirror diagnostic is gated to + * hosts with no central layer (LLP 0185 #status-backstop). + * + * Only a caller that printed something substantial in between calls this. + * `runPickerWalkthrough` writes a short run summary and stops, so it keeps the + * single finale print and never repeats it onto the same screen. + * + * @ref LLP 0188#repeat-at-the-end [implements]: the repeat belongs to the caller whose own output buried the first print + * @param {{ + * clients: string[], + * stdout: NodeJS.WritableStream | { write(chunk: string): unknown }, + * dryRun: boolean, + * }} args + */ +export function writeAttachedNotConfiguredReminder({ clients, stdout, dryRun }) { + stdout.write('\n') + stdout.write(`${dryRun ? '(dry-run) ' : ''}Still attached, no longer collected: ${clients.join(', ')}\n`) + stdout.write('Their requests can start failing until you run:\n') + for (const client of clients) stdout.write(` hyp detach --client ${client}\n`) +} + /** * Run the onboarding backfill step. For each picked client that has a * registered backfill provider (intersection of `clientsPicked` and diff --git a/src/core/cli/wizard/index.js b/src/core/cli/wizard/index.js index 119e758b..ec5e103f 100644 --- a/src/core/cli/wizard/index.js +++ b/src/core/cli/wizard/index.js @@ -19,7 +19,12 @@ import { discoverBundledPlugins } from '../../runtime/bundled.js' import { buildPluginCatalog } from '../../plugin_catalog.js' import { collectHypAwareStatus } from '../../daemon/status.js' import { formatFirstSyncDeadline, readFirstSyncDeadline } from '../../usage-policy/first_sync_hold.js' -import { LOCAL_INSTALL_RETENTION_DAYS, runPickerFinale, writeWalkthroughRunSummary } from '../walkthrough.js' +import { + LOCAL_INSTALL_RETENTION_DAYS, + runPickerFinale, + writeAttachedNotConfiguredReminder, + writeWalkthroughRunSummary, +} from '../walkthrough.js' import { LOGIN_ORG_SELECTION_MESSAGE } from '../remote_commands.js' import { useColor } from '../stdio.js' import { evaluateReturningGate, runWizardFork } from './fork.js' @@ -212,13 +217,22 @@ export async function runInitWizard(opts) { // still have to type. Attended and non-dry-run only: a scripted `--yes` // install gets no extra output, and a dry run has no writes to look at. // @ref LLP 0135#first-look [implements]: placed after the finale (backfill has landed) and before the privacy narration, which stays the last words - if (interactive && !cancelled && opts.finale?.dryRun !== true) { + const firstLookRan = interactive && !cancelled && opts.finale?.dryRun !== true + // Whether the block actually reached the screen, which is not the same + // question: the step is documented to degrade to a silent skip rather than + // fail a finished install (LLP 0135 #first-look), so an unregistered + // dataset, an unreadable cache, or a render that throws all leave + // `firstLookRan` true and stdout untouched. The closing repeat below reads + // this, not the gate, because what it needs to know is what was written. + let firstLookShown = false + if (firstLookRan) { const notices = firstLookNoticeSink(opts.stderr) - await runWizardFirstLook({ + const look = await runWizardFirstLook({ runner: opts.firstLook ?? firstLookRunnerFromCtx(opts.ctx, notices), stdout: opts.stdout, color: useColor(opts.stdout, opts.env), }) + firstLookShown = look.shown // The abandoned queries from an expired deadline keep running and can // still resolve with a withheld-row report. Close the sink so that // report cannot land after the privacy narration below, which is @@ -226,6 +240,34 @@ export async function runInitWizard(opts) { notices.close() } + // The finale already named these, before the daemon restart that strands + // them (LLP 0185 #warn-do-not-detach). That print is no longer on screen by + // the time this run ends: the summary, then the first look's ~60 lines, then + // the narration below all follow it without a pause. Repeat it here, short, + // and only when this closing sequence actually wrote something, so the + // direct `runPickerWalkthrough` entry point (whose summary follows the + // finale with nothing in between) keeps its single print. + // + // `firstLookShown` is the whole condition, the team pathway included. It is + // read rather than `firstLookRan` because a first look that skipped itself + // wrote nothing, and rather than `pathway`, because a `pathway` is only ever + // resolved on an interactive run: a team run that is neither cancelled nor a + // dry run has already run the first look, so `|| pathway === 'team'` would + // widen this to exactly the runs where the first look did *not* run + // (cancelled at the backfill consent, or a dry run). Every run either + // condition would add wrote nothing between the finale and here, so the + // repeat would land a few lines under the print it repeats. + // @ref LLP 0188#when [constrained-by]: nothing written in between, no repeat + // @ref LLP 0188#repeat-at-the-end [implements]: the wizard repeats what its own closing output buried + const stranded = finaleSummary?.attachedNotConfigured ?? [] + if (stranded.length > 0 && firstLookShown) { + writeAttachedNotConfiguredReminder({ + clients: stranded, + stdout: opts.stdout, + dryRun: opts.finale?.dryRun === true, + }) + } + // The wizard's last words on the team pathway: when the first upload // happens and that nothing has shipped yet (LLP 0100/0101, narration // only - the hold itself was written by the join lane's login). diff --git a/test/core/cli/wizard/index.test.js b/test/core/cli/wizard/index.test.js index ad1b19f4..25115218 100644 --- a/test/core/cli/wizard/index.test.js +++ b/test/core/cli/wizard/index.test.js @@ -393,6 +393,121 @@ test('runInitWizard: a non-interactive or dry run skips the first look', async ( assert.equal(dry.seen.length, 0) }) +// --- the stranded-attach warning's closing repeat (LLP 0188) --- + +/** + * A finale summary that reports clients this run left attached but no longer + * collects. The finale itself printed the full warning before the daemon + * restart; this is what it hands back for the closing repeat to read. + * + * @param {string[]} clients + */ +function strandedFinale(clients) { + return /** @type {any} */ ({ + daemonInstall: { skipped: true, dryRun: false }, + globalInstall: { skipped: true, installed: false }, + attach: [], + skillsInstalled: [], + agentsInstalled: [], + daemonRestart: { skipped: true, dryRun: false, ok: false }, + backfill: [], + attachedNotConfigured: clients, + }) +} + +// The finale names the stranded clients before the daemon restart (LLP 0185 +// #warn-do-not-detach) and then the wizard writes the run summary, the first +// look's ~60 lines, and the privacy narration on top of it, so by the time an +// attended run ends the warning has scrolled away. On a managed host it is +// the only signal there is, because `hyp status`'s mirror diagnostic is gated +// to hosts with no central layer. +// @ref LLP 0188#repeat-at-the-end [tests]: the wizard repeats what its own closing output buried +test('runInitWizard: an attended run repeats the stranded-attach warning after the first look', async () => { + const home = await tmpHome() + await writeFirstSyncHoldMarker({ stateDir: path.join(home, '.hyp', 'hypaware') }) + const stub = firstLookStub( + [{ provider: 'anthropic', model: 'claude-opus-5', input_tokens: 400, cached_tokens: 4000, output_tokens: 40 }], + [{ date: '2026-07-24', sessions: 3, input_tokens: 400, cached_tokens: 4000, output_tokens: 40 }] + ) + const { opts, stdout } = wizardOpts(home, { + fork: async () => 'team', + firstLook: stub.runner, + finaleRunner: async () => strandedFinale(['codex']), + }) + await runInitWizard(opts) + const text = stdout.text() + + // The names and the one command that clears each, not a bare mention. + assert.match(text, /Still attached, no longer collected: codex/, text) + assert.match(text, /hyp detach --client codex/, text) + // Past the block that buried the finale's own print. + assert.ok(text.indexOf('First look') >= 0, text) + assert.ok(text.indexOf('hyp detach --client codex') > text.indexOf('First look'), text) + // And still ahead of the privacy narration, which stays the last words. + assert.ok( + text.indexOf('hyp detach --client codex') < text.indexOf('Nothing has been uploaded yet'), + text + ) +}) + +// The repeat exists because the wizard's closing sequence buries the finale's +// print. A scripted run writes nothing between the two, so repeating there +// would be the double-print on one screen the shared run summary would have +// caused. Its output stays byte-identical to what the finale alone produced. +// @ref LLP 0188#when [tests]: no closing sequence, no repeat +test('runInitWizard: a scripted run does not repeat the stranded-attach warning', async () => { + const { opts, stdout } = wizardOpts(await tmpHome(), { + picks: { sources: ['claude'], exportChoice: 'local-parquet', retentionDays: 30 }, + finaleRunner: async () => strandedFinale(['codex']), + }) + await runInitWizard(opts) + assert.doesNotMatch(stdout.text(), /hyp detach --client/, stdout.text()) +}) + +// A cancel at the backfill consent skips the first look, so the run summary is +// the only thing between the finale's own warning (which the finale prints +// before its restart block, cancelled or not) and the end of the run. The team +// pathway is not on its own a reason to repeat: a pathway is only resolved on +// an interactive run, so an uncancelled non-dry team run has already run the +// first look, and the runs a `pathway === 'team'` clause would add are exactly +// the ones with nothing in between. +// @ref LLP 0188#when [tests]: a cancelled team run buried nothing, so it does not repeat +test('runInitWizard: a run cancelled at the finale does not repeat the stranded-attach warning', async () => { + const home = await tmpHome() + await writeFirstSyncHoldMarker({ stateDir: path.join(home, '.hyp', 'hypaware') }) + const { opts, stdout } = wizardOpts(home, { + fork: async () => 'team', + finaleRunner: async () => ({ ...strandedFinale(['codex']), cancelled: true }), + }) + const result = await runInitWizard(opts) + assert.equal(result.cancelled, true) + assert.doesNotMatch(stdout.text(), /hyp detach --client/, stdout.text()) +}) + +// The first look is documented to degrade to a silent skip rather than fail a +// finished install (LLP 0135 #first-look): an unregistered dataset, an +// unreadable cache, or a render that throws all leave an attended run that +// attempted the block and printed none of it. The gate that admits the repeat +// is therefore the block having reached the screen, not the run having tried: +// on a skip the finale's own print is still the last thing above the summary, +// and repeating under it would be the same-screen double print. +// @ref LLP 0188#when [tests]: a first look that printed nothing buried nothing +test('runInitWizard: an attended run whose first look skips itself does not repeat the stranded-attach warning', async () => { + const home = await tmpHome() + await writeFirstSyncHoldMarker({ stateDir: path.join(home, '.hyp', 'hypaware') }) + const { opts, stdout } = wizardOpts(home, { + fork: async () => 'team', + // The shape `firstLookRunnerFromCtx` yields when the overview dataset is + // not registered: the step returns `{ shown: false }` without writing. + firstLook: { hasDataset: () => false, async run() { return { columns: [], rows: [] } } }, + finaleRunner: async () => strandedFinale(['codex']), + }) + await runInitWizard(opts) + const text = stdout.text() + assert.doesNotMatch(text, /First look/, text) + assert.doesNotMatch(text, /hyp detach --client/, text) +}) + test('runInitWizard: team pathway with a live first-sync hold narrates the deadline', async () => { const home = await tmpHome() const stateDir = path.join(home, '.hyp', 'hypaware') diff --git a/test/core/walkthrough-unpicked-attached-clients.test.js b/test/core/walkthrough-unpicked-attached-clients.test.js index ff6ddcea..8674ca9b 100644 --- a/test/core/walkthrough-unpicked-attached-clients.test.js +++ b/test/core/walkthrough-unpicked-attached-clients.test.js @@ -89,6 +89,13 @@ test('the finale names a still-attached client the new config no longer collects const out = stdout.text() assert.match(out, /codex/, out) assert.match(out, /hyp detach --client codex/, out) + // Exactly once. This entry point writes only its short run summary after the + // finale, so the warning is still on screen when the run ends and repeating + // it (the shape an echo from the shared `writeWalkthroughRunSummary` would + // take) would print the same paragraph twice within a few lines of itself. + // @ref LLP 0188#repeat-at-the-end [tests]: the repeat belongs to the caller that buried the first print, and this one does not + assert.equal(out.match(/hyp detach --client codex/g)?.length, 1, out) + assert.equal(out.match(/Still attached, no longer collected/g)?.length, 1, out) }) test('a picked client that stays configured draws no stranded-attach warning', async () => {