From 874e57f84ca02ffb1a58ee91db83aefd2a497658 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 17:42:38 -0700 Subject: [PATCH 1/9] validate nested workspace skills by default --- docs/cli/intent-setup.md | 5 +- .../quick-start-maintainers.md | 6 +- .../templates/workflows/validate-skills.yml | 37 +-- packages/intent/src/commands/validate.ts | 232 +++++++++++------- packages/intent/tests/cli.test.ts | 134 +++++++++- packages/intent/tests/setup.test.ts | 21 ++ 6 files changed, 319 insertions(+), 116 deletions(-) diff --git a/docs/cli/intent-setup.md b/docs/cli/intent-setup.md index 1c85bf6..7906843 100644 --- a/docs/cli/intent-setup.md +++ b/docs/cli/intent-setup.md @@ -41,9 +41,10 @@ npx @tanstack/intent@latest setup - Missing or invalid `package.json` when running `edit-package-json` - Missing template source when running `setup` -## Notes - +## Notes + - `setup` skips existing files +- To adopt updated workflow templates, delete or move the old generated workflow files first, then rerun `setup` - In monorepos, run `setup` from either the repo root or a package directory; Intent writes workflows to the workspace root ## Related diff --git a/docs/getting-started/quick-start-maintainers.md b/docs/getting-started/quick-start-maintainers.md index a3456e2..c363986 100644 --- a/docs/getting-started/quick-start-maintainers.md +++ b/docs/getting-started/quick-start-maintainers.md @@ -113,8 +113,10 @@ npx @tanstack/intent@latest setup - For single packages: also adds `!skills/_artifacts` to exclude artifacts from npm - For monorepos: skips the artifacts exclusion (artifacts live at repo root) - `setup` copies workflow templates to `.github/workflows/` for automated validation and staleness checking - -### 5. Ship skills with your package + +`setup` does not overwrite existing workflow files. To pick up newer generated workflows, delete or move the old generated files in `.github/workflows/`, then rerun `npx @tanstack/intent@latest setup`. + +### 5. Ship skills with your package Skills ship inside your npm package. When you publish: diff --git a/packages/intent/meta/templates/workflows/validate-skills.yml b/packages/intent/meta/templates/workflows/validate-skills.yml index 8f39716..8e95d93 100644 --- a/packages/intent/meta/templates/workflows/validate-skills.yml +++ b/packages/intent/meta/templates/workflows/validate-skills.yml @@ -1,6 +1,6 @@ # validate-skills.yml — Drop this into your library repo's .github/workflows/ # -# Validates skill files on PRs that touch the skills/ directory. +# Validates skill files on PRs that touch any skills/ directory. # Ensures frontmatter is correct, names match paths, and files stay under # the 500-line limit. @@ -30,23 +30,24 @@ jobs: - name: Find and validate skills run: | - # Find all directories containing SKILL.md files - SKILLS_DIR="" - if [ -d "skills" ]; then - SKILLS_DIR="skills" - elif [ -d "packages" ]; then - # Monorepo — find skills/ under packages - for dir in packages/*/skills; do - if [ -d "$dir" ]; then - echo "Validating $dir..." - intent validate "$dir" - fi - done - exit 0 - fi + found_file="$(mktemp)" + + find . \ + \( -path './.git' -o -path './node_modules' \) -prune -o \ + -type d -name skills -print | + while IFS= read -r dir; do + if ! find "$dir" -name SKILL.md -print -quit | grep -q .; then + continue + fi - if [ -n "$SKILLS_DIR" ]; then - intent validate "$SKILLS_DIR" - else + dir="${dir#./}" + echo "Validating $dir..." + intent validate "$dir" + echo "1" > "$found_file" + done + + if [ ! -s "$found_file" ]; then echo "No skills/ directory found — skipping validation." fi + + rm -f "$found_file" diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index a973d3d..17a3408 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -6,6 +6,7 @@ import { type ProjectContext, resolveProjectContext, } from '../core/project-context.js' +import { findWorkspacePackages } from '../workspace-patterns.js' interface ValidationError { file: string @@ -83,140 +84,191 @@ export async function runValidateCommand(dir?: string): Promise { import('yaml'), import('../utils.js'), ]) - const targetDir = dir ?? 'skills' const context = resolveProjectContext({ cwd: process.cwd(), - targetPath: targetDir, + targetPath: dir, }) - const skillsDir = context.targetSkillsDir ?? resolve(process.cwd(), targetDir) + const explicitDir = dir !== undefined + const skillsDirs = explicitDir + ? [context.targetSkillsDir ?? resolve(process.cwd(), dir)] + : collectDefaultSkillsDirs(context, findSkillFiles) - if (!existsSync(skillsDir)) { - fail(`Skills directory not found: ${skillsDir}`) + if (explicitDir && !existsSync(skillsDirs[0]!)) { + fail(`Skills directory not found: ${skillsDirs[0]}`) } const errors: Array = [] - const skillFiles = findSkillFiles(skillsDir) + const warnings: Array = [] + let validatedCount = 0 - if (skillFiles.length === 0) { + if (explicitDir && findSkillFiles(skillsDirs[0]!).length === 0) { fail('No SKILL.md files found') } - for (const filePath of skillFiles) { - const rel = relative(process.cwd(), filePath) - const content = readFileSync(filePath, 'utf8') - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)/) + if (skillsDirs.length === 0) { + console.log('No skills/ directory found — skipping validation.') + return + } - if (!match) { - errors.push({ file: rel, message: 'Missing or invalid frontmatter' }) - continue - } + for (const skillsDir of skillsDirs) { + const skillFiles = findSkillFiles(skillsDir) + const validateContext = resolveProjectContext({ + cwd: process.cwd(), + targetPath: skillsDir, + }) - if (!match[1]) { - errors.push({ file: rel, message: 'Missing YAML frontmatter' }) - continue - } + for (const filePath of skillFiles) { + const rel = relative(process.cwd(), filePath) + const content = readFileSync(filePath, 'utf8') + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)/) - let fm: Record - try { - fm = parseYaml(match[1]) as Record - } catch (err) { - const detail = err instanceof Error ? err.message : String(err) - errors.push({ file: rel, message: `Invalid YAML frontmatter: ${detail}` }) - continue - } + if (!match) { + errors.push({ file: rel, message: 'Missing or invalid frontmatter' }) + continue + } - if (!fm.name) { - errors.push({ file: rel, message: 'Missing required field: name' }) - } - if (!fm.description) { - errors.push({ file: rel, message: 'Missing required field: description' }) - } + if (!match[1]) { + errors.push({ file: rel, message: 'Missing YAML frontmatter' }) + continue + } - if (typeof fm.name === 'string') { - const expectedPath = relative(skillsDir, filePath) - .replace(/[/\\]SKILL\.md$/, '') - .split(sep) - .join('/') - if (fm.name !== expectedPath) { + let fm: Record + try { + fm = parseYaml(match[1]) as Record + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) errors.push({ file: rel, - message: `name "${fm.name}" does not match directory path "${expectedPath}"`, + message: `Invalid YAML frontmatter: ${detail}`, }) + continue } - } - if (typeof fm.description === 'string' && fm.description.length > 1024) { - errors.push({ - file: rel, - message: `Description exceeds 1024 character limit (${fm.description.length} chars)`, - }) - } + if (!fm.name) { + errors.push({ file: rel, message: 'Missing required field: name' }) + } + if (!fm.description) { + errors.push({ + file: rel, + message: 'Missing required field: description', + }) + } - if (fm.type === 'framework' && !Array.isArray(fm.requires)) { - errors.push({ - file: rel, - message: 'Framework skills must have a "requires" field', - }) - } + if (typeof fm.name === 'string') { + const expectedPath = relative(skillsDir, filePath) + .replace(/[/\\]SKILL\.md$/, '') + .split(sep) + .join('/') + if (fm.name !== expectedPath) { + errors.push({ + file: rel, + message: `name "${fm.name}" does not match directory path "${expectedPath}"`, + }) + } + } - const lineCount = content.split(/\r?\n/).length - if (lineCount > 500) { - errors.push({ - file: rel, - message: `Exceeds 500 line limit (${lineCount} lines). Rewrite for conciseness: move API tables to references/, trim verbose examples, and remove content an agent already knows. Do not simply raise the limit.`, - }) - } - } + if (typeof fm.description === 'string' && fm.description.length > 1024) { + errors.push({ + file: rel, + message: `Description exceeds 1024 character limit (${fm.description.length} chars)`, + }) + } - // In monorepos, _artifacts lives at the workspace root, not under each package's skills/ dir. - const artifactsDir = join(skillsDir, '_artifacts') - if (!context.isMonorepo && existsSync(artifactsDir)) { - const requiredArtifacts = [ - 'domain_map.yaml', - 'skill_spec.md', - 'skill_tree.yaml', - ] - - for (const fileName of requiredArtifacts) { - const artifactPath = join(artifactsDir, fileName) - if (!existsSync(artifactPath)) { + if (fm.type === 'framework' && !Array.isArray(fm.requires)) { errors.push({ - file: relative(process.cwd(), artifactPath), - message: 'Missing required artifact', + file: rel, + message: 'Framework skills must have a "requires" field', }) - continue } - const content = readFileSync(artifactPath, 'utf8') - if (content.trim().length === 0) { + const lineCount = content.split(/\r?\n/).length + if (lineCount > 500) { errors.push({ - file: relative(process.cwd(), artifactPath), - message: 'Artifact file is empty', + file: rel, + message: `Exceeds 500 line limit (${lineCount} lines). Rewrite for conciseness: move API tables to references/, trim verbose examples, and remove content an agent already knows. Do not simply raise the limit.`, }) - continue } + } + + // In monorepos, _artifacts lives at the workspace root, not under each package's skills/ dir. + const artifactsDir = join(skillsDir, '_artifacts') + if (!validateContext.isMonorepo && existsSync(artifactsDir)) { + const requiredArtifacts = [ + 'domain_map.yaml', + 'skill_spec.md', + 'skill_tree.yaml', + ] + + for (const fileName of requiredArtifacts) { + const artifactPath = join(artifactsDir, fileName) + if (!existsSync(artifactPath)) { + errors.push({ + file: relative(process.cwd(), artifactPath), + message: 'Missing required artifact', + }) + continue + } - if (fileName.endsWith('.yaml')) { - try { - parseYaml(content) - } catch (err) { - const detail = err instanceof Error ? err.message : String(err) + const content = readFileSync(artifactPath, 'utf8') + if (content.trim().length === 0) { errors.push({ file: relative(process.cwd(), artifactPath), - message: `Invalid YAML in artifact file: ${detail}`, + message: 'Artifact file is empty', }) + continue + } + + if (fileName.endsWith('.yaml')) { + try { + parseYaml(content) + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) + errors.push({ + file: relative(process.cwd(), artifactPath), + message: `Invalid YAML in artifact file: ${detail}`, + }) + } } } } - } - const warnings = collectPackagingWarnings(context) + validatedCount += skillFiles.length + warnings.push(...collectPackagingWarnings(validateContext)) + } if (errors.length > 0) { fail(buildValidationFailure(errors, warnings)) } - console.log(`✅ Validated ${skillFiles.length} skill files — all passed`) + console.log(`✅ Validated ${validatedCount} skill files — all passed`) if (warnings.length > 0) console.log() printWarnings(warnings) } + +function collectDefaultSkillsDirs( + context: ProjectContext, + findSkillFiles: (dir: string) => Array, +): Array { + const skillsDirs: Array = [] + const addSkillsDir = (skillsDir: string): void => { + if (existsSync(skillsDir) && findSkillFiles(skillsDir).length > 0) { + skillsDirs.push(skillsDir) + } + } + + if (context.workspaceRoot && context.cwd === context.workspaceRoot) { + addSkillsDir(join(context.workspaceRoot, 'skills')) + for (const packageDir of findWorkspacePackages(context.workspaceRoot)) { + addSkillsDir(join(packageDir, 'skills')) + } + return [...new Set(skillsDirs)].sort((a, b) => a.localeCompare(b)) + } + + const skillsDir = + context.targetSkillsDir ?? + (context.packageRoot + ? join(context.packageRoot, 'skills') + : resolve(context.cwd, 'skills')) + addSkillsDir(skillsDir) + return skillsDirs +} diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index f8e4344..d6fffca 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1156,6 +1156,131 @@ describe('cli commands', () => { expect(output).not.toContain('@tanstack/intent is not in devDependencies') }) + it('validates nested pnpm workspace package skills from the repo root', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-validate-nested-pnpm-'), + ) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + }) + writeFileSync( + join(root, 'pnpm-workspace.yaml'), + 'packages:\n - packages/typescript/*\n', + ) + + for (const packageName of ['ai', 'ai-code-mode']) { + const packageDir = join(root, 'packages', 'typescript', packageName) + writeJson(join(packageDir, 'package.json'), { + name: `@tanstack/${packageName}`, + devDependencies: { '@tanstack/intent': '^0.0.18' }, + keywords: ['tanstack-intent'], + files: ['skills'], + }) + writeSkillMd(join(packageDir, 'skills', 'core'), { + name: 'core', + description: `${packageName} skill`, + }) + } + + process.chdir(root) + + const exitCode = await main(['validate']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('✅ Validated 2 skill files — all passed') + expect(output).not.toContain('@tanstack/intent is not in devDependencies') + expect(output).not.toContain('Missing "tanstack-intent" in keywords array') + }) + + it('validates nested package.json workspace package skills from the repo root', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-validate-nested-yarn-'), + ) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + workspaces: ['packages/typescript/*'], + }) + + for (const packageName of ['ai', 'ai-code-mode']) { + const packageDir = join(root, 'packages', 'typescript', packageName) + writeJson(join(packageDir, 'package.json'), { + name: `@tanstack/${packageName}`, + devDependencies: { '@tanstack/intent': '^0.0.18' }, + keywords: ['tanstack-intent'], + files: ['skills'], + }) + writeSkillMd(join(packageDir, 'skills', 'core'), { + name: 'core', + description: `${packageName} skill`, + }) + } + + process.chdir(root) + + const exitCode = await main(['validate']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('✅ Validated 2 skill files — all passed') + expect(output).not.toContain('@tanstack/intent is not in devDependencies') + expect(output).not.toContain('Missing "tanstack-intent" in keywords array') + }) + + it('validates only the explicit skills directory when one is passed', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-validate-explicit-nested-'), + ) + tempDirs.push(root) + + writeJson(join(root, 'package.json'), { + private: true, + workspaces: ['packages/typescript/*'], + }) + writeJson(join(root, 'packages', 'typescript', 'ai', 'package.json'), { + name: '@tanstack/ai', + devDependencies: { '@tanstack/intent': '^0.0.18' }, + keywords: ['tanstack-intent'], + files: ['skills'], + }) + writeJson( + join(root, 'packages', 'typescript', 'ai-code-mode', 'package.json'), + { + name: '@tanstack/ai-code-mode', + devDependencies: { '@tanstack/intent': '^0.0.18' }, + keywords: ['tanstack-intent'], + files: ['skills'], + }, + ) + writeSkillMd(join(root, 'packages', 'typescript', 'ai', 'skills', 'core'), { + name: 'core', + description: 'AI skill', + }) + writeSkillMd( + join(root, 'packages', 'typescript', 'ai-code-mode', 'skills', 'bad'), + { + name: 'not-bad', + description: 'Invalid skill outside the explicit target', + }, + ) + + process.chdir(root) + + const exitCode = await main([ + 'validate', + 'packages/typescript/ai/skills', + ]) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('✅ Validated 1 skill files — all passed') + expect(output).not.toContain('not-bad') + }) + it('validates pnpm workspace package skills from repo root without false packaging warnings', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-pnpm-')) tempDirs.push(root) @@ -1191,17 +1316,18 @@ describe('cli commands', () => { ) }) - it('fails cleanly when validate is run without a skills directory', async () => { + it('skips cleanly when validate is run without a skills directory', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-missing-skills-')) tempDirs.push(root) process.chdir(root) const exitCode = await main(['validate']) - expect(exitCode).toBe(1) - expect(errorSpy).toHaveBeenCalledWith( - `Skills directory not found: ${join(root, 'skills')}`, + expect(exitCode).toBe(0) + expect(logSpy).toHaveBeenCalledWith( + 'No skills/ directory found — skipping validation.', ) + expect(errorSpy).not.toHaveBeenCalled() }) it('fails cleanly for unsupported yarn pnp projects', async () => { diff --git a/packages/intent/tests/setup.test.ts b/packages/intent/tests/setup.test.ts index 92cede4..ed82bfb 100644 --- a/packages/intent/tests/setup.test.ts +++ b/packages/intent/tests/setup.test.ts @@ -16,6 +16,8 @@ import { } from '../src/setup.js' import type { EditPackageJsonResult, MonorepoResult } from '../src/setup.js' +const repoRoot = join(import.meta.dirname, '..', '..', '..') + let root: string let metaDir: string @@ -289,6 +291,25 @@ describe('runSetupGithubActions', () => { ) }) + it('ships a validate workflow that discovers nested workspace skills directories', () => { + const validateContent = readFileSync( + join( + repoRoot, + 'packages', + 'intent', + 'meta', + 'templates', + 'workflows', + 'validate-skills.yml', + ), + 'utf8', + ) + + expect(validateContent).toContain('-type d -name skills -print') + expect(validateContent).toContain('intent validate "$dir"') + expect(validateContent).not.toContain('packages/*/skills') + }) + it('copies templates with defaults when no package.json', () => { const result = runSetupGithubActions(root, metaDir) expect(result.workflows).toHaveLength(2) From eb4bfc22d289dfc3117bc5527a653b49428cb345 Mon Sep 17 00:00:00 2001 From: Sarah Gerrard Date: Thu, 23 Apr 2026 17:56:51 -0700 Subject: [PATCH 2/9] fold skill validation into check workflow --- docs/cli/intent-setup.md | 4 +- .../quick-start-maintainers.md | 24 +- .../meta/templates/workflows/check-skills.yml | 214 +++--------------- .../templates/workflows/validate-skills.yml | 53 ----- packages/intent/src/cli-support.ts | 2 +- packages/intent/src/cli.ts | 21 +- packages/intent/src/commands/stale.ts | 46 +++- packages/intent/src/commands/validate.ts | 77 ++++++- packages/intent/src/workflow-review.ts | 54 +++++ packages/intent/tests/cli.test.ts | 31 +++ packages/intent/tests/setup.test.ts | 69 +++--- packages/intent/tests/stale-command.test.ts | 114 +++++++++- packages/intent/tests/workflow-review.test.ts | 86 ++++++- 13 files changed, 482 insertions(+), 313 deletions(-) delete mode 100644 packages/intent/meta/templates/workflows/validate-skills.yml diff --git a/docs/cli/intent-setup.md b/docs/cli/intent-setup.md index 7906843..72edc87 100644 --- a/docs/cli/intent-setup.md +++ b/docs/cli/intent-setup.md @@ -24,7 +24,7 @@ npx @tanstack/intent@latest setup - Ensures `files` includes required publish entries - Preserves existing indentation - `setup` - - Copies templates from `@tanstack/intent/meta/templates/workflows` to `.github/workflows` + - Copies the `check-skills.yml` workflow template from `@tanstack/intent/meta/templates/workflows` to `.github/workflows` - Applies variable substitution (`PACKAGE_NAME`, `PACKAGE_LABEL`, `PAYLOAD_PACKAGE`, `REPO`, `DOCS_PATH`, `SRC_PATH`, `WATCH_PATHS`) - Detects the workspace root in monorepos and writes repo-level workflows there - Skips files that already exist at destination @@ -44,7 +44,9 @@ npx @tanstack/intent@latest setup ## Notes - `setup` skips existing files +- `check-skills.yml` validates skills on PRs and opens review PRs from release/manual runs - To adopt updated workflow templates, delete or move the old generated workflow files first, then rerun `setup` +- If your repo has an older generated `validate-skills.yml`, remove it after adopting the current `check-skills.yml`; PR validation now lives in `check-skills.yml` - In monorepos, run `setup` from either the repo root or a package directory; Intent writes workflows to the workspace root ## Related diff --git a/docs/getting-started/quick-start-maintainers.md b/docs/getting-started/quick-start-maintainers.md index c363986..f5879f8 100644 --- a/docs/getting-started/quick-start-maintainers.md +++ b/docs/getting-started/quick-start-maintainers.md @@ -101,7 +101,7 @@ Run these commands to prepare your package for skill publishing: # Update package.json with required fields npx @tanstack/intent@latest edit-package-json -# Copy CI workflow templates (validate + stale checks) +# Copy the CI workflow template npx @tanstack/intent@latest setup ``` @@ -112,10 +112,12 @@ npx @tanstack/intent@latest setup - `files` array entries for `skills/` - For single packages: also adds `!skills/_artifacts` to exclude artifacts from npm - For monorepos: skips the artifacts exclusion (artifacts live at repo root) -- `setup` copies workflow templates to `.github/workflows/` for automated validation and staleness checking +- `setup` copies `check-skills.yml` to `.github/workflows/` for automated validation and staleness checking `setup` does not overwrite existing workflow files. To pick up newer generated workflows, delete or move the old generated files in `.github/workflows/`, then rerun `npx @tanstack/intent@latest setup`. +If your repo already has an older generated `validate-skills.yml`, remove it after adopting the current `check-skills.yml`; PR validation now runs from `check-skills.yml`. + ### 5. Ship skills with your package Skills ship inside your npm package. When you publish: @@ -135,18 +137,16 @@ Consumers who install your library automatically get the skills. They discover l ## Ongoing Maintenance (Manual or Agent-Assisted) -### 6. Set up CI workflows - -After running `setup`, you'll have two workflows in `.github/workflows/`: - -**validate-skills.yml** (runs on PRs touching `skills/`) -- Validates SKILL.md frontmatter and structure -- Ensures files stay under 500 lines -- Runs automatically on every pull request that modifies skills - -**check-skills.yml** (runs on release or manual trigger) +### 6. Set up the CI workflow + +After running `setup`, you'll have `check-skills.yml` in `.github/workflows/`: + +**check-skills.yml** (runs on PRs touching skills/artifacts, release, or manual trigger) +- Validates SKILL.md frontmatter and structure +- Ensures files stay under 500 lines - Automatically detects stale skills and coverage gaps after you publish a new release - Opens one grouped review PR with an agent-friendly prompt +- Includes the reason each skill or package was flagged - Requires you to copy the prompt into Claude Code, Cursor, or your agent to update skills ### 7. Update stale skills diff --git a/packages/intent/meta/templates/workflows/check-skills.yml b/packages/intent/meta/templates/workflows/check-skills.yml index 55a9516..43b09ad 100644 --- a/packages/intent/meta/templates/workflows/check-skills.yml +++ b/packages/intent/meta/templates/workflows/check-skills.yml @@ -1,11 +1,13 @@ # check-skills.yml — Drop this into your library repo's .github/workflows/ # -# Checks intent skills after a release and opens or updates one review PR when -# existing skills, artifact coverage, or workspace package coverage need review. +# Validates intent skills on PRs. After a release or manual run, opens or +# updates one review PR when existing skills, artifact coverage, or workspace +# package coverage need review. # -# Triggers: new release published, or manual workflow_dispatch. +# Triggers: pull requests touching skills/artifacts, new release published, or +# manual workflow_dispatch. # -# intent-workflow-version: 2 +# intent-workflow-version: 3 # # Template variables (replaced by `intent setup`): # {{PACKAGE_LABEL}} — e.g. @tanstack/query or my-workspace workspace @@ -13,6 +15,12 @@ name: Check Skills on: + pull_request: + paths: + - 'skills/**' + - '**/skills/**' + - '_artifacts/**' + - '**/_artifacts/**' release: types: [published] workflow_dispatch: {} @@ -22,8 +30,28 @@ permissions: pull-requests: write jobs: - check: + validate: + name: Validate intent skills + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install intent + run: npm install -g @tanstack/intent + + - name: Validate skills + run: intent validate --github-summary + + review: name: Check intent skill coverage + if: github.event_name != 'pull_request' runs-on: ubuntu-latest steps: - name: Checkout @@ -42,181 +70,7 @@ jobs: - name: Check skills id: stale run: | - set +e - intent stale --json > stale.json - STATUS=$? - set -e - - cat stale.json - - if [ "$STATUS" -ne 0 ]; then - echo "has_review=true" >> "$GITHUB_OUTPUT" - echo "check_failed=true" >> "$GITHUB_OUTPUT" - cat > review-items.json <<'JSON' - [ - { - "type": "stale-check-failed", - "library": "{{PACKAGE_LABEL}}", - "subject": "intent stale --json", - "reasons": ["The stale check command failed. Review the workflow logs before updating skills."] - } - ] - JSON - else - node <<'NODE' - const fs = require('fs') - const reports = JSON.parse(fs.readFileSync('stale.json', 'utf8')) - const items = [] - - for (const report of reports) { - for (const skill of report.skills ?? []) { - if (!skill?.needsReview) continue - items.push({ - type: 'stale-skill', - library: report.library, - subject: skill.name, - reasons: skill.reasons ?? [], - }) - } - - for (const signal of report.signals ?? []) { - if (signal?.needsReview === false) continue - items.push({ - type: signal?.type ?? 'review-signal', - library: signal?.library ?? report.library, - subject: - signal?.packageName ?? - signal?.packageRoot ?? - signal?.skill ?? - signal?.artifactPath ?? - signal?.subject ?? - report.library, - reasons: signal?.reasons ?? [], - artifactPath: signal?.artifactPath, - packageName: signal?.packageName, - packageRoot: signal?.packageRoot, - skill: signal?.skill, - }) - } - } - - fs.writeFileSync('review-items.json', JSON.stringify(items, null, 2) + '\n') - fs.appendFileSync( - process.env.GITHUB_OUTPUT, - `has_review=${items.length > 0 ? 'true' : 'false'}\n`, - ) - NODE - fi - - { - echo "review_items<> "$GITHUB_OUTPUT" - - - name: Write clean summary - if: steps.stale.outputs.has_review == 'false' - run: | - { - echo "### Intent skill review" - echo "" - echo "No stale skills or coverage gaps found." - } >> "$GITHUB_STEP_SUMMARY" - - - name: Build review PR body - if: steps.stale.outputs.has_review == 'true' - run: | - node <<'NODE' - const fs = require('fs') - const items = JSON.parse(fs.readFileSync('review-items.json', 'utf8')) - const grouped = new Map() - - for (const item of items) { - grouped.set(item.type, (grouped.get(item.type) ?? 0) + 1) - } - - const signalRows = [...grouped.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([type, count]) => `| \`${type}\` | ${count} |`) - - const itemRows = items.map((item) => { - const subject = item.subject ? `\`${item.subject}\`` : '-' - const reasons = item.reasons?.length ? item.reasons.join('; ') : '-' - return `| \`${item.type}\` | ${subject} | \`${item.library}\` | ${reasons} |` - }) - - const prompt = [ - 'You are helping maintain Intent skills for this repository.', - '', - 'Goal:', - 'Resolve the Intent skill review signals below while preserving the existing scope, taxonomy, and maintainer-reviewed artifacts.', - '', - 'Review signals:', - JSON.stringify(items, null, 2), - '', - 'Required workflow:', - '1. Read the existing `_artifacts/*domain_map.yaml`, `_artifacts/*skill_tree.yaml`, and generated `skills/**/SKILL.md` files.', - '2. Read each flagged package package.json, public exports, README/docs if present, and source entry points.', - '3. Compare flagged packages against the existing domains, skills, tasks, packages, covers, sources, tensions, and cross-references in the artifacts.', - '4. For each signal, decide whether it means existing skill coverage, a missing generated skill, a new skill candidate, out-of-scope coverage, or deferred work.', - '', - 'Maintainer questions:', - 'Before editing skills or artifacts, ask the maintainer:', - '1. For each flagged package, is this package user-facing enough to need agent guidance?', - '2. If yes, should it extend an existing skill or become a new skill?', - '3. If it extends an existing skill, which current skill should own it?', - '4. If it is out of scope, what short reason should be recorded in artifact coverage ignores?', - '5. Are any of these packages experimental or unstable enough to exclude for now?', - '', - 'Decision rules:', - '- Do not auto-generate skills.', - '- Do not create broad new skill areas without maintainer confirmation.', - '- Prefer adding package coverage to an existing skill when the package is an implementation variant of an existing domain.', - '- Create a new skill only when the package introduces a distinct developer task or failure mode.', - '- Preserve current naming, path, and package layout conventions.', - '- Keep generated skills under the package-local `skills/` directory.', - '- Keep repo-root `_artifacts` as the reviewed plan.', - '', - 'If maintainer confirms updates:', - '1. Update the relevant `_artifacts/*domain_map.yaml` or `_artifacts/*skill_tree.yaml`.', - '2. Update or create `SKILL.md` files only for confirmed coverage changes.', - '3. Keep `sources` aligned between artifact skill entries and SKILL frontmatter.', - '4. Bump `library_version` only for skills whose covered source package version changed.', - '5. Run `npx @tanstack/intent@latest validate` on touched skill directories.', - '6. Summarize every package as one of: existing-skill coverage, new skill, ignored, or deferred.', - ].join('\n') - - const body = [ - '## Intent Skill Review Needed', - '', - 'Intent found skills, artifact coverage, or workspace package coverage that need maintainer review.', - '', - '### Summary', - '', - '| Signal | Count |', - '| --- | ---: |', - ...signalRows, - '', - '### Review Items', - '', - '| Signal | Subject | Library | Reason |', - '| --- | --- | --- | --- |', - ...itemRows, - '', - '### Agent Prompt', - '', - 'Paste this into your coding agent:', - '', - '```text', - prompt, - '```', - '', - 'This PR is a review reminder only. It does not update skills automatically.', - ].join('\n') - - fs.writeFileSync('pr-body.md', body + '\n') - fs.writeFileSync(process.env.GITHUB_STEP_SUMMARY, body + '\n') - NODE + intent stale --github-review --package-label "{{PACKAGE_LABEL}}" - name: Open or update review PR if: steps.stale.outputs.has_review == 'true' diff --git a/packages/intent/meta/templates/workflows/validate-skills.yml b/packages/intent/meta/templates/workflows/validate-skills.yml deleted file mode 100644 index 8e95d93..0000000 --- a/packages/intent/meta/templates/workflows/validate-skills.yml +++ /dev/null @@ -1,53 +0,0 @@ -# validate-skills.yml — Drop this into your library repo's .github/workflows/ -# -# Validates skill files on PRs that touch any skills/ directory. -# Ensures frontmatter is correct, names match paths, and files stay under -# the 500-line limit. - -name: Validate Skills - -on: - pull_request: - paths: - - 'skills/**' - - '**/skills/**' - -jobs: - validate: - name: Validate skill files - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Install intent CLI - run: npm install -g @tanstack/intent - - - name: Find and validate skills - run: | - found_file="$(mktemp)" - - find . \ - \( -path './.git' -o -path './node_modules' \) -prune -o \ - -type d -name skills -print | - while IFS= read -r dir; do - if ! find "$dir" -name SKILL.md -print -quit | grep -q .; then - continue - fi - - dir="${dir#./}" - echo "Validating $dir..." - intent validate "$dir" - echo "1" > "$found_file" - done - - if [ ! -s "$found_file" ]; then - echo "No skills/ directory found — skipping validation." - fi - - rm -f "$found_file" diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/cli-support.ts index 559a3fa..7b5e962 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/cli-support.ts @@ -17,7 +17,7 @@ export interface StaleTargetResult { workflowAdvisories: Array } -export const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 2 +export const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 3 export function getMetaDir(): string { const thisDir = dirname(fileURLToPath(import.meta.url)) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 2ea6710..bbf0f77 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -22,6 +22,8 @@ import type { CAC } from 'cac' import type { InstallCommandOptions } from './commands/install.js' import type { ListCommandOptions } from './commands/list.js' import type { LoadCommandOptions } from './commands/load.js' +import type { StaleCommandOptions } from './commands/stale.js' +import type { ValidateCommandOptions } from './commands/validate.js' function createCli(): CAC { const cli = cac('intent') @@ -67,11 +69,12 @@ function createCli(): CAC { cli .command('validate [dir]', 'Validate skill files') - .usage('validate [dir]') + .usage('validate [dir] [--github-summary]') + .option('--github-summary', 'Write a GitHub Actions step summary') .example('validate') .example('validate packages/query/skills') - .action(async (dir?: string) => { - await runValidateCommand(dir) + .action(async (dir: string | undefined, options: ValidateCommandOptions) => { + await runValidateCommand(dir, options) }) cli @@ -111,16 +114,16 @@ function createCli(): CAC { 'stale [dir]', 'Check skills for staleness in the current package or workspace', ) - .usage('stale [dir] [--json]') + .usage('stale [dir] [--json] [--github-review]') .option('--json', 'Output JSON') + .option('--github-review', 'Write GitHub Actions review PR files') + .option('--package-label