From 48a9ca83c5f445bcbe8c1ec94410c53854e6adf7 Mon Sep 17 00:00:00 2001 From: kzhivotov Date: Sat, 21 Mar 2026 20:49:57 -0700 Subject: [PATCH 1/9] fix: fix doc-sync hook automation and make it default after doc init --- bin/cli.js | 2 + package-lock.json | 19 ------- src/commands/doc-init.js | 20 ++++++- src/commands/doc-sync.js | 118 +++++++++++++++++++++++++++++---------- 4 files changed, 111 insertions(+), 48 deletions(-) diff --git a/bin/cli.js b/bin/cli.js index 60be4c3..eb9b751 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -99,6 +99,7 @@ doc .option('--strategy ', 'Existing docs: improve, rewrite, skip (skips interactive prompt)') .option('--domains ', 'Additional domains to include (comma-separated, e.g., "backtest,advisory")') .option('--model ', 'Claude model to use (e.g., sonnet, opus, haiku)') + .option('--no-hook', 'Skip post-commit hook prompt') .option('--verbose', 'Show what Claude is reading/doing in real time') .action(docInitCommand); @@ -108,6 +109,7 @@ doc .argument('[path]', 'Path to repo', '.') .option('--commits ', 'Number of commits to analyze', '1') .option('--install-hook', 'Install git post-commit hook') + .option('--remove-hook', 'Remove git post-commit hook') .option('--dry-run', 'Preview without writing files') .option('--timeout ', 'Claude timeout in seconds', '300') .option('--model ', 'Claude model to use (e.g., sonnet, opus, haiku)') diff --git a/package-lock.json b/package-lock.json index 57e196d..6b9de7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "@clack/prompts": "^0.8.0", - "aspens": "^0.2.0", "commander": "^12.0.0", "es-module-lexer": "^2.0.0", "picocolors": "^1.1.0" @@ -542,24 +541,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/aspens": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/aspens/-/aspens-0.2.0.tgz", - "integrity": "sha512-Q2KIsyHtzMS7aAElWcKgQ3WdwNA8EAJZnG9UfsVQxtPx92vvM68VZjmiHVmlUMiqDjE1RppKriGKYWEqIpevPw==", - "license": "MIT", - "dependencies": { - "@clack/prompts": "^0.8.0", - "commander": "^12.0.0", - "es-module-lexer": "^2.0.0", - "picocolors": "^1.1.0" - }, - "bin": { - "aspens": "bin/cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index 99b766f..45535f9 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -1,11 +1,12 @@ import { resolve, join } from 'path'; -import { existsSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import pc from 'picocolors'; import * as p from '@clack/prompts'; import { scanRepo } from '../lib/scanner.js'; import { buildRepoGraph } from '../lib/graph-builder.js'; import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js'; import { writeSkillFiles } from '../lib/skill-writer.js'; +import { installGitHook } from './doc-sync.js'; // Read-only tools — Claude explores the repo itself const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep']; @@ -363,6 +364,23 @@ export async function docInitCommand(path, options) { showTokenSummary(startTime); + // Offer auto-sync hook + if (options.hook !== false && !options.dryRun && existsSync(join(repoPath, '.git'))) { + const hookPath = join(repoPath, '.git', 'hooks', 'post-commit'); + const hookInstalled = existsSync(hookPath) && + readFileSync(hookPath, 'utf8').includes('aspens doc'); + if (!hookInstalled) { + console.log(); + const wantHook = await p.confirm({ + message: 'Install post-commit hook to keep docs in sync automatically?', + initialValue: true, + }); + if (!p.isCancel(wantHook) && wantHook) { + installGitHook(repoPath); + } + } + } + console.log(); p.outro( `${pc.green(`${created} created`)}` + diff --git a/src/commands/doc-sync.js b/src/commands/doc-sync.js index 25c6544..b867f45 100644 --- a/src/commands/doc-sync.js +++ b/src/commands/doc-sync.js @@ -1,5 +1,5 @@ import { resolve, join, relative } from 'path'; -import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'fs'; +import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs'; import { execSync } from 'child_process'; import pc from 'picocolors'; import * as p from '@clack/prompts'; @@ -15,10 +15,13 @@ export async function docSyncCommand(path, options) { const verbose = !!options.verbose; const commits = parseInt(options.commits) || 1; - // Install hook mode + // Install/remove hook mode if (options.installHook) { return installGitHook(repoPath); } + if (options.removeHook) { + return removeGitHook(repoPath); + } p.intro(pc.cyan('aspens doc sync')); @@ -318,7 +321,18 @@ function mapChangesToSkills(changedFiles, existingSkills, scan) { // --- Git hook --- -function installGitHook(repoPath) { +function resolveAspensPath() { + try { + const resolved = execSync('which aspens', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + if (resolved && existsSync(resolved)) return resolved; + } catch { /* not in PATH */ } + return 'npx aspens'; +} + +export function installGitHook(repoPath) { const hookDir = join(repoPath, '.git', 'hooks'); const hookPath = join(hookDir, 'post-commit'); @@ -329,48 +343,96 @@ function installGitHook(repoPath) { mkdirSync(hookDir, { recursive: true }); - const hookCommand = ` -# aspens doc sync — auto-update skills after commit -# Installed by: aspens doc sync --install-hook - -# Cooldown: skip if last sync was less than 5 minutes ago -ASPENS_LOCK="/tmp/aspens-sync-\$(git rev-parse --show-toplevel | shasum | cut -c1-8).lock" -if [ -f "\$ASPENS_LOCK" ]; then - LAST_RUN=\$(cat "\$ASPENS_LOCK" 2>/dev/null || echo 0) - NOW=\$(date +%s) - if [ \$((NOW - LAST_RUN)) -lt 300 ]; then - exit 0 # Too soon since last sync + const aspensCmd = resolveAspensPath(); + + const hookBlock = ` +# >>> aspens doc-sync hook (do not edit) >>> +__aspens_doc_sync() { + REPO_ROOT="\$(git rev-parse --show-toplevel 2>/dev/null)" || return 0 + REPO_HASH="\$(echo "\$REPO_ROOT" | shasum | cut -c1-8)" + ASPENS_LOCK="/tmp/aspens-sync-\${REPO_HASH}.lock" + ASPENS_LOG="/tmp/aspens-sync-\${REPO_HASH}.log" + + # Cooldown: skip if last sync was less than 5 minutes ago + if [ -f "\$ASPENS_LOCK" ]; then + LAST_RUN=\$(cat "\$ASPENS_LOCK" 2>/dev/null || echo 0) + NOW=\$(date +%s) + if [ \$((NOW - LAST_RUN)) -lt 300 ]; then + return 0 + fi fi -fi -echo \$(date +%s) > "\$ASPENS_LOCK" + echo \$(date +%s) > "\$ASPENS_LOCK" -# Clean up stale lock files older than 1 hour -find /tmp -maxdepth 1 -name "aspens-sync-*.lock" -mmin +60 -delete 2>/dev/null + # Clean up stale lock files older than 1 hour + find /tmp -maxdepth 1 -name "aspens-sync-*.lock" -mmin +60 -delete 2>/dev/null -# Run in background so commit isn't blocked -npx aspens doc sync --commits 1 "\$(git rev-parse --show-toplevel)" & -`; + # Truncate log if over 200 lines + if [ -f "\$ASPENS_LOG" ] && [ "\$(wc -l < "\$ASPENS_LOG" 2>/dev/null || echo 0)" -gt 200 ]; then + tail -100 "\$ASPENS_LOG" > "\$ASPENS_LOG.tmp" && mv "\$ASPENS_LOG.tmp" "\$ASPENS_LOG" + fi - const hookFull = `#!/bin/sh${hookCommand}`; + # Run in background with logging + (echo "[sync] \$(date '+%Y-%m-%d %H:%M:%S') started" >> "\$ASPENS_LOG" && ${aspensCmd} doc sync --commits 1 "\$REPO_ROOT" >> "\$ASPENS_LOG" 2>&1; echo "[sync] \$(date '+%Y-%m-%d %H:%M:%S') finished (exit \$?)" >> "\$ASPENS_LOG") & +} +__aspens_doc_sync +# <<< aspens doc-sync hook <<< +`; // Check for existing hook if (existsSync(hookPath)) { const existing = readFileSync(hookPath, 'utf8'); - if (existing.includes('aspens doc sync')) { + if (existing.includes('aspens doc-sync hook') || existing.includes('aspens doc sync')) { console.log(pc.yellow('\n Hook already installed.\n')); return; } - // Append command to existing hook (without shebang) - writeFileSync(hookPath, existing + '\n' + hookCommand, 'utf8'); - console.log(pc.green('\n Appended aspens doc sync to existing post-commit hook.\n')); + // Append to existing hook (outside shebang) + writeFileSync(hookPath, existing + '\n' + hookBlock, 'utf8'); + console.log(pc.green('\n Appended aspens doc-sync to existing post-commit hook.\n')); } else { - writeFileSync(hookPath, hookFull, 'utf8'); + writeFileSync(hookPath, '#!/bin/sh\n' + hookBlock, 'utf8'); execSync(`chmod +x "${hookPath}"`); console.log(pc.green('\n Installed post-commit hook.\n')); } console.log(pc.dim(' Skills will auto-update after every commit.')); - console.log(pc.dim(' Remove with: rm .git/hooks/post-commit\n')); + console.log(pc.dim(' Log: /tmp/aspens-sync-*.log')); + console.log(pc.dim(' Remove with: aspens doc sync --remove-hook\n')); +} + +function removeGitHook(repoPath) { + const hookPath = join(repoPath, '.git', 'hooks', 'post-commit'); + + if (!existsSync(hookPath)) { + console.log(pc.yellow('\n No post-commit hook found.\n')); + return; + } + + const content = readFileSync(hookPath, 'utf8'); + const hasMarkers = content.includes('# >>> aspens doc-sync hook'); + const hasLegacy = !hasMarkers && content.includes('aspens doc sync'); + + if (!hasMarkers && !hasLegacy) { + console.log(pc.yellow('\n Post-commit hook does not contain aspens.\n')); + return; + } + + if (hasMarkers) { + const cleaned = content + .replace(/\n?# >>> aspens doc-sync hook \(do not edit\) >>>[\s\S]*?# <<< aspens doc-sync hook <<<\n?/, '') + .trim(); + + if (!cleaned || cleaned === '#!/bin/sh') { + unlinkSync(hookPath); + console.log(pc.green('\n Removed post-commit hook.\n')); + } else { + writeFileSync(hookPath, cleaned + '\n', 'utf8'); + console.log(pc.green('\n Removed aspens doc-sync from post-commit hook.\n')); + } + } else { + console.log(pc.yellow('\n Legacy aspens hook detected (no removal markers).')); + console.log(pc.dim(' Re-install first: aspens doc sync --install-hook')); + console.log(pc.dim(' Or edit manually: .git/hooks/post-commit\n')); + } } // --- Helpers --- From b6247bfec1e9f3ac663e1805f1fa83ebcd560bbd Mon Sep 17 00:00:00 2001 From: kzhivotov Date: Sat, 21 Mar 2026 20:54:11 -0700 Subject: [PATCH 2/9] feat: tests --- src/commands/doc-sync.js | 2 +- tests/git-hook.test.js | 105 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 tests/git-hook.test.js diff --git a/src/commands/doc-sync.js b/src/commands/doc-sync.js index b867f45..654e0ee 100644 --- a/src/commands/doc-sync.js +++ b/src/commands/doc-sync.js @@ -399,7 +399,7 @@ __aspens_doc_sync console.log(pc.dim(' Remove with: aspens doc sync --remove-hook\n')); } -function removeGitHook(repoPath) { +export function removeGitHook(repoPath) { const hookPath = join(repoPath, '.git', 'hooks', 'post-commit'); if (!existsSync(hookPath)) { diff --git a/tests/git-hook.test.js b/tests/git-hook.test.js new file mode 100644 index 0000000..2ec8519 --- /dev/null +++ b/tests/git-hook.test.js @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, afterAll } from 'vitest'; +import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync, statSync } from 'fs'; +import { join } from 'path'; +import { installGitHook, removeGitHook } from '../src/commands/doc-sync.js'; + +const TEST_DIR = join(import.meta.dirname, 'tmp-hook'); +const HOOKS_DIR = join(TEST_DIR, '.git', 'hooks'); +const HOOK_PATH = join(HOOKS_DIR, 'post-commit'); + +beforeEach(() => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(HOOKS_DIR, { recursive: true }); +}); + +afterAll(() => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); +}); + +describe('installGitHook', () => { + it('creates post-commit hook with shebang and markers', () => { + installGitHook(TEST_DIR); + const content = readFileSync(HOOK_PATH, 'utf8'); + expect(content).toContain('#!/bin/sh'); + expect(content).toContain('# >>> aspens doc-sync hook (do not edit) >>>'); + expect(content).toContain('__aspens_doc_sync()'); + expect(content).toContain('# <<< aspens doc-sync hook <<<'); + }); + + it('makes hook executable', () => { + installGitHook(TEST_DIR); + const mode = statSync(HOOK_PATH).mode; + expect(mode & 0o111).toBeGreaterThan(0); + }); + + it('uses return 0 instead of exit 0 in cooldown', () => { + installGitHook(TEST_DIR); + const content = readFileSync(HOOK_PATH, 'utf8'); + expect(content).toContain('return 0'); + expect(content).not.toContain('exit 0'); + }); + + it('includes logging to a file', () => { + installGitHook(TEST_DIR); + const content = readFileSync(HOOK_PATH, 'utf8'); + expect(content).toContain('ASPENS_LOG='); + expect(content).toContain('>> "$ASPENS_LOG"'); + }); + + it('is idempotent — skips if already installed', () => { + installGitHook(TEST_DIR); + const first = readFileSync(HOOK_PATH, 'utf8'); + installGitHook(TEST_DIR); + const second = readFileSync(HOOK_PATH, 'utf8'); + expect(second).toBe(first); + }); + + it('appends to existing hook without replacing shebang', () => { + writeFileSync(HOOK_PATH, '#!/bin/sh\necho "other hook"\n', 'utf8'); + installGitHook(TEST_DIR); + const content = readFileSync(HOOK_PATH, 'utf8'); + expect(content).toContain('echo "other hook"'); + expect(content).toContain('# >>> aspens doc-sync hook'); + // Only one shebang + expect(content.match(/^#!\/bin\/sh/gm)).toHaveLength(1); + }); +}); + +describe('removeGitHook', () => { + it('removes hook file when it only contains aspens block', () => { + installGitHook(TEST_DIR); + expect(existsSync(HOOK_PATH)).toBe(true); + removeGitHook(TEST_DIR); + expect(existsSync(HOOK_PATH)).toBe(false); + }); + + it('preserves other hook content when removing aspens block', () => { + writeFileSync(HOOK_PATH, '#!/bin/sh\necho "other hook"\n', 'utf8'); + installGitHook(TEST_DIR); + removeGitHook(TEST_DIR); + expect(existsSync(HOOK_PATH)).toBe(true); + const content = readFileSync(HOOK_PATH, 'utf8'); + expect(content).toContain('echo "other hook"'); + expect(content).not.toContain('aspens doc-sync hook'); + }); + + it('handles missing hook file gracefully', () => { + // Should not throw + removeGitHook(TEST_DIR); + }); + + it('handles hook without aspens content gracefully', () => { + writeFileSync(HOOK_PATH, '#!/bin/sh\necho "unrelated"\n', 'utf8'); + // Should not throw or modify + removeGitHook(TEST_DIR); + const content = readFileSync(HOOK_PATH, 'utf8'); + expect(content).toContain('echo "unrelated"'); + }); + + it('detects legacy hooks without markers', () => { + writeFileSync(HOOK_PATH, '#!/bin/sh\nnpx aspens doc sync --commits 1\n', 'utf8'); + // Should not delete — warns about legacy format + removeGitHook(TEST_DIR); + expect(existsSync(HOOK_PATH)).toBe(true); + }); +}); From 726f8a31c7d6cc472646b51024514966ebd9bd69 Mon Sep 17 00:00:00 2001 From: mvoutov Date: Sat, 21 Mar 2026 21:32:17 -0700 Subject: [PATCH 3/9] feat: skill auto activation hooks --- bin/cli.js | 5 +- src/commands/doc-init.js | 195 ++++++++- src/lib/runner.js | 115 ++++- src/lib/skill-reader.js | 108 +++++ src/lib/skill-writer.js | 402 +++++++++++++++++- src/prompts/partials/skill-format.md | 35 ++ src/templates/hooks/post-tool-use-tracker.sh | 7 + .../hooks/skill-activation-prompt.mjs | 388 +++++++++++++++++ .../hooks/skill-activation-prompt.sh | 52 ++- src/templates/settings/settings.json | 25 ++ 10 files changed, 1301 insertions(+), 31 deletions(-) create mode 100644 src/lib/skill-reader.js create mode 100644 src/templates/hooks/skill-activation-prompt.mjs create mode 100644 src/templates/settings/settings.json diff --git a/bin/cli.js b/bin/cli.js index 60be4c3..2840b0d 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -28,7 +28,7 @@ function showWelcome() { ${pc.bold('Quick Start')} ${pc.green('aspens scan')} See your repo's tech stack and domains - ${pc.green('aspens doc init')} Generate skills + CLAUDE.md + ${pc.green('aspens doc init')} Generate skills + hooks + CLAUDE.md ${pc.green('aspens doc sync --install-hook')} Auto-update on every commit ${pc.bold('Generate & Sync')} @@ -52,6 +52,7 @@ function showWelcome() { ${pc.yellow('--force')} Overwrite existing files ${pc.yellow('--model')} ${pc.dim('')} Choose Claude model ${pc.yellow('--mode')} ${pc.dim('')} all, chunked, base-only ${pc.yellow('--timeout')} ${pc.dim('')} Seconds per call ${pc.yellow('--strategy')} ${pc.dim('')} improve, rewrite, skip ${pc.yellow('--json')} JSON output (scan) + ${pc.yellow('--no-hooks')} Skip hook installation ${pc.yellow('--hooks-only')} Update hooks only ${pc.bold('Typical Workflow')} ${pc.dim('$')} aspens scan ${pc.dim('1. See what\'s in your repo')} @@ -100,6 +101,8 @@ doc .option('--domains ', 'Additional domains to include (comma-separated, e.g., "backtest,advisory")') .option('--model ', 'Claude model to use (e.g., sonnet, opus, haiku)') .option('--verbose', 'Show what Claude is reading/doing in real time') + .option('--no-hooks', 'Skip hook/rules/settings installation') + .option('--hooks-only', 'Skip skill generation, just install/update hooks') .action(docInitCommand); doc diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index 99b766f..f7c3569 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -1,11 +1,15 @@ -import { resolve, join } from 'path'; -import { existsSync } from 'fs'; +import { resolve, join, dirname } from 'path'; +import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, chmodSync } from 'fs'; +import { fileURLToPath } from 'url'; import pc from 'picocolors'; import * as p from '@clack/prompts'; import { scanRepo } from '../lib/scanner.js'; import { buildRepoGraph } from '../lib/graph-builder.js'; -import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js'; -import { writeSkillFiles } from '../lib/skill-writer.js'; +import { runClaude, loadPrompt, parseFileOutput, validateSkillFiles } from '../lib/runner.js'; +import { writeSkillFiles, extractRulesFromSkills, generateDomainPatterns, mergeSettings } from '../lib/skill-writer.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEMPLATES_DIR = join(__dirname, '..', 'templates'); // Read-only tools — Claude explores the repo itself const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep']; @@ -48,6 +52,19 @@ export async function docInitCommand(path, options) { const verbose = !!options.verbose; const model = options.model || null; + // --hooks-only: skip skill generation, just install/update hooks + if (options.hooksOnly) { + p.intro(pc.cyan('aspens doc init --hooks-only')); + const skillsDir = join(repoPath, '.claude', 'skills'); + if (!existsSync(skillsDir)) { + p.log.error('No skills found in .claude/skills/. Run `aspens doc init` first.'); + process.exit(1); + } + await installHooks(repoPath, options); + p.outro(pc.green('Hooks updated')); + return; + } + // Reset token tracker for this run const startTime = Date.now(); tokenTracker.promptTokens = 0; @@ -293,7 +310,7 @@ export async function docInitCommand(path, options) { } } - // Step 4: Generate (Layer 3) — write skills from findings + // Step 5: Generate skills let allFiles = []; if (mode === 'all-at-once') { @@ -310,11 +327,38 @@ export async function docInitCommand(path, options) { process.exit(1); } - // Step 4: Show what will be written + // Step 6: Validate generated files + let validation = { valid: true, issues: [] }; + try { + validation = validateSkillFiles(allFiles, repoPath); + } catch (err) { + p.log.warn(`Validation failed: ${err.message}`); + } + + if (!validation.valid) { + console.log(); + p.log.warn(`Found ${validation.issues.length} issue(s) in generated skills:`); + for (const issue of validation.issues) { + const icon = issue.issue === 'bad-path' ? pc.yellow('?') : pc.red('!'); + console.log(pc.dim(' ') + `${icon} ${pc.dim(issue.file)} — ${issue.detail}`); + } + + // Filter out truncated files — they'd be useless + const truncated = validation.issues.filter(i => i.issue === 'truncated').map(i => i.file); + if (truncated.length > 0) { + allFiles = allFiles.filter(f => !truncated.includes(f.path)); + p.log.warn(`Removed ${truncated.length} truncated file(s). Re-run to regenerate them.`); + } + console.log(); + } + + // Step 7: Show what will be written console.log(); p.log.info('Files to write:'); for (const file of allFiles) { - console.log(pc.dim(' ') + pc.green(file.path)); + const hasIssues = validation.issues?.some(i => i.file === file.path) ?? false; + const icon = hasIssues ? pc.yellow('~') : pc.green('+'); + console.log(pc.dim(' ') + icon + ' ' + file.path); } console.log(); @@ -341,7 +385,7 @@ export async function docInitCommand(path, options) { process.exit(0); } - // Step 5: Write files + // Step 8: Write files const writeSpinner = p.spinner(); writeSpinner.start('Writing files...'); const results = writeSkillFiles(repoPath, allFiles, { force: options.force }); @@ -361,6 +405,11 @@ export async function docInitCommand(path, options) { console.log(` ${icon} ${result.path}${status}`); } + // Step 9: Generate skill-rules.json + install hooks (unless --no-hooks) + if (!options.noHooks) { + await installHooks(repoPath, options); + } + showTokenSummary(startTime); console.log(); @@ -393,6 +442,136 @@ function showTokenSummary(startTime) { } } +// --- Hook installation --- + +async function installHooks(repoPath, options) { + const skillsDir = join(repoPath, '.claude', 'skills'); + const hooksDir = join(repoPath, '.claude', 'hooks'); + const settingsPath = join(repoPath, '.claude', 'settings.json'); + + if (!existsSync(skillsDir)) { + p.log.warn('No skills directory found — skipping hook installation.'); + return; + } + + const hookSpinner = p.spinner(); + hookSpinner.start('Installing skill activation hooks...'); + + try { + // 9a: Generate skill-rules.json + const rules = extractRulesFromSkills(skillsDir); + const skillCount = Object.keys(rules.skills).length; + + if (skillCount === 0) { + hookSpinner.stop(pc.dim('No skills found — skipping hooks')); + return; + } + + const rulesPath = join(skillsDir, 'skill-rules.json'); + + if (!options.dryRun) { + writeFileSync(rulesPath, JSON.stringify(rules, null, 2) + '\n'); + } + + // 9b: Copy hook files + mkdirSync(hooksDir, { recursive: true }); + + const hookFiles = [ + { src: 'hooks/skill-activation-prompt.sh', dest: 'skill-activation-prompt.sh', chmod: true }, + { src: 'hooks/skill-activation-prompt.mjs', dest: 'skill-activation-prompt.mjs', chmod: false }, + ]; + + for (const hf of hookFiles) { + const srcPath = join(TEMPLATES_DIR, hf.src); + const destPath = join(hooksDir, hf.dest); + if (!existsSync(srcPath)) { + p.log.warn(`Template not found: ${hf.src}`); + continue; + } + if (!options.dryRun) { + copyFileSync(srcPath, destPath); + if (hf.chmod) { + chmodSync(destPath, 0o755); + } + } + } + + // 9c: Generate post-tool-use-tracker with domain patterns + const trackerSrc = join(TEMPLATES_DIR, 'hooks', 'post-tool-use-tracker.sh'); + const trackerDest = join(hooksDir, 'post-tool-use-tracker.sh'); + if (existsSync(trackerSrc)) { + let trackerContent = readFileSync(trackerSrc, 'utf8'); + + // Inject generated domain patterns into detect_skill_domain() + const domainPatterns = generateDomainPatterns(rules); + // Replace the stub function with the generated one + const stubRegex = /detect_skill_domain\(\)\s*\{[\s\S]*?\n\}/; + if (stubRegex.test(trackerContent)) { + trackerContent = trackerContent.replace(stubRegex, domainPatterns.trim()); + } + + if (!options.dryRun) { + writeFileSync(trackerDest, trackerContent); + chmodSync(trackerDest, 0o755); + } + } + + // 9d: Merge settings.json + let templateSettings; + try { + templateSettings = JSON.parse( + readFileSync(join(TEMPLATES_DIR, 'settings', 'settings.json'), 'utf8') + ); + } catch (err) { + hookSpinner.stop(pc.yellow('Hook installation incomplete')); + p.log.warn(`Could not read template settings: ${err.message}`); + return; + } + + let existingSettings = null; + if (existsSync(settingsPath)) { + try { + existingSettings = JSON.parse(readFileSync(settingsPath, 'utf8')); + // Backup existing settings + if (!options.dryRun) { + writeFileSync(settingsPath + '.bak', JSON.stringify(existingSettings, null, 2) + '\n'); + } + } catch { + // Existing settings malformed — overwrite + } + } + + const merged = mergeSettings(existingSettings, templateSettings); + + if (!options.dryRun) { + mkdirSync(dirname(settingsPath), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n'); + } + + hookSpinner.stop(pc.green(`Hooks installed (${skillCount} skills in rules)`)); + + // Show what was done + console.log(); + const items = [ + `${pc.green('+')} .claude/skills/skill-rules.json ${pc.dim(`(${skillCount} skills)`)}`, + `${pc.green('+')} .claude/hooks/skill-activation-prompt.sh`, + `${pc.green('+')} .claude/hooks/skill-activation-prompt.mjs`, + `${pc.green('+')} .claude/hooks/post-tool-use-tracker.sh ${pc.dim('(with domain patterns)')}`, + `${existingSettings ? pc.yellow('~') : pc.green('+')} .claude/settings.json ${pc.dim(existingSettings ? '(merged)' : '(created)')}`, + ]; + for (const item of items) { + console.log(` ${item}`); + } + if (existingSettings && !options.dryRun) { + console.log(pc.dim(' Backup: .claude/settings.json.bak')); + } + console.log(); + } catch (err) { + hookSpinner.stop(pc.red('Hook installation failed')); + p.log.error(err.message); + } +} + // --- Generation modes --- function buildScanSummary(scan) { diff --git a/src/lib/runner.js b/src/lib/runner.js index ac66086..60380cb 100644 --- a/src/lib/runner.js +++ b/src/lib/runner.js @@ -159,20 +159,64 @@ export function loadPrompt(name, vars = {}) { export function parseFileOutput(output) { let files = []; - // Primary: XML tags — content - // Unambiguous, handles code blocks inside content - const xmlPattern = /\n?([\s\S]*?)<\/file>/g; - let match; - while ((match = xmlPattern.exec(output)) !== null) { - const filePath = sanitizePath(match[1].trim()); - if (filePath) { - files.push({ path: filePath, content: match[2].trim() + '\n' }); + // Primary: Split on tags and match to next outside code fences. + // Strategy: find all positions that are NOT inside ``` fenced code blocks, + // then match each open tag to the nearest valid . + const openTagPattern = //g; + + // Pre-compute which character positions are inside fenced code blocks + const fenceRanges = []; + const fenceRegex = /^```[^\n]*\n[\s\S]*?\n```/gm; + let fm; + while ((fm = fenceRegex.exec(output)) !== null) { + fenceRanges.push([fm.index, fm.index + fm[0].length]); + } + function isInsideFence(pos) { + for (const [start, end] of fenceRanges) { + if (pos >= start && pos < end) return true; + } + return false; + } + + // Find all valid positions (at line start, outside code fences) + const closePositions = []; + const closeRegex = /\n<\/file>/g; + let cm; + while ((cm = closeRegex.exec(output)) !== null) { + if (!isInsideFence(cm.index)) { + closePositions.push(cm.index); } } - // Fallback 1: HTML comment markers with content between them + let openMatch; + while ((openMatch = openTagPattern.exec(output)) !== null) { + const filePath = sanitizePath(openMatch[1].trim()); + if (!filePath) continue; + + const contentStart = openMatch.index + openMatch[0].length; + + // Find the first valid AFTER this open tag + const closePos = closePositions.find(p => p >= contentStart); + + let content; + if (closePos !== undefined) { + content = output.slice(contentStart, closePos).trim() + '\n'; + // Advance past this tag + openTagPattern.lastIndex = closePos + '\n'.length; + } else { + // No valid closing tag — take up to next \s*\n([\s\S]*?)(?= blocks. + * Medium/low skills are listed as available. + * + * @param {Array<{ name: string, matchType: string, config: Object, content?: string }>} matched + * @param {string} currentRepo - Current repository name + * @param {string} projectDir - Absolute path to the project root + * @returns {string} Formatted output for stdout + */ +export function formatOutput(matched, currentRepo, projectDir) { + if (matched.length === 0) { + return ''; + } + + // Load skill content for each matched skill + for (const skill of matched) { + if (!skill.content) { + const content = readSkillContent(projectDir, skill.name); + if (content) { + skill.content = content; + } + } + } + + const skillsWithContent = matched.filter(s => s.content); + const skillsWithoutContent = matched.filter(s => !s.content); + + // High priority: base type OR critical/high priority — inject full content + const highPrioritySkills = skillsWithContent.filter( + s => s.config.type === 'base' || s.config.priority === 'critical' || s.config.priority === 'high' + ); + + // Medium/low priority (excluding those already in high priority) — list as available + const highSet = new Set(highPrioritySkills); + const optionalSkills = skillsWithContent.filter( + s => !highSet.has(s) + ); + + let output = ''; + + if (highPrioritySkills.length > 0) { + output += '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n'; + output += `\uD83D\uDCCD ACTIVE SKILLS (${currentRepo})\n`; + output += '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n\n'; + + for (const skill of highPrioritySkills) { + output += `\n`; + output += skill.content + '\n'; + output += `\n\n`; + } + } + + if (optionalSkills.length > 0 || skillsWithoutContent.length > 0) { + const availableNames = [ + ...optionalSkills.map(s => s.name), + ...skillsWithoutContent.map(s => s.name), + ]; + output += '\uD83D\uDCCC Available skills (ask to activate): ' + availableNames.join(', ') + '\n'; + } + + if (highPrioritySkills.length > 0 || optionalSkills.length > 0 || skillsWithoutContent.length > 0) { + output += '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n'; + } + + return output; +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +async function main() { + try { + // Read input from stdin + const input = readFileSync(0, 'utf-8'); + + let data; + try { + data = JSON.parse(input); + } catch { + // Invalid JSON — exit silently + process.exit(0); + } + + const prompt = data.prompt || ''; + if (!prompt) { + process.exit(0); + } + + // Determine project directory + const projectDir = process.env.CLAUDE_PROJECT_DIR; + if (!projectDir) { + process.exit(0); + } + + // Load skill rules + const rulesPath = join(projectDir, '.claude', 'skills', 'skill-rules.json'); + if (!existsSync(rulesPath)) { + // No skill rules file — exit silently + process.exit(0); + } + + let rules; + try { + rules = JSON.parse(readFileSync(rulesPath, 'utf-8')); + } catch { + // Invalid rules file — exit silently + process.exit(0); + } + + if (!rules.skills || typeof rules.skills !== 'object') { + process.exit(0); + } + + // Detect current repository + const currentRepo = detectCurrentRepo(projectDir); + + // Get session-sticky skills + const sessionSkills = getSessionActiveSkills(projectDir); + + // Match skills against the prompt + const matched = matchSkills(prompt, rules, currentRepo, sessionSkills); + + // Load content for matched skills + for (const skill of matched) { + const content = readSkillContent(projectDir, skill.name); + if (content) { + skill.content = content; + } + } + + // Debug output + if (process.env.ASPENS_DEBUG === '1') { + const debugTrace = { + timestamp: new Date().toISOString(), + projectDir, + currentRepo, + prompt: prompt.substring(0, 500), + sessionSkills, + rulesLoaded: Object.keys(rules.skills), + matched: matched.map(s => ({ + name: s.name, + matchType: s.matchType, + priority: s.config.priority, + type: s.config.type, + hasContent: !!s.content, + })), + }; + try { + writeFileSync('/tmp/aspens-debug-activation.json', JSON.stringify(debugTrace, null, 2)); + } catch { + // Debug write failed — ignore + } + } + + // Format and emit output + if (matched.length > 0) { + const output = formatOutput(matched, currentRepo, projectDir); + + // stderr: terminal status line + const highPriority = matched.filter( + s => s.config.type === 'base' || s.config.priority === 'critical' || s.config.priority === 'high' + ); + const activatedNames = highPriority.map(s => s.name).join(', ') || 'none'; + process.stderr.write(`[Skills] Activated: ${activatedNames}\n`); + + // stdout: injected into Claude's context + if (output) { + process.stdout.write(output); + } + } + + process.exit(0); + } catch (err) { + // NEVER block the user's prompt — log and exit cleanly + process.stderr.write(`[Skills] Error: ${err.message}\n`); + process.exit(0); + } +} + +// CLI entry point guard — only run main() when executed directly +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/src/templates/hooks/skill-activation-prompt.sh b/src/templates/hooks/skill-activation-prompt.sh index 3ef1298..aa92a49 100755 --- a/src/templates/hooks/skill-activation-prompt.sh +++ b/src/templates/hooks/skill-activation-prompt.sh @@ -1,41 +1,67 @@ #!/bin/bash -# Note: Removed set -e to prevent hook failures from blocking prompts +# Skill Activation Prompt Hook — Shell Wrapper +# Called by Claude Code on every UserPromptSubmit. +# Captures stdin, runs the Node.js matching engine, separates stdout/stderr. +# Always exits 0 — NEVER blocks the user's prompt. +# +# Note: No set -e — hook failures must not block prompts. -# Resolve the actual directory where this script lives (handling symlinks) -# This ensures we can find the TypeScript file even when the hook is symlinked +# --------------------------------------------------------------------------- +# Debug logging (opt-in via ASPENS_DEBUG=1 to avoid leaking prompt data) +# --------------------------------------------------------------------------- +log_debug() { + if [ "$ASPENS_DEBUG" = "1" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "${TMPDIR:-/tmp}/claude-skill-hook-debug-$(id -u).log" + fi +} + +log_debug "HOOK SCRIPT STARTED - PID $$" + +# --------------------------------------------------------------------------- +# Resolve script directory (handles symlinks — essential for hub support) +# --------------------------------------------------------------------------- get_script_dir() { local source="${BASH_SOURCE[0]}" - # Resolve symlinks while [ -h "$source" ]; do local dir="$(cd -P "$(dirname "$source")" && pwd)" source="$(readlink "$source")" - # Handle relative symlinks [[ $source != /* ]] && source="$dir/$source" done cd -P "$(dirname "$source")" && pwd } SCRIPT_DIR="$(get_script_dir)" +log_debug "SCRIPT_DIR=$SCRIPT_DIR" +log_debug "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" -# Change to the script directory and run the TypeScript hook cd "$SCRIPT_DIR" -# Capture stdin (printf preserves payload shape, unlike echo which can mangle escapes) +# --------------------------------------------------------------------------- +# Capture stdin +# --------------------------------------------------------------------------- INPUT=$(cat) +log_debug "Input received: ${INPUT:0:200}..." -# Temp files for clean stdout/stderr separation +# --------------------------------------------------------------------------- +# Run matching engine with clean stdout/stderr separation +# --------------------------------------------------------------------------- STDOUT_FILE=$(mktemp) STDERR_FILE=$(mktemp) trap 'rm -f "$STDOUT_FILE" "$STDERR_FILE"' EXIT -# Run the TypeScript hook with stdout and stderr captured separately -printf '%s' "$INPUT" | NODE_NO_WARNINGS=1 npx tsx skill-activation-prompt.ts \ +printf '%s' "$INPUT" | NODE_NO_WARNINGS=1 node skill-activation-prompt.mjs \ >"$STDOUT_FILE" 2>"$STDERR_FILE" EXIT_CODE=$? -# Parse stderr for skill activation info and relay to terminal +log_debug "Exit code: $EXIT_CODE" +log_debug "Stderr: $(cat "$STDERR_FILE" 2>/dev/null | head -5)" + +# --------------------------------------------------------------------------- +# Terminal status output (stderr — visible in verbose mode via Ctrl+O) +# --------------------------------------------------------------------------- if [ $EXIT_CODE -ne 0 ]; then echo "⚡ [Skills] Hook error (exit $EXIT_CODE)" >&2 + log_debug "ERROR: Hook failed with exit code $EXIT_CODE" else SKILL_LINE=$(grep -o '\[Skills\] Activated: [^"]*' "$STDERR_FILE" | head -1) if [ -n "$SKILL_LINE" ]; then @@ -45,7 +71,9 @@ else fi fi -# Output pristine stdout only (no grep filtering needed) +# --------------------------------------------------------------------------- +# Emit pristine stdout (injected into Claude's context) +# --------------------------------------------------------------------------- cat "$STDOUT_FILE" exit 0 diff --git a/src/templates/settings/settings.json b/src/templates/settings/settings.json new file mode 100644 index 0000000..0ef7711 --- /dev/null +++ b/src/templates/settings/settings.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-activation-prompt.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use-tracker.sh" + } + ] + } + ] + } +} From 30c40f3b6e7cffac0bf48c01663f88050ae5fdf8 Mon Sep 17 00:00:00 2001 From: mvoutov Date: Sat, 21 Mar 2026 21:35:33 -0700 Subject: [PATCH 4/9] claude aspens hooks + small fix/safe for hooks dulication name --- .claude/hooks/post-tool-use-tracker.sh | 292 ++++++++++++++++ .claude/hooks/skill-activation-prompt.mjs | 388 ++++++++++++++++++++++ .claude/hooks/skill-activation-prompt.sh | 79 +++++ .claude/settings.json | 25 ++ .claude/skills/skill-rules.json | 255 ++++++++++++++ src/lib/skill-writer.js | 9 +- 6 files changed, 1046 insertions(+), 2 deletions(-) create mode 100755 .claude/hooks/post-tool-use-tracker.sh create mode 100644 .claude/hooks/skill-activation-prompt.mjs create mode 100755 .claude/hooks/skill-activation-prompt.sh create mode 100644 .claude/settings.json create mode 100644 .claude/skills/skill-rules.json diff --git a/.claude/hooks/post-tool-use-tracker.sh b/.claude/hooks/post-tool-use-tracker.sh new file mode 100755 index 0000000..02826c0 --- /dev/null +++ b/.claude/hooks/post-tool-use-tracker.sh @@ -0,0 +1,292 @@ +#!/bin/bash +# Note: Removed set -e to prevent hook failures from blocking edits + +# IMPORTANT: All output MUST go to stderr (>&2), not stdout. +# stdout from PostToolUse hooks is injected into Claude's context. + +# Post-tool-use hook that tracks edited files and their repos +# This runs after Edit, MultiEdit, or Write tools complete successfully + +# Require jq for JSON parsing +if ! command -v jq &> /dev/null; then + exit 0 +fi + +# Exit early if CLAUDE_PROJECT_DIR is not set +if [[ -z "$CLAUDE_PROJECT_DIR" ]]; then + exit 0 +fi + +# Read tool information from stdin +tool_info=$(cat) + + +# Extract relevant data +tool_name=$(echo "$tool_info" | jq -r '.tool_name // empty') +file_path=$(echo "$tool_info" | jq -r '.tool_input.file_path // empty') +session_id=$(echo "$tool_info" | jq -r '.session_id // empty') + + +# Skip if not an edit tool or no file path +if [[ ! "$tool_name" =~ ^(Edit|MultiEdit|Write)$ ]] || [[ -z "$file_path" ]]; then + exit 0 # Exit 0 for skip conditions +fi + +# Skip markdown files +if [[ "$file_path" =~ \.(md|markdown)$ ]]; then + exit 0 # Exit 0 for skip conditions +fi + +# Create cache directory in project +cache_dir="$CLAUDE_PROJECT_DIR/.claude/tsc-cache/${session_id:-default}" +mkdir -p "$cache_dir" + +# Function to detect repo from file path +detect_repo() { + local file="$1" + local project_root="$CLAUDE_PROJECT_DIR" + + # Remove project root from path + local relative_path="${file#$project_root/}" + + # Extract first directory component + local repo=$(echo "$relative_path" | cut -d'/' -f1) + + # Common project directory patterns + case "$repo" in + # Frontend variations + frontend|client|web|app|ui) + echo "$repo" + ;; + # Backend variations + backend|server|api|src|services) + echo "$repo" + ;; + # Database + database|prisma|migrations) + echo "$repo" + ;; + # Package/monorepo structure + packages) + # For monorepos, get the package name + local package=$(echo "$relative_path" | cut -d'/' -f2) + if [[ -n "$package" ]]; then + echo "packages/$package" + else + echo "$repo" + fi + ;; + # Examples directory + examples) + local example=$(echo "$relative_path" | cut -d'/' -f2) + if [[ -n "$example" ]]; then + echo "examples/$example" + else + echo "$repo" + fi + ;; + *) + # Check if it's a source file in root + if [[ ! "$relative_path" =~ / ]]; then + echo "root" + else + echo "unknown" + fi + ;; + esac +} + +# Function to get build command for repo +get_build_command() { + local repo="$1" + local project_root="$CLAUDE_PROJECT_DIR" + + # Map special repo names to actual paths + local repo_path + if [[ "$repo" == "root" ]] || [[ "$repo" == "src" ]] || [[ "$repo" == "unknown" ]]; then + repo_path="$project_root" + else + repo_path="$project_root/$repo" + fi + + # Check if package.json exists and has a build script + if [[ -f "$repo_path/package.json" ]]; then + if grep -q '"build"' "$repo_path/package.json" 2>/dev/null; then + # Detect package manager (prefer pnpm, then npm, then yarn) + if [[ -f "$repo_path/pnpm-lock.yaml" ]]; then + echo "cd $repo_path && pnpm build" + elif [[ -f "$repo_path/package-lock.json" ]]; then + echo "cd $repo_path && npm run build" + elif [[ -f "$repo_path/yarn.lock" ]]; then + echo "cd $repo_path && yarn build" + else + echo "cd $repo_path && npm run build" + fi + return + fi + fi + + # Special case for database with Prisma + if [[ "$repo" == "database" ]] || [[ "$repo" =~ prisma ]]; then + if [[ -f "$repo_path/schema.prisma" ]] || [[ -f "$repo_path/prisma/schema.prisma" ]]; then + echo "cd $repo_path && npx prisma generate" + return + fi + fi + + # No build command found + echo "" +} + +# Function to get TSC command for repo +get_tsc_command() { + local repo="$1" + local project_root="$CLAUDE_PROJECT_DIR" + + # Map special repo names to actual paths + local repo_path + if [[ "$repo" == "root" ]] || [[ "$repo" == "src" ]] || [[ "$repo" == "unknown" ]]; then + repo_path="$project_root" + else + repo_path="$project_root/$repo" + fi + + # Check if tsconfig.json exists + if [[ -f "$repo_path/tsconfig.json" ]]; then + # Check for Vite/React-specific tsconfig + if [[ -f "$repo_path/tsconfig.app.json" ]]; then + echo "cd $repo_path && npx tsc --project tsconfig.app.json --noEmit" + else + echo "cd $repo_path && npx tsc --noEmit" + fi + return + fi + + # No TypeScript config found + echo "" +} + +# Detect repo +repo=$(detect_repo "$file_path") + +# Skip if unknown repo +if [[ "$repo" == "unknown" ]] || [[ -z "$repo" ]]; then + exit 0 # Exit 0 for skip conditions +fi + +# Log edited file +echo "$(date +%s):$file_path:$repo" >> "$cache_dir/edited-files.log" + +# Update affected repos list +if ! grep -q "^$repo$" "$cache_dir/affected-repos.txt" 2>/dev/null; then + echo "$repo" >> "$cache_dir/affected-repos.txt" +fi + +# Store build commands +build_cmd=$(get_build_command "$repo") +tsc_cmd=$(get_tsc_command "$repo") + +if [[ -n "$build_cmd" ]]; then + echo "$repo:build:$build_cmd" >> "$cache_dir/commands.txt.tmp" +fi + +if [[ -n "$tsc_cmd" ]]; then + echo "$repo:tsc:$tsc_cmd" >> "$cache_dir/commands.txt.tmp" +fi + +# Remove duplicates from commands +if [[ -f "$cache_dir/commands.txt.tmp" ]]; then + sort -u "$cache_dir/commands.txt.tmp" > "$cache_dir/commands.txt" + rm -f "$cache_dir/commands.txt.tmp" +fi + +# ============================================ +# SESSION-STICKY SKILLS TRACKING +# ============================================ +# Detect which domain skill should be activated based on file path +# and persist it in session state for sticky behavior + +detect_skill_domain() { + local file="$1" + local detected_skills="" + + # Generated by aspens from skill-rules.json filePatterns + if [[ "$file" =~ /customize ]] || [[ "$file" =~ /customize-agents ]]; then + detected_skills="agent-customization" + elif [[ "$file" =~ /runner ]] || [[ "$file" =~ /skill-writer ]] || [[ "$file" =~ /prompts/ ]] || [[ "$file" =~ /tests/ ]]; then + detected_skills="claude-runner" + elif [[ "$file" =~ /doc-sync ]]; then + detected_skills="doc-sync" + elif [[ "$file" =~ /graph-builder ]] || [[ "$file" =~ /graph-builder.test ]]; then + detected_skills="import-graph" + elif [[ "$file" =~ /scanner ]] || [[ "$file" =~ /scan ]] || [[ "$file" =~ /scanner.test ]]; then + detected_skills="repo-scanning" + elif [[ "$file" =~ /doc-init ]] || [[ "$file" =~ /doc-sync ]] || [[ "$file" =~ /customize ]] || [[ "$file" =~ /context-builder ]] || [[ "$file" =~ /runner ]] || [[ "$file" =~ /skill-writer ]] || [[ "$file" =~ /prompts/ ]]; then + detected_skills="skill-generation" + elif [[ "$file" =~ /add ]] || [[ "$file" =~ /customize ]] || [[ "$file" =~ /templates/ ]]; then + detected_skills="template-library" + fi + + echo "$detected_skills" +} + +# Create session file path based on project directory hash +get_session_file() { + local project_dir="$1" + local hash=$(echo -n "$project_dir" | md5 2>/dev/null || echo -n "$project_dir" | md5sum | cut -d' ' -f1) + echo "/tmp/claude-skills-${hash}.json" +} + +# Add skill to session state +add_skill_to_session() { + local skill="$1" + local session_file="$2" + local repo="$3" + + if [[ -z "$skill" ]]; then + return + fi + + # Create or update session file + if [[ -f "$session_file" ]]; then + # Check if jq is available + if command -v jq &> /dev/null; then + # Add skill to array, keeping unique values + jq --arg skill "$skill" --arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '.active_skills = ((.active_skills + [$skill]) | unique) | .last_updated = $time' \ + "$session_file" > "${session_file}.tmp" 2>/dev/null && \ + mv "${session_file}.tmp" "$session_file" + else + # Fallback: simple append check without jq + if ! grep -q "\"$skill\"" "$session_file" 2>/dev/null; then + # Read existing skills from file, append new one, rewrite + local existing_skills="" + if [[ -f "$session_file" ]]; then + # Extract skills array content: strip brackets, quotes, whitespace + existing_skills=$(grep -o '"active_skills":\[[^]]*\]' "$session_file" 2>/dev/null | sed 's/"active_skills":\[//;s/\]//;s/"//g;s/ //g') + fi + # Build new skills list + local new_skills="" + if [[ -n "$existing_skills" ]]; then + new_skills="\"$(echo "$existing_skills" | sed 's/,/","/g')\",\"$skill\"" + else + new_skills="\"$skill\"" + fi + echo "{\"repo\":\"$repo\",\"active_skills\":[$new_skills],\"last_updated\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$session_file" + fi + fi + else + # Create new session file + echo "{\"repo\":\"$repo\",\"active_skills\":[\"$skill\"],\"last_updated\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$session_file" + fi +} + +# Track skill domain for session-sticky behavior +skill_domain=$(detect_skill_domain "$file_path") +if [[ -n "$skill_domain" ]]; then + session_file=$(get_session_file "$CLAUDE_PROJECT_DIR") + add_skill_to_session "$skill_domain" "$session_file" "$repo" +fi + +# Exit cleanly +exit 0 diff --git a/.claude/hooks/skill-activation-prompt.mjs b/.claude/hooks/skill-activation-prompt.mjs new file mode 100644 index 0000000..9c6582d --- /dev/null +++ b/.claude/hooks/skill-activation-prompt.mjs @@ -0,0 +1,388 @@ +#!/usr/bin/env node +/** + * Skill Activation Prompt Hook — Matching Engine + * + * Standalone ESM module. Copied into target repo's .claude/hooks/ directory. + * No imports from aspens — uses only Node.js builtins. + * + * Called by skill-activation-prompt.sh on every UserPromptSubmit. + * Reads stdin JSON, matches prompt against skill-rules.json, + * injects high-priority skill content into Claude's context (stdout), + * lists medium/low skills as available. + * + * Exports functions for testability (vitest can import them). + */ + +import { readFileSync, existsSync, writeFileSync } from 'fs'; +import { join, basename } from 'path'; +import { createHash } from 'crypto'; +import { fileURLToPath } from 'url'; +import { tmpdir } from 'os'; + +// --------------------------------------------------------------------------- +// Exported functions (for testability) +// --------------------------------------------------------------------------- + +/** + * Read the skill.md content for a given skill name. + * Skill names like "auth" → .claude/skills/auth/skill.md + * Skill names like "backend/base" → .claude/skills/backend/base/skill.md + * + * @param {string} projectDir - Absolute path to the project root + * @param {string} skillName - Skill identifier (e.g. "auth", "backend/base") + * @returns {string|null} Skill markdown content, or null if not found + */ +export function readSkillContent(projectDir, skillName) { + const possiblePaths = [ + join(projectDir, '.claude', 'skills', skillName, 'skill.md'), + join(projectDir, '.claude', 'skills', `${skillName}.md`), + ]; + + for (const skillPath of possiblePaths) { + if (existsSync(skillPath)) { + try { + return readFileSync(skillPath, 'utf-8'); + } catch { + // Continue to next path + } + } + } + + return null; +} + +/** + * Detect which repository we're currently in. + * Checks .claude/repo-config.json first, falls back to directory basename. + * + * @param {string} projectDir - Absolute path to the project root + * @returns {string} Repository name + */ +export function detectCurrentRepo(projectDir) { + // Try repo-config.json first (hub sets this per repo) + const configPath = join(projectDir, '.claude', 'repo-config.json'); + if (existsSync(configPath)) { + try { + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + if (config.repoName && typeof config.repoName === 'string' && config.repoName.trim()) { + return config.repoName.trim(); + } + } catch { + // Fall through to directory-based detection + } + } + + // Fallback: detect from directory name + return basename(projectDir); +} + +/** + * Get session-sticky skills from the session state file in /tmp/. + * Skills activated via file edits stay active for the session. + * + * @param {string} projectDir - Absolute path to the project root + * @returns {string[]} Array of active skill names + */ +export function getSessionActiveSkills(projectDir) { + try { + const hash = createHash('md5').update(projectDir).digest('hex'); + const sessionFile = join(tmpdir(), `claude-skills-${hash}.json`); + + if (existsSync(sessionFile)) { + const content = readFileSync(sessionFile, 'utf-8'); + const session = JSON.parse(content); + return session.active_skills || []; + } + } catch { + // Session file doesn't exist or is invalid — that's fine + } + return []; +} + +/** + * Check if a skill's scope matches the current repository. + * + * @param {{ scope?: string }} config - Skill rule config + * @param {string} currentRepo - Current repository name + * @returns {boolean} + */ +function skillMatchesScope(config, currentRepo) { + const scope = config.scope || 'all'; + + if (scope === 'all') { + return true; + } + + if (scope === currentRepo) { + return true; + } + + return false; +} + +/** + * Match a user prompt against skill rules. + * Returns an array of matched skills with their match type. + * + * @param {string} prompt - The user's prompt text + * @param {{ version: string, skills: Object }} rules - Parsed skill-rules.json + * @param {string} currentRepo - Current repository name + * @param {string[]} sessionSkills - Session-sticky skill names + * @returns {Array<{ name: string, matchType: string, config: Object }>} + */ +export function matchSkills(prompt, rules, currentRepo, sessionSkills) { + const promptLower = prompt.toLowerCase(); + const matched = []; + const addedSkills = new Set(); + + // SESSION-STICKY: add skills from session state first + for (const skillName of sessionSkills) { + const config = rules.skills[skillName]; + if (config && !addedSkills.has(skillName)) { + matched.push({ name: skillName, matchType: 'session', config }); + addedSkills.add(skillName); + } + } + + // Check each skill for matches + for (const [skillName, config] of Object.entries(rules.skills)) { + // Filter by scope first + if (!skillMatchesScope(config, currentRepo)) { + continue; + } + + // Skip if already added via session-sticky + if (addedSkills.has(skillName)) { + continue; + } + + // AUTO-ACTIVATE: alwaysActivate + scope matches (exact repo OR "all") + if (config.alwaysActivate && (config.scope === currentRepo || config.scope === 'all')) { + matched.push({ name: skillName, matchType: 'auto', config }); + addedSkills.add(skillName); + continue; + } + + const triggers = config.promptTriggers; + if (!triggers) { + continue; + } + + // Keyword matching + if (triggers.keywords) { + const keywordMatch = triggers.keywords.some(kw => + promptLower.includes(kw.toLowerCase()) + ); + if (keywordMatch) { + matched.push({ name: skillName, matchType: 'keyword', config }); + addedSkills.add(skillName); + continue; + } + } + + // Intent pattern matching + if (triggers.intentPatterns) { + const intentMatch = triggers.intentPatterns.some(pattern => { + try { + const regex = new RegExp(pattern, 'i'); + return regex.test(prompt); + } catch { + // Invalid regex — skip + return false; + } + }); + if (intentMatch) { + matched.push({ name: skillName, matchType: 'intent', config }); + addedSkills.add(skillName); + } + } + } + + return matched; +} + +/** + * Format the output for Claude's context injection. + * High-priority skills get full content in blocks. + * Medium/low skills are listed as available. + * + * @param {Array<{ name: string, matchType: string, config: Object, content?: string }>} matched + * @param {string} currentRepo - Current repository name + * @param {string} projectDir - Absolute path to the project root + * @returns {string} Formatted output for stdout + */ +export function formatOutput(matched, currentRepo, projectDir) { + if (matched.length === 0) { + return ''; + } + + // Load skill content for each matched skill + for (const skill of matched) { + if (!skill.content) { + const content = readSkillContent(projectDir, skill.name); + if (content) { + skill.content = content; + } + } + } + + const skillsWithContent = matched.filter(s => s.content); + const skillsWithoutContent = matched.filter(s => !s.content); + + // High priority: base type OR critical/high priority — inject full content + const highPrioritySkills = skillsWithContent.filter( + s => s.config.type === 'base' || s.config.priority === 'critical' || s.config.priority === 'high' + ); + + // Medium/low priority (excluding those already in high priority) — list as available + const highSet = new Set(highPrioritySkills); + const optionalSkills = skillsWithContent.filter( + s => !highSet.has(s) + ); + + let output = ''; + + if (highPrioritySkills.length > 0) { + output += '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n'; + output += `\uD83D\uDCCD ACTIVE SKILLS (${currentRepo})\n`; + output += '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n\n'; + + for (const skill of highPrioritySkills) { + output += `\n`; + output += skill.content + '\n'; + output += `\n\n`; + } + } + + if (optionalSkills.length > 0 || skillsWithoutContent.length > 0) { + const availableNames = [ + ...optionalSkills.map(s => s.name), + ...skillsWithoutContent.map(s => s.name), + ]; + output += '\uD83D\uDCCC Available skills (ask to activate): ' + availableNames.join(', ') + '\n'; + } + + if (highPrioritySkills.length > 0 || optionalSkills.length > 0 || skillsWithoutContent.length > 0) { + output += '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n'; + } + + return output; +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +async function main() { + try { + // Read input from stdin + const input = readFileSync(0, 'utf-8'); + + let data; + try { + data = JSON.parse(input); + } catch { + // Invalid JSON — exit silently + process.exit(0); + } + + const prompt = data.prompt || ''; + if (!prompt) { + process.exit(0); + } + + // Determine project directory + const projectDir = process.env.CLAUDE_PROJECT_DIR; + if (!projectDir) { + process.exit(0); + } + + // Load skill rules + const rulesPath = join(projectDir, '.claude', 'skills', 'skill-rules.json'); + if (!existsSync(rulesPath)) { + // No skill rules file — exit silently + process.exit(0); + } + + let rules; + try { + rules = JSON.parse(readFileSync(rulesPath, 'utf-8')); + } catch { + // Invalid rules file — exit silently + process.exit(0); + } + + if (!rules.skills || typeof rules.skills !== 'object') { + process.exit(0); + } + + // Detect current repository + const currentRepo = detectCurrentRepo(projectDir); + + // Get session-sticky skills + const sessionSkills = getSessionActiveSkills(projectDir); + + // Match skills against the prompt + const matched = matchSkills(prompt, rules, currentRepo, sessionSkills); + + // Load content for matched skills + for (const skill of matched) { + const content = readSkillContent(projectDir, skill.name); + if (content) { + skill.content = content; + } + } + + // Debug output + if (process.env.ASPENS_DEBUG === '1') { + const debugTrace = { + timestamp: new Date().toISOString(), + projectDir, + currentRepo, + prompt: prompt.substring(0, 500), + sessionSkills, + rulesLoaded: Object.keys(rules.skills), + matched: matched.map(s => ({ + name: s.name, + matchType: s.matchType, + priority: s.config.priority, + type: s.config.type, + hasContent: !!s.content, + })), + }; + try { + writeFileSync('/tmp/aspens-debug-activation.json', JSON.stringify(debugTrace, null, 2)); + } catch { + // Debug write failed — ignore + } + } + + // Format and emit output + if (matched.length > 0) { + const output = formatOutput(matched, currentRepo, projectDir); + + // stderr: terminal status line + const highPriority = matched.filter( + s => s.config.type === 'base' || s.config.priority === 'critical' || s.config.priority === 'high' + ); + const activatedNames = highPriority.map(s => s.name).join(', ') || 'none'; + process.stderr.write(`[Skills] Activated: ${activatedNames}\n`); + + // stdout: injected into Claude's context + if (output) { + process.stdout.write(output); + } + } + + process.exit(0); + } catch (err) { + // NEVER block the user's prompt — log and exit cleanly + process.stderr.write(`[Skills] Error: ${err.message}\n`); + process.exit(0); + } +} + +// CLI entry point guard — only run main() when executed directly +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/.claude/hooks/skill-activation-prompt.sh b/.claude/hooks/skill-activation-prompt.sh new file mode 100755 index 0000000..aa92a49 --- /dev/null +++ b/.claude/hooks/skill-activation-prompt.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# Skill Activation Prompt Hook — Shell Wrapper +# Called by Claude Code on every UserPromptSubmit. +# Captures stdin, runs the Node.js matching engine, separates stdout/stderr. +# Always exits 0 — NEVER blocks the user's prompt. +# +# Note: No set -e — hook failures must not block prompts. + +# --------------------------------------------------------------------------- +# Debug logging (opt-in via ASPENS_DEBUG=1 to avoid leaking prompt data) +# --------------------------------------------------------------------------- +log_debug() { + if [ "$ASPENS_DEBUG" = "1" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "${TMPDIR:-/tmp}/claude-skill-hook-debug-$(id -u).log" + fi +} + +log_debug "HOOK SCRIPT STARTED - PID $$" + +# --------------------------------------------------------------------------- +# Resolve script directory (handles symlinks — essential for hub support) +# --------------------------------------------------------------------------- +get_script_dir() { + local source="${BASH_SOURCE[0]}" + while [ -h "$source" ]; do + local dir="$(cd -P "$(dirname "$source")" && pwd)" + source="$(readlink "$source")" + [[ $source != /* ]] && source="$dir/$source" + done + cd -P "$(dirname "$source")" && pwd +} + +SCRIPT_DIR="$(get_script_dir)" +log_debug "SCRIPT_DIR=$SCRIPT_DIR" +log_debug "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" + +cd "$SCRIPT_DIR" + +# --------------------------------------------------------------------------- +# Capture stdin +# --------------------------------------------------------------------------- +INPUT=$(cat) +log_debug "Input received: ${INPUT:0:200}..." + +# --------------------------------------------------------------------------- +# Run matching engine with clean stdout/stderr separation +# --------------------------------------------------------------------------- +STDOUT_FILE=$(mktemp) +STDERR_FILE=$(mktemp) +trap 'rm -f "$STDOUT_FILE" "$STDERR_FILE"' EXIT + +printf '%s' "$INPUT" | NODE_NO_WARNINGS=1 node skill-activation-prompt.mjs \ + >"$STDOUT_FILE" 2>"$STDERR_FILE" +EXIT_CODE=$? + +log_debug "Exit code: $EXIT_CODE" +log_debug "Stderr: $(cat "$STDERR_FILE" 2>/dev/null | head -5)" + +# --------------------------------------------------------------------------- +# Terminal status output (stderr — visible in verbose mode via Ctrl+O) +# --------------------------------------------------------------------------- +if [ $EXIT_CODE -ne 0 ]; then + echo "⚡ [Skills] Hook error (exit $EXIT_CODE)" >&2 + log_debug "ERROR: Hook failed with exit code $EXIT_CODE" +else + SKILL_LINE=$(grep -o '\[Skills\] Activated: [^"]*' "$STDERR_FILE" | head -1) + if [ -n "$SKILL_LINE" ]; then + echo "⚡ $SKILL_LINE" >&2 + else + echo "⚡ [Skills] No skills matched" >&2 + fi +fi + +# --------------------------------------------------------------------------- +# Emit pristine stdout (injected into Claude's context) +# --------------------------------------------------------------------------- +cat "$STDOUT_FILE" + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..0ef7711 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-activation-prompt.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use-tracker.sh" + } + ] + } + ] + } +} diff --git a/.claude/skills/skill-rules.json b/.claude/skills/skill-rules.json new file mode 100644 index 0000000..e03ce13 --- /dev/null +++ b/.claude/skills/skill-rules.json @@ -0,0 +1,255 @@ +{ + "version": "2.0", + "skills": { + "agent-customization": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/commands/customize.js", + "src/prompts/customize-agents.md" + ], + "promptTriggers": { + "keywords": [ + "agent", + "customization", + "agent customization", + "llm-powered", + "injection", + "project", + "context", + "installed", + "commands", + "customize", + "prompts", + "customize-agents" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*agent customization", + "agent customization.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*agent.*customization" + ] + } + }, + "base": { + "type": "base", + "enforcement": "suggest", + "priority": "critical", + "scope": "all", + "alwaysActivate": true, + "filePatterns": [], + "promptTriggers": { + "keywords": [ + "core", + "conventions,", + "tech", + "stack,", + "project" + ], + "intentPatterns": [] + } + }, + "claude-runner": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/lib/runner.js", + "src/lib/skill-writer.js", + "src/prompts/**/*.md", + "tests/*extract*" + ], + "promptTriggers": { + "keywords": [ + "claude", + "runner", + "claude runner", + "execution", + "layer", + "prompt", + "loading,", + "skill-writer", + "prompts", + "tests", + "extract" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*claude runner", + "claude runner.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*claude.*runner" + ] + } + }, + "doc-sync": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/commands/doc-sync.js", + "src/prompts/doc-sync.md" + ], + "promptTriggers": { + "keywords": [ + "doc", + "sync", + "doc sync", + "incremental", + "skill", + "updater", + "maps", + "diffs", + "commands", + "doc-sync", + "prompts" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*doc sync", + "doc sync.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*doc.*sync" + ] + } + }, + "import-graph": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/lib/graph-builder.js", + "tests/graph-builder.test.js" + ], + "promptTriggers": { + "keywords": [ + "import", + "graph", + "import graph", + "static", + "analysis", + "builds", + "dependency", + "graph-builder", + "tests", + "graph-builder.test" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*import graph", + "import graph.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*import.*graph" + ] + } + }, + "repo-scanning": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/lib/scanner.js", + "src/commands/scan.js", + "tests/scanner.test.js" + ], + "promptTriggers": { + "keywords": [ + "repo", + "scanning", + "repo scanning", + "deterministic", + "analysis", + "language/framework", + "detection,", + "scanner", + "commands", + "scan", + "tests", + "scanner.test" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*repo scanning", + "repo scanning.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*repo.*scanning" + ] + } + }, + "skill-generation": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/commands/doc-init.js", + "src/commands/doc-sync.js", + "src/commands/customize.js", + "src/lib/context-builder.js", + "src/lib/runner.js", + "src/lib/skill-writer.js", + "src/prompts/**/*" + ], + "promptTriggers": { + "keywords": [ + "skill", + "generation", + "skill generation", + "llm-powered", + "pipeline", + "claude", + "code", + "commands", + "doc-init", + "doc-sync", + "customize", + "context-builder", + "runner", + "skill-writer", + "prompts" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*skill generation", + "skill generation.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*skill.*generation" + ] + } + }, + "template-library": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/commands/add.js", + "src/commands/customize.js", + "src/templates/**/*" + ], + "promptTriggers": { + "keywords": [ + "template", + "library", + "template library", + "bundled", + "agents,", + "commands,", + "hooks", + "users", + "commands", + "add", + "customize", + "templates" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*template library", + "template library.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*template.*library" + ] + } + } + } +} diff --git a/src/lib/skill-writer.js b/src/lib/skill-writer.js index f30429b..62477fa 100644 --- a/src/lib/skill-writer.js +++ b/src/lib/skill-writer.js @@ -195,8 +195,13 @@ export function mergeSettings(existing, template) { const aspensCommands = templateCommands.filter(cmd => isAspensHook(cmd)); if (aspensCommands.length === 0) { - // Not an aspens hook, just append - merged.hooks[eventType].push(templateEntry); + // Not an aspens hook — check for duplicates before appending + const isDuplicate = merged.hooks[eventType].some(e => + JSON.stringify(e) === JSON.stringify(templateEntry) + ); + if (!isDuplicate) { + merged.hooks[eventType].push(templateEntry); + } continue; } From 9335158c3bf10d40110fbdb506d5f4ddf07ea1aa Mon Sep 17 00:00:00 2001 From: mvoutov Date: Sat, 21 Mar 2026 21:32:17 -0700 Subject: [PATCH 5/9] feat: skill auto activation hooks --- bin/cli.js | 5 +- src/commands/doc-init.js | 195 ++++++++- src/lib/runner.js | 115 ++++- src/lib/skill-reader.js | 108 +++++ src/lib/skill-writer.js | 402 +++++++++++++++++- src/prompts/partials/skill-format.md | 35 ++ src/templates/hooks/post-tool-use-tracker.sh | 7 + .../hooks/skill-activation-prompt.mjs | 388 +++++++++++++++++ .../hooks/skill-activation-prompt.sh | 52 ++- src/templates/settings/settings.json | 25 ++ 10 files changed, 1301 insertions(+), 31 deletions(-) create mode 100644 src/lib/skill-reader.js create mode 100644 src/templates/hooks/skill-activation-prompt.mjs create mode 100644 src/templates/settings/settings.json diff --git a/bin/cli.js b/bin/cli.js index eb9b751..5673892 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -28,7 +28,7 @@ function showWelcome() { ${pc.bold('Quick Start')} ${pc.green('aspens scan')} See your repo's tech stack and domains - ${pc.green('aspens doc init')} Generate skills + CLAUDE.md + ${pc.green('aspens doc init')} Generate skills + hooks + CLAUDE.md ${pc.green('aspens doc sync --install-hook')} Auto-update on every commit ${pc.bold('Generate & Sync')} @@ -52,6 +52,7 @@ function showWelcome() { ${pc.yellow('--force')} Overwrite existing files ${pc.yellow('--model')} ${pc.dim('')} Choose Claude model ${pc.yellow('--mode')} ${pc.dim('')} all, chunked, base-only ${pc.yellow('--timeout')} ${pc.dim('')} Seconds per call ${pc.yellow('--strategy')} ${pc.dim('')} improve, rewrite, skip ${pc.yellow('--json')} JSON output (scan) + ${pc.yellow('--no-hooks')} Skip hook installation ${pc.yellow('--hooks-only')} Update hooks only ${pc.bold('Typical Workflow')} ${pc.dim('$')} aspens scan ${pc.dim('1. See what\'s in your repo')} @@ -101,6 +102,8 @@ doc .option('--model ', 'Claude model to use (e.g., sonnet, opus, haiku)') .option('--no-hook', 'Skip post-commit hook prompt') .option('--verbose', 'Show what Claude is reading/doing in real time') + .option('--no-hooks', 'Skip hook/rules/settings installation') + .option('--hooks-only', 'Skip skill generation, just install/update hooks') .action(docInitCommand); doc diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index 45535f9..b4ac16a 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -1,13 +1,17 @@ -import { resolve, join } from 'path'; -import { existsSync, readFileSync } from 'fs'; +import { resolve, join, dirname } from 'path'; +import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, chmodSync } from 'fs'; +import { fileURLToPath } from 'url'; import pc from 'picocolors'; import * as p from '@clack/prompts'; import { scanRepo } from '../lib/scanner.js'; import { buildRepoGraph } from '../lib/graph-builder.js'; -import { runClaude, loadPrompt, parseFileOutput } from '../lib/runner.js'; -import { writeSkillFiles } from '../lib/skill-writer.js'; +import { runClaude, loadPrompt, parseFileOutput, validateSkillFiles } from '../lib/runner.js'; +import { writeSkillFiles, extractRulesFromSkills, generateDomainPatterns, mergeSettings } from '../lib/skill-writer.js'; import { installGitHook } from './doc-sync.js'; +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEMPLATES_DIR = join(__dirname, '..', 'templates'); + // Read-only tools — Claude explores the repo itself const READ_ONLY_TOOLS = ['Read', 'Glob', 'Grep']; @@ -49,6 +53,19 @@ export async function docInitCommand(path, options) { const verbose = !!options.verbose; const model = options.model || null; + // --hooks-only: skip skill generation, just install/update hooks + if (options.hooksOnly) { + p.intro(pc.cyan('aspens doc init --hooks-only')); + const skillsDir = join(repoPath, '.claude', 'skills'); + if (!existsSync(skillsDir)) { + p.log.error('No skills found in .claude/skills/. Run `aspens doc init` first.'); + process.exit(1); + } + await installHooks(repoPath, options); + p.outro(pc.green('Hooks updated')); + return; + } + // Reset token tracker for this run const startTime = Date.now(); tokenTracker.promptTokens = 0; @@ -294,7 +311,7 @@ export async function docInitCommand(path, options) { } } - // Step 4: Generate (Layer 3) — write skills from findings + // Step 5: Generate skills let allFiles = []; if (mode === 'all-at-once') { @@ -311,11 +328,38 @@ export async function docInitCommand(path, options) { process.exit(1); } - // Step 4: Show what will be written + // Step 6: Validate generated files + let validation = { valid: true, issues: [] }; + try { + validation = validateSkillFiles(allFiles, repoPath); + } catch (err) { + p.log.warn(`Validation failed: ${err.message}`); + } + + if (!validation.valid) { + console.log(); + p.log.warn(`Found ${validation.issues.length} issue(s) in generated skills:`); + for (const issue of validation.issues) { + const icon = issue.issue === 'bad-path' ? pc.yellow('?') : pc.red('!'); + console.log(pc.dim(' ') + `${icon} ${pc.dim(issue.file)} — ${issue.detail}`); + } + + // Filter out truncated files — they'd be useless + const truncated = validation.issues.filter(i => i.issue === 'truncated').map(i => i.file); + if (truncated.length > 0) { + allFiles = allFiles.filter(f => !truncated.includes(f.path)); + p.log.warn(`Removed ${truncated.length} truncated file(s). Re-run to regenerate them.`); + } + console.log(); + } + + // Step 7: Show what will be written console.log(); p.log.info('Files to write:'); for (const file of allFiles) { - console.log(pc.dim(' ') + pc.green(file.path)); + const hasIssues = validation.issues?.some(i => i.file === file.path) ?? false; + const icon = hasIssues ? pc.yellow('~') : pc.green('+'); + console.log(pc.dim(' ') + icon + ' ' + file.path); } console.log(); @@ -342,7 +386,7 @@ export async function docInitCommand(path, options) { process.exit(0); } - // Step 5: Write files + // Step 8: Write files const writeSpinner = p.spinner(); writeSpinner.start('Writing files...'); const results = writeSkillFiles(repoPath, allFiles, { force: options.force }); @@ -362,6 +406,11 @@ export async function docInitCommand(path, options) { console.log(` ${icon} ${result.path}${status}`); } + // Step 9: Generate skill-rules.json + install hooks (unless --no-hooks) + if (!options.noHooks) { + await installHooks(repoPath, options); + } + showTokenSummary(startTime); // Offer auto-sync hook @@ -411,6 +460,136 @@ function showTokenSummary(startTime) { } } +// --- Hook installation --- + +async function installHooks(repoPath, options) { + const skillsDir = join(repoPath, '.claude', 'skills'); + const hooksDir = join(repoPath, '.claude', 'hooks'); + const settingsPath = join(repoPath, '.claude', 'settings.json'); + + if (!existsSync(skillsDir)) { + p.log.warn('No skills directory found — skipping hook installation.'); + return; + } + + const hookSpinner = p.spinner(); + hookSpinner.start('Installing skill activation hooks...'); + + try { + // 9a: Generate skill-rules.json + const rules = extractRulesFromSkills(skillsDir); + const skillCount = Object.keys(rules.skills).length; + + if (skillCount === 0) { + hookSpinner.stop(pc.dim('No skills found — skipping hooks')); + return; + } + + const rulesPath = join(skillsDir, 'skill-rules.json'); + + if (!options.dryRun) { + writeFileSync(rulesPath, JSON.stringify(rules, null, 2) + '\n'); + } + + // 9b: Copy hook files + mkdirSync(hooksDir, { recursive: true }); + + const hookFiles = [ + { src: 'hooks/skill-activation-prompt.sh', dest: 'skill-activation-prompt.sh', chmod: true }, + { src: 'hooks/skill-activation-prompt.mjs', dest: 'skill-activation-prompt.mjs', chmod: false }, + ]; + + for (const hf of hookFiles) { + const srcPath = join(TEMPLATES_DIR, hf.src); + const destPath = join(hooksDir, hf.dest); + if (!existsSync(srcPath)) { + p.log.warn(`Template not found: ${hf.src}`); + continue; + } + if (!options.dryRun) { + copyFileSync(srcPath, destPath); + if (hf.chmod) { + chmodSync(destPath, 0o755); + } + } + } + + // 9c: Generate post-tool-use-tracker with domain patterns + const trackerSrc = join(TEMPLATES_DIR, 'hooks', 'post-tool-use-tracker.sh'); + const trackerDest = join(hooksDir, 'post-tool-use-tracker.sh'); + if (existsSync(trackerSrc)) { + let trackerContent = readFileSync(trackerSrc, 'utf8'); + + // Inject generated domain patterns into detect_skill_domain() + const domainPatterns = generateDomainPatterns(rules); + // Replace the stub function with the generated one + const stubRegex = /detect_skill_domain\(\)\s*\{[\s\S]*?\n\}/; + if (stubRegex.test(trackerContent)) { + trackerContent = trackerContent.replace(stubRegex, domainPatterns.trim()); + } + + if (!options.dryRun) { + writeFileSync(trackerDest, trackerContent); + chmodSync(trackerDest, 0o755); + } + } + + // 9d: Merge settings.json + let templateSettings; + try { + templateSettings = JSON.parse( + readFileSync(join(TEMPLATES_DIR, 'settings', 'settings.json'), 'utf8') + ); + } catch (err) { + hookSpinner.stop(pc.yellow('Hook installation incomplete')); + p.log.warn(`Could not read template settings: ${err.message}`); + return; + } + + let existingSettings = null; + if (existsSync(settingsPath)) { + try { + existingSettings = JSON.parse(readFileSync(settingsPath, 'utf8')); + // Backup existing settings + if (!options.dryRun) { + writeFileSync(settingsPath + '.bak', JSON.stringify(existingSettings, null, 2) + '\n'); + } + } catch { + // Existing settings malformed — overwrite + } + } + + const merged = mergeSettings(existingSettings, templateSettings); + + if (!options.dryRun) { + mkdirSync(dirname(settingsPath), { recursive: true }); + writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n'); + } + + hookSpinner.stop(pc.green(`Hooks installed (${skillCount} skills in rules)`)); + + // Show what was done + console.log(); + const items = [ + `${pc.green('+')} .claude/skills/skill-rules.json ${pc.dim(`(${skillCount} skills)`)}`, + `${pc.green('+')} .claude/hooks/skill-activation-prompt.sh`, + `${pc.green('+')} .claude/hooks/skill-activation-prompt.mjs`, + `${pc.green('+')} .claude/hooks/post-tool-use-tracker.sh ${pc.dim('(with domain patterns)')}`, + `${existingSettings ? pc.yellow('~') : pc.green('+')} .claude/settings.json ${pc.dim(existingSettings ? '(merged)' : '(created)')}`, + ]; + for (const item of items) { + console.log(` ${item}`); + } + if (existingSettings && !options.dryRun) { + console.log(pc.dim(' Backup: .claude/settings.json.bak')); + } + console.log(); + } catch (err) { + hookSpinner.stop(pc.red('Hook installation failed')); + p.log.error(err.message); + } +} + // --- Generation modes --- function buildScanSummary(scan) { diff --git a/src/lib/runner.js b/src/lib/runner.js index ac66086..60380cb 100644 --- a/src/lib/runner.js +++ b/src/lib/runner.js @@ -159,20 +159,64 @@ export function loadPrompt(name, vars = {}) { export function parseFileOutput(output) { let files = []; - // Primary: XML tags — content - // Unambiguous, handles code blocks inside content - const xmlPattern = /\n?([\s\S]*?)<\/file>/g; - let match; - while ((match = xmlPattern.exec(output)) !== null) { - const filePath = sanitizePath(match[1].trim()); - if (filePath) { - files.push({ path: filePath, content: match[2].trim() + '\n' }); + // Primary: Split on tags and match to next outside code fences. + // Strategy: find all positions that are NOT inside ``` fenced code blocks, + // then match each open tag to the nearest valid . + const openTagPattern = //g; + + // Pre-compute which character positions are inside fenced code blocks + const fenceRanges = []; + const fenceRegex = /^```[^\n]*\n[\s\S]*?\n```/gm; + let fm; + while ((fm = fenceRegex.exec(output)) !== null) { + fenceRanges.push([fm.index, fm.index + fm[0].length]); + } + function isInsideFence(pos) { + for (const [start, end] of fenceRanges) { + if (pos >= start && pos < end) return true; + } + return false; + } + + // Find all valid positions (at line start, outside code fences) + const closePositions = []; + const closeRegex = /\n<\/file>/g; + let cm; + while ((cm = closeRegex.exec(output)) !== null) { + if (!isInsideFence(cm.index)) { + closePositions.push(cm.index); } } - // Fallback 1: HTML comment markers with content between them + let openMatch; + while ((openMatch = openTagPattern.exec(output)) !== null) { + const filePath = sanitizePath(openMatch[1].trim()); + if (!filePath) continue; + + const contentStart = openMatch.index + openMatch[0].length; + + // Find the first valid AFTER this open tag + const closePos = closePositions.find(p => p >= contentStart); + + let content; + if (closePos !== undefined) { + content = output.slice(contentStart, closePos).trim() + '\n'; + // Advance past this tag + openTagPattern.lastIndex = closePos + '\n'.length; + } else { + // No valid closing tag — take up to next \s*\n([\s\S]*?)(?= blocks. + * Medium/low skills are listed as available. + * + * @param {Array<{ name: string, matchType: string, config: Object, content?: string }>} matched + * @param {string} currentRepo - Current repository name + * @param {string} projectDir - Absolute path to the project root + * @returns {string} Formatted output for stdout + */ +export function formatOutput(matched, currentRepo, projectDir) { + if (matched.length === 0) { + return ''; + } + + // Load skill content for each matched skill + for (const skill of matched) { + if (!skill.content) { + const content = readSkillContent(projectDir, skill.name); + if (content) { + skill.content = content; + } + } + } + + const skillsWithContent = matched.filter(s => s.content); + const skillsWithoutContent = matched.filter(s => !s.content); + + // High priority: base type OR critical/high priority — inject full content + const highPrioritySkills = skillsWithContent.filter( + s => s.config.type === 'base' || s.config.priority === 'critical' || s.config.priority === 'high' + ); + + // Medium/low priority (excluding those already in high priority) — list as available + const highSet = new Set(highPrioritySkills); + const optionalSkills = skillsWithContent.filter( + s => !highSet.has(s) + ); + + let output = ''; + + if (highPrioritySkills.length > 0) { + output += '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n'; + output += `\uD83D\uDCCD ACTIVE SKILLS (${currentRepo})\n`; + output += '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n\n'; + + for (const skill of highPrioritySkills) { + output += `\n`; + output += skill.content + '\n'; + output += `\n\n`; + } + } + + if (optionalSkills.length > 0 || skillsWithoutContent.length > 0) { + const availableNames = [ + ...optionalSkills.map(s => s.name), + ...skillsWithoutContent.map(s => s.name), + ]; + output += '\uD83D\uDCCC Available skills (ask to activate): ' + availableNames.join(', ') + '\n'; + } + + if (highPrioritySkills.length > 0 || optionalSkills.length > 0 || skillsWithoutContent.length > 0) { + output += '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n'; + } + + return output; +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +async function main() { + try { + // Read input from stdin + const input = readFileSync(0, 'utf-8'); + + let data; + try { + data = JSON.parse(input); + } catch { + // Invalid JSON — exit silently + process.exit(0); + } + + const prompt = data.prompt || ''; + if (!prompt) { + process.exit(0); + } + + // Determine project directory + const projectDir = process.env.CLAUDE_PROJECT_DIR; + if (!projectDir) { + process.exit(0); + } + + // Load skill rules + const rulesPath = join(projectDir, '.claude', 'skills', 'skill-rules.json'); + if (!existsSync(rulesPath)) { + // No skill rules file — exit silently + process.exit(0); + } + + let rules; + try { + rules = JSON.parse(readFileSync(rulesPath, 'utf-8')); + } catch { + // Invalid rules file — exit silently + process.exit(0); + } + + if (!rules.skills || typeof rules.skills !== 'object') { + process.exit(0); + } + + // Detect current repository + const currentRepo = detectCurrentRepo(projectDir); + + // Get session-sticky skills + const sessionSkills = getSessionActiveSkills(projectDir); + + // Match skills against the prompt + const matched = matchSkills(prompt, rules, currentRepo, sessionSkills); + + // Load content for matched skills + for (const skill of matched) { + const content = readSkillContent(projectDir, skill.name); + if (content) { + skill.content = content; + } + } + + // Debug output + if (process.env.ASPENS_DEBUG === '1') { + const debugTrace = { + timestamp: new Date().toISOString(), + projectDir, + currentRepo, + prompt: prompt.substring(0, 500), + sessionSkills, + rulesLoaded: Object.keys(rules.skills), + matched: matched.map(s => ({ + name: s.name, + matchType: s.matchType, + priority: s.config.priority, + type: s.config.type, + hasContent: !!s.content, + })), + }; + try { + writeFileSync('/tmp/aspens-debug-activation.json', JSON.stringify(debugTrace, null, 2)); + } catch { + // Debug write failed — ignore + } + } + + // Format and emit output + if (matched.length > 0) { + const output = formatOutput(matched, currentRepo, projectDir); + + // stderr: terminal status line + const highPriority = matched.filter( + s => s.config.type === 'base' || s.config.priority === 'critical' || s.config.priority === 'high' + ); + const activatedNames = highPriority.map(s => s.name).join(', ') || 'none'; + process.stderr.write(`[Skills] Activated: ${activatedNames}\n`); + + // stdout: injected into Claude's context + if (output) { + process.stdout.write(output); + } + } + + process.exit(0); + } catch (err) { + // NEVER block the user's prompt — log and exit cleanly + process.stderr.write(`[Skills] Error: ${err.message}\n`); + process.exit(0); + } +} + +// CLI entry point guard — only run main() when executed directly +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/src/templates/hooks/skill-activation-prompt.sh b/src/templates/hooks/skill-activation-prompt.sh index 3ef1298..aa92a49 100755 --- a/src/templates/hooks/skill-activation-prompt.sh +++ b/src/templates/hooks/skill-activation-prompt.sh @@ -1,41 +1,67 @@ #!/bin/bash -# Note: Removed set -e to prevent hook failures from blocking prompts +# Skill Activation Prompt Hook — Shell Wrapper +# Called by Claude Code on every UserPromptSubmit. +# Captures stdin, runs the Node.js matching engine, separates stdout/stderr. +# Always exits 0 — NEVER blocks the user's prompt. +# +# Note: No set -e — hook failures must not block prompts. -# Resolve the actual directory where this script lives (handling symlinks) -# This ensures we can find the TypeScript file even when the hook is symlinked +# --------------------------------------------------------------------------- +# Debug logging (opt-in via ASPENS_DEBUG=1 to avoid leaking prompt data) +# --------------------------------------------------------------------------- +log_debug() { + if [ "$ASPENS_DEBUG" = "1" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "${TMPDIR:-/tmp}/claude-skill-hook-debug-$(id -u).log" + fi +} + +log_debug "HOOK SCRIPT STARTED - PID $$" + +# --------------------------------------------------------------------------- +# Resolve script directory (handles symlinks — essential for hub support) +# --------------------------------------------------------------------------- get_script_dir() { local source="${BASH_SOURCE[0]}" - # Resolve symlinks while [ -h "$source" ]; do local dir="$(cd -P "$(dirname "$source")" && pwd)" source="$(readlink "$source")" - # Handle relative symlinks [[ $source != /* ]] && source="$dir/$source" done cd -P "$(dirname "$source")" && pwd } SCRIPT_DIR="$(get_script_dir)" +log_debug "SCRIPT_DIR=$SCRIPT_DIR" +log_debug "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" -# Change to the script directory and run the TypeScript hook cd "$SCRIPT_DIR" -# Capture stdin (printf preserves payload shape, unlike echo which can mangle escapes) +# --------------------------------------------------------------------------- +# Capture stdin +# --------------------------------------------------------------------------- INPUT=$(cat) +log_debug "Input received: ${INPUT:0:200}..." -# Temp files for clean stdout/stderr separation +# --------------------------------------------------------------------------- +# Run matching engine with clean stdout/stderr separation +# --------------------------------------------------------------------------- STDOUT_FILE=$(mktemp) STDERR_FILE=$(mktemp) trap 'rm -f "$STDOUT_FILE" "$STDERR_FILE"' EXIT -# Run the TypeScript hook with stdout and stderr captured separately -printf '%s' "$INPUT" | NODE_NO_WARNINGS=1 npx tsx skill-activation-prompt.ts \ +printf '%s' "$INPUT" | NODE_NO_WARNINGS=1 node skill-activation-prompt.mjs \ >"$STDOUT_FILE" 2>"$STDERR_FILE" EXIT_CODE=$? -# Parse stderr for skill activation info and relay to terminal +log_debug "Exit code: $EXIT_CODE" +log_debug "Stderr: $(cat "$STDERR_FILE" 2>/dev/null | head -5)" + +# --------------------------------------------------------------------------- +# Terminal status output (stderr — visible in verbose mode via Ctrl+O) +# --------------------------------------------------------------------------- if [ $EXIT_CODE -ne 0 ]; then echo "⚡ [Skills] Hook error (exit $EXIT_CODE)" >&2 + log_debug "ERROR: Hook failed with exit code $EXIT_CODE" else SKILL_LINE=$(grep -o '\[Skills\] Activated: [^"]*' "$STDERR_FILE" | head -1) if [ -n "$SKILL_LINE" ]; then @@ -45,7 +71,9 @@ else fi fi -# Output pristine stdout only (no grep filtering needed) +# --------------------------------------------------------------------------- +# Emit pristine stdout (injected into Claude's context) +# --------------------------------------------------------------------------- cat "$STDOUT_FILE" exit 0 diff --git a/src/templates/settings/settings.json b/src/templates/settings/settings.json new file mode 100644 index 0000000..0ef7711 --- /dev/null +++ b/src/templates/settings/settings.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-activation-prompt.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use-tracker.sh" + } + ] + } + ] + } +} From c51fe0d2feca6a370dacb10d7007dc1ad1a81171 Mon Sep 17 00:00:00 2001 From: mvoutov Date: Sat, 21 Mar 2026 21:35:33 -0700 Subject: [PATCH 6/9] claude aspens hooks + small fix/safe for hooks dulication name --- .claude/hooks/post-tool-use-tracker.sh | 292 ++++++++++++++++ .claude/hooks/skill-activation-prompt.mjs | 388 ++++++++++++++++++++++ .claude/hooks/skill-activation-prompt.sh | 79 +++++ .claude/settings.json | 25 ++ .claude/skills/skill-rules.json | 255 ++++++++++++++ src/lib/skill-writer.js | 9 +- 6 files changed, 1046 insertions(+), 2 deletions(-) create mode 100755 .claude/hooks/post-tool-use-tracker.sh create mode 100644 .claude/hooks/skill-activation-prompt.mjs create mode 100755 .claude/hooks/skill-activation-prompt.sh create mode 100644 .claude/settings.json create mode 100644 .claude/skills/skill-rules.json diff --git a/.claude/hooks/post-tool-use-tracker.sh b/.claude/hooks/post-tool-use-tracker.sh new file mode 100755 index 0000000..02826c0 --- /dev/null +++ b/.claude/hooks/post-tool-use-tracker.sh @@ -0,0 +1,292 @@ +#!/bin/bash +# Note: Removed set -e to prevent hook failures from blocking edits + +# IMPORTANT: All output MUST go to stderr (>&2), not stdout. +# stdout from PostToolUse hooks is injected into Claude's context. + +# Post-tool-use hook that tracks edited files and their repos +# This runs after Edit, MultiEdit, or Write tools complete successfully + +# Require jq for JSON parsing +if ! command -v jq &> /dev/null; then + exit 0 +fi + +# Exit early if CLAUDE_PROJECT_DIR is not set +if [[ -z "$CLAUDE_PROJECT_DIR" ]]; then + exit 0 +fi + +# Read tool information from stdin +tool_info=$(cat) + + +# Extract relevant data +tool_name=$(echo "$tool_info" | jq -r '.tool_name // empty') +file_path=$(echo "$tool_info" | jq -r '.tool_input.file_path // empty') +session_id=$(echo "$tool_info" | jq -r '.session_id // empty') + + +# Skip if not an edit tool or no file path +if [[ ! "$tool_name" =~ ^(Edit|MultiEdit|Write)$ ]] || [[ -z "$file_path" ]]; then + exit 0 # Exit 0 for skip conditions +fi + +# Skip markdown files +if [[ "$file_path" =~ \.(md|markdown)$ ]]; then + exit 0 # Exit 0 for skip conditions +fi + +# Create cache directory in project +cache_dir="$CLAUDE_PROJECT_DIR/.claude/tsc-cache/${session_id:-default}" +mkdir -p "$cache_dir" + +# Function to detect repo from file path +detect_repo() { + local file="$1" + local project_root="$CLAUDE_PROJECT_DIR" + + # Remove project root from path + local relative_path="${file#$project_root/}" + + # Extract first directory component + local repo=$(echo "$relative_path" | cut -d'/' -f1) + + # Common project directory patterns + case "$repo" in + # Frontend variations + frontend|client|web|app|ui) + echo "$repo" + ;; + # Backend variations + backend|server|api|src|services) + echo "$repo" + ;; + # Database + database|prisma|migrations) + echo "$repo" + ;; + # Package/monorepo structure + packages) + # For monorepos, get the package name + local package=$(echo "$relative_path" | cut -d'/' -f2) + if [[ -n "$package" ]]; then + echo "packages/$package" + else + echo "$repo" + fi + ;; + # Examples directory + examples) + local example=$(echo "$relative_path" | cut -d'/' -f2) + if [[ -n "$example" ]]; then + echo "examples/$example" + else + echo "$repo" + fi + ;; + *) + # Check if it's a source file in root + if [[ ! "$relative_path" =~ / ]]; then + echo "root" + else + echo "unknown" + fi + ;; + esac +} + +# Function to get build command for repo +get_build_command() { + local repo="$1" + local project_root="$CLAUDE_PROJECT_DIR" + + # Map special repo names to actual paths + local repo_path + if [[ "$repo" == "root" ]] || [[ "$repo" == "src" ]] || [[ "$repo" == "unknown" ]]; then + repo_path="$project_root" + else + repo_path="$project_root/$repo" + fi + + # Check if package.json exists and has a build script + if [[ -f "$repo_path/package.json" ]]; then + if grep -q '"build"' "$repo_path/package.json" 2>/dev/null; then + # Detect package manager (prefer pnpm, then npm, then yarn) + if [[ -f "$repo_path/pnpm-lock.yaml" ]]; then + echo "cd $repo_path && pnpm build" + elif [[ -f "$repo_path/package-lock.json" ]]; then + echo "cd $repo_path && npm run build" + elif [[ -f "$repo_path/yarn.lock" ]]; then + echo "cd $repo_path && yarn build" + else + echo "cd $repo_path && npm run build" + fi + return + fi + fi + + # Special case for database with Prisma + if [[ "$repo" == "database" ]] || [[ "$repo" =~ prisma ]]; then + if [[ -f "$repo_path/schema.prisma" ]] || [[ -f "$repo_path/prisma/schema.prisma" ]]; then + echo "cd $repo_path && npx prisma generate" + return + fi + fi + + # No build command found + echo "" +} + +# Function to get TSC command for repo +get_tsc_command() { + local repo="$1" + local project_root="$CLAUDE_PROJECT_DIR" + + # Map special repo names to actual paths + local repo_path + if [[ "$repo" == "root" ]] || [[ "$repo" == "src" ]] || [[ "$repo" == "unknown" ]]; then + repo_path="$project_root" + else + repo_path="$project_root/$repo" + fi + + # Check if tsconfig.json exists + if [[ -f "$repo_path/tsconfig.json" ]]; then + # Check for Vite/React-specific tsconfig + if [[ -f "$repo_path/tsconfig.app.json" ]]; then + echo "cd $repo_path && npx tsc --project tsconfig.app.json --noEmit" + else + echo "cd $repo_path && npx tsc --noEmit" + fi + return + fi + + # No TypeScript config found + echo "" +} + +# Detect repo +repo=$(detect_repo "$file_path") + +# Skip if unknown repo +if [[ "$repo" == "unknown" ]] || [[ -z "$repo" ]]; then + exit 0 # Exit 0 for skip conditions +fi + +# Log edited file +echo "$(date +%s):$file_path:$repo" >> "$cache_dir/edited-files.log" + +# Update affected repos list +if ! grep -q "^$repo$" "$cache_dir/affected-repos.txt" 2>/dev/null; then + echo "$repo" >> "$cache_dir/affected-repos.txt" +fi + +# Store build commands +build_cmd=$(get_build_command "$repo") +tsc_cmd=$(get_tsc_command "$repo") + +if [[ -n "$build_cmd" ]]; then + echo "$repo:build:$build_cmd" >> "$cache_dir/commands.txt.tmp" +fi + +if [[ -n "$tsc_cmd" ]]; then + echo "$repo:tsc:$tsc_cmd" >> "$cache_dir/commands.txt.tmp" +fi + +# Remove duplicates from commands +if [[ -f "$cache_dir/commands.txt.tmp" ]]; then + sort -u "$cache_dir/commands.txt.tmp" > "$cache_dir/commands.txt" + rm -f "$cache_dir/commands.txt.tmp" +fi + +# ============================================ +# SESSION-STICKY SKILLS TRACKING +# ============================================ +# Detect which domain skill should be activated based on file path +# and persist it in session state for sticky behavior + +detect_skill_domain() { + local file="$1" + local detected_skills="" + + # Generated by aspens from skill-rules.json filePatterns + if [[ "$file" =~ /customize ]] || [[ "$file" =~ /customize-agents ]]; then + detected_skills="agent-customization" + elif [[ "$file" =~ /runner ]] || [[ "$file" =~ /skill-writer ]] || [[ "$file" =~ /prompts/ ]] || [[ "$file" =~ /tests/ ]]; then + detected_skills="claude-runner" + elif [[ "$file" =~ /doc-sync ]]; then + detected_skills="doc-sync" + elif [[ "$file" =~ /graph-builder ]] || [[ "$file" =~ /graph-builder.test ]]; then + detected_skills="import-graph" + elif [[ "$file" =~ /scanner ]] || [[ "$file" =~ /scan ]] || [[ "$file" =~ /scanner.test ]]; then + detected_skills="repo-scanning" + elif [[ "$file" =~ /doc-init ]] || [[ "$file" =~ /doc-sync ]] || [[ "$file" =~ /customize ]] || [[ "$file" =~ /context-builder ]] || [[ "$file" =~ /runner ]] || [[ "$file" =~ /skill-writer ]] || [[ "$file" =~ /prompts/ ]]; then + detected_skills="skill-generation" + elif [[ "$file" =~ /add ]] || [[ "$file" =~ /customize ]] || [[ "$file" =~ /templates/ ]]; then + detected_skills="template-library" + fi + + echo "$detected_skills" +} + +# Create session file path based on project directory hash +get_session_file() { + local project_dir="$1" + local hash=$(echo -n "$project_dir" | md5 2>/dev/null || echo -n "$project_dir" | md5sum | cut -d' ' -f1) + echo "/tmp/claude-skills-${hash}.json" +} + +# Add skill to session state +add_skill_to_session() { + local skill="$1" + local session_file="$2" + local repo="$3" + + if [[ -z "$skill" ]]; then + return + fi + + # Create or update session file + if [[ -f "$session_file" ]]; then + # Check if jq is available + if command -v jq &> /dev/null; then + # Add skill to array, keeping unique values + jq --arg skill "$skill" --arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '.active_skills = ((.active_skills + [$skill]) | unique) | .last_updated = $time' \ + "$session_file" > "${session_file}.tmp" 2>/dev/null && \ + mv "${session_file}.tmp" "$session_file" + else + # Fallback: simple append check without jq + if ! grep -q "\"$skill\"" "$session_file" 2>/dev/null; then + # Read existing skills from file, append new one, rewrite + local existing_skills="" + if [[ -f "$session_file" ]]; then + # Extract skills array content: strip brackets, quotes, whitespace + existing_skills=$(grep -o '"active_skills":\[[^]]*\]' "$session_file" 2>/dev/null | sed 's/"active_skills":\[//;s/\]//;s/"//g;s/ //g') + fi + # Build new skills list + local new_skills="" + if [[ -n "$existing_skills" ]]; then + new_skills="\"$(echo "$existing_skills" | sed 's/,/","/g')\",\"$skill\"" + else + new_skills="\"$skill\"" + fi + echo "{\"repo\":\"$repo\",\"active_skills\":[$new_skills],\"last_updated\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$session_file" + fi + fi + else + # Create new session file + echo "{\"repo\":\"$repo\",\"active_skills\":[\"$skill\"],\"last_updated\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > "$session_file" + fi +} + +# Track skill domain for session-sticky behavior +skill_domain=$(detect_skill_domain "$file_path") +if [[ -n "$skill_domain" ]]; then + session_file=$(get_session_file "$CLAUDE_PROJECT_DIR") + add_skill_to_session "$skill_domain" "$session_file" "$repo" +fi + +# Exit cleanly +exit 0 diff --git a/.claude/hooks/skill-activation-prompt.mjs b/.claude/hooks/skill-activation-prompt.mjs new file mode 100644 index 0000000..9c6582d --- /dev/null +++ b/.claude/hooks/skill-activation-prompt.mjs @@ -0,0 +1,388 @@ +#!/usr/bin/env node +/** + * Skill Activation Prompt Hook — Matching Engine + * + * Standalone ESM module. Copied into target repo's .claude/hooks/ directory. + * No imports from aspens — uses only Node.js builtins. + * + * Called by skill-activation-prompt.sh on every UserPromptSubmit. + * Reads stdin JSON, matches prompt against skill-rules.json, + * injects high-priority skill content into Claude's context (stdout), + * lists medium/low skills as available. + * + * Exports functions for testability (vitest can import them). + */ + +import { readFileSync, existsSync, writeFileSync } from 'fs'; +import { join, basename } from 'path'; +import { createHash } from 'crypto'; +import { fileURLToPath } from 'url'; +import { tmpdir } from 'os'; + +// --------------------------------------------------------------------------- +// Exported functions (for testability) +// --------------------------------------------------------------------------- + +/** + * Read the skill.md content for a given skill name. + * Skill names like "auth" → .claude/skills/auth/skill.md + * Skill names like "backend/base" → .claude/skills/backend/base/skill.md + * + * @param {string} projectDir - Absolute path to the project root + * @param {string} skillName - Skill identifier (e.g. "auth", "backend/base") + * @returns {string|null} Skill markdown content, or null if not found + */ +export function readSkillContent(projectDir, skillName) { + const possiblePaths = [ + join(projectDir, '.claude', 'skills', skillName, 'skill.md'), + join(projectDir, '.claude', 'skills', `${skillName}.md`), + ]; + + for (const skillPath of possiblePaths) { + if (existsSync(skillPath)) { + try { + return readFileSync(skillPath, 'utf-8'); + } catch { + // Continue to next path + } + } + } + + return null; +} + +/** + * Detect which repository we're currently in. + * Checks .claude/repo-config.json first, falls back to directory basename. + * + * @param {string} projectDir - Absolute path to the project root + * @returns {string} Repository name + */ +export function detectCurrentRepo(projectDir) { + // Try repo-config.json first (hub sets this per repo) + const configPath = join(projectDir, '.claude', 'repo-config.json'); + if (existsSync(configPath)) { + try { + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + if (config.repoName && typeof config.repoName === 'string' && config.repoName.trim()) { + return config.repoName.trim(); + } + } catch { + // Fall through to directory-based detection + } + } + + // Fallback: detect from directory name + return basename(projectDir); +} + +/** + * Get session-sticky skills from the session state file in /tmp/. + * Skills activated via file edits stay active for the session. + * + * @param {string} projectDir - Absolute path to the project root + * @returns {string[]} Array of active skill names + */ +export function getSessionActiveSkills(projectDir) { + try { + const hash = createHash('md5').update(projectDir).digest('hex'); + const sessionFile = join(tmpdir(), `claude-skills-${hash}.json`); + + if (existsSync(sessionFile)) { + const content = readFileSync(sessionFile, 'utf-8'); + const session = JSON.parse(content); + return session.active_skills || []; + } + } catch { + // Session file doesn't exist or is invalid — that's fine + } + return []; +} + +/** + * Check if a skill's scope matches the current repository. + * + * @param {{ scope?: string }} config - Skill rule config + * @param {string} currentRepo - Current repository name + * @returns {boolean} + */ +function skillMatchesScope(config, currentRepo) { + const scope = config.scope || 'all'; + + if (scope === 'all') { + return true; + } + + if (scope === currentRepo) { + return true; + } + + return false; +} + +/** + * Match a user prompt against skill rules. + * Returns an array of matched skills with their match type. + * + * @param {string} prompt - The user's prompt text + * @param {{ version: string, skills: Object }} rules - Parsed skill-rules.json + * @param {string} currentRepo - Current repository name + * @param {string[]} sessionSkills - Session-sticky skill names + * @returns {Array<{ name: string, matchType: string, config: Object }>} + */ +export function matchSkills(prompt, rules, currentRepo, sessionSkills) { + const promptLower = prompt.toLowerCase(); + const matched = []; + const addedSkills = new Set(); + + // SESSION-STICKY: add skills from session state first + for (const skillName of sessionSkills) { + const config = rules.skills[skillName]; + if (config && !addedSkills.has(skillName)) { + matched.push({ name: skillName, matchType: 'session', config }); + addedSkills.add(skillName); + } + } + + // Check each skill for matches + for (const [skillName, config] of Object.entries(rules.skills)) { + // Filter by scope first + if (!skillMatchesScope(config, currentRepo)) { + continue; + } + + // Skip if already added via session-sticky + if (addedSkills.has(skillName)) { + continue; + } + + // AUTO-ACTIVATE: alwaysActivate + scope matches (exact repo OR "all") + if (config.alwaysActivate && (config.scope === currentRepo || config.scope === 'all')) { + matched.push({ name: skillName, matchType: 'auto', config }); + addedSkills.add(skillName); + continue; + } + + const triggers = config.promptTriggers; + if (!triggers) { + continue; + } + + // Keyword matching + if (triggers.keywords) { + const keywordMatch = triggers.keywords.some(kw => + promptLower.includes(kw.toLowerCase()) + ); + if (keywordMatch) { + matched.push({ name: skillName, matchType: 'keyword', config }); + addedSkills.add(skillName); + continue; + } + } + + // Intent pattern matching + if (triggers.intentPatterns) { + const intentMatch = triggers.intentPatterns.some(pattern => { + try { + const regex = new RegExp(pattern, 'i'); + return regex.test(prompt); + } catch { + // Invalid regex — skip + return false; + } + }); + if (intentMatch) { + matched.push({ name: skillName, matchType: 'intent', config }); + addedSkills.add(skillName); + } + } + } + + return matched; +} + +/** + * Format the output for Claude's context injection. + * High-priority skills get full content in blocks. + * Medium/low skills are listed as available. + * + * @param {Array<{ name: string, matchType: string, config: Object, content?: string }>} matched + * @param {string} currentRepo - Current repository name + * @param {string} projectDir - Absolute path to the project root + * @returns {string} Formatted output for stdout + */ +export function formatOutput(matched, currentRepo, projectDir) { + if (matched.length === 0) { + return ''; + } + + // Load skill content for each matched skill + for (const skill of matched) { + if (!skill.content) { + const content = readSkillContent(projectDir, skill.name); + if (content) { + skill.content = content; + } + } + } + + const skillsWithContent = matched.filter(s => s.content); + const skillsWithoutContent = matched.filter(s => !s.content); + + // High priority: base type OR critical/high priority — inject full content + const highPrioritySkills = skillsWithContent.filter( + s => s.config.type === 'base' || s.config.priority === 'critical' || s.config.priority === 'high' + ); + + // Medium/low priority (excluding those already in high priority) — list as available + const highSet = new Set(highPrioritySkills); + const optionalSkills = skillsWithContent.filter( + s => !highSet.has(s) + ); + + let output = ''; + + if (highPrioritySkills.length > 0) { + output += '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n'; + output += `\uD83D\uDCCD ACTIVE SKILLS (${currentRepo})\n`; + output += '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n\n'; + + for (const skill of highPrioritySkills) { + output += `\n`; + output += skill.content + '\n'; + output += `\n\n`; + } + } + + if (optionalSkills.length > 0 || skillsWithoutContent.length > 0) { + const availableNames = [ + ...optionalSkills.map(s => s.name), + ...skillsWithoutContent.map(s => s.name), + ]; + output += '\uD83D\uDCCC Available skills (ask to activate): ' + availableNames.join(', ') + '\n'; + } + + if (highPrioritySkills.length > 0 || optionalSkills.length > 0 || skillsWithoutContent.length > 0) { + output += '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n'; + } + + return output; +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +async function main() { + try { + // Read input from stdin + const input = readFileSync(0, 'utf-8'); + + let data; + try { + data = JSON.parse(input); + } catch { + // Invalid JSON — exit silently + process.exit(0); + } + + const prompt = data.prompt || ''; + if (!prompt) { + process.exit(0); + } + + // Determine project directory + const projectDir = process.env.CLAUDE_PROJECT_DIR; + if (!projectDir) { + process.exit(0); + } + + // Load skill rules + const rulesPath = join(projectDir, '.claude', 'skills', 'skill-rules.json'); + if (!existsSync(rulesPath)) { + // No skill rules file — exit silently + process.exit(0); + } + + let rules; + try { + rules = JSON.parse(readFileSync(rulesPath, 'utf-8')); + } catch { + // Invalid rules file — exit silently + process.exit(0); + } + + if (!rules.skills || typeof rules.skills !== 'object') { + process.exit(0); + } + + // Detect current repository + const currentRepo = detectCurrentRepo(projectDir); + + // Get session-sticky skills + const sessionSkills = getSessionActiveSkills(projectDir); + + // Match skills against the prompt + const matched = matchSkills(prompt, rules, currentRepo, sessionSkills); + + // Load content for matched skills + for (const skill of matched) { + const content = readSkillContent(projectDir, skill.name); + if (content) { + skill.content = content; + } + } + + // Debug output + if (process.env.ASPENS_DEBUG === '1') { + const debugTrace = { + timestamp: new Date().toISOString(), + projectDir, + currentRepo, + prompt: prompt.substring(0, 500), + sessionSkills, + rulesLoaded: Object.keys(rules.skills), + matched: matched.map(s => ({ + name: s.name, + matchType: s.matchType, + priority: s.config.priority, + type: s.config.type, + hasContent: !!s.content, + })), + }; + try { + writeFileSync('/tmp/aspens-debug-activation.json', JSON.stringify(debugTrace, null, 2)); + } catch { + // Debug write failed — ignore + } + } + + // Format and emit output + if (matched.length > 0) { + const output = formatOutput(matched, currentRepo, projectDir); + + // stderr: terminal status line + const highPriority = matched.filter( + s => s.config.type === 'base' || s.config.priority === 'critical' || s.config.priority === 'high' + ); + const activatedNames = highPriority.map(s => s.name).join(', ') || 'none'; + process.stderr.write(`[Skills] Activated: ${activatedNames}\n`); + + // stdout: injected into Claude's context + if (output) { + process.stdout.write(output); + } + } + + process.exit(0); + } catch (err) { + // NEVER block the user's prompt — log and exit cleanly + process.stderr.write(`[Skills] Error: ${err.message}\n`); + process.exit(0); + } +} + +// CLI entry point guard — only run main() when executed directly +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/.claude/hooks/skill-activation-prompt.sh b/.claude/hooks/skill-activation-prompt.sh new file mode 100755 index 0000000..aa92a49 --- /dev/null +++ b/.claude/hooks/skill-activation-prompt.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# Skill Activation Prompt Hook — Shell Wrapper +# Called by Claude Code on every UserPromptSubmit. +# Captures stdin, runs the Node.js matching engine, separates stdout/stderr. +# Always exits 0 — NEVER blocks the user's prompt. +# +# Note: No set -e — hook failures must not block prompts. + +# --------------------------------------------------------------------------- +# Debug logging (opt-in via ASPENS_DEBUG=1 to avoid leaking prompt data) +# --------------------------------------------------------------------------- +log_debug() { + if [ "$ASPENS_DEBUG" = "1" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "${TMPDIR:-/tmp}/claude-skill-hook-debug-$(id -u).log" + fi +} + +log_debug "HOOK SCRIPT STARTED - PID $$" + +# --------------------------------------------------------------------------- +# Resolve script directory (handles symlinks — essential for hub support) +# --------------------------------------------------------------------------- +get_script_dir() { + local source="${BASH_SOURCE[0]}" + while [ -h "$source" ]; do + local dir="$(cd -P "$(dirname "$source")" && pwd)" + source="$(readlink "$source")" + [[ $source != /* ]] && source="$dir/$source" + done + cd -P "$(dirname "$source")" && pwd +} + +SCRIPT_DIR="$(get_script_dir)" +log_debug "SCRIPT_DIR=$SCRIPT_DIR" +log_debug "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" + +cd "$SCRIPT_DIR" + +# --------------------------------------------------------------------------- +# Capture stdin +# --------------------------------------------------------------------------- +INPUT=$(cat) +log_debug "Input received: ${INPUT:0:200}..." + +# --------------------------------------------------------------------------- +# Run matching engine with clean stdout/stderr separation +# --------------------------------------------------------------------------- +STDOUT_FILE=$(mktemp) +STDERR_FILE=$(mktemp) +trap 'rm -f "$STDOUT_FILE" "$STDERR_FILE"' EXIT + +printf '%s' "$INPUT" | NODE_NO_WARNINGS=1 node skill-activation-prompt.mjs \ + >"$STDOUT_FILE" 2>"$STDERR_FILE" +EXIT_CODE=$? + +log_debug "Exit code: $EXIT_CODE" +log_debug "Stderr: $(cat "$STDERR_FILE" 2>/dev/null | head -5)" + +# --------------------------------------------------------------------------- +# Terminal status output (stderr — visible in verbose mode via Ctrl+O) +# --------------------------------------------------------------------------- +if [ $EXIT_CODE -ne 0 ]; then + echo "⚡ [Skills] Hook error (exit $EXIT_CODE)" >&2 + log_debug "ERROR: Hook failed with exit code $EXIT_CODE" +else + SKILL_LINE=$(grep -o '\[Skills\] Activated: [^"]*' "$STDERR_FILE" | head -1) + if [ -n "$SKILL_LINE" ]; then + echo "⚡ $SKILL_LINE" >&2 + else + echo "⚡ [Skills] No skills matched" >&2 + fi +fi + +# --------------------------------------------------------------------------- +# Emit pristine stdout (injected into Claude's context) +# --------------------------------------------------------------------------- +cat "$STDOUT_FILE" + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..0ef7711 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-activation-prompt.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use-tracker.sh" + } + ] + } + ] + } +} diff --git a/.claude/skills/skill-rules.json b/.claude/skills/skill-rules.json new file mode 100644 index 0000000..e03ce13 --- /dev/null +++ b/.claude/skills/skill-rules.json @@ -0,0 +1,255 @@ +{ + "version": "2.0", + "skills": { + "agent-customization": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/commands/customize.js", + "src/prompts/customize-agents.md" + ], + "promptTriggers": { + "keywords": [ + "agent", + "customization", + "agent customization", + "llm-powered", + "injection", + "project", + "context", + "installed", + "commands", + "customize", + "prompts", + "customize-agents" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*agent customization", + "agent customization.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*agent.*customization" + ] + } + }, + "base": { + "type": "base", + "enforcement": "suggest", + "priority": "critical", + "scope": "all", + "alwaysActivate": true, + "filePatterns": [], + "promptTriggers": { + "keywords": [ + "core", + "conventions,", + "tech", + "stack,", + "project" + ], + "intentPatterns": [] + } + }, + "claude-runner": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/lib/runner.js", + "src/lib/skill-writer.js", + "src/prompts/**/*.md", + "tests/*extract*" + ], + "promptTriggers": { + "keywords": [ + "claude", + "runner", + "claude runner", + "execution", + "layer", + "prompt", + "loading,", + "skill-writer", + "prompts", + "tests", + "extract" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*claude runner", + "claude runner.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*claude.*runner" + ] + } + }, + "doc-sync": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/commands/doc-sync.js", + "src/prompts/doc-sync.md" + ], + "promptTriggers": { + "keywords": [ + "doc", + "sync", + "doc sync", + "incremental", + "skill", + "updater", + "maps", + "diffs", + "commands", + "doc-sync", + "prompts" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*doc sync", + "doc sync.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*doc.*sync" + ] + } + }, + "import-graph": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/lib/graph-builder.js", + "tests/graph-builder.test.js" + ], + "promptTriggers": { + "keywords": [ + "import", + "graph", + "import graph", + "static", + "analysis", + "builds", + "dependency", + "graph-builder", + "tests", + "graph-builder.test" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*import graph", + "import graph.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*import.*graph" + ] + } + }, + "repo-scanning": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/lib/scanner.js", + "src/commands/scan.js", + "tests/scanner.test.js" + ], + "promptTriggers": { + "keywords": [ + "repo", + "scanning", + "repo scanning", + "deterministic", + "analysis", + "language/framework", + "detection,", + "scanner", + "commands", + "scan", + "tests", + "scanner.test" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*repo scanning", + "repo scanning.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*repo.*scanning" + ] + } + }, + "skill-generation": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/commands/doc-init.js", + "src/commands/doc-sync.js", + "src/commands/customize.js", + "src/lib/context-builder.js", + "src/lib/runner.js", + "src/lib/skill-writer.js", + "src/prompts/**/*" + ], + "promptTriggers": { + "keywords": [ + "skill", + "generation", + "skill generation", + "llm-powered", + "pipeline", + "claude", + "code", + "commands", + "doc-init", + "doc-sync", + "customize", + "context-builder", + "runner", + "skill-writer", + "prompts" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*skill generation", + "skill generation.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*skill.*generation" + ] + } + }, + "template-library": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "scope": "all", + "alwaysActivate": false, + "filePatterns": [ + "src/commands/add.js", + "src/commands/customize.js", + "src/templates/**/*" + ], + "promptTriggers": { + "keywords": [ + "template", + "library", + "template library", + "bundled", + "agents,", + "commands,", + "hooks", + "users", + "commands", + "add", + "customize", + "templates" + ], + "intentPatterns": [ + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*template library", + "template library.*(create|update|fix|add|modify|change|debug|refactor|implement|build)", + "(create|update|fix|add|modify|change|debug|refactor|implement|build).*template.*library" + ] + } + } + } +} diff --git a/src/lib/skill-writer.js b/src/lib/skill-writer.js index f30429b..62477fa 100644 --- a/src/lib/skill-writer.js +++ b/src/lib/skill-writer.js @@ -195,8 +195,13 @@ export function mergeSettings(existing, template) { const aspensCommands = templateCommands.filter(cmd => isAspensHook(cmd)); if (aspensCommands.length === 0) { - // Not an aspens hook, just append - merged.hooks[eventType].push(templateEntry); + // Not an aspens hook — check for duplicates before appending + const isDuplicate = merged.hooks[eventType].some(e => + JSON.stringify(e) === JSON.stringify(templateEntry) + ); + if (!isDuplicate) { + merged.hooks[eventType].push(templateEntry); + } continue; } From c3ce9cdff3d572aab2991362580eec9cc477735e Mon Sep 17 00:00:00 2001 From: mvoutov Date: Sat, 21 Mar 2026 21:52:47 -0700 Subject: [PATCH 7/9] fixes to hook activation and clean up --- .claude/hooks/post-tool-use-tracker.sh | 3 +- .claude/hooks/skill-activation-prompt.mjs | 31 ++++++------ .claude/hooks/skill-activation-prompt.sh | 2 +- .claude/settings.json.bak | 25 ++++++++++ .claude/skills/skill-rules.json | 13 +++-- .../affected-repos.txt | 1 + src/commands/doc-init.js | 18 +++++-- src/lib/runner.js | 17 ++++--- src/lib/skill-writer.js | 47 +++++++++++++++---- src/prompts/partials/skill-format.md | 2 +- src/templates/hooks/post-tool-use-tracker.sh | 6 ++- .../hooks/skill-activation-prompt.mjs | 31 ++++++------ .../hooks/skill-activation-prompt.sh | 2 +- 13 files changed, 134 insertions(+), 64 deletions(-) create mode 100644 .claude/settings.json.bak create mode 100644 .claude/tsc-cache/a73e8196-0e5a-4a0f-ad77-f145a75bc3f0/affected-repos.txt diff --git a/.claude/hooks/post-tool-use-tracker.sh b/.claude/hooks/post-tool-use-tracker.sh index 02826c0..ccb518b 100755 --- a/.claude/hooks/post-tool-use-tracker.sh +++ b/.claude/hooks/post-tool-use-tracker.sh @@ -50,7 +50,8 @@ detect_repo() { local relative_path="${file#$project_root/}" # Extract first directory component - local repo=$(echo "$relative_path" | cut -d'/' -f1) + local repo + repo=$(echo "$relative_path" | cut -d'/' -f1) # Common project directory patterns case "$repo" in diff --git a/.claude/hooks/skill-activation-prompt.mjs b/.claude/hooks/skill-activation-prompt.mjs index 9c6582d..7bc23b6 100644 --- a/.claude/hooks/skill-activation-prompt.mjs +++ b/.claude/hooks/skill-activation-prompt.mjs @@ -14,7 +14,7 @@ */ import { readFileSync, existsSync, writeFileSync } from 'fs'; -import { join, basename } from 'path'; +import { join, basename, resolve } from 'path'; import { createHash } from 'crypto'; import { fileURLToPath } from 'url'; import { tmpdir } from 'os'; @@ -33,15 +33,23 @@ import { tmpdir } from 'os'; * @returns {string|null} Skill markdown content, or null if not found */ export function readSkillContent(projectDir, skillName) { + // Guard against path traversal + if (!skillName || skillName.includes('..') || skillName.startsWith('/') || skillName.includes('\\')) { + return null; + } + + const skillsRoot = resolve(projectDir, '.claude', 'skills'); const possiblePaths = [ - join(projectDir, '.claude', 'skills', skillName, 'skill.md'), - join(projectDir, '.claude', 'skills', `${skillName}.md`), + resolve(skillsRoot, skillName, 'skill.md'), + resolve(skillsRoot, `${skillName}.md`), ]; - for (const skillPath of possiblePaths) { - if (existsSync(skillPath)) { + for (const candidate of possiblePaths) { + // Verify resolved path stays within skills directory (use sep to prevent prefix attacks) + if (!candidate.startsWith(skillsRoot + '/') && candidate !== skillsRoot) continue; + if (existsSync(candidate)) { try { - return readFileSync(skillPath, 'utf-8'); + return readFileSync(candidate, 'utf-8'); } catch { // Continue to next path } @@ -216,16 +224,7 @@ export function formatOutput(matched, currentRepo, projectDir) { return ''; } - // Load skill content for each matched skill - for (const skill of matched) { - if (!skill.content) { - const content = readSkillContent(projectDir, skill.name); - if (content) { - skill.content = content; - } - } - } - + // Content should already be set by main(); this is a no-op guard const skillsWithContent = matched.filter(s => s.content); const skillsWithoutContent = matched.filter(s => !s.content); diff --git a/.claude/hooks/skill-activation-prompt.sh b/.claude/hooks/skill-activation-prompt.sh index aa92a49..266fe60 100755 --- a/.claude/hooks/skill-activation-prompt.sh +++ b/.claude/hooks/skill-activation-prompt.sh @@ -34,7 +34,7 @@ SCRIPT_DIR="$(get_script_dir)" log_debug "SCRIPT_DIR=$SCRIPT_DIR" log_debug "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" -cd "$SCRIPT_DIR" +cd "$SCRIPT_DIR" || { echo "⚡ [Skills] Failed to cd to $SCRIPT_DIR" >&2; exit 0; } # --------------------------------------------------------------------------- # Capture stdin diff --git a/.claude/settings.json.bak b/.claude/settings.json.bak new file mode 100644 index 0000000..0ef7711 --- /dev/null +++ b/.claude/settings.json.bak @@ -0,0 +1,25 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-activation-prompt.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-tool-use-tracker.sh" + } + ] + } + ] + } +} diff --git a/.claude/skills/skill-rules.json b/.claude/skills/skill-rules.json index e03ce13..c328f85 100644 --- a/.claude/skills/skill-rules.json +++ b/.claude/skills/skill-rules.json @@ -43,9 +43,9 @@ "promptTriggers": { "keywords": [ "core", - "conventions,", + "conventions", "tech", - "stack,", + "stack", "project" ], "intentPatterns": [] @@ -71,7 +71,7 @@ "execution", "layer", "prompt", - "loading,", + "loading", "skill-writer", "prompts", "tests", @@ -164,7 +164,7 @@ "deterministic", "analysis", "language/framework", - "detection,", + "detection", "scanner", "commands", "scan", @@ -235,11 +235,10 @@ "library", "template library", "bundled", - "agents,", - "commands,", + "agents", + "commands", "hooks", "users", - "commands", "add", "customize", "templates" diff --git a/.claude/tsc-cache/a73e8196-0e5a-4a0f-ad77-f145a75bc3f0/affected-repos.txt b/.claude/tsc-cache/a73e8196-0e5a-4a0f-ad77-f145a75bc3f0/affected-repos.txt new file mode 100644 index 0000000..85de9cf --- /dev/null +++ b/.claude/tsc-cache/a73e8196-0e5a-4a0f-ad77-f145a75bc3f0/affected-repos.txt @@ -0,0 +1 @@ +src diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index b4ac16a..edd897b 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -407,7 +407,7 @@ export async function docInitCommand(path, options) { } // Step 9: Generate skill-rules.json + install hooks (unless --no-hooks) - if (!options.noHooks) { + if (options.hooks !== false) { await installHooks(repoPath, options); } @@ -489,6 +489,8 @@ async function installHooks(repoPath, options) { if (!options.dryRun) { writeFileSync(rulesPath, JSON.stringify(rules, null, 2) + '\n'); + } else { + p.log.info(pc.dim(`[dry-run] Would write ${rulesPath} (${skillCount} skills)`)); } // 9b: Copy hook files @@ -522,10 +524,16 @@ async function installHooks(repoPath, options) { // Inject generated domain patterns into detect_skill_domain() const domainPatterns = generateDomainPatterns(rules); - // Replace the stub function with the generated one - const stubRegex = /detect_skill_domain\(\)\s*\{[\s\S]*?\n\}/; - if (stubRegex.test(trackerContent)) { - trackerContent = trackerContent.replace(stubRegex, domainPatterns.trim()); + // Replace using BEGIN/END markers (preferred), fall back to regex + const markerRegex = /# BEGIN detect_skill_domain[\s\S]*?# END detect_skill_domain/; + if (markerRegex.test(trackerContent)) { + trackerContent = trackerContent.replace(markerRegex, domainPatterns.trim()); + } else { + // Fallback for templates without markers + const stubRegex = /detect_skill_domain\(\)\s*\{[\s\S]*?\n\}/; + if (stubRegex.test(trackerContent)) { + trackerContent = trackerContent.replace(stubRegex, domainPatterns.trim()); + } } if (!options.dryRun) { diff --git a/src/lib/runner.js b/src/lib/runner.js index 60380cb..53f6a1e 100644 --- a/src/lib/runner.js +++ b/src/lib/runner.js @@ -1,6 +1,6 @@ import { execSync, spawn } from 'child_process'; import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { join, dirname, normalize } from 'path'; +import { join, dirname, normalize, resolve, relative, sep } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -166,10 +166,11 @@ export function parseFileOutput(output) { // Pre-compute which character positions are inside fenced code blocks const fenceRanges = []; - const fenceRegex = /^```[^\n]*\n[\s\S]*?\n```/gm; + const fenceRegex = /(^|\n)```[^\n]*\n([\s\S]*?)(?:\n```|$)/g; let fm; while ((fm = fenceRegex.exec(output)) !== null) { - fenceRanges.push([fm.index, fm.index + fm[0].length]); + const start = fm.index + fm[1].length; // skip leading newline if present + fenceRanges.push([start, fm.index + fm[0].length]); } function isInsideFence(pos) { for (const [start, end] of fenceRanges) { @@ -180,10 +181,11 @@ export function parseFileOutput(output) { // Find all valid positions (at line start, outside code fences) const closePositions = []; - const closeRegex = /\n<\/file>/g; + const closeRegex = /(^|\n)<\/file>/g; let cm; while ((cm = closeRegex.exec(output)) !== null) { - if (!isInsideFence(cm.index)) { + const tagStart = cm.index + cm[1].length; + if (!isInsideFence(tagStart)) { closePositions.push(cm.index); } } @@ -270,8 +272,9 @@ export function validateSkillFiles(files, repoPath) { for (const refPath of referencedPaths) { // Skip glob patterns and path traversal if (refPath.includes('*') || refPath.includes('?') || refPath.includes('..')) continue; - const resolved = join(repoPath, refPath); - if (!resolved.startsWith(repoPath)) continue; + const resolved = resolve(repoPath, refPath); + const rel = relative(repoPath, resolved); + if (rel.startsWith('..') || rel.startsWith(sep)) continue; if (!existsSync(resolved)) { issues.push({ file: filePath, issue: 'bad-path', detail: `Referenced path \`${refPath}\` does not exist` }); } diff --git a/src/lib/skill-writer.js b/src/lib/skill-writer.js index 62477fa..153c48b 100644 --- a/src/lib/skill-writer.js +++ b/src/lib/skill-writer.js @@ -117,8 +117,17 @@ export function generateDomainPatterns(rules) { if (bashPatterns.length === 0) continue; - // Deduplicate patterns per skill - const uniquePatterns = [...new Set(bashPatterns)]; + // Deduplicate and validate patterns per skill + const SAFE_BASH_PATTERN = /^[A-Za-z0-9/_.\-]+$/; + const uniquePatterns = [...new Set(bashPatterns)].filter(p => { + if (!SAFE_BASH_PATTERN.test(p)) { + console.warn(`[skill-writer] Skipping unsafe bash pattern "${p}" for skill "${skillName}"`); + return false; + } + return true; + }); + + if (uniquePatterns.length === 0) continue; const conditions = uniquePatterns .map(p => `[[ "$file" =~ ${p} ]]`) @@ -142,7 +151,8 @@ export function generateDomainPatterns(rules) { }); body += ' fi'; - return `detect_skill_domain() { + return `# BEGIN detect_skill_domain +detect_skill_domain() { local file="$1" local detected_skills="" @@ -150,7 +160,8 @@ export function generateDomainPatterns(rules) { ${body} echo "$detected_skills" -}`; +} +# END detect_skill_domain`; } /** @@ -197,7 +208,7 @@ export function mergeSettings(existing, template) { if (aspensCommands.length === 0) { // Not an aspens hook — check for duplicates before appending const isDuplicate = merged.hooks[eventType].some(e => - JSON.stringify(e) === JSON.stringify(templateEntry) + stableStringify(e) === stableStringify(templateEntry) ); if (!isDuplicate) { merged.hooks[eventType].push(templateEntry); @@ -404,13 +415,22 @@ function extractDistinctiveParts(pattern) { } function generateEmptyDetectFunction() { - return `detect_skill_domain() { + return `# BEGIN detect_skill_domain +detect_skill_domain() { local file="$1" local detected_skills="" # No domain patterns generated -- add skills with filePatterns first echo "$detected_skills" -}`; +} +# END detect_skill_domain`; +} + +function stableStringify(obj) { + if (obj === null || typeof obj !== 'object') return JSON.stringify(obj); + if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']'; + const keys = Object.keys(obj).sort(); + return '{' + keys.map(k => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') + '}'; } function escapeRegex(str) { @@ -418,7 +438,18 @@ function escapeRegex(str) { } function dedupeStrings(arr) { - return [...new Set(arr.map(s => s.toLowerCase()))]; + const seen = new Set(); + const result = []; + for (const s of arr) { + const cleaned = s.trim().replace(/[,.:;!?]+$/, ''); + if (!cleaned) continue; + const key = cleaned.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + result.push(cleaned); + } + } + return result; } // Path segments too generic for keyword derivation or bash patterns diff --git a/src/prompts/partials/skill-format.md b/src/prompts/partials/skill-format.md index f9bc4fc..6b6729f 100644 --- a/src/prompts/partials/skill-format.md +++ b/src/prompts/partials/skill-format.md @@ -98,7 +98,7 @@ This skill triggers when editing these files: Keywords: keyword1, keyword2, keyword3 ``` -- File patterns MUST be on their own line, prefixed with `- `, wrapped in backticks. +- File patterns MUST be on their own line, prefixed with a dash and space (`-`), wrapped in backticks. - The `Keywords:` line MUST be in the Activation section, comma-separated. These are case-insensitive terms that trigger this skill when they appear in a user prompt. - For the base skill, use `This is a **base skill** that always loads when working in this repository.` (no file patterns or keywords needed). diff --git a/src/templates/hooks/post-tool-use-tracker.sh b/src/templates/hooks/post-tool-use-tracker.sh index d04bcd3..7823653 100755 --- a/src/templates/hooks/post-tool-use-tracker.sh +++ b/src/templates/hooks/post-tool-use-tracker.sh @@ -50,7 +50,8 @@ detect_repo() { local relative_path="${file#$project_root/}" # Extract first directory component - local repo=$(echo "$relative_path" | cut -d'/' -f1) + local repo + repo=$(echo "$relative_path" | cut -d'/' -f1) # Common project directory patterns case "$repo" in @@ -206,6 +207,8 @@ fi # Detect which domain skill should be activated based on file path # and persist it in session state for sticky behavior +# BEGIN detect_skill_domain +# STUB: replaced during installation by generateDomainPatterns() detect_skill_domain() { local file="$1" local detected_skills="" @@ -231,6 +234,7 @@ detect_skill_domain() { echo "$detected_skills" } +# END detect_skill_domain # Create session file path based on project directory hash get_session_file() { diff --git a/src/templates/hooks/skill-activation-prompt.mjs b/src/templates/hooks/skill-activation-prompt.mjs index 9c6582d..7bc23b6 100644 --- a/src/templates/hooks/skill-activation-prompt.mjs +++ b/src/templates/hooks/skill-activation-prompt.mjs @@ -14,7 +14,7 @@ */ import { readFileSync, existsSync, writeFileSync } from 'fs'; -import { join, basename } from 'path'; +import { join, basename, resolve } from 'path'; import { createHash } from 'crypto'; import { fileURLToPath } from 'url'; import { tmpdir } from 'os'; @@ -33,15 +33,23 @@ import { tmpdir } from 'os'; * @returns {string|null} Skill markdown content, or null if not found */ export function readSkillContent(projectDir, skillName) { + // Guard against path traversal + if (!skillName || skillName.includes('..') || skillName.startsWith('/') || skillName.includes('\\')) { + return null; + } + + const skillsRoot = resolve(projectDir, '.claude', 'skills'); const possiblePaths = [ - join(projectDir, '.claude', 'skills', skillName, 'skill.md'), - join(projectDir, '.claude', 'skills', `${skillName}.md`), + resolve(skillsRoot, skillName, 'skill.md'), + resolve(skillsRoot, `${skillName}.md`), ]; - for (const skillPath of possiblePaths) { - if (existsSync(skillPath)) { + for (const candidate of possiblePaths) { + // Verify resolved path stays within skills directory (use sep to prevent prefix attacks) + if (!candidate.startsWith(skillsRoot + '/') && candidate !== skillsRoot) continue; + if (existsSync(candidate)) { try { - return readFileSync(skillPath, 'utf-8'); + return readFileSync(candidate, 'utf-8'); } catch { // Continue to next path } @@ -216,16 +224,7 @@ export function formatOutput(matched, currentRepo, projectDir) { return ''; } - // Load skill content for each matched skill - for (const skill of matched) { - if (!skill.content) { - const content = readSkillContent(projectDir, skill.name); - if (content) { - skill.content = content; - } - } - } - + // Content should already be set by main(); this is a no-op guard const skillsWithContent = matched.filter(s => s.content); const skillsWithoutContent = matched.filter(s => !s.content); diff --git a/src/templates/hooks/skill-activation-prompt.sh b/src/templates/hooks/skill-activation-prompt.sh index aa92a49..266fe60 100755 --- a/src/templates/hooks/skill-activation-prompt.sh +++ b/src/templates/hooks/skill-activation-prompt.sh @@ -34,7 +34,7 @@ SCRIPT_DIR="$(get_script_dir)" log_debug "SCRIPT_DIR=$SCRIPT_DIR" log_debug "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" -cd "$SCRIPT_DIR" +cd "$SCRIPT_DIR" || { echo "⚡ [Skills] Failed to cd to $SCRIPT_DIR" >&2; exit 0; } # --------------------------------------------------------------------------- # Capture stdin From ce443b6b73695fa00453b39a982d6a19ca2760c8 Mon Sep 17 00:00:00 2001 From: mvoutov Date: Sun, 22 Mar 2026 09:37:13 -0700 Subject: [PATCH 8/9] fix: defensive guards for hooks and validation --- .claude/hooks/post-tool-use-tracker.sh | 2 +- .claude/hooks/skill-activation-prompt.mjs | 15 ++++++++++----- .../affected-repos.txt | 1 + src/commands/doc-init.js | 4 +++- src/commands/doc-sync.js | 5 +++-- src/lib/runner.js | 10 ++++++++++ src/templates/hooks/post-tool-use-tracker.sh | 2 +- src/templates/hooks/skill-activation-prompt.mjs | 15 ++++++++++----- 8 files changed, 39 insertions(+), 15 deletions(-) create mode 100644 .claude/tsc-cache/dcec4868-8c5d-4534-a3d7-e41784332d3c/affected-repos.txt diff --git a/.claude/hooks/post-tool-use-tracker.sh b/.claude/hooks/post-tool-use-tracker.sh index ccb518b..db4c169 100755 --- a/.claude/hooks/post-tool-use-tracker.sh +++ b/.claude/hooks/post-tool-use-tracker.sh @@ -235,7 +235,7 @@ detect_skill_domain() { get_session_file() { local project_dir="$1" local hash=$(echo -n "$project_dir" | md5 2>/dev/null || echo -n "$project_dir" | md5sum | cut -d' ' -f1) - echo "/tmp/claude-skills-${hash}.json" + echo "${TMPDIR:-/tmp}/claude-skills-${hash}.json" } # Add skill to session state diff --git a/.claude/hooks/skill-activation-prompt.mjs b/.claude/hooks/skill-activation-prompt.mjs index 7bc23b6..55e6e53 100644 --- a/.claude/hooks/skill-activation-prompt.mjs +++ b/.claude/hooks/skill-activation-prompt.mjs @@ -14,7 +14,7 @@ */ import { readFileSync, existsSync, writeFileSync } from 'fs'; -import { join, basename, resolve } from 'path'; +import { join, basename, resolve, relative } from 'path'; import { createHash } from 'crypto'; import { fileURLToPath } from 'url'; import { tmpdir } from 'os'; @@ -45,8 +45,9 @@ export function readSkillContent(projectDir, skillName) { ]; for (const candidate of possiblePaths) { - // Verify resolved path stays within skills directory (use sep to prevent prefix attacks) - if (!candidate.startsWith(skillsRoot + '/') && candidate !== skillsRoot) continue; + // Verify resolved path stays within skills directory + const rel = relative(skillsRoot, candidate); + if (rel.startsWith('..') || resolve(rel) === rel) continue; if (existsSync(candidate)) { try { return readFileSync(candidate, 'utf-8'); @@ -91,7 +92,7 @@ export function detectCurrentRepo(projectDir) { * @param {string} projectDir - Absolute path to the project root * @returns {string[]} Array of active skill names */ -export function getSessionActiveSkills(projectDir) { +export function getSessionActiveSkills(projectDir, currentRepo) { try { const hash = createHash('md5').update(projectDir).digest('hex'); const sessionFile = join(tmpdir(), `claude-skills-${hash}.json`); @@ -99,6 +100,10 @@ export function getSessionActiveSkills(projectDir) { if (existsSync(sessionFile)) { const content = readFileSync(sessionFile, 'utf-8'); const session = JSON.parse(content); + // Only return skills if session repo matches current repo + if (currentRepo && session.repo && session.repo !== currentRepo) { + return []; + } return session.active_skills || []; } } catch { @@ -319,7 +324,7 @@ async function main() { const currentRepo = detectCurrentRepo(projectDir); // Get session-sticky skills - const sessionSkills = getSessionActiveSkills(projectDir); + const sessionSkills = getSessionActiveSkills(projectDir, currentRepo); // Match skills against the prompt const matched = matchSkills(prompt, rules, currentRepo, sessionSkills); diff --git a/.claude/tsc-cache/dcec4868-8c5d-4534-a3d7-e41784332d3c/affected-repos.txt b/.claude/tsc-cache/dcec4868-8c5d-4534-a3d7-e41784332d3c/affected-repos.txt new file mode 100644 index 0000000..85de9cf --- /dev/null +++ b/.claude/tsc-cache/dcec4868-8c5d-4534-a3d7-e41784332d3c/affected-repos.txt @@ -0,0 +1 @@ +src diff --git a/src/commands/doc-init.js b/src/commands/doc-init.js index edd897b..e97891d 100644 --- a/src/commands/doc-init.js +++ b/src/commands/doc-init.js @@ -494,7 +494,9 @@ async function installHooks(repoPath, options) { } // 9b: Copy hook files - mkdirSync(hooksDir, { recursive: true }); + if (!options.dryRun) { + mkdirSync(hooksDir, { recursive: true }); + } const hookFiles = [ { src: 'hooks/skill-activation-prompt.sh', dest: 'skill-activation-prompt.sh', chmod: true }, diff --git a/src/commands/doc-sync.js b/src/commands/doc-sync.js index 654e0ee..c06bf4c 100644 --- a/src/commands/doc-sync.js +++ b/src/commands/doc-sync.js @@ -322,8 +322,9 @@ function mapChangesToSkills(changedFiles, existingSkills, scan) { // --- Git hook --- function resolveAspensPath() { + const cmd = process.platform === 'win32' ? 'where aspens' : 'which aspens'; try { - const resolved = execSync('which aspens', { + const resolved = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], }).trim(); @@ -349,7 +350,7 @@ export function installGitHook(repoPath) { # >>> aspens doc-sync hook (do not edit) >>> __aspens_doc_sync() { REPO_ROOT="\$(git rev-parse --show-toplevel 2>/dev/null)" || return 0 - REPO_HASH="\$(echo "\$REPO_ROOT" | shasum | cut -c1-8)" + REPO_HASH="\$(echo "\$REPO_ROOT" | (shasum 2>/dev/null || sha1sum 2>/dev/null || md5sum 2>/dev/null) | cut -c1-8)" ASPENS_LOCK="/tmp/aspens-sync-\${REPO_HASH}.lock" ASPENS_LOG="/tmp/aspens-sync-\${REPO_HASH}.log" diff --git a/src/lib/runner.js b/src/lib/runner.js index 53f6a1e..45a9c22 100644 --- a/src/lib/runner.js +++ b/src/lib/runner.js @@ -261,6 +261,16 @@ export function validateSkillFiles(files, repoPath) { if (contentAfterFrontmatter.length < 50) { issues.push({ file: filePath, issue: 'too-short', detail: 'Skill content is too short (< 50 chars after frontmatter)' }); } + + // Validate required sections for domain skills (not base) + const isBase = contentAfterFrontmatter.includes('**base skill**'); + if (!isBase) { + const requiredSections = ['Activation', 'Key Files', 'Key Concepts', 'Critical Rules']; + const missing = requiredSections.filter(s => !new RegExp(`^#+\\s*${s}\\b`, 'm').test(contentAfterFrontmatter)); + if (missing.length > 0) { + issues.push({ file: filePath, issue: 'missing-sections', detail: `Missing sections: ${missing.join(', ')}` }); + } + } } // Validate referenced file paths exist (check paths in backticks) diff --git a/src/templates/hooks/post-tool-use-tracker.sh b/src/templates/hooks/post-tool-use-tracker.sh index 7823653..c4c9242 100755 --- a/src/templates/hooks/post-tool-use-tracker.sh +++ b/src/templates/hooks/post-tool-use-tracker.sh @@ -240,7 +240,7 @@ detect_skill_domain() { get_session_file() { local project_dir="$1" local hash=$(echo -n "$project_dir" | md5 2>/dev/null || echo -n "$project_dir" | md5sum | cut -d' ' -f1) - echo "/tmp/claude-skills-${hash}.json" + echo "${TMPDIR:-/tmp}/claude-skills-${hash}.json" } # Add skill to session state diff --git a/src/templates/hooks/skill-activation-prompt.mjs b/src/templates/hooks/skill-activation-prompt.mjs index 7bc23b6..55e6e53 100644 --- a/src/templates/hooks/skill-activation-prompt.mjs +++ b/src/templates/hooks/skill-activation-prompt.mjs @@ -14,7 +14,7 @@ */ import { readFileSync, existsSync, writeFileSync } from 'fs'; -import { join, basename, resolve } from 'path'; +import { join, basename, resolve, relative } from 'path'; import { createHash } from 'crypto'; import { fileURLToPath } from 'url'; import { tmpdir } from 'os'; @@ -45,8 +45,9 @@ export function readSkillContent(projectDir, skillName) { ]; for (const candidate of possiblePaths) { - // Verify resolved path stays within skills directory (use sep to prevent prefix attacks) - if (!candidate.startsWith(skillsRoot + '/') && candidate !== skillsRoot) continue; + // Verify resolved path stays within skills directory + const rel = relative(skillsRoot, candidate); + if (rel.startsWith('..') || resolve(rel) === rel) continue; if (existsSync(candidate)) { try { return readFileSync(candidate, 'utf-8'); @@ -91,7 +92,7 @@ export function detectCurrentRepo(projectDir) { * @param {string} projectDir - Absolute path to the project root * @returns {string[]} Array of active skill names */ -export function getSessionActiveSkills(projectDir) { +export function getSessionActiveSkills(projectDir, currentRepo) { try { const hash = createHash('md5').update(projectDir).digest('hex'); const sessionFile = join(tmpdir(), `claude-skills-${hash}.json`); @@ -99,6 +100,10 @@ export function getSessionActiveSkills(projectDir) { if (existsSync(sessionFile)) { const content = readFileSync(sessionFile, 'utf-8'); const session = JSON.parse(content); + // Only return skills if session repo matches current repo + if (currentRepo && session.repo && session.repo !== currentRepo) { + return []; + } return session.active_skills || []; } } catch { @@ -319,7 +324,7 @@ async function main() { const currentRepo = detectCurrentRepo(projectDir); // Get session-sticky skills - const sessionSkills = getSessionActiveSkills(projectDir); + const sessionSkills = getSessionActiveSkills(projectDir, currentRepo); // Match skills against the prompt const matched = matchSkills(prompt, rules, currentRepo, sessionSkills); From 0af78150d47edcb58d5288e115d2e58285de6f90 Mon Sep 17 00:00:00 2001 From: mvoutov Date: Sun, 22 Mar 2026 10:02:17 -0700 Subject: [PATCH 9/9] release notes --- .../affected-repos.txt | 1 - .../affected-repos.txt | 1 - .gitignore | 1 + CHANGELOG.md | 32 +++++++++++++++ README.md | 4 +- bin/cli.js | 41 ++++++++++++++++--- package.json | 5 ++- src/lib/runner.js | 1 + 8 files changed, 75 insertions(+), 11 deletions(-) delete mode 100644 .claude/tsc-cache/a73e8196-0e5a-4a0f-ad77-f145a75bc3f0/affected-repos.txt delete mode 100644 .claude/tsc-cache/dcec4868-8c5d-4534-a3d7-e41784332d3c/affected-repos.txt diff --git a/.claude/tsc-cache/a73e8196-0e5a-4a0f-ad77-f145a75bc3f0/affected-repos.txt b/.claude/tsc-cache/a73e8196-0e5a-4a0f-ad77-f145a75bc3f0/affected-repos.txt deleted file mode 100644 index 85de9cf..0000000 --- a/.claude/tsc-cache/a73e8196-0e5a-4a0f-ad77-f145a75bc3f0/affected-repos.txt +++ /dev/null @@ -1 +0,0 @@ -src diff --git a/.claude/tsc-cache/dcec4868-8c5d-4534-a3d7-e41784332d3c/affected-repos.txt b/.claude/tsc-cache/dcec4868-8c5d-4534-a3d7-e41784332d3c/affected-repos.txt deleted file mode 100644 index 85de9cf..0000000 --- a/.claude/tsc-cache/dcec4868-8c5d-4534-a3d7-e41784332d3c/affected-repos.txt +++ /dev/null @@ -1 +0,0 @@ -src diff --git a/.gitignore b/.gitignore index fe946b9..9096439 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ .env.* !.env.example .claude/settings.local.json +.claude/tsc-cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bd98ef6..d3b1bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ ## [Unreleased] +## [0.2.2] - 2026-03-22 + +### Upgrade notice +If upgrading from 0.2.1 or earlier, run `aspens doc init --hooks-only` in each repo that already has skills. + +### Added +- **Skill activation hooks** — `doc init` now auto-generates `skill-rules.json`, shell + Node.js hooks, and `settings.json` entries so skills activate automatically on every prompt +- **Session-sticky skills** — editing a file activates its domain skill for the rest of the session via `PostToolUse` tracking hook +- **`--hooks-only` flag** — run `aspens doc init --hooks-only` to install/update hooks without regenerating skills +- **`--no-hooks` flag** — skip hook installation during `doc init` +- **Missing hooks warning** — CLI warns when skills exist but activation hooks are missing, with fix command +- **Postinstall upgrade notice** — npm prints a message after install/update telling users to run `--hooks-only` if upgrading +- **Domain skill validation** — `validateSkillFiles` now checks for required sections (Activation, Key Files, Key Concepts, Critical Rules) + +### Fixed +- **Hook settings merge** — order-independent duplicate detection using stable key-sorted stringify +- **Bash pattern injection** — `generateDomainPatterns` validates patterns against a safe character whitelist before emitting bash conditions +- **Keyword normalization** — `dedupeStrings` trims whitespace and strips trailing punctuation while preserving original casing +- **Dry-run side effects** — `mkdirSync` for hooks directory now guarded by `!options.dryRun` +- **Dry-run feedback** — rules file write now logs a message during `--dry-run` instead of silently skipping +- **`--no-hooks` CLI mapping** — uses Commander's `options.hooks !== false` instead of broken `options.noHooks` +- **Path containment** — `readSkillContent` and `validateSkillFiles` use `path.relative()` instead of hardcoded `/` separator +- **Session file location** — shell hooks use `${TMPDIR:-/tmp}` to match Node's `os.tmpdir()` +- **Session repo check** — `getSessionActiveSkills` validates `session.repo` matches current repo before returning sticky skills +- **Marker-based replacement** — `detect_skill_domain` stub uses `BEGIN/END` markers instead of fragile regex +- **Fence detection** — `parseFileOutput` handles fenced code blocks at start-of-string and unclosed fences +- **`` at position 0** — closing tag regex matches start-of-string, not just after newline +- **Portable hash** — git hook falls back from `shasum` to `sha1sum` to `md5sum` +- **Windows path lookup** — `resolveAspensPath` uses `where` on win32, `which` elsewhere + +## [0.2.1] - 2026-03-21 + ### Added - **CLAUDE.md retry logic** — if Claude generates content without `` tags, aspens detects the failure and retries with a format reminder - **Base skill retry logic** — same retry mechanism for the base skill diff --git a/README.md b/README.md index 0fcf27f..4a6dedd 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npx aspens doc init . # Generate skills + CLAUDE.md npx aspens doc sync --install-hook # Auto-update on every commit ``` -Requires [Node.js 18+](https://nodejs.org) and [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code). +Requires [Node.js 20+](https://nodejs.org) and [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code). ## Commands @@ -292,7 +292,7 @@ Less context searching. More code shipping. ## Requirements -- **Node.js 18+** +- **Node.js 20+** - **Claude Code CLI** — `npm install -g @anthropic-ai/claude-code` ## License diff --git a/bin/cli.js b/bin/cli.js index 5673892..d8e958c 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -2,8 +2,8 @@ import { program } from 'commander'; import pc from 'picocolors'; -import { readFileSync, readdirSync } from 'fs'; -import { join, dirname } from 'path'; +import { readFileSync, readdirSync, existsSync } from 'fs'; +import { join, dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; import { scanCommand } from '../src/commands/scan.js'; import { docInitCommand } from '../src/commands/doc-init.js'; @@ -62,9 +62,31 @@ function showWelcome() { ${pc.dim('$')} aspens doc sync --install-hook ${pc.dim('5. Auto-update on every commit')} ${pc.dim('Run')} ${pc.cyan('aspens --help')} ${pc.dim('for detailed usage.')} + + ${pc.dim('🌲 aspens is in active development — please keep it up to date.')} + ${pc.dim(' Run into issues? Let us know:')} ${pc.cyan('https://github.com/aspenkit/aspens/issues')} `); } +/** + * Check if a target repo has skills but is missing hooks. + * Warns users who ran doc init before hooks were available (pre-0.2.2). + */ +function checkMissingHooks(repoPath) { + const skillsDir = join(repoPath, '.claude', 'skills'); + const hookFile = join(repoPath, '.claude', 'hooks', 'skill-activation-prompt.sh'); + const rulesFile = join(repoPath, '.claude', 'skills', 'skill-rules.json'); + + if (existsSync(skillsDir) && (!existsSync(hookFile) || !existsSync(rulesFile))) { + console.log( + pc.yellow('\n ⚠ Skills found but activation hooks are missing.') + + pc.dim('\n Skills won\'t auto-activate without hooks.') + + '\n Run: ' + pc.cyan('aspens doc init --hooks-only') + + pc.dim(' to install them.\n') + ); + } +} + program .name('aspens') .description('Generate and maintain AI-ready documentation for your codebase') @@ -117,7 +139,10 @@ doc .option('--timeout ', 'Claude timeout in seconds', '300') .option('--model ', 'Claude model to use (e.g., sonnet, opus, haiku)') .option('--verbose', 'Show what Claude is reading/doing in real time') - .action(docSyncCommand); + .action((path, options) => { + checkMissingHooks(resolve(path)); + return docSyncCommand(path, options); + }); // Add command program @@ -126,7 +151,10 @@ program .argument('', 'What to add: agent, hook, command') .argument('[name]', 'Name of the resource') .option('--list', 'List available resources') - .action(addCommand); + .action((type, name, options) => { + checkMissingHooks(resolve('.')); + return addCommand(type, name, options); + }); // Customize command program @@ -137,7 +165,10 @@ program .option('--timeout ', 'Claude timeout in seconds', '300') .option('--model ', 'Claude model to use (e.g., sonnet, opus, haiku)') .option('--verbose', 'Show what Claude is reading/doing in real time') - .action(customizeCommand); + .action((what, options) => { + checkMissingHooks(resolve('.')); + return customizeCommand(what, options); + }); program.parseAsync().catch((err) => { console.error(pc.red('Error:'), err.message); diff --git a/package.json b/package.json index 9f68252..f400519 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aspens", - "version": "0.2.1", + "version": "0.2.2", "description": "Generate and maintain AI-ready documentation for any codebase", "type": "module", "bin": { @@ -22,7 +22,8 @@ "scripts": { "test": "vitest run", "start": "node bin/cli.js", - "lint": "echo 'No linter configured yet' && exit 0" + "lint": "echo 'No linter configured yet' && exit 0", + "postinstall": "echo '\n 📌 aspens v0.2.2: Skill activation hooks are now required.\n If you have existing skills, run: aspens doc init --hooks-only\n\n 🌲 aspens is in active development — please keep it up to date.\n Run into issues? Let us know: https://github.com/aspenkit/aspens/issues\n'" }, "engines": { "node": ">=18" diff --git a/src/lib/runner.js b/src/lib/runner.js index 45a9c22..8f8d821 100644 --- a/src/lib/runner.js +++ b/src/lib/runner.js @@ -192,6 +192,7 @@ export function parseFileOutput(output) { let openMatch; while ((openMatch = openTagPattern.exec(output)) !== null) { + if (isInsideFence(openMatch.index)) continue; const filePath = sanitizePath(openMatch[1].trim()); if (!filePath) continue;