Skip to content

Commit d67a5d6

Browse files
Add proactive Intent hook skill catalogs (#180)
1 parent 85f4b1d commit d67a5d6

6 files changed

Lines changed: 346 additions & 24 deletions

File tree

.changeset/mighty-friends-doubt.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@tanstack/intent': patch
3+
---
4+
5+
Add proactive skill catalogs to installed Intent hooks.
6+
7+
`intent hooks install` now installs session-start catalog hooks for supported agents alongside the existing edit gate. Agents see the allowed local Intent skills at session start, resume, clear, and compact where the agent supports those lifecycle events, then still need to run `intent load` before editing.
8+
9+
The generated hook loads the catalog through the Intent CLI with agent audience redaction instead of importing package code from the target repository.

docs/cli/intent-hooks.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ title: intent hooks
33
id: intent-hooks
44
---
55

6-
`intent hooks install` installs lifecycle hooks that enforce loading matching guidance before edits in supported agents.
6+
`intent hooks install` installs lifecycle hooks that surface available Intent skills and enforce loading matching guidance before edits in supported agents.
77

88
```bash
99
npx @tanstack/intent@latest hooks install [--scope project|user] [--agents copilot,claude,codex|all]
@@ -16,19 +16,22 @@ npx @tanstack/intent@latest hooks install [--scope project|user] [--agents copil
1616

1717
## Behavior
1818

19-
- Installs hook enforcement without writing an `intent-skills` guidance block.
19+
- Installs hook behavior without writing an `intent-skills` guidance block.
20+
- Adds a session-start skill catalog for supported agents so the agent sees available `skill-id: description` entries before it starts work.
21+
- Keeps edit enforcement in place: supported edit tools are blocked until the agent runs `intent load <skill-id>` for matching guidance.
2022
- `--scope project` writes project-local hook config for agents that support it.
2123
- `--scope user` writes user-level agent config and stores runner scripts under `~/.tanstack/intent/hooks`.
2224
- `--agents all` is the default. In project scope, Copilot is skipped because the supported Copilot CLI hook location is user-scoped.
2325
- Run `intent install` separately when you also want to write project guidance.
26+
- Use `package.json#intent.skills` and `package.json#intent.exclude` to control which skills are surfaced in the session catalog.
2427

2528
## Hook support
2629

27-
| Agent | Project scope | User scope | Enforcement |
30+
| Agent | Project scope | User scope | Hooks installed |
2831
| --- | --- | --- | --- |
29-
| Claude Code | `.claude/settings.json` | `~/.claude/settings.json` | Blocks configured edit tools with `PreToolUse` |
30-
| 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 |
31-
| 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` |
32+
| Claude Code | `.claude/settings.json` | `~/.claude/settings.json` | `SessionStart` skill catalog plus `PreToolUse` edit gate |
33+
| Codex | `.codex/hooks.json` | `~/.codex/hooks.json` | `SessionStart` skill catalog plus `PreToolUse` edit gate; Codex hook interception is not a complete security boundary |
34+
| 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` | `SessionStart` skill catalog plus `PreToolUse` edit gate in user scope |
3235
| Cursor | Guidance only | Guidance only | Use `AGENTS.md` or Cursor rules; no blocking hook is installed |
3336
| Generic `AGENTS.md` agents | Guidance only | Guidance only | Use the `intent-skills` guidance block; no blocking hook is installed |
3437

docs/getting-started/quick-start-consumers.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ npx @tanstack/intent@latest hooks install --scope user --agents copilot
5656

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

59+
Hooks add the available Intent skill catalog to supported agent sessions and keep the edit gate active until the agent loads matching full guidance. To tailor what appears in the session catalog, configure `intent.skills` and `intent.exclude` in `package.json`.
60+
5961
## 2. Choose which packages' skills to use
6062

6163
`package.json#intent.skills` is an allowlist of the packages whose skills you want surfaced.

packages/intent/src/cli.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,10 @@ function createCli(): CAC {
141141
})
142142

143143
cli
144-
.command('hooks [action]', 'Manage agent hooks that enforce skill loading')
144+
.command(
145+
'hooks [action]',
146+
'Manage agent hooks that surface and enforce skill loading',
147+
)
145148
.usage(
146149
'hooks install [--scope project|user] [--agents copilot,claude,codex|all]',
147150
)

packages/intent/src/hooks/install.ts

