Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## [1.60.2.0] - 2026-07-26

## **Codex can load the full gstack catalog without truncating skill descriptions.**

Codex installs now receive the same compact catalog descriptions as Claude while
keeping natural-language routing and proactive guidance inside each skill body.
The generated 54-skill catalog uses 3,854 description characters, and explicit
invocations now use Codex's `$skill-name` syntax.

### Fixed

- Apply catalog trimming to Codex generation without hiding skills from implicit
routing.
- Generate valid `$gstack-*` default prompts in OpenAI metadata.
- Enforce per-skill and aggregate Codex description budgets and verify that
routing guidance remains available after a skill loads.

## [1.60.1.0] - 2026-07-09

## **The /autoplan dual-voice eval is back on the board, catching real regressions.**
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.60.1.0
1.60.2.0
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.60.1.0",
"version": "1.60.2.0",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
Expand Down
8 changes: 5 additions & 3 deletions scripts/gen-skill-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,7 @@ function generateOpenAIYaml(displayName: string, shortDescription: string): stri
return `interface:
display_name: ${JSON.stringify(displayName)}
short_description: ${JSON.stringify(shortDescription)}
default_prompt: ${JSON.stringify(`Use ${displayName} for this task.`)}
default_prompt: ${JSON.stringify(`Use $${displayName} for this task.`)}
policy:
allow_implicit_invocation: true
`;
Expand Down Expand Up @@ -853,9 +853,11 @@ function processTemplate(tmplPath: string, host: Host = 'claude'): { outputPath:
content = header + content;
}

