IUH-M03 shipped as PR #433 (732f8a49) — unified first-run flow,
session-file bridge removed, backward-compat preserved, and the
M02 hooks-consent follow-up closed end-to-end via the finalize
stage + mosaic-link-runtime-assets MOSAIC_SKIP_CLAUDE_HOOKS flag.
All 7 acceptance criteria satisfied across PRs #429, #431, #433.
Mission phase → complete, progress 3/3.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
22 KiB
Install UX Hardening — IUH-M01 Session Notes
Session: 2026-04-05 (agent-ad6b6696)
Plan
Manifest schema decision:
- Version 1 JSON at
~/.config/mosaic/.install-manifest.json(mode 0600) - Written by
tools/install.shafter successful install - Fields: version, installedAt, cliVersion, frameworkVersion, mutations{directories, npmGlobalPackages, npmrcLines, shellProfileEdits, runtimeAssetCopies}
- Uninstall reads it; if missing → heuristic mode (warn user)
File list:
- NEW:
packages/mosaic/src/runtime/install-manifest.ts— read/write helpers + types - NEW:
packages/mosaic/src/runtime/install-manifest.spec.ts— unit tests - NEW:
packages/mosaic/src/commands/uninstall.ts— command implementation - NEW:
packages/mosaic/src/commands/uninstall.spec.ts— unit tests - MOD:
packages/mosaic/src/cli.ts— registeruninstallcommand - MOD:
tools/install.sh— write manifest on success + add--uninstallpath
Runtime asset list (from mosaic-link-runtime-assets / framework/install.sh):
~/.claude/CLAUDE.md(source:$MOSAIC_HOME/runtime/claude/CLAUDE.md)~/.claude/settings.json(source:$MOSAIC_HOME/runtime/claude/settings.json)~/.claude/hooks-config.json(source:$MOSAIC_HOME/runtime/claude/hooks-config.json)~/.claude/context7-integration.md(source:$MOSAIC_HOME/runtime/claude/context7-integration.md)~/.config/opencode/AGENTS.md(source:$MOSAIC_HOME/runtime/opencode/AGENTS.md)~/.codex/instructions.md(source:$MOSAIC_HOME/runtime/codex/instructions.md)
Reversal logic:
- If
.mosaic-bak-<stamp>exists for a file → restore it - Else if managed copy exists → remove it
- Never touch files not in the known list
npmrc reversal:
- Only remove line
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ - If manifest has the line, use that as authoritative; else check heuristically
PATH reversal:
- Check install.sh: it does NOT add PATH entries to shell profiles (framework/install.sh migration removes old
$MOSAIC_HOME/binPATH entries in v0/v1→v2 migration, but new install does NOT add PATH) - ASSUMPTION: No PATH edits in current install (v0.0.24+). Shell profiles not modified by current install.
- The
$PREFIX/binis mentioned in a warning but NOT added to shell profiles by install.sh. - shellProfileEdits array will be empty for new installs; heuristic mode also skips it.
Test strategy:
- Unit test manifest read/write with temp dir mocking
- Unit test command registration
- Unit test dry-run flag (no actual fs mutations)
- Unit test --keep-data skips protected paths
- Unit test heuristic mode warning
Implementation order:
- install-manifest.ts helpers
- install-manifest.spec.ts tests
- uninstall.ts command
- uninstall.spec.ts tests
- cli.ts registration
- tools/install.sh manifest writing + --uninstall path
ASSUMPTION: No PATH modifications in current install.sh (v0.0.24). Framework v0/v1→v2 migration cleaned old PATH entries but current install does not add new ones.
ASSUMPTION: --uninstall in install.sh handles framework + cli + npmrc only; gateway teardown deferred to mosaic gateway uninstall.
ASSUMPTION: Pi settings.json edits (skills paths) added by framework/install.sh are NOT reversed in this iteration — too risky to touch user Pi config without manifest evidence. Noted as follow-up.
Session 2 — 2026-04-05 (orchestrator resume)
IUH-M01 completion summary
- PR: #429 merged as
25cada77 - CI: green (Woodpecker)
- Issue: #425 closed
- Files: +1205 lines across 4 new + 2 modified + 1 docs
- Tests: 14 new, 170 total passing
Follow-ups captured from worker report
- Pi settings.json reversal deferred — worker flagged as too risky without manifest evidence. Future IUH task should add manifest entries for Pi settings mutations. Not blocking M02/M03.
- Pre-existing
cli-smoke.spec.tsfailure —@mosaicstack/brainpackage entry resolution fails in Vitest. Unrelated to IUH-M01. Worth a separate issue later. pr-create.shwrapper bug with multiline bodies — wrapper evals body args as shell when they contain newlines/paths. Worker fell back to Gitea REST API. Same class of bug I hit earlier withissue-create.sh. Worth a tooling-team issue to fix both wrappers.
Mission doc sync
cli-unification docs that were archived before the M01 subagent ran did not travel into the M01 PR (they were local, stashed before pull). Re-applying now:
docs/archive/missions/cli-unification-20260404/(the old manifest + tasks)docs/MISSION-MANIFEST.md(new install-ux-hardening content)docs/TASKS.md(new install-ux-hardening content)
Committing as docs: scaffold install-ux-hardening mission + archive cli-unification.
Next action
Delegate IUH-M02 to a sonnet subagent in an isolated worktree.
Session 3: 2026-04-05 (agent-a6ff34a5) — IUH-M02 Wizard Remediation
Plan
AC-3: Password masking + confirmation
- New
packages/mosaic/src/prompter/masked-prompt.ts— raw-mode stdin reader that suppresses echo, handles backspace/Ctrl+C/Enter. bootstrapFirstUserinpackages/mosaic/src/commands/gateway/install.ts: replacerl.question('Admin password...')withpromptMaskedPassword(), require confirm pass, keep min-8 validation.- Headless path: when
MOSAIC_ASSUME_YES=1or!process.stdin.isTTY, readMOSAIC_ADMIN_PASSWORDenv var directly.
AC-4a: Hooks preview stage
- New
packages/mosaic/src/stages/hooks-preview.ts— readshooks-config.jsonfromstate.sourceDirorstate.mosaicHome, displays each top-level hook category with name/trigger/command preview, prompts "Install these hooks? [Y/n]", stores result instate.hooks. packages/mosaic/src/types.ts— addhooks?: { accepted: boolean; acceptedAt?: string }toWizardState.packages/mosaic/src/wizard.ts— inserthooksPreviewStagebetweenruntimeSetupStageandskillsSelectStage; skip if no claude runtime detected.
AC-4b: mosaic config hooks subcommands
- Add
hookssubcommand group topackages/mosaic/src/commands/config.ts:list: reads~/.claude/hooks-config.json, shows hook names and enabled/disabled statusdisable <name>: prefixes matching hook key with_disabled_in the JSONenable <name>: removes_disabled_prefix if present
AC-5: Headless install path
runConfigWizard: detect headless mode (MOSAIC_ASSUME_YES=1or!process.stdin.isTTY), read env vars with defaults, validate required vars, skip prompts entirely.bootstrapFirstUser: detect headless mode, readMOSAIC_ADMIN_NAME/EMAIL/PASSWORD, validate, proceed without prompts.- Document env vars in
packages/mosaic/README.md(create if absent).
File list
NEW:
packages/mosaic/src/prompter/masked-prompt.tspackages/mosaic/src/prompter/masked-prompt.spec.tspackages/mosaic/src/stages/hooks-preview.tspackages/mosaic/src/stages/hooks-preview.spec.ts
MODIFIED:
packages/mosaic/src/types.ts— extend WizardStatepackages/mosaic/src/wizard.ts— wire hooksPreviewStagepackages/mosaic/src/commands/gateway/install.ts— masked password + headless pathpackages/mosaic/src/commands/config.ts— add hooks subcommandspackages/mosaic/src/commands/config.spec.ts— extend testspackages/mosaic/README.md— document env vars
Assumptions
ASSUMPTION: hooks-config.json location is <sourceDir>/framework/runtime/claude/hooks-config.json during wizard (sourceDir is package root). Fall back to <mosaicHome>/runtime/claude/hooks-config.json for installed config.
ASSUMPTION: The hooks subcommands under config operate on ~/.claude/hooks-config.json (the installed copy), not the package source.
ASSUMPTION: For the hooks preview stage, the "name" field displayed per hook entry is the top-level event key (e.g. "PostToolUse") plus the matcher from nested hooks array. This is the most user-readable representation given the hooks-config.json structure.
ASSUMPTION: config hooks list/enable/disable use CLAUDE_HOME env or ~/.claude as the target directory for hooks files.
ASSUMPTION: The headless TTY detection (!process.stdin.isTTY) is sufficient; MOSAIC_ASSUME_YES=1 is an explicit override for cases where stdin is a TTY but the user still wants non-interactive (e.g., scripted installs with piped terminal).
Session 4 — 2026-04-05 (orchestrator resume) — IUH-M02 closed, delegating IUH-M03
IUH-M02 completion summary
- PR: #431 merged as
cd8b1f66 - CI: green (Woodpecker)
- Issue: #426 closed
- Acceptance criteria: AC-3 (password mask), AC-4 (hooks visibility — consent recorded), AC-5 (headless path) all satisfied
- New files:
prompter/masked-prompt.ts,stages/hooks-preview.ts(+ specs) - Modified:
wizard.ts,types.ts(state.hooks),commands/gateway/install.ts,commands/config.ts
Follow-up captured from M02 agent
Hooks consent is recorded but not enforced. The hooks-preview stage sets state.hooks.accepted when the user confirms, but the finalize stage still unconditionally runs mosaic-link-runtime-assets, which copies hooks-config.json into ~/.claude/ regardless of consent. This is a soft gap — the user sees the prompt and can decline, but declining currently has no effect downstream.
Options for addressing:
- Fold into IUH-M03 (since M03 touches the finalize/install convergence path anyway)
- Spin a separate small follow-up issue after M03 lands
Leaning toward folding into M03 — the unified first-run flow naturally reworks the finalize→gateway handoff where this gating belongs.
IUH-M03 delegation
Now delegating to an opus subagent in an isolated worktree. Scope from /tmp/iuh-m03-body.md:
- Extract
runConfigWizard→stages/gateway-config.ts - Extract
bootstrapFirstUser→stages/gateway-bootstrap.ts runWizardinvokes gateway stages as final stages- Drop the 10-minute
$XDG_RUNTIME_DIR/mosaic-install-state.jsonsession bridge mosaic gateway installbecomes a thin standalone wrapper for backward-compattools/install.shsingle auto-launch entry point- Bonus if scoped: honor
state.hooks.acceptedin finalize stage so declining hooks actually skips hook install
Known tooling caveats to pass to worker:
issue-create.sh/pr-create.shwrappers eval multiline bodies as shell — use Gitea REST API fallback withload_credentials gitea-mosaicstack- Protected
main: PR-only, squash merge - Must run
ci-queue-wait.sh --purpose push|mergebefore push/merge
Session 5: 2026-04-05 (agent-a7875fbd) — IUH-M03 Unified First-Run
Problem recap
mosaic wizard and mosaic gateway install currently run as two separate phases bridged by a fragile 10-minute session file at $XDG_RUNTIME_DIR/mosaic-install-state.json. tools/install.sh auto-launches both sequentially so the user perceives two wizards stitched together; state is not shared, prompts are duplicated, and if the user walks away the bridge expires.
Design decision — Option A: gateway install becomes terminal stages of runWizard
Two options on the table:
- (A) Extract
runConfigWizardandbootstrapFirstUserintostages/gateway-config.tsandstages/gateway-bootstrap.ts, append them torunWizardas final stages, and makemosaic gateway installa thin wrapper that runs the same stages with an ephemeral state seeded from existing config. - (B) Introduce a new top-level orchestrator that composes the wizard and gateway install as siblings.
Chosen: Option A. Rationale:
- The wizard already owns a
WizardStatethat threads state across stages — gateway config/bootstrap fit naturally as additional stages without a new orchestration layer. mosaic gateway installas standalone entry point stays idempotent by seeding a minimalWizardStateand running only the gateway stages, reusing the same functions.- Avoids a parallel state object and keeps the call graph linear; easier to test and to reason about the "one cohesive flow" UX goal.
- Option B would leave
runWizardand the gateway install as siblings that still need to share a state object — equivalent complexity without the narrative simplification.
Scope
- Extend
WizardStatewith optionalgatewayslice:{ tier, port, databaseUrl?, valkeyUrl?, anthropicKey?, corsOrigin, admin?: { name, email, password } }. The admin password is held in memory only — never persisted to disk as part of the state object. - New
packages/mosaic/src/stages/gateway-config.ts— pure stage that:- Reads existing
.env/mosaic.config.jsonif present (resume path) and sets state. - Otherwise prompts via
WizardPrompter(interactive) or reads env vars (headless). - Writes
.envandmosaic.config.json, starts the daemon, waits for health.
- Reads existing
- New
packages/mosaic/src/stages/gateway-bootstrap.ts— pure stage that:- Checks
/api/bootstrap/status. - If needsSetup, prompts for admin name/email/password (uses
promptMaskedConfirmed) or reads env vars (headless); calls/api/bootstrap/setup; persists token in meta. - If already setup, handles inline token recovery exactly as today.
- Checks
packages/mosaic/src/wizard.ts— append gateway-config and gateway-bootstrap as stages 11 and 12. RemovewriteInstallStateand theINSTALL_STATE_FILEconstant entirely.packages/mosaic/src/commands/gateway/install.ts— becomes a thin wrapper that builds a minimalWizardStatewith aClackPrompter, then callsrunGatewayConfigStage(...)andrunGatewayBootstrapStage(...)directly. Remove the session-file readers/writers. Headless detection is delegated to the stage itself. The wrapper still exposes therunInstall({host, port, skipInstall})API sogateway.tscommand registration is unchanged.tools/install.sh— drop the secondmosaic gateway installcall;mosaic wizardnow covers end-to-end. Leavegateway installguidance for non-auto-launch path so users still know the standalone entry point exists.- Hooks gating (bonus — folded in):
finalize.tsalready runsmosaic-link-runtime-assets. Whenstate.hooks?.accepted === false, setMOSAIC_SKIP_CLAUDE_HOOKS=1in the env for the subprocess; teach the script to skip copyinghooks-config.jsonwhen that env var is set. Other runtime assets (CLAUDE.md, settings.json, context7) still get linked.
Files
NEW:
packages/mosaic/src/stages/gateway-config.ts(+.spec.ts)packages/mosaic/src/stages/gateway-bootstrap.ts(+.spec.ts)
MODIFIED:
packages/mosaic/src/types.ts— extend WizardState withgateway?:slicepackages/mosaic/src/wizard.ts— append gateway stages, remove session-file bridgepackages/mosaic/src/commands/gateway/install.ts— thin wrapper over stages, remove 10-min bridgepackages/mosaic/src/stages/finalize.ts— honorstate.hooks.accepted === falseby settingMOSAIC_SKIP_CLAUDE_HOOKS=1packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets— honorMOSAIC_SKIP_CLAUDE_HOOKS=1tools/install.sh— single unified auto-launch
Assumptions
ASSUMPTION: Gateway stages must run after finalizeStage because finalize writes identity files and links runtime assets that the gateway admin UX may later display — reversed ordering would leave Claude runtime linkage incomplete when the admin token banner prints.
ASSUMPTION: Standalone mosaic gateway install uses a ClackPrompter (interactive) by default; the headless path is still triggered by MOSAIC_ASSUME_YES=1 or non-TTY stdin, and the stage functions detect this internally.
ASSUMPTION: When runWizard reaches the gateway stages, state.mosaicHome is authoritative for GATEWAY_HOME resolution if it differs from the default — we set process.env.MOSAIC_GATEWAY_HOME before importing gateway modules so the constants resolve correctly.
ASSUMPTION: Keeping backwards compatibility for runInstall({host, port, skipInstall}) is enough — no other internal caller exists.
ASSUMPTION: Removing the session file is safe because the old bridge is at most a 10-minute window; there is no on-disk migration to do.
Test plan
gateway-config.spec.ts: fresh install writes .env + mosaic.config.json (mock fs + prompter); resume path reuses existing BETTER_AUTH_SECRET; headless path respects MOSAIC_STORAGE_TIER/MOSAIC_GATEWAY_PORT/etc.gateway-bootstrap.spec.ts: calls/api/bootstrap/setupwith collected creds (mock fetch); handles "already setup" branch; honors headless env vars; persists token viawriteMeta.- Extend existing passing tests — no regressions in
login.spec,recover-token.spec,rotate-token.spec. - Unified flow integration is covered at the stage-level; no new e2e test infra required.
Delivery cycle
plan (this entry) → code → typecheck/lint/format → test → codex review (~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted) → remediate → commit → ci-queue-wait push → push → PR → CI green → merge → close #427.
Remediation log (codex review rounds)
- Round 1 — hooks opt-out did not remove an existing managed file; port override ignored on resume; headless errors swallowed. Fixed: hooks cleanup,
portOverridehonored, errors re-thrown. - Round 2 — headless stage failures exited 0; port override on decline-rerun mismatched; no default-path integration test. Fixed:
process.exit(1)in headless, revert portOverride on decline, addunified-wizard.test.ts. - Round 3 — hooks removal too broad (would touch user-owned files); port override written to meta but not .env (drift); wizard swallowed errors. Fixed:
cmp -smanaged-file check, force regeneration when portOverride differs from saved port, re-throw unexpected errors. - Round 4 — port-override regeneration tripped the corrupt-partial-state guard (blocker); headless already-bootstrapped-with-no-local-token path reported failure instead of no-op; hooks byte-equality fragile across template updates. Fixed: introduce
forcePortRegenflag bypassing the guard (with a dedicated spec test), headless rerun of already-bootstrapped gateway now returns{ completed: true }(with spec coverage), hooks cleanup now checks for a stable"mosaic-managed": truemarker embedded in the template (byte-equality remains as a fallback for legacy installs). - Round 5 codex review attempted but blocked by upstream usage limit (quota). Rerun after quota refresh if further findings appear; all round-4 findings are code-covered.
Session 6 — 2026-04-05 (orchestrator close-out) — MISSION COMPLETE
IUH-M03 completion summary (reported by opus delivery agent)
- PR: #433 merged as
732f8a49 - CI: Woodpecker green on final rebased commit
f3d5ef8d - Issue: #427 closed with summary comment
- Tests: 219 passing (+15 net new), 24 files
- Codex review: 4 rounds applied and remediated; round 5 blocked by upstream quota — no known outstanding findings
What shipped in M03
- NEW stages:
stages/gateway-config.ts,stages/gateway-bootstrap.ts(extracted from the old monolithicgateway/install.ts) - NEW integration test:
__tests__/integration/unified-wizard.test.ts runWizardnow has 12 stages — gateway config + bootstrap are terminal stages 11 & 12- 10-minute
$XDG_RUNTIME_DIR/mosaic-install-state.jsonsession-file bridge deleted mosaic gateway installrewritten as a thin standalone wrapper invoking the same two stages — backward-compat preservedWizardState.gateway?slice carries host/port/tier/admin/adminTokenIssued across stagestools/install.shsingle unifiedmosaic wizardcall — no more two-phase launch- Bonus scoped in: finalize stage honors
state.hooks.accepted === falseviaMOSAIC_SKIP_CLAUDE_HOOKS=1;mosaic-link-runtime-assetshonors the flag; Mosaic-managed detection now uses a stable"mosaic-managed": truemarker inhooks-config.jsonwith byte-equality fallback for legacy installs. Closes the M02 follow-up.
Mission status — ALL DONE
| AC | Status | PR |
|---|---|---|
| AC-1 | ✓ | #429 |
| AC-2 | ✓ | #429 |
| AC-3 | ✓ | #431 |
| AC-4 | ✓ | #431 + #433 (gating) |
| AC-5 | ✓ | #431 |
| AC-6 | ✓ | #433 |
| AC-7 | ✓ | #429, #431, #433 all merged, CI green, issues closed |
Follow-ups for future work (not blocking mission close)
pr-ci-wait.shvs Woodpecker: wrapper reportsstate=unknownbecause Woodpecker doesn't publish to Gitea's combined-status endpoint. Worker usedtea prCI glyphs as authoritative. Pre-existing tooling gap — worth a separate tooling-team issue.issue-create.sh/pr-create.shwrapperevalbug with multiline bodies — hit by M01, M02, M03 workers. All fell back to Gitea REST API. Needs wrapper fix.- Codex review round 5 — attempted but blocked by upstream quota. Rerun after quota resets to confirm nothing else surfaces.
- Pi settings.json reversal — deferred from M01; install manifest schema should be extended to track Pi settings mutations for reversal.
cli-smoke.spec.tspre-existing failure —@mosaicstack/brainresolution in Vitest. Unrelated. Worth a separate issue.
Next steps (orchestrator)
- This scratchpad + MISSION-MANIFEST.md + TASKS.md updates → final docs PR
- After merge: create release tag per framework rule (milestone/mission completion = release tag + repository release)
- Archive mission docs under
docs/archive/missions/install-ux-hardening-20260405/once the tag is published