Compare commits

..
9 Commits
Author SHA1 Message Date
Hannes 8c279cbfb3 feat: add pi extension (#1) 2026-06-12 17:55:24 +02:00
DietrichGebertandClaude Opus 4.8 de318b9457 docs: link agent-portability from README (#10)
The doc landed in #2 but was unlinked. Point to it from the adapters section.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 17:41:45 +02:00
Chindanai Jaiman 784e8bfd1c Document agent adapters (#2) 2026-06-12 17:39:15 +02:00
DietrichGebertandClaude Opus 4.8 bce2162025 chore: tighten rule-copy drift check; align Kiro copy (#9)
Cover the Kiro steering file in the equality check (the stripper is now shared, renamed to stripFrontmatter), and align its body with AGENTS.md: the 'Not lazy about' and 'Non-trivial logic' sentences were split into two paragraphs where the canonical body and the other four copies keep them as one. The improved check caught this on the freshly merged #6.

Also add a small invariant canary: four load-bearing rule phrases must appear verbatim in both SKILL.md (the runtime source of truth) and AGENTS.md, catching a rule that lands in one but not the other even though the two files are intentionally different lengths.

Verified: passes on main, exits 1 when a copy diverges or an invariant goes missing.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 17:39:11 +02:00
dgebertandClaude Opus 4.8 515fb4c5a4 fix: make hook compatibility test cross-platform (USERPROFILE for Windows os.homedir)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 17:11:05 +02:00
dgebertandClaude Opus 4.8 e37b823b92 Merge PR #7: Codex support (avidspartan1)
Adds the Codex adapter: marketplace.json, .codex-plugin/plugin.json,
declarative hooks.json with Windows command variants, and a shared
hooks/ponytail-runtime.js that switches state path and output format
between Claude and Codex (gated on PLUGIN_DATA, so the Claude path is
unchanged). Bumps plugin version to 4.1.0 and ships tests/hooks.test.js.

README adapters block resolved to keep the Codex @ponytail invocation note
together with Kiro (#6) and the drift-check Development section (#3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 17:09:21 +02:00
Chindanai Jaiman e89a4e9863 Add rule copy drift check (#3) 2026-06-12 17:05:09 +02:00
Mark Ingalls 2f2a0d33e0 feat: add Kiro steering file support (#6) 2026-06-12 17:03:55 +02:00
Paul c16f967d37 feat: codex support 2026-06-12 08:49:33 -04:00
20 changed files with 858 additions and 111 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"name": "ponytail",
"interface": {
"displayName": "Ponytail"
},
"plugins": [
{
"name": "ponytail",
"source": {
"source": "url",
"url": "https://github.com/DietrichGebert/ponytail.git",
"ref": "main"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ponytail", "name": "ponytail",
"version": "4.0.0", "version": "4.1.0",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.", "description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"author": { "author": {
"name": "Dietrich Gebert", "name": "Dietrich Gebert",
+31
View File
@@ -0,0 +1,31 @@
{
"name": "ponytail",
"version": "4.1.0",
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
"author": {
"name": "Dietrich Gebert",
"url": "https://github.com/DietrichGebert"
},
"homepage": "https://github.com/DietrichGebert/ponytail",
"repository": "https://github.com/DietrichGebert/ponytail",
"license": "MIT",
"keywords": ["yagni", "minimalism", "code-review", "productivity"],
"skills": "./skills/",
"interface": {
"displayName": "Ponytail",
"shortDescription": "Lazy senior developer mode",
"longDescription": "Prefer YAGNI, the standard library, native platform features, and the smallest correct implementation.",
"developerName": "Dietrich Gebert",
"category": "Productivity",
"capabilities": ["Instructions", "Lifecycle hooks"],
"websiteURL": "https://github.com/DietrichGebert/ponytail",
"defaultPrompt": [
"Use Ponytail mode for this task.",
"Review this diff for over-engineering.",
"Find the smallest correct implementation."
],
"brandColor": "#111111",
"composerIcon": "./assets/logo.png",
"logo": "./assets/logo.png"
}
}
+29
View File
@@ -0,0 +1,29 @@
---
title: Ponytail — lazy senior dev mode
inclusion: always
---
# Ponytail — lazy senior dev mode
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
Before writing any code, stop at the first rung that holds:
1. Does this need to be built at all? (YAGNI)
2. Does the standard library already do this? Use it.
3. Does a native platform feature cover it? Use it.
4. Does an already-installed dependency solve it? Use it.
5. Can this be one line? Make it one line.
6. Only then: write the minimum code that works.
Rules:
- No abstractions that weren't explicitly requested.
- No new dependency if it can be avoided.
- No boilerplate nobody asked for.
- Deletion over addition. Boring over clever. Fewest files possible.
- Question complex requests: "Do you actually need X, or does Y cover it?"
- Pick the edge-case-correct option when two stdlib approaches are the same size — lazy means less code, not the flimsier algorithm.
- Mark intentional simplifications with a `ponytail:` comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
Not lazy about: input validation at trust boundaries, error handling that prevents data loss, security, accessibility, anything explicitly requested. Non-trivial logic leaves ONE runnable check behind — the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.
+34 -1
View File
@@ -56,16 +56,49 @@ Lazy, not negligent: trust-boundary validation, data-loss handling, security, an
The most effort ponytail will ever ask of you: The most effort ponytail will ever ask of you:
### Claude Code
``` ```
/plugin marketplace add DietrichGebert/ponytail /plugin marketplace add DietrichGebert/ponytail
/plugin install ponytail@ponytail /plugin install ponytail@ponytail
``` ```
### Codex
```bash
codex plugin marketplace add DietrichGebert/ponytail
codex
```
Open `/plugins`, select the Ponytail marketplace, and install Ponytail. Then
open `/hooks`, review and trust its two lifecycle hooks, and start a new thread.
### Pi agent harness
```
pi install git:github.com/DietrichGebert/ponytail
```
That was it. He'd be proud. He won't say it. That was it. He'd be proud. He won't say it.
Active every session. `/ponytail-review` finds what to delete in your diff. `/ponytail ultra` exists for when the codebase has wronged you personally. `/ponytail-help` explains the rest. Active every session. `/ponytail-review` finds what to delete in your diff. `/ponytail ultra` exists for when the codebase has wronged you personally. `/ponytail-help` explains the rest.
Cursor, Windsurf, Cline, Copilot, Aider: 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)). In Codex, invoke the skills as `@ponytail`, `@ponytail-review`, and
`@ponytail-help`. Startup and mode-change text shows the current mode.
Cursor, Windsurf, Cline, Copilot, Aider, Kiro: 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.
Which files map to which agent: [Agent portability](docs/agent-portability.md).
## Development
When changing the compact rule text, keep the agent copies aligned:
```bash
node scripts/check-rule-copies.js
```
## FAQ ## FAQ
+31
View File
@@ -0,0 +1,31 @@
# Agent Portability
Ponytail is an agent-portable skill distribution. The skills in `skills/` hold
the core behavior; host-specific files are adapters that make that behavior easy
to load in a given agent.
## Supported Adapters
| Host | Files | Notes |
|------|-------|-------|
| Claude Code | `.claude-plugin/`, `commands/`, `hooks/` | Full plugin install with session activation, mode tracking, commands, and statusline support. |
| Codex | `.codex-plugin/plugin.json`, `hooks/hooks.json`, `hooks/`, `skills/` | Plugin install with the same skills plus lifecycle hooks for activation and mode tracking. |
| Cursor | `.cursor/rules/ponytail.mdc` | Always-on project rule. |
| Windsurf | `.windsurf/rules/ponytail.md` | Project rule. |
| Cline | `.clinerules/ponytail.md` | Project rule. |
| GitHub Copilot | `.github/copilot-instructions.md` | Repository instruction file. |
| Kiro | `.kiro/steering/ponytail.md` | Steering rule; copy globally or into a project. |
| Generic agents | `AGENTS.md` or `skills/*/SKILL.md` | Copy the compact rule file or load the skill files directly. |
## Adapter Rule
Keep adapters thin. When a host supports skills or hooks, point it at the
existing `skills/` and `hooks/` files. When a host only supports project
instructions, keep its copied rule text aligned with `AGENTS.md`.
## Portable Behavior
- `skills/ponytail/SKILL.md`: lazy senior dev mode
- `skills/ponytail-review/SKILL.md`: over-engineering review
- `skills/ponytail-help/SKILL.md`: quick reference
- `AGENTS.md`: compact always-on instruction set for agents without skill support
+31
View File
@@ -0,0 +1,31 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume|clear|compact",
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/hooks/ponytail-activate.js\"",
"commandWindows": "node \"%PLUGIN_ROOT%\\hooks\\ponytail-activate.js\"",
"timeout": 5,
"statusMessage": "Loading ponytail mode..."
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"",
"commandWindows": "node \"%PLUGIN_ROOT%\\hooks\\ponytail-mode-tracker.js\"",
"timeout": 5,
"statusMessage": "Tracking ponytail mode..."
}
]
}
]
}
}
+13 -96
View File
@@ -10,121 +10,38 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const { getDefaultMode } = require('./ponytail-config'); const { getDefaultMode } = require('./ponytail-config');
const { getPonytailInstructions } = require('./ponytail-instructions');
const {
clearMode,
isCodex,
setMode,
writeHookOutput,
} = require('./ponytail-runtime');
const claudeDir = path.join(os.homedir(), '.claude'); const claudeDir = path.join(os.homedir(), '.claude');
const flagPath = path.join(claudeDir, '.ponytail-active');
const settingsPath = path.join(claudeDir, 'settings.json'); const settingsPath = path.join(claudeDir, 'settings.json');
const mode = getDefaultMode(); const mode = getDefaultMode();
// "off" mode — skip activation entirely, don't write flag or emit rules // "off" mode — skip activation entirely, don't write flag or emit rules
if (mode === 'off') { if (mode === 'off') {
try { fs.unlinkSync(flagPath); } catch (e) {} clearMode();
process.stdout.write('OK'); writeHookOutput('SessionStart', 'off', isCodex ? '' : 'OK');
process.exit(0); process.exit(0);
} }
// 1. Write flag file // 1. Write flag file
try { try {
fs.mkdirSync(path.dirname(flagPath), { recursive: true }); setMode(mode);
fs.writeFileSync(flagPath, mode);
} catch (e) { } catch (e) {
// Silent fail -- flag is best-effort, don't block the hook // Silent fail -- flag is best-effort, don't block the hook
} }
// 2. Emit the ponytail ruleset, filtered to the active intensity level. // 2. Emit the ponytail ruleset, filtered to the active intensity level.
// A short summary is too weak — models drift back to over-building let output = getPonytailInstructions(mode);
// mid-conversation, especially after context compression prunes it.
// Full rules with examples anchor behavior much more reliably.
//
// Reads SKILL.md at runtime so edits to the source of truth propagate
// automatically — no hardcoded duplication to go stale.
// Modes that have their own independent skill files — not intensity levels.
// For these, emit a short activation line; the skill itself handles behavior.
const INDEPENDENT_MODES = new Set(['review']);
if (INDEPENDENT_MODES.has(mode)) {
process.stdout.write('PONYTAIL MODE ACTIVE — level: ' + mode + '. Behavior defined by /ponytail-' + mode + ' skill.');
process.exit(0);
}
// Read SKILL.md — the single source of truth for ponytail behavior.
// Plugin installs: __dirname = <plugin_root>/hooks/, SKILL.md at <plugin_root>/skills/ponytail/SKILL.md
let skillContent = '';
try {
skillContent = fs.readFileSync(
path.join(__dirname, '..', 'skills', 'ponytail', 'SKILL.md'), 'utf8'
);
} catch (e) { /* standalone install — will use fallback below */ }
let output;
if (skillContent) {
// Strip YAML frontmatter
const body = skillContent.replace(/^---[\s\S]*?---\s*/, '');
// Filter intensity table: keep header rows + only the active level's row
const filtered = body.split('\n').reduce((acc, line) => {
// Intensity table rows start with | **level** |
const tableRowMatch = line.match(/^\|\s*\*\*(\S+?)\*\*\s*\|/);
if (tableRowMatch) {
if (tableRowMatch[1] === mode) {
acc.push(line);
}
return acc;
}
// Example lines start with "- level:" — keep only lines matching active level
const exampleMatch = line.match(/^- (\S+?):\s/);
if (exampleMatch) {
if (exampleMatch[1] === mode) {
acc.push(line);
}
return acc;
}
acc.push(line);
return acc;
}, []);
output = 'PONYTAIL MODE ACTIVE — level: ' + mode + '\n\n' + filtered.join('\n');
} else {
// Fallback when SKILL.md is not found (hook installed without skills dir).
// Minimum viable ruleset — better than nothing.
output =
'PONYTAIL MODE ACTIVE — level: ' + mode + '\n\n' +
'You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.\n\n' +
'## Persistence\n\n' +
'ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if unsure. Off only: "stop ponytail" / "normal mode".\n\n' +
'Current level: **' + mode + '**. Switch: `/ponytail lite|full|ultra`.\n\n' +
'## The ladder\n\n' +
'Before any code, stop at the first rung that holds:\n' +
'1. Does this need to be built at all? (YAGNI)\n' +
'2. Does the standard library do this? Use it.\n' +
'3. Does a native platform feature cover it? Use it.\n' +
'4. Does an already-installed dependency solve it? Use it.\n' +
'5. Can this be one line? Make it one line.\n' +
'6. Only then: write the minimum code that works.\n\n' +
'## Rules\n\n' +
'No abstractions that were not requested. No avoidable dependencies. No boilerplate nobody asked for. ' +
'Deletion over addition. Boring over clever. Fewest files possible. ' +
'Ship the lazy version and question the complex request in the same response — never stall. ' +
'Between two same-size stdlib options, pick the one correct on edge cases. ' +
'Mark intentional simplifications with a `ponytail:` comment — a shortcut with a known ceiling names the ceiling and the upgrade path in the comment.\n\n' +
'## Output\n\n' +
'Code first. Then at most three short lines: what was skipped, when to add it. ' +
'If the explanation is longer than the code, delete the explanation.\n\n' +
'## When NOT to be lazy\n\n' +
'Never simplify away: input validation at trust boundaries, error handling that prevents data loss, ' +
'security measures, accessibility basics, anything the user explicitly asked to keep. ' +
'Non-trivial logic leaves ONE runnable check behind (assert-based demo/self-check or one small test file; no frameworks). Trivial one-liners need no test.\n\n' +
'## Boundaries\n\n' +
'Ponytail governs what you build, not how you talk. "stop ponytail" or "normal mode": revert. Level persists until changed or session end.';
}
// 3. Detect missing statusline config — nudge Claude to help set it up // 3. Detect missing statusline config — nudge Claude to help set it up
try { if (!isCodex) try {
let hasStatusline = false; let hasStatusline = false;
if (fs.existsSync(settingsPath)) { if (fs.existsSync(settingsPath)) {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
@@ -153,4 +70,4 @@ try {
// Silent fail — don't block session start over statusline detection // Silent fail — don't block session start over statusline detection
} }
process.stdout.write(output); writeHookOutput('SessionStart', mode, output);
+41 -2
View File
@@ -13,7 +13,25 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const DEFAULT_MODE = 'full';
const VALID_MODES = ['off', 'lite', 'full', 'ultra', 'review']; const VALID_MODES = ['off', 'lite', 'full', 'ultra', 'review'];
const RUNTIME_MODES = ['off', 'lite', 'full', 'ultra'];
function normalizeMode(mode) {
if (typeof mode !== 'string') return null;
const normalized = mode.trim().toLowerCase();
return RUNTIME_MODES.includes(normalized) ? normalized : null;
}
function normalizeConfigMode(mode) {
if (typeof mode !== 'string') return null;
const normalized = mode.trim().toLowerCase();
return VALID_MODES.includes(normalized) ? normalized : null;
}
function normalizePersistedMode(mode) {
return normalizeMode(mode) || normalizeConfigMode(mode);
}
function getConfigDir() { function getConfigDir() {
if (process.env.XDG_CONFIG_HOME) { if (process.env.XDG_CONFIG_HOME) {
@@ -51,7 +69,28 @@ function getDefaultMode() {
} }
// 3. Default // 3. Default
return 'full'; return DEFAULT_MODE;
} }
module.exports = { getDefaultMode, getConfigDir, getConfigPath, VALID_MODES }; function writeDefaultMode(mode) {
const normalized = normalizeConfigMode(mode);
if (!normalized) return null;
const configPath = getConfigPath();
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ defaultMode: normalized }, null, 2), 'utf8');
return normalized;
}
module.exports = {
DEFAULT_MODE,
VALID_MODES,
RUNTIME_MODES,
getDefaultMode,
getConfigDir,
getConfigPath,
normalizeMode,
normalizeConfigMode,
normalizePersistedMode,
writeDefaultMode,
};
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env node
// Shared Ponytail instruction builder for Claude hooks and Pi extension.
const fs = require('fs');
const path = require('path');
const { DEFAULT_MODE, normalizeMode, normalizePersistedMode } = require('./ponytail-config');
const INDEPENDENT_MODES = new Set(['review']);
const SKILL_PATH = path.join(__dirname, '..', 'skills', 'ponytail', 'SKILL.md');
function filterSkillBodyForMode(body, mode) {
const effectiveMode = normalizeMode(mode) || DEFAULT_MODE;
const withoutFrontmatter = String(body || '').replace(/^---[\s\S]*?---\s*/, '');
return withoutFrontmatter
.split(/\r?\n/)
.filter((line) => {
const tableMatch = line.match(/^\|\s*\*\*(.+?)\*\*\s*\|/);
if (tableMatch) return tableMatch[1].trim() === effectiveMode;
const exampleMatch = line.match(/^-\s*([^:]+):\s*/);
if (exampleMatch) return exampleMatch[1].trim() === effectiveMode;
return true;
})
.join('\n');
}
function getFallbackInstructions(mode) {
return 'PONYTAIL MODE ACTIVE — level: ' + mode + '\n\n' +
'You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.\n\n' +
'## Persistence\n\n' +
'ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if unsure. Off only: "stop ponytail" / "normal mode".\n\n' +
'Current level: **' + mode + '**. Switch: `/ponytail lite|full|ultra`.\n\n' +
'## The ladder\n\n' +
'Before any code, stop at the first rung that holds:\n' +
'1. Does this need to be built at all? (YAGNI)\n' +
'2. Does the standard library do this? Use it.\n' +
'3. Does a native platform feature cover it? Use it.\n' +
'4. Does an already-installed dependency solve it? Use it.\n' +
'5. Can this be one line? Make it one line.\n' +
'6. Only then: write the minimum code that works.\n\n' +
'## Rules\n\n' +
'No abstractions that were not requested. No avoidable dependencies. No boilerplate nobody asked for. ' +
'Deletion over addition. Boring over clever. Fewest files possible. ' +
'Ship the lazy version and question the complex request in the same response — never stall. ' +
'Between two same-size stdlib options, pick the one correct on edge cases. ' +
'Mark intentional simplifications with a `ponytail:` comment — a shortcut with a known ceiling names the ceiling and the upgrade path in the comment.\n\n' +
'## Output\n\n' +
'Code first. Then at most three short lines: what was skipped, when to add it. ' +
'If the explanation is longer than the code, delete the explanation.\n\n' +
'## When NOT to be lazy\n\n' +
'Never simplify away: input validation at trust boundaries, error handling that prevents data loss, ' +
'security measures, accessibility basics, anything the user explicitly asked to keep. ' +
'Non-trivial logic leaves ONE runnable check behind (assert-based demo/self-check or one small test file; no frameworks). Trivial one-liners need no test.\n\n' +
'## Boundaries\n\n' +
'Ponytail governs what you build, not how you talk. "stop ponytail" or "normal mode": revert. Level persists until changed or session end.';
}
function getPonytailInstructions(mode) {
const configuredMode = normalizePersistedMode(mode) || DEFAULT_MODE;
if (INDEPENDENT_MODES.has(configuredMode)) {
return 'PONYTAIL MODE ACTIVE — level: ' + configuredMode + '. Behavior defined by /ponytail-' + configuredMode + ' skill.';
}
const effectiveMode = normalizeMode(configuredMode) || DEFAULT_MODE;
try {
return 'PONYTAIL MODE ACTIVE — level: ' + effectiveMode + '\n\n' +
filterSkillBodyForMode(fs.readFileSync(SKILL_PATH, 'utf8'), effectiveMode);
} catch (e) {
return getFallbackInstructions(effectiveMode);
}
}
module.exports = {
filterSkillBodyForMode,
getFallbackInstructions,
getPonytailInstructions,
};
+13 -11
View File
@@ -2,12 +2,8 @@
// ponytail — UserPromptSubmit hook to track which ponytail mode is active // ponytail — UserPromptSubmit hook to track which ponytail mode is active
// Inspects user input for /ponytail commands and writes mode to flag file // Inspects user input for /ponytail commands and writes mode to flag file
const fs = require('fs');
const path = require('path');
const os = require('os');
const { getDefaultMode } = require('./ponytail-config'); const { getDefaultMode } = require('./ponytail-config');
const { clearMode, setMode, writeHookOutput } = require('./ponytail-runtime');
const flagPath = path.join(os.homedir(), '.claude', '.ponytail-active');
let input = ''; let input = '';
process.stdin.on('data', chunk => { input += chunk; }); process.stdin.on('data', chunk => { input += chunk; });
@@ -18,9 +14,9 @@ process.stdin.on('end', () => {
const prompt = (data.prompt || '').trim().toLowerCase(); const prompt = (data.prompt || '').trim().toLowerCase();
// Match /ponytail commands // Match /ponytail commands
if (prompt.startsWith('/ponytail')) { if (/^[/@$]ponytail/.test(prompt)) {
const parts = prompt.split(/\s+/); const parts = prompt.split(/\s+/);
const cmd = parts[0]; // /ponytail, /ponytail-review, /ponytail:ponytail, etc. const cmd = parts[0].replace(/^[@$]/, '/');
const arg = parts[1] || ''; const arg = parts[1] || '';
let mode = null; let mode = null;
@@ -36,16 +32,22 @@ process.stdin.on('end', () => {
} }
if (mode && mode !== 'off') { if (mode && mode !== 'off') {
fs.mkdirSync(path.dirname(flagPath), { recursive: true }); setMode(mode);
fs.writeFileSync(flagPath, mode); writeHookOutput(
'UserPromptSubmit',
mode,
'PONYTAIL MODE CHANGED — level: ' + mode,
);
} else if (mode === 'off') { } else if (mode === 'off') {
try { fs.unlinkSync(flagPath); } catch (e) {} clearMode();
writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF');
} }
} }
// Detect deactivation // Detect deactivation
if (/\b(stop ponytail|normal mode)\b/i.test(prompt)) { if (/\b(stop ponytail|normal mode)\b/i.test(prompt)) {
try { fs.unlinkSync(flagPath); } catch (e) {} clearMode();
writeHookOutput('UserPromptSubmit', 'off', 'PONYTAIL MODE OFF');
} }
} catch (e) { } catch (e) {
// Silent fail // Silent fail
+39
View File
@@ -0,0 +1,39 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const isCodex = Boolean(process.env.PLUGIN_DATA);
const statePath = isCodex
? path.join(process.env.PLUGIN_DATA, '.ponytail-active')
: path.join(os.homedir(), '.claude', '.ponytail-active');
function setMode(mode) {
fs.mkdirSync(path.dirname(statePath), { recursive: true });
fs.writeFileSync(statePath, mode);
}
function clearMode() {
try { fs.unlinkSync(statePath); } catch (e) {}
}
function writeHookOutput(event, mode, context = '') {
if (!isCodex) {
process.stdout.write(context);
return;
}
const output = { systemMessage: `PONYTAIL:${mode.toUpperCase()}` };
if (context) {
output.hookSpecificOutput = {
hookEventName: event,
additionalContext: context,
};
}
process.stdout.write(JSON.stringify(output));
}
module.exports = {
clearMode,
isCodex,
setMode,
writeHookOutput,
};
+11
View File
@@ -0,0 +1,11 @@
{
"name": "ponytail",
"version": "0.1.0",
"description": "Lazy senior dev mode for AI agents. The best code is the code you never wrote.",
"keywords": ["pi-package", "pi", "skills", "ponytail"],
"license": "MIT",
"pi": {
"extensions": ["./pi-extension/index.js"],
"skills": ["./skills"]
}
}
+141
View File
@@ -0,0 +1,141 @@
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const {
DEFAULT_MODE,
getDefaultMode,
normalizeMode,
normalizeConfigMode,
normalizePersistedMode,
writeDefaultMode,
} = require("../hooks/ponytail-config.js");
const { getPonytailInstructions, filterSkillBodyForMode } = require("../hooks/ponytail-instructions.js");
export { filterSkillBodyForMode };
export const readDefaultMode = getDefaultMode;
export function resolveSessionMode(entries, fallbackMode = DEFAULT_MODE) {
const fallback = normalizePersistedMode(fallbackMode) || DEFAULT_MODE;
if (!Array.isArray(entries)) return fallback;
for (let i = entries.length - 1; i >= 0; i -= 1) {
const entry = entries[i];
if (entry?.type !== "custom" || entry?.customType !== "ponytail-mode") continue;
const mode = normalizePersistedMode(entry?.data?.mode);
if (mode) return mode;
}
return fallback;
}
export function parsePonytailCommand(text, defaultMode = DEFAULT_MODE) {
const fallback = normalizePersistedMode(defaultMode) || DEFAULT_MODE;
const normalizedText = String(text || "").trim().toLowerCase();
if (!normalizedText) {
return { type: "set-mode", mode: fallback === "off" ? "full" : fallback };
}
const [primary, secondary] = normalizedText.split(/\s+/);
if (primary === "status") return { type: "status" };
if (primary === "default") {
const mode = normalizeConfigMode(secondary);
return mode ? { type: "set-default", mode } : { type: "invalid", reason: "invalid-default-mode" };
}
const mode = normalizeMode(primary);
return mode ? { type: "set-mode", mode } : { type: "invalid", reason: "invalid-mode", mode: primary };
}
export { writeDefaultMode };
export default function ponytailExtension(pi) {
let currentMode = DEFAULT_MODE;
let configuredDefaultMode = getDefaultMode();
const setMode = (mode, ctx) => {
const normalized = normalizePersistedMode(mode);
if (!normalized) return;
currentMode = normalized;
pi.appendEntry("ponytail-mode", { mode: normalized });
ctx?.ui?.notify?.(`Ponytail mode set to ${normalized}.`, "info");
};
const sendAlias = (skillName, args, ctx) => {
const normalized = String(args || "").trim();
const message = normalized ? `${skillName} ${normalized}` : skillName;
if (ctx?.isIdle?.() === false) {
pi.sendUserMessage(message, { deliverAs: "followUp" });
ctx?.ui?.notify?.(`${skillName} queued as follow-up.`, "info");
return;
}
pi.sendUserMessage(message);
};
pi.registerCommand("ponytail", {
description: "Set or report Ponytail mode",
handler: async (args, ctx) => {
const parsed = parsePonytailCommand(args, configuredDefaultMode);
if (parsed.type === "status") {
ctx?.ui?.notify?.(`Ponytail: current ${currentMode} • default ${configuredDefaultMode}`, "info");
return;
}
if (parsed.type === "set-default") {
const written = writeDefaultMode(parsed.mode);
if (written) {
configuredDefaultMode = getDefaultMode();
const message = configuredDefaultMode === written
? `Default Ponytail mode set to ${written}.`
: `Saved default ${written}, but env override keeps default at ${configuredDefaultMode}.`;
ctx?.ui?.notify?.(message, "info");
}
return;
}
if (parsed.type === "set-mode") {
setMode(parsed.mode, ctx);
return;
}
ctx?.ui?.notify?.("Unknown or unsupported /ponytail mode.", "warning");
},
});
pi.registerCommand("ponytail-review", {
description: "Run /skill:ponytail-review",
handler: (_args, ctx) => sendAlias("/skill:ponytail-review", "", ctx),
});
pi.registerCommand("ponytail-help", {
description: "Run /skill:ponytail-help",
handler: (_args, ctx) => sendAlias("/skill:ponytail-help", "", ctx),
});
pi.on("input", async (event) => {
if (event?.source === "extension") return;
const text = String(event?.text || "");
if (currentMode !== "off" && /\b(stop ponytail|normal mode)\b/i.test(text)) {
setMode("off");
}
});
pi.on("session_start", async (_event, ctx) => {
const entries = ctx?.sessionManager?.getBranch?.() || ctx?.sessionManager?.getEntries?.() || [];
configuredDefaultMode = getDefaultMode();
currentMode = resolveSessionMode(entries, configuredDefaultMode);
});
pi.on("before_agent_start", async (event) => {
if (!currentMode || currentMode === "off") return;
return { systemPrompt: `${event.systemPrompt}\n\n${getPonytailInstructions(currentMode)}` };
});
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "ponytail-pi-extension-dev",
"private": true,
"type": "module",
"scripts": {
"test": "node --test ./test/*.test.js"
}
}
+119
View File
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import ponytailExtension from "../index.js";
function createPiHarness() {
const events = new Map();
const commands = new Map();
const appendedEntries = [];
const sentUserMessages = [];
const pi = {
on(eventName, handler) {
events.set(eventName, handler);
},
registerCommand(name, options) {
commands.set(name, options);
},
appendEntry(customType, data) {
appendedEntries.push({ customType, data });
},
sendUserMessage(text, options) {
sentUserMessages.push({ text, options });
},
};
ponytailExtension(pi);
return { events, commands, appendedEntries, sentUserMessages };
}
function createCommandContext(overrides = {}) {
return {
isIdle: () => true,
sessionManager: { getEntries: () => [] },
ui: { notify() {} },
...overrides,
};
}
function withTempConfig(fn) {
const tempConfigHome = mkdtempSync(join(tmpdir(), "ponytail-test-"));
const previousXdg = process.env.XDG_CONFIG_HOME;
process.env.XDG_CONFIG_HOME = tempConfigHome;
return Promise.resolve()
.then(fn)
.finally(() => {
if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = previousXdg;
rmSync(tempConfigHome, { recursive: true, force: true });
});
}
test("extension registers Ponytail commands", () => {
const { commands } = createPiHarness();
assert.deepEqual([...commands.keys()].sort(), ["ponytail", "ponytail-help", "ponytail-review"]);
});
test("/ponytail updates session mode and injects instructions", async () => withTempConfig(async () => {
const { commands, events, appendedEntries } = createPiHarness();
const ctx = createCommandContext();
await events.get("session_start")({ reason: "startup" }, ctx);
await commands.get("ponytail").handler("ultra", ctx);
assert.deepEqual(appendedEntries.at(-1), {
customType: "ponytail-mode",
data: { mode: "ultra" },
});
const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
assert.ok(result.systemPrompt.includes("PONYTAIL MODE ACTIVE"));
assert.ok(result.systemPrompt.includes("ultra"));
}));
test("session_start restores latest persisted mode", async () => withTempConfig(async () => {
const { events } = createPiHarness();
const ctx = createCommandContext({
sessionManager: {
getEntries: () => [
{ type: "custom", customType: "ponytail-mode", data: { mode: "lite" } },
],
},
});
await events.get("session_start")({ reason: "resume" }, ctx);
const result = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
assert.ok(result.systemPrompt.includes("lite"));
}));
test("skill alias commands delegate to Pi skill commands", async () => {
const { commands, sentUserMessages } = createPiHarness();
const ctx = createCommandContext();
await commands.get("ponytail-review").handler("", ctx);
await commands.get("ponytail-help").handler("", ctx);
assert.deepEqual(sentUserMessages.map((entry) => entry.text), [
"/skill:ponytail-review",
"/skill:ponytail-help",
]);
});
test("normal mode disables persistent instructions", async () => withTempConfig(async () => {
const { commands, events } = createPiHarness();
const ctx = createCommandContext();
await events.get("session_start")({ reason: "startup" }, ctx);
await commands.get("ponytail").handler("ultra", ctx);
await events.get("input")({ text: "normal mode", source: "interactive" }, ctx);
const disabled = await events.get("before_agent_start")({ systemPrompt: "BASE" }, ctx);
assert.equal(disabled, undefined);
}));
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
filterSkillBodyForMode,
parsePonytailCommand,
readDefaultMode,
resolveSessionMode,
writeDefaultMode,
} from "../index.js";
test("parsePonytailCommand falls back to full when invoked bare and default is off", () => {
assert.deepEqual(parsePonytailCommand("", "off"), { type: "set-mode", mode: "full" });
});
test("parsePonytailCommand parses modes, status, and default subcommand", () => {
assert.deepEqual(parsePonytailCommand("ultra", "full"), { type: "set-mode", mode: "ultra" });
assert.deepEqual(parsePonytailCommand("status", "full"), { type: "status" });
assert.deepEqual(parsePonytailCommand("default lite", "full"), { type: "set-default", mode: "lite" });
});
test("resolveSessionMode prefers latest persisted session mode", () => {
const entries = [
{ type: "custom", customType: "ponytail-mode", data: { mode: "lite" } },
{ type: "custom", customType: "ponytail-mode", data: { mode: "ultra" } },
];
assert.equal(resolveSessionMode(entries, "full"), "ultra");
});
test("readDefaultMode and writeDefaultMode use XDG config path", () => {
const tempDir = mkdtempSync(join(tmpdir(), "ponytail-config-"));
const previousXdg = process.env.XDG_CONFIG_HOME;
const previousDefault = process.env.PONYTAIL_DEFAULT_MODE;
const configPath = join(tempDir, "ponytail", "config.json");
process.env.XDG_CONFIG_HOME = tempDir;
delete process.env.PONYTAIL_DEFAULT_MODE;
try {
assert.equal(readDefaultMode(), "full");
assert.equal(writeDefaultMode("ultra"), "ultra");
assert.equal(readDefaultMode(), "ultra");
assert.ok(existsSync(configPath));
assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")), { defaultMode: "ultra" });
} finally {
if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = previousXdg;
if (previousDefault === undefined) delete process.env.PONYTAIL_DEFAULT_MODE;
else process.env.PONYTAIL_DEFAULT_MODE = previousDefault;
rmSync(tempDir, { recursive: true, force: true });
}
});
test("filterSkillBodyForMode keeps only requested intensity examples and rows", () => {
const body = `---\nname: ponytail\n---\n| **lite** | keep lite |\n| **full** | keep full |\n| **ultra** | keep ultra |\n- lite: Lite example\n- full: Full example\n- ultra: Ultra example\nOther line`;
const filtered = filterSkillBodyForMode(body, "ultra");
assert.ok(!filtered.includes("keep lite"));
assert.ok(!filtered.includes("keep full"));
assert.ok(filtered.includes("keep ultra"));
assert.ok(!filtered.includes("Lite example"));
assert.ok(filtered.includes("Ultra example"));
assert.ok(filtered.includes("Other line"));
});
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
function read(relPath) {
return fs.readFileSync(path.join(root, relPath), 'utf8').replace(/\r\n/g, '\n').trim();
}
function stripFrontmatter(text) {
return text.replace(/^---\n[\s\S]*?\n---\n*/, '').trim();
}
const agents = read('AGENTS.md');
const canonical = agents.replace(/\n\n\(Yes, this file also applies[\s\S]*?\)$/, '').trim();
// Compact copies: same body as AGENTS.md, host-specific frontmatter stripped.
const copies = [
['.cursor/rules/ponytail.mdc', stripFrontmatter],
['.windsurf/rules/ponytail.md', text => text.trim()],
['.clinerules/ponytail.md', text => text.trim()],
['.github/copilot-instructions.md', text => text.trim()],
['.kiro/steering/ponytail.md', stripFrontmatter],
];
let failed = false;
for (const [relPath, normalize] of copies) {
const actual = normalize(read(relPath));
if (actual !== canonical) {
console.error(`${relPath} drifted from AGENTS.md`);
failed = true;
}
}
// SKILL.md is the runtime source of truth and is longer than the compact body,
// so it cannot be byte-compared. ponytail: canary, not full equality. Assert the
// load-bearing rules survive verbatim in both the source and AGENTS.md. Changing
// a rule's wording trips this, which is the reminder to propagate it everywhere.
// Upgrade path: generate the copies from SKILL.md if this ever misses a real drift.
const INVARIANTS = [
'naive heuristic', // ceiling-comment rule
'ONE runnable check', // test reflex
'flimsier algorithm', // robust-variant rule
'input validation at trust boundaries', // the "not lazy about" clause
];
const skill = read('skills/ponytail/SKILL.md');
const sources = [['skills/ponytail/SKILL.md', skill], ['AGENTS.md', agents]];
for (const phrase of INVARIANTS) {
for (const [label, text] of sources) {
if (!text.includes(phrase)) {
console.error(`${label} is missing rule invariant: "${phrase}"`);
failed = true;
}
}
}
if (failed) {
console.error('Update the copied rule text, AGENTS.md, or SKILL.md so the shared rules match.');
process.exit(1);
}
console.log(`Rule copies match AGENTS.md; ${INVARIANTS.length} rule invariants present in SKILL.md and AGENTS.md.`);
+3
View File
@@ -29,6 +29,9 @@ Level sticks until changed or session end.
| **ponytail-review** | `/ponytail-review` | Over-engineering review: `L42: yagni: factory, one product. Inline.` | | **ponytail-review** | `/ponytail-review` | Over-engineering review: `L42: yagni: factory, one product. Inline.` |
| **ponytail-help** | `/ponytail-help` | This card. | | **ponytail-help** | `/ponytail-help` | This card. |
Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code
uses the slash-command forms above.
## Deactivate ## Deactivate
Say "stop ponytail" or "normal mode". Resume anytime with `/ponytail`. Say "stop ponytail" or "normal mode". Resume anytime with `/ponytail`.
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env node
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const root = path.join(__dirname, '..');
function run(script, env, input = '') {
return spawnSync(process.execPath, [path.join(root, 'hooks', script)], {
env: { ...process.env, ...env },
input,
encoding: 'utf8',
});
}
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-hooks-'));
const home = path.join(temp, 'home');
const pluginData = path.join(temp, 'plugin-data');
fs.mkdirSync(home, { recursive: true });
// USERPROFILE alongside HOME: os.homedir() reads USERPROFILE on Windows, HOME on POSIX.
const codexEnv = {
HOME: home,
USERPROFILE: home,
PLUGIN_DATA: pluginData,
PONYTAIL_DEFAULT_MODE: 'ultra',
};
const codexState = path.join(pluginData, '.ponytail-active');
let result = run('ponytail-activate.js', codexEnv);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(codexState, 'utf8'), 'ultra');
let output = JSON.parse(result.stdout);
assert.equal(output.systemMessage, 'PONYTAIL:ULTRA');
assert.match(
output.hookSpecificOutput.additionalContext,
/PONYTAIL MODE ACTIVE — level: ultra/,
);
result = run(
'ponytail-mode-tracker.js',
codexEnv,
JSON.stringify({ prompt: '@ponytail lite' }),
);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.readFileSync(codexState, 'utf8'), 'lite');
output = JSON.parse(result.stdout);
assert.equal(output.systemMessage, 'PONYTAIL:LITE');
result = run(
'ponytail-mode-tracker.js',
codexEnv,
JSON.stringify({ prompt: 'normal mode' }),
);
assert.equal(result.status, 0, result.stderr);
assert.equal(fs.existsSync(codexState), false);
output = JSON.parse(result.stdout);
assert.equal(output.systemMessage, 'PONYTAIL:OFF');
const claudeEnv = {
HOME: home,
USERPROFILE: home,
PONYTAIL_DEFAULT_MODE: 'full',
};
delete claudeEnv.PLUGIN_DATA;
result = run('ponytail-activate.js', claudeEnv);
assert.equal(result.status, 0, result.stderr);
assert.equal(
fs.readFileSync(path.join(home, '.claude', '.ponytail-active'), 'utf8'),
'full',
);
fs.rmSync(temp, { recursive: true, force: true });
console.log('hook compatibility checks passed');