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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

No files matched your search

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