// R22/D-W1: pure wizard step machine. Imports ONLY ../config/schema and ../config/types — the // dependency arrow stays one-directional (fallback-command → config-wizard → config/schema, no // cycle) because every grammar-owned message template is injected through WizardDeps. No I/O, no // clock, no fs: the store remains the only code that writes files, and the wizard never sees a path. import { candidateSetViolation, emptySetViolation, parseModelKey } from "../config/schema"; import type { CandidateSet, CandidateSetInput, WriteResult } from "../config/types"; export interface WizardUiPort { notify(message: string): void; confirm(message: string): Promise; select(title: string, options: readonly string[]): Promise; input(title: string, placeholder?: string): Promise; } /** Structural slice of the command's config port — the ONE write path (D-W5). */ export interface WizardConfigPort { set(primary: string, candidate: CandidateSetInput, options?: { global?: boolean }): Promise; remove(primary?: string, options?: { global?: boolean }): Promise; } export interface WizardDeps { ui: WizardUiPort; registryKeys(): Promise; // the command's coerced+deduped registry listing projectOverridden(primary: string): boolean; // same predicate as formatAssignments' marker globalSet(primary: string): CandidateSet | undefined; // GLOBAL-source entry (prefill basis, D-W6) overview: string; // formatAssignments(snapshot, sources) output renderSet(set: CandidateSet): string; // renderCandidateSet unregisteredWarning(key: string): string; // the grammar's exact pre-configuration template config: WizardConfigPort; // the ONE write path } export type WizardOutcome = | { outcome: "saved"; primary: string; result: WriteResult } | { outcome: "removed"; primary: string; result: WriteResult } | { outcome: "cancelled" }; /** D-W6: a plain mutable local; nothing is persisted until the single confirmed global write. */ interface Draft { primary: string; substitutes: string[]; assistant?: string; } const CANCEL = "cancel"; const TYPE_A_KEY = "type a model key…"; const EDIT_CHOICE = "edit a candidate set…"; const REMOVE_ASSIGNMENT_CHOICE = "remove a global assignment…"; const KEY_PROMPT = "Model key (provider/modelId):"; const cancelled = (): WizardOutcome => ({ outcome: "cancelled" }); const asCandidateSet = (draft: Draft): CandidateSet => draft.assistant !== undefined ? { substitutes: [...draft.substitutes], assistant: draft.assistant } : { substitutes: [...draft.substitutes] }; /** One typed-model-key prompt with the shared parseModelKey loop: a malformed key re-prompts the same * field with the specific message (scenario 4); an undefined result is a cancel, never a default. */ async function typedModelKey(deps: WizardDeps): Promise { for (;;) { const typed = await deps.ui.input(KEY_PROMPT); if (typed === undefined) return undefined; if (parseModelKey(typed) !== undefined) return typed; deps.ui.notify("invalid model key"); } } /** The P picker (D-W9): registry keys under the command's own coercion plus the typed-key alternative * and an explicit cancel. An empty registry still offers the typed option (scenario 5), so the menu * is never empty. */ async function pickPrimary(deps: WizardDeps, title: string): Promise { const keys = await deps.registryKeys(); const choice = await deps.ui.select(title, [...keys, TYPE_A_KEY, CANCEL]); if (choice === undefined || choice === CANCEL) return undefined; return choice === TYPE_A_KEY ? typedModelKey(deps) : choice; } export async function runConfigWizard(deps: WizardDeps): Promise { // O — the effective sets, verbatim (AC-W3): the overview IS the command's formatAssignments output, // markers included; merely viewing it writes nothing. deps.ui.notify(deps.overview); const opening = await deps.ui.select("Configure fallbacks:", [EDIT_CHOICE, REMOVE_ASSIGNMENT_CHOICE, CANCEL]); if (opening === EDIT_CHOICE) return editFlow(deps); if (opening === REMOVE_ASSIGNMENT_CHOICE) return removalFlow(deps); return cancelled(); // explicit cancel / undefined aborts uniformly } /** Q1/D-W6 warn-and-allow: an overridden primary's global write is allowed but visibly shadowed — * the exact warning first, then a gating confirm; false/timeout cancels. The overview already carried * the (project override) marker (AC-W3). */ async function overrideGate(deps: WizardDeps, primary: string): Promise { if (!deps.projectOverridden(primary)) return true; deps.ui.notify(`warning: ${primary} is project-overridden; this edit writes the global assignment, which the project override shadows`); return deps.ui.confirm("Continue with the global edit anyway?"); } /** P → T. Prefill basis is the GLOBAL source entry (D-W6), never the merged effective set: the wizard * edits the global assignment and must not copy project-authored content into the global file. */ async function editFlow(deps: WizardDeps): Promise { const picked = await pickPrimary(deps, "Primary model:"); if (picked === undefined) return cancelled(); if (!(await overrideGate(deps, picked))) return cancelled(); const global = deps.globalSet(picked); const draft: Draft = { primary: picked, substitutes: [...(global?.substitutes ?? [])] }; if (global?.assistant !== undefined) draft.assistant = global.assistant; return tierLoop(deps, draft); } /** T — the tier menu loop hub (D-W9). Every sub-step obeys the uniform cancellation rule: an * undefined result or explicit cancel aborts the whole wizard with no write, and an undefined result * is never interpreted as accepting a default. The draft is a plain mutable local — nothing is * persisted until the single confirmed global write. */ async function tierLoop(deps: WizardDeps, draft: Draft): Promise { for (;;) { const choice = await deps.ui.select(`Editing ${draft.primary}:`, ["add substitute…", "remove a substitute…", "reorder substitutes…", "set assistant…", "preview and save…", CANCEL]); if (choice === undefined || choice === CANCEL) return cancelled(); if (choice === "add substitute…") { if (!await addSubstitute(deps, draft)) return cancelled(); continue; } if (choice === "remove a substitute…") { if (!await removeSubstitute(deps, draft)) return cancelled(); continue; } if (choice === "reorder substitutes…") { if (!await reorderSubstitutes(deps, draft)) return cancelled(); continue; } if (choice === "set assistant…") { if (!await assistantStep(deps, draft)) return cancelled(); continue; } if (choice === "preview and save…") { // Q5/D-W7: the empty-set rule is enforced HERE, at the wizard step, through the shared export — // not deferred to the store. Assistant-only sets pass (they honour one model). const empty = emptySetViolation(asCandidateSet(draft)); if (empty !== undefined) { deps.ui.notify(empty); continue; } // S — preview, confirm, one atomic global write (D-W9). The store's rejection re-enters the tier // menu with the draft intact; confirm-false/timeout cancels with zero writes. deps.ui.notify(`${draft.primary}: ${deps.renderSet(asCandidateSet(draft))}`); if (!(await deps.ui.confirm("Save this global candidate set?"))) return cancelled(); const result = await deps.config.set(draft.primary, asCandidateSet(draft), { global: true }); if (!result.ok) { deps.ui.notify(result.error ?? "fallback configuration change was rejected"); continue; } return { outcome: "saved", primary: draft.primary, result }; } } } /** T-add (D-W9): append from the registry or a typed key. A typed key that parses but is registry- * absent surfaces the grammar's exact pre-configuration warning and is retained (scenario 6). The * shared candidateSetViolation gate re-prompts the add step with the specific message and leaves the * draft unchanged on rejection (violation half of scenario 12, R13). */ async function addSubstitute(deps: WizardDeps, draft: Draft): Promise { for (;;) { const keys = await deps.registryKeys(); const offered = keys.filter((key) => key !== draft.primary && !draft.substitutes.includes(key) && key !== draft.assistant); const choice = await deps.ui.select(`Add a substitute to ${draft.primary}:`, [...offered, TYPE_A_KEY, CANCEL]); if (choice === undefined || choice === CANCEL) return false; const key = choice === TYPE_A_KEY ? await typedModelKey(deps) : choice; if (key === undefined) return false; const violation = candidateSetViolation(draft.primary, { substitutes: [...draft.substitutes, key], ...(draft.assistant !== undefined ? { assistant: draft.assistant } : {}) }); if (violation !== undefined) { deps.ui.notify(violation); continue; } // re-open the add step, draft unchanged if (!keys.includes(key)) deps.ui.notify(deps.unregisteredWarning(key)); draft.substitutes.push(key); return true; } } /** T-remove (D-W9): splice one entry out of the current draft. With no entries the menu degrades to * [cancel], so the uniform cancellation rule is the only exit. */ async function removeSubstitute(deps: WizardDeps, draft: Draft): Promise { const choice = await deps.ui.select(`Remove a substitute from ${draft.primary}:`, [...draft.substitutes, CANCEL]); if (choice === undefined || choice === CANCEL) return false; const at = draft.substitutes.indexOf(choice); if (at >= 0) draft.substitutes.splice(at, 1); return true; } /** T-reorder (Q2/D-W9): an explicit re-sequencing step — one select per position over the remaining * entries; the resulting order is exactly the picked sequence (R2: no re-ranking). Cancel/undefined * at any position aborts the wizard (uniform rule) and leaves the draft untouched. */ async function reorderSubstitutes(deps: WizardDeps, draft: Draft): Promise { const remaining = [...draft.substitutes]; const picked: string[] = []; for (let position = 1; remaining.length > 0; position++) { const choice = await deps.ui.select(`Position ${position} of ${draft.substitutes.length}:`, [...remaining, CANCEL]); if (choice === undefined || choice === CANCEL) return false; remaining.splice(remaining.indexOf(choice), 1); picked.push(choice); } draft.substitutes = picked; return true; } const CURRENT_ASSISTANT_MARK = " (current assistant)"; /** T-assistant (D-W9): registry-marked options + typed key + "clear assistant" when set + cancel. * At most one assistant by construction — replace = pick another; clearing deletes. The shared * validator catches a self edge (assistant === primary) or a duplicate (assistant === a substitute) * and re-prompts the same step, draft unchanged. */ async function assistantStep(deps: WizardDeps, draft: Draft): Promise { for (;;) { const keys = await deps.registryKeys(); const marked = keys.filter((key) => key !== draft.primary).map((key) => key === draft.assistant ? `${key}${CURRENT_ASSISTANT_MARK}` : key); const options = [...marked, TYPE_A_KEY, ...(draft.assistant !== undefined ? ["clear assistant"] : []), CANCEL]; const choice = await deps.ui.select(`Assistant for ${draft.primary}:`, options); if (choice === undefined || choice === CANCEL) return false; if (choice === "clear assistant") { delete draft.assistant; return true; } const key = choice === TYPE_A_KEY ? await typedModelKey(deps) : choice.replace(CURRENT_ASSISTANT_MARK, ""); if (key === undefined) return false; const violation = candidateSetViolation(draft.primary, { substitutes: [...draft.substitutes], assistant: key }); if (violation !== undefined) { deps.ui.notify(violation); continue; } draft.assistant = key; return true; } } /** R — whole-assignment removal (D-W9, Q4): the same P picker with NO prefill, then a gating confirm * and the single write path. A store rejection re-enters the removal pick with the store's error; * repeating the removal on an absent entry is safe (the store's delete is a validated no-op). The * override warn-and-allow gate lands with the Q1 disposition. */ async function removalFlow(deps: WizardDeps): Promise { for (;;) { const picked = await pickPrimary(deps, "Primary model:"); if (picked === undefined) return cancelled(); if (!(await overrideGate(deps, picked))) return cancelled(); if (!(await deps.ui.confirm(`Remove the global assignment for ${picked}?`))) return cancelled(); const result = await deps.config.remove(picked, { global: true }); if (!result.ok) { deps.ui.notify(result.error ?? "fallback configuration change was rejected"); continue; } return { outcome: "removed", primary: picked, result }; } }