diff --git a/.changeset/giant-geckos-beam.md b/.changeset/giant-geckos-beam.md new file mode 100644 index 00000000..8a11aef1 --- /dev/null +++ b/.changeset/giant-geckos-beam.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Allow agent hooks to continue after checking Intent guidance when no matching skill applies. diff --git a/docs/cli/intent-hooks.md b/docs/cli/intent-hooks.md index f8b593ba..ccdd4167 100644 --- a/docs/cli/intent-hooks.md +++ b/docs/cli/intent-hooks.md @@ -3,7 +3,7 @@ title: intent hooks id: intent-hooks --- -`intent hooks install` installs lifecycle hooks that surface available Intent skills and gate supported edit tools until they observe an Intent load command. +`intent hooks install` installs lifecycle hooks that surface available Intent skills and gate supported edit tools until they observe an Intent guidance check. ```bash npx @tanstack/intent@latest hooks install [--scope project|user] [--agents copilot,claude,codex|all] @@ -20,7 +20,7 @@ npx @tanstack/intent@latest hooks install [--scope project|user] [--agents copil - Installs hook behavior without writing an `intent-skills` guidance block. - Returns a session-start skill catalog as agent context with available `skill-id: description` entries. -- Blocks supported edit tools until the hook observes a recognized `intent load ` command. +- Blocks supported edit tools until the hook observes a recognized `intent list` or `intent load ` command. If no listed skill matches the task, the agent can continue without loading one. - Uses `package.json#intent.skills` and `package.json#intent.exclude` to control which skills appear in the session catalog. ### Installation behavior @@ -30,7 +30,7 @@ npx @tanstack/intent@latest hooks install [--scope project|user] [--agents copil - `--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. -The hook records a recognized load command before that command completes. +The hook records a recognized list or load command before that command completes. Hooks do not verify that: diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index 2e8f6a5d..9b2ebeb5 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -93,7 +93,9 @@ async function main() { const event = readEventFromStdin() if (isSessionStartEvent(event)) { + const stateFile = stateFileForEvent(event) const additionalContext = await createSessionCatalogContext(rootForEvent(event)) + appendObservation(stateFile, { action: 'list', raw: CATALOG_COMMAND }) if (additionalContext) { process.stdout.write(JSON.stringify(sessionStartOutput(additionalContext))) } @@ -108,7 +110,7 @@ async function main() { } const toolName = event?.tool_name ?? event?.toolName - if (typeof toolName === 'string' && EDIT_TOOLS.has(toolName) && !hasLoad(stateFile)) { + if (typeof toolName === 'string' && EDIT_TOOLS.has(toolName) && !hasIntentCheck(stateFile)) { process.stdout.write(JSON.stringify(denyOutput())) } } @@ -266,7 +268,7 @@ function appendObservation(stateFile, observation) { } } -function hasLoad(stateFile) { +function hasIntentCheck(stateFile) { if (!existsSync(stateFile)) return false try { return readFileSync(stateFile, 'utf8') @@ -274,7 +276,8 @@ function hasLoad(stateFile) { .filter(Boolean) .some((line) => { try { - return JSON.parse(line).action === 'load' + const action = JSON.parse(line).action + return action === 'list' || action === 'load' } catch { return false } diff --git a/packages/intent/src/hooks/policy.ts b/packages/intent/src/hooks/policy.ts index df9eb946..b10eb2d3 100644 --- a/packages/intent/src/hooks/policy.ts +++ b/packages/intent/src/hooks/policy.ts @@ -16,7 +16,7 @@ export const EDIT_TOOLS_BY_AGENT: Record> = { } export const GATE_DENY_REASON = - "Blocked: load matching TanStack guidance before editing. Follow this repo's TanStack guidance setup, then retry the edit." + 'Blocked: check TanStack guidance before editing. If a listed skill matches, load it, then retry the edit.' export function parseIntentInvocation( command: unknown, @@ -90,10 +90,12 @@ export function gateDecision({ return { decision: 'allow' } } -export function hasLoadFromObservations( +export function hasIntentCheckFromObservations( observations: Array | undefined>, ): boolean { - return observations.some((entry) => entry?.action === 'load') + return observations.some( + (entry) => entry?.action === 'list' || entry?.action === 'load', + ) } function commandFromObject(value: unknown): unknown { diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index 82e89853..44dafaa3 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -357,6 +357,32 @@ describe('hook installer', () => { expect(afterLoad.stdout).toBe('') }) + it('unlocks edits after the agent checks the catalog without a matching skill', () => { + const root = tempRoot('intent-hooks-runner-list-') + const scriptPath = join(root, 'intent-claude-gate.mjs') + writeFileSync(scriptPath, buildHookRunnerScript('claude')) + + const list = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-a', + tool_name: 'Bash', + tool_input: { command: 'intent list' }, + }) + const edit = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-a', + tool_name: 'Edit', + tool_input: { file_path: join(root, 'src.ts') }, + }) + + expect(list.status).toBe(0) + expect(list.stdout).toBe('') + expect(edit.status).toBe(0) + expect(edit.stdout).toBe('') + }) + it.each(['claude', 'codex', 'copilot'] as const)( 'emits session catalog context for %s', (agent) => { @@ -396,7 +422,7 @@ describe('hook installer', () => { }, ) - it('does not unlock edits after session catalog context', () => { + it('unlocks edits after session catalog context', () => { const root = tempRoot('intent-hooks-session-catalog-gate-') const catalogCommand = writeFakeIntentListCommand(root) const scriptPath = join(root, '.intent', 'hooks', 'intent-claude-gate.mjs') @@ -422,9 +448,7 @@ describe('hook installer', () => { hookSpecificOutput: { hookEventName: 'SessionStart' }, }) expect(edit.status).toBe(0) - expect(JSON.parse(edit.stdout)).toMatchObject({ - hookSpecificOutput: { permissionDecision: 'deny' }, - }) + expect(edit.stdout).toBe('') }) it('continues silently when session catalog loading fails', () => { @@ -448,6 +472,17 @@ describe('hook installer', () => { expect(result.status).toBe(0) expect(result.stdout).toBe('') + + const edit = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-a', + tool_name: 'Edit', + tool_input: { file_path: join(root, 'src.ts') }, + }) + + expect(edit.status).toBe(0) + expect(edit.stdout).toBe('') }) it('does not unlock edits after non-executed load text', () => { diff --git a/packages/intent/tests/hooks.test.ts b/packages/intent/tests/hooks.test.ts index b7a9e66f..ab615a6b 100644 --- a/packages/intent/tests/hooks.test.ts +++ b/packages/intent/tests/hooks.test.ts @@ -6,7 +6,7 @@ import { EDIT_TOOLS_BY_AGENT, GATE_DENY_REASON, gateDecision, - hasLoadFromObservations, + hasIntentCheckFromObservations, observationFromEvent, parseIntentInvocation, } from '../src/hooks/policy.js' @@ -64,7 +64,7 @@ describe('intent hook policy', () => { ).toBeUndefined() }) - it('denies edit tools until a load is observed', () => { + it('denies edit tools until guidance is checked', () => { expect( gateDecision({ agent: 'copilot', toolName: 'Edit', hasLoaded: false }), ).toEqual({ decision: 'deny', reason: GATE_DENY_REASON }) @@ -89,10 +89,10 @@ describe('intent hook policy', () => { expect(EDIT_TOOLS_BY_AGENT.codex.has('apply_patch')).toBe(true) }) - it('detects a prior load from observation records', () => { - expect(hasLoadFromObservations([{ action: 'list' }])).toBe(false) + it('detects a prior guidance check from observation records', () => { + expect(hasIntentCheckFromObservations([{ action: 'list' }])).toBe(true) expect( - hasLoadFromObservations([{ action: 'list' }, { action: 'load' }]), + hasIntentCheckFromObservations([{ action: 'list' }, { action: 'load' }]), ).toBe(true) })