import { fileURLToPath } from "node:url"; import { readFileSync } from "node:fs"; /** * Preset table + the shared `ResolvedMode` selection contract. * * This module owns two things the rest of the mode-composition engine builds * against: * 1. The `ResolvedMode` type — the single source of truth for a resolved * mode's shape. `mode-resolver` PRODUCES it; `deterministic-splice` * CONSUMES it. * 2. `presets.json` — named bundles of the five components. Selecting a * preset expands ATOMICALLY to all five at once. * * Pure module: no pi-runtime imports (Node builtins only), fully unit-testable, * fail-fast at boundaries — matching `cache.ts` / `identity.ts` / * `provider-names.ts` conventions. * * Validation split (the key documented judgement here): this module validates * the SHAPE of a preset (well-formedness + unknown-preset lookup miss). It does * NOT validate axis-VALUE existence (does `agency:"autonomous"` correspond to a * real discovered fragment?) — only `mode-resolver` holds the discovered * fragment set, so existence is its job. A `presets.json` entry with * `agency:"banana"` loads fine here (well-formed string) and fails fast later at * the resolver. * * Design: `.work/active/features/epic-mode-composition-preset-table.md`. */ /** * The default base sentinel: "pi's own voice, no base overlay." A real, * selectable base value, distinct from NO_MODE_SIGNATURE ("" = no mode at * all). A ResolvedMode with base === PI_BASE is a real mode (non-empty * signature, full axis fragments) that simply contributes no base-overlay * fragment. There is intentionally no prompts/base/pi.md. */ export const PI_BASE = "pi"; /** * Virtual preset matching claude-code-modes' `none` mode. It is intentionally * not stored in presets.json because it has no axes or fragments: selecting it * means an explicit no-mode override. */ export const NONE_PRESET = "none"; /** * The shared resolved-mode selection contract — the single source of truth * for a resolved mode's shape. `mode-resolver` PRODUCES this; `assemble.ts` * (deterministic-splice) CONSUMES it. Axis values are validated strings, NOT * closed unions: the discovered fragment set (convention dirs) is the real * catalog, so a closed union would drift. Existence of each value is checked * by the resolver against the discovered fragments; this type asserts only * the SHAPE. * * base — "pi" (PI_BASE, no overlay) or a prompts/base/.md name * agency — a prompts/axis/agency/.md name * quality — a prompts/axis/quality/.md name * scope — a prompts/axis/scope/.md name * modifiers — zero or more prompts/modifiers/.md names, in * preset-declared order (duplicate de-dup is a resolver concern) */ export interface ResolvedMode { base: string; agency: string; quality: string; scope: string; modifiers: string[]; } /** * A preset as stored in presets.json: a named ResolvedMode template. Selecting * a preset expands ATOMICALLY to all five components. Structurally a * ResolvedMode (the name lives in the registry key, not the value). * * Kept distinct from `ResolvedMode` (not a type alias) so the on-disk template * can gain disk-only metadata later (e.g. a description) without a breaking * rename of the runtime contract. */ export interface Preset { base: string; agency: string; quality: string; scope: string; modifiers: string[]; } /** The in-memory registry after load + validation. */ export type PresetRegistry = Readonly>; /** Options for loadPresets — `json` overrides disk read (test seam). */ export interface LoadPresetsOptions { /** Raw presets.json text to parse instead of reading the bundled file. */ json?: string; } // The four required string axes every preset must declare. const AXIS_FIELDS = ["base", "agency", "quality", "scope"] as const; // --- Module-scope memo (the bundled presets.json does not change in-process) - let cachedRegistry: PresetRegistry | undefined; // --- Internal helpers ------------------------------------------------------- /** Resolve the bundled presets.json path package-relative (same idiom as the * sibling fragment-loader resolves `prompts/`). From src/presets.ts, `../` * resolves to the package root where presets.json lives. */ function presetsPath(): string { return fileURLToPath(new URL("../presets.json", import.meta.url)); } function isPlainObject(value: unknown): value is Record { return ( typeof value === "object" && value !== null && !Array.isArray(value) ); } /** * Detect duplicate top-level preset ids in the RAW JSON text. `JSON.parse` * silently keeps the last value for a repeated key, so duplicate-id detection * MUST run on the raw text before the parse collapses them. * * Mechanism: a single depth- and string-aware pass. We only register a key when * its closing quote is read at object depth 1 (the top-level preset map) and it * is immediately followed (across whitespace) by a `:`. Tracking string state * means a `:` or `"` inside a value string is never mistaken for structure, and * the depth gate means a NESTED key sharing a preset name (e.g. a field literally * named "flow") can never false-positive. This replaces the previous per-name * regex, which counted occurrences anywhere in the document. * * Each completed top-level key token is JSON-decoded (via `JSON.parse` on the * reconstructed string literal) before comparison, so escape-equivalent keys — * e.g. "flow" and "flow" — are correctly recognized as the SAME id, matching * how `JSON.parse` would later collapse them. */ function assertNoDuplicateIds(rawText: string): void { const seen = new Set(); let depth = 0; let inString = false; let escaped = false; let token = ""; // contents of the in-progress string literal let pendingKey: string | undefined; // last string completed at depth 1, awaiting ':' for (let i = 0; i < rawText.length; i++) { const ch = rawText[i]; if (inString) { if (escaped) { token += ch; escaped = false; } else if (ch === "\\") { token += ch; escaped = true; } else if (ch === '"') { inString = false; if (depth === 1) { // Decode the raw token through JSON so escape-equivalent keys // ("flow" vs "flow") compare equal — matching JSON.parse's own // collapsing. Fall back to the raw token if the literal is malformed // (the main parse will surface the real error). try { pendingKey = JSON.parse(`"${token}"`) as string; } catch { pendingKey = token; } } } else { token += ch; } continue; } switch (ch) { case '"': inString = true; token = ""; break; case "{": case "[": depth++; pendingKey = undefined; break; case "}": case "]": depth--; pendingKey = undefined; break; case ":": if (depth === 1 && pendingKey !== undefined) { if (seen.has(pendingKey)) { throw new Error(`duplicate preset id "${pendingKey}" in presets.json`); } seen.add(pendingKey); } pendingKey = undefined; break; case ",": pendingKey = undefined; break; // whitespace and other structural chars: keep pendingKey across the gap // between a key's closing quote and its ':'. } } } /** Validate one preset's SHAPE, throwing a specific field-naming error on any * malformed component. Does NOT check axis-value existence (resolver's job). */ function validatePreset(name: string, value: unknown): Preset { if (!isPlainObject(value)) { throw new Error(`preset "${name}": must be an object`); } for (const field of AXIS_FIELDS) { const fieldValue = value[field]; if (typeof fieldValue !== "string" || fieldValue.length === 0) { throw new Error( `preset "${name}": "${field}" must be a non-empty string`, ); } } const modifiers = value.modifiers; if (!Array.isArray(modifiers)) { throw new Error(`preset "${name}": "modifiers" must be a string array`); } for (const modifier of modifiers) { if (typeof modifier !== "string" || modifier.length === 0) { throw new Error( `preset "${name}": every "modifiers" entry must be a non-empty string`, ); } } return { base: value.base as string, agency: value.agency as string, quality: value.quality as string, scope: value.scope as string, modifiers: [...(modifiers as string[])], }; } /** Parse + validate raw presets.json text into a registry. Pure (no memo). */ function parseRegistry(rawText: string): PresetRegistry { let parsed: unknown; try { parsed = JSON.parse(rawText); } catch (cause) { throw new Error( `presets.json is not valid JSON: ${(cause as Error).message}`, ); } if (!isPlainObject(parsed)) { throw new Error("presets.json must be a top-level object of name -> preset"); } assertNoDuplicateIds(rawText); const names = Object.keys(parsed); const registry: Record = {}; for (const name of names) { registry[name] = validatePreset(name, parsed[name]); } return Object.freeze(registry); } // --- Public API ------------------------------------------------------------- /** * Parse + validate presets.json and return the registry. Fail-fast: * - JSON.parse failure -> throw (malformed) * - top level not a plain object -> throw * - a preset missing/!string base/agency/quality/scope -> throw (names id) * - a preset whose base/agency/quality/scope is "" -> throw * - a preset whose modifiers is not a string[] -> throw * - a modifier entry that is not a non-empty string -> throw * - a duplicate top-level preset id -> throw (raw-text scan) * * Duplicate preset ids: a JSON object cannot hold duplicate keys at the value * level (last wins), so duplicate-id detection runs on the RAW TEXT before * JSON.parse (see assertNoDuplicateIds). * * NOTE: does NOT validate axis-VALUE existence against discovered fragments — * that is mode-resolver's job (it holds the fragment set). This validates * SHAPE only. * * Memoization: the disk read is memoized in module scope (the bundled * presets.json does not change within a process). Supplying `opts.json` parses * that text FRESH and never reads/writes the memo (test inputs must not poison * the module memo). `resetPresetsForTesting()` clears the disk memo. */ export function loadPresets(opts?: LoadPresetsOptions): PresetRegistry { if (opts?.json !== undefined) { return parseRegistry(opts.json); } if (cachedRegistry === undefined) { cachedRegistry = parseRegistry(readFileSync(presetsPath(), "utf8")); } return cachedRegistry; } /** * Look up a preset by name. Fail-fast: throws a clear error naming the unknown * preset AND listing the available names. Returns the Preset (a ResolvedMode * template) on hit. Selecting = expanding atomically to all components, so * callers spread it into a ResolvedMode directly. */ export function getPreset(name: string, registry: PresetRegistry): Preset { const preset = registry[name]; if (preset === undefined) { const available = listPresetNames(registry).join(", "); throw new Error(`unknown preset "${name}" — available: ${available}`); } return preset; } /** Sorted user-facing preset names, including virtual `none`. */ export function listPresetNames(registry: PresetRegistry = loadPresets()): string[] { return [...Object.keys(registry), NONE_PRESET].sort(); } /** TEST-ONLY: clear the memoized registry so the next loadPresets re-reads. */ export function resetPresetsForTesting(): void { cachedRegistry = undefined; }