mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 23:30:09 +02:00
refactor(ship): carve into skeleton + on-demand sections (Claude) (T9)
ship/SKILL.md drops 167KB → 68.7KB (~59% of the always-loaded skill) by moving
8 prose-heavy steps into ship/sections/*.md, read on demand:
tests, test-coverage, plan-completion, review-army, greptile, adversarial,
changelog, pr-body. Step 12's version logic now calls the tested
gstack-version-bump CLI instead of inline bash.
Claude-first (S2): {{SECTION:id}} emits a STOP-Read pointer on Claude (skeleton +
generated section files) and INLINES the content on every other host, so external
hosts keep the full monolith — verified factory at 162KB with no sections dir.
{{SECTION_INDEX:ship}} renders the situation→section table from the PASSIVE
manifest (CM2 / v2_PLAN.md:663); required-reads live only in test fixtures.
Multi-pass resolve expands inlined sections' own resolvers.
Parity: ship invariant flipped to sectioned (union content checks + maxSkeletonBytes
asserts the shrink). Carve-fallout fixed across gen-skill-docs/skill-validation/
golden/plan-completion/#1539/size-budget tests via skeleton+sections union reads.
Free suite green except the pre-existing investigate parity drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+33
-17
@@ -563,17 +563,31 @@ function resolvePlaceholders(
|
||||
relTmplPath: string,
|
||||
): string {
|
||||
const suppressed = new Set(hostConfig.suppressedResolvers || []);
|
||||
const content = tmplContent.replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (_match, fullKey) => {
|
||||
const parts = fullKey.split(':');
|
||||
const resolverName = parts[0];
|
||||
const args = parts.slice(1);
|
||||
if (suppressed.has(resolverName)) return '';
|
||||
const entry = RESOLVERS[resolverName];
|
||||
if (!entry) throw new Error(`Unknown placeholder {{${resolverName}}} in ${relTmplPath}`);
|
||||
const { resolve, appliesTo } = unwrapResolver(entry);
|
||||
if (appliesTo && !appliesTo(ctx)) return '';
|
||||
return args.length > 0 ? resolve(ctx, args) : resolve(ctx);
|
||||
});
|
||||
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 entry = RESOLVERS[resolverName];
|
||||
if (!entry) throw new Error(`Unknown placeholder {{${resolverName}}} in ${relTmplPath}`);
|
||||
const { resolve, appliesTo } = unwrapResolver(entry);
|
||||
if (appliesTo && !appliesTo(ctx)) return '';
|
||||
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(', ')}`);
|
||||
@@ -878,12 +892,14 @@ for (const currentHost of hostsToRun) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Section generation (v2 plan T9) ───────────────────────
|
||||
// On-demand sections/*.md for carved skills. No-op for any skill without a
|
||||
// sections/ dir, so this is inert until a skill is carved. Mirrors the
|
||||
// SKILL.md include/skip host filters (keyed on the owning skill dir) and the
|
||||
// DRY_RUN freshness handling, so sections participate in the freshness gate.
|
||||
for (const sec of discoverSectionTemplates(ROOT)) {
|
||||
// ─── Section generation (v2 plan T9, Claude-first carve) ───
|
||||
// On-demand sections/*.md for carved skills. Generated for CLAUDE ONLY:
|
||||
// every other host inlines section content via the {{SECTION:id}} resolver
|
||||
// (keeping the full monolith skill), so they need no section files and we
|
||||
// sidestep host-portable section paths until that plumbing lands. No-op for
|
||||
// any skill without a sections/ dir. Mirrors the SKILL.md DRY_RUN handling so
|
||||
// sections participate in the freshness gate.
|
||||
for (const sec of currentHost === 'claude' ? discoverSectionTemplates(ROOT) : []) {
|
||||
if (currentHostConfig.generation.includeSkills?.length &&
|
||||
!currentHostConfig.generation.includeSkills.includes(sec.skillDir)) continue;
|
||||
if (currentHostConfig.generation.skipSkills?.length &&
|
||||
|
||||
@@ -34,6 +34,7 @@ import { generateGBrainContextLoad, generateGBrainSaveResults } from './gbrain';
|
||||
import { generateQuestionPreferenceCheck, generateQuestionLog, generateInlineTuneFeedback } from './question-tuning';
|
||||
import { generateMakePdfSetup } from './make-pdf';
|
||||
import { generateTasksSectionEmit, generateTasksSectionAggregate } from './tasks-section';
|
||||
import { SECTION, SECTION_INDEX } from './sections';
|
||||
|
||||
export const RESOLVERS: Record<string, ResolverValue> = {
|
||||
SLUG_EVAL: generateSlugEval,
|
||||
@@ -92,4 +93,6 @@ export const RESOLVERS: Record<string, ResolverValue> = {
|
||||
MAKE_PDF_SETUP: generateMakePdfSetup,
|
||||
TASKS_SECTION_EMIT: generateTasksSectionEmit,
|
||||
TASKS_SECTION_AGGREGATE: generateTasksSectionAggregate,
|
||||
SECTION,
|
||||
SECTION_INDEX,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Section resolvers (v2 plan T9, Claude-first carve).
|
||||
*
|
||||
* A carved skill keeps its prose-heavy steps in `<skill>/sections/<id>.md`, read
|
||||
* on demand. The SAME template ships to every host, so these resolvers make the
|
||||
* carve host-aware:
|
||||
*
|
||||
* - On CLAUDE: {{SECTION:id}} emits a STOP-Read pointer to the generated section
|
||||
* file (the skeleton), and the section .md is generated + installed separately.
|
||||
* - On every OTHER host: {{SECTION:id}} INLINES the section template's content,
|
||||
* so external hosts keep the full monolith ship skill (no section files, no
|
||||
* host-portable-path problem). Inlined content keeps its own {{RESOLVER}}
|
||||
* tokens, which the generator's multi-pass resolve expands.
|
||||
*
|
||||
* {{SECTION_INDEX:skill}} renders the situation→section table from the PASSIVE
|
||||
* manifest on Claude (empty on other hosts — they have no sections). The manifest
|
||||
* is the single source of id/file/title/trigger text (CM2; v2_PLAN.md:663).
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { ResolverFn, TemplateContext } from './types';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..', '..');
|
||||
|
||||
interface SectionEntry {
|
||||
id: string;
|
||||
file: string;
|
||||
title: string;
|
||||
trigger: string;
|
||||
}
|
||||
interface SectionManifest {
|
||||
skill: string;
|
||||
sections: SectionEntry[];
|
||||
}
|
||||
|
||||
function loadManifest(skill: string): SectionManifest {
|
||||
const p = path.join(ROOT, skill, 'sections', 'manifest.json');
|
||||
const raw = fs.readFileSync(p, 'utf-8');
|
||||
return JSON.parse(raw) as SectionManifest;
|
||||
}
|
||||
|
||||
function findSection(skill: string, id: string): SectionEntry {
|
||||
const entry = loadManifest(skill).sections.find(s => s.id === id);
|
||||
if (!entry) {
|
||||
throw new Error(`{{SECTION:${id}}} — no section "${id}" in ${skill}/sections/manifest.json`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* {{SECTION:id}} — pointer on Claude, inline on other hosts.
|
||||
* Claude path uses the stable gstack-root install (`{skillRoot}/{skill}/sections/`),
|
||||
* which always exists, instead of a naked relative path (Codex outside-voice #7).
|
||||
*/
|
||||
export const SECTION: ResolverFn = (ctx: TemplateContext, args?: string[]): string => {
|
||||
const id = args?.[0];
|
||||
if (!id) throw new Error('{{SECTION:id}} requires a section id');
|
||||
const entry = findSection(ctx.skillName, id);
|
||||
|
||||
if (ctx.host === 'claude') {
|
||||
const sectionPath = `${ctx.paths.skillRoot}/${ctx.skillName}/sections/${entry.file}`;
|
||||
return [
|
||||
`> **STOP.** Before ${entry.trigger}, Read \`${sectionPath}\` and execute it`,
|
||||
`> in full. Do not work from memory — that section is the source of truth for this step.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Non-Claude hosts inline the section template content (monolith preserved).
|
||||
// Inner {{RESOLVER}} tokens are expanded by the generator's multi-pass resolve.
|
||||
const tmplPath = path.join(ROOT, ctx.skillName, 'sections', `${entry.file}.tmpl`);
|
||||
return fs.readFileSync(tmplPath, 'utf-8').trimEnd();
|
||||
};
|
||||
|
||||
/**
|
||||
* {{SECTION_INDEX:skill}} — situation→section table from the passive manifest.
|
||||
* Claude only; other hosts inline everything so an index would be noise.
|
||||
*/
|
||||
export const SECTION_INDEX: ResolverFn = (ctx: TemplateContext, args?: string[]): string => {
|
||||
if (ctx.host !== 'claude') return '';
|
||||
const skill = args?.[0] ?? ctx.skillName;
|
||||
const manifest = loadManifest(skill);
|
||||
const lines: string[] = [
|
||||
'## Section index — Read each section when its situation applies',
|
||||
'',
|
||||
'This skill is a decision-tree skeleton. The steps below point to on-demand',
|
||||
'sections. Read a section in full before doing its step; do not work from memory.',
|
||||
'',
|
||||
'| When | Read this section |',
|
||||
'|------|-------------------|',
|
||||
];
|
||||
for (const s of manifest.sections) {
|
||||
lines.push(`| ${s.trigger} | \`sections/${s.file}\` |`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
};
|
||||
Reference in New Issue
Block a user