Skip to content
Open
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/hot-bottles-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/intent': minor
---

Add `intent hooks install` for supported AI coding agents.

This adds lifecycle-hook installation for supported agents, including project/user scope handling, generated hook runner scripts, and agent-specific enforcement policy. It also documents the hook setup flow and adds eval/test coverage for hooked intent discovery.
48 changes: 48 additions & 0 deletions docs/cli/intent-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
title: intent hooks
id: intent-hooks
---

`intent hooks install` installs lifecycle hooks that enforce loading matching guidance before edits in supported agents.

```bash
npx @tanstack/intent@latest hooks install [--scope project|user] [--agents copilot,claude,codex|all]
```

## Options

- `--scope <scope>`: hook install scope, either `project` or `user`; defaults to `project`
- `--agents <agents>`: comma-separated hook agents to configure (`copilot`, `claude`, `codex`) or `all`; defaults to `all`

## Behavior

- Installs hook enforcement without writing an `intent-skills` guidance block.
- `--scope project` writes project-local hook config for agents that support it.
- `--scope user` writes user-level agent config and stores runner scripts under `~/.tanstack/intent/hooks`.
- `--agents all` is the default. In project scope, Copilot is skipped because the supported Copilot CLI hook location is user-scoped.
- Run `intent install` separately when you also want to write project guidance.

## Hook support

| Agent | Project scope | User scope | Enforcement |
| --- | --- | --- | --- |
| Claude Code | `.claude/settings.json` | `~/.claude/settings.json` | Blocks configured edit tools with `PreToolUse` |
| Codex | `.codex/hooks.json` | `~/.codex/hooks.json` | Blocks supported `Bash`, `apply_patch`, and MCP tool calls; Codex hook interception is not a complete security boundary |
| GitHub Copilot CLI | Guidance via `.github/copilot-instructions.md`; blocking hooks are not project-scoped | `$COPILOT_HOME/hooks/hooks.json` or `~/.copilot/hooks/hooks.json` | Blocks supported edit tools with `PreToolUse` |
| Cursor | Guidance only | Guidance only | Use `AGENTS.md` or Cursor rules; no blocking hook is installed |
| Generic `AGENTS.md` agents | Guidance only | Guidance only | Use the `intent-skills` guidance block; no blocking hook is installed |

`.github/copilot-instructions.md` is a supported project guidance target for `intent install`. GitHub Copilot CLI hook enforcement uses the user-scoped Copilot hooks directory because that is the supported hook location.

Codex requires users to review and trust non-managed hooks before they run. If Codex reports hooks awaiting review, open its hook browser and trust the generated Intent hook.

## Status messages

- Hook installed: `Installed Intent hooks for claude (project) in .claude/settings.json.`
- Hook skipped: `Skipped Intent hooks for copilot: project scope is not supported; use --scope user`

## Related

