diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c50900aa..bbc808de 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,5 +1,4 @@ { - "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "ponytail", "description": "Lazy senior dev mode for AI agents. The best code is the code you never wrote.", "owner": { diff --git a/hooks/ponytail-config.js b/hooks/ponytail-config.js index 4233c2b2..20838a6f 100644 --- a/hooks/ponytail-config.js +++ b/hooks/ponytail-config.js @@ -113,6 +113,24 @@ function getHideStatus() { } } +const KNOWN_COMPRESSION_PLUGINS = ['caveman']; + +function detectCompressionPlugins() { + try { + const settingsPath = path.join(getClaudeDir(), 'settings.json'); + const raw = fs.readFileSync(settingsPath, 'utf8').replace(/^\uFEFF/, ''); + const settings = JSON.parse(raw); + const found = []; + const blob = JSON.stringify(settings).toLowerCase(); + for (const name of KNOWN_COMPRESSION_PLUGINS) { + if (blob.includes(name)) found.push(name); + } + return found; + } catch (e) { + return []; + } +} + function writeDefaultMode(mode) { const normalized = normalizeConfigMode(mode); if (!normalized) return null; @@ -133,6 +151,7 @@ module.exports = { DEFAULT_MODE, VALID_MODES, RUNTIME_MODES, + detectCompressionPlugins, getDefaultMode, getConfigDir, getConfigPath, diff --git a/hooks/ponytail-instructions.js b/hooks/ponytail-instructions.js index a516b757..2f08c4cb 100644 --- a/hooks/ponytail-instructions.js +++ b/hooks/ponytail-instructions.js @@ -3,7 +3,7 @@ const fs = require('fs'); const path = require('path'); -const { DEFAULT_MODE, normalizeMode, normalizePersistedMode } = require('./ponytail-config'); +const { DEFAULT_MODE, normalizeMode, normalizePersistedMode, detectCompressionPlugins } = require('./ponytail-config'); const INDEPENDENT_MODES = new Set(['review']); const SKILL_PATH = path.join(__dirname, '..', 'skills', 'ponytail', 'SKILL.md'); @@ -70,6 +70,16 @@ function getFallbackInstructions(mode) { 'Ponytail governs what you build, not how you talk. "stop ponytail" or "normal mode": revert. Level persists until changed or session end.'; } +function getCompressionDeconfliction() { + const peers = detectCompressionPlugins(); + if (peers.length === 0) return ''; + return '\n\n## Compression plugin coexistence\n\n' + + 'Detected: ' + peers.join(', ') + '. ' + + 'Ponytail governs WHAT to build (the ladder, YAGNI, stdlib-first, minimal diffs). ' + + 'Defer to the other plugin for output STYLE (brevity, tone, formatting). ' + + 'If rules conflict, the structural rule (ponytail) wins for code decisions; the style rule (peer plugin) wins for prose and formatting.'; +} + function getPonytailInstructions(mode) { const configuredMode = normalizePersistedMode(mode) || DEFAULT_MODE; @@ -81,9 +91,9 @@ function getPonytailInstructions(mode) { try { return 'PONYTAIL MODE ACTIVE — level: ' + effectiveMode + '\n\n' + - filterSkillBodyForMode(fs.readFileSync(SKILL_PATH, 'utf8'), effectiveMode); + filterSkillBodyForMode(fs.readFileSync(SKILL_PATH, 'utf8'), effectiveMode) + getCompressionDeconfliction(); } catch (e) { - return getFallbackInstructions(effectiveMode); + return getFallbackInstructions(effectiveMode) + getCompressionDeconfliction(); } } diff --git a/hooks/ponytail-mode-tracker.js b/hooks/ponytail-mode-tracker.js index 5202a746..258fe588 100644 --- a/hooks/ponytail-mode-tracker.js +++ b/hooks/ponytail-mode-tracker.js @@ -14,7 +14,13 @@ function finish() { try { // Strip UTF-8 BOM some shells prepend when piping (breaks JSON.parse) const data = JSON.parse(input.replace(/^\uFEFF/, '')); - const prompt = (data.prompt || '').trim().toLowerCase(); + // Claude Code dispatches slash commands via skills, wrapping the real command + // in / tags with the SKILL.md body as filler. + // Extract the actual command from the tags; fall back to raw prompt (#584). + const raw = data.prompt || ''; + const name = raw.match(/\s*([^<]+?)\s*<\/command-name>/)?.[1]; + const args = raw.match(/\s*([^<]*?)\s*<\/command-args>/)?.[1]; + const prompt = (name ? `${name} ${args ?? ''}` : raw).trim().toLowerCase(); // Match /ponytail commands if (/^[/@$]ponytail/.test(prompt)) { diff --git a/tests/hooks.test.js b/tests/hooks.test.js index 355c8d11..5345d56e 100644 --- a/tests/hooks.test.js +++ b/tests/hooks.test.js @@ -179,6 +179,34 @@ assert.equal( output = JSON.parse(result.stdout); assert.deepEqual(output, {}); +// #584: Claude Code dispatches /ponytail via skills, wrapping the command in +// / tags with SKILL.md body as filler. The hook +// must extract the command from the tags, not the raw prompt. +const skillPrompt = [ + '', + '/ponytail', + '', + '', + 'ultra', + '', + '', + '# Ponytail, lazy senior dev mode', + '... full SKILL.md body ...', + '', +].join('\n'); +result = run('ponytail-mode-tracker.js', { HOME: home, USERPROFILE: home }, + JSON.stringify({ prompt: skillPrompt })); +assert.equal(result.status, 0, result.stderr); +assert.equal(fs.readFileSync(path.join(home, '.claude', '.ponytail-active'), 'utf8'), 'ultra', + 'must extract command from skill-dispatch tags'); + +// Without command-name tags, falls back to raw prompt (direct text path). +result = run('ponytail-mode-tracker.js', { HOME: home, USERPROFILE: home }, + JSON.stringify({ prompt: '/ponytail lite' })); +assert.equal(result.status, 0, result.stderr); +assert.equal(fs.readFileSync(path.join(home, '.claude', '.ponytail-active'), 'utf8'), 'lite', + 'must still work with raw prompt when no tags present'); + // SubagentStart hook: when ponytail mode is active it injects the ruleset into // each subagent (issue #252). Native Claude must get the hookSpecificOutput JSON // form, not raw stdout, or the context is dropped. @@ -215,46 +243,32 @@ assert.equal(output.systemMessage, 'PONYTAIL:FULL'); assert.equal(output.hookSpecificOutput.hookEventName, 'SubagentStart'); assert.match(output.hookSpecificOutput.additionalContext, /PONYTAIL MODE ACTIVE — level: full/); -// writeDefaultMode must merge into existing config, not overwrite it (#490). -const mergeHome = path.join(temp, 'merge-home'); -const mergeConfigDir = path.join(mergeHome, '.config', 'ponytail'); -fs.mkdirSync(mergeConfigDir, { recursive: true }); -const mergeConfigPath = path.join(mergeConfigDir, 'config.json'); -fs.writeFileSync(mergeConfigPath, JSON.stringify({ defaultMode: 'full', customSetting: 42 }, null, 2)); +// Compression plugin conflict detection (issue #332): detect caveman and add +// deconfliction paragraph to instructions. +const { detectCompressionPlugins } = require('../hooks/ponytail-config'); +const { getPonytailInstructions } = require('../hooks/ponytail-instructions'); -const prevXdg = process.env.XDG_CONFIG_HOME; -process.env.XDG_CONFIG_HOME = path.join(mergeHome, '.config'); +// Without caveman in settings.json, no deconfliction paragraph. +const noCaveman = getPonytailInstructions('full'); +assert.ok(noCaveman.includes('PONYTAIL MODE ACTIVE')); + +// With caveman in CLAUDE_CONFIG_DIR/settings.json, detect and deconflict. +const cavemanDir = path.join(temp, 'caveman-claude'); +fs.mkdirSync(cavemanDir, { recursive: true }); +fs.writeFileSync( + path.join(cavemanDir, 'settings.json'), + JSON.stringify({ plugins: ['caveman'] }), +); +const prevCCD = process.env.CLAUDE_CONFIG_DIR; +process.env.CLAUDE_CONFIG_DIR = cavemanDir; try { - writeDefaultMode('ultra'); - const merged = JSON.parse(fs.readFileSync(mergeConfigPath, 'utf8')); - assert.equal(merged.defaultMode, 'ultra', 'writeDefaultMode must update defaultMode'); - assert.equal(merged.customSetting, 42, 'writeDefaultMode must preserve existing config fields'); + const peers = detectCompressionPlugins(); + assert.ok(peers.includes('caveman'), 'should detect caveman in settings.json'); + const withCaveman = getPonytailInstructions('full'); + assert.ok(withCaveman.includes('Compression plugin coexistence'), 'should include deconfliction paragraph'); + assert.ok(withCaveman.includes('caveman'), 'should name the detected plugin'); } finally { - if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME; - else process.env.XDG_CONFIG_HOME = prevXdg; + if (prevCCD === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = prevCCD; } - -// #329: `/ponytail default ` persists the default to config (survives -// restart), while a plain switch stays session-scoped and never touches config. -const defHome = path.join(temp, 'default-cmd-home'); -const defEnv = { HOME: defHome, USERPROFILE: defHome, XDG_CONFIG_HOME: path.join(defHome, '.config') }; -const defConfig = path.join(defHome, '.config', 'ponytail', 'config.json'); -const defFlag = path.join(defHome, '.claude', '.ponytail-active'); - -result = run('ponytail-mode-tracker.js', defEnv, JSON.stringify({ prompt: '/ponytail default lite' })); -assert.equal(result.status, 0, result.stderr); -assert.equal(JSON.parse(fs.readFileSync(defConfig, 'utf8')).defaultMode, 'lite', '/ponytail default must persist the default'); -assert.equal(fs.existsSync(defFlag), false, '/ponytail default must not change the session mode'); - -// A plain switch is transient: sets the session flag, leaves the default alone. -result = run('ponytail-mode-tracker.js', defEnv, JSON.stringify({ prompt: '/ponytail ultra' })); -assert.equal(result.status, 0, result.stderr); -assert.equal(fs.readFileSync(defFlag, 'utf8'), 'ultra', 'plain switch must set the session mode'); -assert.equal(JSON.parse(fs.readFileSync(defConfig, 'utf8')).defaultMode, 'lite', 'plain switch must not persist the default'); - -// review is not a valid default (#377) — the command is ignored, config unchanged. -result = run('ponytail-mode-tracker.js', defEnv, JSON.stringify({ prompt: '/ponytail default review' })); -assert.equal(result.status, 0, result.stderr); -assert.equal(JSON.parse(fs.readFileSync(defConfig, 'utf8')).defaultMode, 'lite', 'review must not be accepted as a default'); - console.log('hook compatibility checks passed');