mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
v1.13.0.0 feat: add Claude outside-voice skill (#1212)
* Add Claude outside-voice skill * Fix gbrain config isolation test * Restore Opus fanout overlay nudge * Warn on oversized tracked files * Release v1.13.0.0 * Fix Claude diff temp file handling * Remove Opus fanout overlay nudge
This commit is contained in:
@@ -97,11 +97,13 @@ describe('gstack-config gbrain keys', () => {
|
||||
});
|
||||
|
||||
test('GSTACK_HOME overrides real config dir', () => {
|
||||
run(['gstack-config', 'set', 'gbrain_sync_mode', 'full']);
|
||||
// Real ~/.gstack/config.yaml must NOT have been touched.
|
||||
// Real ~/.gstack/config.yaml must not change, regardless of what it
|
||||
// already contains on the developer's machine.
|
||||
const realConfig = path.join(os.homedir(), '.gstack', 'config.yaml');
|
||||
const real = fs.existsSync(realConfig) ? fs.readFileSync(realConfig, 'utf-8') : '';
|
||||
expect(real).not.toContain('gbrain_sync_mode: full');
|
||||
const before = fs.existsSync(realConfig) ? fs.readFileSync(realConfig, 'utf-8') : null;
|
||||
run(['gstack-config', 'set', 'gbrain_sync_mode', 'full']);
|
||||
const after = fs.existsSync(realConfig) ? fs.readFileSync(realConfig, 'utf-8') : null;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,9 @@ const ALL_SKILLS = (() => {
|
||||
return skills;
|
||||
})();
|
||||
|
||||
const CLAUDE_SKIPPED_SKILL_DIRS = new Set(['claude']);
|
||||
const CLAUDE_GENERATED_SKILLS = ALL_SKILLS.filter(skill => !CLAUDE_SKIPPED_SKILL_DIRS.has(skill.dir));
|
||||
|
||||
describe('gen-skill-docs', () => {
|
||||
test('generated SKILL.md contains all command categories', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
||||
@@ -114,7 +117,7 @@ describe('gen-skill-docs', () => {
|
||||
});
|
||||
|
||||
test('every skill has a generated SKILL.md with auto-generated header', () => {
|
||||
for (const skill of ALL_SKILLS) {
|
||||
for (const skill of CLAUDE_GENERATED_SKILLS) {
|
||||
const mdPath = path.join(ROOT, skill.dir, 'SKILL.md');
|
||||
expect(fs.existsSync(mdPath)).toBe(true);
|
||||
const content = fs.readFileSync(mdPath, 'utf-8');
|
||||
@@ -124,7 +127,7 @@ describe('gen-skill-docs', () => {
|
||||
});
|
||||
|
||||
test('every generated SKILL.md has valid YAML frontmatter', () => {
|
||||
for (const skill of ALL_SKILLS) {
|
||||
for (const skill of CLAUDE_GENERATED_SKILLS) {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
|
||||
expect(content.startsWith('---\n')).toBe(true);
|
||||
expect(content).toContain('name:');
|
||||
@@ -133,13 +136,18 @@ describe('gen-skill-docs', () => {
|
||||
});
|
||||
|
||||
test(`every generated SKILL.md description stays within ${MAX_SKILL_DESCRIPTION_LENGTH} chars`, () => {
|
||||
for (const skill of ALL_SKILLS) {
|
||||
for (const skill of CLAUDE_GENERATED_SKILLS) {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
|
||||
const description = extractDescription(content);
|
||||
expect(description.length).toBeLessThanOrEqual(MAX_SKILL_DESCRIPTION_LENGTH);
|
||||
}
|
||||
});
|
||||
|
||||
test('Claude outside-voice skill is not generated for Claude host', () => {
|
||||
expect(fs.existsSync(path.join(ROOT, 'claude', 'SKILL.md.tmpl'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(ROOT, 'claude', 'SKILL.md'))).toBe(false);
|
||||
});
|
||||
|
||||
test(`every Codex SKILL.md description stays within ${MAX_SKILL_DESCRIPTION_LENGTH} chars`, () => {
|
||||
const agentsDir = path.join(ROOT, '.agents', 'skills');
|
||||
if (!fs.existsSync(agentsDir)) return; // skip if not generated
|
||||
@@ -186,7 +194,7 @@ describe('gen-skill-docs', () => {
|
||||
expect(result.exitCode).toBe(0);
|
||||
const output = result.stdout.toString();
|
||||
// Every skill should be FRESH
|
||||
for (const skill of ALL_SKILLS) {
|
||||
for (const skill of CLAUDE_GENERATED_SKILLS) {
|
||||
const file = skill.dir === '.' ? 'SKILL.md' : `${skill.dir}/SKILL.md`;
|
||||
expect(output).toContain(`FRESH: ${file}`);
|
||||
}
|
||||
@@ -194,7 +202,7 @@ describe('gen-skill-docs', () => {
|
||||
});
|
||||
|
||||
test('no generated SKILL.md contains unresolved placeholders', () => {
|
||||
for (const skill of ALL_SKILLS) {
|
||||
for (const skill of CLAUDE_GENERATED_SKILLS) {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
|
||||
const unresolved = content.match(/\{\{[A-Z_]+\}\}/g);
|
||||
expect(unresolved).toBeNull();
|
||||
@@ -264,7 +272,7 @@ describe('gen-skill-docs', () => {
|
||||
});
|
||||
|
||||
test('preamble .pending-* glob is zsh-safe (uses find, not shell glob)', () => {
|
||||
for (const skill of ALL_SKILLS) {
|
||||
for (const skill of CLAUDE_GENERATED_SKILLS) {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
|
||||
if (!content.includes('.pending-')) continue;
|
||||
// Must NOT have a bare shell glob ".pending-*" outside of find's -name argument
|
||||
@@ -275,7 +283,7 @@ describe('gen-skill-docs', () => {
|
||||
});
|
||||
|
||||
test('bash blocks with shell globs are zsh-safe (setopt guard or find)', () => {
|
||||
for (const skill of ALL_SKILLS) {
|
||||
for (const skill of CLAUDE_GENERATED_SKILLS) {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
|
||||
const bashBlocks = [...content.matchAll(/```bash\n([\s\S]*?)```/g)].map(m => m[1]);
|
||||
|
||||
@@ -1603,6 +1611,20 @@ describe('Codex generation (--host codex)', () => {
|
||||
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack-codex'))).toBe(false);
|
||||
});
|
||||
|
||||
test('Codex output includes Claude outside-voice skill with read-only boundary', () => {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-claude', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('claude -p');
|
||||
expect(content).toContain('mktemp /tmp/gstack-claude-prompt-');
|
||||
expect(content).toContain('mktemp /tmp/gstack-claude-diff-');
|
||||
expect(content).not.toContain('/tmp/gstack-claude-diff-$$');
|
||||
expect(content).toContain('cat "$PROMPT_FILE" | claude -p');
|
||||
expect(content).toContain('--disable-slash-commands');
|
||||
expect(content).toContain('--tools ""');
|
||||
expect(content).toContain('--allowedTools Read,Grep,Glob');
|
||||
expect(content).toContain('--disallowedTools Bash,Edit,Write');
|
||||
expect(content).toContain('is_error');
|
||||
});
|
||||
|
||||
test('Codex review step stripped from Codex-host ship and review', () => {
|
||||
const shipContent = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-ship', 'SKILL.md'), 'utf-8');
|
||||
expect(shipContent).not.toContain('codex review --base');
|
||||
@@ -1773,7 +1795,7 @@ describe('Codex generation (--host codex)', () => {
|
||||
});
|
||||
|
||||
test('Claude output unchanged: all Claude skills have zero Codex paths', () => {
|
||||
for (const skill of ALL_SKILLS) {
|
||||
for (const skill of CLAUDE_GENERATED_SKILLS) {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
|
||||
// pair-agent legitimately documents how Codex agents store credentials.
|
||||
// codex + autoplan document the Codex CLI auth file (~/.codex/auth.json)
|
||||
@@ -1996,6 +2018,16 @@ describe('Parameterized host smoke tests', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('generates Claude outside-voice skill for external hosts', () => {
|
||||
const skillMd = path.join(hostDir, 'gstack-claude', 'SKILL.md');
|
||||
expect(fs.existsSync(skillMd)).toBe(true);
|
||||
const content = fs.readFileSync(skillMd, 'utf-8');
|
||||
expect(content).toContain('claude -p');
|
||||
expect(content).toContain('--disable-slash-commands');
|
||||
expect(content).toContain('--allowedTools Read,Grep,Glob');
|
||||
expect(content).toContain('--disallowedTools Bash,Edit,Write');
|
||||
});
|
||||
|
||||
test('--dry-run freshness check passes', () => {
|
||||
const result = Bun.spawnSync(
|
||||
['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', hostConfig.name, '--dry-run'],
|
||||
|
||||
@@ -82,9 +82,8 @@ describe('Opus 4.7 overlay — pacing directive', () => {
|
||||
expect(out).toMatch(/user approval/i);
|
||||
});
|
||||
|
||||
test('resolved overlay keeps Fan out / Effort-match / Literal interpretation nudges', () => {
|
||||
test('resolved overlay keeps Effort-match / Literal interpretation nudges', () => {
|
||||
const out = generateModelOverlay(makeCtx('opus-4-7'));
|
||||
expect(out).toContain('Fan out explicitly');
|
||||
expect(out).toContain('Effort-match the step');
|
||||
expect(out).toContain('Literal interpretation awareness');
|
||||
});
|
||||
|
||||
@@ -1468,12 +1468,16 @@ describe('Codex skill validation', () => {
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||||
});
|
||||
|
||||
// Discover all Claude skills with templates (except /codex which is Claude-only)
|
||||
// Discover all shared skills with templates.
|
||||
// Host-exclusive outside-voice skills are intentionally omitted here:
|
||||
// - /codex is Claude-only
|
||||
// - /claude is external-host-only
|
||||
const CLAUDE_SKILLS_WITH_TEMPLATES = (() => {
|
||||
const skills: string[] = [];
|
||||
for (const entry of fs.readdirSync(ROOT, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
if (entry.name === 'codex') continue; // Claude-only skill
|
||||
if (entry.name === 'claude') continue; // External-host-only skill
|
||||
if (fs.existsSync(path.join(ROOT, entry.name, 'SKILL.md.tmpl'))) {
|
||||
skills.push(entry.name);
|
||||
}
|
||||
@@ -1504,6 +1508,13 @@ describe('Codex skill validation', () => {
|
||||
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack-codex', 'SKILL.md'))).toBe(false);
|
||||
});
|
||||
|
||||
test('/claude skill is external-host-only — no Claude-host variant', () => {
|
||||
// Claude host should not get an outside-voice skill that shells into Claude.
|
||||
expect(fs.existsSync(path.join(ROOT, 'claude', 'SKILL.md'))).toBe(false);
|
||||
// Codex/external hosts should get the generated wrapper.
|
||||
expect(fs.existsSync(path.join(AGENTS_DIR, 'gstack-claude', 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
test('Codex skill names follow gstack-{name} convention', () => {
|
||||
const codexDirs = fs.readdirSync(AGENTS_DIR);
|
||||
for (const dir of codexDirs) {
|
||||
@@ -1631,18 +1642,31 @@ describe('no compiled binaries in git', () => {
|
||||
expect(binaries).toEqual([]);
|
||||
});
|
||||
|
||||
test('git tracks no files larger than 2MB', () => {
|
||||
// Pure fs.statSync — no shell spawn per file.
|
||||
test('warns about tracked files larger than 2MB', () => {
|
||||
// Large fixtures can be legitimate test infrastructure. Keep visibility on
|
||||
// repository size without blocking those fixtures from living in git.
|
||||
const MAX_BYTES = 2 * 1024 * 1024;
|
||||
const oversized = trackedFiles.filter((f: string) => {
|
||||
const oversized = trackedFiles.flatMap((f: string) => {
|
||||
const full = path.join(ROOT, f);
|
||||
try {
|
||||
return fs.statSync(full).size > MAX_BYTES;
|
||||
const size = fs.statSync(full).size;
|
||||
return size > MAX_BYTES ? [{ file: f, size }] : [];
|
||||
} catch {
|
||||
return false;
|
||||
return [];
|
||||
}
|
||||
});
|
||||
expect(oversized).toEqual([]);
|
||||
|
||||
if (oversized.length > 0) {
|
||||
const formatted = oversized
|
||||
.map(({ file, size }: { file: string; size: number }) => {
|
||||
const mib = (size / (1024 * 1024)).toFixed(1);
|
||||
return `${file} (${mib} MiB)`;
|
||||
})
|
||||
.join(', ');
|
||||
console.warn(`[size-warning] tracked files over 2 MiB: ${formatted}`);
|
||||
}
|
||||
|
||||
expect(Array.isArray(oversized)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user