From 0696d3fda29c416240c481fe5bafbfdbc4520836 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 2 Jul 2026 10:23:59 -0700 Subject: [PATCH 1/4] update SKILL.md to clarify usage of `intent stale` for staleness detection --- .../meta/skill-staleness-check/SKILL.md | 96 +++++++++---------- 1 file changed, 44 insertions(+), 52 deletions(-) diff --git a/packages/intent/meta/skill-staleness-check/SKILL.md b/packages/intent/meta/skill-staleness-check/SKILL.md index 0e36f6a2..6cc314c4 100644 --- a/packages/intent/meta/skill-staleness-check/SKILL.md +++ b/packages/intent/meta/skill-staleness-check/SKILL.md @@ -58,32 +58,28 @@ file. If no skills match, exit silently. -### Using sync-skills.mjs +### Using `intent stale` -The repo includes `scripts/sync-skills.mjs` for programmatic staleness -detection. For a given library: +There is no separate sync script — `intent stale [dir] [--json]` is the real, +existing staleness signal (see `docs/cli/intent-stale.md`). It is **read-only**: +it reports drift, it does not write anything. For a given library: ```bash -node scripts/sync-skills.mjs +intent stale packages/query --json ``` -This checks: +This reports: -- Source file SHA drift (compares stored SHAs in `sync-state.json` against - current remote SHAs via GitHub API) -- Library version drift (frontmatter `library_version` vs current published - version) -- Tree-generator changes (whether the meta skill has been updated since - last sync) +- Library version drift (`library_version` in a skill's frontmatter vs the + currently published version), classified `major`/`minor`/`patch`. +- Missing source SHAs recorded in that package's `skills/sync-state.json` + (a conservative signal — it flags gaps in the stored SHA record, not + actual remote content differences; see the doc's Notes section). -Use `--report` to write a structured `staleness_report.yaml`: - -```bash -node scripts/sync-skills.mjs --report -``` - -The report classifies skills as needing regeneration (source changed) or -version bump only. +`intent stale` does not classify "needs regeneration" vs "version bump only" +the way a dedicated sync tool might — that classification is this skill's own +job, done in Step 2 below using the actual source diff, not `intent stale`'s +output alone. --- @@ -130,12 +126,10 @@ For skills classified as needing content updates: For version bump only: -```bash -node scripts/sync-skills.mjs --bump-version -``` - -This updates `library_version` in all frontmatter for the library and -records the new version in `sync-state.json`. +There is no write-capable script for this. Edit `library_version` directly +in each affected skill's frontmatter, then update the recorded SHA(s) for +that library in its `skills/sync-state.json` (see Step 5) to reflect the new +synced state. --- @@ -158,19 +152,13 @@ second-order dependency are not automatically re-checked. ## Step 5 — Mark skills as synced -After updating, mark the affected skills as synced so future staleness -checks have a clean baseline: - -```bash -# Mark specific skills -node scripts/sync-skills.mjs --mark-synced - -# Mark all skills for a library -node scripts/sync-skills.mjs --mark-synced --all -``` - -This updates `sync-state.json` with current source file SHAs, the -tree-generator SHA, and the sync timestamp. +There is no write-capable script for this either. After updating a skill, +edit that library's `skills/sync-state.json` directly: update +`library_version` and the affected skill's `skills[skillName].sources_sha` +map with the new source file SHA(s) from the commit that triggered this run. +(That is the real current shape read by `intent stale` — it has no separate +tree-generator SHA or sync-timestamp field, despite what an earlier version +of this doc implied.) --- @@ -229,7 +217,8 @@ Exit silently (no PR, no notification, no issue) when ANY of these are true: - No changed files match any skill's `sources` - All matched diffs are classified as "no impact" in Step 2 -- The sync-skills.mjs report shows all skills are current +- `intent stale` reports all skills up-to-date (`All skills up-to-date`, per + `docs/cli/intent-stale.md`) --- @@ -237,7 +226,8 @@ Exit silently (no PR, no notification, no issue) when ANY of these are true: ### GitHub API usage -The `sync-skills.mjs` script uses the `gh` CLI for GitHub API access. It +This skill (not a separate script) uses the `gh` CLI directly for GitHub API +access — fetching source diffs in Step 2 and opening PRs in Step 6. It requires: - `gh` CLI installed and authenticated @@ -247,27 +237,29 @@ requires: ### Rate limiting -When checking multiple libraries or many source files, the script makes -one API call per source file per skill. For large batches, the GitHub API -rate limit (5000 requests/hour for authenticated users) may apply. The -script does not currently batch or cache API responses — if this becomes -an issue, add caching at the `getRemoteFileSha` level. +When checking multiple libraries or many source files, this makes one API +call per source file per skill during Step 2's diff fetch. For large +batches, the GitHub API rate limit (5000 requests/hour for authenticated +users) may apply. There is no batching or response caching in place — if +this becomes an issue, add caching around the diff-fetch step. ### Manual triggering -Maintainers can run staleness detection manually: +Maintainers can run the read-only staleness signal manually, outside the +webhook-triggered flow: ```bash # Check a specific library -node scripts/sync-skills.mjs db - -# Check and write a report -node scripts/sync-skills.mjs db --report +intent stale packages/db -# After reviewing and regenerating, mark as synced -node scripts/sync-skills.mjs db --mark-synced --all +# JSON output +intent stale packages/db --json ``` +This only reports drift (version drift, missing source SHAs) — it does not +update `sync-state.json` or open PRs; those remain Step 5 and Step 6 of this +skill's own workflow, done manually as described above. + --- ## Constraints From 0e4668fea73fbbd5f37c4af705cc8e842d89e3d4 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 2 Jul 2026 10:27:13 -0700 Subject: [PATCH 2/4] clarify skill path and name requirements in SKILL.md --- packages/intent/meta/generate-skill/SKILL.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/intent/meta/generate-skill/SKILL.md b/packages/intent/meta/generate-skill/SKILL.md index e0c5dd7c..8129fe82 100644 --- a/packages/intent/meta/generate-skill/SKILL.md +++ b/packages/intent/meta/generate-skill/SKILL.md @@ -51,10 +51,11 @@ below with their chosen directory. SKILL.md into that package's skills directory (e.g. `packages/client/skills/core/SKILL.md`), not a shared root. -1. **Skill name** — format `library-group/skill-name` (e.g. `tanstack-query/core`, +1. **Skill path** — format `library-group/skill-name` (e.g. `tanstack-query/core`, `tanstack-router/loaders`, `db/core/live-queries`). This is the skill's - directory path: its **last segment** becomes the frontmatter `name` (a - spec-legal leaf), and the full path is where the `SKILL.md` lives. + directory path, and it is where the `SKILL.md` lives. Only its **last + segment** becomes the frontmatter `name` (a spec-legal leaf): the full path + is never written into `name`, and `name` must contain no slashes. 2. **Skill description** — what the skill covers and when an agent should load it 3. **Source documentation** — the docs, guides, API references, and/or source files to distill from @@ -172,7 +173,7 @@ domain-discovery — use those directly. ```yaml --- -name: '[skill-name]' +name: '[leaf-segment]' # last path segment only — no slashes, must equal the parent dir (skills/tanstack-query/core/ → core) description: > [1–3 sentences. What this skill covers and exactly when an agent should load it. Written for the agent — include the keywords an agent would @@ -191,7 +192,7 @@ sources: ```yaml --- -name: '[skill-name]' +name: '[leaf-segment]' # last path segment only — no slashes, must equal the parent dir (skills/tanstack-query/loaders/ → loaders) description: > [1–3 sentences. What this sub-topic covers and when to load it.] metadata: @@ -207,7 +208,7 @@ sources: ```yaml --- -name: '[framework]' +name: '[framework]' # leaf segment (the framework name) — no slashes, must equal the parent dir (skills/tanstack-query/react/ → react) description: > [1–3 sentences. Framework-specific bindings. Name the hooks, components, providers.] @@ -375,7 +376,7 @@ Run every check before outputting. Fix any failures. | Common Mistakes are silent | Not obvious compile errors | | Common Mistakes are library-specific | Not generic TS/React mistakes | | Common Mistakes are sourced | Traceable to doc or source | -| `name` matches expected directory path | `db/core/live-queries` → `db/core/live-queries/SKILL.md` | +| `name` is the leaf, matches parent dir | `db/core/live-queries/SKILL.md` → name: `live-queries` (no slashes) | | `sources` filled for sub-skills | At least one Owner/repo:path | | Framework skills have `requires` | Lists core dependency | | Framework skills open with dependency note | First prose line references core | From 5ebd44a479211118da65d64bd276d3daa4b53e9c Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 2 Jul 2026 10:44:03 -0700 Subject: [PATCH 3/4] add --set-version option to validate command for updating library_version in skill metadata --- packages/intent/src/cli.ts | 9 +- packages/intent/src/commands/validate.ts | 95 +++++++++++- packages/intent/tests/cli.test.ts | 190 +++++++++++++++++++++++ 3 files changed, 289 insertions(+), 5 deletions(-) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index fe8fa003..d317b9d7 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -94,15 +94,22 @@ function createCli(): CAC { cli .command('validate [dir]', 'Validate skill files') - .usage('validate [dir] [--github-summary] [--fix] [--check]') + .usage( + 'validate [dir] [--github-summary] [--fix] [--check] [--set-version ]', + ) .option('--github-summary', 'Write a GitHub Actions step summary') .option('--fix', 'Rewrite fixable SKILL.md frontmatter issues') .option( '--check', 'Fail if fixable SKILL.md frontmatter issues would be rewritten', ) + .option( + '--set-version ', + 'Set metadata.library_version on matched skills, then validate', + ) .example('validate') .example('validate packages/query/skills') + .example('validate packages/query/skills --set-version 5.62.0') .action( async (dir: string | undefined, options: ValidateCommandOptions) => { const { runValidateCommand } = await import('./commands/validate.js') diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index 04f2c597..961f0d99 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -27,10 +27,16 @@ interface FrontmatterFixPlan { changes: Array } +interface SetVersionPlan { + file: string + filePath: string +} + export interface ValidateCommandOptions { check?: boolean fix?: boolean githubSummary?: boolean + setVersion?: string } const agentSkillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ @@ -247,6 +253,48 @@ async function applyFrontmatterFixes( } } +async function applySetVersion( + plans: Array, + version: string, +): Promise { + const { parseDocument } = await import('yaml') + + for (const plan of plans) { + const content = readFileSync(plan.filePath, 'utf8') + const match = content.match( + /^---(\r?\n)([\s\S]*?)(\r?\n)---(\r?\n?)([\s\S]*)/, + ) + if (!match) continue + + const openingLineEnding = match[1] + const frontmatter = match[2] + const closingLineEnding = match[3] + const afterClose = match[4] + const body = match[5] + if ( + openingLineEnding === undefined || + frontmatter === undefined || + closingLineEnding === undefined || + afterClose === undefined || + body === undefined + ) { + continue + } + + const doc = parseDocument(frontmatter) + if (doc.errors.length > 0) continue + + doc.setIn(['metadata', 'library_version'], version) + + const nextFrontmatter = normalizeLineEndings( + doc.toString().replace(/\r?\n$/, ''), + openingLineEnding, + ) + const nextContent = `---${openingLineEnding}${nextFrontmatter}${closingLineEnding}---${afterClose}${body}` + writeFileSync(plan.filePath, nextContent) + } +} + function collectAgentSkillSpecWarnings({ fm, rel, @@ -307,6 +355,18 @@ export async function runValidateCommand( fail('Cannot combine --fix and --check') } + if (options.setVersion !== undefined) { + if (options.check) { + fail('Cannot combine --set-version and --check') + } + if ( + typeof options.setVersion !== 'string' || + options.setVersion.trim().length === 0 + ) { + fail('--set-version requires a non-empty version value') + } + } + if (!options.githubSummary) { await runValidateCommandInternal(dir, options) return @@ -346,6 +406,7 @@ async function runValidateCommandInternal( const errors: Array = [] const warnings: Array = [] const fixPlans: Array = [] + const setVersionPlans: Array = [] let validatedCount = 0 if (explicitDir && findSkillFiles(skillsDirs[0]!).length === 0) { @@ -394,6 +455,16 @@ async function runValidateCommandInternal( const fixPlan = collectFrontmatterFixPlan({ filePath, fm, rel }) if (fixPlan) fixPlans.push(fixPlan) + // Only target files whose metadata is a mapping (or absent); a + // non-mapping metadata scalar is rejected by validation below, and + // setIn cannot safely graft a key onto it. + if (options.setVersion !== undefined) { + const meta = fm.metadata + if (meta === undefined || isRecord(meta)) { + setVersionPlans.push({ file: rel, filePath }) + } + } + if (!fm.name) { errors.push({ file: rel, message: 'Missing required field: name' }) } @@ -547,10 +618,26 @@ async function runValidateCommandInternal( } } - if (options.fix && fixPlans.length > 0) { - await applyFrontmatterFixes(fixPlans) - console.log(`✅ Fixed ${fixPlans.length} skill files`) - await runValidateCommandInternal(dir, { ...options, fix: false }) + const willSetVersion = + options.setVersion !== undefined && setVersionPlans.length > 0 + const willFix = options.fix === true && fixPlans.length > 0 + + if (willSetVersion || willFix) { + if (willSetVersion) { + await applySetVersion(setVersionPlans, options.setVersion!) + console.log( + `✅ Set library_version to "${options.setVersion}" on ${setVersionPlans.length} skill files`, + ) + } + if (willFix) { + await applyFrontmatterFixes(fixPlans) + console.log(`✅ Fixed ${fixPlans.length} skill files`) + } + await runValidateCommandInternal(dir, { + ...options, + fix: false, + setVersion: undefined, + }) return } diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index f940d1b7..aedcbe47 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -2279,6 +2279,196 @@ describe('cli commands', () => { expect(errorSpy).toHaveBeenCalledWith('Cannot combine --fix and --check') }) + it('sets metadata.library_version on a skill and re-validates', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-set-version-')) + tempDirs.push(root) + + const skillPath = join(root, 'skills', 'db-core', 'SKILL.md') + mkdirSync(dirname(skillPath), { recursive: true }) + writeFileSync( + skillPath, + [ + '---', + 'name: db-core', + 'description: Core database concepts', + 'metadata:', + ' type: core', + ' library: db', + ' library_version: 1.0.0', + '---', + '', + 'Skill content here.', + '', + ].join('\n'), + ) + + process.chdir(root) + + const exitCode = await main(['validate', '--set-version', '2.5.0']) + const output = logSpy.mock.calls.flat().join('\n') + const fixed = readFileSync(skillPath, 'utf8') + + expect(exitCode).toBe(0) + expect(output).toContain( + '✅ Set library_version to "2.5.0" on 1 skill files', + ) + expect(output).toContain('✅ Validated 1 skill files — all passed') + expect(fixed).toContain('library_version: 2.5.0') + expect(fixed).not.toContain('library_version: 1.0.0') + expect(fixed).toContain('\nSkill content here.\n') + }) + + it('adds metadata.library_version when the key is absent', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-set-version-add-')) + tempDirs.push(root) + + const skillPath = join(root, 'skills', 'db-core', 'SKILL.md') + mkdirSync(dirname(skillPath), { recursive: true }) + writeFileSync( + skillPath, + [ + '---', + 'name: db-core', + 'description: Core database concepts', + 'metadata:', + ' type: core', + ' library: db', + '---', + '', + 'Skill content here.', + '', + ].join('\n'), + ) + + process.chdir(root) + + const exitCode = await main(['validate', '--set-version', '3.0.0-beta.1']) + const fixed = readFileSync(skillPath, 'utf8') + + expect(exitCode).toBe(0) + expect(fixed).toContain('library_version: 3.0.0-beta.1') + }) + + it('is idempotent when the version already matches', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-set-version-idem-')) + tempDirs.push(root) + + const skillPath = join(root, 'skills', 'db-core', 'SKILL.md') + mkdirSync(dirname(skillPath), { recursive: true }) + writeFileSync( + skillPath, + [ + '---', + 'name: db-core', + 'description: Core database concepts', + 'metadata:', + ' library_version: 4.1.0', + '---', + '', + 'Skill content here.', + '', + ].join('\n'), + ) + + process.chdir(root) + + const firstExitCode = await main(['validate', '--set-version', '4.1.0']) + const afterFirst = readFileSync(skillPath, 'utf8') + const secondExitCode = await main(['validate', '--set-version', '4.1.0']) + + expect(firstExitCode).toBe(0) + expect(secondExitCode).toBe(0) + expect(readFileSync(skillPath, 'utf8')).toBe(afterFirst) + }) + + it('preserves CRLF line endings and body bytes when setting version', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-set-version-crlf-')) + tempDirs.push(root) + + const skillPath = join(root, 'skills', 'db-core', 'SKILL.md') + mkdirSync(dirname(skillPath), { recursive: true }) + const body = 'First body line.\r\n\r\nSecond body line.\r\n' + writeFileSync( + skillPath, + [ + '---', + 'name: db-core', + 'description: Core database concepts', + 'metadata:', + ' library_version: 1.0.0', + '---', + '', + ].join('\r\n') + body, + ) + + process.chdir(root) + + const exitCode = await main(['validate', '--set-version', '2.0.0']) + const fixed = readFileSync(skillPath, 'utf8') + const fixedBody = fixed.slice(fixed.indexOf(body)) + + expect(exitCode).toBe(0) + expect(fixed).toContain('library_version: 2.0.0\r\n') + expect(fixedBody).toBe(body) + }) + + it('fails cleanly when set-version and check are combined', async () => { + const exitCode = await main([ + 'validate', + '--set-version', + '2.0.0', + '--check', + ]) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Cannot combine --set-version and --check', + ) + }) + + it('fails when set-version is passed an empty value', async () => { + const exitCode = await main(['validate', '--set-version', ' ']) + + expect(exitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + '--set-version requires a non-empty version value', + ) + }) + + it('does not set version on a skill whose metadata is not a mapping', async () => { + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-set-version-non-map-'), + ) + tempDirs.push(root) + + const skillPath = join(root, 'skills', 'db-core', 'SKILL.md') + mkdirSync(dirname(skillPath), { recursive: true }) + writeFileSync( + skillPath, + [ + '---', + 'name: db-core', + 'description: Core database concepts', + 'metadata: nope', + '---', + '', + 'Skill content here.', + '', + ].join('\n'), + ) + + process.chdir(root) + + const exitCode = await main(['validate', '--set-version', '2.0.0']) + const output = errorSpy.mock.calls.flat().join('\n') + const after = readFileSync(skillPath, 'utf8') + + expect(exitCode).toBe(1) + expect(output).toContain('metadata must be a mapping') + expect(after).toContain('metadata: nope') + expect(after).not.toContain('library_version') + }) + it('fails when a non-spec scalar field is emitted at the top level', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-scalar-')) tempDirs.push(root) From c5af4f020862d302f1ed97495101bd7bc345efdf Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Thu, 2 Jul 2026 10:51:51 -0700 Subject: [PATCH 4/4] changeset --- .changeset/clean-months-change.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/clean-months-change.md diff --git a/.changeset/clean-months-change.md b/.changeset/clean-months-change.md new file mode 100644 index 00000000..d0262f93 --- /dev/null +++ b/.changeset/clean-months-change.md @@ -0,0 +1,8 @@ +--- +'@tanstack/intent': patch +--- + +Improve the maintainer skill-authoring workflow: + + - Add `validate --set-version `, which stamps `metadata.library_version` across matched skills via YAML surgery (preserving comments, line endings, and body) and then re-validates. + - Correct the `generate-skill` meta-skill so it no longer emits slash-path `name` values that fail validation, and the `skill-staleness-check` meta-skill so it points at the real `intent stale` command instead of a non-existent script.