mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(preamble): completeness means complete coverage with minimal code (GSTA-96) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(preamble): tier 2+ skills load the shared preamble once per session (GSTA-96) Every tier 2+ SKILL.md inlined the same ~23 KB preamble. It now renders once per host into sections/preamble.md beside each tier 2+ SKILL.md; the SKILL.md keeps a pointer that asks for the Read only when the file's id is not already in context. Only sections byte-identical to the shared render move, so every skill keeps exactly its pre-hoist paragraphs (verified across all 41 skills). Parity caps for ten skills raised to their measured unions (owner-approved, Garry 2026-10-25: "do it"); 63 size pins lowered and 26 deleted. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(paid-select): a generated sections/preamble.md takes its skill template's identity in the PR planner (GSTA-96) screen-summary failed on #3434 with 'PR profile requires full validation' for nine skills whose only paid coverage is periodic: their new sections/preamble.md had no dependency identity, while the SKILL.md beside it inherits its template's. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(section-manifest): the shared sections/preamble.md renders from the hoist resolver, not a section template (GSTA-96) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (CHANGELOG says 3 characters shorter; model-overlays guards the coverage half) The completeness section shrank by 3 characters (5 bytes), matching the 89 pins lowered by 3. The default-copy test pinned only the minimal-code half; an expectMentions check now fails if the full-coverage or never-excuses-a-gap clause is dropped. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (model-overlays binds smaller diff to equal coverage) The default-copy check now also fails if 'on equal coverage' is dropped from the smaller-diff rule (which would tell models to prefer the smaller diff unconditionally) or if the no-impossible-state-checks clause is removed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG lists the model-overlays coverage checks The [Unreleased] Tests bullet now says the default-copy test checks both halves of the rule (full coverage of the requirement; smaller diff only at equal coverage; no impossible-state checks; never excuses a gap), matching88e3cc0and44eacaf. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: plan-ceo-review and ETHOS say completeness means coverage, not more code Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (CEO bullet bounds code by accepted scope; tests bind each completeness rule) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: cover the shared-preamble hoist (ship Step 7) Adds test/preamble-hoist.test.ts (tier 1 unchanged; tier 2+ SKILL.md plus sections/preamble.md reproduces the pre-hoist preamble; pointer position and id) and two gen-skill-docs-checks cases (one shared preamble per host, beside every pointer and named by its id; preamble artifact kind). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (ship Step 9.4) - setup: follow a symlinked sections/ dir in the carved-pointer status check - touchfiles: select preamble-hoist.ts alongside every preamble.ts entry - section-manifest: exempt sections/preamble.md only for skills whose SKILL.md carries the shared-preamble pointer - skill-size-budget: growth ceiling measures SKILL.md plus its shared preamble - host-conformance: tolerate a missing sections/ dir, as setup does - skill-validation: read bin/gstack-skill-start directly - skill-fixture: error messages and docblocks name the actual boundary (harness-version non-behavioral note updated) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: describe the size-budget growth check's startup measure (ship Step 9.4, pass 2) The growth ceiling now measures SKILL.md plus the shared preamble, the same measure as the shrink floor; the docblock said SKILL.md only. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG says simplification.md quotes the old rule and CLAUDE.md restates the new one Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG lists the hoist tests and the growth-ceiling measure (ship Step 9.4) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: cover the symlinked sections/ status check and the ETHOS house-rule quote (ship Step 7) host-conformance: --status still finds a broken bare sections/ pointer when a carved Claude skill's installed sections/ is a directory symlink (fails at the pre-repair0e5d11fd9). gen-skill-docs: the house rule review/specialists/ simplification.md quotes from ETHOS.md still appears in ETHOS.md. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: describe the shared preamble file (ship Step 14.5 docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: the shared-preamble skip needs the skill's own Read; digest and first-run intro say complete coverage, minimal code (GSTA-96) The pointer skipped the shared file when any text carrying its id was in context, so a page or file with that heading could stand in for the anti-spoofing rules (security, red team and adversarial review, ship run 2). It now requires the skill's own earlier Read. The digest's Boil the Ocean bullet and the first-run intro still said 'AI makes completeness cheap, so do the complete thing' (docs audit blocker); both now carry Garry's 'complete coverage, minimal code' (2026-10-25: '1/ yes fix this'). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: pin the shared-preamble provenance skip and the new Boil the Ocean wording (ship Step 7) preamble-hoist: on every host, tier and render option the pointer allows the skip only when the skill's own Read is in context. agents-digest and gstack-skill-start: the digest bullet and the lake intro say complete coverage, minimal code, never the retired phrasing. Each fails at9af36d010. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: describe the provenance skip where the docs still described the id-only one (ship Step 9.4) The preamble-hoist.ts docblock and ARCHITECTURE.md still said the shared preamble is re-read only when its id is absent; since2acf1a44athe pointer requires the skill's own earlier Read of the file. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG lists the provenance-pointer and Boil the Ocean wording tests (ship Step 13) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * revert(digest): keep the agents digest at main's bytes; an unstamped head cannot change a release output (GSTA-96) CI 'Check VERSION is not stale vs queue' classified #3434 as stamped because it touched agents-digest/gstack-AGENTS.md (a release_outputs path), while regenerating only the generator fails the digest freshness test. The first-run intro rewording stays. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: voice judge reads the hoisted preamble; register ETHOS.md on its reader cases Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: refresh the claude ship golden the main merge took from main (ship Step 5 preflight) The1a4dcaf07merge resolved test/fixtures/golden/claude-ship-SKILL.md to main's pre-hoist bytes. The golden is a copy of ship/SKILL.md; regenerated with bun scripts/gen-ship-goldens.ts (pregate regen: REGEN_STALE). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: pr-dependencies' unplaceable-file example uses SECURITY.md now that ETHOS.md is mapped6183ca8aeregistered ETHOS.md on its reader cases in touchfiles-data.ts, so a PR touching only ETHOS.md plans in pr mode and no longer restores the full gate. The fail-closed test and the real-unknown label check now use SECURITY.md, which is tracked and unmapped; the assertions are unchanged. CHANGELOG names the third test file. (CI free shard 7 at 7c21720ac; remote pregate shard 6.) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: host path-leak checks read the moved shared preamble too; name SHARED_PREAMBLE_FILE (ship review pass 2) The external-host path-leak assertions in gen-skill-docs.test.ts (Codex, Factory) and host-config.test.ts (Copilot, Antigravity) read SKILL.md alone, so a Claude path that moved into sections/preamble.md with the hoist went unchecked. They now read the skill's startup text through readSkillStartup. workflow-judge-input and host-conformance use the SHARED_PREAMBLE_FILE constant instead of a bare 'preamble.md' literal, and preamble.ts's header describes the hoisted composition. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG token savings name the per-skill skip and the autoplan follow-up (ship review pass 2) The pointer's skip is bound to the skill's own copy of sections/preamble.md, so "every later one saves about 5,800" holds per skill, not per session, and /autoplan's skip list and the inline /office-hours loader do not yet skip the pointer section. The entry now says so instead of claiming the full /autoplan saving. Adding the skip entry grows size-pinned skills, so it is a follow-up. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: corpus budget measures startup bytes; carve-safety counts shared preamble as uncarved (ship review pass 2) skill-size-budget's growth ceiling and shrink floor already measured SKILL.md plus sections/preamble.md, but the total-corpus check still summed hoisted SKILL.md bytes against the pre-hoist baseline, hiding about 0.9 MB of headroom. It now sums the same startup bytes (3,114,885 -> 2,590,484, x0.832); the unreachable SKILL.md-missing fallback is gone. auq-format-always-loaded's carve-safety guard treated any sections/ dir as carved, so every skill with only the shared preamble ran it and its sanity check passed on preamble.md. It now uses setup --status's rule: a section other than the shared preamble. Those skills keep the per-skill checks. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: remaining SKILL.md-only negatives read the shared preamble too (ship review pass 3) The parameterized external-host path-leak smoke test, the two Codex .claude path checks and both vestigial-handshake negatives read SKILL.md alone, so text that moved into sections/preamble.md went unchecked. They now read readSkillStartup. preamble.ts's header no longer says tier 1 renders through generatePreamble(). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: ratchet skeleton caps and eager context ceilings down after the hoist (ship review pass 3) The hoist moved about 22 KB out of every tier 2+ SKILL.md, which left each hoisted maxSkeletonBytes cap and each eagerPerInvocation ceiling roughly 22 KB / 5-6K tokens slack. The 20 hoisted skeleton caps now sit at the post-hoist skeleton plus their pre-hoist headroom, and 42 context-budget ceilings take the recaptured (lower) values. Nothing was raised: the four ceilings the recapture would have raised keep their old values. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: preamble-hoist's section splitter closes a fence only on a bare delimiter (ship Step 11) Inside an open fence, a line such as "```bash" is content in CommonMark, but splitSections treated it as the closing fence, so a later "## " line in the same block could split a section mid-code-block and hoist half of it. A closing delimiter now needs nothing but whitespace after it. Every host's render is unchanged (gen-skill-docs --dry-run FRESH on all twelve hosts). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: skill-startup.ts is a global, pinned harness file like skill-fixture.ts (ship review pass 3) skill-fixture.ts is a global touchfile pinned in scripts/harness-version.json because it reshapes the skill content most E2E suites read, and it now builds that content through test/helpers/skill-startup.ts. That helper was in neither list, so a later edit to it would change fixture inputs without the harness-version guard and select only 27 keys. It is now global and pinned, recorded non-behavioral (its fixtures are byte-identical to the pre-hoist output). ARCHITECTURE.md and the CHANGELOG stop calling the pointer id a content id: it hashes the shared text before host rewrites. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG matches the merged skip lists and the baseline diff (ship review, run 4) The headline still called the autoplan and inline /office-hours skip entries a follow-up, but the main merge added them; it now says the loaded skills skip their shared Read. The #3451 sentence is dropped, because that fix shipped in v1.91.237.0 and is recorded there. The typecheck baseline line says the TS2769 entry was re-keyed for the 'preamble' artifact kind with its count unchanged, not fixed and lowered. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: pin the pointer entry in the loader skip lists; drop a carve-safety tautology (ship review, run 4) A new INVOKE_SKILL test checks that plan-ceo-review's inline /office-hours skip list and autoplan's section skip list both name the shared-preamble pointer heading, and that office-hours and the four plan reviews carry it. The carve-safety "sanity" assertion repeated the predicate discoverSkills() now uses to set sectionsDir, so it could never fail; it is removed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: gstack-doctor finds the Codex model patch in the hoisted shared preamble (ship Step 11, run 4) The doctor's patch row grepped only the Codex review SKILL.md for the "Model-Specific Behavioral Patch (" heading. The hoist moves that section into sections/preamble.md, so the required row (for ship, review, the plan reviews, office-hours, investigate and more) would fail on every correct post-hoist Codex install and block install.sh's "gstack: ok". It now checks the SKILL.md and then its shared preamble and names the file it found. A new doctor test covers a hoisted render. preamble-hoist's variantSubsections also returns null when no subsection differs, instead of emitting a replacing heading with no subsections. Every host's render is unchanged. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(docsync): fixtureDocs installs the generated sections/preamble.md (hunk taken from #3472) A generated skill that loads the shared preamble names sections/preamble.md as a required asset before skill start, and a spawned document-release child blocks on its absence, so docsync-spawned stayed red on every #3434 screen. fixtureDocs now copies that file when the generated skill has it. This single hunk is taken unchanged from gstack#3472 (head2e3f7ba1, GSTA-107), which trains ahead of #3434, so the train merge sees identical lines. Nothing else from #3472 is included. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG notes the docsync fixture hunk taken from #3472 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG names the post-merge context-budget ratchet (captain hoisted) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(auq): planted skill fixtures resolve the shared preamble like an install (GSTA-96) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(plan-format): mode menu delivers its question without output-harvesting wording (GSTA-96) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): record the v1.91.270.0 merge values (GSTA-96) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(auq): parallel-capture contract expects the planted skill with its sections path mapped (GSTA-96) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): record the v1.91.273.0 merge re-measurements (GSTA-96) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): restore the released entries the previous commit truncated (GSTA-96) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): end two sentences the prose gate flags (GSTA-96) 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>
1164 lines
55 KiB
TypeScript
1164 lines
55 KiB
TypeScript
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
/**
|
|
* Generate SKILL.md files from .tmpl templates.
|
|
*
|
|
* Pipeline:
|
|
* read .tmpl → find {{PLACEHOLDERS}} → resolve from source → format → write .md
|
|
*
|
|
* Supports --dry-run: generate to memory, exit 1 if different from committed file.
|
|
* Used by skill:check and CI freshness checks.
|
|
*/
|
|
|
|
import { discoverTemplates, discoverSectionTemplates, includesSkill } from './discover-skills';
|
|
import { externalSkillName, extractNameAndDescription } from './external-skill-names';
|
|
export { extractNameAndDescription } from './external-skill-names';
|
|
import { generateLlmsTxt } from './gen-llms-txt';
|
|
import { generateAgentsDigest, DIGEST_RELPATH, DIGEST_BYTE_BUDGET } from './gen-agents-digest';
|
|
import { DOCTOR_COMPONENTS_RELPATH } from './gen-doctor-components';
|
|
import { renderDoctorComponentsSh } from '../lib/doctor-components';
|
|
import { generateDesignChecklistMd } from './resolvers/design-checklist';
|
|
import { DOM_DUMP_SCRIPT, DOM_DUMP_FILE } from '../lib/dom-dump-script';
|
|
import * as fs from 'fs';
|
|
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 { insertRuntimePreludes } from './resolvers/runtime-root';
|
|
import { generateSharedPreambleFile, hoistsPreamble, SHARED_PREAMBLE_FILE } from './resolvers/preamble-hoist';
|
|
import { extractVoiceTriggers } from './resolvers/voice-triggers';
|
|
import { ALL_HOST_NAMES, resolveHostArg, getHostConfig } from '../hosts/index';
|
|
import type { HostConfig } from './host-config';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
import { ALL_MODEL_NAMES, resolveModel, type Model } from './models';
|
|
import { resolveStateRoot } from '../lib/state-root';
|
|
import { mkdirpSync } from '../lib/fs-utils';
|
|
|
|
type HostArg = Host | 'all';
|
|
|
|
/** Internal render settings. Inputs always come from ROOT; output routing and
|
|
* content links are separate so checks can render canonical bytes into scratch. */
|
|
export interface GenerationOptions {
|
|
host?: HostArg;
|
|
dryRun?: boolean;
|
|
outputRoot?: string;
|
|
contentLinkRoot?: string | null;
|
|
/** Install-context render contract: absolute install root this render serves. */
|
|
installRoot?: string | null;
|
|
/** Skills this install leaves unregistered (gstack-config disabled_skills); the router omits them. */
|
|
disabledSkills?: string[];
|
|
model?: Model | null;
|
|
catalogMode?: 'trim' | 'full';
|
|
explainLevel?: 'default' | 'terse';
|
|
respectDetection?: boolean;
|
|
log?: (message: string) => void;
|
|
planMode?: boolean;
|
|
}
|
|
|
|
interface RenderOptions {
|
|
outputRoot: string;
|
|
contentLinkRoot: string | null;
|
|
installRoot: string | null;
|
|
disabledSkills: string[];
|
|
model: Model | null;
|
|
catalogMode: 'trim' | 'full';
|
|
explainLevel: 'default' | 'terse';
|
|
gbrainDetected: boolean;
|
|
planMode: boolean;
|
|
}
|
|
|
|
export interface GeneratedArtifact {
|
|
relativePath: string;
|
|
kind: 'skill' | 'section' | 'preamble' | 'metadata' | 'openclaw' | 'index' | 'digest' | 'doctor-components' | 'asset';
|
|
host?: Host;
|
|
}
|
|
|
|
export interface GenerationDiagnostic {
|
|
kind: 'stale' | 'error' | 'warning' | 'skipped';
|
|
message: string;
|
|
host?: Host;
|
|
relativePath?: string;
|
|
}
|
|
|
|
export interface GenerationResult {
|
|
exitCode: 0 | 1;
|
|
artifacts: GeneratedArtifact[];
|
|
diagnostics: GenerationDiagnostic[];
|
|
}
|
|
|
|
/** Canonical generation never reads local detection state unless opted in. */
|
|
function loadGbrainOverride(respectDetection: boolean): boolean {
|
|
if (!respectDetection) return false;
|
|
const stateDir = resolveStateRoot();
|
|
try {
|
|
const json = JSON.parse(fs.readFileSync(path.join(stateDir, 'gbrain-detection.json'), 'utf-8'));
|
|
// Slow, remote, locked and briefly unreachable engines are still usable (#1964/#2051/#2456, A2).
|
|
return ['ok', 'timeout', 'db-unreachable', 'thin-client', 'engine-locked'].includes(json.gbrain_local_status ?? '');
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function effectiveSuppressedResolvers(hostConfig: HostConfig, options: RenderOptions): Set<string> {
|
|
let list = hostConfig.suppressedResolvers || [];
|
|
if (options.gbrainDetected) {
|
|
list = list.filter(r => r !== 'GBRAIN_CONTEXT_LOAD' && r !== 'GBRAIN_SAVE_RESULTS');
|
|
}
|
|
return new Set(list);
|
|
}
|
|
|
|
/** Parse CLI settings only when executing, never when imported by tests/checks. */
|
|
function parseGenerationArgs(args: string[]): GenerationOptions {
|
|
const value = (flag: string): string | undefined => {
|
|
const index = args.findIndex(arg => arg === flag || arg.startsWith(`${flag}=`));
|
|
if (index < 0) return undefined;
|
|
const arg = args[index];
|
|
const result = arg.startsWith(`${flag}=`) ? arg.slice(flag.length + 1) : args[index + 1];
|
|
if (!result || result.startsWith('--')) throw new Error(`${flag} requires a value`);
|
|
return result;
|
|
};
|
|
const hostValue = value('--host') ?? 'claude';
|
|
const host = hostValue === 'all' ? 'all' : resolveHostArg(hostValue) as Host;
|
|
const modelValue = value('--model');
|
|
const model = modelValue === undefined ? null : resolveModel(modelValue);
|
|
if (modelValue !== undefined && !model) {
|
|
throw new Error(`Unknown model: ${modelValue}. Use ${ALL_MODEL_NAMES.join(', ')}, or a family variant (e.g., claude-opus-4-7, gpt-5.4-mini, o3).`);
|
|
}
|
|
const catalogMode = value('--catalog-mode') ?? 'trim';
|
|
if (catalogMode !== 'trim' && catalogMode !== 'full') {
|
|
throw new Error(`Unknown catalog mode: ${catalogMode}. Use 'trim' (default) or 'full'.`);
|
|
}
|
|
const explainLevel = value('--explain-level') ?? 'default';
|
|
if (explainLevel !== 'default' && explainLevel !== 'terse') {
|
|
throw new Error(`Unknown explain level: ${explainLevel}. Use 'default' or 'terse'.`);
|
|
}
|
|
const planModeValue = value('--plan-mode');
|
|
if (planModeValue !== undefined && planModeValue !== 'on' && planModeValue !== 'off') {
|
|
throw new Error(`Unknown plan mode: ${planModeValue}. Use 'on' or 'off'.`);
|
|
}
|
|
const planMode = planModeValue === 'off' ? false : true;
|
|
const outDir = value('--out-dir');
|
|
const linkRoot = value('--link-root');
|
|
const disabledValue = value('--disabled-skills');
|
|
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 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)})`);
|
|
}
|
|
// Swap-in callers use --link-root for the FINAL serving path (#2692).
|
|
// Direct --out-dir callers retain their existing links into the render.
|
|
return {
|
|
host, model, catalogMode, explainLevel, planMode,
|
|
dryRun: args.includes('--dry-run'),
|
|
respectDetection: args.includes('--respect-detection'),
|
|
outputRoot: outDir === undefined ? ROOT : path.resolve(outDir),
|
|
contentLinkRoot: linkRoot !== undefined ? path.resolve(linkRoot)
|
|
: outDir !== undefined ? path.resolve(outDir) : null,
|
|
installRoot: installRoot ?? null,
|
|
disabledSkills,
|
|
};
|
|
}
|
|
|
|
/** Install roots are spliced into shell lines unquoted, so only plain absolute paths. */
|
|
const INSTALL_ROOT_PATTERN = /^\/[A-Za-z0-9_.@+\/-]*$/;
|
|
|
|
/**
|
|
* Install-context render contract (docs/ADDING_A_HOST.md): a per-install render
|
|
* names its own install root instead of the host's default global root. Null
|
|
* keeps the committed bytes.
|
|
*/
|
|
function rewriteInstallRoot(content: string, hostConfig: HostConfig, installRoot: string | null): string {
|
|
if (!installRoot) return content;
|
|
const root = installRoot.replace(/\/+$/, '');
|
|
const defaults = hostConfig.usesEnvVars
|
|
? [`$HOME/${hostConfig.globalRoot}`, `~/${hostConfig.globalRoot}`]
|
|
: ['$HOME/.claude/skills/gstack', '~/.claude/skills/gstack'];
|
|
return defaults.reduce((text, from) => text.split(from).join(root), content);
|
|
}
|
|
|
|
/**
|
|
* C8 (#3018): the router names every skill, so it routed to skills the user
|
|
* disabled (it also said "When in doubt, invoke the skill" until v1.91.31). A per-install render that
|
|
* knows its disabled skills drops their routing rules (keeping the other side
|
|
* of an "A or B" rule) and says which skills are off. The router itself and
|
|
* the way back (gstack-upgrade) can never be disabled.
|
|
*/
|
|
function omitDisabledSkills(content: string, disabled: string[]): string {
|
|
const off = new Set(disabled.map(n => n.replace(/^\//, '')).filter(n => n !== 'gstack' && n !== 'gstack-upgrade').map(n => n.replace(/^gstack-/, '')));
|
|
if (off.size === 0) return content;
|
|
const isOff = (ref: string) => off.has(ref.replace(/^`\/(?:gstack-)?|`$/g, ''));
|
|
const routed = content.split('\n').flatMap(line => {
|
|
const m = line.match(/^(- .*→ invoke )(`\/[a-z0-9-]+`(?: or `\/[a-z0-9-]+`)*)(.*)$/);
|
|
if (!m) return [line];
|
|
const kept = m[2].split(' or ').filter(ref => !isOff(ref));
|
|
return kept.length ? [`${m[1]}${kept.join(' or ')}${m[3]}`] : [];
|
|
}).join('\n');
|
|
const names = [...off].map(n => `\`/${n}\``).join(', ');
|
|
return routed.replace('\n## Route first\n', `\n## Route first\n\nDisabled on this install (never invoke or suggest them): ${names}.\n`);
|
|
}
|
|
|
|
/** Repoint only Claude section links, retaining global bin/browse/doc paths. */
|
|
function rewriteSectionBase(content: string, linkRoot: string | null): string {
|
|
if (!linkRoot) return content;
|
|
// Callback replacement preserves literal $ sequences in configured paths.
|
|
return content.replace(
|
|
/~\/\.claude\/skills\/gstack\/([^\s)`"'*]+\/sections\/)/g,
|
|
(_m, p1: string) => `${linkRoot}/${p1}`,
|
|
);
|
|
}
|
|
|
|
// HostPaths, HOST_PATHS, and TemplateContext imported from ./resolvers/types (line 7-8)
|
|
// Design constants (AI_SLOP_BLACKLIST, OPENAI_HARD_REJECTIONS, OPENAI_LITMUS_CHECKS)
|
|
// live in ./resolvers/constants and are consumed by resolvers directly.
|
|
|
|
// ─── External Host Helpers ───────────────────────────────────
|
|
|
|
// ─── Voice Trigger Processing ────────────────────────────────
|
|
|
|
/**
|
|
* Preprocess voice triggers: fold voice-triggers YAML field into description,
|
|
* then strip the field from frontmatter. Must run BEFORE transformFrontmatter
|
|
* and extractNameAndDescription so all hosts see the updated description.
|
|
*/
|
|
function processVoiceTriggers(content: string): string {
|
|
const triggers = extractVoiceTriggers(content);
|
|
if (triggers.length === 0) return content;
|
|
|
|
// Strip voice-triggers block from frontmatter
|
|
content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, '');
|
|
|
|
// Get current description (after stripping voice-triggers, so it's clean)
|
|
const { description } = extractNameAndDescription(content);
|
|
if (!description) return content;
|
|
|
|
// Build new description with voice triggers appended
|
|
const voiceLine = `Voice triggers (speech-to-text aliases): ${triggers.map(t => `"${t}"`).join(', ')}.`;
|
|
const newDescription = description + '\n' + voiceLine;
|
|
|
|
// Replace old indented description with new in frontmatter
|
|
const oldIndented = description.split('\n').map(l => ` ${l}`).join('\n');
|
|
const newIndented = newDescription.split('\n').map(l => ` ${l}`).join('\n');
|
|
content = content.replace(oldIndented, newIndented);
|
|
|
|
return content;
|
|
}
|
|
|
|
// Export for testing
|
|
export { extractVoiceTriggers, processVoiceTriggers };
|
|
|
|
// ─── Catalog Trim (v1.45.0.0 T4) ─────────────────────────────
|
|
//
|
|
// Frontmatter `description:` blocks today pack: a one-line outcome, "Use when
|
|
// asked to..." voice triggers, "Proactively..." routing guidance, and a
|
|
// "(gstack)" tag. This pile is the always-loaded catalog surface — every
|
|
// session pays for the full text. The catalog trim splits the description
|
|
// into a one-line catalog entry (lead sentence + "(gstack)") that stays in
|
|
// the frontmatter and drops the routing/voice prose. The body is not a home
|
|
// for it: Claude Code loads a skill body only after the skill is invoked, so
|
|
// routing text there can no longer route anything. Voice triggers live on in
|
|
// the root router's routing block ({{VOICE_TRIGGER_ROUTING}}).
|
|
//
|
|
// Opt-out: `--catalog-mode=full` keeps v1.44 behavior (no trim, full
|
|
// description in frontmatter). Use when debugging routing regressions or
|
|
// when shipping skills to hosts that depend on the legacy fat catalog.
|
|
|
|
export interface CatalogParts {
|
|
lead: string; // First sentence — kept in catalog
|
|
routingProse: string; // "Use when asked to...", "Proactively..." paragraphs
|
|
voiceLine: string | null; // "Voice triggers (speech-to-text aliases): ..." line if present
|
|
hasGstackTag: boolean;
|
|
}
|
|
|
|
export function splitCatalogDescription(description: string): CatalogParts {
|
|
// Voice triggers line (folded in by processVoiceTriggers earlier)
|
|
const voiceMatch = description.match(/Voice triggers \(speech-to-text aliases\):[^\n]+/);
|
|
const voiceLine = voiceMatch ? voiceMatch[0] : null;
|
|
let working = voiceLine ? description.replace(voiceLine, '').trim() : description.trim();
|
|
|
|
const hasGstackTag = /\(gstack\)/.test(working);
|
|
if (hasGstackTag) working = working.replace(/\(gstack\)/, '').trim();
|
|
|
|
// Lead = first sentence, ending at the first `.`/`!`/`?` that is followed by
|
|
// whitespace or end-of-text. Terminator chars NOT followed by whitespace/end
|
|
// (embedded periods in "TODOS.md", URLs, "v1.45.0.0") are consumed by the
|
|
// second alternative `[.!?](?!\s|$)` and do NOT end the sentence. The two
|
|
// alternatives are disjoint character classes, so there is no ambiguity and
|
|
// no catastrophic-backtracking risk. If no terminator-followed-by-boundary
|
|
// exists at all, we fall back to a 20-word cut below.
|
|
// First normalize to single-line for sentence detection, then back out.
|
|
const collapsed = working.replace(/\s+/g, ' ').trim();
|
|
const sentenceMatch = collapsed.match(/^((?:[^.!?]|[.!?](?!\s|$))*[.!?])(?:\s|$)/);
|
|
// sentenceLead is the FULL first sentence (no truncation). We compute routing
|
|
// from this position, then optionally truncate the displayed lead afterwards.
|
|
// Truncating first then computing routing was the v1.45.0.0 bug — when the
|
|
// first sentence exceeded 200 chars, the routing extraction would lose the
|
|
// entire tail of the description (design-consultation's "Use when..."
|
|
// routing prose silently dropped).
|
|
const sentenceLead = sentenceMatch ? sentenceMatch[1].trim() : collapsed.split(/\s/).slice(0, 20).join(' ');
|
|
|
|
// Routing prose: everything AFTER the first sentence boundary in the collapsed view.
|
|
const leadInCollapsed = collapsed.indexOf(sentenceLead);
|
|
const routingCollapsed = leadInCollapsed >= 0
|
|
? collapsed.slice(leadInCollapsed + sentenceLead.length).trim()
|
|
: '';
|
|
|
|
// Now produce the displayed lead — truncated if too long. The original
|
|
// sentenceLead is preserved for routing extraction below.
|
|
let lead = sentenceLead;
|
|
if (lead.length > 200) {
|
|
const trunc = lead.slice(0, 197);
|
|
const lastSpace = trunc.lastIndexOf(' ');
|
|
lead = (lastSpace > 60 ? trunc.slice(0, lastSpace) : trunc) + '...';
|
|
}
|
|
// Restore line breaks for routing prose by mapping back to original layout.
|
|
// Use original whitespace structure where possible; fall back to collapsed.
|
|
// Anchor recovery on sentenceLead (the untruncated first sentence) — not
|
|
// `lead` (which may have a "..." suffix and won't substring-match `working`).
|
|
let routingProse = routingCollapsed;
|
|
const collapsedLeadIdx = working.replace(/\s+/g, ' ').indexOf(sentenceLead);
|
|
if (collapsedLeadIdx >= 0) {
|
|
let consumed = 0;
|
|
let cut = 0;
|
|
for (let i = 0; i < working.length && consumed < collapsedLeadIdx + sentenceLead.length; i++) {
|
|
if (/\s/.test(working[i])) {
|
|
if (i === 0 || /\s/.test(working[i - 1])) continue;
|
|
consumed += 1;
|
|
} else {
|
|
consumed += 1;
|
|
}
|
|
cut = i + 1;
|
|
}
|
|
const tail = working.slice(cut).trim();
|
|
if (tail.length > 0) routingProse = tail;
|
|
}
|
|
|
|
return { lead, routingProse, voiceLine, hasGstackTag };
|
|
}
|
|
|
|
/** Build the catalog-trimmed `description:` block. */
|
|
export function buildTrimmedDescription(parts: CatalogParts): string {
|
|
const lead = parts.lead.trim();
|
|
const suffix = parts.hasGstackTag ? ' (gstack)' : '';
|
|
return `${lead}${suffix}`;
|
|
}
|
|
|
|
/**
|
|
* Render a string as a YAML inline scalar value (the text after `key: `),
|
|
* quoting only when a plain scalar would be invalid or ambiguous.
|
|
*
|
|
* The bug this guards (#1778): a description like "Ship workflow: detect..."
|
|
* emitted as a plain scalar has an interior ": " that a strict YAML parser
|
|
* (Codex/OpenAI skill loading) reads as a nested mapping and rejects with
|
|
* "mapping values are not allowed in this context". When quoting is needed we
|
|
* fall back to JSON.stringify, which produces a double-quoted scalar that YAML
|
|
* accepts verbatim (YAML is a superset of JSON for flow scalars). Strings that
|
|
* are already valid plain scalars pass through unchanged to keep regen diffs small.
|
|
*/
|
|
export function toYamlInlineScalar(s: string): string {
|
|
const needsQuote =
|
|
s.length === 0 ||
|
|
s !== s.trim() || // leading/trailing whitespace
|
|
/:(\s|$)/.test(s) || // "foo: bar" / trailing colon → mapping ambiguity
|
|
/\s#/.test(s) || // " #" → inline comment
|
|
/\.\.\./.test(s) || // "..." → document-end marker; strict parsers reject mid-scalar (catalog-trim truncation appends it)
|
|
/^[\s>|&*!%@`"'#,\[\]{}?-]/.test(s); // leading YAML indicator char
|
|
return needsQuote ? JSON.stringify(s) : s;
|
|
}
|
|
|
|
/**
|
|
* Apply catalog trim to a SKILL.md body:
|
|
* - shorten frontmatter `description:` to lead + (gstack)
|
|
*
|
|
* Returns the rewritten content plus the extracted parts.
|
|
*/
|
|
export function applyCatalogTrim(content: string, skillName: string): { content: string; parts: CatalogParts } | null {
|
|
// Locate description block in frontmatter
|
|
if (!content.startsWith('---\n')) return null;
|
|
const fmEnd = content.indexOf('\n---', 4);
|
|
if (fmEnd === -1) return null;
|
|
const frontmatter = content.slice(4, fmEnd);
|
|
|
|
// Match `description: |` block + indented body lines
|
|
const descMatch = frontmatter.match(/^description:\s*\|?\s*\n((?:\s{2,}.*(?:\n|$))+)/m)
|
|
|| frontmatter.match(/^description:\s+(.+)$/m);
|
|
if (!descMatch) return null;
|
|
|
|
// Extract full description text
|
|
let descText: string;
|
|
if (descMatch[0].startsWith('description: |') || /^description:\s*\|/.test(descMatch[0])) {
|
|
descText = descMatch[1].split('\n').map(l => l.replace(/^\s{2}/, '')).join('\n').trim();
|
|
} else {
|
|
descText = descMatch[1].trim();
|
|
}
|
|
|
|
// Skip skills with very short descriptions (already trimmed or no routing prose).
|
|
// Below ~120 chars, splitting adds no value.
|
|
if (descText.length < 120) return null;
|
|
|
|
const parts = splitCatalogDescription(descText);
|
|
// If lead + (gstack) is already most of the text, no trim needed.
|
|
const trimmedLen = buildTrimmedDescription(parts).length;
|
|
if (trimmedLen >= descText.length - 20) return null;
|
|
|
|
// Replace description in frontmatter — keep trailing newline so the next
|
|
// YAML field doesn't collide on the same line as the description value.
|
|
// Quote the value when it would be an invalid YAML plain scalar (the common
|
|
// case: an interior ": " like "Ship workflow: detect..." which a strict YAML
|
|
// parser reads as a nested mapping and rejects — #1778). toYamlInlineScalar
|
|
// only quotes when needed, so descriptions without special chars stay plain.
|
|
const newDesc = buildTrimmedDescription(parts);
|
|
// Function replacer (not a string) so a `$` in the description — e.g. a future
|
|
// skill referencing `$B`/`$D` — can't be interpreted as a `$&`/`$1` replacement
|
|
// pattern and silently corrupt the frontmatter.
|
|
const newDescLine = `description: ${toYamlInlineScalar(newDesc)}\n`;
|
|
const newFrontmatter = frontmatter.replace(descMatch[0], () => newDescLine);
|
|
const newContent = '---\n' + newFrontmatter + content.slice(fmEnd);
|
|
|
|
return { content: newContent, parts };
|
|
}
|
|
|
|
const OPENAI_SHORT_DESCRIPTION_LIMIT = 120;
|
|
|
|
function condenseOpenAIShortDescription(description: string): string {
|
|
const firstParagraph = description.split(/\n\s*\n/)[0] || description;
|
|
const collapsed = firstParagraph.replace(/\s+/g, ' ').trim();
|
|
if (collapsed.length <= OPENAI_SHORT_DESCRIPTION_LIMIT) return collapsed;
|
|
|
|
const truncated = collapsed.slice(0, OPENAI_SHORT_DESCRIPTION_LIMIT - 3);
|
|
const lastSpace = truncated.lastIndexOf(' ');
|
|
const safe = lastSpace > 40 ? truncated.slice(0, lastSpace) : truncated;
|
|
return `${safe}...`;
|
|
}
|
|
|
|
function generateOpenAIYaml(displayName: string, shortDescription: string): string {
|
|
return `interface:
|
|
display_name: ${JSON.stringify(displayName)}
|
|
short_description: ${JSON.stringify(shortDescription)}
|
|
default_prompt: ${JSON.stringify(`Use ${displayName} for this task.`)}
|
|
policy:
|
|
allow_implicit_invocation: true
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Transform frontmatter for external hosts.
|
|
* Claude: strips `sensitive:` field (only Factory uses it).
|
|
* Codex: keeps name + description only, enforces 1024-char limit.
|
|
* Factory: keeps name + description + user-invocable, conditionally adds disable-model-invocation.
|
|
*/
|
|
function transformFrontmatter(content: string, host: Host): string {
|
|
const hostConfig = getHostConfig(host);
|
|
const fm = hostConfig.frontmatter;
|
|
|
|
if (fm.mode === 'denylist') {
|
|
// Denylist mode: strip listed fields, keep everything else
|
|
for (const field of fm.stripFields || []) {
|
|
if (field === 'voice-triggers') {
|
|
content = content.replace(/^voice-triggers:\n(?:\s+-\s+"[^"]*"\n?)*/m, '');
|
|
} else {
|
|
content = content.replace(new RegExp(`^${field}:\\s*.*\\n`, 'm'), '');
|
|
}
|
|
}
|
|
return content;
|
|
}
|
|
|
|
// Allowlist mode: reconstruct frontmatter with only allowed fields
|
|
const fmStart = content.indexOf('---\n');
|
|
if (fmStart !== 0) return content;
|
|
const fmEnd = content.indexOf('\n---', fmStart + 4);
|
|
if (fmEnd === -1) return content;
|
|
const frontmatter = content.slice(fmStart + 4, fmEnd);
|
|
const body = content.slice(fmEnd + 4);
|
|
const { name, description } = extractNameAndDescription(content);
|
|
|
|
// Description limit enforcement
|
|
if (fm.descriptionLimit) {
|
|
const behavior = fm.descriptionLimitBehavior || 'error';
|
|
if (description.length > fm.descriptionLimit) {
|
|
if (behavior === 'error') {
|
|
throw new Error(
|
|
`${hostConfig.displayName} description for "${name}" is ${description.length} chars (max ${fm.descriptionLimit}). ` +
|
|
`Compress the description in the .tmpl file.`
|
|
);
|
|
} else if (behavior === 'warn') {
|
|
console.warn(`WARNING: ${hostConfig.displayName} description for "${name}" exceeds ${fm.descriptionLimit} chars`);
|
|
}
|
|
// 'truncate' — silently proceed
|
|
}
|
|
}
|
|
|
|
// Build frontmatter with allowed fields
|
|
const indentedDesc = description.split('\n').map(l => ` ${l}`).join('\n');
|
|
const fmName = fm.nameMatchesDirectory && name !== 'gstack' && !name.startsWith('gstack-') ? `gstack-${name}` : name;
|
|
let newFm = `---\nname: ${fmName}\ndescription: |\n${indentedDesc}\n`;
|
|
|
|
// Add extra fields (host-wide)
|
|
if (fm.extraFields) {
|
|
for (const [key, value] of Object.entries(fm.extraFields)) {
|
|
if (key !== 'name' && key !== 'description') {
|
|
newFm += `${key}: ${value}\n`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add conditional fields
|
|
if (fm.conditionalFields) {
|
|
for (const rule of fm.conditionalFields) {
|
|
const match = Object.entries(rule.if).every(([k, v]) =>
|
|
new RegExp(`^${k}:\\s*${v}`, 'm').test(frontmatter)
|
|
);
|
|
if (match) {
|
|
for (const [key, value] of Object.entries(rule.add)) {
|
|
newFm += `${key}: ${value}\n`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Preserve additional keepFields beyond name and description
|
|
if (fm.keepFields) {
|
|
for (const field of fm.keepFields) {
|
|
if (field === 'name' || field === 'description') continue;
|
|
// Match YAML field with possible multi-line/array value (indented lines after colon)
|
|
const fieldMatch = frontmatter.match(new RegExp(`^${field}:(.*(?:\\n(?:[ \\t]+.+))*)`, 'm'));
|
|
if (fieldMatch) {
|
|
newFm += `${field}:${fieldMatch[1]}\n`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rename fields (copy values from template frontmatter with new keys)
|
|
if (fm.renameFields) {
|
|
for (const [oldName, newName] of Object.entries(fm.renameFields)) {
|
|
const fieldMatch = frontmatter.match(new RegExp(`^${oldName}:(.+(?:\\n(?:\\s+.+)*)?)`, 'm'));
|
|
if (fieldMatch) {
|
|
newFm += `${newName}:${fieldMatch[1]}\n`;
|
|
}
|
|
}
|
|
}
|
|
|
|
newFm += '---';
|
|
return newFm + body;
|
|
}
|
|
|
|
/**
|
|
* Extract hook descriptions from frontmatter for inline safety prose.
|
|
* Returns a description of what the hooks do, or null if no hooks.
|
|
*/
|
|
function extractHookSafetyProse(tmplContent: string, hostConfig: HostConfig): string | null {
|
|
if (!tmplContent.match(/^hooks:/m)) return null;
|
|
if (hostConfig.capabilities.safetyHooks === 'enforced') return null;
|
|
|
|
// Parse the hook matchers to build a human-readable safety description
|
|
const matchers: string[] = [];
|
|
const matcherRegex = /matcher:\s*"(\w+)"/g;
|
|
let m;
|
|
while ((m = matcherRegex.exec(tmplContent)) !== null) {
|
|
if (!matchers.includes(m[1])) matchers.push(m[1]);
|
|
}
|
|
|
|
if (matchers.length === 0) return null;
|
|
|
|
// Build safety prose based on what tools are hooked
|
|
const toolDescriptions: Record<string, string> = {
|
|
Bash: 'check bash commands for destructive operations (rm -rf, DROP TABLE, force-push, git reset --hard, etc.) before execution',
|
|
Edit: 'verify file edits are within the allowed scope boundary before applying',
|
|
Write: 'verify file writes are within the allowed scope boundary before applying',
|
|
};
|
|
|
|
const safetyChecks = matchers
|
|
.map(t => toolDescriptions[t] || `check ${t} operations for safety`)
|
|
.join(', and ');
|
|
|
|
return `> **Safety Advisory — not enforced on ${hostConfig.displayName}:** advisory, not blocked. ${hostConfig.displayName} runs no gstack safety hooks, so nothing stops a command automatically. On Claude Code this skill's hooks ${safetyChecks}; here, do those checks yourself: always pause and verify before executing potentially destructive operations. If uncertain about a command's safety, ask the user for confirmation before proceeding.`;
|
|
}
|
|
|
|
// ─── External Host Config (now derived from hosts/*.ts) ──────
|
|
// EXTERNAL_HOST_CONFIG replaced by getHostConfig() from hosts/index.ts
|
|
|
|
// ─── Template Processing ────────────────────────────────────
|
|
|
|
const GENERATED_HEADER = `<!-- AUTO-GENERATED from {{SOURCE}} — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n`;
|
|
|
|
/**
|
|
* Apply a host's configured path + tool rewrites. Extracted so both SKILL.md
|
|
* (via processExternalHost) and section files (via processSectionTemplate) get
|
|
* identical per-host treatment — a section's cross-references must rewrite the
|
|
* same way the parent skill's do, or external hosts get wrong paths.
|
|
*/
|
|
function applyHostRewrites(content: string, hostConfig: HostConfig): string {
|
|
let result = content;
|
|
for (const rewrite of hostConfig.pathRewrites) {
|
|
result = result.replaceAll(rewrite.from, rewrite.to);
|
|
}
|
|
if (hostConfig.toolRewrites) {
|
|
for (const [from, to] of Object.entries(hostConfig.toolRewrites)) {
|
|
result = result.replaceAll(from, to);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Resolve {{PLACEHOLDER}} / {{NAME:arg}} tokens against the RESOLVERS registry,
|
|
* honoring host suppression and appliesTo gating, then assert nothing is left
|
|
* unresolved. Extracted so SKILL.md and section templates resolve through the
|
|
* exact same path — a security/sanitization fix to one can't miss the other.
|
|
*/
|
|
/**
|
|
* A second {{PREAMBLE}} in one template re-expands the entire ~12K-token
|
|
* preamble mid-document (#2508/#2362 — a PROSE mention of the macro in
|
|
* spec/SKILL.md.tmpl expanded it a second time, +43KB per /spec load).
|
|
* Resolution is context-blind, so any second occurrence — code fence, prose,
|
|
* anywhere — is a generation error, never intentional. Throw at render time
|
|
* so the mistake cannot reach a generated SKILL.md again.
|
|
*/
|
|
export function assertSinglePreamble(tmplContent: string, relTmplPath: string): void {
|
|
const count = (tmplContent.match(/\{\{PREAMBLE\}\}/g) || []).length;
|
|
if (count > 1) {
|
|
throw new Error(
|
|
`${relTmplPath} contains {{PREAMBLE}} ${count} times — a template may reference it `
|
|
+ `at most once (each occurrence expands the full preamble; see #2508/#2362). `
|
|
+ `Refer to "the preamble" in prose instead of the macro.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function resolvePlaceholders(
|
|
tmplContent: string,
|
|
ctx: TemplateContext,
|
|
hostConfig: HostConfig,
|
|
relTmplPath: string,
|
|
options: RenderOptions,
|
|
): string {
|
|
assertSinglePreamble(tmplContent, relTmplPath);
|
|
// effectiveSuppressedResolvers() honors --respect-detection: when gbrain is
|
|
// detected locally, GBRAIN_* resolvers un-suppress. Shared by SKILL.md and
|
|
// 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) => {
|
|
const parts = fullKey.split(':');
|
|
const resolverName = parts[0];
|
|
const args = parts.slice(1);
|
|
if (suppressed.has(resolverName)) return '';
|
|
const resolve = RESOLVERS[resolverName];
|
|
if (!resolve) throw new Error(`Unknown placeholder {{${resolverName}}} in ${relTmplPath}`);
|
|
return args.length > 0 ? resolve(ctx, args) : resolve(ctx);
|
|
});
|
|
|
|
// Multi-pass: a resolver may emit content that itself contains {{TOKENS}} — the
|
|
// {{SECTION:id}} resolver inlines a section template (with its own resolvers)
|
|
// for non-Claude hosts. .replace() doesn't re-scan inserted text, so loop until
|
|
// the output stabilizes. Bounded to avoid an infinite loop if a resolver ever
|
|
// emits its own placeholder; 6 passes is far more nesting than any skill needs.
|
|
let content = tmplContent;
|
|
for (let pass = 0; pass < 6; pass++) {
|
|
const next = onePass(content);
|
|
if (next === content) break;
|
|
content = next;
|
|
}
|
|
|
|
const remaining = content.match(/\{\{(\w+(?::[^}]+)?)\}\}/g);
|
|
if (remaining) {
|
|
throw new Error(`Unresolved placeholders in ${relTmplPath}: ${remaining.join(', ')}`);
|
|
}
|
|
return content;
|
|
}
|
|
|
|
/**
|
|
* Build the TemplateContext from a template's frontmatter. Shared by SKILL.md
|
|
* and section generation so sections inherit the SAME context the parent skill
|
|
* resolves with (skillName, tier, benefitsFrom, interactive) — enforced by
|
|
* test/template-context-parity.test.ts. skillNameOverride lets section
|
|
* generation pin the parent skill's name instead of deriving "sections".
|
|
*/
|
|
function buildContext(
|
|
tmplContent: string,
|
|
tmplPath: string,
|
|
host: Host,
|
|
options: RenderOptions,
|
|
skillNameOverride?: string,
|
|
): TemplateContext {
|
|
const { name: extractedName } = extractNameAndDescription(tmplContent);
|
|
const skillName = skillNameOverride || extractedName || path.basename(path.dirname(tmplPath));
|
|
const benefitsMatch = tmplContent.match(/^benefits-from:\s*\[([^\]]*)\]/m);
|
|
const benefitsFrom = benefitsMatch
|
|
? benefitsMatch[1].split(',').map(s => s.trim()).filter(Boolean)
|
|
: undefined;
|
|
const tierMatch = tmplContent.match(/^preamble-tier:\s*(\d+)$/m);
|
|
const preambleTier = tierMatch ? parseInt(tierMatch[1], 10) : undefined;
|
|
const interactiveMatch = tmplContent.match(/^interactive:\s*(true|false)\s*$/m);
|
|
const interactive = interactiveMatch ? interactiveMatch[1] === 'true' : undefined;
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Process external host output: routing, frontmatter, path rewrites, metadata.
|
|
* Shared between Codex and Factory (and future external hosts).
|
|
*/
|
|
function processExternalHost(
|
|
content: string,
|
|
tmplContent: string,
|
|
host: Host,
|
|
skillDir: string,
|
|
extractedDescription: string,
|
|
ctx: TemplateContext,
|
|
options: RenderOptions,
|
|
frontmatterName?: string,
|
|
): { content: string; outputPath: string; symlinkLoop: boolean; metadata?: { outputPath: string; content: string } } {
|
|
const hostConfig = getHostConfig(host);
|
|
|
|
const name = externalSkillName(skillDir === '.' ? '' : skillDir, frontmatterName);
|
|
// --out-dir mirrors the host tree (outputs only; inputs read from ROOT).
|
|
const outputDir = path.join(options.outputRoot, hostConfig.hostSubdir, 'skills', name);
|
|
const outputPath = path.join(outputDir, 'SKILL.md');
|
|
|
|
// Guard against symlink loops
|
|
let symlinkLoop = false;
|
|
const claudePath = ctx.tmplPath.replace(/\.tmpl$/, '');
|
|
try {
|
|
const resolvedClaude = fs.realpathSync(claudePath);
|
|
const resolvedExternal = path.join(fs.realpathSync(path.dirname(outputPath)), path.basename(outputPath));
|
|
if (resolvedClaude === resolvedExternal) {
|
|
symlinkLoop = true;
|
|
}
|
|
} catch {
|
|
// realpathSync fails if file doesn't exist yet — no symlink loop
|
|
}
|
|
|
|
// Extract hook safety prose BEFORE transforming frontmatter (which strips hooks)
|
|
const safetyProse = extractHookSafetyProse(tmplContent, hostConfig);
|
|
|
|
// Transform frontmatter (host-aware)
|
|
let result = transformFrontmatter(content, host);
|
|
|
|
// Insert safety advisory at the top of the body (after frontmatter)
|
|
if (safetyProse) {
|
|
const bodyStart = result.indexOf('\n---') + 4;
|
|
result = result.slice(0, bodyStart) + '\n' + safetyProse + '\n' + result.slice(bodyStart);
|
|
}
|
|
|
|
// Config-driven path + tool rewrites (shared with processSectionTemplate so
|
|
// section cross-references get the same per-host treatment as SKILL.md).
|
|
result = applyHostRewrites(result, hostConfig);
|
|
|
|
// Config-driven: generate metadata (e.g., openai.yaml for Codex)
|
|
const metadata = hostConfig.generation.generateMetadata && !symlinkLoop ? {
|
|
outputPath: path.join(outputDir, 'agents', 'openai.yaml'),
|
|
content: generateOpenAIYaml(name, condenseOpenAIShortDescription(extractedDescription)),
|
|
} : undefined;
|
|
|
|
return { content: result, outputPath, symlinkLoop, metadata };
|
|
}
|
|
|
|
/** sections/preamble.md: the shared preamble with the same host rewrites as a section file. */
|
|
function renderSharedPreamble(ctx: TemplateContext, hostConfig: HostConfig, options: RenderOptions): string {
|
|
let content = generateSharedPreambleFile(ctx);
|
|
content = ctx.host === 'claude' ? rewriteSectionBase(content, options.contentLinkRoot) : applyHostRewrites(content, hostConfig);
|
|
content = rewriteInstallRoot(insertRuntimePreludes(rewriteCarvedSectionRefs(content, ctx), ctx), hostConfig, options.installRoot);
|
|
return GENERATED_HEADER.replace('{{SOURCE}}', 'scripts/resolvers/preamble-hoist.ts') + content;
|
|
}
|
|
|
|
function processTemplate(tmplPath: string, host: Host, options: RenderOptions): { outputPath: string; content: string; symlinkLoop?: boolean; metadata?: { outputPath: string; content: string }; preamble?: { outputPath: string; content: string } } {
|
|
// Normalize to LF at the entry point. Templates may have CRLF on disk when
|
|
// checked out on Windows with core.autocrlf=true. Downstream regexes
|
|
// (processVoiceTriggers, transformFrontmatter) hardcode \n, so without
|
|
// normalization they silently no-op on CRLF — producing different output
|
|
// than CI (Linux, LF) and breaking the Skill Docs Freshness check.
|
|
// (catalogParts left the return type with the proactive-suggestions
|
|
// retirement — merge of the two v1.64 waves.)
|
|
const tmplContent = fs.readFileSync(tmplPath, 'utf-8').replace(/\r\n/g, '\n');
|
|
const relTmplPath = path.relative(ROOT, tmplPath);
|
|
let outputPath = tmplPath.replace(/\.tmpl$/, '');
|
|
|
|
// Determine skill directory relative to ROOT
|
|
const skillDir = path.relative(ROOT, path.dirname(tmplPath));
|
|
|
|
// --out-dir: mirror the skill tree into the out-dir instead of writing in
|
|
// place (external hosts compute their own output paths below).
|
|
if (host === 'claude') {
|
|
outputPath = path.join(options.outputRoot, skillDir, path.basename(tmplPath).replace(/\.tmpl$/, ''));
|
|
}
|
|
|
|
// Extract name/description: name drives external skill naming + setup symlinks
|
|
// (and TemplateContext.skillName via buildContext); description feeds external
|
|
// host metadata. When frontmatter name: differs from directory name (e.g.
|
|
// run-tests/ with name: test), the frontmatter name wins.
|
|
const { name: extractedName, description: extractedDescription } = extractNameAndDescription(tmplContent);
|
|
|
|
const currentHostConfig = getHostConfig(host);
|
|
const ctx = buildContext(tmplContent, tmplPath, host, options);
|
|
const skillName = ctx.skillName;
|
|
|
|
// Replace placeholders + assert none remain (shared path with section generation).
|
|
let content = resolvePlaceholders(tmplContent, ctx, currentHostConfig, relTmplPath, options);
|
|
|
|
// Preprocess voice triggers: fold into description, strip field from frontmatter.
|
|
// Must run BEFORE transformFrontmatter so all hosts see the updated description,
|
|
// and BEFORE extractedDescription is used by external host metadata.
|
|
content = processVoiceTriggers(content);
|
|
|
|
// Re-extract description AFTER voice trigger preprocessing so Codex openai.yaml
|
|
// metadata gets the updated description with voice triggers included.
|
|
const postProcessDescription = extractNameAndDescription(content).description;
|
|
|
|
// For Claude: strip sensitive: field (only Factory uses it)
|
|
// For external hosts: route output, transform frontmatter, rewrite paths
|
|
let symlinkLoop = false;
|
|
let metadata: { outputPath: string; content: string } | undefined;
|
|
if (host === 'claude') {
|
|
content = transformFrontmatter(content, host);
|
|
} else {
|
|
const result = processExternalHost(content, tmplContent, host, skillDir, postProcessDescription, ctx, options, extractedName || undefined);
|
|
content = result.content;
|
|
outputPath = result.outputPath;
|
|
symlinkLoop = result.symlinkLoop;
|
|
metadata = result.metadata;
|
|
}
|
|
|
|
// Prepend generated header (after frontmatter)
|
|
const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath));
|
|
const fmEnd = content.indexOf('---', content.indexOf('---') + 3);
|
|
if (fmEnd !== -1) {
|
|
const insertAt = content.indexOf('\n', fmEnd) + 1;
|
|
content = content.slice(0, insertAt) + header + content.slice(insertAt);
|
|
} else {
|
|
content = header + content;
|
|
}
|
|
|
|
// Catalog trim (Claude only — external hosts have their own frontmatter shapes)
|
|
if (host === 'claude' && options.catalogMode === 'trim') {
|
|
const trimmed = applyCatalogTrim(content, skillName);
|
|
if (trimmed) content = trimmed.content;
|
|
}
|
|
|
|
// --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);
|
|
content = rewriteInstallRoot(insertRuntimePreludes(rewriteCarvedSectionRefs(content, ctx), ctx), currentHostConfig, options.installRoot);
|
|
|
|
const preamble = hoistsPreamble(ctx) && tmplContent.includes('{{PREAMBLE}}') && !effectiveSuppressedResolvers(currentHostConfig, options).has('PREAMBLE')
|
|
? { outputPath: path.join(path.dirname(outputPath), 'sections', SHARED_PREAMBLE_FILE), content: renderSharedPreamble(ctx, currentHostConfig, options) }
|
|
: undefined;
|
|
return { outputPath, content, symlinkLoop, metadata, preamble };
|
|
}
|
|
|
|
/** autoplan/DRIVER.md (field wave item 13): one host-neutral render of autoplan/DRIVER.md.tmpl through the same resolvers. */
|
|
export const AUTOPLAN_DRIVER_RELPATH = 'autoplan/DRIVER.md';
|
|
export function renderAutoplanDriver(options: RenderOptions): string {
|
|
const tmplPath = path.join(ROOT, `${AUTOPLAN_DRIVER_RELPATH}.tmpl`);
|
|
const tmpl = fs.readFileSync(tmplPath, 'utf-8');
|
|
const ctx = buildContext(tmpl, tmplPath, 'claude', options, 'autoplan');
|
|
return GENERATED_HEADER.replace('{{SOURCE}}', `${AUTOPLAN_DRIVER_RELPATH}.tmpl`) + resolvePlaceholders(tmpl, ctx, getHostConfig('claude'), `${AUTOPLAN_DRIVER_RELPATH}.tmpl`, options);
|
|
}
|
|
|
|
/**
|
|
* Generate one on-demand section file (`<skill>/sections/<name>.md.tmpl` →
|
|
* `<name>.md`). Sections are BODY FRAGMENTS — no frontmatter, no catalog trim,
|
|
* no voice triggers. They resolve placeholders through the SAME path as
|
|
* SKILL.md (resolvePlaceholders) using the PARENT skill's TemplateContext
|
|
* (so appliesTo gating + tier behave identically — a section's {{PREAMBLE}}-
|
|
* style resolver renders the same content it would in the parent, not empty).
|
|
*
|
|
* Output routing mirrors SKILL.md: Claude writes in-tree at
|
|
* `<skill>/sections/<name>.md`; external hosts write to
|
|
* `<hostSubdir>/skills/<externalName>/sections/<name>.md`. External hosts get
|
|
* applyHostRewrites so cross-references resolve per host.
|
|
*/
|
|
function processSectionTemplate(
|
|
sectionTmplPath: string,
|
|
skillDir: string,
|
|
host: Host,
|
|
options: RenderOptions,
|
|
): { outputPath: string; content: string } {
|
|
const tmplContent = fs.readFileSync(sectionTmplPath, 'utf-8');
|
|
const relTmplPath = path.relative(ROOT, sectionTmplPath);
|
|
const hostConfig = getHostConfig(host);
|
|
|
|
// Read the owning SKILL.md.tmpl so the section inherits the parent's name +
|
|
// tier + benefits-from (TemplateContext parity). Fall back to the dir name.
|
|
const parentTmplPath = path.join(ROOT, skillDir, 'SKILL.md.tmpl');
|
|
const parentContent = fs.existsSync(parentTmplPath) ? fs.readFileSync(parentTmplPath, 'utf-8') : '';
|
|
const parentName = (parentContent && extractNameAndDescription(parentContent).name) || skillDir;
|
|
const ctx = buildContext(parentContent || tmplContent, parentTmplPath, host, options, parentName);
|
|
|
|
// Resolve placeholders against the section body (shared guard catches stragglers).
|
|
let content = resolvePlaceholders(tmplContent, ctx, hostConfig, relTmplPath, options);
|
|
|
|
// External hosts: rewrite cross-reference paths/tools (no frontmatter to transform).
|
|
if (host !== 'claude') {
|
|
content = applyHostRewrites(content, hostConfig);
|
|
} else {
|
|
// --out-dir: a section may cross-reference another section by absolute path;
|
|
// repoint those to the out-dir too (no-op when --out-dir is unset).
|
|
content = rewriteSectionBase(content, options.contentLinkRoot);
|
|
}
|
|
content = rewriteInstallRoot(insertRuntimePreludes(rewriteCarvedSectionRefs(content, ctx), ctx), hostConfig, options.installRoot);
|
|
|
|
// Plain generated header (no frontmatter to insert after).
|
|
content = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(sectionTmplPath)) + content;
|
|
|
|
const fileName = path.basename(sectionTmplPath).replace(/\.tmpl$/, '');
|
|
let outputPath: string;
|
|
if (host === 'claude') {
|
|
outputPath = path.join(options.outputRoot, skillDir, 'sections', fileName);
|
|
} else {
|
|
const externalName = externalSkillName(skillDir, parentName);
|
|
outputPath = path.join(options.outputRoot, hostConfig.hostSubdir, 'skills', externalName, 'sections', fileName);
|
|
}
|
|
return { outputPath, content };
|
|
}
|
|
|
|
// ─── Main ───────────────────────────────────────────────────
|
|
|
|
/** Render each artifact once. Artifact writes go through emit(); stale external
|
|
* caches are pruned only after a successful host render. Dry runs use the same
|
|
* inventory as normal generation, including metadata and
|
|
* shared outputs. Options are per invocation so imports/concurrent runs cannot
|
|
* inherit another caller's model, detection, or output paths.
|
|
*
|
|
* templates + host settings -> render -> emit -> dry-run: compare only
|
|
* | -> normal: mkdir + write
|
|
* shared index/digest ---------------+ -> artifact inventory + diagnostics
|
|
* successful external host -----------------> normal only: prune retired caches
|
|
*/
|
|
export async function runGeneration(settings: GenerationOptions = {}): Promise<GenerationResult> {
|
|
const options: RenderOptions = {
|
|
outputRoot: path.resolve(settings.outputRoot ?? ROOT),
|
|
contentLinkRoot: settings.contentLinkRoot ?? null,
|
|
installRoot: settings.installRoot ?? null,
|
|
disabledSkills: settings.disabledSkills ?? [],
|
|
model: settings.model ?? null,
|
|
catalogMode: settings.catalogMode ?? 'trim',
|
|
explainLevel: settings.explainLevel ?? 'default',
|
|
gbrainDetected: loadGbrainOverride(settings.respectDetection ?? false),
|
|
planMode: settings.planMode ?? true,
|
|
};
|
|
const hosts = settings.host === 'all' ? ALL_HOST_NAMES as Host[] : [settings.host ?? 'claude'];
|
|
const log = settings.log ?? (() => {});
|
|
const artifacts: GeneratedArtifact[] = [];
|
|
const diagnostics: GenerationDiagnostic[] = [];
|
|
const templates = discoverTemplates(ROOT);
|
|
const sections = discoverSectionTemplates(ROOT);
|
|
const rel = (outputPath: string) => path.relative(options.outputRoot, outputPath).split(path.sep).join('/');
|
|
|
|
function emit(outputPath: string, content: string, kind: GeneratedArtifact['kind'], host?: Host): void {
|
|
const relativePath = rel(outputPath);
|
|
artifacts.push({ relativePath, kind, ...(host ? { host } : {}) });
|
|
try {
|
|
if (settings.dryRun) {
|
|
let existing: string | undefined;
|
|
try {
|
|
existing = fs.readFileSync(outputPath, 'utf-8');
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
// Windows reports ENOENT for a child of a regular file. Distinguish
|
|
// that filesystem error from a missing artifact without writing.
|
|
let parent = path.dirname(outputPath);
|
|
while (true) {
|
|
try {
|
|
if (!fs.statSync(parent).isDirectory()) {
|
|
throw Object.assign(new Error(`ENOTDIR: output ancestor is not a directory: ${parent}`, { cause: error }), { code: 'ENOTDIR' });
|
|
}
|
|
break;
|
|
} catch (ancestorError) {
|
|
if ((ancestorError as NodeJS.ErrnoException).code !== 'ENOENT') throw ancestorError;
|
|
const next = path.dirname(parent);
|
|
if (next === parent) throw error;
|
|
parent = next;
|
|
}
|
|
}
|
|
}
|
|
if (existing !== content) {
|
|
diagnostics.push({ kind: 'stale', relativePath, host, message: `STALE: ${relativePath}` });
|
|
log(`STALE: ${relativePath}`);
|
|
} else {
|
|
log(`FRESH: ${relativePath}`);
|
|
}
|
|
} else {
|
|
mkdirpSync(path.dirname(outputPath));
|
|
fs.writeFileSync(outputPath, content);
|
|
log(`GENERATED: ${relativePath}`);
|
|
}
|
|
} catch (error) {
|
|
throw Object.assign(new Error(`${relativePath}: ${(error as Error).message}`), { relativePath });
|
|
}
|
|
}
|
|
|
|
function failed(error: unknown, host?: Host): void {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
diagnostics.push({ kind: 'error', host, message, relativePath: (error as { relativePath?: string })?.relativePath });
|
|
}
|
|
|
|
for (const host of hosts) {
|
|
try {
|
|
const hostConfig = getHostConfig(host);
|
|
const tokenBudget: Array<{ skill: string; lines: number; tokens: number }> = [];
|
|
const renderedNames = new Set<string>();
|
|
for (const template of templates) {
|
|
const skillDir = path.dirname(template.tmpl);
|
|
if (!includesSkill(hostConfig, 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)));
|
|
if (result.symlinkLoop) {
|
|
diagnostics.push({ kind: 'skipped', relativePath, host, message: `SKIPPED (symlink loop): ${relativePath}` });
|
|
log(`SKIPPED (symlink loop): ${relativePath}`);
|
|
continue;
|
|
}
|
|
emit(result.outputPath, result.content, 'skill', host);
|
|
if (result.metadata) emit(result.metadata.outputPath, result.metadata.content, 'metadata', host);
|
|
if (result.preamble) emit(result.preamble.outputPath, result.preamble.content, 'preamble', host);
|
|
if (skillDir === 'qa') {
|
|
const report = fs.readFileSync(path.join(ROOT, 'qa', 'templates', 'functional-report-template.md'), 'utf-8');
|
|
emit(path.join(path.dirname(result.outputPath), 'templates', 'functional-report-template.md'),
|
|
(host === 'claude' ? '' : GENERATED_HEADER.replace('{{SOURCE}}', 'qa/templates/functional-report-template.md')) + report, 'asset', host);
|
|
}
|
|
tokenBudget.push({ skill: relativePath, lines: result.content.split('\n').length, tokens: Math.round(result.content.length / 4) });
|
|
const bytes = Buffer.byteLength(result.content, 'utf8');
|
|
if (bytes > SKILL_BYTE_CEILING) {
|
|
// Setup renders per install (--install-root): an oversized skill must never leave a user with none.
|
|
const message = `${host}/${path.basename(path.dirname(result.outputPath))}/SKILL.md is ${bytes} bytes, over the ${SKILL_BYTE_CEILING.toLocaleString('en-US')}-byte limit. Fix: carve sections with usesLazySections() for this skill.`;
|
|
diagnostics.push({ kind: options.installRoot ? 'warning' : 'error', host, relativePath, message });
|
|
}
|
|
}
|
|
|
|
for (const section of sections) {
|
|
if (!includesSkill(hostConfig, section.skillDir) || !usesLazySections(host, section.skillDir)) 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) });
|
|
}
|
|
|
|
// Claude owns these catalog-derived runtime assets. Use the same host
|
|
// inclusion rule and compare-or-write path as every other artifact.
|
|
if (host === 'claude' && includesSkill(hostConfig, 'review')) {
|
|
emit(path.join(options.outputRoot, 'review', 'design-checklist.md'),
|
|
generateDesignChecklistMd(), 'asset', host);
|
|
emit(path.join(options.outputRoot, DOM_DUMP_FILE), DOM_DUMP_SCRIPT + '\n', 'asset', host);
|
|
}
|
|
if (host === 'claude' && includesSkill(hostConfig, 'autoplan') && templates.some(t => t.tmpl === 'autoplan/SKILL.md.tmpl')) {
|
|
emit(path.join(options.outputRoot, AUTOPLAN_DRIVER_RELPATH), renderAutoplanDriver(options), 'asset', host);
|
|
}
|
|
|
|
if (host === 'openclaw') {
|
|
for (const variant of ['lite', 'full', 'plan'] as const) {
|
|
const fileName = `gstack-${variant}-CLAUDE.md`;
|
|
emit(path.join(options.outputRoot, 'openclaw', fileName),
|
|
fs.readFileSync(path.join(ROOT, 'openclaw', 'templates', fileName), 'utf-8'), 'openclaw', host);
|
|
}
|
|
}
|
|
|
|
// A failed render exits this try before pruning: its inventory is partial.
|
|
// Only remove generated directories owned by this host; sidecars and user
|
|
// skills survive. Dry runs never create, rewrite, or remove any directory.
|
|
if (!settings.dryRun && host !== 'claude') {
|
|
const skillsRoot = path.join(options.outputRoot, hostConfig.hostSubdir, 'skills');
|
|
let entries: fs.Dirent[] = [];
|
|
try {
|
|
entries = fs.readdirSync(skillsRoot, { withFileTypes: true });
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.isSymbolicLink() || !entry.isDirectory() || !entry.name.startsWith('gstack-') || renderedNames.has(entry.name)) continue;
|
|
// Keep the old render usable until setup has migrated installed copies/links.
|
|
if (entry.name === 'gstack-claude' && process.env.GSTACK_DEFER_CLAUDE_RENAME_PRUNE === '1') continue;
|
|
let generated = false;
|
|
try {
|
|
generated = fs.readFileSync(path.join(skillsRoot, entry.name, 'SKILL.md'), 'utf-8').includes('<!-- AUTO-GENERATED from');
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
}
|
|
if (!generated) {
|
|
log(` kept ${host} skills/${entry.name}: not a gstack render (no generated banner)`);
|
|
continue;
|
|
}
|
|
fs.rmSync(path.join(skillsRoot, entry.name), { recursive: true, force: true });
|
|
log(` pruned stale ${host} render: ${entry.name}`);
|
|
if (entry.name === 'gstack-claude') {
|
|
log(' /claude is now /claude-code. Run ./setup to migrate installed skill links; generation only updates render files.');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!settings.dryRun && tokenBudget.length > 0) {
|
|
tokenBudget.sort((a, b) => b.lines - a.lines);
|
|
log(`\nToken Budget (${host} host)`);
|
|
log('═'.repeat(60));
|
|
for (const item of tokenBudget) {
|
|
const name = item.skill.replace(/\/SKILL\.md$/, '').replace(`${hostConfig.hostSubdir}/skills/`, '');
|
|
log(` ${name.padEnd(30)} ${String(item.lines).padStart(5)} lines ~${String(item.tokens).padStart(6)} tokens`);
|
|
}
|
|
log('─'.repeat(60));
|
|
log(` ${'TOTAL'.padEnd(30)} ${String(tokenBudget.reduce((sum, t) => sum + t.lines, 0)).padStart(5)} lines ~${String(tokenBudget.reduce((sum, t) => sum + t.tokens, 0)).padStart(6)} tokens\n`);
|
|
}
|
|
} catch (error) {
|
|
failed(error, host);
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
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) {
|
|
failed(error);
|
|
}
|
|
try {
|
|
const digest = generateAgentsDigest();
|
|
emit(path.join(options.outputRoot, DIGEST_RELPATH), digest.content, 'digest');
|
|
if (!settings.dryRun) log(`[gen-agents-digest] ${DIGEST_RELPATH}: ${digest.bytes} bytes (budget ${DIGEST_BYTE_BUDGET})`);
|
|
} catch (error) {
|
|
failed(error);
|
|
}
|
|
try {
|
|
emit(path.join(options.outputRoot, DOCTOR_COMPONENTS_RELPATH), renderDoctorComponentsSh(), 'doctor-components');
|
|
} catch (error) {
|
|
failed(error);
|
|
}
|
|
|
|
return { exitCode: diagnostics.some(d => d.kind === 'error' || d.kind === 'stale') ? 1 : 0, artifacts, diagnostics };
|
|
}
|
|
|
|
/** Importing this module never executes generation or reads CLI/user settings.
|
|
* Async main is require()-compatible: only top-level await would break callers. */
|
|
export async function main(args = process.argv.slice(2)): Promise<number> {
|
|
try {
|
|
const settings = parseGenerationArgs(args);
|
|
const result = await runGeneration({ ...settings, log: console.log });
|
|
for (const diagnostic of result.diagnostics) {
|
|
if (diagnostic.kind === 'error') console.error(`ERROR${diagnostic.host ? ` (${diagnostic.host})` : ''}: ${diagnostic.message}`);
|
|
if (diagnostic.kind === 'warning') console.error(diagnostic.message);
|
|
}
|
|
if (result.diagnostics.some(d => d.kind === 'stale')) {
|
|
console.error(`\nGenerated files are stale. Run: bun run gen:skill-docs --host ${settings.host ?? 'claude'}`);
|
|
}
|
|
if (!settings.dryRun) {
|
|
try {
|
|
const config = fs.readFileSync(path.join(resolveStateRoot(), 'config.yaml'), 'utf-8');
|
|
if (/^skill_prefix:\s*true/m.test(config)) {
|
|
console.log('\nNote: skill_prefix is true. Run gstack-relink to re-apply name: patches (it patches both the install and any active gbrain render).');
|
|
}
|
|
} catch { /* optional local install note */ }
|
|
}
|
|
return result.exitCode;
|
|
} catch (error) {
|
|
console.error(`ERROR: ${(error as Error).message}`);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
void main().then(code => { process.exitCode = code; });
|
|
}
|