Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/clean-months-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tanstack/intent': patch
---

Improve the maintainer skill-authoring workflow:

- Add `validate --set-version <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.
15 changes: 8 additions & 7 deletions packages/intent/meta/generate-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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.]
Expand Down Expand Up @@ -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 |
Expand Down
96 changes: 44 additions & 52 deletions packages/intent/meta/skill-staleness-check/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <library>
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 <library> --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.

---

Expand Down Expand Up @@ -130,12 +126,10 @@ For skills classified as needing content updates:

For version bump only:

```bash
node scripts/sync-skills.mjs <library> --bump-version <new-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.

---

Expand All @@ -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 <library> --mark-synced <skill1> <skill2>

# Mark all skills for a library
node scripts/sync-skills.mjs <library> --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.)

---

Expand Down Expand Up @@ -229,15 +217,17 @@ 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`)

---

## Operational notes

### 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
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion packages/intent/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <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')
Expand Down
95 changes: 91 additions & 4 deletions packages/intent/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,16 @@ interface FrontmatterFixPlan {
changes: Array<string>
}

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]+)*$/
Expand Down Expand Up @@ -247,6 +253,48 @@ async function applyFrontmatterFixes(
}
}

async function applySetVersion(
plans: Array<SetVersionPlan>,
version: string,
): Promise<void> {
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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -346,6 +406,7 @@ async function runValidateCommandInternal(
const errors: Array<ValidationError> = []
const warnings: Array<string> = []
const fixPlans: Array<FrontmatterFixPlan> = []
const setVersionPlans: Array<SetVersionPlan> = []
let validatedCount = 0

if (explicitDir && findSkillFiles(skillsDirs[0]!).length === 0) {
Expand Down Expand Up @@ -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' })
}
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading