v1.91.305.0 feat: render gstack per model with modules left out, measured before any model gets less (GSTA-140 PR 1a) (#3554)

* feat(modules): module catalog, per-model loadouts manifest and gstack-modules render (GSTA-140 PR 1a)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(modules): cover bin/gstack-modules and the loadouts schema; allow modules/ in pregate strays

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(modules): gen:skill-docs refuses an exclusion render without --out-dir; render tests set up their own arm

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(modules): skill exclusions honor pinned sections and section references; own-property loadout lookups; llms.txt follows the selection; ownership rechecked before swap

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: bump version and changelog (v1.91.296.0)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: loadout render cost and the modules/ directory in PROJECT_STRUCTURE

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: unstamp #3554 (slot calls stamp); CHANGELOG entry back under [Unreleased]

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-29 04:22:41 +00:00
1 parent 4729f4752b
commit f4f16e83e0
26 files changed
+1272 -57

No files matched your search

+3
View File
@@ -125,6 +125,8 @@ interface GenerateOptions {
root?: string;
/** When true, missing skill description should fail the build. */
strict?: boolean;
/** Skill directories a module loadout leaves out (scripts/modules.ts). */
excludedSkills?: string[];
}
export interface GenerateResult {
@@ -143,6 +145,7 @@ export async function generateLlmsTxt(opts: GenerateOptions = {}): Promise<Gener
const skills: SkillEntry[] = [];
for (const t of templates) {
if (!includesSkill(claudeHost, path.dirname(t.tmpl))) continue;
if (opts.excludedSkills?.includes(path.dirname(t.tmpl))) continue;
const filePath = path.join(root, t.tmpl);
const entry = parseSkillFrontmatter(filePath);
if (!entry) {
+34 -5
View File
@@ -23,7 +23,9 @@ import * as path from 'path';
import type { Host, TemplateContext } from './resolvers/types';
import { HOST_PATHS } from './resolvers/types';
import { RESOLVERS } from './resolvers/index';
import { rewriteCarvedSectionRefs, SKILL_BYTE_CEILING, usesLazySections } from './resolvers/sections';
import { applySectionGuards, rewriteCarvedSectionRefs, SKILL_BYTE_CEILING, usesLazySections } from './resolvers/sections';
import { ModuleError, excludedSkillDirs, selectModules } from './modules';
import type { ModuleFilter } from './resolvers/types';
import { insertRuntimePreludes } from './resolvers/runtime-root';
import { generateSharedPreambleFile, hoistsPreamble, SHARED_PREAMBLE_FILE } from './resolvers/preamble-hoist';
import { extractVoiceTriggers } from './resolvers/voice-triggers';
@@ -54,6 +56,8 @@ export interface GenerationOptions {
respectDetection?: boolean;
log?: (message: string) => void;
planMode?: boolean;
/** Module loadout (scripts/modules.ts selectModules); null/undefined = `full`. */
modules?: ModuleFilter | null;
}
interface RenderOptions {
@@ -66,6 +70,7 @@ interface RenderOptions {
explainLevel: 'default' | 'terse';
gbrainDetected: boolean;
planMode: boolean;
modules: ModuleFilter | null;
}
export interface GeneratedArtifact {
@@ -144,6 +149,10 @@ function parseGenerationArgs(args: string[]): GenerationOptions {
const disabledSkills = (disabledValue ?? '').split(/[\s,]+/).filter(Boolean);
const badDisabled = disabledSkills.find(n => !/^\/?[a-z0-9-]+$/.test(n));
if (badDisabled) throw new Error(`--disabled-skills takes skill names (got ${JSON.stringify(badDisabled)})`);
const modules = selectModules({ loadout: value('--loadout'), modules: value('--modules') });
if (modules && (outDir === undefined || path.resolve(outDir) === ROOT)) {
throw new ModuleError('MODULE_SPEC_INVALID', `--${value('--modules') === undefined ? 'loadout' : 'modules'} with exclusions needs an --out-dir outside the checkout root; the committed tree is always the full render`, 'bin/gstack-modules render --host <host> --modules <spec> --out-dir <dir>');
}
const installRoot = value('--install-root');
if (installRoot !== undefined && !INSTALL_ROOT_PATTERN.test(installRoot)) {
throw new Error(`--install-root must be an absolute path of letters, digits and . _ - + @ / (got ${JSON.stringify(installRoot)})`);
@@ -159,9 +168,21 @@ function parseGenerationArgs(args: string[]): GenerationOptions {
: outDir !== undefined ? path.resolve(outDir) : null,
installRoot: installRoot ?? null,
disabledSkills,
modules,
};
}
/** A section file is left out when its skill or its manifest entry is excluded. */
function excludesSection(filter: ModuleFilter | null, skillDir: string, tmpl: string): boolean {
if (!filter) return false;
if (filter.excluded.has(`skill:${skillDir}`)) return true;
const file = path.basename(tmpl).replace(/\.tmpl$/, '');
const manifest = path.join(ROOT, skillDir, 'sections', 'manifest.json');
if (!fs.existsSync(manifest)) return false;
const entry = (JSON.parse(fs.readFileSync(manifest, 'utf-8')).sections ?? []).find((s: { file: string }) => s.file === file);
return entry ? filter.excluded.has(`section:${skillDir}/${entry.id}`) : false;
}
/** Install roots are spliced into shell lines unquoted, so only plain absolute paths. */
const INSTALL_ROOT_PATTERN = /^\/[A-Za-z0-9_.@+\/-]*$/;
@@ -657,7 +678,7 @@ function resolvePlaceholders(
// section generation so both paths get the same gbrain-aware behavior.
const suppressed = effectiveSuppressedResolvers(hostConfig, options);
const onePass = (input: string): string =>
input.replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (_match, fullKey) => {
applySectionGuards(input, ctx).replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (_match, fullKey) => {
const parts = fullKey.split(':');
const resolverName = parts[0];
const args = parts.slice(1);
@@ -713,6 +734,7 @@ function buildContext(
return {
skillName, tmplPath, benefitsFrom, host, paths: HOST_PATHS[host],
preambleTier, model: options.model ?? getHostConfig(host).defaultModel, interactive, explainLevel: options.explainLevel, installRoot: options.installRoot, planMode: options.planMode,
...(options.modules ? { modules: options.modules } : {}),
};
}
@@ -842,7 +864,7 @@ function processTemplate(tmplPath: string, host: Host, options: RenderOptions):
}
// Prepend generated header (after frontmatter)
const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath));
const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath)) + (options.modules ? `<!-- loadout: ${options.modules.name} -->\n` : '');
const fmEnd = content.indexOf('---', content.indexOf('---') + 3);
if (fmEnd !== -1) {
const insertAt = content.indexOf('\n', fmEnd) + 1;
@@ -859,7 +881,7 @@ function processTemplate(tmplPath: string, host: Host, options: RenderOptions):
// --out-dir: repoint section-base paths to the out-dir (no-op otherwise).
if (host === 'claude') content = rewriteSectionBase(content, options.contentLinkRoot);
if (skillDir === '' || skillDir === '.') content = omitDisabledSkills(content, options.disabledSkills);
if (skillDir === '' || skillDir === '.') content = omitDisabledSkills(content, [...options.disabledSkills, ...excludedSkillDirs(options.modules)]);
content = rewriteInstallRoot(insertRuntimePreludes(rewriteCarvedSectionRefs(content, ctx), ctx), currentHostConfig, options.installRoot);
const preamble = hoistsPreamble(ctx) && tmplContent.includes('{{PREAMBLE}}') && !effectiveSuppressedResolvers(currentHostConfig, options).has('PREAMBLE')
@@ -958,6 +980,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise<G
explainLevel: settings.explainLevel ?? 'default',
gbrainDetected: loadGbrainOverride(settings.respectDetection ?? false),
planMode: settings.planMode ?? true,
modules: settings.modules ?? null,
};
const hosts = settings.host === 'all' ? ALL_HOST_NAMES as Host[] : [settings.host ?? 'claude'];
const log = settings.log ?? (() => {});
@@ -1023,6 +1046,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise<G
for (const template of templates) {
const skillDir = path.dirname(template.tmpl);
if (!includesSkill(hostConfig, skillDir)) continue;
if (options.modules?.excluded.has(`skill:${skillDir === '.' ? 'gstack' : skillDir}`)) continue;
const result = processTemplate(path.join(ROOT, template.tmpl), host, options);
const relativePath = rel(result.outputPath);
if (host !== 'claude') renderedNames.add(path.basename(path.dirname(result.outputPath)));
@@ -1050,6 +1074,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise<G
for (const section of sections) {
if (!includesSkill(hostConfig, section.skillDir) || !usesLazySections(host, section.skillDir)) continue;
if (excludesSection(options.modules, section.skillDir, section.tmpl)) continue;
const result = processSectionTemplate(path.join(ROOT, section.tmpl), section.skillDir, host, options);
emit(result.outputPath, result.content, 'section', host);
tokenBudget.push({ skill: rel(result.outputPath), lines: result.content.split('\n').length, tokens: Math.round(result.content.length / 4) });
@@ -1126,7 +1151,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise<G
// Shared artifacts are awaited in both modes. A failure must reach the CLI
// exit code, never disappear in a fire-and-forget auxiliary writer.
try {
const index = await generateLlmsTxt();
const index = await generateLlmsTxt({ excludedSkills: excludedSkillDirs(options.modules) });
emit(path.join(options.outputRoot, 'gstack', 'llms.txt'), index.content, 'index');
for (const warning of index.warnings) diagnostics.push({ kind: 'warning', message: `[gen-llms-txt] ${warning}` });
} catch (error) {
@@ -1171,6 +1196,10 @@ export async function main(args = process.argv.slice(2)): Promise<number> {
}
return result.exitCode;
} catch (error) {
if (error instanceof ModuleError) {
console.error(`${error.code}: ${error.message}`);
return 2;
}
console.error(`ERROR: ${(error as Error).message}`);
return 1;
}
+86
View File
@@ -0,0 +1,86 @@
/**
* bin/gstack-modules (GSTA-140, PR 1a): the module catalog and loadout renders.
*
* gstack-modules list [--json] [--pinned]
* gstack-modules validate
* gstack-modules render --host <h> (--loadout <name> | --modules <base>,-id,+id) --out-dir <d>
* [--install-root <abs>] [--model <id>] [--plan-mode on|off]
*
* Exit 0 on success, 2 on a refused input (ModuleError, printed with its code,
* fix and docs anchor), 1 on an unexpected failure.
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { resolveHostArg } from '../hosts/index';
import { resolveModel } from './models';
import { LOADOUTS_FILE, ModuleError, loadLoadouts, moduleCatalog, selectModules, validateLoadouts } from './modules';
import { renderLoadout } from './modules-render';
import type { Host } from './resolvers/types';
const ROOT = path.resolve(import.meta.dir, '..');
const USAGE = `Usage:
gstack-modules list [--json] [--pinned] every module id with its kind, source and pin
gstack-modules validate check ${LOADOUTS_FILE} against the catalog
gstack-modules render --host <h> (--loadout <name> | --modules <base>,-id,+id) --out-dir <d>
[--install-root <abs>] [--model <id>] [--plan-mode on|off]
render one loadout and write loadout.lock.json
Guide: docs/loadouts.md`;
export interface CliResult { code: number; stdout: string; stderr: string }
function flag(args: string[], name: string): string | undefined {
const index = args.findIndex(a => a === name || a.startsWith(`${name}=`));
if (index < 0) return undefined;
const value = args[index].startsWith(`${name}=`) ? args[index].slice(name.length + 1) : args[index + 1];
if (!value || value.startsWith('--')) throw new ModuleError('MODULE_SPEC_INVALID', `${name} requires a value`, `gstack-modules --help`);
return value;
}
export async function modulesMain(args: string[]): Promise<CliResult> {
const [command, ...rest] = args;
try {
if (!command || command === '--help' || command === '-h' || command === 'help') return { code: 0, stdout: USAGE + '\n', stderr: '' };
if (command === 'list') {
const manifest = loadLoadouts();
const catalog = moduleCatalog(manifest).filter(e => !rest.includes('--pinned') || e.pinned);
const rows = catalog.map(e => {
const bytes = fs.readFileSync(path.join(ROOT, e.source));
return { ...e, source_bytes: bytes.length, source_sha256: crypto.createHash('sha256').update(bytes).digest('hex') };
});
if (rest.includes('--json')) return { code: 0, stdout: JSON.stringify({ loadouts: Object.keys(manifest.loadouts), modules: rows }, null, 2) + '\n', stderr: '' };
const lines = rows.map(r => `${r.id.padEnd(48)} ${r.kind.padEnd(8)} ${r.pinned ? 'pinned' : ' '} ${r.source}`);
return { code: 0, stdout: [`loadouts: ${Object.keys(manifest.loadouts).join(', ')}`, ...lines].join('\n') + '\n', stderr: '' };
}
if (command === 'validate') {
const manifest = loadLoadouts();
const problems = validateLoadouts(manifest, moduleCatalog(manifest));
if (problems.length) return { code: 2, stdout: '', stderr: problems.map(p => `LOADOUTS_INVALID: ${p}`).join('\n') + '\n' };
return { code: 0, stdout: `LOADOUTS: ok (${Object.keys(manifest.loadouts).length} loadouts)\n`, stderr: '' };
}
if (command === 'render') {
const hostValue = flag(rest, '--host');
const outDir = flag(rest, '--out-dir');
if (!hostValue || !outDir) throw new ModuleError('MODULE_SPEC_INVALID', 'render needs --host and --out-dir', 'gstack-modules render --host claude --loadout full --out-dir <dir>');
let host: Host;
try { host = resolveHostArg(hostValue) as Host; } catch (error) { throw new ModuleError('MODULE_SPEC_INVALID', (error as Error).message, 'pass a host from docs/hosts.md, for example --host claude'); }
const loadout = flag(rest, '--loadout');
const modules = flag(rest, '--modules');
if (loadout === undefined && modules === undefined) throw new ModuleError('MODULE_SPEC_INVALID', 'render needs --loadout or --modules', 'add --loadout full for the control arm');
const modelValue = flag(rest, '--model');
const model = modelValue === undefined ? null : resolveModel(modelValue);
if (modelValue !== undefined && !model) throw new ModuleError('MODULE_SPEC_INVALID', `--model ${JSON.stringify(modelValue.slice(0, 80))} is not a model gstack knows`, 'pass the same --model setup uses, for example gpt-6-astra');
const planMode = flag(rest, '--plan-mode');
if (planMode !== undefined && planMode !== 'on' && planMode !== 'off') throw new ModuleError('MODULE_SPEC_INVALID', '--plan-mode takes on or off', 'drop it, or pass --plan-mode off');
const installRoot = flag(rest, '--install-root');
if (installRoot !== undefined && !/^\/[A-Za-z0-9_.@+\/-]*$/.test(installRoot)) throw new ModuleError('MODULE_SPEC_INVALID', '--install-root must be a plain absolute path', 'pass the absolute install root setup would use');
const selection = selectModules({ loadout, modules });
const lock = await renderLoadout({ host, selection, outDir, installRoot: installRoot ?? null, model, planMode: planMode !== 'off' });
return { code: 0, stdout: `RENDERED: loadout=${lock.loadout} host=${host} files=${lock.files.length} bytes=${lock.total_bytes} excluded=${lock.excluded.length} lock=${path.join(path.resolve(outDir), 'loadout.lock.json')}\n`, stderr: '' };
}
return { code: 2, stdout: '', stderr: `Unknown command ${JSON.stringify(command.slice(0, 40))}.\n${USAGE}\n` };
} catch (error) {
if (error instanceof ModuleError) return { code: 2, stdout: '', stderr: `${error.code}: ${error.message}\n` };
return { code: 1, stdout: '', stderr: `gstack-modules: unexpected failure: ${(error as Error).message}\n` };
}
}
+176
View File
@@ -0,0 +1,176 @@
/**
* gstack-modules render (GSTA-140, PR 1a): one loadout rendered into a
* directory GSTA-2 can preregister, published only when complete.
*
* Order: render into an owned staging directory beside --out-dir (Claude
* section links already name the final path), refuse dangling references to
* an excluded module, hash every file into loadout.lock.json (written last),
* then swap the staging directory into place. Any failure leaves the previous
* --out-dir untouched and removes the staging directory.
*/
import { createHash, randomBytes } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { runGeneration } from './gen-skill-docs';
import { ModuleError, type ModuleSelection } from './modules';
import type { Host } from './resolvers/types';
import { getHostConfig } from '../hosts/index';
import type { Model } from './models';
export const LOCK_FILE = 'loadout.lock.json';
const ROOT = path.resolve(import.meta.dir, '..');
export interface RenderRequest {
host: Host;
/** null = the full loadout. */
selection: ModuleSelection | null;
outDir: string;
installRoot?: string | null;
model?: Model | null;
planMode?: boolean;
}
export interface LoadoutLock {
schema_version: 1;
loadout: string;
spec: string;
spec_sha256: string | null;
host: Host;
model: string | null;
install_root: string | null;
plan_mode: boolean;
gstack_version: string;
excluded: string[];
total_bytes: number;
files: Array<{ path: string; bytes: number; sha256: string }>;
}
function walk(dir: string, base = dir): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return walk(full, base);
return entry.isFile() ? [path.relative(base, full).split(path.sep).join('/')] : [];
}).sort();
}
/**
* References an excluded module leaves behind in the rendered text. A section
* counts as referenced by: its absolute path (`/<skill>/sections/<file>`, any
* file, so resolver-inlined cross-skill pointers count), an installed-relative
* pointer naming its skill, or, inside its own skill's directory, a bare
* `sections/<file>` or the inline-host "section inlined" phrase. An excluded
* skill counts as referenced by another skill naming its SKILL.md or one of
* its section files.
*/
export function danglingReferences(stage: string, selection: ModuleSelection): string[] {
const checks: Array<{ id: string; scope: string | null; test: (line: string) => boolean }> = [];
for (const id of selection.excluded) {
if (id.startsWith('section:')) {
const [skill, sectionId] = id.slice('section:'.length).split('/');
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, skill, 'sections', 'manifest.json'), 'utf-8'));
const file = (manifest.sections as Array<{ id: string; file: string }>).find(entry => entry.id === sectionId)!.file;
const stem = file.replace(/\.md$/, '');
checks.push(
{ id, scope: null, test: line => line.includes(`/${skill}/sections/${file}`) },
{ id, scope: null, test: line => line.includes(`sections/${file}\` relative to the installed`) && line.includes(`${skill}\``) },
{ id, scope: skill, test: line => line.includes(`sections/${file}`) || line.includes(`the \`${stem}\` section inlined`) },
);
} else if (id.startsWith('skill:')) {
const skill = id.slice('skill:'.length);
checks.push({ id, scope: null, test: line => line.includes(`/${skill}/SKILL.md`) || line.includes(`gstack-${skill}/SKILL.md`) });
const manifestPath = path.join(ROOT, skill, 'sections', 'manifest.json');
if (fs.existsSync(manifestPath)) {
for (const { file } of JSON.parse(fs.readFileSync(manifestPath, 'utf-8')).sections as Array<{ file: string }>) {
checks.push(
{ id, scope: null, test: line => line.includes(`/${skill}/sections/${file}`) },
{ id, scope: null, test: line => line.includes(`sections/${file}\` relative to the installed`) && line.includes(`${skill}\``) },
);
}
}
}
}
if (!checks.length) return [];
const found = new Set<string>();
for (const rel of walk(stage)) {
if (!rel.endsWith('.md')) continue;
const dirs = rel.split('/');
const lines = fs.readFileSync(path.join(stage, rel), 'utf-8').split('\n');
lines.forEach((line, index) => {
for (const check of checks) {
if (check.scope && !dirs.includes(check.scope) && !dirs.includes(`gstack-${check.scope}`)) continue;
if (check.test(line)) found.add(`${check.id}: referenced by ${rel}:${index + 1}`);
}
});
}
return [...found];
}
function owned(dir: string): boolean {
if (!fs.existsSync(dir)) return true;
return fs.readdirSync(dir).length === 0 || fs.existsSync(path.join(dir, LOCK_FILE));
}
export async function renderLoadout(request: RenderRequest): Promise<LoadoutLock> {
const final = path.resolve(request.outDir);
if (!owned(final)) {
throw new ModuleError('RENDER_DEST_NOT_OWNED', `${final} exists and holds no ${LOCK_FILE}, so it is not a gstack-modules render`, 'pass an empty or new --out-dir, or one an earlier gstack-modules render wrote');
}
const tag = `${process.pid}-${randomBytes(4).toString('hex')}`;
const stage = `${final}.stage-${tag}`;
const retired = `${final}.old-${tag}`;
fs.mkdirSync(path.dirname(final), { recursive: true });
try {
const result = await runGeneration({
host: request.host,
outputRoot: stage,
contentLinkRoot: final,
installRoot: request.installRoot ?? null,
model: request.model ?? null,
planMode: request.planMode ?? true,
modules: request.selection,
});
const errors = result.diagnostics.filter(d => d.kind === 'error').map(d => d.message);
if (result.exitCode !== 0 || errors.length) {
throw new ModuleError('RENDER_FAILED', `gen-skill-docs failed: ${errors.join('; ') || 'exit 1'}`, 'fix the named template or module, then rerun; side effects: none, safe to retry');
}
if (request.selection) {
const dangling = danglingReferences(stage, request.selection);
if (dangling.length) {
throw new ModuleError('MODULE_EXCLUSION_UNSAFE', dangling.slice(0, 5).join('; ') + (dangling.length > 5 ? ` (+${dangling.length - 5} more)` : ''), 'exclude the referencing module too, or guard the reference with {{IF_SECTION:id}} in its template; side effects: none, safe to retry');
}
}
const files = walk(stage).map(rel => {
const bytes = fs.readFileSync(path.join(stage, rel));
return { path: rel, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') };
});
const lock: LoadoutLock = {
schema_version: 1,
loadout: request.selection?.name ?? 'full',
spec: request.selection?.spec ?? 'full',
spec_sha256: request.selection?.specSha256 ?? null,
host: request.host,
model: request.model ?? getHostConfig(request.host).defaultModel ?? null,
install_root: request.installRoot ?? null,
plan_mode: request.planMode ?? true,
gstack_version: fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf-8').trim(),
excluded: [...(request.selection?.excluded ?? [])].sort(),
total_bytes: files.reduce((sum, f) => sum + f.bytes, 0),
files,
};
fs.writeFileSync(path.join(stage, LOCK_FILE), JSON.stringify(lock, null, 2) + '\n');
if (!owned(final)) {
throw new ModuleError('RENDER_DEST_NOT_OWNED', `${final} changed during the render and now holds no ${LOCK_FILE}`, 'pass an empty or new --out-dir, or one an earlier gstack-modules render wrote');
}
if (fs.existsSync(final)) fs.renameSync(final, retired);
try {
fs.renameSync(stage, final);
} catch (error) {
if (fs.existsSync(retired)) fs.renameSync(retired, final);
throw error;
}
fs.rmSync(retired, { recursive: true, force: true });
return lock;
} finally {
fs.rmSync(stage, { recursive: true, force: true });
}
}
+236
View File
@@ -0,0 +1,236 @@
/**
* 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:<dir>`), one carved section
* (`section:<skill>/<id>`, from <skill>/sections/manifest.json), one model
* overlay (`overlay:<name>`, from model-overlays/) or one preamble piece
* (`preamble:<id>`, 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<string, Rung>;
rungs: Record<(typeof RUNGS)[number], string[]>;
pinned: string[];
loadouts: Record<string, LoadoutDef>;
default: Record<Rung, string>;
}
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<ModuleEntry, 'pinned'>[] = [];
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<string>();
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<string>; tierMax?: number } {
const def = manifest.loadouts[name];
const inherited = def.base ? excludedBy(manifest, def.base) : { excluded: new Set<string>() };
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 (`<loadout>` or `<base>,-id,+id`) and its sha256, for the lock file. */
spec: string;
specSha256: string;
}
/**
* Resolve `--loadout <name>` or `--modules <base>,-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 <name> or --modules <base>,-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 <base>,-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 -<module id> or +<module id>`, 'write the spec as <loadout>,-<id>,+<id>; 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));
}
+4 -3
View File
@@ -24,9 +24,10 @@ const OVERLAY_DIR = path.resolve(import.meta.dir, '../../model-overlays');
const INHERIT_RE = /^\s*\{\{INHERIT:([a-z0-9-]+(?:\.[0-9]+)*)\}\}\s*\n/;
export function readOverlay(model: string, seen: Set<string> = new Set()): string {
export function readOverlay(model: string, seen: Set<string> = new Set(), excluded: ReadonlySet<string> = new Set()): string {
if (seen.has(model)) return ''; // cycle guard
seen.add(model);
if (excluded.has(`overlay:${model}`)) return '';
const filePath = path.join(OVERLAY_DIR, `${model}.md`);
if (!fs.existsSync(filePath)) return '';
@@ -36,7 +37,7 @@ export function readOverlay(model: string, seen: Set<string> = new Set()): strin
if (!match) return raw.trim();
const baseModel = match[1];
const base = readOverlay(baseModel, seen);
const base = readOverlay(baseModel, seen, excluded);
const rest = raw.replace(INHERIT_RE, '').trim();
if (!base) return rest;
@@ -46,7 +47,7 @@ export function readOverlay(model: string, seen: Set<string> = new Set()): strin
export function generateModelOverlay(ctx: TemplateContext): string {
if (!ctx.model) return '';
const content = readOverlay(ctx.model);
const content = readOverlay(ctx.model, new Set(), ctx.modules?.excluded);
if (!content) return '';
const precedence = ctx.model === 'gpt-5.6-sol'
+52 -37
View File
@@ -72,6 +72,47 @@ export function generatePreamble(ctx: TemplateContext): string {
return preambleSections(ctx).join('\n\n');
}
interface PreamblePiece {
/** Module id suffix (`preamble:<id>`); null for the model overlay, whose module id is `overlay:<model>`. */
id: string | null;
minTier: number;
when?: (ctx: TemplateContext) => boolean;
render: (ctx: TemplateContext, tier: number) => string;
}
/**
* The preamble's pieces in render order. AskUserQuestion Format renders BEFORE
* the model overlay so the pacing rule is the ambient default; the overlay's
* nudges land as subordinate patches (Opus 4.7 absorbs the first pacing
* directive it hits; reversing this regresses plan-review cadence, v1.6.4.0).
* Plan-mode semantics stays right after bash (so _SESSION_ID / _BRANCH / _TEL
* are live) and before every onboarding gate. The 8 one-time onboarding
* generators live in bin/gstack-skill-start's instruction layer (plan Q2/OV6/F5).
*/
export const PREAMBLE_PIECES: readonly PreamblePiece[] = [
{ id: 'bash', minTier: 1, render: generatePreambleBash },
{ id: 'make-pdf-setup', minTier: 1, when: ctx => ctx.skillName === 'make-pdf', render: generateMakePdfSetup },
{ id: 'plan-mode-info', minTier: 1, render: generatePlanModeInfo },
{ id: 'upgrade-check', minTier: 1, render: generateUpgradeCheck },
{ id: 'brain-health', minTier: 1, render: generateBrainHealthInstruction },
{ id: 'ask-user-format', minTier: 2, render: generateAskUserFormat },
{ id: 'brain-sync', minTier: 1, render: generateBrainSyncBlock },
{ id: null, minTier: 1, render: generateModelOverlay },
{ id: 'voice', minTier: 1, render: generateVoiceDirective },
{ id: 'context-recovery', minTier: 2, render: generateContextRecovery },
{ id: 'writing-style', minTier: 2, render: generateWritingStyle },
{ id: 'completeness', minTier: 2, render: generateCompletenessSection },
{ id: 'confusion', minTier: 2, render: generateConfusionProtocol },
{ id: 'evidence', minTier: 2, render: generateEvidenceDirective },
{ id: 'context-health', minTier: 2, render: generateContextHealth },
{ id: 'question-tuning', minTier: 2, render: generateQuestionTuning },
{ id: 'repo-mode', minTier: 3, render: () => generateRepoModeSection() },
{ id: 'search-before-building', minTier: 3, render: generateSearchBeforeBuildingSection },
{ id: 'completion-status', minTier: 1, render: generateCompletionStatus },
];
export const PREAMBLE_MODULE_IDS: readonly string[] = PREAMBLE_PIECES.flatMap(p => (p.id ? [`preamble:${p.id}`] : []));
/** The preamble's generator outputs in render order, empty ones dropped. */
export function preambleSections(ctx: TemplateContext): string[] {
const tier = ctx.preambleTier;
@@ -84,41 +125,15 @@ export function preambleSections(ctx: TemplateContext): string[] {
if (tier < 1 || tier > 4) {
throw new Error(`Invalid preamble-tier: ${tier} in ${ctx.tmplPath}. Must be 1-4.`);
}
const sections = [
generatePreambleBash(ctx),
...(ctx.skillName === 'make-pdf' ? [generateMakePdfSetup(ctx)] : []),
// Plan-mode-skill semantics stays near the top: after bash (so _SESSION_ID /
// _BRANCH / _TEL env vars are live) and before all onboarding gates so
// models read the authoritative "AskUserQuestion satisfies plan mode's
// end-of-turn" rule before any other instruction. Renders for all skills
// (not interactive-gated) on hosts whose capabilities declare planMode.
generatePlanModeInfo(ctx),
generateUpgradeCheck(ctx),
// Phase 2: the 8 one-time onboarding generators (lake-intro, telemetry-
// prompt, proactive-prompt, first-run-guidance, routing-injection,
// vendoring-deprecation, spawned-session-check, writing-style-migration)
// moved into bin/gstack-skill-start's instruction-emission layer — their
// text appears at runtime only when its gate fires (plan Q2/OV6/F5).
generateBrainHealthInstruction(ctx),
// AskUserQuestion Format renders BEFORE the model overlay so the pacing rule
// is the ambient default; the overlay's behavioral nudges land as subordinate
// patches. Opus 4.7 reads top-to-bottom and absorbs the first pacing directive
// it hits; reversing this order regresses plan-review cadence (v1.6.4.0 bug).
...(tier >= 2 ? [generateAskUserFormat(ctx)] : []),
generateBrainSyncBlock(ctx),
generateModelOverlay(ctx),
generateVoiceDirective(ctx, tier),
...(tier >= 2 ? [
generateContextRecovery(ctx),
generateWritingStyle(ctx),
generateCompletenessSection(ctx),
generateConfusionProtocol(ctx),
generateEvidenceDirective(ctx),
generateContextHealth(ctx),
generateQuestionTuning(ctx),
] : []),
...(tier >= 3 ? [generateRepoModeSection(), generateSearchBeforeBuildingSection(ctx)] : []),
generateCompletionStatus(ctx),
];
return sections.filter(s => s && s.trim().length > 0);
const filter = ctx.modules;
const pieceTier = (piece: PreamblePiece): number => {
if (!filter || piece.id === null || filter.pinned.has(`preamble:${piece.id}`)) return tier;
return Math.min(tier, filter.preambleTierMax ?? tier);
};
return PREAMBLE_PIECES
.filter(piece => (piece.when?.(ctx) ?? true)
&& !(piece.id && filter?.excluded.has(`preamble:${piece.id}`))
&& pieceTier(piece) >= piece.minTier)
.map(piece => piece.render(ctx, pieceTier(piece)))
.filter(s => s && s.trim().length > 0);
}
+19 -1
View File
@@ -108,6 +108,9 @@ export const SECTION: ResolverFn = (ctx: TemplateContext, args?: string[]): stri
const id = args?.[0];
if (!id) throw new Error('{{SECTION:id}} requires a section id');
const entry = findSection(ctx.skillName, id);
if (ctx.modules?.excluded.has(`section:${ctx.skillName}/${id}`)) {
return `> Not loaded in the ${ctx.modules!.name} loadout: skip ${entry.trigger}. Do not offer it, and treat it as not run.`;
}
if (usesLazySections(ctx.host, ctx.skillName)) {
if (ctx.skillName === 'qa' || ctx.skillName === 'qa-only') {
@@ -129,6 +132,21 @@ export const SECTION: ResolverFn = (ctx: TemplateContext, args?: string[]): stri
return fs.readFileSync(tmplPath, 'utf-8').trimEnd();
};
const SECTION_GUARD_RE = /\{\{IF_SECTION:([a-z0-9-]+)\}\}([\s\S]*?)\{\{\/IF_SECTION\}\}/g;
/**
* {{IF_SECTION:id}}…{{/IF_SECTION}}: skeleton text that belongs to one of this
* skill's sections (an offer, a self-check naming it). The markers vanish and
* the text stays when the section loads, so `full` bytes are unchanged; a
* loadout that excludes the section drops the text with it.
*/
export function applySectionGuards(content: string, ctx: TemplateContext): string {
return content.replace(SECTION_GUARD_RE, (_match, id: string, inner: string) => {
findSection(ctx.skillName, id);
return ctx.modules?.excluded.has(`section:${ctx.skillName}/${id}`) ? '' : inner;
});
}
/**
* {{SECTION_INDEX:skill}} — situation→section table from the passive manifest.
* Lazy skills only; an index would be noise for inlined skills.
@@ -149,7 +167,7 @@ export const SECTION_INDEX: ResolverFn = (ctx: TemplateContext, args?: string[])
'|------|-------------------|',
];
for (const s of manifest.sections) {
if (s.runner_only) continue;
if (s.runner_only || ctx.modules?.excluded.has(`section:${skill}/${s.id}`)) continue;
const reference = skill === 'qa' || skill === 'qa-only' ? sectionPath(ctx, skill, s.id) : `\`sections/${s.file}\``;
if (skill === 'review' && s.id === 'review-army') {
lines.push(`| Select surfaces and read QA methods | Inline in [Step 4](#step-4-critical-pass-core-review); setup and probes run in Step 4.7 |`);
+15
View File
@@ -125,6 +125,21 @@ export interface TemplateContext {
* still apply.
*/
planMode?: boolean;
/**
* Module loadout filter (modules/loadouts.json, gen-skill-docs --loadout/--modules).
* Undefined = the `full` loadout: every module renders and committed bytes are unchanged.
*/
modules?: ModuleFilter;
}
/** A resolved loadout: which module ids it leaves out, and which ids can never be left out. */
export interface ModuleFilter {
/** Loadout name, or `adhoc` for a --modules spec. */
name: string;
excluded: ReadonlySet<string>;
pinned: ReadonlySet<string>;
/** Unpinned preamble pieces render as if the skill's preamble-tier were at most this. */
preambleTierMax?: number;
}
/** Resolver function signature. args is populated for parameterized placeholders like {{INVOKE_SKILL:name}}. */