diff --git a/openclaw/skills/gstack-openclaw-ceo-review/SKILL.md b/openclaw/skills/gstack-openclaw-ceo-review/SKILL.md index d4ae213df0..5364396f3e 100644 --- a/openclaw/skills/gstack-openclaw-ceo-review/SKILL.md +++ b/openclaw/skills/gstack-openclaw-ceo-review/SKILL.md @@ -1,6 +1,12 @@ --- name: gstack-openclaw-ceo-review -description: CEO/founder-mode plan review. Rethink the problem, find the 10-star product, challenge premises, expand scope when it creates a better product. Four modes: SCOPE EXPANSION (dream big), SELECTIVE EXPANSION (hold scope + cherry-pick), HOLD SCOPE (maximum rigor), SCOPE REDUCTION (strip to essentials). Use when asked to review a plan, challenge this, CEO review, poke holes, think bigger, or expand scope. +description: | + CEO/founder-mode plan review. Rethink the problem, find the 10-star product, + challenge premises, expand scope when it creates a better product. Four + modes: SCOPE EXPANSION (dream big), SELECTIVE EXPANSION (hold scope + + cherry-pick), HOLD SCOPE (maximum rigor), SCOPE REDUCTION (strip to + essentials). Use when asked to review a plan, challenge this, CEO review, + poke holes, think bigger, or expand scope. version: 1.0.0 metadata: { "openclaw": { "emoji": "👑" } } --- diff --git a/openclaw/skills/gstack-openclaw-investigate/SKILL.md b/openclaw/skills/gstack-openclaw-investigate/SKILL.md index e83d9cda66..0273fbf263 100644 --- a/openclaw/skills/gstack-openclaw-investigate/SKILL.md +++ b/openclaw/skills/gstack-openclaw-investigate/SKILL.md @@ -1,6 +1,11 @@ --- name: gstack-openclaw-investigate -description: Systematic debugging with root cause investigation. Four phases: investigate, analyze, hypothesize, implement. Iron Law: no fixes without root cause. Use when asked to debug, fix a bug, investigate an error, or root cause analysis. Proactively use when user reports errors, stack traces, unexpected behavior, or says something stopped working. +description: | + Systematic debugging with root cause investigation. Four phases: investigate, + analyze, hypothesize, implement. Iron Law: no fixes without root cause. Use + when asked to debug, fix a bug, investigate an error, or root cause analysis. + Proactively use when user reports errors, stack traces, unexpected behavior, + or says something stopped working. version: 1.0.0 metadata: { "openclaw": { "emoji": "🔍" } } --- diff --git a/openclaw/skills/gstack-openclaw-office-hours/SKILL.md b/openclaw/skills/gstack-openclaw-office-hours/SKILL.md index 8cb1f2b7d2..a1010136a8 100644 --- a/openclaw/skills/gstack-openclaw-office-hours/SKILL.md +++ b/openclaw/skills/gstack-openclaw-office-hours/SKILL.md @@ -1,6 +1,13 @@ --- name: gstack-openclaw-office-hours -description: Product interrogation with six forcing questions. Two modes: startup diagnostic (demand reality, status quo, desperate specificity, narrowest wedge, observation, future-fit) and builder brainstorm. Use when asked to brainstorm, "is this worth building", "I have an idea", "office hours", or "help me think through this". Proactively use when user describes a new product idea or wants to think through design decisions before any code is written. +description: | + Product interrogation with six forcing questions. Two modes: startup + diagnostic (demand reality, status quo, desperate specificity, narrowest + wedge, observation, future-fit) and builder brainstorm. Use when asked to + brainstorm, "is this worth building", "I have an idea", "office hours", or + "help me think through this". Proactively use when user describes a new + product idea or wants to think through design decisions before any code is + written. version: 1.0.0 metadata: { "openclaw": { "emoji": "🎯" } } --- diff --git a/package.json b/package.json index ca64667e26..779573d97b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "0.15.8.0", + "version": "0.15.11.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/discover-skills.ts b/scripts/discover-skills.ts index 67d9a3b6c7..349e16917f 100644 --- a/scripts/discover-skills.ts +++ b/scripts/discover-skills.ts @@ -1,7 +1,7 @@ -/** - * Shared discovery for SKILL.md and .tmpl files. - * Scans root + one level of subdirs, skipping node_modules/.git/dist. - */ +// Shared discovery for SKILL.md and .tmpl files. +// Template discovery scans root + one level of subdirs. +// SKILL.md discovery walks nested directories so maintenance checks also cover +// host-specific trees such as openclaw/skills/*/SKILL.md. import * as fs from 'fs'; import * as path from 'path'; @@ -27,13 +27,25 @@ export function discoverTemplates(root: string): Array<{ tmpl: string; output: s } export function discoverSkillFiles(root: string): string[] { - const dirs = ['', ...subdirs(root)]; const results: string[] = []; - for (const dir of dirs) { - const rel = dir ? `${dir}/SKILL.md` : 'SKILL.md'; - if (fs.existsSync(path.join(root, rel))) { - results.push(rel); + + function walk(currentDir: string, relativeDir = ''): void { + const skillRel = relativeDir ? `${relativeDir}/SKILL.md` : 'SKILL.md'; + if (fs.existsSync(path.join(currentDir, 'SKILL.md'))) { + results.push(skillRel); + } + + for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith('.')) continue; + if (SKIP.has(entry.name)) continue; + + const nextDir = path.join(currentDir, entry.name); + const nextRel = relativeDir ? `${relativeDir}/${entry.name}` : entry.name; + walk(nextDir, nextRel); } } - return results; + + walk(root); + return results.sort(); } diff --git a/scripts/skill-check.ts b/scripts/skill-check.ts index ebcced40af..d2b36f438c 100644 --- a/scripts/skill-check.ts +++ b/scripts/skill-check.ts @@ -36,16 +36,20 @@ for (const file of SKILL_FILES) { const totalValid = result.valid.length; const totalInvalid = result.invalid.length; const totalSnapErrors = result.snapshotFlagErrors.length; + const totalFrontmatterErrors = result.frontmatterErrors.length; - if (totalInvalid > 0 || totalSnapErrors > 0) { + if (totalInvalid > 0 || totalSnapErrors > 0 || totalFrontmatterErrors > 0) { hasErrors = true; - console.log(` \u274c ${file.padEnd(30)} — ${totalValid} valid, ${totalInvalid} invalid, ${totalSnapErrors} snapshot errors`); + console.log(` \u274c ${file.padEnd(30)} — ${totalValid} valid, ${totalInvalid} invalid, ${totalSnapErrors} snapshot errors, ${totalFrontmatterErrors} frontmatter errors`); for (const inv of result.invalid) { console.log(` line ${inv.line}: unknown command '${inv.command}'`); } for (const se of result.snapshotFlagErrors) { console.log(` line ${se.command.line}: ${se.error}`); } + for (const fe of result.frontmatterErrors) { + console.log(` ${fe}`); + } } else { console.log(` \u2705 ${file.padEnd(30)} — ${totalValid} commands, all valid`); } diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 3cf2d043c8..b8e4b68770 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -132,6 +132,28 @@ describe('gen-skill-docs', () => { } }); + test('native OpenClaw skill descriptions avoid invalid plain-scalar YAML', () => { + const openClawSkillGlob = path.join(ROOT, 'openclaw', 'skills'); + const skillDirs = fs.readdirSync(openClawSkillGlob, { withFileTypes: true }).filter(entry => entry.isDirectory()); + + for (const entry of skillDirs) { + const content = fs.readFileSync(path.join(openClawSkillGlob, entry.name, 'SKILL.md'), 'utf-8'); + const fmEnd = content.indexOf('\n---', 4); + expect(fmEnd).toBeGreaterThan(0); + const frontmatter = content.slice(4, fmEnd); + + const descriptionLine = frontmatter.split('\n').find(line => line.startsWith('description:')); + expect(descriptionLine).toBeDefined(); + + if (descriptionLine === 'description: |') { + continue; + } + + const plainDescription = descriptionLine!.replace(/^description:\s*/, ''); + expect(plainDescription.includes(': ')).toBe(false); + } + }); + 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'); diff --git a/test/helpers/skill-parser.ts b/test/helpers/skill-parser.ts index 0da19f63ed..af8f25c765 100644 --- a/test/helpers/skill-parser.ts +++ b/test/helpers/skill-parser.ts @@ -26,9 +26,61 @@ export interface ValidationResult { valid: BrowseCommand[]; invalid: BrowseCommand[]; snapshotFlagErrors: Array<{ command: BrowseCommand; error: string }>; + frontmatterErrors: string[]; warnings: string[]; } +function extractFrontmatter(content: string): string | null { + if (!content.startsWith('---\n')) return null; + + const fmEnd = content.indexOf('\n---\n', 4); + if (fmEnd === -1) return null; + + return content.slice(4, fmEnd); +} + +/** + * Lightweight YAML frontmatter validation for SKILL.md files. + * + * We only need to catch the class of errors that breaks host parsers in practice: + * top-level scalar values written without quotes even though they contain `: `, + * which YAML interprets as a nested mapping. + */ +export function validateFrontmatter(skillPath: string): string[] { + const content = fs.readFileSync(skillPath, 'utf-8'); + const frontmatter = extractFrontmatter(content); + if (!frontmatter) return []; + + const errors: string[] = []; + const lines = frontmatter.split('\n'); + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const trimmed = line.trim(); + + if (!trimmed || trimmed.startsWith('#')) continue; + if (/^\s/.test(line)) continue; + + const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!match) { + errors.push(`line ${index + 2}: invalid frontmatter line`); + continue; + } + + const [, key, value] = match; + if (!value) continue; + + const firstChar = value[0]; + if (['"', '\'', '{', '[', '|', '>'].includes(firstChar)) continue; + + if (value.includes(': ')) { + errors.push(`line ${index + 2}: ${key} contains unquoted ": "`); + } + } + + return errors; +} + /** * Extract all $B invocations from bash code blocks in a SKILL.md file. */ @@ -103,6 +155,7 @@ export function validateSkill(skillPath: string): ValidationResult { valid: [], invalid: [], snapshotFlagErrors: [], + frontmatterErrors: validateFrontmatter(skillPath), warnings: [], }; diff --git a/test/skill-parser.test.ts b/test/skill-parser.test.ts index 3c62c682cb..645c4c8389 100644 --- a/test/skill-parser.test.ts +++ b/test/skill-parser.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { extractBrowseCommands, validateSkill } from './helpers/skill-parser'; +import { extractBrowseCommands, validateFrontmatter, validateSkill } from './helpers/skill-parser'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -176,4 +176,35 @@ describe('validateSkill', () => { expect(result.valid).toHaveLength(4); expect(result.snapshotFlagErrors).toHaveLength(0); }); + + test('invalid frontmatter plain scalars with colon are flagged', () => { + const p = writeFixture('bad-frontmatter.md', [ + '---', + 'name: bad-skill', + 'description: Iron Law: no fixes without root cause.', + 'version: 1.0.0', + '---', + '', + '# Skill', + ].join('\n')); + const result = validateSkill(p); + expect(result.frontmatterErrors).toEqual([ + 'line 3: description contains unquoted ": "', + ]); + }); +}); + +describe('validateFrontmatter', () => { + test('quoted descriptions with colons pass validation', () => { + const p = writeFixture('quoted-frontmatter.md', [ + '---', + 'name: good-skill', + "description: 'Iron Law: no fixes without root cause.'", + 'version: 1.0.0', + '---', + '', + '# Skill', + ].join('\n')); + expect(validateFrontmatter(p)).toEqual([]); + }); }); diff --git a/test/skill-validation.test.ts b/test/skill-validation.test.ts index 1da5db6d84..d988689e2d 100644 --- a/test/skill-validation.test.ts +++ b/test/skill-validation.test.ts @@ -2,12 +2,26 @@ import { describe, test, expect } from 'bun:test'; import { validateSkill, extractRemoteSlugPatterns, extractWeightsFromTable } from './helpers/skill-parser'; import { ALL_COMMANDS, COMMAND_DESCRIPTIONS, READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from '../browse/src/commands'; import { SNAPSHOT_FLAGS } from '../browse/src/snapshot'; +import { discoverSkillFiles } from '../scripts/discover-skills'; import * as fs from 'fs'; import * as path from 'path'; const ROOT = path.resolve(import.meta.dir, '..'); describe('SKILL.md command validation', () => { + test('all discovered SKILL.md files have valid frontmatter', () => { + const failures: string[] = []; + + for (const skillFile of discoverSkillFiles(ROOT)) { + const result = validateSkill(path.join(ROOT, skillFile)); + if (result.frontmatterErrors.length > 0) { + failures.push(`${skillFile}: ${result.frontmatterErrors.join(', ')}`); + } + } + + expect(failures).toEqual([]); + }); + test('all $B commands in SKILL.md are valid browse commands', () => { const result = validateSkill(path.join(ROOT, 'SKILL.md')); expect(result.invalid).toHaveLength(0);