diff --git a/llp/0190-wizard-defaults-gate.decision.md b/llp/0190-wizard-defaults-gate.decision.md index 5a6de2a4f..c30020235 100644 --- a/llp/0190-wizard-defaults-gate.decision.md +++ b/llp/0190-wizard-defaults-gate.decision.md @@ -6,7 +6,7 @@ **Author:** Brendan / Claude **Date:** 2026-08-04 **Related:** LLP 0188 (#never-silent: the sync-scope step this reshapes), LLP 0135 (#pick: the pick lane this reshapes), LLP 0129 (fork/join/pick order, unchanged), LLP 0011 (autodetect seeds the default), LLP 0130 (picker descriptors) -**Extended-by:** [LLP 0201](./0201-express-defaults-gate.decision.md) (one express gate now precedes the lanes and can accept every gate below at once; each lane keeps its gate, its statement, and its default, and an auto-accepted gate prints its statement instead of prompting) +**Extended-by:** [LLP 0201](./0201-express-defaults-gate.decision.md) (one express gate now precedes the lanes and can accept every gate below at once; each lane keeps its gate, its statement, and its default, and an auto-accepted gate prints its statement instead of prompting), [LLP 0279](./0279-lane-answers-commit-with-the-config.decision.md) (#commit-point's carve-out for the sync lane's store write is withdrawn: both question lanes' policy writes now ride the commit point too) > Extends [LLP 0188 §never-silent](./0188-enrolled-default-sync-with-client-optout.decision.md#never-silent) > and the prompt flow of [LLP 0135 §pick](./0135-install-experience-overhaul.design.md). @@ -234,7 +234,14 @@ the team pathway, narrates the enrolled state per #abort-narration. The sync lane's `client-sync.json` write still precedes the commit; a store entry for a source whose config never lands is inert and errs toward under-sync, which LLP 0188 #migration already names as the safe -direction. On a scripted run (`--yes`, presets) no lane runs between pick +direction. +**Extended-by:** [LLP 0279 §one-commit-point](./0279-lane-answers-commit-with-the-config.decision.md#one-commit-point) +withdraws that last sentence: the store is an editor (a re-run can *remove* +an opt-out), its absence is itself LLP 0188 #migration's marker, and the +new-folder lane that arrived later inherited the same shape without the +argument. Both question lanes now defer their writes to the commit point, +and a refusal names the answers it dropped alongside the config. +On a scripted run (`--yes`, presets) no lane runs between pick and the commit, so its ordering is unchanged; direct `runWizardPick` callers without `deferWrite` keep the inline write. diff --git a/llp/0201-express-defaults-gate.decision.md b/llp/0201-express-defaults-gate.decision.md index e7ee8f46f..14b238e3e 100644 --- a/llp/0201-express-defaults-gate.decision.md +++ b/llp/0201-express-defaults-gate.decision.md @@ -5,6 +5,7 @@ **Systems:** Onboarding, CLI **Author:** Brendan / Claude **Date:** 2026-08-07 +**Extended-by:** [LLP 0279](./0279-lane-answers-commit-with-the-config.decision.md) (#standing-answer: "each lane's stated default" is the standing answer where a lane has one, so accepting cannot retire a set `hyp policy folders ask`) **Related:** LLP 0190 (#pick-gate, #sync-gate: the per-lane defaults gates this collapses), LLP 0188 (#never-silent: the floor this must not breach), LLP 0200 (#wizard: the third lane it answers), LLP 0135 (#progress, #orchestration), LLP 0131 (#attended-only), LLP 0129 (#fork), LLP 0191 (#back-edges) > Extends [LLP 0190](./0190-wizard-defaults-gate.decision.md). Each lane diff --git a/llp/0279-lane-answers-commit-with-the-config.decision.md b/llp/0279-lane-answers-commit-with-the-config.decision.md new file mode 100644 index 000000000..283d3e20f --- /dev/null +++ b/llp/0279-lane-answers-commit-with-the-config.decision.md @@ -0,0 +1,163 @@ +# LLP 0279: The wizard's question lanes record with the config, and accepting a default never retires a standing answer + +**Type:** Decision +**Status:** Accepted +**Systems:** Onboarding, CLI, Privacy +**Author:** Brendan / Claude +**Date:** 2026-08-19 +**Related:** LLP 0190 (#commit-point: the commit point this completes, and the exception it carved out), LLP 0188 (#never-silent, #opt-out, #migration: the store this writes and what its absence means), LLP 0200 (#wizard, #default: the new-folder lane), LLP 0201 (#gate, #narrate: the express gate whose accept this pins), LLP 0191 (#back-edges: the re-answering this has to survive) + +> Extends [LLP 0190 §commit-point](./0190-wizard-defaults-gate.decision.md#commit-point). +> The commit point is unchanged; what changes is that the sync lane's +> `client-sync.json` write and the new-folder lane's preference write ride +> it too, instead of landing the moment their lane is answered. Also pins +> what LLP 0201's express accept means for a lane whose default is a +> standing answer. + +## The problem {#problem} + +[LLP 0190 §commit-point](./0190-wizard-defaults-gate.decision.md#commit-point) +moved the config write past the question lanes so that "a cancel at the +sync lane leaves the existing config untouched". It carved out one +exception, in a single line: "The sync lane's `client-sync.json` write +still precedes the commit; a store entry for a source whose config never +lands is inert and errs toward under-sync." + +That reasoning holds for exactly one direction of one lane. Three things +it does not cover: + +1. **The store is an editor, not an append log.** On a re-run the sync + menu can *un*-check nothing and *check* a source that was opted out + before, which removes a `local-only` entry. Decline the overwrite + confirm that follows and the machine is left syncing a source the user + never got to keep off, told only "keeping existing config at ...". That + is the over-sync direction, and LLP 0188 #migration's "safe direction" + argument does not reach it. +2. **Absence is a signal.** `readClientSyncEntries` returns `null` for a + missing file and `[]` for an empty one, because absence is LLP 0188 + #migration's marker for a machine that predates default-sync. So even + the "inert" write is a state change: it stamps the machine. +3. **The new-folder lane was never considered.** LLP 0200's lane + (`writeFolderAskMode`) landed after LLP 0190 and inherited the same + write-on-answer shape without the sentence that justified it. It is a + per-folder question policy, not an opt-out list, and nothing about it + errs toward under-sync. + +A second defect sits in the same pair of lanes. The express gate +([LLP 0201 §gate](./0201-express-defaults-gate.decision.md#gate)) "accepts +every lane's stated default". The sync lane's auto-accept arm honors that: +it returns the opt-outs already standing. The new-folder lane's arm +recorded `DEFAULT_FOLDER_ASK_MODE` instead, so a user who had set +`hyp policy folders ask`, re-ran `hyp init`, and took the express gate had +their per-folder question turned off - by a screen offering to accept the +defaults, on a lane whose own asked screen deliberately defaults to the +standing answer. + +## Decision {#decision} + +**A run's answers are recorded together or +not at all.** The sync lane and the new-folder lane take a `deferWrite` +flag, exactly as the pick lane does: they answer, they state their answer +on screen, and they hand their write back to the orchestrator as a +`commit`. The orchestrator runs both immediately after +`commitWizardPickedConfig` succeeds, and runs neither when it does not. +Order: pick questions, sync questions, new-folder question, overwrite +confirm, config write, `client-sync.json`, `folder-ask.json`, configure, +finale. + +The **statement** stays with the lane that produced it. Only the write +moves. The lanes' statements are the never-silent floor +([LLP 0188 §never-silent](./0188-enrolled-default-sync-with-client-optout.decision.md#never-silent)) +and, on the express path, +[LLP 0201 §narrate](./0201-express-defaults-gate.decision.md#narrate) binds +each one to its own block ("Syncing them all" is one more indented line +under "When you start a session in a new folder:"). Deferring the printing +too would have floated those lines out from under their titles and behind +the overwrite confirm. + +That leaves a refusal with two statements on screen that no longer +describe the machine, so **a refusal says what else it dropped**: after +the "keeping existing config at ..." line, the wizard names the held +answers it did not record ("the sync and new-folder answers from this run +were not recorded either"), listing only the lanes that actually asked - +a lane that rendered a statement rather than a question had no answer to +lose, which covers both a sync lane with nothing left to opt out of and an +express pass, where both lanes narrate the standing state. + +The held writes are **re-assigned, never accumulated**. Back navigation +(LLP 0191 #back-edges) re-runs the lanes, and only the last answer is the +one to write; a pass that never reaches the lanes clears them, so a back +through the fork onto a solo local run cannot carry an earlier pass's +answers forward. The reported answers (`sourcesOptedOut`, `folderAsk`) +clear with them: they were only ever true because the write had already +happened inline, and a held write that never ran leaves nothing to report. + +A deferred write that *fails* warns and leaves the previous state +standing. For the new-folder lane that is exactly what the inline write +did, and its `commit` resolves to the mode actually left in force, which +is what the run's finish log records. The sync lane's deferred `commit` +takes the same contract even though its inline write throws: by the time +it runs, the config is on disk and the run still owes the new-folder +lane's write, the configure phase, and the finale, so a throw there would +abandon the run half-done. The inline write runs before anything has been +committed and keeps throwing. + +Both `commit`s therefore **resolve to what they left in force, never to +the answer**. A warning emitted at the commit point is followed by the +configure phase and the finale, so by the end of the run it has scrolled +away, and the failure is a fail-open one: an opt-out the export seam never +sees. The finish log is the signal that survives, and it must report the +opt-out set and the mode that actually stand, not the ones the run asked +for. + +**Accepting a default never retires a standing +answer.** LLP 0201's accept takes each lane's *stated* default. For the +new-folder lane that is `before`, the mode already in force - the same +value its asked screen puts on `default:`, for the reason its comment +gives: "a re-run defaults to the standing answer, so re-entering the +wizard round-trips the preference instead of resetting it". The +auto-accept arm now records `before` rather than +`DEFAULT_FOLDER_ASK_MODE`, which changes nothing on a first run (where +`before` *is* the shipped default) and stops the express gate from being +the one screen that silently reverses a privacy-adjacent preference. + +The gate's own line follows the answer. The accept row's single line of +consequence is the one thing the fast path is guaranteed to read, so it +states the standing answer: "new folders keep asking" on a machine set to +`ask`, "new folders sync too" otherwise. The orchestrator reads the mode +once, before the gate, through the safe read. + +Rejected: leaving the express arm on the shipped default and instead +warning that it overrode a standing preference (the wizard does not +answer its own questions, and a gate labelled "accept the defaults" that +changes an existing answer is not accepting anything). Also rejected: +moving the lanes' statements to commit time so the write and its receipt +stay adjacent (it breaks LLP 0201 #narrate's blocks and puts a lane's +answer under the overwrite confirm, where it reads as a consequence of +the confirm). + +## Consequences {#consequences} + +- A declined overwrite is now a true no-op: config, `client-sync.json`, + and `folder-ask.json` are all as the run found them, and the run says + so. +- `client-sync.json` absence keeps meaning what LLP 0188 #migration says + it means. An abandoned run no longer stamps a machine as migrated. +- Direct callers of either lane without `deferWrite` keep the inline + write, so the lanes stay usable on their own exactly as `runWizardPick` + does. +- A machine with `hyp policy folders ask` can re-run `hyp init` and take + the express gate without losing the per-folder question. +- The finish log's `folder_ask` and `sources_opted_out` attributes are now + read from the committed writes rather than the answers, so a failed + write is visible in telemetry as the state that stands. +- A run that abandons a pass through the lanes reports neither: both + attributes clear with the held writes they belong to. + +## References + +- LLP 0190, LLP 0188, LLP 0200, LLP 0201, LLP 0191 +- `src/core/cli/wizard/index.js` (the commit point and the held writes), + `src/core/cli/wizard/sync_scope.js`, `src/core/cli/wizard/folder_ask.js`, + `src/core/cli/wizard/express.js` (the accept row's line), + `src/core/cli/wizard/types.d.ts` diff --git a/src/core/cli/wizard/express.js b/src/core/cli/wizard/express.js index 0bcaf69fc..968c91a1b 100644 --- a/src/core/cli/wizard/express.js +++ b/src/core/cli/wizard/express.js @@ -66,9 +66,18 @@ export async function runWizardExpressGate(opts) { // side-effect disclosure lives here as well as on the pick gate // it stands in for (LLP 0190 #pick-gate). One line: what // accepting does to the machine, plus the folder policy that - // rides with it. + // rides with it - the *standing* one, because accepting takes + // each lane's stated default and the new-folder lane's default + // on a re-run is the answer already in force (LLP 0279 + // #standing-answer). Promising "new folders sync too" to a + // machine whose answer is `ask`, and then keeping `ask`, would + // make the one line the fast path reads the one line it cannot + // trust. + // @ref LLP 0279#standing-answer [implements]: the accept row names the standing new-folder answer, not the shipped default summary: opts.enrolled - ? 'Configures each to record through HypAware; new folders sync too.' + ? opts.folderAsk === 'ask' + ? 'Configures each to record through HypAware; new folders keep asking.' + : 'Configures each to record through HypAware; new folders sync too.' : 'Configures each to record through HypAware.', }, { diff --git a/src/core/cli/wizard/folder_ask.js b/src/core/cli/wizard/folder_ask.js index e20a9f0a5..edbcc7720 100644 --- a/src/core/cli/wizard/folder_ask.js +++ b/src/core/cli/wizard/folder_ask.js @@ -4,7 +4,7 @@ import { Attr, withSpan } from '../../observability/index.js' import { readObservabilityEnv } from '../../observability/env.js' import { isPromptBackError, isPromptCancelledError } from '../tui/runtime.js' import { defaultConfirmSelectPromptFactory } from '../walkthrough.js' -import { DEFAULT_FOLDER_ASK_MODE, readFolderAskModeSafe, writeFolderAskMode } from '../../usage-policy/index.js' +import { readFolderAskModeSafe, writeFolderAskMode } from '../../usage-policy/index.js' import { narrateAcceptedGate } from './express.js' /** @@ -76,14 +76,18 @@ export async function runWizardFolderAsk(opts) { const items = [' Folders you already marked keep their class either way.'] // The express gate already answered this lane (LLP 0201): state the - // question and its default answer, record it, and move on. + // question and its default answer, record it, and move on. The default + // is the one the asked screen below states - the standing answer, not + // the shipped one - so accepting the gate cannot silently retire a + // per-folder question the user asked for on an earlier run. // @ref LLP 0201#narrate [implements]: an auto-accepted question prints its statement instead of prompting + // @ref LLP 0279#standing-answer [implements]: the express arm records the lane's stated default, which on a re-run is the standing mode if (opts.autoAccept) { narrateAcceptedGate({ stdout: opts.stdout, title: FOLDER_ASK_TITLE, items }) // Inline: the block's title already said "New folders", so the answer // belongs under it as one more indented line rather than as a second // flush-left announcement repeating the subject. - return await recordAnswer(DEFAULT_FOLDER_ASK_MODE, { stateDir, before, opts, inline: true }) + return await recordAnswer(before, { stateDir, before, opts, inline: true }) } const confirm = opts.confirm ?? defaultConfirmSelectPromptFactory(opts) @@ -126,25 +130,61 @@ export async function runWizardFolderAsk(opts) { * auto-accepted path, where the title is still on screen); the asked path * prints it flush-left, because the prompt frame it answers has cleared. * + * Under `deferWrite` the lane states the answer here and hands the write + * back as `commit` (LLP 0279 #one-commit-point): the statement belongs to + * the screen that produced it, but the preference itself is part of this + * run's answer set and lands only once the run's config does. A run that + * ends before that point leaves the standing mode alone. + * + * @ref LLP 0279#one-commit-point [implements]: the preference write is handed back rather than made the moment the lane is answered * @param {FolderAskMode} mode * @param {{ stateDir: string, before: FolderAskMode, opts: RunWizardFolderAskOptions, inline?: boolean }} ctx * @returns {Promise} */ async function recordAnswer(mode, { stateDir, before, opts, inline = false }) { - try { - await writeFolderAskMode({ stateDir, mode }) - } catch (err) { - const detail = err instanceof Error ? err.message : String(err) - opts.stderr.write( - `warning: could not record the new-folder answer (${detail}); ` + - `it stays '${before}' - set it later with 'hyp policy folders ${mode}'\n` - ) - return await finishSpan({ mode: before, skipped: true }, opts) + /** + * The write, and what it leaves in force: the answer, or the previous + * mode with the warning the lane's contract promises. + * + * @returns {Promise<{ mode: FolderAskMode, skipped?: true }>} + */ + const write = async () => { + try { + await writeFolderAskMode({ stateDir, mode }) + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) + opts.stderr.write( + `warning: could not record the new-folder answer (${detail}); ` + + `it stays '${before}' - set it later with 'hyp policy folders ${mode}'\n` + ) + return { mode: before, skipped: /** @type {const} */ (true) } + } + return { mode } } - // Two short lines rather than one long one: what is now true, then the - // command that changes it, indented so it reads as a footnote to the - // first rather than a second announcement. + if (opts.deferWrite) { + announceAnswer(mode, opts, inline) + return await finishSpan({ mode, commit: async () => (await write()).mode }, opts) + } + + const written = await write() + if (written.skipped) return await finishSpan(written, opts) + announceAnswer(mode, opts, inline) + return await finishSpan({ mode }, opts) +} + +/** + * Say what is now true and how to change it. + * + * Two short lines rather than one long one: what is now true, then the + * command that changes it, indented so it reads as a footnote to the + * first rather than a second announcement. + * + * @param {FolderAskMode} mode + * @param {RunWizardFolderAskOptions} opts + * @param {boolean} inline + */ +function announceAnswer(mode, opts, inline) { const undo = mode === 'sync' ? 'hyp policy folders ask' : 'hyp policy folders sync' const said = mode === 'sync' ? 'New folders will sync without asking.' @@ -154,7 +194,6 @@ async function recordAnswer(mode, { stateDir, before, opts, inline = false }) { ? ` ${mode === 'sync' ? 'Syncing them all' : 'Asking about each one'}; change later with ${undo}\n` : `${said}\n change this later: ${undo}\n` ) - return await finishSpan({ mode }, opts) } /** diff --git a/src/core/cli/wizard/index.js b/src/core/cli/wizard/index.js index 33207d902..16a2ef7d1 100644 --- a/src/core/cli/wizard/index.js +++ b/src/core/cli/wizard/index.js @@ -24,6 +24,7 @@ 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 { readFolderAskModeSafe } from '../../usage-policy/folder_ask.js' import { LOCAL_INSTALL_RETENTION_DAYS, buildWalkthroughClientDescriptorMap, @@ -165,6 +166,22 @@ export async function runInitWizard(opts) { * @type {FolderAskMode | undefined} */ let folderAsk + /** + * The question lanes' policy writes, held until the config commit + * (LLP 0279 #one-commit-point). Each lane answers, states its answer, + * and hands its write back; the orchestrator runs them once the config + * this run composed is on disk, so an abandoned run leaves every store + * as it found it. Re-assigned, never appended: a back through the lanes + * re-answers them, and only the last answer is the one to write. + * + * Both commits resolve to what the write actually left in force rather + * than to the answer, so a write that fails is reported as the state + * that stands and not as the one it could not keep. + * @type {(() => Promise) | undefined} + */ + let syncCommit + /** @type {(() => Promise) | undefined} */ + let folderCommit /** * Did this pass through the lanes accept the express gate (LLP 0201)? * Re-answered on every pass, so stepping back to the fork and forward @@ -391,6 +408,14 @@ export async function runInitWizard(opts) { // always would (LLP 0201 #no-default-no-accept). if (rows.length > 0) { const expressFn = opts.express ?? runWizardExpressGate + // The standing new-folder answer, so the gate's one line of + // consequence names what accepting leaves in force rather than + // the shipped default (LLP 0279 #standing-answer). Read here and + // not in the lane: the gate has to state it before the lane + // runs, and the safe read never throws. + const standingFolderAsk = await readFolderAskModeSafe({ + stateDir: readObservabilityEnv(opts.env).stateDir, + }) const choice = await expressFn({ stdout: opts.stdout, stderr: opts.stderr, @@ -398,6 +423,7 @@ export async function runInitWizard(opts) { env: opts.env, rows, enrolled: enrolled(), + folderAsk: standingFolderAsk, // The fork is always behind this question. allowBack: true, ...(opts.confirm ? { confirm: opts.confirm } : {}), @@ -419,6 +445,18 @@ export async function runInitWizard(opts) { backFromPick = false atPick: while (true) { + // A pass that re-answers the lanes replaces their held writes, and + // a pass that never reaches them (a back through the fork onto a + // solo local run) must not carry the previous pass's answers + // forward. `sourcesOptedOut` and `folderAsk` go with them: they are + // what the finish log reports, and now that the writes are held + // rather than made on the spot, an abandoned pass's answers were + // never recorded anywhere - so carrying them forward would report a + // policy this machine was never put under. + syncCommit = undefined + folderCommit = undefined + sourcesOptedOut = [] + folderAsk = undefined // The lanes' positions, resolved when their pathway is: a back // through the fork can land on the other pathway, whose itinerary // then states its own positions - exactly as a failed join's retry @@ -533,6 +571,10 @@ export async function runInitWizard(opts) { ...(express ? { autoAccept: true } : {}), // The pick lane is always behind this one. allowBack: true, + // The store write commits below with the config, so a + // declined overwrite leaves this run's opt-outs unwritten + // too (LLP 0279 #one-commit-point). + deferWrite: true, }) if (syncScope.back) continue atPick if (syncScope.cancelled) { @@ -543,6 +585,7 @@ export async function runInitWizard(opts) { return { exitCode: 130, cancelled: true, ...(pathway ? { pathway } : {}) } } sourcesOptedOut = syncScope.optedOut + syncCommit = syncScope.commit const folderFn = opts.folderAsk ?? runWizardFolderAsk const folders = await folderFn({ @@ -555,6 +598,9 @@ export async function runInitWizard(opts) { ...(express ? { autoAccept: true } : {}), // The sync lane is always behind this one. allowBack: true, + // As above: the preference lands with the config or not at + // all (LLP 0279 #one-commit-point). + deferWrite: true, }) // One screen back is the sync lane only when the sync lane was // a screen. On a fully fleet-managed machine (nothing left to @@ -576,6 +622,7 @@ export async function runInitWizard(opts) { return { exitCode: 130, cancelled: true, ...(pathway ? { pathway } : {}) } } folderAsk = folders.mode + folderCommit = folders.commit break atSync } } @@ -608,11 +655,41 @@ export async function runInitWizard(opts) { config: picked.config, }) if (!committed.ok) { + // The lanes stated their answers on screen and the refusal message + // only speaks for the config, so the run says the rest of the answer + // set went with it. Naming only the lanes that actually asked: a sync + // lane with nothing left to opt out of made a statement and handed + // back no commit, and an express pass narrated the standing state on + // both lanes rather than taking an answer, so it has no answer to + // have lost. + // @ref LLP 0279#one-commit-point [implements]: a refusal reports the held policy writes it also dropped + const held = express + ? [] + : [...(syncCommit ? ['sync'] : []), ...(folderCommit ? ['new-folder'] : [])] + if (held.length > 0) { + opts.stderr.write( + held.length > 1 + ? `hyp init: the ${held.join(' and ')} answers from this run were not recorded either\n` + : `hyp init: the ${held[0]} answer from this run was not recorded either\n` + ) + } if (joined) await narrateEnrolledAbort(opts) return { exitCode: 1, ...(pathway ? { pathway } : {}) } } } + // The question lanes' policy stores land here, with the config they + // belong to: they hold this run's answers, and this run's answers are + // either all recorded or none of them are (LLP 0279 #one-commit-point). + // Both commits resolve to what they left in force rather than to the + // answer, so a write that failed is reported by the finish log as the + // state that stands. That is the signal that separates a recorded + // opt-out from one whose warning scrolled past under the configure + // phase, so it must not claim the answer landed. + // @ref LLP 0279#one-commit-point [implements]: the lanes' policy writes run once the config commits, never before + if (syncCommit) sourcesOptedOut = await syncCommit() + if (folderCommit) folderAsk = await folderCommit() + // Attended-only (LLP 0131): the configure phase itself no-ops when // `picks` is set, so threading it through keeps the rule in one place. const configureFn = opts.configure ?? runConfigurePhase diff --git a/src/core/cli/wizard/sync_scope.js b/src/core/cli/wizard/sync_scope.js index d52cac287..bc02071a5 100644 --- a/src/core/cli/wizard/sync_scope.js +++ b/src/core/cli/wizard/sync_scope.js @@ -50,6 +50,11 @@ const SYNC_SCOPE_MENU_TITLE = 'Choose what syncs. Unchecked sources stay on this * never overwrite an uninterpretable privacy signal; the export seam fails * closed on it and `hyp status` names it too. * + * Under `deferWrite` the write itself comes back to the caller as + * `commit` (LLP 0279 #one-commit-point), so the wizard can land it with + * the config this run composed instead of the moment the lane is + * answered. Direct callers without the flag keep the inline write. + * * @ref LLP 0188#never-silent [implements]: the enrolled wizard's sync-scope step names what syncs before anything ships * @ref LLP 0190#sync-gate [implements]: defaults gate first, menu on request; the menu checks what syncs * @param {RunWizardSyncScopeOptions} opts @@ -109,19 +114,53 @@ export async function runWizardSyncScope(opts) { const optedOut = selection.optedOut const kept = existing.filter((e) => !candidateIds.has(e.source)) - await writeClientSyncEntries({ - stateDir, - entries: [ - ...kept, - ...optedOut.map((source) => ({ source, class: /** @type {'local-only'} */ ('local-only') })), - ], - }) + const entries = [ + ...kept, + ...optedOut.map((source) => ({ source, class: /** @type {'local-only'} */ ('local-only') })), + ] + const write = async () => { await writeClientSyncEntries({ stateDir, entries }) } + // The deferred form of the write runs after this run's config is already + // on disk. A throw there would abort everything the commit point still + // owes the run - the new-folder lane's held write, the configure phase, + // the finale - so it warns and leaves the standing store alone instead. + // That is the contract the new-folder lane's write already has. The + // inline form runs before anything has been committed, so it still + // throws and the run dies with nothing written. + // + // It resolves to the opt-outs actually in force, which on a failure is + // the set that stood before this run: warning and then reporting the + // answer would leave the finish log claiming an opt-out the export seam + // will not honour, and that log is the only place the failure is still + // visible once the configure phase has scrolled the warning away. + /** @returns {Promise} */ + const commit = async () => { + try { + await write() + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) + opts.stderr.write( + `warning: could not record the sync answers (${detail}); ` + + "the previous sync scope stands - set it later with 'hyp policy client sync|local-only'\n" + ) + return [...optedOutBefore].sort() + } + return optedOut + } + // Under `deferWrite` the store write is handed back instead of made here + // (LLP 0279 #one-commit-point). The lane still states the split it just + // settled - that statement belongs to the screen the user answered - but + // the opt-out set is one of this run's answers, and this run's answers + // land together with its config or not at all. Absence of the store is + // itself a signal (LLP 0188 #migration), so stamping it on a run the + // user then abandons is not the inert half-write it looks like. + // @ref LLP 0279#one-commit-point [implements]: the store write is handed back rather than made the moment the lane is answered + if (!opts.deferWrite) await write() if (optedOut.length > 0) { opts.stdout.write( `Keeping local-only: ${optedOut.join(' · ')}. Change later with 'hyp policy client sync|local-only'.\n` ) } - return await finishSpan({ optedOut }, opts) + return await finishSpan({ optedOut, ...(opts.deferWrite ? { commit } : {}) }, opts) } /** diff --git a/src/core/cli/wizard/types.d.ts b/src/core/cli/wizard/types.d.ts index 66cf4e086..610ba44b7 100644 --- a/src/core/cli/wizard/types.d.ts +++ b/src/core/cli/wizard/types.d.ts @@ -104,6 +104,14 @@ export interface RunWizardSyncScopeOptions { * no default to state, which still asks. */ autoAccept?: boolean + /** + * Hand the store write back instead of making it here (LLP 0279 + * #one-commit-point). The lane answers and states the split as always, + * and the caller runs `commit` once this run's config is on disk, so an + * abandoned run leaves the opt-out store exactly as it found it. Direct + * callers without the flag keep the inline write. + */ + deferWrite?: boolean } export interface WizardSyncScopeResult { @@ -124,6 +132,16 @@ export interface WizardSyncScopeResult { * express path, which asks nothing anywhere and never backs. */ noQuestion?: true + /** + * The store write this lane deferred (`deferWrite`, LLP 0279 + * #one-commit-point), for the caller to run when the run's config + * commits. It resolves to the opt-outs actually left in force, which is + * the previously standing set when the write fails (the failure warns + * rather than throwing, since the run is past its commit point by then). + * Absent when the lane wrote inline, and absent on every outcome that + * writes nothing (cancel, back, corrupt store, no candidates). + */ + commit?: () => Promise } /** @@ -148,9 +166,17 @@ export interface RunWizardFolderAskOptions { confirm?: AsyncConfirmSelectPrompt /** * Take the default answer without asking (LLP 0201): the express gate - * already answered this lane, so it narrates and records the default. + * already answered this lane, so it narrates and records the default, + * which on a re-run is the standing answer (LLP 0279 #standing-answer). */ autoAccept?: boolean + /** + * Hand the preference write back instead of making it here (LLP 0279 + * #one-commit-point). The lane still states its answer; the caller runs + * `commit` once this run's config is on disk, so an abandoned run leaves + * the standing mode alone. + */ + deferWrite?: boolean } /** @@ -180,6 +206,13 @@ export interface RunWizardExpressGateOptions { * that new folders sync without a question. */ enrolled?: boolean + /** + * The standing new-folder answer (LLP 0200), so the accept row's one + * line of consequence states what accepting actually leaves in force + * (LLP 0279 #standing-answer). Absent or `sync` reads as the shipped + * default and the line is unchanged. + */ + folderAsk?: FolderAskMode /** Offer back-navigation to the fork (LLP 0191). */ allowBack?: boolean /** Prompt seam (tests); defaults to the confirm-select factory. */ @@ -198,6 +231,14 @@ export interface WizardFolderAskResult { back?: true /** The answer could not be written; the previous mode stands. */ skipped?: boolean + /** + * The preference write this lane deferred (`deferWrite`, LLP 0279 + * #one-commit-point), for the caller to run when the run's config + * commits. It resolves to the mode actually left in force, which is the + * previous one when the write fails (the failure warns exactly as the + * inline path's does). Absent on cancel and back, which write nothing. + */ + commit?: () => Promise } export interface RunWizardForkOptions { diff --git a/test/core/cli/wizard/express.test.js b/test/core/cli/wizard/express.test.js index 849aa6255..3e80d261d 100644 --- a/test/core/cli/wizard/express.test.js +++ b/test/core/cli/wizard/express.test.js @@ -169,3 +169,20 @@ test('narrateAcceptedGate prints the gate title and its items verbatim, led by a // arrive back to back with no prompts between them. assert.equal(stdout.text(), '\nHypAware will record:\n Claude Code\n Codex\n') }) + +// Accepting takes each lane's stated default, and the new-folder lane's +// default on a re-run is the answer already in force (LLP 0279 +// #standing-answer). The one line the fast path is guaranteed to read has +// to name that, not the shipped default it is not about to apply. +// @ref LLP 0279#standing-answer [tests]: +test('the accept row names the standing new-folder answer, not the shipped default', async () => { + const { env } = await makeHome() + const { confirm, state } = capturingConfirm('defaults') + + await runWizardExpressGate(/** @type {any} */ ({ + stdout: makeBuf(), stderr: makeBuf(), env, enrolled: true, rows: ROWS, folderAsk: 'ask', confirm, + })) + + assert.match(state.question.options[0].summary, /new folders keep asking/) + assert.doesNotMatch(state.question.options[0].summary, /new folders sync too/) +}) diff --git a/test/core/cli/wizard/folder_ask.test.js b/test/core/cli/wizard/folder_ask.test.js index 9007b226a..41ba63881 100644 --- a/test/core/cli/wizard/folder_ask.test.js +++ b/test/core/cli/wizard/folder_ask.test.js @@ -167,3 +167,64 @@ test('an unwritable preference warns and leaves the previous mode standing', asy test('the two options are exactly sync and ask', () => { assert.deepEqual(FOLDER_ASK_OPTIONS.map((o) => o.value), ['sync', 'ask']) }) + +// Accepting the express gate takes each lane's *stated* default (LLP 0201 +// #gate), and this lane's stated default on a re-run is the standing +// answer, not the shipped one: the asked path already defaults to it. +// @ref LLP 0279#standing-answer [tests]: +test('the express path keeps a standing ask instead of resetting it to sync', async () => { + const { env, stateDir } = await makeHome() + await writeFolderAskMode({ stateDir, mode: 'ask' }) + const stdout = makeBuf() + + const result = await runWizardFolderAsk(/** @type {any} */ ({ + stdout, stderr: makeBuf(), env, + autoAccept: true, + confirm: async () => { throw new Error('the express path must not prompt') }, + })) + + assert.deepEqual(result, { mode: 'ask' }) + assert.equal(await readFolderAskMode({ stateDir }), 'ask', 'the standing preference survives the express gate') + assert.match(stdout.text(), /^ {2}Asking about each one; change later with hyp policy folders sync$/m) +}) + +// The lanes' writes ride the config commit (LLP 0279 #one-commit-point): +// with `deferWrite` the lane answers and states its answer, and hands the +// caller the write to run once the config it belongs to is on disk. +// @ref LLP 0279#one-commit-point [tests]: +test('deferWrite answers and states the answer, but hands the write back to the caller', async () => { + const { env, stateDir } = await makeHome() + await writeFolderAskMode({ stateDir, mode: 'ask' }) + const stdout = makeBuf() + + const result = await runWizardFolderAsk(/** @type {any} */ ({ + stdout, stderr: makeBuf(), env, + deferWrite: true, + confirm: async () => 'sync', + })) + + assert.equal(result.mode, 'sync') + assert.ok(result.commit, 'the write comes back as a commit for the caller to run') + assert.equal(await readFolderAskMode({ stateDir }), 'ask', 'nothing is written until the caller commits') + assert.match(stdout.text(), /New folders will sync without asking/) + + assert.equal(await result.commit(), 'sync') + assert.equal(await readFolderAskMode({ stateDir }), 'sync') +}) + +test('a deferred write that fails warns and reports the mode still in force', async () => { + const { env, stateDir } = await makeHome() + const stderr = makeBuf() + + const result = await runWizardFolderAsk(/** @type {any} */ ({ + stdout: makeBuf(), stderr, env, + deferWrite: true, + confirm: async () => 'ask', + })) + // A directory where the file belongs makes the deferred write fail. + await fs.mkdir(folderAskPath(stateDir), { recursive: true }) + + assert.ok(result.commit) + assert.equal(await result.commit(), 'sync', 'the mode already in force is what is reported') + assert.match(stderr.text(), /could not record the new-folder answer/) +}) diff --git a/test/core/cli/wizard/index.test.js b/test/core/cli/wizard/index.test.js index 24b454b57..ad1275783 100644 --- a/test/core/cli/wizard/index.test.js +++ b/test/core/cli/wizard/index.test.js @@ -8,6 +8,10 @@ import path from 'node:path' import { EventEmitter } from 'node:events' import { firstLookHadRows, runInitWizard } from '../../../../src/core/cli/wizard/index.js' +import { runWizardSyncScope } from '../../../../src/core/cli/wizard/sync_scope.js' +import { runWizardFolderAsk } from '../../../../src/core/cli/wizard/folder_ask.js' +import { clientSyncListPath } from '../../../../src/core/usage-policy/client_sync.js' +import { readFolderAskMode, writeFolderAskMode } from '../../../../src/core/usage-policy/folder_ask.js' import { writeFirstSyncHoldMarker } from '../../../../src/core/usage-policy/first_sync_hold.js' import { OVERVIEW_PROBE_SQL } from '../../../../src/core/query/overview.js' import { SUGGESTED_PROMPTS } from '../../../../src/core/cli/wizard/first_ask.js' @@ -1229,3 +1233,119 @@ test('runInitWizard: local pathway never narrates the first-sync hold', async () await runInitWizard(opts) assert.doesNotMatch(stdout.text(), /Nothing has been uploaded yet/) }) + +// --- the question lanes' policy writes ride the same commit point --- +// The sync lane's opt-out store and the new-folder lane's preference are +// answers to this run's questions, so they land when this run's config +// lands and not before: a declined overwrite leaves the machine exactly as +// it found it, and says so. +// @ref LLP 0279#one-commit-point [tests]: + +/** The pick result an enrolled lane run needs: one editable candidate. */ +function enrolledPickResult(configPath) { + return pickResult({ + configPath, + configPending: true, + descriptors: [{ plugin: '@hypaware/claude', id: 'claude', label: 'Claude Code' }], + }) +} + +/** + * A `confirm` seam that answers each question lane's gate by title, so the + * real lanes can run inside the orchestrator without a terminal. + * + * @param {string} folderAnswer + */ +function laneConfirm(folderAnswer) { + return async (/** @type {any} */ question) => { + if (String(question.title).startsWith('When you start a session')) return folderAnswer + return 'accept' + } +} + +test('runInitWizard: a declined commit leaves the sync store and the new-folder preference untouched', async () => { + const home = await tmpHome() + const stateDir = path.join(home, '.hyp', 'hypaware') + const configPath = path.join(home, '.hyp', 'config.json') + await fs.mkdir(path.dirname(configPath), { recursive: true }) + await fs.writeFile(configPath, '{"version":2,"plugins":["existing"]}\n', 'utf8') + // A standing preference the abandoned run must not overwrite. + await writeFolderAskMode({ stateDir, mode: 'ask' }) + + const { opts, stderr } = wizardOpts(home, { + fork: async () => 'team', + pick: async () => enrolledPickResult(configPath), + // The real lanes, so the writes under test are the real writes. + syncScope: runWizardSyncScope, + folderAsk: runWizardFolderAsk, + confirm: laneConfirm('sync'), + confirmOverwrite: async () => false, + }) + + const result = await runInitWizard(opts) + assert.equal(result.exitCode, 1) + assert.equal( + await fs.access(clientSyncListPath(stateDir)).then(() => true, () => false), + false, + 'the opt-out store is not even stamped: absence is the LLP 0188 migration marker' + ) + assert.equal(await readFolderAskMode({ stateDir }), 'ask', 'the standing new-folder answer stands') + assert.match(stderr.text(), /answers from this run were not recorded/) +}) + +test('runInitWizard: a committed run persists both lanes right after the config lands', async () => { + const home = await tmpHome() + const stateDir = path.join(home, '.hyp', 'hypaware') + const configPath = path.join(home, '.hyp', 'config.json') + let storeDuringLanes = true + + const { opts } = wizardOpts(home, { + fork: async () => 'team', + pick: async () => enrolledPickResult(configPath), + syncScope: runWizardSyncScope, + folderAsk: async (/** @type {any} */ o) => { + storeDuringLanes = await fs.access(clientSyncListPath(stateDir)).then(() => true, () => false) + return runWizardFolderAsk(o) + }, + confirm: laneConfirm('ask'), + }) + + const result = await runInitWizard(opts) + assert.equal(result.exitCode, 0) + assert.equal(storeDuringLanes, false, 'the sync store waits for the commit, like the config does') + assert.deepEqual( + JSON.parse(await fs.readFile(clientSyncListPath(stateDir), 'utf8')), + { version: 1, entries: [] }, + 'the answered lane still stamps the store once the config lands' + ) + assert.equal(await readFolderAskMode({ stateDir }), 'ask') +}) + +// An express pass narrates the standing state on both lanes rather than +// taking an answer from the user, so a declined overwrite has no lane +// answer to have dropped and must not claim it does: the message exists to +// name what the run threw away, and naming something it never held would +// send the user to `hyp policy` to restore an answer they never gave. +// @ref LLP 0279#one-commit-point [tests]: +test('runInitWizard: a declined commit on the express path claims no dropped lane answers', async () => { + const home = await tmpHome() + const configPath = path.join(home, '.hyp', 'config.json') + await fs.mkdir(path.dirname(configPath), { recursive: true }) + await fs.writeFile(configPath, '{"version":2,"plugins":["existing"]}\n', 'utf8') + + const { opts, stderr } = wizardOpts(home, { + fork: async () => 'team', + catalog: detectableCatalog(), + detect: async () => new Set(['claude']), + express: async () => 'defaults', + pick: async () => enrolledPickResult(configPath), + // The real lanes, so their auto-accept arms hand back the real commits. + syncScope: runWizardSyncScope, + folderAsk: runWizardFolderAsk, + confirmOverwrite: async () => false, + }) + + const result = await runInitWizard(opts) + assert.equal(result.exitCode, 1) + assert.doesNotMatch(stderr.text(), /answers from this run were not recorded/) +}) diff --git a/test/core/cli/wizard/sync_scope.test.js b/test/core/cli/wizard/sync_scope.test.js index 18bf0069c..cf84bb27c 100644 --- a/test/core/cli/wizard/sync_scope.test.js +++ b/test/core/cli/wizard/sync_scope.test.js @@ -391,3 +391,91 @@ test('a corrupt store skips the step with a warning and is never overwritten', a assert.match(stderr.text(), /unreadable/) assert.equal(await fs.readFile(storePath, 'utf8'), '{ nope') }) + +// The lane's write rides the config commit (LLP 0279 #one-commit-point): +// with `deferWrite` it answers and states the split, and hands the caller +// the store write to run once the config it belongs to is on disk. +// @ref LLP 0279#one-commit-point [tests]: +test('deferWrite answers and states the split, but hands the store write back to the caller', async () => { + const { env, stateDir } = await makeHome() + const { prompt } = capturingPrompt(['b']) + const { confirm } = capturingConfirm('customize') + const stdout = makeBuf() + + const result = await runWizardSyncScope(/** @type {any} */ ({ + stdout, stderr: makeBuf(), env, + candidates: [descriptor('a'), descriptor('b')], + deferWrite: true, + prompt, + confirm, + })) + + assert.deepEqual(result.optedOut, ['a']) + assert.ok(result.commit, 'the store write comes back as a commit for the caller to run') + assert.equal( + await fs.access(clientSyncListPath(stateDir)).then(() => true, () => false), + false, + 'nothing is stamped until the caller commits' + ) + assert.match(stdout.text(), /Keeping local-only: a/) + + await result.commit() + assert.deepEqual(await readClientSyncEntries({ stateDir }), [{ source: 'a', class: 'local-only' }]) +}) + +// The deferred commit runs after this run's config is on disk, with the +// new-folder lane's held write, the configure phase, and the finale still +// owed. A throw there would abandon the run half-done, so it warns and +// leaves the standing store alone, exactly as the new-folder lane's +// deferred write does. +// @ref LLP 0279#one-commit-point [tests]: +test('a deferred store write that fails warns instead of aborting the run', async () => { + const { env, stateDir } = await makeHome() + const { prompt } = capturingPrompt(['b']) + const { confirm } = capturingConfirm('customize') + const stderr = makeBuf() + + const result = await runWizardSyncScope(/** @type {any} */ ({ + stdout: makeBuf(), stderr, env, + candidates: [descriptor('a'), descriptor('b')], + deferWrite: true, + prompt, + confirm, + })) + // A directory where the file belongs makes the deferred write fail. + await fs.mkdir(clientSyncListPath(stateDir), { recursive: true }) + + assert.ok(result.commit) + await result.commit() + assert.match(stderr.text(), /could not record the sync answers/) + assert.match(stderr.text(), /the previous sync scope stands/) +}) + +// The warning a failing deferred commit prints is followed by the configure +// phase and the finale, so it has scrolled away by the time the run ends. +// What survives is the set the commit reported, which the orchestrator puts +// on the finish log - so it has to be the scope that actually stands, not +// the answer the write could not keep. +// @ref LLP 0279#one-commit-point [tests]: +test('a failed deferred store write reports the sync scope still in force, not the answer', async () => { + const { env, stateDir } = await makeHome() + await writeClientSyncEntries({ stateDir, entries: [{ source: 'a', class: 'local-only' }] }) + const { prompt } = capturingPrompt(['a', 'b']) + const { confirm } = capturingConfirm('customize') + + const result = await runWizardSyncScope(/** @type {any} */ ({ + stdout: makeBuf(), stderr: makeBuf(), env, + candidates: [descriptor('a'), descriptor('b')], + deferWrite: true, + prompt, + confirm, + })) + assert.deepEqual(result.optedOut, [], 'the answer checks both sources back on') + + // A directory where the file belongs makes the deferred write fail. + await fs.rm(clientSyncListPath(stateDir)) + await fs.mkdir(clientSyncListPath(stateDir), { recursive: true }) + + assert.ok(result.commit) + assert.deepEqual(await result.commit(), ['a'], 'the opt-out that still stands is what is reported') +})