From 00bd08f108b3f7b4810e29fe935e453ee269690a Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 16:46:20 +0000 Subject: [PATCH 1/7] 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> --- CONTRIBUTING.md | 1 + bin/gstack-modules | 8 + docs/loadouts.md | 40 +++++ docs/troubleshooting.md | 44 +++++ modules/loadouts.json | 62 +++++++ modules/loadouts.schema.json | 159 +++++++++++++++++ office-hours/SKILL.md.tmpl | 8 +- plan-design-review/SKILL.md.tmpl | 4 +- scripts/gen-skill-docs.ts | 30 +++- scripts/modules-cli.ts | 86 ++++++++++ scripts/modules-render.ts | 163 ++++++++++++++++++ scripts/modules.ts | 227 +++++++++++++++++++++++++ scripts/resolvers/model-overlay.ts | 7 +- scripts/resolvers/preamble.ts | 89 ++++++---- scripts/resolvers/sections.ts | 22 ++- scripts/resolvers/types.ts | 19 +++ test/fixtures/module-size-ratchet.json | 3 + test/module-loadouts.test.ts | 219 ++++++++++++++++++++++++ 18 files changed, 1139 insertions(+), 52 deletions(-) create mode 100755 bin/gstack-modules create mode 100644 docs/loadouts.md create mode 100644 modules/loadouts.json create mode 100644 modules/loadouts.schema.json create mode 100644 scripts/modules-cli.ts create mode 100644 scripts/modules-render.ts create mode 100644 scripts/modules.ts create mode 100644 test/module-loadouts.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37eece64e..1a0a72f5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -205,6 +205,7 @@ the new defaults. | Validate periodic cases on a branch in CI | `gh workflow run evals-periodic.yml --ref ` (periodic lane only) | CI runners + API spend | Pushed branch, `gh` with workflow rights | | Validate periodic cases plus the weekly gate census on a branch | `gh workflow run evals-periodic.yml --ref -f include_gate_census=true` | CI runners + API spend | Pushed branch, `gh` with workflow rights | | Run the opt-in ML, gitleaks and Swift checks | `gh workflow run platform-qualification.yml --ref ` | Free CI runners | Pushed branch, `gh` with workflow rights | +| Render one module loadout as an eval arm ([loadouts](docs/loadouts.md)) | `bin/gstack-modules render --host claude --modules full,-section:/ --out-dir ` (`--loadout full` for the control) | Free, about 20 s per host | Bun 1.4.2 | | Look at past local eval runs | `bun run eval:list` / `eval:compare` / `eval:summary` | Free | Local eval history | | See a case's pass rate across recent weekly runs | `bun run eval:pass-rates --case ` | Free | `gh` with repo read access | | Measure one red case alone against the ship bar (MEETS 9/10) | `bun run scripts/ship-measure.ts measure --case --round baseline` ([bar](docs/TESTING_INTERNALS.md#ship-measure)) | API spend for 10 trials (12 for behavior); asks above $2/trial, stops at $25/case | Same as paid coverage | diff --git a/bin/gstack-modules b/bin/gstack-modules new file mode 100755 index 000000000..fa3f98d8d --- /dev/null +++ b/bin/gstack-modules @@ -0,0 +1,8 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +import { modulesMain } from '../scripts/modules-cli.ts'; + +void modulesMain(process.argv.slice(2)).then(result => { + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + process.exitCode = result.code; +}); diff --git a/docs/loadouts.md b/docs/loadouts.md new file mode 100644 index 000000000..3fec04d3b --- /dev/null +++ b/docs/loadouts.md @@ -0,0 +1,40 @@ +# Module loadouts + +gstack can render a smaller or larger set of its instructions for a given model. Each toggleable piece is a **module** with an id. A **loadout** is a named set of modules, and `modules/loadouts.json` maps each model **rung** to a loadout. Today every rung loads `full`, which is the same render gstack has always shipped. A loadout changes only when the shared skill eval (GSTA-2) measures a smaller one that matches `full` within its preregistered margin. + +Install-time model detection, which picks a loadout for the model you run, ships with the first loadout that is not `full`. Until then, loadouts are an eval tool: `bin/gstack-modules render` produces any loadout as a byte-exact tree with a lock file. + +## Terms + +| Term | Meaning | +|---|---| +| Module | One of four kinds. `skill:` is a whole skill. `section:/` is one entry of `/sections/manifest.json`. `overlay:` is one `model-overlays/.md`; excluding a base overlay also drops the text that inherits it. `preamble:` is one piece of the shared preamble (`PREAMBLE_PIECES` in `scripts/resolvers/preamble.ts`). | +| Pinned | A module no loadout can remove where `full` renders it: the start command, plan-mode rules, the question format, completion status, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade`. A skill's `preamble-tier` still applies, so a tier-1 skill has no question-format section in any loadout. | +| Rung | How much scaffolding a model gets: `frontier`, `strong`, `mid`, `light`, or `unknown`. Model ids are matched after lowercasing and stripping a `provider/` prefix (`anthropic/claude-haiku-5-5` is `mid`). Rungs are separate from the frontier/smart tiers in `lib/model-catalog.ts`, which choose which model runs a role. | +| Loadout | `{ base, exclude, include, preamble_tier_max, note }`. `full` (include `*`) is always present. An unknown model gets `default.unknown`, which is `full`. | + +## Commands + +```bash +bin/gstack-modules list [--json] [--pinned] # every module id, kind, source, pin, source bytes and sha256 +bin/gstack-modules validate # check modules/loadouts.json against the catalog +bin/gstack-modules render --host claude --loadout full --out-dir ~/arms/control +bin/gstack-modules render --host claude \ + --modules 'full,-section:office-hours/second-opinion,-section:office-hours/visual-design' \ + --out-dir ~/arms/oh-lean +``` + +`render` writes the whole host tree into a staging directory beside `--out-dir`. It checks that no rendered file still points at an excluded module, writes `loadout.lock.json` last (loadout name or `adhoc`, the normalized spec and its sha256, host, model, every excluded id, and the sha256 and byte count of every file), then swaps the result into place. A failure leaves an earlier render at `--out-dir` untouched. Claude section links name `--out-dir`, so point the eval harness at that directory. Pass the same `--model`, `--install-root` and `--plan-mode` the install under test uses, so the arms differ only by the modules. Codex renders default to the host's generic `gpt` overlay, while `./setup` uses `gpt-6-astra`. + +`bun run gen:skill-docs --loadout ` and `--modules ` apply the same filter to an ordinary generation run. A non-`full` render adds `` to each SKILL.md header. + +## Building an eval arm + +1. Render the control with `--loadout full` and each arm with `--modules full,-,…`. Every refusal names its code and fix ([troubleshooting](troubleshooting.md#module-loadouts-gstack-modules)). +2. Preregister the lock files' `spec_sha256` and per-file sha256 values with the eval. +3. Excluding a section removes its STOP pointer, its Section-index row and its file. The step becomes `> Not loaded in the loadout: skip . Do not offer it, and treat it as not run.` Skeleton text that belongs to the section, such as office-hours' Phase 3.5 offer, sits inside `{{IF_SECTION:}}…{{/IF_SECTION}}` in the template and goes with it. +4. The autoplan runner (`gstack-autoplan`) reads review methodology from the source tree, not from a rendered loadout, so autoplan-runner arms always measure `full` methodology. + +## Changing a loadout + +Add or edit an entry under `loadouts` and cite the GSTA-2 run (preregistration, margin, per-rung result) in its `note` and in the PR body. A model-specific loadout that wins a preregistered comparison is allowed even if it breaks the expected nesting (light ⊇ mid ⊇ strong). Committed SKILL.md files are always the `full` render, so the parity suite, the SKILL.md size guard, the size ledger and the catalog and context budgets keep measuring `full`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1ffa23542..5208d68bd 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5141,3 +5141,47 @@ QM's admin API refused or did not answer. gstack lists packs before it creates o QM registration does not work yet. QM's skill-pack import clones the whole repository and reads every file before it picks the skills, and by default it refuses a pack over 32 MiB or 5,000 files. The gstack repository is larger (about 73 MiB in 4,300 files), so gstack checks the size first and sends nothing. When QM itself refuses (a QM with other limits), the pack it created stays in the org without skills: `gstack-harness unregister qm --remote` archives it. Meanwhile, give a QM agent gstack by pasting the output of `gstack-harness register generic --print` into its instructions. + +## Module loadouts (gstack-modules) + +These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modules` (guide: [loadouts.md](loadouts.md)). Each one exits 2 before anything is written, and none falls back to the `full` loadout: an eval arm that silently rendered `full` would measure the control. + + +### `LOADOUTS_INVALID: modules/loadouts.json …` + +**Meaning.** `modules/loadouts.json` is unreadable, or names a module, base loadout, rung or default that does not exist, excludes a pinned module, or maps one model id to two rungs. **Fix.** Correct the named entries, then run `bin/gstack-modules validate` until it prints `LOADOUTS: ok`; `git checkout -- modules/loadouts.json` restores the committed file. + + +### `LOADOUT_UNKNOWN: "" is not a loadout (valid: …)` + +**Meaning.** `--loadout` or the first token of `--modules` names no loadout in `modules/loadouts.json`. **Fix.** Use a name from the `valid:` list (`bin/gstack-modules list` prints them); `full` is always present. + + +### `MODULE_SPEC_INVALID: …` + +**Meaning.** A `--modules` token is not `-` or `+`, both `--loadout` and `--modules` were given, or a render flag is missing or malformed. **Fix.** Write the spec as `,-,+` (for example `full,-section:office-hours/second-opinion`) and pass the flags `bin/gstack-modules --help` lists. + + +### `MODULE_UNKNOWN: "" is not a module` + +**Meaning.** The id names no skill, section, overlay or preamble piece. **Fix.** Copy the id from `bin/gstack-modules list`. + + +### `MODULE_PINNED: is pinned and can never be excluded` + +**Meaning.** The spec removes a module the safety contract needs: the start command (`preamble:bash`), plan-mode rules, the question format, completion status, the unattended profile, /ship's review gates, the router or `/gstack-upgrade`. **Fix.** Drop `-` from the spec; `bin/gstack-modules list --pinned` prints every pinned id. + + +### `MODULE_EXCLUSION_UNSAFE: : referenced by :` + +**Meaning.** The rendered tree still points at a module the loadout left out, so an agent following it would read a file that is not there. **Fix.** Exclude the referencing module as well, or wrap the referencing skeleton text in `{{IF_SECTION:}}…{{/IF_SECTION}}` in its template (the text renders unchanged when the section loads). Nothing was published; rerun after the change. + + +### `RENDER_FAILED: gen-skill-docs failed: …` + +**Meaning.** Generation itself failed for this loadout (an unresolved placeholder, an oversized skill, a missing section). **Fix.** Fix the named template or module and rerun. The staging directory was removed and `--out-dir` is unchanged. + + +### `RENDER_DEST_NOT_OWNED: exists and holds no loadout.lock.json` + +**Meaning.** `--out-dir` is a non-empty directory that an earlier `gstack-modules render` did not write, so replacing it could delete someone's files. **Fix.** Pass a new or empty directory, or the directory of an earlier render. diff --git a/modules/loadouts.json b/modules/loadouts.json new file mode 100644 index 000000000..9208dfa47 --- /dev/null +++ b/modules/loadouts.json @@ -0,0 +1,62 @@ +{ + "$schema": "./loadouts.schema.json", + "version": 1, + "note": "Per-model loadouts (GSTA-140). Every rung loads full until a GSTA-2 measurement shows a smaller loadout matches full within its preregistered margin; each change cites that run in the loadout's note. Module ids: bin/gstack-modules list. Guide: docs/loadouts.md.", + "aliases": { + "opus": "strong", + "sonnet": "strong", + "haiku": "mid" + }, + "rungs": { + "frontier": [ + "claude-fable-5*", + "gpt-6-astra*" + ], + "strong": [ + "claude-opus-5*", + "claude-sonnet-5*", + "gpt-6.1-sol*", + "gpt-6-sol*", + "gpt-5.6-sol*", + "gemini-4-argon*" + ], + "mid": [ + "claude-haiku-5*", + "glm-5.3", + "gpt-6.1-terra*", + "gemini-3.8-flash*", + "grok-4*", + "kimi-k3*", + "deepseek-v4-pro*", + "qwen3.8-max*" + ], + "light": [ + "deepseek-v4.1-flash*", + "glm-5.3-flash*", + "gpt-6-luna*", + "qwen3.8-flash*", + "minimax-m3*" + ] + }, + "pinned": [ + "preamble:plan-mode-info", + "preamble:ask-user-format", + "preamble:completion-status", + "section:autoplan/unattended", + "section:ship/review-army", + "section:ship/adversarial" + ], + "loadouts": { + "full": { + "include": "*", + "note": "Every module: today's render." + } + }, + "default": { + "frontier": "full", + "strong": "full", + "mid": "full", + "light": "full", + "unknown": "full" + } +} diff --git a/modules/loadouts.schema.json b/modules/loadouts.schema.json new file mode 100644 index 000000000..75024a25c --- /dev/null +++ b/modules/loadouts.schema.json @@ -0,0 +1,159 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "gstack module loadouts", + "description": "Rungs, the model-id to rung table, pinned module ids and named loadouts. Validated by scripts/modules.ts validateLoadouts (bin/gstack-modules validate, test/module-loadouts.test.ts).", + "type": "object", + "required": [ + "version", + "aliases", + "rungs", + "pinned", + "loadouts", + "default" + ], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string" + }, + "version": { + "const": 1 + }, + "note": { + "type": "string" + }, + "aliases": { + "type": "object", + "additionalProperties": { + "enum": [ + "frontier", + "strong", + "mid", + "light" + ] + } + }, + "rungs": { + "type": "object", + "additionalProperties": false, + "required": [ + "frontier", + "strong", + "mid", + "light" + ], + "properties": { + "frontier": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9.-]+\\*?$" + } + }, + "strong": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9.-]+\\*?$" + } + }, + "mid": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9.-]+\\*?$" + } + }, + "light": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9.-]+\\*?$" + } + } + } + }, + "pinned": { + "type": "array", + "items": { + "type": "string" + } + }, + "loadouts": { + "type": "object", + "required": [ + "full" + ], + "propertyNames": { + "pattern": "^[a-z0-9-]+$" + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "base": { + "type": "string" + }, + "include": { + "oneOf": [ + { + "const": "*" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + } + }, + "preamble_tier_max": { + "enum": [ + 1, + 2, + 3, + 4 + ] + }, + "note": { + "type": "string" + } + } + } + }, + "default": { + "type": "object", + "additionalProperties": false, + "required": [ + "frontier", + "strong", + "mid", + "light", + "unknown" + ], + "properties": { + "frontier": { + "type": "string" + }, + "strong": { + "type": "string" + }, + "mid": { + "type": "string" + }, + "light": { + "type": "string" + }, + "unknown": { + "type": "string" + } + } + } + } +} diff --git a/office-hours/SKILL.md.tmpl b/office-hours/SKILL.md.tmpl index 7cda00564..de7f761c0 100644 --- a/office-hours/SKILL.md.tmpl +++ b/office-hours/SKILL.md.tmpl @@ -228,13 +228,13 @@ Use AskUserQuestion to confirm. If the user disagrees with a premise, revise und --- -## Phase 3.5: Cross-Model Second Opinion (optional) +{{IF_SECTION:second-opinion}}## Phase 3.5: Cross-Model Second Opinion (optional) Use AskUserQuestion: "Want a second opinion from an independent AI perspective? It reviews your problem statement, key answers, premises and landscape findings from a structured summary, without this conversation. Usually 2-5 minutes." A) Yes, get a second opinion. B) No, proceed to alternatives. If B: skip Phase 3.5 and remember the second opinion did NOT run (the design doc, founder signals and Phase 4 depend on it). If A: -{{SECTION:second-opinion}} +{{/IF_SECTION}}{{SECTION:second-opinion}} --- @@ -274,10 +274,10 @@ Emit ONE AskUserQuestion that lists every alternative (A/B and optionally C) as --- -## Phase 4.25: Visual design (UI ideas only) +{{IF_SECTION:visual-design}}## Phase 4.25: Visual design (UI ideas only) If the approach approved in Phase 4 has no user-facing UI (screens, pages, forms, dashboards, interactive elements), skip to Phase 4.5. -{{SECTION:visual-design}} +{{/IF_SECTION}}{{SECTION:visual-design}} --- diff --git a/plan-design-review/SKILL.md.tmpl b/plan-design-review/SKILL.md.tmpl index b9258d176..52ce41d51 100644 --- a/plan-design-review/SKILL.md.tmpl +++ b/plan-design-review/SKILL.md.tmpl @@ -288,10 +288,10 @@ Exit 0: show the image at the printed `outputPath` via the Read tool. Exit 2 or {{SECTION:review-sections}} -## Section self-check (before you finish) +{{IF_SECTION:review-sections}}## Section self-check (before you finish) Confirm you Read the review section the Section index named, and executed all 7 design passes, the required outputs, and the review report in full. If you produced findings or the review report from memory without Reading `sections/review-sections.md`, stop and Read it now. -Before summaries, review logs or next-step menus, run approval check 0 below. +{{/IF_SECTION}}Before summaries, review logs or next-step menus, run approval check 0 below. {{EXIT_PLAN_MODE_GATE}} diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 9ccf36502..97dfae1a0 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -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 { 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,7 @@ 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') }); 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 +165,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 +675,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 +731,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 +861,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 ? `\n` : ''); const fmEnd = content.indexOf('---', content.indexOf('---') + 3); if (fmEnd !== -1) { const insertAt = content.indexOf('\n', fmEnd) + 1; @@ -859,7 +878,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 +977,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise {}); @@ -1023,6 +1043,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise (--loadout | --modules ,-id,+id) --out-dir + * [--install-root ] [--model ] [--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 (--loadout | --modules ,-id,+id) --out-dir + [--install-root ] [--model ] [--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 { + 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 '); + 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` }; + } +} diff --git a/scripts/modules-render.ts b/scripts/modules-render.ts new file mode 100644 index 000000000..484e6d48a --- /dev/null +++ b/scripts/modules-render.ts @@ -0,0 +1,163 @@ +/** + * 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 (`//sections/`, 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/` or the inline-host "section inlined" phrase. An excluded + * skill counts as referenced by another skill naming its SKILL.md. + */ +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`) }); + } + } + if (!checks.length) return []; + const found = new Set(); + 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 { + 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 (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 }); + } +} diff --git a/scripts/modules.ts b/scripts/modules.ts new file mode 100644 index 000000000..b5ad5fd3d --- /dev/null +++ b/scripts/modules.ts @@ -0,0 +1,227 @@ +/** + * Module loadouts (GSTA-140, PR 1a): the toggleable units of gstack and the + * named sets of them a render can load. + * + * A module is a skill (`skill:`), one carved section + * (`section:/`, from /sections/manifest.json), one model + * overlay (`overlay:`, from model-overlays/) or one preamble piece + * (`preamble:`, from PREAMBLE_PIECES). The catalog is derived from those + * sources; modules/loadouts.json is the only hand-written file and holds the + * rungs, the model-id to rung table, the pinned ids and the loadouts. + * + * Every error is a ModuleError with a stable code, a fix line and a + * docs/troubleshooting.md anchor. Nothing here ever falls back to `full` on + * bad input: an eval arm that silently rendered `full` would measure the + * control. + */ +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { discoverTemplates } from './discover-skills'; +import { PREAMBLE_MODULE_IDS } from './resolvers/preamble'; +import type { ModuleFilter } from './resolvers/types'; + +const ROOT = path.resolve(import.meta.dir, '..'); +export const LOADOUTS_FILE = 'modules/loadouts.json'; +export const RUNGS = ['frontier', 'strong', 'mid', 'light'] as const; +export type Rung = (typeof RUNGS)[number] | 'unknown'; +export const FULL = 'full'; +/** The router and the way back can never be left out (same rule as --disabled-skills). */ +export const IMPLICIT_PINS = ['skill:gstack', 'skill:gstack-upgrade', 'preamble:bash']; +const NAME_RE = /^[a-z0-9-]+$/; + +export type ModuleKind = 'skill' | 'section' | 'overlay' | 'preamble'; +export interface ModuleEntry { + id: string; + kind: ModuleKind; + /** Repository-relative source file. */ + source: string; + pinned: boolean; +} + +export interface LoadoutDef { + base?: string; + include?: '*' | string[]; + exclude?: string[]; + preamble_tier_max?: number; + note?: string; +} + +export interface LoadoutsManifest { + version: 1; + aliases: Record; + rungs: Record<(typeof RUNGS)[number], string[]>; + pinned: string[]; + loadouts: Record; + default: Record; +} + +export type ModuleErrorCode = + | 'LOADOUTS_INVALID' + | 'LOADOUT_UNKNOWN' + | 'MODULE_SPEC_INVALID' + | 'MODULE_UNKNOWN' + | 'MODULE_PINNED' + | 'MODULE_EXCLUSION_UNSAFE' + | 'RENDER_FAILED' + | 'RENDER_DEST_NOT_OWNED'; + +export class ModuleError extends Error { + constructor(readonly code: ModuleErrorCode, problem: string, readonly fix: string) { + super(`${problem}; fix: ${fix} (${code}; docs/troubleshooting.md#${code.toLowerCase().replaceAll('_', '-')})`); + } +} + +const clamp = (value: string) => JSON.stringify(value.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 120)); + +export function loadLoadouts(root = ROOT): LoadoutsManifest { + const file = path.join(root, LOADOUTS_FILE); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')) as LoadoutsManifest; + } catch (error) { + throw new ModuleError('LOADOUTS_INVALID', `${LOADOUTS_FILE} is unreadable or not JSON (${(error as Error).message})`, `restore it with git checkout -- ${LOADOUTS_FILE}`); + } +} + +/** Every module id with its kind and source, derived from the files that define them. */ +export function moduleCatalog(manifest: LoadoutsManifest, root = ROOT): ModuleEntry[] { + const pinned = new Set([...manifest.pinned, ...IMPLICIT_PINS]); + const entries: Omit[] = []; + for (const { tmpl } of discoverTemplates(root)) { + const dir = path.dirname(tmpl); + entries.push({ id: `skill:${dir === '.' ? 'gstack' : dir}`, kind: 'skill', source: tmpl }); + const manifestPath = path.join(root, dir, 'sections', 'manifest.json'); + if (dir === '.' || !fs.existsSync(manifestPath)) continue; + const sections = (JSON.parse(fs.readFileSync(manifestPath, 'utf-8')).sections ?? []) as Array<{ id: string; file: string }>; + for (const s of sections) entries.push({ id: `section:${dir}/${s.id}`, kind: 'section', source: `${dir}/sections/${s.file}.tmpl` }); + } + for (const file of fs.readdirSync(path.join(root, 'model-overlays')).filter(f => f.endsWith('.md')).sort()) { + entries.push({ id: `overlay:${file.slice(0, -3)}`, kind: 'overlay', source: `model-overlays/${file}` }); + } + for (const id of PREAMBLE_MODULE_IDS) entries.push({ id, kind: 'preamble', source: 'scripts/resolvers/preamble.ts' }); + return entries.map(e => ({ ...e, pinned: pinned.has(e.id) })); +} + +export function normalizeModelId(raw: string): string { + return raw.trim().toLowerCase().replace(/\[[^\]]*\]$/, '').trim().replace(/^[a-z0-9-]+\//, ''); +} + +const matches = (pattern: string, id: string) => (pattern.endsWith('*') ? id.startsWith(pattern.slice(0, -1)) : id === pattern); + +/** The rung a model id belongs to: aliases first, then the first rung whose pattern matches. */ +export function rungFor(manifest: LoadoutsManifest, raw: string): Rung { + const id = normalizeModelId(raw); + if (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 ?? {}; + if (!loadouts[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 && !loadouts[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}`); + if (def.preamble_tier_max !== undefined && ![1, 2, 3, 4].includes(def.preamble_tier_max)) problems.push(`loadouts.${name}.preamble_tier_max must be 1-4`); + const seen = new Set(); + for (let at: string | undefined = name; at && loadouts[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 (!loadouts[manifest.default?.[rung]]) problems.push(`default.${rung} names no loadout (${clamp(String(manifest.default?.[rung]))})`); + } + for (const raw of corpus) { + const id = normalizeModelId(raw); + const hits = RUNGS.filter(rung => manifest.rungs[rung]?.some(p => matches(p, id))); + if (hits.length > 1) problems.push(`model ${id} matches more than one rung (${hits.join(', ')})`); + } + return problems; +} + +function assertValid(manifest: LoadoutsManifest, catalog: ModuleEntry[]): void { + const problems = validateLoadouts(manifest, catalog); + if (problems.length) throw new ModuleError('LOADOUTS_INVALID', `${LOADOUTS_FILE}: ${problems.join('; ')}`, 'fix the named entries, then run gstack-modules validate'); +} + +function excludedBy(manifest: LoadoutsManifest, name: string): { excluded: Set; tierMax?: number } { + const def = manifest.loadouts[name]; + const inherited = def.base ? excludedBy(manifest, def.base) : { excluded: new Set() }; + const excluded = new Set([...inherited.excluded, ...(def.exclude ?? [])]); + if (Array.isArray(def.include)) for (const id of def.include) excluded.delete(id); + return { excluded, tierMax: def.preamble_tier_max ?? inherited.tierMax }; +} + +export interface ModuleSelection extends ModuleFilter { + /** The normalized spec (`` or `,-id,+id`) and its sha256, for the lock file. */ + spec: string; + specSha256: string; +} + +/** + * Resolve `--loadout ` or `--modules ,-id,+id` to a filter. + * Returns null for the plain `full` loadout, which renders today's bytes. + */ +export function selectModules(input: { loadout?: string; modules?: string }, root = ROOT): ModuleSelection | null { + if (input.loadout !== undefined && input.modules !== undefined) { + throw new ModuleError('MODULE_SPEC_INVALID', '--loadout and --modules were both given', 'pass one: --loadout or --modules ,-id,+id'); + } + const text = (input.modules ?? input.loadout ?? '').trim(); + if (!text) return null; + const manifest = loadLoadouts(root); + const catalog = moduleCatalog(manifest, root); + assertValid(manifest, catalog); + const [base, ...edits] = text.split(',').map(t => t.trim()); + const names = Object.keys(manifest.loadouts).sort().join(', '); + if (!manifest.loadouts[base]) { + throw new ModuleError('LOADOUT_UNKNOWN', `${clamp(base)} is not a loadout (valid: ${names})`, 'gstack-modules list'); + } + if (input.loadout !== undefined && edits.length) { + throw new ModuleError('MODULE_SPEC_INVALID', `--loadout takes one name (got ${clamp(text)})`, 'use --modules ,-id,+id for edits'); + } + const { excluded, tierMax } = excludedBy(manifest, base); + const ids = new Map(catalog.map(e => [e.id, e])); + for (const edit of edits) { + const op = edit[0]; + const id = edit.slice(1); + if ((op !== '-' && op !== '+') || !id) { + throw new ModuleError('MODULE_SPEC_INVALID', `${clamp(edit)} is not - or +`, 'write the spec as ,-,+; list ids with gstack-modules list'); + } + const entry = ids.get(id); + if (!entry) throw new ModuleError('MODULE_UNKNOWN', `${clamp(id)} is not a module`, 'gstack-modules list'); + if (op === '-' && entry.pinned) { + throw new ModuleError('MODULE_PINNED', `${id} is pinned and can never be excluded`, `drop -${id} from the spec (pinned ids: gstack-modules list --pinned)`); + } + 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)); +} diff --git a/scripts/resolvers/model-overlay.ts b/scripts/resolvers/model-overlay.ts index 2a6242e2c..7933dffac 100644 --- a/scripts/resolvers/model-overlay.ts +++ b/scripts/resolvers/model-overlay.ts @@ -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 = new Set()): string { +export function readOverlay(model: string, seen: Set = new Set(), excluded: ReadonlySet = 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 = 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 = 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' diff --git a/scripts/resolvers/preamble.ts b/scripts/resolvers/preamble.ts index c39da2f2b..19575f9e6 100644 --- a/scripts/resolvers/preamble.ts +++ b/scripts/resolvers/preamble.ts @@ -72,6 +72,47 @@ export function generatePreamble(ctx: TemplateContext): string { return preambleSections(ctx).join('\n\n'); } +interface PreamblePiece { + /** Module id suffix (`preamble:`); null for the model overlay, whose module id is `overlay:`. */ + 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); } diff --git a/scripts/resolvers/sections.ts b/scripts/resolvers/sections.ts index a210b8e8a..11c49e693 100644 --- a/scripts/resolvers/sections.ts +++ b/scripts/resolvers/sections.ts @@ -19,7 +19,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { Host, ResolverFn, TemplateContext } from './types'; +import { moduleExcluded, type Host, type ResolverFn, type TemplateContext } from './types'; const ROOT = path.resolve(import.meta.dir, '..', '..'); @@ -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 (moduleExcluded(ctx, `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 moduleExcluded(ctx, `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 || moduleExcluded(ctx, `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 |`); diff --git a/scripts/resolvers/types.ts b/scripts/resolvers/types.ts index e23c156da..c625adfb9 100644 --- a/scripts/resolvers/types.ts +++ b/scripts/resolvers/types.ts @@ -125,6 +125,25 @@ 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; + pinned: ReadonlySet; + /** Unpinned preamble pieces render as if the skill's preamble-tier were at most this. */ + preambleTierMax?: number; +} + +export function moduleExcluded(ctx: TemplateContext, id: string): boolean { + return ctx.modules?.excluded.has(id) ?? false; } /** Resolver function signature. args is populated for parameterized placeholders like {{INVOKE_SKILL:name}}. */ diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index 40d430758..c01582fed 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -1,5 +1,8 @@ { "newModules": [ + { "file": "scripts/modules.ts", "movedFrom": "(new: module loadouts catalog and selection, GSTA-140)" }, + { "file": "scripts/modules-render.ts", "movedFrom": "(new: module loadout render and lock, GSTA-140)" }, + { "file": "scripts/modules-cli.ts", "movedFrom": "(new: bin/gstack-modules CLI, GSTA-140)" }, { "file": "lib/harness-register.ts", "movedFrom": "(new: gstack-harness registrar, GSTA-135)" }, { "file": "lib/harness-qm.ts", "movedFrom": "(new: gstack-harness QM skill-pack target, GSTA-135)" }, { "file": "lib/design-lint/render.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" }, diff --git a/test/module-loadouts.test.ts b/test/module-loadouts.test.ts new file mode 100644 index 000000000..5507ab1fc --- /dev/null +++ b/test/module-loadouts.test.ts @@ -0,0 +1,219 @@ +/** + * Module loadouts (GSTA-140, PR 1a): modules/loadouts.json, the derived + * catalog, --modules selection, the render filter, and gstack-modules render. + * Every rung loads `full` today, so the synthetic ad-hoc arms below are the + * only exclusion coverage; committed bytes are covered by Skill Docs Freshness. + */ +import { afterAll, describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { MODEL_CATALOG } from '../lib/model-catalog'; +import { runGeneration } from '../scripts/gen-skill-docs'; +import { ModuleError, excludedSkillDirs, loadLoadouts, moduleCatalog, normalizeModelId, rungFor, selectModules, validateLoadouts, type ModuleErrorCode } from '../scripts/modules'; +import { LOCK_FILE, renderLoadout, type LoadoutLock } from '../scripts/modules-render'; +import { applySectionGuards } from '../scripts/resolvers/sections'; +import { PREAMBLE_MODULE_IDS, preambleSections } from '../scripts/resolvers/preamble'; +import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const manifest = loadLoadouts(); +const catalog = moduleCatalog(manifest); +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'loadouts-')); +afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); + +/** Model ids gstack runs on or GSTA-2 measures (Capy's model list, 2026-10-28). */ +const CORPUS = [ + ...MODEL_CATALOG.map(e => e.model), 'gpt-6-astra', + 'anthropic/claude-opus-5-5', 'anthropic/claude-haiku-5-5', 'anthropic/claude-sonnet-5-5', 'anthropic/claude-fable-5-1', + 'openai/gpt-6.1-sol', 'openai/gpt-6-luna', 'openai/gpt-6.1-terra', 'deepseek/deepseek-v4.1-flash-1017', 'deepseek/deepseek-v4-pro', + 'zai/glm-5.3', 'zai/glm-5.3-flash', 'google/gemini-4-argon', 'google/gemini-3.8-flash', 'xai/grok-4.7', + 'moonshotai/kimi-k3.1-pro', 'qwen/qwen3.8-max', 'qwen/qwen3.8-flash', 'minimax/minimax-m3', +]; + +function codeOf(fn: () => unknown): ModuleErrorCode | 'none' { + try { fn(); return 'none'; } catch (error) { if (error instanceof ModuleError) return error.code; throw error; } +} + +describe('modules/loadouts.json', () => { + test('validates against the derived catalog, with disjoint rungs over the model corpus', () => { + expect(validateLoadouts(manifest, catalog, CORPUS)).toEqual([]); + }); + + test('every rung and unknown loads full until GSTA-2 measures a smaller loadout', () => { + expect(Object.values(manifest.default)).toEqual(['full', 'full', 'full', 'full', 'full']); + }); + + test('the catalog derives every module kind from its source files', () => { + const ids = new Set(catalog.map(e => e.id)); + expect(ids.has('skill:gstack')).toBe(true); + expect(ids.has('section:office-hours/second-opinion')).toBe(true); + expect(ids.has('overlay:opus-4-8')).toBe(true); + for (const id of PREAMBLE_MODULE_IDS) expect(ids.has(id)).toBe(true); + for (const entry of catalog) expect(fs.existsSync(path.join(ROOT, entry.source))).toBe(true); + expect(catalog.filter(e => e.pinned).map(e => e.id)).toEqual(expect.arrayContaining(['preamble:bash', 'skill:gstack', 'skill:gstack-upgrade', ...manifest.pinned])); + }); + + test('model ids normalize and map to rungs', () => { + expect(normalizeModelId(' Anthropic/Claude-Haiku-5-5[1m] ')).toBe('claude-haiku-5-5'); + expect(rungFor(manifest, 'anthropic/claude-opus-5-5')).toBe('strong'); + expect(rungFor(manifest, 'anthropic/claude-haiku-5-5')).toBe('mid'); + expect(rungFor(manifest, 'deepseek/deepseek-v4.1-flash-1017')).toBe('light'); + expect(rungFor(manifest, 'zai/glm-5.3-flash')).toBe('light'); + expect(rungFor(manifest, 'zai/glm-5.3')).toBe('mid'); + expect(rungFor(manifest, 'haiku')).toBe('mid'); + expect(rungFor(manifest, 'some-new-model')).toBe('unknown'); + for (const model of MODEL_CATALOG) expect(rungFor(manifest, model.model)).not.toBe('unknown'); + }); + + test('a broken manifest names each problem', () => { + const bad = structuredClone(manifest); + bad.loadouts.lean = { base: 'missing', exclude: ['preamble:bash', 'section:nope/none'] }; + bad.default.mid = 'ghost'; + bad.rungs.light.push('claude-opus-5*'); + const problems = validateLoadouts(bad, catalog, CORPUS).join('\n'); + expect(problems).toContain('loadouts.lean.base names no loadout'); + expect(problems).toContain('excludes pinned preamble:bash'); + expect(problems).toContain('"section:nope/none", which is not a module'); + expect(problems).toContain('default.mid names no loadout'); + expect(problems).toContain('matches more than one rung'); + }); +}); + +describe('selectModules', () => { + test('full and an empty spec render today\'s bytes (no filter)', () => { + expect(selectModules({ loadout: 'full' })).toBeNull(); + expect(selectModules({})).toBeNull(); + expect(selectModules({ modules: 'full,-section:office-hours/second-opinion,+section:office-hours/second-opinion' })).toBeNull(); + }); + + test('an ad-hoc spec records its name, normalized spec and hash', () => { + const s = selectModules({ modules: ' full , -skill:design-shotgun ' })!; + expect(s.name).toBe('adhoc'); + expect(s.spec).toBe('full,-skill:design-shotgun'); + expect(s.specSha256).toMatch(/^[0-9a-f]{64}$/); + expect(excludedSkillDirs(s)).toEqual(['design-shotgun']); + }); + + test('bad input is refused with a code, never a fallback to full', () => { + expect(codeOf(() => selectModules({ loadout: 'nope' }))).toBe('LOADOUT_UNKNOWN'); + expect(codeOf(() => selectModules({ modules: 'full,skill:qa' }))).toBe('MODULE_SPEC_INVALID'); + expect(codeOf(() => selectModules({ loadout: 'full', modules: 'full' }))).toBe('MODULE_SPEC_INVALID'); + expect(codeOf(() => selectModules({ loadout: 'full,-skill:qa' }))).toBe('MODULE_SPEC_INVALID'); + expect(codeOf(() => selectModules({ modules: 'full,-skill:nope' }))).toBe('MODULE_UNKNOWN'); + expect(codeOf(() => selectModules({ modules: 'full,-preamble:bash' }))).toBe('MODULE_PINNED'); + expect(codeOf(() => selectModules({ modules: 'full,-section:ship/adversarial' }))).toBe('MODULE_PINNED'); + }); + + test('every error code has a troubleshooting section', () => { + const doc = fs.readFileSync(path.join(ROOT, 'docs/troubleshooting.md'), 'utf-8'); + const codes: ModuleErrorCode[] = ['LOADOUTS_INVALID', 'LOADOUT_UNKNOWN', 'MODULE_SPEC_INVALID', 'MODULE_UNKNOWN', 'MODULE_PINNED', 'MODULE_EXCLUSION_UNSAFE', 'RENDER_FAILED', 'RENDER_DEST_NOT_OWNED']; + for (const code of codes) { + const anchor = code.toLowerCase().replaceAll('_', '-'); + const at = doc.indexOf(``); + expect(at).toBeGreaterThan(-1); + expect(doc.slice(at).split('\n')[1]).toMatch(new RegExp(`^### \`${code}`)); + } + }); +}); + +describe('render filter units', () => { + const ctx = (extra: Partial = {}): TemplateContext => ({ + skillName: 'office-hours', tmplPath: 'office-hours/SKILL.md.tmpl', host: 'claude', paths: HOST_PATHS.claude, preambleTier: 3, model: 'claude', ...extra, + }); + const filter = (excluded: string[], preambleTierMax?: number) => ({ + name: 'test', excluded: new Set(excluded), pinned: new Set(catalog.filter(e => e.pinned).map(e => e.id)), preambleTierMax, + }); + + test('section guards vanish under full and drop their text when the section is excluded', () => { + const text = 'a\n{{IF_SECTION:second-opinion}}offer\n{{/IF_SECTION}}b'; + expect(applySectionGuards(text, ctx())).toBe('a\noffer\nb'); + expect(applySectionGuards(text, ctx({ modules: filter(['section:office-hours/second-opinion']) }))).toBe('a\nb'); + expect(() => applySectionGuards('{{IF_SECTION:nope}}x{{/IF_SECTION}}', ctx())).toThrow('no section "nope"'); + }); + + test('a tier cap drops unpinned pieces; pinned pieces follow full', () => { + const full = preambleSections(ctx()).join('\n'); + const capped = preambleSections(ctx({ modules: filter([], 1) })).join('\n'); + expect(full).toContain('## Writing Style'); + expect(capped).not.toContain('## Writing Style'); + expect(capped).toContain('## AskUserQuestion Format'); + const tier1 = ctx({ preambleTier: 1 }); + expect(preambleSections({ ...tier1, modules: filter([], 1) })).toEqual(preambleSections(tier1)); + }); + + test('excluding a base overlay drops the text that inherits it', () => { + const astra = ctx({ model: 'gpt-6-astra' as TemplateContext['model'] }); + const withBase = preambleSections(astra).join('\n'); + const withoutBase = preambleSections({ ...astra, modules: filter(['overlay:gpt']) }).join('\n'); + expect(withBase.length).toBeGreaterThan(withoutBase.length); + expect(preambleSections({ ...astra, modules: filter(['overlay:gpt-6-astra']) }).join('\n')).not.toContain('Model-Specific Behavioral Patch'); + }); +}); + +describe('gstack-modules render', () => { + const spec = 'full,-section:office-hours/second-opinion,-section:office-hours/visual-design,-preamble:writing-style,-overlay:claude,-skill:design-shotgun'; + const readLock = (dir: string) => JSON.parse(fs.readFileSync(path.join(dir, LOCK_FILE), 'utf-8')) as LoadoutLock; + + test('claude: one arm excludes one module of each kind and locks every file', async () => { + const out = path.join(tmp, 'claude-arm'); + const lock = await renderLoadout({ host: 'claude', selection: selectModules({ modules: spec }), outDir: out }); + const skill = fs.readFileSync(path.join(out, 'office-hours/SKILL.md'), 'utf-8'); + expect(skill).toContain(''); + expect(skill).not.toContain('Want a second opinion'); + expect(skill).not.toContain('## Phase 4.25: Visual design'); + expect(skill).toContain('> Not loaded in the adhoc loadout: skip running the Phase 3.5 second opinion'); + expect(skill).not.toContain('sections/second-opinion.md'); + expect(fs.existsSync(path.join(out, 'office-hours/sections/second-opinion.md'))).toBe(false); + expect(fs.existsSync(path.join(out, 'office-hours/sections/design-and-handoff.md'))).toBe(true); + expect(skill).toContain(`${out}/office-hours/sections/design-and-handoff.md`); + const preamble = fs.readFileSync(path.join(out, 'office-hours/sections/preamble.md'), 'utf-8'); + expect(preamble).not.toContain('## Writing Style'); + expect(preamble).not.toContain('Model-Specific Behavioral Patch'); + expect(preamble).toContain('## AskUserQuestion Format'); + expect(fs.existsSync(path.join(out, 'design-shotgun'))).toBe(false); + expect(fs.readFileSync(path.join(out, 'SKILL.md'), 'utf-8')).toContain('Disabled on this install (never invoke or suggest them): `/design-shotgun`'); + expect(lock).toMatchObject({ loadout: 'adhoc', spec, host: 'claude', model: 'claude' }); + expect(lock.excluded).toEqual(spec.split(',').slice(1).map(t => t.slice(1)).sort()); + expect(readLock(out).files.length).toBe(lock.files.length); + expect(lock.files.find(f => f.path === 'office-hours/SKILL.md')!.bytes).toBe(Buffer.byteLength(skill)); + expect(fs.readdirSync(tmp).filter(n => n.includes('.stage-') || n.includes('.old-'))).toEqual([]); + }); + + test('codex: the same arm inlines nothing it excluded', async () => { + const out = path.join(tmp, 'codex-arm'); + await renderLoadout({ host: 'codex', selection: selectModules({ modules: spec }), outDir: out, model: 'gpt-6-astra' }); + const skill = fs.readFileSync(path.join(out, '.agents/skills/gstack-office-hours/SKILL.md'), 'utf-8'); + expect(skill).not.toContain('Want a second opinion'); + expect(skill).toContain('> Not loaded in the adhoc loadout'); + expect(fs.existsSync(path.join(out, '.agents/skills/gstack-design-shotgun'))).toBe(false); + expect(readLock(out).model).toBe('gpt-6-astra'); + }); + + test('a dangling reference is refused and the earlier render survives', async () => { + const out = path.join(tmp, 'claude-arm'); + const before = readLock(out); + const unsafe = renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-section:plan-design-review/review-sections' }), outDir: out }); + await expect(unsafe).rejects.toMatchObject({ code: 'MODULE_EXCLUSION_UNSAFE' }); + await expect(unsafe).rejects.toThrow('referenced by plan-design-review/sections/checklist.md'); + expect(readLock(out)).toEqual(before); + expect(fs.readdirSync(tmp).filter(n => n.includes('.stage-'))).toEqual([]); + }); + + test('a later render replaces an earlier one; a foreign directory is never replaced', async () => { + const out = path.join(tmp, 'claude-arm'); + const lock = await renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-skill:design-shotgun' }), outDir: out }); + expect(readLock(out).spec_sha256).toBe(lock.spec_sha256); + expect(fs.existsSync(path.join(out, 'office-hours/sections/second-opinion.md'))).toBe(true); + const foreign = path.join(tmp, 'foreign'); + fs.mkdirSync(foreign); + fs.writeFileSync(path.join(foreign, 'notes.txt'), 'mine'); + await expect(renderLoadout({ host: 'claude', selection: null, outDir: foreign })).rejects.toMatchObject({ code: 'RENDER_DEST_NOT_OWNED' }); + expect(fs.readFileSync(path.join(foreign, 'notes.txt'), 'utf-8')).toBe('mine'); + }); + + test('the full loadout regenerates the committed claude tree byte for byte', async () => { + const result = await runGeneration({ host: 'claude', dryRun: true, modules: selectModules({ loadout: 'full' }) }); + expect(result.diagnostics.filter(d => d.kind === 'stale' || d.kind === 'error')).toEqual([]); + }); +}); From a919a416787d27232a8d0ef06ac48aaafc76b290 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 16:53:11 +0000 Subject: [PATCH 2/7] 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> --- .gstack/pregate.json | 2 +- test/module-loadouts.test.ts | 57 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/.gstack/pregate.json b/.gstack/pregate.json index f9cb2832c..67a9a5625 100644 --- a/.gstack/pregate.json +++ b/.gstack/pregate.json @@ -20,5 +20,5 @@ "windows-free-tests.yml/windows-free-shard": "bun run scripts/test-free-shards.ts --windows-only --list" }, "warn": [], - "strays": { "allow": [] } + "strays": { "allow": ["modules/**"] } } diff --git a/test/module-loadouts.test.ts b/test/module-loadouts.test.ts index 5507ab1fc..a1e0f54c8 100644 --- a/test/module-loadouts.test.ts +++ b/test/module-loadouts.test.ts @@ -5,9 +5,11 @@ * only exclusion coverage; committed bytes are covered by Skill Docs Freshness. */ import { afterAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { bunIsolationArgs } from '../lib/bun-isolation'; import { MODEL_CATALOG } from '../lib/model-catalog'; import { runGeneration } from '../scripts/gen-skill-docs'; import { ModuleError, excludedSkillDirs, loadLoadouts, moduleCatalog, normalizeModelId, rungFor, selectModules, validateLoadouts, type ModuleErrorCode } from '../scripts/modules'; @@ -217,3 +219,58 @@ describe('gstack-modules render', () => { expect(result.diagnostics.filter(d => d.kind === 'stale' || d.kind === 'error')).toEqual([]); }); }); + +describe('modules/loadouts.schema.json', () => { + const schema = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/loadouts.schema.json'), 'utf-8')); + const raw = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/loadouts.json'), 'utf-8')); + + test('the manifest carries every required key and no key the schema lacks', () => { + for (const key of schema.required) expect(raw).toHaveProperty(key); + for (const key of Object.keys(raw)) expect(Object.keys(schema.properties)).toContain(key); + expect(raw.version).toBe(schema.properties.version.const); + }); + + test('rung names and patterns agree between the schema and the manifest', () => { + const rungs = schema.properties.rungs.required as string[]; + expect(Object.keys(raw.rungs).sort()).toEqual([...rungs].sort()); + expect([...schema.properties.aliases.additionalProperties.enum].sort()).toEqual([...rungs].sort()); + const pattern = new RegExp(schema.properties.rungs.properties.frontier.items.pattern); + for (const rung of rungs) for (const glob of raw.rungs[rung]) expect(glob).toMatch(pattern); + }); +}); + +describe('bin/gstack-modules', () => { + const run = (...args: string[]) => { + const result = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'bin/gstack-modules'), ...args], { cwd: ROOT, encoding: 'utf-8', timeout: 60_000 }); + return { code: result.status, stdout: result.stdout, stderr: result.stderr }; + }; + + test('validate and list read the committed manifest', () => { + expect(run('validate')).toMatchObject({ code: 0, stdout: expect.stringContaining('LOADOUTS: ok') }); + const listed = JSON.parse(run('list', '--json').stdout); + expect(listed.modules.map((m: { id: string }) => m.id)).toEqual(catalog.map(e => e.id)); + const pinned = run('list', '--pinned').stdout; + expect(pinned).toContain('preamble:bash'); + expect(pinned).not.toContain('skill:design-shotgun'); + }); + + test('refused inputs exit 2 with their code; unknown commands print usage', () => { + const out = path.join(tmp, 'cli-refused'); + expect(run('render', '--host', 'claude', '--modules', 'full,-skill:nope', '--out-dir', out)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_UNKNOWN') }); + expect(run('render', '--host', 'claude', '--modules', 'full,-preamble:bash', '--out-dir', out)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_PINNED') }); + expect(run('render', '--host', 'claude', '--loadout', 'full', '--out-dir', out, '--plan-mode', 'maybe')).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') }); + expect(run('render', '--host', 'claude', '--loadout', 'full', '--out-dir', out, '--model', 'no-such-model')).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') }); + expect(run('render', '--host', 'claude', '--out-dir', out)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') }); + expect(fs.existsSync(out)).toBe(false); + expect(run('bogus')).toMatchObject({ code: 2, stderr: expect.stringContaining('Usage:') }); + expect(run('--help')).toMatchObject({ code: 0, stdout: expect.stringContaining('gstack-modules render') }); + }); + + test('render prints one RENDERED line and writes the lock', () => { + const out = path.join(tmp, 'cli-render'); + const result = run('render', '--host', 'claude', '--modules', 'full,-skill:design-shotgun', '--out-dir', out); + expect(result.code).toBe(0); + expect(result.stdout).toMatch(/^RENDERED: loadout=adhoc host=claude files=\d+ bytes=\d+ excluded=1 lock=/); + expect(JSON.parse(fs.readFileSync(path.join(out, LOCK_FILE), 'utf-8')).excluded).toEqual(['skill:design-shotgun']); + }); +}); From 1d3e05aaf95f081988ed0970da0e1760c1ee2e3f Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 17:01:47 +0000 Subject: [PATCH 3/7] 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> --- docs/loadouts.md | 2 +- docs/troubleshooting.md | 2 +- scripts/gen-skill-docs.ts | 5 ++++- test/module-loadouts.test.ts | 12 ++++++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/loadouts.md b/docs/loadouts.md index 3fec04d3b..ac140af83 100644 --- a/docs/loadouts.md +++ b/docs/loadouts.md @@ -26,7 +26,7 @@ bin/gstack-modules render --host claude \ `render` writes the whole host tree into a staging directory beside `--out-dir`. It checks that no rendered file still points at an excluded module, writes `loadout.lock.json` last (loadout name or `adhoc`, the normalized spec and its sha256, host, model, every excluded id, and the sha256 and byte count of every file), then swaps the result into place. A failure leaves an earlier render at `--out-dir` untouched. Claude section links name `--out-dir`, so point the eval harness at that directory. Pass the same `--model`, `--install-root` and `--plan-mode` the install under test uses, so the arms differ only by the modules. Codex renders default to the host's generic `gpt` overlay, while `./setup` uses `gpt-6-astra`. -`bun run gen:skill-docs --loadout ` and `--modules ` apply the same filter to an ordinary generation run. A non-`full` render adds `` to each SKILL.md header. +`bun run gen:skill-docs --loadout --out-dir ` and `--modules --out-dir ` apply the same filter to a generation run without the staging, dangling-reference check or lock file; a selection with exclusions and no `--out-dir` is refused with `MODULE_SPEC_INVALID`, because the committed tree is always the `full` render. A non-`full` render adds `` to each SKILL.md header. ## Building an eval arm diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5208d68bd..353f367b4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5159,7 +5159,7 @@ These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modul ### `MODULE_SPEC_INVALID: …` -**Meaning.** A `--modules` token is not `-` or `+`, both `--loadout` and `--modules` were given, or a render flag is missing or malformed. **Fix.** Write the spec as `,-,+` (for example `full,-section:office-hours/second-opinion`) and pass the flags `bin/gstack-modules --help` lists. +**Meaning.** A `--modules` token is not `-` or `+`, both `--loadout` and `--modules` were given, `gen:skill-docs` got a selection with exclusions but no `--out-dir`, or a render flag is missing or malformed. **Fix.** Write the spec as `,-,+` (for example `full,-section:office-hours/second-opinion`) and pass the flags `bin/gstack-modules --help` lists. ### `MODULE_UNKNOWN: "" is not a module` diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 97dfae1a0..322be1b8e 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -24,7 +24,7 @@ import type { Host, TemplateContext } from './resolvers/types'; import { HOST_PATHS } from './resolvers/types'; import { RESOLVERS } from './resolvers/index'; import { applySectionGuards, rewriteCarvedSectionRefs, SKILL_BYTE_CEILING, usesLazySections } from './resolvers/sections'; -import { excludedSkillDirs, selectModules } from './modules'; +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'; @@ -150,6 +150,9 @@ function parseGenerationArgs(args: string[]): GenerationOptions { 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) { + throw new ModuleError('MODULE_SPEC_INVALID', `--${value('--modules') === undefined ? 'loadout' : 'modules'} with exclusions needs --out-dir; the committed tree is always the full render`, 'bin/gstack-modules render --host --modules --out-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)})`); diff --git a/test/module-loadouts.test.ts b/test/module-loadouts.test.ts index a1e0f54c8..cb29038da 100644 --- a/test/module-loadouts.test.ts +++ b/test/module-loadouts.test.ts @@ -156,6 +156,7 @@ describe('render filter units', () => { describe('gstack-modules render', () => { const spec = 'full,-section:office-hours/second-opinion,-section:office-hours/visual-design,-preamble:writing-style,-overlay:claude,-skill:design-shotgun'; const readLock = (dir: string) => JSON.parse(fs.readFileSync(path.join(dir, LOCK_FILE), 'utf-8')) as LoadoutLock; + const ensureArm = async (dir: string) => { if (!fs.existsSync(path.join(dir, LOCK_FILE))) await renderLoadout({ host: 'claude', selection: selectModules({ modules: spec }), outDir: dir }); }; test('claude: one arm excludes one module of each kind and locks every file', async () => { const out = path.join(tmp, 'claude-arm'); @@ -194,6 +195,7 @@ describe('gstack-modules render', () => { test('a dangling reference is refused and the earlier render survives', async () => { const out = path.join(tmp, 'claude-arm'); + await ensureArm(out); const before = readLock(out); const unsafe = renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-section:plan-design-review/review-sections' }), outDir: out }); await expect(unsafe).rejects.toMatchObject({ code: 'MODULE_EXCLUSION_UNSAFE' }); @@ -204,6 +206,8 @@ describe('gstack-modules render', () => { test('a later render replaces an earlier one; a foreign directory is never replaced', async () => { const out = path.join(tmp, 'claude-arm'); + await ensureArm(out); + expect(readLock(out).loadout).toBe('adhoc'); const lock = await renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-skill:design-shotgun' }), outDir: out }); expect(readLock(out).spec_sha256).toBe(lock.spec_sha256); expect(fs.existsSync(path.join(out, 'office-hours/sections/second-opinion.md'))).toBe(true); @@ -266,6 +270,14 @@ describe('bin/gstack-modules', () => { expect(run('--help')).toMatchObject({ code: 0, stdout: expect.stringContaining('gstack-modules render') }); }); + test('gen:skill-docs refuses an exclusion render into the committed tree', () => { + const before = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout; + const result = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'scripts/gen-skill-docs.ts'), '--host', 'claude', '--modules', 'full,-skill:design-shotgun'], { cwd: ROOT, encoding: 'utf-8', timeout: 120_000 }); + expect(result.status).not.toBe(0); + expect(result.stderr + result.stdout).toContain('MODULE_SPEC_INVALID'); + expect(spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout).toBe(before); + }); + test('render prints one RENDERED line and writes the lock', () => { const out = path.join(tmp, 'cli-render'); const result = run('render', '--host', 'claude', '--modules', 'full,-skill:design-shotgun', '--out-dir', out); From 4472dd072601034f9668fb192dc92de0f2e7987e Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 17:11:08 +0000 Subject: [PATCH 4/7] 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> --- .gstack/pregate.json | 2 +- docs/loadouts.md | 2 +- scripts/gen-llms-txt.ts | 3 +++ scripts/gen-skill-docs.ts | 10 +++++++--- scripts/modules-render.ts | 15 ++++++++++++++- scripts/modules.ts | 23 ++++++++++++++++------- scripts/resolvers/sections.ts | 8 ++++---- scripts/resolvers/types.ts | 4 ---- test/gen-skill-docs-prune-stale.test.ts | 2 +- test/module-loadouts.test.ts | 21 +++++++++++++++++---- 10 files changed, 64 insertions(+), 26 deletions(-) diff --git a/.gstack/pregate.json b/.gstack/pregate.json index 67a9a5625..0454e74f3 100644 --- a/.gstack/pregate.json +++ b/.gstack/pregate.json @@ -20,5 +20,5 @@ "windows-free-tests.yml/windows-free-shard": "bun run scripts/test-free-shards.ts --windows-only --list" }, "warn": [], - "strays": { "allow": ["modules/**"] } + "strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] } } diff --git a/docs/loadouts.md b/docs/loadouts.md index ac140af83..f065842d5 100644 --- a/docs/loadouts.md +++ b/docs/loadouts.md @@ -8,7 +8,7 @@ Install-time model detection, which picks a loadout for the model you run, ships | Term | Meaning | |---|---| -| Module | One of four kinds. `skill:` is a whole skill. `section:/` is one entry of `/sections/manifest.json`. `overlay:` is one `model-overlays/.md`; excluding a base overlay also drops the text that inherits it. `preamble:` is one piece of the shared preamble (`PREAMBLE_PIECES` in `scripts/resolvers/preamble.ts`). | +| Module | One of four kinds. `skill:` is a whole skill. `section:/` is one entry of `/sections/manifest.json`. `overlay:` is one `model-overlays/.md`; excluding a base overlay also removes the base's text from every overlay that inherits it; the inheriting overlay's own text stays. `preamble:` is one piece of the shared preamble (`PREAMBLE_PIECES` in `scripts/resolvers/preamble.ts`). | | Pinned | A module no loadout can remove where `full` renders it: the start command, plan-mode rules, the question format, completion status, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade`. A skill's `preamble-tier` still applies, so a tier-1 skill has no question-format section in any loadout. | | Rung | How much scaffolding a model gets: `frontier`, `strong`, `mid`, `light`, or `unknown`. Model ids are matched after lowercasing and stripping a `provider/` prefix (`anthropic/claude-haiku-5-5` is `mid`). Rungs are separate from the frontier/smart tiers in `lib/model-catalog.ts`, which choose which model runs a role. | | Loadout | `{ base, exclude, include, preamble_tier_max, note }`. `full` (include `*`) is always present. An unknown model gets `default.unknown`, which is `full`. | diff --git a/scripts/gen-llms-txt.ts b/scripts/gen-llms-txt.ts index cb8766f7b..176e720f6 100644 --- a/scripts/gen-llms-txt.ts +++ b/scripts/gen-llms-txt.ts @@ -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 !/^\/?[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) { - throw new ModuleError('MODULE_SPEC_INVALID', `--${value('--modules') === undefined ? 'loadout' : 'modules'} with exclusions needs --out-dir; the committed tree is always the full render`, 'bin/gstack-modules render --host --modules --out-dir '); + 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 --modules --out-dir '); } const installRoot = value('--install-root'); if (installRoot !== undefined && !INSTALL_ROOT_PATTERN.test(installRoot)) { @@ -1151,7 +1151,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise { } 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; } diff --git a/scripts/modules-render.ts b/scripts/modules-render.ts index 484e6d48a..f12164960 100644 --- a/scripts/modules-render.ts +++ b/scripts/modules-render.ts @@ -59,7 +59,8 @@ function walk(dir: string, base = dir): string[] { * 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/` or the inline-host "section inlined" phrase. An excluded - * skill counts as referenced by another skill naming its SKILL.md. + * 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 }> = []; @@ -77,6 +78,15 @@ export function danglingReferences(stage: string, selection: ModuleSelection): s } 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 []; @@ -148,6 +158,9 @@ export async function renderLoadout(request: RenderRequest): Promise (pattern.endsWith('*') ? id.sta /** 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 (manifest.aliases[id]) return manifest.aliases[id]; + 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'; } @@ -128,23 +128,28 @@ export function validateLoadouts(manifest: LoadoutsManifest, catalog: ModuleEntr } for (const id of manifest.pinned ?? []) if (!ids.has(id)) problems.push(`pinned id ${clamp(id)} is not a module`); const loadouts = manifest.loadouts ?? {}; - if (!loadouts[FULL] || loadouts[FULL].include !== '*') problems.push('loadouts.full must exist with include "*"'); + 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 && !loadouts[def.base]) problems.push(`loadouts.${name}.base names no loadout (${clamp(def.base)})`); + 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}`); + for (const id of def.exclude ?? []) { + if (pinned.has(id)) problems.push(`loadouts.${name} excludes pinned ${id}`); + const child = id.startsWith('skill:') ? [...pinned].find(p => p.startsWith(`section:${id.slice('skill:'.length)}/`)) : undefined; + if (child) problems.push(`loadouts.${name} excludes ${id}, which holds pinned ${child}`); + } if (def.preamble_tier_max !== undefined && ![1, 2, 3, 4].includes(def.preamble_tier_max)) problems.push(`loadouts.${name}.preamble_tier_max must be 1-4`); const seen = new Set(); - for (let at: string | undefined = name; at && loadouts[at]; at = loadouts[at].base) { + 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 (!loadouts[manifest.default?.[rung]]) problems.push(`default.${rung} names no loadout (${clamp(String(manifest.default?.[rung]))})`); + 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); @@ -188,7 +193,7 @@ export function selectModules(input: { loadout?: string; modules?: string }, roo assertValid(manifest, catalog); const [base, ...edits] = text.split(',').map(t => t.trim()); const names = Object.keys(manifest.loadouts).sort().join(', '); - if (!manifest.loadouts[base]) { + 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) { @@ -207,6 +212,10 @@ export function selectModules(input: { loadout?: string; modules?: string }, roo 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; diff --git a/scripts/resolvers/sections.ts b/scripts/resolvers/sections.ts index 11c49e693..ea6fd4df0 100644 --- a/scripts/resolvers/sections.ts +++ b/scripts/resolvers/sections.ts @@ -19,7 +19,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { moduleExcluded, type Host, type ResolverFn, type TemplateContext } from './types'; +import type { Host, ResolverFn, TemplateContext } from './types'; const ROOT = path.resolve(import.meta.dir, '..', '..'); @@ -108,7 +108,7 @@ 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 (moduleExcluded(ctx, `section:${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.`; } @@ -143,7 +143,7 @@ const SECTION_GUARD_RE = /\{\{IF_SECTION:([a-z0-9-]+)\}\}([\s\S]*?)\{\{\/IF_SECT export function applySectionGuards(content: string, ctx: TemplateContext): string { return content.replace(SECTION_GUARD_RE, (_match, id: string, inner: string) => { findSection(ctx.skillName, id); - return moduleExcluded(ctx, `section:${ctx.skillName}/${id}`) ? '' : inner; + return ctx.modules?.excluded.has(`section:${ctx.skillName}/${id}`) ? '' : inner; }); } @@ -167,7 +167,7 @@ export const SECTION_INDEX: ResolverFn = (ctx: TemplateContext, args?: string[]) '|------|-------------------|', ]; for (const s of manifest.sections) { - if (s.runner_only || moduleExcluded(ctx, `section:${skill}/${s.id}`)) 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 |`); diff --git a/scripts/resolvers/types.ts b/scripts/resolvers/types.ts index c625adfb9..de6dd8c58 100644 --- a/scripts/resolvers/types.ts +++ b/scripts/resolvers/types.ts @@ -142,10 +142,6 @@ export interface ModuleFilter { preambleTierMax?: number; } -export function moduleExcluded(ctx: TemplateContext, id: string): boolean { - return ctx.modules?.excluded.has(id) ?? false; -} - /** Resolver function signature. args is populated for parameterized placeholders like {{INVOKE_SKILL:name}}. */ export type ResolverFn = (ctx: TemplateContext, args?: string[]) => string; diff --git a/test/gen-skill-docs-prune-stale.test.ts b/test/gen-skill-docs-prune-stale.test.ts index 1089dcfde..15879072a 100644 --- a/test/gen-skill-docs-prune-stale.test.ts +++ b/test/gen-skill-docs-prune-stale.test.ts @@ -39,7 +39,7 @@ describe('gen-skill-docs stale-render prune', () => { const source = path.join(out, 'source'); fs.mkdirSync(path.join(source, 'scripts'), { recursive: true }); fs.copyFileSync(path.join(ROOT, 'scripts', 'gen-skill-docs.ts'), path.join(source, 'scripts', 'gen-skill-docs.ts')); - for (const file of ['discover-skills.ts', 'external-skill-names.ts', 'gen-llms-txt.ts', 'gen-agents-digest.ts', 'gen-doctor-components.ts', 'models.ts']) { + for (const file of ['discover-skills.ts', 'external-skill-names.ts', 'gen-llms-txt.ts', 'gen-agents-digest.ts', 'gen-doctor-components.ts', 'models.ts', 'modules.ts']) { fs.symlinkSync(path.join(ROOT, 'scripts', file), path.join(source, 'scripts', file), 'file'); } fs.symlinkSync(path.join(ROOT, 'scripts', 'resolvers'), path.join(source, 'scripts', 'resolvers'), 'dir'); diff --git a/test/module-loadouts.test.ts b/test/module-loadouts.test.ts index cb29038da..e85986947 100644 --- a/test/module-loadouts.test.ts +++ b/test/module-loadouts.test.ts @@ -65,6 +65,7 @@ describe('modules/loadouts.json', () => { expect(rungFor(manifest, 'zai/glm-5.3')).toBe('mid'); expect(rungFor(manifest, 'haiku')).toBe('mid'); expect(rungFor(manifest, 'some-new-model')).toBe('unknown'); + expect(rungFor(manifest, 'constructor')).toBe('unknown'); for (const model of MODEL_CATALOG) expect(rungFor(manifest, model.model)).not.toBe('unknown'); }); @@ -72,12 +73,17 @@ describe('modules/loadouts.json', () => { const bad = structuredClone(manifest); bad.loadouts.lean = { base: 'missing', exclude: ['preamble:bash', 'section:nope/none'] }; bad.default.mid = 'ghost'; + bad.default.light = 'toString'; + bad.loadouts.noship = { base: 'constructor', exclude: ['skill:ship'] }; bad.rungs.light.push('claude-opus-5*'); const problems = validateLoadouts(bad, catalog, CORPUS).join('\n'); expect(problems).toContain('loadouts.lean.base names no loadout'); expect(problems).toContain('excludes pinned preamble:bash'); expect(problems).toContain('"section:nope/none", which is not a module'); expect(problems).toContain('default.mid names no loadout'); + expect(problems).toContain('default.light names no loadout'); + expect(problems).toContain('loadouts.noship.base names no loadout'); + expect(problems).toContain('excludes skill:ship, which holds pinned section:ship/'); expect(problems).toContain('matches more than one rung'); }); }); @@ -105,6 +111,9 @@ describe('selectModules', () => { expect(codeOf(() => selectModules({ modules: 'full,-skill:nope' }))).toBe('MODULE_UNKNOWN'); expect(codeOf(() => selectModules({ modules: 'full,-preamble:bash' }))).toBe('MODULE_PINNED'); expect(codeOf(() => selectModules({ modules: 'full,-section:ship/adversarial' }))).toBe('MODULE_PINNED'); + expect(codeOf(() => selectModules({ modules: 'full,-skill:ship' }))).toBe('MODULE_PINNED'); + expect(codeOf(() => selectModules({ modules: 'full,-skill:autoplan' }))).toBe('MODULE_PINNED'); + expect(codeOf(() => selectModules({ loadout: 'constructor' }))).toBe('LOADOUT_UNKNOWN'); }); test('every error code has a troubleshooting section', () => { @@ -144,7 +153,7 @@ describe('render filter units', () => { expect(preambleSections({ ...tier1, modules: filter([], 1) })).toEqual(preambleSections(tier1)); }); - test('excluding a base overlay drops the text that inherits it', () => { + test('excluding a base overlay drops its text from the overlay that inherits it', () => { const astra = ctx({ model: 'gpt-6-astra' as TemplateContext['model'] }); const withBase = preambleSections(astra).join('\n'); const withoutBase = preambleSections({ ...astra, modules: filter(['overlay:gpt']) }).join('\n'); @@ -175,6 +184,7 @@ describe('gstack-modules render', () => { expect(preamble).not.toContain('Model-Specific Behavioral Patch'); expect(preamble).toContain('## AskUserQuestion Format'); expect(fs.existsSync(path.join(out, 'design-shotgun'))).toBe(false); + expect(fs.readFileSync(path.join(out, 'gstack/llms.txt'), 'utf-8')).not.toContain('design-shotgun/SKILL.md'); expect(fs.readFileSync(path.join(out, 'SKILL.md'), 'utf-8')).toContain('Disabled on this install (never invoke or suggest them): `/design-shotgun`'); expect(lock).toMatchObject({ loadout: 'adhoc', spec, host: 'claude', model: 'claude' }); expect(lock.excluded).toEqual(spec.split(',').slice(1).map(t => t.slice(1)).sort()); @@ -200,6 +210,7 @@ describe('gstack-modules render', () => { const unsafe = renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-section:plan-design-review/review-sections' }), outDir: out }); await expect(unsafe).rejects.toMatchObject({ code: 'MODULE_EXCLUSION_UNSAFE' }); await expect(unsafe).rejects.toThrow('referenced by plan-design-review/sections/checklist.md'); + await expect(renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-skill:qa' }), outDir: out })).rejects.toThrow(/skill:qa: referenced by qa-only\//); expect(readLock(out)).toEqual(before); expect(fs.readdirSync(tmp).filter(n => n.includes('.stage-'))).toEqual([]); }); @@ -272,9 +283,11 @@ describe('bin/gstack-modules', () => { test('gen:skill-docs refuses an exclusion render into the committed tree', () => { const before = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout; - const result = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'scripts/gen-skill-docs.ts'), '--host', 'claude', '--modules', 'full,-skill:design-shotgun'], { cwd: ROOT, encoding: 'utf-8', timeout: 120_000 }); - expect(result.status).not.toBe(0); - expect(result.stderr + result.stdout).toContain('MODULE_SPEC_INVALID'); + for (const extra of [[], ['--out-dir', '.']]) { + const result = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'scripts/gen-skill-docs.ts'), '--host', 'claude', '--modules', 'full,-skill:design-shotgun', ...extra], { cwd: ROOT, encoding: 'utf-8', timeout: 120_000 }); + expect(result.status).toBe(2); + expect(result.stderr).toContain('MODULE_SPEC_INVALID'); + } expect(spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout).toBe(before); }); From b1e0930952eb8705c3c6caf50bef1cf9d70aea16 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 17:18:21 +0000 Subject: [PATCH 5/7] chore: bump version and changelog (v1.91.296.0) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 17 +++++++++++++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- package.json | 2 +- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc9780e57..a18e21c21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.91.296.0] - 2026-10-28 + +**You can now render gstack with whole skills, skill sections, model overlays or preamble pieces left out, as a byte-exact tree with a lock file, so an eval can measure which instructions a model actually needs.** Garry asked for "95% of the outcome but with 50% of the token use", and the free ceiling measurement on train gate run 40705984271 found skill text is about 21% of billed eval cost (35 of 103 tests), so trimming it per model is worth measuring. Nothing changes for anyone today. Every model rung and unknown models load `full`, and the committed SKILL.md files are byte-identical to the previous release. A smaller loadout ships only after GSTA-2 measures it. + +### Added +- **`bin/gstack-modules`**: `list` prints every module id (`skill:`, `section:`, `overlay:`, `preamble:`) with its source and pin; `validate` checks `modules/loadouts.json`; `render --host (--loadout | --modules full,-,+) --out-dir ` renders one loadout with the same `--model`, `--install-root` and `--plan-mode` setup uses, and writes `loadout.lock.json` (spec sha256, excluded ids, per-file sha256 and bytes). +- **`modules/loadouts.json`** (with a JSON schema): rungs `frontier`, `strong`, `mid`, `light`, the model-id table, pinned modules and named loadouts. Every rung and `unknown` map to `full`. +- **Safety pins**: the start command, plan-mode rules, the question format, completion status, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade` can never be excluded, directly or by excluding the skill that holds them (`MODULE_PINNED`). +- **Renders refuse instead of guessing**: an unknown loadout or module, a malformed spec, a rendered file that still points at an excluded skill or section (`MODULE_EXCLUSION_UNSAFE`), or an `--out-dir` that an earlier render did not write (`RENDER_DEST_NOT_OWNED`) each exit 2 with a fix line and a `docs/troubleshooting.md` anchor, and never fall back to `full`. A failed render leaves the earlier one in place. +- **`docs/loadouts.md`**: terms, commands, how to build an eval arm, and how a loadout changes (only with a cited GSTA-2 run). + +### Changed +- `gen:skill-docs` accepts `--loadout` and `--modules` with an `--out-dir` outside the checkout; a selection with exclusions aimed at the committed tree is refused. Excluded sections render a one-line `Not loaded in the loadout` stub, and `{{IF_SECTION:}}` guards remove skeleton text that belongs to a section (office-hours' second-opinion offer and visual-design phase, plan-design-review's review-sections self-check). `gstack/llms.txt` lists only the skills a render includes. + +### For contributors +- New `test/module-loadouts.test.ts` covers the manifest, selection, pins, the render filter, the CLI and the byte-identical `full` render. `.gstack/pregate.json` allows the two new `modules/` files as strays. `test/gen-skill-docs-prune-stale.test.ts`'s copied fixture now links `scripts/modules.ts`; no assertion, threshold or budget changed. + ## [1.91.295.0] - 2026-10-28 **Deleting or renaming a test no longer fails pregate with PREGATE_NO_LANE.** A PR that removed a test file, or renamed one, failed `lanes` because the removed path had no test lane of its own, so cleanup PRs needed a hand-written `dependencies` entry to pass (#3529). Pregate now accounts for a removed test the way it accounts for an edit. A renamed test runs under its new name, a deleted test runs the remaining tests of the files it exercised, and either one runs the tests of any file that still names it, such as a curation list. Every waived test is printed on its own line, so the reason is visible in the gate output. Removing any other file still needs a lane, as before. diff --git a/VERSION b/VERSION index c7e4e95c1..ecfb77db2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.295.0 +1.91.296.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 358c63a00..738d6cf05 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.295.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.296.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/package.json b/package.json index 3532e7b54..a90776814 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.295", + "version": "1.91.296", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", From 4040734b787385fc487e3299ff97d0a5d2458bb6 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 17:22:01 +0000 Subject: [PATCH 6/7] 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> --- CONTRIBUTING.md | 2 +- docs/PROJECT_STRUCTURE.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a0a72f5e..24837b324 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -205,7 +205,7 @@ the new defaults. | Validate periodic cases on a branch in CI | `gh workflow run evals-periodic.yml --ref ` (periodic lane only) | CI runners + API spend | Pushed branch, `gh` with workflow rights | | Validate periodic cases plus the weekly gate census on a branch | `gh workflow run evals-periodic.yml --ref -f include_gate_census=true` | CI runners + API spend | Pushed branch, `gh` with workflow rights | | Run the opt-in ML, gitleaks and Swift checks | `gh workflow run platform-qualification.yml --ref ` | Free CI runners | Pushed branch, `gh` with workflow rights | -| Render one module loadout as an eval arm ([loadouts](docs/loadouts.md)) | `bin/gstack-modules render --host claude --modules full,-section:/ --out-dir ` (`--loadout full` for the control) | Free, about 20 s per host | Bun 1.4.2 | +| Render one module loadout as an eval arm ([loadouts](docs/loadouts.md)) | `bin/gstack-modules render --host claude --modules full,-section:/ --out-dir ` (`--loadout full` for the control) | Free, about a second per host | Bun 1.4.2 | | Look at past local eval runs | `bun run eval:list` / `eval:compare` / `eval:summary` | Free | Local eval history | | See a case's pass rate across recent weekly runs | `bun run eval:pass-rates --case ` | Free | `gh` with repo read access | | Measure one red case alone against the ship bar (MEETS 9/10) | `bun run scripts/ship-measure.ts measure --case --round baseline` ([bar](docs/TESTING_INTERNALS.md#ship-measure)) | API spend for 10 trials (12 for behavior); asks above $2/trial, stops at $25/case | Same as paid coverage | diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index 4b0e01ca4..6c99766c2 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -92,6 +92,7 @@ gstack/ │ ├── test/ # Integration tests │ └── dist/ # Compiled binary ├── agents-digest/ # Committed 2KB instruction-tier rules digest (gstack-AGENTS.md) for rules-reading hosts +├── modules/ # loadouts.json (+ loadouts.schema.json): model rungs, pinned modules and named loadouts; read by scripts/modules.ts, rendered by bin/gstack-modules (docs/loadouts.md) ├── extension/ # Chrome extension (side panel + activity feed + CSS inspector) ├── lib/ # Shared libraries (aside-render.ts = local-HTML rendering, Aside first, engine fallback; design-catalog.ts = the typed design anti-pattern catalog every design skill renders from; design-lint/ = the static and render tiers behind bin/gstack-design-lint, ported from hallmark's sloplint; design-detect-contract.ts = detector sentinel vocabulary; design-md.ts = open DESIGN.md reader/writer; dom-dump-script.ts + generated dom-dump.js = rendered-DOM dump for the detector; review-evidence.ts = review-start receipt binding, computed freshness and shared-code snapshot eligibility; frontend-scope.ts; claude-bin.ts, error-handling.ts, worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, tracker-guard.ts, version-source.ts, code-intelligence/) │ └── diagram-render/ # Vendored mermaid + excalidraw runtimes, built into one offline bundle the renderer loads From 90e7ee30549f15116eeb1dbcf1d1f078acf95871 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 18:59:46 +0000 Subject: [PATCH 7/7] 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> --- CHANGELOG.md | 2 +- VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a18e21c21..e6f0161e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [1.91.296.0] - 2026-10-28 +## [Unreleased] **You can now render gstack with whole skills, skill sections, model overlays or preamble pieces left out, as a byte-exact tree with a lock file, so an eval can measure which instructions a model actually needs.** Garry asked for "95% of the outcome but with 50% of the token use", and the free ceiling measurement on train gate run 40705984271 found skill text is about 21% of billed eval cost (35 of 103 tests), so trimming it per model is worth measuring. Nothing changes for anyone today. Every model rung and unknown models load `full`, and the committed SKILL.md files are byte-identical to the previous release. A smaller loadout ships only after GSTA-2 measures it. diff --git a/VERSION b/VERSION index ecfb77db2..c7e4e95c1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.296.0 +1.91.295.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 738d6cf05..358c63a00 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.296.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.295.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/package.json b/package.json index a90776814..3532e7b54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.296", + "version": "1.91.295", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module",