diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d798e8..6e95c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,18 @@ Closes [#121](https://github.com/breferrari/shardmind/issues/121). The guard tha - **Tests**: `tests/unit/manifest.test.ts` (range parse + reject incl. empty/whitespace → `MANIFEST_VALIDATION_FAILED` and deliberate `*`/`x` match-all; `assertEngineCompatible` matrix — satisfied / lower-bound / too-old / absent / unknown-version skip / prerelease-ahead / match-all) and Layer 1 flow tests refusing `>=99.0.0` before any write across all three commands: install (scenario 12, no `state.json`), update (scenario 18, `state.json` byte-unchanged), adopt (scenario 24, no `.shardmind/`). **Docs**: `docs/AUTHORING.md` (field reference + §6 note), `docs/ERRORS.md` (`SHARDMIND_VERSION_MISMATCH`), `schemas/shard.schema.json` (`requires.shardmind`). +### Changed (adopt values confirm-or-override — #104) + +Closes [#104](https://github.com/breferrari/shardmind/issues/104). Adopt is for users who already have a populated vault, but it reused the full step-by-step install wizard to collect the values classification needs (the planner renders the shard's `.njk` against them before hashing). Being interrogated value-by-value reads as a fresh-install flow shoehorned onto a retrofit. + +- **`shardmind adopt` opens on a values confirm page** (`source/components/AdoptValuesGate.tsx`) instead of `InstallWizard`. It surfaces the exact values that will drive classification — resolved defaults, computed defaults, and any `--values` prefill, each labelled with its provenance (`default` / `computed` / `from --values`) — plus the module default, with **Use these values / Override individually / Cancel**. No more hidden defaults: the values are shown before classification runs. + +- **"Override individually" reuses `InstallWizard` verbatim** (per-value + module editing) rather than forking its value-iteration / computed-preview / module-review logic. A required value with no default and no prefill opens straight into the wizard (no confident set to confirm) — the predicate feeds `missingValueKeys` the *merged* map, exactly as the `--yes` path does, so the common all-defaults shard lands on the confirm page. + +- **`--yes` / `--values` unchanged.** Those non-interactive paths resolve values in the machine and never reach the gate, so adopt's scripted contract is byte-identical. + +- **Tests**: `tests/component/AdoptValuesGate.test.tsx` (provenance labels, Use → `onComplete` with resolved values + all-default selections, Override → wizard, Cancel, computed-resolve + computed-throw → `onError`, required-no-default → override, zero-values shard); Layer 1 adopt flow scenarios 19/20/21 re-driven through the confirm gate + new 25 (Override → wizard → adopt) and 26 (values surfaced before classification); Layer 2 PTY adopt scenario 20 drives the gate. **Docs**: `docs/ARCHITECTURE.md §10.5`, `docs/IMPLEMENTATION.md §3.5`, `CLAUDE.md` component tree. + ### Added (multiselect value type — #101) Closes [#101](https://github.com/breferrari/shardmind/issues/101). Promotes `multiselect` from a partially-wired type (engine validation existed; the wizard rendered a comma-separated text fallback flagged in-code as v0.2 polish) to a first-class value type. Lets a shard author ask one checkbox question ("Which agents do you use?") instead of N booleans. Scope is the value type + widget; gating *which modules install* on a multiselect value remains [#80](https://github.com/breferrari/shardmind/issues/80) (v0.2). diff --git a/CLAUDE.md b/CLAUDE.md index dc7266d..42fb2cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,6 +145,7 @@ shardmind/ │ │ ├── CollisionReview.tsx # Install: backup / overwrite / cancel │ │ ├── ExistingInstallGate.tsx # Install: existing-install disambiguation │ │ ├── DiffView.tsx # Three-way diff + conflict resolution +│ │ ├── AdoptValuesGate.tsx # Adopt: values confirm-or-override page (#104) │ │ ├── AdoptDiffView.tsx # Adopt: 2-way diff (no merge base) + per-file prompt │ │ ├── AdoptSummary.tsx # Final adopt report (matched / mine / shard / fresh) │ │ ├── NewValuesPrompt.tsx # Update: prompt for newly required values diff --git a/ROADMAP.md b/ROADMAP.md index 4ad0a2f..5547f1b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,7 +126,7 @@ UX gaps surfaced during real obsidian-mind v6 install + adopt runs. None block t - [x] Wizard scroll indicator + boolean prompt consistency (Y/n typed input → selectable Yes/No) ([#100](https://github.com/breferrari/shardmind/issues/100)) — smallest item, recommended starting point. - [x] `multiselect` value type for module-set questions ([#101](https://github.com/breferrari/shardmind/issues/101)) — pairs with #100 (shortens module list). First-class value type + scrollable widget + per-option `default` + min/max; value→module gating stays #80. -- [ ] Adopt: replace step-by-step install wizard with confirm-or-override values flow ([#104](https://github.com/breferrari/shardmind/issues/104)) +- [x] Adopt: replace step-by-step install wizard with confirm-or-override values flow ([#104](https://github.com/breferrari/shardmind/issues/104)) - [ ] Adopt batch operations (keep all mine / use all theirs / auto-merge non-conflicting) ([#120](https://github.com/breferrari/shardmind/issues/120)) — pairs with #104. - [ ] Hook stderr presentation in Summary (truncate, dim, label as non-fatal) ([#105](https://github.com/breferrari/shardmind/issues/105)) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b959116..1b419d5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -872,7 +872,7 @@ Two pre-flight guards run before the network resolve, so a deterministically-wro Phase ordering (logical; UI may interleave loading messages — see IMPLEMENTATION §3.5 for the data-flow diagram): 1. **Fetch** — resolve + download into a temp directory. -2. **Wizard** — collect values + module selections via the same `InstallWizard` component install uses. Wizard runs **before** classification because `.njk` templates need values to render before their output bytes can be hashed. +2. **Values gate** (`source/components/AdoptValuesGate.tsx`, #104) — adopt's user already has a populated vault, so it opens on a single confirm page that surfaces the values that will drive classification (resolved defaults, computed defaults, `--values` prefill, each labelled with its provenance) plus the module default, with **Use these values / Override individually / Cancel**. "Override individually" drops into the same `InstallWizard` install uses (per-value + module editing). Required values with no default and no prefill open straight into the wizard (no confident set to confirm). This step runs **before** classification because `.njk` templates need values to render before their output bytes can be hashed. 3. **Classify** (`source/core/adopt-planner.ts::classifyAdoption`) — for every shard output, render or read, hash, stat the user's vault, and assign one of four buckets: - **matches** — byte-identical post-render → managed silently. - **differs** — bytes differ → 2-way diff prompt (`AdoptDiffView`); user picks `keep_mine` (record user's hash, ownership=`modified`) or `use_shard` (overwrite with shard bytes, ownership=`managed`). @@ -883,16 +883,16 @@ Phase ordering (logical; UI may interleave loading messages — see IMPLEMENTATI 6. **Re-hash** — recompute managed-file hashes per `state.ts::rehashManagedFiles` so any hook edits to managed paths land in the recorded state. Flags: -- `--yes` — skip wizard + auto-pick `keep_mine` on every `differs`. Preserves the user's bytes on every divergence; safe default for retroactive adoption. -- `--values ` — prefill wizard answers (same shape as `install --values`). +- `--yes` — skip the values gate + auto-pick `keep_mine` on every `differs`. Preserves the user's bytes on every divergence; safe default for retroactive adoption. +- `--values ` — prefill value answers (same shape as `install --values`); shown on the gate's confirm page as `(from --values)`. - `--verbose` — show per-file action history during the apply phase. -- `--dry-run` — run the full pipeline (fetch, wizard, classify) without touching the vault. Summary reports what *would* happen. +- `--dry-run` — run the full pipeline (fetch, values gate, classify) without touching the vault. Summary reports what *would* happen. Volatile templates (`{# shardmind: volatile #}`) skip the differs prompt entirely — their rendered output is expected to vary across renders, so a content prompt would be meaningless. User's bytes are accepted as-is; missing-on-disk falls through to shard-only. Excluded modules' files in the user's vault are not classified — adopt mirrors install's "module excluded → file not installed" rule, so user content at those paths stays user-content without any prompt. -Implementation modules: `source/core/adopt-planner.ts` (IMPLEMENTATION §4.17), `source/core/adopt-executor.ts` (§4.18). Orchestration lives in `source/commands/hooks/use-adopt-machine.ts`; UI components are `source/components/AdoptDiffView.tsx` + `source/components/AdoptSummary.tsx`. +Implementation modules: `source/core/adopt-planner.ts` (IMPLEMENTATION §4.17), `source/core/adopt-executor.ts` (§4.18). Orchestration lives in `source/commands/hooks/use-adopt-machine.ts`; UI components are `source/components/AdoptValuesGate.tsx` (values confirm-or-override), `source/components/AdoptDiffView.tsx` + `source/components/AdoptSummary.tsx`. ### 10.6 Install Location diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index d98ac41..d151d1f 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -158,7 +158,7 @@ graph TD B["registry + download
Resolve + extract to temp"] --> C C["manifest + schema
Parse new shard.yaml, shard-schema.yaml"] --> D - D["InstallWizard — Ink TUI
① Value prompts from schema.groups
② Module review
③ Confirm
(runs BEFORE classification — .njk needs values to render)"] --> E + D["AdoptValuesGate — Ink TUI (#104)
Confirm page: values + provenance + module default
Use these values / Override individually / Cancel
Override → InstallWizard (value + module editing)
(runs BEFORE classification — .njk needs values to render)"] --> E E["adopt-planner.ts::classifyAdoption
resolveModules walks shard, render or read each output
per-output sha256 vs sha256(user vault path)
→ matches | differs | shard-only buckets"] --> F1 diff --git a/docs/SHARD-LAYOUT.md b/docs/SHARD-LAYOUT.md index 0b426b0..e3fcb0b 100644 --- a/docs/SHARD-LAYOUT.md +++ b/docs/SHARD-LAYOUT.md @@ -229,7 +229,7 @@ Phases (logical order; UI may interleave loading messages): Future `shardmind update` calls work normally — merge base is the adopt-time cache. -Reuses: drift detection (`core/drift.ts`), install-executor, values wizard, hook runtime. New surfaces: 2-way diff UI component (`AdoptDiffView`), adopt-planner, adopt-executor. +Reuses: drift detection (`core/drift.ts`), install-executor, value collection (`AdoptValuesGate` confirm page → `InstallWizard` on override, #104), hook runtime. New surfaces: 2-way diff UI component (`AdoptDiffView`), adopt-planner, adopt-executor. ## Naming decisions diff --git a/source/commands/adopt.tsx b/source/commands/adopt.tsx index 4c75bd9..44db3fb 100644 --- a/source/commands/adopt.tsx +++ b/source/commands/adopt.tsx @@ -4,7 +4,7 @@ import zod from 'zod'; import { ShardMindError, assertNever } from '../runtime/types.js'; import { Spinner, StatusMessage, Alert } from '../components/ui.js'; -import InstallWizard from '../components/InstallWizard.js'; +import AdoptValuesGate from '../components/AdoptValuesGate.js'; import AdoptDiffView from '../components/AdoptDiffView.js'; import AdoptSummary from '../components/AdoptSummary.js'; import CommandFrame from '../components/CommandFrame.js'; @@ -83,21 +83,16 @@ export default function Adopt({ args, options }: Props) { case 'wizard': return ( - [id, 0]), - )} - alwaysIncludedFileCount={0} onComplete={onWizardComplete} onCancel={onWizardCancel} onError={onWizardError} diff --git a/source/commands/hooks/use-adopt-machine.ts b/source/commands/hooks/use-adopt-machine.ts index 7b87d1f..2a608b8 100644 --- a/source/commands/hooks/use-adopt-machine.ts +++ b/source/commands/hooks/use-adopt-machine.ts @@ -11,6 +11,13 @@ * diff-review (loop over `differs`) → executing → * running-hook → summary * + * The `wizard` phase renders `AdoptValuesGate` (not `InstallWizard` + * directly): adopt opens on a values confirm-or-override page (#104) since + * the user already has a populated vault. The phase still means "collect + * values interactively", and the `onWizard*` callbacks are unchanged — the + * gate's "Override individually" path renders `InstallWizard` and returns + * the same `WizardResult`. + * * Reuses `useSigintRollback` and `appendHookOutput` from `shared.ts`, and * the hook orchestrator from `core`, so install / update / adopt can't * drift on any of those concerns. diff --git a/source/components/AdoptValuesGate.tsx b/source/components/AdoptValuesGate.tsx new file mode 100644 index 0000000..61230b4 --- /dev/null +++ b/source/components/AdoptValuesGate.tsx @@ -0,0 +1,214 @@ +import { useState, useMemo, useEffect, useRef } from 'react'; +import { Box, Text } from 'ink'; +import { Select } from './ui.js'; +import type { ShardManifest, ShardSchema } from '../runtime/types.js'; +import Header from './Header.js'; +import InstallWizard, { type WizardResult, formatValue } from './InstallWizard.js'; +import { + mergePrefill, + resolveComputedDefaults, + defaultModuleSelections, +} from '../core/install-planner.js'; +import { isComputedDefault } from '../core/schema.js'; + +/** + * Adopt's value-collection gate (#104). + * + * Install interrogates a clean target step-by-step; adopt's user already + * *has* a populated vault, so opening with the full `InstallWizard` reads + * as a fresh-install flow shoehorned onto a retrofit. Instead, adopt opens + * on a single page that surfaces the exact values that will drive + * classification (`adopt-planner.ts::classifyAdoption` renders the shard's + * `.njk` against them) — resolved defaults, computed defaults, and any + * `--values` prefill, each labelled with its provenance — and lets the user + * accept them in one keystroke or drop into the wizard to edit. + * + * "Override individually" reuses `InstallWizard` verbatim rather than + * forking the value-iteration / computed-preview / module-review logic. + * + * The `--yes` / `--values` non-interactive paths never reach this component + * (the machine resolves values directly), so they are unchanged. + */ +interface AdoptValuesGateProps { + manifest: ShardManifest; + schema: ShardSchema; + /** Raw `--values` prefill (pre-merge), used for provenance + override seed. */ + prefillValues: Record; + onComplete: (result: WizardResult) => void; + onCancel: () => void; + onError: (err: Error) => void; +} + +type Provenance = 'from --values' | 'computed' | 'default'; + +export default function AdoptValuesGate({ + manifest, + schema, + prefillValues, + onComplete, + onCancel, + onError, +}: AdoptValuesGateProps) { + const merged = useMemo( + () => mergePrefill(schema, prefillValues), + [schema, prefillValues], + ); + + // A value blocks the confirm page only if it is **required**, has no + // default (literal or computed), and wasn't supplied via `--values` — + // there is no value to confirm, so fall straight through to the per-value + // wizard. Optional unset values do NOT block: "Use these values" simply + // leaves them unset, which the validator accepts. (This is deliberately + // not `missingValueKeys`, which — by design, for the install wizard's + // prompt list — also returns optional unset keys and would wrongly force + // override for a shard with any optional no-default value.) + const hasBlockingValue = useMemo( + () => + Object.entries(schema.values).some( + ([key, def]) => + def.required === true && + def.default === undefined && + !Object.prototype.hasOwnProperty.call(prefillValues, key), + ), + [schema, prefillValues], + ); + + const [mode, setMode] = useState<'confirm' | 'override'>( + hasBlockingValue ? 'override' : 'confirm', + ); + + // Resolve computed defaults *synchronously* so the confirm page — and the + // "Use these values" action that reads `resolved` — never observe a + // pre-resolution frame. An effect-based setState would leave a window + // where a fast ENTER submits unresolved values (computed keys still + // absent), and the re-render would re-fire the live Select. Skipped in + // override mode: the wizard resolves computed defaults itself after + // collecting values. Errors are surfaced from an effect below, not here, + // to keep the onError call (a parent setState) off the render path. + const { values: resolved, error: resolveError } = useMemo( + (): { values: Record; error: Error | null } => { + if (hasBlockingValue) return { values: merged, error: null }; + try { + return { values: resolveComputedDefaults(schema, merged), error: null }; + } catch (err) { + return { values: merged, error: err as Error }; + } + }, + [schema, merged, hasBlockingValue], + ); + + useEffect(() => { + if (resolveError) onError(resolveError); + }, [resolveError, onError]); + + // `Select` can fire onChange more than once if Ink re-focuses the + // instance (see CollisionReview / DiffView). Every branch is a one-shot + // decision, so guard all of them against a double-fire that would advance + // the adopt machine twice. + const firedRef = useRef(false); + + if (mode === 'override') { + return ( + [id, 0]), + )} + alwaysIncludedFileCount={0} + onComplete={onComplete} + onCancel={onCancel} + onError={onError} + /> + ); + } + + // resolveComputedDefaults threw. The error effect above is calling + // onError, which moves the adopt machine to its error phase and unmounts + // this gate. Render a non-interactive placeholder for that brief window + // so the user can't select "Use these values" and submit the unresolved + // value set before the transition lands. + if (resolveError) { + return ( + +
+ Preparing values… + + ); + } + + const moduleCount = Object.keys(schema.modules).length; + const valueKeys = Object.keys(schema.values); + + return ( + +
+ + These values will be used to compare the shard against your vault: + + + {valueKeys.length === 0 ? ( + (this shard declares no values) + ) : ( + valueKeys.map((key) => { + const provenance = provenanceOf(key, schema, prefillValues); + return ( + + {key}: + {formatValue(resolved[key])} + {/* No label when the value has no default and no prefill — + it renders as (unset); "(default)" there would be a lie. */} + {provenance && ({provenance})} + + ); + }) + )} + + + + Modules: all {moduleCount} included (override to customize) + + +