Lines changed: 176 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
22
import { homedir } from 'node:os'
33
import { dirname, relative } from 'node:path'
4+
import { detectPackageManager } from '../discovery/package-manager.js'
45
import { fail } from '../shared/cli-error.js'
6+
import { formatIntentCommand } from '../shared/command-runner.js'
57
import { ALL_HOOK_AGENTS, HOOK_AGENT_ADAPTERS } from './adapters.js'
68
import { EDIT_TOOLS_BY_AGENT, GATE_DENY_REASON } from './policy.js'
79
import type { HookAgent, HookInstallScope } from './types.js'
@@ -25,7 +27,8 @@ export type InstallHooksOptions = {
2527
scope?: string
2628
}
2729

28-
const STATUS_MESSAGE = 'Checking Intent guidance'
30+
const GATE_STATUS_MESSAGE = 'Checking Intent guidance'
31+
const CATALOG_STATUS_MESSAGE = 'Loading Intent skill catalog'
2932

3033
export function runInstallHooks({
3134
agents,
@@ -56,22 +59,47 @@ export function validateHookInstallOptions({
5659
parseAgents(agents)
5760
}
5861

59-
export function buildHookRunnerScript(agent: HookAgent): string {
62+
export function buildHookRunnerScript(
63+
agent: HookAgent,
64+
catalogCommand = formatIntentCommand(
65+
detectPackageManager(),
66+
'list --json --no-notices',
67+
),
68+
): string {
6069
const editTools = [...EDIT_TOOLS_BY_AGENT[agent]].sort()
6170

6271
return `#!/usr/bin/env node
6372
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
73+
import { execFileSync } from 'node:child_process'
6474
import { tmpdir } from 'node:os'
6575
import { dirname, join } from 'node:path'
6676
import { createHash } from 'node:crypto'
77+
import { performance } from 'node:perf_hooks'
6778
6879
const AGENT = ${JSON.stringify(agent)}
80+
const CATALOG_COMMAND = ${JSON.stringify(catalogCommand)}
6981
const EDIT_TOOLS = new Set(${JSON.stringify(editTools)})
7082
const GATE_DENY_REASON = ${JSON.stringify(GATE_DENY_REASON)}
7183
const INTENT_COMMAND_PATTERN = /(?:^|&&|\\|\\||;|\\|)\\s*((?:bunx\\s+@tanstack\\/intent(?:@latest)?)|(?:pnpm\\s+exec\\s+intent)|(?:pnpm\\s+dlx\\s+@tanstack\\/intent(?:@latest)?)|(?:npx\\s+@tanstack\\/intent(?:@latest)?)|(?:yarn\\s+dlx\\s+@tanstack\\/intent(?:@latest)?)|(?:intent))\\s+(list|load)(?:\\s+([^\\s|;&]+))?/i
7284
7385
try {
86+
await main()
87+
} catch {
88+
}
89+
90+
process.exit(0)
91+
92+
async function main() {
7493
const event = readEventFromStdin()
94+
95+
if (isSessionStartEvent(event)) {
96+
const additionalContext = await createSessionCatalogContext(rootForEvent(event))
97+
if (additionalContext) {
98+
process.stdout.write(JSON.stringify(sessionStartOutput(additionalContext)))
99+
}
100+
return
101+
}
102+
75103
const stateFile = stateFileForEvent(event)
76104
const observation = observationFromEvent(event)
77105
@@ -83,11 +111,8 @@ try {
83111
if (typeof toolName === 'string' && EDIT_TOOLS.has(toolName) && !hasLoad(stateFile)) {
84112
process.stdout.write(JSON.stringify(denyOutput()))
85113
}
86-
} catch {
87114
}
88115
89-
process.exit(0)
90-
91116
function readEventFromStdin() {
92117
try {
93118
return JSON.parse(readFileSync(0, 'utf8'))
@@ -96,6 +121,101 @@ function readEventFromStdin() {
96121
}
97122
}
98123
124+
function isSessionStartEvent(event) {
125+
return (event?.hook_event_name ?? event?.hookEventName) === 'SessionStart'
126+
}
127+
128+
function rootForEvent(event) {
129+
return typeof event?.cwd === 'string' ? event.cwd : process.cwd()
130+
}
131+
132+
async function createSessionCatalogContext(root) {
133+
try {
134+
const start = performance.now()
135+
const result = readIntentList(root)
136+
const durationMs = performance.now() - start
137+
console.error(
138+
\`[intent-\${AGENT}-session-catalog] listIntentSkills found \${result.skills.length} skills from \${result.packages.length} packages in \${formatDuration(durationMs)} (packageJsonReadCount=\${result.debug?.scan.packageJsonReadCount ?? 'unknown'})\`,
139+
)
140+
return formatSessionCatalog(result)
141+
} catch {
142+
return ''
143+
}
144+
}
145+
146+
function readIntentList(root) {
147+
const output = execFileSync(CATALOG_COMMAND, {
148+
cwd: root,
149+
encoding: 'utf8',
150+
env: { ...process.env, INTENT_AUDIENCE: 'agent' },
151+
maxBuffer: 1024 * 1024,
152+
shell: true,
153+
stdio: ['ignore', 'pipe', 'pipe'],
154+
timeout: 9000,
155+
})
156+
return JSON.parse(output)
157+
}
158+
159+
function formatDuration(durationMs) {
160+
return \`\${durationMs.toFixed(1)}ms\`
161+
}
162+
163+
function formatSessionCatalog(result) {
164+
if (!Array.isArray(result.skills) || result.skills.length === 0) return ''
165+
166+
return [
167+
'TanStack Intent skills are available in this repository.',
168+
'',
169+
'Before substantial work, check whether one listed skill clearly matches the user task. If one clearly matches, load that full skill guidance with the Intent CLI before proceeding.',
170+
'',
171+
'If no skill clearly matches, continue normally. Do not load a skill just to improve phrasing or gather nonessential context.',
172+
'',
173+
'Available local Intent skills:',
174+
formatSkillCatalog(result.skills),
175+
formatWarnings(result),
176+
]
177+
.filter(Boolean)
178+
.join('\\n')
179+
}
180+
181+
function formatSkillCatalog(skills) {
182+
return skills
183+
.map((skill) => \`- \${skill.use}: \${normalizeDescription(skill.description)}\`)
184+
.join('\\n')
185+
}
186+
187+
function normalizeDescription(description) {
188+
return typeof description === 'string' ? description.replace(/\\s+/g, ' ').trim() : ''
189+
}
190+
191+
function formatWarnings(result) {
192+
const warnings = [
193+
...(Array.isArray(result.warnings) ? result.warnings : []),
194+
...(Array.isArray(result.conflicts)
195+
? result.conflicts.map(
196+
(conflict) =>
197+
\`Version conflict for \${conflict.packageName}; using \${conflict.chosen.version}\`,
198+
)
199+
: []),
200+
]
201+
202+
if (warnings.length === 0) return ''
203+
return \`\\nWarnings:\\n\${warnings.map((warning) => \`- \${warning}\`).join('\\n')}\`
204+
}
205+
206+
function sessionStartOutput(additionalContext) {
207+
if (AGENT === 'copilot') {
208+
return { additionalContext }
209+
}
210+
211+
return {
212+
hookSpecificOutput: {
213+
hookEventName: 'SessionStart',
214+
additionalContext,
215+
},
216+
}
217+
}
218+
99219
function stateFileForEvent(event) {
100220
const sessionId = typeof event?.session_id === 'string' ? event.session_id : 'unknown'
101221
const cwd = typeof event?.cwd === 'string' ? event.cwd : process.cwd()
@@ -229,9 +349,16 @@ function installAgentHook({
229349
homeDir,
230350
root,
231351
})
232-
const scriptStatus = writeIfChanged(scriptPath, buildHookRunnerScript(agent))
352+
const catalogCommand = formatIntentCommand(
353+
detectPackageManager(root),
354+
'list --json --no-notices',
355+
)
356+
const scriptStatus = writeIfChanged(
357+
scriptPath,
358+
buildHookRunnerScript(agent, catalogCommand),
359+
)
233360
const configStatus = updateJsonConfig(configPath, (config) =>
234-
upsertAdapterPreToolUseHook({
361+
upsertAdapterHooks({
235362
config,
236363
configKind: adapter.configKind,
237364
project: scope === 'project',
@@ -278,7 +405,7 @@ function hookInstallResult({
278405
}
279406
}
280407

281-
function upsertAdapterPreToolUseHook({
408+
function upsertAdapterHooks({
282409
config,
283410
configKind,
284411
project,
@@ -291,20 +418,36 @@ function upsertAdapterPreToolUseHook({
291418
}): Record<string, unknown> {
292419
switch (configKind) {
293420
case 'claude-settings':
294-
return upsertClaudePreToolUseHook(config, project, scriptPath)
421+
return upsertClaudeHooks(config, project, scriptPath)
295422
case 'codex-hooks':
296-
return upsertCodexPreToolUseHook(config, project, scriptPath)
423+
return upsertCodexHooks(config, project, scriptPath)
297424
case 'copilot-hooks':
298-
return upsertCopilotPreToolUseHook(config, scriptPath)
425+
return upsertCopilotHooks(config, scriptPath)
299426
}
300427
}
301428

302-
function upsertClaudePreToolUseHook(
429+
function upsertClaudeHooks(
303430
config: Record<string, unknown>,
304431
project: boolean,
305432
scriptPath: string,
306433
): Record<string, unknown> {
307434
const hooks = objectValue(config.hooks)
435+
hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), {
436+
matcher: 'startup|resume|clear|compact',
437+
hooks: [
438+
{
439+
type: 'command',
440+
command: 'node',
441+
args: [
442+
project
443+
? '${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs'
444+
: scriptPath,
445+
],
446+
timeout: 10,
447+
statusMessage: CATALOG_STATUS_MESSAGE,
448+
},
449+
],
450+
})
308451
hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), {
309452
matcher: 'Bash|Write|Edit|MultiEdit|NotebookEdit',
310453
hooks: [
@@ -317,19 +460,32 @@ function upsertClaudePreToolUseHook(
317460
: scriptPath,
318461
],
319462
timeout: 10,
320-
statusMessage: STATUS_MESSAGE,
463+
statusMessage: GATE_STATUS_MESSAGE,
321464
},
322465
],
323466
})
324467
return { ...config, hooks }
325468
}
326469

327-
function upsertCodexPreToolUseHook(
470+
function upsertCodexHooks(
328471
config: Record<string, unknown>,
329472
project: boolean,
330473
scriptPath: string,
331474
): Record<string, unknown> {
332475
const hooks = objectValue(config.hooks)
476+
hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), {
477+
matcher: 'startup|resume|clear|compact',
478+
hooks: [
479+
{
480+
type: 'command',
481+
command: project
482+
? 'node "$(git rev-parse --show-toplevel)/.intent/hooks/intent-codex-gate.mjs"'
483+
: `node ${quoteShell(scriptPath)}`,
484+
timeout: 10,
485+
statusMessage: CATALOG_STATUS_MESSAGE,
486+
},
487+
],
488+
})
333489
hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), {
334490
matcher: 'Bash|apply_patch|Edit|Write',
335491
hooks: [
@@ -339,18 +495,21 @@ function upsertCodexPreToolUseHook(
339495
? 'node "$(git rev-parse --show-toplevel)/.intent/hooks/intent-codex-gate.mjs"'
340496
: `node ${quoteShell(scriptPath)}`,
341497
timeout: 10,
342-
statusMessage: STATUS_MESSAGE,
498+
statusMessage: GATE_STATUS_MESSAGE,
343499
},
344500
],
345501
})
346502
return { ...config, hooks }
347503
}
348504

349-
function upsertCopilotPreToolUseHook(
505+
function upsertCopilotHooks(
350506
config: Record<string, unknown>,
351507
scriptPath: string,
352508
): Record<string, unknown> {
353509
const hooks = objectValue(config.hooks)
510+
hooks.SessionStart = upsertHookGroup(arrayValue(hooks.SessionStart), {
511+
command: `node ${quoteShell(scriptPath)}`,
512+
})
354513
hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), {
355514
command: `node ${quoteShell(scriptPath)}`,
356515
})
@@ -389,7 +548,7 @@ function isIntentHook(value: unknown): boolean {
389548
}
390549

391550
function isIntentGateScriptReference(value: string): boolean {
392-
return /(?:^|[\s"'\\/])(?:old-)?intent-(claude|codex|copilot)-gate\.mjs(?:$|[?#\s"'])/i.test(
551+
return /(?:^|[\s"'\/])(?:old-)?intent-(claude|codex|copilot)-gate\.mjs(?:$|[?#\s"'])/i.test(
393552
value,
394553
)
395554
}

0 commit comments

Comments
 (0)