diff --git a/.changeset/icy-loops-act.md b/.changeset/icy-loops-act.md new file mode 100644 index 0000000..30d8324 --- /dev/null +++ b/.changeset/icy-loops-act.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Fix maintainer review discovering skills inside hidden agent directories diff --git a/packages/intent/src/maintainer/existing.ts b/packages/intent/src/maintainer/existing.ts index 0434ca0..967e65d 100644 --- a/packages/intent/src/maintainer/existing.ts +++ b/packages/intent/src/maintainer/existing.ts @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs' import { basename, dirname, relative } from 'node:path' import { resolveProjectContext } from '../core/project-context.js' import { resolveWorkspacePackages } from '../setup/workspace-patterns.js' -import { parseFrontmatter } from '../shared/utils.js' +import { isDefaultSkillPath, parseFrontmatter } from '../shared/utils.js' import { stringList } from './add.js' import { isObject, @@ -76,9 +76,7 @@ export function findExistingSkills( (path) => basename(path) === 'SKILL.md' && /(^|\/)skills\//.test(path) && - !path - .split('/') - .some((part) => part.startsWith('.') || part === 'node_modules') && + isDefaultSkillPath(path) && !registered.has(path), ) .sort() diff --git a/packages/intent/src/review/review.ts b/packages/intent/src/review/review.ts index 77c9b0d..106413d 100644 --- a/packages/intent/src/review/review.ts +++ b/packages/intent/src/review/review.ts @@ -14,7 +14,7 @@ import { import { basename, dirname, join, relative, resolve } from 'node:path' import { parse as parseYaml } from 'yaml' import { resolveProjectContext } from '../core/project-context.js' -import { parseFrontmatter } from '../shared/utils.js' +import { isDefaultSkillPath, parseFrontmatter } from '../shared/utils.js' type Snapshot = Record type Outcome = 'updated' | 'no-change' | 'out-of-scope' | 'unresolved' @@ -565,7 +565,7 @@ export function createReview(cwd: string, baseRef?: string): ReviewReport { const skillFiles = files.filter( (path) => basename(path) === 'SKILL.md' && - (/(^|\/)skills\//.test(path) || + ((/(^|\/)skills\//.test(path) && isDefaultSkillPath(path)) || customRoots.some((dir) => path.startsWith(`${dir}/`)) || declaredSkills.has(path) || state?.items[`skill:${path}`]), diff --git a/packages/intent/src/shared/utils.ts b/packages/intent/src/shared/utils.ts index d998558..8e47165 100644 --- a/packages/intent/src/shared/utils.ts +++ b/packages/intent/src/shared/utils.ts @@ -58,6 +58,19 @@ export function toPosixPath(p: string): string { return p.split(sep).join('/') } +/** + * Check whether a skill path belongs to the default skill discovery tree. + * + * Hidden directories and node_modules are excluded from default discovery, + * while explicitly declared or custom-root skills can still be handled by + * their respective discovery paths. + */ +export function isDefaultSkillPath(path: string): boolean { + return !path + .split('/') + .some((part) => part.startsWith('.') || part === 'node_modules') +} + export interface FsIdentityResolver { (path: string): string /** diff --git a/packages/intent/tests/review.test.ts b/packages/intent/tests/review.test.ts index 7741909..bcf07b2 100644 --- a/packages/intent/tests/review.test.ts +++ b/packages/intent/tests/review.test.ts @@ -147,7 +147,71 @@ it('does not classify shipped meta skills or unrelated agent instructions as lib accept() expect(createReview(root).items).toEqual([]) }) +it('ignores hidden agent directories during default skill discovery', () => { + write( + '.claude/skills/deploy/SKILL.md', + '---\nname: deploy\ndescription: Deploy safely\n---\nDeploy guidance.\n', + ) + write( + '.cursor/skills/review/SKILL.md', + '---\nname: review\ndescription: Review safely\n---\nReview guidance.\n', + ) + write( + '.agents/skills/hidden/SKILL.md', + '---\nname: hidden\ndescription: Hidden guidance\n---\nHidden guidance.\n', + ) + + git('add', '.') + git('commit', '-qm', 'hidden agent skills') + expect(createReview(root).items.map((item) => item.id)).toEqual([ + 'skill:skills/request/SKILL.md', + ]) +}) +it('retains a hidden skill through review state without explicit declaration or custom root', () => { + const skillPath = '.agents/skills/hidden/SKILL.md' + + write( + skillPath, + '---\nname: hidden\nsources: [src/hidden.ts]\n---\nHidden guidance.\n', + ) + write('src/hidden.ts', 'export const hidden = true\n') + + planningRecords('_artifacts') + write( + '_artifacts/skill_tree.yaml', + `library: { name: library }\nskills: [{path: ${skillPath}}]\n`, + ) + + git('add', '.') + git('commit', '-qm', 'record hidden skill') + + const initial = createReview(root) + + expect(initial.items.map((item) => item.id)).toContain(`skill:${skillPath}`) + + expect( + initial.items.find((item) => item.id === `skill:${skillPath}`)?.problems, + ).toEqual([]) + + accept(initial) + + write( + '_artifacts/skill_tree.yaml', + 'library: { name: library }\nskills: []\n', + ) + + write('src/hidden.ts', 'export const hidden = false\n') + + const retained = createReview(root) + const retainedSkill = retained.items.find( + (item) => item.id === `skill:${skillPath}`, + ) + + expect(retainedSkill).toBeDefined() + expect(retainedSkill?.problems).toEqual([]) + expect(retainedSkill?.changedFiles).toContain('src/hidden.ts') +}) it('reviews a repository-root skill without including its own review state', () => { renameSync(join(root, 'skills/request/SKILL.md'), join(root, 'SKILL.md')) planningRecords('_artifacts')