import type { ReadSource } from "../config/loader"; import type { CandidateSet, CandidateSetInput, ConfigSnapshot, LoadResult, WriteResult } from "../config/types"; import { parseModelKey } from "../config/schema"; import { recoveryTargets } from "../episode/ledger"; import type { EpisodeState, Segment } from "../episode/types"; import { EMPTY_HANDOFF, pendingReviewCount, renderHandoff } from "../review/handoff"; import type { FallbackReviewIndex, ReviewIndexEntry } from "../review/types"; import type { UiPort } from "../platform/ports"; import { runConfigWizard, type WizardDeps } from "./config-wizard"; export interface FallbackCommandContext { // R22/D-W2: select/input are the optional dialog members — a context without dialog support // simply omits them. `hasUI` is undefined ⇒ treat as false (print/JSON degradation). ui: Pick; hasUI?: boolean; currentModel?: string; } export interface FallbackConfigPort { snapshot(): ConfigSnapshot; reload(): Promise; set(primary: string, candidate: CandidateSetInput, options?: { global?: boolean }): Promise; remove(primary?: string, options?: { global?: boolean; all?: boolean }): Promise; } export interface FallbackReviewPort { snapshot(): FallbackReviewIndex | undefined; persist(value: FallbackReviewIndex): Promise; } export interface FallbackModelRegistry { getAll(): readonly unknown[] | Promise; } export interface FallbackCommandPorts { config: FallbackConfigPort; review: FallbackReviewPort; readSources: () => Promise; modelRegistry: FallbackModelRegistry; onConfigChange?: (config: ConfigSnapshot) => void | Promise; getEpisode?: () => EpisodeState | undefined; sendMessage?: (message: { role: "user"; content: string }, options?: { triggerTurn?: boolean }) => void | Promise; } export interface AutocompleteItem { value: string; label: string; } export interface FallbackCommandDefinition { description: string; getArgumentCompletions(prefix: string): Promise; handler(args: string, context: FallbackCommandContext): Promise; } export interface CommandRegistrar { registerCommand(name: string, command: FallbackCommandDefinition): void; } const usage = "usage: /fallback ..."; const knownCommands = new Set(["set", "remove", "list", "status", "review", "clear", "reload", "configure"]); // R22/D-W4: the one degradation notice for every gate-false shape (hasUI false, or a host that // claims hasUI but lacks the dialog methods). Exactly one message, no dialog, no write. const CONFIGURE_DEGRADED = "configure needs an interactive session; use /fallback set [sub2 ...] [--assistant=] [--global]"; const modelKey = (value: unknown): string | undefined => { if (typeof value === "string") return parseModelKey(value) ? value : undefined; if (!value || typeof value !== "object") return undefined; const item = value as Record; if (typeof item.key === "string" && parseModelKey(item.key)) return item.key; if (typeof item.provider !== "string") return undefined; const id = typeof item.id === "string" ? item.id : item.modelId; return typeof id === "string" && id.length > 0 ? `${item.provider}/${id}` : undefined; }; const tokens = (args: string): string[] => args.trim().split(/\s+/).filter(Boolean); // OD-10: no arrow, ever — a single-substitute set must not render as `P -> S1` (R15). Uniform brackets. export const renderCandidateSet = (set: CandidateSet): string => `[${set.substitutes.join(", ")}]${set.assistant ? ` assistant=${set.assistant}` : ""}`; export function formatAssignments(config: ConfigSnapshot, sources: readonly [ReadSource, ReadSource]): string { const project = new Set(Object.keys(sources[1].value?.fallbacks ?? {})); const lines = Object.entries(config.fallbacks).map(([primary, set]) => `${primary}: ${renderCandidateSet(set)}${project.has(primary) ? " (project override)" : ""}`); return lines.length ? lines.join("\n") : "no fallback assignments configured"; } /** R15 status half: the active candidate's tier and its place in the operator's own order. The tier is * read off the SEGMENT, where `beginSegment` recorded it at entry — never re-derived from current * configuration, so a mid-episode re-role cannot relabel a running segment. Tier 2 has no ordered * position by construction (the assistant is what comes AFTER the order is exhausted), so it says so * rather than inventing an index; a substitute the operator has since removed from the set says so too, * instead of rendering a misleading `-1`. */ const candidateLine = (state: EpisodeState, segment: Segment): string => { if (segment.tier === 2) return `${segment.active} — tier 2 assistant (no ordered position remains)`; const substitutes = state.fallbacks[segment.primary]?.substitutes ?? []; const position = substitutes.indexOf(segment.active); return `${segment.active} — tier 1 substitute ${position < 0 ? "(no longer in the operator's order)" : `${position + 1} of ${substitutes.length}`}`; }; export function formatStatus(state: EpisodeState | undefined, index: FallbackReviewIndex | undefined, config: ConfigSnapshot, currentModel: string | undefined, lastConfigError?: string): string { const status = state?.status === "active" && state.substate ? `active (${state.substate})` : state?.status ?? "idle"; const active = state?.activeModel ?? currentModel ?? "unknown"; const root = state?.rootPrimary ?? active; const stack = state?.segments.length ? [state.segments[0].primary, ...state.segments.map((segment) => segment.active)].join(" -> ") : "none"; const count = state?.probeCount ?? 0; const cap = state?.maxProbes ?? config.policy.probe.maxProbes; const due = state?.nextDueAt === undefined ? "none" : String(state.nextDueAt); // Task 4.6: one shared per-episode timer (OD-4) means a candidate is checked once per rotation, not // once per interval. Rendered only when the rotation actually has something to choose between, so a // single-target episode keeps its previous output verbatim. const targets = state ? recoveryTargets(state) : []; const position = targets.length > 1 ? state!.probeCursor % targets.length : -1; const rotation = position < 0 ? [] : [`next probe target: ${targets[position]} (${position + 1} of ${targets.length} in rotation)`]; const entered = state?.segments.at(-1); const candidate = state && entered ? [`candidate : ${candidateLine(state, entered)}`] : []; return [ `state : ${status}`, `current model: ${active}`, `root primary : ${root}`, ...candidate, `segment stack: ${stack}`, `depth : ${state?.segments.length ?? 0}`, `probe count : ${count}/${cap}`, `next due : ${due}`, ...rotation, `pending review: ${index ? pendingReviewCount(index) : 0}`, `last config error: ${lastConfigError ?? "none"}`, ].join("\n"); } // OD-10: `--assistant=` uses the `=` form deliberately, so this loop's `--`-prefix vs. bare-value // split stays intact — a space-separated `--assistant ` would swallow the model key into `flags`. const ASSISTANT_PREFIX = "--assistant="; const parse = (raw: string): { command?: string; values: string[]; flags: Set; assistant?: string; error?: string } => { const parts = tokens(raw); const [command, ...rest] = parts; if (!command || !knownCommands.has(command)) return { values: [], flags: new Set(), error: usage }; const values: string[] = [], flags = new Set(); let assistant: string | undefined; for (const part of rest) { if (part.startsWith(ASSISTANT_PREFIX)) { assistant = part.slice(ASSISTANT_PREFIX.length); flags.add(part); continue; } part.startsWith("--") ? flags.add(part) : values.push(part); } const allowedFlag = (flag: string): boolean => flag === "--global" || flag === "--all" || flag.startsWith(ASSISTANT_PREFIX); if ([...flags].some((flag) => !allowedFlag(flag))) return { command, values, flags, assistant, error: `${usage}\nunknown option` }; return { command, values, flags, assistant }; }; export function createFallbackCommand(ports: FallbackCommandPorts): FallbackCommandDefinition { let lastConfigError: string | undefined; const notify = (context: FallbackCommandContext, message: string): string => { context.ui.notify(message); return message; }; const registryKeys = async (): Promise => { const values = await ports.modelRegistry.getAll(); return [...new Set(values.map(modelKey).filter((key): key is string => !!key))]; }; const operationError = (context: FallbackCommandContext, result: WriteResult): string => { lastConfigError = result.error; return notify(context, result.error ?? "fallback configuration change was rejected"); }; // R22/D-W1/D-W5: the wizard's deps are built HERE — every grammar-owned string (overview, // renderers, the pre-configuration warning) is injected, so the pure wizard module can never // drift from the grammar's wording and the import arrow stays one-directional. const wizardDeps = async (context: FallbackCommandContext): Promise => { let sources: readonly [ReadSource, ReadSource]; try { sources = await ports.readSources(); } catch { sources = [{ scope: "global", path: "", value: undefined }, { scope: "project", path: "", value: undefined }]; } return { ui: { notify: (message) => context.ui.notify(message), confirm: (message) => context.ui.confirm(message), // Called THROUGH the ui object (never destructured) so host ui instances keep their `this`. select: (title, options) => context.ui.select!(title, options), input: (title, placeholder) => context.ui.input!(title, placeholder), }, registryKeys, projectOverridden: (primary) => Object.keys(sources[1].value?.fallbacks ?? {}).includes(primary), globalSet: (primary) => sources[0].value?.fallbacks[primary], overview: formatAssignments(ports.config.snapshot(), sources), renderSet: renderCandidateSet, unregisteredWarning: (key) => `warning: model key ${key} not found in registry; assignment is retained for pre-configuration`, config: ports.config, }; }; const handler = async (args: string, context: FallbackCommandContext): Promise => { const parsed = parse(args); if (parsed.error) return notify(context, parsed.error); const { command, values, flags, assistant } = parsed; if (command === "set") { // OD-10 grammar: `/fallback set [sub2 ...] [--assistant=]`. const [primary, ...substitutes] = values; if (!primary || (substitutes.length === 0 && assistant === undefined) || flags.has("--all")) { return notify(context, `${usage}\nset requires and at least one substitute or --assistant=`); } const keys = await registryKeys(); const candidateKeys = assistant !== undefined ? [primary, ...substitutes, assistant] : [primary, ...substitutes]; for (const key of candidateKeys) if (!keys.includes(key)) notify(context, `warning: model key ${key} not found in registry; assignment is retained for pre-configuration`); const candidate: CandidateSetInput = assistant !== undefined ? { substitutes, assistant } : { substitutes }; const result = await ports.config.set(primary, candidate, { global: flags.has("--global") }); if (!result.ok) return operationError(context, result); await ports.onConfigChange?.(result.config); lastConfigError = undefined; // Task 3.16: slice 2's interim advisory about only the first substitute being honoured is gone — // the reducer now walks the whole ordered set, so keeping it would be a lie. return notify(context, `set: ${primary}: ${renderCandidateSet({ substitutes, assistant })}`); } if (command === "remove") { if ((flags.has("--all") && values.length) || (!flags.has("--all") && values.length !== 1)) return notify(context, `${usage}\nremove requires or --all`); const result = await ports.config.remove(values[0], { global: flags.has("--global"), all: flags.has("--all") }); if (!result.ok) return operationError(context, result); await ports.onConfigChange?.(result.config); lastConfigError = undefined; const residual = !flags.has("--global") && !flags.has("--all") && values[0] ? ports.config.snapshot().fallbacks[values[0]] : undefined; return notify(context, residual ? `removed project assignment for ${values[0]}; global fallback remains ${renderCandidateSet(residual)}` : "fallback assignment removed"); } if (command === "list") { if (values.length || flags.size) return notify(context, `${usage}\nlist takes no arguments`); let sources: readonly [ReadSource, ReadSource]; try { sources = await ports.readSources(); } catch { sources = [{ scope: "global", path: "", value: undefined }, { scope: "project", path: "", value: undefined }]; } return notify(context, formatAssignments(ports.config.snapshot(), sources)); } if (values.length || flags.size) return notify(context, `${usage}\n${command} takes no arguments`); if (command === "configure") { // R22/D-W2: structural gate, matching the codebase's guarded-coupling style — the wizard runs // only in a genuinely dialog-capable session; every gate-false shape (hasUI false, or a host // that claims hasUI but lacks the methods) degrades exactly like print/JSON: one notice, no // dialog, no write (AC-W2). if (context.hasUI !== true || typeof context.ui.select !== "function" || typeof context.ui.input !== "function") return notify(context, CONFIGURE_DEGRADED); const wizard = await runConfigWizard(await wizardDeps(context)); // D-W5: onConfigChange and lastConfigError bookkeeping stay command-owned, mirroring the // existing set/remove branches; the store remains the only code that writes files. if (wizard.outcome === "cancelled") return notify(context, "no changes made"); if (!wizard.result.ok) { lastConfigError = wizard.result.error; return notify(context, wizard.result.error ?? "fallback configuration change was rejected"); } await ports.onConfigChange?.(wizard.result.config); lastConfigError = undefined; // Success notification renders the STORE-ACCEPTED set read back from the result snapshot. return wizard.outcome === "saved" ? notify(context, `saved: ${wizard.primary}: ${renderCandidateSet(wizard.result.config.fallbacks[wizard.primary] ?? { substitutes: [] })}`) : notify(context, "fallback assignment removed"); } if (command === "status") return notify(context, formatStatus(ports.getEpisode?.(), ports.review.snapshot(), ports.config.snapshot(), context.currentModel, lastConfigError)); if (command === "review") { const index = ports.review.snapshot(); const output = index ? renderHandoff(index) : EMPTY_HANDOFF; notify(context, output); const canInject = index && output !== EMPTY_HANDOFF && !index.reviewed && index.status === "reverted" && (ports.getEpisode?.()?.activeModel ?? context.currentModel) === index.primary && ports.sendMessage; if (canInject && await context.ui.confirm("Inject this fallback review handoff into the primary model?")) await ports.sendMessage!({ role: "user", content: output }, { triggerTurn: true }); return output; } if (command === "clear") { const index = ports.review.snapshot(); if (!index || index.reviewed) return notify(context, EMPTY_HANDOFF); await ports.review.persist({ ...index, reviewed: true, flaggedFiles: [...index.flaggedFiles], shellActivity: [...index.shellActivity] }); return notify(context, "fallback review marked as reviewed"); } const result = await ports.config.reload(); await ports.onConfigChange?.(result.config); lastConfigError = result.errors[0]?.message; const errorFor = (scope: "global" | "project") => result.errors.find((error) => error.scope === scope); const report = (["global", "project"] as const).map((scope) => { const error = errorFor(scope), path = result.paths[scope]; return `${scope}: ${error ? `invalid ${path} (${error.message})` : `loaded ${path}`}`; }).join("\n"); return notify(context, report); }; return { description: "configure and inspect model fallbacks", getArgumentCompletions: async (prefix) => { const token = tokens(prefix).at(-1) ?? ""; return (await registryKeys()).filter((key) => key.startsWith(token)).map((value) => ({ value, label: value })); }, handler }; } export function registerFallbackCommand(api: CommandRegistrar, ports: FallbackCommandPorts): FallbackCommandDefinition { const command = createFallbackCommand(ports); api.registerCommand("fallback", command); return command; }