- [intent install](./intent-install)
- [intent list](./intent-list)
- [intent load](./intent-load)
33 changes: 21 additions & 12 deletions docs/cli/intent-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@ npx @tanstack/intent@latest install [--map] [--dry-run] [--print-prompt] [--glob

## Options

### Guidance output

- `--map`: write explicit task-to-skill mappings instead of lightweight loading guidance
- `--dry-run`: print the generated block without writing files
- `--print-prompt`: print the agent setup prompt instead of writing files

### Mapping scan scope

- `--global`: include global packages after project packages when `--map` is passed
- `--global-only`: install mappings from global packages only when `--map` is passed
- `--no-notices`: suppress non-critical notices on stderr
Expand All @@ -24,10 +29,10 @@ npx @tanstack/intent@latest install [--map] [--dry-run] [--print-prompt] [--glob
- Creates `AGENTS.md` when no managed block exists.
- Updates an existing managed block in a supported config file.
- Preserves all content outside the managed block.
- Scans packages and writes compact `when` and `use` mappings only when `--map` is passed.
- Scans packages and writes compact `id`, `run`, and `for` mappings only when `--map` is passed.
- Surfaces packages permitted by `package.json#intent.skills` in `--map` mode. See [Configuration](../concepts/configuration).
- Skips reference, meta, maintainer, and maintainer-only skills in `--map` mode.
- Writes compact `when` and `use` entries instead of load paths in `--map` mode.
- Writes compact skill identities and runnable guidance commands instead of local file paths in `--map` mode.
- Verifies the managed block before reporting success.
- Prints `No intent-enabled skills found.` and does not create a config file when `--map` finds no actionable skills.

Expand All @@ -41,29 +46,32 @@ The default block tells agents to discover skills and load matching guidance on
<!-- intent-skills:start -->
## Skill Loading

Before substantial work:
- Skill check: run `npx @tanstack/intent@latest list`, or use skills already listed in context.
- Skill guidance: if one local skill clearly matches the task, run `npx @tanstack/intent@latest load <package>#<skill>` and follow the returned `SKILL.md`.
Before editing files for a substantial task:
- Run `npx @tanstack/intent@latest list` from the workspace root to see available local skills.
- If a listed skill matches the task, run `npx @tanstack/intent@latest load <package>#<skill>` before changing files.
- Use the loaded `SKILL.md` guidance while making the change.
- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed.
- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns.
<!-- intent-skills:end -->
```

## Mapping output

`--map` writes compact skill identities:
`--map` writes compact skill identities and commands:

```yaml
<!-- intent-skills:start -->
# Skill mappings - load `use` with `npx @tanstack/intent@latest load <use>`.
skills:
- when: "Query data fetching patterns"
use: "@tanstack/query#fetching"
# TanStack Intent - before editing files, run the matching guidance command.
tanstackIntent:
- id: "@tanstack/query#fetching"
run: "npx @tanstack/intent@latest load @tanstack/query#fetching"
for: "Query data fetching patterns"
<!-- intent-skills:end -->
```

- `when`: task-routing phrase for agents
- `use`: portable skill identity in `<package>#<skill>` format
- `id`: portable skill identity in `<package>#<skill>` format
- `run`: package-manager-aware command agents should run before editing
- `for`: task-routing phrase for agents
- The block does not store `load` paths, absolute paths, or package-manager-internal paths

## Status messages
Expand All @@ -82,4 +90,5 @@ To suppress trust and migration notices in automation, pass `--no-notices`.

- [intent list](./intent-list)
- [intent load](./intent-load)
- [intent hooks](./intent-hooks)
- [Quick Start for Consumers](../getting-started/quick-start-consumers)
4 changes: 4 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
"label": "intent install",
"to": "cli/intent-install"
},
{
"label": "intent hooks",
"to": "cli/intent-hooks"
},
{
"label": "intent exclude",
"to": "cli/intent-exclude"
Expand Down
22 changes: 18 additions & 4 deletions docs/getting-started/quick-start-consumers.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,31 @@ Intent creates guidance like:
<!-- intent-skills:start -->
## Skill Loading

Before substantial work:
- Skill check: run `pnpm dlx @tanstack/intent@latest list`, or use skills already listed in context.
- Skill guidance: if one local skill clearly matches the task, run `pnpm dlx @tanstack/intent@latest load <package>#<skill>` and follow the returned `SKILL.md`.
Before editing files for a substantial task:
- Run `pnpm dlx @tanstack/intent@latest list` from the workspace root to see available local skills.
- If a listed skill matches the task, run `pnpm dlx @tanstack/intent@latest load <package>#<skill>` before changing files.
- Use the loaded `SKILL.md` guidance while making the change.
- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed.
- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns.
<!-- intent-skills:end -->
```

Intent detects the package manager when generating this block, so the runner may be `npx`, `pnpm dlx`, `yarn dlx`, or `bunx`.

To enforce loading guidance before edits in supported agents, opt in to hooks:

```bash
npx @tanstack/intent@latest hooks install
```

Project-scoped hooks are installed for Claude Code and Codex. `intent install` can write project guidance to `.github/copilot-instructions.md`, but GitHub Copilot CLI hook enforcement is user-scoped, so configure it explicitly:

```bash
npx @tanstack/intent@latest hooks install --scope user --agents copilot
```

Cursor and generic `AGENTS.md` agents use the guidance block only.

## 2. Choose which packages' skills to use

`package.json#intent.skills` is an allowlist of the packages whose skills you want surfaced.
Expand Down Expand Up @@ -106,4 +121,3 @@ You can also check if any skills reference outdated source documentation:
```bash
npx @tanstack/intent@latest stale
```

9 changes: 9 additions & 0 deletions docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ Skills are markdown documents that teach AI coding agents how to use your librar
Intent provides tooling for two workflows:

**For consumers:**

- Discover skills from your project and workspace dependencies
- Control which packages' skills are surfaced with an allowlist
- Add lightweight skill loading guidance to your agent config
- Add hook enforcement for agents that support blocking lifecycle hooks
- Keep skills synchronized with library versions

**For maintainers (library teams):**

- Scaffold skills through AI-assisted domain discovery
- Validate SKILL.md format and packaging
- Ship skills in the same release pipeline as code
Expand Down Expand Up @@ -50,6 +53,12 @@ npx @tanstack/intent@latest install

Creates or updates lightweight `intent-skills` guidance in your config files (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, etc.). Existing guidance is updated in place; otherwise `AGENTS.md` is the default target. Pass `--map` to opt in to explicit task-to-skill mappings.

```bash
npx @tanstack/intent@latest hooks install
```

Installs hook enforcement for supported agents. Project-scoped hooks are available for Claude Code and Codex. GitHub Copilot CLI project guidance can live in `.github/copilot-instructions.md`, while blocking hooks are user-scoped. Cursor and generic `AGENTS.md` agents use guidance only.

```bash
npx @tanstack/intent@latest load @tanstack/query#fetching
```
Expand Down
8 changes: 8 additions & 0 deletions evals/intent-discovery/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ pnpm eval:intent-discovery:report

Set `INTENT_DISCOVERY_RUN_COUNT=3` with the live commands to run each live condition three times and include `pass@k` / `pass^k` in the generated summary.

## Live eval speed

Only the live `copilot -p` subprocess runs are slow; the saved-transcript suite (`pnpm eval:intent-discovery`) is unaffected.

- `INTENT_DISCOVERY_LIVE_CONCURRENCY` bounds how many live runs execute at once (default `1`, clamped to an integer `>= 1`). Values above `1` measured slower here: concurrent `copilot -p` calls on one account contend upstream (a run with its own isolated `COPILOT_HOME` still slowed ~2x), so raise it only with separate accounts or dedicated infrastructure.
- `COPILOT_MODEL` selects the Copilot model end-to-end. The adapter passes the process environment through to `copilot -p`, and the CLI honors `COPILOT_MODEL`. `INTENT_DISCOVERY_COPILOT_MODEL` only sets the model label recorded in report metadata; it does not change the model the CLI runs.
- `INTENT_DISCOVERY_RUN_COUNT` stays `1` by default for iteration. Set it to `3` only when measuring `pass@k` / `pass^k`.

The optional LLM judge is secondary. It can annotate whether final answers appear to apply loaded guidance, but it never changes deterministic scores such as `StrictIntentInvocation`, `CorrectSkillLoaded`, or `AutonomousDiscoverySuccess`.

## Current scope
Expand Down
9 changes: 6 additions & 3 deletions evals/intent-discovery/condition-setup.eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe('Intent discovery condition setup', () => {
expect(result.filesWritten).toHaveLength(4)
expect(agents).toContain('Skill Loading')
expect(agents).toContain('npx @tanstack/intent@latest list')
expect(agents).not.toContain('\nskills:\n')
expect(agents).not.toContain('\ntanstackIntent:\n')
expect(packageJson).toContain('"@tanstack/router"')
expect(
existsSync(
Expand Down Expand Up @@ -81,8 +81,11 @@ describe('Intent discovery condition setup', () => {
'utf8',
)

expect(agents).toContain('skills:')
expect(agents).toContain('use: "@tanstack/router#routing"')
expect(agents).toContain('tanstackIntent:')
expect(agents).toContain('id: "@tanstack/router#routing"')
expect(agents).toContain(
'run: "npx @tanstack/intent@latest load @tanstack/router#routing"',
)
} finally {
prepared.cleanup()
}
Expand Down
4 changes: 4 additions & 0 deletions evals/intent-discovery/corpus/conditions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ const intentDiscoveryConditions = [
id: 'mapped-intent',
countsTowardAutonomousScore: true,
},
{
id: 'hooked-intent',
countsTowardAutonomousScore: true,
},
{
id: 'explicit-intent-control',
countsTowardAutonomousScore: false,
Expand Down
14 changes: 14 additions & 0 deletions evals/intent-discovery/corpus/live-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ export const liveTasks: Array<IntentDiscoveryTask> = [
failureClass: 'strict-success',
},
},
{
id: 'live-router-hooked-intent',
fixture: 'router-basic',
condition: 'hooked-intent',
explicitnessLevel: 2,
prompt: routerPrompt,
expectedSkillAreas: ['router'],
expected: {
strictInvocation: true,
correctSkillLoaded: true,
referenceOnly: false,
failureClass: 'strict-success',
},
},
{
id: 'live-router-explicit-intent-control',
fixture: 'router-basic',
Expand Down
39 changes: 39 additions & 0 deletions evals/intent-discovery/harness/intent-hooks/gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env node
import {
appendObservation,
readEventFromStdin,
readObservations,
} from './hook-io.mjs'
import {
gateDecision,
hasLoadFromObservations,
observationFromEvent,
} from './hook-core.mjs'

try {
const event = readEventFromStdin()
const observation = observationFromEvent(event)

if (observation) {
appendObservation(observation)
}

const toolName = event?.tool_name ?? event?.toolName
const decision = gateDecision({
toolName,
hasLoaded: hasLoadFromObservations(readObservations()),
})

if (decision.decision === 'deny') {
process.stdout.write(
JSON.stringify({
permissionDecision: 'deny',
permissionDecisionReason: decision.reason,
}),
)
}
} catch {
// Fail open: never block on hook error.
}

process.exit(0)
36 changes: 36 additions & 0 deletions evals/intent-discovery/harness/intent-hooks/hook-core.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export type IntentAction = 'list' | 'load'

export type IntentInvocation = {
action: IntentAction
skillUse?: string
}

export type IntentObservation = {
action: IntentAction
skillUse?: string
raw: string
}

export type GateDecision =
| { decision: 'allow' }
| { decision: 'deny'; reason: string }

export const EDIT_TOOLS: Set<string>
export const GATE_DENY_REASON: string

export function parseIntentInvocation(
command: unknown,
): IntentInvocation | undefined

export function observationFromEvent(
event: unknown,
): IntentObservation | undefined

export function gateDecision(input: {
toolName: unknown
hasLoaded: boolean
}): GateDecision

export function hasLoadFromObservations(
observations: Array<{ action?: string } | null | undefined>,
): boolean
Loading
Loading