From 21c165da6c25e57644ec48287f9300ce5811543e Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 20 Jun 2026 18:54:28 -0700 Subject: [PATCH 01/13] Refactor skill mapping guidance and add `run` command support in intent skills block --- .../intent/src/commands/install-writer.ts | 31 ++++++++++++++----- packages/intent/tests/cli.test.ts | 6 +++- packages/intent/tests/install-writer.test.ts | 23 +++++++++++--- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/packages/intent/src/commands/install-writer.ts b/packages/intent/src/commands/install-writer.ts index e7a9524..d82399c 100644 --- a/packages/intent/src/commands/install-writer.ts +++ b/packages/intent/src/commands/install-writer.ts @@ -180,7 +180,12 @@ export function verifyIntentSkillsBlockFile({ continue } - const mapping = skill as { load?: unknown; use?: unknown; when?: unknown } + const mapping = skill as { + load?: unknown + run?: unknown + use?: unknown + when?: unknown + } if (mapping.load !== undefined) { errors.push('Skill mappings must use compact `use` entries, not `load`.') @@ -199,6 +204,10 @@ export function verifyIntentSkillsBlockFile({ errors.push(err instanceof Error ? err.message : String(err)) } } + + if (mapping.run !== undefined && typeof mapping.run !== 'string') { + errors.push('Skill mapping `run` fields must be strings.') + } } return { @@ -241,10 +250,7 @@ export function buildIntentSkillsBlock( ): IntentSkillsBlockResult { const lines = [ INTENT_SKILLS_START, - `# Skill mappings - load \`use\` with \`${formatIntentCommand( - scanResult.packageManager, - 'load ', - )}\`.`, + '# Skill mappings - before editing files, choose the matching skill and run its `run` command.', 'skills:', ] let mappingCount = 0 @@ -258,6 +264,14 @@ export function buildIntentSkillsBlock( lines.push( ` use: ${quoteYamlString(formatSkillUse(pkg.name, skill.name))}`, ) + lines.push( + ` run: ${quoteYamlString( + formatIntentCommand( + scanResult.packageManager, + `load ${formatSkillUse(pkg.name, skill.name)}`, + ), + )}`, + ) } } @@ -286,9 +300,10 @@ export function buildIntentSkillGuidanceBlock( INTENT_SKILLS_START, '## Skill Loading', '', - 'Before substantial work:', - `- Skill check: run \`${listCommand}\`, or use skills already listed in context.`, - `- Skill guidance: if one local skill clearly matches the task, run \`${loadCommand}\` and follow the returned \`SKILL.md\`.`, + 'Before editing files for a substantial task:', + `- Run \`${listCommand}\` from the workspace root to see available local skills.`, + `- If a listed skill matches the task, run \`${loadCommand}\` 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, diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index baba931..dece1fc 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -356,7 +356,8 @@ describe('cli commands', () => { expect(output).toContain('Created AGENTS.md with skill loading guidance.') expect(content).toContain('## Skill Loading') expect(content).toContain('npx @tanstack/intent@latest list') - expect(content).toContain('if one local skill clearly matches the task') + expect(content).toContain('If a listed skill matches the task') + expect(content).toContain('before changing files') expect(content).toContain('Monorepos:') expect(content).toContain('Multiple matches:') expect(content).not.toContain('--global') @@ -470,6 +471,9 @@ describe('cli commands', () => { expect(output).toContain('Created AGENTS.md with 1 mapping.') expect(content).toContain('when: "Query data fetching patterns"') expect(content).toContain('use: "@tanstack/query#fetching"') + expect(content).toContain( + 'run: "npx @tanstack/intent@latest load @tanstack/query#fetching"', + ) expect(content).not.toContain('load:') expect(content).not.toContain(root) diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index 09539e1..602055c 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -82,10 +82,11 @@ function scanResult(packages: Array): ScanResult { } const exampleBlock = ` -# Skill mappings - load \`use\` with \`pnpm dlx @tanstack/intent@latest load \`. +# Skill mappings - before editing files, choose the matching skill and run its \`run\` command. skills: - when: "Query data fetching" use: "@tanstack/query#fetching" + run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#fetching" ` @@ -97,8 +98,9 @@ describe('install writer block builder', () => { expect(generated.block).toContain('## Skill Loading') expect(generated.block).toContain('npx @tanstack/intent@latest list') expect(generated.block).toContain( - 'if one local skill clearly matches the task', + 'If a listed skill matches the task', ) + expect(generated.block).toContain('before changing files') expect(generated.block).toContain('Monorepos:') expect(generated.block).toContain('Multiple matches:') expect(generated.block).not.toContain('install --map') @@ -147,14 +149,17 @@ describe('install writer block builder', () => { expect(generated.mappingCount).toBe(3) expect(generated.block).toBe(` -# Skill mappings - load \`use\` with \`pnpm dlx @tanstack/intent@latest load \`. +# Skill mappings - before editing files, choose the matching skill and run its \`run\` command. skills: - when: "Query data fetching patterns" use: "@tanstack/query#fetching" + run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#fetching" - when: "Mutation patterns" use: "@tanstack/query#mutations" + run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#mutations" - when: "Routing patterns" use: "@tanstack/router#routing" + run: "pnpm dlx @tanstack/intent@latest load @tanstack/router#routing" `) }) @@ -183,6 +188,9 @@ skills: expect(generated.mappingCount).toBe(2) expect(generated.block).toContain('use: "@tanstack/query#global-fetching"') expect(generated.block).toContain('use: "@tanstack/query#pnpm-fetching"') + expect(generated.block).toContain( + 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#global-fetching"', + ) expect(generated.block).not.toContain('/home/sarah') expect(generated.block).not.toContain('node_modules/.pnpm') expect(generated.block).not.toContain('load:') @@ -219,8 +227,14 @@ skills: expect(generated.mappingCount).toBe(2) expect(generated.block).toContain('when: "Core skill"') expect(generated.block).toContain('use: "@tanstack/query#core"') + expect(generated.block).toContain( + 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#core"', + ) expect(generated.block).toContain('when: "Sub-skill"') expect(generated.block).toContain('use: "@tanstack/query#core/fetching"') + expect(generated.block).toContain( + 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#core/fetching"', + ) expect(generated.block).not.toContain('Reference material') expect(generated.block).not.toContain('Maintainer task') expect(generated.block).not.toContain('Maintainer-only task') @@ -454,10 +468,11 @@ describe('install writer verification', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') const block = ` -# Skill mappings - load \`use\` with \`npx @tanstack/intent@latest load \`. +# Skill mappings - before editing files, choose the matching skill and run its \`run\` command. skills: - when: "Query data fetching" use: "@tanstack/query#fetching" + run: "npx @tanstack/intent@latest load @tanstack/query#fetching" ` writeFileSync(agentsPath, block) From 9ec6179af8c315853eacfc3ebe55601a2498bc3c Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 20 Jun 2026 19:50:41 -0700 Subject: [PATCH 02/13] Refactor intent skill mappings to use new structure and update related tests --- .../intent-discovery/condition-setup.eval.ts | 9 +- .../intent/src/commands/install-writer.ts | 63 +++++++------ packages/intent/tests/cli.test.ts | 10 +- packages/intent/tests/install-writer.test.ts | 94 +++++++++---------- .../source-policy-surfaces.test.ts | 6 +- 5 files changed, 96 insertions(+), 86 deletions(-) diff --git a/evals/intent-discovery/condition-setup.eval.ts b/evals/intent-discovery/condition-setup.eval.ts index 53e5bf1..0fec098 100644 --- a/evals/intent-discovery/condition-setup.eval.ts +++ b/evals/intent-discovery/condition-setup.eval.ts @@ -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( @@ -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() } diff --git a/packages/intent/src/commands/install-writer.ts b/packages/intent/src/commands/install-writer.ts index d82399c..a5b5c38 100644 --- a/packages/intent/src/commands/install-writer.ts +++ b/packages/intent/src/commands/install-writer.ts @@ -98,7 +98,7 @@ function readManagedBlock(content: string): { function parseSkillsList(block: string): { errors: Array - skills: Array + mappings: Array } { const yamlBody = normalizeBlock(block) .split('\n') @@ -108,21 +108,23 @@ function parseSkillsList(block: string): { .join('\n') try { - const parsed = parseYaml(yamlBody) as { skills?: unknown } | null - if (!parsed || !Array.isArray(parsed.skills)) { + const parsed = parseYaml(yamlBody) as { + tanstackIntent?: unknown + } | null + if (!parsed || !Array.isArray(parsed.tanstackIntent)) { return { - errors: ['Managed block must contain a skills list.'], - skills: [], + errors: ['Managed block must contain a tanstackIntent list.'], + mappings: [], } } - return { errors: [], skills: parsed.skills } + return { errors: [], mappings: parsed.tanstackIntent } } catch (err) { return { errors: [ `Managed block contains invalid YAML: ${err instanceof Error ? err.message : String(err)}`, ], - skills: [], + mappings: [], } } } @@ -166,47 +168,52 @@ export function verifyIntentSkillsBlockFile({ } } - const { skills, errors: parseErrors } = parseSkillsList(block) + const { mappings, errors: parseErrors } = parseSkillsList(block) errors.push(...parseErrors) - if (skills.length !== expectedMappingCount) { + if (mappings.length !== expectedMappingCount) { errors.push( - `Expected ${expectedMappingCount} skill mappings, found ${skills.length}.`, + `Expected ${expectedMappingCount} skill mappings, found ${mappings.length}.`, ) } - for (const skill of skills) { - if (!skill || typeof skill !== 'object') { + for (const mappingValue of mappings) { + if (!mappingValue || typeof mappingValue !== 'object') { errors.push('Each skill mapping must be an object.') continue } - const mapping = skill as { - load?: unknown + const mapping = mappingValue as { + for?: unknown + id?: unknown run?: unknown use?: unknown when?: unknown } - if (mapping.load !== undefined) { - errors.push('Skill mappings must use compact `use` entries, not `load`.') + if (mapping.use !== undefined) { + errors.push('Skill mappings must use `id` entries, not `use`.') } - if (typeof mapping.when !== 'string' || mapping.when.trim() === '') { - errors.push('Each skill mapping must include a non-empty `when` field.') + if (mapping.when !== undefined) { + errors.push('Skill mappings must use compact `for` entries, not `when`.') } - if (typeof mapping.use !== 'string') { - errors.push('Each skill mapping must include a `use` field.') + if (typeof mapping.id !== 'string') { + errors.push('Each skill mapping must include an `id` field.') } else { try { - parseSkillUse(mapping.use) + parseSkillUse(mapping.id) } catch (err) { errors.push(err instanceof Error ? err.message : String(err)) } } - if (mapping.run !== undefined && typeof mapping.run !== 'string') { - errors.push('Skill mapping `run` fields must be strings.') + if (typeof mapping.run !== 'string' || mapping.run.trim() === '') { + errors.push('Each skill mapping must include a non-empty `run` field.') + } + + if (typeof mapping.for !== 'string' || mapping.for.trim() === '') { + errors.push('Each skill mapping must include a non-empty `for` field.') } } @@ -250,8 +257,8 @@ export function buildIntentSkillsBlock( ): IntentSkillsBlockResult { const lines = [ INTENT_SKILLS_START, - '# Skill mappings - before editing files, choose the matching skill and run its `run` command.', - 'skills:', + '# TanStack Intent - before editing files, run the matching guidance command.', + 'tanstackIntent:', ] let mappingCount = 0 @@ -260,9 +267,8 @@ export function buildIntentSkillsBlock( if (!isActionableSkill(skill)) continue mappingCount++ - lines.push(` - when: ${quoteYamlString(formatWhen(pkg.name, skill))}`) lines.push( - ` use: ${quoteYamlString(formatSkillUse(pkg.name, skill.name))}`, + ` - id: ${quoteYamlString(formatSkillUse(pkg.name, skill.name))}`, ) lines.push( ` run: ${quoteYamlString( @@ -272,11 +278,12 @@ export function buildIntentSkillsBlock( ), )}`, ) + lines.push(` for: ${quoteYamlString(formatWhen(pkg.name, skill))}`) } } if (mappingCount === 0) { - lines[2] = 'skills: []' + lines[2] = 'tanstackIntent: []' } lines.push(INTENT_SKILLS_END) diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index dece1fc..8cde72d 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -469,8 +469,8 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain('Created AGENTS.md with 1 mapping.') - expect(content).toContain('when: "Query data fetching patterns"') - expect(content).toContain('use: "@tanstack/query#fetching"') + expect(content).toContain('for: "Query data fetching patterns"') + expect(content).toContain('id: "@tanstack/query#fetching"') expect(content).toContain( 'run: "npx @tanstack/intent@latest load @tanstack/query#fetching"', ) @@ -520,7 +520,7 @@ describe('cli commands', () => { const content = readFileSync(join(root, 'AGENTS.md'), 'utf8') expect(exitCode).toBe(0) - expect(content).toContain('use: "@tanstack/query#fetching"') + expect(content).toContain('id: "@tanstack/query#fetching"') expect(content).not.toContain('@tanstack/unlisted') }) @@ -579,8 +579,8 @@ describe('cli commands', () => { expect(exitCode).toBe(0) expect(output).toContain('Generated 1 mapping for AGENTS.md.') - expect(output).toContain('when: "Global fetching skill"') - expect(output).toContain('use: "@tanstack/query#fetching"') + expect(output).toContain('for: "Global fetching skill"') + expect(output).toContain('id: "@tanstack/query#fetching"') }) it('prints the scaffold prompt', async () => { diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index 602055c..370656e 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -82,11 +82,11 @@ function scanResult(packages: Array): ScanResult { } const exampleBlock = ` -# Skill mappings - before editing files, choose the matching skill and run its \`run\` command. -skills: - - when: "Query data fetching" - use: "@tanstack/query#fetching" +# TanStack Intent - before editing files, run the matching guidance command. +tanstackIntent: + - id: "@tanstack/query#fetching" run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#fetching" + for: "Query data fetching" ` @@ -149,17 +149,17 @@ describe('install writer block builder', () => { expect(generated.mappingCount).toBe(3) expect(generated.block).toBe(` -# Skill mappings - before editing files, choose the matching skill and run its \`run\` command. -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: "pnpm dlx @tanstack/intent@latest load @tanstack/query#fetching" - - when: "Mutation patterns" - use: "@tanstack/query#mutations" + for: "Query data fetching patterns" + - id: "@tanstack/query#mutations" run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#mutations" - - when: "Routing patterns" - use: "@tanstack/router#routing" + for: "Mutation patterns" + - id: "@tanstack/router#routing" run: "pnpm dlx @tanstack/intent@latest load @tanstack/router#routing" + for: "Routing patterns" `) }) @@ -186,8 +186,8 @@ skills: const generated = buildIntentSkillsBlock(result) expect(generated.mappingCount).toBe(2) - expect(generated.block).toContain('use: "@tanstack/query#global-fetching"') - expect(generated.block).toContain('use: "@tanstack/query#pnpm-fetching"') + expect(generated.block).toContain('id: "@tanstack/query#global-fetching"') + expect(generated.block).toContain('id: "@tanstack/query#pnpm-fetching"') expect(generated.block).toContain( 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#global-fetching"', ) @@ -225,13 +225,13 @@ skills: const generated = buildIntentSkillsBlock(result) expect(generated.mappingCount).toBe(2) - expect(generated.block).toContain('when: "Core skill"') - expect(generated.block).toContain('use: "@tanstack/query#core"') + expect(generated.block).toContain('for: "Core skill"') + expect(generated.block).toContain('id: "@tanstack/query#core"') expect(generated.block).toContain( 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#core"', ) - expect(generated.block).toContain('when: "Sub-skill"') - expect(generated.block).toContain('use: "@tanstack/query#core/fetching"') + expect(generated.block).toContain('for: "Sub-skill"') + expect(generated.block).toContain('id: "@tanstack/query#core/fetching"') expect(generated.block).toContain( 'run: "pnpm dlx @tanstack/intent@latest load @tanstack/query#core/fetching"', ) @@ -256,8 +256,8 @@ skills: const generated = buildIntentSkillsBlock(result) - expect(generated.block).toContain('when: "Use \\"quoted\\" names"') - expect(generated.block).toContain('use: "@tanstack/query#quotes"') + expect(generated.block).toContain('for: "Use \\"quoted\\" names"') + expect(generated.block).toContain('id: "@tanstack/query#quotes"') }) it('collapses whitespace in skill descriptions including newlines', () => { @@ -276,7 +276,7 @@ skills: const generated = buildIntentSkillsBlock(result) - expect(generated.block).toContain('when: "Line one Line two tabbed"') + expect(generated.block).toContain('for: "Line one Line two tabbed"') }) it('uses fallback when description for skills with empty descriptions', () => { @@ -295,7 +295,7 @@ skills: const generated = buildIntentSkillsBlock(result) - expect(generated.block).toContain('when: "Use @tanstack/query fetching"') + expect(generated.block).toContain('for: "Use @tanstack/query fetching"') }) }) @@ -468,11 +468,11 @@ describe('install writer verification', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') const block = ` -# Skill mappings - before editing files, choose the matching skill and run its \`run\` command. -skills: - - when: "Query data fetching" - 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" ` writeFileSync(agentsPath, block) @@ -503,7 +503,7 @@ skills: it('rejects missing managed block markers', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') - writeFileSync(agentsPath, 'skills: []\n') + writeFileSync(agentsPath, 'tanstackIntent: []\n') const result = verifyIntentSkillsBlockFile({ expectedBlock: exampleBlock, @@ -536,7 +536,7 @@ skills: ) }) - it('rejects legacy load paths', () => { + it('rejects legacy skills lists', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') const block = ` @@ -556,20 +556,18 @@ skills: expect(result.ok).toBe(false) expect(result.errors).toContain( - 'Skill mappings must use compact `use` entries, not `load`.', - ) - expect(result.errors).toContain( - 'Each skill mapping must include a `use` field.', + 'Managed block must contain a tanstackIntent list.', ) }) - it('rejects mappings without when', () => { + it('rejects mappings without for', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') const block = ` -# Skill mappings - load \`use\` with \`npx @tanstack/intent@latest load \`. -skills: - - 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" ` writeFileSync(agentsPath, block) @@ -582,17 +580,18 @@ skills: expect(result.ok).toBe(false) expect(result.errors).toContain( - 'Each skill mapping must include a non-empty `when` field.', + 'Each skill mapping must include a non-empty `for` field.', ) }) - it('rejects mappings without use', () => { + it('rejects mappings without id', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') const block = ` -# Skill mappings - load \`use\` with \`npx @tanstack/intent@latest load \`. -skills: - - when: "Query data fetching" +# TanStack Intent - before editing files, run the matching guidance command. +tanstackIntent: + - run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + for: "Query data fetching" ` writeFileSync(agentsPath, block) @@ -605,18 +604,19 @@ skills: expect(result.ok).toBe(false) expect(result.errors).toContain( - 'Each skill mapping must include a `use` field.', + 'Each skill mapping must include an `id` field.', ) }) - it('rejects invalid use values', () => { + it('rejects invalid id values', () => { const root = tempRoot() const agentsPath = join(root, 'AGENTS.md') const block = ` -# Skill mappings - load \`use\` with \`npx @tanstack/intent@latest load \`. -skills: - - when: "Query data fetching" - use: "@tanstack/query" +# TanStack Intent - before editing files, run the matching guidance command. +tanstackIntent: + - id: "@tanstack/query" + run: "npx @tanstack/intent@latest load @tanstack/query#fetching" + for: "Query data fetching" ` writeFileSync(agentsPath, block) diff --git a/packages/intent/tests/integration/source-policy-surfaces.test.ts b/packages/intent/tests/integration/source-policy-surfaces.test.ts index 220c867..ea4c0b3 100644 --- a/packages/intent/tests/integration/source-policy-surfaces.test.ts +++ b/packages/intent/tests/integration/source-policy-surfaces.test.ts @@ -113,9 +113,9 @@ describe('source policy — all four surfaces filter excluded and unlisted', () const output = logSpy.mock.calls.flat().join('\n') expect(exitCode).toBe(0) - expect(output).toContain(`use: "${LISTED}#core"`) - expect(output).not.toContain(`use: "${UNLISTED}#core"`) - expect(output).not.toContain(`use: "${EXCLUDED}#core"`) + expect(output).toContain(`id: "${LISTED}#core"`) + expect(output).not.toContain(`id: "${UNLISTED}#core"`) + expect(output).not.toContain(`id: "${EXCLUDED}#core"`) rmSync(isolatedGlobalRoot, { recursive: true, force: true }) }) From 0e52ecbae4b825ea6ff82806efbff891b1949207 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 20 Jun 2026 20:57:17 -0700 Subject: [PATCH 03/13] Add hooked-intent condition and related functionality to intent discovery evals - Introduce 'hooked-intent' condition in conditions.ts - Implement live-router-hooked-intent task in live-tasks.ts - Create gate.mjs and hook-core.mts for intent hooks management - Add hook-io.mjs for event handling and observation logging - Implement prepare-copilot-home.ts for managing copilot home directory - Update run-copilot-task.ts to support gate state management - Enhance setup-intent-condition.ts to handle hooked-intent - Add tests for hooked-intent functionality in intent-hooks.eval.ts - Update vitest configuration for live concurrency settings --- evals/intent-discovery/README.md | 8 ++ evals/intent-discovery/corpus/conditions.ts | 4 + evals/intent-discovery/corpus/live-tasks.ts | 14 +++ .../harness/intent-hooks/gate.mjs | 39 ++++++ .../harness/intent-hooks/hook-core.d.mts | 36 ++++++ .../harness/intent-hooks/hook-core.mjs | 75 ++++++++++++ .../harness/intent-hooks/hook-io.mjs | 54 ++++++++ .../harness/prepare-copilot-home.ts | 60 +++++++++ .../harness/run-copilot-task.ts | 17 ++- .../harness/setup-intent-condition.ts | 2 +- evals/intent-discovery/intent-hooks.eval.ts | 115 ++++++++++++++++++ .../live-copilot-harness.eval.ts | 7 +- evals/intent-discovery/vitest.evals.config.ts | 11 ++ 13 files changed, 438 insertions(+), 4 deletions(-) create mode 100644 evals/intent-discovery/harness/intent-hooks/gate.mjs create mode 100644 evals/intent-discovery/harness/intent-hooks/hook-core.d.mts create mode 100644 evals/intent-discovery/harness/intent-hooks/hook-core.mjs create mode 100644 evals/intent-discovery/harness/intent-hooks/hook-io.mjs create mode 100644 evals/intent-discovery/harness/prepare-copilot-home.ts create mode 100644 evals/intent-discovery/intent-hooks.eval.ts diff --git a/evals/intent-discovery/README.md b/evals/intent-discovery/README.md index 77b880d..76f3647 100644 --- a/evals/intent-discovery/README.md +++ b/evals/intent-discovery/README.md @@ -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 diff --git a/evals/intent-discovery/corpus/conditions.ts b/evals/intent-discovery/corpus/conditions.ts index 656d067..0190f37 100644 --- a/evals/intent-discovery/corpus/conditions.ts +++ b/evals/intent-discovery/corpus/conditions.ts @@ -15,6 +15,10 @@ const intentDiscoveryConditions = [ id: 'mapped-intent', countsTowardAutonomousScore: true, }, + { + id: 'hooked-intent', + countsTowardAutonomousScore: true, + }, { id: 'explicit-intent-control', countsTowardAutonomousScore: false, diff --git a/evals/intent-discovery/corpus/live-tasks.ts b/evals/intent-discovery/corpus/live-tasks.ts index d0977de..80c7451 100644 --- a/evals/intent-discovery/corpus/live-tasks.ts +++ b/evals/intent-discovery/corpus/live-tasks.ts @@ -46,6 +46,20 @@ export const liveTasks: Array = [ 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', diff --git a/evals/intent-discovery/harness/intent-hooks/gate.mjs b/evals/intent-discovery/harness/intent-hooks/gate.mjs new file mode 100644 index 0000000..d70df60 --- /dev/null +++ b/evals/intent-discovery/harness/intent-hooks/gate.mjs @@ -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) diff --git a/evals/intent-discovery/harness/intent-hooks/hook-core.d.mts b/evals/intent-discovery/harness/intent-hooks/hook-core.d.mts new file mode 100644 index 0000000..3050482 --- /dev/null +++ b/evals/intent-discovery/harness/intent-hooks/hook-core.d.mts @@ -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 +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 diff --git a/evals/intent-discovery/harness/intent-hooks/hook-core.mjs b/evals/intent-discovery/harness/intent-hooks/hook-core.mjs new file mode 100644 index 0000000..aa873a9 --- /dev/null +++ b/evals/intent-discovery/harness/intent-hooks/hook-core.mjs @@ -0,0 +1,75 @@ +const INTENT_COMMAND_PATTERN = + /(?:^|\s|&&|;|\|)\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 + +export const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']) + +export const GATE_DENY_REASON = + 'Blocked: load the matching TanStack guidance before editing. Use the guidance command from the AGENTS.md tanstackIntent block, then retry the edit.' + +export function parseIntentInvocation(command) { + if (typeof command !== 'string') { + return undefined + } + + const match = command.match(INTENT_COMMAND_PATTERN) + + if (!match?.[1] || !match[2]) { + return undefined + } + + const action = match[2].toLowerCase() + const skillUse = action === 'load' ? match[3] : undefined + + if (action === 'load' && !skillUse) { + return undefined + } + + return { action, skillUse } +} + +export function observationFromEvent(event) { + if (!event || typeof event !== 'object') { + return undefined + } + + const toolName = event.tool_name ?? event.toolName + const toolInput = event.tool_input ?? event.toolArgs + + if (toolName !== 'Bash') { + return undefined + } + + const command = + typeof toolInput === 'string' + ? safeCommandFromString(toolInput) + : toolInput?.command + + const parsed = parseIntentInvocation(command) + + if (!parsed) { + return undefined + } + + return { action: parsed.action, skillUse: parsed.skillUse, raw: command } +} + +export function gateDecision({ toolName, hasLoaded }) { + if (EDIT_TOOLS.has(toolName) && !hasLoaded) { + return { decision: 'deny', reason: GATE_DENY_REASON } + } + + return { decision: 'allow' } +} + +export function hasLoadFromObservations(observations) { + return observations.some((entry) => entry?.action === 'load') +} + +function safeCommandFromString(value) { + try { + const parsed = JSON.parse(value) + return typeof parsed?.command === 'string' ? parsed.command : value + } catch { + return value + } +} diff --git a/evals/intent-discovery/harness/intent-hooks/hook-io.mjs b/evals/intent-discovery/harness/intent-hooks/hook-io.mjs new file mode 100644 index 0000000..093df6c --- /dev/null +++ b/evals/intent-discovery/harness/intent-hooks/hook-io.mjs @@ -0,0 +1,54 @@ +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, +} from 'node:fs' +import { dirname } from 'node:path' + +const ATTRIB_FILE = process.env.INTENT_DISCOVERY_GATE_STATE + +export function readEventFromStdin() { + try { + return JSON.parse(readFileSync(0, 'utf8')) + } catch { + return {} + } +} + +export function appendObservation(observation) { + if (!ATTRIB_FILE) { + return + } + + try { + mkdirSync(dirname(ATTRIB_FILE), { recursive: true }) + appendFileSync( + ATTRIB_FILE, + `${JSON.stringify({ ts: new Date().toISOString(), ...observation })}\n`, + ) + } catch { + // Fail open: a hook must never brick the run. + } +} + +export function readObservations() { + if (!ATTRIB_FILE || !existsSync(ATTRIB_FILE)) { + return [] + } + + try { + return readFileSync(ATTRIB_FILE, 'utf8') + .split('\n') + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line)] + } catch { + return [] + } + }) + } catch { + return [] + } +} diff --git a/evals/intent-discovery/harness/prepare-copilot-home.ts b/evals/intent-discovery/harness/prepare-copilot-home.ts new file mode 100644 index 0000000..dc48ce3 --- /dev/null +++ b/evals/intent-discovery/harness/prepare-copilot-home.ts @@ -0,0 +1,60 @@ +import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const harnessDir = dirname(fileURLToPath(import.meta.url)) +const hooksSourceDir = join(harnessDir, 'intent-hooks') +const runsDir = join(dirname(harnessDir), 'runs') +const gateHomeDir = join(runsDir, '.copilot-homes', 'gate') +const gateStateDir = join(runsDir, 'latest', 'gate-state') + +export type GateRun = { + copilotHome: string + stateFile: string +} + +let builtGateHome: string | undefined + +export function prepareGateRun(runId: string): GateRun { + const copilotHome = buildGateHome() + + mkdirSync(gateStateDir, { recursive: true }) + const stateFile = join(gateStateDir, `${runId}.jsonl`) + rmSync(stateFile, { force: true }) + + return { copilotHome, stateFile } +} + +function buildGateHome(): string { + if (builtGateHome) { + return builtGateHome + } + + const realHome = join(homedir(), '.copilot') + + mkdirSync(join(gateHomeDir, 'hooks'), { recursive: true }) + copyIfPresent(join(realHome, 'config.json'), join(gateHomeDir, 'config.json')) + copyIfPresent( + join(realHome, 'permissions-config.json'), + join(gateHomeDir, 'permissions-config.json'), + ) + copyIfPresent(join(realHome, 'ide'), join(gateHomeDir, 'ide')) + + const command = `node ${join(hooksSourceDir, 'gate.mjs')}` + + writeFileSync( + join(gateHomeDir, 'hooks', 'hooks.json'), + `${JSON.stringify({ hooks: { PreToolUse: [{ command }] } }, null, 2)}\n`, + ) + + builtGateHome = gateHomeDir + + return gateHomeDir +} + +function copyIfPresent(source: string, destination: string): void { + if (existsSync(source)) { + cpSync(source, destination, { recursive: true }) + } +} diff --git a/evals/intent-discovery/harness/run-copilot-task.ts b/evals/intent-discovery/harness/run-copilot-task.ts index f1295aa..859af10 100644 --- a/evals/intent-discovery/harness/run-copilot-task.ts +++ b/evals/intent-discovery/harness/run-copilot-task.ts @@ -3,6 +3,8 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { spawn } from 'node:child_process' import { parseIntentCommand } from './parse-intent-commands' +import { prepareGateRun } from './prepare-copilot-home' +import type { GateRun } from './prepare-copilot-home' import type { IntentDiscoveryTask } from '../corpus/tasks' import type { NormalizedMessage, @@ -56,7 +58,12 @@ export async function runCopilotTask( throw new LiveCopilotRunnerUnavailableError() } - const result = await runCommand({ command, input }) + const gateState = + input.task.condition === 'hooked-intent' + ? prepareGateRun(sanitizeFileName(input.runId)) + : undefined + + const result = await runCommand({ command, input, gateState }) const transcript = transcriptFromCommandResult(result) const transcriptPath = writeTranscript(input.runId, transcript) const intentCommandCaptures = captureIntentCommands(transcript) @@ -123,9 +130,11 @@ type IntentCommandCapture = { async function runCommand({ command, input, + gateState, }: { command: string input: RunCopilotTaskInput + gateState?: GateRun }): Promise { return new Promise((resolve, reject) => { let settled = false @@ -134,6 +143,12 @@ async function runCommand({ shell: true, env: { ...process.env, + ...(gateState + ? { + COPILOT_HOME: gateState.copilotHome, + INTENT_DISCOVERY_GATE_STATE: gateState.stateFile, + } + : {}), INTENT_DISCOVERY_TASK_ID: input.task.id, INTENT_DISCOVERY_FIXTURE: input.task.fixture, INTENT_DISCOVERY_PROMPT: input.task.prompt, diff --git a/evals/intent-discovery/harness/setup-intent-condition.ts b/evals/intent-discovery/harness/setup-intent-condition.ts index 3ae9da5..c2b0f5e 100644 --- a/evals/intent-discovery/harness/setup-intent-condition.ts +++ b/evals/intent-discovery/harness/setup-intent-condition.ts @@ -116,7 +116,7 @@ function writeAgentsFile({ }): string { const agentsPath = join(workspacePath, 'AGENTS.md') const block = - condition === 'mapped-intent' + condition === 'mapped-intent' || condition === 'hooked-intent' ? mappedGuidanceBlock(expectedSkillAreas) : loadingGuidanceBlock() diff --git a/evals/intent-discovery/intent-hooks.eval.ts b/evals/intent-discovery/intent-hooks.eval.ts new file mode 100644 index 0000000..cccb3b3 --- /dev/null +++ b/evals/intent-discovery/intent-hooks.eval.ts @@ -0,0 +1,115 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + EDIT_TOOLS, + GATE_DENY_REASON, + gateDecision, + hasLoadFromObservations, + observationFromEvent, + parseIntentInvocation, +} from './harness/intent-hooks/hook-core.mjs' +import { applyIntentCondition } from './harness/setup-intent-condition' +import { prepareFixtureWorkspace } from './harness/prepare-fixture' + +describe('intent hook core', () => { + it('parses intent load and list invocations across runners', () => { + expect( + parseIntentInvocation( + 'npx @tanstack/intent@latest load @tanstack/router#routing', + ), + ).toEqual({ action: 'load', skillUse: '@tanstack/router#routing' }) + expect(parseIntentInvocation('intent list')).toEqual({ action: 'list' }) + expect( + parseIntentInvocation('cd packages/app && intent load @tanstack/x#y'), + ).toEqual({ action: 'load', skillUse: '@tanstack/x#y' }) + }) + + it('ignores non-intent commands and load without a skill use', () => { + expect(parseIntentInvocation('npm run build')).toBeUndefined() + expect(parseIntentInvocation('intent load')).toBeUndefined() + expect(parseIntentInvocation(undefined)).toBeUndefined() + }) + + it('observes intent commands only from Bash tool calls', () => { + expect( + observationFromEvent({ + tool_name: 'Bash', + tool_input: { command: 'intent load @tanstack/router#routing' }, + }), + ).toEqual({ + action: 'load', + skillUse: '@tanstack/router#routing', + raw: 'intent load @tanstack/router#routing', + }) + expect( + observationFromEvent({ + tool_name: 'Edit', + tool_input: { command: 'intent load @tanstack/router#routing' }, + }), + ).toBeUndefined() + expect( + observationFromEvent({ + tool_name: 'Bash', + tool_input: { command: 'echo hello' }, + }), + ).toBeUndefined() + }) + + it('denies edits until a load is observed, allows shell tools', () => { + expect(gateDecision({ toolName: 'Edit', hasLoaded: false })).toEqual({ + decision: 'deny', + reason: GATE_DENY_REASON, + }) + expect(gateDecision({ toolName: 'Write', hasLoaded: false })).toEqual({ + decision: 'deny', + reason: GATE_DENY_REASON, + }) + expect(gateDecision({ toolName: 'Edit', hasLoaded: true })).toEqual({ + decision: 'allow', + }) + expect(gateDecision({ toolName: 'Bash', hasLoaded: false })).toEqual({ + decision: 'allow', + }) + expect(EDIT_TOOLS.has('Write')).toBe(true) + expect(EDIT_TOOLS.has('Edit')).toBe(true) + }) + + it('detects a prior load from observation records', () => { + expect(hasLoadFromObservations([{ action: 'list' }])).toBe(false) + expect( + hasLoadFromObservations([{ action: 'list' }, { action: 'load' }]), + ).toBe(true) + }) + + it('keeps the deny reason free of parseable intent commands', () => { + expect(parseIntentInvocation(GATE_DENY_REASON)).toBeUndefined() + expect(/intent\s+(list|load)/i.test(GATE_DENY_REASON)).toBe(false) + }) +}) + +describe('hooked-intent condition setup', () => { + it('writes the mapped guidance block the gate points to', () => { + const prepared = prepareFixtureWorkspace({ fixture: 'router-basic' }) + + try { + applyIntentCondition({ + condition: 'hooked-intent', + expectedSkillAreas: ['router'], + workspacePath: prepared.workspacePath, + }) + const agents = readFileSync( + join(prepared.workspacePath, 'AGENTS.md'), + 'utf8', + ) + + 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() + } + }) +}) diff --git a/evals/intent-discovery/live-copilot-harness.eval.ts b/evals/intent-discovery/live-copilot-harness.eval.ts index 631f7cf..6b18200 100644 --- a/evals/intent-discovery/live-copilot-harness.eval.ts +++ b/evals/intent-discovery/live-copilot-harness.eval.ts @@ -97,17 +97,19 @@ describe('Intent discovery live Copilot harness', () => { rmSync(tempDir, { recursive: true, force: true }) } }) +}) +describe.concurrent('Intent discovery live runs', () => { for (const liveTask of liveTasks) { for (let runIndex = 1; runIndex <= liveRunCount; runIndex += 1) { it.skipIf(process.env.INTENT_DISCOVERY_RUN_LIVE !== '1')( `live/${liveTask.condition}/${liveTask.fixture}/run-${runIndex}`, - async (context) => { + async ({ task: contextTask, expect }) => { const task = liveRunTask(liveTask, runIndex) const result = await runLiveHarness(task) attachLiveEvalMetadata({ - contextTask: context.task, + contextTask, result, task, }) @@ -125,6 +127,7 @@ describe('Intent discovery live Copilot harness', () => { } }) + function liveRunCountFromEnv(): number { const value = Number(process.env.INTENT_DISCOVERY_RUN_COUNT ?? '1') diff --git a/evals/intent-discovery/vitest.evals.config.ts b/evals/intent-discovery/vitest.evals.config.ts index 28a386a..95e1c10 100644 --- a/evals/intent-discovery/vitest.evals.config.ts +++ b/evals/intent-discovery/vitest.evals.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ include: ['evals/intent-discovery/**/*.eval.ts'], testTimeout: 120_000, hookTimeout: 120_000, + maxConcurrency: liveConcurrencyFromEnv(), reporters: ['default'], env: { VITEST_EVALS_REPLAY_DIR: @@ -13,3 +14,13 @@ export default defineConfig({ }, }, }) + +function liveConcurrencyFromEnv(): number { + const raw = Number(process.env.INTENT_DISCOVERY_LIVE_CONCURRENCY ?? '1') + + if (!Number.isFinite(raw)) { + return 1 + } + + return Math.max(1, Math.trunc(raw)) +} From f0428884936e2994c5329e2329481034d2ad2c82 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 20 Jun 2026 21:13:08 -0700 Subject: [PATCH 04/13] Refactor install command to use guidance module for intent skills block handling --- packages/intent/src/commands/install.ts | 2 +- .../src/{commands/install-writer.ts => install/guidance.ts} | 0 packages/intent/tests/install-writer.test.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/intent/src/{commands/install-writer.ts => install/guidance.ts} (100%) diff --git a/packages/intent/src/commands/install.ts b/packages/intent/src/commands/install.ts index 020b0e9..b8dd869 100644 --- a/packages/intent/src/commands/install.ts +++ b/packages/intent/src/commands/install.ts @@ -13,7 +13,7 @@ import { resolveIntentSkillsBlockTargetPath, verifyIntentSkillsBlockFile, writeIntentSkillsBlock, -} from './install-writer.js' +} from '../install/guidance.js' import type { GlobalScanFlags } from '../cli-support.js' import type { IntentCoreOptions } from '../core.js' import type { ScanResult } from '../types.js' diff --git a/packages/intent/src/commands/install-writer.ts b/packages/intent/src/install/guidance.ts similarity index 100% rename from packages/intent/src/commands/install-writer.ts rename to packages/intent/src/install/guidance.ts diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index 370656e..e888a16 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -14,7 +14,7 @@ import { resolveIntentSkillsBlockTargetPath, verifyIntentSkillsBlockFile, writeIntentSkillsBlock, -} from '../src/commands/install-writer.js' +} from '../src/install/guidance.js' import type { IntentPackage, ScanResult, SkillEntry } from '../src/types.js' const tempDirs: Array = [] From 6aecea11116155ebdbfea753c2a652503e87a619 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 20 Jun 2026 21:15:49 -0700 Subject: [PATCH 05/13] Add hooks and policy for intent handling with agent-specific outputs --- packages/intent/src/hooks/agents/claude.ts | 25 ++++ packages/intent/src/hooks/agents/codex.ts | 25 ++++ packages/intent/src/hooks/agents/copilot.ts | 19 +++ packages/intent/src/hooks/policy.ts | 113 ++++++++++++++++++ packages/intent/src/hooks/types.ts | 21 ++++ packages/intent/tests/hooks.test.ts | 124 ++++++++++++++++++++ 6 files changed, 327 insertions(+) create mode 100644 packages/intent/src/hooks/agents/claude.ts create mode 100644 packages/intent/src/hooks/agents/codex.ts create mode 100644 packages/intent/src/hooks/agents/copilot.ts create mode 100644 packages/intent/src/hooks/policy.ts create mode 100644 packages/intent/src/hooks/types.ts create mode 100644 packages/intent/tests/hooks.test.ts diff --git a/packages/intent/src/hooks/agents/claude.ts b/packages/intent/src/hooks/agents/claude.ts new file mode 100644 index 0000000..4c387c8 --- /dev/null +++ b/packages/intent/src/hooks/agents/claude.ts @@ -0,0 +1,25 @@ +import type { HookDecision } from '../types.js' + +export type ClaudeHookOutput = { + hookSpecificOutput: { + hookEventName: 'PreToolUse' + permissionDecision: 'deny' + permissionDecisionReason: string + } +} + +export function formatClaudePreToolUseOutput( + decision: HookDecision, +): ClaudeHookOutput | undefined { + if (decision.decision === 'allow') { + return undefined + } + + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: decision.reason, + }, + } +} \ No newline at end of file diff --git a/packages/intent/src/hooks/agents/codex.ts b/packages/intent/src/hooks/agents/codex.ts new file mode 100644 index 0000000..e052633 --- /dev/null +++ b/packages/intent/src/hooks/agents/codex.ts @@ -0,0 +1,25 @@ +import type { HookDecision } from '../types.js' + +export type CodexHookOutput = { + hookSpecificOutput: { + hookEventName: 'PreToolUse' + permissionDecision: 'deny' + permissionDecisionReason: string + } +} + +export function formatCodexPreToolUseOutput( + decision: HookDecision, +): CodexHookOutput | undefined { + if (decision.decision === 'allow') { + return undefined + } + + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: decision.reason, + }, + } +} \ No newline at end of file diff --git a/packages/intent/src/hooks/agents/copilot.ts b/packages/intent/src/hooks/agents/copilot.ts new file mode 100644 index 0000000..92f9dfb --- /dev/null +++ b/packages/intent/src/hooks/agents/copilot.ts @@ -0,0 +1,19 @@ +import type { HookDecision } from '../types.js' + +export type CopilotHookOutput = { + permissionDecision: 'deny' + permissionDecisionReason: string +} + +export function formatCopilotPreToolUseOutput( + decision: HookDecision, +): CopilotHookOutput | undefined { + if (decision.decision === 'allow') { + return undefined + } + + return { + permissionDecision: 'deny', + permissionDecisionReason: decision.reason, + } +} \ No newline at end of file diff --git a/packages/intent/src/hooks/policy.ts b/packages/intent/src/hooks/policy.ts new file mode 100644 index 0000000..35bff7a --- /dev/null +++ b/packages/intent/src/hooks/policy.ts @@ -0,0 +1,113 @@ +import type { + HookAgent, + HookDecision, + IntentInvocation, + IntentObservation, + ToolEvent, +} from './types.js' + +const INTENT_COMMAND_PATTERN = + /(?:^|\s|&&|;|\|)\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 + +export const EDIT_TOOLS_BY_AGENT: Record> = { + claude: new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']), + codex: new Set(['apply_patch', 'Write', 'Edit']), + copilot: new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']), +} + +export const GATE_DENY_REASON = + 'Blocked: load the matching TanStack guidance before editing. Use the guidance command from the AGENTS.md tanstackIntent block, then retry the edit.' + +export function parseIntentInvocation( + command: unknown, +): IntentInvocation | undefined { + if (typeof command !== 'string') { + return undefined + } + + const match = command.match(INTENT_COMMAND_PATTERN) + + if (!match?.[1] || !match[2]) { + return undefined + } + + const action = match[2].toLowerCase() + + if (action !== 'list' && action !== 'load') { + return undefined + } + + const skillUse = action === 'load' ? match[3] : undefined + + if (action === 'load' && !skillUse) { + return undefined + } + + return action === 'load' ? { action, skillUse } : { action } +} + +export function observationFromEvent( + event: ToolEvent | undefined, +): IntentObservation | undefined { + if (!event || typeof event !== 'object') { + return undefined + } + + const toolName = event.tool_name ?? event.toolName + const toolInput = event.tool_input ?? event.toolArgs + + if (toolName !== 'Bash') { + return undefined + } + + const command = + typeof toolInput === 'string' + ? safeCommandFromString(toolInput) + : commandFromObject(toolInput) + + const parsed = parseIntentInvocation(command) + + if (!parsed || typeof command !== 'string') { + return undefined + } + + return { action: parsed.action, skillUse: parsed.skillUse, raw: command } +} + +export function gateDecision({ + agent, + hasLoaded, + toolName, +}: { + agent: HookAgent + hasLoaded: boolean + toolName: string +}): HookDecision { + if (EDIT_TOOLS_BY_AGENT[agent].has(toolName) && !hasLoaded) { + return { decision: 'deny', reason: GATE_DENY_REASON } + } + + return { decision: 'allow' } +} + +export function hasLoadFromObservations( + observations: Array | undefined>, +): boolean { + return observations.some((entry) => entry?.action === 'load') +} + +function commandFromObject(value: unknown): unknown { + return value && typeof value === 'object' + ? (value as { command?: unknown }).command + : undefined +} + +function safeCommandFromString(value: string): string { + try { + const parsed = JSON.parse(value) as unknown + const command = commandFromObject(parsed) + return typeof command === 'string' ? command : value + } catch { + return value + } +} \ No newline at end of file diff --git a/packages/intent/src/hooks/types.ts b/packages/intent/src/hooks/types.ts new file mode 100644 index 0000000..c3f56e3 --- /dev/null +++ b/packages/intent/src/hooks/types.ts @@ -0,0 +1,21 @@ +export type HookAgent = 'claude' | 'codex' | 'copilot' + +export type IntentInvocation = { + action: 'list' | 'load' + skillUse?: string +} + +export type IntentObservation = IntentInvocation & { + raw: string +} + +export type HookDecision = + | { decision: 'allow' } + | { decision: 'deny'; reason: string } + +export type ToolEvent = { + tool_name?: unknown + toolName?: unknown + tool_input?: unknown + toolArgs?: unknown +} \ No newline at end of file diff --git a/packages/intent/tests/hooks.test.ts b/packages/intent/tests/hooks.test.ts new file mode 100644 index 0000000..4e1eeb5 --- /dev/null +++ b/packages/intent/tests/hooks.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { formatClaudePreToolUseOutput } from '../src/hooks/agents/claude.js' +import { formatCodexPreToolUseOutput } from '../src/hooks/agents/codex.js' +import { formatCopilotPreToolUseOutput } from '../src/hooks/agents/copilot.js' +import { + EDIT_TOOLS_BY_AGENT, + GATE_DENY_REASON, + gateDecision, + hasLoadFromObservations, + observationFromEvent, + parseIntentInvocation, +} from '../src/hooks/policy.js' + +describe('intent hook policy', () => { + it('parses intent load and list invocations across runners', () => { + expect( + parseIntentInvocation( + 'npx @tanstack/intent@latest load @tanstack/router#routing', + ), + ).toEqual({ action: 'load', skillUse: '@tanstack/router#routing' }) + expect(parseIntentInvocation('intent list')).toEqual({ action: 'list' }) + expect( + parseIntentInvocation('cd packages/app && intent load @tanstack/x#y'), + ).toEqual({ action: 'load', skillUse: '@tanstack/x#y' }) + }) + + it('ignores non-intent commands and incomplete load commands', () => { + expect(parseIntentInvocation('npm run build')).toBeUndefined() + expect(parseIntentInvocation('intent load')).toBeUndefined() + expect(parseIntentInvocation(undefined)).toBeUndefined() + }) + + it('observes intent commands only from Bash tool calls', () => { + expect( + observationFromEvent({ + tool_name: 'Bash', + tool_input: { command: 'intent load @tanstack/router#routing' }, + }), + ).toEqual({ + action: 'load', + skillUse: '@tanstack/router#routing', + raw: 'intent load @tanstack/router#routing', + }) + expect( + observationFromEvent({ + toolName: 'Bash', + toolArgs: JSON.stringify({ command: 'intent list' }), + }), + ).toEqual({ action: 'list', raw: 'intent list', skillUse: undefined }) + expect( + observationFromEvent({ + tool_name: 'Edit', + tool_input: { command: 'intent load @tanstack/router#routing' }, + }), + ).toBeUndefined() + }) + + it('denies edit tools until a load is observed', () => { + expect( + gateDecision({ agent: 'copilot', toolName: 'Edit', hasLoaded: false }), + ).toEqual({ decision: 'deny', reason: GATE_DENY_REASON }) + expect( + gateDecision({ agent: 'claude', toolName: 'Write', hasLoaded: false }), + ).toEqual({ decision: 'deny', reason: GATE_DENY_REASON }) + expect( + gateDecision({ agent: 'codex', toolName: 'apply_patch', hasLoaded: false }), + ).toEqual({ decision: 'deny', reason: GATE_DENY_REASON }) + expect( + gateDecision({ agent: 'copilot', toolName: 'Edit', hasLoaded: true }), + ).toEqual({ decision: 'allow' }) + expect( + gateDecision({ agent: 'codex', toolName: 'Bash', hasLoaded: false }), + ).toEqual({ decision: 'allow' }) + expect(EDIT_TOOLS_BY_AGENT.copilot.has('Write')).toBe(true) + expect(EDIT_TOOLS_BY_AGENT.claude.has('Edit')).toBe(true) + 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) + expect( + hasLoadFromObservations([{ action: 'list' }, { action: 'load' }]), + ).toBe(true) + }) + + it('keeps the deny reason free of parseable intent commands', () => { + expect(parseIntentInvocation(GATE_DENY_REASON)).toBeUndefined() + expect(/intent\s+(list|load)/i.test(GATE_DENY_REASON)).toBe(false) + }) +}) + +describe('intent hook agent adapters', () => { + const deny = { decision: 'deny' as const, reason: GATE_DENY_REASON } + + it('formats Copilot PreToolUse denial output', () => { + expect(formatCopilotPreToolUseOutput(deny)).toEqual({ + permissionDecision: 'deny', + permissionDecisionReason: GATE_DENY_REASON, + }) + expect(formatCopilotPreToolUseOutput({ decision: 'allow' })).toBeUndefined() + }) + + it('formats Claude PreToolUse denial output', () => { + expect(formatClaudePreToolUseOutput(deny)).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: GATE_DENY_REASON, + }, + }) + expect(formatClaudePreToolUseOutput({ decision: 'allow' })).toBeUndefined() + }) + + it('formats Codex PreToolUse denial output', () => { + expect(formatCodexPreToolUseOutput(deny)).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: GATE_DENY_REASON, + }, + }) + expect(formatCodexPreToolUseOutput({ decision: 'allow' })).toBeUndefined() + }) +}) \ No newline at end of file From 72694876607db024725ce7a6f674bf920260c009 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 20 Jun 2026 21:23:52 -0700 Subject: [PATCH 06/13] Add support for agent hooks in install command with project/user scope --- packages/intent/src/cli.ts | 7 +- packages/intent/src/commands/install.ts | 22 + packages/intent/src/hooks/install.ts | 528 ++++++++++++++++++++ packages/intent/tests/cli.test.ts | 52 ++ packages/intent/tests/hooks-install.test.ts | 225 +++++++++ 5 files changed, 833 insertions(+), 1 deletion(-) create mode 100644 packages/intent/src/hooks/install.ts create mode 100644 packages/intent/tests/hooks-install.test.ts diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 481590e..6d00778 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -109,9 +109,12 @@ function createCli(): CAC { 'Create or update skill loading guidance in an agent config file', ) .usage( - 'install [--map] [--dry-run] [--print-prompt] [--global] [--global-only] [--no-notices]', + 'install [--map] [--hooks] [--scope project|user] [--agents copilot,claude,codex|all] [--dry-run] [--print-prompt] [--global] [--global-only] [--no-notices]', ) .option('--map', 'Write explicit skill-to-task mappings') + .option('--hooks', 'Install agent hooks that enforce skill loading') + .option('--scope ', 'Hook install scope: project or user') + .option('--agents ', 'Hook agents: copilot,claude,codex, or all') .option('--dry-run', 'Print the generated block without writing') .option( '--print-prompt', @@ -122,6 +125,8 @@ function createCli(): CAC { .option('--no-notices', 'Suppress non-critical notices on stderr') .example('install') .example('install --map') + .example('install --hooks') + .example('install --hooks --scope user --agents copilot') .example('install --dry-run') .example('install --print-prompt') .example('install --global') diff --git a/packages/intent/src/commands/install.ts b/packages/intent/src/commands/install.ts index b8dd869..195b61b 100644 --- a/packages/intent/src/commands/install.ts +++ b/packages/intent/src/commands/install.ts @@ -14,6 +14,7 @@ import { verifyIntentSkillsBlockFile, writeIntentSkillsBlock, } from '../install/guidance.js' +import { formatHookInstallResult, runInstallHooks } from '../hooks/install.js' import type { GlobalScanFlags } from '../cli-support.js' import type { IntentCoreOptions } from '../core.js' import type { ScanResult } from '../types.js' @@ -121,9 +122,12 @@ skills: - The verification result` export interface InstallCommandOptions extends GlobalScanFlags { + agents?: string dryRun?: boolean + hooks?: boolean map?: boolean printPrompt?: boolean + scope?: string } function formatTargetPath(targetPath: string): string { @@ -193,6 +197,22 @@ function printWriteResult({ } } +function installHooks(options: InstallCommandOptions): void { + if (!options.hooks || options.dryRun) { + return + } + + const results = runInstallHooks({ + agents: options.agents, + root: process.cwd(), + scope: options.scope, + }) + + for (const result of results) { + console.log(formatHookInstallResult(result)) + } +} + export async function runInstallCommand( options: InstallCommandOptions, scanIntentsOrFail: (coreOptions?: IntentCoreOptions) => Promise, @@ -246,6 +266,7 @@ export async function runInstallCommand( printWriteResult(result) printPlacementTip(result.targetPath) + installHooks(options) return } @@ -308,6 +329,7 @@ export async function runInstallCommand( printWriteResult(result) printPlacementTip(result.targetPath) + installHooks(options) printWarnings(scanResult.warnings) printNotices(scanResult.notices, noticeOptions) diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts new file mode 100644 index 0000000..f2dafac --- /dev/null +++ b/packages/intent/src/hooks/install.ts @@ -0,0 +1,528 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join, relative } from 'node:path' +import { fail } from '../cli-error.js' +import { EDIT_TOOLS_BY_AGENT, GATE_DENY_REASON } from './policy.js' +import type { HookAgent } from './types.js' + +export type HookInstallScope = 'project' | 'user' + +export type HookInstallStatus = 'created' | 'skipped' | 'unchanged' | 'updated' + +export type HookInstallResult = { + agent: HookAgent + configPath: string | null + scope: HookInstallScope + scriptPath: string | null + status: HookInstallStatus + reason?: string +} + +export type InstallHooksOptions = { + agents?: string + copilotHome?: string + homeDir?: string + root: string + scope?: string +} + +const ALL_HOOK_AGENTS: Array = ['copilot', 'claude', 'codex'] +const PROJECT_HOOK_AGENTS = new Set(['claude', 'codex']) +const HOOK_SCRIPT_DIR = '.intent/hooks' +const STATUS_MESSAGE = 'Checking Intent guidance' + +export function runInstallHooks({ + agents, + copilotHome, + homeDir = homedir(), + root, + scope, +}: InstallHooksOptions): Array { + const resolvedScope = parseScope(scope) + const resolvedAgents = parseAgents(agents) + + return resolvedAgents.map((agent) => + installAgentHook({ + agent, + copilotHome, + homeDir, + root, + scope: resolvedScope, + }), + ) +} + +export function buildHookRunnerScript(agent: HookAgent): string { + const editTools = [...EDIT_TOOLS_BY_AGENT[agent]].sort() + + return `#!/usr/bin/env node +import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { createHash } from 'node:crypto' + +const AGENT = ${JSON.stringify(agent)} +const EDIT_TOOLS = new Set(${JSON.stringify(editTools)}) +const GATE_DENY_REASON = ${JSON.stringify(GATE_DENY_REASON)} +const INTENT_COMMAND_PATTERN = /(?:^|\\s|&&|;|\\|)\\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 + +try { + const event = readEventFromStdin() + const stateFile = stateFileForEvent(event) + const observation = observationFromEvent(event) + + if (observation) { + appendObservation(stateFile, observation) + } + + const toolName = event?.tool_name ?? event?.toolName + if (typeof toolName === 'string' && EDIT_TOOLS.has(toolName) && !hasLoad(stateFile)) { + process.stdout.write(JSON.stringify(denyOutput())) + } +} catch { +} + +process.exit(0) + +function readEventFromStdin() { + try { + return JSON.parse(readFileSync(0, 'utf8')) + } catch { + return {} + } +} + +function stateFileForEvent(event) { + const sessionId = typeof event?.session_id === 'string' ? event.session_id : 'unknown' + const cwd = typeof event?.cwd === 'string' ? event.cwd : process.cwd() + const key = createHash('sha256').update(AGENT + '\\0' + cwd + '\\0' + sessionId).digest('hex') + return join(tmpdir(), 'tanstack-intent-hooks', key + '.jsonl') +} + +function observationFromEvent(event) { + if (!event || typeof event !== 'object') return undefined + const toolName = event.tool_name ?? event.toolName + const toolInput = event.tool_input ?? event.toolArgs + if (toolName !== 'Bash') return undefined + const command = typeof toolInput === 'string' ? safeCommandFromString(toolInput) : commandFromObject(toolInput) + const parsed = parseIntentInvocation(command) + if (!parsed || typeof command !== 'string') return undefined + return { action: parsed.action, skillUse: parsed.skillUse, raw: command } +} + +function parseIntentInvocation(command) { + if (typeof command !== 'string') return undefined + const match = command.match(INTENT_COMMAND_PATTERN) + if (!match?.[1] || !match[2]) return undefined + const action = match[2].toLowerCase() + if (action !== 'list' && action !== 'load') return undefined + const skillUse = action === 'load' ? match[3] : undefined + if (action === 'load' && !skillUse) return undefined + return action === 'load' ? { action, skillUse } : { action } +} + +function commandFromObject(value) { + return value && typeof value === 'object' ? value.command : undefined +} + +function safeCommandFromString(value) { + try { + const command = commandFromObject(JSON.parse(value)) + return typeof command === 'string' ? command : value + } catch { + return value + } +} + +function appendObservation(stateFile, observation) { + try { + mkdirSync(dirname(stateFile), { recursive: true }) + appendFileSync(stateFile, JSON.stringify({ ts: new Date().toISOString(), ...observation }) + '\\n') + } catch { + } +} + +function hasLoad(stateFile) { + if (!existsSync(stateFile)) return false + try { + return readFileSync(stateFile, 'utf8') + .split('\\n') + .filter(Boolean) + .some((line) => { + try { + return JSON.parse(line).action === 'load' + } catch { + return false + } + }) + } catch { + return false + } +} + +function denyOutput() { + if (AGENT === 'copilot') { + return { permissionDecision: 'deny', permissionDecisionReason: GATE_DENY_REASON } + } + + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: GATE_DENY_REASON, + }, + } +} +` +} + +export function formatHookInstallResult(result: HookInstallResult): string { + if (result.status === 'skipped') { + return `Skipped Intent hooks for ${result.agent}: ${result.reason}` + } + + const target = result.configPath + ? formatPath(result.configPath) + : result.agent + switch (result.status) { + case 'created': + return `Installed Intent hooks for ${result.agent} (${result.scope}) in ${target}.` + case 'updated': + return `Updated Intent hooks for ${result.agent} (${result.scope}) in ${target}.` + case 'unchanged': + return `No changes to Intent hooks for ${result.agent} (${result.scope}); already current.` + } +} + +function installAgentHook({ + agent, + copilotHome, + homeDir, + root, + scope, +}: { + agent: HookAgent + copilotHome?: string + homeDir: string + root: string + scope: HookInstallScope +}): HookInstallResult { + if (scope === 'project' && !PROJECT_HOOK_AGENTS.has(agent)) { + return { + agent, + configPath: null, + reason: 'project scope is not supported; use --scope user', + scope, + scriptPath: null, + status: 'skipped', + } + } + + switch (agent) { + case 'claude': + return installClaudeHook({ agent, homeDir, root, scope }) + case 'codex': + return installCodexHook({ agent, homeDir, root, scope }) + case 'copilot': + return installCopilotHook({ agent, copilotHome, homeDir, scope }) + } +} + +function installClaudeHook({ + agent, + homeDir, + root, + scope, +}: { + agent: HookAgent + homeDir: string + root: string + scope: HookInstallScope +}): HookInstallResult { + const project = scope === 'project' + const scriptPath = project + ? join(root, HOOK_SCRIPT_DIR, 'intent-claude-gate.mjs') + : join(homeDir, '.tanstack', 'intent', 'hooks', 'intent-claude-gate.mjs') + const configPath = project + ? join(root, '.claude', 'settings.json') + : join(homeDir, '.claude', 'settings.json') + const scriptStatus = writeIfChanged(scriptPath, buildHookRunnerScript(agent)) + const configStatus = updateJsonConfig(configPath, (config) => + upsertClaudePreToolUseHook(config, project, scriptPath), + ) + + return hookInstallResult({ + agent, + configPath, + scope, + scriptPath, + scriptStatus, + configStatus, + }) +} + +function installCodexHook({ + agent, + homeDir, + root, + scope, +}: { + agent: HookAgent + homeDir: string + root: string + scope: HookInstallScope +}): HookInstallResult { + const project = scope === 'project' + const scriptPath = project + ? join(root, HOOK_SCRIPT_DIR, 'intent-codex-gate.mjs') + : join(homeDir, '.tanstack', 'intent', 'hooks', 'intent-codex-gate.mjs') + const configPath = project + ? join(root, '.codex', 'hooks.json') + : join(homeDir, '.codex', 'hooks.json') + const scriptStatus = writeIfChanged(scriptPath, buildHookRunnerScript(agent)) + const configStatus = updateJsonConfig(configPath, (config) => + upsertCodexPreToolUseHook(config, project, scriptPath), + ) + + return hookInstallResult({ + agent, + configPath, + scope, + scriptPath, + scriptStatus, + configStatus, + }) +} + +function installCopilotHook({ + agent, + copilotHome, + homeDir, + scope, +}: { + agent: HookAgent + copilotHome?: string + homeDir: string + scope: HookInstallScope +}): HookInstallResult { + const resolvedCopilotHome = + copilotHome ?? process.env.COPILOT_HOME ?? join(homeDir, '.copilot') + const scriptPath = join( + homeDir, + '.tanstack', + 'intent', + 'hooks', + 'intent-copilot-gate.mjs', + ) + const configPath = join(resolvedCopilotHome, 'hooks', 'hooks.json') + const scriptStatus = writeIfChanged(scriptPath, buildHookRunnerScript(agent)) + const configStatus = updateJsonConfig(configPath, (config) => + upsertCopilotPreToolUseHook(config, scriptPath), + ) + + return hookInstallResult({ + agent, + configPath, + scope, + scriptPath, + scriptStatus, + configStatus, + }) +} + +function hookInstallResult({ + agent, + configPath, + configStatus, + scope, + scriptPath, + scriptStatus, +}: { + agent: HookAgent + configPath: string + configStatus: HookInstallStatus + scope: HookInstallScope + scriptPath: string + scriptStatus: HookInstallStatus +}): HookInstallResult { + return { + agent, + configPath, + scope, + scriptPath, + status: + scriptStatus === 'created' || configStatus === 'created' + ? 'created' + : scriptStatus === 'updated' || configStatus === 'updated' + ? 'updated' + : 'unchanged', + } +} + +function upsertClaudePreToolUseHook( + config: Record, + project: boolean, + scriptPath: string, +): Record { + const hooks = objectValue(config.hooks) + hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { + matcher: 'Bash|Write|Edit|MultiEdit|NotebookEdit', + hooks: [ + { + type: 'command', + command: 'node', + args: [ + project + ? '${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs' + : scriptPath, + ], + timeout: 10, + statusMessage: STATUS_MESSAGE, + }, + ], + }) + return { ...config, hooks } +} + +function upsertCodexPreToolUseHook( + config: Record, + project: boolean, + scriptPath: string, +): Record { + const hooks = objectValue(config.hooks) + hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { + matcher: 'Bash|apply_patch|Edit|Write', + hooks: [ + { + type: 'command', + command: project + ? 'node "$(git rev-parse --show-toplevel)/.intent/hooks/intent-codex-gate.mjs"' + : `node ${quoteShell(scriptPath)}`, + timeout: 10, + statusMessage: STATUS_MESSAGE, + }, + ], + }) + return { ...config, hooks } +} + +function upsertCopilotPreToolUseHook( + config: Record, + scriptPath: string, +): Record { + const hooks = objectValue(config.hooks) + hooks.PreToolUse = upsertHookGroup(arrayValue(hooks.PreToolUse), { + command: `node ${quoteShell(scriptPath)}`, + }) + return { ...config, hooks } +} + +function upsertHookGroup( + groups: Array, + nextGroup: Record, +): Array { + return [...groups.filter((group) => !containsIntentHook(group)), nextGroup] +} + +function containsIntentHook(value: unknown): boolean { + if (!value || typeof value !== 'object') return false + const hooks = arrayValue((value as { hooks?: unknown }).hooks) + return hooks.some( + (hook) => + JSON.stringify(hook).includes('intent-') && + JSON.stringify(hook).includes('-gate.mjs'), + ) +} + +function updateJsonConfig( + filePath: string, + update: (config: Record) => Record, +): HookInstallStatus { + const existed = existsSync(filePath) + const current = existed ? readFileSync(filePath, 'utf8') : '' + const parsed = current.trim() ? parseJsonObject(filePath, current) : {} + const next = `${JSON.stringify(update(parsed), null, 2)}\n` + + if (current === next) { + return 'unchanged' + } + + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, next) + return existed ? 'updated' : 'created' +} + +function writeIfChanged(filePath: string, content: string): HookInstallStatus { + const existed = existsSync(filePath) + if (existed && readFileSync(filePath, 'utf8') === content) { + return 'unchanged' + } + + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, content) + return existed ? 'updated' : 'created' +} + +function parseAgents(value: string | undefined): Array { + if (!value || value === 'all') { + return ALL_HOOK_AGENTS + } + + const agents = value + .split(',') + .map((agent) => agent.trim()) + .filter(Boolean) + const invalid = agents.filter( + (agent) => !ALL_HOOK_AGENTS.includes(agent as HookAgent), + ) + + if (invalid.length > 0) { + fail( + `Unknown hook agent: ${invalid.join(', ')}. Expected copilot, claude, codex, or all.`, + ) + } + + return [...new Set(agents as Array)] +} + +function parseScope(value: string | undefined): HookInstallScope { + if (!value) return 'project' + if (value === 'project' || value === 'user') return value + fail(`Unknown hook scope: ${value}. Expected project or user.`) +} + +function parseJsonObject( + filePath: string, + content: string, +): Record { + try { + const parsed = JSON.parse(content) as unknown + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + } catch (err) { + fail( + `Failed to parse ${formatPath(filePath)}: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + fail(`Failed to parse ${formatPath(filePath)}: expected a JSON object.`) +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? { ...(value as Record) } + : {} +} + +function arrayValue(value: unknown): Array { + return Array.isArray(value) ? value : [] +} + +function quoteShell(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function formatPath(filePath: string): string { + return relative(process.cwd(), filePath) || filePath +} diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 8cde72d..0616718 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -446,6 +446,58 @@ describe('cli commands', () => { ) }) + it('writes skill loading guidance and project hooks with --hooks', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-hooks-')) + tempDirs.push(root) + process.chdir(root) + + const exitCode = await main([ + 'install', + '--hooks', + '--agents', + 'claude,codex', + ]) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('Created AGENTS.md with skill loading guidance.') + expect(output).toContain('Installed Intent hooks for claude (project)') + expect(output).toContain('Installed Intent hooks for codex (project)') + expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(true) + expect(existsSync(join(root, '.codex', 'hooks.json'))).toBe(true) + expect( + existsSync(join(root, '.intent', 'hooks', 'intent-claude-gate.mjs')), + ).toBe(true) + }) + + it('fails cleanly for invalid install hook options', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-hooks-bad-')) + tempDirs.push(root) + process.chdir(root) + + const badAgentExitCode = await main([ + 'install', + '--hooks', + '--agents', + 'wat', + ]) + const badScopeExitCode = await main([ + 'install', + '--hooks', + '--scope', + 'repo', + ]) + + expect(badAgentExitCode).toBe(1) + expect(badScopeExitCode).toBe(1) + expect(errorSpy).toHaveBeenCalledWith( + 'Unknown hook agent: wat. Expected copilot, claude, codex, or all.', + ) + expect(errorSpy).toHaveBeenCalledWith( + 'Unknown hook scope: repo. Expected project or user.', + ) + }) + it('writes install mappings with --map and is idempotent', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-map-')) const isolatedGlobalRoot = mkdtempSync( diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts new file mode 100644 index 0000000..3e53de8 --- /dev/null +++ b/packages/intent/tests/hooks-install.test.ts @@ -0,0 +1,225 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { afterEach, describe, expect, it } from 'vitest' +import { + buildHookRunnerScript, + formatHookInstallResult, + runInstallHooks, +} from '../src/hooks/install.js' + +const tempDirs: Array = [] + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tempRoot(name: string): string { + const root = mkdtempSync(join(tmpdir(), name)) + tempDirs.push(root) + return root +} + +function readJson(filePath: string): Record { + return JSON.parse(readFileSync(filePath, 'utf8')) as Record +} + +describe('hook installer', () => { + it('installs project-scoped Claude and Codex hooks and skips Copilot', () => { + const root = tempRoot('intent-hooks-project-') + + const results = runInstallHooks({ root, scope: 'project' }) + + expect(results.map((result) => result.agent)).toEqual([ + 'copilot', + 'claude', + 'codex', + ]) + expect(results.find((result) => result.agent === 'copilot')).toMatchObject({ + status: 'skipped', + reason: 'project scope is not supported; use --scope user', + }) + expect(results.find((result) => result.agent === 'claude')).toMatchObject({ + status: 'created', + scope: 'project', + }) + expect(results.find((result) => result.agent === 'codex')).toMatchObject({ + status: 'created', + scope: 'project', + }) + + const claudeConfig = readJson(join(root, '.claude', 'settings.json')) + expect(claudeConfig.hooks.PreToolUse).toHaveLength(1) + expect(claudeConfig.hooks.PreToolUse[0].matcher).toBe( + 'Bash|Write|Edit|MultiEdit|NotebookEdit', + ) + expect(claudeConfig.hooks.PreToolUse[0].hooks[0]).toMatchObject({ + command: 'node', + args: ['${CLAUDE_PROJECT_DIR}/.intent/hooks/intent-claude-gate.mjs'], + type: 'command', + }) + + const codexConfig = readJson(join(root, '.codex', 'hooks.json')) + expect(codexConfig.hooks.PreToolUse[0].matcher).toBe( + 'Bash|apply_patch|Edit|Write', + ) + expect(codexConfig.hooks.PreToolUse[0].hooks[0].command).toContain( + '.intent/hooks/intent-codex-gate.mjs', + ) + expect( + existsSync(join(root, '.intent', 'hooks', 'intent-claude-gate.mjs')), + ).toBe(true) + expect( + existsSync(join(root, '.intent', 'hooks', 'intent-codex-gate.mjs')), + ).toBe(true) + }) + + it('installs user-scoped Copilot hooks into the selected home', () => { + const root = tempRoot('intent-hooks-root-') + const homeDir = tempRoot('intent-hooks-home-') + const copilotHome = join(homeDir, '.custom-copilot') + + const [result] = runInstallHooks({ + agents: 'copilot', + copilotHome, + homeDir, + root, + scope: 'user', + }) + + expect(result).toMatchObject({ agent: 'copilot', status: 'created' }) + const config = readJson(join(copilotHome, 'hooks', 'hooks.json')) + const command = config.hooks.PreToolUse[0].command as string + + expect(command).toContain(join(homeDir, '.tanstack')) + expect(command).toContain('intent-copilot-gate.mjs') + expect( + existsSync( + join( + homeDir, + '.tanstack', + 'intent', + 'hooks', + 'intent-copilot-gate.mjs', + ), + ), + ).toBe(true) + }) + + it('updates only the Intent hook group on repeated installs', () => { + const root = tempRoot('intent-hooks-update-') + const settingsPath = join(root, '.claude', 'settings.json') + mkdirSync(join(root, '.claude'), { recursive: true }) + writeFileSync( + settingsPath, + JSON.stringify( + { + hooks: { + PreToolUse: [ + { + matcher: 'Bash', + hooks: [{ type: 'command', command: 'echo keep' }], + }, + { + matcher: 'Edit', + hooks: [ + { + type: 'command', + command: 'node old-intent-claude-gate.mjs', + }, + ], + }, + ], + }, + }, + null, + 2, + ) + '\n', + ) + + runInstallHooks({ agents: 'claude', root, scope: 'project' }) + const second = runInstallHooks({ agents: 'claude', root, scope: 'project' }) + + const config = readJson(settingsPath) + expect(config.hooks.PreToolUse).toHaveLength(2) + expect(config.hooks.PreToolUse[0].hooks[0].command).toBe('echo keep') + expect(second[0]).toMatchObject({ status: 'unchanged' }) + }) + + it('builds a runner script with command-free denial text', () => { + const script = buildHookRunnerScript('claude') + + expect(script).toContain('const AGENT = "claude"') + expect(script).toContain('permissionDecision') + expect(script).not.toMatch(/Blocked:.*intent\s+(list|load)/i) + }) + + it('runs the generated gate script through the load then edit cycle', () => { + const root = tempRoot('intent-hooks-runner-') + const scriptPath = join(root, 'intent-claude-gate.mjs') + writeFileSync(scriptPath, buildHookRunnerScript('claude')) + + const beforeLoad = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-a', + tool_name: 'Edit', + tool_input: { file_path: join(root, 'src.ts') }, + }) + const load = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-a', + tool_name: 'Bash', + tool_input: { command: 'intent load @tanstack/router#routing' }, + }) + const afterLoad = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-a', + tool_name: 'Edit', + tool_input: { file_path: join(root, 'src.ts') }, + }) + + expect(beforeLoad.status).toBe(0) + expect(JSON.parse(beforeLoad.stdout)).toMatchObject({ + hookSpecificOutput: { permissionDecision: 'deny' }, + }) + expect(load.status).toBe(0) + expect(load.stdout).toBe('') + expect(afterLoad.status).toBe(0) + expect(afterLoad.stdout).toBe('') + }) + + it('formats skipped install results', () => { + expect( + formatHookInstallResult({ + agent: 'copilot', + configPath: null, + reason: 'project scope is not supported; use --scope user', + scope: 'project', + scriptPath: null, + status: 'skipped', + }), + ).toBe( + 'Skipped Intent hooks for copilot: project scope is not supported; use --scope user', + ) + }) +}) + +function runHookScript(scriptPath: string, event: Record) { + return spawnSync(process.execPath, [scriptPath], { + encoding: 'utf8', + input: JSON.stringify(event), + }) +} From de13847734f085fc2e8bc482a9679291f017d5d1 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 20 Jun 2026 21:31:09 -0700 Subject: [PATCH 07/13] Enhance intent installation with lifecycle hooks support and update documentation - Added `--hooks` option to `intent install` for lifecycle hook installation. - Updated documentation to reflect new hook options and usage. - Introduced hook adapters for Claude, Codex, and Copilot with project/user scope support. - Implemented tests to verify supported scopes in the adapter registry. --- docs/cli/intent-install.md | 86 +++++++++-- docs/getting-started/quick-start-consumers.md | 22 ++- docs/overview.md | 9 ++ packages/intent/src/hooks/adapters.ts | 93 ++++++++++++ packages/intent/src/hooks/install.ts | 142 +++++------------- packages/intent/src/hooks/types.ts | 2 + packages/intent/tests/hooks-install.test.ts | 10 ++ 7 files changed, 241 insertions(+), 123 deletions(-) create mode 100644 packages/intent/src/hooks/adapters.ts diff --git a/docs/cli/intent-install.md b/docs/cli/intent-install.md index 8d444e0..49aac6d 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -3,15 +3,18 @@ title: intent install id: intent-install --- -`intent install` creates or updates an `intent-skills` guidance block in a project guidance file. +`intent install` creates or updates an `intent-skills` guidance block in a project guidance file. Pass `--hooks` to also install lifecycle hooks that enforce loading matching guidance before edits in supported agents. ```bash -npx @tanstack/intent@latest install [--map] [--dry-run] [--print-prompt] [--global] [--global-only] [--no-notices] +npx @tanstack/intent@latest install [--map] [--hooks] [--scope project|user] [--agents copilot,claude,codex|all] [--dry-run] [--print-prompt] [--global] [--global-only] [--no-notices] ``` ## Options - `--map`: write explicit task-to-skill mappings instead of lightweight loading guidance +- `--hooks`: install agent lifecycle hooks that block edits until matching Intent guidance is loaded +- `--scope `: hook install scope, either `project` or `user`; defaults to `project` +- `--agents `: comma-separated hook agents to configure (`copilot`, `claude`, `codex`) or `all`; defaults to `all` - `--dry-run`: print the generated block without writing files - `--print-prompt`: print the agent setup prompt instead of writing files - `--global`: include global packages after project packages when `--map` is passed @@ -24,10 +27,11 @@ 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. +- Installs hook enforcement only when `--hooks` is passed. - 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. @@ -41,9 +45,10 @@ The default block tells agents to discover skills and load matching guidance on ## 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 #` 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 #` 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. @@ -51,21 +56,72 @@ Before substantial work: ## Mapping output -`--map` writes compact skill identities: +`--map` writes compact skill identities and commands: ```yaml -# Skill mappings - load `use` with `npx @tanstack/intent@latest load `. -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" ``` -- `when`: task-routing phrase for agents -- `use`: portable skill identity in `#` format +- `id`: portable skill identity in `#` 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 +## Hook enforcement + +`--hooks` installs local lifecycle hooks that observe `intent load` commands and block edit tools until guidance has been loaded in the current agent session. + +```bash +npx @tanstack/intent@latest install --hooks +npx @tanstack/intent@latest install --hooks --agents claude,codex +npx @tanstack/intent@latest install --hooks --scope user --agents copilot +``` + +Default hook behavior: + +- `--scope project` is the default. It writes project-local hook config for agents that support it. +- `--agents all` is the default. In project scope, Copilot is skipped because the supported Copilot CLI hook location is user-scoped. +- `--scope user` writes user-level agent config and stores runner scripts under `~/.tanstack/intent/hooks`. +- `--dry-run` prints the generated guidance block and does not write hook config. + +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 | Not supported | `$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 | + +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. + +## Add another coding platform + +Intent can support any coding agent that exposes a lifecycle hook before file edits or shell commands run. A good platform adapter needs three pieces: + +1. A hook event that runs before edits, ideally before tools like `Write`, `Edit`, `apply_patch`, or notebook edits execute. +2. Access to the pending tool name and command or edit input, so the hook can observe `intent load #` and block edits until a load has happened in the session. +3. A documented way to return a blocking decision with a message the agent can see. + +The shared policy is intentionally small: observe `intent load`, remember that the current session loaded guidance, and deny edit tools until that happens. Platform adapters should only translate that policy into the platform's hook config, event shape, and denial response. + +If your coding platform has a compatible hook API, open a PR with: + +- an adapter entry in `packages/intent/src/hooks/adapters.ts` +- config generation in `packages/intent/src/hooks/install.ts` +- tests for config generation and deny/allow behavior +- a row in the support table above +- links to the platform's public hook documentation + +If the platform only supports prompt instructions, use the `intent-skills` guidance block instead of claiming hook enforcement. + ## Status messages - Created: `Created AGENTS.md with 1 mapping.` @@ -75,6 +131,8 @@ skills: - Guidance unchanged: `No changes to AGENTS.md; skill loading guidance already current.` - Placement tip: `Tip: Keep the intent-skills block near the top of AGENTS.md so agents read it before task-specific instructions.` - No actionable skills in `--map` mode: `No intent-enabled skills found.` +- 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` To suppress trust and migration notices in automation, pass `--no-notices`. diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index 5e4fa0f..84b53bd 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -31,9 +31,10 @@ Intent creates guidance like: ## 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 #` 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 #` 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. @@ -41,6 +42,20 @@ Before substantial work: 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 install --hooks +``` + +Project-scoped hooks are installed for Claude Code and Codex. GitHub Copilot CLI hooks are user-scoped, so configure them explicitly: + +```bash +npx @tanstack/intent@latest install --hooks --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. @@ -106,4 +121,3 @@ You can also check if any skills reference outdated source documentation: ```bash npx @tanstack/intent@latest stale ``` - diff --git a/docs/overview.md b/docs/overview.md index 1d62c3a..7c50a65 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -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 @@ -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 install --hooks +``` + +Installs hook enforcement for supported agents. Project-scoped hooks are available for Claude Code and Codex. GitHub Copilot CLI hooks are user-scoped. Cursor and generic `AGENTS.md` agents use guidance only. + ```bash npx @tanstack/intent@latest load @tanstack/query#fetching ``` diff --git a/packages/intent/src/hooks/adapters.ts b/packages/intent/src/hooks/adapters.ts new file mode 100644 index 0000000..03d049e --- /dev/null +++ b/packages/intent/src/hooks/adapters.ts @@ -0,0 +1,93 @@ +import { join } from 'node:path' +import type { HookAgent, HookInstallScope } from './types.js' + +export type HookAdapterPaths = { + configPath: string + scriptPath: string +} + +export type HookAdapterContext = { + copilotHome?: string + homeDir: string + root: string +} + +export type HookAgentAdapter = { + agent: HookAgent + configKind: 'claude-settings' | 'codex-hooks' | 'copilot-hooks' + supportedScopes: ReadonlySet + paths: ( + scope: HookInstallScope, + context: HookAdapterContext, + ) => HookAdapterPaths +} + +export const HOOK_SCRIPT_DIR = '.intent/hooks' + +export const HOOK_AGENT_ADAPTERS: Record = { + claude: { + agent: 'claude', + configKind: 'claude-settings', + supportedScopes: new Set(['project', 'user']), + paths: (scope, { homeDir, root }) => { + const project = scope === 'project' + return { + configPath: project + ? join(root, '.claude', 'settings.json') + : join(homeDir, '.claude', 'settings.json'), + scriptPath: project + ? join(root, HOOK_SCRIPT_DIR, 'intent-claude-gate.mjs') + : join( + homeDir, + '.tanstack', + 'intent', + 'hooks', + 'intent-claude-gate.mjs', + ), + } + }, + }, + codex: { + agent: 'codex', + configKind: 'codex-hooks', + supportedScopes: new Set(['project', 'user']), + paths: (scope, { homeDir, root }) => { + const project = scope === 'project' + return { + configPath: project + ? join(root, '.codex', 'hooks.json') + : join(homeDir, '.codex', 'hooks.json'), + scriptPath: project + ? join(root, HOOK_SCRIPT_DIR, 'intent-codex-gate.mjs') + : join( + homeDir, + '.tanstack', + 'intent', + 'hooks', + 'intent-codex-gate.mjs', + ), + } + }, + }, + copilot: { + agent: 'copilot', + configKind: 'copilot-hooks', + supportedScopes: new Set(['user']), + paths: (_scope, { copilotHome, homeDir }) => ({ + configPath: join( + copilotHome ?? join(homeDir, '.copilot'), + 'hooks', + 'hooks.json', + ), + scriptPath: join( + homeDir, + '.tanstack', + 'intent', + 'hooks', + 'intent-copilot-gate.mjs', + ), + }), + }, +} + +export const ALL_HOOK_AGENTS: Array = ['copilot', 'claude', 'codex'] diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index f2dafac..0e5c6b5 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -1,11 +1,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' -import { dirname, join, relative } from 'node:path' +import { dirname, relative } from 'node:path' import { fail } from '../cli-error.js' +import { ALL_HOOK_AGENTS, HOOK_AGENT_ADAPTERS } from './adapters.js' import { EDIT_TOOLS_BY_AGENT, GATE_DENY_REASON } from './policy.js' -import type { HookAgent } from './types.js' - -export type HookInstallScope = 'project' | 'user' +import type { HookAgent, HookInstallScope } from './types.js' export type HookInstallStatus = 'created' | 'skipped' | 'unchanged' | 'updated' @@ -26,9 +25,6 @@ export type InstallHooksOptions = { scope?: string } -const ALL_HOOK_AGENTS: Array = ['copilot', 'claude', 'codex'] -const PROJECT_HOOK_AGENTS = new Set(['claude', 'codex']) -const HOOK_SCRIPT_DIR = '.intent/hooks' const STATUS_MESSAGE = 'Checking Intent guidance' export function runInstallHooks({ @@ -207,7 +203,9 @@ function installAgentHook({ root: string scope: HookInstallScope }): HookInstallResult { - if (scope === 'project' && !PROJECT_HOOK_AGENTS.has(agent)) { + const adapter = HOOK_AGENT_ADAPTERS[agent] + + if (!adapter.supportedScopes.has(scope)) { return { agent, configPath: null, @@ -218,106 +216,19 @@ function installAgentHook({ } } - switch (agent) { - case 'claude': - return installClaudeHook({ agent, homeDir, root, scope }) - case 'codex': - return installCodexHook({ agent, homeDir, root, scope }) - case 'copilot': - return installCopilotHook({ agent, copilotHome, homeDir, scope }) - } -} - -function installClaudeHook({ - agent, - homeDir, - root, - scope, -}: { - agent: HookAgent - homeDir: string - root: string - scope: HookInstallScope -}): HookInstallResult { - const project = scope === 'project' - const scriptPath = project - ? join(root, HOOK_SCRIPT_DIR, 'intent-claude-gate.mjs') - : join(homeDir, '.tanstack', 'intent', 'hooks', 'intent-claude-gate.mjs') - const configPath = project - ? join(root, '.claude', 'settings.json') - : join(homeDir, '.claude', 'settings.json') - const scriptStatus = writeIfChanged(scriptPath, buildHookRunnerScript(agent)) - const configStatus = updateJsonConfig(configPath, (config) => - upsertClaudePreToolUseHook(config, project, scriptPath), - ) - - return hookInstallResult({ - agent, - configPath, - scope, - scriptPath, - scriptStatus, - configStatus, - }) -} - -function installCodexHook({ - agent, - homeDir, - root, - scope, -}: { - agent: HookAgent - homeDir: string - root: string - scope: HookInstallScope -}): HookInstallResult { - const project = scope === 'project' - const scriptPath = project - ? join(root, HOOK_SCRIPT_DIR, 'intent-codex-gate.mjs') - : join(homeDir, '.tanstack', 'intent', 'hooks', 'intent-codex-gate.mjs') - const configPath = project - ? join(root, '.codex', 'hooks.json') - : join(homeDir, '.codex', 'hooks.json') - const scriptStatus = writeIfChanged(scriptPath, buildHookRunnerScript(agent)) - const configStatus = updateJsonConfig(configPath, (config) => - upsertCodexPreToolUseHook(config, project, scriptPath), - ) - - return hookInstallResult({ - agent, - configPath, - scope, - scriptPath, - scriptStatus, - configStatus, - }) -} - -function installCopilotHook({ - agent, - copilotHome, - homeDir, - scope, -}: { - agent: HookAgent - copilotHome?: string - homeDir: string - scope: HookInstallScope -}): HookInstallResult { - const resolvedCopilotHome = - copilotHome ?? process.env.COPILOT_HOME ?? join(homeDir, '.copilot') - const scriptPath = join( + const { configPath, scriptPath } = adapter.paths(scope, { + copilotHome: copilotHome ?? process.env.COPILOT_HOME, homeDir, - '.tanstack', - 'intent', - 'hooks', - 'intent-copilot-gate.mjs', - ) - const configPath = join(resolvedCopilotHome, 'hooks', 'hooks.json') + root, + }) const scriptStatus = writeIfChanged(scriptPath, buildHookRunnerScript(agent)) const configStatus = updateJsonConfig(configPath, (config) => - upsertCopilotPreToolUseHook(config, scriptPath), + upsertAdapterPreToolUseHook({ + config, + configKind: adapter.configKind, + project: scope === 'project', + scriptPath, + }), ) return hookInstallResult({ @@ -359,6 +270,27 @@ function hookInstallResult({ } } +function upsertAdapterPreToolUseHook({ + config, + configKind, + project, + scriptPath, +}: { + config: Record + configKind: (typeof HOOK_AGENT_ADAPTERS)[HookAgent]['configKind'] + project: boolean + scriptPath: string +}): Record { + switch (configKind) { + case 'claude-settings': + return upsertClaudePreToolUseHook(config, project, scriptPath) + case 'codex-hooks': + return upsertCodexPreToolUseHook(config, project, scriptPath) + case 'copilot-hooks': + return upsertCopilotPreToolUseHook(config, scriptPath) + } +} + function upsertClaudePreToolUseHook( config: Record, project: boolean, diff --git a/packages/intent/src/hooks/types.ts b/packages/intent/src/hooks/types.ts index c3f56e3..771cdfb 100644 --- a/packages/intent/src/hooks/types.ts +++ b/packages/intent/src/hooks/types.ts @@ -1,5 +1,7 @@ export type HookAgent = 'claude' | 'codex' | 'copilot' +export type HookInstallScope = 'project' | 'user' + export type IntentInvocation = { action: 'list' | 'load' skillUse?: string diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index 3e53de8..c3af74d 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -10,6 +10,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' import { afterEach, describe, expect, it } from 'vitest' +import { HOOK_AGENT_ADAPTERS } from '../src/hooks/adapters.js' import { buildHookRunnerScript, formatHookInstallResult, @@ -35,6 +36,15 @@ function readJson(filePath: string): Record { } describe('hook installer', () => { + it('declares supported scopes in the adapter registry', () => { + expect(HOOK_AGENT_ADAPTERS.claude.supportedScopes.has('project')).toBe(true) + expect(HOOK_AGENT_ADAPTERS.codex.supportedScopes.has('project')).toBe(true) + expect(HOOK_AGENT_ADAPTERS.copilot.supportedScopes.has('project')).toBe( + false, + ) + expect(HOOK_AGENT_ADAPTERS.copilot.supportedScopes.has('user')).toBe(true) + }) + it('installs project-scoped Claude and Codex hooks and skips Copilot', () => { const root = tempRoot('intent-hooks-project-') From e57595cb2fd66abfbaccd3049f28932409d6996c Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 20 Jun 2026 21:48:09 -0700 Subject: [PATCH 08/13] Refactor intent installation and hook handling; enhance command patterns and add skill categorization tests --- packages/intent/src/commands/install.ts | 25 +++-- packages/intent/src/hooks/install.ts | 27 +++-- packages/intent/src/hooks/policy.ts | 4 +- packages/intent/src/install/guidance.ts | 15 +-- packages/intent/src/skill-categories.ts | 19 ++++ packages/intent/tests/cli.test.ts | 9 ++ packages/intent/tests/hooks-install.test.ts | 106 ++++++++++++++++++ packages/intent/tests/hooks.test.ts | 9 ++ .../intent/tests/skill-categories.test.ts | 27 +++++ 9 files changed, 205 insertions(+), 36 deletions(-) create mode 100644 packages/intent/src/skill-categories.ts create mode 100644 packages/intent/tests/skill-categories.test.ts diff --git a/packages/intent/src/commands/install.ts b/packages/intent/src/commands/install.ts index 195b61b..bf7015e 100644 --- a/packages/intent/src/commands/install.ts +++ b/packages/intent/src/commands/install.ts @@ -28,7 +28,7 @@ Hard rules: - If skills are discovered and no mapping block exists, create AGENTS.md unless the user asks for another supported config file. - If a mapping block already exists in a supported config file, update that file. - Preserve all content outside the managed block unchanged. -- Store compact \`use\` values in the managed block; do not write \`load\` paths. +- Store compact \`id\` values and runnable \`run\` commands in the managed block; do not write local paths. - Never write absolute local file paths, node_modules paths, or package-manager-internal paths in the managed block. - Verify the target file before your final response. @@ -88,19 +88,20 @@ Follow these steps in order: Use this exact block: -# Skill mappings - load \`use\` with \`npx @tanstack/intent@latest load \`. -skills: - - when: "describe the task or code area here" - use: "@scope/package#skill-name" +# TanStack Intent - before editing files, run the matching guidance command. +tanstackIntent: + - id: "@scope/package#skill-name" + run: "npx @tanstack/intent@latest load @scope/package#skill-name" + for: "describe the task or code area here" Rules: - - Use the user's own words for \`when\` descriptions - - Use compact \`use\` values in \`#\` format - - Do not include \`load\` + - Use the user's own words for \`for\` descriptions + - Use compact \`id\` values in \`#\` format + - Include a \`run\` command that loads the matching \`id\` - Do not include machine-specific directories such as \`/Users/...\`, \`/home/...\`, \`/private/...\`, drive letters, temp workspace paths, \`.pnpm/\`, \`.bun/\`, or \`.yarn/\`. - - Agents should load \`use\` at runtime with \`npx @tanstack/intent@latest load \` + - Agents should run the \`run\` command before editing matching files - Keep entries concise - this block is read on every agent task - Preserve all content outside the block tags unchanged - If the user is on Deno, note that this setup is best-effort today and relies on npm interop @@ -109,9 +110,9 @@ skills: Before reporting completion: - Confirm the target file exists - Confirm it contains both managed block markers - - Confirm every mapping has \`when\` and \`use\` - - Confirm every \`use\` parses as \`#\` - - Confirm no mapping includes \`load\` + - Confirm every mapping has \`id\`, \`run\`, and \`for\` + - Confirm every \`id\` parses as \`#\` + - Confirm no mapping includes local file paths - Confirm no path-like machine-specific values are stored in the managed block - Confirm every discovered actionable skill is mapped, skipped by rule, or deferred by user choice diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index 0e5c6b5..519ccbf 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -60,7 +60,7 @@ import { createHash } from 'node:crypto' const AGENT = ${JSON.stringify(agent)} const EDIT_TOOLS = new Set(${JSON.stringify(editTools)}) const GATE_DENY_REASON = ${JSON.stringify(GATE_DENY_REASON)} -const INTENT_COMMAND_PATTERN = /(?:^|\\s|&&|;|\\|)\\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 + 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 try { const event = readEventFromStdin() @@ -353,17 +353,26 @@ function upsertHookGroup( groups: Array, nextGroup: Record, ): Array { - return [...groups.filter((group) => !containsIntentHook(group)), nextGroup] + return [...groups.flatMap(withoutIntentHooks), nextGroup] } -function containsIntentHook(value: unknown): boolean { - if (!value || typeof value !== 'object') return false +function withoutIntentHooks(value: unknown): Array { + if (!value || typeof value !== 'object') return [value] + const hooks = arrayValue((value as { hooks?: unknown }).hooks) - return hooks.some( - (hook) => - JSON.stringify(hook).includes('intent-') && - JSON.stringify(hook).includes('-gate.mjs'), - ) + if (hooks.length === 0) return isIntentHook(value) ? [] : [value] + + const nextHooks = hooks.filter((hook) => !isIntentHook(hook)) + if (nextHooks.length === hooks.length) return [value] + if (nextHooks.length === 0) return [] + + return [{ ...(value as Record), hooks: nextHooks }] +} + +function isIntentHook(value: unknown): boolean { + if (!value || typeof value !== 'object') return false + const serialized = JSON.stringify(value) + return serialized.includes('intent-') && serialized.includes('-gate.mjs') } function updateJsonConfig( diff --git a/packages/intent/src/hooks/policy.ts b/packages/intent/src/hooks/policy.ts index 35bff7a..db1c06d 100644 --- a/packages/intent/src/hooks/policy.ts +++ b/packages/intent/src/hooks/policy.ts @@ -7,7 +7,7 @@ import type { } from './types.js' const INTENT_COMMAND_PATTERN = - /(?:^|\s|&&|;|\|)\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 + /(?:^|&&|\|\||;|\|)\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 export const EDIT_TOOLS_BY_AGENT: Record> = { claude: new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']), @@ -16,7 +16,7 @@ export const EDIT_TOOLS_BY_AGENT: Record> = { } export const GATE_DENY_REASON = - 'Blocked: load the matching TanStack guidance before editing. Use the guidance command from the AGENTS.md tanstackIntent block, then retry the edit.' + 'Blocked: load matching TanStack guidance before editing. Follow this repo\'s TanStack guidance setup, then retry the edit.' export function parseIntentInvocation( command: unknown, diff --git a/packages/intent/src/install/guidance.ts b/packages/intent/src/install/guidance.ts index a5b5c38..7884938 100644 --- a/packages/intent/src/install/guidance.ts +++ b/packages/intent/src/install/guidance.ts @@ -3,6 +3,7 @@ import { dirname, join } from 'node:path' import { parse as parseYaml } from 'yaml' import { formatIntentCommand } from '../command-runner.js' import { formatSkillUse, parseSkillUse } from '../skill-use.js' +import { isGeneratedMappingSkill } from '../skill-categories.js' import type { ScanResult, SkillEntry } from '../types.js' const INTENT_SKILLS_START = '' @@ -15,13 +16,6 @@ const SUPPORTED_AGENT_CONFIG_FILES = [ '.github/copilot-instructions.md', ] -const NON_ACTIONABLE_SKILL_TYPES = new Set([ - 'maintainer', - 'maintainer-only', - 'meta', - 'reference', -]) - export interface IntentSkillsBlockResult { block: string mappingCount: number @@ -242,11 +236,6 @@ function quoteYamlString(value: string): string { return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')}"` } -function isActionableSkill(skill: SkillEntry): boolean { - const type = skill.type?.trim().toLowerCase() - return !type || !NON_ACTIONABLE_SKILL_TYPES.has(type) -} - function formatWhen(packageName: string, skill: SkillEntry): string { const description = skill.description.replace(/\s+/g, ' ').trim() return description || `Use ${packageName} ${skill.name}` @@ -264,7 +253,7 @@ export function buildIntentSkillsBlock( for (const pkg of [...scanResult.packages].sort(compareNames)) { for (const skill of [...pkg.skills].sort(compareNames)) { - if (!isActionableSkill(skill)) continue + if (!isGeneratedMappingSkill(skill)) continue mappingCount++ lines.push( diff --git a/packages/intent/src/skill-categories.ts b/packages/intent/src/skill-categories.ts new file mode 100644 index 0000000..f31cac1 --- /dev/null +++ b/packages/intent/src/skill-categories.ts @@ -0,0 +1,19 @@ +import type { SkillEntry } from './types.js' + +export type SkillCategory = 'maintainer' | 'meta' | 'reference' | 'task' + +const MAINTAINER_TYPES = new Set(['maintainer', 'maintainer-only']) + +export function getSkillCategory(skill: Pick): SkillCategory { + const type = skill.type?.trim().toLowerCase() + + if (type === 'reference') return 'reference' + if (type === 'meta') return 'meta' + if (type && MAINTAINER_TYPES.has(type)) return 'maintainer' + + return 'task' +} + +export function isGeneratedMappingSkill(skill: Pick): boolean { + return getSkillCategory(skill) === 'task' +} diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 0616718..8faa007 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -215,9 +215,18 @@ describe('cli commands', () => { it('prints the install prompt', async () => { const exitCode = await main(['install', '--print-prompt']) + const output = String(logSpy.mock.calls[0]?.[0]) expect(exitCode).toBe(0) expect(logSpy).toHaveBeenCalledWith(INSTALL_PROMPT) + expect(output).toContain('tanstackIntent:') + expect(output).toContain(' - id: "@scope/package#skill-name"') + expect(output).toContain( + ' run: "npx @tanstack/intent@latest load @scope/package#skill-name"', + ) + expect(output).toContain(' for: "describe the task or code area here"') + expect(output).not.toContain('skills:\n - when:') + expect(output).not.toContain('use: "@scope/package#skill-name"') }) it('lists excludes when none are configured', async () => { diff --git a/packages/intent/tests/hooks-install.test.ts b/packages/intent/tests/hooks-install.test.ts index c3af74d..55ede08 100644 --- a/packages/intent/tests/hooks-install.test.ts +++ b/packages/intent/tests/hooks-install.test.ts @@ -166,6 +166,84 @@ describe('hook installer', () => { expect(second[0]).toMatchObject({ status: 'unchanged' }) }) + it('preserves sibling hooks when replacing an Intent hook entry', () => { + const root = tempRoot('intent-hooks-sibling-') + const settingsPath = join(root, '.claude', 'settings.json') + mkdirSync(join(root, '.claude'), { recursive: true }) + writeFileSync( + settingsPath, + JSON.stringify( + { + hooks: { + PreToolUse: [ + { + matcher: 'Edit', + hooks: [ + { + type: 'command', + command: 'node old-intent-claude-gate.mjs', + }, + { type: 'command', command: 'echo keep' }, + ], + }, + ], + }, + }, + null, + 2, + ) + '\n', + ) + + runInstallHooks({ agents: 'claude', root, scope: 'project' }) + + const config = readJson(settingsPath) + expect(config.hooks.PreToolUse).toHaveLength(2) + expect(config.hooks.PreToolUse[0].hooks).toEqual([ + { type: 'command', command: 'echo keep' }, + ]) + expect(config.hooks.PreToolUse[1].hooks[0].args[0]).toContain( + 'intent-claude-gate.mjs', + ) + }) + + it('replaces direct Copilot Intent hook entries on reinstall', () => { + const root = tempRoot('intent-hooks-copilot-replace-root-') + const homeDir = tempRoot('intent-hooks-copilot-replace-home-') + const copilotHome = join(homeDir, '.copilot') + const hooksPath = join(copilotHome, 'hooks', 'hooks.json') + mkdirSync(join(copilotHome, 'hooks'), { recursive: true }) + writeFileSync( + hooksPath, + JSON.stringify( + { + hooks: { + PreToolUse: [ + { command: 'node /tmp/old-intent-copilot-gate.mjs' }, + { command: 'echo keep' }, + ], + }, + }, + null, + 2, + ) + '\n', + ) + + runInstallHooks({ + agents: 'copilot', + copilotHome, + homeDir, + root, + scope: 'user', + }) + + const config = readJson(hooksPath) + expect(config.hooks.PreToolUse).toHaveLength(2) + expect(config.hooks.PreToolUse[0]).toEqual({ command: 'echo keep' }) + expect(config.hooks.PreToolUse[1].command).toContain( + 'intent-copilot-gate.mjs', + ) + }) + it('builds a runner script with command-free denial text', () => { const script = buildHookRunnerScript('claude') @@ -211,6 +289,34 @@ describe('hook installer', () => { expect(afterLoad.stdout).toBe('') }) + it('does not unlock edits after non-executed load text', () => { + const root = tempRoot('intent-hooks-non-executed-load-') + const scriptPath = join(root, 'intent-claude-gate.mjs') + writeFileSync(scriptPath, buildHookRunnerScript('claude')) + + const echoLoad = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-a', + tool_name: 'Bash', + tool_input: { command: 'echo intent load @tanstack/router#routing' }, + }) + const afterEcho = runHookScript(scriptPath, { + cwd: root, + hook_event_name: 'PreToolUse', + session_id: 'session-a', + tool_name: 'Edit', + tool_input: { file_path: join(root, 'src.ts') }, + }) + + expect(echoLoad.status).toBe(0) + expect(echoLoad.stdout).toBe('') + expect(afterEcho.status).toBe(0) + expect(JSON.parse(afterEcho.stdout)).toMatchObject({ + hookSpecificOutput: { permissionDecision: 'deny' }, + }) + }) + it('formats skipped install results', () => { expect( formatHookInstallResult({ diff --git a/packages/intent/tests/hooks.test.ts b/packages/intent/tests/hooks.test.ts index 4e1eeb5..51f1a73 100644 --- a/packages/intent/tests/hooks.test.ts +++ b/packages/intent/tests/hooks.test.ts @@ -22,10 +22,19 @@ describe('intent hook policy', () => { expect( parseIntentInvocation('cd packages/app && intent load @tanstack/x#y'), ).toEqual({ action: 'load', skillUse: '@tanstack/x#y' }) + expect( + parseIntentInvocation('npm test || intent load @tanstack/x#y'), + ).toEqual({ action: 'load', skillUse: '@tanstack/x#y' }) }) it('ignores non-intent commands and incomplete load commands', () => { expect(parseIntentInvocation('npm run build')).toBeUndefined() + expect( + parseIntentInvocation('echo intent load @tanstack/router#routing'), + ).toBeUndefined() + expect( + parseIntentInvocation('# intent load @tanstack/router#routing'), + ).toBeUndefined() expect(parseIntentInvocation('intent load')).toBeUndefined() expect(parseIntentInvocation(undefined)).toBeUndefined() }) diff --git a/packages/intent/tests/skill-categories.test.ts b/packages/intent/tests/skill-categories.test.ts new file mode 100644 index 0000000..5432f3d --- /dev/null +++ b/packages/intent/tests/skill-categories.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { + getSkillCategory, + isGeneratedMappingSkill, +} from '../src/skill-categories.js' + +describe('skill categories', () => { + it('treats empty and unknown types as task skills', () => { + expect(getSkillCategory({})).toBe('task') + expect(getSkillCategory({ type: 'core' })).toBe('task') + expect(getSkillCategory({ type: ' CORE ' })).toBe('task') + }) + + it('categorizes non-task skill types', () => { + expect(getSkillCategory({ type: 'reference' })).toBe('reference') + expect(getSkillCategory({ type: 'meta' })).toBe('meta') + expect(getSkillCategory({ type: 'maintainer' })).toBe('maintainer') + expect(getSkillCategory({ type: 'maintainer-only' })).toBe('maintainer') + }) + + it('maps only task skills into generated guidance', () => { + expect(isGeneratedMappingSkill({ type: 'core' })).toBe(true) + expect(isGeneratedMappingSkill({ type: 'reference' })).toBe(false) + expect(isGeneratedMappingSkill({ type: 'meta' })).toBe(false) + expect(isGeneratedMappingSkill({ type: 'maintainer-only' })).toBe(false) + }) +}) From fa56f00cd4d09a4d1b944a54c2d4c7da5d53fded Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 20 Jun 2026 21:57:29 -0700 Subject: [PATCH 09/13] Add intent hooks command and update documentation for lifecycle hooks installation --- docs/cli/intent-hooks.md | 48 ++++++++++++ docs/cli/intent-install.md | 65 ++-------------- docs/config.json | 4 + docs/getting-started/quick-start-consumers.md | 6 +- docs/overview.md | 4 +- packages/intent/README.md | 1 + packages/intent/src/cli.ts | 30 ++++++-- packages/intent/src/commands/install.ts | 22 +++--- packages/intent/src/hooks/install.ts | 8 ++ packages/intent/tests/cli.test.ts | 77 +++++++++++-------- 10 files changed, 154 insertions(+), 111 deletions(-) create mode 100644 docs/cli/intent-hooks.md diff --git a/docs/cli/intent-hooks.md b/docs/cli/intent-hooks.md new file mode 100644 index 0000000..a1aab8c --- /dev/null +++ b/docs/cli/intent-hooks.md @@ -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 `: hook install scope, either `project` or `user`; defaults to `project` +- `--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) diff --git a/docs/cli/intent-install.md b/docs/cli/intent-install.md index 49aac6d..85b70e9 100644 --- a/docs/cli/intent-install.md +++ b/docs/cli/intent-install.md @@ -3,20 +3,22 @@ title: intent install id: intent-install --- -`intent install` creates or updates an `intent-skills` guidance block in a project guidance file. Pass `--hooks` to also install lifecycle hooks that enforce loading matching guidance before edits in supported agents. +`intent install` creates or updates an `intent-skills` guidance block in a project guidance file. ```bash -npx @tanstack/intent@latest install [--map] [--hooks] [--scope project|user] [--agents copilot,claude,codex|all] [--dry-run] [--print-prompt] [--global] [--global-only] [--no-notices] +npx @tanstack/intent@latest install [--map] [--dry-run] [--print-prompt] [--global] [--global-only] [--no-notices] ``` ## Options +### Guidance output + - `--map`: write explicit task-to-skill mappings instead of lightweight loading guidance -- `--hooks`: install agent lifecycle hooks that block edits until matching Intent guidance is loaded -- `--scope `: hook install scope, either `project` or `user`; defaults to `project` -- `--agents `: comma-separated hook agents to configure (`copilot`, `claude`, `codex`) or `all`; defaults to `all` - `--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 @@ -31,7 +33,6 @@ npx @tanstack/intent@latest install [--map] [--hooks] [--scope project|user] [-- - 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 skill identities and runnable guidance commands instead of local file paths in `--map` mode. -- Installs hook enforcement only when `--hooks` is passed. - 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. @@ -73,55 +74,6 @@ tanstackIntent: - `for`: task-routing phrase for agents - The block does not store `load` paths, absolute paths, or package-manager-internal paths -## Hook enforcement - -`--hooks` installs local lifecycle hooks that observe `intent load` commands and block edit tools until guidance has been loaded in the current agent session. - -```bash -npx @tanstack/intent@latest install --hooks -npx @tanstack/intent@latest install --hooks --agents claude,codex -npx @tanstack/intent@latest install --hooks --scope user --agents copilot -``` - -Default hook behavior: - -- `--scope project` is the default. It writes project-local hook config for agents that support it. -- `--agents all` is the default. In project scope, Copilot is skipped because the supported Copilot CLI hook location is user-scoped. -- `--scope user` writes user-level agent config and stores runner scripts under `~/.tanstack/intent/hooks`. -- `--dry-run` prints the generated guidance block and does not write hook config. - -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 | Not supported | `$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 | - -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. - -## Add another coding platform - -Intent can support any coding agent that exposes a lifecycle hook before file edits or shell commands run. A good platform adapter needs three pieces: - -1. A hook event that runs before edits, ideally before tools like `Write`, `Edit`, `apply_patch`, or notebook edits execute. -2. Access to the pending tool name and command or edit input, so the hook can observe `intent load #` and block edits until a load has happened in the session. -3. A documented way to return a blocking decision with a message the agent can see. - -The shared policy is intentionally small: observe `intent load`, remember that the current session loaded guidance, and deny edit tools until that happens. Platform adapters should only translate that policy into the platform's hook config, event shape, and denial response. - -If your coding platform has a compatible hook API, open a PR with: - -- an adapter entry in `packages/intent/src/hooks/adapters.ts` -- config generation in `packages/intent/src/hooks/install.ts` -- tests for config generation and deny/allow behavior -- a row in the support table above -- links to the platform's public hook documentation - -If the platform only supports prompt instructions, use the `intent-skills` guidance block instead of claiming hook enforcement. - ## Status messages - Created: `Created AGENTS.md with 1 mapping.` @@ -131,8 +83,6 @@ If the platform only supports prompt instructions, use the `intent-skills` guida - Guidance unchanged: `No changes to AGENTS.md; skill loading guidance already current.` - Placement tip: `Tip: Keep the intent-skills block near the top of AGENTS.md so agents read it before task-specific instructions.` - No actionable skills in `--map` mode: `No intent-enabled skills found.` -- 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` To suppress trust and migration notices in automation, pass `--no-notices`. @@ -140,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) diff --git a/docs/config.json b/docs/config.json index 656dad5..11e0909 100644 --- a/docs/config.json +++ b/docs/config.json @@ -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" diff --git a/docs/getting-started/quick-start-consumers.md b/docs/getting-started/quick-start-consumers.md index 84b53bd..b3369b0 100644 --- a/docs/getting-started/quick-start-consumers.md +++ b/docs/getting-started/quick-start-consumers.md @@ -45,13 +45,13 @@ Intent detects the package manager when generating this block, so the runner may To enforce loading guidance before edits in supported agents, opt in to hooks: ```bash -npx @tanstack/intent@latest install --hooks +npx @tanstack/intent@latest hooks install ``` -Project-scoped hooks are installed for Claude Code and Codex. GitHub Copilot CLI hooks are user-scoped, so configure them explicitly: +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 install --hooks --scope user --agents copilot +npx @tanstack/intent@latest hooks install --scope user --agents copilot ``` Cursor and generic `AGENTS.md` agents use the guidance block only. diff --git a/docs/overview.md b/docs/overview.md index 7c50a65..237fffe 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -54,10 +54,10 @@ 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 install --hooks +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 hooks are user-scoped. Cursor and generic `AGENTS.md` agents use guidance only. +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 diff --git a/packages/intent/README.md b/packages/intent/README.md index fc4a9da..8a4aabd 100644 --- a/packages/intent/README.md +++ b/packages/intent/README.md @@ -122,6 +122,7 @@ The real risk with any derived artifact is staleness. `npx @tanstack/intent@late | 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 ` | Load `#` SKILL.md content | | `npx @tanstack/intent@latest meta` | List meta-skills for library maintainers | diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 6d00778..166ee23 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -6,7 +6,10 @@ import { cac } from 'cac' import { fail, isCliFailure } from './cli-error.js' import type { CAC } from 'cac' import type { ExcludeCommandOptions } from './commands/exclude.js' -import type { InstallCommandOptions } from './commands/install.js' +import type { + HooksInstallCommandOptions, + InstallCommandOptions, +} from './commands/install.js' import type { ListCommandOptions } from './commands/list.js' import type { LoadCommandOptions } from './commands/load.js' import type { StaleCommandOptions } from './commands/stale.js' @@ -109,12 +112,9 @@ function createCli(): CAC { 'Create or update skill loading guidance in an agent config file', ) .usage( - 'install [--map] [--hooks] [--scope project|user] [--agents copilot,claude,codex|all] [--dry-run] [--print-prompt] [--global] [--global-only] [--no-notices]', + 'install [--map] [--dry-run] [--print-prompt] [--global] [--global-only] [--no-notices]', ) .option('--map', 'Write explicit skill-to-task mappings') - .option('--hooks', 'Install agent hooks that enforce skill loading') - .option('--scope ', 'Hook install scope: project or user') - .option('--agents ', 'Hook agents: copilot,claude,codex, or all') .option('--dry-run', 'Print the generated block without writing') .option( '--print-prompt', @@ -125,8 +125,6 @@ function createCli(): CAC { .option('--no-notices', 'Suppress non-critical notices on stderr') .example('install') .example('install --map') - .example('install --hooks') - .example('install --hooks --scope user --agents copilot') .example('install --dry-run') .example('install --print-prompt') .example('install --global') @@ -138,6 +136,24 @@ function createCli(): CAC { await runInstallCommand(options, scanIntentsOrFail) }) + cli + .command('hooks [action]', 'Manage agent hooks that enforce skill loading') + .usage( + 'hooks install [--scope project|user] [--agents copilot,claude,codex|all]', + ) + .option('--scope ', 'Hook install scope: project or user') + .option('--agents ', 'Hook agents: copilot,claude,codex, or all') + .example('hooks install') + .example('hooks install --scope user --agents copilot') + .action(async (action: string | undefined, options: HooksInstallCommandOptions) => { + if (action !== 'install') { + fail('Unknown hooks action: expected install.') + } + + const { runHooksInstallCommand } = await import('./commands/install.js') + runHooksInstallCommand(options) + }) + cli .command('scaffold', 'Print maintainer scaffold prompt') .usage('scaffold') diff --git a/packages/intent/src/commands/install.ts b/packages/intent/src/commands/install.ts index bf7015e..5fa9d83 100644 --- a/packages/intent/src/commands/install.ts +++ b/packages/intent/src/commands/install.ts @@ -14,7 +14,11 @@ import { verifyIntentSkillsBlockFile, writeIntentSkillsBlock, } from '../install/guidance.js' -import { formatHookInstallResult, runInstallHooks } from '../hooks/install.js' +import { + formatHookInstallResult, + runInstallHooks, + validateHookInstallOptions, +} from '../hooks/install.js' import type { GlobalScanFlags } from '../cli-support.js' import type { IntentCoreOptions } from '../core.js' import type { ScanResult } from '../types.js' @@ -123,11 +127,13 @@ tanstackIntent: - The verification result` export interface InstallCommandOptions extends GlobalScanFlags { - agents?: string dryRun?: boolean - hooks?: boolean map?: boolean printPrompt?: boolean +} + +export interface HooksInstallCommandOptions { + agents?: string scope?: string } @@ -198,10 +204,10 @@ function printWriteResult({ } } -function installHooks(options: InstallCommandOptions): void { - if (!options.hooks || options.dryRun) { - return - } +export function runHooksInstallCommand( + options: HooksInstallCommandOptions, +): void { + validateHookInstallOptions(options) const results = runInstallHooks({ agents: options.agents, @@ -267,7 +273,6 @@ export async function runInstallCommand( printWriteResult(result) printPlacementTip(result.targetPath) - installHooks(options) return } @@ -330,7 +335,6 @@ export async function runInstallCommand( printWriteResult(result) printPlacementTip(result.targetPath) - installHooks(options) printWarnings(scanResult.warnings) printNotices(scanResult.notices, noticeOptions) diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index 519ccbf..4b3e1e2 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -48,6 +48,14 @@ export function runInstallHooks({ ) } +export function validateHookInstallOptions({ + agents, + scope, +}: Pick): void { + parseScope(scope) + parseAgents(agents) +} + export function buildHookRunnerScript(agent: HookAgent): string { const editTools = [...EDIT_TOOLS_BY_AGENT[agent]].sort() diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 8faa007..c42941f 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -455,56 +455,32 @@ describe('cli commands', () => { ) }) - it('writes skill loading guidance and project hooks with --hooks', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-hooks-')) + it('installs hooks with the hooks install command', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-hooks-install-')) tempDirs.push(root) process.chdir(root) - const exitCode = await main([ - 'install', - '--hooks', - '--agents', - 'claude,codex', - ]) + const exitCode = await main(['hooks', 'install', '--agents', 'claude']) const output = logSpy.mock.calls.flat().join('\n') expect(exitCode).toBe(0) - expect(output).toContain('Created AGENTS.md with skill loading guidance.') expect(output).toContain('Installed Intent hooks for claude (project)') - expect(output).toContain('Installed Intent hooks for codex (project)') expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(true) - expect(existsSync(join(root, '.codex', 'hooks.json'))).toBe(true) - expect( - existsSync(join(root, '.intent', 'hooks', 'intent-claude-gate.mjs')), - ).toBe(true) + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false) }) - it('fails cleanly for invalid install hook options', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-hooks-bad-')) + it('fails cleanly for invalid hooks install options', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-hooks-bad-options-')) tempDirs.push(root) process.chdir(root) - const badAgentExitCode = await main([ - 'install', - '--hooks', - '--agents', - 'wat', - ]) - const badScopeExitCode = await main([ - 'install', - '--hooks', - '--scope', - 'repo', - ]) + const exitCode = await main(['hooks', 'install', '--scope', 'repo']) - expect(badAgentExitCode).toBe(1) - expect(badScopeExitCode).toBe(1) - expect(errorSpy).toHaveBeenCalledWith( - 'Unknown hook agent: wat. Expected copilot, claude, codex, or all.', - ) + expect(exitCode).toBe(1) expect(errorSpy).toHaveBeenCalledWith( 'Unknown hook scope: repo. Expected project or user.', ) + expect(existsSync(join(root, '.claude', 'settings.json'))).toBe(false) }) it('writes install mappings with --map and is idempotent', async () => { @@ -644,6 +620,41 @@ describe('cli commands', () => { expect(output).toContain('id: "@tanstack/query#fetching"') }) + it('uses only global packages during install --map --global-only', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-global-only-')) + const globalRoot = mkdtempSync( + join(realTmpdir, 'intent-cli-install-global-only-node-modules-'), + ) + tempDirs.push(root, globalRoot) + + writeInstalledIntentPackage(root, { + name: '@tanstack/local', + version: '1.0.0', + skillName: 'local-skill', + description: 'Local skill', + }) + const globalPkgDir = join(globalRoot, '@tanstack', 'query') + writeJson(join(globalPkgDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + intent: { version: 1, repo: 'TanStack/query', docs: 'docs/' }, + }) + writeSkillMd(join(globalPkgDir, 'skills', 'fetching'), { + name: 'fetching', + description: 'Global fetching skill', + }) + + process.env.INTENT_GLOBAL_NODE_MODULES = globalRoot + process.chdir(root) + + const exitCode = await main(['install', '--map', '--global-only']) + const content = readFileSync(join(root, 'AGENTS.md'), 'utf8') + + expect(exitCode).toBe(0) + expect(content).toContain('id: "@tanstack/query#fetching"') + expect(content).not.toContain('@tanstack/local#local-skill') + }) + it('prints the scaffold prompt', async () => { const exitCode = await main(['scaffold']) const output = String(logSpy.mock.calls[0]?.[0]) From df0cac6c1c1c9bcb629782e47320ea6fbe10f7b7 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 20 Jun 2026 22:12:15 -0700 Subject: [PATCH 10/13] Improve folder layout --- packages/intent/src/cli.ts | 49 +-- packages/intent/src/commands/exclude.ts | 2 +- packages/intent/src/commands/hooks/command.ts | 26 ++ packages/intent/src/commands/install.ts | 36 +- .../intent/src/commands/install/command.ts | 315 ++++++++++++++++++ .../src/{ => commands}/install/guidance.ts | 8 +- packages/intent/src/commands/list.ts | 14 +- packages/intent/src/commands/load.ts | 10 +- packages/intent/src/commands/meta.ts | 4 +- .../commands/{ => setup}/edit-package-json.ts | 2 +- .../github-actions.ts} | 2 +- packages/intent/src/commands/stale.ts | 6 +- .../{cli-support.ts => commands/support.ts} | 22 +- packages/intent/src/commands/validate.ts | 8 +- packages/intent/src/core/index.ts | 1 + .../src/{core.ts => core/intent-core.ts} | 28 +- packages/intent/src/core/load-resolution.ts | 16 +- packages/intent/src/core/markdown.ts | 2 +- packages/intent/src/core/project-context.ts | 2 +- packages/intent/src/core/source-policy.ts | 6 +- packages/intent/src/core/types.ts | 2 +- .../intent/src/{ => discovery}/fs-cache.ts | 4 +- .../src/{ => discovery}/package-manager.ts | 2 +- packages/intent/src/discovery/register.ts | 6 +- .../intent/src/{ => discovery}/scanner.ts | 10 +- packages/intent/src/discovery/walk.ts | 8 +- packages/intent/src/hooks/install.ts | 2 +- packages/intent/src/index.ts | 20 +- packages/intent/src/setup/index.ts | 1 + .../src/{setup.ts => setup/project-setup.ts} | 2 +- .../src/{ => setup}/workspace-patterns.ts | 2 +- packages/intent/src/{ => shared}/cli-error.ts | 0 .../intent/src/{ => shared}/cli-output.ts | 0 .../intent/src/{ => shared}/command-runner.ts | 2 +- packages/intent/src/{ => shared}/display.ts | 2 +- packages/intent/src/{ => shared}/types.ts | 0 packages/intent/src/{ => shared}/utils.ts | 0 .../categories.ts} | 2 +- .../src/{skill-paths.ts => skills/paths.ts} | 6 +- packages/intent/src/{ => skills}/resolver.ts | 6 +- .../src/{skill-use.ts => skills/use.ts} | 0 .../src/{ => staleness}/artifact-coverage.ts | 2 +- .../src/{staleness.ts => staleness/check.ts} | 4 +- packages/intent/src/staleness/index.ts | 1 + .../src/{ => staleness}/workflow-review.ts | 2 +- .../intent/tests/artifact-coverage.test.ts | 2 +- packages/intent/tests/cli.test.ts | 2 +- packages/intent/tests/core.test.ts | 2 +- packages/intent/tests/fs-cache.test.ts | 2 +- packages/intent/tests/install-writer.test.ts | 4 +- .../source-policy-surfaces.test.ts | 2 +- .../intent/tests/parse-frontmatter.test.ts | 2 +- .../intent/tests/read-scalar-field.test.ts | 2 +- packages/intent/tests/resolver.test.ts | 4 +- packages/intent/tests/scanner.test.ts | 2 +- packages/intent/tests/setup.test.ts | 4 +- .../intent/tests/skill-categories.test.ts | 2 +- packages/intent/tests/skill-paths.test.ts | 4 +- packages/intent/tests/skill-use.test.ts | 2 +- packages/intent/tests/skills.test.ts | 2 +- packages/intent/tests/source-policy.test.ts | 2 +- packages/intent/tests/stale-command.test.ts | 2 +- packages/intent/tests/staleness.test.ts | 2 +- packages/intent/tests/workflow-review.test.ts | 4 +- .../intent/tests/workspace-patterns.test.ts | 2 +- 65 files changed, 510 insertions(+), 185 deletions(-) create mode 100644 packages/intent/src/commands/hooks/command.ts create mode 100644 packages/intent/src/commands/install/command.ts rename packages/intent/src/{ => commands}/install/guidance.ts (97%) rename packages/intent/src/commands/{ => setup}/edit-package-json.ts (60%) rename packages/intent/src/commands/{setup-github-actions.ts => setup/github-actions.ts} (66%) rename packages/intent/src/{cli-support.ts => commands/support.ts} (90%) create mode 100644 packages/intent/src/core/index.ts rename packages/intent/src/{core.ts => core/intent-core.ts} (92%) rename packages/intent/src/{ => discovery}/fs-cache.ts (97%) rename packages/intent/src/{ => discovery}/package-manager.ts (97%) rename packages/intent/src/{ => discovery}/scanner.ts (99%) create mode 100644 packages/intent/src/setup/index.ts rename packages/intent/src/{setup.ts => setup/project-setup.ts} (99%) rename packages/intent/src/{ => setup}/workspace-patterns.ts (99%) rename packages/intent/src/{ => shared}/cli-error.ts (100%) rename packages/intent/src/{ => shared}/cli-output.ts (100%) rename packages/intent/src/{ => shared}/command-runner.ts (90%) rename packages/intent/src/{ => shared}/display.ts (99%) rename packages/intent/src/{ => shared}/types.ts (100%) rename packages/intent/src/{ => shared}/utils.ts (100%) rename packages/intent/src/{skill-categories.ts => skills/categories.ts} (91%) rename packages/intent/src/{skill-paths.ts => skills/paths.ts} (94%) rename packages/intent/src/{ => skills}/resolver.ts (97%) rename packages/intent/src/{skill-use.ts => skills/use.ts} (100%) rename packages/intent/src/{ => staleness}/artifact-coverage.ts (99%) rename packages/intent/src/{staleness.ts => staleness/check.ts} (99%) create mode 100644 packages/intent/src/staleness/index.ts rename packages/intent/src/{ => staleness}/workflow-review.ts (99%) diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index 166ee23..b01fe08 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -3,13 +3,11 @@ import { realpathSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { cac } from 'cac' -import { fail, isCliFailure } from './cli-error.js' +import { fail, isCliFailure } from './shared/cli-error.js' import type { CAC } from 'cac' import type { ExcludeCommandOptions } from './commands/exclude.js' -import type { - HooksInstallCommandOptions, - InstallCommandOptions, -} from './commands/install.js' +import type { HooksInstallCommandOptions } from './commands/hooks/command.js' +import type { InstallCommandOptions } from './commands/install/command.js' import type { ListCommandOptions } from './commands/list.js' import type { LoadCommandOptions } from './commands/load.js' import type { StaleCommandOptions } from './commands/stale.js' @@ -82,7 +80,7 @@ function createCli(): CAC { .example('meta domain-discovery') .action(async (name?: string) => { const [{ getMetaDir }, { runMetaCommand }] = await Promise.all([ - import('./cli-support.js'), + import('./commands/support.js'), import('./commands/meta.js'), ]) await runMetaCommand(name, getMetaDir()) @@ -130,8 +128,8 @@ function createCli(): CAC { .example('install --global') .action(async (options: InstallCommandOptions) => { const [{ scanIntentsOrFail }, { runInstallCommand }] = await Promise.all([ - import('./cli-support.js'), - import('./commands/install.js'), + import('./commands/support.js'), + import('./commands/install/command.js'), ]) await runInstallCommand(options, scanIntentsOrFail) }) @@ -145,21 +143,28 @@ function createCli(): CAC { .option('--agents ', 'Hook agents: copilot,claude,codex, or all') .example('hooks install') .example('hooks install --scope user --agents copilot') - .action(async (action: string | undefined, options: HooksInstallCommandOptions) => { - if (action !== 'install') { - fail('Unknown hooks action: expected install.') - } + .action( + async ( + action: string | undefined, + options: HooksInstallCommandOptions, + ) => { + if (action !== 'install') { + fail('Unknown hooks action: expected install.') + } - const { runHooksInstallCommand } = await import('./commands/install.js') - runHooksInstallCommand(options) - }) + const { runHooksInstallCommand } = await import( + './commands/hooks/command.js' + ) + runHooksInstallCommand(options) + }, + ) cli .command('scaffold', 'Print maintainer scaffold prompt') .usage('scaffold') .action(async () => { const [{ getMetaDir }, { runScaffoldCommand }] = await Promise.all([ - import('./cli-support.js'), + import('./commands/support.js'), import('./commands/scaffold.js'), ]) runScaffoldCommand(getMetaDir()) @@ -181,7 +186,7 @@ function createCli(): CAC { async (targetDir: string | undefined, options: StaleCommandOptions) => { const [{ resolveStaleTargets }, { runStaleCommand }] = await Promise.all([ - import('./cli-support.js'), + import('./commands/support.js'), import('./commands/stale.js'), ]) await runStaleCommand(targetDir, options, resolveStaleTargets) @@ -196,7 +201,7 @@ function createCli(): CAC { .usage('edit-package-json') .action(async () => { const { runEditPackageJsonCommand } = - await import('./commands/edit-package-json.js') + await import('./commands/setup/edit-package-json.js') await runEditPackageJsonCommand(process.cwd()) }) @@ -209,8 +214,8 @@ function createCli(): CAC { .action(async () => { const [{ getMetaDir }, { runSetupGithubActionsCommand }] = await Promise.all([ - import('./cli-support.js'), - import('./commands/setup-github-actions.js'), + import('./commands/support.js'), + import('./commands/setup/github-actions.js'), ]) await runSetupGithubActionsCommand(process.cwd(), getMetaDir()) }) @@ -224,8 +229,8 @@ function createCli(): CAC { .action(async () => { const [{ getMetaDir }, { runSetupGithubActionsCommand }] = await Promise.all([ - import('./cli-support.js'), - import('./commands/setup-github-actions.js'), + import('./commands/support.js'), + import('./commands/setup/github-actions.js'), ]) await runSetupGithubActionsCommand(process.cwd(), getMetaDir()) }) diff --git a/packages/intent/src/commands/exclude.ts b/packages/intent/src/commands/exclude.ts index 2b6f3b3..d525409 100644 --- a/packages/intent/src/commands/exclude.ts +++ b/packages/intent/src/commands/exclude.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { fail } from '../cli-error.js' +import { fail } from '../shared/cli-error.js' import { compileExcludePatterns } from '../core/excludes.js' export interface ExcludeCommandOptions { diff --git a/packages/intent/src/commands/hooks/command.ts b/packages/intent/src/commands/hooks/command.ts new file mode 100644 index 0000000..aca1eee --- /dev/null +++ b/packages/intent/src/commands/hooks/command.ts @@ -0,0 +1,26 @@ +import { + formatHookInstallResult, + runInstallHooks, + validateHookInstallOptions, +} from '../../hooks/install.js' + +export interface HooksInstallCommandOptions { + agents?: string + scope?: string +} + +export function runHooksInstallCommand( + options: HooksInstallCommandOptions, +): void { + validateHookInstallOptions(options) + + const results = runInstallHooks({ + agents: options.agents, + root: process.cwd(), + scope: options.scope, + }) + + for (const result of results) { + console.log(formatHookInstallResult(result)) + } +} diff --git a/packages/intent/src/commands/install.ts b/packages/intent/src/commands/install.ts index 5fa9d83..3dd0a76 100644 --- a/packages/intent/src/commands/install.ts +++ b/packages/intent/src/commands/install.ts @@ -13,12 +13,7 @@ import { resolveIntentSkillsBlockTargetPath, verifyIntentSkillsBlockFile, writeIntentSkillsBlock, -} from '../install/guidance.js' -import { - formatHookInstallResult, - runInstallHooks, - validateHookInstallOptions, -} from '../hooks/install.js' +} from './install-guidance.js' import type { GlobalScanFlags } from '../cli-support.js' import type { IntentCoreOptions } from '../core.js' import type { ScanResult } from '../types.js' @@ -105,7 +100,7 @@ tanstackIntent: - Include a \`run\` command that loads the matching \`id\` - Do not include machine-specific directories such as \`/Users/...\`, \`/home/...\`, \`/private/...\`, drive letters, temp workspace paths, \`.pnpm/\`, \`.bun/\`, or \`.yarn/\`. - - Agents should run the \`run\` command before editing matching files + - Agents should run the \`run\` command before editing matching files - Keep entries concise - this block is read on every agent task - Preserve all content outside the block tags unchanged - If the user is on Deno, note that this setup is best-effort today and relies on npm interop @@ -114,9 +109,9 @@ tanstackIntent: Before reporting completion: - Confirm the target file exists - Confirm it contains both managed block markers - - Confirm every mapping has \`id\`, \`run\`, and \`for\` - - Confirm every \`id\` parses as \`#\` - - Confirm no mapping includes local file paths + - Confirm every mapping has \`id\`, \`run\`, and \`for\` + - Confirm every \`id\` parses as \`#\` + - Confirm no mapping includes local file paths - Confirm no path-like machine-specific values are stored in the managed block - Confirm every discovered actionable skill is mapped, skipped by rule, or deferred by user choice @@ -132,11 +127,6 @@ export interface InstallCommandOptions extends GlobalScanFlags { printPrompt?: boolean } -export interface HooksInstallCommandOptions { - agents?: string - scope?: string -} - function formatTargetPath(targetPath: string): string { return relative(process.cwd(), targetPath) || targetPath } @@ -204,22 +194,6 @@ function printWriteResult({ } } -export function runHooksInstallCommand( - options: HooksInstallCommandOptions, -): void { - validateHookInstallOptions(options) - - const results = runInstallHooks({ - agents: options.agents, - root: process.cwd(), - scope: options.scope, - }) - - for (const result of results) { - console.log(formatHookInstallResult(result)) - } -} - export async function runInstallCommand( options: InstallCommandOptions, scanIntentsOrFail: (coreOptions?: IntentCoreOptions) => Promise, diff --git a/packages/intent/src/commands/install/command.ts b/packages/intent/src/commands/install/command.ts new file mode 100644 index 0000000..718a7c6 --- /dev/null +++ b/packages/intent/src/commands/install/command.ts @@ -0,0 +1,315 @@ +import { relative } from 'node:path' +import { fail } from '../../shared/cli-error.js' +import { detectIntentCommandPackageManager } from '../../shared/command-runner.js' +import { + coreOptionsFromGlobalFlags, + noticeOptionsFromGlobalFlags, + printNotices, + printWarnings, +} from '../support.js' +import { + buildIntentSkillGuidanceBlock, + buildIntentSkillsBlock, + resolveIntentSkillsBlockTargetPath, + verifyIntentSkillsBlockFile, + writeIntentSkillsBlock, +} from './guidance.js' +import type { GlobalScanFlags } from '../support.js' +import type { IntentCoreOptions } from '../../core/index.js' +import type { ScanResult } from '../../shared/types.js' + +export const INSTALL_PROMPT = `You are an AI assistant helping a developer set up skill-to-task mappings for their project. + +Goal: create or update one agent config file with an intent-skills mapping block. + +Hard rules: +- Do not report success until a file was created or updated, or an existing mapping block was confirmed. +- If skills are discovered and no mapping block exists, create AGENTS.md unless the user asks for another supported config file. +- If a mapping block already exists in a supported config file, update that file. +- Preserve all content outside the managed block unchanged. +- Store compact \`id\` values and runnable \`run\` commands in the managed block; do not write local paths. +- Never write absolute local file paths, node_modules paths, or package-manager-internal paths in the managed block. +- Verify the target file before your final response. + +Follow these steps in order: + +1. CHECK FOR EXISTING MAPPINGS + Search the project's agent config files (AGENTS.md, CLAUDE.md, .cursorrules, + .github/copilot-instructions.md) for a block delimited by: + + + - If found: show the user the current mappings, keep that file as the source of truth, + and ask "What would you like to update?" Then skip to step 4 with their requested changes. + - If not found: continue to step 2. + +2. DISCOVER AVAILABLE SKILLS + Run: \`npx @tanstack/intent@latest list\` + This scans project-local node_modules by default and outputs each package and skill's name, + description, and source. + If the user explicitly wants globally installed skills included, run: + \`npx @tanstack/intent@latest list --global\` + This works best in Node-compatible environments (npm, pnpm, Bun, or Deno npm interop + with node_modules enabled). + If no skills are found, do not create a config file. Report: "No intent-enabled skills found." + +3. SCAN THE REPOSITORY + Build a picture of the project's structure and patterns: + - Read package.json for library dependencies + - Survey the directory layout (src/, app/, routes/, components/, api/, etc.) + - Note recurring patterns (routing, data fetching, auth, UI components, etc.) + + Mapping coverage rule: + - Create mappings for all discovered actionable skills. + - Do not omit an actionable skill only because the repo does not currently appear to use it. + - Do not map reference, meta, maintainer, or maintainer-only skills by default. + - Include slash-named sub-skills when no parent mapping exists, or when they describe distinct user tasks. + - If the proposed block would exceed 12 mappings, show the full discovered list and ask which packages + or skill groups to include before writing. + - Add one fallback note telling the agent to run \`npx @tanstack/intent@latest list\` for less common local skills. + + Based on the repository scan and the coverage rule, propose the skill-to-task mappings. + For each one explain: + - The task or code area (in plain language the user would recognise) + - Which skill applies and why + + Then ask: "What other tasks do you commonly use AI coding agents for? + I'll create mappings for those too." + Also ask: "I'll default to AGENTS.md unless you want another supported config file. + Do you have a preference?" + +4. WRITE THE MAPPINGS BLOCK + Once you have the full set of mappings, write or update the agent config file. + - If you found an existing intent-skills block, update that file in place. + - Otherwise prefer AGENTS.md by default, unless the user asked for another supported file. + - Do not stop after discovery. If skills were found, the task is incomplete until this file exists + and contains the managed block. + + Use this exact block: + + +# TanStack Intent - before editing files, run the matching guidance command. +tanstackIntent: + - id: "@scope/package#skill-name" + run: "npx @tanstack/intent@latest load @scope/package#skill-name" + for: "describe the task or code area here" + + + Rules: + - Use the user's own words for \`for\` descriptions + - Use compact \`id\` values in \`#\` format + - Include a \`run\` command that loads the matching \`id\` + - Do not include machine-specific directories such as \`/Users/...\`, \`/home/...\`, \`/private/...\`, + drive letters, temp workspace paths, \`.pnpm/\`, \`.bun/\`, or \`.yarn/\`. + - Agents should run the \`run\` command before editing matching files + - Keep entries concise - this block is read on every agent task + - Preserve all content outside the block tags unchanged + - If the user is on Deno, note that this setup is best-effort today and relies on npm interop + +5. VERIFY AND REPORT + Before reporting completion: + - Confirm the target file exists + - Confirm it contains both managed block markers + - Confirm every mapping has \`id\`, \`run\`, and \`for\` + - Confirm every \`id\` parses as \`#\` + - Confirm no mapping includes local file paths + - Confirm no path-like machine-specific values are stored in the managed block + - Confirm every discovered actionable skill is mapped, skipped by rule, or deferred by user choice + + Final response must include: + - The target file path + - Whether it was created, updated, or already contained a valid block + - The number of mappings + - The verification result` + +export interface InstallCommandOptions extends GlobalScanFlags { + dryRun?: boolean + map?: boolean + printPrompt?: boolean +} + +function formatTargetPath(targetPath: string): string { + return relative(process.cwd(), targetPath) || targetPath +} + +function formatMappingCount(mappingCount: number): string { + return `${mappingCount} ${mappingCount === 1 ? 'mapping' : 'mappings'}` +} + +function printNoActionableSkills( + warnings: Array, + notices: Array, + noticeOptions: { noNotices?: boolean }, +): void { + console.log('No intent-enabled skills found.') + printWarnings(warnings) + printNotices(notices, noticeOptions) +} + +function printPlacementTip(targetPath: string): void { + console.log( + `Tip: Keep the intent-skills block near the top of ${formatTargetPath(targetPath)} so agents read it before task-specific instructions.`, + ) +} + +function printWriteResult({ + mappingCount, + status, + targetPath, +}: { + mappingCount: number + status: 'created' | 'unchanged' | 'updated' + targetPath: string +}): void { + const target = formatTargetPath(targetPath) + + if (mappingCount === 0) { + switch (status) { + case 'created': + console.log(`Created ${target} with skill loading guidance.`) + break + case 'updated': + console.log(`Updated ${target} with skill loading guidance.`) + break + case 'unchanged': + console.log( + `No changes to ${target}; skill loading guidance already current.`, + ) + break + } + return + } + + switch (status) { + case 'created': + console.log(`Created ${target} with ${formatMappingCount(mappingCount)}.`) + break + case 'updated': + console.log(`Updated ${target} with ${formatMappingCount(mappingCount)}.`) + break + case 'unchanged': + console.log( + `No changes to ${target}; ${formatMappingCount(mappingCount)} already current.`, + ) + break + } +} + +export async function runInstallCommand( + options: InstallCommandOptions, + scanIntentsOrFail: (coreOptions?: IntentCoreOptions) => Promise, +): Promise { + if (options.printPrompt) { + console.log(INSTALL_PROMPT) + return + } + + const coreOptions = coreOptionsFromGlobalFlags(options) + const noticeOptions = noticeOptionsFromGlobalFlags(options) + + if (!options.map) { + const generated = buildIntentSkillGuidanceBlock( + detectIntentCommandPackageManager(), + ) + + if (options.dryRun) { + const targetPath = resolveIntentSkillsBlockTargetPath(process.cwd(), 1) + console.log( + `Generated skill loading guidance for ${formatTargetPath(targetPath!)}.`, + ) + console.log(generated.block) + return + } + + const result = writeIntentSkillsBlock({ + ...generated, + root: process.cwd(), + skipWhenEmpty: false, + }) + + if (!result.targetPath) { + fail('Install guidance target was not created.') + } + + const verification = verifyIntentSkillsBlockFile({ + expectedBlock: generated.block, + targetPath: result.targetPath, + }) + + const target = formatTargetPath(result.targetPath) + if (!verification.ok) { + fail( + [ + `Install verification failed for ${target}:`, + ...verification.errors.map((error) => `- ${error}`), + ].join('\n'), + ) + } + + printWriteResult(result) + printPlacementTip(result.targetPath) + return + } + + const scanResult = await scanIntentsOrFail(coreOptions) + const generated = buildIntentSkillsBlock(scanResult) + + if (options.dryRun) { + const targetPath = resolveIntentSkillsBlockTargetPath( + process.cwd(), + generated.mappingCount, + ) + + if (!targetPath) { + printNoActionableSkills( + scanResult.warnings, + scanResult.notices, + noticeOptions, + ) + return + } + + console.log( + `Generated ${formatMappingCount(generated.mappingCount)} for ${formatTargetPath(targetPath)}.`, + ) + console.log(generated.block) + printWarnings(scanResult.warnings) + printNotices(scanResult.notices, noticeOptions) + return + } + + const result = writeIntentSkillsBlock({ + ...generated, + root: process.cwd(), + }) + + if (!result.targetPath) { + printNoActionableSkills( + scanResult.warnings, + scanResult.notices, + noticeOptions, + ) + return + } + + const target = formatTargetPath(result.targetPath) + const verification = verifyIntentSkillsBlockFile({ + expectedBlock: generated.block, + expectedMappingCount: generated.mappingCount, + targetPath: result.targetPath, + }) + + if (!verification.ok) { + fail( + [ + `Install verification failed for ${target}:`, + ...verification.errors.map((error) => `- ${error}`), + ].join('\n'), + ) + } + + printWriteResult(result) + printPlacementTip(result.targetPath) + + printWarnings(scanResult.warnings) + printNotices(scanResult.notices, noticeOptions) +} diff --git a/packages/intent/src/install/guidance.ts b/packages/intent/src/commands/install/guidance.ts similarity index 97% rename from packages/intent/src/install/guidance.ts rename to packages/intent/src/commands/install/guidance.ts index 7884938..0ba3d55 100644 --- a/packages/intent/src/install/guidance.ts +++ b/packages/intent/src/commands/install/guidance.ts @@ -1,10 +1,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { parse as parseYaml } from 'yaml' -import { formatIntentCommand } from '../command-runner.js' -import { formatSkillUse, parseSkillUse } from '../skill-use.js' -import { isGeneratedMappingSkill } from '../skill-categories.js' -import type { ScanResult, SkillEntry } from '../types.js' +import { formatIntentCommand } from '../../shared/command-runner.js' +import { isGeneratedMappingSkill } from '../../skills/categories.js' +import type { ScanResult, SkillEntry } from '../../shared/types.js' +import { formatSkillUse, parseSkillUse } from '../../skills/use.js' const INTENT_SKILLS_START = '' const INTENT_SKILLS_END = '' diff --git a/packages/intent/src/commands/list.ts b/packages/intent/src/commands/list.ts index ea84286..1108a33 100644 --- a/packages/intent/src/commands/list.ts +++ b/packages/intent/src/commands/list.ts @@ -4,16 +4,16 @@ import { printDebugInfo, printNotices, printWarnings, -} from '../cli-support.js' -import { formatIntentCommand } from '../command-runner.js' -import { listIntentSkills } from '../core.js' -import type { GlobalScanFlags } from '../cli-support.js' +} from './support.js' +import { formatIntentCommand } from '../shared/command-runner.js' +import { listIntentSkills } from '../core/index.js' +import type { GlobalScanFlags } from './support.js' import type { IntentPackageSummary, IntentSkillList, IntentSkillSummary, -} from '../core.js' -import type { ScanResult } from '../types.js' +} from '../core/index.js' +import type { ScanResult } from '../shared/types.js' export interface ListCommandOptions extends GlobalScanFlags { json?: boolean @@ -105,7 +105,7 @@ export async function runListCommand( } const { computeSkillNameWidth, printSkillTree, printTable } = - await import('../display.js') + await import('../shared/display.js') if (result.packages.length === 0) { console.log('No intent-enabled packages found.') diff --git a/packages/intent/src/commands/load.ts b/packages/intent/src/commands/load.ts index 88db754..7a35de1 100644 --- a/packages/intent/src/commands/load.ts +++ b/packages/intent/src/commands/load.ts @@ -1,12 +1,12 @@ -import { fail } from '../cli-error.js' -import { coreOptionsFromGlobalFlags, printDebugInfo } from '../cli-support.js' +import { fail } from '../shared/cli-error.js' +import { coreOptionsFromGlobalFlags, printDebugInfo } from './support.js' import { IntentCoreError, loadIntentSkill, resolveIntentSkill, -} from '../core.js' -import type { GlobalScanFlags } from '../cli-support.js' -import type { LoadedIntentSkill, ResolvedIntentSkill } from '../core.js' +} from '../core/index.js' +import type { GlobalScanFlags } from './support.js' +import type { LoadedIntentSkill, ResolvedIntentSkill } from '../core/index.js' export interface LoadCommandOptions extends GlobalScanFlags { json?: boolean diff --git a/packages/intent/src/commands/meta.ts b/packages/intent/src/commands/meta.ts index 3be577e..d75d49d 100644 --- a/packages/intent/src/commands/meta.ts +++ b/packages/intent/src/commands/meta.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { join } from 'node:path' -import { fail } from '../cli-error.js' +import { fail } from '../shared/cli-error.js' export async function runMetaCommand( name: string | undefined, @@ -32,7 +32,7 @@ export async function runMetaCommand( return } - const { parseFrontmatter } = await import('../utils.js') + const { parseFrontmatter } = await import('../shared/utils.js') const entries = readdirSync(metaDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .filter((entry) => existsSync(join(metaDir, entry.name, 'SKILL.md'))) diff --git a/packages/intent/src/commands/edit-package-json.ts b/packages/intent/src/commands/setup/edit-package-json.ts similarity index 60% rename from packages/intent/src/commands/edit-package-json.ts rename to packages/intent/src/commands/setup/edit-package-json.ts index 39a7e07..da7eebf 100644 --- a/packages/intent/src/commands/edit-package-json.ts +++ b/packages/intent/src/commands/setup/edit-package-json.ts @@ -1,4 +1,4 @@ export async function runEditPackageJsonCommand(root: string): Promise { - const { runEditPackageJsonAll } = await import('../setup.js') + const { runEditPackageJsonAll } = await import('../../setup/index.js') runEditPackageJsonAll(root) } diff --git a/packages/intent/src/commands/setup-github-actions.ts b/packages/intent/src/commands/setup/github-actions.ts similarity index 66% rename from packages/intent/src/commands/setup-github-actions.ts rename to packages/intent/src/commands/setup/github-actions.ts index a663592..b5c1870 100644 --- a/packages/intent/src/commands/setup-github-actions.ts +++ b/packages/intent/src/commands/setup/github-actions.ts @@ -2,6 +2,6 @@ export async function runSetupGithubActionsCommand( root: string, metaDir: string, ): Promise { - const { runSetupGithubActions } = await import('../setup.js') + const { runSetupGithubActions } = await import('../../setup/index.js') runSetupGithubActions(root, metaDir) } diff --git a/packages/intent/src/commands/stale.ts b/packages/intent/src/commands/stale.ts index bb65ef6..462be93 100644 --- a/packages/intent/src/commands/stale.ts +++ b/packages/intent/src/commands/stale.ts @@ -1,5 +1,5 @@ -import { isCliFailure } from '../cli-error.js' -import type { StalenessReport } from '../types.js' +import { isCliFailure } from '../shared/cli-error.js' +import type { StalenessReport } from '../shared/types.js' export interface StaleCommandOptions { json?: boolean @@ -87,7 +87,7 @@ async function runGithubReview( createFailedStaleReviewItem, createWorkflowAdvisoryReviewItems, writeStaleReviewWorkflowFiles, - } = await import('../workflow-review.js') + } = await import('../staleness/workflow-review.js') const packageLabel = options.packageLabel ?? 'workspace' try { diff --git a/packages/intent/src/cli-support.ts b/packages/intent/src/commands/support.ts similarity index 90% rename from packages/intent/src/cli-support.ts rename to packages/intent/src/commands/support.ts index a79843d..505d2d9 100644 --- a/packages/intent/src/cli-support.ts +++ b/packages/intent/src/commands/support.ts @@ -1,12 +1,12 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { fail } from './cli-error.js' -import { resolveProjectContext } from './core/project-context.js' -import type { IntentCoreOptions } from './core.js' -import type { ScanOptions, ScanResult, StalenessReport } from './types.js' +import { fail } from '../shared/cli-error.js' +import { resolveProjectContext } from '../core/project-context.js' +import type { IntentCoreOptions } from '../core/index.js' +import type { ScanOptions, ScanResult, StalenessReport } from '../shared/types.js' -export { printNotices, printWarnings } from './cli-output.js' +export { printNotices, printWarnings } from '../shared/cli-output.js' export interface GlobalScanFlags { debug?: boolean @@ -25,7 +25,7 @@ export const INTENT_CHECK_SKILLS_WORKFLOW_VERSION = 3 export function getMetaDir(): string { const thisDir = dirname(fileURLToPath(import.meta.url)) - return join(thisDir, '..', 'meta') + return join(thisDir, '..', '..', 'meta') } export function getCheckSkillsWorkflowAdvisories(root: string): Array { @@ -51,7 +51,7 @@ export function getCheckSkillsWorkflowAdvisories(root: string): Array { export async function scanIntentsOrFail( coreOptions: IntentCoreOptions = {}, ): Promise { - const { scanForPolicedIntents } = await import('./core/source-policy.js') + const { scanForPolicedIntents } = await import('../core/source-policy.js') try { const { scan } = scanForPolicedIntents({ @@ -133,7 +133,7 @@ export async function resolveStaleTargets( context.workspaceRoot ?? context.packageRoot ?? resolvedRoot const workflowAdvisories = getCheckSkillsWorkflowAdvisories(advisoryRoot) const { buildWorkspaceCoverageSignals, checkStaleness, readPackageName } = - await import('./staleness.js') + await import('../staleness/index.js') const isWorkspaceRootTarget = context.workspaceRoot !== null && resolvedRoot === context.workspaceRoot @@ -155,7 +155,7 @@ export async function resolveStaleTargets( } const { findWorkspaceRoot, getWorkspaceInfo } = - await import('./workspace-patterns.js') + await import('../setup/workspace-patterns.js') const workspaceRoot = findWorkspaceRoot(resolvedRoot) const workspaceInfo = workspaceRoot ? getWorkspaceInfo(workspaceRoot) : null if (workspaceInfo) { @@ -168,7 +168,9 @@ export async function resolveStaleTargets( ), ), ) - const { readIntentArtifacts } = await import('./artifact-coverage.js') + const { readIntentArtifacts } = await import( + '../staleness/artifact-coverage.js' + ) const artifacts = existsSync(join(workspaceInfo.root, '_artifacts')) ? readIntentArtifacts(workspaceInfo.root) : null diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index e582700..0f7e617 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -5,10 +5,10 @@ import { writeFileSync, } from 'node:fs' import { basename, dirname, join, relative, resolve } from 'node:path' -import { fail, isCliFailure } from '../cli-error.js' -import { printWarnings } from '../cli-support.js' +import { fail, isCliFailure } from '../shared/cli-error.js' +import { printWarnings } from './support.js' import { resolveProjectContext } from '../core/project-context.js' -import { findWorkspacePackages } from '../workspace-patterns.js' +import { findWorkspacePackages } from '../setup/workspace-patterns.js' import type { ProjectContext } from '../core/project-context.js' interface ValidationError { @@ -329,7 +329,7 @@ async function runValidateCommandInternal( options: ValidateCommandOptions = {}, ): Promise { const [{ parse: parseYaml }, { findSkillFiles, readScalarField }] = - await Promise.all([import('yaml'), import('../utils.js')]) + await Promise.all([import('yaml'), import('../shared/utils.js')]) const context = resolveProjectContext({ cwd: process.cwd(), targetPath: dir, diff --git a/packages/intent/src/core/index.ts b/packages/intent/src/core/index.ts new file mode 100644 index 0000000..e30c346 --- /dev/null +++ b/packages/intent/src/core/index.ts @@ -0,0 +1 @@ +export * from './intent-core.js' diff --git a/packages/intent/src/core.ts b/packages/intent/src/core/intent-core.ts similarity index 92% rename from packages/intent/src/core.ts rename to packages/intent/src/core/intent-core.ts index bf593cd..3ccc42d 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core/intent-core.ts @@ -2,22 +2,22 @@ import { isAbsolute, relative, resolve } from 'node:path' import { compileExcludePatterns, getEffectiveExcludePatterns, -} from './core/excludes.js' -import { createIntentFsCache } from './fs-cache.js' -import { rewriteLoadedSkillMarkdownDestinations } from './core/markdown.js' -import { resolveSkillUseFastPath } from './core/load-resolution.js' -import { resolveProjectContext } from './core/project-context.js' +} from './excludes.js' +import { createIntentFsCache } from '../discovery/fs-cache.js' +import { rewriteLoadedSkillMarkdownDestinations } from './markdown.js' +import { resolveSkillUseFastPath } from './load-resolution.js' +import { resolveProjectContext } from './project-context.js' import { checkLoadAllowed, readSkillSourcesConfig, scanForPolicedIntents, -} from './core/source-policy.js' -import { ResolveSkillUseError, resolveSkillUse } from './resolver.js' -import { formatSkillUse, parseSkillUse } from './skill-use.js' -import type { ResolveSkillResult } from './resolver.js' -import type { IntentFsCache } from './fs-cache.js' -import type { ReadFs } from './utils.js' -import type { ScanOptions, ScanScope } from './types.js' +} from './source-policy.js' +import { ResolveSkillUseError, resolveSkillUse } from '../skills/resolver.js' +import { formatSkillUse, parseSkillUse } from '../skills/use.js' +import type { ResolveSkillResult } from '../skills/resolver.js' +import type { IntentFsCache } from '../discovery/fs-cache.js' +import type { ReadFs } from '../shared/utils.js' +import type { ScanOptions, ScanScope } from '../shared/types.js' import type { IntentCoreErrorCode, IntentCoreOptions, @@ -26,7 +26,7 @@ import type { LoadedIntentSkill, LoadedIntentSkillDebug, ResolvedIntentSkill, -} from './core/types.js' +} from './types.js' export type { IntentCoreErrorCode, @@ -38,7 +38,7 @@ export type { LoadedIntentSkillDebug, LoadedIntentSkill, ResolvedIntentSkill, -} from './core/types.js' +} from './types.js' export class IntentCoreError extends Error { readonly code: IntentCoreErrorCode diff --git a/packages/intent/src/core/load-resolution.ts b/packages/intent/src/core/load-resolution.ts index 97fda1c..a2a47bf 100644 --- a/packages/intent/src/core/load-resolution.ts +++ b/packages/intent/src/core/load-resolution.ts @@ -1,16 +1,16 @@ import { existsSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' -import { createIntentFsCache } from '../fs-cache.js' -import { resolveSkillEntry } from '../resolver.js' -import { scanIntentPackageAtRoot } from '../scanner.js' -import { findWorkspacePackages } from '../workspace-patterns.js' -import { getDeps, resolveDepDir } from '../utils.js' +import { createIntentFsCache } from '../discovery/fs-cache.js' +import { resolveSkillEntry } from '../skills/resolver.js' +import { scanIntentPackageAtRoot } from '../discovery/scanner.js' +import { findWorkspacePackages } from '../setup/workspace-patterns.js' +import { getDeps, resolveDepDir } from '../shared/utils.js' import { warningMentionsPackage } from './excludes.js' import { resolveProjectContext } from './project-context.js' -import type { ResolveSkillResult } from '../resolver.js' -import type { IntentFsCache } from '../fs-cache.js' +import type { ResolveSkillResult } from '../skills/resolver.js' +import type { IntentFsCache } from '../discovery/fs-cache.js' import type { ProjectContext } from './project-context.js' -import type { SkillUse } from '../skill-use.js' +import type { SkillUse } from '../skills/use.js' import type { IntentCoreOptions } from './types.js' interface WorkspacePackageInfo { diff --git a/packages/intent/src/core/markdown.ts b/packages/intent/src/core/markdown.ts index 5ebabb8..57a6ed4 100644 --- a/packages/intent/src/core/markdown.ts +++ b/packages/intent/src/core/markdown.ts @@ -1,5 +1,5 @@ import { dirname, isAbsolute, relative, resolve } from 'node:path' -import { toPosixPath } from '../utils.js' +import { toPosixPath } from '../shared/utils.js' function resolveFromCwd(path: string): string { return resolve(process.cwd(), path) diff --git a/packages/intent/src/core/project-context.ts b/packages/intent/src/core/project-context.ts index 8e5a703..2549587 100644 --- a/packages/intent/src/core/project-context.ts +++ b/packages/intent/src/core/project-context.ts @@ -3,7 +3,7 @@ import { dirname, join, relative, resolve } from 'node:path' import { findWorkspaceRoot, readWorkspacePatterns, -} from '../workspace-patterns.js' +} from '../setup/workspace-patterns.js' export type ProjectContext = { cwd: string diff --git a/packages/intent/src/core/source-policy.ts b/packages/intent/src/core/source-policy.ts index 3c440b8..c44c777 100644 --- a/packages/intent/src/core/source-policy.ts +++ b/packages/intent/src/core/source-policy.ts @@ -1,4 +1,4 @@ -import { scanForIntents } from '../scanner.js' +import { scanForIntents } from '../discovery/scanner.js' import { compileExcludePatterns, getConfigDirs, @@ -13,9 +13,9 @@ import { resolveProjectContext } from './project-context.js' import type { ExcludeMatcher } from './excludes.js' import type { ProjectContext } from './project-context.js' import type { SkillSourcesConfig } from './skill-sources.js' -import type { SkillUse } from '../skill-use.js' +import type { SkillUse } from '../skills/use.js' import type { IntentCoreOptions } from './types.js' -import type { IntentPackage, ScanOptions, ScanResult } from '../types.js' +import type { IntentPackage, ScanOptions, ScanResult } from '../shared/types.js' export const ALLOW_ALL_NOTICE = 'All skill sources allowed (intent.skills: ["*"]) — unvetted skills may be surfaced into agent guidance.' diff --git a/packages/intent/src/core/types.ts b/packages/intent/src/core/types.ts index a3107cd..361a0f6 100644 --- a/packages/intent/src/core/types.ts +++ b/packages/intent/src/core/types.ts @@ -4,7 +4,7 @@ import type { ScanScope, ScanStats, VersionConflict, -} from '../types.js' +} from '../shared/types.js' export interface IntentCoreOptions { cwd?: string diff --git a/packages/intent/src/fs-cache.ts b/packages/intent/src/discovery/fs-cache.ts similarity index 97% rename from packages/intent/src/fs-cache.ts rename to packages/intent/src/discovery/fs-cache.ts index 35b0e34..a8e5df4 100644 --- a/packages/intent/src/fs-cache.ts +++ b/packages/intent/src/discovery/fs-cache.ts @@ -3,8 +3,8 @@ import { createFsIdentityCache, findSkillFiles as findSkillFilesUncached, nodeReadFs, -} from './utils.js' -import type { ReadFs } from './utils.js' +} from '../shared/utils.js' +import type { ReadFs } from '../shared/utils.js' type PackageJsonReadResult = { packageJson: Record | null diff --git a/packages/intent/src/package-manager.ts b/packages/intent/src/discovery/package-manager.ts similarity index 97% rename from packages/intent/src/package-manager.ts rename to packages/intent/src/discovery/package-manager.ts index 6d3a7e5..97482e8 100644 --- a/packages/intent/src/package-manager.ts +++ b/packages/intent/src/discovery/package-manager.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' -import type { PackageManager } from './types.js' +import type { PackageManager } from '../shared/types.js' function readPackageManagerField(dir: string): PackageManager | null { try { diff --git a/packages/intent/src/discovery/register.ts b/packages/intent/src/discovery/register.ts index b606830..90516a0 100644 --- a/packages/intent/src/discovery/register.ts +++ b/packages/intent/src/discovery/register.ts @@ -1,13 +1,13 @@ import { existsSync } from 'node:fs' import { join, sep } from 'node:path' -import { rewriteSkillLoadPaths } from '../skill-paths.js' -import { listNodeModulesPackageDirs } from '../utils.js' +import { rewriteSkillLoadPaths } from '../skills/paths.js' +import { listNodeModulesPackageDirs } from '../shared/utils.js' import type { IntentConfig, IntentPackage, NodeModulesScanTarget, SkillEntry, -} from '../types.js' +} from '../shared/types.js' type PackageJson = Record diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/discovery/scanner.ts similarity index 99% rename from packages/intent/src/scanner.ts rename to packages/intent/src/discovery/scanner.ts index 22ed833..1410f30 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/discovery/scanner.ts @@ -9,22 +9,22 @@ import semver from 'semver' import { createDependencyWalker, createPackageRegistrar, -} from './discovery/index.js' +} from './index.js' import { detectGlobalNodeModules, nodeReadFs, parseFrontmatter, readScalarField, toPosixPath, -} from './utils.js' +} from '../shared/utils.js' import { createIntentFsCache } from './fs-cache.js' import { detectPackageManager } from './package-manager.js' import { findWorkspacePackages, findWorkspaceRoot, -} from './workspace-patterns.js' +} from '../setup/workspace-patterns.js' import type { IntentFsCache } from './fs-cache.js' -import type { ReadFs } from './utils.js' +import type { ReadFs } from '../shared/utils.js' import type { InstalledVariant, IntentConfig, @@ -34,7 +34,7 @@ import type { ScanScope, SkillEntry, VersionConflict, -} from './types.js' +} from '../shared/types.js' type ScanOptionsWithFsCache = ScanOptions & { fsCache?: IntentFsCache diff --git a/packages/intent/src/discovery/walk.ts b/packages/intent/src/discovery/walk.ts index 6709af0..ac233cf 100644 --- a/packages/intent/src/discovery/walk.ts +++ b/packages/intent/src/discovery/walk.ts @@ -3,10 +3,10 @@ import { getDeps, listNestedNodeModulesPackageDirs, resolveDepDir, -} from '../utils.js' -import { findWorkspacePackages } from '../workspace-patterns.js' -import type { IntentFsCache } from '../fs-cache.js' -import type { IntentPackage } from '../types.js' +} from '../shared/utils.js' +import { findWorkspacePackages } from '../setup/workspace-patterns.js' +import type { IntentFsCache } from './fs-cache.js' +import type { IntentPackage } from '../shared/types.js' type PackageJson = Record diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index 4b3e1e2..8137ed4 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, relative } from 'node:path' -import { fail } from '../cli-error.js' +import { fail } from '../shared/cli-error.js' import { ALL_HOOK_AGENTS, HOOK_AGENT_ADAPTERS } from './adapters.js' import { EDIT_TOOLS_BY_AGENT, GATE_DENY_REASON } from './policy.js' import type { HookAgent, HookInstallScope } from './types.js' diff --git a/packages/intent/src/index.ts b/packages/intent/src/index.ts index 75b49bd..dc71567 100644 --- a/packages/intent/src/index.ts +++ b/packages/intent/src/index.ts @@ -1,18 +1,18 @@ -export { scanForIntents } from './scanner.js' -export { checkStaleness } from './staleness.js' -export { readIntentArtifacts } from './artifact-coverage.js' +export { scanForIntents } from './discovery/scanner.js' +export { checkStaleness } from './staleness/index.js' +export { readIntentArtifacts } from './staleness/artifact-coverage.js' export { buildStaleReviewBody, collectStaleReviewItems, createFailedStaleReviewItem, type StaleReviewItem, -} from './workflow-review.js' +} from './staleness/workflow-review.js' export { findSkillFiles, getDeps, parseFrontmatter, resolveDepDir, -} from './utils.js' +} from './shared/utils.js' export { formatSkillUse, isSkillUseParseError, @@ -20,19 +20,19 @@ export { SkillUseParseError, type SkillUse, type SkillUseParseErrorCode, -} from './skill-use.js' +} from './skills/use.js' export { isResolveSkillUseError, resolveSkillUse, ResolveSkillUseError, type ResolveSkillResult, type ResolveSkillUseErrorCode, -} from './resolver.js' -export { runEditPackageJson, runSetupGithubActions } from './setup.js' +} from './skills/resolver.js' +export { runEditPackageJson, runSetupGithubActions } from './setup/index.js' export type { EditPackageJsonResult, SetupGithubActionsResult, -} from './setup.js' +} from './setup/index.js' export type { IntentConfig, IntentArtifactCoverageIgnore, @@ -47,4 +47,4 @@ export type { StalenessReport, SkillStaleness, StalenessSignal, -} from './types.js' +} from './shared/types.js' diff --git a/packages/intent/src/setup/index.ts b/packages/intent/src/setup/index.ts new file mode 100644 index 0000000..450f928 --- /dev/null +++ b/packages/intent/src/setup/index.ts @@ -0,0 +1 @@ +export * from './project-setup.js' diff --git a/packages/intent/src/setup.ts b/packages/intent/src/setup/project-setup.ts similarity index 99% rename from packages/intent/src/setup.ts rename to packages/intent/src/setup/project-setup.ts index 4f922e2..93718e8 100644 --- a/packages/intent/src/setup.ts +++ b/packages/intent/src/setup/project-setup.ts @@ -11,7 +11,7 @@ import { findWorkspaceRoot, readWorkspacePatterns, } from './workspace-patterns.js' -import { resolveProjectContext } from './core/project-context.js' +import { resolveProjectContext } from '../core/project-context.js' export { findPackagesWithSkills, diff --git a/packages/intent/src/workspace-patterns.ts b/packages/intent/src/setup/workspace-patterns.ts similarity index 99% rename from packages/intent/src/workspace-patterns.ts rename to packages/intent/src/setup/workspace-patterns.ts index 7117565..1285775 100644 --- a/packages/intent/src/workspace-patterns.ts +++ b/packages/intent/src/setup/workspace-patterns.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { dirname, join } from 'node:path' import { parse as parseJsonc } from 'jsonc-parser' import { parse as parseYaml } from 'yaml' -import { findSkillFiles } from './utils.js' +import { findSkillFiles } from '../shared/utils.js' import type { ParseError } from 'jsonc-parser' function normalizeWorkspacePattern(pattern: string): string { diff --git a/packages/intent/src/cli-error.ts b/packages/intent/src/shared/cli-error.ts similarity index 100% rename from packages/intent/src/cli-error.ts rename to packages/intent/src/shared/cli-error.ts diff --git a/packages/intent/src/cli-output.ts b/packages/intent/src/shared/cli-output.ts similarity index 100% rename from packages/intent/src/cli-output.ts rename to packages/intent/src/shared/cli-output.ts diff --git a/packages/intent/src/command-runner.ts b/packages/intent/src/shared/command-runner.ts similarity index 90% rename from packages/intent/src/command-runner.ts rename to packages/intent/src/shared/command-runner.ts index 658abef..0bd681a 100644 --- a/packages/intent/src/command-runner.ts +++ b/packages/intent/src/shared/command-runner.ts @@ -1,4 +1,4 @@ -import { detectPackageManager } from './package-manager.js' +import { detectPackageManager } from '../discovery/package-manager.js' import type { PackageManager } from './types.js' export { detectPackageManager as detectIntentCommandPackageManager } diff --git a/packages/intent/src/display.ts b/packages/intent/src/shared/display.ts similarity index 99% rename from packages/intent/src/display.ts rename to packages/intent/src/shared/display.ts index 297f49a..1cf7be5 100644 --- a/packages/intent/src/display.ts +++ b/packages/intent/src/shared/display.ts @@ -5,7 +5,7 @@ import { formatRuntimeSkillLookupHint, isStableLoadPath, -} from './skill-paths.js' +} from '../skills/paths.js' export interface SkillDisplay { name: string diff --git a/packages/intent/src/types.ts b/packages/intent/src/shared/types.ts similarity index 100% rename from packages/intent/src/types.ts rename to packages/intent/src/shared/types.ts diff --git a/packages/intent/src/utils.ts b/packages/intent/src/shared/utils.ts similarity index 100% rename from packages/intent/src/utils.ts rename to packages/intent/src/shared/utils.ts diff --git a/packages/intent/src/skill-categories.ts b/packages/intent/src/skills/categories.ts similarity index 91% rename from packages/intent/src/skill-categories.ts rename to packages/intent/src/skills/categories.ts index f31cac1..5b26e16 100644 --- a/packages/intent/src/skill-categories.ts +++ b/packages/intent/src/skills/categories.ts @@ -1,4 +1,4 @@ -import type { SkillEntry } from './types.js' +import type { SkillEntry } from '../shared/types.js' export type SkillCategory = 'maintainer' | 'meta' | 'reference' | 'task' diff --git a/packages/intent/src/skill-paths.ts b/packages/intent/src/skills/paths.ts similarity index 94% rename from packages/intent/src/skill-paths.ts rename to packages/intent/src/skills/paths.ts index 6d921fd..cc23672 100644 --- a/packages/intent/src/skill-paths.ts +++ b/packages/intent/src/skills/paths.ts @@ -1,8 +1,8 @@ import { existsSync } from 'node:fs' import { join, relative } from 'node:path' -import { toPosixPath } from './utils.js' -import type { SkillUse } from './skill-use.js' -import type { SkillEntry } from './types.js' +import { toPosixPath } from '../shared/utils.js' +import type { SkillUse } from './use.js' +import type { SkillEntry } from '../shared/types.js' const RUNTIME_SKILL_LOOKUP_COMMENT_PATTERN = /^Runtime lookup only: run `npx @tanstack\/intent@latest load [^`]+ --path`, and load its reported path for this session\. Do not copy the resolved path into this file\.$/ diff --git a/packages/intent/src/resolver.ts b/packages/intent/src/skills/resolver.ts similarity index 97% rename from packages/intent/src/resolver.ts rename to packages/intent/src/skills/resolver.ts index 121c542..93f0142 100644 --- a/packages/intent/src/resolver.ts +++ b/packages/intent/src/skills/resolver.ts @@ -1,11 +1,11 @@ -import { warningMentionsPackage } from './core/excludes.js' -import { parseSkillUse } from './skill-use.js' +import { warningMentionsPackage } from '../core/excludes.js' +import { parseSkillUse } from './use.js' import type { IntentPackage, ScanResult, SkillEntry, VersionConflict, -} from './types.js' +} from '../shared/types.js' export interface ResolveSkillResult { packageName: string diff --git a/packages/intent/src/skill-use.ts b/packages/intent/src/skills/use.ts similarity index 100% rename from packages/intent/src/skill-use.ts rename to packages/intent/src/skills/use.ts diff --git a/packages/intent/src/artifact-coverage.ts b/packages/intent/src/staleness/artifact-coverage.ts similarity index 99% rename from packages/intent/src/artifact-coverage.ts rename to packages/intent/src/staleness/artifact-coverage.ts index b1211a9..9c2f2a6 100644 --- a/packages/intent/src/artifact-coverage.ts +++ b/packages/intent/src/staleness/artifact-coverage.ts @@ -7,7 +7,7 @@ import type { IntentArtifactSet, IntentArtifactSkill, IntentArtifactWarning, -} from './types.js' +} from '../shared/types.js' type ArtifactKind = IntentArtifactFile['kind'] diff --git a/packages/intent/src/staleness.ts b/packages/intent/src/staleness/check.ts similarity index 99% rename from packages/intent/src/staleness.ts rename to packages/intent/src/staleness/check.ts index e62fb15..9f9bcd7 100644 --- a/packages/intent/src/staleness.ts +++ b/packages/intent/src/staleness/check.ts @@ -7,14 +7,14 @@ import { parseFrontmatter, readScalarField, toPosixPath, -} from './utils.js' +} from '../shared/utils.js' import type { IntentArtifactSet, IntentArtifactSkill, SkillStaleness, StalenessReport, StalenessSignal, -} from './types.js' +} from '../shared/types.js' // --------------------------------------------------------------------------- // Helpers diff --git a/packages/intent/src/staleness/index.ts b/packages/intent/src/staleness/index.ts new file mode 100644 index 0000000..80bf4ad --- /dev/null +++ b/packages/intent/src/staleness/index.ts @@ -0,0 +1 @@ +export * from './check.js' diff --git a/packages/intent/src/workflow-review.ts b/packages/intent/src/staleness/workflow-review.ts similarity index 99% rename from packages/intent/src/workflow-review.ts rename to packages/intent/src/staleness/workflow-review.ts index e742ce8..bb7c1e4 100644 --- a/packages/intent/src/workflow-review.ts +++ b/packages/intent/src/staleness/workflow-review.ts @@ -1,5 +1,5 @@ import { appendFileSync, writeFileSync } from 'node:fs' -import type { StalenessReport } from './types.js' +import type { StalenessReport } from '../shared/types.js' export interface StaleReviewItem { type: string diff --git a/packages/intent/tests/artifact-coverage.test.ts b/packages/intent/tests/artifact-coverage.test.ts index c7a0945..ac234fb 100644 --- a/packages/intent/tests/artifact-coverage.test.ts +++ b/packages/intent/tests/artifact-coverage.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { readIntentArtifacts } from '../src/artifact-coverage.js' +import { readIntentArtifacts } from '../src/staleness/artifact-coverage.js' let root: string diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index c42941f..1fbc85a 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { INSTALL_PROMPT } from '../src/commands/install.js' +import { INSTALL_PROMPT } from '../src/commands/install/command.js' import { isMainModule, main } from '../src/cli.js' const thisDir = dirname(fileURLToPath(import.meta.url)) diff --git a/packages/intent/tests/core.test.ts b/packages/intent/tests/core.test.ts index 9cacc37..0d55629 100644 --- a/packages/intent/tests/core.test.ts +++ b/packages/intent/tests/core.test.ts @@ -14,7 +14,7 @@ import { listIntentSkills, loadIntentSkill, resolveIntentSkill, -} from '../src/core.js' +} from '../src/core/index.js' const realTmpdir = realpathSync(tmpdir()) diff --git a/packages/intent/tests/fs-cache.test.ts b/packages/intent/tests/fs-cache.test.ts index d90f0d0..fa5e3c1 100644 --- a/packages/intent/tests/fs-cache.test.ts +++ b/packages/intent/tests/fs-cache.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { createIntentFsCache } from '../src/fs-cache.js' +import { createIntentFsCache } from '../src/discovery/fs-cache.js' let root: string diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index e888a16..7ddbf06 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -14,8 +14,8 @@ import { resolveIntentSkillsBlockTargetPath, verifyIntentSkillsBlockFile, writeIntentSkillsBlock, -} from '../src/install/guidance.js' -import type { IntentPackage, ScanResult, SkillEntry } from '../src/types.js' +} from '../src/commands/install/guidance.js' +import type { IntentPackage, ScanResult, SkillEntry } from '../src/shared/types.js' const tempDirs: Array = [] diff --git a/packages/intent/tests/integration/source-policy-surfaces.test.ts b/packages/intent/tests/integration/source-policy-surfaces.test.ts index ea4c0b3..1bedd01 100644 --- a/packages/intent/tests/integration/source-policy-surfaces.test.ts +++ b/packages/intent/tests/integration/source-policy-surfaces.test.ts @@ -8,7 +8,7 @@ import { import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { listIntentSkills, loadIntentSkill } from '../../src/core.js' +import { listIntentSkills, loadIntentSkill } from '../../src/core/index.js' import { main } from '../../src/cli.js' const realTmpdir = realpathSync(tmpdir()) diff --git a/packages/intent/tests/parse-frontmatter.test.ts b/packages/intent/tests/parse-frontmatter.test.ts index 89e4aef..182e2c6 100644 --- a/packages/intent/tests/parse-frontmatter.test.ts +++ b/packages/intent/tests/parse-frontmatter.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { parseFrontmatter } from '../src/utils.js' +import { parseFrontmatter } from '../src/shared/utils.js' let root: string diff --git a/packages/intent/tests/read-scalar-field.test.ts b/packages/intent/tests/read-scalar-field.test.ts index c4b0215..461d81f 100644 --- a/packages/intent/tests/read-scalar-field.test.ts +++ b/packages/intent/tests/read-scalar-field.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { readScalarField } from '../src/utils.js' +import { readScalarField } from '../src/shared/utils.js' describe('readScalarField', () => { it('reads a top-level scalar (old shape)', () => { diff --git a/packages/intent/tests/resolver.test.ts b/packages/intent/tests/resolver.test.ts index 78df895..b5a8e80 100644 --- a/packages/intent/tests/resolver.test.ts +++ b/packages/intent/tests/resolver.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' -import { ResolveSkillUseError, resolveSkillUse } from '../src/resolver.js' +import { ResolveSkillUseError, resolveSkillUse } from '../src/skills/resolver.js' import type { IntentPackage, ScanResult, SkillEntry, VersionConflict, -} from '../src/types.js' +} from '../src/shared/types.js' function skill( name: string, diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 289e86d..5853ef4 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -11,7 +11,7 @@ import { createRequire } from 'node:module' import { join, sep } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { scanForIntents, scanIntentPackageAtRoot } from '../src/scanner.js' +import { scanForIntents, scanIntentPackageAtRoot } from '../src/discovery/scanner.js' // ── Helpers ── diff --git a/packages/intent/tests/setup.test.ts b/packages/intent/tests/setup.test.ts index d1aee9e..71d32b3 100644 --- a/packages/intent/tests/setup.test.ts +++ b/packages/intent/tests/setup.test.ts @@ -13,8 +13,8 @@ import { runEditPackageJson, runEditPackageJsonAll, runSetupGithubActions, -} from '../src/setup.js' -import type { EditPackageJsonResult, MonorepoResult } from '../src/setup.js' +} from '../src/setup/index.js' +import type { EditPackageJsonResult, MonorepoResult } from '../src/setup/index.js' const repoRoot = join(import.meta.dirname, '..', '..', '..') diff --git a/packages/intent/tests/skill-categories.test.ts b/packages/intent/tests/skill-categories.test.ts index 5432f3d..aea896c 100644 --- a/packages/intent/tests/skill-categories.test.ts +++ b/packages/intent/tests/skill-categories.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { getSkillCategory, isGeneratedMappingSkill, -} from '../src/skill-categories.js' +} from '../src/skills/categories.js' describe('skill categories', () => { it('treats empty and unknown types as task skills', () => { diff --git a/packages/intent/tests/skill-paths.test.ts b/packages/intent/tests/skill-paths.test.ts index d29ea1f..162af17 100644 --- a/packages/intent/tests/skill-paths.test.ts +++ b/packages/intent/tests/skill-paths.test.ts @@ -10,8 +10,8 @@ import { isRuntimeSkillLookupComment, isStableLoadPath, rewriteSkillLoadPaths, -} from '../src/skill-paths.js' -import type { SkillEntry } from '../src/types.js' +} from '../src/skills/paths.js' +import type { SkillEntry } from '../src/shared/types.js' const tempDirs: Array = [] diff --git a/packages/intent/tests/skill-use.test.ts b/packages/intent/tests/skill-use.test.ts index da4db33..04d00d3 100644 --- a/packages/intent/tests/skill-use.test.ts +++ b/packages/intent/tests/skill-use.test.ts @@ -3,7 +3,7 @@ import { SkillUseParseError, formatSkillUse, parseSkillUse, -} from '../src/skill-use.js' +} from '../src/skills/use.js' describe('skill use helpers', () => { it('formats scoped packages and slash-named skills', () => { diff --git a/packages/intent/tests/skills.test.ts b/packages/intent/tests/skills.test.ts index 36bfbbc..16b0456 100644 --- a/packages/intent/tests/skills.test.ts +++ b/packages/intent/tests/skills.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { join, relative, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { parse as parseYaml } from 'yaml' -import { findSkillFiles } from '../src/utils.js' +import { findSkillFiles } from '../src/shared/utils.js' // ── Types ── diff --git a/packages/intent/tests/source-policy.test.ts b/packages/intent/tests/source-policy.test.ts index 0c565a4..9fdef84 100644 --- a/packages/intent/tests/source-policy.test.ts +++ b/packages/intent/tests/source-policy.test.ts @@ -17,7 +17,7 @@ import { readSkillSourcesConfig, } from '../src/core/source-policy.js' import { parseSkillSources } from '../src/core/skill-sources.js' -import type { IntentPackage, SkillEntry } from '../src/types.js' +import type { IntentPackage, SkillEntry } from '../src/shared/types.js' const realTmpdir = realpathSync(tmpdir()) diff --git a/packages/intent/tests/stale-command.test.ts b/packages/intent/tests/stale-command.test.ts index db9f559..29653ba 100644 --- a/packages/intent/tests/stale-command.test.ts +++ b/packages/intent/tests/stale-command.test.ts @@ -11,7 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { INTENT_CHECK_SKILLS_WORKFLOW_VERSION, getCheckSkillsWorkflowAdvisories, -} from '../src/cli-support.js' +} from '../src/commands/support.js' import { runStaleCommand } from '../src/commands/stale.js' describe('runStaleCommand', () => { diff --git a/packages/intent/tests/staleness.test.ts b/packages/intent/tests/staleness.test.ts index d25fbdc..fa26657 100644 --- a/packages/intent/tests/staleness.test.ts +++ b/packages/intent/tests/staleness.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { checkStaleness } from '../src/staleness.js' +import { checkStaleness } from '../src/staleness/index.js' // --------------------------------------------------------------------------- // Fixture helpers diff --git a/packages/intent/tests/workflow-review.test.ts b/packages/intent/tests/workflow-review.test.ts index 648e2ee..96dedbc 100644 --- a/packages/intent/tests/workflow-review.test.ts +++ b/packages/intent/tests/workflow-review.test.ts @@ -8,8 +8,8 @@ import { createFailedStaleReviewItem, createWorkflowAdvisoryReviewItems, writeStaleReviewWorkflowFiles, -} from '../src/workflow-review.js' -import type { StalenessReport } from '../src/types.js' +} from '../src/staleness/workflow-review.js' +import type { StalenessReport } from '../src/shared/types.js' const repoRoot = join(import.meta.dirname, '..', '..', '..') diff --git a/packages/intent/tests/workspace-patterns.test.ts b/packages/intent/tests/workspace-patterns.test.ts index cfb8338..3c9c6a6 100644 --- a/packages/intent/tests/workspace-patterns.test.ts +++ b/packages/intent/tests/workspace-patterns.test.ts @@ -15,7 +15,7 @@ import { getWorkspaceInfo, readWorkspacePatterns, resolveWorkspacePackages, -} from '../src/workspace-patterns.js' +} from '../src/setup/workspace-patterns.js' const roots: Array = [] const cwdStack: Array = [] From d5c9525dafdff73f42b9ebc65e450aab849ec211 Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 20 Jun 2026 22:41:49 -0700 Subject: [PATCH 11/13] Refactor intent installation and update file structure; remove deprecated install command and enhance type definitions --- .../harness/setup-intent-condition.ts | 4 +- knip.json | 11 +- packages/intent/src/commands/install.ts | 315 ------------------ packages/intent/src/core.ts | 1 + packages/intent/src/hooks/adapters.ts | 6 +- packages/intent/src/hooks/install.ts | 2 +- packages/intent/src/setup.ts | 1 + 7 files changed, 15 insertions(+), 325 deletions(-) delete mode 100644 packages/intent/src/commands/install.ts create mode 100644 packages/intent/src/core.ts create mode 100644 packages/intent/src/setup.ts diff --git a/evals/intent-discovery/harness/setup-intent-condition.ts b/evals/intent-discovery/harness/setup-intent-condition.ts index c2b0f5e..7db3271 100644 --- a/evals/intent-discovery/harness/setup-intent-condition.ts +++ b/evals/intent-discovery/harness/setup-intent-condition.ts @@ -3,14 +3,14 @@ import { join } from 'node:path' import { buildIntentSkillGuidanceBlock, buildIntentSkillsBlock, -} from '../../../packages/intent/src/commands/install-writer.js' +} from '../../../packages/intent/src/commands/install/guidance.js' import { expectedSkillUseByArea, packageAllowlistByArea, } from '../corpus/skill-uses' import type { IntentDiscoveryCondition } from '../corpus/conditions' import type { ExpectedSkillArea } from '../corpus/tasks' -import type { ScanResult } from '../../../packages/intent/src/types.js' +import type { ScanResult } from '../../../packages/intent/src/shared/types.js' export type AppliedIntentCondition = { condition: IntentDiscoveryCondition diff --git a/knip.json b/knip.json index fb1b4d4..f46e762 100644 --- a/knip.json +++ b/knip.json @@ -4,15 +4,18 @@ ".": { "entry": [ "scripts/*.ts", - "evals/intent-discovery/*.eval.ts", - "evals/intent-discovery/bin/*.mjs" + "evals/intent-discovery/**/*.ts", + "evals/intent-discovery/**/*.mjs", + "evals/intent-discovery/harness/intent-hooks/gate.mjs" ], "ignoreBinaries": ["copilot", "diff"], - "ignoreFiles": ["evals/intent-discovery/fixtures/**/src/**/*"] + "ignoreFiles": [ + "evals/intent-discovery/fixtures/**/src/**/*", + "evals/intent-discovery/harness/intent-hooks/hook-core.d.mts" + ] }, "packages/intent": { "entry": ["src/index.ts", "src/cli.ts", "src/core.ts", "src/setup.ts"], - "ignore": ["meta/**"], "ignoreDependencies": ["verdaccio"] } } diff --git a/packages/intent/src/commands/install.ts b/packages/intent/src/commands/install.ts deleted file mode 100644 index 3dd0a76..0000000 --- a/packages/intent/src/commands/install.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { relative } from 'node:path' -import { fail } from '../cli-error.js' -import { detectIntentCommandPackageManager } from '../command-runner.js' -import { - coreOptionsFromGlobalFlags, - noticeOptionsFromGlobalFlags, - printNotices, - printWarnings, -} from '../cli-support.js' -import { - buildIntentSkillGuidanceBlock, - buildIntentSkillsBlock, - resolveIntentSkillsBlockTargetPath, - verifyIntentSkillsBlockFile, - writeIntentSkillsBlock, -} from './install-guidance.js' -import type { GlobalScanFlags } from '../cli-support.js' -import type { IntentCoreOptions } from '../core.js' -import type { ScanResult } from '../types.js' - -export const INSTALL_PROMPT = `You are an AI assistant helping a developer set up skill-to-task mappings for their project. - -Goal: create or update one agent config file with an intent-skills mapping block. - -Hard rules: -- Do not report success until a file was created or updated, or an existing mapping block was confirmed. -- If skills are discovered and no mapping block exists, create AGENTS.md unless the user asks for another supported config file. -- If a mapping block already exists in a supported config file, update that file. -- Preserve all content outside the managed block unchanged. -- Store compact \`id\` values and runnable \`run\` commands in the managed block; do not write local paths. -- Never write absolute local file paths, node_modules paths, or package-manager-internal paths in the managed block. -- Verify the target file before your final response. - -Follow these steps in order: - -1. CHECK FOR EXISTING MAPPINGS - Search the project's agent config files (AGENTS.md, CLAUDE.md, .cursorrules, - .github/copilot-instructions.md) for a block delimited by: - - - - If found: show the user the current mappings, keep that file as the source of truth, - and ask "What would you like to update?" Then skip to step 4 with their requested changes. - - If not found: continue to step 2. - -2. DISCOVER AVAILABLE SKILLS - Run: \`npx @tanstack/intent@latest list\` - This scans project-local node_modules by default and outputs each package and skill's name, - description, and source. - If the user explicitly wants globally installed skills included, run: - \`npx @tanstack/intent@latest list --global\` - This works best in Node-compatible environments (npm, pnpm, Bun, or Deno npm interop - with node_modules enabled). - If no skills are found, do not create a config file. Report: "No intent-enabled skills found." - -3. SCAN THE REPOSITORY - Build a picture of the project's structure and patterns: - - Read package.json for library dependencies - - Survey the directory layout (src/, app/, routes/, components/, api/, etc.) - - Note recurring patterns (routing, data fetching, auth, UI components, etc.) - - Mapping coverage rule: - - Create mappings for all discovered actionable skills. - - Do not omit an actionable skill only because the repo does not currently appear to use it. - - Do not map reference, meta, maintainer, or maintainer-only skills by default. - - Include slash-named sub-skills when no parent mapping exists, or when they describe distinct user tasks. - - If the proposed block would exceed 12 mappings, show the full discovered list and ask which packages - or skill groups to include before writing. - - Add one fallback note telling the agent to run \`npx @tanstack/intent@latest list\` for less common local skills. - - Based on the repository scan and the coverage rule, propose the skill-to-task mappings. - For each one explain: - - The task or code area (in plain language the user would recognise) - - Which skill applies and why - - Then ask: "What other tasks do you commonly use AI coding agents for? - I'll create mappings for those too." - Also ask: "I'll default to AGENTS.md unless you want another supported config file. - Do you have a preference?" - -4. WRITE THE MAPPINGS BLOCK - Once you have the full set of mappings, write or update the agent config file. - - If you found an existing intent-skills block, update that file in place. - - Otherwise prefer AGENTS.md by default, unless the user asked for another supported file. - - Do not stop after discovery. If skills were found, the task is incomplete until this file exists - and contains the managed block. - - Use this exact block: - - -# TanStack Intent - before editing files, run the matching guidance command. -tanstackIntent: - - id: "@scope/package#skill-name" - run: "npx @tanstack/intent@latest load @scope/package#skill-name" - for: "describe the task or code area here" - - - Rules: - - Use the user's own words for \`for\` descriptions - - Use compact \`id\` values in \`#\` format - - Include a \`run\` command that loads the matching \`id\` - - Do not include machine-specific directories such as \`/Users/...\`, \`/home/...\`, \`/private/...\`, - drive letters, temp workspace paths, \`.pnpm/\`, \`.bun/\`, or \`.yarn/\`. - - Agents should run the \`run\` command before editing matching files - - Keep entries concise - this block is read on every agent task - - Preserve all content outside the block tags unchanged - - If the user is on Deno, note that this setup is best-effort today and relies on npm interop - -5. VERIFY AND REPORT - Before reporting completion: - - Confirm the target file exists - - Confirm it contains both managed block markers - - Confirm every mapping has \`id\`, \`run\`, and \`for\` - - Confirm every \`id\` parses as \`#\` - - Confirm no mapping includes local file paths - - Confirm no path-like machine-specific values are stored in the managed block - - Confirm every discovered actionable skill is mapped, skipped by rule, or deferred by user choice - - Final response must include: - - The target file path - - Whether it was created, updated, or already contained a valid block - - The number of mappings - - The verification result` - -export interface InstallCommandOptions extends GlobalScanFlags { - dryRun?: boolean - map?: boolean - printPrompt?: boolean -} - -function formatTargetPath(targetPath: string): string { - return relative(process.cwd(), targetPath) || targetPath -} - -function formatMappingCount(mappingCount: number): string { - return `${mappingCount} ${mappingCount === 1 ? 'mapping' : 'mappings'}` -} - -function printNoActionableSkills( - warnings: Array, - notices: Array, - noticeOptions: { noNotices?: boolean }, -): void { - console.log('No intent-enabled skills found.') - printWarnings(warnings) - printNotices(notices, noticeOptions) -} - -function printPlacementTip(targetPath: string): void { - console.log( - `Tip: Keep the intent-skills block near the top of ${formatTargetPath(targetPath)} so agents read it before task-specific instructions.`, - ) -} - -function printWriteResult({ - mappingCount, - status, - targetPath, -}: { - mappingCount: number - status: 'created' | 'unchanged' | 'updated' - targetPath: string -}): void { - const target = formatTargetPath(targetPath) - - if (mappingCount === 0) { - switch (status) { - case 'created': - console.log(`Created ${target} with skill loading guidance.`) - break - case 'updated': - console.log(`Updated ${target} with skill loading guidance.`) - break - case 'unchanged': - console.log( - `No changes to ${target}; skill loading guidance already current.`, - ) - break - } - return - } - - switch (status) { - case 'created': - console.log(`Created ${target} with ${formatMappingCount(mappingCount)}.`) - break - case 'updated': - console.log(`Updated ${target} with ${formatMappingCount(mappingCount)}.`) - break - case 'unchanged': - console.log( - `No changes to ${target}; ${formatMappingCount(mappingCount)} already current.`, - ) - break - } -} - -export async function runInstallCommand( - options: InstallCommandOptions, - scanIntentsOrFail: (coreOptions?: IntentCoreOptions) => Promise, -): Promise { - if (options.printPrompt) { - console.log(INSTALL_PROMPT) - return - } - - const coreOptions = coreOptionsFromGlobalFlags(options) - const noticeOptions = noticeOptionsFromGlobalFlags(options) - - if (!options.map) { - const generated = buildIntentSkillGuidanceBlock( - detectIntentCommandPackageManager(), - ) - - if (options.dryRun) { - const targetPath = resolveIntentSkillsBlockTargetPath(process.cwd(), 1) - console.log( - `Generated skill loading guidance for ${formatTargetPath(targetPath!)}.`, - ) - console.log(generated.block) - return - } - - const result = writeIntentSkillsBlock({ - ...generated, - root: process.cwd(), - skipWhenEmpty: false, - }) - - if (!result.targetPath) { - fail('Install guidance target was not created.') - } - - const verification = verifyIntentSkillsBlockFile({ - expectedBlock: generated.block, - targetPath: result.targetPath, - }) - - const target = formatTargetPath(result.targetPath) - if (!verification.ok) { - fail( - [ - `Install verification failed for ${target}:`, - ...verification.errors.map((error) => `- ${error}`), - ].join('\n'), - ) - } - - printWriteResult(result) - printPlacementTip(result.targetPath) - return - } - - const scanResult = await scanIntentsOrFail(coreOptions) - const generated = buildIntentSkillsBlock(scanResult) - - if (options.dryRun) { - const targetPath = resolveIntentSkillsBlockTargetPath( - process.cwd(), - generated.mappingCount, - ) - - if (!targetPath) { - printNoActionableSkills( - scanResult.warnings, - scanResult.notices, - noticeOptions, - ) - return - } - - console.log( - `Generated ${formatMappingCount(generated.mappingCount)} for ${formatTargetPath(targetPath)}.`, - ) - console.log(generated.block) - printWarnings(scanResult.warnings) - printNotices(scanResult.notices, noticeOptions) - return - } - - const result = writeIntentSkillsBlock({ - ...generated, - root: process.cwd(), - }) - - if (!result.targetPath) { - printNoActionableSkills( - scanResult.warnings, - scanResult.notices, - noticeOptions, - ) - return - } - - const target = formatTargetPath(result.targetPath) - const verification = verifyIntentSkillsBlockFile({ - expectedBlock: generated.block, - expectedMappingCount: generated.mappingCount, - targetPath: result.targetPath, - }) - - if (!verification.ok) { - fail( - [ - `Install verification failed for ${target}:`, - ...verification.errors.map((error) => `- ${error}`), - ].join('\n'), - ) - } - - printWriteResult(result) - printPlacementTip(result.targetPath) - - printWarnings(scanResult.warnings) - printNotices(scanResult.notices, noticeOptions) -} diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts new file mode 100644 index 0000000..7a0cec1 --- /dev/null +++ b/packages/intent/src/core.ts @@ -0,0 +1 @@ +export * from './core/index.js' \ No newline at end of file diff --git a/packages/intent/src/hooks/adapters.ts b/packages/intent/src/hooks/adapters.ts index 03d049e..472b36e 100644 --- a/packages/intent/src/hooks/adapters.ts +++ b/packages/intent/src/hooks/adapters.ts @@ -1,12 +1,12 @@ import { join } from 'node:path' import type { HookAgent, HookInstallScope } from './types.js' -export type HookAdapterPaths = { +type HookAdapterPaths = { configPath: string scriptPath: string } -export type HookAdapterContext = { +type HookAdapterContext = { copilotHome?: string homeDir: string root: string @@ -22,7 +22,7 @@ export type HookAgentAdapter = { ) => HookAdapterPaths } -export const HOOK_SCRIPT_DIR = '.intent/hooks' +const HOOK_SCRIPT_DIR = '.intent/hooks' export const HOOK_AGENT_ADAPTERS: Record = { claude: { diff --git a/packages/intent/src/hooks/install.ts b/packages/intent/src/hooks/install.ts index 8137ed4..0739f35 100644 --- a/packages/intent/src/hooks/install.ts +++ b/packages/intent/src/hooks/install.ts @@ -6,7 +6,7 @@ import { ALL_HOOK_AGENTS, HOOK_AGENT_ADAPTERS } from './adapters.js' import { EDIT_TOOLS_BY_AGENT, GATE_DENY_REASON } from './policy.js' import type { HookAgent, HookInstallScope } from './types.js' -export type HookInstallStatus = 'created' | 'skipped' | 'unchanged' | 'updated' +type HookInstallStatus = 'created' | 'skipped' | 'unchanged' | 'updated' export type HookInstallResult = { agent: HookAgent diff --git a/packages/intent/src/setup.ts b/packages/intent/src/setup.ts new file mode 100644 index 0000000..dcd9aa2 --- /dev/null +++ b/packages/intent/src/setup.ts @@ -0,0 +1 @@ +export * from './setup/index.js' \ No newline at end of file From e14434acd05582cbfd5e4a7fcad6e89086b24974 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 05:45:17 +0000 Subject: [PATCH 12/13] ci: apply automated fixes --- .../harness/intent-hooks/hook-core.mjs | 7 ++++++- .../intent-discovery/harness/intent-hooks/hook-io.mjs | 7 +------ evals/intent-discovery/live-copilot-harness.eval.ts | 1 - packages/intent/src/cli.ts | 5 ++--- packages/intent/src/commands/support.ts | 11 +++++++---- packages/intent/src/core.ts | 2 +- packages/intent/src/discovery/scanner.ts | 5 +---- packages/intent/src/hooks/agents/claude.ts | 2 +- packages/intent/src/hooks/agents/codex.ts | 2 +- packages/intent/src/hooks/agents/copilot.ts | 2 +- packages/intent/src/hooks/policy.ts | 4 ++-- packages/intent/src/hooks/types.ts | 2 +- packages/intent/src/setup.ts | 2 +- packages/intent/src/skills/categories.ts | 8 ++++++-- packages/intent/tests/cli.test.ts | 4 +++- packages/intent/tests/hooks.test.ts | 8 ++++++-- packages/intent/tests/install-writer.test.ts | 10 ++++++---- packages/intent/tests/resolver.test.ts | 5 ++++- packages/intent/tests/scanner.test.ts | 5 ++++- packages/intent/tests/setup.test.ts | 5 ++++- 20 files changed, 58 insertions(+), 39 deletions(-) diff --git a/evals/intent-discovery/harness/intent-hooks/hook-core.mjs b/evals/intent-discovery/harness/intent-hooks/hook-core.mjs index aa873a9..8391838 100644 --- a/evals/intent-discovery/harness/intent-hooks/hook-core.mjs +++ b/evals/intent-discovery/harness/intent-hooks/hook-core.mjs @@ -1,7 +1,12 @@ const INTENT_COMMAND_PATTERN = /(?:^|\s|&&|;|\|)\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 -export const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']) +export const EDIT_TOOLS = new Set([ + 'Write', + 'Edit', + 'MultiEdit', + 'NotebookEdit', +]) export const GATE_DENY_REASON = 'Blocked: load the matching TanStack guidance before editing. Use the guidance command from the AGENTS.md tanstackIntent block, then retry the edit.' diff --git a/evals/intent-discovery/harness/intent-hooks/hook-io.mjs b/evals/intent-discovery/harness/intent-hooks/hook-io.mjs index 093df6c..2c0b076 100644 --- a/evals/intent-discovery/harness/intent-hooks/hook-io.mjs +++ b/evals/intent-discovery/harness/intent-hooks/hook-io.mjs @@ -1,9 +1,4 @@ -import { - appendFileSync, - existsSync, - mkdirSync, - readFileSync, -} from 'node:fs' +import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' import { dirname } from 'node:path' const ATTRIB_FILE = process.env.INTENT_DISCOVERY_GATE_STATE diff --git a/evals/intent-discovery/live-copilot-harness.eval.ts b/evals/intent-discovery/live-copilot-harness.eval.ts index 6b18200..faac304 100644 --- a/evals/intent-discovery/live-copilot-harness.eval.ts +++ b/evals/intent-discovery/live-copilot-harness.eval.ts @@ -127,7 +127,6 @@ describe.concurrent('Intent discovery live runs', () => { } }) - function liveRunCountFromEnv(): number { const value = Number(process.env.INTENT_DISCOVERY_RUN_COUNT ?? '1') diff --git a/packages/intent/src/cli.ts b/packages/intent/src/cli.ts index b01fe08..0498628 100644 --- a/packages/intent/src/cli.ts +++ b/packages/intent/src/cli.ts @@ -152,9 +152,8 @@ function createCli(): CAC { fail('Unknown hooks action: expected install.') } - const { runHooksInstallCommand } = await import( - './commands/hooks/command.js' - ) + const { runHooksInstallCommand } = + await import('./commands/hooks/command.js') runHooksInstallCommand(options) }, ) diff --git a/packages/intent/src/commands/support.ts b/packages/intent/src/commands/support.ts index 505d2d9..5304d8c 100644 --- a/packages/intent/src/commands/support.ts +++ b/packages/intent/src/commands/support.ts @@ -4,7 +4,11 @@ import { fileURLToPath } from 'node:url' import { fail } from '../shared/cli-error.js' import { resolveProjectContext } from '../core/project-context.js' import type { IntentCoreOptions } from '../core/index.js' -import type { ScanOptions, ScanResult, StalenessReport } from '../shared/types.js' +import type { + ScanOptions, + ScanResult, + StalenessReport, +} from '../shared/types.js' export { printNotices, printWarnings } from '../shared/cli-output.js' @@ -168,9 +172,8 @@ export async function resolveStaleTargets( ), ), ) - const { readIntentArtifacts } = await import( - '../staleness/artifact-coverage.js' - ) + const { readIntentArtifacts } = + await import('../staleness/artifact-coverage.js') const artifacts = existsSync(join(workspaceInfo.root, '_artifacts')) ? readIntentArtifacts(workspaceInfo.root) : null diff --git a/packages/intent/src/core.ts b/packages/intent/src/core.ts index 7a0cec1..2632019 100644 --- a/packages/intent/src/core.ts +++ b/packages/intent/src/core.ts @@ -1 +1 @@ -export * from './core/index.js' \ No newline at end of file +export * from './core/index.js' diff --git a/packages/intent/src/discovery/scanner.ts b/packages/intent/src/discovery/scanner.ts index 1410f30..c7b1b3f 100644 --- a/packages/intent/src/discovery/scanner.ts +++ b/packages/intent/src/discovery/scanner.ts @@ -6,10 +6,7 @@ import { existsSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import semver from 'semver' -import { - createDependencyWalker, - createPackageRegistrar, -} from './index.js' +import { createDependencyWalker, createPackageRegistrar } from './index.js' import { detectGlobalNodeModules, nodeReadFs, diff --git a/packages/intent/src/hooks/agents/claude.ts b/packages/intent/src/hooks/agents/claude.ts index 4c387c8..44bb71f 100644 --- a/packages/intent/src/hooks/agents/claude.ts +++ b/packages/intent/src/hooks/agents/claude.ts @@ -22,4 +22,4 @@ export function formatClaudePreToolUseOutput( permissionDecisionReason: decision.reason, }, } -} \ No newline at end of file +} diff --git a/packages/intent/src/hooks/agents/codex.ts b/packages/intent/src/hooks/agents/codex.ts index e052633..ccc13e8 100644 --- a/packages/intent/src/hooks/agents/codex.ts +++ b/packages/intent/src/hooks/agents/codex.ts @@ -22,4 +22,4 @@ export function formatCodexPreToolUseOutput( permissionDecisionReason: decision.reason, }, } -} \ No newline at end of file +} diff --git a/packages/intent/src/hooks/agents/copilot.ts b/packages/intent/src/hooks/agents/copilot.ts index 92f9dfb..c223107 100644 --- a/packages/intent/src/hooks/agents/copilot.ts +++ b/packages/intent/src/hooks/agents/copilot.ts @@ -16,4 +16,4 @@ export function formatCopilotPreToolUseOutput( permissionDecision: 'deny', permissionDecisionReason: decision.reason, } -} \ No newline at end of file +} diff --git a/packages/intent/src/hooks/policy.ts b/packages/intent/src/hooks/policy.ts index db1c06d..df9eb94 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: load matching TanStack guidance before editing. Follow this repo's TanStack guidance setup, then retry the edit." export function parseIntentInvocation( command: unknown, @@ -110,4 +110,4 @@ function safeCommandFromString(value: string): string { } catch { return value } -} \ No newline at end of file +} diff --git a/packages/intent/src/hooks/types.ts b/packages/intent/src/hooks/types.ts index 771cdfb..db5602c 100644 --- a/packages/intent/src/hooks/types.ts +++ b/packages/intent/src/hooks/types.ts @@ -20,4 +20,4 @@ export type ToolEvent = { toolName?: unknown tool_input?: unknown toolArgs?: unknown -} \ No newline at end of file +} diff --git a/packages/intent/src/setup.ts b/packages/intent/src/setup.ts index dcd9aa2..7f77e28 100644 --- a/packages/intent/src/setup.ts +++ b/packages/intent/src/setup.ts @@ -1 +1 @@ -export * from './setup/index.js' \ No newline at end of file +export * from './setup/index.js' diff --git a/packages/intent/src/skills/categories.ts b/packages/intent/src/skills/categories.ts index 5b26e16..09581f8 100644 --- a/packages/intent/src/skills/categories.ts +++ b/packages/intent/src/skills/categories.ts @@ -4,7 +4,9 @@ export type SkillCategory = 'maintainer' | 'meta' | 'reference' | 'task' const MAINTAINER_TYPES = new Set(['maintainer', 'maintainer-only']) -export function getSkillCategory(skill: Pick): SkillCategory { +export function getSkillCategory( + skill: Pick, +): SkillCategory { const type = skill.type?.trim().toLowerCase() if (type === 'reference') return 'reference' @@ -14,6 +16,8 @@ export function getSkillCategory(skill: Pick): SkillCategory return 'task' } -export function isGeneratedMappingSkill(skill: Pick): boolean { +export function isGeneratedMappingSkill( + skill: Pick, +): boolean { return getSkillCategory(skill) === 'task' } diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 1fbc85a..21b20e9 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -621,7 +621,9 @@ describe('cli commands', () => { }) it('uses only global packages during install --map --global-only', async () => { - const root = mkdtempSync(join(realTmpdir, 'intent-cli-install-global-only-')) + const root = mkdtempSync( + join(realTmpdir, 'intent-cli-install-global-only-'), + ) const globalRoot = mkdtempSync( join(realTmpdir, 'intent-cli-install-global-only-node-modules-'), ) diff --git a/packages/intent/tests/hooks.test.ts b/packages/intent/tests/hooks.test.ts index 51f1a73..b7a9e66 100644 --- a/packages/intent/tests/hooks.test.ts +++ b/packages/intent/tests/hooks.test.ts @@ -72,7 +72,11 @@ describe('intent hook policy', () => { gateDecision({ agent: 'claude', toolName: 'Write', hasLoaded: false }), ).toEqual({ decision: 'deny', reason: GATE_DENY_REASON }) expect( - gateDecision({ agent: 'codex', toolName: 'apply_patch', hasLoaded: false }), + gateDecision({ + agent: 'codex', + toolName: 'apply_patch', + hasLoaded: false, + }), ).toEqual({ decision: 'deny', reason: GATE_DENY_REASON }) expect( gateDecision({ agent: 'copilot', toolName: 'Edit', hasLoaded: true }), @@ -130,4 +134,4 @@ describe('intent hook agent adapters', () => { }) expect(formatCodexPreToolUseOutput({ decision: 'allow' })).toBeUndefined() }) -}) \ No newline at end of file +}) diff --git a/packages/intent/tests/install-writer.test.ts b/packages/intent/tests/install-writer.test.ts index 7ddbf06..7127519 100644 --- a/packages/intent/tests/install-writer.test.ts +++ b/packages/intent/tests/install-writer.test.ts @@ -15,7 +15,11 @@ import { verifyIntentSkillsBlockFile, writeIntentSkillsBlock, } from '../src/commands/install/guidance.js' -import type { IntentPackage, ScanResult, SkillEntry } from '../src/shared/types.js' +import type { + IntentPackage, + ScanResult, + SkillEntry, +} from '../src/shared/types.js' const tempDirs: Array = [] @@ -97,9 +101,7 @@ describe('install writer block builder', () => { expect(generated.mappingCount).toBe(0) expect(generated.block).toContain('## Skill Loading') expect(generated.block).toContain('npx @tanstack/intent@latest list') - expect(generated.block).toContain( - 'If a listed skill matches the task', - ) + expect(generated.block).toContain('If a listed skill matches the task') expect(generated.block).toContain('before changing files') expect(generated.block).toContain('Monorepos:') expect(generated.block).toContain('Multiple matches:') diff --git a/packages/intent/tests/resolver.test.ts b/packages/intent/tests/resolver.test.ts index b5a8e80..1ffb28e 100644 --- a/packages/intent/tests/resolver.test.ts +++ b/packages/intent/tests/resolver.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { ResolveSkillUseError, resolveSkillUse } from '../src/skills/resolver.js' +import { + ResolveSkillUseError, + resolveSkillUse, +} from '../src/skills/resolver.js' import type { IntentPackage, ScanResult, diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 5853ef4..970016c 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -11,7 +11,10 @@ import { createRequire } from 'node:module' import { join, sep } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { scanForIntents, scanIntentPackageAtRoot } from '../src/discovery/scanner.js' +import { + scanForIntents, + scanIntentPackageAtRoot, +} from '../src/discovery/scanner.js' // ── Helpers ── diff --git a/packages/intent/tests/setup.test.ts b/packages/intent/tests/setup.test.ts index 71d32b3..35894fa 100644 --- a/packages/intent/tests/setup.test.ts +++ b/packages/intent/tests/setup.test.ts @@ -14,7 +14,10 @@ import { runEditPackageJsonAll, runSetupGithubActions, } from '../src/setup/index.js' -import type { EditPackageJsonResult, MonorepoResult } from '../src/setup/index.js' +import type { + EditPackageJsonResult, + MonorepoResult, +} from '../src/setup/index.js' const repoRoot = join(import.meta.dirname, '..', '..', '..') From a88f636aed1e666f90e2c114a73c13a64a41a27e Mon Sep 17 00:00:00 2001 From: ladybluenotes Date: Sat, 20 Jun 2026 22:45:23 -0700 Subject: [PATCH 13/13] changeset --- .changeset/hot-bottles-float.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/hot-bottles-float.md diff --git a/.changeset/hot-bottles-float.md b/.changeset/hot-bottles-float.md new file mode 100644 index 0000000..189ca31 --- /dev/null +++ b/.changeset/hot-bottles-float.md @@ -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.