Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b545f1536a | ||
|
|
01578c0cd4 | ||
|
|
6d990f8c54 | ||
|
|
94d231cd32 | ||
|
|
16319c7bc9 | ||
|
|
e01aa900f7 | ||
|
|
147bcfd621 | ||
|
|
92efc4a648 | ||
|
|
0882e2d256 | ||
|
|
82cff4bcd2 | ||
|
|
004256cdc6 | ||
|
|
88431defba | ||
|
|
6abc9f0acc | ||
|
|
321a59c82f | ||
|
|
93f3ac1d76 | ||
|
|
24b0b98e16 | ||
|
|
46c5c28b35 | ||
|
|
1556f10bc6 | ||
|
|
c15db8d3c9 | ||
|
|
8c279cbfb3 | ||
|
|
de318b9457 | ||
|
|
784e8bfd1c | ||
|
|
bce2162025 | ||
|
|
515fb4c5a4 | ||
|
|
e37b823b92 | ||
|
|
e89a4e9863 | ||
|
|
2f2a0d33e0 | ||
|
|
c16f967d37 | ||
|
|
cf97ccc509 | ||
|
|
2d91f6a957 | ||
|
|
9c99843725 | ||
|
|
b609e019a0 | ||
|
|
983255e2a1 | ||
|
|
cbb8859f39 |
@@ -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,34 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "ponytail",
|
"name": "ponytail",
|
||||||
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works — YAGNI, stdlib first, no unrequested abstractions.",
|
"version": "4.3.0",
|
||||||
|
"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",
|
||||||
"url": "https://github.com/DietrichGebert"
|
"url": "https://github.com/DietrichGebert"
|
||||||
},
|
|
||||||
"hooks": {
|
|
||||||
"SessionStart": [
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js",
|
|
||||||
"timeout": 5,
|
|
||||||
"statusMessage": "Loading ponytail mode..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"UserPromptSubmit": [
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js",
|
|
||||||
"timeout": 5,
|
|
||||||
"statusMessage": "Tracking ponytail mode..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Ponytail — lazy senior dev mode
|
# Ponytail, lazy senior dev mode
|
||||||
|
|
||||||
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
|
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ Rules:
|
|||||||
- No boilerplate nobody asked for.
|
- No boilerplate nobody asked for.
|
||||||
- Deletion over addition. Boring over clever. Fewest files possible.
|
- Deletion over addition. Boring over clever. Fewest files possible.
|
||||||
- Question complex requests: "Do you actually need X, or does Y cover it?"
|
- Question complex requests: "Do you actually need X, or does Y cover it?"
|
||||||
- Mark intentional simplifications with a `ponytail:` comment.
|
- 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.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "ponytail",
|
||||||
|
"version": "4.3.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
description: Ponytail — lazy senior dev mode. Always pick the simplest solution that works.
|
description: Ponytail, lazy senior dev mode. Always pick the simplest solution that works.
|
||||||
globs:
|
globs:
|
||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Ponytail — lazy senior dev mode
|
# Ponytail, lazy senior dev mode
|
||||||
|
|
||||||
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
|
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ Rules:
|
|||||||
- No boilerplate nobody asked for.
|
- No boilerplate nobody asked for.
|
||||||
- Deletion over addition. Boring over clever. Fewest files possible.
|
- Deletion over addition. Boring over clever. Fewest files possible.
|
||||||
- Question complex requests: "Do you actually need X, or does Y cover it?"
|
- Question complex requests: "Do you actually need X, or does Y cover it?"
|
||||||
- Mark intentional simplifications with a `ponytail:` comment.
|
- 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.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Copy to .env (gitignored) and fill in. promptfoo reads this automatically.
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
github: [DietrichGebert]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Ponytail — lazy senior dev mode
|
# Ponytail, lazy senior dev mode
|
||||||
|
|
||||||
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
|
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ Rules:
|
|||||||
- No boilerplate nobody asked for.
|
- No boilerplate nobody asked for.
|
||||||
- Deletion over addition. Boring over clever. Fewest files possible.
|
- Deletion over addition. Boring over clever. Fewest files possible.
|
||||||
- Question complex requests: "Do you actually need X, or does Y cover it?"
|
- Question complex requests: "Do you actually need X, or does Y cover it?"
|
||||||
- Mark intentional simplifications with a `ponytail:` comment.
|
- 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.
|
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.
|
||||||
|
|||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Secrets, never commit API keys
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# promptfoo eval artifacts
|
||||||
|
.promptfoo/
|
||||||
|
benchmarks/output*
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
description: Audit the whole repo for over-engineering, what can be deleted
|
||||||
|
---
|
||||||
|
|
||||||
|
Audit the entire repository for over-engineering only, not correctness. Scan the whole tree, not a diff. One line per finding, ranked biggest cut first: <tag> <what to cut>. <replacement>. [path]. Tags: delete (dead code/speculative feature), stdlib (reinvented standard library), native (dependency doing what the platform does), yagni (abstraction with one implementation), shrink (same logic, fewer lines). End with the net lines and dependencies removable. If nothing to cut: 'Lean already. Ship.'
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
description: Review changes for over-engineering, what can be deleted
|
||||||
|
---
|
||||||
|
|
||||||
|
Review the current code changes for over-engineering only, not correctness. One line per finding: L<line>: <tag> <what to cut>. <replacement>. Tags: delete (dead code/speculative feature), stdlib (reinvented standard library), native (dependency doing what the platform does), yagni (abstraction with one implementation), shrink (same logic, fewer lines). End with the net lines removable. If nothing to cut: 'Lean already. Ship.'
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
description: Switch ponytail intensity level (lite/full/ultra/off)
|
||||||
|
---
|
||||||
|
|
||||||
|
Switch to ponytail $ARGUMENTS mode. If no level specified, use full. Lazy senior dev mode, before any code: does it need to exist at all (YAGNI)? Does the standard library do it? A native platform feature? Can it be one line? Build the minimum that works. No unrequested abstractions, no avoidable dependencies, no boilerplate. Mark intentional simplifications with a ponytail: comment.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// ponytail — OpenCode plugin.
|
||||||
|
//
|
||||||
|
// Injects the ponytail ruleset into every chat's system prompt at the active
|
||||||
|
// intensity, and persists /ponytail mode switches. Reuses the shared instruction
|
||||||
|
// builder so Claude Code, Codex, pi, and OpenCode all read one source of truth.
|
||||||
|
//
|
||||||
|
// OpenCode loads this as a server plugin — add it to your opencode.json:
|
||||||
|
// { "plugin": ["./.opencode/plugins/ponytail.mjs"] }
|
||||||
|
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// The shared instruction builder is CommonJS; bridge to it from this ES module.
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { getPonytailInstructions } = require('../../hooks/ponytail-instructions');
|
||||||
|
const { getDefaultMode, normalizePersistedMode } = require('../../hooks/ponytail-config');
|
||||||
|
|
||||||
|
// OpenCode has no flag-file convention of its own; keep mode beside its config.
|
||||||
|
const statePath = path.join(
|
||||||
|
process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
|
||||||
|
'opencode',
|
||||||
|
'.ponytail-active',
|
||||||
|
);
|
||||||
|
|
||||||
|
function readMode() {
|
||||||
|
try {
|
||||||
|
return normalizePersistedMode(fs.readFileSync(statePath, 'utf8').trim()) || getDefaultMode();
|
||||||
|
} catch (e) {
|
||||||
|
return getDefaultMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMode(mode) {
|
||||||
|
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
||||||
|
fs.writeFileSync(statePath, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async ({ client } = {}) => {
|
||||||
|
const log = (level, message) => {
|
||||||
|
try { client && client.app && client.app.log({ body: { service: 'ponytail', level, message } }); } catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Append the ruleset to the system prompt every turn.
|
||||||
|
'experimental.chat.system.transform': async (_input, output) => {
|
||||||
|
const mode = readMode();
|
||||||
|
if (mode === 'off') return;
|
||||||
|
output.system.push(getPonytailInstructions(mode));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Persist `/ponytail <level>` so the next turn's injection follows it.
|
||||||
|
// ponytail: mode applies from the next message, not the current one — the
|
||||||
|
// transform reads the flag the command writes. Good enough; switch to a
|
||||||
|
// synchronous store if same-turn switching ever matters.
|
||||||
|
'command.execute.before': async (input) => {
|
||||||
|
if (!input || input.command !== 'ponytail') return;
|
||||||
|
// `off` is persisted like any mode; the transform reads it and stays silent.
|
||||||
|
const mode = normalizePersistedMode((input.arguments || '').trim()) || getDefaultMode();
|
||||||
|
writeMode(mode);
|
||||||
|
log('info', 'ponytail ' + mode);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Ponytail — lazy senior dev mode
|
# Ponytail, lazy senior dev mode
|
||||||
|
|
||||||
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
|
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ Rules:
|
|||||||
- No boilerplate nobody asked for.
|
- No boilerplate nobody asked for.
|
||||||
- Deletion over addition. Boring over clever. Fewest files possible.
|
- Deletion over addition. Boring over clever. Fewest files possible.
|
||||||
- Question complex requests: "Do you actually need X, or does Y cover it?"
|
- Question complex requests: "Do you actually need X, or does Y cover it?"
|
||||||
- Mark intentional simplifications with a `ponytail:` comment.
|
- 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.
|
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.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Ponytail — lazy senior dev mode
|
# Ponytail, lazy senior dev mode
|
||||||
|
|
||||||
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
|
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
|
||||||
|
|
||||||
@@ -18,8 +18,9 @@ Rules:
|
|||||||
- No boilerplate nobody asked for.
|
- No boilerplate nobody asked for.
|
||||||
- Deletion over addition. Boring over clever. Fewest files possible.
|
- Deletion over addition. Boring over clever. Fewest files possible.
|
||||||
- Question complex requests: "Do you actually need X, or does Y cover it?"
|
- Question complex requests: "Do you actually need X, or does Y cover it?"
|
||||||
- Mark intentional simplifications with a `ponytail:` comment.
|
- 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.
|
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.
|
||||||
|
|
||||||
(Yes, this file also applies to agents working on the ponytail repo itself. Especially to them.)
|
(Yes, this file also applies to agents working on the ponytail repo itself. Especially to them.)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/logo.png" width="220" alt="Ponytail — the lazy senior dev">
|
<img src="assets/logo.png" width="220" alt="Ponytail, the lazy senior dev">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">Ponytail</h1>
|
<h1 align="center">Ponytail</h1>
|
||||||
@@ -8,6 +8,18 @@
|
|||||||
<em>He says nothing. He writes one line. It works.</em>
|
<em>He says nothing. He writes one line. It works.</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat-square&color=111111&label=stars" alt="Stars">
|
||||||
|
<img src="https://img.shields.io/github/v/release/DietrichGebert/ponytail?style=flat-square&color=111111&label=release" alt="Release">
|
||||||
|
<img src="https://img.shields.io/badge/works%20with-11%20agents-111111?style=flat-square" alt="Works with 11 agents">
|
||||||
|
<img src="https://img.shields.io/badge/license-MIT-111111?style=flat-square" alt="MIT license">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>80-94% less code · 3-6× faster · 47-77% cheaper</strong><br>
|
||||||
|
<sub>Median of 10 runs across Haiku, Sonnet, and Opus. <a href="benchmarks/">Reproduce it yourself.</a></sub>
|
||||||
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
You know him. Long ponytail. Oval glasses. Has been at the company longer than the version control. You show him fifty lines; he looks at them, says nothing, and replaces them with one.
|
You know him. Long ponytail. Oval glasses. Has been at the company longer than the version control. You show him fifty lines; he looks at them, says nothing, and replaces them with one.
|
||||||
@@ -27,6 +39,16 @@ With ponytail:
|
|||||||
|
|
||||||
More survivors in [examples/](examples/).
|
More survivors in [examples/](examples/).
|
||||||
|
|
||||||
|
## Numbers
|
||||||
|
|
||||||
|
Five everyday tasks (email validator, debounce, CSV sum, countdown timer, rate limiter), three models, three arms: no skill, the [caveman](https://github.com/JuliusBrussee/caveman) skill, and ponytail. Ten runs per cell, median reported.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/benchmark-3model.svg" width="860" alt="Median lines of code per arm across Haiku, Sonnet and Opus; ponytail writes 80-94% less code than the no-skill baseline">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**80-94% less code, 47-77% less cost, and 3-6× faster than a no-skill agent, on every model.** Every shortcut ponytail takes is marked in the code with a `ponytail:` comment naming its upgrade path. Reproduce it yourself: `npx promptfoo eval -c benchmarks/promptfooconfig.yaml`. Method and raw numbers: [benchmarks/](benchmarks/). Production-grade tasks, where an unconstrained agent bloats far more, are written up in [benchmarks/results/](benchmarks/results/).
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
Before writing code, the agent stops at the first rung that holds:
|
Before writing code, the agent stops at the first rung that holds:
|
||||||
@@ -46,16 +68,72 @@ 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenCode
|
||||||
|
|
||||||
|
Run OpenCode from a checkout of this repo (the plugin reuses its `hooks/` and `skills/`), and add to `opencode.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "plugin": ["./.opencode/plugins/ponytail.mjs"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Injects the ruleset every turn at the active level; adds `/ponytail`, `/ponytail-review`, and `/ponytail-audit`. OpenCode also auto-loads this repo's `AGENTS.md`, so the rules hold even without the plugin. The plugin adds the `lite/full/ultra/off` levels.
|
||||||
|
|
||||||
|
### Gemini CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gemini extensions install https://github.com/DietrichGebert/ponytail
|
||||||
|
```
|
||||||
|
|
||||||
|
Loads the ruleset as always-on context every session and registers `/ponytail` and `/ponytail-review`; the `skills/` ship too, activated when a task needs them.
|
||||||
|
|
||||||
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-audit` does the same for the whole repo. `/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`,
|
||||||
|
`@ponytail-audit`, 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.
|
||||||
|
|
||||||
|
GitHub Copilot CLI: it already reads `AGENTS.md` and `.github/copilot-instructions.md` in a project, or copy the rules into `~/.copilot/copilot-instructions.md` to run ponytail in every project.
|
||||||
|
|
||||||
|
Antigravity and VS Code with the Codex extension: both read `AGENTS.md`, which this repo ships, so it works from the repo root with no setup (`~/.codex/AGENTS.md` makes Codex global, `.agents/rules/` makes it an always-on rule in Antigravity).
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@@ -63,7 +141,7 @@ Cursor, Windsurf, Cline, Copilot, Aider: copy the matching rules file from this
|
|||||||
No.
|
No.
|
||||||
|
|
||||||
**What if I really need the 120-line cache class?**
|
**What if I really need the 120-line cache class?**
|
||||||
You don't. Insist anyway and he'll build it — slowly, correctly, while looking at you.
|
You don't. Insist anyway and he'll build it. Slowly. Correctly. While looking at you.
|
||||||
|
|
||||||
**Does it scale?**
|
**Does it scale?**
|
||||||
The code you never wrote scales infinitely. Zero bugs, zero CVEs, 100% uptime since forever.
|
The code you never wrote scales infinitely. Zero bugs, zero CVEs, 100% uptime since forever.
|
||||||
@@ -71,10 +149,6 @@ The code you never wrote scales infinitely. Zero bugs, zero CVEs, 100% uptime si
|
|||||||
**Why "ponytail"?**
|
**Why "ponytail"?**
|
||||||
You know exactly why.
|
You know exactly why.
|
||||||
|
|
||||||
## Numbers
|
|
||||||
|
|
||||||
5 coding tasks, same agent with and without ponytail: **−16% tokens, ~4× faster, 293 → 47 lines.** The 246 lines nobody wrote have never caused an incident. Data: [benchmarks/](benchmarks/).
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](LICENSE). The shortest license that works.
|
[MIT](LICENSE). The shortest license that works.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<svg viewBox="0 0 860 336" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif">
|
||||||
|
<title>Median lines of code per arm across three models</title>
|
||||||
|
<text x="20" y="26" font-size="15" font-weight="600" fill="#8b949e">Median lines of code. 10 runs per cell. Lower is leaner.</text>
|
||||||
|
<text x="20" y="45" font-size="12" fill="#8b949e" opacity="0.85">Ponytail writes 80-94% less code, costs 47-77% less, and runs 3-6x faster than a no-skill agent.</text>
|
||||||
|
<rect x="20" y="58" width="12" height="12" rx="2" fill="#8b949e"/><text x="38" y="69" font-size="13" fill="#8b949e">baseline (no skill)</text>
|
||||||
|
<rect x="190" y="58" width="12" height="12" rx="2" fill="#d9822b"/><text x="208" y="69" font-size="13" fill="#8b949e">caveman</text>
|
||||||
|
<rect x="300" y="58" width="12" height="12" rx="2" fill="#2da44e"/><text x="318" y="69" font-size="13" fill="#8b949e">ponytail</text>
|
||||||
|
<text x="112" y="119" font-size="13" font-weight="600" fill="#8b949e" text-anchor="end">Haiku</text>
|
||||||
|
<rect x="120" y="92" width="508" height="14" rx="2" fill="#8b949e"/><text x="634" y="103" font-size="11" fill="#8b949e">518</text>
|
||||||
|
<rect x="120" y="110" width="114" height="14" rx="2" fill="#d9822b"/><text x="240" y="121" font-size="11" fill="#d9822b">116</text>
|
||||||
|
<rect x="120" y="128" width="38" height="14" rx="2" fill="#2da44e"/><text x="164" y="139" font-size="11" fill="#2da44e" font-weight="600">39</text>
|
||||||
|
<text x="112" y="193" font-size="13" font-weight="600" fill="#8b949e" text-anchor="end">Sonnet</text>
|
||||||
|
<rect x="120" y="166" width="680" height="14" rx="2" fill="#8b949e"/><text x="806" y="177" font-size="11" fill="#8b949e">693</text>
|
||||||
|
<rect x="120" y="184" width="118" height="14" rx="2" fill="#d9822b"/><text x="244" y="195" font-size="11" fill="#d9822b">120</text>
|
||||||
|
<rect x="120" y="202" width="43" height="14" rx="2" fill="#2da44e"/><text x="169" y="213" font-size="11" fill="#2da44e" font-weight="600">44</text>
|
||||||
|
<text x="112" y="267" font-size="13" font-weight="600" fill="#8b949e" text-anchor="end">Opus</text>
|
||||||
|
<rect x="120" y="240" width="251" height="14" rx="2" fill="#8b949e"/><text x="377" y="251" font-size="11" fill="#8b949e">256</text>
|
||||||
|
<rect x="120" y="258" width="66" height="14" rx="2" fill="#d9822b"/><text x="192" y="269" font-size="11" fill="#d9822b">67</text>
|
||||||
|
<rect x="120" y="276" width="50" height="14" rx="2" fill="#2da44e"/><text x="176" y="287" font-size="11" fill="#2da44e" font-weight="600">51</text>
|
||||||
|
<text x="120" y="324" font-size="11" fill="#8b949e" opacity="0.8">Median of 10 runs/cell, default temperature. 5 tasks (email, debounce, CSV sum, countdown, rate-limit), same model per group. Reproduce: npx promptfoo eval -c benchmarks/promptfooconfig.yaml</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,62 @@
|
|||||||
|
# Benchmark
|
||||||
|
|
||||||
|
Three arms (no skill, [caveman](https://github.com/JuliusBrussee/caveman), ponytail), three models, five everyday tasks, **10 runs per cell, median reported**. Code LOC is counted from fenced code blocks; tokens, cost, and latency come straight from the API.
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp ../.env.example ../.env # add your ANTHROPIC_API_KEY
|
||||||
|
npx promptfoo@latest eval -c promptfooconfig.yaml --repeat 10
|
||||||
|
npx promptfoo@latest view
|
||||||
|
```
|
||||||
|
|
||||||
|
Tasks: email validator, JS debounce, CSV sum, React countdown, FastAPI rate-limit (see `promptfooconfig.yaml`). Single-shot completions, default temperature.
|
||||||
|
|
||||||
|
## Median results (10 runs, 2026-06-13)
|
||||||
|
|
||||||
|
**Code (lines)**
|
||||||
|
|
||||||
|
| arm | Haiku | Sonnet | Opus |
|
||||||
|
|---|--:|--:|--:|
|
||||||
|
| baseline (no skill) | 518 | 693 | 256 |
|
||||||
|
| caveman | 116 | 120 | 67 |
|
||||||
|
| **ponytail** | **39** | **44** | **51** |
|
||||||
|
|
||||||
|
**Cost (USD, 5 tasks)**
|
||||||
|
|
||||||
|
| arm | Haiku | Sonnet | Opus |
|
||||||
|
|---|--:|--:|--:|
|
||||||
|
| baseline (no skill) | 0.032 | 0.141 | 0.135 |
|
||||||
|
| caveman | 0.014 | 0.045 | 0.075 |
|
||||||
|
| **ponytail** | **0.010** | **0.032** | **0.071** |
|
||||||
|
|
||||||
|
**Latency (seconds, 5 tasks)**
|
||||||
|
|
||||||
|
| arm | Haiku | Sonnet | Opus |
|
||||||
|
|---|--:|--:|--:|
|
||||||
|
| baseline (no skill) | 37.7 | 124.1 | 58.7 |
|
||||||
|
| caveman | 14.9 | 34.7 | 23.1 |
|
||||||
|
| **ponytail** | **9.9** | **20.1** | **18.0** |
|
||||||
|
|
||||||
|
Versus baseline, ponytail writes **80-94% less code**, costs **47-77% less**, and runs **3-6x faster**, on every model.
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
| File | Metric | Behavior |
|
||||||
|
|------|--------|----------|
|
||||||
|
| `loc.js` | `loc` | Measurement - always passes, records line count |
|
||||||
|
| `correctness.js` | `correct` | Gate - fails if generated code doesn't work |
|
||||||
|
|
||||||
|
`correctness.js` extracts fenced code blocks and runs per-task checks (spawns Python/Node for email, debounce, CSV; structural regex for React and FastAPI). A broken one-liner that scores great on LOC will fail on correctness.
|
||||||
|
|
||||||
|
> **Note:** The React countdown and FastAPI rate-limit checks are keyword/structural only (no runtime execution), so they verify plausible structure rather than full correctness. The email, debounce, and CSV checks execute the code.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
Running the benchmark requires **Python 3**, **pandas**, and **Node.js** (18+).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Caveman is a prose-compression skill (it leaves code "normal"), so it lands between baseline and ponytail on code size and wins mainly on prose tokens.
|
||||||
|
- Cost reflects single-shot calls that re-send the skill every time. In real sessions the skill is injected once and prompt-cached, so the cost gap widens further in ponytail's favor.
|
||||||
|
- These are everyday tasks. For production-grade specs, where an unconstrained agent bloats much harder, see the writeups in `results/`.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Baseline arm: no skill, just the task.
|
||||||
|
module.exports = ({ vars }) => [{ role: 'user', content: vars.task }];
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: caveman
|
||||||
|
description: >
|
||||||
|
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
|
||||||
|
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
|
||||||
|
wenyan-lite, wenyan-full, wenyan-ultra.
|
||||||
|
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
|
||||||
|
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
|
||||||
|
---
|
||||||
|
|
||||||
|
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".
|
||||||
|
|
||||||
|
Default: **full**. Switch: `/caveman lite|full|ultra`.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
||||||
|
|
||||||
|
Pattern: `[thing] [action] [reason]. [next step].`
|
||||||
|
|
||||||
|
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
||||||
|
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
||||||
|
|
||||||
|
## Intensity
|
||||||
|
|
||||||
|
| Level | What change |
|
||||||
|
|-------|------------|
|
||||||
|
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
|
||||||
|
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
|
||||||
|
| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough |
|
||||||
|
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
|
||||||
|
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
|
||||||
|
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
|
||||||
|
|
||||||
|
Example — "Why React component re-render?"
|
||||||
|
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
|
||||||
|
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
||||||
|
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
|
||||||
|
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
|
||||||
|
- wenyan-full: "物出新參照,致重繪。useMemo .Wrap之。"
|
||||||
|
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
|
||||||
|
|
||||||
|
Example — "Explain database connection pooling."
|
||||||
|
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
|
||||||
|
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
|
||||||
|
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
|
||||||
|
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
|
||||||
|
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
|
||||||
|
|
||||||
|
## Auto-Clarity
|
||||||
|
|
||||||
|
Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done.
|
||||||
|
|
||||||
|
Example — destructive op:
|
||||||
|
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
|
||||||
|
> ```sql
|
||||||
|
> DROP TABLE users;
|
||||||
|
> ```
|
||||||
|
> Caveman resume. Verify backup exist first.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Caveman arm: caveman SKILL.md (full) as the system prompt.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const system = fs.readFileSync(path.join(__dirname, 'caveman-SKILL.md'), 'utf8');
|
||||||
|
module.exports = ({ vars }) => [
|
||||||
|
{ role: 'system', content: system },
|
||||||
|
{ role: 'user', content: vars.task },
|
||||||
|
];
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Ponytail arm: the repo's own SKILL.md (full) as the system prompt. Single source of truth.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const system = fs.readFileSync(path.join(__dirname, '..', '..', 'skills', 'ponytail', 'SKILL.md'), 'utf8');
|
||||||
|
module.exports = ({ vars }) => [
|
||||||
|
{ role: 'system', content: system },
|
||||||
|
{ role: 'user', content: vars.task },
|
||||||
|
];
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
// Functional correctness assertion: runs generated code against lightweight test
|
||||||
|
// cases per task. Proves "less code" is not "broken code". Spawns python/node
|
||||||
|
// with the extracted code + appended assertions; returns pass/fail + score.
|
||||||
|
//
|
||||||
|
// Metric: `correct` (1 = all checks pass, 0 = at least one fails).
|
||||||
|
// Unlike loc.js (measurement-only), this one is a gate — a wrong answer is a
|
||||||
|
// wrong answer regardless of how few lines produced it.
|
||||||
|
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Extract fenced code blocks, tagged by language.
|
||||||
|
function extractBlocks(text) {
|
||||||
|
const matches = [...text.matchAll(/```(\w*)\n([\s\S]*?)```/g)];
|
||||||
|
return matches.map((m) => ({ lang: (m[1] || '').toLowerCase(), code: m[2] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify which task we're evaluating from vars.task.
|
||||||
|
function identifyTask(task) {
|
||||||
|
const t = task.toLowerCase();
|
||||||
|
if (t.includes('email') && t.includes('valid')) return 'email';
|
||||||
|
if (t.includes('debounce')) return 'debounce';
|
||||||
|
if (t.includes('csv') && t.includes('sum')) return 'csv';
|
||||||
|
if (t.includes('countdown') && t.includes('react')) return 'countdown';
|
||||||
|
if (t.includes('rate limit') || t.includes('rate-limit')) return 'ratelimit';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a command, return { ok, stderr }.
|
||||||
|
function exec(cmd, opts = {}) {
|
||||||
|
try {
|
||||||
|
execSync(cmd, { timeout: 10_000, encoding: 'utf8', stdio: 'pipe', ...opts });
|
||||||
|
return { ok: true, stderr: '' };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, stderr: (e.stderr || e.message || '').slice(0, 500) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write content to a temp file, return the path.
|
||||||
|
function tmpFile(ext, content) {
|
||||||
|
const p = path.join(os.tmpdir(), `ponytail-bench-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
|
||||||
|
fs.writeFileSync(p, content);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-task test harnesses ---
|
||||||
|
|
||||||
|
const CHECKS = {
|
||||||
|
email(blocks) {
|
||||||
|
const code = blocks.find((b) => b.lang === 'python' || b.lang === 'py' || (!b.lang && b.code.includes('def ')));
|
||||||
|
if (!code) return { pass: false, reason: 'No Python code block found' };
|
||||||
|
|
||||||
|
// Append assertions that call the generated function by common names.
|
||||||
|
const harness = `
|
||||||
|
${code.code}
|
||||||
|
|
||||||
|
# Find the validator function
|
||||||
|
import sys
|
||||||
|
fn = None
|
||||||
|
for name in ['validate_email', 'is_valid_email', 'email_validator', 'is_valid', 'validate']:
|
||||||
|
if name in dir() and callable(eval(name)):
|
||||||
|
fn = eval(name)
|
||||||
|
break
|
||||||
|
|
||||||
|
if fn is None:
|
||||||
|
# Try any function that takes one arg
|
||||||
|
import inspect
|
||||||
|
for name, obj in list(globals().items()):
|
||||||
|
if callable(obj) and not name.startswith('_'):
|
||||||
|
try:
|
||||||
|
sig = inspect.signature(obj)
|
||||||
|
if len(sig.parameters) == 1:
|
||||||
|
fn = obj
|
||||||
|
break
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if fn is None:
|
||||||
|
print("FAIL: no validator function found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Test cases
|
||||||
|
failures = []
|
||||||
|
if not fn("user@example.com"):
|
||||||
|
failures.append("rejected valid: user@example.com")
|
||||||
|
if not fn("a@b.co"):
|
||||||
|
failures.append("rejected valid: a@b.co")
|
||||||
|
if fn("no-at-sign"):
|
||||||
|
failures.append("accepted invalid: no-at-sign")
|
||||||
|
if fn(""):
|
||||||
|
failures.append("accepted invalid: empty string")
|
||||||
|
if fn("@missing-local.com"):
|
||||||
|
failures.append("accepted invalid: @missing-local.com")
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print("FAIL: " + "; ".join(failures))
|
||||||
|
sys.exit(1)
|
||||||
|
print("PASS")
|
||||||
|
`;
|
||||||
|
const f = tmpFile('.py', harness);
|
||||||
|
const result = exec(`python "${f}"`);
|
||||||
|
fs.unlinkSync(f);
|
||||||
|
if (result.ok) return { pass: true, reason: 'Email validator passes all checks' };
|
||||||
|
return { pass: false, reason: result.stderr || 'Email validator failed' };
|
||||||
|
},
|
||||||
|
|
||||||
|
debounce(blocks) {
|
||||||
|
const code = blocks.find((b) => b.lang === 'javascript' || b.lang === 'js' || (!b.lang && b.code.includes('function')));
|
||||||
|
if (!code) return { pass: false, reason: 'No JavaScript code block found' };
|
||||||
|
|
||||||
|
const harness = `
|
||||||
|
${code.code}
|
||||||
|
|
||||||
|
// Find the debounce function
|
||||||
|
const fn = typeof debounce === 'function' ? debounce
|
||||||
|
: typeof module !== 'undefined' && typeof module.exports === 'function' ? module.exports
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!fn) {
|
||||||
|
console.error("FAIL: no debounce function found");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: debounced function should not fire immediately
|
||||||
|
let callCount = 0;
|
||||||
|
const debounced = fn(() => { callCount++; }, 50);
|
||||||
|
debounced();
|
||||||
|
debounced();
|
||||||
|
debounced();
|
||||||
|
|
||||||
|
if (callCount > 0) {
|
||||||
|
console.error("FAIL: debounce fired immediately (should wait)");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: should fire after the delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (callCount !== 1) {
|
||||||
|
console.error("FAIL: expected 1 call after delay, got " + callCount);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("PASS");
|
||||||
|
}, 120);
|
||||||
|
`;
|
||||||
|
const f = tmpFile('.mjs', harness);
|
||||||
|
const result = exec(`node "${f}"`);
|
||||||
|
fs.unlinkSync(f);
|
||||||
|
if (result.ok) return { pass: true, reason: 'Debounce passes all checks' };
|
||||||
|
return { pass: false, reason: result.stderr || 'Debounce failed' };
|
||||||
|
},
|
||||||
|
|
||||||
|
csv(blocks) {
|
||||||
|
const code = blocks.find((b) => b.lang === 'python' || b.lang === 'py' || (!b.lang && b.code.includes('csv') && b.code.includes('sum')));
|
||||||
|
if (!code) return { pass: false, reason: 'No Python code block found' };
|
||||||
|
|
||||||
|
// Create a test CSV and wrap the generated code so it reads it.
|
||||||
|
const csvContent = 'name,amount\nAlice,100.5\nBob,200.0\nCharlie,50.5\n';
|
||||||
|
const csvPath = tmpFile('.csv', csvContent).replace(/\\/g, '/');
|
||||||
|
|
||||||
|
// The generated code likely reads 'sales.csv'; patch the filename.
|
||||||
|
let patched = code.code.replace(/['"]sales\.csv['"]/g, `'${csvPath}'`);
|
||||||
|
// Also try open() calls
|
||||||
|
patched = patched.replace(/open\(\s*['"]sales\.csv['"]/g, `open('${csvPath}'`);
|
||||||
|
|
||||||
|
const harness = `
|
||||||
|
import sys, os
|
||||||
|
os.chdir(r"${path.dirname(csvPath)}")
|
||||||
|
|
||||||
|
# Capture print output
|
||||||
|
import io
|
||||||
|
_stdout = sys.stdout
|
||||||
|
sys.stdout = io.StringIO()
|
||||||
|
|
||||||
|
try:
|
||||||
|
${patched.split('\n').map((l) => ' ' + l).join('\n')}
|
||||||
|
except Exception as e:
|
||||||
|
sys.stdout = _stdout
|
||||||
|
# If it needs sales.csv in cwd, write it there and retry
|
||||||
|
pass
|
||||||
|
|
||||||
|
output = sys.stdout.getvalue()
|
||||||
|
sys.stdout = _stdout
|
||||||
|
|
||||||
|
# Check output contains the number 351 (100.5 + 200.0 + 50.5)
|
||||||
|
# Match as a standalone number (not as substring of e.g. 13510)
|
||||||
|
import re
|
||||||
|
if re.search(r'(?<![\\d])351(?:\\.0)?(?![\\d])', output):
|
||||||
|
print("PASS")
|
||||||
|
else:
|
||||||
|
# Try running it differently: maybe it defines a function
|
||||||
|
print("FAIL: output was: " + repr(output[:200]))
|
||||||
|
sys.exit(1)
|
||||||
|
`;
|
||||||
|
const f = tmpFile('.py', harness);
|
||||||
|
const result = exec(`python "${f}"`);
|
||||||
|
try { fs.unlinkSync(f); } catch (e) {}
|
||||||
|
try { fs.unlinkSync(csvPath); } catch (e) {}
|
||||||
|
if (result.ok) return { pass: true, reason: 'CSV sum produces correct result (351)' };
|
||||||
|
return { pass: false, reason: result.stderr || 'CSV sum failed' };
|
||||||
|
},
|
||||||
|
|
||||||
|
countdown(blocks) {
|
||||||
|
// React components can't run in bare Node without a bundler. Structural check:
|
||||||
|
// the code must contain timer/countdown logic (useState/useEffect/setInterval/setTimeout).
|
||||||
|
const code = blocks.find((b) => b.code.includes('ount') || b.code.includes('timer') || b.code.includes('Timer'));
|
||||||
|
if (!code) return { pass: false, reason: 'No countdown component found' };
|
||||||
|
|
||||||
|
const src = code.code;
|
||||||
|
const hasState = /useState|useReducer|this\.state/.test(src);
|
||||||
|
const hasEffect = /useEffect|componentDidMount|setInterval|setTimeout/.test(src);
|
||||||
|
const hasDecrement = /- 1|-= 1|prev - 1|count - 1|seconds - 1|time - 1/.test(src);
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
if (!hasState) failures.push('no state management (useState/useReducer)');
|
||||||
|
if (!hasEffect) failures.push('no timer setup (useEffect/setInterval/setTimeout)');
|
||||||
|
if (!hasDecrement) failures.push('no countdown decrement logic');
|
||||||
|
|
||||||
|
if (failures.length === 0) return { pass: true, reason: 'Countdown has required structure' };
|
||||||
|
return { pass: false, reason: 'Missing: ' + failures.join(', ') };
|
||||||
|
},
|
||||||
|
|
||||||
|
ratelimit(blocks) {
|
||||||
|
const code = blocks.find((b) => b.lang === 'python' || b.lang === 'py' || (!b.lang && (b.code.includes('rate') || b.code.includes('limit'))));
|
||||||
|
if (!code) return { pass: false, reason: 'No Python code block found' };
|
||||||
|
|
||||||
|
// Structural check for rate limiting: must have some form of counter/time tracking.
|
||||||
|
const src = code.code;
|
||||||
|
const hasTimeTracking = /time\.|datetime|asyncio/.test(src);
|
||||||
|
const hasLimitLogic = /limit|max_requests|rate|429|Too Many|HTTPException|RateLimiter/.test(src);
|
||||||
|
const hasFastAPI = /fastapi|FastAPI|app\s*=|@app\./.test(src);
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
if (!hasLimitLogic) failures.push('no rate limit logic');
|
||||||
|
if (!hasFastAPI) failures.push('no FastAPI usage');
|
||||||
|
|
||||||
|
if (failures.length === 0) return { pass: true, reason: 'Rate limiter has required structure' };
|
||||||
|
return { pass: false, reason: 'Missing: ' + failures.join(', ') };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Main assertion entry point ---
|
||||||
|
|
||||||
|
module.exports = (output, context) => {
|
||||||
|
const task = identifyTask(context.vars.task || '');
|
||||||
|
if (!task) {
|
||||||
|
return { pass: true, score: 1, reason: 'Unknown task, skipped correctness check' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = extractBlocks(String(output || ''));
|
||||||
|
if (blocks.length === 0) {
|
||||||
|
return { pass: false, score: 0, reason: 'No code blocks in output' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = CHECKS[task];
|
||||||
|
const result = check(blocks);
|
||||||
|
return {
|
||||||
|
pass: result.pass,
|
||||||
|
score: result.pass ? 1 : 0,
|
||||||
|
reason: result.reason,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Deterministic code-size metric: non-blank, non-comment lines inside fenced code blocks.
|
||||||
|
// Recorded as the `code_loc` metric per arm (always passes; it is a measurement, not a gate).
|
||||||
|
module.exports = (output) => {
|
||||||
|
const text = String(output || '');
|
||||||
|
const blocks = [...text.matchAll(/```[a-zA-Z0-9_+-]*\n([\s\S]*?)```/g)].map((m) => m[1]);
|
||||||
|
const code = blocks.join('\n');
|
||||||
|
const loc = code
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l && !l.startsWith('//') && !l.startsWith('#') && l !== '*/' && !l.startsWith('/*') && !l.startsWith('*')).length;
|
||||||
|
return { pass: true, score: loc, reason: loc + ' code LOC' };
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Ponytail benchmark: code size + cost across three arms, same model, same tasks.
|
||||||
|
#
|
||||||
|
# Run: npx promptfoo@latest eval -c benchmarks/promptfooconfig.yaml
|
||||||
|
# View: npx promptfoo@latest view
|
||||||
|
# Share: npx promptfoo@latest share (publishes a hosted report URL)
|
||||||
|
#
|
||||||
|
# Needs ANTHROPIC_API_KEY in the environment or a .env file (see benchmarks/README.md).
|
||||||
|
# Caveman arm uses JuliusBrussee/caveman SKILL.md (MIT), vendored at arms/caveman-SKILL.md.
|
||||||
|
description: "Ponytail vs caveman vs no-skill: same model, same tasks. Measures code LOC (deterministic) and tokens/cost (API telemetry)."
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- id: anthropic:messages:claude-haiku-4-5-20251001
|
||||||
|
config: { max_tokens: 8192, temperature: 1 }
|
||||||
|
- id: anthropic:messages:claude-sonnet-4-6
|
||||||
|
config: { max_tokens: 8192, temperature: 1 }
|
||||||
|
- id: anthropic:messages:claude-opus-4-8
|
||||||
|
config: { max_tokens: 8192, temperature: 1 }
|
||||||
|
|
||||||
|
prompts:
|
||||||
|
- id: file://arms/baseline.js
|
||||||
|
label: baseline (no skill)
|
||||||
|
- id: file://arms/caveman.js
|
||||||
|
label: caveman
|
||||||
|
- id: file://arms/ponytail.js
|
||||||
|
label: ponytail
|
||||||
|
|
||||||
|
defaultTest:
|
||||||
|
assert:
|
||||||
|
- type: javascript
|
||||||
|
value: file://loc.js
|
||||||
|
metric: code_loc
|
||||||
|
- type: javascript
|
||||||
|
value: file://correctness.js
|
||||||
|
metric: correct
|
||||||
|
|
||||||
|
tests:
|
||||||
|
- vars: { task: "Write me a Python function that validates email addresses." }
|
||||||
|
- vars: { task: "Add debounce to a search input in vanilla JavaScript. It currently fires an API call on every keystroke." }
|
||||||
|
- vars: { task: "Write Python code that reads sales.csv and sums the 'amount' column." }
|
||||||
|
- vars: { task: "Build me a countdown timer component in React that counts down from a given number of seconds." }
|
||||||
|
- vars: { task: "Add rate limiting to my FastAPI endpoint so users can't spam it." }
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Ponytail v4 hardening — A–F benchmark vs Caveman (2026-06-12)
|
||||||
|
|
||||||
|
Response to the hardening brief in `C:\dev\ponytail-bench\PONYTAIL-BENCHMARK-WRITEUP.md`.
|
||||||
|
Harness reused as-is: same 6 tasks (specs reconstructed in `ponytail-bench\specs.md` —
|
||||||
|
the originals were not preserved; both new arms got identical text), same scorer
|
||||||
|
(`score.py`, arms now auto-discovered), same adversarial probes (`probe_e.py`,
|
||||||
|
`probe_f.py`), same extension protocol (phase-1 git commit, cost = `git diff
|
||||||
|
--numstat` insertions + new-file LOC). Caveman = `JuliusBrussee/caveman` SKILL.md
|
||||||
|
verbatim (full level), saved at `ponytail-bench\caveman-SKILL.md`. One fresh
|
||||||
|
subagent per task × arm, same model for all 16 runs. Caveat: this model/harness
|
||||||
|
differs from the original Cursor runs, so comparisons to the old treatment
|
||||||
|
numbers are directional; the ponytail4-vs-caveman head-to-head is same-model.
|
||||||
|
|
||||||
|
## v4 changes (the hardening, ~10 lines of prompt total)
|
||||||
|
|
||||||
|
1. **Test reflex** (brief 5.1): non-trivial logic leaves ONE runnable check —
|
||||||
|
assert-based `demo()`/`__main__` self-check or one small `test_*.py`. No
|
||||||
|
frameworks. One-liners need no test.
|
||||||
|
2. **Ceiling comments** (5.2): a `ponytail:` shortcut with a known ceiling must
|
||||||
|
name the ceiling and the upgrade path in the comment.
|
||||||
|
3. **Robust variant rule** (5.3): between two same-size stdlib options, take the
|
||||||
|
edge-case-correct one.
|
||||||
|
|
||||||
|
Applied to SKILL.md, all five cross-agent rule copies, the hook fallback, and a
|
||||||
|
guard line in ponytail-review (never flag the minimal check as bloat).
|
||||||
|
|
||||||
|
## Build phase — non-blank LOC / .py files (scorer-verified)
|
||||||
|
|
||||||
|
| Task | Control (orig) | Treatment v3 (orig) | **Ponytail v4** | **Caveman** |
|
||||||
|
|---|--:|--:|--:|--:|
|
||||||
|
| A log CLI | 970 / 13 | 150 / 1 | **145 / 2** | 283 / 1 |
|
||||||
|
| B file sync | 587 / 9 | 175 / 2 | **99 / 1** | 228 / 2 |
|
||||||
|
| C dispatcher | 726 / 13 | 85 / 1 | **73 / 1** | 396 / 10 |
|
||||||
|
| D validation | 343 / 8 | 93 / 1 | **70 / 1** | 218 / 3 |
|
||||||
|
| E auth | 155 / 1 | 74 / 1 | **49 / 1** | 148 / 1 |
|
||||||
|
| F ledger | 162 / 1 | 86 / 1 | **54 / 1** | 167 / 1 |
|
||||||
|
| **Total** | 2943 | 663 | **490** | 1440 |
|
||||||
|
|
||||||
|
v4 is at or below v3 on every task (−3% to −43%) **despite now shipping a
|
||||||
|
runnable check in all six arms** — the test reflex did not cause bloat creep.
|
||||||
|
v4 is 34% of Caveman's size. Task A is the one place Caveman has fewer .py
|
||||||
|
files (1 vs 2): v4's second file is the 24-line regression check Caveman
|
||||||
|
doesn't ship — deleting it to win file count would sacrifice the safety clause
|
||||||
|
to win on size, which the brief forbids.
|
||||||
|
|
||||||
|
## Extension phase (tasks C, D — surprise requests, git-measured)
|
||||||
|
|
||||||
|
| Metric | C v4 | C caveman | D v4 | D caveman |
|
||||||
|
|---|--:|--:|--:|--:|
|
||||||
|
| Lines changed (insertions + new-file LOC) | **41** | 156 | **55** | 257 |
|
||||||
|
| Files touched | 1 | 7 | 1 | 3 |
|
||||||
|
| Still correct after | yes | yes | yes | yes |
|
||||||
|
|
||||||
|
v4 honored the requested seams (duck-typed registry in C, `@rule` registry in
|
||||||
|
D) and extended 74–79% cheaper. Both arms' extended demos re-run exit 0.
|
||||||
|
|
||||||
|
## Safety — adversarial probes (independently executed)
|
||||||
|
|
||||||
|
| Probe | v4 | caveman |
|
||||||
|
|---|--:|--:|
|
||||||
|
| Security, task E (8 checks) | **8/8** | 8/8 |
|
||||||
|
| Concurrency, task F (6 checks) | **6/6** | 6/6 |
|
||||||
|
|
||||||
|
No regression from the added rules. v4's E chose PBKDF2-HMAC-SHA256 (600k
|
||||||
|
iters) + 16-byte `secrets` salt + `hmac.compare_digest` + `token_urlsafe(32)`;
|
||||||
|
F kept integer cents + a global lock with the ceiling comment naming the
|
||||||
|
per-account-lock upgrade (5.2 working as designed; Caveman built per-account
|
||||||
|
locks at 3× the LOC).
|
||||||
|
|
||||||
|
## Correctness
|
||||||
|
|
||||||
|
19/19 independent re-runs exit 0 (14 build demos/tests + 5 post-extension).
|
||||||
|
|
||||||
|
## Acceptance criteria (brief §5.6)
|
||||||
|
|
||||||
|
1. Probes 100% — **pass** (8/8 + 6/6).
|
||||||
|
2. Every treatment arm ships a runnable check — **pass** (A: `test_loganalyze.py`;
|
||||||
|
B–F: assert-based `__main__` checks; all executed). This was the #1 gap (was 1/4).
|
||||||
|
3. LOC within ~20% of v3 treatment numbers — **pass on intent**: every arm at or
|
||||||
|
below v3 (A −3%, C −14%; B/D/E/F 25–43% *below* — leaner, not bloated).
|
||||||
|
4. Ceiling-bearing `ponytail:` comments name upgrade paths — **pass**, verified
|
||||||
|
per arm: global lock→per-account locks (F), no token TTL→add TTL (E),
|
||||||
|
sequential sends→async/threaded + hardcoded route→routing table (C),
|
||||||
|
special-cased `unique`→DATASET_RULES registry (D), observed-hours stats→
|
||||||
|
impute full range (A), no empty-dir handling→dir pass (B).
|
||||||
|
5. Head-to-head vs Caveman — **pass**: ≥ on every axis, strictly better on three.
|
||||||
|
- Safety: tie at 100% (≥, never regressed to win on size).
|
||||||
|
- Size: LOC strictly better 6/6; files ≤ on 5/6 (A caveat above).
|
||||||
|
- Extension cost: strictly better on both tasks.
|
||||||
|
- Reviewability: strictly better — every v4 simplification is `ponytail:`-marked
|
||||||
|
with its ceiling; Caveman's code marks only spec-allowed simulated transports,
|
||||||
|
and its design trade-offs live in the chat report, invisible to a later reviewer.
|
||||||
|
|
||||||
|
## Addendum: same-model control arm (control2, added same day)
|
||||||
|
|
||||||
|
The control numbers above were inherited from the original Cursor harness,
|
||||||
|
which could not expose token counts. Six fresh `task*-control2` arms were run
|
||||||
|
through this harness (no skill, "build production-normal", same model, same
|
||||||
|
specs), making all three arms same-model. Control2 passes both probes (8/8,
|
||||||
|
6/6) and all 10 demo/test runs exit 0; extensions on C and D re-verified.
|
||||||
|
|
||||||
|
| Whole benchmark (6 builds + C/D extensions) | Control2 | Caveman | Ponytail v4 |
|
||||||
|
|---|--:|--:|--:|
|
||||||
|
| Build LOC | 3,629 | 1,440 | **490** |
|
||||||
|
| Build LOC per task (A-F) | 946/656/808/677/260/282 | 283/228/396/218/148/167 | **145/99/73/70/49/54** |
|
||||||
|
| Extension lines changed (C, D) | 378, 737 | 156, 257 | **41, 55** |
|
||||||
|
| Agent tokens, total | 430,697 | 290,546 | **229,370 (-47% vs control2)** |
|
||||||
|
| Agent wall time, total | 2,749s | 1,596s | **821s (3.3x)** |
|
||||||
|
| Probes | 8/8 + 6/6 | 8/8 + 6/6 | 8/8 + 6/6 |
|
||||||
|
|
||||||
|
Wall times carry parallel-scheduling noise (arms ran concurrently, n=1 per
|
||||||
|
cell); token counts are exact from agent telemetry. The README "Numbers"
|
||||||
|
section now cites this same-model dataset and retires the older 5-task v3
|
||||||
|
figures (still recorded in `2026-06-12-caveman-vs-ponytail.md`).
|
||||||
|
|
||||||
|
## Residual (honest notes)
|
||||||
|
|
||||||
|
- A's spike stats still use observed-hours-only mean+3σ rather than a
|
||||||
|
leave-one-out/imputed baseline (Caveman zero-filled the hour range). The 5.3
|
||||||
|
rule softened but did not eliminate the naive-algorithm tendency; the choice
|
||||||
|
is now at least documented with its upgrade path (5.2). Candidate for a
|
||||||
|
future eval if it bites in practice.
|
||||||
|
- Caveman is a prose-compression skill that explicitly writes code "normal" —
|
||||||
|
it loses on code size by design. The meaningful result is that adding the
|
||||||
|
test reflex did not erode ponytail's size advantage or its 100% probe record.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
description = "Audit the whole repo for over-engineering, what can be deleted"
|
||||||
|
prompt = "Audit the entire repository for over-engineering only, not correctness. Scan the whole tree, not a diff. One line per finding, ranked biggest cut first: <tag> <what to cut>. <replacement>. [path]. Tags: delete (dead code/speculative feature), stdlib (reinvented standard library), native (dependency doing what the platform does), yagni (abstraction with one implementation), shrink (same logic, fewer lines). End with the net lines and dependencies removable. If nothing to cut: 'Lean already. Ship.'"
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
description = "Review changes for over-engineering — what can be deleted"
|
description = "Review changes for over-engineering, what can be deleted"
|
||||||
prompt = "Review the current code changes for over-engineering only — not correctness. One line per finding: L<line>: <tag> <what to cut>. <replacement>. Tags: delete (dead code/speculative feature), stdlib (reinvented standard library), native (dependency doing what the platform does), yagni (abstraction with one implementation), shrink (same logic, fewer lines). End with the net lines removable. If nothing to cut: 'Lean already. Ship.'"
|
prompt = "Review the current code changes for over-engineering only, not correctness. One line per finding: L<line>: <tag> <what to cut>. <replacement>. Tags: delete (dead code/speculative feature), stdlib (reinvented standard library), native (dependency doing what the platform does), yagni (abstraction with one implementation), shrink (same logic, fewer lines). End with the net lines removable. If nothing to cut: 'Lean already. Ship.'"
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
description = "Switch ponytail intensity level (lite/full/ultra/off)"
|
description = "Switch ponytail intensity level (lite/full/ultra/off)"
|
||||||
prompt = "Switch to ponytail {{args}} mode. If no level specified, use full. Lazy senior dev mode — before any code: does it need to exist at all (YAGNI)? Does the standard library do it? A native platform feature? Can it be one line? Build the minimum that works. No unrequested abstractions, no avoidable dependencies, no boilerplate. Mark intentional simplifications with a ponytail: comment."
|
prompt = "Switch to ponytail {{args}} mode. If no level specified, use full. Lazy senior dev mode, before any code: does it need to exist at all (YAGNI)? Does the standard library do it? A native platform feature? Can it be one line? Build the minimum that works. No unrequested abstractions, no avoidable dependencies, no boilerplate. Mark intentional simplifications with a ponytail: comment."
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 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. |
|
||||||
|
| OpenCode | `.opencode/plugins/ponytail.mjs`, `.opencode/command/`, `hooks/`, `skills/` | Server plugin injects the ruleset each turn via `experimental.chat.system.transform` and persists `/ponytail` switches; reuses the shared instruction builder. |
|
||||||
|
| Gemini CLI | `gemini-extension.json`, `AGENTS.md`, `commands/`, `skills/` | Extension manifest points `contextFileName` at `AGENTS.md` for always-on rules, and reuses the existing `commands/*.toml` (`/ponytail`, `/ponytail-review`) and `skills/`, which Gemini CLI auto-discovers. |
|
||||||
|
| 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. |
|
||||||
|
| GitHub Copilot CLI | `AGENTS.md`, `.github/copilot-instructions.md`, `~/.copilot/copilot-instructions.md` | Reads custom instructions: per-project from `AGENTS.md` or `.github/copilot-instructions.md`, or globally from `~/.copilot/copilot-instructions.md`. Instruction-tier (no `/ponytail` levels or hooks). |
|
||||||
|
| Antigravity | `AGENTS.md` | Reads `AGENTS.md` at the repo root as always-on rules (like `.cursorrules`/`CLAUDE.md`); `.agents/rules/` also works for workspace rules. Instruction-tier. |
|
||||||
|
| VS Code + Codex extension | `AGENTS.md` | The Codex extension reads `AGENTS.md` (repo root, or `~/.codex/AGENTS.md` globally). Instruction-tier; the full Codex plugin row above adds `/ponytail` levels and hooks. |
|
||||||
|
| 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-audit/SKILL.md`: whole-repo over-engineering audit
|
||||||
|
- `skills/ponytail-help/SKILL.md`: quick reference
|
||||||
|
- `AGENTS.md`: compact always-on instruction set for agents without skill support
|
||||||
@@ -46,13 +46,18 @@ def get_user(user_id: int, service: UserService = Depends(get_user_service)):
|
|||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
```
|
```
|
||||||
|
|
||||||
Five files, three classes, a custom exception, and a dependency-injection chain — wrapping one database call.
|
Five files, three classes, a custom exception, and a dependency-injection chain, wrapping one database call.
|
||||||
|
|
||||||
## With Ponytail
|
## With Ponytail
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ponytail: it's one query
|
# ponytail: drop the layers; keep the response schema, it whitelists what leaves the API
|
||||||
@app.get("/users/{user_id}")
|
class UserOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
|
||||||
|
@app.get("/users/{user_id}", response_model=UserOut)
|
||||||
def get_user(user_id: int, db: Session = Depends(get_db)):
|
def get_user(user_id: int, db: Session = Depends(get_db)):
|
||||||
user = db.get(User, user_id)
|
user = db.get(User, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
@@ -60,4 +65,4 @@ def get_user(user_id: int, db: Session = Depends(get_db)):
|
|||||||
return user
|
return user
|
||||||
```
|
```
|
||||||
|
|
||||||
**5 files → 5 lines.** Layers earn their place when there are two implementations, not before. Add the service layer when a second caller shows up — if it ever does.
|
**5 files → 9 lines.** The repository, service, and custom exception were ceremony. The response schema was not: it whitelists which fields leave the API, so it stays. Returning the raw ORM model (`return user`) would leak every column, including the ones you never meant to expose. That is the line ponytail draws, and it is the same one the skill draws in "when NOT to be lazy": cut the layers, keep the trust boundary. Add a service layer when a second caller shows up, if it ever does.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function DatePicker({ value, onChange, minDate, maxDate }) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
One dependency, one wrapper component, two `useEffect` hooks, a cleanup function, and a CSS import — to pick a date.
|
One dependency, one wrapper component, two `useEffect` hooks, a cleanup function, and a CSS import, to pick a date.
|
||||||
|
|
||||||
## With Ponytail
|
## With Ponytail
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ A class, a wrapper, a regex that still rejects valid addresses and accepts inval
|
|||||||
## With Ponytail
|
## With Ponytail
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ponytail: good enough — real validation is sending the mail
|
# ponytail: good enough, real validation is sending the mail
|
||||||
"@" in email and "." in email.split("@")[-1]
|
"@" in email and "." in email.split("@")[-1]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "ponytail",
|
||||||
|
"version": "4.3.0",
|
||||||
|
"description": "Lazy senior dev mode. Forces the simplest, shortest solution that actually works: YAGNI, stdlib first, no unrequested abstractions.",
|
||||||
|
"contextFileName": "AGENTS.md"
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [
|
||||||
|
{
|
||||||
|
"matcher": "startup|resume|clear|compact",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-activate.js\"",
|
||||||
|
"commandWindows": "node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-activate.js\"",
|
||||||
|
"timeout": 5,
|
||||||
|
"statusMessage": "Loading ponytail mode..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"UserPromptSubmit": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/ponytail-mode-tracker.js\"",
|
||||||
|
"commandWindows": "node \"$env:CLAUDE_PLUGIN_ROOT\\hooks\\ponytail-mode-tracker.js\"",
|
||||||
|
"timeout": 5,
|
||||||
|
"statusMessage": "Tracking ponytail mode..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-97
@@ -8,121 +8,39 @@
|
|||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const { getDefaultMode, getClaudeDir } = 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 = getClaudeDir();
|
||||||
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. ' +
|
|
||||||
'Mark intentional simplifications with a `ponytail:` 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.\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'));
|
||||||
@@ -151,4 +69,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) {
|
||||||
@@ -32,6 +50,11 @@ function getConfigPath() {
|
|||||||
return path.join(getConfigDir(), 'config.json');
|
return path.join(getConfigDir(), 'config.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getClaudeDir() {
|
||||||
|
// ponytail: CLAUDE_CONFIG_DIR overrides ~/.claude, matching Claude Code.
|
||||||
|
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
||||||
|
}
|
||||||
|
|
||||||
function getDefaultMode() {
|
function getDefaultMode() {
|
||||||
// 1. Environment variable (highest priority)
|
// 1. Environment variable (highest priority)
|
||||||
const envMode = process.env.PONYTAIL_DEFAULT_MODE;
|
const envMode = process.env.PONYTAIL_DEFAULT_MODE;
|
||||||
@@ -51,7 +74,29 @@ 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,
|
||||||
|
getClaudeDir,
|
||||||
|
normalizeMode,
|
||||||
|
normalizeConfigMode,
|
||||||
|
normalizePersistedMode,
|
||||||
|
writeDefaultMode,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#!/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*/, '');
|
||||||
|
|
||||||
|
// Only the intensity table rows and worked examples are mode-specific, and
|
||||||
|
// both are keyed by a mode name (lite/full/ultra). A bullet whose label is
|
||||||
|
// not a mode — e.g. "No unrequested abstractions: ..." — is a normal rule
|
||||||
|
// and must be kept verbatim.
|
||||||
|
return withoutFrontmatter
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((line) => {
|
||||||
|
const tableLabel = line.match(/^\|\s*\*\*(.+?)\*\*\s*\|/);
|
||||||
|
if (tableLabel) {
|
||||||
|
const labelMode = normalizeMode(tableLabel[1].trim());
|
||||||
|
if (labelMode) return labelMode === effectiveMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exampleLabel = line.match(/^-\s*([^:]+):\s*/);
|
||||||
|
if (exampleLabel) {
|
||||||
|
const labelMode = normalizeMode(exampleLabel[1].trim());
|
||||||
|
if (labelMode) return labelMode === 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 { getClaudeDir } = require('./ponytail-config');
|
||||||
|
|
||||||
|
const isCodex = Boolean(process.env.PLUGIN_DATA);
|
||||||
|
const statePath = isCodex
|
||||||
|
? path.join(process.env.PLUGIN_DATA, '.ponytail-active')
|
||||||
|
: path.join(getClaudeDir(), '.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,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"plugin": ["./.opencode/plugins/ponytail.mjs"]
|
||||||
|
}
|
||||||
@@ -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,146 @@
|
|||||||
|
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-audit", {
|
||||||
|
description: "Run /skill:ponytail-audit",
|
||||||
|
handler: (_args, ctx) => sendAlias("/skill:ponytail-audit", "", 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,121 @@
|
|||||||
|
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-audit", "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-audit").handler("", ctx);
|
||||||
|
await commands.get("ponytail-help").handler("", ctx);
|
||||||
|
|
||||||
|
assert.deepEqual(sentUserMessages.map((entry) => entry.text), [
|
||||||
|
"/skill:ponytail-review",
|
||||||
|
"/skill:ponytail-audit",
|
||||||
|
"/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,85 @@
|
|||||||
|
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"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filterSkillBodyForMode keeps rule bullets that contain a colon", () => {
|
||||||
|
// Regression: rule bullets outside the Intensity section (e.g. the
|
||||||
|
// "No unrequested abstractions:" rule or the `ponytail:` comment convention)
|
||||||
|
// contain a colon and must not be mistaken for mode-example lines.
|
||||||
|
const skillPath = join(import.meta.dirname, "..", "..", "skills", "ponytail", "SKILL.md");
|
||||||
|
const body = readFileSync(skillPath, "utf8");
|
||||||
|
|
||||||
|
const filtered = filterSkillBodyForMode(body, "full");
|
||||||
|
|
||||||
|
assert.ok(filtered.includes("No unrequested abstractions"));
|
||||||
|
assert.ok(filtered.includes("Mark deliberate simplifications"));
|
||||||
|
// The Intensity examples are still filtered down to the active mode.
|
||||||
|
assert.ok(filtered.includes('full: "`@lru_cache'));
|
||||||
|
assert.ok(!filtered.includes('lite: "Done'));
|
||||||
|
assert.ok(!filtered.includes('ultra: "No cache'));
|
||||||
|
});
|
||||||
@@ -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.`);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: ponytail-audit
|
||||||
|
description: >
|
||||||
|
Whole-repo audit for over-engineering. Like ponytail-review, but scans the
|
||||||
|
entire codebase instead of a diff: a ranked list of what to delete, simplify,
|
||||||
|
or replace with stdlib/native equivalents. Use when the user says "audit this
|
||||||
|
codebase", "audit for over-engineering", "what can I delete from this repo",
|
||||||
|
"find bloat", "ponytail-audit", or "/ponytail-audit". One-shot report, does
|
||||||
|
not apply fixes.
|
||||||
|
---
|
||||||
|
|
||||||
|
ponytail-review, repo-wide. Scan the whole tree instead of a diff. Rank
|
||||||
|
findings biggest cut first.
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
Same as ponytail-review:
|
||||||
|
|
||||||
|
- `delete:` dead code, unused flexibility, speculative feature. Replacement: nothing.
|
||||||
|
- `stdlib:` hand-rolled thing the standard library ships. Name the function.
|
||||||
|
- `native:` dependency or code doing what the platform already does. Name the feature.
|
||||||
|
- `yagni:` abstraction with one implementation, config nobody sets, layer with one caller.
|
||||||
|
- `shrink:` same logic, fewer lines. Show the shorter form.
|
||||||
|
|
||||||
|
## Hunt
|
||||||
|
|
||||||
|
Deps the stdlib or platform already ships, single-implementation interfaces,
|
||||||
|
factories with one product, wrappers that only delegate, files exporting one
|
||||||
|
thing, dead flags and config, hand-rolled stdlib.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
One line per finding, ranked: `<tag> <what to cut>. <replacement>. [path]`.
|
||||||
|
End with `net: -<N> lines, -<M> deps possible.` Nothing to cut: `Lean already. Ship.`
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
Complexity only, correctness bugs, security holes, and performance go to a
|
||||||
|
normal review pass. Lists findings, applies nothing. One-shot.
|
||||||
|
"stop ponytail-audit" or "normal mode" to revert.
|
||||||
@@ -8,7 +8,7 @@ description: >
|
|||||||
|
|
||||||
# Ponytail Help
|
# Ponytail Help
|
||||||
|
|
||||||
Display this reference card when invoked. One-shot — do NOT change mode,
|
Display this reference card when invoked. One-shot, do NOT change mode,
|
||||||
write flag files, or persist anything.
|
write flag files, or persist anything.
|
||||||
|
|
||||||
## Levels
|
## Levels
|
||||||
@@ -29,6 +29,10 @@ 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
|
||||||
|
and OpenCode use the slash-command forms above (OpenCode ships `/ponytail` and
|
||||||
|
`/ponytail-review`).
|
||||||
|
|
||||||
## Deactivate
|
## Deactivate
|
||||||
|
|
||||||
Say "stop ponytail" or "normal mode". Resume anytime with `/ponytail`.
|
Say "stop ponytail" or "normal mode". Resume anytime with `/ponytail`.
|
||||||
@@ -48,11 +52,17 @@ export PONYTAIL_DEFAULT_MODE=ultra
|
|||||||
{ "defaultMode": "lite" }
|
{ "defaultMode": "lite" }
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `"off"` to disable auto-activation on session start — activate manually
|
Set `"off"` to disable auto-activation on session start, activate manually
|
||||||
with `/ponytail` when wanted.
|
with `/ponytail` when wanted.
|
||||||
|
|
||||||
Resolution: env var > config file > `full`.
|
Resolution: env var > config file > `full`.
|
||||||
|
|
||||||
|
## Update
|
||||||
|
|
||||||
|
Enable auto-update once: open `/plugin`, go to Marketplaces, pick ponytail, Enable auto-update. Claude Code then pulls new versions at startup (run `/reload-plugins` when it prompts). Manual refresh: `/plugin marketplace update ponytail` then `/reload-plugins`.
|
||||||
|
|
||||||
|
If `/plugin` is not recognized, your Claude Code is out of date. Update it (`npm install -g @anthropic-ai/claude-code@latest`, or `brew upgrade claude-code`) and restart. Other hosts use their own update flow.
|
||||||
|
|
||||||
## More
|
## More
|
||||||
|
|
||||||
Full docs + examples: https://github.com/DietrichGebert/ponytail
|
Full docs + examples: https://github.com/DietrichGebert/ponytail
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: >
|
|||||||
dead flexibility. One line per finding: location, what to cut, what replaces
|
dead flexibility. One line per finding: location, what to cut, what replaces
|
||||||
it. Use when the user says "review for over-engineering", "what can we
|
it. Use when the user says "review for over-engineering", "what can we
|
||||||
delete", "is this over-engineered", "simplify review", or invokes
|
delete", "is this over-engineered", "simplify review", or invokes
|
||||||
/ponytail-review. Complements correctness-focused review — this one only
|
/ponytail-review. Complements correctness-focused review, this one only
|
||||||
hunts complexity.
|
hunts complexity.
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -15,23 +15,23 @@ to cut, what replaces it. The diff's best outcome is getting shorter.
|
|||||||
|
|
||||||
## Format
|
## Format
|
||||||
|
|
||||||
`L<line>: <tag> <what>. <replacement>.` — or `<file>:L<line>: ...` for
|
`L<line>: <tag> <what>. <replacement>.`, or `<file>:L<line>: ...` for
|
||||||
multi-file diffs.
|
multi-file diffs.
|
||||||
|
|
||||||
Tags:
|
Tags:
|
||||||
|
|
||||||
- `delete:` — dead code, unused flexibility, speculative feature. Replacement: nothing.
|
- `delete:` dead code, unused flexibility, speculative feature. Replacement: nothing.
|
||||||
- `stdlib:` — hand-rolled thing the standard library ships. Name the function.
|
- `stdlib:` hand-rolled thing the standard library ships. Name the function.
|
||||||
- `native:` — dependency or code doing what the platform already does. Name the feature.
|
- `native:` dependency or code doing what the platform already does. Name the feature.
|
||||||
- `yagni:` — abstraction with one implementation, config nobody sets, layer with one caller.
|
- `yagni:` abstraction with one implementation, config nobody sets, layer with one caller.
|
||||||
- `shrink:` — same logic, fewer lines. Show the shorter form.
|
- `shrink:` same logic, fewer lines. Show the shorter form.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
❌ "This EmailValidator class might be more complex than necessary, have you
|
❌ "This EmailValidator class might be more complex than necessary, have you
|
||||||
considered whether all these validation rules are needed at this stage?"
|
considered whether all these validation rules are needed at this stage?"
|
||||||
|
|
||||||
✅ `L12-38: stdlib: 27-line validator class. "@" in email, 1 line — real validation is the confirmation mail.`
|
✅ `L12-38: stdlib: 27-line validator class. "@" in email, 1 line, real validation is the confirmation mail.`
|
||||||
|
|
||||||
✅ `L4: native: moment.js imported for one format call. Intl.DateTimeFormat, 0 deps.`
|
✅ `L4: native: moment.js imported for one format call. Intl.DateTimeFormat, 0 deps.`
|
||||||
|
|
||||||
@@ -49,6 +49,8 @@ If there is nothing to cut, say `Lean already. Ship.` and stop.
|
|||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
Complexity only — correctness bugs, security holes, and performance go to a
|
Complexity only, correctness bugs, security holes, and performance go to a
|
||||||
normal review pass, not this one. Does not apply the fixes, only lists them.
|
normal review pass, not this one. A single smoke test or `assert`-based
|
||||||
|
self-check is the ponytail minimum, not bloat, never flag it for deletion.
|
||||||
|
Does not apply the fixes, only lists them.
|
||||||
"stop ponytail-review" or "normal mode": revert to verbose review style.
|
"stop ponytail-review" or "normal mode": revert to verbose review style.
|
||||||
|
|||||||
+18
-11
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: ponytail
|
name: ponytail
|
||||||
description: >
|
description: >
|
||||||
Forces the laziest solution that actually works — simplest, shortest, most
|
Forces the laziest solution that actually works, simplest, shortest, most
|
||||||
minimal. Channels a senior dev who has seen everything: question whether the
|
minimal. Channels a senior dev who has seen everything: question whether the
|
||||||
task needs to exist at all (YAGNI), reach for the standard library before
|
task needs to exist at all (YAGNI), reach for the standard library before
|
||||||
custom code, native platform features before dependencies, one line before
|
custom code, native platform features before dependencies, one line before
|
||||||
fifty. Supports intensity levels: lite, full (default), ultra. Use whenever
|
fifty. Supports intensity levels: lite, full (default), ultra. Use whenever
|
||||||
the user says "ponytail", "be lazy", "lazy mode", "simplest solution",
|
the user says "ponytail", "be lazy", "lazy mode", "simplest solution",
|
||||||
"minimal solution", "yagni", "do less", or "shortest path" — and whenever
|
"minimal solution", "yagni", "do less", or "shortest path", and whenever
|
||||||
they complain about over-engineering, bloat, boilerplate, or unnecessary
|
they complain about over-engineering, bloat, boilerplate, or unnecessary
|
||||||
dependencies.
|
dependencies.
|
||||||
license: MIT
|
license: MIT
|
||||||
@@ -42,20 +42,21 @@ higher one and move on. The first lazy solution that works is the right one.
|
|||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes.
|
- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes.
|
||||||
- No boilerplate, no scaffolding "for later" — later can scaffold for itself.
|
- No boilerplate, no scaffolding "for later", later can scaffold for itself.
|
||||||
- Deletion over addition. Boring over clever — clever is what someone decodes at 3am.
|
- Deletion over addition. Boring over clever, clever is what someone decodes at 3am.
|
||||||
- Fewest files possible. Shortest working diff wins.
|
- Fewest files possible. Shortest working diff wins.
|
||||||
- Complex request? Ship the lazy version and question it in the same response — "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default.
|
- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default.
|
||||||
- Mark deliberate simplifications with a `ponytail:` comment (`// ponytail: this exists`) — simple reads as intent, not ignorance.
|
- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm.
|
||||||
|
- Mark deliberate simplifications with a `ponytail:` comment (`// ponytail: this exists`), simple reads as intent, not ignorance. Shortcut with a known ceiling (global lock, O(n²) scan, naive heuristic)? The comment names the ceiling and the upgrade path: `# ponytail: global lock, per-account locks if throughput matters`.
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
Code first. Then at most three short lines: what was skipped, when to add it.
|
Code first. Then at most three short lines: what was skipped, when to add it.
|
||||||
No essays, no feature tours, no design notes. If the explanation is longer
|
No essays, no feature tours, no design notes. If the explanation is longer
|
||||||
than the code, delete the explanation — every paragraph defending a
|
than the code, delete the explanation, every paragraph defending a
|
||||||
simplification is complexity smuggled back in as prose.
|
simplification is complexity smuggled back in as prose.
|
||||||
|
|
||||||
Pattern: `[code] → skipped: [X] — add when [Y].`
|
Pattern: `[code] → skipped: [X], add when [Y].`
|
||||||
|
|
||||||
## Intensity
|
## Intensity
|
||||||
|
|
||||||
@@ -65,9 +66,9 @@ Pattern: `[code] → skipped: [X] — add when [Y].`
|
|||||||
| **full** | The ladder enforced. Stdlib and native first. Shortest diff, shortest explanation. Default. |
|
| **full** | The ladder enforced. Stdlib and native first. Shortest diff, shortest explanation. Default. |
|
||||||
| **ultra** | YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same breath. |
|
| **ultra** | YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same breath. |
|
||||||
|
|
||||||
Example — "Add a cache for these API responses."
|
Example: "Add a cache for these API responses."
|
||||||
- lite: "Done — cache added. FYI: `functools.lru_cache` covers this in one line if you'd rather not own a cache class."
|
- lite: "Done, cache added. FYI: `functools.lru_cache` covers this in one line if you'd rather not own a cache class."
|
||||||
- full: "`@lru_cache(maxsize=1000)` on the fetch function. Skipped custom cache class — add when lru_cache measurably falls short."
|
- full: "`@lru_cache(maxsize=1000)` on the fetch function. Skipped custom cache class, add when lru_cache measurably falls short."
|
||||||
- ultra: "No cache until a profiler says so. When it does: `@lru_cache`. A hand-rolled TTL cache class is a bug farm with a hit rate."
|
- ultra: "No cache until a profiler says so. When it does: `@lru_cache`. A hand-rolled TTL cache class is a bug farm with a hit rate."
|
||||||
|
|
||||||
## When NOT to be lazy
|
## When NOT to be lazy
|
||||||
@@ -77,6 +78,12 @@ that prevents data loss, security measures, accessibility basics, anything
|
|||||||
explicitly requested. User insists on the full version → build it, no
|
explicitly requested. User insists on the full version → build it, no
|
||||||
re-arguing.
|
re-arguing.
|
||||||
|
|
||||||
|
Non-trivial logic (a branch, a loop, a parser, a money/security path) leaves
|
||||||
|
ONE runnable check behind, the smallest thing that fails if the logic
|
||||||
|
breaks: an `assert`-based `demo()`/`__main__` self-check or one small
|
||||||
|
`test_*.py`. No frameworks, no fixtures, no per-function suites unless
|
||||||
|
asked. Trivial one-liners need no test, YAGNI applies to tests too.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
Ponytail governs what you build, not how you talk (pair with Caveman for
|
Ponytail governs what you build, not how you talk (pair with Caveman for
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Unit test for the correctness benchmark assertion. Feeds known-good and
|
||||||
|
// known-bad LLM outputs through each task checker and asserts the expected
|
||||||
|
// pass/fail verdict. Runs without promptfoo — just node:test + the module.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const correctness = require('../benchmarks/correctness');
|
||||||
|
|
||||||
|
// Helper: wrap code in a fenced block and call the assertion with task vars.
|
||||||
|
function check(task, lang, code) {
|
||||||
|
const output = '```' + lang + '\n' + code + '\n```';
|
||||||
|
return correctness(output, { vars: { task } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Email validator ---
|
||||||
|
|
||||||
|
test('email: correct one-liner passes', () => {
|
||||||
|
const result = check(
|
||||||
|
'Write me a Python function that validates email addresses.',
|
||||||
|
'python',
|
||||||
|
'def validate_email(email):\n return "@" in email and "." in email.split("@")[-1] and email.split("@")[0] != ""',
|
||||||
|
);
|
||||||
|
assert.equal(result.pass, true);
|
||||||
|
assert.equal(result.score, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('email: always-true validator fails', () => {
|
||||||
|
const result = check(
|
||||||
|
'Write me a Python function that validates email addresses.',
|
||||||
|
'python',
|
||||||
|
'def validate_email(email):\n return True',
|
||||||
|
);
|
||||||
|
assert.equal(result.pass, false);
|
||||||
|
assert.equal(result.score, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('email: no code block fails', () => {
|
||||||
|
const result = correctness('Here is my answer: just use regex.', {
|
||||||
|
vars: { task: 'Write me a Python function that validates email addresses.' },
|
||||||
|
});
|
||||||
|
assert.equal(result.pass, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Debounce ---
|
||||||
|
|
||||||
|
test('debounce: correct implementation passes', () => {
|
||||||
|
const result = check(
|
||||||
|
'Add debounce to a search input in vanilla JavaScript.',
|
||||||
|
'javascript',
|
||||||
|
`function debounce(fn, delay) {
|
||||||
|
let timer;
|
||||||
|
return function(...args) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => fn.apply(this, args), delay);
|
||||||
|
};
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
assert.equal(result.pass, true);
|
||||||
|
assert.equal(result.score, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('debounce: immediate-call implementation fails', () => {
|
||||||
|
const result = check(
|
||||||
|
'Add debounce to a search input in vanilla JavaScript.',
|
||||||
|
'javascript',
|
||||||
|
`function debounce(fn, delay) {
|
||||||
|
return function(...args) { fn.apply(this, args); };
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
assert.equal(result.pass, false);
|
||||||
|
assert.equal(result.score, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- CSV sum ---
|
||||||
|
|
||||||
|
test('csv: correct pandas one-liner passes', () => {
|
||||||
|
const result = check(
|
||||||
|
"Write Python code that reads sales.csv and sums the 'amount' column.",
|
||||||
|
'python',
|
||||||
|
`import pandas as pd
|
||||||
|
df = pd.read_csv('sales.csv')
|
||||||
|
print(df['amount'].sum())`,
|
||||||
|
);
|
||||||
|
assert.equal(result.pass, true);
|
||||||
|
assert.equal(result.score, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('csv: code that prints wrong value fails', () => {
|
||||||
|
const result = check(
|
||||||
|
"Write Python code that reads sales.csv and sums the 'amount' column.",
|
||||||
|
'python',
|
||||||
|
`print(999)`,
|
||||||
|
);
|
||||||
|
assert.equal(result.pass, false);
|
||||||
|
assert.equal(result.score, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('csv: value containing 351 as substring fails (e.g. 13510)', () => {
|
||||||
|
const result = check(
|
||||||
|
"Write Python code that reads sales.csv and sums the 'amount' column.",
|
||||||
|
'python',
|
||||||
|
`print(13510)`,
|
||||||
|
);
|
||||||
|
assert.equal(result.pass, false);
|
||||||
|
assert.equal(result.score, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- React countdown ---
|
||||||
|
|
||||||
|
test('countdown: valid React component passes', () => {
|
||||||
|
const result = check(
|
||||||
|
'Build me a countdown timer component in React.',
|
||||||
|
'javascript',
|
||||||
|
`import { useState, useEffect } from 'react';
|
||||||
|
export default function Countdown({ seconds }) {
|
||||||
|
const [count, setCount] = useState(seconds);
|
||||||
|
useEffect(() => {
|
||||||
|
if (count <= 0) return;
|
||||||
|
const id = setInterval(() => setCount(prev => prev - 1), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [count]);
|
||||||
|
return <div>{count}</div>;
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
assert.equal(result.pass, true);
|
||||||
|
assert.equal(result.score, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('countdown: static div without state fails', () => {
|
||||||
|
const result = check(
|
||||||
|
'Build me a countdown timer component in React.',
|
||||||
|
'javascript',
|
||||||
|
`export default function Countdown() { return <div>10</div>; }`,
|
||||||
|
);
|
||||||
|
assert.equal(result.pass, false);
|
||||||
|
assert.equal(result.score, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Rate limiter ---
|
||||||
|
|
||||||
|
test('ratelimit: FastAPI with limit logic passes', () => {
|
||||||
|
const result = check(
|
||||||
|
'Add rate limiting to my FastAPI endpoint so users can\'t spam it.',
|
||||||
|
'python',
|
||||||
|
`from fastapi import FastAPI, HTTPException
|
||||||
|
import time
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
requests = {}
|
||||||
|
|
||||||
|
@app.get("/api")
|
||||||
|
def endpoint(user: str = "anon"):
|
||||||
|
now = time.time()
|
||||||
|
window = requests.get(user, [])
|
||||||
|
window = [t for t in window if now - t < 60]
|
||||||
|
if len(window) >= 10:
|
||||||
|
raise HTTPException(429, "Too Many Requests")
|
||||||
|
window.append(now)
|
||||||
|
requests[user] = window
|
||||||
|
return {"ok": True}`,
|
||||||
|
);
|
||||||
|
assert.equal(result.pass, true);
|
||||||
|
assert.equal(result.score, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ratelimit: plain endpoint without limiting fails', () => {
|
||||||
|
const result = check(
|
||||||
|
'Add rate limiting to my FastAPI endpoint.',
|
||||||
|
'python',
|
||||||
|
`from fastapi import FastAPI
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
@app.get("/api")
|
||||||
|
def endpoint():
|
||||||
|
return {"ok": True}`,
|
||||||
|
);
|
||||||
|
assert.equal(result.pass, false);
|
||||||
|
assert.equal(result.score, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Edge cases ---
|
||||||
|
|
||||||
|
test('unknown task is gracefully skipped', () => {
|
||||||
|
const result = correctness('```python\nprint("hi")\n```', {
|
||||||
|
vars: { task: 'Explain quantum computing.' },
|
||||||
|
});
|
||||||
|
assert.equal(result.pass, true);
|
||||||
|
assert.equal(result.score, 1);
|
||||||
|
assert.match(result.reason, /unknown task/i);
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Smoke test for the Gemini CLI adapter. The adapter is a single thin manifest
|
||||||
|
// (gemini-extension.json) that reuses the repo's existing files: AGENTS.md for
|
||||||
|
// always-on context, commands/*.toml for /ponytail + /ponytail-review, and
|
||||||
|
// skills/ for the agent skills. This test fails if the manifest is removed,
|
||||||
|
// loses its pinned version, or points contextFileName at a file that no longer
|
||||||
|
// carries the load-bearing rules — i.e. if the adapter stops wiring ponytail.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const root = path.join(__dirname, '..');
|
||||||
|
const MANIFEST = 'gemini-extension.json';
|
||||||
|
const EXTENSION_NAME = 'ponytail';
|
||||||
|
// Floating refs are a supply-chain footgun; the manifest version must be pinned.
|
||||||
|
const PINNED_SEMVER = /^\d+\.\d+\.\d+$/;
|
||||||
|
// Gemini auto-discovers these by directory; the manifest is only useful if they exist.
|
||||||
|
const REUSED_COMMANDS = ['commands/ponytail.toml', 'commands/ponytail-review.toml'];
|
||||||
|
const REUSED_SKILLS = ['skills/ponytail/SKILL.md'];
|
||||||
|
// Same load-bearing phrases asserted by scripts/check-rule-copies.js: the file
|
||||||
|
// contextFileName points at must actually carry the rules, not just exist.
|
||||||
|
const RULE_INVARIANTS = [
|
||||||
|
'lazy senior',
|
||||||
|
'input validation at trust boundaries',
|
||||||
|
'naive heuristic',
|
||||||
|
];
|
||||||
|
|
||||||
|
function read(relPath) {
|
||||||
|
return fs.readFileSync(path.join(root, relPath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read inside each test (not at module scope) so a missing or malformed manifest
|
||||||
|
// surfaces as a clean per-test assertion failure, not a load-time crash that
|
||||||
|
// collapses every case into one unreadable stack trace.
|
||||||
|
function loadManifest() {
|
||||||
|
assert.ok(fs.existsSync(path.join(root, MANIFEST)), `${MANIFEST} must exist`);
|
||||||
|
return JSON.parse(read(MANIFEST));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('manifest names the ponytail extension with a pinned version', () => {
|
||||||
|
const manifest = loadManifest();
|
||||||
|
assert.equal(manifest.name, EXTENSION_NAME);
|
||||||
|
assert.match(manifest.version, PINNED_SEMVER);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('version stays aligned with the other plugin manifests', () => {
|
||||||
|
const manifest = loadManifest();
|
||||||
|
const claude = JSON.parse(read('.claude-plugin/plugin.json'));
|
||||||
|
assert.equal(manifest.version, claude.version);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('contextFileName resolves to a file carrying the ponytail rules', () => {
|
||||||
|
const manifest = loadManifest();
|
||||||
|
assert.ok(manifest.contextFileName, 'contextFileName must be set so rules load every session');
|
||||||
|
const context = read(manifest.contextFileName);
|
||||||
|
for (const phrase of RULE_INVARIANTS) {
|
||||||
|
assert.ok(context.includes(phrase), `context file missing rule invariant: "${phrase}"`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the commands and skills the adapter reuses are present', () => {
|
||||||
|
for (const rel of [...REUSED_COMMANDS, ...REUSED_SKILLS]) {
|
||||||
|
assert.ok(fs.existsSync(path.join(root, rel)), `reused file missing: ${rel}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Regression test for issue #19: on Windows the lifecycle hooks run via
|
||||||
|
// PowerShell, which does NOT expand cmd.exe-style %VAR% — it needs $env:VAR.
|
||||||
|
// The hook also has to point at a script that actually ships in hooks/.
|
||||||
|
// This guards both failure modes: the original %CLAUDE_PLUGIN_ROOT% bug, and
|
||||||
|
// the "switch to a .ps1 that doesn't exist" mistake.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const root = path.join(__dirname, '..');
|
||||||
|
const HOOKS_JSON = 'hooks/hooks.json';
|
||||||
|
// cmd.exe variable syntax (%FOO%); PowerShell leaves it literal, breaking the path.
|
||||||
|
const CMD_VAR_SYNTAX = /%[A-Za-z_][A-Za-z0-9_]*%/;
|
||||||
|
// Pull the hooks/<script> a command launches, so we can check it exists.
|
||||||
|
const HOOK_SCRIPT = /hooks[\\/]([\w.-]+\.(?:js|mjs|cjs|ps1|sh))/;
|
||||||
|
|
||||||
|
// Read inside each case so a missing/malformed file fails as a clean assertion,
|
||||||
|
// not a load-time crash.
|
||||||
|
function commandHooks() {
|
||||||
|
const config = JSON.parse(fs.readFileSync(path.join(root, HOOKS_JSON), 'utf8'));
|
||||||
|
return Object.values(config.hooks)
|
||||||
|
.flat()
|
||||||
|
.flatMap((entry) => entry.hooks);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('every commandWindows uses PowerShell $env: syntax, not cmd.exe %VAR%', () => {
|
||||||
|
const windowsCommands = commandHooks()
|
||||||
|
.map((h) => h.commandWindows)
|
||||||
|
.filter(Boolean);
|
||||||
|
assert.ok(windowsCommands.length > 0, 'expected at least one commandWindows entry');
|
||||||
|
for (const cmd of windowsCommands) {
|
||||||
|
assert.doesNotMatch(cmd, CMD_VAR_SYNTAX, `commandWindows uses cmd.exe %VAR% (breaks under PowerShell): ${cmd}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every hook command points at a script that ships in hooks/', () => {
|
||||||
|
for (const hook of commandHooks()) {
|
||||||
|
for (const cmd of [hook.command, hook.commandWindows].filter(Boolean)) {
|
||||||
|
const match = cmd.match(HOOK_SCRIPT);
|
||||||
|
assert.ok(match, `cannot find a hooks/ script in command: ${cmd}`);
|
||||||
|
const script = path.join(root, 'hooks', match[1]);
|
||||||
|
assert.ok(fs.existsSync(script), `command references a missing hook script: ${match[1]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#!/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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the base env clean so the default-dir checks are deterministic; the
|
||||||
|
// CLAUDE_CONFIG_DIR case sets it explicitly.
|
||||||
|
delete process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
|
||||||
|
// CLAUDE_CONFIG_DIR overrides ~/.claude for the flag file (issue #34).
|
||||||
|
const home2 = path.join(temp, 'home2');
|
||||||
|
fs.mkdirSync(home2, { recursive: true });
|
||||||
|
const customConfigDir = path.join(temp, 'custom-claude');
|
||||||
|
result = run('ponytail-activate.js', {
|
||||||
|
HOME: home2,
|
||||||
|
USERPROFILE: home2,
|
||||||
|
CLAUDE_CONFIG_DIR: customConfigDir,
|
||||||
|
PONYTAIL_DEFAULT_MODE: 'lite',
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(path.join(customConfigDir, '.ponytail-active'), 'utf8'),
|
||||||
|
'lite',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.existsSync(path.join(home2, '.claude', '.ponytail-active')),
|
||||||
|
false,
|
||||||
|
'flag must not land in ~/.claude when CLAUDE_CONFIG_DIR is set',
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.rmSync(temp, { recursive: true, force: true });
|
||||||
|
console.log('hook compatibility checks passed');
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Smoke test for the OpenCode adapter: the plugin's hooks behave against the
|
||||||
|
// real (structural) OpenCode hook shapes. No live OpenCode needed.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { pathToFileURL } = require('url');
|
||||||
|
|
||||||
|
// Point the plugin's mode-flag at a temp config home BEFORE it loads — the
|
||||||
|
// plugin resolves its state path once at load (as it does under a real OpenCode
|
||||||
|
// process, where XDG_CONFIG_HOME is already set). The dynamic import below runs
|
||||||
|
// after this assignment, so the ordering holds.
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ponytail-opencode-'));
|
||||||
|
process.env.XDG_CONFIG_HOME = tmp;
|
||||||
|
delete process.env.PONYTAIL_DEFAULT_MODE;
|
||||||
|
const statePath = path.join(tmp, 'opencode', '.ponytail-active');
|
||||||
|
|
||||||
|
let loadPlugin;
|
||||||
|
test.before(async () => {
|
||||||
|
const url = pathToFileURL(path.join(__dirname, '..', '.opencode', 'plugins', 'ponytail.mjs'));
|
||||||
|
loadPlugin = (await import(url)).default;
|
||||||
|
});
|
||||||
|
|
||||||
|
function transform(hooks) {
|
||||||
|
const output = { system: [] };
|
||||||
|
return hooks['experimental.chat.system.transform']({ model: {} }, output).then(() => output.system);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('system.transform injects the ruleset at the default mode (full)', async () => {
|
||||||
|
try { fs.unlinkSync(statePath); } catch (e) {}
|
||||||
|
const hooks = await loadPlugin({});
|
||||||
|
const system = await transform(hooks);
|
||||||
|
assert.equal(system.length, 1);
|
||||||
|
assert.match(system[0], /PONYTAIL MODE ACTIVE — level: full/);
|
||||||
|
assert.match(system[0], /lazy senior developer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('command.execute.before persists /ponytail ultra, transform follows it', async () => {
|
||||||
|
const hooks = await loadPlugin({});
|
||||||
|
await hooks['command.execute.before']({ command: 'ponytail', arguments: 'ultra', sessionID: 's' });
|
||||||
|
assert.equal(fs.readFileSync(statePath, 'utf8'), 'ultra');
|
||||||
|
const system = await transform(hooks);
|
||||||
|
assert.match(system[0], /PONYTAIL MODE ACTIVE — level: ultra/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/ponytail off persists off and transform injects nothing', async () => {
|
||||||
|
const hooks = await loadPlugin({});
|
||||||
|
await hooks['command.execute.before']({ command: 'ponytail', arguments: 'off', sessionID: 's' });
|
||||||
|
assert.equal(fs.readFileSync(statePath, 'utf8'), 'off');
|
||||||
|
const system = await transform(hooks);
|
||||||
|
assert.deepEqual(system, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unrelated commands do not touch the flag', async () => {
|
||||||
|
try { fs.unlinkSync(statePath); } catch (e) {}
|
||||||
|
const hooks = await loadPlugin({});
|
||||||
|
await hooks['command.execute.before']({ command: 'commit', arguments: 'x', sessionID: 's' });
|
||||||
|
assert.equal(fs.existsSync(statePath), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||||
Reference in New Issue
Block a user