mirror of https://github.com/garrytan/gstack.git
feat(grok-build): load carved sections on demand (token ceiling)
Grok ship and other carved skills no longer inline full section bodies into SKILL.md. Like Claude, they emit STOP-Read pointers to package-local sections/ files under ~/.grok/skills/gstack-*/sections/. Drops gstack-ship always-loaded size from ~45k tokens / 179KB to ~20k / 80KB (under the 160KB soft ceiling). Codex and other hosts keep monolith inline.
This commit is contained in:
parent
1480052e77
commit
c8132daf79
|
|
@ -18,6 +18,7 @@ import * as path from 'path';
|
|||
import type { Host, TemplateContext } from './resolvers/types';
|
||||
import { HOST_PATHS, unwrapResolver } from './resolvers/types';
|
||||
import { RESOLVERS } from './resolvers/index';
|
||||
import { hostUsesSectionPointers } from './resolvers/sections';
|
||||
import { externalSkillName, extractHookSafetyProse as _extractHookSafetyProse, extractNameAndDescription as _extractNameAndDescription, condenseOpenAIShortDescription as _condenseOpenAIShortDescription, generateOpenAIYaml as _generateOpenAIYaml } from './resolvers/codex-helpers';
|
||||
import { generatePlanCompletionAuditShip, generatePlanCompletionAuditReview, generatePlanVerificationExec } from './resolvers/review';
|
||||
import { ALL_HOST_CONFIGS, ALL_HOST_NAMES, resolveHostArg, getHostConfig } from '../hosts/index';
|
||||
|
|
@ -1024,14 +1025,15 @@ for (const currentHost of hostsToRun) {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── 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) : []) {
|
||||
// ─── Section generation (v2 plan T9 carve) ───
|
||||
// On-demand sections/*.md for carved skills.
|
||||
// Pointer hosts (Claude, Grok Build): emit section files next to the skill
|
||||
// package; {{SECTION:id}} is a STOP-Read pointer (keeps the skeleton small).
|
||||
// Inline hosts (Codex, Factory, …): {{SECTION:id}} inlines content into the
|
||||
// monolith SKILL.md — no separate section files.
|
||||
// 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 hostUsesSectionPointers(currentHost) ? discoverSectionTemplates(ROOT) : []) {
|
||||
if (currentHostConfig.generation.includeSkills?.length &&
|
||||
!currentHostConfig.generation.includeSkills.includes(sec.skillDir)) continue;
|
||||
if (currentHostConfig.generation.skipSkills?.length &&
|
||||
|
|
|
|||
|
|
@ -1,25 +1,28 @@
|
|||
/**
|
||||
* Section resolvers (v2 plan T9, Claude-first carve).
|
||||
* Section resolvers (v2 plan T9 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.
|
||||
* - On CLAUDE: SECTION:id emits a STOP-Read pointer to the generated section
|
||||
* file under the nested monorepo install ({skillRoot}/{skill}/sections/).
|
||||
* - On GROK-BUILD: same pointer mode, but paths use the flat Grok package layout
|
||||
* (~/.grok/skills/gstack-{skill}/sections/). Section files are generated into
|
||||
* each package's sections/ dir and ride along with package install.
|
||||
* - On every OTHER host: SECTION placeholders INLINE the section template content,
|
||||
* so those hosts keep the full monolith 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
|
||||
* SECTION_INDEX renders the situation-to-section table from the PASSIVE
|
||||
* manifest on pointer hosts (empty when sections are inlined). 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';
|
||||
import type { Host, ResolverFn, TemplateContext } from './types';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..', '..');
|
||||
|
||||
|
|
@ -34,6 +37,21 @@ interface SectionManifest {
|
|||
sections: SectionEntry[];
|
||||
}
|
||||
|
||||
/** Hosts that load carved sections on demand (not monolith-inline). */
|
||||
export function hostUsesSectionPointers(host: Host | string): boolean {
|
||||
return host === 'claude' || host === 'grok-build';
|
||||
}
|
||||
|
||||
/**
|
||||
* External package dir name for flat hosts (gstack-ship, gstack-upgrade, …).
|
||||
* Mirrors gen-skill-docs externalSkillName for skill dirs.
|
||||
*/
|
||||
export function externalSkillPackageName(skillName: string): string {
|
||||
if (skillName === '.' || skillName === '' || skillName === 'gstack') return 'gstack';
|
||||
if (skillName.startsWith('gstack-')) return skillName;
|
||||
return `gstack-${skillName}`;
|
||||
}
|
||||
|
||||
function loadManifest(skill: string): SectionManifest {
|
||||
const p = path.join(ROOT, skill, 'sections', 'manifest.json');
|
||||
const raw = fs.readFileSync(p, 'utf-8');
|
||||
|
|
@ -49,35 +67,56 @@ function findSection(skill: string, id: string): SectionEntry {
|
|||
}
|
||||
|
||||
/**
|
||||
* {{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).
|
||||
* Absolute-style path the agent should Read for a section file.
|
||||
* Claude: nested monorepo install under skillRoot.
|
||||
* Grok: flat package next to the thin runtime root.
|
||||
*/
|
||||
export function sectionPointerPath(
|
||||
host: Host | string,
|
||||
skillName: string,
|
||||
sectionFile: string,
|
||||
skillRoot: string,
|
||||
): string {
|
||||
if (host === 'grok-build') {
|
||||
const pkg = externalSkillPackageName(skillName);
|
||||
return `~/.grok/skills/${pkg}/sections/${sectionFile}`;
|
||||
}
|
||||
// Claude (and any future nested-install pointer host)
|
||||
return `${skillRoot}/${skillName}/sections/${sectionFile}`;
|
||||
}
|
||||
|
||||
function stopReadDirective(sectionPath: string, trigger: string): string {
|
||||
return [
|
||||
`> **STOP.** Before ${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');
|
||||
}
|
||||
|
||||
/**
|
||||
* SECTION:id — pointer on Claude/Grok, inline on other hosts.
|
||||
*/
|
||||
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');
|
||||
if (hostUsesSectionPointers(ctx.host)) {
|
||||
const sectionPath = sectionPointerPath(ctx.host, ctx.skillName, entry.file, ctx.paths.skillRoot);
|
||||
return stopReadDirective(sectionPath, entry.trigger);
|
||||
}
|
||||
|
||||
// Non-Claude hosts inline the section template content (monolith preserved).
|
||||
// Non-pointer 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.
|
||||
* SECTION_INDEX — situation-to-section table from the passive manifest.
|
||||
* Pointer hosts only; inline hosts have no separate section files.
|
||||
*/
|
||||
export const SECTION_INDEX: ResolverFn = (ctx: TemplateContext, args?: string[]): string => {
|
||||
if (ctx.host !== 'claude') return '';
|
||||
if (!hostUsesSectionPointers(ctx.host)) return '';
|
||||
const skill = args?.[0] ?? ctx.skillName;
|
||||
const manifest = loadManifest(skill);
|
||||
const lines: string[] = [
|
||||
|
|
@ -90,7 +129,11 @@ export const SECTION_INDEX: ResolverFn = (ctx: TemplateContext, args?: string[])
|
|||
'|------|-------------------|',
|
||||
];
|
||||
for (const s of manifest.sections) {
|
||||
lines.push(`| ${s.trigger} | \`sections/${s.file}\` |`);
|
||||
const sectionPath = sectionPointerPath(ctx.host, skill, s.file, ctx.paths.skillRoot);
|
||||
// Table shows the resolvable path (Grok: full ~/.grok/...; Claude: short sections/)
|
||||
const display =
|
||||
ctx.host === 'grok-build' ? `\`${sectionPath}\`` : `\`sections/${s.file}\``;
|
||||
lines.push(`| ${s.trigger} | ${display} |`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Grok Build section-pointer mode (Option A token-ceiling fix).
|
||||
*
|
||||
* Carved skills must emit STOP-Read pointers + separate section files under
|
||||
* .grok/skills/gstack-<skill>/sections/, not a monolith that exceeds the
|
||||
* gen-skill-docs soft ceiling (~160KB / ~40k tokens).
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
hostUsesSectionPointers,
|
||||
externalSkillPackageName,
|
||||
sectionPointerPath,
|
||||
SECTION,
|
||||
SECTION_INDEX,
|
||||
} from '../scripts/resolvers/sections';
|
||||
import type { TemplateContext } from '../scripts/resolvers/types';
|
||||
import { HOST_PATHS } from '../scripts/resolvers/types';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
|
||||
function shipCtx(host: 'claude' | 'grok-build' | 'codex'): TemplateContext {
|
||||
return {
|
||||
skillName: 'ship',
|
||||
tmplPath: path.join(ROOT, 'ship', 'SKILL.md.tmpl'),
|
||||
host,
|
||||
paths: HOST_PATHS[host],
|
||||
};
|
||||
}
|
||||
|
||||
describe('hostUsesSectionPointers', () => {
|
||||
test('claude and grok-build use pointers; codex inlines', () => {
|
||||
expect(hostUsesSectionPointers('claude')).toBe(true);
|
||||
expect(hostUsesSectionPointers('grok-build')).toBe(true);
|
||||
expect(hostUsesSectionPointers('codex')).toBe(false);
|
||||
expect(hostUsesSectionPointers('factory')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sectionPointerPath', () => {
|
||||
test('grok uses flat package under ~/.grok/skills', () => {
|
||||
expect(sectionPointerPath('grok-build', 'ship', 'tests.md', '$GSTACK_ROOT')).toBe(
|
||||
'~/.grok/skills/gstack-ship/sections/tests.md',
|
||||
);
|
||||
expect(externalSkillPackageName('ship')).toBe('gstack-ship');
|
||||
expect(externalSkillPackageName('gstack-upgrade')).toBe('gstack-upgrade');
|
||||
});
|
||||
|
||||
test('claude uses nested skillRoot layout', () => {
|
||||
expect(sectionPointerPath('claude', 'ship', 'tests.md', '~/.claude/skills/gstack')).toBe(
|
||||
'~/.claude/skills/gstack/ship/sections/tests.md',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SECTION / SECTION_INDEX resolvers', () => {
|
||||
test('grok ship SECTION emits STOP-Read to gstack-ship package path', () => {
|
||||
const out = SECTION(shipCtx('grok-build'), ['tests']);
|
||||
expect(out).toContain('**STOP.**');
|
||||
expect(out).toContain('~/.grok/skills/gstack-ship/sections/tests.md');
|
||||
expect(out).not.toContain('## Test Framework Bootstrap'); // not inlined
|
||||
});
|
||||
|
||||
test('codex ship SECTION still inlines section body', () => {
|
||||
const out = SECTION(shipCtx('codex'), ['tests']);
|
||||
expect(out).not.toContain('**STOP.**');
|
||||
// tests.md.tmpl has substantive body (not a pointer)
|
||||
expect(out.length).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
test('grok SECTION_INDEX lists full paths; codex SECTION_INDEX is empty', () => {
|
||||
const grok = SECTION_INDEX(shipCtx('grok-build'), ['ship']);
|
||||
expect(grok).toContain('## Section index');
|
||||
expect(grok).toContain('~/.grok/skills/gstack-ship/sections/review-army.md');
|
||||
expect(SECTION_INDEX(shipCtx('codex'), ['ship'])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generated grok ship package (when present)', () => {
|
||||
const shipPkg = path.join(ROOT, '.grok', 'skills', 'gstack-ship');
|
||||
const skillMd = path.join(shipPkg, 'SKILL.md');
|
||||
const sectionsDir = path.join(shipPkg, 'sections');
|
||||
|
||||
test('gstack-ship SKILL.md is under soft token ceiling when generated', () => {
|
||||
if (!fs.existsSync(skillMd)) return; // gen not run in this env
|
||||
const bytes = fs.statSync(skillMd).size;
|
||||
// Soft ceiling in gen-skill-docs: 160_000 bytes (~40k tokens)
|
||||
expect(bytes).toBeLessThan(160_000);
|
||||
});
|
||||
|
||||
test('gstack-ship has carved section files + STOP pointers when generated', () => {
|
||||
if (!fs.existsSync(skillMd)) return;
|
||||
const body = fs.readFileSync(skillMd, 'utf-8');
|
||||
expect(body).toContain('**STOP.**');
|
||||
expect(body).toContain('~/.grok/skills/gstack-ship/sections/');
|
||||
// Heavy steps must not be inlined into the skeleton
|
||||
expect(body).not.toMatch(/## Step 9\.1: Review Army/);
|
||||
expect(fs.existsSync(path.join(sectionsDir, 'review-army.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(sectionsDir, 'adversarial.md'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(sectionsDir, 'tests.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue