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
7 changes: 7 additions & 0 deletions .changeset/maintainer-first-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/intent': patch
---

Stop reporting files Intent writes as unmapped source changes. Agent instruction files, generated plugin metadata, the CI workflow, package manifests, and lockfiles no longer need a recorded review unless a skill maps them; `review.ignore` in `skill_tree.yaml` adds repository-specific patterns.

Register a skill with the workspace package that owns the current directory when `maintainer add` runs without `--package`. Reject a review record that annotates no outcomes and explain the required fields. Accept per-skill `files` entries written by `maintainer sync` during validation. Advise the current CI workflow version.
29 changes: 15 additions & 14 deletions packages/intent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,14 @@ npx @tanstack/intent@latest load @tanstack/query#fetching

### For library maintainers

Generate skills for your library by telling your AI coding agent to run:
Set up the maintainer workflow, then register each skill beside the package that owns it:

```bash
npx @tanstack/intent@latest scaffold
npx @tanstack/intent@latest maintainer setup
npx @tanstack/intent@latest maintainer add caching --domain queries --description "Use when caching queries." --source "src/**"
```

This walks the agent through domain discovery, skill tree generation, and skill creation β€” one step at a time with your review at each stage.
Your coding agent authors the guidance with `intent meta generate-skill`. `maintainer status`, `sync`, `review`, and `check` keep the planning records, package metadata, and source reviews consistent.

Validate your skill files:

Expand Down Expand Up @@ -119,17 +120,17 @@ The real risk with any derived artifact is staleness. `npx @tanstack/intent@late

## CLI Commands

| Command | Description |
| -------------------------------------------------- | --------------------------------------------------- |
| `npx @tanstack/intent@latest install` | Set up skill loading guidance in agent config files |
| `npx @tanstack/intent@latest hooks install` | Install hook enforcement for supported agents |
| `npx @tanstack/intent@latest list [--json]` | Discover local intent-enabled packages |
| `npx @tanstack/intent@latest load <use>` | Load `<package>#<skill>` SKILL.md content |
| `npx @tanstack/intent@latest meta` | List meta-skills for library maintainers |
| `npx @tanstack/intent@latest scaffold` | Print the guided skill generation prompt |
| `npx @tanstack/intent@latest validate [dir]` | Validate SKILL.md files |
| `npx @tanstack/intent@latest setup` | Copy CI templates into your repo |
| `npx @tanstack/intent@latest stale [dir] [--json]` | Check skills for version drift |
| Command | Description |
| -------------------------------------------------- | ----------------------------------------------------- |
| `npx @tanstack/intent@latest install` | Set up skill loading guidance in agent config files |
| `npx @tanstack/intent@latest hooks install` | Install hook enforcement for supported agents |
| `npx @tanstack/intent@latest list [--json]` | Discover local intent-enabled packages |
| `npx @tanstack/intent@latest load <use>` | Load `<package>#<skill>` SKILL.md content |
| `npx @tanstack/intent@latest meta` | List meta-skills for library maintainers |
| `npx @tanstack/intent@latest maintainer <action>` | Set up, author, synchronize, review, and check skills |
| `npx @tanstack/intent@latest validate [dir]` | Validate SKILL.md files |
| `npx @tanstack/intent@latest setup` | Copy CI templates into your repo |
| `npx @tanstack/intent@latest stale [dir] [--json]` | Check skills for version drift |

## License