// Catalog trim (Claude only — external hosts have their own frontmatter shapes)
// Catalog trim for hosts whose skill catalogs consume SKILL.md descriptions.
// Codex uses the same description field after its host-specific transform, so
// keep routing/voice prose in the body instead of spending catalog budget on it.
let catalogParts: CatalogParts | null = null;
if (host === 'claude' && CATALOG_MODE === 'trim') {
if ((host === 'claude' || host === 'codex') && CATALOG_MODE === 'trim') {
const trimmed = applyCatalogTrim(content, skillName);
if (trimmed) {
content = trimmed.content;
Expand Down
2 changes: 1 addition & 1 deletion scripts/resolvers/codex-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function generateOpenAIYaml(displayName: string, shortDescription: string
return `interface:
display_name: ${JSON.stringify(displayName)}
short_description: ${JSON.stringify(shortDescription)}
default_prompt: ${JSON.stringify(`Use ${displayName} for this task.`)}
default_prompt: ${JSON.stringify(`Use $${displayName} for this task.`)}
policy:
allow_implicit_invocation: true
`;
Expand Down
2 changes: 1 addition & 1 deletion test/catalog-mode-full.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* The catalog trim is the default. The opt-out (`--catalog-mode=full`)
* preserves v1.44 multi-line frontmatter descriptions for users / hosts
* that depend on the legacy fat catalog. Without this test, someone could
* break the conditional `if (host === 'claude' && CATALOG_MODE === 'trim')`
* break the conditional that gates supported hosts on `CATALOG_MODE === 'trim'`
* and silently turn the opt-out path into a no-op — users with the flag
* still get trim'd output, the v1.44 behavior is gone.
*
Expand Down
16 changes: 10 additions & 6 deletions test/fixtures/golden/codex-ship-SKILL.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
---
name: ship
description: |
Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION,
update CHANGELOG, commit, push, create PR. Use when asked to "ship", "deploy",
"push to main", "create a PR", "merge and push", or "get it deployed".
Proactively invoke this skill (do NOT push/PR directly) when the user says code
is ready, asks about deploying, wants to push code up, or asks to create a PR. (gstack)
description: "Ship workflow: detect + merge base branch, run tests, review diff, bump VERSION, update CHANGELOG, commit, push, create PR. (gstack)"

---
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->


## When to invoke this skill

Use when asked to "ship", "deploy",
"push to main", "create a PR", "merge and push", or "get it deployed".
Proactively invoke this skill (do NOT push/PR directly) when the user says code
is ready, asks about deploying, wants to push code up, or asks to create a PR.

## Preamble (run first)

```bash
Expand Down
76 changes: 37 additions & 39 deletions test/gen-skill-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import * as os from 'os';

const ROOT = path.resolve(import.meta.dir, '..');
const MAX_SKILL_DESCRIPTION_LENGTH = 1024;
const MAX_CODEX_CATALOG_DESCRIPTION_LENGTH = 240;
const MAX_CODEX_CATALOG_TOTAL_DESCRIPTION_LENGTH = 6000;

// Carved-skill aware (v2 plan T9): ship is now a skeleton SKILL.md + sections/*.md.
// Read the union so assertions about content that MOVED into a section still pass.
Expand All @@ -30,32 +32,8 @@ function extractDescription(content: string): string {
const fmEnd = content.indexOf('\n---', 4);
expect(fmEnd).toBeGreaterThan(0);
const frontmatter = content.slice(4, fmEnd);
const lines = frontmatter.split('\n');
let description = '';
let inDescription = false;
const descLines: string[] = [];

for (const line of lines) {
if (line.match(/^description:\s*\|?\s*$/)) {
inDescription = true;
continue;
}
if (line.match(/^description:\s*\S/)) {
return line.replace(/^description:\s*/, '').trim();
}
if (inDescription) {
if (line === '' || line.match(/^\s/)) {
descLines.push(line.replace(/^ /, ''));
} else {
break;
}
}
}

if (descLines.length > 0) {
description = descLines.join('\n').trim();
}
return description;
const parsed = Bun.YAML.parse(frontmatter) as { description?: unknown };
return typeof parsed.description === 'string' ? parsed.description.trim() : '';
}

function extractMarkdownSection(content: string, heading: string): string {
Expand Down Expand Up @@ -222,7 +200,7 @@ describe('gen-skill-docs', () => {
expect(fs.existsSync(path.join(ROOT, 'claude', 'SKILL.md'))).toBe(false);
});

test(`every Codex SKILL.md description stays within ${MAX_SKILL_DESCRIPTION_LENGTH} chars`, () => {
test(`every Codex SKILL.md description stays within ${MAX_CODEX_CATALOG_DESCRIPTION_LENGTH} chars`, () => {
const agentsDir = path.join(ROOT, '.agents', 'skills');
if (!fs.existsSync(agentsDir)) return; // skip if not generated
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
Expand All @@ -231,12 +209,27 @@ describe('gen-skill-docs', () => {
if (!fs.existsSync(skillMd)) continue;
const content = fs.readFileSync(skillMd, 'utf-8');
const description = extractDescription(content);
expect(description.length).toBeLessThanOrEqual(MAX_SKILL_DESCRIPTION_LENGTH);
expect(description.length).toBeLessThanOrEqual(MAX_CODEX_CATALOG_DESCRIPTION_LENGTH);
}
});

test('every Codex SKILL.md description stays under 900-char warning threshold', () => {
const WARN_THRESHOLD = 900;
test(`Codex catalog descriptions stay within ${MAX_CODEX_CATALOG_TOTAL_DESCRIPTION_LENGTH} chars total`, () => {
const agentsDir = path.join(ROOT, '.agents', 'skills');
if (!fs.existsSync(agentsDir)) return;
const lengths: number[] = [];
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const skillMd = path.join(agentsDir, entry.name, 'SKILL.md');
if (!fs.existsSync(skillMd)) continue;
lengths.push(extractDescription(fs.readFileSync(skillMd, 'utf-8')).length);
}

expect(lengths.length).toBeGreaterThan(0);
expect(lengths.reduce((sum, length) => sum + length, 0))
.toBeLessThanOrEqual(MAX_CODEX_CATALOG_TOTAL_DESCRIPTION_LENGTH);
});

test('every Codex SKILL.md keeps routing guidance out of catalog descriptions', () => {
const agentsDir = path.join(ROOT, '.agents', 'skills');
if (!fs.existsSync(agentsDir)) return;
const violations: string[] = [];
Expand All @@ -246,8 +239,8 @@ describe('gen-skill-docs', () => {
if (!fs.existsSync(skillMd)) continue;
const content = fs.readFileSync(skillMd, 'utf-8');
const description = extractDescription(content);
if (description.length > WARN_THRESHOLD) {
violations.push(`${entry.name}: ${description.length} chars (limit ${MAX_SKILL_DESCRIPTION_LENGTH}, ${MAX_SKILL_DESCRIPTION_LENGTH - description.length} remaining)`);
if (/\b(?:Use when|Proactively suggest when|Voice triggers):/i.test(description)) {
violations.push(entry.name);
}
}
expect(violations).toEqual([]);
Expand Down Expand Up @@ -1733,23 +1726,26 @@ describe('Codex generation (--host codex)', () => {
const fmEnd = content.indexOf('\n---', 4);
expect(fmEnd).toBeGreaterThan(0);
const frontmatter = content.slice(4, fmEnd);
const parsed = Bun.YAML.parse(frontmatter) as Record<string, unknown>;
// Must have name and description
expect(frontmatter).toContain('name:');
expect(frontmatter).toContain('description:');
expect(Object.keys(parsed).sort()).toEqual(['description', 'name']);
// Must NOT have allowed-tools, version, or hooks
expect(frontmatter).not.toContain('allowed-tools:');
expect(frontmatter).not.toContain('version:');
expect(frontmatter).not.toContain('hooks:');
}
});

test('all Codex skills have agents/openai.yaml metadata', () => {
test('all generated Codex skills keep implicit routing and valid explicit prompts', () => {
for (const skill of CODEX_SKILLS) {
const metadata = path.join(AGENTS_DIR, skill.codexName, 'agents', 'openai.yaml');
expect(fs.existsSync(metadata)).toBe(true);
const content = fs.readFileSync(metadata, 'utf-8');
expect(content).toContain(`display_name: "${skill.codexName}"`);
expect(content).toContain('short_description:');
expect(content).toContain(`default_prompt: "Use $${skill.codexName} for this task."`);
expect(content).toContain('allow_implicit_invocation: true');
}
});
Expand Down Expand Up @@ -1829,16 +1825,18 @@ describe('Codex generation (--host codex)', () => {
expect(codexResult.stdout.toString()).toBe(agentsResult.stdout.toString());
});

test('multiline descriptions preserved in Codex output', () => {
// office-hours has a multiline description — verify it survives the frontmatter transform
test('Codex catalog trim moves routing prose into the skill body', () => {
// office-hours has a long routed description in its template.
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-office-hours', 'SKILL.md'), 'utf-8');
const fmEnd = content.indexOf('\n---', 4);
const frontmatter = content.slice(4, fmEnd);
// Description should span multiple lines (block scalar)
const descLines = frontmatter.split('\n').filter(l => l.startsWith(' '));
expect(descLines.length).toBeGreaterThan(1);
// Verify key phrases survived
const description = extractDescription(content);

expect(description.length).toBeLessThanOrEqual(MAX_CODEX_CATALOG_DESCRIPTION_LENGTH);
expect(frontmatter).toContain('YC Office Hours');
expect(frontmatter).not.toMatch(/\bUse when\b/i);
expect(content).toContain('## When to invoke this skill');
expect(content).toMatch(/\bUse when\b/i);
});

test('hook skills have safety prose and no hooks: in frontmatter', () => {
Expand Down
Loading