Skip to content
Merged
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
7 changes: 7 additions & 0 deletions llp/0069-local-only-dir-selection.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ is **no half-enrolled window** (see [dismiss semantics, LLP 0072 §default](./00
> fresh, so R6's "not forwarded, even once" holds again on the auto-daemon
> fresh-enroll path (bounded by the marker's TTL).

> **Suspended-by [LLP 0094](./0094-enrollment-picker-suspended.decision.md).**
> The login-time trigger this section specifies is currently disabled behind
> `ENROLLMENT_PICKER_ENABLED = false` pending a redesign of the enumeration
> (which raced the first backfill and showed a partial candidate list) and
> presentation. Enumeration, persistence, and enforcement below are unchanged;
> the enrolling forks print the durable-command hint instead of prompting.

The picker only makes sense on an **enrolling** login (one that provisions or
already has a central sink). A `--no-forward` / query-only login
([LLP 0063 D3](./0063-login-auto-provision-forward-sink.decision.md)) forwards
Expand Down
5 changes: 5 additions & 0 deletions llp/0072-enrollment-dir-picker.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
>
> @ref LLP 0069 [implements] — the picker/consent half of the spec.
> @ref LLP 0063 [constrained-by] — must not reintroduce the enrollment prompt D3 (§Decisions) rejected.
>
> **Suspended-by [LLP 0094](./0094-enrollment-picker-suspended.decision.md):**
> the login-time trigger is currently disabled pending redesign; the doctrine
> this decision settles (skippable refinement, never a consent gate) is
> unchanged and is what makes the outright suspension safe.
## Context

Expand Down
6 changes: 6 additions & 0 deletions llp/0080-local-only-dir-selection.design.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
> `@ref LLP 0072 [constrained-by]` — the picker is a skippable, TTY-gated,
> post-enrollment refinement that defaults to excluding nothing; never a consent
> gate.
>
> **Suspended-by [LLP 0094](./0094-enrollment-picker-suspended.decision.md):**
> the enrollment picker trigger this design wires into `hyp remote login` is
> currently disabled pending redesign (the fresh-enroll candidate wait raced
> the first backfill; the bounded 50-item presentation is also being
> rethought). The machine-local list and export-seam withholding remain live.

## Overview

Expand Down
5 changes: 5 additions & 0 deletions llp/0093-pick-pending-export-hold.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,8 @@ switch, and LLP 0072 already decided the picker never gets one.
usually-absent file otherwise.
- `hyp remote login --no-forward` and query-only logins never write the
marker (they never reach the enrolling fork).

> **Dormant while [LLP 0094](./0094-enrollment-picker-suspended.decision.md)
> suspends the picker trigger:** no login writes the marker (with no pending
> pick there is nothing to guard). The sink-driver hold and TTL behavior stay
> implemented and tested for when the trigger returns.
107 changes: 107 additions & 0 deletions llp/0094-enrollment-picker-suspended.decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# LLP 0094: the enrollment picker trigger is suspended pending redesign

**Type:** Decision
**Status:** Accepted
**Systems:** CLI, Onboarding, Usage-Policy
**Author:** Phil / Claude
**Date:** 2026-07-09
**Related:** LLP 0069, LLP 0072, LLP 0080, LLP 0081, LLP 0093

> Turns the login-time local-only directory picker OFF, wholesale, until its
> candidate enumeration and presentation are redesigned. Everything around the
> trigger survives: the durable `hyp ignore --local-only` command, the
> machine-local list ([LLP 0071](./0071-machine-local-exclusion-list.decision.md)),
> the export-seam enforcement ([LLP 0070](./0070-local-only-export-seam.decision.md)),
> and the picker module itself. Only the enrolling-login prompt is withheld.
>
> @ref LLP 0069 [constrained-by] - suspends the §trigger requirement; enumeration, persistence, and enforcement stand.
> @ref LLP 0072 [constrained-by] - the "skippable refinement, never a gate" doctrine is why an outright suspension is safe.

## Context

The fresh-enroll picker path accumulated three fixes in one week (issue #281's
reordering in PR #283, the registry re-boot in PR #286, the export hold in
LLP 0093 / PR #287) and the first real-history enrollment after all three
still misfired, in a new way:

On the 2026-07-09 fresh enroll (v1.13.1, empty cache, ~128k rows of claude +
codex history), the picker appeared but offered only a small handful of
directories. The local cache actually contains **126 distinct captured
`cwd`s**. The cause is structural, not a tuning bug:
`waitForCapturedDirectories` (`src/core/cli/remote_commands.js`) polls the
candidate enumeration every second and returns at the **first non-empty**
result, because its contract is "has backfill started landing rows", while
the picker needs "has backfill *settled*". The daemon's first backfill streams
~119k rows over ~25 seconds; the wait returned within the first tick or two
and its early snapshot was handed to the picker verbatim
(`listCandidates: () => Promise.resolve(captured)`), never re-enumerated. The
user is silently shown a race-ordered slice of their history presented as the
whole thing - worse than showing nothing, because it invites a "pick"
decision over a false picture.

Fixing this properly is not another patch on the wait. The rethink on the
table replaces both halves of the feature:

- **Enumeration**: read candidate directories from the clients' own on-disk
logs (Claude transcripts carry `cwd` on every line; Codex rollouts carry
`session_meta.cwd` on line 1, already read by the codex plugin's
`rollout-cwd.js`) via a plugin-contributed provider, instead of racing the
cache the backfill is still filling.
- **Presentation**: a searchable TUI (filter-as-you-type) instead of the
50-item "bounded presentation" cap of [LLP 0080](./0080-local-only-dir-selection.design.md).

Both change Accepted designs (LLP 0069 §enumerate, LLP 0080) and deserve
their own deliberation. Meanwhile every fresh enroll runs a picker that
misrepresents the machine's history.

## Decision

**The enrolling-login picker trigger is disabled behind a single module
constant, `ENROLLMENT_PICKER_ENABLED = false`
(`src/core/cli/remote_commands.js`), until a redesign re-enables it.**

Scope of the suspension:

- Every login fork that would have prompted (fresh enroll, `--no-daemon`,
daemon-install-failure, re-login editor) prints the picker's existing
durable-command hint instead: the capability stays discoverable
(LLP 0072 §tty's never-silent spirit) with zero interactivity.
- The fresh-enroll support machinery is skipped as pure overhead while the
trigger is off: no captured-directory wait, no `freshenCaptureEnumeration`
kernel re-boot, and no pick-pending marker (LLP 0093) - with no pending
pick there is nothing for the marker to guard, so exports proceed exactly
as they would after a non-TTY skip.
- **Unchanged**: `hyp ignore --local-only` / `hyp unignore`, the
machine-local list and its editor semantics, status reporting of withheld
directories, and the export-seam withholding. Existing lists written by
earlier picks keep working; this suspends refinement *acquisition* at
enrollment, never enforcement.
- The picker module (`src/core/commands/local_only.js`), the wait, the
freshen re-boot, and the marker helpers stay in-tree with their unit
tests: the suspension is one flag flip to revert, and the wiring's
ordering rationale remains documented in place.

Chosen over the alternatives:

- *Patch the wait again* (poll to quiescence, gate on backfill-done client
actions): rejected for now; it deepens investment in cache-race heuristics
the redesign intends to delete.
- *Delete the wiring outright*: rejected; the redesign will want the fork
ordering, the TTY gating, and the editor semantics as its skeleton, and a
flag documents "off on purpose" better than an absence.

## Consequences

- Fresh enrolls are simpler and faster (no up-to-30s wait, no second kernel
boot) and can no longer present a misleading candidate list.
- Privacy refinement at enrollment regresses to opt-in via the printed
durable command. R6's "never forwarded, not even once" guarantee for
*would-have-been-picked* directories no longer has an enrollment-time
acquisition path; a user who wants exclusions before first forward must run
`hyp ignore --local-only` (or enroll with `--no-daemon`, exclude, then
`hyp daemon install`).
- LLP 0093's marker mechanism is dormant (the sink-driver hold and TTL
behavior remain implemented and tested; no login writes the marker).
- Re-enabling is a deliberate act: flip the constant only alongside the
redesign that fixes enumeration, and record that as a new decision
superseding this one.
36 changes: 33 additions & 3 deletions src/core/cli/remote_commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { Attr, getLogger } from '../observability/index.js'
import { readCentralSinkOrigins, seedLoginGateway } from '../remote/gateway_seed.js'
import { bootKernel } from '../runtime/boot.js'
import { enrollCentralSink } from '../commands/central.js'
import { CAPTURE_DATASET, listCapturedDirectories, runLocalOnlyPicker } from '../commands/local_only.js'
import { CAPTURE_DATASET, DURABLE_HINT, listCapturedDirectories, runLocalOnlyPicker } from '../commands/local_only.js'
import { clearPickPendingMarker, writePickPendingMarker } from '../usage-policy/pick_pending.js'
import { originOf } from '../remote/gateway_seed.js'
import { isTty, readAllStdin } from './stdio.js'
Expand Down Expand Up @@ -308,6 +308,18 @@ async function clearPickPendingBestEffort(stateDir) {
}
}

// The login-time local-only picker is disabled pending a redesign: its
// fresh-enroll candidate wait returns at the FIRST non-empty enumeration,
// racing the daemon's still-running backfill, so on any machine with real
// history it offers a small early slice of the captured directories as if
// it were the full set. Until the enumeration source and presentation are
// rethought, the trigger is off: every fork prints the durable hint
// instead. The picker module, its editor semantics, the durable
// `hyp ignore --local-only` command, and the export-seam withholding all
// remain; flipping this constant restores the wiring below unchanged.
// @ref LLP 0094 [implements]: enrollment picker trigger disabled pending redesign
const ENROLLMENT_PICKER_ENABLED = false

/**
* Run the local-only directory picker as a best-effort refinement of an
* enrolling login. A non-cancellation failure (e.g. a corrupt existing list) is
Expand All @@ -317,10 +329,18 @@ async function clearPickPendingBestEffort(stateDir) {
* handed to the picker so the cache is not re-scanned; otherwise the picker
* enumerates the cache itself.
*
* While the enrollment trigger is disabled (LLP 0094) this prints the same
* durable-command hint the picker's own skip paths print, keeping the
* capability discoverable (LLP 0072 #tty) without prompting.
*
* @param {{ ctx: CommandRunContext, stateDir: string, picker: typeof runLocalOnlyPicker, listCandidates?: () => Promise<any[] | null> }} args
* @ref LLP 0072 [implements]: never blocks login/enrollment on picker failure
*/
async function refineLocalOnly({ ctx, stateDir, picker, listCandidates }) {
if (!ENROLLMENT_PICKER_ENABLED) {
ctx.stderr.write(DURABLE_HINT)
return
}
try {
await picker(listCandidates ? { ctx, stateDir, listCandidates } : { ctx, stateDir })
} catch (err) {
Expand Down Expand Up @@ -698,6 +718,10 @@ async function runBrowserLogin(name, { org, host, noBrowser, noForward, noDaemon
return 1
}

// NOTE: the picker trigger is currently disabled (ENROLLMENT_PICKER_ENABLED,
// LLP 0094); the forks below keep their ordering and print the durable hint
// where they would have prompted. The ordering rationale stands for when it
// is re-enabled:
// The local-only directory picker is deferred to *after* provisioning, not run
// here (issue #281). Its candidate list is the distinct captured cwds in the
// local cache (LLP 0069 #enumerate), and on a first-time enroll the cache is
Expand Down Expand Up @@ -727,8 +751,11 @@ async function runBrowserLogin(name, { org, host, noBrowser, noForward, noDaemon
// and the marker's TTL bounds a crashed login that never reaches it.
// Best-effort: a failed marker write degrades to today's behavior (no
// hold) rather than failing the login.
// With the picker trigger disabled (LLP 0094) there is no pending pick
// for the marker to guard, so it is not written: exports proceed as they
// would after a non-TTY skip.
// @ref LLP 0093 [implements]: login-owned marker lifecycle - write pre-enroll, clear on every exit
const holdingExports = await markPickPendingBestEffort(stateDir)
const holdingExports = ENROLLMENT_PICKER_ENABLED ? await markPickPendingBestEffort(stateDir) : false
try {
/** @type {Awaited<ReturnType<typeof enrollCentralSink>>} */
let result
Expand Down Expand Up @@ -814,7 +841,10 @@ async function runBrowserLogin(name, { org, host, noBrowser, noForward, noDaemon
// poll when it cannot prompt, so a scripted enroll never stalls up to
// CAPTURE_WAIT_DEFAULT_MS for a list it will never show; the picker still
// runs and takes its own durable-hint path immediately.
const canPromptPicker = isTty(ctx.stdin ?? process.stdin) && isTty(ctx.stderr)
// While the picker trigger is disabled (LLP 0094) the wait and the
// registry refresh are pure overhead - they exist only to fill an
// interactive candidate list - so both are skipped with the prompt.
const canPromptPicker = ENROLLMENT_PICKER_ENABLED && isTty(ctx.stdin ?? process.stdin) && isTty(ctx.stderr)
if (attached.length > 0 && canPromptPicker) {
// On a first-run box the boot snapshot in ctx.query predates the org
// config pull that registers the dataset the enumeration reads, so the
Expand Down
4 changes: 3 additions & 1 deletion src/core/commands/local_only.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ const MAX_SHOWN_CANDIDATES = 50
// Printed whenever the picker cannot run to completion (no candidates, a
// non-TTY login, or a failed enumeration) so the capability stays
// discoverable even when the interactive step is skipped (LLP 0072 #tty).
const DURABLE_HINT = "tip: mark a directory local-only anytime with 'hyp ignore --local-only [path]'\n"
// Exported so the login flow can print the same hint while the enrollment
// trigger itself is disabled (LLP 0094).
export const DURABLE_HINT = "tip: mark a directory local-only anytime with 'hyp ignore --local-only [path]'\n"

// The dataset the candidate enumeration reads. Exported so the login flow
// can ask "can this kernel snapshot run the enumeration at all?" against the
Expand Down
Loading