Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
19 changes: 19 additions & 0 deletions hooks/ponytail-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -133,6 +151,7 @@ module.exports = {
DEFAULT_MODE,
VALID_MODES,
RUNTIME_MODES,
detectCompressionPlugins,
getDefaultMode,
getConfigDir,
getConfigPath,
Expand Down
16 changes: 13 additions & 3 deletions hooks/ponytail-instructions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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;

Expand All @@ -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();
}
}

Expand Down
8 changes: 7 additions & 1 deletion hooks/ponytail-mode-tracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command-name>/<command-args> 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(/<command-name>\s*([^<]+?)\s*<\/command-name>/)?.[1];
const args = raw.match(/<command-args>\s*([^<]*?)\s*<\/command-args>/)?.[1];
const prompt = (name ? `${name} ${args ?? ''}` : raw).trim().toLowerCase();

// Match /ponytail commands
if (/^[/@$]ponytail/.test(prompt)) {
Expand Down
90 changes: 52 additions & 38 deletions tests/hooks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <command-name>/<command-args> tags with SKILL.md body as filler. The hook
// must extract the command from the tags, not the raw prompt.
const skillPrompt = [
'<command-name>',
'/ponytail',
'</command-name>',
'<command-args>',
'ultra',
'</command-args>',
'<command-message>',
'# Ponytail, lazy senior dev mode',
'... full SKILL.md body ...',
'</command-message>',
].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.
Expand Down Expand Up @@ -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 <mode>` 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');