Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions llp/0190-wizard-defaults-gate.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions llp/0201-express-defaults-gate.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 163 additions & 0 deletions llp/0279-lane-answers-commit-with-the-config.decision.md
Original file line number Diff line number Diff line change
@@ -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 id="one-commit-point"></a>**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.

<a id="standing-answer"></a>**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`
13 changes: 11 additions & 2 deletions src/core/cli/wizard/express.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
{
Expand Down
71 changes: 55 additions & 16 deletions src/core/cli/wizard/folder_ask.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<WizardFolderAskResult>}
*/
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.'
Expand All @@ -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)
}

/**
Expand Down
Loading
Loading