mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-07 05:56:41 +02:00
Merge origin/main into garrytan/codex-in-office-hours
This commit is contained in:
+260
-10
@@ -5,6 +5,39 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const MAX_SKILL_DESCRIPTION_LENGTH = 1024;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Dynamic template discovery — matches the generator's findTemplates() behavior.
|
||||
// New skills automatically get test coverage without updating a static list.
|
||||
@@ -98,6 +131,14 @@ describe('gen-skill-docs', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test(`every generated SKILL.md description stays within ${MAX_SKILL_DESCRIPTION_LENGTH} chars`, () => {
|
||||
for (const skill of ALL_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('generated files are fresh (match --dry-run)', () => {
|
||||
const result = Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--dry-run'], {
|
||||
cwd: ROOT,
|
||||
@@ -700,9 +741,11 @@ describe('CODEX_SECOND_OPINION resolver', () => {
|
||||
test('Codex host variant does NOT contain the Phase 3.5 resolver output', () => {
|
||||
// The resolver returns '' for codex host, so the interactive section is stripped.
|
||||
// Static template references to "Phase 3.5" in prose/conditionals are fine.
|
||||
// Other resolvers (design review lite) may contain CODEX_NOT_AVAILABLE, so we
|
||||
// check for Phase 3.5-specific markers only.
|
||||
expect(codexContent).not.toContain('Phase 3.5: Cross-Model Second Opinion');
|
||||
expect(codexContent).not.toContain('CODEX_NOT_AVAILABLE');
|
||||
expect(codexContent).not.toContain('TMPERR_OH');
|
||||
expect(codexContent).not.toContain('gstack-codex-oh-');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -732,11 +775,126 @@ describe('BENEFITS_FROM resolver', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- {{DESIGN_OUTSIDE_VOICES}} resolver tests ---
|
||||
|
||||
describe('DESIGN_OUTSIDE_VOICES resolver', () => {
|
||||
test('plan-design-review contains outside voices section', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'plan-design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Design Outside Voices');
|
||||
expect(content).toContain('CODEX_AVAILABLE');
|
||||
expect(content).toContain('LITMUS SCORECARD');
|
||||
});
|
||||
|
||||
test('design-review contains outside voices section', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Design Outside Voices');
|
||||
expect(content).toContain('source audit');
|
||||
});
|
||||
|
||||
test('design-consultation contains outside voices section', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'design-consultation', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Design Outside Voices');
|
||||
expect(content).toContain('design direction');
|
||||
});
|
||||
|
||||
test('branches correctly per skillName — different prompts', () => {
|
||||
const planContent = fs.readFileSync(path.join(ROOT, 'plan-design-review', 'SKILL.md'), 'utf-8');
|
||||
const consultContent = fs.readFileSync(path.join(ROOT, 'design-consultation', 'SKILL.md'), 'utf-8');
|
||||
// plan-design-review uses analytical prompt (high reasoning)
|
||||
expect(planContent).toContain('model_reasoning_effort="high"');
|
||||
// design-consultation uses creative prompt (medium reasoning)
|
||||
expect(consultContent).toContain('model_reasoning_effort="medium"');
|
||||
});
|
||||
});
|
||||
|
||||
// --- {{DESIGN_HARD_RULES}} resolver tests ---
|
||||
|
||||
describe('DESIGN_HARD_RULES resolver', () => {
|
||||
test('plan-design-review Pass 4 contains hard rules', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'plan-design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Design Hard Rules');
|
||||
expect(content).toContain('Classifier');
|
||||
expect(content).toContain('MARKETING/LANDING PAGE');
|
||||
expect(content).toContain('APP UI');
|
||||
});
|
||||
|
||||
test('design-review contains hard rules', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Design Hard Rules');
|
||||
});
|
||||
|
||||
test('includes all 3 rule sets', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'plan-design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Landing page rules');
|
||||
expect(content).toContain('App UI rules');
|
||||
expect(content).toContain('Universal rules');
|
||||
});
|
||||
|
||||
test('references shared AI slop blacklist items', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'plan-design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('3-column feature grid');
|
||||
expect(content).toContain('Purple/violet/indigo');
|
||||
});
|
||||
|
||||
test('includes OpenAI hard rejection criteria', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'plan-design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Generic SaaS card grid');
|
||||
expect(content).toContain('Carousel with no narrative purpose');
|
||||
});
|
||||
|
||||
test('includes OpenAI litmus checks', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'plan-design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('Brand/product unmistakable');
|
||||
expect(content).toContain('premium with all decorative shadows removed');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Extended DESIGN_SKETCH resolver tests ---
|
||||
|
||||
describe('DESIGN_SKETCH extended with outside voices', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'office-hours', 'SKILL.md'), 'utf-8');
|
||||
|
||||
test('contains outside design voices step', () => {
|
||||
expect(content).toContain('Outside design voices');
|
||||
});
|
||||
|
||||
test('offers opt-in via AskUserQuestion', () => {
|
||||
expect(content).toContain('outside design perspectives');
|
||||
});
|
||||
|
||||
test('still contains original wireframe steps', () => {
|
||||
expect(content).toContain('wireframe');
|
||||
expect(content).toContain('$B goto');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Extended DESIGN_REVIEW_LITE resolver tests ---
|
||||
|
||||
describe('DESIGN_REVIEW_LITE extended with Codex', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
||||
|
||||
test('contains Codex design voice block', () => {
|
||||
expect(content).toContain('Codex design voice');
|
||||
expect(content).toContain('CODEX (design)');
|
||||
});
|
||||
|
||||
test('still contains original checklist steps', () => {
|
||||
expect(content).toContain('design-checklist.md');
|
||||
expect(content).toContain('SCOPE_FRONTEND');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── Codex Generation Tests ─────────────────────────────────
|
||||
|
||||
describe('Codex generation (--host codex)', () => {
|
||||
const AGENTS_DIR = path.join(ROOT, '.agents', 'skills');
|
||||
|
||||
// .agents/ is gitignored (v0.11.2.0) — generate on demand for tests
|
||||
Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex'], {
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||||
});
|
||||
|
||||
// Dynamic discovery of expected Codex skills: all templates except /codex
|
||||
const CODEX_SKILLS = (() => {
|
||||
const skills: Array<{ dir: string; codexName: string }> = [];
|
||||
@@ -883,11 +1041,14 @@ describe('Codex generation (--host codex)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('Codex preamble uses codex paths', () => {
|
||||
test('Codex preamble resolves runtime assets from repo-local or global gstack roots', () => {
|
||||
// Check a skill that has a preamble (review is a good candidate)
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('~/.codex/skills/gstack');
|
||||
expect(content).toContain('.agents/skills/gstack');
|
||||
expect(content).toContain('GSTACK_ROOT');
|
||||
expect(content).toContain('$_ROOT/.agents/skills/gstack');
|
||||
expect(content).toContain('$GSTACK_BIN/gstack-config');
|
||||
expect(content).toContain('$GSTACK_ROOT/gstack-upgrade/SKILL.md');
|
||||
expect(content).not.toContain('~/.codex/skills/gstack/bin/gstack-config get telemetry');
|
||||
});
|
||||
|
||||
// ─── Path rewriting regression tests ─────────────────────────
|
||||
@@ -925,9 +1086,9 @@ describe('Codex generation (--host codex)', () => {
|
||||
// Test each of the 4 path rewrite rules individually
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-review', 'SKILL.md'), 'utf-8');
|
||||
|
||||
// Rule 1: ~/.claude/skills/gstack → ~/.codex/skills/gstack
|
||||
// Rule 1: ~/.claude/skills/gstack → $GSTACK_ROOT
|
||||
expect(content).not.toContain('~/.claude/skills/gstack');
|
||||
expect(content).toContain('~/.codex/skills/gstack');
|
||||
expect(content).toContain('$GSTACK_ROOT');
|
||||
|
||||
// Rule 2: .claude/skills/gstack → .agents/skills/gstack
|
||||
expect(content).not.toContain('.claude/skills/gstack');
|
||||
@@ -946,6 +1107,9 @@ describe('Codex generation (--host codex)', () => {
|
||||
// No skill should reference Claude paths
|
||||
expect(content).not.toContain('~/.claude/skills');
|
||||
expect(content).not.toContain('.claude/skills');
|
||||
if (content.includes('gstack-config') || content.includes('gstack-update-check') || content.includes('gstack-telemetry-log')) {
|
||||
expect(content).toContain('$GSTACK_ROOT');
|
||||
}
|
||||
// If a skill references checklist.md, it must use the correct sidecar path
|
||||
if (content.includes('checklist.md') && !content.includes('design-checklist.md')) {
|
||||
expect(content).not.toContain('gstack-review/checklist.md');
|
||||
@@ -976,9 +1140,24 @@ describe('Codex generation (--host codex)', () => {
|
||||
for (const skill of ALL_SKILLS) {
|
||||
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
|
||||
expect(content).not.toContain('~/.codex/');
|
||||
expect(content).not.toContain('.agents/skills');
|
||||
// gstack-upgrade legitimately references .agents/skills for cross-platform detection
|
||||
if (skill.dir !== 'gstack-upgrade') {
|
||||
expect(content).not.toContain('.agents/skills');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Design outside voices: Codex host guard ─────────────────
|
||||
|
||||
test('codex host produces empty outside voices in design-review', () => {
|
||||
const codexContent = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-design-review', 'SKILL.md'), 'utf-8');
|
||||
expect(codexContent).not.toContain('Design Outside Voices');
|
||||
});
|
||||
|
||||
test('codex host does not include Codex design block in ship', () => {
|
||||
const codexContent = fs.readFileSync(path.join(AGENTS_DIR, 'gstack-ship', 'SKILL.md'), 'utf-8');
|
||||
expect(codexContent).not.toContain('Codex design voice');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Setup script validation ─────────────────────────────────
|
||||
@@ -1012,8 +1191,31 @@ describe('setup script validation', () => {
|
||||
setupContent.indexOf('# 5. Install for Codex'),
|
||||
setupContent.indexOf('# 6. Create')
|
||||
);
|
||||
expect(codexSection).toContain('create_codex_runtime_root');
|
||||
expect(codexSection).toContain('link_codex_skill_dirs');
|
||||
expect(codexSection).not.toContain('link_claude_skill_dirs');
|
||||
expect(codexSection).not.toContain('ln -snf "$GSTACK_DIR" "$CODEX_GSTACK"');
|
||||
});
|
||||
|
||||
test('Codex install prefers repo-local .agents/skills when setup runs from there', () => {
|
||||
expect(setupContent).toContain('SKILLS_PARENT_BASENAME');
|
||||
expect(setupContent).toContain('CODEX_REPO_LOCAL=0');
|
||||
expect(setupContent).toContain('[ "$SKILLS_PARENT_BASENAME" = ".agents" ]');
|
||||
expect(setupContent).toContain('CODEX_REPO_LOCAL=1');
|
||||
expect(setupContent).toContain('CODEX_SKILLS="$INSTALL_SKILLS_DIR"');
|
||||
});
|
||||
|
||||
test('setup separates install path from source path for symlinked repo-local installs', () => {
|
||||
expect(setupContent).toContain('INSTALL_GSTACK_DIR=');
|
||||
expect(setupContent).toContain('SOURCE_GSTACK_DIR=');
|
||||
expect(setupContent).toContain('INSTALL_SKILLS_DIR=');
|
||||
expect(setupContent).toContain('CODEX_GSTACK="$INSTALL_GSTACK_DIR"');
|
||||
expect(setupContent).toContain('link_codex_skill_dirs "$SOURCE_GSTACK_DIR" "$CODEX_SKILLS"');
|
||||
});
|
||||
|
||||
test('Codex installs always create sidecar runtime assets for the real skill target', () => {
|
||||
expect(setupContent).toContain('if [ "$INSTALL_CODEX" -eq 1 ]; then');
|
||||
expect(setupContent).toContain('create_agents_sidecar "$SOURCE_GSTACK_DIR"');
|
||||
});
|
||||
|
||||
test('link_codex_skill_dirs reads from .agents/skills/', () => {
|
||||
@@ -1033,14 +1235,40 @@ describe('setup script validation', () => {
|
||||
expect(fnBody).toContain('ln -snf "gstack/$skill_name"');
|
||||
});
|
||||
|
||||
test('setup supports --host auto|claude|codex', () => {
|
||||
test('setup supports --host auto|claude|codex|kiro', () => {
|
||||
expect(setupContent).toContain('--host');
|
||||
expect(setupContent).toContain('claude|codex|auto');
|
||||
expect(setupContent).toContain('claude|codex|kiro|auto');
|
||||
});
|
||||
|
||||
test('auto mode detects claude and codex binaries', () => {
|
||||
test('auto mode detects claude, codex, and kiro binaries', () => {
|
||||
expect(setupContent).toContain('command -v claude');
|
||||
expect(setupContent).toContain('command -v codex');
|
||||
expect(setupContent).toContain('command -v kiro-cli');
|
||||
});
|
||||
|
||||
// T1: Sidecar skip guard — prevents .agents/skills/gstack from being linked as a skill
|
||||
test('link_codex_skill_dirs skips the gstack sidecar directory', () => {
|
||||
const fnStart = setupContent.indexOf('link_codex_skill_dirs()');
|
||||
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', fnStart));
|
||||
const fnBody = setupContent.slice(fnStart, fnEnd);
|
||||
expect(fnBody).toContain('[ "$skill_name" = "gstack" ] && continue');
|
||||
});
|
||||
|
||||
// T2: Dynamic $GSTACK_ROOT paths in generated Codex preambles
|
||||
test('generated Codex preambles use dynamic GSTACK_ROOT paths', () => {
|
||||
const codexSkillDir = path.join(ROOT, '.agents', 'skills', 'gstack-ship');
|
||||
if (!fs.existsSync(codexSkillDir)) return; // skip if .agents/ not generated
|
||||
const content = fs.readFileSync(path.join(codexSkillDir, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('GSTACK_ROOT=');
|
||||
expect(content).toContain('$GSTACK_BIN/');
|
||||
});
|
||||
|
||||
// T3: Kiro host support in setup script
|
||||
test('setup supports --host kiro with install section and sed rewrites', () => {
|
||||
expect(setupContent).toContain('INSTALL_KIRO=');
|
||||
expect(setupContent).toContain('kiro-cli');
|
||||
expect(setupContent).toContain('KIRO_SKILLS=');
|
||||
expect(setupContent).toContain('~/.kiro/skills/gstack');
|
||||
});
|
||||
|
||||
test('create_agents_sidecar links runtime assets', () => {
|
||||
@@ -1053,6 +1281,28 @@ describe('setup script validation', () => {
|
||||
expect(fnBody).toContain('review');
|
||||
expect(fnBody).toContain('qa');
|
||||
});
|
||||
|
||||
test('create_codex_runtime_root exposes only runtime assets', () => {
|
||||
const fnStart = setupContent.indexOf('create_codex_runtime_root()');
|
||||
const fnEnd = setupContent.indexOf('}', setupContent.indexOf('done', setupContent.indexOf('review/', fnStart)));
|
||||
const fnBody = setupContent.slice(fnStart, fnEnd);
|
||||
expect(fnBody).toContain('gstack/SKILL.md');
|
||||
expect(fnBody).toContain('browse/dist');
|
||||
expect(fnBody).toContain('browse/bin');
|
||||
expect(fnBody).toContain('gstack-upgrade/SKILL.md');
|
||||
// Review runtime assets (individual files, not the whole dir)
|
||||
expect(fnBody).toContain('checklist.md');
|
||||
expect(fnBody).toContain('design-checklist.md');
|
||||
expect(fnBody).toContain('greptile-triage.md');
|
||||
expect(fnBody).toContain('TODOS-format.md');
|
||||
expect(fnBody).not.toContain('ln -snf "$gstack_dir" "$codex_gstack"');
|
||||
});
|
||||
|
||||
test('direct Codex installs are migrated out of ~/.codex/skills/gstack', () => {
|
||||
expect(setupContent).toContain('migrate_direct_codex_install');
|
||||
expect(setupContent).toContain('$HOME/.gstack/repos/gstack');
|
||||
expect(setupContent).toContain('avoid duplicate skill discovery');
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetry', () => {
|
||||
|
||||
@@ -105,13 +105,13 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
'ship-triage': ['ship/**', 'bin/gstack-repo-mode'],
|
||||
|
||||
// Design
|
||||
'design-consultation-core': ['design-consultation/**'],
|
||||
'design-consultation-existing': ['design-consultation/**'],
|
||||
'design-consultation-research': ['design-consultation/**'],
|
||||
'design-consultation-preview': ['design-consultation/**'],
|
||||
'plan-design-review-plan-mode': ['plan-design-review/**'],
|
||||
'plan-design-review-no-ui-scope': ['plan-design-review/**'],
|
||||
'design-review-fix': ['design-review/**', 'browse/src/**'],
|
||||
'design-consultation-core': ['design-consultation/**', 'scripts/gen-skill-docs.ts'],
|
||||
'design-consultation-existing': ['design-consultation/**', 'scripts/gen-skill-docs.ts'],
|
||||
'design-consultation-research': ['design-consultation/**', 'scripts/gen-skill-docs.ts'],
|
||||
'design-consultation-preview': ['design-consultation/**', 'scripts/gen-skill-docs.ts'],
|
||||
'plan-design-review-plan-mode': ['plan-design-review/**', 'scripts/gen-skill-docs.ts'],
|
||||
'plan-design-review-no-ui-scope': ['plan-design-review/**', 'scripts/gen-skill-docs.ts'],
|
||||
'design-review-fix': ['design-review/**', 'browse/src/**', 'scripts/gen-skill-docs.ts'],
|
||||
|
||||
// gstack-upgrade
|
||||
'gstack-upgrade-happy-path': ['gstack-upgrade/**'],
|
||||
|
||||
@@ -1328,6 +1328,10 @@ describe('Codex skill', () => {
|
||||
});
|
||||
|
||||
test('codex-host ship/review do NOT contain adversarial review step', () => {
|
||||
// .agents/ is gitignored — generate on demand
|
||||
Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex'], {
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||||
});
|
||||
const shipContent = fs.readFileSync(path.join(ROOT, '.agents', 'skills', 'gstack-ship', 'SKILL.md'), 'utf-8');
|
||||
expect(shipContent).not.toContain('codex review --base');
|
||||
expect(shipContent).not.toContain('CODEX_REVIEWS');
|
||||
@@ -1402,6 +1406,11 @@ describe('Skill trigger phrases', () => {
|
||||
describe('Codex skill validation', () => {
|
||||
const AGENTS_DIR = path.join(ROOT, '.agents', 'skills');
|
||||
|
||||
// .agents/ is gitignored (v0.11.2.0) — generate on demand for tests
|
||||
Bun.spawnSync(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex'], {
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||||
});
|
||||
|
||||
// Discover all Claude skills with templates (except /codex which is Claude-only)
|
||||
const CLAUDE_SKILLS_WITH_TEMPLATES = (() => {
|
||||
const skills: string[] = [];
|
||||
|
||||
Reference in New Issue
Block a user