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')
+})