mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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>
This commit is contained in:
1 parent
61e201ec08
commit
00bd08f108
18 files changed
+1139
-52
No files matched your search
@@ -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 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 <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 |
|
||||
|
||||
Executable
+8
@@ -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;
|
||||
});
|
||||
@@ -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 drops the text that inherits it. `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>` and `--modules <spec>` apply the same filter to an ordinary generation run. 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`.
|
||||
@@ -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, 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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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}}
|
||||
@@ -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 ? `<!-- loadout: ${options.modules.name} -->\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<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 +1043,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 +1071,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) });
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* bin/gstack-modules (GSTA-140, PR 1a): the module catalog and loadout renders.
|
||||
*
|
||||
* gstack-modules list [--json] [--pinned]
|
||||
* gstack-modules validate
|
||||
* gstack-modules render --host <h> (--loadout <name> | --modules <base>,-id,+id) --out-dir <d>
|
||||
* [--install-root <abs>] [--model <id>] [--plan-mode on|off]
|
||||
*
|
||||
* Exit 0 on success, 2 on a refused input (ModuleError, printed with its code,
|
||||
* fix and docs anchor), 1 on an unexpected failure.
|
||||
*/
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { resolveHostArg } from '../hosts/index';
|
||||
import { resolveModel } from './models';
|
||||
import { LOADOUTS_FILE, ModuleError, loadLoadouts, moduleCatalog, selectModules, validateLoadouts } from './modules';
|
||||
import { renderLoadout } from './modules-render';
|
||||
import type { Host } from './resolvers/types';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const USAGE = `Usage:
|
||||
gstack-modules list [--json] [--pinned] every module id with its kind, source and pin
|
||||
gstack-modules validate check ${LOADOUTS_FILE} against the catalog
|
||||
gstack-modules render --host <h> (--loadout <name> | --modules <base>,-id,+id) --out-dir <d>
|
||||
[--install-root <abs>] [--model <id>] [--plan-mode on|off]
|
||||
render one loadout and write loadout.lock.json
|
||||
Guide: docs/loadouts.md`;
|
||||
|
||||
export interface CliResult { code: number; stdout: string; stderr: string }
|
||||
|
||||
function flag(args: string[], name: string): string | undefined {
|
||||
const index = args.findIndex(a => a === name || a.startsWith(`${name}=`));
|
||||
if (index < 0) return undefined;
|
||||
const value = args[index].startsWith(`${name}=`) ? args[index].slice(name.length + 1) : args[index + 1];
|
||||
if (!value || value.startsWith('--')) throw new ModuleError('MODULE_SPEC_INVALID', `${name} requires a value`, `gstack-modules --help`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function modulesMain(args: string[]): Promise<CliResult> {
|
||||
const [command, ...rest] = args;
|
||||
try {
|
||||
if (!command || command === '--help' || command === '-h' || command === 'help') return { code: 0, stdout: USAGE + '\n', stderr: '' };
|
||||
if (command === 'list') {
|
||||
const manifest = loadLoadouts();
|
||||
const catalog = moduleCatalog(manifest).filter(e => !rest.includes('--pinned') || e.pinned);
|
||||
const rows = catalog.map(e => {
|
||||
const bytes = fs.readFileSync(path.join(ROOT, e.source));
|
||||
return { ...e, source_bytes: bytes.length, source_sha256: crypto.createHash('sha256').update(bytes).digest('hex') };
|
||||
});
|
||||
if (rest.includes('--json')) return { code: 0, stdout: JSON.stringify({ loadouts: Object.keys(manifest.loadouts), modules: rows }, null, 2) + '\n', stderr: '' };
|
||||
const lines = rows.map(r => `${r.id.padEnd(48)} ${r.kind.padEnd(8)} ${r.pinned ? 'pinned' : ' '} ${r.source}`);
|
||||
return { code: 0, stdout: [`loadouts: ${Object.keys(manifest.loadouts).join(', ')}`, ...lines].join('\n') + '\n', stderr: '' };
|
||||
}
|
||||
if (command === 'validate') {
|
||||
const manifest = loadLoadouts();
|
||||
const problems = validateLoadouts(manifest, moduleCatalog(manifest));
|
||||
if (problems.length) return { code: 2, stdout: '', stderr: problems.map(p => `LOADOUTS_INVALID: ${p}`).join('\n') + '\n' };
|
||||
return { code: 0, stdout: `LOADOUTS: ok (${Object.keys(manifest.loadouts).length} loadouts)\n`, stderr: '' };
|
||||
}
|
||||
if (command === 'render') {
|
||||
const hostValue = flag(rest, '--host');
|
||||
const outDir = flag(rest, '--out-dir');
|
||||
if (!hostValue || !outDir) throw new ModuleError('MODULE_SPEC_INVALID', 'render needs --host and --out-dir', 'gstack-modules render --host claude --loadout full --out-dir <dir>');
|
||||
let host: Host;
|
||||
try { host = resolveHostArg(hostValue) as Host; } catch (error) { throw new ModuleError('MODULE_SPEC_INVALID', (error as Error).message, 'pass a host from docs/hosts.md, for example --host claude'); }
|
||||
const loadout = flag(rest, '--loadout');
|
||||
const modules = flag(rest, '--modules');
|
||||
if (loadout === undefined && modules === undefined) throw new ModuleError('MODULE_SPEC_INVALID', 'render needs --loadout or --modules', 'add --loadout full for the control arm');
|
||||
const modelValue = flag(rest, '--model');
|
||||
const model = modelValue === undefined ? null : resolveModel(modelValue);
|
||||
if (modelValue !== undefined && !model) throw new ModuleError('MODULE_SPEC_INVALID', `--model ${JSON.stringify(modelValue.slice(0, 80))} is not a model gstack knows`, 'pass the same --model setup uses, for example gpt-6-astra');
|
||||
const planMode = flag(rest, '--plan-mode');
|
||||
if (planMode !== undefined && planMode !== 'on' && planMode !== 'off') throw new ModuleError('MODULE_SPEC_INVALID', '--plan-mode takes on or off', 'drop it, or pass --plan-mode off');
|
||||
const installRoot = flag(rest, '--install-root');
|
||||
if (installRoot !== undefined && !/^\/[A-Za-z0-9_.@+\/-]*$/.test(installRoot)) throw new ModuleError('MODULE_SPEC_INVALID', '--install-root must be a plain absolute path', 'pass the absolute install root setup would use');
|
||||
const selection = selectModules({ loadout, modules });
|
||||
const lock = await renderLoadout({ host, selection, outDir, installRoot: installRoot ?? null, model, planMode: planMode !== 'off' });
|
||||
return { code: 0, stdout: `RENDERED: loadout=${lock.loadout} host=${host} files=${lock.files.length} bytes=${lock.total_bytes} excluded=${lock.excluded.length} lock=${path.join(path.resolve(outDir), 'loadout.lock.json')}\n`, stderr: '' };
|
||||
}
|
||||
return { code: 2, stdout: '', stderr: `Unknown command ${JSON.stringify(command.slice(0, 40))}.\n${USAGE}\n` };
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleError) return { code: 2, stdout: '', stderr: `${error.code}: ${error.message}\n` };
|
||||
return { code: 1, stdout: '', stderr: `gstack-modules: unexpected failure: ${(error as Error).message}\n` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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 (`/<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.
|
||||
*/
|
||||
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<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 (fs.existsSync(final)) fs.renameSync(final, retired);
|
||||
try {
|
||||
fs.renameSync(stage, final);
|
||||
} catch (error) {
|
||||
if (fs.existsSync(retired)) fs.renameSync(retired, final);
|
||||
throw error;
|
||||
}
|
||||
fs.rmSync(retired, { recursive: true, force: true });
|
||||
return lock;
|
||||
} finally {
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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:<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 (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<string>();
|
||||
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<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 (!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)`);
|
||||
}
|
||||
if (op === '-') excluded.add(id); else excluded.delete(id);
|
||||
}
|
||||
if (excluded.size === 0 && tierMax === undefined && base === FULL) return null;
|
||||
const spec = edits.length ? [base, ...edits].join(',') : base;
|
||||
return {
|
||||
name: edits.length ? 'adhoc' : base,
|
||||
excluded,
|
||||
pinned: new Set(catalog.filter(e => e.pinned).map(e => e.id)),
|
||||
preambleTierMax: tierMax,
|
||||
spec,
|
||||
specSha256: createHash('sha256').update(spec).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Skill directories a selection leaves out (`skill:gstack` is the root template). */
|
||||
export function excludedSkillDirs(filter: ModuleFilter | null | undefined): string[] {
|
||||
return [...(filter?.excluded ?? [])].filter(id => id.startsWith('skill:')).map(id => id.slice('skill:'.length));
|
||||
}
|
||||
@@ -24,9 +24,10 @@ const OVERLAY_DIR = path.resolve(import.meta.dir, '../../model-overlays');
|
||||
|
||||
const INHERIT_RE = /^\s*\{\{INHERIT:([a-z0-9-]+(?:\.[0-9]+)*)\}\}\s*\n/;
|
||||
|
||||
export function readOverlay(model: string, seen: Set<string> = new Set()): string {
|
||||
export function readOverlay(model: string, seen: Set<string> = new Set(), excluded: ReadonlySet<string> = new Set()): string {
|
||||
if (seen.has(model)) return ''; // cycle guard
|
||||
seen.add(model);
|
||||
if (excluded.has(`overlay:${model}`)) return '';
|
||||
|
||||
const filePath = path.join(OVERLAY_DIR, `${model}.md`);
|
||||
if (!fs.existsSync(filePath)) return '';
|
||||
@@ -36,7 +37,7 @@ export function readOverlay(model: string, seen: Set<string> = new Set()): strin
|
||||
if (!match) return raw.trim();
|
||||
|
||||
const baseModel = match[1];
|
||||
const base = readOverlay(baseModel, seen);
|
||||
const base = readOverlay(baseModel, seen, excluded);
|
||||
const rest = raw.replace(INHERIT_RE, '').trim();
|
||||
|
||||
if (!base) return rest;
|
||||
@@ -46,7 +47,7 @@ export function readOverlay(model: string, seen: Set<string> = new Set()): strin
|
||||
export function generateModelOverlay(ctx: TemplateContext): string {
|
||||
if (!ctx.model) return '';
|
||||
|
||||
const content = readOverlay(ctx.model);
|
||||
const content = readOverlay(ctx.model, new Set(), ctx.modules?.excluded);
|
||||
if (!content) return '';
|
||||
|
||||
const precedence = ctx.model === 'gpt-5.6-sol'
|
||||
|
||||
@@ -72,6 +72,47 @@ export function generatePreamble(ctx: TemplateContext): string {
|
||||
return preambleSections(ctx).join('\n\n');
|
||||
}
|
||||
|
||||
interface PreamblePiece {
|
||||
/** Module id suffix (`preamble:<id>`); null for the model overlay, whose module id is `overlay:<model>`. */
|
||||
id: string | null;
|
||||
minTier: number;
|
||||
when?: (ctx: TemplateContext) => boolean;
|
||||
render: (ctx: TemplateContext, tier: number) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The preamble's pieces in render order. AskUserQuestion Format renders BEFORE
|
||||
* the model overlay so the pacing rule is the ambient default; the overlay's
|
||||
* nudges land as subordinate patches (Opus 4.7 absorbs the first pacing
|
||||
* directive it hits; reversing this regresses plan-review cadence, v1.6.4.0).
|
||||
* Plan-mode semantics stays right after bash (so _SESSION_ID / _BRANCH / _TEL
|
||||
* are live) and before every onboarding gate. The 8 one-time onboarding
|
||||
* generators live in bin/gstack-skill-start's instruction layer (plan Q2/OV6/F5).
|
||||
*/
|
||||
export const PREAMBLE_PIECES: readonly PreamblePiece[] = [
|
||||
{ id: 'bash', minTier: 1, render: generatePreambleBash },
|
||||
{ id: 'make-pdf-setup', minTier: 1, when: ctx => ctx.skillName === 'make-pdf', render: generateMakePdfSetup },
|
||||
{ id: 'plan-mode-info', minTier: 1, render: generatePlanModeInfo },
|
||||
{ id: 'upgrade-check', minTier: 1, render: generateUpgradeCheck },
|
||||
{ id: 'brain-health', minTier: 1, render: generateBrainHealthInstruction },
|
||||
{ id: 'ask-user-format', minTier: 2, render: generateAskUserFormat },
|
||||
{ id: 'brain-sync', minTier: 1, render: generateBrainSyncBlock },
|
||||
{ id: null, minTier: 1, render: generateModelOverlay },
|
||||
{ id: 'voice', minTier: 1, render: generateVoiceDirective },
|
||||
{ id: 'context-recovery', minTier: 2, render: generateContextRecovery },
|
||||
{ id: 'writing-style', minTier: 2, render: generateWritingStyle },
|
||||
{ id: 'completeness', minTier: 2, render: generateCompletenessSection },
|
||||
{ id: 'confusion', minTier: 2, render: generateConfusionProtocol },
|
||||
{ id: 'evidence', minTier: 2, render: generateEvidenceDirective },
|
||||
{ id: 'context-health', minTier: 2, render: generateContextHealth },
|
||||
{ id: 'question-tuning', minTier: 2, render: generateQuestionTuning },
|
||||
{ id: 'repo-mode', minTier: 3, render: () => generateRepoModeSection() },
|
||||
{ id: 'search-before-building', minTier: 3, render: generateSearchBeforeBuildingSection },
|
||||
{ id: 'completion-status', minTier: 1, render: generateCompletionStatus },
|
||||
];
|
||||
|
||||
export const PREAMBLE_MODULE_IDS: readonly string[] = PREAMBLE_PIECES.flatMap(p => (p.id ? [`preamble:${p.id}`] : []));
|
||||
|
||||
/** The preamble's generator outputs in render order, empty ones dropped. */
|
||||
export function preambleSections(ctx: TemplateContext): string[] {
|
||||
const tier = ctx.preambleTier;
|
||||
@@ -84,41 +125,15 @@ export function preambleSections(ctx: TemplateContext): string[] {
|
||||
if (tier < 1 || tier > 4) {
|
||||
throw new Error(`Invalid preamble-tier: ${tier} in ${ctx.tmplPath}. Must be 1-4.`);
|
||||
}
|
||||
const sections = [
|
||||
generatePreambleBash(ctx),
|
||||
...(ctx.skillName === 'make-pdf' ? [generateMakePdfSetup(ctx)] : []),
|
||||
// Plan-mode-skill semantics stays near the top: after bash (so _SESSION_ID /
|
||||
// _BRANCH / _TEL env vars are live) and before all onboarding gates so
|
||||
// models read the authoritative "AskUserQuestion satisfies plan mode's
|
||||
// end-of-turn" rule before any other instruction. Renders for all skills
|
||||
// (not interactive-gated) on hosts whose capabilities declare planMode.
|
||||
generatePlanModeInfo(ctx),
|
||||
generateUpgradeCheck(ctx),
|
||||
// Phase 2: the 8 one-time onboarding generators (lake-intro, telemetry-
|
||||
// prompt, proactive-prompt, first-run-guidance, routing-injection,
|
||||
// vendoring-deprecation, spawned-session-check, writing-style-migration)
|
||||
// moved into bin/gstack-skill-start's instruction-emission layer — their
|
||||
// text appears at runtime only when its gate fires (plan Q2/OV6/F5).
|
||||
generateBrainHealthInstruction(ctx),
|
||||
// AskUserQuestion Format renders BEFORE the model overlay so the pacing rule
|
||||
// is the ambient default; the overlay's behavioral nudges land as subordinate
|
||||
// patches. Opus 4.7 reads top-to-bottom and absorbs the first pacing directive
|
||||
// it hits; reversing this order regresses plan-review cadence (v1.6.4.0 bug).
|
||||
...(tier >= 2 ? [generateAskUserFormat(ctx)] : []),
|
||||
generateBrainSyncBlock(ctx),
|
||||
generateModelOverlay(ctx),
|
||||
generateVoiceDirective(ctx, tier),
|
||||
...(tier >= 2 ? [
|
||||
generateContextRecovery(ctx),
|
||||
generateWritingStyle(ctx),
|
||||
generateCompletenessSection(ctx),
|
||||
generateConfusionProtocol(ctx),
|
||||
generateEvidenceDirective(ctx),
|
||||
generateContextHealth(ctx),
|
||||
generateQuestionTuning(ctx),
|
||||
] : []),
|
||||
...(tier >= 3 ? [generateRepoModeSection(), generateSearchBeforeBuildingSection(ctx)] : []),
|
||||
generateCompletionStatus(ctx),
|
||||
];
|
||||
return sections.filter(s => s && s.trim().length > 0);
|
||||
const filter = ctx.modules;
|
||||
const pieceTier = (piece: PreamblePiece): number => {
|
||||
if (!filter || piece.id === null || filter.pinned.has(`preamble:${piece.id}`)) return tier;
|
||||
return Math.min(tier, filter.preambleTierMax ?? tier);
|
||||
};
|
||||
return PREAMBLE_PIECES
|
||||
.filter(piece => (piece.when?.(ctx) ?? true)
|
||||
&& !(piece.id && filter?.excluded.has(`preamble:${piece.id}`))
|
||||
&& pieceTier(piece) >= piece.minTier)
|
||||
.map(piece => piece.render(ctx, pieceTier(piece)))
|
||||
.filter(s => s && s.trim().length > 0);
|
||||
}
|
||||
@@ -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 |`);
|
||||
|
||||
@@ -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<string>;
|
||||
pinned: ReadonlySet<string>;
|
||||
/** 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}}. */
|
||||
|
||||
+3
@@ -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)" },
|
||||
|
||||
@@ -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(`<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 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('<!-- 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, '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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user