Expand Down
23 changes: 21 additions & 2 deletions packages/intent/src/commands/maintainer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import { isCI } from 'std-env'
import { resolveProjectContext } from '../core/project-context.js'
import { fail } from '../shared/cli-error.js'
import {
readRecord,
Expand Down Expand Up @@ -50,6 +51,21 @@ export interface MaintainerCommandOptions extends DistributionOptions {
interactive?: boolean
}

// An explicit --package is repository-relative. Without one, a command run from
// inside a workspace member registers the skill with that member instead of
// silently placing it at the repository root.
function inferOwningPackage(
root: string,
explicit: string | undefined,
): string | undefined {
if (explicit !== undefined) return explicit
const { packageRoot } = resolveProjectContext({ cwd: process.cwd() })
if (!packageRoot || packageRoot === root) return undefined
const owner = relative(root, packageRoot).replaceAll('\\', '/')
if (!owner || owner.startsWith('..')) return undefined
return owner
}

export async function runMaintainerCommand(
action: string,
name: string | undefined,
Expand Down Expand Up @@ -195,7 +211,10 @@ export async function runMaintainerCommand(
`Repository distribution: ${distribution?.mode ?? 'unconfigured'}. Run maintainer sync after authoring to update export metadata.`,
)
} else if (action === 'add') {
console.log(`Registered ${addSkill(project, name, options)}.`)
const owner = inferOwningPackage(project.root, options.package)
console.log(
`Registered ${addSkill(project, name, { ...options, package: owner })}.`,
)
console.log(
'Next: author the skill and its task coverage, then run intent maintainer sync, maintainer review, and maintainer check.',
)
Expand Down Expand Up @@ -245,7 +264,7 @@ export async function runMaintainerCommand(
await runValidateCommand(dir)
if (plan.problems.length || plan.changes.length || review.items.length)
fail(
'Maintainer check failed. Resolve the authoring issues, run maintainer sync, and record review outcomes with maintainer review --record <report.json>.',
'Maintainer check failed. Resolve the authoring issues, run intent maintainer sync, and record review outcomes with intent maintainer review --interactive, or annotate a --json report and pass it to --record <report.json>.',
)
console.log(
'Maintainer checks passed. Recorded conclusions still depend on the supplied review evidence.',
Expand Down
27 changes: 23 additions & 4 deletions packages/intent/src/commands/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,29 @@ export function runReviewCommand(
throw new Error(
'--record cannot be combined with --base, --json or --check.',
)
const count = recordReview(
cwd,
JSON.parse(readFileSync(resolve(options.record), 'utf8')),
const input: unknown = JSON.parse(
readFileSync(resolve(options.record), 'utf8'),
)
if (
typeof input === 'object' &&
input !== null &&
Array.isArray((input as { items?: unknown }).items)
) {
const items = (input as { items: Array<unknown> }).items
const annotated = items.filter(
(item) =>
typeof item === 'object' &&
item !== null &&
'outcome' in item &&
item.outcome !== undefined &&
item.outcome !== 'unresolved',
)
if (items.length > 0 && annotated.length === 0)
throw new Error(
`${options.record} annotates none of its ${items.length} review item(s). Set outcome (updated, no-change, or out-of-scope), reason, and a non-empty evidence array on each completed item, or use intent maintainer review --interactive in a terminal.`,
)
}
const count = recordReview(cwd, input)
console.log(`Recorded ${count} review outcome(s).`)
return
}
Expand Down Expand Up @@ -80,7 +99,7 @@ export function runReviewCommand(
console.log(' Use --json for all review items.')
if (report.items.length)
console.log(
'Next: run intent meta generate-skill in your coding agent. Review the evidence, run task checks, and record justified outcomes with intent review --record <report.json>.',
'Next: run intent meta generate-skill in your coding agent. Review the evidence, run task checks, and record justified outcomes with intent maintainer review --interactive, or annotate intent review --json output and pass it to intent review --record <report.json>.',
)
else
console.log(
Expand Down
2 changes: 1 addition & 1 deletion packages/intent/src/commands/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export interface StaleTargetResult {
workflowAdvisories: Array<string>
}

export const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 4
export const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 5

export function getMetaDir(): string {
return findMetaDir(dirname(fileURLToPath(import.meta.url)))
Expand Down
40 changes: 33 additions & 7 deletions packages/intent/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,16 @@ function buildValidationFailure(
return lines.join('\n')
}

function collectPackagingWarnings(context: ProjectContext): Array<string> {
function filesEntryCovers(entry: string, directory: string): boolean {
if (entry.startsWith('!')) return false
const prefix = entry.replace(/\/(?:\*\*|\*)?$/, '')
return directory === prefix || directory.startsWith(`${prefix}/`)
}

function collectPackagingWarnings(
context: ProjectContext,
skillFiles: ReadonlyArray<string>,
): Array<string> {
if (!context.packageRoot || !context.targetPackageJsonPath) return []

const pkgJsonPath = context.targetPackageJsonPath
Expand Down Expand Up @@ -134,15 +143,32 @@ function collectPackagingWarnings(context: ProjectContext): Array<string> {

const files = pkgJson.files as Array<string> | undefined
if (Array.isArray(files)) {
if (!files.includes('skills')) {
warnings.push(
'"skills" is not in the "files" array β€” skills won\'t be published',
)
const packageRoot = context.packageRoot
const skillDirs = [
...new Set(
skillFiles.map((file) =>
relative(packageRoot, dirname(file)).replaceAll('\\', '/'),
),
),
]
// Either the whole skills directory or each skill directory (as written
// by `intent maintainer sync`) publishes the guidance.
for (const directory of skillDirs) {
if (!files.some((entry) => filesEntryCovers(entry, directory))) {
warnings.push(
`"${directory}" is not covered by the "files" array β€” this skill won't be published`,
)
}
}

// In monorepos, _artifacts lives at repo root, not under packages β€”
// the negation pattern is a no-op and shouldn't be added.
if (!context.isMonorepo && !files.includes('!skills/_artifacts')) {
if (
!context.isMonorepo &&
existsSync(join(packageRoot, 'skills', '_artifacts')) &&
files.some((entry) => filesEntryCovers(entry, 'skills/_artifacts')) &&
!files.includes('!skills/_artifacts')
) {
warnings.push(
'"!skills/_artifacts" is not in the "files" array β€” artifacts will be published unnecessarily',
)
Expand Down Expand Up @@ -625,7 +651,7 @@ async function runValidateCommandInternal(
}

validatedCount += skillFiles.length
warnings.push(...collectPackagingWarnings(validateContext))
warnings.push(...collectPackagingWarnings(validateContext, skillFiles))
}

if (options.check) {
Expand Down
70 changes: 58 additions & 12 deletions packages/intent/src/review/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,26 @@ interface ReviewState {

const statePath = '.intent/review-state.json'
const dependencyExclude = ':(top,exclude,glob)**/node_modules/**'
// Files Intent writes or that never carry library guidance. Skills that map
// one of these paths in `sources` still track it; the list only stops the
// paths from surfacing as unmapped changes.
const defaultReviewIgnore = [
'.intent/**',
'AGENTS.md',
'CLAUDE.md',
'.cursorrules',
'.github/copilot-instructions.md',
'.github/workflows/check-skills.yml',
'.claude-plugin/**',
'.cursor-plugin/**',
'**/package.json',
'pnpm-lock.yaml',
'package-lock.json',
'npm-shrinkwrap.json',
'yarn.lock',
'bun.lock',
'bun.lockb',
]
const digest = (value: string | Buffer) =>
createHash('sha256').update(value).digest('hex')
const sorted = (values: Iterable<string>) => [...new Set(values)].sort()
Expand Down Expand Up @@ -277,6 +297,10 @@ function sourcePattern(
} else if (packageDir) {
path = `${packageDir}/${source}`
}
return globPattern(path, source, 'source')
}

function globPattern(path: string, label: string, kind: string): string {
if (
!path ||
path.startsWith('/') ||
Expand All @@ -285,16 +309,33 @@ function sourcePattern(
path.includes(':') ||
path.split('/').some((part) => part === '..' || part === '.' || part === '')
) {
throw new Error(`Unsupported source path: ${source}`)
throw new Error(`Unsupported ${kind} path: ${label}`)
}
// Git owns glob matching; braces and extglobs are not Git pathspec syntax.
if (/[{}]/.test(path) || /[!+@?*]\(/.test(path))
throw new Error(
`Unsupported source glob: ${source}. Use Git glob syntax (*, ?, [], **).`,
`Unsupported ${kind} glob: ${label}. Use Git glob syntax (*, ?, [], **).`,
)
return `:(top,glob)${path}`
}

function reviewIgnorePatterns(tree: Record<string, unknown>, path: string) {
if (tree.review === undefined) return []
const ignore = isObject(tree.review) ? tree.review.ignore : undefined
if (
!isObject(tree.review) ||
(ignore !== undefined &&
(!Array.isArray(ignore) ||
ignore.some((entry) => typeof entry !== 'string' || !entry.trim())))
)
throw new Error(
`Invalid review.ignore in ${path}: expected an array of Git glob patterns.`,
)
return ((ignore ?? []) as Array<string>).map((pattern) =>
globPattern(pattern, pattern, 'review.ignore'),
)
}

export function createReview(cwd: string, baseRef?: string): ReviewReport {
let root: string
try {
Expand Down Expand Up @@ -428,15 +469,21 @@ export function createReview(cwd: string, baseRef?: string): ReviewReport {
.map((dir) => dirname(dir))
.filter((dir) => dir !== '.' && !files.includes(`${dir}/package.json`))
const declaredSkills = new Set<string>()
const ignorePatterns = defaultReviewIgnore.map((pattern) =>
globPattern(pattern, pattern, 'review.ignore'),
)
for (const dir of existingArtifactDirs) {
const treePath = join(dir, 'skill_tree.yaml').replaceAll('\\', '/')
let tree: unknown
try {
const tree: unknown = parseYaml(
readFileSync(
safePath(root, join(dir, 'skill_tree.yaml').replaceAll('\\', '/')),
'utf8',
),
)
if (!isObject(tree) || !Array.isArray(tree.skills)) continue
tree = parseYaml(readFileSync(safePath(root, treePath), 'utf8'))
} catch {
// Missing or invalid trees remain unresolved in planning validation below.
continue
}
if (!isObject(tree)) continue
ignorePatterns.push(...reviewIgnorePatterns(tree, treePath))
if (Array.isArray(tree.skills)) {
for (const entry of tree.skills) {
if (!isObject(entry) || typeof entry.path !== 'string') continue
declaredSkills.add(
Expand All @@ -445,10 +492,9 @@ export function createReview(cwd: string, baseRef?: string): ReviewReport {
: entry.path,
)
}
} catch {
// Missing or invalid trees remain unresolved in planning validation below.
}
}
const ignored = new Set([...list(ignorePatterns), ...diff(ignorePatterns)])
const skillFiles = files.filter(
(path) =>
basename(path) === 'SKILL.md' &&
Expand Down Expand Up @@ -589,7 +635,7 @@ export function createReview(cwd: string, baseRef?: string): ReviewReport {
}
}
for (const path of changed) {
if (covered.has(path) || path.startsWith('.intent/')) continue
if (covered.has(path) || ignored.has(path)) continue
add('source', path, [path], [])
}
for (const id of Object.keys(state?.items ?? {})) {
Expand Down
Loading
Loading