Files
gstack/scripts/resolvers/preamble.ts
Garry Tanandcapy-ai[bot] f4f16e83e0 v1.91.305.0 feat: render gstack per model with modules left out, measured before any model gets less (GSTA-140 PR 1a) (#3554)
* feat(modules): module catalog, per-model loadouts manifest and gstack-modules render (GSTA-140 PR 1a)

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-29 04:22:41 +00:00

140 lines
6.9 KiB
TypeScript

/**
* Preamble composition root.
*
* Each generator lives in its own file under ./preamble/*.ts. This file only
* wires them together: preambleSections() is the composition, and
* generatePreamble() joins it into the full inline render that tests use as
* the pre-hoist oracle. {{PREAMBLE}} resolves through ./preamble-hoist.ts,
* which renders tier 1 inline and splits tier 2+ into a per-skill pointer plus
* the shared sections/preamble.md the skill Reads on load. Keep composition
* declarative — no inline logic beyond tier gating.
*
* Each skill runs independently via `claude -p` (or the host's equivalent).
* The preamble provides: update checks, session
* tracking, user preferences, repo mode detection, model overlays, and
* telemetry.
*
* Telemetry data flow:
* 1. Always: local JSONL append to ~/.gstack/analytics/ (inline, inspectable)
* 2. If _TEL != "off" AND binary exists: gstack-telemetry-log for remote reporting
*/
import type { TemplateContext } from './types';
import { generateModelOverlay } from './model-overlay';
import { generateQuestionTuning } from './question-tuning';
// Core bootstrap
import { generatePreambleBash } from './preamble/generate-preamble-bash';
import { generateUpgradeCheck } from './preamble/generate-upgrade-check';
import {
generateCompletionStatus,
generatePlanModeInfo,
} from './preamble/generate-completion-status';
// Host-specific instructions
import { generateBrainHealthInstruction } from './preamble/generate-brain-health-instruction';
// GBrain cross-machine sync (runs at skill start; end-side handled in completion-status)
import { generateBrainSyncBlock } from './preamble/generate-brain-sync-block';
// Behavioral / voice
import { generateVoiceDirective } from './preamble/generate-voice-directive';
// Tier 2+ context and interaction framework
import { generateContextRecovery } from './preamble/generate-context-recovery';
import { generateAskUserFormat } from './preamble/generate-ask-user-format';
import { generateWritingStyle } from './preamble/generate-writing-style';
import { generateCompletenessSection } from './preamble/generate-completeness-section';
import { generateConfusionProtocol } from './preamble/generate-confusion-protocol';
import { generateEvidenceDirective } from './preamble/generate-evidence-directive';
import { generateContextHealth } from './preamble/generate-context-health';
// Tier 3+ repo mode + search
import { generateRepoModeSection } from './preamble/generate-repo-mode-section';
import { generateSearchBeforeBuildingSection } from './preamble/generate-search-before-building';
import { generateMakePdfSetup } from './make-pdf';
// Standalone export used directly by the resolver registry
export { generateTestFailureTriage } from './preamble/generate-test-failure-triage';
// Preamble Composition (tier → sections)
// ─────────────────────────────────────────────
// T1: core + upgrade + lake + telemetry + voice(trimmed) + completion
// T2: T1 + voice(full) + ask + completeness + context-recovery + confusion + context-health
// T3: T2 + repo-mode + search
// T4: (same as T3 — TEST_FAILURE_TRIAGE is a separate {{}} placeholder, not preamble)
//
// Which skill gets which tier lives in each template's frontmatter
// (`preamble-tier: N`). Every template that resolves {{PREAMBLE}} must
// declare it — there is no default.
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;
if (tier === undefined) {
throw new Error(
`Missing preamble-tier frontmatter in ${ctx.tmplPath}: every template that ` +
`resolves {{PREAMBLE}} must declare 'preamble-tier: N' (1-4).`
);
}
if (tier < 1 || tier > 4) {
throw new Error(`Invalid preamble-tier: ${tier} in ${ctx.tmplPath}. Must be 1-4.`);
}
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);
}