mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
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:
1 parent
4729f4752b
commit
f4f16e83e0
26 files changed
+1272
-57
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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` };
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 |`);
|
||||
|
||||
@@ -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}}. */
|
||||
|
||||
Reference in new issue
Block a user