Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c279cbfb3 | ||
|
|
de318b9457 | ||
|
|
784e8bfd1c | ||
|
|
bce2162025 | ||
|
|
515fb4c5a4 | ||
|
|
e37b823b92 | ||
|
|
e89a4e9863 | ||
|
|
2f2a0d33e0 | ||
|
|
c16f967d37 |
@@ -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,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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}` };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "ponytail-pi-extension-dev",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test ./test/*.test.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}));
|
||||||
@@ -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"));
|
||||||
|
});
|
||||||
@@ -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.`);
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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');
|
||||||
Reference in New Issue
Block a user