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
14 changes: 11 additions & 3 deletions llp/0011-setup-and-onboarding.decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,27 @@
> realizing the plugin-contributed picks this document already named. The
> prose below is rewritten with the implementation.

> **Amended by [LLP 0137](./0137-onboarding-retention-defaults.decision.md)**:
> the "Cache retention (days)" question is removed; the pathway sets the
> default instead (30-day team / 120-day local).

## Interactive walkthrough

The primary way to get a HypAware install on the ground is the interactive
setup (`npx hypaware`):

```text
What do you want to collect? (Claude / Codex / raw Anthropic+OpenAI / OTEL)
Where should HypAware export? (local cache only / local Parquet / central / later)
Cache retention (days, default 30): 30

✓ Wrote ~/.hyp/hypaware-config.json
✓ Wired Claude Code to use the local proxy
✓ Started the HypAware daemon
```

Export and retention are not asked: export defaults to local Parquet, and
the retention window comes from the pathway chosen at the fork
([LLP 0137](./0137-onboarding-retention-defaults.decision.md#pathway-defaults)).

The walkthrough is the canonical first-run experience. It composes
**plugin-contributed picks** — each source/client plugin registers what it
collects; each sink plugin registers what it exports to — and writes a config
Expand Down Expand Up @@ -92,7 +98,9 @@ HypAware picks the plugin set. There are no names like "standalone" or
a source on, never hides one. Raw proxy sources and OTEL are never
autodetected (no installed tool to find). See [LLP 0012](./0012-sources.spec.md#source-kinds).
- **Default** is a fixed starting selection not derived from system state:
export defaults to local Parquet, retention defaults to 30 days.
export defaults to local Parquet; retention defaults by pathway, 30 days
on a team install and 120 days on a local-only one
([LLP 0137](./0137-onboarding-retention-defaults.decision.md#pathway-defaults)).

(Canonical definitions live in `CONTEXT.md`.)

Expand Down
53 changes: 53 additions & 0 deletions llp/0137-onboarding-retention-defaults.decision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# LLP 0137: Onboarding never asks for retention; the pathway sets the default

**Type:** Decision
**Status:** Active
**Systems:** Onboarding, CLI, Config
**Author:** Phil / Claude
**Date:** 2026-07-24
**Related:** LLP 0011, LLP 0013, LLP 0129, LLP 0135

> Amends [LLP 0011](./0011-setup-and-onboarding.decision.md): the
> "Cache retention (days)" question is removed from the interactive
> walkthrough, and the flat 30-day default becomes pathway-scoped.

## Decision

<a id="pathway-defaults"></a>**Onboarding never asks for a cache
retention window. The pathway chosen at the fork
([LLP 0129](./0129-init-wizard-fork.decision.md#fork)) decides the
default instead:**

- **Join a team: 30 days.** The org server holds the durable copy of
forwarded history, so the local cache is a working window, not the
record.
- **Local install: 120 days.** Nothing leaves the machine; the local
cache is the only copy of history, so it keeps a much longer window.
- A managed machine's **scoped re-entry** counts as the team side
(30 days), same rationale.

The wizard orchestrator passes the local default into the pick phase as
`retentionDefault`; team and scoped runs fall through to the pick
phase's `DEFAULT_RETENTION_DAYS`. The superseded programmatic
walkthrough (`runPickerWalkthrough`), which has no fork, keeps the flat
30-day default.

## Why not ask

Retention was the last free-text question in onboarding, and it asked
for a judgment the user has no basis to make on first run (how many
days of an as-yet-empty cache to keep). Every other pick already
defaults (export defaults to local Parquet,
[LLP 0011](./0011-setup-and-onboarding.decision.md#autodetect-vs-default));
the window is a config value (`query.cache.retention.default_days`,
[LLP 0013](./0013-local-query-cache.decision.md#retention-is-the-central-tradeoff))
that remains freely editable after the fact, so asking up front bought
nothing but friction.

## Overrides unchanged

`hyp init --retention-days <n>` still sets the window explicitly on the
non-interactive path (its flag default stays 30), and editing
`query.cache.retention` in the written config remains the post-install
knob. Fleet-enforced retention stays deliberately deferred
([LLP 0031](./0031-layered-config.decision.md#open-questions--deferred)).
3 changes: 0 additions & 3 deletions src/core/cli/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import type { KernelRuntime } from '../runtime/types.d.ts'
import type { createCommandRegistry } from '../registry/commands.js'

export type AsyncPickPrompt = (question: WalkthroughQuestion) => Promise<string[]>
export type AsyncRetentionPrompt = (prompt: string, defaultDays: number) => Promise<number>
export type AsyncBackfillConsentPrompt = (args: {
providers: string[]
retentionDays: number
Expand Down Expand Up @@ -82,7 +81,6 @@ export interface WalkthroughOptions {
env: NodeJS.ProcessEnv
/** Override prompt resolver (tests pre-bake answers). */
prompt?: AsyncPickPrompt
retentionPrompt?: AsyncRetentionPrompt
}

export interface WalkthroughResult {
Expand Down Expand Up @@ -143,7 +141,6 @@ export interface RunPickerWalkthroughOptions {
*/
exportOrigin?: PickerExportOrigin
prompt?: AsyncPickPrompt
retentionPrompt?: AsyncRetentionPrompt
/**
* Override the system source detector. Defaults to a catalog-backed
* wrapper around the real filesystem-based `detectPickerSources`.
Expand Down
79 changes: 12 additions & 67 deletions src/core/cli/walkthrough.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { discoverBundledPlugins } from '../runtime/bundled.js'
import { isWithinDir } from '../runtime/contribution_names.js'
import { buildPluginCatalog } from '../plugin_catalog.js'
import { detectPickerSources } from './detect.js'
import { multiselect, select, text } from './tui/index.js'
import { multiselect, select } from './tui/index.js'
import { isPromptCancelledError } from './tui/runtime.js'
import { shouldUseTui } from './tui-router.js'
import { copyDir } from '../util/fs_copy.js'
Expand All @@ -35,7 +35,6 @@ export const WALKTHROUGH_CANCEL_EXIT_CODE = 130
* @import {
* AsyncBackfillConsentPrompt,
* AsyncPickPrompt,
* AsyncRetentionPrompt,
* BackfillFinaleResult,
* PickerBackfillRunner,
* PickerSource,
Expand All @@ -50,7 +49,14 @@ export const WALKTHROUGH_CANCEL_EXIT_CODE = 130
* } from '../../../src/core/cli/types.js'
*/

// Onboarding never asks for a retention window; these are the pathway
// defaults the wizard applies instead. A team (or managed) install keeps
// the 30-day window because the org server holds the durable copy; a
// local-only install keeps 120 days because the local cache is the only
// copy of history. `hyp init --retention-days <n>` remains the override.
// @ref LLP 0137#pathway-defaults [implements]: no retention question; 30-day team / 120-day local defaults
export const DEFAULT_RETENTION_DAYS = 30
export const LOCAL_INSTALL_RETENTION_DAYS = 120

/**
* Resolve the HYP_HOME root the same way the kernel does (matches
Expand Down Expand Up @@ -103,28 +109,6 @@ function legacyNumberedPromptFactory(opts) {
}
}

/**
* @param {Pick<WalkthroughOptions, 'stdin' | 'stdout'>} opts
* @returns {AsyncRetentionPrompt}
*/
function legacyRetentionPromptFactory(opts) {
const input = /** @type {NodeJS.ReadableStream} */ (opts.stdin ?? process.stdin)
const output = /** @type {NodeJS.WritableStream} */ (opts.stdout)
return async function (prompt, defaultDays) {
const rl = readline.createInterface({ input, output, terminal: false })
try {
const answer = await rl.question(`${prompt} [${defaultDays}]: `)
const trimmed = answer.trim()
if (!trimmed) return defaultDays
const parsed = Number.parseInt(trimmed, 10)
if (!Number.isInteger(parsed) || parsed < 0) return defaultDays
return parsed
} finally {
rl.close()
}
}
}

/**
* Build the interactive "overwrite existing config?" confirm. Defaults
* to **no** (a bare Enter keeps the existing config), so a stray
Expand Down Expand Up @@ -177,37 +161,6 @@ function tuiPromptFactory(opts) {
}
}

/**
* Prompt for the cache retention window through the TUI text input.
* Empty input falls through to the supplied default to match the legacy
* behavior.
*
* @param {Pick<WalkthroughOptions, 'stdin' | 'stdout' | 'env'>} opts
* @returns {AsyncRetentionPrompt}
*/
function tuiRetentionPromptFactory(opts) {
return async function (prompt, defaultDays) {
const v = await text({
title: prompt,
default: String(defaultDays),
validate: (s) => {
if (s.trim() === '') return null
const n = Number.parseInt(s.trim(), 10)
return Number.isInteger(n) && n >= 0 ? null : 'enter a non-negative integer'
},
clearOnResolve: true,
stdin: opts.stdin ?? process.stdin,
stdout: /** @type {NodeJS.WritableStream} */ (/** @type {unknown} */ (opts.stdout)),
env: opts.env,
})
const trimmed = v.trim()
if (trimmed === '') return defaultDays
const parsed = Number.parseInt(trimmed, 10)
if (!Number.isInteger(parsed) || parsed < 0) return defaultDays
return parsed
}
}

/**
* Route between the TUI and legacy prompts. Tests and CI keep getting
* the legacy numbered list, but only real TTYs without `HYP_NO_TUI=1` see
Expand All @@ -221,15 +174,6 @@ export function defaultPromptFactory(opts) {
return legacyNumberedPromptFactory(opts)
}

/**
* @param {Pick<WalkthroughOptions, 'stdin' | 'stdout' | 'env'>} opts
* @returns {AsyncRetentionPrompt}
*/
export function defaultRetentionPromptFactory(opts) {
if (shouldUseTui(opts)) return tuiRetentionPromptFactory(opts)
return legacyRetentionPromptFactory(opts)
}

/**
* Build the interactive backfill-consent prompt. Routes to the TUI
* arrow-navigable yes/no select on a real TTY, else a legacy readline
Expand Down Expand Up @@ -423,7 +367,6 @@ export async function runPickerWalkthrough(opts) {
exportOrigin = opts.exportOrigin ?? 'default'
} else {
const ask = opts.prompt ?? defaultPromptFactory(opts)
const retentionAsk = opts.retentionPrompt ?? defaultRetentionPromptFactory(opts)

stdout.write('Welcome to HypAware - the local logs+telemetry collector.\n\n')

Expand All @@ -450,8 +393,10 @@ export async function runPickerWalkthrough(opts) {
// editing the written config later.
const exportChoice = /** @type {PickerExport} */ ('local-parquet')

const retentionDays = await retentionAsk('Cache retention (days)', DEFAULT_RETENTION_DAYS)
picks = { sources, exportChoice, retentionDays }
// Retention is not asked either (LLP 0137): this legacy surface has
// no pathway fork, so it takes the flat default. The wizard applies
// the pathway-aware defaults; `--retention-days` overrides via picks.
picks = { sources, exportChoice, retentionDays: DEFAULT_RETENTION_DAYS }
} catch (err) {
if (isPromptCancelledError(err)) {
return await cancelledResult(opts)
Expand Down
9 changes: 7 additions & 2 deletions src/core/cli/wizard/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,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 { runPickerFinale, writeWalkthroughRunSummary } from '../walkthrough.js'
import { LOCAL_INSTALL_RETENTION_DAYS, runPickerFinale, writeWalkthroughRunSummary } from '../walkthrough.js'
import { LOGIN_ORG_SELECTION_MESSAGE } from '../remote_commands.js'
import { evaluateReturningGate, runWizardFork } from './fork.js'
import { computeCentralLockedSources, runWizardJoin } from './join.js'
Expand Down Expand Up @@ -127,7 +127,12 @@ export async function runInitWizard(opts) {
...(opts.exportOrigin ? { exportOrigin: opts.exportOrigin } : {}),
...(opts.force ? { force: opts.force } : {}),
...(opts.prompt ? { prompt: opts.prompt } : {}),
...(opts.retentionPrompt ? { retentionPrompt: opts.retentionPrompt } : {}),
// Retention is never asked; the pathway decides the default. A local
// install keeps the longer 120-day window (the cache is the only copy
// of history); team and scoped runs fall through to the pick phase's
// 30-day default (the org server holds the durable copy).
// @ref LLP 0137#pathway-defaults [implements]: 30-day team / 120-day local retention defaults
...(pathway === 'local' ? { retentionDefault: LOCAL_INSTALL_RETENTION_DAYS } : {}),
...(opts.detect ? { detect: opts.detect } : {}),
...(opts.confirmOverwrite ? { confirmOverwrite: opts.confirmOverwrite } : {}),
})
Expand Down
8 changes: 5 additions & 3 deletions src/core/cli/wizard/pick.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
defaultOverwriteConfirmFactory,
defaultPickerDetect,
defaultPromptFactory,
defaultRetentionPromptFactory,
loadPickerDescriptors,
orderPickerDescriptors,
resolveHypHome,
Expand Down Expand Up @@ -133,7 +132,6 @@ export async function runWizardPick(opts) {
exportOrigin = opts.exportOrigin ?? 'default'
} else {
const ask = opts.prompt ?? defaultPromptFactory(opts)
const retentionAsk = opts.retentionPrompt ?? defaultRetentionPromptFactory(opts)

opts.stdout.write('Welcome to HypAware - the local logs+telemetry collector.\n\n')

Expand All @@ -151,7 +149,11 @@ export async function runWizardPick(opts) {
// Parquet exports so the first run produces durable files out of the
// box. Other destinations remain available via `hyp init --export`.
exportChoice = /** @type {PickerExport} */ ('local-parquet')
retentionDays = await retentionAsk('Cache retention (days)', DEFAULT_RETENTION_DAYS)
// Retention is not asked either: the orchestrator supplies the
// pathway default (30-day team / 120-day local), overridable only
// via `hyp init --retention-days` on the non-interactive path.
// @ref LLP 0137#pathway-defaults [implements]: the retention question is removed from onboarding
retentionDays = opts.retentionDefault ?? DEFAULT_RETENTION_DAYS
} catch (err) {
if (isPromptCancelledError(err)) return cancelledResult(opts)
throw err
Expand Down
10 changes: 6 additions & 4 deletions src/core/cli/wizard/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { PickerDescriptor, PluginCatalog } from '../../types.d.ts'
import type {
AsyncBackfillConsentPrompt,
AsyncPickPrompt,
AsyncRetentionPrompt,
FinaleSummary,
PickerBackfillRunner,
PickerExport,
Expand Down Expand Up @@ -251,8 +250,12 @@ export interface RunWizardPickOptions {
exportOrigin?: PickerExportOrigin
/** Override the source prompt (tests pre-bake answers). */
prompt?: AsyncPickPrompt
/** Override the retention prompt (tests pre-bake answers). */
retentionPrompt?: AsyncRetentionPrompt
/**
* The retention window applied without asking (LLP 0137): the
* orchestrator passes the pathway default (120-day local; team and
* scoped runs omit it and take the 30-day `DEFAULT_RETENTION_DAYS`).
*/
retentionDefault?: number
/** Override the system source detector (interactive only). */
detect?: (opts: { env: NodeJS.ProcessEnv }) => Promise<Set<PickerSource>>
/** Overwrite an existing local config non-interactively (`--force`). */
Expand Down Expand Up @@ -313,7 +316,6 @@ export interface RunInitWizardOptions {
finaleRunner?: (args: Record<string, unknown>) => Promise<FinaleSummary>
/** Pick-phase prompt seams, threaded through unchanged (tests). */
prompt?: AsyncPickPrompt
retentionPrompt?: AsyncRetentionPrompt
detect?: (opts: { env: NodeJS.ProcessEnv }) => Promise<Set<PickerSource>>
confirmOverwrite?: (targetPath: string) => Promise<boolean>
backfillConsentPrompt?: AsyncBackfillConsentPrompt
Expand Down
6 changes: 6 additions & 0 deletions test/core/cli/wizard/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ test('runInitWizard: local pathway runs pick -> configure -> finale, no join', a
assert.deepEqual(calls, ['gate', 'fork', 'pick', 'configure', 'finale'])
assert.equal(result.pathway, 'local')
assert.equal(opts._pickOpts.managed, undefined)
// The local pathway supplies the 120-day retention default without a
// prompt (LLP 0137 #pathway-defaults).
assert.equal(opts._pickOpts.retentionDefault, 120)
})

test('runInitWizard: fork quit exits 0 before the pick phase', async () => {
Expand All @@ -172,6 +175,9 @@ test('runInitWizard: team pathway threads locked + managed into the pick phase',
assert.equal(result.pathway, 'team')
assert.deepEqual(opts._pickOpts.locked, ['claude'])
assert.equal(opts._pickOpts.managed, true)
// The team pathway takes the pick phase's 30-day default: no
// retentionDefault override (LLP 0137 #pathway-defaults).
assert.equal(opts._pickOpts.retentionDefault, undefined)
})

test('runInitWizard: a failed join explains and returns to the fork', async () => {
Expand Down
Loading
Loading