diff --git a/README.md b/README.md index 597adf84..59c49658 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,8 @@ Active every session, with a handful of commands (see [Commands](#commands)). `/ Set the level for every new session with the `PONYTAIL_DEFAULT_MODE` env var (`lite`/`full`/`ultra`/`off`), or a `defaultMode` field in `~/.config/ponytail/config.json` (`%APPDATA%\ponytail\config.json` on Windows). The default is `full`. +Claude Code / Codex subagent scoping (opt-in): `PONYTAIL_SUBAGENT_MATCHER` limits the ponytail ruleset to specific subagent types. Set it to an unanchored, case-insensitive regex tested against the subagent's `agent_type` (e.g. `explore|general`). Use `^…$` for an exact match; plugin agent types look like `plugin:name`. Unset = inject into all subagents (default). + Cursor, Windsurf, Cline, GitHub Copilot (editor), Aider, Kiro, Zed, CodeWhale, Swival: copy the matching rules file from this repo ([`.cursor/rules/`](.cursor/rules/), [`.windsurf/rules/`](.windsurf/rules/), [`.clinerules/`](.clinerules/), [`.github/copilot-instructions.md`](.github/copilot-instructions.md), [`AGENTS.md`](AGENTS.md), [`.kiro/steering/`](.kiro/steering/)). Kiro: copy `.kiro/steering/ponytail.md` to `~/.kiro/steering/` (global) or `.kiro/steering/` in your project. diff --git a/hooks/ponytail-subagent.js b/hooks/ponytail-subagent.js index 96cd10ba..f06bc13a 100644 --- a/hooks/ponytail-subagent.js +++ b/hooks/ponytail-subagent.js @@ -4,6 +4,11 @@ // SessionStart context is parent-thread only and never reaches subagents, so // without this every Task-spawned agent runs ponytail-unaware (issue #252). // When ponytail mode is active, inject the same ruleset into each subagent. +// +// Scoping: by default injects into all subagents. To limit to specific agent +// types, set PONYTAIL_SUBAGENT_MATCHER to a regex (e.g. "explore|general"). +// The hook reads agent_type from stdin and skips injection when it doesn't +// match. This is opt-in to preserve backward compatibility (#506). const { getPonytailInstructions } = require('./ponytail-instructions'); const { readMode, writeHookOutput } = require('./ponytail-runtime'); @@ -15,8 +20,53 @@ if (!mode || mode === 'off') { process.exit(0); } +// ponytail: opt-in scoping via env var. Off by default = all subagents. +// Set PONYTAIL_SUBAGENT_MATCHER to a regex matching the agent types to inject. +// Upgrade path: if many users set this, promote to config.json. +const matcher = process.env.PONYTAIL_SUBAGENT_MATCHER; +let matcherRe = null; try { - writeHookOutput('SubagentStart', mode, getPonytailInstructions(mode)); -} catch (e) { - // Silent fail — a stdout error at hook exit must not surface as a hook failure. + if (matcher) matcherRe = new RegExp(matcher, 'i'); +} catch (_) { + // Invalid regex must not crash the hook — fall back to injecting everywhere. +} + +function inject() { + try { + writeHookOutput('SubagentStart', mode, getPonytailInstructions(mode)); + } catch (e) { + // Silent fail — a stdout error at hook exit must not surface as a hook failure. + } +} + +// No matcher: keep the original synchronous, stdin-independent behavior. On +// Windows the PowerShell `if {}` wrapper can swallow stdin so 'end' never fires +// (#443), so the default path must not depend on reading stdin. +if (!matcherRe) { + inject(); + process.exit(0); } + +// Matcher set: read agent_type from stdin, skip injection only on a definite +// non-match. Fail OPEN (inject) on unparseable input, stdin error, or timeout. +let input = ''; +let done = false; + +function finish() { + if (done) return; + done = true; + try { + const data = JSON.parse(input.replace(/^\uFEFF/, '')); + const agentType = (data.agent_type || '').trim(); + if (agentType && !matcherRe.test(agentType)) process.exit(0); + } catch (_) { + // Can't parse input or missing agent_type — inject to be safe. + } + inject(); +} + +process.stdin.on('data', chunk => { input += chunk; }); +process.stdin.on('end', finish); +// Mirror mode-tracker's never-block contract: recover on error/timeout too. +process.stdin.on('error', () => { finish(); process.exit(0); }); +setTimeout(() => { finish(); process.exit(0); }, 1000).unref(); diff --git a/tests/hooks.test.js b/tests/hooks.test.js index 355c8d11..cdfb6d14 100644 --- a/tests/hooks.test.js +++ b/tests/hooks.test.js @@ -34,6 +34,8 @@ function run(script, env, input = '') { delete process.env.CLAUDE_CONFIG_DIR; delete process.env.PLUGIN_DATA; delete process.env.COPILOT_PLUGIN_DATA; +// A leaked matcher would scope the inject-into-every-subagent assertions. +delete process.env.PONYTAIL_SUBAGENT_MATCHER; const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-hooks-')); // Runs on normal exit and on assertion-throw exit; force makes it idempotent. @@ -215,46 +217,64 @@ 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)); - -const prevXdg = process.env.XDG_CONFIG_HOME; -process.env.XDG_CONFIG_HOME = path.join(mergeHome, '.config'); -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'); -} finally { - if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME; - else process.env.XDG_CONFIG_HOME = prevXdg; -} +// SubagentStart matcher (issue #506): PONYTAIL_SUBAGENT_MATCHER filters by agent_type. +const matcherHome = path.join(temp, 'matcher-home'); +const matcherFlag = path.join(matcherHome, '.claude', '.ponytail-active'); +fs.mkdirSync(path.dirname(matcherFlag), { recursive: true }); +fs.writeFileSync(matcherFlag, 'ultra'); +const matcherEnv = { HOME: matcherHome, USERPROFILE: matcherHome }; + +// Matching agent_type → inject. +result = run('ponytail-subagent.js', { ...matcherEnv, PONYTAIL_SUBAGENT_MATCHER: 'general' }, + JSON.stringify({ agent_type: 'general' })); +assert.equal(result.status, 0, result.stderr); +output = JSON.parse(result.stdout); +assert.ok(output.hookSpecificOutput, 'should inject when agent_type matches'); + +// Non-matching agent_type → exit silently. +result = run('ponytail-subagent.js', { ...matcherEnv, PONYTAIL_SUBAGENT_MATCHER: 'general' }, + JSON.stringify({ agent_type: 'explore' })); +assert.equal(result.status, 0, result.stderr); +assert.equal(result.stdout, '', 'should skip injection when agent_type does not match'); + +// No matcher env → inject regardless of agent_type. +result = run('ponytail-subagent.js', matcherEnv, + JSON.stringify({ agent_type: 'explore' })); +assert.equal(result.status, 0, result.stderr); +output = JSON.parse(result.stdout); +assert.ok(output.hookSpecificOutput, 'should inject when no matcher is set'); + +// Invalid regex → must not crash the hook; fail open (inject). +result = run('ponytail-subagent.js', { ...matcherEnv, PONYTAIL_SUBAGENT_MATCHER: '[' }, + JSON.stringify({ agent_type: 'explore' })); +assert.equal(result.status, 0, result.stderr); +output = JSON.parse(result.stdout); +assert.ok(output.hookSpecificOutput, 'invalid matcher regex should inject, not crash'); -// #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'); +// Matcher set but agent_type absent → inject to be safe. +result = run('ponytail-subagent.js', { ...matcherEnv, PONYTAIL_SUBAGENT_MATCHER: 'general' }, + JSON.stringify({})); +assert.equal(result.status, 0, result.stderr); +output = JSON.parse(result.stdout); +assert.ok(output.hookSpecificOutput, 'absent agent_type should inject to be safe'); -result = run('ponytail-mode-tracker.js', defEnv, JSON.stringify({ prompt: '/ponytail default lite' })); +// Case-insensitive, unanchored match → inject ('General-purpose' matches 'general'). +result = run('ponytail-subagent.js', { ...matcherEnv, PONYTAIL_SUBAGENT_MATCHER: 'general|plan' }, + JSON.stringify({ agent_type: 'General-purpose' })); 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'); +output = JSON.parse(result.stdout); +assert.ok(output.hookSpecificOutput, 'matcher should be case-insensitive and unanchored'); -// 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' })); +// Anchored regex → exact match only; a superset agent_type is rejected. +result = run('ponytail-subagent.js', { ...matcherEnv, PONYTAIL_SUBAGENT_MATCHER: '^general$' }, + JSON.stringify({ agent_type: 'general-purpose' })); 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'); +assert.equal(result.stdout, '', 'anchored matcher must not match a superset agent_type'); -// 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' })); +// No matcher + empty stdin → default path injects without waiting on stdin (#443). +result = run('ponytail-subagent.js', matcherEnv, ''); 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'); +output = JSON.parse(result.stdout); +assert.ok(output.hookSpecificOutput, 'no-matcher path must not depend on stdin'); console.log('hook compatibility checks passed');