/** * Module loadouts (GSTA-140, PR 1a): the toggleable units of gstack and the * named sets of them a render can load. * * A module is a skill (`skill:`), one carved section * (`section:/`, from /sections/manifest.json), one model * overlay (`overlay:`, from model-overlays/) or one preamble piece * (`preamble:`, from PREAMBLE_PIECES). The catalog is derived from those * sources; modules/loadouts.json is the only hand-written file and holds the * rungs, the model-id to rung table, the pinned ids and the loadouts. * * Every error is a ModuleError with a stable code, a fix line and a * docs/troubleshooting.md anchor. Nothing here ever falls back to `full` on * bad input: an eval arm that silently rendered `full` would measure the * control. */ import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { discoverTemplates } from './discover-skills'; import { PREAMBLE_MODULE_IDS } from './resolvers/preamble'; import type { ModuleFilter } from './resolvers/types'; const ROOT = path.resolve(import.meta.dir, '..'); export const LOADOUTS_FILE = 'modules/loadouts.json'; export const RUNGS = ['frontier', 'strong', 'mid', 'light'] as const; export type Rung = (typeof RUNGS)[number] | 'unknown'; export const FULL = 'full'; /** The router and the way back can never be left out (same rule as --disabled-skills). */ export const IMPLICIT_PINS = ['skill:gstack', 'skill:gstack-upgrade', 'preamble:bash']; const NAME_RE = /^[a-z0-9-]+$/; export type ModuleKind = 'skill' | 'section' | 'overlay' | 'preamble'; export interface ModuleEntry { id: string; kind: ModuleKind; /** Repository-relative source file. */ source: string; pinned: boolean; } export interface LoadoutDef { base?: string; include?: '*' | string[]; exclude?: string[]; preamble_tier_max?: number; note?: string; } export interface LoadoutsManifest { version: 1; aliases: Record; rungs: Record<(typeof RUNGS)[number], string[]>; pinned: string[]; loadouts: Record; default: Record; } export type ModuleErrorCode = | 'LOADOUTS_INVALID' | 'LOADOUT_UNKNOWN' | 'MODULE_SPEC_INVALID' | 'MODULE_UNKNOWN' | 'MODULE_PINNED' | 'MODULE_EXCLUSION_UNSAFE' | 'RENDER_FAILED' | 'RENDER_DEST_NOT_OWNED'; export class ModuleError extends Error { constructor(readonly code: ModuleErrorCode, problem: string, readonly fix: string) { super(`${problem}; fix: ${fix} (${code}; docs/troubleshooting.md#${code.toLowerCase().replaceAll('_', '-')})`); } } const clamp = (value: string) => JSON.stringify(value.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 120)); export function loadLoadouts(root = ROOT): LoadoutsManifest { const file = path.join(root, LOADOUTS_FILE); try { return JSON.parse(fs.readFileSync(file, 'utf-8')) as LoadoutsManifest; } catch (error) { throw new ModuleError('LOADOUTS_INVALID', `${LOADOUTS_FILE} is unreadable or not JSON (${(error as Error).message})`, `restore it with git checkout -- ${LOADOUTS_FILE}`); } } /** Every module id with its kind and source, derived from the files that define them. */ export function moduleCatalog(manifest: LoadoutsManifest, root = ROOT): ModuleEntry[] { const pinned = new Set([...manifest.pinned, ...IMPLICIT_PINS]); const entries: Omit[] = []; for (const { tmpl } of discoverTemplates(root)) { const dir = path.dirname(tmpl); entries.push({ id: `skill:${dir === '.' ? 'gstack' : dir}`, kind: 'skill', source: tmpl }); const manifestPath = path.join(root, dir, 'sections', 'manifest.json'); if (dir === '.' || !fs.existsSync(manifestPath)) continue; const sections = (JSON.parse(fs.readFileSync(manifestPath, 'utf-8')).sections ?? []) as Array<{ id: string; file: string }>; for (const s of sections) entries.push({ id: `section:${dir}/${s.id}`, kind: 'section', source: `${dir}/sections/${s.file}.tmpl` }); } for (const file of fs.readdirSync(path.join(root, 'model-overlays')).filter(f => f.endsWith('.md')).sort()) { entries.push({ id: `overlay:${file.slice(0, -3)}`, kind: 'overlay', source: `model-overlays/${file}` }); } for (const id of PREAMBLE_MODULE_IDS) entries.push({ id, kind: 'preamble', source: 'scripts/resolvers/preamble.ts' }); return entries.map(e => ({ ...e, pinned: pinned.has(e.id) })); } export function normalizeModelId(raw: string): string { return raw.trim().toLowerCase().replace(/\[[^\]]*\]$/, '').trim().replace(/^[a-z0-9-]+\//, ''); } const matches = (pattern: string, id: string) => (pattern.endsWith('*') ? id.startsWith(pattern.slice(0, -1)) : id === pattern); /** The rung a model id belongs to: aliases first, then the first rung whose pattern matches. */ export function rungFor(manifest: LoadoutsManifest, raw: string): Rung { const id = normalizeModelId(raw); if (Object.hasOwn(manifest.aliases, id)) return manifest.aliases[id]; for (const rung of RUNGS) if (manifest.rungs[rung].some(p => matches(p, id))) return rung; return 'unknown'; } /** Every problem with the manifest, or [] (free test and gstack-modules validate). */ export function validateLoadouts(manifest: LoadoutsManifest, catalog: ModuleEntry[], corpus: string[] = []): string[] { const problems: string[] = []; const ids = new Set(catalog.map(e => e.id)); const pinned = new Set([...(manifest.pinned ?? []), ...IMPLICIT_PINS]); if (manifest.version !== 1) problems.push(`version must be 1 (got ${clamp(String(manifest.version))})`); for (const rung of RUNGS) if (!Array.isArray(manifest.rungs?.[rung])) problems.push(`rungs.${rung} must be a list of model-id patterns`); for (const [alias, rung] of Object.entries(manifest.aliases ?? {})) { if (!(RUNGS as readonly string[]).includes(rung)) problems.push(`aliases.${alias} names no rung (${clamp(rung)})`); } for (const id of manifest.pinned ?? []) if (!ids.has(id)) problems.push(`pinned id ${clamp(id)} is not a module`); const loadouts = manifest.loadouts ?? {}; const has = (name: string | undefined) => name !== undefined && Object.hasOwn(loadouts, name); if (!has(FULL) || loadouts[FULL].include !== '*') problems.push('loadouts.full must exist with include "*"'); for (const [name, def] of Object.entries(loadouts)) { if (!NAME_RE.test(name)) problems.push(`loadout name ${clamp(name)} must match ^[a-z0-9-]+$`); if (def.base !== undefined && !has(def.base)) problems.push(`loadouts.${name}.base names no loadout (${clamp(def.base)})`); for (const id of [...(def.exclude ?? []), ...(Array.isArray(def.include) ? def.include : [])]) { if (!ids.has(id)) problems.push(`loadouts.${name} names ${clamp(id)}, which is not a module`); } for (const id of def.exclude ?? []) { if (pinned.has(id)) problems.push(`loadouts.${name} excludes pinned ${id}`); const child = id.startsWith('skill:') ? [...pinned].find(p => p.startsWith(`section:${id.slice('skill:'.length)}/`)) : undefined; if (child) problems.push(`loadouts.${name} excludes ${id}, which holds pinned ${child}`); } if (def.preamble_tier_max !== undefined && ![1, 2, 3, 4].includes(def.preamble_tier_max)) problems.push(`loadouts.${name}.preamble_tier_max must be 1-4`); const seen = new Set(); for (let at: string | undefined = name; at && has(at); at = loadouts[at].base) { if (seen.has(at)) { problems.push(`loadouts.${name} has a base cycle through ${at}`); break; } seen.add(at); } } for (const rung of [...RUNGS, 'unknown'] as const) { if (!has(manifest.default?.[rung])) problems.push(`default.${rung} names no loadout (${clamp(String(manifest.default?.[rung]))})`); } for (const raw of corpus) { const id = normalizeModelId(raw); const hits = RUNGS.filter(rung => manifest.rungs[rung]?.some(p => matches(p, id))); if (hits.length > 1) problems.push(`model ${id} matches more than one rung (${hits.join(', ')})`); } return problems; } function assertValid(manifest: LoadoutsManifest, catalog: ModuleEntry[]): void { const problems = validateLoadouts(manifest, catalog); if (problems.length) throw new ModuleError('LOADOUTS_INVALID', `${LOADOUTS_FILE}: ${problems.join('; ')}`, 'fix the named entries, then run gstack-modules validate'); } function excludedBy(manifest: LoadoutsManifest, name: string): { excluded: Set; tierMax?: number } { const def = manifest.loadouts[name]; const inherited = def.base ? excludedBy(manifest, def.base) : { excluded: new Set() }; const excluded = new Set([...inherited.excluded, ...(def.exclude ?? [])]); if (Array.isArray(def.include)) for (const id of def.include) excluded.delete(id); return { excluded, tierMax: def.preamble_tier_max ?? inherited.tierMax }; } export interface ModuleSelection extends ModuleFilter { /** The normalized spec (`` or `,-id,+id`) and its sha256, for the lock file. */ spec: string; specSha256: string; } /** * Resolve `--loadout ` or `--modules ,-id,+id` to a filter. * Returns null for the plain `full` loadout, which renders today's bytes. */ export function selectModules(input: { loadout?: string; modules?: string }, root = ROOT): ModuleSelection | null { if (input.loadout !== undefined && input.modules !== undefined) { throw new ModuleError('MODULE_SPEC_INVALID', '--loadout and --modules were both given', 'pass one: --loadout or --modules ,-id,+id'); } const text = (input.modules ?? input.loadout ?? '').trim(); if (!text) return null; const manifest = loadLoadouts(root); const catalog = moduleCatalog(manifest, root); assertValid(manifest, catalog); const [base, ...edits] = text.split(',').map(t => t.trim()); const names = Object.keys(manifest.loadouts).sort().join(', '); if (!Object.hasOwn(manifest.loadouts, base)) { throw new ModuleError('LOADOUT_UNKNOWN', `${clamp(base)} is not a loadout (valid: ${names})`, 'gstack-modules list'); } if (input.loadout !== undefined && edits.length) { throw new ModuleError('MODULE_SPEC_INVALID', `--loadout takes one name (got ${clamp(text)})`, 'use --modules ,-id,+id for edits'); } const { excluded, tierMax } = excludedBy(manifest, base); const ids = new Map(catalog.map(e => [e.id, e])); for (const edit of edits) { const op = edit[0]; const id = edit.slice(1); if ((op !== '-' && op !== '+') || !id) { throw new ModuleError('MODULE_SPEC_INVALID', `${clamp(edit)} is not - or +`, 'write the spec as ,-,+; list ids with gstack-modules list'); } const entry = ids.get(id); if (!entry) throw new ModuleError('MODULE_UNKNOWN', `${clamp(id)} is not a module`, 'gstack-modules list'); if (op === '-' && entry.pinned) { throw new ModuleError('MODULE_PINNED', `${id} is pinned and can never be excluded`, `drop -${id} from the spec (pinned ids: gstack-modules list --pinned)`); } const pinnedChild = op === '-' && id.startsWith('skill:') ? catalog.find(e => e.pinned && e.id.startsWith(`section:${id.slice('skill:'.length)}/`)) : undefined; if (pinnedChild) { throw new ModuleError('MODULE_PINNED', `${id} holds pinned ${pinnedChild.id} and can never be excluded`, `drop -${id} from the spec (pinned ids: gstack-modules list --pinned)`); } if (op === '-') excluded.add(id); else excluded.delete(id); } if (excluded.size === 0 && tierMax === undefined && base === FULL) return null; const spec = edits.length ? [base, ...edits].join(',') : base; return { name: edits.length ? 'adhoc' : base, excluded, pinned: new Set(catalog.filter(e => e.pinned).map(e => e.id)), preambleTierMax: tierMax, spec, specSha256: createHash('sha256').update(spec).digest('hex'), }; } /** Skill directories a selection leaves out (`skill:gstack` is the root template). */ export function excludedSkillDirs(filter: ModuleFilter | null | undefined): string[] { return [...(filter?.excluded ?? [])].filter(id => id.startsWith('skill:')).map(id => id.slice('skill:'.length)); }